Skip to main content

max / multithreaded

6.2 KB · 173 lines History Blame Raw
1 //! Write handlers — footnotes and endorsements.
2
3 use axum::{
4 extract::Path,
5 http::StatusCode,
6 response::{IntoResponse, Redirect, Response},
7 Form,
8 };
9
10 use crate::auth::MaybeUser;
11 use crate::AppState;
12
13 use super::super::{
14 check_community_access, check_user_post_rate, check_write_access, get_community,
15 parse_uuid, validate_body, FootnoteForm,
16 };
17 use super::posts::{resolve_and_render_mentions, MAX_FOOTNOTES_PER_POST};
18
19 // ============================================================================
20 // Footnote handler
21 // ============================================================================
22
23 #[tracing::instrument(skip_all)]
24 pub(in crate::routes) async fn add_footnote_handler(
25 axum::extract::State(state): axum::extract::State<AppState>,
26 Path((slug, category_slug, thread_id_str, post_id_str)): Path<(String, String, String, String)>,
27 MaybeUser(session_user): MaybeUser,
28 Form(form): Form<FootnoteForm>,
29 ) -> Result<Redirect, Response> {
30 let user = session_user
31 .ok_or_else(|| Redirect::to("/auth/login").into_response())?;
32
33 let post_id = parse_uuid(&post_id_str)?;
34
35 let post_data = mt_db::queries::get_post_for_edit(&state.db, post_id)
36 .await
37 .map_err(|e| {
38 tracing::error!(error = ?e, "db error fetching post for footnote");
39 StatusCode::INTERNAL_SERVER_ERROR.into_response()
40 })?
41 .ok_or_else(|| StatusCode::NOT_FOUND.into_response())?;
42
43 // Only the post author can add footnotes
44 if user.user_id != post_data.author_id {
45 return Err(StatusCode::FORBIDDEN.into_response());
46 }
47
48 // Cannot add footnotes to removed posts
49 let removed: bool = sqlx::query_scalar("SELECT removed_at IS NOT NULL FROM posts WHERE id = $1")
50 .bind(post_id)
51 .fetch_one(&state.db)
52 .await
53 .map_err(|e| {
54 tracing::error!(error = ?e, "db error checking removal status");
55 StatusCode::INTERNAL_SERVER_ERROR.into_response()
56 })?;
57 if removed {
58 return Err(StatusCode::FORBIDDEN.into_response());
59 }
60
61 // Cap footnotes per post
62 let footnote_count = mt_db::queries::count_footnotes_for_post(&state.db, post_id)
63 .await
64 .map_err(|e| {
65 tracing::error!(error = ?e, "db error counting footnotes");
66 StatusCode::INTERNAL_SERVER_ERROR.into_response()
67 })?;
68 if footnote_count >= MAX_FOOTNOTES_PER_POST as i64 {
69 return Err((
70 StatusCode::UNPROCESSABLE_ENTITY,
71 "Maximum footnotes reached for this post.",
72 )
73 .into_response());
74 }
75
76 // Check write access (suspension + ban + mute)
77 let community = get_community(&state.db, &slug).await?;
78
79 check_write_access(&state.db, community.id, user.user_id, community.suspended_at.is_some()).await?;
80 mt_db::mutations::ensure_membership(&state.db, user.user_id, community.id)
81 .await
82 .map_err(|e| {
83 tracing::error!(error = ?e, "db error ensuring membership");
84 StatusCode::INTERNAL_SERVER_ERROR.into_response()
85 })?;
86 check_user_post_rate(&state.db, user.user_id).await?;
87
88 let body = validate_body(&form.body, 65536, "Footnote")?;
89
90 let (body_html, _mention_ids) = resolve_and_render_mentions(
91 &state.db, body, community.id, &slug, user.user_id,
92 ).await?;
93
94 mt_db::mutations::insert_footnote(&state.db, post_id, user.user_id, body, &body_html)
95 .await
96 .map_err(|e| {
97 tracing::error!(error = ?e, "db error inserting footnote");
98 StatusCode::INTERNAL_SERVER_ERROR.into_response()
99 })?;
100
101 Ok(Redirect::to(&format!(
102 "/p/{slug}/{category_slug}/{thread_id_str}?toast=Footnote+added"
103 )))
104 }
105
106 // ============================================================================
107 // Endorsement handler
108 // ============================================================================
109
110 #[tracing::instrument(skip_all)]
111 pub(in crate::routes) async fn toggle_endorsement_handler(
112 axum::extract::State(state): axum::extract::State<AppState>,
113 Path((slug, category_slug, thread_id_str, post_id_str)): Path<(String, String, String, String)>,
114 MaybeUser(session_user): MaybeUser,
115 ) -> Result<Redirect, Response> {
116 let user = session_user
117 .ok_or_else(|| Redirect::to("/auth/login").into_response())?;
118
119 let post_id = parse_uuid(&post_id_str)?;
120
121 let post_data = mt_db::queries::get_post_for_edit(&state.db, post_id)
122 .await
123 .map_err(|e| {
124 tracing::error!(error = ?e, "db error fetching post for endorsement");
125 StatusCode::INTERNAL_SERVER_ERROR.into_response()
126 })?
127 .ok_or_else(|| StatusCode::NOT_FOUND.into_response())?;
128
129 // Cannot endorse own post
130 if user.user_id == post_data.author_id {
131 return Err(StatusCode::FORBIDDEN.into_response());
132 }
133
134 // Cannot endorse a removed post
135 let removed: bool = sqlx::query_scalar("SELECT removed_at IS NOT NULL FROM posts WHERE id = $1")
136 .bind(post_id)
137 .fetch_one(&state.db)
138 .await
139 .map_err(|e| {
140 tracing::error!(error = ?e, "db error checking removal status");
141 StatusCode::INTERNAL_SERVER_ERROR.into_response()
142 })?;
143 if removed {
144 return Err(StatusCode::FORBIDDEN.into_response());
145 }
146
147 // Check community access (suspension + ban) — no mute check since endorsing is not content
148 let community = get_community(&state.db, &slug).await?;
149 check_community_access(&state.db, &community, Some(user.user_id)).await?;
150
151 // Check platform suspension
152 let suspended = mt_db::queries::is_user_suspended(&state.db, user.user_id)
153 .await
154 .map_err(|e| {
155 tracing::error!(error = ?e, "db error checking user suspension");
156 StatusCode::INTERNAL_SERVER_ERROR.into_response()
157 })?;
158 if suspended {
159 return Err((StatusCode::FORBIDDEN, "Your account has been suspended.").into_response());
160 }
161
162 mt_db::mutations::toggle_endorsement(&state.db, post_id, user.user_id)
163 .await
164 .map_err(|e| {
165 tracing::error!(error = ?e, "db error toggling endorsement");
166 StatusCode::INTERNAL_SERVER_ERROR.into_response()
167 })?;
168
169 Ok(Redirect::to(&format!(
170 "/p/{slug}/{category_slug}/{thread_id_str}#post-{post_id_str}"
171 )))
172 }
173