Skip to main content

max / makenotwork

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