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
**Stand:** 2026-05-05
**Version (Code):** 0.8.10 (`backend/version.py`, APP_VERSION)
**DB-Schema-Version:** `20260505037`
**Stand:** 2026-05-07
**Version (Code):** 0.8.48 (`backend/version.py`, APP_VERSION)
**DB-Schema-Version:** `20260507045` (u. a. `media_assets`, `platform_media_storage`)
**Branch:** develop
---
## 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.
- ✅ 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) · Medien-Norm: [`technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`](technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md)
**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.
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** | ✅ | 🔲 |
| **032034** | **Progressionsgraph Übung→Übung** | ✅ | 🔲 |
| **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
- [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
- [ ] Responsive Design / Dark Mode / PWA
- [ ] 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
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
@ -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 |
| Search & Filter | `technical/SEARCH_FILTER_SPEC.md` | 2026-04-27 | ✅ Aktualisiert (Liste UX) |
| Media Upload | `technical/MEDIA_UPLOAD_SPEC.md` | 2026-04-27 | ✅ Aktualisiert (Limits) |
| Projektstatus | `PROJECT_STATUS.md` | 2026-05-05 | ✅ Diese Datei |
| Medien-Archiv & Lifecycle | `technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` | 2026-05-07 | ✅ §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
- `.claude/docs/technical/MULTI_TENANCY_RBAC_ARCHITECTURE.md` übergeordnetes Zielbild & Begriffe.
- `.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` verbindliche Domänenregeln für **Medien-Assets** (gleiche Sichtbarkeit wie Übungen, Promotion-Kopplung, Copyright, Papierkorb/Lebenszyklus, externer Speicher). Bei Widerspruch zur Sichtbarkeits-Tabelle in §3 dieses Dokuments: §3 für Enums/`library_content_*`-Semantik, Medien-Spez für Asset-spezifische Zusatzregeln.
- `backend/club_tenancy.py` bestehende Bausteine (`assert_club_member`, `exercise_visible_to_profile`, …); Ziel ist Deren schrittweise Zusammenführung unter die neue Zugriffsschicht ohne Big-Bang.
---
**Letzte Aktualisierung:** 2026-05-06
**Letzte Aktualisierung:** 2026-05-07

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
**Ä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

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_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 | ü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 |
| training_planning | alle geschützten Endpoints | ja | `get_tenant_context` | ja | Vorlagen-Liste wie Übungen; POST Vorlage Default club_id |
| training_framework_programs | alle geschützten Endpoints | ja | `get_tenant_context` | ja | Liste + POST Default club_id |
| admin_users | `GET /api/admin/users` | Plattform | `require_auth` | Admin-Rolle | EXEMPT `check_access_layer_hints.py` |
| platform_media_storage | `GET/PUT /api/admin/platform-media-storage` | Plattform | `require_auth` | GET: `is_platform_admin`; PUT: nur `superadmin` | Relativer Pfad unter `MEDIA_ROOT`; keine Secrets; EXEMPT wie admin_users |
| 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 |
| 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 |
| 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 |
| 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.
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)
**Verbindlicher Rahmen:** `.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`
**Medien-Assets (Archiv, Papierkorb, Promotion, Copyright, externer Speicher):** `.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`
**Fortlaufendes Inventar:** `.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md`
**Definition of Done für neue oder geänderte geschützte APIs**, sobald Daten **Verein**, **Sichtbarkeit** oder **mandantenbezogene Listen** betreffen:

View File

@ -38,6 +38,7 @@ jobs:
pip install -r /app/requirements-dev.txt &&
cd /app &&
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
"

View File

@ -11,6 +11,7 @@
> | Lessons Learned | `.claude/rules/LESSONS_LEARNED.md` |
> | Setup-Dokument | `.claude/docs/working/SHINKAN_PROJECT_SETUP.md` |
> | Anforderungen | `.claude/docs/functional/SHINKAN_REQUIREMENTS.md` |
> | Medien-Archiv, Lifecycle, Promotion | `.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` |
## Projekt-Übersicht
@ -105,7 +106,7 @@ Kurz (Stand 2026-05-05): App **0.8.10**, DBSchemaVersion **`20260505037`**
- `exercises` - Übungen (Kernobjekt)
- `exercise_variants` - Übungsvarianten
- `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:**
- `training_plan_templates` + Sektionsvorlagen — Mikrovorlage pro Einheit (Migration **031**)
@ -200,6 +201,10 @@ ALLOWED_ORIGINS=https://shinkan.jinkendo.de
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
### Must-Do:

View File

@ -13,8 +13,6 @@ import bcrypt
from db import get_db, get_cursor
print("[AUTH.PY] Module loaded - require_auth_flexible will be defined")
def hash_pin(pin: str) -> str:
"""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")
def is_superadmin(role: Optional[str]) -> bool:
return (role or "").lower() == "superadmin"
def club_ids_for_profile(cur, profile_id: int) -> Set[int]:
cur.execute(
"""
@ -125,6 +129,24 @@ def memberships_with_roles(cur, profile_id: int, active_only: bool = True) -> Li
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"})

View File

@ -6,7 +6,7 @@ Trainer- und Vereinsplattform für Kampfsport-Trainingsplanung
from pathlib import Path
from typing import Optional
from fastapi import FastAPI
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
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
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
# Lokal ohne DB / nur Tests: SKIP_DB_MIGRATE=1
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
# 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
app = FastAPI(
title="Shinkan Jinkendo API",
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)
@ -62,7 +85,13 @@ app.add_middleware(
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)
@app.get("/api/version")
@ -132,7 +161,7 @@ def health_ready():
migration_count = 0
complete = bool(err is None and all(tables.get(t) for t in REQUIRED))
return {
body = {
"status": "ready" if complete else "degraded",
"database": err is None,
"detail": err,
@ -140,21 +169,30 @@ def health_ready():
"tables": tables,
"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
@app.get("/")
def read_root():
"""Root endpoint - API info"""
return {
out = {
"app": "Shinkan Jinkendo API",
"version": APP_VERSION,
"docs": "/docs",
"health": "/health"
"health": "/health",
}
if _expose_docs:
out["docs"] = "/docs"
return out
# Register routers
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, skills, training_planning, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, platform_media_storage, 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(profiles.router)
@ -164,6 +202,8 @@ app.include_router(clubs.router)
app.include_router(club_memberships.router)
app.include_router(club_join_requests.router)
app.include_router(admin_users.router)
app.include_router(platform_media_storage.router)
app.include_router(media_assets.router)
app.include_router(skills.router)
app.include_router(training_planning.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_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"))
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__":
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()]
# ── 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")
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
role = tenant.global_role
with get_db() as conn:
@ -96,7 +97,12 @@ def club_members_directory(club_id: int, tenant: TenantContext = Depends(get_ten
""",
(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 ──────────────────────────────────────────────────────────

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).
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.
"""
from datetime import datetime, timezone
@ -534,6 +535,7 @@ def list_maturity_models(
@router.get("/maturity-models/{model_id}")
def get_maturity_model(model_id: int, session: dict = Depends(require_auth)):
_require_admin(session)
with get_db() as conn:
cur = get_cursor(conn)
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"})
# ── 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 ──────────────────────────────────────────────────────
@router.get("/profiles/me")
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")
count = cur.fetchone()['count']
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']:
cur.execute(f"DELETE FROM {table} WHERE profile_id=%s", (pid,))
# Mitai-Überbleibsel: nur löschen, wenn die Tabelle im Schema existiert (Shinkan-DB ohne diese Tabellen).
_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,))
return {"ok": True}
# ── Current User Profile ──────────────────────────────────────────────────────
@router.get("/profile")
def get_active_profile(x_profile_id: Optional[str] = Header(default=None), session: dict = Depends(require_auth)):
"""Legacy endpoint returns active profile."""
pid = get_pid(x_profile_id)
def get_active_profile(session: dict = Depends(require_auth)):
"""Legacy-Alias für das eingeloggte Profil — immer Session, kein X-Profile-Id (SECURITY: kein IDOR)."""
pid = str(session["profile_id"])
return profile_document(pid)
@router.put("/profile")
def update_active_profile(
p: ProfileUpdate,
x_profile_id: Optional[str] = Header(default=None),
tenant: TenantContext = Depends(get_tenant_context),
):
"""Update current user's profile."""
pid = get_pid(x_profile_id)
"""Profil des eingeloggten Nutzers aktualisieren — dieselbe Quelle wie GET /profile."""
pid = str(tenant.profile_id)
return _run_profile_update(pid, p, tenant)

View File

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

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 auth import require_auth
from auth import require_auth, require_auth_flexible
from club_tenancy import is_platform_admin, memberships_with_roles
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(
cur,
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
APP_VERSION = "0.8.40"
BUILD_DATE = "2026-05-06"
DB_SCHEMA_VERSION = "20260506043"
APP_VERSION = "0.8.49"
BUILD_DATE = "2026-05-07"
DB_SCHEMA_VERSION = "20260507045"
MODULE_VERSIONS = {
"auth": "1.2.1", # Login-Rate-Limit 30/minute pro IP (vorher 5/minute)
@ -12,10 +12,12 @@ MODULE_VERSIONS = {
"club_memberships": "1.0.1", # Depends(get_tenant_context)
"club_join_requests": "1.0.1", # Depends(get_tenant_context)
"admin_users": "1.0.0", # GET /api/admin/users
"platform_media_storage": "1.0.0", # GET/PUT /api/admin/platform-media-storage (Superadmin-Pfad unter MEDIA_ROOT)
"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",
"skills": "0.1.0",
"methods": "0.1.0",
"exercises": "2.10.0", # GET /exercises: focus_area_must_include/exclude_ids, focus_only_without_focus_areas; UI +/- Fokusregeln
"exercises": "2.16.0", # §4.2 official: angehängte media_assets + Copyright (PUT + bulk-metadata)
"training_units": "0.2.0",
"training_programs": "0.1.0",
"planning": "0.8.1", # Vorlagen Leseprüfung library_content_visible_to_profile
@ -27,6 +29,79 @@ MODULE_VERSIONS = {
}
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",
"date": "2026-05-06",

View File

@ -8,8 +8,9 @@ services:
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- 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:
- "5434:5432"
- "127.0.0.1:5434:5432"
restart: unless-stopped
networks:
- shinkan-network

View File

@ -4,6 +4,11 @@ server {
root /usr/share/nginx/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
# verringert sporadische 502, wenn sich nur die Backend-Container-IP geändert hat.
resolver 127.0.0.11 valid=10s ipv6=off;
@ -26,6 +31,8 @@ server {
}
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;
proxy_pass http://$docker_backend_svc:8000$request_uri;
proxy_http_version 1.1;
@ -42,8 +49,10 @@ server {
proxy_set_header Host $host;
}
# SPA routing - serve index.html for all routes
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;
}

View File

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

View File

@ -5454,3 +5454,516 @@ a.analysis-split__nav-item {
-webkit-overflow-scrolling: touch;
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 { TreePine, FolderTree, Download, Grid3x3, Users } from 'lucide-react'
import { TreePine, FolderTree, Download, Grid3x3, Users, Images } from 'lucide-react'
/**
* Admin-Seiten-Navigation (horizontal)
@ -11,6 +11,7 @@ export default function AdminPageNav() {
{ to: '/admin/users', label: 'Nutzer', icon: Users },
{ to: '/admin/maturity-models', label: 'Fähigkeitsmatrix', icon: Grid3x3 },
{ to: '/admin/catalogs', label: 'Kataloge', icon: FolderTree },
{ to: '/media', label: 'Medien', icon: Images },
{ to: '/admin/mediawiki-import', label: 'Wiki-Import', icon: Download }
]

View File

@ -4,15 +4,7 @@
import React from 'react'
import { Link } from 'react-router-dom'
import { sanitizeTrainerHtml } from '../utils/htmlUtils'
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}`
}
import { resolveExerciseMediaFileUrl } from '../utils/exerciseMediaUrl'
function HtmlBlock({ html, className = '' }) {
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) {
return (
<div style={{ marginTop: '0.5rem' }}>
@ -37,7 +29,7 @@ function MediaBlock({ media }) {
</div>
)
}
const src = resolveMediaUrl(media.file_path)
const src = resolveExerciseMediaFileUrl(exerciseId, media)
if (!src) return null
if (media.media_type === 'image' || (media.mime_type && media.mime_type.startsWith('image/'))) {
return (
@ -121,6 +113,10 @@ export default function ExerciseFullContent({ exercise, loading, error, exercise
if (!exercise) return null
const meta = metaParts(exercise)
const visibleMedia = (exercise.media || []).filter((m) => {
const lc = String(m.asset_lifecycle_state || 'active').toLowerCase()
return lc !== 'trash_hidden'
})
return (
<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} />
</section>
)}
{(exercise.media || []).length > 0 && (
{visibleMedia.length > 0 && (
<section className="card" style={{ marginTop: '10px', padding: '12px 14px' }}>
<h3 style={{ fontSize: '0.78rem', textTransform: 'uppercase', color: 'var(--text3)', marginBottom: '8px' }}>
Medien
</h3>
{exercise.media.map((m) => (
{visibleMedia.map((m) => (
<div key={m.id} style={{ marginBottom: '12px' }}>
<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>}
<MediaBlock media={m} />
<MediaBlock media={m} exerciseId={exercise.id ?? exerciseId} />
</div>
))}
</section>

View File

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

View File

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

View File

@ -1,18 +1,10 @@
import React, { useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import api from '../utils/api'
import { resolveExerciseMediaFileUrl } from '../utils/exerciseMediaUrl'
import { sanitizeTrainerHtml } from '../utils/htmlUtils'
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 = '' }) {
if (!html || !String(html).trim()) return null
const safe = sanitizeTrainerHtml(html)
@ -24,7 +16,7 @@ function HtmlBlock({ html, className = '' }) {
)
}
function MediaBlock({ media }) {
function MediaBlock({ media, exerciseId }) {
if (media.embed_url) {
return (
<div style={{ marginTop: '0.5rem' }}>
@ -39,7 +31,7 @@ function MediaBlock({ media }) {
</div>
)
}
const src = resolveMediaUrl(media.file_path)
const src = resolveExerciseMediaFileUrl(exerciseId, media)
if (!src) return null
if (media.media_type === 'image' || (media.mime_type && media.mime_type.startsWith('image/'))) {
return (
@ -159,6 +151,10 @@ function ExerciseDetailPage() {
if (!exercise) return null
const meta = metaParts(exercise)
const visibleMedia = (exercise.media || []).filter((m) => {
const lc = String(m.asset_lifecycle_state || 'active').toLowerCase()
return lc !== 'trash_hidden'
})
return (
<div className="exercise-detail-shell" style={{ padding: '12px 12px 24px' }}>
@ -219,14 +215,19 @@ function ExerciseDetailPage() {
</section>
)}
{(exercise.media || []).length > 0 && (
{visibleMedia.length > 0 && (
<section className="card exercise-detail-section">
<h2>Medien</h2>
{exercise.media.map((m) => (
{visibleMedia.map((m) => (
<div key={m.id} style={{ marginBottom: '1.25rem' }}>
<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>}
<MediaBlock media={m} />
<MediaBlock media={m} exerciseId={exercise.id} />
</div>
))}
</section>

View File

@ -1,10 +1,75 @@
import React, { useEffect, useState, useRef } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import React, { useEffect, useState, useRef, useMemo } from 'react'
import { useNavigate, useParams, Link } from 'react-router-dom'
import api, { buildExerciseApiPayload } from '../utils/api'
import { resolveExerciseMediaFileUrl, resolveMediaAssetFileUrl } from '../utils/exerciseMediaUrl'
import RichTextEditor from '../components/RichTextEditor'
import ExerciseProgressionGraphPanel from '../components/ExerciseProgressionGraphPanel'
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 = [
{ value: '', label: '—' },
{ value: 'niedrig', label: 'niedrig' },
@ -367,6 +432,50 @@ function ExerciseFormPage() {
const [mediaContext, setMediaContext] = useState('ablauf')
const [embedUrl, setEmbedUrl] = 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(() => {
let cancelled = false
@ -523,7 +632,54 @@ function ExerciseFormPage() {
setSaving(true)
try {
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)
setMediaList(ex.media || [])
setVariants((ex.variants || []).map(apiVariantToRow))
@ -545,6 +701,28 @@ function ExerciseFormPage() {
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 () => {
if (!exerciseId || !mediaFile) {
alert('Datei wählen')
@ -563,7 +741,44 @@ function ExerciseFormPage() {
setMediaTitle('')
await refreshMedia()
} 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) => {
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 {
await api.deleteExerciseMedia(exerciseId, mid)
const res = await api.deleteExerciseMedia(exerciseId, mid)
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) {
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 () => {
if (!exerciseId) return
const ex = await api.getExercise(exerciseId)
@ -1170,6 +1439,22 @@ function ExerciseFormPage() {
<p style={{ color: 'var(--text2)', fontSize: '13px' }}>
Datei oder Embed (YouTube, Vimeo, Instagram, TikTok). Max. 10 pro Übung.
</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>
<label className="form-label">Datei</label>
@ -1230,30 +1515,308 @@ function ExerciseFormPage() {
</div>
</div>
{mediaList.length > 0 && (
<ul style={{ marginTop: '12px', paddingLeft: '1.2rem' }}>
{mediaList.map((m) => (
<li key={m.id} style={{ marginBottom: '6px' }}>
{m.title || m.original_filename || m.media_type}{' '}
{m.embed_platform ? `(${m.embed_platform})` : ''}
<ul style={{ marginTop: '12px', paddingLeft: '0', listStyle: 'none' }}>
{mediaList.map((m, idx) => (
<li
key={m.id}
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
type="button"
className="btn"
className="btn btn-secondary"
style={{
marginLeft: '8px',
fontSize: '11px',
padding: '2px 8px',
background: 'var(--danger)',
color: '#fff',
border: 'none',
marginTop: '8px',
fontSize: '12px',
padding: '6px 12px',
}}
onClick={() => handleDeleteMedia(m.id)}
>
Löschen
Aus Übung entfernen
</button>
</div>
</li>
))}
</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>
)}

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')
}
/** Medien-Speicher (MEDIA_ROOT + relativer Unterordner) — GET: admin/superadmin, PUT: nur superadmin. */
export async function getPlatformMediaStorage() {
return request('/api/admin/platform-media-storage')
}
export async function putPlatformMediaStorage(payload) {
return request('/api/admin/platform-media-storage', {
method: 'PUT',
body: JSON.stringify(payload),
})
}
export async function updateProfile(profileId, data) {
return request(`/api/profiles/${profileId}`, {
method: 'PUT',
@ -394,7 +406,7 @@ export async function listExercises(filters = {}) {
}
/** 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 goalHtml = formData.goal || ''
@ -446,6 +458,7 @@ export function buildExerciseApiPayload(formData) {
visibility: formData.visibility || 'private',
status: formData.status || 'draft',
club_id: formData.club_id ?? null,
...extras,
}
}
@ -461,12 +474,29 @@ export async function uploadExerciseMedia(exerciseId, formData) {
if (!response.ok) {
const err = await response.json().catch(() => ({ detail: 'Unknown error' }))
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 =
typeof d === 'string'
? d
: d != null
? JSON.stringify(d)
: `HTTP ${response.status}`
: d != null && typeof d === 'object' && typeof d.message === 'string'
? d.message
: d != null
? JSON.stringify(d)
: `HTTP ${response.status}`
throw new Error(msg)
}
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) {
return request(`/api/exercises/${id}`)
}
@ -502,10 +580,45 @@ export async function createExercise(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',
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`). */
@ -1191,6 +1304,12 @@ export const api = {
updateExerciseMedia,
deleteExerciseMedia,
reorderExerciseMedia,
postMediaAssetLifecycle,
listMediaAssets,
patchMediaAsset,
bulkMediaLifecycle,
bulkPatchMediaAssets,
attachExerciseMediaFromAsset,
listExerciseProgressionGraphs,
getExerciseProgressionGraph,
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}`
}