sync: encrypted cloud backup for portfolios + settings UX rework

Adds opt-in client-side-encrypted portfolio sync (paid). Browser
PBKDF2(PIN) → AES-GCM, server HKDF(pepper, user_id) outer wrap;
server stores opaque bytes only. Sliding-window rate limit on GET.

  - new portfolio_sync table (migration 0015)
  - POST/GET/DELETE /api/portfolio/sync + /status
  - app/services/portfolio_sync.py crypto + rate limit
  - app/routers/sync.py paid-gated
  - app/static/js/portfolio-sync.js WebCrypto wrapper
  - settings page: enable/disable + PIN modal
  - PORTFOLIO_SYNC_PEPPER setting (warn on startup if missing)

Settings + import rework:

  - /upload merged into /settings#import (legacy route 302s)
  - drop CSV → auto-parse → preview → Import only / Import & sync
  - nav slimmed to Dashboard / News / Log
  - Settings + Logout moved to a user dropdown
  - brand logo links to /

Collateral fixes:

  - settings 500: re-fetch User in current session before mutating
    referral_code (assign_code_if_missing was refreshing a User
    loaded in the auth dep's now-closed session)
  - csv_import: distinct error for unfunded T212 pies (all qty=0)
  - db.py: drop pool_pre_ping (aiomysql 0.3.2 incompat); pin
    isolation_level=READ COMMITTED to avoid gap-lock deadlocks
  - alembic env: disable_existing_loggers=False so in-process
    migrations don't silence uvicorn's loggers
  - docker-compose.override.yml: dev-only volume mount + --reload

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Giorgio Gilestro 2026-05-23 16:15:54 +02:00
parent 89632e9937
commit f326b41a08
23 changed files with 1637 additions and 95 deletions

133
app/routers/sync.py Normal file
View file

@ -0,0 +1,133 @@
"""Encrypted-pie cloud sync — endpoints behind the paid-tier gate.
The blob field is base64 because JSON can't carry raw bytes. The server
treats it as opaque: we only need to know its length (to reject obviously
oversized payloads) and to hand it back as-is on GET. All crypto for the
inner layer happens in the browser; we just add the outer wrap in
app.services.portfolio_sync.
"""
from __future__ import annotations
import base64
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import CurrentUser
from app.db import get_session
from app.logging import get_logger
from app.services import portfolio_sync as svc
from app.services.access import require_paid
log = get_logger("portfolio_sync_router")
router = APIRouter(prefix="/api/portfolio/sync")
# A 256 KB cap is ~200× a typical pie's serialized size — generous
# headroom for AI analysis blobs the client may bundle later.
MAX_BLOB_BYTES = 256 * 1024
class SyncBlobIn(BaseModel):
blob: str = Field(..., description="base64url of the client-side ciphertext")
class SyncBlobOut(BaseModel):
blob: str
updated_at: datetime
class SyncStatusOut(BaseModel):
exists: bool
updated_at: datetime | None = None
class SyncWriteOut(BaseModel):
updated_at: datetime
def _decode_blob(b64: str) -> bytes:
"""Tolerates url-safe and standard alphabets, with or without padding."""
try:
s = b64.strip()
# Pad to multiple of 4 — base64 in browsers commonly omits it.
s += "=" * (-len(s) % 4)
return base64.urlsafe_b64decode(s)
except Exception:
# Last-ditch: try standard alphabet too.
try:
return base64.b64decode(b64, validate=False)
except Exception:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="blob must be base64",
)
@router.get("/status", response_model=SyncStatusOut)
async def get_status(
principal: CurrentUser = Depends(require_paid),
session: AsyncSession = Depends(get_session),
) -> SyncStatusOut:
exists, updated_at = await svc.fetch_status(session, principal.id)
return SyncStatusOut(exists=exists, updated_at=updated_at)
@router.post("", response_model=SyncWriteOut)
async def upload_blob(
body: SyncBlobIn,
principal: CurrentUser = Depends(require_paid),
session: AsyncSession = Depends(get_session),
) -> SyncWriteOut:
raw = _decode_blob(body.blob)
if len(raw) > MAX_BLOB_BYTES:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail=f"blob exceeds {MAX_BLOB_BYTES} bytes",
)
if not raw:
raise HTTPException(status_code=400, detail="blob is empty")
updated_at = await svc.upsert(session, principal.id, raw)
log.info("portfolio_sync.upserted", user_id=principal.id, bytes=len(raw))
return SyncWriteOut(updated_at=updated_at)
@router.get("", response_model=SyncBlobOut)
async def download_blob(
principal: CurrentUser = Depends(require_paid),
session: AsyncSession = Depends(get_session),
) -> SyncBlobOut:
if not await svc.consume_fetch_budget(session, principal.id):
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="too many fetches; try again in a minute",
)
try:
result = await svc.fetch(session, principal.id)
except svc.SyncCryptoError:
log.error("portfolio_sync.unwrap_failed", user_id=principal.id)
raise HTTPException(
status_code=500,
detail="server failed to read the encrypted blob",
)
if result is None:
raise HTTPException(status_code=404, detail="no synced portfolio")
inner, updated_at = result
return SyncBlobOut(
blob=base64.urlsafe_b64encode(inner).decode("ascii").rstrip("="),
updated_at=updated_at,
)
@router.delete("")
async def delete_blob(
principal: CurrentUser = Depends(require_paid),
session: AsyncSession = Depends(get_session),
) -> dict:
removed = await svc.delete(session, principal.id)
log.info("portfolio_sync.deleted", user_id=principal.id, removed=removed)
return {"ok": True, "removed": removed}