Files
EventSnap/backend/src/models/session.rs
fabi ee554e7f38 style(backend): rustfmt the whole tree; gate cargo fmt --check in CI
The backend had never been run through rustfmt. Doing it in one mechanical pass (134 files)
so no future functional diff is buried under formatting churn, then gating `cargo fmt
--check` in checks.yml so it stays clean.

Formatting only — no logic, SQL, or behaviour changed. Verified after the reformat:
cargo test 56 passed, clippy --all-targets -D warnings clean, cargo fmt --check clean.

This is the deferred cleanup noted when CI's Format step was first left out.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 19:52:17 +02:00

111 lines
3.7 KiB
Rust

use chrono::{DateTime, Utc};
use sqlx::PgPool;
use uuid::Uuid;
// Row shape for `session`, populated by sqlx from `RETURNING *`. Session validation is done in SQL
// (expiry/last-seen predicates), so no field is read in Rust — the struct is the row's shape.
#[allow(dead_code)]
#[derive(Debug, sqlx::FromRow)]
pub struct Session {
pub id: Uuid,
pub user_id: Uuid,
pub token_hash: String,
pub expires_at: DateTime<Utc>,
pub last_seen_at: DateTime<Utc>,
pub created_at: DateTime<Utc>,
}
impl Session {
pub async fn create(
pool: &PgPool,
user_id: Uuid,
token_hash: &str,
expires_at: DateTime<Utc>,
) -> Result<Self, sqlx::Error> {
sqlx::query_as::<_, Self>(
"INSERT INTO session (user_id, token_hash, expires_at)
VALUES ($1, $2, $3)
RETURNING *",
)
.bind(user_id)
.bind(token_hash)
.bind(expires_at)
.fetch_one(pool)
.await
}
pub async fn find_by_token_hash(
pool: &PgPool,
token_hash: &str,
) -> Result<Option<Self>, sqlx::Error> {
sqlx::query_as::<_, Self>(
"SELECT * FROM session WHERE token_hash = $1 AND expires_at > NOW()",
)
.bind(token_hash)
.fetch_optional(pool)
.await
}
/// Resolve a session token straight to its live user row in one round-trip.
///
/// The auth extractor runs on every authenticated request and used to do two
/// sequential queries (session lookup, then user lookup); this collapses them into
/// a single `session JOIN "user"`. The `expires_at` guard mirrors
/// [`Self::find_by_token_hash`], and the user row is read live (role/ban are never
/// trusted from the JWT). Returns `None` when the session is missing/expired.
pub async fn find_user_by_token_hash(
pool: &PgPool,
token_hash: &str,
) -> Result<Option<crate::models::user::User>, sqlx::Error> {
sqlx::query_as::<_, crate::models::user::User>(
"SELECT u.*
FROM session s
JOIN \"user\" u ON u.id = s.user_id
WHERE s.token_hash = $1 AND s.expires_at > NOW()",
)
.bind(token_hash)
.fetch_optional(pool)
.await
}
/// Touch `last_seen_at` AND slide `expires_at` forward by `expiry_days` from now, so
/// an actively-used session never hits the fixed 30-day cliff (it renews on every
/// authenticated request). An idle session still expires `expiry_days` after its last
/// activity. Keyed by token hash so the auth extractor needs no prior id lookup.
pub async fn touch_and_renew(
pool: &PgPool,
token_hash: &str,
expiry_days: i64,
) -> Result<(), sqlx::Error> {
sqlx::query(
"UPDATE session
SET last_seen_at = NOW(),
expires_at = NOW() + ($2 || ' days')::interval
WHERE token_hash = $1",
)
.bind(token_hash)
.bind(expiry_days.to_string())
.execute(pool)
.await?;
Ok(())
}
pub async fn delete_by_token_hash(pool: &PgPool, token_hash: &str) -> Result<(), sqlx::Error> {
sqlx::query("DELETE FROM session WHERE token_hash = $1")
.bind(token_hash)
.execute(pool)
.await?;
Ok(())
}
/// Revoke every session for a user. Backs "sign out everywhere" and the forced
/// re-auth after a host PIN-reset or a ban. Returns the number of sessions cleared.
pub async fn delete_all_for_user(pool: &PgPool, user_id: Uuid) -> Result<u64, sqlx::Error> {
let r = sqlx::query("DELETE FROM session WHERE user_id = $1")
.bind(user_id)
.execute(pool)
.await?;
Ok(r.rows_affected())
}
}