MediaPfad extern, Upload Manager Bug Fixes #23

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

View File

@ -2,7 +2,6 @@
from __future__ import annotations from __future__ import annotations
import os import os
import re
import shutil import shutil
from pathlib import Path from pathlib import Path
from typing import Any, Optional from typing import Any, Optional
@ -45,6 +44,44 @@ def get_effective_media_root(cur: Any) -> Path:
return (base / rel).resolve() return (base / rel).resolve()
def library_media_kind_dir(mime_type: Optional[str], ext: str) -> str:
"""
Unterordner unter library/* an API-Filter media_kind angelehnt (image/video/pdf/other).
Bei fehlendem MIME wird anhand der Dateiendung geraten (letzte Instanz: other).
"""
m = (mime_type or "").strip().lower()
x = (ext or "").strip().lower()
if x and not x.startswith("."):
x = "." + x
if m.startswith("image/"):
return "image"
if m.startswith("video/"):
return "video"
if m == "application/pdf" or (m and "pdf" in m):
return "pdf"
if x in (
".jpg",
".jpeg",
".png",
".gif",
".webp",
".heic",
".heif",
".bmp",
".svg",
".avif",
):
return "image"
if x in (".mp4", ".mov", ".webm", ".mkv", ".avi", ".m4v"):
return "video"
if x == ".pdf":
return "pdf"
return "other"
def path_under_media_root(media_root: Path, storage_key: str) -> Optional[Path]: def path_under_media_root(media_root: Path, storage_key: str) -> Optional[Path]:
"""Gibt absoluten Pfad zurück oder None bei Path-Traversal.""" """Gibt absoluten Pfad zurück oder None bei Path-Traversal."""
key = (storage_key or "").strip().replace("\\", "/").lstrip("/") key = (storage_key or "").strip().replace("\\", "/").lstrip("/")
@ -65,13 +102,16 @@ def library_storage_key(
ext: str, ext: str,
*, *,
uploader_profile_id: Optional[int] = None, uploader_profile_id: Optional[int] = None,
mime_type: Optional[str] = None,
) -> str: ) -> str:
""" """
Relativer Speicherpfad unter MEDIA_ROOT für lokale media_assets. Relativer Speicherpfad unter MEDIA_ROOT für lokale media_assets.
- official library/official/{sha256}{ext} - official library/official/{kind}/{sha256}{ext}
- club (vereinsgeteilt) library/club/c{club_id}/{sha256}{ext} - club (vereinsgeteilt) library/club/c{club_id}/{kind}/{sha256}{ext}
- private (nur Hochlader, Governance unverändert) library/club/c{club_id}/u{profile}/{sha256}{ext} - private (nur Hochlader, Governance unverändert) library/club/c{club_id}/u{profile}/{kind}/{sha256}{ext}
kind {image, video, pdf, other} siehe library_media_kind_dir.
Kein Ordnername private auf der Platte: Zuordnung erfolgt über Uploader unter dem Verein. Kein Ordnername private auf der Platte: Zuordnung erfolgt über Uploader unter dem Verein.
""" """
@ -88,11 +128,12 @@ def library_storage_key(
e = ".bin" e = ".bin"
if not e.startswith("."): if not e.startswith("."):
e = "." + e e = "." + e
e = e[:16]
if ".." in e or "/" in e or "\\" in e or "\x00" in e: if ".." in e or "/" in e or "\\" in e or "\x00" in e:
raise ValueError("Ungültige Dateiendung") raise ValueError("Ungültige Dateiendung")
blob = f"{sha}{e}" kind = library_media_kind_dir(mime_type, e)
e = e[:16]
blob = f"{kind}/{sha}{e}"
if vis == "official": if vis == "official":
return f"library/official/{blob}" return f"library/official/{blob}"
if club_id is None: if club_id is None:

View File

@ -2525,6 +2525,7 @@ async def upload_exercise_media(
full_sha, full_sha,
ext, ext,
uploader_profile_id=profile_id if ex_vis == "private" else None, uploader_profile_id=profile_id if ex_vis == "private" else None,
mime_type=mime,
) )
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e raise HTTPException(status_code=400, detail=str(e)) from e

View File

@ -340,6 +340,7 @@ def _relocate_asset_file_if_governance_changed(
sha, sha,
ext, ext,
uploader_profile_id=up_i if next_vis == "private" else None, uploader_profile_id=up_i if next_vis == "private" else None,
mime_type=str(asset.get("mime_type") or "") or None,
) )
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e raise HTTPException(status_code=400, detail=str(e)) from e
@ -630,6 +631,7 @@ def _ingest_library_media_file(
full_sha, full_sha,
ext, ext,
uploader_profile_id=profile_id if vis == "private" else None, uploader_profile_id=profile_id if vis == "private" else None,
mime_type=mime,
) )
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e raise HTTPException(status_code=400, detail=str(e)) from e
@ -1009,7 +1011,7 @@ def bulk_media_patch(
try: try:
cur.execute( cur.execute(
"""SELECT id, visibility, club_id, uploaded_by_profile_id, lifecycle_state, """SELECT id, visibility, club_id, uploaded_by_profile_id, lifecycle_state,
copyright_notice, original_filename, sha256, storage_key, storage_backend copyright_notice, original_filename, sha256, storage_key, storage_backend, mime_type
FROM media_assets WHERE id = %s""", FROM media_assets WHERE id = %s""",
(asset_id,), (asset_id,),
) )
@ -1113,7 +1115,7 @@ def patch_media_asset(
cur = get_cursor(conn) cur = get_cursor(conn)
cur.execute( cur.execute(
"""SELECT id, visibility, club_id, uploaded_by_profile_id, lifecycle_state, """SELECT id, visibility, club_id, uploaded_by_profile_id, lifecycle_state,
copyright_notice, original_filename, sha256, storage_key, storage_backend copyright_notice, original_filename, sha256, storage_key, storage_backend, mime_type
FROM media_assets WHERE id = %s""", FROM media_assets WHERE id = %s""",
(asset_id,), (asset_id,),
) )

View File

@ -3,31 +3,49 @@ from __future__ import annotations
import pytest import pytest
from media_storage import library_storage_key from media_storage import library_media_kind_dir, library_storage_key
_HEX64 = "a" * 64 _HEX64 = "a" * 64
def test_library_media_kind_dir_mime_overrides_ext() -> None:
assert library_media_kind_dir("image/png", ".bin") == "image"
assert library_media_kind_dir("application/pdf", ".tmp") == "pdf"
def test_library_storage_key_private_is_uploader_under_club() -> None: def test_library_storage_key_private_is_uploader_under_club() -> None:
assert ( assert (
library_storage_key("private", 7, _HEX64, ".jpg", uploader_profile_id=99) library_storage_key("private", 7, _HEX64, ".jpg", uploader_profile_id=99)
== f"library/club/c7/u99/{_HEX64}.jpg" == f"library/club/c7/u99/image/{_HEX64}.jpg"
) )
def test_library_storage_key_club_flat_under_club() -> None: def test_library_storage_key_club_flat_under_club() -> None:
assert library_storage_key("club", 42, _HEX64, ".png") == f"library/club/c42/{_HEX64}.png" assert (
library_storage_key("club", 42, _HEX64, ".png")
== f"library/club/c42/image/{_HEX64}.png"
)
def test_library_storage_key_official() -> None: def test_library_storage_key_official() -> None:
assert ( assert (
library_storage_key("official", None, _HEX64, ".mp4", uploader_profile_id=1) library_storage_key("official", None, _HEX64, ".mp4", uploader_profile_id=1)
== f"library/official/{_HEX64}.mp4" == f"library/official/video/{_HEX64}.mp4"
) )
def test_library_storage_key_normalizes_visibility() -> None: def test_library_storage_key_normalizes_visibility() -> None:
assert library_storage_key(" CLUB ", 1, _HEX64, "pdf") == f"library/club/c1/{_HEX64}.pdf" assert (
library_storage_key(" CLUB ", 1, _HEX64, "pdf")
== f"library/club/c1/pdf/{_HEX64}.pdf"
)
def test_library_storage_key_other_bin() -> None:
assert (
library_storage_key("official", None, _HEX64, "", mime_type="application/octet-stream")
== f"library/official/other/{_HEX64}.bin"
)
def test_library_storage_key_private_requires_uploader() -> None: def test_library_storage_key_private_requires_uploader() -> None:

View File

@ -16,9 +16,9 @@ from main import app
from tenant_context import TenantContext, get_tenant_context from tenant_context import TenantContext, get_tenant_context
# Gültige storage_key-Beispiele (64 Hex-Zeichen wie echter SHA-256) # Gültige storage_key-Beispiele (64 Hex-Zeichen wie echter SHA-256)
_SK_OFF_A = f"library/official/{'a' * 64}.jpg" _SK_OFF_A = f"library/official/image/{'a' * 64}.jpg"
_SK_OFF_B = f"library/official/{'b' * 64}.jpg" _SK_OFF_B = f"library/official/image/{'b' * 64}.jpg"
_SK_PRIV_C = f"library/club/c1/u1/{'c' * 64}.mp4" _SK_PRIV_C = f"library/club/c1/u1/video/{'c' * 64}.mp4"
@pytest.fixture @pytest.fixture

View File

@ -1,7 +1,7 @@
# Shinkan Jinkendo Version Information # Shinkan Jinkendo Version Information
APP_VERSION = "0.8.54" APP_VERSION = "0.8.55"
BUILD_DATE = "2026-05-08" BUILD_DATE = "2026-05-07"
DB_SCHEMA_VERSION = "20260508049" DB_SCHEMA_VERSION = "20260508049"
MODULE_VERSIONS = { MODULE_VERSIONS = {
@ -13,11 +13,11 @@ MODULE_VERSIONS = {
"club_join_requests": "1.0.1", # Depends(get_tenant_context) "club_join_requests": "1.0.1", # Depends(get_tenant_context)
"admin_users": "1.0.0", # GET /api/admin/users "admin_users": "1.0.0", # GET /api/admin/users
"platform_media_storage": "1.0.0", # GET/PUT /api/admin/platform-media-storage (Superadmin-Pfad unter MEDIA_ROOT) "platform_media_storage": "1.0.0", # GET/PUT /api/admin/platform-media-storage (Superadmin-Pfad unter MEDIA_ROOT)
"media_assets": "1.9.0", # club: flach unter library/club/c*; private: …/u{profile}/; Dedupe private + uploader "media_assets": "1.10.0", # library/*/image|video|pdf|other/ Unterordner (Medientyp)
"groups": "0.1.0", "groups": "0.1.0",
"skills": "0.1.0", "skills": "0.1.0",
"methods": "0.1.0", "methods": "0.1.0",
"exercises": "2.17.2", # Dedupe private + uploaded_by; storage_key mit uploader_profile_id "exercises": "2.17.3", # library_storage_key mit mime_type (Medientyp-Ordner)
"training_units": "0.2.0", "training_units": "0.2.0",
"training_programs": "0.1.0", "training_programs": "0.1.0",
"planning": "0.8.1", # Vorlagen Leseprüfung library_content_visible_to_profile "planning": "0.8.1", # Vorlagen Leseprüfung library_content_visible_to_profile
@ -29,6 +29,13 @@ MODULE_VERSIONS = {
} }
CHANGELOG = [ CHANGELOG = [
{
"version": "0.8.55",
"date": "2026-05-07",
"changes": [
"Medienbibliothek: Speicherpfad library/* mit Medientyp-Unterordnern (image, video, pdf, other) vor SHA-Dateinamen; Umzug bei Governance-PATCH nutzt mime_type aus DB",
],
},
{ {
"version": "0.8.54", "version": "0.8.54",
"date": "2026-05-08", "date": "2026-05-08",