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