//! Admin routes for creator waitlist management, user moderation, and platform operations. mod comp_codes; mod moderation; pub mod moderation_service; mod signups; mod uploads; mod users; mod waitlist; use axum::extract::FromRequestParts; use axum::http::request::Parts; use axum::{ Form, extract::{Path, Request, State}, middleware::{Next, from_fn_with_state}, response::{IntoResponse, Response}, routing::get, }; use serde::Deserialize; use sqlx::PgPool; use crate::{ AppState, Integrations, Ops, auth::AdminUser, csrf::{CsrfRouter, post_csrf}, db::{self, UserId}, email::EmailClient, error::{AppError, Result}, }; /// `/admin*` routes served WITHOUT the admin gate, by explicit design. /// /// Kept as a sealed allowlist so the blanket [`require_admin_layer`] stays /// honest: every route under `admin_routes()` requires admin EXCEPT these, and /// `admin_gate_covers_every_route` asserts the set is exactly this. Mirrors the /// CSRF skip-list discipline, an exemption is a visible, tested decision, not an /// accidental gap. pub(crate) const ADMIN_PUBLIC_PATHS: &[&str] = &[ // Scan-queue health JSON, intentionally unauthenticated (counts only, no // PII) so PoM can scrape it against stable threshold rules. "/admin/uploads/health.json", ]; /// Blanket admin gate over the whole `/admin*` subtree. Runs the same /// [`AdminUser`] check every admin handler ran individually, so a handler that /// forgets the extractor can no longer ship unauthenticated (Run 20 Security, /// auth-by-convention). Non-admins get 404, keeping admin routes hidden; the /// only bypass is the explicit [`ADMIN_PUBLIC_PATHS`] allowlist. Handlers keep /// their own `AdminUser` where they need the `AdminId` write-witness; the /// duplicate check is a cached session touch. async fn require_admin_layer( State(state): State, req: Request, next: Next, ) -> Result { if ADMIN_PUBLIC_PATHS.contains(&req.uri().path()) { return Ok(next.run(req).await); } let (mut parts, body): (Parts, _) = req.into_parts(); // The AdminUser rejection (404 for non-admins) becomes the response. let _admin = AdminUser::from_request_parts(&mut parts, &state).await?; let req = Request::from_parts(parts, body); Ok(next.run(req).await) } /// Register admin routes for waitlist management, user moderation, and lottery. pub fn admin_routes(state: AppState) -> CsrfRouter { CsrfRouter::new() // Waitlist .route_get("/admin/waitlist", get(waitlist::admin_waitlist)) .route_get( "/admin/waitlist/entries", get(waitlist::admin_waitlist_entries), ) .route( "/api/admin/waitlist/{id}/approve", post_csrf(waitlist::admin_approve), ) .route( "/api/admin/waitlist/{id}/spam", post_csrf(waitlist::admin_spam), ) .route("/api/admin/lottery", post_csrf(waitlist::admin_lottery)) // User management .route_get("/admin/users", get(users::admin_users)) .route_get("/admin/users/entries", get(users::admin_user_entries)) .route( "/api/admin/users/{id}/warn", post_csrf(users::admin_warn_user), ) .route( "/api/admin/users/{id}/suspend", post_csrf(users::admin_suspend_user), ) .route( "/api/admin/users/{id}/unsuspend", post_csrf(users::admin_unsuspend_user), ) .route( "/api/admin/users/{id}/terminate", post_csrf(users::admin_terminate_user), ) // Upload review queue .route_get("/admin/uploads", get(uploads::admin_uploads)) .route( "/api/admin/uploads/items/{id}/promote", post_csrf(uploads::admin_promote_item), ) .route( "/api/admin/uploads/items/{id}/quarantine", post_csrf(uploads::admin_quarantine_item), ) .route( "/api/admin/uploads/items/{id}/rescan", post_csrf(uploads::admin_rescan_item), ) .route( "/api/admin/uploads/versions/{id}/promote", post_csrf(uploads::admin_promote_version), ) .route( "/api/admin/uploads/versions/{id}/quarantine", post_csrf(uploads::admin_quarantine_version), ) .route( "/api/admin/uploads/versions/{id}/rescan", post_csrf(uploads::admin_rescan_version), ) .route( "/api/admin/uploads/bulk/rescan", post_csrf(uploads::admin_bulk_rescan_held), ) .route( "/api/admin/uploads/bulk/promote", post_csrf(uploads::admin_bulk_promote_held), ) .route_get( "/admin/uploads/queue-summary", get(uploads::admin_queue_summary_partial), ) .route_get("/admin/uploads/audit", get(uploads::admin_scan_audit)) .route_get("/admin/uploads/health.json", get(uploads::scan_health_json)) .route( "/api/admin/users/{id}/trust", post_csrf(users::admin_trust_user), ) .route( "/api/admin/users/{id}/untrust", post_csrf(users::admin_untrust_user), ) .route( "/api/admin/users/{id}/lock-pages", post_csrf(users::admin_lock_custom_pages), ) .route( "/api/admin/users/{id}/unlock-pages", post_csrf(users::admin_unlock_custom_pages), ) // Appeals .route_get("/admin/appeals", get(moderation::admin_appeals)) .route( "/api/admin/appeals/{user_id}/decide", post_csrf(moderation::admin_decide_appeal), ) // Email signups .route_get("/admin/signups", get(signups::admin_signups)) // Reports .route_get("/admin/reports", get(moderation::admin_reports)) .route_get( "/admin/reports/entries", get(moderation::admin_report_entries), ) .route( "/api/admin/reports/{id}/resolve", post_csrf(moderation::admin_resolve_report), ) // Per-item content removal .route( "/api/admin/items/{id}/remove", post_csrf(moderation::admin_remove_item), ) .route( "/api/admin/items/{id}/restore", post_csrf(moderation::admin_restore_item), ) // MT provisioning .route("/api/admin/mt/provision", post_csrf(admin_mt_provision)) // Storage overrides .route( "/api/admin/users/{id}/file-override", post_csrf(admin_file_override), ) // Shutdown .route( "/api/admin/shutdown-notice", post_csrf(admin_shutdown_notice), ) // Founder pricing .route( "/api/admin/founder-window/close", post_csrf(admin_close_founder_window), ) .route_get("/admin/comp-codes", get(comp_codes::admin_comp_codes)) .route( "/api/admin/comp-codes/create", post_csrf(comp_codes::admin_create_comp_code), ) // Metrics .route_get("/admin/metrics", get(admin_metrics)) // Structural admin gate over every route above (Run 20). A forgotten // per-handler AdminUser can no longer expose an admin route. .route_layer(from_fn_with_state(state, require_admin_layer)) } // --- MT Provisioning --- /// Backfill MT communities for all projects that don't have one yet. #[tracing::instrument(skip_all, name = "admin::admin_mt_provision")] async fn admin_mt_provision( State(db): State, State(integrations): State, AdminUser(_admin): AdminUser, ) -> Result { let Some(ref mt) = integrations.mt_client else { return Err(AppError::validation( "MT integration not configured".to_string(), )); }; let projects = db::projects::get_projects_without_mt_community(&db).await?; let total = projects.len(); let mut provisioned = 0u32; let mut failed = 0u32; // Batch-load all project owners in one query instead of N individual lookups let owner_ids: Vec = projects.iter().map(|p| p.user_id).collect(); let owners = db::users::get_users_by_ids(&db, &owner_ids).await?; let owner_map: std::collections::HashMap = owners.iter().map(|u| (u.id, u)).collect(); // Fan the per-project MT HTTP call (5s timeout each) out over a bounded // JoinSet instead of awaiting them strictly serially, provisioning latency // was MT-latency x project-count over the whole table (Perf-S3, Run 9). Cap // concurrency so a large backfill can't open hundreds of MT connections at // once; mirror admin_shutdown_notice's bound. let parallelism = crate::constants::BROADCAST_PARALLELISM; let mut set: tokio::task::JoinSet = tokio::task::JoinSet::new(); for project in &projects { let Some(user) = owner_map.get(&project.user_id) else { failed += 1; continue; }; if set.len() >= parallelism && let Some(res) = set.join_next().await { if res.unwrap_or(false) { provisioned += 1; } else { failed += 1; } } let mt = mt.clone(); let pool = db.clone(); let req = crate::mt_client::CreateCommunityRequest { name: project.title.clone(), slug: project.slug.to_string(), description: project.description.clone(), owner_mnw_id: *user.id, owner_username: user.username.to_string(), owner_display_name: user.display_name.clone(), }; let project_id = project.id; let slug = project.slug.to_string(); set.spawn(async move { match mt.create_community(&req).await { Ok(resp) => match db::projects::set_mt_community_id(&pool, project_id, resp.community_id).await { Ok(()) => true, Err(e) => { tracing::error!(error = ?e, project_id = %project_id, "failed to store MT community ID"); false } }, Err(e) => { tracing::error!(error = ?e, slug = %slug, "MT community provisioning failed"); false } } }); } while let Some(res) = set.join_next().await { if res.unwrap_or(false) { provisioned += 1; } else { failed += 1; } } tracing::info!(total, provisioned, failed, "MT community backfill complete"); Ok(( axum::http::StatusCode::OK, format!("MT provisioning: {provisioned} of {total} projects provisioned ({failed} failed)"), ) .into_response()) } // --- Shutdown --- #[derive(Debug, Deserialize)] pub(super) struct ShutdownNoticeForm { pub shutdown_date: String, } /// Send a shutdown notice email to all users. #[tracing::instrument(skip_all, name = "admin::admin_shutdown_notice")] async fn admin_shutdown_notice( State(db): State, State(email): State, AdminUser(_admin): AdminUser, Form(form): Form, ) -> Result { let shutdown_date = form.shutdown_date.trim(); if shutdown_date.is_empty() { return Err(AppError::validation( "Shutdown date is required".to_string(), )); } let all_users = db::users::get_all_user_emails(&db).await?; let count = all_users.len(); // Fan out on a background task with a bounded JoinSet (mirrors the broadcast // path). The previous version sent one email at a time, inline, scaling the // request's latency with the entire user base and tying up a handler for the // whole send. Return immediately; the task logs its own totals. let email_client = email.clone(); let shutdown_date = shutdown_date.to_string(); tokio::spawn(async move { let mut set = tokio::task::JoinSet::new(); let chunk_delay = std::time::Duration::from_millis(crate::constants::BROADCAST_CHUNK_DELAY_MS); let sent = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0)); let failed = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0)); for (email, display_name) in all_users { if set.len() >= crate::constants::BROADCAST_PARALLELISM { let _ = set.join_next().await; } let email_client = email_client.clone(); let shutdown_date = shutdown_date.clone(); let sent = std::sync::Arc::clone(&sent); let failed = std::sync::Arc::clone(&failed); set.spawn(async move { use std::sync::atomic::Ordering; if let Err(e) = email_client .send_shutdown_notice(&email, display_name.as_deref(), &shutdown_date) .await { tracing::error!(error = ?e, email = %email, "failed to send shutdown notice"); failed.fetch_add(1, Ordering::Relaxed); } else { sent.fetch_add(1, Ordering::Relaxed); } }); tokio::time::sleep(chunk_delay).await; } while set.join_next().await.is_some() {} use std::sync::atomic::Ordering; tracing::info!( sent = sent.load(Ordering::Relaxed), failed = failed.load(Ordering::Relaxed), shutdown_date = %shutdown_date, "shutdown notices sent" ); }); Ok(( axum::http::StatusCode::OK, [("HX-Redirect", "/admin/users")], format!("Sending shutdown notices to {count} users."), ) .into_response()) } // --- Founder pricing --- /// Close the founder pricing window. Idempotent: stamps `founder_locked_at` /// on every user currently flagged `is_founder` who has an active /// creator-tier subscription, and skips anyone already locked. Operators /// should also flip `CREATOR_FOUNDER_WINDOW_OPEN=false` in the env /// separately so new signups stop getting founder prices; this route only /// performs the snapshot, it doesn't change config. The snapshot is one-way: /// nothing un-stamps `founder_locked_at`. #[tracing::instrument(skip_all, name = "admin::admin_close_founder_window")] async fn admin_close_founder_window( State(db): State, AdminUser(_admin): AdminUser, ) -> Result { let locked = db::users::lock_in_founders_with_active_subscriptions(&db).await?; tracing::info!( locked = locked, "founder window close: users stamped with founder_locked_at" ); Ok(( axum::http::StatusCode::OK, format!( "Founder window snapshot complete: {} user{} locked in. \ Remember to flip CREATOR_FOUNDER_WINDOW_OPEN=false in the env.", locked, if locked == 1 { "" } else { "s" } ), ) .into_response()) } // --- Metrics --- /// Render the admin metrics dashboard with live Prometheus data. #[tracing::instrument(skip_all, name = "admin::admin_metrics")] async fn admin_metrics( State(db): State, State(ops): State, session: tower_sessions::Session, AdminUser(user): AdminUser, ) -> Result { let csrf_token = crate::helpers::get_csrf_token(&session).await; let uptime = { let d = ops.start_instant.elapsed(); let secs = d.as_secs(); let days = secs / 86400; let hours = (secs % 86400) / 3600; let mins = (secs % 3600) / 60; if days > 0 { format!("{days}d {hours}h {mins}m") } else if hours > 0 { format!("{hours}h {mins}m") } else { format!("{mins}m") } }; let pool_size = db.size(); let pool_idle = db.num_idle() as u32; let pool_active = pool_size.saturating_sub(pool_idle); // Parse metrics from the Prometheus handle (if available) let (total_requests, error_rate, total_errors, top_routes, error_breakdown) = if let Some(ref handle) = ops.metrics_handle { let snap = crate::metrics::snapshot(handle); let rate = if snap.total_requests > 0 { snap.total_5xx as f64 / snap.total_requests as f64 * 100.0 } else { 0.0 }; let routes = snap .top_routes .into_iter() .map( |(method, path, status, count)| crate::templates::RouteMetric { method, path, status, count, }, ) .collect(); let errors = snap .error_breakdown .into_iter() .map(|(kind, count)| crate::templates::ErrorMetric { kind, count }) .collect(); (snap.total_requests, rate, snap.total_errors, routes, errors) } else { (0, 0.0, 0, vec![], vec![]) }; Ok(crate::templates::AdminMetricsTemplate { csrf_token, session_user: Some(user), admin_active_page: "metrics", uptime, total_requests, error_rate, total_errors, pool_max: pool_size, pool_active, pool_idle, top_routes, error_breakdown, }) } // --- File Override --- #[derive(Debug, Deserialize)] pub(super) struct FileOverrideForm { pub max_file_bytes: Option, } /// Set or clear the admin per-file size override for a user. /// /// POST /api/admin/users/{id}/file-override #[tracing::instrument(skip_all, name = "admin::admin_file_override")] async fn admin_file_override( State(db): State, AdminUser(_admin): AdminUser, Path(user_id): Path, Form(form): Form, ) -> Result { // Validate: if provided, must be positive if let Some(bytes) = form.max_file_bytes && bytes <= 0 { return Err(AppError::BadRequest( "Override must be a positive number of bytes".to_string(), )); } db::creator_tiers::set_max_file_override(&db, user_id, form.max_file_bytes).await?; let msg = match form.max_file_bytes { Some(bytes) => format!( "File override set to {}", crate::helpers::format_bytes(bytes) ), None => "File override cleared".to_string(), }; Ok(crate::helpers::htmx_toast_response(&msg, "success")) } #[cfg(test)] mod tests { use super::ADMIN_PUBLIC_PATHS; /// Seal the admin-gate exemption allowlist. Adding a route here is a /// deliberate decision to serve it unauthenticated under `/admin*`; this /// test forces that decision to be explicit rather than a silent gap. #[test] fn admin_public_paths_is_exactly_the_health_endpoint() { assert_eq!(ADMIN_PUBLIC_PATHS, &["/admin/uploads/health.json"]); } }