Skip to main content

max / makenotwork

12.7 KB · 405 lines History Blame Raw
1 //! Internal API routes, called by MNW with HMAC-SHA256 authentication.
2 //! Registered outside the CSRF/session middleware stack.
3
4 use axum::{
5 Json, Router,
6 extract::{Path, State},
7 http::StatusCode,
8 response::{IntoResponse, Response},
9 routing::{get, post},
10 };
11 use serde::{Deserialize, Serialize};
12 use tower_governor::{GovernorLayer, governor::GovernorConfigBuilder};
13 use uuid::Uuid;
14
15 use crate::AppState;
16 use crate::internal_auth::InternalAuth;
17
18 // Request/response types
19
20 #[derive(Deserialize)]
21 pub struct CreateCommunityRequest {
22 pub name: String,
23 pub slug: String,
24 pub description: Option<String>,
25 pub owner_mnw_id: Uuid,
26 pub owner_username: String,
27 pub owner_display_name: Option<String>,
28 }
29
30 #[derive(Serialize)]
31 pub struct CreateCommunityResponse {
32 pub community_id: Uuid,
33 pub created: bool,
34 }
35
36 #[derive(Deserialize)]
37 pub struct CreateThreadRequest {
38 pub community_slug: String,
39 pub category_slug: String,
40 pub title: String,
41 pub body_markdown: String,
42 pub author_mnw_id: Uuid,
43 pub author_username: String,
44 pub author_display_name: Option<String>,
45 pub external_ref: String,
46 }
47
48 #[derive(Serialize)]
49 pub struct CreateThreadResponse {
50 pub thread_id: Uuid,
51 pub post_id: Uuid,
52 pub created: bool,
53 }
54
55 #[derive(Deserialize)]
56 pub struct CreatePostRequest {
57 pub body_markdown: String,
58 pub author_mnw_id: Uuid,
59 pub author_username: String,
60 pub author_display_name: Option<String>,
61 pub external_ref: String,
62 }
63
64 #[derive(Serialize)]
65 pub struct CreatePostResponse {
66 pub post_id: Uuid,
67 pub created: bool,
68 }
69
70 #[derive(Serialize)]
71 pub struct ThreadStatsResponse {
72 pub post_count: i64,
73 pub last_activity_at: Option<chrono::DateTime<chrono::Utc>>,
74 }
75
76 // Handlers
77
78 /// `POST /internal/communities`, Create or return an existing community.
79 #[tracing::instrument(skip_all, name = "internal::create_community")]
80 async fn create_community(
81 State(state): State<AppState>,
82 InternalAuth(body): InternalAuth,
83 ) -> Result<Json<CreateCommunityResponse>, Response> {
84 let req: CreateCommunityRequest = serde_json::from_slice(&body).map_err(|e| {
85 tracing::warn!(error = %e, "invalid create_community request body");
86 (StatusCode::BAD_REQUEST, "Invalid request body").into_response()
87 })?;
88
89 // Check if community already exists (idempotent)
90 if let Some(existing) = mt_db::queries::get_community_by_slug(&state.db, &req.slug)
91 .await
92 .map_err(db_error)?
93 {
94 return Ok(Json(CreateCommunityResponse {
95 community_id: existing.id,
96 created: false,
97 }));
98 }
99
100 // Upsert the owner user (may not have logged into MT yet)
101 mt_db::mutations::upsert_user(
102 &state.db,
103 req.owner_mnw_id,
104 &req.owner_username,
105 req.owner_display_name.as_deref(),
106 )
107 .await
108 .map_err(db_error)?;
109
110 let community_id = mt_db::mutations::create_community(
111 &state.db,
112 &req.name,
113 &req.slug,
114 req.description.as_deref(),
115 )
116 .await
117 .map_err(db_error)?;
118
119 // Create default categories. Issues + Patches are seeded empty so they
120 // surface in the directory before the first email lands, the internal
121 // API also auto-creates any missing category on demand (see thread
122 // handler below), so this is for discoverability, not correctness.
123 let categories = [
124 ("Items", "items", 0),
125 ("Blog", "blog", 1),
126 ("Devlog", "devlog", 2),
127 ("Discussion", "discussion", 3),
128 ("Issues", "issues", 4),
129 ("Patches", "patches", 5),
130 ];
131 for (name, slug, order) in categories {
132 mt_db::mutations::create_category(&state.db, community_id, name, slug, None, order)
133 .await
134 .map_err(db_error)?;
135 }
136
137 mt_db::mutations::ensure_membership_with_role(
138 &state.db,
139 req.owner_mnw_id,
140 community_id,
141 mt_core::types::CommunityRole::Owner,
142 )
143 .await
144 .map_err(db_error)?;
145
146 tracing::info!(community_id = %community_id, slug = %req.slug, "internal: community created");
147
148 Ok(Json(CreateCommunityResponse {
149 community_id,
150 created: true,
151 }))
152 }
153
154 /// `POST /internal/threads`, Create a thread with external reference (idempotent).
155 #[tracing::instrument(skip_all, name = "internal::create_thread")]
156 async fn create_thread(
157 State(state): State<AppState>,
158 InternalAuth(body): InternalAuth,
159 ) -> Result<Json<CreateThreadResponse>, Response> {
160 let req: CreateThreadRequest = serde_json::from_slice(&body).map_err(|e| {
161 tracing::warn!(error = %e, "invalid create_thread request body");
162 (StatusCode::BAD_REQUEST, "Invalid request body").into_response()
163 })?;
164
165 // Idempotent: return existing thread if external_ref matches
166 if let Some((thread_id,)) =
167 mt_db::queries::get_thread_by_external_ref(&state.db, &req.external_ref)
168 .await
169 .map_err(db_error)?
170 {
171 // Get the opening post ID
172 let posts = mt_db::queries::list_posts_in_thread_paginated(&state.db, thread_id, 1, 0)
173 .await
174 .map_err(db_error)?;
175 let post_id = posts.first().map_or(thread_id, |p| p.id);
176 return Ok(Json(CreateThreadResponse {
177 thread_id,
178 post_id,
179 created: false,
180 }));
181 }
182
183 let community = mt_db::queries::get_community_by_slug(&state.db, &req.community_slug)
184 .await
185 .map_err(db_error)?
186 .ok_or_else(|| (StatusCode::NOT_FOUND, "Community not found").into_response())?;
187
188 // Look up category, auto-create if it doesn't exist (supports on-demand "patches" etc.)
189 let category = match mt_db::queries::get_category_by_community_and_slug(
190 &state.db,
191 community.id,
192 &req.category_slug,
193 )
194 .await
195 .map_err(db_error)?
196 {
197 Some(cat) => cat,
198 None => {
199 let next_order = mt_db::queries::get_max_category_order(&state.db, community.id)
200 .await
201 .map_err(db_error)?
202 + 1;
203 let cat_name = capitalize(&req.category_slug);
204 let cat_id = mt_db::mutations::create_category(
205 &state.db,
206 community.id,
207 &cat_name,
208 &req.category_slug,
209 None,
210 next_order,
211 )
212 .await
213 .map_err(db_error)?;
214 tracing::info!(
215 category_slug = %req.category_slug,
216 community_slug = %req.community_slug,
217 "internal: auto-created category"
218 );
219 mt_db::queries::CategoryIdRow {
220 id: cat_id,
221 name: cat_name,
222 slug: req.category_slug.clone(),
223 }
224 }
225 };
226
227 mt_db::mutations::upsert_user(
228 &state.db,
229 req.author_mnw_id,
230 &req.author_username,
231 req.author_display_name.as_deref(),
232 )
233 .await
234 .map_err(db_error)?;
235
236 mt_db::mutations::ensure_membership(&state.db, req.author_mnw_id, community.id)
237 .await
238 .map_err(db_error)?;
239
240 // Create thread + opening post atomically (external_ref retry-safe).
241 let body_html = super::render_markdown(&req.body_markdown);
242 let (thread_id, post_id) = mt_db::mutations::create_thread_with_op_external_ref(
243 &state.db,
244 category.id,
245 req.author_mnw_id,
246 &req.title,
247 &req.external_ref,
248 &req.body_markdown,
249 &body_html,
250 )
251 .await
252 .map_err(db_error)?;
253
254 tracing::info!(
255 thread_id = %thread_id,
256 external_ref = %req.external_ref,
257 "internal: thread created"
258 );
259
260 Ok(Json(CreateThreadResponse {
261 thread_id,
262 post_id,
263 created: true,
264 }))
265 }
266
267 /// `GET /internal/threads/:id/stats`, Thread post count and last activity.
268 #[tracing::instrument(skip_all, name = "internal::thread_stats")]
269 async fn thread_stats(
270 State(state): State<AppState>,
271 Path(id): Path<String>,
272 // Single HMAC path: the same `InternalAuth` extractor the POST siblings use.
273 // It binds the concrete request path (from `req.uri().path()`) and an empty
274 // body for this GET, no hand-rolled second verification path to keep in sync.
275 InternalAuth(_body): InternalAuth,
276 ) -> Result<Json<ThreadStatsResponse>, Response> {
277 let thread_id = Uuid::parse_str(&id).map_err(|_| StatusCode::NOT_FOUND.into_response())?;
278
279 let (post_count, last_activity_at) = mt_db::queries::get_thread_stats(&state.db, thread_id)
280 .await
281 .map_err(db_error)?
282 .unwrap_or((0, None));
283
284 Ok(Json(ThreadStatsResponse {
285 post_count,
286 last_activity_at,
287 }))
288 }
289
290 /// `POST /internal/threads/:id/posts`, Add a reply to an existing thread.
291 #[tracing::instrument(skip_all, name = "internal::create_post")]
292 async fn create_post(
293 State(state): State<AppState>,
294 Path(id): Path<String>,
295 InternalAuth(body): InternalAuth,
296 ) -> Result<Json<CreatePostResponse>, Response> {
297 let thread_id = Uuid::parse_str(&id).map_err(|_| StatusCode::NOT_FOUND.into_response())?;
298
299 let req: CreatePostRequest = serde_json::from_slice(&body).map_err(|e| {
300 tracing::warn!(error = %e, "invalid create_post request body");
301 (StatusCode::BAD_REQUEST, "Invalid request body").into_response()
302 })?;
303
304 if !mt_db::queries::thread_exists(&state.db, thread_id)
305 .await
306 .map_err(db_error)?
307 {
308 return Err((StatusCode::NOT_FOUND, "Thread not found").into_response());
309 }
310
311 mt_db::mutations::upsert_user(
312 &state.db,
313 req.author_mnw_id,
314 &req.author_username,
315 req.author_display_name.as_deref(),
316 )
317 .await
318 .map_err(db_error)?;
319
320 // Look up thread's community and ensure membership. This is the internal
321 // server-to-server API (no URL slug to scope against), so the CommunityScope
322 // resolver doesn't apply, the thread id is supplied directly by the trusted
323 // internal caller, not derived from a `/p/{slug}/…` path.
324 let thread_info = mt_db::queries::get_thread_with_breadcrumb(&state.db, thread_id)
325 .await
326 .map_err(db_error)?
327 .ok_or_else(|| (StatusCode::NOT_FOUND, "Thread not found").into_response())?;
328 #[allow(clippy::disallowed_methods)] // internal API: no slug to verify against
329 let thread_info = thread_info.into_inner_unchecked();
330
331 mt_db::mutations::ensure_membership(&state.db, req.author_mnw_id, thread_info.community_id)
332 .await
333 .map_err(db_error)?;
334
335 // Render markdown and create post (idempotent on external_ref: a retried or
336 // replayed call returns the existing reply instead of duplicating it).
337 let body_html = super::render_markdown(&req.body_markdown);
338 let (post_id, created) = mt_db::mutations::create_post_external_ref(
339 &state.db,
340 thread_id,
341 req.author_mnw_id,
342 &req.external_ref,
343 &req.body_markdown,
344 &body_html,
345 )
346 .await
347 .map_err(db_error)?;
348
349 tracing::info!(
350 thread_id = %thread_id,
351 post_id = %post_id,
352 external_ref = %req.external_ref,
353 created,
354 "internal: post created"
355 );
356
357 Ok(Json(CreatePostResponse { post_id, created }))
358 }
359
360 /// Build the internal API router. Registered outside CSRF/session middleware.
361 ///
362 /// HMAC-authenticated, but still rate-limited per IP so an attacker can't flood
363 /// it with signature-guessing attempts unbounded. The cap is generous (burst
364 /// 60, then 20/sec), well above legitimate MNW→MT traffic.
365 pub fn internal_routes(state: AppState) -> Router {
366 let internal_rate_limit = std::sync::Arc::new(
367 GovernorConfigBuilder::default()
368 .key_extractor(crate::trusted_proxy::TrustedProxyKeyExtractor::new(
369 state.config.trusted_proxies.clone(),
370 ))
371 .per_millisecond(50)
372 .burst_size(60)
373 .finish()
374 .expect("internal rate limiter config"),
375 );
376
377 Router::new()
378 .route("/internal/communities", post(create_community))
379 .route("/internal/threads", post(create_thread))
380 .route("/internal/threads/{id}/posts", post(create_post))
381 .route("/internal/threads/{id}/stats", get(thread_stats))
382 .route_layer(GovernorLayer::new(internal_rate_limit))
383 .with_state(state)
384 }
385
386 // Helpers
387
388 #[allow(
389 clippy::needless_pass_by_value,
390 reason = "used as a `.map_err(db_error)` combinator, which requires the fn(E) -> R by-value signature"
391 )]
392 fn db_error(e: sqlx::Error) -> Response {
393 tracing::error!(error = %e, "internal API database error");
394 StatusCode::INTERNAL_SERVER_ERROR.into_response()
395 }
396
397 /// Capitalize the first letter of a string (for auto-created category names).
398 fn capitalize(s: &str) -> String {
399 let mut chars = s.chars();
400 match chars.next() {
401 None => String::new(),
402 Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
403 }
404 }
405