OXYGENHUB
# b1860d6090ec 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
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_async(&name, None).await.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 (repo, 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
    // 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
    }
548
    let topics = match db::repos::parse_topics(&f.topics) {
549
        Ok(t) => t,
550
        Err(msg) => return back(&row, Section::General, None, Some(msg)),
551
    };
552
    db::repos::set_description(&app.db, row.id, f.description.trim()).await.map_err(e)?;
553
    db::repos::set_topics(&app.db, row.id, &topics).await.map_err(e)?;
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);
564
    back(&row, Section::General, Some("Saved.".into()), None)
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>,
586
) -> Result<Response, Error> {
587
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
588
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
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),
594
            None => return back(&row, Section::Collaborators, None, Some("No such account.".into())),
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)),
604
            None => return back(&row, Section::Collaborators, None, Some("No such team in this organisation.".into())),
605
        }
606
    } else {
607
        return back(&row, Section::Collaborators, None, Some("Name an account or a team.".into()));
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();
612
        return back(&row, Section::Collaborators, Some(format!("Removed {label}.")), None);
613
    }
614
    let Some(new_role) = Role::parse(&f.role) else {
615
        return back(&row, Section::Collaborators, None, Some("Pick a role.".into()));
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();
627
    back(&row, Section::Collaborators, Some(format!("{label} now has {} access.", new_role.as_str())), None)
628
}
629
630
#[derive(Deserialize)]
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);
648
    let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
649
    csrf(&viewer, &f.csrf_token, &shell)?;
650
    let me = viewer.account().cloned().ok_or_else(|| Error::not_found(shell.clone()))?;
651
    let fail = |msg: &str| back(&row, Section::Advanced, None, Some(msg.into()));
652
    if f.confirm.trim() != repo.to_string() {
653
        return fail("Type the repository's full name to confirm.");
654
    }
655
    let to = f.owner.trim().trim_start_matches('@').to_string();
656
    let Some(dest) = RepoName::new(&to, &row.name) else {
657
        return fail("That is not an owner name.");
658
    };
659
    if dest.owner == row.owner {
660
        return fail("The repository is already there.");
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 {
666
        return fail("There is no user or organisation of that name.");
667
    }
668
    if !roles::may_create_under(&app.db, &me, &dest.owner).await.map_err(e)? {
669
        return fail("You cannot create repositories under that owner, so you cannot move one there.");
670
    }
671
    if db::repos::get(&app.db, &dest.owner, &dest.name).await.map_err(e)?.is_some() || app.repos.exists(&dest) {
672
        return fail("That owner already has a repository of this name.");
673
    }
674
    {
675
        let _guard = app.repos.exclusive_guard(&repo).await;
676
        app.repos.rename(&repo, &dest).map_err(|e| Error::from_repo(shell.clone(), e))?;
677
        crate::storage::renamed(&app, &repo, &dest);
678
        if app.wikis.exists(&repo) {
679
            app.wikis.rename(&repo, &dest).ok();
680
        }
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."));
693
    Ok(Redirect::to(&format!("{}/settings/{}?notice={notice}", dest.url(), Section::Advanced.slug())).into_response())
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>,
713
) -> Result<Response, Error> {
714
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
715
    let (repo, mut row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
716
    csrf(&viewer, &f.csrf_token, &shell)?;
717
    let archive = match f.action.as_str() {
718
        "archive" => true,
719
        "unarchive" => false,
720
        _ => return back(&row, Section::Advanced, None, Some("Unknown action.".into())),
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
    };
734
    back(&row, Section::Advanced, Some(notice.into()), None)
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>,
743
) -> Result<Response, Error> {
744
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
745
    let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
746
    csrf(&viewer, &f.csrf_token, &shell)?;
747
    if f.confirm.trim() != repo.to_string() {
748
        return back(&row, Section::Advanced, None, Some("Type the repository's full name to confirm.".into()));
749
    }
750
    if !crate::wiki::exists(&app, &repo) {
751
        return back(&row, Section::Advanced, None, Some("This repository has no wiki.".into()));
752
    }
753
    let _guard = app.wikis.exclusive_guard(&repo).await;
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();
757
    back(&row, Section::Advanced, Some("The wiki is in the trash; a new first page starts a fresh one.".into()), None)
758
}
759
760
#[derive(Deserialize)]
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);
774
    let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
775
    csrf(&viewer, &f.csrf_token, &shell)?;
776
    if f.confirm.trim() != repo.to_string() {
777
        return back(&row, Section::Advanced, None, Some("Type the repository's full name to delete it.".into()));
778
    }
779
    let _guard = app.repos.exclusive_guard(&repo).await;
780
    app.repos.trash(&repo).map_err(|e| Error::from_repo(shell.clone(), e))?;
781
    // A repository kept in a bucket leaves its data there: the owner's.
782
    crate::storage::removed(&app, &repo);
783
    crate::wiki::trash(&app, &repo).ok();
784
    db::repos::delete(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
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();
787
    crate::secrets::delete_scope(&app.db, crate::levels::Scope::Repo(row.id)).await.ok();
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
}
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>,
809
) -> Result<Response, Error> {
810
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
811
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
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();
828
    back(&row, Section::Landing, Some("Landing requirements saved.".into()), None)
829
}
830
831
pub async fn hooks(
832
    State(app): State<Shared>,
833
    viewer: Viewer,
834
    Path((owner, name)): Path<(String, String)>,
835
    Form(f): Form<super::webhooks::HookForm>,
836
) -> Result<Response, Error> {
837
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
838
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
839
    csrf(&viewer, &f.csrf_token, &shell)?;
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))? {
842
        Ok(notice) => back(&row, Section::Webhooks, Some(notice), None),
843
        Err(error) => back(&row, Section::Webhooks, None, Some(error)),
844
    }
845
}
846
847
pub async fn deliveries(
848
    State(app): State<Shared>,
849
    viewer: Viewer,
850
    Path((owner, name, id)): Path<(String, String, i64)>,
851
) -> Result<Page<super::webhooks::DeliveriesPage>, Error> {
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?;
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
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>,
863
) -> Result<Page<super::webhooks::DeliveriesPage>, Error> {
864
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings/hooks/{id}/deliveries"), &viewer);
865
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
866
    csrf(&viewer, &f.csrf, &shell)?;
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
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>,
885
) -> Result<Response, Error> {
886
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
887
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
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))?;
891
    back(&row, Section::Checks, Some("Required checks saved.".into()), None)
892
}
893
894
#[derive(Deserialize)]
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>,
910
) -> Result<Response, Error> {
911
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
912
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
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 },
926
        (Err(e), _) | (_, Err(e)) => return back(&row, Section::Coverage, None, Some(e)),
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();
938
    back(&row, Section::Coverage, Some("Coverage rules saved; they apply from the next report.".into()), None)
939
}
940
941
#[derive(Deserialize)]
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>,
958
) -> Result<Response, Error> {
959
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
960
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
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 {
963
        return back(&row, Section::General, None, Some("This repository is kept on this server's disk.".into()));
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();
976
            back(&row, Section::Storage, Some(notice), None)
977
        }
978
        Err(e) => back(&row, Section::Storage, None, Some(format!("Storage: {e}"))),
979
    }
980
}
981
982
#[derive(Deserialize)]
983
pub struct MirrorForm {
984
    #[serde(rename = "_csrf")]
985
    csrf_token: String,
986
    #[serde(default)]
987
    enabled: Option<String>,
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>,
1006
) -> Result<Response, Error> {
1007
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1008
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
1009
    csrf(&viewer, &f.csrf_token, &shell)?;
1010
    if !crate::backups::REPO_CHOICES.iter().any(|(v, _)| *v == f.backup) {
1011
        return back(&row, Section::Backup, None, Some("Pick how this repository is backed up.".into()));
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
    };
1024
    back(&row, Section::Backup, Some(notice), None)
1025
}
1026
1027
#[derive(Deserialize)]
1028
pub struct DiffusionForm {
1029
    #[serde(rename = "_csrf")]
1030
    csrf_token: String,
1031
    /// `off`, `green`, `any`, or `default`.
1032
    diffusion: String,
1033
    /// `auto` or `propose` (absent keeps it as it is).
1034
    #[serde(default)]
1035
    land: String,
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>,
1045
) -> Result<Response, Error> {
1046
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1047
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
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" => "",
1052
        _ => return back(&row, Section::Diffusion, None, Some("Pick off, green, any or the site default.".into())),
1053
    };
1054
    let land = match f.land.as_str() {
1055
        "auto" | "propose" => Some(f.land.as_str()),
1056
        "" => None,
1057
        _ => return back(&row, Section::Diffusion, None, Some("Pick propose or auto for landing.".into())),
1058
    };
1059
    db::repos::set_diffusion(&app.db, row.id, setting).await.map_err(|e| Error::from_db(shell.clone(), e))?;
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();
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);
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
    }
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
    }
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
    };
1083
    back(&row, Section::Diffusion, Some(notice.into()), None)
1084
}
1085
1086
#[derive(Deserialize)]
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>,
1103
) -> Result<Response, Error> {
1104
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1105
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
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
    };
1118
    back(&row, Section::Gates, Some(notice.into()), None)
1119
}
1120
1121
#[derive(Deserialize)]
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>,
1138
) -> Result<Response, Error> {
1139
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1140
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
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();
1147
    if on {
1148
        // What was pushed while it was off is scanned now (#277).
1149
        crate::history_scan::wake(&app, row.id);
1150
    }
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
    };
1157
    back(&row, Section::PushProtection, Some(notice.into()), None)
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>,
1177
) -> Result<Response, Error> {
1178
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1179
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
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 {
1193
        return back(&row, Section::PushProtection, None, Some(e.into()));
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.");
1204
    back(&row, Section::PushProtection, Some(notice), None)
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>,
1222
) -> Result<Response, Error> {
1223
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1224
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
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 {
1229
        return back(&row, Section::PushProtection, None, Some(format!("Fingerprint {fingerprint} is not allowed here.")));
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.");
1238
    back(&row, Section::PushProtection, Some(notice), None)
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>,
1258
) -> Result<Response, Error> {
1259
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1260
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
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 {
1271
        return back(&row, Section::PushProtection, None, Some(e.into()));
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 {
1276
        return back(&row, Section::PushProtection, None, Some("That finding is not open here.".into()));
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);
1281
    back(&row, Section::PushProtection, Some(notice), None)
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>,
1298
) -> Result<Response, Error> {
1299
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1300
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
1301
    csrf(&viewer, &f.csrf_token, &shell)?;
1302
    if !row.push_protection {
1303
        return back(
1304
            &row,
1305
            Section::PushProtection,
1306
            None,
1307
            Some("Turn push protection on first: history is scanned only where it is on.".into()),
1308
        );
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.";
1313
    back(&row, Section::PushProtection, Some(notice.into()), None)
1314
}
1315
1316
#[derive(Deserialize)]
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>,
1334
) -> Result<Response, Error> {
1335
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1336
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
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
    };
1349
    back(&row, Section::Runs, Some(notice.into()), None)
1350
}
1351
1352
#[derive(Deserialize)]
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)]
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)]
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>,
1431
) -> Result<Response, Error> {
1432
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1433
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
1434
    csrf(&viewer, &f.csrf_token, &shell)?;
1435
    let level = match f.priority.as_str() {
1436
        "low" | "normal" | "high" => f.priority.as_str(),
1437
        "default" => "",
1438
        _ => return back(&row, Section::Runs, None, Some("Pick low, normal, high or the default.".into())),
1439
    };
1440
    if level == "high" && !viewer.is_admin() {
1441
        return back(&row, Section::Runs, None, Some("Only site administrators raise a repository to high.".into()));
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
    };
1459
    back(&row, Section::Runs, Some(notice), None)
1460
}
1461
1462
#[derive(Deserialize)]
1463
pub struct FlowForm {
1464
    #[serde(rename = "_csrf")]
1465
    csrf_token: String,
1466
    /// `on`, `off`, or `default`.
1467
    flow: String,
1468
}
1469
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.
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>,
1479
) -> Result<Response, Error> {
1480
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1481
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
1482
    csrf(&viewer, &f.csrf_token, &shell)?;
1483
    let setting = match f.flow.as_str() {
1484
        "on" => "on",
1485
        "own" => "own",
1486
        "off" => "off",
1487
        "default" => "",
1488
        _ => return back(&row, Section::Pipelines, None, Some("Pick on, own runners, off or the site default.".into())),
1489
    };
1490
    if !viewer.is_admin() {
1491
        if matches!(setting, "on" | "") {
1492
            return back(
1493
                &row,
1494
                Section::Pipelines,
1495
                None,
1496
                Some("Only site administrators let the site's runners take this repository's jobs.".into()),
1497
            );
1498
        }
1499
        if setting == "own" && !has_own_runner(&app, &row).await.map_err(|e| Error::from_db(shell.clone(), e))? {
1500
            return back(
1501
                &row,
1502
                Section::Pipelines,
1503
                None,
1504
                Some("Register a runner for this repository (Settings › Runners) or its owner first.".into()),
1505
            );
1506
        }
1507
    }
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);
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) {
1515
        "OxygenFlow is on: pushes with a .flow/pipeline.kdl start runs."
1516
    } else {
1517
        "OxygenFlow is off for this repository."
1518
    };
1519
    back(&row, Section::Pipelines, Some(notice.into()), None)
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>,
1527
) -> Result<Response, Error> {
1528
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1529
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
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 {
1534
        crate::mirror::refresh(&app, &row).await.ok();
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." };
1541
    back(&row, Section::GitMirror, Some(notice.into()), None)
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();
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?;
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>,
1575
) -> Result<Response, Error> {
1576
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1577
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
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) {
1584
        return back(&row, Section::Runners, None, Some(format!("{} is running jobs: wait for them to finish.", r.name)));
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();
1588
    back(&row, Section::Runners, Some(format!("{} removed: its credential no longer works.", r.name)), None)
1589
}
1590
1591
fn repo_assistants(row: &db::repos::RepoRow) -> crate::levels::At {
1592
    crate::levels::At::repo(row)
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>,
1602
) -> Result<Response, 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
    match super::assistants::apply(&app, &viewer, &repo_assistants(&row), f).await.map_err(|e| Error::from_db(shell.clone(), e))? {
1607
        Ok(notice) => back(&row, Section::Assistants, Some(notice), None),
1608
        Err(error) => back(&row, Section::Assistants, None, Some(error)),
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>,
1619
) -> Result<Response, Error> {
1620
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1621
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
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))? {
1624
        Ok(notice) => back(&row, Section::Variables, Some(notice), None),
1625
        Err(error) => back(&row, Section::Variables, None, Some(error)),
1626
    }
1627
}
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>,
1636
) -> Result<Response, Error> {
1637
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1638
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
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))? {
1641
        Ok(notice) => back(&row, Section::Secrets, Some(notice), None),
1642
        Err(error) => back(&row, Section::Secrets, None, Some(error)),
1643
    }
1644
}
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))?))
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()))
1663
}