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
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:
parent
d213746bd2
commit
cc4e621f95
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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' }}>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user