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>
111 lines
3.7 KiB
Rust
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())
|
|
}
|
|
}
|