Skip to main content

max / makenotwork

Require the typed slug to wipe a chat room The wipe was a bare button. A misclick destroys everyone's messages at once, so it takes the same typed-phrase confirmation the admin clean slate does, checked server-side because a client-side dialog is a suggestion.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-09 19:03 UTC
Signed with PGP, not checked
Commit: d35632abed15a7ab6f1368ada4d9e9c930b1b69c
Parent: 2f81be8
4 files changed, +81 insertions, -21 deletions
@@ -4862,6 +4862,14 @@
4862 4862 source = "registry+https://github.com/rust-lang/crates.io-index"
4863 4863 checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
4864 4864
4865 + [[patch.unused]]
4866 + name = "synckit-client"
4867 + version = "0.8.0"
4868 +
4869 + [[patch.unused]]
4870 + name = "synckit-config"
4871 + version = "0.2.0"
4872 +
4865 4873 [[patch.unused]]
4866 4874 name = "kberg"
4867 4875 version = "0.1.0"
@@ -4893,11 +4901,3 @@
4893 4901 [[patch.unused]]
4894 4902 name = "quasi-webview"
4895 4903 version = "0.1.0"
4896 -
4897 - [[patch.unused]]
4898 - name = "synckit-client"
4899 - version = "0.8.0"
4900 -
4901 - [[patch.unused]]
4902 - name = "synckit-config"
4903 - version = "0.2.0"
@@ -21,10 +21,10 @@
21 21 use mt_core::types::{ChatPolicy, CommunityState, ModAction, ModActor};
22 22
23 23 use super::{
24 - ChatSettingsForm, CreateCategoryForm, CreateTagForm, DeleteTagForm, EditCategoryFormData,
25 - MoveCategoryForm, SetCommunityStateForm, UpdateCommunityForm, audit, begin_tx, commit_tx,
26 - db_error, field_error, is_platform_admin, parse_uuid, require_mod_or_superadmin, require_owner,
27 - template_user, validate_title,
24 + ChatSettingsForm, CleanSlateForm, CreateCategoryForm, CreateTagForm, DeleteTagForm,
25 + EditCategoryFormData, MoveCategoryForm, SetCommunityStateForm, UpdateCommunityForm, audit,
26 + begin_tx, commit_tx, db_error, field_error, is_platform_admin, parse_uuid,
27 + require_mod_or_superadmin, require_owner, template_user, validate_title,
28 28 };
29 29
30 30 #[tracing::instrument(skip_all)]
@@ -221,9 +221,20 @@
221 221 axum::extract::State(state): axum::extract::State<AppState>,
222 222 Path(slug): Path<String>,
223 223 RequireUser(user): RequireUser,
224 + Form(form): Form<CleanSlateForm>,
224 225 ) -> Result<Redirect, Response> {
225 226 require_owner(&state, &slug, &user).await?;
226 227
228 + // Typed-phrase confirmation, the same check and the same form the admin
229 + // clean slate uses. Trim only; case is significant. Checked here rather
230 + // than in the browser because a client-side dialog is a suggestion.
231 + if form.confirm.trim() != slug {
232 + return Err(field_error(
233 + "confirm",
234 + "Confirmation phrase did not match the community slug.",
235 + ));
236 + }
237 +
227 238 let room = MtChatRooms::new(state.db.clone())
228 239 .resolve(&slug)
229 240 .await
@@ -74,18 +74,25 @@
74 74 </form>
75 75 </div>
76 76
77 - {# No typed-slug confirmation, unlike the admin clean slate. That one
78 - destroys threads written to be permanent; this brings forward a
79 - deletion the retention window was always going to perform, and the
80 - room says so on the tin. #}
77 + {# Typed-slug confirmation, the same shape the admin clean slate uses.
78 + A button alone is one misclick away from destroying everyone's
79 + messages, and the server checks the phrase rather than trusting a
80 + dialog the client could skip. #}
81 81 <div class="danger-zone">
82 82 <p class="form-help">
83 83 Delete every message in the room right now, rather than waiting for the
84 84 retention window. <strong>This cannot be undone.</strong> It is recorded in
85 85 the moderation log.
86 86 </p>
87 - <form method="post" action="/p/{{ community_slug }}/settings/chat/wipe">
87 + <p class="form-help">
88 + To confirm, type the community slug <code>{{ community_slug }}</code> below.
89 + </p>
90 + <form method="post" action="/p/{{ community_slug }}/settings/chat/wipe" class="form-container">
88 91 {% include "partials/csrf_input.html" %}
92 + <div class="form-group">
93 + <label for="wipe_confirm">Confirmation</label>
94 + <input type="text" id="wipe_confirm" name="confirm" placeholder="{{ community_slug }}" autocomplete="off" required>
95 + </div>
89 96 <div class="form-actions">
90 97 <button type="submit" class="danger">Delete all chat messages now</button>
91 98 </div>
@@ -237,7 +237,10 @@
237 237 .unwrap();
238 238 }
239 239
240 - let resp = h.client.post_form("/p/test/settings/chat/wipe", "").await;
240 + let resp = h
241 + .client
242 + .post_form("/p/test/settings/chat/wipe", "confirm=test")
243 + .await;
241 244 assert_eq!(resp.status, StatusCode::SEE_OTHER);
242 245
243 246 assert!(
@@ -277,7 +280,10 @@
277 280 .post_form("/p/test/settings/chat", &form("off", "168", "5000"))
278 281 .await;
279 282
280 - let resp = h.client.post_form("/p/test/settings/chat/wipe", "").await;
283 + let resp = h
284 + .client
285 + .post_form("/p/test/settings/chat/wipe", "confirm=test")
286 + .await;
281 287 assert_eq!(resp.status, StatusCode::SEE_OTHER);
282 288 assert!(
283 289 mt_db::queries::recent_backlog(&h.db, id, 10)
@@ -308,7 +314,10 @@
308 314 sign_in(&mut h, moderator, "mod").await;
309 315 h.client.get("/p/test/chat").await;
310 316
311 - let resp = h.client.post_form("/p/test/settings/chat/wipe", "").await;
317 + let resp = h
318 + .client
319 + .post_form("/p/test/settings/chat/wipe", "confirm=test")
320 + .await;
312 321
313 322 assert_eq!(resp.status, StatusCode::FORBIDDEN);
314 323 assert_eq!(
@@ -321,6 +330,39 @@
321 330 );
322 331 }
323 332
333 + #[sqlx::test]
334 + async fn a_wipe_without_the_typed_slug_does_nothing(_pool: sqlx::PgPool) {
335 + // The misclick guard. Checked server-side because a confirmation the client
336 + // owns is a suggestion, and this destroys everyone's messages at once.
337 + let mut h = TestHarness::new().await;
338 + let (id, owner) = owned_community(&mut h).await;
339 + h.client
340 + .post_form("/p/test/settings/chat", &form("members", "168", "5000"))
341 + .await;
342 + mt_db::mutations::insert_chat_message(&h.db, id, owner, "hi", 168)
343 + .await
344 + .unwrap();
345 +
346 + // Case is significant, so the community's own name in title case is not it.
347 + for body in ["confirm=", "confirm=Test", "confirm=wrong"] {
348 + let resp = h.client.post_form("/p/test/settings/chat/wipe", body).await;
349 + assert_eq!(
350 + resp.status,
351 + StatusCode::UNPROCESSABLE_ENTITY,
352 + "{body:?} should not have been accepted"
353 + );
354 + }
355 +
356 + assert_eq!(
357 + mt_db::queries::recent_backlog(&h.db, id, 10)
358 + .await
359 + .unwrap()
360 + .len(),
361 + 1,
362 + "the room is intact"
363 + );
364 + }
365 +
324 366 #[sqlx::test]
325 367 async fn wiping_without_a_csrf_token_is_refused(_pool: sqlx::PgPool) {
326 368 let mut h = TestHarness::new().await;
@@ -336,7 +378,7 @@
336 378
337 379 let resp = h
338 380 .client
339 - .post_form_no_csrf("/p/test/settings/chat/wipe", "")
381 + .post_form_no_csrf("/p/test/settings/chat/wipe", "confirm=test")
340 382 .await;
341 383
342 384 assert_eq!(resp.status, StatusCode::FORBIDDEN);