feat: enhance media upload handling and MIME type resolution
All checks were successful
Deploy Development / deploy (push) Successful in 34s
Test Suite / pytest-backend (push) Successful in 25s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 7s
Test Suite / playwright-tests (push) Successful in 24s

- Expanded the allowed upload MIME types to include HEIC, HEIF, and QuickTime formats.
- Introduced a new function to resolve MIME types based on file content, filename extensions, and client-provided content types.
- Updated media upload logic in both the exercises and media assets routers to utilize the new MIME resolution function.
- Adjusted frontend file input accept attributes to allow broader media types, improving user experience.
- Enhanced media library components to handle HEIC/HEIF formats with appropriate fallback messaging for browser compatibility.
This commit is contained in:
Lars 2026-05-07 21:55:22 +02:00
parent d213746bd2
commit cc4e621f95
5 changed files with 109 additions and 17 deletions

View File

@ -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",

View File

@ -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}"

View File

@ -1460,7 +1460,7 @@ function ExerciseFormPage() {
<label className="form-label">Datei</label>
<input
type="file"
accept="image/jpeg,image/png,image/gif,video/mp4,application/pdf"
accept="image/*,video/*,application/pdf"
onChange={(e) => setMediaFile(e.target.files?.[0] || null)}
/>
<div className="form-row" style={{ marginTop: '8px' }}>

View File

@ -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 <div className="media-library__thumb-ph">HEIC</div>
}
if (mime.startsWith('video/')) {
return (
<video
@ -609,7 +613,7 @@ export default function MediaLibraryPage() {
ref={bulkFileInputRef}
type="file"
className="media-library__sr-file"
accept="image/jpeg,image/png,image/gif,video/mp4,application/pdf,.jpg,.jpeg,.png,.gif,.mp4,.pdf"
accept="image/*,video/*,application/pdf"
multiple
onChange={onBulkArchiveFiles}
/>
@ -857,9 +861,22 @@ export default function MediaLibraryPage() {
{(() => {
const url = resolveMediaAssetFileUrl(preview.id)
const kind = previewDisplayKind(preview.mime_type)
const mlow = (preview.mime_type || '').toLowerCase()
if (!url) {
return <p className="media-library__hint">Keine Datei-URL.</p>
}
if (mlow.includes('heic') || mlow.includes('heif')) {
return (
<div className="media-library__preview-fallback">
<p className="media-library__hint">
HEIC/HEIF in diesem Browser oft keine eingebettete Vorschau. Zum Ansehen herunterladen oder im neuen Tab öffnen.
</p>
<a className="btn btn-secondary" href={url} target="_blank" rel="noopener noreferrer">
Datei öffnen
</a>
</div>
)
}
if (kind === 'image') {
return (
<img

View File

@ -464,7 +464,7 @@ export function buildExerciseApiPayload(formData, extras = {}) {
export async function uploadExerciseMedia(exerciseId, formData) {
const token = localStorage.getItem('authToken')
const headers = {}
const headers = mergeActiveClubHeader({})
if (token) headers['X-Auth-Token'] = token
const response = await fetch(`${API_URL}/api/exercises/${exerciseId}/media`, {
method: 'POST',