Medienmanager und Sicherheitsupdate #21

Merged
Lars merged 15 commits from develop into main 2026-05-07 16:00:19 +02:00
44 changed files with 5596 additions and 222 deletions

View File

@ -1,26 +1,33 @@
# Shinkan Jinkendo - Projekt-Status # Shinkan Jinkendo - Projekt-Status
**Stand:** 2026-05-05 **Stand:** 2026-05-07
**Version (Code):** 0.8.10 (`backend/version.py`, APP_VERSION) **Version (Code):** 0.8.48 (`backend/version.py`, APP_VERSION)
**DB-Schema-Version:** `20260505037` **DB-Schema-Version:** `20260507045` (u. a. `media_assets`, `platform_media_storage`)
**Branch:** develop **Branch:** develop
--- ---
## Executive Summary ## Executive Summary
**Aktueller Meilenstein:** **Trainingsrahmenprogramm Bibliothek + SlotBlueprint** (DB **036037**): Rahmenkopf nur als Vorlage mit KontextStammdaten; pro Slot genau eine **Blueprint`training_unit`** mit **`framework_unit_sections`/`_items`** wie die Planung; Kalenderliste blendet Blueprints aus; **`POST /api/training-units/from-framework-slot`** materialisiert Kopien mit **`origin_framework_slot_id`**. Parallel: **Progressionsgraph** (032034) bleibt unterstützend (**`TRAINING_FRAMEWORK_SPEC.md`** §3§4). **Aktueller Meilenstein (Medien):** **Medienbibliothek `/media`** ergänzt den Archiv-Picker: **Lifecycle-Filter** (aktiv / Papierkorb / ausgeblendet), **Copyright bearbeiten** (`PATCH`), **Vorschau** inkl. Papierkorb bei Verwaltungsrecht; API-Liste `copyright_notice`; zuvor **§4.2 Promotion official** (Übung + Medien).
**Letzte dokumentierte Änderungen (Mai 2026):** **Parallel weiter relevant:** **Trainingsrahmenprogramm** (036037), **Progressionsgraph** (032034) — siehe **`TRAINING_FRAMEWORK_SPEC.md`**.
- ✅ Migration **036:** Rahmen nur Bibliothek; Fokus/Stil + M:N Trainingsarten/Zielgruppen; Entfall `plan_mode`/`group_id` am Kopf. **Referenz:** [`library/FEATURES_DELIVERED_2026-Q2.md`](library/FEATURES_DELIVERED_2026-Q2.md) · Medien-Norm: [`technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`](technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md)
- ✅ Migration **037:** `training_units.framework_slot_id` / `origin_framework_slot_id`; Migration Entfall **`training_framework_slot_exercises`**.
- ✅ APIs: erweiterte RahmenHydration (`sections`, `exercises`, `blueprint_training_unit_id`); Planung siehe **`TRAINING_FRAMEWORK_SPEC.md`** §2.4.
- ✅ Frontend: `createTrainingUnitFromFrameworkSlot` in `api.js`.
**Referenz:** [`library/FEATURES_DELIVERED_2026-Q2.md`](library/FEATURES_DELIVERED_2026-Q2.md) · Rahmen/Graph: [`technical/TRAINING_FRAMEWORK_SPEC.md`](technical/TRAINING_FRAMEWORK_SPEC.md) **Nächste Schritte — Medien & Archiv** (neu priorisiert, Stand 2026-05-07):
**Nächste Schritte (Auszug):** 1. ~~**Übung → `official` Promotion** inkl. Medien-Anhebung + **Copyright-Pflicht** bei `official` (Spec §4.2)~~ — umgesetzt (0.8.47).
2. ~~**Eigenständige Medienmanager-Seite**~~**Basis umgesetzt** (`/media`): Filter, Copyright, Lifecycle-Aktionen; Ausbau: Sichtbarkeit bearbeiten, Bulk, Quotas.
3. **Tests & Observability:** gezielte pytest-Abdeckung für Archiv/Verknüpfen; optional Retention-Job-Dry-Run dokumentieren.
4. **S3 / externes Backend** hinter Speicher-Abstraktion (Spec §7) — nach stabiler Nutzung lokaler/NAS-Pfade.
5. **Inline-Medien im Fließtext** (Spec §11) — bewusst **nach** Promotion/Copyright und tragfähigem Archiv-Workflow.
**Inline:** Leitplanken in **`MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` §11**; kein Big-Bang vor stabiler Archiv-/Governance-Basis.
---
**Nächste Schritte (Auszug — Planung/Rahmen):**
1. KalenderUI: „Aus Rahmen übernehmen“ an **`from-framework-slot`** anbinden; ggf. Bulk. 1. KalenderUI: „Aus Rahmen übernehmen“ an **`from-framework-slot`** anbinden; ggf. Bulk.
2. Governance: Sichtbarkeit **club/official** für Rahmen so ausprägen, dass andere Trainer kopieren dürfen (Policy + API). 2. Governance: Sichtbarkeit **club/official** für Rahmen so ausprägen, dass andere Trainer kopieren dürfen (Policy + API).
@ -43,6 +50,7 @@
| **030** | **training_unit_exercises.exercise_variant_id** | ✅ | 🔲 | | **030** | **training_unit_exercises.exercise_variant_id** | ✅ | 🔲 |
| **032034** | **Progressionsgraph Übung→Übung** | ✅ | 🔲 | | **032034** | **Progressionsgraph Übung→Übung** | ✅ | 🔲 |
| **035037** | **Rahmenprogramm, BibliothekKopf, SlotBlueprintUnits** | ✅ | 🔲 | | **035037** | **Rahmenprogramm, BibliothekKopf, SlotBlueprintUnits** | ✅ | 🔲 |
| **040045** | **u. a. Mitgliedschaften, Übungs-Governance, `media_assets`, Plattform-Speicherpfad** | ✅ | 🔲 |
--- ---
@ -95,6 +103,10 @@ Die exakten Zahlen hängen von der Umgebung ab (siehe Admin/DB). Die Skills/Übu
### 🔲 In Arbeit / Backlog ### 🔲 In Arbeit / Backlog
- [x] **Medien:** Papierkorb (§5), Retention-Job, Archiv-API, „Aus Archiv verknüpfen“, Picker/Vorschau in Übungsbearbeitung (Release 0.8.42 ff.)
- [x] **Medien:** Promotion Übung↔Medien + Copyright-Pflicht `official` (Spec §4.2) — 0.8.47
- [x] **Medien:** Medienbibliothek `/media` (Lifecycle-Filter, Copyright PATCH, Vorschau); Ausbau Manager/Bulk/S3 — Roadmap
- [ ] **Medien:** Inline im Fließtext — nach Spec §11, nach Promotion/Archiv-Reife
- [ ] Admin-UI für Skill-Kategorien (CRUD) falls noch offen - [ ] Admin-UI für Skill-Kategorien (CRUD) falls noch offen
- [ ] Responsive Design / Dark Mode / PWA - [ ] Responsive Design / Dark Mode / PWA
- [ ] KI-Suche (`ai_search`) über reine Volltextsuche hinaus - [ ] KI-Suche (`ai_search`) über reine Volltextsuche hinaus
@ -125,7 +137,7 @@ Die exakten Zahlen hängen von der Umgebung ab (siehe Admin/DB). Die Skills/Übu
### Dev ### Dev
Branch `develop`; Migrations bis mindestens **037** auf dem aktuellen Entwicklungsstand; Details in `backend/version.py`. Branch `develop`; Migrations bis mindestens **045** auf dem aktuellen Entwicklungsstand; Details in `backend/version.py`.
### Prod ### Prod
@ -146,7 +158,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 | | 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) | | 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) | | 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 | ✅ §11 Inline-Plan, Drift-Tab |
| Projektstatus | `PROJECT_STATUS.md` | 2026-05-07 | ✅ Medien-Meilenstein aktualisiert |
--- ---
@ -157,4 +170,4 @@ Deployment der oben genannten Migrationen und Datenabgleich nach internem Prozes
--- ---
**Letzte Aktualisierung:** 2026-05-05 **Letzte Aktualisierung:** 2026-05-07

View File

@ -118,8 +118,9 @@ Ausgangslage im Code: `private` \| `club` \| `official` (siehe `club_tenancy`).
## 7. Referenzen ## 7. Referenzen
- `.claude/docs/technical/MULTI_TENANCY_RBAC_ARCHITECTURE.md` übergeordnetes Zielbild & Begriffe. - `.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. - `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

View File

@ -0,0 +1,238 @@
# Medien-Assets, Archiv & Lebenszyklus (Single Source of Truth)
**Status:** verbindlich für Design, API und DB-Migrationen zum Thema Medien
**Stand:** 2026-05-07 (§11 Inline-Plan ergänzt)
**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**. Änderungen an **Inline-Platzhaltern** (§11) → ebenfalls Changelog + ggf. Frontend-Sanitize-Regeln dokumentieren.
---
## 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 |
| **Inline-Referenzen** in Übungstexten (§11) | Renderer/Sanitizer-Regeln + dieses Dokument §11; kein zweites Rechtemodell |
| 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 | §11 **Inline-Medien im Fließtext**: Leitplanken (Anker `exercise_media.id`, einheitlicher Render-Pfad, keine zweite Governance); Zeitpunkt der Umsetzung; Drift-Vermeidung ohne jetzigen Vollbau. |
| 2026-05-07 | **Medienmanager (Basis):** `GET /api/media-assets?lifecycle=…`, `copyright_notice` in Response; `PATCH` Copyright; `GET …/file` für Papierkorb-Zeilen bei Verwaltungsrecht; UI-Route `/media` (Medienbibliothek). |
---
## 11. Inline-Medien im Fließtext (Planung, Leitplanken)
**Status:** nicht implementiert; verbindlich nur als **Richtschnur**, damit später **kein Big-Bang-Refactor** nötig ist.
### 11.1 Ziel
- Medien (Player, Bild) sollen **an definierter Stelle** in Feldern wie Ablauf / Ziel / Notizen erscheinen können zusätzlich oder statt reiner Zuordnung zu den Sektionen `ablauf` / `detail` / `trainer_hint`.
- **Keine zweite Sichtbarkeit:** Inline verweist immer auf dieselbe **Übungs-Medium-Zeile** (`exercise_media.id`) bzw. indirekt auf das gleiche Asset wie die Medienliste; **Lesen/Ausliefern** nur nach **bestehender** Übungs- + Medien-Governance (§4.1).
### 11.2 Platzhalter-Konvention (Vorschlag für spätere Umsetzung)
- Beim **Speichern** im Rich-Text: markierter Verweis, z.B.
`data-shinkan-exercise-media="<numerische exercise_media.id>"` auf einem **neutralen** Element (`span`/`figure`), **oder** eine interne Kurzsyntax (`{{exerciseMedia:123}}`), die der Server beim Speichern in eine **kanonische** HTML-Form überführt.
- **Final festlegen** beim Start der Implementierung (ein Format, nicht mehrere parallele).
### 11.3 Rendering & Sicherheit
- **Ein zentraler Pfad** „Übungstext für Anzeige aufbereiten“: HTML sanitizen (Allowlist), erlaubte Platzhalter auflösen, **ID gehört zur aktuellen Übung** und Medium ist für den Nutzer **sichtbar** sonst Platzhalter mit neutralem Hinweis oder ausblenden.
- **XSS/CSP:** keine rohen `iframe`/Skripte aus Nutzer-HTML ohne Kontrolle; eingebettete Player nur über kontrollierte Komponenten.
### 11.4 Koexistenz mit Sektions-Medien
- **Liste + Inline** dürfen dasselbe `exercise_media` referenzieren (ein Player an zwei Stellen) Produktentscheidung: später optional „Duplikat vermeiden“-Hinweis in der UI.
- Import/Wiki: vor großen Content-Migrationen **Syntax festlegen**, damit nicht irreversibel „falsches“ HTML importiert wird.
### 11.5 Wann umsetzen (Reihenfolge)
1. **Vorher:** Medien-Archiv, `media_assets`, Upload/Dedupe, Speicherpfad, Basis-Papierkorb (§5) jeweils stabil.
2. **Danach:** Inline implementieren, sobald Trainer-Feedback oder Content-Menge den Bedarf **konkret** bestätigt (typisch nach 12 Beta-Zyklen).
3. **Nicht nötig:** vorher kompletten Block-Editor einführen; **Platzhalter im bestehenden RTE** ist der vorgesehene **schlanke** Einstieg.
### 11.6 Refactor-Vermeidung (jetzt schon)
- Neue Features **nicht** so bauen, dass HTML aus Übungstexten an **vielen** Stellen „roh“ gerendert wird **ein** wiederverwendbarer Renderer vorbereiten (schrittweise einziehen).
- Medien immer über **stabile IDs** anbinden, nicht nur über Datei-URLs im Text.
---
## 12. 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

View File

@ -6,6 +6,10 @@
**Autor:** Claude Code **Autor:** Claude Code
**Änderungen v1.1:** Rollenbasierte Server-Limits (`EXERCISE_MEDIA_*_MB`) **Änderungen v1.1:** Rollenbasierte Server-Limits (`EXERCISE_MEDIA_*_MB`)
> **Zielbild Medien-Archiv, Wiederverwendung, Papierkorb, Copyright, externe Speicherung, später Inline im Fließtext:**
> Verbindliche Single Source of Truth: **`MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`** (§11 Leitplanken Inline, ohne Umsetzung).
> Dieses Dokument bleibt maßgeblich für **konkrete Upload-Limits, MIME-Liste und Embed-Hosting** des aktuellen Stands bis zum Refactor.
--- ---
## 1. Upload-Strategie ## 1. Upload-Strategie

View File

@ -11,21 +11,39 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe AC.
| club_memberships | `/clubs/{id}/members*` | ja | `get_tenant_context` | ja | | | club_memberships | `/clubs/{id}/members*` | ja | `get_tenant_context` | ja | |
| club_join_requests | `/me/club-join-requests`, `/clubs/{id}/join-requests*` | ja | `get_tenant_context` | ja | | | club_join_requests | `/me/club-join-requests`, `/clubs/{id}/join-requests*` | ja | `get_tenant_context` | ja | |
| exercises | `PATCH /api/exercises/bulk-metadata` | ja | `get_tenant_context` | ja | Liste: UI-Mehrfachwahl; bis 500 IDs; nur Ersteller oder Plattform-Admin | | exercises | `PATCH /api/exercises/bulk-metadata` | ja | `get_tenant_context` | ja | Liste: UI-Mehrfachwahl; bis 500 IDs; nur Ersteller oder Plattform-Admin |
| exercises | übrige geschützte `/api/exercises*` | ja | `get_tenant_context` | ja | | | exercises | `GET .../media/{mid}/file` | ja | `get_tenant_context_flexible` | ja (wie Übung lesen) | Datei oder `?ssetoken`; kein anonymes `/media/` ohne ALLOW_PUBLIC_MEDIA_STATIC |
| exercises | übrige geschützte `/api/exercises*` | ja | `get_tenant_context` | ja | PUT Einzelübung: bei Sichtbarkeit `official` Medien-§4.2 (422: Lifecycle/Promotion/Copyright) |
| exercise_progression_graphs | `/api/exercise-progression-graphs*` | ja | `get_tenant_context` | Liste wie Bibliothek; Schreiben Ersteller/Plattform-Admin | Kanten: Lesen wenn Graph lesbar | | exercise_progression_graphs | `/api/exercise-progression-graphs*` | ja | `get_tenant_context` | Liste wie Bibliothek; Schreiben Ersteller/Plattform-Admin | Kanten: Lesen wenn Graph lesbar |
| training_planning | alle geschützten Endpoints | ja | `get_tenant_context` | ja | Vorlagen-Liste wie Übungen; POST Vorlage Default club_id | | 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 | | 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` | | 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 |
| media_assets | `POST /api/media-assets/{id}/lifecycle` | ja | `get_tenant_context` | ja | u. a. `trash_soft` mit Trainer-nur-privat-Eigentum; `purge` nur **Superadmin**; Superadmin: `superadmin_force_lifecycle`, `superadmin_hard_delete` |
| media_assets | `GET /api/media-assets` | ja | `get_tenant_context` | ja | optional `lifecycle`; Standard `active`; Liste inkl. `copyright_notice`; Papierkorb-Ansicht nur sichtbare Mandanten-Assets |
| media_assets | `POST /api/media-assets/bulk-lifecycle` | ja | `get_tenant_context` | ja | Mehrfach-Lifecycle; gleiche Regeln wie Einzel-POST |
| media_assets | `POST /api/media-assets/bulk-patch` | ja | `get_tenant_context` | ja | Copyright / Bezeichner / Sichtbarkeit für viele IDs; gemischte Fehler in `failed[]` |
| media_assets | `PATCH /api/media-assets/{id}` | ja | `get_tenant_context` | ja | Copyright, `original_filename`, optional `visibility`/`club_id`; Rechte pro Stufe |
| media_assets | `GET /api/media-assets/{id}/file` | ja | `get_tenant_context_flexible` | ja | aktiv: Bibliotheks-Sichtbarkeit; `trash_soft`/`trash_hidden`: wie Lifecycle-Verwaltung |
| exercises | `POST /api/exercises/{id}/media/from-asset` | ja | `get_tenant_context` | ja | Verknüpfung `exercise_media` → bestehendes `media_asset_id`; Bearbeitungsrecht Übung + Leserecht Archiv |
| auth | `/api/auth/*` | nein | — | Login/Session | EXEMPT | | 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 | | 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 | | skills | `/api/skills*` | nein (global) | `require_auth` | je Endpoint | EXEMPT |
| maturity_models | Admin-Matrix | nein (global) | `require_auth` | Admin | EXEMPT | | maturity_models | Admin-Matrix | nein (global) | `require_auth` | Admin für Schreiben; `GET …/{id}` nur Portal-Admin | EXEMPT |
| matrix_stack_bundle | Export/Import Bundles | Plattform/Test | `require_auth` | Admin | EXEMPT | | matrix_stack_bundle | Export/Import Bundles | Plattform/Test | `require_auth` | Admin | EXEMPT |
| import_wiki / import_wiki_admin | Wiki-Import | Werkzeug | `require_auth`/Admin | Admin | EXEMPT | | import_wiki / import_wiki_admin | Wiki-Import | Werkzeug | `require_auth`/Admin | Admin | EXEMPT |
**Legende:** Router auf der EXEMPT-Liste des Scripts sind globale oder Auth-only-Pfade; sobald ein Router Vereinsdaten oder Bibliotheks-Sichtbarkeit erhält, EXEMPT entfernen und `get_tenant_context` einführen. **Legende:** Router auf der EXEMPT-Liste des Scripts sind globale oder Auth-only-Pfade; sobald ein Router Vereinsdaten oder Bibliotheks-Sichtbarkeit erhält, EXEMPT entfernen und `get_tenant_context` einführen.
Letzte Änderung: 2026-05-06 — MULTI_TENANCY §3 Gap-Analyse aktualisiert; Audit um geschützte Profil-Endpunkte ergänzt. **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-07 — Upload-Dedupe Papierkorb 409 + `reactivate`; DELETE …/media nur Verknüpfung.
---
### Changelog (Fortführung)
- **2026-05-07:** Legacy `GET/PUT /api/profile` auf Session-Profil gehärtet; OpenAPI/Health-Ready Produktionsdefaults; Security-Release-Tests + CI-Schritt `security_release_checks.py` — siehe `PRODUCTION_READINESS_AUDIT_2026-05.md`.
- **2026-05-07 (Phase 3):** CSP SPA (nginx); API `nosniff`-Middleware — siehe `PRODUCTION_READINESS_AUDIT_2026-05.md`.
--- ---

View File

@ -0,0 +1,95 @@
# Produktionsreife: Audit-Ergebnis & Umsetzungsplan (Stand 2026-05-07, Phase-3-Update)
**Zweck:** Einheitliche Referenz gegen **Drift** zwischen Sicherheits-/Betriebsanforderungen und Code.
**Bezug:** Zugriffsschicht `.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`, Cursor-Regel `.cursor/rules/access-layer.mdc`.
---
## Teil A — Audit-Ergebnis (Kurzfassung)
### A.1 Kritisch / Hoch (behoben oder geplant)
| ID | Befund | Status (2026-05-07) |
|----|--------|----------------------|
| SEC-01 | `GET /api/profile` (Legacy): ohne Header wurde das **erste Profil der DB** geliefert → IDOR / Datenleck | **Behoben:** Profil immer aus Session |
| SEC-02 | OpenAPI `/docs`, `/redoc`, `/openapi.json` in Produktion exponiert | **Behoben:** bei `ENVIRONMENT=production` aus; Override `PUBLIC_OPENAPI=1` |
| SEC-03 | `/api/health/ready` mit Tabellendetails / Migrationszähler öffentlich | **Behoben:** in Prod nur kompakte Antwort; Detail via `HEALTH_READY_PUBLIC_DETAIL=1` |
| SEC-04 | Statische Auslieferung `/media` ohne Auth (Link-Leak) | **Behoben:** `GET /api/exercises/.../media/.../file` mit Governance + `ssetoken`; `/media` nur mit `ALLOW_PUBLIC_MEDIA_STATIC=1` |
| OPS-01 | PostgreSQL-Hostport in `docker-compose.yml` | **Behoben:** Bind `127.0.0.1:5434` (nur Host-Local) |
### A.2 Mittel / Konsistenz
| ID | Befund | Status |
|----|--------|--------|
| CON-01 | Frontend: direktes `fetch('/api/profiles')` ohne zentralen Client | **Behoben** (AdminCatalogsPage → `api.listProfiles`) |
| CON-02 | `ProfileContext.jsx` (unused): falsches Token-Feld / `session` | **Behoben** (`user` + `getCurrentProfile` / `listProfiles`) |
| MEM-01 | Vereinsmitgliederverzeichnis zeigt E-Mail an alle Mitglieder | **Behoben:** E-Mail nur Plattform-Admin / `can_manage_club_org` |
| MAT-01 | `GET /maturity-models/{id}` für jeden Auth-Nutzer | **Behoben:** nur Portal-Admin (UI nutzt ohnehin nur Admin-Panel) |
### A.3 Niedrig / Offen
| ID | Befund | Status |
|----|--------|--------|
| CON-03 | Sparten (`division`) vs. Zielbild ACCESS_LAYER | **Roadmap** — siehe Phase 3 unten (Stufe D); nicht in diesem Schritt implementiert |
| OPS-02 | CSP / restliche Browser-Härtung | **Behoben (Basis):** CSP auf SPA-Dokumente (nginx `location /`); API `X-Content-Type-Options: nosniff` (FastAPI-Middleware) |
| MISC-01 | `auth.py` Debug-`print` beim Import | **Behoben** (entfernt) |
| MISC-02 | `delete_profile`: DELETE auf Mitai-Tabellen schlägt fehl, wenn Tabelle fehlt | **Behoben:** nur löschen, wenn Tabelle existiert |
---
## Teil B — Priorisierter Umsetzungsplan
### Phase 1 — Sofort (Beta-Blocker) ✅ umgesetzt in Repo-Stand
1. Legacy-Endpunkte `/api/profile` GET/PUT: **nur Session-`profile_id`**, kein „erstes Profil“-Fallback.
2. OpenAPI in Produktion deaktivieren; opt-in für Notfall-Debugging.
3. Readiness-Endpoint in Prod entschärfen; operatives Detail per Env.
4. Automatisierte **Security-Release-Tests** (`tests/test_security_release.py`) + Script `scripts/security_release_checks.py` in CI.
5. Profil-Löschung: optionale Tabellen nur bei Existenz.
6. Frontend: Admin-Kataloge Profilliste über `api.js`.
### Phase 2 — Kurzfristig (vor breitem Go-Live) ✅ Hauptpunkte umgesetzt (2026-05-07)
1. **Medien:** `GET /api/exercises/{id}/media/{mid}/file` — Auth (`X-Auth-Token` oder `?ssetoken=`), Zugriff wie GET Übung; statisches `/media` standardmäßig aus.
2. **Mitgliederverzeichnis:** E-Mail nur wenn Plattform-Admin oder `can_manage_club_org`.
3. **DB-Port:** Prod-Compose Postgres an `127.0.0.1` gebunden.
4. **`GET /api/maturity-models/{id}`:** nur Portal-Admin (Liste/resolve unverändert für Trainer).
### Phase 3 — Mittelfristig
1. **CSP & Header:** Content-Security-Policy für die SPA (nginx, `location /`); `X-Content-Type-Options: nosniff` auf allen FastAPI-Responses — ✅ umgesetzt.
2. **Audit-Pflege:** bei Tenant/Governance-Änderungen `ACCESS_LAYER_ENDPOINT_AUDIT.md` und dieses Dokument anpassen — fortlaufend (Hinweis im Endpoint-Audit).
3. **Division / Sparten (CON-03, Stufe D):** noch **nicht** code-seitig durchgesetzt. Nächste technische Schritte (für eigenes Epic):
- Ist-Zustand: `division_id` auf Objekten & `division_lead` in `can_plan_in_club` ohne strikte Objekt-Filter.
- Ziel: Lesen/Schreiben nur mit passender Sparten-Mitgliedschaft bzw. Rolle; einheitliche Filter in Listen (`training_groups`, Übungen, Planung).
- Tests: Zwei Nutzer, zwei Sparten, kein Cross-Lesen; Superadmin-Pfade getrennt.
- Spec: `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe D.
---
## Teil C — CI / Qualitätssicherung
- **ACCESS_LAYER_STRICT:** `scripts/check_access_layer_hints.py` (unverändert Pflicht).
- **Security-Release:** `python scripts/security_release_checks.py` (pytest-Modul `test_security_release`).
- **Regression Profil:** `tests/test_profiles_read_access.py` (Legacy-Route).
---
## Teil D — Umgebungsvariablen (Referenz)
| Variable | Bedeutung |
|----------|-----------|
| `ENVIRONMENT` | `production` / `prod` → OpenAPI aus, Health-Ready kompakt |
| `PUBLIC_OPENAPI` | `1` / `true` → OpenAPI trotz Prod einschalten (nur Debugging) |
| `HEALTH_READY_PUBLIC_DETAIL` | `1` / `true` → volle Ready-JSON inkl. Tabellenliste in Prod |
| `ALLOW_PUBLIC_MEDIA_STATIC` | `1` / `true` → öffentliches Mount von `/media/` (Notfall/Legacy; Standard: aus) |
| `CSP` (nginx) | Kein Env: Policy fest in `frontend/nginx.conf` (`location /`). API auf **anderer Host-URL** als SPA: `connect-src` in der Policy erweitern oder Build mit envsubst. |
---
**Pflege:** Nach jeder relevanten Änderung **Status-Spalte** und ggf. **Phase** aktualisieren; bei neuem Audit Datum im Titel anpassen oder Abschnitt „Changelog“ ergänzen.
### Changelog
- **2026-05-07:** Phase 2 Medien, Mitgliederverzeichnis E-Mail, maturity GET admin-only, Postgres localhost bind, Tests `test_exercise_media_download.py`.
- **Phase 3:** CSP (nginx SPA), API `X-Content-Type-Options` (FastAPI), Test `test_api_attachments_x_content_type_options_nosniff`; Division-Durchsetzung nur als Roadmap (CON-03).

View File

@ -58,6 +58,7 @@ return {"message": "Fehler", "success": False}
### 1.4 Mandanten & Zugriffsschicht (Shinkan / ACCESS_LAYER) ### 1.4 Mandanten & Zugriffsschicht (Shinkan / ACCESS_LAYER)
**Verbindlicher Rahmen:** `.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` **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` **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: **Definition of Done für neue oder geänderte geschützte APIs**, sobald Daten **Verein**, **Sichtbarkeit** oder **mandantenbezogene Listen** betreffen:

View File

@ -38,6 +38,7 @@ jobs:
pip install -r /app/requirements-dev.txt && pip install -r /app/requirements-dev.txt &&
cd /app && cd /app &&
ACCESS_LAYER_STRICT=1 python scripts/check_access_layer_hints.py && ACCESS_LAYER_STRICT=1 python scripts/check_access_layer_hints.py &&
python scripts/security_release_checks.py &&
ACCESS_LAYER_INTEGRATION=1 SKIP_DB_MIGRATE=1 python -m pytest tests -m 'not slow' -ra -vv --tb=short ACCESS_LAYER_INTEGRATION=1 SKIP_DB_MIGRATE=1 python -m pytest tests -m 'not slow' -ra -vv --tb=short
" "

View File

@ -11,6 +11,7 @@
> | Lessons Learned | `.claude/rules/LESSONS_LEARNED.md` | > | Lessons Learned | `.claude/rules/LESSONS_LEARNED.md` |
> | Setup-Dokument | `.claude/docs/working/SHINKAN_PROJECT_SETUP.md` | > | Setup-Dokument | `.claude/docs/working/SHINKAN_PROJECT_SETUP.md` |
> | Anforderungen | `.claude/docs/functional/SHINKAN_REQUIREMENTS.md` | > | Anforderungen | `.claude/docs/functional/SHINKAN_REQUIREMENTS.md` |
> | Medien-Archiv, Lifecycle, Promotion | `.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` |
## Projekt-Übersicht ## Projekt-Übersicht
@ -105,7 +106,7 @@ Kurz (Stand 2026-05-05): App **0.8.10**, DBSchemaVersion **`20260505037`**
- `exercises` - Übungen (Kernobjekt) - `exercises` - Übungen (Kernobjekt)
- `exercise_variants` - Übungsvarianten - `exercise_variants` - Übungsvarianten
- `exercise_skills` - M:N Übung ↔ Fähigkeit - `exercise_skills` - M:N Übung ↔ Fähigkeit
- `exercise_media` - Medien (Bilder, Videos) - `exercise_media` - Medien (Bilder, Videos); Zielbild Archiv & Lifecycle: **`MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`**. **Inline im Fließtext** (Übungstexte): geplant §11 derselben Spec — Anker `exercise_media.id`, einheitlicher Render-Pfad; noch nicht implementiert.
**Trainingsplanung / Rahmen:** **Trainingsplanung / Rahmen:**
- `training_plan_templates` + Sektionsvorlagen — Mikrovorlage pro Einheit (Migration **031**) - `training_plan_templates` + Sektionsvorlagen — Mikrovorlage pro Einheit (Migration **031**)
@ -200,6 +201,10 @@ ALLOWED_ORIGINS=https://shinkan.jinkendo.de
MEDIA_DIR=/app/media MEDIA_DIR=/app/media
``` ```
## Produktions-/Sicherheitsaudit (Drift vermeiden)
Aktuelle Befunde und Umsetzungsstände: `.claude/docs/working/PRODUCTION_READINESS_AUDIT_2026-05.md` (Fortlaufend pflegen.)
## Kritische Regeln für Claude Code ## Kritische Regeln für Claude Code
### Must-Do: ### Must-Do:

View File

@ -13,8 +13,6 @@ import bcrypt
from db import get_db, get_cursor from db import get_db, get_cursor
print("[AUTH.PY] Module loaded - require_auth_flexible will be defined")
def hash_pin(pin: str) -> str: def hash_pin(pin: str) -> str:
"""Hash password with bcrypt. Falls back gracefully from legacy SHA256.""" """Hash password with bcrypt. Falls back gracefully from legacy SHA256."""

View File

@ -11,6 +11,10 @@ def is_platform_admin(role: Optional[str]) -> bool:
return (role or "").lower() in ("admin", "superadmin") return (role or "").lower() in ("admin", "superadmin")
def is_superadmin(role: Optional[str]) -> bool:
return (role or "").lower() == "superadmin"
def club_ids_for_profile(cur, profile_id: int) -> Set[int]: def club_ids_for_profile(cur, profile_id: int) -> Set[int]:
cur.execute( cur.execute(
""" """
@ -125,6 +129,24 @@ def memberships_with_roles(cur, profile_id: int, active_only: bool = True) -> Li
return out return out
def club_ids_for_profile_with_roles(cur, profile_id: int, *role_codes: str) -> Set[int]:
"""Vereins-IDs, in denen das Profil mindestens eine der Rollen hat."""
if not role_codes:
return set()
ph = ",".join(["%s"] * len(role_codes))
cur.execute(
f"""
SELECT DISTINCT cm.club_id
FROM club_members cm
INNER JOIN club_member_roles r ON r.club_member_id = cm.id
WHERE cm.profile_id = %s AND cm.status = 'active'
AND r.role_code IN ({ph})
""",
(profile_id, *role_codes),
)
return {int(r["club_id"]) for r in cur.fetchall() if r.get("club_id") is not None}
_GOVERNANCE_VISIBILITY = frozenset({"private", "club", "official"}) _GOVERNANCE_VISIBILITY = frozenset({"private", "club", "official"})

View File

@ -6,7 +6,7 @@ Trainer- und Vereinsplattform für Kampfsport-Trainingsplanung
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
from fastapi import FastAPI from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
@ -18,6 +18,20 @@ from slowapi.errors import RateLimitExceeded
from version import APP_VERSION, BUILD_DATE, DB_SCHEMA_VERSION, MODULE_VERSIONS from version import APP_VERSION, BUILD_DATE, DB_SCHEMA_VERSION, MODULE_VERSIONS
def _is_production_environment() -> bool:
return os.getenv("ENVIRONMENT", "development").strip().lower() in ("production", "prod")
def _public_openapi_enabled() -> bool:
return os.getenv("PUBLIC_OPENAPI", "").strip().lower() in ("1", "true", "yes")
def _health_ready_public_detail_enabled() -> bool:
"""In Prod standardmäßig keine Tabellen-/Migrations-Details (Information Disclosure)."""
return os.getenv("HEALTH_READY_PUBLIC_DETAIL", "").strip().lower() in ("1", "true", "yes")
# Run database migrations before API start — halbes Schema ist schlimmer als kein Start # Run database migrations before API start — halbes Schema ist schlimmer als kein Start
# Lokal ohne DB / nur Tests: SKIP_DB_MIGRATE=1 # Lokal ohne DB / nur Tests: SKIP_DB_MIGRATE=1
if os.getenv("SKIP_DB_MIGRATE", "").strip().lower() in ("1", "true", "yes"): if os.getenv("SKIP_DB_MIGRATE", "").strip().lower() in ("1", "true", "yes"):
@ -39,11 +53,20 @@ else:
from routers.auth import limiter as auth_rate_limiter from routers.auth import limiter as auth_rate_limiter
# OpenAPI: in Produktion standardmäßig aus (Schema nicht öffentlich). Notfall: PUBLIC_OPENAPI=1
_expose_docs = (not _is_production_environment()) or _public_openapi_enabled()
_openapi_url = "/openapi.json" if _expose_docs else None
_docs_url = "/docs" if _expose_docs else None
_redoc_url = "/redoc" if _expose_docs else None
# Initialize FastAPI app # Initialize FastAPI app
app = FastAPI( app = FastAPI(
title="Shinkan Jinkendo API", title="Shinkan Jinkendo API",
description="Trainer- und Vereinsplattform für Kampfsport-Trainingsplanung", description="Trainer- und Vereinsplattform für Kampfsport-Trainingsplanung",
version=APP_VERSION version=APP_VERSION,
openapi_url=_openapi_url,
docs_url=_docs_url,
redoc_url=_redoc_url,
) )
# SlowAPI: Rate Limits auf /api/auth/* (Decorator in routers/auth.py) # SlowAPI: Rate Limits auf /api/auth/* (Decorator in routers/auth.py)
@ -62,7 +85,13 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
# TODO: Initialize Database with migrations
@app.middleware("http")
async def add_api_security_headers(request: Request, call_next):
"""Konsistente Basis-Header auch für rein JSON-Responses (MIME-Sniffing)."""
response = await call_next(request)
response.headers.setdefault("X-Content-Type-Options", "nosniff")
return response
# Version Endpoint (public, no auth) # Version Endpoint (public, no auth)
@app.get("/api/version") @app.get("/api/version")
@ -132,7 +161,7 @@ def health_ready():
migration_count = 0 migration_count = 0
complete = bool(err is None and all(tables.get(t) for t in REQUIRED)) complete = bool(err is None and all(tables.get(t) for t in REQUIRED))
return { body = {
"status": "ready" if complete else "degraded", "status": "ready" if complete else "degraded",
"database": err is None, "database": err is None,
"detail": err, "detail": err,
@ -140,21 +169,30 @@ def health_ready():
"tables": tables, "tables": tables,
"schema_migrations_count": migration_count, "schema_migrations_count": migration_count,
} }
if _is_production_environment() and not _health_ready_public_detail_enabled():
return {
"status": body["status"],
"database": body["database"],
"schema_complete": body["schema_complete"],
}
return body
# Root Endpoint # Root Endpoint
@app.get("/") @app.get("/")
def read_root(): def read_root():
"""Root endpoint - API info""" """Root endpoint - API info"""
return { out = {
"app": "Shinkan Jinkendo API", "app": "Shinkan Jinkendo API",
"version": APP_VERSION, "version": APP_VERSION,
"docs": "/docs", "health": "/health",
"health": "/health"
} }
if _expose_docs:
out["docs"] = "/docs"
return out
# Register routers # 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, media_assets, 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(auth.router)
app.include_router(profiles.router) app.include_router(profiles.router)
@ -164,6 +202,8 @@ app.include_router(clubs.router)
app.include_router(club_memberships.router) app.include_router(club_memberships.router)
app.include_router(club_join_requests.router) app.include_router(club_join_requests.router)
app.include_router(admin_users.router) app.include_router(admin_users.router)
app.include_router(platform_media_storage.router)
app.include_router(media_assets.router)
app.include_router(skills.router) app.include_router(skills.router)
app.include_router(training_planning.router) app.include_router(training_planning.router)
app.include_router(training_framework_programs.router) app.include_router(training_framework_programs.router)
@ -173,10 +213,13 @@ app.include_router(matrix_stack_bundle.router)
app.include_router(import_wiki.router) app.include_router(import_wiki.router)
app.include_router(import_wiki_admin.router) app.include_router(import_wiki_admin.router)
# Lokale Medien (Übungen-Uploads) unter MEDIA_ROOT, ausliefern unter /media/... # Lokale Übungs-Medien: standardmäßig nur über geschützten API-Pfad
# GET /api/exercises/{id}/media/{mid}/file (?ssetoken für <img>/<video>).
# Notfall/Legacy: ALLOW_PUBLIC_MEDIA_STATIC=1 → wieder öffentlich unter /media/
_media_dir = os.getenv("MEDIA_ROOT", str(Path(__file__).resolve().parent / "media")) _media_dir = os.getenv("MEDIA_ROOT", str(Path(__file__).resolve().parent / "media"))
Path(_media_dir).mkdir(parents=True, exist_ok=True) Path(_media_dir).mkdir(parents=True, exist_ok=True)
app.mount("/media", StaticFiles(directory=_media_dir), name="media") if os.getenv("ALLOW_PUBLIC_MEDIA_STATIC", "").strip().lower() in ("1", "true", "yes"):
app.mount("/media", StaticFiles(directory=_media_dir), name="media")
if __name__ == "__main__": if __name__ == "__main__":
import uvicorn import uvicorn

296
backend/media_lifecycle.py Normal file
View File

@ -0,0 +1,296 @@
"""
Medien-Lebenszyklus (Papierkorb) siehe MEDIA_ASSETS_AND_ARCHIVE_SPEC.md §5.
"""
from __future__ import annotations
import logging
import os
from datetime import datetime, timedelta, timezone
from typing import Any, Optional
from fastapi import HTTPException
from club_tenancy import can_manage_club_org, is_platform_admin, is_superadmin
from db import r2d
from media_storage import get_effective_media_root, path_under_media_root
logger = logging.getLogger(__name__)
LC_ACTIVE = "active"
LC_TRASH_SOFT = "trash_soft"
LC_TRASH_HIDDEN = "trash_hidden"
SOFT_TO_HIDDEN_DAYS = max(1, int(os.getenv("MEDIA_TRASH_SOFT_TO_HIDDEN_DAYS", "30")))
HIDDEN_TO_PURGE_DAYS = max(1, int(os.getenv("MEDIA_TRASH_HIDDEN_TO_PURGE_DAYS", "90")))
def assert_can_manage_media_asset_lifecycle(cur: Any, tenant: Any, asset: dict) -> None:
"""
Papierkorb Stufe 2 / Recovery / Reaktivierung nicht für trash_soft (siehe assert_can_trash_soft).
§5.2: official nur Plattform-Admin; club Vereinsorga; privat nur Uploader.
"""
profile_id = tenant.profile_id
role = (tenant.global_role or "").strip().lower()
if is_platform_admin(role):
return
vis = (asset.get("visibility") or "private").strip().lower()
uid = asset.get("uploaded_by_profile_id")
if vis == "private":
if uid is not None and int(uid) == int(profile_id):
return
raise HTTPException(status_code=403, detail="Keine Berechtigung für dieses Medium")
if vis == "official":
raise HTTPException(status_code=403, detail="Nur Plattform-Admin")
if vis == "club":
cid = asset.get("club_id")
if cid is None:
raise HTTPException(status_code=403, detail="Ungültiges Vereins-Medium")
if can_manage_club_org(cur, profile_id, int(cid), role):
return
raise HTTPException(status_code=403, detail="Nur Vereinsorganisation/Plattform-Admin")
raise HTTPException(status_code=403, detail="Keine Berechtigung für dieses Medium")
def assert_can_trash_soft(cur: Any, tenant: Any, asset: dict) -> None:
"""
Aktiv Papierkorb (Stufe 1). Trainer: nur eigene private Uploads.
Vereinsmedien: Vereinsorga; official: Plattform-Admin; Superadmin: immer.
"""
role_raw = tenant.global_role
role = (role_raw or "").strip().lower()
if is_superadmin(role):
return
if is_platform_admin(role):
return
vis = (asset.get("visibility") or "private").strip().lower()
uid = asset.get("uploaded_by_profile_id")
cid = asset.get("club_id")
pid = int(tenant.profile_id)
if vis == "private":
if uid is not None and int(uid) == pid:
return
raise HTTPException(
status_code=403,
detail="Nur eigene private Medien dürfen in den Papierkorb",
)
if vis == "club":
if cid is not None and can_manage_club_org(cur, pid, int(cid), role_raw):
return
raise HTTPException(
status_code=403,
detail="Nur Vereinsorganisation darf Vereinsmedien in den Papierkorb legen",
)
if vis == "official":
raise HTTPException(status_code=403, detail="Nur Plattform-Admin")
raise HTTPException(status_code=403, detail="Keine Berechtigung für dieses Medium")
def assert_can_edit_media_asset_metadata(cur: Any, tenant: Any, asset: dict) -> None:
"""PATCH Metadaten / Sichtbarkeit — gleiche Stufen wie Lifecycle (ohne Papierkorb)."""
assert_can_manage_media_asset_lifecycle(cur, tenant, asset)
def superadmin_force_lifecycle_state(cur: Any, conn: Any, asset_id: int, target: str) -> dict:
"""Nur Superadmin: Zustand direkt setzen."""
if target not in (LC_ACTIVE, LC_TRASH_SOFT, LC_TRASH_HIDDEN):
raise HTTPException(status_code=400, detail="Ungültiger Ziel-Lifecycle")
if target == LC_ACTIVE:
cur.execute(
"""UPDATE media_assets
SET lifecycle_state = %s, updated_at = NOW(),
trash_soft_at = NULL, trash_hidden_at = NULL, purge_after_at = NULL
WHERE id = %s
RETURNING id, lifecycle_state""",
(LC_ACTIVE, asset_id),
)
elif target == LC_TRASH_SOFT:
cur.execute(
"""UPDATE media_assets
SET lifecycle_state = %s, trash_soft_at = COALESCE(trash_soft_at, NOW()),
trash_hidden_at = NULL, purge_after_at = NULL, updated_at = NOW()
WHERE id = %s
RETURNING id, lifecycle_state, trash_soft_at""",
(LC_TRASH_SOFT, asset_id),
)
else:
pa = datetime.now(timezone.utc) + timedelta(days=HIDDEN_TO_PURGE_DAYS)
cur.execute(
"""UPDATE media_assets
SET lifecycle_state = %s, trash_hidden_at = NOW(),
purge_after_at = %s, updated_at = NOW(),
trash_soft_at = COALESCE(trash_soft_at, NOW())
WHERE id = %s
RETURNING id, lifecycle_state, trash_hidden_at, purge_after_at""",
(LC_TRASH_HIDDEN, pa, asset_id),
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Medium nicht gefunden")
conn.commit()
return r2d(row)
def superadmin_hard_delete_media_asset(cur: Any, conn: Any, asset_id: int) -> bool:
"""Nur Superadmin: Zeile + Datei unabhängig vom Lifecycle entfernen."""
cur.execute(
"SELECT id, storage_key FROM media_assets WHERE id = %s",
(asset_id,),
)
row = cur.fetchone()
if not row:
return False
asset = r2d(row)
cur.execute("DELETE FROM exercise_media WHERE media_asset_id = %s", (asset_id,))
purge_asset_filesystem(cur, asset)
cur.execute("DELETE FROM media_assets WHERE id = %s", (asset_id,))
conn.commit()
return True
def fetch_media_asset_row(cur: Any, asset_id: int) -> Optional[dict]:
cur.execute(
"""SELECT id, visibility, club_id, uploaded_by_profile_id, lifecycle_state,
storage_key, storage_backend, trash_soft_at, trash_hidden_at, purge_after_at
FROM media_assets WHERE id = %s""",
(asset_id,),
)
row = cur.fetchone()
return r2d(row) if row else None
def purge_asset_filesystem(cur: Any, asset: dict) -> None:
sk = asset.get("storage_key")
if not sk:
return
root = get_effective_media_root(cur)
p = path_under_media_root(root, str(sk))
if p and p.is_file():
try:
p.unlink()
except OSError as e:
logger.warning("Physische Medien-Löschung fehlgeschlagen: %s", e)
def purge_media_asset(cur: Any, conn: Any, asset_id: int) -> bool:
"""Löscht Verknüpfungen, Datei und DB-Zeile. Returns True wenn ausgeführt."""
cur.execute(
"""SELECT id, storage_key FROM media_assets
WHERE id = %s AND lifecycle_state = %s""",
(asset_id, LC_TRASH_HIDDEN),
)
row = cur.fetchone()
if not row:
return False
asset = r2d(row)
cur.execute("DELETE FROM exercise_media WHERE media_asset_id = %s", (asset_id,))
purge_asset_filesystem(cur, asset)
cur.execute("DELETE FROM media_assets WHERE id = %s", (asset_id,))
conn.commit()
return True
def transition_to_trash_soft(cur: Any, conn: Any, asset_id: int) -> dict:
cur.execute(
"""UPDATE media_assets
SET lifecycle_state = %s, trash_soft_at = NOW(), updated_at = NOW(),
trash_hidden_at = NULL, purge_after_at = NULL
WHERE id = %s AND lifecycle_state = %s
RETURNING id, lifecycle_state, trash_soft_at""",
(LC_TRASH_SOFT, asset_id, LC_ACTIVE),
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=400, detail="Nur aktive Medien können in den Papierkorb")
conn.commit()
return r2d(row)
def reactivate_media_asset_from_trash(cur: Any, conn: Any, asset_id: int) -> dict:
"""
Stufe 1 oder 2 wieder aktiv (z. B. erneuter Upload derselben Datei / explizite Wiederherstellung).
"""
cur.execute(
"""UPDATE media_assets
SET lifecycle_state = %s, updated_at = NOW(),
trash_soft_at = NULL, trash_hidden_at = NULL, purge_after_at = NULL
WHERE id = %s AND lifecycle_state IN (%s, %s)
RETURNING id, lifecycle_state""",
(LC_ACTIVE, asset_id, LC_TRASH_SOFT, LC_TRASH_HIDDEN),
)
row = cur.fetchone()
if not row:
raise HTTPException(
status_code=400,
detail="Nur Medien aus dem Papierkorb (Stufe 1 oder 2) können reaktiviert werden",
)
conn.commit()
return r2d(row)
def transition_to_trash_hidden(cur: Any, conn: Any, asset_id: int, *, set_purge_after: Optional[datetime] = None) -> dict:
now = datetime.now(timezone.utc)
if set_purge_after is None:
set_purge_after = now + timedelta(days=HIDDEN_TO_PURGE_DAYS)
cur.execute(
"""UPDATE media_assets
SET lifecycle_state = %s, trash_hidden_at = NOW(), updated_at = NOW(),
purge_after_at = %s
WHERE id = %s AND lifecycle_state IN (%s, %s)
RETURNING id, lifecycle_state, trash_hidden_at, purge_after_at""",
(LC_TRASH_HIDDEN, set_purge_after, asset_id, LC_ACTIVE, LC_TRASH_SOFT),
)
row = cur.fetchone()
if not row:
raise HTTPException(
status_code=400,
detail="Nur aktive oder Papierkorb-Stufe-1 Medien können ausgeblendet werden",
)
conn.commit()
return r2d(row)
def transition_recover_from_hidden(cur: Any, conn: Any, asset_id: int) -> dict:
cur.execute(
"""UPDATE media_assets
SET lifecycle_state = %s, updated_at = NOW(),
trash_hidden_at = NULL, purge_after_at = NULL
WHERE id = %s AND lifecycle_state = %s
RETURNING id, lifecycle_state, trash_soft_at""",
(LC_TRASH_SOFT, asset_id, LC_TRASH_HIDDEN),
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=400, detail="Nur ausgeblendete Medien können zurückgestuft werden")
conn.commit()
return r2d(row)
def run_retention_pass(cur: Any, conn: Any) -> dict:
"""
Automatik: trash_soft älter als SOFT_TO_HIDDEN_DAYS trash_hidden;
trash_hidden mit purge_after_at in der Vergangenheit purge.
"""
cutoff_soft = datetime.now(timezone.utc) - timedelta(days=SOFT_TO_HIDDEN_DAYS)
cur.execute(
"""UPDATE media_assets
SET lifecycle_state = %s, trash_hidden_at = NOW(), updated_at = NOW(),
purge_after_at = NOW() + (%s * INTERVAL '1 day')
WHERE lifecycle_state = %s AND trash_soft_at IS NOT NULL AND trash_soft_at <= %s
RETURNING id""",
(LC_TRASH_HIDDEN, HIDDEN_TO_PURGE_DAYS, LC_TRASH_SOFT, cutoff_soft),
)
n_hidden = len(cur.fetchall())
conn.commit()
cur.execute(
"""SELECT id FROM media_assets
WHERE lifecycle_state = %s AND purge_after_at IS NOT NULL AND purge_after_at <= NOW()""",
(LC_TRASH_HIDDEN,),
)
purge_ids = [r2d(r)["id"] for r in cur.fetchall()]
purged = 0
for aid in purge_ids:
if purge_media_asset(cur, conn, int(aid)):
purged += 1
return {"moved_to_hidden": n_hidden, "purged": purged}

57
backend/media_storage.py Normal file
View File

@ -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

View File

@ -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/+', '');

View File

@ -74,9 +74,10 @@ def public_club_directory():
return [r2d(r) for r in cur.fetchall()] return [r2d(r) for r in cur.fetchall()]
# ── Aktive Mitglieder für Trainer-/Co-Trainer-Auswahl (jeder Vereinsmitglied) ── # ── Aktive Mitglieder für Trainer-/Co-Trainer-Auswahl (Vereinsmitglied) ──
@router.get("/clubs/{club_id}/members/directory") @router.get("/clubs/{club_id}/members/directory")
def club_members_directory(club_id: int, tenant: TenantContext = Depends(get_tenant_context)): def club_members_directory(club_id: int, tenant: TenantContext = Depends(get_tenant_context)):
"""id + name für alle aktiven Mitglieder; E-Mail nur für Plattform-Admin oder Vereinsadmin (Org-Verwaltung)."""
profile_id = tenant.profile_id profile_id = tenant.profile_id
role = tenant.global_role role = tenant.global_role
with get_db() as conn: with get_db() as conn:
@ -96,7 +97,12 @@ def club_members_directory(club_id: int, tenant: TenantContext = Depends(get_ten
""", """,
(club_id,), (club_id,),
) )
return [r2d(r) for r in cur.fetchall()] rows = [r2d(r) for r in cur.fetchall()]
show_email = is_platform_admin(role) or can_manage_club_org(cur, profile_id, club_id, role)
if not show_email:
for d in rows:
d["email"] = None
return rows
# ── Get Club ────────────────────────────────────────────────────────── # ── Get Club ──────────────────────────────────────────────────────────

File diff suppressed because it is too large Load Diff

View File

@ -3,7 +3,8 @@ Reifegradmodelle / Fähigkeitsmatrix (kontextbezogen)
Kontext zu Fokusbereich, Stilrichtung, Zielgruppe: jeweils M:N (leer = gilt überall). Kontext zu Fokusbereich, Stilrichtung, Zielgruppe: jeweils M:N (leer = gilt überall).
Lesen: alle authentifizierten Nutzer. Lesen: Liste & resolve für alle authentifizierten Nutzer; GET eines Modells nach ID nur Portal-Admin (Admin-UI).
Schreiben: admin, superadmin. Schreiben: admin, superadmin.
""" """
from datetime import datetime, timezone from datetime import datetime, timezone
@ -534,6 +535,7 @@ def list_maturity_models(
@router.get("/maturity-models/{model_id}") @router.get("/maturity-models/{model_id}")
def get_maturity_model(model_id: int, session: dict = Depends(require_auth)): def get_maturity_model(model_id: int, session: dict = Depends(require_auth)):
_require_admin(session)
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
return _load_full_model(cur, model_id) return _load_full_model(cur, model_id)

View File

@ -0,0 +1,552 @@
"""Medien-Archiv (Liste, Datei) und Lifecycle — MEDIA_ASSETS_AND_ARCHIVE_SPEC."""
from __future__ import annotations
from typing import Any, Literal, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel, Field, model_validator
from club_tenancy import (
assert_valid_governance_visibility,
club_ids_for_profile_with_roles,
is_platform_admin,
is_superadmin,
library_content_visible_to_profile,
)
from db import get_db, get_cursor, r2d
from media_lifecycle import (
LC_ACTIVE,
LC_TRASH_HIDDEN,
LC_TRASH_SOFT,
assert_can_edit_media_asset_metadata,
assert_can_manage_media_asset_lifecycle,
assert_can_trash_soft,
fetch_media_asset_row,
purge_media_asset,
reactivate_media_asset_from_trash,
superadmin_force_lifecycle_state,
superadmin_hard_delete_media_asset,
transition_recover_from_hidden,
transition_to_trash_hidden,
transition_to_trash_soft,
)
from media_storage import get_effective_media_root, path_under_media_root
from tenant_context import TenantContext, get_tenant_context, get_tenant_context_flexible
router = APIRouter(prefix="/api/media-assets", tags=["media-assets"])
class MediaLifecycleBody(BaseModel):
action: Literal[
"trash_soft",
"trash_hidden",
"recover",
"purge",
"reactivate",
"superadmin_force_lifecycle",
"superadmin_hard_delete",
]
target_lifecycle: Optional[Literal["active", "trash_soft", "trash_hidden"]] = None
@model_validator(mode="after")
def _target_lifecycle_rules(self):
if self.action == "superadmin_force_lifecycle":
if not self.target_lifecycle:
raise ValueError("target_lifecycle ist für diese Aktion erforderlich")
elif self.target_lifecycle is not None:
raise ValueError("target_lifecycle nur bei superadmin_force_lifecycle")
return self
class MediaAssetPatch(BaseModel):
copyright_notice: Optional[str] = Field(None, max_length=8000)
original_filename: Optional[str] = Field(None, max_length=300)
visibility: Optional[str] = Field(None, pattern="^(private|club|official)$")
club_id: Optional[int] = None
class MediaBulkLifecycleBody(BaseModel):
media_asset_ids: list[int] = Field(..., min_length=1, max_length=200)
action: Literal[
"trash_soft",
"trash_hidden",
"recover",
"purge",
"reactivate",
"superadmin_force_lifecycle",
"superadmin_hard_delete",
]
target_lifecycle: Optional[Literal["active", "trash_soft", "trash_hidden"]] = None
@model_validator(mode="after")
def _bulk_target(self):
if self.action == "superadmin_force_lifecycle" and not self.target_lifecycle:
raise ValueError("target_lifecycle ist für diese Aktion erforderlich")
if self.action != "superadmin_force_lifecycle" and self.target_lifecycle is not None:
raise ValueError("target_lifecycle nur bei superadmin_force_lifecycle")
return self
class MediaBulkPatchBody(BaseModel):
media_asset_ids: list[int] = Field(..., min_length=1, max_length=200)
copyright_notice: Optional[str] = Field(None, max_length=8000)
original_filename: Optional[str] = Field(None, max_length=300)
visibility: Optional[str] = Field(None, pattern="^(private|club|official)$")
club_id: Optional[int] = None
_LIFECYCLE_LIST_FILTERS = frozenset({"active", "trash_soft", "trash_hidden", "all"})
def _effective_media_patch_fields(patch_fields: dict, asset: dict) -> dict:
"""Nach visibility-Wechsel club_id konsistent setzen (official/private → NULL)."""
eff = dict(patch_fields)
if eff.get("visibility") is not None:
v = str(eff["visibility"]).strip().lower()
if v in ("official", "private"):
eff["club_id"] = None
elif v == "club" and "club_id" not in eff:
eff["club_id"] = asset.get("club_id")
return eff
def _lifecycle_where_sql(lifecycle: str) -> str:
lc = (lifecycle or "active").strip().lower()
if lc not in _LIFECYCLE_LIST_FILTERS:
raise HTTPException(status_code=400, detail="Ungültiger lifecycle-Filter")
if lc == "active":
return "ma.lifecycle_state = 'active'"
if lc == "trash_soft":
return "ma.lifecycle_state = 'trash_soft'"
if lc == "trash_hidden":
return "ma.lifecycle_state = 'trash_hidden'"
return "ma.lifecycle_state IN ('active', 'trash_soft', 'trash_hidden')"
def _fetch_asset_file_row(cur: Any, asset_id: int) -> Optional[dict]:
cur.execute(
"""SELECT id, visibility, club_id, uploaded_by_profile_id, lifecycle_state,
storage_key, mime_type, original_filename
FROM media_assets WHERE id = %s""",
(asset_id,),
)
row = cur.fetchone()
return r2d(row) if row else None
def _assert_can_view_archive_asset(cur: Any, tenant: TenantContext, asset: dict) -> None:
if not library_content_visible_to_profile(
cur,
tenant.profile_id,
(asset.get("visibility") or "").strip().lower(),
asset.get("club_id"),
asset.get("uploaded_by_profile_id"),
tenant.global_role,
):
raise HTTPException(status_code=403, detail="Keine Berechtigung für dieses Medium")
def _item_permissions(row: dict, tenant: TenantContext, admin_club_ids: set[int]) -> dict:
"""Berechnete UI-/Policy-Flags pro Zeile (ohne zusätzliche DB)."""
role_raw = tenant.global_role
role = (role_raw or "").strip().lower()
pid = int(tenant.profile_id)
sup = is_superadmin(role_raw)
plat = is_platform_admin(role_raw)
vis = (row.get("visibility") or "private").strip().lower()
uid = row.get("uploaded_by_profile_id")
cid = row.get("club_id")
lc = (row.get("lifecycle_state") or "active").strip().lower()
is_owner = uid is not None and int(uid) == pid
club_mgr = plat or (cid is not None and int(cid) in admin_club_ids)
edit_metadata = (
sup
or (vis == "official" and plat)
or (vis == "club" and club_mgr)
or (vis == "private" and is_owner)
)
trash_soft = lc == "active" and (
sup or plat or (vis == "private" and is_owner) or (vis == "club" and club_mgr)
)
if vis == "official" and not (sup or plat):
trash_soft = False
can_manage_adv = (
sup
or plat
or (vis == "private" and is_owner)
or (vis == "club" and club_mgr)
)
trash_hidden = lc in ("active", "trash_soft") and can_manage_adv
recover_from_hidden = lc == "trash_hidden" and can_manage_adv
reactivate = lc in ("trash_soft", "trash_hidden") and can_manage_adv
purge = lc == "trash_hidden" and sup
return {
"edit_metadata": edit_metadata,
"change_visibility": edit_metadata,
"trash_soft": trash_soft,
"trash_hidden": trash_hidden,
"recover": recover_from_hidden,
"reactivate": reactivate,
"purge": purge,
"superadmin_lifecycle": sup,
"superadmin_hard_delete": sup,
}
def _apply_lifecycle_action(
cur: Any,
conn: Any,
asset_id: int,
body: MediaLifecycleBody,
tenant: TenantContext,
) -> dict:
asset = fetch_media_asset_row(cur, asset_id)
if not asset:
raise HTTPException(status_code=404, detail="Medium nicht gefunden")
action = body.action
role_raw = tenant.global_role
if action == "superadmin_hard_delete":
if not is_superadmin(role_raw):
raise HTTPException(status_code=403, detail="Nur Superadmin")
ok = superadmin_hard_delete_media_asset(cur, conn, asset_id)
if not ok:
raise HTTPException(status_code=404, detail="Medium nicht gefunden")
return {"ok": True, "hard_deleted": asset_id}
if action == "superadmin_force_lifecycle":
if not is_superadmin(role_raw):
raise HTTPException(status_code=403, detail="Nur Superadmin")
tl = body.target_lifecycle or "active"
mp = {"active": LC_ACTIVE, "trash_soft": LC_TRASH_SOFT, "trash_hidden": LC_TRASH_HIDDEN}
return superadmin_force_lifecycle_state(cur, conn, asset_id, mp[tl])
if action == "purge":
if not is_superadmin(role_raw):
raise HTTPException(status_code=403, detail="Endgültiges Löschen nur als Superadmin")
state = (asset.get("lifecycle_state") or "").strip().lower()
if state != LC_TRASH_HIDDEN:
raise HTTPException(
status_code=400,
detail="Nur ausgeblendete Medien (Stufe 2) dürfen mit dieser Aktion entfernt werden",
)
if not purge_media_asset(cur, conn, asset_id):
raise HTTPException(status_code=400, detail="Löschen nicht möglich")
return {"ok": True, "purged": asset_id}
if action == "trash_soft":
assert_can_trash_soft(cur, tenant, asset)
return transition_to_trash_soft(cur, conn, asset_id)
if action == "trash_hidden":
assert_can_manage_media_asset_lifecycle(cur, tenant, asset)
return transition_to_trash_hidden(cur, conn, asset_id)
if action == "recover":
assert_can_manage_media_asset_lifecycle(cur, tenant, asset)
return transition_recover_from_hidden(cur, conn, asset_id)
if action == "reactivate":
assert_can_manage_media_asset_lifecycle(cur, tenant, asset)
return reactivate_media_asset_from_trash(cur, conn, asset_id)
raise HTTPException(status_code=500, detail="Interner Fehler: lifecycle action")
@router.get("")
def list_media_assets(
tenant: TenantContext = Depends(get_tenant_context),
q: Optional[str] = Query(None, max_length=120),
lifecycle: str = Query(
"active",
description="active | trash_soft | trash_hidden | all (nicht purgierte Zustände)",
),
limit: int = Query(30, ge=1, le=100),
offset: int = Query(0, ge=0),
):
lc_where = _lifecycle_where_sql(lifecycle)
role = tenant.global_role or ""
is_adm = is_platform_admin(role)
profile_id = tenant.profile_id
needle = (q or "").strip()
params: list[Any] = [is_adm, profile_id, profile_id]
search_sql = ""
if needle:
like = f"%{needle}%"
params.extend([like, like])
search_sql = " AND (ma.original_filename ILIKE %s OR ma.storage_key ILIKE %s)"
params.extend([limit, offset])
with get_db() as conn:
cur = get_cursor(conn)
admin_club_ids = club_ids_for_profile_with_roles(cur, profile_id, "club_admin")
cur.execute(
f"""SELECT ma.id, ma.mime_type, ma.byte_size, ma.original_filename, ma.visibility, ma.club_id,
ma.uploaded_by_profile_id, ma.lifecycle_state, ma.created_at, ma.sha256,
ma.copyright_notice, ma.storage_key,
pr.name AS uploader_name,
pr.email AS uploader_email,
cl.name AS club_name
FROM media_assets ma
LEFT JOIN profiles pr ON pr.id = ma.uploaded_by_profile_id
LEFT JOIN clubs cl ON cl.id = ma.club_id
WHERE {lc_where}
AND (
%s
OR lower(trim(ma.visibility)) = 'official'
OR (
lower(trim(ma.visibility)) = 'private'
AND ma.uploaded_by_profile_id = %s
)
OR (
lower(trim(ma.visibility)) = 'club'
AND EXISTS (
SELECT 1 FROM club_members cm
WHERE cm.profile_id = %s
AND cm.club_id = ma.club_id
AND cm.status = 'active'
)
)
)
{search_sql}
ORDER BY ma.updated_at DESC NULLS LAST, ma.created_at DESC
LIMIT %s OFFSET %s""",
params,
)
rows = [r2d(r) for r in cur.fetchall()]
show_uploader = is_superadmin(role) or is_platform_admin(role) or bool(admin_club_ids)
show_club = is_superadmin(role) or is_platform_admin(role)
for r in rows:
r["permissions"] = _item_permissions(r, tenant, admin_club_ids)
if not show_uploader:
r["uploader_name"] = None
r["uploader_email"] = None
if not show_club:
r["club_name"] = None
viewer = {
"show_uploader_meta": show_uploader,
"show_club_meta": show_club,
"is_superadmin": is_superadmin(role),
"is_platform_admin": is_platform_admin(role),
}
return {
"items": rows,
"limit": limit,
"offset": offset,
"lifecycle": lifecycle.strip().lower(),
"viewer": viewer,
}
@router.api_route("/{asset_id}/file", methods=["GET", "HEAD"])
def download_media_asset_file(
request: Request,
asset_id: int,
tenant: TenantContext = Depends(get_tenant_context_flexible),
):
"""Direktzugriff auf Archiv-Datei (Thumbnail/Vorschau); Auth wie Übungs-Medien (?ssetoken)."""
from routers.exercises import _binary_media_response
with get_db() as conn:
cur = get_cursor(conn)
asset = _fetch_asset_file_row(cur, asset_id)
if not asset:
raise HTTPException(status_code=404, detail="Medium nicht gefunden")
lc = (asset.get("lifecycle_state") or "").strip().lower()
if lc == "active":
_assert_can_view_archive_asset(cur, tenant, asset)
elif lc in ("trash_soft", "trash_hidden"):
assert_can_manage_media_asset_lifecycle(cur, tenant, asset)
else:
raise HTTPException(status_code=404, detail="Medium nicht verfügbar")
sk = asset.get("storage_key")
if not sk:
raise HTTPException(status_code=404, detail="Keine Datei hinterlegt")
media_root = get_effective_media_root(cur)
abs_p = path_under_media_root(media_root, str(sk))
if not abs_p or not abs_p.is_file():
raise HTTPException(status_code=404, detail="Datei nicht gefunden")
mime = asset.get("mime_type") or "application/octet-stream"
fname = asset.get("original_filename") or abs_p.name
return _binary_media_response(abs_p, mime, str(fname) if fname else None, request)
@router.post("/{asset_id}/lifecycle")
def post_media_asset_lifecycle(
asset_id: int,
body: MediaLifecycleBody,
tenant: TenantContext = Depends(get_tenant_context),
):
with get_db() as conn:
cur = get_cursor(conn)
return _apply_lifecycle_action(cur, conn, asset_id, body, tenant)
@router.post("/bulk-lifecycle")
def bulk_media_lifecycle(
body: MediaBulkLifecycleBody,
tenant: TenantContext = Depends(get_tenant_context),
):
inner = MediaLifecycleBody(action=body.action, target_lifecycle=body.target_lifecycle)
updated: list[int] = []
failed: list[dict] = []
with get_db() as conn:
cur = get_cursor(conn)
for aid in sorted(set(int(x) for x in body.media_asset_ids if x and int(x) > 0)):
try:
_apply_lifecycle_action(cur, conn, aid, inner, tenant)
updated.append(aid)
except HTTPException as he:
msg = he.detail if isinstance(he.detail, str) else str(he.detail)
failed.append({"id": aid, "detail": msg})
return {"updated": updated, "failed": failed, "updated_count": len(updated), "failed_count": len(failed)}
@router.post("/bulk-patch")
def bulk_media_patch(
body: MediaBulkPatchBody,
tenant: TenantContext = Depends(get_tenant_context),
):
raw = body.model_dump(exclude_unset=True) if hasattr(body, "model_dump") else body.dict(exclude_unset=True)
patch_fields = {k: v for k, v in raw.items() if k != "media_asset_ids" and v is not None}
if not patch_fields:
raise HTTPException(status_code=400, detail="Keine Felder zum Aktualisieren")
updated: list[int] = []
failed: list[dict] = []
profile_id = tenant.profile_id
role = tenant.global_role
with get_db() as conn:
cur = get_cursor(conn)
for asset_id in sorted(set(int(x) for x in body.media_asset_ids if x and int(x) > 0)):
try:
cur.execute(
"""SELECT id, visibility, club_id, uploaded_by_profile_id, lifecycle_state,
copyright_notice, original_filename
FROM media_assets WHERE id = %s""",
(asset_id,),
)
row = cur.fetchone()
if not row:
failed.append({"id": asset_id, "detail": "Medium nicht gefunden"})
continue
asset = r2d(row)
assert_can_edit_media_asset_metadata(cur, tenant, asset)
eff = _effective_media_patch_fields(patch_fields, asset)
next_vis = str(eff.get("visibility", asset["visibility"])).strip().lower()
next_cid = eff["club_id"] if "club_id" in eff else asset.get("club_id")
if "visibility" in patch_fields or "club_id" in patch_fields:
assert_valid_governance_visibility(
cur,
profile_id,
role,
next_vis,
int(next_cid) if next_cid is not None else None,
)
sets: list[str] = []
vals: list[Any] = []
if "copyright_notice" in patch_fields:
sets.append("copyright_notice = %s")
vals.append(patch_fields["copyright_notice"])
if "original_filename" in patch_fields:
sets.append("original_filename = %s")
vals.append(patch_fields["original_filename"])
if "visibility" in patch_fields or "club_id" in patch_fields:
sets.append("visibility = %s")
vals.append(str(eff.get("visibility", asset["visibility"])).strip())
sets.append("club_id = %s")
vals.append(eff.get("club_id"))
if not sets:
failed.append({"id": asset_id, "detail": "Nichts zu aktualisieren"})
continue
sets.append("updated_at = NOW()")
vals.append(asset_id)
cur.execute(
f"UPDATE media_assets SET {', '.join(sets)} WHERE id = %s",
tuple(vals),
)
conn.commit()
updated.append(asset_id)
except HTTPException as he:
msg = he.detail if isinstance(he.detail, str) else str(he.detail)
failed.append({"id": asset_id, "detail": msg})
return {"updated": updated, "failed": failed, "updated_count": len(updated), "failed_count": len(failed)}
@router.patch("/{asset_id}")
def patch_media_asset(
asset_id: int,
body: MediaAssetPatch,
tenant: TenantContext = Depends(get_tenant_context),
):
profile_id = tenant.profile_id
role = tenant.global_role
data = body.model_dump(exclude_unset=True) if hasattr(body, "model_dump") else body.dict(exclude_unset=True)
if not data:
raise HTTPException(status_code=400, detail="Keine Felder zum Aktualisieren")
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""SELECT id, visibility, club_id, uploaded_by_profile_id, lifecycle_state,
copyright_notice, original_filename
FROM media_assets WHERE id = %s""",
(asset_id,),
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Medium nicht gefunden")
asset = r2d(row)
assert_can_edit_media_asset_metadata(cur, tenant, asset)
eff = _effective_media_patch_fields(data, asset)
next_vis = str(eff.get("visibility", asset["visibility"])).strip().lower()
next_cid = eff["club_id"] if "club_id" in eff else asset.get("club_id")
if "visibility" in data or "club_id" in data:
assert_valid_governance_visibility(
cur,
profile_id,
role,
next_vis,
int(next_cid) if next_cid is not None else None,
)
sets: list[str] = []
vals: list[Any] = []
if "copyright_notice" in data:
sets.append("copyright_notice = %s")
vals.append(data["copyright_notice"])
if "original_filename" in data:
sets.append("original_filename = %s")
vals.append(data["original_filename"])
if "visibility" in data or "club_id" in data:
sets.append("visibility = %s")
vals.append(str(eff.get("visibility", asset["visibility"])).strip())
sets.append("club_id = %s")
vals.append(eff.get("club_id"))
if sets:
sets.append("updated_at = NOW()")
vals.append(asset_id)
cur.execute(
f"UPDATE media_assets SET {', '.join(sets)} WHERE id = %s",
tuple(vals),
)
conn.commit()
cur.execute(
"""SELECT id, mime_type, byte_size, original_filename, visibility, club_id,
uploaded_by_profile_id, lifecycle_state, created_at, sha256, copyright_notice
FROM media_assets WHERE id = %s""",
(asset_id,),
)
out = r2d(cur.fetchone())
return out

View File

@ -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),
)

View File

@ -22,19 +22,6 @@ router = APIRouter(prefix="/api", tags=["profiles"])
_ALLOWED_PORTAL_ROLES = frozenset({"user", "trainer", "admin", "superadmin"}) _ALLOWED_PORTAL_ROLES = frozenset({"user", "trainer", "admin", "superadmin"})
# ── Helper ────────────────────────────────────────────────────────────────────
def get_pid(x_profile_id: Optional[str] = Header(default=None)) -> str:
"""Get profile_id - from header for legacy endpoints."""
if x_profile_id:
return x_profile_id
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT id FROM profiles ORDER BY created LIMIT 1")
row = cur.fetchone()
if row: return row['id']
raise HTTPException(400, "Kein Profil gefunden")
# ── Current User Profile ────────────────────────────────────────────────────── # ── Current User Profile ──────────────────────────────────────────────────────
@router.get("/profiles/me") @router.get("/profiles/me")
def get_current_profile( def get_current_profile(
@ -310,26 +297,45 @@ def delete_profile(pid: str, session=Depends(require_auth)):
cur.execute("SELECT COUNT(*) as count FROM profiles") cur.execute("SELECT COUNT(*) as count FROM profiles")
count = cur.fetchone()['count'] count = cur.fetchone()['count']
if count <= 1: raise HTTPException(400, "Letztes Profil kann nicht gelöscht werden") if count <= 1: raise HTTPException(400, "Letztes Profil kann nicht gelöscht werden")
for table in ['weight_log','circumference_log','caliper_log','nutrition_log','activity_log','ai_insights']: # Mitai-Überbleibsel: nur löschen, wenn die Tabelle im Schema existiert (Shinkan-DB ohne diese Tabellen).
cur.execute(f"DELETE FROM {table} WHERE profile_id=%s", (pid,)) _optional_mitai_tables = (
"weight_log",
"circumference_log",
"caliper_log",
"nutrition_log",
"activity_log",
"ai_insights",
)
for table in _optional_mitai_tables:
cur.execute(
"""
SELECT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = %s
) AS t_exists
""",
(table,),
)
ex = cur.fetchone()
if ex and next(iter(ex.values())):
cur.execute(f"DELETE FROM {table} WHERE profile_id=%s", (pid,))
cur.execute("DELETE FROM profiles WHERE id=%s", (pid,)) cur.execute("DELETE FROM profiles WHERE id=%s", (pid,))
return {"ok": True} return {"ok": True}
# ── Current User Profile ────────────────────────────────────────────────────── # ── Current User Profile ──────────────────────────────────────────────────────
@router.get("/profile") @router.get("/profile")
def get_active_profile(x_profile_id: Optional[str] = Header(default=None), session: dict = Depends(require_auth)): def get_active_profile(session: dict = Depends(require_auth)):
"""Legacy endpoint returns active profile.""" """Legacy-Alias für das eingeloggte Profil — immer Session, kein X-Profile-Id (SECURITY: kein IDOR)."""
pid = get_pid(x_profile_id) pid = str(session["profile_id"])
return profile_document(pid) return profile_document(pid)
@router.put("/profile") @router.put("/profile")
def update_active_profile( def update_active_profile(
p: ProfileUpdate, p: ProfileUpdate,
x_profile_id: Optional[str] = Header(default=None),
tenant: TenantContext = Depends(get_tenant_context), tenant: TenantContext = Depends(get_tenant_context),
): ):
"""Update current user's profile.""" """Profil des eingeloggten Nutzers aktualisieren — dieselbe Quelle wie GET /profile."""
pid = get_pid(x_profile_id) pid = str(tenant.profile_id)
return _run_profile_update(pid, p, tenant) return _run_profile_update(pid, p, tenant)

View File

@ -21,6 +21,7 @@ EXEMPT_ROUTERS: frozenset[str] = frozenset(
{ {
"auth.py", "auth.py",
"admin_users.py", "admin_users.py",
"platform_media_storage.py",
"catalogs.py", "catalogs.py",
"skills.py", "skills.py",
"maturity_models.py", "maturity_models.py",

View File

@ -0,0 +1,35 @@
#!/usr/bin/env python3
"""
Automatische Medien-Retention (Papierkorb Stufe 12, Purge wenn fällig).
Lauf z. B. täglich per Cron:
cd /path/to/backend && python scripts/media_retention_job.py
Umgebung wie Backend (DB_*), optional:
MEDIA_TRASH_SOFT_TO_HIDDEN_DAYS (Default 30)
MEDIA_TRASH_HIDDEN_TO_PURGE_DAYS (Default 90)
"""
from __future__ import annotations
import os
import sys
# Repo-Root: backend/scripts -> parents[1] == backend
_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if _ROOT not in sys.path:
sys.path.insert(0, _ROOT)
from db import get_db, get_cursor # noqa: E402
from media_lifecycle import run_retention_pass # noqa: E402
def main() -> int:
with get_db() as conn:
cur = get_cursor(conn)
summary = run_retention_pass(cur, conn)
print(summary)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,32 @@
#!/usr/bin/env python3
"""
CI-Sicherheitschecks: schlanke pytest-Sammlung ohne Integrations-DB.
Repo-Root ist egal arbeitet relativ zu diesem Script (backend/).
Usage (aus backend/):
python scripts/security_release_checks.py
"""
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
def main() -> int:
root = Path(__file__).resolve().parents[1]
test_file = root / "tests" / "test_security_release.py"
cmd = [
sys.executable,
"-m",
"pytest",
str(test_file),
"-v",
"--tb=short",
]
return subprocess.run(cmd, cwd=str(root)).returncode
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -11,7 +11,7 @@ from typing import Any, Dict, List, Optional
from fastapi import Depends, Header, HTTPException from fastapi import Depends, Header, HTTPException
from auth import require_auth from auth import require_auth, require_auth_flexible
from club_tenancy import is_platform_admin, memberships_with_roles from club_tenancy import is_platform_admin, memberships_with_roles
from db import get_db, get_cursor from db import get_db, get_cursor
@ -183,6 +183,34 @@ def get_tenant_context(
) )
def get_tenant_context_flexible(
session: dict = Depends(require_auth_flexible),
x_active_club_id: Optional[str] = Header(default=None, alias="X-Active-Club-Id"),
) -> TenantContext:
"""
Wie get_tenant_context, aber Auth per Header oder Query ?ssetoken (für <img>/<video> ohne Custom-Header).
"""
pid = int(session["profile_id"])
role = session.get("role") or ""
stored: Optional[int] = None
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT active_club_id FROM profiles WHERE id = %s", (pid,))
row = cur.fetchone()
if row is not None:
ac = row.get("active_club_id")
if ac is not None:
stored = int(ac)
return resolve_tenant_context(
cur,
profile_id=pid,
global_role=role,
header_raw=x_active_club_id,
memberships=None,
stored_active_club_id=stored,
)
def tenant_context_from_session_only( def tenant_context_from_session_only(
cur, cur,
session: dict, session: dict,

View File

@ -0,0 +1,75 @@
"""
Geschützte Übungs-Mediendatei: Auth + Governance (kein anonymes /media/).
"""
from __future__ import annotations
import os
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
os.environ.setdefault("SKIP_DB_MIGRATE", "1")
from auth import require_auth_flexible
from main import app
from tenant_context import TenantContext, get_tenant_context_flexible
@pytest.fixture
def client() -> TestClient:
return TestClient(app)
@pytest.fixture(autouse=True)
def _clear_overrides():
yield
app.dependency_overrides.pop(require_auth_flexible, None)
app.dependency_overrides.pop(get_tenant_context_flexible, None)
def test_exercise_media_file_unauthenticated_401(client: TestClient) -> None:
r = client.get("/api/exercises/1/media/2/file")
assert r.status_code == 401
def test_exercise_media_file_forbidden_when_not_visible(client: TestClient) -> None:
app.dependency_overrides[get_tenant_context_flexible] = lambda: TenantContext(
profile_id=22,
global_role="trainer",
effective_club_id=None,
club_ids=frozenset(),
memberships=[],
)
mock_cm = MagicMock()
mock_conn = MagicMock()
mock_cm.__enter__.return_value = mock_conn
mock_cm.__exit__.return_value = False
mock_cur = MagicMock()
mock_cur.fetchone.return_value = {
"id": 3,
"visibility": "private",
"club_id": None,
"created_by": 99,
}
with patch("routers.exercises.get_db", return_value=mock_cm), patch(
"routers.exercises.get_cursor", return_value=mock_cur
), patch("routers.exercises.library_content_visible_to_profile", return_value=False):
r = client.get("/api/exercises/3/media/2/file", headers={"X-Auth-Token": "t"})
assert r.status_code == 403
def test_get_maturity_model_requires_admin(client: TestClient) -> None:
from auth import require_auth
def _user() -> dict:
return {"profile_id": 1, "role": "trainer"}
app.dependency_overrides[require_auth] = _user
try:
r = client.get("/api/maturity-models/1", headers={"X-Auth-Token": "x"})
assert r.status_code == 403
finally:
app.dependency_overrides.pop(require_auth, None)

View File

@ -0,0 +1,438 @@
"""
Medienarchiv: GET /api/media-assets und POST /api/exercises/{id}/media/from-asset (gemockte DB).
"""
from __future__ import annotations
import os
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
os.environ.setdefault("SKIP_DB_MIGRATE", "1")
from auth import require_auth
from main import app
from tenant_context import TenantContext, get_tenant_context
@pytest.fixture
def client() -> TestClient:
return TestClient(app)
@pytest.fixture(autouse=True)
def _clear_overrides() -> None:
yield
app.dependency_overrides.pop(require_auth, None)
app.dependency_overrides.pop(get_tenant_context, None)
def _mock_db(mock_cur: MagicMock) -> MagicMock:
mock_conn = MagicMock()
mock_cm = MagicMock()
mock_cm.__enter__.return_value = mock_conn
mock_cm.__exit__.return_value = False
return mock_cm
def test_list_media_assets_ok_mocked(client: TestClient) -> None:
app.dependency_overrides[require_auth] = lambda: {"profile_id": 10, "role": "trainer"}
app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
profile_id=10,
global_role="trainer",
effective_club_id=5,
club_ids=frozenset({5}),
memberships=[],
)
mock_cur = MagicMock()
mock_cur.fetchall.return_value = [
{
"id": 1,
"mime_type": "image/png",
"byte_size": 100,
"original_filename": "a.png",
"visibility": "official",
"club_id": None,
"uploaded_by_profile_id": 2,
"lifecycle_state": "active",
"created_at": None,
"sha256": "a" * 64,
"copyright_notice": None,
}
]
mock_cm = _mock_db(mock_cur)
with patch("routers.media_assets.get_db", return_value=mock_cm), patch(
"routers.media_assets.get_cursor", return_value=mock_cur
), patch("routers.media_assets.club_ids_for_profile_with_roles", return_value=set()):
r = client.get("/api/media-assets?q=test", headers={"X-Auth-Token": "t"})
assert r.status_code == 200
body = r.json()
assert body["limit"] == 30
assert len(body["items"]) == 1
assert body["items"][0]["original_filename"] == "a.png"
assert "viewer" in body
def test_attach_from_asset_duplicate_returns_400(client: TestClient) -> None:
app.dependency_overrides[require_auth] = lambda: {"profile_id": 1, "role": "trainer"}
app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
profile_id=1,
global_role="trainer",
effective_club_id=None,
club_ids=frozenset(),
memberships=[],
)
mock_cur = MagicMock()
mock_cur.fetchone.side_effect = [
{"created_by": 1, "visibility": "private", "club_id": None},
{"c": 0},
{
"id": 5,
"mime_type": "image/jpeg",
"byte_size": 10,
"original_filename": "x.jpg",
"visibility": "official",
"club_id": None,
"uploaded_by_profile_id": 1,
"lifecycle_state": "active",
"storage_key": "exercises/x.jpg",
},
{"id": 1},
]
mock_cm = _mock_db(mock_cur)
with patch("routers.exercises.get_db", return_value=mock_cm), patch(
"routers.exercises.get_cursor", return_value=mock_cur
):
r = client.post(
"/api/exercises/3/media/from-asset",
headers={"X-Auth-Token": "t", "Content-Type": "application/json"},
json={
"media_asset_id": 5,
"title": "",
"description": "",
"context": "ablauf",
"is_primary": False,
},
)
assert r.status_code == 400
assert "bereits" in (r.json().get("detail") or "").lower()
def test_attach_from_asset_ok_mocked(client: TestClient) -> None:
app.dependency_overrides[require_auth] = lambda: {"profile_id": 1, "role": "trainer"}
app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
profile_id=1,
global_role="trainer",
effective_club_id=None,
club_ids=frozenset(),
memberships=[],
)
inserted = {
"id": 99,
"exercise_id": 3,
"media_type": "image",
"file_path": "/media/exercises/h.jpg",
"file_size": 10,
"mime_type": "image/jpeg",
"original_filename": "h.jpg",
"embed_url": None,
"embed_platform": None,
"title": "h.jpg",
"description": None,
"sort_order": 1,
"is_primary": False,
"context": "ablauf",
"created_at": None,
"media_asset_id": 5,
}
mock_cur = MagicMock()
mock_cur.fetchone.side_effect = [
{"created_by": 1, "visibility": "private", "club_id": None},
{"c": 0},
{
"id": 5,
"mime_type": "image/jpeg",
"byte_size": 10,
"original_filename": "h.jpg",
"visibility": "official",
"club_id": None,
"uploaded_by_profile_id": 1,
"lifecycle_state": "active",
"storage_key": "exercises/h.jpg",
},
None,
inserted,
]
mock_cm = _mock_db(mock_cur)
with patch("routers.exercises.get_db", return_value=mock_cm), patch(
"routers.exercises.get_cursor", return_value=mock_cur
):
r = client.post(
"/api/exercises/3/media/from-asset",
headers={"X-Auth-Token": "t", "Content-Type": "application/json"},
json={
"media_asset_id": 5,
"context": "detail",
"is_primary": False,
},
)
assert r.status_code == 201
body = r.json()
assert body["id"] == 99
assert body["media_asset_id"] == 5
assert body["asset_lifecycle_state"] == "active"
def test_delete_exercise_media_returns_orphan_when_last_ref(client: TestClient) -> None:
app.dependency_overrides[require_auth] = lambda: {"profile_id": 1, "role": "trainer"}
app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
profile_id=1,
global_role="trainer",
effective_club_id=None,
club_ids=frozenset(),
memberships=[],
)
mock_cur = MagicMock()
mock_cur.fetchone.side_effect = [
{"created_by": 1, "visibility": "private", "club_id": None},
{"media_asset_id": 88},
{"c": 1},
]
mock_cm = _mock_db(mock_cur)
with patch("routers.exercises.get_db", return_value=mock_cm), patch(
"routers.exercises.get_cursor", return_value=mock_cur
):
r = client.delete("/api/exercises/10/media/20", headers={"X-Auth-Token": "t"})
assert r.status_code == 200
body = r.json()
assert body["ok"] is True
assert body["orphan_media_asset_id"] == 88
def test_delete_exercise_media_no_orphan_when_shared(client: TestClient) -> None:
app.dependency_overrides[require_auth] = lambda: {"profile_id": 1, "role": "trainer"}
app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
profile_id=1,
global_role="trainer",
effective_club_id=None,
club_ids=frozenset(),
memberships=[],
)
mock_cur = MagicMock()
mock_cur.fetchone.side_effect = [
{"created_by": 1, "visibility": "private", "club_id": None},
{"media_asset_id": 88},
{"c": 2},
]
mock_cm = _mock_db(mock_cur)
with patch("routers.exercises.get_db", return_value=mock_cm), patch(
"routers.exercises.get_cursor", return_value=mock_cur
):
r = client.delete("/api/exercises/10/media/20", headers={"X-Auth-Token": "t"})
assert r.status_code == 200
assert r.json()["orphan_media_asset_id"] is None
def test_delete_exercise_media_embed_row_no_orphan(client: TestClient) -> None:
app.dependency_overrides[require_auth] = lambda: {"profile_id": 1, "role": "trainer"}
app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
profile_id=1,
global_role="trainer",
effective_club_id=None,
club_ids=frozenset(),
memberships=[],
)
mock_cur = MagicMock()
mock_cur.fetchone.side_effect = [
{"created_by": 1, "visibility": "private", "club_id": None},
{"media_asset_id": None},
]
mock_cm = _mock_db(mock_cur)
with patch("routers.exercises.get_db", return_value=mock_cm), patch(
"routers.exercises.get_cursor", return_value=mock_cur
):
r = client.delete("/api/exercises/10/media/21", headers={"X-Auth-Token": "t"})
assert r.status_code == 200
assert r.json() == {"ok": True, "orphan_media_asset_id": None}
def test_media_asset_lifecycle_reactivate_mocked(client: TestClient) -> None:
app.dependency_overrides[require_auth] = lambda: {"profile_id": 1, "role": "trainer"}
app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
profile_id=1,
global_role="trainer",
effective_club_id=None,
club_ids=frozenset(),
memberships=[],
)
mock_cur = MagicMock()
mock_cur.fetchone.side_effect = [
{
"id": 5,
"visibility": "private",
"club_id": None,
"uploaded_by_profile_id": 1,
"lifecycle_state": "trash_soft",
"storage_key": "exercises/a.mp4",
"storage_backend": "local",
"trash_soft_at": None,
"trash_hidden_at": None,
"purge_after_at": None,
},
{"id": 5, "lifecycle_state": "active"},
]
mock_cm = _mock_db(mock_cur)
with patch("routers.media_assets.get_db", return_value=mock_cm), patch(
"routers.media_assets.get_cursor", return_value=mock_cur
):
r = client.post(
"/api/media-assets/5/lifecycle",
headers={"X-Auth-Token": "t", "Content-Type": "application/json"},
json={"action": "reactivate"},
)
assert r.status_code == 200
assert r.json()["lifecycle_state"] == "active"
def test_media_asset_lifecycle_purge_forbidden_for_non_superadmin(client: TestClient) -> None:
app.dependency_overrides[require_auth] = lambda: {"profile_id": 1, "role": "admin"}
app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
profile_id=1,
global_role="admin",
effective_club_id=None,
club_ids=frozenset(),
memberships=[],
)
mock_cur = MagicMock()
mock_cm = _mock_db(mock_cur)
fake_row = {
"id": 99,
"visibility": "official",
"club_id": None,
"uploaded_by_profile_id": 2,
"lifecycle_state": "trash_hidden",
"storage_key": "x",
"storage_backend": "local",
"trash_soft_at": None,
"trash_hidden_at": None,
"purge_after_at": None,
}
with patch("routers.media_assets.get_db", return_value=mock_cm), patch(
"routers.media_assets.get_cursor", return_value=mock_cur
), patch("routers.media_assets.fetch_media_asset_row", return_value=fake_row):
r = client.post(
"/api/media-assets/99/lifecycle",
headers={"X-Auth-Token": "t", "Content-Type": "application/json"},
json={"action": "purge"},
)
assert r.status_code == 403
assert "Superadmin" in (r.json().get("detail") or "")
def test_list_media_assets_lifecycle_trash_soft_mocked(client: TestClient) -> None:
app.dependency_overrides[require_auth] = lambda: {"profile_id": 10, "role": "trainer"}
app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
profile_id=10,
global_role="trainer",
effective_club_id=5,
club_ids=frozenset({5}),
memberships=[],
)
mock_cur = MagicMock()
mock_cur.fetchall.return_value = []
mock_cm = _mock_db(mock_cur)
with patch("routers.media_assets.get_db", return_value=mock_cm), patch(
"routers.media_assets.get_cursor", return_value=mock_cur
), patch("routers.media_assets.club_ids_for_profile_with_roles", return_value=set()):
r = client.get("/api/media-assets?lifecycle=trash_soft", headers={"X-Auth-Token": "t"})
assert r.status_code == 200
assert r.json()["lifecycle"] == "trash_soft"
list_sql_calls = [c[0][0] for c in mock_cur.execute.call_args_list if c[0] and "FROM media_assets ma" in str(c[0][0])]
assert list_sql_calls and "trash_soft" in list_sql_calls[0]
def test_list_media_assets_invalid_lifecycle_400(client: TestClient) -> None:
app.dependency_overrides[require_auth] = lambda: {"profile_id": 10, "role": "trainer"}
app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
profile_id=10,
global_role="trainer",
effective_club_id=None,
club_ids=frozenset(),
memberships=[],
)
r = client.get("/api/media-assets?lifecycle=invalid", headers={"X-Auth-Token": "t"})
assert r.status_code == 400
def test_patch_media_asset_copyright_mocked(client: TestClient) -> None:
app.dependency_overrides[require_auth] = lambda: {"profile_id": 10, "role": "trainer"}
app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
profile_id=10,
global_role="trainer",
effective_club_id=None,
club_ids=frozenset(),
memberships=[],
)
mock_cur = MagicMock()
mock_cur.fetchone.side_effect = [
{
"id": 9,
"visibility": "private",
"club_id": None,
"uploaded_by_profile_id": 10,
"lifecycle_state": "active",
"copyright_notice": "",
"original_filename": "x.png",
},
{
"id": 9,
"mime_type": "image/png",
"byte_size": 10,
"original_filename": "x.png",
"visibility": "private",
"club_id": None,
"uploaded_by_profile_id": 10,
"lifecycle_state": "active",
"created_at": None,
"sha256": "b" * 64,
"copyright_notice": "© HoldCo",
},
]
mock_cm = _mock_db(mock_cur)
with patch("routers.media_assets.get_db", return_value=mock_cm), patch(
"routers.media_assets.get_cursor", return_value=mock_cur
), patch("media_lifecycle.assert_can_manage_media_asset_lifecycle", lambda *a, **k: None):
r = client.patch(
"/api/media-assets/9",
headers={"X-Auth-Token": "t", "Content-Type": "application/json"},
json={"copyright_notice": "© HoldCo"},
)
assert r.status_code == 200
assert r.json()["copyright_notice"] == "© HoldCo"

View File

@ -0,0 +1,107 @@
"""§4.2: apply_official_exercise_media_rules — Lifecycle, Sichtbarkeit, Copyright."""
from __future__ import annotations
import os
from unittest.mock import MagicMock
import pytest
from fastapi import HTTPException
os.environ.setdefault("SKIP_DB_MIGRATE", "1")
from routers.exercises import apply_official_exercise_media_rules
def _row(
aid: int,
*,
vis: str = "private",
lifecycle: str = "active",
copyright_notice: str | None = "",
name: str = "f.bin",
) -> dict:
return {
"id": aid,
"visibility": vis,
"club_id": 1,
"lifecycle_state": lifecycle,
"copyright_notice": copyright_notice,
"original_filename": name,
}
def test_non_official_visibility_noop() -> None:
cur = MagicMock()
apply_official_exercise_media_rules(
cur,
1,
"private",
promote_attached_media=False,
default_official_media_copyright=None,
)
cur.execute.assert_not_called()
def test_lifecycle_not_active_422() -> None:
cur = MagicMock()
cur.fetchall.return_value = [_row(1, lifecycle="trash_soft", copyright_notice="abc")]
with pytest.raises(HTTPException) as ei:
apply_official_exercise_media_rules(
cur,
1,
"official",
promote_attached_media=False,
default_official_media_copyright=None,
)
assert ei.value.status_code == 422
assert ei.value.detail["code"] == "OFFICIAL_MEDIA_LIFECYCLE"
def test_visibility_promotion_confirm_422() -> None:
cur = MagicMock()
cur.fetchall.return_value = [_row(1, vis="club", copyright_notice="halten")]
with pytest.raises(HTTPException) as ei:
apply_official_exercise_media_rules(
cur,
1,
"official",
promote_attached_media=False,
default_official_media_copyright=None,
)
assert ei.value.status_code == 422
assert ei.value.detail["code"] == "OFFICIAL_MEDIA_CONFIRM_REQUIRED"
assert ei.value.detail["assets_need_visibility_promotion"]
def test_copyright_required_422() -> None:
cur = MagicMock()
cur.fetchall.return_value = [_row(1, vis="official", copyright_notice="")]
with pytest.raises(HTTPException) as ei:
apply_official_exercise_media_rules(
cur,
1,
"official",
promote_attached_media=True,
default_official_media_copyright=None,
)
assert ei.value.status_code == 422
assert ei.value.detail["code"] == "OFFICIAL_MEDIA_CONFIRM_REQUIRED"
assert ei.value.detail["assets_missing_copyright"]
def test_promote_and_fill_copyright_updates() -> None:
cur = MagicMock()
cur.fetchall.return_value = [_row(1, vis="private", copyright_notice=" ")]
apply_official_exercise_media_rules(
cur,
42,
"official",
promote_attached_media=True,
default_official_media_copyright="© Test Holding",
)
assert cur.execute.call_count == 3
sql1 = cur.execute.call_args_list[1][0][0]
sql2 = cur.execute.call_args_list[2][0][0]
assert "visibility = 'official'" in sql1
assert "copyright_notice" in sql2

View File

@ -0,0 +1,74 @@
"""GET/PUT /api/admin/platform-media-storage — Auth-Matrix (kein Live-DB nötig)."""
from __future__ import annotations
import os
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
os.environ.setdefault("SKIP_DB_MIGRATE", "1")
from auth import require_auth
from main import app
@pytest.fixture
def client() -> TestClient:
return TestClient(app)
@pytest.fixture(autouse=True)
def _clear_overrides():
yield
app.dependency_overrides.pop(require_auth, None)
def test_platform_media_storage_get_requires_platform_admin(client: TestClient) -> None:
def _trainer():
return {"profile_id": 1, "role": "trainer"}
app.dependency_overrides[require_auth] = _trainer
r = client.get("/api/admin/platform-media-storage", headers={"X-Auth-Token": "t"})
assert r.status_code == 403
def test_platform_media_storage_put_requires_superadmin(client: TestClient) -> None:
def _admin():
return {"profile_id": 1, "role": "admin"}
app.dependency_overrides[require_auth] = _admin
r = client.put(
"/api/admin/platform-media-storage",
headers={"X-Auth-Token": "t", "Content-Type": "application/json"},
json={"local_relative_root": "foo"},
)
assert r.status_code == 403
@patch("routers.platform_media_storage.get_effective_media_root", return_value=__import__("pathlib").Path("/tmp/media"))
def test_platform_media_storage_get_ok_for_admin(mock_root, client: TestClient) -> None:
def _admin():
return {"profile_id": 1, "role": "admin"}
app.dependency_overrides[require_auth] = _admin
mock_cm = MagicMock()
mock_conn = MagicMock()
mock_cm.__enter__.return_value = mock_conn
mock_cm.__exit__.return_value = False
mock_cur = MagicMock()
mock_cur.fetchone.return_value = {
"storage_backend": "local",
"local_relative_root": "nas",
}
with patch("routers.platform_media_storage.get_db", return_value=mock_cm), patch(
"routers.platform_media_storage.get_cursor", return_value=mock_cur
):
r = client.get("/api/admin/platform-media-storage", headers={"X-Auth-Token": "t"})
assert r.status_code == 200
body = r.json()
assert body["storage_backend"] == "local"
assert body["local_relative_root"] == "nas"

View File

@ -0,0 +1,126 @@
"""
Sicherheits-Regression für Release-Konfiguration (OpenAPI, Health-Ready, Legacy-Routen).
Läuft ohne PostgreSQL, wo möglich (TestClient + Subprocess mit frischem Import).
CI: scripts/security_release_checks.py
"""
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
from unittest.mock import patch
import pytest
from fastapi.testclient import TestClient
os.environ.setdefault("SKIP_DB_MIGRATE", "1")
from auth import require_auth
from main import app
BACKEND_ROOT = Path(__file__).resolve().parents[1]
@pytest.fixture
def client() -> TestClient:
return TestClient(app)
@pytest.fixture(autouse=True)
def _clear_auth_override():
yield
app.dependency_overrides.pop(require_auth, None)
def test_legacy_get_profile_uses_session_only(client: TestClient) -> None:
"""SEC-01: /api/profile darf nur das Session-Profil liefern."""
def auth_user() -> dict:
return {"profile_id": 901, "role": "trainer"}
app.dependency_overrides[require_auth] = auth_user
with patch("routers.profiles.profile_document") as pd_mock:
pd_mock.return_value = {"id": 901, "name": "T"}
r = client.get("/api/profile", headers={"X-Auth-Token": "dummy"})
assert r.status_code == 200
pd_mock.assert_called_once_with("901")
def _run_fresh_import_int(code: str, env: dict) -> subprocess.CompletedProcess:
merged = {**os.environ, **env, "SKIP_DB_MIGRATE": "1"}
return subprocess.run(
[sys.executable, "-c", code],
cwd=str(BACKEND_ROOT),
env=merged,
capture_output=True,
text=True,
check=False,
)
@pytest.mark.parametrize(
"extra_env, expect_openapi",
[
({"ENVIRONMENT": "development"}, True),
({"ENVIRONMENT": "production"}, False),
({"ENVIRONMENT": "production", "PUBLIC_OPENAPI": "true"}, True),
],
)
def test_openapi_urls_match_environment(extra_env: dict, expect_openapi: bool) -> None:
snippet = f"""
from main import app
assert (app.openapi_url is not None) == {expect_openapi}
assert (app.docs_url is not None) == {expect_openapi}
"""
proc = _run_fresh_import_int(snippet, extra_env)
assert proc.returncode == 0, proc.stderr + proc.stdout
def test_health_ready_minimal_body_in_production() -> None:
snippet = """
from fastapi.testclient import TestClient
from main import app
c = TestClient(app)
r = c.get("/api/health/ready")
r.raise_for_status()
j = r.json()
assert "tables" not in j
assert "detail" not in j
assert "schema_migrations_count" not in j
assert "status" in j and "database" in j and "schema_complete" in j
"""
proc = _run_fresh_import_int(
snippet,
{"ENVIRONMENT": "production"},
)
assert proc.returncode == 0, proc.stderr + proc.stdout
def test_health_ready_full_detail_when_flag_in_production() -> None:
snippet = """
from fastapi.testclient import TestClient
from main import app
c = TestClient(app)
r = c.get("/api/health/ready")
r.raise_for_status()
j = r.json()
assert "tables" in j
assert "schema_migrations_count" in j
"""
proc = _run_fresh_import_int(
snippet,
{"ENVIRONMENT": "production", "HEALTH_READY_PUBLIC_DETAIL": "1"},
)
assert proc.returncode == 0, proc.stderr + proc.stdout
def test_api_attachments_x_content_type_options_nosniff(client: TestClient) -> None:
"""Globales Middleware: keine MIME-Sniffing-Heuristik für API/Health."""
r = client.get("/health")
assert r.status_code == 200
assert r.headers.get("x-content-type-options") == "nosniff"
r2 = client.get("/api/version")
assert r2.status_code == 200
assert r2.headers.get("x-content-type-options") == "nosniff"

View File

@ -1,8 +1,8 @@
# Shinkan Jinkendo Version Information # Shinkan Jinkendo Version Information
APP_VERSION = "0.8.40" APP_VERSION = "0.8.49"
BUILD_DATE = "2026-05-06" BUILD_DATE = "2026-05-07"
DB_SCHEMA_VERSION = "20260506043" DB_SCHEMA_VERSION = "20260507045"
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)
@ -12,10 +12,12 @@ MODULE_VERSIONS = {
"club_memberships": "1.0.1", # Depends(get_tenant_context) "club_memberships": "1.0.1", # Depends(get_tenant_context)
"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)
"media_assets": "1.4.0", # Manager: RBAC trash_soft Trainer nur privat; purge nur Superadmin; Superadmin force/hard-delete; Liste + permissions + JOINs; bulk lifecycle/patch
"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.10.0", # GET /exercises: focus_area_must_include/exclude_ids, focus_only_without_focus_areas; UI +/- Fokusregeln "exercises": "2.16.0", # §4.2 official: angehängte media_assets + Copyright (PUT + bulk-metadata)
"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
@ -27,6 +29,79 @@ MODULE_VERSIONS = {
} }
CHANGELOG = [ CHANGELOG = [
{
"version": "0.8.49",
"date": "2026-05-07",
"changes": [
"Medienbibliothek UI: Kacheln/Liste, Modal Bearbeiten, Video-First-Frame-Thumbs, Mobile/Safe-Area, Bulk; API: permissions pro Zeile, Uploader/Verein für Admin, PATCH Sichtbarkeit+Bezeichner, trash_soft nur Trainer-Eigenes-Privat / Vereinsorga / Plattform; purge nur Superadmin; superadmin_force_lifecycle + hard_delete; bulk-lifecycle, bulk-patch",
],
},
{
"version": "0.8.48",
"date": "2026-05-07",
"changes": [
"Medienbibliothek: GET /api/media-assets mit lifecycle (active|trash_soft|trash_hidden|all), copyright_notice in Liste; PATCH /api/media-assets/{id} (Copyright); GET …/file für Papierkorb wenn Lifecycle-Recht; Frontend /media + Admin-Nav + Link Übungsformular",
],
},
{
"version": "0.8.47",
"date": "2026-05-07",
"changes": [
"Übung „offiziell“ (§4.2): angehängte Datei-Assets müssen aktiv sein; Sichtbarkeit/Copyright per Bestätigung anheben; PUT /api/exercises/{id} + PATCH bulk-metadata: Felder promote_attached_media_for_official, default_official_media_copyright; Frontend Bestätigungsdialog",
],
},
{
"version": "0.8.46",
"date": "2026-05-07",
"changes": [
"Übung Medien-Upload: bei 409-Konflikt (Papierkorb-Dedupe) logger.warning mit exercise_id, profile_id, media_asset_id, lifecycle, visibility, club_id, sha256_prefix, Dateinamen",
"ExerciseFormPage: erklärende Medien-Hinweistexte in GUI entfernt (Kurzbeschreibung + Archiv-Dialog)",
],
},
{
"version": "0.8.45",
"date": "2026-05-07",
"changes": [
"Upload Übungsmedien: gleicher Inhalt (SHA-256) wie Papierkorb-Asset → 409 MEDIA_ASSET_IN_TRASH statt DB-Fehler; Lifecycle action reactivate (trash_soft/hidden → active)",
"Frontend: Dialog Reaktivieren + Verknüpfen; uploadExerciseMedia wertet strukturiertes 409 aus",
],
},
{
"version": "0.8.44",
"date": "2026-05-07",
"changes": [
"DELETE /api/exercises/{id}/media/{mid}: entfernt nur exercise_media; keine Datei-/media_assets-Löschung; Response orphan_media_asset_id wenn letzte Referenz",
"Übung bearbeiten: Video-Kachel (Erstframe), Dateiname; Papierkorb-Schalter entfernt; „Aus Übung entfernen“ + optional Papierkorb bei Waise",
],
},
{
"version": "0.8.43",
"date": "2026-05-07",
"changes": [
"Medienarchiv: GET /api/media-assets (Suche, nur aktive Assets, Bibliotheks-Sichtbarkeit); GET /api/media-assets/{id}/file (Thumbnails/Vorschau, ssetoken)",
"Übungen: POST /api/exercises/{id}/media/from-asset — bestehendes Archiv-Medium verknüpfen ohne Upload-Duplikat",
"Frontend Übung bearbeiten: „Aus Archiv verknüpfen…“, Medienvorschau-Modal, Kachel-Thumbnails in der Medienliste",
],
},
{
"version": "0.8.42",
"date": "2026-05-07",
"changes": [
"Medien-Papierkorb: POST /api/media-assets/{id}/lifecycle (trash_soft, trash_hidden, recover, purge); Retention-Job scripts/media_retention_job.py",
"Übungen: GET-Detail inkl. asset_lifecycle_state; Bearbeitungsrechte Erweiterung (Vereinsplanung); Frontend Übung bearbeiten: Reihenfolge, Papierkorb-Actions; Detail/Katalog: trash_hidden ausgeblendet, Hinweis trash_soft",
"Fix: ExerciseDetailPage zeigt „Hinweise für Trainer“ wieder an",
],
},
{
"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", "version": "0.8.40",
"date": "2026-05-06", "date": "2026-05-06",

View File

@ -8,8 +8,9 @@ services:
POSTGRES_PASSWORD: ${DB_PASSWORD} POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes: volumes:
- shinkan-db-data:/var/lib/postgresql/data - shinkan-db-data:/var/lib/postgresql/data
# Nur localhost: DB nicht im LAN exponieren (Beta/Prod). Entferne 127.0.0.1: nur wenn du bewusst remote willst.
ports: ports:
- "5434:5432" - "127.0.0.1:5434:5432"
restart: unless-stopped restart: unless-stopped
networks: networks:
- shinkan-network - shinkan-network

View File

@ -4,6 +4,11 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# Docker-Embedded DNS: Hostname »backend« bei Container-Neustarts neu auflösen # Docker-Embedded DNS: Hostname »backend« bei Container-Neustarts neu auflösen
# verringert sporadische 502, wenn sich nur die Backend-Container-IP geändert hat. # verringert sporadische 502, wenn sich nur die Backend-Container-IP geändert hat.
resolver 127.0.0.11 valid=10s ipv6=off; resolver 127.0.0.11 valid=10s ipv6=off;
@ -26,6 +31,8 @@ server {
} }
location ^~ /media/ { location ^~ /media/ {
# Auslieferung Übungsdateien erfolgt geschützt über /api/exercises/.../media/.../file (?ssetoken).
# Optional: Backend mit ALLOW_PUBLIC_MEDIA_STATIC=1 wieder /media/ ohne Auth.
set $docker_backend_svc backend; set $docker_backend_svc backend;
proxy_pass http://$docker_backend_svc:8000$request_uri; proxy_pass http://$docker_backend_svc:8000$request_uri;
proxy_http_version 1.1; proxy_http_version 1.1;
@ -42,8 +49,10 @@ server {
proxy_set_header Host $host; proxy_set_header Host $host;
} }
# SPA routing - serve index.html for all routes
location / { location / {
# Document-CSP für SPA/PWA React nutzt häufig inline-styles; Mediendateien & API sind same-origin (Proxy).
# Bei separater API-Origin: connect-src hier erweitern oder nginx-Envsubst nutzen.
add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; connect-src 'self'; media-src 'self' blob: data:; worker-src 'self' blob:; manifest-src 'self';" always;
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }

View File

@ -32,6 +32,7 @@ import AdminMaturityModelsPage from './pages/AdminMaturityModelsPage'
import TrainerContextsPage from './pages/TrainerContextsPage' import TrainerContextsPage from './pages/TrainerContextsPage'
import MediaWikiImportPage from './pages/MediaWikiImportPage' import MediaWikiImportPage from './pages/MediaWikiImportPage'
import AdminUsersPage from './pages/AdminUsersPage' import AdminUsersPage from './pages/AdminUsersPage'
import MediaLibraryPage from './pages/MediaLibraryPage'
import ActiveClubSwitcher from './components/ActiveClubSwitcher' import ActiveClubSwitcher from './components/ActiveClubSwitcher'
import './app.css' import './app.css'
@ -158,6 +159,7 @@ function AppRoutes() {
<Route path="profile" element={<Navigate to="/settings" replace />} /> <Route path="profile" element={<Navigate to="/settings" replace />} />
<Route path="settings" element={<AccountSettingsPage />} /> <Route path="settings" element={<AccountSettingsPage />} />
<Route path="settings/system" element={<SettingsSystemInfoPage />} /> <Route path="settings/system" element={<SettingsSystemInfoPage />} />
<Route path="media" element={<MediaLibraryPage />} />
<Route path="exercises"> <Route path="exercises">
<Route index element={<ExercisesListPage />} /> <Route index element={<ExercisesListPage />} />
<Route path="new" element={<ExerciseFormPage />} /> <Route path="new" element={<ExerciseFormPage />} />

View File

@ -5454,3 +5454,516 @@ a.analysis-split__nav-item {
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
padding-bottom: 4px; padding-bottom: 4px;
} }
/* ——— Medienbibliothek (/media) ——— */
.media-library {
padding: max(12px, env(safe-area-inset-top, 0px)) max(16px, env(safe-area-inset-right, 0px))
max(24px, env(safe-area-inset-bottom, 0px) + 8px) max(16px, env(safe-area-inset-left, 0px));
}
.media-library__container {
max-width: 1200px;
margin: 0 auto;
}
.media-library__hero {
margin-bottom: 1.25rem;
}
.media-library__hero-row {
display: flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: space-between;
gap: 0.75rem;
}
.media-library__title {
font-size: clamp(1.35rem, 2.5vw, 1.65rem);
font-weight: 700;
letter-spacing: -0.02em;
margin: 0;
}
.media-library__hero-links {
display: flex;
gap: 1rem;
font-size: 0.9rem;
}
.media-library__hero-links a {
color: var(--accent-dark);
}
.media-library__intro {
margin: 0.5rem 0 0;
font-size: 0.9rem;
color: var(--text2);
line-height: 1.5;
max-width: 46rem;
}
.media-library__toolbar {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 12px 14px;
margin-bottom: 1rem;
display: flex;
flex-direction: column;
gap: 10px;
}
.media-library__toolbar-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.media-library__search {
flex: 1 1 200px;
min-width: 0;
}
.media-library__select {
min-width: 160px;
}
.media-library__view-toggle {
display: inline-flex;
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
}
.media-library__toolbar-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
font-size: 0.875rem;
}
.media-library__check-all {
display: inline-flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.media-library__refresh {
margin-left: auto;
}
.media-library__icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 44px;
min-height: 44px;
padding: 0 10px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--surface);
color: var(--text1);
cursor: pointer;
}
.media-library__icon-btn--on {
background: var(--accent-light);
border-color: var(--accent);
color: var(--accent-dark);
}
.media-library__err {
color: var(--danger);
margin-bottom: 1rem;
}
.media-library__empty,
.media-library__hint {
color: var(--text2);
font-size: 0.9rem;
}
.media-library__spinner {
margin: 2rem auto;
}
.media-library__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(158px, 1fr));
gap: 14px;
}
@media (min-width: 768px) {
.media-library__grid {
grid-template-columns: repeat(auto-fill, minmax(176px, 1fr));
gap: 16px;
}
}
.media-library__card {
position: relative;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 14px;
overflow: hidden;
display: flex;
flex-direction: column;
min-width: 0;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}
.media-library__card-check {
position: absolute;
top: 8px;
left: 8px;
z-index: 3;
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
background: rgba(255, 255, 255, 0.92);
border-radius: 8px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
}
.media-library__card-check input {
width: 18px;
height: 18px;
cursor: pointer;
}
.media-library__card-menu {
position: absolute;
top: 8px;
right: 8px;
z-index: 3;
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 10px;
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
cursor: pointer;
color: var(--text1);
}
.media-library__card-thumb-hit {
display: block;
width: 100%;
margin: 0;
padding: 0;
border: none;
background: transparent;
cursor: zoom-in;
text-align: left;
font: inherit;
color: inherit;
-webkit-tap-highlight-color: transparent;
}
.media-library__card-thumb-hit:active {
opacity: 0.96;
}
.media-library__card-thumb-wrap {
aspect-ratio: 1;
background: var(--surface2);
position: relative;
}
.media-library__thumb-img,
.media-library__thumb-video {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.media-library__thumb-ph {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
color: var(--text3);
font-weight: 600;
}
.media-library__card-footer {
padding: 10px 10px 12px;
min-height: 0;
}
.media-library__card-name {
font-size: 0.8125rem;
font-weight: 600;
line-height: 1.35;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.media-library__card-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 6px;
font-size: 0.7rem;
color: var(--text2);
}
.media-library__card-tags span {
background: var(--surface2);
padding: 2px 6px;
border-radius: 6px;
}
.media-library__table-wrap {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--surface);
}
.media-library__table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
.media-library__table th,
.media-library__table td {
padding: 10px 12px;
text-align: left;
border-bottom: 1px solid var(--border);
vertical-align: middle;
}
.media-library__table th {
font-weight: 600;
color: var(--text2);
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.media-library__th-check {
width: 40px;
}
.media-library__th-act {
width: 52px;
}
.media-library__td-thumb {
width: 72px;
}
.media-library__table-thumb {
width: 56px;
height: 56px;
border-radius: 8px;
overflow: hidden;
background: var(--surface2);
}
.media-library__table-thumb-hit {
display: block;
margin: 0;
padding: 0;
border: none;
background: transparent;
cursor: zoom-in;
font: inherit;
border-radius: 8px;
-webkit-tap-highlight-color: transparent;
}
.media-library__table-thumb-hit:focus-visible {
outline: 2px solid var(--accent, #3b82f6);
outline-offset: 2px;
}
.media-library__table-thumb .media-library__thumb-img,
.media-library__table-thumb .media-library__thumb-video {
width: 56px;
height: 56px;
}
.media-library__td-name {
font-weight: 500;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
}
.media-library__td-sub {
font-size: 0.8rem;
color: var(--text2);
max-width: 160px;
}
@media (max-width: 639px) {
.media-library__table .media-library__td-sub,
.media-library__table th:nth-child(5),
.media-library__table td:nth-child(5) {
display: none;
}
}
.media-library__overlay {
position: fixed;
inset: 0;
z-index: 200;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: flex-end;
justify-content: center;
padding: env(safe-area-inset-top, 0px) 0 0;
}
@media (min-width: 640px) {
.media-library__overlay {
align-items: center;
padding: 24px 16px;
}
}
.media-library__modal {
width: 100%;
max-width: 480px;
max-height: min(92vh, 720px);
overflow-y: auto;
background: var(--surface);
border-radius: 16px 16px 0 0;
box-shadow: 0 -8px 40px rgba(0, 0, 0, 0.2);
}
@media (min-width: 640px) {
.media-library__modal {
border-radius: 16px;
max-height: 90vh;
}
}
.media-library__modal--wide {
max-width: 520px;
}
.media-library__modal-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 18px;
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
background: var(--surface);
z-index: 1;
}
.media-library__modal-head h2 {
margin: 0;
font-size: 1.1rem;
}
.media-library__modal-body {
padding: 16px 18px 24px;
display: flex;
flex-direction: column;
gap: 12px;
}
.media-library__check {
display: flex;
align-items: center;
gap: 10px;
font-size: 0.9rem;
cursor: pointer;
}
.media-library__meta-block {
display: grid;
gap: 10px;
padding: 12px;
background: var(--surface2);
border-radius: 10px;
font-size: 0.85rem;
}
.media-library__meta-k {
display: block;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text3);
margin-bottom: 2px;
}
.media-library__meta-v {
color: var(--text1);
word-break: break-word;
}
.media-library__meta-v.mono {
font-family: ui-monospace, monospace;
font-size: 0.78rem;
}
.media-library__modal-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 8px;
}
.media-library__lc-block {
margin-top: 8px;
padding-top: 16px;
border-top: 1px solid var(--border);
}
.media-library__lc-block--danger {
background: rgba(216, 90, 48, 0.06);
margin: 8px -18px -24px;
padding: 16px 18px 20px;
border-radius: 0 0 16px 16px;
}
.media-library__lc-title {
font-size: 0.8rem;
font-weight: 600;
color: var(--text2);
margin-bottom: 10px;
}
.media-library__lc-btns {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.media-library__row {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
margin-bottom: 10px;
}
.media-library__row .form-input {
flex: 1 1 160px;
min-width: 0;
}
/* Vorschau-Modal (Vollbild nah) */
.media-library__overlay--preview {
background: rgba(0, 0, 0, 0.72);
z-index: 210;
}
.media-library__modal--preview {
max-width: min(92vw, 960px);
width: 100%;
max-height: min(94vh, 900px);
display: flex;
flex-direction: column;
overflow: hidden;
}
.media-library__modal--preview .media-library__modal-head {
flex-shrink: 0;
}
.media-library__preview-title {
flex: 1;
min-width: 0;
font-size: 1rem;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-right: 8px;
}
.media-library__modal--preview .media-library__modal-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.media-library__preview-head-actions {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.media-library__preview-body {
flex: 1;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 12px 16px 20px;
background: var(--surface2);
}
.media-library__preview-img {
max-width: 100%;
max-height: min(78vh, 720px);
width: auto;
height: auto;
object-fit: contain;
border-radius: 8px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.12);
}
.media-library__preview-video {
width: 100%;
max-height: min(78vh, 720px);
border-radius: 8px;
background: #000;
}
.media-library__preview-fallback {
text-align: center;
padding: 24px 16px;
max-width: 360px;
}
.media-library__preview-fallback .btn {
margin-top: 12px;
}

View File

@ -1,5 +1,5 @@
import { NavLink } from 'react-router-dom' import { NavLink } from 'react-router-dom'
import { TreePine, FolderTree, Download, Grid3x3, Users } from 'lucide-react' import { TreePine, FolderTree, Download, Grid3x3, Users, Images } from 'lucide-react'
/** /**
* Admin-Seiten-Navigation (horizontal) * Admin-Seiten-Navigation (horizontal)
@ -11,6 +11,7 @@ export default function AdminPageNav() {
{ to: '/admin/users', label: 'Nutzer', icon: Users }, { to: '/admin/users', label: 'Nutzer', icon: Users },
{ to: '/admin/maturity-models', label: 'Fähigkeitsmatrix', icon: Grid3x3 }, { to: '/admin/maturity-models', label: 'Fähigkeitsmatrix', icon: Grid3x3 },
{ to: '/admin/catalogs', label: 'Kataloge', icon: FolderTree }, { to: '/admin/catalogs', label: 'Kataloge', icon: FolderTree },
{ to: '/media', label: 'Medien', icon: Images },
{ to: '/admin/mediawiki-import', label: 'Wiki-Import', icon: Download } { to: '/admin/mediawiki-import', label: 'Wiki-Import', icon: Download }
] ]

View File

@ -4,15 +4,7 @@
import React from 'react' import React from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { sanitizeTrainerHtml } from '../utils/htmlUtils' import { sanitizeTrainerHtml } from '../utils/htmlUtils'
import { resolveExerciseMediaFileUrl } from '../utils/exerciseMediaUrl'
const API_BASE = (import.meta.env.VITE_API_URL || '').replace(/\/$/, '')
function resolveMediaUrl(filePath) {
if (!filePath) return null
if (filePath.startsWith('http://') || filePath.startsWith('https://')) return filePath
const p = filePath.startsWith('/') ? filePath : `/${filePath}`
return `${API_BASE}${p}`
}
function HtmlBlock({ html, className = '' }) { function HtmlBlock({ html, className = '' }) {
if (!html || !String(html).trim()) return null if (!html || !String(html).trim()) return null
@ -22,7 +14,7 @@ function HtmlBlock({ html, className = '' }) {
) )
} }
function MediaBlock({ media }) { function MediaBlock({ media, exerciseId }) {
if (media.embed_url) { if (media.embed_url) {
return ( return (
<div style={{ marginTop: '0.5rem' }}> <div style={{ marginTop: '0.5rem' }}>
@ -37,7 +29,7 @@ function MediaBlock({ media }) {
</div> </div>
) )
} }
const src = resolveMediaUrl(media.file_path) const src = resolveExerciseMediaFileUrl(exerciseId, media)
if (!src) return null if (!src) return null
if (media.media_type === 'image' || (media.mime_type && media.mime_type.startsWith('image/'))) { if (media.media_type === 'image' || (media.mime_type && media.mime_type.startsWith('image/'))) {
return ( return (
@ -121,6 +113,10 @@ export default function ExerciseFullContent({ exercise, loading, error, exercise
if (!exercise) return null if (!exercise) return null
const meta = metaParts(exercise) const meta = metaParts(exercise)
const visibleMedia = (exercise.media || []).filter((m) => {
const lc = String(m.asset_lifecycle_state || 'active').toLowerCase()
return lc !== 'trash_hidden'
})
return ( return (
<div className="exercise-coach-catalog" style={{ fontSize: '0.93rem', lineHeight: 1.5 }}> <div className="exercise-coach-catalog" style={{ fontSize: '0.93rem', lineHeight: 1.5 }}>
@ -169,16 +165,21 @@ export default function ExerciseFullContent({ exercise, loading, error, exercise
<HtmlBlock html={exercise.execution} /> <HtmlBlock html={exercise.execution} />
</section> </section>
)} )}
{(exercise.media || []).length > 0 && ( {visibleMedia.length > 0 && (
<section className="card" style={{ marginTop: '10px', padding: '12px 14px' }}> <section className="card" style={{ marginTop: '10px', padding: '12px 14px' }}>
<h3 style={{ fontSize: '0.78rem', textTransform: 'uppercase', color: 'var(--text3)', marginBottom: '8px' }}> <h3 style={{ fontSize: '0.78rem', textTransform: 'uppercase', color: 'var(--text3)', marginBottom: '8px' }}>
Medien Medien
</h3> </h3>
{exercise.media.map((m) => ( {visibleMedia.map((m) => (
<div key={m.id} style={{ marginBottom: '12px' }}> <div key={m.id} style={{ marginBottom: '12px' }}>
<strong style={{ fontSize: '0.9rem' }}>{m.title || m.original_filename || m.media_type}</strong> <strong style={{ fontSize: '0.9rem' }}>{m.title || m.original_filename || m.media_type}</strong>
{String(m.asset_lifecycle_state || 'active').toLowerCase() === 'trash_soft' && (
<p style={{ fontSize: '0.75rem', color: 'var(--danger)', margin: '4px 0 0' }}>
Hinweis: Dieses Medium ist im Papierkorb und steht künftig nicht mehr zur Verfügung.
</p>
)}
{m.description && <p style={{ color: 'var(--text2)', fontSize: '0.82rem', marginTop: '4px' }}>{m.description}</p>} {m.description && <p style={{ color: 'var(--text2)', fontSize: '0.82rem', marginTop: '4px' }}>{m.description}</p>}
<MediaBlock media={m} /> <MediaBlock media={m} exerciseId={exercise.id ?? exerciseId} />
</div> </div>
))} ))}
</section> </section>

View File

@ -1,58 +1,70 @@
import { createContext, useContext, useState, useEffect } from 'react' import { createContext, useContext, useState, useEffect } from 'react'
import { useAuth } from './AuthContext' import { useAuth } from './AuthContext'
import { getCurrentProfile, listProfiles } from '../utils/api'
const ProfileContext = createContext(null) const ProfileContext = createContext(null)
export function ProfileProvider({ children }) { export function ProfileProvider({ children }) {
const { session } = useAuth() const { user, isAuthenticated } = useAuth()
const [profiles, setProfiles] = useState([]) const [profiles, setProfiles] = useState([])
const [activeProfile, setActiveProfileState] = useState(null) const [activeProfile, setActiveProfileState] = useState(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const loadProfiles = async () => { const loadProfiles = async (authUser) => {
try { try {
const token = localStorage.getItem('bodytrack_token') || '' if (!authUser?.id) return []
const res = await fetch('/api/profiles', { const admin = authUser.role === 'admin' || authUser.role === 'superadmin'
headers: { 'X-Auth-Token': token } if (admin) {
}) try {
if (!res.ok) return [] return await listProfiles()
return await res.json() } catch {
} catch(e) { return [] } const me = await getCurrentProfile()
return me ? [me] : []
}
}
const me = await getCurrentProfile()
return me ? [me] : []
} catch {
return []
}
} }
// Re-load whenever session changes (login/logout/switch)
useEffect(() => { useEffect(() => {
if (!session) { if (!isAuthenticated || !user?.id) {
setActiveProfileState(null) setActiveProfileState(null)
setProfiles([]) setProfiles([])
setLoading(false) setLoading(false)
return return
} }
setLoading(true) setLoading(true)
loadProfiles().then(data => { loadProfiles(user).then((data) => {
setProfiles(data) const rows = Array.isArray(data) ? data : []
// Always use the profile_id from the session token not localStorage setProfiles(rows)
const match = data.find(p => p.id === session.profile_id) const uid = user.id
setActiveProfileState(match || data[0] || null) const match = rows.find((p) => String(p.id) === String(uid))
setActiveProfileState(match || rows[0] || null)
setLoading(false) setLoading(false)
}) })
}, [session?.profile_id]) // re-runs when profile changes }, [isAuthenticated, user?.id, user?.role])
const setActiveProfile = (profile) => { const setActiveProfile = (profile) => {
setActiveProfileState(profile) setActiveProfileState(profile)
localStorage.setItem('bodytrack_active_profile', profile.id) localStorage.setItem('shinkan_active_profile', String(profile.id))
} }
const refreshProfiles = () => loadProfiles().then(data => { const refreshProfiles = () =>
setProfiles(data) loadProfiles(user).then((data) => {
if (activeProfile) { setProfiles(Array.isArray(data) ? data : [])
const updated = data.find(p => p.id === activeProfile.id) if (activeProfile) {
if (updated) setActiveProfileState(updated) const updated = data.find((p) => String(p.id) === String(activeProfile.id))
} if (updated) setActiveProfileState(updated)
}) }
})
return ( return (
<ProfileContext.Provider value={{ profiles, activeProfile, setActiveProfile, refreshProfiles, loading }}> <ProfileContext.Provider
value={{ profiles, activeProfile, setActiveProfile, refreshProfiles, loading }}
>
{children} {children}
</ProfileContext.Provider> </ProfileContext.Provider>
) )

View File

@ -92,7 +92,7 @@ export default function AdminCatalogsPage() {
} else if (activeTab === 'trainer-assignments') { } else if (activeTab === 'trainer-assignments') {
const [assignments, profs, areas] = await Promise.all([ const [assignments, profs, areas] = await Promise.all([
api.listTrainerFocusAreas(), api.listTrainerFocusAreas(),
fetch('/api/profiles').then(r => r.json()), api.listProfiles(),
api.listFocusAreas() api.listFocusAreas()
]) ])
setTrainerAssignments(assignments) setTrainerAssignments(assignments)

View File

@ -1,18 +1,10 @@
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom' import { Link, useNavigate, useParams } from 'react-router-dom'
import api from '../utils/api' import api from '../utils/api'
import { resolveExerciseMediaFileUrl } from '../utils/exerciseMediaUrl'
import { sanitizeTrainerHtml } from '../utils/htmlUtils' import { sanitizeTrainerHtml } from '../utils/htmlUtils'
import { formatSkillLevelSlug } from '../constants/skillLevels' import { formatSkillLevelSlug } from '../constants/skillLevels'
const API_BASE = (import.meta.env.VITE_API_URL || '').replace(/\/$/, '')
function resolveMediaUrl(filePath) {
if (!filePath) return null
if (filePath.startsWith('http://') || filePath.startsWith('https://')) return filePath
const p = filePath.startsWith('/') ? filePath : `/${filePath}`
return `${API_BASE}${p}`
}
function HtmlBlock({ html, className = '' }) { function HtmlBlock({ html, className = '' }) {
if (!html || !String(html).trim()) return null if (!html || !String(html).trim()) return null
const safe = sanitizeTrainerHtml(html) const safe = sanitizeTrainerHtml(html)
@ -24,7 +16,7 @@ function HtmlBlock({ html, className = '' }) {
) )
} }
function MediaBlock({ media }) { function MediaBlock({ media, exerciseId }) {
if (media.embed_url) { if (media.embed_url) {
return ( return (
<div style={{ marginTop: '0.5rem' }}> <div style={{ marginTop: '0.5rem' }}>
@ -39,7 +31,7 @@ function MediaBlock({ media }) {
</div> </div>
) )
} }
const src = resolveMediaUrl(media.file_path) const src = resolveExerciseMediaFileUrl(exerciseId, media)
if (!src) return null if (!src) return null
if (media.media_type === 'image' || (media.mime_type && media.mime_type.startsWith('image/'))) { if (media.media_type === 'image' || (media.mime_type && media.mime_type.startsWith('image/'))) {
return ( return (
@ -159,6 +151,10 @@ function ExerciseDetailPage() {
if (!exercise) return null if (!exercise) return null
const meta = metaParts(exercise) const meta = metaParts(exercise)
const visibleMedia = (exercise.media || []).filter((m) => {
const lc = String(m.asset_lifecycle_state || 'active').toLowerCase()
return lc !== 'trash_hidden'
})
return ( return (
<div className="exercise-detail-shell" style={{ padding: '12px 12px 24px' }}> <div className="exercise-detail-shell" style={{ padding: '12px 12px 24px' }}>
@ -219,14 +215,19 @@ function ExerciseDetailPage() {
</section> </section>
)} )}
{(exercise.media || []).length > 0 && ( {visibleMedia.length > 0 && (
<section className="card exercise-detail-section"> <section className="card exercise-detail-section">
<h2>Medien</h2> <h2>Medien</h2>
{exercise.media.map((m) => ( {visibleMedia.map((m) => (
<div key={m.id} style={{ marginBottom: '1.25rem' }}> <div key={m.id} style={{ marginBottom: '1.25rem' }}>
<strong style={{ fontSize: '15px' }}>{m.title || m.original_filename || m.media_type}</strong> <strong style={{ fontSize: '15px' }}>{m.title || m.original_filename || m.media_type}</strong>
{String(m.asset_lifecycle_state || 'active').toLowerCase() === 'trash_soft' && (
<p style={{ fontSize: '12px', color: 'var(--danger)', margin: '6px 0 0' }}>
Hinweis: Dieses Medium ist im Papierkorb und steht künftig nicht mehr zur Verfügung.
</p>
)}
{m.description && <p style={{ color: 'var(--text2)', fontSize: '0.9rem' }}>{m.description}</p>} {m.description && <p style={{ color: 'var(--text2)', fontSize: '0.9rem' }}>{m.description}</p>}
<MediaBlock media={m} /> <MediaBlock media={m} exerciseId={exercise.id} />
</div> </div>
))} ))}
</section> </section>

View File

@ -1,10 +1,75 @@
import React, { useEffect, useState, useRef } from 'react' import React, { useEffect, useState, useRef, useMemo } from 'react'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams, Link } from 'react-router-dom'
import api, { buildExerciseApiPayload } from '../utils/api' import api, { buildExerciseApiPayload } from '../utils/api'
import { resolveExerciseMediaFileUrl, resolveMediaAssetFileUrl } from '../utils/exerciseMediaUrl'
import RichTextEditor from '../components/RichTextEditor' import RichTextEditor from '../components/RichTextEditor'
import ExerciseProgressionGraphPanel from '../components/ExerciseProgressionGraphPanel' import ExerciseProgressionGraphPanel from '../components/ExerciseProgressionGraphPanel'
import { SKILL_LEVEL_OPTIONS, normalizeSkillLevelSlug } from '../constants/skillLevels' import { SKILL_LEVEL_OPTIONS, normalizeSkillLevelSlug } from '../constants/skillLevels'
/** Kachelvorschau: Video nutzt ersten Frame (metadata), Bild = img. */
function ExerciseMediaThumbTile({ exerciseId, media, onOpenPreview }) {
const src = !media.embed_url ? resolveExerciseMediaFileUrl(exerciseId, media) : null
const commonStyle = {
width: '100%',
height: '100%',
objectFit: 'cover',
}
return (
<div
role="button"
tabIndex={0}
title="Vorschau"
onClick={() => onOpenPreview(media)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onOpenPreview(media)
}
}}
style={{
width: 72,
height: 72,
flexShrink: 0,
borderRadius: '8px',
overflow: 'hidden',
background: 'var(--surface2, rgba(127,127,127,0.12))',
border: '1px solid var(--border)',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{media.embed_url ? (
<span style={{ fontSize: '11px', padding: '4px', color: 'var(--text2)', textAlign: 'center' }}>
{media.embed_platform || 'Embed'}
</span>
) : (media.mime_type?.startsWith('image/') || media.media_type === 'image') && src ? (
<img alt="" src={src} style={commonStyle} />
) : (media.mime_type?.startsWith('video/') || media.media_type === 'video') && src ? (
<video
src={src}
muted
playsInline
preload="metadata"
style={{ ...commonStyle, pointerEvents: 'none' }}
onLoadedMetadata={(e) => {
try {
const el = e.currentTarget
const d = el.duration
el.currentTime = Number.isFinite(d) && d > 0 ? Math.min(0.05, d * 0.01) : 0.05
} catch (_) {
/* ignore */
}
}}
/>
) : (
<span style={{ fontSize: '11px', color: 'var(--text2)' }}>Datei</span>
)}
</div>
)
}
const INTENSITY_OPTIONS = [ const INTENSITY_OPTIONS = [
{ value: '', label: '—' }, { value: '', label: '—' },
{ value: 'niedrig', label: 'niedrig' }, { value: 'niedrig', label: 'niedrig' },
@ -367,6 +432,50 @@ function ExerciseFormPage() {
const [mediaContext, setMediaContext] = useState('ablauf') const [mediaContext, setMediaContext] = useState('ablauf')
const [embedUrl, setEmbedUrl] = useState('') const [embedUrl, setEmbedUrl] = useState('')
const [embedTitle, setEmbedTitle] = useState('') const [embedTitle, setEmbedTitle] = useState('')
const [mediaFields, setMediaFields] = useState({})
const [mediaSavingId, setMediaSavingId] = useState(null)
const [archiveOpen, setArchiveOpen] = useState(false)
const [archiveQ, setArchiveQ] = useState('')
const [archiveCtx, setArchiveCtx] = useState('ablauf')
const [archiveLoading, setArchiveLoading] = useState(false)
const [archiveItems, setArchiveItems] = useState([])
const [archiveError, setArchiveError] = useState(null)
const [mediaPreview, setMediaPreview] = useState(null)
useEffect(() => {
const next = {}
for (const m of mediaList) {
next[m.id] = {
title: m.title || '',
context: m.context || 'ablauf',
}
}
setMediaFields(next)
}, [mediaList])
useEffect(() => {
if (!archiveOpen) return undefined
let cancelled = false
const t = setTimeout(async () => {
setArchiveLoading(true)
setArchiveError(null)
try {
const res = await api.listMediaAssets({
q: archiveQ.trim() || undefined,
limit: 40,
})
if (!cancelled) setArchiveItems(res.items || [])
} catch (e) {
if (!cancelled) setArchiveError(e.message || String(e))
} finally {
if (!cancelled) setArchiveLoading(false)
}
}, 280)
return () => {
cancelled = true
clearTimeout(t)
}
}, [archiveOpen, archiveQ])
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
@ -523,7 +632,54 @@ function ExerciseFormPage() {
setSaving(true) setSaving(true)
try { try {
if (isEdit) { if (isEdit) {
await api.updateExercise(exerciseId, payload) const saveOnce = (extras = {}) =>
api.updateExercise(exerciseId, buildExerciseApiPayload(payloadBase, extras))
try {
await saveOnce()
} catch (firstErr) {
if (
firstErr.status === 422 &&
firstErr.code === 'OFFICIAL_MEDIA_LIFECYCLE' &&
firstErr.payload?.media_assets
) {
alert(
'Speichern nicht möglich: mindestens ein verknüpftes Medium ist nicht aktiv (Papierkorb). ' +
'Bitte Medium wiederherstellen oder aus der Übung entfernen.',
)
throw firstErr
}
if (firstErr.status === 422 && firstErr.code === 'OFFICIAL_MEDIA_CONFIRM_REQUIRED') {
const promo = (firstErr.payload.assets_need_visibility_promotion || []).length
const miss = (firstErr.payload.assets_missing_copyright || []).length
let msg =
'Die Übung ist oder wird offiziell. '
if (promo > 0) {
msg += `${promo} verknüpfte Datei(en) werden dabei plattformweit („offiziell“) freigegeben. `
}
if (miss > 0) {
msg += `${miss} Datei(en) haben noch keinen ausreichenden Copyright-Vermerk (mind. 3 Zeichen). `
}
msg += 'Fortfahren?'
if (!window.confirm(msg)) throw firstErr
let defaultCopyright = ''
if (miss > 0) {
defaultCopyright = window.prompt(
'Copyright-Vermerk für betroffene Dateien ohne Eintrag (mind. 3 Zeichen):',
'© ',
)
if (!defaultCopyright || String(defaultCopyright).trim().length < 3) {
alert('Mindestens 3 Zeichen für den Copyright-Vermerk.')
throw firstErr
}
}
await saveOnce({
promote_attached_media_for_official: true,
...(miss > 0 ? { default_official_media_copyright: String(defaultCopyright).trim() } : {}),
})
} else {
throw firstErr
}
}
const ex = await api.getExercise(exerciseId) const ex = await api.getExercise(exerciseId)
setMediaList(ex.media || []) setMediaList(ex.media || [])
setVariants((ex.variants || []).map(apiVariantToRow)) setVariants((ex.variants || []).map(apiVariantToRow))
@ -545,6 +701,28 @@ function ExerciseFormPage() {
setMediaList(ex.media || []) setMediaList(ex.media || [])
} }
const attachFromArchive = async (assetId) => {
if (!exerciseId) return
try {
await api.attachExerciseMediaFromAsset(exerciseId, {
media_asset_id: assetId,
context: archiveCtx,
title: '',
description: '',
is_primary: false,
})
setArchiveOpen(false)
await refreshMedia()
} catch (e) {
alert(e.message || String(e))
}
}
const linkedArchiveAssetIds = useMemo(
() => new Set((mediaList || []).map((m) => m.media_asset_id).filter(Boolean)),
[mediaList],
)
const handleUploadFile = async () => { const handleUploadFile = async () => {
if (!exerciseId || !mediaFile) { if (!exerciseId || !mediaFile) {
alert('Datei wählen') alert('Datei wählen')
@ -563,7 +741,44 @@ function ExerciseFormPage() {
setMediaTitle('') setMediaTitle('')
await refreshMedia() await refreshMedia()
} catch (err) { } catch (err) {
alert('Upload: ' + err.message) if (err.code === 'MEDIA_ASSET_IN_TRASH' && err.payload?.media_asset_id != null) {
const aid = err.payload.media_asset_id
const nameHint =
(mediaFile && mediaFile.name) ||
err.payload.original_filename ||
'diese Datei'
if (
confirm(
`Die hochgeladene Datei ist inhaltsgleich mit einem Archiv-Medium im Papierkorb (${nameHint}). ` +
'Soll dieses Medium wieder aktiviert und an die Übung gehängt werden? (Es wird kein zweites Exemplar auf der Platte angelegt.)',
)
) {
try {
await api.postMediaAssetLifecycle(aid, 'reactivate')
await api.attachExerciseMediaFromAsset(exerciseId, {
media_asset_id: aid,
title: mediaTitle || undefined,
description: '',
context: mediaContext,
is_primary: false,
})
setMediaFile(null)
setMediaTitle('')
await refreshMedia()
} catch (e2) {
alert(e2.message || String(e2))
}
}
return
}
if (err.code === 'MEDIA_ASSET_UNAVAILABLE') {
alert(
(err.message || 'Archiv-Konflikt') +
' Bitte wenden Sie sich an einen Administrator oder wählen Sie eine andere Datei.',
)
return
}
alert('Upload: ' + (err.message || String(err)))
} }
} }
@ -590,15 +805,69 @@ function ExerciseFormPage() {
} }
const handleDeleteMedia = async (mid) => { const handleDeleteMedia = async (mid) => {
if (!confirm('Medium löschen?')) return if (
!confirm(
'Dieses Medium aus der Übung entfernen? Nur die Verknüpfung wird gelöscht. Die Datei bleibt im Archiv, solange sie noch woanders genutzt wird.',
)
) {
return
}
try { try {
await api.deleteExerciseMedia(exerciseId, mid) const res = await api.deleteExerciseMedia(exerciseId, mid)
await refreshMedia() await refreshMedia()
const oid = res?.orphan_media_asset_id
if (oid != null) {
if (
confirm(
'Dieses Archiv-Medium wird danach nirgendwo mehr verwendet. In den Papierkorb (Stufe 1) legen? (Später in der Medienverwaltung wiederherstellbar.)',
)
) {
await api.postMediaAssetLifecycle(oid, 'trash_soft')
await refreshMedia()
}
}
} catch (err) { } catch (err) {
alert(err.message) alert(err.message)
} }
} }
const moveMediaRow = async (idx, dir) => {
if (!exerciseId) return
const j = idx + dir
if (j < 0 || j >= mediaList.length) return
const next = [...mediaList]
const tmp = next[idx]
next[idx] = next[j]
next[j] = tmp
try {
await api.reorderExerciseMedia(
exerciseId,
next.map((x) => x.id),
)
setMediaList(next)
} catch (e) {
alert(e.message || String(e))
}
}
const saveMediaMeta = async (mid) => {
if (!exerciseId) return
const fld = mediaFields[mid]
if (!fld) return
setMediaSavingId(mid)
try {
await api.updateExerciseMedia(exerciseId, mid, {
title: fld.title.trim() || null,
context: fld.context,
})
await refreshMedia()
} catch (e) {
alert(e.message || String(e))
} finally {
setMediaSavingId(null)
}
}
const refreshVariants = async () => { const refreshVariants = async () => {
if (!exerciseId) return if (!exerciseId) return
const ex = await api.getExercise(exerciseId) const ex = await api.getExercise(exerciseId)
@ -1170,6 +1439,22 @@ function ExerciseFormPage() {
<p style={{ color: 'var(--text2)', fontSize: '13px' }}> <p style={{ color: 'var(--text2)', fontSize: '13px' }}>
Datei oder Embed (YouTube, Vimeo, Instagram, TikTok). Max. 10 pro Übung. Datei oder Embed (YouTube, Vimeo, Instagram, TikTok). Max. 10 pro Übung.
</p> </p>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '8px',
alignItems: 'center',
marginTop: '10px',
}}
>
<button type="button" className="btn btn-secondary" onClick={() => setArchiveOpen(true)}>
Aus Archiv verknüpfen
</button>
<Link to="/media" className="btn btn-secondary" style={{ textDecoration: 'none' }}>
Medienbibliothek
</Link>
</div>
<div style={{ display: 'grid', gap: '12px', marginTop: '12px' }}> <div style={{ display: 'grid', gap: '12px', marginTop: '12px' }}>
<div> <div>
<label className="form-label">Datei</label> <label className="form-label">Datei</label>
@ -1230,30 +1515,308 @@ function ExerciseFormPage() {
</div> </div>
</div> </div>
{mediaList.length > 0 && ( {mediaList.length > 0 && (
<ul style={{ marginTop: '12px', paddingLeft: '1.2rem' }}> <ul style={{ marginTop: '12px', paddingLeft: '0', listStyle: 'none' }}>
{mediaList.map((m) => ( {mediaList.map((m, idx) => (
<li key={m.id} style={{ marginBottom: '6px' }}> <li
{m.title || m.original_filename || m.media_type}{' '} key={m.id}
{m.embed_platform ? `(${m.embed_platform})` : ''} className="card"
style={{
marginBottom: '10px',
padding: '10px 12px',
border: '1px solid var(--border)',
display: 'flex',
gap: '12px',
alignItems: 'flex-start',
}}
>
<ExerciseMediaThumbTile exerciseId={exerciseId} media={m} onOpenPreview={setMediaPreview} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'center' }}>
<span style={{ fontSize: '12px', color: 'var(--text3)' }}>
#{idx + 1} · {m.media_type}
{m.embed_platform ? ` · ${m.embed_platform}` : ''}
</span>
{mediaList.length > 1 && (
<>
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: '11px', padding: '2px 8px' }}
disabled={idx === 0}
onClick={() => moveMediaRow(idx, -1)}
title="Nach oben"
>
</button>
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: '11px', padding: '2px 8px' }}
disabled={idx >= mediaList.length - 1}
onClick={() => moveMediaRow(idx, 1)}
title="Nach unten"
>
</button>
</>
)}
</div>
<div
style={{
fontSize: '12px',
color: 'var(--text2)',
marginTop: '6px',
wordBreak: 'break-word',
lineHeight: 1.35,
}}
>
{(m.original_filename || '').trim() ||
(m.title || '').trim() ||
(m.embed_url ? m.embed_url : '') ||
'—'}
</div>
<div className="form-row" style={{ marginTop: '8px', display: 'grid', gap: '8px' }}>
<input
type="text"
className="form-input"
placeholder="Titel"
value={(mediaFields[m.id] || {}).title ?? ''}
onChange={(e) =>
setMediaFields((prev) => ({
...prev,
[m.id]: { ...(prev[m.id] || {}), title: e.target.value, context: (prev[m.id] || {}).context || 'ablauf' },
}))
}
/>
<select
className="form-input"
value={(mediaFields[m.id] || {}).context || 'ablauf'}
onChange={(e) =>
setMediaFields((prev) => ({
...prev,
[m.id]: {
...(prev[m.id] || {}),
title: (prev[m.id] || {}).title ?? '',
context: e.target.value,
},
}))
}
>
<option value="ablauf">Ablauf</option>
<option value="detail">Detail</option>
<option value="trainer_hint">Trainer-Hinweis</option>
</select>
<button
type="button"
className="btn btn-primary"
style={{ fontSize: '12px' }}
disabled={mediaSavingId === m.id}
onClick={() => saveMediaMeta(m.id)}
>
{mediaSavingId === m.id ? 'Speichern…' : 'Titel & Sektion speichern'}
</button>
</div>
<button <button
type="button" type="button"
className="btn" className="btn btn-secondary"
style={{ style={{
marginLeft: '8px', marginTop: '8px',
fontSize: '11px', fontSize: '12px',
padding: '2px 8px', padding: '6px 12px',
background: 'var(--danger)',
color: '#fff',
border: 'none',
}} }}
onClick={() => handleDeleteMedia(m.id)} onClick={() => handleDeleteMedia(m.id)}
> >
Löschen Aus Übung entfernen
</button> </button>
</div>
</li> </li>
))} ))}
</ul> </ul>
)} )}
{archiveOpen && (
<div
role="dialog"
aria-modal="true"
aria-label="Medienarchiv"
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.5)',
zIndex: 1000,
overflow: 'auto',
padding: '16px',
}}
onClick={() => setArchiveOpen(false)}
onKeyDown={(e) => e.key === 'Escape' && setArchiveOpen(false)}
>
<div
className="card"
style={{
maxWidth: 560,
margin: '4vh auto',
maxHeight: '88vh',
overflow: 'auto',
position: 'relative',
}}
onClick={(e) => e.stopPropagation()}
>
<h3 style={{ marginTop: 0, fontSize: '1.05rem' }}>Medienarchiv</h3>
<input
type="search"
className="form-input"
placeholder="Suche Dateiname…"
value={archiveQ}
onChange={(e) => setArchiveQ(e.target.value)}
style={{ marginBottom: '8px' }}
/>
<select
className="form-input"
value={archiveCtx}
onChange={(e) => setArchiveCtx(e.target.value)}
style={{ marginBottom: '12px' }}
>
<option value="ablauf">Sektion: Ablauf</option>
<option value="detail">Sektion: Detail</option>
<option value="trainer_hint">Sektion: Trainer-Hinweis</option>
</select>
{archiveLoading && <p style={{ fontSize: '13px', color: 'var(--text3)' }}>Laden</p>}
{archiveError && <p style={{ fontSize: '13px', color: 'var(--danger)' }}>{archiveError}</p>}
{!archiveLoading && !archiveError && archiveItems.length === 0 && (
<p style={{ fontSize: '13px', color: 'var(--text3)' }}>Keine Treffer.</p>
)}
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
{archiveItems.map((a) => {
const already = linkedArchiveAssetIds.has(a.id)
return (
<li
key={a.id}
style={{
display: 'flex',
gap: '10px',
alignItems: 'center',
padding: '8px 0',
borderBottom: '1px solid var(--border)',
}}
>
<div
style={{
width: 56,
height: 56,
flexShrink: 0,
borderRadius: '6px',
overflow: 'hidden',
background: 'var(--surface2, rgba(127,127,127,0.12))',
border: '1px solid var(--border)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{a.mime_type?.startsWith('image/') ? (
<img
alt=""
src={resolveMediaAssetFileUrl(a.id)}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
) : a.mime_type?.startsWith('video/') ? (
<span style={{ fontSize: '18px', opacity: 0.75 }} aria-hidden>
</span>
) : (
<span style={{ fontSize: '10px', color: 'var(--text2)', padding: '2px', textAlign: 'center' }}>
PDF
</span>
)}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: '13px', fontWeight: 600, wordBreak: 'break-word' }}>
{a.original_filename || `Asset #${a.id}`}
</div>
<div style={{ fontSize: '11px', color: 'var(--text3)', marginTop: '2px' }}>
{a.visibility} · {a.mime_type || '—'}{' '}
{a.byte_size != null ? `· ${(a.byte_size / 1024).toFixed(0)} KB` : ''}
</div>
</div>
<button
type="button"
className="btn btn-primary"
style={{ fontSize: '12px' }}
disabled={already}
title={already ? 'Schon mit dieser Übung verknüpft' : ''}
onClick={() => !already && attachFromArchive(a.id)}
>
{already ? 'Bereits verknüpft' : 'Verknüpfen'}
</button>
</li>
)
})}
</ul>
<div style={{ marginTop: '16px', display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
<button type="button" className="btn btn-secondary" onClick={() => setArchiveOpen(false)}>
Schließen
</button>
</div>
</div>
</div>
)}
{mediaPreview && (
<div
role="dialog"
aria-modal="true"
aria-label="Medienvorschau"
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.55)',
zIndex: 1001,
overflow: 'auto',
padding: '16px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
onClick={() => setMediaPreview(null)}
onKeyDown={(e) => e.key === 'Escape' && setMediaPreview(null)}
>
<div
className="card"
style={{ maxWidth: 720, width: '100%', maxHeight: '90vh', overflow: 'auto' }}
onClick={(e) => e.stopPropagation()}
>
<h3 style={{ marginTop: 0, fontSize: '1.05rem' }}>Vorschau</h3>
{mediaPreview.embed_url ? (
<p style={{ fontSize: '14px', wordBreak: 'break-all' }}>
<a href={mediaPreview.embed_url} target="_blank" rel="noreferrer">
{mediaPreview.embed_url}
</a>
</p>
) : mediaPreview.mime_type?.startsWith('video/') || mediaPreview.media_type === 'video' ? (
<video
src={resolveExerciseMediaFileUrl(exerciseId, mediaPreview)}
controls
style={{ width: '100%', borderRadius: '8px', maxHeight: '70vh' }}
/>
) : mediaPreview.mime_type?.startsWith('image/') || mediaPreview.media_type === 'image' ? (
<img
alt=""
src={resolveExerciseMediaFileUrl(exerciseId, mediaPreview)}
style={{ maxWidth: '100%', borderRadius: '8px', maxHeight: '70vh', objectFit: 'contain' }}
/>
) : (
<p style={{ fontSize: '14px' }}>
<a href={resolveExerciseMediaFileUrl(exerciseId, mediaPreview)} target="_blank" rel="noreferrer">
Datei öffnen
</a>
</p>
)}
<div style={{ marginTop: '16px' }}>
<button type="button" className="btn btn-secondary" onClick={() => setMediaPreview(null)}>
Schließen
</button>
</div>
</div>
</div>
)}
</div> </div>
)} )}

View File

@ -0,0 +1,893 @@
import { useEffect, useState, useCallback } from 'react'
import { Link } from 'react-router-dom'
import {
LayoutGrid,
List,
MoreVertical,
X,
} from 'lucide-react'
import { useAuth } from '../context/AuthContext'
import api from '../utils/api'
import AdminPageNav from '../components/AdminPageNav'
import { resolveMediaAssetFileUrl } from '../utils/exerciseMediaUrl'
const LC_OPTIONS = [
{ value: 'active', label: 'Aktiv' },
{ value: 'trash_soft', label: 'Papierkorb (1)' },
{ value: 'trash_hidden', label: 'Ausgeblendet (2)' },
{ value: 'all', label: 'Alle' },
]
const VIS_OPTIONS = [
{ value: 'private', label: 'Privat' },
{ value: 'club', label: 'Verein' },
{ value: 'official', label: 'Offiziell' },
]
function lcLabel(code) {
const o = LC_OPTIONS.find((x) => x.value === code)
return o ? o.label : code
}
function uploaderLabel(it, viewer) {
if (!viewer?.show_uploader_meta) return null
const n = (it.uploader_name || '').trim()
const e = (it.uploader_email || '').trim()
if (n) return n
if (e) return e
return it.uploaded_by_profile_id != null ? `Profil #${it.uploaded_by_profile_id}` : '—'
}
function MediaThumb({ mediaId, mimeType }) {
const url = resolveMediaAssetFileUrl(mediaId)
const mime = (mimeType || '').toLowerCase()
if (mime.startsWith('video/')) {
return (
<video
className="media-library__thumb-video"
src={url}
muted
playsInline
preload="metadata"
onLoadedMetadata={(e) => {
const el = e.target
try {
const d = el.duration
el.currentTime = Number.isFinite(d) && d > 0 ? Math.min(0.05, d * 0.01) : 0.05
} catch {
/* ignore */
}
}}
/>
)
}
if (mime.startsWith('image/')) {
return (
<img
className="media-library__thumb-img"
src={url}
alt=""
loading="lazy"
onError={(e) => {
e.target.style.display = 'none'
}}
/>
)
}
if (mime.includes('pdf')) {
return <div className="media-library__thumb-ph">PDF</div>
}
return <div className="media-library__thumb-ph"></div>
}
function previewDisplayKind(mimeType) {
const m = (mimeType || '').toLowerCase()
if (m.startsWith('image/')) return 'image'
if (m.startsWith('video/')) return 'video'
if (m.includes('pdf')) return 'pdf'
return 'other'
}
export default function MediaLibraryPage() {
const { user } = useAuth()
const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin'
const isSuperadmin = user?.role === 'superadmin'
const [lifecycle, setLifecycle] = useState('active')
const [q, setQ] = useState('')
const [items, setItems] = useState([])
const [viewer, setViewer] = useState(null)
const [clubs, setClubs] = useState([])
const [viewMode, setViewMode] = useState('grid')
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [selected, setSelected] = useState(() => new Set())
const [modal, setModal] = useState(null)
const [modalDraft, setModalDraft] = useState(null)
const [bulkOpen, setBulkOpen] = useState(false)
const [bulkCopyright, setBulkCopyright] = useState('')
const [bulkVis, setBulkVis] = useState('private')
const [bulkClubId, setBulkClubId] = useState('')
const [bulkApplyVis, setBulkApplyVis] = useState(false)
const [busy, setBusy] = useState(false)
const [preview, setPreview] = useState(null)
const loadClubs = useCallback(async () => {
try {
const c = await api.listClubs()
setClubs(Array.isArray(c) ? c : [])
} catch {
setClubs([])
}
}, [])
useEffect(() => {
loadClubs()
}, [loadClubs])
const loadItems = useCallback(async () => {
setLoading(true)
setError('')
try {
const res = await api.listMediaAssets({
lifecycle,
q: q.trim(),
limit: 100,
offset: 0,
})
setItems(res.items || [])
setViewer(res.viewer || null)
setSelected(new Set())
} catch (e) {
setError(e.message || String(e))
} finally {
setLoading(false)
}
}, [lifecycle, q])
useEffect(() => {
const t = setTimeout(() => {
loadItems()
}, 320)
return () => clearTimeout(t)
}, [lifecycle, q, loadItems])
useEffect(() => {
if (!preview) return
const onKey = (e) => {
if (e.key === 'Escape') setPreview(null)
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [preview])
const toggleSel = (id) => {
setSelected((prev) => {
const n = new Set(prev)
if (n.has(id)) n.delete(id)
else n.add(id)
return n
})
}
const selectAll = () => {
if (selected.size === items.length) setSelected(new Set())
else setSelected(new Set(items.map((x) => x.id)))
}
const openEdit = (it) => {
setPreview(null)
const p = it.permissions || {}
setModal(it)
setModalDraft({
display_name: it.original_filename != null ? String(it.original_filename) : '',
copyright_notice: it.copyright_notice != null ? String(it.copyright_notice) : '',
visibility: (it.visibility || 'private').toLowerCase(),
club_id: it.club_id != null ? String(it.club_id) : '',
superTarget: 'active',
})
}
const closeModal = () => {
setModal(null)
setModalDraft(null)
}
const saveModal = async () => {
if (!modal || !modalDraft) return
const p = modal.permissions || {}
setBusy(true)
try {
const body = {}
if (p.edit_metadata) {
body.original_filename = modalDraft.display_name
body.copyright_notice = modalDraft.copyright_notice
}
if (p.change_visibility) {
body.visibility = modalDraft.visibility
if (modalDraft.visibility === 'club') {
const cid = Number(modalDraft.club_id)
if (!cid) {
alert('Bitte einen Verein wählen.')
setBusy(false)
return
}
body.club_id = cid
}
}
if (Object.keys(body).length) {
await api.patchMediaAsset(modal.id, body)
}
closeModal()
await loadItems()
} catch (e) {
alert(e.message || String(e))
} finally {
setBusy(false)
}
}
const runLc = async (id, action, confirmMsg, extra = {}) => {
if (confirmMsg && !window.confirm(confirmMsg)) return
setBusy(true)
try {
await api.postMediaAssetLifecycle(id, action, extra)
await loadItems()
closeModal()
} catch (e) {
alert(e.message || String(e))
} finally {
setBusy(false)
}
}
const runBulkPatch = async () => {
const ids = [...selected]
if (!ids.length) return
const body = { media_asset_ids: ids }
let has = false
if (bulkCopyright.trim()) {
body.copyright_notice = bulkCopyright.trim()
has = true
}
if (bulkApplyVis) {
body.visibility = bulkVis
if (bulkVis === 'club') {
const cid = Number(bulkClubId)
if (!cid) {
alert('Bitte einen Verein wählen.')
return
}
body.club_id = cid
}
has = true
}
if (!has) {
alert('Mindestens Copyright ausfüllen oder „Sichtbarkeit ändern“ aktivieren.')
return
}
setBusy(true)
try {
const res = await api.bulkPatchMediaAssets(body)
if (res.failed_count) {
alert(
`${res.updated_count} aktualisiert, ${res.failed_count} fehlgeschlagen. Erste Meldung: ${res.failed[0]?.detail || ''}`,
)
}
setBulkOpen(false)
await loadItems()
} catch (e) {
alert(e.message || String(e))
} finally {
setBusy(false)
}
}
const runBulkLifecycle = async (action) => {
const ids = [...selected]
if (!ids.length) return
setBusy(true)
try {
const res = await api.bulkMediaLifecycle({ media_asset_ids: ids, action })
if (res.failed_count) {
alert(
`${res.updated_count} OK, ${res.failed_count} fehlgeschlagen: ${res.failed[0]?.detail || ''}`,
)
}
await loadItems()
} catch (e) {
alert(e.message || String(e))
} finally {
setBusy(false)
}
}
const selCount = selected.size
return (
<div className="app-page media-library">
{isPlatformAdmin ? <AdminPageNav /> : null}
<div className="media-library__container">
<header className="media-library__hero">
<div className="media-library__hero-row">
<h1 className="media-library__title">Medienbibliothek</h1>
<div className="media-library__hero-links">
<Link to="/exercises">Übungen</Link>
{isPlatformAdmin ? <Link to="/admin/hierarchy">Admin</Link> : null}
</div>
</div>
<p className="media-library__intro">
Veröffentlichte Medien (Verein/Plattform) und eigene Uploads. Vorschau: Bild oder Video groß
anklicken. Bearbeiten und Papierkorb über das Menü pro Medium Bulk unten in der Leiste.
</p>
</header>
<div className="media-library__toolbar">
<div className="media-library__toolbar-row">
<input
type="search"
className="form-input media-library__search"
placeholder="Suche Dateiname …"
value={q}
onChange={(e) => setQ(e.target.value)}
aria-label="Suche"
/>
<select
className="form-input media-library__select"
value={lifecycle}
onChange={(e) => setLifecycle(e.target.value)}
aria-label="Lebenszyklus"
>
{LC_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
<div className="media-library__view-toggle" role="group" aria-label="Darstellung">
<button
type="button"
className={`media-library__icon-btn${viewMode === 'grid' ? ' media-library__icon-btn--on' : ''}`}
onClick={() => setViewMode('grid')}
title="Kacheln"
>
<LayoutGrid size={20} />
</button>
<button
type="button"
className={`media-library__icon-btn${viewMode === 'list' ? ' media-library__icon-btn--on' : ''}`}
onClick={() => setViewMode('list')}
title="Liste"
>
<List size={20} />
</button>
</div>
<button
type="button"
className="btn btn-secondary media-library__refresh"
onClick={loadItems}
disabled={loading}
>
Aktualisieren
</button>
</div>
<div className="media-library__toolbar-meta">
<label className="media-library__check-all">
<input type="checkbox" checked={items.length > 0 && selCount === items.length} onChange={selectAll} />
<span>alle sichtbaren</span>
</label>
{selCount > 0 ? (
<button type="button" className="btn btn-secondary" onClick={() => setBulkOpen(true)}>
Bulk ({selCount})
</button>
) : null}
</div>
</div>
{error ? (
<p className="media-library__err" role="alert">
{error}
</p>
) : null}
{loading && !items.length ? <div className="spinner media-library__spinner" /> : null}
{!loading && !items.length && !error ? (
<p className="media-library__empty">Keine Medien für diese Filter.</p>
) : null}
{viewMode === 'grid' && items.length > 0 ? (
<div className="media-library__grid">
{items.map((it) => {
const lc = (it.lifecycle_state || '').toLowerCase()
const chk = selected.has(it.id)
return (
<div key={it.id} className="media-library__card">
<label className="media-library__card-check">
<input type="checkbox" checked={chk} onChange={() => toggleSel(it.id)} />
</label>
<button
type="button"
className="media-library__card-menu"
onClick={() => openEdit(it)}
title="Bearbeiten"
aria-label="Bearbeiten"
>
<MoreVertical size={20} />
</button>
<button
type="button"
className="media-library__card-thumb-hit"
onClick={() => setPreview(it)}
title="Vorschau"
>
<div className="media-library__card-thumb-wrap">
<MediaThumb mediaId={it.id} mimeType={it.mime_type} />
</div>
</button>
<div className="media-library__card-footer">
<div className="media-library__card-name" title={it.original_filename || `#${it.id}`}>
{it.original_filename || `Medium #${it.id}`}
</div>
<div className="media-library__card-tags">
<span>{it.visibility}</span>
<span>{lcLabel(lc)}</span>
</div>
</div>
</div>
)
})}
</div>
) : null}
{viewMode === 'list' && items.length > 0 ? (
<div className="media-library__table-wrap">
<table className="media-library__table">
<thead>
<tr>
<th className="media-library__th-check" />
<th>Vorschau</th>
<th>Bezeichnung</th>
<th>Sichtbarkeit</th>
<th>Status</th>
{viewer?.show_club_meta ? <th>Verein</th> : null}
{viewer?.show_uploader_meta ? <th>Uploader</th> : null}
<th className="media-library__th-act" />
</tr>
</thead>
<tbody>
{items.map((it) => {
const lc = (it.lifecycle_state || '').toLowerCase()
return (
<tr key={it.id}>
<td>
<input type="checkbox" checked={selected.has(it.id)} onChange={() => toggleSel(it.id)} />
</td>
<td className="media-library__td-thumb">
<button
type="button"
className="media-library__table-thumb-hit"
onClick={() => setPreview(it)}
title="Vorschau"
>
<div className="media-library__table-thumb">
<MediaThumb mediaId={it.id} mimeType={it.mime_type} />
</div>
</button>
</td>
<td className="media-library__td-name">{it.original_filename || `#${it.id}`}</td>
<td>{it.visibility}</td>
<td>{lcLabel(lc)}</td>
{viewer?.show_club_meta ? (
<td className="media-library__td-sub">{it.club_name || it.club_id || '—'}</td>
) : null}
{viewer?.show_uploader_meta ? (
<td className="media-library__td-sub">{uploaderLabel(it, viewer) || '—'}</td>
) : null}
<td>
<button
type="button"
className="media-library__icon-btn"
onClick={() => openEdit(it)}
aria-label="Bearbeiten"
>
<MoreVertical size={18} />
</button>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
) : null}
</div>
{preview ? (
<div
className="media-library__overlay media-library__overlay--preview"
role="dialog"
aria-modal="true"
aria-labelledby="preview-media-title"
onClick={() => setPreview(null)}
>
<div
className="media-library__modal media-library__modal--preview"
onClick={(e) => e.stopPropagation()}
>
<div className="media-library__modal-head">
<h2 id="preview-media-title" className="media-library__preview-title">
{preview.original_filename || `Medium #${preview.id}`}
</h2>
<div className="media-library__preview-head-actions">
<button
type="button"
className="btn btn-secondary btn-small"
onClick={() => {
openEdit(preview)
}}
>
Bearbeiten
</button>
<button
type="button"
className="media-library__icon-btn"
onClick={() => setPreview(null)}
aria-label="Vorschau schließen"
>
<X size={22} />
</button>
</div>
</div>
<div className="media-library__preview-body">
{(() => {
const url = resolveMediaAssetFileUrl(preview.id)
const kind = previewDisplayKind(preview.mime_type)
if (!url) {
return <p className="media-library__hint">Keine Datei-URL.</p>
}
if (kind === 'image') {
return (
<img
key={preview.id}
className="media-library__preview-img"
src={url}
alt=""
/>
)
}
if (kind === 'video') {
return (
<video
key={preview.id}
className="media-library__preview-video"
src={url}
controls
playsInline
preload="metadata"
>
Wiedergabe nicht unterstützt.
</video>
)
}
if (kind === 'pdf') {
return (
<div className="media-library__preview-fallback">
<p className="media-library__hint">PDF zur Ansicht im neuen Tab öffnen.</p>
<a className="btn btn-secondary" href={url} target="_blank" rel="noopener noreferrer">
PDF öffnen
</a>
</div>
)
}
return (
<div className="media-library__preview-fallback">
<p className="media-library__hint">
Vorschau für diesen Typ nicht verfügbar ({preview.mime_type || 'unbekannt'}).
</p>
<a className="btn btn-secondary" href={url} target="_blank" rel="noopener noreferrer">
Datei öffnen
</a>
</div>
)
})()}
</div>
</div>
</div>
) : null}
{bulkOpen ? (
<div className="media-library__overlay" role="dialog" aria-modal="true" aria-labelledby="bulk-title">
<div className="media-library__modal media-library__modal--wide">
<div className="media-library__modal-head">
<h2 id="bulk-title">Bulk für {selCount} Medien</h2>
<button type="button" className="media-library__icon-btn" onClick={() => setBulkOpen(false)} aria-label="Schließen">
<X size={22} />
</button>
</div>
<div className="media-library__modal-body">
<p className="media-library__hint">Nur ausgefüllte Felder werden gesetzt. Fehlende Rechte pro Medium werden im Ergebnis gemeldet.</p>
<label className="form-label">Copyright (optional)</label>
<textarea
className="form-input"
rows={2}
value={bulkCopyright}
onChange={(e) => setBulkCopyright(e.target.value)}
/>
<label className="media-library__check">
<input
type="checkbox"
checked={bulkApplyVis}
onChange={(e) => setBulkApplyVis(e.target.checked)}
/>
<span>Sichtbarkeit ändern</span>
</label>
{bulkApplyVis ? (
<>
<select className="form-input" value={bulkVis} onChange={(e) => setBulkVis(e.target.value)}>
{VIS_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
{bulkVis === 'club' ? (
<select className="form-input" value={bulkClubId} onChange={(e) => setBulkClubId(e.target.value)}>
<option value=""> Verein </option>
{clubs.map((c) => (
<option key={c.id} value={String(c.id)}>
{c.name || c.id}
</option>
))}
</select>
) : null}
</>
) : null}
<div className="media-library__modal-actions">
<button type="button" className="btn btn-secondary" disabled={busy} onClick={runBulkPatch}>
Metadaten anwenden
</button>
<button
type="button"
className="btn btn-secondary"
disabled={busy}
onClick={() => {
if (!window.confirm('Ausgewählte aktiven Medien in Papierkorb (1)?')) return
runBulkLifecycle('trash_soft')
}}
>
Papierkorb (1)
</button>
{isSuperadmin ? (
<button
type="button"
className="btn btn-secondary"
disabled={busy}
onClick={() => {
if (!window.confirm('Superadmin: Ausgewählte endgültig löschen?')) return
runBulkLifecycle('superadmin_hard_delete')
}}
>
Hard-Delete
</button>
) : null}
</div>
</div>
</div>
</div>
) : null}
{modal && modalDraft ? (
<div className="media-library__overlay" role="dialog" aria-modal="true" aria-labelledby="edit-media-title">
<div className="media-library__modal">
<div className="media-library__modal-head">
<h2 id="edit-media-title">Medium #{modal.id}</h2>
<button type="button" className="media-library__icon-btn" onClick={closeModal} aria-label="Schließen">
<X size={22} />
</button>
</div>
<div className="media-library__modal-body">
{(viewer?.show_club_meta || viewer?.show_uploader_meta) && (
<div className="media-library__meta-block">
{viewer?.show_club_meta ? (
<div>
<span className="media-library__meta-k">Verein</span>
<span className="media-library__meta-v">{modal.club_name || modal.club_id || '—'}</span>
</div>
) : null}
{viewer?.show_uploader_meta ? (
<div>
<span className="media-library__meta-k">Uploader</span>
<span className="media-library__meta-v">{uploaderLabel(modal, viewer) || '—'}</span>
</div>
) : null}
<div>
<span className="media-library__meta-k">Technisch</span>
<span className="media-library__meta-v mono">
{modal.mime_type || '—'} · ID {modal.id}
{isSuperadmin && modal.storage_key ? ` · ${modal.storage_key}` : ''}
</span>
</div>
</div>
)}
{modal.permissions?.edit_metadata ? (
<>
<label className="form-label">Bezeichnung (Dateiname / Anzeige)</label>
<input
className="form-input"
value={modalDraft.display_name}
onChange={(e) => setModalDraft((d) => ({ ...d, display_name: e.target.value }))}
/>
<label className="form-label">Copyright</label>
<textarea
className="form-input"
rows={3}
value={modalDraft.copyright_notice}
onChange={(e) => setModalDraft((d) => ({ ...d, copyright_notice: e.target.value }))}
/>
</>
) : (
<p className="media-library__hint">Keine Berechtigung für Metadaten nur Verwaltende dieser Stufe.</p>
)}
{modal.permissions?.change_visibility ? (
<>
<label className="form-label">Sichtbarkeit</label>
<select
className="form-input"
value={modalDraft.visibility}
onChange={(e) => setModalDraft((d) => ({ ...d, visibility: e.target.value }))}
>
{VIS_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
{modalDraft.visibility === 'club' ? (
<>
<label className="form-label">Verein</label>
<select
className="form-input"
value={modalDraft.club_id}
onChange={(e) => setModalDraft((d) => ({ ...d, club_id: e.target.value }))}
>
<option value=""> wählen </option>
{clubs.map((c) => (
<option key={c.id} value={String(c.id)}>
{c.name || c.id}
</option>
))}
</select>
</>
) : null}
</>
) : null}
<div className="media-library__modal-actions">
<button type="button" className="btn btn-secondary" disabled={busy} onClick={saveModal}>
Speichern
</button>
</div>
<div className="media-library__lc-block">
<div className="media-library__lc-title">Lebenszyklus</div>
<div className="media-library__lc-btns">
{modal.permissions?.trash_soft ? (
<button
type="button"
className="btn btn-secondary"
disabled={busy}
onClick={() =>
runLc(modal.id, 'trash_soft', 'In Papierkorb (Stufe 1) legen?')
}
>
Papierkorb
</button>
) : null}
{modal.permissions?.trash_hidden ? (
<button
type="button"
className="btn btn-secondary"
disabled={busy}
onClick={() =>
runLc(
modal.id,
'trash_hidden',
'Ausblenden (Stufe 2)? Öffentliche Ansicht verliert das Medium.',
)
}
>
Ausblenden
</button>
) : null}
{modal.permissions?.recover ? (
<button type="button" className="btn btn-secondary" disabled={busy} onClick={() => runLc(modal.id, 'recover', null)}>
Stufe 1
</button>
) : null}
{modal.permissions?.reactivate ? (
<button
type="button"
className="btn btn-secondary"
disabled={busy}
onClick={() => runLc(modal.id, 'reactivate', null)}
>
Wieder aktiv
</button>
) : null}
{modal.permissions?.purge ? (
<button
type="button"
className="btn btn-secondary"
disabled={busy}
onClick={() =>
runLc(
modal.id,
'purge',
'Endgültig löschen (nur Superadmin, Stufe 2)? Datei und DB-Eintrag.',
)
}
>
Endgültig löschen
</button>
) : null}
</div>
</div>
{modal.permissions?.superadmin_lifecycle ? (
<div className="media-library__lc-block media-library__lc-block--danger">
<div className="media-library__lc-title">Superadmin</div>
<div className="media-library__row">
<select
className="form-input"
value={modalDraft.superTarget}
onChange={(e) => setModalDraft((d) => ({ ...d, superTarget: e.target.value }))}
>
<option value="active">aktiv</option>
<option value="trash_soft">Papierkorb 1</option>
<option value="trash_hidden">ausgeblendet 2</option>
</select>
<button
type="button"
className="btn btn-secondary"
disabled={busy}
onClick={() =>
runLc(
modal.id,
'superadmin_force_lifecycle',
`Zustand erzwingen: ${modalDraft.superTarget}?`,
{ target_lifecycle: modalDraft.superTarget },
)
}
>
Zustand setzen
</button>
</div>
{modal.permissions?.superadmin_hard_delete ? (
<button
type="button"
className="btn btn-secondary"
disabled={busy}
onClick={() =>
runLc(
modal.id,
'superadmin_hard_delete',
'Medium komplett entfernen (DB + Datei)?',
)
}
>
Komplett löschen
</button>
) : null}
</div>
) : null}
</div>
</div>
</div>
) : null}
</div>
)
}

View File

@ -120,6 +120,18 @@ export async function listAdminUsers() {
return request('/api/admin/users') 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) { export async function updateProfile(profileId, data) {
return request(`/api/profiles/${profileId}`, { return request(`/api/profiles/${profileId}`, {
method: 'PUT', method: 'PUT',
@ -394,7 +406,7 @@ export async function listExercises(filters = {}) {
} }
/** Formular → API-Body (M:N gemäß EXERCISES_API_SPEC + training_types) */ /** Formular → API-Body (M:N gemäß EXERCISES_API_SPEC + training_types) */
export function buildExerciseApiPayload(formData) { export function buildExerciseApiPayload(formData, extras = {}) {
const num = (v) => (v === '' || v == null ? null : Number(v)) const num = (v) => (v === '' || v == null ? null : Number(v))
const goalHtml = formData.goal || '' const goalHtml = formData.goal || ''
@ -446,6 +458,7 @@ export function buildExerciseApiPayload(formData) {
visibility: formData.visibility || 'private', visibility: formData.visibility || 'private',
status: formData.status || 'draft', status: formData.status || 'draft',
club_id: formData.club_id ?? null, club_id: formData.club_id ?? null,
...extras,
} }
} }
@ -461,12 +474,29 @@ export async function uploadExerciseMedia(exerciseId, formData) {
if (!response.ok) { if (!response.ok) {
const err = await response.json().catch(() => ({ detail: 'Unknown error' })) const err = await response.json().catch(() => ({ detail: 'Unknown error' }))
const d = err.detail const d = err.detail
if (
response.status === 409 &&
d &&
typeof d === 'object' &&
!Array.isArray(d) &&
typeof d.code === 'string'
) {
const e = new Error(
typeof d.message === 'string' ? d.message : 'Upload konnte nicht verarbeitet werden',
)
e.code = d.code
e.status = 409
e.payload = d
throw e
}
const msg = const msg =
typeof d === 'string' typeof d === 'string'
? d ? d
: d != null : d != null && typeof d === 'object' && typeof d.message === 'string'
? JSON.stringify(d) ? d.message
: `HTTP ${response.status}` : d != null
? JSON.stringify(d)
: `HTTP ${response.status}`
throw new Error(msg) throw new Error(msg)
} }
return response.json() return response.json()
@ -490,6 +520,54 @@ export async function reorderExerciseMedia(exerciseId, mediaIds) {
}) })
} }
/** Papierkorb / Recovery — `action`: trash_soft | trash_hidden | recover | purge | reactivate */
export async function postMediaAssetLifecycle(assetId, action, extra = {}) {
return request(`/api/media-assets/${assetId}/lifecycle`, {
method: 'POST',
body: JSON.stringify({ action, ...extra }),
})
}
/** Archiv: aktive media_assets sichtbar für den Nutzer (Bibliotheksrechte). */
export async function listMediaAssets(params = {}) {
const sp = new URLSearchParams()
if (params.q) sp.set('q', params.q)
if (params.limit != null) sp.set('limit', String(params.limit))
if (params.offset != null) sp.set('offset', String(params.offset))
if (params.lifecycle) sp.set('lifecycle', String(params.lifecycle))
const qs = sp.toString()
return request(`/api/media-assets${qs ? `?${qs}` : ''}`)
}
export async function patchMediaAsset(assetId, data) {
return request(`/api/media-assets/${assetId}`, {
method: 'PATCH',
body: JSON.stringify(data),
})
}
export async function bulkMediaLifecycle(data) {
return request('/api/media-assets/bulk-lifecycle', {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function bulkPatchMediaAssets(data) {
return request('/api/media-assets/bulk-patch', {
method: 'POST',
body: JSON.stringify(data),
})
}
/** Übung: bestehendes Archiv-Medium verknüpfen (`POST /api/exercises/{id}/media/from-asset`). */
export async function attachExerciseMediaFromAsset(exerciseId, body) {
return request(`/api/exercises/${exerciseId}/media/from-asset`, {
method: 'POST',
body: JSON.stringify(body),
})
}
export async function getExercise(id) { export async function getExercise(id) {
return request(`/api/exercises/${id}`) return request(`/api/exercises/${id}`)
} }
@ -502,10 +580,45 @@ export async function createExercise(data) {
} }
export async function updateExercise(id, data) { export async function updateExercise(id, data) {
return request(`/api/exercises/${id}`, { const token = localStorage.getItem('authToken')
const headers = mergeActiveClubHeader({ 'Content-Type': 'application/json' })
if (token) headers['X-Auth-Token'] = token
const url = `${API_URL}/api/exercises/${id}`
const response = await fetch(url, {
method: 'PUT', method: 'PUT',
body: JSON.stringify(data) headers,
body: JSON.stringify(data),
}) })
if (!response.ok) {
const text = await response.text()
let parsed = null
try {
parsed = JSON.parse(text)
} catch {
parsed = null
}
const d = parsed?.detail
if (
response.status === 422 &&
d &&
typeof d === 'object' &&
!Array.isArray(d) &&
typeof d.code === 'string'
) {
const e = new Error(typeof d.message === 'string' ? d.message : 'Validierung fehlgeschlagen')
e.status = 422
e.code = d.code
e.payload = d
throw e
}
if (parsed?.detail != null) {
const msg = typeof parsed.detail === 'string' ? parsed.detail : JSON.stringify(parsed.detail)
throw new Error(msg)
}
const snippet = (text || '').replace(/\s+/g, ' ').trim().slice(0, 180)
throw new Error(snippet ? `HTTP ${response.status}: ${snippet}` : `HTTP ${response.status}`)
}
return response.json()
} }
/** Massenänderung Übungen: Sichtbarkeit, Status, Katalog-Zuordnungen (`PATCH /api/exercises/bulk-metadata`). */ /** Massenänderung Übungen: Sichtbarkeit, Status, Katalog-Zuordnungen (`PATCH /api/exercises/bulk-metadata`). */
@ -1191,6 +1304,12 @@ export const api = {
updateExerciseMedia, updateExerciseMedia,
deleteExerciseMedia, deleteExerciseMedia,
reorderExerciseMedia, reorderExerciseMedia,
postMediaAssetLifecycle,
listMediaAssets,
patchMediaAsset,
bulkMediaLifecycle,
bulkPatchMediaAssets,
attachExerciseMediaFromAsset,
listExerciseProgressionGraphs, listExerciseProgressionGraphs,
getExerciseProgressionGraph, getExerciseProgressionGraph,
createExerciseProgressionGraph, createExerciseProgressionGraph,

View File

@ -0,0 +1,31 @@
/** URL für Übungs-Mediendateien: API mit Token (Legacy /media/ ohne Auth ist abgeschaltet). */
const API_BASE = (import.meta.env.VITE_API_URL || '').replace(/\/$/, '')
/**
* @param {number|string} exerciseId
* @param {object} media exercise_media Zeile mit id, file_path
* @returns {string|null}
*/
export function resolveExerciseMediaFileUrl(exerciseId, media) {
if (!media?.file_path) return null
const fp = String(media.file_path)
if (fp.startsWith('http://') || fp.startsWith('https://')) return fp
const token = typeof localStorage !== 'undefined' ? localStorage.getItem('authToken') : ''
const q = token ? `?ssetoken=${encodeURIComponent(token)}` : ''
const id = media.id
if (id == null || exerciseId == null) return null
return `${API_BASE}/api/exercises/${exerciseId}/media/${id}/file${q}`
}
/**
* Direkt-URL für Archiv-Asset (Picker/Vorschau ohne exercise_media-Zeile).
* @param {number|string} assetId media_assets.id
* @returns {string|null}
*/
export function resolveMediaAssetFileUrl(assetId) {
if (assetId == null) return null
const token = typeof localStorage !== 'undefined' ? localStorage.getItem('authToken') : ''
const q = token ? `?ssetoken=${encodeURIComponent(token)}` : ''
return `${API_BASE}/api/media-assets/${assetId}/file${q}`
}