Skip to main content

max / makenotwork

10.4 KB · 313 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, KeyInit, 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 // Request/response types (must match MT's internal API)
32
33 #[derive(Serialize)]
34 pub struct CreateCommunityRequest {
35 pub name: String,
36 pub slug: String,
37 pub description: Option<String>,
38 pub owner_mnw_id: Uuid,
39 pub owner_username: String,
40 pub owner_display_name: Option<String>,
41 }
42
43 #[derive(Deserialize)]
44 pub struct CreateCommunityResponse {
45 pub community_id: Uuid,
46 pub created: bool,
47 }
48
49 #[derive(Serialize)]
50 pub struct CreateThreadRequest {
51 pub community_slug: String,
52 pub category_slug: String,
53 pub title: String,
54 pub body_markdown: String,
55 pub author_mnw_id: Uuid,
56 pub author_username: String,
57 pub author_display_name: Option<String>,
58 pub external_ref: String,
59 }
60
61 #[derive(Deserialize)]
62 pub struct CreateThreadResponse {
63 pub thread_id: MtThreadId,
64 pub post_id: Uuid,
65 pub created: bool,
66 }
67
68 #[derive(Serialize)]
69 pub struct CreatePostRequest {
70 pub body_markdown: String,
71 pub author_mnw_id: Uuid,
72 pub author_username: String,
73 pub author_display_name: Option<String>,
74 /// Idempotency key, MT dedups a retried/replayed reply on this. Use a
75 /// stable per-message value (e.g. `mnw:post:<email-message-id>`).
76 pub external_ref: String,
77 }
78
79 #[derive(Deserialize)]
80 pub struct CreatePostResponse {
81 pub post_id: Uuid,
82 /// False when MT returned an existing reply (the ref was already seen).
83 #[serde(default)]
84 pub created: bool,
85 }
86
87 #[derive(Deserialize)]
88 pub struct ThreadStatsResponse {
89 pub post_count: i64,
90 pub last_activity_at: Option<chrono::DateTime<chrono::Utc>>,
91 }
92
93 impl MtClient {
94 /// Create a new MT client with the given base URL and shared secret.
95 pub fn new(base_url: String, secret: String) -> Self {
96 let http = reqwest::Client::builder()
97 .timeout(std::time::Duration::from_secs(5))
98 .connect_timeout(std::time::Duration::from_secs(3))
99 .build()
100 .expect("failed to build MT HTTP client");
101
102 Self {
103 http,
104 base_url,
105 secret,
106 }
107 }
108
109 /// Sign a request, binding method + path + a fresh nonce in addition to the
110 /// timestamp and body. Returns (timestamp, nonce, hex signature). The
111 /// canonical message, `timestamp \n METHOD \n PATH \n NONCE \n body`, must
112 /// match MT's `compute_internal_signature_v2` byte-for-byte. `path` is the
113 /// request path only (no scheme/host, no query string).
114 fn sign_request(&self, method: &str, path: &str, body: &str) -> (String, String, String) {
115 let timestamp = chrono::Utc::now().timestamp().to_string();
116 let nonce = Uuid::new_v4().simple().to_string();
117
118 let mut mac = Hmac::<Sha256>::new_from_slice(self.secret.as_bytes())
119 .expect("HMAC-SHA256 accepts any key length");
120 mac.update(timestamp.as_bytes());
121 mac.update(b"\n");
122 mac.update(method.as_bytes());
123 mac.update(b"\n");
124 mac.update(path.as_bytes());
125 mac.update(b"\n");
126 mac.update(nonce.as_bytes());
127 mac.update(b"\n");
128 mac.update(body.as_bytes());
129 let signature = hex::encode(mac.finalize().into_bytes());
130
131 (timestamp, nonce, signature)
132 }
133
134 /// Send a signed POST request and deserialize the response.
135 async fn signed_post<Req: Serialize, Resp: for<'de> Deserialize<'de>>(
136 &self,
137 path: &str,
138 req: &Req,
139 ) -> Result<Resp, MtClientError> {
140 let body = serde_json::to_string(req).expect("request serialization cannot fail");
141 let (timestamp, nonce, signature) = self.sign_request("POST", path, &body);
142
143 let resp = self
144 .http
145 .post(format!("{}{}", self.base_url, path))
146 .header("Content-Type", "application/json")
147 .header("X-Internal-Timestamp", &timestamp)
148 .header("X-Internal-Signature", &signature)
149 .header("X-Internal-Nonce", &nonce)
150 .body(body)
151 .send()
152 .await
153 .map_err(MtClientError::Unreachable)?;
154
155 let status = resp.status();
156 if !status.is_success() {
157 let body = resp.text().await.unwrap_or_default();
158 return Err(MtClientError::BadResponse {
159 status: status.as_u16(),
160 body,
161 });
162 }
163
164 resp.json().await.map_err(MtClientError::Deserialize)
165 }
166
167 /// Create or retrieve an existing community on MT.
168 #[tracing::instrument(skip_all)]
169 pub async fn create_community(
170 &self,
171 req: &CreateCommunityRequest,
172 ) -> Result<CreateCommunityResponse, MtClientError> {
173 self.signed_post("/internal/communities", req).await
174 }
175
176 /// Create a discussion thread on MT linked to MNW content.
177 #[tracing::instrument(skip_all)]
178 pub async fn create_thread(
179 &self,
180 req: &CreateThreadRequest,
181 ) -> Result<CreateThreadResponse, MtClientError> {
182 self.signed_post("/internal/threads", req).await
183 }
184
185 /// Add a reply to an existing thread on MT.
186 #[tracing::instrument(skip_all, fields(thread_id = %thread_id))]
187 pub async fn create_post(
188 &self,
189 thread_id: MtThreadId,
190 req: &CreatePostRequest,
191 ) -> Result<CreatePostResponse, MtClientError> {
192 self.signed_post(&format!("/internal/threads/{thread_id}/posts"), req)
193 .await
194 }
195
196 /// Get thread stats (post count + last activity).
197 #[tracing::instrument(skip_all, fields(thread_id = %thread_id))]
198 pub async fn get_thread_stats(
199 &self,
200 thread_id: MtThreadId,
201 ) -> Result<ThreadStatsResponse, MtClientError> {
202 let path = format!("/internal/threads/{thread_id}/stats");
203 let (timestamp, nonce, signature) = self.sign_request("GET", &path, "");
204 let resp = self
205 .http
206 .get(format!("{}{}", self.base_url, path))
207 .header("X-Internal-Timestamp", &timestamp)
208 .header("X-Internal-Signature", &signature)
209 .header("X-Internal-Nonce", &nonce)
210 .send()
211 .await
212 .map_err(MtClientError::Unreachable)?;
213
214 let status = resp.status();
215 if !status.is_success() {
216 let body = resp.text().await.unwrap_or_default();
217 return Err(MtClientError::BadResponse {
218 status: status.as_u16(),
219 body,
220 });
221 }
222
223 resp.json().await.map_err(MtClientError::Deserialize)
224 }
225 }
226
227 #[cfg(test)]
228 mod tests {
229 use super::*;
230
231 #[test]
232 fn sign_request_produces_valid_signature_and_fresh_nonce() {
233 let client = MtClient::new("http://localhost".to_string(), "test-secret".to_string());
234 let body = r#"{"name":"test"}"#;
235 let (ts1, nonce1, sig1) = client.sign_request("POST", "/internal/communities", body);
236 let (ts2, nonce2, sig2) = client.sign_request("POST", "/internal/communities", body);
237
238 let t1: i64 = ts1.parse().unwrap();
239 let t2: i64 = ts2.parse().unwrap();
240 assert!((t1 - t2).abs() <= 1);
241
242 assert_eq!(sig1.len(), 64, "SHA-256 hex is 64 chars");
243 assert!(sig1.chars().all(|c| c.is_ascii_hexdigit()));
244
245 // Each request carries a fresh nonce, so even identical method/path/body
246 // produce a distinct signature, single-use by construction.
247 assert_ne!(nonce1, nonce2, "nonce must be fresh per request");
248 assert_ne!(sig1, sig2, "fresh nonce must change the signature");
249 }
250
251 /// Recompute the canonical v2 message inline to pin that method, path, and
252 /// nonce are all bound (a mutation dropping any field would collide).
253 #[test]
254 fn signed_message_binds_method_path_nonce() {
255 use hmac::{Hmac, KeyInit, Mac};
256 use sha2::Sha256;
257
258 fn sig(
259 secret: &str,
260 ts: &str,
261 method: &str,
262 path: &str,
263 nonce: &str,
264 body: &str,
265 ) -> String {
266 let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
267 for field in [ts, method, path, nonce] {
268 mac.update(field.as_bytes());
269 mac.update(b"\n");
270 }
271 mac.update(body.as_bytes());
272 hex::encode(mac.finalize().into_bytes())
273 }
274
275 // The verifier is multithreaded's compute_internal_signature_v2, so the
276 // exact bytes are a cross-repo contract that neither repo can catch by
277 // agreeing with itself. Both ends pin this same independently computed
278 // HMAC-SHA256: key "secret", message "100\nPOST\n/x\nn\nbody".
279 assert_eq!(
280 sig("secret", "100", "POST", "/x", "n", "body"),
281 "0e97f90cb4e4ca7aaa5499e67a22fb5b7ad45ad3cc966f37d225f39da2728098"
282 );
283
284 // Tie the production signer to that layout, so the pinned vector above
285 // constrains sign_request and not just this local reimplementation.
286 let client = MtClient::new("http://localhost".to_string(), "s".to_string());
287 let (ts, nonce, produced) = client.sign_request("POST", "/a", "body");
288 assert_eq!(produced, sig("s", &ts, "POST", "/a", &nonce, "body"));
289
290 let base = sig("s", "100", "POST", "/a", "n1", "body");
291 assert_ne!(
292 base,
293 sig("s", "100", "GET", "/a", "n1", "body"),
294 "method bound"
295 );
296 assert_ne!(
297 base,
298 sig("s", "100", "POST", "/b", "n1", "body"),
299 "path bound"
300 );
301 assert_ne!(
302 base,
303 sig("s", "100", "POST", "/a", "n2", "body"),
304 "nonce bound"
305 );
306 assert_ne!(
307 base,
308 sig("s", "100", "POST", "/a", "n1", "body2"),
309 "body bound"
310 );
311 }
312 }
313