OXYGENHUB
# c30444e35f2e oxygenhub / crates / hub / src / web / repo_settings.rs
51.4 KiB · Rust · last changed Newest run wins: a proposal's new run supersedes its older ones 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
}
200
201
impl RepoSettingsPage {
202
    fn backup_choices(&self) -> &'static [(&'static str, &'static str)] {
203
        crate::backups::REPO_CHOICES
204
    }
205
}
206
207
async fn settings_page(
208
    app: &Shared,
209
    shell: Shell,
210
    row: db::repos::RepoRow,
211
    role: Role,
212
    notice: Option<String>,
213
    error: Option<String>,
214
) -> Result<Page<RepoSettingsPage>, Error> {
215
    let collaborators = accounts::collaborators(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
216
    let teams = match accounts::org_by_name(&app.db, &row.owner).await.map_err(|e| Error::from_db(shell.clone(), e))? {
217
        Some(org) => accounts::teams(&app.db, org.id).await.map_err(|e| Error::from_db(shell.clone(), e))?,
218
        None => vec![],
219
    };
220
    let repo = RepoShell::new(app, &row, "settings").with_role(role);
221
    let requirements = db::proposals::requirements(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
222
    let hooks = db::hooks::hooks_for_repo(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
223
    let hooks = super::webhooks::rows(app, hooks).await.map_err(|e| Error::from_db(shell.clone(), e))?;
224
    let required_checks = db::hooks::required_checks(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.join(", ");
225
    let flow_default = app.config.flow.allows(&row.owner, &row.name);
226
    let diffusion_site = app.config.diffusion.follow.clone();
227
    let flow_enabled = crate::flow::enabled(app, &row);
228
    let site_admin = shell.is_admin;
229
    let own_runner = has_own_runner(app, &row).await.map_err(|e| Error::from_db(shell.clone(), e))?;
230
    let runners = db::runners::serving(&app.db, &row.owner, row.id)
231
        .await
232
        .map_err(|e| Error::from_db(shell.clone(), e))?
233
        .into_iter()
234
        .map(|r| {
235
            let own = r.scope_repo_id == Some(row.id);
236
            let serves = if own {
237
                "this repository".to_string()
238
            } else if r.scope_owner.is_empty() {
239
                "the whole site".to_string()
240
            } else {
241
                format!("{}'s repositories", r.scope_owner)
242
            };
243
            (r, serves, own)
244
        })
245
        .collect();
246
    let assistants = super::assistants::panel(app, &repo_assistants(&row)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
247
    let variables = super::variables::panel(app, &repo_assistants(&row)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
248
    let secrets = super::secrets::panel(app, &repo_assistants(&row)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
249
    let storage = crate::storage::of(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
250
    let coverage = crate::coverage::rules(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
251
    let backup_owner = crate::backups::setting(&app.db, &row.owner).await.map_err(|e| Error::from_db(shell.clone(), e))?;
252
    Ok(Page(RepoSettingsPage {
253
        backup_owner,
254
        coverage,
255
        storage,
256
        assistants,
257
        variables,
258
        secrets,
259
        runners,
260
        runner_token: None,
261
        shell,
262
        repo,
263
        row,
264
        collaborators,
265
        teams,
266
        role,
267
        requirements,
268
        hooks,
269
        required_checks,
270
        flow_default,
271
        diffusion_site,
272
        flow_enabled,
273
        site_admin,
274
        own_runner,
275
        notice,
276
        error,
277
    }))
278
}
279
280
pub async fn settings(
281
    State(app): State<Shared>,
282
    viewer: Viewer,
283
    Path((owner, name)): Path<(String, String)>,
284
) -> Result<Page<RepoSettingsPage>, Error> {
285
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
286
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
287
    settings_page(&app, shell, row, role, None, None).await
288
}
289
290
#[derive(Deserialize)]
291
pub struct GeneralForm {
292
    #[serde(rename = "_csrf")]
293
    csrf_token: String,
294
    description: String,
295
    default_bookmark: String,
296
    visibility: String,
297
    #[serde(default)]
298
    topics: String,
299
}
300
301
pub async fn update(
302
    State(app): State<Shared>,
303
    viewer: Viewer,
304
    Path((owner, name)): Path<(String, String)>,
305
    Form(f): Form<GeneralForm>,
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
    csrf(&viewer, &f.csrf_token, &shell)?;
310
    let e = |e| Error::from_db(shell.clone(), e);
311
    let topics = match db::repos::parse_topics(&f.topics) {
312
        Ok(t) => t,
313
        Err(msg) => return settings_page(&app, shell, row, role, None, Some(msg)).await,
314
    };
315
    db::repos::set_description(&app.db, row.id, f.description.trim()).await.map_err(e)?;
316
    db::repos::set_topics(&app.db, row.id, &topics).await.map_err(e)?;
317
    if crate::repos::names::valid_segment(f.default_bookmark.trim()) {
318
        db::repos::set_default_bookmark(&app.db, row.id, f.default_bookmark.trim()).await.map_err(e)?;
319
    }
320
    if role >= Role::Admin {
321
        if let Some(v) = Visibility::parse(&f.visibility) {
322
            db::repos::set_visibility(&app.db, row.id, v).await.map_err(e)?;
323
        }
324
    }
325
    accounts::audit(&app.db, viewer.account(), "repo.updated", &format!("{owner}/{name}"), "").await.ok();
326
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(e)?.unwrap_or(row);
327
    settings_page(&app, shell, row, role, Some("Saved.".into()), None).await
328
}
329
330
#[derive(Deserialize)]
331
pub struct CollaboratorForm {
332
    #[serde(rename = "_csrf")]
333
    csrf_token: String,
334
    #[serde(default)]
335
    username: String,
336
    #[serde(default)]
337
    team: String,
338
    #[serde(default)]
339
    role: String,
340
    #[serde(default)]
341
    remove: Option<String>,
342
}
343
344
pub async fn collaborator(
345
    State(app): State<Shared>,
346
    viewer: Viewer,
347
    Path((owner, name)): Path<(String, String)>,
348
    Form(f): Form<CollaboratorForm>,
349
) -> Result<Page<RepoSettingsPage>, Error> {
350
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
351
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
352
    csrf(&viewer, &f.csrf_token, &shell)?;
353
    let e = |e| Error::from_db(shell.clone(), e);
354
    let (account_id, team_id, label) = if !f.username.trim().is_empty() {
355
        match accounts::account_by_name(&app.db, f.username.trim()).await.map_err(e)? {
356
            Some(a) => (Some(a.id), None, a.username),
357
            None => return settings_page(&app, shell, row, role, None, Some("No such account.".into())).await,
358
        }
359
    } else if !f.team.trim().is_empty() {
360
        let org = accounts::org_by_name(&app.db, &row.owner).await.map_err(e)?;
361
        let team = match org {
362
            Some(o) => accounts::team_by_name(&app.db, o.id, f.team.trim()).await.map_err(e)?,
363
            None => None,
364
        };
365
        match team {
366
            Some(t) => (None, Some(t.id), format!("team {}", t.name)),
367
            None => return settings_page(&app, shell, row, role, None, Some("No such team in this organisation.".into())).await,
368
        }
369
    } else {
370
        return settings_page(&app, shell, row, role, None, Some("Name an account or a team.".into())).await;
371
    };
372
    if f.remove.is_some() {
373
        accounts::remove_collaborator(&app.db, row.id, account_id, team_id).await.map_err(e)?;
374
        accounts::audit(&app.db, viewer.account(), "repo.collaborator.removed", &format!("{owner}/{name}"), &label).await.ok();
375
        return settings_page(&app, shell, row, role, Some(format!("Removed {label}.")), None).await;
376
    }
377
    let Some(new_role) = Role::parse(&f.role) else {
378
        return settings_page(&app, shell, row, role, None, Some("Pick a role.".into())).await;
379
    };
380
    accounts::set_collaborator(&app.db, row.id, account_id, team_id, new_role.as_str()).await.map_err(e)?;
381
    accounts::audit(
382
        &app.db,
383
        viewer.account(),
384
        "repo.collaborator.set",
385
        &format!("{owner}/{name}"),
386
        &format!("{label} {}", new_role.as_str()),
387
    )
388
    .await
389
    .ok();
390
    settings_page(&app, shell, row, role, Some(format!("{label} now has {} access.", new_role.as_str())), None).await
391
}
392
393
#[derive(Deserialize)]
394
pub struct TransferForm {
395
    #[serde(rename = "_csrf")]
396
    csrf_token: String,
397
    owner: String,
398
    confirm: String,
399
}
400
401
/// `POST /{owner}/{name}/settings/transfer`: move the repository to a
402
/// user or organisation the admin may create repositories under (#108).
403
/// The old URL redirects and oxy remotes keep working.
404
pub async fn transfer(
405
    State(app): State<Shared>,
406
    viewer: Viewer,
407
    Path((owner, name)): Path<(String, String)>,
408
    Form(f): Form<TransferForm>,
409
) -> Result<Response, Error> {
410
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
411
    let (repo, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
412
    csrf(&viewer, &f.csrf_token, &shell)?;
413
    let me = viewer.account().cloned().ok_or_else(|| Error::not_found(shell.clone()))?;
414
    let fail = |msg: &str| settings_page(&app, shell.clone(), row.clone(), role, None, Some(msg.into()));
415
    if f.confirm.trim() != repo.to_string() {
416
        return Ok(fail("Type the repository's full name to confirm.").await?.into_response());
417
    }
418
    let to = f.owner.trim().trim_start_matches('@').to_string();
419
    let Some(dest) = RepoName::new(&to, &row.name) else {
420
        return Ok(fail("That is not an owner name.").await?.into_response());
421
    };
422
    if dest.owner == row.owner {
423
        return Ok(fail("The repository is already there.").await?.into_response());
424
    }
425
    let e = |e| Error::from_db(shell.clone(), e);
426
    let known = accounts::account_by_name(&app.db, &dest.owner).await.map_err(e)?.is_some()
427
        || accounts::org_by_name(&app.db, &dest.owner).await.map_err(e)?.is_some();
428
    if !known {
429
        return Ok(fail("There is no user or organisation of that name.").await?.into_response());
430
    }
431
    if !roles::may_create_under(&app.db, &me, &dest.owner).await.map_err(e)? {
432
        return Ok(fail("You cannot create repositories under that owner, so you cannot move one there.").await?.into_response());
433
    }
434
    if db::repos::get(&app.db, &dest.owner, &dest.name).await.map_err(e)?.is_some() || app.repos.exists(&dest) {
435
        return Ok(fail("That owner already has a repository of this name.").await?.into_response());
436
    }
437
    {
438
        let _guard = app.repos.exclusive_guard(&repo).await;
439
        app.repos.rename(&repo, &dest).map_err(|e| Error::from_repo(shell.clone(), e))?;
440
        crate::storage::renamed(&app, &repo, &dest);
441
        if app.wikis.exists(&repo) {
442
            app.wikis.rename(&repo, &dest).ok();
443
        }
444
        db::repos::transfer(&app.db, row.id, &row.owner, &row.name, &dest.owner).await.map_err(e)?;
445
        let mirror = crate::mirror::mirror_dir(&app, &repo);
446
        if mirror.is_dir() {
447
            let to = crate::mirror::mirror_dir(&app, &dest);
448
            if let Some(parent) = to.parent() {
449
                std::fs::create_dir_all(parent).ok();
450
            }
451
            std::fs::rename(&mirror, &to).ok();
452
        }
453
    }
454
    accounts::audit(&app.db, Some(&me), "repo.transferred", &repo.to_string(), &dest.to_string()).await.ok();
455
    let notice = super::settings::urlencode(&format!("Moved from {repo}; the old address redirects here."));
456
    Ok(Redirect::to(&format!("{}/settings?notice={notice}", dest.url())).into_response())
457
}
458
459
#[derive(Deserialize)]
460
pub struct ArchiveForm {
461
    #[serde(rename = "_csrf")]
462
    csrf_token: String,
463
    /// `archive` or `unarchive`.
464
    action: String,
465
    #[serde(default)]
466
    reason: String,
467
}
468
469
/// `POST /{owner}/{name}/settings/archive`: read-only, with a reason, or
470
/// back again (#108).
471
pub async fn archive(
472
    State(app): State<Shared>,
473
    viewer: Viewer,
474
    Path((owner, name)): Path<(String, String)>,
475
    Form(f): Form<ArchiveForm>,
476
) -> Result<Page<RepoSettingsPage>, Error> {
477
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
478
    let (repo, mut row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
479
    csrf(&viewer, &f.csrf_token, &shell)?;
480
    let archive = match f.action.as_str() {
481
        "archive" => true,
482
        "unarchive" => false,
483
        _ => return settings_page(&app, shell, row, role, None, Some("Unknown action.".into())).await,
484
    };
485
    let reason: String = f.reason.trim().chars().take(500).collect();
486
    db::repos::set_archived(&app.db, row.id, archive, &reason).await.map_err(|e| Error::from_db(shell.clone(), e))?;
487
    accounts::audit(&app.db, viewer.account(), if archive { "repo.archived" } else { "repo.unarchived" }, &repo.to_string(), &reason)
488
        .await
489
        .ok();
490
    row.archived = archive;
491
    row.archived_reason = if archive { reason } else { String::new() };
492
    let notice = if archive {
493
        "The repository is archived: read-only until it is brought back."
494
    } else {
495
        "The repository is back: writes are allowed again."
496
    };
497
    settings_page(&app, shell, row, role, Some(notice.into()), None).await
498
}
499
500
/// `POST /{owner}/{name}/settings/wiki/delete`: the wiki to the trash (#104).
501
pub async fn wiki_delete(
502
    State(app): State<Shared>,
503
    viewer: Viewer,
504
    Path((owner, name)): Path<(String, String)>,
505
    Form(f): Form<DeleteForm>,
506
) -> Result<Page<RepoSettingsPage>, Error> {
507
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
508
    let (repo, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
509
    csrf(&viewer, &f.csrf_token, &shell)?;
510
    if f.confirm.trim() != repo.to_string() {
511
        return settings_page(&app, shell, row, role, None, Some("Type the repository's full name to confirm.".into())).await;
512
    }
513
    if !crate::wiki::exists(&app, &repo) {
514
        return settings_page(&app, shell, row, role, None, Some("This repository has no wiki.".into())).await;
515
    }
516
    let _guard = app.wikis.exclusive_guard(&repo).await;
517
    crate::wiki::trash(&app, &repo).map_err(|e| Error::from_repo(shell.clone(), e))?;
518
    drop(_guard);
519
    accounts::audit(&app.db, viewer.account(), "wiki.data_deleted", &repo.to_string(), "moved to trash").await.ok();
520
    settings_page(&app, shell, row, role, Some("The wiki is in the trash; a new first page starts a fresh one.".into()), None).await
521
}
522
523
#[derive(Deserialize)]
524
pub struct DeleteForm {
525
    #[serde(rename = "_csrf")]
526
    csrf_token: String,
527
    confirm: String,
528
}
529
530
pub async fn delete(
531
    State(app): State<Shared>,
532
    viewer: Viewer,
533
    Path((owner, name)): Path<(String, String)>,
534
    Form(f): Form<DeleteForm>,
535
) -> Result<Response, Error> {
536
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
537
    let (repo, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
538
    csrf(&viewer, &f.csrf_token, &shell)?;
539
    if f.confirm.trim() != repo.to_string() {
540
        return Ok(settings_page(&app, shell, row, role, None, Some("Type the repository's full name to delete it.".into()))
541
            .await?
542
            .into_response());
543
    }
544
    let _guard = app.repos.exclusive_guard(&repo).await;
545
    app.repos.trash(&repo).map_err(|e| Error::from_repo(shell.clone(), e))?;
546
    // A repository kept in a bucket leaves its data there: the owner's.
547
    crate::storage::removed(&app, &repo);
548
    crate::wiki::trash(&app, &repo).ok();
549
    db::repos::delete(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
550
    crate::assistants::delete_scope(&app.db, crate::levels::Scope::Repo(row.id)).await.ok();
551
    crate::variables::delete_scope(&app.db, crate::levels::Scope::Repo(row.id)).await.ok();
552
    crate::secrets::delete_scope(&app.db, crate::levels::Scope::Repo(row.id)).await.ok();
553
    accounts::audit(&app.db, viewer.account(), "repo.deleted", &repo.to_string(), "moved to trash").await.ok();
554
    Ok(Redirect::to(&format!("/{owner}")).into_response())
555
}
556
557
#[derive(Deserialize)]
558
pub struct RequirementsForm {
559
    #[serde(rename = "_csrf")]
560
    csrf_token: String,
561
    #[serde(default)]
562
    required_approvals: i64,
563
    #[serde(default)]
564
    require_owners: Option<String>,
565
    #[serde(default)]
566
    allow_conflicts: Option<String>,
567
}
568
569
pub async fn requirements(
570
    State(app): State<Shared>,
571
    viewer: Viewer,
572
    Path((owner, name)): Path<(String, String)>,
573
    Form(f): Form<RequirementsForm>,
574
) -> Result<Page<RepoSettingsPage>, Error> {
575
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
576
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
577
    csrf(&viewer, &f.csrf_token, &shell)?;
578
    let r = db::proposals::Requirements {
579
        required_approvals: f.required_approvals.max(0),
580
        require_owners: f.require_owners.is_some(),
581
        allow_conflicts: f.allow_conflicts.is_some(),
582
    };
583
    db::proposals::set_requirements(&app.db, row.id, r).await.map_err(|e| Error::from_db(shell.clone(), e))?;
584
    accounts::audit(
585
        &app.db,
586
        viewer.account(),
587
        "repo.requirements",
588
        &format!("{owner}/{name}"),
589
        &format!("{} approvals, owners {}, conflicts {}", r.required_approvals, r.require_owners, r.allow_conflicts),
590
    )
591
    .await
592
    .ok();
593
    settings_page(&app, shell, row, role, Some("Landing requirements saved.".into()), None).await
594
}
595
596
pub async fn hooks(
597
    State(app): State<Shared>,
598
    viewer: Viewer,
599
    Path((owner, name)): Path<(String, String)>,
600
    Form(f): Form<super::webhooks::HookForm>,
601
) -> Result<Page<RepoSettingsPage>, Error> {
602
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
603
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
604
    csrf(&viewer, &f.csrf_token, &shell)?;
605
    let scope = super::webhooks::Scope::Repo { id: row.id, slug: format!("{owner}/{name}") };
606
    match super::webhooks::apply(&app, &viewer, &scope, f).await.map_err(|e| Error::from_db(shell.clone(), e))? {
607
        Ok(notice) => settings_page(&app, shell, row, role, Some(notice), None).await,
608
        Err(error) => settings_page(&app, shell, row, role, None, Some(error)).await,
609
    }
610
}
611
612
pub async fn deliveries(
613
    State(app): State<Shared>,
614
    viewer: Viewer,
615
    Path((owner, name, id)): Path<(String, String, i64)>,
616
) -> Result<Page<super::webhooks::DeliveriesPage>, Error> {
617
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings/hooks/{id}/deliveries"), &viewer);
618
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
619
    let scope = super::webhooks::Scope::Repo { id: row.id, slug: format!("{owner}/{name}") };
620
    super::webhooks::deliveries_page(&app, shell, &scope, Some(RepoShell::new(&app, &row, "settings").with_role(role)), id).await
621
}
622
623
pub async fn redeliver(
624
    State(app): State<Shared>,
625
    viewer: Viewer,
626
    Path((owner, name, id, delivery)): Path<(String, String, i64, i64)>,
627
    Form(f): Form<super::session::CsrfForm>,
628
) -> Result<Page<super::webhooks::DeliveriesPage>, Error> {
629
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings/hooks/{id}/deliveries"), &viewer);
630
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
631
    csrf(&viewer, &f.csrf, &shell)?;
632
    let scope = super::webhooks::Scope::Repo { id: row.id, slug: format!("{owner}/{name}") };
633
    super::webhooks::redeliver(&app, &scope, id, delivery).await.map_err(|e| Error::from_db(shell.clone(), e))?;
634
    super::webhooks::deliveries_page(&app, shell, &scope, Some(RepoShell::new(&app, &row, "settings").with_role(role)), id).await
635
}
636
637
#[derive(Deserialize)]
638
pub struct ChecksForm {
639
    #[serde(rename = "_csrf")]
640
    csrf_token: String,
641
    #[serde(default)]
642
    required_checks: String,
643
}
644
645
pub async fn required_checks(
646
    State(app): State<Shared>,
647
    viewer: Viewer,
648
    Path((owner, name)): Path<(String, String)>,
649
    Form(f): Form<ChecksForm>,
650
) -> Result<Page<RepoSettingsPage>, Error> {
651
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
652
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
653
    csrf(&viewer, &f.csrf_token, &shell)?;
654
    let names: Vec<String> = f.required_checks.split(',').map(str::trim).filter(|s| !s.is_empty()).map(String::from).collect();
655
    db::hooks::set_required_checks(&app.db, row.id, &names).await.map_err(|e| Error::from_db(shell.clone(), e))?;
656
    settings_page(&app, shell, row, role, Some("Required checks saved.".into()), None).await
657
}
658
659
#[derive(Deserialize)]
660
pub struct CoverageForm {
661
    #[serde(rename = "_csrf")]
662
    csrf_token: String,
663
    #[serde(default)]
664
    min: String,
665
    #[serde(default)]
666
    max_drop: String,
667
}
668
669
/// Settings → Coverage (#113): the rules the `coverage` check applies.
670
pub async fn coverage(
671
    State(app): State<Shared>,
672
    viewer: Viewer,
673
    Path((owner, name)): Path<(String, String)>,
674
    Form(f): Form<CoverageForm>,
675
) -> Result<Page<RepoSettingsPage>, Error> {
676
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
677
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
678
    csrf(&viewer, &f.csrf_token, &shell)?;
679
    let number = |v: &str, what: &str, hi: f64| -> Result<Option<f64>, String> {
680
        let v = v.trim().trim_end_matches('%').trim();
681
        if v.is_empty() {
682
            return Ok(None);
683
        }
684
        match v.parse::<f64>() {
685
            Ok(n) if (0.0..=hi).contains(&n) => Ok(Some(n)),
686
            _ => Err(format!("{what} is a number from 0 to {hi}, or empty for no rule.")),
687
        }
688
    };
689
    let rules = match (number(&f.min, "The minimum", 100.0), number(&f.max_drop, "The largest drop", 100.0)) {
690
        (Ok(min), Ok(max_drop)) => crate::coverage::Rules { min, max_drop },
691
        (Err(e), _) | (_, Err(e)) => return settings_page(&app, shell, row, role, None, Some(e)).await,
692
    };
693
    crate::coverage::set_rules(&app.db, row.id, rules).await.map_err(|e| Error::from_db(shell.clone(), e))?;
694
    accounts::audit(
695
        &app.db,
696
        viewer.account(),
697
        "repo.coverage_rules",
698
        &format!("{owner}/{name}"),
699
        &format!("min {:?}, max drop {:?}", rules.min, rules.max_drop),
700
    )
701
    .await
702
    .ok();
703
    settings_page(&app, shell, row, role, Some("Coverage rules saved; they apply from the next report.".into()), None).await
704
}
705
706
#[derive(Deserialize)]
707
pub struct StorageForm {
708
    #[serde(rename = "_csrf")]
709
    csrf_token: String,
710
    /// `check`, or `credentials` (with the new ones).
711
    action: String,
712
    #[serde(flatten)]
713
    bucket: BucketForm,
714
}
715
716
/// Settings → Storage (oxygenhub#96): check the bucket again, or replace
717
/// its credentials once the new ones pass the check.
718
pub async fn storage(
719
    State(app): State<Shared>,
720
    viewer: Viewer,
721
    Path((owner, name)): Path<(String, String)>,
722
    Form(f): Form<StorageForm>,
723
) -> Result<Page<RepoSettingsPage>, Error> {
724
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
725
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
726
    csrf(&viewer, &f.csrf_token, &shell)?;
727
    let Some(s) = crate::storage::of(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))? else {
728
        return settings_page(&app, shell, row, role, None, Some("This repository is kept on this server's disk.".into())).await;
729
    };
730
    let scheme = s.url.split("://").next().unwrap_or("").to_string();
731
    let outcome = match f.action.as_str() {
732
        "credentials" => match f.bucket.credentials(&scheme) {
733
            Ok(c) => crate::storage::set_credentials(&app, &s, c).await.map(|r| format!("New credentials saved: {r}.")),
734
            Err(e) => Err(format!("Credentials: {e}")),
735
        },
736
        _ => crate::storage::check(&app, &s).await.map(|r| format!("The bucket passed the check: {r}.")),
737
    };
738
    match outcome {
739
        Ok(notice) => {
740
            accounts::audit(&app.db, viewer.account(), "repo.storage", &format!("{owner}/{name}"), &f.action).await.ok();
741
            settings_page(&app, shell, row, role, Some(notice), None).await
742
        }
743
        Err(e) => settings_page(&app, shell, row, role, None, Some(format!("Storage: {e}"))).await,
744
    }
745
}
746
747
#[derive(Deserialize)]
748
pub struct MirrorForm {
749
    #[serde(rename = "_csrf")]
750
    csrf_token: String,
751
    #[serde(default)]
752
    enabled: Option<String>,
753
}
754
755
#[derive(Deserialize)]
756
pub struct BackupForm {
757
    #[serde(rename = "_csrf")]
758
    csrf_token: String,
759
    /// Empty (with its owner's), `hourly`, `daily` or `off`.
760
    #[serde(default)]
761
    backup: String,
762
}
763
764
/// This repository's backup (docs/backups.md §5): with its owner's, left
765
/// out, or at least hourly or daily. Its owners and admins decide.
766
pub async fn backup(
767
    State(app): State<Shared>,
768
    viewer: Viewer,
769
    Path((owner, name)): Path<(String, String)>,
770
    Form(f): Form<BackupForm>,
771
) -> Result<Page<RepoSettingsPage>, Error> {
772
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
773
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
774
    csrf(&viewer, &f.csrf_token, &shell)?;
775
    if !crate::backups::REPO_CHOICES.iter().any(|(v, _)| *v == f.backup) {
776
        return settings_page(&app, shell, row, role, None, Some("Pick how this repository is backed up.".into())).await;
777
    }
778
    db::repos::set_backup(&app.db, row.id, &f.backup).await.map_err(|e| Error::from_db(shell.clone(), e))?;
779
    let what = if f.backup.is_empty() { "with its owner's" } else { f.backup.as_str() };
780
    accounts::audit(&app.db, viewer.account(), "repo.backup", &format!("{owner}/{name}"), what).await.ok();
781
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
782
    let notice = match f.backup.as_str() {
783
        "off" => "This repository is left out of its owner's backups from the next run.".to_string(),
784
        "" => "This repository is backed up with its owner's.".to_string(),
785
        every => {
786
            format!("This repository is backed up at least {} from now on.", if every == "hourly" { "every hour" } else { "every day" })
787
        }
788
    };
789
    settings_page(&app, shell, row, role, Some(notice), None).await
790
}
791
792
#[derive(Deserialize)]
793
pub struct DiffusionForm {
794
    #[serde(rename = "_csrf")]
795
    csrf_token: String,
796
    /// `off`, `green`, `any`, or `default`.
797
    diffusion: String,
798
    /// `auto` or `propose` (absent keeps it as it is).
799
    #[serde(default)]
800
    land: String,
801
}
802
803
/// Whether this repository follows the upstreams it pins (Diffusion,
804
/// #148): its owners and admins decide, since it opens proposals here.
805
pub async fn diffusion(
806
    State(app): State<Shared>,
807
    viewer: Viewer,
808
    Path((owner, name)): Path<(String, String)>,
809
    Form(f): Form<DiffusionForm>,
810
) -> Result<Page<RepoSettingsPage>, Error> {
811
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
812
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
813
    csrf(&viewer, &f.csrf_token, &shell)?;
814
    let setting = match f.diffusion.as_str() {
815
        "off" | "green" | "any" => f.diffusion.as_str(),
816
        "default" => "",
817
        _ => return settings_page(&app, shell, row, role, None, Some("Pick off, green, any or the site default.".into())).await,
818
    };
819
    let land = match f.land.as_str() {
820
        "auto" | "propose" => Some(f.land.as_str()),
821
        "" => None,
822
        _ => return settings_page(&app, shell, row, role, None, Some("Pick propose or auto for landing.".into())).await,
823
    };
824
    db::repos::set_diffusion(&app.db, row.id, setting).await.map_err(|e| Error::from_db(shell.clone(), e))?;
825
    if let Some(l) = land {
826
        db::repos::set_diffusion_land(&app.db, row.id, l).await.map_err(|e| Error::from_db(shell.clone(), e))?;
827
    }
828
    let what =
829
        format!("{}{}", if setting.is_empty() { "default" } else { setting }, land.map(|l| format!(", land {l}")).unwrap_or_default());
830
    accounts::audit(&app.db, viewer.account(), "repo.diffusion", &format!("{owner}/{name}"), &what).await.ok();
831
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
832
    // Turned to land by itself: any bump already green lands now.
833
    if land == Some("auto") {
834
        let (app, row) = (app.clone(), row.clone());
835
        tokio::spawn(async move { crate::diffusion::land_ready(&app, &row).await });
836
    }
837
    if crate::diffusion::rule(&app.config.diffusion.follow, &row, None, "").follow != crate::diffusion::Follow::Off {
838
        let (app, row) = (app.clone(), row.clone());
839
        tokio::spawn(async move { crate::diffusion::catch_up(&app, &row).await });
840
    }
841
    let notice = match crate::diffusion::rule(&app.config.diffusion.follow, &row, None, "").follow {
842
        crate::diffusion::Follow::Off => "Diffusion is off: upstream moves open no proposals here.",
843
        crate::diffusion::Follow::Green => {
844
            "Diffusion is on: when a subrepo's upstream moves and its checks pass, a proposal moves the pin here."
845
        }
846
        crate::diffusion::Follow::Any => "Diffusion is on: every move of a subrepo's upstream opens or updates a proposal here.",
847
    };
848
    settings_page(&app, shell, row, role, Some(notice.into()), None).await
849
}
850
851
#[derive(Deserialize)]
852
pub struct AgentGatesForm {
853
    #[serde(rename = "_csrf")]
854
    csrf_token: String,
855
    /// `on` when the box is ticked.
856
    #[serde(default)]
857
    agents: String,
858
}
859
860
/// `POST /{owner}/{repo}/settings/agent-gates` (oxygenhub#258): whether an
861
/// agent's token may approve or reject this repository's approval gates;
862
/// for maintainers, audited.
863
pub async fn agent_gates(
864
    State(app): State<Shared>,
865
    viewer: Viewer,
866
    Path((owner, name)): Path<(String, String)>,
867
    Form(f): Form<AgentGatesForm>,
868
) -> Result<Page<RepoSettingsPage>, Error> {
869
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
870
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
871
    csrf(&viewer, &f.csrf_token, &shell)?;
872
    let on = f.agents == "on";
873
    db::repos::set_agents_decide_gates(&app.db, row.id, on).await.map_err(|e| Error::from_db(shell.clone(), e))?;
874
    accounts::audit(&app.db, viewer.account(), "repo.agents_decide_gates", &format!("{owner}/{name}"), if on { "on" } else { "off" })
875
        .await
876
        .ok();
877
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
878
    let notice = if on {
879
        "Agents' tokens may now approve or reject this repository's approval gates; it says so on each."
880
    } else {
881
        "Approval gates here are decided by people: agents' tokens are refused."
882
    };
883
    settings_page(&app, shell, row, role, Some(notice.into()), None).await
884
}
885
886
#[derive(Deserialize)]
887
pub struct SupersedeForm {
888
    #[serde(rename = "_csrf")]
889
    csrf_token: String,
890
    /// `on` when the box is ticked: superseded runs already running are
891
    /// cancelled too.
892
    #[serde(default)]
893
    running: String,
894
}
895
896
/// `POST /{owner}/{repo}/settings/flow-supersede` (oxygenhub#267): whether
897
/// a new push or proposal run cancels the older runs it supersedes that are
898
/// already running, or only those still queued; for maintainers, audited.
899
pub async fn flow_supersede(
900
    State(app): State<Shared>,
901
    viewer: Viewer,
902
    Path((owner, name)): Path<(String, String)>,
903
    Form(f): Form<SupersedeForm>,
904
) -> Result<Page<RepoSettingsPage>, Error> {
905
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
906
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
907
    csrf(&viewer, &f.csrf_token, &shell)?;
908
    let keep = f.running != "on";
909
    db::repos::set_flow_keep_running(&app.db, row.id, keep).await.map_err(|e| Error::from_db(shell.clone(), e))?;
910
    accounts::audit(&app.db, viewer.account(), "repo.flow_keep_running", &format!("{owner}/{name}"), if keep { "on" } else { "off" })
911
        .await
912
        .ok();
913
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
914
    let notice = if keep {
915
        "A new push or proposal run now cancels only the older runs still queued; those already running finish."
916
    } else {
917
        "A new push or proposal run now cancels the older runs it supersedes, queued or running."
918
    };
919
    settings_page(&app, shell, row, role, Some(notice.into()), None).await
920
}
921
922
#[derive(Deserialize)]
923
pub struct PriorityForm {
924
    #[serde(rename = "_csrf")]
925
    csrf_token: String,
926
    /// `low`, `normal`, `high`, or `default`.
927
    priority: String,
928
}
929
930
/// `POST /{owner}/{repo}/settings/flow-priority` (oxygenhub#244): its jobs'
931
/// level. Maintainers choose low, normal or the default; only site
932
/// administrators raise a repository to high.
933
pub async fn flow_priority(
934
    State(app): State<Shared>,
935
    viewer: Viewer,
936
    Path((owner, name)): Path<(String, String)>,
937
    Form(f): Form<PriorityForm>,
938
) -> Result<Page<RepoSettingsPage>, Error> {
939
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
940
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
941
    csrf(&viewer, &f.csrf_token, &shell)?;
942
    let level = match f.priority.as_str() {
943
        "low" | "normal" | "high" => f.priority.as_str(),
944
        "default" => "",
945
        _ => return settings_page(&app, shell, row, role, None, Some("Pick low, normal, high or the default.".into())).await,
946
    };
947
    if level == "high" && !viewer.is_admin() {
948
        return settings_page(&app, shell, row, role, None, Some("Only site administrators raise a repository to high.".into())).await;
949
    }
950
    db::repos::set_flow_priority(&app.db, row.id, level).await.map_err(|e| Error::from_db(shell.clone(), e))?;
951
    accounts::audit(
952
        &app.db,
953
        viewer.account(),
954
        "repo.flow_priority",
955
        &format!("{owner}/{name}"),
956
        if level.is_empty() { "default" } else { level },
957
    )
958
    .await
959
    .ok();
960
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
961
    let notice = if level.is_empty() {
962
        "Its jobs follow the organisation's and the site's priority.".to_string()
963
    } else {
964
        format!("Its jobs are {level} priority.")
965
    };
966
    settings_page(&app, shell, row, role, Some(notice), None).await
967
}
968
969
#[derive(Deserialize)]
970
pub struct FlowForm {
971
    #[serde(rename = "_csrf")]
972
    csrf_token: String,
973
    /// `on`, `off`, or `default`.
974
    flow: String,
975
}
976
977
/// Turn OxygenFlow on or off for one repository. Site administrators choose
978
/// anything; the repository's maintainers may turn it on on their own
979
/// runners (`own`), when the repository or its owner has one, or off (#225).
980
/// Only site administrators let the site's runners take its jobs.
981
pub async fn flow(
982
    State(app): State<Shared>,
983
    viewer: Viewer,
984
    Path((owner, name)): Path<(String, String)>,
985
    Form(f): Form<FlowForm>,
986
) -> Result<Page<RepoSettingsPage>, Error> {
987
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
988
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
989
    csrf(&viewer, &f.csrf_token, &shell)?;
990
    let setting = match f.flow.as_str() {
991
        "on" => "on",
992
        "own" => "own",
993
        "off" => "off",
994
        "default" => "",
995
        _ => return settings_page(&app, shell, row, role, None, Some("Pick on, own runners, off or the site default.".into())).await,
996
    };
997
    if !viewer.is_admin() {
998
        if matches!(setting, "on" | "") {
999
            return settings_page(
1000
                &app,
1001
                shell,
1002
                row,
1003
                role,
1004
                None,
1005
                Some("Only site administrators let the site's runners take this repository's jobs.".into()),
1006
            )
1007
            .await;
1008
        }
1009
        if setting == "own" && !has_own_runner(&app, &row).await.map_err(|e| Error::from_db(shell.clone(), e))? {
1010
            return settings_page(
1011
                &app,
1012
                shell,
1013
                row,
1014
                role,
1015
                None,
1016
                Some("Register a runner for this repository (Runners, below) or its owner first.".into()),
1017
            )
1018
            .await;
1019
        }
1020
    }
1021
    db::repos::set_flow(&app.db, row.id, setting).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1022
    let label = if setting.is_empty() { "default" } else { setting };
1023
    accounts::audit(&app.db, viewer.account(), "repo.flow", &format!("{owner}/{name}"), label).await.ok();
1024
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
1025
    let notice = if row.flow == "own" {
1026
        "OxygenFlow is on, on this repository's own runners: pushes with a .flow/pipeline.kdl start runs that only they take."
1027
    } else if crate::flow::enabled(&app, &row) {
1028
        "OxygenFlow is on: pushes with a .flow/pipeline.kdl start runs."
1029
    } else {
1030
        "OxygenFlow is off for this repository."
1031
    };
1032
    settings_page(&app, shell, row, role, Some(notice.into()), None).await
1033
}
1034
1035
pub async fn mirror(
1036
    State(app): State<Shared>,
1037
    viewer: Viewer,
1038
    Path((owner, name)): Path<(String, String)>,
1039
    Form(f): Form<MirrorForm>,
1040
) -> Result<Page<RepoSettingsPage>, Error> {
1041
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1042
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
1043
    csrf(&viewer, &f.csrf_token, &shell)?;
1044
    db::repos::set_git_mirror(&app.db, row.id, f.enabled.is_some()).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1045
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
1046
    if row.git_mirror {
1047
        crate::mirror::refresh(&app, &row).await;
1048
    }
1049
    accounts::audit(&app.db, viewer.account(), "repo.mirror", &format!("{owner}/{name}"), if row.git_mirror { "on" } else { "off" })
1050
        .await
1051
        .ok();
1052
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
1053
    let notice = if row.git_mirror { "The Git mirror is on and up to date." } else { "The Git mirror is off." };
1054
    settings_page(&app, shell, row, role, Some(notice.into()), None).await
1055
}
1056
1057
/// `POST /{owner}/{name}/settings/runners/token`: a one-time registration
1058
/// token for a runner that serves this repository only (oxygenhub#41).
1059
pub async fn runner_token(
1060
    State(app): State<Shared>,
1061
    viewer: Viewer,
1062
    Path((owner, name)): Path<(String, String)>,
1063
    Form(f): Form<super::session::CsrfForm>,
1064
) -> Result<Page<RepoSettingsPage>, Error> {
1065
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1066
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
1067
    csrf(&viewer, &f.csrf, &shell)?;
1068
    let me = viewer.account().map(|a| a.username.clone()).unwrap_or_default();
1069
    let token = format!("flowreg_{}", crate::auth::random_hex(24));
1070
    let scope = db::runners::Scope { owner: row.owner.clone(), repo_id: Some(row.id) };
1071
    db::runners::add_registration(&app.db, &blake3::hash(token.as_bytes()).to_hex(), &me, &scope)
1072
        .await
1073
        .map_err(|e| Error::from_db(shell.clone(), e))?;
1074
    accounts::audit(&app.db, viewer.account(), "flow.runner.token", &row.slug(), "registration token for this repository").await.ok();
1075
    let mut page = settings_page(&app, shell, row, role, Some("Copy the token now; it is not shown again.".into()), None).await?;
1076
    page.0.runner_token = Some(token);
1077
    Ok(page)
1078
}
1079
1080
/// `POST /{owner}/{name}/settings/runners/{id}/remove`: remove one of this
1081
/// repository's own runners, when it is running nothing.
1082
pub async fn runner_remove(
1083
    State(app): State<Shared>,
1084
    viewer: Viewer,
1085
    Path((owner, name, id)): Path<(String, String, i64)>,
1086
    Form(f): Form<super::session::CsrfForm>,
1087
) -> Result<Page<RepoSettingsPage>, Error> {
1088
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1089
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
1090
    csrf(&viewer, &f.csrf, &shell)?;
1091
    let e = |e| Error::from_db(shell.clone(), e);
1092
    let Some(r) = db::runners::get(&app.db, id).await.map_err(e)?.filter(|r| r.scope_repo_id == Some(row.id)) else {
1093
        return Err(Error::not_found(shell));
1094
    };
1095
    if db::runners::running(&app.db).await.map_err(e)?.iter().any(|(rid, n)| *rid == id && *n > 0) {
1096
        return settings_page(&app, shell, row, role, None, Some(format!("{} is running jobs: wait for them to finish.", r.name))).await;
1097
    }
1098
    db::runners::remove(&app.db, id).await.map_err(e)?;
1099
    accounts::audit(&app.db, viewer.account(), "flow.runner.removed", &r.name, &row.slug()).await.ok();
1100
    settings_page(&app, shell, row, role, Some(format!("{} removed: its credential no longer works.", r.name)), None).await
1101
}
1102
1103
fn repo_assistants(row: &db::repos::RepoRow) -> crate::levels::At {
1104
    crate::levels::At::repo(row)
1105
}
1106
1107
/// `POST /{owner}/{name}/settings/assistants`: the repository's assistants
1108
/// and prompt (#97), for its admins.
1109
pub async fn assistants(
1110
    State(app): State<Shared>,
1111
    viewer: Viewer,
1112
    Path((owner, name)): Path<(String, String)>,
1113
    Form(f): Form<super::assistants::AssistantForm>,
1114
) -> Result<Page<RepoSettingsPage>, Error> {
1115
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1116
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
1117
    csrf(&viewer, &f.csrf_token, &shell)?;
1118
    match super::assistants::apply(&app, &viewer, &repo_assistants(&row), f).await.map_err(|e| Error::from_db(shell.clone(), e))? {
1119
        Ok(notice) => settings_page(&app, shell, row, role, Some(notice), None).await,
1120
        Err(error) => settings_page(&app, shell, row, role, None, Some(error)).await,
1121
    }
1122
}
1123
1124
/// `POST /{owner}/{name}/settings/variables`: the repository's Flow
1125
/// variables (#102), for its admins.
1126
pub async fn variables(
1127
    State(app): State<Shared>,
1128
    viewer: Viewer,
1129
    Path((owner, name)): Path<(String, String)>,
1130
    Form(f): Form<super::variables::VariableForm>,
1131
) -> Result<Page<RepoSettingsPage>, Error> {
1132
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1133
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
1134
    csrf(&viewer, &f.csrf_token, &shell)?;
1135
    match super::variables::apply(&app, &viewer, &repo_assistants(&row), f).await.map_err(|e| Error::from_db(shell.clone(), e))? {
1136
        Ok(notice) => settings_page(&app, shell, row, role, Some(notice), None).await,
1137
        Err(error) => settings_page(&app, shell, row, role, None, Some(error)).await,
1138
    }
1139
}
1140
1141
/// `POST /{owner}/{name}/settings/secrets`: the repository's Flow secrets
1142
/// (#107), for its admins.
1143
pub async fn secrets(
1144
    State(app): State<Shared>,
1145
    viewer: Viewer,
1146
    Path((owner, name)): Path<(String, String)>,
1147
    Form(f): Form<super::secrets::SecretForm>,
1148
) -> Result<Page<RepoSettingsPage>, Error> {
1149
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1150
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
1151
    csrf(&viewer, &f.csrf_token, &shell)?;
1152
    match super::secrets::apply(&app, &viewer, &repo_assistants(&row), f).await.map_err(|e| Error::from_db(shell.clone(), e))? {
1153
        Ok(notice) => settings_page(&app, shell, row, role, Some(notice), None).await,
1154
        Err(error) => settings_page(&app, shell, row, role, None, Some(error)).await,
1155
    }
1156
}
1157
1158
/// `POST /{owner}/{name}/settings/assistants/models`: what a provider offers (#109).
1159
pub async fn assistant_models(
1160
    State(app): State<Shared>,
1161
    viewer: Viewer,
1162
    Path((owner, name)): Path<(String, String)>,
1163
    Form(f): Form<super::assistants::ModelsForm>,
1164
) -> Result<axum::Json<serde_json::Value>, Error> {
1165
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
1166
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
1167
    csrf(&viewer, &f.csrf_token, &shell)?;
1168
    Ok(axum::Json(super::assistants::models(&app, &repo_assistants(&row), f).await.map_err(|e| Error::from_db(shell.clone(), e))?))
1169
}
1170
1171
/// Whether a runner serves this repository that is not the site's: one
1172
/// scoped to it or to its owner (#225).
1173
async fn has_own_runner(app: &Shared, row: &db::repos::RepoRow) -> sqlx::Result<bool> {
1174
    Ok(db::runners::serving(&app.db, &row.owner, row.id).await?.iter().any(|r| !r.scope().is_site()))
1175
}