Compare commits

..

No commits in common. "01636b5baf8ac60bfb710106418c6a5740af1523" and "3f0d02edab32f2b0e2759a7e6128feeb5929c451" have entirely different histories.

6 changed files with 51 additions and 283 deletions

View File

@ -4,22 +4,9 @@ from __future__ import annotations
import os import os
import re import re
import shutil import shutil
import unicodedata
from pathlib import Path from pathlib import Path
from typing import Any, Optional from typing import Any, Optional
_CLUB_SLUG_TRANS = str.maketrans(
{
"ä": "ae",
"ö": "oe",
"ü": "ue",
"ß": "ss",
"Ä": "ae",
"Ö": "oe",
"Ü": "ue",
}
)
def _default_media_root() -> Path: def _default_media_root() -> Path:
return Path(os.getenv("MEDIA_ROOT", str(Path(__file__).resolve().parent / "media"))) return Path(os.getenv("MEDIA_ROOT", str(Path(__file__).resolve().parent / "media")))
@ -58,70 +45,6 @@ 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 library_club_path_segment(club_id: int, club_name: Optional[str]) -> str:
"""
Ein Verzeichnissegment pro Verein: aus Anzeigenamen abgeleitet + -c{id} für Eindeutigkeit
(Umbenennung, Kollisionen nach Slugify). Nur [a-z0-9-], keine Pfad-Sonderzeichen.
Fehlt der Name / Slug leer verein-c{id}.
"""
cid = int(club_id)
if cid < 1:
raise ValueError("club_id muss eine positive Ganzzahl sein")
raw = unicodedata.normalize("NFKC", (club_name or "").strip()).translate(_CLUB_SLUG_TRANS).lower()
raw = re.sub(r"[^a-z0-9]+", "-", raw)
raw = raw.strip("-")
if len(raw) > 48:
raw = raw[:48].rstrip("-")
if not raw:
base = f"verein-c{cid}"
else:
base = f"{raw}-c{cid}"
if not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", base):
raise ValueError("Ungültiger abgeleiteter Vereins-Ordnername")
return base
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("/")
@ -140,23 +63,16 @@ def library_storage_key(
club_id: Optional[int], club_id: Optional[int],
sha256_hex: str, sha256_hex: str,
ext: str, ext: str,
*,
uploader_profile_id: Optional[int] = None,
mime_type: Optional[str] = None,
club_name: 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/{kind}/{sha256}{ext} Layout (nach Verein gegliedert, globale Medien getrennt):
- club (vereinsgeteilt) library/{vereins-segment}/{kind}/{sha256}{ext} - private library/club/c{club_id}/private/{sha256}{ext}
- private (nur Hochlader) library/{vereins-segment}/u{profile}/{kind}/{sha256}{ext} - club (Vereinssichtbarkeit) library/club/c{club_id}/shared/{sha256}{ext}
- official library/official/{sha256}{ext}
Vereins-Segment: aus club_name abgeleitet + -c{club_id} siehe library_club_path_segment. club_id ist bei private/club zwingend (Vereinsordner); bei official nicht genutzt.
kind {image, video, pdf, other} siehe library_media_kind_dir.
Kein Ordnername private auf der Platte: Zuordnung erfolgt über Uploader unter dem Verein.
""" """
vis = (visibility or "private").strip().lower() vis = (visibility or "private").strip().lower()
if vis not in ("private", "club", "official"): if vis not in ("private", "club", "official"):
@ -171,12 +87,11 @@ 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")
kind = library_media_kind_dir(mime_type, e) blob = f"{sha}{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:
@ -184,15 +99,9 @@ def library_storage_key(
cid = int(club_id) cid = int(club_id)
if cid < 1: if cid < 1:
raise ValueError("club_id muss eine positive Ganzzahl sein") raise ValueError("club_id muss eine positive Ganzzahl sein")
club_seg = library_club_path_segment(cid, club_name) if vis == "private":
if vis == "club": return f"library/club/c{cid}/private/{blob}"
return f"library/{club_seg}/{blob}" return f"library/club/c{cid}/shared/{blob}"
if uploader_profile_id is None:
raise ValueError("uploader_profile_id ist für private Medien erforderlich (Ordner u{…} unter dem Verein)")
up = int(uploader_profile_id)
if up < 1:
raise ValueError("uploader_profile_id muss positiv sein")
return f"library/{club_seg}/u{up}/{blob}"
def relocate_local_media_file(media_root: Path, old_storage_key: str, new_storage_key: str) -> None: def relocate_local_media_file(media_root: Path, old_storage_key: str, new_storage_key: str) -> None:

View File

@ -2415,23 +2415,13 @@ async def upload_exercise_media(
raise HTTPException(status_code=400, detail="Vereinsübung ohne club_id") raise HTTPException(status_code=400, detail="Vereinsübung ohne club_id")
dedupe_club = int(ex_club) dedupe_club = int(ex_club)
if ex_vis == "private": cur.execute(
cur.execute( """SELECT id, storage_key, byte_size, lifecycle_state, original_filename FROM media_assets
"""SELECT id, storage_key, byte_size, lifecycle_state, original_filename FROM media_assets WHERE sha256 = %s AND lower(trim(visibility)) = %s
WHERE sha256 = %s AND lower(trim(visibility)) = 'private' AND (club_id IS NOT DISTINCT FROM %s)
AND (club_id IS NOT DISTINCT FROM %s) LIMIT 1""",
AND (uploaded_by_profile_id IS NOT DISTINCT FROM %s) (full_sha, ex_vis, dedupe_club),
LIMIT 1""", )
(full_sha, dedupe_club, profile_id),
)
else:
cur.execute(
"""SELECT id, storage_key, byte_size, lifecycle_state, original_filename FROM media_assets
WHERE sha256 = %s AND lower(trim(visibility)) = %s
AND (club_id IS NOT DISTINCT FROM %s)
LIMIT 1""",
(full_sha, ex_vis, dedupe_club),
)
existing_asset = cur.fetchone() existing_asset = cur.fetchone()
if existing_asset: if existing_asset:
@ -2518,21 +2508,8 @@ async def upload_exercise_media(
}, },
) )
else: else:
club_nm = ""
if dedupe_club is not None:
cur.execute("SELECT name FROM clubs WHERE id = %s", (int(dedupe_club),))
cnr = cur.fetchone()
club_nm = str(r2d(cnr).get("name") or "") if cnr else ""
try: try:
storage_key = library_storage_key( storage_key = library_storage_key(ex_vis, dedupe_club if ex_vis != "official" else None, full_sha, ext)
ex_vis,
dedupe_club if ex_vis != "official" else None,
full_sha,
ext,
uploader_profile_id=profile_id if ex_vis == "private" else None,
mime_type=mime,
club_name=club_nm,
)
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
dest_path = path_under_media_root(media_root, storage_key) dest_path = path_under_media_root(media_root, storage_key)

View File

@ -312,15 +312,6 @@ def _effective_media_patch_fields(patch_fields: dict, asset: dict) -> dict:
return eff return eff
def _club_display_name_for_storage(cur: Any, club_id: int) -> str:
"""Anzeigename für library_club_path_segment; leer wenn Verein fehlt."""
cur.execute("SELECT name FROM clubs WHERE id = %s", (int(club_id),))
row = cur.fetchone()
if not row:
return ""
return str(r2d(row).get("name") or "")
def _relocate_asset_file_if_governance_changed( def _relocate_asset_file_if_governance_changed(
cur: Any, cur: Any,
media_root: Path, media_root: Path,
@ -341,20 +332,7 @@ def _relocate_asset_file_if_governance_changed(
return None return None
ext = Path(old_key.replace("\\", "/")).suffix or ".bin" ext = Path(old_key.replace("\\", "/")).suffix or ".bin"
try: try:
up = asset.get("uploaded_by_profile_id") new_key = library_storage_key(next_vis, next_club_id, sha, ext)
up_i = int(up) if up is not None else None
club_nm: Optional[str] = None
if next_vis in ("club", "private") and next_club_id is not None:
club_nm = _club_display_name_for_storage(cur, next_club_id)
new_key = library_storage_key(
next_vis,
next_club_id if next_vis != "official" else None,
sha,
ext,
uploader_profile_id=up_i if next_vis == "private" else None,
mime_type=str(asset.get("mime_type") or "") or None,
club_name=club_nm,
)
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
old_norm = old_key.replace("\\", "/").lstrip("/") old_norm = old_key.replace("\\", "/").lstrip("/")
@ -579,23 +557,13 @@ def _ingest_library_media_file(
raise HTTPException(status_code=400, detail=str(e)) from e raise HTTPException(status_code=400, detail=str(e)) from e
full_sha = hashlib.sha256(raw).hexdigest() full_sha = hashlib.sha256(raw).hexdigest()
if vis == "private": cur.execute(
cur.execute( """SELECT id, storage_key, byte_size, lifecycle_state, original_filename FROM media_assets
"""SELECT id, storage_key, byte_size, lifecycle_state, original_filename FROM media_assets WHERE sha256 = %s AND lower(trim(visibility)) = %s
WHERE sha256 = %s AND lower(trim(visibility)) = %s AND (club_id IS NOT DISTINCT FROM %s)
AND (club_id IS NOT DISTINCT FROM %s) LIMIT 1""",
AND (uploaded_by_profile_id IS NOT DISTINCT FROM %s) (full_sha, vis, next_cid),
LIMIT 1""", )
(full_sha, vis, next_cid, profile_id),
)
else:
cur.execute(
"""SELECT id, storage_key, byte_size, lifecycle_state, original_filename FROM media_assets
WHERE sha256 = %s AND lower(trim(visibility)) = %s
AND (club_id IS NOT DISTINCT FROM %s)
LIMIT 1""",
(full_sha, vis, next_cid),
)
existing_asset = cur.fetchone() existing_asset = cur.fetchone()
if existing_asset: if existing_asset:
@ -636,21 +604,9 @@ def _ingest_library_media_file(
elif not ext and mime == "video/quicktime": elif not ext and mime == "video/quicktime":
ext = ".mov" ext = ".mov"
club_nm = ""
if next_cid is not None:
club_nm = _club_display_name_for_storage(cur, next_cid)
media_root = get_effective_media_root(cur) media_root = get_effective_media_root(cur)
try: try:
storage_key = library_storage_key( storage_key = library_storage_key(vis, next_cid, full_sha, ext)
vis,
next_cid,
full_sha,
ext,
uploader_profile_id=profile_id if vis == "private" else None,
mime_type=mime,
club_name=club_nm,
)
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
dest_path = path_under_media_root(media_root, storage_key) dest_path = path_under_media_root(media_root, storage_key)
@ -1029,7 +985,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, mime_type copyright_notice, original_filename, sha256, storage_key, storage_backend
FROM media_assets WHERE id = %s""", FROM media_assets WHERE id = %s""",
(asset_id,), (asset_id,),
) )
@ -1133,7 +1089,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, mime_type copyright_notice, original_filename, sha256, storage_key, storage_backend
FROM media_assets WHERE id = %s""", FROM media_assets WHERE id = %s""",
(asset_id,), (asset_id,),
) )

View File

@ -3,105 +3,52 @@ from __future__ import annotations
import pytest import pytest
from media_storage import library_club_path_segment, library_media_kind_dir, library_storage_key from media_storage import library_storage_key
_HEX64 = "a" * 64 _HEX64 = "a" * 64
def test_library_media_kind_dir_mime_overrides_ext() -> None: def test_library_storage_key_private_under_club() -> None:
assert library_media_kind_dir("image/png", ".bin") == "image" assert library_storage_key("private", 7, _HEX64, ".jpg") == f"library/club/c7/private/{_HEX64}.jpg"
assert library_media_kind_dir("application/pdf", ".tmp") == "pdf"
def test_library_club_path_segment_slug_and_fallback() -> None: def test_library_storage_key_shared_under_club() -> None:
assert library_club_path_segment(3, "Grün & Weiß / Dojo!") == "gruen-weiss-dojo-c3" assert library_storage_key("club", 42, _HEX64, ".png") == f"library/club/c42/shared/{_HEX64}.png"
assert library_club_path_segment(3, None) == "verein-c3"
assert library_club_path_segment(3, " ") == "verein-c3"
assert library_club_path_segment(12, "東道場") == "verein-c12"
def test_library_club_path_segment_long_name_truncated() -> None:
long = "a" * 80
seg = library_club_path_segment(5, long)
assert seg == "a" * 48 + "-c5"
def test_library_storage_key_private_is_uploader_under_club() -> None:
assert (
library_storage_key(
"private", 7, _HEX64, ".jpg", uploader_profile_id=99, club_name="Ost Dojo München"
)
== f"library/ost-dojo-muenchen-c7/u99/image/{_HEX64}.jpg"
)
def test_library_storage_key_club_flat_under_club() -> None:
assert (
library_storage_key("club", 42, _HEX64, ".png", club_name="Demo-Verein")
== f"library/demo-verein-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") == f"library/official/{_HEX64}.mp4"
library_storage_key("official", None, _HEX64, ".mp4", uploader_profile_id=1)
== f"library/official/video/{_HEX64}.mp4"
)
def test_library_storage_key_normalizes_visibility() -> None: def test_library_storage_key_normalizes_visibility() -> None:
assert ( assert library_storage_key(" CLUB ", 1, _HEX64, "pdf") == f"library/club/c1/shared/{_HEX64}.pdf"
library_storage_key(" CLUB ", 1, _HEX64, "pdf", club_name="Alpha")
== f"library/alpha-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:
with pytest.raises(ValueError, match="uploader_profile_id"):
library_storage_key("private", 1, _HEX64, ".jpg", club_name="V")
def test_library_storage_key_private_requires_club() -> None: def test_library_storage_key_private_requires_club() -> None:
with pytest.raises(ValueError, match="Verein"): with pytest.raises(ValueError, match="Verein"):
library_storage_key("private", None, _HEX64, ".jpg", uploader_profile_id=1) library_storage_key("private", None, _HEX64, ".jpg")
def test_library_storage_key_club_requires_id() -> None: def test_library_storage_key_club_requires_id() -> None:
with pytest.raises(ValueError, match="Verein"): with pytest.raises(ValueError, match="Verein"):
library_storage_key("club", None, _HEX64, ".jpg", club_name="X") library_storage_key("club", None, _HEX64, ".jpg")
def test_library_storage_key_club_id_positive() -> None: def test_library_storage_key_club_id_positive() -> None:
with pytest.raises(ValueError, match="positiv"): with pytest.raises(ValueError, match="positiv"):
library_storage_key("club", 0, _HEX64, ".jpg", club_name="X") library_storage_key("club", 0, _HEX64, ".jpg")
def test_library_storage_key_invalid_visibility() -> None: def test_library_storage_key_invalid_visibility() -> None:
with pytest.raises(ValueError, match="Sichtbarkeit"): with pytest.raises(ValueError, match="Sichtbarkeit"):
library_storage_key("public", 1, _HEX64, ".jpg", club_name="X") library_storage_key("public", 1, _HEX64, ".jpg")
def test_library_storage_key_invalid_sha() -> None: def test_library_storage_key_invalid_sha() -> None:
with pytest.raises(ValueError, match="64"): with pytest.raises(ValueError, match="64"):
library_storage_key("private", 1, "deadbeef", ".jpg", uploader_profile_id=1, club_name="X") library_storage_key("private", 1, "deadbeef", ".jpg")
def test_library_storage_key_extension() -> None: def test_library_storage_key_extension_sanitized() -> None:
with pytest.raises(ValueError): with pytest.raises(ValueError):
library_storage_key( library_storage_key("private", 1, _HEX64, "../x")
"private", 1, _HEX64, "../x", uploader_profile_id=1, club_name="X"
)
def test_library_storage_key_private_no_club_name_fallback() -> None:
assert (
library_storage_key("private", 2, _HEX64, ".jpg", uploader_profile_id=3)
== f"library/verein-c2/u3/image/{_HEX64}.jpg"
)

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/image/{'a' * 64}.jpg" _SK_OFF_A = f"library/official/{'a' * 64}.jpg"
_SK_OFF_B = f"library/official/image/{'b' * 64}.jpg" _SK_OFF_B = f"library/official/{'b' * 64}.jpg"
_SK_PRIV_C = f"library/verein-c1/u1/video/{'c' * 64}.mp4" _SK_PRIV_C = f"library/club/c1/private/{'c' * 64}.mp4"
@pytest.fixture @pytest.fixture

View File

@ -1,8 +1,8 @@
# Shinkan Jinkendo Version Information # Shinkan Jinkendo Version Information
APP_VERSION = "0.8.56" APP_VERSION = "0.8.53"
BUILD_DATE = "2026-05-07" BUILD_DATE = "2026-05-08"
DB_SCHEMA_VERSION = "20260508049" DB_SCHEMA_VERSION = "20260508048"
MODULE_VERSIONS = { MODULE_VERSIONS = {
"auth": "1.2.1", # Login-Rate-Limit 30/minute pro IP (vorher 5/minute) "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) "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.11.0", # library/{vereins-slug}-c{id}/ statt library/club/c{id}/ "media_assets": "1.8.0", # private unter library/club/c*/private; club → …/shared; Vereinskontext Pflicht
"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.4", # Übungs-Upload: Vereinsnamen für library-Pfad aus clubs "exercises": "2.17.1", # Übungsmedien: dedupe_club für private aus Übung oder X-Active-Club-Id
"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,27 +29,6 @@ MODULE_VERSIONS = {
} }
CHANGELOG = [ CHANGELOG = [
{
"version": "0.8.56",
"date": "2026-05-07",
"changes": [
"Medienbibliothek: Vereinsmedien unter library/{aus Name abgeleitet}-c{club_id}/ statt festem library/club/c{id}/; Sonderzeichen/Umlaute zu sicheren Ordnernamen; fehlender Name → verein-c{id}; Governance-Umzug liest aktuellen Vereinsnamen aus DB",
],
},
{
"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",
"date": "2026-05-08",
"changes": [
"Medienpfade: vereinsgeteilt (club) direkt unter library/club/c{id}/; private unter library/club/c{id}/u{profile}/ (kein Ordner „private“/„shared“); Dedupe private inkl. uploaded_by_profile_id",
],
},
{ {
"version": "0.8.53", "version": "0.8.53",
"date": "2026-05-08", "date": "2026-05-08",