diff --git a/Dockerfile b/Dockerfile index 07e8bd6..ff22003 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,6 +8,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ ffmpeg \ && rm -rf /var/lib/apt/lists/* +# Non-root User für Container-Hardening +# (Synology DSM-Volumes haben ACLs — daher chown auf /data + /app) +RUN groupadd -r appuser -g 1000 && \ + useradd -r -u 1000 -g appuser -d /app -s /sbin/nologin appuser + # Python-Dependencies zuerst (Docker Layer Cache) COPY backend/requirements.txt . RUN pip install --no-cache-dir -r requirements.txt @@ -22,6 +27,12 @@ COPY VERSION /app/VERSION RUN mkdir -p /data/media/dogs /data/media/diary /data/media/poison \ /data/media/breeds/gallery /data/media/breeds/submissions +# USER appuser auskommentiert: Synology DSM Volume-ACLs blockieren das +# (SQLite OperationalError: 'attempt to write a readonly database'). User- +# Anlage bleibt im Dockerfile damit nicht-DS-Deployments später wechseln +# können via `USER appuser` Zeile auskommentieren-entfernen. +# USER appuser + EXPOSE 8000 CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers", "--forwarded-allow-ips=*"] diff --git a/Makefile b/Makefile index 9402ea3..c16bf3f 100644 --- a/Makefile +++ b/Makefile @@ -287,7 +287,8 @@ bump: sed -i.bak -E "s/const VER[[:space:]]*=[[:space:]]*'[0-9]+'/const VER = '$$NEW'/" backend/static/sw.js && rm -f backend/static/sw.js.bak; \ sed -i.bak -E "s/const APP_VER[[:space:]]*=[[:space:]]*'[0-9]+'/const APP_VER = '$$NEW'/" backend/static/js/app.js && rm -f backend/static/js/app.js.bak; \ sed -i.bak -E "s/\?v=[0-9]+/?v=$$NEW/g" backend/static/index.html && rm -f backend/static/index.html.bak; \ - echo " ✓ APP_VER $$CUR → $$NEW (VERSION, sw.js, app.js, index.html aktualisiert)" + sed -i.bak -E "s/\?v=[0-9]+/?v=$$NEW/g" backend/static/landing.html && rm -f backend/static/landing.html.bak; \ + echo " ✓ APP_VER $$CUR → $$NEW (VERSION, sw.js, app.js, index.html, landing.html aktualisiert)" # ---------------------------------------------------------- # TEST — Smoke-Tests gegen isolierte Test-DB (kein Docker, kein DS) diff --git a/VERSION b/VERSION index 39987d0..a2998a8 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1099 \ No newline at end of file +1120 \ No newline at end of file diff --git a/backend/auth.py b/backend/auth.py index 9cb25c6..1b5f126 100644 --- a/backend/auth.py +++ b/backend/auth.py @@ -212,6 +212,49 @@ def require_admin(user=Depends(get_current_user)): return user +def require_moderator(user=Depends(get_current_user)): + """Dependency: Admin oder Moderator. Konsequente Nutzung statt + Inline-`if user['rolle'] not in (...):` in den Routen.""" + if user["rolle"] not in ("admin", "moderator") and not user.get("is_moderator"): + raise HTTPException(status.HTTP_403_FORBIDDEN, "Moderator-Zugriff erforderlich.") + return user + + +def require_breeder(user=Depends(get_current_user)): + """Dependency: Admin oder Züchter (breeder/breeder_test).""" + if user["rolle"] == "admin": + return user + if user.get("subscription_tier") in ("breeder", "breeder_test"): + return user + raise HTTPException(status.HTTP_403_FORBIDDEN, "Züchter-Zugriff erforderlich.") + + +# ------------------------------------------------------------------ +# Owner-Checks — zentral, statt 54x inline `if row['user_id'] != user['id']: 403` +# ------------------------------------------------------------------ +def require_owner(row, user: dict, owner_field: str = "user_id", + not_found_msg: str = "Nicht gefunden", + forbidden_msg: str = "Kein Zugriff"): + """Wirft 404 wenn row None/falsy ist, 403 wenn User nicht Besitzer. + Returns row für chainability: + dog = require_owner(conn.execute(...).fetchone(), user, 'user_id', 'Hund nicht gefunden') + """ + if not row: + raise HTTPException(status.HTTP_404_NOT_FOUND, not_found_msg) + if row[owner_field] != user["id"]: + raise HTTPException(status.HTTP_403_FORBIDDEN, forbidden_msg) + return row + + +def is_owner_or_admin(row, user: dict, owner_field: str = "user_id") -> bool: + """True wenn User Owner ist oder Admin/Moderator.""" + if not row: + return False + if user["rolle"] in ("admin", "moderator") or user.get("is_moderator"): + return True + return row[owner_field] == user["id"] + + def has_pro_access(user: dict) -> bool: """True wenn User Pro-Features nutzen darf.""" if not user: diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 0000000..a05b6be --- /dev/null +++ b/backend/config.py @@ -0,0 +1,20 @@ +"""Zentrale Konfiguration — vermeidet 19× duplizierte os.getenv-Aufrufe +für MEDIA_DIR und gibt einheitliche Timeout-Konstanten für externe APIs.""" +import os + + +# Speicher-Pfade +DB_PATH = os.getenv("DB_PATH", "/data/banyaro.db") +MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") +BREEDER_DOCS_DIR = os.getenv("BREEDER_DOCS_DIR", "/data/breeder_docs") +SCANINPUT_DIR = os.getenv("SCANINPUT_DIR", "/data/scaninput") + +# HTTP-Timeouts für externe APIs (in Sekunden) +# Verwendung: httpx.AsyncClient(timeout=API_TIMEOUT_DEFAULT) +API_TIMEOUT_SHORT = 5 # Schnelle Lookups (Geocoding, Reverse, einzelne Werte) +API_TIMEOUT_DEFAULT = 10 # Standardfall (Wetter, Wikipedia) +API_TIMEOUT_LONG = 30 # Größere Antworten (Overpass-Tiles, KI-Calls) + +# Standard-Header für externe Requests (Höflichkeit + Fair-Use) +HTTP_USER_AGENT = "BanYaro/1.0 (https://banyaro.app)" +HTTP_HEADERS = {"User-Agent": HTTP_USER_AGENT} diff --git a/backend/errors.py b/backend/errors.py new file mode 100644 index 0000000..2cbaf3f --- /dev/null +++ b/backend/errors.py @@ -0,0 +1,47 @@ +"""Standardisierte HTTP-Exceptions — vermeidet inkonsistente Texte +in 200+ raise-Statements.""" +from fastapi import HTTPException + + +def not_found(msg: str = "Nicht gefunden") -> HTTPException: + """404. Beispiel: `raise not_found('Hund nicht gefunden')`.""" + return HTTPException(404, msg) + + +def forbidden(msg: str = "Kein Zugriff") -> HTTPException: + """403.""" + return HTTPException(403, msg) + + +def bad_request(msg: str = "Ungültige Eingabe") -> HTTPException: + """400.""" + return HTTPException(400, msg) + + +def unauthorized(msg: str = "Nicht angemeldet") -> HTTPException: + """401.""" + return HTTPException(401, msg) + + +def conflict(msg: str = "Konflikt") -> HTTPException: + """409.""" + return HTTPException(409, msg) + + +def too_many_requests(msg: str = "Zu viele Anfragen", retry_after: int | None = None) -> HTTPException: + """429. Optional mit Retry-After Header (in Sekunden).""" + headers = {"Retry-After": str(retry_after)} if retry_after else None + return HTTPException(429, msg, headers=headers) + + +def service_unavailable(msg: str = "Dienst gerade nicht verfügbar") -> HTTPException: + """503.""" + return HTTPException(503, msg) + + +def require_or_404(row, msg: str = "Nicht gefunden"): + """Convenience: wirft 404 wenn row None/falsy, sonst gibt row zurück. + Beispiel: `dog = require_or_404(conn.execute(...).fetchone(), 'Hund nicht gefunden')`""" + if not row: + raise not_found(msg) + return row diff --git a/backend/main.py b/backend/main.py index 569a887..aac642e 100644 --- a/backend/main.py +++ b/backend/main.py @@ -110,8 +110,8 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware): response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" response.headers["Content-Security-Policy"] = ( "default-src 'self'; " - "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://umami.motocamp.de; " - "style-src 'self' 'unsafe-inline'; " + "script-src 'self' https://umami.motocamp.de; " # ohne unsafe-inline/eval — alle Inline-Scripts extrahiert + "style-src 'self' 'unsafe-inline'; " # Inline-Styles bleiben (zu viele Fundstellen für jetzt) "img-src 'self' data: blob: https:; " "connect-src 'self' https:; " "frame-ancestors 'none'; " @@ -1763,19 +1763,40 @@ async def force_update():
Wir besorgen neue Leckerlis 🦴
+