forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
17.4 KiB
1//! Page: labels (`/{owner}/{repo}/labels`). Anyone who can read the repo sees each label as a chip
2//! with its description and open issue count. Triage holders also get a create form and, per
3//! label, edit and delete forms. Writes check CSRF and `Action::Triage` (see `issue_meta`).
4
5use std::collections::HashMap;
6
7use serde::Deserialize;
8use topcoat::Result;
9use topcoat::context::{Cx, app_context};
10use topcoat::router::content::Form;
11use topcoat::router::error::see_other;
12use topcoat::router::{page, path_param};
13use topcoat::view::{View, component, view};
14
15use crate::i18n::issues::{self as g, K};
16use crate::i18n::{self, Lang};
17use crate::pages::issue_meta::{
18 RepoFrame, action_error_at, issue_subnav, refusal_message, triage_guard,
19};
20use crate::pages::repo::repo_head;
21use crate::pages::repo_ctx::load_repo;
22use crate::state::AppState;
23use crate::views::{enc, shell};
24
25path_param!(owner);
26path_param!(repo);
27path_param!(label_id);
28
29/// The create and edit form. Only the fields the form sends are read.
30#[derive(Deserialize)]
31struct LabelForm {
32 #[serde(default)]
33 csrf: String,
34 #[serde(default)]
35 name: String,
36 #[serde(default)]
37 color: String,
38 #[serde(default)]
39 description: String,
40}
41
42/// The body of the delete form, which only carries the CSRF token.
43#[derive(Deserialize)]
44struct CsrfForm {
45 #[serde(default)]
46 csrf: String,
47}
48
49/// One label as the page shows it.
50struct LabelRow {
51 name: String,
52 description: String,
53 color: String,
54 chip_class: String,
55 issues_href: String,
56 open_text: String,
57 edit_action: String,
58 delete_action: String,
59}
60
61/// `/{owner}/{repo}/labels` for this repo.
62fn labels_href(title: &str) -> String {
63 format!("/{title}/labels")
64}
65
66/// The header, the label list and, for triage holders, the forms.
67#[component]
68async fn labels_body(lang: Lang, frame: RepoFrame, rows: Vec<LabelRow>) -> Result<impl View> {
69 let labels_action = labels_href(&frame.title);
70 let defaults_action = format!("{labels_action}/defaults");
71 let empty = rows.is_empty();
72 let archived = frame.archived;
73 let can_triage = frame.can_triage;
74 let csrf = frame.csrf.clone();
75 let header = frame.head.clone();
76 Ok(view! {
77 repo_head(head: header)
78 issue_subnav(lang: lang, title: frame.title.clone(), active: "labels".to_owned())
79 if archived {
80 <section class="card notice" role="status">(g::t(lang, K::LabelsArchived))</section>
81 }
82 <section class="card">
83 <h2>(g::t(lang, K::Labels))</h2>
84 if empty {
85 <p class="muted">(g::t(lang, K::NoLabelsYet))</p>
86 if can_triage && !archived {
87 <form method="post" action=(defaults_action.as_str()) class="stack">
88 <input type="hidden" name="csrf" value=(csrf.as_str())>
89 <div>
90 <button type="submit">(g::t(lang, K::AddDefaultLabels))</button>
91 </div>
92 </form>
93 }
94 }
95 <ul class="label-list">
96 for row in rows.iter() {
97 <li class="label-row">
98 <div class="label-head">
99 <a class=(row.chip_class.as_str()) href=(row.issues_href.as_str())>(row.name.as_str())</a>
100 <a class="small" href=(row.issues_href.as_str())>(row.open_text.as_str())</a>
101 </div>
102 if !row.description.is_empty() {
103 <p class="muted small">(row.description.as_str())</p>
104 }
105 if can_triage {
106 <div class="label-actions">
107 <details>
108 <summary>(g::t(lang, K::Edit))</summary>
109 <form method="post" action=(row.edit_action.as_str()) class="stack">
110 <input type="hidden" name="csrf" value=(csrf.as_str())>
111 <label>
112 <span>(g::t(lang, K::Name))</span>
113 <input type="text" name="name" value=(row.name.as_str()) autocomplete="off" required=(true)>
114 </label>
115 <label>
116 <span>(g::t(lang, K::Color))</span>
117 <input type="color" name="color" value=(row.color.as_str())>
118 </label>
119 <label>
120 <span>(g::t(lang, K::Description))</span>
121 <input type="text" name="description" value=(row.description.as_str()) autocomplete="off">
122 </label>
123 <div>
124 <button type="submit">(g::t(lang, K::SaveLabel))</button>
125 </div>
126 </form>
127 </details>
128 <form method="post" action=(row.delete_action.as_str()) class="remove-form">
129 <input type="hidden" name="csrf" value=(csrf.as_str())>
130 <button type="submit" class="danger">(g::t(lang, K::Delete))</button>
131 </form>
132 </div>
133 }
134 </li>
135 }
136 </ul>
137 </section>
138 if can_triage {
139 <section class="card">
140 <h2>(g::t(lang, K::NewLabel))</h2>
141 <form method="post" action=(labels_action.as_str()) class="stack">
142 <input type="hidden" name="csrf" value=(csrf.as_str())>
143 <label>
144 <span>(g::t(lang, K::Name))</span>
145 <input type="text" name="name" autocomplete="off" required=(true) maxlength="50">
146 </label>
147 <label>
148 <span>(g::t(lang, K::Color))</span>
149 <input type="color" name="color" value="#ededed">
150 </label>
151 <label>
152 <span>(g::t(lang, K::Description))</span>
153 <input type="text" name="description" autocomplete="off">
154 </label>
155 <div>
156 <button type="submit" class="primary">(g::t(lang, K::CreateLabel))</button>
157 </div>
158 </form>
159 </section>
160 }
161 })
162}
163
164/// Gets the labels page. Hidden repos are 404 (checked in `load_repo`).
165#[page("/{owner}/{repo}/labels")]
166async fn labels_page(cx: &Cx) -> Result<impl View> {
167 let owner = path_param::<Owner>(cx);
168 let name = path_param::<Repo>(cx);
169 let ctx = load_repo(cx, owner, name).await?;
170 let lang = i18n::lang(cx, ctx.viewer.as_ref().map(|(u, _)| u));
171 let store = &app_context::<AppState>(cx).store;
172 let labels = store.list_labels(&ctx.repo.id).await?;
173 let counts: HashMap<String, i64> = store
174 .label_open_counts(&ctx.repo.id)
175 .await?
176 .into_iter()
177 .collect();
178 let frame = RepoFrame::new(&ctx, app_context::<AppState>(cx), "issues").await?;
179 let title = frame.title.clone();
180 let rows: Vec<LabelRow> = labels
181 .into_iter()
182 .map(|label| {
183 let open = counts.get(&label.id).copied().unwrap_or(0);
184 LabelRow {
185 chip_class: format!("label-chip label-{}", label.id),
186 issues_href: format!("/{title}/issues?label={}", enc(&label.name)),
187 open_text: g::open_label_count(lang, open),
188 edit_action: format!("{}/{}", labels_href(&title), label.id),
189 delete_action: format!("{}/{}/delete", labels_href(&title), label.id),
190 name: label.name,
191 description: label.description,
192 color: label.color,
193 }
194 })
195 .collect();
196 let css_href = format!("/{}/labels.css", frame.title);
197 let page_title = g::labels_title(lang, &frame.title);
198 Ok(view! {
199 shell(title: page_title.as_str(), viewer: ctx.viewer.as_ref(),
200 extra_stylesheet: Some(css_href.as_str()),
201 labels_body(lang: lang, frame: frame, rows: rows))
202 })
203}
204
205/// Creates a label. A duplicate name is shown on the error page.
206#[page(POST "/{owner}/{repo}/labels")]
207async fn label_create(cx: &Cx, Form(form): Form<LabelForm>) -> Result<impl View> {
208 let owner = path_param::<Owner>(cx);
209 let name = path_param::<Repo>(cx);
210 let ctx = load_repo(cx, owner, name).await?;
211 triage_guard(cx, &ctx, &form.csrf).await?;
212 let lang = i18n::lang(cx, ctx.viewer.as_ref().map(|(u, _)| u));
213 let back = labels_href(&format!("{}/{}", ctx.repo.owner_name, ctx.repo.name));
214 let result = app_context::<AppState>(cx)
215 .store
216 .create_label(&ctx.repo.id, &form.name, &form.color, &form.description)
217 .await;
218 match result {
219 Ok(_) => Err(see_other(back).into()),
220 Err(e) => {
221 let message = refusal_message(e)?;
222 Ok(view! {
223 shell(title: g::t(lang, K::CouldNotSave), viewer: ctx.viewer.as_ref(),
224 action_error_at(lang: lang, message: message, back: back))
225 })
226 }
227 }
228}
229
230/// Updates a label's name, color and description.
231#[page(POST "/{owner}/{repo}/labels/{label_id}")]
232async fn label_update(cx: &Cx, Form(form): Form<LabelForm>) -> Result<impl View> {
233 let owner = path_param::<Owner>(cx);
234 let name = path_param::<Repo>(cx);
235 let id = path_param::<LabelId>(cx);
236 let ctx = load_repo(cx, owner, name).await?;
237 triage_guard(cx, &ctx, &form.csrf).await?;
238 let lang = i18n::lang(cx, ctx.viewer.as_ref().map(|(u, _)| u));
239 let back = labels_href(&format!("{}/{}", ctx.repo.owner_name, ctx.repo.name));
240 let result = app_context::<AppState>(cx)
241 .store
242 .update_label(&ctx.repo.id, id, &form.name, &form.color, &form.description)
243 .await;
244 match result {
245 Ok(()) => Err(see_other(back).into()),
246 Err(e) => {
247 let message = refusal_message(e)?;
248 Ok(view! {
249 shell(title: g::t(lang, K::CouldNotSave), viewer: ctx.viewer.as_ref(),
250 action_error_at(lang: lang, message: message, back: back))
251 })
252 }
253 }
254}
255
256/// Adds the default labels to a repo that has none. Triage holders only; labels that already
257/// exist are skipped, so a repeated submit adds nothing.
258#[page(POST "/{owner}/{repo}/labels/defaults")]
259async fn label_defaults(cx: &Cx, Form(form): Form<CsrfForm>) -> Result<impl View> {
260 let owner = path_param::<Owner>(cx);
261 let name = path_param::<Repo>(cx);
262 let ctx = load_repo(cx, owner, name).await?;
263 triage_guard(cx, &ctx, &form.csrf).await?;
264 let lang = i18n::lang(cx, ctx.viewer.as_ref().map(|(u, _)| u));
265 let back = labels_href(&format!("{}/{}", ctx.repo.owner_name, ctx.repo.name));
266 let result = app_context::<AppState>(cx)
267 .store
268 .create_default_labels(&ctx.repo.id)
269 .await;
270 match result {
271 Ok(_) => Err(see_other(back).into()),
272 Err(e) => {
273 let message = refusal_message(e)?;
274 Ok(view! {
275 shell(title: g::t(lang, K::CouldNotSave), viewer: ctx.viewer.as_ref(),
276 action_error_at(lang: lang, message: message, back: back))
277 })
278 }
279 }
280}
281
282/// Deletes a label and its links to issues.
283#[page(POST "/{owner}/{repo}/labels/{label_id}/delete")]
284async fn label_delete(cx: &Cx, Form(form): Form<CsrfForm>) -> Result<impl View> {
285 let owner = path_param::<Owner>(cx);
286 let name = path_param::<Repo>(cx);
287 let id = path_param::<LabelId>(cx);
288 let ctx = load_repo(cx, owner, name).await?;
289 triage_guard(cx, &ctx, &form.csrf).await?;
290 let lang = i18n::lang(cx, ctx.viewer.as_ref().map(|(u, _)| u));
291 let back = labels_href(&format!("{}/{}", ctx.repo.owner_name, ctx.repo.name));
292 let result = app_context::<AppState>(cx)
293 .store
294 .delete_label(&ctx.repo.id, id)
295 .await;
296 match result {
297 Ok(()) => Err(see_other(back).into()),
298 Err(e) => {
299 let message = refusal_message(e)?;
300 Ok(view! {
301 shell(title: g::t(lang, K::CouldNotSave), viewer: ctx.viewer.as_ref(),
302 action_error_at(lang: lang, message: message, back: back))
303 })
304 }
305 }
306}
307
308#[cfg(test)]
309mod tests {
310 use forge_core::{Role, Store, Visibility};
311 use topcoat::router::request::Request;
312 use topcoat::router::{Body, Router, to_bytes};
313
314 use crate::auth::SESSION_COOKIE;
315
316 const CSRF: &str = "labels-defaults-csrf";
317
318 struct Site {
319 router: Router,
320 store: Store,
321 _dir: tempfile::TempDir,
322 bob: String,
323 carol: String,
324 }
325
326 /// `alice/forge` (public) with Bob as a Read collaborator and Carol as Triage.
327 async fn site() -> Site {
328 let dir = tempfile::tempdir().expect("tempdir");
329 let state = crate::test_support::test_state(dir.path()).await;
330 let store = state.store.clone();
331 let router = crate::test_support::app_router(state);
332 let alice = store
333 .create_user("alice", None, "x", false)
334 .await
335 .expect("alice");
336 let bob = store
337 .create_user("bob", None, "x", false)
338 .await
339 .expect("bob");
340 let carol = store
341 .create_user("carol", None, "x", false)
342 .await
343 .expect("carol");
344 let repo = store
345 .create_repo(&alice.id, "forge", "", Visibility::Public, "main")
346 .await
347 .expect("repo");
348 store
349 .set_collaborator(&repo.id, &bob.id, Role::Read)
350 .await
351 .expect("bob role");
352 store
353 .set_collaborator(&repo.id, &carol.id, Role::Triage)
354 .await
355 .expect("carol role");
356 crate::test_support::create_session(&store, &bob.id, "bob-token", CSRF).await;
357 crate::test_support::create_session(&store, &carol.id, "carol-token", CSRF).await;
358 Site {
359 router,
360 store,
361 _dir: dir,
362 bob: format!("__Host-{SESSION_COOKIE}=bob-token"),
363 carol: format!("__Host-{SESSION_COOKIE}=carol-token"),
364 }
365 }
366
367 /// Sends a request and returns the status and body text.
368 async fn send(
369 site: &Site,
370 method: &str,
371 uri: &str,
372 cookie: Option<&str>,
373 csrf: &str,
374 ) -> (u16, String) {
375 let mut builder = Request::builder().method(method).uri(uri);
376 if let Some(cookie) = cookie {
377 builder = builder.header("cookie", cookie);
378 }
379 let body = form_urlencoded::Serializer::new(String::new())
380 .append_pair("csrf", csrf)
381 .finish();
382 builder = builder.header("content-type", "application/x-www-form-urlencoded");
383 let res = site
384 .router
385 .handle(builder.body(Body::from(body)).expect("request"))
386 .await;
387 let status = res.status().as_u16();
388 let bytes = to_bytes(res.into_body(), usize::MAX).await.expect("body");
389 (status, String::from_utf8_lossy(&bytes).into_owned())
390 }
391
392 async fn label_count(site: &Site) -> usize {
393 let repo = site
394 .store
395 .repo_by_owner_and_name("alice", "forge")
396 .await
397 .expect("query")
398 .expect("repo");
399 site.store
400 .list_labels(&repo.id)
401 .await
402 .expect("labels")
403 .len()
404 }
405
406 #[tokio::test]
407 async fn triage_adds_defaults_once_and_repeat_adds_nothing() {
408 let site = site().await;
409 let url = "/alice/forge/labels/defaults";
410 let (status, _) = send(&site, "POST", url, Some(&site.carol), CSRF).await;
411 assert_eq!(status, 303);
412 assert_eq!(label_count(&site).await, 9);
413 let (status, _) = send(&site, "POST", url, Some(&site.carol), CSRF).await;
414 assert_eq!(status, 303);
415 assert_eq!(label_count(&site).await, 9);
416 }
417
418 #[tokio::test]
419 async fn read_only_viewer_cannot_add_defaults() {
420 let site = site().await;
421 let (status, _) = send(
422 &site,
423 "POST",
424 "/alice/forge/labels/defaults",
425 Some(&site.bob),
426 CSRF,
427 )
428 .await;
429 assert_eq!(status, 403);
430 assert_eq!(label_count(&site).await, 0);
431 }
432
433 #[tokio::test]
434 async fn defaults_need_a_valid_csrf_token() {
435 let site = site().await;
436 let (status, _) = send(
437 &site,
438 "POST",
439 "/alice/forge/labels/defaults",
440 Some(&site.carol),
441 "wrong",
442 )
443 .await;
444 assert_ne!(status, 303);
445 assert_eq!(label_count(&site).await, 0);
446 }
447
448 #[tokio::test]
449 async fn button_shows_only_for_triage_when_no_labels() {
450 let site = site().await;
451 let (status, body) =
452 send(&site, "GET", "/alice/forge/labels", Some(&site.carol), CSRF).await;
453 assert_eq!(status, 200);
454 assert!(body.contains("/alice/forge/labels/defaults"));
455 let (status, body) = send(&site, "GET", "/alice/forge/labels", Some(&site.bob), CSRF).await;
456 assert_eq!(status, 200);
457 assert!(!body.contains("/labels/defaults"));
458 }
459}