Skip to main content

max / multithreaded

10.2 KB · 282 lines History Blame Raw
1 //! Route handlers — MNW-integrated forum.
2
3 mod admin;
4 mod flagging;
5 mod forum;
6 mod helpers;
7 pub mod internal;
8 mod moderation;
9 mod search;
10 mod settings;
11 mod tracking;
12 mod uploads;
13
14 // Re-export helpers so submodules can `use super::*` as before.
15 pub(crate) use helpers::*;
16
17 use axum::{
18 http::StatusCode,
19 response::IntoResponse,
20 Json, Router,
21 routing::{get, post},
22 };
23 use serde::Deserialize;
24 use tower_governor::{GovernorLayer, governor::GovernorConfigBuilder, key_extractor::SmartIpKeyExtractor};
25 use tower_sessions::Session;
26
27 use crate::auth::{self, MaybeUser};
28 use crate::csrf;
29 use crate::templates::*;
30 use crate::AppState;
31
32 // ============================================================================
33 // Rate limiting — per-IP on write endpoints
34 // ============================================================================
35
36 /// Write endpoints: burst 10, then 2/sec (one token per 500ms).
37 const WRITE_RATE_LIMIT_MS: u64 = 500;
38 const WRITE_RATE_LIMIT_BURST: u32 = 10;
39
40 /// Build the forum route tree.
41 pub fn forum_routes(state: AppState) -> Router {
42 let write_rate_limit = std::sync::Arc::new(
43 GovernorConfigBuilder::default()
44 .key_extractor(SmartIpKeyExtractor)
45 .per_millisecond(WRITE_RATE_LIMIT_MS)
46 .burst_size(WRITE_RATE_LIMIT_BURST)
47 .finish()
48 .expect("rate limiter config"),
49 );
50
51 // POST-only routes — rate limited per IP
52 let write_routes = Router::new()
53 .route("/p/{slug}/settings", post(settings::update_community_handler))
54 .route("/p/{slug}/settings/categories/new", post(settings::create_category_handler))
55 .route("/p/{slug}/settings/categories/{cat_id}/edit", post(settings::edit_category_handler))
56 .route("/p/{slug}/settings/categories/{cat_id}/move", post(settings::move_category_handler))
57 .route("/p/{slug}/settings/tags/new", post(settings::create_tag_handler))
58 .route("/p/{slug}/settings/tags/delete", post(settings::delete_tag_handler))
59 .route("/p/{slug}/moderation/ban", post(moderation::ban_user_handler))
60 .route("/p/{slug}/moderation/unban", post(moderation::unban_user_handler))
61 .route("/p/{slug}/moderation/mute", post(moderation::mute_user_handler))
62 .route("/p/{slug}/moderation/unmute", post(moderation::unmute_user_handler))
63 .route("/p/{slug}/{category}/new", post(forum::create_thread_handler))
64 .route("/p/{slug}/{category}/{thread_id}/reply", post(forum::create_reply_handler))
65 .route("/p/{slug}/{category}/{thread_id}/edit", post(forum::edit_thread_handler))
66 .route("/p/{slug}/{category}/{thread_id}/delete", post(forum::delete_thread_handler))
67 .route("/p/{slug}/{category}/{thread_id}/pin", post(moderation::pin_thread_handler))
68 .route("/p/{slug}/{category}/{thread_id}/lock", post(moderation::lock_thread_handler))
69 .route("/p/{slug}/{category}/{thread_id}/posts/{post_id}/footnote", post(forum::add_footnote_handler))
70 .route("/p/{slug}/{category}/{thread_id}/posts/{post_id}/endorse", post(forum::toggle_endorsement_handler))
71 .route("/p/{slug}/{category}/{thread_id}/posts/{post_id}/remove", post(moderation::mod_remove_post_handler))
72 .route("/p/{slug}/{category}/{thread_id}/posts/{post_id}/flag", post(flagging::flag_post_handler))
73 .route("/p/{slug}/moderation/flags/{flag_id}/dismiss", post(flagging::dismiss_flag_handler))
74 .route("/p/{slug}/moderation/flags/{flag_id}/remove", post(flagging::remove_flagged_post_handler))
75 .route("/p/{slug}/{category}/{thread_id}/track", post(tracking::track_thread_handler))
76 .route("/p/{slug}/{category}/{thread_id}/untrack", post(tracking::untrack_thread_handler))
77 .route("/tracked/stop-all", post(tracking::untrack_all_handler))
78 .route("/_admin/communities/{id}/suspend", post(admin::suspend_community_handler))
79 .route("/_admin/communities/{id}/unsuspend", post(admin::unsuspend_community_handler))
80 .route("/_admin/users/{id}/suspend", post(admin::suspend_user_handler))
81 .route("/_admin/users/{id}/unsuspend", post(admin::unsuspend_user_handler))
82 .route("/p/{slug}/upload", post(uploads::upload_image_handler))
83 .route("/p/{slug}/uploads/{id}/remove", post(uploads::remove_image_handler))
84 .route_layer(GovernorLayer {
85 config: write_rate_limit,
86 });
87
88 // GET routes + auth + health — no rate limiting
89 let read_routes = Router::new()
90 .route("/", get(forum::forum_directory))
91 .route("/p/{slug}", get(forum::project_forum))
92 .route("/p/{slug}/members", get(forum::community_members))
93 .route("/p/{slug}/u/{username}", get(forum::user_profile))
94 .route("/p/{slug}/settings", get(settings::community_settings))
95 .route("/p/{slug}/settings/categories/{cat_id}/edit", get(settings::edit_category_form))
96 .route("/p/{slug}/moderation", get(moderation::moderation_page))
97 .route("/p/{slug}/moderation/log", get(moderation::mod_log_page))
98 .route("/p/{slug}/{category}", get(forum::category))
99 .route("/p/{slug}/{category}/new", get(forum::new_thread))
100 .route("/p/{slug}/{category}/{thread_id}", get(forum::thread))
101 .route("/p/{slug}/{category}/{thread_id}/edit", get(forum::edit_thread_form))
102 .route("/tracked", get(tracking::tracked_threads_page))
103 .route("/about/tracking", get(tracking::tracking_info_page))
104 .route("/search", get(search::search_handler))
105 .route("/_admin", get(admin::admin_dashboard))
106 .route("/auth/login", get(auth::login))
107 .route("/auth/callback", get(auth::callback))
108 .route("/auth/logout", post(auth::logout))
109 .route("/api/user/{user_id}/summary", get(forum::user_summary_api))
110 .route("/uploads/{id}", get(uploads::serve_image_handler))
111 .route("/api/health", get(health));
112
113 read_routes
114 .merge(write_routes)
115 .fallback(not_found_handler)
116 .with_state(state)
117 }
118
119 // ============================================================================
120 // Form types
121 // ============================================================================
122
123 #[derive(Deserialize)]
124 pub(super) struct CreateThreadForm {
125 pub(super) title: String,
126 pub(super) body: String,
127 #[serde(default, deserialize_with = "deserialize_string_or_seq")]
128 pub(super) tags: Vec<String>,
129 }
130
131 /// Deserialize a form field that may be a single string or a repeated-key sequence.
132 /// serde_urlencoded sends a single `tags=x` as a string, but `tags=x&tags=y` as a sequence.
133 fn deserialize_string_or_seq<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
134 where
135 D: serde::Deserializer<'de>,
136 {
137 struct StringOrSeq;
138
139 impl<'de> serde::de::Visitor<'de> for StringOrSeq {
140 type Value = Vec<String>;
141
142 fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
143 f.write_str("a string or sequence of strings")
144 }
145
146 fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<Vec<String>, E> {
147 Ok(vec![v.to_string()])
148 }
149
150 fn visit_seq<A: serde::de::SeqAccess<'de>>(self, mut seq: A) -> Result<Vec<String>, A::Error> {
151 let mut v = Vec::new();
152 while let Some(s) = seq.next_element::<String>()? {
153 v.push(s);
154 }
155 Ok(v)
156 }
157 }
158
159 deserializer.deserialize_any(StringOrSeq)
160 }
161
162 #[derive(Deserialize)]
163 pub(super) struct CreateReplyForm {
164 pub(super) body: String,
165 }
166
167 #[derive(Deserialize)]
168 pub(super) struct FootnoteForm {
169 pub(super) body: String,
170 }
171
172 #[derive(Deserialize)]
173 pub(super) struct EditThreadForm {
174 pub(super) title: String,
175 }
176
177 #[derive(Deserialize)]
178 pub(super) struct UpdateCommunityForm {
179 pub(super) name: String,
180 pub(super) description: String,
181 pub(super) auto_hide_threshold: Option<String>,
182 }
183
184 #[derive(Deserialize)]
185 pub(super) struct CreateCategoryForm {
186 pub(super) name: String,
187 pub(super) slug: String,
188 pub(super) description: String,
189 }
190
191 #[derive(Deserialize)]
192 pub(super) struct EditCategoryFormData {
193 pub(super) name: String,
194 pub(super) description: String,
195 }
196
197 #[derive(Deserialize)]
198 pub(super) struct MoveCategoryForm {
199 pub(super) direction: String,
200 }
201
202 #[derive(Deserialize)]
203 pub(super) struct PageQuery {
204 pub(super) page: Option<u32>,
205 }
206
207 #[derive(Deserialize)]
208 pub(super) struct CategoryQuery {
209 pub(super) page: Option<u32>,
210 pub(super) sort: Option<String>,
211 pub(super) order: Option<String>,
212 pub(super) tag: Option<String>,
213 }
214
215 #[derive(Deserialize)]
216 pub(super) struct BanForm {
217 pub(super) username: String,
218 pub(super) duration: String,
219 pub(super) reason: Option<String>,
220 }
221
222 #[derive(Deserialize)]
223 pub(super) struct UnbanForm {
224 pub(super) username: String,
225 }
226
227 #[derive(Deserialize)]
228 pub(super) struct AdminSearchQuery {
229 pub(super) q: Option<String>,
230 }
231
232 #[derive(Deserialize)]
233 pub(super) struct SuspendForm {
234 pub(super) reason: Option<String>,
235 }
236
237 #[derive(Deserialize)]
238 pub(super) struct CreateTagForm {
239 pub(super) name: String,
240 pub(super) slug: String,
241 }
242
243 #[derive(Deserialize)]
244 pub(super) struct DeleteTagForm {
245 pub(super) tag_id: String,
246 }
247
248 // ============================================================================
249 // Handlers
250 // ============================================================================
251
252 /// Health check — proves the service is responding.
253 #[tracing::instrument(skip_all)]
254 async fn health() -> Json<serde_json::Value> {
255 Json(serde_json::json!({
256 "status": "operational",
257 "version": env!("CARGO_PKG_VERSION"),
258 }))
259 }
260
261 // ============================================================================
262 // 404 fallback
263 // ============================================================================
264
265 #[tracing::instrument(skip_all)]
266 async fn not_found_handler(
267 axum::extract::State(state): axum::extract::State<AppState>,
268 session: Session,
269 MaybeUser(session_user): MaybeUser,
270 ) -> impl IntoResponse {
271 let csrf_token = Some(csrf::get_or_create_token(&session).await);
272 let session_user = session_user.as_ref().map(|u| template_user(u, state.config.platform_admin_id));
273 (
274 StatusCode::NOT_FOUND,
275 Error404Template {
276 csrf_token,
277 session_user,
278 mnw_base_url: state.config.mnw_base_url.clone(),
279 },
280 )
281 }
282