Skip to main content

max / multithreaded

11.0 KB · 316 lines History Blame Raw
1 //! Shared route helpers — validation, permission checks, markdown rendering, enforcement.
2
3 use axum::{
4 http::StatusCode,
5 response::{IntoResponse, Response},
6 };
7 use chrono::{DateTime, Duration, Utc};
8 use uuid::Uuid;
9
10 use mt_core::types::{CommunityRole, ModAction};
11
12 use crate::auth;
13 use crate::templates::*;
14 use crate::AppState;
15
16 // ============================================================================
17 // Rate limiting constants
18 // ============================================================================
19
20 /// Per-user rate limit: max posts per window (complements per-IP tower-governor).
21 pub(crate) const USER_POST_RATE_LIMIT: i64 = 15;
22 pub(crate) const USER_POST_RATE_WINDOW_SECS: i64 = 60;
23
24 // ============================================================================
25 // Markdown rendering
26 // ============================================================================
27
28 /// Render markdown to HTML, stripping raw HTML events to prevent XSS.
29 pub(crate) fn render_markdown(input: &str) -> String {
30 docengine::render_strict(input)
31 }
32
33 /// Render markdown to HTML, resolving `@mentions` to profile links for valid community members.
34 pub(crate) fn render_markdown_with_mentions(
35 input: &str,
36 community_slug: &str,
37 valid_usernames: &std::collections::HashSet<String>,
38 ) -> String {
39 let template = format!("/p/{community_slug}/u/{{username}}");
40 let resolved = docengine::resolve_mentions(input, valid_usernames, &template);
41 docengine::render_strict(&resolved)
42 }
43
44 // ============================================================================
45 // Common helpers — reduce boilerplate in handlers
46 // ============================================================================
47
48 /// Fetch community by slug, returning 404/500 on failure.
49 #[tracing::instrument(skip_all)]
50 pub(crate) async fn get_community(
51 db: &sqlx::PgPool,
52 slug: &str,
53 ) -> Result<mt_db::queries::CommunityRow, Response> {
54 mt_db::queries::get_community_by_slug(db, slug)
55 .await
56 .map_err(|e| {
57 tracing::error!(error = ?e, "db error fetching community");
58 StatusCode::INTERNAL_SERVER_ERROR.into_response()
59 })?
60 .ok_or_else(|| StatusCode::NOT_FOUND.into_response())
61 }
62
63 /// Fetch thread with breadcrumb, returning 404/500 on failure.
64 #[tracing::instrument(skip_all)]
65 pub(crate) async fn get_thread(
66 db: &sqlx::PgPool,
67 thread_id_str: &str,
68 ) -> Result<mt_db::queries::ThreadWithBreadcrumb, Response> {
69 let thread_id = parse_uuid(thread_id_str)?;
70 mt_db::queries::get_thread_with_breadcrumb(db, thread_id)
71 .await
72 .map_err(|e| {
73 tracing::error!(error = ?e, "db error fetching thread");
74 StatusCode::INTERNAL_SERVER_ERROR.into_response()
75 })?
76 .ok_or_else(|| StatusCode::NOT_FOUND.into_response())
77 }
78
79 /// Parse a UUID from a string, returning 404 on failure.
80 #[allow(clippy::result_large_err)]
81 pub(crate) fn parse_uuid(id_str: &str) -> Result<Uuid, Response> {
82 Uuid::parse_str(id_str).map_err(|_| StatusCode::NOT_FOUND.into_response())
83 }
84
85 /// Fetch a user's role in a community, returning 500 on DB error.
86 #[tracing::instrument(skip_all)]
87 pub(crate) async fn get_role(
88 db: &sqlx::PgPool,
89 user_id: Uuid,
90 community_id: Uuid,
91 ) -> Result<Option<CommunityRole>, Response> {
92 let role_str = mt_db::queries::get_user_role(db, user_id, community_id)
93 .await
94 .map_err(|e| {
95 tracing::error!(error = ?e, "db error fetching role");
96 StatusCode::INTERNAL_SERVER_ERROR.into_response()
97 })?;
98 Ok(role_str.and_then(|s| CommunityRole::from_db(&s)))
99 }
100
101 /// Look up a user by username, returning 422 if not found.
102 #[tracing::instrument(skip_all)]
103 pub(crate) async fn get_user_by_username(
104 db: &sqlx::PgPool,
105 username: &str,
106 ) -> Result<Uuid, Response> {
107 mt_db::queries::get_user_by_username(db, username)
108 .await
109 .map_err(|e| {
110 tracing::error!(error = ?e, "db error looking up user");
111 StatusCode::INTERNAL_SERVER_ERROR.into_response()
112 })?
113 .ok_or_else(|| (StatusCode::UNPROCESSABLE_ENTITY, "User not found.").into_response())
114 }
115
116 /// Fire-and-forget mod log entry. Logs errors but never fails the request.
117 pub(crate) async fn log_mod_action(
118 db: &sqlx::PgPool,
119 community_id: Option<Uuid>,
120 actor_id: Uuid,
121 action: ModAction,
122 target_user: Option<Uuid>,
123 target_id: Option<Uuid>,
124 reason: Option<&str>,
125 ) {
126 if let Err(e) = mt_db::mutations::insert_mod_log(
127 db, community_id, actor_id, action, target_user, target_id, reason,
128 )
129 .await
130 {
131 tracing::error!(error = %e, "failed to insert mod log");
132 }
133 }
134
135 /// Convert a session user to a template session user.
136 pub(crate) fn template_user(
137 user: &auth::SessionUser,
138 platform_admin_id: Option<Uuid>,
139 ) -> TemplateSessionUser {
140 TemplateSessionUser {
141 is_platform_admin: platform_admin_id == Some(user.user_id),
142 username: user.username.clone(),
143 }
144 }
145
146 /// Validate a title field (1-256 chars).
147 #[allow(clippy::result_large_err)]
148 pub(crate) fn validate_title(text: &str) -> Result<&str, Response> {
149 let t = text.trim();
150 if t.is_empty() || t.len() > 256 {
151 return Err((
152 StatusCode::UNPROCESSABLE_ENTITY,
153 "Title must be between 1 and 256 characters.",
154 )
155 .into_response());
156 }
157 Ok(t)
158 }
159
160 /// Validate a body/content field (1 to max chars).
161 #[allow(clippy::result_large_err)]
162 pub(crate) fn validate_body<'a>(text: &'a str, max: usize, field: &str) -> Result<&'a str, Response> {
163 let t = text.trim();
164 if t.is_empty() || t.len() > max {
165 return Err((
166 StatusCode::UNPROCESSABLE_ENTITY,
167 format!("{field} must be between 1 and {max} characters."),
168 )
169 .into_response());
170 }
171 Ok(t)
172 }
173
174 // ============================================================================
175 // Permission helpers
176 // ============================================================================
177
178 /// Is this user a moderator or owner in the community?
179 pub(crate) fn is_mod_or_owner(role: &Option<CommunityRole>) -> bool {
180 role.is_some_and(|r| r.is_mod_or_owner())
181 }
182
183 /// Is this user an owner of the community?
184 pub(crate) fn is_owner(role: &Option<CommunityRole>) -> bool {
185 role.is_some_and(|r| r.is_owner())
186 }
187
188 // ============================================================================
189 // Enforcement helpers
190 // ============================================================================
191
192 /// Check community suspension + user ban. For read handlers.
193 #[tracing::instrument(skip_all)]
194 pub(crate) async fn check_community_access(
195 db: &sqlx::PgPool,
196 community: &mt_db::queries::CommunityRow,
197 user_id: Option<Uuid>,
198 ) -> Result<(), Response> {
199 if community.suspended_at.is_some() {
200 return Err((StatusCode::FORBIDDEN, "This community has been suspended.").into_response());
201 }
202 if let Some(uid) = user_id {
203 let banned = mt_db::queries::is_user_banned(db, community.id, uid)
204 .await
205 .map_err(|e| {
206 tracing::error!(error = ?e, "db error checking ban status");
207 StatusCode::INTERNAL_SERVER_ERROR.into_response()
208 })?;
209 if banned {
210 return Err((StatusCode::FORBIDDEN, "You are banned from this community.").into_response());
211 }
212 }
213 Ok(())
214 }
215
216 /// Check community suspension + platform suspension + user ban + user mute. For write handlers.
217 #[tracing::instrument(skip_all)]
218 pub(crate) async fn check_write_access(
219 db: &sqlx::PgPool,
220 community_id: Uuid,
221 user_id: Uuid,
222 community_suspended: bool,
223 ) -> Result<(), Response> {
224 if community_suspended {
225 return Err((StatusCode::FORBIDDEN, "This community has been suspended.").into_response());
226 }
227 let suspended = mt_db::queries::is_user_suspended(db, user_id)
228 .await
229 .map_err(|e| {
230 tracing::error!(error = ?e, "db error checking user suspension");
231 StatusCode::INTERNAL_SERVER_ERROR.into_response()
232 })?;
233 if suspended {
234 return Err((StatusCode::FORBIDDEN, "Your account has been suspended.").into_response());
235 }
236 let banned = mt_db::queries::is_user_banned(db, community_id, user_id)
237 .await
238 .map_err(|e| {
239 tracing::error!(error = ?e, "db error checking ban status");
240 StatusCode::INTERNAL_SERVER_ERROR.into_response()
241 })?;
242 if banned {
243 return Err((StatusCode::FORBIDDEN, "You are banned from this community.").into_response());
244 }
245 let muted = mt_db::queries::is_user_muted(db, community_id, user_id)
246 .await
247 .map_err(|e| {
248 tracing::error!(error = ?e, "db error checking mute status");
249 StatusCode::INTERNAL_SERVER_ERROR.into_response()
250 })?;
251 if muted {
252 return Err((StatusCode::FORBIDDEN, "You are muted in this community.").into_response());
253 }
254 Ok(())
255 }
256
257 /// Per-user posting rate limit. Returns 429 if the user has exceeded the limit.
258 #[tracing::instrument(skip_all)]
259 pub(crate) async fn check_user_post_rate(
260 db: &sqlx::PgPool,
261 user_id: Uuid,
262 ) -> Result<(), Response> {
263 let count = mt_db::queries::count_recent_posts_by_user(db, user_id, USER_POST_RATE_WINDOW_SECS)
264 .await
265 .map_err(|e| {
266 tracing::error!(error = ?e, "db error checking user post rate");
267 StatusCode::INTERNAL_SERVER_ERROR.into_response()
268 })?;
269 if count >= USER_POST_RATE_LIMIT {
270 return Err((StatusCode::TOO_MANY_REQUESTS, "You are posting too quickly. Please wait a moment.").into_response());
271 }
272 Ok(())
273 }
274
275 /// Parse a ban duration string into an optional expiration datetime.
276 pub(crate) fn parse_duration(duration: &str) -> Option<DateTime<Utc>> {
277 match duration {
278 "permanent" => None,
279 "1h" => Some(Utc::now() + Duration::hours(1)),
280 "1d" => Some(Utc::now() + Duration::days(1)),
281 "7d" => Some(Utc::now() + Duration::days(7)),
282 "30d" => Some(Utc::now() + Duration::days(30)),
283 _ => None,
284 }
285 }
286
287 /// Helper: fetch community + verify owner role, returning 403 if not owner.
288 #[tracing::instrument(skip_all)]
289 pub(crate) async fn require_owner(
290 state: &AppState,
291 slug: &str,
292 user: &auth::SessionUser,
293 ) -> Result<mt_db::queries::CommunityRow, Response> {
294 let community = get_community(&state.db, slug).await?;
295 let role = get_role(&state.db, user.user_id, community.id).await?;
296 if !is_owner(&role) {
297 return Err(StatusCode::FORBIDDEN.into_response());
298 }
299 Ok(community)
300 }
301
302 /// Helper: fetch community + verify mod_or_owner role, returning 403 if not.
303 #[tracing::instrument(skip_all)]
304 pub(crate) async fn require_mod_or_owner(
305 state: &AppState,
306 slug: &str,
307 user: &auth::SessionUser,
308 ) -> Result<(mt_db::queries::CommunityRow, Option<CommunityRole>), Response> {
309 let community = get_community(&state.db, slug).await?;
310 let role = get_role(&state.db, user.user_id, community.id).await?;
311 if !is_mod_or_owner(&role) {
312 return Err(StatusCode::FORBIDDEN.into_response());
313 }
314 Ok((community, role))
315 }
316