import asyncio import os import re from datetime import datetime from pathlib import Path from fastapi import APIRouter, Depends, HTTPException, status from fastapi.responses import FileResponse from pydantic import BaseModel from app.config import get_settings from app.dependencies import get_current_user from app.db.models.user import User _DEMO_DISABLED = "Backups are disabled in demo mode" router = APIRouter(prefix="/admin", tags=["admin"]) BACKUP_DIR = Path(os.environ.get("BACKUP_DIR", "/app/backups")) BACKUP_PATTERN = re.compile(r"^\d{8}_\d{6}\.sql\.gz\.gpg$") class BackupFile(BaseModel): filename: str size_bytes: int created_at: str class BackupResult(BaseModel): ok: bool message: str def _list_backup_files() -> list[BackupFile]: if not BACKUP_DIR.exists(): return [] files = [] for f in sorted(BACKUP_DIR.glob("*.sql.gz.gpg"), reverse=True): stat = f.stat() files.append(BackupFile( filename=f.name, size_bytes=stat.st_size, created_at=datetime.fromtimestamp(stat.st_mtime).isoformat(), )) return files @router.get("/backups", response_model=list[BackupFile]) async def list_backups(current_user: User = Depends(get_current_user)): if get_settings().is_demo: raise HTTPException(status_code=403, detail=_DEMO_DISABLED) return _list_backup_files() @router.post("/backup", response_model=BackupResult) async def trigger_backup(current_user: User = Depends(get_current_user)): if get_settings().is_demo: raise HTTPException(status_code=403, detail=_DEMO_DISABLED) try: proc = await asyncio.create_subprocess_exec( "bash", "/app/scripts/backup.sh", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, ) stdout, _ = await proc.communicate() output = stdout.decode().strip() if stdout else "" if proc.returncode == 0: return BackupResult(ok=True, message=output or "Backup completed") raise HTTPException(status_code=500, detail=f"Backup failed: {output}") except HTTPException: raise except Exception as exc: raise HTTPException(status_code=500, detail=str(exc)) @router.get("/backups/{filename}") async def download_backup( filename: str, current_user: User = Depends(get_current_user), ): if get_settings().is_demo: raise HTTPException(status_code=403, detail=_DEMO_DISABLED) if not BACKUP_PATTERN.match(filename): raise HTTPException(status_code=400, detail="Invalid filename") path = BACKUP_DIR / filename if not path.exists(): raise HTTPException(status_code=404, detail="Backup not found") return FileResponse( path=str(path), filename=filename, media_type="application/octet-stream", ) @router.post("/restore/{filename}", response_model=BackupResult) async def restore_backup( filename: str, current_user: User = Depends(get_current_user), ): if get_settings().is_demo: raise HTTPException(status_code=403, detail=_DEMO_DISABLED) if not BACKUP_PATTERN.match(filename): raise HTTPException(status_code=400, detail="Invalid filename") path = BACKUP_DIR / filename if not path.exists(): raise HTTPException(status_code=404, detail="Backup not found") passphrase = os.environ.get("BACKUP_PASSPHRASE", "") if not passphrase: raise HTTPException(status_code=500, detail="BACKUP_PASSPHRASE not configured") database_url = os.environ.get("DATABASE_URL", "") pg_url = database_url.replace("postgresql+asyncpg", "postgresql") # Ensure GPG has a writable home (appuser has no real home directory) gnupg_home = "/tmp/.gnupg" os.makedirs(gnupg_home, mode=0o700, exist_ok=True) gpg_env = {**os.environ, "GNUPGHOME": gnupg_home} try: # Decrypt and decompress into psql non-interactively gpg_proc = await asyncio.create_subprocess_exec( "gpg", "--batch", "--yes", "--no-symkey-cache", "--pinentry-mode", "loopback", "--decrypt", "--passphrase", passphrase, str(path), stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, env=gpg_env, ) gpg_out, gpg_err = await gpg_proc.communicate() if gpg_proc.returncode != 0: raise HTTPException(status_code=500, detail=f"Decryption failed: {gpg_err.decode()}") gunzip_proc = await asyncio.create_subprocess_exec( "gunzip", stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) sql_data, gunzip_err = await gunzip_proc.communicate(input=gpg_out) if gunzip_proc.returncode != 0: raise HTTPException(status_code=500, detail=f"Decompression failed: {gunzip_err.decode()}") psql_proc = await asyncio.create_subprocess_exec( "psql", "--single-transaction", "-v", "ON_ERROR_STOP=1", pg_url, stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) psql_out, psql_err = await psql_proc.communicate(input=sql_data) if psql_proc.returncode != 0: detail = (psql_err.decode().strip() or psql_out.decode().strip() or "psql exited with no output") raise HTTPException(status_code=500, detail=f"Restore failed: {detail}") return BackupResult(ok=True, message=f"Restored from {filename}") except HTTPException: raise except Exception as exc: raise HTTPException(status_code=500, detail=str(exc))