diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py index ba7f919..e416f02 100644 --- a/backend/routers/exercises.py +++ b/backend/routers/exercises.py @@ -107,9 +107,76 @@ _MAX_UPLOAD_MB_ADMIN = max(_MAX_UPLOAD_MB_USER, int(os.getenv("EXERCISE_MEDIA_AD MAX_UPLOAD_BYTES_USER = _MAX_UPLOAD_MB_USER * 1024 * 1024 MAX_UPLOAD_BYTES_ADMIN = _MAX_UPLOAD_MB_ADMIN * 1024 * 1024 ALLOWED_UPLOAD_MIMES = frozenset( - {"image/jpeg", "image/png", "image/gif", "video/mp4", "application/pdf"} + { + "image/jpeg", + "image/png", + "image/gif", + "image/heic", + "image/heif", + "video/mp4", + "video/quicktime", + "application/pdf", + } ) +# Dateiendung → MIME wenn der Client keinen sinnvollen Content-Type sendet (häufig mobil / iOS). +_UPLOAD_FILENAME_MIME_FALLBACK = { + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".gif": "image/gif", + ".heic": "image/heic", + ".heif": "image/heif", + ".mp4": "video/mp4", + ".mov": "video/quicktime", + ".pdf": "application/pdf", +} + + +def _sniff_allowed_upload_mime(raw: bytes) -> Optional[str]: + """Erkennt erlaubte MIME-Typen anhand weniger Magic Bytes (ohne Pillow/python-magic).""" + if len(raw) < 12: + return None + if raw[:3] == b"\xff\xd8\xff": + return "image/jpeg" + if raw[:8] == b"\x89PNG\r\n\x1a\n": + return "image/png" + if raw[:6] in (b"GIF87a", b"GIF89a"): + return "image/gif" + if raw[:4] == b"%PDF": + return "application/pdf" + if raw[4:8] != b"ftyp": + return None + brand = raw[8:12] + # HEIC / HEIF (u. a. iPhone „高效率“) + if brand in (b"heic", b"heix", b"hevx", b"hevc", b"mif1", b"msf1"): + return "image/heic" + if brand in (b"isom", b"iso2", b"iso5", b"iso6", b"mp41", b"mp42", b"M4V ", b"dash", b"msdh"): + return "video/mp4" + # iPhone-Kamera / Fotos: MOV (QuickTime-Container) + if brand == b"qt ": + return "video/quicktime" + return None + + +def resolve_upload_mime_type( + raw: bytes, + content_type: Optional[str], + filename: Optional[str], +) -> str: + """Ermittelt ein erlaubtes MIME (Client-Header, Magic Bytes oder Dateiendung).""" + ct = (content_type or "").split(";")[0].strip().lower() + if ct in ALLOWED_UPLOAD_MIMES: + return ct + guessed = _sniff_allowed_upload_mime(raw) + if guessed in ALLOWED_UPLOAD_MIMES: + return guessed + ext = Path(filename or "").suffix.lower() + fb = _UPLOAD_FILENAME_MIME_FALLBACK.get(ext) + if fb in ALLOWED_UPLOAD_MIMES: + return fb + raise ValueError(f"Dateityp nicht erlaubt: {ct or 'unbekannt'}") + def _upload_limit_bytes(tenant: TenantContext) -> int: role = tenant.global_role or "" @@ -2302,17 +2369,21 @@ async def upload_exercise_media( status_code=413, detail=f"Datei zu groß (max. {max_upload // (1024 * 1024)} MB)", ) - mime = file.content_type or "" - if mime not in ALLOWED_UPLOAD_MIMES: - raise HTTPException( - status_code=400, - detail=f"Dateityp nicht erlaubt: {mime or 'unbekannt'}", - ) + try: + mime = resolve_upload_mime_type(raw, file.content_type, file.filename) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e ext = Path(file.filename or "").suffix[:12] if file.filename else "" if not ext and mime == "image/jpeg": ext = ".jpg" elif not ext and mime == "image/png": ext = ".png" + elif not ext and mime in ("image/heic", "image/heif"): + ext = ".heic" + elif not ext and mime == "video/mp4": + ext = ".mp4" + elif not ext and mime == "video/quicktime": + ext = ".mov" cur.execute( "SELECT visibility, club_id, created_by FROM exercises WHERE id = %s", diff --git a/backend/routers/media_assets.py b/backend/routers/media_assets.py index e57cbd5..db714d9 100644 --- a/backend/routers/media_assets.py +++ b/backend/routers/media_assets.py @@ -36,7 +36,7 @@ from media_lifecycle import ( from media_storage import get_effective_media_root, path_under_media_root from tenant_context import TenantContext, get_tenant_context, get_tenant_context_flexible -from routers.exercises import ALLOWED_UPLOAD_MIMES, _upload_limit_bytes +from routers.exercises import _upload_limit_bytes, resolve_upload_mime_type router = APIRouter(prefix="/api/media-assets", tags=["media-assets"]) @@ -484,12 +484,10 @@ def _ingest_library_media_file( detail=f"Datei zu groß (max. {max_b // (1024 * 1024)} MB)", ) - mime = (content_type or "").split(";")[0].strip().lower() - if mime not in ALLOWED_UPLOAD_MIMES: - raise HTTPException( - status_code=400, - detail=f"Dateityp nicht erlaubt: {mime or 'unbekannt'}", - ) + try: + mime = resolve_upload_mime_type(raw, content_type, filename) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e full_sha = hashlib.sha256(raw).hexdigest() cur.execute( @@ -532,6 +530,12 @@ def _ingest_library_media_file( ext = ".jpg" elif not ext and mime == "image/png": ext = ".png" + elif not ext and mime in ("image/heic", "image/heif"): + ext = ".heic" + elif not ext and mime == "video/mp4": + ext = ".mp4" + elif not ext and mime == "video/quicktime": + ext = ".mov" media_root = get_effective_media_root(cur) storage_key = f"exercises/{full_sha}{ext}" diff --git a/frontend/src/pages/ExerciseFormPage.jsx b/frontend/src/pages/ExerciseFormPage.jsx index 6f398ec..c85fe02 100644 --- a/frontend/src/pages/ExerciseFormPage.jsx +++ b/frontend/src/pages/ExerciseFormPage.jsx @@ -1460,7 +1460,7 @@ function ExerciseFormPage() { setMediaFile(e.target.files?.[0] || null)} />
diff --git a/frontend/src/pages/MediaLibraryPage.jsx b/frontend/src/pages/MediaLibraryPage.jsx index 8d381af..290e5af 100644 --- a/frontend/src/pages/MediaLibraryPage.jsx +++ b/frontend/src/pages/MediaLibraryPage.jsx @@ -153,6 +153,10 @@ function uploaderLabel(it, viewer) { function MediaThumb({ mediaId, mimeType }) { const url = resolveMediaAssetFileUrl(mediaId) const mime = (mimeType || '').toLowerCase() + /* iPhone-Fotos: Browser-Vorschau oft nicht nutzbar */ + if (mime.includes('heic') || mime.includes('heif')) { + return
HEIC
+ } if (mime.startsWith('video/')) { return (