// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. //! Groups: manual creation and the per-group settings page (rename, visibility, //! collaborators, delete). Auto-created groups are garbage-collected by the store //! when their last repository leaves; only manual groups reach the delete control. use axum::Form; use axum::extract::{Path, Query, State}; use axum::http::{HeaderMap, Uri}; use axum::response::{IntoResponse, Redirect, Response}; use axum_extra::extract::cookie::CookieJar; use maud::{Markup, html}; use model::{Group, User, Visibility}; use serde::Deserialize; use crate::AppState; use crate::error::{AppError, AppResult}; use crate::icons::{Icon, icon}; use crate::layout::page; use crate::pages::build_chrome; use crate::repo::path_crumbs; use crate::session::{MaybeUser, RequireUser, verify_csrf}; /// Resolve the access `viewer` has to `group`, mirroring the repo rules: admin and /// owner get [`auth::Access::Admin`]; a group collaborator (on the group or an /// ancestor) gets their stored permission; otherwise the group's visibility /// decides read access, and everything else is [`auth::Access::None`]. /// /// # Errors /// /// Returns [`AppError`] on a store failure. pub(crate) async fn group_access( state: &AppState, viewer: Option<&User>, group: &Group, ) -> AppResult { if let Some(v) = viewer && (v.is_admin || v.id == group.owner_id) { return Ok(auth::Access::Admin); } if let Some(v) = viewer && let Some(p) = state .store .effective_group_permission(&group.id, &v.id) .await? && let Some(perm) = auth::Permission::parse(&p) { return Ok(perm.into()); } let read = match group.visibility { Visibility::Public => viewer.is_some() || state.allow_anonymous(), Visibility::Internal => viewer.is_some(), Visibility::Private => false, }; Ok(if read { auth::Access::Read } else { auth::Access::None }) } /// Load a group by id and require the viewer be an admin of it, else 404 (never /// leak the group's existence). async fn require_admin_group( state: &AppState, viewer: Option<&User>, id: &str, ) -> AppResult { let group = state .store .group_by_id(id) .await? .ok_or(AppError::NotFound)?; if group_access(state, viewer, &group).await? == auth::Access::Admin { Ok(group) } else { Err(AppError::NotFound) } } // ---- Manual creation ---- /// `GET /new/group` — the manual group-creation form. An optional `?parent=path` /// prefills the path so "New subgroup" lands inside an existing group. pub async fn new_form( State(state): State, RequireUser(user): RequireUser, jar: CookieJar, uri: Uri, Query(q): Query, ) -> Response { let (jar, chrome) = build_chrome(&state, jar, Some(user), uri.path().to_string()); let prefill = q .parent .as_deref() .map(|p| format!("{}/", p.trim_matches('/'))) .unwrap_or_default(); let body = new_group_body(&chrome.csrf, None, &prefill); (jar, page(&chrome, "New group", body)).into_response() } /// Query for the new-group form. #[derive(Debug, Deserialize)] pub struct NewGroupQuery { /// Optional parent group path to prefill. parent: Option, } /// New-group form fields. #[derive(Debug, Deserialize)] pub struct NewGroupForm { /// The group path (may be nested with `/`). #[serde(default)] path: String, /// Visibility token (`public` | `internal` | `private`). #[serde(default)] visibility: String, /// CSRF token. #[serde(rename = "_csrf")] csrf: String, } /// `POST /new/group` — create a manual group and redirect to it. pub async fn new_submit( State(state): State, RequireUser(user): RequireUser, jar: CookieJar, headers: HeaderMap, Form(form): Form, ) -> Response { if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { return AppError::Forbidden.into_response(); } match create_group_from_form(&state, &user, &form).await { Ok(path) => Redirect::to(&format!("/{}/{path}", user.username)).into_response(), Err(msg) => { let (jar, chrome) = build_chrome(&state, jar, Some(user), "/new/group".to_string()); let body = new_group_body(&chrome.csrf, Some(&msg), form.path.trim()); ( axum::http::StatusCode::BAD_REQUEST, jar, page(&chrome, "New group", body), ) .into_response() } } } /// Create the group described by `form`, returning its path or a user-facing error. async fn create_group_from_form( state: &AppState, user: &User, form: &NewGroupForm, ) -> Result { let path = form.path.trim().trim_matches('/'); if path.is_empty() { return Err("Group path is required.".to_string()); } // A group may not collide with an existing repository at the same path. if state .store .repo_by_owner_path(&user.id, path) .await .map_err(|e| e.to_string())? .is_some() { return Err("A repository already exists at that path.".to_string()); } let group = state .store .create_group(&user.id, path) .await .map_err(|e| match e { store::StoreError::Conflict { .. } => "That group already exists.".to_string(), store::StoreError::Name(_) => { "Invalid name: use letters, digits, '-', '_', and '/' for nesting.".to_string() } other => other.to_string(), })?; if let Some(mut vis) = Visibility::from_token(&form.visibility) { // A subgroup may not be more visible than its parent's ceiling. if let Some(parent_id) = &group.parent_id { if let Ok(ceiling) = state.store.group_visibility_ceiling(parent_id).await && vis.rank() > ceiling.rank() { vis = ceiling; } } let _ = state.store.set_group_visibility(&group.id, vis).await; } Ok(group.path) } /// The new-group form body. fn new_group_body(csrf: &str, error: Option<&str>, prefill: &str) -> Markup { let vis_option = |value: Visibility, label: &str| { html! { option value=(value.as_str()) selected[value == Visibility::Private] { (label) } } }; html! { h1 { "New group" } @if let Some(msg) = error { p class="form-error" { (msg) } } div class="card" { form method="post" action="/new/group" { input type="hidden" name="_csrf" value=(csrf); label for="path" { "Group path" } input type="text" id="path" name="path" value=(prefill) placeholder="team (or team/subteam)" autofocus required; p class="muted field-hint" { "Nest with '/'. Intermediate groups are created automatically." } label for="visibility" { "Visibility" } select id="visibility" name="visibility" { (vis_option(Visibility::Public, "Public — visible to everyone")) (vis_option(Visibility::Internal, "Internal — any signed-in user")) (vis_option(Visibility::Private, "Private — only you and collaborators")) } div class="new-issue-actions" { button class="btn btn-primary inline-btn" type="submit" { "Create group" } } } } } } // ---- Settings page ---- /// `GET /group-settings/{id}` — the per-group settings page (admin only). pub async fn settings_view( State(state): State, MaybeUser(viewer): MaybeUser, jar: CookieJar, uri: Uri, Path(id): Path, ) -> Response { let result = async { let group = require_admin_group(&state, viewer.as_ref(), &id).await?; let owner = state .store .user_by_id(&group.owner_id) .await? .map_or_else(|| group.owner_id.clone(), |u| u.username); let collaborators = state.store.list_group_collaborators(&group.id).await?; // A manual group may be deleted only once its subtree holds no repos and no // child groups, so the delete never orphans anything. let subtree_repos = state.store.group_subtree_repo_count(&group).await?; let all_groups = state.store.groups_by_owner(&group.owner_id).await?; let child_prefix = format!("{}/", group.path); let has_children = all_groups.iter().any(|g| g.path.starts_with(&child_prefix)); let deletable = group.manual && subtree_repos == 0 && !has_children; let (jar, chrome) = build_chrome(&state, jar, viewer, uri.path().to_string()); let gid = &group.id; let vis = group.visibility; let vis_option = |value: Visibility, label: &str| { html! { option value=(value.as_str()) selected[vis == value] { (label) } } }; let crumbs = path_crumbs(&owner, &group.path); let body = html! { h1 class="repo-slug group-slug" { (icon(Icon::Folder)) a href={ "/" (owner) } { (owner) } @for (name, href) in &crumbs { span class="slug-sep" { "/" } a href=(href) { (name) } } } p class="muted" { "Group settings" } section class="listing" { h2 { "General" } div class="card" { form id="group-general" method="post" action=(format!("/group-settings/{gid}/general")) { input type="hidden" name="_csrf" value=(chrome.csrf); label for="name" { "Group name" } input type="text" id="name" name="name" value=(group.name) required; label for="visibility" { "Visibility" } select id="visibility" name="visibility" { (vis_option(Visibility::Public, "Public — visible to everyone")) (vis_option(Visibility::Internal, "Internal — any signed-in user")) (vis_option(Visibility::Private, "Private — only you and collaborators")) } } div class="settings-actions" { button class="btn btn-primary" type="submit" form="group-general" { "Save changes" } } } } section class="listing" { h2 { "Collaborators" } p class="muted field-hint" { "Group collaborators gain the same access to every repository within this group." } (crate::repo::collaborators_card(&format!("/group-settings/{gid}"), &chrome.csrf, &collaborators)) } section class="listing" { h2 { "Danger zone" } div class="card danger-zone" { @if deletable { p { strong { "Delete this group." } " This removes the group; it holds no repositories or subgroups." } form method="post" action=(format!("/group-settings/{gid}/delete")) { input type="hidden" name="_csrf" value=(chrome.csrf); button class="btn btn-danger" type="submit" { "Delete group" } } } @else if !group.manual { p class="muted" { "Automatic groups are removed on their own once empty." } } @else { p class="muted" { "Empty the group of all repositories and subgroups before deleting it." } } } } }; let title = format!("{owner}/{}: settings", group.path); Ok::((jar, page(&chrome, &title, body)).into_response()) } .await; result.unwrap_or_else(IntoResponse::into_response) } /// The group general-settings form (rename + visibility). #[derive(Debug, Deserialize)] pub struct GroupGeneralForm { /// The new leaf name (the parent prefix is preserved). #[serde(default)] name: String, /// The chosen visibility token. #[serde(default)] visibility: String, /// CSRF token. #[serde(rename = "_csrf")] csrf: String, } /// `POST /group-settings/{id}/general` — rename and set visibility. pub async fn settings_general( State(state): State, MaybeUser(viewer): MaybeUser, jar: CookieJar, headers: HeaderMap, Path(id): Path, Form(form): Form, ) -> Response { if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { return AppError::Forbidden.into_response(); } let result = async { let group = require_admin_group(&state, viewer.as_ref(), &id).await?; let owner = state .store .user_by_id(&group.owner_id) .await? .map_or_else(|| group.owner_id.clone(), |u| u.username); let new_name = form.name.trim(); let renamed = state .store .rename_group(&group.id, new_name) .await .map_err(|e| match e { store::StoreError::Conflict { .. } => { AppError::BadRequest("That name is already taken.".to_string()) } store::StoreError::Name(_) => { AppError::BadRequest("Invalid group name.".to_string()) } other => AppError::from(other), })?; if let Some(mut vis) = Visibility::from_token(&form.visibility) { // A subgroup may not be more visible than its parent's ceiling. if let Some(parent_id) = &renamed.parent_id { let ceiling = state.store.group_visibility_ceiling(parent_id).await?; if vis.rank() > ceiling.rank() { vis = ceiling; } } state.store.set_group_visibility(&renamed.id, vis).await?; // Cascade the (possibly lowered) ceiling down to repos and subgroups. state.store.clamp_subtree_visibility(&renamed).await?; } let _ = owner; Ok::(format!("/group-settings/{}", renamed.id)) } .await; match result { Ok(dest) => Redirect::to(&dest).into_response(), Err(err) => err.into_response(), } } /// The add-collaborator form. #[derive(Debug, Deserialize)] pub struct CollaboratorAddForm { /// The collaborator's username. #[serde(default)] username: String, /// The permission to grant (`read` | `write` | `admin`). #[serde(default)] permission: String, /// CSRF token. #[serde(rename = "_csrf")] csrf: String, } /// `POST /group-settings/{id}/collaborators` — add or update a group collaborator. pub async fn settings_collaborator_add( State(state): State, MaybeUser(viewer): MaybeUser, jar: CookieJar, headers: HeaderMap, Path(id): Path, Form(form): Form, ) -> Response { if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { return AppError::Forbidden.into_response(); } let result = async { let group = require_admin_group(&state, viewer.as_ref(), &id).await?; let permission = auth::Permission::parse(&form.permission) .ok_or_else(|| AppError::BadRequest("Invalid permission.".to_string()))?; let target = state .store .user_by_username(form.username.trim()) .await? .ok_or_else(|| AppError::BadRequest("No such user.".to_string()))?; // The owner is already an admin; adding them is a no-op. if target.id != group.owner_id { state .store .add_group_collaborator(&group.id, &target.id, permission.as_str()) .await?; } Ok::(format!("/group-settings/{}", group.id)) } .await; match result { Ok(dest) => Redirect::to(&dest).into_response(), Err(err) => err.into_response(), } } /// The remove-collaborator form. #[derive(Debug, Deserialize)] pub struct CollaboratorRemoveForm { /// The collaborator's user id. #[serde(default)] user_id: String, /// CSRF token. #[serde(rename = "_csrf")] csrf: String, } /// `POST /group-settings/{id}/collaborators/remove` — remove a group collaborator. pub async fn settings_collaborator_remove( State(state): State, MaybeUser(viewer): MaybeUser, jar: CookieJar, headers: HeaderMap, Path(id): Path, Form(form): Form, ) -> Response { if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { return AppError::Forbidden.into_response(); } let result = async { let group = require_admin_group(&state, viewer.as_ref(), &id).await?; state .store .remove_group_collaborator(&group.id, &form.user_id) .await?; Ok::(format!("/group-settings/{}", group.id)) } .await; match result { Ok(dest) => Redirect::to(&dest).into_response(), Err(err) => err.into_response(), } } /// The group delete form (CSRF only). #[derive(Debug, Deserialize)] pub struct GroupDeleteForm { /// CSRF token. #[serde(rename = "_csrf")] csrf: String, } /// `POST /group-settings/{id}/delete` — delete an empty manual group. pub async fn settings_delete( State(state): State, MaybeUser(viewer): MaybeUser, jar: CookieJar, headers: HeaderMap, Path(id): Path, Form(form): Form, ) -> Response { if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { return AppError::Forbidden.into_response(); } let result = async { let group = require_admin_group(&state, viewer.as_ref(), &id).await?; let owner = state .store .user_by_id(&group.owner_id) .await? .map_or_else(|| group.owner_id.clone(), |u| u.username); // Guard against orphaning: refuse unless manual and the subtree is empty. let subtree_repos = state.store.group_subtree_repo_count(&group).await?; let all_groups = state.store.groups_by_owner(&group.owner_id).await?; let child_prefix = format!("{}/", group.path); let has_children = all_groups.iter().any(|g| g.path.starts_with(&child_prefix)); if !group.manual || subtree_repos != 0 || has_children { return Err(AppError::BadRequest( "This group cannot be deleted while it is not empty.".to_string(), )); } state.store.delete_group(&group.id).await?; // Deleting this manual group may leave its (auto) parent empty. state.store.gc_empty_groups(group.parent_id.clone()).await?; Ok::(format!("/{owner}")) } .await; match result { Ok(dest) => Redirect::to(&dest).into_response(), Err(err) => err.into_response(), } }