repos
/ finance-rust master

finance-rust

mirror archived upstream

Single-binary self-hosted market watcher for stocks, ETFs, indexes, and futures: live charts, key stats, fundamentals, SEC filings, and SSE streaming.

axumdockerfinancerustself-hostedsqlitestocksvite

4.9 KB · 132 lines · Rust Raw History
  1//! Session-scoped dashboard watchlists (Phase C).
  2//!
  3//! The dashboard is a personal, editable watchlist with no accounts: a browser
  4//! is identified by an opaque `fin_sid` cookie, and its symbols live in the
  5//! `watchlist` table keyed on that sid (see migration 0015). A brand-new
  6//! browser (no cookie) is minted a sid and seeded with the [`STARTERS`]; an
  7//! existing cookie's list is used as-is, even when empty, so a user who removes
  8//! everything is not re-seeded. Clearing cookies loses the list, by design.
  9
 10use axum::http::{header, HeaderMap};
 11use sqlx::SqlitePool;
 12
 13use crate::db::now_ms;
 14
 15/// The symbols a brand-new browser's watchlist is seeded with. The S&P 500
 16/// baseline is *not* here — the dashboard always shows it as the comparison
 17/// baseline; these are the user's editable rows.
 18pub const STARTERS: &[&str] = &["VTI", "VXUS", "BND", "IAU", "IBIT"];
 19
 20/// The cookie name that carries the opaque session id.
 21pub const COOKIE: &str = "fin_sid";
 22
 23/// The session cookie's lifetime: one year.
 24const COOKIE_MAX_AGE_SECS: i64 = 365 * 24 * 3600;
 25
 26/// A resolved browser session: its sid, and the `Set-Cookie` value to send when
 27/// the session was just minted (a first visit).
 28pub struct Session {
 29    pub sid: String,
 30    pub set_cookie: Option<String>,
 31}
 32
 33/// Parse the `fin_sid` value out of the request's `Cookie` header, if present.
 34/// Accepts only a short hex string (what we mint), so a hand-crafted cookie
 35/// can't smuggle anything unexpected into the sid.
 36pub fn sid_from_headers(headers: &HeaderMap) -> Option<String> {
 37    let raw = headers.get(header::COOKIE)?.to_str().ok()?;
 38    let prefix = format!("{COOKIE}=");
 39    for part in raw.split(';') {
 40        let part = part.trim();
 41        if let Some(v) = part.strip_prefix(&prefix) {
 42            let v = v.trim();
 43            if !v.is_empty() && v.len() <= 64 && v.bytes().all(|b| b.is_ascii_hexdigit()) {
 44                return Some(v.to_string());
 45            }
 46        }
 47    }
 48    None
 49}
 50
 51/// The `Set-Cookie` header value that persists `sid` for a year.
 52pub fn set_cookie_value(sid: &str) -> String {
 53    format!("{COOKIE}={sid}; Path=/; Max-Age={COOKIE_MAX_AGE_SECS}; HttpOnly; SameSite=Lax")
 54}
 55
 56/// Resolve the browser's session. With a cookie present, use that sid as-is
 57/// (its list is whatever the browser arranged, even if empty). With no cookie,
 58/// mint a new opaque sid, seed the starter watchlist, and return the cookie to
 59/// set.
 60pub async fn resolve(pool: &SqlitePool, headers: &HeaderMap) -> Session {
 61    if let Some(sid) = sid_from_headers(headers) {
 62        return Session { sid, set_cookie: None };
 63    }
 64    // Mint 16 random bytes as hex via SQLite, so no extra crate is needed; fall
 65    // back to a timestamp-derived id only if that ever fails.
 66    let sid: String = sqlx::query_scalar("SELECT lower(hex(randomblob(16)))")
 67        .fetch_one(pool)
 68        .await
 69        .unwrap_or_else(|_| format!("{:032x}", now_ms()));
 70    seed_starters(pool, &sid).await;
 71    let set_cookie = Some(set_cookie_value(&sid));
 72    Session { sid, set_cookie }
 73}
 74
 75/// Seed a fresh session with the starter symbols (only those that exist in the
 76/// universe — they all do, but the guard keeps a missing one from inserting a
 77/// dangling row).
 78async fn seed_starters(pool: &SqlitePool, sid: &str) {
 79    let now = now_ms();
 80    for (i, t) in STARTERS.iter().enumerate() {
 81        let _ = sqlx::query(
 82            "INSERT INTO watchlist (sid, ticker, position, added_at) \
 83             SELECT ?, ?, ?, ? WHERE EXISTS (SELECT 1 FROM symbols WHERE ticker = ?) \
 84             ON CONFLICT(sid, ticker) DO NOTHING",
 85        )
 86        .bind(sid)
 87        .bind(t)
 88        .bind(i as i64)
 89        .bind(now)
 90        .bind(t)
 91        .execute(pool)
 92        .await;
 93    }
 94}
 95
 96/// The watchlist tickers for `sid`, in display order.
 97pub async fn list(pool: &SqlitePool, sid: &str) -> Vec<String> {
 98    sqlx::query_scalar("SELECT ticker FROM watchlist WHERE sid = ? ORDER BY position, added_at")
 99        .bind(sid)
100        .fetch_all(pool)
101        .await
102        .unwrap_or_default()
103}
104
105/// Append `ticker` to `sid`'s watchlist. Assumes the symbol already exists in
106/// the universe (the route ensures it first). Idempotent on (sid, ticker).
107pub async fn add_ticker(pool: &SqlitePool, sid: &str, ticker: &str) -> sqlx::Result<()> {
108    let now = now_ms();
109    sqlx::query(
110        "INSERT INTO watchlist (sid, ticker, position, added_at) \
111         VALUES (?, ?, COALESCE((SELECT MAX(position) + 1 FROM watchlist WHERE sid = ?), 0), ?) \
112         ON CONFLICT(sid, ticker) DO NOTHING",
113    )
114    .bind(sid)
115    .bind(ticker)
116    .bind(sid)
117    .bind(now)
118    .execute(pool)
119    .await?;
120    Ok(())
121}
122
123/// Remove `ticker` from `sid`'s watchlist.
124pub async fn remove_ticker(pool: &SqlitePool, sid: &str, ticker: &str) -> sqlx::Result<()> {
125    sqlx::query("DELETE FROM watchlist WHERE sid = ? AND ticker = ?")
126        .bind(sid)
127        .bind(ticker)
128        .execute(pool)
129        .await?;
130    Ok(())
131}