Skip to main content

max / makenotwork

22.5 KB · 625 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, check_write_state, get_community, get_thread,
21 is_mod_or_owner, log_mod_action, parse_uuid, reject_embeds_for_free_user, render_markdown,
22 render_markdown_plus, render_markdown_with_mentions, template_user, validate_body,
23 validate_title, get_role, WriteScope,
24 CreateReplyForm, CreateThreadForm,
25 };
26
27 // ============================================================================
28 // Quote verification
29 // ============================================================================
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| line.strip_prefix("> ").unwrap_or(line.strip_prefix('>').unwrap_or(line)))
78 .collect::<Vec<_>>()
79 .join("\n")
80 .trim()
81 .to_string()
82 }
83
84 /// Compute the 8-hex-char quote hash (first 4 bytes of SHA-256, hex encoded).
85 pub(super) fn compute_quote_hash(text: &str) -> String {
86 let mut hasher = Sha256::new();
87 hasher.update(text.as_bytes());
88 let hash = hasher.finalize();
89 hex::encode(&hash[..4])
90 }
91
92 /// Extract `[quote:POST_ID:HASH]` markers from markdown body and verify each.
93 /// Returns the IDs of quoted posts for attribution rendering.
94 #[tracing::instrument(skip_all)]
95 pub(super) async fn verify_quotes(
96 db: &sqlx::PgPool,
97 body: &str,
98 ) -> Result<Vec<Uuid>, Response> {
99 let refs = find_quote_refs(body);
100 if refs.len() > MAX_QUOTES_PER_POST {
101 return Err((
102 StatusCode::UNPROCESSABLE_ENTITY,
103 "Too many quotes. Maximum is 10 per post.",
104 )
105 .into_response());
106 }
107
108 let mut quoted_post_ids = Vec::new();
109
110 for q in refs {
111 let post_id = Uuid::parse_str(q.post_id_str)
112 .map_err(|_| (StatusCode::UNPROCESSABLE_ENTITY, "Invalid quote reference.").into_response())?;
113
114 let quoted_text = extract_preceding_quote_text(body, q.marker_start);
115 if quoted_text.is_empty() {
116 return Err((StatusCode::UNPROCESSABLE_ENTITY, "Empty quote text.").into_response());
117 }
118
119 let (_, original_markdown) = mt_db::queries::get_post_body_markdown(db, post_id)
120 .await
121 .map_err(|e| {
122 tracing::error!(error = ?e, "db error fetching post for quote verification");
123 StatusCode::INTERNAL_SERVER_ERROR.into_response()
124 })?
125 .ok_or_else(|| (StatusCode::UNPROCESSABLE_ENTITY, "Quoted post not found.").into_response())?;
126
127 if !original_markdown.contains(&quoted_text) {
128 return Err((StatusCode::UNPROCESSABLE_ENTITY, "Quote does not match original post.").into_response());
129 }
130
131 if q.claimed_hash != compute_quote_hash(&quoted_text) {
132 return Err((StatusCode::UNPROCESSABLE_ENTITY, "Quote hash mismatch.").into_response());
133 }
134
135 quoted_post_ids.push(post_id);
136 }
137
138 Ok(quoted_post_ids)
139 }
140
141 // ============================================================================
142 // Mention pipeline
143 // ============================================================================
144
145 /// Resolve mentions in a post body and return (rendered_html, mentioned_user_ids).
146 /// `author_id` is excluded from the mention list (self-mention not stored).
147 /// `allow_images` enables image embeds (Fan+ subscribers only).
148 #[tracing::instrument(skip_all)]
149 pub(super) async fn resolve_and_render_mentions(
150 db: &sqlx::PgPool,
151 body: &str,
152 community_id: Uuid,
153 community_slug: &str,
154 author_id: Uuid,
155 allow_images: bool,
156 ) -> Result<(String, Vec<Uuid>), Response> {
157 let usernames = docengine::extract_mentions(body);
158 if usernames.is_empty() {
159 let rendered = if allow_images {
160 render_markdown_plus(body)
161 } else {
162 render_markdown(body)
163 };
164 return Ok((rendered, Vec::new()));
165 }
166
167 let resolved = mt_db::queries::resolve_usernames_in_community(db, community_id, &usernames)
168 .await
169 .map_err(|e| {
170 tracing::error!(error = ?e, "db error resolving mention usernames");
171 StatusCode::INTERNAL_SERVER_ERROR.into_response()
172 })?;
173
174 let valid_set: std::collections::HashSet<String> = resolved.keys().cloned().collect();
175 let body_html = render_markdown_with_mentions(body, community_slug, &valid_set, allow_images);
176
177 // Collect user IDs, excluding self
178 let mention_ids: Vec<Uuid> = resolved
179 .values()
180 .copied()
181 .filter(|uid| *uid != author_id)
182 .collect();
183
184 Ok((body_html, mention_ids))
185 }
186
187 // ============================================================================
188 // Link preview pipeline
189 // ============================================================================
190
191 /// Spawn link preview fetching as a detached background task.
192 /// The HTTP response returns immediately; previews appear asynchronously.
193 fn spawn_link_preview_fetch(state: AppState, body: String, post_id: Uuid) {
194 tokio::spawn(async move {
195 fetch_and_store_link_previews(&state, &body, post_id).await;
196 });
197 }
198
199 /// Extract URLs from post body, fetch OG metadata, and store previews.
200 /// Best-effort: fetch failures are logged but never block post creation.
201 #[tracing::instrument(skip_all)]
202 async fn fetch_and_store_link_previews(state: &AppState, body: &str, post_id: Uuid) {
203 let urls = crate::link_preview::extract_urls(body);
204 for url in urls {
205 match state.link_preview.fetch(&url).await {
206 Some((title, description)) => {
207 if let Err(e) = mt_db::mutations::insert_link_preview(
208 &state.db,
209 post_id,
210 &url,
211 title.as_deref(),
212 description.as_deref(),
213 )
214 .await
215 {
216 tracing::warn!(error = ?e, url = %url, "failed to insert link preview");
217 }
218 }
219 None => {
220 tracing::debug!(url = %url, "no OG metadata found");
221 }
222 }
223 }
224 }
225
226 // ============================================================================
227 // Thread + reply creation
228 // ============================================================================
229
230 #[tracing::instrument(skip_all)]
231 pub(in crate::routes) async fn create_thread_handler(
232 axum::extract::State(state): axum::extract::State<AppState>,
233 Path((slug, category_slug)): Path<(String, String)>,
234 MaybeUser(session_user): MaybeUser,
235 Form(form): Form<CreateThreadForm>,
236 ) -> Result<Redirect, Response> {
237 let user = session_user
238 .ok_or_else(|| Redirect::to("/auth/login").into_response())?;
239
240 let community = get_community(&state.db, &slug).await?;
241
242 check_write_access(&state.db, community.id, user.user_id, community.suspended_at.is_some()).await?;
243 check_write_state(&state, &community, &user, WriteScope::NewThread).await?;
244 mt_db::mutations::ensure_membership(&state.db, user.user_id, community.id)
245 .await
246 .map_err(|e| {
247 tracing::error!(error = ?e, "db error ensuring membership");
248 StatusCode::INTERNAL_SERVER_ERROR.into_response()
249 })?;
250 check_user_post_rate(&state.db, user.user_id).await?;
251
252 let title = validate_title(&form.title)?;
253 let body = validate_body(&form.body, 65536, "Body")?;
254 let author_plus = user.perks.effective_plus();
255 if !author_plus {
256 reject_embeds_for_free_user(body)?;
257 }
258
259 let category_id = mt_db::mutations::get_category_id_by_slugs(&state.db, &slug, &category_slug)
260 .await
261 .map_err(|e| {
262 tracing::error!(error = ?e, "db error looking up category");
263 StatusCode::INTERNAL_SERVER_ERROR.into_response()
264 })?
265 .ok_or_else(|| StatusCode::NOT_FOUND.into_response())?;
266
267 verify_quotes(&state.db, body).await?;
268
269 let (body_html, mention_ids) = resolve_and_render_mentions(
270 &state.db, body, community.id, &slug, user.user_id, author_plus,
271 ).await?;
272
273 let thread_id = mt_db::mutations::create_thread(&state.db, category_id, user.user_id, title)
274 .await
275 .map_err(|e| {
276 tracing::error!(error = ?e, "db error creating thread");
277 StatusCode::INTERNAL_SERVER_ERROR.into_response()
278 })?;
279
280 let post_id = mt_db::mutations::create_post(&state.db, thread_id, user.user_id, body, &body_html, false)
281 .await
282 .map_err(|e| {
283 tracing::error!(error = ?e, "db error creating post");
284 StatusCode::INTERNAL_SERVER_ERROR.into_response()
285 })?;
286
287 if !mention_ids.is_empty() {
288 mt_db::mutations::insert_mentions(&state.db, post_id, &mention_ids)
289 .await
290 .map_err(|e| {
291 tracing::error!(error = ?e, "db error inserting mentions");
292 StatusCode::INTERNAL_SERVER_ERROR.into_response()
293 })?;
294 }
295
296 // Fetch link previews in background (best-effort, never blocks response)
297 spawn_link_preview_fetch(state.clone(), body.to_string(), post_id);
298
299 // Save tags if any were selected
300 if !form.tags.is_empty() {
301 let tag_ids: Vec<uuid::Uuid> = form
302 .tags
303 .iter()
304 .filter_map(|t| uuid::Uuid::parse_str(t).ok())
305 .collect();
306 if !tag_ids.is_empty() {
307 mt_db::mutations::set_thread_tags(&state.db, thread_id, &tag_ids)
308 .await
309 .map_err(|e| {
310 tracing::error!(error = ?e, "db error setting thread tags");
311 StatusCode::INTERNAL_SERVER_ERROR.into_response()
312 })?;
313 }
314 }
315
316 Ok(Redirect::to(&format!(
317 "/p/{slug}/{category_slug}/{thread_id}?toast=Thread+created"
318 )))
319 }
320
321 #[tracing::instrument(skip_all)]
322 pub(in crate::routes) async fn create_reply_handler(
323 axum::extract::State(state): axum::extract::State<AppState>,
324 Path((slug, category_slug, thread_id_str)): Path<(String, String, String)>,
325 MaybeUser(session_user): MaybeUser,
326 Form(form): Form<CreateReplyForm>,
327 ) -> Result<Redirect, Response> {
328 let user = session_user
329 .ok_or_else(|| Redirect::to("/auth/login").into_response())?;
330
331 let thread_data = get_thread(&state.db, &thread_id_str).await?;
332 let community = get_community(&state.db, &slug).await?;
333
334 check_write_access(&state.db, community.id, user.user_id, community.suspended_at.is_some()).await?;
335 check_write_state(&state, &community, &user, WriteScope::ContinueExisting).await?;
336 mt_db::mutations::ensure_membership(&state.db, user.user_id, community.id)
337 .await
338 .map_err(|e| {
339 tracing::error!(error = ?e, "db error ensuring membership");
340 StatusCode::INTERNAL_SERVER_ERROR.into_response()
341 })?;
342 check_user_post_rate(&state.db, user.user_id).await?;
343
344 if thread_data.locked {
345 return Err((StatusCode::FORBIDDEN, "This thread is locked.").into_response());
346 }
347
348 let body = validate_body(&form.body, 65536, "Body")?;
349 let author_plus = user.perks.effective_plus();
350 if !author_plus {
351 reject_embeds_for_free_user(body)?;
352 }
353
354 verify_quotes(&state.db, body).await?;
355
356 let (body_html, mention_ids) = resolve_and_render_mentions(
357 &state.db, body, community.id, &slug, user.user_id, author_plus,
358 ).await?;
359
360 let thread_id = parse_uuid(&thread_id_str)?;
361 let post_id = mt_db::mutations::create_post(&state.db, thread_id, user.user_id, body, &body_html, true)
362 .await
363 .map_err(|e| {
364 tracing::error!(error = ?e, "db error creating reply");
365 StatusCode::INTERNAL_SERVER_ERROR.into_response()
366 })?;
367
368 if !mention_ids.is_empty() {
369 mt_db::mutations::insert_mentions(&state.db, post_id, &mention_ids)
370 .await
371 .map_err(|e| {
372 tracing::error!(error = ?e, "db error inserting mentions");
373 StatusCode::INTERNAL_SERVER_ERROR.into_response()
374 })?;
375 }
376
377 // Fetch link previews in background (best-effort, never blocks response)
378 spawn_link_preview_fetch(state.clone(), body.to_string(), post_id);
379
380 Ok(Redirect::to(&format!(
381 "/p/{slug}/{category_slug}/{thread_id_str}?toast=Reply+posted"
382 )))
383 }
384
385 // ============================================================================
386 // Thread edit/delete handlers (mod/owner only)
387 // ============================================================================
388
389 #[tracing::instrument(skip_all)]
390 pub(in crate::routes) async fn edit_thread_form(
391 axum::extract::State(state): axum::extract::State<AppState>,
392 Path((slug, category_slug, thread_id_str)): Path<(String, String, String)>,
393 session: tower_sessions::Session,
394 MaybeUser(session_user): MaybeUser,
395 ) -> Result<impl IntoResponse, Response> {
396 let csrf_token = Some(crate::csrf::get_or_create_token(&session).await);
397 let user = session_user
398 .ok_or_else(|| Redirect::to("/auth/login").into_response())?;
399
400 let thread_data = get_thread(&state.db, &thread_id_str).await?;
401 let community = get_community(&state.db, &slug).await?;
402
403 check_write_access(&state.db, community.id, user.user_id, community.suspended_at.is_some()).await?;
404
405 let role = get_role(&state.db, user.user_id, thread_data.community_id).await?;
406 if !is_mod_or_owner(&role) {
407 return Err(StatusCode::FORBIDDEN.into_response());
408 }
409
410 Ok(crate::templates::EditThreadTemplate {
411 csrf_token,
412 session_user: Some(template_user(&user, state.config.platform_admin_id)),
413 mnw_base_url: state.config.mnw_base_url.clone(),
414 community_name: thread_data.community_name,
415 community_slug: slug,
416 category_name: thread_data.category_name,
417 category_slug,
418 thread_id: thread_id_str,
419 current_title: thread_data.title,
420 })
421 }
422
423 #[tracing::instrument(skip_all)]
424 pub(in crate::routes) async fn edit_thread_handler(
425 axum::extract::State(state): axum::extract::State<AppState>,
426 Path((slug, category_slug, thread_id_str)): Path<(String, String, String)>,
427 MaybeUser(session_user): MaybeUser,
428 Form(form): Form<super::super::EditThreadForm>,
429 ) -> Result<Redirect, Response> {
430 let user = session_user
431 .ok_or_else(|| Redirect::to("/auth/login").into_response())?;
432
433 let thread_data = get_thread(&state.db, &thread_id_str).await?;
434 let community = get_community(&state.db, &slug).await?;
435
436 check_write_access(&state.db, community.id, user.user_id, community.suspended_at.is_some()).await?;
437
438 let role = get_role(&state.db, user.user_id, thread_data.community_id).await?;
439 if !is_mod_or_owner(&role) {
440 return Err(StatusCode::FORBIDDEN.into_response());
441 }
442
443 let title = validate_title(&form.title)?;
444
445 let thread_id = parse_uuid(&thread_id_str)?;
446 mt_db::mutations::update_thread_title(&state.db, thread_id, title)
447 .await
448 .map_err(|e| {
449 tracing::error!(error = ?e, "db error updating thread title");
450 StatusCode::INTERNAL_SERVER_ERROR.into_response()
451 })?;
452
453 Ok(Redirect::to(&format!(
454 "/p/{slug}/{category_slug}/{thread_id_str}?toast=Title+updated"
455 )))
456 }
457
458 #[tracing::instrument(skip_all)]
459 pub(in crate::routes) async fn delete_thread_handler(
460 axum::extract::State(state): axum::extract::State<AppState>,
461 Path((slug, category_slug, thread_id_str)): Path<(String, String, String)>,
462 MaybeUser(session_user): MaybeUser,
463 ) -> Result<Redirect, Response> {
464 let user = session_user
465 .ok_or_else(|| Redirect::to("/auth/login").into_response())?;
466
467 let thread_data = get_thread(&state.db, &thread_id_str).await?;
468 let community = get_community(&state.db, &slug).await?;
469
470 check_write_access(&state.db, community.id, user.user_id, community.suspended_at.is_some()).await?;
471
472 let role = get_role(&state.db, user.user_id, thread_data.community_id).await?;
473 if !is_mod_or_owner(&role) {
474 return Err(StatusCode::FORBIDDEN.into_response());
475 }
476
477 let thread_id = parse_uuid(&thread_id_str)?;
478 mt_db::mutations::soft_delete_thread(&state.db, thread_id)
479 .await
480 .map_err(|e| {
481 tracing::error!(error = ?e, "db error deleting thread");
482 StatusCode::INTERNAL_SERVER_ERROR.into_response()
483 })?;
484
485 log_mod_action(
486 &state.db, Some(thread_data.community_id), user.user_id,
487 ModAction::DeleteThread, Some(thread_data.author_id), Some(thread_id), None,
488 ).await;
489
490 Ok(Redirect::to(&format!(
491 "/p/{slug}/{category_slug}?toast=Thread+deleted"
492 )))
493 }
494
495 #[cfg(test)]
496 mod quote_tests {
497 use super::*;
498
499 // ── compute_quote_hash ──
500
501 #[test]
502 fn hash_is_eight_hex_chars() {
503 let h = compute_quote_hash("hello world");
504 assert_eq!(h.len(), 8, "hash must be 8 hex chars, got: {h}");
505 assert!(h.chars().all(|c| c.is_ascii_hexdigit()), "non-hex char in {h}");
506 }
507
508 #[test]
509 fn hash_is_stable_for_same_input() {
510 assert_eq!(compute_quote_hash("abc"), compute_quote_hash("abc"));
511 }
512
513 #[test]
514 fn hash_differs_for_different_input() {
515 assert_ne!(compute_quote_hash("abc"), compute_quote_hash("abd"));
516 }
517
518 #[test]
519 fn hash_takes_first_four_bytes_only() {
520 // Pins `&hash[..4]` — known SHA-256("a") prefix is 0xca978112.
521 assert_eq!(compute_quote_hash("a"), "ca978112");
522 }
523
524 // ── find_quote_refs ──
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 + 1], "[");
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 // ── extract_preceding_quote_text ──
563
564 #[test]
565 fn extract_single_quoted_line() {
566 let body = "> hello\nMARKER";
567 let marker = body.find("MARKER").unwrap();
568 assert_eq!(extract_preceding_quote_text(body, marker), "hello");
569 }
570
571 #[test]
572 fn extract_multi_line_preserves_order() {
573 let body = "> line one\n> line two\n> line three\nMARKER";
574 let marker = body.find("MARKER").unwrap();
575 assert_eq!(
576 extract_preceding_quote_text(body, marker),
577 "line one\nline two\nline three"
578 );
579 }
580
581 #[test]
582 fn extract_handles_bare_gt_prefix_without_space() {
583 // Lines like `>foo` (no space) must also be stripped.
584 let body = ">no-space\n> with space\nMARKER";
585 let marker = body.find("MARKER").unwrap();
586 assert_eq!(
587 extract_preceding_quote_text(body, marker),
588 "no-space\nwith space"
589 );
590 }
591
592 #[test]
593 fn extract_stops_at_first_non_quote_line() {
594 // Quote lines only count if they are *immediately* before the marker.
595 // The earlier "> ignored" must NOT be included because a plain line
596 // breaks the run.
597 let body = "> ignored\nplain text\n> kept\nMARKER";
598 let marker = body.find("MARKER").unwrap();
599 assert_eq!(extract_preceding_quote_text(body, marker), "kept");
600 }
601
602 #[test]
603 fn extract_returns_empty_when_no_preceding_quote() {
604 let body = "regular text\nMARKER";
605 let marker = body.find("MARKER").unwrap();
606 assert_eq!(extract_preceding_quote_text(body, marker), "");
607 }
608
609 #[test]
610 fn extract_returns_empty_when_marker_is_at_start() {
611 let body = "MARKER\nrest";
612 let marker = body.find("MARKER").unwrap();
613 assert_eq!(extract_preceding_quote_text(body, marker), "");
614 }
615
616 #[test]
617 fn extract_trims_trailing_blank_quote_lines() {
618 // A `>` with only the prefix becomes an empty line; final .trim()
619 // strips surrounding whitespace.
620 let body = "> real content\n>\nMARKER";
621 let marker = body.find("MARKER").unwrap();
622 assert_eq!(extract_preceding_quote_text(body, marker), "real content");
623 }
624 }
625