forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
13.7 KiB
1//! Writes on a discussion, all POST, all checked for CSRF: `/{owner}/{repo}/discussions/{n}/...`
2//! for comments and replies, edit, delete, lock, answer and reactions, and `comments/{id}/edit`
3//! and `comments/{id}/delete` for comments. The rules: the author may edit or delete their own
4//! discussion and comments, Triage holders may lock and mark answers, anyone signed in may comment
5//! and react unless the discussion is locked (then only Triage holders). Archived repos refuse all
6//! of it with 403. A validation error on a comment, edit or answer shows the "Could not save" page
7//! with a way back. Success redirects to the discussion, anchored on what changed.
8
9use forge_core::{AuditAction, Reaction, ReactionTarget};
10use serde::Deserialize;
11use topcoat::Result;
12use topcoat::context::{Cx, app_context};
13use topcoat::router::content::Form;
14use topcoat::router::error::{SeeOther, bad_request, forbidden, not_found, see_other};
15use topcoat::router::{page, path_param, route};
16use topcoat::view::{View, view};
17
18use crate::auth::require_csrf;
19use crate::i18n;
20use crate::i18n::community::{self as g, K};
21use crate::pages::discussions::{
22 audit_discussion, discussion_by_number, discussions_href, ensure_enabled, is_author,
23 may_converse, notify_mentions,
24};
25use crate::pages::issue_meta::{action_error_at, can_triage, refusal_message};
26use crate::pages::repo_ctx::{RepoCtx, load_repo};
27use crate::state::AppState;
28use crate::views::shell;
29
30path_param!(owner);
31path_param!(repo);
32path_param!(number);
33path_param!(comment_id);
34
35/// Every field any discussion write may carry. Missing fields read as empty.
36#[derive(Deserialize, Default)]
37struct ActionForm {
38 #[serde(default)]
39 csrf: String,
40 #[serde(default)]
41 title: String,
42 #[serde(default)]
43 body: String,
44 #[serde(default)]
45 parent: String,
46 #[serde(default)]
47 locked: String,
48 #[serde(default)]
49 comment_id: String,
50 #[serde(default)]
51 answer: String,
52 #[serde(default)]
53 target_kind: String,
54 #[serde(default)]
55 target_id: String,
56 #[serde(default)]
57 reaction: String,
58}
59
60/// The discussion's own URL in this repo.
61fn base_of(ctx: &RepoCtx, number: i64) -> String {
62 discussions_href(&ctx.repo.owner_name, &ctx.repo.name, &number.to_string())
63}
64
65/// Loads a live comment of this discussion for an author check: 404 when it is missing or
66/// deleted, 403 when the viewer did not write it or the repo is archived.
67async fn own_comment(
68 state: &AppState,
69 ctx: &RepoCtx,
70 discussion_id: &str,
71 comment_id: &str,
72) -> Result<forge_core::discussions::DiscussionComment> {
73 let comment = match state
74 .store
75 .discussion_comment(discussion_id, comment_id)
76 .await?
77 {
78 Some(comment) if !comment.deleted => comment,
79 _ => return Err(not_found().into()),
80 };
81 if ctx.repo.archived || !is_author(ctx, &comment.author_id) {
82 return Err(forbidden().into());
83 }
84 Ok(comment)
85}
86
87#[page(POST "/{owner}/{repo}/discussions/{number}/comments")]
88async fn discussion_comment(cx: &Cx, Form(form): Form<ActionForm>) -> Result<impl View> {
89 let ctx = load_repo(cx, path_param::<Owner>(cx), path_param::<Repo>(cx)).await?;
90 let state = app_context::<AppState>(cx);
91 let (user, _) = require_csrf(cx, &form.csrf).await?;
92 let lang = i18n::lang(cx, Some(&user));
93 ensure_enabled(state, &ctx).await?;
94 let discussion = discussion_by_number(state, &ctx, path_param::<Number>(cx)).await?;
95 if !may_converse(&ctx, discussion.locked) {
96 return Err(forbidden().into());
97 }
98 let parent = Some(form.parent.as_str()).filter(|p| !p.is_empty());
99 let base = base_of(&ctx, discussion.number);
100 let comment = match state
101 .store
102 .add_discussion_comment(
103 &discussion.id,
104 &user.id,
105 parent,
106 &form.body,
107 can_triage(&ctx),
108 )
109 .await
110 {
111 Ok(comment) => comment,
112 Err(e) => {
113 let message = refusal_message(e)?;
114 return Ok(view! { cx =>
115 shell(title: g::t(lang, K::CouldNotSave), viewer: ctx.viewer.as_ref(),
116 action_error_at(lang: lang, message: message, back: base))
117 });
118 }
119 };
120 audit_discussion(
121 cx,
122 state,
123 &user,
124 &ctx,
125 AuditAction::DiscussionCommentCreated,
126 &discussion.id,
127 )
128 .await;
129 notify_mentions(state, &ctx, &discussion.id, &user.id, &form.body, None).await;
130 Err(see_other(format!("{base}#comment-{}", comment.id)).into())
131}
132
133#[page(POST "/{owner}/{repo}/discussions/{number}/edit")]
134async fn discussion_edit(cx: &Cx, Form(form): Form<ActionForm>) -> Result<impl View> {
135 let ctx = load_repo(cx, path_param::<Owner>(cx), path_param::<Repo>(cx)).await?;
136 let state = app_context::<AppState>(cx);
137 let (user, _) = require_csrf(cx, &form.csrf).await?;
138 let lang = i18n::lang(cx, Some(&user));
139 ensure_enabled(state, &ctx).await?;
140 let discussion = discussion_by_number(state, &ctx, path_param::<Number>(cx)).await?;
141 if ctx.repo.archived || !is_author(&ctx, &discussion.author_id) {
142 return Err(forbidden().into());
143 }
144 let base = base_of(&ctx, discussion.number);
145 if let Err(e) = state
146 .store
147 .edit_discussion(&discussion.id, &form.title, &form.body)
148 .await
149 {
150 let message = refusal_message(e)?;
151 return Ok(view! { cx =>
152 shell(title: g::t(lang, K::CouldNotSave), viewer: ctx.viewer.as_ref(),
153 action_error_at(lang: lang, message: message, back: base))
154 });
155 }
156 audit_discussion(
157 cx,
158 state,
159 &user,
160 &ctx,
161 AuditAction::DiscussionEdited,
162 &discussion.id,
163 )
164 .await;
165 notify_mentions(
166 state,
167 &ctx,
168 &discussion.id,
169 &user.id,
170 &form.body,
171 Some(&discussion.body),
172 )
173 .await;
174 Err(see_other(base).into())
175}
176
177/// Deletes the discussion with its comments. Its author only.
178#[route(POST "/{owner}/{repo}/discussions/{number}/delete")]
179async fn discussion_delete(cx: &Cx, Form(form): Form<ActionForm>) -> Result<SeeOther> {
180 let ctx = load_repo(cx, path_param::<Owner>(cx), path_param::<Repo>(cx)).await?;
181 let state = app_context::<AppState>(cx);
182 let (user, _) = require_csrf(cx, &form.csrf).await?;
183 ensure_enabled(state, &ctx).await?;
184 let discussion = discussion_by_number(state, &ctx, path_param::<Number>(cx)).await?;
185 if ctx.repo.archived || !is_author(&ctx, &discussion.author_id) {
186 return Err(forbidden().into());
187 }
188 state.store.delete_discussion(&discussion.id).await?;
189 audit_discussion(
190 cx,
191 state,
192 &user,
193 &ctx,
194 AuditAction::DiscussionDeleted,
195 &discussion.id,
196 )
197 .await;
198 Ok(see_other(discussions_href(
199 &ctx.repo.owner_name,
200 &ctx.repo.name,
201 "",
202 )))
203}
204
205/// Locks (`locked=1`) or unlocks (`locked=0`) a discussion. Triage holders only.
206#[route(POST "/{owner}/{repo}/discussions/{number}/lock")]
207async fn discussion_lock(cx: &Cx, Form(form): Form<ActionForm>) -> Result<SeeOther> {
208 let ctx = load_repo(cx, path_param::<Owner>(cx), path_param::<Repo>(cx)).await?;
209 let state = app_context::<AppState>(cx);
210 let (user, _) = require_csrf(cx, &form.csrf).await?;
211 ensure_enabled(state, &ctx).await?;
212 let discussion = discussion_by_number(state, &ctx, path_param::<Number>(cx)).await?;
213 if !can_triage(&ctx) {
214 return Err(forbidden().into());
215 }
216 let locked = form.locked == "1";
217 let changed = state
218 .store
219 .set_discussion_locked(&discussion.id, locked)
220 .await?;
221 if changed {
222 let action = if locked {
223 AuditAction::DiscussionLocked
224 } else {
225 AuditAction::DiscussionUnlocked
226 };
227 audit_discussion(cx, state, &user, &ctx, action, &discussion.id).await;
228 }
229 Ok(see_other(base_of(&ctx, discussion.number)))
230}
231
232/// Marks (`answer=1`) or clears (`answer=0`) the answer of a Q&A discussion. The author or a
233/// Triage holder may do it.
234#[page(POST "/{owner}/{repo}/discussions/{number}/answer")]
235async fn discussion_answer(cx: &Cx, Form(form): Form<ActionForm>) -> Result<impl View> {
236 let ctx = load_repo(cx, path_param::<Owner>(cx), path_param::<Repo>(cx)).await?;
237 let state = app_context::<AppState>(cx);
238 let (user, _) = require_csrf(cx, &form.csrf).await?;
239 let lang = i18n::lang(cx, Some(&user));
240 ensure_enabled(state, &ctx).await?;
241 let discussion = discussion_by_number(state, &ctx, path_param::<Number>(cx)).await?;
242 if ctx.repo.archived || !(can_triage(&ctx) || is_author(&ctx, &discussion.author_id)) {
243 return Err(forbidden().into());
244 }
245 let base = base_of(&ctx, discussion.number);
246 let answer = form.answer == "1";
247 let chosen = answer
248 .then_some(form.comment_id.as_str())
249 .filter(|c| !c.is_empty());
250 if answer && chosen.is_none() {
251 return Err(bad_request("missing comment").into());
252 }
253 if let Err(e) = state
254 .store
255 .set_discussion_answer(&discussion.id, chosen)
256 .await
257 {
258 let message = refusal_message(e)?;
259 return Ok(view! { cx =>
260 shell(title: g::t(lang, K::CouldNotSave), viewer: ctx.viewer.as_ref(),
261 action_error_at(lang: lang, message: message, back: base))
262 });
263 }
264 audit_discussion(
265 cx,
266 state,
267 &user,
268 &ctx,
269 AuditAction::DiscussionAnswered,
270 &discussion.id,
271 )
272 .await;
273 match chosen {
274 Some(id) => Err(see_other(format!("{base}#comment-{id}")).into()),
275 None => Err(see_other(base).into()),
276 }
277}
278
279#[page(POST "/{owner}/{repo}/discussions/{number}/comments/{comment_id}/edit")]
280async fn discussion_comment_edit(cx: &Cx, Form(form): Form<ActionForm>) -> Result<impl View> {
281 let ctx = load_repo(cx, path_param::<Owner>(cx), path_param::<Repo>(cx)).await?;
282 let state = app_context::<AppState>(cx);
283 let (user, _) = require_csrf(cx, &form.csrf).await?;
284 ensure_enabled(state, &ctx).await?;
285 let discussion = discussion_by_number(state, &ctx, path_param::<Number>(cx)).await?;
286 let lang = i18n::lang(cx, Some(&user));
287 let comment = own_comment(state, &ctx, &discussion.id, path_param::<CommentId>(cx)).await?;
288 let base = base_of(&ctx, discussion.number);
289 if let Err(e) = state
290 .store
291 .edit_discussion_comment(&comment.id, &form.body)
292 .await
293 {
294 let message = refusal_message(e)?;
295 return Ok(view! { cx =>
296 shell(title: g::t(lang, K::CouldNotSave), viewer: ctx.viewer.as_ref(),
297 action_error_at(lang: lang, message: message, back: base))
298 });
299 }
300 audit_discussion(
301 cx,
302 state,
303 &user,
304 &ctx,
305 AuditAction::DiscussionCommentEdited,
306 &discussion.id,
307 )
308 .await;
309 notify_mentions(
310 state,
311 &ctx,
312 &discussion.id,
313 &user.id,
314 &form.body,
315 Some(&comment.body),
316 )
317 .await;
318 Err(see_other(format!("{base}#comment-{}", comment.id)).into())
319}
320
321#[route(POST "/{owner}/{repo}/discussions/{number}/comments/{comment_id}/delete")]
322async fn discussion_comment_delete(cx: &Cx, Form(form): Form<ActionForm>) -> Result<SeeOther> {
323 let ctx = load_repo(cx, path_param::<Owner>(cx), path_param::<Repo>(cx)).await?;
324 let state = app_context::<AppState>(cx);
325 let (user, _) = require_csrf(cx, &form.csrf).await?;
326 ensure_enabled(state, &ctx).await?;
327 let discussion = discussion_by_number(state, &ctx, path_param::<Number>(cx)).await?;
328 let comment = own_comment(state, &ctx, &discussion.id, path_param::<CommentId>(cx)).await?;
329 state.store.delete_discussion_comment(&comment.id).await?;
330 audit_discussion(
331 cx,
332 state,
333 &user,
334 &ctx,
335 AuditAction::DiscussionCommentDeleted,
336 &discussion.id,
337 )
338 .await;
339 Ok(see_other(base_of(&ctx, discussion.number)))
340}
341
342/// Toggles the viewer's reaction on the discussion (`target_kind=discussion`) or on one of its live
343/// comments (`target_kind=discussion_comment`).
344#[route(POST "/{owner}/{repo}/discussions/{number}/reactions")]
345async fn discussion_reaction(cx: &Cx, Form(form): Form<ActionForm>) -> Result<SeeOther> {
346 let ctx = load_repo(cx, path_param::<Owner>(cx), path_param::<Repo>(cx)).await?;
347 let state = app_context::<AppState>(cx);
348 let (user, _) = require_csrf(cx, &form.csrf).await?;
349 ensure_enabled(state, &ctx).await?;
350 let discussion = discussion_by_number(state, &ctx, path_param::<Number>(cx)).await?;
351 if !may_converse(&ctx, discussion.locked) {
352 return Err(forbidden().into());
353 }
354 let Some(reaction) = Reaction::parse(&form.reaction) else {
355 return Err(bad_request("unknown reaction").into());
356 };
357 let target_id = form.target_id.clone();
358 let (kind, anchor) = match form.target_kind.as_str() {
359 "discussion" => {
360 if target_id != discussion.id {
361 return Err(not_found().into());
362 }
363 (ReactionTarget::Discussion, "discussion-body".to_owned())
364 }
365 "discussion_comment" => {
366 match state
367 .store
368 .discussion_comment(&discussion.id, &target_id)
369 .await?
370 {
371 Some(comment) if !comment.deleted => {}
372 _ => return Err(not_found().into()),
373 }
374 (
375 ReactionTarget::DiscussionComment,
376 format!("comment-{target_id}"),
377 )
378 }
379 _ => return Err(bad_request("unknown reaction target").into()),
380 };
381 state
382 .store
383 .toggle_reaction(kind, &target_id, &user.id, reaction)
384 .await?;
385 Ok(see_other(format!(
386 "{}#{anchor}",
387 base_of(&ctx, discussion.number)
388 )))
389}