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