//! Page: repo settings (`/{owner}/{repo}/settings`). The owner, an instance admin and collaborators //! with the Admin role get the page. It holds the General section (description, default branch, //! visibility, archive), the branch protection rules (Admin) and the danger zone (Delete: owner or //! instance admin only). Deleting moves the bare repo into `data_dir/trash` and removes its database //! rows in one transaction. The repo is never erased from disk, so an admin can recover it from the //! trash. use std::path::Path; use forge_core::protection::{ BranchRule, RuleFlags, pattern_matches, validate_pattern, validate_required_approvals, }; use forge_core::store::MAX_DESCRIPTION_CHARS; use forge_core::{AuditAction, Repo as RepoRow, Visibility}; use forge_git::browse::RefKind; use forge_git::command::{GitCommand, GitEnv}; use forge_git::repo::{ move_to_trash, move_wiki_to_trash, repo_dir_missing, restore_from_trash, restore_wiki_from_trash, }; use serde::Deserialize; use topcoat::Result; use topcoat::context::{Cx, app_context}; use topcoat::cookie::{Cookie, Cookies, SameSite, cookies}; use topcoat::router::content::Form; use topcoat::router::error::{SeeOther, forbidden, see_other}; use topcoat::router::{page, path_param, route}; use topcoat::view::{View, component, view}; use crate::audit::{self, Actor, Target}; use crate::auth::{require_csrf, require_user}; use crate::i18n::repo_admin::{self as g, K}; use crate::i18n::{self, Lang}; use crate::pages::discussion_settings::discussions_card; use crate::pages::refs::load_refs; use crate::pages::repo::repo_head; use crate::pages::repo_ctx::{RepoCtx, RepoHead, load_repo}; use crate::state::AppState; use crate::views::{alert, shell}; path_param!(owner); path_param!(repo); /// Cookie that carries a one-shot notice (the deleted repo's `owner/name`) to the next home page. pub const NOTICE_COOKIE: &str = "forge_notice"; /// Cookie that carries a one-shot settings notice to the next settings page. Its value is one of /// the fixed words in `SETTINGS_NOTICES`, never user text. const SETTINGS_NOTICE_COOKIE: &str = "forge_settings_notice"; /// Notice words, the text each one shows, and whether it is an error (shown as an alert). const SETTINGS_NOTICES: [(&str, K, bool); 11] = [ ("saved", K::BranchRuleSaved, false), ("removed", K::BranchRuleRemoved, false), ("general", K::GeneralSaved, false), ("visibility", K::VisibilityChanged, false), ("archived", K::RepoArchived, false), ("unarchived", K::RepoUnarchived, false), ("description_long", K::DescriptionLong, true), ("branch_missing", K::BranchMissing, true), ("git_failed", K::DefaultBranchFailed, true), ("understand", K::UnderstandNeeded, true), ("bad_form", K::BadForm, true), ]; /// Longest accepted `owner/name` in the notice cookie. const MAX_NOTICE_CHARS: usize = 256; #[derive(Deserialize)] struct DeleteForm { #[serde(default)] csrf: String, #[serde(default)] confirm: String, } /// The General form: description and default branch, saved together. #[derive(Deserialize)] struct GeneralForm { #[serde(default)] csrf: String, #[serde(default)] description: String, /// Empty when the repo has no branches yet; the current default branch is then kept. #[serde(default)] default_branch: String, } /// The visibility form. An unchecked "I understand" box is absent from the body. #[derive(Deserialize)] struct VisibilityForm { #[serde(default)] csrf: String, #[serde(default)] visibility: String, #[serde(default)] understand: Option, } #[derive(Deserialize)] struct ArchiveForm { #[serde(default)] csrf: String, /// `1` archives the repo, `0` unarchives it. #[serde(default)] archived: String, } /// The add/update rule form. An unchecked box is absent from the body, so `Option` is `None`. #[derive(Deserialize)] struct BranchForm { #[serde(default)] csrf: String, #[serde(default)] pattern: String, #[serde(default)] allow_force_push: Option, #[serde(default)] allow_deletion: Option, /// Text from the number field; blank means 0. #[serde(default)] required_approvals: String, #[serde(default)] block_on_changes_requested: Option, #[serde(default)] require_up_to_date: Option, #[serde(default)] restrict_push: Option, } #[derive(Deserialize)] struct BranchDeleteForm { #[serde(default)] csrf: String, #[serde(default)] rule_id: String, } /// Sets the notice shown on the next home page view. pub fn set_notice(cx: &Cx, title: &str) { cookies(cx).add( Cookie::build((NOTICE_COOKIE, title.to_owned())) .path("/") .http_only(true) .same_site(SameSite::Lax) .build(), ); } /// Reads and clears the notice cookie. Anything that is not a plain `owner/name` is ignored. pub fn take_notice(cx: &Cx) -> Option { let jar = cookies(cx); let value = jar.get(NOTICE_COOKIE)?.value().to_owned(); jar.remove(Cookie::build((NOTICE_COOKIE, "")).path("/")); let plain = !value.is_empty() && value.chars().count() <= MAX_NOTICE_CHARS && value .chars() .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '/')); plain.then_some(value) } /// Sets the one-line notice shown on the next settings page view. fn set_settings_notice(cx: &Cx, word: &'static str) { cookies(cx).add( Cookie::build((SETTINGS_NOTICE_COOKIE, word)) .path("/") .http_only(true) .same_site(SameSite::Lax) .build(), ); } /// Reads and clears the settings notice cookie. Only the fixed words are shown. The flag says /// whether the notice is an error. fn take_settings_notice(cx: &Cx) -> Option<(K, bool)> { let jar = cookies(cx); let value = jar.get(SETTINGS_NOTICE_COOKIE)?.value().to_owned(); jar.remove(Cookie::build((SETTINGS_NOTICE_COOKIE, "")).path("/")); SETTINGS_NOTICES .iter() .find(|(word, _, _)| *word == value) .map(|(_, key, is_error)| (*key, *is_error)) } /// Redirect back to the General section, which shows the notice set just before. fn back_to_settings(title: &str) -> SeeOther { see_other(format!("/{title}/settings")) } /// Drops control characters (so the text is one line) and surrounding spaces. fn clean_description(raw: &str) -> String { raw.chars() .filter(|c| !c.is_control()) .collect::() .trim() .to_owned() } /// Points git HEAD of the bare repo at `branch`. The caller has checked that the branch exists. /// The `refs/heads/` prefix means the value can never be read as an option. async fn point_head(env: &GitEnv, repo_path: &Path, branch: &str) -> forge_git::Result<()> { GitCommand::new(env, "symbolic-ref") .git_dir(repo_path) .arg("HEAD") .user_arg(&format!("refs/heads/{branch}"))? .status() .await } /// Branch names of the repo. Empty for an empty repo. async fn load_branch_names(ctx: &RepoCtx) -> Result> { let refs = load_refs(ctx.path.clone()).await?; Ok(refs .into_iter() .filter(|r| r.kind == RefKind::Branch) .map(|r| r.name) .collect()) } /// The one-line notice on the home page after a delete. #[component] pub async fn notice_banner_for(lang: Lang, title: Option) -> Result { Ok(view! { if let Some(title) = title {
(g::t(lang, K::RepoDeletedBefore))(title)(g::t(lang, K::RepoDeletedAfter))
} }) } /// The danger zone: explains what deleting does and holds the confirmation form. #[component] async fn danger_zone( lang: Lang, csrf: String, title: String, error: Option, ) -> Result { let action = format!("/{title}/settings/delete"); Ok(view! {

(g::t(lang, K::DangerZone))

(g::t(lang, K::DeleteRepoHelp))

(g::t(lang, K::DeleteTrashHelp))

if let Some(message) = error { alert(message: message.as_str()) }
}) } /// Sub-navigation for the settings pages. `current` names the page shown (`general` or `webhooks`) /// and gets `aria-current`. #[component] pub async fn settings_nav_for( lang: Lang, title: String, current: &'static str, ) -> Result { let general = format!("/{title}/settings"); let collaborators = format!("/{title}/settings/collaborators"); let rename = format!("/{title}/settings/rename"); let hooks = format!("/{title}/settings/hooks"); let mirrors = format!("/{title}/settings/mirrors"); let secrets = format!("/{title}/settings/secrets"); let general_on = current == "general"; let hooks_on = current == "webhooks"; let mirrors_on = current == "mirrors"; let secrets_on = current == "secrets"; Ok(view! { }) } /// The General section: description and default branch (one form), then visibility and archive. /// Each form carries its own CSRF token. An empty repo shows "No branches yet" and no selector. #[component] async fn general_section( lang: Lang, title: String, csrf: String, description: String, default_branch: String, branches: Vec, visibility: Visibility, archived: bool, ) -> Result { let general_action = format!("/{title}/settings/general"); let visibility_action = format!("/{title}/settings/visibility"); let archive_action = format!("/{title}/settings/archive"); let is_public = visibility == Visibility::Public; let private_checked = !is_public; let archive_value = if archived { "0" } else { "1" }; let archive_label = if archived { g::t(lang, K::UnarchiveRepo) } else { g::t(lang, K::ArchiveRepo) }; let options: Vec<(String, bool)> = branches .into_iter() .map(|b| { let current = b == default_branch; (b, current) }) .collect(); Ok(view! {

(g::t(lang, K::General))

(g::t(lang, K::DescriptionHelp))

if options.is_empty() {

(g::t(lang, K::NoBranchesYet))

} else { }

(g::t(lang, K::VisibilityHeading))

(g::t(lang, K::VisibilityHelp))

(g::t(lang, K::ArchiveHeading))

if archived {

(g::t(lang, K::ArchivedHelp))

} else {

(g::t(lang, K::ArchiveHelp))

}
}) } /// Reads the rule settings from the form. Any problem is the message to show next to the form. /// The message is for a `required_approvals` value outside 0..=20 or not a whole number. fn parse_rule_flags(lang: Lang, form: &BranchForm) -> std::result::Result { let approvals_msg = || g::t(lang, K::ApprovalsRange).to_owned(); let raw = form.required_approvals.trim(); let required_approvals = if raw.is_empty() { 0 } else { raw.parse::().map_err(|_| approvals_msg())? }; validate_required_approvals(required_approvals).map_err(|_| approvals_msg())?; Ok(RuleFlags { allow_force_push: form.allow_force_push.is_some(), allow_deletion: form.allow_deletion.is_some(), required_approvals, block_on_changes_requested: form.block_on_changes_requested.is_some(), require_up_to_date: form.require_up_to_date.is_some(), restrict_push: form.restrict_push.is_some(), }) } /// A short list of the merge and push rules a rule sets, or "none". fn rule_summary(lang: Lang, rule: &BranchRule) -> String { let mut parts = Vec::new(); if rule.required_approvals > 0 { parts.push(g::approvals_count(lang, rule.required_approvals)); } if rule.block_on_changes_requested { parts.push(g::t(lang, K::RuleChangesBlock).to_owned()); } if rule.require_up_to_date { parts.push(g::t(lang, K::RuleUpToDate).to_owned()); } if rule.restrict_push { parts.push(g::t(lang, K::RuleRestrictPush).to_owned()); } if parts.is_empty() { g::t(lang, K::NoRule).to_owned() } else { parts.join("; ") } } /// The rules table. A muted built-in row stands in for the default branch when no rule matches it. #[component] async fn branch_rules_table( lang: Lang, rules: Vec, default_branch: String, builtin_row: bool, delete_action: String, csrf: String, ) -> Result { Ok(view! {
for rule in rules.iter() { } if builtin_row { }
(g::t(lang, K::PatternColumn)) (g::t(lang, K::ForcePushAllowed)) (g::t(lang, K::DeletionAllowed)) (g::t(lang, K::MergePushRules)) (g::t(lang, K::Remove))
(rule.pattern.as_str()) (g::yes_no(lang, rule.allow_force_push)) (g::yes_no(lang, rule.allow_deletion)) (rule_summary(lang, rule))
(default_branch.as_str())(g::t(lang, K::BuiltinRowText))
}) } /// The add/update form. Saving an existing pattern updates its rule. #[component] async fn branch_rule_form( lang: Lang, csrf: String, save_action: String, pattern: String, allow_force_push: bool, allow_deletion: bool, required_approvals: String, block_on_changes_requested: bool, require_up_to_date: bool, restrict_push: bool, error: Option, ) -> Result { Ok(view! {
if let Some(message) = error { alert(message: message.as_str()) }

(g::t(lang, K::PatternHelpA))"*"(g::t(lang, K::PatternHelpB))"**"(g::t(lang, K::PatternHelpC))"/"(g::t(lang, K::PatternHelpD))

(g::t(lang, K::ApprovalsHelp))

}) } /// The form values to show: the pattern and checkboxes, plus any validation error. struct RuleDraft { pattern: String, allow_force_push: bool, allow_deletion: bool, required_approvals: String, block_on_changes_requested: bool, require_up_to_date: bool, restrict_push: bool, error: Option, } impl RuleDraft { /// The default values for the add form, with the default branch as the pattern. fn for_default_branch(default_branch: &str) -> Self { Self { pattern: default_branch.to_owned(), allow_force_push: false, allow_deletion: false, required_approvals: "0".to_owned(), block_on_changes_requested: false, require_up_to_date: false, restrict_push: false, error: None, } } } /// Everything the settings page shows besides the repo header. struct SettingsView { rules: Vec, /// A settings notice: its text and whether it is an error. notice: Option<(K, bool)>, draft: RuleDraft, delete_error: Option, } /// The settings body: the repo header tabs with Settings active, the sub-navigation, the General /// section, the branch protection section, then the danger zone when the viewer may delete the repo. #[component] async fn settings_body( lang: Lang, head: RepoHead, default_branch: String, archived: bool, branches: Vec, title: String, csrf: String, can_delete: bool, page: SettingsView, ) -> Result { let builtin_row = !page .rules .iter() .any(|rule| pattern_matches(&rule.pattern, &default_branch)); let rules_action = format!("/{title}/settings/branches"); let delete_action = format!("/{title}/settings/branches/delete"); let SettingsView { rules, notice, draft, delete_error, } = page; let (description, visibility) = (head.description.clone(), head.visibility); let discussions_on = head.discussions; let notice = notice.map(|(key, is_error)| (g::t(lang, key), is_error)); Ok(view! { repo_head(head: head) settings_nav_for(lang: lang, title: title.clone(), current: "general") if let Some((text, is_error)) = notice { if is_error { alert(message: text) } else {
(text)
} } general_section(lang: lang, title: title.clone(), csrf: csrf.clone(), description: description, default_branch: default_branch.clone(), branches: branches, visibility: visibility, archived: archived)

(g::t(lang, K::BranchProtection))

(g::t(lang, K::BranchProtectionHelp))

branch_rules_table(lang: lang, rules: rules, default_branch: default_branch.clone(), builtin_row: builtin_row, delete_action: delete_action, csrf: csrf.clone())

(g::t(lang, K::AddOrUpdateRule))

branch_rule_form(lang: lang, csrf: csrf.clone(), save_action: rules_action, pattern: draft.pattern, allow_force_push: draft.allow_force_push, allow_deletion: draft.allow_deletion, required_approvals: draft.required_approvals, block_on_changes_requested: draft.block_on_changes_requested, require_up_to_date: draft.require_up_to_date, restrict_push: draft.restrict_push, error: draft.error)
discussions_card(lang: lang, action: format!("/{title}/settings/discussions"), csrf: csrf.clone(), enabled: discussions_on) if can_delete { danger_zone(lang: lang, csrf: csrf, title: title, error: delete_error) } }) } /// The repo fields the settings page shows, read once from the repo context. struct SettingsFrame { page_title: String, default_branch: String, archived: bool, title: String, can_delete: bool, head: RepoHead, } impl SettingsFrame { async fn new(ctx: &RepoCtx, state: &AppState, lang: Lang) -> Result { let title = format!("{}/{}", ctx.repo.owner_name, ctx.repo.name); Ok(Self { page_title: g::settings_page_title(lang, &title), default_branch: ctx.repo.default_branch.clone(), archived: ctx.repo.archived, title, can_delete: ctx.can_delete(), head: ctx.head(state, "settings").await?, }) } } /// Gets the settings page. Hidden repos are 404 (checked in `load_repo`), anonymous viewers of /// visible repos go to login, and signed-in viewers who may not manage the repo get 403. #[page("/{owner}/{repo}/settings")] async fn settings_page(cx: &Cx) -> Result { let owner = path_param::(cx); let name = path_param::(cx); let ctx = load_repo(cx, owner, name).await?; let (user, session) = require_user(cx).await?; if !ctx.can_admin() { return Err(forbidden().into()); } let lang = i18n::lang(cx, Some(&user)); let rules = app_context::(cx) .store .list_branch_rules(&ctx.repo.id) .await?; let page = SettingsView { rules, notice: take_settings_notice(cx), draft: RuleDraft::for_default_branch(&ctx.repo.default_branch), delete_error: None, }; let csrf = session.csrf_token.clone(); let frame = SettingsFrame::new(&ctx, app_context::(cx), lang).await?; let branches = load_branch_names(&ctx).await?; Ok(view! { shell(title: frame.page_title.as_str(), viewer: ctx.viewer.as_ref(), settings_body(lang: lang, head: frame.head, default_branch: frame.default_branch, archived: frame.archived, branches: branches, title: frame.title, csrf: csrf, can_delete: frame.can_delete, page: page)) }) } /// Saves the description and the default branch together. The form is checked for CSRF and /// `Action::Admin` first. The branch must be an existing branch: git HEAD is pointed at it first, /// and the database changes only when that succeeds. A failed database write puts HEAD back. #[route(POST "/{owner}/{repo}/settings/general")] async fn settings_general_save(cx: &Cx, Form(form): Form) -> Result { let owner = path_param::(cx); let name = path_param::(cx); let ctx = load_repo(cx, owner, name).await?; let (user, _) = require_csrf(cx, &form.csrf).await?; if !ctx.can_admin() { return Err(forbidden().into()); } let title = format!("{}/{}", ctx.repo.owner_name, ctx.repo.name); let state = app_context::(cx); let description = clean_description(&form.description); if description.chars().count() > MAX_DESCRIPTION_CHARS { set_settings_notice(cx, "description_long"); return Ok(back_to_settings(&title)); } let requested = form.default_branch.trim(); let mut default_branch = ctx.repo.default_branch.clone(); if !requested.is_empty() { let branches = load_branch_names(&ctx).await?; if !branches.iter().any(|b| b == requested) { set_settings_notice(cx, "branch_missing"); return Ok(back_to_settings(&title)); } if let Err(e) = point_head(&state.git, &ctx.path, requested).await { tracing::warn!( repo_id = %ctx.repo.id, branch = requested, error = %e, "symbolic-ref failed; default branch unchanged" ); set_settings_notice(cx, "git_failed"); return Ok(back_to_settings(&title)); } default_branch = requested.to_owned(); } if let Err(e) = state .store .update_repo_general(&ctx.repo.id, &description, &default_branch) .await { if default_branch != ctx.repo.default_branch && let Err(revert) = point_head(&state.git, &ctx.path, &ctx.repo.default_branch).await { tracing::error!( repo_id = %ctx.repo.id, error = %revert, "could not restore git HEAD after a failed save; check HEAD by hand" ); } return Err(e.into()); } tracing::info!( actor_id = %user.id, repo_id = %ctx.repo.id, repo = %title, default_branch = %default_branch, "general settings saved" ); set_settings_notice(cx, "general"); Ok(back_to_settings(&title)) } /// Changes the visibility. Changing it needs the "I understand" box. Picking the current value /// changes nothing. #[route(POST "/{owner}/{repo}/settings/visibility")] async fn settings_visibility_save(cx: &Cx, Form(form): Form) -> Result { let owner = path_param::(cx); let name = path_param::(cx); let ctx = load_repo(cx, owner, name).await?; let (user, _) = require_csrf(cx, &form.csrf).await?; if !ctx.can_admin() { return Err(forbidden().into()); } let title = format!("{}/{}", ctx.repo.owner_name, ctx.repo.name); let Ok(target) = form.visibility.parse::() else { set_settings_notice(cx, "bad_form"); return Ok(back_to_settings(&title)); }; if target != ctx.repo.visibility { if form.understand.is_none() { set_settings_notice(cx, "understand"); return Ok(back_to_settings(&title)); } let state = app_context::(cx); state .store .set_repo_visibility(&ctx.repo.id, target) .await?; tracing::info!( actor_id = %user.id, repo_id = %ctx.repo.id, repo = %title, visibility = target.as_str(), "repo visibility changed" ); audit::record( &state.store, Some(cx), Actor::User(&user), AuditAction::RepoVisibilityChanged, Target { kind: "repo", id: &ctx.repo.id, name: &title, }, serde_json::json!({ "to": target.as_str() }), ) .await; set_settings_notice(cx, "visibility"); } Ok(back_to_settings(&title)) } /// Archives (`archived=1`) or unarchives (`archived=0`) the repo. Archiving is an Admin action, and /// the archived flag does not block Admin. #[route(POST "/{owner}/{repo}/settings/archive")] async fn settings_archive_save(cx: &Cx, Form(form): Form) -> Result { let owner = path_param::(cx); let name = path_param::(cx); let ctx = load_repo(cx, owner, name).await?; let (user, _) = require_csrf(cx, &form.csrf).await?; if !ctx.can_admin() { return Err(forbidden().into()); } let title = format!("{}/{}", ctx.repo.owner_name, ctx.repo.name); let archived = match form.archived.as_str() { "1" => true, "0" => false, _ => { set_settings_notice(cx, "bad_form"); return Ok(back_to_settings(&title)); } }; let state = app_context::(cx); state .store .set_repo_archived(&ctx.repo.id, archived) .await?; let action = if archived { AuditAction::RepoArchived } else { AuditAction::RepoUnarchived }; audit::record( &state.store, Some(cx), Actor::User(&user), action, Target { kind: "repo", id: &ctx.repo.id, name: &title, }, serde_json::Value::Null, ) .await; tracing::info!( actor_id = %user.id, repo_id = %ctx.repo.id, repo = %title, archived, "repo archive flag changed" ); set_settings_notice(cx, if archived { "archived" } else { "unarchived" }); Ok(back_to_settings(&title)) } /// Deletes the repo when the viewer may delete it, the CSRF token is valid and the confirmation /// is exactly `owner/name`. On success the repo is moved to trash, its rows are deleted, and the /// viewer is redirected home with a notice. If the database delete fails the directory goes back. /// A wrong confirmation re-renders the page with an error and changes nothing. #[page(POST "/{owner}/{repo}/settings/delete")] async fn settings_delete(cx: &Cx, Form(form): Form) -> Result { let owner = path_param::(cx); let name = path_param::(cx); let ctx = load_repo(cx, owner, name).await?; let (user, session) = require_csrf(cx, &form.csrf).await?; if !ctx.can_delete() { return Err(forbidden().into()); } let lang = i18n::lang(cx, Some(&user)); let title = format!("{}/{}", ctx.repo.owner_name, ctx.repo.name); let error = if form.confirm.trim() == title { None } else { Some(g::confirm_delete_mismatch(lang, &title)) }; if error.is_none() { let state = app_context::(cx); let repo: &RepoRow = &ctx.repo; let data_dir = &state.config.data_dir; // A missing directory (already removed by hand) has nothing to trash, so only the rows go. // Any other failure to inspect or move the directory aborts before the database is touched. let trash_path = if repo_dir_missing(data_dir, &repo.id).map_err(forge_core::Error::from)? { tracing::warn!(repo_id = %repo.id, "repo dir missing, deleting DB rows only"); None } else { Some( move_to_trash(data_dir, &repo.id) .await .map_err(forge_core::Error::from)?, ) }; // The wiki goes to the trash with the repo. A failure here puts the repo back first. let wiki_trash = match move_wiki_to_trash(data_dir, &repo.id).await { Ok(path) => path, Err(e) => { if let Some(trash_path) = &trash_path && let Err(restore_err) = restore_from_trash(data_dir, trash_path, &repo.id).await { tracing::error!(error = %restore_err, repo_id = %repo.id, "could not restore the repo after a failed wiki move"); } return Err(forge_core::Error::from(e).into()); } }; // Release assets: their rows go with the repo, so their blob ids are read first. let release_assets = state.store.repo_release_asset_ids(&repo.id).await?; if let Err(e) = state.store.delete_repo(&repo.id).await { if let Some(trash_path) = &trash_path && let Err(restore_err) = restore_from_trash(data_dir, trash_path, &repo.id).await { tracing::error!( repo_id = %repo.id, trash_path = %trash_path.display(), error = %restore_err, "failed to restore repo directory after a failed delete; move it back by hand" ); } if let Some(wiki_path) = &wiki_trash && let Err(restore_err) = restore_wiki_from_trash(data_dir, wiki_path, &repo.id).await { tracing::error!( repo_id = %repo.id, trash_path = %wiki_path.display(), error = %restore_err, "failed to restore wiki after a failed delete; move it back by hand" ); } return Err(e.into()); } for asset_id in &release_assets { if let Err(e) = state.blobs.delete(&crate::blobs::asset_key(asset_id)).await { tracing::error!(error = %e, repo_id = %repo.id, "releases: could not delete an asset blob"); } } tracing::info!( actor_id = %user.id, actor = %user.username, repo_id = %repo.id, repo = %title, trash_path = ?trash_path, "repository deleted" ); audit::record( &state.store, Some(cx), Actor::User(&user), AuditAction::RepoDeleted, Target { kind: "repo", id: &repo.id, name: &title, }, serde_json::Value::Null, ) .await; set_notice(cx, &title); return Err(see_other("/").into()); } let rules = app_context::(cx) .store .list_branch_rules(&ctx.repo.id) .await?; let page = SettingsView { rules, notice: None, draft: RuleDraft::for_default_branch(&ctx.repo.default_branch), delete_error: error, }; let csrf = session.csrf_token.clone(); let frame = SettingsFrame::new(&ctx, app_context::(cx), lang).await?; let branches = load_branch_names(&ctx).await?; Ok(view! { shell(title: frame.page_title.as_str(), viewer: ctx.viewer.as_ref(), settings_body(lang: lang, head: frame.head, default_branch: frame.default_branch, archived: frame.archived, branches: branches, title: frame.title, csrf: csrf, can_delete: frame.can_delete, page: page)) }) } /// Adds a branch rule or updates the one with the same pattern. The form is checked for CSRF and /// `Action::Admin` first. An invalid pattern re-renders the page with the error next to the form. #[page(POST "/{owner}/{repo}/settings/branches")] async fn settings_branch_save(cx: &Cx, Form(form): Form) -> Result { let owner = path_param::(cx); let name = path_param::(cx); let ctx = load_repo(cx, owner, name).await?; let (user, session) = require_csrf(cx, &form.csrf).await?; if !ctx.can_admin() { return Err(forbidden().into()); } let lang = i18n::lang(cx, Some(&user)); let pattern = form.pattern.trim().to_owned(); let title = format!("{}/{}", ctx.repo.owner_name, ctx.repo.name); let flags = match validate_pattern(&pattern) .map_err(|e| e.to_string()) .and_then(|()| parse_rule_flags(lang, &form)) { Ok(flags) => flags, Err(message) => { let rules = app_context::(cx) .store .list_branch_rules(&ctx.repo.id) .await?; let page = SettingsView { rules, notice: None, draft: RuleDraft { pattern, allow_force_push: form.allow_force_push.is_some(), allow_deletion: form.allow_deletion.is_some(), required_approvals: form.required_approvals.clone(), block_on_changes_requested: form.block_on_changes_requested.is_some(), require_up_to_date: form.require_up_to_date.is_some(), restrict_push: form.restrict_push.is_some(), error: Some(message), }, delete_error: None, }; let csrf = session.csrf_token.clone(); let frame = SettingsFrame::new(&ctx, app_context::(cx), lang).await?; let branches = load_branch_names(&ctx).await?; return Ok(view! { shell(title: frame.page_title.as_str(), viewer: ctx.viewer.as_ref(), settings_body(lang: lang, head: frame.head, default_branch: frame.default_branch, archived: frame.archived, branches: branches, title: frame.title, csrf: csrf, can_delete: frame.can_delete, page: page)) }); } }; let state = app_context::(cx); state .store .upsert_branch_rule(&ctx.repo.id, &pattern, flags) .await?; tracing::info!( actor_id = %user.id, repo_id = %ctx.repo.id, repo = %title, pattern = %pattern, allow_force_push = flags.allow_force_push, allow_deletion = flags.allow_deletion, required_approvals = flags.required_approvals, block_on_changes_requested = flags.block_on_changes_requested, require_up_to_date = flags.require_up_to_date, restrict_push = flags.restrict_push, "branch rule saved" ); set_settings_notice(cx, "saved"); Err(see_other(format!("/{title}/settings")).into()) } /// Removes a branch rule by id, scoped to this repo. Same checks as the save form. #[route(POST "/{owner}/{repo}/settings/branches/delete")] async fn settings_branch_delete(cx: &Cx, Form(form): Form) -> Result { let owner = path_param::(cx); let name = path_param::(cx); let ctx = load_repo(cx, owner, name).await?; let (user, _) = require_csrf(cx, &form.csrf).await?; if !ctx.can_admin() { return Err(forbidden().into()); } let state = app_context::(cx); state .store .delete_branch_rule(&ctx.repo.id, &form.rule_id) .await?; audit::record( &state.store, Some(cx), Actor::User(&user), AuditAction::BranchRuleDeleted, Target { kind: "branch_rule", id: &form.rule_id, name: "", }, serde_json::Value::Null, ) .await; set_settings_notice(cx, "removed"); let title = format!("{}/{}", ctx.repo.owner_name, ctx.repo.name); Ok(see_other(format!("/{title}/settings"))) } #[cfg(test)] mod tests { use forge_core::{Store, Visibility}; use forge_git::repo::{init_bare, repo_path}; use topcoat::router::request::Request; use topcoat::router::{Body, Router, to_bytes}; use crate::auth::SESSION_COOKIE; use crate::test_support::{app_router, create_session, test_state}; const CSRF: &str = "settings-test-csrf"; /// Posts the branch form as `alice` and returns the status and body. async fn post_rule(app: &Router, cookie: &str, fields: &[(&str, &str)]) -> (u16, String) { let mut form = form_urlencoded::Serializer::new(String::new()); form.append_pair("csrf", CSRF); for (name, value) in fields { form.append_pair(name, value); } let req = Request::builder() .method("POST") .uri("/alice/proj/settings/branches") .header("content-type", "application/x-www-form-urlencoded") .header("cookie", cookie) .body(Body::from(form.finish())) .expect("request"); let res = app.handle(req).await; let status = res.status().as_u16(); let bytes = to_bytes(res.into_body(), usize::MAX).await.expect("body"); (status, String::from_utf8_lossy(&bytes).into_owned()) } #[tokio::test] async fn branch_rule_form_saves_merge_and_push_fields() { let tmp = tempfile::tempdir().expect("tempdir"); let state = test_state(tmp.path()).await; let store: Store = state.store.clone(); let alice = store .create_user("alice", None, "unused", false) .await .expect("alice"); let repo = store .create_repo(&alice.id, "proj", "", Visibility::Private, "main") .await .expect("repo"); let bare = repo_path(&state.git.data_dir, &repo.id).expect("repo path"); init_bare(&state.git, &bare, "main") .await .expect("init bare"); create_session(&store, &alice.id, "alice-token", CSRF).await; let cookie = format!("__Host-{SESSION_COOKIE}=alice-token"); let app = app_router(state.clone()); let (status, body) = post_rule( &app, &cookie, &[ ("pattern", "main"), ("required_approvals", " 3 "), ("block_on_changes_requested", "on"), ("require_up_to_date", "on"), ("restrict_push", "on"), ], ) .await; assert_eq!(status, 303, "body: {body}"); let rules = store.list_branch_rules(&repo.id).await.expect("rules"); assert_eq!(rules.len(), 1); let rule = &rules[0]; assert_eq!(rule.pattern, "main"); assert_eq!(rule.required_approvals, 3); assert!(rule.block_on_changes_requested); assert!(rule.require_up_to_date); assert!(rule.restrict_push); assert!(!rule.allow_force_push && !rule.allow_deletion); // A blank count means 0 and unchecked boxes are off. let (status, _) = post_rule( &app, &cookie, &[("pattern", "main"), ("required_approvals", "")], ) .await; assert_eq!(status, 303); let rule = store.list_branch_rules(&repo.id).await.expect("rules")[0].clone(); assert_eq!(rule.required_approvals, 0); assert!( !rule.block_on_changes_requested && !rule.require_up_to_date && !rule.restrict_push ); // Out of range re-renders the form with the error and saves nothing. let (status, body) = post_rule( &app, &cookie, &[ ("pattern", "main"), ("required_approvals", "21"), ("restrict_push", "on"), ], ) .await; assert_eq!(status, 200); assert!( body.contains("required approvals must be a whole number from 0 to 20"), "{body}" ); let rule = store.list_branch_rules(&repo.id).await.expect("rules")[0].clone(); assert_eq!(rule.required_approvals, 0); assert!(!rule.restrict_push); // Not a number is refused the same way. let (status, body) = post_rule( &app, &cookie, &[("pattern", "main"), ("required_approvals", "two")], ) .await; assert_eq!(status, 200); assert!( body.contains("required approvals must be a whole number from 0 to 20"), "{body}" ); } }