//! HTTP client for the Multithreaded internal API. //! //! Signs requests with HMAC-SHA256 and communicates with MT's `/internal/*` endpoints. use hmac::{Hmac, Mac}; use serde::{Deserialize, Serialize}; use sha2::Sha256; use uuid::Uuid; use crate::db::MtThreadId; /// Errors from the MT internal API client. #[derive(Debug, thiserror::Error)] pub enum MtClientError { #[error("MT unreachable: {0}")] Unreachable(reqwest::Error), #[error("MT returned error status {status}: {body}")] BadResponse { status: u16, body: String }, #[error("failed to deserialize MT response: {0}")] Deserialize(reqwest::Error), } /// HTTP client for MT's internal API with HMAC-SHA256 request signing. #[derive(Clone)] pub struct MtClient { http: reqwest::Client, base_url: String, secret: String, } // ============================================================================ // Request/response types (must match MT's internal API) // ============================================================================ #[derive(Serialize)] 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(Deserialize)] pub struct CreateCommunityResponse { pub community_id: Uuid, pub created: bool, } #[derive(Serialize)] 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(Deserialize)] pub struct CreateThreadResponse { pub thread_id: MtThreadId, pub post_id: Uuid, pub created: bool, } #[derive(Serialize)] pub struct CreatePostRequest { pub body_markdown: String, pub author_mnw_id: Uuid, pub author_username: String, pub author_display_name: Option, } #[derive(Deserialize)] pub struct CreatePostResponse { pub post_id: Uuid, } #[derive(Deserialize)] pub struct ThreadStatsResponse { pub post_count: i64, pub last_activity_at: Option>, } impl MtClient { /// Create a new MT client with the given base URL and shared secret. pub fn new(base_url: String, secret: String) -> Self { let http = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(5)) .connect_timeout(std::time::Duration::from_secs(3)) .build() .expect("failed to build MT HTTP client"); Self { http, base_url, secret, } } /// Sign a request body, returning (timestamp, hex-encoded signature). fn sign_request(&self, body: &str) -> (String, String) { let timestamp = chrono::Utc::now().timestamp().to_string(); let message = format!("{}\n{}", timestamp, body); let mut mac = Hmac::::new_from_slice(self.secret.as_bytes()) .expect("HMAC-SHA256 accepts any key length"); mac.update(message.as_bytes()); let signature = hex::encode(mac.finalize().into_bytes()); (timestamp, signature) } /// Send a signed POST request and deserialize the response. async fn signed_post Deserialize<'de>>( &self, path: &str, req: &Req, ) -> Result { let body = serde_json::to_string(req).expect("request serialization cannot fail"); let (timestamp, signature) = self.sign_request(&body); let resp = self .http .post(format!("{}{}", self.base_url, path)) .header("Content-Type", "application/json") .header("X-Internal-Timestamp", ×tamp) .header("X-Internal-Signature", &signature) .body(body) .send() .await .map_err(MtClientError::Unreachable)?; let status = resp.status(); if !status.is_success() { let body = resp.text().await.unwrap_or_default(); return Err(MtClientError::BadResponse { status: status.as_u16(), body, }); } resp.json().await.map_err(MtClientError::Deserialize) } /// Create or retrieve an existing community on MT. pub async fn create_community( &self, req: &CreateCommunityRequest, ) -> Result { self.signed_post("/internal/communities", req).await } /// Create a discussion thread on MT linked to MNW content. pub async fn create_thread( &self, req: &CreateThreadRequest, ) -> Result { self.signed_post("/internal/threads", req).await } /// Add a reply to an existing thread on MT. pub async fn create_post( &self, thread_id: MtThreadId, req: &CreatePostRequest, ) -> Result { self.signed_post(&format!("/internal/threads/{}/posts", thread_id), req) .await } /// Get thread stats (post count + last activity). pub async fn get_thread_stats( &self, thread_id: MtThreadId, ) -> Result { let (timestamp, signature) = self.sign_request(""); let resp = self .http .get(format!( "{}/internal/threads/{}/stats", self.base_url, thread_id )) .header("X-Internal-Timestamp", ×tamp) .header("X-Internal-Signature", &signature) .send() .await .map_err(MtClientError::Unreachable)?; let status = resp.status(); if !status.is_success() { let body = resp.text().await.unwrap_or_default(); return Err(MtClientError::BadResponse { status: status.as_u16(), body, }); } resp.json().await.map_err(MtClientError::Deserialize) } } #[cfg(test)] mod tests { use super::*; #[test] fn sign_request_produces_deterministic_output() { let client = MtClient::new("http://localhost".to_string(), "test-secret".to_string()); let body = r#"{"name":"test"}"#; let (ts1, sig1) = client.sign_request(body); let (ts2, sig2) = client.sign_request(body); // Timestamps should be within 1 second of each other let t1: i64 = ts1.parse().unwrap(); let t2: i64 = ts2.parse().unwrap(); assert!((t1 - t2).abs() <= 1); // Signatures should be valid hex (64 chars for SHA256) assert_eq!(sig1.len(), 64); assert_eq!(sig2.len(), 64); } }