Medienmanager und Sicherheitsupdate #21

Merged
Lars merged 15 commits from develop into main 2026-05-07 16:00:19 +02:00
Showing only changes of commit 9365125969 - Show all commits

View File

@ -8,11 +8,13 @@ import hashlib
import json
import logging
import os
import re
from pathlib import Path
from typing import Any, Dict, List, Optional
from typing import Any, Dict, Iterator, List, Optional, Tuple
from urllib.parse import quote
from fastapi import APIRouter, HTTPException, Depends, Query, UploadFile, File, Form
from fastapi.responses import FileResponse
from fastapi import APIRouter, HTTPException, Depends, Query, Request, UploadFile, File, Form
from fastapi.responses import FileResponse, Response, StreamingResponse
from pydantic import BaseModel, Field, model_validator
from db import get_db, get_cursor, r2d
@ -1818,6 +1820,120 @@ def delete_exercise_variant(
return {"ok": True}
def _content_disposition_inline(filename: Optional[str]) -> str:
"""Inline-Darstellung im Browser (<video>/<img>), keine attachment-Download-Leiste."""
if not filename:
return "inline"
fn = str(filename).strip()
quoted = quote(fn, safe="")
escaped = fn.replace('"', "\\")
if quoted != fn:
return f"inline; filename*=utf-8''{quoted}"
return f'inline; filename="{escaped}"'
def _parse_bytes_range_single(range_header: str, file_size: int) -> Optional[Tuple[int, int]]:
"""
Aus Range-Header einen inklusiven (start, end)-Bereich; None = gesamte Datei.
Nur ein bytes=-Segment (erstes Segment bei mehreren, durch Komma getrennt).
"""
if not range_header:
return None
hdr = range_header.strip()
if "," in hdr:
hdr = hdr.split(",", 1)[0].strip()
m = re.match(r"^bytes=(\d*)-(\d*)$", hdr)
if not m:
return None
start_s, end_s = m.group(1), m.group(2)
if not start_s and not end_s:
return None
if file_size <= 0:
return None
last_idx = file_size - 1
if start_s == "" and end_s != "":
suffix = int(end_s)
if suffix <= 0:
return None
start = max(0, file_size - suffix)
end = last_idx
elif start_s != "" and end_s == "":
start = int(start_s)
end = last_idx
else:
start = int(start_s)
end = int(end_s)
end = min(end, last_idx)
if start > last_idx or start > end:
return None
return start, end
def _iter_file_chunks(path_str: str, start: int, length: int) -> Iterator[bytes]:
chunk_size = 64 * 1024
remaining = length
with open(path_str, "rb") as f:
f.seek(start)
while remaining > 0:
blob = f.read(min(chunk_size, remaining))
if not blob:
break
remaining -= len(blob)
yield blob
def _binary_media_response(
path_arg: Path, mime_type: str, download_name: Optional[str], request: Request
):
"""Vollständige oder Range-Download-Antwort; MP4/streaming brauchen oft 206 + Content-Range."""
path_str = str(path_arg.resolve())
stat_r = os.stat(path_str)
file_size = int(stat_r.st_size)
cd_val = _content_disposition_inline(download_name)
base_h = {"accept-ranges": "bytes", "content-disposition": cd_val}
if request.method.upper() == "HEAD":
return Response(
status_code=200,
headers={**base_h, "content-type": mime_type or "application/octet-stream", "content-length": str(file_size)},
)
rng = request.headers.get("range")
if rng and request.method.upper() != "HEAD":
rstrip = rng.strip()
if re.match(r"^bytes=(\d*)-(\d*)$", rstrip):
parsed = _parse_bytes_range_single(rng, file_size)
if parsed is None:
return Response(
status_code=416,
headers={
"accept-ranges": "bytes",
"content-range": f"bytes */{file_size}",
},
)
start, end_incl = parsed
content_length = end_incl - start + 1
hdrs = {
**base_h,
"content-type": mime_type or "application/octet-stream",
"content-range": f"bytes {start}-{end_incl}/{file_size}",
"content-length": str(content_length),
}
return StreamingResponse(
_iter_file_chunks(path_str, start, content_length),
status_code=206,
headers=hdrs,
media_type=mime_type or "application/octet-stream",
)
return FileResponse(
path_str,
media_type=mime_type or "application/octet-stream",
stat_result=stat_r,
headers=base_h,
)
# --- Medien (MEDIA_UPLOAD_SPEC.md / EXERCISES_API_SPEC.md) ---
@ -1832,15 +1948,16 @@ def _fetch_media_row(cur, exercise_id: int, media_id: int) -> Optional[dict]:
return r2d(row) if row else None
@router.get("/exercises/{exercise_id}/media/{media_id}/file")
@router.api_route("/exercises/{exercise_id}/media/{media_id}/file", methods=["GET", "HEAD"])
def download_exercise_media_file(
request: Request,
exercise_id: int,
media_id: int,
tenant: TenantContext = Depends(get_tenant_context_flexible),
):
"""
Dateiauslieferung mit Governance wie GET Übung Auth via X-Auth-Token oder ?ssetoken (für <img>/<video>).
Direktes /media/... ohne Token ist nicht vorgesehen (kein anonymes Hosting).
Unterstützt HTTP Range (206) für MP4-Streaming und Content-Disposition: inline (kein attachment).
"""
with get_db() as conn:
cur = get_cursor(conn)
@ -1858,11 +1975,7 @@ def download_exercise_media_file(
mime = media.get("mime_type") or "application/octet-stream"
fname = media.get("original_filename") or abs_p.name
return FileResponse(
path=str(abs_p.resolve()),
media_type=mime,
filename=str(fname),
)
return _binary_media_response(abs_p, mime, str(fname) if fname else None, request)
@router.post("/exercises/{exercise_id}/media", status_code=201)