Skip to main content

max / makenotwork

6.8 KB · 228 lines History Blame Raw
1 //! HTTP client for the Multithreaded internal API.
2 //!
3 //! Signs requests with HMAC-SHA256 and communicates with MT's `/internal/*` endpoints.
4
5 use hmac::{Hmac, Mac};
6 use serde::{Deserialize, Serialize};
7 use sha2::Sha256;
8 use uuid::Uuid;
9
10 use crate::db::MtThreadId;
11
12 /// Errors from the MT internal API client.
13 #[derive(Debug, thiserror::Error)]
14 pub enum MtClientError {
15 #[error("MT unreachable: {0}")]
16 Unreachable(reqwest::Error),
17 #[error("MT returned error status {status}: {body}")]
18 BadResponse { status: u16, body: String },
19 #[error("failed to deserialize MT response: {0}")]
20 Deserialize(reqwest::Error),
21 }
22
23 /// HTTP client for MT's internal API with HMAC-SHA256 request signing.
24 #[derive(Clone)]
25 pub struct MtClient {
26 http: reqwest::Client,
27 base_url: String,
28 secret: String,
29 }
30
31 // ============================================================================
32 // Request/response types (must match MT's internal API)
33 // ============================================================================
34
35 #[derive(Serialize)]
36 pub struct CreateCommunityRequest {
37 pub name: String,
38 pub slug: String,
39 pub description: Option<String>,
40 pub owner_mnw_id: Uuid,
41 pub owner_username: String,
42 pub owner_display_name: Option<String>,
43 }
44
45 #[derive(Deserialize)]
46 pub struct CreateCommunityResponse {
47 pub community_id: Uuid,
48 pub created: bool,
49 }
50
51 #[derive(Serialize)]
52 pub struct CreateThreadRequest {
53 pub community_slug: String,
54 pub category_slug: String,
55 pub title: String,
56 pub body_markdown: String,
57 pub author_mnw_id: Uuid,
58 pub author_username: String,
59 pub author_display_name: Option<String>,
60 pub external_ref: String,
61 }
62
63 #[derive(Deserialize)]
64 pub struct CreateThreadResponse {
65 pub thread_id: MtThreadId,
66 pub post_id: Uuid,
67 pub created: bool,
68 }
69
70 #[derive(Serialize)]
71 pub struct CreatePostRequest {
72 pub body_markdown: String,
73 pub author_mnw_id: Uuid,
74 pub author_username: String,
75 pub author_display_name: Option<String>,
76 }
77
78 #[derive(Deserialize)]
79 pub struct CreatePostResponse {
80 pub post_id: Uuid,
81 }
82
83 #[derive(Deserialize)]
84 pub struct ThreadStatsResponse {
85 pub post_count: i64,
86 pub last_activity_at: Option<chrono::DateTime<chrono::Utc>>,
87 }
88
89 impl MtClient {
90 /// Create a new MT client with the given base URL and shared secret.
91 pub fn new(base_url: String, secret: String) -> Self {
92 let http = reqwest::Client::builder()
93 .timeout(std::time::Duration::from_secs(5))
94 .connect_timeout(std::time::Duration::from_secs(3))
95 .build()
96 .expect("failed to build MT HTTP client");
97
98 Self {
99 http,
100 base_url,
101 secret,
102 }
103 }
104
105 /// Sign a request body, returning (timestamp, hex-encoded signature).
106 fn sign_request(&self, body: &str) -> (String, String) {
107 let timestamp = chrono::Utc::now().timestamp().to_string();
108 let message = format!("{}\n{}", timestamp, body);
109
110 let mut mac = Hmac::<Sha256>::new_from_slice(self.secret.as_bytes())
111 .expect("HMAC-SHA256 accepts any key length");
112 mac.update(message.as_bytes());
113 let signature = hex::encode(mac.finalize().into_bytes());
114
115 (timestamp, signature)
116 }
117
118 /// Send a signed POST request and deserialize the response.
119 async fn signed_post<Req: Serialize, Resp: for<'de> Deserialize<'de>>(
120 &self,
121 path: &str,
122 req: &Req,
123 ) -> Result<Resp, MtClientError> {
124 let body = serde_json::to_string(req).expect("request serialization cannot fail");
125 let (timestamp, signature) = self.sign_request(&body);
126
127 let resp = self
128 .http
129 .post(format!("{}{}", self.base_url, path))
130 .header("Content-Type", "application/json")
131 .header("X-Internal-Timestamp", &timestamp)
132 .header("X-Internal-Signature", &signature)
133 .body(body)
134 .send()
135 .await
136 .map_err(MtClientError::Unreachable)?;
137
138 let status = resp.status();
139 if !status.is_success() {
140 let body = resp.text().await.unwrap_or_default();
141 return Err(MtClientError::BadResponse {
142 status: status.as_u16(),
143 body,
144 });
145 }
146
147 resp.json().await.map_err(MtClientError::Deserialize)
148 }
149
150 /// Create or retrieve an existing community on MT.
151 pub async fn create_community(
152 &self,
153 req: &CreateCommunityRequest,
154 ) -> Result<CreateCommunityResponse, MtClientError> {
155 self.signed_post("/internal/communities", req).await
156 }
157
158 /// Create a discussion thread on MT linked to MNW content.
159 pub async fn create_thread(
160 &self,
161 req: &CreateThreadRequest,
162 ) -> Result<CreateThreadResponse, MtClientError> {
163 self.signed_post("/internal/threads", req).await
164 }
165
166 /// Add a reply to an existing thread on MT.
167 pub async fn create_post(
168 &self,
169 thread_id: MtThreadId,
170 req: &CreatePostRequest,
171 ) -> Result<CreatePostResponse, MtClientError> {
172 self.signed_post(&format!("/internal/threads/{}/posts", thread_id), req)
173 .await
174 }
175
176 /// Get thread stats (post count + last activity).
177 pub async fn get_thread_stats(
178 &self,
179 thread_id: MtThreadId,
180 ) -> Result<ThreadStatsResponse, MtClientError> {
181 let (timestamp, signature) = self.sign_request("");
182 let resp = self
183 .http
184 .get(format!(
185 "{}/internal/threads/{}/stats",
186 self.base_url, thread_id
187 ))
188 .header("X-Internal-Timestamp", &timestamp)
189 .header("X-Internal-Signature", &signature)
190 .send()
191 .await
192 .map_err(MtClientError::Unreachable)?;
193
194 let status = resp.status();
195 if !status.is_success() {
196 let body = resp.text().await.unwrap_or_default();
197 return Err(MtClientError::BadResponse {
198 status: status.as_u16(),
199 body,
200 });
201 }
202
203 resp.json().await.map_err(MtClientError::Deserialize)
204 }
205 }
206
207 #[cfg(test)]
208 mod tests {
209 use super::*;
210
211 #[test]
212 fn sign_request_produces_deterministic_output() {
213 let client = MtClient::new("http://localhost".to_string(), "test-secret".to_string());
214 let body = r#"{"name":"test"}"#;
215 let (ts1, sig1) = client.sign_request(body);
216 let (ts2, sig2) = client.sign_request(body);
217
218 // Timestamps should be within 1 second of each other
219 let t1: i64 = ts1.parse().unwrap();
220 let t2: i64 = ts2.parse().unwrap();
221 assert!((t1 - t2).abs() <= 1);
222
223 // Signatures should be valid hex (64 chars for SHA256)
224 assert_eq!(sig1.len(), 64);
225 assert_eq!(sig2.len(), 64);
226 }
227 }
228