MediaPfad extern, Upload Manager Bug Fixes #23

Merged
Lars merged 21 commits from develop into main 2026-05-08 11:17:12 +02:00
5 changed files with 109 additions and 17 deletions
Showing only changes of commit cc4e621f95 - Show all commits

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_USER = _MAX_UPLOAD_MB_USER * 1024 * 1024
MAX_UPLOAD_BYTES_ADMIN = _MAX_UPLOAD_MB_ADMIN * 1024 * 1024 MAX_UPLOAD_BYTES_ADMIN = _MAX_UPLOAD_MB_ADMIN * 1024 * 1024
ALLOWED_UPLOAD_MIMES = frozenset( 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: def _upload_limit_bytes(tenant: TenantContext) -> int:
role = tenant.global_role or "" role = tenant.global_role or ""
@ -2302,17 +2369,21 @@ async def upload_exercise_media(
status_code=413, status_code=413,
detail=f"Datei zu groß (max. {max_upload // (1024 * 1024)} MB)", detail=f"Datei zu groß (max. {max_upload // (1024 * 1024)} MB)",
) )
mime = file.content_type or "" try:
if mime not in ALLOWED_UPLOAD_MIMES: mime = resolve_upload_mime_type(raw, file.content_type, file.filename)
raise HTTPException( except ValueError as e:
status_code=400, raise HTTPException(status_code=400, detail=str(e)) from e
detail=f"Dateityp nicht erlaubt: {mime or 'unbekannt'}",
)
ext = Path(file.filename or "").suffix[:12] if file.filename else "" ext = Path(file.filename or "").suffix[:12] if file.filename else ""
if not ext and mime == "image/jpeg": if not ext and mime == "image/jpeg":
ext = ".jpg" ext = ".jpg"
elif not ext and mime == "image/png": elif not ext and mime == "image/png":
ext = ".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( cur.execute(
"SELECT visibility, club_id, created_by FROM exercises WHERE id = %s", "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 media_storage import get_effective_media_root, path_under_media_root
from tenant_context import TenantContext, get_tenant_context, get_tenant_context_flexible 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"]) 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)", detail=f"Datei zu groß (max. {max_b // (1024 * 1024)} MB)",
) )
mime = (content_type or "").split(";")[0].strip().lower() try:
if mime not in ALLOWED_UPLOAD_MIMES: mime = resolve_upload_mime_type(raw, content_type, filename)
raise HTTPException( except ValueError as e:
status_code=400, raise HTTPException(status_code=400, detail=str(e)) from e
detail=f"Dateityp nicht erlaubt: {mime or 'unbekannt'}",
)
full_sha = hashlib.sha256(raw).hexdigest() full_sha = hashlib.sha256(raw).hexdigest()
cur.execute( cur.execute(
@ -532,6 +530,12 @@ def _ingest_library_media_file(
ext = ".jpg" ext = ".jpg"
elif not ext and mime == "image/png": elif not ext and mime == "image/png":
ext = ".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) media_root = get_effective_media_root(cur)
storage_key = f"exercises/{full_sha}{ext}" storage_key = f"exercises/{full_sha}{ext}"

View File

@ -1460,7 +1460,7 @@ function ExerciseFormPage() {
<label className="form-label">Datei</label> <label className="form-label">Datei</label>
<input <input
type="file" 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)} onChange={(e) => setMediaFile(e.target.files?.[0] || null)}
/> />
<div className="form-row" style={{ marginTop: '8px' }}> <div className="form-row" style={{ marginTop: '8px' }}>

View File

@ -153,6 +153,10 @@ function uploaderLabel(it, viewer) {
function MediaThumb({ mediaId, mimeType }) { function MediaThumb({ mediaId, mimeType }) {
const url = resolveMediaAssetFileUrl(mediaId) const url = resolveMediaAssetFileUrl(mediaId)
const mime = (mimeType || '').toLowerCase() 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/')) { if (mime.startsWith('video/')) {
return ( return (
<video <video
@ -609,7 +613,7 @@ export default function MediaLibraryPage() {
ref={bulkFileInputRef} ref={bulkFileInputRef}
type="file" type="file"
className="media-library__sr-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 multiple
onChange={onBulkArchiveFiles} onChange={onBulkArchiveFiles}
/> />
@ -857,9 +861,22 @@ export default function MediaLibraryPage() {
{(() => { {(() => {
const url = resolveMediaAssetFileUrl(preview.id) const url = resolveMediaAssetFileUrl(preview.id)
const kind = previewDisplayKind(preview.mime_type) const kind = previewDisplayKind(preview.mime_type)
const mlow = (preview.mime_type || '').toLowerCase()
if (!url) { if (!url) {
return <p className="media-library__hint">Keine Datei-URL.</p> 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') { if (kind === 'image') {
return ( return (
<img <img

View File

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