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:
parent
89632e9937
commit
f326b41a08
23 changed files with 1637 additions and 95 deletions
27
app/db.py
27
app/db.py
|
|
@ -31,12 +31,27 @@ def get_engine():
|
|||
global _engine
|
||||
if _engine is None:
|
||||
s = get_settings()
|
||||
_engine = create_async_engine(
|
||||
s.DATABASE_URL,
|
||||
pool_pre_ping=True,
|
||||
pool_recycle=3600,
|
||||
future=True,
|
||||
)
|
||||
# NB: pool_pre_ping is intentionally OFF. aiomysql 0.3.x made
|
||||
# Connection.ping()'s `reconnect` arg mandatory, but SQLAlchemy's
|
||||
# MySQL pre-ping (2.0.49) calls it without that arg — so every
|
||||
# reused pooled connection raises TypeError, surfacing as an
|
||||
# intermittent 500 (502 behind the proxy). pool_recycle below
|
||||
# (1h, well under MariaDB's 8h wait_timeout) keeps connections
|
||||
# fresh without needing a ping.
|
||||
#
|
||||
# isolation_level READ COMMITTED: under MariaDB's default
|
||||
# REPEATABLE READ, the "invalidate prior unused codes" UPDATE in
|
||||
# otp_service.issue() takes next-key/gap locks on the
|
||||
# (email, created_at) index even when it matches no rows;
|
||||
# concurrent OTP INSERTs then deadlock (errno 1213). READ
|
||||
# COMMITTED drops those gap locks — appropriate here since every
|
||||
# request is a short, self-contained transaction. SQLite (the
|
||||
# test sentinel backend) rejects this level, so set it only for
|
||||
# the real server backends.
|
||||
kwargs: dict = {"pool_recycle": 3600, "future": True}
|
||||
if not s.DATABASE_URL.startswith("sqlite"):
|
||||
kwargs["isolation_level"] = "READ COMMITTED"
|
||||
_engine = create_async_engine(s.DATABASE_URL, **kwargs)
|
||||
return _engine
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue