Skip to main content

max / makenotwork

20.0 KB · 615 lines History Blame Raw
1 //! Route handlers, MNW-integrated forum.
2
3 mod account;
4 mod admin;
5 mod flagging;
6 mod forum;
7 pub(crate) mod helpers;
8 pub mod internal;
9 mod moderation;
10 mod scope;
11 mod search;
12 mod settings;
13 mod tracking;
14 mod uploads;
15
16 // Re-export helpers so submodules can `use super::*` as before.
17 pub(crate) use helpers::*;
18 pub(crate) use scope::CommunityScope;
19
20 use axum::{
21 Json, Router,
22 http::StatusCode,
23 response::{IntoResponse, Response},
24 routing::{get, post},
25 };
26 use serde::Deserialize;
27 use tower_governor::{GovernorLayer, governor::GovernorConfigBuilder};
28 use tower_sessions::Session;
29
30 use crate::trusted_proxy::TrustedProxyKeyExtractor;
31
32 use crate::AppState;
33 use crate::auth::{self, MaybeUser};
34 use crate::csrf;
35 use crate::templates::Error404Template;
36
37 // Rate limiting, per-IP on write endpoints
38
39 /// Write endpoints: burst 10, then 2/sec (one token per 500ms).
40 const WRITE_RATE_LIMIT_MS: u64 = 500;
41 const WRITE_RATE_LIMIT_BURST: u32 = 10;
42
43 /// Search endpoint: burst 5, then 1/sec, full-text + trigram queries are expensive.
44 const SEARCH_RATE_LIMIT_MS: u64 = 1000;
45 const SEARCH_RATE_LIMIT_BURST: u32 = 5;
46
47 /// Upload request body cap: the image size limit plus headroom for multipart
48 /// framing. Bounds the in-memory buffer before the handler reads the field.
49 const MAX_UPLOAD_BODY_BYTES: usize = crate::storage::MAX_IMAGE_SIZE + 64 * 1024;
50
51 /// Auth endpoints: burst 10, then 1/sec. Throttles login/callback floods and
52 /// the `/auth/refresh` → MNW userinfo amplifier.
53 const AUTH_RATE_LIMIT_MS: u64 = 1000;
54 const AUTH_RATE_LIMIT_BURST: u32 = 10;
55
56 /// Image serve (`/uploads/{id}`): generous, since a single page legitimately
57 /// fans out one request per embedded `<img>`. Burst 60 covers an image-heavy
58 /// page load, then refills ~20/sec, enough to bound an unauthenticated
59 /// S3-egress proxy against a scraping/amplification flood (ultra-fuzz Mi1)
60 /// without throttling normal browsing.
61 const IMAGE_RATE_LIMIT_MS: u64 = 50;
62 const IMAGE_RATE_LIMIT_BURST: u32 = 60;
63
64 /// Build the forum route tree.
65 pub fn forum_routes(state: AppState) -> Router {
66 let write_rate_limit = std::sync::Arc::new(
67 GovernorConfigBuilder::default()
68 .key_extractor(TrustedProxyKeyExtractor::new(
69 state.config.trusted_proxies.clone(),
70 ))
71 .per_millisecond(WRITE_RATE_LIMIT_MS)
72 .burst_size(WRITE_RATE_LIMIT_BURST)
73 .finish()
74 .expect("rate limiter config"),
75 );
76
77 // POST-only routes, rate limited per IP
78 let write_routes = Router::new()
79 .route(
80 "/p/{slug}/settings",
81 post(settings::update_community_handler),
82 )
83 .route(
84 "/p/{slug}/settings/categories/new",
85 post(settings::create_category_handler),
86 )
87 .route(
88 "/p/{slug}/settings/categories/{cat_id}/edit",
89 post(settings::edit_category_handler),
90 )
91 .route(
92 "/p/{slug}/settings/categories/{cat_id}/move",
93 post(settings::move_category_handler),
94 )
95 .route(
96 "/p/{slug}/settings/tags/new",
97 post(settings::create_tag_handler),
98 )
99 .route(
100 "/p/{slug}/settings/tags/delete",
101 post(settings::delete_tag_handler),
102 )
103 .route(
104 "/p/{slug}/settings/state",
105 post(settings::set_community_state_handler),
106 )
107 .route(
108 "/account/signature",
109 post(account::update_signature_handler),
110 )
111 .route(
112 "/p/{slug}/moderation/ban",
113 post(moderation::ban_user_handler),
114 )
115 .route(
116 "/p/{slug}/moderation/unban",
117 post(moderation::unban_user_handler),
118 )
119 .route(
120 "/p/{slug}/moderation/mute",
121 post(moderation::mute_user_handler),
122 )
123 .route(
124 "/p/{slug}/moderation/unmute",
125 post(moderation::unmute_user_handler),
126 )
127 .route(
128 "/p/{slug}/{category}/new",
129 post(forum::create_thread_handler),
130 )
131 .route(
132 "/p/{slug}/{category}/{thread_id}/reply",
133 post(forum::create_reply_handler),
134 )
135 .route(
136 "/p/{slug}/{category}/{thread_id}/edit",
137 post(forum::edit_thread_handler),
138 )
139 .route(
140 "/p/{slug}/{category}/{thread_id}/delete",
141 post(forum::delete_thread_handler),
142 )
143 .route(
144 "/p/{slug}/{category}/{thread_id}/pin",
145 post(moderation::pin_thread_handler),
146 )
147 .route(
148 "/p/{slug}/{category}/{thread_id}/lock",
149 post(moderation::lock_thread_handler),
150 )
151 .route(
152 "/p/{slug}/{category}/{thread_id}/posts/{post_id}/footnote",
153 post(forum::add_footnote_handler),
154 )
155 .route(
156 "/p/{slug}/{category}/{thread_id}/posts/{post_id}/endorse",
157 post(forum::toggle_endorsement_handler),
158 )
159 .route(
160 "/p/{slug}/{category}/{thread_id}/posts/{post_id}/remove",
161 post(moderation::mod_remove_post_handler),
162 )
163 .route(
164 "/p/{slug}/{category}/{thread_id}/posts/{post_id}/restore",
165 post(moderation::mod_restore_post_handler),
166 )
167 .route(
168 "/p/{slug}/{category}/{thread_id}/posts/{post_id}/flag",
169 post(flagging::flag_post_handler),
170 )
171 .route(
172 "/p/{slug}/moderation/flags/{flag_id}/dismiss",
173 post(flagging::dismiss_flag_handler),
174 )
175 .route(
176 "/p/{slug}/moderation/flags/{flag_id}/remove",
177 post(flagging::remove_flagged_post_handler),
178 )
179 .route(
180 "/p/{slug}/{category}/{thread_id}/track",
181 post(tracking::track_thread_handler),
182 )
183 .route(
184 "/p/{slug}/{category}/{thread_id}/untrack",
185 post(tracking::untrack_thread_handler),
186 )
187 .route("/tracked/stop-all", post(tracking::untrack_all_handler))
188 .route(
189 "/_admin/communities/{id}/suspend",
190 post(admin::suspend_community_handler),
191 )
192 .route(
193 "/_admin/communities/{id}/unsuspend",
194 post(admin::unsuspend_community_handler),
195 )
196 .route(
197 "/_admin/communities/{slug}/clean-slate",
198 post(admin::admin_community_clean_slate_handler),
199 )
200 .route(
201 "/_admin/users/{id}/suspend",
202 post(admin::suspend_user_handler),
203 )
204 .route(
205 "/_admin/users/{id}/unsuspend",
206 post(admin::unsuspend_user_handler),
207 )
208 .route(
209 "/p/{slug}/upload",
210 post(uploads::upload_image_handler)
211 .layer(axum::extract::DefaultBodyLimit::max(MAX_UPLOAD_BODY_BYTES)),
212 )
213 .route(
214 "/p/{slug}/uploads/{id}/remove",
215 post(uploads::remove_image_handler),
216 )
217 .route_layer(GovernorLayer::new(write_rate_limit.clone()));
218
219 // Search, rate limited per IP (expensive full-text queries)
220 let search_rate_limit = std::sync::Arc::new(
221 GovernorConfigBuilder::default()
222 .key_extractor(TrustedProxyKeyExtractor::new(
223 state.config.trusted_proxies.clone(),
224 ))
225 .per_millisecond(SEARCH_RATE_LIMIT_MS)
226 .burst_size(SEARCH_RATE_LIMIT_BURST)
227 .finish()
228 .expect("search rate limiter config"),
229 );
230
231 let search_routes = Router::new()
232 .route("/search", get(search::search_handler))
233 .route_layer(GovernorLayer::new(search_rate_limit.clone()));
234
235 // Auth endpoints, rate limited per IP (login/callback flood + refresh
236 // amplifier against MNW).
237 let auth_rate_limit = std::sync::Arc::new(
238 GovernorConfigBuilder::default()
239 .key_extractor(TrustedProxyKeyExtractor::new(
240 state.config.trusted_proxies.clone(),
241 ))
242 .per_millisecond(AUTH_RATE_LIMIT_MS)
243 .burst_size(AUTH_RATE_LIMIT_BURST)
244 .finish()
245 .expect("auth rate limiter config"),
246 );
247
248 let auth_routes = Router::new()
249 .route("/auth/login", get(auth::login))
250 .route("/auth/reverify", get(auth::reverify))
251 .route("/auth/callback", get(auth::callback))
252 .route("/auth/logout", post(auth::logout))
253 .route("/auth/refresh", post(auth::refresh))
254 .route_layer(GovernorLayer::new(auth_rate_limit.clone()));
255
256 // Image serve, per-IP rate limited. `/uploads/{id}` is an unauthenticated
257 // S3-egress proxy (it streams bytes to any viewer who passes the community
258 // access check), so a generous governor bounds scraping/amplification floods
259 // (ultra-fuzz Mi1) while leaving image-heavy page loads unthrottled.
260 let image_rate_limit = std::sync::Arc::new(
261 GovernorConfigBuilder::default()
262 .key_extractor(TrustedProxyKeyExtractor::new(
263 state.config.trusted_proxies.clone(),
264 ))
265 .per_millisecond(IMAGE_RATE_LIMIT_MS)
266 .burst_size(IMAGE_RATE_LIMIT_BURST)
267 .finish()
268 .expect("image rate limiter config"),
269 );
270
271 let image_routes = Router::new()
272 .route("/uploads/{id}", get(uploads::serve_image_handler))
273 .route("/img-proxy", get(uploads::image_proxy_handler))
274 .route_layer(GovernorLayer::new(image_rate_limit.clone()));
275
276 // Periodically evict idle per-IP buckets from every rate limiter so the
277 // keyspace can't grow unbounded over the process lifetime (M-Pf3).
278 // Trusted-proxy keying already bounds keys to real client IPs, but a
279 // long-running server still accumulates one-off visitors; `retain_recent`
280 // drops buckets with no recent activity. Spawned here because `forum_routes`
281 // runs inside the tokio runtime at startup.
282 {
283 let limiters = [
284 write_rate_limit.limiter().clone(),
285 search_rate_limit.limiter().clone(),
286 auth_rate_limit.limiter().clone(),
287 image_rate_limit.limiter().clone(),
288 ];
289 tokio::spawn(async move {
290 let mut interval = tokio::time::interval(std::time::Duration::from_mins(5));
291 interval.tick().await; // consume the immediate first tick
292 loop {
293 interval.tick().await;
294 for limiter in &limiters {
295 limiter.retain_recent();
296 }
297 }
298 });
299 }
300
301 // GET routes + health, no rate limiting
302 let read_routes = Router::new()
303 .route("/", get(forum::forum_directory))
304 .route("/p/{slug}", get(forum::project_forum))
305 .route("/p/{slug}/members", get(forum::community_members))
306 .route("/p/{slug}/u/{username}", get(forum::user_profile))
307 .route("/account", get(account::account_settings))
308 .route("/p/{slug}/settings", get(settings::community_settings))
309 .route(
310 "/p/{slug}/settings/categories/{cat_id}/edit",
311 get(settings::edit_category_form),
312 )
313 .route("/p/{slug}/moderation", get(moderation::moderation_page))
314 .route("/p/{slug}/moderation/log", get(moderation::mod_log_page))
315 .route(
316 "/p/{slug}/moderation/deleted",
317 get(moderation::deleted_threads_page),
318 )
319 .route(
320 "/p/{slug}/moderation/threads/{thread_id}/restore",
321 post(moderation::restore_thread_handler),
322 )
323 .route("/p/{slug}/{category}", get(forum::category))
324 .route("/p/{slug}/{category}/new", get(forum::new_thread))
325 .route("/p/{slug}/{category}/{thread_id}", get(forum::thread))
326 .route(
327 "/p/{slug}/{category}/{thread_id}/edit",
328 get(forum::edit_thread_form),
329 )
330 .route("/tracked", get(tracking::tracked_threads_page))
331 .route("/about/tracking", get(tracking::tracking_info_page))
332 .route("/_admin", get(admin::admin_dashboard))
333 .route(
334 "/_admin/communities/{slug}",
335 get(admin::admin_community_detail),
336 )
337 .route("/api/user/{user_id}/summary", get(forum::user_summary_api))
338 .route("/api/health", get(health));
339
340 read_routes
341 .merge(search_routes)
342 .merge(auth_routes)
343 .merge(image_routes)
344 .merge(write_routes)
345 .fallback(not_found_handler)
346 .with_state(state)
347 }
348
349 // Form types
350
351 #[derive(Deserialize)]
352 pub(super) struct CreateThreadForm {
353 pub(super) title: String,
354 pub(super) body: String,
355 #[serde(default, deserialize_with = "deserialize_string_or_seq")]
356 pub(super) tags: Vec<String>,
357 }
358
359 /// Deserialize a form field that may be a single string or a repeated-key sequence.
360 /// serde_urlencoded sends a single `tags=x` as a string, but `tags=x&tags=y` as a sequence.
361 fn deserialize_string_or_seq<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
362 where
363 D: serde::Deserializer<'de>,
364 {
365 struct StringOrSeq;
366
367 impl<'de> serde::de::Visitor<'de> for StringOrSeq {
368 type Value = Vec<String>;
369
370 fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
371 f.write_str("a string or sequence of strings")
372 }
373
374 fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<Vec<String>, E> {
375 Ok(vec![v.to_string()])
376 }
377
378 fn visit_seq<A: serde::de::SeqAccess<'de>>(
379 self,
380 mut seq: A,
381 ) -> Result<Vec<String>, A::Error> {
382 let mut v = Vec::new();
383 while let Some(s) = seq.next_element::<String>()? {
384 v.push(s);
385 }
386 Ok(v)
387 }
388 }
389
390 deserializer.deserialize_any(StringOrSeq)
391 }
392
393 #[derive(Deserialize)]
394 pub(super) struct CreateReplyForm {
395 pub(super) body: String,
396 }
397
398 #[derive(Deserialize)]
399 pub(super) struct FootnoteForm {
400 pub(super) body: String,
401 }
402
403 #[derive(Deserialize)]
404 pub(super) struct EditThreadForm {
405 pub(super) title: String,
406 }
407
408 #[derive(Deserialize)]
409 pub(super) struct UpdateCommunityForm {
410 pub(super) name: String,
411 pub(super) description: String,
412 pub(super) auto_hide_threshold: Option<String>,
413 }
414
415 /// `POST /_admin/communities/{slug}/clean-slate` confirmation form.
416 /// `confirm` must exactly match the community slug (typed-phrase pattern).
417 #[derive(Deserialize)]
418 pub(super) struct CleanSlateForm {
419 pub(super) confirm: String,
420 }
421
422 #[derive(Deserialize)]
423 pub(super) struct SignatureForm {
424 pub(super) signature: String,
425 /// `Some("1")` when the Clear button is pressed.
426 pub(super) clear: Option<String>,
427 }
428
429 #[derive(Deserialize)]
430 pub(super) struct SetCommunityStateForm {
431 /// Target state: `"active" | "restricted" | "frozen" | "archived"`.
432 pub(super) state: String,
433 }
434
435 #[derive(Deserialize)]
436 pub(super) struct CreateCategoryForm {
437 pub(super) name: String,
438 pub(super) slug: String,
439 pub(super) description: String,
440 }
441
442 #[derive(Deserialize)]
443 pub(super) struct EditCategoryFormData {
444 pub(super) name: String,
445 pub(super) description: String,
446 }
447
448 #[derive(Deserialize)]
449 pub(super) struct MoveCategoryForm {
450 pub(super) direction: String,
451 }
452
453 #[derive(Deserialize)]
454 pub(super) struct PageQuery {
455 pub(super) page: Option<u32>,
456 }
457
458 /// Query for `/` forum directory. `filter=archived` shows only archived
459 /// communities; otherwise default listing (archived hidden).
460 #[derive(Deserialize)]
461 pub(super) struct ForumDirectoryQuery {
462 pub(super) page: Option<u32>,
463 pub(super) filter: Option<String>,
464 }
465
466 #[derive(Deserialize)]
467 pub(super) struct CategoryQuery {
468 pub(super) page: Option<u32>,
469 pub(super) sort: Option<String>,
470 pub(super) order: Option<String>,
471 pub(super) tag: Option<String>,
472 }
473
474 #[derive(Deserialize)]
475 pub(super) struct BanForm {
476 pub(super) username: String,
477 pub(super) duration: String,
478 pub(super) reason: Option<String>,
479 }
480
481 #[derive(Deserialize)]
482 pub(super) struct UnbanForm {
483 pub(super) username: String,
484 }
485
486 #[derive(Deserialize)]
487 pub(super) struct AdminSearchQuery {
488 pub(super) q: Option<String>,
489 }
490
491 #[derive(Deserialize)]
492 pub(super) struct SuspendForm {
493 pub(super) reason: Option<String>,
494 }
495
496 #[derive(Deserialize)]
497 pub(super) struct CreateTagForm {
498 pub(super) name: String,
499 pub(super) slug: String,
500 }
501
502 #[derive(Deserialize)]
503 pub(super) struct DeleteTagForm {
504 pub(super) tag_id: String,
505 }
506
507 // Handlers
508
509 /// Health check, proves the service is responding and the database is reachable.
510 ///
511 /// Returns `200 OK` when the DB is reachable and `503 Service Unavailable` when
512 /// it is not, so a status-only uptime probe or load-balancer healthcheck can't
513 /// read a box that can't serve a single DB-backed page as healthy. PoM parses
514 /// the JSON body key-by-key and additionally expects `200` for the operational
515 /// case (`pom/deploy/pom-hetzner.toml`), which the OK branch satisfies.
516 #[tracing::instrument(skip_all)]
517 async fn health(axum::extract::State(state): axum::extract::State<AppState>) -> impl IntoResponse {
518 let db_ok = sqlx::query_scalar::<_, i32>("SELECT 1")
519 .fetch_one(&state.db)
520 .await
521 .is_ok();
522
523 (health_status(db_ok), Json(health_body(db_ok)))
524 }
525
526 /// Map DB reachability to the HTTP status. Pure so the status contract can be
527 /// tested without a live DB (mirrors `health_body`).
528 fn health_status(db_ok: bool) -> StatusCode {
529 if db_ok {
530 StatusCode::OK
531 } else {
532 StatusCode::SERVICE_UNAVAILABLE
533 }
534 }
535
536 /// Build the JSON body for the `/api/health` response.
537 ///
538 /// Kept as a pure function (no AppState, no DB) so the schema-drift guard
539 /// test in this module can exercise it directly. PoM polls this endpoint
540 /// and runs key-by-key assertions from `pom/deploy/pom-hetzner.toml`; the
541 /// guard test validates that every asserted path still resolves here.
542 fn health_body(db_ok: bool) -> serde_json::Value {
543 let status = if db_ok { "operational" } else { "degraded" };
544 serde_json::json!({
545 "status": status,
546 "version": env!("CARGO_PKG_VERSION"),
547 // The commit this binary was built from (short sha, set by build.rs).
548 // `null` on a build without git metadata. Lets monitoring see a
549 // same-semver redeploy, which `version` alone cannot distinguish.
550 "git_sha": option_env!("GIT_HASH").filter(|h| !h.is_empty()),
551 "database": db_ok,
552 })
553 }
554
555 // 404 fallback
556
557 #[tracing::instrument(skip_all)]
558 async fn not_found_handler(
559 axum::extract::State(state): axum::extract::State<AppState>,
560 session: Session,
561 MaybeUser(session_user): MaybeUser,
562 ) -> Result<impl IntoResponse, Response> {
563 let csrf_token = Some(csrf::get_or_create_token(&session).await?);
564 let session_user = session_user
565 .as_ref()
566 .map(|u| template_user(u, state.config.platform_admin_id));
567 Ok((
568 StatusCode::NOT_FOUND,
569 Error404Template {
570 csrf_token,
571 session_user,
572 mnw_base_url: state.config.mnw_base_url.clone(),
573 },
574 ))
575 }
576
577 #[cfg(test)]
578 mod health_tests {
579 use super::{health_body, health_status};
580 use axum::http::StatusCode;
581
582 /// Schema-drift guard for the `mt` target. See `shared/pom-contract/`.
583 #[test]
584 fn pom_hetzner_health_expectations_resolve() {
585 let body = health_body(true);
586 pom_contract::assert_health_expectations_resolve(
587 "../pom/deploy/pom-hetzner.toml",
588 "mt",
589 &body,
590 );
591 }
592
593 /// The `git_sha` key is what tells monitoring a same-semver redeploy
594 /// happened, so lock its presence. Its value varies per build (and is
595 /// `null` without git metadata), which is why it is asserted here rather
596 /// than in PoM's exact-match `json_fields`.
597 #[test]
598 fn health_body_carries_version_and_git_sha_keys() {
599 let body = health_body(true);
600 assert_eq!(body["version"], env!("CARGO_PKG_VERSION"));
601 assert!(
602 body.get("git_sha").is_some(),
603 "git_sha key must be present (null is fine)"
604 );
605 }
606
607 /// A reachable DB is `200`; an unreachable DB is `503` so status-only probes
608 /// don't read a degraded box as healthy. PoM expects `200` for operational.
609 #[test]
610 fn health_status_reflects_db_reachability() {
611 assert_eq!(health_status(true), StatusCode::OK);
612 assert_eq!(health_status(false), StatusCode::SERVICE_UNAVAILABLE);
613 }
614 }
615