//! Writes on a discussion, all POST, all checked for CSRF: `/{owner}/{repo}/discussions/{n}/...` //! for comments and replies, edit, delete, lock, answer and reactions, and `comments/{id}/edit` //! and `comments/{id}/delete` for comments. The rules: the author may edit or delete their own //! discussion and comments, Triage holders may lock and mark answers, anyone signed in may comment //! and react unless the discussion is locked (then only Triage holders). Archived repos refuse all //! of it with 403. A validation error on a comment, edit or answer shows the "Could not save" page //! with a way back. Success redirects to the discussion, anchored on what changed. use forge_core::{AuditAction, Reaction, ReactionTarget}; use serde::Deserialize; use topcoat::Result; use topcoat::context::{Cx, app_context}; use topcoat::router::content::Form; use topcoat::router::error::{SeeOther, bad_request, forbidden, not_found, see_other}; use topcoat::router::{page, path_param, route}; use topcoat::view::{View, view}; use crate::auth::require_csrf; use crate::i18n; use crate::i18n::community::{self as g, K}; use crate::pages::discussions::{ audit_discussion, discussion_by_number, discussions_href, ensure_enabled, is_author, may_converse, notify_mentions, }; use crate::pages::issue_meta::{action_error_at, can_triage, refusal_message}; use crate::pages::repo_ctx::{RepoCtx, load_repo}; use crate::state::AppState; use crate::views::shell; path_param!(owner); path_param!(repo); path_param!(number); path_param!(comment_id); /// Every field any discussion write may carry. Missing fields read as empty. #[derive(Deserialize, Default)] struct ActionForm { #[serde(default)] csrf: String, #[serde(default)] title: String, #[serde(default)] body: String, #[serde(default)] parent: String, #[serde(default)] locked: String, #[serde(default)] comment_id: String, #[serde(default)] answer: String, #[serde(default)] target_kind: String, #[serde(default)] target_id: String, #[serde(default)] reaction: String, } /// The discussion's own URL in this repo. fn base_of(ctx: &RepoCtx, number: i64) -> String { discussions_href(&ctx.repo.owner_name, &ctx.repo.name, &number.to_string()) } /// Loads a live comment of this discussion for an author check: 404 when it is missing or /// deleted, 403 when the viewer did not write it or the repo is archived. async fn own_comment( state: &AppState, ctx: &RepoCtx, discussion_id: &str, comment_id: &str, ) -> Result { let comment = match state .store .discussion_comment(discussion_id, comment_id) .await? { Some(comment) if !comment.deleted => comment, _ => return Err(not_found().into()), }; if ctx.repo.archived || !is_author(ctx, &comment.author_id) { return Err(forbidden().into()); } Ok(comment) } #[page(POST "/{owner}/{repo}/discussions/{number}/comments")] async fn discussion_comment(cx: &Cx, Form(form): Form) -> Result { let ctx = load_repo(cx, path_param::(cx), path_param::(cx)).await?; let state = app_context::(cx); let (user, _) = require_csrf(cx, &form.csrf).await?; let lang = i18n::lang(cx, Some(&user)); ensure_enabled(state, &ctx).await?; let discussion = discussion_by_number(state, &ctx, path_param::(cx)).await?; if !may_converse(&ctx, discussion.locked) { return Err(forbidden().into()); } let parent = Some(form.parent.as_str()).filter(|p| !p.is_empty()); let base = base_of(&ctx, discussion.number); let comment = match state .store .add_discussion_comment( &discussion.id, &user.id, parent, &form.body, can_triage(&ctx), ) .await { Ok(comment) => comment, Err(e) => { let message = refusal_message(e)?; return Ok(view! { cx => shell(title: g::t(lang, K::CouldNotSave), viewer: ctx.viewer.as_ref(), action_error_at(lang: lang, message: message, back: base)) }); } }; audit_discussion( cx, state, &user, &ctx, AuditAction::DiscussionCommentCreated, &discussion.id, ) .await; notify_mentions(state, &ctx, &discussion.id, &user.id, &form.body, None).await; Err(see_other(format!("{base}#comment-{}", comment.id)).into()) } #[page(POST "/{owner}/{repo}/discussions/{number}/edit")] async fn discussion_edit(cx: &Cx, Form(form): Form) -> Result { let ctx = load_repo(cx, path_param::(cx), path_param::(cx)).await?; let state = app_context::(cx); let (user, _) = require_csrf(cx, &form.csrf).await?; let lang = i18n::lang(cx, Some(&user)); ensure_enabled(state, &ctx).await?; let discussion = discussion_by_number(state, &ctx, path_param::(cx)).await?; if ctx.repo.archived || !is_author(&ctx, &discussion.author_id) { return Err(forbidden().into()); } let base = base_of(&ctx, discussion.number); if let Err(e) = state .store .edit_discussion(&discussion.id, &form.title, &form.body) .await { let message = refusal_message(e)?; return Ok(view! { cx => shell(title: g::t(lang, K::CouldNotSave), viewer: ctx.viewer.as_ref(), action_error_at(lang: lang, message: message, back: base)) }); } audit_discussion( cx, state, &user, &ctx, AuditAction::DiscussionEdited, &discussion.id, ) .await; notify_mentions( state, &ctx, &discussion.id, &user.id, &form.body, Some(&discussion.body), ) .await; Err(see_other(base).into()) } /// Deletes the discussion with its comments. Its author only. #[route(POST "/{owner}/{repo}/discussions/{number}/delete")] async fn discussion_delete(cx: &Cx, Form(form): Form) -> Result { let ctx = load_repo(cx, path_param::(cx), path_param::(cx)).await?; let state = app_context::(cx); let (user, _) = require_csrf(cx, &form.csrf).await?; ensure_enabled(state, &ctx).await?; let discussion = discussion_by_number(state, &ctx, path_param::(cx)).await?; if ctx.repo.archived || !is_author(&ctx, &discussion.author_id) { return Err(forbidden().into()); } state.store.delete_discussion(&discussion.id).await?; audit_discussion( cx, state, &user, &ctx, AuditAction::DiscussionDeleted, &discussion.id, ) .await; Ok(see_other(discussions_href( &ctx.repo.owner_name, &ctx.repo.name, "", ))) } /// Locks (`locked=1`) or unlocks (`locked=0`) a discussion. Triage holders only. #[route(POST "/{owner}/{repo}/discussions/{number}/lock")] async fn discussion_lock(cx: &Cx, Form(form): Form) -> Result { let ctx = load_repo(cx, path_param::(cx), path_param::(cx)).await?; let state = app_context::(cx); let (user, _) = require_csrf(cx, &form.csrf).await?; ensure_enabled(state, &ctx).await?; let discussion = discussion_by_number(state, &ctx, path_param::(cx)).await?; if !can_triage(&ctx) { return Err(forbidden().into()); } let locked = form.locked == "1"; let changed = state .store .set_discussion_locked(&discussion.id, locked) .await?; if changed { let action = if locked { AuditAction::DiscussionLocked } else { AuditAction::DiscussionUnlocked }; audit_discussion(cx, state, &user, &ctx, action, &discussion.id).await; } Ok(see_other(base_of(&ctx, discussion.number))) } /// Marks (`answer=1`) or clears (`answer=0`) the answer of a Q&A discussion. The author or a /// Triage holder may do it. #[page(POST "/{owner}/{repo}/discussions/{number}/answer")] async fn discussion_answer(cx: &Cx, Form(form): Form) -> Result { let ctx = load_repo(cx, path_param::(cx), path_param::(cx)).await?; let state = app_context::(cx); let (user, _) = require_csrf(cx, &form.csrf).await?; let lang = i18n::lang(cx, Some(&user)); ensure_enabled(state, &ctx).await?; let discussion = discussion_by_number(state, &ctx, path_param::(cx)).await?; if ctx.repo.archived || !(can_triage(&ctx) || is_author(&ctx, &discussion.author_id)) { return Err(forbidden().into()); } let base = base_of(&ctx, discussion.number); let answer = form.answer == "1"; let chosen = answer .then_some(form.comment_id.as_str()) .filter(|c| !c.is_empty()); if answer && chosen.is_none() { return Err(bad_request("missing comment").into()); } if let Err(e) = state .store .set_discussion_answer(&discussion.id, chosen) .await { let message = refusal_message(e)?; return Ok(view! { cx => shell(title: g::t(lang, K::CouldNotSave), viewer: ctx.viewer.as_ref(), action_error_at(lang: lang, message: message, back: base)) }); } audit_discussion( cx, state, &user, &ctx, AuditAction::DiscussionAnswered, &discussion.id, ) .await; match chosen { Some(id) => Err(see_other(format!("{base}#comment-{id}")).into()), None => Err(see_other(base).into()), } } #[page(POST "/{owner}/{repo}/discussions/{number}/comments/{comment_id}/edit")] async fn discussion_comment_edit(cx: &Cx, Form(form): Form) -> Result { let ctx = load_repo(cx, path_param::(cx), path_param::(cx)).await?; let state = app_context::(cx); let (user, _) = require_csrf(cx, &form.csrf).await?; ensure_enabled(state, &ctx).await?; let discussion = discussion_by_number(state, &ctx, path_param::(cx)).await?; let lang = i18n::lang(cx, Some(&user)); let comment = own_comment(state, &ctx, &discussion.id, path_param::(cx)).await?; let base = base_of(&ctx, discussion.number); if let Err(e) = state .store .edit_discussion_comment(&comment.id, &form.body) .await { let message = refusal_message(e)?; return Ok(view! { cx => shell(title: g::t(lang, K::CouldNotSave), viewer: ctx.viewer.as_ref(), action_error_at(lang: lang, message: message, back: base)) }); } audit_discussion( cx, state, &user, &ctx, AuditAction::DiscussionCommentEdited, &discussion.id, ) .await; notify_mentions( state, &ctx, &discussion.id, &user.id, &form.body, Some(&comment.body), ) .await; Err(see_other(format!("{base}#comment-{}", comment.id)).into()) } #[route(POST "/{owner}/{repo}/discussions/{number}/comments/{comment_id}/delete")] async fn discussion_comment_delete(cx: &Cx, Form(form): Form) -> Result { let ctx = load_repo(cx, path_param::(cx), path_param::(cx)).await?; let state = app_context::(cx); let (user, _) = require_csrf(cx, &form.csrf).await?; ensure_enabled(state, &ctx).await?; let discussion = discussion_by_number(state, &ctx, path_param::(cx)).await?; let comment = own_comment(state, &ctx, &discussion.id, path_param::(cx)).await?; state.store.delete_discussion_comment(&comment.id).await?; audit_discussion( cx, state, &user, &ctx, AuditAction::DiscussionCommentDeleted, &discussion.id, ) .await; Ok(see_other(base_of(&ctx, discussion.number))) } /// Toggles the viewer's reaction on the discussion (`target_kind=discussion`) or on one of its live /// comments (`target_kind=discussion_comment`). #[route(POST "/{owner}/{repo}/discussions/{number}/reactions")] async fn discussion_reaction(cx: &Cx, Form(form): Form) -> Result { let ctx = load_repo(cx, path_param::(cx), path_param::(cx)).await?; let state = app_context::(cx); let (user, _) = require_csrf(cx, &form.csrf).await?; ensure_enabled(state, &ctx).await?; let discussion = discussion_by_number(state, &ctx, path_param::(cx)).await?; if !may_converse(&ctx, discussion.locked) { return Err(forbidden().into()); } let Some(reaction) = Reaction::parse(&form.reaction) else { return Err(bad_request("unknown reaction").into()); }; let target_id = form.target_id.clone(); let (kind, anchor) = match form.target_kind.as_str() { "discussion" => { if target_id != discussion.id { return Err(not_found().into()); } (ReactionTarget::Discussion, "discussion-body".to_owned()) } "discussion_comment" => { match state .store .discussion_comment(&discussion.id, &target_id) .await? { Some(comment) if !comment.deleted => {} _ => return Err(not_found().into()), } ( ReactionTarget::DiscussionComment, format!("comment-{target_id}"), ) } _ => return Err(bad_request("unknown reaction target").into()), }; state .store .toggle_reaction(kind, &target_id, &user.id, reaction) .await?; Ok(see_other(format!( "{}#{anchor}", base_of(&ctx, discussion.number) ))) }