Files
gh-christianlouis-docuelevate/app/api/backup.py
2026-03-08 09:56:58 +00:00

254 lines
8.5 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Backup and restore API endpoints for DocuElevate.
Provides REST endpoints for:
- Listing existing backups
- Triggering a manual backup
- Downloading a backup archive
- Restoring from an uploaded backup file
- Deleting a backup record
- Running retention cleanup
"""
import logging
import os
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, status
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import BackupRecord
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/admin/backup", tags=["backup"])
def _require_admin(request: Request) -> dict:
"""Ensure the caller is an admin. Raises 403 otherwise."""
user = request.session.get("user")
if not user or not user.get("is_admin"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
return user
# Annotated shorthand so FastAPI can resolve and tests can override it.
AdminUser = Annotated[dict, Depends(_require_admin)]
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.get("/")
async def list_backups(
_admin: AdminUser,
db: Session = Depends(get_db),
) -> list[dict]:
"""Return all backup records, newest first."""
records = db.query(BackupRecord).order_by(BackupRecord.created_at.desc()).all()
return [
{
"id": r.id,
"filename": r.filename,
"backup_type": r.backup_type,
"size_bytes": r.size_bytes,
"checksum": r.checksum,
"status": r.status,
"local_path": r.local_path,
"remote_destination": r.remote_destination,
"remote_path": r.remote_path,
"created_at": r.created_at.isoformat() if r.created_at else None,
"local_available": bool(r.local_path and os.path.exists(r.local_path)),
}
for r in records
]
@router.post("/create")
async def trigger_backup(
_admin: AdminUser,
backup_type: str = "hourly",
) -> dict:
"""Trigger a manual backup immediately.
Query parameter ``backup_type`` accepts ``hourly``, ``daily``, or
``weekly`` (default: ``hourly``).
"""
if backup_type not in ("hourly", "daily", "weekly"):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid backup_type")
from app.tasks.backup_tasks import create_backup
task = create_backup.delay(backup_type=backup_type)
return {"task_id": task.id, "status": "queued", "backup_type": backup_type}
@router.get("/{backup_id}/download")
async def download_backup(
backup_id: int,
_admin: AdminUser,
db: Session = Depends(get_db),
) -> FileResponse:
"""Stream the backup archive to the client."""
rec = db.get(BackupRecord, backup_id)
if rec is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Backup not found")
if not rec.local_path or not os.path.exists(rec.local_path):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Local archive file is not available (may have been pruned)",
)
return FileResponse(
path=rec.local_path,
filename=rec.filename,
media_type="application/gzip",
)
@router.post("/restore")
async def restore_backup(
_admin: AdminUser,
file: UploadFile,
db: Session = Depends(get_db),
) -> dict:
"""Restore the database from an uploaded gzip-compressed SQL dump.
**Warning**: This overwrites the current database contents.
Supported formats (must match the currently configured database backend):
- ``*.db.gz`` gzip-compressed SQLite ``.dump()`` SQL script (SQLite backend)
- ``*.pgsql.gz`` gzip-compressed ``pg_dump --format=plain`` output (PostgreSQL backend)
- ``*.mysql.gz`` gzip-compressed ``mysqldump`` output (MySQL / MariaDB backend)
"""
import tempfile
from pathlib import Path
from sqlalchemy.engine.url import make_url
from app.config import settings as app_settings
from app.tasks.backup_tasks import (
_archive_ext_for_backend,
_db_path,
_restore_mysql,
_restore_postgresql,
_restore_sqlite,
)
url = make_url(app_settings.database_url)
backend = url.get_backend_name()
expected_ext = _archive_ext_for_backend(backend)
if not file.filename or not file.filename.endswith(expected_ext):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
f"Uploaded file must be a '{expected_ext}' backup archive for the current database backend ({backend})."
),
)
# Write upload to a temp file
with tempfile.NamedTemporaryFile(suffix=expected_ext, delete=False) as tmp:
tmp_path = Path(tmp.name)
content = await file.read()
tmp.write(content)
try:
if backend == "sqlite":
db_path = _db_path()
if db_path is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Restore is only supported for file-based SQLite databases.",
)
# Close the application DB session before replacing the file
db.close()
try:
_restore_sqlite(db_path, tmp_path)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc),
) from exc
except RuntimeError as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(exc),
) from exc
elif backend == "postgresql":
db.close()
try:
_restore_postgresql(app_settings.database_url, tmp_path)
except FileNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"psql binary not found is PostgreSQL client installed? ({exc})",
) from exc
except RuntimeError as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"PostgreSQL restore failed: {exc}",
) from exc
elif backend == "mysql":
db.close()
try:
_restore_mysql(app_settings.database_url, tmp_path)
except FileNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"mysql binary not found is MySQL client installed? ({exc})",
) from exc
except RuntimeError as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"MySQL restore failed: {exc}",
) from exc
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Database backend '{backend}' does not support restore.",
)
finally:
tmp_path.unlink(missing_ok=True)
logger.info(f"Database restored from uploaded backup: {file.filename}")
return {"status": "restored", "filename": file.filename}
@router.delete("/{backup_id}")
async def delete_backup(
backup_id: int,
_admin: AdminUser,
db: Session = Depends(get_db),
) -> dict:
"""Delete a backup record (and local file if present)."""
rec = db.get(BackupRecord, backup_id)
if rec is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Backup not found")
if rec.local_path and os.path.exists(rec.local_path):
try:
os.remove(rec.local_path)
except OSError as exc:
logger.warning(f"Could not remove local backup file {rec.local_path}: {exc}")
db.delete(rec)
db.commit()
return {"status": "deleted", "id": backup_id}
@router.post("/cleanup")
async def run_cleanup(_admin: AdminUser) -> dict:
"""Manually trigger the retention cleanup for all backup tiers."""
from app.tasks.backup_tasks import cleanup_old_backups
task = cleanup_old_backups.delay()
return {"task_id": task.id, "status": "queued"}