OXYGENHUB
# 9041db4e4d92 oxygenhub / crates / hub / src / web / repo_settings.rs
68.5 KiB · Rust · last changed Push without starting a run: [skip flow] (closes #284) 16 days ago source blame history raw
1
//! Creating repositories and their settings pages (hub spec §6.3).
2
3
use super::settings::{csrf, require};
4
use super::{filters, lookup_role, Error, Page, RepoShell, Shell};
5
use crate::auth::{roles, Access, Role, Viewer};
6
use crate::db::{self, accounts};
7
use crate::repos::RepoName;
8
use crate::Shared;
9
use askama::Template;
10
use axum::extract::{Form, Path, Query, State};
11
use axum::http::Uri;
12
use axum::response::{IntoResponse, Redirect, Response};
13
use hub_api::Visibility;
14
use serde::Deserialize;
15
16
#[derive(Template)]
17
#[template(path = "repo_new.html")]
18
pub struct NewRepoPage {
19
    shell: Shell,
20
    owners: Vec<String>,
21
    error: Option<String>,
22
}
23
24
pub(crate) async fn owners_for(app: &Shared, me: &accounts::Account) -> Vec<String> {
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,
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,
74
}
75
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
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
    };
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
    }
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."));
114
    }
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
    }
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());
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
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
295
#[derive(Template)]
296
#[template(path = "repo_settings.html")]
297
pub struct RepoSettingsPage {
298
    /// The page shown, and the sidebar's groups of pages.
299
    section: Section,
300
    nav: Vec<(&'static str, Vec<Section>)>,
301
    shell: Shell,
302
    repo: RepoShell,
303
    row: db::repos::RepoRow,
304
    collaborators: Vec<accounts::Collaborator>,
305
    teams: Vec<accounts::Team>,
306
    role: Role,
307
    requirements: db::proposals::Requirements,
308
    hooks: Vec<super::webhooks::HookRow>,
309
    required_checks: String,
310
    /// Whether hub.toml alone would run Flow here, and whether it runs.
311
    flow_default: bool,
312
    /// hub.toml's `[diffusion] follow` (#148).
313
    diffusion_site: String,
314
    flow_enabled: bool,
315
    site_admin: bool,
316
    /// Whether the repository or its owner has a runner of its own (#225).
317
    own_runner: bool,
318
    notice: Option<String>,
319
    error: Option<String>,
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>,
325
    /// The repository's AI assistants and prompt (#97).
326
    assistants: super::assistants::Panel,
327
    /// The repository's Flow variables (#102).
328
    variables: super::variables::Panel,
329
    /// The repository's Flow secrets (#107).
330
    secrets: super::secrets::Panel,
331
    /// Where its data lives, when in a bucket (oxygenhub#96).
332
    storage: Option<crate::storage::Storage>,
333
    /// The coverage check's rules (#113).
334
    coverage: crate::coverage::Rules,
335
    /// Its owner's backup, when one is set up (docs/backups.md §5).
336
    backup_owner: Option<crate::backups::Setting>,
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>,
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,
347
}
348
349
impl RepoSettingsPage {
350
    fn backup_choices(&self) -> &'static [(&'static str, &'static str)] {
351
        crate::backups::REPO_CHOICES
352
    }
353
}
354
355
/// One settings page, or not found when the viewer's role does not have it.
356
async fn settings_page(
357
    app: &Shared,
358
    shell: Shell,
359
    row: db::repos::RepoRow,
360
    role: Role,
361
    section: Section,
362
    notice: Option<String>,
363
    error: Option<String>,
364
) -> Result<Page<RepoSettingsPage>, Error> {
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());
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
    };
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))?;
377
    let hooks = db::hooks::hooks_for_repo(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
378
    let hooks = super::webhooks::rows(app, hooks).await.map_err(|e| Error::from_db(shell.clone(), e))?;
379
    let required_checks = db::hooks::required_checks(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.join(", ");
380
    let flow_default = app.config.flow.allows(&row.owner, &row.name);
381
    let diffusion_site = app.config.diffusion.follow.clone();
382
    let flow_enabled = crate::flow::enabled(app, &row);
383
    let site_admin = shell.is_admin;
384
    let own_runner = has_own_runner(app, &row).await.map_err(|e| Error::from_db(shell.clone(), e))?;
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();
401
    let assistants = super::assistants::panel(app, &repo_assistants(&row)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
402
    let variables = super::variables::panel(app, &repo_assistants(&row)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
403
    let secrets = super::secrets::panel(app, &repo_assistants(&row)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
404
    let coverage = crate::coverage::rules(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
405
    let backup_owner = crate::backups::setting(&app.db, &row.owner).await.map_err(|e| Error::from_db(shell.clone(), e))?;
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))?;
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();
412
    Ok(Page(RepoSettingsPage {
413
        section,
414
        nav,
415
        push_findings,
416
        secret_bypasses,
417
        history_scan,
418
        history_findings,
419
        resolved_findings,
420
        history_scan_on,
421
        backup_owner,
422
        coverage,
423
        storage,
424
        assistants,
425
        variables,
426
        secrets,
427
        runners,
428
        runner_token: None,
429
        shell,
430
        repo,
431
        row,
432
        collaborators,
433
        teams,
434
        role,
435
        requirements,
436
        hooks,
437
        required_checks,
438
        flow_default,
439
        diffusion_site,
440
        flow_enabled,
441
        site_admin,
442
        own_runner,
443
        notice,
444
        error,
445
    }))
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>,
455
}
456
457
/// `GET /{owner}/{name}/settings`: the General page.
458
pub async fn settings(
459
    State(app): State<Shared>,
460
    viewer: Viewer,
461
    Path((owner, name)): Path<(String, String)>,
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,
497
) -> Result<Page<RepoSettingsPage>, Error> {
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
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,
516
    #[serde(default)]
517
    topics: String,
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>,
525
) -> Result<Response, Error> {
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);
530
    let topics = match db::repos::parse_topics(&f.topics) {
531
        Ok(t) => t,
532
        Err(msg) => return back(&row, Section::General, None, Some(msg)),
533
    };
534
    db::repos::set_description(&app.db, row.id, f.description.trim()).await.map_err(e)?;
535
    db::repos::set_topics(&app.db, row.id, &topics).await.map_err(e)?;
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);
546
    back(&row, Section::General, Some("Saved.".into()), None)
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>,
568
) -> Result<Response, Error> {
569
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
570
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
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),
576
            None => return back(&row, Section::Collaborators, None, Some("No such account.".into())),
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)),
586
            None => return back(&row, Section::Collaborators, None, Some("No such team in this organisation.".into())),
587
        }
588
    } else {
589
        return back(&row, Section::Collaborators, None, Some("Name an account or a team.".into()));
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();
594
        return back(&row, Section::Collaborators, Some(format!("Removed {label}.")), None);
595
    }
596
    let Some(new_role) = Role::parse(&f.role) else {
597
        return back(&row, Section::Collaborators, None, Some("Pick a role.".into()));
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();
609
    back(&row, Section::Collaborators, Some(format!("{label} now has {} access.", new_role.as_str())), None)
610
}
611
612
#[derive(Deserialize)]
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);
630
    let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
631
    csrf(&viewer, &f.csrf_token, &shell)?;
632
    let me = viewer.account().cloned().ok_or_else(|| Error::not_found(shell.clone()))?;
633
    let fail = |msg: &str| back(&row, Section::Advanced, None, Some(msg.into()));
634
    if f.confirm.trim() != repo.to_string() {
635
        return fail("Type the repository's full name to confirm.");
636
    }
637
    let to = f.owner.trim().trim_start_matches('@').to_string();
638
    let Some(dest) = RepoName::new(&to, &row.name) else {
639
        return fail("That is not an owner name.");
640
    };
641
    if dest.owner == row.owner {
642
        return fail("The repository is already there.");
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 {
648
        return fail("There is no user or organisation of that name.");
649
    }
650
    if !roles::may_create_under(&app.db, &me, &dest.owner).await.map_err(e)? {
651
        return fail("You cannot create repositories under that owner, so you cannot move one there.");
652
    }
653
    if db::repos::get(&app.db, &dest.owner, &dest.name).await.map_err(e)?.is_some() || app.repos.exists(&dest) {
654
        return fail("That owner already has a repository of this name.");
655
    }
656
    {
657
        let _guard = app.repos.exclusive_guard(&repo).await;
658
        app.repos.rename(&repo, &dest).map_err(|e| Error::from_repo(shell.clone(), e))?;
659
        crate::storage::renamed(&app, &repo, &dest);
660
        if app.wikis.exists(&repo) {
661
            app.wikis.rename(&repo, &dest).ok();
662
        }
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."));
675
    Ok(Redirect::to(&format!("{}/settings/{}?notice={notice}", dest.url(), Section::Advanced.slug())).into_response())
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>,
695
) -> Result<Response, Error> {
696
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
697
    let (repo, mut row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
698
    csrf(&viewer, &f.csrf_token, &shell)?;
699
    let archive = match f.action.as_str() {
700
        "archive" => true,
701
        "unarchive" => false,
702
        _ => return back(&row, Section::Advanced, None, Some("Unknown action.".into())),
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
    };
716
    back(&row, Section::Advanced, Some(notice.into()), None)
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>,
725
) -> Result<Response, Error> {
726
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
727
    let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
728
    csrf(&viewer, &f.csrf_token, &shell)?;
729
    if f.confirm.trim() != repo.to_string() {
730
        return back(&row, Section::Advanced, None, Some("Type the repository's full name to confirm.".into()));
731
    }
732
    if !crate::wiki::exists(&app, &repo) {
733
        return back(&row, Section::Advanced, None, Some("This repository has no wiki.".into()));
734
    }
735
    let _guard = app.wikis.exclusive_guard(&repo).await;
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();
739
    back(&row, Section::Advanced, Some("The wiki is in the trash; a new first page starts a fresh one.".into()), None)
740
}
741
742
#[derive(Deserialize)]
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);
756
    let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
757
    csrf(&viewer, &f.csrf_token, &shell)?;
758
    if f.confirm.trim() != repo.to_string() {
759
        return back(&row, Section::Advanced, None, Some("Type the repository's full name to delete it.".into()));
760
    }
761
    let _guard = app.repos.exclusive_guard(&repo).await;
762
    app.repos.trash(&repo).map_err(|e| Error::from_repo(shell.clone(), e))?;
763
    // A repository kept in a bucket leaves its data there: the owner's.
764
    crate::storage::removed(&app, &repo);
765
    crate::wiki::trash(&app, &repo).ok();
766
    db::repos::delete(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
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();
769
    crate::secrets::delete_scope(&app.db, crate::levels::Scope::Repo(row.id)).await.ok();
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
}
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>,
791
) -> Result<Response, Error> {
792
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
793
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
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();
810
    back(&row, Section::Landing, Some("Landing requirements saved.".into()), None)
811
}
812
813
pub async fn hooks(
814
    State(app): State<Shared>,
815
    viewer: Viewer,
816
    Path((owner, name)): Path<(String, String)>,
817
    Form(f): Form<super::webhooks::HookForm>,
818
) -> Result<Response, Error> {
819
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
820
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
821
    csrf(&viewer, &f.csrf_token, &shell)?;
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))? {
824
        Ok(notice) => back(&row, Section::Webhooks, Some(notice), None),
825
        Err(error) => back(&row, Section::Webhooks, None, Some(error)),
826
    }
827
}
828
829
pub async fn deliveries(
830
    State(app): State<Shared>,
831
    viewer: Viewer,
832
    Path((owner, name, id)): Path<(String, String, i64)>,
833
) -> Result<Page<super::webhooks::DeliveriesPage>, Error> {
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?;
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
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>,
845
) -> Result<Page<super::webhooks::DeliveriesPage>, Error> {
846
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings/hooks/{id}/deliveries"), &viewer);
847
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
848
    csrf(&viewer, &f.csrf, &shell)?;
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
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>,
867
) -> Result<Response, Error> {
868
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
869
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
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))?;
873
    back(&row, Section::Checks, Some("Required checks saved.".into()), None)
874
}
875
876
#[derive(Deserialize)]
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>,
892
) -> Result<Response, Error> {
893
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
894
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
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 },
908
        (Err(e), _) | (_, Err(e)) => return back(&row, Section::Coverage, None, Some(e)),
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();
920
    back(&row, Section::Coverage, Some("Coverage rules saved; they apply from the next report.".into()), None)
921
}
922
923
#[derive(Deserialize)]
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>,
940
) -> Result<Response, Error> {
941
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
942
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
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 {
945
        return back(&row, Section::General, None, Some("This repository is kept on this server's disk.".into()));
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();
958
            back(&row, Section::Storage, Some(notice), None)
959
        }
960
        Err(e) => back(&row, Section::Storage, None, Some(format!("Storage: {e}"))),
961
    }
962
}
963
964
#[derive(Deserialize)]
965
pub struct MirrorForm {
966
    #[serde(rename = "_csrf")]
967
    csrf_token: String,
968
    #[serde(default)]
969
    enabled: Option<String>,
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>,
988
) -> Result<Response, Error> {
989
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
990
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
991
    csrf(&viewer, &f.csrf_token, &shell)?;
992
    if !crate::backups::REPO_CHOICES.iter().any(|(v, _)| *v == f.backup) {
993
        return back(&row, Section::Backup, None, Some("Pick how this repository is backed up.".into()));
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
    };
1006
    back(&row, Section::Backup, Some(notice), None)
1007
}
1008
1009
#[derive(Deserialize)]
1010
pub struct DiffusionForm {
1011
    #[serde(rename = "_csrf")]
1012
    csrf_token: String,
1013
    /// `off`, `green`, `any`, or `default`.
1014
    diffusion: String,
1015
    /// `auto` or `propose` (absent keeps it as it is).
1016
    #[serde(default)]
1017
    land: String,
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>,
1027
) -> Result<Response, Error> {
1028
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1029
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
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" => "",
1034
        _ => return back(&row, Section::Diffusion, None, Some("Pick off, green, any or the site default.".into())),
1035
    };
1036
    let land = match f.land.as_str() {
1037
        "auto" | "propose" => Some(f.land.as_str()),
1038
        "" => None,
1039
        _ => return back(&row, Section::Diffusion, None, Some("Pick propose or auto for landing.".into())),
1040
    };
1041
    db::repos::set_diffusion(&app.db, row.id, setting).await.map_err(|e| Error::from_db(shell.clone(), e))?;
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();
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);
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
    }
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
    }
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
    };
1065
    back(&row, Section::Diffusion, Some(notice.into()), None)
1066
}
1067
1068
#[derive(Deserialize)]
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>,
1085
) -> Result<Response, Error> {
1086
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1087
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
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
    };
1100
    back(&row, Section::Gates, Some(notice.into()), None)
1101
}
1102
1103
#[derive(Deserialize)]
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>,
1120
) -> Result<Response, Error> {
1121
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1122
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
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();
1129
    if on {
1130
        // What was pushed while it was off is scanned now (#277).
1131
        crate::history_scan::wake(&app, row.id);
1132
    }
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
    };
1139
    back(&row, Section::PushProtection, Some(notice.into()), None)
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>,
1159
) -> Result<Response, Error> {
1160
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1161
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
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 {
1175
        return back(&row, Section::PushProtection, None, Some(e.into()));
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.");
1186
    back(&row, Section::PushProtection, Some(notice), None)
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>,
1204
) -> Result<Response, Error> {
1205
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1206
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
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 {
1211
        return back(&row, Section::PushProtection, None, Some(format!("Fingerprint {fingerprint} is not allowed here.")));
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.");
1220
    back(&row, Section::PushProtection, Some(notice), None)
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>,
1240
) -> Result<Response, Error> {
1241
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1242
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
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 {
1253
        return back(&row, Section::PushProtection, None, Some(e.into()));
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 {
1258
        return back(&row, Section::PushProtection, None, Some("That finding is not open here.".into()));
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);
1263
    back(&row, Section::PushProtection, Some(notice), None)
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>,
1280
) -> Result<Response, Error> {
1281
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1282
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
1283
    csrf(&viewer, &f.csrf_token, &shell)?;
1284
    if !row.push_protection {
1285
        return back(
1286
            &row,
1287
            Section::PushProtection,
1288
            None,
1289
            Some("Turn push protection on first: history is scanned only where it is on.".into()),
1290
        );
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.";
1295
    back(&row, Section::PushProtection, Some(notice.into()), None)
1296
}
1297
1298
#[derive(Deserialize)]
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>,
1316
) -> Result<Response, Error> {
1317
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1318
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
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
    };
1331
    back(&row, Section::Runs, Some(notice.into()), None)
1332
}
1333
1334
#[derive(Deserialize)]
1335
pub struct SkipForm {
1336
    #[serde(rename = "_csrf")]
1337
    csrf_token: String,
1338
    /// `any`, `others` or `never`.
1339
    skip: String,
1340
}
1341
1342
/// `POST /{owner}/{repo}/settings/flow-skip` (oxygenhub#284): where a push
1343
/// may skip its run with `[skip flow]` in its message; for maintainers,
1344
/// audited.
1345
pub async fn flow_skip(
1346
    State(app): State<Shared>,
1347
    viewer: Viewer,
1348
    Path((owner, name)): Path<(String, String)>,
1349
    Form(f): Form<SkipForm>,
1350
) -> Result<Response, Error> {
1351
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1352
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
1353
    csrf(&viewer, &f.csrf_token, &shell)?;
1354
    let notice = match f.skip.as_str() {
1355
        "any" => "A push may now skip its run on any bookmark.".to_string(),
1356
        "others" => format!("A push may now skip its run on any bookmark but {}.", row.default_bookmark),
1357
        "never" => "Every push now runs; skip markers are ignored.".to_string(),
1358
        _ => return back(&row, Section::Runs, None, Some("Choose where a push may skip its run.".into())),
1359
    };
1360
    db::repos::set_flow_skip(&app.db, row.id, &f.skip).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1361
    accounts::audit(&app.db, viewer.account(), "repo.flow_skip", &format!("{owner}/{name}"), &f.skip).await.ok();
1362
    back(&row, Section::Runs, Some(notice), None)
1363
}
1364
1365
#[derive(Deserialize)]
1366
pub struct PriorityForm {
1367
    #[serde(rename = "_csrf")]
1368
    csrf_token: String,
1369
    /// `low`, `normal`, `high`, or `default`.
1370
    priority: String,
1371
}
1372
1373
/// `POST /{owner}/{repo}/settings/flow-priority` (oxygenhub#244): its jobs'
1374
/// level. Maintainers choose low, normal or the default; only site
1375
/// administrators raise a repository to high.
1376
pub async fn flow_priority(
1377
    State(app): State<Shared>,
1378
    viewer: Viewer,
1379
    Path((owner, name)): Path<(String, String)>,
1380
    Form(f): Form<PriorityForm>,
1381
) -> Result<Response, Error> {
1382
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1383
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
1384
    csrf(&viewer, &f.csrf_token, &shell)?;
1385
    let level = match f.priority.as_str() {
1386
        "low" | "normal" | "high" => f.priority.as_str(),
1387
        "default" => "",
1388
        _ => return back(&row, Section::Runs, None, Some("Pick low, normal, high or the default.".into())),
1389
    };
1390
    if level == "high" && !viewer.is_admin() {
1391
        return back(&row, Section::Runs, None, Some("Only site administrators raise a repository to high.".into()));
1392
    }
1393
    db::repos::set_flow_priority(&app.db, row.id, level).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1394
    accounts::audit(
1395
        &app.db,
1396
        viewer.account(),
1397
        "repo.flow_priority",
1398
        &format!("{owner}/{name}"),
1399
        if level.is_empty() { "default" } else { level },
1400
    )
1401
    .await
1402
    .ok();
1403
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
1404
    let notice = if level.is_empty() {
1405
        "Its jobs follow the organisation's and the site's priority.".to_string()
1406
    } else {
1407
        format!("Its jobs are {level} priority.")
1408
    };
1409
    back(&row, Section::Runs, Some(notice), None)
1410
}
1411
1412
#[derive(Deserialize)]
1413
pub struct FlowForm {
1414
    #[serde(rename = "_csrf")]
1415
    csrf_token: String,
1416
    /// `on`, `off`, or `default`.
1417
    flow: String,
1418
}
1419
1420
/// Turn OxygenFlow on or off for one repository. Site administrators choose
1421
/// anything; the repository's maintainers may turn it on on their own
1422
/// runners (`own`), when the repository or its owner has one, or off (#225).
1423
/// Only site administrators let the site's runners take its jobs.
1424
pub async fn flow(
1425
    State(app): State<Shared>,
1426
    viewer: Viewer,
1427
    Path((owner, name)): Path<(String, String)>,
1428
    Form(f): Form<FlowForm>,
1429
) -> Result<Response, Error> {
1430
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1431
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
1432
    csrf(&viewer, &f.csrf_token, &shell)?;
1433
    let setting = match f.flow.as_str() {
1434
        "on" => "on",
1435
        "own" => "own",
1436
        "off" => "off",
1437
        "default" => "",
1438
        _ => return back(&row, Section::Pipelines, None, Some("Pick on, own runners, off or the site default.".into())),
1439
    };
1440
    if !viewer.is_admin() {
1441
        if matches!(setting, "on" | "") {
1442
            return back(
1443
                &row,
1444
                Section::Pipelines,
1445
                None,
1446
                Some("Only site administrators let the site's runners take this repository's jobs.".into()),
1447
            );
1448
        }
1449
        if setting == "own" && !has_own_runner(&app, &row).await.map_err(|e| Error::from_db(shell.clone(), e))? {
1450
            return back(
1451
                &row,
1452
                Section::Pipelines,
1453
                None,
1454
                Some("Register a runner for this repository (Settings › Runners) or its owner first.".into()),
1455
            );
1456
        }
1457
    }
1458
    db::repos::set_flow(&app.db, row.id, setting).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1459
    let label = if setting.is_empty() { "default" } else { setting };
1460
    accounts::audit(&app.db, viewer.account(), "repo.flow", &format!("{owner}/{name}"), label).await.ok();
1461
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
1462
    let notice = if row.flow == "own" {
1463
        "OxygenFlow is on, on this repository's own runners: pushes with a .flow/pipeline.kdl start runs that only they take."
1464
    } else if crate::flow::enabled(&app, &row) {
1465
        "OxygenFlow is on: pushes with a .flow/pipeline.kdl start runs."
1466
    } else {
1467
        "OxygenFlow is off for this repository."
1468
    };
1469
    back(&row, Section::Pipelines, Some(notice.into()), None)
1470
}
1471
1472
pub async fn mirror(
1473
    State(app): State<Shared>,
1474
    viewer: Viewer,
1475
    Path((owner, name)): Path<(String, String)>,
1476
    Form(f): Form<MirrorForm>,
1477
) -> Result<Response, Error> {
1478
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1479
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
1480
    csrf(&viewer, &f.csrf_token, &shell)?;
1481
    db::repos::set_git_mirror(&app.db, row.id, f.enabled.is_some()).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1482
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
1483
    if row.git_mirror {
1484
        crate::mirror::refresh(&app, &row).await;
1485
    }
1486
    accounts::audit(&app.db, viewer.account(), "repo.mirror", &format!("{owner}/{name}"), if row.git_mirror { "on" } else { "off" })
1487
        .await
1488
        .ok();
1489
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
1490
    let notice = if row.git_mirror { "The Git mirror is on and up to date." } else { "The Git mirror is off." };
1491
    back(&row, Section::GitMirror, Some(notice.into()), None)
1492
}
1493
1494
/// `POST /{owner}/{name}/settings/runners/token`: a one-time registration
1495
/// token for a runner that serves this repository only (oxygenhub#41).
1496
pub async fn runner_token(
1497
    State(app): State<Shared>,
1498
    viewer: Viewer,
1499
    Path((owner, name)): Path<(String, String)>,
1500
    Form(f): Form<super::session::CsrfForm>,
1501
) -> Result<Page<RepoSettingsPage>, Error> {
1502
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1503
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
1504
    csrf(&viewer, &f.csrf, &shell)?;
1505
    let me = viewer.account().map(|a| a.username.clone()).unwrap_or_default();
1506
    let token = format!("flowreg_{}", crate::auth::random_hex(24));
1507
    let scope = db::runners::Scope { owner: row.owner.clone(), repo_id: Some(row.id) };
1508
    db::runners::add_registration(&app.db, &blake3::hash(token.as_bytes()).to_hex(), &me, &scope)
1509
        .await
1510
        .map_err(|e| Error::from_db(shell.clone(), e))?;
1511
    accounts::audit(&app.db, viewer.account(), "flow.runner.token", &row.slug(), "registration token for this repository").await.ok();
1512
    let mut page =
1513
        settings_page(&app, shell, row, role, Section::Runners, Some("Copy the token now; it is not shown again.".into()), None).await?;
1514
    page.0.runner_token = Some(token);
1515
    Ok(page)
1516
}
1517
1518
/// `POST /{owner}/{name}/settings/runners/{id}/remove`: remove one of this
1519
/// repository's own runners, when it is running nothing.
1520
pub async fn runner_remove(
1521
    State(app): State<Shared>,
1522
    viewer: Viewer,
1523
    Path((owner, name, id)): Path<(String, String, i64)>,
1524
    Form(f): Form<super::session::CsrfForm>,
1525
) -> Result<Response, Error> {
1526
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1527
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
1528
    csrf(&viewer, &f.csrf, &shell)?;
1529
    let e = |e| Error::from_db(shell.clone(), e);
1530
    let Some(r) = db::runners::get(&app.db, id).await.map_err(e)?.filter(|r| r.scope_repo_id == Some(row.id)) else {
1531
        return Err(Error::not_found(shell));
1532
    };
1533
    if db::runners::running(&app.db).await.map_err(e)?.iter().any(|(rid, n)| *rid == id && *n > 0) {
1534
        return back(&row, Section::Runners, None, Some(format!("{} is running jobs: wait for them to finish.", r.name)));
1535
    }
1536
    db::runners::remove(&app.db, id).await.map_err(e)?;
1537
    accounts::audit(&app.db, viewer.account(), "flow.runner.removed", &r.name, &row.slug()).await.ok();
1538
    back(&row, Section::Runners, Some(format!("{} removed: its credential no longer works.", r.name)), None)
1539
}
1540
1541
fn repo_assistants(row: &db::repos::RepoRow) -> crate::levels::At {
1542
    crate::levels::At::repo(row)
1543
}
1544
1545
/// `POST /{owner}/{name}/settings/assistants`: the repository's assistants
1546
/// and prompt (#97), for its admins.
1547
pub async fn assistants(
1548
    State(app): State<Shared>,
1549
    viewer: Viewer,
1550
    Path((owner, name)): Path<(String, String)>,
1551
    Form(f): Form<super::assistants::AssistantForm>,
1552
) -> Result<Response, Error> {
1553
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1554
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
1555
    csrf(&viewer, &f.csrf_token, &shell)?;
1556
    match super::assistants::apply(&app, &viewer, &repo_assistants(&row), f).await.map_err(|e| Error::from_db(shell.clone(), e))? {
1557
        Ok(notice) => back(&row, Section::Assistants, Some(notice), None),
1558
        Err(error) => back(&row, Section::Assistants, None, Some(error)),
1559
    }
1560
}
1561
1562
/// `POST /{owner}/{name}/settings/variables`: the repository's Flow
1563
/// variables (#102), for its admins.
1564
pub async fn variables(
1565
    State(app): State<Shared>,
1566
    viewer: Viewer,
1567
    Path((owner, name)): Path<(String, String)>,
1568
    Form(f): Form<super::variables::VariableForm>,
1569
) -> Result<Response, Error> {
1570
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1571
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
1572
    csrf(&viewer, &f.csrf_token, &shell)?;
1573
    match super::variables::apply(&app, &viewer, &repo_assistants(&row), f).await.map_err(|e| Error::from_db(shell.clone(), e))? {
1574
        Ok(notice) => back(&row, Section::Variables, Some(notice), None),
1575
        Err(error) => back(&row, Section::Variables, None, Some(error)),
1576
    }
1577
}
1578
1579
/// `POST /{owner}/{name}/settings/secrets`: the repository's Flow secrets
1580
/// (#107), for its admins.
1581
pub async fn secrets(
1582
    State(app): State<Shared>,
1583
    viewer: Viewer,
1584
    Path((owner, name)): Path<(String, String)>,
1585
    Form(f): Form<super::secrets::SecretForm>,
1586
) -> Result<Response, Error> {
1587
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1588
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
1589
    csrf(&viewer, &f.csrf_token, &shell)?;
1590
    match super::secrets::apply(&app, &viewer, &repo_assistants(&row), f).await.map_err(|e| Error::from_db(shell.clone(), e))? {
1591
        Ok(notice) => back(&row, Section::Secrets, Some(notice), None),
1592
        Err(error) => back(&row, Section::Secrets, None, Some(error)),
1593
    }
1594
}
1595
1596
/// `POST /{owner}/{name}/settings/assistants/models`: what a provider offers (#109).
1597
pub async fn assistant_models(
1598
    State(app): State<Shared>,
1599
    viewer: Viewer,
1600
    Path((owner, name)): Path<(String, String)>,
1601
    Form(f): Form<super::assistants::ModelsForm>,
1602
) -> Result<axum::Json<serde_json::Value>, Error> {
1603
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1604
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
1605
    csrf(&viewer, &f.csrf_token, &shell)?;
1606
    Ok(axum::Json(super::assistants::models(&app, &repo_assistants(&row), f).await.map_err(|e| Error::from_db(shell.clone(), e))?))
1607
}
1608
1609
/// Whether a runner serves this repository that is not the site's: one
1610
/// scoped to it or to its owner (#225).
1611
async fn has_own_runner(app: &Shared, row: &db::repos::RepoRow) -> sqlx::Result<bool> {
1612
    Ok(db::runners::serving(&app.db, &row.owner, row.id).await?.iter().any(|r| !r.scope().is_site()))
1613
}