86 lines
2.9 KiB
Python
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"}
|