Skip to main content

max / makenotwork

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