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