diff --git a/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md b/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md index 1806218..0f8026d 100644 --- a/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md +++ b/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md @@ -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. --- diff --git a/.claude/docs/working/PRODUCTION_READINESS_AUDIT_2026-05.md b/.claude/docs/working/PRODUCTION_READINESS_AUDIT_2026-05.md index 24b1c40..79f4491 100644 --- a/.claude/docs/working/PRODUCTION_READINESS_AUDIT_2026-05.md +++ b/.claude/docs/working/PRODUCTION_READINESS_AUDIT_2026-05.md @@ -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`. diff --git a/backend/main.py b/backend/main.py index 6ed81ab..64e3652 100644 --- a/backend/main.py +++ b/backend/main.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 /). +# 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 diff --git a/backend/routers/clubs.py b/backend/routers/clubs.py index b949ff0..2ea9c93 100644 --- a/backend/routers/clubs.py +++ b/backend/routers/clubs.py @@ -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 ────────────────────────────────────────────────────────── diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py index cc28842..79ddcd6 100644 --- a/backend/routers/exercises.py +++ b/backend/routers/exercises.py @@ -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 /). + 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, diff --git a/backend/routers/maturity_models.py b/backend/routers/maturity_models.py index 1bd528f..f34dcfb 100644 --- a/backend/routers/maturity_models.py +++ b/backend/routers/maturity_models.py @@ -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) diff --git a/backend/tenant_context.py b/backend/tenant_context.py index 7181a5a..aac56e0 100644 --- a/backend/tenant_context.py +++ b/backend/tenant_context.py @@ -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 / 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, diff --git a/backend/tests/test_exercise_media_download.py b/backend/tests/test_exercise_media_download.py new file mode 100644 index 0000000..faee1a7 --- /dev/null +++ b/backend/tests/test_exercise_media_download.py @@ -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) diff --git a/docker-compose.yml b/docker-compose.yml index 78084c4..b41c62e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 3e8daeb..0b3a178 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -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; diff --git a/frontend/src/components/ExerciseFullContent.jsx b/frontend/src/components/ExerciseFullContent.jsx index 76c441c..881c8c3 100644 --- a/frontend/src/components/ExerciseFullContent.jsx +++ b/frontend/src/components/ExerciseFullContent.jsx @@ -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 ( @@ -37,7 +29,7 @@ function MediaBlock({ media }) { ) } - 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 {m.title || m.original_filename || m.media_type} {m.description && {m.description}} - + ))} diff --git a/frontend/src/pages/ExerciseDetailPage.jsx b/frontend/src/pages/ExerciseDetailPage.jsx index 59336b1..1827c9f 100644 --- a/frontend/src/pages/ExerciseDetailPage.jsx +++ b/frontend/src/pages/ExerciseDetailPage.jsx @@ -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 ( @@ -39,7 +31,7 @@ function MediaBlock({ media }) { ) } - 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() { {m.title || m.original_filename || m.media_type} {m.description && {m.description}} - + ))} diff --git a/frontend/src/utils/exerciseMediaUrl.js b/frontend/src/utils/exerciseMediaUrl.js new file mode 100644 index 0000000..25ddff0 --- /dev/null +++ b/frontend/src/utils/exerciseMediaUrl.js @@ -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}` +}
{m.description}