"""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"}