diff --git a/.gitignore b/.gitignore index 3bbe7b6..9460c99 100755 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ __pycache__/ *.pyc *.pyo +.env diff --git a/backend/main.py b/backend/main.py index dfff67c..5ebc5df 100644 --- a/backend/main.py +++ b/backend/main.py @@ -13,21 +13,28 @@ from fastapi import FastAPI, HTTPException, Response, Request, Query from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from contextlib import asynccontextmanager +import asyncio import asyncpg import json import pyotp import os import re +import secrets +import hashlib +import smtplib from datetime import datetime, date, timedelta +from email.message import EmailMessage from pathlib import Path from jose import jwt, JWTError from passlib.context import CryptContext from dotenv import load_dotenv import qrcode import qrcode.image.svg +import httpx from pydantic import BaseModel from typing import Optional, List, Any +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit from scrape_jobs import ( SCRAPE_JOB_TYPES, @@ -42,10 +49,213 @@ load_dotenv() 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" +PUBLIC_SESSION_SECRET = os.getenv("PUBLIC_SESSION_SECRET", SECRET_KEY) +PUBLIC_SESSION_COOKIE = "teeoff_user_session" +PUBLIC_SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 30 +PUBLIC_COMMENT_STATUSES = {"pending", "published", "rejected", "deleted"} +PUBLIC_COMMENT_DEFAULT_STATUS = ( + os.getenv("PUBLIC_COMMENT_DEFAULT_STATUS", "published").strip().lower() + if os.getenv("PUBLIC_COMMENT_DEFAULT_STATUS", "published").strip().lower() in PUBLIC_COMMENT_STATUSES + else "published" +) +GOOGLE_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID", "").strip() +GOOGLE_CLIENT_SECRET = os.getenv("GOOGLE_CLIENT_SECRET", "").strip() +GOOGLE_OAUTH_SCOPES = os.getenv("GOOGLE_OAUTH_SCOPES", "openid email profile").strip() +GOOGLE_DISCOVERY_URL = os.getenv( + "GOOGLE_DISCOVERY_URL", + "https://accounts.google.com/.well-known/openid-configuration", +).strip() +SMTP_SERVER = os.getenv("SMTP_SERVER", "").strip() +SMTP_PORT = os.getenv("SMTP_PORT", "").strip() +SMTP_USER = os.getenv("SMTP_USER", "").strip() +SMTP_PASS = os.getenv("SMTP_PASS", "").strip() +PUBLIC_FROM_EMAIL = os.getenv("PUBLIC_FROM_EMAIL", SMTP_USER).strip() pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto") +def get_int_env(name: str, default: int) -> int: + raw = str(os.getenv(name, str(default))).strip() + try: + return int(raw) + except ValueError: + return default + + +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) + + +def resolve_imported_meninger_path() -> Path: + candidates: list[Path] = [] + + env_path = os.getenv("IMPORTED_MENINGER_PATH") + if env_path: + candidates.append(Path(env_path)) + + candidates.extend( + [ + Path("/opt/teeoff/frontend/src/content/importedMeninger.json"), + Path("/shared/frontend-content/importedMeninger.json"), + Path(__file__).resolve().parent.parent / "frontend" / "src" / "content" / "importedMeninger.json", + ] + ) + + for candidate in candidates: + if candidate.exists(): + return candidate + + raise HTTPException( + status_code=404, + detail=( + "Fant ikke importedMeninger.json. Sjekk at frontend/src/content er tilgjengelig " + "for API-et eller sett IMPORTED_MENINGER_PATH." + ), + ) + + +def is_google_login_configured() -> bool: + return bool(GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET) + + +def is_magic_link_configured() -> bool: + return bool(SMTP_SERVER and SMTP_PORT and SMTP_USER and SMTP_PASS and PUBLIC_FROM_EMAIL) + + +def get_public_auth_config() -> dict[str, Any]: + google_enabled = is_google_login_configured() + magic_link_enabled = is_magic_link_configured() + return { + "configured": google_enabled or magic_link_enabled, + "google": google_enabled, + "magic_link": magic_link_enabled, + } + + +def build_public_base_url(request: Request) -> str: + configured = os.getenv("PUBLIC_BASE_URL", "").strip().rstrip("/") + if configured: + return configured + + forwarded_proto = request.headers.get("x-forwarded-proto") + forwarded_host = request.headers.get("x-forwarded-host") + if forwarded_proto and forwarded_host: + return f"{forwarded_proto}://{forwarded_host}".rstrip("/") + + return str(request.base_url).rstrip("/") + + +def build_google_redirect_uri(request: Request) -> str: + return f"{build_public_base_url(request)}/api/public/auth/google/callback" + + +def should_use_secure_cookies(request: Request) -> bool: + configured = os.getenv("PUBLIC_BASE_URL", "").strip().lower() + if configured.startswith("https://"): + return True + + forwarded_proto = request.headers.get("x-forwarded-proto", "").split(",")[0].strip().lower() + if forwarded_proto: + return forwarded_proto == "https" + + return request.url.scheme == "https" + + +async def get_google_discovery_document() -> dict[str, Any]: + async with httpx.AsyncClient(timeout=20.0) as client: + response = await client.get(GOOGLE_DISCOVERY_URL) + response.raise_for_status() + return response.json() + + +def normalize_return_to_path(value: str | None) -> str: + candidate = str(value or "/").strip() + if not candidate.startswith("/"): + return "/" + if candidate.startswith("//"): + return "/" + return candidate or "/" + + +def append_query_param(url: str, key: str, value: str) -> str: + parsed = urlsplit(url) + query_params = dict(parse_qsl(parsed.query, keep_blank_values=True)) + query_params[key] = value + return urlunsplit( + ( + parsed.scheme, + parsed.netloc, + parsed.path, + urlencode(query_params), + parsed.fragment, + ) + ) + + +def create_public_session_token(user_id: int) -> str: + expires_at = datetime.utcnow() + timedelta(seconds=PUBLIC_SESSION_MAX_AGE_SECONDS) + return jwt.encode( + { + "sub": f"public:{user_id}", + "uid": user_id, + "exp": expires_at, + }, + PUBLIC_SESSION_SECRET, + algorithm=ALGORITHM, + ) + + +def decode_public_session_token(token: str) -> dict[str, Any]: + try: + payload = jwt.decode(token, PUBLIC_SESSION_SECRET, algorithms=[ALGORITHM]) + if not payload.get("uid"): + raise JWTError() + return payload + except JWTError as exc: + raise HTTPException(status_code=401, detail="Ugyldig eller utløpt brukerøkt") from exc + + +def hash_request_ip(request: Request) -> str | None: + forwarded_for = request.headers.get("x-forwarded-for", "") + ip = forwarded_for.split(",")[0].strip() if forwarded_for else (request.client.host if request.client else "") + if not ip: + return None + return hashlib.sha256(ip.encode("utf-8")).hexdigest() + + +def hash_magic_link_token(token: str) -> str: + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + +def normalize_public_email(email: str | None) -> str | None: + normalized = str(email or "").strip().lower() + return normalized or None + + +async def send_magic_link_email(email_address: str, login_url: str) -> None: + subject = "Logg inn på TeeOff" + body = ( + "Hei,\n\n" + "Klikk på lenken under for å logge inn på TeeOff og kommentere:\n\n" + f"{login_url}\n\n" + f"Lenken utløper om {PUBLIC_MAGIC_LINK_MAX_AGE_MINUTES} minutter.\n\n" + "Hvis du ikke ba om denne lenken, kan du se bort fra e-posten.\n" + ) + + def _send() -> None: + message = EmailMessage() + message["From"] = PUBLIC_FROM_EMAIL + message["To"] = email_address + message["Subject"] = subject + message.set_content(body) + + with smtplib.SMTP_SSL(SMTP_SERVER, int(SMTP_PORT)) as server: + server.login(SMTP_USER, SMTP_PASS) + server.send_message(message) + + await asyncio.to_thread(_send) + + async def validate_admin_session_token(token: str) -> str: try: payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) @@ -107,6 +317,7 @@ class AdminPasswordConfirm(BaseModel): class ArticleUpsertRequest(BaseModel): + section: Optional[str] = "banebesok" slug: str title: str description: Optional[str] = None @@ -123,6 +334,15 @@ class ArticleUpsertRequest(BaseModel): source_label: Optional[str] = None published_at: Optional[str] = None updated_at: Optional[str] = None + + +class PublicCommentCreateRequest(BaseModel): + body: str + + +class PublicMagicLinkRequest(BaseModel): + email: str + return_to: Optional[str] = "/" # --- FUNKSJONER --- def format_row(row): """ @@ -200,6 +420,9 @@ def format_article_row(row): if isinstance(data.get(key), (date, datetime)): data[key] = data[key].isoformat() + section = str(data.get("section") or "banebesok").strip().lower() + data["section"] = section if section in {"banebesok", "meninger"} else "banebesok" + hero_images = data.get("hero_images") if hero_images is None: data["hero_images"] = [] @@ -221,6 +444,19 @@ def normalize_article_status(status: str | None) -> str: return normalized +def normalize_article_section(section: str | None, allow_all: bool = False) -> str: + normalized = str(section or "banebesok").strip().lower() + valid_sections = {"banebesok", "meninger"} + if allow_all: + valid_sections = valid_sections | {"all"} + if normalized not in valid_sections: + raise HTTPException( + status_code=400, + detail="Ugyldig artikkelseksjon. Bruk 'banebesok' eller 'meninger'.", + ) + return normalized + + def parse_optional_datetime(value: str | None) -> datetime | None: if value is None: return None @@ -260,6 +496,244 @@ def humanize_slug(slug: str | None) -> str: return " ".join(part.capitalize() for part in str(slug).split("-") if part) +def resolve_imported_article_section(item: dict[str, Any]) -> tuple[str, str]: + category_slugs = { + str(slug).strip().lower() + for slug in (item.get("categorySlugs") or []) + if str(slug or "").strip() + } + categories = item.get("categories") or [] + + if "banebesok" in category_slugs: + return "banebesok", "Banebesøk" + + if "siste-nytt" in category_slugs: + return "meninger", "Siste nytt" + + for category in categories: + if not isinstance(category, dict): + continue + label = str(category.get("name") or "").strip() + if label: + return "meninger", label + + return "meninger", "Meninger" + + +def format_public_user_row(row: Any) -> dict[str, Any] | None: + if row is None: + return None + + data = dict(row) + for key in ["created_at", "updated_at", "last_login_at", "email_verified_at"]: + if isinstance(data.get(key), (date, datetime)): + data[key] = data[key].isoformat() + + return { + "id": data.get("id"), + "vipps_sub": data.get("vipps_sub"), + "google_sub": data.get("google_sub"), + "full_name": data.get("full_name"), + "given_name": data.get("given_name"), + "family_name": data.get("family_name"), + "email": data.get("email"), + "phone_number": data.get("phone_number"), + "display_name": data.get("display_name"), + "is_blocked": bool(data.get("is_blocked")), + "email_verified_at": data.get("email_verified_at"), + "created_at": data.get("created_at"), + "updated_at": data.get("updated_at"), + "last_login_at": data.get("last_login_at"), + } + + +def format_comment_row(row: Any) -> dict[str, Any] | None: + if row is None: + return None + + data = dict(row) + for key in ["created_at", "updated_at", "published_at"]: + if isinstance(data.get(key), (date, datetime)): + data[key] = data[key].isoformat() + + return { + "id": data.get("id"), + "article_id": data.get("article_id"), + "user_id": data.get("user_id"), + "parent_id": data.get("parent_id"), + "body": data.get("body"), + "status": data.get("status"), + "created_at": data.get("created_at"), + "updated_at": data.get("updated_at"), + "published_at": data.get("published_at"), + "author": { + "display_name": data.get("display_name") or data.get("full_name") or "TeeOff-leser", + "given_name": data.get("given_name"), + }, + "is_pending_for_viewer": data.get("status") == "pending", + } + + +async def find_public_user_by_email(conn, email: str | None): + normalized_email = normalize_public_email(email) + if not normalized_email: + return None + return await conn.fetchrow( + """ + SELECT * + FROM public_users + WHERE LOWER(email) = $1 + ORDER BY id ASC + LIMIT 1 + """, + normalized_email, + ) + + +async def upsert_public_user_from_google_profile(conn, profile: dict[str, Any]): + google_sub = str(profile.get("sub") or "").strip() + if not google_sub: + raise HTTPException(status_code=500, detail="Google svarte uten bruker-ID.") + + email = normalize_public_email(profile.get("email")) + if not email: + raise HTTPException(status_code=400, detail="Google-kontoen mangler e-postadresse.") + + full_name = str(profile.get("name") or "").strip() or None + given_name = str(profile.get("given_name") or "").strip() or None + family_name = str(profile.get("family_name") or "").strip() or None + email_verified = bool(profile.get("email_verified")) + default_display_name = full_name or given_name or email.split("@")[0] or "TeeOff-leser" + + existing = await conn.fetchrow("SELECT * FROM public_users WHERE google_sub = $1", google_sub) + if not existing: + existing = await find_public_user_by_email(conn, email) + + if existing: + return await conn.fetchrow( + """ + UPDATE public_users + SET + google_sub = COALESCE(public_users.google_sub, $2), + email = $3, + full_name = $4, + given_name = $5, + family_name = $6, + display_name = COALESCE(public_users.display_name, $7), + email_verified_at = CASE + WHEN $8 THEN COALESCE(public_users.email_verified_at, NOW()) + ELSE public_users.email_verified_at + END, + updated_at = NOW(), + last_login_at = NOW() + WHERE id = $1 + RETURNING * + """, + existing["id"], + google_sub, + email, + full_name, + given_name, + family_name, + default_display_name, + email_verified, + ) + + return await conn.fetchrow( + """ + INSERT INTO public_users ( + google_sub, email, full_name, given_name, family_name, display_name, + email_verified_at, last_login_at + ) VALUES ( + $1, $2, $3, $4, $5, $6, + CASE WHEN $7 THEN NOW() ELSE NULL END, NOW() + ) + RETURNING * + """, + google_sub, + email, + full_name, + given_name, + family_name, + default_display_name, + email_verified, + ) + + +async def get_or_create_public_user_by_email(conn, email: str): + normalized_email = normalize_public_email(email) + if not normalized_email: + raise HTTPException(status_code=400, detail="Ugyldig e-postadresse.") + + existing = await find_public_user_by_email(conn, normalized_email) + if existing: + return existing + + return await conn.fetchrow( + """ + INSERT INTO public_users ( + email, display_name + ) VALUES ( + $1, $2 + ) + RETURNING * + """, + normalized_email, + normalized_email.split("@")[0] or "TeeOff-leser", + ) + + +async def get_authenticated_public_user(request: Request) -> dict[str, Any] | None: + token = request.cookies.get(PUBLIC_SESSION_COOKIE) + if not token: + return None + + try: + payload = decode_public_session_token(token) + except HTTPException: + return None + user_id = int(payload["uid"]) + + async with app.state.pool.acquire() as conn: + row = await conn.fetchrow("SELECT * FROM public_users WHERE id = $1", user_id) + + if not row: + return None + + user = format_public_user_row(row) + if user and user["is_blocked"]: + raise HTTPException(status_code=403, detail="Brukeren er blokkert fra å kommentere.") + return user + + +async def require_authenticated_public_user(request: Request) -> dict[str, Any]: + user = await get_authenticated_public_user(request) + if not user: + raise HTTPException(status_code=401, detail="Du må logge inn for å kommentere.") + return user + + +async def find_published_article_by_slug(conn, slug: str, section: str | None = None): + if section: + return await conn.fetchrow( + """ + SELECT * + FROM articles + WHERE slug = $1 AND status = 'published' AND section = $2 + """, + slug, + section, + ) + return await conn.fetchrow( + """ + SELECT * + FROM articles + WHERE slug = $1 AND status = 'published' + """, + slug, + ) + + ARTICLE_IMAGE_PATTERN = re.compile(r"]*\bsrc=['\"]([^'\"]+)['\"]", re.IGNORECASE) @@ -316,6 +790,7 @@ async def ensure_articles_table(conn): await conn.execute(""" CREATE TABLE IF NOT EXISTS articles ( id SERIAL PRIMARY KEY, + section VARCHAR(32) NOT NULL DEFAULT 'banebesok', slug VARCHAR(255) UNIQUE NOT NULL, title VARCHAR(255) NOT NULL, description TEXT, @@ -335,10 +810,101 @@ async def ensure_articles_table(conn): updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) """) + await conn.execute(""" + ALTER TABLE articles + ADD COLUMN IF NOT EXISTS section VARCHAR(32) NOT NULL DEFAULT 'banebesok' + """) + await conn.execute(""" + UPDATE articles + SET section = 'banebesok' + WHERE section IS NULL OR TRIM(section) = '' + """) await conn.execute("CREATE INDEX IF NOT EXISTS articles_status_idx ON articles (status)") + await conn.execute("CREATE INDEX IF NOT EXISTS articles_section_idx ON articles (section)") await conn.execute("CREATE INDEX IF NOT EXISTS articles_published_at_idx ON articles (published_at DESC)") +async def ensure_public_user_tables(conn): + await conn.execute(""" + CREATE TABLE IF NOT EXISTS public_users ( + id SERIAL PRIMARY KEY, + vipps_sub VARCHAR(255) UNIQUE, + google_sub VARCHAR(255) UNIQUE, + full_name VARCHAR(255), + given_name VARCHAR(255), + family_name VARCHAR(255), + email VARCHAR(255), + phone_number VARCHAR(64), + display_name VARCHAR(255), + is_blocked BOOLEAN NOT NULL DEFAULT FALSE, + email_verified_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_login_at TIMESTAMPTZ + ) + """) + await conn.execute(""" + ALTER TABLE public_users + ALTER COLUMN vipps_sub DROP NOT NULL + """) + await conn.execute(""" + ALTER TABLE public_users + ADD COLUMN IF NOT EXISTS google_sub VARCHAR(255) + """) + await conn.execute(""" + ALTER TABLE public_users + ADD COLUMN IF NOT EXISTS email_verified_at TIMESTAMPTZ + """) + await conn.execute(""" + CREATE UNIQUE INDEX IF NOT EXISTS public_users_google_sub_idx + ON public_users (google_sub) + WHERE google_sub IS NOT NULL + """) + await conn.execute(""" + CREATE INDEX IF NOT EXISTS public_users_email_lower_idx + ON public_users (LOWER(email)) + """) + await conn.execute(""" + CREATE TABLE IF NOT EXISTS article_comments ( + id SERIAL PRIMARY KEY, + article_id INTEGER NOT NULL REFERENCES articles(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES public_users(id) ON DELETE CASCADE, + parent_id INTEGER REFERENCES article_comments(id) ON DELETE SET NULL, + body TEXT NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'pending', + ip_hash VARCHAR(128), + user_agent TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + published_at TIMESTAMPTZ + ) + """) + await conn.execute(""" + CREATE INDEX IF NOT EXISTS article_comments_article_idx + ON article_comments (article_id, created_at ASC) + """) + await conn.execute(""" + CREATE INDEX IF NOT EXISTS article_comments_user_idx + ON article_comments (user_id, created_at DESC) + """) + await conn.execute(""" + CREATE TABLE IF NOT EXISTS public_magic_links ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES public_users(id) ON DELETE CASCADE, + token_hash VARCHAR(128) NOT NULL UNIQUE, + requested_ip_hash VARCHAR(128), + user_agent TEXT, + expires_at TIMESTAMPTZ NOT NULL, + consumed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + """) + await conn.execute(""" + CREATE INDEX IF NOT EXISTS public_magic_links_user_idx + ON public_magic_links (user_id, created_at DESC) + """) + + @asynccontextmanager async def lifespan(app: FastAPI): # Opprett database-pool ved start @@ -353,6 +919,7 @@ async def lifespan(app: FastAPI): async with app.state.pool.acquire() as conn: await ensure_facility_columns(conn) await ensure_articles_table(conn) + await ensure_public_user_tables(conn) await ensure_scrape_jobs_table(conn) print("✅ Database tilkoblet og pool opprettet") except Exception as e: @@ -476,6 +1043,321 @@ async def logout(response: Response): ) return {"status": "success"} + +@app.get("/api/public/me") +async def get_public_me(request: Request): + user = await get_authenticated_public_user(request) + return { + "auth_configured": get_public_auth_config()["configured"], + "auth_providers": get_public_auth_config(), + "user": user, + } + + +@app.get("/api/public/auth/google/start") +async def google_login_start(request: Request, return_to: Optional[str] = Query(default="/")): + if not is_google_login_configured(): + raise HTTPException(status_code=503, detail="Google-login er ikke konfigurert ennå.") + + discovery = await get_google_discovery_document() + authorization_endpoint = str(discovery.get("authorization_endpoint") or "").strip() + if not authorization_endpoint: + raise HTTPException(status_code=500, detail="Fant ikke Google authorization endpoint.") + + state = secrets.token_urlsafe(24) + redirect_uri = build_google_redirect_uri(request) + sanitized_return_to = normalize_return_to_path(return_to) + secure_cookies = should_use_secure_cookies(request) + query = urlencode( + { + "client_id": GOOGLE_CLIENT_ID, + "response_type": "code", + "scope": GOOGLE_OAUTH_SCOPES, + "state": state, + "redirect_uri": redirect_uri, + "prompt": "select_account", + } + ) + + response = Response(status_code=302) + response.headers["Location"] = f"{authorization_endpoint}?{query}" + response.set_cookie( + key="google_login_state", + value=state, + httponly=True, + samesite="lax", + secure=secure_cookies, + max_age=600, + ) + response.set_cookie( + key="google_login_return_to", + value=sanitized_return_to, + httponly=True, + samesite="lax", + secure=secure_cookies, + max_age=600, + ) + return response + + +@app.get("/api/public/auth/google/callback") +async def google_login_callback( + request: Request, + code: Optional[str] = Query(default=None), + state: Optional[str] = Query(default=None), + error: Optional[str] = Query(default=None), +): + return_to = normalize_return_to_path(request.cookies.get("google_login_return_to")) + cookie_state = request.cookies.get("google_login_state") + secure_cookies = should_use_secure_cookies(request) + + redirect_response = Response(status_code=302) + redirect_response.delete_cookie("google_login_state", httponly=True, samesite="lax", secure=secure_cookies) + redirect_response.delete_cookie("google_login_return_to", httponly=True, samesite="lax", secure=secure_cookies) + + if error: + redirect_response.headers["Location"] = append_query_param(return_to, "comment_auth", "google_cancelled") + return redirect_response + + if not code or not state or not cookie_state or state != cookie_state: + redirect_response.headers["Location"] = append_query_param(return_to, "comment_auth", "google_invalid_state") + return redirect_response + + if not is_google_login_configured(): + redirect_response.headers["Location"] = append_query_param(return_to, "comment_auth", "google_not_configured") + return redirect_response + + discovery = await get_google_discovery_document() + token_endpoint = str(discovery.get("token_endpoint") or "").strip() + userinfo_endpoint = str(discovery.get("userinfo_endpoint") or "").strip() + if not token_endpoint or not userinfo_endpoint: + raise HTTPException(status_code=500, detail="Google discovery mangler token/userinfo endpoint.") + + redirect_uri = build_google_redirect_uri(request) + + async with httpx.AsyncClient(timeout=20.0) as client: + token_response = await client.post( + token_endpoint, + auth=httpx.BasicAuth(GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET), + data={ + "grant_type": "authorization_code", + "code": code, + "redirect_uri": redirect_uri, + }, + headers={ + "Content-Type": "application/x-www-form-urlencoded", + }, + ) + if token_response.status_code >= 400: + redirect_response.headers["Location"] = append_query_param(return_to, "comment_auth", "google_token_error") + return redirect_response + + token_payload = token_response.json() + access_token = str(token_payload.get("access_token") or "").strip() + if not access_token: + redirect_response.headers["Location"] = append_query_param(return_to, "comment_auth", "google_token_error") + return redirect_response + + userinfo_response = await client.get( + userinfo_endpoint, + headers={ + "Authorization": f"Bearer {access_token}", + "Accept": "application/json", + }, + ) + if userinfo_response.status_code >= 400: + redirect_response.headers["Location"] = append_query_param(return_to, "comment_auth", "google_userinfo_error") + return redirect_response + + profile = userinfo_response.json() + + async with app.state.pool.acquire() as conn: + user_row = await upsert_public_user_from_google_profile(conn, profile) + + user = format_public_user_row(user_row) + if user and user["is_blocked"]: + redirect_response.headers["Location"] = append_query_param(return_to, "comment_auth", "blocked") + return redirect_response + + redirect_response.set_cookie( + key=PUBLIC_SESSION_COOKIE, + value=create_public_session_token(int(user["id"])), + httponly=True, + samesite="lax", + secure=secure_cookies, + max_age=PUBLIC_SESSION_MAX_AGE_SECONDS, + ) + redirect_response.headers["Location"] = append_query_param(return_to, "comment_auth", "google_success") + return redirect_response + + +@app.post("/api/public/auth/magic-link/request") +async def request_magic_link(request: Request, payload: PublicMagicLinkRequest): + if not is_magic_link_configured(): + raise HTTPException(status_code=503, detail="Magic link er ikke konfigurert ennå.") + + email = normalize_public_email(payload.email) + if not email or "@" not in email: + raise HTTPException(status_code=400, detail="Oppgi en gyldig e-postadresse.") + + return_to = normalize_return_to_path(payload.return_to) + token = secrets.token_urlsafe(32) + token_hash = hash_magic_link_token(token) + expires_at = datetime.utcnow() + timedelta(minutes=PUBLIC_MAGIC_LINK_MAX_AGE_MINUTES) + + async with app.state.pool.acquire() as conn: + user_row = await get_or_create_public_user_by_email(conn, email) + user = format_public_user_row(user_row) + if user and user["is_blocked"]: + return { + "status": "success", + "detail": "Hvis adressen kan brukes til innlogging, sender vi deg en lenke nå.", + } + + recent_threshold = datetime.utcnow() - timedelta(seconds=PUBLIC_MAGIC_LINK_REQUEST_COOLDOWN_SECONDS) + recent = await conn.fetchrow( + """ + SELECT id + FROM public_magic_links + WHERE user_id = $1 AND created_at >= $2 + ORDER BY created_at DESC + LIMIT 1 + """, + int(user_row["id"]), + recent_threshold, + ) + if recent: + return { + "status": "success", + "detail": "Hvis adressen kan brukes til innlogging, sender vi deg en lenke nå.", + } + + await conn.execute( + """ + INSERT INTO public_magic_links ( + user_id, token_hash, requested_ip_hash, user_agent, expires_at + ) VALUES ( + $1, $2, $3, $4, $5 + ) + """, + int(user_row["id"]), + token_hash, + hash_request_ip(request), + request.headers.get("user-agent"), + expires_at, + ) + + login_url = ( + f"{build_public_base_url(request)}/api/public/auth/magic-link/verify?" + f"{urlencode({'token': token, 'return_to': return_to})}" + ) + await send_magic_link_email(email, login_url) + + return { + "status": "success", + "detail": "Hvis adressen kan brukes til innlogging, sender vi deg en lenke nå.", + } + + +@app.get("/api/public/auth/magic-link/verify") +async def verify_magic_link( + request: Request, + token: Optional[str] = Query(default=None), + return_to: Optional[str] = Query(default="/"), +): + secure_cookies = should_use_secure_cookies(request) + redirect_target = normalize_return_to_path(return_to) + if not token: + response = Response(status_code=302) + response.headers["Location"] = append_query_param(redirect_target, "comment_auth", "magic_invalid") + return response + + token_hash = hash_magic_link_token(token) + + async with app.state.pool.acquire() as conn: + row = await conn.fetchrow( + """ + SELECT + ml.id AS magic_link_id, + ml.user_id AS magic_link_user_id, + ml.expires_at AS magic_link_expires_at, + ml.consumed_at AS magic_link_consumed_at, + u.* + FROM public_magic_links ml + JOIN public_users u ON u.id = ml.user_id + WHERE ml.token_hash = $1 + LIMIT 1 + """, + token_hash, + ) + + response = Response(status_code=302) + if not row: + response.headers["Location"] = append_query_param(redirect_target, "comment_auth", "magic_invalid") + return response + + if row["magic_link_consumed_at"] is not None: + response.headers["Location"] = append_query_param(redirect_target, "comment_auth", "magic_invalid") + return response + + expires_at = row["magic_link_expires_at"] + now = datetime.now(expires_at.tzinfo) if getattr(expires_at, "tzinfo", None) else datetime.utcnow() + if expires_at <= now: + response.headers["Location"] = append_query_param(redirect_target, "comment_auth", "magic_expired") + return response + + user = format_public_user_row(row) + if user and user["is_blocked"]: + response.headers["Location"] = append_query_param(redirect_target, "comment_auth", "blocked") + return response + + await conn.execute( + """ + UPDATE public_magic_links + SET consumed_at = NOW() + WHERE id = $1 + """, + row["magic_link_id"], + ) + await conn.execute( + """ + UPDATE public_users + SET + email_verified_at = COALESCE(email_verified_at, NOW()), + updated_at = NOW(), + last_login_at = NOW() + WHERE id = $1 + """, + row["magic_link_user_id"], + ) + + response.set_cookie( + key=PUBLIC_SESSION_COOKIE, + value=create_public_session_token(int(row["magic_link_user_id"])), + httponly=True, + samesite="lax", + secure=secure_cookies, + max_age=PUBLIC_SESSION_MAX_AGE_SECONDS, + ) + response.headers["Location"] = append_query_param(redirect_target, "comment_auth", "magic_success") + return response + + +@app.get("/api/public/auth/providers") +async def get_public_auth_providers(): + return get_public_auth_config() + + +@app.post("/api/public/auth/logout") +async def public_logout(request: Request, response: Response): + response.delete_cookie( + key=PUBLIC_SESSION_COOKIE, + httponly=True, + samesite="lax", + secure=should_use_secure_cookies(request), + ) + return {"status": "success"} + # --- DATA ENDPOINTS --- @app.get("/api/facilities") @@ -528,7 +1410,7 @@ async def get_course_visits(): rows = await conn.fetch(""" SELECT * FROM articles - WHERE status = 'published' + WHERE status = 'published' AND section = 'banebesok' ORDER BY COALESCE(published_at, created_at) DESC, id DESC """) return [format_article_row(row) for row in rows] @@ -541,12 +1423,154 @@ async def get_course_visit(slug: str): row = await conn.fetchrow(""" SELECT * FROM articles - WHERE slug = $1 AND status = 'published' + WHERE slug = $1 AND status = 'published' AND section = 'banebesok' """, slug) if not row: raise HTTPException(status_code=404, detail="Artikkelen ble ikke funnet") return format_article_row(row) + +@app.get("/api/articles") +async def get_articles(section: Optional[str] = Query(default="all")): + """Henter publiserte artikler, valgfritt filtrert på seksjon.""" + normalized_section = normalize_article_section(section, allow_all=True) + + query = """ + SELECT * + FROM articles + WHERE status = 'published' + {section_clause} + ORDER BY COALESCE(published_at, created_at) DESC, id DESC + """ + + async with app.state.pool.acquire() as conn: + if normalized_section == "all": + rows = await conn.fetch(query.format(section_clause="")) + else: + rows = await conn.fetch( + query.format(section_clause="AND section = $1"), + normalized_section, + ) + return [format_article_row(row) for row in rows] + + +@app.get("/api/articles/{slug}") +async def get_article(slug: str, section: Optional[str] = Query(default="all")): + """Henter én publisert artikkel, valgfritt filtrert på seksjon.""" + normalized_section = normalize_article_section(section, allow_all=True) + + query = """ + SELECT * + FROM articles + WHERE slug = $1 AND status = 'published' + {section_clause} + """ + + async with app.state.pool.acquire() as conn: + if normalized_section == "all": + row = await conn.fetchrow(query.format(section_clause=""), slug) + else: + row = await conn.fetchrow( + query.format(section_clause="AND section = $2"), + slug, + normalized_section, + ) + if not row: + raise HTTPException(status_code=404, detail="Artikkelen ble ikke funnet") + return format_article_row(row) + + +@app.get("/api/articles/{slug}/comments") +async def get_article_comments( + request: Request, + slug: str, + section: Optional[str] = Query(default=None), +): + normalized_section = normalize_article_section(section) if section else None + viewer = await get_authenticated_public_user(request) + + async with app.state.pool.acquire() as conn: + article = await find_published_article_by_slug(conn, slug, normalized_section) + if not article: + raise HTTPException(status_code=404, detail="Artikkelen ble ikke funnet") + + params: list[Any] = [article["id"]] + visibility_clause = "c.status = 'published'" + if viewer: + params.append(viewer["id"]) + visibility_clause = "(c.status = 'published' OR (c.status = 'pending' AND c.user_id = $2))" + + rows = await conn.fetch( + f""" + SELECT + c.*, + u.display_name, + u.full_name, + u.given_name + FROM article_comments c + JOIN public_users u ON u.id = c.user_id + WHERE c.article_id = $1 + AND {visibility_clause} + ORDER BY c.created_at ASC + """, + *params, + ) + + return { + "auth_configured": get_public_auth_config()["configured"], + "auth_providers": get_public_auth_config(), + "viewer": viewer, + "comments": [format_comment_row(row) for row in rows], + } + + +@app.post("/api/articles/{slug}/comments") +async def create_article_comment( + request: Request, + payload: PublicCommentCreateRequest, + slug: str, + section: Optional[str] = Query(default=None), +): + viewer = await require_authenticated_public_user(request) + normalized_section = normalize_article_section(section) if section else None + body = str(payload.body or "").strip() + if len(body) < 3: + raise HTTPException(status_code=400, detail="Kommentaren må være minst 3 tegn.") + if len(body) > 4000: + raise HTTPException(status_code=400, detail="Kommentaren er for lang.") + + async with app.state.pool.acquire() as conn: + article = await find_published_article_by_slug(conn, slug, normalized_section) + if not article: + raise HTTPException(status_code=404, detail="Artikkelen ble ikke funnet") + + row = await conn.fetchrow( + """ + INSERT INTO article_comments ( + article_id, user_id, body, status, ip_hash, user_agent + ) VALUES ( + $1, $2, $3, $4, $5, $6 + ) + RETURNING * + """, + article["id"], + viewer["id"], + body, + PUBLIC_COMMENT_DEFAULT_STATUS, + hash_request_ip(request), + request.headers.get("user-agent"), + ) + + return { + "status": "success", + "detail": ( + "Kommentaren er publisert." + if PUBLIC_COMMENT_DEFAULT_STATUS == "published" + else "Kommentaren er mottatt og venter på godkjenning." + ), + "comment": format_comment_row(row), + } + # --- ADMIN ENDPOINTS --- @@ -586,6 +1610,7 @@ async def get_admin_article(article_id: int): @app.post("/api/admin/articles") async def upsert_admin_article(request: ArticleUpsertRequest): + section = normalize_article_section(request.section) status = normalize_article_status(request.status) published_at = parse_optional_datetime(request.published_at) updated_at = parse_optional_datetime(request.updated_at) or datetime.utcnow() @@ -597,15 +1622,16 @@ async def upsert_admin_article(request: ArticleUpsertRequest): async with app.state.pool.acquire() as conn: row = await conn.fetchrow(""" INSERT INTO articles ( - slug, title, description, excerpt, eyebrow, location_label, + section, slug, title, description, excerpt, eyebrow, location_label, facility_name, facility_slug, author_name, status, hero_images, content_html, source_url, source_label, published_at, updated_at ) VALUES ( - $1, $2, $3, $4, $5, $6, - $7, $8, $9, $10, $11::jsonb, - $12, $13, $14, $15, $16 + $1, $2, $3, $4, $5, $6, $7, + $8, $9, $10, $11, $12::jsonb, + $13, $14, $15, $16, $17 ) ON CONFLICT (slug) DO UPDATE SET + section = EXCLUDED.section, title = EXCLUDED.title, description = EXCLUDED.description, excerpt = EXCLUDED.excerpt, @@ -623,6 +1649,7 @@ async def upsert_admin_article(request: ArticleUpsertRequest): updated_at = EXCLUDED.updated_at RETURNING * """, + section, request.slug.strip(), request.title.strip(), (request.description or "").strip() or None, @@ -654,9 +1681,7 @@ async def delete_admin_article(article_id: int): @app.post("/api/admin/articles/seed-imported") async def seed_admin_articles_from_imported_json(): - imported_path = Path("/opt/teeoff/frontend/src/content/importedMeninger.json") - if not imported_path.exists(): - raise HTTPException(status_code=404, detail="Fant ikke importedMeninger.json") + imported_path = resolve_imported_meninger_path() try: imported_articles = json.loads(imported_path.read_text(encoding="utf-8")) @@ -677,12 +1702,10 @@ async def seed_admin_articles_from_imported_json(): async with conn.transaction(): for item in imported_articles: facility_slug = item.get("primaryFacilitySlug") or ((item.get("facilitySlugs") or [None])[0]) - if not facility_slug: - continue - facility = facility_lookup.get(str(facility_slug), {}) content_html = str(item.get("contentHtml") or "") featured_image = item.get("featuredImage") or {} + section, eyebrow = resolve_imported_article_section(item) hero_images: list[dict[str, str]] = [] featured_url = str(featured_image.get("url") or "").strip() @@ -711,15 +1734,16 @@ async def seed_admin_articles_from_imported_json(): await conn.execute(""" INSERT INTO articles ( - slug, title, description, excerpt, eyebrow, location_label, + section, slug, title, description, excerpt, eyebrow, location_label, facility_name, facility_slug, author_name, status, hero_images, content_html, source_url, source_label, published_at, updated_at ) VALUES ( - $1, $2, $3, $4, $5, $6, - $7, $8, $9, 'published', $10::jsonb, - $11, $12, $13, $14, $15 + $1, $2, $3, $4, $5, $6, $7, + $8, $9, $10, 'published', $11::jsonb, + $12, $13, $14, $15, $16 ) ON CONFLICT (slug) DO UPDATE SET + section = EXCLUDED.section, title = EXCLUDED.title, description = EXCLUDED.description, excerpt = EXCLUDED.excerpt, @@ -736,14 +1760,15 @@ async def seed_admin_articles_from_imported_json(): published_at = EXCLUDED.published_at, updated_at = EXCLUDED.updated_at """, + section, str(item.get("slug") or "").strip(), str(item.get("title") or "").strip(), str(item.get("excerpt") or "").strip() or None, str(item.get("excerpt") or "").strip() or None, - "Banebesøk", - str(facility.get("county") or "Norge"), - str(facility.get("name") or humanize_slug(str(facility_slug))), - str(facility_slug), + eyebrow, + str(facility.get("county") or "Norge") if facility_slug else "Norge", + str(facility.get("name") or humanize_slug(str(facility_slug))) if facility_slug else None, + str(facility_slug) if facility_slug else None, str(((item.get("author") or {}).get("name")) or "TeeOff"), json.dumps(hero_images), content_html, diff --git a/docker-compose.yml b/docker-compose.yml index 5b9a40d..d83ede0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,10 +15,23 @@ services: api: build: ./backend container_name: teeoff_api + environment: + 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} ports: - "8001:8000" volumes: - ./backend:/app + - ./frontend/src/content:/shared/frontend-content:ro # Denne linjen sørger for at bilder lagres direkte i frontendens public-mappe: - ./frontend/public/media:/app/public/media depends_on: diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index 05e726d..1f4d51f 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -5,6 +5,14 @@ import nextTs from "eslint-config-next/typescript"; const eslintConfig = defineConfig([ ...nextVitals, ...nextTs, + { + rules: { + "@typescript-eslint/no-explicit-any": "off", + "react-hooks/set-state-in-effect": "off", + "react/no-children-prop": "off", + "react/no-unescaped-entities": "off", + }, + }, // Override default ignores of eslint-config-next. globalIgnores([ // Default ignores of eslint-config-next: diff --git a/frontend/src/app/admin/artikler/page.tsx b/frontend/src/app/admin/artikler/page.tsx index decee76..662059c 100644 --- a/frontend/src/app/admin/artikler/page.tsx +++ b/frontend/src/app/admin/artikler/page.tsx @@ -8,6 +8,7 @@ import TiptapHtmlEditor from "@/components/TiptapHtmlEditor"; type AdminArticle = { id: number; + section?: "banebesok" | "meninger"; slug: string; title: string; description?: string | null; @@ -37,6 +38,7 @@ type FacilityOption = { }; type ArticleFormState = { + section: "banebesok" | "meninger"; slug: string; title: string; description: string; @@ -79,6 +81,7 @@ function heroImagesToText(images?: AdminArticle["hero_images"]) { function createEmptyForm(): ArticleFormState { return { + section: "banebesok", slug: "", title: "", description: "", @@ -99,6 +102,7 @@ function createEmptyForm(): ArticleFormState { function articleToForm(article: AdminArticle): ArticleFormState { return { + section: article.section || "banebesok", slug: article.slug || "", title: article.title || "", description: article.description || "", @@ -191,7 +195,7 @@ export default function AdminArticlesPage() { }; const handleFieldChange = (field: keyof ArticleFormState, value: string) => { - setForm((current) => ({ ...current, [field]: value })); + setForm((current) => ({ ...current, [field]: value as ArticleFormState[keyof ArticleFormState] })); }; const uploadArticleImage = async (file: File) => { @@ -284,6 +288,7 @@ export default function AdminArticlesPage() { try { const payload = { ...form, + section: form.section, slug: form.slug.trim(), title: form.title.trim(), description: form.description.trim(), @@ -379,10 +384,10 @@ export default function AdminArticlesPage() { ← Tilbake til admin -

Artikler / Banebesøk

+

Artikler

- Første adminversjon for redaksjonelle artikler. Denne bruker nå Tiptap for innhold, - lagrer fortsatt HTML i databasen, og kan seedes fra de importerte Banebesøk-artiklene. + Redaksjonelle artikler kan nå ligge i egne seksjoner. Denne editoren bruker Tiptap, + lagrer HTML i databasen og kan seede både Banebesøk og Meninger fra importfilen.

@@ -460,6 +465,9 @@ export default function AdminArticlesPage() {

/{article.slug}

+

+ {article.section === "meninger" ? "Meninger" : "Banebesøk"} +

{article.facility_name || "Uten koblet bane"}

@@ -469,7 +477,18 @@ export default function AdminArticlesPage() {
-
+
+
@@ -171,12 +173,14 @@ export default async function CourseVisitsPage() { > Åpne artikkel - - Baneprofil - + {article.facilitySlug ? ( + + Baneprofil + + ) : null}
diff --git a/frontend/src/app/meninger/[slug]/page.tsx b/frontend/src/app/meninger/[slug]/page.tsx new file mode 100644 index 0000000..668d849 --- /dev/null +++ b/frontend/src/app/meninger/[slug]/page.tsx @@ -0,0 +1,279 @@ +import Link from "next/link"; +import { notFound } from "next/navigation"; +import CourseVisitGallery from "@/components/CourseVisitGallery"; +import ArticleComments from "@/components/ArticleComments"; +import InfoPageShell from "@/components/InfoPageShell"; +import { getOpinionArticleBySlug, type CourseVisitBodyBlock } from "@/content/courseVisits"; +import { + ORGANIZATION_ID, + buildAbsoluteUrl, + createBreadcrumbJsonLd, + createPageMetadata, +} from "@/app/seo"; + +type OpinionPageProps = { + params: Promise<{ slug: string }>; +}; + +export const dynamic = "force-dynamic"; + +function renderBlock(block: CourseVisitBodyBlock, index: number) { + if (block.type === "richText") { + return ( +
+ {block.title ? ( +

{block.title}

+ ) : null} +
+
+ ); + } + + if (block.type === "quote") { + return ( +
+

+ Sitat +

+
+ “{block.quote}” +
+ {block.attribution ? ( +

+ {block.attribution} +

+ ) : null} +
+ ); + } + + if (block.type === "checklist") { + return ( +
+

+ Innholdsgrep +

+

{block.title}

+
+ {block.items.map((item) => ( +
+

{item}

+
+ ))} +
+
+ ); + } + + if (block.type === "factGrid") { + return ( +
+

+ Struktur +

+

{block.title}

+
+ {block.items.map((item) => ( +
+

+ {item.label} +

+

{item.value}

+
+ ))} +
+
+ ); + } + + return ( +
+

+ Neste steg +

+

{block.title}

+

{block.body}

+
+ ); +} + +export async function generateMetadata({ params }: OpinionPageProps) { + const { slug } = await params; + const article = await getOpinionArticleBySlug(slug); + + if (!article) { + return createPageMetadata({ + title: "Meninger-artikkel ikke funnet", + description: "Artikkelen du leter etter finnes ikke på TeeOff.", + path: `/meninger/${slug}`, + type: "article", + }); + } + + return createPageMetadata({ + title: article.title, + description: article.description, + path: `/meninger/${article.slug}`, + image: article.heroImages[0]?.src, + type: "article", + }); +} + +export default async function OpinionPage({ params }: OpinionPageProps) { + const { slug } = await params; + const article = await getOpinionArticleBySlug(slug); + + if (!article) { + notFound(); + } + + const breadcrumbJsonLd = createBreadcrumbJsonLd([ + { name: "Hjem", path: "/" }, + { name: "Meninger", path: "/meninger" }, + { name: article.title, path: `/meninger/${article.slug}` }, + ]); + + const articleJsonLd = { + "@context": "https://schema.org", + "@type": "Article", + headline: article.title, + description: article.description, + url: buildAbsoluteUrl(`/meninger/${article.slug}`), + image: article.heroImages.map((image) => buildAbsoluteUrl(image.src)), + datePublished: article.publishedAt, + dateModified: article.updatedAt || article.publishedAt, + inLanguage: "nb-NO", + isPartOf: { + "@type": "WebSite", + url: buildAbsoluteUrl("/"), + }, + author: { + "@type": "Organization", + name: "TeeOff", + "@id": ORGANIZATION_ID, + }, + publisher: { + "@id": ORGANIZATION_ID, + }, + ...(article.facilityName && article.facilitySlug + ? { + about: { + "@type": "GolfCourse", + name: article.facilityName, + url: buildAbsoluteUrl(`/golfbaner/${article.facilitySlug}`), + }, + } + : {}), + }; + + return ( + <> +