Skip to main content

max / makenotwork

12.2 KB · 368 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 use uuid::Uuid;
10
11 use crate::auth::MaybeUser;
12 use crate::AppState;
13
14 use super::super::{
15 check_community_access, check_user_post_rate, check_write_access, check_write_state, get_community, WriteScope,
16 parse_uuid, validate_body, FootnoteForm,
17 };
18 use super::posts::{resolve_and_render_mentions, MAX_FOOTNOTES_PER_POST};
19
20 /// Why a footnote add was rejected. Pure predicate result; the handler
21 /// translates each variant to an HTTP response.
22 #[derive(Debug, PartialEq, Eq)]
23 pub(super) enum FootnoteDenial {
24 NotAuthor,
25 PostRemoved,
26 TooManyFootnotes,
27 }
28
29 /// Check whether `user_id` may add a footnote to a post. Pure — no I/O.
30 pub(super) fn check_footnote_permission(
31 user_id: Uuid,
32 post_author_id: Uuid,
33 post_removed: bool,
34 existing_footnote_count: i64,
35 ) -> Result<(), FootnoteDenial> {
36 if user_id != post_author_id {
37 return Err(FootnoteDenial::NotAuthor);
38 }
39 if post_removed {
40 return Err(FootnoteDenial::PostRemoved);
41 }
42 if existing_footnote_count >= MAX_FOOTNOTES_PER_POST as i64 {
43 return Err(FootnoteDenial::TooManyFootnotes);
44 }
45 Ok(())
46 }
47
48 /// Why an endorsement toggle was rejected.
49 #[derive(Debug, PartialEq, Eq)]
50 pub(super) enum EndorsementDenial {
51 CannotEndorseOwn,
52 PostRemoved,
53 UserSuspended,
54 }
55
56 /// Check whether `user_id` may toggle an endorsement on a post. Pure — no I/O.
57 pub(super) fn check_endorsement_permission(
58 user_id: Uuid,
59 post_author_id: Uuid,
60 post_removed: bool,
61 user_suspended: bool,
62 ) -> Result<(), EndorsementDenial> {
63 if user_id == post_author_id {
64 return Err(EndorsementDenial::CannotEndorseOwn);
65 }
66 if post_removed {
67 return Err(EndorsementDenial::PostRemoved);
68 }
69 if user_suspended {
70 return Err(EndorsementDenial::UserSuspended);
71 }
72 Ok(())
73 }
74
75 // ============================================================================
76 // Footnote handler
77 // ============================================================================
78
79 #[tracing::instrument(skip_all)]
80 pub(in crate::routes) async fn add_footnote_handler(
81 axum::extract::State(state): axum::extract::State<AppState>,
82 Path((slug, category_slug, thread_id_str, post_id_str)): Path<(String, String, String, String)>,
83 MaybeUser(session_user): MaybeUser,
84 Form(form): Form<FootnoteForm>,
85 ) -> Result<Redirect, Response> {
86 let user = session_user
87 .ok_or_else(|| Redirect::to("/auth/login").into_response())?;
88
89 let post_id = parse_uuid(&post_id_str)?;
90
91 let post_data = mt_db::queries::get_post_for_edit(&state.db, post_id)
92 .await
93 .map_err(|e| {
94 tracing::error!(error = ?e, "db error fetching post for footnote");
95 StatusCode::INTERNAL_SERVER_ERROR.into_response()
96 })?
97 .ok_or_else(|| StatusCode::NOT_FOUND.into_response())?;
98
99 let removed: bool = sqlx::query_scalar("SELECT removed_at IS NOT NULL FROM posts WHERE id = $1")
100 .bind(post_id)
101 .fetch_one(&state.db)
102 .await
103 .map_err(|e| {
104 tracing::error!(error = ?e, "db error checking removal status");
105 StatusCode::INTERNAL_SERVER_ERROR.into_response()
106 })?;
107
108 let footnote_count = mt_db::queries::count_footnotes_for_post(&state.db, post_id)
109 .await
110 .map_err(|e| {
111 tracing::error!(error = ?e, "db error counting footnotes");
112 StatusCode::INTERNAL_SERVER_ERROR.into_response()
113 })?;
114
115 check_footnote_permission(user.user_id, post_data.author_id, removed, footnote_count)
116 .map_err(|denial| match denial {
117 FootnoteDenial::NotAuthor | FootnoteDenial::PostRemoved => {
118 StatusCode::FORBIDDEN.into_response()
119 }
120 FootnoteDenial::TooManyFootnotes => (
121 StatusCode::UNPROCESSABLE_ENTITY,
122 "Maximum footnotes reached for this post.",
123 )
124 .into_response(),
125 })?;
126
127 // Check write access (suspension + ban + mute)
128 let community = get_community(&state.db, &slug).await?;
129
130 check_write_access(&state.db, community.id, user.user_id, community.suspended_at.is_some()).await?;
131 check_write_state(&state, &community, &user, WriteScope::ContinueExisting).await?;
132 mt_db::mutations::ensure_membership(&state.db, user.user_id, community.id)
133 .await
134 .map_err(|e| {
135 tracing::error!(error = ?e, "db error ensuring membership");
136 StatusCode::INTERNAL_SERVER_ERROR.into_response()
137 })?;
138 check_user_post_rate(&state.db, user.user_id).await?;
139
140 let body = validate_body(&form.body, 65536, "Footnote")?;
141 let author_plus = user.perks.effective_plus();
142 if !author_plus {
143 crate::routes::reject_embeds_for_free_user(body)?;
144 }
145
146 let (body_html, _mention_ids) = resolve_and_render_mentions(
147 &state.db, body, community.id, &slug, user.user_id, author_plus,
148 ).await?;
149
150 mt_db::mutations::insert_footnote(&state.db, post_id, user.user_id, body, &body_html)
151 .await
152 .map_err(|e| {
153 tracing::error!(error = ?e, "db error inserting footnote");
154 StatusCode::INTERNAL_SERVER_ERROR.into_response()
155 })?;
156
157 Ok(Redirect::to(&format!(
158 "/p/{slug}/{category_slug}/{thread_id_str}?toast=Footnote+added"
159 )))
160 }
161
162 // ============================================================================
163 // Endorsement handler
164 // ============================================================================
165
166 #[tracing::instrument(skip_all)]
167 pub(in crate::routes) async fn toggle_endorsement_handler(
168 axum::extract::State(state): axum::extract::State<AppState>,
169 Path((slug, category_slug, thread_id_str, post_id_str)): Path<(String, String, String, String)>,
170 MaybeUser(session_user): MaybeUser,
171 ) -> Result<Redirect, Response> {
172 let user = session_user
173 .ok_or_else(|| Redirect::to("/auth/login").into_response())?;
174
175 let post_id = parse_uuid(&post_id_str)?;
176
177 let post_data = mt_db::queries::get_post_for_edit(&state.db, post_id)
178 .await
179 .map_err(|e| {
180 tracing::error!(error = ?e, "db error fetching post for endorsement");
181 StatusCode::INTERNAL_SERVER_ERROR.into_response()
182 })?
183 .ok_or_else(|| StatusCode::NOT_FOUND.into_response())?;
184
185 let removed: bool = sqlx::query_scalar("SELECT removed_at IS NOT NULL FROM posts WHERE id = $1")
186 .bind(post_id)
187 .fetch_one(&state.db)
188 .await
189 .map_err(|e| {
190 tracing::error!(error = ?e, "db error checking removal status");
191 StatusCode::INTERNAL_SERVER_ERROR.into_response()
192 })?;
193
194 // Check community access (suspension + ban) — no mute check since endorsing is not content
195 let community = get_community(&state.db, &slug).await?;
196 check_community_access(&state.db, &community, Some(user.user_id)).await?;
197 // Endorsement is a write action, so it's blocked by Frozen/Archived state.
198 check_write_state(&state, &community, &user, WriteScope::ContinueExisting).await?;
199
200 let suspended = mt_db::queries::is_user_suspended(&state.db, user.user_id)
201 .await
202 .map_err(|e| {
203 tracing::error!(error = ?e, "db error checking user suspension");
204 StatusCode::INTERNAL_SERVER_ERROR.into_response()
205 })?;
206
207 check_endorsement_permission(user.user_id, post_data.author_id, removed, suspended)
208 .map_err(|denial| match denial {
209 EndorsementDenial::CannotEndorseOwn | EndorsementDenial::PostRemoved => {
210 StatusCode::FORBIDDEN.into_response()
211 }
212 EndorsementDenial::UserSuspended => {
213 (StatusCode::FORBIDDEN, "Your account has been suspended.").into_response()
214 }
215 })?;
216
217 mt_db::mutations::toggle_endorsement(&state.db, post_id, user.user_id)
218 .await
219 .map_err(|e| {
220 tracing::error!(error = ?e, "db error toggling endorsement");
221 StatusCode::INTERNAL_SERVER_ERROR.into_response()
222 })?;
223
224 Ok(Redirect::to(&format!(
225 "/p/{slug}/{category_slug}/{thread_id_str}#post-{post_id_str}"
226 )))
227 }
228
229 #[cfg(test)]
230 mod permission_tests {
231 use super::*;
232
233 fn uid(b: u8) -> Uuid {
234 Uuid::from_bytes([b; 16])
235 }
236
237 // ── check_footnote_permission ──
238
239 #[test]
240 fn footnote_author_can_add_when_not_removed_and_under_cap() {
241 let me = uid(1);
242 let result = check_footnote_permission(me, me, false, 0);
243 assert!(result.is_ok());
244 }
245
246 #[test]
247 fn footnote_non_author_is_rejected() {
248 // Pins `user_id != post_author_id` vs `==`.
249 let me = uid(1);
250 let other = uid(2);
251 assert_eq!(
252 check_footnote_permission(me, other, false, 0),
253 Err(FootnoteDenial::NotAuthor)
254 );
255 }
256
257 #[test]
258 fn footnote_on_removed_post_is_rejected_even_for_author() {
259 let me = uid(1);
260 assert_eq!(
261 check_footnote_permission(me, me, true, 0),
262 Err(FootnoteDenial::PostRemoved)
263 );
264 }
265
266 #[test]
267 fn footnote_at_cap_is_rejected() {
268 // Pins `count >= MAX` vs `>`. At exactly MAX, must reject.
269 let me = uid(1);
270 let cap = MAX_FOOTNOTES_PER_POST as i64;
271 assert_eq!(
272 check_footnote_permission(me, me, false, cap),
273 Err(FootnoteDenial::TooManyFootnotes)
274 );
275 }
276
277 #[test]
278 fn footnote_one_below_cap_is_allowed() {
279 let me = uid(1);
280 let just_below = MAX_FOOTNOTES_PER_POST as i64 - 1;
281 assert!(check_footnote_permission(me, me, false, just_below).is_ok());
282 }
283
284 #[test]
285 fn footnote_above_cap_is_rejected() {
286 let me = uid(1);
287 let over = MAX_FOOTNOTES_PER_POST as i64 + 1;
288 assert_eq!(
289 check_footnote_permission(me, me, false, over),
290 Err(FootnoteDenial::TooManyFootnotes)
291 );
292 }
293
294 #[test]
295 fn footnote_check_order_author_then_removal_then_cap() {
296 // The author check fires first — even on a removed post over the cap,
297 // a non-author gets NotAuthor (not PostRemoved or TooMany).
298 let me = uid(1);
299 let other = uid(2);
300 let cap = MAX_FOOTNOTES_PER_POST as i64;
301 assert_eq!(
302 check_footnote_permission(me, other, true, cap),
303 Err(FootnoteDenial::NotAuthor)
304 );
305 // Removal check fires second.
306 assert_eq!(
307 check_footnote_permission(me, me, true, cap),
308 Err(FootnoteDenial::PostRemoved)
309 );
310 }
311
312 // ── check_endorsement_permission ──
313
314 #[test]
315 fn endorsement_other_user_on_live_post_is_allowed() {
316 let me = uid(1);
317 let author = uid(2);
318 assert!(check_endorsement_permission(me, author, false, false).is_ok());
319 }
320
321 #[test]
322 fn endorsement_self_is_rejected() {
323 // Pins `user_id == post_author_id` vs `!=`.
324 let me = uid(1);
325 assert_eq!(
326 check_endorsement_permission(me, me, false, false),
327 Err(EndorsementDenial::CannotEndorseOwn)
328 );
329 }
330
331 #[test]
332 fn endorsement_on_removed_post_is_rejected() {
333 let me = uid(1);
334 let author = uid(2);
335 assert_eq!(
336 check_endorsement_permission(me, author, true, false),
337 Err(EndorsementDenial::PostRemoved)
338 );
339 }
340
341 #[test]
342 fn endorsement_by_suspended_user_is_rejected() {
343 let me = uid(1);
344 let author = uid(2);
345 assert_eq!(
346 check_endorsement_permission(me, author, false, true),
347 Err(EndorsementDenial::UserSuspended)
348 );
349 }
350
351 #[test]
352 fn endorsement_check_order_self_then_removal_then_suspension() {
353 // Self-check fires first: even if removed AND suspended, self attempt
354 // returns CannotEndorseOwn.
355 let me = uid(1);
356 assert_eq!(
357 check_endorsement_permission(me, me, true, true),
358 Err(EndorsementDenial::CannotEndorseOwn)
359 );
360 // With author check passing, removal fires before suspension.
361 let author = uid(2);
362 assert_eq!(
363 check_endorsement_permission(me, author, true, true),
364 Err(EndorsementDenial::PostRemoved)
365 );
366 }
367 }
368