Security: Auth-geschützte Media-Endpoints für Diary+Health, Walk-Foto-Naming (SW by-v879)
This commit is contained in:
parent
44ba51cd38
commit
465dc2e4d3
5 changed files with 110 additions and 30 deletions
111
backend/main.py
111
backend/main.py
|
|
@ -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():
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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 -->
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue