fabrica

hanna/fabrica

19704 bytes
Raw
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Groups: manual creation and the per-group settings page (rename, visibility,
6//! collaborators, delete). Auto-created groups are garbage-collected by the store
7//! when their last repository leaves; only manual groups reach the delete control.
8
9use axum::Form;
10use axum::extract::{Path, Query, State};
11use axum::http::{HeaderMap, Uri};
12use axum::response::{IntoResponse, Redirect, Response};
13use axum_extra::extract::cookie::CookieJar;
14use maud::{Markup, html};
15use model::{Group, User, Visibility};
16use serde::Deserialize;
17
18use crate::AppState;
19use crate::error::{AppError, AppResult};
20use crate::icons::{Icon, icon};
21use crate::layout::page;
22use crate::pages::build_chrome;
23use crate::repo::path_crumbs;
24use crate::session::{MaybeUser, RequireUser, verify_csrf};
25
26/// Resolve the access `viewer` has to `group`, mirroring the repo rules: admin and
27/// owner get [`auth::Access::Admin`]; a group collaborator (on the group or an
28/// ancestor) gets their stored permission; otherwise the group's visibility
29/// decides read access, and everything else is [`auth::Access::None`].
30///
31/// # Errors
32///
33/// Returns [`AppError`] on a store failure.
34pub(crate) async fn group_access(
35 state: &AppState,
36 viewer: Option<&User>,
37 group: &Group,
38) -> AppResult<auth::Access> {
39 if let Some(v) = viewer
40 && (v.is_admin || v.id == group.owner_id)
41 {
42 return Ok(auth::Access::Admin);
43 }
44 if let Some(v) = viewer
45 && let Some(p) = state
46 .store
47 .effective_group_permission(&group.id, &v.id)
48 .await?
49 && let Some(perm) = auth::Permission::parse(&p)
50 {
51 return Ok(perm.into());
52 }
53 let read = match group.visibility {
54 Visibility::Public => viewer.is_some() || state.allow_anonymous(),
55 Visibility::Internal => viewer.is_some(),
56 Visibility::Private => false,
57 };
58 Ok(if read {
59 auth::Access::Read
60 } else {
61 auth::Access::None
62 })
63}
64
65/// Load a group by id and require the viewer be an admin of it, else 404 (never
66/// leak the group's existence).
67async fn require_admin_group(
68 state: &AppState,
69 viewer: Option<&User>,
70 id: &str,
71) -> AppResult<Group> {
72 let group = state
73 .store
74 .group_by_id(id)
75 .await?
76 .ok_or(AppError::NotFound)?;
77 if group_access(state, viewer, &group).await? == auth::Access::Admin {
78 Ok(group)
79 } else {
80 Err(AppError::NotFound)
81 }
82}
83
84// ---- Manual creation ----
85
86/// `GET /new/group` — the manual group-creation form. An optional `?parent=path`
87/// prefills the path so "New subgroup" lands inside an existing group.
88pub async fn new_form(
89 State(state): State<AppState>,
90 RequireUser(user): RequireUser,
91 jar: CookieJar,
92 uri: Uri,
93 Query(q): Query<NewGroupQuery>,
94) -> Response {
95 let (jar, chrome) = build_chrome(&state, jar, Some(user), uri.path().to_string());
96 let prefill = q
97 .parent
98 .as_deref()
99 .map(|p| format!("{}/", p.trim_matches('/')))
100 .unwrap_or_default();
101 let body = new_group_body(&chrome.csrf, None, &prefill);
102 (jar, page(&chrome, "New group", body)).into_response()
103}
104
105/// Query for the new-group form.
106#[derive(Debug, Deserialize)]
107pub struct NewGroupQuery {
108 /// Optional parent group path to prefill.
109 parent: Option<String>,
110}
111
112/// New-group form fields.
113#[derive(Debug, Deserialize)]
114pub struct NewGroupForm {
115 /// The group path (may be nested with `/`).
116 #[serde(default)]
117 path: String,
118 /// Visibility token (`public` | `internal` | `private`).
119 #[serde(default)]
120 visibility: String,
121 /// CSRF token.
122 #[serde(rename = "_csrf")]
123 csrf: String,
124}
125
126/// `POST /new/group` — create a manual group and redirect to it.
127pub async fn new_submit(
128 State(state): State<AppState>,
129 RequireUser(user): RequireUser,
130 jar: CookieJar,
131 headers: HeaderMap,
132 Form(form): Form<NewGroupForm>,
133) -> Response {
134 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
135 return AppError::Forbidden.into_response();
136 }
137 match create_group_from_form(&state, &user, &form).await {
138 Ok(path) => Redirect::to(&format!("/{}/{path}", user.username)).into_response(),
139 Err(msg) => {
140 let (jar, chrome) = build_chrome(&state, jar, Some(user), "/new/group".to_string());
141 let body = new_group_body(&chrome.csrf, Some(&msg), form.path.trim());
142 (
143 axum::http::StatusCode::BAD_REQUEST,
144 jar,
145 page(&chrome, "New group", body),
146 )
147 .into_response()
148 }
149 }
150}
151
152/// Create the group described by `form`, returning its path or a user-facing error.
153async fn create_group_from_form(
154 state: &AppState,
155 user: &User,
156 form: &NewGroupForm,
157) -> Result<String, String> {
158 let path = form.path.trim().trim_matches('/');
159 if path.is_empty() {
160 return Err("Group path is required.".to_string());
161 }
162 // A group may not collide with an existing repository at the same path.
163 if state
164 .store
165 .repo_by_owner_path(&user.id, path)
166 .await
167 .map_err(|e| e.to_string())?
168 .is_some()
169 {
170 return Err("A repository already exists at that path.".to_string());
171 }
172 let group = state
173 .store
174 .create_group(&user.id, path)
175 .await
176 .map_err(|e| match e {
177 store::StoreError::Conflict { .. } => "That group already exists.".to_string(),
178 store::StoreError::Name(_) => {
179 "Invalid name: use letters, digits, '-', '_', and '/' for nesting.".to_string()
180 }
181 other => other.to_string(),
182 })?;
183 if let Some(mut vis) = Visibility::from_token(&form.visibility) {
184 // A subgroup may not be more visible than its parent's ceiling.
185 if let Some(parent_id) = &group.parent_id {
186 if let Ok(ceiling) = state.store.group_visibility_ceiling(parent_id).await
187 && vis.rank() > ceiling.rank()
188 {
189 vis = ceiling;
190 }
191 }
192 let _ = state.store.set_group_visibility(&group.id, vis).await;
193 }
194 Ok(group.path)
195}
196
197/// The new-group form body.
198fn new_group_body(csrf: &str, error: Option<&str>, prefill: &str) -> Markup {
199 let vis_option = |value: Visibility, label: &str| {
200 html! { option value=(value.as_str()) selected[value == Visibility::Private] { (label) } }
201 };
202 html! {
203 h1 { "New group" }
204 @if let Some(msg) = error { p class="form-error" { (msg) } }
205 div class="card" {
206 form method="post" action="/new/group" {
207 input type="hidden" name="_csrf" value=(csrf);
208 label for="path" { "Group path" }
209 input type="text" id="path" name="path" value=(prefill)
210 placeholder="team (or team/subteam)" autofocus required;
211 p class="muted field-hint" {
212 "Nest with '/'. Intermediate groups are created automatically."
213 }
214 label for="visibility" { "Visibility" }
215 select id="visibility" name="visibility" {
216 (vis_option(Visibility::Public, "Public — visible to everyone"))
217 (vis_option(Visibility::Internal, "Internal — any signed-in user"))
218 (vis_option(Visibility::Private, "Private — only you and collaborators"))
219 }
220 div class="new-issue-actions" {
221 button class="btn btn-primary inline-btn" type="submit" { "Create group" }
222 }
223 }
224 }
225 }
226}
227
228// ---- Settings page ----
229
230/// `GET /group-settings/{id}` — the per-group settings page (admin only).
231pub async fn settings_view(
232 State(state): State<AppState>,
233 MaybeUser(viewer): MaybeUser,
234 jar: CookieJar,
235 uri: Uri,
236 Path(id): Path<String>,
237) -> Response {
238 let result = async {
239 let group = require_admin_group(&state, viewer.as_ref(), &id).await?;
240 let owner = state
241 .store
242 .user_by_id(&group.owner_id)
243 .await?
244 .map_or_else(|| group.owner_id.clone(), |u| u.username);
245 let collaborators = state.store.list_group_collaborators(&group.id).await?;
246 // A manual group may be deleted only once its subtree holds no repos and no
247 // child groups, so the delete never orphans anything.
248 let subtree_repos = state.store.group_subtree_repo_count(&group).await?;
249 let all_groups = state.store.groups_by_owner(&group.owner_id).await?;
250 let child_prefix = format!("{}/", group.path);
251 let has_children = all_groups.iter().any(|g| g.path.starts_with(&child_prefix));
252 let deletable = group.manual && subtree_repos == 0 && !has_children;
253
254 let (jar, chrome) = build_chrome(&state, jar, viewer, uri.path().to_string());
255 let gid = &group.id;
256 let vis = group.visibility;
257 let vis_option = |value: Visibility, label: &str| {
258 html! { option value=(value.as_str()) selected[vis == value] { (label) } }
259 };
260 let crumbs = path_crumbs(&owner, &group.path);
261 let body = html! {
262 h1 class="repo-slug group-slug" {
263 (icon(Icon::Folder))
264 a href={ "/" (owner) } { (owner) }
265 @for (name, href) in &crumbs {
266 span class="slug-sep" { "/" }
267 a href=(href) { (name) }
268 }
269 }
270 p class="muted" { "Group settings" }
271 section class="listing" {
272 h2 { "General" }
273 div class="card" {
274 form id="group-general" method="post" action=(format!("/group-settings/{gid}/general")) {
275 input type="hidden" name="_csrf" value=(chrome.csrf);
276 label for="name" { "Group name" }
277 input type="text" id="name" name="name" value=(group.name) required;
278 label for="visibility" { "Visibility" }
279 select id="visibility" name="visibility" {
280 (vis_option(Visibility::Public, "Public — visible to everyone"))
281 (vis_option(Visibility::Internal, "Internal — any signed-in user"))
282 (vis_option(Visibility::Private, "Private — only you and collaborators"))
283 }
284 }
285 div class="settings-actions" {
286 button class="btn btn-primary" type="submit" form="group-general" { "Save changes" }
287 }
288 }
289 }
290 section class="listing" {
291 h2 { "Collaborators" }
292 p class="muted field-hint" { "Group collaborators gain the same access to every repository within this group." }
293 (crate::repo::collaborators_card(&format!("/group-settings/{gid}"), &chrome.csrf, &collaborators))
294 }
295 section class="listing" {
296 h2 { "Danger zone" }
297 div class="card danger-zone" {
298 @if deletable {
299 p { strong { "Delete this group." } " This removes the group; it holds no repositories or subgroups." }
300 form method="post" action=(format!("/group-settings/{gid}/delete")) {
301 input type="hidden" name="_csrf" value=(chrome.csrf);
302 button class="btn btn-danger" type="submit" { "Delete group" }
303 }
304 } @else if !group.manual {
305 p class="muted" { "Automatic groups are removed on their own once empty." }
306 } @else {
307 p class="muted" { "Empty the group of all repositories and subgroups before deleting it." }
308 }
309 }
310 }
311 };
312 let title = format!("{owner}/{}: settings", group.path);
313 Ok::<Response, AppError>((jar, page(&chrome, &title, body)).into_response())
314 }
315 .await;
316 result.unwrap_or_else(IntoResponse::into_response)
317}
318
319/// The group general-settings form (rename + visibility).
320#[derive(Debug, Deserialize)]
321pub struct GroupGeneralForm {
322 /// The new leaf name (the parent prefix is preserved).
323 #[serde(default)]
324 name: String,
325 /// The chosen visibility token.
326 #[serde(default)]
327 visibility: String,
328 /// CSRF token.
329 #[serde(rename = "_csrf")]
330 csrf: String,
331}
332
333/// `POST /group-settings/{id}/general` — rename and set visibility.
334pub async fn settings_general(
335 State(state): State<AppState>,
336 MaybeUser(viewer): MaybeUser,
337 jar: CookieJar,
338 headers: HeaderMap,
339 Path(id): Path<String>,
340 Form(form): Form<GroupGeneralForm>,
341) -> Response {
342 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
343 return AppError::Forbidden.into_response();
344 }
345 let result = async {
346 let group = require_admin_group(&state, viewer.as_ref(), &id).await?;
347 let owner = state
348 .store
349 .user_by_id(&group.owner_id)
350 .await?
351 .map_or_else(|| group.owner_id.clone(), |u| u.username);
352 let new_name = form.name.trim();
353 let renamed = state
354 .store
355 .rename_group(&group.id, new_name)
356 .await
357 .map_err(|e| match e {
358 store::StoreError::Conflict { .. } => {
359 AppError::BadRequest("That name is already taken.".to_string())
360 }
361 store::StoreError::Name(_) => {
362 AppError::BadRequest("Invalid group name.".to_string())
363 }
364 other => AppError::from(other),
365 })?;
366 if let Some(mut vis) = Visibility::from_token(&form.visibility) {
367 // A subgroup may not be more visible than its parent's ceiling.
368 if let Some(parent_id) = &renamed.parent_id {
369 let ceiling = state.store.group_visibility_ceiling(parent_id).await?;
370 if vis.rank() > ceiling.rank() {
371 vis = ceiling;
372 }
373 }
374 state.store.set_group_visibility(&renamed.id, vis).await?;
375 // Cascade the (possibly lowered) ceiling down to repos and subgroups.
376 state.store.clamp_subtree_visibility(&renamed).await?;
377 }
378 let _ = owner;
379 Ok::<String, AppError>(format!("/group-settings/{}", renamed.id))
380 }
381 .await;
382 match result {
383 Ok(dest) => Redirect::to(&dest).into_response(),
384 Err(err) => err.into_response(),
385 }
386}
387
388/// The add-collaborator form.
389#[derive(Debug, Deserialize)]
390pub struct CollaboratorAddForm {
391 /// The collaborator's username.
392 #[serde(default)]
393 username: String,
394 /// The permission to grant (`read` | `write` | `admin`).
395 #[serde(default)]
396 permission: String,
397 /// CSRF token.
398 #[serde(rename = "_csrf")]
399 csrf: String,
400}
401
402/// `POST /group-settings/{id}/collaborators` — add or update a group collaborator.
403pub async fn settings_collaborator_add(
404 State(state): State<AppState>,
405 MaybeUser(viewer): MaybeUser,
406 jar: CookieJar,
407 headers: HeaderMap,
408 Path(id): Path<String>,
409 Form(form): Form<CollaboratorAddForm>,
410) -> Response {
411 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
412 return AppError::Forbidden.into_response();
413 }
414 let result = async {
415 let group = require_admin_group(&state, viewer.as_ref(), &id).await?;
416 let permission = auth::Permission::parse(&form.permission)
417 .ok_or_else(|| AppError::BadRequest("Invalid permission.".to_string()))?;
418 let target = state
419 .store
420 .user_by_username(form.username.trim())
421 .await?
422 .ok_or_else(|| AppError::BadRequest("No such user.".to_string()))?;
423 // The owner is already an admin; adding them is a no-op.
424 if target.id != group.owner_id {
425 state
426 .store
427 .add_group_collaborator(&group.id, &target.id, permission.as_str())
428 .await?;
429 }
430 Ok::<String, AppError>(format!("/group-settings/{}", group.id))
431 }
432 .await;
433 match result {
434 Ok(dest) => Redirect::to(&dest).into_response(),
435 Err(err) => err.into_response(),
436 }
437}
438
439/// The remove-collaborator form.
440#[derive(Debug, Deserialize)]
441pub struct CollaboratorRemoveForm {
442 /// The collaborator's user id.
443 #[serde(default)]
444 user_id: String,
445 /// CSRF token.
446 #[serde(rename = "_csrf")]
447 csrf: String,
448}
449
450/// `POST /group-settings/{id}/collaborators/remove` — remove a group collaborator.
451pub async fn settings_collaborator_remove(
452 State(state): State<AppState>,
453 MaybeUser(viewer): MaybeUser,
454 jar: CookieJar,
455 headers: HeaderMap,
456 Path(id): Path<String>,
457 Form(form): Form<CollaboratorRemoveForm>,
458) -> Response {
459 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
460 return AppError::Forbidden.into_response();
461 }
462 let result = async {
463 let group = require_admin_group(&state, viewer.as_ref(), &id).await?;
464 state
465 .store
466 .remove_group_collaborator(&group.id, &form.user_id)
467 .await?;
468 Ok::<String, AppError>(format!("/group-settings/{}", group.id))
469 }
470 .await;
471 match result {
472 Ok(dest) => Redirect::to(&dest).into_response(),
473 Err(err) => err.into_response(),
474 }
475}
476
477/// The group delete form (CSRF only).
478#[derive(Debug, Deserialize)]
479pub struct GroupDeleteForm {
480 /// CSRF token.
481 #[serde(rename = "_csrf")]
482 csrf: String,
483}
484
485/// `POST /group-settings/{id}/delete` — delete an empty manual group.
486pub async fn settings_delete(
487 State(state): State<AppState>,
488 MaybeUser(viewer): MaybeUser,
489 jar: CookieJar,
490 headers: HeaderMap,
491 Path(id): Path<String>,
492 Form(form): Form<GroupDeleteForm>,
493) -> Response {
494 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
495 return AppError::Forbidden.into_response();
496 }
497 let result = async {
498 let group = require_admin_group(&state, viewer.as_ref(), &id).await?;
499 let owner = state
500 .store
501 .user_by_id(&group.owner_id)
502 .await?
503 .map_or_else(|| group.owner_id.clone(), |u| u.username);
504 // Guard against orphaning: refuse unless manual and the subtree is empty.
505 let subtree_repos = state.store.group_subtree_repo_count(&group).await?;
506 let all_groups = state.store.groups_by_owner(&group.owner_id).await?;
507 let child_prefix = format!("{}/", group.path);
508 let has_children = all_groups.iter().any(|g| g.path.starts_with(&child_prefix));
509 if !group.manual || subtree_repos != 0 || has_children {
510 return Err(AppError::BadRequest(
511 "This group cannot be deleted while it is not empty.".to_string(),
512 ));
513 }
514 state.store.delete_group(&group.id).await?;
515 // Deleting this manual group may leave its (auto) parent empty.
516 state.store.gc_empty_groups(group.parent_id.clone()).await?;
517 Ok::<String, AppError>(format!("/{owner}"))
518 }
519 .await;
520 match result {
521 Ok(dest) => Redirect::to(&dest).into_response(),
522 Err(err) => err.into_response(),
523 }
524}