OXYGENHUB

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

970 of 1217 lines run, 104 of 388 functions · run #509 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
}
23
2452×
pub(crate) async fn owners_for(app: &Shared, me: &accounts::Account) -> Vec<String> {
2552×
    let mut owners = vec![me.username.clone()];
2652×
    if let Ok(orgs) = accounts::orgs_of(&app.db, me.id).await {
2752×
        owners.extend(orgs.into_iter().map(|(o, _)| o.name));
280×
    }
2952×
    owners
3052×
}
31
3224×
pub async fn new_form(State(app): State<Shared>, viewer: Viewer) -> Result<Page<NewRepoPage>, Error> {
3324×
    let shell = Shell::new(&app, "/new", &viewer);
3424×
    let me = require(&viewer, &shell)?;
3524×
    let owners = owners_for(&app, &me.account).await;
3624×
    Ok(Page(NewRepoPage { shell, owners, error: None }))
3724×
}
38
39
#[derive(Deserialize)]
40
pub struct NewRepoForm {
41
    #[serde(rename = "_csrf")]
42
    csrf_token: String,
43
    owner: String,
44
    name: String,
45
    #[serde(default)]
46
    description: String,
47
    #[serde(default = "default_visibility")]
48
    visibility: String,
49
    /// `hub` (this server's disk, the default) or `bucket` (oxygenhub#96).
50
    #[serde(default)]
51
    storage: String,
52
    #[serde(flatten)]
53
    bucket: BucketForm,
54
}
55
56
/// A bucket and the credentials for it, as the New repository form and
57
/// Settings → Storage give them; only the kind the URL names is read.
58
#[derive(Deserialize, Default)]
59
pub struct BucketForm {
60
    #[serde(default)]
61
    store_url: String,
62
    #[serde(default)]
63
    access_key_id: String,
64
    #[serde(default)]
65
    secret_access_key: String,
66
    #[serde(default)]
67
    service_account_key: String,
68
    #[serde(default)]
69
    tenant_id: String,
70
    #[serde(default)]
71
    client_id: String,
72
    #[serde(default)]
73
    client_secret: String,
74
}
75
76
impl BucketForm {
771×
    fn credentials(&self, scheme: &str) -> Result<oxy_strata::Credentials, String> {
781×
        crate::storage::credentials_from(
791×
            scheme,
801×
            &self.access_key_id,
811×
            &self.secret_access_key,
821×
            &self.service_account_key,
831×
            &self.tenant_id,
841×
            &self.client_id,
851×
            &self.client_secret,
86
        )
871×
    }
88
}
89
9017×
fn default_visibility() -> String {
9117×
    "public".into()
9217×
}
93
9425×
pub async fn create(State(app): State<Shared>, viewer: Viewer, Form(f): Form<NewRepoForm>) -> Result<Response, Error> {
9525×
    let shell = Shell::new(&app, "/new", &viewer);
9625×
    let me = require(&viewer, &shell)?;
9725×
    csrf(&viewer, &f.csrf_token, &shell)?;
9825×
    let owners = owners_for(&app, &me.account).await;
9925×
    let fail = |msg: &str| Page(NewRepoPage { shell: shell.clone(), owners: owners.clone(), error: Some(msg.into()) }).into_response();
10025×
    let Some(name) = RepoName::new(f.owner.trim(), f.name.trim()) else {
1011×
        return Ok(fail("A repository name is letters, digits, dots, dashes and underscores."));
102
    };
10324×
    if name.name.ends_with(crate::wiki::SUFFIX) {
1041×
        return Ok(fail("A name cannot end in .wiki: that is the address of a repository's wiki."));
10523×
    }
10623×
    if !roles::may_create_under(&app.db, &me.account, &name.owner).await.map_err(|e| Error::from_db(shell.clone(), e))? {
1070×
        return Ok(fail("You cannot create repositories under that owner."));
10823×
    }
10923×
    let Some(visibility) = Visibility::parse(&f.visibility) else { return Ok(fail("Visibility is public, internal or private.")) };
11023×
    if db::repos::get(&app.db, &name.owner, &name.name).await.map_err(|e| Error::from_db(shell.clone(), e))?.is_some()
11123×
        || app.repos.exists(&name)
112
    {
1130×
        return Ok(fail("That repository exists."));
11423×
    }
115
    // An organisation's storage policy (oxygenhub#96).
11623×
    if let Some(org) = accounts::org_by_name(&app.db, &name.owner).await.map_err(|e| Error::from_db(shell.clone(), e))? {
1175×
        let policy = crate::storage::policy(&app.db, org.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1185×
        let store = (f.storage == "bucket").then_some(f.bucket.store_url.as_str());
1195×
        if let Err(e) = policy.permits(&org.name, store) {
1202×
            return Ok(fail(&format!("{e}.")));
1213×
        }
12218×
    }
12321×
    if f.storage == "bucket" {
124
        // Kept in the owner's bucket (oxygenhub#96): checked before
125
        // anything is made, and nothing is left behind when it fails.
1266×
        let url = f.bucket.store_url.trim();
1276×
        let parsed = match crate::storage::check_url(url) {
1281×
            Ok(p) => p,
1295×
            Err(e) => return Ok(fail(&format!("Store: {e}."))),
130
        };
1311×
        let credentials = match f.bucket.credentials(&parsed.scheme) {
1320×
            Ok(c) => c,
1331×
            Err(e) => return Ok(fail(&format!("Credentials: {e}."))),
134
        };
1350×
        let row = db::repos::create(&app.db, &name.owner, &name.name, f.description.trim(), visibility)
1360×
            .await
1370×
            .map_err(|e| Error::from_db(shell.clone(), e))?;
1380×
        if let Err(e) = crate::storage::create(&app, &name, row.id, url, credentials).await {
1390×
            db::repos::delete(&app.db, row.id).await.ok();
1400×
            return Ok(fail(&format!("The bucket did not pass the check, so nothing was created: {e}")));
1410×
        }
1420×
        accounts::audit(
1430×
            &app.db,
1440×
            Some(&me.account),
1450×
            "repo.created",
1460×
            &name.to_string(),
1470×
            &format!("{} in {}", visibility.as_str(), parsed.plain()),
1480×
        )
1490×
        .await
1500×
        .ok();
1510×
        return Ok(Redirect::to(&name.url()).into_response());
15215×
    }
15315×
    app.repos.create_async(&name, None).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
15415×
    db::repos::create(&app.db, &name.owner, &name.name, f.description.trim(), visibility)
15515×
        .await
15615×
        .map_err(|e| Error::from_db(shell.clone(), e))?;
15715×
    accounts::audit(&app.db, Some(&me.account), "repo.created", &name.to_string(), visibility.as_str()).await.ok();
15815×
    Ok(Redirect::to(&name.url()).into_response())
15925×
}
160
161
/// One page of a repository's settings (#278), at
162
/// `/{owner}/{name}/settings/{slug}`; `/settings` is General.
163
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
164
pub enum Section {
165
    General,
166
    Collaborators,
167
    Landing,
168
    Checks,
169
    PushProtection,
170
    Pipelines,
171
    Runs,
172
    Gates,
173
    Runners,
174
    Variables,
175
    Secrets,
176
    Coverage,
177
    Webhooks,
178
    Assistants,
179
    GitMirror,
180
    Diffusion,
181
    Storage,
182
    Backup,
183
    Advanced,
184
}
185
186
/// The sidebar: each group's heading and its pages, in order.
187
const GROUPS: &[(&str, &[Section])] = &[
188
    ("General", &[Section::General, Section::Collaborators]),
189
    ("Code", &[Section::Landing, Section::Checks, Section::PushProtection]),
190
    (
191
        "Flow",
192
        &[Section::Pipelines, Section::Runs, Section::Gates, Section::Runners, Section::Variables, Section::Secrets, Section::Coverage],
193
    ),
194
    ("Integrations", &[Section::Webhooks, Section::Assistants, Section::GitMirror, Section::Diffusion]),
195
    ("Data", &[Section::Storage, Section::Backup]),
196
    ("Danger zone", &[Section::Advanced]),
197
];
198
199
impl Section {
2005908×
    pub fn slug(self) -> &'static str {
2015908×
        match self {
202405×
            Section::General => "general",
203372×
            Section::Collaborators => "collaborators",
204388×
            Section::Landing => "landing",
205385×
            Section::Checks => "checks",
206392×
            Section::PushProtection => "push-protection",
207366×
            Section::Pipelines => "flow",
208343×
            Section::Runs => "runs",
209331×
            Section::Gates => "gates",
210302×
            Section::Runners => "runners",
211299×
            Section::Variables => "variables",
212291×
            Section::Secrets => "secrets",
213313×
            Section::Coverage => "coverage",
214289×
            Section::Webhooks => "webhooks",
215274×
            Section::Assistants => "assistants",
216296×
            Section::GitMirror => "git-mirror",
217291×
            Section::Diffusion => "diffusion",
21821×
            Section::Storage => "storage",
219289×
            Section::Backup => "backup",
220261×
            Section::Advanced => "advanced",
221
        }
2225908×
    }
223
2242421×
    pub fn title(self) -> &'static str {
2252421×
        match self {
226133×
            Section::General => "General",
227126×
            Section::Collaborators => "Collaborators",
228136×
            Section::Landing => "Landing rules",
229138×
            Section::Checks => "Required checks",
230153×
            Section::PushProtection => "Push protection",
231152×
            Section::Pipelines => "Pipelines",
232141×
            Section::Runs => "Runs & priority",
233136×
            Section::Gates => "Gates & agents",
234126×
            Section::Runners => "Runners",
235125×
            Section::Variables => "Variables",
236123×
            Section::Secrets => "Secrets",
237136×
            Section::Coverage => "Coverage",
238129×
            Section::Webhooks => "Webhooks",
239124×
            Section::Assistants => "AI assistants",
240137×
            Section::GitMirror => "Git mirror",
241136×
            Section::Diffusion => "Diffusion",
2420×
            Section::Storage => "Storage",
243139×
            Section::Backup => "Backup",
244131×
            Section::Advanced => "Advanced",
245
        }
2462421×
    }
247
248133×
    pub fn parse(slug: &str) -> Option<Section> {
2491232×
        GROUPS.iter().flat_map(|(_, pages)| pages.iter().copied()).find(|s| s.slug() == slug)
250133×
    }
251
252
    /// Whether only the repository's admins use it; its maintainers use
253
    /// the rest. The roles are those of each page's forms.
2542668×
    fn admin_only(self) -> bool {
2551552×
        matches!(
2562668×
            self,
257
            Section::Collaborators
258
                | Section::Runners
259
                | Section::Variables
260
                | Section::Secrets
261
                | Section::Webhooks
262
                | Section::Assistants
263
                | Section::Storage
264
                | Section::Advanced
265
        )
2662668×
    }
267
268
    /// Whether this viewer has the page: Storage only for a repository kept
269
    /// in a bucket.
2702668×
    fn shown(self, role: Role, in_bucket: bool) -> bool {
2712668×
        (!self.admin_only() || role == Role::Admin) && (self != Section::Storage || in_bucket)
2722668×
    }
273
}
274
275
/// The sidebar a viewer sees: the groups with at least one page for them.
276133×
fn sidebar(role: Role, in_bucket: bool) -> Vec<(&'static str, Vec<Section>)> {
277133×
    GROUPS
278133×
        .iter()
2792527×
        .map(|(group, pages)| (*group, pages.iter().copied().filter(|s| s.shown(role, in_bucket)).collect::<Vec<_>>()))
280798×
        .filter(|(_, pages)| !pages.is_empty())
281133×
        .collect()
282133×
}
283
284
/// Back to the page that holds a settings form, with what happened (#278).
28569×
fn back(row: &db::repos::RepoRow, section: Section, notice: Option<String>, error: Option<String>) -> Result<Response, Error> {
28669×
    let mut to = format!("/{}/{}/settings/{}", row.owner, row.name, section.slug());
28769×
    if let Some(n) = notice {
28851×
        to.push_str(&format!("?notice={}", super::settings::urlencode(&n)));
28951×
    } else if let Some(e) = error {
29018×
        to.push_str(&format!("?error={}", super::settings::urlencode(&e)));
29118×
    }
29269×
    Ok(Redirect::to(&to).into_response())
29369×
}
294
295
#[derive(Template)]
296
#[template(path = "repo_settings.html")]
297
pub struct RepoSettingsPage {
298
    /// The page shown, and the sidebar's groups of pages.
299
    section: Section,
300
    nav: Vec<(&'static str, Vec<Section>)>,
301
    shell: Shell,
302
    repo: RepoShell,
303
    row: db::repos::RepoRow,
304
    collaborators: Vec<accounts::Collaborator>,
305
    teams: Vec<accounts::Team>,
306
    role: Role,
307
    requirements: db::proposals::Requirements,
308
    hooks: Vec<super::webhooks::HookRow>,
309
    required_checks: String,
310
    /// Whether hub.toml alone would run Flow here, and whether it runs.
311
    flow_default: bool,
312
    /// hub.toml's `[diffusion] follow` (#148).
313
    diffusion_site: String,
314
    flow_enabled: bool,
315
    site_admin: bool,
316
    /// Whether the repository or its owner has a runner of its own (#225).
317
    own_runner: bool,
318
    notice: Option<String>,
319
    error: Option<String>,
320
    /// Runners that may take this repository's jobs: (runner, what it
321
    /// serves, whether it is this repository's own).
322
    runners: Vec<(db::runners::Runner, String, bool)>,
323
    /// A registration token just made, shown once.
324
    runner_token: Option<String>,
325
    /// The repository's AI assistants and prompt (#97).
326
    assistants: super::assistants::Panel,
327
    /// The repository's Flow variables (#102).
328
    variables: super::variables::Panel,
329
    /// The repository's Flow secrets (#107).
330
    secrets: super::secrets::Panel,
331
    /// Where its data lives, when in a bucket (oxygenhub#96).
332
    storage: Option<crate::storage::Storage>,
333
    /// The coverage check's rules (#113).
334
    coverage: crate::coverage::Rules,
335
    /// Its owner's backup, when one is set up (docs/backups.md §5).
336
    backup_owner: Option<crate::backups::Setting>,
337
    /// Push protection (#268): the findings of recent refused pushes not
338
    /// yet allowed, and the fingerprints allowed.
339
    push_findings: Vec<db::push_protection::FindingRow>,
340
    secret_bypasses: Vec<db::push_protection::Bypass>,
341
    /// The history scan (#277): its progress, its open findings and those
342
    /// marked resolved, and whether the hub runs it at all.
343
    history_scan: Option<db::push_protection::ScanRow>,
344
    history_findings: Vec<db::push_protection::HistoryRow>,
345
    resolved_findings: Vec<db::push_protection::HistoryRow>,
346
    history_scan_on: bool,
347
}
348
349
impl RepoSettingsPage {
3506×
    fn backup_choices(&self) -> &'static [(&'static str, &'static str)] {
3516×
        crate::backups::REPO_CHOICES
3526×
    }
353
}
354
355
/// One settings page, or not found when the viewer's role does not have it.
356141×
async fn settings_page(
357141×
    app: &Shared,
358141×
    shell: Shell,
359141×
    row: db::repos::RepoRow,
360141×
    role: Role,
361141×
    section: Section,
362141×
    notice: Option<String>,
363141×
    error: Option<String>,
364141×
) -> Result<Page<RepoSettingsPage>, Error> {
365141×
    let storage = crate::storage::of(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
366141×
    if !section.shown(role, storage.is_some()) {
3678×
        return Err(Error::not_found(shell));
368133×
    }
369133×
    let nav = sidebar(role, storage.is_some());
370133×
    let collaborators = accounts::collaborators(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
371133×
    let teams = match accounts::org_by_name(&app.db, &row.owner).await.map_err(|e| Error::from_db(shell.clone(), e))? {
37210×
        Some(org) => accounts::teams(&app.db, org.id).await.map_err(|e| Error::from_db(shell.clone(), e))?,
373123×
        None => vec![],
374
    };
375133×
    let repo = RepoShell::new(app, &row, "settings").with_role(role);
376133×
    let requirements = db::proposals::requirements(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
377133×
    let hooks = db::hooks::hooks_for_repo(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
378133×
    let hooks = super::webhooks::rows(app, hooks).await.map_err(|e| Error::from_db(shell.clone(), e))?;
379133×
    let required_checks = db::hooks::required_checks(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.join(", ");
380133×
    let flow_default = app.config.flow.allows(&row.owner, &row.name);
381133×
    let diffusion_site = app.config.diffusion.follow.clone();
382133×
    let flow_enabled = crate::flow::enabled(app, &row);
383133×
    let site_admin = shell.is_admin;
384133×
    let own_runner = has_own_runner(app, &row).await.map_err(|e| Error::from_db(shell.clone(), e))?;
385133×
    let runners = db::runners::serving(&app.db, &row.owner, row.id)
386133×
        .await
387133×
        .map_err(|e| Error::from_db(shell.clone(), e))?
388133×
        .into_iter()
389133×
        .map(|r| {
3906×
            let own = r.scope_repo_id == Some(row.id);
3916×
            let serves = if own {
3924×
                "this repository".to_string()
3932×
            } else if r.scope_owner.is_empty() {
3942×
                "the whole site".to_string()
395
            } else {
3960×
                format!("{}'s repositories", r.scope_owner)
397
            };
3986×
            (r, serves, own)
3996×
        })
400133×
        .collect();
401133×
    let assistants = super::assistants::panel(app, &repo_assistants(&row)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
402133×
    let variables = super::variables::panel(app, &repo_assistants(&row)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
403133×
    let secrets = super::secrets::panel(app, &repo_assistants(&row)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
404133×
    let coverage = crate::coverage::rules(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
405133×
    let backup_owner = crate::backups::setting(&app.db, &row.owner).await.map_err(|e| Error::from_db(shell.clone(), e))?;
406133×
    let push_findings = db::push_protection::recent(&app.db, row.id, 50).await.map_err(|e| Error::from_db(shell.clone(), e))?;
407133×
    let secret_bypasses = db::push_protection::bypasses(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
408133×
    let history_scan = db::push_protection::scan_of(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
409133×
    let history_findings = db::push_protection::history(&app.db, row.id, 200).await.map_err(|e| Error::from_db(shell.clone(), e))?;
410133×
    let resolved_findings = db::push_protection::resolved(&app.db, row.id, 50).await.map_err(|e| Error::from_db(shell.clone(), e))?;
411133×
    let history_scan_on = crate::history_scan::Budget::of(&app.config).is_some();
412133×
    Ok(Page(RepoSettingsPage {
413133×
        section,
414133×
        nav,
415133×
        push_findings,
416133×
        secret_bypasses,
417133×
        history_scan,
418133×
        history_findings,
419133×
        resolved_findings,
420133×
        history_scan_on,
421133×
        backup_owner,
422133×
        coverage,
423133×
        storage,
424133×
        assistants,
425133×
        variables,
426133×
        secrets,
427133×
        runners,
428133×
        runner_token: None,
429133×
        shell,
430133×
        repo,
431133×
        row,
432133×
        collaborators,
433133×
        teams,
434133×
        role,
435133×
        requirements,
436133×
        hooks,
437133×
        required_checks,
438133×
        flow_default,
439133×
        diffusion_site,
440133×
        flow_enabled,
441133×
        site_admin,
442133×
        own_runner,
443133×
        notice,
444133×
        error,
445133×
    }))
446141×
}
447
448
/// What a settings form did, carried to the page it redirects to.
449
#[derive(Deserialize, Default)]
450
pub struct Outcome {
451
    #[serde(default)]
452
    notice: Option<String>,
453
    #[serde(default)]
454
    error: Option<String>,
455
}
456
457
/// `GET /{owner}/{name}/settings`: the General page.
45812×
pub async fn settings(
45912×
    State(app): State<Shared>,
46012×
    viewer: Viewer,
46112×
    Path((owner, name)): Path<(String, String)>,
46212×
    Query(q): Query<Outcome>,
46312×
) -> Result<Page<RepoSettingsPage>, Error> {
46412×
    let path = format!("/{owner}/{name}/settings");
46512×
    show(&app, &viewer, &path, &owner, &name, Section::General, q).await
46612×
}
467
468
/// `GET /{owner}/{name}/settings/{page}` (#278): one page of the settings.
469
/// Some pages share their path with their form's `POST`, so the page is
470
/// the path's last segment.
471133×
pub async fn page(
472133×
    State(app): State<Shared>,
473133×
    viewer: Viewer,
474133×
    Path(params): Path<std::collections::HashMap<String, String>>,
475133×
    uri: Uri,
476133×
    Query(q): Query<Outcome>,
477133×
) -> Result<Page<RepoSettingsPage>, Error> {
478133×
    let (owner, name) = (params.get("owner").cloned().unwrap_or_default(), params.get("name").cloned().unwrap_or_default());
479133×
    let path = format!("/{owner}/{name}/settings");
480133×
    let slug = uri.path().rsplit('/').next().unwrap_or("");
481133×
    let Some(section) = Section::parse(slug) else {
4821×
        return Err(Error::not_found(Shell::new(&app, &path, &viewer)));
483
    };
484132×
    show(&app, &viewer, &format!("{path}/{slug}"), &owner, &name, section, q).await
485133×
}
486
487
/// A settings page for the repository's maintainers, or its admins: on an
488
/// archived repository, only its admins, who can bring it back.
489144×
async fn show(
490144×
    app: &Shared,
491144×
    viewer: &Viewer,
492144×
    path: &str,
493144×
    owner: &str,
494144×
    name: &str,
495144×
    section: Section,
496144×
    q: Outcome,
497144×
) -> Result<Page<RepoSettingsPage>, Error> {
498144×
    let shell = Shell::new(app, path, viewer);
499144×
    let (_, row, role) = lookup_role(app, &shell, viewer, owner, name, Access::Read).await?;
500144×
    if role < Role::Maintain {
5015×
        return Err(Error::not_found(shell));
502139×
    }
503139×
    if row.archived && role < Role::Admin {
5040×
        return Err(super::archived(&shell));
505139×
    }
506139×
    settings_page(app, shell, row, role, section, q.notice, q.error).await
507144×
}
508
509
#[derive(Deserialize)]
510
pub struct GeneralForm {
511
    #[serde(rename = "_csrf")]
512
    csrf_token: String,
513
    description: String,
514
    default_bookmark: String,
515
    visibility: String,
516
    #[serde(default)]
517
    topics: String,
518
}
519
5206×
pub async fn update(
5216×
    State(app): State<Shared>,
5226×
    viewer: Viewer,
5236×
    Path((owner, name)): Path<(String, String)>,
5246×
    Form(f): Form<GeneralForm>,
5256×
) -> Result<Response, Error> {
5266×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
5276×
    let (repo, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
5286×
    csrf(&viewer, &f.csrf_token, &shell)?;
5296×
    let e = |e| Error::from_db(shell.clone(), e);
530
    // A new default must be a bookmark the repository has, or every page
531
    // that opens it finds nothing; an empty repository may name any, for
532
    // its first push to create.
5336×
    let wanted = f.default_bookmark.trim().to_string();
5346×
    if wanted != row.default_bookmark {
5352×
        let names: Vec<String> = app
5362×
            .repos
5372×
            .read(&repo, |r, _| Ok(r.view()?.bookmarks.keys().cloned().collect::<Vec<String>>()))
5382×
            .await
5392×
            .map_err(|err| Error::from_repo(shell.clone(), err))?;
5402×
        if !names.is_empty() && !names.contains(&wanted) {
5412×
            let mut some: Vec<&str> = names.iter().map(String::as_str).filter(|n| *n == "main" || *n == "master").collect();
5422×
            some.extend(names.iter().map(String::as_str).filter(|n| *n != "main" && *n != "master").take(8 - some.len()));
5431×
            let more = if names.len() > some.len() { format!(" and {} more", names.len() - some.len()) } else { String::new() };
5441×
            let msg = format!("{wanted} is not a bookmark here yet: push it first, or choose one of {}{more}.", some.join(", "));
5451×
            return back(&row, Section::General, None, Some(msg));
5461×
        }
5474×
    }
5485×
    let topics = match db::repos::parse_topics(&f.topics) {
5494×
        Ok(t) => t,
5501×
        Err(msg) => return back(&row, Section::General, None, Some(msg)),
551
    };
5524×
    db::repos::set_description(&app.db, row.id, f.description.trim()).await.map_err(e)?;
5534×
    db::repos::set_topics(&app.db, row.id, &topics).await.map_err(e)?;
5544×
    if crate::repos::names::valid_segment(f.default_bookmark.trim()) {
5554×
        db::repos::set_default_bookmark(&app.db, row.id, f.default_bookmark.trim()).await.map_err(e)?;
5560×
    }
5574×
    if role >= Role::Admin {
5584×
        if let Some(v) = Visibility::parse(&f.visibility) {
5594×
            db::repos::set_visibility(&app.db, row.id, v).await.map_err(e)?;
5600×
        }
5610×
    }
5624×
    accounts::audit(&app.db, viewer.account(), "repo.updated", &format!("{owner}/{name}"), "").await.ok();
5634×
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(e)?.unwrap_or(row);
5644×
    back(&row, Section::General, Some("Saved.".into()), None)
5656×
}
566
567
#[derive(Deserialize)]
568
pub struct CollaboratorForm {
569
    #[serde(rename = "_csrf")]
570
    csrf_token: String,
571
    #[serde(default)]
572
    username: String,
573
    #[serde(default)]
574
    team: String,
575
    #[serde(default)]
576
    role: String,
577
    #[serde(default)]
578
    remove: Option<String>,
579
}
580
5815×
pub async fn collaborator(
5825×
    State(app): State<Shared>,
5835×
    viewer: Viewer,
5845×
    Path((owner, name)): Path<(String, String)>,
5855×
    Form(f): Form<CollaboratorForm>,
5865×
) -> Result<Response, Error> {
5875×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
5885×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
5895×
    csrf(&viewer, &f.csrf_token, &shell)?;
5905×
    let e = |e| Error::from_db(shell.clone(), e);
5915×
    let (account_id, team_id, label) = if !f.username.trim().is_empty() {
5925×
        match accounts::account_by_name(&app.db, f.username.trim()).await.map_err(e)? {
5935×
            Some(a) => (Some(a.id), None, a.username),
5940×
            None => return back(&row, Section::Collaborators, None, Some("No such account.".into())),
595
        }
5960×
    } else if !f.team.trim().is_empty() {
5970×
        let org = accounts::org_by_name(&app.db, &row.owner).await.map_err(e)?;
5980×
        let team = match org {
5990×
            Some(o) => accounts::team_by_name(&app.db, o.id, f.team.trim()).await.map_err(e)?,
6000×
            None => None,
601
        };
6020×
        match team {
6030×
            Some(t) => (None, Some(t.id), format!("team {}", t.name)),
6040×
            None => return back(&row, Section::Collaborators, None, Some("No such team in this organisation.".into())),
605
        }
606
    } else {
6070×
        return back(&row, Section::Collaborators, None, Some("Name an account or a team.".into()));
608
    };
6095×
    if f.remove.is_some() {
6100×
        accounts::remove_collaborator(&app.db, row.id, account_id, team_id).await.map_err(e)?;
6110×
        accounts::audit(&app.db, viewer.account(), "repo.collaborator.removed", &format!("{owner}/{name}"), &label).await.ok();
6120×
        return back(&row, Section::Collaborators, Some(format!("Removed {label}.")), None);
6135×
    }
6145×
    let Some(new_role) = Role::parse(&f.role) else {
6150×
        return back(&row, Section::Collaborators, None, Some("Pick a role.".into()));
616
    };
6175×
    accounts::set_collaborator(&app.db, row.id, account_id, team_id, new_role.as_str()).await.map_err(e)?;
6185×
    accounts::audit(
6195×
        &app.db,
6205×
        viewer.account(),
6215×
        "repo.collaborator.set",
6225×
        &format!("{owner}/{name}"),
6235×
        &format!("{label} {}", new_role.as_str()),
6245×
    )
6255×
    .await
6265×
    .ok();
6275×
    back(&row, Section::Collaborators, Some(format!("{label} now has {} access.", new_role.as_str())), None)
6285×
}
629
630
#[derive(Deserialize)]
631
pub struct TransferForm {
632
    #[serde(rename = "_csrf")]
633
    csrf_token: String,
634
    owner: String,
635
    confirm: String,
636
}
637
638
/// `POST /{owner}/{name}/settings/transfer`: move the repository to a
639
/// user or organisation the admin may create repositories under (#108).
640
/// The old URL redirects and oxy remotes keep working.
6413×
pub async fn transfer(
6423×
    State(app): State<Shared>,
6433×
    viewer: Viewer,
6443×
    Path((owner, name)): Path<(String, String)>,
6453×
    Form(f): Form<TransferForm>,
6463×
) -> Result<Response, Error> {
6473×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
6483×
    let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
6493×
    csrf(&viewer, &f.csrf_token, &shell)?;
6503×
    let me = viewer.account().cloned().ok_or_else(|| Error::not_found(shell.clone()))?;
6513×
    let fail = |msg: &str| back(&row, Section::Advanced, None, Some(msg.into()));
6523×
    if f.confirm.trim() != repo.to_string() {
6531×
        return fail("Type the repository's full name to confirm.");
6542×
    }
6552×
    let to = f.owner.trim().trim_start_matches('@').to_string();
6562×
    let Some(dest) = RepoName::new(&to, &row.name) else {
6570×
        return fail("That is not an owner name.");
658
    };
6592×
    if dest.owner == row.owner {
6600×
        return fail("The repository is already there.");
6612×
    }
6622×
    let e = |e| Error::from_db(shell.clone(), e);
6632×
    let known = accounts::account_by_name(&app.db, &dest.owner).await.map_err(e)?.is_some()
6642×
        || accounts::org_by_name(&app.db, &dest.owner).await.map_err(e)?.is_some();
6652×
    if !known {
6661×
        return fail("There is no user or organisation of that name.");
6671×
    }
6681×
    if !roles::may_create_under(&app.db, &me, &dest.owner).await.map_err(e)? {
6690×
        return fail("You cannot create repositories under that owner, so you cannot move one there.");
6701×
    }
6711×
    if db::repos::get(&app.db, &dest.owner, &dest.name).await.map_err(e)?.is_some() || app.repos.exists(&dest) {
6720×
        return fail("That owner already has a repository of this name.");
6731×
    }
674
    {
6751×
        let _guard = app.repos.exclusive_guard(&repo).await;
6761×
        app.repos.rename(&repo, &dest).map_err(|e| Error::from_repo(shell.clone(), e))?;
6771×
        crate::storage::renamed(&app, &repo, &dest);
6781×
        if app.wikis.exists(&repo) {
6790×
            app.wikis.rename(&repo, &dest).ok();
6801×
        }
6811×
        db::repos::transfer(&app.db, row.id, &row.owner, &row.name, &dest.owner).await.map_err(e)?;
6821×
        let mirror = crate::mirror::mirror_dir(&app, &repo);
6831×
        if mirror.is_dir() {
6840×
            let to = crate::mirror::mirror_dir(&app, &dest);
6850×
            if let Some(parent) = to.parent() {
6860×
                std::fs::create_dir_all(parent).ok();
6870×
            }
6880×
            std::fs::rename(&mirror, &to).ok();
6891×
        }
690
    }
6911×
    accounts::audit(&app.db, Some(&me), "repo.transferred", &repo.to_string(), &dest.to_string()).await.ok();
6921×
    let notice = super::settings::urlencode(&format!("Moved from {repo}; the old address redirects here."));
6931×
    Ok(Redirect::to(&format!("{}/settings/{}?notice={notice}", dest.url(), Section::Advanced.slug())).into_response())
6943×
}
695
696
#[derive(Deserialize)]
697
pub struct ArchiveForm {
698
    #[serde(rename = "_csrf")]
699
    csrf_token: String,
700
    /// `archive` or `unarchive`.
701
    action: String,
702
    #[serde(default)]
703
    reason: String,
704
}
705
706
/// `POST /{owner}/{name}/settings/archive`: read-only, with a reason, or
707
/// back again (#108).
7083×
pub async fn archive(
7093×
    State(app): State<Shared>,
7103×
    viewer: Viewer,
7113×
    Path((owner, name)): Path<(String, String)>,
7123×
    Form(f): Form<ArchiveForm>,
7133×
) -> Result<Response, Error> {
7143×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
7153×
    let (repo, mut row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
7163×
    csrf(&viewer, &f.csrf_token, &shell)?;
7173×
    let archive = match f.action.as_str() {
7183×
        "archive" => true,
7192×
        "unarchive" => false,
7201×
        _ => return back(&row, Section::Advanced, None, Some("Unknown action.".into())),
721
    };
7222×
    let reason: String = f.reason.trim().chars().take(500).collect();
7232×
    db::repos::set_archived(&app.db, row.id, archive, &reason).await.map_err(|e| Error::from_db(shell.clone(), e))?;
7242×
    accounts::audit(&app.db, viewer.account(), if archive { "repo.archived" } else { "repo.unarchived" }, &repo.to_string(), &reason)
7252×
        .await
7262×
        .ok();
7272×
    row.archived = archive;
7282×
    row.archived_reason = if archive { reason } else { String::new() };
7292×
    let notice = if archive {
7301×
        "The repository is archived: read-only until it is brought back."
731
    } else {
7321×
        "The repository is back: writes are allowed again."
733
    };
7342×
    back(&row, Section::Advanced, Some(notice.into()), None)
7353×
}
736
737
/// `POST /{owner}/{name}/settings/wiki/delete`: the wiki to the trash (#104).
7381×
pub async fn wiki_delete(
7391×
    State(app): State<Shared>,
7401×
    viewer: Viewer,
7411×
    Path((owner, name)): Path<(String, String)>,
7421×
    Form(f): Form<DeleteForm>,
7431×
) -> Result<Response, Error> {
7441×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
7451×
    let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
7461×
    csrf(&viewer, &f.csrf_token, &shell)?;
7471×
    if f.confirm.trim() != repo.to_string() {
7480×
        return back(&row, Section::Advanced, None, Some("Type the repository's full name to confirm.".into()));
7491×
    }
7501×
    if !crate::wiki::exists(&app, &repo) {
7510×
        return back(&row, Section::Advanced, None, Some("This repository has no wiki.".into()));
7521×
    }
7531×
    let _guard = app.wikis.exclusive_guard(&repo).await;
7541×
    crate::wiki::trash(&app, &repo).map_err(|e| Error::from_repo(shell.clone(), e))?;
7551×
    drop(_guard);
7561×
    accounts::audit(&app.db, viewer.account(), "wiki.data_deleted", &repo.to_string(), "moved to trash").await.ok();
7571×
    back(&row, Section::Advanced, Some("The wiki is in the trash; a new first page starts a fresh one.".into()), None)
7581×
}
759
760
#[derive(Deserialize)]
761
pub struct DeleteForm {
762
    #[serde(rename = "_csrf")]
763
    csrf_token: String,
764
    confirm: String,
765
}
766
7671×
pub async fn delete(
7681×
    State(app): State<Shared>,
7691×
    viewer: Viewer,
7701×
    Path((owner, name)): Path<(String, String)>,
7711×
    Form(f): Form<DeleteForm>,
7721×
) -> Result<Response, Error> {
7731×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
7741×
    let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
7751×
    csrf(&viewer, &f.csrf_token, &shell)?;
7761×
    if f.confirm.trim() != repo.to_string() {
7770×
        return back(&row, Section::Advanced, None, Some("Type the repository's full name to delete it.".into()));
7781×
    }
7791×
    let _guard = app.repos.exclusive_guard(&repo).await;
7801×
    app.repos.trash(&repo).map_err(|e| Error::from_repo(shell.clone(), e))?;
781
    // A repository kept in a bucket leaves its data there: the owner's.
7821×
    crate::storage::removed(&app, &repo);
7831×
    crate::wiki::trash(&app, &repo).ok();
7841×
    db::repos::delete(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
7851×
    crate::assistants::delete_scope(&app.db, crate::levels::Scope::Repo(row.id)).await.ok();
7861×
    crate::variables::delete_scope(&app.db, crate::levels::Scope::Repo(row.id)).await.ok();
7871×
    crate::secrets::delete_scope(&app.db, crate::levels::Scope::Repo(row.id)).await.ok();
7881×
    accounts::audit(&app.db, viewer.account(), "repo.deleted", &repo.to_string(), "moved to trash").await.ok();
7891×
    Ok(Redirect::to(&format!("/{owner}")).into_response())
7901×
}
791
792
#[derive(Deserialize)]
793
pub struct RequirementsForm {
794
    #[serde(rename = "_csrf")]
795
    csrf_token: String,
796
    #[serde(default)]
797
    required_approvals: i64,
798
    #[serde(default)]
799
    require_owners: Option<String>,
800
    #[serde(default)]
801
    allow_conflicts: Option<String>,
802
}
803
8042×
pub async fn requirements(
8052×
    State(app): State<Shared>,
8062×
    viewer: Viewer,
8072×
    Path((owner, name)): Path<(String, String)>,
8082×
    Form(f): Form<RequirementsForm>,
8092×
) -> Result<Response, Error> {
8102×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
8112×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
8122×
    csrf(&viewer, &f.csrf_token, &shell)?;
8132×
    let r = db::proposals::Requirements {
8142×
        required_approvals: f.required_approvals.max(0),
8152×
        require_owners: f.require_owners.is_some(),
8162×
        allow_conflicts: f.allow_conflicts.is_some(),
8172×
    };
8182×
    db::proposals::set_requirements(&app.db, row.id, r).await.map_err(|e| Error::from_db(shell.clone(), e))?;
8192×
    accounts::audit(
8202×
        &app.db,
8212×
        viewer.account(),
8222×
        "repo.requirements",
8232×
        &format!("{owner}/{name}"),
8242×
        &format!("{} approvals, owners {}, conflicts {}", r.required_approvals, r.require_owners, r.allow_conflicts),
8252×
    )
8262×
    .await
8272×
    .ok();
8282×
    back(&row, Section::Landing, Some("Landing requirements saved.".into()), None)
8292×
}
830
8316×
pub async fn hooks(
8326×
    State(app): State<Shared>,
8336×
    viewer: Viewer,
8346×
    Path((owner, name)): Path<(String, String)>,
8356×
    Form(f): Form<super::webhooks::HookForm>,
8366×
) -> Result<Response, Error> {
8376×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
8386×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
8396×
    csrf(&viewer, &f.csrf_token, &shell)?;
8406×
    let scope = super::webhooks::Scope::Repo { id: row.id, slug: format!("{owner}/{name}") };
8416×
    match super::webhooks::apply(&app, &viewer, &scope, f).await.map_err(|e| Error::from_db(shell.clone(), e))? {
8425×
        Ok(notice) => back(&row, Section::Webhooks, Some(notice), None),
8431×
        Err(error) => back(&row, Section::Webhooks, None, Some(error)),
844
    }
8456×
}
846
8470×
pub async fn deliveries(
8480×
    State(app): State<Shared>,
8490×
    viewer: Viewer,
8500×
    Path((owner, name, id)): Path<(String, String, i64)>,
8510×
) -> Result<Page<super::webhooks::DeliveriesPage>, Error> {
8520×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings/hooks/{id}/deliveries"), &viewer);
8530×
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
8540×
    let scope = super::webhooks::Scope::Repo { id: row.id, slug: format!("{owner}/{name}") };
8550×
    super::webhooks::deliveries_page(&app, shell, &scope, Some(RepoShell::new(&app, &row, "settings").with_role(role)), id).await
8560×
}
857
8580×
pub async fn redeliver(
8590×
    State(app): State<Shared>,
8600×
    viewer: Viewer,
8610×
    Path((owner, name, id, delivery)): Path<(String, String, i64, i64)>,
8620×
    Form(f): Form<super::session::CsrfForm>,
8630×
) -> Result<Page<super::webhooks::DeliveriesPage>, Error> {
8640×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings/hooks/{id}/deliveries"), &viewer);
8650×
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
8660×
    csrf(&viewer, &f.csrf, &shell)?;
8670×
    let scope = super::webhooks::Scope::Repo { id: row.id, slug: format!("{owner}/{name}") };
8680×
    super::webhooks::redeliver(&app, &scope, id, delivery).await.map_err(|e| Error::from_db(shell.clone(), e))?;
8690×
    super::webhooks::deliveries_page(&app, shell, &scope, Some(RepoShell::new(&app, &row, "settings").with_role(role)), id).await
8700×
}
871
872
#[derive(Deserialize)]
873
pub struct ChecksForm {
874
    #[serde(rename = "_csrf")]
875
    csrf_token: String,
876
    #[serde(default)]
877
    required_checks: String,
878
}
879
8802×
pub async fn required_checks(
8812×
    State(app): State<Shared>,
8822×
    viewer: Viewer,
8832×
    Path((owner, name)): Path<(String, String)>,
8842×
    Form(f): Form<ChecksForm>,
8852×
) -> Result<Response, Error> {
8862×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
8872×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
8882×
    csrf(&viewer, &f.csrf_token, &shell)?;
8892×
    let names: Vec<String> = f.required_checks.split(',').map(str::trim).filter(|s| !s.is_empty()).map(String::from).collect();
8902×
    db::hooks::set_required_checks(&app.db, row.id, &names).await.map_err(|e| Error::from_db(shell.clone(), e))?;
8912×
    back(&row, Section::Checks, Some("Required checks saved.".into()), None)
8922×
}
893
894
#[derive(Deserialize)]
895
pub struct CoverageForm {
896
    #[serde(rename = "_csrf")]
897
    csrf_token: String,
898
    #[serde(default)]
899
    min: String,
900
    #[serde(default)]
901
    max_drop: String,
902
}
903
904
/// Settings → Coverage (#113): the rules the `coverage` check applies.
9051×
pub async fn coverage(
9061×
    State(app): State<Shared>,
9071×
    viewer: Viewer,
9081×
    Path((owner, name)): Path<(String, String)>,
9091×
    Form(f): Form<CoverageForm>,
9101×
) -> Result<Response, Error> {
9111×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
9121×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
9131×
    csrf(&viewer, &f.csrf_token, &shell)?;
9142×
    let number = |v: &str, what: &str, hi: f64| -> Result<Option<f64>, String> {
9152×
        let v = v.trim().trim_end_matches('%').trim();
9162×
        if v.is_empty() {
9171×
            return Ok(None);
9181×
        }
9191×
        match v.parse::<f64>() {
9201×
            Ok(n) if (0.0..=hi).contains(&n) => Ok(Some(n)),
9210×
            _ => Err(format!("{what} is a number from 0 to {hi}, or empty for no rule.")),
922
        }
9232×
    };
9241×
    let rules = match (number(&f.min, "The minimum", 100.0), number(&f.max_drop, "The largest drop", 100.0)) {
9251×
        (Ok(min), Ok(max_drop)) => crate::coverage::Rules { min, max_drop },
9260×
        (Err(e), _) | (_, Err(e)) => return back(&row, Section::Coverage, None, Some(e)),
927
    };
9281×
    crate::coverage::set_rules(&app.db, row.id, rules).await.map_err(|e| Error::from_db(shell.clone(), e))?;
9291×
    accounts::audit(
9301×
        &app.db,
9311×
        viewer.account(),
9321×
        "repo.coverage_rules",
9331×
        &format!("{owner}/{name}"),
9341×
        &format!("min {:?}, max drop {:?}", rules.min, rules.max_drop),
9351×
    )
9361×
    .await
9371×
    .ok();
9381×
    back(&row, Section::Coverage, Some("Coverage rules saved; they apply from the next report.".into()), None)
9391×
}
940
941
#[derive(Deserialize)]
942
pub struct StorageForm {
943
    #[serde(rename = "_csrf")]
944
    csrf_token: String,
945
    /// `check`, or `credentials` (with the new ones).
946
    action: String,
947
    #[serde(flatten)]
948
    bucket: BucketForm,
949
}
950
951
/// Settings → Storage (oxygenhub#96): check the bucket again, or replace
952
/// its credentials once the new ones pass the check.
9530×
pub async fn storage(
9540×
    State(app): State<Shared>,
9550×
    viewer: Viewer,
9560×
    Path((owner, name)): Path<(String, String)>,
9570×
    Form(f): Form<StorageForm>,
9580×
) -> Result<Response, Error> {
9590×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
9600×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
9610×
    csrf(&viewer, &f.csrf_token, &shell)?;
9620×
    let Some(s) = crate::storage::of(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))? else {
9630×
        return back(&row, Section::General, None, Some("This repository is kept on this server's disk.".into()));
964
    };
9650×
    let scheme = s.url.split("://").next().unwrap_or("").to_string();
9660×
    let outcome = match f.action.as_str() {
9670×
        "credentials" => match f.bucket.credentials(&scheme) {
9680×
            Ok(c) => crate::storage::set_credentials(&app, &s, c).await.map(|r| format!("New credentials saved: {r}.")),
9690×
            Err(e) => Err(format!("Credentials: {e}")),
970
        },
9710×
        _ => crate::storage::check(&app, &s).await.map(|r| format!("The bucket passed the check: {r}.")),
972
    };
9730×
    match outcome {
9740×
        Ok(notice) => {
9750×
            accounts::audit(&app.db, viewer.account(), "repo.storage", &format!("{owner}/{name}"), &f.action).await.ok();
9760×
            back(&row, Section::Storage, Some(notice), None)
977
        }
9780×
        Err(e) => back(&row, Section::Storage, None, Some(format!("Storage: {e}"))),
979
    }
9800×
}
981
982
#[derive(Deserialize)]
983
pub struct MirrorForm {
984
    #[serde(rename = "_csrf")]
985
    csrf_token: String,
986
    #[serde(default)]
987
    enabled: Option<String>,
988
}
989
990
#[derive(Deserialize)]
991
pub struct BackupForm {
992
    #[serde(rename = "_csrf")]
993
    csrf_token: String,
994
    /// Empty (with its owner's), `hourly`, `daily` or `off`.
995
    #[serde(default)]
996
    backup: String,
997
}
998
999
/// This repository's backup (docs/backups.md §5): with its owner's, left
1000
/// out, or at least hourly or daily. Its owners and admins decide.
10013×
pub async fn backup(
10023×
    State(app): State<Shared>,
10033×
    viewer: Viewer,
10043×
    Path((owner, name)): Path<(String, String)>,
10053×
    Form(f): Form<BackupForm>,
10063×
) -> Result<Response, Error> {
10073×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
10083×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
10093×
    csrf(&viewer, &f.csrf_token, &shell)?;
101012×
    if !crate::backups::REPO_CHOICES.iter().any(|(v, _)| *v == f.backup) {
10111×
        return back(&row, Section::Backup, None, Some("Pick how this repository is backed up.".into()));
10122×
    }
10132×
    db::repos::set_backup(&app.db, row.id, &f.backup).await.map_err(|e| Error::from_db(shell.clone(), e))?;
10142×
    let what = if f.backup.is_empty() { "with its owner's" } else { f.backup.as_str() };
10152×
    accounts::audit(&app.db, viewer.account(), "repo.backup", &format!("{owner}/{name}"), what).await.ok();
10162×
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
10172×
    let notice = match f.backup.as_str() {
10182×
        "off" => "This repository is left out of its owner's backups from the next run.".to_string(),
10190×
        "" => "This repository is backed up with its owner's.".to_string(),
10200×
        every => {
10210×
            format!("This repository is backed up at least {} from now on.", if every == "hourly" { "every hour" } else { "every day" })
1022
        }
1023
    };
10242×
    back(&row, Section::Backup, Some(notice), None)
10253×
}
1026
1027
#[derive(Deserialize)]
1028
pub struct DiffusionForm {
1029
    #[serde(rename = "_csrf")]
1030
    csrf_token: String,
1031
    /// `off`, `green`, `any`, or `default`.
1032
    diffusion: String,
1033
    /// `auto` or `propose` (absent keeps it as it is).
1034
    #[serde(default)]
1035
    land: String,
1036
}
1037
1038
/// Whether this repository follows the upstreams it pins (Diffusion,
1039
/// #148): its owners and admins decide, since it opens proposals here.
10401×
pub async fn diffusion(
10411×
    State(app): State<Shared>,
10421×
    viewer: Viewer,
10431×
    Path((owner, name)): Path<(String, String)>,
10441×
    Form(f): Form<DiffusionForm>,
10451×
) -> Result<Response, Error> {
10461×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
10471×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
10481×
    csrf(&viewer, &f.csrf_token, &shell)?;
10491×
    let setting = match f.diffusion.as_str() {
10501×
        "off" | "green" | "any" => f.diffusion.as_str(),
10510×
        "default" => "",
10520×
        _ => return back(&row, Section::Diffusion, None, Some("Pick off, green, any or the site default.".into())),
1053
    };
10541×
    let land = match f.land.as_str() {
10551×
        "auto" | "propose" => Some(f.land.as_str()),
10560×
        "" => None,
10570×
        _ => return back(&row, Section::Diffusion, None, Some("Pick propose or auto for landing.".into())),
1058
    };
10591×
    db::repos::set_diffusion(&app.db, row.id, setting).await.map_err(|e| Error::from_db(shell.clone(), e))?;
10601×
    if let Some(l) = land {
10611×
        db::repos::set_diffusion_land(&app.db, row.id, l).await.map_err(|e| Error::from_db(shell.clone(), e))?;
10620×
    }
10631×
    let what =
10641×
        format!("{}{}", if setting.is_empty() { "default" } else { setting }, land.map(|l| format!(", land {l}")).unwrap_or_default());
10651×
    accounts::audit(&app.db, viewer.account(), "repo.diffusion", &format!("{owner}/{name}"), &what).await.ok();
10661×
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
1067
    // Turned to land by itself: any bump already green lands now.
10681×
    if land == Some("auto") {
10690×
        let (app, row) = (app.clone(), row.clone());
10700×
        tokio::spawn(async move { crate::diffusion::land_ready(&app, &row).await });
10711×
    }
10721×
    if crate::diffusion::rule(&app.config.diffusion.follow, &row, None, "").follow != crate::diffusion::Follow::Off {
10730×
        let (app, row) = (app.clone(), row.clone());
10740×
        tokio::spawn(async move { crate::diffusion::catch_up(&app, &row).await });
10751×
    }
10761×
    let notice = match crate::diffusion::rule(&app.config.diffusion.follow, &row, None, "").follow {
10771×
        crate::diffusion::Follow::Off => "Diffusion is off: upstream moves open no proposals here.",
1078
        crate::diffusion::Follow::Green => {
10790×
            "Diffusion is on: when a subrepo's upstream moves and its checks pass, a proposal moves the pin here."
1080
        }
10810×
        crate::diffusion::Follow::Any => "Diffusion is on: every move of a subrepo's upstream opens or updates a proposal here.",
1082
    };
10831×
    back(&row, Section::Diffusion, Some(notice.into()), None)
10841×
}
1085
1086
#[derive(Deserialize)]
1087
pub struct AgentGatesForm {
1088
    #[serde(rename = "_csrf")]
1089
    csrf_token: String,
1090
    /// `on` when the box is ticked.
1091
    #[serde(default)]
1092
    agents: String,
1093
}
1094
1095
/// `POST /{owner}/{repo}/settings/agent-gates` (oxygenhub#258): whether an
1096
/// agent's token may approve or reject this repository's approval gates;
1097
/// for maintainers, audited.
10981×
pub async fn agent_gates(
10991×
    State(app): State<Shared>,
11001×
    viewer: Viewer,
11011×
    Path((owner, name)): Path<(String, String)>,
11021×
    Form(f): Form<AgentGatesForm>,
11031×
) -> Result<Response, Error> {
11041×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
11051×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
11061×
    csrf(&viewer, &f.csrf_token, &shell)?;
11071×
    let on = f.agents == "on";
11081×
    db::repos::set_agents_decide_gates(&app.db, row.id, on).await.map_err(|e| Error::from_db(shell.clone(), e))?;
11091×
    accounts::audit(&app.db, viewer.account(), "repo.agents_decide_gates", &format!("{owner}/{name}"), if on { "on" } else { "off" })
11101×
        .await
11111×
        .ok();
11121×
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
11131×
    let notice = if on {
11141×
        "Agents' tokens may now approve or reject this repository's approval gates; it says so on each."
1115
    } else {
11160×
        "Approval gates here are decided by people: agents' tokens are refused."
1117
    };
11181×
    back(&row, Section::Gates, Some(notice.into()), None)
11191×
}
1120
1121
#[derive(Deserialize)]
1122
pub struct PushProtectionForm {
1123
    #[serde(rename = "_csrf")]
1124
    csrf_token: String,
1125
    /// `on` when the box is ticked.
1126
    #[serde(default)]
1127
    protect: String,
1128
}
1129
1130
/// `POST /{owner}/{repo}/settings/push-protection` (oxygenhub#268): whether
1131
/// pushes adding what looks like a secret are refused; for maintainers,
1132
/// audited.
11334×
pub async fn push_protection(
11344×
    State(app): State<Shared>,
11354×
    viewer: Viewer,
11364×
    Path((owner, name)): Path<(String, String)>,
11374×
    Form(f): Form<PushProtectionForm>,
11384×
) -> Result<Response, Error> {
11394×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
11404×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
11414×
    csrf(&viewer, &f.csrf_token, &shell)?;
11424×
    let on = f.protect == "on";
11434×
    db::push_protection::set_enabled(&app.db, row.id, on).await.map_err(|e| Error::from_db(shell.clone(), e))?;
11444×
    accounts::audit(&app.db, viewer.account(), "repo.push_protection", &format!("{owner}/{name}"), if on { "on" } else { "off" })
11454×
        .await
11464×
        .ok();
11474×
    if on {
11482×
        // What was pushed while it was off is scanned now (#277).
11492×
        crate::history_scan::wake(&app, row.id);
11502×
    }
11514×
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
11524×
    let notice = if on {
11532×
        "Push protection is on: a push that adds what looks like a secret is refused, saying where."
1154
    } else {
11552×
        "Push protection is off: pushes are no longer scanned for secrets here."
1156
    };
11574×
    back(&row, Section::PushProtection, Some(notice.into()), None)
11584×
}
1159
1160
#[derive(Deserialize)]
1161
pub struct SecretBypassForm {
1162
    #[serde(rename = "_csrf")]
1163
    csrf_token: String,
1164
    fingerprint: String,
1165
    #[serde(default)]
1166
    reason: String,
1167
}
1168
1169
/// `POST /{owner}/{repo}/settings/secret-bypass` (oxygenhub#268): let one
1170
/// push protection finding through, by its fingerprint, with a reason; for
1171
/// maintainers, audited as `secret.bypass`.
11725×
pub async fn secret_bypass(
11735×
    State(app): State<Shared>,
11745×
    viewer: Viewer,
11755×
    Path((owner, name)): Path<(String, String)>,
11765×
    Form(f): Form<SecretBypassForm>,
11775×
) -> Result<Response, Error> {
11785×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
11795×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
11804×
    csrf(&viewer, &f.csrf_token, &shell)?;
11814×
    let fingerprint = f.fingerprint.trim().to_ascii_lowercase();
11824×
    let reason = f.reason.trim();
118364×
    let error = if fingerprint.len() != 16 || !fingerprint.chars().all(|c| c.is_ascii_hexdigit()) {
11840×
        Some("A fingerprint is the 16 hexadecimal digits a refused push printed.")
11854×
    } else if reason.is_empty() {
11861×
        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.")
11873×
    } else if reason.chars().count() > 500 {
11880×
        Some("Keep the reason under 500 characters.")
1189
    } else {
11903×
        None
1191
    };
11924×
    if let Some(e) = error {
11931×
        return back(&row, Section::PushProtection, None, Some(e.into()));
11943×
    }
11953×
    let rule = db::push_protection::rule_of(&app.db, row.id, &fingerprint)
11963×
        .await
11973×
        .map_err(|e| Error::from_db(shell.clone(), e))?
11983×
        .unwrap_or_default();
11993×
    let by = viewer.account().map(|a| (a.id, a.username.as_str()));
12003×
    db::push_protection::allow(&app.db, row.id, &fingerprint, &rule, reason, by).await.map_err(|e| Error::from_db(shell.clone(), e))?;
12013×
    let detail = if rule.is_empty() { format!("{fingerprint}: {reason}") } else { format!("{fingerprint} ({rule}): {reason}") };
12023×
    accounts::audit(&app.db, viewer.account(), "secret.bypass", &format!("{owner}/{name}"), &detail).await.ok();
12033×
    let notice = format!("Fingerprint {fingerprint} is allowed here: a push adding that value is no longer refused for it.");
12043×
    back(&row, Section::PushProtection, Some(notice), None)
12055×
}
1206
1207
#[derive(Deserialize)]
1208
pub struct BypassRemoveForm {
1209
    #[serde(rename = "_csrf")]
1210
    csrf_token: String,
1211
    fingerprint: String,
1212
}
1213
1214
/// `POST /{owner}/{repo}/settings/secret-bypass/remove` (oxygenhub#277):
1215
/// take back an allowed fingerprint, so a push adding that value is refused
1216
/// again; for maintainers, audited as `secret.bypass_removed`.
12173×
pub async fn secret_bypass_remove(
12183×
    State(app): State<Shared>,
12193×
    viewer: Viewer,
12203×
    Path((owner, name)): Path<(String, String)>,
12213×
    Form(f): Form<BypassRemoveForm>,
12223×
) -> Result<Response, Error> {
12233×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
12243×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
12252×
    csrf(&viewer, &f.csrf_token, &shell)?;
12262×
    let fingerprint = f.fingerprint.trim().to_ascii_lowercase();
12272×
    let removed = db::push_protection::remove_bypass(&app.db, row.id, &fingerprint).await.map_err(|e| Error::from_db(shell.clone(), e))?;
12282×
    let Some(b) = removed else {
12291×
        return back(&row, Section::PushProtection, None, Some(format!("Fingerprint {fingerprint} is not allowed here.")));
1230
    };
12311×
    let detail = if b.rule.is_empty() {
12320×
        format!("{fingerprint} (was: {})", b.reason)
1233
    } else {
12341×
        format!("{fingerprint} ({}) (was: {})", b.rule, b.reason)
1235
    };
12361×
    accounts::audit(&app.db, viewer.account(), "secret.bypass_removed", &format!("{owner}/{name}"), &detail).await.ok();
12371×
    let notice = format!("Fingerprint {fingerprint} is no longer allowed: a push adding that value is refused again.");
12381×
    back(&row, Section::PushProtection, Some(notice), None)
12393×
}
1240
1241
#[derive(Deserialize)]
1242
pub struct SecretResolveForm {
1243
    #[serde(rename = "_csrf")]
1244
    csrf_token: String,
1245
    finding: i64,
1246
    #[serde(default)]
1247
    reason: String,
1248
}
1249
1250
/// `POST /{owner}/{repo}/settings/secret-resolve` (oxygenhub#277): mark a
1251
/// finding of the history scan resolved (the secret was revoked or
1252
/// rotated), with a reason; for maintainers, audited as `secret.resolved`.
12534×
pub async fn secret_resolve(
12544×
    State(app): State<Shared>,
12554×
    viewer: Viewer,
12564×
    Path((owner, name)): Path<(String, String)>,
12574×
    Form(f): Form<SecretResolveForm>,
12584×
) -> Result<Response, Error> {
12594×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
12604×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
12613×
    csrf(&viewer, &f.csrf_token, &shell)?;
12623×
    let reason = f.reason.trim();
12633×
    let error = if reason.is_empty() {
12641×
        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.")
12652×
    } else if reason.chars().count() > 500 {
12660×
        Some("Keep the reason under 500 characters.")
1267
    } else {
12682×
        None
1269
    };
12703×
    if let Some(e) = error {
12711×
        return back(&row, Section::PushProtection, None, Some(e.into()));
12722×
    }
12732×
    let by = viewer.account().map(|a| a.username.as_str()).unwrap_or("");
12742×
    let done = db::push_protection::resolve(&app.db, row.id, f.finding, by, reason).await.map_err(|e| Error::from_db(shell.clone(), e))?;
12752×
    let Some(found) = done else {
12761×
        return back(&row, Section::PushProtection, None, Some("That finding is not open here.".into()));
1277
    };
12781×
    let detail = format!("{} ({}) at {}:{}: {reason}", found.fingerprint, found.rule, found.path, found.line);
12791×
    accounts::audit(&app.db, viewer.account(), "secret.resolved", &format!("{owner}/{name}"), &detail).await.ok();
12801×
    let notice = format!("The finding at {}:{} is marked resolved.", found.path, found.line);
12811×
    back(&row, Section::PushProtection, Some(notice), None)
12824×
}
1283
1284
#[derive(Deserialize)]
1285
pub struct SecretScanForm {
1286
    #[serde(rename = "_csrf")]
1287
    csrf_token: String,
1288
}
1289
1290
/// `POST /{owner}/{repo}/settings/secret-scan` (oxygenhub#277): scan the
1291
/// whole history again now, with the current rules; for maintainers,
1292
/// audited as `secret.history_scan`.
12932×
pub async fn secret_scan(
12942×
    State(app): State<Shared>,
12952×
    viewer: Viewer,
12962×
    Path((owner, name)): Path<(String, String)>,
12972×
    Form(f): Form<SecretScanForm>,
12982×
) -> Result<Response, Error> {
12992×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
13002×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
13011×
    csrf(&viewer, &f.csrf_token, &shell)?;
13021×
    if !row.push_protection {
13030×
        return back(
13040×
            &row,
13050×
            Section::PushProtection,
13060×
            None,
13070×
            Some("Turn push protection on first: history is scanned only where it is on.".into()),
1308
        );
13091×
    }
13101×
    crate::history_scan::request(&app, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
13111×
    accounts::audit(&app.db, viewer.account(), "secret.history_scan", &format!("{owner}/{name}"), "requested").await.ok();
13121×
    let notice = "The whole history will be scanned again, before other repositories: findings appear below as they are found.";
13131×
    back(&row, Section::PushProtection, Some(notice.into()), None)
13142×
}
1315
1316
#[derive(Deserialize)]
1317
pub struct SupersedeForm {
1318
    #[serde(rename = "_csrf")]
1319
    csrf_token: String,
1320
    /// `on` when the box is ticked: superseded runs already running are
1321
    /// cancelled too.
1322
    #[serde(default)]
1323
    running: String,
1324
}
1325
1326
/// `POST /{owner}/{repo}/settings/flow-supersede` (oxygenhub#267): whether
1327
/// a new push or proposal run cancels the older runs it supersedes that are
1328
/// already running, or only those still queued; for maintainers, audited.
13291×
pub async fn flow_supersede(
13301×
    State(app): State<Shared>,
13311×
    viewer: Viewer,
13321×
    Path((owner, name)): Path<(String, String)>,
13331×
    Form(f): Form<SupersedeForm>,
13341×
) -> Result<Response, Error> {
13351×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
13361×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
13371×
    csrf(&viewer, &f.csrf_token, &shell)?;
13381×
    let keep = f.running != "on";
13391×
    db::repos::set_flow_keep_running(&app.db, row.id, keep).await.map_err(|e| Error::from_db(shell.clone(), e))?;
13401×
    accounts::audit(&app.db, viewer.account(), "repo.flow_keep_running", &format!("{owner}/{name}"), if keep { "on" } else { "off" })
13411×
        .await
13421×
        .ok();
13431×
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
13441×
    let notice = if keep {
13450×
        "A new push or proposal run now cancels only the older runs still queued; those already running finish."
1346
    } else {
13471×
        "A new push or proposal run now cancels the older runs it supersedes, queued or running."
1348
    };
13491×
    back(&row, Section::Runs, Some(notice.into()), None)
13501×
}
1351
1352
#[derive(Deserialize)]
1353
pub struct ReuseForm {
1354
    #[serde(rename = "_csrf")]
1355
    csrf_token: String,
1356
    /// `on` when the box is ticked.
1357
    #[serde(default)]
1358
    reuse: String,
1359
}
1360
1361
/// `POST /{owner}/{repo}/settings/flow-reuse` (oxygenhub#265): whether a
1362
/// push run reuses a job's pass on the same state; for maintainers,
1363
/// audited.
13640×
pub async fn flow_reuse(
13650×
    State(app): State<Shared>,
13660×
    viewer: Viewer,
13670×
    Path((owner, name)): Path<(String, String)>,
13680×
    Form(f): Form<ReuseForm>,
13690×
) -> Result<Response, Error> {
13700×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
13710×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
13720×
    csrf(&viewer, &f.csrf_token, &shell)?;
13730×
    let on = f.reuse == "on";
13740×
    db::repos::set_flow_reuse(&app.db, row.id, on).await.map_err(|e| Error::from_db(shell.clone(), e))?;
13750×
    accounts::audit(&app.db, viewer.account(), "repo.flow_reuse", &format!("{owner}/{name}"), if on { "on" } else { "off" }).await.ok();
13760×
    let notice = if on {
13770×
        "A push run now reuses a job's pass on the same state."
1378
    } else {
13790×
        "Every job of every push now runs, passed before or not."
1380
    };
13810×
    back(&row, Section::Runs, Some(notice.into()), None)
13820×
}
1383
1384
#[derive(Deserialize)]
1385
pub struct SkipForm {
1386
    #[serde(rename = "_csrf")]
1387
    csrf_token: String,
1388
    /// `any`, `others` or `never`.
1389
    skip: String,
1390
}
1391
1392
/// `POST /{owner}/{repo}/settings/flow-skip` (oxygenhub#284): where a push
1393
/// may skip its run with `[skip flow]` in its message; for maintainers,
1394
/// audited.
13952×
pub async fn flow_skip(
13962×
    State(app): State<Shared>,
13972×
    viewer: Viewer,
13982×
    Path((owner, name)): Path<(String, String)>,
13992×
    Form(f): Form<SkipForm>,
14002×
) -> Result<Response, Error> {
14012×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
14022×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
14032×
    csrf(&viewer, &f.csrf_token, &shell)?;
14042×
    let notice = match f.skip.as_str() {
14052×
        "any" => "A push may now skip its run on any bookmark.".to_string(),
14062×
        "others" => format!("A push may now skip its run on any bookmark but {}.", row.default_bookmark),
14071×
        "never" => "Every push now runs; skip markers are ignored.".to_string(),
14081×
        _ => return back(&row, Section::Runs, None, Some("Choose where a push may skip its run.".into())),
1409
    };
14101×
    db::repos::set_flow_skip(&app.db, row.id, &f.skip).await.map_err(|e| Error::from_db(shell.clone(), e))?;
14111×
    accounts::audit(&app.db, viewer.account(), "repo.flow_skip", &format!("{owner}/{name}"), &f.skip).await.ok();
14121×
    back(&row, Section::Runs, Some(notice), None)
14132×
}
1414
1415
#[derive(Deserialize)]
1416
pub struct PriorityForm {
1417
    #[serde(rename = "_csrf")]
1418
    csrf_token: String,
1419
    /// `low`, `normal`, `high`, or `default`.
1420
    priority: String,
1421
}
1422
1423
/// `POST /{owner}/{repo}/settings/flow-priority` (oxygenhub#244): its jobs'
1424
/// level. Maintainers choose low, normal or the default; only site
1425
/// administrators raise a repository to high.
14262×
pub async fn flow_priority(
14272×
    State(app): State<Shared>,
14282×
    viewer: Viewer,
14292×
    Path((owner, name)): Path<(String, String)>,
14302×
    Form(f): Form<PriorityForm>,
14312×
) -> Result<Response, Error> {
14322×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
14332×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
14342×
    csrf(&viewer, &f.csrf_token, &shell)?;
14352×
    let level = match f.priority.as_str() {
14362×
        "low" | "normal" | "high" => f.priority.as_str(),
14370×
        "default" => "",
14380×
        _ => return back(&row, Section::Runs, None, Some("Pick low, normal, high or the default.".into())),
1439
    };
14402×
    if level == "high" && !viewer.is_admin() {
14410×
        return back(&row, Section::Runs, None, Some("Only site administrators raise a repository to high.".into()));
14422×
    }
14432×
    db::repos::set_flow_priority(&app.db, row.id, level).await.map_err(|e| Error::from_db(shell.clone(), e))?;
14442×
    accounts::audit(
14452×
        &app.db,
14462×
        viewer.account(),
14472×
        "repo.flow_priority",
14482×
        &format!("{owner}/{name}"),
14492×
        if level.is_empty() { "default" } else { level },
1450
    )
14512×
    .await
14522×
    .ok();
14532×
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
14542×
    let notice = if level.is_empty() {
14550×
        "Its jobs follow the organisation's and the site's priority.".to_string()
1456
    } else {
14572×
        format!("Its jobs are {level} priority.")
1458
    };
14592×
    back(&row, Section::Runs, Some(notice), None)
14602×
}
1461
1462
#[derive(Deserialize)]
1463
pub struct FlowForm {
1464
    #[serde(rename = "_csrf")]
1465
    csrf_token: String,
1466
    /// `on`, `off`, or `default`.
1467
    flow: String,
1468
}
1469
1470
/// Turn OxygenFlow on or off for one repository. Site administrators choose
1471
/// anything; the repository's maintainers may turn it on on their own
1472
/// runners (`own`), when the repository or its owner has one, or off (#225).
1473
/// Only site administrators let the site's runners take its jobs.
14749×
pub async fn flow(
14759×
    State(app): State<Shared>,
14769×
    viewer: Viewer,
14779×
    Path((owner, name)): Path<(String, String)>,
14789×
    Form(f): Form<FlowForm>,
14799×
) -> Result<Response, Error> {
14809×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
14819×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
14829×
    csrf(&viewer, &f.csrf_token, &shell)?;
14839×
    let setting = match f.flow.as_str() {
14849×
        "on" => "on",
14856×
        "own" => "own",
14864×
        "off" => "off",
14873×
        "default" => "",
14882×
        _ => return back(&row, Section::Pipelines, None, Some("Pick on, own runners, off or the site default.".into())),
1489
    };
14907×
    if !viewer.is_admin() {
14914×
        if matches!(setting, "on" | "") {
14922×
            return back(
14932×
                &row,
14942×
                Section::Pipelines,
14952×
                None,
14962×
                Some("Only site administrators let the site's runners take this repository's jobs.".into()),
1497
            );
14982×
        }
14992×
        if setting == "own" && !has_own_runner(&app, &row).await.map_err(|e| Error::from_db(shell.clone(), e))? {
15001×
            return back(
15011×
                &row,
15021×
                Section::Pipelines,
15031×
                None,
15041×
                Some("Register a runner for this repository (Settings › Runners) or its owner first.".into()),
1505
            );
15061×
        }
15073×
    }
15084×
    db::repos::set_flow(&app.db, row.id, setting).await.map_err(|e| Error::from_db(shell.clone(), e))?;
15094×
    let label = if setting.is_empty() { "default" } else { setting };
15104×
    accounts::audit(&app.db, viewer.account(), "repo.flow", &format!("{owner}/{name}"), label).await.ok();
15114×
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
15124×
    let notice = if row.flow == "own" {
15131×
        "OxygenFlow is on, on this repository's own runners: pushes with a .flow/pipeline.kdl start runs that only they take."
15143×
    } else if crate::flow::enabled(&app, &row) {
15151×
        "OxygenFlow is on: pushes with a .flow/pipeline.kdl start runs."
1516
    } else {
15172×
        "OxygenFlow is off for this repository."
1518
    };
15194×
    back(&row, Section::Pipelines, Some(notice.into()), None)
15209×
}
1521
15222×
pub async fn mirror(
15232×
    State(app): State<Shared>,
15242×
    viewer: Viewer,
15252×
    Path((owner, name)): Path<(String, String)>,
15262×
    Form(f): Form<MirrorForm>,
15272×
) -> Result<Response, Error> {
15282×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
15292×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
15302×
    csrf(&viewer, &f.csrf_token, &shell)?;
15312×
    db::repos::set_git_mirror(&app.db, row.id, f.enabled.is_some()).await.map_err(|e| Error::from_db(shell.clone(), e))?;
15322×
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
15332×
    if row.git_mirror {
15341×
        crate::mirror::refresh(&app, &row).await.ok();
15351×
    }
15362×
    accounts::audit(&app.db, viewer.account(), "repo.mirror", &format!("{owner}/{name}"), if row.git_mirror { "on" } else { "off" })
15372×
        .await
15382×
        .ok();
15392×
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
15402×
    let notice = if row.git_mirror { "The Git mirror is on and up to date." } else { "The Git mirror is off." };
15412×
    back(&row, Section::GitMirror, Some(notice.into()), None)
15422×
}
1543
1544
/// `POST /{owner}/{name}/settings/runners/token`: a one-time registration
1545
/// token for a runner that serves this repository only (oxygenhub#41).
15462×
pub async fn runner_token(
15472×
    State(app): State<Shared>,
15482×
    viewer: Viewer,
15492×
    Path((owner, name)): Path<(String, String)>,
15502×
    Form(f): Form<super::session::CsrfForm>,
15512×
) -> Result<Page<RepoSettingsPage>, Error> {
15522×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
15532×
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
15542×
    csrf(&viewer, &f.csrf, &shell)?;
15552×
    let me = viewer.account().map(|a| a.username.clone()).unwrap_or_default();
15562×
    let token = format!("flowreg_{}", crate::auth::random_hex(24));
15572×
    let scope = db::runners::Scope { owner: row.owner.clone(), repo_id: Some(row.id) };
15582×
    db::runners::add_registration(&app.db, &blake3::hash(token.as_bytes()).to_hex(), &me, &scope)
15592×
        .await
15602×
        .map_err(|e| Error::from_db(shell.clone(), e))?;
15612×
    accounts::audit(&app.db, viewer.account(), "flow.runner.token", &row.slug(), "registration token for this repository").await.ok();
15622×
    let mut page =
15632×
        settings_page(&app, shell, row, role, Section::Runners, Some("Copy the token now; it is not shown again.".into()), None).await?;
15642×
    page.0.runner_token = Some(token);
15652×
    Ok(page)
15662×
}
1567
1568
/// `POST /{owner}/{name}/settings/runners/{id}/remove`: remove one of this
1569
/// repository's own runners, when it is running nothing.
15702×
pub async fn runner_remove(
15712×
    State(app): State<Shared>,
15722×
    viewer: Viewer,
15732×
    Path((owner, name, id)): Path<(String, String, i64)>,
15742×
    Form(f): Form<super::session::CsrfForm>,
15752×
) -> Result<Response, Error> {
15762×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
15772×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
15782×
    csrf(&viewer, &f.csrf, &shell)?;
15792×
    let e = |e| Error::from_db(shell.clone(), e);
15802×
    let Some(r) = db::runners::get(&app.db, id).await.map_err(e)?.filter(|r| r.scope_repo_id == Some(row.id)) else {
15811×
        return Err(Error::not_found(shell));
1582
    };
15831×
    if db::runners::running(&app.db).await.map_err(e)?.iter().any(|(rid, n)| *rid == id && *n > 0) {
15840×
        return back(&row, Section::Runners, None, Some(format!("{} is running jobs: wait for them to finish.", r.name)));
15851×
    }
15861×
    db::runners::remove(&app.db, id).await.map_err(e)?;
15871×
    accounts::audit(&app.db, viewer.account(), "flow.runner.removed", &r.name, &row.slug()).await.ok();
15881×
    back(&row, Section::Runners, Some(format!("{} removed: its credential no longer works.", r.name)), None)
15892×
}
1590
1591404×
fn repo_assistants(row: &db::repos::RepoRow) -> crate::levels::At {
1592404×
    crate::levels::At::repo(row)
1593404×
}
1594
1595
/// `POST /{owner}/{name}/settings/assistants`: the repository's assistants
1596
/// and prompt (#97), for its admins.
15972×
pub async fn assistants(
15982×
    State(app): State<Shared>,
15992×
    viewer: Viewer,
16002×
    Path((owner, name)): Path<(String, String)>,
16012×
    Form(f): Form<super::assistants::AssistantForm>,
16022×
) -> Result<Response, Error> {
16032×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
16042×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
16051×
    csrf(&viewer, &f.csrf_token, &shell)?;
16061×
    match super::assistants::apply(&app, &viewer, &repo_assistants(&row), f).await.map_err(|e| Error::from_db(shell.clone(), e))? {
16071×
        Ok(notice) => back(&row, Section::Assistants, Some(notice), None),
16080×
        Err(error) => back(&row, Section::Assistants, None, Some(error)),
1609
    }
16102×
}
1611
1612
/// `POST /{owner}/{name}/settings/variables`: the repository's Flow
1613
/// variables (#102), for its admins.
16143×
pub async fn variables(
16153×
    State(app): State<Shared>,
16163×
    viewer: Viewer,
16173×
    Path((owner, name)): Path<(String, String)>,
16183×
    Form(f): Form<super::variables::VariableForm>,
16193×
) -> Result<Response, Error> {
16203×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
16213×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
16223×
    csrf(&viewer, &f.csrf_token, &shell)?;
16233×
    match super::variables::apply(&app, &viewer, &repo_assistants(&row), f).await.map_err(|e| Error::from_db(shell.clone(), e))? {
16242×
        Ok(notice) => back(&row, Section::Variables, Some(notice), None),
16251×
        Err(error) => back(&row, Section::Variables, None, Some(error)),
1626
    }
16273×
}
1628
1629
/// `POST /{owner}/{name}/settings/secrets`: the repository's Flow secrets
1630
/// (#107), for its admins.
16311×
pub async fn secrets(
16321×
    State(app): State<Shared>,
16331×
    viewer: Viewer,
16341×
    Path((owner, name)): Path<(String, String)>,
16351×
    Form(f): Form<super::secrets::SecretForm>,
16361×
) -> Result<Response, Error> {
16371×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
16381×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
16391×
    csrf(&viewer, &f.csrf_token, &shell)?;
16401×
    match super::secrets::apply(&app, &viewer, &repo_assistants(&row), f).await.map_err(|e| Error::from_db(shell.clone(), e))? {
16411×
        Ok(notice) => back(&row, Section::Secrets, Some(notice), None),
16420×
        Err(error) => back(&row, Section::Secrets, None, Some(error)),
1643
    }
16441×
}
1645
1646
/// `POST /{owner}/{name}/settings/assistants/models`: what a provider offers (#109).
16470×
pub async fn assistant_models(
16480×
    State(app): State<Shared>,
16490×
    viewer: Viewer,
16500×
    Path((owner, name)): Path<(String, String)>,
16510×
    Form(f): Form<super::assistants::ModelsForm>,
16520×
) -> Result<axum::Json<serde_json::Value>, Error> {
16530×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
16540×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
16550×
    csrf(&viewer, &f.csrf_token, &shell)?;
16560×
    Ok(axum::Json(super::assistants::models(&app, &repo_assistants(&row), f).await.map_err(|e| Error::from_db(shell.clone(), e))?))
16570×
}
1658
1659
/// Whether a runner serves this repository that is not the site's: one
1660
/// scoped to it or to its owner (#225).
1661135×
async fn has_own_runner(app: &Shared, row: &db::repos::RepoRow) -> sqlx::Result<bool> {
1662135×
    Ok(db::runners::serving(&app.db, &row.owner, row.id).await?.iter().any(|r| !r.scope().is_site()))
1663135×
}