Skip to main content

max / makenotwork

9.0 KB · 283 lines History Blame Raw
1 //! Platform admin handlers.
2
3 use axum::{
4 extract::{Path, Query},
5 http::StatusCode,
6 response::{IntoResponse, Redirect, Response},
7 Form,
8 };
9 use tower_sessions::Session;
10
11 use crate::auth::PlatformAdmin;
12 use crate::csrf;
13 use crate::templates::*;
14 use crate::AppState;
15
16 use mt_core::types::ModAction;
17
18 use super::{
19 get_community, log_mod_action, parse_uuid, template_user, AdminSearchQuery,
20 CleanSlateForm, SuspendForm,
21 };
22
23 #[tracing::instrument(skip_all)]
24 pub(super) async fn admin_dashboard(
25 axum::extract::State(state): axum::extract::State<AppState>,
26 session: Session,
27 PlatformAdmin(admin): PlatformAdmin,
28 Query(query): Query<AdminSearchQuery>,
29 ) -> Result<impl IntoResponse, Response> {
30 let csrf_token = Some(csrf::get_or_create_token(&session).await);
31
32 let communities = mt_db::queries::list_all_communities(&state.db)
33 .await
34 .map_err(|e| {
35 tracing::error!(error = ?e, "db error listing communities");
36 (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
37 })?
38 .into_iter()
39 .map(|c| AdminCommunityViewRow {
40 id: c.id.to_string(),
41 name: c.name,
42 slug: c.slug,
43 is_suspended: c.suspended_at.is_some(),
44 suspension_reason: c.suspension_reason,
45 })
46 .collect();
47
48 let search_query = query.q.unwrap_or_default();
49 let users = if !search_query.is_empty() {
50 mt_db::queries::search_users(&state.db, &search_query)
51 .await
52 .map_err(|e| {
53 tracing::error!(error = ?e, "db error searching users");
54 (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
55 })?
56 .into_iter()
57 .map(|u| AdminUserViewRow {
58 id: u.id.to_string(),
59 username: u.username,
60 display_name: u.display_name,
61 is_suspended: u.suspended_at.is_some(),
62 suspension_reason: u.suspension_reason,
63 })
64 .collect()
65 } else {
66 vec![]
67 };
68
69 Ok(AdminDashboardTemplate {
70 csrf_token,
71 session_user: Some(TemplateSessionUser {
72 is_platform_admin: true,
73 username: admin.username,
74 }),
75 mnw_base_url: state.config.mnw_base_url.clone(),
76 communities,
77 users,
78 search_query,
79 })
80 }
81
82 #[tracing::instrument(skip_all)]
83 pub(super) async fn suspend_community_handler(
84 axum::extract::State(state): axum::extract::State<AppState>,
85 PlatformAdmin(admin): PlatformAdmin,
86 Path(id): Path<String>,
87 Form(form): Form<SuspendForm>,
88 ) -> Result<Redirect, Response> {
89 let community_id = parse_uuid(&id)?;
90 let reason = form.reason.as_deref().filter(|r| !r.trim().is_empty());
91
92 mt_db::mutations::suspend_community(&state.db, community_id, reason)
93 .await
94 .map_err(|e| {
95 tracing::error!(error = ?e, "db error suspending community");
96 (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
97 })?;
98
99 log_mod_action(
100 &state.db, None, admin.user_id,
101 ModAction::SuspendCommunity, None, Some(community_id), reason,
102 ).await;
103
104 Ok(Redirect::to("/_admin?toast=Community+suspended"))
105 }
106
107 #[tracing::instrument(skip_all)]
108 pub(super) async fn unsuspend_community_handler(
109 axum::extract::State(state): axum::extract::State<AppState>,
110 PlatformAdmin(admin): PlatformAdmin,
111 Path(id): Path<String>,
112 ) -> Result<Redirect, Response> {
113 let community_id = parse_uuid(&id)?;
114
115 mt_db::mutations::unsuspend_community(&state.db, community_id)
116 .await
117 .map_err(|e| {
118 tracing::error!(error = ?e, "db error unsuspending community");
119 (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
120 })?;
121
122 log_mod_action(
123 &state.db, None, admin.user_id,
124 ModAction::UnsuspendCommunity, None, Some(community_id), None,
125 ).await;
126
127 Ok(Redirect::to("/_admin?toast=Community+unsuspended"))
128 }
129
130 #[tracing::instrument(skip_all)]
131 pub(super) async fn suspend_user_handler(
132 axum::extract::State(state): axum::extract::State<AppState>,
133 PlatformAdmin(admin): PlatformAdmin,
134 Path(id): Path<String>,
135 Form(form): Form<SuspendForm>,
136 ) -> Result<Redirect, Response> {
137 let user_id = parse_uuid(&id)?;
138 let reason = form.reason.as_deref().filter(|r| !r.trim().is_empty());
139
140 mt_db::mutations::suspend_user(&state.db, user_id, reason)
141 .await
142 .map_err(|e| {
143 tracing::error!(error = ?e, "db error suspending user");
144 (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
145 })?;
146
147 log_mod_action(
148 &state.db, None, admin.user_id,
149 ModAction::SuspendUser, Some(user_id), None, reason,
150 ).await;
151
152 Ok(Redirect::to("/_admin?toast=User+suspended"))
153 }
154
155 #[tracing::instrument(skip_all)]
156 pub(super) async fn unsuspend_user_handler(
157 axum::extract::State(state): axum::extract::State<AppState>,
158 PlatformAdmin(admin): PlatformAdmin,
159 Path(id): Path<String>,
160 ) -> Result<Redirect, Response> {
161 let user_id = parse_uuid(&id)?;
162
163 mt_db::mutations::unsuspend_user(&state.db, user_id)
164 .await
165 .map_err(|e| {
166 tracing::error!(error = ?e, "db error unsuspending user");
167 (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
168 })?;
169
170 log_mod_action(
171 &state.db, None, admin.user_id,
172 ModAction::UnsuspendUser, Some(user_id), None, None,
173 ).await;
174
175 Ok(Redirect::to("/_admin?toast=User+unsuspended"))
176 }
177
178 // ============================================================================
179 // Dedicated admin view per community: state machine + clean-slate.
180 // ============================================================================
181
182 #[tracing::instrument(skip_all)]
183 pub(super) async fn admin_community_detail(
184 axum::extract::State(state): axum::extract::State<AppState>,
185 session: Session,
186 PlatformAdmin(admin): PlatformAdmin,
187 Path(slug): Path<String>,
188 ) -> Result<AdminCommunityTemplate, Response> {
189 let csrf_token = Some(csrf::get_or_create_token(&session).await);
190 let community = get_community(&state.db, &slug).await?;
191
192 let thread_count: i64 = sqlx::query_scalar(
193 "SELECT COUNT(*) FROM threads t
194 JOIN categories c ON c.id = t.category_id
195 WHERE c.community_id = $1",
196 )
197 .bind(community.id)
198 .fetch_one(&state.db)
199 .await
200 .map_err(|e| {
201 tracing::error!(error = ?e, "db error counting threads");
202 StatusCode::INTERNAL_SERVER_ERROR.into_response()
203 })?;
204
205 let member_count = mt_db::queries::count_community_members(&state.db, community.id)
206 .await
207 .map_err(|e| {
208 tracing::error!(error = ?e, "db error counting members");
209 StatusCode::INTERNAL_SERVER_ERROR.into_response()
210 })?;
211
212 let suspension_reason: Option<String> = if community.suspended_at.is_some() {
213 sqlx::query_scalar("SELECT suspension_reason FROM communities WHERE id = $1")
214 .bind(community.id)
215 .fetch_one(&state.db)
216 .await
217 .ok()
218 .flatten()
219 } else {
220 None
221 };
222
223 Ok(AdminCommunityTemplate {
224 csrf_token,
225 session_user: Some(template_user(&admin, state.config.platform_admin_id)),
226 mnw_base_url: state.config.mnw_base_url.clone(),
227 community_name: community.name,
228 community_slug: slug,
229 current_state: community.state.as_str(),
230 thread_count,
231 member_count,
232 is_suspended: community.suspended_at.is_some(),
233 suspension_reason,
234 })
235 }
236
237 #[tracing::instrument(skip_all)]
238 pub(super) async fn admin_community_clean_slate_handler(
239 axum::extract::State(state): axum::extract::State<AppState>,
240 PlatformAdmin(admin): PlatformAdmin,
241 Path(slug): Path<String>,
242 Form(form): Form<CleanSlateForm>,
243 ) -> Result<Redirect, Response> {
244 let community = get_community(&state.db, &slug).await?;
245
246 // GitHub-style typed-phrase confirmation: must match the community slug
247 // exactly. Trim only — case is significant.
248 if form.confirm.trim() != slug {
249 return Err((
250 StatusCode::UNPROCESSABLE_ENTITY,
251 "Confirmation phrase did not match the community slug.",
252 )
253 .into_response());
254 }
255
256 let result = mt_db::mutations::clean_slate_community(
257 &state.db,
258 community.id,
259 admin.user_id,
260 &admin.username,
261 )
262 .await
263 .map_err(|e| {
264 tracing::error!(error = ?e, "clean-slate failed");
265 (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
266 })?;
267
268 log_mod_action(
269 &state.db,
270 Some(community.id),
271 admin.user_id,
272 ModAction::CleanSlateCommunity,
273 None,
274 result.system_thread_id,
275 Some(&format!("deleted {} threads", result.deleted_thread_count)),
276 )
277 .await;
278
279 Ok(Redirect::to(&format!(
280 "/_admin/communities/{slug}?toast=Community+reset"
281 )))
282 }
283