diff --git a/.claude/docs/PROJECT_STATUS.md b/.claude/docs/PROJECT_STATUS.md index 1fcaccc..25283f0 100644 --- a/.claude/docs/PROJECT_STATUS.md +++ b/.claude/docs/PROJECT_STATUS.md @@ -146,7 +146,8 @@ Deployment der oben genannten Migrationen und Datenabgleich nach internem Prozes | Frontend Routing | `technical/EXERCISES_FRONTEND_ROUTING.md` | 2026-04-30 | ✅ Ergänzt UI-Hinweise | | Search & Filter | `technical/SEARCH_FILTER_SPEC.md` | 2026-04-27 | ✅ Aktualisiert (Liste UX) | | Media Upload | `technical/MEDIA_UPLOAD_SPEC.md` | 2026-04-27 | ✅ Aktualisiert (Limits) | -| Projektstatus | `PROJECT_STATUS.md` | 2026-05-05 | ✅ Diese Datei | +| Medien-Archiv & Lifecycle | `technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` | 2026-05-07 | ✅ Single Source of Truth | +| Projektstatus | `PROJECT_STATUS.md` | 2026-05-07 | ✅ Diese Datei | --- @@ -157,4 +158,4 @@ Deployment der oben genannten Migrationen und Datenabgleich nach internem Prozes --- -**Letzte Aktualisierung:** 2026-05-05 +**Letzte Aktualisierung:** 2026-05-07 diff --git a/.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md b/.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md index d8d4e67..2daf6d6 100644 --- a/.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md +++ b/.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md @@ -118,8 +118,9 @@ Ausgangslage im Code: `private` \| `club` \| `official` (siehe `club_tenancy`). ## 7. Referenzen - `.claude/docs/technical/MULTI_TENANCY_RBAC_ARCHITECTURE.md` – übergeordnetes Zielbild & Begriffe. +- `.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` – verbindliche Domänenregeln für **Medien-Assets** (gleiche Sichtbarkeit wie Übungen, Promotion-Kopplung, Copyright, Papierkorb/Lebenszyklus, externer Speicher). Bei Widerspruch zur Sichtbarkeits-Tabelle in §3 dieses Dokuments: §3 für Enums/`library_content_*`-Semantik, Medien-Spez für Asset-spezifische Zusatzregeln. - `backend/club_tenancy.py` – bestehende Bausteine (`assert_club_member`, `exercise_visible_to_profile`, …); Ziel ist Deren schrittweise Zusammenführung unter die neue Zugriffsschicht ohne Big-Bang. --- -**Letzte Aktualisierung:** 2026-05-06 +**Letzte Aktualisierung:** 2026-05-07 diff --git a/.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md b/.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md new file mode 100644 index 0000000..34bc3c0 --- /dev/null +++ b/.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md @@ -0,0 +1,198 @@ +# Medien-Assets, Archiv & Lebenszyklus (Single Source of Truth) + +**Status:** verbindlich für Design, API und DB-Migrationen zum Thema Medien +**Stand:** 2026-05-07 +**ersetzt/ergänzt:** operatives Upload-Format/Größen siehe weiterhin `MEDIA_UPLOAD_SPEC.md` +**normative Governance:** Sichtbarkeit & Mandanten wie `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`; bei Abweichung zwischen Dokumenten hat der Zugriffsplan Vorrang, **außer** dieses Dokument präzisiert explizit nur den Medien-Domain. + +--- + +## 1. Zweck und Abgrenzung + +### 1.1 Zweck + +- **Ein physisches Medium** („Datei-Asset“) wird **einmal** gespeichert und **mehrfach** mit Übungen verknüpft (Wiederverwendung, keine Dubletten auf der Platte). +- **Sichtbarkeit** von Datei-Assets folgt **derselben Semantik** wie bei Übungen (`private` \| `club` \| `official` – ggf. spätere Enum-Erweiterungen nur gemeinsam mit Migration + ACCESS_LAYER-Doku). +- **Superadmin** kann Medien **global** freigeben (fachlich: Stufe wie „offiziell“ / plattformweit lesbar gemäß `library_content_*`-Regeln). +- **Copyright:** pro Asset sind **Vermerke** vorgesehen und an `official` / Vereinsnutzung anzubinden (Validierungsstufen in Umsetzung festlegen). +- **Externer Speicher:** Speicherung darf **lokal/NAS** (Filesystem) oder **extern** (z. B. S3-kompatibler Dienst) erfolgen – die App arbeitet über eine **Speicher-Abstraktion** (Interface), nicht mit verstreuten `Path`-Annahmen. +- **Papierkorb & Retention:** mehrstufiger Lebenszyklus mit **automatischen** und **manuellen** Übergängen, siehe §5. + +### 1.2 Abgrenzung + +- **Embeds** (reine externe URL, kein Upload) bleiben pro **Verknüpfung zur Übung** möglich (`embed_url` o. Ä.); sie haben **keinen** physischen Lebenszyklus wie Datei-Assets (kein Papierkorb „Stufe 3 physische Löschung“). Trotzdem: UI kann „Link ungültig“ o. Ä. separat abbilden. +- **Übungs-Governance** (wer eine Übung bearbeiten darf) **bleibt maßgeblich** für **Anlegen/Entfernen/Umsortieren** von Medien **in dieser Übung**; Medien erzwingen **keine** schärfere Edit-Policy als die Übung selbst. + +--- + +## 2. Begriffe + +| Begriff | Bedeutung | +|--------|-----------| +| **Media Asset** | Logische Entität für eine **hochgeladene Datei** inkl. Metadaten, Sichtbarkeit, Copyright, Speicherreferenz, Lifecycle-Status. | +| **Übungs-Medium / Attachment** | Verknüpfung **Übung ↔ Asset** oder reines Embed; enthält u. a. `context` (Sektion), `sort_order`, übungsspezifischen Titel/Beschreibung, `is_primary`. | +| **Medienmanager / Archiv** | Verwaltungs-UI/API für Assets (Suche, Metadaten, Lifecycle, ggf. Superadmin-global). | + +--- + +## 3. Zieldatenmodell (Überblick) + +> Konkrete Tabellennamen/Migrationen beim Implementierungsstart festziehen; dieses Kapitel ist die **fachliche Norm**. + +### 3.1 Tabelle `media_assets` (oder gleichwertiger Name) + +**Pflichtidee:** + +- Identität, technische Metadaten: `id`, `mime_type`, `byte_size`, `sha256` (vollständig), `original_filename`, `created_at`, `updated_at`. +- Mandant & Governance: `visibility`, `club_id` (nullable je nach Semantik wie bei Übungen), `uploaded_by_profile_id`. +- Copyright: mindestens `copyright_notice` (TEXT); optional `license`, `attribution`, `source_url`. +- Speicher: `storage_backend` (Enum/String: z. B. `local`, `s3`, …), `storage_key` (relativer oder bucket-key), optional `storage_url` nur für interne Zwecke – **keine** öffentlichen Secrets in der DB. +- Lifecycle: `lifecycle_state` (siehe §5), Timestamps für Übergänge und geplante Purge-Termine. + +**Deduplizierung:** Innerhalb sinnvoller Grenze (z. B. **pro Verein** bei `club`, global nur mit Superadmin-Policy) über `sha256` + `club_id`/`visibility`; Konflikt bei Upload → bestehendes Asset verknüpfen oder expliziter Nutzerdialog (Umsetzungsdetail). + +### 3.2 Verknüpfung (heute `exercise_media`) + +- Erweiterung um **`media_asset_id`** (FK, nullable wenn reines Embed). +- Embed-Zeilen: `media_asset_id IS NULL`, `embed_url`/`embed_platform` gesetzt wie heute. +- Übungsspezifische Felder: `context` (`ablauf` \| `detail` \| `trainer_hint`), `sort_order`, `title`, `description`, `is_primary`. + +### 3.3 Referenzen & physisches Löschen + +- Vor **physischem** Löschen muss die Referenzanzahl aller aktiven/nicht-purged Links **0** sein (oder Quarantäne-Policy); siehe §5.3. + +--- + +## 4. Sichtbarkeit, Lesen und Übungs-Promotion + +### 4.1 Leseregel Datei-Asset + +- Zentrale Entscheidung analog `library_content_visible_to_profile` / TenantContext: **Profil darf Asset lesen** nur wenn Visibility+`club_id`+`created_by` zum Objekt passen (gleiche Philosophie wie Übungen). +- **GET Download / Stream:** muss **Asset-Governance** prüfen; wenn der Aufruf **im Kontext einer Übung** erfolgt, zusätzlich (oder als Schnittmenge) **Übung lesbar** – verbindlich **ohne Seitenkanal** (kein Download nur mit Übungs-ID, wenn Asset für Nutzer unsichtbar wäre). + +### 4.2 Promotion Übung → `official` (bzw. global) + +Beim Speichern/Promoten einer Übung auf **`official`** (oder vergleichbare globale Stufe): + +1. **Pflicht-Dialog:** Hinweis, dass **alle zugeordneten Datei-Assets**, die noch **nicht** diese Sichtbarkeit haben, **mit angehoben** werden (oder die Aktion schlägt fehl / erfordert Anpassung). +2. **Abbruch:** Nutzer kann abbrechen oder einzelne Assets vor Freigabe anpassen / entkoppeln. +3. **Copyright:** Für `official` sind leere oder unzureichende Copyright-Felder **nicht** akzeptabel (genaue Regel in Umsetzung + Validierung). + +Superadmin kann Medien **explizit** global/offiziell machen (Archiv-Pfad), unabhängig von einer einzelnen Übung – konsistent zur Rolle. + +### 4.3 Bearbeitung Medien in der Übung + +Wer die Übung **bearbeiten** darf (bestehende oder künftig erweiterte Regel), darf für diese Übung: + +- Medien **hinzufügen** (Upload neu oder Verknüpfung aus Archiv), +- **Reihenfolge** / **Sektion** / **Titel** ändern, +- **Verknüpfung** entfernen (Asset bleibt im Archiv, sofern nicht anderweitig gelöscht), +- Embeds pflegen. + +Das widerspricht **nicht** dem Papierkorb: wer kein Recht hat, ein Asset **global** zu Trash/ Purge zu bewegen, darf trotzdem die **Verknüpfung** in „seiner“ Übung lösen, solange die Übungs-Edit-Policy das erlaubt. + +--- + +## 5. Lebenszyklus (Papierkorb) – Norm + +### 5.1 Zustände + +| Zustand | Code (Beispiel) | Neue Zuordnung zu Übungen | Anzeige in Übung (Lesemodus) | Anzeige Bearbeiten | +|---------|-----------------|---------------------------|------------------------------|-------------------| +| Aktiv | `active` | ja | normal | normal | +| Stufe 1 – Papierkorb | `trash_soft` | **nein** | ja + **Warnhinweis** („wird abgeschafft“) | ja + Warnhinweis | +| Stufe 2 – Ausblendung | `trash_hidden` | nein | **nein** (Platzhalter/„nicht verfügbar“) | Recovery-Aktion sichtbar | +| Stufe 3 – purgiert | `purged` / Zeile entfernt | nein | nein | nur noch historischer Hinweis nach Policy | + +**Default-Retention (automatisch):** + +- Stufe 1 → Stufe 2: **ca. 30 Tage** nach Eintritt in Stufe 1 (konfigurierbar). +- Stufe 2 → Stufe 3 **physisches Löschen**: **ca. 90 Tage** (3 Monate) nach Eintritt in Stufe 2 (konfigurierbar). + +**Manuell:** Berechtigte Rollen dürfen Übergänge **vorziehen** (Stufe 1 erzwingen, Stufe 2 erzwingen, Purge erzwingen) gemäß §5.2 – ggf. **Tier-Gates** (§6). + +### 5.2 Wer darf Lifecycle-Transitionen? + +| Aktion | Vereins-Asset (`club`, Owner-Verein) | Privates Asset (`private`, Uploader) | `official` / plattformweit | +|--------|--------------------------------------|--------------------------------------|-----------------------------| +| Stufe 1 (Papierkorb) | **Vereinsadmin** + **Superadmin** | **Uploader** + **Superadmin** | **Superadmin** | +| Stufe 2 vorziehen / erzwingen | Vereinsadmin + Superadmin | Uploader + Superadmin | Superadmin | +| Recovery Stufe 2 → 1 | wie Stufe 1-Eintritt, ggf. Tier | wie links | Superadmin | +| Physisches Löschen (3) | Vereinsadmin + Superadmin; Systemjob nach Frist | Uploader + Superadmin | Superadmin | + +**Superadmin / Systemadmin** ist immer **Override** (Klarstellung zur „systemadmin“-Formulierung). + +### 5.3 Konsistenz mit bestehenden Übungen + +- In **Stufe 1** bleiben Verknüpfungen funktional für Anzeige bestehen; Nutzer sehen **Warnung**. +- In **Stufe 2** wird das Medium in der **öffentlichen Übungsansicht nicht gerendert**; im **Bearbeitungsmodus** gibt es einen **Recovery-Link**, der das Asset zurück auf **Stufe 1** setzt (Policy: nur wer nach §5.2 Stufe 1 setzen darf, oder erweiterte Regel – bei Konflikt ACCESS_LAYER + dieses Dokument anpassen). +- **Physisches Löschen:** nur nach Stufe 2-Frist oder manueller Purge-Aktion; Backend entfernt Datei über Speicher-Backend und markiert Asset endgültig. + +### 5.4 Hintergrundjobs + +- Geplanter Job (täglich o. Ä.): Transitionen 1→2 und 2→3 gemäß Timestamps. +- Alle Zeiten **konfigurierbar** (Umgebungsvariablen oder DB-Config), Defaults wie §5.1. + +--- + +## 6. Tier & Features + +- **Tier** kann erlauben oder verbieten: **manuelles** Vorziehen von Stufe 2 oder Purge, zusätzliche Speicherquoten, externes Backend, etc. +- Tier ändert **nicht** die Übungs-Sichtbarkeit an sich; es **limitiert** nur Medien-spezifische Aktionen (Löschstufen, Archiv-Größe, …). Konkrete Feature-Keys in Umsetzung in `features` / `tier_limits` ergänzen und hier im **Changelog** dieses Dokuments verlinken. + +--- + +## 7. Externe Speicherung (Server / S3) + +- Abstraktion **StorageAdapter**: `put`, `get`, `delete`, ggf. `exists`. +- Konfiguration über ENV (z. B. Endpoint, Bucket, Credentials) – nie im Frontend. +- **NAS / anderer Rechner als App-Host:** In der Praxis `MEDIA_ROOT` in Compose/ENV auf den **Mount-Punkt** des NAS (oder NFS/SMB) setzen – die App läuft z. B. auf dem Raspberry, die Bytes liegen auf dem Speichersystem. Kein »Mediaserver« mit eigener Geschäftslogik nötig. +- **Pfadkonvention** auf der Platte z. B. `clubs/{club_id}/…` für Mandantentrennung (Umsetzung schrittweise; neue Uploads können zuerst unter `exercises/{sha256}{ext}` liegen). + +### 7.1 Konfiguration: Bootstrap vs. Superadmin (Laufzeit) + +| Ebene | Inhalt | Wer / Wo | +|-------|--------|----------| +| **Bootstrap** | Basis-Verzeichnis `MEDIA_ROOT` (Container/Host), ggf. S3-Secrets | Deployment (`.env`, Docker Compose) – Container muss ohne UI starten können | +| **Laufzeit (nicht-geheim)** | Zusätzlicher **relativer Unterpfad** unter `MEDIA_ROOT` (`local_relative_root`), später: aktives Backend `local` \| `s3`, öffentlicher Endpoint/Bucket-Name | **Superadmin** über Tabelle `platform_media_storage` + API; keine Secrets für S3 im Klartext in der DB (Keys nur ENV/Vault) | +| **Effektives Wurzelverzeichnis** | `Path(MEDIA_ROOT) / local_relative_root` nach Normalisierung (kein `..`, kein absoluter Pfad im relativen Segment) | berechnet im Backend bei jedem Zugriff | + +**Hinweis Beta:** Primär ein Verein, Videos auf separatem physischen System → typischerweise **ein NAS-Mount** als `MEDIA_ROOT`; Superadmin kann bei Bedarf einen **Unterordner** setzen, ohne neues Image zu bauen. + +**Drift:** Jede Änderung an Speicher-Konfiguration oder Asset-Schema → Migration + Eintrag **§10 Changelog** + bei neuen Endpoints **ACCESS_LAYER_ENDPOINT_AUDIT.md**. + +--- + +## 8. Embeds & Streaming-Plattformen + +- Embeds **bleiben** erste Klasse; bestehende Plattform-Erkennung erweiterbar (`embed_platform`). +- Roadmap: weitere Hosts (Player-Komponente, oEmbed, CSP) – **ohne** Änderung am Asset-Lifecycle-Modell. + +--- + +## 9. Pflichten zur Drift-Vermeidung + +| Änderung an … | Pflegepflicht | +|---------------|---------------| +| DB-Schema Medien | Neue Migration + **Abschnitt/Changelog in diesem Dokument** (Datum, Kurzbeschreibung) | +| Sichtbarkeit / Enums | Zuerst `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`, dann dieses Dokument synchron halten | +| Neue Endpoints Medien/Archiv | `ACCESS_LAYER_ENDPOINT_AUDIT.md` + `get_tenant_context`/Governance | +| Implementierungs-Meilenstein | `backend/version.py` (`MODULE_VERSIONS` / Schema-Kommentar) und bei größerem Release `PROJECT_STATUS.md` | + +--- + +## 10. Changelog + +| Datum | Änderung | +|-------|----------| +| 2026-05-07 | Erstfassung als Single Source of Truth (verbindlich); Abstimmung mit Stakeholder: Promotion Übung↔Medien, Copyright, Papierkorb 3-stufig, externe Speicher, Embeds getrennt vom Asset-Lifecycle. | +| 2026-05-07 | §7.1 Konfiguration Bootstrap vs. Superadmin (`platform_media_storage`), NAS/Mount-Hinweis, Drift-Regel. Umsetzung Start: DB `media_assets`, FK `exercise_media.media_asset_id`, API Speichereinstellungen Superadmin. | + +--- + +## 11. Referenzen + +- `.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` +- `.claude/docs/technical/MEDIA_UPLOAD_SPEC.md` (Limits, MIME, Embed-Typen im aktuellen Backend) +- `.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md` +- `backend/routers/exercises.py` – Ist-Zustand `exercise_media` bis Refactor diff --git a/.claude/docs/technical/MEDIA_UPLOAD_SPEC.md b/.claude/docs/technical/MEDIA_UPLOAD_SPEC.md index 15cba7d..4b2f503 100644 --- a/.claude/docs/technical/MEDIA_UPLOAD_SPEC.md +++ b/.claude/docs/technical/MEDIA_UPLOAD_SPEC.md @@ -6,6 +6,10 @@ **Autor:** Claude Code **Änderungen v1.1:** Rollenbasierte Server-Limits (`EXERCISE_MEDIA_*_MB`) +> **Zielbild Medien-Archiv, Wiederverwendung, Papierkorb, Copyright, externe Speicherung:** +> Verbindliche Single Source of Truth: **`MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`** (gleicher Ordner). +> Dieses Dokument bleibt maßgeblich für **konkrete Upload-Limits, MIME-Liste und Embed-Hosting** des aktuellen Stands bis zum Refactor. + --- ## 1. Upload-Strategie diff --git a/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md b/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md index 08b09ea..785d4e6 100644 --- a/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md +++ b/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md @@ -17,6 +17,7 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe A–C. | training_planning | alle geschützten Endpoints | ja | `get_tenant_context` | ja | Vorlagen-Liste wie Übungen; POST Vorlage Default club_id | | training_framework_programs | alle geschützten Endpoints | ja | `get_tenant_context` | ja | Liste + POST Default club_id | | admin_users | `GET /api/admin/users` | Plattform | `require_auth` | Admin-Rolle | EXEMPT `check_access_layer_hints.py` | +| platform_media_storage | `GET/PUT /api/admin/platform-media-storage` | Plattform | `require_auth` | GET: `is_platform_admin`; PUT: nur `superadmin` | Relativer Pfad unter `MEDIA_ROOT`; keine Secrets; EXEMPT wie admin_users | | auth | `/api/auth/*` | nein | — | Login/Session | EXEMPT | | catalogs | Katalog-CRUD | nein (global) | `require_auth` | Admin/Trainer je Endpoint | EXEMPT; bei späterem `club_id` nachziehen | | skills | `/api/skills*` | nein (global) | `require_auth` | je Endpoint | EXEMPT | @@ -28,7 +29,7 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe A–C. **Pflege / Drift:** Änderungen an Mandanten, Governance (`visibility`/`club_id`) oder neuen inhaltsbezogenen Endpoints → eine Zeile in dieser Tabelle anpassen und `PRODUCTION_READINESS_AUDIT_2026-05.md` prüfen. -Letzte Änderung: 2026-05-06 — MULTI_TENANCY §3 Gap-Analyse aktualisiert; Audit um geschützte Profil-Endpunkte ergänzt. +Letzte Änderung: 2026-05-07 — `platform_media_storage` Admin-API (Speicherpfad Superadmin). --- diff --git a/.claude/rules/ARCHITECTURE.md b/.claude/rules/ARCHITECTURE.md index 066dbd3..7a7c853 100644 --- a/.claude/rules/ARCHITECTURE.md +++ b/.claude/rules/ARCHITECTURE.md @@ -58,6 +58,7 @@ return {"message": "Fehler", "success": False} ### 1.4 Mandanten & Zugriffsschicht (Shinkan / ACCESS_LAYER) **Verbindlicher Rahmen:** `.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` +**Medien-Assets (Archiv, Papierkorb, Promotion, Copyright, externer Speicher):** `.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` **Fortlaufendes Inventar:** `.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md` **Definition of Done für neue oder geänderte geschützte APIs**, sobald Daten **Verein**, **Sichtbarkeit** oder **mandantenbezogene Listen** betreffen: diff --git a/CLAUDE.md b/CLAUDE.md index 4a23c64..a4a95dd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,6 +11,7 @@ > | Lessons Learned | `.claude/rules/LESSONS_LEARNED.md` | > | Setup-Dokument | `.claude/docs/working/SHINKAN_PROJECT_SETUP.md` | > | Anforderungen | `.claude/docs/functional/SHINKAN_REQUIREMENTS.md` | +> | Medien-Archiv, Lifecycle, Promotion | `.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` | ## Projekt-Übersicht diff --git a/backend/main.py b/backend/main.py index 629db6f..ac92a9d 100644 --- a/backend/main.py +++ b/backend/main.py @@ -192,7 +192,7 @@ def read_root(): return out # Register routers -from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, skills, training_planning, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin +from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, platform_media_storage, skills, training_planning, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin app.include_router(auth.router) app.include_router(profiles.router) @@ -202,6 +202,7 @@ app.include_router(clubs.router) app.include_router(club_memberships.router) app.include_router(club_join_requests.router) app.include_router(admin_users.router) +app.include_router(platform_media_storage.router) app.include_router(skills.router) app.include_router(training_planning.router) app.include_router(training_framework_programs.router) diff --git a/backend/media_storage.py b/backend/media_storage.py new file mode 100644 index 0000000..f24a6a4 --- /dev/null +++ b/backend/media_storage.py @@ -0,0 +1,57 @@ +"""Effektives Medien-Wurzelverzeichnis (MEDIA_ROOT + Superadmin-relativer Pfad). Siehe MEDIA_ASSETS_AND_ARCHIVE_SPEC.md §7.1.""" +from __future__ import annotations + +import os +import re +from pathlib import Path +from typing import Any, Optional + + +def _default_media_root() -> Path: + return Path(os.getenv("MEDIA_ROOT", str(Path(__file__).resolve().parent / "media"))) + + +def normalize_local_relative_root(raw: str) -> str: + s = (raw or "").strip().replace("\\", "/") + s = s.strip("/") + if not s: + return "" + if ".." in s.split("/"): + raise ValueError("Pfad darf nicht '..' enthalten") + if s.startswith("/"): + raise ValueError("Nur relativer Pfad erlaubt") + return s + + +def get_effective_media_root(cur: Any) -> Path: + """ + MEDIA_ROOT aus ENV mit optionalem local_relative_root aus platform_media_storage (id=1). + """ + base = _default_media_root().resolve() + rel = "" + try: + cur.execute( + "SELECT local_relative_root FROM platform_media_storage WHERE id = 1", + ) + row = cur.fetchone() + if row is not None: + v = row["local_relative_root"] if isinstance(row, dict) else row[0] + rel = normalize_local_relative_root(str(v or "")) + except Exception: + rel = "" + if not rel: + return base + return (base / rel).resolve() + + +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("/") + if not key or ".." in key.split("/"): + return None + p = (media_root / key).resolve() + try: + p.relative_to(media_root.resolve()) + except ValueError: + return None + return p diff --git a/backend/migrations/045_media_assets_and_platform_storage.sql b/backend/migrations/045_media_assets_and_platform_storage.sql new file mode 100644 index 0000000..085a619 --- /dev/null +++ b/backend/migrations/045_media_assets_and_platform_storage.sql @@ -0,0 +1,106 @@ +-- Migration 045: Zentrale media_assets, exercise_media.media_asset_id, platform_media_storage (Superadmin-Pfad). +-- Siehe .claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md + +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +CREATE TABLE platform_media_storage ( + id SMALLINT PRIMARY KEY DEFAULT 1, + CONSTRAINT platform_media_storage_singleton CHECK (id = 1), + storage_backend VARCHAR(32) NOT NULL DEFAULT 'local', + local_relative_root VARCHAR(512) NOT NULL DEFAULT '', + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_by_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL +); + +INSERT INTO platform_media_storage (id, storage_backend, local_relative_root) +VALUES (1, 'local', '') +ON CONFLICT (id) DO NOTHING; + +CREATE TABLE media_assets ( + id SERIAL PRIMARY KEY, + mime_type VARCHAR(100), + byte_size INT, + sha256 CHAR(64) NOT NULL, + original_filename VARCHAR(300), + visibility VARCHAR(32) NOT NULL DEFAULT 'private', + club_id INT REFERENCES clubs(id) ON DELETE SET NULL, + uploaded_by_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL, + copyright_notice TEXT, + storage_backend VARCHAR(32) NOT NULL DEFAULT 'local', + storage_key TEXT NOT NULL, + lifecycle_state VARCHAR(32) NOT NULL DEFAULT 'active', + trash_soft_at TIMESTAMP WITH TIME ZONE, + trash_hidden_at TIMESTAMP WITH TIME ZONE, + purge_after_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE INDEX idx_media_assets_club ON media_assets(club_id); +CREATE INDEX idx_media_assets_sha256 ON media_assets(sha256); +CREATE INDEX idx_media_assets_lifecycle ON media_assets(lifecycle_state); +CREATE UNIQUE INDEX ux_media_assets_storage_key ON media_assets(storage_key); + +ALTER TABLE exercise_media + ADD COLUMN IF NOT EXISTS media_asset_id INT REFERENCES media_assets(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_exercise_media_asset ON exercise_media(media_asset_id); + +-- Bestand: je distinct storage_key ein Asset; sha256 aus DB-Pfad (Migrations-Platzhalter, nicht Content-Hash) +INSERT INTO media_assets ( + mime_type, + byte_size, + sha256, + original_filename, + visibility, + club_id, + uploaded_by_profile_id, + storage_backend, + storage_key, + copyright_notice, + lifecycle_state, + created_at, + updated_at +) +SELECT + s.mime_type, + s.byte_size, + s.sha256, + s.original_filename, + s.visibility, + s.club_id, + s.uploaded_by_profile_id, + 'local', + s.storage_key, + NULL, + 'active', + s.created_at, + NOW() +FROM ( + SELECT DISTINCT ON (storage_key) + em.mime_type, + em.file_size AS byte_size, + encode(digest(trim(em.file_path), 'sha256'), 'hex') AS sha256, + em.original_filename, + lower(trim(e.visibility)) AS visibility, + e.club_id, + e.created_by AS uploaded_by_profile_id, + regexp_replace(trim(em.file_path), '^/+media/+', '') AS storage_key, + em.created_at + FROM exercise_media em + JOIN exercises e ON e.id = em.exercise_id + WHERE em.file_path IS NOT NULL + AND trim(em.file_path) <> '' + AND em.embed_url IS NULL + ORDER BY regexp_replace(trim(em.file_path), '^/+media/+', ''), em.id +) s +WHERE NOT EXISTS (SELECT 1 FROM media_assets ma WHERE ma.storage_key = s.storage_key); + +UPDATE exercise_media em +SET media_asset_id = ma.id +FROM media_assets ma +WHERE em.media_asset_id IS NULL + AND em.file_path IS NOT NULL + AND trim(em.file_path) <> '' + AND em.embed_url IS NULL + AND ma.storage_key = regexp_replace(trim(em.file_path), '^/+media/+', ''); diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py index 415c92c..f05be4b 100644 --- a/backend/routers/exercises.py +++ b/backend/routers/exercises.py @@ -27,6 +27,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 logger = logging.getLogger(__name__) @@ -285,12 +286,6 @@ def _row_created_by(row) -> int: return row[0] -def _ensure_media_dirs(): - sub = MEDIA_ROOT / "exercises" - sub.mkdir(parents=True, exist_ok=True) - return sub - - def _detect_embed_platform(url: str) -> Optional[str]: if not url: return None @@ -393,19 +388,29 @@ def _count_exercise_media(cur, exercise_id: int) -> int: return int(r["c"] if isinstance(r, dict) else r[0]) -def _abs_media_path(file_path_db: str) -> Optional[Path]: +def _abs_media_path(file_path_db: str, media_root: Path) -> Optional[Path]: if not file_path_db or file_path_db.startswith("http"): return None rel = file_path_db.lstrip("/") if rel.startswith("media/"): rel = rel[len("media/") :] - p = MEDIA_ROOT / rel + p = (media_root / rel).resolve() try: - p.resolve().relative_to(MEDIA_ROOT.resolve()) + p.relative_to(media_root.resolve()) except ValueError: return None return p + +def _resolve_local_media_file( + media_root: Path, + file_path_db: Optional[str], + asset_storage_key: Optional[str], +) -> Optional[Path]: + if asset_storage_key: + return path_under_media_root(media_root, asset_storage_key) + return _abs_media_path(file_path_db or "", media_root) if file_path_db else None + def enrich_exercise_detail(exercise_id: int, cur) -> dict: """ Lädt alle M:N Relations für eine Übung und gibt ein vollständiges @@ -515,11 +520,13 @@ def enrich_exercise_detail(exercise_id: int, cur) -> dict: # Media (1:N) cur.execute( - """SELECT id, media_type, file_path, file_size, mime_type, original_filename, - embed_url, embed_platform, title, description, sort_order, is_primary, context - FROM exercise_media - WHERE exercise_id = %s - ORDER BY sort_order, id""", + """SELECT em.id, em.media_type, em.file_path, em.file_size, em.mime_type, em.original_filename, + em.embed_url, em.embed_platform, em.title, em.description, em.sort_order, em.is_primary, em.context, + em.media_asset_id, ma.copyright_notice AS asset_copyright_notice + FROM exercise_media em + LEFT JOIN media_assets ma ON ma.id = em.media_asset_id + WHERE em.exercise_id = %s + ORDER BY em.sort_order, em.id""", (exercise_id,) ) exercise["media"] = [r2d(r) for r in cur.fetchall()] @@ -1939,9 +1946,12 @@ def _binary_media_response( def _fetch_media_row(cur, exercise_id: int, media_id: int) -> Optional[dict]: cur.execute( - """SELECT id, exercise_id, media_type, file_path, file_size, mime_type, original_filename, - embed_url, embed_platform, title, description, sort_order, is_primary, context, created_at - FROM exercise_media WHERE id = %s AND exercise_id = %s""", + """SELECT em.id, em.exercise_id, em.media_type, em.file_path, em.file_size, em.mime_type, em.original_filename, + em.embed_url, em.embed_platform, em.title, em.description, em.sort_order, em.is_primary, em.context, em.created_at, + em.media_asset_id, ma.storage_key AS asset_storage_key + FROM exercise_media em + LEFT JOIN media_assets ma ON ma.id = em.media_asset_id + WHERE em.id = %s AND em.exercise_id = %s""", (media_id, exercise_id), ) row = cur.fetchone() @@ -1969,7 +1979,12 @@ def download_exercise_media_file( raise HTTPException(status_code=400, detail="Embed-Medien haben keine Datei-URL") fp = media.get("file_path") - abs_p = _abs_media_path(fp) if fp else None + media_root = get_effective_media_root(cur) + abs_p = _resolve_local_media_file( + media_root, + fp, + media.get("asset_storage_key"), + ) if not abs_p or not abs_p.is_file(): raise HTTPException(status_code=404, detail="Datei nicht gefunden") @@ -2065,35 +2080,116 @@ async def upload_exercise_media( ext = ".jpg" elif not ext and mime == "image/png": ext = ".png" - digest = hashlib.sha256(raw).hexdigest()[:12] - fname = f"{digest}_{exercise_id}{ext}" - dest_dir = _ensure_media_dirs() - dest_path = dest_dir / fname - dest_path.write_bytes(raw) - db_path = f"/media/exercises/{fname}" + cur.execute( - f"""INSERT INTO exercise_media ( - exercise_id, media_type, file_path, file_size, mime_type, original_filename, - embed_url, embed_platform, title, description, context, is_primary, sort_order - ) VALUES ( - %s, %s, %s, %s, %s, %s, NULL, NULL, %s, %s, %s, %s, {sort_sql} - ) - RETURNING id, exercise_id, media_type, file_path, file_size, mime_type, original_filename, - embed_url, embed_platform, title, description, sort_order, is_primary, context, created_at""", - ( - exercise_id, - media_type, - db_path, - len(raw), - mime, - file.filename, - title or None, - description or None, - context, - is_primary, - exercise_id, - ), + "SELECT visibility, club_id, created_by FROM exercises WHERE id = %s", + (exercise_id,), ) + ex_gov = cur.fetchone() + if not ex_gov: + raise HTTPException(status_code=404, detail="Übung nicht gefunden") + ex_vis = (r2d(ex_gov).get("visibility") or "private").strip().lower() + ex_club = r2d(ex_gov).get("club_id") + media_root = get_effective_media_root(cur) + full_sha = hashlib.sha256(raw).hexdigest() + + cur.execute( + """SELECT id, storage_key, byte_size FROM media_assets + WHERE sha256 = %s AND lower(trim(visibility)) = %s + AND (club_id IS NOT DISTINCT FROM %s) + AND lifecycle_state = 'active' + LIMIT 1""", + (full_sha, ex_vis, ex_club), + ) + existing_asset = cur.fetchone() + + if existing_asset: + ea = r2d(existing_asset) + aid = ea["id"] + sk = ea["storage_key"] + sz = ea.get("byte_size") or len(raw) + db_path = f"/media/{sk}" + cur.execute( + f"""INSERT INTO exercise_media ( + exercise_id, media_type, file_path, file_size, mime_type, original_filename, + embed_url, embed_platform, title, description, context, is_primary, sort_order, + media_asset_id + ) VALUES ( + %s, %s, %s, %s, %s, %s, NULL, NULL, %s, %s, %s, %s, {sort_sql}, %s + ) + RETURNING id, exercise_id, media_type, file_path, file_size, mime_type, original_filename, + embed_url, embed_platform, title, description, sort_order, is_primary, context, created_at, + media_asset_id""", + ( + exercise_id, + media_type, + db_path, + sz, + mime, + file.filename, + title or None, + description or None, + context, + is_primary, + exercise_id, + aid, + ), + ) + else: + storage_key = f"exercises/{full_sha}{ext}" + dest_path = path_under_media_root(media_root, storage_key) + if dest_path is None: + raise HTTPException(status_code=500, detail="Ungültiger Speicherpfad") + dest_path.parent.mkdir(parents=True, exist_ok=True) + if not dest_path.is_file(): + dest_path.write_bytes(raw) + + cur.execute( + """INSERT INTO media_assets ( + mime_type, byte_size, sha256, original_filename, visibility, club_id, + uploaded_by_profile_id, copyright_notice, storage_backend, storage_key, lifecycle_state + ) VALUES (%s, %s, %s, %s, %s, %s, %s, NULL, 'local', %s, 'active') + RETURNING id""", + ( + mime, + len(raw), + full_sha, + file.filename, + ex_vis, + ex_club, + profile_id, + storage_key, + ), + ) + ar = cur.fetchone() + aid = r2d(ar)["id"] + db_path = f"/media/{storage_key}" + cur.execute( + f"""INSERT INTO exercise_media ( + exercise_id, media_type, file_path, file_size, mime_type, original_filename, + embed_url, embed_platform, title, description, context, is_primary, sort_order, + media_asset_id + ) VALUES ( + %s, %s, %s, %s, %s, %s, NULL, NULL, %s, %s, %s, %s, {sort_sql}, %s + ) + RETURNING id, exercise_id, media_type, file_path, file_size, mime_type, original_filename, + embed_url, embed_platform, title, description, sort_order, is_primary, context, created_at, + media_asset_id""", + ( + exercise_id, + media_type, + db_path, + len(raw), + mime, + file.filename, + title or None, + description or None, + context, + is_primary, + exercise_id, + aid, + ), + ) row = cur.fetchone() conn.commit() return r2d(row) @@ -2165,7 +2261,8 @@ def update_exercise_media( conn.commit() cur.execute( """SELECT id, media_type, file_path, file_size, mime_type, original_filename, - embed_url, embed_platform, title, description, sort_order, is_primary, context, created_at + embed_url, embed_platform, title, description, sort_order, is_primary, context, created_at, + media_asset_id FROM exercise_media WHERE id = %s""", (media_id,), ) @@ -2179,27 +2276,56 @@ def delete_exercise_media( tenant: TenantContext = Depends(get_tenant_context), ): profile_id = tenant.profile_id + unlink_path: Optional[Path] = None with get_db() as conn: cur = get_cursor(conn) _assert_can_edit_exercise(cur, exercise_id, profile_id) + media_root = get_effective_media_root(cur) cur.execute( - """SELECT file_path FROM exercise_media WHERE id = %s AND exercise_id = %s""", + """SELECT em.file_path, em.media_asset_id, ma.storage_key AS asset_storage_key + FROM exercise_media em + LEFT JOIN media_assets ma ON ma.id = em.media_asset_id + WHERE em.id = %s AND em.exercise_id = %s""", (media_id, exercise_id), ) row = cur.fetchone() if not row: raise HTTPException(status_code=404, detail="Medium nicht gefunden") - fp = row["file_path"] if isinstance(row, dict) else row[0] + rec = r2d(row) + fp = rec.get("file_path") + media_asset_id = rec.get("media_asset_id") + asset_storage_key = rec.get("asset_storage_key") + cur.execute( "DELETE FROM exercise_media WHERE id = %s AND exercise_id = %s", (media_id, exercise_id), ) + + if media_asset_id: + cur.execute( + "SELECT COUNT(*) AS c FROM exercise_media WHERE media_asset_id = %s", + (media_asset_id,), + ) + cnt = int(r2d(cur.fetchone())["c"]) + if cnt == 0: + cur.execute( + "DELETE FROM media_assets WHERE id = %s RETURNING storage_key", + (media_asset_id,), + ) + del_asset = cur.fetchone() + sk = None + if del_asset: + sk = r2d(del_asset).get("storage_key") or asset_storage_key + if sk: + unlink_path = path_under_media_root(media_root, sk) + elif fp: + unlink_path = _abs_media_path(fp, media_root) + conn.commit() - abs_p = _abs_media_path(fp) if fp else None - if abs_p and abs_p.is_file(): + if unlink_path and unlink_path.is_file(): try: - abs_p.unlink() + unlink_path.unlink() except OSError as e: logger.warning("Medien-Datei konnte nicht gelöscht werden: %s", e) diff --git a/backend/routers/platform_media_storage.py b/backend/routers/platform_media_storage.py new file mode 100644 index 0000000..adb68e8 --- /dev/null +++ b/backend/routers/platform_media_storage.py @@ -0,0 +1,97 @@ +"""Superadmin: Speicherpfad-Konfiguration (§7.1 MEDIA_ASSETS_AND_ARCHIVE_SPEC.md).""" +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field + +from club_tenancy import is_platform_admin +from db import get_db, get_cursor, r2d +from auth import require_auth +from media_storage import _default_media_root, get_effective_media_root, normalize_local_relative_root + +router = APIRouter(prefix="/api/admin", tags=["admin", "media-storage"]) + + +class PlatformMediaStorageUpdate(BaseModel): + local_relative_root: str = Field( + "", + description="Relativer Unterordner unter MEDIA_ROOT (z. B. nas/videos). Leer = nur MEDIA_ROOT.", + max_length=512, + ) + + +class PlatformMediaStorageOut(BaseModel): + storage_backend: str + local_relative_root: str + media_root_env: str + effective_media_root: str + + +def _require_superadmin(session: dict) -> None: + role = (session.get("role") or "").strip().lower() + if role != "superadmin": + raise HTTPException(status_code=403, detail="Nur Superadmin") + + +@router.get("/platform-media-storage", response_model=PlatformMediaStorageOut) +def get_platform_media_storage(session: dict = Depends(require_auth)): + """Lesen: Plattform-Admin (admin/superadmin) – Hilfe für Betrieb.""" + role = (session.get("role") or "").strip().lower() + if not is_platform_admin(role): + raise HTTPException(status_code=403, detail="Keine Berechtigung") + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + """SELECT storage_backend, local_relative_root + FROM platform_media_storage WHERE id = 1""", + ) + row = cur.fetchone() + if not row: + backend, rel = "local", "" + else: + d = r2d(row) + backend = d.get("storage_backend") or "local" + rel = str(d.get("local_relative_root") or "") + eff = get_effective_media_root(cur) + return PlatformMediaStorageOut( + storage_backend=backend, + local_relative_root=rel, + media_root_env=str(_default_media_root().resolve()), + effective_media_root=str(eff), + ) + + +@router.put("/platform-media-storage", response_model=PlatformMediaStorageOut) +def put_platform_media_storage( + body: PlatformMediaStorageUpdate, + session: dict = Depends(require_auth), +): + """Schreiben: nur superadmin.""" + _require_superadmin(session) + profile_id = session["profile_id"] + try: + rel_norm = normalize_local_relative_root(body.local_relative_root) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + """UPDATE platform_media_storage + SET local_relative_root = %s, updated_at = NOW(), updated_by_profile_id = %s + WHERE id = 1 + RETURNING storage_backend, local_relative_root""", + (rel_norm, profile_id), + ) + row = cur.fetchone() + conn.commit() + if not row: + raise HTTPException(status_code=500, detail="platform_media_storage fehlt") + d = r2d(row) + eff = get_effective_media_root(cur) + return PlatformMediaStorageOut( + storage_backend=str(d.get("storage_backend") or "local"), + local_relative_root=str(d.get("local_relative_root") or ""), + media_root_env=str(_default_media_root().resolve()), + effective_media_root=str(eff), + ) diff --git a/backend/scripts/check_access_layer_hints.py b/backend/scripts/check_access_layer_hints.py index 858b664..a85422f 100644 --- a/backend/scripts/check_access_layer_hints.py +++ b/backend/scripts/check_access_layer_hints.py @@ -21,6 +21,7 @@ EXEMPT_ROUTERS: frozenset[str] = frozenset( { "auth.py", "admin_users.py", + "platform_media_storage.py", "catalogs.py", "skills.py", "maturity_models.py", diff --git a/backend/version.py b/backend/version.py index 6e41b32..c556b2b 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,8 +1,8 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.40" -BUILD_DATE = "2026-05-06" -DB_SCHEMA_VERSION = "20260506043" +APP_VERSION = "0.8.41" +BUILD_DATE = "2026-05-07" +DB_SCHEMA_VERSION = "20260507045" MODULE_VERSIONS = { "auth": "1.2.1", # Login-Rate-Limit 30/minute pro IP (vorher 5/minute) @@ -12,10 +12,11 @@ MODULE_VERSIONS = { "club_memberships": "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 + "platform_media_storage": "1.0.0", # GET/PUT /api/admin/platform-media-storage (Superadmin-Pfad unter MEDIA_ROOT) "groups": "0.1.0", "skills": "0.1.0", "methods": "0.1.0", - "exercises": "2.10.0", # GET /exercises: focus_area_must_include/exclude_ids, focus_only_without_focus_areas; UI +/- Fokusregeln + "exercises": "2.11.0", # media_assets + Dedupe-Upload; effektives MEDIA_ROOT aus platform_media_storage "training_units": "0.2.0", "training_programs": "0.1.0", "planning": "0.8.1", # Vorlagen Leseprüfung library_content_visible_to_profile @@ -27,6 +28,16 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.41", + "date": "2026-05-07", + "changes": [ + "DB 045: media_assets, exercise_media.media_asset_id, platform_media_storage; Migration bestehender Medien; Upload-Dedupe pro sha256+visibility+club_id", + "Effektives Medien-Verzeichnis: MEDIA_ROOT + Superadmin local_relative_root (GET/PUT /api/admin/platform-media-storage)", + "Neue Uploads: storage_key exercises/{sha256}{ext}; Download/Delete nutzen media_assets", + "api.js: getPlatformMediaStorage, putPlatformMediaStorage", + ], + }, { "version": "0.8.40", "date": "2026-05-06", diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 2f21734..202ad3e 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -120,6 +120,18 @@ export async function listAdminUsers() { return request('/api/admin/users') } +/** Medien-Speicher (MEDIA_ROOT + relativer Unterordner) — GET: admin/superadmin, PUT: nur superadmin. */ +export async function getPlatformMediaStorage() { + return request('/api/admin/platform-media-storage') +} + +export async function putPlatformMediaStorage(payload) { + return request('/api/admin/platform-media-storage', { + method: 'PUT', + body: JSON.stringify(payload), + }) +} + export async function updateProfile(profileId, data) { return request(`/api/profiles/${profileId}`, { method: 'PUT',