//! Internal API routes, called by MNW with HMAC-SHA256 authentication. //! Registered outside the CSRF/session middleware stack. use axum::{ Json, Router, extract::{Path, State}, http::StatusCode, response::{IntoResponse, Response}, routing::{get, post}, }; use serde::{Deserialize, Serialize}; use tower_governor::{GovernorLayer, governor::GovernorConfigBuilder}; use uuid::Uuid; use crate::AppState; use crate::internal_auth::InternalAuth; // Request/response types #[derive(Deserialize)] pub struct CreateCommunityRequest { pub name: String, pub slug: String, pub description: Option, pub owner_mnw_id: Uuid, pub owner_username: String, pub owner_display_name: Option, } #[derive(Serialize)] pub struct CreateCommunityResponse { pub community_id: Uuid, pub created: bool, } #[derive(Deserialize)] pub struct CreateThreadRequest { pub community_slug: String, pub category_slug: String, pub title: String, pub body_markdown: String, pub author_mnw_id: Uuid, pub author_username: String, pub author_display_name: Option, pub external_ref: String, } #[derive(Serialize)] pub struct CreateThreadResponse { pub thread_id: Uuid, pub post_id: Uuid, pub created: bool, } #[derive(Deserialize)] pub struct CreatePostRequest { pub body_markdown: String, pub author_mnw_id: Uuid, pub author_username: String, pub author_display_name: Option, pub external_ref: String, } #[derive(Serialize)] pub struct CreatePostResponse { pub post_id: Uuid, pub created: bool, } #[derive(Serialize)] pub struct ThreadStatsResponse { pub post_count: i64, pub last_activity_at: Option>, } // Handlers /// `POST /internal/communities`, Create or return an existing community. #[tracing::instrument(skip_all, name = "internal::create_community")] async fn create_community( State(state): State, InternalAuth(body): InternalAuth, ) -> Result, Response> { let req: CreateCommunityRequest = serde_json::from_slice(&body).map_err(|e| { tracing::warn!(error = %e, "invalid create_community request body"); (StatusCode::BAD_REQUEST, "Invalid request body").into_response() })?; // Check if community already exists (idempotent) if let Some(existing) = mt_db::queries::get_community_by_slug(&state.db, &req.slug) .await .map_err(db_error)? { return Ok(Json(CreateCommunityResponse { community_id: existing.id, created: false, })); } // Upsert the owner user (may not have logged into MT yet) mt_db::mutations::upsert_user( &state.db, req.owner_mnw_id, &req.owner_username, req.owner_display_name.as_deref(), ) .await .map_err(db_error)?; let community_id = mt_db::mutations::create_community( &state.db, &req.name, &req.slug, req.description.as_deref(), ) .await .map_err(db_error)?; // Create default categories. Issues + Patches are seeded empty so they // surface in the directory before the first email lands, the internal // API also auto-creates any missing category on demand (see thread // handler below), so this is for discoverability, not correctness. let categories = [ ("Items", "items", 0), ("Blog", "blog", 1), ("Devlog", "devlog", 2), ("Discussion", "discussion", 3), ("Issues", "issues", 4), ("Patches", "patches", 5), ]; for (name, slug, order) in categories { mt_db::mutations::create_category(&state.db, community_id, name, slug, None, order) .await .map_err(db_error)?; } mt_db::mutations::ensure_membership_with_role( &state.db, req.owner_mnw_id, community_id, mt_core::types::CommunityRole::Owner, ) .await .map_err(db_error)?; tracing::info!(community_id = %community_id, slug = %req.slug, "internal: community created"); Ok(Json(CreateCommunityResponse { community_id, created: true, })) } /// `POST /internal/threads`, Create a thread with external reference (idempotent). #[tracing::instrument(skip_all, name = "internal::create_thread")] async fn create_thread( State(state): State, InternalAuth(body): InternalAuth, ) -> Result, Response> { let req: CreateThreadRequest = serde_json::from_slice(&body).map_err(|e| { tracing::warn!(error = %e, "invalid create_thread request body"); (StatusCode::BAD_REQUEST, "Invalid request body").into_response() })?; // Idempotent: return existing thread if external_ref matches if let Some((thread_id,)) = mt_db::queries::get_thread_by_external_ref(&state.db, &req.external_ref) .await .map_err(db_error)? { // Get the opening post ID let posts = mt_db::queries::list_posts_in_thread_paginated(&state.db, thread_id, 1, 0) .await .map_err(db_error)?; let post_id = posts.first().map_or(thread_id, |p| p.id); return Ok(Json(CreateThreadResponse { thread_id, post_id, created: false, })); } let community = mt_db::queries::get_community_by_slug(&state.db, &req.community_slug) .await .map_err(db_error)? .ok_or_else(|| (StatusCode::NOT_FOUND, "Community not found").into_response())?; // Look up category, auto-create if it doesn't exist (supports on-demand "patches" etc.) let category = match mt_db::queries::get_category_by_community_and_slug( &state.db, community.id, &req.category_slug, ) .await .map_err(db_error)? { Some(cat) => cat, None => { let next_order = mt_db::queries::get_max_category_order(&state.db, community.id) .await .map_err(db_error)? + 1; let cat_name = capitalize(&req.category_slug); let cat_id = mt_db::mutations::create_category( &state.db, community.id, &cat_name, &req.category_slug, None, next_order, ) .await .map_err(db_error)?; tracing::info!( category_slug = %req.category_slug, community_slug = %req.community_slug, "internal: auto-created category" ); mt_db::queries::CategoryIdRow { id: cat_id, name: cat_name, slug: req.category_slug.clone(), } } }; mt_db::mutations::upsert_user( &state.db, req.author_mnw_id, &req.author_username, req.author_display_name.as_deref(), ) .await .map_err(db_error)?; mt_db::mutations::ensure_membership(&state.db, req.author_mnw_id, community.id) .await .map_err(db_error)?; // Create thread + opening post atomically (external_ref retry-safe). let body_html = super::render_markdown(&req.body_markdown); let (thread_id, post_id) = mt_db::mutations::create_thread_with_op_external_ref( &state.db, category.id, req.author_mnw_id, &req.title, &req.external_ref, &req.body_markdown, &body_html, ) .await .map_err(db_error)?; tracing::info!( thread_id = %thread_id, external_ref = %req.external_ref, "internal: thread created" ); Ok(Json(CreateThreadResponse { thread_id, post_id, created: true, })) } /// `GET /internal/threads/:id/stats`, Thread post count and last activity. #[tracing::instrument(skip_all, name = "internal::thread_stats")] async fn thread_stats( State(state): State, Path(id): Path, // Single HMAC path: the same `InternalAuth` extractor the POST siblings use. // It binds the concrete request path (from `req.uri().path()`) and an empty // body for this GET, no hand-rolled second verification path to keep in sync. InternalAuth(_body): InternalAuth, ) -> Result, Response> { let thread_id = Uuid::parse_str(&id).map_err(|_| StatusCode::NOT_FOUND.into_response())?; let (post_count, last_activity_at) = mt_db::queries::get_thread_stats(&state.db, thread_id) .await .map_err(db_error)? .unwrap_or((0, None)); Ok(Json(ThreadStatsResponse { post_count, last_activity_at, })) } /// `POST /internal/threads/:id/posts`, Add a reply to an existing thread. #[tracing::instrument(skip_all, name = "internal::create_post")] async fn create_post( State(state): State, Path(id): Path, InternalAuth(body): InternalAuth, ) -> Result, Response> { let thread_id = Uuid::parse_str(&id).map_err(|_| StatusCode::NOT_FOUND.into_response())?; let req: CreatePostRequest = serde_json::from_slice(&body).map_err(|e| { tracing::warn!(error = %e, "invalid create_post request body"); (StatusCode::BAD_REQUEST, "Invalid request body").into_response() })?; if !mt_db::queries::thread_exists(&state.db, thread_id) .await .map_err(db_error)? { return Err((StatusCode::NOT_FOUND, "Thread not found").into_response()); } mt_db::mutations::upsert_user( &state.db, req.author_mnw_id, &req.author_username, req.author_display_name.as_deref(), ) .await .map_err(db_error)?; // Look up thread's community and ensure membership. This is the internal // server-to-server API (no URL slug to scope against), so the CommunityScope // resolver doesn't apply, the thread id is supplied directly by the trusted // internal caller, not derived from a `/p/{slug}/…` path. let thread_info = mt_db::queries::get_thread_with_breadcrumb(&state.db, thread_id) .await .map_err(db_error)? .ok_or_else(|| (StatusCode::NOT_FOUND, "Thread not found").into_response())?; #[allow(clippy::disallowed_methods)] // internal API: no slug to verify against let thread_info = thread_info.into_inner_unchecked(); mt_db::mutations::ensure_membership(&state.db, req.author_mnw_id, thread_info.community_id) .await .map_err(db_error)?; // Render markdown and create post (idempotent on external_ref: a retried or // replayed call returns the existing reply instead of duplicating it). let body_html = super::render_markdown(&req.body_markdown); let (post_id, created) = mt_db::mutations::create_post_external_ref( &state.db, thread_id, req.author_mnw_id, &req.external_ref, &req.body_markdown, &body_html, ) .await .map_err(db_error)?; tracing::info!( thread_id = %thread_id, post_id = %post_id, external_ref = %req.external_ref, created, "internal: post created" ); Ok(Json(CreatePostResponse { post_id, created })) } /// Build the internal API router. Registered outside CSRF/session middleware. /// /// HMAC-authenticated, but still rate-limited per IP so an attacker can't flood /// it with signature-guessing attempts unbounded. The cap is generous (burst /// 60, then 20/sec), well above legitimate MNW→MT traffic. pub fn internal_routes(state: AppState) -> Router { let internal_rate_limit = std::sync::Arc::new( GovernorConfigBuilder::default() .key_extractor(crate::trusted_proxy::TrustedProxyKeyExtractor::new( state.config.trusted_proxies.clone(), )) .per_millisecond(50) .burst_size(60) .finish() .expect("internal rate limiter config"), ); Router::new() .route("/internal/communities", post(create_community)) .route("/internal/threads", post(create_thread)) .route("/internal/threads/{id}/posts", post(create_post)) .route("/internal/threads/{id}/stats", get(thread_stats)) .route_layer(GovernorLayer::new(internal_rate_limit)) .with_state(state) } // Helpers #[allow( clippy::needless_pass_by_value, reason = "used as a `.map_err(db_error)` combinator, which requires the fn(E) -> R by-value signature" )] fn db_error(e: sqlx::Error) -> Response { tracing::error!(error = %e, "internal API database error"); StatusCode::INTERNAL_SERVER_ERROR.into_response() } /// Capitalize the first letter of a string (for auto-created category names). fn capitalize(s: &str) -> String { let mut chars = s.chars(); match chars.next() { None => String::new(), Some(c) => c.to_uppercase().collect::() + chars.as_str(), } }