OXYGENHUB
◆ v0.9.24 oxygenhub / crates / hub / src / web / repo_settings.rs
75.4 KiB · Rust · last changed New repository page with board and starting files; assetlinks.json; hung tests stopped; changelog on the WASM tier (closes #360, closes #365, closes #370) 1 day ago source blame history raw
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).
33
async fn new_page(app: &Shared, shell: Shell, me: &accounts::Account, error: Option<String>) -> NewRepoPage {
34
    let owners = owners_for(app, me).await;
35
    let mut boards = Vec::new();
36
    let mut orgs = Vec::new();
37
    for o in &owners {
38
        if accounts::org_by_name(&app.db, o).await.ok().flatten().is_some() {
39
            orgs.push(o.clone());
40
        }
41
        let may = crate::web::projects::rights_of(app, Some(me), o).await.ok().flatten().is_some_and(|r| r.edit);
42
        if may {
43
            for p in db::projects::of_owner(&app.db, o).await.unwrap_or_default().into_iter().filter(|p| !p.closed) {
44
                boards.push((o.clone(), p.number, p.title));
45
            }
46
        }
47
    }
48
    NewRepoPage {
49
        shell,
50
        owners,
51
        error,
52
        boards,
53
        orgs,
54
        licences: crate::repo_setup::LICENCES,
55
        ignores: crate::repo_setup::IGNORES,
56
        base_url: app.config.base_url().trim_end_matches('/').to_string(),
57
    }
58
}
59
60
pub(crate) async fn owners_for(app: &Shared, me: &accounts::Account) -> Vec<String> {
61
    let mut owners = vec![me.username.clone()];
62
    if let Ok(orgs) = accounts::orgs_of(&app.db, me.id).await {
63
        owners.extend(orgs.into_iter().map(|(o, _)| o.name));
64
    }
65
    owners
66
}
67
68
pub async fn new_form(State(app): State<Shared>, viewer: Viewer) -> Result<Page<NewRepoPage>, Error> {
69
    let shell = Shell::new(&app, "/new", &viewer);
70
    let me = require(&viewer, &shell)?;
71
    Ok(Page(new_page(&app, shell, &me.account, None).await))
72
}
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"}`.
84
pub async fn check_name(State(app): State<Shared>, viewer: Viewer, Query(q): Query<NameCheck>) -> Response {
85
    let Some(me) = viewer.account() else {
86
        return (axum::http::StatusCode::UNAUTHORIZED, axum::Json(serde_json::json!({"ok": false, "message": "Sign in first."})))
87
            .into_response();
88
    };
89
    if q.name.trim().is_empty() {
90
        return axum::Json(serde_json::json!({"ok": false, "message": ""})).into_response();
91
    }
92
    match crate::repo_setup::check_name(&app, me, &q.owner, &q.name).await {
93
        Ok(n) => axum::Json(serde_json::json!({
94
            "ok": true,
95
            "message": format!("{n} is free."),
96
            "address": format!("{}{}", app.config.base_url().trim_end_matches('/'), n.url()),
97
        }))
98
        .into_response(),
99
        Err(m) => axum::Json(serde_json::json!({"ok": false, "message": m})).into_response(),
100
    }
101
}
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 {
156
    fn credentials(&self, scheme: &str) -> Result<oxy_strata::Credentials, String> {
157
        crate::storage::credentials_from(
158
            scheme,
159
            &self.access_key_id,
160
            &self.secret_access_key,
161
            &self.service_account_key,
162
            &self.tenant_id,
163
            &self.client_id,
164
            &self.client_secret,
165
        )
166
    }
167
}
168
169
fn default_visibility() -> String {
170
    "public".into()
171
}
172
173
pub async fn create(State(app): State<Shared>, viewer: Viewer, Form(f): Form<NewRepoForm>) -> Result<Response, Error> {
174
    let shell = Shell::new(&app, "/new", &viewer);
175
    let me = require(&viewer, &shell)?;
176
    csrf(&viewer, &f.csrf_token, &shell)?;
177
    let page = new_page(&app, shell.clone(), &me.account, None).await;
178
    let fail = |msg: &str| Page(NewRepoPage { error: Some(msg.into()), ..clone_page(&page) }).into_response();
179
    let name = match crate::repo_setup::check_name(&app, &me.account, &f.owner, &f.name).await {
180
        Ok(n) => n,
181
        Err(m) => return Ok(fail(&m)),
182
    };
183
    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).
185
    let choice = if f.board == "existing" { f.board_n.as_str() } else { f.board.as_str() };
186
    let board = match crate::repo_setup::Board::parse(choice, &f.board_title, &name.name) {
187
        Ok(b) => b,
188
        Err(m) => return Ok(fail(&m)),
189
    };
190
    if let Err(m) = crate::repo_setup::check_board(&app, &me.account, &name.owner, &board).await {
191
        return Ok(fail(&m));
192
    }
193
    let start =
194
        crate::repo_setup::Start { readme: f.readme.is_some(), licence: f.licence.trim().to_string(), ignore: f.ignore.trim().to_string() };
195
    let year = time::OffsetDateTime::now_utc().year();
196
    let holder = if me.account.display_name.trim().is_empty() { me.account.username.clone() } else { me.account.display_name.clone() };
197
    let files = match start.files(&name, &f.description, &holder, year) {
198
        Ok(files) => files,
199
        Err(m) => return Ok(fail(&m)),
200
    };
201
    if !files.is_empty() && f.storage == "bucket" {
202
        return Ok(fail("A repository kept in your own bucket starts empty: push its first files to it."));
203
    }
204
    if !files.is_empty() {
205
        if let Err(m) = crate::web::edit::signer_for(&app, me.account.id).await {
206
            return Ok(fail(&format!("The hub cannot write the starting files for you yet. {m}")));
207
        }
208
    }
209
    // An organisation's storage policy (oxygenhub#96).
210
    if let Some(org) = accounts::org_by_name(&app.db, &name.owner).await.map_err(|e| Error::from_db(shell.clone(), e))? {
211
        let policy = crate::storage::policy(&app.db, org.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
212
        let store = (f.storage == "bucket").then_some(f.bucket.store_url.as_str());
213
        if let Err(e) = policy.permits(&org.name, store) {
214
            return Ok(fail(&format!("{e}.")));
215
        }
216
    }
217
    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.
220
        let url = f.bucket.store_url.trim();
221
        let parsed = match crate::storage::check_url(url) {
222
            Ok(p) => p,
223
            Err(e) => return Ok(fail(&format!("Store: {e}."))),
224
        };
225
        let credentials = match f.bucket.credentials(&parsed.scheme) {
226
            Ok(c) => c,
227
            Err(e) => return Ok(fail(&format!("Credentials: {e}."))),
228
        };
229
        let row = db::repos::create(&app.db, &name.owner, &name.name, f.description.trim(), visibility)
230
            .await
231
            .map_err(|e| Error::from_db(shell.clone(), e))?;
232
        if let Err(e) = crate::storage::create(&app, &name, row.id, url, credentials).await {
233
            db::repos::delete(&app.db, row.id).await.ok();
234
            return Ok(fail(&format!("The bucket did not pass the check, so nothing was created: {e}")));
235
        }
236
        accounts::audit(
237
            &app.db,
238
            Some(&me.account),
239
            "repo.created",
240
            &name.to_string(),
241
            &format!("{} in {}", visibility.as_str(), parsed.plain()),
242
        )
243
        .await
244
        .ok();
245
        crate::repo_setup::link_board(&app, &row, &board, visibility == Visibility::Public)
246
            .await
247
            .map_err(|e| Error::from_db(shell.clone(), e))?;
248
        return Ok(Redirect::to(&name.url()).into_response());
249
    }
250
    app.repos.create_async(&name, None).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
251
    let row = db::repos::create(&app.db, &name.owner, &name.name, f.description.trim(), visibility)
252
        .await
253
        .map_err(|e| Error::from_db(shell.clone(), e))?;
254
    accounts::audit(&app.db, Some(&me.account), "repo.created", &name.to_string(), visibility.as_str()).await.ok();
255
    crate::repo_setup::link_board(&app, &row, &board, visibility == Visibility::Public)
256
        .await
257
        .map_err(|e| Error::from_db(shell.clone(), e))?;
258
    if let Err(e) = crate::repo_setup::commit_start(&app, &row, &me.account, files).await {
259
        tracing::warn!("starting files for {name}: {e}");
260
    }
261
    Ok(Redirect::to(&name.url()).into_response())
262
}
263
264
fn clone_page(p: &NewRepoPage) -> NewRepoPage {
265
    NewRepoPage {
266
        shell: p.shell.clone(),
267
        owners: p.owners.clone(),
268
        error: p.error.clone(),
269
        boards: p.boards.clone(),
270
        orgs: p.orgs.clone(),
271
        licences: p.licences,
272
        ignores: p.ignores,
273
        base_url: p.base_url.clone(),
274
    }
275
}
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 {
316
    pub fn slug(self) -> &'static str {
317
        match self {
318
            Section::General => "general",
319
            Section::Collaborators => "collaborators",
320
            Section::Landing => "landing",
321
            Section::Checks => "checks",
322
            Section::PushProtection => "push-protection",
323
            Section::Pipelines => "flow",
324
            Section::Runs => "runs",
325
            Section::Gates => "gates",
326
            Section::Runners => "runners",
327
            Section::Variables => "variables",
328
            Section::Secrets => "secrets",
329
            Section::Coverage => "coverage",
330
            Section::Webhooks => "webhooks",
331
            Section::Assistants => "assistants",
332
            Section::GitMirror => "git-mirror",
333
            Section::Diffusion => "diffusion",
334
            Section::Storage => "storage",
335
            Section::Backup => "backup",
336
            Section::Advanced => "advanced",
337
        }
338
    }
339
340
    pub fn title(self) -> &'static str {
341
        match self {
342
            Section::General => "General",
343
            Section::Collaborators => "Collaborators",
344
            Section::Landing => "Landing rules",
345
            Section::Checks => "Required checks",
346
            Section::PushProtection => "Push protection",
347
            Section::Pipelines => "Pipelines",
348
            Section::Runs => "Runs & priority",
349
            Section::Gates => "Gates & agents",
350
            Section::Runners => "Runners",
351
            Section::Variables => "Variables",
352
            Section::Secrets => "Secrets",
353
            Section::Coverage => "Coverage",
354
            Section::Webhooks => "Webhooks",
355
            Section::Assistants => "AI assistants",
356
            Section::GitMirror => "Git mirror",
357
            Section::Diffusion => "Diffusion",
358
            Section::Storage => "Storage",
359
            Section::Backup => "Backup",
360
            Section::Advanced => "Advanced",
361
        }
362
    }
363
364
    pub fn parse(slug: &str) -> Option<Section> {
365
        GROUPS.iter().flat_map(|(_, pages)| pages.iter().copied()).find(|s| s.slug() == slug)
366
    }
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.
370
    fn admin_only(self) -> bool {
371
        matches!(
372
            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
        )
382
    }
383
384
    /// Whether this viewer has the page: Storage only for a repository kept
385
    /// in a bucket.
386
    fn shown(self, role: Role, in_bucket: bool) -> bool {
387
        (!self.admin_only() || role == Role::Admin) && (self != Section::Storage || in_bucket)
388
    }
389
}
390
391
/// The sidebar a viewer sees: the groups with at least one page for them.
392
fn sidebar(role: Role, in_bucket: bool) -> Vec<(&'static str, Vec<Section>)> {
393
    GROUPS
394
        .iter()
395
        .map(|(group, pages)| (*group, pages.iter().copied().filter(|s| s.shown(role, in_bucket)).collect::<Vec<_>>()))
396
        .filter(|(_, pages)| !pages.is_empty())
397
        .collect()
398
}
399
400
/// Back to the page that holds a settings form, with what happened (#278).
401
fn back(row: &db::repos::RepoRow, section: Section, notice: Option<String>, error: Option<String>) -> Result<Response, Error> {
402
    let mut to = format!("/{}/{}/settings/{}", row.owner, row.name, section.slug());
403
    if let Some(n) = notice {
404
        to.push_str(&format!("?notice={}", super::settings::urlencode(&n)));
405
    } else if let Some(e) = error {
406
        to.push_str(&format!("?error={}", super::settings::urlencode(&e)));
407
    }
408
    Ok(Redirect::to(&to).into_response())
409
}
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 {
466
    fn backup_choices(&self) -> &'static [(&'static str, &'static str)] {
467
        crate::backups::REPO_CHOICES
468
    }
469
}
470
471
/// One settings page, or not found when the viewer's role does not have it.
472
async fn settings_page(
473
    app: &Shared,
474
    shell: Shell,
475
    row: db::repos::RepoRow,
476
    role: Role,
477
    section: Section,
478
    notice: Option<String>,
479
    error: Option<String>,
480
) -> Result<Page<RepoSettingsPage>, Error> {
481
    let storage = crate::storage::of(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
482
    if !section.shown(role, storage.is_some()) {
483
        return Err(Error::not_found(shell));
484
    }
485
    let nav = sidebar(role, storage.is_some());
486
    let collaborators = accounts::collaborators(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
487
    let teams = match accounts::org_by_name(&app.db, &row.owner).await.map_err(|e| Error::from_db(shell.clone(), e))? {
488
        Some(org) => accounts::teams(&app.db, org.id).await.map_err(|e| Error::from_db(shell.clone(), e))?,
489
        None => vec![],
490
    };
491
    let repo = RepoShell::new(app, &row, "settings").with_role(role);
492
    let requirements = db::proposals::requirements(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
493
    let hooks = db::hooks::hooks_for_repo(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
494
    let hooks = super::webhooks::rows(app, hooks).await.map_err(|e| Error::from_db(shell.clone(), e))?;
495
    let required_checks = db::hooks::required_checks(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.join(", ");
496
    let flow_default = app.config.flow.allows(&row.owner, &row.name);
497
    let diffusion_site = app.config.diffusion.follow.clone();
498
    let flow_enabled = crate::flow::enabled(app, &row);
499
    let site_admin = shell.is_admin;
500
    let own_runner = has_own_runner(app, &row).await.map_err(|e| Error::from_db(shell.clone(), e))?;
501
    let runners = db::runners::serving(&app.db, &row.owner, row.id)
502
        .await
503
        .map_err(|e| Error::from_db(shell.clone(), e))?
504
        .into_iter()
505
        .map(|r| {
506
            let own = r.scope_repo_id == Some(row.id);
507
            let serves = if own {
508
                "this repository".to_string()
509
            } else if r.scope_owner.is_empty() {
510
                "the whole site".to_string()
511
            } else {
512
                format!("{}'s repositories", r.scope_owner)
513
            };
514
            (r, serves, own)
515
        })
516
        .collect();
517
    let assistants = super::assistants::panel(app, &repo_assistants(&row)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
518
    let variables = super::variables::panel(app, &repo_assistants(&row)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
519
    let secrets = super::secrets::panel(app, &repo_assistants(&row)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
520
    let coverage = crate::coverage::rules(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
521
    let backup_owner = crate::backups::setting(&app.db, &row.owner).await.map_err(|e| Error::from_db(shell.clone(), e))?;
522
    let push_findings = db::push_protection::recent(&app.db, row.id, 50).await.map_err(|e| Error::from_db(shell.clone(), e))?;
523
    let secret_bypasses = db::push_protection::bypasses(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
524
    let history_scan = db::push_protection::scan_of(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
525
    let history_findings = db::push_protection::history(&app.db, row.id, 200).await.map_err(|e| Error::from_db(shell.clone(), e))?;
526
    let resolved_findings = db::push_protection::resolved(&app.db, row.id, 50).await.map_err(|e| Error::from_db(shell.clone(), e))?;
527
    let history_scan_on = crate::history_scan::Budget::of(&app.config).is_some();
528
    Ok(Page(RepoSettingsPage {
529
        section,
530
        nav,
531
        push_findings,
532
        secret_bypasses,
533
        history_scan,
534
        history_findings,
535
        resolved_findings,
536
        history_scan_on,
537
        backup_owner,
538
        coverage,
539
        storage,
540
        assistants,
541
        variables,
542
        secrets,
543
        runners,
544
        runner_token: None,
545
        shell,
546
        repo,
547
        row,
548
        collaborators,
549
        teams,
550
        role,
551
        requirements,
552
        hooks,
553
        required_checks,
554
        flow_default,
555
        diffusion_site,
556
        flow_enabled,
557
        site_admin,
558
        own_runner,
559
        notice,
560
        error,
561
    }))
562
}
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.
574
pub async fn settings(
575
    State(app): State<Shared>,
576
    viewer: Viewer,
577
    Path((owner, name)): Path<(String, String)>,
578
    Query(q): Query<Outcome>,
579
) -> Result<Page<RepoSettingsPage>, Error> {
580
    let path = format!("/{owner}/{name}/settings");
581
    show(&app, &viewer, &path, &owner, &name, Section::General, q).await
582
}
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.
587
pub async fn page(
588
    State(app): State<Shared>,
589
    viewer: Viewer,
590
    Path(params): Path<std::collections::HashMap<String, String>>,
591
    uri: Uri,
592
    Query(q): Query<Outcome>,
593
) -> Result<Page<RepoSettingsPage>, Error> {
594
    let (owner, name) = (params.get("owner").cloned().unwrap_or_default(), params.get("name").cloned().unwrap_or_default());
595
    let path = format!("/{owner}/{name}/settings");
596
    let slug = uri.path().rsplit('/').next().unwrap_or("");
597
    let Some(section) = Section::parse(slug) else {
598
        return Err(Error::not_found(Shell::new(&app, &path, &viewer)));
599
    };
600
    show(&app, &viewer, &format!("{path}/{slug}"), &owner, &name, section, q).await
601
}
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.
605
async fn show(
606
    app: &Shared,
607
    viewer: &Viewer,
608
    path: &str,
609
    owner: &str,
610
    name: &str,
611
    section: Section,
612
    q: Outcome,
613
) -> Result<Page<RepoSettingsPage>, Error> {
614
    let shell = Shell::new(app, path, viewer);
615
    let (_, row, role) = lookup_role(app, &shell, viewer, owner, name, Access::Read).await?;
616
    if role < Role::Maintain {
617
        return Err(Error::not_found(shell));
618
    }
619
    if row.archived && role < Role::Admin {
620
        return Err(super::archived(&shell));
621
    }
622
    settings_page(app, shell, row, role, section, q.notice, q.error).await
623
}
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
636
pub async fn update(
637
    State(app): State<Shared>,
638
    viewer: Viewer,
639
    Path((owner, name)): Path<(String, String)>,
640
    Form(f): Form<GeneralForm>,
641
) -> Result<Response, Error> {
642
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
643
    let (repo, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
644
    csrf(&viewer, &f.csrf_token, &shell)?;
645
    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.
649
    let wanted = f.default_bookmark.trim().to_string();
650
    if wanted != row.default_bookmark {
651
        let names: Vec<String> = app
652
            .repos
653
            .read(&repo, |r, _| Ok(r.view()?.bookmarks.keys().cloned().collect::<Vec<String>>()))
654
            .await
655
            .map_err(|err| Error::from_repo(shell.clone(), err))?;
656
        if !names.is_empty() && !names.contains(&wanted) {
657
            let mut some: Vec<&str> = names.iter().map(String::as_str).filter(|n| *n == "main" || *n == "master").collect();
658
            some.extend(names.iter().map(String::as_str).filter(|n| *n != "main" && *n != "master").take(8 - some.len()));
659
            let more = if names.len() > some.len() { format!(" and {} more", names.len() - some.len()) } else { String::new() };
660
            let msg = format!("{wanted} is not a bookmark here yet: push it first, or choose one of {}{more}.", some.join(", "));
661
            return back(&row, Section::General, None, Some(msg));
662
        }
663
    }
664
    let topics = match db::repos::parse_topics(&f.topics) {
665
        Ok(t) => t,
666
        Err(msg) => return back(&row, Section::General, None, Some(msg)),
667
    };
668
    db::repos::set_description(&app.db, row.id, f.description.trim()).await.map_err(e)?;
669
    db::repos::set_topics(&app.db, row.id, &topics).await.map_err(e)?;
670
    if crate::repos::names::valid_segment(f.default_bookmark.trim()) {
671
        db::repos::set_default_bookmark(&app.db, row.id, f.default_bookmark.trim()).await.map_err(e)?;
672
    }
673
    if role >= Role::Admin {
674
        if let Some(v) = Visibility::parse(&f.visibility) {
675
            db::repos::set_visibility(&app.db, row.id, v).await.map_err(e)?;
676
        }
677
    }
678
    accounts::audit(&app.db, viewer.account(), "repo.updated", &format!("{owner}/{name}"), "").await.ok();
679
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(e)?.unwrap_or(row);
680
    back(&row, Section::General, Some("Saved.".into()), None)
681
}
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
697
pub async fn collaborator(
698
    State(app): State<Shared>,
699
    viewer: Viewer,
700
    Path((owner, name)): Path<(String, String)>,
701
    Form(f): Form<CollaboratorForm>,
702
) -> Result<Response, Error> {
703
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
704
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
705
    csrf(&viewer, &f.csrf_token, &shell)?;
706
    let e = |e| Error::from_db(shell.clone(), e);
707
    let (account_id, team_id, label) = if !f.username.trim().is_empty() {
708
        match accounts::account_by_name(&app.db, f.username.trim()).await.map_err(e)? {
709
            Some(a) => (Some(a.id), None, a.username),
710
            None => return back(&row, Section::Collaborators, None, Some("No such account.".into())),
711
        }
712
    } else if !f.team.trim().is_empty() {
713
        let org = accounts::org_by_name(&app.db, &row.owner).await.map_err(e)?;
714
        let team = match org {
715
            Some(o) => accounts::team_by_name(&app.db, o.id, f.team.trim()).await.map_err(e)?,
716
            None => None,
717
        };
718
        match team {
719
            Some(t) => (None, Some(t.id), format!("team {}", t.name)),
720
            None => return back(&row, Section::Collaborators, None, Some("No such team in this organisation.".into())),
721
        }
722
    } else {
723
        return back(&row, Section::Collaborators, None, Some("Name an account or a team.".into()));
724
    };
725
    if f.remove.is_some() {
726
        accounts::remove_collaborator(&app.db, row.id, account_id, team_id).await.map_err(e)?;
727
        accounts::audit(&app.db, viewer.account(), "repo.collaborator.removed", &format!("{owner}/{name}"), &label).await.ok();
728
        return back(&row, Section::Collaborators, Some(format!("Removed {label}.")), None);
729
    }
730
    let Some(new_role) = Role::parse(&f.role) else {
731
        return back(&row, Section::Collaborators, None, Some("Pick a role.".into()));
732
    };
733
    accounts::set_collaborator(&app.db, row.id, account_id, team_id, new_role.as_str()).await.map_err(e)?;
734
    accounts::audit(
735
        &app.db,
736
        viewer.account(),
737
        "repo.collaborator.set",
738
        &format!("{owner}/{name}"),
739
        &format!("{label} {}", new_role.as_str()),
740
    )
741
    .await
742
    .ok();
743
    back(&row, Section::Collaborators, Some(format!("{label} now has {} access.", new_role.as_str())), None)
744
}
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.
757
pub async fn transfer(
758
    State(app): State<Shared>,
759
    viewer: Viewer,
760
    Path((owner, name)): Path<(String, String)>,
761
    Form(f): Form<TransferForm>,
762
) -> Result<Response, Error> {
763
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
764
    let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
765
    csrf(&viewer, &f.csrf_token, &shell)?;
766
    let me = viewer.account().cloned().ok_or_else(|| Error::not_found(shell.clone()))?;
767
    let fail = |msg: &str| back(&row, Section::Advanced, None, Some(msg.into()));
768
    if f.confirm.trim() != repo.to_string() {
769
        return fail("Type the repository's full name to confirm.");
770
    }
771
    let to = f.owner.trim().trim_start_matches('@').to_string();
772
    let Some(dest) = RepoName::new(&to, &row.name) else {
773
        return fail("That is not an owner name.");
774
    };
775
    if dest.owner == row.owner {
776
        return fail("The repository is already there.");
777
    }
778
    let e = |e| Error::from_db(shell.clone(), e);
779
    let known = accounts::account_by_name(&app.db, &dest.owner).await.map_err(e)?.is_some()
780
        || accounts::org_by_name(&app.db, &dest.owner).await.map_err(e)?.is_some();
781
    if !known {
782
        return fail("There is no user or organisation of that name.");
783
    }
784
    if !roles::may_create_under(&app.db, &me, &dest.owner).await.map_err(e)? {
785
        return fail("You cannot create repositories under that owner, so you cannot move one there.");
786
    }
787
    if db::repos::get(&app.db, &dest.owner, &dest.name).await.map_err(e)?.is_some() || app.repos.exists(&dest) {
788
        return fail("That owner already has a repository of this name.");
789
    }
790
    {
791
        let _guard = app.repos.exclusive_guard(&repo).await;
792
        app.repos.rename(&repo, &dest).map_err(|e| Error::from_repo(shell.clone(), e))?;
793
        crate::storage::renamed(&app, &repo, &dest);
794
        if app.wikis.exists(&repo) {
795
            app.wikis.rename(&repo, &dest).ok();
796
        }
797
        db::repos::transfer(&app.db, row.id, &row.owner, &row.name, &dest.owner).await.map_err(e)?;
798
        let mirror = crate::mirror::mirror_dir(&app, &repo);
799
        if mirror.is_dir() {
800
            let to = crate::mirror::mirror_dir(&app, &dest);
801
            if let Some(parent) = to.parent() {
802
                std::fs::create_dir_all(parent).ok();
803
            }
804
            std::fs::rename(&mirror, &to).ok();
805
        }
806
    }
807
    accounts::audit(&app.db, Some(&me), "repo.transferred", &repo.to_string(), &dest.to_string()).await.ok();
808
    let notice = super::settings::urlencode(&format!("Moved from {repo}; the old address redirects here."));
809
    Ok(Redirect::to(&format!("{}/settings/{}?notice={notice}", dest.url(), Section::Advanced.slug())).into_response())
810
}
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).
824
pub async fn archive(
825
    State(app): State<Shared>,
826
    viewer: Viewer,
827
    Path((owner, name)): Path<(String, String)>,
828
    Form(f): Form<ArchiveForm>,
829
) -> Result<Response, Error> {
830
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
831
    let (repo, mut row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
832
    csrf(&viewer, &f.csrf_token, &shell)?;
833
    let archive = match f.action.as_str() {
834
        "archive" => true,
835
        "unarchive" => false,
836
        _ => return back(&row, Section::Advanced, None, Some("Unknown action.".into())),
837
    };
838
    let reason: String = f.reason.trim().chars().take(500).collect();
839
    db::repos::set_archived(&app.db, row.id, archive, &reason).await.map_err(|e| Error::from_db(shell.clone(), e))?;
840
    accounts::audit(&app.db, viewer.account(), if archive { "repo.archived" } else { "repo.unarchived" }, &repo.to_string(), &reason)
841
        .await
842
        .ok();
843
    row.archived = archive;
844
    row.archived_reason = if archive { reason } else { String::new() };
845
    let notice = if archive {
846
        "The repository is archived: read-only until it is brought back."
847
    } else {
848
        "The repository is back: writes are allowed again."
849
    };
850
    back(&row, Section::Advanced, Some(notice.into()), None)
851
}
852
853
/// `POST /{owner}/{name}/settings/wiki/delete`: the wiki to the trash (#104).
854
pub async fn wiki_delete(
855
    State(app): State<Shared>,
856
    viewer: Viewer,
857
    Path((owner, name)): Path<(String, String)>,
858
    Form(f): Form<DeleteForm>,
859
) -> Result<Response, Error> {
860
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
861
    let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
862
    csrf(&viewer, &f.csrf_token, &shell)?;
863
    if f.confirm.trim() != repo.to_string() {
864
        return back(&row, Section::Advanced, None, Some("Type the repository's full name to confirm.".into()));
865
    }
866
    if !crate::wiki::exists(&app, &repo) {
867
        return back(&row, Section::Advanced, None, Some("This repository has no wiki.".into()));
868
    }
869
    let _guard = app.wikis.exclusive_guard(&repo).await;
870
    crate::wiki::trash(&app, &repo).map_err(|e| Error::from_repo(shell.clone(), e))?;
871
    drop(_guard);
872
    accounts::audit(&app.db, viewer.account(), "wiki.data_deleted", &repo.to_string(), "moved to trash").await.ok();
873
    back(&row, Section::Advanced, Some("The wiki is in the trash; a new first page starts a fresh one.".into()), None)
874
}
875
876
#[derive(Deserialize)]
877
pub struct DeleteForm {
878
    #[serde(rename = "_csrf")]
879
    csrf_token: String,
880
    confirm: String,
881
}
882
883
pub async fn delete(
884
    State(app): State<Shared>,
885
    viewer: Viewer,
886
    Path((owner, name)): Path<(String, String)>,
887
    Form(f): Form<DeleteForm>,
888
) -> Result<Response, Error> {
889
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
890
    let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
891
    csrf(&viewer, &f.csrf_token, &shell)?;
892
    if f.confirm.trim() != repo.to_string() {
893
        return back(&row, Section::Advanced, None, Some("Type the repository's full name to delete it.".into()));
894
    }
895
    let _guard = app.repos.exclusive_guard(&repo).await;
896
    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.
898
    crate::storage::removed(&app, &repo);
899
    crate::wiki::trash(&app, &repo).ok();
900
    db::repos::delete(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
901
    crate::assistants::delete_scope(&app.db, crate::levels::Scope::Repo(row.id)).await.ok();
902
    crate::variables::delete_scope(&app.db, crate::levels::Scope::Repo(row.id)).await.ok();
903
    crate::secrets::delete_scope(&app.db, crate::levels::Scope::Repo(row.id)).await.ok();
904
    accounts::audit(&app.db, viewer.account(), "repo.deleted", &repo.to_string(), "moved to trash").await.ok();
905
    Ok(Redirect::to(&format!("/{owner}")).into_response())
906
}
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
920
pub async fn requirements(
921
    State(app): State<Shared>,
922
    viewer: Viewer,
923
    Path((owner, name)): Path<(String, String)>,
924
    Form(f): Form<RequirementsForm>,
925
) -> Result<Response, Error> {
926
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
927
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
928
    csrf(&viewer, &f.csrf_token, &shell)?;
929
    let r = db::proposals::Requirements {
930
        required_approvals: f.required_approvals.max(0),
931
        require_owners: f.require_owners.is_some(),
932
        allow_conflicts: f.allow_conflicts.is_some(),
933
    };
934
    db::proposals::set_requirements(&app.db, row.id, r).await.map_err(|e| Error::from_db(shell.clone(), e))?;
935
    accounts::audit(
936
        &app.db,
937
        viewer.account(),
938
        "repo.requirements",
939
        &format!("{owner}/{name}"),
940
        &format!("{} approvals, owners {}, conflicts {}", r.required_approvals, r.require_owners, r.allow_conflicts),
941
    )
942
    .await
943
    .ok();
944
    back(&row, Section::Landing, Some("Landing requirements saved.".into()), None)
945
}
946
947
pub async fn hooks(
948
    State(app): State<Shared>,
949
    viewer: Viewer,
950
    Path((owner, name)): Path<(String, String)>,
951
    Form(f): Form<super::webhooks::HookForm>,
952
) -> Result<Response, Error> {
953
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
954
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
955
    csrf(&viewer, &f.csrf_token, &shell)?;
956
    let scope = super::webhooks::Scope::Repo { id: row.id, slug: format!("{owner}/{name}") };
957
    match super::webhooks::apply(&app, &viewer, &scope, f).await.map_err(|e| Error::from_db(shell.clone(), e))? {
958
        Ok(notice) => back(&row, Section::Webhooks, Some(notice), None),
959
        Err(error) => back(&row, Section::Webhooks, None, Some(error)),
960
    }
961
}
962
963
pub async fn deliveries(
964
    State(app): State<Shared>,
965
    viewer: Viewer,
966
    Path((owner, name, id)): Path<(String, String, i64)>,
967
) -> Result<Page<super::webhooks::DeliveriesPage>, Error> {
968
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings/hooks/{id}/deliveries"), &viewer);
969
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
970
    let scope = super::webhooks::Scope::Repo { id: row.id, slug: format!("{owner}/{name}") };
971
    super::webhooks::deliveries_page(&app, shell, &scope, Some(RepoShell::new(&app, &row, "settings").with_role(role)), id).await
972
}
973
974
pub async fn redeliver(
975
    State(app): State<Shared>,
976
    viewer: Viewer,
977
    Path((owner, name, id, delivery)): Path<(String, String, i64, i64)>,
978
    Form(f): Form<super::session::CsrfForm>,
979
) -> Result<Page<super::webhooks::DeliveriesPage>, Error> {
980
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings/hooks/{id}/deliveries"), &viewer);
981
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
982
    csrf(&viewer, &f.csrf, &shell)?;
983
    let scope = super::webhooks::Scope::Repo { id: row.id, slug: format!("{owner}/{name}") };
984
    super::webhooks::redeliver(&app, &scope, id, delivery).await.map_err(|e| Error::from_db(shell.clone(), e))?;
985
    super::webhooks::deliveries_page(&app, shell, &scope, Some(RepoShell::new(&app, &row, "settings").with_role(role)), id).await
986
}
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
996
pub async fn required_checks(
997
    State(app): State<Shared>,
998
    viewer: Viewer,
999
    Path((owner, name)): Path<(String, String)>,
1000
    Form(f): Form<ChecksForm>,
1001
) -> Result<Response, Error> {
1002
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1003
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
1004
    csrf(&viewer, &f.csrf_token, &shell)?;
1005
    let names: Vec<String> = f.required_checks.split(',').map(str::trim).filter(|s| !s.is_empty()).map(String::from).collect();
1006
    db::hooks::set_required_checks(&app.db, row.id, &names).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1007
    back(&row, Section::Checks, Some("Required checks saved.".into()), None)
1008
}
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.
1021
pub async fn coverage(
1022
    State(app): State<Shared>,
1023
    viewer: Viewer,
1024
    Path((owner, name)): Path<(String, String)>,
1025
    Form(f): Form<CoverageForm>,
1026
) -> Result<Response, Error> {
1027
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1028
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
1029
    csrf(&viewer, &f.csrf_token, &shell)?;
1030
    let number = |v: &str, what: &str, hi: f64| -> Result<Option<f64>, String> {
1031
        let v = v.trim().trim_end_matches('%').trim();
1032
        if v.is_empty() {
1033
            return Ok(None);
1034
        }
1035
        match v.parse::<f64>() {
1036
            Ok(n) if (0.0..=hi).contains(&n) => Ok(Some(n)),
1037
            _ => Err(format!("{what} is a number from 0 to {hi}, or empty for no rule.")),
1038
        }
1039
    };
1040
    let rules = match (number(&f.min, "The minimum", 100.0), number(&f.max_drop, "The largest drop", 100.0)) {
1041
        (Ok(min), Ok(max_drop)) => crate::coverage::Rules { min, max_drop },
1042
        (Err(e), _) | (_, Err(e)) => return back(&row, Section::Coverage, None, Some(e)),
1043
    };
1044
    crate::coverage::set_rules(&app.db, row.id, rules).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1045
    accounts::audit(
1046
        &app.db,
1047
        viewer.account(),
1048
        "repo.coverage_rules",
1049
        &format!("{owner}/{name}"),
1050
        &format!("min {:?}, max drop {:?}", rules.min, rules.max_drop),
1051
    )
1052
    .await
1053
    .ok();
1054
    back(&row, Section::Coverage, Some("Coverage rules saved; they apply from the next report.".into()), None)
1055
}
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.
1069
pub async fn storage(
1070
    State(app): State<Shared>,
1071
    viewer: Viewer,
1072
    Path((owner, name)): Path<(String, String)>,
1073
    Form(f): Form<StorageForm>,
1074
) -> Result<Response, Error> {
1075
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1076
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
1077
    csrf(&viewer, &f.csrf_token, &shell)?;
1078
    let Some(s) = crate::storage::of(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))? else {
1079
        return back(&row, Section::General, None, Some("This repository is kept on this server's disk.".into()));
1080
    };
1081
    let scheme = s.url.split("://").next().unwrap_or("").to_string();
1082
    let outcome = match f.action.as_str() {
1083
        "credentials" => match f.bucket.credentials(&scheme) {
1084
            Ok(c) => crate::storage::set_credentials(&app, &s, c).await.map(|r| format!("New credentials saved: {r}.")),
1085
            Err(e) => Err(format!("Credentials: {e}")),
1086
        },
1087
        _ => crate::storage::check(&app, &s).await.map(|r| format!("The bucket passed the check: {r}.")),
1088
    };
1089
    match outcome {
1090
        Ok(notice) => {
1091
            accounts::audit(&app.db, viewer.account(), "repo.storage", &format!("{owner}/{name}"), &f.action).await.ok();
1092
            back(&row, Section::Storage, Some(notice), None)
1093
        }
1094
        Err(e) => back(&row, Section::Storage, None, Some(format!("Storage: {e}"))),
1095
    }
1096
}
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.
1117
pub async fn backup(
1118
    State(app): State<Shared>,
1119
    viewer: Viewer,
1120
    Path((owner, name)): Path<(String, String)>,
1121
    Form(f): Form<BackupForm>,
1122
) -> Result<Response, Error> {
1123
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1124
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
1125
    csrf(&viewer, &f.csrf_token, &shell)?;
1126
    if !crate::backups::REPO_CHOICES.iter().any(|(v, _)| *v == f.backup) {
1127
        return back(&row, Section::Backup, None, Some("Pick how this repository is backed up.".into()));
1128
    }
1129
    db::repos::set_backup(&app.db, row.id, &f.backup).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1130
    let what = if f.backup.is_empty() { "with its owner's" } else { f.backup.as_str() };
1131
    accounts::audit(&app.db, viewer.account(), "repo.backup", &format!("{owner}/{name}"), what).await.ok();
1132
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
1133
    let notice = match f.backup.as_str() {
1134
        "off" => "This repository is left out of its owner's backups from the next run.".to_string(),
1135
        "" => "This repository is backed up with its owner's.".to_string(),
1136
        every => {
1137
            format!("This repository is backed up at least {} from now on.", if every == "hourly" { "every hour" } else { "every day" })
1138
        }
1139
    };
1140
    back(&row, Section::Backup, Some(notice), None)
1141
}
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.
1156
pub async fn diffusion(
1157
    State(app): State<Shared>,
1158
    viewer: Viewer,
1159
    Path((owner, name)): Path<(String, String)>,
1160
    Form(f): Form<DiffusionForm>,
1161
) -> Result<Response, Error> {
1162
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1163
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
1164
    csrf(&viewer, &f.csrf_token, &shell)?;
1165
    let setting = match f.diffusion.as_str() {
1166
        "off" | "green" | "any" => f.diffusion.as_str(),
1167
        "default" => "",
1168
        _ => return back(&row, Section::Diffusion, None, Some("Pick off, green, any or the site default.".into())),
1169
    };
1170
    let land = match f.land.as_str() {
1171
        "auto" | "propose" => Some(f.land.as_str()),
1172
        "" => None,
1173
        _ => return back(&row, Section::Diffusion, None, Some("Pick propose or auto for landing.".into())),
1174
    };
1175
    db::repos::set_diffusion(&app.db, row.id, setting).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1176
    if let Some(l) = land {
1177
        db::repos::set_diffusion_land(&app.db, row.id, l).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1178
    }
1179
    let what =
1180
        format!("{}{}", if setting.is_empty() { "default" } else { setting }, land.map(|l| format!(", land {l}")).unwrap_or_default());
1181
    accounts::audit(&app.db, viewer.account(), "repo.diffusion", &format!("{owner}/{name}"), &what).await.ok();
1182
    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.
1184
    if land == Some("auto") {
1185
        let (app, row) = (app.clone(), row.clone());
1186
        tokio::spawn(async move { crate::diffusion::land_ready(&app, &row).await });
1187
    }
1188
    if crate::diffusion::rule(&app.config.diffusion.follow, &row, None, "").follow != crate::diffusion::Follow::Off {
1189
        let (app, row) = (app.clone(), row.clone());
1190
        tokio::spawn(async move { crate::diffusion::catch_up(&app, &row).await });
1191
    }
1192
    let notice = match crate::diffusion::rule(&app.config.diffusion.follow, &row, None, "").follow {
1193
        crate::diffusion::Follow::Off => "Diffusion is off: upstream moves open no proposals here.",
1194
        crate::diffusion::Follow::Green => {
1195
            "Diffusion is on: when a subrepo's upstream moves and its checks pass, a proposal moves the pin here."
1196
        }
1197
        crate::diffusion::Follow::Any => "Diffusion is on: every move of a subrepo's upstream opens or updates a proposal here.",
1198
    };
1199
    back(&row, Section::Diffusion, Some(notice.into()), None)
1200
}
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.
1214
pub async fn agent_gates(
1215
    State(app): State<Shared>,
1216
    viewer: Viewer,
1217
    Path((owner, name)): Path<(String, String)>,
1218
    Form(f): Form<AgentGatesForm>,
1219
) -> Result<Response, Error> {
1220
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1221
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
1222
    csrf(&viewer, &f.csrf_token, &shell)?;
1223
    let on = f.agents == "on";
1224
    db::repos::set_agents_decide_gates(&app.db, row.id, on).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1225
    accounts::audit(&app.db, viewer.account(), "repo.agents_decide_gates", &format!("{owner}/{name}"), if on { "on" } else { "off" })
1226
        .await
1227
        .ok();
1228
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
1229
    let notice = if on {
1230
        "Agents' tokens may now approve or reject this repository's approval gates; it says so on each."
1231
    } else {
1232
        "Approval gates here are decided by people: agents' tokens are refused."
1233
    };
1234
    back(&row, Section::Gates, Some(notice.into()), None)
1235
}
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.
1249
pub async fn push_protection(
1250
    State(app): State<Shared>,
1251
    viewer: Viewer,
1252
    Path((owner, name)): Path<(String, String)>,
1253
    Form(f): Form<PushProtectionForm>,
1254
) -> Result<Response, Error> {
1255
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1256
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
1257
    csrf(&viewer, &f.csrf_token, &shell)?;
1258
    let on = f.protect == "on";
1259
    db::push_protection::set_enabled(&app.db, row.id, on).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1260
    accounts::audit(&app.db, viewer.account(), "repo.push_protection", &format!("{owner}/{name}"), if on { "on" } else { "off" })
1261
        .await
1262
        .ok();
1263
    if on {
1264
        // What was pushed while it was off is scanned now (#277).
1265
        crate::history_scan::wake(&app, row.id);
1266
    }
1267
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
1268
    let notice = if on {
1269
        "Push protection is on: a push that adds what looks like a secret is refused, saying where."
1270
    } else {
1271
        "Push protection is off: pushes are no longer scanned for secrets here."
1272
    };
1273
    back(&row, Section::PushProtection, Some(notice.into()), None)
1274
}
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`.
1288
pub async fn secret_bypass(
1289
    State(app): State<Shared>,
1290
    viewer: Viewer,
1291
    Path((owner, name)): Path<(String, String)>,
1292
    Form(f): Form<SecretBypassForm>,
1293
) -> Result<Response, Error> {
1294
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1295
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
1296
    csrf(&viewer, &f.csrf_token, &shell)?;
1297
    let fingerprint = f.fingerprint.trim().to_ascii_lowercase();
1298
    let reason = f.reason.trim();
1299
    let error = if fingerprint.len() != 16 || !fingerprint.chars().all(|c| c.is_ascii_hexdigit()) {
1300
        Some("A fingerprint is the 16 hexadecimal digits a refused push printed.")
1301
    } else if reason.is_empty() {
1302
        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.")
1303
    } else if reason.chars().count() > 500 {
1304
        Some("Keep the reason under 500 characters.")
1305
    } else {
1306
        None
1307
    };
1308
    if let Some(e) = error {
1309
        return back(&row, Section::PushProtection, None, Some(e.into()));
1310
    }
1311
    let rule = db::push_protection::rule_of(&app.db, row.id, &fingerprint)
1312
        .await
1313
        .map_err(|e| Error::from_db(shell.clone(), e))?
1314
        .unwrap_or_default();
1315
    let by = viewer.account().map(|a| (a.id, a.username.as_str()));
1316
    db::push_protection::allow(&app.db, row.id, &fingerprint, &rule, reason, by).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1317
    let detail = if rule.is_empty() { format!("{fingerprint}: {reason}") } else { format!("{fingerprint} ({rule}): {reason}") };
1318
    accounts::audit(&app.db, viewer.account(), "secret.bypass", &format!("{owner}/{name}"), &detail).await.ok();
1319
    let notice = format!("Fingerprint {fingerprint} is allowed here: a push adding that value is no longer refused for it.");
1320
    back(&row, Section::PushProtection, Some(notice), None)
1321
}
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`.
1333
pub async fn secret_bypass_remove(
1334
    State(app): State<Shared>,
1335
    viewer: Viewer,
1336
    Path((owner, name)): Path<(String, String)>,
1337
    Form(f): Form<BypassRemoveForm>,
1338
) -> Result<Response, Error> {
1339
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1340
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
1341
    csrf(&viewer, &f.csrf_token, &shell)?;
1342
    let fingerprint = f.fingerprint.trim().to_ascii_lowercase();
1343
    let removed = db::push_protection::remove_bypass(&app.db, row.id, &fingerprint).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1344
    let Some(b) = removed else {
1345
        return back(&row, Section::PushProtection, None, Some(format!("Fingerprint {fingerprint} is not allowed here.")));
1346
    };
1347
    let detail = if b.rule.is_empty() {
1348
        format!("{fingerprint} (was: {})", b.reason)
1349
    } else {
1350
        format!("{fingerprint} ({}) (was: {})", b.rule, b.reason)
1351
    };
1352
    accounts::audit(&app.db, viewer.account(), "secret.bypass_removed", &format!("{owner}/{name}"), &detail).await.ok();
1353
    let notice = format!("Fingerprint {fingerprint} is no longer allowed: a push adding that value is refused again.");
1354
    back(&row, Section::PushProtection, Some(notice), None)
1355
}
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`.
1369
pub async fn secret_resolve(
1370
    State(app): State<Shared>,
1371
    viewer: Viewer,
1372
    Path((owner, name)): Path<(String, String)>,
1373
    Form(f): Form<SecretResolveForm>,
1374
) -> Result<Response, Error> {
1375
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1376
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
1377
    csrf(&viewer, &f.csrf_token, &shell)?;
1378
    let reason = f.reason.trim();
1379
    let error = if reason.is_empty() {
1380
        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.")
1381
    } else if reason.chars().count() > 500 {
1382
        Some("Keep the reason under 500 characters.")
1383
    } else {
1384
        None
1385
    };
1386
    if let Some(e) = error {
1387
        return back(&row, Section::PushProtection, None, Some(e.into()));
1388
    }
1389
    let by = viewer.account().map(|a| a.username.as_str()).unwrap_or("");
1390
    let done = db::push_protection::resolve(&app.db, row.id, f.finding, by, reason).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1391
    let Some(found) = done else {
1392
        return back(&row, Section::PushProtection, None, Some("That finding is not open here.".into()));
1393
    };
1394
    let detail = format!("{} ({}) at {}:{}: {reason}", found.fingerprint, found.rule, found.path, found.line);
1395
    accounts::audit(&app.db, viewer.account(), "secret.resolved", &format!("{owner}/{name}"), &detail).await.ok();
1396
    let notice = format!("The finding at {}:{} is marked resolved.", found.path, found.line);
1397
    back(&row, Section::PushProtection, Some(notice), None)
1398
}
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`.
1409
pub async fn secret_scan(
1410
    State(app): State<Shared>,
1411
    viewer: Viewer,
1412
    Path((owner, name)): Path<(String, String)>,
1413
    Form(f): Form<SecretScanForm>,
1414
) -> Result<Response, Error> {
1415
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1416
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
1417
    csrf(&viewer, &f.csrf_token, &shell)?;
1418
    if !row.push_protection {
1419
        return back(
1420
            &row,
1421
            Section::PushProtection,
1422
            None,
1423
            Some("Turn push protection on first: history is scanned only where it is on.".into()),
1424
        );
1425
    }
1426
    crate::history_scan::request(&app, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1427
    accounts::audit(&app.db, viewer.account(), "secret.history_scan", &format!("{owner}/{name}"), "requested").await.ok();
1428
    let notice = "The whole history will be scanned again, before other repositories: findings appear below as they are found.";
1429
    back(&row, Section::PushProtection, Some(notice.into()), None)
1430
}
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.
1445
pub async fn flow_supersede(
1446
    State(app): State<Shared>,
1447
    viewer: Viewer,
1448
    Path((owner, name)): Path<(String, String)>,
1449
    Form(f): Form<SupersedeForm>,
1450
) -> Result<Response, Error> {
1451
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1452
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
1453
    csrf(&viewer, &f.csrf_token, &shell)?;
1454
    let keep = f.running != "on";
1455
    db::repos::set_flow_keep_running(&app.db, row.id, keep).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1456
    accounts::audit(&app.db, viewer.account(), "repo.flow_keep_running", &format!("{owner}/{name}"), if keep { "on" } else { "off" })
1457
        .await
1458
        .ok();
1459
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
1460
    let notice = if keep {
1461
        "A new push or proposal run now cancels only the older runs still queued; those already running finish."
1462
    } else {
1463
        "A new push or proposal run now cancels the older runs it supersedes, queued or running."
1464
    };
1465
    back(&row, Section::Runs, Some(notice.into()), None)
1466
}
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.
1480
pub async fn flow_reuse(
1481
    State(app): State<Shared>,
1482
    viewer: Viewer,
1483
    Path((owner, name)): Path<(String, String)>,
1484
    Form(f): Form<ReuseForm>,
1485
) -> Result<Response, Error> {
1486
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1487
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
1488
    csrf(&viewer, &f.csrf_token, &shell)?;
1489
    let on = f.reuse == "on";
1490
    db::repos::set_flow_reuse(&app.db, row.id, on).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1491
    accounts::audit(&app.db, viewer.account(), "repo.flow_reuse", &format!("{owner}/{name}"), if on { "on" } else { "off" }).await.ok();
1492
    let notice = if on {
1493
        "A push run now reuses a job's pass on the same state."
1494
    } else {
1495
        "Every job of every push now runs, passed before or not."
1496
    };
1497
    back(&row, Section::Runs, Some(notice.into()), None)
1498
}
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.
1511
pub async fn flow_skip(
1512
    State(app): State<Shared>,
1513
    viewer: Viewer,
1514
    Path((owner, name)): Path<(String, String)>,
1515
    Form(f): Form<SkipForm>,
1516
) -> Result<Response, Error> {
1517
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1518
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
1519
    csrf(&viewer, &f.csrf_token, &shell)?;
1520
    let notice = match f.skip.as_str() {
1521
        "any" => "A push may now skip its run on any bookmark.".to_string(),
1522
        "others" => format!("A push may now skip its run on any bookmark but {}.", row.default_bookmark),
1523
        "never" => "Every push now runs; skip markers are ignored.".to_string(),
1524
        _ => return back(&row, Section::Runs, None, Some("Choose where a push may skip its run.".into())),
1525
    };
1526
    db::repos::set_flow_skip(&app.db, row.id, &f.skip).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1527
    accounts::audit(&app.db, viewer.account(), "repo.flow_skip", &format!("{owner}/{name}"), &f.skip).await.ok();
1528
    back(&row, Section::Runs, Some(notice), None)
1529
}
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.
1542
pub async fn flow_priority(
1543
    State(app): State<Shared>,
1544
    viewer: Viewer,
1545
    Path((owner, name)): Path<(String, String)>,
1546
    Form(f): Form<PriorityForm>,
1547
) -> Result<Response, Error> {
1548
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1549
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
1550
    csrf(&viewer, &f.csrf_token, &shell)?;
1551
    let level = match f.priority.as_str() {
1552
        "low" | "normal" | "high" => f.priority.as_str(),
1553
        "default" => "",
1554
        _ => return back(&row, Section::Runs, None, Some("Pick low, normal, high or the default.".into())),
1555
    };
1556
    if level == "high" && !viewer.is_admin() {
1557
        return back(&row, Section::Runs, None, Some("Only site administrators raise a repository to high.".into()));
1558
    }
1559
    db::repos::set_flow_priority(&app.db, row.id, level).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1560
    accounts::audit(
1561
        &app.db,
1562
        viewer.account(),
1563
        "repo.flow_priority",
1564
        &format!("{owner}/{name}"),
1565
        if level.is_empty() { "default" } else { level },
1566
    )
1567
    .await
1568
    .ok();
1569
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
1570
    let notice = if level.is_empty() {
1571
        "Its jobs follow the organisation's and the site's priority.".to_string()
1572
    } else {
1573
        format!("Its jobs are {level} priority.")
1574
    };
1575
    back(&row, Section::Runs, Some(notice), None)
1576
}
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.
1590
pub async fn flow(
1591
    State(app): State<Shared>,
1592
    viewer: Viewer,
1593
    Path((owner, name)): Path<(String, String)>,
1594
    Form(f): Form<FlowForm>,
1595
) -> Result<Response, Error> {
1596
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1597
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
1598
    csrf(&viewer, &f.csrf_token, &shell)?;
1599
    let setting = match f.flow.as_str() {
1600
        "on" => "on",
1601
        "own" => "own",
1602
        "off" => "off",
1603
        "default" => "",
1604
        _ => return back(&row, Section::Pipelines, None, Some("Pick on, own runners, off or the site default.".into())),
1605
    };
1606
    if !viewer.is_admin() {
1607
        if matches!(setting, "on" | "") {
1608
            return back(
1609
                &row,
1610
                Section::Pipelines,
1611
                None,
1612
                Some("Only site administrators let the site's runners take this repository's jobs.".into()),
1613
            );
1614
        }
1615
        if setting == "own" && !has_own_runner(&app, &row).await.map_err(|e| Error::from_db(shell.clone(), e))? {
1616
            return back(
1617
                &row,
1618
                Section::Pipelines,
1619
                None,
1620
                Some("Register a runner for this repository (Settings › Runners) or its owner first.".into()),
1621
            );
1622
        }
1623
    }
1624
    db::repos::set_flow(&app.db, row.id, setting).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1625
    let label = if setting.is_empty() { "default" } else { setting };
1626
    accounts::audit(&app.db, viewer.account(), "repo.flow", &format!("{owner}/{name}"), label).await.ok();
1627
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
1628
    let notice = if row.flow == "own" {
1629
        "OxygenFlow is on, on this repository's own runners: pushes with a .flow/pipeline.kdl start runs that only they take."
1630
    } else if crate::flow::enabled(&app, &row) {
1631
        "OxygenFlow is on: pushes with a .flow/pipeline.kdl start runs."
1632
    } else {
1633
        "OxygenFlow is off for this repository."
1634
    };
1635
    back(&row, Section::Pipelines, Some(notice.into()), None)
1636
}
1637
1638
pub async fn mirror(
1639
    State(app): State<Shared>,
1640
    viewer: Viewer,
1641
    Path((owner, name)): Path<(String, String)>,
1642
    Form(f): Form<MirrorForm>,
1643
) -> Result<Response, Error> {
1644
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1645
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
1646
    csrf(&viewer, &f.csrf_token, &shell)?;
1647
    db::repos::set_git_mirror(&app.db, row.id, f.enabled.is_some()).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1648
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
1649
    if row.git_mirror {
1650
        crate::mirror::refresh(&app, &row).await.ok();
1651
    }
1652
    accounts::audit(&app.db, viewer.account(), "repo.mirror", &format!("{owner}/{name}"), if row.git_mirror { "on" } else { "off" })
1653
        .await
1654
        .ok();
1655
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
1656
    let notice = if row.git_mirror { "The Git mirror is on and up to date." } else { "The Git mirror is off." };
1657
    back(&row, Section::GitMirror, Some(notice.into()), None)
1658
}
1659
1660
/// `POST /{owner}/{name}/settings/runners/token`: a one-time registration
1661
/// token for a runner that serves this repository only (oxygenhub#41).
1662
pub async fn runner_token(
1663
    State(app): State<Shared>,
1664
    viewer: Viewer,
1665
    Path((owner, name)): Path<(String, String)>,
1666
    Form(f): Form<super::session::CsrfForm>,
1667
) -> Result<Page<RepoSettingsPage>, Error> {
1668
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1669
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
1670
    csrf(&viewer, &f.csrf, &shell)?;
1671
    let me = viewer.account().map(|a| a.username.clone()).unwrap_or_default();
1672
    let token = format!("flowreg_{}", crate::auth::random_hex(24));
1673
    let scope = db::runners::Scope { owner: row.owner.clone(), repo_id: Some(row.id) };
1674
    db::runners::add_registration(&app.db, &blake3::hash(token.as_bytes()).to_hex(), &me, &scope)
1675
        .await
1676
        .map_err(|e| Error::from_db(shell.clone(), e))?;
1677
    accounts::audit(&app.db, viewer.account(), "flow.runner.token", &row.slug(), "registration token for this repository").await.ok();
1678
    let mut page =
1679
        settings_page(&app, shell, row, role, Section::Runners, Some("Copy the token now; it is not shown again.".into()), None).await?;
1680
    page.0.runner_token = Some(token);
1681
    Ok(page)
1682
}
1683
1684
/// `POST /{owner}/{name}/settings/runners/{id}/remove`: remove one of this
1685
/// repository's own runners, when it is running nothing.
1686
pub async fn runner_remove(
1687
    State(app): State<Shared>,
1688
    viewer: Viewer,
1689
    Path((owner, name, id)): Path<(String, String, i64)>,
1690
    Form(f): Form<super::session::CsrfForm>,
1691
) -> Result<Response, Error> {
1692
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1693
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
1694
    csrf(&viewer, &f.csrf, &shell)?;
1695
    let e = |e| Error::from_db(shell.clone(), e);
1696
    let Some(r) = db::runners::get(&app.db, id).await.map_err(e)?.filter(|r| r.scope_repo_id == Some(row.id)) else {
1697
        return Err(Error::not_found(shell));
1698
    };
1699
    if db::runners::running(&app.db).await.map_err(e)?.iter().any(|(rid, n)| *rid == id && *n > 0) {
1700
        return back(&row, Section::Runners, None, Some(format!("{} is running jobs: wait for them to finish.", r.name)));
1701
    }
1702
    db::runners::remove(&app.db, id).await.map_err(e)?;
1703
    accounts::audit(&app.db, viewer.account(), "flow.runner.removed", &r.name, &row.slug()).await.ok();
1704
    back(&row, Section::Runners, Some(format!("{} removed: its credential no longer works.", r.name)), None)
1705
}
1706
1707
fn repo_assistants(row: &db::repos::RepoRow) -> crate::levels::At {
1708
    crate::levels::At::repo(row)
1709
}
1710
1711
/// `POST /{owner}/{name}/settings/assistants`: the repository's assistants
1712
/// and prompt (#97), for its admins.
1713
pub async fn assistants(
1714
    State(app): State<Shared>,
1715
    viewer: Viewer,
1716
    Path((owner, name)): Path<(String, String)>,
1717
    Form(f): Form<super::assistants::AssistantForm>,
1718
) -> Result<Response, Error> {
1719
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1720
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
1721
    csrf(&viewer, &f.csrf_token, &shell)?;
1722
    match super::assistants::apply(&app, &viewer, &repo_assistants(&row), f).await.map_err(|e| Error::from_db(shell.clone(), e))? {
1723
        Ok(notice) => back(&row, Section::Assistants, Some(notice), None),
1724
        Err(error) => back(&row, Section::Assistants, None, Some(error)),
1725
    }
1726
}
1727
1728
/// `POST /{owner}/{name}/settings/variables`: the repository's Flow
1729
/// variables (#102), for its admins.
1730
pub async fn variables(
1731
    State(app): State<Shared>,
1732
    viewer: Viewer,
1733
    Path((owner, name)): Path<(String, String)>,
1734
    Form(f): Form<super::variables::VariableForm>,
1735
) -> Result<Response, Error> {
1736
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1737
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
1738
    csrf(&viewer, &f.csrf_token, &shell)?;
1739
    match super::variables::apply(&app, &viewer, &repo_assistants(&row), f).await.map_err(|e| Error::from_db(shell.clone(), e))? {
1740
        Ok(notice) => back(&row, Section::Variables, Some(notice), None),
1741
        Err(error) => back(&row, Section::Variables, None, Some(error)),
1742
    }
1743
}
1744
1745
/// `POST /{owner}/{name}/settings/secrets`: the repository's Flow secrets
1746
/// (#107), for its admins.
1747
pub async fn secrets(
1748
    State(app): State<Shared>,
1749
    viewer: Viewer,
1750
    Path((owner, name)): Path<(String, String)>,
1751
    Form(f): Form<super::secrets::SecretForm>,
1752
) -> Result<Response, Error> {
1753
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1754
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
1755
    csrf(&viewer, &f.csrf_token, &shell)?;
1756
    match super::secrets::apply(&app, &viewer, &repo_assistants(&row), f).await.map_err(|e| Error::from_db(shell.clone(), e))? {
1757
        Ok(notice) => back(&row, Section::Secrets, Some(notice), None),
1758
        Err(error) => back(&row, Section::Secrets, None, Some(error)),
1759
    }
1760
}
1761
1762
/// `POST /{owner}/{name}/settings/assistants/models`: what a provider offers (#109).
1763
pub async fn assistant_models(
1764
    State(app): State<Shared>,
1765
    viewer: Viewer,
1766
    Path((owner, name)): Path<(String, String)>,
1767
    Form(f): Form<super::assistants::ModelsForm>,
1768
) -> Result<axum::Json<serde_json::Value>, Error> {
1769
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1770
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
1771
    csrf(&viewer, &f.csrf_token, &shell)?;
1772
    Ok(axum::Json(super::assistants::models(&app, &repo_assistants(&row), f).await.map_err(|e| Error::from_db(shell.clone(), e))?))
1773
}
1774
1775
/// Whether a runner serves this repository that is not the site's: one
1776
/// scoped to it or to its owner (#225).
1777
async fn has_own_runner(app: &Shared, row: &db::repos::RepoRow) -> sqlx::Result<bool> {
1778
    Ok(db::runners::serving(&app.db, &row.owner, row.id).await?.iter().any(|r| !r.scope().is_site()))
1779
}