Files
Magent/backend/app/routers/backups.py
T
Assclaw fd6671cf7e
Magent CI/CD / verify (push) Successful in 5m20s
Magent CI/CD / deploy-beta (push) Successful in 1m41s
feat: add backup recovery, setup wizard and user-view guards
2026-09-18 17:23:03 +12:00

86 lines
2.9 KiB
Python

"""Administrator-only encrypted backup downloads and staged restores."""
from typing import Literal
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from fastapi.responses import Response
from pydantic import BaseModel, ConfigDict, Field, SecretStr
from starlette.concurrency import run_in_threadpool
from ..auth import require_admin
from ..db import get_rate_limit_status, record_rate_limit_event
from ..services import backups
def _no_store(response: Response) -> None:
response.headers["Cache-Control"] = "no-store"
response.headers["Pragma"] = "no-cache"
router = APIRouter(
prefix="/admin/backups", tags=["backups"],
dependencies=[Depends(require_admin), Depends(_no_store)],
)
class ExportRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
passphrase: SecretStr = Field(min_length=12, max_length=1024)
include_cache: bool = False
def _rate_limit(user: dict) -> None:
key = str(user["username"])
exceeded, retry = get_rate_limit_status("backups", key, 300, 3)
if exceeded:
raise HTTPException(429, "Too many backup operations; try again shortly", headers={"Retry-After": str(retry)})
record_rate_limit_event("backups", key)
@router.get("")
def status() -> dict:
return backups.backup_status()
@router.post("/export")
def export(payload: ExportRequest, user: dict = Depends(require_admin)) -> Response:
_rate_limit(user)
try:
content, filename = backups.create_backup(payload.passphrase.get_secret_value(), payload.include_cache)
except backups.BackupError as exc:
raise HTTPException(400, str(exc)) from exc
return Response(content, media_type="application/octet-stream", headers={
"Content-Disposition": f'attachment; filename="{filename}"',
"Cache-Control": "no-store", "Pragma": "no-cache",
})
@router.post("/restore", status_code=202)
async def restore(
file: UploadFile = File(...),
passphrase: str = Form(..., min_length=12, max_length=1024),
confirmation: Literal["RESTORE"] = Form(...),
user: dict = Depends(require_admin),
) -> dict:
_rate_limit(user)
try:
if file.size is not None and file.size > backups.MAX_UPLOAD_BYTES:
raise HTTPException(413, "Backup exceeds the 32 MiB upload limit")
metadata = await run_in_threadpool(backups.stage_restore, file.file, passphrase)
except backups.BackupError as exc:
raise HTTPException(400, str(exc)) from exc
finally:
await file.close()
return {
"status": "staged", "restart_required": True, "backup": metadata,
"message": "Backup validated. Restart Magent to apply it. Current data remains active until restart.",
}
@router.delete("/restore")
def cancel() -> dict:
try:
backups.cancel_restore()
except backups.BackupError as exc:
raise HTTPException(409, str(exc)) from exc
return {"status": "cancelled"}