//! 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, get_community, get_thread, is_mod_or_owner, log_mod_action, parse_uuid, render_markdown, render_markdown_with_mentions, template_user, validate_body, validate_title, get_role, CreateReplyForm, CreateThreadForm, }; // ============================================================================ // Quote verification // ============================================================================ pub(super) const MAX_QUOTES_PER_POST: usize = 10; pub(super) const MAX_FOOTNOTES_PER_POST: usize = 10; /// 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> { 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() }); let match_count = QUOTE_RE.find_iter(body).count(); if match_count > 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 caps in QUOTE_RE.captures_iter(body) { let post_id_str = &caps[1]; let claimed_hash = &caps[2]; let post_id = Uuid::parse_str(post_id_str) .map_err(|_| (StatusCode::UNPROCESSABLE_ENTITY, "Invalid quote reference.").into_response())?; // Extract the quoted text: lines starting with `> ` immediately before the marker let marker = caps.get(0).unwrap(); 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(); let quoted_text: String = quoted_lines .iter() .map(|line| line.strip_prefix("> ").unwrap_or(line.strip_prefix('>').unwrap_or(line))) .collect::>() .join("\n") .trim() .to_string(); if quoted_text.is_empty() { return Err((StatusCode::UNPROCESSABLE_ENTITY, "Empty quote text.").into_response()); } // Fetch original post body 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())?; // Verify quoted text is a substring of the original if !original_markdown.contains("ed_text) { return Err((StatusCode::UNPROCESSABLE_ENTITY, "Quote does not match original post.").into_response()); } // Verify hash let mut hasher = Sha256::new(); hasher.update(quoted_text.as_bytes()); let hash = hasher.finalize(); let expected_hash = hex::encode(&hash[..4]); if claimed_hash != expected_hash { 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). #[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, ) -> Result<(String, Vec), Response> { let usernames = docengine::extract_mentions(body); if usernames.is_empty() { return Ok((render_markdown(body), 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); // 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 // ============================================================================ /// 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)] pub(super) 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 crate::link_preview::fetch_og_metadata(&state.preview_http, &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?; 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 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, ).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) .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 (best-effort, failures don't block post creation) fetch_and_store_link_previews(&state, body, post_id).await; // 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?; 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")?; verify_quotes(&state.db, body).await?; let (body_html, mention_ids) = resolve_and_render_mentions( &state.db, body, community.id, &slug, user.user_id, ).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) .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 (best-effort) fetch_and_store_link_previews(&state, body, post_id).await; 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" ))) }