OXYGENHUB

Coverage / crates/hub/src/db/sessions.rs 68.7%

57 of 83 lines run, 16 of 37 functions · run #530 on main · run missed

1
//! Browser sessions as server-side records (oxygenhub#223).
2
3
use super::{now_ms, Pool};
4
use sqlx::FromRow;
5
6
/// A session ends after this long unused.
7
pub const IDLE_MS: i64 = 14 * 24 * 60 * 60_000;
8
/// And after this long whatever happens.
9
pub const ABSOLUTE_MS: i64 = 30 * 24 * 60 * 60_000;
10
/// `seen_ms` is written at most this often per session.
11
const TOUCH_EVERY_MS: i64 = 60_000;
12
13
#[derive(Clone, Debug, FromRow)]
14
pub struct Session {
15
    pub id: i64,
16
    pub account_id: i64,
17
    pub salt: String,
18
    pub created_ms: i64,
19
    pub seen_ms: i64,
20
    pub address: String,
21
    pub agent: String,
22
}
23
24
impl Session {
25
    /// Whether it is still good at `now`, for an account whose salt is `salt`.
261134×
    pub fn live(&self, salt: &str, now: i64) -> bool {
271134×
        self.salt == salt && now - self.seen_ms < IDLE_MS && now - self.created_ms < ABSOLUTE_MS
281134×
    }
29
}
30
31
const COLUMNS: &str = "id, account_id, salt, created_ms, seen_ms, address, agent";
32
33146×
pub async fn create(pool: &Pool, account_id: i64, hash: &str, salt: &str) -> sqlx::Result<i64> {
34146×
    let now = now_ms();
35146×
    sqlx::query_scalar("INSERT INTO sessions (account_id, hash, salt, created_ms, seen_ms) VALUES ($1, $2, $3, $4, $5) RETURNING id")
36146×
        .bind(account_id)
37146×
        .bind(hash)
38146×
        .bind(salt)
39146×
        .bind(now)
40146×
        .bind(now)
41146×
        .fetch_one(pool)
42146×
        .await
43146×
}
44
451122×
pub async fn by_hash(pool: &Pool, hash: &str) -> sqlx::Result<Option<Session>> {
461122×
    sqlx::query_as(&format!("SELECT {COLUMNS} FROM sessions WHERE hash = $1")).bind(hash).fetch_optional(pool).await
471122×
}
48
49
/// Note a use: the time (at most once a minute) and, the first time, the
50
/// address and browser it came from.
511112×
pub async fn touch(pool: &Pool, s: &Session, address: &str, agent: &str, now: i64) -> sqlx::Result<()> {
521112×
    if s.agent.is_empty() && s.address.is_empty() && (!agent.is_empty() || !address.is_empty()) {
530×
        sqlx::query("UPDATE sessions SET seen_ms = $1, address = $2, agent = $3 WHERE id = $4")
540×
            .bind(now)
550×
            .bind(address)
560×
            .bind(agent)
570×
            .bind(s.id)
580×
            .execute(pool)
590×
            .await?;
601112×
    } else if now - s.seen_ms >= TOUCH_EVERY_MS {
610×
        sqlx::query("UPDATE sessions SET seen_ms = $1 WHERE id = $2").bind(now).bind(s.id).execute(pool).await?;
621112×
    }
631112×
    Ok(())
641112×
}
65
66
/// An account's live sessions, most recently used first.
677×
pub async fn of_account(pool: &Pool, account_id: i64, salt: &str) -> sqlx::Result<Vec<Session>> {
687×
    let now = now_ms();
697×
    let all: Vec<Session> = sqlx::query_as(&format!("SELECT {COLUMNS} FROM sessions WHERE account_id = $1 ORDER BY seen_ms DESC"))
707×
        .bind(account_id)
717×
        .fetch_all(pool)
727×
        .await?;
7312×
    Ok(all.into_iter().filter(|s| s.live(salt, now)).collect())
747×
}
75
76
/// End one session of an account's; whether there was one.
771×
pub async fn end(pool: &Pool, account_id: i64, id: i64) -> sqlx::Result<bool> {
781×
    let r = sqlx::query("DELETE FROM sessions WHERE id = $1 AND account_id = $2").bind(id).bind(account_id).execute(pool).await?;
791×
    Ok(r.rows_affected() > 0)
801×
}
81
821×
pub async fn end_hash(pool: &Pool, hash: &str) -> sqlx::Result<()> {
831×
    sqlx::query("DELETE FROM sessions WHERE hash = $1").bind(hash).execute(pool).await?;
841×
    Ok(())
851×
}
86
87
/// Every session of an account's but `keep`.
880×
pub async fn end_others(pool: &Pool, account_id: i64, keep: i64) -> sqlx::Result<()> {
890×
    sqlx::query("DELETE FROM sessions WHERE account_id = $1 AND id != $2").bind(account_id).bind(keep).execute(pool).await?;
900×
    Ok(())
910×
}
92
93
/// Forget sessions that can no longer be used; how many.
940×
pub async fn sweep(pool: &Pool) -> sqlx::Result<u64> {
950×
    let now = now_ms();
960×
    let r = sqlx::query(
970×
        "DELETE FROM sessions WHERE seen_ms < $1 OR created_ms < $2 \
980×
         OR salt != (SELECT session_salt FROM accounts WHERE accounts.id = sessions.account_id)",
990×
    )
1000×
    .bind(now - IDLE_MS)
1010×
    .bind(now - ABSOLUTE_MS)
1020×
    .execute(pool)
1030×
    .await?;
1040×
    Ok(r.rows_affected())
1050×
}
106
107
#[cfg(test)]
108
mod tests {
109
    use super::*;
110
111
    #[test]
1121×
    fn a_session_ends_on_a_new_salt_when_idle_or_when_old() {
1131×
        let t0 = 1_000_000_000_000;
1141×
        let s =
1151×
            Session { id: 1, account_id: 1, salt: "a".into(), created_ms: t0, seen_ms: t0, address: String::new(), agent: String::new() };
1161×
        assert!(s.live("a", t0 + 1000));
1171×
        assert!(!s.live("b", t0 + 1000), "a new salt: signed out everywhere");
1181×
        assert!(!s.live("a", t0 + IDLE_MS), "unused for 14 days");
1191×
        let busy = Session { seen_ms: t0 + ABSOLUTE_MS - 1000, ..s.clone() };
1201×
        assert!(busy.live("a", t0 + ABSOLUTE_MS - 1));
1211×
        assert!(!busy.live("a", t0 + ABSOLUTE_MS), "30 days, however busy");
1221×
    }
123
}