Skip to main content

max / makenotwork

Multithreaded second sweep: Community/Storage/UX to A Take the A- axes from ultra-fuzz Run #1 up to A (Security stays A-, capped by cross-repo S13/HMAC items that are post-launch). Community: mod_remove_post_cascade soft-deletes the thread when its opening post is removed (atomic); wired into both mod-removal handlers. get_thread_with_breadcrumb now filters deleted_at so a soft-deleted thread 404s instead of staying reachable and repliable by direct link. Storage: migration 028 adds images.s3_purged_at; a background reconcile sweep purges orphaned S3 objects convergently and retries failed inline deletes. remove_image_handler marks purged on successful delete. UX: field_error returns 422 + X-Form-Field for content validations; mt.js renders a persistent inline error next to the named input (highlight/focus, input preserved) instead of a transient toast. Performance: keyset pagination and streaming uploads reviewed and deliberately deferred (net-negative at this scale). 359 tests green (117 unit + 242 integration), clippy clean.
Co-Authored-By
Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-15 19:54 UTC
Signed with PGP, not checked
Commit: 873dc0667fd37e9d0265fc0498fc13f5ad7f7057
Parent: 9e7cbde
18 files changed, +641 insertions, -40 deletions
@@ -5,6 +5,7 @@
5 5 pub mod csrf;
6 6 pub mod internal_auth;
7 7 pub mod link_preview;
8 + pub mod maintenance;
8 9 pub mod routes;
9 10 pub mod seed;
10 11 pub mod storage;
@@ -87,6 +87,16 @@
87 87 .continuously_delete_expired(tokio::time::Duration::from_secs(3600)),
88 88 );
89 89
90 + // Reconcile sweep: purge S3 objects for removed images (backlog + retries).
91 + // Only meaningful when S3 is configured.
92 + let purge_task = state.s3.as_ref().map(|s3| {
93 + tokio::task::spawn(multithreaded::maintenance::continuously_purge_removed_images(
94 + state.db.clone(),
95 + s3.clone(),
96 + tokio::time::Duration::from_secs(6 * 3600),
97 + ))
98 + });
99 +
90 100 let session_layer = SessionManagerLayer::new(session_store)
91 101 .with_name("mt_session")
92 102 .with_same_site(SameSite::Lax)
@@ -146,6 +156,10 @@
146 156
147 157 deletion_task.abort();
148 158 let _ = deletion_task.await;
159 + if let Some(task) = purge_task {
160 + task.abort();
161 + let _ = task.await;
162 + }
149 163 }
150 164
151 165 async fn shutdown_signal() {
@@ -58,6 +58,14 @@
58 58 pub async fn delete(&self, s3_key: &str) -> Result<(), String> {
59 59 self.inner.delete(s3_key).await
60 60 }
61 +
62 + /// Batch-delete objects in a single request (S3 allows up to 1000 keys).
63 + /// Returns the keys that failed to delete, paired with the error message;
64 + /// an empty vec means every key was deleted (or was already absent).
65 + #[tracing::instrument(skip_all)]
66 + pub async fn delete_objects(&self, s3_keys: &[String]) -> Result<Vec<(String, String)>, String> {
67 + self.inner.delete_objects(s3_keys).await
68 + }
61 69 }
62 70
63 71 /// Generate an S3 key for a forum image.
@@ -40,6 +40,49 @@
40 40 showToast(evt.detail.message || 'Action completed', evt.detail.type || 'info');
41 41 });
42 42
43 + /* ===========================================
44 + INLINE FORM ERRORS
45 +
46 + A failed submit (422) keeps the user on the page with their input intact and
47 + shows a persistent error attached to the form — and, when the handler names
48 + the offending field via X-Form-Field, highlights and focuses that input.
49 + This replaces the transient error toast for validation failures.
50 + =========================================== */
51 +
52 + function clearFormError(form) {
53 + var prev = form.querySelector('.form-error');
54 + if (prev) prev.remove();
55 + form.querySelectorAll('[aria-invalid="true"]').forEach(function(el) {
56 + el.removeAttribute('aria-invalid');
57 + });
58 + }
59 +
60 + function showFormError(form, message, field) {
61 + clearFormError(form);
62 + var err = document.createElement('div');
63 + err.className = 'form-error';
64 + err.setAttribute('role', 'alert');
65 + err.textContent = message;
66 + form.insertBefore(err, form.firstChild);
67 +
68 + var focusTarget = null;
69 + if (field) {
70 + focusTarget = form.querySelector('[name="' + field + '"]');
71 + if (focusTarget) focusTarget.setAttribute('aria-invalid', 'true');
72 + }
73 + (focusTarget || err).scrollIntoView({ block: 'nearest', behavior: 'smooth' });
74 + if (focusTarget) focusTarget.focus();
75 + }
76 +
77 + // Clear a field's invalid state (and the form error) once the user edits it.
78 + document.addEventListener('input', function(e) {
79 + var field = e.target;
80 + if (field.getAttribute && field.getAttribute('aria-invalid') === 'true') {
81 + var form = field.closest('form');
82 + if (form) clearFormError(form);
83 + }
84 + });
85 +
43 86 document.body.addEventListener('htmx:responseError', function(evt) {
44 87 var container = document.getElementById('notifications');
45 88 if (!container) return;
@@ -200,6 +243,7 @@
200 243 var token = document.querySelector('meta[name="csrf-token"]')?.content;
201 244 if (!token) return;
202 245 e.preventDefault();
246 + clearFormError(form);
203 247 fetch(form.action || window.location.href, {
204 248 method: 'POST',
205 249 headers: { 'X-CSRF-Token': token, 'Content-Type': 'application/x-www-form-urlencoded' },
@@ -213,11 +257,17 @@
213 257 window.location.href = resp.url;
214 258 return;
215 259 }
216 - // Validation/other failure: keep the user on the page with their
217 - // input intact and surface the handler's message, rather than
218 - // navigating to the POST-only URL (which would GET a 404).
219 260 return resp.text().then(function(msg) {
220 - showToast(msg || 'Something went wrong. Please try again.', 'error');
261 + if (resp.status === 422) {
262 + // Validation failure: persistent inline error on the form,
263 + // input preserved, offending field highlighted/focused.
264 + showFormError(form, msg || 'Please check your input and try again.',
265 + resp.headers.get('X-Form-Field'));
266 + } else {
267 + // Auth/rate-limit/server errors aren't field problems —
268 + // a transient toast is the right surface.
269 + showToast(msg || 'Something went wrong. Please try again.', 'error');
270 + }
221 271 });
222 272 }).catch(function() {
223 273 showToast('Network error. Please try again.', 'error');
@@ -341,6 +341,23 @@
341 341 background: var(--surface-muted);
342 342 }
343 343
344 + /* Inline form validation error — inserted by mt.js on a 422 submit, replacing
345 + the old error toast. Persists next to the form with the input preserved. */
346 + .form-error {
347 + background: var(--surface-muted);
348 + border-left: 3px solid var(--error);
349 + color: var(--error);
350 + padding: 0.6rem 0.75rem;
351 + margin-bottom: 0.75rem;
352 + font-size: 0.85rem;
353 + }
354 +
355 + input[aria-invalid="true"],
356 + textarea[aria-invalid="true"],
357 + select[aria-invalid="true"] {
358 + border: 2px solid var(--error);
359 + }
360 +
344 361 select {
345 362 cursor: pointer;
346 363 }
@@ -18,7 +18,7 @@
18 18 use crate::AppState;
19 19
20 20 use super::{
21 - render_markdown, render_markdown_plus, template_user, SignatureForm,
21 + field_error, render_markdown, render_markdown_plus, template_user, SignatureForm,
22 22 };
23 23
24 24 const SIGNATURE_MAX: usize = 1024;
@@ -97,11 +97,10 @@
97 97 return Ok(Redirect::to("/account"));
98 98 }
99 99 if trimmed.chars().count() > SIGNATURE_MAX {
100 - return Err((
101 - StatusCode::UNPROCESSABLE_ENTITY,
100 + return Err(field_error(
101 + "signature",
102 102 format!("Signature must be at most {SIGNATURE_MAX} characters."),
103 - )
104 - .into_response());
103 + ));
105 104 }
106 105
107 106 // Render with the same plus-aware paths as posts: creators get image
@@ -14,7 +14,8 @@
14 14 use mt_core::types::ModAction;
15 15
16 16 use super::{
17 - check_community_access, get_community, log_mod_action, parse_uuid, require_mod_or_owner,
17 + check_community_access, field_error, get_community, log_mod_action, parse_uuid,
18 + require_mod_or_owner,
18 19 };
19 20
20 21 #[derive(Deserialize)]
@@ -38,7 +39,7 @@
38 39
39 40 // Validate reason
40 41 if !matches!(form.reason.as_str(), "spam" | "rule_breaking" | "off_topic") {
41 - return Err((StatusCode::UNPROCESSABLE_ENTITY, "Invalid flag reason.").into_response());
42 + return Err(field_error("reason", "Invalid flag reason."));
42 43 }
43 44
44 45 // Fetch post to check ownership and existence
@@ -70,7 +71,7 @@
70 71 if let Some(d) = detail
71 72 && d.len() > 1024
72 73 {
73 - return Err((StatusCode::UNPROCESSABLE_ENTITY, "Flag detail too long (max 1024 bytes).").into_response());
74 + return Err(field_error("detail", "Flag detail too long (max 1024 bytes)."));
74 75 }
75 76
76 77 mt_db::mutations::insert_flag(&state.db, post_id, user.user_id, &form.reason, detail)
@@ -153,9 +154,9 @@
153 154 let (community, _role) = require_mod_or_owner(&state, &slug, &user).await?;
154 155 let flag_id = parse_uuid(&flag_id_str)?;
155 156
156 - // Get the flag to find the post_id — scoped to this community
157 - let flag_row: Option<(uuid::Uuid, uuid::Uuid)> = sqlx::query_as(
158 - "SELECT pf.post_id, p.author_id
157 + // Get the flag to find the post_id and its thread — scoped to this community
158 + let flag_row: Option<(uuid::Uuid, uuid::Uuid, uuid::Uuid)> = sqlx::query_as(
159 + "SELECT pf.post_id, p.author_id, t.id
159 160 FROM post_flags pf
160 161 JOIN posts p ON p.id = pf.post_id
161 162 JOIN threads t ON t.id = p.thread_id
@@ -171,11 +172,12 @@
171 172 (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
172 173 })?;
173 174
174 - let (post_id, author_id) = flag_row
175 + let (post_id, author_id, thread_id) = flag_row
175 176 .ok_or_else(|| (StatusCode::NOT_FOUND, "Not found").into_response())?;
176 177
177 - // Mod-remove the post (idempotent — returns false if already removed)
178 - let _ = mt_db::mutations::mod_remove_post(&state.db, post_id, user.user_id)
178 + // Mod-remove the post (idempotent); if it is the OP, the whole thread is
179 + // soft-deleted in the same transaction.
180 + let removal = mt_db::mutations::mod_remove_post_cascade(&state.db, post_id, user.user_id)
179 181 .await
180 182 .map_err(|e| {
181 183 tracing::error!(error = ?e, "db error removing flagged post");
@@ -195,6 +197,16 @@
195 197 ModAction::RemovePostViaFlag, Some(author_id), Some(post_id), None,
196 198 ).await;
197 199
200 + if removal.thread_removed {
201 + log_mod_action(
202 + &state.db, Some(community.id), user.user_id,
203 + ModAction::DeleteThread, Some(author_id), Some(thread_id), None,
204 + ).await;
205 + return Ok(Redirect::to(&format!(
206 + "/p/{slug}/moderation?toast=Thread+removed"
207 + )));
208 + }
209 +
198 210 Ok(Redirect::to(&format!(
199 211 "/p/{slug}/moderation?toast=Post+removed"
200 212 )))
@@ -183,30 +183,47 @@
183 183 }
184 184 }
185 185
186 + /// Build a 422 validation response tagged with the offending form field.
187 + ///
188 + /// `field` matches the input's `name` attribute. The `X-Form-Field` header lets
189 + /// `mt.js` render the message as a persistent inline error next to that input
190 + /// (and focus it) instead of a transient toast — the A-grade form-failure UX.
191 + /// Submissions never navigate away on 422, so the user's input is preserved in
192 + /// the live DOM; only the error needs to come back.
193 + #[allow(clippy::result_large_err)]
194 + pub(crate) fn field_error(field: &'static str, message: impl Into<String>) -> Response {
195 + (
196 + StatusCode::UNPROCESSABLE_ENTITY,
197 + [("X-Form-Field", field)],
198 + message.into(),
199 + )
200 + .into_response()
201 + }
202 +
186 203 /// Validate a title field (1-256 chars).
187 204 #[allow(clippy::result_large_err)]
188 205 pub(crate) fn validate_title(text: &str) -> Result<&str, Response> {
189 206 let t = text.trim();
190 207 if t.is_empty() || t.len() > 256 {
191 - return Err((
192 - StatusCode::UNPROCESSABLE_ENTITY,
208 + return Err(field_error(
209 + "title",
193 210 "Title must be between 1 and 256 characters.",
194 - )
195 - .into_response());
211 + ));
196 212 }
197 213 Ok(t)
198 214 }
199 215
200 - /// Validate a body/content field (1 to max chars).
216 + /// Validate a body/content field (1 to max chars). `label` names the field in
217 + /// the message ("Body", "Footnote"); the inline error attaches to the `body`
218 + /// input, which every content textarea in the app uses as its `name`.
201 219 #[allow(clippy::result_large_err)]
202 - pub(crate) fn validate_body<'a>(text: &'a str, max: usize, field: &str) -> Result<&'a str, Response> {
220 + pub(crate) fn validate_body<'a>(text: &'a str, max: usize, label: &str) -> Result<&'a str, Response> {
203 221 let t = text.trim();
204 222 if t.is_empty() || t.len() > max {
205 - return Err((
206 - StatusCode::UNPROCESSABLE_ENTITY,
207 - format!("{field} must be between 1 and {max} characters."),
208 - )
209 - .into_response());
223 + return Err(field_error(
224 + "body",
225 + format!("{label} must be between 1 and {max} characters."),
226 + ));
210 227 }
211 228 Ok(t)
212 229 }
@@ -16,8 +16,9 @@
16 16 use mt_core::types::{BanType, ModAction};
17 17
18 18 use super::{
19 - get_role, get_thread, get_user_by_username, is_mod_or_owner, is_owner, log_mod_action,
20 - parse_duration, parse_uuid, require_mod_or_owner, template_user, BanForm, PageQuery, UnbanForm,
19 + field_error, get_role, get_thread, get_user_by_username, is_mod_or_owner, is_owner,
20 + log_mod_action, parse_duration, parse_uuid, require_mod_or_owner, template_user, BanForm,
21 + PageQuery, UnbanForm,
21 22 };
22 23
23 24 #[tracing::instrument(skip_all)]
@@ -121,7 +122,7 @@
121 122 return Err(StatusCode::FORBIDDEN.into_response());
122 123 }
123 124
124 - let _ = mt_db::mutations::mod_remove_post(&state.db, post_id, user.user_id)
125 + let removal = mt_db::mutations::mod_remove_post_cascade(&state.db, post_id, user.user_id)
125 126 .await
126 127 .map_err(|e| {
127 128 tracing::error!(error = ?e, "db error removing post");
@@ -133,6 +134,19 @@
133 134 ModAction::RemovePost, Some(post_data.author_id), Some(post_id), None,
134 135 ).await;
135 136
137 + // Removing the opening post cascades to soft-deleting the whole thread; the
138 + // thread page now 404s, so send the mod back to the category listing.
139 + if removal.thread_removed {
140 + let thread_id = parse_uuid(&thread_id_str)?;
141 + log_mod_action(
142 + &state.db, Some(post_data.community_id), user.user_id,
143 + ModAction::DeleteThread, Some(post_data.author_id), Some(thread_id), None,
144 + ).await;
145 + return Ok(Redirect::to(&format!(
146 + "/p/{slug}/{category_slug}?toast=Thread+removed"
147 + )));
148 + }
149 +
136 150 Ok(Redirect::to(&format!(
137 151 "/p/{slug}/{category_slug}/{thread_id_str}?toast=Post+removed"
138 152 )))
@@ -250,7 +264,7 @@
250 264 if let Some(r) = reason
251 265 && r.len() > 1024
252 266 {
253 - return Err((StatusCode::UNPROCESSABLE_ENTITY, "Reason too long (max 1024 bytes).").into_response());
267 + return Err(field_error("reason", "Reason too long (max 1024 bytes)."));
254 268 }
255 269
256 270 mt_db::mutations::create_community_ban(
@@ -335,7 +349,7 @@
335 349 if let Some(r) = reason
336 350 && r.len() > 1024
337 351 {
338 - return Err((StatusCode::UNPROCESSABLE_ENTITY, "Reason too long (max 1024 bytes).").into_response());
352 + return Err(field_error("reason", "Reason too long (max 1024 bytes)."));
339 353 }
340 354
341 355 mt_db::mutations::create_community_ban(
@@ -216,11 +216,19 @@
216 216
217 217 // Delete the backing S3 object so removed images don't accumulate in the
218 218 // bucket forever. Best-effort: the DB row is already marked removed (serve
219 - // returns 410), so a transient S3 failure is logged, not surfaced.
220 - if let Some(s3) = state.s3.as_ref()
221 - && let Err(e) = s3.delete(&image.s3_key).await
222 - {
223 - tracing::warn!(error = %e, s3_key = %image.s3_key, "failed to delete removed image from S3");
219 + // returns 410). On success, record s3_purged_at so the reconcile sweep skips
220 + // it; on failure, leave it unmarked so the sweep retries it later.
221 + if let Some(s3) = state.s3.as_ref() {
222 + match s3.delete(&image.s3_key).await {
223 + Ok(()) => {
224 + if let Err(e) = mt_db::mutations::mark_images_s3_purged(&state.db, &[image_id]).await {
225 + tracing::warn!(error = ?e, "failed to mark image S3-purged (sweep will retry)");
226 + }
227 + }
228 + Err(e) => {
229 + tracing::warn!(error = %e, s3_key = %image.s3_key, "failed to delete removed image from S3 (sweep will retry)");
230 + }
231 + }
224 232 }
225 233
226 234 // Log mod action