Medienmanager und Sicherheitsupdate #21
|
|
@ -11,6 +11,7 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe A–C.
|
|||
| 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 | `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 | |
|
||||
| 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 |
|
||||
|
|
@ -32,6 +33,7 @@ Letzte Änderung: 2026-05-06 — MULTI_TENANCY §3 Gap-Analyse aktualisiert; Aud
|
|||
### 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 2):** Geschützte Übungs-Mediendatei-API; `/media`-Static optional; Mitgliederverzeichnis E-Mail eingeschränkt; GET maturity-by-id nur Admin; Postgres `127.0.0.1` im Prod-Compose.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -14,9 +14,8 @@
|
|||
| 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) | **Geplant** (Phase 2): geschützter Download oder signierte URLs |
|
||||
| OPS-01 | PostgreSQL-Hostport in `docker-compose.yml` | **Dokumentiert:** nur intern binden / Firewall |
|
||||
| OPS-02 | Fehlende Security-Header (CSP, X-Frame-Options, …) | **Teilweise:** nginx-Basis-Header ergänzt |
|
||||
| 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
|
||||
|
||||
|
|
@ -24,12 +23,15 @@
|
|||
|----|--------|--------|
|
||||
| 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`) |
|
||||
| CON-03 | Sparten (`division`) vs. Zielbild ACCESS_LAYER | **Offen** (Roadmap Stufe D) |
|
||||
| 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
|
||||
### A.3 Niedrig / Offen
|
||||
|
||||
| ID | Befund | Status |
|
||||
|----|--------|--------|
|
||||
| CON-03 | Sparten (`division`) vs. Zielbild ACCESS_LAYER | **Offen** (Roadmap Stufe D) |
|
||||
| OPS-02 | CSP / restliche Browser-Härtung | **Teilweise** (Basis-Header nginx; CSP offen) |
|
||||
| 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 |
|
||||
|
||||
|
|
@ -46,12 +48,12 @@
|
|||
5. Profil-Löschung: optionale Tabellen nur bei Existenz.
|
||||
6. Frontend: Admin-Kataloge Profilliste über `api.js`.
|
||||
|
||||
### Phase 2 — Kurzfristig (vor breitem Go-Live)
|
||||
### Phase 2 — Kurzfristig (vor breitem Go-Live) ✅ Hauptpunkte umgesetzt (2026-05-07)
|
||||
|
||||
1. **Medien:** Zugriff an Übungs-/Governance-Rechte koppeln (kein anonymes `/media/...` für sensible Objekte).
|
||||
2. **Mitgliederverzeichnis:** E-Mail-Sichtbarkeit rollenbasierend oder Richtlinie/DSE.
|
||||
3. **DB-Port:** Prod-Compose ohne öffentliche DB-Port-Publikation oder strikte Bindung.
|
||||
4. `GET /api/maturity-models/{id}`: Leserechte mit Admin/Matrix-Politik abstimmen.
|
||||
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
|
||||
|
||||
|
|
@ -76,7 +78,12 @@
|
|||
| `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) |
|
||||
|
||||
---
|
||||
|
||||
**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`.
|
||||
|
|
|
|||
|
|
@ -205,10 +205,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
|
||||
|
|
|
|||
|
|
@ -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 ──────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ from pathlib import Path
|
|||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query, UploadFile, File, Form
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
from db import get_db, get_cursor, r2d
|
||||
|
|
@ -23,7 +24,7 @@ from club_tenancy import (
|
|||
is_platform_admin,
|
||||
library_content_visible_to_profile,
|
||||
)
|
||||
from tenant_context import TenantContext, get_tenant_context, library_content_visibility_sql
|
||||
from tenant_context import TenantContext, get_tenant_context, get_tenant_context_flexible, library_content_visibility_sql
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -303,6 +304,36 @@ def _detect_embed_platform(url: str) -> Optional[str]:
|
|||
return None
|
||||
|
||||
|
||||
def _fetch_exercise_governance_row(cur, exercise_id: int) -> Optional[dict]:
|
||||
cur.execute(
|
||||
"SELECT id, visibility, club_id, created_by FROM exercises WHERE id = %s",
|
||||
(exercise_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return r2d(row) if row else None
|
||||
|
||||
|
||||
def _assert_can_view_exercise_media(
|
||||
cur,
|
||||
exercise_id: int,
|
||||
tenant: TenantContext,
|
||||
) -> dict:
|
||||
"""403 wenn Übung für den Nutzer nicht lesbar (wie GET /exercises/{id})."""
|
||||
ex = _fetch_exercise_governance_row(cur, exercise_id)
|
||||
if not ex:
|
||||
raise HTTPException(status_code=404, detail="Übung nicht gefunden")
|
||||
if not library_content_visible_to_profile(
|
||||
cur,
|
||||
tenant.profile_id,
|
||||
(ex.get("visibility") or "").strip().lower(),
|
||||
ex.get("club_id"),
|
||||
ex.get("created_by"),
|
||||
tenant.global_role,
|
||||
):
|
||||
raise HTTPException(status_code=403, detail="Keine Berechtigung für diese Übung")
|
||||
return ex
|
||||
|
||||
|
||||
def _assert_can_edit_exercise(cur, exercise_id: int, profile_id: int):
|
||||
cur.execute("SELECT created_by FROM exercises WHERE id = %s", (exercise_id,))
|
||||
row = cur.fetchone()
|
||||
|
|
@ -1801,6 +1832,39 @@ def _fetch_media_row(cur, exercise_id: int, media_id: int) -> Optional[dict]:
|
|||
return r2d(row) if row else None
|
||||
|
||||
|
||||
@router.get("/exercises/{exercise_id}/media/{media_id}/file")
|
||||
def download_exercise_media_file(
|
||||
exercise_id: int,
|
||||
media_id: int,
|
||||
tenant: TenantContext = Depends(get_tenant_context_flexible),
|
||||
):
|
||||
"""
|
||||
Dateiauslieferung mit Governance wie GET Übung — Auth via X-Auth-Token oder ?ssetoken (für <img>/<video>).
|
||||
Direktes /media/... ohne Token ist nicht vorgesehen (kein anonymes Hosting).
|
||||
"""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
_assert_can_view_exercise_media(cur, exercise_id, tenant)
|
||||
media = _fetch_media_row(cur, exercise_id, media_id)
|
||||
if not media:
|
||||
raise HTTPException(status_code=404, detail="Medium nicht gefunden")
|
||||
if (media.get("embed_url") or "").strip():
|
||||
raise HTTPException(status_code=400, detail="Embed-Medien haben keine Datei-URL")
|
||||
|
||||
fp = media.get("file_path")
|
||||
abs_p = _abs_media_path(fp) if fp else None
|
||||
if not abs_p or not abs_p.is_file():
|
||||
raise HTTPException(status_code=404, detail="Datei nicht gefunden")
|
||||
|
||||
mime = media.get("mime_type") or "application/octet-stream"
|
||||
fname = media.get("original_filename") or abs_p.name
|
||||
return FileResponse(
|
||||
path=str(abs_p.resolve()),
|
||||
media_type=mime,
|
||||
filename=str(fname),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/exercises/{exercise_id}/media", status_code=201)
|
||||
async def upload_exercise_media(
|
||||
exercise_id: int,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
75
backend/tests/test_exercise_media_download.py
Normal file
75
backend/tests/test_exercise_media_download.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -31,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;
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
@ -178,7 +170,7 @@ export default function ExerciseFullContent({ exercise, loading, error, exercise
|
|||
<div key={m.id} style={{ marginBottom: '12px' }}>
|
||||
<strong style={{ fontSize: '0.9rem' }}>{m.title || m.original_filename || m.media_type}</strong>
|
||||
{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>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
@ -226,7 +218,7 @@ function ExerciseDetailPage() {
|
|||
<div key={m.id} style={{ marginBottom: '1.25rem' }}>
|
||||
<strong style={{ fontSize: '15px' }}>{m.title || m.original_filename || m.media_type}</strong>
|
||||
{m.description && <p style={{ color: 'var(--text2)', fontSize: '0.9rem' }}>{m.description}</p>}
|
||||
<MediaBlock media={m} />
|
||||
<MediaBlock media={m} exerciseId={exercise.id} />
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
|
|
|||
19
frontend/src/utils/exerciseMediaUrl.js
Normal file
19
frontend/src/utils/exerciseMediaUrl.js
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
/** 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}`
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user