Matthias Nott
2026-02-26 5d0247159b125bf035285d56c2b9bb58d6bb3029
app/routers/backups.py
....@@ -1,9 +1,11 @@
11 import json
2
+import os
23 from datetime import datetime, timezone
34 from typing import Any, AsyncGenerator
45
56 from fastapi import APIRouter, Depends, HTTPException, Query
67 from fastapi.responses import StreamingResponse
8
+from starlette.responses import FileResponse
79
810 from app.auth import verify_token
911 from app.ops_runner import (
....@@ -304,3 +306,27 @@
304306 raise HTTPException(status_code=500, detail=f"Delete failed: {results}")
305307
306308 return {"success": True, "project": project, "env": env, "name": name, "results": results}
309
+
310
+
311
+@router.get("/download/{project}/{env}/{name}", summary="Download a backup to browser")
312
+async def download_backup(
313
+ project: str,
314
+ env: str,
315
+ name: str,
316
+ _: str = Depends(verify_token),
317
+) -> FileResponse:
318
+ """Serve a local backup file as a browser download."""
319
+ for param in (project, env, name):
320
+ if "/" in param or "\\" in param or ".." in param:
321
+ raise HTTPException(status_code=400, detail="Invalid path parameter")
322
+
323
+ backup_path = f"/opt/data/backups/{project}/{env}/{name}"
324
+ if not os.path.isfile(backup_path):
325
+ raise HTTPException(status_code=404, detail="Backup file not found")
326
+
327
+ media_type = "application/gzip" if name.endswith(".gz") else "application/octet-stream"
328
+ return FileResponse(
329
+ path=backup_path,
330
+ media_type=media_type,
331
+ filename=name,
332
+ )