diff --git a/src/data/sessions.rs b/src/data/sessions.rs index c907e8c..d4dca3a 100644 --- a/src/data/sessions.rs +++ b/src/data/sessions.rs @@ -7,6 +7,9 @@ use sqlx::PgPool; use super::models::UserSession; use crate::error::Result; +/// Session lifetime: 7 days (in seconds). +pub const SESSION_DURATION_SECS: u64 = 7 * 24 * 3600; + /// Generate a cryptographically random 32-byte hex token. fn generate_token() -> String { let bytes: [u8; 32] = rand::rng().random(); @@ -48,13 +51,21 @@ pub async fn get_session(pool: &PgPool, token: &str) -> Result Result<()> { - sqlx::query("UPDATE user_sessions SET last_active_at = now() WHERE id = $1") - .bind(token) - .execute(pool) - .await - .context("failed to touch session")?; + sqlx::query( + r#" + UPDATE user_sessions + SET last_active_at = now(), + expires_at = now() + make_interval(secs => $2::double precision) + WHERE id = $1 + "#, + ) + .bind(token) + .bind(SESSION_DURATION_SECS as f64) + .execute(pool) + .await + .context("failed to touch session")?; Ok(()) } @@ -80,7 +91,6 @@ pub async fn delete_user_sessions(pool: &PgPool, user_id: i64) -> Result { } /// Delete all expired sessions. Returns the number of sessions cleaned up. -#[allow(dead_code)] // Called by SessionCache::cleanup_expired (not yet wired to periodic task) pub async fn cleanup_expired(pool: &PgPool) -> Result { let result = sqlx::query("DELETE FROM user_sessions WHERE expires_at <= now()") .execute(pool) diff --git a/src/services/web.rs b/src/services/web.rs index c397988..cce70fb 100644 --- a/src/services/web.rs +++ b/src/services/web.rs @@ -51,6 +51,33 @@ impl WebService { } } } + + /// Periodically cleans up expired sessions from the database and in-memory cache. + async fn session_cleanup_loop(state: AppState, mut shutdown_rx: broadcast::Receiver<()>) { + use std::time::Duration; + // Run every hour + let mut interval = tokio::time::interval(Duration::from_secs(3600)); + + loop { + tokio::select! { + _ = interval.tick() => { + match state.session_cache.cleanup_expired().await { + Ok(deleted) => { + if deleted > 0 { + info!(deleted, "cleaned up expired sessions"); + } + } + Err(e) => { + warn!(error = %e, "session cleanup failed"); + } + } + } + _ = shutdown_rx.recv() => { + break; + } + } + } + } } #[async_trait::async_trait] @@ -87,6 +114,13 @@ impl Service for WebService { Self::db_health_check_loop(health_state, health_shutdown_rx).await; }); + // Spawn session cleanup task + let cleanup_state = self.app_state.clone(); + let cleanup_shutdown_rx = shutdown_tx.subscribe(); + tokio::spawn(async move { + Self::session_cleanup_loop(cleanup_state, cleanup_shutdown_rx).await; + }); + // Use axum's graceful shutdown with the internal shutdown signal axum::serve(listener, app) .with_graceful_shutdown(async move { diff --git a/src/web/auth.rs b/src/web/auth.rs index 5cf506e..36ea9bb 100644 --- a/src/web/auth.rs +++ b/src/web/auth.rs @@ -235,7 +235,7 @@ pub async fn auth_callback( let session = crate::data::sessions::create_session( &state.db_pool, discord_id, - Duration::from_secs(7 * 24 * 3600), + Duration::from_secs(crate::data::sessions::SESSION_DURATION_SECS), ) .await .map_err(|e| { @@ -248,7 +248,11 @@ pub async fn auth_callback( // 6. Build response with session cookie let secure = redirect_uri.starts_with("https://"); - let cookie = session_cookie(&session.id, 604800, secure); + let cookie = session_cookie( + &session.id, + crate::data::sessions::SESSION_DURATION_SECS as i64, + secure, + ); let redirect_to = if user.is_admin { "/admin" } else { "/" }; diff --git a/src/web/schedule_cache.rs b/src/web/schedule_cache.rs index dc9d719..b68592d 100644 --- a/src/web/schedule_cache.rs +++ b/src/web/schedule_cache.rs @@ -9,8 +9,8 @@ use chrono::NaiveDate; use serde_json::Value; use sqlx::PgPool; -use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; use tokio::sync::watch; use tracing::{debug, error, info}; @@ -141,11 +141,10 @@ struct ScheduleRow { async fn load_snapshot(pool: &PgPool) -> anyhow::Result { let start = std::time::Instant::now(); - let rows: Vec = sqlx::query_as( - "SELECT subject, enrollment, meeting_times FROM courses", - ) - .fetch_all(pool) - .await?; + let rows: Vec = + sqlx::query_as("SELECT subject, enrollment, meeting_times FROM courses") + .fetch_all(pool) + .await?; let courses: Vec = rows .into_iter() diff --git a/src/web/session_cache.rs b/src/web/session_cache.rs index 4d9ca5f..ec61df3 100644 --- a/src/web/session_cache.rs +++ b/src/web/session_cache.rs @@ -108,7 +108,6 @@ impl SessionCache { /// Delete expired sessions from the database and sweep the in-memory cache. /// /// Returns the number of sessions deleted from the database. - #[allow(dead_code)] // Intended for periodic cleanup task (not yet wired) pub async fn cleanup_expired(&self) -> anyhow::Result { let deleted = crate::data::sessions::cleanup_expired(&self.db_pool).await?; diff --git a/src/web/timeline.rs b/src/web/timeline.rs index 7f9aaaf..46012b3 100644 --- a/src/web/timeline.rs +++ b/src/web/timeline.rs @@ -233,9 +233,8 @@ pub(crate) async fn timeline( s.active_during(local_date, wday_bit, slot_start_minutes, slot_end_minutes) }); if active { - *subject_totals - .entry(course.subject.clone()) - .or_default() += course.enrollment as i64; + *subject_totals.entry(course.subject.clone()).or_default() += + course.enrollment as i64; } } diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 0edc023..7a2e223 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -1,3 +1,4 @@ +import { authStore } from "$lib/auth.svelte"; import type { CandidateResponse, CodeDescription, @@ -212,6 +213,10 @@ export class BannerApiClient { const response = await this.fetchFn(...args); + if (response.status === 401) { + authStore.handleUnauthorized(); + } + if (!response.ok) { throw new Error(`API request failed: ${response.status} ${response.statusText}`); } @@ -229,6 +234,10 @@ export class BannerApiClient { const response = await this.fetchFn(...args); + if (response.status === 401) { + authStore.handleUnauthorized(); + } + if (!response.ok) { throw new Error(`API request failed: ${response.status} ${response.statusText}`); } diff --git a/web/src/lib/auth.svelte.ts b/web/src/lib/auth.svelte.ts index 01b97a0..f78829a 100644 --- a/web/src/lib/auth.svelte.ts +++ b/web/src/lib/auth.svelte.ts @@ -60,6 +60,13 @@ class AuthStore { } } + /** Idempotently mark the session as lost. Called by apiFetch on 401. */ + handleUnauthorized() { + if (this.state.mode !== "unauthenticated") { + this.state = { mode: "unauthenticated" }; + } + } + login() { window.location.href = "/api/auth/login"; } diff --git a/web/src/lib/components/NavBar.svelte b/web/src/lib/components/NavBar.svelte index cef164d..1e21672 100644 --- a/web/src/lib/components/NavBar.svelte +++ b/web/src/lib/components/NavBar.svelte @@ -11,11 +11,15 @@ const staticTabs = [ const APP_PREFIXES = ["/profile", "/settings", "/admin"]; -let profileTab = $derived({ - href: authStore.isAuthenticated ? "/profile" : "/login", - label: authStore.isAuthenticated ? "Account" : "Login", - icon: User, -}); +let profileTab = $derived( + authStore.isLoading + ? { href: "/login" as const, label: null, icon: User } + : { + href: authStore.isAuthenticated ? ("/profile" as const) : ("/login" as const), + label: authStore.isAuthenticated ? "Account" : "Login", + icon: User, + } +); function isActive(tabHref: string): boolean { if (tabHref === "/") return page.url.pathname === "/"; @@ -50,7 +54,7 @@ function isActive(tabHref: string): boolean { : 'text-muted-foreground hover:text-foreground hover:bg-background/50'}" > - {profileTab.label} + {#if profileTab.label}{profileTab.label}{/if} diff --git a/web/src/routes/(app)/+layout.svelte b/web/src/routes/(app)/+layout.svelte index 3cfba6a..64ca00c 100644 --- a/web/src/routes/(app)/+layout.svelte +++ b/web/src/routes/(app)/+layout.svelte @@ -15,7 +15,7 @@ import { User, Users, } from "@lucide/svelte"; -import { onMount, tick } from "svelte"; +import { tick } from "svelte"; let { children } = $props(); @@ -41,12 +41,6 @@ $effect(() => { } }); -onMount(async () => { - if (authStore.isLoading) { - await authStore.init(); - } -}); - $effect(() => { if (authStore.state.mode === "unauthenticated") { goto("/login"); diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index a25aed5..d3ab2ac 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -7,6 +7,7 @@ import NavBar from "$lib/components/NavBar.svelte"; import { useOverlayScrollbars } from "$lib/composables/useOverlayScrollbars.svelte"; import { initNavigation } from "$lib/stores/navigation.svelte"; import { themeStore } from "$lib/stores/theme.svelte"; +import { authStore } from "$lib/auth.svelte"; import { Tooltip } from "bits-ui"; import ErrorBoundaryFallback from "$lib/components/ErrorBoundaryFallback.svelte"; import { onMount } from "svelte"; @@ -34,6 +35,7 @@ useOverlayScrollbars(() => document.body, { onMount(() => { themeStore.init(); + authStore.init(); });