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 re
import shutil
import unicodedata
from pathlib import Path
from typing import Any, Optional
_CLUB_SLUG_TRANS = str.maketrans(
{
"ä": "ae",
"ö": "oe",
"ü": "ue",
"ß": "ss",
"Ä": "ae",
"Ö": "oe",
"Ü": "ue",
}
)
def _default_media_root() -> Path:
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()
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]:
"""Gibt absoluten Pfad zurück oder None bei Path-Traversal."""
key = (storage_key or "").strip().replace("\\", "/").lstrip("/")
@ -140,23 +63,16 @@ def library_storage_key(
club_id: Optional[int],
sha256_hex: str,
ext: str,
*,
uploader_profile_id: Optional[int] = None,
mime_type: Optional[str] = None,
club_name: Optional[str] = None,
) -> str:
"""
Relativer Speicherpfad unter MEDIA_ROOT für lokale media_assets.
- official library/official/{kind}/{sha256}{ext}
- club (vereinsgeteilt) library/{vereins-segment}/{kind}/{sha256}{ext}
- private (nur Hochlader) library/{vereins-segment}/u{profile}/{kind}/{sha256}{ext}
Layout (nach Verein gegliedert, globale Medien getrennt):
- private library/club/c{club_id}/private/{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.
kind {image, video, pdf, other} siehe library_media_kind_dir.
Kein Ordnername private auf der Platte: Zuordnung erfolgt über Uploader unter dem Verein.
club_id ist bei private/club zwingend (Vereinsordner); bei official nicht genutzt.
"""
vis = (visibility or "private").strip().lower()
if vis not in ("private", "club", "official"):
@ -171,12 +87,11 @@ def library_storage_key(
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")
kind = library_media_kind_dir(mime_type, e)
e = e[:16]
blob = f"{kind}/{sha}{e}"
blob = f"{sha}{e}"
if vis == "official":
return f"library/official/{blob}"
if club_id is None:
@ -184,15 +99,9 @@ def library_storage_key(
cid = int(club_id)
if cid < 1:
raise ValueError("club_id muss eine positive Ganzzahl sein")
club_seg = library_club_path_segment(cid, club_name)
if vis == "club":
return f"library/{club_seg}/{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}"
if vis == "private":
return f"library/club/c{cid}/private/{blob}"
return f"library/club/c{cid}/shared/{blob}"
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")
dedupe_club = int(ex_club)
if ex_vis == "private":
cur.execute(
"""SELECT id, storage_key, byte_size, lifecycle_state, original_filename FROM media_assets
WHERE sha256 = %s AND lower(trim(visibility)) = 'private'
AND (club_id IS NOT DISTINCT FROM %s)
AND (uploaded_by_profile_id IS NOT DISTINCT FROM %s)
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),
)
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()
if existing_asset:
@ -2518,21 +2508,8 @@ async def upload_exercise_media(
},
)
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:
storage_key = library_storage_key(
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,
)
storage_key = library_storage_key(ex_vis, dedupe_club if ex_vis != "official" else None, 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)

View File

@ -312,15 +312,6 @@ def _effective_media_patch_fields(patch_fields: dict, asset: dict) -> dict:
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(
cur: Any,
media_root: Path,
@ -341,20 +332,7 @@ def _relocate_asset_file_if_governance_changed(
return None
ext = Path(old_key.replace("\\", "/")).suffix or ".bin"
try:
up = asset.get("uploaded_by_profile_id")
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,
)
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("/")
@ -579,23 +557,13 @@ def _ingest_library_media_file(
raise HTTPException(status_code=400, detail=str(e)) from e
full_sha = hashlib.sha256(raw).hexdigest()
if vis == "private":
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)
AND (uploaded_by_profile_id IS NOT DISTINCT FROM %s)
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),
)
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()
if existing_asset:
@ -636,21 +604,9 @@ def _ingest_library_media_file(
elif not ext and mime == "video/quicktime":
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)
try:
storage_key = library_storage_key(
vis,
next_cid,
full_sha,
ext,
uploader_profile_id=profile_id if vis == "private" else None,
mime_type=mime,
club_name=club_nm,
)
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)
@ -1029,7 +985,7 @@ def bulk_media_patch(
try:
cur.execute(
"""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""",
(asset_id,),
)
@ -1133,7 +1089,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, sha256, storage_key, storage_backend, mime_type
copyright_notice, original_filename, sha256, storage_key, storage_backend
FROM media_assets WHERE id = %s""",
(asset_id,),
)

View File

@ -3,105 +3,52 @@ from __future__ import annotations
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
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_under_club() -> None:
assert library_storage_key("private", 7, _HEX64, ".jpg") == f"library/club/c7/private/{_HEX64}.jpg"
def test_library_club_path_segment_slug_and_fallback() -> None:
assert library_club_path_segment(3, "Grün & Weiß / Dojo!") == "gruen-weiss-dojo-c3"
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_shared_under_club() -> None:
assert library_storage_key("club", 42, _HEX64, ".png") == f"library/club/c42/shared/{_HEX64}.png"
def test_library_storage_key_official() -> None:
assert (
library_storage_key("official", None, _HEX64, ".mp4", uploader_profile_id=1)
== f"library/official/video/{_HEX64}.mp4"
)
assert library_storage_key("official", None, _HEX64, ".mp4") == f"library/official/{_HEX64}.mp4"
def test_library_storage_key_normalizes_visibility() -> None:
assert (
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")
assert library_storage_key(" CLUB ", 1, _HEX64, "pdf") == f"library/club/c1/shared/{_HEX64}.pdf"
def test_library_storage_key_private_requires_club() -> None:
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:
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:
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:
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:
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):
library_storage_key(
"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"
)
library_storage_key("private", 1, _HEX64, "../x")

View File

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

View File

@ -1,8 +1,8 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.56"
BUILD_DATE = "2026-05-07"
DB_SCHEMA_VERSION = "20260508049"
APP_VERSION = "0.8.53"
BUILD_DATE = "2026-05-08"
DB_SCHEMA_VERSION = "20260508048"
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.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",
"skills": "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_programs": "0.1.0",
"planning": "0.8.1", # Vorlagen Leseprüfung library_content_visible_to_profile
@ -29,27 +29,6 @@ MODULE_VERSIONS = {
}
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",
"date": "2026-05-08",