Skip to main content

max / makenotwork

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