Skip to main content

max / makenotwork

12.2 KB · 315 lines History Blame Raw
1 //! Admin routes for creator waitlist management, user moderation, and platform operations.
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 /// Register admin routes for waitlist management, user moderation, and lottery.
26 pub fn admin_routes() -> CsrfRouter<AppState> {
27 CsrfRouter::new()
28 // Waitlist
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 // User management
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 // Upload review queue
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 // Appeals
57 .route_get("/admin/appeals", get(moderation::admin_appeals))
58 .route("/api/admin/appeals/{user_id}/decide", post_csrf(moderation::admin_decide_appeal))
59 // Email signups
60 .route_get("/admin/signups", get(signups::admin_signups))
61 // Reports
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 // Per-item content removal
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 // MT provisioning
69 .route("/api/admin/mt/provision", post_csrf(admin_mt_provision))
70 // Storage overrides
71 .route("/api/admin/users/{id}/file-override", post_csrf(admin_file_override))
72 // Shutdown
73 .route("/api/admin/shutdown-notice", post_csrf(admin_shutdown_notice))
74 // Founder pricing
75 .route("/api/admin/founder-window/close", post_csrf(admin_close_founder_window))
76 // Metrics
77 .route_get("/admin/metrics", get(admin_metrics))
78 }
79
80 // ── MT Provisioning ──
81
82 /// Backfill MT communities for all projects that don't have one yet.
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 // Batch-load all project owners in one query instead of N individual lookups
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 // ── Shutdown ──
151
152 #[derive(Debug, Deserialize)]
153 pub(super) struct ShutdownNoticeForm {
154 pub shutdown_date: String,
155 }
156
157 /// Send a shutdown notice email to all users.
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 // ── Founder pricing ──
195
196 /// Close the founder pricing window. Idempotent: stamps `founder_locked_at`
197 /// on every user currently flagged `is_founder` who has an active
198 /// creator-tier subscription, and skips anyone already locked. Operators
199 /// should also flip `CREATOR_FOUNDER_WINDOW_OPEN=false` in the env
200 /// separately so new signups stop getting founder prices; this route only
201 /// performs the snapshot, it doesn't change config. See
202 /// `project_founder_pricing.md` for the full plan.
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 // ── Metrics ──
222
223 /// Render the admin metrics dashboard with live Prometheus data.
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 // Parse metrics from the Prometheus handle (if available)
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 // ── File Override ──
283
284 #[derive(Debug, Deserialize)]
285 pub(super) struct FileOverrideForm {
286 pub max_file_bytes: Option<i64>,
287 }
288
289 /// Set or clear the admin per-file size override for a user.
290 ///
291 /// POST /api/admin/users/{id}/file-override
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 // Validate: if provided, must be positive
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