| 1 |
|
| 2 |
|
| 3 |
mod moderation; |
| 4 |
mod signups; |
| 5 |
mod uploads; |
| 6 |
mod users; |
| 7 |
mod waitlist; |
| 8 |
|
| 9 |
use axum::{ |
| 10 |
extract::{Path, State}, |
| 11 |
response::{IntoResponse, Response}, |
| 12 |
routing::get, |
| 13 |
Form, |
| 14 |
}; |
| 15 |
use serde::Deserialize; |
| 16 |
|
| 17 |
use crate::{ |
| 18 |
auth::AdminUser, |
| 19 |
csrf::{post_csrf, CsrfRouter}, |
| 20 |
db::{self, UserId}, |
| 21 |
error::{AppError, Result}, |
| 22 |
AppState, |
| 23 |
}; |
| 24 |
|
| 25 |
|
| 26 |
pub fn admin_routes() -> CsrfRouter<AppState> { |
| 27 |
CsrfRouter::new() |
| 28 |
|
| 29 |
.route_get("/admin/waitlist", get(waitlist::admin_waitlist)) |
| 30 |
.route_get("/admin/waitlist/entries", get(waitlist::admin_waitlist_entries)) |
| 31 |
.route("/api/admin/waitlist/{id}/approve", post_csrf(waitlist::admin_approve)) |
| 32 |
.route("/api/admin/waitlist/{id}/spam", post_csrf(waitlist::admin_spam)) |
| 33 |
.route("/api/admin/lottery", post_csrf(waitlist::admin_lottery)) |
| 34 |
|
| 35 |
.route_get("/admin/users", get(users::admin_users)) |
| 36 |
.route_get("/admin/users/entries", get(users::admin_user_entries)) |
| 37 |
.route("/api/admin/users/{id}/warn", post_csrf(users::admin_warn_user)) |
| 38 |
.route("/api/admin/users/{id}/suspend", post_csrf(users::admin_suspend_user)) |
| 39 |
.route("/api/admin/users/{id}/unsuspend", post_csrf(users::admin_unsuspend_user)) |
| 40 |
.route("/api/admin/users/{id}/terminate", post_csrf(users::admin_terminate_user)) |
| 41 |
|
| 42 |
.route_get("/admin/uploads", get(uploads::admin_uploads)) |
| 43 |
.route("/api/admin/uploads/items/{id}/promote", post_csrf(uploads::admin_promote_item)) |
| 44 |
.route("/api/admin/uploads/items/{id}/quarantine", post_csrf(uploads::admin_quarantine_item)) |
| 45 |
.route("/api/admin/uploads/items/{id}/rescan", post_csrf(uploads::admin_rescan_item)) |
| 46 |
.route("/api/admin/uploads/versions/{id}/promote", post_csrf(uploads::admin_promote_version)) |
| 47 |
.route("/api/admin/uploads/versions/{id}/quarantine", post_csrf(uploads::admin_quarantine_version)) |
| 48 |
.route("/api/admin/uploads/versions/{id}/rescan", post_csrf(uploads::admin_rescan_version)) |
| 49 |
.route("/api/admin/uploads/bulk/rescan", post_csrf(uploads::admin_bulk_rescan_held)) |
| 50 |
.route("/api/admin/uploads/bulk/promote", post_csrf(uploads::admin_bulk_promote_held)) |
| 51 |
.route_get("/admin/uploads/queue-summary", get(uploads::admin_queue_summary_partial)) |
| 52 |
.route_get("/admin/uploads/audit", get(uploads::admin_scan_audit)) |
| 53 |
.route_get("/admin/uploads/health.json", get(uploads::scan_health_json)) |
| 54 |
.route("/api/admin/users/{id}/trust", post_csrf(users::admin_trust_user)) |
| 55 |
.route("/api/admin/users/{id}/untrust", post_csrf(users::admin_untrust_user)) |
| 56 |
|
| 57 |
.route_get("/admin/appeals", get(moderation::admin_appeals)) |
| 58 |
.route("/api/admin/appeals/{user_id}/decide", post_csrf(moderation::admin_decide_appeal)) |
| 59 |
|
| 60 |
.route_get("/admin/signups", get(signups::admin_signups)) |
| 61 |
|
| 62 |
.route_get("/admin/reports", get(moderation::admin_reports)) |
| 63 |
.route_get("/admin/reports/entries", get(moderation::admin_report_entries)) |
| 64 |
.route("/api/admin/reports/{id}/resolve", post_csrf(moderation::admin_resolve_report)) |
| 65 |
|
| 66 |
.route("/api/admin/items/{id}/remove", post_csrf(moderation::admin_remove_item)) |
| 67 |
.route("/api/admin/items/{id}/restore", post_csrf(moderation::admin_restore_item)) |
| 68 |
|
| 69 |
.route("/api/admin/mt/provision", post_csrf(admin_mt_provision)) |
| 70 |
|
| 71 |
.route("/api/admin/users/{id}/file-override", post_csrf(admin_file_override)) |
| 72 |
|
| 73 |
.route("/api/admin/shutdown-notice", post_csrf(admin_shutdown_notice)) |
| 74 |
|
| 75 |
.route("/api/admin/founder-window/close", post_csrf(admin_close_founder_window)) |
| 76 |
|
| 77 |
.route_get("/admin/metrics", get(admin_metrics)) |
| 78 |
} |
| 79 |
|
| 80 |
|
| 81 |
|
| 82 |
|
| 83 |
#[tracing::instrument(skip_all, name = "admin::admin_mt_provision")] |
| 84 |
async fn admin_mt_provision( |
| 85 |
State(state): State<AppState>, |
| 86 |
AdminUser(_admin): AdminUser, |
| 87 |
) -> Result<Response> { |
| 88 |
let Some(ref mt) = state.mt_client else { |
| 89 |
return Err(AppError::validation("MT integration not configured".to_string())); |
| 90 |
}; |
| 91 |
|
| 92 |
let projects = db::projects::get_projects_without_mt_community(&state.db).await?; |
| 93 |
let total = projects.len(); |
| 94 |
let mut provisioned = 0u32; |
| 95 |
let mut failed = 0u32; |
| 96 |
|
| 97 |
|
| 98 |
let owner_ids: Vec<db::UserId> = projects.iter().map(|p| p.user_id).collect(); |
| 99 |
let owners = db::users::get_users_by_ids(&state.db, &owner_ids).await?; |
| 100 |
let owner_map: std::collections::HashMap<db::UserId, &db::DbUser> = |
| 101 |
owners.iter().map(|u| (u.id, u)).collect(); |
| 102 |
|
| 103 |
for project in &projects { |
| 104 |
let Some(user) = owner_map.get(&project.user_id) else { |
| 105 |
failed += 1; |
| 106 |
continue; |
| 107 |
}; |
| 108 |
|
| 109 |
match mt |
| 110 |
.create_community(&crate::mt_client::CreateCommunityRequest { |
| 111 |
name: project.title.clone(), |
| 112 |
slug: project.slug.to_string(), |
| 113 |
description: project.description.clone(), |
| 114 |
owner_mnw_id: *user.id, |
| 115 |
owner_username: user.username.to_string(), |
| 116 |
owner_display_name: user.display_name.clone(), |
| 117 |
}) |
| 118 |
.await |
| 119 |
{ |
| 120 |
Ok(resp) => { |
| 121 |
if let Err(e) = |
| 122 |
db::projects::set_mt_community_id(&state.db, project.id, resp.community_id) |
| 123 |
.await |
| 124 |
{ |
| 125 |
tracing::error!(error = ?e, project_id = %project.id, "failed to store MT community ID"); |
| 126 |
failed += 1; |
| 127 |
} else { |
| 128 |
provisioned += 1; |
| 129 |
} |
| 130 |
} |
| 131 |
Err(e) => { |
| 132 |
tracing::error!(error = ?e, slug = %project.slug, "MT community provisioning failed"); |
| 133 |
failed += 1; |
| 134 |
} |
| 135 |
} |
| 136 |
} |
| 137 |
|
| 138 |
tracing::info!(total, provisioned, failed, "MT community backfill complete"); |
| 139 |
|
| 140 |
Ok(( |
| 141 |
axum::http::StatusCode::OK, |
| 142 |
format!( |
| 143 |
"MT provisioning: {} of {} projects provisioned ({} failed)", |
| 144 |
provisioned, total, failed |
| 145 |
), |
| 146 |
) |
| 147 |
.into_response()) |
| 148 |
} |
| 149 |
|
| 150 |
|
| 151 |
|
| 152 |
#[derive(Debug, Deserialize)] |
| 153 |
pub(super) struct ShutdownNoticeForm { |
| 154 |
pub shutdown_date: String, |
| 155 |
} |
| 156 |
|
| 157 |
|
| 158 |
#[tracing::instrument(skip_all, name = "admin::admin_shutdown_notice")] |
| 159 |
async fn admin_shutdown_notice( |
| 160 |
State(state): State<AppState>, |
| 161 |
AdminUser(_admin): AdminUser, |
| 162 |
Form(form): Form<ShutdownNoticeForm>, |
| 163 |
) -> Result<Response> { |
| 164 |
let shutdown_date = form.shutdown_date.trim(); |
| 165 |
if shutdown_date.is_empty() { |
| 166 |
return Err(AppError::validation("Shutdown date is required".to_string())); |
| 167 |
} |
| 168 |
|
| 169 |
let all_users = db::users::get_all_user_emails(&state.db).await?; |
| 170 |
let mut sent = 0u32; |
| 171 |
let mut failed = 0u32; |
| 172 |
|
| 173 |
for (email, display_name) in &all_users { |
| 174 |
if let Err(e) = state.email |
| 175 |
.send_shutdown_notice(email, display_name.as_deref(), shutdown_date) |
| 176 |
.await |
| 177 |
{ |
| 178 |
tracing::error!(error = ?e, email = %email, "failed to send shutdown notice"); |
| 179 |
failed += 1; |
| 180 |
} else { |
| 181 |
sent += 1; |
| 182 |
} |
| 183 |
} |
| 184 |
|
| 185 |
tracing::info!(sent = sent, failed = failed, shutdown_date = %shutdown_date, "shutdown notices sent"); |
| 186 |
|
| 187 |
Ok(( |
| 188 |
axum::http::StatusCode::OK, |
| 189 |
[("HX-Redirect", "/admin/users")], |
| 190 |
format!("Sent {} shutdown notices ({} failed)", sent, failed), |
| 191 |
).into_response()) |
| 192 |
} |
| 193 |
|
| 194 |
|
| 195 |
|
| 196 |
|
| 197 |
|
| 198 |
|
| 199 |
|
| 200 |
|
| 201 |
|
| 202 |
|
| 203 |
#[tracing::instrument(skip_all, name = "admin::admin_close_founder_window")] |
| 204 |
async fn admin_close_founder_window( |
| 205 |
State(state): State<AppState>, |
| 206 |
AdminUser(_admin): AdminUser, |
| 207 |
) -> Result<Response> { |
| 208 |
let locked = db::users::lock_in_founders_with_active_subscriptions(&state.db).await?; |
| 209 |
tracing::info!(locked = locked, "founder window close: users stamped with founder_locked_at"); |
| 210 |
Ok(( |
| 211 |
axum::http::StatusCode::OK, |
| 212 |
format!( |
| 213 |
"Founder window snapshot complete: {} user{} locked in. \ |
| 214 |
Remember to flip CREATOR_FOUNDER_WINDOW_OPEN=false in the env.", |
| 215 |
locked, |
| 216 |
if locked == 1 { "" } else { "s" } |
| 217 |
), |
| 218 |
).into_response()) |
| 219 |
} |
| 220 |
|
| 221 |
|
| 222 |
|
| 223 |
|
| 224 |
#[tracing::instrument(skip_all, name = "admin::admin_metrics")] |
| 225 |
async fn admin_metrics( |
| 226 |
State(state): State<AppState>, |
| 227 |
session: tower_sessions::Session, |
| 228 |
AdminUser(user): AdminUser, |
| 229 |
) -> Result<impl IntoResponse> { |
| 230 |
let csrf_token = crate::helpers::get_csrf_token(&session).await; |
| 231 |
let uptime = { |
| 232 |
let d = state.start_instant.elapsed(); |
| 233 |
let secs = d.as_secs(); |
| 234 |
let days = secs / 86400; |
| 235 |
let hours = (secs % 86400) / 3600; |
| 236 |
let mins = (secs % 3600) / 60; |
| 237 |
if days > 0 { format!("{days}d {hours}h {mins}m") } |
| 238 |
else if hours > 0 { format!("{hours}h {mins}m") } |
| 239 |
else { format!("{mins}m") } |
| 240 |
}; |
| 241 |
|
| 242 |
let pool_size = state.db.size(); |
| 243 |
let pool_idle = state.db.num_idle() as u32; |
| 244 |
let pool_active = pool_size.saturating_sub(pool_idle); |
| 245 |
|
| 246 |
|
| 247 |
let (total_requests, error_rate, total_errors, top_routes, error_breakdown) = |
| 248 |
if let Some(ref handle) = state.metrics_handle { |
| 249 |
let snap = crate::metrics::snapshot(handle); |
| 250 |
let rate = if snap.total_requests > 0 { |
| 251 |
snap.total_5xx as f64 / snap.total_requests as f64 * 100.0 |
| 252 |
} else { |
| 253 |
0.0 |
| 254 |
}; |
| 255 |
let routes = snap.top_routes.into_iter() |
| 256 |
.map(|(method, path, status, count)| crate::templates::RouteMetric { method, path, status, count }) |
| 257 |
.collect(); |
| 258 |
let errors = snap.error_breakdown.into_iter() |
| 259 |
.map(|(kind, count)| crate::templates::ErrorMetric { kind, count }) |
| 260 |
.collect(); |
| 261 |
(snap.total_requests, rate, snap.total_errors, routes, errors) |
| 262 |
} else { |
| 263 |
(0, 0.0, 0, vec![], vec![]) |
| 264 |
}; |
| 265 |
|
| 266 |
Ok(crate::templates::AdminMetricsTemplate { |
| 267 |
csrf_token, |
| 268 |
session_user: Some(user), |
| 269 |
admin_active_page: "metrics", |
| 270 |
uptime, |
| 271 |
total_requests, |
| 272 |
error_rate, |
| 273 |
total_errors, |
| 274 |
pool_max: pool_size, |
| 275 |
pool_active, |
| 276 |
pool_idle, |
| 277 |
top_routes, |
| 278 |
error_breakdown, |
| 279 |
}) |
| 280 |
} |
| 281 |
|
| 282 |
|
| 283 |
|
| 284 |
#[derive(Debug, Deserialize)] |
| 285 |
pub(super) struct FileOverrideForm { |
| 286 |
pub max_file_bytes: Option<i64>, |
| 287 |
} |
| 288 |
|
| 289 |
|
| 290 |
|
| 291 |
|
| 292 |
#[tracing::instrument(skip_all, name = "admin::admin_file_override")] |
| 293 |
async fn admin_file_override( |
| 294 |
State(state): State<AppState>, |
| 295 |
AdminUser(_admin): AdminUser, |
| 296 |
Path(user_id): Path<UserId>, |
| 297 |
Form(form): Form<FileOverrideForm>, |
| 298 |
) -> Result<impl IntoResponse> { |
| 299 |
|
| 300 |
if let Some(bytes) = form.max_file_bytes |
| 301 |
&& bytes <= 0 |
| 302 |
{ |
| 303 |
return Err(AppError::BadRequest("Override must be a positive number of bytes".to_string())); |
| 304 |
} |
| 305 |
|
| 306 |
db::creator_tiers::set_max_file_override(&state.db, user_id, form.max_file_bytes).await?; |
| 307 |
|
| 308 |
let msg = match form.max_file_bytes { |
| 309 |
Some(bytes) => format!("File override set to {}", crate::helpers::format_bytes(bytes)), |
| 310 |
None => "File override cleared".to_string(), |
| 311 |
}; |
| 312 |
|
| 313 |
Ok(crate::helpers::htmx_toast_response(&msg, "success")) |
| 314 |
} |
| 315 |
|