Phase 3 — Investments: - Multi-currency support: holdings track purchase currency, FX rates convert to base for totals - Capital gains report using UK Section 104 pool method, grouped by tax year - Capital Gains tab added to Reports page Phase 5 — Polish & Hardening: - Mobile-responsive layout: bottom nav, sidebar hidden on mobile, logo in TopBar, compact header buttons, hover-only actions now always visible on touch - Backup system: encrypted GPG backups via backup.sh, nightly scheduler job, admin API (list/trigger/download/restore), Settings UI with drag-to-restore confirmation - Docker entrypoint with gosu privilege drop to fix bind-mount ownership on fresh deployments - OWASP fixes: refresh token now bound to its session (new refresh_token_hash column + migration), CSRF secure flag tied to environment, IP-level rate limiting on login, TOTPEnableRequest Pydantic schema replaces raw dict - AES-256-GCM key rotation script (rotate_keys.py) with dry-run mode and atomic DB transaction - CLAUDE.md added for AI-assisted development context - README updated: correct reverse proxy port, accurate backup/restore commands, key rotation instructions Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
152 lines
5.2 KiB
Python
152 lines
5.2 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.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))
|