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