diff --git a/ROADMAP.md b/ROADMAP.md index 4df553d..24f6cbd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -332,6 +332,11 @@ auffiel, in einem Rutsch. Alles gebaut, Tests dabei, Ampel-Blocker behoben. Claude and KrBrZ". - [x] README: Media-Server, Benachrichtigungen, UHD-Arbeitsverzeichnis, „Rippy woanders bereitstellen" (beliebiger Docker-Host). +- [x] **Download-Knopf für fertige Rips** (Wunsch aus der Übernahme-Session): + GET /jobs/{id}/files (+ Datei-Stream, Pfad-Validierung strikt unter + /app/media inkl. realpath-Check), Download-Knopf in der Aktion-Spalte, + Dateiliste mit Größen im Job-Detail-Popup — vorher kam man an fertige + MKVs nur per scp. nginx hatte proxy_buffering off schon (SSE). **Offen aus dieser Runde:** nichts — Rest siehe Etappe 12 und Ideen unten. diff --git a/SAVEPOINT.md b/SAVEPOINT.md index 71d4055..0fab9f6 100644 --- a/SAVEPOINT.md +++ b/SAVEPOINT.md @@ -29,6 +29,10 @@ Zwei Ursachen, beide behoben: **MakeMKV-Beta-Key im UI pflegbar** — gilt ab dem nächsten Rip, ohne Rebuild. - **Job-Detail-Popup** (Klick auf Titel in „Neueste Jobs"): Poster, Jahr, Beschreibung, Genres, Ablagepfad, Fehler (GET /jobs/{id}/detail). +- **Download fertiger Rips im Browser**: „Download"-Knopf bei fertigen Jobs + (Aktion-Spalte) → Dateiliste mit Größen im Detail-Popup, Stream via + GET /jobs/{id}/files/{name} (Pfad-Validierung strikt unter /app/media, + realpath-Check gegen Symlink-Ausbrüche) — vorher nur per scp erreichbar. - **UI-Feedback**: Toast-System (ToastContext, ploppt oben rechts), drehende Refresh-Knöpfe, Logs-Pills um Warnung/Fehler ergänzt, Datei-Browser zeigt jetzt auch DATEIEN (grau, mit Größe) — der „leere" Bluray-Ordner war voll. diff --git a/docker/api/main.py b/docker/api/main.py index 2c6edfa..ffc9336 100644 --- a/docker/api/main.py +++ b/docker/api/main.py @@ -1,6 +1,6 @@ from fastapi import FastAPI, HTTPException, Request, Response from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import StreamingResponse +from fastapi.responses import FileResponse, StreamingResponse from pydantic import BaseModel from typing import List, Optional, Dict from pathlib import Path @@ -305,6 +305,78 @@ async def get_job_detail(job_id: str): return detail +def _sicherer_dateiname(name: str) -> bool: + """Pure Funktion (testbar): nur nackte Dateinamen, keine Pfad-Tricks.""" + return bool(name) and "/" not in name and "\\" not in name and not name.startswith(".") + + +def _job_ausgabeordner(job: dict) -> str: + """Validierter Ausgabeordner eines Jobs — strikt unter /app/media.""" + ausgabe = os.path.normpath(job.get("output_path") or "") + if not ausgabe.startswith(MEDIA_ROOT): + raise HTTPException(status_code=404, detail="Job hat keinen Ausgabeordner unter /app/media") + return ausgabe + + +@app.get("/jobs/{job_id}/files") +async def list_job_files(job_id: str): + """Dateien eines fertigen Jobs — fürs Download-Menü im Dashboard. + + Vorher kam man an fertige MKVs nur per scp auf die VM. + """ + job = await asyncio.to_thread(db.get_job, job_id) + if not job: + raise HTTPException(status_code=404, detail="Job nicht gefunden") + ausgabe = _job_ausgabeordner(job) + + def liste(): + try: + eintraege = sorted(os.listdir(ausgabe)) + except OSError: + return None + dateien = [] + for name in eintraege: + pfad = os.path.join(ausgabe, name) + if os.path.isfile(pfad): + try: + groesse_mb = round(os.path.getsize(pfad) / 1024**2, 1) + except OSError: + groesse_mb = None + dateien.append({"name": name, "size_mb": groesse_mb}) + return dateien + + dateien = await asyncio.to_thread(liste) + if dateien is None: + raise HTTPException( + status_code=404, + detail="Ausgabeordner nicht lesbar — Job noch nicht fertig oder Ziel ausgehängt?", + ) + return {"job_id": job_id, "output_path": ausgabe, "files": dateien} + + +@app.get("/jobs/{job_id}/files/{dateiname}") +async def download_job_file(job_id: str, dateiname: str): + """Streamt EINE Datei eines Jobs zum Browser (Download-Knopf). + + Pfad-Validierung strikt: nackter Dateiname, realpath muss unter + /app/media bleiben (kein ..-Ausbruch, kein Symlink nach draußen). + """ + job = await asyncio.to_thread(db.get_job, job_id) + if not job: + raise HTTPException(status_code=404, detail="Job nicht gefunden") + ausgabe = _job_ausgabeordner(job) + if not _sicherer_dateiname(dateiname): + raise HTTPException(status_code=422, detail="Ungültiger Dateiname") + pfad = os.path.join(ausgabe, dateiname) + + def pruefe(): + return os.path.isfile(pfad) and os.path.realpath(pfad).startswith(MEDIA_ROOT) + + if not await asyncio.to_thread(pruefe): + raise HTTPException(status_code=404, detail="Datei nicht gefunden") + return FileResponse(pfad, filename=dateiname, media_type="application/octet-stream") + + @app.get("/storage-targets") async def storage_targets(): """Verfügbare Ablageziele: Verzeichnisse unter /app/media inkl. Mounts. diff --git a/docker/api/test_api_smoke.py b/docker/api/test_api_smoke.py index 0eba54f..28498f5 100644 --- a/docker/api/test_api_smoke.py +++ b/docker/api/test_api_smoke.py @@ -22,6 +22,18 @@ def test_main_importierbar_und_routen_verdrahtet(): assert pfad in routen, f"Route {pfad} fehlt" +def test_dateiname_validierung_blockt_pfad_tricks(): + """Download-Endpoint: nur nackte Dateinamen — kein .., kein Slash, kein Dotfile.""" + from main import _sicherer_dateiname + + assert _sicherer_dateiname("film.mkv") is True + assert _sicherer_dateiname("../../etc/passwd") is False + assert _sicherer_dateiname("a/b.mkv") is False + assert _sicherer_dateiname("a\\b.mkv") is False + assert _sicherer_dateiname(".versteckt") is False + assert _sicherer_dateiname("") is False + + def test_worker_task_name_passt_zum_celery_client(): """API schickt an 'worker.tasks.rip_disc' — der Name ist Vertrag mit dem Worker.""" import inspect diff --git a/docker/ui/src/components/JobDetailModal.tsx b/docker/ui/src/components/JobDetailModal.tsx index b0bd839..198beb1 100644 --- a/docker/ui/src/components/JobDetailModal.tsx +++ b/docker/ui/src/components/JobDetailModal.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react' -import { X, Film, Loader2, FolderOpen, AlertCircle } from 'lucide-react' +import { X, Film, Loader2, FolderOpen, AlertCircle, Download } from 'lucide-react' import { api } from '../lib/api' import { useDarkMode } from '../context/ThemeContext' @@ -48,19 +48,34 @@ const STATUS_LABEL: Record = { failed: 'Fehler', } +interface JobDatei { + name: string + size_mb: number | null +} + export default function JobDetailModal({ jobId, onClose }: { jobId: string | null, onClose: () => void }) { const [detail, setDetail] = useState(null) + const [dateien, setDateien] = useState(null) const [laedt, setLaedt] = useState(false) const { theme } = useDarkMode() useEffect(() => { if (!jobId) { setDetail(null) + setDateien(null) return } setLaedt(true) api.get(`/jobs/${jobId}/detail`) - .then(r => setDetail(r.data)) + .then(r => { + setDetail(r.data) + // Fertige Jobs: Dateiliste für die Download-Knöpfe nachladen + if (r.data?.status === 'completed') { + api.get(`/jobs/${jobId}/files`) + .then(f => setDateien(f.data.files)) + .catch(() => setDateien(null)) + } + }) .catch(() => setDetail(null)) .finally(() => setLaedt(false)) }, [jobId]) @@ -159,6 +174,33 @@ export default function JobDetailModal({ jobId, onClose }: { jobId: string | nul )} + {/* Download der fertigen Dateien — vorher kam man nur per scp dran */} + {detail.status === 'completed' && dateien && dateien.length > 0 && ( +
+

+ Dateien herunterladen +

+
+ {dateien.map(f => ( + + + {f.name} + {f.size_mb != null && ( + + {f.size_mb >= 1024 ? `${(f.size_mb / 1024).toFixed(1)} GB` : `${f.size_mb} MB`} + + )} + + ))} +
+
+ )} + {detail.error && (
diff --git a/docker/ui/src/pages/Dashboard.tsx b/docker/ui/src/pages/Dashboard.tsx index 4a23a56..3108aba 100644 --- a/docker/ui/src/pages/Dashboard.tsx +++ b/docker/ui/src/pages/Dashboard.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react' -import { Clock, Activity, AlertCircle, CheckCircle, Disc } from 'lucide-react' +import { Clock, Activity, AlertCircle, CheckCircle, Disc, Download } from 'lucide-react' import { api } from '../lib/api' import { useDarkMode } from '../context/ThemeContext' import DeviceDiscovery from '../components/DeviceDiscovery' @@ -304,6 +304,16 @@ export default function Dashboard() { {new Date(job.startTime).toLocaleString('de-DE')} + {job.status === 'completed' && ( + + )} {job.status === 'failed' && (