feat: datei-bulk (checkboxen, zip-download, sammel-löschen)
CI / backend-tests (push) Successful in 10s
Docker images / backend (push) Successful in 9s
Docker images / frontend (push) Successful in 25s
CI / docker (push) Successful in 5s

This commit is contained in:
loki committed 2026-10-04 15:03:30 +02:00
1 parent 008c713840
commit 7819ae3d12
3 files changed
+193 -12

No files matched your search

+53 -10
View File
@@ -13,16 +13,22 @@ def _root() -> Path:
return Path(settings.DOWNLOAD_DIR).expanduser().resolve()
@router.get("/download")
def download_file(path: str = Query(default="")):
from fastapi.responses import FileResponse
def _resolve_in_root(path: str) -> Path:
"""Resolve user path inside the download root or raise 403/404."""
root = _root()
if not path:
raise HTTPException(status_code=404, detail="Not found")
target = (root / path).resolve()
if target != root and root not in target.parents:
raise HTTPException(status_code=403, detail="Path escape blocked")
return target
@router.get("/download")
def download_file(path: str = Query(default="")):
from fastapi.responses import FileResponse
target = _resolve_in_root(path)
if not target.is_file():
raise HTTPException(status_code=404, detail="Not found")
return FileResponse(
@@ -32,14 +38,51 @@ def download_file(path: str = Query(default="")):
)
@router.get("/archive")
def download_archive(paths: list[str] = Query(default=[])):
import tempfile
import zipfile
from fastapi.responses import StreamingResponse
if not paths or len(paths) > 100:
raise HTTPException(status_code=422, detail="1..100 Dateien angeben")
root = _root()
files: list[Path] = []
saw_dir = False
for p in paths:
target = (root / p).resolve()
if target != root and root not in target.parents:
raise HTTPException(status_code=403, detail="Path escape blocked")
if target.is_file():
files.append(target)
elif target.exists():
saw_dir = True
if not files:
raise HTTPException(status_code=422 if saw_dir else 404, detail="Keine Dateien gefunden")
tmp = tempfile.SpooledTemporaryFile(max_size=10 * 1024 * 1024)
with zipfile.ZipFile(tmp, "w", zipfile.ZIP_DEFLATED) as zf:
for f in files:
zf.write(f, f.relative_to(root))
tmp.seek(0)
def gen():
try:
while chunk := tmp.read(1024 * 1024):
yield chunk
finally:
tmp.close()
return StreamingResponse(
gen(),
media_type="application/zip",
headers={"Content-Disposition": 'attachment; filename="auswahl.zip"'},
)
@router.delete("")
def delete_file(path: str = Query(default="")):
root = _root()
if not path:
raise HTTPException(status_code=404, detail="Not found")
target = (root / path).resolve()
if target != root and root not in target.parents:
raise HTTPException(status_code=403, detail="Path escape blocked")
target = _resolve_in_root(path)
if not target.is_file():
if target.exists():
raise HTTPException(status_code=422, detail="Only files can be deleted")
+41
View File
@@ -0,0 +1,41 @@
import io
import zipfile
from fastapi.testclient import TestClient
from app.main import app
def _setup(tmp_path, monkeypatch):
from app.core import settings
root = tmp_path / "dl"
(root / "sub").mkdir(parents=True)
(root / "a.mp3").write_bytes(b"AAA")
(root / "sub" / "b.mp3").write_bytes(b"BBBB")
monkeypatch.setattr(settings, "DOWNLOAD_DIR", str(root))
return root
def test_archive_contains_files(tmp_path, monkeypatch):
_setup(tmp_path, monkeypatch)
c = TestClient(app)
r = c.get("/api/files/archive", params=[("paths", "a.mp3"), ("paths", "sub/b.mp3")])
assert r.status_code == 200
assert "zip" in r.headers.get("content-type", "")
zf = zipfile.ZipFile(io.BytesIO(r.content))
assert sorted(zf.namelist()) == ["a.mp3", "sub/b.mp3"]
assert zf.read("a.mp3") == b"AAA"
def test_archive_blocks_traversal(tmp_path, monkeypatch):
_setup(tmp_path, monkeypatch)
c = TestClient(app)
assert c.get("/api/files/archive", params=[("paths", "../../etc/passwd")]).status_code == 403
def test_archive_rejects_empty_and_dirs(tmp_path, monkeypatch):
root = _setup(tmp_path, monkeypatch)
c = TestClient(app)
assert c.get("/api/files/archive").status_code == 422
assert c.get("/api/files/archive", params=[("paths", "sub")]).status_code == 422
assert c.get("/api/files/archive", params=[("paths", "missing.mp3")]).status_code == 404
+99 -2
View File
@@ -16,6 +16,8 @@ export default function FilesBrowser() {
const [entries, setEntries] = useState<Entry[]>([]);
const [loading, setLoading] = useState(false);
const [confirm, setConfirm] = useState<Entry | null>(null);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [confirmBulkDelete, setConfirmBulkDelete] = useState(false);
async function load(p: string) {
setLoading(true);
@@ -44,8 +46,43 @@ export default function FilesBrowser() {
async function remove(entry: Entry) {
const r = await fetch(`/api/files?path=${encodeURIComponent(entry.rel ?? entry.name)}`, { method: "DELETE" });
setConfirm(null);
if (r.ok) load(path);
else alert(`Löschen fehlgeschlagen (${r.status})`);
if (r.ok) {
setSelected((prev) => {
const next = new Set(prev);
next.delete(entry.rel ?? entry.name);
return next;
});
load(path);
} else alert(`Löschen fehlgeschlagen (${r.status})`);
}
async function removeSelected() {
setConfirmBulkDelete(false);
let ok = 0;
for (const rel of selected) {
const r = await fetch(`/api/files?path=${encodeURIComponent(rel)}`, { method: "DELETE" });
if (r.ok) ok++;
}
setSelected(new Set());
load(path);
if (ok < selected.size) alert("Manche Dateien konnten nicht gelöscht werden.");
}
function toggle(rel: string) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(rel)) next.delete(rel);
else next.add(rel);
return next;
});
}
const files = entries.filter((e) => e.type === "file");
const allChecked = files.length > 0 && files.every((e) => selected.has(e.rel ?? e.name));
function archiveHref(): string {
const q = [...selected].map((rel) => `paths=${encodeURIComponent(rel)}`).join("&");
return `/api/files/archive?${q}`;
}
const crumbs = path.split("/").filter(Boolean);
@@ -78,9 +115,57 @@ export default function FilesBrowser() {
{loading && <p className="muted">Lädt …</p>}
{!loading && entries.length === 0 && <p className="muted">Ordner ist leer.</p>}
{selected.size > 0 && (
<div className="card" style={{ display: "flex", alignItems: "center", gap: 12, padding: "8px 14px", marginBottom: 8 }}>
<strong>{selected.size} ausgewählt</strong>
<a href={archiveHref()} download className="btn btn-primary" style={{ textDecoration: "none" }}>
↓ Als ZIP laden
</a>
<button onClick={() => setConfirmBulkDelete(true)} className="btn">
Löschen
</button>
<button onClick={() => setSelected(new Set())} className="btn">
Auswahl aufheben
</button>
</div>
)}
<ol style={{ listStyle: "none", margin: 0, padding: 0, display: "flex", flexDirection: "column", gap: 8 }}>
{files.length > 0 && (
<li style={{ display: "flex", alignItems: "center", gap: 12, padding: "0 14px" }}>
<input
type="checkbox"
checked={allChecked}
onChange={() => {
if (allChecked) {
setSelected((prev) => {
const next = new Set(prev);
files.forEach((e) => next.delete(e.rel ?? e.name));
return next;
});
} else {
setSelected((prev) => {
const next = new Set(prev);
files.forEach((e) => next.add(e.rel ?? e.name));
return next;
});
}
}}
aria-label="Alle Dateien auswählen"
/>
<span className="muted" style={{ fontSize: 13 }}>Alle</span>
</li>
)}
{entries.map((e) => (
<li key={e.rel ?? e.name} className="card" style={{ display: "flex", alignItems: "center", gap: 12, padding: "10px 14px" }}>
{e.type === "file" && (
<input
type="checkbox"
checked={selected.has(e.rel ?? e.name)}
onChange={() => toggle(e.rel ?? e.name)}
aria-label={`${e.name} auswählen`}
/>
)}
<span style={{ fontSize: 20 }} aria-hidden>{e.type === "dir" ? "📁" : "🎵"}</span>
<div style={{ flex: 1, minWidth: 0 }}>
{e.type === "dir" ? (
@@ -108,6 +193,18 @@ export default function FilesBrowser() {
))}
</ol>
{confirmBulkDelete && (
<div style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,.6)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 100 }} onClick={() => setConfirmBulkDelete(false)}>
<div className="card" style={{ padding: 20, maxWidth: 400, width: "90%", background: "var(--surface-2)" }} onClick={(ev) => ev.stopPropagation()}>
<h3 style={{ marginTop: 0 }}>{selected.size} Dateien löschen?</h3>
<p className="muted">Endgültig — kein Papierkorb.</p>
<div style={{ display: "flex", gap: 8 }}>
<button onClick={removeSelected} className="btn btn-primary">Ja, alle löschen</button>
<button onClick={() => setConfirmBulkDelete(false)} className="btn">Abbrechen</button>
</div>
</div>
</div>
)}
{confirm && (
<div style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,.6)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 100 }} onClick={() => setConfirm(null)}>
<div className="card" style={{ padding: 20, maxWidth: 400, width: "90%", background: "var(--surface-2)" }} onClick={(ev) => ev.stopPropagation()}>