diff --git a/backend/media_storage.py b/backend/media_storage.py index f24a6a4..9362f82 100644 --- a/backend/media_storage.py +++ b/backend/media_storage.py @@ -3,6 +3,7 @@ from __future__ import annotations import os import re +import shutil from pathlib import Path from typing import Any, Optional @@ -55,3 +56,78 @@ def path_under_media_root(media_root: Path, storage_key: str) -> Optional[Path]: except ValueError: return None return p + + +def library_storage_key( + visibility: str, + club_id: Optional[int], + sha256_hex: str, + ext: str, +) -> str: + """ + Relativer Speicherpfad unter MEDIA_ROOT für lokale media_assets. + + Layout (Mandantentrennung, globale Medien getrennt): + - private → library/private/{sha256}{ext} + - club → library/club/c{club_id}/{sha256}{ext} + - official → library/official/{sha256}{ext} + """ + vis = (visibility or "private").strip().lower() + if vis not in ("private", "club", "official"): + raise ValueError(f"Ungültige Sichtbarkeit für Speicherpfad: {visibility!r}") + + sha = (sha256_hex or "").strip().lower() + if len(sha) != 64 or any(c not in "0123456789abcdef" for c in sha): + raise ValueError("sha256_hex muss 64 Hex-Zeichen sein") + + e = (ext or "").strip() + if not e: + e = ".bin" + if not e.startswith("."): + e = "." + e + e = e[:16] + if ".." in e or "/" in e or "\\" in e or "\x00" in e: + raise ValueError("Ungültige Dateiendung") + + blob = f"{sha}{e}" + if vis == "private": + return f"library/private/{blob}" + if vis == "official": + return f"library/official/{blob}" + if club_id is None: + raise ValueError("club_id ist für Sichtbarkeit „Verein“ erforderlich") + cid = int(club_id) + if cid < 1: + raise ValueError("club_id muss eine positive Ganzzahl sein") + return f"library/club/c{cid}/{blob}" + + +def relocate_local_media_file(media_root: Path, old_storage_key: str, new_storage_key: str) -> None: + """ + Physisches Verschieben bei geändertem library_*-Pfad (z. B. nach PATCH visibility/club_id). + + - Kein Op, wenn Quelle fehlt aber Ziel bereits existiert (idempotent). + - Erwartet storage_backend=local; Aufrufer prüft das. + """ + if (old_storage_key or "").replace("\\", "/").lstrip("/") == (new_storage_key or "").replace( + "\\", "/" + ).lstrip("/"): + return + + old_p = path_under_media_root(media_root, old_storage_key) + new_p = path_under_media_root(media_root, new_storage_key) + if old_p is None or new_p is None: + raise ValueError("Ungültiger Speicherpfad (Path-Traversal)") + + if new_p.is_file(): + if not old_p.is_file(): + return + if old_p.resolve() == new_p.resolve(): + return + raise FileExistsError(f"Zieldatei existiert bereits: {new_storage_key}") + + if not old_p.is_file(): + raise FileNotFoundError(f"Medien-Quelldatei nicht gefunden: {old_storage_key}") + + new_p.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(old_p), str(new_p)) diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py index e416f02..9007360 100644 --- a/backend/routers/exercises.py +++ b/backend/routers/exercises.py @@ -28,7 +28,7 @@ from club_tenancy import ( library_content_visible_to_profile, ) from tenant_context import TenantContext, get_tenant_context, get_tenant_context_flexible, library_content_visibility_sql -from media_storage import get_effective_media_root, path_under_media_root +from media_storage import get_effective_media_root, library_storage_key, path_under_media_root logger = logging.getLogger(__name__) @@ -2490,7 +2490,10 @@ async def upload_exercise_media( }, ) else: - storage_key = f"exercises/{full_sha}{ext}" + try: + storage_key = library_storage_key(ex_vis, ex_club, full_sha, ext) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e dest_path = path_under_media_root(media_root, storage_key) if dest_path is None: raise HTTPException(status_code=500, detail="Ungültiger Speicherpfad") diff --git a/backend/routers/media_assets.py b/backend/routers/media_assets.py index db714d9..2f885e4 100644 --- a/backend/routers/media_assets.py +++ b/backend/routers/media_assets.py @@ -33,7 +33,7 @@ from media_lifecycle import ( transition_to_trash_hidden, transition_to_trash_soft, ) -from media_storage import get_effective_media_root, path_under_media_root +from media_storage import get_effective_media_root, library_storage_key, path_under_media_root, relocate_local_media_file from tenant_context import TenantContext, get_tenant_context, get_tenant_context_flexible from routers.exercises import _upload_limit_bytes, resolve_upload_mime_type @@ -304,6 +304,49 @@ def _effective_media_patch_fields(patch_fields: dict, asset: dict) -> dict: return eff +def _relocate_asset_file_if_governance_changed( + cur: Any, + media_root: Path, + asset_id: int, + asset: dict, + next_vis: str, + next_club_id: Optional[int], +) -> Optional[str]: + """ + Passt bei local-Assets den Dateipfad an, wenn sich Sichtbarkeit/Verein ändert. + Aktualisiert exercise_media.file_path. Gibt neuen storage_key oder None zurück. + """ + if (asset.get("storage_backend") or "local") != "local": + return None + old_key = (asset.get("storage_key") or "").strip() + sha = (asset.get("sha256") or "").strip().lower() + if not old_key or len(sha) != 64: + return None + ext = Path(old_key.replace("\\", "/")).suffix or ".bin" + try: + new_key = library_storage_key(next_vis, next_club_id, sha, ext) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + old_norm = old_key.replace("\\", "/").lstrip("/") + if new_key == old_norm: + return None + try: + relocate_local_media_file(media_root, old_key, new_key) + except FileNotFoundError as e: + raise HTTPException(status_code=500, detail=f"Mediendatei fehlt auf der Platte: {e}") from e + except FileExistsError as e: + raise HTTPException(status_code=409, detail=str(e)) from e + except (ValueError, OSError) as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + db_path = f"/media/{new_key}" + cur.execute( + "UPDATE exercise_media SET file_path = %s WHERE media_asset_id = %s", + (db_path, asset_id), + ) + return new_key + + def _lifecycle_where_sql(lifecycle: str) -> str: lc = (lifecycle or "active").strip().lower() if lc not in _LIFECYCLE_LIST_FILTERS: @@ -538,7 +581,10 @@ def _ingest_library_media_file( ext = ".mov" media_root = get_effective_media_root(cur) - storage_key = f"exercises/{full_sha}{ext}" + try: + storage_key = library_storage_key(vis, next_cid, full_sha, ext) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e dest_path = path_under_media_root(media_root, storage_key) if dest_path is None: raise HTTPException(status_code=500, detail="Ungültiger Speicherpfad") @@ -915,7 +961,7 @@ def bulk_media_patch( try: cur.execute( """SELECT id, visibility, club_id, uploaded_by_profile_id, lifecycle_state, - copyright_notice, original_filename + copyright_notice, original_filename, sha256, storage_key, storage_backend FROM media_assets WHERE id = %s""", (asset_id,), ) @@ -947,6 +993,16 @@ def bulk_media_patch( int(next_cid) if next_cid is not None else None, ) + new_sk: Optional[str] = None + if "visibility" in patch_fields or "club_id" in patch_fields: + next_club_param: Optional[int] = None + if next_vis == "club": + next_club_param = int(next_cid) if next_cid is not None else None + media_root = get_effective_media_root(cur) + new_sk = _relocate_asset_file_if_governance_changed( + cur, media_root, asset_id, asset, next_vis, next_club_param + ) + sets: list[str] = [] vals: list[Any] = [] if "copyright_notice" in patch_fields: @@ -963,6 +1019,9 @@ def bulk_media_patch( vals.append(str(eff.get("visibility", asset["visibility"])).strip()) sets.append("club_id = %s") vals.append(eff.get("club_id")) + if new_sk: + sets.append("storage_key = %s") + vals.append(new_sk) if not sets: failed.append({"id": asset_id, "detail": "Nichts zu aktualisieren"}) continue @@ -996,7 +1055,7 @@ def patch_media_asset( cur = get_cursor(conn) cur.execute( """SELECT id, visibility, club_id, uploaded_by_profile_id, lifecycle_state, - copyright_notice, original_filename + copyright_notice, original_filename, sha256, storage_key, storage_backend FROM media_assets WHERE id = %s""", (asset_id,), ) @@ -1024,6 +1083,16 @@ def patch_media_asset( int(next_cid) if next_cid is not None else None, ) + new_sk: Optional[str] = None + if "visibility" in data or "club_id" in data: + next_club_param: Optional[int] = None + if next_vis == "club": + next_club_param = int(next_cid) if next_cid is not None else None + media_root = get_effective_media_root(cur) + new_sk = _relocate_asset_file_if_governance_changed( + cur, media_root, asset_id, asset, next_vis, next_club_param + ) + sets: list[str] = [] vals: list[Any] = [] if "copyright_notice" in data: @@ -1040,6 +1109,9 @@ def patch_media_asset( vals.append(str(eff.get("visibility", asset["visibility"])).strip()) sets.append("club_id = %s") vals.append(eff.get("club_id")) + if new_sk: + sets.append("storage_key = %s") + vals.append(new_sk) if sets: sets.append("updated_at = NOW()") vals.append(asset_id) diff --git a/backend/tests/test_library_storage_key.py b/backend/tests/test_library_storage_key.py new file mode 100644 index 0000000..be10ab7 --- /dev/null +++ b/backend/tests/test_library_storage_key.py @@ -0,0 +1,49 @@ +"""library_storage_key: Mandantenpfade unter MEDIA_ROOT.""" +from __future__ import annotations + +import pytest + +from media_storage import library_storage_key + +_HEX64 = "a" * 64 + + +def test_library_storage_key_private() -> None: + assert library_storage_key("private", None, _HEX64, ".jpg") == f"library/private/{_HEX64}.jpg" + + +def test_library_storage_key_official() -> None: + assert library_storage_key("official", None, _HEX64, ".mp4") == f"library/official/{_HEX64}.mp4" + + +def test_library_storage_key_club() -> None: + assert library_storage_key("club", 42, _HEX64, ".png") == f"library/club/c42/{_HEX64}.png" + + +def test_library_storage_key_normalizes_visibility() -> None: + assert library_storage_key(" CLUB ", 1, _HEX64, "pdf") == f"library/club/c1/{_HEX64}.pdf" + + +def test_library_storage_key_club_requires_id() -> None: + with pytest.raises(ValueError, match="club_id"): + library_storage_key("club", None, _HEX64, ".jpg") + + +def test_library_storage_key_club_id_positive() -> None: + with pytest.raises(ValueError, match="positiv"): + library_storage_key("club", 0, _HEX64, ".jpg") + + +def test_library_storage_key_invalid_visibility() -> None: + with pytest.raises(ValueError, match="Sichtbarkeit"): + library_storage_key("public", None, _HEX64, ".jpg") + + +def test_library_storage_key_invalid_sha() -> None: + with pytest.raises(ValueError, match="64"): + library_storage_key("private", None, "deadbeef", ".jpg") + + +def test_library_storage_key_extension_sanitized() -> None: + with pytest.raises(ValueError): + library_storage_key("private", None, _HEX64, "../x") diff --git a/backend/tests/test_media_assets_archive.py b/backend/tests/test_media_assets_archive.py index d5ba65c..5887ddb 100644 --- a/backend/tests/test_media_assets_archive.py +++ b/backend/tests/test_media_assets_archive.py @@ -15,6 +15,11 @@ from auth import require_auth from main import app from tenant_context import TenantContext, get_tenant_context +# Gültige storage_key-Beispiele (64 Hex-Zeichen wie echter SHA-256) +_SK_OFF_A = f"library/official/{'a' * 64}.jpg" +_SK_OFF_B = f"library/official/{'b' * 64}.jpg" +_SK_PRIV_C = f"library/private/{'c' * 64}.mp4" + @pytest.fixture def client() -> TestClient: @@ -136,7 +141,7 @@ def test_attach_from_asset_duplicate_returns_400(client: TestClient) -> None: "club_id": None, "uploaded_by_profile_id": 1, "lifecycle_state": "active", - "storage_key": "exercises/x.jpg", + "storage_key": _SK_OFF_A, }, {"id": 1}, ] @@ -175,7 +180,7 @@ def test_attach_from_asset_ok_mocked(client: TestClient) -> None: "id": 99, "exercise_id": 3, "media_type": "image", - "file_path": "/media/exercises/h.jpg", + "file_path": f"/media/{_SK_OFF_B}", "file_size": 10, "mime_type": "image/jpeg", "original_filename": "h.jpg", @@ -202,7 +207,7 @@ def test_attach_from_asset_ok_mocked(client: TestClient) -> None: "club_id": None, "uploaded_by_profile_id": 1, "lifecycle_state": "active", - "storage_key": "exercises/h.jpg", + "storage_key": _SK_OFF_B, }, None, inserted, @@ -329,7 +334,7 @@ def test_media_asset_lifecycle_reactivate_mocked(client: TestClient) -> None: "club_id": None, "uploaded_by_profile_id": 1, "lifecycle_state": "trash_soft", - "storage_key": "exercises/a.mp4", + "storage_key": _SK_PRIV_C, "storage_backend": "local", "trash_soft_at": None, "trash_hidden_at": None, diff --git a/backend/version.py b/backend/version.py index 317abc7..90a4d24 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,8 +1,8 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.51" -BUILD_DATE = "2026-05-07" -DB_SCHEMA_VERSION = "20260507046" +APP_VERSION = "0.8.52" +BUILD_DATE = "2026-05-08" +DB_SCHEMA_VERSION = "20260508047" MODULE_VERSIONS = { "auth": "1.2.1", # Login-Rate-Limit 30/minute pro IP (vorher 5/minute) @@ -13,11 +13,11 @@ MODULE_VERSIONS = { "club_join_requests": "1.0.1", # Depends(get_tenant_context) "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) - "media_assets": "1.5.1", # usage: training_unit_exercises optional (Schema ohne planning-Tabelle) + "media_assets": "1.7.0", # library/* paths; Relocate bei Governance-PATCH "groups": "0.1.0", "skills": "0.1.0", "methods": "0.1.0", - "exercises": "2.16.0", # §4.2 official: angehängte media_assets + Copyright (PUT + bulk-metadata) + "exercises": "2.17.0", # Medien-Upload storage_key library/private|club|official "training_units": "0.2.0", "training_programs": "0.1.0", "planning": "0.8.1", # Vorlagen Leseprüfung library_content_visible_to_profile @@ -29,6 +29,13 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.52", + "date": "2026-05-08", + "changes": [ + "Neue lokale media_assets: storage_key unter library/private, library/club/c{id}, library/official (SHA256+Ext); Dedupe unverändert;bei PATCH/Bulk-Patch Sichtbarkeit/Verein: Datei wird mit umgezogen, exercise_media.file_path aktualisiert", + ], + }, { "version": "0.8.51", "date": "2026-05-07",