Skip to main content

max / makenotwork

21.0 KB · 623 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 Form,
6 extract::Path,
7 http::StatusCode,
8 response::{IntoResponse, Redirect, Response},
9 };
10 use uuid::Uuid;
11
12 use sha2::{Digest, Sha256};
13
14 use crate::AppState;
15 use crate::auth::RequireUser;
16
17 use mt_core::types::ModAction;
18
19 use super::super::{
20 CommunityScope, CreateReplyForm, CreateThreadForm, WriteScope, audit, begin_tx,
21 check_user_post_rate, check_write_access, check_write_state, commit_tx, db_error,
22 get_community, is_mod_or_owner, parse_uuid, reject_embeds_for_free_user, render_markdown,
23 render_markdown_plus, render_markdown_with_mentions, template_user, validate_body,
24 validate_title,
25 };
26 use mt_core::types::ModActor;
27 use mt_db::queries::ThreadWithBreadcrumb;
28
29 // Quote verification
30
31 pub(super) const MAX_QUOTES_PER_POST: usize = 10;
32 pub(super) const MAX_FOOTNOTES_PER_POST: usize = 10;
33
34 static QUOTE_RE: std::sync::LazyLock<regex_lite::Regex> = std::sync::LazyLock::new(|| {
35 regex_lite::Regex::new(r"\[quote:([0-9a-f\-]{36}):([0-9a-f]{8})\]").unwrap()
36 });
37
38 /// A parsed `[quote:UUID:HASH]` marker in a body.
39 #[derive(Debug, PartialEq, Eq)]
40 pub(super) struct QuoteRef<'a> {
41 pub post_id_str: &'a str,
42 pub claimed_hash: &'a str,
43 pub marker_start: usize,
44 }
45
46 /// Find every quote marker in `body`. Pure, no I/O.
47 pub(super) fn find_quote_refs(body: &str) -> Vec<QuoteRef<'_>> {
48 QUOTE_RE
49 .captures_iter(body)
50 .map(|caps| {
51 let marker = caps.get(0).unwrap();
52 QuoteRef {
53 post_id_str: caps.get(1).unwrap().as_str(),
54 claimed_hash: caps.get(2).unwrap().as_str(),
55 marker_start: marker.start(),
56 }
57 })
58 .collect()
59 }
60
61 /// Extract the `> `-prefixed lines immediately preceding the marker at
62 /// `marker_start`, strip the prefix from each, join with newlines, and trim.
63 /// Returns the empty string when no quoted lines precede the marker.
64 pub(super) fn extract_preceding_quote_text(body: &str, marker_start: usize) -> String {
65 let before_marker = &body[..marker_start];
66 let quoted_lines: Vec<&str> = before_marker
67 .lines()
68 .rev()
69 .take_while(|line| line.starts_with("> ") || line.starts_with('>'))
70 .collect::<Vec<_>>()
71 .into_iter()
72 .rev()
73 .collect();
74
75 quoted_lines
76 .iter()
77 .map(|line| {
78 line.strip_prefix("> ")
79 .unwrap_or(line.strip_prefix('>').unwrap_or(line))
80 })
81 .collect::<Vec<_>>()
82 .join("\n")
83 .trim()
84 .to_string()
85 }
86
87 /// Compute the 8-hex-char quote hash (first 4 bytes of SHA-256, hex encoded).
88 pub(super) fn compute_quote_hash(text: &str) -> String {
89 let mut hasher = Sha256::new();
90 hasher.update(text.as_bytes());
91 let hash = hasher.finalize();
92 hex::encode(&hash[..4])
93 }
94
95 /// Extract `[quote:POST_ID:HASH]` markers from markdown body and verify each.
96 /// Returns the IDs of quoted posts for attribution rendering.
97 #[tracing::instrument(skip_all)]
98 pub(super) async fn verify_quotes(
99 db: &sqlx::PgPool,
100 community_id: Uuid,
101 body: &str,
102 ) -> Result<Vec<Uuid>, Response> {
103 let refs = find_quote_refs(body);
104 if refs.len() > MAX_QUOTES_PER_POST {
105 return Err((
106 StatusCode::UNPROCESSABLE_ENTITY,
107 "Too many quotes. Maximum is 10 per post.",
108 )
109 .into_response());
110 }
111
112 let mut quoted_post_ids = Vec::new();
113
114 for q in refs {
115 let post_id = Uuid::parse_str(q.post_id_str).map_err(|_| {
116 (StatusCode::UNPROCESSABLE_ENTITY, "Invalid quote reference.").into_response()
117 })?;
118
119 let quoted_text = extract_preceding_quote_text(body, q.marker_start);
120 if quoted_text.is_empty() {
121 return Err((StatusCode::UNPROCESSABLE_ENTITY, "Empty quote text.").into_response());
122 }
123
124 let (_, original_markdown) =
125 mt_db::queries::get_post_body_markdown_in_community(db, post_id, community_id)
126 .await
127 .map_err(db_error)?
128 .ok_or_else(|| {
129 (StatusCode::UNPROCESSABLE_ENTITY, "Quoted post not found.").into_response()
130 })?;
131
132 if !original_markdown.contains(&quoted_text) {
133 return Err((
134 StatusCode::UNPROCESSABLE_ENTITY,
135 "Quote does not match original post.",
136 )
137 .into_response());
138 }
139
140 if q.claimed_hash != compute_quote_hash(&quoted_text) {
141 return Err((StatusCode::UNPROCESSABLE_ENTITY, "Quote hash mismatch.").into_response());
142 }
143
144 quoted_post_ids.push(post_id);
145 }
146
147 Ok(quoted_post_ids)
148 }
149
150 // Mention pipeline
151
152 /// Resolve mentions in a post body and return (rendered_html, mentioned_user_ids).
153 /// `author_id` is excluded from the mention list (self-mention not stored).
154 /// `allow_images` enables image embeds (Fan+ subscribers only).
155 #[tracing::instrument(skip_all)]
156 pub(super) async fn resolve_and_render_mentions(
157 db: &sqlx::PgPool,
158 body: &str,
159 community_id: Uuid,
160 community_slug: &str,
161 author_id: Uuid,
162 allow_images: bool,
163 ) -> Result<(String, Vec<Uuid>), Response> {
164 let usernames = docengine::extract_mentions(body);
165 if usernames.is_empty() {
166 let rendered = if allow_images {
167 render_markdown_plus(body)
168 } else {
169 render_markdown(body)
170 };
171 return Ok((rendered, Vec::new()));
172 }
173
174 let resolved = mt_db::queries::resolve_usernames_in_community(db, community_id, &usernames)
175 .await
176 .map_err(db_error)?;
177
178 let valid_set: std::collections::HashSet<String> = resolved.keys().cloned().collect();
179 let body_html = render_markdown_with_mentions(body, community_slug, &valid_set, allow_images);
180
181 // Collect user IDs, excluding self
182 let mention_ids: Vec<Uuid> = resolved
183 .values()
184 .copied()
185 .filter(|uid| *uid != author_id)
186 .collect();
187
188 Ok((body_html, mention_ids))
189 }
190
191 // Link preview pipeline
192
193 /// Spawn link preview fetching as a detached background task.
194 /// The HTTP response returns immediately; previews appear asynchronously.
195 /// Caps concurrent background link-preview fetches process-wide. Without it, a
196 /// burst of posts spawns unbounded outbound HTTP fetchers, each acquiring a DB
197 /// connection to store its results, a pool-exhaustion vector. Excess fetches
198 /// queue on the permit instead of running at once.
199 static LINK_PREVIEW_FETCHES: std::sync::LazyLock<tokio::sync::Semaphore> =
200 std::sync::LazyLock::new(|| tokio::sync::Semaphore::new(8));
201
202 fn spawn_link_preview_fetch(state: AppState, body: String, post_id: Uuid) {
203 tokio::spawn(async move {
204 let Ok(_permit) = LINK_PREVIEW_FETCHES.acquire().await else {
205 return; // semaphore closed (shutdown), skip
206 };
207 fetch_and_store_link_previews(&state, &body, post_id).await;
208 });
209 }
210
211 /// Extract URLs from post body, fetch OG metadata, and store previews.
212 /// Best-effort: fetch failures are logged but never block post creation.
213 #[tracing::instrument(skip_all)]
214 async fn fetch_and_store_link_previews(state: &AppState, body: &str, post_id: Uuid) {
215 let urls = crate::link_preview::extract_urls(body);
216 for url in urls {
217 match state.link_preview.fetch(&url).await {
218 Some((title, description)) => {
219 if let Err(e) = mt_db::mutations::insert_link_preview(
220 &state.db,
221 post_id,
222 &url,
223 title.as_deref(),
224 description.as_deref(),
225 )
226 .await
227 {
228 tracing::warn!(error = ?e, url = %url, "failed to insert link preview");
229 }
230 }
231 None => {
232 tracing::debug!(url = %url, "no OG metadata found");
233 }
234 }
235 }
236 }
237
238 // Thread + reply creation
239
240 #[tracing::instrument(skip_all)]
241 pub(in crate::routes) async fn create_thread_handler(
242 axum::extract::State(state): axum::extract::State<AppState>,
243 Path((slug, category_slug)): Path<(String, String)>,
244 RequireUser(user): RequireUser,
245 Form(form): Form<CreateThreadForm>,
246 ) -> Result<Redirect, Response> {
247 let community = get_community(&state.db, &slug).await?;
248
249 check_write_access(
250 &state.db,
251 community.id,
252 user.user_id,
253 community.suspended_at.is_some(),
254 )
255 .await?;
256 check_write_state(&state, &community, &user, WriteScope::NewThread).await?;
257 mt_db::mutations::ensure_membership(&state.db, user.user_id, community.id)
258 .await
259 .map_err(db_error)?;
260 check_user_post_rate(&state.db, user.user_id).await?;
261
262 let title = validate_title(&form.title)?;
263 let body = validate_body(&form.body, 65536, "Body")?;
264 let author_plus = user.perks.effective_plus();
265 if !author_plus {
266 reject_embeds_for_free_user(body)?;
267 }
268
269 let category_id = mt_db::mutations::get_category_id_by_slugs(&state.db, &slug, &category_slug)
270 .await
271 .map_err(db_error)?
272 .ok_or_else(|| StatusCode::NOT_FOUND.into_response())?;
273
274 verify_quotes(&state.db, community.id, body).await?;
275
276 let (body_html, mention_ids) = resolve_and_render_mentions(
277 &state.db,
278 body,
279 community.id,
280 &slug,
281 user.user_id,
282 author_plus,
283 )
284 .await?;
285
286 let tag_ids: Vec<uuid::Uuid> = form
287 .tags
288 .iter()
289 .filter_map(|t| uuid::Uuid::parse_str(t).ok())
290 .collect();
291
292 // Create the thread, its opening post, its @mentions, and its tags as one
293 // atomic unit. Previously these committed in separate statements, so a
294 // failure after the thread insert silently dropped the mentions (no
295 // notification) and tags (M-St1).
296 let mut tx = begin_tx(&state.db).await?;
297 let (thread_id, post_id) = mt_db::mutations::create_thread_with_op_tx(
298 &mut tx,
299 category_id,
300 user.user_id,
301 title,
302 body,
303 &body_html,
304 )
305 .await
306 .map_err(db_error)?;
307 mt_db::mutations::insert_mentions(&mut *tx, post_id, &mention_ids)
308 .await
309 .map_err(db_error)?;
310 if !tag_ids.is_empty() {
311 mt_db::mutations::set_thread_tags_tx(&mut tx, thread_id, &tag_ids)
312 .await
313 .map_err(db_error)?;
314 }
315 commit_tx(tx).await?;
316
317 // Fetch link previews in background (best-effort, never blocks response)
318 spawn_link_preview_fetch(state.clone(), body.to_string(), post_id);
319
320 Ok(Redirect::to(&format!(
321 "/p/{slug}/{category_slug}/{thread_id}?toast=Thread+created"
322 )))
323 }
324
325 #[tracing::instrument(skip_all)]
326 pub(in crate::routes) async fn create_reply_handler(
327 axum::extract::State(state): axum::extract::State<AppState>,
328 Path((slug, category_slug, thread_id_str)): Path<(String, String, String)>,
329 RequireUser(user): RequireUser,
330 Form(form): Form<CreateReplyForm>,
331 ) -> Result<Redirect, Response> {
332 let scope =
333 CommunityScope::<ThreadWithBreadcrumb>::resolve(&state.db, &slug, &thread_id_str).await?;
334 scope.require_write_access(&state.db, user.user_id).await?;
335 let CommunityScope {
336 community,
337 resource: thread_data,
338 } = scope;
339
340 check_write_state(&state, &community, &user, WriteScope::ContinueExisting).await?;
341 mt_db::mutations::ensure_membership(&state.db, user.user_id, community.id)
342 .await
343 .map_err(db_error)?;
344 check_user_post_rate(&state.db, user.user_id).await?;
345
346 if thread_data.locked {
347 return Err((StatusCode::FORBIDDEN, "This thread is locked.").into_response());
348 }
349
350 let body = validate_body(&form.body, 65536, "Body")?;
351 let author_plus = user.perks.effective_plus();
352 if !author_plus {
353 reject_embeds_for_free_user(body)?;
354 }
355
356 verify_quotes(&state.db, community.id, body).await?;
357
358 let (body_html, mention_ids) = resolve_and_render_mentions(
359 &state.db,
360 body,
361 community.id,
362 &slug,
363 user.user_id,
364 author_plus,
365 )
366 .await?;
367
368 let thread_id = parse_uuid(&thread_id_str)?;
369
370 // Create the reply and its @mentions as one atomic unit so a failure between
371 // them can't leave a committed reply whose mentions silently vanished (M-St1).
372 let mut tx = begin_tx(&state.db).await?;
373 // Conditional insert: lands only if the thread is still live+unlocked, closing
374 // the TOCTOU against the snapshot-time `locked` check above. None => a
375 // concurrent lock/soft-delete won the race.
376 let post_id =
377 mt_db::mutations::create_post_tx(&mut tx, thread_id, user.user_id, body, &body_html)
378 .await
379 .map_err(db_error)?
380 .ok_or_else(|| {
381 (
382 StatusCode::CONFLICT,
383 "This thread was locked or removed before your reply posted.",
384 )
385 .into_response()
386 })?;
387 mt_db::mutations::insert_mentions(&mut *tx, post_id, &mention_ids)
388 .await
389 .map_err(db_error)?;
390 commit_tx(tx).await?;
391
392 // Fetch link previews in background (best-effort, never blocks response)
393 spawn_link_preview_fetch(state.clone(), body.to_string(), post_id);
394
395 Ok(Redirect::to(&format!(
396 "/p/{slug}/{category_slug}/{thread_id_str}?toast=Reply+posted"
397 )))
398 }
399
400 // Thread edit/delete handlers (mod/owner only)
401
402 #[tracing::instrument(skip_all)]
403 pub(in crate::routes) async fn edit_thread_form(
404 axum::extract::State(state): axum::extract::State<AppState>,
405 Path((slug, category_slug, thread_id_str)): Path<(String, String, String)>,
406 session: tower_sessions::Session,
407 RequireUser(user): RequireUser,
408 ) -> Result<impl IntoResponse, Response> {
409 let csrf_token = Some(crate::csrf::get_or_create_token(&session).await?);
410 let scope =
411 CommunityScope::<ThreadWithBreadcrumb>::resolve(&state.db, &slug, &thread_id_str).await?;
412 scope.require_write_access(&state.db, user.user_id).await?;
413 let role = scope.role(&state.db, user.user_id).await?;
414 if !is_mod_or_owner(role) {
415 return Err(StatusCode::FORBIDDEN.into_response());
416 }
417 let thread_data = scope.resource;
418
419 Ok(crate::templates::EditThreadTemplate {
420 csrf_token,
421 session_user: Some(template_user(&user, state.config.platform_admin_id)),
422 mnw_base_url: state.config.mnw_base_url.clone(),
423 community_name: thread_data.community_name,
424 community_slug: slug,
425 category_name: thread_data.category_name,
426 category_slug,
427 thread_id: thread_id_str,
428 current_title: thread_data.title,
429 })
430 }
431
432 #[tracing::instrument(skip_all)]
433 pub(in crate::routes) async fn edit_thread_handler(
434 axum::extract::State(state): axum::extract::State<AppState>,
435 Path((slug, category_slug, thread_id_str)): Path<(String, String, String)>,
436 RequireUser(user): RequireUser,
437 Form(form): Form<super::super::EditThreadForm>,
438 ) -> Result<Redirect, Response> {
439 let scope =
440 CommunityScope::<ThreadWithBreadcrumb>::resolve(&state.db, &slug, &thread_id_str).await?;
441 scope.require_write_access(&state.db, user.user_id).await?;
442 let role = scope.role(&state.db, user.user_id).await?;
443 if !is_mod_or_owner(role) {
444 return Err(StatusCode::FORBIDDEN.into_response());
445 }
446
447 let title = validate_title(&form.title)?;
448
449 let thread_id = parse_uuid(&thread_id_str)?;
450 mt_db::mutations::update_thread_title(&state.db, thread_id, title)
451 .await
452 .map_err(db_error)?;
453
454 Ok(Redirect::to(&format!(
455 "/p/{slug}/{category_slug}/{thread_id_str}?toast=Title+updated"
456 )))
457 }
458
459 #[tracing::instrument(skip_all)]
460 pub(in crate::routes) async fn delete_thread_handler(
461 axum::extract::State(state): axum::extract::State<AppState>,
462 Path((slug, category_slug, thread_id_str)): Path<(String, String, String)>,
463 RequireUser(user): RequireUser,
464 ) -> Result<Redirect, Response> {
465 let scope =
466 CommunityScope::<ThreadWithBreadcrumb>::resolve(&state.db, &slug, &thread_id_str).await?;
467 scope.require_write_access(&state.db, user.user_id).await?;
468 let role = scope.role(&state.db, user.user_id).await?;
469 if !is_mod_or_owner(role) {
470 return Err(StatusCode::FORBIDDEN.into_response());
471 }
472 let thread_data = scope.resource;
473
474 let thread_id = parse_uuid(&thread_id_str)?;
475 let mut tx = begin_tx(&state.db).await?;
476 mt_db::mutations::soft_delete_thread(&mut *tx, thread_id)
477 .await
478 .map_err(db_error)?;
479 audit(
480 &mut tx,
481 Some(thread_data.community_id),
482 ModActor::User(user.user_id),
483 ModAction::DeleteThread,
484 Some(thread_data.author_id),
485 Some(thread_id),
486 None,
487 )
488 .await?;
489 commit_tx(tx).await?;
490
491 Ok(Redirect::to(&format!(
492 "/p/{slug}/{category_slug}?toast=Thread+deleted"
493 )))
494 }
495
496 #[cfg(test)]
497 mod quote_tests {
498 use super::*;
499
500 #[test]
501 fn hash_is_eight_hex_chars() {
502 let h = compute_quote_hash("hello world");
503 assert_eq!(h.len(), 8, "hash must be 8 hex chars, got: {h}");
504 assert!(
505 h.chars().all(|c| c.is_ascii_hexdigit()),
506 "non-hex char in {h}"
507 );
508 }
509
510 #[test]
511 fn hash_is_stable_for_same_input() {
512 assert_eq!(compute_quote_hash("abc"), compute_quote_hash("abc"));
513 }
514
515 #[test]
516 fn hash_differs_for_different_input() {
517 assert_ne!(compute_quote_hash("abc"), compute_quote_hash("abd"));
518 }
519
520 #[test]
521 fn hash_takes_first_four_bytes_only() {
522 // Pins `&hash[..4]`, known SHA-256("a") prefix is 0xca978112.
523 assert_eq!(compute_quote_hash("a"), "ca978112");
524 }
525
526 #[test]
527 fn find_quote_refs_finds_zero_markers() {
528 assert!(find_quote_refs("body with no markers").is_empty());
529 }
530
531 #[test]
532 fn find_quote_refs_extracts_post_id_and_hash() {
533 let body = "> hi\n[quote:11111111-2222-3333-4444-555555555555:abcd1234]";
534 let refs = find_quote_refs(body);
535 assert_eq!(refs.len(), 1);
536 assert_eq!(refs[0].post_id_str, "11111111-2222-3333-4444-555555555555");
537 assert_eq!(refs[0].claimed_hash, "abcd1234");
538 // marker_start points at the `[`.
539 assert_eq!(&body[refs[0].marker_start..=refs[0].marker_start], "[");
540 }
541
542 #[test]
543 fn find_quote_refs_finds_multiple_distinct_markers() {
544 let body = "[quote:11111111-2222-3333-4444-555555555555:aaaaaaaa] and [quote:66666666-7777-8888-9999-000000000000:bbbbbbbb]";
545 let refs = find_quote_refs(body);
546 assert_eq!(refs.len(), 2);
547 assert_eq!(refs[0].claimed_hash, "aaaaaaaa");
548 assert_eq!(refs[1].claimed_hash, "bbbbbbbb");
549 assert!(refs[0].marker_start < refs[1].marker_start);
550 }
551
552 #[test]
553 fn find_quote_refs_rejects_malformed_marker() {
554 // Hash too short → regex doesn't match.
555 let body = "[quote:11111111-2222-3333-4444-555555555555:abc]";
556 assert!(find_quote_refs(body).is_empty());
557 // UUID wrong length → no match.
558 let body2 = "[quote:short-uuid:abcd1234]";
559 assert!(find_quote_refs(body2).is_empty());
560 }
561
562 #[test]
563 fn extract_single_quoted_line() {
564 let body = "> hello\nMARKER";
565 let marker = body.find("MARKER").unwrap();
566 assert_eq!(extract_preceding_quote_text(body, marker), "hello");
567 }
568
569 #[test]
570 fn extract_multi_line_preserves_order() {
571 let body = "> line one\n> line two\n> line three\nMARKER";
572 let marker = body.find("MARKER").unwrap();
573 assert_eq!(
574 extract_preceding_quote_text(body, marker),
575 "line one\nline two\nline three"
576 );
577 }
578
579 #[test]
580 fn extract_handles_bare_gt_prefix_without_space() {
581 // Lines like `>foo` (no space) must also be stripped.
582 let body = ">no-space\n> with space\nMARKER";
583 let marker = body.find("MARKER").unwrap();
584 assert_eq!(
585 extract_preceding_quote_text(body, marker),
586 "no-space\nwith space"
587 );
588 }
589
590 #[test]
591 fn extract_stops_at_first_non_quote_line() {
592 // Quote lines only count if they are *immediately* before the marker.
593 // The earlier "> ignored" must NOT be included because a plain line
594 // breaks the run.
595 let body = "> ignored\nplain text\n> kept\nMARKER";
596 let marker = body.find("MARKER").unwrap();
597 assert_eq!(extract_preceding_quote_text(body, marker), "kept");
598 }
599
600 #[test]
601 fn extract_returns_empty_when_no_preceding_quote() {
602 let body = "regular text\nMARKER";
603 let marker = body.find("MARKER").unwrap();
604 assert_eq!(extract_preceding_quote_text(body, marker), "");
605 }
606
607 #[test]
608 fn extract_returns_empty_when_marker_is_at_start() {
609 let body = "MARKER\nrest";
610 let marker = body.find("MARKER").unwrap();
611 assert_eq!(extract_preceding_quote_text(body, marker), "");
612 }
613
614 #[test]
615 fn extract_trims_trailing_blank_quote_lines() {
616 // A `>` with only the prefix becomes an empty line; final .trim()
617 // strips surrounding whitespace.
618 let body = "> real content\n>\nMARKER";
619 let marker = body.find("MARKER").unwrap();
620 assert_eq!(extract_preceding_quote_text(body, marker), "real content");
621 }
622 }
623