Nye-TeeOff/backend/main.py

250 lines
8.8 KiB
Python
Raw Normal View History

"""
2026-03-02 09:56:37 +01:00
TEE OFF BACKEND API v3.6.8 - THE RESTORED 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.
2026-03-02 09:56:37 +01:00
LOV: Aldri trunker eller slett logikk for "effektivitet".
---------------------------------------------------------------------------
"""
from fastapi import FastAPI, HTTPException, Response, Cookie, Depends, Request
2026-02-26 09:20:51 +01:00
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()
2026-02-26 09:20:51 +01:00
2026-02-27 09:35:30 +01:00
# --- 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"
2026-03-02 09:56:37 +01:00
# VIKTIG: Vi bruker PBKDF2-SHA256 for å unngå Bcrypt-begrensninger
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
2026-02-26 09:20:51 +01:00
2026-02-27 09:35:30 +01:00
def format_row(row):
"""
Vasker data fra databasen:
1. Konverterer datoer til ISO-format.
2026-03-02 09:56:37 +01:00
2. Tvinger tekst-JSON (stringified JSON) over til ekte Python objekter/lister.
3. Sikrer at lister og objekter aldri er None for å hindre Frontend-krasj.
2026-02-27 09:35:30 +01:00
"""
if row is None:
return None
d = dict(row)
2026-03-02 09:56:37 +01:00
# 1. Håndter dato- og tidsformater for JSON-serialisering
2026-02-27 09:35:30 +01:00
for key in ['status_updated_at', 'created_at']:
if isinstance(d.get(key), (date, datetime)):
d[key] = d[key].isoformat()
2026-03-02 09:56:37 +01:00
# 2. Definer alle felter som inneholder JSON-data
2026-02-27 09:35:30 +01:00
json_list_fields = [
'course_statuses', 'courses', 'gallery', 'greenfee',
'faqs', 'shotzoom', 'social_links', 'holes'
]
2026-03-02 09:56:37 +01:00
json_dict_fields = [
'amenities', 'vtg', 'nsg_data', 'golfamore_data'
]
# Vask list-felter
2026-02-27 09:35:30 +01:00
for field in json_list_fields:
if field in d:
val = d[field]
2026-03-02 09:56:37 +01:00
if val is None:
d[field] = []
2026-02-27 09:35:30 +01:00
elif isinstance(val, str):
2026-03-02 09:56:37 +01:00
try:
d[field] = json.loads(val)
except:
d[field] = []
elif not isinstance(val, list):
d[field] = []
# Vask objekt-felter
2026-02-27 09:35:30 +01:00
for field in json_dict_fields:
if field in d:
val = d[field]
2026-03-02 09:56:37 +01:00
if val is None:
d[field] = {}
2026-02-27 09:35:30 +01:00
elif isinstance(val, str):
2026-03-02 09:56:37 +01:00
try:
d[field] = json.loads(val)
except:
d[field] = {}
elif not isinstance(val, dict):
d[field] = {}
2026-02-27 09:35:30 +01:00
return d
2026-02-26 09:20:51 +01:00
@asynccontextmanager
async def lifespan(app: FastAPI):
2026-03-02 09:56:37 +01:00
# Opprett database-pool ved start
2026-02-27 08:53:14 +01:00
try:
2026-03-02 09:56:37 +01:00
print(f"📡 Forsøker å koble til database på: {DB_URL}")
2026-02-27 09:35:30 +01:00
app.state.pool = await asyncpg.create_pool(
2026-03-02 09:56:37 +01:00
DB_URL,
min_size=5,
max_size=20,
command_timeout=60
2026-02-27 09:35:30 +01:00
)
2026-03-02 09:56:37 +01:00
print("✅ Database tilkoblet og pool opprettet")
2026-02-27 08:53:14 +01:00
except Exception as e:
2026-03-02 09:56:37 +01:00
print(f"❌ Databasefeil under oppstart: {e}")
2026-02-27 08:53:14 +01:00
raise e
2026-02-26 09:20:51 +01:00
yield
2026-03-02 09:56:37 +01:00
# Lukk pool ved avslutning
2026-02-26 09:20:51 +01:00
await app.state.pool.close()
2026-03-02 09:56:37 +01:00
app = FastAPI(title="TeeOff API v3.6.8", lifespan=lifespan)
2026-02-27 08:53:14 +01:00
# CORS - Tillater både lokal utvikling og produksjonsdomene
2026-02-27 08:53:14 +01:00
app.add_middleware(
CORSMiddleware,
allow_origins=[
"https://nye.teeoff.no",
"http://nye.teeoff.no",
"http://localhost:3000"
],
allow_credentials=True,
2026-02-27 08:53:14 +01:00
allow_methods=["*"],
allow_headers=["*"],
)
2026-02-26 09:20:51 +01:00
# --- AUTH ENDPOINTS ---
@app.post("/api/auth/login")
async def login(data: dict):
"""Steg 1: Sjekk passord og returner temp_token for 2FA."""
2026-03-02 09:56:37 +01:00
print(f"🔐 Login-forsøk for: {data.get('username')}")
async with app.state.pool.acquire() as conn:
admin = await conn.fetchrow(
"SELECT * FROM admins WHERE username = $1 OR email = $1",
data.get('username')
)
2026-03-02 09:56:37 +01:00
if not admin:
print(" - ❌ Bruker ikke funnet i databasen")
raise HTTPException(status_code=401, detail="Ugyldig brukernavn eller passord")
2026-03-02 09:56:37 +01:00
# DEBUG: Printer starten på hashen for å verifisere formatet ($pbkdf2-sha256$...)
h = admin['password_hash']
print(f" - Verifiserer hash i DB (starter med: {h[:20]}...)")
try:
if not pwd_context.verify(data.get('password'), h):
print(" - ❌ Passordet samsvarer ikke med hashen")
raise HTTPException(status_code=401, detail="Ugyldig brukernavn eller passord")
except Exception as e:
print(f" - 🔥 FEIL VED VERIFISERING: {e}")
raise HTTPException(status_code=500, detail="Internt problem med passord-format")
temp_token = jwt.encode(
{"sub": admin['username'], "partial": True, "exp": datetime.utcnow() + timedelta(minutes=5)},
SECRET_KEY, algorithm=ALGORITHM
)
2026-03-02 09:56:37 +01:00
print(" - ✅ Steg 1 fullført. Temp-token generert.")
return {"step": "2fa", "temp_token": temp_token}
@app.post("/api/auth/verify-2fa")
async def verify_2fa(data: dict, response: Response):
2026-03-02 09:56:37 +01:00
"""Steg 2: Verifiser TOTP-kode og sett session cookie."""
try:
payload = jwt.decode(data.get('temp_token'), SECRET_KEY, algorithms=[ALGORITHM])
2026-03-02 09:56:37 +01:00
if not payload.get("partial"):
raise JWTError()
username = payload.get("sub")
2026-03-02 09:56:37 +01:00
except JWTError:
raise HTTPException(status_code=401, detail="Sesjonen har utløpt eller er ugyldig")
async with app.state.pool.acquire() as conn:
admin = await conn.fetchrow("SELECT otp_secret FROM admins WHERE username = $1", username)
2026-03-02 09:56:37 +01:00
totp = pyotp.TOTP(admin['otp_secret'])
if not totp.verify(data.get('code')):
2026-03-02 09:56:37 +01:00
print(f" - ❌ Feil 2FA-kode oppgitt for {username}")
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
)
2026-03-02 09:56:37 +01:00
# Sett som HTTP-only cookie
response.set_cookie(
2026-03-02 09:56:37 +01:00
key="admin_session",
value=final_token,
httponly=True,
samesite="lax",
secure=False # Sett til True i produksjon (HTTPS)
)
return {"status": "success"}
# --- DATA ENDPOINTS ---
2026-02-26 09:20:51 +01:00
@app.get("/api/facilities")
async def get_facilities():
2026-03-02 09:56:37 +01:00
"""Henter alle golfanlegg med aggregert banestatus for forsiden."""
2026-02-26 09:20:51 +01:00
async with app.state.pool.acquire() as conn:
rows = await conn.fetch("""
SELECT f.*, (
SELECT jsonb_agg(cs) FROM (
SELECT name, status FROM courses
2026-02-27 08:53:14 +01:00
WHERE facility_id = f.id AND status != 'finnes_ingen_bane_to'
2026-02-26 09:20:51 +01:00
ORDER BY is_main_course DESC, id ASC
) cs
) as course_statuses
2026-02-27 09:35:30 +01:00
FROM facilities f
ORDER BY f.name ASC
2026-02-26 09:20:51 +01:00
""")
return [format_row(row) for row in rows]
@app.get("/api/facilities/{slug}")
async def get_facility(slug: str):
2026-03-02 09:56:37 +01:00
"""Henter detaljer for ett spesifikt golfanlegg inkludert alle baner og hull."""
2026-02-26 09:20:51 +01:00
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
2026-02-27 08:53:14 +01:00
AND (c.is_main_course = true OR (c.status NOT IN ('finnes_ingen_bane_to', 'ukjent')))
2026-02-26 09:20:51 +01:00
ORDER BY c.is_main_course DESC, c.id ASC
) c_data
) as courses
FROM facilities f WHERE f.slug = $1
""", slug)
2026-02-27 09:35:30 +01:00
if not row:
2026-03-02 09:56:37 +01:00
raise HTTPException(status_code=404, detail="Golfanlegget ble ikke funnet")
2026-02-27 09:35:30 +01:00
return format_row(row)
@app.get("/api/health")
async def health_check():
2026-03-02 09:56:37 +01:00
"""Enkel sjekk for å se at API og DB lever."""
try:
async with app.state.pool.acquire() as conn:
await conn.execute("SELECT 1")
return {"status": "healthy", "database": "connected"}
except Exception as e:
return {"status": "unhealthy", "error": str(e)}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)