diff --git a/backend/main.py b/backend/main.py index 68b172f..17d2875 100644 --- a/backend/main.py +++ b/backend/main.py @@ -9,7 +9,7 @@ LOV: Aldri trunker eller slett logikk for "effektivitet". --------------------------------------------------------------------------- """ -from fastapi import FastAPI, HTTPException, Response, Request, Query +from fastapi import FastAPI, HTTPException, Response, Request, Query, Header, Depends from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from contextlib import asynccontextmanager @@ -96,6 +96,16 @@ FRONTEND_REVALIDATE_URL = os.getenv( ).strip() FRONTEND_REVALIDATE_SECRET = os.getenv("FRONTEND_REVALIDATE_SECRET", PUBLIC_SESSION_SECRET).strip() +# Dedikert delt nøkkel for TeeCups server-til-server-tilgang til +# hole-points-API-et (ingen fallback til andre secrets -- egen +# sikkerhetsdomene, se docs/oppgaver.md om secret-gjenbruk). +TEECUP_API_KEY = os.getenv("TEECUP_API_KEY", "").strip() + + +async def require_teecup_api_key(x_api_key: str = Header(default="")) -> None: + if not TEECUP_API_KEY or not secrets.compare_digest(x_api_key, TEECUP_API_KEY): + raise HTTPException(status_code=401, detail="Ugyldig eller manglende API-nøkkel") + PUBLIC_FACILITIES_CACHE_TTLS = { "search": 900, "home": 900, @@ -5006,6 +5016,84 @@ async def get_facility(slug: str, response: Response): return payload +@app.get("/api/hole-points/poi-types", dependencies=[Depends(require_teecup_api_key)]) +async def get_hole_points_poi_types(): + """ + Levende (ikke statisk) liste over alle poi_type-verdier som faktisk finnes + i hole_points akkurat nå, med antall forekomster. Til bruk for eksterne + konsumenter (f.eks. TeeCup) som vil kunne oppdage nye kategorier automatisk + i stedet for at en oversettelse knekker stille når en ny type dukker opp. + """ + async with app.state.pool.acquire() as conn: + rows = await conn.fetch( + "SELECT poi_type, count(*) AS count FROM hole_points GROUP BY poi_type ORDER BY poi_type" + ) + return [{"poi_type": row["poi_type"], "count": int(row["count"])} for row in rows] + + +@app.get("/api/facilities/{slug}/courses/{course_id}/hole-points", dependencies=[Depends(require_teecup_api_key)]) +async def get_course_hole_points(slug: str, course_id: int): + """ + Flat liste med koordinatpunkter per hull (green/bunker/vann/tre/tee osv.) + for én bane, til bruk for eksterne prosjekter som slår opp banen via + facility-slug (f.eks. TeeCup sin GPS-/avstandsfunksjon). Taksonomien er + dokumentert i migrations/2026-08-05_add_hole_points.sql og + migrations/2026-08-06_add_hole_points_distance_meters.sql. + """ + normalized_slug = str(slug or "").strip().lower() + async with app.state.pool.acquire() as conn: + course_row = await conn.fetchrow( + """ + SELECT c.id + FROM courses c + JOIN facilities f ON f.id = c.facility_id + WHERE f.slug = $1 + AND f.is_published IS DISTINCT FROM FALSE + AND c.id = $2 + AND COALESCE(c.is_visible, TRUE) = TRUE + """, + normalized_slug, + course_id, + ) + if not course_row: + raise HTTPException(status_code=404, detail="Banen ble ikke funnet på dette anlegget") + + point_rows = await conn.fetch( + """ + SELECT + h.hole_number, + hp.poi_type, + hp.location, + hp.side_fairway, + ST_Y(hp.geom::geometry) AS latitude, + ST_X(hp.geom::geometry) AS longitude, + hp.feature_group, + hp.distance_meters, + hp.note + FROM hole_points hp + JOIN holes h ON h.id = hp.hole_id + WHERE h.course_id = $1 + ORDER BY h.hole_number ASC, hp.id ASC + """, + course_id, + ) + + return [ + { + "hole_number": int(row["hole_number"]), + "poi_type": row["poi_type"], + "location": row["location"], + "side_fairway": row["side_fairway"], + "latitude": row["latitude"], + "longitude": row["longitude"], + "feature_group": row["feature_group"], + "distance_meters": row["distance_meters"], + "note": row["note"], + } + for row in point_rows + ] + + @app.get("/api/place-pages/{slug}") async def get_place_page(slug: str, response: Response): normalized_slug = str(slug or "").strip().lower() diff --git a/deploy/Caddyfile b/deploy/Caddyfile index 7886a2b..4cba826 100644 --- a/deploy/Caddyfile +++ b/deploy/Caddyfile @@ -21,6 +21,26 @@ teecup.golf, www.teecup.golf { format console } + # Sikkerhetshoder (sikkerhetsgjennomgang 2026-08-09, se + # ARCHITECTURE_DECISIONS.md-tillegg). CSP-en er bevisst IKKE en streng + # nonce-basert policy -- appen bruker inline `style`-attributter mange + # steder (React `style={{...}}`, bl.a. den "clubhouse"-pallet-tonede + # UI-en) og Next.js sin egen hydrerings-oppstart kan trenge inline + # script, så 'unsafe-inline' er beholdt for script-src/style-src for å + # unngå å knekke appen blindt. Verdien ligger likevel i det som ER + # strammet inn: object-src/frame-ancestors/base-uri blokkerer hele + # klasser av angrep, og connect-src/img-src er begrenset til KUN de + # eksterne vertene appen faktisk bruker (Mapbox for slagmåling-kartet, + # ADR-048) -- en fremtidig XSS-bug kan dermed ikke enkelt eksfiltrere + # data til en vilkårlig tredjeparts-vert. + header { + X-Frame-Options "DENY" + X-Content-Type-Options "nosniff" + Referrer-Policy "strict-origin-when-cross-origin" + Strict-Transport-Security "max-age=31536000; includeSubDomains" + Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://api.mapbox.com; connect-src 'self' https://api.mapbox.com https://events.mapbox.com wss://teecup.golf; worker-src 'self' blob:; frame-ancestors 'none'; base-uri 'self'; object-src 'none'" + } + # Offentlig, anonym lesing av opplastede bilder (MinIO, public-read # bucket-policy) -- bucket-navnet ligger i selve stien (path-style # adressering), derav /teecup-media/* som prefiks, ikke et eget diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 6e5b233..f4f86b8 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -28,6 +28,7 @@ services: PUBLIC_FROM_EMAIL: ${PUBLIC_FROM_EMAIL} PUBLIC_MAGIC_LINK_MAX_AGE_MINUTES: ${PUBLIC_MAGIC_LINK_MAX_AGE_MINUTES} INDEXNOW_KEY: ${INDEXNOW_KEY} + TEECUP_API_KEY: ${TEECUP_API_KEY} volumes: - ./backend:/app - ./frontend/src/content:/shared/frontend-content:ro diff --git a/docker-compose.yml b/docker-compose.yml index cda89d7..0362c5a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,6 +30,7 @@ services: PUBLIC_FROM_EMAIL: ${PUBLIC_FROM_EMAIL} PUBLIC_MAGIC_LINK_MAX_AGE_MINUTES: ${PUBLIC_MAGIC_LINK_MAX_AGE_MINUTES} INDEXNOW_KEY: ${INDEXNOW_KEY} + TEECUP_API_KEY: ${TEECUP_API_KEY} ports: - "8001:8000" volumes: diff --git a/migrations/2026-08-05_add_hole_points.sql b/migrations/2026-08-05_add_hole_points.sql new file mode 100644 index 0000000..e59eb55 --- /dev/null +++ b/migrations/2026-08-05_add_hole_points.sql @@ -0,0 +1,27 @@ +-- Koordinat-punkter per hull (green/bunker/vann/tre/tee osv.), for GPS-/ +-- avstandsfunksjoner og for å kunne eksporteres til andre prosjekter +-- (f.eks. TeeCup) som slår opp anlegg via facility-slug. +-- +-- Taksonomi (poi_type / location / side_fairway) er bevisst holdt som fri +-- tekst, ikke en Postgres ENUM, i tråd med resten av kodebasens mønster +-- for kategori-felt (f.eks. banetype) -- listen med forventede verdier +-- dokumenteres her, ikke håndheves i databasen: +-- +-- poi_type: green, bunker_fairway, bunker_green, vann, trær, stein, +-- landemerke, vei, dogleg, tee, avstandsmarkør +-- location: front, midt, bak (nullable -- ikke alle poi_type har dette) +-- side_fairway: venstre, senter, høyre (nullable -- ikke alle poi_type har dette) + +CREATE TABLE IF NOT EXISTS hole_points ( + id SERIAL PRIMARY KEY, + hole_id INTEGER NOT NULL REFERENCES holes(id) ON DELETE CASCADE, + poi_type VARCHAR(50) NOT NULL, + location VARCHAR(20), + side_fairway VARCHAR(20), + geom GEOGRAPHY(Point, 4326) NOT NULL, + note TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS hole_points_hole_id_idx ON hole_points (hole_id); +CREATE INDEX IF NOT EXISTS hole_points_geom_gix ON hole_points USING GIST (geom); diff --git a/migrations/2026-08-06_add_hole_points_distance_meters.sql b/migrations/2026-08-06_add_hole_points_distance_meters.sql new file mode 100644 index 0000000..7a169e3 --- /dev/null +++ b/migrations/2026-08-06_add_hole_points_distance_meters.sql @@ -0,0 +1,7 @@ +-- Valgfri avstandsverdi for punkter som representerer en fysisk avstandsmarkør +-- (f.eks. en 100/150/200-meter-plate). NULL for alle andre poi_type, og NULL +-- for eksisterende avstandsmarkør-punkter -- disse representerer i dag "start +-- av fairway (landingssone)", ikke en tallmerket avstandsplate, så det finnes +-- ingen kildedata å fylle inn ennå. + +ALTER TABLE hole_points ADD COLUMN IF NOT EXISTS distance_meters INTEGER; diff --git a/migrations/2026-08-06_add_hole_points_feature_group.sql b/migrations/2026-08-06_add_hole_points_feature_group.sql new file mode 100644 index 0000000..6568b38 --- /dev/null +++ b/migrations/2026-08-06_add_hole_points_feature_group.sql @@ -0,0 +1,9 @@ +-- Grupperer forkant/bakkant-punkt (og andre par) som hører til samme +-- fysiske objekt, f.eks. hvilken "forkant" og "bakkant" som utgjør én og +-- samme fairwaybunker når et hull har flere bunkere av samme type/side. +-- Tallverdien er meningsfull kun innenfor ett hull (hole_id), ikke globalt +-- -- kildedata er kildearkets egen "ID"-kolonne per hull, videreført as-is. +-- NULL betyr at punktet er entydig alene på hullet (f.eks. Tee, Green, +-- Dogleg) og ikke trenger gruppering. + +ALTER TABLE hole_points ADD COLUMN IF NOT EXISTS feature_group INTEGER;