""" TEE OFF BACKEND API v3.6.5 - THE FINAL MASTER VERSION --------------------------------------------------------------------------- REGEL 1: Bruk str (ikke string) for type-hinting. REGEL 2: Inkluder alle subqueries for banestatus og hull-data. REGEL 3: Robust JSON-parsing (format_row) for å hindre Frontend-krasj. REGEL 4: JWT-sesjoner lagres i HTTP-only cookies. --------------------------------------------------------------------------- """ from fastapi import FastAPI, HTTPException, Response, Cookie, Depends, Request from fastapi.middleware.cors import CORSMiddleware from contextlib import asynccontextmanager import asyncpg import json import pyotp import os from datetime import datetime, date, timedelta from jose import jwt, JWTError from passlib.context import CryptContext from dotenv import load_dotenv load_dotenv() # --- KONFIGURASJON --- DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff") SECRET_KEY = os.getenv("JWT_SECRET", "super_secret_change_this_in_production") ALGORITHM = "HS256" pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto") def format_row(row): """ Vasker data fra databasen: 1. Konverterer datoer til ISO-format. 2. Parser stringified JSON til ekte Python-objekter. """ if row is None: return None d = dict(row) # 1. Datoer for key in ['status_updated_at', 'created_at']: if isinstance(d.get(key), (date, datetime)): d[key] = d[key].isoformat() # 2. JSON-felter (Lister) json_list_fields = [ 'course_statuses', 'courses', 'gallery', 'greenfee', 'faqs', 'shotzoom', 'social_links', 'holes' ] for field in json_list_fields: if field in d: val = d[field] if val is None: d[field] = [] elif isinstance(val, str): try: d[field] = json.loads(val) except: d[field] = [] elif not isinstance(val, list): d[field] = [] # 3. JSON-felter (Objekter) json_dict_fields = ['amenities', 'vtg', 'nsg_data', 'golfamore_data'] for field in json_dict_fields: if field in d: val = d[field] if val is None: d[field] = {} elif isinstance(val, str): try: d[field] = json.loads(val) except: d[field] = {} elif not isinstance(val, dict): d[field] = {} return d @asynccontextmanager async def lifespan(app: FastAPI): # Opprett database-pool try: app.state.pool = await asyncpg.create_pool( DB_URL, min_size=5, max_size=20, command_timeout=60 ) print("✅ Database pool opprettet") except Exception as e: print(f"❌ Databasefeil: {e}") raise e yield await app.state.pool.close() app = FastAPI(title="TeeOff API v3.6.5", lifespan=lifespan) # CORS - Tillater både lokal utvikling og produksjonsdomene app.add_middleware( CORSMiddleware, allow_origins=[ "https://nye.teeoff.no", "http://nye.teeoff.no", "http://localhost:3000" ], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # --- AUTH ENDPOINTS --- @app.post("/api/auth/login") async def login(data: dict): """Steg 1: Sjekk passord og returner temp_token for 2FA.""" async with app.state.pool.acquire() as conn: admin = await conn.fetchrow( "SELECT * FROM admins WHERE username = $1 OR email = $1", data.get('username') ) if not admin or not pwd_context.verify(data.get('password'), admin['password_hash']): 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)}, 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): """Steg 2: Sjekk TOTP og sett session cookie.""" try: payload = jwt.decode(data.get('temp_token'), SECRET_KEY, algorithms=[ALGORITHM]) username = payload.get("sub") except: raise HTTPException(status_code=401, detail="Sesjonen har utløpt") async with app.state.pool.acquire() as conn: admin = await conn.fetchrow("SELECT otp_secret FROM admins WHERE username = $1", username) totp = pyotp.TOTP(admin['otp_secret']) if not totp.verify(data.get('code')): raise HTTPException(status_code=401, detail="Feil 2FA-kode") final_token = jwt.encode( {"sub": username, "exp": datetime.utcnow() + timedelta(hours=12)}, SECRET_KEY, algorithm=ALGORITHM ) response.set_cookie( key="admin_session", value=final_token, httponly=True, samesite="lax", secure=False # False for utvikling ) return {"status": "success"} # --- DATA ENDPOINTS --- @app.get("/api/facilities") async def get_facilities(): """Henter alle anlegg med aggregert banestatus for kortene.""" async with app.state.pool.acquire() as conn: rows = await conn.fetch(""" SELECT f.*, ( SELECT jsonb_agg(cs) FROM ( SELECT name, status FROM courses WHERE facility_id = f.id AND status != 'finnes_ingen_bane_to' ORDER BY is_main_course DESC, id ASC ) cs ) as course_statuses FROM facilities f ORDER BY f.name ASC """) return [format_row(row) for row in rows] @app.get("/api/facilities/{slug}") async def get_facility(slug: str): """Henter ett anlegg med alle baner og hull (brukes i FacilityDetailView).""" async with app.state.pool.acquire() as conn: row = await conn.fetchrow(""" SELECT f.*, ( SELECT jsonb_agg(c_data) FROM ( SELECT c.*, ( SELECT jsonb_agg(h_data ORDER BY h_data.hole_number ASC) FROM (SELECT * FROM holes WHERE course_id = c.id) h_data ) as holes FROM courses c WHERE c.facility_id = f.id AND (c.is_main_course = true OR (c.status NOT IN ('finnes_ingen_bane_to', 'ukjent'))) ORDER BY c.is_main_course DESC, c.id ASC ) c_data ) as courses FROM facilities f WHERE f.slug = $1 """, slug) if not row: raise HTTPException(status_code=404, detail="Banen finnes ikke") return format_row(row) @app.get("/api/health") async def health_check(): return {"status": "healthy"}