OXYGENHUB
◆ main oxygenhub / crates / hub / src / web / repo_settings.rs
77.9 KiB · Rust · last changed New repository page in sections, with OxygenFlow, the wiki and a default bookmark 4 hours 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
    /// Whether the viewer may put a repository on the site's runners (#371).
31
    admin: bool,
32
    /// Owners with runners of their own, for "on its owner's runners".
33
    runner_owners: Vec<String>,
34
    /// Each owner's default bookmark and where it comes from (#371).
35
    bookmarks: Vec<(String, String, String)>,
36
}
37
38
/// The New repository page's choices for `me` (#360).
39
async fn new_page(app: &Shared, shell: Shell, me: &accounts::Account, error: Option<String>) -> NewRepoPage {
40
    let owners = owners_for(app, me).await;
41
    let mut boards = Vec::new();
42
    let mut orgs = Vec::new();
43
    let mut runner_owners = Vec::new();
44
    let mut bookmarks = Vec::new();
45
    for o in &owners {
46
        let (b, from) = crate::repo_setup::default_bookmark_for(app, o).await;
47
        bookmarks.push((o.clone(), b, from));
48
        if crate::repo_setup::owner_has_runner(app, o).await.unwrap_or(false) {
49
            runner_owners.push(o.clone());
50
        }
51
        if accounts::org_by_name(&app.db, o).await.ok().flatten().is_some() {
52
            orgs.push(o.clone());
53
        }
54
        let may = crate::web::projects::rights_of(app, Some(me), o).await.ok().flatten().is_some_and(|r| r.edit);
55
        if may {
56
            for p in db::projects::of_owner(&app.db, o).await.unwrap_or_default().into_iter().filter(|p| !p.closed) {
57
                boards.push((o.clone(), p.number, p.title));
58
            }
59
        }
60
    }
61
    NewRepoPage {
62
        shell,
63
        owners,
64
        error,
65
        boards,
66
        orgs,
67
        licences: crate::repo_setup::LICENCES,
68
        ignores: crate::repo_setup::IGNORES,
69
        base_url: app.config.base_url().trim_end_matches('/').to_string(),
70
        admin: me.is_admin,
71
        runner_owners,
72
        bookmarks,
73
    }
74
}
75
76
pub(crate) async fn owners_for(app: &Shared, me: &accounts::Account) -> Vec<String> {
77
    let mut owners = vec![me.username.clone()];
78
    if let Ok(orgs) = accounts::orgs_of(&app.db, me.id).await {
79
        owners.extend(orgs.into_iter().map(|(o, _)| o.name));
80
    }
81
    owners
82
}
83
84
pub async fn new_form(State(app): State<Shared>, viewer: Viewer) -> Result<Page<NewRepoPage>, Error> {
85
    let shell = Shell::new(&app, "/new", &viewer);
86
    let me = require(&viewer, &shell)?;
87
    Ok(Page(new_page(&app, shell, &me.account, None).await))
88
}
89
90
#[derive(Deserialize)]
91
pub struct NameCheck {
92
    #[serde(default)]
93
    owner: String,
94
    #[serde(default)]
95
    name: String,
96
}
97
98
/// `GET /new/check?owner=&name=` (#360): the page's live check that a name
99
/// is free and valid, answering `{"ok", "message", "address"}`.
100
pub async fn check_name(State(app): State<Shared>, viewer: Viewer, Query(q): Query<NameCheck>) -> Response {
101
    let Some(me) = viewer.account() else {
102
        return (axum::http::StatusCode::UNAUTHORIZED, axum::Json(serde_json::json!({"ok": false, "message": "Sign in first."})))
103
            .into_response();
104
    };
105
    if q.name.trim().is_empty() {
106
        return axum::Json(serde_json::json!({"ok": false, "message": ""})).into_response();
107
    }
108
    match crate::repo_setup::check_name(&app, me, &q.owner, &q.name).await {
109
        Ok(n) => axum::Json(serde_json::json!({
110
            "ok": true,
111
            "message": format!("{n} is free."),
112
            "address": format!("{}{}", app.config.base_url().trim_end_matches('/'), n.url()),
113
        }))
114
        .into_response(),
115
        Err(m) => axum::Json(serde_json::json!({"ok": false, "message": m})).into_response(),
116
    }
117
}
118
119
#[derive(Deserialize)]
120
pub struct NewRepoForm {
121
    #[serde(rename = "_csrf")]
122
    csrf_token: String,
123
    owner: String,
124
    name: String,
125
    #[serde(default)]
126
    description: String,
127
    #[serde(default = "default_visibility")]
128
    visibility: String,
129
    /// `hub` (this server's disk, the default) or `bucket` (oxygenhub#96).
130
    #[serde(default)]
131
    storage: String,
132
    #[serde(flatten)]
133
    bucket: BucketForm,
134
    /// What it starts with (#360): a README, a licence, an `.oxyignore`.
135
    #[serde(default)]
136
    readme: Option<String>,
137
    #[serde(default)]
138
    licence: String,
139
    #[serde(default)]
140
    ignore: String,
141
    /// A board with it: `new` (titled `board_title`), a board number, or none.
142
    #[serde(default)]
143
    board: String,
144
    #[serde(default)]
145
    board_title: String,
146
    /// The existing board's number, with `board=existing`.
147
    #[serde(default)]
148
    board_n: String,
149
    /// OxygenFlow (#371): `on`, `own`, or the site default.
150
    #[serde(default)]
151
    flow: String,
152
    /// Start the wiki with a Home page (#371).
153
    #[serde(default)]
154
    wiki: Option<String>,
155
    /// Its default bookmark; empty, the owner's or the site's default (#371).
156
    #[serde(default)]
157
    default_bookmark: String,
158
}
159
160
/// A bucket and the credentials for it, as the New repository form and
161
/// Settings → Storage give them; only the kind the URL names is read.
162
#[derive(Deserialize, Default)]
163
pub struct BucketForm {
164
    #[serde(default)]
165
    store_url: String,
166
    #[serde(default)]
167
    access_key_id: String,
168
    #[serde(default)]
169
    secret_access_key: String,
170
    #[serde(default)]
171
    service_account_key: String,
172
    #[serde(default)]
173
    tenant_id: String,
174
    #[serde(default)]
175
    client_id: String,
176
    #[serde(default)]
177
    client_secret: String,
178
}
179
180
impl BucketForm {
181
    fn credentials(&self, scheme: &str) -> Result<oxy_strata::Credentials, String> {
182
        crate::storage::credentials_from(
183
            scheme,
184
            &self.access_key_id,
185
            &self.secret_access_key,
186
            &self.service_account_key,
187
            &self.tenant_id,
188
            &self.client_id,
189
            &self.client_secret,
190
        )
191
    }
192
}
193
194
fn default_visibility() -> String {
195
    "public".into()
196
}
197
198
pub async fn create(State(app): State<Shared>, viewer: Viewer, Form(f): Form<NewRepoForm>) -> Result<Response, Error> {
199
    let shell = Shell::new(&app, "/new", &viewer);
200
    let me = require(&viewer, &shell)?;
201
    csrf(&viewer, &f.csrf_token, &shell)?;
202
    let page = new_page(&app, shell.clone(), &me.account, None).await;
203
    let fail = |msg: &str| Page(NewRepoPage { error: Some(msg.into()), ..clone_page(&page) }).into_response();
204
    let name = match crate::repo_setup::check_name(&app, &me.account, &f.owner, &f.name).await {
205
        Ok(n) => n,
206
        Err(m) => return Ok(fail(&m)),
207
    };
208
    let Some(visibility) = Visibility::parse(&f.visibility) else { return Ok(fail("Visibility is public, internal or private.")) };
209
    // Its board and its starting files are checked before anything is made (#360).
210
    let choice = if f.board == "existing" { f.board_n.as_str() } else { f.board.as_str() };
211
    let board = match crate::repo_setup::Board::parse(choice, &f.board_title, &name.name) {
212
        Ok(b) => b,
213
        Err(m) => return Ok(fail(&m)),
214
    };
215
    if let Err(m) = crate::repo_setup::check_board(&app, &me.account, &name.owner, &board).await {
216
        return Ok(fail(&m));
217
    }
218
    let flow = match crate::repo_setup::Flow::parse(&f.flow) {
219
        Ok(flow) => flow,
220
        Err(m) => return Ok(fail(&m)),
221
    };
222
    if let Err(m) = crate::repo_setup::check_flow(&app, &me.account, &name.owner, flow).await {
223
        return Ok(fail(&m));
224
    }
225
    let wiki = f.wiki.is_some();
226
    if !f.default_bookmark.trim().is_empty() {
227
        if let Err(m) = crate::repo_setup::check_bookmark(f.default_bookmark.trim()) {
228
            return Ok(fail(&m));
229
        }
230
    }
231
    let start =
232
        crate::repo_setup::Start { readme: f.readme.is_some(), licence: f.licence.trim().to_string(), ignore: f.ignore.trim().to_string() };
233
    let year = time::OffsetDateTime::now_utc().year();
234
    let holder = if me.account.display_name.trim().is_empty() { me.account.username.clone() } else { me.account.display_name.clone() };
235
    let files = match start.files(&name, &f.description, &holder, year) {
236
        Ok(files) => files,
237
        Err(m) => return Ok(fail(&m)),
238
    };
239
    if !files.is_empty() && f.storage == "bucket" {
240
        return Ok(fail("A repository kept in your own bucket starts empty: push its first files to it."));
241
    }
242
    if !files.is_empty() || wiki {
243
        if let Err(m) = crate::web::edit::signer_for(&app, me.account.id).await {
244
            return Ok(fail(&format!("The hub cannot write the starting files or the wiki for you yet. {m}")));
245
        }
246
    }
247
    // An organisation's storage policy (oxygenhub#96).
248
    if let Some(org) = accounts::org_by_name(&app.db, &name.owner).await.map_err(|e| Error::from_db(shell.clone(), e))? {
249
        let policy = crate::storage::policy(&app.db, org.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
250
        let store = (f.storage == "bucket").then_some(f.bucket.store_url.as_str());
251
        if let Err(e) = policy.permits(&org.name, store) {
252
            return Ok(fail(&format!("{e}.")));
253
        }
254
    }
255
    if f.storage == "bucket" {
256
        // Kept in the owner's bucket (oxygenhub#96): checked before
257
        // anything is made, and nothing is left behind when it fails.
258
        let url = f.bucket.store_url.trim();
259
        let parsed = match crate::storage::check_url(url) {
260
            Ok(p) => p,
261
            Err(e) => return Ok(fail(&format!("Store: {e}."))),
262
        };
263
        let credentials = match f.bucket.credentials(&parsed.scheme) {
264
            Ok(c) => c,
265
            Err(e) => return Ok(fail(&format!("Credentials: {e}."))),
266
        };
267
        let row = db::repos::create(&app.db, &name.owner, &name.name, f.description.trim(), visibility)
268
            .await
269
            .map_err(|e| Error::from_db(shell.clone(), e))?;
270
        let row =
271
            crate::repo_setup::set_default_bookmark(&app, row, &f.default_bookmark).await.map_err(|e| Error::from_db(shell.clone(), e))?;
272
        if let Err(e) = crate::storage::create(&app, &name, row.id, url, credentials).await {
273
            db::repos::delete(&app.db, row.id).await.ok();
274
            return Ok(fail(&format!("The bucket did not pass the check, so nothing was created: {e}")));
275
        }
276
        accounts::audit(
277
            &app.db,
278
            Some(&me.account),
279
            "repo.created",
280
            &name.to_string(),
281
            &format!("{} in {}", visibility.as_str(), parsed.plain()),
282
        )
283
        .await
284
        .ok();
285
        crate::repo_setup::link_board(&app, &row, &board, visibility == Visibility::Public)
286
            .await
287
            .map_err(|e| Error::from_db(shell.clone(), e))?;
288
        crate::repo_setup::set_flow(&app, &row, &me.account, flow).await.map_err(|e| Error::from_db(shell.clone(), e))?;
289
        if wiki {
290
            if let Err(e) = crate::repo_setup::start_wiki(&app, &row, &me.account, &f.description).await {
291
                tracing::warn!("wiki for {name}: {e}");
292
            }
293
        }
294
        return Ok(Redirect::to(&name.url()).into_response());
295
    }
296
    app.repos.create_async(&name, None).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
297
    let row = db::repos::create(&app.db, &name.owner, &name.name, f.description.trim(), visibility)
298
        .await
299
        .map_err(|e| Error::from_db(shell.clone(), e))?;
300
    let row =
301
        crate::repo_setup::set_default_bookmark(&app, row, &f.default_bookmark).await.map_err(|e| Error::from_db(shell.clone(), e))?;
302
    accounts::audit(&app.db, Some(&me.account), "repo.created", &name.to_string(), visibility.as_str()).await.ok();
303
    crate::repo_setup::link_board(&app, &row, &board, visibility == Visibility::Public)
304
        .await
305
        .map_err(|e| Error::from_db(shell.clone(), e))?;
306
    crate::repo_setup::set_flow(&app, &row, &me.account, flow).await.map_err(|e| Error::from_db(shell.clone(), e))?;
307
    if let Err(e) = crate::repo_setup::commit_start(&app, &row, &me.account, files).await {
308
        tracing::warn!("starting files for {name}: {e}");
309
    }
310
    if wiki {
311
        if let Err(e) = crate::repo_setup::start_wiki(&app, &row, &me.account, &f.description).await {
312
            tracing::warn!("wiki for {name}: {e}");
313
        }
314
    }
315
    Ok(Redirect::to(&name.url()).into_response())
316
}
317
318
fn clone_page(p: &NewRepoPage) -> NewRepoPage {
319
    NewRepoPage {
320
        shell: p.shell.clone(),
321
        owners: p.owners.clone(),
322
        error: p.error.clone(),
323
        boards: p.boards.clone(),
324
        orgs: p.orgs.clone(),
325
        licences: p.licences,
326
        ignores: p.ignores,
327
        base_url: p.base_url.clone(),
328
        admin: p.admin,
329
        runner_owners: p.runner_owners.clone(),
330
        bookmarks: p.bookmarks.clone(),
331
    }
332
}
333
334
/// One page of a repository's settings (#278), at
335
/// `/{owner}/{name}/settings/{slug}`; `/settings` is General.
336
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
337
pub enum Section {
338
    General,
339
    Collaborators,
340
    Landing,
341
    Checks,
342
    PushProtection,
343
    Pipelines,
344
    Runs,
345
    Gates,
346
    Runners,
347
    Variables,
348
    Secrets,
349
    Coverage,
350
    Webhooks,
351
    Assistants,
352
    GitMirror,
353
    Diffusion,
354
    Storage,
355
    Backup,
356
    Advanced,
357
}
358
359
/// The sidebar: each group's heading and its pages, in order.
360
const GROUPS: &[(&str, &[Section])] = &[
361
    ("General", &[Section::General, Section::Collaborators]),
362
    ("Code", &[Section::Landing, Section::Checks, Section::PushProtection]),
363
    (
364
        "Flow",
365
        &[Section::Pipelines, Section::Runs, Section::Gates, Section::Runners, Section::Variables, Section::Secrets, Section::Coverage],
366
    ),
367
    ("Integrations", &[Section::Webhooks, Section::Assistants, Section::GitMirror, Section::Diffusion]),
368
    ("Data", &[Section::Storage, Section::Backup]),
369
    ("Danger zone", &[Section::Advanced]),
370
];
371
372
impl Section {
373
    pub fn slug(self) -> &'static str {
374
        match self {
375
            Section::General => "general",
376
            Section::Collaborators => "collaborators",
377
            Section::Landing => "landing",
378
            Section::Checks => "checks",
379
            Section::PushProtection => "push-protection",
380
            Section::Pipelines => "flow",
381
            Section::Runs => "runs",
382
            Section::Gates => "gates",
383
            Section::Runners => "runners",
384
            Section::Variables => "variables",
385
            Section::Secrets => "secrets",
386
            Section::Coverage => "coverage",
387
            Section::Webhooks => "webhooks",
388
            Section::Assistants => "assistants",
389
            Section::GitMirror => "git-mirror",
390
            Section::Diffusion => "diffusion",
391
            Section::Storage => "storage",
392
            Section::Backup => "backup",
393
            Section::Advanced => "advanced",
394
        }
395
    }
396
397
    pub fn title(self) -> &'static str {
398
        match self {
399
            Section::General => "General",
400
            Section::Collaborators => "Collaborators",
401
            Section::Landing => "Landing rules",
402
            Section::Checks => "Required checks",
403
            Section::PushProtection => "Push protection",
404
            Section::Pipelines => "Pipelines",
405
            Section::Runs => "Runs & priority",
406
            Section::Gates => "Gates & agents",
407
            Section::Runners => "Runners",
408
            Section::Variables => "Variables",
409
            Section::Secrets => "Secrets",
410
            Section::Coverage => "Coverage",
411
            Section::Webhooks => "Webhooks",
412
            Section::Assistants => "AI assistants",
413
            Section::GitMirror => "Git mirror",
414
            Section::Diffusion => "Diffusion",
415
            Section::Storage => "Storage",
416
            Section::Backup => "Backup",
417
            Section::Advanced => "Advanced",
418
        }
419
    }
420
421
    pub fn parse(slug: &str) -> Option<Section> {
422
        GROUPS.iter().flat_map(|(_, pages)| pages.iter().copied()).find(|s| s.slug() == slug)
423
    }
424
425
    /// Whether only the repository's admins use it; its maintainers use
426
    /// the rest. The roles are those of each page's forms.
427
    fn admin_only(self) -> bool {
428
        matches!(
429
            self,
430
            Section::Collaborators
431
                | Section::Runners
432
                | Section::Variables
433
                | Section::Secrets
434
                | Section::Webhooks
435
                | Section::Assistants
436
                | Section::Storage
437
                | Section::Advanced
438
        )
439
    }
440
441
    /// Whether this viewer has the page: Storage only for a repository kept
442
    /// in a bucket.
443
    fn shown(self, role: Role, in_bucket: bool) -> bool {
444
        (!self.admin_only() || role == Role::Admin) && (self != Section::Storage || in_bucket)
445
    }
446
}
447
448
/// The sidebar a viewer sees: the groups with at least one page for them.
449
fn sidebar(role: Role, in_bucket: bool) -> Vec<(&'static str, Vec<Section>)> {
450
    GROUPS
451
        .iter()
452
        .map(|(group, pages)| (*group, pages.iter().copied().filter(|s| s.shown(role, in_bucket)).collect::<Vec<_>>()))
453
        .filter(|(_, pages)| !pages.is_empty())
454
        .collect()
455
}
456
457
/// Back to the page that holds a settings form, with what happened (#278).
458
fn back(row: &db::repos::RepoRow, section: Section, notice: Option<String>, error: Option<String>) -> Result<Response, Error> {
459
    let mut to = format!("/{}/{}/settings/{}", row.owner, row.name, section.slug());
460
    if let Some(n) = notice {
461
        to.push_str(&format!("?notice={}", super::settings::urlencode(&n)));
462
    } else if let Some(e) = error {
463
        to.push_str(&format!("?error={}", super::settings::urlencode(&e)));
464
    }
465
    Ok(Redirect::to(&to).into_response())
466
}
467
468
#[derive(Template)]
469
#[template(path = "repo_settings.html")]
470
pub struct RepoSettingsPage {
471
    /// The page shown, and the sidebar's groups of pages.
472
    section: Section,
473
    nav: Vec<(&'static str, Vec<Section>)>,
474
    shell: Shell,
475
    repo: RepoShell,
476
    row: db::repos::RepoRow,
477
    collaborators: Vec<accounts::Collaborator>,
478
    teams: Vec<accounts::Team>,
479
    role: Role,
480
    requirements: db::proposals::Requirements,
481
    hooks: Vec<super::webhooks::HookRow>,
482
    required_checks: String,
483
    /// Whether hub.toml alone would run Flow here, and whether it runs.
484
    flow_default: bool,
485
    /// hub.toml's `[diffusion] follow` (#148).
486
    diffusion_site: String,
487
    flow_enabled: bool,
488
    site_admin: bool,
489
    /// Whether the repository or its owner has a runner of its own (#225).
490
    own_runner: bool,
491
    notice: Option<String>,
492
    error: Option<String>,
493
    /// Runners that may take this repository's jobs: (runner, what it
494
    /// serves, whether it is this repository's own).
495
    runners: Vec<(db::runners::Runner, String, bool)>,
496
    /// A registration token just made, shown once.
497
    runner_token: Option<String>,
498
    /// The repository's AI assistants and prompt (#97).
499
    assistants: super::assistants::Panel,
500
    /// The repository's Flow variables (#102).
501
    variables: super::variables::Panel,
502
    /// The repository's Flow secrets (#107).
503
    secrets: super::secrets::Panel,
504
    /// Where its data lives, when in a bucket (oxygenhub#96).
505
    storage: Option<crate::storage::Storage>,
506
    /// The coverage check's rules (#113).
507
    coverage: crate::coverage::Rules,
508
    /// Its owner's backup, when one is set up (docs/backups.md §5).
509
    backup_owner: Option<crate::backups::Setting>,
510
    /// Push protection (#268): the findings of recent refused pushes not
511
    /// yet allowed, and the fingerprints allowed.
512
    push_findings: Vec<db::push_protection::FindingRow>,
513
    secret_bypasses: Vec<db::push_protection::Bypass>,
514
    /// The history scan (#277): its progress, its open findings and those
515
    /// marked resolved, and whether the hub runs it at all.
516
    history_scan: Option<db::push_protection::ScanRow>,
517
    history_findings: Vec<db::push_protection::HistoryRow>,
518
    resolved_findings: Vec<db::push_protection::HistoryRow>,
519
    history_scan_on: bool,
520
}
521
522
impl RepoSettingsPage {
523
    fn backup_choices(&self) -> &'static [(&'static str, &'static str)] {
524
        crate::backups::REPO_CHOICES
525
    }
526
}
527
528
/// One settings page, or not found when the viewer's role does not have it.
529
async fn settings_page(
530
    app: &Shared,
531
    shell: Shell,
532
    row: db::repos::RepoRow,
533
    role: Role,
534
    section: Section,
535
    notice: Option<String>,
536
    error: Option<String>,
537
) -> Result<Page<RepoSettingsPage>, Error> {
538
    let storage = crate::storage::of(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
539
    if !section.shown(role, storage.is_some()) {
540
        return Err(Error::not_found(shell));
541
    }
542
    let nav = sidebar(role, storage.is_some());
543
    let collaborators = accounts::collaborators(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
544
    let teams = match accounts::org_by_name(&app.db, &row.owner).await.map_err(|e| Error::from_db(shell.clone(), e))? {
545
        Some(org) => accounts::teams(&app.db, org.id).await.map_err(|e| Error::from_db(shell.clone(), e))?,
546
        None => vec![],
547
    };
548
    let repo = RepoShell::new(app, &row, "settings").with_role(role);
549
    let requirements = db::proposals::requirements(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
550
    let hooks = db::hooks::hooks_for_repo(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
551
    let hooks = super::webhooks::rows(app, hooks).await.map_err(|e| Error::from_db(shell.clone(), e))?;
552
    let required_checks = db::hooks::required_checks(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.join(", ");
553
    let flow_default = app.config.flow.allows(&row.owner, &row.name);
554
    let diffusion_site = app.config.diffusion.follow.clone();
555
    let flow_enabled = crate::flow::enabled(app, &row);
556
    let site_admin = shell.is_admin;
557
    let own_runner = has_own_runner(app, &row).await.map_err(|e| Error::from_db(shell.clone(), e))?;
558
    let runners = db::runners::serving(&app.db, &row.owner, row.id)
559
        .await
560
        .map_err(|e| Error::from_db(shell.clone(), e))?
561
        .into_iter()
562
        .map(|r| {
563
            let own = r.scope_repo_id == Some(row.id);
564
            let serves = if own {
565
                "this repository".to_string()
566
            } else if r.scope_owner.is_empty() {
567
                "the whole site".to_string()
568
            } else {
569
                format!("{}'s repositories", r.scope_owner)
570
            };
571
            (r, serves, own)
572
        })
573
        .collect();
574
    let assistants = super::assistants::panel(app, &repo_assistants(&row)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
575
    let variables = super::variables::panel(app, &repo_assistants(&row)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
576
    let secrets = super::secrets::panel(app, &repo_assistants(&row)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
577
    let coverage = crate::coverage::rules(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
578
    let backup_owner = crate::backups::setting(&app.db, &row.owner).await.map_err(|e| Error::from_db(shell.clone(), e))?;
579
    let push_findings = db::push_protection::recent(&app.db, row.id, 50).await.map_err(|e| Error::from_db(shell.clone(), e))?;
580
    let secret_bypasses = db::push_protection::bypasses(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
581
    let history_scan = db::push_protection::scan_of(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
582
    let history_findings = db::push_protection::history(&app.db, row.id, 200).await.map_err(|e| Error::from_db(shell.clone(), e))?;
583
    let resolved_findings = db::push_protection::resolved(&app.db, row.id, 50).await.map_err(|e| Error::from_db(shell.clone(), e))?;
584
    let history_scan_on = crate::history_scan::Budget::of(&app.config).is_some();
585
    Ok(Page(RepoSettingsPage {
586
        section,
587
        nav,
588
        push_findings,
589
        secret_bypasses,
590
        history_scan,
591
        history_findings,
592
        resolved_findings,
593
        history_scan_on,
594
        backup_owner,
595
        coverage,
596
        storage,
597
        assistants,
598
        variables,
599
        secrets,
600
        runners,
601
        runner_token: None,
602
        shell,
603
        repo,
604
        row,
605
        collaborators,
606
        teams,
607
        role,
608
        requirements,
609
        hooks,
610
        required_checks,
611
        flow_default,
612
        diffusion_site,
613
        flow_enabled,
614
        site_admin,
615
        own_runner,
616
        notice,
617
        error,
618
    }))
619
}
620
621
/// What a settings form did, carried to the page it redirects to.
622
#[derive(Deserialize, Default)]
623
pub struct Outcome {
624
    #[serde(default)]
625
    notice: Option<String>,
626
    #[serde(default)]
627
    error: Option<String>,
628
}
629
630
/// `GET /{owner}/{name}/settings`: the General page.
631
pub async fn settings(
632
    State(app): State<Shared>,
633
    viewer: Viewer,
634
    Path((owner, name)): Path<(String, String)>,
635
    Query(q): Query<Outcome>,
636
) -> Result<Page<RepoSettingsPage>, Error> {
637
    let path = format!("/{owner}/{name}/settings");
638
    show(&app, &viewer, &path, &owner, &name, Section::General, q).await
639
}
640
641
/// `GET /{owner}/{name}/settings/{page}` (#278): one page of the settings.
642
/// Some pages share their path with their form's `POST`, so the page is
643
/// the path's last segment.
644
pub async fn page(
645
    State(app): State<Shared>,
646
    viewer: Viewer,
647
    Path(params): Path<std::collections::HashMap<String, String>>,
648
    uri: Uri,
649
    Query(q): Query<Outcome>,
650
) -> Result<Page<RepoSettingsPage>, Error> {
651
    let (owner, name) = (params.get("owner").cloned().unwrap_or_default(), params.get("name").cloned().unwrap_or_default());
652
    let path = format!("/{owner}/{name}/settings");
653
    let slug = uri.path().rsplit('/').next().unwrap_or("");
654
    let Some(section) = Section::parse(slug) else {
655
        return Err(Error::not_found(Shell::new(&app, &path, &viewer)));
656
    };
657
    show(&app, &viewer, &format!("{path}/{slug}"), &owner, &name, section, q).await
658
}
659
660
/// A settings page for the repository's maintainers, or its admins: on an
661
/// archived repository, only its admins, who can bring it back.
662
async fn show(
663
    app: &Shared,
664
    viewer: &Viewer,
665
    path: &str,
666
    owner: &str,
667
    name: &str,
668
    section: Section,
669
    q: Outcome,
670
) -> Result<Page<RepoSettingsPage>, Error> {
671
    let shell = Shell::new(app, path, viewer);
672
    let (_, row, role) = lookup_role(app, &shell, viewer, owner, name, Access::Read).await?;
673
    if role < Role::Maintain {
674
        return Err(Error::not_found(shell));
675
    }
676
    if row.archived && role < Role::Admin {
677
        return Err(super::archived(&shell));
678
    }
679
    settings_page(app, shell, row, role, section, q.notice, q.error).await
680
}
681
682
#[derive(Deserialize)]
683
pub struct GeneralForm {
684
    #[serde(rename = "_csrf")]
685
    csrf_token: String,
686
    description: String,
687
    default_bookmark: String,
688
    visibility: String,
689
    #[serde(default)]
690
    topics: String,
691
}
692
693
pub async fn update(
694
    State(app): State<Shared>,
695
    viewer: Viewer,
696
    Path((owner, name)): Path<(String, String)>,
697
    Form(f): Form<GeneralForm>,
698
) -> Result<Response, Error> {
699
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
700
    let (repo, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
701
    csrf(&viewer, &f.csrf_token, &shell)?;
702
    let e = |e| Error::from_db(shell.clone(), e);
703
    // A new default must be a bookmark the repository has, or every page
704
    // that opens it finds nothing; an empty repository may name any, for
705
    // its first push to create.
706
    let wanted = f.default_bookmark.trim().to_string();
707
    if wanted != row.default_bookmark {
708
        let names: Vec<String> = app
709
            .repos
710
            .read(&repo, |r, _| Ok(r.view()?.bookmarks.keys().cloned().collect::<Vec<String>>()))
711
            .await
712
            .map_err(|err| Error::from_repo(shell.clone(), err))?;
713
        if !names.is_empty() && !names.contains(&wanted) {
714
            let mut some: Vec<&str> = names.iter().map(String::as_str).filter(|n| *n == "main" || *n == "master").collect();
715
            some.extend(names.iter().map(String::as_str).filter(|n| *n != "main" && *n != "master").take(8 - some.len()));
716
            let more = if names.len() > some.len() { format!(" and {} more", names.len() - some.len()) } else { String::new() };
717
            let msg = format!("{wanted} is not a bookmark here yet: push it first, or choose one of {}{more}.", some.join(", "));
718
            return back(&row, Section::General, None, Some(msg));
719
        }
720
    }
721
    let topics = match db::repos::parse_topics(&f.topics) {
722
        Ok(t) => t,
723
        Err(msg) => return back(&row, Section::General, None, Some(msg)),
724
    };
725
    db::repos::set_description(&app.db, row.id, f.description.trim()).await.map_err(e)?;
726
    db::repos::set_topics(&app.db, row.id, &topics).await.map_err(e)?;
727
    if crate::repos::names::valid_segment(f.default_bookmark.trim()) {
728
        db::repos::set_default_bookmark(&app.db, row.id, f.default_bookmark.trim()).await.map_err(e)?;
729
    }
730
    if role >= Role::Admin {
731
        if let Some(v) = Visibility::parse(&f.visibility) {
732
            db::repos::set_visibility(&app.db, row.id, v).await.map_err(e)?;
733
        }
734
    }
735
    accounts::audit(&app.db, viewer.account(), "repo.updated", &format!("{owner}/{name}"), "").await.ok();
736
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(e)?.unwrap_or(row);
737
    back(&row, Section::General, Some("Saved.".into()), None)
738
}
739
740
#[derive(Deserialize)]
741
pub struct CollaboratorForm {
742
    #[serde(rename = "_csrf")]
743
    csrf_token: String,
744
    #[serde(default)]
745
    username: String,
746
    #[serde(default)]
747
    team: String,
748
    #[serde(default)]
749
    role: String,
750
    #[serde(default)]
751
    remove: Option<String>,
752
}
753
754
pub async fn collaborator(
755
    State(app): State<Shared>,
756
    viewer: Viewer,
757
    Path((owner, name)): Path<(String, String)>,
758
    Form(f): Form<CollaboratorForm>,
759
) -> Result<Response, Error> {
760
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
761
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
762
    csrf(&viewer, &f.csrf_token, &shell)?;
763
    let e = |e| Error::from_db(shell.clone(), e);
764
    let (account_id, team_id, label) = if !f.username.trim().is_empty() {
765
        match accounts::account_by_name(&app.db, f.username.trim()).await.map_err(e)? {
766
            Some(a) => (Some(a.id), None, a.username),
767
            None => return back(&row, Section::Collaborators, None, Some("No such account.".into())),
768
        }
769
    } else if !f.team.trim().is_empty() {
770
        let org = accounts::org_by_name(&app.db, &row.owner).await.map_err(e)?;
771
        let team = match org {
772
            Some(o) => accounts::team_by_name(&app.db, o.id, f.team.trim()).await.map_err(e)?,
773
            None => None,
774
        };
775
        match team {
776
            Some(t) => (None, Some(t.id), format!("team {}", t.name)),
777
            None => return back(&row, Section::Collaborators, None, Some("No such team in this organisation.".into())),
778
        }
779
    } else {
780
        return back(&row, Section::Collaborators, None, Some("Name an account or a team.".into()));
781
    };
782
    if f.remove.is_some() {
783
        accounts::remove_collaborator(&app.db, row.id, account_id, team_id).await.map_err(e)?;
784
        accounts::audit(&app.db, viewer.account(), "repo.collaborator.removed", &format!("{owner}/{name}"), &label).await.ok();
785
        return back(&row, Section::Collaborators, Some(format!("Removed {label}.")), None);
786
    }
787
    let Some(new_role) = Role::parse(&f.role) else {
788
        return back(&row, Section::Collaborators, None, Some("Pick a role.".into()));
789
    };
790
    accounts::set_collaborator(&app.db, row.id, account_id, team_id, new_role.as_str()).await.map_err(e)?;
791
    accounts::audit(
792
        &app.db,
793
        viewer.account(),
794
        "repo.collaborator.set",
795
        &format!("{owner}/{name}"),
796
        &format!("{label} {}", new_role.as_str()),
797
    )
798
    .await
799
    .ok();
800
    back(&row, Section::Collaborators, Some(format!("{label} now has {} access.", new_role.as_str())), None)
801
}
802
803
#[derive(Deserialize)]
804
pub struct TransferForm {
805
    #[serde(rename = "_csrf")]
806
    csrf_token: String,
807
    owner: String,
808
    confirm: String,
809
}
810
811
/// `POST /{owner}/{name}/settings/transfer`: move the repository to a
812
/// user or organisation the admin may create repositories under (#108).
813
/// The old URL redirects and oxy remotes keep working.
814
pub async fn transfer(
815
    State(app): State<Shared>,
816
    viewer: Viewer,
817
    Path((owner, name)): Path<(String, String)>,
818
    Form(f): Form<TransferForm>,
819
) -> Result<Response, Error> {
820
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
821
    let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
822
    csrf(&viewer, &f.csrf_token, &shell)?;
823
    let me = viewer.account().cloned().ok_or_else(|| Error::not_found(shell.clone()))?;
824
    let fail = |msg: &str| back(&row, Section::Advanced, None, Some(msg.into()));
825
    if f.confirm.trim() != repo.to_string() {
826
        return fail("Type the repository's full name to confirm.");
827
    }
828
    let to = f.owner.trim().trim_start_matches('@').to_string();
829
    let Some(dest) = RepoName::new(&to, &row.name) else {
830
        return fail("That is not an owner name.");
831
    };
832
    if dest.owner == row.owner {
833
        return fail("The repository is already there.");
834
    }
835
    let e = |e| Error::from_db(shell.clone(), e);
836
    let known = accounts::account_by_name(&app.db, &dest.owner).await.map_err(e)?.is_some()
837
        || accounts::org_by_name(&app.db, &dest.owner).await.map_err(e)?.is_some();
838
    if !known {
839
        return fail("There is no user or organisation of that name.");
840
    }
841
    if !roles::may_create_under(&app.db, &me, &dest.owner).await.map_err(e)? {
842
        return fail("You cannot create repositories under that owner, so you cannot move one there.");
843
    }
844
    if db::repos::get(&app.db, &dest.owner, &dest.name).await.map_err(e)?.is_some() || app.repos.exists(&dest) {
845
        return fail("That owner already has a repository of this name.");
846
    }
847
    {
848
        let _guard = app.repos.exclusive_guard(&repo).await;
849
        app.repos.rename(&repo, &dest).map_err(|e| Error::from_repo(shell.clone(), e))?;
850
        crate::storage::renamed(&app, &repo, &dest);
851
        if app.wikis.exists(&repo) {
852
            app.wikis.rename(&repo, &dest).ok();
853
        }
854
        db::repos::transfer(&app.db, row.id, &row.owner, &row.name, &dest.owner).await.map_err(e)?;
855
        let mirror = crate::mirror::mirror_dir(&app, &repo);
856
        if mirror.is_dir() {
857
            let to = crate::mirror::mirror_dir(&app, &dest);
858
            if let Some(parent) = to.parent() {
859
                std::fs::create_dir_all(parent).ok();
860
            }
861
            std::fs::rename(&mirror, &to).ok();
862
        }
863
    }
864
    accounts::audit(&app.db, Some(&me), "repo.transferred", &repo.to_string(), &dest.to_string()).await.ok();
865
    let notice = super::settings::urlencode(&format!("Moved from {repo}; the old address redirects here."));
866
    Ok(Redirect::to(&format!("{}/settings/{}?notice={notice}", dest.url(), Section::Advanced.slug())).into_response())
867
}
868
869
#[derive(Deserialize)]
870
pub struct ArchiveForm {
871
    #[serde(rename = "_csrf")]
872
    csrf_token: String,
873
    /// `archive` or `unarchive`.
874
    action: String,
875
    #[serde(default)]
876
    reason: String,
877
}
878
879
/// `POST /{owner}/{name}/settings/archive`: read-only, with a reason, or
880
/// back again (#108).
881
pub async fn archive(
882
    State(app): State<Shared>,
883
    viewer: Viewer,
884
    Path((owner, name)): Path<(String, String)>,
885
    Form(f): Form<ArchiveForm>,
886
) -> Result<Response, Error> {
887
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
888
    let (repo, mut row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
889
    csrf(&viewer, &f.csrf_token, &shell)?;
890
    let archive = match f.action.as_str() {
891
        "archive" => true,
892
        "unarchive" => false,
893
        _ => return back(&row, Section::Advanced, None, Some("Unknown action.".into())),
894
    };
895
    let reason: String = f.reason.trim().chars().take(500).collect();
896
    db::repos::set_archived(&app.db, row.id, archive, &reason).await.map_err(|e| Error::from_db(shell.clone(), e))?;
897
    accounts::audit(&app.db, viewer.account(), if archive { "repo.archived" } else { "repo.unarchived" }, &repo.to_string(), &reason)
898
        .await
899
        .ok();
900
    row.archived = archive;
901
    row.archived_reason = if archive { reason } else { String::new() };
902
    let notice = if archive {
903
        "The repository is archived: read-only until it is brought back."
904
    } else {
905
        "The repository is back: writes are allowed again."
906
    };
907
    back(&row, Section::Advanced, Some(notice.into()), None)
908
}
909
910
/// `POST /{owner}/{name}/settings/wiki/delete`: the wiki to the trash (#104).
911
pub async fn wiki_delete(
912
    State(app): State<Shared>,
913
    viewer: Viewer,
914
    Path((owner, name)): Path<(String, String)>,
915
    Form(f): Form<DeleteForm>,
916
) -> Result<Response, Error> {
917
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
918
    let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
919
    csrf(&viewer, &f.csrf_token, &shell)?;
920
    if f.confirm.trim() != repo.to_string() {
921
        return back(&row, Section::Advanced, None, Some("Type the repository's full name to confirm.".into()));
922
    }
923
    if !crate::wiki::exists(&app, &repo) {
924
        return back(&row, Section::Advanced, None, Some("This repository has no wiki.".into()));
925
    }
926
    let _guard = app.wikis.exclusive_guard(&repo).await;
927
    crate::wiki::trash(&app, &repo).map_err(|e| Error::from_repo(shell.clone(), e))?;
928
    drop(_guard);
929
    accounts::audit(&app.db, viewer.account(), "wiki.data_deleted", &repo.to_string(), "moved to trash").await.ok();
930
    back(&row, Section::Advanced, Some("The wiki is in the trash; a new first page starts a fresh one.".into()), None)
931
}
932
933
#[derive(Deserialize)]
934
pub struct DeleteForm {
935
    #[serde(rename = "_csrf")]
936
    csrf_token: String,
937
    confirm: String,
938
}
939
940
pub async fn delete(
941
    State(app): State<Shared>,
942
    viewer: Viewer,
943
    Path((owner, name)): Path<(String, String)>,
944
    Form(f): Form<DeleteForm>,
945
) -> Result<Response, Error> {
946
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
947
    let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
948
    csrf(&viewer, &f.csrf_token, &shell)?;
949
    if f.confirm.trim() != repo.to_string() {
950
        return back(&row, Section::Advanced, None, Some("Type the repository's full name to delete it.".into()));
951
    }
952
    let _guard = app.repos.exclusive_guard(&repo).await;
953
    app.repos.trash(&repo).map_err(|e| Error::from_repo(shell.clone(), e))?;
954
    // A repository kept in a bucket leaves its data there: the owner's.
955
    crate::storage::removed(&app, &repo);
956
    crate::wiki::trash(&app, &repo).ok();
957
    db::repos::delete(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
958
    crate::assistants::delete_scope(&app.db, crate::levels::Scope::Repo(row.id)).await.ok();
959
    crate::variables::delete_scope(&app.db, crate::levels::Scope::Repo(row.id)).await.ok();
960
    crate::secrets::delete_scope(&app.db, crate::levels::Scope::Repo(row.id)).await.ok();
961
    accounts::audit(&app.db, viewer.account(), "repo.deleted", &repo.to_string(), "moved to trash").await.ok();
962
    Ok(Redirect::to(&format!("/{owner}")).into_response())
963
}
964
965
#[derive(Deserialize)]
966
pub struct RequirementsForm {
967
    #[serde(rename = "_csrf")]
968
    csrf_token: String,
969
    #[serde(default)]
970
    required_approvals: i64,
971
    #[serde(default)]
972
    require_owners: Option<String>,
973
    #[serde(default)]
974
    allow_conflicts: Option<String>,
975
}
976
977
pub async fn requirements(
978
    State(app): State<Shared>,
979
    viewer: Viewer,
980
    Path((owner, name)): Path<(String, String)>,
981
    Form(f): Form<RequirementsForm>,
982
) -> Result<Response, Error> {
983
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
984
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
985
    csrf(&viewer, &f.csrf_token, &shell)?;
986
    let r = db::proposals::Requirements {
987
        required_approvals: f.required_approvals.max(0),
988
        require_owners: f.require_owners.is_some(),
989
        allow_conflicts: f.allow_conflicts.is_some(),
990
    };
991
    db::proposals::set_requirements(&app.db, row.id, r).await.map_err(|e| Error::from_db(shell.clone(), e))?;
992
    accounts::audit(
993
        &app.db,
994
        viewer.account(),
995
        "repo.requirements",
996
        &format!("{owner}/{name}"),
997
        &format!("{} approvals, owners {}, conflicts {}", r.required_approvals, r.require_owners, r.allow_conflicts),
998
    )
999
    .await
1000
    .ok();
1001
    back(&row, Section::Landing, Some("Landing requirements saved.".into()), None)
1002
}
1003
1004
pub async fn hooks(
1005
    State(app): State<Shared>,
1006
    viewer: Viewer,
1007
    Path((owner, name)): Path<(String, String)>,
1008
    Form(f): Form<super::webhooks::HookForm>,
1009
) -> Result<Response, Error> {
1010
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1011
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
1012
    csrf(&viewer, &f.csrf_token, &shell)?;
1013
    let scope = super::webhooks::Scope::Repo { id: row.id, slug: format!("{owner}/{name}") };
1014
    match super::webhooks::apply(&app, &viewer, &scope, f).await.map_err(|e| Error::from_db(shell.clone(), e))? {
1015
        Ok(notice) => back(&row, Section::Webhooks, Some(notice), None),
1016
        Err(error) => back(&row, Section::Webhooks, None, Some(error)),
1017
    }
1018
}
1019
1020
pub async fn deliveries(
1021
    State(app): State<Shared>,
1022
    viewer: Viewer,
1023
    Path((owner, name, id)): Path<(String, String, i64)>,
1024
) -> Result<Page<super::webhooks::DeliveriesPage>, Error> {
1025
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings/hooks/{id}/deliveries"), &viewer);
1026
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
1027
    let scope = super::webhooks::Scope::Repo { id: row.id, slug: format!("{owner}/{name}") };
1028
    super::webhooks::deliveries_page(&app, shell, &scope, Some(RepoShell::new(&app, &row, "settings").with_role(role)), id).await
1029
}
1030
1031
pub async fn redeliver(
1032
    State(app): State<Shared>,
1033
    viewer: Viewer,
1034
    Path((owner, name, id, delivery)): Path<(String, String, i64, i64)>,
1035
    Form(f): Form<super::session::CsrfForm>,
1036
) -> Result<Page<super::webhooks::DeliveriesPage>, Error> {
1037
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings/hooks/{id}/deliveries"), &viewer);
1038
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
1039
    csrf(&viewer, &f.csrf, &shell)?;
1040
    let scope = super::webhooks::Scope::Repo { id: row.id, slug: format!("{owner}/{name}") };
1041
    super::webhooks::redeliver(&app, &scope, id, delivery).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1042
    super::webhooks::deliveries_page(&app, shell, &scope, Some(RepoShell::new(&app, &row, "settings").with_role(role)), id).await
1043
}
1044
1045
#[derive(Deserialize)]
1046
pub struct ChecksForm {
1047
    #[serde(rename = "_csrf")]
1048
    csrf_token: String,
1049
    #[serde(default)]
1050
    required_checks: String,
1051
}
1052
1053
pub async fn required_checks(
1054
    State(app): State<Shared>,
1055
    viewer: Viewer,
1056
    Path((owner, name)): Path<(String, String)>,
1057
    Form(f): Form<ChecksForm>,
1058
) -> Result<Response, Error> {
1059
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1060
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
1061
    csrf(&viewer, &f.csrf_token, &shell)?;
1062
    let names: Vec<String> = f.required_checks.split(',').map(str::trim).filter(|s| !s.is_empty()).map(String::from).collect();
1063
    db::hooks::set_required_checks(&app.db, row.id, &names).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1064
    back(&row, Section::Checks, Some("Required checks saved.".into()), None)
1065
}
1066
1067
#[derive(Deserialize)]
1068
pub struct CoverageForm {
1069
    #[serde(rename = "_csrf")]
1070
    csrf_token: String,
1071
    #[serde(default)]
1072
    min: String,
1073
    #[serde(default)]
1074
    max_drop: String,
1075
}
1076
1077
/// Settings → Coverage (#113): the rules the `coverage` check applies.
1078
pub async fn coverage(
1079
    State(app): State<Shared>,
1080
    viewer: Viewer,
1081
    Path((owner, name)): Path<(String, String)>,
1082
    Form(f): Form<CoverageForm>,
1083
) -> Result<Response, Error> {
1084
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1085
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
1086
    csrf(&viewer, &f.csrf_token, &shell)?;
1087
    let number = |v: &str, what: &str, hi: f64| -> Result<Option<f64>, String> {
1088
        let v = v.trim().trim_end_matches('%').trim();
1089
        if v.is_empty() {
1090
            return Ok(None);
1091
        }
1092
        match v.parse::<f64>() {
1093
            Ok(n) if (0.0..=hi).contains(&n) => Ok(Some(n)),
1094
            _ => Err(format!("{what} is a number from 0 to {hi}, or empty for no rule.")),
1095
        }
1096
    };
1097
    let rules = match (number(&f.min, "The minimum", 100.0), number(&f.max_drop, "The largest drop", 100.0)) {
1098
        (Ok(min), Ok(max_drop)) => crate::coverage::Rules { min, max_drop },
1099
        (Err(e), _) | (_, Err(e)) => return back(&row, Section::Coverage, None, Some(e)),
1100
    };
1101
    crate::coverage::set_rules(&app.db, row.id, rules).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1102
    accounts::audit(
1103
        &app.db,
1104
        viewer.account(),
1105
        "repo.coverage_rules",
1106
        &format!("{owner}/{name}"),
1107
        &format!("min {:?}, max drop {:?}", rules.min, rules.max_drop),
1108
    )
1109
    .await
1110
    .ok();
1111
    back(&row, Section::Coverage, Some("Coverage rules saved; they apply from the next report.".into()), None)
1112
}
1113
1114
#[derive(Deserialize)]
1115
pub struct StorageForm {
1116
    #[serde(rename = "_csrf")]
1117
    csrf_token: String,
1118
    /// `check`, or `credentials` (with the new ones).
1119
    action: String,
1120
    #[serde(flatten)]
1121
    bucket: BucketForm,
1122
}
1123
1124
/// Settings → Storage (oxygenhub#96): check the bucket again, or replace
1125
/// its credentials once the new ones pass the check.
1126
pub async fn storage(
1127
    State(app): State<Shared>,
1128
    viewer: Viewer,
1129
    Path((owner, name)): Path<(String, String)>,
1130
    Form(f): Form<StorageForm>,
1131
) -> Result<Response, Error> {
1132
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1133
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
1134
    csrf(&viewer, &f.csrf_token, &shell)?;
1135
    let Some(s) = crate::storage::of(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))? else {
1136
        return back(&row, Section::General, None, Some("This repository is kept on this server's disk.".into()));
1137
    };
1138
    let scheme = s.url.split("://").next().unwrap_or("").to_string();
1139
    let outcome = match f.action.as_str() {
1140
        "credentials" => match f.bucket.credentials(&scheme) {
1141
            Ok(c) => crate::storage::set_credentials(&app, &s, c).await.map(|r| format!("New credentials saved: {r}.")),
1142
            Err(e) => Err(format!("Credentials: {e}")),
1143
        },
1144
        _ => crate::storage::check(&app, &s).await.map(|r| format!("The bucket passed the check: {r}.")),
1145
    };
1146
    match outcome {
1147
        Ok(notice) => {
1148
            accounts::audit(&app.db, viewer.account(), "repo.storage", &format!("{owner}/{name}"), &f.action).await.ok();
1149
            back(&row, Section::Storage, Some(notice), None)
1150
        }
1151
        Err(e) => back(&row, Section::Storage, None, Some(format!("Storage: {e}"))),
1152
    }
1153
}
1154
1155
#[derive(Deserialize)]
1156
pub struct MirrorForm {
1157
    #[serde(rename = "_csrf")]
1158
    csrf_token: String,
1159
    #[serde(default)]
1160
    enabled: Option<String>,
1161
}
1162
1163
#[derive(Deserialize)]
1164
pub struct BackupForm {
1165
    #[serde(rename = "_csrf")]
1166
    csrf_token: String,
1167
    /// Empty (with its owner's), `hourly`, `daily` or `off`.
1168
    #[serde(default)]
1169
    backup: String,
1170
}
1171
1172
/// This repository's backup (docs/backups.md §5): with its owner's, left
1173
/// out, or at least hourly or daily. Its owners and admins decide.
1174
pub async fn backup(
1175
    State(app): State<Shared>,
1176
    viewer: Viewer,
1177
    Path((owner, name)): Path<(String, String)>,
1178
    Form(f): Form<BackupForm>,
1179
) -> Result<Response, Error> {
1180
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1181
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
1182
    csrf(&viewer, &f.csrf_token, &shell)?;
1183
    if !crate::backups::REPO_CHOICES.iter().any(|(v, _)| *v == f.backup) {
1184
        return back(&row, Section::Backup, None, Some("Pick how this repository is backed up.".into()));
1185
    }
1186
    db::repos::set_backup(&app.db, row.id, &f.backup).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1187
    let what = if f.backup.is_empty() { "with its owner's" } else { f.backup.as_str() };
1188
    accounts::audit(&app.db, viewer.account(), "repo.backup", &format!("{owner}/{name}"), what).await.ok();
1189
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
1190
    let notice = match f.backup.as_str() {
1191
        "off" => "This repository is left out of its owner's backups from the next run.".to_string(),
1192
        "" => "This repository is backed up with its owner's.".to_string(),
1193
        every => {
1194
            format!("This repository is backed up at least {} from now on.", if every == "hourly" { "every hour" } else { "every day" })
1195
        }
1196
    };
1197
    back(&row, Section::Backup, Some(notice), None)
1198
}
1199
1200
#[derive(Deserialize)]
1201
pub struct DiffusionForm {
1202
    #[serde(rename = "_csrf")]
1203
    csrf_token: String,
1204
    /// `off`, `green`, `any`, or `default`.
1205
    diffusion: String,
1206
    /// `auto` or `propose` (absent keeps it as it is).
1207
    #[serde(default)]
1208
    land: String,
1209
}
1210
1211
/// Whether this repository follows the upstreams it pins (Diffusion,
1212
/// #148): its owners and admins decide, since it opens proposals here.
1213
pub async fn diffusion(
1214
    State(app): State<Shared>,
1215
    viewer: Viewer,
1216
    Path((owner, name)): Path<(String, String)>,
1217
    Form(f): Form<DiffusionForm>,
1218
) -> Result<Response, Error> {
1219
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1220
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
1221
    csrf(&viewer, &f.csrf_token, &shell)?;
1222
    let setting = match f.diffusion.as_str() {
1223
        "off" | "green" | "any" => f.diffusion.as_str(),
1224
        "default" => "",
1225
        _ => return back(&row, Section::Diffusion, None, Some("Pick off, green, any or the site default.".into())),
1226
    };
1227
    let land = match f.land.as_str() {
1228
        "auto" | "propose" => Some(f.land.as_str()),
1229
        "" => None,
1230
        _ => return back(&row, Section::Diffusion, None, Some("Pick propose or auto for landing.".into())),
1231
    };
1232
    db::repos::set_diffusion(&app.db, row.id, setting).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1233
    if let Some(l) = land {
1234
        db::repos::set_diffusion_land(&app.db, row.id, l).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1235
    }
1236
    let what =
1237
        format!("{}{}", if setting.is_empty() { "default" } else { setting }, land.map(|l| format!(", land {l}")).unwrap_or_default());
1238
    accounts::audit(&app.db, viewer.account(), "repo.diffusion", &format!("{owner}/{name}"), &what).await.ok();
1239
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
1240
    // Turned to land by itself: any bump already green lands now.
1241
    if land == Some("auto") {
1242
        let (app, row) = (app.clone(), row.clone());
1243
        tokio::spawn(async move { crate::diffusion::land_ready(&app, &row).await });
1244
    }
1245
    if crate::diffusion::rule(&app.config.diffusion.follow, &row, None, "").follow != crate::diffusion::Follow::Off {
1246
        let (app, row) = (app.clone(), row.clone());
1247
        tokio::spawn(async move { crate::diffusion::catch_up(&app, &row).await });
1248
    }
1249
    let notice = match crate::diffusion::rule(&app.config.diffusion.follow, &row, None, "").follow {
1250
        crate::diffusion::Follow::Off => "Diffusion is off: upstream moves open no proposals here.",
1251
        crate::diffusion::Follow::Green => {
1252
            "Diffusion is on: when a subrepo's upstream moves and its checks pass, a proposal moves the pin here."
1253
        }
1254
        crate::diffusion::Follow::Any => "Diffusion is on: every move of a subrepo's upstream opens or updates a proposal here.",
1255
    };
1256
    back(&row, Section::Diffusion, Some(notice.into()), None)
1257
}
1258
1259
#[derive(Deserialize)]
1260
pub struct AgentGatesForm {
1261
    #[serde(rename = "_csrf")]
1262
    csrf_token: String,
1263
    /// `on` when the box is ticked.
1264
    #[serde(default)]
1265
    agents: String,
1266
}
1267
1268
/// `POST /{owner}/{repo}/settings/agent-gates` (oxygenhub#258): whether an
1269
/// agent's token may approve or reject this repository's approval gates;
1270
/// for maintainers, audited.
1271
pub async fn agent_gates(
1272
    State(app): State<Shared>,
1273
    viewer: Viewer,
1274
    Path((owner, name)): Path<(String, String)>,
1275
    Form(f): Form<AgentGatesForm>,
1276
) -> Result<Response, Error> {
1277
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1278
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
1279
    csrf(&viewer, &f.csrf_token, &shell)?;
1280
    let on = f.agents == "on";
1281
    db::repos::set_agents_decide_gates(&app.db, row.id, on).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1282
    accounts::audit(&app.db, viewer.account(), "repo.agents_decide_gates", &format!("{owner}/{name}"), if on { "on" } else { "off" })
1283
        .await
1284
        .ok();
1285
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
1286
    let notice = if on {
1287
        "Agents' tokens may now approve or reject this repository's approval gates; it says so on each."
1288
    } else {
1289
        "Approval gates here are decided by people: agents' tokens are refused."
1290
    };
1291
    back(&row, Section::Gates, Some(notice.into()), None)
1292
}
1293
1294
#[derive(Deserialize)]
1295
pub struct PushProtectionForm {
1296
    #[serde(rename = "_csrf")]
1297
    csrf_token: String,
1298
    /// `on` when the box is ticked.
1299
    #[serde(default)]
1300
    protect: String,
1301
}
1302
1303
/// `POST /{owner}/{repo}/settings/push-protection` (oxygenhub#268): whether
1304
/// pushes adding what looks like a secret are refused; for maintainers,
1305
/// audited.
1306
pub async fn push_protection(
1307
    State(app): State<Shared>,
1308
    viewer: Viewer,
1309
    Path((owner, name)): Path<(String, String)>,
1310
    Form(f): Form<PushProtectionForm>,
1311
) -> Result<Response, Error> {
1312
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1313
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
1314
    csrf(&viewer, &f.csrf_token, &shell)?;
1315
    let on = f.protect == "on";
1316
    db::push_protection::set_enabled(&app.db, row.id, on).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1317
    accounts::audit(&app.db, viewer.account(), "repo.push_protection", &format!("{owner}/{name}"), if on { "on" } else { "off" })
1318
        .await
1319
        .ok();
1320
    if on {
1321
        // What was pushed while it was off is scanned now (#277).
1322
        crate::history_scan::wake(&app, row.id);
1323
    }
1324
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
1325
    let notice = if on {
1326
        "Push protection is on: a push that adds what looks like a secret is refused, saying where."
1327
    } else {
1328
        "Push protection is off: pushes are no longer scanned for secrets here."
1329
    };
1330
    back(&row, Section::PushProtection, Some(notice.into()), None)
1331
}
1332
1333
#[derive(Deserialize)]
1334
pub struct SecretBypassForm {
1335
    #[serde(rename = "_csrf")]
1336
    csrf_token: String,
1337
    fingerprint: String,
1338
    #[serde(default)]
1339
    reason: String,
1340
}
1341
1342
/// `POST /{owner}/{repo}/settings/secret-bypass` (oxygenhub#268): let one
1343
/// push protection finding through, by its fingerprint, with a reason; for
1344
/// maintainers, audited as `secret.bypass`.
1345
pub async fn secret_bypass(
1346
    State(app): State<Shared>,
1347
    viewer: Viewer,
1348
    Path((owner, name)): Path<(String, String)>,
1349
    Form(f): Form<SecretBypassForm>,
1350
) -> Result<Response, Error> {
1351
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1352
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
1353
    csrf(&viewer, &f.csrf_token, &shell)?;
1354
    let fingerprint = f.fingerprint.trim().to_ascii_lowercase();
1355
    let reason = f.reason.trim();
1356
    let error = if fingerprint.len() != 16 || !fingerprint.chars().all(|c| c.is_ascii_hexdigit()) {
1357
        Some("A fingerprint is the 16 hexadecimal digits a refused push printed.")
1358
    } else if reason.is_empty() {
1359
        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.")
1360
    } else if reason.chars().count() > 500 {
1361
        Some("Keep the reason under 500 characters.")
1362
    } else {
1363
        None
1364
    };
1365
    if let Some(e) = error {
1366
        return back(&row, Section::PushProtection, None, Some(e.into()));
1367
    }
1368
    let rule = db::push_protection::rule_of(&app.db, row.id, &fingerprint)
1369
        .await
1370
        .map_err(|e| Error::from_db(shell.clone(), e))?
1371
        .unwrap_or_default();
1372
    let by = viewer.account().map(|a| (a.id, a.username.as_str()));
1373
    db::push_protection::allow(&app.db, row.id, &fingerprint, &rule, reason, by).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1374
    let detail = if rule.is_empty() { format!("{fingerprint}: {reason}") } else { format!("{fingerprint} ({rule}): {reason}") };
1375
    accounts::audit(&app.db, viewer.account(), "secret.bypass", &format!("{owner}/{name}"), &detail).await.ok();
1376
    let notice = format!("Fingerprint {fingerprint} is allowed here: a push adding that value is no longer refused for it.");
1377
    back(&row, Section::PushProtection, Some(notice), None)
1378
}
1379
1380
#[derive(Deserialize)]
1381
pub struct BypassRemoveForm {
1382
    #[serde(rename = "_csrf")]
1383
    csrf_token: String,
1384
    fingerprint: String,
1385
}
1386
1387
/// `POST /{owner}/{repo}/settings/secret-bypass/remove` (oxygenhub#277):
1388
/// take back an allowed fingerprint, so a push adding that value is refused
1389
/// again; for maintainers, audited as `secret.bypass_removed`.
1390
pub async fn secret_bypass_remove(
1391
    State(app): State<Shared>,
1392
    viewer: Viewer,
1393
    Path((owner, name)): Path<(String, String)>,
1394
    Form(f): Form<BypassRemoveForm>,
1395
) -> Result<Response, Error> {
1396
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1397
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
1398
    csrf(&viewer, &f.csrf_token, &shell)?;
1399
    let fingerprint = f.fingerprint.trim().to_ascii_lowercase();
1400
    let removed = db::push_protection::remove_bypass(&app.db, row.id, &fingerprint).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1401
    let Some(b) = removed else {
1402
        return back(&row, Section::PushProtection, None, Some(format!("Fingerprint {fingerprint} is not allowed here.")));
1403
    };
1404
    let detail = if b.rule.is_empty() {
1405
        format!("{fingerprint} (was: {})", b.reason)
1406
    } else {
1407
        format!("{fingerprint} ({}) (was: {})", b.rule, b.reason)
1408
    };
1409
    accounts::audit(&app.db, viewer.account(), "secret.bypass_removed", &format!("{owner}/{name}"), &detail).await.ok();
1410
    let notice = format!("Fingerprint {fingerprint} is no longer allowed: a push adding that value is refused again.");
1411
    back(&row, Section::PushProtection, Some(notice), None)
1412
}
1413
1414
#[derive(Deserialize)]
1415
pub struct SecretResolveForm {
1416
    #[serde(rename = "_csrf")]
1417
    csrf_token: String,
1418
    finding: i64,
1419
    #[serde(default)]
1420
    reason: String,
1421
}
1422
1423
/// `POST /{owner}/{repo}/settings/secret-resolve` (oxygenhub#277): mark a
1424
/// finding of the history scan resolved (the secret was revoked or
1425
/// rotated), with a reason; for maintainers, audited as `secret.resolved`.
1426
pub async fn secret_resolve(
1427
    State(app): State<Shared>,
1428
    viewer: Viewer,
1429
    Path((owner, name)): Path<(String, String)>,
1430
    Form(f): Form<SecretResolveForm>,
1431
) -> Result<Response, Error> {
1432
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1433
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
1434
    csrf(&viewer, &f.csrf_token, &shell)?;
1435
    let reason = f.reason.trim();
1436
    let error = if reason.is_empty() {
1437
        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.")
1438
    } else if reason.chars().count() > 500 {
1439
        Some("Keep the reason under 500 characters.")
1440
    } else {
1441
        None
1442
    };
1443
    if let Some(e) = error {
1444
        return back(&row, Section::PushProtection, None, Some(e.into()));
1445
    }
1446
    let by = viewer.account().map(|a| a.username.as_str()).unwrap_or("");
1447
    let done = db::push_protection::resolve(&app.db, row.id, f.finding, by, reason).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1448
    let Some(found) = done else {
1449
        return back(&row, Section::PushProtection, None, Some("That finding is not open here.".into()));
1450
    };
1451
    let detail = format!("{} ({}) at {}:{}: {reason}", found.fingerprint, found.rule, found.path, found.line);
1452
    accounts::audit(&app.db, viewer.account(), "secret.resolved", &format!("{owner}/{name}"), &detail).await.ok();
1453
    let notice = format!("The finding at {}:{} is marked resolved.", found.path, found.line);
1454
    back(&row, Section::PushProtection, Some(notice), None)
1455
}
1456
1457
#[derive(Deserialize)]
1458
pub struct SecretScanForm {
1459
    #[serde(rename = "_csrf")]
1460
    csrf_token: String,
1461
}
1462
1463
/// `POST /{owner}/{repo}/settings/secret-scan` (oxygenhub#277): scan the
1464
/// whole history again now, with the current rules; for maintainers,
1465
/// audited as `secret.history_scan`.
1466
pub async fn secret_scan(
1467
    State(app): State<Shared>,
1468
    viewer: Viewer,
1469
    Path((owner, name)): Path<(String, String)>,
1470
    Form(f): Form<SecretScanForm>,
1471
) -> Result<Response, Error> {
1472
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1473
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
1474
    csrf(&viewer, &f.csrf_token, &shell)?;
1475
    if !row.push_protection {
1476
        return back(
1477
            &row,
1478
            Section::PushProtection,
1479
            None,
1480
            Some("Turn push protection on first: history is scanned only where it is on.".into()),
1481
        );
1482
    }
1483
    crate::history_scan::request(&app, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1484
    accounts::audit(&app.db, viewer.account(), "secret.history_scan", &format!("{owner}/{name}"), "requested").await.ok();
1485
    let notice = "The whole history will be scanned again, before other repositories: findings appear below as they are found.";
1486
    back(&row, Section::PushProtection, Some(notice.into()), None)
1487
}
1488
1489
#[derive(Deserialize)]
1490
pub struct SupersedeForm {
1491
    #[serde(rename = "_csrf")]
1492
    csrf_token: String,
1493
    /// `on` when the box is ticked: superseded runs already running are
1494
    /// cancelled too.
1495
    #[serde(default)]
1496
    running: String,
1497
}
1498
1499
/// `POST /{owner}/{repo}/settings/flow-supersede` (oxygenhub#267): whether
1500
/// a new push or proposal run cancels the older runs it supersedes that are
1501
/// already running, or only those still queued; for maintainers, audited.
1502
pub async fn flow_supersede(
1503
    State(app): State<Shared>,
1504
    viewer: Viewer,
1505
    Path((owner, name)): Path<(String, String)>,
1506
    Form(f): Form<SupersedeForm>,
1507
) -> Result<Response, Error> {
1508
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1509
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
1510
    csrf(&viewer, &f.csrf_token, &shell)?;
1511
    let keep = f.running != "on";
1512
    db::repos::set_flow_keep_running(&app.db, row.id, keep).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1513
    accounts::audit(&app.db, viewer.account(), "repo.flow_keep_running", &format!("{owner}/{name}"), if keep { "on" } else { "off" })
1514
        .await
1515
        .ok();
1516
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
1517
    let notice = if keep {
1518
        "A new push or proposal run now cancels only the older runs still queued; those already running finish."
1519
    } else {
1520
        "A new push or proposal run now cancels the older runs it supersedes, queued or running."
1521
    };
1522
    back(&row, Section::Runs, Some(notice.into()), None)
1523
}
1524
1525
#[derive(Deserialize)]
1526
pub struct ReuseForm {
1527
    #[serde(rename = "_csrf")]
1528
    csrf_token: String,
1529
    /// `on` when the box is ticked.
1530
    #[serde(default)]
1531
    reuse: String,
1532
}
1533
1534
/// `POST /{owner}/{repo}/settings/flow-reuse` (oxygenhub#265): whether a
1535
/// push run reuses a job's pass on the same state; for maintainers,
1536
/// audited.
1537
pub async fn flow_reuse(
1538
    State(app): State<Shared>,
1539
    viewer: Viewer,
1540
    Path((owner, name)): Path<(String, String)>,
1541
    Form(f): Form<ReuseForm>,
1542
) -> Result<Response, Error> {
1543
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1544
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
1545
    csrf(&viewer, &f.csrf_token, &shell)?;
1546
    let on = f.reuse == "on";
1547
    db::repos::set_flow_reuse(&app.db, row.id, on).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1548
    accounts::audit(&app.db, viewer.account(), "repo.flow_reuse", &format!("{owner}/{name}"), if on { "on" } else { "off" }).await.ok();
1549
    let notice = if on {
1550
        "A push run now reuses a job's pass on the same state."
1551
    } else {
1552
        "Every job of every push now runs, passed before or not."
1553
    };
1554
    back(&row, Section::Runs, Some(notice.into()), None)
1555
}
1556
1557
#[derive(Deserialize)]
1558
pub struct SkipForm {
1559
    #[serde(rename = "_csrf")]
1560
    csrf_token: String,
1561
    /// `any`, `others` or `never`.
1562
    skip: String,
1563
}
1564
1565
/// `POST /{owner}/{repo}/settings/flow-skip` (oxygenhub#284): where a push
1566
/// may skip its run with `[skip flow]` in its message; for maintainers,
1567
/// audited.
1568
pub async fn flow_skip(
1569
    State(app): State<Shared>,
1570
    viewer: Viewer,
1571
    Path((owner, name)): Path<(String, String)>,
1572
    Form(f): Form<SkipForm>,
1573
) -> Result<Response, Error> {
1574
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1575
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
1576
    csrf(&viewer, &f.csrf_token, &shell)?;
1577
    let notice = match f.skip.as_str() {
1578
        "any" => "A push may now skip its run on any bookmark.".to_string(),
1579
        "others" => format!("A push may now skip its run on any bookmark but {}.", row.default_bookmark),
1580
        "never" => "Every push now runs; skip markers are ignored.".to_string(),
1581
        _ => return back(&row, Section::Runs, None, Some("Choose where a push may skip its run.".into())),
1582
    };
1583
    db::repos::set_flow_skip(&app.db, row.id, &f.skip).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1584
    accounts::audit(&app.db, viewer.account(), "repo.flow_skip", &format!("{owner}/{name}"), &f.skip).await.ok();
1585
    back(&row, Section::Runs, Some(notice), None)
1586
}
1587
1588
#[derive(Deserialize)]
1589
pub struct PriorityForm {
1590
    #[serde(rename = "_csrf")]
1591
    csrf_token: String,
1592
    /// `low`, `normal`, `high`, or `default`.
1593
    priority: String,
1594
}
1595
1596
/// `POST /{owner}/{repo}/settings/flow-priority` (oxygenhub#244): its jobs'
1597
/// level. Maintainers choose low, normal or the default; only site
1598
/// administrators raise a repository to high.
1599
pub async fn flow_priority(
1600
    State(app): State<Shared>,
1601
    viewer: Viewer,
1602
    Path((owner, name)): Path<(String, String)>,
1603
    Form(f): Form<PriorityForm>,
1604
) -> Result<Response, Error> {
1605
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1606
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
1607
    csrf(&viewer, &f.csrf_token, &shell)?;
1608
    let level = match f.priority.as_str() {
1609
        "low" | "normal" | "high" => f.priority.as_str(),
1610
        "default" => "",
1611
        _ => return back(&row, Section::Runs, None, Some("Pick low, normal, high or the default.".into())),
1612
    };
1613
    if level == "high" && !viewer.is_admin() {
1614
        return back(&row, Section::Runs, None, Some("Only site administrators raise a repository to high.".into()));
1615
    }
1616
    db::repos::set_flow_priority(&app.db, row.id, level).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1617
    accounts::audit(
1618
        &app.db,
1619
        viewer.account(),
1620
        "repo.flow_priority",
1621
        &format!("{owner}/{name}"),
1622
        if level.is_empty() { "default" } else { level },
1623
    )
1624
    .await
1625
    .ok();
1626
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
1627
    let notice = if level.is_empty() {
1628
        "Its jobs follow the organisation's and the site's priority.".to_string()
1629
    } else {
1630
        format!("Its jobs are {level} priority.")
1631
    };
1632
    back(&row, Section::Runs, Some(notice), None)
1633
}
1634
1635
#[derive(Deserialize)]
1636
pub struct FlowForm {
1637
    #[serde(rename = "_csrf")]
1638
    csrf_token: String,
1639
    /// `on`, `off`, or `default`.
1640
    flow: String,
1641
}
1642
1643
/// Turn OxygenFlow on or off for one repository. Site administrators choose
1644
/// anything; the repository's maintainers may turn it on on their own
1645
/// runners (`own`), when the repository or its owner has one, or off (#225).
1646
/// Only site administrators let the site's runners take its jobs.
1647
pub async fn flow(
1648
    State(app): State<Shared>,
1649
    viewer: Viewer,
1650
    Path((owner, name)): Path<(String, String)>,
1651
    Form(f): Form<FlowForm>,
1652
) -> Result<Response, Error> {
1653
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1654
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
1655
    csrf(&viewer, &f.csrf_token, &shell)?;
1656
    let setting = match f.flow.as_str() {
1657
        "on" => "on",
1658
        "own" => "own",
1659
        "off" => "off",
1660
        "default" => "",
1661
        _ => return back(&row, Section::Pipelines, None, Some("Pick on, own runners, off or the site default.".into())),
1662
    };
1663
    if !viewer.is_admin() {
1664
        if matches!(setting, "on" | "") {
1665
            return back(
1666
                &row,
1667
                Section::Pipelines,
1668
                None,
1669
                Some("Only site administrators let the site's runners take this repository's jobs.".into()),
1670
            );
1671
        }
1672
        if setting == "own" && !has_own_runner(&app, &row).await.map_err(|e| Error::from_db(shell.clone(), e))? {
1673
            return back(
1674
                &row,
1675
                Section::Pipelines,
1676
                None,
1677
                Some("Register a runner for this repository (Settings › Runners) or its owner first.".into()),
1678
            );
1679
        }
1680
    }
1681
    db::repos::set_flow(&app.db, row.id, setting).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1682
    let label = if setting.is_empty() { "default" } else { setting };
1683
    accounts::audit(&app.db, viewer.account(), "repo.flow", &format!("{owner}/{name}"), label).await.ok();
1684
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
1685
    let notice = if row.flow == "own" {
1686
        "OxygenFlow is on, on this repository's own runners: pushes with a .flow/pipeline.kdl start runs that only they take."
1687
    } else if crate::flow::enabled(&app, &row) {
1688
        "OxygenFlow is on: pushes with a .flow/pipeline.kdl start runs."
1689
    } else {
1690
        "OxygenFlow is off for this repository."
1691
    };
1692
    back(&row, Section::Pipelines, Some(notice.into()), None)
1693
}
1694
1695
pub async fn mirror(
1696
    State(app): State<Shared>,
1697
    viewer: Viewer,
1698
    Path((owner, name)): Path<(String, String)>,
1699
    Form(f): Form<MirrorForm>,
1700
) -> Result<Response, Error> {
1701
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1702
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
1703
    csrf(&viewer, &f.csrf_token, &shell)?;
1704
    db::repos::set_git_mirror(&app.db, row.id, f.enabled.is_some()).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1705
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
1706
    if row.git_mirror {
1707
        crate::mirror::refresh(&app, &row).await.ok();
1708
    }
1709
    accounts::audit(&app.db, viewer.account(), "repo.mirror", &format!("{owner}/{name}"), if row.git_mirror { "on" } else { "off" })
1710
        .await
1711
        .ok();
1712
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
1713
    let notice = if row.git_mirror { "The Git mirror is on and up to date." } else { "The Git mirror is off." };
1714
    back(&row, Section::GitMirror, Some(notice.into()), None)
1715
}
1716
1717
/// `POST /{owner}/{name}/settings/runners/token`: a one-time registration
1718
/// token for a runner that serves this repository only (oxygenhub#41).
1719
pub async fn runner_token(
1720
    State(app): State<Shared>,
1721
    viewer: Viewer,
1722
    Path((owner, name)): Path<(String, String)>,
1723
    Form(f): Form<super::session::CsrfForm>,
1724
) -> Result<Page<RepoSettingsPage>, Error> {
1725
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1726
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
1727
    csrf(&viewer, &f.csrf, &shell)?;
1728
    let me = viewer.account().map(|a| a.username.clone()).unwrap_or_default();
1729
    let token = format!("flowreg_{}", crate::auth::random_hex(24));
1730
    let scope = db::runners::Scope { owner: row.owner.clone(), repo_id: Some(row.id) };
1731
    db::runners::add_registration(&app.db, &blake3::hash(token.as_bytes()).to_hex(), &me, &scope)
1732
        .await
1733
        .map_err(|e| Error::from_db(shell.clone(), e))?;
1734
    accounts::audit(&app.db, viewer.account(), "flow.runner.token", &row.slug(), "registration token for this repository").await.ok();
1735
    let mut page =
1736
        settings_page(&app, shell, row, role, Section::Runners, Some("Copy the token now; it is not shown again.".into()), None).await?;
1737
    page.0.runner_token = Some(token);
1738
    Ok(page)
1739
}
1740
1741
/// `POST /{owner}/{name}/settings/runners/{id}/remove`: remove one of this
1742
/// repository's own runners, when it is running nothing.
1743
pub async fn runner_remove(
1744
    State(app): State<Shared>,
1745
    viewer: Viewer,
1746
    Path((owner, name, id)): Path<(String, String, i64)>,
1747
    Form(f): Form<super::session::CsrfForm>,
1748
) -> Result<Response, Error> {
1749
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1750
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
1751
    csrf(&viewer, &f.csrf, &shell)?;
1752
    let e = |e| Error::from_db(shell.clone(), e);
1753
    let Some(r) = db::runners::get(&app.db, id).await.map_err(e)?.filter(|r| r.scope_repo_id == Some(row.id)) else {
1754
        return Err(Error::not_found(shell));
1755
    };
1756
    if db::runners::running(&app.db).await.map_err(e)?.iter().any(|(rid, n)| *rid == id && *n > 0) {
1757
        return back(&row, Section::Runners, None, Some(format!("{} is running jobs: wait for them to finish.", r.name)));
1758
    }
1759
    db::runners::remove(&app.db, id).await.map_err(e)?;
1760
    accounts::audit(&app.db, viewer.account(), "flow.runner.removed", &r.name, &row.slug()).await.ok();
1761
    back(&row, Section::Runners, Some(format!("{} removed: its credential no longer works.", r.name)), None)
1762
}
1763
1764
fn repo_assistants(row: &db::repos::RepoRow) -> crate::levels::At {
1765
    crate::levels::At::repo(row)
1766
}
1767
1768
/// `POST /{owner}/{name}/settings/assistants`: the repository's assistants
1769
/// and prompt (#97), for its admins.
1770
pub async fn assistants(
1771
    State(app): State<Shared>,
1772
    viewer: Viewer,
1773
    Path((owner, name)): Path<(String, String)>,
1774
    Form(f): Form<super::assistants::AssistantForm>,
1775
) -> Result<Response, Error> {
1776
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1777
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
1778
    csrf(&viewer, &f.csrf_token, &shell)?;
1779
    match super::assistants::apply(&app, &viewer, &repo_assistants(&row), f).await.map_err(|e| Error::from_db(shell.clone(), e))? {
1780
        Ok(notice) => back(&row, Section::Assistants, Some(notice), None),
1781
        Err(error) => back(&row, Section::Assistants, None, Some(error)),
1782
    }
1783
}
1784
1785
/// `POST /{owner}/{name}/settings/variables`: the repository's Flow
1786
/// variables (#102), for its admins.
1787
pub async fn variables(
1788
    State(app): State<Shared>,
1789
    viewer: Viewer,
1790
    Path((owner, name)): Path<(String, String)>,
1791
    Form(f): Form<super::variables::VariableForm>,
1792
) -> Result<Response, Error> {
1793
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1794
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
1795
    csrf(&viewer, &f.csrf_token, &shell)?;
1796
    match super::variables::apply(&app, &viewer, &repo_assistants(&row), f).await.map_err(|e| Error::from_db(shell.clone(), e))? {
1797
        Ok(notice) => back(&row, Section::Variables, Some(notice), None),
1798
        Err(error) => back(&row, Section::Variables, None, Some(error)),
1799
    }
1800
}
1801
1802
/// `POST /{owner}/{name}/settings/secrets`: the repository's Flow secrets
1803
/// (#107), for its admins.
1804
pub async fn secrets(
1805
    State(app): State<Shared>,
1806
    viewer: Viewer,
1807
    Path((owner, name)): Path<(String, String)>,
1808
    Form(f): Form<super::secrets::SecretForm>,
1809
) -> Result<Response, Error> {
1810
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1811
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
1812
    csrf(&viewer, &f.csrf_token, &shell)?;
1813
    match super::secrets::apply(&app, &viewer, &repo_assistants(&row), f).await.map_err(|e| Error::from_db(shell.clone(), e))? {
1814
        Ok(notice) => back(&row, Section::Secrets, Some(notice), None),
1815
        Err(error) => back(&row, Section::Secrets, None, Some(error)),
1816
    }
1817
}
1818
1819
/// `POST /{owner}/{name}/settings/assistants/models`: what a provider offers (#109).
1820
pub async fn assistant_models(
1821
    State(app): State<Shared>,
1822
    viewer: Viewer,
1823
    Path((owner, name)): Path<(String, String)>,
1824
    Form(f): Form<super::assistants::ModelsForm>,
1825
) -> Result<axum::Json<serde_json::Value>, Error> {
1826
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1827
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
1828
    csrf(&viewer, &f.csrf_token, &shell)?;
1829
    Ok(axum::Json(super::assistants::models(&app, &repo_assistants(&row), f).await.map_err(|e| Error::from_db(shell.clone(), e))?))
1830
}
1831
1832
/// Whether a runner serves this repository that is not the site's: one
1833
/// scoped to it or to its owner (#225).
1834
async fn has_own_runner(app: &Shared, row: &db::repos::RepoRow) -> sqlx::Result<bool> {
1835
    Ok(db::runners::serving(&app.db, &row.owner, row.id).await?.iter().any(|r| !r.scope().is_site()))
1836
}