The sessions table only grew — find_active ignored expired rows but nothing deleted them, so lapsed sessions accumulated indefinitely. - repo::session::delete_expired: indexed DELETE (sessions_expires_idx) returning the reaped count. - app::build spawns a detached hourly reaper (SESSION_GC_INTERVAL) that calls it, independent of crawler/analysis config. Test: delete_expired_removes_only_lapsed_sessions. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
80 lines
2.4 KiB
Rust
80 lines
2.4 KiB
Rust
//! Session persistence. `token_hash` is sha256 of the raw cookie value;
|
|
//! the raw value never hits the DB.
|
|
|
|
use chrono::{DateTime, Utc};
|
|
use sqlx::{PgExecutor, PgPool};
|
|
use uuid::Uuid;
|
|
|
|
use crate::domain::Session;
|
|
use crate::error::AppResult;
|
|
|
|
/// Accepts any `PgExecutor` so callers can pass `&PgPool` for simple
|
|
/// inserts or `&mut *tx` to run inside a transaction (e.g., password
|
|
/// change rotates the user's sessions atomically with the hash UPDATE).
|
|
pub async fn create<'e, E: PgExecutor<'e>>(
|
|
executor: E,
|
|
user_id: Uuid,
|
|
token_hash: &[u8],
|
|
expires_at: DateTime<Utc>,
|
|
) -> AppResult<Session> {
|
|
let row = sqlx::query_as::<_, Session>(
|
|
r#"
|
|
INSERT INTO sessions (user_id, token_hash, expires_at)
|
|
VALUES ($1, $2, $3)
|
|
RETURNING id, user_id, token_hash, created_at, expires_at
|
|
"#,
|
|
)
|
|
.bind(user_id)
|
|
.bind(token_hash)
|
|
.bind(expires_at)
|
|
.fetch_one(executor)
|
|
.await?;
|
|
Ok(row)
|
|
}
|
|
|
|
pub async fn delete_all_for_user<'e, E: PgExecutor<'e>>(
|
|
executor: E,
|
|
user_id: Uuid,
|
|
) -> AppResult<()> {
|
|
sqlx::query("DELETE FROM sessions WHERE user_id = $1")
|
|
.bind(user_id)
|
|
.execute(executor)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Returns the session iff `token_hash` matches and it hasn't expired.
|
|
pub async fn find_active(pool: &PgPool, token_hash: &[u8]) -> AppResult<Option<Session>> {
|
|
let row = sqlx::query_as::<_, Session>(
|
|
r#"
|
|
SELECT id, user_id, token_hash, created_at, expires_at
|
|
FROM sessions
|
|
WHERE token_hash = $1 AND expires_at > now()
|
|
"#,
|
|
)
|
|
.bind(token_hash)
|
|
.fetch_optional(pool)
|
|
.await?;
|
|
Ok(row)
|
|
}
|
|
|
|
pub async fn delete_by_token_hash(pool: &PgPool, token_hash: &[u8]) -> AppResult<()> {
|
|
sqlx::query("DELETE FROM sessions WHERE token_hash = $1")
|
|
.bind(token_hash)
|
|
.execute(pool)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Delete every session whose `expires_at` has passed, returning the number
|
|
/// reaped. `find_active` already refuses expired sessions, so this only
|
|
/// reclaims storage — running it on any schedule (or not at all) is safe.
|
|
/// Backed by `sessions_expires_idx` (0002). Called by the periodic reaper in
|
|
/// `app::build`.
|
|
pub async fn delete_expired(pool: &PgPool) -> AppResult<u64> {
|
|
let result = sqlx::query("DELETE FROM sessions WHERE expires_at <= now()")
|
|
.execute(pool)
|
|
.await?;
|
|
Ok(result.rows_affected())
|
|
}
|