max / makenotwork
- Co-Authored-By
- Claude Opus 4.8 <noreply@anthropic.com>
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 |