Etter dag 1 ny server
This commit is contained in:
parent
6511a3aee2
commit
e2f94dcaaa
19 changed files with 958 additions and 313 deletions
17
.env.example
17
.env.example
|
|
@ -1,17 +0,0 @@
|
|||
GOOGLE_CLIENT_ID=your-google-client-id
|
||||
GOOGLE_CLIENT_SECRET=your-google-client-secret
|
||||
PUBLIC_BASE_URL=https://teeoff.no
|
||||
NEXT_PUBLIC_SITE_URL=https://teeoff.no
|
||||
PUBLIC_SESSION_SECRET=replace-with-a-long-random-secret
|
||||
JWT_SECRET=replace-with-a-separate-long-random-secret
|
||||
PUBLIC_COMMENT_DEFAULT_STATUS=pending
|
||||
SMTP_SERVER=send.one.com
|
||||
SMTP_PORT=465
|
||||
SMTP_USER=comment@example.com
|
||||
SMTP_PASS=replace-with-your-smtp-password
|
||||
PUBLIC_FROM_EMAIL=TeeOff kommentarer <comment@example.com>
|
||||
PUBLIC_MAGIC_LINK_MAX_AGE_MINUTES=20
|
||||
POSTGRES_USER=teeoff_admin
|
||||
POSTGRES_PASSWORD=replace-with-your-postgres-password
|
||||
POSTGRES_DB=teeoff
|
||||
DATABASE_URL=postgresql://teeoff_admin:replace-with-your-postgres-password@db:5432/teeoff
|
||||
138
backend/bootstrap_admin_access.py
Normal file
138
backend/bootstrap_admin_access.py
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
"""
|
||||
TEE OFF ADMIN ACCESS BOOTSTRAP
|
||||
---------------------------------------------------------------------------
|
||||
FUNKSJON: Oppretter eller oppdaterer én administrator uten å påvirke andre.
|
||||
Passord leses skjult fra terminalen, og 2FA kan genereres/roteres.
|
||||
STATUS: Trygg erstatning for create_admin.py når flere admins skal eksistere.
|
||||
---------------------------------------------------------------------------
|
||||
"""
|
||||
import argparse
|
||||
import asyncio
|
||||
import getpass
|
||||
import sys
|
||||
|
||||
import asyncpg
|
||||
import pyotp
|
||||
from passlib.hash import pbkdf2_sha256
|
||||
|
||||
from env_config import get_database_url
|
||||
|
||||
DB_URL = get_database_url()
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Opprett eller oppdater en admin-bruker trygt.")
|
||||
parser.add_argument("--username", required=True, help="Brukernavn for admin-brukeren")
|
||||
parser.add_argument("--email", required=True, help="E-post for admin-brukeren")
|
||||
parser.add_argument(
|
||||
"--rotate-2fa",
|
||||
action="store_true",
|
||||
help="Generer en ny 2FA-hemmelighet selv om brukeren allerede har en",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
async def bootstrap_admin() -> None:
|
||||
args = parse_args()
|
||||
username = args.username.strip()
|
||||
email = args.email.strip().lower()
|
||||
|
||||
if not username:
|
||||
print("❌ Brukernavn kan ikke være tomt.")
|
||||
sys.exit(1)
|
||||
if "@" not in email:
|
||||
print("❌ E-postadressen ser ugyldig ut.")
|
||||
sys.exit(1)
|
||||
|
||||
while True:
|
||||
password = getpass.getpass("Skriv inn passord: ")
|
||||
password_confirm = getpass.getpass("Gjenta passord: ")
|
||||
|
||||
if password != password_confirm:
|
||||
print("❌ Passordene er ikke like. Prøv igjen.\n")
|
||||
continue
|
||||
|
||||
if len(password) < 8:
|
||||
print("⚠️ Advarsel: Passordet bør være minst 8 tegn.")
|
||||
break
|
||||
|
||||
password_hash = pbkdf2_sha256.hash(password)
|
||||
|
||||
conn = None
|
||||
try:
|
||||
conn = await asyncpg.connect(DB_URL)
|
||||
existing = await conn.fetchrow(
|
||||
"""
|
||||
SELECT id, username, email, otp_secret
|
||||
FROM admins
|
||||
WHERE username = $1 OR email = $2
|
||||
ORDER BY id ASC
|
||||
LIMIT 1
|
||||
""",
|
||||
username,
|
||||
email,
|
||||
)
|
||||
|
||||
otp_secret = pyotp.random_base32() if (args.rotate_2fa or not existing or not existing["otp_secret"]) else existing["otp_secret"]
|
||||
|
||||
if existing:
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE admins
|
||||
SET username = $1,
|
||||
email = $2,
|
||||
password_hash = $3,
|
||||
otp_secret = $4
|
||||
WHERE id = $5
|
||||
""",
|
||||
username,
|
||||
email,
|
||||
password_hash,
|
||||
otp_secret,
|
||||
existing["id"],
|
||||
)
|
||||
action = "oppdatert"
|
||||
else:
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO admins (username, email, password_hash, otp_secret)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
""",
|
||||
username,
|
||||
email,
|
||||
password_hash,
|
||||
otp_secret,
|
||||
)
|
||||
action = "opprettet"
|
||||
except asyncpg.UniqueViolationError:
|
||||
print("❌ Brukernavn eller e-post er allerede i bruk av en annen admin.")
|
||||
sys.exit(1)
|
||||
except Exception as exc:
|
||||
print(f"❌ Kunne ikke bootstrappe admin-brukeren: {type(exc).__name__}")
|
||||
sys.exit(1)
|
||||
finally:
|
||||
if conn is not None:
|
||||
await conn.close()
|
||||
|
||||
provisioning_uri = pyotp.TOTP(otp_secret).provisioning_uri(
|
||||
name=email or username,
|
||||
issuer_name="TeeOff.no",
|
||||
)
|
||||
|
||||
print("\n✅ ADMIN BRUKER KLAR")
|
||||
print("-" * 50)
|
||||
print(f"Brukeren '{username}' er {action}.")
|
||||
print("Passordet ble satt skjult i terminalen.")
|
||||
print("Legg inn denne 2FA-hemmeligheten i authenticator-appen:")
|
||||
print(f"2FA-nøkkel: {otp_secret}")
|
||||
print("Alternativt kan du bruke provisioning URI:")
|
||||
print(provisioning_uri)
|
||||
print("-" * 50 + "\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
asyncio.run(bootstrap_admin())
|
||||
except KeyboardInterrupt:
|
||||
print("\nAvbrutt.")
|
||||
sys.exit(0)
|
||||
|
|
@ -80,6 +80,8 @@ def get_int_env(name: str, default: int) -> int:
|
|||
return default
|
||||
|
||||
|
||||
ADMIN_SESSION_MAX_AGE_SECONDS = get_int_env("ADMIN_SESSION_MAX_AGE_SECONDS", 60 * 60 * 12)
|
||||
ADMIN_REMEMBER_ME_MAX_AGE_SECONDS = get_int_env("ADMIN_REMEMBER_ME_MAX_AGE_SECONDS", 60 * 60 * 24 * 30)
|
||||
PUBLIC_MAGIC_LINK_MAX_AGE_MINUTES = get_int_env("PUBLIC_MAGIC_LINK_MAX_AGE_MINUTES", 20)
|
||||
PUBLIC_MAGIC_LINK_REQUEST_COOLDOWN_SECONDS = get_int_env("PUBLIC_MAGIC_LINK_REQUEST_COOLDOWN_SECONDS", 60)
|
||||
|
||||
|
|
@ -479,6 +481,19 @@ async def resolve_cooperating_club_slugs(
|
|||
return resolved_slugs
|
||||
|
||||
|
||||
async def get_table_columns(conn, table_name: str, schema_name: str = "public") -> set[str]:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = $1 AND table_name = $2
|
||||
""",
|
||||
schema_name,
|
||||
table_name,
|
||||
)
|
||||
return {str(row["column_name"]) for row in rows}
|
||||
|
||||
|
||||
def generate_totp_qr_svg(provisioning_uri: str) -> str:
|
||||
image = qrcode.make(
|
||||
provisioning_uri,
|
||||
|
|
@ -1047,6 +1062,8 @@ async def require_admin_session_for_admin_routes(request: Request, call_next):
|
|||
@app.post("/api/auth/login")
|
||||
async def login(data: dict):
|
||||
"""Steg 1: Sjekk passord og returner temp_token for 2FA."""
|
||||
remember_me = bool(data.get("remember_me") or data.get("rememberMe"))
|
||||
|
||||
async with app.state.pool.acquire() as conn:
|
||||
admin = await conn.fetchrow(
|
||||
"SELECT * FROM admins WHERE username = $1 OR email = $1",
|
||||
|
|
@ -1066,19 +1083,25 @@ async def login(data: dict):
|
|||
raise HTTPException(status_code=401, detail="Ugyldig brukernavn eller passord")
|
||||
|
||||
temp_token = jwt.encode(
|
||||
{"sub": admin['username'], "partial": True, "exp": datetime.utcnow() + timedelta(minutes=5)},
|
||||
{
|
||||
"sub": admin['username'],
|
||||
"partial": True,
|
||||
"remember_me": remember_me,
|
||||
"exp": datetime.utcnow() + timedelta(minutes=5),
|
||||
},
|
||||
SECRET_KEY, algorithm=ALGORITHM
|
||||
)
|
||||
return {"step": "2fa", "temp_token": temp_token}
|
||||
|
||||
@app.post("/api/auth/verify-2fa")
|
||||
async def verify_2fa(data: dict, response: Response):
|
||||
async def verify_2fa(data: dict, response: Response, request: Request):
|
||||
"""Steg 2: Verifiser TOTP-kode og sett session cookie."""
|
||||
try:
|
||||
payload = jwt.decode(data.get('temp_token'), SECRET_KEY, algorithms=[ALGORITHM])
|
||||
if not payload.get("partial"):
|
||||
raise JWTError()
|
||||
username = payload.get("sub")
|
||||
remember_me = bool(payload.get("remember_me"))
|
||||
except JWTError:
|
||||
raise HTTPException(status_code=401, detail="Sesjonen har utløpt eller er ugyldig")
|
||||
|
||||
|
|
@ -1090,8 +1113,13 @@ async def verify_2fa(data: dict, response: Response):
|
|||
print("❌ Ugyldig 2FA-kode ved admin-innlogging")
|
||||
raise HTTPException(status_code=401, detail="Feil 2FA-kode")
|
||||
|
||||
session_max_age = (
|
||||
ADMIN_REMEMBER_ME_MAX_AGE_SECONDS
|
||||
if remember_me
|
||||
else ADMIN_SESSION_MAX_AGE_SECONDS
|
||||
)
|
||||
final_token = jwt.encode(
|
||||
{"sub": username, "exp": datetime.utcnow() + timedelta(hours=12)},
|
||||
{"sub": username, "exp": datetime.utcnow() + timedelta(seconds=session_max_age)},
|
||||
SECRET_KEY, algorithm=ALGORITHM
|
||||
)
|
||||
|
||||
|
|
@ -1099,20 +1127,22 @@ async def verify_2fa(data: dict, response: Response):
|
|||
response.set_cookie(
|
||||
key="admin_session",
|
||||
value=final_token,
|
||||
max_age=session_max_age,
|
||||
expires=session_max_age,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
secure=False # Sett til True i produksjon (HTTPS)
|
||||
secure=should_use_secure_cookies(request),
|
||||
)
|
||||
return {"status": "success"}
|
||||
|
||||
@app.post("/api/auth/logout")
|
||||
async def logout(response: Response):
|
||||
async def logout(response: Response, request: Request):
|
||||
"""Logger ut admin ved å slette sesjonscookien."""
|
||||
response.delete_cookie(
|
||||
key="admin_session",
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
secure=False,
|
||||
secure=should_use_secure_cookies(request),
|
||||
)
|
||||
return {"status": "success"}
|
||||
|
||||
|
|
@ -1927,6 +1957,8 @@ async def update_facility_full(facility_id: int, request: Request):
|
|||
|
||||
async with app.state.pool.acquire() as conn:
|
||||
async with conn.transaction(): # Sikrer at alt lagres samlet
|
||||
facility_columns = await get_table_columns(conn, "facilities")
|
||||
update_data = {k: v for k, v in update_data.items() if k in facility_columns}
|
||||
|
||||
# 1. OPPDATER ANLEGG (FACILITIES)
|
||||
if update_data:
|
||||
|
|
@ -2196,6 +2228,9 @@ async def approve_greenfee_bulk(request: BulkGreenfeeRequest):
|
|||
"""Godkjenner AI-forslag, setter oppdatert-dato og sletter utkastet."""
|
||||
async with app.state.pool.acquire() as conn:
|
||||
async with conn.transaction():
|
||||
facility_columns = await get_table_columns(conn, "facilities")
|
||||
has_cooperating_clubs = "cooperating_clubs" in facility_columns
|
||||
|
||||
for approval in request.approvals:
|
||||
draft_row = await conn.fetchrow(
|
||||
"SELECT greenfee_draft FROM facilities WHERE id = $1",
|
||||
|
|
@ -2210,17 +2245,26 @@ async def approve_greenfee_bulk(request: BulkGreenfeeRequest):
|
|||
exclude_facility_id=approval.facility_id,
|
||||
)
|
||||
|
||||
await conn.execute("""
|
||||
UPDATE facilities
|
||||
SET greenfee = $1::jsonb,
|
||||
cooperating_clubs = CASE
|
||||
WHEN $2::jsonb = '[]'::jsonb THEN cooperating_clubs
|
||||
ELSE $2::jsonb
|
||||
END,
|
||||
greenfee_updated_at = NOW(),
|
||||
greenfee_draft = NULL
|
||||
WHERE id = $3
|
||||
""", json.dumps(approval.greenfee), json.dumps(cooperating_club_slugs), approval.facility_id)
|
||||
if has_cooperating_clubs:
|
||||
await conn.execute("""
|
||||
UPDATE facilities
|
||||
SET greenfee = $1::jsonb,
|
||||
cooperating_clubs = CASE
|
||||
WHEN $2::jsonb = '[]'::jsonb THEN cooperating_clubs
|
||||
ELSE $2::jsonb
|
||||
END,
|
||||
greenfee_updated_at = NOW(),
|
||||
greenfee_draft = NULL
|
||||
WHERE id = $3
|
||||
""", json.dumps(approval.greenfee), json.dumps(cooperating_club_slugs), approval.facility_id)
|
||||
else:
|
||||
await conn.execute("""
|
||||
UPDATE facilities
|
||||
SET greenfee = $1::jsonb,
|
||||
greenfee_updated_at = NOW(),
|
||||
greenfee_draft = NULL
|
||||
WHERE id = $2
|
||||
""", json.dumps(approval.greenfee), approval.facility_id)
|
||||
return {"status": "success"}
|
||||
|
||||
@app.post("/api/admin/run-greenfee-scraper")
|
||||
|
|
|
|||
BIN
bilde1.png
Normal file
BIN
bilde1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
32
deploy/Caddyfile
Normal file
32
deploy/Caddyfile
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
{
|
||||
email {$ACME_EMAIL}
|
||||
}
|
||||
|
||||
www.teeoff.no {
|
||||
redir https://teeoff.no{uri} permanent
|
||||
}
|
||||
|
||||
teeoff.no {
|
||||
encode zstd gzip
|
||||
|
||||
log {
|
||||
output stdout
|
||||
format console
|
||||
}
|
||||
|
||||
# This upload route is implemented in Next.js, not FastAPI.
|
||||
handle /api/admin/uploads/images* {
|
||||
reverse_proxy frontend:3000
|
||||
}
|
||||
|
||||
# All other /api traffic goes to the FastAPI backend.
|
||||
# Use handle, not handle_path, so the /api prefix is preserved.
|
||||
handle /api/* {
|
||||
reverse_proxy api:8000
|
||||
}
|
||||
|
||||
# Everything else is served by Next.js.
|
||||
handle {
|
||||
reverse_proxy frontend:3000
|
||||
}
|
||||
}
|
||||
95
docker-compose.prod.yml
Normal file
95
docker-compose.prod.yml
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
services:
|
||||
db:
|
||||
image: postgis/postgis:15-3.4
|
||||
container_name: teeoff_db
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
volumes:
|
||||
- teeoff_db_data:/var/lib/postgresql/data
|
||||
restart: unless-stopped
|
||||
|
||||
api:
|
||||
build: ./backend
|
||||
container_name: teeoff_api
|
||||
environment:
|
||||
DATABASE_URL: ${DATABASE_URL}
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID}
|
||||
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET}
|
||||
PUBLIC_BASE_URL: ${PUBLIC_BASE_URL}
|
||||
PUBLIC_SESSION_SECRET: ${PUBLIC_SESSION_SECRET}
|
||||
PUBLIC_COMMENT_DEFAULT_STATUS: ${PUBLIC_COMMENT_DEFAULT_STATUS}
|
||||
SMTP_SERVER: ${SMTP_SERVER}
|
||||
SMTP_PORT: ${SMTP_PORT}
|
||||
SMTP_USER: ${SMTP_USER}
|
||||
SMTP_PASS: ${SMTP_PASS}
|
||||
PUBLIC_FROM_EMAIL: ${PUBLIC_FROM_EMAIL}
|
||||
PUBLIC_MAGIC_LINK_MAX_AGE_MINUTES: ${PUBLIC_MAGIC_LINK_MAX_AGE_MINUTES}
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
- ./frontend/src/content:/shared/frontend-content:ro
|
||||
- ./frontend/public/media:/app/public/media
|
||||
depends_on:
|
||||
- db
|
||||
restart: unless-stopped
|
||||
expose:
|
||||
- "8000"
|
||||
|
||||
worker:
|
||||
build: ./backend
|
||||
container_name: teeoff_worker
|
||||
command: python worker.py
|
||||
environment:
|
||||
DATABASE_URL: ${DATABASE_URL}
|
||||
GEMINI_API_KEY: ${GEMINI_API_KEY}
|
||||
SMTP_SERVER: ${SMTP_SERVER}
|
||||
SMTP_PORT: ${SMTP_PORT}
|
||||
SMTP_USER: ${SMTP_USER}
|
||||
SMTP_PASS: ${SMTP_PASS}
|
||||
EMAIL_TO: ${EMAIL_TO}
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
depends_on:
|
||||
- db
|
||||
restart: unless-stopped
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
args:
|
||||
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL}
|
||||
container_name: teeoff_frontend
|
||||
command: npm start
|
||||
environment:
|
||||
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL}
|
||||
volumes:
|
||||
- ./frontend/public/uploads:/app/public/uploads
|
||||
depends_on:
|
||||
- api
|
||||
restart: unless-stopped
|
||||
expose:
|
||||
- "3000"
|
||||
|
||||
caddy:
|
||||
image: caddy:2
|
||||
container_name: teeoff_caddy
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
environment:
|
||||
ACME_EMAIL: ${ACME_EMAIL}
|
||||
volumes:
|
||||
- ./deploy/Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- caddy_data:/data
|
||||
- caddy_config:/config
|
||||
depends_on:
|
||||
- frontend
|
||||
- api
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
teeoff_db_data:
|
||||
caddy_data:
|
||||
caddy_config:
|
||||
141
docs/vps-deploy-teeoff.md
Normal file
141
docs/vps-deploy-teeoff.md
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
# TeeOff VPS deployment
|
||||
|
||||
This project can be served on `https://teeoff.no` with Docker Compose and Caddy.
|
||||
|
||||
## 1. DNS at one.com
|
||||
|
||||
Create or update these records:
|
||||
|
||||
- `A` record for the root domain (`@` / empty hostname) -> `85.137.228.98`
|
||||
- `CNAME` for `www` -> `teeoff.no`
|
||||
|
||||
If `teeoff.no` currently has old `A`, `AAAA`, web-forward, or alias records pointing elsewhere, remove or replace them.
|
||||
|
||||
If you do not actively use IPv6 on the VPS, remove any stale `AAAA` record for `teeoff.no` and `www`.
|
||||
|
||||
one.com documents A/CNAME management here:
|
||||
|
||||
- https://help.one.com/hc/en-us/articles/360000799298-How-do-I-create-an-A-record
|
||||
- https://help.one.com/hc/en-us/articles/360000803517-How-do-I-create-a-CNAME-record
|
||||
|
||||
## 2. Required environment values
|
||||
|
||||
On the VPS, create `.env` in the project root and make sure these values are correct:
|
||||
|
||||
```env
|
||||
PUBLIC_BASE_URL=https://teeoff.no
|
||||
NEXT_PUBLIC_SITE_URL=https://teeoff.no
|
||||
DATABASE_URL=postgresql://teeoff_admin:...@db:5432/teeoff
|
||||
POSTGRES_USER=teeoff_admin
|
||||
POSTGRES_PASSWORD=...
|
||||
POSTGRES_DB=teeoff
|
||||
JWT_SECRET=...
|
||||
PUBLIC_SESSION_SECRET=...
|
||||
ACME_EMAIL=you@example.com
|
||||
```
|
||||
|
||||
If public comments or Google login are used, keep the SMTP and Google OAuth values configured too.
|
||||
|
||||
Important Google OAuth update:
|
||||
|
||||
- Authorized redirect URI should include `https://teeoff.no/api/public/auth/google/callback`
|
||||
|
||||
## 3. Start the production stack
|
||||
|
||||
Use the production compose file:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml up -d --build
|
||||
```
|
||||
|
||||
This stack exposes only:
|
||||
|
||||
- `80/tcp`
|
||||
- `443/tcp`
|
||||
|
||||
The app containers stay internal and are reached through Caddy only.
|
||||
|
||||
## 4. Reverse proxy layout
|
||||
|
||||
The Caddy config is in `deploy/Caddyfile`.
|
||||
|
||||
Routing is:
|
||||
|
||||
- `https://teeoff.no/api/admin/uploads/images` -> Next.js
|
||||
- all other `https://teeoff.no/api/*` -> FastAPI
|
||||
- everything else -> Next.js frontend
|
||||
|
||||
That exception matters because the image upload endpoint is implemented in Next.js, while the rest of the API lives in FastAPI.
|
||||
The `/api` prefix must be preserved when proxying, because both apps define routes with `/api/...` paths.
|
||||
|
||||
## 5. VPS hardening
|
||||
|
||||
Recommended minimum host steps on Ubuntu 24.04:
|
||||
|
||||
```bash
|
||||
sudo apt update && sudo apt upgrade -y
|
||||
sudo apt install -y ufw fail2ban
|
||||
sudo ufw allow OpenSSH
|
||||
sudo ufw allow 80/tcp
|
||||
sudo ufw allow 443/tcp
|
||||
sudo ufw enable
|
||||
```
|
||||
|
||||
Important Docker note: published Docker ports can bypass `ufw` if you expose them directly on the host. That is why the production compose file publishes only Caddy's `80` and `443`.
|
||||
|
||||
Useful official references:
|
||||
|
||||
- Ubuntu UFW docs: https://ubuntu.com/server/docs/how-to/security/firewalls/
|
||||
- Docker firewall warning: https://docs.docker.com/engine/network/packet-filtering-firewalls/
|
||||
- Docker port publishing warning: https://docs.docker.com/engine/network/port-publishing/
|
||||
- Caddy automatic HTTPS: https://caddyserver.com/docs/automatic-https
|
||||
|
||||
## 6. SSH hardening
|
||||
|
||||
Do this after you have verified SSH key login works:
|
||||
|
||||
- create a non-root sudo user if you do not already use one
|
||||
- disable password authentication in `/etc/ssh/sshd_config`
|
||||
- disable root SSH login if you do not need it
|
||||
- restart SSH
|
||||
|
||||
Typical settings:
|
||||
|
||||
```text
|
||||
PasswordAuthentication no
|
||||
PermitRootLogin no
|
||||
PubkeyAuthentication yes
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
```bash
|
||||
sudo systemctl restart ssh
|
||||
```
|
||||
|
||||
## 7. Verification checklist
|
||||
|
||||
After DNS has propagated and the stack is up:
|
||||
|
||||
```bash
|
||||
curl -I http://teeoff.no
|
||||
curl -I https://teeoff.no
|
||||
curl -I https://www.teeoff.no
|
||||
curl -I https://teeoff.no/api/health
|
||||
```
|
||||
|
||||
Expected results:
|
||||
|
||||
- `http://teeoff.no` redirects to HTTPS
|
||||
- `https://www.teeoff.no` redirects to `https://teeoff.no`
|
||||
- `https://teeoff.no/api/health` returns the API health payload
|
||||
|
||||
## 8. Existing compose file
|
||||
|
||||
`docker-compose.yml` is still useful for the earlier/local setup.
|
||||
|
||||
For the VPS cutover, prefer:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml up -d --build
|
||||
```
|
||||
1
frontend/.nvmrc
Normal file
1
frontend/.nvmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
20
|
||||
|
|
@ -2,15 +2,18 @@ FROM node:20-alpine
|
|||
|
||||
WORKDIR /app
|
||||
|
||||
# Kopier package.json og installer avhengigheter
|
||||
ARG NEXT_PUBLIC_SITE_URL
|
||||
ENV NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL}
|
||||
|
||||
# Kopier package.json og installer avhengigheter deterministisk
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
RUN npm ci
|
||||
|
||||
# Kopier resten av koden
|
||||
COPY . .
|
||||
|
||||
# BYGG koden her (kjøres jyb én gang når imaget bygges
|
||||
# Bygg koden én gang ved image-build
|
||||
RUN npm run build
|
||||
|
||||
# Vi starter serveren i "produksjons"-modus (utviklingsmodus).
|
||||
# Start Next i produksjonsmodus
|
||||
CMD ["npm", "start"]
|
||||
|
|
|
|||
|
|
@ -2,11 +2,15 @@
|
|||
"name": "frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=20.9.0",
|
||||
"npm": ">=10"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@tiptap/extension-image": "^3.22.3",
|
||||
|
|
|
|||
|
|
@ -236,13 +236,20 @@ const sanitizeHref = (value: string) => {
|
|||
return /^(https?:|mailto:|tel:|\/|#)/i.test(href) ? href : "#";
|
||||
};
|
||||
|
||||
const getSiteOrigin = () => {
|
||||
if (typeof window !== "undefined" && window.location?.origin) {
|
||||
return window.location.origin;
|
||||
}
|
||||
return process.env.NEXT_PUBLIC_SITE_URL || "https://teeoff.no";
|
||||
};
|
||||
|
||||
const isInternalTeeoffHref = (href: string) => {
|
||||
if (!href || href.startsWith("/") || href.startsWith("#") || href.startsWith("mailto:") || href.startsWith("tel:")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(href, "https://nye.teeoff.no");
|
||||
const url = new URL(href, getSiteOrigin());
|
||||
return url.hostname === "teeoff.no" || url.hostname.endsWith(".teeoff.no");
|
||||
} catch {
|
||||
return false;
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import { API_URL } from "@/config/constants";
|
|||
|
||||
export default function AdminLogin() {
|
||||
const [step, setStep] = useState(1);
|
||||
const [formData, setFormData] = useState({ username: '', password: '', code: '' });
|
||||
const [formData, setFormData] = useState({ username: '', password: '', code: '', rememberMe: false });
|
||||
const [tempToken, setTempToken] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
|
@ -28,7 +28,11 @@ export default function AdminLogin() {
|
|||
const res = await fetch(`${API_URL}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: formData.username, password: formData.password })
|
||||
body: JSON.stringify({
|
||||
username: formData.username,
|
||||
password: formData.password,
|
||||
remember_me: formData.rememberMe,
|
||||
})
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
|
@ -85,6 +89,15 @@ export default function AdminLogin() {
|
|||
<>
|
||||
<input type="text" placeholder="Brukernavn eller E-post" className="w-full p-5 bg-gray-50 rounded-2xl border-none ring-1 ring-gray-100 outline-none focus:ring-2 focus:ring-[#8bc34a] transition-all text-sm font-bold text-[#11280f]" onChange={e => setFormData(prevState => ({...prevState, username: e.target.value}))} required />
|
||||
<input type="password" placeholder="Passord" className="w-full p-5 bg-gray-50 rounded-2xl border-none ring-1 ring-gray-100 outline-none focus:ring-2 focus:ring-[#8bc34a] transition-all text-sm font-bold text-[#11280f]" onChange={e => setFormData(prevState => ({...prevState, password: e.target.value}))} required />
|
||||
<label className="flex items-center gap-3 rounded-2xl bg-[#f7faF4] px-4 py-4 text-sm font-bold text-[#11280f]">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 rounded border-gray-300 text-[#11280f] focus:ring-[#8bc34a]"
|
||||
checked={formData.rememberMe}
|
||||
onChange={e => setFormData(prevState => ({ ...prevState, rememberMe: e.target.checked }))}
|
||||
/>
|
||||
Husk meg i 30 dager på denne enheten
|
||||
</label>
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,86 @@
|
|||
"use client";
|
||||
|
||||
import { Icon as LeafletIcon } from "leaflet";
|
||||
import { MapContainer, Marker, Popup, TileLayer } from "react-leaflet";
|
||||
import { STATUS_MAP } from "@/config/constants";
|
||||
import { STATUS_ICON_PATHS } from "@/app/facilityData";
|
||||
|
||||
type FacilityDetailLeafletMapProps = {
|
||||
lat: number;
|
||||
lng: number;
|
||||
name: string;
|
||||
city?: string | null;
|
||||
county?: string | null;
|
||||
primaryStatus: string;
|
||||
mapUrl: string | null;
|
||||
};
|
||||
|
||||
const detailMarkerIconCache: Record<string, LeafletIcon> = {};
|
||||
|
||||
const getDetailMarkerIcon = (status: string) => {
|
||||
const key = STATUS_ICON_PATHS[status] ? status : "ukjent";
|
||||
if (!detailMarkerIconCache[key]) {
|
||||
detailMarkerIconCache[key] = new LeafletIcon({
|
||||
iconUrl: STATUS_ICON_PATHS[key],
|
||||
iconSize: [34, 48],
|
||||
iconAnchor: [17, 48],
|
||||
popupAnchor: [0, -42],
|
||||
});
|
||||
}
|
||||
return detailMarkerIconCache[key];
|
||||
};
|
||||
|
||||
export default function FacilityDetailLeafletMap({
|
||||
lat,
|
||||
lng,
|
||||
name,
|
||||
city,
|
||||
county,
|
||||
primaryStatus,
|
||||
mapUrl,
|
||||
}: FacilityDetailLeafletMapProps) {
|
||||
return (
|
||||
<div className="h-[450px] w-full md:h-[650px]">
|
||||
<MapContainer
|
||||
center={[lat, lng]}
|
||||
zoom={13}
|
||||
scrollWheelZoom={false}
|
||||
zoomControl
|
||||
className="h-full w-full"
|
||||
>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
<Marker
|
||||
position={[lat, lng]}
|
||||
icon={getDetailMarkerIcon(primaryStatus)}
|
||||
>
|
||||
<Popup>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-lg font-extrabold text-[#112015]">{name}</p>
|
||||
<p className="mt-1 text-[11px] font-extrabold uppercase tracking-[0.18em] text-[#617063]">
|
||||
{city} • {county}
|
||||
</p>
|
||||
</div>
|
||||
<div className="inline-flex rounded-full bg-[#F3F6EE] px-3 py-1 text-[10px] font-extrabold uppercase tracking-[0.18em] text-[#112015]">
|
||||
{STATUS_MAP[primaryStatus] || "Ukjent status"}
|
||||
</div>
|
||||
{mapUrl && (
|
||||
<a
|
||||
href={mapUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="btn btn-sm btn-secondary"
|
||||
>
|
||||
Åpne kart
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
</MapContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -14,27 +14,20 @@
|
|||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Icon as LeafletIcon } from "leaflet";
|
||||
import { MapContainer, Marker, Popup, TileLayer } from "react-leaflet";
|
||||
import dynamic from "next/dynamic";
|
||||
import { STATUS_MAP, FALLBACK_IMAGE } from "@/config/constants";
|
||||
import { STATUS_ICON_PATHS, buildMapUrl, getPrimaryStatus, parseJson as parseSharedJson, slugify } from "@/app/facilityData";
|
||||
import Link from 'next/link';
|
||||
import CourseDisplay from './CourseDisplay';
|
||||
|
||||
const detailMarkerIconCache: Record<string, LeafletIcon> = {};
|
||||
|
||||
const getDetailMarkerIcon = (status: string) => {
|
||||
const key = STATUS_ICON_PATHS[status] ? status : "ukjent";
|
||||
if (!detailMarkerIconCache[key]) {
|
||||
detailMarkerIconCache[key] = new LeafletIcon({
|
||||
iconUrl: STATUS_ICON_PATHS[key],
|
||||
iconSize: [34, 48],
|
||||
iconAnchor: [17, 48],
|
||||
popupAnchor: [0, -42],
|
||||
});
|
||||
}
|
||||
return detailMarkerIconCache[key];
|
||||
};
|
||||
const FacilityDetailLeafletMap = dynamic(() => import("./FacilityDetailLeafletMap"), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="flex h-[450px] w-full items-center justify-center bg-[#f1f7ed] text-sm font-bold text-[#617063] md:h-[650px]">
|
||||
Laster kart…
|
||||
</div>
|
||||
),
|
||||
});
|
||||
|
||||
const formatPhoneForUrl = (phone: string) => {
|
||||
if (!phone) return "";
|
||||
|
|
@ -65,13 +58,20 @@ const sanitizeHref = (value: string) => {
|
|||
return /^(https?:|mailto:|tel:|\/|#)/i.test(href) ? href : "#";
|
||||
};
|
||||
|
||||
const getSiteOrigin = () => {
|
||||
if (typeof window !== "undefined" && window.location?.origin) {
|
||||
return window.location.origin;
|
||||
}
|
||||
return process.env.NEXT_PUBLIC_SITE_URL || "https://teeoff.no";
|
||||
};
|
||||
|
||||
const isInternalTeeoffHref = (href: string) => {
|
||||
if (!href || href.startsWith("/") || href.startsWith("#") || href.startsWith("mailto:") || href.startsWith("tel:")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(href, "https://nye.teeoff.no");
|
||||
const url = new URL(href, getSiteOrigin());
|
||||
return url.hostname === "teeoff.no" || url.hostname.endsWith(".teeoff.no");
|
||||
} catch {
|
||||
return false;
|
||||
|
|
@ -445,20 +445,6 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
|
|||
</span>
|
||||
</div>
|
||||
|
||||
{/* SAMARBEIDENDE KLUBBER */}
|
||||
{cooperatingClubs.length > 0 && (
|
||||
<div className="pt-2">
|
||||
<span className="text-gray-400 block mb-2">Samarbeider med:</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{cooperatingClubs.map((slug: string) => (
|
||||
<Link key={slug} href={`/golfbaner/${slug}`} className="btn btn-sm btn-secondary">
|
||||
{slug.replace('-golfklubb', '').replace(/-/g, ' ')}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{golfpakkerRaw.length > 0 && (
|
||||
<div className="pt-4">
|
||||
<span className="text-gray-400 block mb-2">Golfpakker:</span>
|
||||
|
|
@ -503,48 +489,15 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
|
|||
<section id="map" className="space-y-6">
|
||||
<h2 className="text-3xl md:text-4xl font-black uppercase tracking-tighter flex items-center gap-5 ml-6 md:ml-0">Kart <span className="h-1 flex-grow bg-gray-100 rounded-full" /></h2>
|
||||
<div className="teeoff-map overflow-hidden md:rounded-[3rem] border-y-4 md:border-[12px] border-white bg-white shadow-xl">
|
||||
<div className="h-[450px] md:h-[650px] w-full">
|
||||
<MapContainer
|
||||
center={[facility.lat as number, facility.lng as number]}
|
||||
zoom={13}
|
||||
scrollWheelZoom={false}
|
||||
zoomControl
|
||||
className="h-full w-full"
|
||||
>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
<Marker
|
||||
position={[facility.lat as number, facility.lng as number]}
|
||||
icon={getDetailMarkerIcon(primaryStatus)}
|
||||
>
|
||||
<Popup>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-lg font-extrabold text-[#112015]">{facility.name}</p>
|
||||
<p className="mt-1 text-[11px] font-extrabold uppercase tracking-[0.18em] text-[#617063]">
|
||||
{facility.city} • {facility.county}
|
||||
</p>
|
||||
</div>
|
||||
<div className="inline-flex rounded-full bg-[#F3F6EE] px-3 py-1 text-[10px] font-extrabold uppercase tracking-[0.18em] text-[#112015]">
|
||||
{STATUS_MAP[primaryStatus] || "Ukjent status"}
|
||||
</div>
|
||||
{mapUrl && (
|
||||
<a
|
||||
href={mapUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="btn btn-sm btn-secondary"
|
||||
>
|
||||
Åpne kart
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
</MapContainer>
|
||||
</div>
|
||||
<FacilityDetailLeafletMap
|
||||
lat={facility.lat as number}
|
||||
lng={facility.lng as number}
|
||||
name={facility.name}
|
||||
city={facility.city}
|
||||
county={facility.county}
|
||||
primaryStatus={primaryStatus}
|
||||
mapUrl={mapUrl}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
|
@ -655,6 +608,20 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
|
|||
Krav: {facility.guest_requirements}
|
||||
</p>
|
||||
)}
|
||||
{cooperatingClubs.length > 0 && (
|
||||
<div className="mt-4 border-t border-gray-50 pt-4">
|
||||
<span className="mb-3 block text-[10px] font-black uppercase tracking-widest text-gray-400">
|
||||
Samarbeidende klubber
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{cooperatingClubs.map((slug: string) => (
|
||||
<Link key={slug} href={`/golfbaner/${slug}`} className="btn btn-sm btn-secondary">
|
||||
{slug.replace('-golfklubb', '').replace(/-/g, ' ')}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,105 +1,22 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Icon, LatLngBounds } from "leaflet";
|
||||
import { MapContainer, Marker, Popup, TileLayer, useMap } from "react-leaflet";
|
||||
import {
|
||||
type EnrichedFacility,
|
||||
STATUS_ICON_PATHS,
|
||||
buildMapUrl,
|
||||
formatUpdatedDate,
|
||||
getStatusLabel,
|
||||
parseJson,
|
||||
} from "@/app/facilityData";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useMemo } from "react";
|
||||
import { type EnrichedFacility, STATUS_ICON_PATHS } from "@/app/facilityData";
|
||||
|
||||
type PlaceMapProps = {
|
||||
facilities: EnrichedFacility[];
|
||||
placeLabel: string;
|
||||
};
|
||||
|
||||
const markerIconCache: Record<string, Icon> = {};
|
||||
|
||||
const getMarkerIcon = (status: string) => {
|
||||
const key = STATUS_ICON_PATHS[status] ? status : "ukjent";
|
||||
if (!markerIconCache[key]) {
|
||||
markerIconCache[key] = new Icon({
|
||||
iconUrl: STATUS_ICON_PATHS[key],
|
||||
iconSize: [34, 48],
|
||||
iconAnchor: [17, 48],
|
||||
popupAnchor: [0, -42],
|
||||
});
|
||||
}
|
||||
return markerIconCache[key];
|
||||
};
|
||||
|
||||
function ShiftScrollZoomGuard() {
|
||||
const map = useMap();
|
||||
|
||||
useEffect(() => {
|
||||
const updateWheelMode = (shiftPressed: boolean) => {
|
||||
if (window.innerWidth < 1024) {
|
||||
map.scrollWheelZoom.enable();
|
||||
return;
|
||||
}
|
||||
|
||||
if (shiftPressed) {
|
||||
map.scrollWheelZoom.enable();
|
||||
} else {
|
||||
map.scrollWheelZoom.disable();
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Shift") updateWheelMode(true);
|
||||
};
|
||||
|
||||
const onKeyUp = (event: KeyboardEvent) => {
|
||||
if (event.key === "Shift") updateWheelMode(false);
|
||||
};
|
||||
|
||||
const onBlur = () => updateWheelMode(false);
|
||||
const onResize = () => updateWheelMode(false);
|
||||
|
||||
updateWheelMode(false);
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
window.addEventListener("keyup", onKeyUp);
|
||||
window.addEventListener("blur", onBlur);
|
||||
window.addEventListener("resize", onResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
window.removeEventListener("keyup", onKeyUp);
|
||||
window.removeEventListener("blur", onBlur);
|
||||
window.removeEventListener("resize", onResize);
|
||||
};
|
||||
}, [map]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function FitMapBounds({ facilities }: { facilities: EnrichedFacility[] }) {
|
||||
const map = useMap();
|
||||
|
||||
useEffect(() => {
|
||||
const withCoords = facilities.filter(
|
||||
(facility) => typeof facility.lat === "number" && typeof facility.lng === "number"
|
||||
);
|
||||
|
||||
if (withCoords.length === 0) return;
|
||||
if (withCoords.length === 1) {
|
||||
map.setView([withCoords[0].lat as number, withCoords[0].lng as number], 10);
|
||||
return;
|
||||
}
|
||||
|
||||
const bounds = new LatLngBounds(
|
||||
withCoords.map((facility) => [facility.lat as number, facility.lng as number] as [number, number])
|
||||
);
|
||||
map.fitBounds(bounds, { padding: [36, 36] });
|
||||
}, [facilities, map]);
|
||||
|
||||
return null;
|
||||
}
|
||||
const PlaceMapLeaflet = dynamic(() => import("./PlaceMapLeaflet"), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="flex h-[26rem] w-full items-center justify-center bg-[#F3F6EE] text-sm font-bold text-[#617063] sm:h-[34rem]">
|
||||
Laster kart…
|
||||
</div>
|
||||
),
|
||||
});
|
||||
|
||||
function ActionGlyph({ type }: { type: "teeoff" | "phone" | "mail" | "home" | "calendar" | "weather" | "facebook" | "instagram" }) {
|
||||
if (type === "teeoff") {
|
||||
|
|
@ -252,108 +169,7 @@ export default function PlaceMap({ facilities, placeLabel }: PlaceMapProps) {
|
|||
</div>
|
||||
|
||||
<div className="teeoff-map overflow-hidden rounded-[2rem] border border-[#D7DED0] bg-white shadow-sm">
|
||||
<div className="h-[26rem] w-full sm:h-[34rem]">
|
||||
<MapContainer
|
||||
center={[64.5, 15.5]}
|
||||
zoom={5}
|
||||
scrollWheelZoom
|
||||
zoomControl
|
||||
className="h-full w-full"
|
||||
>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
<ShiftScrollZoomGuard />
|
||||
<FitMapBounds facilities={mapFacilities} />
|
||||
|
||||
{mapFacilities.map((facility) => {
|
||||
const socialLinks = parseJson<Array<{ platform?: string; url?: string }>>(facility.social_links, []);
|
||||
const facebook = socialLinks.find((entry) => entry.platform?.toLowerCase() === "facebook")?.url;
|
||||
const instagram = socialLinks.find((entry) => entry.platform?.toLowerCase() === "instagram")?.url;
|
||||
|
||||
return (
|
||||
<Marker
|
||||
key={facility.id}
|
||||
position={[facility.lat as number, facility.lng as number]}
|
||||
icon={getMarkerIcon(facility.primaryStatus)}
|
||||
>
|
||||
<Popup>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Link href={`/golfbaner/${facility.slug}`} className="text-lg font-extrabold text-[#112015] hover:text-[#FF5722]">
|
||||
{facility.name}
|
||||
</Link>
|
||||
<p className="mt-1 text-[11px] font-extrabold uppercase tracking-[0.18em] text-[#617063]">
|
||||
{facility.city} • {facility.county}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="inline-flex rounded-full bg-[#F3F6EE] px-3 py-1 text-[10px] font-extrabold uppercase tracking-[0.18em] text-[#112015]">
|
||||
{getStatusLabel(facility.primaryStatus)}
|
||||
</div>
|
||||
|
||||
{facility.status_updated_at && (
|
||||
<p className="text-[11px] text-[#617063]">Oppdatert {formatUpdatedDate(facility.status_updated_at)}</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<a href={`/golfbaner/${facility.slug}`} className="map-popup-icon" aria-label={`Se ${facility.name} på TeeOff`}>
|
||||
<ActionGlyph type="teeoff" />
|
||||
</a>
|
||||
{facility.phone && (
|
||||
<a href={`tel:${facility.phone.replace(/\s/g, "")}`} className="map-popup-icon" aria-label={`Ring ${facility.name}`}>
|
||||
<ActionGlyph type="phone" />
|
||||
</a>
|
||||
)}
|
||||
{facility.email && (
|
||||
<a href={`mailto:${facility.email}`} className="map-popup-icon" aria-label={`Send e-post til ${facility.name}`}>
|
||||
<ActionGlyph type="mail" />
|
||||
</a>
|
||||
)}
|
||||
{facility.website_url && (
|
||||
<a href={facility.website_url} target="_blank" rel="noreferrer" className="map-popup-icon" aria-label={`Besøk nettsiden til ${facility.name}`}>
|
||||
<ActionGlyph type="home" />
|
||||
</a>
|
||||
)}
|
||||
{facility.golfbox_tournament_url && (
|
||||
<a href={facility.golfbox_tournament_url} target="_blank" rel="noreferrer" className="map-popup-icon" aria-label={`Turneringer hos ${facility.name}`}>
|
||||
<ActionGlyph type="calendar" />
|
||||
</a>
|
||||
)}
|
||||
{facility.weather_url && (
|
||||
<a href={facility.weather_url} target="_blank" rel="noreferrer" className="map-popup-icon" aria-label={`Vær for ${facility.name}`}>
|
||||
<ActionGlyph type="weather" />
|
||||
</a>
|
||||
)}
|
||||
{facebook && (
|
||||
<a href={facebook} target="_blank" rel="noreferrer" className="map-popup-icon" aria-label={`Facebook for ${facility.name}`}>
|
||||
<ActionGlyph type="facebook" />
|
||||
</a>
|
||||
)}
|
||||
{instagram && (
|
||||
<a href={instagram} target="_blank" rel="noreferrer" className="map-popup-icon" aria-label={`Instagram for ${facility.name}`}>
|
||||
<ActionGlyph type="instagram" />
|
||||
</a>
|
||||
)}
|
||||
{buildMapUrl(facility.lat, facility.lng) && (
|
||||
<a
|
||||
href={buildMapUrl(facility.lat, facility.lng) || "#"}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="rounded-full border border-[#D7DED0] px-3 py-1 text-[10px] font-extrabold uppercase tracking-[0.18em] text-[#617063] transition hover:border-[#FF5722] hover:text-[#FF5722]"
|
||||
>
|
||||
Åpne kart
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
</MapContainer>
|
||||
</div>
|
||||
<PlaceMapLeaflet facilities={mapFacilities} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
|
|
|||
267
frontend/src/components/PlaceMapLeaflet.tsx
Normal file
267
frontend/src/components/PlaceMapLeaflet.tsx
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect } from "react";
|
||||
import { Icon, LatLngBounds } from "leaflet";
|
||||
import { MapContainer, Marker, Popup, TileLayer, useMap } from "react-leaflet";
|
||||
import {
|
||||
buildMapUrl,
|
||||
formatUpdatedDate,
|
||||
getStatusLabel,
|
||||
parseJson,
|
||||
STATUS_ICON_PATHS,
|
||||
type EnrichedFacility,
|
||||
} from "@/app/facilityData";
|
||||
|
||||
type PlaceMapLeafletProps = {
|
||||
facilities: EnrichedFacility[];
|
||||
};
|
||||
|
||||
const markerIconCache: Record<string, Icon> = {};
|
||||
|
||||
const getMarkerIcon = (status: string) => {
|
||||
const key = STATUS_ICON_PATHS[status] ? status : "ukjent";
|
||||
if (!markerIconCache[key]) {
|
||||
markerIconCache[key] = new Icon({
|
||||
iconUrl: STATUS_ICON_PATHS[key],
|
||||
iconSize: [34, 48],
|
||||
iconAnchor: [17, 48],
|
||||
popupAnchor: [0, -42],
|
||||
});
|
||||
}
|
||||
return markerIconCache[key];
|
||||
};
|
||||
|
||||
function ShiftScrollZoomGuard() {
|
||||
const map = useMap();
|
||||
|
||||
useEffect(() => {
|
||||
const updateWheelMode = (shiftPressed: boolean) => {
|
||||
if (window.innerWidth < 1024) {
|
||||
map.scrollWheelZoom.enable();
|
||||
return;
|
||||
}
|
||||
|
||||
if (shiftPressed) {
|
||||
map.scrollWheelZoom.enable();
|
||||
} else {
|
||||
map.scrollWheelZoom.disable();
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Shift") updateWheelMode(true);
|
||||
};
|
||||
|
||||
const onKeyUp = (event: KeyboardEvent) => {
|
||||
if (event.key === "Shift") updateWheelMode(false);
|
||||
};
|
||||
|
||||
const onBlur = () => updateWheelMode(false);
|
||||
const onResize = () => updateWheelMode(false);
|
||||
|
||||
updateWheelMode(false);
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
window.addEventListener("keyup", onKeyUp);
|
||||
window.addEventListener("blur", onBlur);
|
||||
window.addEventListener("resize", onResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
window.removeEventListener("keyup", onKeyUp);
|
||||
window.removeEventListener("blur", onBlur);
|
||||
window.removeEventListener("resize", onResize);
|
||||
};
|
||||
}, [map]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function FitMapBounds({ facilities }: { facilities: EnrichedFacility[] }) {
|
||||
const map = useMap();
|
||||
|
||||
useEffect(() => {
|
||||
const withCoords = facilities.filter(
|
||||
(facility) => typeof facility.lat === "number" && typeof facility.lng === "number"
|
||||
);
|
||||
|
||||
if (withCoords.length === 0) return;
|
||||
if (withCoords.length === 1) {
|
||||
map.setView([withCoords[0].lat as number, withCoords[0].lng as number], 10);
|
||||
return;
|
||||
}
|
||||
|
||||
const bounds = new LatLngBounds(
|
||||
withCoords.map((facility) => [facility.lat as number, facility.lng as number] as [number, number])
|
||||
);
|
||||
map.fitBounds(bounds, { padding: [36, 36] });
|
||||
}, [facilities, map]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function ActionGlyph({ type }: { type: "teeoff" | "phone" | "mail" | "home" | "calendar" | "weather" | "facebook" | "instagram" }) {
|
||||
if (type === "teeoff") {
|
||||
return <span className="text-lg font-black leading-none text-[#FF5722]">t</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<svg
|
||||
className="h-4 w-4 text-[#FF5722]"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{type === "phone" && <path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z" />}
|
||||
{type === "mail" && (
|
||||
<>
|
||||
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" />
|
||||
<polyline points="22,6 12,13 2,6" />
|
||||
</>
|
||||
)}
|
||||
{type === "home" && (
|
||||
<>
|
||||
<path d="M3 11.5 12 4l9 7.5" />
|
||||
<path d="M5 10.5V20h14v-9.5" />
|
||||
</>
|
||||
)}
|
||||
{type === "calendar" && (
|
||||
<>
|
||||
<path d="M3 10h18" />
|
||||
<path d="M8 3v4" />
|
||||
<path d="M16 3v4" />
|
||||
<rect x="4" y="5" width="16" height="16" rx="2" />
|
||||
</>
|
||||
)}
|
||||
{type === "weather" && (
|
||||
<>
|
||||
<path d="M12 2v2" />
|
||||
<path d="m4.93 4.93 1.41 1.41" />
|
||||
<path d="M20 12h2" />
|
||||
<path d="m19.07 4.93-1.41 1.41" />
|
||||
<path d="M15.947 12.65a4 4 0 0 0-5.925-4.128" />
|
||||
<path d="M13 22H7a5 5 0 1 1 4.9-6H13a3 3 0 0 1 0 6Z" />
|
||||
</>
|
||||
)}
|
||||
{type === "facebook" && <path d="M18 2h-3a5 5 0 0 0-5 5v3H7v4h3v8h4v-8h3l1-4h-4V7a1 1 0 0 1 1-1h3z" />}
|
||||
{type === "instagram" && (
|
||||
<>
|
||||
<rect x="2" y="2" width="20" height="20" rx="5" ry="5" />
|
||||
<path d="M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z" />
|
||||
<line x1="17.5" y1="6.5" x2="17.51" y2="6.5" />
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PlaceMapLeaflet({ facilities }: PlaceMapLeafletProps) {
|
||||
return (
|
||||
<div className="h-[26rem] w-full sm:h-[34rem]">
|
||||
<MapContainer
|
||||
center={[64.5, 15.5]}
|
||||
zoom={5}
|
||||
scrollWheelZoom
|
||||
zoomControl
|
||||
className="h-full w-full"
|
||||
>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
<ShiftScrollZoomGuard />
|
||||
<FitMapBounds facilities={facilities} />
|
||||
|
||||
{facilities.map((facility) => {
|
||||
const socialLinks = parseJson<Array<{ platform?: string; url?: string }>>(facility.social_links, []);
|
||||
const facebook = socialLinks.find((entry) => entry.platform?.toLowerCase() === "facebook")?.url;
|
||||
const instagram = socialLinks.find((entry) => entry.platform?.toLowerCase() === "instagram")?.url;
|
||||
|
||||
return (
|
||||
<Marker
|
||||
key={facility.id}
|
||||
position={[facility.lat as number, facility.lng as number]}
|
||||
icon={getMarkerIcon(facility.primaryStatus)}
|
||||
>
|
||||
<Popup>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Link href={`/golfbaner/${facility.slug}`} className="text-lg font-extrabold text-[#112015] hover:text-[#FF5722]">
|
||||
{facility.name}
|
||||
</Link>
|
||||
<p className="mt-1 text-[11px] font-extrabold uppercase tracking-[0.18em] text-[#617063]">
|
||||
{facility.city} • {facility.county}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="inline-flex rounded-full bg-[#F3F6EE] px-3 py-1 text-[10px] font-extrabold uppercase tracking-[0.18em] text-[#112015]">
|
||||
{getStatusLabel(facility.primaryStatus)}
|
||||
</div>
|
||||
|
||||
{facility.status_updated_at && (
|
||||
<p className="text-[11px] text-[#617063]">Oppdatert {formatUpdatedDate(facility.status_updated_at)}</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<a href={`/golfbaner/${facility.slug}`} className="map-popup-icon" aria-label={`Se ${facility.name} på TeeOff`}>
|
||||
<ActionGlyph type="teeoff" />
|
||||
</a>
|
||||
{facility.phone && (
|
||||
<a href={`tel:${facility.phone.replace(/\s/g, "")}`} className="map-popup-icon" aria-label={`Ring ${facility.name}`}>
|
||||
<ActionGlyph type="phone" />
|
||||
</a>
|
||||
)}
|
||||
{facility.email && (
|
||||
<a href={`mailto:${facility.email}`} className="map-popup-icon" aria-label={`Send e-post til ${facility.name}`}>
|
||||
<ActionGlyph type="mail" />
|
||||
</a>
|
||||
)}
|
||||
{facility.website_url && (
|
||||
<a href={facility.website_url} target="_blank" rel="noreferrer" className="map-popup-icon" aria-label={`Besøk nettsiden til ${facility.name}`}>
|
||||
<ActionGlyph type="home" />
|
||||
</a>
|
||||
)}
|
||||
{facility.golfbox_tournament_url && (
|
||||
<a href={facility.golfbox_tournament_url} target="_blank" rel="noreferrer" className="map-popup-icon" aria-label={`Turneringer hos ${facility.name}`}>
|
||||
<ActionGlyph type="calendar" />
|
||||
</a>
|
||||
)}
|
||||
{facility.weather_url && (
|
||||
<a href={facility.weather_url} target="_blank" rel="noreferrer" className="map-popup-icon" aria-label={`Vær for ${facility.name}`}>
|
||||
<ActionGlyph type="weather" />
|
||||
</a>
|
||||
)}
|
||||
{facebook && (
|
||||
<a href={facebook} target="_blank" rel="noreferrer" className="map-popup-icon" aria-label={`Facebook for ${facility.name}`}>
|
||||
<ActionGlyph type="facebook" />
|
||||
</a>
|
||||
)}
|
||||
{instagram && (
|
||||
<a href={instagram} target="_blank" rel="noreferrer" className="map-popup-icon" aria-label={`Instagram for ${facility.name}`}>
|
||||
<ActionGlyph type="instagram" />
|
||||
</a>
|
||||
)}
|
||||
{buildMapUrl(facility.lat, facility.lng) && (
|
||||
<a
|
||||
href={buildMapUrl(facility.lat, facility.lng) || "#"}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="rounded-full border border-[#D7DED0] px-3 py-1 text-[10px] font-extrabold uppercase tracking-[0.18em] text-[#617063] transition hover:border-[#FF5722] hover:text-[#FF5722]"
|
||||
>
|
||||
Åpne kart
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
</MapContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
2
init.sql
2
init.sql
|
|
@ -20,7 +20,7 @@ CREATE TABLE facilities (
|
|||
proshop_url VARCHAR(255),
|
||||
cafe_url VARCHAR(255),
|
||||
nsg_agreement BOOLEAN DEFAULT FALSE,
|
||||
cooperating_clubs TEXT,
|
||||
cooperating_clubs JSONB DEFAULT '[]'::jsonb,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
|
|
|||
47
migrations/2026-04-17_add_cooperating_clubs_jsonb.sql
Normal file
47
migrations/2026-04-17_add_cooperating_clubs_jsonb.sql
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'facilities'
|
||||
AND column_name = 'cooperating_clubs'
|
||||
AND udt_name = 'text'
|
||||
) THEN
|
||||
ALTER TABLE public.facilities
|
||||
RENAME COLUMN cooperating_clubs TO cooperating_clubs_legacy;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'facilities'
|
||||
AND column_name = 'cooperating_clubs'
|
||||
) THEN
|
||||
ALTER TABLE public.facilities
|
||||
ADD COLUMN cooperating_clubs jsonb DEFAULT '[]'::jsonb;
|
||||
END IF;
|
||||
|
||||
ALTER TABLE public.facilities
|
||||
ALTER COLUMN cooperating_clubs SET DEFAULT '[]'::jsonb;
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'facilities'
|
||||
AND column_name = 'cooperating_clubs_legacy'
|
||||
) THEN
|
||||
UPDATE public.facilities
|
||||
SET cooperating_clubs = CASE
|
||||
WHEN cooperating_clubs_legacy IS NULL OR btrim(cooperating_clubs_legacy) = '' THEN '[]'::jsonb
|
||||
WHEN cooperating_clubs_legacy ~ '^\s*\[' THEN cooperating_clubs_legacy::jsonb
|
||||
ELSE to_jsonb(regexp_split_to_array(cooperating_clubs_legacy, '\s*,\s*'))
|
||||
END
|
||||
WHERE cooperating_clubs = '[]'::jsonb;
|
||||
|
||||
ALTER TABLE public.facilities
|
||||
DROP COLUMN cooperating_clubs_legacy;
|
||||
END IF;
|
||||
END $$;
|
||||
|
|
@ -24,6 +24,7 @@ CREATE TABLE facilities (
|
|||
website_url VARCHAR(255),
|
||||
golfbox_booking_url VARCHAR(255),
|
||||
golfbox_tournament_url VARCHAR(255),
|
||||
cooperating_clubs JSONB DEFAULT '[]'::jsonb,
|
||||
facebook_url VARCHAR(255),
|
||||
instagram_url VARCHAR(255),
|
||||
weather_url VARCHAR(255),
|
||||
|
|
|
|||
Loading…
Reference in a new issue