Skip to main content

max / makenotwork

13.3 KB · 372 lines History Blame Raw
1 //! Route handlers — MNW-integrated forum.
2
3 mod account;
4 mod admin;
5 mod flagging;
6 mod forum;
7 mod helpers;
8 pub mod internal;
9 mod moderation;
10 mod search;
11 mod settings;
12 mod tracking;
13 mod uploads;
14
15 // Re-export helpers so submodules can `use super::*` as before.
16 pub(crate) use helpers::*;
17
18 use axum::{
19 http::StatusCode,
20 response::IntoResponse,
21 Json, Router,
22 routing::{get, post},
23 };
24 use serde::Deserialize;
25 use tower_governor::{GovernorLayer, governor::GovernorConfigBuilder, key_extractor::SmartIpKeyExtractor};
26 use tower_sessions::Session;
27
28 use crate::auth::{self, MaybeUser};
29 use crate::csrf;
30 use crate::templates::*;
31 use crate::AppState;
32
33 // ============================================================================
34 // Rate limiting — per-IP on write endpoints
35 // ============================================================================
36
37 /// Write endpoints: burst 10, then 2/sec (one token per 500ms).
38 const WRITE_RATE_LIMIT_MS: u64 = 500;
39 const WRITE_RATE_LIMIT_BURST: u32 = 10;
40
41 /// Search endpoint: burst 5, then 1/sec — full-text + trigram queries are expensive.
42 const SEARCH_RATE_LIMIT_MS: u64 = 1000;
43 const SEARCH_RATE_LIMIT_BURST: u32 = 5;
44
45 /// Build the forum route tree.
46 pub fn forum_routes(state: AppState) -> Router {
47 let write_rate_limit = std::sync::Arc::new(
48 GovernorConfigBuilder::default()
49 .key_extractor(SmartIpKeyExtractor)
50 .per_millisecond(WRITE_RATE_LIMIT_MS)
51 .burst_size(WRITE_RATE_LIMIT_BURST)
52 .finish()
53 .expect("rate limiter config"),
54 );
55
56 // POST-only routes — rate limited per IP
57 let write_routes = Router::new()
58 .route("/p/{slug}/settings", post(settings::update_community_handler))
59 .route("/p/{slug}/settings/categories/new", post(settings::create_category_handler))
60 .route("/p/{slug}/settings/categories/{cat_id}/edit", post(settings::edit_category_handler))
61 .route("/p/{slug}/settings/categories/{cat_id}/move", post(settings::move_category_handler))
62 .route("/p/{slug}/settings/tags/new", post(settings::create_tag_handler))
63 .route("/p/{slug}/settings/tags/delete", post(settings::delete_tag_handler))
64 .route("/p/{slug}/settings/state", post(settings::set_community_state_handler))
65 .route("/account/signature", post(account::update_signature_handler))
66 .route("/p/{slug}/moderation/ban", post(moderation::ban_user_handler))
67 .route("/p/{slug}/moderation/unban", post(moderation::unban_user_handler))
68 .route("/p/{slug}/moderation/mute", post(moderation::mute_user_handler))
69 .route("/p/{slug}/moderation/unmute", post(moderation::unmute_user_handler))
70 .route("/p/{slug}/{category}/new", post(forum::create_thread_handler))
71 .route("/p/{slug}/{category}/{thread_id}/reply", post(forum::create_reply_handler))
72 .route("/p/{slug}/{category}/{thread_id}/edit", post(forum::edit_thread_handler))
73 .route("/p/{slug}/{category}/{thread_id}/delete", post(forum::delete_thread_handler))
74 .route("/p/{slug}/{category}/{thread_id}/pin", post(moderation::pin_thread_handler))
75 .route("/p/{slug}/{category}/{thread_id}/lock", post(moderation::lock_thread_handler))
76 .route("/p/{slug}/{category}/{thread_id}/posts/{post_id}/footnote", post(forum::add_footnote_handler))
77 .route("/p/{slug}/{category}/{thread_id}/posts/{post_id}/endorse", post(forum::toggle_endorsement_handler))
78 .route("/p/{slug}/{category}/{thread_id}/posts/{post_id}/remove", post(moderation::mod_remove_post_handler))
79 .route("/p/{slug}/{category}/{thread_id}/posts/{post_id}/flag", post(flagging::flag_post_handler))
80 .route("/p/{slug}/moderation/flags/{flag_id}/dismiss", post(flagging::dismiss_flag_handler))
81 .route("/p/{slug}/moderation/flags/{flag_id}/remove", post(flagging::remove_flagged_post_handler))
82 .route("/p/{slug}/{category}/{thread_id}/track", post(tracking::track_thread_handler))
83 .route("/p/{slug}/{category}/{thread_id}/untrack", post(tracking::untrack_thread_handler))
84 .route("/tracked/stop-all", post(tracking::untrack_all_handler))
85 .route("/_admin/communities/{id}/suspend", post(admin::suspend_community_handler))
86 .route("/_admin/communities/{id}/unsuspend", post(admin::unsuspend_community_handler))
87 .route("/_admin/communities/{slug}/clean-slate", post(admin::admin_community_clean_slate_handler))
88 .route("/_admin/users/{id}/suspend", post(admin::suspend_user_handler))
89 .route("/_admin/users/{id}/unsuspend", post(admin::unsuspend_user_handler))
90 .route("/p/{slug}/upload", post(uploads::upload_image_handler))
91 .route("/p/{slug}/uploads/{id}/remove", post(uploads::remove_image_handler))
92 .route_layer(GovernorLayer {
93 config: write_rate_limit,
94 });
95
96 // Search — rate limited per IP (expensive full-text queries)
97 let search_rate_limit = std::sync::Arc::new(
98 GovernorConfigBuilder::default()
99 .key_extractor(SmartIpKeyExtractor)
100 .per_millisecond(SEARCH_RATE_LIMIT_MS)
101 .burst_size(SEARCH_RATE_LIMIT_BURST)
102 .finish()
103 .expect("search rate limiter config"),
104 );
105
106 let search_routes = Router::new()
107 .route("/search", get(search::search_handler))
108 .route_layer(GovernorLayer {
109 config: search_rate_limit,
110 });
111
112 // GET routes + auth + health — no rate limiting
113 let read_routes = Router::new()
114 .route("/", get(forum::forum_directory))
115 .route("/p/{slug}", get(forum::project_forum))
116 .route("/p/{slug}/members", get(forum::community_members))
117 .route("/p/{slug}/u/{username}", get(forum::user_profile))
118 .route("/account", get(account::account_settings))
119 .route("/p/{slug}/settings", get(settings::community_settings))
120 .route("/p/{slug}/settings/categories/{cat_id}/edit", get(settings::edit_category_form))
121 .route("/p/{slug}/moderation", get(moderation::moderation_page))
122 .route("/p/{slug}/moderation/log", get(moderation::mod_log_page))
123 .route("/p/{slug}/{category}", get(forum::category))
124 .route("/p/{slug}/{category}/new", get(forum::new_thread))
125 .route("/p/{slug}/{category}/{thread_id}", get(forum::thread))
126 .route("/p/{slug}/{category}/{thread_id}/edit", get(forum::edit_thread_form))
127 .route("/tracked", get(tracking::tracked_threads_page))
128 .route("/about/tracking", get(tracking::tracking_info_page))
129 .route("/_admin", get(admin::admin_dashboard))
130 .route("/_admin/communities/{slug}", get(admin::admin_community_detail))
131 .route("/auth/login", get(auth::login))
132 .route("/auth/callback", get(auth::callback))
133 .route("/auth/logout", post(auth::logout))
134 .route("/auth/refresh", post(auth::refresh))
135 .route("/api/user/{user_id}/summary", get(forum::user_summary_api))
136 .route("/uploads/{id}", get(uploads::serve_image_handler))
137 .route("/api/health", get(health));
138
139 read_routes
140 .merge(search_routes)
141 .merge(write_routes)
142 .fallback(not_found_handler)
143 .with_state(state)
144 }
145
146 // ============================================================================
147 // Form types
148 // ============================================================================
149
150 #[derive(Deserialize)]
151 pub(super) struct CreateThreadForm {
152 pub(super) title: String,
153 pub(super) body: String,
154 #[serde(default, deserialize_with = "deserialize_string_or_seq")]
155 pub(super) tags: Vec<String>,
156 }
157
158 /// Deserialize a form field that may be a single string or a repeated-key sequence.
159 /// serde_urlencoded sends a single `tags=x` as a string, but `tags=x&tags=y` as a sequence.
160 fn deserialize_string_or_seq<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
161 where
162 D: serde::Deserializer<'de>,
163 {
164 struct StringOrSeq;
165
166 impl<'de> serde::de::Visitor<'de> for StringOrSeq {
167 type Value = Vec<String>;
168
169 fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
170 f.write_str("a string or sequence of strings")
171 }
172
173 fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<Vec<String>, E> {
174 Ok(vec![v.to_string()])
175 }
176
177 fn visit_seq<A: serde::de::SeqAccess<'de>>(self, mut seq: A) -> Result<Vec<String>, A::Error> {
178 let mut v = Vec::new();
179 while let Some(s) = seq.next_element::<String>()? {
180 v.push(s);
181 }
182 Ok(v)
183 }
184 }
185
186 deserializer.deserialize_any(StringOrSeq)
187 }
188
189 #[derive(Deserialize)]
190 pub(super) struct CreateReplyForm {
191 pub(super) body: String,
192 }
193
194 #[derive(Deserialize)]
195 pub(super) struct FootnoteForm {
196 pub(super) body: String,
197 }
198
199 #[derive(Deserialize)]
200 pub(super) struct EditThreadForm {
201 pub(super) title: String,
202 }
203
204 #[derive(Deserialize)]
205 pub(super) struct UpdateCommunityForm {
206 pub(super) name: String,
207 pub(super) description: String,
208 pub(super) auto_hide_threshold: Option<String>,
209 }
210
211 /// `POST /_admin/communities/{slug}/clean-slate` confirmation form.
212 /// `confirm` must exactly match the community slug (typed-phrase pattern).
213 #[derive(Deserialize)]
214 pub(super) struct CleanSlateForm {
215 pub(super) confirm: String,
216 }
217
218 #[derive(Deserialize)]
219 pub(super) struct SignatureForm {
220 pub(super) signature: String,
221 /// `Some("1")` when the Clear button is pressed.
222 pub(super) clear: Option<String>,
223 }
224
225 #[derive(Deserialize)]
226 pub(super) struct SetCommunityStateForm {
227 /// Target state: `"active" | "restricted" | "frozen" | "archived"`.
228 pub(super) state: String,
229 }
230
231 #[derive(Deserialize)]
232 pub(super) struct CreateCategoryForm {
233 pub(super) name: String,
234 pub(super) slug: String,
235 pub(super) description: String,
236 }
237
238 #[derive(Deserialize)]
239 pub(super) struct EditCategoryFormData {
240 pub(super) name: String,
241 pub(super) description: String,
242 }
243
244 #[derive(Deserialize)]
245 pub(super) struct MoveCategoryForm {
246 pub(super) direction: String,
247 }
248
249 #[derive(Deserialize)]
250 pub(super) struct PageQuery {
251 pub(super) page: Option<u32>,
252 }
253
254 /// Query for `/` forum directory. `filter=archived` shows only archived
255 /// communities; otherwise default listing (archived hidden).
256 #[derive(Deserialize)]
257 pub(super) struct ForumDirectoryQuery {
258 pub(super) page: Option<u32>,
259 pub(super) filter: Option<String>,
260 }
261
262 #[derive(Deserialize)]
263 pub(super) struct CategoryQuery {
264 pub(super) page: Option<u32>,
265 pub(super) sort: Option<String>,
266 pub(super) order: Option<String>,
267 pub(super) tag: Option<String>,
268 }
269
270 #[derive(Deserialize)]
271 pub(super) struct BanForm {
272 pub(super) username: String,
273 pub(super) duration: String,
274 pub(super) reason: Option<String>,
275 }
276
277 #[derive(Deserialize)]
278 pub(super) struct UnbanForm {
279 pub(super) username: String,
280 }
281
282 #[derive(Deserialize)]
283 pub(super) struct AdminSearchQuery {
284 pub(super) q: Option<String>,
285 }
286
287 #[derive(Deserialize)]
288 pub(super) struct SuspendForm {
289 pub(super) reason: Option<String>,
290 }
291
292 #[derive(Deserialize)]
293 pub(super) struct CreateTagForm {
294 pub(super) name: String,
295 pub(super) slug: String,
296 }
297
298 #[derive(Deserialize)]
299 pub(super) struct DeleteTagForm {
300 pub(super) tag_id: String,
301 }
302
303 // ============================================================================
304 // Handlers
305 // ============================================================================
306
307 /// Health check — proves the service is responding and the database is reachable.
308 #[tracing::instrument(skip_all)]
309 async fn health(
310 axum::extract::State(state): axum::extract::State<AppState>,
311 ) -> Json<serde_json::Value> {
312 let db_ok = sqlx::query_scalar::<_, i32>("SELECT 1")
313 .fetch_one(&state.db)
314 .await
315 .is_ok();
316
317 Json(health_body(db_ok))
318 }
319
320 /// Build the JSON body for the `/api/health` response.
321 ///
322 /// Kept as a pure function (no AppState, no DB) so the schema-drift guard
323 /// test in this module can exercise it directly. PoM polls this endpoint
324 /// and runs key-by-key assertions from `pom/deploy/pom-hetzner.toml`; the
325 /// guard test validates that every asserted path still resolves here.
326 fn health_body(db_ok: bool) -> serde_json::Value {
327 let status = if db_ok { "operational" } else { "degraded" };
328 serde_json::json!({
329 "status": status,
330 "version": env!("CARGO_PKG_VERSION"),
331 "database": db_ok,
332 })
333 }
334
335 #[cfg(test)]
336 mod health_tests {
337 use super::health_body;
338
339 /// Schema-drift guard for the `mt` target. See `shared/pom-contract/`.
340 #[test]
341 fn pom_hetzner_health_expectations_resolve() {
342 let body = health_body(true);
343 pom_contract::assert_health_expectations_resolve(
344 "../pom/deploy/pom-hetzner.toml",
345 "mt",
346 &body,
347 );
348 }
349 }
350
351 // ============================================================================
352 // 404 fallback
353 // ============================================================================
354
355 #[tracing::instrument(skip_all)]
356 async fn not_found_handler(
357 axum::extract::State(state): axum::extract::State<AppState>,
358 session: Session,
359 MaybeUser(session_user): MaybeUser,
360 ) -> impl IntoResponse {
361 let csrf_token = Some(csrf::get_or_create_token(&session).await);
362 let session_user = session_user.as_ref().map(|u| template_user(u, state.config.platform_admin_id));
363 (
364 StatusCode::NOT_FOUND,
365 Error404Template {
366 csrf_token,
367 session_user,
368 mnw_base_url: state.config.mnw_base_url.clone(),
369 },
370 )
371 }
372