//! Creating repositories and their settings pages (hub spec §6.3). use super::settings::{csrf, require}; use super::{filters, lookup_role, Error, Page, RepoShell, Shell}; use crate::auth::{roles, Access, Role, Viewer}; use crate::db::{self, accounts}; use crate::repos::RepoName; use crate::Shared; use askama::Template; use axum::extract::{Form, Path, Query, State}; use axum::http::Uri; use axum::response::{IntoResponse, Redirect, Response}; use hub_api::Visibility; use serde::Deserialize; #[derive(Template)] #[template(path = "repo_new.html")] pub struct NewRepoPage { shell: Shell, owners: Vec, error: Option, } pub(crate) async fn owners_for(app: &Shared, me: &accounts::Account) -> Vec { let mut owners = vec![me.username.clone()]; if let Ok(orgs) = accounts::orgs_of(&app.db, me.id).await { owners.extend(orgs.into_iter().map(|(o, _)| o.name)); } owners } pub async fn new_form(State(app): State, viewer: Viewer) -> Result, Error> { let shell = Shell::new(&app, "/new", &viewer); let me = require(&viewer, &shell)?; let owners = owners_for(&app, &me.account).await; Ok(Page(NewRepoPage { shell, owners, error: None })) } #[derive(Deserialize)] pub struct NewRepoForm { #[serde(rename = "_csrf")] csrf_token: String, owner: String, name: String, #[serde(default)] description: String, #[serde(default = "default_visibility")] visibility: String, /// `hub` (this server's disk, the default) or `bucket` (oxygenhub#96). #[serde(default)] storage: String, #[serde(flatten)] bucket: BucketForm, } /// A bucket and the credentials for it, as the New repository form and /// Settings → Storage give them; only the kind the URL names is read. #[derive(Deserialize, Default)] pub struct BucketForm { #[serde(default)] store_url: String, #[serde(default)] access_key_id: String, #[serde(default)] secret_access_key: String, #[serde(default)] service_account_key: String, #[serde(default)] tenant_id: String, #[serde(default)] client_id: String, #[serde(default)] client_secret: String, } impl BucketForm { fn credentials(&self, scheme: &str) -> Result { crate::storage::credentials_from( scheme, &self.access_key_id, &self.secret_access_key, &self.service_account_key, &self.tenant_id, &self.client_id, &self.client_secret, ) } } fn default_visibility() -> String { "public".into() } pub async fn create(State(app): State, viewer: Viewer, Form(f): Form) -> Result { let shell = Shell::new(&app, "/new", &viewer); let me = require(&viewer, &shell)?; csrf(&viewer, &f.csrf_token, &shell)?; let owners = owners_for(&app, &me.account).await; let fail = |msg: &str| Page(NewRepoPage { shell: shell.clone(), owners: owners.clone(), error: Some(msg.into()) }).into_response(); let Some(name) = RepoName::new(f.owner.trim(), f.name.trim()) else { return Ok(fail("A repository name is letters, digits, dots, dashes and underscores.")); }; if name.name.ends_with(crate::wiki::SUFFIX) { return Ok(fail("A name cannot end in .wiki: that is the address of a repository's wiki.")); } if !roles::may_create_under(&app.db, &me.account, &name.owner).await.map_err(|e| Error::from_db(shell.clone(), e))? { return Ok(fail("You cannot create repositories under that owner.")); } let Some(visibility) = Visibility::parse(&f.visibility) else { return Ok(fail("Visibility is public, internal or private.")) }; if db::repos::get(&app.db, &name.owner, &name.name).await.map_err(|e| Error::from_db(shell.clone(), e))?.is_some() || app.repos.exists(&name) { return Ok(fail("That repository exists.")); } // An organisation's storage policy (oxygenhub#96). if let Some(org) = accounts::org_by_name(&app.db, &name.owner).await.map_err(|e| Error::from_db(shell.clone(), e))? { let policy = crate::storage::policy(&app.db, org.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; let store = (f.storage == "bucket").then_some(f.bucket.store_url.as_str()); if let Err(e) = policy.permits(&org.name, store) { return Ok(fail(&format!("{e}."))); } } if f.storage == "bucket" { // Kept in the owner's bucket (oxygenhub#96): checked before // anything is made, and nothing is left behind when it fails. let url = f.bucket.store_url.trim(); let parsed = match crate::storage::check_url(url) { Ok(p) => p, Err(e) => return Ok(fail(&format!("Store: {e}."))), }; let credentials = match f.bucket.credentials(&parsed.scheme) { Ok(c) => c, Err(e) => return Ok(fail(&format!("Credentials: {e}."))), }; let row = db::repos::create(&app.db, &name.owner, &name.name, f.description.trim(), visibility) .await .map_err(|e| Error::from_db(shell.clone(), e))?; if let Err(e) = crate::storage::create(&app, &name, row.id, url, credentials).await { db::repos::delete(&app.db, row.id).await.ok(); return Ok(fail(&format!("The bucket did not pass the check, so nothing was created: {e}"))); } accounts::audit( &app.db, Some(&me.account), "repo.created", &name.to_string(), &format!("{} in {}", visibility.as_str(), parsed.plain()), ) .await .ok(); return Ok(Redirect::to(&name.url()).into_response()); } app.repos.create_async(&name, None).await.map_err(|e| Error::from_repo(shell.clone(), e))?; db::repos::create(&app.db, &name.owner, &name.name, f.description.trim(), visibility) .await .map_err(|e| Error::from_db(shell.clone(), e))?; accounts::audit(&app.db, Some(&me.account), "repo.created", &name.to_string(), visibility.as_str()).await.ok(); Ok(Redirect::to(&name.url()).into_response()) } /// One page of a repository's settings (#278), at /// `/{owner}/{name}/settings/{slug}`; `/settings` is General. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Section { General, Collaborators, Landing, Checks, PushProtection, Pipelines, Runs, Gates, Runners, Variables, Secrets, Coverage, Webhooks, Assistants, GitMirror, Diffusion, Storage, Backup, Advanced, } /// The sidebar: each group's heading and its pages, in order. const GROUPS: &[(&str, &[Section])] = &[ ("General", &[Section::General, Section::Collaborators]), ("Code", &[Section::Landing, Section::Checks, Section::PushProtection]), ( "Flow", &[Section::Pipelines, Section::Runs, Section::Gates, Section::Runners, Section::Variables, Section::Secrets, Section::Coverage], ), ("Integrations", &[Section::Webhooks, Section::Assistants, Section::GitMirror, Section::Diffusion]), ("Data", &[Section::Storage, Section::Backup]), ("Danger zone", &[Section::Advanced]), ]; impl Section { pub fn slug(self) -> &'static str { match self { Section::General => "general", Section::Collaborators => "collaborators", Section::Landing => "landing", Section::Checks => "checks", Section::PushProtection => "push-protection", Section::Pipelines => "flow", Section::Runs => "runs", Section::Gates => "gates", Section::Runners => "runners", Section::Variables => "variables", Section::Secrets => "secrets", Section::Coverage => "coverage", Section::Webhooks => "webhooks", Section::Assistants => "assistants", Section::GitMirror => "git-mirror", Section::Diffusion => "diffusion", Section::Storage => "storage", Section::Backup => "backup", Section::Advanced => "advanced", } } pub fn title(self) -> &'static str { match self { Section::General => "General", Section::Collaborators => "Collaborators", Section::Landing => "Landing rules", Section::Checks => "Required checks", Section::PushProtection => "Push protection", Section::Pipelines => "Pipelines", Section::Runs => "Runs & priority", Section::Gates => "Gates & agents", Section::Runners => "Runners", Section::Variables => "Variables", Section::Secrets => "Secrets", Section::Coverage => "Coverage", Section::Webhooks => "Webhooks", Section::Assistants => "AI assistants", Section::GitMirror => "Git mirror", Section::Diffusion => "Diffusion", Section::Storage => "Storage", Section::Backup => "Backup", Section::Advanced => "Advanced", } } pub fn parse(slug: &str) -> Option
{ GROUPS.iter().flat_map(|(_, pages)| pages.iter().copied()).find(|s| s.slug() == slug) } /// Whether only the repository's admins use it; its maintainers use /// the rest. The roles are those of each page's forms. fn admin_only(self) -> bool { matches!( self, Section::Collaborators | Section::Runners | Section::Variables | Section::Secrets | Section::Webhooks | Section::Assistants | Section::Storage | Section::Advanced ) } /// Whether this viewer has the page: Storage only for a repository kept /// in a bucket. fn shown(self, role: Role, in_bucket: bool) -> bool { (!self.admin_only() || role == Role::Admin) && (self != Section::Storage || in_bucket) } } /// The sidebar a viewer sees: the groups with at least one page for them. fn sidebar(role: Role, in_bucket: bool) -> Vec<(&'static str, Vec
)> { GROUPS .iter() .map(|(group, pages)| (*group, pages.iter().copied().filter(|s| s.shown(role, in_bucket)).collect::>())) .filter(|(_, pages)| !pages.is_empty()) .collect() } /// Back to the page that holds a settings form, with what happened (#278). fn back(row: &db::repos::RepoRow, section: Section, notice: Option, error: Option) -> Result { let mut to = format!("/{}/{}/settings/{}", row.owner, row.name, section.slug()); if let Some(n) = notice { to.push_str(&format!("?notice={}", super::settings::urlencode(&n))); } else if let Some(e) = error { to.push_str(&format!("?error={}", super::settings::urlencode(&e))); } Ok(Redirect::to(&to).into_response()) } #[derive(Template)] #[template(path = "repo_settings.html")] pub struct RepoSettingsPage { /// The page shown, and the sidebar's groups of pages. section: Section, nav: Vec<(&'static str, Vec
)>, shell: Shell, repo: RepoShell, row: db::repos::RepoRow, collaborators: Vec, teams: Vec, role: Role, requirements: db::proposals::Requirements, hooks: Vec, required_checks: String, /// Whether hub.toml alone would run Flow here, and whether it runs. flow_default: bool, /// hub.toml's `[diffusion] follow` (#148). diffusion_site: String, flow_enabled: bool, site_admin: bool, /// Whether the repository or its owner has a runner of its own (#225). own_runner: bool, notice: Option, error: Option, /// Runners that may take this repository's jobs: (runner, what it /// serves, whether it is this repository's own). runners: Vec<(db::runners::Runner, String, bool)>, /// A registration token just made, shown once. runner_token: Option, /// The repository's AI assistants and prompt (#97). assistants: super::assistants::Panel, /// The repository's Flow variables (#102). variables: super::variables::Panel, /// The repository's Flow secrets (#107). secrets: super::secrets::Panel, /// Where its data lives, when in a bucket (oxygenhub#96). storage: Option, /// The coverage check's rules (#113). coverage: crate::coverage::Rules, /// Its owner's backup, when one is set up (docs/backups.md §5). backup_owner: Option, /// Push protection (#268): the findings of recent refused pushes not /// yet allowed, and the fingerprints allowed. push_findings: Vec, secret_bypasses: Vec, /// The history scan (#277): its progress, its open findings and those /// marked resolved, and whether the hub runs it at all. history_scan: Option, history_findings: Vec, resolved_findings: Vec, history_scan_on: bool, } impl RepoSettingsPage { fn backup_choices(&self) -> &'static [(&'static str, &'static str)] { crate::backups::REPO_CHOICES } } /// One settings page, or not found when the viewer's role does not have it. async fn settings_page( app: &Shared, shell: Shell, row: db::repos::RepoRow, role: Role, section: Section, notice: Option, error: Option, ) -> Result, Error> { let storage = crate::storage::of(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; if !section.shown(role, storage.is_some()) { return Err(Error::not_found(shell)); } let nav = sidebar(role, storage.is_some()); let collaborators = accounts::collaborators(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; let teams = match accounts::org_by_name(&app.db, &row.owner).await.map_err(|e| Error::from_db(shell.clone(), e))? { Some(org) => accounts::teams(&app.db, org.id).await.map_err(|e| Error::from_db(shell.clone(), e))?, None => vec![], }; let repo = RepoShell::new(app, &row, "settings").with_role(role); let requirements = db::proposals::requirements(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; let hooks = db::hooks::hooks_for_repo(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; let hooks = super::webhooks::rows(app, hooks).await.map_err(|e| Error::from_db(shell.clone(), e))?; let required_checks = db::hooks::required_checks(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.join(", "); let flow_default = app.config.flow.allows(&row.owner, &row.name); let diffusion_site = app.config.diffusion.follow.clone(); let flow_enabled = crate::flow::enabled(app, &row); let site_admin = shell.is_admin; let own_runner = has_own_runner(app, &row).await.map_err(|e| Error::from_db(shell.clone(), e))?; let runners = db::runners::serving(&app.db, &row.owner, row.id) .await .map_err(|e| Error::from_db(shell.clone(), e))? .into_iter() .map(|r| { let own = r.scope_repo_id == Some(row.id); let serves = if own { "this repository".to_string() } else if r.scope_owner.is_empty() { "the whole site".to_string() } else { format!("{}'s repositories", r.scope_owner) }; (r, serves, own) }) .collect(); let assistants = super::assistants::panel(app, &repo_assistants(&row)).await.map_err(|e| Error::from_db(shell.clone(), e))?; let variables = super::variables::panel(app, &repo_assistants(&row)).await.map_err(|e| Error::from_db(shell.clone(), e))?; let secrets = super::secrets::panel(app, &repo_assistants(&row)).await.map_err(|e| Error::from_db(shell.clone(), e))?; let coverage = crate::coverage::rules(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; let backup_owner = crate::backups::setting(&app.db, &row.owner).await.map_err(|e| Error::from_db(shell.clone(), e))?; let push_findings = db::push_protection::recent(&app.db, row.id, 50).await.map_err(|e| Error::from_db(shell.clone(), e))?; let secret_bypasses = db::push_protection::bypasses(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; let history_scan = db::push_protection::scan_of(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; let history_findings = db::push_protection::history(&app.db, row.id, 200).await.map_err(|e| Error::from_db(shell.clone(), e))?; let resolved_findings = db::push_protection::resolved(&app.db, row.id, 50).await.map_err(|e| Error::from_db(shell.clone(), e))?; let history_scan_on = crate::history_scan::Budget::of(&app.config).is_some(); Ok(Page(RepoSettingsPage { section, nav, push_findings, secret_bypasses, history_scan, history_findings, resolved_findings, history_scan_on, backup_owner, coverage, storage, assistants, variables, secrets, runners, runner_token: None, shell, repo, row, collaborators, teams, role, requirements, hooks, required_checks, flow_default, diffusion_site, flow_enabled, site_admin, own_runner, notice, error, })) } /// What a settings form did, carried to the page it redirects to. #[derive(Deserialize, Default)] pub struct Outcome { #[serde(default)] notice: Option, #[serde(default)] error: Option, } /// `GET /{owner}/{name}/settings`: the General page. pub async fn settings( State(app): State, viewer: Viewer, Path((owner, name)): Path<(String, String)>, Query(q): Query, ) -> Result, Error> { let path = format!("/{owner}/{name}/settings"); show(&app, &viewer, &path, &owner, &name, Section::General, q).await } /// `GET /{owner}/{name}/settings/{page}` (#278): one page of the settings. /// Some pages share their path with their form's `POST`, so the page is /// the path's last segment. pub async fn page( State(app): State, viewer: Viewer, Path(params): Path>, uri: Uri, Query(q): Query, ) -> Result, Error> { let (owner, name) = (params.get("owner").cloned().unwrap_or_default(), params.get("name").cloned().unwrap_or_default()); let path = format!("/{owner}/{name}/settings"); let slug = uri.path().rsplit('/').next().unwrap_or(""); let Some(section) = Section::parse(slug) else { return Err(Error::not_found(Shell::new(&app, &path, &viewer))); }; show(&app, &viewer, &format!("{path}/{slug}"), &owner, &name, section, q).await } /// A settings page for the repository's maintainers, or its admins: on an /// archived repository, only its admins, who can bring it back. async fn show( app: &Shared, viewer: &Viewer, path: &str, owner: &str, name: &str, section: Section, q: Outcome, ) -> Result, Error> { let shell = Shell::new(app, path, viewer); let (_, row, role) = lookup_role(app, &shell, viewer, owner, name, Access::Read).await?; if role < Role::Maintain { return Err(Error::not_found(shell)); } if row.archived && role < Role::Admin { return Err(super::archived(&shell)); } settings_page(app, shell, row, role, section, q.notice, q.error).await } #[derive(Deserialize)] pub struct GeneralForm { #[serde(rename = "_csrf")] csrf_token: String, description: String, default_bookmark: String, visibility: String, #[serde(default)] topics: String, } pub async fn update( State(app): State, viewer: Viewer, Path((owner, name)): Path<(String, String)>, Form(f): Form, ) -> Result { let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?; csrf(&viewer, &f.csrf_token, &shell)?; let e = |e| Error::from_db(shell.clone(), e); let topics = match db::repos::parse_topics(&f.topics) { Ok(t) => t, Err(msg) => return back(&row, Section::General, None, Some(msg)), }; db::repos::set_description(&app.db, row.id, f.description.trim()).await.map_err(e)?; db::repos::set_topics(&app.db, row.id, &topics).await.map_err(e)?; if crate::repos::names::valid_segment(f.default_bookmark.trim()) { db::repos::set_default_bookmark(&app.db, row.id, f.default_bookmark.trim()).await.map_err(e)?; } if role >= Role::Admin { if let Some(v) = Visibility::parse(&f.visibility) { db::repos::set_visibility(&app.db, row.id, v).await.map_err(e)?; } } accounts::audit(&app.db, viewer.account(), "repo.updated", &format!("{owner}/{name}"), "").await.ok(); let row = db::repos::get_by_id(&app.db, row.id).await.map_err(e)?.unwrap_or(row); back(&row, Section::General, Some("Saved.".into()), None) } #[derive(Deserialize)] pub struct CollaboratorForm { #[serde(rename = "_csrf")] csrf_token: String, #[serde(default)] username: String, #[serde(default)] team: String, #[serde(default)] role: String, #[serde(default)] remove: Option, } pub async fn collaborator( State(app): State, viewer: Viewer, Path((owner, name)): Path<(String, String)>, Form(f): Form, ) -> Result { let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?; csrf(&viewer, &f.csrf_token, &shell)?; let e = |e| Error::from_db(shell.clone(), e); let (account_id, team_id, label) = if !f.username.trim().is_empty() { match accounts::account_by_name(&app.db, f.username.trim()).await.map_err(e)? { Some(a) => (Some(a.id), None, a.username), None => return back(&row, Section::Collaborators, None, Some("No such account.".into())), } } else if !f.team.trim().is_empty() { let org = accounts::org_by_name(&app.db, &row.owner).await.map_err(e)?; let team = match org { Some(o) => accounts::team_by_name(&app.db, o.id, f.team.trim()).await.map_err(e)?, None => None, }; match team { Some(t) => (None, Some(t.id), format!("team {}", t.name)), None => return back(&row, Section::Collaborators, None, Some("No such team in this organisation.".into())), } } else { return back(&row, Section::Collaborators, None, Some("Name an account or a team.".into())); }; if f.remove.is_some() { accounts::remove_collaborator(&app.db, row.id, account_id, team_id).await.map_err(e)?; accounts::audit(&app.db, viewer.account(), "repo.collaborator.removed", &format!("{owner}/{name}"), &label).await.ok(); return back(&row, Section::Collaborators, Some(format!("Removed {label}.")), None); } let Some(new_role) = Role::parse(&f.role) else { return back(&row, Section::Collaborators, None, Some("Pick a role.".into())); }; accounts::set_collaborator(&app.db, row.id, account_id, team_id, new_role.as_str()).await.map_err(e)?; accounts::audit( &app.db, viewer.account(), "repo.collaborator.set", &format!("{owner}/{name}"), &format!("{label} {}", new_role.as_str()), ) .await .ok(); back(&row, Section::Collaborators, Some(format!("{label} now has {} access.", new_role.as_str())), None) } #[derive(Deserialize)] pub struct TransferForm { #[serde(rename = "_csrf")] csrf_token: String, owner: String, confirm: String, } /// `POST /{owner}/{name}/settings/transfer`: move the repository to a /// user or organisation the admin may create repositories under (#108). /// The old URL redirects and oxy remotes keep working. pub async fn transfer( State(app): State, viewer: Viewer, Path((owner, name)): Path<(String, String)>, Form(f): Form, ) -> Result { let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?; csrf(&viewer, &f.csrf_token, &shell)?; let me = viewer.account().cloned().ok_or_else(|| Error::not_found(shell.clone()))?; let fail = |msg: &str| back(&row, Section::Advanced, None, Some(msg.into())); if f.confirm.trim() != repo.to_string() { return fail("Type the repository's full name to confirm."); } let to = f.owner.trim().trim_start_matches('@').to_string(); let Some(dest) = RepoName::new(&to, &row.name) else { return fail("That is not an owner name."); }; if dest.owner == row.owner { return fail("The repository is already there."); } let e = |e| Error::from_db(shell.clone(), e); let known = accounts::account_by_name(&app.db, &dest.owner).await.map_err(e)?.is_some() || accounts::org_by_name(&app.db, &dest.owner).await.map_err(e)?.is_some(); if !known { return fail("There is no user or organisation of that name."); } if !roles::may_create_under(&app.db, &me, &dest.owner).await.map_err(e)? { return fail("You cannot create repositories under that owner, so you cannot move one there."); } if db::repos::get(&app.db, &dest.owner, &dest.name).await.map_err(e)?.is_some() || app.repos.exists(&dest) { return fail("That owner already has a repository of this name."); } { let _guard = app.repos.exclusive_guard(&repo).await; app.repos.rename(&repo, &dest).map_err(|e| Error::from_repo(shell.clone(), e))?; crate::storage::renamed(&app, &repo, &dest); if app.wikis.exists(&repo) { app.wikis.rename(&repo, &dest).ok(); } db::repos::transfer(&app.db, row.id, &row.owner, &row.name, &dest.owner).await.map_err(e)?; let mirror = crate::mirror::mirror_dir(&app, &repo); if mirror.is_dir() { let to = crate::mirror::mirror_dir(&app, &dest); if let Some(parent) = to.parent() { std::fs::create_dir_all(parent).ok(); } std::fs::rename(&mirror, &to).ok(); } } accounts::audit(&app.db, Some(&me), "repo.transferred", &repo.to_string(), &dest.to_string()).await.ok(); let notice = super::settings::urlencode(&format!("Moved from {repo}; the old address redirects here.")); Ok(Redirect::to(&format!("{}/settings/{}?notice={notice}", dest.url(), Section::Advanced.slug())).into_response()) } #[derive(Deserialize)] pub struct ArchiveForm { #[serde(rename = "_csrf")] csrf_token: String, /// `archive` or `unarchive`. action: String, #[serde(default)] reason: String, } /// `POST /{owner}/{name}/settings/archive`: read-only, with a reason, or /// back again (#108). pub async fn archive( State(app): State, viewer: Viewer, Path((owner, name)): Path<(String, String)>, Form(f): Form, ) -> Result { let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); let (repo, mut row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?; csrf(&viewer, &f.csrf_token, &shell)?; let archive = match f.action.as_str() { "archive" => true, "unarchive" => false, _ => return back(&row, Section::Advanced, None, Some("Unknown action.".into())), }; let reason: String = f.reason.trim().chars().take(500).collect(); db::repos::set_archived(&app.db, row.id, archive, &reason).await.map_err(|e| Error::from_db(shell.clone(), e))?; accounts::audit(&app.db, viewer.account(), if archive { "repo.archived" } else { "repo.unarchived" }, &repo.to_string(), &reason) .await .ok(); row.archived = archive; row.archived_reason = if archive { reason } else { String::new() }; let notice = if archive { "The repository is archived: read-only until it is brought back." } else { "The repository is back: writes are allowed again." }; back(&row, Section::Advanced, Some(notice.into()), None) } /// `POST /{owner}/{name}/settings/wiki/delete`: the wiki to the trash (#104). pub async fn wiki_delete( State(app): State, viewer: Viewer, Path((owner, name)): Path<(String, String)>, Form(f): Form, ) -> Result { let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?; csrf(&viewer, &f.csrf_token, &shell)?; if f.confirm.trim() != repo.to_string() { return back(&row, Section::Advanced, None, Some("Type the repository's full name to confirm.".into())); } if !crate::wiki::exists(&app, &repo) { return back(&row, Section::Advanced, None, Some("This repository has no wiki.".into())); } let _guard = app.wikis.exclusive_guard(&repo).await; crate::wiki::trash(&app, &repo).map_err(|e| Error::from_repo(shell.clone(), e))?; drop(_guard); accounts::audit(&app.db, viewer.account(), "wiki.data_deleted", &repo.to_string(), "moved to trash").await.ok(); back(&row, Section::Advanced, Some("The wiki is in the trash; a new first page starts a fresh one.".into()), None) } #[derive(Deserialize)] pub struct DeleteForm { #[serde(rename = "_csrf")] csrf_token: String, confirm: String, } pub async fn delete( State(app): State, viewer: Viewer, Path((owner, name)): Path<(String, String)>, Form(f): Form, ) -> Result { let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?; csrf(&viewer, &f.csrf_token, &shell)?; if f.confirm.trim() != repo.to_string() { return back(&row, Section::Advanced, None, Some("Type the repository's full name to delete it.".into())); } let _guard = app.repos.exclusive_guard(&repo).await; app.repos.trash(&repo).map_err(|e| Error::from_repo(shell.clone(), e))?; // A repository kept in a bucket leaves its data there: the owner's. crate::storage::removed(&app, &repo); crate::wiki::trash(&app, &repo).ok(); db::repos::delete(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; crate::assistants::delete_scope(&app.db, crate::levels::Scope::Repo(row.id)).await.ok(); crate::variables::delete_scope(&app.db, crate::levels::Scope::Repo(row.id)).await.ok(); crate::secrets::delete_scope(&app.db, crate::levels::Scope::Repo(row.id)).await.ok(); accounts::audit(&app.db, viewer.account(), "repo.deleted", &repo.to_string(), "moved to trash").await.ok(); Ok(Redirect::to(&format!("/{owner}")).into_response()) } #[derive(Deserialize)] pub struct RequirementsForm { #[serde(rename = "_csrf")] csrf_token: String, #[serde(default)] required_approvals: i64, #[serde(default)] require_owners: Option, #[serde(default)] allow_conflicts: Option, } pub async fn requirements( State(app): State, viewer: Viewer, Path((owner, name)): Path<(String, String)>, Form(f): Form, ) -> Result { let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?; csrf(&viewer, &f.csrf_token, &shell)?; let r = db::proposals::Requirements { required_approvals: f.required_approvals.max(0), require_owners: f.require_owners.is_some(), allow_conflicts: f.allow_conflicts.is_some(), }; db::proposals::set_requirements(&app.db, row.id, r).await.map_err(|e| Error::from_db(shell.clone(), e))?; accounts::audit( &app.db, viewer.account(), "repo.requirements", &format!("{owner}/{name}"), &format!("{} approvals, owners {}, conflicts {}", r.required_approvals, r.require_owners, r.allow_conflicts), ) .await .ok(); back(&row, Section::Landing, Some("Landing requirements saved.".into()), None) } pub async fn hooks( State(app): State, viewer: Viewer, Path((owner, name)): Path<(String, String)>, Form(f): Form, ) -> Result { let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?; csrf(&viewer, &f.csrf_token, &shell)?; let scope = super::webhooks::Scope::Repo { id: row.id, slug: format!("{owner}/{name}") }; match super::webhooks::apply(&app, &viewer, &scope, f).await.map_err(|e| Error::from_db(shell.clone(), e))? { Ok(notice) => back(&row, Section::Webhooks, Some(notice), None), Err(error) => back(&row, Section::Webhooks, None, Some(error)), } } pub async fn deliveries( State(app): State, viewer: Viewer, Path((owner, name, id)): Path<(String, String, i64)>, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/{owner}/{name}/settings/hooks/{id}/deliveries"), &viewer); let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?; let scope = super::webhooks::Scope::Repo { id: row.id, slug: format!("{owner}/{name}") }; super::webhooks::deliveries_page(&app, shell, &scope, Some(RepoShell::new(&app, &row, "settings").with_role(role)), id).await } pub async fn redeliver( State(app): State, viewer: Viewer, Path((owner, name, id, delivery)): Path<(String, String, i64, i64)>, Form(f): Form, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/{owner}/{name}/settings/hooks/{id}/deliveries"), &viewer); let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?; csrf(&viewer, &f.csrf, &shell)?; let scope = super::webhooks::Scope::Repo { id: row.id, slug: format!("{owner}/{name}") }; super::webhooks::redeliver(&app, &scope, id, delivery).await.map_err(|e| Error::from_db(shell.clone(), e))?; super::webhooks::deliveries_page(&app, shell, &scope, Some(RepoShell::new(&app, &row, "settings").with_role(role)), id).await } #[derive(Deserialize)] pub struct ChecksForm { #[serde(rename = "_csrf")] csrf_token: String, #[serde(default)] required_checks: String, } pub async fn required_checks( State(app): State, viewer: Viewer, Path((owner, name)): Path<(String, String)>, Form(f): Form, ) -> Result { let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?; csrf(&viewer, &f.csrf_token, &shell)?; let names: Vec = f.required_checks.split(',').map(str::trim).filter(|s| !s.is_empty()).map(String::from).collect(); db::hooks::set_required_checks(&app.db, row.id, &names).await.map_err(|e| Error::from_db(shell.clone(), e))?; back(&row, Section::Checks, Some("Required checks saved.".into()), None) } #[derive(Deserialize)] pub struct CoverageForm { #[serde(rename = "_csrf")] csrf_token: String, #[serde(default)] min: String, #[serde(default)] max_drop: String, } /// Settings → Coverage (#113): the rules the `coverage` check applies. pub async fn coverage( State(app): State, viewer: Viewer, Path((owner, name)): Path<(String, String)>, Form(f): Form, ) -> Result { let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?; csrf(&viewer, &f.csrf_token, &shell)?; let number = |v: &str, what: &str, hi: f64| -> Result, String> { let v = v.trim().trim_end_matches('%').trim(); if v.is_empty() { return Ok(None); } match v.parse::() { Ok(n) if (0.0..=hi).contains(&n) => Ok(Some(n)), _ => Err(format!("{what} is a number from 0 to {hi}, or empty for no rule.")), } }; let rules = match (number(&f.min, "The minimum", 100.0), number(&f.max_drop, "The largest drop", 100.0)) { (Ok(min), Ok(max_drop)) => crate::coverage::Rules { min, max_drop }, (Err(e), _) | (_, Err(e)) => return back(&row, Section::Coverage, None, Some(e)), }; crate::coverage::set_rules(&app.db, row.id, rules).await.map_err(|e| Error::from_db(shell.clone(), e))?; accounts::audit( &app.db, viewer.account(), "repo.coverage_rules", &format!("{owner}/{name}"), &format!("min {:?}, max drop {:?}", rules.min, rules.max_drop), ) .await .ok(); back(&row, Section::Coverage, Some("Coverage rules saved; they apply from the next report.".into()), None) } #[derive(Deserialize)] pub struct StorageForm { #[serde(rename = "_csrf")] csrf_token: String, /// `check`, or `credentials` (with the new ones). action: String, #[serde(flatten)] bucket: BucketForm, } /// Settings → Storage (oxygenhub#96): check the bucket again, or replace /// its credentials once the new ones pass the check. pub async fn storage( State(app): State, viewer: Viewer, Path((owner, name)): Path<(String, String)>, Form(f): Form, ) -> Result { let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?; csrf(&viewer, &f.csrf_token, &shell)?; let Some(s) = crate::storage::of(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))? else { return back(&row, Section::General, None, Some("This repository is kept on this server's disk.".into())); }; let scheme = s.url.split("://").next().unwrap_or("").to_string(); let outcome = match f.action.as_str() { "credentials" => match f.bucket.credentials(&scheme) { Ok(c) => crate::storage::set_credentials(&app, &s, c).await.map(|r| format!("New credentials saved: {r}.")), Err(e) => Err(format!("Credentials: {e}")), }, _ => crate::storage::check(&app, &s).await.map(|r| format!("The bucket passed the check: {r}.")), }; match outcome { Ok(notice) => { accounts::audit(&app.db, viewer.account(), "repo.storage", &format!("{owner}/{name}"), &f.action).await.ok(); back(&row, Section::Storage, Some(notice), None) } Err(e) => back(&row, Section::Storage, None, Some(format!("Storage: {e}"))), } } #[derive(Deserialize)] pub struct MirrorForm { #[serde(rename = "_csrf")] csrf_token: String, #[serde(default)] enabled: Option, } #[derive(Deserialize)] pub struct BackupForm { #[serde(rename = "_csrf")] csrf_token: String, /// Empty (with its owner's), `hourly`, `daily` or `off`. #[serde(default)] backup: String, } /// This repository's backup (docs/backups.md §5): with its owner's, left /// out, or at least hourly or daily. Its owners and admins decide. pub async fn backup( State(app): State, viewer: Viewer, Path((owner, name)): Path<(String, String)>, Form(f): Form, ) -> Result { let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?; csrf(&viewer, &f.csrf_token, &shell)?; if !crate::backups::REPO_CHOICES.iter().any(|(v, _)| *v == f.backup) { return back(&row, Section::Backup, None, Some("Pick how this repository is backed up.".into())); } db::repos::set_backup(&app.db, row.id, &f.backup).await.map_err(|e| Error::from_db(shell.clone(), e))?; let what = if f.backup.is_empty() { "with its owner's" } else { f.backup.as_str() }; accounts::audit(&app.db, viewer.account(), "repo.backup", &format!("{owner}/{name}"), what).await.ok(); let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row); let notice = match f.backup.as_str() { "off" => "This repository is left out of its owner's backups from the next run.".to_string(), "" => "This repository is backed up with its owner's.".to_string(), every => { format!("This repository is backed up at least {} from now on.", if every == "hourly" { "every hour" } else { "every day" }) } }; back(&row, Section::Backup, Some(notice), None) } #[derive(Deserialize)] pub struct DiffusionForm { #[serde(rename = "_csrf")] csrf_token: String, /// `off`, `green`, `any`, or `default`. diffusion: String, /// `auto` or `propose` (absent keeps it as it is). #[serde(default)] land: String, } /// Whether this repository follows the upstreams it pins (Diffusion, /// #148): its owners and admins decide, since it opens proposals here. pub async fn diffusion( State(app): State, viewer: Viewer, Path((owner, name)): Path<(String, String)>, Form(f): Form, ) -> Result { let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?; csrf(&viewer, &f.csrf_token, &shell)?; let setting = match f.diffusion.as_str() { "off" | "green" | "any" => f.diffusion.as_str(), "default" => "", _ => return back(&row, Section::Diffusion, None, Some("Pick off, green, any or the site default.".into())), }; let land = match f.land.as_str() { "auto" | "propose" => Some(f.land.as_str()), "" => None, _ => return back(&row, Section::Diffusion, None, Some("Pick propose or auto for landing.".into())), }; db::repos::set_diffusion(&app.db, row.id, setting).await.map_err(|e| Error::from_db(shell.clone(), e))?; if let Some(l) = land { db::repos::set_diffusion_land(&app.db, row.id, l).await.map_err(|e| Error::from_db(shell.clone(), e))?; } let what = format!("{}{}", if setting.is_empty() { "default" } else { setting }, land.map(|l| format!(", land {l}")).unwrap_or_default()); accounts::audit(&app.db, viewer.account(), "repo.diffusion", &format!("{owner}/{name}"), &what).await.ok(); let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row); // Turned to land by itself: any bump already green lands now. if land == Some("auto") { let (app, row) = (app.clone(), row.clone()); tokio::spawn(async move { crate::diffusion::land_ready(&app, &row).await }); } if crate::diffusion::rule(&app.config.diffusion.follow, &row, None, "").follow != crate::diffusion::Follow::Off { let (app, row) = (app.clone(), row.clone()); tokio::spawn(async move { crate::diffusion::catch_up(&app, &row).await }); } let notice = match crate::diffusion::rule(&app.config.diffusion.follow, &row, None, "").follow { crate::diffusion::Follow::Off => "Diffusion is off: upstream moves open no proposals here.", crate::diffusion::Follow::Green => { "Diffusion is on: when a subrepo's upstream moves and its checks pass, a proposal moves the pin here." } crate::diffusion::Follow::Any => "Diffusion is on: every move of a subrepo's upstream opens or updates a proposal here.", }; back(&row, Section::Diffusion, Some(notice.into()), None) } #[derive(Deserialize)] pub struct AgentGatesForm { #[serde(rename = "_csrf")] csrf_token: String, /// `on` when the box is ticked. #[serde(default)] agents: String, } /// `POST /{owner}/{repo}/settings/agent-gates` (oxygenhub#258): whether an /// agent's token may approve or reject this repository's approval gates; /// for maintainers, audited. pub async fn agent_gates( State(app): State, viewer: Viewer, Path((owner, name)): Path<(String, String)>, Form(f): Form, ) -> Result { let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?; csrf(&viewer, &f.csrf_token, &shell)?; let on = f.agents == "on"; db::repos::set_agents_decide_gates(&app.db, row.id, on).await.map_err(|e| Error::from_db(shell.clone(), e))?; accounts::audit(&app.db, viewer.account(), "repo.agents_decide_gates", &format!("{owner}/{name}"), if on { "on" } else { "off" }) .await .ok(); let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row); let notice = if on { "Agents' tokens may now approve or reject this repository's approval gates; it says so on each." } else { "Approval gates here are decided by people: agents' tokens are refused." }; back(&row, Section::Gates, Some(notice.into()), None) } #[derive(Deserialize)] pub struct PushProtectionForm { #[serde(rename = "_csrf")] csrf_token: String, /// `on` when the box is ticked. #[serde(default)] protect: String, } /// `POST /{owner}/{repo}/settings/push-protection` (oxygenhub#268): whether /// pushes adding what looks like a secret are refused; for maintainers, /// audited. pub async fn push_protection( State(app): State, viewer: Viewer, Path((owner, name)): Path<(String, String)>, Form(f): Form, ) -> Result { let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?; csrf(&viewer, &f.csrf_token, &shell)?; let on = f.protect == "on"; db::push_protection::set_enabled(&app.db, row.id, on).await.map_err(|e| Error::from_db(shell.clone(), e))?; accounts::audit(&app.db, viewer.account(), "repo.push_protection", &format!("{owner}/{name}"), if on { "on" } else { "off" }) .await .ok(); if on { // What was pushed while it was off is scanned now (#277). crate::history_scan::wake(&app, row.id); } let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row); let notice = if on { "Push protection is on: a push that adds what looks like a secret is refused, saying where." } else { "Push protection is off: pushes are no longer scanned for secrets here." }; back(&row, Section::PushProtection, Some(notice.into()), None) } #[derive(Deserialize)] pub struct SecretBypassForm { #[serde(rename = "_csrf")] csrf_token: String, fingerprint: String, #[serde(default)] reason: String, } /// `POST /{owner}/{repo}/settings/secret-bypass` (oxygenhub#268): let one /// push protection finding through, by its fingerprint, with a reason; for /// maintainers, audited as `secret.bypass`. pub async fn secret_bypass( State(app): State, viewer: Viewer, Path((owner, name)): Path<(String, String)>, Form(f): Form, ) -> Result { let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?; csrf(&viewer, &f.csrf_token, &shell)?; let fingerprint = f.fingerprint.trim().to_ascii_lowercase(); let reason = f.reason.trim(); let error = if fingerprint.len() != 16 || !fingerprint.chars().all(|c| c.is_ascii_hexdigit()) { Some("A fingerprint is the 16 hexadecimal digits a refused push printed.") } else if reason.is_empty() { Some("Say why this is not a secret (an example key, a test fixture): the reason is kept with the fingerprint and in the audit log.") } else if reason.chars().count() > 500 { Some("Keep the reason under 500 characters.") } else { None }; if let Some(e) = error { return back(&row, Section::PushProtection, None, Some(e.into())); } let rule = db::push_protection::rule_of(&app.db, row.id, &fingerprint) .await .map_err(|e| Error::from_db(shell.clone(), e))? .unwrap_or_default(); let by = viewer.account().map(|a| (a.id, a.username.as_str())); db::push_protection::allow(&app.db, row.id, &fingerprint, &rule, reason, by).await.map_err(|e| Error::from_db(shell.clone(), e))?; let detail = if rule.is_empty() { format!("{fingerprint}: {reason}") } else { format!("{fingerprint} ({rule}): {reason}") }; accounts::audit(&app.db, viewer.account(), "secret.bypass", &format!("{owner}/{name}"), &detail).await.ok(); let notice = format!("Fingerprint {fingerprint} is allowed here: a push adding that value is no longer refused for it."); back(&row, Section::PushProtection, Some(notice), None) } #[derive(Deserialize)] pub struct BypassRemoveForm { #[serde(rename = "_csrf")] csrf_token: String, fingerprint: String, } /// `POST /{owner}/{repo}/settings/secret-bypass/remove` (oxygenhub#277): /// take back an allowed fingerprint, so a push adding that value is refused /// again; for maintainers, audited as `secret.bypass_removed`. pub async fn secret_bypass_remove( State(app): State, viewer: Viewer, Path((owner, name)): Path<(String, String)>, Form(f): Form, ) -> Result { let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?; csrf(&viewer, &f.csrf_token, &shell)?; let fingerprint = f.fingerprint.trim().to_ascii_lowercase(); let removed = db::push_protection::remove_bypass(&app.db, row.id, &fingerprint).await.map_err(|e| Error::from_db(shell.clone(), e))?; let Some(b) = removed else { return back(&row, Section::PushProtection, None, Some(format!("Fingerprint {fingerprint} is not allowed here."))); }; let detail = if b.rule.is_empty() { format!("{fingerprint} (was: {})", b.reason) } else { format!("{fingerprint} ({}) (was: {})", b.rule, b.reason) }; accounts::audit(&app.db, viewer.account(), "secret.bypass_removed", &format!("{owner}/{name}"), &detail).await.ok(); let notice = format!("Fingerprint {fingerprint} is no longer allowed: a push adding that value is refused again."); back(&row, Section::PushProtection, Some(notice), None) } #[derive(Deserialize)] pub struct SecretResolveForm { #[serde(rename = "_csrf")] csrf_token: String, finding: i64, #[serde(default)] reason: String, } /// `POST /{owner}/{repo}/settings/secret-resolve` (oxygenhub#277): mark a /// finding of the history scan resolved (the secret was revoked or /// rotated), with a reason; for maintainers, audited as `secret.resolved`. pub async fn secret_resolve( State(app): State, viewer: Viewer, Path((owner, name)): Path<(String, String)>, Form(f): Form, ) -> Result { let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?; csrf(&viewer, &f.csrf_token, &shell)?; let reason = f.reason.trim(); let error = if reason.is_empty() { Some("Say how it was resolved (the key was revoked, the token rotated): the reason is kept with the finding and in the audit log.") } else if reason.chars().count() > 500 { Some("Keep the reason under 500 characters.") } else { None }; if let Some(e) = error { return back(&row, Section::PushProtection, None, Some(e.into())); } let by = viewer.account().map(|a| a.username.as_str()).unwrap_or(""); let done = db::push_protection::resolve(&app.db, row.id, f.finding, by, reason).await.map_err(|e| Error::from_db(shell.clone(), e))?; let Some(found) = done else { return back(&row, Section::PushProtection, None, Some("That finding is not open here.".into())); }; let detail = format!("{} ({}) at {}:{}: {reason}", found.fingerprint, found.rule, found.path, found.line); accounts::audit(&app.db, viewer.account(), "secret.resolved", &format!("{owner}/{name}"), &detail).await.ok(); let notice = format!("The finding at {}:{} is marked resolved.", found.path, found.line); back(&row, Section::PushProtection, Some(notice), None) } #[derive(Deserialize)] pub struct SecretScanForm { #[serde(rename = "_csrf")] csrf_token: String, } /// `POST /{owner}/{repo}/settings/secret-scan` (oxygenhub#277): scan the /// whole history again now, with the current rules; for maintainers, /// audited as `secret.history_scan`. pub async fn secret_scan( State(app): State, viewer: Viewer, Path((owner, name)): Path<(String, String)>, Form(f): Form, ) -> Result { let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?; csrf(&viewer, &f.csrf_token, &shell)?; if !row.push_protection { return back( &row, Section::PushProtection, None, Some("Turn push protection on first: history is scanned only where it is on.".into()), ); } crate::history_scan::request(&app, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; accounts::audit(&app.db, viewer.account(), "secret.history_scan", &format!("{owner}/{name}"), "requested").await.ok(); let notice = "The whole history will be scanned again, before other repositories: findings appear below as they are found."; back(&row, Section::PushProtection, Some(notice.into()), None) } #[derive(Deserialize)] pub struct SupersedeForm { #[serde(rename = "_csrf")] csrf_token: String, /// `on` when the box is ticked: superseded runs already running are /// cancelled too. #[serde(default)] running: String, } /// `POST /{owner}/{repo}/settings/flow-supersede` (oxygenhub#267): whether /// a new push or proposal run cancels the older runs it supersedes that are /// already running, or only those still queued; for maintainers, audited. pub async fn flow_supersede( State(app): State, viewer: Viewer, Path((owner, name)): Path<(String, String)>, Form(f): Form, ) -> Result { let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?; csrf(&viewer, &f.csrf_token, &shell)?; let keep = f.running != "on"; db::repos::set_flow_keep_running(&app.db, row.id, keep).await.map_err(|e| Error::from_db(shell.clone(), e))?; accounts::audit(&app.db, viewer.account(), "repo.flow_keep_running", &format!("{owner}/{name}"), if keep { "on" } else { "off" }) .await .ok(); let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row); let notice = if keep { "A new push or proposal run now cancels only the older runs still queued; those already running finish." } else { "A new push or proposal run now cancels the older runs it supersedes, queued or running." }; back(&row, Section::Runs, Some(notice.into()), None) } #[derive(Deserialize)] pub struct ReuseForm { #[serde(rename = "_csrf")] csrf_token: String, /// `on` when the box is ticked. #[serde(default)] reuse: String, } /// `POST /{owner}/{repo}/settings/flow-reuse` (oxygenhub#265): whether a /// push run reuses a job's pass on the same state; for maintainers, /// audited. pub async fn flow_reuse( State(app): State, viewer: Viewer, Path((owner, name)): Path<(String, String)>, Form(f): Form, ) -> Result { let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?; csrf(&viewer, &f.csrf_token, &shell)?; let on = f.reuse == "on"; db::repos::set_flow_reuse(&app.db, row.id, on).await.map_err(|e| Error::from_db(shell.clone(), e))?; accounts::audit(&app.db, viewer.account(), "repo.flow_reuse", &format!("{owner}/{name}"), if on { "on" } else { "off" }).await.ok(); let notice = if on { "A push run now reuses a job's pass on the same state." } else { "Every job of every push now runs, passed before or not." }; back(&row, Section::Runs, Some(notice.into()), None) } #[derive(Deserialize)] pub struct SkipForm { #[serde(rename = "_csrf")] csrf_token: String, /// `any`, `others` or `never`. skip: String, } /// `POST /{owner}/{repo}/settings/flow-skip` (oxygenhub#284): where a push /// may skip its run with `[skip flow]` in its message; for maintainers, /// audited. pub async fn flow_skip( State(app): State, viewer: Viewer, Path((owner, name)): Path<(String, String)>, Form(f): Form, ) -> Result { let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?; csrf(&viewer, &f.csrf_token, &shell)?; let notice = match f.skip.as_str() { "any" => "A push may now skip its run on any bookmark.".to_string(), "others" => format!("A push may now skip its run on any bookmark but {}.", row.default_bookmark), "never" => "Every push now runs; skip markers are ignored.".to_string(), _ => return back(&row, Section::Runs, None, Some("Choose where a push may skip its run.".into())), }; db::repos::set_flow_skip(&app.db, row.id, &f.skip).await.map_err(|e| Error::from_db(shell.clone(), e))?; accounts::audit(&app.db, viewer.account(), "repo.flow_skip", &format!("{owner}/{name}"), &f.skip).await.ok(); back(&row, Section::Runs, Some(notice), None) } #[derive(Deserialize)] pub struct PriorityForm { #[serde(rename = "_csrf")] csrf_token: String, /// `low`, `normal`, `high`, or `default`. priority: String, } /// `POST /{owner}/{repo}/settings/flow-priority` (oxygenhub#244): its jobs' /// level. Maintainers choose low, normal or the default; only site /// administrators raise a repository to high. pub async fn flow_priority( State(app): State, viewer: Viewer, Path((owner, name)): Path<(String, String)>, Form(f): Form, ) -> Result { let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?; csrf(&viewer, &f.csrf_token, &shell)?; let level = match f.priority.as_str() { "low" | "normal" | "high" => f.priority.as_str(), "default" => "", _ => return back(&row, Section::Runs, None, Some("Pick low, normal, high or the default.".into())), }; if level == "high" && !viewer.is_admin() { return back(&row, Section::Runs, None, Some("Only site administrators raise a repository to high.".into())); } db::repos::set_flow_priority(&app.db, row.id, level).await.map_err(|e| Error::from_db(shell.clone(), e))?; accounts::audit( &app.db, viewer.account(), "repo.flow_priority", &format!("{owner}/{name}"), if level.is_empty() { "default" } else { level }, ) .await .ok(); let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row); let notice = if level.is_empty() { "Its jobs follow the organisation's and the site's priority.".to_string() } else { format!("Its jobs are {level} priority.") }; back(&row, Section::Runs, Some(notice), None) } #[derive(Deserialize)] pub struct FlowForm { #[serde(rename = "_csrf")] csrf_token: String, /// `on`, `off`, or `default`. flow: String, } /// Turn OxygenFlow on or off for one repository. Site administrators choose /// anything; the repository's maintainers may turn it on on their own /// runners (`own`), when the repository or its owner has one, or off (#225). /// Only site administrators let the site's runners take its jobs. pub async fn flow( State(app): State, viewer: Viewer, Path((owner, name)): Path<(String, String)>, Form(f): Form, ) -> Result { let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?; csrf(&viewer, &f.csrf_token, &shell)?; let setting = match f.flow.as_str() { "on" => "on", "own" => "own", "off" => "off", "default" => "", _ => return back(&row, Section::Pipelines, None, Some("Pick on, own runners, off or the site default.".into())), }; if !viewer.is_admin() { if matches!(setting, "on" | "") { return back( &row, Section::Pipelines, None, Some("Only site administrators let the site's runners take this repository's jobs.".into()), ); } if setting == "own" && !has_own_runner(&app, &row).await.map_err(|e| Error::from_db(shell.clone(), e))? { return back( &row, Section::Pipelines, None, Some("Register a runner for this repository (Settings › Runners) or its owner first.".into()), ); } } db::repos::set_flow(&app.db, row.id, setting).await.map_err(|e| Error::from_db(shell.clone(), e))?; let label = if setting.is_empty() { "default" } else { setting }; accounts::audit(&app.db, viewer.account(), "repo.flow", &format!("{owner}/{name}"), label).await.ok(); let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row); let notice = if row.flow == "own" { "OxygenFlow is on, on this repository's own runners: pushes with a .flow/pipeline.kdl start runs that only they take." } else if crate::flow::enabled(&app, &row) { "OxygenFlow is on: pushes with a .flow/pipeline.kdl start runs." } else { "OxygenFlow is off for this repository." }; back(&row, Section::Pipelines, Some(notice.into()), None) } pub async fn mirror( State(app): State, viewer: Viewer, Path((owner, name)): Path<(String, String)>, Form(f): Form, ) -> Result { let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?; csrf(&viewer, &f.csrf_token, &shell)?; db::repos::set_git_mirror(&app.db, row.id, f.enabled.is_some()).await.map_err(|e| Error::from_db(shell.clone(), e))?; let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row); if row.git_mirror { crate::mirror::refresh(&app, &row).await.ok(); } accounts::audit(&app.db, viewer.account(), "repo.mirror", &format!("{owner}/{name}"), if row.git_mirror { "on" } else { "off" }) .await .ok(); let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row); let notice = if row.git_mirror { "The Git mirror is on and up to date." } else { "The Git mirror is off." }; back(&row, Section::GitMirror, Some(notice.into()), None) } /// `POST /{owner}/{name}/settings/runners/token`: a one-time registration /// token for a runner that serves this repository only (oxygenhub#41). pub async fn runner_token( State(app): State, viewer: Viewer, Path((owner, name)): Path<(String, String)>, Form(f): Form, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?; csrf(&viewer, &f.csrf, &shell)?; let me = viewer.account().map(|a| a.username.clone()).unwrap_or_default(); let token = format!("flowreg_{}", crate::auth::random_hex(24)); let scope = db::runners::Scope { owner: row.owner.clone(), repo_id: Some(row.id) }; db::runners::add_registration(&app.db, &blake3::hash(token.as_bytes()).to_hex(), &me, &scope) .await .map_err(|e| Error::from_db(shell.clone(), e))?; accounts::audit(&app.db, viewer.account(), "flow.runner.token", &row.slug(), "registration token for this repository").await.ok(); let mut page = settings_page(&app, shell, row, role, Section::Runners, Some("Copy the token now; it is not shown again.".into()), None).await?; page.0.runner_token = Some(token); Ok(page) } /// `POST /{owner}/{name}/settings/runners/{id}/remove`: remove one of this /// repository's own runners, when it is running nothing. pub async fn runner_remove( State(app): State, viewer: Viewer, Path((owner, name, id)): Path<(String, String, i64)>, Form(f): Form, ) -> Result { let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?; csrf(&viewer, &f.csrf, &shell)?; let e = |e| Error::from_db(shell.clone(), e); let Some(r) = db::runners::get(&app.db, id).await.map_err(e)?.filter(|r| r.scope_repo_id == Some(row.id)) else { return Err(Error::not_found(shell)); }; if db::runners::running(&app.db).await.map_err(e)?.iter().any(|(rid, n)| *rid == id && *n > 0) { return back(&row, Section::Runners, None, Some(format!("{} is running jobs: wait for them to finish.", r.name))); } db::runners::remove(&app.db, id).await.map_err(e)?; accounts::audit(&app.db, viewer.account(), "flow.runner.removed", &r.name, &row.slug()).await.ok(); back(&row, Section::Runners, Some(format!("{} removed: its credential no longer works.", r.name)), None) } fn repo_assistants(row: &db::repos::RepoRow) -> crate::levels::At { crate::levels::At::repo(row) } /// `POST /{owner}/{name}/settings/assistants`: the repository's assistants /// and prompt (#97), for its admins. pub async fn assistants( State(app): State, viewer: Viewer, Path((owner, name)): Path<(String, String)>, Form(f): Form, ) -> Result { let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?; csrf(&viewer, &f.csrf_token, &shell)?; match super::assistants::apply(&app, &viewer, &repo_assistants(&row), f).await.map_err(|e| Error::from_db(shell.clone(), e))? { Ok(notice) => back(&row, Section::Assistants, Some(notice), None), Err(error) => back(&row, Section::Assistants, None, Some(error)), } } /// `POST /{owner}/{name}/settings/variables`: the repository's Flow /// variables (#102), for its admins. pub async fn variables( State(app): State, viewer: Viewer, Path((owner, name)): Path<(String, String)>, Form(f): Form, ) -> Result { let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?; csrf(&viewer, &f.csrf_token, &shell)?; match super::variables::apply(&app, &viewer, &repo_assistants(&row), f).await.map_err(|e| Error::from_db(shell.clone(), e))? { Ok(notice) => back(&row, Section::Variables, Some(notice), None), Err(error) => back(&row, Section::Variables, None, Some(error)), } } /// `POST /{owner}/{name}/settings/secrets`: the repository's Flow secrets /// (#107), for its admins. pub async fn secrets( State(app): State, viewer: Viewer, Path((owner, name)): Path<(String, String)>, Form(f): Form, ) -> Result { let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?; csrf(&viewer, &f.csrf_token, &shell)?; match super::secrets::apply(&app, &viewer, &repo_assistants(&row), f).await.map_err(|e| Error::from_db(shell.clone(), e))? { Ok(notice) => back(&row, Section::Secrets, Some(notice), None), Err(error) => back(&row, Section::Secrets, None, Some(error)), } } /// `POST /{owner}/{name}/settings/assistants/models`: what a provider offers (#109). pub async fn assistant_models( State(app): State, viewer: Viewer, Path((owner, name)): Path<(String, String)>, Form(f): Form, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?; csrf(&viewer, &f.csrf_token, &shell)?; Ok(axum::Json(super::assistants::models(&app, &repo_assistants(&row), f).await.map_err(|e| Error::from_db(shell.clone(), e))?)) } /// Whether a runner serves this repository that is not the site's: one /// scoped to it or to its owner (#225). async fn has_own_runner(app: &Shared, row: &db::repos::RepoRow) -> sqlx::Result { Ok(db::runners::serving(&app.db, &row.owner, row.id).await?.iter().any(|r| !r.scope().is_site())) }