//! Post creation handlers, thread creation, replies, and supporting pipelines //! (quote verification, mention resolution, link preview fetching). use axum::{ Form, extract::Path, http::StatusCode, response::{IntoResponse, Redirect, Response}, }; use uuid::Uuid; use sha2::{Digest, Sha256}; use crate::AppState; use crate::auth::RequireUser; use mt_core::types::ModAction; use super::super::{ CommunityScope, CreateReplyForm, CreateThreadForm, WriteScope, audit, begin_tx, check_user_post_rate, check_write_access, check_write_state, commit_tx, db_error, get_community, is_mod_or_owner, parse_uuid, reject_embeds_for_free_user, render_markdown, render_markdown_plus, render_markdown_with_mentions, template_user, validate_body, validate_title, }; use mt_core::types::ModActor; use mt_db::queries::ThreadWithBreadcrumb; // Quote verification pub(super) const MAX_QUOTES_PER_POST: usize = 10; pub(super) const MAX_FOOTNOTES_PER_POST: usize = 10; static QUOTE_RE: std::sync::LazyLock = std::sync::LazyLock::new(|| { regex_lite::Regex::new(r"\[quote:([0-9a-f\-]{36}):([0-9a-f]{8})\]").unwrap() }); /// A parsed `[quote:UUID:HASH]` marker in a body. #[derive(Debug, PartialEq, Eq)] pub(super) struct QuoteRef<'a> { pub post_id_str: &'a str, pub claimed_hash: &'a str, pub marker_start: usize, } /// Find every quote marker in `body`. Pure, no I/O. pub(super) fn find_quote_refs(body: &str) -> Vec> { QUOTE_RE .captures_iter(body) .map(|caps| { let marker = caps.get(0).unwrap(); QuoteRef { post_id_str: caps.get(1).unwrap().as_str(), claimed_hash: caps.get(2).unwrap().as_str(), marker_start: marker.start(), } }) .collect() } /// Extract the `> `-prefixed lines immediately preceding the marker at /// `marker_start`, strip the prefix from each, join with newlines, and trim. /// Returns the empty string when no quoted lines precede the marker. pub(super) fn extract_preceding_quote_text(body: &str, marker_start: usize) -> String { let before_marker = &body[..marker_start]; let quoted_lines: Vec<&str> = before_marker .lines() .rev() .take_while(|line| line.starts_with("> ") || line.starts_with('>')) .collect::>() .into_iter() .rev() .collect(); quoted_lines .iter() .map(|line| { line.strip_prefix("> ") .unwrap_or(line.strip_prefix('>').unwrap_or(line)) }) .collect::>() .join("\n") .trim() .to_string() } /// Compute the 8-hex-char quote hash (first 4 bytes of SHA-256, hex encoded). pub(super) fn compute_quote_hash(text: &str) -> String { let mut hasher = Sha256::new(); hasher.update(text.as_bytes()); let hash = hasher.finalize(); hex::encode(&hash[..4]) } /// Extract `[quote:POST_ID:HASH]` markers from markdown body and verify each. /// Returns the IDs of quoted posts for attribution rendering. #[tracing::instrument(skip_all)] pub(super) async fn verify_quotes( db: &sqlx::PgPool, community_id: Uuid, body: &str, ) -> Result, Response> { let refs = find_quote_refs(body); if refs.len() > MAX_QUOTES_PER_POST { return Err(( StatusCode::UNPROCESSABLE_ENTITY, "Too many quotes. Maximum is 10 per post.", ) .into_response()); } let mut quoted_post_ids = Vec::new(); for q in refs { let post_id = Uuid::parse_str(q.post_id_str).map_err(|_| { (StatusCode::UNPROCESSABLE_ENTITY, "Invalid quote reference.").into_response() })?; let quoted_text = extract_preceding_quote_text(body, q.marker_start); if quoted_text.is_empty() { return Err((StatusCode::UNPROCESSABLE_ENTITY, "Empty quote text.").into_response()); } let (_, original_markdown) = mt_db::queries::get_post_body_markdown_in_community(db, post_id, community_id) .await .map_err(db_error)? .ok_or_else(|| { (StatusCode::UNPROCESSABLE_ENTITY, "Quoted post not found.").into_response() })?; if !original_markdown.contains("ed_text) { return Err(( StatusCode::UNPROCESSABLE_ENTITY, "Quote does not match original post.", ) .into_response()); } if q.claimed_hash != compute_quote_hash("ed_text) { return Err((StatusCode::UNPROCESSABLE_ENTITY, "Quote hash mismatch.").into_response()); } quoted_post_ids.push(post_id); } Ok(quoted_post_ids) } // Mention pipeline /// Resolve mentions in a post body and return (rendered_html, mentioned_user_ids). /// `author_id` is excluded from the mention list (self-mention not stored). /// `allow_images` enables image embeds (Fan+ subscribers only). #[tracing::instrument(skip_all)] pub(super) async fn resolve_and_render_mentions( db: &sqlx::PgPool, body: &str, community_id: Uuid, community_slug: &str, author_id: Uuid, allow_images: bool, ) -> Result<(String, Vec), Response> { let usernames = docengine::extract_mentions(body); if usernames.is_empty() { let rendered = if allow_images { render_markdown_plus(body) } else { render_markdown(body) }; return Ok((rendered, Vec::new())); } let resolved = mt_db::queries::resolve_usernames_in_community(db, community_id, &usernames) .await .map_err(db_error)?; let valid_set: std::collections::HashSet = resolved.keys().cloned().collect(); let body_html = render_markdown_with_mentions(body, community_slug, &valid_set, allow_images); // Collect user IDs, excluding self let mention_ids: Vec = resolved .values() .copied() .filter(|uid| *uid != author_id) .collect(); Ok((body_html, mention_ids)) } // Link preview pipeline /// Spawn link preview fetching as a detached background task. /// The HTTP response returns immediately; previews appear asynchronously. /// Caps concurrent background link-preview fetches process-wide. Without it, a /// burst of posts spawns unbounded outbound HTTP fetchers, each acquiring a DB /// connection to store its results, a pool-exhaustion vector. Excess fetches /// queue on the permit instead of running at once. static LINK_PREVIEW_FETCHES: std::sync::LazyLock = std::sync::LazyLock::new(|| tokio::sync::Semaphore::new(8)); fn spawn_link_preview_fetch(state: AppState, body: String, post_id: Uuid) { tokio::spawn(async move { let Ok(_permit) = LINK_PREVIEW_FETCHES.acquire().await else { return; // semaphore closed (shutdown), skip }; fetch_and_store_link_previews(&state, &body, post_id).await; }); } /// Extract URLs from post body, fetch OG metadata, and store previews. /// Best-effort: fetch failures are logged but never block post creation. #[tracing::instrument(skip_all)] async fn fetch_and_store_link_previews(state: &AppState, body: &str, post_id: Uuid) { let urls = crate::link_preview::extract_urls(body); for url in urls { match state.link_preview.fetch(&url).await { Some((title, description)) => { if let Err(e) = mt_db::mutations::insert_link_preview( &state.db, post_id, &url, title.as_deref(), description.as_deref(), ) .await { tracing::warn!(error = ?e, url = %url, "failed to insert link preview"); } } None => { tracing::debug!(url = %url, "no OG metadata found"); } } } } // Thread + reply creation #[tracing::instrument(skip_all)] pub(in crate::routes) async fn create_thread_handler( axum::extract::State(state): axum::extract::State, Path((slug, category_slug)): Path<(String, String)>, RequireUser(user): RequireUser, Form(form): Form, ) -> Result { let community = get_community(&state.db, &slug).await?; check_write_access( &state.db, community.id, user.user_id, community.suspended_at.is_some(), ) .await?; check_write_state(&state, &community, &user, WriteScope::NewThread).await?; mt_db::mutations::ensure_membership(&state.db, user.user_id, community.id) .await .map_err(db_error)?; check_user_post_rate(&state.db, user.user_id).await?; let title = validate_title(&form.title)?; let body = validate_body(&form.body, 65536, "Body")?; let author_plus = user.perks.effective_plus(); if !author_plus { reject_embeds_for_free_user(body)?; } let category_id = mt_db::mutations::get_category_id_by_slugs(&state.db, &slug, &category_slug) .await .map_err(db_error)? .ok_or_else(|| StatusCode::NOT_FOUND.into_response())?; verify_quotes(&state.db, community.id, body).await?; let (body_html, mention_ids) = resolve_and_render_mentions( &state.db, body, community.id, &slug, user.user_id, author_plus, ) .await?; let tag_ids: Vec = form .tags .iter() .filter_map(|t| uuid::Uuid::parse_str(t).ok()) .collect(); // Create the thread, its opening post, its @mentions, and its tags as one // atomic unit. Previously these committed in separate statements, so a // failure after the thread insert silently dropped the mentions (no // notification) and tags (M-St1). let mut tx = begin_tx(&state.db).await?; let (thread_id, post_id) = mt_db::mutations::create_thread_with_op_tx( &mut tx, category_id, user.user_id, title, body, &body_html, ) .await .map_err(db_error)?; mt_db::mutations::insert_mentions(&mut *tx, post_id, &mention_ids) .await .map_err(db_error)?; if !tag_ids.is_empty() { mt_db::mutations::set_thread_tags_tx(&mut tx, thread_id, &tag_ids) .await .map_err(db_error)?; } commit_tx(tx).await?; // Fetch link previews in background (best-effort, never blocks response) spawn_link_preview_fetch(state.clone(), body.to_string(), post_id); Ok(Redirect::to(&format!( "/p/{slug}/{category_slug}/{thread_id}?toast=Thread+created" ))) } #[tracing::instrument(skip_all)] pub(in crate::routes) async fn create_reply_handler( axum::extract::State(state): axum::extract::State, Path((slug, category_slug, thread_id_str)): Path<(String, String, String)>, RequireUser(user): RequireUser, Form(form): Form, ) -> Result { let scope = CommunityScope::::resolve(&state.db, &slug, &thread_id_str).await?; scope.require_write_access(&state.db, user.user_id).await?; let CommunityScope { community, resource: thread_data, } = scope; check_write_state(&state, &community, &user, WriteScope::ContinueExisting).await?; mt_db::mutations::ensure_membership(&state.db, user.user_id, community.id) .await .map_err(db_error)?; check_user_post_rate(&state.db, user.user_id).await?; if thread_data.locked { return Err((StatusCode::FORBIDDEN, "This thread is locked.").into_response()); } let body = validate_body(&form.body, 65536, "Body")?; let author_plus = user.perks.effective_plus(); if !author_plus { reject_embeds_for_free_user(body)?; } verify_quotes(&state.db, community.id, body).await?; let (body_html, mention_ids) = resolve_and_render_mentions( &state.db, body, community.id, &slug, user.user_id, author_plus, ) .await?; let thread_id = parse_uuid(&thread_id_str)?; // Create the reply and its @mentions as one atomic unit so a failure between // them can't leave a committed reply whose mentions silently vanished (M-St1). let mut tx = begin_tx(&state.db).await?; // Conditional insert: lands only if the thread is still live+unlocked, closing // the TOCTOU against the snapshot-time `locked` check above. None => a // concurrent lock/soft-delete won the race. let post_id = mt_db::mutations::create_post_tx(&mut tx, thread_id, user.user_id, body, &body_html) .await .map_err(db_error)? .ok_or_else(|| { ( StatusCode::CONFLICT, "This thread was locked or removed before your reply posted.", ) .into_response() })?; mt_db::mutations::insert_mentions(&mut *tx, post_id, &mention_ids) .await .map_err(db_error)?; commit_tx(tx).await?; // Fetch link previews in background (best-effort, never blocks response) spawn_link_preview_fetch(state.clone(), body.to_string(), post_id); Ok(Redirect::to(&format!( "/p/{slug}/{category_slug}/{thread_id_str}?toast=Reply+posted" ))) } // Thread edit/delete handlers (mod/owner only) #[tracing::instrument(skip_all)] pub(in crate::routes) async fn edit_thread_form( axum::extract::State(state): axum::extract::State, Path((slug, category_slug, thread_id_str)): Path<(String, String, String)>, session: tower_sessions::Session, RequireUser(user): RequireUser, ) -> Result { let csrf_token = Some(crate::csrf::get_or_create_token(&session).await?); let scope = CommunityScope::::resolve(&state.db, &slug, &thread_id_str).await?; scope.require_write_access(&state.db, user.user_id).await?; let role = scope.role(&state.db, user.user_id).await?; if !is_mod_or_owner(role) { return Err(StatusCode::FORBIDDEN.into_response()); } let thread_data = scope.resource; Ok(crate::templates::EditThreadTemplate { csrf_token, session_user: Some(template_user(&user, state.config.platform_admin_id)), mnw_base_url: state.config.mnw_base_url.clone(), community_name: thread_data.community_name, community_slug: slug, category_name: thread_data.category_name, category_slug, thread_id: thread_id_str, current_title: thread_data.title, }) } #[tracing::instrument(skip_all)] pub(in crate::routes) async fn edit_thread_handler( axum::extract::State(state): axum::extract::State, Path((slug, category_slug, thread_id_str)): Path<(String, String, String)>, RequireUser(user): RequireUser, Form(form): Form, ) -> Result { let scope = CommunityScope::::resolve(&state.db, &slug, &thread_id_str).await?; scope.require_write_access(&state.db, user.user_id).await?; let role = scope.role(&state.db, user.user_id).await?; if !is_mod_or_owner(role) { return Err(StatusCode::FORBIDDEN.into_response()); } let title = validate_title(&form.title)?; let thread_id = parse_uuid(&thread_id_str)?; mt_db::mutations::update_thread_title(&state.db, thread_id, title) .await .map_err(db_error)?; Ok(Redirect::to(&format!( "/p/{slug}/{category_slug}/{thread_id_str}?toast=Title+updated" ))) } #[tracing::instrument(skip_all)] pub(in crate::routes) async fn delete_thread_handler( axum::extract::State(state): axum::extract::State, Path((slug, category_slug, thread_id_str)): Path<(String, String, String)>, RequireUser(user): RequireUser, ) -> Result { let scope = CommunityScope::::resolve(&state.db, &slug, &thread_id_str).await?; scope.require_write_access(&state.db, user.user_id).await?; let role = scope.role(&state.db, user.user_id).await?; if !is_mod_or_owner(role) { return Err(StatusCode::FORBIDDEN.into_response()); } let thread_data = scope.resource; let thread_id = parse_uuid(&thread_id_str)?; let mut tx = begin_tx(&state.db).await?; mt_db::mutations::soft_delete_thread(&mut *tx, thread_id) .await .map_err(db_error)?; audit( &mut tx, Some(thread_data.community_id), ModActor::User(user.user_id), ModAction::DeleteThread, Some(thread_data.author_id), Some(thread_id), None, ) .await?; commit_tx(tx).await?; Ok(Redirect::to(&format!( "/p/{slug}/{category_slug}?toast=Thread+deleted" ))) } #[cfg(test)] mod quote_tests { use super::*; #[test] fn hash_is_eight_hex_chars() { let h = compute_quote_hash("hello world"); assert_eq!(h.len(), 8, "hash must be 8 hex chars, got: {h}"); assert!( h.chars().all(|c| c.is_ascii_hexdigit()), "non-hex char in {h}" ); } #[test] fn hash_is_stable_for_same_input() { assert_eq!(compute_quote_hash("abc"), compute_quote_hash("abc")); } #[test] fn hash_differs_for_different_input() { assert_ne!(compute_quote_hash("abc"), compute_quote_hash("abd")); } #[test] fn hash_takes_first_four_bytes_only() { // Pins `&hash[..4]`, known SHA-256("a") prefix is 0xca978112. assert_eq!(compute_quote_hash("a"), "ca978112"); } #[test] fn find_quote_refs_finds_zero_markers() { assert!(find_quote_refs("body with no markers").is_empty()); } #[test] fn find_quote_refs_extracts_post_id_and_hash() { let body = "> hi\n[quote:11111111-2222-3333-4444-555555555555:abcd1234]"; let refs = find_quote_refs(body); assert_eq!(refs.len(), 1); assert_eq!(refs[0].post_id_str, "11111111-2222-3333-4444-555555555555"); assert_eq!(refs[0].claimed_hash, "abcd1234"); // marker_start points at the `[`. assert_eq!(&body[refs[0].marker_start..=refs[0].marker_start], "["); } #[test] fn find_quote_refs_finds_multiple_distinct_markers() { let body = "[quote:11111111-2222-3333-4444-555555555555:aaaaaaaa] and [quote:66666666-7777-8888-9999-000000000000:bbbbbbbb]"; let refs = find_quote_refs(body); assert_eq!(refs.len(), 2); assert_eq!(refs[0].claimed_hash, "aaaaaaaa"); assert_eq!(refs[1].claimed_hash, "bbbbbbbb"); assert!(refs[0].marker_start < refs[1].marker_start); } #[test] fn find_quote_refs_rejects_malformed_marker() { // Hash too short → regex doesn't match. let body = "[quote:11111111-2222-3333-4444-555555555555:abc]"; assert!(find_quote_refs(body).is_empty()); // UUID wrong length → no match. let body2 = "[quote:short-uuid:abcd1234]"; assert!(find_quote_refs(body2).is_empty()); } #[test] fn extract_single_quoted_line() { let body = "> hello\nMARKER"; let marker = body.find("MARKER").unwrap(); assert_eq!(extract_preceding_quote_text(body, marker), "hello"); } #[test] fn extract_multi_line_preserves_order() { let body = "> line one\n> line two\n> line three\nMARKER"; let marker = body.find("MARKER").unwrap(); assert_eq!( extract_preceding_quote_text(body, marker), "line one\nline two\nline three" ); } #[test] fn extract_handles_bare_gt_prefix_without_space() { // Lines like `>foo` (no space) must also be stripped. let body = ">no-space\n> with space\nMARKER"; let marker = body.find("MARKER").unwrap(); assert_eq!( extract_preceding_quote_text(body, marker), "no-space\nwith space" ); } #[test] fn extract_stops_at_first_non_quote_line() { // Quote lines only count if they are *immediately* before the marker. // The earlier "> ignored" must NOT be included because a plain line // breaks the run. let body = "> ignored\nplain text\n> kept\nMARKER"; let marker = body.find("MARKER").unwrap(); assert_eq!(extract_preceding_quote_text(body, marker), "kept"); } #[test] fn extract_returns_empty_when_no_preceding_quote() { let body = "regular text\nMARKER"; let marker = body.find("MARKER").unwrap(); assert_eq!(extract_preceding_quote_text(body, marker), ""); } #[test] fn extract_returns_empty_when_marker_is_at_start() { let body = "MARKER\nrest"; let marker = body.find("MARKER").unwrap(); assert_eq!(extract_preceding_quote_text(body, marker), ""); } #[test] fn extract_trims_trailing_blank_quote_lines() { // A `>` with only the prefix becomes an empty line; final .trim() // strips surrounding whitespace. let body = "> real content\n>\nMARKER"; let marker = body.find("MARKER").unwrap(); assert_eq!(extract_preceding_quote_text(body, marker), "real content"); } }