From 527cbebc6aa24daa8bf62a28856fc2a9e450565d Mon Sep 17 00:00:00 2001 From: Xevion Date: Thu, 29 Jan 2026 12:56:51 -0600 Subject: [PATCH] feat: implement user authentication system with admin dashboard --- Cargo.lock | 10 +- Cargo.toml | 3 +- .../20260129200000_add_users_and_sessions.sql | 19 ++ src/app.rs | 20 +- src/banner/models/meetings.rs | 2 +- src/config/mod.rs | 50 +++ src/data/courses.rs | 5 +- src/data/mod.rs | 2 + src/data/models.rs | 24 ++ src/data/sessions.rs | 90 ++++++ src/data/users.rs | 86 +++++ src/services/web.rs | 7 +- src/state.rs | 5 + src/web/admin.rs | 205 ++++++++++++ src/web/auth.rs | 300 ++++++++++++++++++ src/web/extractors.rs | 74 +++++ src/web/mod.rs | 4 + src/web/routes.rs | 33 +- src/web/session_cache.rs | 188 +++++++++++ web/src/lib/api.ts | 65 ++++ web/src/lib/auth.svelte.ts | 55 ++++ web/src/lib/bindings/index.ts | 1 + web/src/routes/admin/+layout.svelte | 74 +++++ web/src/routes/admin/+page.svelte | 54 ++++ web/src/routes/admin/audit-log/+page.svelte | 50 +++ web/src/routes/admin/scrape-jobs/+page.svelte | 52 +++ web/src/routes/admin/users/+page.svelte | 92 ++++++ web/src/routes/login/+page.svelte | 18 ++ 28 files changed, 1575 insertions(+), 13 deletions(-) create mode 100644 migrations/20260129200000_add_users_and_sessions.sql create mode 100644 src/data/sessions.rs create mode 100644 src/data/users.rs create mode 100644 src/web/admin.rs create mode 100644 src/web/auth.rs create mode 100644 src/web/extractors.rs create mode 100644 src/web/session_cache.rs create mode 100644 web/src/lib/auth.svelte.ts create mode 100644 web/src/routes/admin/+layout.svelte create mode 100644 web/src/routes/admin/+page.svelte create mode 100644 web/src/routes/admin/audit-log/+page.svelte create mode 100644 web/src/routes/admin/scrape-jobs/+page.svelte create mode 100644 web/src/routes/admin/users/+page.svelte create mode 100644 web/src/routes/login/+page.svelte diff --git a/Cargo.lock b/Cargo.lock index 742a210..972c484 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -241,7 +241,7 @@ dependencies = [ [[package]] name = "banner" -version = "0.3.4" +version = "0.5.0" dependencies = [ "anyhow", "async-trait", @@ -284,6 +284,7 @@ dependencies = [ "tracing-subscriber", "ts-rs", "url", + "urlencoding", "yansi", ] @@ -3721,6 +3722,7 @@ version = "11.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4994acea2522cd2b3b85c1d9529a55991e3ad5e25cdcd3de9d505972c4379424" dependencies = [ + "chrono", "serde_json", "thiserror 2.0.16", "ts-rs-macros", @@ -3860,6 +3862,12 @@ dependencies = [ "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf-8" version = "0.7.6" diff --git a/Cargo.toml b/Cargo.toml index bc09471..c2a30cd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,9 +55,10 @@ clap = { version = "4.5", features = ["derive"] } rapidhash = "4.1.0" yansi = "1.0.1" extension-traits = "2" -ts-rs = { version = "11.1.0", features = ["serde-compat", "serde-json-impl"] } +ts-rs = { version = "11.1.0", features = ["chrono-impl", "serde-compat", "serde-json-impl"] } html-escape = "0.2.13" axum-extra = { version = "0.12.5", features = ["query"] } +urlencoding = "2.1.3" [dev-dependencies] diff --git a/migrations/20260129200000_add_users_and_sessions.sql b/migrations/20260129200000_add_users_and_sessions.sql new file mode 100644 index 0000000..e6eac45 --- /dev/null +++ b/migrations/20260129200000_add_users_and_sessions.sql @@ -0,0 +1,19 @@ +CREATE TABLE users ( + discord_id BIGINT PRIMARY KEY, + discord_username TEXT NOT NULL, + discord_avatar_hash TEXT, + is_admin BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE user_sessions ( + id TEXT PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(discord_id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ NOT NULL, + last_active_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_user_sessions_user_id ON user_sessions(user_id); +CREATE INDEX idx_user_sessions_expires_at ON user_sessions(expires_at); diff --git a/src/app.rs b/src/app.rs index 6284e25..260c436 100644 --- a/src/app.rs +++ b/src/app.rs @@ -6,6 +6,7 @@ use crate::services::bot::BotService; use crate::services::manager::ServiceManager; use crate::services::web::WebService; use crate::state::AppState; +use crate::web::auth::AuthConfig; use anyhow::Context; use figment::value::UncasedStr; use figment::{Figment, providers::Env}; @@ -84,6 +85,14 @@ impl App { info!(error = ?e, "Could not load reference cache on startup (may be empty)"); } + // Seed the initial admin user if configured + if let Some(admin_id) = config.admin_discord_id { + let user = crate::data::users::ensure_seed_admin(&db_pool, admin_id as i64) + .await + .context("Failed to seed admin user")?; + info!(discord_id = admin_id, username = %user.discord_username, "Seed admin ensured"); + } + Ok(App { config, db_pool, @@ -97,7 +106,16 @@ impl App { pub fn setup_services(&mut self, services: &[ServiceName]) -> Result<(), anyhow::Error> { // Register enabled services with the manager if services.contains(&ServiceName::Web) { - let web_service = Box::new(WebService::new(self.config.port, self.app_state.clone())); + let auth_config = AuthConfig { + client_id: self.config.discord_client_id.clone(), + client_secret: self.config.discord_client_secret.clone(), + redirect_base: self.config.discord_redirect_uri.clone(), + }; + let web_service = Box::new(WebService::new( + self.config.port, + self.app_state.clone(), + auth_config, + )); self.service_manager .register_service(ServiceName::Web.as_str(), web_service); } diff --git a/src/banner/models/meetings.rs b/src/banner/models/meetings.rs index 8d6afb3..3cf937c 100644 --- a/src/banner/models/meetings.rs +++ b/src/banner/models/meetings.rs @@ -1,4 +1,4 @@ -use bitflags::{bitflags, Flags}; +use bitflags::{Flags, bitflags}; use chrono::{DateTime, NaiveDate, NaiveTime, Timelike, Utc, Weekday}; use extension_traits::extension; use serde::{Deserialize, Deserializer, Serialize}; diff --git a/src/config/mod.rs b/src/config/mod.rs index e7cae95..120183e 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -47,6 +47,19 @@ pub struct Config { /// Rate limiting configuration for Banner API requests #[serde(default = "default_rate_limiting")] pub rate_limiting: RateLimitingConfig, + + /// Discord OAuth2 client ID for web authentication + #[serde(deserialize_with = "deserialize_string_or_uint")] + pub discord_client_id: String, + /// Discord OAuth2 client secret for web authentication + pub discord_client_secret: String, + /// Optional base URL override for OAuth2 redirect (e.g. "https://banner.xevion.dev"). + /// When unset, the redirect URI is derived from the incoming request's Origin/Host. + #[serde(default)] + pub discord_redirect_uri: Option, + /// Discord user ID to seed as initial admin on startup (optional) + #[serde(default)] + pub admin_discord_id: Option, } /// Default log level of "info" @@ -216,6 +229,43 @@ where deserializer.deserialize_any(DurationVisitor) } +/// Deserializes a value that may arrive as either a string or unsigned integer. +/// +/// Figment's env provider infers types from raw values, so numeric-looking strings +/// like Discord client IDs get parsed as integers. This accepts both forms. +fn deserialize_string_or_uint<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + use serde::de::Visitor; + + struct StringOrUintVisitor; + + impl<'de> Visitor<'de> for StringOrUintVisitor { + type Value = String; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a string or unsigned integer") + } + + fn visit_str(self, value: &str) -> Result + where + E: serde::de::Error, + { + Ok(value.to_owned()) + } + + fn visit_u64(self, value: u64) -> Result + where + E: serde::de::Error, + { + Ok(value.to_string()) + } + } + + deserializer.deserialize_any(StringOrUintVisitor) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/data/courses.rs b/src/data/courses.rs index e89fc41..d6e5a85 100644 --- a/src/data/courses.rs +++ b/src/data/courses.rs @@ -92,9 +92,8 @@ pub async fn search_courses( ) -> Result<(Vec, i64)> { let order_by = sort_clause(sort_by, sort_dir); - let data_query = format!( - "SELECT * FROM courses {SEARCH_WHERE} ORDER BY {order_by} LIMIT $9 OFFSET $10" - ); + let data_query = + format!("SELECT * FROM courses {SEARCH_WHERE} ORDER BY {order_by} LIMIT $9 OFFSET $10"); let count_query = format!("SELECT COUNT(*) FROM courses {SEARCH_WHERE}"); let courses = sqlx::query_as::<_, Course>(&data_query) diff --git a/src/data/mod.rs b/src/data/mod.rs index f97a776..6edd39a 100644 --- a/src/data/mod.rs +++ b/src/data/mod.rs @@ -6,3 +6,5 @@ pub mod models; pub mod reference; pub mod rmp; pub mod scrape_jobs; +pub mod sessions; +pub mod users; diff --git a/src/data/models.rs b/src/data/models.rs index 2b50220..c56eeb7 100644 --- a/src/data/models.rs +++ b/src/data/models.rs @@ -155,3 +155,27 @@ pub struct ScrapeJob { /// Maximum number of retry attempts allowed (non-negative, enforced by CHECK constraint) pub max_retries: i32, } + +/// A user authenticated via Discord OAuth. +#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct User { + pub discord_id: i64, + pub discord_username: String, + pub discord_avatar_hash: Option, + pub is_admin: bool, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// A server-side session for an authenticated user. +#[allow(dead_code)] // Fields read via sqlx::FromRow; some only used in DB queries +#[derive(sqlx::FromRow, Debug, Clone)] +pub struct UserSession { + pub id: String, + pub user_id: i64, + pub created_at: DateTime, + pub expires_at: DateTime, + pub last_active_at: DateTime, +} diff --git a/src/data/sessions.rs b/src/data/sessions.rs new file mode 100644 index 0000000..c907e8c --- /dev/null +++ b/src/data/sessions.rs @@ -0,0 +1,90 @@ +//! Database query functions for user sessions. + +use anyhow::Context; +use rand::Rng; +use sqlx::PgPool; + +use super::models::UserSession; +use crate::error::Result; + +/// Generate a cryptographically random 32-byte hex token. +fn generate_token() -> String { + let bytes: [u8; 32] = rand::rng().random(); + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +/// Create a new session for a user with the given duration. +pub async fn create_session( + pool: &PgPool, + user_id: i64, + duration: std::time::Duration, +) -> Result { + let token = generate_token(); + let duration_secs = duration.as_secs() as i64; + + sqlx::query_as::<_, UserSession>( + r#" + INSERT INTO user_sessions (id, user_id, expires_at) + VALUES ($1, $2, now() + make_interval(secs => $3::double precision)) + RETURNING * + "#, + ) + .bind(&token) + .bind(user_id) + .bind(duration_secs as f64) + .fetch_one(pool) + .await + .context("failed to create session") +} + +/// Fetch a session by token, only if it has not expired. +pub async fn get_session(pool: &PgPool, token: &str) -> Result> { + sqlx::query_as::<_, UserSession>( + "SELECT * FROM user_sessions WHERE id = $1 AND expires_at > now()", + ) + .bind(token) + .fetch_optional(pool) + .await + .context("failed to get session") +} + +/// Update the last-active timestamp for a session. +pub async fn touch_session(pool: &PgPool, token: &str) -> Result<()> { + sqlx::query("UPDATE user_sessions SET last_active_at = now() WHERE id = $1") + .bind(token) + .execute(pool) + .await + .context("failed to touch session")?; + Ok(()) +} + +/// Delete a session by token. +pub async fn delete_session(pool: &PgPool, token: &str) -> Result<()> { + sqlx::query("DELETE FROM user_sessions WHERE id = $1") + .bind(token) + .execute(pool) + .await + .context("failed to delete session")?; + Ok(()) +} + +/// Delete all sessions for a user. Returns the number of sessions deleted. +#[allow(dead_code)] // Available for admin user-deletion flow +pub async fn delete_user_sessions(pool: &PgPool, user_id: i64) -> Result { + let result = sqlx::query("DELETE FROM user_sessions WHERE user_id = $1") + .bind(user_id) + .execute(pool) + .await + .context("failed to delete user sessions")?; + Ok(result.rows_affected()) +} + +/// 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) + .await + .context("failed to cleanup expired sessions")?; + Ok(result.rows_affected()) +} diff --git a/src/data/users.rs b/src/data/users.rs new file mode 100644 index 0000000..130508d --- /dev/null +++ b/src/data/users.rs @@ -0,0 +1,86 @@ +//! Database query functions for users. + +use anyhow::Context; +use sqlx::PgPool; + +use super::models::User; +use crate::error::Result; + +/// Insert a new user or update username/avatar on conflict. +pub async fn upsert_user( + pool: &PgPool, + discord_id: i64, + username: &str, + avatar_hash: Option<&str>, +) -> Result { + sqlx::query_as::<_, User>( + r#" + INSERT INTO users (discord_id, discord_username, discord_avatar_hash) + VALUES ($1, $2, $3) + ON CONFLICT (discord_id) DO UPDATE + SET discord_username = EXCLUDED.discord_username, + discord_avatar_hash = EXCLUDED.discord_avatar_hash, + updated_at = now() + RETURNING * + "#, + ) + .bind(discord_id) + .bind(username) + .bind(avatar_hash) + .fetch_one(pool) + .await + .context("failed to upsert user") +} + +/// Fetch a user by Discord ID. +pub async fn get_user(pool: &PgPool, discord_id: i64) -> Result> { + sqlx::query_as::<_, User>("SELECT * FROM users WHERE discord_id = $1") + .bind(discord_id) + .fetch_optional(pool) + .await + .context("failed to get user") +} + +/// List all users ordered by creation date (newest first). +pub async fn list_users(pool: &PgPool) -> Result> { + sqlx::query_as::<_, User>("SELECT * FROM users ORDER BY created_at DESC") + .fetch_all(pool) + .await + .context("failed to list users") +} + +/// Set the admin flag for a user, returning the updated user if found. +pub async fn set_admin(pool: &PgPool, discord_id: i64, is_admin: bool) -> Result> { + sqlx::query_as::<_, User>( + r#" + UPDATE users + SET is_admin = $2, updated_at = now() + WHERE discord_id = $1 + RETURNING * + "#, + ) + .bind(discord_id) + .bind(is_admin) + .fetch_optional(pool) + .await + .context("failed to set admin status") +} + +/// Ensure a seed admin exists. Upserts with `is_admin = true` and a placeholder +/// username that will be replaced on first OAuth login. +pub async fn ensure_seed_admin(pool: &PgPool, discord_id: i64) -> Result { + sqlx::query_as::<_, User>( + r#" + INSERT INTO users (discord_id, discord_username, is_admin) + VALUES ($1, 'seed-admin', true) + ON CONFLICT (discord_id) DO UPDATE + SET is_admin = true, + updated_at = now() + RETURNING * + "#, + ) + .bind(discord_id) + .fetch_one(pool) + .await + .context("failed to ensure seed admin") +} diff --git a/src/services/web.rs b/src/services/web.rs index 4ba10f1..c397988 100644 --- a/src/services/web.rs +++ b/src/services/web.rs @@ -1,6 +1,7 @@ use super::Service; use crate::state::AppState; use crate::status::ServiceStatus; +use crate::web::auth::AuthConfig; use crate::web::create_router; use std::net::SocketAddr; use tokio::net::TcpListener; @@ -11,14 +12,16 @@ use tracing::{info, trace, warn}; pub struct WebService { port: u16, app_state: AppState, + auth_config: AuthConfig, shutdown_tx: Option>, } impl WebService { - pub fn new(port: u16, app_state: AppState) -> Self { + pub fn new(port: u16, app_state: AppState, auth_config: AuthConfig) -> Self { Self { port, app_state, + auth_config, shutdown_tx: None, } } @@ -58,7 +61,7 @@ impl Service for WebService { async fn run(&mut self) -> Result<(), anyhow::Error> { // Create the main router with Banner API routes - let app = create_router(self.app_state.clone()); + let app = create_router(self.app_state.clone(), self.auth_config.clone()); let addr = SocketAddr::from(([0, 0, 0, 0], self.port)); diff --git a/src/state.rs b/src/state.rs index 701b750..10c14da 100644 --- a/src/state.rs +++ b/src/state.rs @@ -4,6 +4,7 @@ use crate::banner::BannerApi; use crate::banner::Course; use crate::data::models::ReferenceData; use crate::status::ServiceStatusRegistry; +use crate::web::session_cache::{OAuthStateStore, SessionCache}; use anyhow::Result; use sqlx::PgPool; use std::collections::HashMap; @@ -72,11 +73,15 @@ pub struct AppState { pub db_pool: PgPool, pub service_statuses: ServiceStatusRegistry, pub reference_cache: Arc>, + pub session_cache: SessionCache, + pub oauth_state_store: OAuthStateStore, } impl AppState { pub fn new(banner_api: Arc, db_pool: PgPool) -> Self { Self { + session_cache: SessionCache::new(db_pool.clone()), + oauth_state_store: OAuthStateStore::new(), banner_api, db_pool, service_statuses: ServiceStatusRegistry::new(), diff --git a/src/web/admin.rs b/src/web/admin.rs new file mode 100644 index 0000000..b7e8db8 --- /dev/null +++ b/src/web/admin.rs @@ -0,0 +1,205 @@ +//! Admin API handlers. +//! +//! All endpoints require the `AdminUser` extractor, returning 401/403 as needed. + +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::response::Json; +use serde::Deserialize; +use serde_json::{Value, json}; + +use crate::data::models::User; +use crate::state::AppState; +use crate::web::extractors::AdminUser; + +/// `GET /api/admin/status` — Enhanced system status for admins. +pub async fn admin_status( + AdminUser(_user): AdminUser, + State(state): State, +) -> Result, (StatusCode, Json)> { + let (user_count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users") + .fetch_one(&state.db_pool) + .await + .map_err(|e| { + tracing::error!(error = %e, "failed to count users"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "failed to count users"})), + ) + })?; + + let (session_count,): (i64,) = + sqlx::query_as("SELECT COUNT(*) FROM user_sessions WHERE expires_at > now()") + .fetch_one(&state.db_pool) + .await + .map_err(|e| { + tracing::error!(error = %e, "failed to count sessions"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "failed to count sessions"})), + ) + })?; + + let course_count = state.get_course_count().await.map_err(|e| { + tracing::error!(error = %e, "failed to count courses"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "failed to count courses"})), + ) + })?; + + let (scrape_job_count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM scrape_jobs") + .fetch_one(&state.db_pool) + .await + .map_err(|e| { + tracing::error!(error = %e, "failed to count scrape jobs"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "failed to count scrape jobs"})), + ) + })?; + + let services: Vec = state + .service_statuses + .all() + .into_iter() + .map(|(name, status)| { + json!({ + "name": name, + "status": status, + }) + }) + .collect(); + + Ok(Json(json!({ + "userCount": user_count, + "sessionCount": session_count, + "courseCount": course_count, + "scrapeJobCount": scrape_job_count, + "services": services, + }))) +} + +/// `GET /api/admin/users` — List all users. +pub async fn list_users( + AdminUser(_user): AdminUser, + State(state): State, +) -> Result>, (StatusCode, Json)> { + let users = crate::data::users::list_users(&state.db_pool) + .await + .map_err(|e| { + tracing::error!(error = %e, "failed to list users"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "failed to list users"})), + ) + })?; + + Ok(Json(users)) +} + +#[derive(Deserialize)] +pub struct SetAdminBody { + is_admin: bool, +} + +/// `PUT /api/admin/users/{discord_id}/admin` — Set admin status for a user. +pub async fn set_user_admin( + AdminUser(_user): AdminUser, + State(state): State, + Path(discord_id): Path, + Json(body): Json, +) -> Result, (StatusCode, Json)> { + let user = crate::data::users::set_admin(&state.db_pool, discord_id, body.is_admin) + .await + .map_err(|e| { + tracing::error!(error = %e, "failed to set admin status"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "failed to set admin status"})), + ) + })? + .ok_or_else(|| { + ( + StatusCode::NOT_FOUND, + Json(json!({"error": "user not found"})), + ) + })?; + + state.session_cache.evict_user(discord_id); + + Ok(Json(user)) +} + +/// `GET /api/admin/scrape-jobs` — List scrape jobs. +pub async fn list_scrape_jobs( + AdminUser(_user): AdminUser, + State(state): State, +) -> Result, (StatusCode, Json)> { + let rows = sqlx::query_as::<_, crate::data::models::ScrapeJob>( + "SELECT * FROM scrape_jobs ORDER BY priority DESC, execute_at ASC LIMIT 100", + ) + .fetch_all(&state.db_pool) + .await + .map_err(|e| { + tracing::error!(error = %e, "failed to list scrape jobs"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "failed to list scrape jobs"})), + ) + })?; + + let jobs: Vec = rows + .iter() + .map(|j| { + json!({ + "id": j.id, + "targetType": format!("{:?}", j.target_type), + "targetPayload": j.target_payload, + "priority": format!("{:?}", j.priority), + "executeAt": j.execute_at.to_rfc3339(), + "createdAt": j.created_at.to_rfc3339(), + "lockedAt": j.locked_at.map(|t| t.to_rfc3339()), + "retryCount": j.retry_count, + "maxRetries": j.max_retries, + }) + }) + .collect(); + + Ok(Json(json!({ "jobs": jobs }))) +} + +/// `GET /api/admin/audit-log` — List recent audit entries. +pub async fn list_audit_log( + AdminUser(_user): AdminUser, + State(state): State, +) -> Result, (StatusCode, Json)> { + let rows = sqlx::query_as::<_, crate::data::models::CourseAudit>( + "SELECT * FROM course_audits ORDER BY timestamp DESC LIMIT 200", + ) + .fetch_all(&state.db_pool) + .await + .map_err(|e| { + tracing::error!(error = %e, "failed to list audit log"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "failed to list audit log"})), + ) + })?; + + let entries: Vec = rows + .iter() + .map(|a| { + json!({ + "id": a.id, + "courseId": a.course_id, + "timestamp": a.timestamp.to_rfc3339(), + "fieldChanged": a.field_changed, + "oldValue": a.old_value, + "newValue": a.new_value, + }) + }) + .collect(); + + Ok(Json(json!({ "entries": entries }))) +} diff --git a/src/web/auth.rs b/src/web/auth.rs new file mode 100644 index 0000000..5cf506e --- /dev/null +++ b/src/web/auth.rs @@ -0,0 +1,300 @@ +//! Discord OAuth2 authentication handlers. +//! +//! Provides login, callback, logout, and session introspection endpoints +//! for Discord OAuth2 authentication flow. + +use axum::extract::{Extension, Query, State}; +use axum::http::{HeaderMap, StatusCode, header}; +use axum::response::{IntoResponse, Json, Redirect, Response}; +use serde::Deserialize; +use serde_json::{Value, json}; +use std::time::Duration; +use tracing::{error, info, warn}; + +use crate::state::AppState; + +/// OAuth configuration passed as an Axum Extension. +#[derive(Clone)] +pub struct AuthConfig { + pub client_id: String, + pub client_secret: String, + /// Optional base URL override (e.g. "https://banner.xevion.dev"). + /// When `None`, the redirect URI is derived from the request's Origin/Host header. + pub redirect_base: Option, +} + +const CALLBACK_PATH: &str = "/api/auth/callback"; + +/// Derive the origin (scheme + host + port) the user's browser is actually on. +/// +/// Priority: +/// 1. Configured `redirect_base` (production override) +/// 2. `Referer` header — preserves the real browser origin even through +/// reverse proxies that rewrite `Host` (e.g. Vite dev proxy with +/// `changeOrigin: true`) +/// 3. `Origin` header (present on POST / CORS requests) +/// 4. `Host` header (last resort, may be rewritten by proxies) +fn resolve_origin(auth_config: &AuthConfig, headers: &HeaderMap) -> String { + if let Some(base) = &auth_config.redirect_base { + return base.trim_end_matches('/').to_owned(); + } + + // Referer carries the full browser URL; extract just the origin. + if let Some(referer) = headers.get(header::REFERER).and_then(|v| v.to_str().ok()) + && let Ok(parsed) = url::Url::parse(referer) + { + let origin = parsed.origin().unicode_serialization(); + if origin != "null" { + return origin; + } + } + + if let Some(origin) = headers.get("origin").and_then(|v| v.to_str().ok()) { + return origin.trim_end_matches('/').to_owned(); + } + + if let Some(host) = headers.get(header::HOST).and_then(|v| v.to_str().ok()) { + return format!("http://{host}"); + } + + "http://localhost:8080".to_owned() +} + +#[derive(Deserialize)] +pub struct CallbackParams { + code: String, + state: String, +} + +#[derive(Deserialize)] +struct TokenResponse { + access_token: String, +} + +#[derive(Deserialize)] +struct DiscordUser { + id: String, + username: String, + avatar: Option, +} + +/// Extract the `session` cookie value from request headers. +fn extract_session_token(headers: &HeaderMap) -> Option { + headers + .get(header::COOKIE)? + .to_str() + .ok()? + .split(';') + .find_map(|cookie| { + let cookie = cookie.trim(); + cookie.strip_prefix("session=").map(|v| v.to_owned()) + }) +} + +/// Build a `Set-Cookie` header value for the session cookie. +fn session_cookie(token: &str, max_age: i64, secure: bool) -> String { + let mut cookie = format!("session={token}; HttpOnly; SameSite=Lax; Path=/; Max-Age={max_age}"); + if secure { + cookie.push_str("; Secure"); + } + cookie +} + +/// `GET /api/auth/login` — Redirect to Discord OAuth2 authorization page. +pub async fn auth_login( + State(state): State, + Extension(auth_config): Extension, + headers: HeaderMap, +) -> Redirect { + let origin = resolve_origin(&auth_config, &headers); + let redirect_uri = format!("{origin}{CALLBACK_PATH}"); + let csrf_state = state.oauth_state_store.generate(origin); + let redirect_uri_encoded = urlencoding::encode(&redirect_uri); + + let url = format!( + "https://discord.com/oauth2/authorize\ + ?client_id={}\ + &redirect_uri={redirect_uri_encoded}\ + &response_type=code\ + &scope=identify\ + &state={csrf_state}", + auth_config.client_id, + ); + + Redirect::temporary(&url) +} + +/// `GET /api/auth/callback` — Handle Discord OAuth2 callback. +pub async fn auth_callback( + State(state): State, + Extension(auth_config): Extension, + Query(params): Query, +) -> Result)> { + // 1. Validate CSRF state and recover the origin used during login + let origin = state + .oauth_state_store + .validate(¶ms.state) + .ok_or_else(|| { + warn!("OAuth callback with invalid CSRF state"); + ( + StatusCode::BAD_REQUEST, + Json(json!({ "error": "Invalid OAuth state" })), + ) + })?; + + // 2. Exchange authorization code for access token + let redirect_uri = format!("{origin}{CALLBACK_PATH}"); + let client = reqwest::Client::new(); + let token_response = client + .post("https://discord.com/api/oauth2/token") + .form(&[ + ("client_id", auth_config.client_id.as_str()), + ("client_secret", auth_config.client_secret.as_str()), + ("grant_type", "authorization_code"), + ("code", params.code.as_str()), + ("redirect_uri", redirect_uri.as_str()), + ]) + .send() + .await + .map_err(|e| { + error!(error = %e, "failed to exchange OAuth code for token"); + ( + StatusCode::BAD_GATEWAY, + Json(json!({ "error": "Failed to exchange code with Discord" })), + ) + })?; + + if !token_response.status().is_success() { + let status = token_response.status(); + let body = token_response.text().await.unwrap_or_default(); + error!(%status, %body, "Discord token exchange returned error"); + return Err(( + StatusCode::BAD_GATEWAY, + Json(json!({ "error": "Discord token exchange failed" })), + )); + } + + let token_data: TokenResponse = token_response.json().await.map_err(|e| { + error!(error = %e, "failed to parse Discord token response"); + ( + StatusCode::BAD_GATEWAY, + Json(json!({ "error": "Invalid token response from Discord" })), + ) + })?; + + // 3. Fetch Discord user profile + let discord_user: DiscordUser = client + .get("https://discord.com/api/users/@me") + .bearer_auth(&token_data.access_token) + .send() + .await + .map_err(|e| { + error!(error = %e, "failed to fetch Discord user profile"); + ( + StatusCode::BAD_GATEWAY, + Json(json!({ "error": "Failed to fetch Discord profile" })), + ) + })? + .json() + .await + .map_err(|e| { + error!(error = %e, "failed to parse Discord user profile"); + ( + StatusCode::BAD_GATEWAY, + Json(json!({ "error": "Invalid user profile from Discord" })), + ) + })?; + + let discord_id: i64 = discord_user.id.parse().map_err(|_| { + error!(id = %discord_user.id, "Discord user ID is not a valid i64"); + ( + StatusCode::BAD_GATEWAY, + Json(json!({ "error": "Invalid Discord user ID" })), + ) + })?; + + // 4. Upsert user + let user = crate::data::users::upsert_user( + &state.db_pool, + discord_id, + &discord_user.username, + discord_user.avatar.as_deref(), + ) + .await + .map_err(|e| { + error!(error = %e, "failed to upsert user"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "Database error" })), + ) + })?; + + info!(discord_id, username = %user.discord_username, "user authenticated via OAuth"); + + // 5. Create session + let session = crate::data::sessions::create_session( + &state.db_pool, + discord_id, + Duration::from_secs(7 * 24 * 3600), + ) + .await + .map_err(|e| { + error!(error = %e, "failed to create session"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "Failed to create session" })), + ) + })?; + + // 6. Build response with session cookie + let secure = redirect_uri.starts_with("https://"); + let cookie = session_cookie(&session.id, 604800, secure); + + let redirect_to = if user.is_admin { "/admin" } else { "/" }; + + Ok(( + [(header::SET_COOKIE, cookie)], + Redirect::temporary(redirect_to), + ) + .into_response()) +} + +/// `POST /api/auth/logout` — Destroy the current session. +pub async fn auth_logout(State(state): State, headers: HeaderMap) -> Response { + if let Some(token) = extract_session_token(&headers) { + if let Err(e) = crate::data::sessions::delete_session(&state.db_pool, &token).await { + warn!(error = %e, "failed to delete session from database"); + } + state.session_cache.evict(&token); + } + + let cookie = session_cookie("", 0, false); + + ( + StatusCode::OK, + [(header::SET_COOKIE, cookie)], + Json(json!({ "ok": true })), + ) + .into_response() +} + +/// `GET /api/auth/me` — Return the current authenticated user's info. +pub async fn auth_me( + State(state): State, + headers: HeaderMap, +) -> Result, StatusCode> { + let token = extract_session_token(&headers).ok_or(StatusCode::UNAUTHORIZED)?; + + let user = state + .session_cache + .get_user(&token) + .await + .ok_or(StatusCode::UNAUTHORIZED)?; + + Ok(Json(json!({ + "discordId": user.discord_id.to_string(), + "username": user.discord_username, + "avatarHash": user.discord_avatar_hash, + "isAdmin": user.is_admin, + }))) +} diff --git a/src/web/extractors.rs b/src/web/extractors.rs new file mode 100644 index 0000000..71de2d6 --- /dev/null +++ b/src/web/extractors.rs @@ -0,0 +1,74 @@ +//! Axum extractors for authentication and authorization. + +use axum::extract::FromRequestParts; +use axum::http::{StatusCode, header}; +use axum::response::Json; +use http::request::Parts; +use serde_json::json; + +use crate::data::models::User; +use crate::state::AppState; + +/// Extractor that resolves the session cookie to an authenticated [`User`]. +/// +/// Returns 401 if no valid session cookie is present. +pub struct AuthUser(pub User); + +impl FromRequestParts for AuthUser { + type Rejection = (StatusCode, Json); + + async fn from_request_parts( + parts: &mut Parts, + state: &AppState, + ) -> Result { + let token = parts + .headers + .get(header::COOKIE) + .and_then(|v| v.to_str().ok()) + .and_then(|cookies| { + cookies + .split(';') + .find_map(|c| c.trim().strip_prefix("session=").map(|v| v.to_owned())) + }) + .ok_or_else(|| { + ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": "unauthorized", "message": "No session cookie"})), + ) + })?; + + let user = state.session_cache.get_user(&token).await.ok_or_else(|| { + ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": "unauthorized", "message": "Invalid or expired session"})), + ) + })?; + + Ok(AuthUser(user)) + } +} + +/// Extractor that requires an authenticated admin user. +/// +/// Returns 401 if not authenticated, 403 if not admin. +pub struct AdminUser(pub User); + +impl FromRequestParts for AdminUser { + type Rejection = (StatusCode, Json); + + async fn from_request_parts( + parts: &mut Parts, + state: &AppState, + ) -> Result { + let AuthUser(user) = AuthUser::from_request_parts(parts, state).await?; + + if !user.is_admin { + return Err(( + StatusCode::FORBIDDEN, + Json(json!({"error": "forbidden", "message": "Admin access required"})), + )); + } + + Ok(AdminUser(user)) + } +} diff --git a/src/web/mod.rs b/src/web/mod.rs index 0e5a030..8e08f35 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -1,7 +1,11 @@ //! Web API module for the banner application. +pub mod admin; #[cfg(feature = "embed-assets")] pub mod assets; +pub mod auth; +pub mod extractors; pub mod routes; +pub mod session_cache; pub use routes::*; diff --git a/src/web/routes.rs b/src/web/routes.rs index 7aefc80..34c56f4 100644 --- a/src/web/routes.rs +++ b/src/web/routes.rs @@ -1,13 +1,16 @@ //! Web API endpoints for Banner bot monitoring and metrics. use axum::{ - Router, + Extension, Router, body::Body, extract::{Path, Query, Request, State}, http::StatusCode as AxumStatusCode, response::{Json, Response}, - routing::get, + routing::{get, post, put}, }; + +use crate::web::admin; +use crate::web::auth::{self, AuthConfig}; #[cfg(feature = "embed-assets")] use axum::{ http::{HeaderMap, HeaderValue, StatusCode, Uri}, @@ -68,7 +71,7 @@ fn set_caching_headers(response: &mut Response, path: &str, etag: &str) { } /// Creates the web server router -pub fn create_router(app_state: AppState) -> Router { +pub fn create_router(app_state: AppState, auth_config: AuthConfig) -> Router { let api_router = Router::new() .route("/health", get(health)) .route("/status", get(status)) @@ -78,9 +81,31 @@ pub fn create_router(app_state: AppState) -> Router { .route("/terms", get(get_terms)) .route("/subjects", get(get_subjects)) .route("/reference/{category}", get(get_reference)) + .with_state(app_state.clone()); + + let auth_router = Router::new() + .route("/auth/login", get(auth::auth_login)) + .route("/auth/callback", get(auth::auth_callback)) + .route("/auth/logout", post(auth::auth_logout)) + .route("/auth/me", get(auth::auth_me)) + .layer(Extension(auth_config)) + .with_state(app_state.clone()); + + let admin_router = Router::new() + .route("/admin/status", get(admin::admin_status)) + .route("/admin/users", get(admin::list_users)) + .route( + "/admin/users/{discord_id}/admin", + put(admin::set_user_admin), + ) + .route("/admin/scrape-jobs", get(admin::list_scrape_jobs)) + .route("/admin/audit-log", get(admin::list_audit_log)) .with_state(app_state); - let mut router = Router::new().nest("/api", api_router); + let mut router = Router::new() + .nest("/api", api_router) + .nest("/api", auth_router) + .nest("/api", admin_router); // When embed-assets feature is enabled, serve embedded static assets #[cfg(feature = "embed-assets")] diff --git a/src/web/session_cache.rs b/src/web/session_cache.rs new file mode 100644 index 0000000..4d9ca5f --- /dev/null +++ b/src/web/session_cache.rs @@ -0,0 +1,188 @@ +//! In-memory caches for session resolution and OAuth CSRF state. + +use chrono::{DateTime, Utc}; +use dashmap::DashMap; +use rand::Rng; +use sqlx::PgPool; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use crate::data::models::User; + +/// Cached session entry with TTL. +#[derive(Debug, Clone)] +struct CachedSession { + user: User, + session_expires_at: DateTime, + cached_at: Instant, +} + +/// In-memory session cache backed by PostgreSQL. +/// +/// Provides fast session resolution without a DB round-trip on every request. +/// Cache entries expire after a configurable TTL (default 5 minutes). +#[derive(Clone)] +pub struct SessionCache { + cache: Arc>, + db_pool: PgPool, + cache_ttl: Duration, +} + +impl SessionCache { + /// Create a new session cache with a 5-minute default TTL. + pub fn new(db_pool: PgPool) -> Self { + Self { + cache: Arc::new(DashMap::new()), + db_pool, + cache_ttl: Duration::from_secs(5 * 60), + } + } + + /// Resolve a session token to a [`User`], using the cache when possible. + /// + /// On cache hit (entry present, not stale, session not expired), returns the + /// cached user immediately. On miss or stale entry, queries the database for + /// the session and user, populates the cache, and fire-and-forgets a + /// `touch_session` call to update `last_active_at`. + pub async fn get_user(&self, token: &str) -> Option { + // Check cache first + if let Some(entry) = self.cache.get(token) { + let now_instant = Instant::now(); + let now_utc = Utc::now(); + + let cache_fresh = entry.cached_at + self.cache_ttl > now_instant; + let session_valid = entry.session_expires_at > now_utc; + + if cache_fresh && session_valid { + return Some(entry.user.clone()); + } + + // Stale or expired — drop the ref before removing + drop(entry); + self.cache.remove(token); + } + + // Cache miss — query DB + let session = crate::data::sessions::get_session(&self.db_pool, token) + .await + .ok() + .flatten()?; + + let user = crate::data::users::get_user(&self.db_pool, session.user_id) + .await + .ok() + .flatten()?; + + self.cache.insert( + token.to_owned(), + CachedSession { + user: user.clone(), + session_expires_at: session.expires_at, + cached_at: Instant::now(), + }, + ); + + // Fire-and-forget touch to update last_active_at + let pool = self.db_pool.clone(); + let token_owned = token.to_owned(); + tokio::spawn(async move { + if let Err(e) = crate::data::sessions::touch_session(&pool, &token_owned).await { + tracing::warn!(error = %e, "failed to touch session"); + } + }); + + Some(user) + } + + /// Remove a single session from the cache (e.g. on logout). + pub fn evict(&self, token: &str) { + self.cache.remove(token); + } + + /// Remove all cached sessions belonging to a user. + pub fn evict_user(&self, discord_id: i64) { + self.cache + .retain(|_, entry| entry.user.discord_id != discord_id); + } + + /// 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?; + + let now = Utc::now(); + self.cache.retain(|_, entry| entry.session_expires_at > now); + + Ok(deleted) + } +} + +/// Data stored alongside each OAuth CSRF state token. +struct OAuthStateEntry { + created_at: Instant, + /// The browser origin that initiated the login flow, so the callback + /// can reconstruct the exact redirect_uri Discord expects. + origin: String, +} + +/// Ephemeral store for OAuth CSRF state tokens. +/// +/// Tokens are stored with creation time and expire after a configurable TTL. +/// Each token is single-use: validation consumes it. +#[derive(Clone)] +pub struct OAuthStateStore { + states: Arc>, + ttl: Duration, +} + +impl Default for OAuthStateStore { + fn default() -> Self { + Self::new() + } +} + +impl OAuthStateStore { + /// Create a new store with a 10-minute TTL. + pub fn new() -> Self { + Self { + states: Arc::new(DashMap::new()), + ttl: Duration::from_secs(10 * 60), + } + } + + /// Generate a random 16-byte hex CSRF token, store it with the given + /// origin, and return the token. + pub fn generate(&self, origin: String) -> String { + let bytes: [u8; 16] = rand::rng().random(); + let token: String = bytes.iter().map(|b| format!("{b:02x}")).collect(); + self.states.insert( + token.clone(), + OAuthStateEntry { + created_at: Instant::now(), + origin, + }, + ); + token + } + + /// Validate and consume a CSRF token. Returns the stored origin if the + /// token was present and not expired. + pub fn validate(&self, state: &str) -> Option { + let (_, entry) = self.states.remove(state)?; + if entry.created_at.elapsed() < self.ttl { + Some(entry.origin) + } else { + None + } + } + + /// Remove all expired entries from the store. + #[allow(dead_code)] // Intended for periodic cleanup task (not yet wired) + pub fn cleanup(&self) { + let ttl = self.ttl; + self.states + .retain(|_, entry| entry.created_at.elapsed() < ttl); + } +} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index fc0e693..1962ebd 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -7,6 +7,7 @@ import type { ServiceInfo, ServiceStatus, StatusResponse, + User, } from "$lib/bindings"; const API_BASE_URL = "/api"; @@ -34,6 +35,43 @@ export type SearchResponse = SearchResponseGenerated; export type SortColumn = "course_code" | "title" | "instructor" | "time" | "seats"; export type SortDirection = "asc" | "desc"; +export interface AdminStatus { + userCount: number; + sessionCount: number; + courseCount: number; + scrapeJobCount: number; + services: { name: string; status: string }[]; +} + +export interface ScrapeJob { + id: number; + targetType: string; + targetPayload: unknown; + priority: string; + executeAt: string; + createdAt: string; + lockedAt: string | null; + retryCount: number; + maxRetries: number; +} + +export interface ScrapeJobsResponse { + jobs: ScrapeJob[]; +} + +export interface AuditLogEntry { + id: number; + courseId: number; + timestamp: string; + fieldChanged: string; + oldValue: string; + newValue: string; +} + +export interface AuditLogResponse { + entries: AuditLogEntry[]; +} + export interface SearchParams { term: string; subjects?: string[]; @@ -96,6 +134,33 @@ export class BannerApiClient { async getReference(category: string): Promise { return this.request(`/reference/${encodeURIComponent(category)}`); } + + // Admin endpoints + async getAdminStatus(): Promise { + return this.request("/admin/status"); + } + + async getAdminUsers(): Promise { + return this.request("/admin/users"); + } + + async setUserAdmin(discordId: string, isAdmin: boolean): Promise { + const response = await this.fetchFn(`${this.baseUrl}/admin/users/${discordId}/admin`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ is_admin: isAdmin }), + }); + if (!response.ok) throw new Error(`API request failed: ${response.status}`); + return (await response.json()) as User; + } + + async getAdminScrapeJobs(): Promise { + return this.request("/admin/scrape-jobs"); + } + + async getAdminAuditLog(): Promise { + return this.request("/admin/audit-log"); + } } export const client = new BannerApiClient(); diff --git a/web/src/lib/auth.svelte.ts b/web/src/lib/auth.svelte.ts new file mode 100644 index 0000000..56e15ed --- /dev/null +++ b/web/src/lib/auth.svelte.ts @@ -0,0 +1,55 @@ +import type { User } from "$lib/bindings"; + +type AuthState = + | { mode: "loading" } + | { mode: "authenticated"; user: User } + | { mode: "unauthenticated" }; + +class AuthStore { + state = $state({ mode: "loading" }); + + get user(): User | null { + return this.state.mode === "authenticated" ? this.state.user : null; + } + + get isAdmin(): boolean { + return this.user?.isAdmin ?? false; + } + + get isLoading(): boolean { + return this.state.mode === "loading"; + } + + get isAuthenticated(): boolean { + return this.state.mode === "authenticated"; + } + + async init() { + try { + const response = await fetch("/api/auth/me"); + if (response.ok) { + const user: User = await response.json(); + this.state = { mode: "authenticated", user }; + } else { + this.state = { mode: "unauthenticated" }; + } + } catch { + this.state = { mode: "unauthenticated" }; + } + } + + login() { + window.location.href = "/api/auth/login"; + } + + async logout() { + try { + await fetch("/api/auth/logout", { method: "POST" }); + } finally { + this.state = { mode: "unauthenticated" }; + window.location.href = "/"; + } + } +} + +export const authStore = new AuthStore(); diff --git a/web/src/lib/bindings/index.ts b/web/src/lib/bindings/index.ts index 96c06ba..81d4e31 100644 --- a/web/src/lib/bindings/index.ts +++ b/web/src/lib/bindings/index.ts @@ -6,3 +6,4 @@ export type { SearchResponse } from "./SearchResponse"; export type { ServiceInfo } from "./ServiceInfo"; export type { ServiceStatus } from "./ServiceStatus"; export type { StatusResponse } from "./StatusResponse"; +export type { User } from "./User"; diff --git a/web/src/routes/admin/+layout.svelte b/web/src/routes/admin/+layout.svelte new file mode 100644 index 0000000..81c8a96 --- /dev/null +++ b/web/src/routes/admin/+layout.svelte @@ -0,0 +1,74 @@ + + +{#if authStore.isLoading} +
+

Loading...

+
+{:else if !authStore.isAdmin} +
+
+

Access Denied

+

You do not have admin access.

+
+
+{:else} +
+ +
+ {@render children()} +
+
+{/if} diff --git a/web/src/routes/admin/+page.svelte b/web/src/routes/admin/+page.svelte new file mode 100644 index 0000000..daff06f --- /dev/null +++ b/web/src/routes/admin/+page.svelte @@ -0,0 +1,54 @@ + + +

Dashboard

+ +{#if error} +

{error}

+{:else if !status} +

Loading...

+{:else} +
+
+

Users

+

{status.userCount}

+
+
+

Active Sessions

+

{status.sessionCount}

+
+
+

Courses

+

{status.courseCount}

+
+
+

Scrape Jobs

+

{status.scrapeJobCount}

+
+
+ +

Services

+
+ {#each status.services as service} +
+ {service.name} + + {service.status} + +
+ {/each} +
+{/if} diff --git a/web/src/routes/admin/audit-log/+page.svelte b/web/src/routes/admin/audit-log/+page.svelte new file mode 100644 index 0000000..b94dc9b --- /dev/null +++ b/web/src/routes/admin/audit-log/+page.svelte @@ -0,0 +1,50 @@ + + +

Audit Log

+ +{#if error} +

{error}

+{:else if !data} +

Loading...

+{:else if data.entries.length === 0} +

No audit log entries found.

+{:else} +
+ + + + + + + + + + + + {#each data.entries as entry} + + + + + + + + {/each} + +
TimeCourse IDFieldOld ValueNew Value
{new Date(entry.timestamp).toLocaleString()}{entry.courseId}{entry.fieldChanged}{entry.oldValue}{entry.newValue}
+
+{/if} diff --git a/web/src/routes/admin/scrape-jobs/+page.svelte b/web/src/routes/admin/scrape-jobs/+page.svelte new file mode 100644 index 0000000..aeaee64 --- /dev/null +++ b/web/src/routes/admin/scrape-jobs/+page.svelte @@ -0,0 +1,52 @@ + + +

Scrape Jobs

+ +{#if error} +

{error}

+{:else if !data} +

Loading...

+{:else if data.jobs.length === 0} +

No scrape jobs found.

+{:else} +
+ + + + + + + + + + + + + {#each data.jobs as job} + + + + + + + + + {/each} + +
IDTypePriorityExecute AtRetriesStatus
{job.id}{job.targetType}{job.priority}{new Date(job.executeAt).toLocaleString()}{job.retryCount}/{job.maxRetries}{job.lockedAt ? "Locked" : "Pending"}
+
+{/if} diff --git a/web/src/routes/admin/users/+page.svelte b/web/src/routes/admin/users/+page.svelte new file mode 100644 index 0000000..943f4e8 --- /dev/null +++ b/web/src/routes/admin/users/+page.svelte @@ -0,0 +1,92 @@ + + +

Users

+ +{#if error} +

{error}

+{/if} + +{#if users.length === 0 && !error} +

Loading...

+{:else} +
+ + + + + + + + + + + {#each users as user} + + + + + + + {/each} + +
UsernameDiscord IDAdminActions
+ {#if user.avatarHash} + + {/if} + {user.username} + {user.discordId} + {#if user.isAdmin} + Admin + {:else} + User + {/if} + + +
+
+{/if} diff --git a/web/src/routes/login/+page.svelte b/web/src/routes/login/+page.svelte new file mode 100644 index 0000000..1976d66 --- /dev/null +++ b/web/src/routes/login/+page.svelte @@ -0,0 +1,18 @@ + + +
+
+

Sign In

+

Sign in with your Discord account to continue.

+ +
+