OXYGENHUB

Coverage / crates/hub/src/web/repo_settings.rs 80.1%

1036 of 1294 lines run, 110 of 400 functions · run #544 on main · run missed

1
//! Creating repositories and their settings pages (hub spec §6.3).
2
3
use super::settings::{csrf, require};
4
use super::{filters, lookup_role, Error, Page, RepoShell, Shell};
5
use crate::auth::{roles, Access, Role, Viewer};
6
use crate::db::{self, accounts};
7
use crate::repos::RepoName;
8
use crate::Shared;
9
use askama::Template;
10
use axum::extract::{Form, Path, Query, State};
11
use axum::http::Uri;
12
use axum::response::{IntoResponse, Redirect, Response};
13
use hub_api::Visibility;
14
use serde::Deserialize;
15
16
#[derive(Template)]
17
#[template(path = "repo_new.html")]
18
pub struct NewRepoPage {
19
    shell: Shell,
20
    owners: Vec<String>,
21
    error: Option<String>,
22
    /// The open boards of each owner the viewer may change, for "add it to
23
    /// an existing board" (#360): (owner, number, title).
24
    boards: Vec<(String, i64, String)>,
25
    /// Owners that are organisations: a board is on by default for them.
26
    orgs: Vec<String>,
27
    licences: &'static [(&'static str, &'static str)],
28
    ignores: &'static [&'static str],
29
    base_url: String,
30
}
31
32
/// The New repository page's choices for `me` (#360).
3350×
async fn new_page(app: &Shared, shell: Shell, me: &accounts::Account, error: Option<String>) -> NewRepoPage {
3450×
    let owners = owners_for(app, me).await;
3550×
    let mut boards = Vec::new();
3650×
    let mut orgs = Vec::new();
3761×
    for o in &owners {
3861×
        if accounts::org_by_name(&app.db, o).await.ok().flatten().is_some() {
3911×
            orgs.push(o.clone());
4050×
        }
4161×
        let may = crate::web::projects::rights_of(app, Some(me), o).await.ok().flatten().is_some_and(|r| r.edit);
4261×
        if may {
4361×
            for p in db::projects::of_owner(&app.db, o).await.unwrap_or_default().into_iter().filter(|p| !p.closed) {
441×
                boards.push((o.clone(), p.number, p.title));
451×
            }
460×
        }
47
    }
4850×
    NewRepoPage {
4950×
        shell,
5050×
        owners,
5150×
        error,
5250×
        boards,
5350×
        orgs,
5450×
        licences: crate::repo_setup::LICENCES,
5550×
        ignores: crate::repo_setup::IGNORES,
5650×
        base_url: app.config.base_url().trim_end_matches('/').to_string(),
5750×
    }
5850×
}
59
6053×
pub(crate) async fn owners_for(app: &Shared, me: &accounts::Account) -> Vec<String> {
6153×
    let mut owners = vec![me.username.clone()];
6253×
    if let Ok(orgs) = accounts::orgs_of(&app.db, me.id).await {
6353×
        owners.extend(orgs.into_iter().map(|(o, _)| o.name));
640×
    }
6553×
    owners
6653×
}
67
6824×
pub async fn new_form(State(app): State<Shared>, viewer: Viewer) -> Result<Page<NewRepoPage>, Error> {
6924×
    let shell = Shell::new(&app, "/new", &viewer);
7024×
    let me = require(&viewer, &shell)?;
7124×
    Ok(Page(new_page(&app, shell, &me.account, None).await))
7224×
}
73
74
#[derive(Deserialize)]
75
pub struct NameCheck {
76
    #[serde(default)]
77
    owner: String,
78
    #[serde(default)]
79
    name: String,
80
}
81
82
/// `GET /new/check?owner=&name=` (#360): the page's live check that a name
83
/// is free and valid, answering `{"ok", "message", "address"}`.
843×
pub async fn check_name(State(app): State<Shared>, viewer: Viewer, Query(q): Query<NameCheck>) -> Response {
853×
    let Some(me) = viewer.account() else {
860×
        return (axum::http::StatusCode::UNAUTHORIZED, axum::Json(serde_json::json!({"ok": false, "message": "Sign in first."})))
870×
            .into_response();
88
    };
893×
    if q.name.trim().is_empty() {
900×
        return axum::Json(serde_json::json!({"ok": false, "message": ""})).into_response();
913×
    }
923×
    match crate::repo_setup::check_name(&app, me, &q.owner, &q.name).await {
931×
        Ok(n) => axum::Json(serde_json::json!({
941×
            "ok": true,
951×
            "message": format!("{n} is free."),
961×
            "address": format!("{}{}", app.config.base_url().trim_end_matches('/'), n.url()),
971×
        }))
981×
        .into_response(),
992×
        Err(m) => axum::Json(serde_json::json!({"ok": false, "message": m})).into_response(),
100
    }
1013×
}
102
103
#[derive(Deserialize)]
104
pub struct NewRepoForm {
105
    #[serde(rename = "_csrf")]
106
    csrf_token: String,
107
    owner: String,
108
    name: String,
109
    #[serde(default)]
110
    description: String,
111
    #[serde(default = "default_visibility")]
112
    visibility: String,
113
    /// `hub` (this server's disk, the default) or `bucket` (oxygenhub#96).
114
    #[serde(default)]
115
    storage: String,
116
    #[serde(flatten)]
117
    bucket: BucketForm,
118
    /// What it starts with (#360): a README, a licence, an `.oxyignore`.
119
    #[serde(default)]
120
    readme: Option<String>,
121
    #[serde(default)]
122
    licence: String,
123
    #[serde(default)]
124
    ignore: String,
125
    /// A board with it: `new` (titled `board_title`), a board number, or none.
126
    #[serde(default)]
127
    board: String,
128
    #[serde(default)]
129
    board_title: String,
130
    /// The existing board's number, with `board=existing`.
131
    #[serde(default)]
132
    board_n: String,
133
}
134
135
/// A bucket and the credentials for it, as the New repository form and
136
/// Settings → Storage give them; only the kind the URL names is read.
137
#[derive(Deserialize, Default)]
138
pub struct BucketForm {
139
    #[serde(default)]
140
    store_url: String,
141
    #[serde(default)]
142
    access_key_id: String,
143
    #[serde(default)]
144
    secret_access_key: String,
145
    #[serde(default)]
146
    service_account_key: String,
147
    #[serde(default)]
148
    tenant_id: String,
149
    #[serde(default)]
150
    client_id: String,
151
    #[serde(default)]
152
    client_secret: String,
153
}
154
155
impl BucketForm {
1561×
    fn credentials(&self, scheme: &str) -> Result<oxy_strata::Credentials, String> {
1571×
        crate::storage::credentials_from(
1581×
            scheme,
1591×
            &self.access_key_id,
1601×
            &self.secret_access_key,
1611×
            &self.service_account_key,
1621×
            &self.tenant_id,
1631×
            &self.client_id,
1641×
            &self.client_secret,
165
        )
1661×
    }
167
}
168
16918×
fn default_visibility() -> String {
17018×
    "public".into()
17118×
}
172
17326×
pub async fn create(State(app): State<Shared>, viewer: Viewer, Form(f): Form<NewRepoForm>) -> Result<Response, Error> {
17426×
    let shell = Shell::new(&app, "/new", &viewer);
17526×
    let me = require(&viewer, &shell)?;
17626×
    csrf(&viewer, &f.csrf_token, &shell)?;
17726×
    let page = new_page(&app, shell.clone(), &me.account, None).await;
17826×
    let fail = |msg: &str| Page(NewRepoPage { error: Some(msg.into()), ..clone_page(&page) }).into_response();
17926×
    let name = match crate::repo_setup::check_name(&app, &me.account, &f.owner, &f.name).await {
18024×
        Ok(n) => n,
1812×
        Err(m) => return Ok(fail(&m)),
182
    };
18324×
    let Some(visibility) = Visibility::parse(&f.visibility) else { return Ok(fail("Visibility is public, internal or private.")) };
184
    // Its board and its starting files are checked before anything is made (#360).
18524×
    let choice = if f.board == "existing" { f.board_n.as_str() } else { f.board.as_str() };
18624×
    let board = match crate::repo_setup::Board::parse(choice, &f.board_title, &name.name) {
18724×
        Ok(b) => b,
1880×
        Err(m) => return Ok(fail(&m)),
189
    };
19024×
    if let Err(m) = crate::repo_setup::check_board(&app, &me.account, &name.owner, &board).await {
1910×
        return Ok(fail(&m));
19224×
    }
19324×
    let start =
19424×
        crate::repo_setup::Start { readme: f.readme.is_some(), licence: f.licence.trim().to_string(), ignore: f.ignore.trim().to_string() };
19524×
    let year = time::OffsetDateTime::now_utc().year();
19624×
    let holder = if me.account.display_name.trim().is_empty() { me.account.username.clone() } else { me.account.display_name.clone() };
19724×
    let files = match start.files(&name, &f.description, &holder, year) {
19824×
        Ok(files) => files,
1990×
        Err(m) => return Ok(fail(&m)),
200
    };
20124×
    if !files.is_empty() && f.storage == "bucket" {
2020×
        return Ok(fail("A repository kept in your own bucket starts empty: push its first files to it."));
20324×
    }
20424×
    if !files.is_empty() {
2051×
        if let Err(m) = crate::web::edit::signer_for(&app, me.account.id).await {
2061×
            return Ok(fail(&format!("The hub cannot write the starting files for you yet. {m}")));
2070×
        }
20823×
    }
209
    // An organisation's storage policy (oxygenhub#96).
21023×
    if let Some(org) = accounts::org_by_name(&app.db, &name.owner).await.map_err(|e| Error::from_db(shell.clone(), e))? {
2115×
        let policy = crate::storage::policy(&app.db, org.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
2125×
        let store = (f.storage == "bucket").then_some(f.bucket.store_url.as_str());
2135×
        if let Err(e) = policy.permits(&org.name, store) {
2142×
            return Ok(fail(&format!("{e}.")));
2153×
        }
21618×
    }
21721×
    if f.storage == "bucket" {
218
        // Kept in the owner's bucket (oxygenhub#96): checked before
219
        // anything is made, and nothing is left behind when it fails.
2206×
        let url = f.bucket.store_url.trim();
2216×
        let parsed = match crate::storage::check_url(url) {
2221×
            Ok(p) => p,
2235×
            Err(e) => return Ok(fail(&format!("Store: {e}."))),
224
        };
2251×
        let credentials = match f.bucket.credentials(&parsed.scheme) {
2260×
            Ok(c) => c,
2271×
            Err(e) => return Ok(fail(&format!("Credentials: {e}."))),
228
        };
2290×
        let row = db::repos::create(&app.db, &name.owner, &name.name, f.description.trim(), visibility)
2300×
            .await
2310×
            .map_err(|e| Error::from_db(shell.clone(), e))?;
2320×
        if let Err(e) = crate::storage::create(&app, &name, row.id, url, credentials).await {
2330×
            db::repos::delete(&app.db, row.id).await.ok();
2340×
            return Ok(fail(&format!("The bucket did not pass the check, so nothing was created: {e}")));
2350×
        }
2360×
        accounts::audit(
2370×
            &app.db,
2380×
            Some(&me.account),
2390×
            "repo.created",
2400×
            &name.to_string(),
2410×
            &format!("{} in {}", visibility.as_str(), parsed.plain()),
2420×
        )
2430×
        .await
2440×
        .ok();
2450×
        crate::repo_setup::link_board(&app, &row, &board, visibility == Visibility::Public)
2460×
            .await
2470×
            .map_err(|e| Error::from_db(shell.clone(), e))?;
2480×
        return Ok(Redirect::to(&name.url()).into_response());
24915×
    }
25015×
    app.repos.create_async(&name, None).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
25115×
    let row = db::repos::create(&app.db, &name.owner, &name.name, f.description.trim(), visibility)
25215×
        .await
25315×
        .map_err(|e| Error::from_db(shell.clone(), e))?;
25415×
    accounts::audit(&app.db, Some(&me.account), "repo.created", &name.to_string(), visibility.as_str()).await.ok();
25515×
    crate::repo_setup::link_board(&app, &row, &board, visibility == Visibility::Public)
25615×
        .await
25715×
        .map_err(|e| Error::from_db(shell.clone(), e))?;
25815×
    if let Err(e) = crate::repo_setup::commit_start(&app, &row, &me.account, files).await {
2590×
        tracing::warn!("starting files for {name}: {e}");
26015×
    }
26115×
    Ok(Redirect::to(&name.url()).into_response())
26226×
}
263
26411×
fn clone_page(p: &NewRepoPage) -> NewRepoPage {
26511×
    NewRepoPage {
26611×
        shell: p.shell.clone(),
26711×
        owners: p.owners.clone(),
26811×
        error: p.error.clone(),
26911×
        boards: p.boards.clone(),
27011×
        orgs: p.orgs.clone(),
27111×
        licences: p.licences,
27211×
        ignores: p.ignores,
27311×
        base_url: p.base_url.clone(),
27411×
    }
27511×
}
276
277
/// One page of a repository's settings (#278), at
278
/// `/{owner}/{name}/settings/{slug}`; `/settings` is General.
279
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
280
pub enum Section {
281
    General,
282
    Collaborators,
283
    Landing,
284
    Checks,
285
    PushProtection,
286
    Pipelines,
287
    Runs,
288
    Gates,
289
    Runners,
290
    Variables,
291
    Secrets,
292
    Coverage,
293
    Webhooks,
294
    Assistants,
295
    GitMirror,
296
    Diffusion,
297
    Storage,
298
    Backup,
299
    Advanced,
300
}
301
302
/// The sidebar: each group's heading and its pages, in order.
303
const GROUPS: &[(&str, &[Section])] = &[
304
    ("General", &[Section::General, Section::Collaborators]),
305
    ("Code", &[Section::Landing, Section::Checks, Section::PushProtection]),
306
    (
307
        "Flow",
308
        &[Section::Pipelines, Section::Runs, Section::Gates, Section::Runners, Section::Variables, Section::Secrets, Section::Coverage],
309
    ),
310
    ("Integrations", &[Section::Webhooks, Section::Assistants, Section::GitMirror, Section::Diffusion]),
311
    ("Data", &[Section::Storage, Section::Backup]),
312
    ("Danger zone", &[Section::Advanced]),
313
];
314
315
impl Section {
3165908×
    pub fn slug(self) -> &'static str {
3175908×
        match self {
318405×
            Section::General => "general",
319372×
            Section::Collaborators => "collaborators",
320388×
            Section::Landing => "landing",
321385×
            Section::Checks => "checks",
322392×
            Section::PushProtection => "push-protection",
323366×
            Section::Pipelines => "flow",
324343×
            Section::Runs => "runs",
325331×
            Section::Gates => "gates",
326302×
            Section::Runners => "runners",
327299×
            Section::Variables => "variables",
328291×
            Section::Secrets => "secrets",
329313×
            Section::Coverage => "coverage",
330289×
            Section::Webhooks => "webhooks",
331274×
            Section::Assistants => "assistants",
332296×
            Section::GitMirror => "git-mirror",
333291×
            Section::Diffusion => "diffusion",
33421×
            Section::Storage => "storage",
335289×
            Section::Backup => "backup",
336261×
            Section::Advanced => "advanced",
337
        }
3385908×
    }
339
3402421×
    pub fn title(self) -> &'static str {
3412421×
        match self {
342133×
            Section::General => "General",
343126×
            Section::Collaborators => "Collaborators",
344136×
            Section::Landing => "Landing rules",
345138×
            Section::Checks => "Required checks",
346153×
            Section::PushProtection => "Push protection",
347152×
            Section::Pipelines => "Pipelines",
348141×
            Section::Runs => "Runs & priority",
349136×
            Section::Gates => "Gates & agents",
350126×
            Section::Runners => "Runners",
351125×
            Section::Variables => "Variables",
352123×
            Section::Secrets => "Secrets",
353136×
            Section::Coverage => "Coverage",
354129×
            Section::Webhooks => "Webhooks",
355124×
            Section::Assistants => "AI assistants",
356137×
            Section::GitMirror => "Git mirror",
357136×
            Section::Diffusion => "Diffusion",
3580×
            Section::Storage => "Storage",
359139×
            Section::Backup => "Backup",
360131×
            Section::Advanced => "Advanced",
361
        }
3622421×
    }
363
364133×
    pub fn parse(slug: &str) -> Option<Section> {
3651232×
        GROUPS.iter().flat_map(|(_, pages)| pages.iter().copied()).find(|s| s.slug() == slug)
366133×
    }
367
368
    /// Whether only the repository's admins use it; its maintainers use
369
    /// the rest. The roles are those of each page's forms.
3702668×
    fn admin_only(self) -> bool {
3711552×
        matches!(
3722668×
            self,
373
            Section::Collaborators
374
                | Section::Runners
375
                | Section::Variables
376
                | Section::Secrets
377
                | Section::Webhooks
378
                | Section::Assistants
379
                | Section::Storage
380
                | Section::Advanced
381
        )
3822668×
    }
383
384
    /// Whether this viewer has the page: Storage only for a repository kept
385
    /// in a bucket.
3862668×
    fn shown(self, role: Role, in_bucket: bool) -> bool {
3872668×
        (!self.admin_only() || role == Role::Admin) && (self != Section::Storage || in_bucket)
3882668×
    }
389
}
390
391
/// The sidebar a viewer sees: the groups with at least one page for them.
392133×
fn sidebar(role: Role, in_bucket: bool) -> Vec<(&'static str, Vec<Section>)> {
393133×
    GROUPS
394133×
        .iter()
3952527×
        .map(|(group, pages)| (*group, pages.iter().copied().filter(|s| s.shown(role, in_bucket)).collect::<Vec<_>>()))
396798×
        .filter(|(_, pages)| !pages.is_empty())
397133×
        .collect()
398133×
}
399
400
/// Back to the page that holds a settings form, with what happened (#278).
40169×
fn back(row: &db::repos::RepoRow, section: Section, notice: Option<String>, error: Option<String>) -> Result<Response, Error> {
40269×
    let mut to = format!("/{}/{}/settings/{}", row.owner, row.name, section.slug());
40369×
    if let Some(n) = notice {
40451×
        to.push_str(&format!("?notice={}", super::settings::urlencode(&n)));
40551×
    } else if let Some(e) = error {
40618×
        to.push_str(&format!("?error={}", super::settings::urlencode(&e)));
40718×
    }
40869×
    Ok(Redirect::to(&to).into_response())
40969×
}
410
411
#[derive(Template)]
412
#[template(path = "repo_settings.html")]
413
pub struct RepoSettingsPage {
414
    /// The page shown, and the sidebar's groups of pages.
415
    section: Section,
416
    nav: Vec<(&'static str, Vec<Section>)>,
417
    shell: Shell,
418
    repo: RepoShell,
419
    row: db::repos::RepoRow,
420
    collaborators: Vec<accounts::Collaborator>,
421
    teams: Vec<accounts::Team>,
422
    role: Role,
423
    requirements: db::proposals::Requirements,
424
    hooks: Vec<super::webhooks::HookRow>,
425
    required_checks: String,
426
    /// Whether hub.toml alone would run Flow here, and whether it runs.
427
    flow_default: bool,
428
    /// hub.toml's `[diffusion] follow` (#148).
429
    diffusion_site: String,
430
    flow_enabled: bool,
431
    site_admin: bool,
432
    /// Whether the repository or its owner has a runner of its own (#225).
433
    own_runner: bool,
434
    notice: Option<String>,
435
    error: Option<String>,
436
    /// Runners that may take this repository's jobs: (runner, what it
437
    /// serves, whether it is this repository's own).
438
    runners: Vec<(db::runners::Runner, String, bool)>,
439
    /// A registration token just made, shown once.
440
    runner_token: Option<String>,
441
    /// The repository's AI assistants and prompt (#97).
442
    assistants: super::assistants::Panel,
443
    /// The repository's Flow variables (#102).
444
    variables: super::variables::Panel,
445
    /// The repository's Flow secrets (#107).
446
    secrets: super::secrets::Panel,
447
    /// Where its data lives, when in a bucket (oxygenhub#96).
448
    storage: Option<crate::storage::Storage>,
449
    /// The coverage check's rules (#113).
450
    coverage: crate::coverage::Rules,
451
    /// Its owner's backup, when one is set up (docs/backups.md §5).
452
    backup_owner: Option<crate::backups::Setting>,
453
    /// Push protection (#268): the findings of recent refused pushes not
454
    /// yet allowed, and the fingerprints allowed.
455
    push_findings: Vec<db::push_protection::FindingRow>,
456
    secret_bypasses: Vec<db::push_protection::Bypass>,
457
    /// The history scan (#277): its progress, its open findings and those
458
    /// marked resolved, and whether the hub runs it at all.
459
    history_scan: Option<db::push_protection::ScanRow>,
460
    history_findings: Vec<db::push_protection::HistoryRow>,
461
    resolved_findings: Vec<db::push_protection::HistoryRow>,
462
    history_scan_on: bool,
463
}
464
465
impl RepoSettingsPage {
4666×
    fn backup_choices(&self) -> &'static [(&'static str, &'static str)] {
4676×
        crate::backups::REPO_CHOICES
4686×
    }
469
}
470
471
/// One settings page, or not found when the viewer's role does not have it.
472141×
async fn settings_page(
473141×
    app: &Shared,
474141×
    shell: Shell,
475141×
    row: db::repos::RepoRow,
476141×
    role: Role,
477141×
    section: Section,
478141×
    notice: Option<String>,
479141×
    error: Option<String>,
480141×
) -> Result<Page<RepoSettingsPage>, Error> {
481141×
    let storage = crate::storage::of(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
482141×
    if !section.shown(role, storage.is_some()) {
4838×
        return Err(Error::not_found(shell));
484133×
    }
485133×
    let nav = sidebar(role, storage.is_some());
486133×
    let collaborators = accounts::collaborators(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
487133×
    let teams = match accounts::org_by_name(&app.db, &row.owner).await.map_err(|e| Error::from_db(shell.clone(), e))? {
48810×
        Some(org) => accounts::teams(&app.db, org.id).await.map_err(|e| Error::from_db(shell.clone(), e))?,
489123×
        None => vec![],
490
    };
491133×
    let repo = RepoShell::new(app, &row, "settings").with_role(role);
492133×
    let requirements = db::proposals::requirements(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
493133×
    let hooks = db::hooks::hooks_for_repo(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
494133×
    let hooks = super::webhooks::rows(app, hooks).await.map_err(|e| Error::from_db(shell.clone(), e))?;
495133×
    let required_checks = db::hooks::required_checks(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.join(", ");
496133×
    let flow_default = app.config.flow.allows(&row.owner, &row.name);
497133×
    let diffusion_site = app.config.diffusion.follow.clone();
498133×
    let flow_enabled = crate::flow::enabled(app, &row);
499133×
    let site_admin = shell.is_admin;
500133×
    let own_runner = has_own_runner(app, &row).await.map_err(|e| Error::from_db(shell.clone(), e))?;
501133×
    let runners = db::runners::serving(&app.db, &row.owner, row.id)
502133×
        .await
503133×
        .map_err(|e| Error::from_db(shell.clone(), e))?
504133×
        .into_iter()
505133×
        .map(|r| {
5066×
            let own = r.scope_repo_id == Some(row.id);
5076×
            let serves = if own {
5084×
                "this repository".to_string()
5092×
            } else if r.scope_owner.is_empty() {
5102×
                "the whole site".to_string()
511
            } else {
5120×
                format!("{}'s repositories", r.scope_owner)
513
            };
5146×
            (r, serves, own)
5156×
        })
516133×
        .collect();
517133×
    let assistants = super::assistants::panel(app, &repo_assistants(&row)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
518133×
    let variables = super::variables::panel(app, &repo_assistants(&row)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
519133×
    let secrets = super::secrets::panel(app, &repo_assistants(&row)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
520133×
    let coverage = crate::coverage::rules(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
521133×
    let backup_owner = crate::backups::setting(&app.db, &row.owner).await.map_err(|e| Error::from_db(shell.clone(), e))?;
522133×
    let push_findings = db::push_protection::recent(&app.db, row.id, 50).await.map_err(|e| Error::from_db(shell.clone(), e))?;
523133×
    let secret_bypasses = db::push_protection::bypasses(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
524133×
    let history_scan = db::push_protection::scan_of(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
525133×
    let history_findings = db::push_protection::history(&app.db, row.id, 200).await.map_err(|e| Error::from_db(shell.clone(), e))?;
526133×
    let resolved_findings = db::push_protection::resolved(&app.db, row.id, 50).await.map_err(|e| Error::from_db(shell.clone(), e))?;
527133×
    let history_scan_on = crate::history_scan::Budget::of(&app.config).is_some();
528133×
    Ok(Page(RepoSettingsPage {
529133×
        section,
530133×
        nav,
531133×
        push_findings,
532133×
        secret_bypasses,
533133×
        history_scan,
534133×
        history_findings,
535133×
        resolved_findings,
536133×
        history_scan_on,
537133×
        backup_owner,
538133×
        coverage,
539133×
        storage,
540133×
        assistants,
541133×
        variables,
542133×
        secrets,
543133×
        runners,
544133×
        runner_token: None,
545133×
        shell,
546133×
        repo,
547133×
        row,
548133×
        collaborators,
549133×
        teams,
550133×
        role,
551133×
        requirements,
552133×
        hooks,
553133×
        required_checks,
554133×
        flow_default,
555133×
        diffusion_site,
556133×
        flow_enabled,
557133×
        site_admin,
558133×
        own_runner,
559133×
        notice,
560133×
        error,
561133×
    }))
562141×
}
563
564
/// What a settings form did, carried to the page it redirects to.
565
#[derive(Deserialize, Default)]
566
pub struct Outcome {
567
    #[serde(default)]
568
    notice: Option<String>,
569
    #[serde(default)]
570
    error: Option<String>,
571
}
572
573
/// `GET /{owner}/{name}/settings`: the General page.
57412×
pub async fn settings(
57512×
    State(app): State<Shared>,
57612×
    viewer: Viewer,
57712×
    Path((owner, name)): Path<(String, String)>,
57812×
    Query(q): Query<Outcome>,
57912×
) -> Result<Page<RepoSettingsPage>, Error> {
58012×
    let path = format!("/{owner}/{name}/settings");
58112×
    show(&app, &viewer, &path, &owner, &name, Section::General, q).await
58212×
}
583
584
/// `GET /{owner}/{name}/settings/{page}` (#278): one page of the settings.
585
/// Some pages share their path with their form's `POST`, so the page is
586
/// the path's last segment.
587133×
pub async fn page(
588133×
    State(app): State<Shared>,
589133×
    viewer: Viewer,
590133×
    Path(params): Path<std::collections::HashMap<String, String>>,
591133×
    uri: Uri,
592133×
    Query(q): Query<Outcome>,
593133×
) -> Result<Page<RepoSettingsPage>, Error> {
594133×
    let (owner, name) = (params.get("owner").cloned().unwrap_or_default(), params.get("name").cloned().unwrap_or_default());
595133×
    let path = format!("/{owner}/{name}/settings");
596133×
    let slug = uri.path().rsplit('/').next().unwrap_or("");
597133×
    let Some(section) = Section::parse(slug) else {
5981×
        return Err(Error::not_found(Shell::new(&app, &path, &viewer)));
599
    };
600132×
    show(&app, &viewer, &format!("{path}/{slug}"), &owner, &name, section, q).await
601133×
}
602
603
/// A settings page for the repository's maintainers, or its admins: on an
604
/// archived repository, only its admins, who can bring it back.
605144×
async fn show(
606144×
    app: &Shared,
607144×
    viewer: &Viewer,
608144×
    path: &str,
609144×
    owner: &str,
610144×
    name: &str,
611144×
    section: Section,
612144×
    q: Outcome,
613144×
) -> Result<Page<RepoSettingsPage>, Error> {
614144×
    let shell = Shell::new(app, path, viewer);
615144×
    let (_, row, role) = lookup_role(app, &shell, viewer, owner, name, Access::Read).await?;
616144×
    if role < Role::Maintain {
6175×
        return Err(Error::not_found(shell));
618139×
    }
619139×
    if row.archived && role < Role::Admin {
6200×
        return Err(super::archived(&shell));
621139×
    }
622139×
    settings_page(app, shell, row, role, section, q.notice, q.error).await
623144×
}
624
625
#[derive(Deserialize)]
626
pub struct GeneralForm {
627
    #[serde(rename = "_csrf")]
628
    csrf_token: String,
629
    description: String,
630
    default_bookmark: String,
631
    visibility: String,
632
    #[serde(default)]
633
    topics: String,
634
}
635
6366×
pub async fn update(
6376×
    State(app): State<Shared>,
6386×
    viewer: Viewer,
6396×
    Path((owner, name)): Path<(String, String)>,
6406×
    Form(f): Form<GeneralForm>,
6416×
) -> Result<Response, Error> {
6426×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
6436×
    let (repo, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
6446×
    csrf(&viewer, &f.csrf_token, &shell)?;
6456×
    let e = |e| Error::from_db(shell.clone(), e);
646
    // A new default must be a bookmark the repository has, or every page
647
    // that opens it finds nothing; an empty repository may name any, for
648
    // its first push to create.
6496×
    let wanted = f.default_bookmark.trim().to_string();
6506×
    if wanted != row.default_bookmark {
6512×
        let names: Vec<String> = app
6522×
            .repos
6532×
            .read(&repo, |r, _| Ok(r.view()?.bookmarks.keys().cloned().collect::<Vec<String>>()))
6542×
            .await
6552×
            .map_err(|err| Error::from_repo(shell.clone(), err))?;
6562×
        if !names.is_empty() && !names.contains(&wanted) {
6572×
            let mut some: Vec<&str> = names.iter().map(String::as_str).filter(|n| *n == "main" || *n == "master").collect();
6582×
            some.extend(names.iter().map(String::as_str).filter(|n| *n != "main" && *n != "master").take(8 - some.len()));
6591×
            let more = if names.len() > some.len() { format!(" and {} more", names.len() - some.len()) } else { String::new() };
6601×
            let msg = format!("{wanted} is not a bookmark here yet: push it first, or choose one of {}{more}.", some.join(", "));
6611×
            return back(&row, Section::General, None, Some(msg));
6621×
        }
6634×
    }
6645×
    let topics = match db::repos::parse_topics(&f.topics) {
6654×
        Ok(t) => t,
6661×
        Err(msg) => return back(&row, Section::General, None, Some(msg)),
667
    };
6684×
    db::repos::set_description(&app.db, row.id, f.description.trim()).await.map_err(e)?;
6694×
    db::repos::set_topics(&app.db, row.id, &topics).await.map_err(e)?;
6704×
    if crate::repos::names::valid_segment(f.default_bookmark.trim()) {
6714×
        db::repos::set_default_bookmark(&app.db, row.id, f.default_bookmark.trim()).await.map_err(e)?;
6720×
    }
6734×
    if role >= Role::Admin {
6744×
        if let Some(v) = Visibility::parse(&f.visibility) {
6754×
            db::repos::set_visibility(&app.db, row.id, v).await.map_err(e)?;
6760×
        }
6770×
    }
6784×
    accounts::audit(&app.db, viewer.account(), "repo.updated", &format!("{owner}/{name}"), "").await.ok();
6794×
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(e)?.unwrap_or(row);
6804×
    back(&row, Section::General, Some("Saved.".into()), None)
6816×
}
682
683
#[derive(Deserialize)]
684
pub struct CollaboratorForm {
685
    #[serde(rename = "_csrf")]
686
    csrf_token: String,
687
    #[serde(default)]
688
    username: String,
689
    #[serde(default)]
690
    team: String,
691
    #[serde(default)]
692
    role: String,
693
    #[serde(default)]
694
    remove: Option<String>,
695
}
696
6975×
pub async fn collaborator(
6985×
    State(app): State<Shared>,
6995×
    viewer: Viewer,
7005×
    Path((owner, name)): Path<(String, String)>,
7015×
    Form(f): Form<CollaboratorForm>,
7025×
) -> Result<Response, Error> {
7035×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
7045×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
7055×
    csrf(&viewer, &f.csrf_token, &shell)?;
7065×
    let e = |e| Error::from_db(shell.clone(), e);
7075×
    let (account_id, team_id, label) = if !f.username.trim().is_empty() {
7085×
        match accounts::account_by_name(&app.db, f.username.trim()).await.map_err(e)? {
7095×
            Some(a) => (Some(a.id), None, a.username),
7100×
            None => return back(&row, Section::Collaborators, None, Some("No such account.".into())),
711
        }
7120×
    } else if !f.team.trim().is_empty() {
7130×
        let org = accounts::org_by_name(&app.db, &row.owner).await.map_err(e)?;
7140×
        let team = match org {
7150×
            Some(o) => accounts::team_by_name(&app.db, o.id, f.team.trim()).await.map_err(e)?,
7160×
            None => None,
717
        };
7180×
        match team {
7190×
            Some(t) => (None, Some(t.id), format!("team {}", t.name)),
7200×
            None => return back(&row, Section::Collaborators, None, Some("No such team in this organisation.".into())),
721
        }
722
    } else {
7230×
        return back(&row, Section::Collaborators, None, Some("Name an account or a team.".into()));
724
    };
7255×
    if f.remove.is_some() {
7260×
        accounts::remove_collaborator(&app.db, row.id, account_id, team_id).await.map_err(e)?;
7270×
        accounts::audit(&app.db, viewer.account(), "repo.collaborator.removed", &format!("{owner}/{name}"), &label).await.ok();
7280×
        return back(&row, Section::Collaborators, Some(format!("Removed {label}.")), None);
7295×
    }
7305×
    let Some(new_role) = Role::parse(&f.role) else {
7310×
        return back(&row, Section::Collaborators, None, Some("Pick a role.".into()));
732
    };
7335×
    accounts::set_collaborator(&app.db, row.id, account_id, team_id, new_role.as_str()).await.map_err(e)?;
7345×
    accounts::audit(
7355×
        &app.db,
7365×
        viewer.account(),
7375×
        "repo.collaborator.set",
7385×
        &format!("{owner}/{name}"),
7395×
        &format!("{label} {}", new_role.as_str()),
7405×
    )
7415×
    .await
7425×
    .ok();
7435×
    back(&row, Section::Collaborators, Some(format!("{label} now has {} access.", new_role.as_str())), None)
7445×
}
745
746
#[derive(Deserialize)]
747
pub struct TransferForm {
748
    #[serde(rename = "_csrf")]
749
    csrf_token: String,
750
    owner: String,
751
    confirm: String,
752
}
753
754
/// `POST /{owner}/{name}/settings/transfer`: move the repository to a
755
/// user or organisation the admin may create repositories under (#108).
756
/// The old URL redirects and oxy remotes keep working.
7573×
pub async fn transfer(
7583×
    State(app): State<Shared>,
7593×
    viewer: Viewer,
7603×
    Path((owner, name)): Path<(String, String)>,
7613×
    Form(f): Form<TransferForm>,
7623×
) -> Result<Response, Error> {
7633×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
7643×
    let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
7653×
    csrf(&viewer, &f.csrf_token, &shell)?;
7663×
    let me = viewer.account().cloned().ok_or_else(|| Error::not_found(shell.clone()))?;
7673×
    let fail = |msg: &str| back(&row, Section::Advanced, None, Some(msg.into()));
7683×
    if f.confirm.trim() != repo.to_string() {
7691×
        return fail("Type the repository's full name to confirm.");
7702×
    }
7712×
    let to = f.owner.trim().trim_start_matches('@').to_string();
7722×
    let Some(dest) = RepoName::new(&to, &row.name) else {
7730×
        return fail("That is not an owner name.");
774
    };
7752×
    if dest.owner == row.owner {
7760×
        return fail("The repository is already there.");
7772×
    }
7782×
    let e = |e| Error::from_db(shell.clone(), e);
7792×
    let known = accounts::account_by_name(&app.db, &dest.owner).await.map_err(e)?.is_some()
7802×
        || accounts::org_by_name(&app.db, &dest.owner).await.map_err(e)?.is_some();
7812×
    if !known {
7821×
        return fail("There is no user or organisation of that name.");
7831×
    }
7841×
    if !roles::may_create_under(&app.db, &me, &dest.owner).await.map_err(e)? {
7850×
        return fail("You cannot create repositories under that owner, so you cannot move one there.");
7861×
    }
7871×
    if db::repos::get(&app.db, &dest.owner, &dest.name).await.map_err(e)?.is_some() || app.repos.exists(&dest) {
7880×
        return fail("That owner already has a repository of this name.");
7891×
    }
790
    {
7911×
        let _guard = app.repos.exclusive_guard(&repo).await;
7921×
        app.repos.rename(&repo, &dest).map_err(|e| Error::from_repo(shell.clone(), e))?;
7931×
        crate::storage::renamed(&app, &repo, &dest);
7941×
        if app.wikis.exists(&repo) {
7950×
            app.wikis.rename(&repo, &dest).ok();
7961×
        }
7971×
        db::repos::transfer(&app.db, row.id, &row.owner, &row.name, &dest.owner).await.map_err(e)?;
7981×
        let mirror = crate::mirror::mirror_dir(&app, &repo);
7991×
        if mirror.is_dir() {
8000×
            let to = crate::mirror::mirror_dir(&app, &dest);
8010×
            if let Some(parent) = to.parent() {
8020×
                std::fs::create_dir_all(parent).ok();
8030×
            }
8040×
            std::fs::rename(&mirror, &to).ok();
8051×
        }
806
    }
8071×
    accounts::audit(&app.db, Some(&me), "repo.transferred", &repo.to_string(), &dest.to_string()).await.ok();
8081×
    let notice = super::settings::urlencode(&format!("Moved from {repo}; the old address redirects here."));
8091×
    Ok(Redirect::to(&format!("{}/settings/{}?notice={notice}", dest.url(), Section::Advanced.slug())).into_response())
8103×
}
811
812
#[derive(Deserialize)]
813
pub struct ArchiveForm {
814
    #[serde(rename = "_csrf")]
815
    csrf_token: String,
816
    /// `archive` or `unarchive`.
817
    action: String,
818
    #[serde(default)]
819
    reason: String,
820
}
821
822
/// `POST /{owner}/{name}/settings/archive`: read-only, with a reason, or
823
/// back again (#108).
8243×
pub async fn archive(
8253×
    State(app): State<Shared>,
8263×
    viewer: Viewer,
8273×
    Path((owner, name)): Path<(String, String)>,
8283×
    Form(f): Form<ArchiveForm>,
8293×
) -> Result<Response, Error> {
8303×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
8313×
    let (repo, mut row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
8323×
    csrf(&viewer, &f.csrf_token, &shell)?;
8333×
    let archive = match f.action.as_str() {
8343×
        "archive" => true,
8352×
        "unarchive" => false,
8361×
        _ => return back(&row, Section::Advanced, None, Some("Unknown action.".into())),
837
    };
8382×
    let reason: String = f.reason.trim().chars().take(500).collect();
8392×
    db::repos::set_archived(&app.db, row.id, archive, &reason).await.map_err(|e| Error::from_db(shell.clone(), e))?;
8402×
    accounts::audit(&app.db, viewer.account(), if archive { "repo.archived" } else { "repo.unarchived" }, &repo.to_string(), &reason)
8412×
        .await
8422×
        .ok();
8432×
    row.archived = archive;
8442×
    row.archived_reason = if archive { reason } else { String::new() };
8452×
    let notice = if archive {
8461×
        "The repository is archived: read-only until it is brought back."
847
    } else {
8481×
        "The repository is back: writes are allowed again."
849
    };
8502×
    back(&row, Section::Advanced, Some(notice.into()), None)
8513×
}
852
853
/// `POST /{owner}/{name}/settings/wiki/delete`: the wiki to the trash (#104).
8541×
pub async fn wiki_delete(
8551×
    State(app): State<Shared>,
8561×
    viewer: Viewer,
8571×
    Path((owner, name)): Path<(String, String)>,
8581×
    Form(f): Form<DeleteForm>,
8591×
) -> Result<Response, Error> {
8601×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
8611×
    let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
8621×
    csrf(&viewer, &f.csrf_token, &shell)?;
8631×
    if f.confirm.trim() != repo.to_string() {
8640×
        return back(&row, Section::Advanced, None, Some("Type the repository's full name to confirm.".into()));
8651×
    }
8661×
    if !crate::wiki::exists(&app, &repo) {
8670×
        return back(&row, Section::Advanced, None, Some("This repository has no wiki.".into()));
8681×
    }
8691×
    let _guard = app.wikis.exclusive_guard(&repo).await;
8701×
    crate::wiki::trash(&app, &repo).map_err(|e| Error::from_repo(shell.clone(), e))?;
8711×
    drop(_guard);
8721×
    accounts::audit(&app.db, viewer.account(), "wiki.data_deleted", &repo.to_string(), "moved to trash").await.ok();
8731×
    back(&row, Section::Advanced, Some("The wiki is in the trash; a new first page starts a fresh one.".into()), None)
8741×
}
875
876
#[derive(Deserialize)]
877
pub struct DeleteForm {
878
    #[serde(rename = "_csrf")]
879
    csrf_token: String,
880
    confirm: String,
881
}
882
8831×
pub async fn delete(
8841×
    State(app): State<Shared>,
8851×
    viewer: Viewer,
8861×
    Path((owner, name)): Path<(String, String)>,
8871×
    Form(f): Form<DeleteForm>,
8881×
) -> Result<Response, Error> {
8891×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
8901×
    let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
8911×
    csrf(&viewer, &f.csrf_token, &shell)?;
8921×
    if f.confirm.trim() != repo.to_string() {
8930×
        return back(&row, Section::Advanced, None, Some("Type the repository's full name to delete it.".into()));
8941×
    }
8951×
    let _guard = app.repos.exclusive_guard(&repo).await;
8961×
    app.repos.trash(&repo).map_err(|e| Error::from_repo(shell.clone(), e))?;
897
    // A repository kept in a bucket leaves its data there: the owner's.
8981×
    crate::storage::removed(&app, &repo);
8991×
    crate::wiki::trash(&app, &repo).ok();
9001×
    db::repos::delete(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
9011×
    crate::assistants::delete_scope(&app.db, crate::levels::Scope::Repo(row.id)).await.ok();
9021×
    crate::variables::delete_scope(&app.db, crate::levels::Scope::Repo(row.id)).await.ok();
9031×
    crate::secrets::delete_scope(&app.db, crate::levels::Scope::Repo(row.id)).await.ok();
9041×
    accounts::audit(&app.db, viewer.account(), "repo.deleted", &repo.to_string(), "moved to trash").await.ok();
9051×
    Ok(Redirect::to(&format!("/{owner}")).into_response())
9061×
}
907
908
#[derive(Deserialize)]
909
pub struct RequirementsForm {
910
    #[serde(rename = "_csrf")]
911
    csrf_token: String,
912
    #[serde(default)]
913
    required_approvals: i64,
914
    #[serde(default)]
915
    require_owners: Option<String>,
916
    #[serde(default)]
917
    allow_conflicts: Option<String>,
918
}
919
9202×
pub async fn requirements(
9212×
    State(app): State<Shared>,
9222×
    viewer: Viewer,
9232×
    Path((owner, name)): Path<(String, String)>,
9242×
    Form(f): Form<RequirementsForm>,
9252×
) -> Result<Response, Error> {
9262×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
9272×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
9282×
    csrf(&viewer, &f.csrf_token, &shell)?;
9292×
    let r = db::proposals::Requirements {
9302×
        required_approvals: f.required_approvals.max(0),
9312×
        require_owners: f.require_owners.is_some(),
9322×
        allow_conflicts: f.allow_conflicts.is_some(),
9332×
    };
9342×
    db::proposals::set_requirements(&app.db, row.id, r).await.map_err(|e| Error::from_db(shell.clone(), e))?;
9352×
    accounts::audit(
9362×
        &app.db,
9372×
        viewer.account(),
9382×
        "repo.requirements",
9392×
        &format!("{owner}/{name}"),
9402×
        &format!("{} approvals, owners {}, conflicts {}", r.required_approvals, r.require_owners, r.allow_conflicts),
9412×
    )
9422×
    .await
9432×
    .ok();
9442×
    back(&row, Section::Landing, Some("Landing requirements saved.".into()), None)
9452×
}
946
9476×
pub async fn hooks(
9486×
    State(app): State<Shared>,
9496×
    viewer: Viewer,
9506×
    Path((owner, name)): Path<(String, String)>,
9516×
    Form(f): Form<super::webhooks::HookForm>,
9526×
) -> Result<Response, Error> {
9536×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
9546×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
9556×
    csrf(&viewer, &f.csrf_token, &shell)?;
9566×
    let scope = super::webhooks::Scope::Repo { id: row.id, slug: format!("{owner}/{name}") };
9576×
    match super::webhooks::apply(&app, &viewer, &scope, f).await.map_err(|e| Error::from_db(shell.clone(), e))? {
9585×
        Ok(notice) => back(&row, Section::Webhooks, Some(notice), None),
9591×
        Err(error) => back(&row, Section::Webhooks, None, Some(error)),
960
    }
9616×
}
962
9630×
pub async fn deliveries(
9640×
    State(app): State<Shared>,
9650×
    viewer: Viewer,
9660×
    Path((owner, name, id)): Path<(String, String, i64)>,
9670×
) -> Result<Page<super::webhooks::DeliveriesPage>, Error> {
9680×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings/hooks/{id}/deliveries"), &viewer);
9690×
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
9700×
    let scope = super::webhooks::Scope::Repo { id: row.id, slug: format!("{owner}/{name}") };
9710×
    super::webhooks::deliveries_page(&app, shell, &scope, Some(RepoShell::new(&app, &row, "settings").with_role(role)), id).await
9720×
}
973
9740×
pub async fn redeliver(
9750×
    State(app): State<Shared>,
9760×
    viewer: Viewer,
9770×
    Path((owner, name, id, delivery)): Path<(String, String, i64, i64)>,
9780×
    Form(f): Form<super::session::CsrfForm>,
9790×
) -> Result<Page<super::webhooks::DeliveriesPage>, Error> {
9800×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings/hooks/{id}/deliveries"), &viewer);
9810×
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
9820×
    csrf(&viewer, &f.csrf, &shell)?;
9830×
    let scope = super::webhooks::Scope::Repo { id: row.id, slug: format!("{owner}/{name}") };
9840×
    super::webhooks::redeliver(&app, &scope, id, delivery).await.map_err(|e| Error::from_db(shell.clone(), e))?;
9850×
    super::webhooks::deliveries_page(&app, shell, &scope, Some(RepoShell::new(&app, &row, "settings").with_role(role)), id).await
9860×
}
987
988
#[derive(Deserialize)]
989
pub struct ChecksForm {
990
    #[serde(rename = "_csrf")]
991
    csrf_token: String,
992
    #[serde(default)]
993
    required_checks: String,
994
}
995
9962×
pub async fn required_checks(
9972×
    State(app): State<Shared>,
9982×
    viewer: Viewer,
9992×
    Path((owner, name)): Path<(String, String)>,
10002×
    Form(f): Form<ChecksForm>,
10012×
) -> Result<Response, Error> {
10022×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
10032×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
10042×
    csrf(&viewer, &f.csrf_token, &shell)?;
10052×
    let names: Vec<String> = f.required_checks.split(',').map(str::trim).filter(|s| !s.is_empty()).map(String::from).collect();
10062×
    db::hooks::set_required_checks(&app.db, row.id, &names).await.map_err(|e| Error::from_db(shell.clone(), e))?;
10072×
    back(&row, Section::Checks, Some("Required checks saved.".into()), None)
10082×
}
1009
1010
#[derive(Deserialize)]
1011
pub struct CoverageForm {
1012
    #[serde(rename = "_csrf")]
1013
    csrf_token: String,
1014
    #[serde(default)]
1015
    min: String,
1016
    #[serde(default)]
1017
    max_drop: String,
1018
}
1019
1020
/// Settings → Coverage (#113): the rules the `coverage` check applies.
10211×
pub async fn coverage(
10221×
    State(app): State<Shared>,
10231×
    viewer: Viewer,
10241×
    Path((owner, name)): Path<(String, String)>,
10251×
    Form(f): Form<CoverageForm>,
10261×
) -> Result<Response, Error> {
10271×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
10281×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
10291×
    csrf(&viewer, &f.csrf_token, &shell)?;
10302×
    let number = |v: &str, what: &str, hi: f64| -> Result<Option<f64>, String> {
10312×
        let v = v.trim().trim_end_matches('%').trim();
10322×
        if v.is_empty() {
10331×
            return Ok(None);
10341×
        }
10351×
        match v.parse::<f64>() {
10361×
            Ok(n) if (0.0..=hi).contains(&n) => Ok(Some(n)),
10370×
            _ => Err(format!("{what} is a number from 0 to {hi}, or empty for no rule.")),
1038
        }
10392×
    };
10401×
    let rules = match (number(&f.min, "The minimum", 100.0), number(&f.max_drop, "The largest drop", 100.0)) {
10411×
        (Ok(min), Ok(max_drop)) => crate::coverage::Rules { min, max_drop },
10420×
        (Err(e), _) | (_, Err(e)) => return back(&row, Section::Coverage, None, Some(e)),
1043
    };
10441×
    crate::coverage::set_rules(&app.db, row.id, rules).await.map_err(|e| Error::from_db(shell.clone(), e))?;
10451×
    accounts::audit(
10461×
        &app.db,
10471×
        viewer.account(),
10481×
        "repo.coverage_rules",
10491×
        &format!("{owner}/{name}"),
10501×
        &format!("min {:?}, max drop {:?}", rules.min, rules.max_drop),
10511×
    )
10521×
    .await
10531×
    .ok();
10541×
    back(&row, Section::Coverage, Some("Coverage rules saved; they apply from the next report.".into()), None)
10551×
}
1056
1057
#[derive(Deserialize)]
1058
pub struct StorageForm {
1059
    #[serde(rename = "_csrf")]
1060
    csrf_token: String,
1061
    /// `check`, or `credentials` (with the new ones).
1062
    action: String,
1063
    #[serde(flatten)]
1064
    bucket: BucketForm,
1065
}
1066
1067
/// Settings → Storage (oxygenhub#96): check the bucket again, or replace
1068
/// its credentials once the new ones pass the check.
10690×
pub async fn storage(
10700×
    State(app): State<Shared>,
10710×
    viewer: Viewer,
10720×
    Path((owner, name)): Path<(String, String)>,
10730×
    Form(f): Form<StorageForm>,
10740×
) -> Result<Response, Error> {
10750×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
10760×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
10770×
    csrf(&viewer, &f.csrf_token, &shell)?;
10780×
    let Some(s) = crate::storage::of(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))? else {
10790×
        return back(&row, Section::General, None, Some("This repository is kept on this server's disk.".into()));
1080
    };
10810×
    let scheme = s.url.split("://").next().unwrap_or("").to_string();
10820×
    let outcome = match f.action.as_str() {
10830×
        "credentials" => match f.bucket.credentials(&scheme) {
10840×
            Ok(c) => crate::storage::set_credentials(&app, &s, c).await.map(|r| format!("New credentials saved: {r}.")),
10850×
            Err(e) => Err(format!("Credentials: {e}")),
1086
        },
10870×
        _ => crate::storage::check(&app, &s).await.map(|r| format!("The bucket passed the check: {r}.")),
1088
    };
10890×
    match outcome {
10900×
        Ok(notice) => {
10910×
            accounts::audit(&app.db, viewer.account(), "repo.storage", &format!("{owner}/{name}"), &f.action).await.ok();
10920×
            back(&row, Section::Storage, Some(notice), None)
1093
        }
10940×
        Err(e) => back(&row, Section::Storage, None, Some(format!("Storage: {e}"))),
1095
    }
10960×
}
1097
1098
#[derive(Deserialize)]
1099
pub struct MirrorForm {
1100
    #[serde(rename = "_csrf")]
1101
    csrf_token: String,
1102
    #[serde(default)]
1103
    enabled: Option<String>,
1104
}
1105
1106
#[derive(Deserialize)]
1107
pub struct BackupForm {
1108
    #[serde(rename = "_csrf")]
1109
    csrf_token: String,
1110
    /// Empty (with its owner's), `hourly`, `daily` or `off`.
1111
    #[serde(default)]
1112
    backup: String,
1113
}
1114
1115
/// This repository's backup (docs/backups.md §5): with its owner's, left
1116
/// out, or at least hourly or daily. Its owners and admins decide.
11173×
pub async fn backup(
11183×
    State(app): State<Shared>,
11193×
    viewer: Viewer,
11203×
    Path((owner, name)): Path<(String, String)>,
11213×
    Form(f): Form<BackupForm>,
11223×
) -> Result<Response, Error> {
11233×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
11243×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
11253×
    csrf(&viewer, &f.csrf_token, &shell)?;
112612×
    if !crate::backups::REPO_CHOICES.iter().any(|(v, _)| *v == f.backup) {
11271×
        return back(&row, Section::Backup, None, Some("Pick how this repository is backed up.".into()));
11282×
    }
11292×
    db::repos::set_backup(&app.db, row.id, &f.backup).await.map_err(|e| Error::from_db(shell.clone(), e))?;
11302×
    let what = if f.backup.is_empty() { "with its owner's" } else { f.backup.as_str() };
11312×
    accounts::audit(&app.db, viewer.account(), "repo.backup", &format!("{owner}/{name}"), what).await.ok();
11322×
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
11332×
    let notice = match f.backup.as_str() {
11342×
        "off" => "This repository is left out of its owner's backups from the next run.".to_string(),
11350×
        "" => "This repository is backed up with its owner's.".to_string(),
11360×
        every => {
11370×
            format!("This repository is backed up at least {} from now on.", if every == "hourly" { "every hour" } else { "every day" })
1138
        }
1139
    };
11402×
    back(&row, Section::Backup, Some(notice), None)
11413×
}
1142
1143
#[derive(Deserialize)]
1144
pub struct DiffusionForm {
1145
    #[serde(rename = "_csrf")]
1146
    csrf_token: String,
1147
    /// `off`, `green`, `any`, or `default`.
1148
    diffusion: String,
1149
    /// `auto` or `propose` (absent keeps it as it is).
1150
    #[serde(default)]
1151
    land: String,
1152
}
1153
1154
/// Whether this repository follows the upstreams it pins (Diffusion,
1155
/// #148): its owners and admins decide, since it opens proposals here.
11561×
pub async fn diffusion(
11571×
    State(app): State<Shared>,
11581×
    viewer: Viewer,
11591×
    Path((owner, name)): Path<(String, String)>,
11601×
    Form(f): Form<DiffusionForm>,
11611×
) -> Result<Response, Error> {
11621×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
11631×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
11641×
    csrf(&viewer, &f.csrf_token, &shell)?;
11651×
    let setting = match f.diffusion.as_str() {
11661×
        "off" | "green" | "any" => f.diffusion.as_str(),
11670×
        "default" => "",
11680×
        _ => return back(&row, Section::Diffusion, None, Some("Pick off, green, any or the site default.".into())),
1169
    };
11701×
    let land = match f.land.as_str() {
11711×
        "auto" | "propose" => Some(f.land.as_str()),
11720×
        "" => None,
11730×
        _ => return back(&row, Section::Diffusion, None, Some("Pick propose or auto for landing.".into())),
1174
    };
11751×
    db::repos::set_diffusion(&app.db, row.id, setting).await.map_err(|e| Error::from_db(shell.clone(), e))?;
11761×
    if let Some(l) = land {
11771×
        db::repos::set_diffusion_land(&app.db, row.id, l).await.map_err(|e| Error::from_db(shell.clone(), e))?;
11780×
    }
11791×
    let what =
11801×
        format!("{}{}", if setting.is_empty() { "default" } else { setting }, land.map(|l| format!(", land {l}")).unwrap_or_default());
11811×
    accounts::audit(&app.db, viewer.account(), "repo.diffusion", &format!("{owner}/{name}"), &what).await.ok();
11821×
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
1183
    // Turned to land by itself: any bump already green lands now.
11841×
    if land == Some("auto") {
11850×
        let (app, row) = (app.clone(), row.clone());
11860×
        tokio::spawn(async move { crate::diffusion::land_ready(&app, &row).await });
11871×
    }
11881×
    if crate::diffusion::rule(&app.config.diffusion.follow, &row, None, "").follow != crate::diffusion::Follow::Off {
11890×
        let (app, row) = (app.clone(), row.clone());
11900×
        tokio::spawn(async move { crate::diffusion::catch_up(&app, &row).await });
11911×
    }
11921×
    let notice = match crate::diffusion::rule(&app.config.diffusion.follow, &row, None, "").follow {
11931×
        crate::diffusion::Follow::Off => "Diffusion is off: upstream moves open no proposals here.",
1194
        crate::diffusion::Follow::Green => {
11950×
            "Diffusion is on: when a subrepo's upstream moves and its checks pass, a proposal moves the pin here."
1196
        }
11970×
        crate::diffusion::Follow::Any => "Diffusion is on: every move of a subrepo's upstream opens or updates a proposal here.",
1198
    };
11991×
    back(&row, Section::Diffusion, Some(notice.into()), None)
12001×
}
1201
1202
#[derive(Deserialize)]
1203
pub struct AgentGatesForm {
1204
    #[serde(rename = "_csrf")]
1205
    csrf_token: String,
1206
    /// `on` when the box is ticked.
1207
    #[serde(default)]
1208
    agents: String,
1209
}
1210
1211
/// `POST /{owner}/{repo}/settings/agent-gates` (oxygenhub#258): whether an
1212
/// agent's token may approve or reject this repository's approval gates;
1213
/// for maintainers, audited.
12141×
pub async fn agent_gates(
12151×
    State(app): State<Shared>,
12161×
    viewer: Viewer,
12171×
    Path((owner, name)): Path<(String, String)>,
12181×
    Form(f): Form<AgentGatesForm>,
12191×
) -> Result<Response, Error> {
12201×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
12211×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
12221×
    csrf(&viewer, &f.csrf_token, &shell)?;
12231×
    let on = f.agents == "on";
12241×
    db::repos::set_agents_decide_gates(&app.db, row.id, on).await.map_err(|e| Error::from_db(shell.clone(), e))?;
12251×
    accounts::audit(&app.db, viewer.account(), "repo.agents_decide_gates", &format!("{owner}/{name}"), if on { "on" } else { "off" })
12261×
        .await
12271×
        .ok();
12281×
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
12291×
    let notice = if on {
12301×
        "Agents' tokens may now approve or reject this repository's approval gates; it says so on each."
1231
    } else {
12320×
        "Approval gates here are decided by people: agents' tokens are refused."
1233
    };
12341×
    back(&row, Section::Gates, Some(notice.into()), None)
12351×
}
1236
1237
#[derive(Deserialize)]
1238
pub struct PushProtectionForm {
1239
    #[serde(rename = "_csrf")]
1240
    csrf_token: String,
1241
    /// `on` when the box is ticked.
1242
    #[serde(default)]
1243
    protect: String,
1244
}
1245
1246
/// `POST /{owner}/{repo}/settings/push-protection` (oxygenhub#268): whether
1247
/// pushes adding what looks like a secret are refused; for maintainers,
1248
/// audited.
12494×
pub async fn push_protection(
12504×
    State(app): State<Shared>,
12514×
    viewer: Viewer,
12524×
    Path((owner, name)): Path<(String, String)>,
12534×
    Form(f): Form<PushProtectionForm>,
12544×
) -> Result<Response, Error> {
12554×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
12564×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
12574×
    csrf(&viewer, &f.csrf_token, &shell)?;
12584×
    let on = f.protect == "on";
12594×
    db::push_protection::set_enabled(&app.db, row.id, on).await.map_err(|e| Error::from_db(shell.clone(), e))?;
12604×
    accounts::audit(&app.db, viewer.account(), "repo.push_protection", &format!("{owner}/{name}"), if on { "on" } else { "off" })
12614×
        .await
12624×
        .ok();
12634×
    if on {
12642×
        // What was pushed while it was off is scanned now (#277).
12652×
        crate::history_scan::wake(&app, row.id);
12662×
    }
12674×
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
12684×
    let notice = if on {
12692×
        "Push protection is on: a push that adds what looks like a secret is refused, saying where."
1270
    } else {
12712×
        "Push protection is off: pushes are no longer scanned for secrets here."
1272
    };
12734×
    back(&row, Section::PushProtection, Some(notice.into()), None)
12744×
}
1275
1276
#[derive(Deserialize)]
1277
pub struct SecretBypassForm {
1278
    #[serde(rename = "_csrf")]
1279
    csrf_token: String,
1280
    fingerprint: String,
1281
    #[serde(default)]
1282
    reason: String,
1283
}
1284
1285
/// `POST /{owner}/{repo}/settings/secret-bypass` (oxygenhub#268): let one
1286
/// push protection finding through, by its fingerprint, with a reason; for
1287
/// maintainers, audited as `secret.bypass`.
12885×
pub async fn secret_bypass(
12895×
    State(app): State<Shared>,
12905×
    viewer: Viewer,
12915×
    Path((owner, name)): Path<(String, String)>,
12925×
    Form(f): Form<SecretBypassForm>,
12935×
) -> Result<Response, Error> {
12945×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
12955×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
12964×
    csrf(&viewer, &f.csrf_token, &shell)?;
12974×
    let fingerprint = f.fingerprint.trim().to_ascii_lowercase();
12984×
    let reason = f.reason.trim();
129964×
    let error = if fingerprint.len() != 16 || !fingerprint.chars().all(|c| c.is_ascii_hexdigit()) {
13000×
        Some("A fingerprint is the 16 hexadecimal digits a refused push printed.")
13014×
    } else if reason.is_empty() {
13021×
        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.")
13033×
    } else if reason.chars().count() > 500 {
13040×
        Some("Keep the reason under 500 characters.")
1305
    } else {
13063×
        None
1307
    };
13084×
    if let Some(e) = error {
13091×
        return back(&row, Section::PushProtection, None, Some(e.into()));
13103×
    }
13113×
    let rule = db::push_protection::rule_of(&app.db, row.id, &fingerprint)
13123×
        .await
13133×
        .map_err(|e| Error::from_db(shell.clone(), e))?
13143×
        .unwrap_or_default();
13153×
    let by = viewer.account().map(|a| (a.id, a.username.as_str()));
13163×
    db::push_protection::allow(&app.db, row.id, &fingerprint, &rule, reason, by).await.map_err(|e| Error::from_db(shell.clone(), e))?;
13173×
    let detail = if rule.is_empty() { format!("{fingerprint}: {reason}") } else { format!("{fingerprint} ({rule}): {reason}") };
13183×
    accounts::audit(&app.db, viewer.account(), "secret.bypass", &format!("{owner}/{name}"), &detail).await.ok();
13193×
    let notice = format!("Fingerprint {fingerprint} is allowed here: a push adding that value is no longer refused for it.");
13203×
    back(&row, Section::PushProtection, Some(notice), None)
13215×
}
1322
1323
#[derive(Deserialize)]
1324
pub struct BypassRemoveForm {
1325
    #[serde(rename = "_csrf")]
1326
    csrf_token: String,
1327
    fingerprint: String,
1328
}
1329
1330
/// `POST /{owner}/{repo}/settings/secret-bypass/remove` (oxygenhub#277):
1331
/// take back an allowed fingerprint, so a push adding that value is refused
1332
/// again; for maintainers, audited as `secret.bypass_removed`.
13333×
pub async fn secret_bypass_remove(
13343×
    State(app): State<Shared>,
13353×
    viewer: Viewer,
13363×
    Path((owner, name)): Path<(String, String)>,
13373×
    Form(f): Form<BypassRemoveForm>,
13383×
) -> Result<Response, Error> {
13393×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
13403×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
13412×
    csrf(&viewer, &f.csrf_token, &shell)?;
13422×
    let fingerprint = f.fingerprint.trim().to_ascii_lowercase();
13432×
    let removed = db::push_protection::remove_bypass(&app.db, row.id, &fingerprint).await.map_err(|e| Error::from_db(shell.clone(), e))?;
13442×
    let Some(b) = removed else {
13451×
        return back(&row, Section::PushProtection, None, Some(format!("Fingerprint {fingerprint} is not allowed here.")));
1346
    };
13471×
    let detail = if b.rule.is_empty() {
13480×
        format!("{fingerprint} (was: {})", b.reason)
1349
    } else {
13501×
        format!("{fingerprint} ({}) (was: {})", b.rule, b.reason)
1351
    };
13521×
    accounts::audit(&app.db, viewer.account(), "secret.bypass_removed", &format!("{owner}/{name}"), &detail).await.ok();
13531×
    let notice = format!("Fingerprint {fingerprint} is no longer allowed: a push adding that value is refused again.");
13541×
    back(&row, Section::PushProtection, Some(notice), None)
13553×
}
1356
1357
#[derive(Deserialize)]
1358
pub struct SecretResolveForm {
1359
    #[serde(rename = "_csrf")]
1360
    csrf_token: String,
1361
    finding: i64,
1362
    #[serde(default)]
1363
    reason: String,
1364
}
1365
1366
/// `POST /{owner}/{repo}/settings/secret-resolve` (oxygenhub#277): mark a
1367
/// finding of the history scan resolved (the secret was revoked or
1368
/// rotated), with a reason; for maintainers, audited as `secret.resolved`.
13694×
pub async fn secret_resolve(
13704×
    State(app): State<Shared>,
13714×
    viewer: Viewer,
13724×
    Path((owner, name)): Path<(String, String)>,
13734×
    Form(f): Form<SecretResolveForm>,
13744×
) -> Result<Response, Error> {
13754×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
13764×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
13773×
    csrf(&viewer, &f.csrf_token, &shell)?;
13783×
    let reason = f.reason.trim();
13793×
    let error = if reason.is_empty() {
13801×
        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.")
13812×
    } else if reason.chars().count() > 500 {
13820×
        Some("Keep the reason under 500 characters.")
1383
    } else {
13842×
        None
1385
    };
13863×
    if let Some(e) = error {
13871×
        return back(&row, Section::PushProtection, None, Some(e.into()));
13882×
    }
13892×
    let by = viewer.account().map(|a| a.username.as_str()).unwrap_or("");
13902×
    let done = db::push_protection::resolve(&app.db, row.id, f.finding, by, reason).await.map_err(|e| Error::from_db(shell.clone(), e))?;
13912×
    let Some(found) = done else {
13921×
        return back(&row, Section::PushProtection, None, Some("That finding is not open here.".into()));
1393
    };
13941×
    let detail = format!("{} ({}) at {}:{}: {reason}", found.fingerprint, found.rule, found.path, found.line);
13951×
    accounts::audit(&app.db, viewer.account(), "secret.resolved", &format!("{owner}/{name}"), &detail).await.ok();
13961×
    let notice = format!("The finding at {}:{} is marked resolved.", found.path, found.line);
13971×
    back(&row, Section::PushProtection, Some(notice), None)
13984×
}
1399
1400
#[derive(Deserialize)]
1401
pub struct SecretScanForm {
1402
    #[serde(rename = "_csrf")]
1403
    csrf_token: String,
1404
}
1405
1406
/// `POST /{owner}/{repo}/settings/secret-scan` (oxygenhub#277): scan the
1407
/// whole history again now, with the current rules; for maintainers,
1408
/// audited as `secret.history_scan`.
14092×
pub async fn secret_scan(
14102×
    State(app): State<Shared>,
14112×
    viewer: Viewer,
14122×
    Path((owner, name)): Path<(String, String)>,
14132×
    Form(f): Form<SecretScanForm>,
14142×
) -> Result<Response, Error> {
14152×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
14162×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
14171×
    csrf(&viewer, &f.csrf_token, &shell)?;
14181×
    if !row.push_protection {
14190×
        return back(
14200×
            &row,
14210×
            Section::PushProtection,
14220×
            None,
14230×
            Some("Turn push protection on first: history is scanned only where it is on.".into()),
1424
        );
14251×
    }
14261×
    crate::history_scan::request(&app, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
14271×
    accounts::audit(&app.db, viewer.account(), "secret.history_scan", &format!("{owner}/{name}"), "requested").await.ok();
14281×
    let notice = "The whole history will be scanned again, before other repositories: findings appear below as they are found.";
14291×
    back(&row, Section::PushProtection, Some(notice.into()), None)
14302×
}
1431
1432
#[derive(Deserialize)]
1433
pub struct SupersedeForm {
1434
    #[serde(rename = "_csrf")]
1435
    csrf_token: String,
1436
    /// `on` when the box is ticked: superseded runs already running are
1437
    /// cancelled too.
1438
    #[serde(default)]
1439
    running: String,
1440
}
1441
1442
/// `POST /{owner}/{repo}/settings/flow-supersede` (oxygenhub#267): whether
1443
/// a new push or proposal run cancels the older runs it supersedes that are
1444
/// already running, or only those still queued; for maintainers, audited.
14451×
pub async fn flow_supersede(
14461×
    State(app): State<Shared>,
14471×
    viewer: Viewer,
14481×
    Path((owner, name)): Path<(String, String)>,
14491×
    Form(f): Form<SupersedeForm>,
14501×
) -> Result<Response, Error> {
14511×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
14521×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
14531×
    csrf(&viewer, &f.csrf_token, &shell)?;
14541×
    let keep = f.running != "on";
14551×
    db::repos::set_flow_keep_running(&app.db, row.id, keep).await.map_err(|e| Error::from_db(shell.clone(), e))?;
14561×
    accounts::audit(&app.db, viewer.account(), "repo.flow_keep_running", &format!("{owner}/{name}"), if keep { "on" } else { "off" })
14571×
        .await
14581×
        .ok();
14591×
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
14601×
    let notice = if keep {
14610×
        "A new push or proposal run now cancels only the older runs still queued; those already running finish."
1462
    } else {
14631×
        "A new push or proposal run now cancels the older runs it supersedes, queued or running."
1464
    };
14651×
    back(&row, Section::Runs, Some(notice.into()), None)
14661×
}
1467
1468
#[derive(Deserialize)]
1469
pub struct ReuseForm {
1470
    #[serde(rename = "_csrf")]
1471
    csrf_token: String,
1472
    /// `on` when the box is ticked.
1473
    #[serde(default)]
1474
    reuse: String,
1475
}
1476
1477
/// `POST /{owner}/{repo}/settings/flow-reuse` (oxygenhub#265): whether a
1478
/// push run reuses a job's pass on the same state; for maintainers,
1479
/// audited.
14800×
pub async fn flow_reuse(
14810×
    State(app): State<Shared>,
14820×
    viewer: Viewer,
14830×
    Path((owner, name)): Path<(String, String)>,
14840×
    Form(f): Form<ReuseForm>,
14850×
) -> Result<Response, Error> {
14860×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
14870×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
14880×
    csrf(&viewer, &f.csrf_token, &shell)?;
14890×
    let on = f.reuse == "on";
14900×
    db::repos::set_flow_reuse(&app.db, row.id, on).await.map_err(|e| Error::from_db(shell.clone(), e))?;
14910×
    accounts::audit(&app.db, viewer.account(), "repo.flow_reuse", &format!("{owner}/{name}"), if on { "on" } else { "off" }).await.ok();
14920×
    let notice = if on {
14930×
        "A push run now reuses a job's pass on the same state."
1494
    } else {
14950×
        "Every job of every push now runs, passed before or not."
1496
    };
14970×
    back(&row, Section::Runs, Some(notice.into()), None)
14980×
}
1499
1500
#[derive(Deserialize)]
1501
pub struct SkipForm {
1502
    #[serde(rename = "_csrf")]
1503
    csrf_token: String,
1504
    /// `any`, `others` or `never`.
1505
    skip: String,
1506
}
1507
1508
/// `POST /{owner}/{repo}/settings/flow-skip` (oxygenhub#284): where a push
1509
/// may skip its run with `[skip flow]` in its message; for maintainers,
1510
/// audited.
15112×
pub async fn flow_skip(
15122×
    State(app): State<Shared>,
15132×
    viewer: Viewer,
15142×
    Path((owner, name)): Path<(String, String)>,
15152×
    Form(f): Form<SkipForm>,
15162×
) -> Result<Response, Error> {
15172×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
15182×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
15192×
    csrf(&viewer, &f.csrf_token, &shell)?;
15202×
    let notice = match f.skip.as_str() {
15212×
        "any" => "A push may now skip its run on any bookmark.".to_string(),
15222×
        "others" => format!("A push may now skip its run on any bookmark but {}.", row.default_bookmark),
15231×
        "never" => "Every push now runs; skip markers are ignored.".to_string(),
15241×
        _ => return back(&row, Section::Runs, None, Some("Choose where a push may skip its run.".into())),
1525
    };
15261×
    db::repos::set_flow_skip(&app.db, row.id, &f.skip).await.map_err(|e| Error::from_db(shell.clone(), e))?;
15271×
    accounts::audit(&app.db, viewer.account(), "repo.flow_skip", &format!("{owner}/{name}"), &f.skip).await.ok();
15281×
    back(&row, Section::Runs, Some(notice), None)
15292×
}
1530
1531
#[derive(Deserialize)]
1532
pub struct PriorityForm {
1533
    #[serde(rename = "_csrf")]
1534
    csrf_token: String,
1535
    /// `low`, `normal`, `high`, or `default`.
1536
    priority: String,
1537
}
1538
1539
/// `POST /{owner}/{repo}/settings/flow-priority` (oxygenhub#244): its jobs'
1540
/// level. Maintainers choose low, normal or the default; only site
1541
/// administrators raise a repository to high.
15422×
pub async fn flow_priority(
15432×
    State(app): State<Shared>,
15442×
    viewer: Viewer,
15452×
    Path((owner, name)): Path<(String, String)>,
15462×
    Form(f): Form<PriorityForm>,
15472×
) -> Result<Response, Error> {
15482×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
15492×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
15502×
    csrf(&viewer, &f.csrf_token, &shell)?;
15512×
    let level = match f.priority.as_str() {
15522×
        "low" | "normal" | "high" => f.priority.as_str(),
15530×
        "default" => "",
15540×
        _ => return back(&row, Section::Runs, None, Some("Pick low, normal, high or the default.".into())),
1555
    };
15562×
    if level == "high" && !viewer.is_admin() {
15570×
        return back(&row, Section::Runs, None, Some("Only site administrators raise a repository to high.".into()));
15582×
    }
15592×
    db::repos::set_flow_priority(&app.db, row.id, level).await.map_err(|e| Error::from_db(shell.clone(), e))?;
15602×
    accounts::audit(
15612×
        &app.db,
15622×
        viewer.account(),
15632×
        "repo.flow_priority",
15642×
        &format!("{owner}/{name}"),
15652×
        if level.is_empty() { "default" } else { level },
1566
    )
15672×
    .await
15682×
    .ok();
15692×
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
15702×
    let notice = if level.is_empty() {
15710×
        "Its jobs follow the organisation's and the site's priority.".to_string()
1572
    } else {
15732×
        format!("Its jobs are {level} priority.")
1574
    };
15752×
    back(&row, Section::Runs, Some(notice), None)
15762×
}
1577
1578
#[derive(Deserialize)]
1579
pub struct FlowForm {
1580
    #[serde(rename = "_csrf")]
1581
    csrf_token: String,
1582
    /// `on`, `off`, or `default`.
1583
    flow: String,
1584
}
1585
1586
/// Turn OxygenFlow on or off for one repository. Site administrators choose
1587
/// anything; the repository's maintainers may turn it on on their own
1588
/// runners (`own`), when the repository or its owner has one, or off (#225).
1589
/// Only site administrators let the site's runners take its jobs.
15909×
pub async fn flow(
15919×
    State(app): State<Shared>,
15929×
    viewer: Viewer,
15939×
    Path((owner, name)): Path<(String, String)>,
15949×
    Form(f): Form<FlowForm>,
15959×
) -> Result<Response, Error> {
15969×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
15979×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
15989×
    csrf(&viewer, &f.csrf_token, &shell)?;
15999×
    let setting = match f.flow.as_str() {
16009×
        "on" => "on",
16016×
        "own" => "own",
16024×
        "off" => "off",
16033×
        "default" => "",
16042×
        _ => return back(&row, Section::Pipelines, None, Some("Pick on, own runners, off or the site default.".into())),
1605
    };
16067×
    if !viewer.is_admin() {
16074×
        if matches!(setting, "on" | "") {
16082×
            return back(
16092×
                &row,
16102×
                Section::Pipelines,
16112×
                None,
16122×
                Some("Only site administrators let the site's runners take this repository's jobs.".into()),
1613
            );
16142×
        }
16152×
        if setting == "own" && !has_own_runner(&app, &row).await.map_err(|e| Error::from_db(shell.clone(), e))? {
16161×
            return back(
16171×
                &row,
16181×
                Section::Pipelines,
16191×
                None,
16201×
                Some("Register a runner for this repository (Settings › Runners) or its owner first.".into()),
1621
            );
16221×
        }
16233×
    }
16244×
    db::repos::set_flow(&app.db, row.id, setting).await.map_err(|e| Error::from_db(shell.clone(), e))?;
16254×
    let label = if setting.is_empty() { "default" } else { setting };
16264×
    accounts::audit(&app.db, viewer.account(), "repo.flow", &format!("{owner}/{name}"), label).await.ok();
16274×
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
16284×
    let notice = if row.flow == "own" {
16291×
        "OxygenFlow is on, on this repository's own runners: pushes with a .flow/pipeline.kdl start runs that only they take."
16303×
    } else if crate::flow::enabled(&app, &row) {
16311×
        "OxygenFlow is on: pushes with a .flow/pipeline.kdl start runs."
1632
    } else {
16332×
        "OxygenFlow is off for this repository."
1634
    };
16354×
    back(&row, Section::Pipelines, Some(notice.into()), None)
16369×
}
1637
16382×
pub async fn mirror(
16392×
    State(app): State<Shared>,
16402×
    viewer: Viewer,
16412×
    Path((owner, name)): Path<(String, String)>,
16422×
    Form(f): Form<MirrorForm>,
16432×
) -> Result<Response, Error> {
16442×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
16452×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
16462×
    csrf(&viewer, &f.csrf_token, &shell)?;
16472×
    db::repos::set_git_mirror(&app.db, row.id, f.enabled.is_some()).await.map_err(|e| Error::from_db(shell.clone(), e))?;
16482×
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
16492×
    if row.git_mirror {
16501×
        crate::mirror::refresh(&app, &row).await.ok();
16511×
    }
16522×
    accounts::audit(&app.db, viewer.account(), "repo.mirror", &format!("{owner}/{name}"), if row.git_mirror { "on" } else { "off" })
16532×
        .await
16542×
        .ok();
16552×
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
16562×
    let notice = if row.git_mirror { "The Git mirror is on and up to date." } else { "The Git mirror is off." };
16572×
    back(&row, Section::GitMirror, Some(notice.into()), None)
16582×
}
1659
1660
/// `POST /{owner}/{name}/settings/runners/token`: a one-time registration
1661
/// token for a runner that serves this repository only (oxygenhub#41).
16622×
pub async fn runner_token(
16632×
    State(app): State<Shared>,
16642×
    viewer: Viewer,
16652×
    Path((owner, name)): Path<(String, String)>,
16662×
    Form(f): Form<super::session::CsrfForm>,
16672×
) -> Result<Page<RepoSettingsPage>, Error> {
16682×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
16692×
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
16702×
    csrf(&viewer, &f.csrf, &shell)?;
16712×
    let me = viewer.account().map(|a| a.username.clone()).unwrap_or_default();
16722×
    let token = format!("flowreg_{}", crate::auth::random_hex(24));
16732×
    let scope = db::runners::Scope { owner: row.owner.clone(), repo_id: Some(row.id) };
16742×
    db::runners::add_registration(&app.db, &blake3::hash(token.as_bytes()).to_hex(), &me, &scope)
16752×
        .await
16762×
        .map_err(|e| Error::from_db(shell.clone(), e))?;
16772×
    accounts::audit(&app.db, viewer.account(), "flow.runner.token", &row.slug(), "registration token for this repository").await.ok();
16782×
    let mut page =
16792×
        settings_page(&app, shell, row, role, Section::Runners, Some("Copy the token now; it is not shown again.".into()), None).await?;
16802×
    page.0.runner_token = Some(token);
16812×
    Ok(page)
16822×
}
1683
1684
/// `POST /{owner}/{name}/settings/runners/{id}/remove`: remove one of this
1685
/// repository's own runners, when it is running nothing.
16862×
pub async fn runner_remove(
16872×
    State(app): State<Shared>,
16882×
    viewer: Viewer,
16892×
    Path((owner, name, id)): Path<(String, String, i64)>,
16902×
    Form(f): Form<super::session::CsrfForm>,
16912×
) -> Result<Response, Error> {
16922×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
16932×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
16942×
    csrf(&viewer, &f.csrf, &shell)?;
16952×
    let e = |e| Error::from_db(shell.clone(), e);
16962×
    let Some(r) = db::runners::get(&app.db, id).await.map_err(e)?.filter(|r| r.scope_repo_id == Some(row.id)) else {
16971×
        return Err(Error::not_found(shell));
1698
    };
16991×
    if db::runners::running(&app.db).await.map_err(e)?.iter().any(|(rid, n)| *rid == id && *n > 0) {
17000×
        return back(&row, Section::Runners, None, Some(format!("{} is running jobs: wait for them to finish.", r.name)));
17011×
    }
17021×
    db::runners::remove(&app.db, id).await.map_err(e)?;
17031×
    accounts::audit(&app.db, viewer.account(), "flow.runner.removed", &r.name, &row.slug()).await.ok();
17041×
    back(&row, Section::Runners, Some(format!("{} removed: its credential no longer works.", r.name)), None)
17052×
}
1706
1707404×
fn repo_assistants(row: &db::repos::RepoRow) -> crate::levels::At {
1708404×
    crate::levels::At::repo(row)
1709404×
}
1710
1711
/// `POST /{owner}/{name}/settings/assistants`: the repository's assistants
1712
/// and prompt (#97), for its admins.
17132×
pub async fn assistants(
17142×
    State(app): State<Shared>,
17152×
    viewer: Viewer,
17162×
    Path((owner, name)): Path<(String, String)>,
17172×
    Form(f): Form<super::assistants::AssistantForm>,
17182×
) -> Result<Response, Error> {
17192×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
17202×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
17211×
    csrf(&viewer, &f.csrf_token, &shell)?;
17221×
    match super::assistants::apply(&app, &viewer, &repo_assistants(&row), f).await.map_err(|e| Error::from_db(shell.clone(), e))? {
17231×
        Ok(notice) => back(&row, Section::Assistants, Some(notice), None),
17240×
        Err(error) => back(&row, Section::Assistants, None, Some(error)),
1725
    }
17262×
}
1727
1728
/// `POST /{owner}/{name}/settings/variables`: the repository's Flow
1729
/// variables (#102), for its admins.
17303×
pub async fn variables(
17313×
    State(app): State<Shared>,
17323×
    viewer: Viewer,
17333×
    Path((owner, name)): Path<(String, String)>,
17343×
    Form(f): Form<super::variables::VariableForm>,
17353×
) -> Result<Response, Error> {
17363×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
17373×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
17383×
    csrf(&viewer, &f.csrf_token, &shell)?;
17393×
    match super::variables::apply(&app, &viewer, &repo_assistants(&row), f).await.map_err(|e| Error::from_db(shell.clone(), e))? {
17402×
        Ok(notice) => back(&row, Section::Variables, Some(notice), None),
17411×
        Err(error) => back(&row, Section::Variables, None, Some(error)),
1742
    }
17433×
}
1744
1745
/// `POST /{owner}/{name}/settings/secrets`: the repository's Flow secrets
1746
/// (#107), for its admins.
17471×
pub async fn secrets(
17481×
    State(app): State<Shared>,
17491×
    viewer: Viewer,
17501×
    Path((owner, name)): Path<(String, String)>,
17511×
    Form(f): Form<super::secrets::SecretForm>,
17521×
) -> Result<Response, Error> {
17531×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
17541×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
17551×
    csrf(&viewer, &f.csrf_token, &shell)?;
17561×
    match super::secrets::apply(&app, &viewer, &repo_assistants(&row), f).await.map_err(|e| Error::from_db(shell.clone(), e))? {
17571×
        Ok(notice) => back(&row, Section::Secrets, Some(notice), None),
17580×
        Err(error) => back(&row, Section::Secrets, None, Some(error)),
1759
    }
17601×
}
1761
1762
/// `POST /{owner}/{name}/settings/assistants/models`: what a provider offers (#109).
17630×
pub async fn assistant_models(
17640×
    State(app): State<Shared>,
17650×
    viewer: Viewer,
17660×
    Path((owner, name)): Path<(String, String)>,
17670×
    Form(f): Form<super::assistants::ModelsForm>,
17680×
) -> Result<axum::Json<serde_json::Value>, Error> {
17690×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
17700×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
17710×
    csrf(&viewer, &f.csrf_token, &shell)?;
17720×
    Ok(axum::Json(super::assistants::models(&app, &repo_assistants(&row), f).await.map_err(|e| Error::from_db(shell.clone(), e))?))
17730×
}
1774
1775
/// Whether a runner serves this repository that is not the site's: one
1776
/// scoped to it or to its owner (#225).
1777135×
async fn has_own_runner(app: &Shared, row: &db::repos::RepoRow) -> sqlx::Result<bool> {
1778135×
    Ok(db::runners::serving(&app.db, &row.owner, row.id).await?.iter().any(|r| !r.scope().is_site()))
1779135×
}