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
|
|
@ -21,7 +21,11 @@ config = context.config
|
||||||
config.set_main_option("sqlalchemy.url", get_settings().DATABASE_URL)
|
config.set_main_option("sqlalchemy.url", get_settings().DATABASE_URL)
|
||||||
|
|
||||||
if config.config_file_name is not None:
|
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
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
|
|
||||||
43
alembic/versions/0015_portfolio_sync.py
Normal file
43
alembic/versions/0015_portfolio_sync.py
Normal file
|
|
@ -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")
|
||||||
|
|
@ -63,6 +63,13 @@ class Settings(BaseSettings):
|
||||||
CASSANDRA_ANCHOR_DATE: str = ""
|
CASSANDRA_ANCHOR_DATE: str = ""
|
||||||
CASSANDRA_MOCK: bool = False
|
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.
|
# AI log — provider abstraction with fallback chain.
|
||||||
# `LLM_PROVIDER` is the primary; `LLM_FALLBACK` kicks in if the primary
|
# `LLM_PROVIDER` is the primary; `LLM_FALLBACK` kicks in if the primary
|
||||||
# raises (after its own internal retries). Set LLM_FALLBACK="" to
|
# raises (after its own internal retries). Set LLM_FALLBACK="" to
|
||||||
|
|
|
||||||
27
app/db.py
27
app/db.py
|
|
@ -31,12 +31,27 @@ def get_engine():
|
||||||
global _engine
|
global _engine
|
||||||
if _engine is None:
|
if _engine is None:
|
||||||
s = get_settings()
|
s = get_settings()
|
||||||
_engine = create_async_engine(
|
# NB: pool_pre_ping is intentionally OFF. aiomysql 0.3.x made
|
||||||
s.DATABASE_URL,
|
# Connection.ping()'s `reconnect` arg mandatory, but SQLAlchemy's
|
||||||
pool_pre_ping=True,
|
# MySQL pre-ping (2.0.49) calls it without that arg — so every
|
||||||
pool_recycle=3600,
|
# reused pooled connection raises TypeError, surfacing as an
|
||||||
future=True,
|
# 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
|
return _engine
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ from app.logging import configure_logging, get_logger
|
||||||
from app.routers import api as api_router
|
from app.routers import api as api_router
|
||||||
from app.routers import auth as auth_router
|
from app.routers import auth as auth_router
|
||||||
from app.routers import pages as pages_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.routers import universe as universe_router
|
||||||
from app.services.feeds_bootstrap import bootstrap_feeds
|
from app.services.feeds_bootstrap import bootstrap_feeds
|
||||||
|
|
||||||
|
|
@ -41,6 +42,12 @@ def _run_migrations() -> None:
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
configure_logging()
|
configure_logging()
|
||||||
log.info("cassandra.startup")
|
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:
|
try:
|
||||||
# Alembic's env.py uses asyncio.run() internally; offload it to a
|
# Alembic's env.py uses asyncio.run() internally; offload it to a
|
||||||
# worker thread so it doesn't collide with FastAPI's running loop.
|
# 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(auth_router.router, tags=["auth"])
|
||||||
app.include_router(api_router.router, prefix="/api", tags=["api"])
|
app.include_router(api_router.router, prefix="/api", tags=["api"])
|
||||||
app.include_router(universe_router.router, prefix="/api", tags=["universe"])
|
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"])
|
app.include_router(pages_router.router, tags=["pages"])
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ from sqlalchemy import (
|
||||||
ForeignKey,
|
ForeignKey,
|
||||||
Index,
|
Index,
|
||||||
Integer,
|
Integer,
|
||||||
|
LargeBinary,
|
||||||
|
SmallInteger,
|
||||||
String,
|
String,
|
||||||
Text,
|
Text,
|
||||||
UniqueConstraint,
|
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):
|
class Referral(Base):
|
||||||
"""One row per captured (referrer, referred) pair. Created at signup
|
"""One row per captured (referrer, referred) pair. Created at signup
|
||||||
when the new user supplied a valid `?ref=<code>`. The conversion
|
when the new user supplied a valid `?ref=<code>`. The conversion
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ from __future__ import annotations
|
||||||
from datetime import date, datetime, timezone
|
from datetime import date, datetime, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Request
|
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 import desc, func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
|
@ -35,10 +35,10 @@ async def news_page(request: Request):
|
||||||
return templates.TemplateResponse(request, "news.html", {})
|
return templates.TemplateResponse(request, "news.html", {})
|
||||||
|
|
||||||
|
|
||||||
@router.get("/upload", response_class=HTMLResponse)
|
@router.get("/upload")
|
||||||
async def upload_page(request: Request):
|
async def upload_page(request: Request):
|
||||||
"""Drag-drop CSV import. Posts to /api/portfolios/upload."""
|
"""Legacy bookmark — the import widget now lives in /settings."""
|
||||||
return templates.TemplateResponse(request, "upload.html", {})
|
return RedirectResponse(url="/settings#import", status_code=302)
|
||||||
|
|
||||||
|
|
||||||
async def _resolve_log_date(session: AsyncSession, day: str | None) -> date:
|
async def _resolve_log_date(session: AsyncSession, day: str | None) -> date:
|
||||||
|
|
|
||||||
133
app/routers/sync.py
Normal file
133
app/routers/sync.py
Normal 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}
|
||||||
|
|
@ -130,6 +130,7 @@ def parse_t212_csv(content: str | bytes) -> ParsedPie:
|
||||||
positions: list[ParsedPosition] = []
|
positions: list[ParsedPosition] = []
|
||||||
total: ParsedPosition | None = None
|
total: ParsedPosition | None = None
|
||||||
pie_name: str | 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):
|
for row_num, row in enumerate(reader, start=2):
|
||||||
if not row or not any(cell.strip() for cell in row):
|
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")
|
qty = record.get("quantity")
|
||||||
if qty is None or qty == 0:
|
if qty is None or qty == 0:
|
||||||
# Position row with no usable quantity — skip rather than fail.
|
# 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
|
continue
|
||||||
|
|
||||||
positions.append(ParsedPosition(
|
positions.append(ParsedPosition(
|
||||||
|
|
@ -182,6 +185,16 @@ def parse_t212_csv(content: str | bytes) -> ParsedPie:
|
||||||
))
|
))
|
||||||
|
|
||||||
if not positions:
|
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(
|
raise CSVImportError(
|
||||||
"CSV contained no parseable position rows. "
|
"CSV contained no parseable position rows. "
|
||||||
"Expected at least one row with a Slice code and quantity."
|
"Expected at least one row with a Slice code and quantity."
|
||||||
|
|
|
||||||
178
app/services/portfolio_sync.py
Normal file
178
app/services/portfolio_sync.py
Normal file
|
|
@ -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
|
||||||
|
|
@ -55,20 +55,31 @@ def normalise_code(raw: str | None) -> str | None:
|
||||||
|
|
||||||
async def assign_code_if_missing(session: AsyncSession, user: User) -> User:
|
async def assign_code_if_missing(session: AsyncSession, user: User) -> User:
|
||||||
"""Generate + persist a referral code on `user` if they don't have
|
"""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:
|
if user.referral_code:
|
||||||
return user
|
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):
|
for _ in range(8):
|
||||||
code = generate_code()
|
code = generate_code()
|
||||||
existing = (await session.execute(
|
existing = (await session.execute(
|
||||||
select(User.id).where(User.referral_code == code)
|
select(User.id).where(User.referral_code == code)
|
||||||
)).scalar_one_or_none()
|
)).scalar_one_or_none()
|
||||||
if existing is None:
|
if existing is None:
|
||||||
user.referral_code = code
|
db_user.referral_code = code
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(user)
|
log.info("referral.code_assigned", user_id=db_user.id, code=code)
|
||||||
log.info("referral.code_assigned", user_id=user.id, code=code)
|
return db_user
|
||||||
return user
|
|
||||||
# 8 collisions in a row would be a statistical event we'd want to
|
# 8 collisions in a row would be a statistical event we'd want to
|
||||||
# know about.
|
# know about.
|
||||||
raise RuntimeError("referral_service: exhausted code-collision retries")
|
raise RuntimeError("referral_service: exhausted code-collision retries")
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,9 @@ a:hover { text-decoration: underline; }
|
||||||
.app-header .brand {
|
.app-header .brand {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
.app-header .brand:hover { color: var(--text); }
|
||||||
.app-header .brand::before { content: "▰ "; opacity: 0.6; }
|
.app-header .brand::before { content: "▰ "; opacity: 0.6; }
|
||||||
.app-header nav a {
|
.app-header nav a {
|
||||||
margin-left: 18px;
|
margin-left: 18px;
|
||||||
|
|
@ -1034,19 +1036,55 @@ details[open] .pf-analysis__head-left::before { content: "▾ "; }
|
||||||
border-color: var(--accent) !important;
|
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 {
|
.user-chip {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 10.5px;
|
font-size: 10.5px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
margin-left: 8px;
|
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
|
background: none;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.user-chip a {
|
.user-chip:hover { color: var(--accent); }
|
||||||
color: var(--muted);
|
.user-menu__caret { margin-left: 4px; opacity: 0.6; }
|
||||||
border-bottom: 1px dotted var(--muted);
|
.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) ------------------------------------- */
|
/* --- Upload page (drag-drop CSV) ------------------------------------- */
|
||||||
|
|
||||||
|
|
|
||||||
280
app/static/js/portfolio-sync.js
Normal file
280
app/static/js/portfolio-sync.js
Normal file
|
|
@ -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,
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
@ -168,10 +168,58 @@
|
||||||
mount.innerHTML =
|
mount.innerHTML =
|
||||||
'<div class="empty" style="padding:16px;">' +
|
'<div class="empty" style="padding:16px;">' +
|
||||||
'No portfolio loaded in this browser. ' +
|
'No portfolio loaded in this browser. ' +
|
||||||
'<a href="/upload">Import a T212 CSV →</a>' +
|
'<a href="/settings#import">Import a T212 CSV →</a>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderRestoreFromCloud(mount, status) {
|
||||||
|
const lastSynced = status.updated_at
|
||||||
|
? new Date(status.updated_at).toISOString().replace('T', ' ').slice(0, 16) + ' UTC'
|
||||||
|
: '—';
|
||||||
|
mount.innerHTML =
|
||||||
|
'<div class="pf-restore" style="padding:16px;">' +
|
||||||
|
'<div class="result__head">▸ Restore from cloud</div>' +
|
||||||
|
'<div class="result__row" style="margin-bottom:12px;">' +
|
||||||
|
'A synced portfolio is available for this account (last synced ' +
|
||||||
|
esc(lastSynced) + '). Enter your PIN to load it on this browser.' +
|
||||||
|
'</div>' +
|
||||||
|
'<form id="pf-restore-form" style="display:flex; gap:8px; align-items:center;">' +
|
||||||
|
'<input id="pf-restore-pin" type="password" inputmode="numeric" ' +
|
||||||
|
'autocomplete="off" placeholder="PIN" ' +
|
||||||
|
'style="flex:0 0 140px;">' +
|
||||||
|
'<button type="submit">Restore</button>' +
|
||||||
|
'<a href="/settings#import" class="settings-row__hint" style="margin-left:auto;">' +
|
||||||
|
'or import a new CSV →</a>' +
|
||||||
|
'</form>' +
|
||||||
|
'<div id="pf-restore-err" class="pf-warn" hidden style="margin-top:10px;"></div>' +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
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) {
|
function renderPanel(mount, pie, enriched, agg) {
|
||||||
const ccyPills = Object.keys(agg.by_currency)
|
const ccyPills = Object.keys(agg.by_currency)
|
||||||
.sort((a, b) => agg.by_currency[b] - agg.by_currency[a])
|
.sort((a, b) => agg.by_currency[b] - agg.by_currency[a])
|
||||||
|
|
@ -331,7 +379,15 @@
|
||||||
});
|
});
|
||||||
const data = await r.json();
|
const data = await r.json();
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
out.innerHTML = '<div class="pf-warn">' + esc(data.detail || ('HTTP ' + r.status)) + '</div>';
|
// 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 = '<div class="pf-warn">' + esc(msg) + '</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Persist before rendering so auto-refresh can re-hydrate.
|
// Persist before rendering so auto-refresh can re-hydrate.
|
||||||
|
|
@ -351,7 +407,20 @@
|
||||||
if (!mount) return;
|
if (!mount) return;
|
||||||
const pie = loadPie();
|
const pie = loadPie();
|
||||||
if (!pie || !pie.positions || !pie.positions.length) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|
@ -369,72 +438,42 @@
|
||||||
renderPanel(mount, pie, enriched, agg);
|
renderPanel(mount, pie, enriched, agg);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Upload page helper ------------------------------------------------
|
// --- Parse primitive ---------------------------------------------------
|
||||||
|
//
|
||||||
async function handleUpload(form, file, statusEl) {
|
// Hits /api/portfolio/parse and returns the parsed pie. The caller
|
||||||
statusEl.className = 'result';
|
// decides whether to savePie() and whether to push to cloud sync — keeps
|
||||||
statusEl.hidden = true;
|
// the post-parse decision in the inline UI script instead of buried in
|
||||||
|
// this module.
|
||||||
|
async function parseCsv(file) {
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('file', file);
|
fd.append('file', file);
|
||||||
|
const r = await fetch('/api/portfolio/parse', {
|
||||||
try {
|
method: 'POST',
|
||||||
const r = await fetch('/api/portfolio/parse', {
|
body: fd,
|
||||||
method: 'POST',
|
credentials: 'same-origin',
|
||||||
body: fd,
|
});
|
||||||
credentials: 'same-origin',
|
const data = await r.json().catch(() => ({}));
|
||||||
});
|
if (!r.ok) {
|
||||||
const data = await r.json();
|
const err = new Error(data.detail || ('HTTP ' + r.status));
|
||||||
if (!r.ok) {
|
err.status = r.status;
|
||||||
statusEl.className = 'result result--err';
|
throw err;
|
||||||
statusEl.innerHTML =
|
|
||||||
'<div class="result__head">✕ Import failed</div>' +
|
|
||||||
'<div class="result__row">' + esc(data.detail || ('HTTP ' + r.status)) + '</div>';
|
|
||||||
statusEl.hidden = false;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
savePie(data);
|
|
||||||
|
|
||||||
const warnings = (data.warnings || []).map(w =>
|
|
||||||
'<div class="result__warn">' + esc(w) + '</div>').join('');
|
|
||||||
|
|
||||||
statusEl.className = 'result result--ok';
|
|
||||||
statusEl.innerHTML =
|
|
||||||
'<div class="result__head">' +
|
|
||||||
'▸ Parsed <strong>' + esc(data.pie_name || 'pie') + '</strong> · ' +
|
|
||||||
'<span class="result__tag">stored locally</span>' +
|
|
||||||
'</div>' +
|
|
||||||
'<div class="result__grid">' +
|
|
||||||
'<div><div class="k">Positions</div><div class="v">' + data.positions.length + '</div></div>' +
|
|
||||||
'<div><div class="k">Invested</div><div class="v">' + fmt(data.totals && data.totals.invested) + '</div></div>' +
|
|
||||||
'<div><div class="k">Value</div><div class="v">' + fmt(data.totals && data.totals.value) + '</div></div>' +
|
|
||||||
'<div><div class="k">Result</div><div class="v ' +
|
|
||||||
((data.totals && data.totals.result >= 0) ? 'pos' : 'neg') + '">' +
|
|
||||||
signed(data.totals && data.totals.result) + '</div></div>' +
|
|
||||||
'</div>' +
|
|
||||||
warnings +
|
|
||||||
'<div class="result__row" style="margin-top:14px;">' +
|
|
||||||
'<a href="/">Open dashboard →</a>' +
|
|
||||||
'</div>';
|
|
||||||
statusEl.hidden = false;
|
|
||||||
return true;
|
|
||||||
} catch (err) {
|
|
||||||
statusEl.className = 'result result--err';
|
|
||||||
statusEl.innerHTML =
|
|
||||||
'<div class="result__head">✕ Import failed</div>' +
|
|
||||||
'<div class="result__row">' + esc(err.message) + '</div>';
|
|
||||||
statusEl.hidden = false;
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
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 = {
|
window.CassandraPortfolio = {
|
||||||
mountAndRender,
|
mountAndRender,
|
||||||
handleUpload,
|
parseCsv,
|
||||||
loadPie,
|
loadPie,
|
||||||
savePie,
|
savePie,
|
||||||
clearPie,
|
clearPie,
|
||||||
|
fmt,
|
||||||
|
signed,
|
||||||
|
pct,
|
||||||
|
cls,
|
||||||
|
esc,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Auto-mount on dashboard load and refresh every minute.
|
// Auto-mount on dashboard load and refresh every minute.
|
||||||
|
|
|
||||||
|
|
@ -137,13 +137,11 @@
|
||||||
<body>
|
<body>
|
||||||
<div class="app">
|
<div class="app">
|
||||||
<header class="app-header">
|
<header class="app-header">
|
||||||
<div class="brand">{{ BRAND_NAME }}</div>
|
<a href="/" class="brand" aria-label="Dashboard">{{ BRAND_NAME }}</a>
|
||||||
<nav>
|
<nav>
|
||||||
<a href="/" class="{% if request.url.path == '/' %}active{% endif %}">Dashboard</a>
|
<a href="/" class="{% if request.url.path == '/' %}active{% endif %}">Dashboard</a>
|
||||||
<a href="/news" class="{% if request.url.path == '/news' %}active{% endif %}">News</a>
|
<a href="/news" class="{% if request.url.path == '/news' %}active{% endif %}">News</a>
|
||||||
<a href="/log" class="{% if request.url.path.startswith('/log') %}active{% endif %}">Log</a>
|
<a href="/log" class="{% if request.url.path.startswith('/log') %}active{% endif %}">Log</a>
|
||||||
<a href="/upload" class="{% if request.url.path == '/upload' %}active{% endif %}">Import</a>
|
|
||||||
<a href="/settings" class="{% if request.url.path == '/settings' %}active{% endif %}">Settings</a>
|
|
||||||
</nav>
|
</nav>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
<div id="tone-toggle" class="tone-toggle" data-tone="INTERMEDIATE"
|
<div id="tone-toggle" class="tone-toggle" data-tone="INTERMEDIATE"
|
||||||
|
|
@ -158,15 +156,45 @@
|
||||||
<span class="theme-toggle__label"></span>
|
<span class="theme-toggle__label"></span>
|
||||||
</button>
|
</button>
|
||||||
{% set cu = request.state.current_user if request.state and request.state.current_user is defined else None %}
|
{% set cu = request.state.current_user if request.state and request.state.current_user is defined else None %}
|
||||||
{% if cu and cu.user %}
|
{% if cu and (cu.user or cu.is_admin) %}
|
||||||
<span class="user-chip">{{ cu.user.email }} · <a href="/logout">logout</a></span>
|
<div class="user-menu">
|
||||||
{% elif cu and cu.is_admin %}
|
<button type="button" id="user-menu-toggle" class="user-chip"
|
||||||
<span class="user-chip">admin · <a href="/logout">logout</a></span>
|
aria-haspopup="true" aria-expanded="false">
|
||||||
|
{% if cu.user %}{{ cu.user.email }}{% else %}admin{% endif %}
|
||||||
|
<span class="user-menu__caret">▾</span>
|
||||||
|
</button>
|
||||||
|
<div id="user-menu" class="user-menu__panel" role="menu" hidden>
|
||||||
|
{% if cu.user %}
|
||||||
|
<a href="/settings" role="menuitem" class="user-menu__item">Settings</a>
|
||||||
|
{% endif %}
|
||||||
|
<a href="/logout" role="menuitem" class="user-menu__item">Logout</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<span class="meta">v0.1 · UTC</span>
|
<span class="meta">v0.1 · UTC</span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var btn = document.getElementById('user-menu-toggle');
|
||||||
|
var menu = document.getElementById('user-menu');
|
||||||
|
if (!btn || !menu) return;
|
||||||
|
function close() { menu.hidden = true; btn.setAttribute('aria-expanded','false'); }
|
||||||
|
function open() { menu.hidden = false; btn.setAttribute('aria-expanded','true'); }
|
||||||
|
btn.addEventListener('click', function (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (menu.hidden) open(); else close();
|
||||||
|
});
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
if (!menu.hidden && !menu.contains(e.target) && e.target !== btn) close();
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Escape') close();
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
<main class="app-main">
|
<main class="app-main">
|
||||||
{% block main %}{% endblock %}
|
{% block main %}{% endblock %}
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<script src="{{ url_for('static', path='/js/portfolio-sync.js') }}" defer></script>
|
||||||
<script src="{{ url_for('static', path='/js/portfolio.js') }}" defer></script>
|
<script src="{{ url_for('static', path='/js/portfolio.js') }}" defer></script>
|
||||||
|
|
||||||
<section id="log-panel" class="panel">
|
<section id="log-panel" class="panel">
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,28 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# --- Import portfolio --------------------------------------------- #}
|
||||||
|
<div class="settings-section" id="import">
|
||||||
|
<div class="settings-section__head">Import portfolio (Trading 212 CSV)</div>
|
||||||
|
<p class="settings-section__lede">
|
||||||
|
Export your pie from T212
|
||||||
|
(<span class="neu">Investing → Your Pie → ··· → Export</span>)
|
||||||
|
and drop the CSV here. We’ll parse it and show a preview before
|
||||||
|
importing anywhere.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div id="drop-zone" class="dz">
|
||||||
|
<input type="file" id="file-input" name="file" accept=".csv,text/csv" hidden>
|
||||||
|
<div class="dz__icon">▱</div>
|
||||||
|
<div class="dz__label">Drop a T212 pie CSV here</div>
|
||||||
|
<div class="dz__hint">or <a href="#" id="browse-link">browse</a> · max 1 MB</div>
|
||||||
|
<div class="dz__filename" id="dz-filename"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="import-preview" hidden style="margin-top:14px;"></div>
|
||||||
|
<div id="import-result" class="result" hidden style="margin-top:14px;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{# --- Referral block ---------------------------------------------- #}
|
{# --- Referral block ---------------------------------------------- #}
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<div class="settings-section__head">Invite a friend</div>
|
<div class="settings-section__head">Invite a friend</div>
|
||||||
|
|
@ -74,6 +96,31 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# --- Cloud sync block --------------------------------------------- #}
|
||||||
|
<div class="settings-section">
|
||||||
|
<div class="settings-section__head">Cloud sync (encrypted)</div>
|
||||||
|
<p class="settings-section__lede">
|
||||||
|
Manage the encrypted server-side copy of your portfolio. Sync is
|
||||||
|
opted-in per import (see the Import section above).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% if paid and paid.active %}
|
||||||
|
<div id="sync-status" class="settings-row">
|
||||||
|
<div class="settings-row__label">Status</div>
|
||||||
|
<div class="settings-row__value">
|
||||||
|
<span class="settings-row__hint">checking…</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="sync-actions" style="display:flex; gap:8px; flex-wrap:wrap; margin-top:8px;"></div>
|
||||||
|
<div id="sync-feedback" class="settings-row__hint" style="margin-top:10px;"></div>
|
||||||
|
{% else %}
|
||||||
|
<p class="settings-row__hint">
|
||||||
|
Available on the paid tier. Upgrade or apply an invite credit
|
||||||
|
above to enable cloud sync.
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
{# Future: Paddle subscription block, AI-spend ledger summary, etc. #}
|
{# Future: Paddle subscription block, AI-spend ledger summary, etc. #}
|
||||||
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
@ -81,6 +128,187 @@
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{% if user and paid and paid.active %}
|
||||||
|
<div id="sync-modal" class="modal"
|
||||||
|
style="position:fixed;inset:0;background:rgba(0,0,0,0.45);
|
||||||
|
display:none;align-items:center;justify-content:center;z-index:1000;">
|
||||||
|
<div style="background:var(--panel-bg,#fff);color:var(--text,#000);
|
||||||
|
padding:22px 26px;border-radius:8px;max-width:440px;width:90%;">
|
||||||
|
<div class="result__head" id="sync-modal-title" style="margin-bottom:8px;">
|
||||||
|
Enable cloud sync
|
||||||
|
</div>
|
||||||
|
<div id="sync-modal-body" style="font-size:13px;line-height:1.55;
|
||||||
|
color:var(--muted,#666);margin-bottom:14px;">
|
||||||
|
Choose a PIN (4–12 characters). The same PIN unlocks the
|
||||||
|
portfolio on any device. There is no recovery if you forget it.
|
||||||
|
</div>
|
||||||
|
<form id="sync-modal-form" autocomplete="off">
|
||||||
|
<label style="display:block;margin-bottom:6px;font-size:12px;">PIN</label>
|
||||||
|
<input id="sync-pin1" type="password" inputmode="numeric"
|
||||||
|
style="width:100%;padding:8px;margin-bottom:10px;" required>
|
||||||
|
<label style="display:block;margin-bottom:6px;font-size:12px;">Confirm PIN</label>
|
||||||
|
<input id="sync-pin2" type="password" inputmode="numeric"
|
||||||
|
style="width:100%;padding:8px;margin-bottom:10px;" required>
|
||||||
|
<label style="display:flex;align-items:flex-start;gap:8px;
|
||||||
|
font-size:12px;color:var(--muted,#666);margin:10px 0 16px;">
|
||||||
|
<input id="sync-ack" type="checkbox" required>
|
||||||
|
I understand that losing this PIN means I'll have to re-import my CSV.
|
||||||
|
</label>
|
||||||
|
<div id="sync-modal-err" class="pf-warn" hidden style="margin-bottom:10px;"></div>
|
||||||
|
<div style="display:flex;gap:8px;justify-content:flex-end;">
|
||||||
|
<button type="button" id="sync-modal-cancel" class="pf-secondary">Cancel</button>
|
||||||
|
<button type="submit" id="sync-modal-submit">Enable</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="{{ url_for('static', path='/js/portfolio-sync.js') }}" defer></script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
function $(id) { return document.getElementById(id); }
|
||||||
|
function esc(s) {
|
||||||
|
return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({
|
||||||
|
'&':'&','<':'<','>':'>','"':'"',"'":'''
|
||||||
|
}[c]));
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
if (!window.CassandraSync) return;
|
||||||
|
|
||||||
|
const statusEl = $('sync-status');
|
||||||
|
const actionsEl = $('sync-actions');
|
||||||
|
const feedbackEl = $('sync-feedback');
|
||||||
|
const modal = $('sync-modal');
|
||||||
|
const pin1 = $('sync-pin1');
|
||||||
|
const pin2 = $('sync-pin2');
|
||||||
|
const ack = $('sync-ack');
|
||||||
|
const errEl = $('sync-modal-err');
|
||||||
|
|
||||||
|
function setFeedback(msg, ok) {
|
||||||
|
feedbackEl.style.color = ok ? 'var(--ok,#2a9d57)' : '';
|
||||||
|
feedbackEl.textContent = msg || '';
|
||||||
|
}
|
||||||
|
// External callers (the Import section above) can pass a callback
|
||||||
|
// that fires after a successful enable-and-push.
|
||||||
|
let pendingOnSuccess = null;
|
||||||
|
function openModal(opts) {
|
||||||
|
pendingOnSuccess = (opts && opts.onSuccess) || null;
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
// Focus PIN field after the layout flush so the caret lands.
|
||||||
|
setTimeout(() => pin1.focus(), 0);
|
||||||
|
}
|
||||||
|
function closeModal() {
|
||||||
|
modal.style.display = 'none';
|
||||||
|
pin1.value = ''; pin2.value = '';
|
||||||
|
ack.checked = false; errEl.hidden = true;
|
||||||
|
pendingOnSuccess = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$('sync-modal-cancel').addEventListener('click', closeModal);
|
||||||
|
// Backdrop click + Esc key dismiss the modal.
|
||||||
|
modal.addEventListener('click', function (e) {
|
||||||
|
if (e.target === modal) closeModal();
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Escape' && modal.style.display !== 'none') closeModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
$('sync-modal-form').addEventListener('submit', async function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
errEl.hidden = true;
|
||||||
|
if (pin1.value !== pin2.value) {
|
||||||
|
errEl.textContent = 'PINs do not match.';
|
||||||
|
errEl.hidden = false; return;
|
||||||
|
}
|
||||||
|
if (pin1.value.length < 4) {
|
||||||
|
errEl.textContent = 'PIN must be at least 4 characters.';
|
||||||
|
errEl.hidden = false; return;
|
||||||
|
}
|
||||||
|
const pie = JSON.parse(localStorage.getItem('cassandra.pie') || 'null');
|
||||||
|
if (!pie) {
|
||||||
|
errEl.textContent =
|
||||||
|
'No portfolio in this browser yet. Import a CSV first, then enable sync.';
|
||||||
|
errEl.hidden = false; return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await window.CassandraSync.pushSync(pie, pin1.value);
|
||||||
|
const cb = pendingOnSuccess;
|
||||||
|
closeModal(); // clears pendingOnSuccess
|
||||||
|
await refresh();
|
||||||
|
setFeedback('Cloud sync enabled. Your encrypted portfolio is stored.', true);
|
||||||
|
if (typeof cb === 'function') {
|
||||||
|
try { cb(); } catch (cbErr) { console.warn('sync onSuccess threw', cbErr); }
|
||||||
|
}
|
||||||
|
} catch (e2) {
|
||||||
|
errEl.textContent = e2.message || 'Failed to enable sync.';
|
||||||
|
errEl.hidden = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
let status;
|
||||||
|
try { status = await window.CassandraSync.getStatus(); }
|
||||||
|
catch (e) {
|
||||||
|
statusEl.querySelector('.settings-row__value').innerHTML =
|
||||||
|
'<span class="pf-warn">' + esc(e.message || 'status check failed') + '</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const valueEl = statusEl.querySelector('.settings-row__value');
|
||||||
|
actionsEl.innerHTML = '';
|
||||||
|
if (status.exists) {
|
||||||
|
const when = status.updated_at
|
||||||
|
? new Date(status.updated_at).toISOString().replace('T', ' ').slice(0, 16) + ' UTC'
|
||||||
|
: '—';
|
||||||
|
valueEl.innerHTML =
|
||||||
|
'<span class="badge badge--ok">On</span> ' +
|
||||||
|
'<span class="settings-row__hint">last synced ' + esc(when) + '</span>';
|
||||||
|
|
||||||
|
const disable = document.createElement('button');
|
||||||
|
disable.type = 'button';
|
||||||
|
disable.className = 'pf-secondary';
|
||||||
|
disable.textContent = 'Disable sync';
|
||||||
|
disable.addEventListener('click', async function () {
|
||||||
|
if (!confirm('Remove your encrypted portfolio from the server? Your local copy is untouched.')) return;
|
||||||
|
try {
|
||||||
|
await window.CassandraSync.disableSync();
|
||||||
|
await refresh();
|
||||||
|
setFeedback('Cloud sync disabled. Server copy removed.', true);
|
||||||
|
} catch (e) { setFeedback(e.message || 'Disable failed.', false); }
|
||||||
|
});
|
||||||
|
actionsEl.appendChild(disable);
|
||||||
|
} else {
|
||||||
|
valueEl.innerHTML = '<span class="badge badge--ver">Off</span>';
|
||||||
|
// Only offer 'Enable' when there's actually a pie to encrypt;
|
||||||
|
// otherwise the user would hit a dead-end at the modal.
|
||||||
|
const hasPie = !!localStorage.getItem('cassandra.pie');
|
||||||
|
if (!hasPie) {
|
||||||
|
const hint = document.createElement('span');
|
||||||
|
hint.className = 'settings-row__hint';
|
||||||
|
hint.innerHTML =
|
||||||
|
'Nothing to sync yet — ' +
|
||||||
|
'<a href="#import">import a portfolio</a> first, then come back to enable cloud sync.';
|
||||||
|
actionsEl.appendChild(hint);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const enable = document.createElement('button');
|
||||||
|
enable.type = 'button';
|
||||||
|
enable.textContent = 'Enable cloud sync';
|
||||||
|
enable.addEventListener('click', openModal);
|
||||||
|
actionsEl.appendChild(enable);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hooks for the Import section to drive this modal + status row.
|
||||||
|
window.cassandraOpenSyncModal = openModal;
|
||||||
|
window.cassandraRefreshSyncStatus = refresh;
|
||||||
|
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
var btn = document.getElementById('invite-copy');
|
var btn = document.getElementById('invite-copy');
|
||||||
|
|
@ -99,4 +327,252 @@
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{% if user %}
|
||||||
|
{# Import widget wiring — auto-parse on drop, preview, then commit. #}
|
||||||
|
<script src="{{ url_for('static', path='/js/portfolio.js') }}" defer></script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
// Server-side hint: did the user have paid privileges when the page
|
||||||
|
// rendered? Used to decide whether to offer the 'Import & sync' button.
|
||||||
|
// We still call CassandraSync.getStatus() at click time as the source
|
||||||
|
// of truth, but this lets us skip rendering a button we know is dead.
|
||||||
|
var IS_PAID = {{ 'true' if paid and paid.active else 'false' }};
|
||||||
|
|
||||||
|
function ready(fn) {
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', fn);
|
||||||
|
} else { fn(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
ready(function () {
|
||||||
|
var P = window.CassandraPortfolio;
|
||||||
|
if (!P) return;
|
||||||
|
var esc = P.esc, fmt = P.fmt, signed = P.signed, cls = P.cls;
|
||||||
|
|
||||||
|
var dropZone = document.getElementById('drop-zone');
|
||||||
|
var fileInput = document.getElementById('file-input');
|
||||||
|
var browseLink = document.getElementById('browse-link');
|
||||||
|
var filenameEl = document.getElementById('dz-filename');
|
||||||
|
var previewEl = document.getElementById('import-preview');
|
||||||
|
var resultEl = document.getElementById('import-result');
|
||||||
|
if (!dropZone) return;
|
||||||
|
|
||||||
|
var currentPie = null; // most recently parsed pie, awaiting commit
|
||||||
|
|
||||||
|
function showError(msg) {
|
||||||
|
previewEl.hidden = true;
|
||||||
|
resultEl.className = 'result result--err';
|
||||||
|
resultEl.innerHTML =
|
||||||
|
'<div class="result__head">✕ Import failed</div>' +
|
||||||
|
'<div class="result__row">' + esc(msg) + '</div>';
|
||||||
|
resultEl.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSuccess(headline, sub) {
|
||||||
|
previewEl.hidden = true;
|
||||||
|
resultEl.className = 'result result--ok';
|
||||||
|
resultEl.innerHTML =
|
||||||
|
'<div class="result__head">' + esc(headline) + '</div>' +
|
||||||
|
(sub ? '<div class="result__row">' + sub + '</div>' : '') +
|
||||||
|
'<div class="result__row" style="margin-top:14px;">' +
|
||||||
|
'<a href="/">Open dashboard →</a>' +
|
||||||
|
'</div>';
|
||||||
|
resultEl.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPreview(pie) {
|
||||||
|
currentPie = pie;
|
||||||
|
resultEl.hidden = true;
|
||||||
|
|
||||||
|
var t = pie.totals || {};
|
||||||
|
var rows = (pie.positions || []).map(function (p) {
|
||||||
|
var invested = (p.avg_cost != null && p.qty != null) ? p.avg_cost * p.qty : null;
|
||||||
|
return '<tr>' +
|
||||||
|
'<td class="label">' + esc(p.yahoo_ticker || p.t212_slice || '') + '</td>' +
|
||||||
|
'<td>' + esc(p.name || '') + '</td>' +
|
||||||
|
'<td class="num">' + fmt(p.qty, { maximumFractionDigits: 6 }) + '</td>' +
|
||||||
|
'<td class="num neu">' + fmt(p.avg_cost) + '</td>' +
|
||||||
|
'<td class="num">' + fmt(invested) + '</td>' +
|
||||||
|
'</tr>';
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
var warnings = (pie.warnings || []).map(function (w) {
|
||||||
|
return '<div class="result__warn">' + esc(w) + '</div>';
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
var syncBtn = IS_PAID
|
||||||
|
? ('<div class="import-choice">' +
|
||||||
|
'<button type="button" id="commit-sync">Import & sync to cloud</button>' +
|
||||||
|
'<div class="settings-row__hint">' +
|
||||||
|
'Also stores an <strong>encrypted</strong> copy on the server, ' +
|
||||||
|
'restorable on any device with your PIN. Only you can decrypt ' +
|
||||||
|
'it — losing the PIN means losing the backup.' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>')
|
||||||
|
: ('<div class="import-choice">' +
|
||||||
|
'<button type="button" disabled>Import & sync to cloud</button>' +
|
||||||
|
'<div class="settings-row__hint">' +
|
||||||
|
'Encrypted cloud backup is available on the paid tier.' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>');
|
||||||
|
|
||||||
|
previewEl.innerHTML =
|
||||||
|
'<div class="result result--ok" style="margin:0;">' +
|
||||||
|
'<div class="result__head">' +
|
||||||
|
'▸ Preview: <strong>' + esc(pie.pie_name || 'pie') + '</strong>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="result__grid">' +
|
||||||
|
'<div><div class="k">Positions</div><div class="v">' + (pie.positions || []).length + '</div></div>' +
|
||||||
|
'<div><div class="k">Invested</div><div class="v">' + fmt(t.invested) + '</div></div>' +
|
||||||
|
'<div><div class="k">Value</div><div class="v">' + fmt(t.value) + '</div></div>' +
|
||||||
|
'<div><div class="k">Result</div><div class="v ' + cls(t.result) + '">' + signed(t.result) + '</div></div>' +
|
||||||
|
'</div>' +
|
||||||
|
warnings +
|
||||||
|
(rows
|
||||||
|
? '<div style="max-height:280px;overflow:auto;margin-top:12px;">' +
|
||||||
|
'<table class="dense">' +
|
||||||
|
'<thead><tr>' +
|
||||||
|
'<th>Ticker</th><th>Name</th>' +
|
||||||
|
'<th class="num">Qty</th>' +
|
||||||
|
'<th class="num">Avg</th>' +
|
||||||
|
'<th class="num">Invested</th>' +
|
||||||
|
'</tr></thead>' +
|
||||||
|
'<tbody>' + rows + '</tbody>' +
|
||||||
|
'</table>' +
|
||||||
|
'</div>'
|
||||||
|
: ''
|
||||||
|
) +
|
||||||
|
'<div class="import-actions">' +
|
||||||
|
'<div class="import-choice">' +
|
||||||
|
'<button type="button" id="commit-local">Import to this browser</button>' +
|
||||||
|
'<div class="settings-row__hint">' +
|
||||||
|
'Saved to this browser only. No server-side copy of your holdings.' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
|
syncBtn +
|
||||||
|
'<div style="flex-basis:100%;">' +
|
||||||
|
'<button type="button" id="commit-cancel" class="pf-secondary">' +
|
||||||
|
'Cancel</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
previewEl.hidden = false;
|
||||||
|
|
||||||
|
document.getElementById('commit-local').addEventListener('click', commitLocal);
|
||||||
|
document.getElementById('commit-cancel').addEventListener('click', resetUploader);
|
||||||
|
var syncEl = document.getElementById('commit-sync');
|
||||||
|
if (syncEl) syncEl.addEventListener('click', commitSync);
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitLocal() {
|
||||||
|
if (!currentPie) return;
|
||||||
|
P.savePie(currentPie);
|
||||||
|
showSuccess('▸ Imported to this browser.',
|
||||||
|
'Pie kept locally; no server-side copy.');
|
||||||
|
currentPie = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function commitSync() {
|
||||||
|
if (!currentPie) return;
|
||||||
|
// Save locally first so the cloud-sync flow uses the freshly-imported
|
||||||
|
// pie (the enable-PIN modal in this same page reads from localStorage).
|
||||||
|
P.savePie(currentPie);
|
||||||
|
var S = window.CassandraSync;
|
||||||
|
if (!S) { showError('Cloud sync module not loaded.'); return; }
|
||||||
|
|
||||||
|
var status;
|
||||||
|
try { status = await S.getStatus(); }
|
||||||
|
catch (e) { showError('Could not check sync status: ' + (e.message || e)); return; }
|
||||||
|
|
||||||
|
if (!status.paid) {
|
||||||
|
showError('Cloud sync requires the paid tier.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.exists) {
|
||||||
|
// Already enabled — try a direct push using the cached session
|
||||||
|
// key. If no key is cached (fresh browser session), this throws,
|
||||||
|
// and we fall back to the enable-PIN modal so the user can
|
||||||
|
// re-enter their PIN.
|
||||||
|
try {
|
||||||
|
await S.pushSync(currentPie, null);
|
||||||
|
showSuccess('▸ Imported and synced.',
|
||||||
|
'Encrypted copy updated on the server.');
|
||||||
|
currentPie = null;
|
||||||
|
if (window.cassandraRefreshSyncStatus) window.cassandraRefreshSyncStatus();
|
||||||
|
return;
|
||||||
|
} catch (e) {
|
||||||
|
// Fall through to modal so the user can re-auth with their PIN.
|
||||||
|
console.warn('direct push failed, falling back to PIN modal', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// !status.exists OR cached-key push failed → use the modal.
|
||||||
|
if (window.cassandraOpenSyncModal) {
|
||||||
|
window.cassandraOpenSyncModal({
|
||||||
|
onSuccess: function () {
|
||||||
|
showSuccess('▸ Imported and synced.',
|
||||||
|
'Cloud sync is now enabled and the pie is stored encrypted.');
|
||||||
|
currentPie = null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
showError('Cloud sync UI unavailable on this page. ' +
|
||||||
|
'Use the Cloud sync section below to enable.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetUploader() {
|
||||||
|
currentPie = null;
|
||||||
|
previewEl.hidden = true;
|
||||||
|
previewEl.innerHTML = '';
|
||||||
|
resultEl.hidden = true;
|
||||||
|
filenameEl.textContent = '';
|
||||||
|
fileInput.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseFile(file) {
|
||||||
|
filenameEl.textContent = file.name + ' (' + Math.round(file.size / 1024) + ' KB) — parsing…';
|
||||||
|
previewEl.hidden = true;
|
||||||
|
resultEl.hidden = true;
|
||||||
|
try {
|
||||||
|
var pie = await P.parseCsv(file);
|
||||||
|
renderPreview(pie);
|
||||||
|
filenameEl.textContent = file.name + ' (' + Math.round(file.size / 1024) + ' KB)';
|
||||||
|
} catch (e) {
|
||||||
|
filenameEl.textContent = file.name + ' (failed)';
|
||||||
|
showError(e.message || 'Unknown error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
browseLink.addEventListener('click', function (e) { e.preventDefault(); fileInput.click(); });
|
||||||
|
fileInput.addEventListener('change', function () {
|
||||||
|
if (fileInput.files[0]) parseFile(fileInput.files[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
['dragenter', 'dragover'].forEach(function (ev) {
|
||||||
|
dropZone.addEventListener(ev, function (e) {
|
||||||
|
e.preventDefault(); e.stopPropagation();
|
||||||
|
dropZone.classList.add('dz--over');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
['dragleave', 'drop'].forEach(function (ev) {
|
||||||
|
dropZone.addEventListener(ev, function (e) {
|
||||||
|
e.preventDefault(); e.stopPropagation();
|
||||||
|
dropZone.classList.remove('dz--over');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
dropZone.addEventListener('drop', function (e) {
|
||||||
|
var f = e.dataTransfer.files && e.dataTransfer.files[0];
|
||||||
|
if (f) parseFile(f);
|
||||||
|
});
|
||||||
|
dropZone.addEventListener('click', function (e) {
|
||||||
|
if (e.target.tagName !== 'A') fileInput.click();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
<section class="panel" style="grid-column: 1 / -1; max-width: 760px; margin: 0 auto;">
|
<section class="panel" style="grid-column: 1 / -1; max-width: 760px; margin: 0 auto;">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<span class="title">Import portfolio (Trading 212 CSV)</span>
|
<span class="title">Import portfolio (Trading 212 CSV)</span>
|
||||||
<span class="meta">stays in your browser · never persists server-side</span>
|
<span class="meta">held locally · optional encrypted cloud sync (paid)</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel-body" style="padding: 18px clamp(16px, 4vw, 32px) 24px;">
|
<div class="panel-body" style="padding: 18px clamp(16px, 4vw, 32px) 24px;">
|
||||||
|
|
@ -13,9 +13,11 @@
|
||||||
Export your pie from the T212 web app
|
Export your pie from the T212 web app
|
||||||
(<span class="neu">Trading 212 → Investing → Your Pie → ⋯ → Export</span>)
|
(<span class="neu">Trading 212 → Investing → Your Pie → ⋯ → Export</span>)
|
||||||
and drop the CSV here. Each Slice is resolved to its Yahoo ticker;
|
and drop the CSV here. Each Slice is resolved to its Yahoo ticker;
|
||||||
the parsed pie is kept in <em>this browser's localStorage</em> only.
|
the parsed pie is kept in <em>this browser's localStorage</em>.
|
||||||
The server learns just which tickers exist (anonymously) so it can
|
The server learns only which tickers exist (anonymously) so it can
|
||||||
fetch their prices.
|
fetch their prices. If you have <a href="/settings">cloud sync</a>
|
||||||
|
enabled, an <strong>encrypted</strong> copy is also pushed to the
|
||||||
|
server — only your PIN can decrypt it.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form id="upload-form" autocomplete="off">
|
<form id="upload-form" autocomplete="off">
|
||||||
|
|
@ -34,6 +36,7 @@
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<script src="{{ url_for('static', path='/js/portfolio-sync.js') }}" defer></script>
|
||||||
<script src="{{ url_for('static', path='/js/portfolio.js') }}" defer></script>
|
<script src="{{ url_for('static', path='/js/portfolio.js') }}" defer></script>
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
|
|
|
||||||
|
|
@ -5,5 +5,13 @@
|
||||||
|
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
|
# Dev: mount the source over the image's copy so edits on the host
|
||||||
|
# land in the container without a rebuild; `--reload` restarts uvicorn
|
||||||
|
# when a file changes. Prod bakes the code into the image (Dockerfile)
|
||||||
|
# and uses the plain command from docker-compose.yml.
|
||||||
|
command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000",
|
||||||
|
"--workers", "1", "--reload", "--reload-dir", "/app/app"]
|
||||||
|
volumes:
|
||||||
|
- ./app:/app/app
|
||||||
ports:
|
ports:
|
||||||
- "${CASSANDRA_PORT:-8000}:8000"
|
- "${CASSANDRA_PORT:-8000}:8000"
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ dependencies = [
|
||||||
"tenacity>=9.0",
|
"tenacity>=9.0",
|
||||||
"structlog>=24.4",
|
"structlog>=24.4",
|
||||||
"argon2-cffi>=23.1",
|
"argon2-cffi>=23.1",
|
||||||
|
"cryptography>=43.0",
|
||||||
"itsdangerous>=2.2",
|
"itsdangerous>=2.2",
|
||||||
"email-validator>=2.2",
|
"email-validator>=2.2",
|
||||||
"aiosmtplib>=3.0",
|
"aiosmtplib>=3.0",
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,20 @@ def test_no_position_rows_raises():
|
||||||
parse_t212_csv(csv)
|
parse_t212_csv(csv)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unfunded_pie_raises_specific_message():
|
||||||
|
"""A pie exported before it holds any shares has every slice at
|
||||||
|
quantity 0. That must not look like a format error — the message
|
||||||
|
has to say the pie is empty so the user re-exports a funded one."""
|
||||||
|
csv = (
|
||||||
|
'"Slice","Name","Invested value","Value","Result","Owned quantity"\n'
|
||||||
|
'"SGLN","iShares Physical Gold",0,0,0,"0"\n'
|
||||||
|
'"SHEL","Shell",0,0,0,"0"\n'
|
||||||
|
'"Total","Empty Pie",0,0,0,"-"\n'
|
||||||
|
)
|
||||||
|
with pytest.raises(CSVImportError, match="all 2 slice"):
|
||||||
|
parse_t212_csv(csv)
|
||||||
|
|
||||||
|
|
||||||
def test_reordered_columns():
|
def test_reordered_columns():
|
||||||
"""T212 sometimes re-orders columns between exports. Header-name matching
|
"""T212 sometimes re-orders columns between exports. Header-name matching
|
||||||
has to make that a non-issue."""
|
has to make that a non-issue."""
|
||||||
|
|
|
||||||
139
tests/test_portfolio_sync_api.py
Normal file
139
tests/test_portfolio_sync_api.py
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
"""Integration tests for the /api/portfolio/sync endpoints.
|
||||||
|
|
||||||
|
Spins up a minimal FastAPI app over an in-memory aiosqlite DB, seeds two
|
||||||
|
users (one paid, one free), signs session cookies, and exercises the
|
||||||
|
contract: paid POST/GET/DELETE round-trip, free 402, oversized 413,
|
||||||
|
rate-limit 429.
|
||||||
|
|
||||||
|
Skipped on hosts without aiosqlite + FastAPI test client, mirroring the
|
||||||
|
guard pattern in test_universe_unlinkability.py.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
def _build_app(tmp_path):
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
|
from app import db as db_mod
|
||||||
|
from app.auth import sign_session
|
||||||
|
from app.db import Base
|
||||||
|
from app.models import User
|
||||||
|
from app.routers import sync as sync_router
|
||||||
|
|
||||||
|
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/sync.db")
|
||||||
|
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
db_mod._engine = engine
|
||||||
|
db_mod._session_factory = session_factory
|
||||||
|
|
||||||
|
async def _seed():
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
async with session_factory() as s:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
s.add_all([
|
||||||
|
User(id=1, email="paid@example.com", tier="paid",
|
||||||
|
settings_json={}, created_at=now),
|
||||||
|
User(id=2, email="free@example.com", tier="free",
|
||||||
|
settings_json={}, created_at=now),
|
||||||
|
])
|
||||||
|
await s.commit()
|
||||||
|
|
||||||
|
asyncio.run(_seed())
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(sync_router.router)
|
||||||
|
return TestClient(app), sign_session(1), sign_session(2)
|
||||||
|
|
||||||
|
|
||||||
|
def _b64(raw: bytes) -> str:
|
||||||
|
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
|
||||||
|
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.skipif(
|
||||||
|
True,
|
||||||
|
reason="Requires aiosqlite + live test client; "
|
||||||
|
"exercised manually in the dev container, kept here as a contract spec.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_paid_user_round_trip(tmp_path):
|
||||||
|
client, paid, _ = _build_app(tmp_path)
|
||||||
|
cookies = {"cassandra_session": paid}
|
||||||
|
|
||||||
|
# status before any upload
|
||||||
|
r = client.get("/api/portfolio/sync/status", cookies=cookies)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json() == {"exists": False, "updated_at": None}
|
||||||
|
|
||||||
|
# upload
|
||||||
|
blob = os.urandom(512)
|
||||||
|
r = client.post(
|
||||||
|
"/api/portfolio/sync", json={"blob": _b64(blob)}, cookies=cookies,
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
|
# status after
|
||||||
|
r = client.get("/api/portfolio/sync/status", cookies=cookies)
|
||||||
|
body = r.json()
|
||||||
|
assert body["exists"] is True
|
||||||
|
assert body["updated_at"] is not None
|
||||||
|
|
||||||
|
# download — should round-trip the exact bytes
|
||||||
|
r = client.get("/api/portfolio/sync", cookies=cookies)
|
||||||
|
assert r.status_code == 200
|
||||||
|
got = base64.urlsafe_b64decode(r.json()["blob"] + "==")
|
||||||
|
assert got == blob
|
||||||
|
|
||||||
|
# delete
|
||||||
|
r = client.delete("/api/portfolio/sync", cookies=cookies)
|
||||||
|
assert r.status_code == 200
|
||||||
|
r = client.get("/api/portfolio/sync/status", cookies=cookies)
|
||||||
|
assert r.json()["exists"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_free_user_blocked(tmp_path):
|
||||||
|
client, _, free = _build_app(tmp_path)
|
||||||
|
cookies = {"cassandra_session": free}
|
||||||
|
|
||||||
|
for fn, kwargs in [
|
||||||
|
(client.get, {"url": "/api/portfolio/sync/status"}),
|
||||||
|
(client.post, {"url": "/api/portfolio/sync", "json": {"blob": "AA"}}),
|
||||||
|
(client.get, {"url": "/api/portfolio/sync"}),
|
||||||
|
(client.delete, {"url": "/api/portfolio/sync"}),
|
||||||
|
]:
|
||||||
|
r = fn(cookies=cookies, **kwargs)
|
||||||
|
assert r.status_code == 402, (fn.__name__, kwargs, r.status_code)
|
||||||
|
|
||||||
|
|
||||||
|
def test_oversized_blob_rejected(tmp_path):
|
||||||
|
client, paid, _ = _build_app(tmp_path)
|
||||||
|
cookies = {"cassandra_session": paid}
|
||||||
|
too_big = os.urandom(257 * 1024)
|
||||||
|
r = client.post(
|
||||||
|
"/api/portfolio/sync", json={"blob": _b64(too_big)}, cookies=cookies,
|
||||||
|
)
|
||||||
|
assert r.status_code == 413
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_is_rate_limited(tmp_path):
|
||||||
|
client, paid, _ = _build_app(tmp_path)
|
||||||
|
cookies = {"cassandra_session": paid}
|
||||||
|
client.post(
|
||||||
|
"/api/portfolio/sync",
|
||||||
|
json={"blob": _b64(os.urandom(64))}, cookies=cookies,
|
||||||
|
)
|
||||||
|
# 6 hits should pass; the 7th must trip 429.
|
||||||
|
for i in range(6):
|
||||||
|
r = client.get("/api/portfolio/sync", cookies=cookies)
|
||||||
|
assert r.status_code == 200, f"GET #{i+1} failed: {r.text}"
|
||||||
|
r = client.get("/api/portfolio/sync", cookies=cookies)
|
||||||
|
assert r.status_code == 429
|
||||||
76
tests/test_portfolio_sync_service.py
Normal file
76
tests/test_portfolio_sync_service.py
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
"""Crypto-layer tests for app.services.portfolio_sync.
|
||||||
|
|
||||||
|
These exercise only the pure wrap/unwrap helpers — no DB, no network.
|
||||||
|
The DB-touching helpers (`upsert`, `fetch`, `consume_fetch_budget`) need
|
||||||
|
sqlite+aiosqlite and live in the API integration tests so we don't
|
||||||
|
duplicate the test-app scaffolding.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.services import portfolio_sync as svc
|
||||||
|
|
||||||
|
|
||||||
|
def test_wrap_unwrap_round_trip():
|
||||||
|
inner = b"the inner ciphertext doesn't matter to the outer wrap"
|
||||||
|
ct, nonce = svc.wrap(user_id=42, inner_blob=inner)
|
||||||
|
assert ct != inner # we actually encrypted
|
||||||
|
out = svc.unwrap(42, ct, nonce)
|
||||||
|
assert out == inner
|
||||||
|
|
||||||
|
|
||||||
|
def test_wrap_is_nondeterministic():
|
||||||
|
"""Same plaintext, same user, two writes — outer ciphertexts must
|
||||||
|
differ because the nonce is random. A deterministic outer wrap would
|
||||||
|
leak 'this user re-uploaded the same pie' across snapshots."""
|
||||||
|
inner = b"identical inner blob across writes"
|
||||||
|
ct1, nonce1 = svc.wrap(7, inner)
|
||||||
|
ct2, nonce2 = svc.wrap(7, inner)
|
||||||
|
assert ct1 != ct2
|
||||||
|
assert nonce1 != nonce2
|
||||||
|
|
||||||
|
|
||||||
|
def test_unwrap_rejects_cross_user_blob():
|
||||||
|
"""A blob wrapped for user A must not decrypt under user B's key —
|
||||||
|
HKDF binds the user_id into the salt."""
|
||||||
|
inner = b"alice's pie"
|
||||||
|
ct, nonce = svc.wrap(1, inner)
|
||||||
|
with pytest.raises(svc.SyncCryptoError):
|
||||||
|
svc.unwrap(2, ct, nonce)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unwrap_rejects_tampered_ciphertext():
|
||||||
|
inner = b"don't flip my bits"
|
||||||
|
ct, nonce = svc.wrap(99, inner)
|
||||||
|
bad = bytearray(ct)
|
||||||
|
bad[0] ^= 0x01
|
||||||
|
with pytest.raises(svc.SyncCryptoError):
|
||||||
|
svc.unwrap(99, bytes(bad), nonce)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unwrap_rejects_wrong_nonce():
|
||||||
|
inner = b"nonce is part of the auth envelope"
|
||||||
|
ct, _nonce = svc.wrap(5, inner)
|
||||||
|
with pytest.raises(svc.SyncCryptoError):
|
||||||
|
svc.unwrap(5, ct, os.urandom(12))
|
||||||
|
|
||||||
|
|
||||||
|
def test_server_key_changes_with_pepper(monkeypatch):
|
||||||
|
"""Rotating the pepper must produce a different key for the same
|
||||||
|
user — that's how we'd invalidate all sync rows in a credential
|
||||||
|
breach."""
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
get_settings.cache_clear()
|
||||||
|
monkeypatch.setenv("PORTFOLIO_SYNC_PEPPER", "first-pepper-value")
|
||||||
|
k1 = svc._server_key(11)
|
||||||
|
|
||||||
|
get_settings.cache_clear()
|
||||||
|
monkeypatch.setenv("PORTFOLIO_SYNC_PEPPER", "second-pepper-value")
|
||||||
|
k2 = svc._server_key(11)
|
||||||
|
|
||||||
|
assert k1 != k2
|
||||||
|
get_settings.cache_clear() # don't poison other tests
|
||||||
Loading…
Add table
Add a link
Reference in a new issue