//! Browser sessions as server-side records (oxygenhub#223). use super::{now_ms, Pool}; use sqlx::FromRow; /// A session ends after this long unused. pub const IDLE_MS: i64 = 14 * 24 * 60 * 60_000; /// And after this long whatever happens. pub const ABSOLUTE_MS: i64 = 30 * 24 * 60 * 60_000; /// `seen_ms` is written at most this often per session. const TOUCH_EVERY_MS: i64 = 60_000; #[derive(Clone, Debug, FromRow)] pub struct Session { pub id: i64, pub account_id: i64, pub salt: String, pub created_ms: i64, pub seen_ms: i64, pub address: String, pub agent: String, } impl Session { /// Whether it is still good at `now`, for an account whose salt is `salt`. pub fn live(&self, salt: &str, now: i64) -> bool { self.salt == salt && now - self.seen_ms < IDLE_MS && now - self.created_ms < ABSOLUTE_MS } } const COLUMNS: &str = "id, account_id, salt, created_ms, seen_ms, address, agent"; pub async fn create(pool: &Pool, account_id: i64, hash: &str, salt: &str) -> sqlx::Result { let now = now_ms(); let r = sqlx::query("INSERT INTO sessions (account_id, hash, salt, created_ms, seen_ms) VALUES ($1, $2, $3, $4, $5)") .bind(account_id) .bind(hash) .bind(salt) .bind(now) .bind(now) .execute(pool) .await?; Ok(r.last_insert_rowid()) } pub async fn by_hash(pool: &Pool, hash: &str) -> sqlx::Result> { sqlx::query_as(&format!("SELECT {COLUMNS} FROM sessions WHERE hash = $1")).bind(hash).fetch_optional(pool).await } /// Note a use: the time (at most once a minute) and, the first time, the /// address and browser it came from. pub async fn touch(pool: &Pool, s: &Session, address: &str, agent: &str, now: i64) -> sqlx::Result<()> { if s.agent.is_empty() && s.address.is_empty() && (!agent.is_empty() || !address.is_empty()) { sqlx::query("UPDATE sessions SET seen_ms = $1, address = $2, agent = $3 WHERE id = $4") .bind(now) .bind(address) .bind(agent) .bind(s.id) .execute(pool) .await?; } else if now - s.seen_ms >= TOUCH_EVERY_MS { sqlx::query("UPDATE sessions SET seen_ms = $1 WHERE id = $2").bind(now).bind(s.id).execute(pool).await?; } Ok(()) } /// An account's live sessions, most recently used first. pub async fn of_account(pool: &Pool, account_id: i64, salt: &str) -> sqlx::Result> { let now = now_ms(); let all: Vec = sqlx::query_as(&format!("SELECT {COLUMNS} FROM sessions WHERE account_id = $1 ORDER BY seen_ms DESC")) .bind(account_id) .fetch_all(pool) .await?; Ok(all.into_iter().filter(|s| s.live(salt, now)).collect()) } /// End one session of an account's; whether there was one. pub async fn end(pool: &Pool, account_id: i64, id: i64) -> sqlx::Result { let r = sqlx::query("DELETE FROM sessions WHERE id = $1 AND account_id = $2").bind(id).bind(account_id).execute(pool).await?; Ok(r.rows_affected() > 0) } pub async fn end_hash(pool: &Pool, hash: &str) -> sqlx::Result<()> { sqlx::query("DELETE FROM sessions WHERE hash = $1").bind(hash).execute(pool).await?; Ok(()) } /// Every session of an account's but `keep`. pub async fn end_others(pool: &Pool, account_id: i64, keep: i64) -> sqlx::Result<()> { sqlx::query("DELETE FROM sessions WHERE account_id = $1 AND id != $2").bind(account_id).bind(keep).execute(pool).await?; Ok(()) } /// Forget sessions that can no longer be used; how many. pub async fn sweep(pool: &Pool) -> sqlx::Result { let now = now_ms(); let r = sqlx::query( "DELETE FROM sessions WHERE seen_ms < $1 OR created_ms < $2 \ OR salt != (SELECT session_salt FROM accounts WHERE accounts.id = sessions.account_id)", ) .bind(now - IDLE_MS) .bind(now - ABSOLUTE_MS) .execute(pool) .await?; Ok(r.rows_affected()) } #[cfg(test)] mod tests { use super::*; #[test] fn a_session_ends_on_a_new_salt_when_idle_or_when_old() { let t0 = 1_000_000_000_000; let s = Session { id: 1, account_id: 1, salt: "a".into(), created_ms: t0, seen_ms: t0, address: String::new(), agent: String::new() }; assert!(s.live("a", t0 + 1000)); assert!(!s.live("b", t0 + 1000), "a new salt: signed out everywhere"); assert!(!s.live("a", t0 + IDLE_MS), "unused for 14 days"); let busy = Session { seen_ms: t0 + ABSOLUTE_MS - 1000, ..s.clone() }; assert!(busy.live("a", t0 + ABSOLUTE_MS - 1)); assert!(!busy.live("a", t0 + ABSOLUTE_MS), "30 days, however busy"); } }