- DEMO_MODE=true env flag: disables password changes and backup endpoints (403), exposes GET /demo/status for frontend detection - Auto-seed on first startup: creates demo user (demo@mymidas.app / demo123) with 6 months of transactions, investments, budgets, subscriptions, and tax payslips; takes a pg_dump snapshot immediately after for hourly restore - Hourly reset: resetter Alpine container with cron restores DB from snapshot and purges uploaded attachments every hour on the hour - Frontend: amber demo banner on all pages, login page shows credentials, password change disabled with notice, backups section replaced with notice - demo/ directory: self-contained docker-compose.yml (ports 4001/8091), .env.example, reset.sh, and step-by-step Portainer DEPLOY.md Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
163 lines
5.6 KiB
Python
163 lines
5.6 KiB
Python
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))
|