Security: Auth-geschützte Media-Endpoints für Diary+Health, Walk-Foto-Naming (SW by-v879)

This commit is contained in:
rene 2026-05-12 17:17:36 +02:00
parent 44ba51cd38
commit 465dc2e4d3
5 changed files with 110 additions and 30 deletions

View file

@ -354,29 +354,100 @@ _MIME_MAP = {
".webm": "video/webm", ".pdf": "application/pdf", ".webm": "video/webm", ".pdf": "application/pdf",
} }
if STAGING and os.path.isdir(PROD_MEDIA_DIR): from fastapi import Request as _Request
# Staging: eigene Uploads in MEDIA_DIR, Fallback auf Prod-Medien (read-only) from fastapi.responses import FileResponse as _FileResponse
from fastapi.responses import FileResponse as _FileResponse from auth import decode_token as _decode_token
def _media_response(filepath: str): # Pfade die Login erfordern (Eigentümer-Check)
ext = os.path.splitext(filepath)[1].lower() _OWNER_PROTECTED = ("diary/", "health/")
mt = _MIME_MAP.get(ext, "application/octet-stream") # Pfade die nur Login erfordern (kein Eigentümer-Check nötig)
return _FileResponse(filepath, media_type=mt) _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") @app.get("/.well-known/assetlinks.json")
async def assetlinks(): async def assetlinks():

View file

@ -576,7 +576,16 @@ async def upload_walk_photo(
except Exception: except Exception:
pass 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) path = os.path.join(MEDIA_DIR, "walks", filename)
os.makedirs(os.path.dirname(path), exist_ok=True) os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "wb") as f: with open(path, "wb") as f:

View file

@ -101,9 +101,9 @@
</script> </script>
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung --> <!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
<link rel="stylesheet" href="/css/design-system.css?v=878"> <link rel="stylesheet" href="/css/design-system.css?v=879">
<link rel="stylesheet" href="/css/layout.css?v=878"> <link rel="stylesheet" href="/css/layout.css?v=879">
<link rel="stylesheet" href="/css/components.css?v=878"> <link rel="stylesheet" href="/css/components.css?v=879">
</head> </head>
<body> <body>
@ -583,10 +583,10 @@
<div id="modal-container"></div> <div id="modal-container"></div>
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features --> <!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
<script src="/js/api.js?v=878"></script> <script src="/js/api.js?v=879"></script>
<script src="/js/ui.js?v=878"></script> <script src="/js/ui.js?v=879"></script>
<script src="/js/app.js?v=878"></script> <script src="/js/app.js?v=879"></script>
<script src="/js/worlds.js?v=878"></script> <script src="/js/worlds.js?v=879"></script>
<!-- Feature-Seiten werden lazy geladen --> <!-- Feature-Seiten werden lazy geladen -->

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung. 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 APP_VERSION = '1.5.1'; // ← semantische Version, wird bei make release gesetzt
const IS_STAGING = location.hostname === 'staging.banyaro.app'; const IS_STAGING = location.hostname === 'staging.banyaro.app';
// Cache-Bust-Parameter nach Update-Reload sofort entfernen // Cache-Bust-Parameter nach Update-Reload sofort entfernen

View file

@ -3,7 +3,7 @@
Offline-Cache + Push Notifications + Tile-Cache Offline-Cache + Push Notifications + Tile-Cache
============================================================ */ ============================================================ */
const CACHE_VERSION = 'by-v878'; const CACHE_VERSION = 'by-v879';
const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache