//! Internal API routes — called by MNW with HMAC-SHA256 authentication. //! Registered outside the CSRF/session middleware stack. use axum::{ extract::{Path, State}, http::StatusCode, response::{IntoResponse, Response}, routing::{get, post}, Json, Router, }; use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::internal_auth::InternalAuth; use crate::AppState; // ============================================================================ // 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, } #[derive(Serialize)] pub struct CreatePostResponse { pub post_id: Uuid, } #[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)?; // Create the community 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 let categories = [ ("Items", "items", 0), ("Blog", "blog", 1), ("Devlog", "devlog", 2), ("Discussion", "discussion", 3), ]; for (name, slug, order) in categories { mt_db::mutations::create_category(&state.db, community_id, name, slug, None, order) .await .map_err(db_error)?; } // Create owner membership mt_db::mutations::ensure_membership_with_role( &state.db, req.owner_mnw_id, community_id, "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(|p| p.id).unwrap_or(thread_id); return Ok(Json(CreateThreadResponse { thread_id, post_id, created: false, })); } // Look up community 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(), } } }; // Upsert author 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)?; // Ensure membership mt_db::mutations::ensure_membership(&state.db, req.author_mnw_id, community.id) .await .map_err(db_error)?; // Create thread with external_ref let thread_id = mt_db::mutations::create_thread_with_external_ref( &state.db, category.id, req.author_mnw_id, &req.title, &req.external_ref, ) .await .map_err(db_error)?; // Create opening post let body_html = super::render_markdown(&req.body_markdown); let post_id = mt_db::mutations::create_post( &state.db, thread_id, req.author_mnw_id, &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, ) -> Result, Response> { // Note: stats endpoint doesn't require HMAC auth (read-only, no sensitive data). // But it's only accessible via the internal route prefix which is not public. 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() })?; // Verify thread exists 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()); } // Upsert author 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 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())?; 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 let body_html = super::render_markdown(&req.body_markdown); let post_id = mt_db::mutations::create_post( &state.db, thread_id, req.author_mnw_id, &req.body_markdown, &body_html, ) .await .map_err(db_error)?; tracing::info!( thread_id = %thread_id, post_id = %post_id, "internal: post created" ); Ok(Json(CreatePostResponse { post_id })) } /// Build the internal API router. Registered outside CSRF/session middleware. pub fn internal_routes(state: AppState) -> Router { 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)) .with_state(state) } // ============================================================================ // Helpers // ============================================================================ 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(), } }