forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
18.8 KiB
1//! Discussions: the list at `/{owner}/{repo}/discussions` and the new-discussion form (GET shows
2//! it, POST creates the discussion and redirects to it). The other discussion pages are
3//! `discussion_view` (one discussion) and `discussion_actions` (every POST). While a repo has
4//! discussions off, every discussion page is 404. Writes need a signed-in viewer on a repo that is
5//! not archived. Announcements need Maintain or higher.
6
7use forge_core::discussions::{
8 DISCUSSION_CATEGORIES, Discussion, DiscussionCategory, DiscussionFilter,
9};
10use forge_core::{Action, AuditAction, Role, User, authorize};
11use topcoat::Result;
12use topcoat::context::{Cx, app_context};
13use topcoat::router::content::RawForm;
14use topcoat::router::error::{bad_request, forbidden, not_found, see_other};
15use topcoat::router::{StatusCode, page, path_param, query_params};
16use topcoat::view::{View, component, view};
17
18use crate::audit::{self, Actor, Target};
19use crate::auth::{require_csrf, require_user};
20use crate::i18n::community::{self as g, K};
21use crate::i18n::{self, Lang};
22use crate::pages::issue_meta::{can_triage, form_field, form_pairs, refusal_message};
23use crate::pages::issue_parts::When;
24use crate::pages::issues_list::{list_href, state_tab_class};
25use crate::pages::repo::repo_head;
26use crate::pages::repo_ctx::{RepoCtx, RepoHead, load_repo};
27use crate::render::markdown::mentioned_usernames;
28use crate::state::AppState;
29use crate::views::{alert, csrf_field, now_unix, repo_href, shell};
30
31path_param!(owner);
32path_param!(repo);
33
34/// Discussions per list page.
35pub(crate) const PER_PAGE: u32 = 25;
36/// Highest accepted `?page=` value. Anything else falls back to page 1.
37const MAX_PAGE: u32 = 1000;
38
39/// The repo's discussions URL, with `rest` appended (for example a number).
40pub(crate) fn discussions_href(owner: &str, repo: &str, rest: &str) -> String {
41 repo_href(owner, repo, "discussions", rest)
42}
43
44/// 404 while the repo has discussions off. Every discussion page starts with this.
45pub(crate) async fn ensure_enabled(state: &AppState, ctx: &RepoCtx) -> Result<()> {
46 if state.store.discussions_enabled(&ctx.repo.id).await? {
47 Ok(())
48 } else {
49 Err(not_found().into())
50 }
51}
52
53/// Maintain or higher, the owner, or an instance admin. Only these may post announcements.
54pub(crate) fn maintains(ctx: &RepoCtx) -> bool {
55 let actor = ctx.viewer.as_ref().map(|(user, _)| user);
56 authorize(actor, &ctx.repo, ctx.role, ctx.org_role, Action::Admin).is_ok()
57 || (ctx.viewer.is_some() && matches!(ctx.role, Some(Role::Maintain)))
58}
59
60/// Whether the viewer may add comments, reactions or replies: signed in, and the repo is not
61/// archived. A locked discussion takes writes from Triage holders only.
62pub(crate) fn may_converse(ctx: &RepoCtx, locked: bool) -> bool {
63 ctx.viewer.is_some() && !ctx.repo.archived && (!locked || can_triage(ctx))
64}
65
66/// Whether the signed-in viewer wrote this row.
67pub(crate) fn is_author(ctx: &RepoCtx, author_id: &str) -> bool {
68 ctx.viewer
69 .as_ref()
70 .is_some_and(|(user, _)| user.id == author_id)
71}
72
73/// Writes the audit entry for a discussion or comment action. `target_id` is the discussion, or a
74/// comment of it. The repo's `owner/name` names the target.
75pub(crate) async fn audit_discussion(
76 cx: &Cx,
77 state: &AppState,
78 user: &User,
79 ctx: &RepoCtx,
80 action: AuditAction,
81 target_id: &str,
82) {
83 let name = format!("{}/{}", ctx.repo.owner_name, ctx.repo.name);
84 audit::record(
85 &state.store,
86 Some(cx),
87 Actor::User(user),
88 action,
89 Target {
90 kind: "discussion",
91 id: target_id,
92 name: &name,
93 },
94 serde_json::Value::Null,
95 )
96 .await;
97}
98
99/// Notifies the users that `text` mentions. Only people who can read the repo, other than the
100/// actor, get an inbox row. When `old` is given (an edit), only users mentioned newly are notified.
101/// Never fails: problems are logged.
102pub(crate) async fn notify_mentions(
103 state: &AppState,
104 ctx: &RepoCtx,
105 discussion_id: &str,
106 actor_id: &str,
107 text: &str,
108 old: Option<&str>,
109) {
110 let mut names = mentioned_usernames(text);
111 if let Some(old) = old {
112 let before = mentioned_usernames(old);
113 names.retain(|name| !before.contains(name));
114 }
115 let mut ids: Vec<String> = Vec::new();
116 for name in names {
117 let user = match state.store.user_by_username(&name).await {
118 Ok(Some(user)) => user,
119 Ok(None) => continue,
120 Err(e) => {
121 tracing::error!(error = %e, name, "discussions: mention lookup failed");
122 continue;
123 }
124 };
125 if user.id == actor_id {
126 continue;
127 }
128 let role = match state.store.collaborator_role(&ctx.repo.id, &user.id).await {
129 Ok(role) => role,
130 Err(e) => {
131 tracing::error!(error = %e, "discussions: role lookup failed");
132 continue;
133 }
134 };
135 let org_role = match state
136 .store
137 .org_repo_role(&ctx.repo.owner_id, &user.id, &ctx.repo.id)
138 .await
139 {
140 Ok(role) => role,
141 Err(e) => {
142 tracing::error!(error = %e, "discussions: org role lookup failed");
143 continue;
144 }
145 };
146 if authorize(Some(&user), &ctx.repo, role, org_role, Action::Read).is_ok() {
147 ids.push(user.id);
148 }
149 }
150 if let Err(e) = state
151 .store
152 .record_discussion_mentions(&ctx.repo.id, discussion_id, actor_id, &ids)
153 .await
154 {
155 tracing::error!(error = %e, discussion_id, "discussions: mention rows failed");
156 }
157}
158
159/// The category choices of a select. Announcements appear only for maintainers.
160pub(crate) struct CategoryOption {
161 pub(crate) value: &'static str,
162 pub(crate) label: &'static str,
163 pub(crate) selected: bool,
164}
165
166pub(crate) fn category_options(
167 lang: Lang,
168 selected: Option<DiscussionCategory>,
169 maintainer: bool,
170) -> Vec<CategoryOption> {
171 DISCUSSION_CATEGORIES
172 .iter()
173 .filter(|c| maintainer || **c != DiscussionCategory::Announcements)
174 .map(|c| CategoryOption {
175 value: c.as_str(),
176 label: g::category_label(lang, *c),
177 selected: Some(*c) == selected,
178 })
179 .collect()
180}
181
182/// The list's query. `category` is a category name; anything else lists every category.
183#[query_params(error = bad_request)]
184pub(crate) struct ListQuery {
185 category: Option<String>,
186 page: Option<String>,
187}
188
189/// One row of the list, with the strings already built.
190struct RowView {
191 href: String,
192 title: String,
193 category: &'static str,
194 author: String,
195 relative: String,
196 absolute: String,
197 iso: String,
198 comments_text: String,
199}
200
201fn row_view(
202 lang: Lang,
203 owner: &str,
204 repo: &str,
205 item: &forge_core::discussions::DiscussionListItem,
206 tz: &str,
207 now: i64,
208) -> RowView {
209 let d = &item.discussion;
210 let when = When::new(lang, &d.created_at, tz, now);
211 let comments = d.comment_count.max(0);
212 RowView {
213 href: discussions_href(owner, repo, &d.number.to_string()),
214 title: d.title.clone(),
215 category: g::category_label(lang, d.category),
216 author: item.author.clone(),
217 relative: when.relative,
218 absolute: when.absolute,
219 iso: when.iso,
220 comments_text: g::comments_count(lang, comments),
221 }
222}
223
224#[page("/{owner}/{repo}/discussions")]
225async fn discussions_list_page(cx: &Cx) -> Result<impl View> {
226 let owner = path_param::<Owner>(cx);
227 let name = path_param::<Repo>(cx);
228 let query = query_params::<ListQuery>(cx)?;
229 let ctx = load_repo(cx, owner, name).await?;
230 let state = app_context::<AppState>(cx);
231 ensure_enabled(state, &ctx).await?;
232 let lang = i18n::lang(cx, ctx.viewer.as_ref().map(|(user, _)| user));
233
234 let category = query
235 .category
236 .as_deref()
237 .and_then(DiscussionCategory::parse);
238 let page = query
239 .page
240 .as_deref()
241 .and_then(|p| p.trim().parse::<u32>().ok())
242 .filter(|p| (1..=MAX_PAGE).contains(p))
243 .unwrap_or(1);
244 let filter = DiscussionFilter {
245 category,
246 page,
247 per_page: PER_PAGE,
248 };
249 let (items, total) = state.store.list_discussions(&ctx.repo.id, &filter).await?;
250
251 let owner_name = ctx.repo.owner_name.clone();
252 let repo_name = ctx.repo.name.clone();
253 let now = now_unix();
254 let tz = ctx.viewer_tz().to_owned();
255 let rows: Vec<RowView> = items
256 .iter()
257 .map(|item| row_view(lang, &owner_name, &repo_name, item, &tz, now))
258 .collect();
259
260 let base = discussions_href(&owner_name, &repo_name, "");
261 let selected = category.map_or("", |c| c.as_str());
262 let all_class = state_tab_class(category.is_none());
263 let all_href = list_href(&base, &[]);
264 let all_label = g::t(lang, K::AllCategories);
265 let nav_label = g::t(lang, K::CategoriesNav);
266 let tabs: Vec<(&'static str, String, &'static str)> = DISCUSSION_CATEGORIES
267 .iter()
268 .map(|c| {
269 (
270 g::category_label(lang, *c),
271 list_href(&base, &[("category", c.as_str())]),
272 state_tab_class(category == Some(*c)),
273 )
274 })
275 .collect();
276
277 let last_page = total.div_ceil(u64::from(PER_PAGE)).max(1);
278 let has_newer = page > 1;
279 let has_older = u64::from(page) < last_page;
280 let page_param = |p: u32| {
281 let text = if p > 1 { p.to_string() } else { String::new() };
282 list_href(&base, &[("category", selected), ("page", text.as_str())])
283 };
284 let newer_href = page_param(page.saturating_sub(1));
285 let older_href = page_param(page + 1);
286 let page_label = g::page_number(lang, page);
287
288 let can_start = ctx.viewer.is_some() && !ctx.repo.archived;
289 let new_href = discussions_href(&owner_name, &repo_name, "new");
290 let title = format!(
291 "{owner_name}/{repo_name} \u{b7} {}",
292 g::t(lang, K::Discussions)
293 );
294 let empty_text = g::t(lang, K::NoDiscussionsYet);
295 let new_label = g::t(lang, K::NewDiscussion);
296 let (by_before, by_middle, by_after) = g::byline(lang);
297 let newer_label = g::t(lang, K::Newer);
298 let older_label = g::t(lang, K::Older);
299
300 let header = ctx.head(state, "discussions").await?;
301 Ok(view! {
302 shell(title: title.as_str(), viewer: ctx.viewer.as_ref(),
303 repo_head(head: header)
304 <div class="issue-toolbar">
305 <nav class="issue-tabs" aria-label=(nav_label)>
306 <a class=(all_class) href=(all_href.as_str())>(all_label)</a>
307 for tab in tabs.iter() {
308 <a class=(tab.2) href=(tab.1.as_str())>(tab.0)</a>
309 }
310 </nav>
311 if can_start {
312 <div class="issue-tools">
313 <a class="button" href=(new_href.as_str())>(new_label)</a>
314 </div>
315 }
316 </div>
317 if rows.is_empty() {
318 <p class="muted">(empty_text)</p>
319 } else {
320 <ul class="issue-list">
321 for row in rows.iter() {
322 <li class="issue-row">
323 <div class="issue-main">
324 <div class="issue-title-line">
325 <a class="issue-title" href=(row.href.as_str())>(row.title.as_str())</a>
326 <span class="label-chip">(row.category)</span>
327 </div>
328 <div class="issue-meta muted small">
329 <span>(by_before)</span>
330 <span>(row.author.as_str())</span>
331 <span>(by_middle)</span>
332 <time datetime=(row.iso.as_str()) title=(row.absolute.as_str())>(row.relative.as_str())</time>
333 if !by_after.is_empty() {
334 <span>(by_after)</span>
335 }
336 </div>
337 </div>
338 <div class="issue-aside">
339 <a class="small muted" href=(row.href.as_str())>(row.comments_text.as_str())</a>
340 </div>
341 </li>
342 }
343 </ul>
344 }
345 <nav class="pager">
346 if has_newer {
347 <a href=(newer_href.as_str())>(newer_label)</a>
348 }
349 <span class="muted small">(page_label.as_str())</span>
350 if has_older {
351 <a href=(older_href.as_str())>(older_label)</a>
352 }
353 </nav>
354 )
355 })
356}
357
358/// The values the new-discussion form shows: what was typed, and the message when it failed.
359#[derive(Default)]
360struct DraftDiscussion {
361 title: String,
362 body: String,
363 error: Option<String>,
364}
365
366#[component]
367async fn new_discussion_body(
368 lang: Lang,
369 head: RepoHead,
370 csrf: String,
371 draft: DraftDiscussion,
372 options: Vec<CategoryOption>,
373) -> Result<impl View> {
374 let action = discussions_href(&head.owner, &head.name, "new");
375 let cancel = discussions_href(&head.owner, &head.name, "");
376 Ok(view! {
377 repo_head(head: head)
378 <section class="card issue-new">
379 <h2>(g::t(lang, K::NewDiscussion))</h2>
380 if let Some(message) = draft.error {
381 alert(message: message.as_str())
382 }
383 <form method="post" action=(action.as_str()) class="stack">
384 csrf_field(token: csrf.as_str())
385 <label>
386 <span>(g::t(lang, K::Category))</span>
387 <select name="category">
388 for option in options.iter() {
389 <option value=(option.value) selected=(option.selected)>(option.label)</option>
390 }
391 </select>
392 </label>
393 <label>
394 <span>(g::t(lang, K::Title))</span>
395 <input type="text" name="title" value=(draft.title.as_str()) required=(true) autocomplete="off">
396 </label>
397 <label>
398 <span>(g::t(lang, K::Description))</span>
399 <textarea name="body" rows="12">(draft.body.as_str())</textarea>
400 <span class="muted small">(g::t(lang, K::MarkdownSupported))</span>
401 </label>
402 <div class="inline-form">
403 <button type="submit" class="primary">(g::t(lang, K::StartDiscussion))</button>
404 <a href=(cancel.as_str())>(g::t(lang, K::Cancel))</a>
405 </div>
406 </form>
407 </section>
408 })
409}
410
411#[page("/{owner}/{repo}/discussions/new")]
412async fn discussion_new_page(cx: &Cx) -> Result<impl View> {
413 let owner = path_param::<Owner>(cx);
414 let name = path_param::<Repo>(cx);
415 let ctx = load_repo(cx, owner, name).await?;
416 let state = app_context::<AppState>(cx);
417 ensure_enabled(state, &ctx).await?;
418 let viewer = require_user(cx).await?;
419 let lang = i18n::lang(cx, Some(&viewer.0));
420 if ctx.repo.archived {
421 return Err(forbidden().into());
422 }
423 let header = ctx.head(state, "discussions").await?;
424 let title = format!(
425 "{}/{} \u{b7} {}",
426 ctx.repo.owner_name,
427 ctx.repo.name,
428 g::t(lang, K::NewDiscussion)
429 );
430 let csrf = viewer.1.csrf_token.clone();
431 let options = category_options(lang, Some(DiscussionCategory::General), maintains(&ctx));
432 Ok(view! {
433 shell(title: title.as_str(), viewer: Some(&viewer),
434 new_discussion_body(lang: lang, head: header, csrf: csrf, draft: DraftDiscussion::default(), options: options))
435 })
436}
437
438#[page(POST "/{owner}/{repo}/discussions/new")]
439async fn discussion_create(cx: &Cx, RawForm(raw): RawForm) -> Result<impl View> {
440 let owner = path_param::<Owner>(cx);
441 let name = path_param::<Repo>(cx);
442 let ctx = load_repo(cx, owner, name).await?;
443 let state = app_context::<AppState>(cx);
444 ensure_enabled(state, &ctx).await?;
445 let fields = form_pairs(&raw[..]);
446 let viewer = require_csrf(cx, &form_field(&fields, "csrf")).await?;
447 let lang = i18n::lang(cx, Some(&viewer.0));
448 if ctx.repo.archived {
449 return Err(forbidden().into());
450 }
451 let category = match DiscussionCategory::parse(&form_field(&fields, "category")) {
452 Some(category) => category,
453 None => return Err(bad_request("unknown category").into()),
454 };
455 if category == DiscussionCategory::Announcements && !maintains(&ctx) {
456 return Err(forbidden().into());
457 }
458 let typed_title = form_field(&fields, "title");
459 let typed_body = form_field(&fields, "body");
460 match state
461 .store
462 .create_discussion(
463 &ctx.repo.id,
464 &viewer.0.id,
465 category,
466 &typed_title,
467 &typed_body,
468 )
469 .await
470 {
471 Ok(discussion) => {
472 audit_discussion(
473 cx,
474 state,
475 &viewer.0,
476 &ctx,
477 AuditAction::DiscussionCreated,
478 &discussion.id,
479 )
480 .await;
481 notify_mentions(state, &ctx, &discussion.id, &viewer.0.id, &typed_body, None).await;
482 let location = discussions_href(
483 &ctx.repo.owner_name,
484 &ctx.repo.name,
485 &discussion.number.to_string(),
486 );
487 Err(see_other(location).into())
488 }
489 Err(e) => {
490 let message = refusal_message(e)?;
491 let header = ctx.head(state, "discussions").await?;
492 let title = format!(
493 "{}/{} \u{b7} {}",
494 ctx.repo.owner_name,
495 ctx.repo.name,
496 g::t(lang, K::NewDiscussion)
497 );
498 let csrf = viewer.1.csrf_token.clone();
499 let draft = DraftDiscussion {
500 title: typed_title,
501 body: typed_body,
502 error: Some(message),
503 };
504 let options = category_options(lang, Some(category), maintains(&ctx));
505 Ok(view! {
506 (StatusCode::UNPROCESSABLE_ENTITY)
507 shell(title: title.as_str(), viewer: Some(&viewer),
508 new_discussion_body(lang: lang, head: header, csrf: csrf, draft: draft, options: options))
509 })
510 }
511 }
512}
513
514/// The discussion with this number in the repo, or 404.
515pub(crate) async fn discussion_by_number(
516 state: &AppState,
517 ctx: &RepoCtx,
518 number: &str,
519) -> Result<Discussion> {
520 let Some(n) = number.parse::<i64>().ok() else {
521 return Err(not_found().into());
522 };
523 match state.store.discussion_by_number(&ctx.repo.id, n).await? {
524 Some(discussion) => Ok(discussion),
525 None => Err(not_found().into()),
526 }
527}