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.dependencies import get_current_user from app.db.models.user import User 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)): return _list_backup_files() @router.post("/backup", response_model=BackupResult) async def trigger_backup(current_user: User = Depends(get_current_user)): 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 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 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))