feat: Download-Knopf fuer fertige Rips — Dateien direkt im Browser statt scp
Ampel / ampel (push) Successful in 29s
Ampel / ampel (push) Successful in 29s
- GET /jobs/{id}/files: Dateiliste aus job.output_path (Name + Groesse)
- GET /jobs/{id}/files/{name}: FileResponse-Stream; Validierung strikt —
output_path muss unter /app/media liegen, nackter Dateiname (kein
Slash/.., kein Dotfile), realpath-Check gegen Symlink-Ausbrueche.
Mit Test (test_dateiname_validierung_blockt_pfad_tricks).
- UI: 'Download'-Knopf in der Aktion-Spalte bei fertigen Jobs; die
Dateiliste mit Groessen + Download-Links lebt im Job-Detail-Popup
(ein Dropdown wuerde im overflow-x-auto-Tabellencontainer clippen).
- nginx: proxy_buffering off + proxy_read_timeout 3600s waren fuer SSE
schon gesetzt — grosse Downloads brauchen keine Aenderung.
Wunsch aus der Uebernahme-Session (Commander-Sammelliste 24.07.).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+73
-1
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user