Skip to main content

max / multithreaded

16.3 KB · 444 lines History Blame Raw
1 //! Post creation handlers — thread creation, replies, and supporting pipelines
2 //! (quote verification, mention resolution, link preview fetching).
3
4 use axum::{
5 extract::Path,
6 http::StatusCode,
7 response::{IntoResponse, Redirect, Response},
8 Form,
9 };
10 use uuid::Uuid;
11
12 use sha2::{Sha256, Digest};
13
14 use crate::auth::MaybeUser;
15 use crate::AppState;
16
17 use mt_core::types::ModAction;
18
19 use super::super::{
20 check_user_post_rate, check_write_access, get_community, get_thread, is_mod_or_owner,
21 log_mod_action, parse_uuid, render_markdown, render_markdown_with_mentions,
22 template_user, validate_body, validate_title, get_role,
23 CreateReplyForm, CreateThreadForm,
24 };
25
26 // ============================================================================
27 // Quote verification
28 // ============================================================================
29
30 pub(super) const MAX_QUOTES_PER_POST: usize = 10;
31 pub(super) const MAX_FOOTNOTES_PER_POST: usize = 10;
32
33 /// Extract `[quote:POST_ID:HASH]` markers from markdown body and verify each.
34 /// Returns the IDs of quoted posts for attribution rendering.
35 #[tracing::instrument(skip_all)]
36 pub(super) async fn verify_quotes(
37 db: &sqlx::PgPool,
38 body: &str,
39 ) -> Result<Vec<Uuid>, Response> {
40 static QUOTE_RE: std::sync::LazyLock<regex_lite::Regex> = std::sync::LazyLock::new(|| {
41 regex_lite::Regex::new(r"\[quote:([0-9a-f\-]{36}):([0-9a-f]{8})\]").unwrap()
42 });
43
44 let match_count = QUOTE_RE.find_iter(body).count();
45 if match_count > MAX_QUOTES_PER_POST {
46 return Err((
47 StatusCode::UNPROCESSABLE_ENTITY,
48 "Too many quotes. Maximum is 10 per post.",
49 )
50 .into_response());
51 }
52
53 let mut quoted_post_ids = Vec::new();
54
55 for caps in QUOTE_RE.captures_iter(body) {
56 let post_id_str = &caps[1];
57 let claimed_hash = &caps[2];
58
59 let post_id = Uuid::parse_str(post_id_str)
60 .map_err(|_| (StatusCode::UNPROCESSABLE_ENTITY, "Invalid quote reference.").into_response())?;
61
62 // Extract the quoted text: lines starting with `> ` immediately before the marker
63 let marker = caps.get(0).unwrap();
64 let before_marker = &body[..marker.start()];
65 let quoted_lines: Vec<&str> = before_marker
66 .lines()
67 .rev()
68 .take_while(|line| line.starts_with("> ") || line.starts_with('>'))
69 .collect::<Vec<_>>()
70 .into_iter()
71 .rev()
72 .collect();
73
74 let quoted_text: String = quoted_lines
75 .iter()
76 .map(|line| line.strip_prefix("> ").unwrap_or(line.strip_prefix('>').unwrap_or(line)))
77 .collect::<Vec<_>>()
78 .join("\n")
79 .trim()
80 .to_string();
81
82 if quoted_text.is_empty() {
83 return Err((StatusCode::UNPROCESSABLE_ENTITY, "Empty quote text.").into_response());
84 }
85
86 // Fetch original post body
87 let (_, original_markdown) = mt_db::queries::get_post_body_markdown(db, post_id)
88 .await
89 .map_err(|e| {
90 tracing::error!(error = ?e, "db error fetching post for quote verification");
91 StatusCode::INTERNAL_SERVER_ERROR.into_response()
92 })?
93 .ok_or_else(|| (StatusCode::UNPROCESSABLE_ENTITY, "Quoted post not found.").into_response())?;
94
95 // Verify quoted text is a substring of the original
96 if !original_markdown.contains(&quoted_text) {
97 return Err((StatusCode::UNPROCESSABLE_ENTITY, "Quote does not match original post.").into_response());
98 }
99
100 // Verify hash
101 let mut hasher = Sha256::new();
102 hasher.update(quoted_text.as_bytes());
103 let hash = hasher.finalize();
104 let expected_hash = hex::encode(&hash[..4]);
105
106 if claimed_hash != expected_hash {
107 return Err((StatusCode::UNPROCESSABLE_ENTITY, "Quote hash mismatch.").into_response());
108 }
109
110 quoted_post_ids.push(post_id);
111 }
112
113 Ok(quoted_post_ids)
114 }
115
116 // ============================================================================
117 // Mention pipeline
118 // ============================================================================
119
120 /// Resolve mentions in a post body and return (rendered_html, mentioned_user_ids).
121 /// `author_id` is excluded from the mention list (self-mention not stored).
122 #[tracing::instrument(skip_all)]
123 pub(super) async fn resolve_and_render_mentions(
124 db: &sqlx::PgPool,
125 body: &str,
126 community_id: Uuid,
127 community_slug: &str,
128 author_id: Uuid,
129 ) -> Result<(String, Vec<Uuid>), Response> {
130 let usernames = docengine::extract_mentions(body);
131 if usernames.is_empty() {
132 return Ok((render_markdown(body), Vec::new()));
133 }
134
135 let resolved = mt_db::queries::resolve_usernames_in_community(db, community_id, &usernames)
136 .await
137 .map_err(|e| {
138 tracing::error!(error = ?e, "db error resolving mention usernames");
139 StatusCode::INTERNAL_SERVER_ERROR.into_response()
140 })?;
141
142 let valid_set: std::collections::HashSet<String> = resolved.keys().cloned().collect();
143 let body_html = render_markdown_with_mentions(body, community_slug, &valid_set);
144
145 // Collect user IDs, excluding self
146 let mention_ids: Vec<Uuid> = resolved
147 .values()
148 .copied()
149 .filter(|uid| *uid != author_id)
150 .collect();
151
152 Ok((body_html, mention_ids))
153 }
154
155 // ============================================================================
156 // Link preview pipeline
157 // ============================================================================
158
159 /// Extract URLs from post body, fetch OG metadata, and store previews.
160 /// Best-effort: fetch failures are logged but never block post creation.
161 #[tracing::instrument(skip_all)]
162 pub(super) async fn fetch_and_store_link_previews(state: &AppState, body: &str, post_id: Uuid) {
163 let urls = crate::link_preview::extract_urls(body);
164 for url in urls {
165 match crate::link_preview::fetch_og_metadata(&state.preview_http, &url).await {
166 Some((title, description)) => {
167 if let Err(e) = mt_db::mutations::insert_link_preview(
168 &state.db,
169 post_id,
170 &url,
171 title.as_deref(),
172 description.as_deref(),
173 )
174 .await
175 {
176 tracing::warn!(error = ?e, url = %url, "failed to insert link preview");
177 }
178 }
179 None => {
180 tracing::debug!(url = %url, "no OG metadata found");
181 }
182 }
183 }
184 }
185
186 // ============================================================================
187 // Thread + reply creation
188 // ============================================================================
189
190 #[tracing::instrument(skip_all)]
191 pub(in crate::routes) async fn create_thread_handler(
192 axum::extract::State(state): axum::extract::State<AppState>,
193 Path((slug, category_slug)): Path<(String, String)>,
194 MaybeUser(session_user): MaybeUser,
195 Form(form): Form<CreateThreadForm>,
196 ) -> Result<Redirect, Response> {
197 let user = session_user
198 .ok_or_else(|| Redirect::to("/auth/login").into_response())?;
199
200 let community = get_community(&state.db, &slug).await?;
201
202 check_write_access(&state.db, community.id, user.user_id, community.suspended_at.is_some()).await?;
203 mt_db::mutations::ensure_membership(&state.db, user.user_id, community.id)
204 .await
205 .map_err(|e| {
206 tracing::error!(error = ?e, "db error ensuring membership");
207 StatusCode::INTERNAL_SERVER_ERROR.into_response()
208 })?;
209 check_user_post_rate(&state.db, user.user_id).await?;
210
211 let title = validate_title(&form.title)?;
212 let body = validate_body(&form.body, 65536, "Body")?;
213
214 let category_id = mt_db::mutations::get_category_id_by_slugs(&state.db, &slug, &category_slug)
215 .await
216 .map_err(|e| {
217 tracing::error!(error = ?e, "db error looking up category");
218 StatusCode::INTERNAL_SERVER_ERROR.into_response()
219 })?
220 .ok_or_else(|| StatusCode::NOT_FOUND.into_response())?;
221
222 verify_quotes(&state.db, body).await?;
223
224 let (body_html, mention_ids) = resolve_and_render_mentions(
225 &state.db, body, community.id, &slug, user.user_id,
226 ).await?;
227
228 let thread_id = mt_db::mutations::create_thread(&state.db, category_id, user.user_id, title)
229 .await
230 .map_err(|e| {
231 tracing::error!(error = ?e, "db error creating thread");
232 StatusCode::INTERNAL_SERVER_ERROR.into_response()
233 })?;
234
235 let post_id = mt_db::mutations::create_post(&state.db, thread_id, user.user_id, body, &body_html)
236 .await
237 .map_err(|e| {
238 tracing::error!(error = ?e, "db error creating post");
239 StatusCode::INTERNAL_SERVER_ERROR.into_response()
240 })?;
241
242 if !mention_ids.is_empty() {
243 mt_db::mutations::insert_mentions(&state.db, post_id, &mention_ids)
244 .await
245 .map_err(|e| {
246 tracing::error!(error = ?e, "db error inserting mentions");
247 StatusCode::INTERNAL_SERVER_ERROR.into_response()
248 })?;
249 }
250
251 // Fetch link previews (best-effort, failures don't block post creation)
252 fetch_and_store_link_previews(&state, body, post_id).await;
253
254 // Save tags if any were selected
255 if !form.tags.is_empty() {
256 let tag_ids: Vec<uuid::Uuid> = form
257 .tags
258 .iter()
259 .filter_map(|t| uuid::Uuid::parse_str(t).ok())
260 .collect();
261 if !tag_ids.is_empty() {
262 mt_db::mutations::set_thread_tags(&state.db, thread_id, &tag_ids)
263 .await
264 .map_err(|e| {
265 tracing::error!(error = ?e, "db error setting thread tags");
266 StatusCode::INTERNAL_SERVER_ERROR.into_response()
267 })?;
268 }
269 }
270
271 Ok(Redirect::to(&format!(
272 "/p/{slug}/{category_slug}/{thread_id}?toast=Thread+created"
273 )))
274 }
275
276 #[tracing::instrument(skip_all)]
277 pub(in crate::routes) async fn create_reply_handler(
278 axum::extract::State(state): axum::extract::State<AppState>,
279 Path((slug, category_slug, thread_id_str)): Path<(String, String, String)>,
280 MaybeUser(session_user): MaybeUser,
281 Form(form): Form<CreateReplyForm>,
282 ) -> Result<Redirect, Response> {
283 let user = session_user
284 .ok_or_else(|| Redirect::to("/auth/login").into_response())?;
285
286 let thread_data = get_thread(&state.db, &thread_id_str).await?;
287 let community = get_community(&state.db, &slug).await?;
288
289 check_write_access(&state.db, community.id, user.user_id, community.suspended_at.is_some()).await?;
290 mt_db::mutations::ensure_membership(&state.db, user.user_id, community.id)
291 .await
292 .map_err(|e| {
293 tracing::error!(error = ?e, "db error ensuring membership");
294 StatusCode::INTERNAL_SERVER_ERROR.into_response()
295 })?;
296 check_user_post_rate(&state.db, user.user_id).await?;
297
298 if thread_data.locked {
299 return Err((StatusCode::FORBIDDEN, "This thread is locked.").into_response());
300 }
301
302 let body = validate_body(&form.body, 65536, "Body")?;
303
304 verify_quotes(&state.db, body).await?;
305
306 let (body_html, mention_ids) = resolve_and_render_mentions(
307 &state.db, body, community.id, &slug, user.user_id,
308 ).await?;
309
310 let thread_id = parse_uuid(&thread_id_str)?;
311 let post_id = mt_db::mutations::create_post(&state.db, thread_id, user.user_id, body, &body_html)
312 .await
313 .map_err(|e| {
314 tracing::error!(error = ?e, "db error creating reply");
315 StatusCode::INTERNAL_SERVER_ERROR.into_response()
316 })?;
317
318 if !mention_ids.is_empty() {
319 mt_db::mutations::insert_mentions(&state.db, post_id, &mention_ids)
320 .await
321 .map_err(|e| {
322 tracing::error!(error = ?e, "db error inserting mentions");
323 StatusCode::INTERNAL_SERVER_ERROR.into_response()
324 })?;
325 }
326
327 // Fetch link previews (best-effort)
328 fetch_and_store_link_previews(&state, body, post_id).await;
329
330 Ok(Redirect::to(&format!(
331 "/p/{slug}/{category_slug}/{thread_id_str}?toast=Reply+posted"
332 )))
333 }
334
335 // ============================================================================
336 // Thread edit/delete handlers (mod/owner only)
337 // ============================================================================
338
339 #[tracing::instrument(skip_all)]
340 pub(in crate::routes) async fn edit_thread_form(
341 axum::extract::State(state): axum::extract::State<AppState>,
342 Path((slug, category_slug, thread_id_str)): Path<(String, String, String)>,
343 session: tower_sessions::Session,
344 MaybeUser(session_user): MaybeUser,
345 ) -> Result<impl IntoResponse, Response> {
346 let csrf_token = Some(crate::csrf::get_or_create_token(&session).await);
347 let user = session_user
348 .ok_or_else(|| Redirect::to("/auth/login").into_response())?;
349
350 let thread_data = get_thread(&state.db, &thread_id_str).await?;
351 let community = get_community(&state.db, &slug).await?;
352
353 check_write_access(&state.db, community.id, user.user_id, community.suspended_at.is_some()).await?;
354
355 let role = get_role(&state.db, user.user_id, thread_data.community_id).await?;
356 if !is_mod_or_owner(&role) {
357 return Err(StatusCode::FORBIDDEN.into_response());
358 }
359
360 Ok(crate::templates::EditThreadTemplate {
361 csrf_token,
362 session_user: Some(template_user(&user, state.config.platform_admin_id)),
363 mnw_base_url: state.config.mnw_base_url.clone(),
364 community_name: thread_data.community_name,
365 community_slug: slug,
366 category_name: thread_data.category_name,
367 category_slug,
368 thread_id: thread_id_str,
369 current_title: thread_data.title,
370 })
371 }
372
373 #[tracing::instrument(skip_all)]
374 pub(in crate::routes) async fn edit_thread_handler(
375 axum::extract::State(state): axum::extract::State<AppState>,
376 Path((slug, category_slug, thread_id_str)): Path<(String, String, String)>,
377 MaybeUser(session_user): MaybeUser,
378 Form(form): Form<super::super::EditThreadForm>,
379 ) -> Result<Redirect, Response> {
380 let user = session_user
381 .ok_or_else(|| Redirect::to("/auth/login").into_response())?;
382
383 let thread_data = get_thread(&state.db, &thread_id_str).await?;
384 let community = get_community(&state.db, &slug).await?;
385
386 check_write_access(&state.db, community.id, user.user_id, community.suspended_at.is_some()).await?;
387
388 let role = get_role(&state.db, user.user_id, thread_data.community_id).await?;
389 if !is_mod_or_owner(&role) {
390 return Err(StatusCode::FORBIDDEN.into_response());
391 }
392
393 let title = validate_title(&form.title)?;
394
395 let thread_id = parse_uuid(&thread_id_str)?;
396 mt_db::mutations::update_thread_title(&state.db, thread_id, title)
397 .await
398 .map_err(|e| {
399 tracing::error!(error = ?e, "db error updating thread title");
400 StatusCode::INTERNAL_SERVER_ERROR.into_response()
401 })?;
402
403 Ok(Redirect::to(&format!(
404 "/p/{slug}/{category_slug}/{thread_id_str}?toast=Title+updated"
405 )))
406 }
407
408 #[tracing::instrument(skip_all)]
409 pub(in crate::routes) async fn delete_thread_handler(
410 axum::extract::State(state): axum::extract::State<AppState>,
411 Path((slug, category_slug, thread_id_str)): Path<(String, String, String)>,
412 MaybeUser(session_user): MaybeUser,
413 ) -> Result<Redirect, Response> {
414 let user = session_user
415 .ok_or_else(|| Redirect::to("/auth/login").into_response())?;
416
417 let thread_data = get_thread(&state.db, &thread_id_str).await?;
418 let community = get_community(&state.db, &slug).await?;
419
420 check_write_access(&state.db, community.id, user.user_id, community.suspended_at.is_some()).await?;
421
422 let role = get_role(&state.db, user.user_id, thread_data.community_id).await?;
423 if !is_mod_or_owner(&role) {
424 return Err(StatusCode::FORBIDDEN.into_response());
425 }
426
427 let thread_id = parse_uuid(&thread_id_str)?;
428 mt_db::mutations::soft_delete_thread(&state.db, thread_id)
429 .await
430 .map_err(|e| {
431 tracing::error!(error = ?e, "db error deleting thread");
432 StatusCode::INTERNAL_SERVER_ERROR.into_response()
433 })?;
434
435 log_mod_action(
436 &state.db, Some(thread_data.community_id), user.user_id,
437 ModAction::DeleteThread, Some(thread_data.author_id), Some(thread_id), None,
438 ).await;
439
440 Ok(Redirect::to(&format!(
441 "/p/{slug}/{category_slug}?toast=Thread+deleted"
442 )))
443 }
444