Før koordinater-grensesnitt.

This commit is contained in:
Erol Haagenrud 2026-09-01 05:11:26 +02:00
parent 1da3e32a8a
commit 8df889cc00
7 changed files with 154 additions and 1 deletions

View file

@ -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 , 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()

View file

@ -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

View file

@ -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

View file

@ -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:

View file

@ -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);

View file

@ -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;

View file

@ -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;