Complete Phase 3, Phase 5 polish and hardening
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>
This commit is contained in:
parent
74e57a35c0
commit
fe4e69b9ad
40 changed files with 2079 additions and 127 deletions
152
backend/app/api/v1/admin.py
Normal file
152
backend/app/api/v1/admin.py
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
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))
|
||||
Loading…
Add table
Add a link
Reference in a new issue