Files
Mangalord/backend/src/repo/session.rs
MechaCat02 9148e23da8 fix: reap expired sessions with a periodic background sweep
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>
2026-07-13 20:19:41 +02:00

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())
}