//! Post creation handlers — thread creation, replies, and supporting pipelines //! (quote verification, mention resolution, link preview fetching). use axum::{ extract::Path, http::StatusCode, response::{IntoResponse, Redirect, Response}, Form, }; use uuid::Uuid; use sha2::{Sha256, Digest}; use crate::auth::MaybeUser; use crate::AppState; use mt_core::types::ModAction; use super::super::{ check_user_post_rate, check_write_access, check_write_state, get_community, get_thread, is_mod_or_owner, log_mod_action, parse_uuid, reject_embeds_for_free_user, render_markdown, render_markdown_plus, render_markdown_with_mentions, template_user, validate_body, validate_title, get_role, WriteScope, CreateReplyForm, CreateThreadForm, }; // ============================================================================ // 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, 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(db, post_id) .await .map_err(|e| { tracing::error!(error = ?e, "db error fetching post for quote verification"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })? .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(|e| { tracing::error!(error = ?e, "db error resolving mention usernames"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })?; 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. fn spawn_link_preview_fetch(state: AppState, body: String, post_id: Uuid) { tokio::spawn(async move { 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)>, MaybeUser(session_user): MaybeUser, Form(form): Form, ) -> Result { let user = session_user .ok_or_else(|| Redirect::to("/auth/login").into_response())?; 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(|e| { tracing::error!(error = ?e, "db error ensuring membership"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })?; 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(|e| { tracing::error!(error = ?e, "db error looking up category"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })? .ok_or_else(|| StatusCode::NOT_FOUND.into_response())?; verify_quotes(&state.db, 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 = mt_db::mutations::create_thread(&state.db, category_id, user.user_id, title) .await .map_err(|e| { tracing::error!(error = ?e, "db error creating thread"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })?; let post_id = mt_db::mutations::create_post(&state.db, thread_id, user.user_id, body, &body_html, false) .await .map_err(|e| { tracing::error!(error = ?e, "db error creating post"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })?; if !mention_ids.is_empty() { mt_db::mutations::insert_mentions(&state.db, post_id, &mention_ids) .await .map_err(|e| { tracing::error!(error = ?e, "db error inserting mentions"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })?; } // Fetch link previews in background (best-effort, never blocks response) spawn_link_preview_fetch(state.clone(), body.to_string(), post_id); // Save tags if any were selected if !form.tags.is_empty() { let tag_ids: Vec = form .tags .iter() .filter_map(|t| uuid::Uuid::parse_str(t).ok()) .collect(); if !tag_ids.is_empty() { mt_db::mutations::set_thread_tags(&state.db, thread_id, &tag_ids) .await .map_err(|e| { tracing::error!(error = ?e, "db error setting thread tags"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })?; } } 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)>, MaybeUser(session_user): MaybeUser, Form(form): Form, ) -> Result { let user = session_user .ok_or_else(|| Redirect::to("/auth/login").into_response())?; let thread_data = get_thread(&state.db, &thread_id_str).await?; 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::ContinueExisting).await?; mt_db::mutations::ensure_membership(&state.db, user.user_id, community.id) .await .map_err(|e| { tracing::error!(error = ?e, "db error ensuring membership"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })?; 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, 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)?; let post_id = mt_db::mutations::create_post(&state.db, thread_id, user.user_id, body, &body_html, true) .await .map_err(|e| { tracing::error!(error = ?e, "db error creating reply"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })?; if !mention_ids.is_empty() { mt_db::mutations::insert_mentions(&state.db, post_id, &mention_ids) .await .map_err(|e| { tracing::error!(error = ?e, "db error inserting mentions"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })?; } // 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, MaybeUser(session_user): MaybeUser, ) -> Result { let csrf_token = Some(crate::csrf::get_or_create_token(&session).await); let user = session_user .ok_or_else(|| Redirect::to("/auth/login").into_response())?; let thread_data = get_thread(&state.db, &thread_id_str).await?; let community = get_community(&state.db, &slug).await?; check_write_access(&state.db, community.id, user.user_id, community.suspended_at.is_some()).await?; let role = get_role(&state.db, user.user_id, thread_data.community_id).await?; if !is_mod_or_owner(&role) { return Err(StatusCode::FORBIDDEN.into_response()); } 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)>, MaybeUser(session_user): MaybeUser, Form(form): Form, ) -> Result { let user = session_user .ok_or_else(|| Redirect::to("/auth/login").into_response())?; let thread_data = get_thread(&state.db, &thread_id_str).await?; let community = get_community(&state.db, &slug).await?; check_write_access(&state.db, community.id, user.user_id, community.suspended_at.is_some()).await?; let role = get_role(&state.db, user.user_id, thread_data.community_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(|e| { tracing::error!(error = ?e, "db error updating thread title"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })?; 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)>, MaybeUser(session_user): MaybeUser, ) -> Result { let user = session_user .ok_or_else(|| Redirect::to("/auth/login").into_response())?; let thread_data = get_thread(&state.db, &thread_id_str).await?; let community = get_community(&state.db, &slug).await?; check_write_access(&state.db, community.id, user.user_id, community.suspended_at.is_some()).await?; let role = get_role(&state.db, user.user_id, thread_data.community_id).await?; if !is_mod_or_owner(&role) { return Err(StatusCode::FORBIDDEN.into_response()); } let thread_id = parse_uuid(&thread_id_str)?; mt_db::mutations::soft_delete_thread(&state.db, thread_id) .await .map_err(|e| { tracing::error!(error = ?e, "db error deleting thread"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })?; log_mod_action( &state.db, Some(thread_data.community_id), user.user_id, ModAction::DeleteThread, Some(thread_data.author_id), Some(thread_id), None, ).await; Ok(Redirect::to(&format!( "/p/{slug}/{category_slug}?toast=Thread+deleted" ))) } #[cfg(test)] mod quote_tests { use super::*; // ── compute_quote_hash ── #[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"); } // ── find_quote_refs ── #[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 + 1], "["); } #[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()); } // ── extract_preceding_quote_text ── #[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"); } }