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
19bb31ff Andy McCall ↶ 1
//! Creating repositories and their settings pages (hub spec §6.3).
2
3
use super::settings::{csrf, require};
b64e2537 Andy McCall ↶ 4
use super::{filters, lookup_role, Error, Page, RepoShell, Shell};
19bb31ff Andy McCall ↶ 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;
c7067c7b Andy McCall 10
use axum::extract::{Form, Path, Query, State};
11
use axum::http::Uri;
19bb31ff Andy McCall ↶ 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>,
40a43921 Andy McCall ↶ 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,
19bb31ff Andy McCall ↶ 30
}
31
40a43921 Andy McCall ↶ 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
07b1a5bb Andy McCall ↶ 60
pub(crate) async fn owners_for(app: &Shared, me: &accounts::Account) -> Vec<String> {
19bb31ff Andy McCall ↶ 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)?;
40a43921 Andy McCall ↶ 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
    }
19bb31ff Andy McCall ↶ 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,
1f78655a Andy McCall ↶ 113
    /// `hub` (this server's disk, the default) or `bucket` (oxygenhub#96).
114
    #[serde(default)]
115
    storage: String,
116
    #[serde(flatten)]
117
    bucket: BucketForm,
40a43921 Andy McCall ↶ 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,
1f78655a Andy McCall ↶ 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,
19bb31ff Andy McCall ↶ 153
}
154
1f78655a Andy McCall ↶ 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
19bb31ff Andy McCall ↶ 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)?;
40a43921 Andy McCall ↶ 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)),
19bb31ff Andy McCall ↶ 189
    };
40a43921 Andy McCall ↶ 190
    if let Err(m) = crate::repo_setup::check_board(&app, &me.account, &name.owner, &board).await {
191
        return Ok(fail(&m));
66fd9d6b Andy McCall ↶ 192
    }
40a43921 Andy McCall ↶ 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."));
19bb31ff Andy McCall ↶ 203
    }
40a43921 Andy McCall ↶ 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
        }
1f78655a Andy McCall ↶ 208
    }
8ddebaa7 Andy McCall ↶ 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
    }
1f78655a Andy McCall ↶ 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();
40a43921 Andy McCall ↶ 245
        crate::repo_setup::link_board(&app, &row, &board, visibility == Visibility::Public)
246
            .await
247
            .map_err(|e| Error::from_db(shell.clone(), e))?;
1f78655a Andy McCall ↶ 248
        return Ok(Redirect::to(&name.url()).into_response());
19bb31ff Andy McCall ↶ 249
    }
15d3f6f7 Andy McCall ↶ 250
    app.repos.create_async(&name, None).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
40a43921 Andy McCall ↶ 251
    let row = db::repos::create(&app.db, &name.owner, &name.name, f.description.trim(), visibility)
19bb31ff Andy McCall ↶ 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();
40a43921 Andy McCall ↶ 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
    }
19bb31ff Andy McCall ↶ 261
    Ok(Redirect::to(&name.url()).into_response())
40a43921 Andy McCall ↶ 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
    }
19bb31ff Andy McCall ↶ 275
}
276
c7067c7b Andy McCall 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
19bb31ff Andy McCall ↶ 411
#[derive(Template)]
412
#[template(path = "repo_settings.html")]
413
pub struct RepoSettingsPage {
c7067c7b Andy McCall 414
    /// The page shown, and the sidebar's groups of pages.
415
    section: Section,
416
    nav: Vec<(&'static str, Vec<Section>)>,
19bb31ff Andy McCall ↶ 417
    shell: Shell,
418
    repo: RepoShell,
419
    row: db::repos::RepoRow,
420
    collaborators: Vec<accounts::Collaborator>,
421
    teams: Vec<accounts::Team>,
422
    role: Role,
de505df5 Andy McCall ↶ 423
    requirements: db::proposals::Requirements,
9756c67d Andy McCall ↶ 424
    hooks: Vec<super::webhooks::HookRow>,
b64e2537 Andy McCall ↶ 425
    required_checks: String,
9008bf6c Andy McCall ↶ 426
    /// Whether hub.toml alone would run Flow here, and whether it runs.
427
    flow_default: bool,
e7158350 Andy McCall ↶ 428
    /// hub.toml's `[diffusion] follow` (#148).
429
    diffusion_site: String,
9008bf6c Andy McCall ↶ 430
    flow_enabled: bool,
431
    site_admin: bool,
1870e4fa Andy McCall ↶ 432
    /// Whether the repository or its owner has a runner of its own (#225).
433
    own_runner: bool,
19bb31ff Andy McCall ↶ 434
    notice: Option<String>,
435
    error: Option<String>,
7974adb0 Andy McCall ↶ 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>,
e71846aa Andy McCall ↶ 441
    /// The repository's AI assistants and prompt (#97).
442
    assistants: super::assistants::Panel,
2a83f530 Andy McCall ↶ 443
    /// The repository's Flow variables (#102).
444
    variables: super::variables::Panel,
4ac01a73 Andy McCall ↶ 445
    /// The repository's Flow secrets (#107).
446
    secrets: super::secrets::Panel,
1f78655a Andy McCall ↶ 447
    /// Where its data lives, when in a bucket (oxygenhub#96).
448
    storage: Option<crate::storage::Storage>,
7edaff01 Andy McCall ↶ 449
    /// The coverage check's rules (#113).
450
    coverage: crate::coverage::Rules,
d3b048b2 Andy McCall ↶ 451
    /// Its owner's backup, when one is set up (docs/backups.md §5).
452
    backup_owner: Option<crate::backups::Setting>,
0ee5c3fb Andy McCall ↶ 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>,
cb7b3cd4 Andy McCall ↶ 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,
19bb31ff Andy McCall ↶ 463
}
464
d3b048b2 Andy McCall ↶ 465
impl RepoSettingsPage {
466
    fn backup_choices(&self) -> &'static [(&'static str, &'static str)] {
467
        crate::backups::REPO_CHOICES
468
    }
469
}
470
c7067c7b Andy McCall 471
/// One settings page, or not found when the viewer's role does not have it.
19bb31ff Andy McCall ↶ 472
async fn settings_page(
473
    app: &Shared,
474
    shell: Shell,
475
    row: db::repos::RepoRow,
476
    role: Role,
c7067c7b Andy McCall 477
    section: Section,
19bb31ff Andy McCall ↶ 478
    notice: Option<String>,
479
    error: Option<String>,
480
) -> Result<Page<RepoSettingsPage>, Error> {
c7067c7b Andy McCall 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());
19bb31ff Andy McCall ↶ 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
    };
de505df5 Andy McCall ↶ 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))?;
b64e2537 Andy McCall ↶ 493
    let hooks = db::hooks::hooks_for_repo(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
9756c67d Andy McCall ↶ 494
    let hooks = super::webhooks::rows(app, hooks).await.map_err(|e| Error::from_db(shell.clone(), e))?;
b64e2537 Andy McCall ↶ 495
    let required_checks = db::hooks::required_checks(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.join(", ");
9008bf6c Andy McCall ↶ 496
    let flow_default = app.config.flow.allows(&row.owner, &row.name);
e7158350 Andy McCall ↶ 497
    let diffusion_site = app.config.diffusion.follow.clone();
9008bf6c Andy McCall ↶ 498
    let flow_enabled = crate::flow::enabled(app, &row);
499
    let site_admin = shell.is_admin;
1870e4fa Andy McCall ↶ 500
    let own_runner = has_own_runner(app, &row).await.map_err(|e| Error::from_db(shell.clone(), e))?;
7974adb0 Andy McCall ↶ 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();
e71846aa Andy McCall ↶ 517
    let assistants = super::assistants::panel(app, &repo_assistants(&row)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
2a83f530 Andy McCall ↶ 518
    let variables = super::variables::panel(app, &repo_assistants(&row)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
4ac01a73 Andy McCall ↶ 519
    let secrets = super::secrets::panel(app, &repo_assistants(&row)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
7edaff01 Andy McCall ↶ 520
    let coverage = crate::coverage::rules(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
d3b048b2 Andy McCall ↶ 521
    let backup_owner = crate::backups::setting(&app.db, &row.owner).await.map_err(|e| Error::from_db(shell.clone(), e))?;
0ee5c3fb Andy McCall ↶ 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))?;
cb7b3cd4 Andy McCall ↶ 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();
9008bf6c Andy McCall ↶ 528
    Ok(Page(RepoSettingsPage {
c7067c7b Andy McCall 529
        section,
530
        nav,
0ee5c3fb Andy McCall ↶ 531
        push_findings,
532
        secret_bypasses,
cb7b3cd4 Andy McCall ↶ 533
        history_scan,
534
        history_findings,
535
        resolved_findings,
536
        history_scan_on,
d3b048b2 Andy McCall ↶ 537
        backup_owner,
7edaff01 Andy McCall ↶ 538
        coverage,
1f78655a Andy McCall ↶ 539
        storage,
e71846aa Andy McCall ↶ 540
        assistants,
2a83f530 Andy McCall ↶ 541
        variables,
4ac01a73 Andy McCall ↶ 542
        secrets,
7974adb0 Andy McCall ↶ 543
        runners,
544
        runner_token: None,
9008bf6c Andy McCall ↶ 545
        shell,
546
        repo,
547
        row,
548
        collaborators,
549
        teams,
550
        role,
551
        requirements,
552
        hooks,
553
        required_checks,
554
        flow_default,
e7158350 Andy McCall ↶ 555
        diffusion_site,
9008bf6c Andy McCall ↶ 556
        flow_enabled,
557
        site_admin,
1870e4fa Andy McCall ↶ 558
        own_runner,
9008bf6c Andy McCall ↶ 559
        notice,
560
        error,
561
    }))
c7067c7b Andy McCall 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>,
19bb31ff Andy McCall ↶ 571
}
572
c7067c7b Andy McCall 573
/// `GET /{owner}/{name}/settings`: the General page.
19bb31ff Andy McCall ↶ 574
pub async fn settings(
575
    State(app): State<Shared>,
576
    viewer: Viewer,
577
    Path((owner, name)): Path<(String, String)>,
c7067c7b Andy McCall 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,
19bb31ff Andy McCall ↶ 613
) -> Result<Page<RepoSettingsPage>, Error> {
c7067c7b Andy McCall 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
19bb31ff Andy McCall ↶ 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,
1553bfc9 Andy McCall ↶ 632
    #[serde(default)]
633
    topics: String,
19bb31ff Andy McCall ↶ 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>,
c7067c7b Andy McCall 641
) -> Result<Response, Error> {
19bb31ff Andy McCall ↶ 642
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
351cf57e Andy McCall ↶ 643
    let (repo, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
19bb31ff Andy McCall ↶ 644
    csrf(&viewer, &f.csrf_token, &shell)?;
645
    let e = |e| Error::from_db(shell.clone(), e);
351cf57e Andy McCall ↶ 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
    }
1553bfc9 Andy McCall ↶ 664
    let topics = match db::repos::parse_topics(&f.topics) {
665
        Ok(t) => t,
c7067c7b Andy McCall 666
        Err(msg) => return back(&row, Section::General, None, Some(msg)),
1553bfc9 Andy McCall ↶ 667
    };
19bb31ff Andy McCall ↶ 668
    db::repos::set_description(&app.db, row.id, f.description.trim()).await.map_err(e)?;
1553bfc9 Andy McCall ↶ 669
    db::repos::set_topics(&app.db, row.id, &topics).await.map_err(e)?;
19bb31ff Andy McCall ↶ 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);
c7067c7b Andy McCall 680
    back(&row, Section::General, Some("Saved.".into()), None)
19bb31ff Andy McCall ↶ 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>,
c7067c7b Andy McCall 702
) -> Result<Response, Error> {
19bb31ff Andy McCall ↶ 703
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
c7067c7b Andy McCall 704
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
19bb31ff Andy McCall ↶ 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),
c7067c7b Andy McCall 710
            None => return back(&row, Section::Collaborators, None, Some("No such account.".into())),
19bb31ff Andy McCall ↶ 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)),
c7067c7b Andy McCall 720
            None => return back(&row, Section::Collaborators, None, Some("No such team in this organisation.".into())),
19bb31ff Andy McCall ↶ 721
        }
722
    } else {
c7067c7b Andy McCall 723
        return back(&row, Section::Collaborators, None, Some("Name an account or a team.".into()));
19bb31ff Andy McCall ↶ 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();
c7067c7b Andy McCall 728
        return back(&row, Section::Collaborators, Some(format!("Removed {label}.")), None);
19bb31ff Andy McCall ↶ 729
    }
730
    let Some(new_role) = Role::parse(&f.role) else {
c7067c7b Andy McCall 731
        return back(&row, Section::Collaborators, None, Some("Pick a role.".into()));
19bb31ff Andy McCall ↶ 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();
c7067c7b Andy McCall 743
    back(&row, Section::Collaborators, Some(format!("{label} now has {} access.", new_role.as_str())), None)
19bb31ff Andy McCall ↶ 744
}
745
746
#[derive(Deserialize)]
d0fbea2e Andy McCall ↶ 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);
c7067c7b Andy McCall 764
    let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
d0fbea2e Andy McCall ↶ 765
    csrf(&viewer, &f.csrf_token, &shell)?;
766
    let me = viewer.account().cloned().ok_or_else(|| Error::not_found(shell.clone()))?;
c7067c7b Andy McCall 767
    let fail = |msg: &str| back(&row, Section::Advanced, None, Some(msg.into()));
d0fbea2e Andy McCall ↶ 768
    if f.confirm.trim() != repo.to_string() {
c7067c7b Andy McCall 769
        return fail("Type the repository's full name to confirm.");
d0fbea2e Andy McCall ↶ 770
    }
771
    let to = f.owner.trim().trim_start_matches('@').to_string();
772
    let Some(dest) = RepoName::new(&to, &row.name) else {
c7067c7b Andy McCall 773
        return fail("That is not an owner name.");
d0fbea2e Andy McCall ↶ 774
    };
775
    if dest.owner == row.owner {
c7067c7b Andy McCall 776
        return fail("The repository is already there.");
d0fbea2e Andy McCall ↶ 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 {
c7067c7b Andy McCall 782
        return fail("There is no user or organisation of that name.");
d0fbea2e Andy McCall ↶ 783
    }
784
    if !roles::may_create_under(&app.db, &me, &dest.owner).await.map_err(e)? {
c7067c7b Andy McCall 785
        return fail("You cannot create repositories under that owner, so you cannot move one there.");
d0fbea2e Andy McCall ↶ 786
    }
787
    if db::repos::get(&app.db, &dest.owner, &dest.name).await.map_err(e)?.is_some() || app.repos.exists(&dest) {
c7067c7b Andy McCall 788
        return fail("That owner already has a repository of this name.");
d0fbea2e Andy McCall ↶ 789
    }
790
    {
7c06f5b2 Andy McCall ↶ 791
        let _guard = app.repos.exclusive_guard(&repo).await;
d0fbea2e Andy McCall ↶ 792
        app.repos.rename(&repo, &dest).map_err(|e| Error::from_repo(shell.clone(), e))?;
1f78655a Andy McCall ↶ 793
        crate::storage::renamed(&app, &repo, &dest);
66fd9d6b Andy McCall ↶ 794
        if app.wikis.exists(&repo) {
795
            app.wikis.rename(&repo, &dest).ok();
796
        }
d0fbea2e Andy McCall ↶ 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."));
c7067c7b Andy McCall 809
    Ok(Redirect::to(&format!("{}/settings/{}?notice={notice}", dest.url(), Section::Advanced.slug())).into_response())
d0fbea2e Andy McCall ↶ 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>,
c7067c7b Andy McCall 829
) -> Result<Response, Error> {
d0fbea2e Andy McCall ↶ 830
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
c7067c7b Andy McCall 831
    let (repo, mut row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
d0fbea2e Andy McCall ↶ 832
    csrf(&viewer, &f.csrf_token, &shell)?;
833
    let archive = match f.action.as_str() {
834
        "archive" => true,
835
        "unarchive" => false,
c7067c7b Andy McCall 836
        _ => return back(&row, Section::Advanced, None, Some("Unknown action.".into())),
d0fbea2e Andy McCall ↶ 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
    };
c7067c7b Andy McCall 850
    back(&row, Section::Advanced, Some(notice.into()), None)
66fd9d6b Andy McCall ↶ 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>,
c7067c7b Andy McCall 859
) -> Result<Response, Error> {
66fd9d6b Andy McCall ↶ 860
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
c7067c7b Andy McCall 861
    let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
66fd9d6b Andy McCall ↶ 862
    csrf(&viewer, &f.csrf_token, &shell)?;
863
    if f.confirm.trim() != repo.to_string() {
c7067c7b Andy McCall 864
        return back(&row, Section::Advanced, None, Some("Type the repository's full name to confirm.".into()));
66fd9d6b Andy McCall ↶ 865
    }
866
    if !crate::wiki::exists(&app, &repo) {
c7067c7b Andy McCall 867
        return back(&row, Section::Advanced, None, Some("This repository has no wiki.".into()));
66fd9d6b Andy McCall ↶ 868
    }
7c06f5b2 Andy McCall ↶ 869
    let _guard = app.wikis.exclusive_guard(&repo).await;
66fd9d6b Andy McCall ↶ 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();
c7067c7b Andy McCall 873
    back(&row, Section::Advanced, Some("The wiki is in the trash; a new first page starts a fresh one.".into()), None)
d0fbea2e Andy McCall ↶ 874
}
875
876
#[derive(Deserialize)]
19bb31ff Andy McCall ↶ 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);
c7067c7b Andy McCall 890
    let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
19bb31ff Andy McCall ↶ 891
    csrf(&viewer, &f.csrf_token, &shell)?;
892
    if f.confirm.trim() != repo.to_string() {
c7067c7b Andy McCall 893
        return back(&row, Section::Advanced, None, Some("Type the repository's full name to delete it.".into()));
19bb31ff Andy McCall ↶ 894
    }
7c06f5b2 Andy McCall ↶ 895
    let _guard = app.repos.exclusive_guard(&repo).await;
19bb31ff Andy McCall ↶ 896
    app.repos.trash(&repo).map_err(|e| Error::from_repo(shell.clone(), e))?;
1f78655a Andy McCall ↶ 897
    // A repository kept in a bucket leaves its data there: the owner's.
898
    crate::storage::removed(&app, &repo);
66fd9d6b Andy McCall ↶ 899
    crate::wiki::trash(&app, &repo).ok();
19bb31ff Andy McCall ↶ 900
    db::repos::delete(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
2a83f530 Andy McCall ↶ 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();
4ac01a73 Andy McCall ↶ 903
    crate::secrets::delete_scope(&app.db, crate::levels::Scope::Repo(row.id)).await.ok();
19bb31ff Andy McCall ↶ 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
}
de505df5 Andy McCall ↶ 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>,
c7067c7b Andy McCall 925
) -> Result<Response, Error> {
de505df5 Andy McCall ↶ 926
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
c7067c7b Andy McCall 927
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
de505df5 Andy McCall ↶ 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();
c7067c7b Andy McCall 944
    back(&row, Section::Landing, Some("Landing requirements saved.".into()), None)
de505df5 Andy McCall ↶ 945
}
b64e2537 Andy McCall ↶ 946
947
pub async fn hooks(
948
    State(app): State<Shared>,
949
    viewer: Viewer,
950
    Path((owner, name)): Path<(String, String)>,
8fb35df7 Andy McCall ↶ 951
    Form(f): Form<super::webhooks::HookForm>,
c7067c7b Andy McCall 952
) -> Result<Response, Error> {
b64e2537 Andy McCall ↶ 953
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
c7067c7b Andy McCall 954
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
b64e2537 Andy McCall ↶ 955
    csrf(&viewer, &f.csrf_token, &shell)?;
8fb35df7 Andy McCall ↶ 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))? {
c7067c7b Andy McCall 958
        Ok(notice) => back(&row, Section::Webhooks, Some(notice), None),
959
        Err(error) => back(&row, Section::Webhooks, None, Some(error)),
8fb35df7 Andy McCall ↶ 960
    }
b64e2537 Andy McCall ↶ 961
}
962
963
pub async fn deliveries(
964
    State(app): State<Shared>,
965
    viewer: Viewer,
966
    Path((owner, name, id)): Path<(String, String, i64)>,
8fb35df7 Andy McCall ↶ 967
) -> Result<Page<super::webhooks::DeliveriesPage>, Error> {
b64e2537 Andy McCall ↶ 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?;
8fb35df7 Andy McCall ↶ 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
b64e2537 Andy McCall ↶ 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>,
8fb35df7 Andy McCall ↶ 979
) -> Result<Page<super::webhooks::DeliveriesPage>, Error> {
b64e2537 Andy McCall ↶ 980
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings/hooks/{id}/deliveries"), &viewer);
8fb35df7 Andy McCall ↶ 981
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
b64e2537 Andy McCall ↶ 982
    csrf(&viewer, &f.csrf, &shell)?;
8fb35df7 Andy McCall ↶ 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
b64e2537 Andy McCall ↶ 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>,
c7067c7b Andy McCall 1001
) -> Result<Response, Error> {
b64e2537 Andy McCall ↶ 1002
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
c7067c7b Andy McCall 1003
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
b64e2537 Andy McCall ↶ 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))?;
c7067c7b Andy McCall 1007
    back(&row, Section::Checks, Some("Required checks saved.".into()), None)
1f78655a Andy McCall ↶ 1008
}
1009
1010
#[derive(Deserialize)]
7edaff01 Andy McCall ↶ 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>,
c7067c7b Andy McCall 1026
) -> Result<Response, Error> {
7edaff01 Andy McCall ↶ 1027
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
c7067c7b Andy McCall 1028
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
7edaff01 Andy McCall ↶ 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 },
c7067c7b Andy McCall 1042
        (Err(e), _) | (_, Err(e)) => return back(&row, Section::Coverage, None, Some(e)),
7edaff01 Andy McCall ↶ 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();
c7067c7b Andy McCall 1054
    back(&row, Section::Coverage, Some("Coverage rules saved; they apply from the next report.".into()), None)
7edaff01 Andy McCall ↶ 1055
}
1056
1057
#[derive(Deserialize)]
1f78655a Andy McCall ↶ 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>,
c7067c7b Andy McCall 1074
) -> Result<Response, Error> {
1f78655a Andy McCall ↶ 1075
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
c7067c7b Andy McCall 1076
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
1f78655a Andy McCall ↶ 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 {
c7067c7b Andy McCall 1079
        return back(&row, Section::General, None, Some("This repository is kept on this server's disk.".into()));
1f78655a Andy McCall ↶ 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();
c7067c7b Andy McCall 1092
            back(&row, Section::Storage, Some(notice), None)
1f78655a Andy McCall ↶ 1093
        }
c7067c7b Andy McCall 1094
        Err(e) => back(&row, Section::Storage, None, Some(format!("Storage: {e}"))),
1f78655a Andy McCall ↶ 1095
    }
b64e2537 Andy McCall ↶ 1096
}
3f4b0ea6 Andy McCall ↶ 1097
1098
#[derive(Deserialize)]
1099
pub struct MirrorForm {
1100
    #[serde(rename = "_csrf")]
1101
    csrf_token: String,
1102
    #[serde(default)]
1103
    enabled: Option<String>,
d3b048b2 Andy McCall ↶ 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>,
c7067c7b Andy McCall 1122
) -> Result<Response, Error> {
d3b048b2 Andy McCall ↶ 1123
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
c7067c7b Andy McCall 1124
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
d3b048b2 Andy McCall ↶ 1125
    csrf(&viewer, &f.csrf_token, &shell)?;
1126
    if !crate::backups::REPO_CHOICES.iter().any(|(v, _)| *v == f.backup) {
c7067c7b Andy McCall 1127
        return back(&row, Section::Backup, None, Some("Pick how this repository is backed up.".into()));
d3b048b2 Andy McCall ↶ 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
    };
c7067c7b Andy McCall 1140
    back(&row, Section::Backup, Some(notice), None)
9008bf6c Andy McCall ↶ 1141
}
1142
1143
#[derive(Deserialize)]
e7158350 Andy McCall ↶ 1144
pub struct DiffusionForm {
1145
    #[serde(rename = "_csrf")]
1146
    csrf_token: String,
1147
    /// `off`, `green`, `any`, or `default`.
1148
    diffusion: String,
f4d6ef41 Andy McCall ↶ 1149
    /// `auto` or `propose` (absent keeps it as it is).
1150
    #[serde(default)]
1151
    land: String,
e7158350 Andy McCall ↶ 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>,
c7067c7b Andy McCall 1161
) -> Result<Response, Error> {
e7158350 Andy McCall ↶ 1162
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
c7067c7b Andy McCall 1163
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
e7158350 Andy McCall ↶ 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" => "",
c7067c7b Andy McCall 1168
        _ => return back(&row, Section::Diffusion, None, Some("Pick off, green, any or the site default.".into())),
f4d6ef41 Andy McCall ↶ 1169
    };
1170
    let land = match f.land.as_str() {
1171
        "auto" | "propose" => Some(f.land.as_str()),
1172
        "" => None,
c7067c7b Andy McCall 1173
        _ => return back(&row, Section::Diffusion, None, Some("Pick propose or auto for landing.".into())),
e7158350 Andy McCall ↶ 1174
    };
1175
    db::repos::set_diffusion(&app.db, row.id, setting).await.map_err(|e| Error::from_db(shell.clone(), e))?;
f4d6ef41 Andy McCall ↶ 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();
e7158350 Andy McCall ↶ 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);
f4d6ef41 Andy McCall ↶ 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
    }
3776b70d Andy McCall ↶ 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
    }
e7158350 Andy McCall ↶ 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
    };
c7067c7b Andy McCall 1199
    back(&row, Section::Diffusion, Some(notice.into()), None)
e7158350 Andy McCall ↶ 1200
}
1201
1202
#[derive(Deserialize)]
4a68f106 Andy McCall ↶ 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>,
c7067c7b Andy McCall 1219
) -> Result<Response, Error> {
4a68f106 Andy McCall ↶ 1220
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
c7067c7b Andy McCall 1221
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
4a68f106 Andy McCall ↶ 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
    };
c7067c7b Andy McCall 1234
    back(&row, Section::Gates, Some(notice.into()), None)
4a68f106 Andy McCall ↶ 1235
}
1236
1237
#[derive(Deserialize)]
0ee5c3fb Andy McCall ↶ 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>,
c7067c7b Andy McCall 1254
) -> Result<Response, Error> {
0ee5c3fb Andy McCall ↶ 1255
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
c7067c7b Andy McCall 1256
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
0ee5c3fb Andy McCall ↶ 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();
cb7b3cd4 Andy McCall ↶ 1263
    if on {
1264
        // What was pushed while it was off is scanned now (#277).
1265
        crate::history_scan::wake(&app, row.id);
1266
    }
0ee5c3fb Andy McCall ↶ 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
    };
c7067c7b Andy McCall 1273
    back(&row, Section::PushProtection, Some(notice.into()), None)
0ee5c3fb Andy McCall ↶ 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>,
c7067c7b Andy McCall 1293
) -> Result<Response, Error> {
0ee5c3fb Andy McCall ↶ 1294
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
c7067c7b Andy McCall 1295
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
0ee5c3fb Andy McCall ↶ 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 {
c7067c7b Andy McCall 1309
        return back(&row, Section::PushProtection, None, Some(e.into()));
0ee5c3fb Andy McCall ↶ 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.");
c7067c7b Andy McCall 1320
    back(&row, Section::PushProtection, Some(notice), None)
cb7b3cd4 Andy McCall ↶ 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>,
c7067c7b Andy McCall 1338
) -> Result<Response, Error> {
cb7b3cd4 Andy McCall ↶ 1339
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
c7067c7b Andy McCall 1340
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
cb7b3cd4 Andy McCall ↶ 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 {
c7067c7b Andy McCall 1345
        return back(&row, Section::PushProtection, None, Some(format!("Fingerprint {fingerprint} is not allowed here.")));
cb7b3cd4 Andy McCall ↶ 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.");
c7067c7b Andy McCall 1354
    back(&row, Section::PushProtection, Some(notice), None)
cb7b3cd4 Andy McCall ↶ 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>,
c7067c7b Andy McCall 1374
) -> Result<Response, Error> {
cb7b3cd4 Andy McCall ↶ 1375
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
c7067c7b Andy McCall 1376
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
cb7b3cd4 Andy McCall ↶ 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 {
c7067c7b Andy McCall 1387
        return back(&row, Section::PushProtection, None, Some(e.into()));
cb7b3cd4 Andy McCall ↶ 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 {
c7067c7b Andy McCall 1392
        return back(&row, Section::PushProtection, None, Some("That finding is not open here.".into()));
cb7b3cd4 Andy McCall ↶ 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);
c7067c7b Andy McCall 1397
    back(&row, Section::PushProtection, Some(notice), None)
cb7b3cd4 Andy McCall ↶ 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>,
c7067c7b Andy McCall 1414
) -> Result<Response, Error> {
cb7b3cd4 Andy McCall ↶ 1415
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
c7067c7b Andy McCall 1416
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
cb7b3cd4 Andy McCall ↶ 1417
    csrf(&viewer, &f.csrf_token, &shell)?;
1418
    if !row.push_protection {
c7067c7b Andy McCall 1419
        return back(
1420
            &row,
1421
            Section::PushProtection,
cb7b3cd4 Andy McCall ↶ 1422
            None,
1423
            Some("Turn push protection on first: history is scanned only where it is on.".into()),
c7067c7b Andy McCall 1424
        );
cb7b3cd4 Andy McCall ↶ 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.";
c7067c7b Andy McCall 1429
    back(&row, Section::PushProtection, Some(notice.into()), None)
0ee5c3fb Andy McCall ↶ 1430
}
1431
1432
#[derive(Deserialize)]
a9f1c0bf Andy McCall ↶ 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>,
c7067c7b Andy McCall 1450
) -> Result<Response, Error> {
a9f1c0bf Andy McCall ↶ 1451
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
c7067c7b Andy McCall 1452
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
a9f1c0bf Andy McCall ↶ 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
    };
c7067c7b Andy McCall 1465
    back(&row, Section::Runs, Some(notice.into()), None)
a9f1c0bf Andy McCall ↶ 1466
}
1467
1468
#[derive(Deserialize)]
5588bb36 Andy McCall ↶ 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)]
367f34b8 Andy McCall ↶ 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)]
107eaba5 Andy McCall ↶ 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>,
c7067c7b Andy McCall 1547
) -> Result<Response, Error> {
107eaba5 Andy McCall ↶ 1548
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
c7067c7b Andy McCall 1549
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
107eaba5 Andy McCall ↶ 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" => "",
c7067c7b Andy McCall 1554
        _ => return back(&row, Section::Runs, None, Some("Pick low, normal, high or the default.".into())),
107eaba5 Andy McCall ↶ 1555
    };
1556
    if level == "high" && !viewer.is_admin() {
c7067c7b Andy McCall 1557
        return back(&row, Section::Runs, None, Some("Only site administrators raise a repository to high.".into()));
107eaba5 Andy McCall ↶ 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
    };
c7067c7b Andy McCall 1575
    back(&row, Section::Runs, Some(notice), None)
107eaba5 Andy McCall ↶ 1576
}
1577
1578
#[derive(Deserialize)]
9008bf6c Andy McCall ↶ 1579
pub struct FlowForm {
1580
    #[serde(rename = "_csrf")]
1581
    csrf_token: String,
1582
    /// `on`, `off`, or `default`.
1583
    flow: String,
1584
}
1585
1870e4fa Andy McCall ↶ 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.
9008bf6c Andy McCall ↶ 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>,
c7067c7b Andy McCall 1595
) -> Result<Response, Error> {
9008bf6c Andy McCall ↶ 1596
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
c7067c7b Andy McCall 1597
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
9008bf6c Andy McCall ↶ 1598
    csrf(&viewer, &f.csrf_token, &shell)?;
1599
    let setting = match f.flow.as_str() {
1600
        "on" => "on",
1870e4fa Andy McCall ↶ 1601
        "own" => "own",
9008bf6c Andy McCall ↶ 1602
        "off" => "off",
1603
        "default" => "",
c7067c7b Andy McCall 1604
        _ => return back(&row, Section::Pipelines, None, Some("Pick on, own runners, off or the site default.".into())),
9008bf6c Andy McCall ↶ 1605
    };
1870e4fa Andy McCall ↶ 1606
    if !viewer.is_admin() {
1607
        if matches!(setting, "on" | "") {
c7067c7b Andy McCall 1608
            return back(
1609
                &row,
1610
                Section::Pipelines,
1870e4fa Andy McCall ↶ 1611
                None,
1612
                Some("Only site administrators let the site's runners take this repository's jobs.".into()),
c7067c7b Andy McCall 1613
            );
1870e4fa Andy McCall ↶ 1614
        }
1615
        if setting == "own" && !has_own_runner(&app, &row).await.map_err(|e| Error::from_db(shell.clone(), e))? {
c7067c7b Andy McCall 1616
            return back(
1617
                &row,
1618
                Section::Pipelines,
1870e4fa Andy McCall ↶ 1619
                None,
c7067c7b Andy McCall 1620
                Some("Register a runner for this repository (Settings › Runners) or its owner first.".into()),
1621
            );
1870e4fa Andy McCall ↶ 1622
        }
1623
    }
9008bf6c Andy McCall ↶ 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);
1870e4fa Andy McCall ↶ 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) {
9008bf6c Andy McCall ↶ 1631
        "OxygenFlow is on: pushes with a .flow/pipeline.kdl start runs."
1632
    } else {
1633
        "OxygenFlow is off for this repository."
1634
    };
c7067c7b Andy McCall 1635
    back(&row, Section::Pipelines, Some(notice.into()), None)
3f4b0ea6 Andy McCall ↶ 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>,
c7067c7b Andy McCall 1643
) -> Result<Response, Error> {
3f4b0ea6 Andy McCall ↶ 1644
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
c7067c7b Andy McCall 1645
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
3f4b0ea6 Andy McCall ↶ 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 {
dd327d76 Andy McCall ↶ 1650
        crate::mirror::refresh(&app, &row).await.ok();
3f4b0ea6 Andy McCall ↶ 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." };
c7067c7b Andy McCall 1657
    back(&row, Section::GitMirror, Some(notice.into()), None)
7974adb0 Andy McCall ↶ 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();
c7067c7b Andy McCall 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?;
7974adb0 Andy McCall ↶ 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>,
c7067c7b Andy McCall 1691
) -> Result<Response, Error> {
7974adb0 Andy McCall ↶ 1692
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
c7067c7b Andy McCall 1693
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
7974adb0 Andy McCall ↶ 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) {
c7067c7b Andy McCall 1700
        return back(&row, Section::Runners, None, Some(format!("{} is running jobs: wait for them to finish.", r.name)));
7974adb0 Andy McCall ↶ 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();
c7067c7b Andy McCall 1704
    back(&row, Section::Runners, Some(format!("{} removed: its credential no longer works.", r.name)), None)
3f4b0ea6 Andy McCall ↶ 1705
}
e71846aa Andy McCall ↶ 1706
2a83f530 Andy McCall ↶ 1707
fn repo_assistants(row: &db::repos::RepoRow) -> crate::levels::At {
1708
    crate::levels::At::repo(row)
e71846aa Andy McCall ↶ 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>,
c7067c7b Andy McCall 1718
) -> Result<Response, Error> {
e71846aa Andy McCall ↶ 1719
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
c7067c7b Andy McCall 1720
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
e71846aa Andy McCall ↶ 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))? {
c7067c7b Andy McCall 1723
        Ok(notice) => back(&row, Section::Assistants, Some(notice), None),
1724
        Err(error) => back(&row, Section::Assistants, None, Some(error)),
2a83f530 Andy McCall ↶ 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>,
c7067c7b Andy McCall 1735
) -> Result<Response, Error> {
2a83f530 Andy McCall ↶ 1736
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
c7067c7b Andy McCall 1737
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
2a83f530 Andy McCall ↶ 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))? {
c7067c7b Andy McCall 1740
        Ok(notice) => back(&row, Section::Variables, Some(notice), None),
1741
        Err(error) => back(&row, Section::Variables, None, Some(error)),
e71846aa Andy McCall ↶ 1742
    }
1743
}
4ac01a73 Andy McCall ↶ 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>,
c7067c7b Andy McCall 1752
) -> Result<Response, Error> {
4ac01a73 Andy McCall ↶ 1753
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
c7067c7b Andy McCall 1754
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
4ac01a73 Andy McCall ↶ 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))? {
c7067c7b Andy McCall 1757
        Ok(notice) => back(&row, Section::Secrets, Some(notice), None),
1758
        Err(error) => back(&row, Section::Secrets, None, Some(error)),
4ac01a73 Andy McCall ↶ 1759
    }
1760
}
155cab14 Andy McCall ↶ 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))?))
1870e4fa Andy McCall ↶ 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()))
155cab14 Andy McCall ↶ 1779
}