diff --git a/backend/main.py b/backend/main.py index 1971763..4e8cbd1 100644 --- a/backend/main.py +++ b/backend/main.py @@ -354,29 +354,100 @@ _MIME_MAP = { ".webm": "video/webm", ".pdf": "application/pdf", } -if STAGING and os.path.isdir(PROD_MEDIA_DIR): - # Staging: eigene Uploads in MEDIA_DIR, Fallback auf Prod-Medien (read-only) - from fastapi.responses import FileResponse as _FileResponse +from fastapi import Request as _Request +from fastapi.responses import FileResponse as _FileResponse +from auth import decode_token as _decode_token - def _media_response(filepath: str): - ext = os.path.splitext(filepath)[1].lower() - mt = _MIME_MAP.get(ext, "application/octet-stream") - return _FileResponse(filepath, media_type=mt) +# Pfade die Login erfordern (Eigentümer-Check) +_OWNER_PROTECTED = ("diary/", "health/") +# Pfade die nur Login erfordern (kein Eigentümer-Check nötig) +_AUTH_ONLY = ("walks/",) - @app.api_route("/media/{path:path}", methods=["GET", "HEAD"]) - async def serve_media_staging(path: str): - staging_file = os.path.join(MEDIA_DIR, path) - if os.path.isfile(staging_file): - return _media_response(staging_file) - prod_file = os.path.join(PROD_MEDIA_DIR, path) - if os.path.isfile(prod_file): - return _media_response(prod_file) - from fastapi import HTTPException as _HE - raise _HE(404, "Media not found") -else: - app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media") -APP_VER = "878" # muss mit APP_VER in app.js übereinstimmen +def _uid_from_request(request: _Request): + token = request.cookies.get("by_token") + if not token: + return None + try: + return int(_decode_token(token)["sub"]) + except Exception: + return None + + +def _media_response(filepath: str): + ext = os.path.splitext(filepath)[1].lower() + mt = _MIME_MAP.get(ext, "application/octet-stream") + return _FileResponse(filepath, media_type=mt, + headers={"Cache-Control": "private, max-age=3600"}) + + +def _resolve_media_path(path: str) -> str | None: + """Gibt den echten Dateipfad zurück — Staging sucht zuerst lokal, dann Prod.""" + primary = os.path.join(MEDIA_DIR, path) + if os.path.isfile(primary): + return primary + if STAGING and os.path.isdir(PROD_MEDIA_DIR): + fallback = os.path.join(PROD_MEDIA_DIR, path) + if os.path.isfile(fallback): + return fallback + return None + + +@app.api_route("/media/{path:path}", methods=["GET", "HEAD"]) +async def serve_media(path: str, request: _Request): + from fastapi import HTTPException as _HE + + prefix = path.split("/")[0] + "/" + filename = path.split("/", 1)[1] if "/" in path else path + + # Auth-Pflicht für geschützte Pfade + if prefix in _OWNER_PROTECTED or prefix in _AUTH_ONLY: + uid = _uid_from_request(request) + if not uid: + raise _HE(401, "Anmeldung erforderlich.") + + if prefix in _OWNER_PROTECTED: + # Eigentümer-Check: Datei muss zum eingeloggten User gehören + # Preview-Dateien (foo_preview.webp) → Basis-Stem suchen + stem = filename.rsplit("_preview", 1)[0] if "_preview" in filename else filename.rsplit(".", 1)[0] + with db() as conn: + if prefix == "diary/": + row = conn.execute(""" + SELECT dm.id FROM diary_media dm + JOIN diary d ON d.id = dm.diary_id + JOIN dogs dog ON dog.id = d.dog_id + LEFT JOIN dog_shares ds + ON ds.dog_id = dog.id AND ds.shared_with_id = ? + AND ds.accepted_at IS NOT NULL + WHERE (dog.user_id = ? OR ds.id IS NOT NULL) + AND dm.url LIKE ? + LIMIT 1 + """, (uid, uid, f'/media/diary/{stem}%')).fetchone() + else: # health/ + row = conn.execute(""" + SELECT hm.id FROM health_media hm + JOIN health h ON h.id = hm.health_id + JOIN dogs dog ON dog.id = h.dog_id + WHERE dog.user_id = ? AND hm.url LIKE ? + LIMIT 1 + """, (uid, f'/media/health/{stem}%')).fetchone() + # Fallback: dokument_url (alte Einzel-Uploads) + if not row: + row = conn.execute(""" + SELECT h.id FROM health h + JOIN dogs dog ON dog.id = h.dog_id + WHERE dog.user_id = ? AND h.dokument_url LIKE ? + LIMIT 1 + """, (uid, f'/media/health/{stem}%')).fetchone() + if not row: + raise _HE(403, "Zugriff verweigert.") + + filepath = _resolve_media_path(path) + if not filepath: + raise _HE(404, "Nicht gefunden.") + return _media_response(filepath) + +APP_VER = "879" # muss mit APP_VER in app.js übereinstimmen @app.get("/.well-known/assetlinks.json") async def assetlinks(): diff --git a/backend/routes/walks.py b/backend/routes/walks.py index 03074f3..3a0c48b 100644 --- a/backend/routes/walks.py +++ b/backend/routes/walks.py @@ -576,7 +576,16 @@ async def upload_walk_photo( except Exception: pass - filename = f"walk_{walk_id}_{uuid.uuid4().hex[:8]}.jpg" + import re as _re + walk_datum = walk['datum'] or "0000-00-00" # YYYY-MM-DD + uname_raw = (user.get('name') or 'user').lower() + uname_safe = _re.sub(r'[^a-z0-9]', '-', uname_raw)[:20].strip('-') + with db() as conn: + count = conn.execute( + "SELECT COUNT(*) FROM walk_photos WHERE walk_id=? AND user_id=?", + (walk_id, user['id']) + ).fetchone()[0] + filename = f"{walk_datum}-{uname_safe}-{count + 1:03d}.jpg" path = os.path.join(MEDIA_DIR, "walks", filename) os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "wb") as f: diff --git a/backend/static/index.html b/backend/static/index.html index 10c0040..5b09df8 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -101,9 +101,9 @@ - - - + + + @@ -583,10 +583,10 @@ - - - - + + + + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 759a280..82f2ac3 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '878'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '879'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.5.1'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; // Cache-Bust-Parameter nach Update-Reload sofort entfernen diff --git a/backend/static/sw.js b/backend/static/sw.js index 91c4cd8..1cff854 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v878'; +const CACHE_VERSION = 'by-v879'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache