//! Admin routes for creator waitlist management, user moderation, and platform operations. mod moderation; mod signups; mod uploads; mod users; mod waitlist; use axum::{ extract::{Path, State}, response::{IntoResponse, Response}, routing::get, Form, }; use serde::Deserialize; use crate::{ auth::AdminUser, csrf::{post_csrf, CsrfRouter}, db::{self, UserId}, error::{AppError, Result}, AppState, }; /// Register admin routes for waitlist management, user moderation, and lottery. pub fn admin_routes() -> 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)) // 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)) // Metrics .route_get("/admin/metrics", get(admin_metrics)) } // ── 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(state): State, AdminUser(_admin): AdminUser, ) -> Result { let Some(ref mt) = state.mt_client else { return Err(AppError::validation("MT integration not configured".to_string())); }; let projects = db::projects::get_projects_without_mt_community(&state.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(&state.db, &owner_ids).await?; let owner_map: std::collections::HashMap = owners.iter().map(|u| (u.id, u)).collect(); for project in &projects { let Some(user) = owner_map.get(&project.user_id) else { failed += 1; continue; }; match mt .create_community(&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(), }) .await { Ok(resp) => { if let Err(e) = db::projects::set_mt_community_id(&state.db, project.id, resp.community_id) .await { tracing::error!(error = ?e, project_id = %project.id, "failed to store MT community ID"); failed += 1; } else { provisioned += 1; } } Err(e) => { tracing::error!(error = ?e, slug = %project.slug, "MT community provisioning failed"); failed += 1; } } } tracing::info!(total, provisioned, failed, "MT community backfill complete"); Ok(( axum::http::StatusCode::OK, format!( "MT provisioning: {} of {} projects provisioned ({} failed)", provisioned, total, 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(state): 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(&state.db).await?; let mut sent = 0u32; let mut failed = 0u32; for (email, display_name) in &all_users { if let Err(e) = state.email .send_shutdown_notice(email, display_name.as_deref(), shutdown_date) .await { tracing::error!(error = ?e, email = %email, "failed to send shutdown notice"); failed += 1; } else { sent += 1; } } tracing::info!(sent = sent, failed = failed, shutdown_date = %shutdown_date, "shutdown notices sent"); Ok(( axum::http::StatusCode::OK, [("HX-Redirect", "/admin/users")], format!("Sent {} shutdown notices ({} failed)", sent, failed), ).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. See /// `project_founder_pricing.md` for the full plan. #[tracing::instrument(skip_all, name = "admin::admin_close_founder_window")] async fn admin_close_founder_window( State(state): State, AdminUser(_admin): AdminUser, ) -> Result { let locked = db::users::lock_in_founders_with_active_subscriptions(&state.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(state): State, session: tower_sessions::Session, AdminUser(user): AdminUser, ) -> Result { let csrf_token = crate::helpers::get_csrf_token(&session).await; let uptime = { let d = state.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 = state.db.size(); let pool_idle = state.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) = state.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(state): 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(&state.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")) }