OXYGENHUB
◆ v0.9.22 oxygenhub / crates / hub / src / web / repo_settings.rs
70.9 KiB · Rust · last changed A default bookmark must exist: settings refuse a missing one, the overview stands in main with a note, an unknown name is not a bookmark, tag or change; Bookmarks › New bookmark makes one on the web (closes #345) 9 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
    }
15d3f6f7 Andy McCall ↶ 153
    app.repos.create_async(&name, None).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
19bb31ff Andy McCall ↶ 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);
351cf57e Andy McCall ↶ 527
    let (repo, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
19bb31ff Andy McCall ↶ 528
    csrf(&viewer, &f.csrf_token, &shell)?;
529
    let e = |e| Error::from_db(shell.clone(), e);
351cf57e Andy McCall ↶ 530
    // A new default must be a bookmark the repository has, or every page
531
    // that opens it finds nothing; an empty repository may name any, for
532
    // its first push to create.
533
    let wanted = f.default_bookmark.trim().to_string();
534
    if wanted != row.default_bookmark {
535
        let names: Vec<String> = app
536
            .repos
537
            .read(&repo, |r, _| Ok(r.view()?.bookmarks.keys().cloned().collect::<Vec<String>>()))
538
            .await
539
            .map_err(|err| Error::from_repo(shell.clone(), err))?;
540
        if !names.is_empty() && !names.contains(&wanted) {
541
            let mut some: Vec<&str> = names.iter().map(String::as_str).filter(|n| *n == "main" || *n == "master").collect();
542
            some.extend(names.iter().map(String::as_str).filter(|n| *n != "main" && *n != "master").take(8 - some.len()));
543
            let more = if names.len() > some.len() { format!(" and {} more", names.len() - some.len()) } else { String::new() };
544
            let msg = format!("{wanted} is not a bookmark here yet: push it first, or choose one of {}{more}.", some.join(", "));
545
            return back(&row, Section::General, None, Some(msg));
546
        }
547
    }
1553bfc9 Andy McCall ↶ 548
    let topics = match db::repos::parse_topics(&f.topics) {
549
        Ok(t) => t,
c7067c7b Andy McCall 550
        Err(msg) => return back(&row, Section::General, None, Some(msg)),
1553bfc9 Andy McCall ↶ 551
    };
19bb31ff Andy McCall ↶ 552
    db::repos::set_description(&app.db, row.id, f.description.trim()).await.map_err(e)?;
1553bfc9 Andy McCall ↶ 553
    db::repos::set_topics(&app.db, row.id, &topics).await.map_err(e)?;
19bb31ff Andy McCall ↶ 554
    if crate::repos::names::valid_segment(f.default_bookmark.trim()) {
555
        db::repos::set_default_bookmark(&app.db, row.id, f.default_bookmark.trim()).await.map_err(e)?;
556
    }
557
    if role >= Role::Admin {
558
        if let Some(v) = Visibility::parse(&f.visibility) {
559
            db::repos::set_visibility(&app.db, row.id, v).await.map_err(e)?;
560
        }
561
    }
562
    accounts::audit(&app.db, viewer.account(), "repo.updated", &format!("{owner}/{name}"), "").await.ok();
563
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(e)?.unwrap_or(row);
c7067c7b Andy McCall 564
    back(&row, Section::General, Some("Saved.".into()), None)
19bb31ff Andy McCall ↶ 565
}
566
567
#[derive(Deserialize)]
568
pub struct CollaboratorForm {
569
    #[serde(rename = "_csrf")]
570
    csrf_token: String,
571
    #[serde(default)]
572
    username: String,
573
    #[serde(default)]
574
    team: String,
575
    #[serde(default)]
576
    role: String,
577
    #[serde(default)]
578
    remove: Option<String>,
579
}
580
581
pub async fn collaborator(
582
    State(app): State<Shared>,
583
    viewer: Viewer,
584
    Path((owner, name)): Path<(String, String)>,
585
    Form(f): Form<CollaboratorForm>,
c7067c7b Andy McCall 586
) -> Result<Response, Error> {
19bb31ff Andy McCall ↶ 587
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
c7067c7b Andy McCall 588
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
19bb31ff Andy McCall ↶ 589
    csrf(&viewer, &f.csrf_token, &shell)?;
590
    let e = |e| Error::from_db(shell.clone(), e);
591
    let (account_id, team_id, label) = if !f.username.trim().is_empty() {
592
        match accounts::account_by_name(&app.db, f.username.trim()).await.map_err(e)? {
593
            Some(a) => (Some(a.id), None, a.username),
c7067c7b Andy McCall 594
            None => return back(&row, Section::Collaborators, None, Some("No such account.".into())),
19bb31ff Andy McCall ↶ 595
        }
596
    } else if !f.team.trim().is_empty() {
597
        let org = accounts::org_by_name(&app.db, &row.owner).await.map_err(e)?;
598
        let team = match org {
599
            Some(o) => accounts::team_by_name(&app.db, o.id, f.team.trim()).await.map_err(e)?,
600
            None => None,
601
        };
602
        match team {
603
            Some(t) => (None, Some(t.id), format!("team {}", t.name)),
c7067c7b Andy McCall 604
            None => return back(&row, Section::Collaborators, None, Some("No such team in this organisation.".into())),
19bb31ff Andy McCall ↶ 605
        }
606
    } else {
c7067c7b Andy McCall 607
        return back(&row, Section::Collaborators, None, Some("Name an account or a team.".into()));
19bb31ff Andy McCall ↶ 608
    };
609
    if f.remove.is_some() {
610
        accounts::remove_collaborator(&app.db, row.id, account_id, team_id).await.map_err(e)?;
611
        accounts::audit(&app.db, viewer.account(), "repo.collaborator.removed", &format!("{owner}/{name}"), &label).await.ok();
c7067c7b Andy McCall 612
        return back(&row, Section::Collaborators, Some(format!("Removed {label}.")), None);
19bb31ff Andy McCall ↶ 613
    }
614
    let Some(new_role) = Role::parse(&f.role) else {
c7067c7b Andy McCall 615
        return back(&row, Section::Collaborators, None, Some("Pick a role.".into()));
19bb31ff Andy McCall ↶ 616
    };
617
    accounts::set_collaborator(&app.db, row.id, account_id, team_id, new_role.as_str()).await.map_err(e)?;
618
    accounts::audit(
619
        &app.db,
620
        viewer.account(),
621
        "repo.collaborator.set",
622
        &format!("{owner}/{name}"),
623
        &format!("{label} {}", new_role.as_str()),
624
    )
625
    .await
626
    .ok();
c7067c7b Andy McCall 627
    back(&row, Section::Collaborators, Some(format!("{label} now has {} access.", new_role.as_str())), None)
19bb31ff Andy McCall ↶ 628
}
629
630
#[derive(Deserialize)]
d0fbea2e Andy McCall ↶ 631
pub struct TransferForm {
632
    #[serde(rename = "_csrf")]
633
    csrf_token: String,
634
    owner: String,
635
    confirm: String,
636
}
637
638
/// `POST /{owner}/{name}/settings/transfer`: move the repository to a
639
/// user or organisation the admin may create repositories under (#108).
640
/// The old URL redirects and oxy remotes keep working.
641
pub async fn transfer(
642
    State(app): State<Shared>,
643
    viewer: Viewer,
644
    Path((owner, name)): Path<(String, String)>,
645
    Form(f): Form<TransferForm>,
646
) -> Result<Response, Error> {
647
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
c7067c7b Andy McCall 648
    let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
d0fbea2e Andy McCall ↶ 649
    csrf(&viewer, &f.csrf_token, &shell)?;
650
    let me = viewer.account().cloned().ok_or_else(|| Error::not_found(shell.clone()))?;
c7067c7b Andy McCall 651
    let fail = |msg: &str| back(&row, Section::Advanced, None, Some(msg.into()));
d0fbea2e Andy McCall ↶ 652
    if f.confirm.trim() != repo.to_string() {
c7067c7b Andy McCall 653
        return fail("Type the repository's full name to confirm.");
d0fbea2e Andy McCall ↶ 654
    }
655
    let to = f.owner.trim().trim_start_matches('@').to_string();
656
    let Some(dest) = RepoName::new(&to, &row.name) else {
c7067c7b Andy McCall 657
        return fail("That is not an owner name.");
d0fbea2e Andy McCall ↶ 658
    };
659
    if dest.owner == row.owner {
c7067c7b Andy McCall 660
        return fail("The repository is already there.");
d0fbea2e Andy McCall ↶ 661
    }
662
    let e = |e| Error::from_db(shell.clone(), e);
663
    let known = accounts::account_by_name(&app.db, &dest.owner).await.map_err(e)?.is_some()
664
        || accounts::org_by_name(&app.db, &dest.owner).await.map_err(e)?.is_some();
665
    if !known {
c7067c7b Andy McCall 666
        return fail("There is no user or organisation of that name.");
d0fbea2e Andy McCall ↶ 667
    }
668
    if !roles::may_create_under(&app.db, &me, &dest.owner).await.map_err(e)? {
c7067c7b Andy McCall 669
        return fail("You cannot create repositories under that owner, so you cannot move one there.");
d0fbea2e Andy McCall ↶ 670
    }
671
    if db::repos::get(&app.db, &dest.owner, &dest.name).await.map_err(e)?.is_some() || app.repos.exists(&dest) {
c7067c7b Andy McCall 672
        return fail("That owner already has a repository of this name.");
d0fbea2e Andy McCall ↶ 673
    }
674
    {
7c06f5b2 Andy McCall ↶ 675
        let _guard = app.repos.exclusive_guard(&repo).await;
d0fbea2e Andy McCall ↶ 676
        app.repos.rename(&repo, &dest).map_err(|e| Error::from_repo(shell.clone(), e))?;
1f78655a Andy McCall ↶ 677
        crate::storage::renamed(&app, &repo, &dest);
66fd9d6b Andy McCall ↶ 678
        if app.wikis.exists(&repo) {
679
            app.wikis.rename(&repo, &dest).ok();
680
        }
d0fbea2e Andy McCall ↶ 681
        db::repos::transfer(&app.db, row.id, &row.owner, &row.name, &dest.owner).await.map_err(e)?;
682
        let mirror = crate::mirror::mirror_dir(&app, &repo);
683
        if mirror.is_dir() {
684
            let to = crate::mirror::mirror_dir(&app, &dest);
685
            if let Some(parent) = to.parent() {
686
                std::fs::create_dir_all(parent).ok();
687
            }
688
            std::fs::rename(&mirror, &to).ok();
689
        }
690
    }
691
    accounts::audit(&app.db, Some(&me), "repo.transferred", &repo.to_string(), &dest.to_string()).await.ok();
692
    let notice = super::settings::urlencode(&format!("Moved from {repo}; the old address redirects here."));
c7067c7b Andy McCall 693
    Ok(Redirect::to(&format!("{}/settings/{}?notice={notice}", dest.url(), Section::Advanced.slug())).into_response())
d0fbea2e Andy McCall ↶ 694
}
695
696
#[derive(Deserialize)]
697
pub struct ArchiveForm {
698
    #[serde(rename = "_csrf")]
699
    csrf_token: String,
700
    /// `archive` or `unarchive`.
701
    action: String,
702
    #[serde(default)]
703
    reason: String,
704
}
705
706
/// `POST /{owner}/{name}/settings/archive`: read-only, with a reason, or
707
/// back again (#108).
708
pub async fn archive(
709
    State(app): State<Shared>,
710
    viewer: Viewer,
711
    Path((owner, name)): Path<(String, String)>,
712
    Form(f): Form<ArchiveForm>,
c7067c7b Andy McCall 713
) -> Result<Response, Error> {
d0fbea2e Andy McCall ↶ 714
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
c7067c7b Andy McCall 715
    let (repo, mut row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
d0fbea2e Andy McCall ↶ 716
    csrf(&viewer, &f.csrf_token, &shell)?;
717
    let archive = match f.action.as_str() {
718
        "archive" => true,
719
        "unarchive" => false,
c7067c7b Andy McCall 720
        _ => return back(&row, Section::Advanced, None, Some("Unknown action.".into())),
d0fbea2e Andy McCall ↶ 721
    };
722
    let reason: String = f.reason.trim().chars().take(500).collect();
723
    db::repos::set_archived(&app.db, row.id, archive, &reason).await.map_err(|e| Error::from_db(shell.clone(), e))?;
724
    accounts::audit(&app.db, viewer.account(), if archive { "repo.archived" } else { "repo.unarchived" }, &repo.to_string(), &reason)
725
        .await
726
        .ok();
727
    row.archived = archive;
728
    row.archived_reason = if archive { reason } else { String::new() };
729
    let notice = if archive {
730
        "The repository is archived: read-only until it is brought back."
731
    } else {
732
        "The repository is back: writes are allowed again."
733
    };
c7067c7b Andy McCall 734
    back(&row, Section::Advanced, Some(notice.into()), None)
66fd9d6b Andy McCall ↶ 735
}
736
737
/// `POST /{owner}/{name}/settings/wiki/delete`: the wiki to the trash (#104).
738
pub async fn wiki_delete(
739
    State(app): State<Shared>,
740
    viewer: Viewer,
741
    Path((owner, name)): Path<(String, String)>,
742
    Form(f): Form<DeleteForm>,
c7067c7b Andy McCall 743
) -> Result<Response, Error> {
66fd9d6b Andy McCall ↶ 744
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
c7067c7b Andy McCall 745
    let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
66fd9d6b Andy McCall ↶ 746
    csrf(&viewer, &f.csrf_token, &shell)?;
747
    if f.confirm.trim() != repo.to_string() {
c7067c7b Andy McCall 748
        return back(&row, Section::Advanced, None, Some("Type the repository's full name to confirm.".into()));
66fd9d6b Andy McCall ↶ 749
    }
750
    if !crate::wiki::exists(&app, &repo) {
c7067c7b Andy McCall 751
        return back(&row, Section::Advanced, None, Some("This repository has no wiki.".into()));
66fd9d6b Andy McCall ↶ 752
    }
7c06f5b2 Andy McCall ↶ 753
    let _guard = app.wikis.exclusive_guard(&repo).await;
66fd9d6b Andy McCall ↶ 754
    crate::wiki::trash(&app, &repo).map_err(|e| Error::from_repo(shell.clone(), e))?;
755
    drop(_guard);
756
    accounts::audit(&app.db, viewer.account(), "wiki.data_deleted", &repo.to_string(), "moved to trash").await.ok();
c7067c7b Andy McCall 757
    back(&row, Section::Advanced, Some("The wiki is in the trash; a new first page starts a fresh one.".into()), None)
d0fbea2e Andy McCall ↶ 758
}
759
760
#[derive(Deserialize)]
19bb31ff Andy McCall ↶ 761
pub struct DeleteForm {
762
    #[serde(rename = "_csrf")]
763
    csrf_token: String,
764
    confirm: String,
765
}
766
767
pub async fn delete(
768
    State(app): State<Shared>,
769
    viewer: Viewer,
770
    Path((owner, name)): Path<(String, String)>,
771
    Form(f): Form<DeleteForm>,
772
) -> Result<Response, Error> {
773
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
c7067c7b Andy McCall 774
    let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
19bb31ff Andy McCall ↶ 775
    csrf(&viewer, &f.csrf_token, &shell)?;
776
    if f.confirm.trim() != repo.to_string() {
c7067c7b Andy McCall 777
        return back(&row, Section::Advanced, None, Some("Type the repository's full name to delete it.".into()));
19bb31ff Andy McCall ↶ 778
    }
7c06f5b2 Andy McCall ↶ 779
    let _guard = app.repos.exclusive_guard(&repo).await;
19bb31ff Andy McCall ↶ 780
    app.repos.trash(&repo).map_err(|e| Error::from_repo(shell.clone(), e))?;
1f78655a Andy McCall ↶ 781
    // A repository kept in a bucket leaves its data there: the owner's.
782
    crate::storage::removed(&app, &repo);
66fd9d6b Andy McCall ↶ 783
    crate::wiki::trash(&app, &repo).ok();
19bb31ff Andy McCall ↶ 784
    db::repos::delete(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
2a83f530 Andy McCall ↶ 785
    crate::assistants::delete_scope(&app.db, crate::levels::Scope::Repo(row.id)).await.ok();
786
    crate::variables::delete_scope(&app.db, crate::levels::Scope::Repo(row.id)).await.ok();
4ac01a73 Andy McCall ↶ 787
    crate::secrets::delete_scope(&app.db, crate::levels::Scope::Repo(row.id)).await.ok();
19bb31ff Andy McCall ↶ 788
    accounts::audit(&app.db, viewer.account(), "repo.deleted", &repo.to_string(), "moved to trash").await.ok();
789
    Ok(Redirect::to(&format!("/{owner}")).into_response())
790
}
de505df5 Andy McCall ↶ 791
792
#[derive(Deserialize)]
793
pub struct RequirementsForm {
794
    #[serde(rename = "_csrf")]
795
    csrf_token: String,
796
    #[serde(default)]
797
    required_approvals: i64,
798
    #[serde(default)]
799
    require_owners: Option<String>,
800
    #[serde(default)]
801
    allow_conflicts: Option<String>,
802
}
803
804
pub async fn requirements(
805
    State(app): State<Shared>,
806
    viewer: Viewer,
807
    Path((owner, name)): Path<(String, String)>,
808
    Form(f): Form<RequirementsForm>,
c7067c7b Andy McCall 809
) -> Result<Response, Error> {
de505df5 Andy McCall ↶ 810
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
c7067c7b Andy McCall 811
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
de505df5 Andy McCall ↶ 812
    csrf(&viewer, &f.csrf_token, &shell)?;
813
    let r = db::proposals::Requirements {
814
        required_approvals: f.required_approvals.max(0),
815
        require_owners: f.require_owners.is_some(),
816
        allow_conflicts: f.allow_conflicts.is_some(),
817
    };
818
    db::proposals::set_requirements(&app.db, row.id, r).await.map_err(|e| Error::from_db(shell.clone(), e))?;
819
    accounts::audit(
820
        &app.db,
821
        viewer.account(),
822
        "repo.requirements",
823
        &format!("{owner}/{name}"),
824
        &format!("{} approvals, owners {}, conflicts {}", r.required_approvals, r.require_owners, r.allow_conflicts),
825
    )
826
    .await
827
    .ok();
c7067c7b Andy McCall 828
    back(&row, Section::Landing, Some("Landing requirements saved.".into()), None)
de505df5 Andy McCall ↶ 829
}
b64e2537 Andy McCall ↶ 830
831
pub async fn hooks(
832
    State(app): State<Shared>,
833
    viewer: Viewer,
834
    Path((owner, name)): Path<(String, String)>,
8fb35df7 Andy McCall ↶ 835
    Form(f): Form<super::webhooks::HookForm>,
c7067c7b Andy McCall 836
) -> Result<Response, Error> {
b64e2537 Andy McCall ↶ 837
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
c7067c7b Andy McCall 838
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
b64e2537 Andy McCall ↶ 839
    csrf(&viewer, &f.csrf_token, &shell)?;
8fb35df7 Andy McCall ↶ 840
    let scope = super::webhooks::Scope::Repo { id: row.id, slug: format!("{owner}/{name}") };
841
    match super::webhooks::apply(&app, &viewer, &scope, f).await.map_err(|e| Error::from_db(shell.clone(), e))? {
c7067c7b Andy McCall 842
        Ok(notice) => back(&row, Section::Webhooks, Some(notice), None),
843
        Err(error) => back(&row, Section::Webhooks, None, Some(error)),
8fb35df7 Andy McCall ↶ 844
    }
b64e2537 Andy McCall ↶ 845
}
846
847
pub async fn deliveries(
848
    State(app): State<Shared>,
849
    viewer: Viewer,
850
    Path((owner, name, id)): Path<(String, String, i64)>,
8fb35df7 Andy McCall ↶ 851
) -> Result<Page<super::webhooks::DeliveriesPage>, Error> {
b64e2537 Andy McCall ↶ 852
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings/hooks/{id}/deliveries"), &viewer);
853
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
8fb35df7 Andy McCall ↶ 854
    let scope = super::webhooks::Scope::Repo { id: row.id, slug: format!("{owner}/{name}") };
855
    super::webhooks::deliveries_page(&app, shell, &scope, Some(RepoShell::new(&app, &row, "settings").with_role(role)), id).await
b64e2537 Andy McCall ↶ 856
}
857
858
pub async fn redeliver(
859
    State(app): State<Shared>,
860
    viewer: Viewer,
861
    Path((owner, name, id, delivery)): Path<(String, String, i64, i64)>,
862
    Form(f): Form<super::session::CsrfForm>,
8fb35df7 Andy McCall ↶ 863
) -> Result<Page<super::webhooks::DeliveriesPage>, Error> {
b64e2537 Andy McCall ↶ 864
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings/hooks/{id}/deliveries"), &viewer);
8fb35df7 Andy McCall ↶ 865
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
b64e2537 Andy McCall ↶ 866
    csrf(&viewer, &f.csrf, &shell)?;
8fb35df7 Andy McCall ↶ 867
    let scope = super::webhooks::Scope::Repo { id: row.id, slug: format!("{owner}/{name}") };
868
    super::webhooks::redeliver(&app, &scope, id, delivery).await.map_err(|e| Error::from_db(shell.clone(), e))?;
869
    super::webhooks::deliveries_page(&app, shell, &scope, Some(RepoShell::new(&app, &row, "settings").with_role(role)), id).await
b64e2537 Andy McCall ↶ 870
}
871
872
#[derive(Deserialize)]
873
pub struct ChecksForm {
874
    #[serde(rename = "_csrf")]
875
    csrf_token: String,
876
    #[serde(default)]
877
    required_checks: String,
878
}
879
880
pub async fn required_checks(
881
    State(app): State<Shared>,
882
    viewer: Viewer,
883
    Path((owner, name)): Path<(String, String)>,
884
    Form(f): Form<ChecksForm>,
c7067c7b Andy McCall 885
) -> Result<Response, Error> {
b64e2537 Andy McCall ↶ 886
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
c7067c7b Andy McCall 887
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
b64e2537 Andy McCall ↶ 888
    csrf(&viewer, &f.csrf_token, &shell)?;
889
    let names: Vec<String> = f.required_checks.split(',').map(str::trim).filter(|s| !s.is_empty()).map(String::from).collect();
890
    db::hooks::set_required_checks(&app.db, row.id, &names).await.map_err(|e| Error::from_db(shell.clone(), e))?;
c7067c7b Andy McCall 891
    back(&row, Section::Checks, Some("Required checks saved.".into()), None)
1f78655a Andy McCall ↶ 892
}
893
894
#[derive(Deserialize)]
7edaff01 Andy McCall ↶ 895
pub struct CoverageForm {
896
    #[serde(rename = "_csrf")]
897
    csrf_token: String,
898
    #[serde(default)]
899
    min: String,
900
    #[serde(default)]
901
    max_drop: String,
902
}
903
904
/// Settings → Coverage (#113): the rules the `coverage` check applies.
905
pub async fn coverage(
906
    State(app): State<Shared>,
907
    viewer: Viewer,
908
    Path((owner, name)): Path<(String, String)>,
909
    Form(f): Form<CoverageForm>,
c7067c7b Andy McCall 910
) -> Result<Response, Error> {
7edaff01 Andy McCall ↶ 911
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
c7067c7b Andy McCall 912
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
7edaff01 Andy McCall ↶ 913
    csrf(&viewer, &f.csrf_token, &shell)?;
914
    let number = |v: &str, what: &str, hi: f64| -> Result<Option<f64>, String> {
915
        let v = v.trim().trim_end_matches('%').trim();
916
        if v.is_empty() {
917
            return Ok(None);
918
        }
919
        match v.parse::<f64>() {
920
            Ok(n) if (0.0..=hi).contains(&n) => Ok(Some(n)),
921
            _ => Err(format!("{what} is a number from 0 to {hi}, or empty for no rule.")),
922
        }
923
    };
924
    let rules = match (number(&f.min, "The minimum", 100.0), number(&f.max_drop, "The largest drop", 100.0)) {
925
        (Ok(min), Ok(max_drop)) => crate::coverage::Rules { min, max_drop },
c7067c7b Andy McCall 926
        (Err(e), _) | (_, Err(e)) => return back(&row, Section::Coverage, None, Some(e)),
7edaff01 Andy McCall ↶ 927
    };
928
    crate::coverage::set_rules(&app.db, row.id, rules).await.map_err(|e| Error::from_db(shell.clone(), e))?;
929
    accounts::audit(
930
        &app.db,
931
        viewer.account(),
932
        "repo.coverage_rules",
933
        &format!("{owner}/{name}"),
934
        &format!("min {:?}, max drop {:?}", rules.min, rules.max_drop),
935
    )
936
    .await
937
    .ok();
c7067c7b Andy McCall 938
    back(&row, Section::Coverage, Some("Coverage rules saved; they apply from the next report.".into()), None)
7edaff01 Andy McCall ↶ 939
}
940
941
#[derive(Deserialize)]
1f78655a Andy McCall ↶ 942
pub struct StorageForm {
943
    #[serde(rename = "_csrf")]
944
    csrf_token: String,
945
    /// `check`, or `credentials` (with the new ones).
946
    action: String,
947
    #[serde(flatten)]
948
    bucket: BucketForm,
949
}
950
951
/// Settings → Storage (oxygenhub#96): check the bucket again, or replace
952
/// its credentials once the new ones pass the check.
953
pub async fn storage(
954
    State(app): State<Shared>,
955
    viewer: Viewer,
956
    Path((owner, name)): Path<(String, String)>,
957
    Form(f): Form<StorageForm>,
c7067c7b Andy McCall 958
) -> Result<Response, Error> {
1f78655a Andy McCall ↶ 959
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
c7067c7b Andy McCall 960
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
1f78655a Andy McCall ↶ 961
    csrf(&viewer, &f.csrf_token, &shell)?;
962
    let Some(s) = crate::storage::of(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))? else {
c7067c7b Andy McCall 963
        return back(&row, Section::General, None, Some("This repository is kept on this server's disk.".into()));
1f78655a Andy McCall ↶ 964
    };
965
    let scheme = s.url.split("://").next().unwrap_or("").to_string();
966
    let outcome = match f.action.as_str() {
967
        "credentials" => match f.bucket.credentials(&scheme) {
968
            Ok(c) => crate::storage::set_credentials(&app, &s, c).await.map(|r| format!("New credentials saved: {r}.")),
969
            Err(e) => Err(format!("Credentials: {e}")),
970
        },
971
        _ => crate::storage::check(&app, &s).await.map(|r| format!("The bucket passed the check: {r}.")),
972
    };
973
    match outcome {
974
        Ok(notice) => {
975
            accounts::audit(&app.db, viewer.account(), "repo.storage", &format!("{owner}/{name}"), &f.action).await.ok();
c7067c7b Andy McCall 976
            back(&row, Section::Storage, Some(notice), None)
1f78655a Andy McCall ↶ 977
        }
c7067c7b Andy McCall 978
        Err(e) => back(&row, Section::Storage, None, Some(format!("Storage: {e}"))),
1f78655a Andy McCall ↶ 979
    }
b64e2537 Andy McCall ↶ 980
}
3f4b0ea6 Andy McCall ↶ 981
982
#[derive(Deserialize)]
983
pub struct MirrorForm {
984
    #[serde(rename = "_csrf")]
985
    csrf_token: String,
986
    #[serde(default)]
987
    enabled: Option<String>,
d3b048b2 Andy McCall ↶ 988
}
989
990
#[derive(Deserialize)]
991
pub struct BackupForm {
992
    #[serde(rename = "_csrf")]
993
    csrf_token: String,
994
    /// Empty (with its owner's), `hourly`, `daily` or `off`.
995
    #[serde(default)]
996
    backup: String,
997
}
998
999
/// This repository's backup (docs/backups.md §5): with its owner's, left
1000
/// out, or at least hourly or daily. Its owners and admins decide.
1001
pub async fn backup(
1002
    State(app): State<Shared>,
1003
    viewer: Viewer,
1004
    Path((owner, name)): Path<(String, String)>,
1005
    Form(f): Form<BackupForm>,
c7067c7b Andy McCall 1006
) -> Result<Response, Error> {
d3b048b2 Andy McCall ↶ 1007
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
c7067c7b Andy McCall 1008
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
d3b048b2 Andy McCall ↶ 1009
    csrf(&viewer, &f.csrf_token, &shell)?;
1010
    if !crate::backups::REPO_CHOICES.iter().any(|(v, _)| *v == f.backup) {
c7067c7b Andy McCall 1011
        return back(&row, Section::Backup, None, Some("Pick how this repository is backed up.".into()));
d3b048b2 Andy McCall ↶ 1012
    }
1013
    db::repos::set_backup(&app.db, row.id, &f.backup).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1014
    let what = if f.backup.is_empty() { "with its owner's" } else { f.backup.as_str() };
1015
    accounts::audit(&app.db, viewer.account(), "repo.backup", &format!("{owner}/{name}"), what).await.ok();
1016
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
1017
    let notice = match f.backup.as_str() {
1018
        "off" => "This repository is left out of its owner's backups from the next run.".to_string(),
1019
        "" => "This repository is backed up with its owner's.".to_string(),
1020
        every => {
1021
            format!("This repository is backed up at least {} from now on.", if every == "hourly" { "every hour" } else { "every day" })
1022
        }
1023
    };
c7067c7b Andy McCall 1024
    back(&row, Section::Backup, Some(notice), None)
9008bf6c Andy McCall ↶ 1025
}
1026
1027
#[derive(Deserialize)]
e7158350 Andy McCall ↶ 1028
pub struct DiffusionForm {
1029
    #[serde(rename = "_csrf")]
1030
    csrf_token: String,
1031
    /// `off`, `green`, `any`, or `default`.
1032
    diffusion: String,
f4d6ef41 Andy McCall ↶ 1033
    /// `auto` or `propose` (absent keeps it as it is).
1034
    #[serde(default)]
1035
    land: String,
e7158350 Andy McCall ↶ 1036
}
1037
1038
/// Whether this repository follows the upstreams it pins (Diffusion,
1039
/// #148): its owners and admins decide, since it opens proposals here.
1040
pub async fn diffusion(
1041
    State(app): State<Shared>,
1042
    viewer: Viewer,
1043
    Path((owner, name)): Path<(String, String)>,
1044
    Form(f): Form<DiffusionForm>,
c7067c7b Andy McCall 1045
) -> Result<Response, Error> {
e7158350 Andy McCall ↶ 1046
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
c7067c7b Andy McCall 1047
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
e7158350 Andy McCall ↶ 1048
    csrf(&viewer, &f.csrf_token, &shell)?;
1049
    let setting = match f.diffusion.as_str() {
1050
        "off" | "green" | "any" => f.diffusion.as_str(),
1051
        "default" => "",
c7067c7b Andy McCall 1052
        _ => return back(&row, Section::Diffusion, None, Some("Pick off, green, any or the site default.".into())),
f4d6ef41 Andy McCall ↶ 1053
    };
1054
    let land = match f.land.as_str() {
1055
        "auto" | "propose" => Some(f.land.as_str()),
1056
        "" => None,
c7067c7b Andy McCall 1057
        _ => return back(&row, Section::Diffusion, None, Some("Pick propose or auto for landing.".into())),
e7158350 Andy McCall ↶ 1058
    };
1059
    db::repos::set_diffusion(&app.db, row.id, setting).await.map_err(|e| Error::from_db(shell.clone(), e))?;
f4d6ef41 Andy McCall ↶ 1060
    if let Some(l) = land {
1061
        db::repos::set_diffusion_land(&app.db, row.id, l).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1062
    }
1063
    let what =
1064
        format!("{}{}", if setting.is_empty() { "default" } else { setting }, land.map(|l| format!(", land {l}")).unwrap_or_default());
1065
    accounts::audit(&app.db, viewer.account(), "repo.diffusion", &format!("{owner}/{name}"), &what).await.ok();
e7158350 Andy McCall ↶ 1066
    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 ↶ 1067
    // Turned to land by itself: any bump already green lands now.
1068
    if land == Some("auto") {
1069
        let (app, row) = (app.clone(), row.clone());
1070
        tokio::spawn(async move { crate::diffusion::land_ready(&app, &row).await });
1071
    }
3776b70d Andy McCall ↶ 1072
    if crate::diffusion::rule(&app.config.diffusion.follow, &row, None, "").follow != crate::diffusion::Follow::Off {
1073
        let (app, row) = (app.clone(), row.clone());
1074
        tokio::spawn(async move { crate::diffusion::catch_up(&app, &row).await });
1075
    }
e7158350 Andy McCall ↶ 1076
    let notice = match crate::diffusion::rule(&app.config.diffusion.follow, &row, None, "").follow {
1077
        crate::diffusion::Follow::Off => "Diffusion is off: upstream moves open no proposals here.",
1078
        crate::diffusion::Follow::Green => {
1079
            "Diffusion is on: when a subrepo's upstream moves and its checks pass, a proposal moves the pin here."
1080
        }
1081
        crate::diffusion::Follow::Any => "Diffusion is on: every move of a subrepo's upstream opens or updates a proposal here.",
1082
    };
c7067c7b Andy McCall 1083
    back(&row, Section::Diffusion, Some(notice.into()), None)
e7158350 Andy McCall ↶ 1084
}
1085
1086
#[derive(Deserialize)]
4a68f106 Andy McCall ↶ 1087
pub struct AgentGatesForm {
1088
    #[serde(rename = "_csrf")]
1089
    csrf_token: String,
1090
    /// `on` when the box is ticked.
1091
    #[serde(default)]
1092
    agents: String,
1093
}
1094
1095
/// `POST /{owner}/{repo}/settings/agent-gates` (oxygenhub#258): whether an
1096
/// agent's token may approve or reject this repository's approval gates;
1097
/// for maintainers, audited.
1098
pub async fn agent_gates(
1099
    State(app): State<Shared>,
1100
    viewer: Viewer,
1101
    Path((owner, name)): Path<(String, String)>,
1102
    Form(f): Form<AgentGatesForm>,
c7067c7b Andy McCall 1103
) -> Result<Response, Error> {
4a68f106 Andy McCall ↶ 1104
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
c7067c7b Andy McCall 1105
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
4a68f106 Andy McCall ↶ 1106
    csrf(&viewer, &f.csrf_token, &shell)?;
1107
    let on = f.agents == "on";
1108
    db::repos::set_agents_decide_gates(&app.db, row.id, on).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1109
    accounts::audit(&app.db, viewer.account(), "repo.agents_decide_gates", &format!("{owner}/{name}"), if on { "on" } else { "off" })
1110
        .await
1111
        .ok();
1112
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
1113
    let notice = if on {
1114
        "Agents' tokens may now approve or reject this repository's approval gates; it says so on each."
1115
    } else {
1116
        "Approval gates here are decided by people: agents' tokens are refused."
1117
    };
c7067c7b Andy McCall 1118
    back(&row, Section::Gates, Some(notice.into()), None)
4a68f106 Andy McCall ↶ 1119
}
1120
1121
#[derive(Deserialize)]
0ee5c3fb Andy McCall ↶ 1122
pub struct PushProtectionForm {
1123
    #[serde(rename = "_csrf")]
1124
    csrf_token: String,
1125
    /// `on` when the box is ticked.
1126
    #[serde(default)]
1127
    protect: String,
1128
}
1129
1130
/// `POST /{owner}/{repo}/settings/push-protection` (oxygenhub#268): whether
1131
/// pushes adding what looks like a secret are refused; for maintainers,
1132
/// audited.
1133
pub async fn push_protection(
1134
    State(app): State<Shared>,
1135
    viewer: Viewer,
1136
    Path((owner, name)): Path<(String, String)>,
1137
    Form(f): Form<PushProtectionForm>,
c7067c7b Andy McCall 1138
) -> Result<Response, Error> {
0ee5c3fb Andy McCall ↶ 1139
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
c7067c7b Andy McCall 1140
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
0ee5c3fb Andy McCall ↶ 1141
    csrf(&viewer, &f.csrf_token, &shell)?;
1142
    let on = f.protect == "on";
1143
    db::push_protection::set_enabled(&app.db, row.id, on).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1144
    accounts::audit(&app.db, viewer.account(), "repo.push_protection", &format!("{owner}/{name}"), if on { "on" } else { "off" })
1145
        .await
1146
        .ok();
cb7b3cd4 Andy McCall ↶ 1147
    if on {
1148
        // What was pushed while it was off is scanned now (#277).
1149
        crate::history_scan::wake(&app, row.id);
1150
    }
0ee5c3fb Andy McCall ↶ 1151
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
1152
    let notice = if on {
1153
        "Push protection is on: a push that adds what looks like a secret is refused, saying where."
1154
    } else {
1155
        "Push protection is off: pushes are no longer scanned for secrets here."
1156
    };
c7067c7b Andy McCall 1157
    back(&row, Section::PushProtection, Some(notice.into()), None)
0ee5c3fb Andy McCall ↶ 1158
}
1159
1160
#[derive(Deserialize)]
1161
pub struct SecretBypassForm {
1162
    #[serde(rename = "_csrf")]
1163
    csrf_token: String,
1164
    fingerprint: String,
1165
    #[serde(default)]
1166
    reason: String,
1167
}
1168
1169
/// `POST /{owner}/{repo}/settings/secret-bypass` (oxygenhub#268): let one
1170
/// push protection finding through, by its fingerprint, with a reason; for
1171
/// maintainers, audited as `secret.bypass`.
1172
pub async fn secret_bypass(
1173
    State(app): State<Shared>,
1174
    viewer: Viewer,
1175
    Path((owner, name)): Path<(String, String)>,
1176
    Form(f): Form<SecretBypassForm>,
c7067c7b Andy McCall 1177
) -> Result<Response, Error> {
0ee5c3fb Andy McCall ↶ 1178
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
c7067c7b Andy McCall 1179
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
0ee5c3fb Andy McCall ↶ 1180
    csrf(&viewer, &f.csrf_token, &shell)?;
1181
    let fingerprint = f.fingerprint.trim().to_ascii_lowercase();
1182
    let reason = f.reason.trim();
1183
    let error = if fingerprint.len() != 16 || !fingerprint.chars().all(|c| c.is_ascii_hexdigit()) {
1184
        Some("A fingerprint is the 16 hexadecimal digits a refused push printed.")
1185
    } else if reason.is_empty() {
1186
        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.")
1187
    } else if reason.chars().count() > 500 {
1188
        Some("Keep the reason under 500 characters.")
1189
    } else {
1190
        None
1191
    };
1192
    if let Some(e) = error {
c7067c7b Andy McCall 1193
        return back(&row, Section::PushProtection, None, Some(e.into()));
0ee5c3fb Andy McCall ↶ 1194
    }
1195
    let rule = db::push_protection::rule_of(&app.db, row.id, &fingerprint)
1196
        .await
1197
        .map_err(|e| Error::from_db(shell.clone(), e))?
1198
        .unwrap_or_default();
1199
    let by = viewer.account().map(|a| (a.id, a.username.as_str()));
1200
    db::push_protection::allow(&app.db, row.id, &fingerprint, &rule, reason, by).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1201
    let detail = if rule.is_empty() { format!("{fingerprint}: {reason}") } else { format!("{fingerprint} ({rule}): {reason}") };
1202
    accounts::audit(&app.db, viewer.account(), "secret.bypass", &format!("{owner}/{name}"), &detail).await.ok();
1203
    let notice = format!("Fingerprint {fingerprint} is allowed here: a push adding that value is no longer refused for it.");
c7067c7b Andy McCall 1204
    back(&row, Section::PushProtection, Some(notice), None)
cb7b3cd4 Andy McCall ↶ 1205
}
1206
1207
#[derive(Deserialize)]
1208
pub struct BypassRemoveForm {
1209
    #[serde(rename = "_csrf")]
1210
    csrf_token: String,
1211
    fingerprint: String,
1212
}
1213
1214
/// `POST /{owner}/{repo}/settings/secret-bypass/remove` (oxygenhub#277):
1215
/// take back an allowed fingerprint, so a push adding that value is refused
1216
/// again; for maintainers, audited as `secret.bypass_removed`.
1217
pub async fn secret_bypass_remove(
1218
    State(app): State<Shared>,
1219
    viewer: Viewer,
1220
    Path((owner, name)): Path<(String, String)>,
1221
    Form(f): Form<BypassRemoveForm>,
c7067c7b Andy McCall 1222
) -> Result<Response, Error> {
cb7b3cd4 Andy McCall ↶ 1223
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
c7067c7b Andy McCall 1224
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
cb7b3cd4 Andy McCall ↶ 1225
    csrf(&viewer, &f.csrf_token, &shell)?;
1226
    let fingerprint = f.fingerprint.trim().to_ascii_lowercase();
1227
    let removed = db::push_protection::remove_bypass(&app.db, row.id, &fingerprint).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1228
    let Some(b) = removed else {
c7067c7b Andy McCall 1229
        return back(&row, Section::PushProtection, None, Some(format!("Fingerprint {fingerprint} is not allowed here.")));
cb7b3cd4 Andy McCall ↶ 1230
    };
1231
    let detail = if b.rule.is_empty() {
1232
        format!("{fingerprint} (was: {})", b.reason)
1233
    } else {
1234
        format!("{fingerprint} ({}) (was: {})", b.rule, b.reason)
1235
    };
1236
    accounts::audit(&app.db, viewer.account(), "secret.bypass_removed", &format!("{owner}/{name}"), &detail).await.ok();
1237
    let notice = format!("Fingerprint {fingerprint} is no longer allowed: a push adding that value is refused again.");
c7067c7b Andy McCall 1238
    back(&row, Section::PushProtection, Some(notice), None)
cb7b3cd4 Andy McCall ↶ 1239
}
1240
1241
#[derive(Deserialize)]
1242
pub struct SecretResolveForm {
1243
    #[serde(rename = "_csrf")]
1244
    csrf_token: String,
1245
    finding: i64,
1246
    #[serde(default)]
1247
    reason: String,
1248
}
1249
1250
/// `POST /{owner}/{repo}/settings/secret-resolve` (oxygenhub#277): mark a
1251
/// finding of the history scan resolved (the secret was revoked or
1252
/// rotated), with a reason; for maintainers, audited as `secret.resolved`.
1253
pub async fn secret_resolve(
1254
    State(app): State<Shared>,
1255
    viewer: Viewer,
1256
    Path((owner, name)): Path<(String, String)>,
1257
    Form(f): Form<SecretResolveForm>,
c7067c7b Andy McCall 1258
) -> Result<Response, Error> {
cb7b3cd4 Andy McCall ↶ 1259
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
c7067c7b Andy McCall 1260
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
cb7b3cd4 Andy McCall ↶ 1261
    csrf(&viewer, &f.csrf_token, &shell)?;
1262
    let reason = f.reason.trim();
1263
    let error = if reason.is_empty() {
1264
        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.")
1265
    } else if reason.chars().count() > 500 {
1266
        Some("Keep the reason under 500 characters.")
1267
    } else {
1268
        None
1269
    };
1270
    if let Some(e) = error {
c7067c7b Andy McCall 1271
        return back(&row, Section::PushProtection, None, Some(e.into()));
cb7b3cd4 Andy McCall ↶ 1272
    }
1273
    let by = viewer.account().map(|a| a.username.as_str()).unwrap_or("");
1274
    let done = db::push_protection::resolve(&app.db, row.id, f.finding, by, reason).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1275
    let Some(found) = done else {
c7067c7b Andy McCall 1276
        return back(&row, Section::PushProtection, None, Some("That finding is not open here.".into()));
cb7b3cd4 Andy McCall ↶ 1277
    };
1278
    let detail = format!("{} ({}) at {}:{}: {reason}", found.fingerprint, found.rule, found.path, found.line);
1279
    accounts::audit(&app.db, viewer.account(), "secret.resolved", &format!("{owner}/{name}"), &detail).await.ok();
1280
    let notice = format!("The finding at {}:{} is marked resolved.", found.path, found.line);
c7067c7b Andy McCall 1281
    back(&row, Section::PushProtection, Some(notice), None)
cb7b3cd4 Andy McCall ↶ 1282
}
1283
1284
#[derive(Deserialize)]
1285
pub struct SecretScanForm {
1286
    #[serde(rename = "_csrf")]
1287
    csrf_token: String,
1288
}
1289
1290
/// `POST /{owner}/{repo}/settings/secret-scan` (oxygenhub#277): scan the
1291
/// whole history again now, with the current rules; for maintainers,
1292
/// audited as `secret.history_scan`.
1293
pub async fn secret_scan(
1294
    State(app): State<Shared>,
1295
    viewer: Viewer,
1296
    Path((owner, name)): Path<(String, String)>,
1297
    Form(f): Form<SecretScanForm>,
c7067c7b Andy McCall 1298
) -> Result<Response, Error> {
cb7b3cd4 Andy McCall ↶ 1299
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
c7067c7b Andy McCall 1300
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
cb7b3cd4 Andy McCall ↶ 1301
    csrf(&viewer, &f.csrf_token, &shell)?;
1302
    if !row.push_protection {
c7067c7b Andy McCall 1303
        return back(
1304
            &row,
1305
            Section::PushProtection,
cb7b3cd4 Andy McCall ↶ 1306
            None,
1307
            Some("Turn push protection on first: history is scanned only where it is on.".into()),
c7067c7b Andy McCall 1308
        );
cb7b3cd4 Andy McCall ↶ 1309
    }
1310
    crate::history_scan::request(&app, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1311
    accounts::audit(&app.db, viewer.account(), "secret.history_scan", &format!("{owner}/{name}"), "requested").await.ok();
1312
    let notice = "The whole history will be scanned again, before other repositories: findings appear below as they are found.";
c7067c7b Andy McCall 1313
    back(&row, Section::PushProtection, Some(notice.into()), None)
0ee5c3fb Andy McCall ↶ 1314
}
1315
1316
#[derive(Deserialize)]
a9f1c0bf Andy McCall ↶ 1317
pub struct SupersedeForm {
1318
    #[serde(rename = "_csrf")]
1319
    csrf_token: String,
1320
    /// `on` when the box is ticked: superseded runs already running are
1321
    /// cancelled too.
1322
    #[serde(default)]
1323
    running: String,
1324
}
1325
1326
/// `POST /{owner}/{repo}/settings/flow-supersede` (oxygenhub#267): whether
1327
/// a new push or proposal run cancels the older runs it supersedes that are
1328
/// already running, or only those still queued; for maintainers, audited.
1329
pub async fn flow_supersede(
1330
    State(app): State<Shared>,
1331
    viewer: Viewer,
1332
    Path((owner, name)): Path<(String, String)>,
1333
    Form(f): Form<SupersedeForm>,
c7067c7b Andy McCall 1334
) -> Result<Response, Error> {
a9f1c0bf Andy McCall ↶ 1335
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
c7067c7b Andy McCall 1336
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
a9f1c0bf Andy McCall ↶ 1337
    csrf(&viewer, &f.csrf_token, &shell)?;
1338
    let keep = f.running != "on";
1339
    db::repos::set_flow_keep_running(&app.db, row.id, keep).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1340
    accounts::audit(&app.db, viewer.account(), "repo.flow_keep_running", &format!("{owner}/{name}"), if keep { "on" } else { "off" })
1341
        .await
1342
        .ok();
1343
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
1344
    let notice = if keep {
1345
        "A new push or proposal run now cancels only the older runs still queued; those already running finish."
1346
    } else {
1347
        "A new push or proposal run now cancels the older runs it supersedes, queued or running."
1348
    };
c7067c7b Andy McCall 1349
    back(&row, Section::Runs, Some(notice.into()), None)
a9f1c0bf Andy McCall ↶ 1350
}
1351
1352
#[derive(Deserialize)]
5588bb36 Andy McCall ↶ 1353
pub struct ReuseForm {
1354
    #[serde(rename = "_csrf")]
1355
    csrf_token: String,
1356
    /// `on` when the box is ticked.
1357
    #[serde(default)]
1358
    reuse: String,
1359
}
1360
1361
/// `POST /{owner}/{repo}/settings/flow-reuse` (oxygenhub#265): whether a
1362
/// push run reuses a job's pass on the same state; for maintainers,
1363
/// audited.
1364
pub async fn flow_reuse(
1365
    State(app): State<Shared>,
1366
    viewer: Viewer,
1367
    Path((owner, name)): Path<(String, String)>,
1368
    Form(f): Form<ReuseForm>,
1369
) -> Result<Response, Error> {
1370
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1371
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
1372
    csrf(&viewer, &f.csrf_token, &shell)?;
1373
    let on = f.reuse == "on";
1374
    db::repos::set_flow_reuse(&app.db, row.id, on).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1375
    accounts::audit(&app.db, viewer.account(), "repo.flow_reuse", &format!("{owner}/{name}"), if on { "on" } else { "off" }).await.ok();
1376
    let notice = if on {
1377
        "A push run now reuses a job's pass on the same state."
1378
    } else {
1379
        "Every job of every push now runs, passed before or not."
1380
    };
1381
    back(&row, Section::Runs, Some(notice.into()), None)
1382
}
1383
1384
#[derive(Deserialize)]
367f34b8 Andy McCall ↶ 1385
pub struct SkipForm {
1386
    #[serde(rename = "_csrf")]
1387
    csrf_token: String,
1388
    /// `any`, `others` or `never`.
1389
    skip: String,
1390
}
1391
1392
/// `POST /{owner}/{repo}/settings/flow-skip` (oxygenhub#284): where a push
1393
/// may skip its run with `[skip flow]` in its message; for maintainers,
1394
/// audited.
1395
pub async fn flow_skip(
1396
    State(app): State<Shared>,
1397
    viewer: Viewer,
1398
    Path((owner, name)): Path<(String, String)>,
1399
    Form(f): Form<SkipForm>,
1400
) -> Result<Response, Error> {
1401
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1402
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
1403
    csrf(&viewer, &f.csrf_token, &shell)?;
1404
    let notice = match f.skip.as_str() {
1405
        "any" => "A push may now skip its run on any bookmark.".to_string(),
1406
        "others" => format!("A push may now skip its run on any bookmark but {}.", row.default_bookmark),
1407
        "never" => "Every push now runs; skip markers are ignored.".to_string(),
1408
        _ => return back(&row, Section::Runs, None, Some("Choose where a push may skip its run.".into())),
1409
    };
1410
    db::repos::set_flow_skip(&app.db, row.id, &f.skip).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1411
    accounts::audit(&app.db, viewer.account(), "repo.flow_skip", &format!("{owner}/{name}"), &f.skip).await.ok();
1412
    back(&row, Section::Runs, Some(notice), None)
1413
}
1414
1415
#[derive(Deserialize)]
107eaba5 Andy McCall ↶ 1416
pub struct PriorityForm {
1417
    #[serde(rename = "_csrf")]
1418
    csrf_token: String,
1419
    /// `low`, `normal`, `high`, or `default`.
1420
    priority: String,
1421
}
1422
1423
/// `POST /{owner}/{repo}/settings/flow-priority` (oxygenhub#244): its jobs'
1424
/// level. Maintainers choose low, normal or the default; only site
1425
/// administrators raise a repository to high.
1426
pub async fn flow_priority(
1427
    State(app): State<Shared>,
1428
    viewer: Viewer,
1429
    Path((owner, name)): Path<(String, String)>,
1430
    Form(f): Form<PriorityForm>,
c7067c7b Andy McCall 1431
) -> Result<Response, Error> {
107eaba5 Andy McCall ↶ 1432
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
c7067c7b Andy McCall 1433
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
107eaba5 Andy McCall ↶ 1434
    csrf(&viewer, &f.csrf_token, &shell)?;
1435
    let level = match f.priority.as_str() {
1436
        "low" | "normal" | "high" => f.priority.as_str(),
1437
        "default" => "",
c7067c7b Andy McCall 1438
        _ => return back(&row, Section::Runs, None, Some("Pick low, normal, high or the default.".into())),
107eaba5 Andy McCall ↶ 1439
    };
1440
    if level == "high" && !viewer.is_admin() {
c7067c7b Andy McCall 1441
        return back(&row, Section::Runs, None, Some("Only site administrators raise a repository to high.".into()));
107eaba5 Andy McCall ↶ 1442
    }
1443
    db::repos::set_flow_priority(&app.db, row.id, level).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1444
    accounts::audit(
1445
        &app.db,
1446
        viewer.account(),
1447
        "repo.flow_priority",
1448
        &format!("{owner}/{name}"),
1449
        if level.is_empty() { "default" } else { level },
1450
    )
1451
    .await
1452
    .ok();
1453
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
1454
    let notice = if level.is_empty() {
1455
        "Its jobs follow the organisation's and the site's priority.".to_string()
1456
    } else {
1457
        format!("Its jobs are {level} priority.")
1458
    };
c7067c7b Andy McCall 1459
    back(&row, Section::Runs, Some(notice), None)
107eaba5 Andy McCall ↶ 1460
}
1461
1462
#[derive(Deserialize)]
9008bf6c Andy McCall ↶ 1463
pub struct FlowForm {
1464
    #[serde(rename = "_csrf")]
1465
    csrf_token: String,
1466
    /// `on`, `off`, or `default`.
1467
    flow: String,
1468
}
1469
1870e4fa Andy McCall ↶ 1470
/// Turn OxygenFlow on or off for one repository. Site administrators choose
1471
/// anything; the repository's maintainers may turn it on on their own
1472
/// runners (`own`), when the repository or its owner has one, or off (#225).
1473
/// Only site administrators let the site's runners take its jobs.
9008bf6c Andy McCall ↶ 1474
pub async fn flow(
1475
    State(app): State<Shared>,
1476
    viewer: Viewer,
1477
    Path((owner, name)): Path<(String, String)>,
1478
    Form(f): Form<FlowForm>,
c7067c7b Andy McCall 1479
) -> Result<Response, Error> {
9008bf6c Andy McCall ↶ 1480
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
c7067c7b Andy McCall 1481
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
9008bf6c Andy McCall ↶ 1482
    csrf(&viewer, &f.csrf_token, &shell)?;
1483
    let setting = match f.flow.as_str() {
1484
        "on" => "on",
1870e4fa Andy McCall ↶ 1485
        "own" => "own",
9008bf6c Andy McCall ↶ 1486
        "off" => "off",
1487
        "default" => "",
c7067c7b Andy McCall 1488
        _ => return back(&row, Section::Pipelines, None, Some("Pick on, own runners, off or the site default.".into())),
9008bf6c Andy McCall ↶ 1489
    };
1870e4fa Andy McCall ↶ 1490
    if !viewer.is_admin() {
1491
        if matches!(setting, "on" | "") {
c7067c7b Andy McCall 1492
            return back(
1493
                &row,
1494
                Section::Pipelines,
1870e4fa Andy McCall ↶ 1495
                None,
1496
                Some("Only site administrators let the site's runners take this repository's jobs.".into()),
c7067c7b Andy McCall 1497
            );
1870e4fa Andy McCall ↶ 1498
        }
1499
        if setting == "own" && !has_own_runner(&app, &row).await.map_err(|e| Error::from_db(shell.clone(), e))? {
c7067c7b Andy McCall 1500
            return back(
1501
                &row,
1502
                Section::Pipelines,
1870e4fa Andy McCall ↶ 1503
                None,
c7067c7b Andy McCall 1504
                Some("Register a runner for this repository (Settings › Runners) or its owner first.".into()),
1505
            );
1870e4fa Andy McCall ↶ 1506
        }
1507
    }
9008bf6c Andy McCall ↶ 1508
    db::repos::set_flow(&app.db, row.id, setting).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1509
    let label = if setting.is_empty() { "default" } else { setting };
1510
    accounts::audit(&app.db, viewer.account(), "repo.flow", &format!("{owner}/{name}"), label).await.ok();
1511
    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 ↶ 1512
    let notice = if row.flow == "own" {
1513
        "OxygenFlow is on, on this repository's own runners: pushes with a .flow/pipeline.kdl start runs that only they take."
1514
    } else if crate::flow::enabled(&app, &row) {
9008bf6c Andy McCall ↶ 1515
        "OxygenFlow is on: pushes with a .flow/pipeline.kdl start runs."
1516
    } else {
1517
        "OxygenFlow is off for this repository."
1518
    };
c7067c7b Andy McCall 1519
    back(&row, Section::Pipelines, Some(notice.into()), None)
3f4b0ea6 Andy McCall ↶ 1520
}
1521
1522
pub async fn mirror(
1523
    State(app): State<Shared>,
1524
    viewer: Viewer,
1525
    Path((owner, name)): Path<(String, String)>,
1526
    Form(f): Form<MirrorForm>,
c7067c7b Andy McCall 1527
) -> Result<Response, Error> {
3f4b0ea6 Andy McCall ↶ 1528
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
c7067c7b Andy McCall 1529
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
3f4b0ea6 Andy McCall ↶ 1530
    csrf(&viewer, &f.csrf_token, &shell)?;
1531
    db::repos::set_git_mirror(&app.db, row.id, f.enabled.is_some()).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1532
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
1533
    if row.git_mirror {
dd327d76 Andy McCall ↶ 1534
        crate::mirror::refresh(&app, &row).await.ok();
3f4b0ea6 Andy McCall ↶ 1535
    }
1536
    accounts::audit(&app.db, viewer.account(), "repo.mirror", &format!("{owner}/{name}"), if row.git_mirror { "on" } else { "off" })
1537
        .await
1538
        .ok();
1539
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
1540
    let notice = if row.git_mirror { "The Git mirror is on and up to date." } else { "The Git mirror is off." };
c7067c7b Andy McCall 1541
    back(&row, Section::GitMirror, Some(notice.into()), None)
7974adb0 Andy McCall ↶ 1542
}
1543
1544
/// `POST /{owner}/{name}/settings/runners/token`: a one-time registration
1545
/// token for a runner that serves this repository only (oxygenhub#41).
1546
pub async fn runner_token(
1547
    State(app): State<Shared>,
1548
    viewer: Viewer,
1549
    Path((owner, name)): Path<(String, String)>,
1550
    Form(f): Form<super::session::CsrfForm>,
1551
) -> Result<Page<RepoSettingsPage>, Error> {
1552
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1553
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
1554
    csrf(&viewer, &f.csrf, &shell)?;
1555
    let me = viewer.account().map(|a| a.username.clone()).unwrap_or_default();
1556
    let token = format!("flowreg_{}", crate::auth::random_hex(24));
1557
    let scope = db::runners::Scope { owner: row.owner.clone(), repo_id: Some(row.id) };
1558
    db::runners::add_registration(&app.db, &blake3::hash(token.as_bytes()).to_hex(), &me, &scope)
1559
        .await
1560
        .map_err(|e| Error::from_db(shell.clone(), e))?;
1561
    accounts::audit(&app.db, viewer.account(), "flow.runner.token", &row.slug(), "registration token for this repository").await.ok();
c7067c7b Andy McCall 1562
    let mut page =
1563
        settings_page(&app, shell, row, role, Section::Runners, Some("Copy the token now; it is not shown again.".into()), None).await?;
7974adb0 Andy McCall ↶ 1564
    page.0.runner_token = Some(token);
1565
    Ok(page)
1566
}
1567
1568
/// `POST /{owner}/{name}/settings/runners/{id}/remove`: remove one of this
1569
/// repository's own runners, when it is running nothing.
1570
pub async fn runner_remove(
1571
    State(app): State<Shared>,
1572
    viewer: Viewer,
1573
    Path((owner, name, id)): Path<(String, String, i64)>,
1574
    Form(f): Form<super::session::CsrfForm>,
c7067c7b Andy McCall 1575
) -> Result<Response, Error> {
7974adb0 Andy McCall ↶ 1576
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
c7067c7b Andy McCall 1577
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
7974adb0 Andy McCall ↶ 1578
    csrf(&viewer, &f.csrf, &shell)?;
1579
    let e = |e| Error::from_db(shell.clone(), e);
1580
    let Some(r) = db::runners::get(&app.db, id).await.map_err(e)?.filter(|r| r.scope_repo_id == Some(row.id)) else {
1581
        return Err(Error::not_found(shell));
1582
    };
1583
    if db::runners::running(&app.db).await.map_err(e)?.iter().any(|(rid, n)| *rid == id && *n > 0) {
c7067c7b Andy McCall 1584
        return back(&row, Section::Runners, None, Some(format!("{} is running jobs: wait for them to finish.", r.name)));
7974adb0 Andy McCall ↶ 1585
    }
1586
    db::runners::remove(&app.db, id).await.map_err(e)?;
1587
    accounts::audit(&app.db, viewer.account(), "flow.runner.removed", &r.name, &row.slug()).await.ok();
c7067c7b Andy McCall 1588
    back(&row, Section::Runners, Some(format!("{} removed: its credential no longer works.", r.name)), None)
3f4b0ea6 Andy McCall ↶ 1589
}
e71846aa Andy McCall ↶ 1590
2a83f530 Andy McCall ↶ 1591
fn repo_assistants(row: &db::repos::RepoRow) -> crate::levels::At {
1592
    crate::levels::At::repo(row)
e71846aa Andy McCall ↶ 1593
}
1594
1595
/// `POST /{owner}/{name}/settings/assistants`: the repository's assistants
1596
/// and prompt (#97), for its admins.
1597
pub async fn assistants(
1598
    State(app): State<Shared>,
1599
    viewer: Viewer,
1600
    Path((owner, name)): Path<(String, String)>,
1601
    Form(f): Form<super::assistants::AssistantForm>,
c7067c7b Andy McCall 1602
) -> Result<Response, Error> {
e71846aa Andy McCall ↶ 1603
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
c7067c7b Andy McCall 1604
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
e71846aa Andy McCall ↶ 1605
    csrf(&viewer, &f.csrf_token, &shell)?;
1606
    match super::assistants::apply(&app, &viewer, &repo_assistants(&row), f).await.map_err(|e| Error::from_db(shell.clone(), e))? {
c7067c7b Andy McCall 1607
        Ok(notice) => back(&row, Section::Assistants, Some(notice), None),
1608
        Err(error) => back(&row, Section::Assistants, None, Some(error)),
2a83f530 Andy McCall ↶ 1609
    }
1610
}
1611
1612
/// `POST /{owner}/{name}/settings/variables`: the repository's Flow
1613
/// variables (#102), for its admins.
1614
pub async fn variables(
1615
    State(app): State<Shared>,
1616
    viewer: Viewer,
1617
    Path((owner, name)): Path<(String, String)>,
1618
    Form(f): Form<super::variables::VariableForm>,
c7067c7b Andy McCall 1619
) -> Result<Response, Error> {
2a83f530 Andy McCall ↶ 1620
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
c7067c7b Andy McCall 1621
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
2a83f530 Andy McCall ↶ 1622
    csrf(&viewer, &f.csrf_token, &shell)?;
1623
    match super::variables::apply(&app, &viewer, &repo_assistants(&row), f).await.map_err(|e| Error::from_db(shell.clone(), e))? {
c7067c7b Andy McCall 1624
        Ok(notice) => back(&row, Section::Variables, Some(notice), None),
1625
        Err(error) => back(&row, Section::Variables, None, Some(error)),
e71846aa Andy McCall ↶ 1626
    }
1627
}
4ac01a73 Andy McCall ↶ 1628
1629
/// `POST /{owner}/{name}/settings/secrets`: the repository's Flow secrets
1630
/// (#107), for its admins.
1631
pub async fn secrets(
1632
    State(app): State<Shared>,
1633
    viewer: Viewer,
1634
    Path((owner, name)): Path<(String, String)>,
1635
    Form(f): Form<super::secrets::SecretForm>,
c7067c7b Andy McCall 1636
) -> Result<Response, Error> {
4ac01a73 Andy McCall ↶ 1637
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
c7067c7b Andy McCall 1638
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
4ac01a73 Andy McCall ↶ 1639
    csrf(&viewer, &f.csrf_token, &shell)?;
1640
    match super::secrets::apply(&app, &viewer, &repo_assistants(&row), f).await.map_err(|e| Error::from_db(shell.clone(), e))? {
c7067c7b Andy McCall 1641
        Ok(notice) => back(&row, Section::Secrets, Some(notice), None),
1642
        Err(error) => back(&row, Section::Secrets, None, Some(error)),
4ac01a73 Andy McCall ↶ 1643
    }
1644
}
155cab14 Andy McCall ↶ 1645
1646
/// `POST /{owner}/{name}/settings/assistants/models`: what a provider offers (#109).
1647
pub async fn assistant_models(
1648
    State(app): State<Shared>,
1649
    viewer: Viewer,
1650
    Path((owner, name)): Path<(String, String)>,
1651
    Form(f): Form<super::assistants::ModelsForm>,
1652
) -> Result<axum::Json<serde_json::Value>, Error> {
1653
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1654
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
1655
    csrf(&viewer, &f.csrf_token, &shell)?;
1656
    Ok(axum::Json(super::assistants::models(&app, &repo_assistants(&row), f).await.map_err(|e| Error::from_db(shell.clone(), e))?))
1870e4fa Andy McCall ↶ 1657
}
1658
1659
/// Whether a runner serves this repository that is not the site's: one
1660
/// scoped to it or to its owner (#225).
1661
async fn has_own_runner(app: &Shared, row: &db::repos::RepoRow) -> sqlx::Result<bool> {
1662
    Ok(db::runners::serving(&app.db, &row.owner, row.id).await?.iter().any(|r| !r.scope().is_site()))
155cab14 Andy McCall ↶ 1663
}