diff --git a/alembic/env.py b/alembic/env.py index 9918b4f..a652b05 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -21,7 +21,11 @@ config = context.config config.set_main_option("sqlalchemy.url", get_settings().DATABASE_URL) if config.config_file_name is not None: - fileConfig(config.config_file_name) + # disable_existing_loggers=False is essential: the app applies + # migrations in-process at startup (see app.main lifespan), so the + # default True would disable uvicorn's already-configured loggers — + # silencing access logs and 500 tracebacks for the whole process. + fileConfig(config.config_file_name, disable_existing_loggers=False) target_metadata = Base.metadata diff --git a/alembic/versions/0015_portfolio_sync.py b/alembic/versions/0015_portfolio_sync.py new file mode 100644 index 0000000..f6e9521 --- /dev/null +++ b/alembic/versions/0015_portfolio_sync.py @@ -0,0 +1,43 @@ +"""portfolio_sync: opt-in encrypted backup of a user's pie. + +The plaintext pie is encrypted client-side with a PIN-derived AES-GCM +key; the server wraps the ciphertext again with a key derived from +PORTFOLIO_SYNC_PEPPER + user_id. We only store the outer-wrapped bytes +plus a small rate-limit window pair for GET throttling. + +Revision ID: 0015 +Revises: 0014 +Create Date: 2026-05-23 +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + + +revision: str = "0015" +down_revision: Union[str, None] = "0014" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "portfolio_sync", + sa.Column( + "user_id", sa.Integer(), + sa.ForeignKey("users.id", ondelete="CASCADE"), + primary_key=True, nullable=False, + ), + sa.Column("outer_ciphertext", sa.LargeBinary(), nullable=False), + sa.Column("outer_nonce", sa.LargeBinary(), nullable=False), + sa.Column("version", sa.SmallInteger(), nullable=False, server_default="1"), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("fetch_window_start", sa.DateTime(timezone=True), nullable=True), + sa.Column("fetch_count", sa.Integer(), nullable=False, server_default="0"), + ) + + +def downgrade() -> None: + op.drop_table("portfolio_sync") diff --git a/app/config.py b/app/config.py index a035f60..70c0f1c 100644 --- a/app/config.py +++ b/app/config.py @@ -63,6 +63,13 @@ class Settings(BaseSettings): CASSANDRA_ANCHOR_DATE: str = "" CASSANDRA_MOCK: bool = False + # Server-side pepper for the cloud-sync outer wrap. Generate with: + # python -c "import secrets; print(secrets.token_urlsafe(32))" + # When empty, the outer layer degrades to "salt by user_id only" — fine + # for dev, but a prod DB leak would then suffice to brute-force PINs + # offline. The startup log warns if this is empty on a non-sqlite DB. + PORTFOLIO_SYNC_PEPPER: str = "" + # AI log — provider abstraction with fallback chain. # `LLM_PROVIDER` is the primary; `LLM_FALLBACK` kicks in if the primary # raises (after its own internal retries). Set LLM_FALLBACK="" to diff --git a/app/db.py b/app/db.py index 646959d..a3affe1 100644 --- a/app/db.py +++ b/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 diff --git a/app/main.py b/app/main.py index 241aa7e..20ed7f0 100644 --- a/app/main.py +++ b/app/main.py @@ -20,6 +20,7 @@ from app.logging import configure_logging, get_logger from app.routers import api as api_router from app.routers import auth as auth_router from app.routers import pages as pages_router +from app.routers import sync as sync_router from app.routers import universe as universe_router from app.services.feeds_bootstrap import bootstrap_feeds @@ -41,6 +42,12 @@ def _run_migrations() -> None: async def lifespan(app: FastAPI): configure_logging() log.info("cassandra.startup") + s = get_settings() + if not s.PORTFOLIO_SYNC_PEPPER and not s.DATABASE_URL.startswith("sqlite"): + # Outer wrap still works (it just degrades to a per-user derived + # key with no shared secret), but a DB leak would let an attacker + # brute-force the PIN offline. Loud warning, not a hard failure. + log.warning("cassandra.portfolio_sync.pepper_missing") try: # Alembic's env.py uses asyncio.run() internally; offload it to a # worker thread so it doesn't collide with FastAPI's running loop. @@ -77,4 +84,5 @@ app.mount( app.include_router(auth_router.router, tags=["auth"]) app.include_router(api_router.router, prefix="/api", tags=["api"]) app.include_router(universe_router.router, prefix="/api", tags=["universe"]) +app.include_router(sync_router.router, tags=["portfolio-sync"]) app.include_router(pages_router.router, tags=["pages"]) diff --git a/app/models.py b/app/models.py index efa5a03..bdc884a 100644 --- a/app/models.py +++ b/app/models.py @@ -17,6 +17,8 @@ from sqlalchemy import ( ForeignKey, Index, Integer, + LargeBinary, + SmallInteger, String, Text, UniqueConstraint, @@ -179,6 +181,31 @@ class User(Base): ) +class PortfolioSync(Base): + """Opt-in encrypted backup of a user's pie. Stored as opaque bytes: + the client encrypts the pie with a PIN-derived key (AES-GCM), and the + server wraps that ciphertext again with a per-user key derived from + PORTFOLIO_SYNC_PEPPER + user_id (also AES-GCM). A DB-only leak yields + nothing usable without the env-only pepper; a pepper-only leak still + leaves the attacker brute-forcing the PIN through PBKDF2(600k). + + One row per user. Absent row = sync disabled for that user. The + fetch_window_* fields drive a sliding-window rate limit on GET so the + pepper-leak threat model can't degenerate into an unthrottled brute + force against the inner PBKDF2.""" + __tablename__ = "portfolio_sync" + user_id: Mapped[int] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), primary_key=True, + ) + outer_ciphertext: Mapped[bytes] = mapped_column(LargeBinary, nullable=False) + outer_nonce: Mapped[bytes] = mapped_column(LargeBinary, nullable=False) + version: Mapped[int] = mapped_column(SmallInteger, nullable=False, default=1) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) + fetch_window_start: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + fetch_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + + class Referral(Base): """One row per captured (referrer, referred) pair. Created at signup when the new user supplied a valid `?ref=`. The conversion diff --git a/app/routers/pages.py b/app/routers/pages.py index 1214586..46f58e0 100644 --- a/app/routers/pages.py +++ b/app/routers/pages.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import date, datetime, timezone from fastapi import APIRouter, Depends, Request -from fastapi.responses import HTMLResponse +from fastapi.responses import HTMLResponse, RedirectResponse from sqlalchemy import desc, func, select from sqlalchemy.ext.asyncio import AsyncSession @@ -35,10 +35,10 @@ async def news_page(request: Request): return templates.TemplateResponse(request, "news.html", {}) -@router.get("/upload", response_class=HTMLResponse) +@router.get("/upload") async def upload_page(request: Request): - """Drag-drop CSV import. Posts to /api/portfolios/upload.""" - return templates.TemplateResponse(request, "upload.html", {}) + """Legacy bookmark — the import widget now lives in /settings.""" + return RedirectResponse(url="/settings#import", status_code=302) async def _resolve_log_date(session: AsyncSession, day: str | None) -> date: diff --git a/app/routers/sync.py b/app/routers/sync.py new file mode 100644 index 0000000..e6496e0 --- /dev/null +++ b/app/routers/sync.py @@ -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} diff --git a/app/services/csv_import.py b/app/services/csv_import.py index 97f4bde..770ff1e 100644 --- a/app/services/csv_import.py +++ b/app/services/csv_import.py @@ -130,6 +130,7 @@ def parse_t212_csv(content: str | bytes) -> ParsedPie: positions: list[ParsedPosition] = [] total: ParsedPosition | None = None pie_name: str | None = None + zero_qty_slices = 0 # real slice rows skipped for missing/zero quantity for row_num, row in enumerate(reader, start=2): if not row or not any(cell.strip() for cell in row): @@ -167,6 +168,8 @@ def parse_t212_csv(content: str | bytes) -> ParsedPie: qty = record.get("quantity") if qty is None or qty == 0: # Position row with no usable quantity — skip rather than fail. + # Counted so an all-zero (unfunded) pie yields a precise error. + zero_qty_slices += 1 continue positions.append(ParsedPosition( @@ -182,6 +185,16 @@ def parse_t212_csv(content: str | bytes) -> ParsedPie: )) if not positions: + # Distinguish an unfunded pie (slices present, all 0 quantity) + # from a genuinely unreadable file — the two need very different + # user action, and the generic message misleads people into + # debugging the file format. + if zero_qty_slices: + raise CSVImportError( + f"This pie holds no shares — all {zero_qty_slices} " + f"slice(s) have an Owned quantity of 0. Export the pie from " + f"Trading 212 after it has been funded." + ) raise CSVImportError( "CSV contained no parseable position rows. " "Expected at least one row with a Slice code and quantity." diff --git a/app/services/portfolio_sync.py b/app/services/portfolio_sync.py new file mode 100644 index 0000000..15c9c41 --- /dev/null +++ b/app/services/portfolio_sync.py @@ -0,0 +1,178 @@ +"""Encrypted-pie cloud-sync service. + +The client encrypts the pie locally with a PIN-derived AES-GCM key — we +never see that key. We add a second AES-GCM layer with a per-user key +derived from the env-only PORTFOLIO_SYNC_PEPPER, so a DB-only leak yields +nothing usable. Stored bytes are opaque from the inside (the inner +ciphertext) and from the outside (the outer wrap). + +Threat-model summary: + +- DB leaks, env intact: safe (outer key still secret). +- env leaks, DB intact: safe (no rows to decrypt). +- Both leak: attacker still brute-forces PBKDF2(600k) + over the PIN; the fetch endpoint is + rate-limited to bound online attempts. +- PIN forgotten: unrecoverable. Re-upload the CSV. + +The service is pure: no FastAPI deps, no logging. The router wires it up. +""" +from __future__ import annotations + +import os +from datetime import datetime, timedelta, timezone + +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from cryptography.hazmat.primitives.kdf.hkdf import HKDF +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import get_settings +from app.models import PortfolioSync + + +# AES-GCM standard nonce is 12 bytes; AES-256 needs a 32-byte key. +_NONCE_LEN = 12 +_KEY_LEN = 32 + +# Sliding-window rate limit on GET (the brute-force vector). 6 / 60s ≈ +# 100k attempts/year — slow enough that a 6-digit PIN would take a decade +# even if the pepper leaked. +RATE_LIMIT_WINDOW = timedelta(seconds=60) +RATE_LIMIT_MAX = 6 + + +class SyncCryptoError(Exception): + """Outer-wrap decryption failed — usually a pepper change or + bit-rotted row. The router maps this to a 500.""" + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +def _pepper_bytes() -> bytes: + """Returns the configured pepper as bytes. Empty in dev / tests; that + weakens the outer wrap (key is now derived from user_id alone) but + keeps everything functional.""" + return get_settings().PORTFOLIO_SYNC_PEPPER.encode("utf-8") + + +def _server_key(user_id: int) -> bytes: + """Per-user 32-byte AES key derived from PORTFOLIO_SYNC_PEPPER. HKDF + binds the user_id into the `salt` so two users with the same pepper + get independent keys, and includes a versioned info string so we can + rotate the derivation later without breaking old rows.""" + return HKDF( + algorithm=hashes.SHA256(), + length=_KEY_LEN, + salt=str(user_id).encode("utf-8"), + info=b"portfolio-sync-v1", + ).derive(_pepper_bytes()) + + +def wrap(user_id: int, inner_blob: bytes) -> tuple[bytes, bytes]: + """Encrypt the client-side ciphertext (`inner_blob`) for storage. + Returns (outer_ct, outer_nonce). The nonce is random per write.""" + nonce = os.urandom(_NONCE_LEN) + ct = AESGCM(_server_key(user_id)).encrypt(nonce, inner_blob, None) + return ct, nonce + + +def unwrap(user_id: int, outer_ct: bytes, outer_nonce: bytes) -> bytes: + """Inverse of wrap(). Raises SyncCryptoError if the GCM tag fails.""" + try: + return AESGCM(_server_key(user_id)).decrypt(outer_ct, outer_nonce, None) + except Exception as exc: # InvalidTag, malformed ciphertext, etc. + raise SyncCryptoError("outer wrap unwrap failed") from exc + + +async def upsert(session: AsyncSession, user_id: int, inner_blob: bytes) -> datetime: + """Insert or replace this user's sync row. Returns the new updated_at.""" + outer_ct, outer_nonce = wrap(user_id, inner_blob) + now = _utcnow() + row = await session.get(PortfolioSync, user_id) + if row is None: + row = PortfolioSync( + user_id=user_id, + outer_ciphertext=outer_ct, + outer_nonce=outer_nonce, + version=1, + created_at=now, + updated_at=now, + ) + session.add(row) + else: + row.outer_ciphertext = outer_ct + row.outer_nonce = outer_nonce + row.updated_at = now + # Bump version field forward if we ever change the wrap scheme. + row.version = 1 + await session.commit() + return now + + +async def fetch_status( + session: AsyncSession, user_id: int, +) -> tuple[bool, datetime | None]: + """Cheap existence check — does NOT decrypt. Used by the dashboard to + decide whether to show the restore prompt.""" + row = await session.get(PortfolioSync, user_id) + if row is None: + return False, None + return True, row.updated_at + + +async def fetch( + session: AsyncSession, user_id: int, +) -> tuple[bytes, datetime] | None: + """Returns (inner_blob, updated_at) or None if sync disabled. + + Raises SyncCryptoError if the row exists but the outer wrap is + unreadable (typically: pepper was rotated without re-encrypting). + """ + row = await session.get(PortfolioSync, user_id) + if row is None: + return None + inner = unwrap(user_id, row.outer_ciphertext, row.outer_nonce) + return inner, row.updated_at + + +async def delete(session: AsyncSession, user_id: int) -> bool: + """Returns True if a row was deleted, False if none existed.""" + row = await session.get(PortfolioSync, user_id) + if row is None: + return False + await session.delete(row) + await session.commit() + return True + + +async def consume_fetch_budget(session: AsyncSession, user_id: int) -> bool: + """Sliding-window rate-limiter for GET. Returns True if the call is + within budget (caller proceeds), False if over (caller returns 429). + + Window state lives on the row itself — no need for Redis. On no-row, + the GET handler will 404 anyway; we return True so it can fall + through to that handler. + """ + row = await session.get(PortfolioSync, user_id) + if row is None: + return True + now = _utcnow() + start = row.fetch_window_start + # Normalise: aiomysql sometimes returns naive datetimes. + if start is not None and start.tzinfo is None: + start = start.replace(tzinfo=timezone.utc) + if start is None or now - start >= RATE_LIMIT_WINDOW: + row.fetch_window_start = now + row.fetch_count = 1 + await session.commit() + return True + if row.fetch_count >= RATE_LIMIT_MAX: + # Don't bump — over budget. Window expires on its own; no commit. + return False + row.fetch_count += 1 + await session.commit() + return True diff --git a/app/services/referral_service.py b/app/services/referral_service.py index 91e7b7c..5f663e6 100644 --- a/app/services/referral_service.py +++ b/app/services/referral_service.py @@ -55,20 +55,31 @@ def normalise_code(raw: str | None) -> str | None: async def assign_code_if_missing(session: AsyncSession, user: User) -> User: """Generate + persist a referral code on `user` if they don't have - one yet. Retries on the (very rare) collision.""" + one yet. Retries on the (very rare) collision. + + The `user` argument is the User attached to the auth-dependency + session, which has since been closed — so it is detached from our + `session`. We re-fetch it here before mutating so SQLAlchemy doesn't + refuse with 'not persistent within this Session'. + """ if user.referral_code: return user + db_user = await session.get(User, user.id) + if db_user is None: + raise RuntimeError(f"referral_service: user {user.id} vanished mid-request") + if db_user.referral_code: + # Raced with another request — accept their code. + return db_user for _ in range(8): code = generate_code() existing = (await session.execute( select(User.id).where(User.referral_code == code) )).scalar_one_or_none() if existing is None: - user.referral_code = code + db_user.referral_code = code await session.commit() - await session.refresh(user) - log.info("referral.code_assigned", user_id=user.id, code=code) - return user + log.info("referral.code_assigned", user_id=db_user.id, code=code) + return db_user # 8 collisions in a row would be a statistical event we'd want to # know about. raise RuntimeError("referral_service: exhausted code-collision retries") diff --git a/app/static/css/cassandra.css b/app/static/css/cassandra.css index 391be28..e1e04fc 100644 --- a/app/static/css/cassandra.css +++ b/app/static/css/cassandra.css @@ -82,7 +82,9 @@ a:hover { text-decoration: underline; } .app-header .brand { color: var(--accent); font-weight: 700; + text-decoration: none; } +.app-header .brand:hover { color: var(--text); } .app-header .brand::before { content: "▰ "; opacity: 0.6; } .app-header nav a { margin-left: 18px; @@ -1034,19 +1036,55 @@ details[open] .pf-analysis__head-left::before { content: "▾ "; } border-color: var(--accent) !important; } -/* User chip in header */ +/* Import preview action row — two stacked buttons with an explainer. */ +.import-actions { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-top: 14px; +} +.import-choice { flex: 1 1 240px; min-width: 220px; } +.import-choice button { width: 100%; } +.import-choice .settings-row__hint { + display: block; + margin-top: 6px; + line-height: 1.5; +} + +/* User chip in header — now a button that toggles a dropdown menu. */ +.user-menu { position: relative; margin-left: 8px; } .user-chip { font-family: var(--font-mono); font-size: 10.5px; color: var(--muted); - margin-left: 8px; letter-spacing: 0.04em; + background: none; + border: 0; + padding: 0; + cursor: pointer; } -.user-chip a { - color: var(--muted); - border-bottom: 1px dotted var(--muted); +.user-chip:hover { color: var(--accent); } +.user-menu__caret { margin-left: 4px; opacity: 0.6; } +.user-menu__panel { + position: absolute; + top: calc(100% + 6px); + right: 0; + min-width: 160px; + background: var(--surface-1, var(--surface-2)); + border: 1px solid var(--border); + border-radius: 6px; + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.18); + z-index: 200; + padding: 4px 0; } -.user-chip a:hover { color: var(--accent); border-color: var(--accent); } +.user-menu__item { + display: block; + padding: 8px 14px; + color: var(--text); + text-decoration: none; + font-size: 12px; +} +.user-menu__item:hover { background: var(--surface-2); color: var(--accent); } /* --- Upload page (drag-drop CSV) ------------------------------------- */ diff --git a/app/static/js/portfolio-sync.js b/app/static/js/portfolio-sync.js new file mode 100644 index 0000000..6772dd9 --- /dev/null +++ b/app/static/js/portfolio-sync.js @@ -0,0 +1,280 @@ +/* Cassandra — client-side encrypted portfolio sync. + * + * The server only ever sees opaque ciphertext. The browser: + * 1. Derives an AES-GCM key from the user's PIN with PBKDF2 (600k SHA-256). + * 2. Encrypts the pie JSON, packs salt+nonce+ct into one blob. + * 3. POSTs the blob to /api/portfolio/sync. + * 4. On pull, fetches the blob, reverses the steps with the PIN. + * + * The derived key is cached in sessionStorage so the user enters the PIN + * at most once per browser session (cleared on tab close / logout). The + * server-side outer wrap (see app/services/portfolio_sync.py) hardens the + * stored ciphertext against a DB-only leak. + * + * Packed inner-blob format (all bytes, then base64-url for transport): + * byte 0: version (currently 1) + * bytes 1..4: PBKDF2 iteration count, uint32 big-endian + * bytes 5..20: salt (16 bytes) + * bytes 21..32: nonce (12 bytes) + * bytes 33..: AES-GCM ciphertext (includes 16-byte tag suffix) + */ +(function () { + 'use strict'; + + const VERSION = 1; + const ITERATIONS = 600_000; + const SALT_LEN = 16; + const NONCE_LEN = 12; + const HEADER_LEN = 1 + 4 + SALT_LEN + NONCE_LEN; // = 33 + + const SESSION_KEY_STORAGE = 'cassandra.sync.key.v1'; + const SESSION_SALT_STORAGE = 'cassandra.sync.salt.v1'; + + // --- byte helpers ---------------------------------------------------- + + function u8concat(parts) { + let n = 0; + for (const p of parts) n += p.length; + const out = new Uint8Array(n); + let i = 0; + for (const p of parts) { out.set(p, i); i += p.length; } + return out; + } + + function b64urlEncode(bytes) { + let s = ''; + const chunk = 0x8000; + for (let i = 0; i < bytes.length; i += chunk) { + s += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk)); + } + return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + } + + function b64urlDecode(s) { + const norm = s.replace(/-/g, '+').replace(/_/g, '/'); + const padded = norm + '='.repeat((4 - norm.length % 4) % 4); + const bin = atob(padded); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; + } + + // --- WebCrypto ------------------------------------------------------- + + async function pbkdf2Derive(pin, salt, iterations) { + const baseKey = await crypto.subtle.importKey( + 'raw', + new TextEncoder().encode(pin), + { name: 'PBKDF2' }, + false, + ['deriveKey'], + ); + return crypto.subtle.deriveKey( + { name: 'PBKDF2', salt, iterations, hash: 'SHA-256' }, + baseKey, + { name: 'AES-GCM', length: 256 }, + true, // extractable: we cache raw bytes in sessionStorage + ['encrypt', 'decrypt'], + ); + } + + async function exportKey(key) { + const raw = await crypto.subtle.exportKey('raw', key); + return new Uint8Array(raw); + } + + async function importKey(raw) { + return crypto.subtle.importKey( + 'raw', raw, { name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt'], + ); + } + + // --- session cache --------------------------------------------------- + + // We cache both the raw key AND the salt that produced it, so a push + // after upload can rebuild the same packed-blob header without + // re-prompting for a PIN. Lives in sessionStorage so it dies with the + // tab. + function cacheKey(rawKey, salt) { + try { + sessionStorage.setItem(SESSION_KEY_STORAGE, b64urlEncode(rawKey)); + sessionStorage.setItem(SESSION_SALT_STORAGE, b64urlEncode(salt)); + } catch (e) { + console.warn('cassandra.sync: sessionStorage write failed', e); + } + } + + async function getCachedKeyAndSalt() { + const rk = sessionStorage.getItem(SESSION_KEY_STORAGE); + const sk = sessionStorage.getItem(SESSION_SALT_STORAGE); + if (!rk || !sk) return null; + return { + key: await importKey(b64urlDecode(rk)), + salt: b64urlDecode(sk), + }; + } + + function clearCachedKey() { + sessionStorage.removeItem(SESSION_KEY_STORAGE); + sessionStorage.removeItem(SESSION_SALT_STORAGE); + } + + // --- pack / unpack --------------------------------------------------- + + function packBlob(salt, nonce, ct, iterations) { + const header = new Uint8Array(HEADER_LEN); + header[0] = VERSION; + new DataView(header.buffer).setUint32(1, iterations, false); // big-endian + header.set(salt, 5); + header.set(nonce, 5 + SALT_LEN); + return u8concat([header, new Uint8Array(ct)]); + } + + function unpackBlob(bytes) { + if (bytes.length < HEADER_LEN + 16) { + throw new Error('blob too small'); + } + const version = bytes[0]; + if (version !== VERSION) { + throw new Error('unknown sync blob version: ' + version); + } + const iterations = new DataView(bytes.buffer, bytes.byteOffset, HEADER_LEN) + .getUint32(1, false); + const salt = bytes.slice(5, 5 + SALT_LEN); + const nonce = bytes.slice(5 + SALT_LEN, HEADER_LEN); + const ct = bytes.slice(HEADER_LEN); + return { version, iterations, salt, nonce, ct }; + } + + // --- encrypt / decrypt ---------------------------------------------- + + /** + * Encrypt a pie object with `pin`. Returns the packed blob as a + * base64url string ready for POST /api/portfolio/sync. Also caches + * the derived key in sessionStorage so subsequent pushes don't need + * the PIN. + */ + async function encryptPie(pie, pin) { + const salt = crypto.getRandomValues(new Uint8Array(SALT_LEN)); + const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LEN)); + const key = await pbkdf2Derive(pin, salt, ITERATIONS); + const plaintext = new TextEncoder().encode(JSON.stringify(pie)); + const ct = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv: nonce }, key, plaintext, + ); + cacheKey(await exportKey(key), salt); + return b64urlEncode(packBlob(salt, nonce, ct, ITERATIONS)); + } + + /** + * Re-encrypt with a cached key (no PIN needed). Re-uses the cached + * salt so the blob remains decryptable with the same PIN later. + * Returns null if no key is cached. + */ + async function encryptPieWithCachedKey(pie) { + const cached = await getCachedKeyAndSalt(); + if (!cached) return null; + const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LEN)); + const plaintext = new TextEncoder().encode(JSON.stringify(pie)); + const ct = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv: nonce }, cached.key, plaintext, + ); + return b64urlEncode(packBlob(cached.salt, nonce, ct, ITERATIONS)); + } + + /** + * Decrypt a server blob with `pin`. Throws BadPinError on auth failure. + * Caches the derived key on success. + */ + class BadPinError extends Error { + constructor() { super('Incorrect PIN'); this.name = 'BadPinError'; } + } + + async function decryptBlob(blobB64, pin) { + const bytes = b64urlDecode(blobB64); + const { iterations, salt, nonce, ct } = unpackBlob(bytes); + const key = await pbkdf2Derive(pin, salt, iterations); + let plaintext; + try { + plaintext = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv: nonce }, key, ct, + ); + } catch (_e) { + throw new BadPinError(); + } + cacheKey(await exportKey(key), salt); + return JSON.parse(new TextDecoder().decode(plaintext)); + } + + // --- network --------------------------------------------------------- + + async function getStatus() { + const r = await fetch('/api/portfolio/sync/status', { + credentials: 'same-origin', + headers: { 'Accept': 'application/json' }, + }); + if (r.status === 402) return { exists: false, paid: false }; + if (!r.ok) throw new Error('sync status: HTTP ' + r.status); + const body = await r.json(); + return { exists: !!body.exists, updated_at: body.updated_at, paid: true }; + } + + async function pushSync(pie, pin) { + // If a cached key exists, re-use it; otherwise derive from the PIN. + let blob = await encryptPieWithCachedKey(pie); + if (!blob) { + if (!pin) throw new Error('PIN required to enable sync'); + blob = await encryptPie(pie, pin); + } + const r = await fetch('/api/portfolio/sync', { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ blob }), + }); + if (!r.ok) { + const body = await r.json().catch(() => ({})); + throw new Error(body.detail || ('sync push: HTTP ' + r.status)); + } + return r.json(); + } + + async function pullSync(pin) { + const r = await fetch('/api/portfolio/sync', { + credentials: 'same-origin', + headers: { 'Accept': 'application/json' }, + }); + if (r.status === 404) return null; + if (!r.ok) { + const body = await r.json().catch(() => ({})); + // 429 → server already throttling; bubble the message up unchanged. + throw new Error(body.detail || ('sync pull: HTTP ' + r.status)); + } + const { blob } = await r.json(); + return decryptBlob(blob, pin); + } + + async function disableSync() { + const r = await fetch('/api/portfolio/sync', { + method: 'DELETE', + credentials: 'same-origin', + }); + if (!r.ok) throw new Error('sync delete: HTTP ' + r.status); + clearCachedKey(); + return r.json(); + } + + window.CassandraSync = { + getStatus, + encryptPie, + decryptBlob, + pushSync, + pullSync, + disableSync, + clearCachedKey, + BadPinError, + // Exposed for tests / debugging: + _packBlob: packBlob, + _unpackBlob: unpackBlob, + }; +})(); diff --git a/app/static/js/portfolio.js b/app/static/js/portfolio.js index a6c8bbe..d40dcde 100644 --- a/app/static/js/portfolio.js +++ b/app/static/js/portfolio.js @@ -168,10 +168,58 @@ mount.innerHTML = '
' + 'No portfolio loaded in this browser. ' + - 'Import a T212 CSV →' + + 'Import a T212 CSV →' + '
'; } + function renderRestoreFromCloud(mount, status) { + const lastSynced = status.updated_at + ? new Date(status.updated_at).toISOString().replace('T', ' ').slice(0, 16) + ' UTC' + : '—'; + mount.innerHTML = + '
' + + '
▸ Restore from cloud
' + + '
' + + 'A synced portfolio is available for this account (last synced ' + + esc(lastSynced) + '). Enter your PIN to load it on this browser.' + + '
' + + '
' + + '' + + '' + + '' + + 'or import a new CSV →' + + '
' + + '' + + '
'; + + const form = document.getElementById('pf-restore-form'); + const pin = document.getElementById('pf-restore-pin'); + const err = document.getElementById('pf-restore-err'); + form.addEventListener('submit', async (e) => { + e.preventDefault(); + err.hidden = true; + const value = (pin.value || '').trim(); + if (!value) return; + try { + const pie = await window.CassandraSync.pullSync(value); + if (!pie) { + err.textContent = 'No synced portfolio found.'; + err.hidden = false; + return; + } + savePie(pie); + mountAndRender(); + } catch (e2) { + err.textContent = (e2 && e2.name === 'BadPinError') + ? 'Incorrect PIN.' + : (e2.message || 'Could not restore.'); + err.hidden = false; + } + }); + } + function renderPanel(mount, pie, enriched, agg) { const ccyPills = Object.keys(agg.by_currency) .sort((a, b) => agg.by_currency[b] - agg.by_currency[a]) @@ -331,7 +379,15 @@ }); const data = await r.json(); if (!r.ok) { - out.innerHTML = '
' + esc(data.detail || ('HTTP ' + r.status)) + '
'; + // FastAPI `detail` is usually a string, but some endpoints send + // an object — e.g. the 402 paid-gate returns {code, message}. + // Render the human-readable text either way; never the object + // (which stringifies to the useless "[object Object]"). + const d = data && data.detail; + const msg = (d && typeof d === 'object') + ? (d.message || JSON.stringify(d)) + : (d || ('HTTP ' + r.status)); + out.innerHTML = '
' + esc(msg) + '
'; return; } // Persist before rendering so auto-refresh can re-hydrate. @@ -351,7 +407,20 @@ if (!mount) return; const pie = loadPie(); if (!pie || !pie.positions || !pie.positions.length) { - renderEmpty(mount); + // Before falling back to "no portfolio", check whether the account + // has a synced blob this device could restore from. Status is + // 402 for free-tier users — getStatus() returns paid:false there + // and we fall through to the standard empty state. + let status = null; + if (window.CassandraSync) { + try { status = await window.CassandraSync.getStatus(); } + catch (e) { console.warn('sync status check failed', e); } + } + if (status && status.paid && status.exists) { + renderRestoreFromCloud(mount, status); + } else { + renderEmpty(mount); + } return; } try { @@ -369,72 +438,42 @@ renderPanel(mount, pie, enriched, agg); } - // --- Upload page helper ------------------------------------------------ - - async function handleUpload(form, file, statusEl) { - statusEl.className = 'result'; - statusEl.hidden = true; - + // --- Parse primitive --------------------------------------------------- + // + // Hits /api/portfolio/parse and returns the parsed pie. The caller + // decides whether to savePie() and whether to push to cloud sync — keeps + // the post-parse decision in the inline UI script instead of buried in + // this module. + async function parseCsv(file) { const fd = new FormData(); fd.append('file', file); - - try { - const r = await fetch('/api/portfolio/parse', { - method: 'POST', - body: fd, - credentials: 'same-origin', - }); - const data = await r.json(); - if (!r.ok) { - statusEl.className = 'result result--err'; - statusEl.innerHTML = - '
✕ Import failed
' + - '
' + esc(data.detail || ('HTTP ' + r.status)) + '
'; - statusEl.hidden = false; - return false; - } - savePie(data); - - const warnings = (data.warnings || []).map(w => - '
' + esc(w) + '
').join(''); - - statusEl.className = 'result result--ok'; - statusEl.innerHTML = - '
' + - '▸ Parsed ' + esc(data.pie_name || 'pie') + ' · ' + - 'stored locally' + - '
' + - '
' + - '
Positions
' + data.positions.length + '
' + - '
Invested
' + fmt(data.totals && data.totals.invested) + '
' + - '
Value
' + fmt(data.totals && data.totals.value) + '
' + - '
Result
' + - signed(data.totals && data.totals.result) + '
' + - '
' + - warnings + - '
' + - 'Open dashboard →' + - '
'; - statusEl.hidden = false; - return true; - } catch (err) { - statusEl.className = 'result result--err'; - statusEl.innerHTML = - '
✕ Import failed
' + - '
' + esc(err.message) + '
'; - statusEl.hidden = false; - return false; + const r = await fetch('/api/portfolio/parse', { + method: 'POST', + body: fd, + credentials: 'same-origin', + }); + const data = await r.json().catch(() => ({})); + if (!r.ok) { + const err = new Error(data.detail || ('HTTP ' + r.status)); + err.status = r.status; + throw err; } + return data; } - // Public surface — usable from inline scripts on upload.html. + // Formatting helpers exposed so inline UI scripts (like the import + // preview in settings.html) don't have to re-implement them. window.CassandraPortfolio = { mountAndRender, - handleUpload, + parseCsv, loadPie, savePie, clearPie, + fmt, + signed, + pct, + cls, + esc, }; // Auto-mount on dashboard load and refresh every minute. diff --git a/app/templates/base.html b/app/templates/base.html index 4a2f402..29a2425 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -137,13 +137,11 @@
-
{{ BRAND_NAME }}
+ {{ BRAND_NAME }}
{% set cu = request.state.current_user if request.state and request.state.current_user is defined else None %} - {% if cu and cu.user %} - {{ cu.user.email }} · logout - {% elif cu and cu.is_admin %} - admin · logout + {% if cu and (cu.user or cu.is_admin) %} +
+ + +
{% endif %} v0.1 · UTC
+ +
{% block main %}{% endblock %}
diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index 6c1efcc..8e778d1 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -55,6 +55,7 @@
+
diff --git a/app/templates/settings.html b/app/templates/settings.html index 2679c59..ecdd25a 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -39,6 +39,28 @@ + {# --- Import portfolio --------------------------------------------- #} +
+
Import portfolio (Trading 212 CSV)
+

+ Export your pie from T212 + (Investing → Your Pie → ··· → Export) + and drop the CSV here. We’ll parse it and show a preview before + importing anywhere. +

+ +
+ +
+
Drop a T212 pie CSV here
+
or browse · max 1 MB
+
+
+ + + +
+ {# --- Referral block ---------------------------------------------- #}
Invite a friend
@@ -74,6 +96,31 @@
+ {# --- Cloud sync block --------------------------------------------- #} +
+
Cloud sync (encrypted)
+

+ Manage the encrypted server-side copy of your portfolio. Sync is + opted-in per import (see the Import section above). +

+ + {% if paid and paid.active %} +
+
Status
+
+ checking… +
+
+
+
+ {% else %} +

+ Available on the paid tier. Upgrade or apply an invite credit + above to enable cloud sync. +

+ {% endif %} +
+ {# Future: Paddle subscription block, AI-spend ledger summary, etc. #} {% endif %} @@ -81,6 +128,187 @@
+{% if user and paid and paid.active %} + + + + +{% endif %} + + +{% if user %} +{# Import widget wiring — auto-parse on drop, preview, then commit. #} + + +{% endif %} {% endblock %} diff --git a/app/templates/upload.html b/app/templates/upload.html index 4233d3e..0c397e8 100644 --- a/app/templates/upload.html +++ b/app/templates/upload.html @@ -5,7 +5,7 @@
Import portfolio (Trading 212 CSV) - stays in your browser · never persists server-side + held locally · optional encrypted cloud sync (paid)
@@ -13,9 +13,11 @@ Export your pie from the T212 web app (Trading 212 → Investing → Your Pie → ⋯ → Export) and drop the CSV here. Each Slice is resolved to its Yahoo ticker; - the parsed pie is kept in this browser's localStorage only. - The server learns just which tickers exist (anonymously) so it can - fetch their prices. + the parsed pie is kept in this browser's localStorage. + The server learns only which tickers exist (anonymously) so it can + fetch their prices. If you have cloud sync + enabled, an encrypted copy is also pushed to the + server — only your PIN can decrypt it.

@@ -34,6 +36,7 @@
+