Skip to main content

max / makenotwork

5.5 KB · 165 lines History Blame Raw
1 //! Shared route helpers.
2 //!
3 //! Split by concern: [`markdown`] (rendering + Fan+ image proxying),
4 //! [`validation`] (input parsing → HTTP responses), and [`authz`] (roles,
5 //! ban/mute/suspension gates, community-state machine, superadmin). This module
6 //! keeps the small DB/transaction/audit context helpers and re-exports the
7 //! submodules so callers still reach everything via `crate::routes::*`.
8
9 use axum::{
10 http::StatusCode,
11 response::{IntoResponse, Response},
12 };
13 use uuid::Uuid;
14
15 use mt_core::types::{ModAction, ModActor};
16
17 use crate::auth;
18 use crate::templates::TemplateSessionUser;
19
20 mod authz;
21 mod markdown;
22 mod validation;
23
24 pub(crate) use authz::*;
25 pub(crate) use markdown::*;
26 pub(crate) use validation::*;
27
28 // Rate limiting constants
29
30 /// Per-user rate limit: max posts per window (complements per-IP tower-governor).
31 pub(crate) const USER_POST_RATE_LIMIT: i64 = 15;
32 pub(crate) const USER_POST_RATE_WINDOW_SECS: i64 = 60;
33
34 // Common DB / transaction / context helpers
35
36 /// Log a DB error and map it to a branded 500 page.
37 ///
38 /// The one-liner `.map_err(db_error)?` most page/write handlers want, replacing
39 /// the open-coded `|e| { tracing::error!(error = ?e, "…"); internal_error() }`
40 /// closure. For the internal HMAC API, keep that module's local `db_error`
41 /// (which returns a plain 500, not an HTML page).
42 #[allow(
43 clippy::needless_pass_by_value,
44 reason = "used as a `.map_err(db_error)` combinator, which requires the fn(E) -> R by-value signature"
45 )]
46 pub(crate) fn db_error(e: sqlx::Error) -> Response {
47 tracing::error!(error = ?e, "db error");
48 crate::error_page::internal_error()
49 }
50
51 /// Fetch community by slug, returning 404/500 on failure.
52 #[tracing::instrument(skip_all)]
53 pub(crate) async fn get_community(
54 db: &sqlx::PgPool,
55 slug: &str,
56 ) -> Result<mt_db::queries::CommunityRow, Response> {
57 mt_db::queries::get_community_by_slug(db, slug)
58 .await
59 .map_err(|e| {
60 tracing::error!(error = ?e, "db error fetching community");
61 crate::error_page::internal_error()
62 })?
63 .ok_or_else(crate::error_page::not_found)
64 }
65
66 /// Look up a user by username, returning 422 if not found.
67 #[tracing::instrument(skip_all)]
68 pub(crate) async fn get_user_by_username(
69 db: &sqlx::PgPool,
70 username: &str,
71 ) -> Result<Uuid, Response> {
72 mt_db::queries::get_user_by_username(db, username)
73 .await
74 .map_err(|e| {
75 tracing::error!(error = ?e, "db error looking up user");
76 crate::error_page::internal_error()
77 })?
78 // 422 stays a plain form-validation response (consumed inline by mt.js),
79 // not a full branded page.
80 .ok_or_else(|| (StatusCode::UNPROCESSABLE_ENTITY, "User not found.").into_response())
81 }
82
83 /// Begin a transaction, mapping a DB error to a branded 500.
84 #[allow(clippy::result_large_err)]
85 pub(crate) async fn begin_tx(
86 db: &sqlx::PgPool,
87 ) -> Result<sqlx::Transaction<'static, sqlx::Postgres>, Response> {
88 db.begin().await.map_err(|e| {
89 tracing::error!(error = ?e, "db error opening transaction");
90 crate::error_page::internal_error()
91 })
92 }
93
94 /// Commit a transaction, mapping a DB error to a branded 500.
95 #[allow(clippy::result_large_err)]
96 pub(crate) async fn commit_tx(tx: sqlx::Transaction<'_, sqlx::Postgres>) -> Result<(), Response> {
97 tx.commit().await.map_err(|e| {
98 tracing::error!(error = ?e, "db error committing transaction");
99 crate::error_page::internal_error()
100 })
101 }
102
103 /// Write a mod-log entry on the caller's transaction.
104 ///
105 /// The audit row is written on the *same* `tx` as the mutation it records, so
106 /// the moderation action and its log either both commit or both roll back, an
107 /// auditable action can never land without its correctly-attributed audit row
108 /// (closes the fire-and-forget gap). A `System` actor (e.g. flag-threshold
109 /// auto-hide) is recorded as a NULL `actor_id`, never the user who tripped it.
110 #[allow(clippy::result_large_err)]
111 pub(crate) async fn audit(
112 tx: &mut sqlx::PgConnection,
113 community_id: Option<Uuid>,
114 actor: ModActor,
115 action: ModAction,
116 target_user: Option<Uuid>,
117 target_id: Option<Uuid>,
118 reason: Option<&str>,
119 ) -> Result<(), Response> {
120 mt_db::mutations::insert_mod_log(
121 &mut *tx,
122 community_id,
123 actor,
124 action,
125 target_user,
126 target_id,
127 reason,
128 )
129 .await
130 .map_err(|e| {
131 tracing::error!(error = %e, "failed to insert mod log");
132 crate::error_page::internal_error()
133 })
134 }
135
136 /// Convert a session user to a template session user.
137 pub(crate) fn template_user(
138 user: &auth::SessionUser,
139 platform_admin_id: Option<Uuid>,
140 ) -> TemplateSessionUser {
141 TemplateSessionUser {
142 is_platform_admin: platform_admin_id == Some(user.user_id),
143 username: user.username.clone(),
144 }
145 }
146
147 /// Per-user posting rate limit. Returns 429 if the user has exceeded the limit.
148 #[tracing::instrument(skip_all)]
149 pub(crate) async fn check_user_post_rate(db: &sqlx::PgPool, user_id: Uuid) -> Result<(), Response> {
150 let count = mt_db::queries::count_recent_posts_by_user(db, user_id, USER_POST_RATE_WINDOW_SECS)
151 .await
152 .map_err(|e| {
153 tracing::error!(error = ?e, "db error checking user post rate");
154 (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
155 })?;
156 if count >= USER_POST_RATE_LIMIT {
157 return Err((
158 StatusCode::TOO_MANY_REQUESTS,
159 "You are posting too quickly. Please wait a moment.",
160 )
161 .into_response());
162 }
163 Ok(())
164 }
165