OXYGENHUB
# 4bae285c6ed8 oxygenhub / crates / hub / src / web / repo_settings.rs
36.9 KiB · Rust · last changed A repository can live in its owner's bucket 1 month ago source blame history raw
1
//! Creating repositories and their settings pages (hub spec §6.3).
2
3
use super::settings::{csrf, require};
4
use super::{filters, lookup_role, Error, Page, RepoShell, Shell};
5
use crate::auth::{roles, Access, Role, Viewer};
6
use crate::db::{self, accounts};
7
use crate::repos::RepoName;
8
use crate::Shared;
9
use askama::Template;
10
use axum::extract::{Form, Path, State};
11
use axum::response::{IntoResponse, Redirect, Response};
12
use hub_api::Visibility;
13
use serde::Deserialize;
14
15
#[derive(Template)]
16
#[template(path = "repo_new.html")]
17
pub struct NewRepoPage {
18
    shell: Shell,
19
    owners: Vec<String>,
20
    error: Option<String>,
21
}
22
23
pub(crate) async fn owners_for(app: &Shared, me: &accounts::Account) -> Vec<String> {
24
    let mut owners = vec![me.username.clone()];
25
    if let Ok(orgs) = accounts::orgs_of(&app.db, me.id).await {
26
        owners.extend(orgs.into_iter().map(|(o, _)| o.name));
27
    }
28
    owners
29
}
30
31
pub async fn new_form(State(app): State<Shared>, viewer: Viewer) -> Result<Page<NewRepoPage>, Error> {
32
    let shell = Shell::new(&app, "/new", &viewer);
33
    let me = require(&viewer, &shell)?;
34
    let owners = owners_for(&app, &me.account).await;
35
    Ok(Page(NewRepoPage { shell, owners, error: None }))
36
}
37
38
#[derive(Deserialize)]
39
pub struct NewRepoForm {
40
    #[serde(rename = "_csrf")]
41
    csrf_token: String,
42
    owner: String,
43
    name: String,
44
    #[serde(default)]
45
    description: String,
46
    #[serde(default = "default_visibility")]
47
    visibility: String,
48
    /// `hub` (this server's disk, the default) or `bucket` (oxygenhub#96).
49
    #[serde(default)]
50
    storage: String,
51
    #[serde(flatten)]
52
    bucket: BucketForm,
53
}
54
55
/// A bucket and the credentials for it, as the New repository form and
56
/// Settings → Storage give them; only the kind the URL names is read.
57
#[derive(Deserialize, Default)]
58
pub struct BucketForm {
59
    #[serde(default)]
60
    store_url: String,
61
    #[serde(default)]
62
    access_key_id: String,
63
    #[serde(default)]
64
    secret_access_key: String,
65
    #[serde(default)]
66
    service_account_key: String,
67
    #[serde(default)]
68
    tenant_id: String,
69
    #[serde(default)]
70
    client_id: String,
71
    #[serde(default)]
72
    client_secret: String,
73
}
74
75
impl BucketForm {
76
    fn credentials(&self, scheme: &str) -> Result<oxy_strata::Credentials, String> {
77
        crate::storage::credentials_from(
78
            scheme,
79
            &self.access_key_id,
80
            &self.secret_access_key,
81
            &self.service_account_key,
82
            &self.tenant_id,
83
            &self.client_id,
84
            &self.client_secret,
85
        )
86
    }
87
}
88
89
fn default_visibility() -> String {
90
    "public".into()
91
}
92
93
pub async fn create(State(app): State<Shared>, viewer: Viewer, Form(f): Form<NewRepoForm>) -> Result<Response, Error> {
94
    let shell = Shell::new(&app, "/new", &viewer);
95
    let me = require(&viewer, &shell)?;
96
    csrf(&viewer, &f.csrf_token, &shell)?;
97
    let owners = owners_for(&app, &me.account).await;
98
    let fail = |msg: &str| Page(NewRepoPage { shell: shell.clone(), owners: owners.clone(), error: Some(msg.into()) }).into_response();
99
    let Some(name) = RepoName::new(f.owner.trim(), f.name.trim()) else {
100
        return Ok(fail("A repository name is letters, digits, dots, dashes and underscores."));
101
    };
102
    if name.name.ends_with(crate::wiki::SUFFIX) {
103
        return Ok(fail("A name cannot end in .wiki: that is the address of a repository's wiki."));
104
    }
105
    if !roles::may_create_under(&app.db, &me.account, &name.owner).await.map_err(|e| Error::from_db(shell.clone(), e))? {
106
        return Ok(fail("You cannot create repositories under that owner."));
107
    }
108
    let Some(visibility) = Visibility::parse(&f.visibility) else { return Ok(fail("Visibility is public, internal or private.")) };
109
    if db::repos::get(&app.db, &name.owner, &name.name).await.map_err(|e| Error::from_db(shell.clone(), e))?.is_some()
110
        || app.repos.exists(&name)
111
    {
112
        return Ok(fail("That repository exists."));
113
    }
114
    if f.storage == "bucket" {
115
        // Kept in the owner's bucket (oxygenhub#96): checked before
116
        // anything is made, and nothing is left behind when it fails.
117
        let url = f.bucket.store_url.trim();
118
        let parsed = match crate::storage::check_url(url) {
119
            Ok(p) => p,
120
            Err(e) => return Ok(fail(&format!("Store: {e}."))),
121
        };
122
        let credentials = match f.bucket.credentials(&parsed.scheme) {
123
            Ok(c) => c,
124
            Err(e) => return Ok(fail(&format!("Credentials: {e}."))),
125
        };
126
        let row = db::repos::create(&app.db, &name.owner, &name.name, f.description.trim(), visibility)
127
            .await
128
            .map_err(|e| Error::from_db(shell.clone(), e))?;
129
        if let Err(e) = crate::storage::create(&app, &name, row.id, url, credentials).await {
130
            db::repos::delete(&app.db, row.id).await.ok();
131
            return Ok(fail(&format!("The bucket did not pass the check, so nothing was created: {e}")));
132
        }
133
        accounts::audit(
134
            &app.db,
135
            Some(&me.account),
136
            "repo.created",
137
            &name.to_string(),
138
            &format!("{} in {}", visibility.as_str(), parsed.plain()),
139
        )
140
        .await
141
        .ok();
142
        return Ok(Redirect::to(&name.url()).into_response());
143
    }
144
    app.repos.create(&name, None).map_err(|e| Error::from_repo(shell.clone(), e))?;
145
    db::repos::create(&app.db, &name.owner, &name.name, f.description.trim(), visibility)
146
        .await
147
        .map_err(|e| Error::from_db(shell.clone(), e))?;
148
    accounts::audit(&app.db, Some(&me.account), "repo.created", &name.to_string(), visibility.as_str()).await.ok();
149
    Ok(Redirect::to(&name.url()).into_response())
150
}
151
152
#[derive(Template)]
153
#[template(path = "repo_settings.html")]
154
pub struct RepoSettingsPage {
155
    shell: Shell,
156
    repo: RepoShell,
157
    row: db::repos::RepoRow,
158
    collaborators: Vec<accounts::Collaborator>,
159
    teams: Vec<accounts::Team>,
160
    role: Role,
161
    requirements: db::proposals::Requirements,
162
    hooks: Vec<super::webhooks::HookRow>,
163
    required_checks: String,
164
    /// Whether hub.toml alone would run Flow here, and whether it runs.
165
    flow_default: bool,
166
    flow_enabled: bool,
167
    site_admin: bool,
168
    notice: Option<String>,
169
    error: Option<String>,
170
    /// Runners that may take this repository's jobs: (runner, what it
171
    /// serves, whether it is this repository's own).
172
    runners: Vec<(db::runners::Runner, String, bool)>,
173
    /// A registration token just made, shown once.
174
    runner_token: Option<String>,
175
    /// The repository's AI assistants and prompt (#97).
176
    assistants: super::assistants::Panel,
177
    /// The repository's Flow variables (#102).
178
    variables: super::variables::Panel,
179
    /// The repository's Flow secrets (#107).
180
    secrets: super::secrets::Panel,
181
    /// Where its data lives, when in a bucket (oxygenhub#96).
182
    storage: Option<crate::storage::Storage>,
183
}
184
185
async fn settings_page(
186
    app: &Shared,
187
    shell: Shell,
188
    row: db::repos::RepoRow,
189
    role: Role,
190
    notice: Option<String>,
191
    error: Option<String>,
192
) -> Result<Page<RepoSettingsPage>, Error> {
193
    let collaborators = accounts::collaborators(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
194
    let teams = match accounts::org_by_name(&app.db, &row.owner).await.map_err(|e| Error::from_db(shell.clone(), e))? {
195
        Some(org) => accounts::teams(&app.db, org.id).await.map_err(|e| Error::from_db(shell.clone(), e))?,
196
        None => vec![],
197
    };
198
    let repo = RepoShell::new(app, &row, "settings").with_role(role);
199
    let requirements = db::proposals::requirements(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
200
    let hooks = db::hooks::hooks_for_repo(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
201
    let hooks = super::webhooks::rows(app, hooks).await.map_err(|e| Error::from_db(shell.clone(), e))?;
202
    let required_checks = db::hooks::required_checks(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.join(", ");
203
    let flow_default = app.config.flow.allows(&row.owner, &row.name);
204
    let flow_enabled = crate::flow::enabled(app, &row);
205
    let site_admin = shell.is_admin;
206
    let runners = db::runners::serving(&app.db, &row.owner, row.id)
207
        .await
208
        .map_err(|e| Error::from_db(shell.clone(), e))?
209
        .into_iter()
210
        .map(|r| {
211
            let own = r.scope_repo_id == Some(row.id);
212
            let serves = if own {
213
                "this repository".to_string()
214
            } else if r.scope_owner.is_empty() {
215
                "the whole site".to_string()
216
            } else {
217
                format!("{}'s repositories", r.scope_owner)
218
            };
219
            (r, serves, own)
220
        })
221
        .collect();
222
    let assistants = super::assistants::panel(app, &repo_assistants(&row)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
223
    let variables = super::variables::panel(app, &repo_assistants(&row)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
224
    let secrets = super::secrets::panel(app, &repo_assistants(&row)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
225
    let storage = crate::storage::of(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
226
    Ok(Page(RepoSettingsPage {
227
        storage,
228
        assistants,
229
        variables,
230
        secrets,
231
        runners,
232
        runner_token: None,
233
        shell,
234
        repo,
235
        row,
236
        collaborators,
237
        teams,
238
        role,
239
        requirements,
240
        hooks,
241
        required_checks,
242
        flow_default,
243
        flow_enabled,
244
        site_admin,
245
        notice,
246
        error,
247
    }))
248
}
249
250
pub async fn settings(
251
    State(app): State<Shared>,
252
    viewer: Viewer,
253
    Path((owner, name)): Path<(String, String)>,
254
) -> Result<Page<RepoSettingsPage>, Error> {
255
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
256
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
257
    settings_page(&app, shell, row, role, None, None).await
258
}
259
260
#[derive(Deserialize)]
261
pub struct GeneralForm {
262
    #[serde(rename = "_csrf")]
263
    csrf_token: String,
264
    description: String,
265
    default_bookmark: String,
266
    visibility: String,
267
    #[serde(default)]
268
    topics: String,
269
}
270
271
pub async fn update(
272
    State(app): State<Shared>,
273
    viewer: Viewer,
274
    Path((owner, name)): Path<(String, String)>,
275
    Form(f): Form<GeneralForm>,
276
) -> Result<Page<RepoSettingsPage>, Error> {
277
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
278
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
279
    csrf(&viewer, &f.csrf_token, &shell)?;
280
    let e = |e| Error::from_db(shell.clone(), e);
281
    let topics = match db::repos::parse_topics(&f.topics) {
282
        Ok(t) => t,
283
        Err(msg) => return settings_page(&app, shell, row, role, None, Some(msg)).await,
284
    };
285
    db::repos::set_description(&app.db, row.id, f.description.trim()).await.map_err(e)?;
286
    db::repos::set_topics(&app.db, row.id, &topics).await.map_err(e)?;
287
    if crate::repos::names::valid_segment(f.default_bookmark.trim()) {
288
        db::repos::set_default_bookmark(&app.db, row.id, f.default_bookmark.trim()).await.map_err(e)?;
289
    }
290
    if role >= Role::Admin {
291
        if let Some(v) = Visibility::parse(&f.visibility) {
292
            db::repos::set_visibility(&app.db, row.id, v).await.map_err(e)?;
293
        }
294
    }
295
    accounts::audit(&app.db, viewer.account(), "repo.updated", &format!("{owner}/{name}"), "").await.ok();
296
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(e)?.unwrap_or(row);
297
    settings_page(&app, shell, row, role, Some("Saved.".into()), None).await
298
}
299
300
#[derive(Deserialize)]
301
pub struct CollaboratorForm {
302
    #[serde(rename = "_csrf")]
303
    csrf_token: String,
304
    #[serde(default)]
305
    username: String,
306
    #[serde(default)]
307
    team: String,
308
    #[serde(default)]
309
    role: String,
310
    #[serde(default)]
311
    remove: Option<String>,
312
}
313
314
pub async fn collaborator(
315
    State(app): State<Shared>,
316
    viewer: Viewer,
317
    Path((owner, name)): Path<(String, String)>,
318
    Form(f): Form<CollaboratorForm>,
319
) -> Result<Page<RepoSettingsPage>, Error> {
320
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
321
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
322
    csrf(&viewer, &f.csrf_token, &shell)?;
323
    let e = |e| Error::from_db(shell.clone(), e);
324
    let (account_id, team_id, label) = if !f.username.trim().is_empty() {
325
        match accounts::account_by_name(&app.db, f.username.trim()).await.map_err(e)? {
326
            Some(a) => (Some(a.id), None, a.username),
327
            None => return settings_page(&app, shell, row, role, None, Some("No such account.".into())).await,
328
        }
329
    } else if !f.team.trim().is_empty() {
330
        let org = accounts::org_by_name(&app.db, &row.owner).await.map_err(e)?;
331
        let team = match org {
332
            Some(o) => accounts::team_by_name(&app.db, o.id, f.team.trim()).await.map_err(e)?,
333
            None => None,
334
        };
335
        match team {
336
            Some(t) => (None, Some(t.id), format!("team {}", t.name)),
337
            None => return settings_page(&app, shell, row, role, None, Some("No such team in this organisation.".into())).await,
338
        }
339
    } else {
340
        return settings_page(&app, shell, row, role, None, Some("Name an account or a team.".into())).await;
341
    };
342
    if f.remove.is_some() {
343
        accounts::remove_collaborator(&app.db, row.id, account_id, team_id).await.map_err(e)?;
344
        accounts::audit(&app.db, viewer.account(), "repo.collaborator.removed", &format!("{owner}/{name}"), &label).await.ok();
345
        return settings_page(&app, shell, row, role, Some(format!("Removed {label}.")), None).await;
346
    }
347
    let Some(new_role) = Role::parse(&f.role) else {
348
        return settings_page(&app, shell, row, role, None, Some("Pick a role.".into())).await;
349
    };
350
    accounts::set_collaborator(&app.db, row.id, account_id, team_id, new_role.as_str()).await.map_err(e)?;
351
    accounts::audit(
352
        &app.db,
353
        viewer.account(),
354
        "repo.collaborator.set",
355
        &format!("{owner}/{name}"),
356
        &format!("{label} {}", new_role.as_str()),
357
    )
358
    .await
359
    .ok();
360
    settings_page(&app, shell, row, role, Some(format!("{label} now has {} access.", new_role.as_str())), None).await
361
}
362
363
#[derive(Deserialize)]
364
pub struct TransferForm {
365
    #[serde(rename = "_csrf")]
366
    csrf_token: String,
367
    owner: String,
368
    confirm: String,
369
}
370
371
/// `POST /{owner}/{name}/settings/transfer`: move the repository to a
372
/// user or organisation the admin may create repositories under (#108).
373
/// The old URL redirects and oxy remotes keep working.
374
pub async fn transfer(
375
    State(app): State<Shared>,
376
    viewer: Viewer,
377
    Path((owner, name)): Path<(String, String)>,
378
    Form(f): Form<TransferForm>,
379
) -> Result<Response, Error> {
380
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
381
    let (repo, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
382
    csrf(&viewer, &f.csrf_token, &shell)?;
383
    let me = viewer.account().cloned().ok_or_else(|| Error::not_found(shell.clone()))?;
384
    let fail = |msg: &str| settings_page(&app, shell.clone(), row.clone(), role, None, Some(msg.into()));
385
    if f.confirm.trim() != repo.to_string() {
386
        return Ok(fail("Type the repository's full name to confirm.").await?.into_response());
387
    }
388
    let to = f.owner.trim().trim_start_matches('@').to_string();
389
    let Some(dest) = RepoName::new(&to, &row.name) else {
390
        return Ok(fail("That is not an owner name.").await?.into_response());
391
    };
392
    if dest.owner == row.owner {
393
        return Ok(fail("The repository is already there.").await?.into_response());
394
    }
395
    let e = |e| Error::from_db(shell.clone(), e);
396
    let known = accounts::account_by_name(&app.db, &dest.owner).await.map_err(e)?.is_some()
397
        || accounts::org_by_name(&app.db, &dest.owner).await.map_err(e)?.is_some();
398
    if !known {
399
        return Ok(fail("There is no user or organisation of that name.").await?.into_response());
400
    }
401
    if !roles::may_create_under(&app.db, &me, &dest.owner).await.map_err(e)? {
402
        return Ok(fail("You cannot create repositories under that owner, so you cannot move one there.").await?.into_response());
403
    }
404
    if db::repos::get(&app.db, &dest.owner, &dest.name).await.map_err(e)?.is_some() || app.repos.exists(&dest) {
405
        return Ok(fail("That owner already has a repository of this name.").await?.into_response());
406
    }
407
    {
408
        let _guard = app.repos.write_guard(&repo).await;
409
        app.repos.rename(&repo, &dest).map_err(|e| Error::from_repo(shell.clone(), e))?;
410
        crate::storage::renamed(&app, &repo, &dest);
411
        if app.wikis.exists(&repo) {
412
            app.wikis.rename(&repo, &dest).ok();
413
        }
414
        db::repos::transfer(&app.db, row.id, &row.owner, &row.name, &dest.owner).await.map_err(e)?;
415
        let mirror = crate::mirror::mirror_dir(&app, &repo);
416
        if mirror.is_dir() {
417
            let to = crate::mirror::mirror_dir(&app, &dest);
418
            if let Some(parent) = to.parent() {
419
                std::fs::create_dir_all(parent).ok();
420
            }
421
            std::fs::rename(&mirror, &to).ok();
422
        }
423
    }
424
    accounts::audit(&app.db, Some(&me), "repo.transferred", &repo.to_string(), &dest.to_string()).await.ok();
425
    let notice = super::settings::urlencode(&format!("Moved from {repo}; the old address redirects here."));
426
    Ok(Redirect::to(&format!("{}/settings?notice={notice}", dest.url())).into_response())
427
}
428
429
#[derive(Deserialize)]
430
pub struct ArchiveForm {
431
    #[serde(rename = "_csrf")]
432
    csrf_token: String,
433
    /// `archive` or `unarchive`.
434
    action: String,
435
    #[serde(default)]
436
    reason: String,
437
}
438
439
/// `POST /{owner}/{name}/settings/archive`: read-only, with a reason, or
440
/// back again (#108).
441
pub async fn archive(
442
    State(app): State<Shared>,
443
    viewer: Viewer,
444
    Path((owner, name)): Path<(String, String)>,
445
    Form(f): Form<ArchiveForm>,
446
) -> Result<Page<RepoSettingsPage>, Error> {
447
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
448
    let (repo, mut row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
449
    csrf(&viewer, &f.csrf_token, &shell)?;
450
    let archive = match f.action.as_str() {
451
        "archive" => true,
452
        "unarchive" => false,
453
        _ => return settings_page(&app, shell, row, role, None, Some("Unknown action.".into())).await,
454
    };
455
    let reason: String = f.reason.trim().chars().take(500).collect();
456
    db::repos::set_archived(&app.db, row.id, archive, &reason).await.map_err(|e| Error::from_db(shell.clone(), e))?;
457
    accounts::audit(&app.db, viewer.account(), if archive { "repo.archived" } else { "repo.unarchived" }, &repo.to_string(), &reason)
458
        .await
459
        .ok();
460
    row.archived = archive;
461
    row.archived_reason = if archive { reason } else { String::new() };
462
    let notice = if archive {
463
        "The repository is archived: read-only until it is brought back."
464
    } else {
465
        "The repository is back: writes are allowed again."
466
    };
467
    settings_page(&app, shell, row, role, Some(notice.into()), None).await
468
}
469
470
/// `POST /{owner}/{name}/settings/wiki/delete`: the wiki to the trash (#104).
471
pub async fn wiki_delete(
472
    State(app): State<Shared>,
473
    viewer: Viewer,
474
    Path((owner, name)): Path<(String, String)>,
475
    Form(f): Form<DeleteForm>,
476
) -> Result<Page<RepoSettingsPage>, Error> {
477
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
478
    let (repo, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
479
    csrf(&viewer, &f.csrf_token, &shell)?;
480
    if f.confirm.trim() != repo.to_string() {
481
        return settings_page(&app, shell, row, role, None, Some("Type the repository's full name to confirm.".into())).await;
482
    }
483
    if !crate::wiki::exists(&app, &repo) {
484
        return settings_page(&app, shell, row, role, None, Some("This repository has no wiki.".into())).await;
485
    }
486
    let _guard = app.wikis.write_guard(&repo).await;
487
    crate::wiki::trash(&app, &repo).map_err(|e| Error::from_repo(shell.clone(), e))?;
488
    drop(_guard);
489
    accounts::audit(&app.db, viewer.account(), "wiki.data_deleted", &repo.to_string(), "moved to trash").await.ok();
490
    settings_page(&app, shell, row, role, Some("The wiki is in the trash; a new first page starts a fresh one.".into()), None).await
491
}
492
493
#[derive(Deserialize)]
494
pub struct DeleteForm {
495
    #[serde(rename = "_csrf")]
496
    csrf_token: String,
497
    confirm: String,
498
}
499
500
pub async fn delete(
501
    State(app): State<Shared>,
502
    viewer: Viewer,
503
    Path((owner, name)): Path<(String, String)>,
504
    Form(f): Form<DeleteForm>,
505
) -> Result<Response, Error> {
506
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
507
    let (repo, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
508
    csrf(&viewer, &f.csrf_token, &shell)?;
509
    if f.confirm.trim() != repo.to_string() {
510
        return Ok(settings_page(&app, shell, row, role, None, Some("Type the repository's full name to delete it.".into()))
511
            .await?
512
            .into_response());
513
    }
514
    let _guard = app.repos.write_guard(&repo).await;
515
    app.repos.trash(&repo).map_err(|e| Error::from_repo(shell.clone(), e))?;
516
    // A repository kept in a bucket leaves its data there: the owner's.
517
    crate::storage::removed(&app, &repo);
518
    crate::wiki::trash(&app, &repo).ok();
519
    db::repos::delete(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
520
    crate::assistants::delete_scope(&app.db, crate::levels::Scope::Repo(row.id)).await.ok();
521
    crate::variables::delete_scope(&app.db, crate::levels::Scope::Repo(row.id)).await.ok();
522
    crate::secrets::delete_scope(&app.db, crate::levels::Scope::Repo(row.id)).await.ok();
523
    accounts::audit(&app.db, viewer.account(), "repo.deleted", &repo.to_string(), "moved to trash").await.ok();
524
    Ok(Redirect::to(&format!("/{owner}")).into_response())
525
}
526
527
#[derive(Deserialize)]
528
pub struct RequirementsForm {
529
    #[serde(rename = "_csrf")]
530
    csrf_token: String,
531
    #[serde(default)]
532
    required_approvals: i64,
533
    #[serde(default)]
534
    require_owners: Option<String>,
535
    #[serde(default)]
536
    allow_conflicts: Option<String>,
537
}
538
539
pub async fn requirements(
540
    State(app): State<Shared>,
541
    viewer: Viewer,
542
    Path((owner, name)): Path<(String, String)>,
543
    Form(f): Form<RequirementsForm>,
544
) -> Result<Page<RepoSettingsPage>, Error> {
545
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
546
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
547
    csrf(&viewer, &f.csrf_token, &shell)?;
548
    let r = db::proposals::Requirements {
549
        required_approvals: f.required_approvals.max(0),
550
        require_owners: f.require_owners.is_some(),
551
        allow_conflicts: f.allow_conflicts.is_some(),
552
    };
553
    db::proposals::set_requirements(&app.db, row.id, r).await.map_err(|e| Error::from_db(shell.clone(), e))?;
554
    accounts::audit(
555
        &app.db,
556
        viewer.account(),
557
        "repo.requirements",
558
        &format!("{owner}/{name}"),
559
        &format!("{} approvals, owners {}, conflicts {}", r.required_approvals, r.require_owners, r.allow_conflicts),
560
    )
561
    .await
562
    .ok();
563
    settings_page(&app, shell, row, role, Some("Landing requirements saved.".into()), None).await
564
}
565
566
pub async fn hooks(
567
    State(app): State<Shared>,
568
    viewer: Viewer,
569
    Path((owner, name)): Path<(String, String)>,
570
    Form(f): Form<super::webhooks::HookForm>,
571
) -> Result<Page<RepoSettingsPage>, Error> {
572
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
573
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
574
    csrf(&viewer, &f.csrf_token, &shell)?;
575
    let scope = super::webhooks::Scope::Repo { id: row.id, slug: format!("{owner}/{name}") };
576
    match super::webhooks::apply(&app, &viewer, &scope, f).await.map_err(|e| Error::from_db(shell.clone(), e))? {
577
        Ok(notice) => settings_page(&app, shell, row, role, Some(notice), None).await,
578
        Err(error) => settings_page(&app, shell, row, role, None, Some(error)).await,
579
    }
580
}
581
582
pub async fn deliveries(
583
    State(app): State<Shared>,
584
    viewer: Viewer,
585
    Path((owner, name, id)): Path<(String, String, i64)>,
586
) -> Result<Page<super::webhooks::DeliveriesPage>, Error> {
587
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings/hooks/{id}/deliveries"), &viewer);
588
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
589
    let scope = super::webhooks::Scope::Repo { id: row.id, slug: format!("{owner}/{name}") };
590
    super::webhooks::deliveries_page(&app, shell, &scope, Some(RepoShell::new(&app, &row, "settings").with_role(role)), id).await
591
}
592
593
pub async fn redeliver(
594
    State(app): State<Shared>,
595
    viewer: Viewer,
596
    Path((owner, name, id, delivery)): Path<(String, String, i64, i64)>,
597
    Form(f): Form<super::session::CsrfForm>,
598
) -> Result<Page<super::webhooks::DeliveriesPage>, Error> {
599
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings/hooks/{id}/deliveries"), &viewer);
600
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
601
    csrf(&viewer, &f.csrf, &shell)?;
602
    let scope = super::webhooks::Scope::Repo { id: row.id, slug: format!("{owner}/{name}") };
603
    super::webhooks::redeliver(&app, &scope, id, delivery).await.map_err(|e| Error::from_db(shell.clone(), e))?;
604
    super::webhooks::deliveries_page(&app, shell, &scope, Some(RepoShell::new(&app, &row, "settings").with_role(role)), id).await
605
}
606
607
#[derive(Deserialize)]
608
pub struct ChecksForm {
609
    #[serde(rename = "_csrf")]
610
    csrf_token: String,
611
    #[serde(default)]
612
    required_checks: String,
613
}
614
615
pub async fn required_checks(
616
    State(app): State<Shared>,
617
    viewer: Viewer,
618
    Path((owner, name)): Path<(String, String)>,
619
    Form(f): Form<ChecksForm>,
620
) -> Result<Page<RepoSettingsPage>, Error> {
621
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
622
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
623
    csrf(&viewer, &f.csrf_token, &shell)?;
624
    let names: Vec<String> = f.required_checks.split(',').map(str::trim).filter(|s| !s.is_empty()).map(String::from).collect();
625
    db::hooks::set_required_checks(&app.db, row.id, &names).await.map_err(|e| Error::from_db(shell.clone(), e))?;
626
    settings_page(&app, shell, row, role, Some("Required checks saved.".into()), None).await
627
}
628
629
#[derive(Deserialize)]
630
pub struct StorageForm {
631
    #[serde(rename = "_csrf")]
632
    csrf_token: String,
633
    /// `check`, or `credentials` (with the new ones).
634
    action: String,
635
    #[serde(flatten)]
636
    bucket: BucketForm,
637
}
638
639
/// Settings → Storage (oxygenhub#96): check the bucket again, or replace
640
/// its credentials once the new ones pass the check.
641
pub async fn storage(
642
    State(app): State<Shared>,
643
    viewer: Viewer,
644
    Path((owner, name)): Path<(String, String)>,
645
    Form(f): Form<StorageForm>,
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::Admin).await?;
649
    csrf(&viewer, &f.csrf_token, &shell)?;
650
    let Some(s) = crate::storage::of(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))? else {
651
        return settings_page(&app, shell, row, role, None, Some("This repository is kept on this server's disk.".into())).await;
652
    };
653
    let scheme = s.url.split("://").next().unwrap_or("").to_string();
654
    let outcome = match f.action.as_str() {
655
        "credentials" => match f.bucket.credentials(&scheme) {
656
            Ok(c) => crate::storage::set_credentials(&app, &s, c).await.map(|r| format!("New credentials saved: {r}.")),
657
            Err(e) => Err(format!("Credentials: {e}")),
658
        },
659
        _ => crate::storage::check(&app, &s).await.map(|r| format!("The bucket passed the check: {r}.")),
660
    };
661
    match outcome {
662
        Ok(notice) => {
663
            accounts::audit(&app.db, viewer.account(), "repo.storage", &format!("{owner}/{name}"), &f.action).await.ok();
664
            settings_page(&app, shell, row, role, Some(notice), None).await
665
        }
666
        Err(e) => settings_page(&app, shell, row, role, None, Some(format!("Storage: {e}"))).await,
667
    }
668
}
669
670
#[derive(Deserialize)]
671
pub struct MirrorForm {
672
    #[serde(rename = "_csrf")]
673
    csrf_token: String,
674
    #[serde(default)]
675
    enabled: Option<String>,
676
}
677
678
#[derive(Deserialize)]
679
pub struct FlowForm {
680
    #[serde(rename = "_csrf")]
681
    csrf_token: String,
682
    /// `on`, `off`, or `default`.
683
    flow: String,
684
}
685
686
/// Turn OxygenFlow on or off for one repository. Runs use the site's
687
/// runners, so only site administrators decide.
688
pub async fn flow(
689
    State(app): State<Shared>,
690
    viewer: Viewer,
691
    Path((owner, name)): Path<(String, String)>,
692
    Form(f): Form<FlowForm>,
693
) -> Result<Page<RepoSettingsPage>, Error> {
694
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
695
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
696
    csrf(&viewer, &f.csrf_token, &shell)?;
697
    if !viewer.is_admin() {
698
        return settings_page(&app, shell, row, role, None, Some("Only site administrators turn OxygenFlow on or off.".into())).await;
699
    }
700
    let setting = match f.flow.as_str() {
701
        "on" => "on",
702
        "off" => "off",
703
        "default" => "",
704
        _ => return settings_page(&app, shell, row, role, None, Some("Pick on, off or the site default.".into())).await,
705
    };
706
    db::repos::set_flow(&app.db, row.id, setting).await.map_err(|e| Error::from_db(shell.clone(), e))?;
707
    let label = if setting.is_empty() { "default" } else { setting };
708
    accounts::audit(&app.db, viewer.account(), "repo.flow", &format!("{owner}/{name}"), label).await.ok();
709
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
710
    let notice = if crate::flow::enabled(&app, &row) {
711
        "OxygenFlow is on: pushes with a .flow/pipeline.kdl start runs."
712
    } else {
713
        "OxygenFlow is off for this repository."
714
    };
715
    settings_page(&app, shell, row, role, Some(notice.into()), None).await
716
}
717
718
pub async fn mirror(
719
    State(app): State<Shared>,
720
    viewer: Viewer,
721
    Path((owner, name)): Path<(String, String)>,
722
    Form(f): Form<MirrorForm>,
723
) -> Result<Page<RepoSettingsPage>, Error> {
724
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
725
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
726
    csrf(&viewer, &f.csrf_token, &shell)?;
727
    db::repos::set_git_mirror(&app.db, row.id, f.enabled.is_some()).await.map_err(|e| Error::from_db(shell.clone(), e))?;
728
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
729
    if row.git_mirror {
730
        crate::mirror::refresh(&app, &row).await;
731
    }
732
    accounts::audit(&app.db, viewer.account(), "repo.mirror", &format!("{owner}/{name}"), if row.git_mirror { "on" } else { "off" })
733
        .await
734
        .ok();
735
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
736
    let notice = if row.git_mirror { "The Git mirror is on and up to date." } else { "The Git mirror is off." };
737
    settings_page(&app, shell, row, role, Some(notice.into()), None).await
738
}
739
740
/// `POST /{owner}/{name}/settings/runners/token`: a one-time registration
741
/// token for a runner that serves this repository only (oxygenhub#41).
742
pub async fn runner_token(
743
    State(app): State<Shared>,
744
    viewer: Viewer,
745
    Path((owner, name)): Path<(String, String)>,
746
    Form(f): Form<super::session::CsrfForm>,
747
) -> Result<Page<RepoSettingsPage>, Error> {
748
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
749
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
750
    csrf(&viewer, &f.csrf, &shell)?;
751
    let me = viewer.account().map(|a| a.username.clone()).unwrap_or_default();
752
    let token = format!("flowreg_{}", crate::auth::random_hex(24));
753
    let scope = db::runners::Scope { owner: row.owner.clone(), repo_id: Some(row.id) };
754
    db::runners::add_registration(&app.db, &blake3::hash(token.as_bytes()).to_hex(), &me, &scope)
755
        .await
756
        .map_err(|e| Error::from_db(shell.clone(), e))?;
757
    accounts::audit(&app.db, viewer.account(), "flow.runner.token", &row.slug(), "registration token for this repository").await.ok();
758
    let mut page = settings_page(&app, shell, row, role, Some("Copy the token now; it is not shown again.".into()), None).await?;
759
    page.0.runner_token = Some(token);
760
    Ok(page)
761
}
762
763
/// `POST /{owner}/{name}/settings/runners/{id}/remove`: remove one of this
764
/// repository's own runners, when it is running nothing.
765
pub async fn runner_remove(
766
    State(app): State<Shared>,
767
    viewer: Viewer,
768
    Path((owner, name, id)): Path<(String, String, i64)>,
769
    Form(f): Form<super::session::CsrfForm>,
770
) -> Result<Page<RepoSettingsPage>, Error> {
771
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
772
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
773
    csrf(&viewer, &f.csrf, &shell)?;
774
    let e = |e| Error::from_db(shell.clone(), e);
775
    let Some(r) = db::runners::get(&app.db, id).await.map_err(e)?.filter(|r| r.scope_repo_id == Some(row.id)) else {
776
        return Err(Error::not_found(shell));
777
    };
778
    if db::runners::running(&app.db).await.map_err(e)?.iter().any(|(rid, n)| *rid == id && *n > 0) {
779
        return settings_page(&app, shell, row, role, None, Some(format!("{} is running jobs: wait for them to finish.", r.name))).await;
780
    }
781
    db::runners::remove(&app.db, id).await.map_err(e)?;
782
    accounts::audit(&app.db, viewer.account(), "flow.runner.removed", &r.name, &row.slug()).await.ok();
783
    settings_page(&app, shell, row, role, Some(format!("{} removed: its credential no longer works.", r.name)), None).await
784
}
785
786
fn repo_assistants(row: &db::repos::RepoRow) -> crate::levels::At {
787
    crate::levels::At::repo(row)
788
}
789
790
/// `POST /{owner}/{name}/settings/assistants`: the repository's assistants
791
/// and prompt (#97), for its admins.
792
pub async fn assistants(
793
    State(app): State<Shared>,
794
    viewer: Viewer,
795
    Path((owner, name)): Path<(String, String)>,
796
    Form(f): Form<super::assistants::AssistantForm>,
797
) -> Result<Page<RepoSettingsPage>, Error> {
798
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
799
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
800
    csrf(&viewer, &f.csrf_token, &shell)?;
801
    match super::assistants::apply(&app, &viewer, &repo_assistants(&row), f).await.map_err(|e| Error::from_db(shell.clone(), e))? {
802
        Ok(notice) => settings_page(&app, shell, row, role, Some(notice), None).await,
803
        Err(error) => settings_page(&app, shell, row, role, None, Some(error)).await,
804
    }
805
}
806
807
/// `POST /{owner}/{name}/settings/variables`: the repository's Flow
808
/// variables (#102), for its admins.
809
pub async fn variables(
810
    State(app): State<Shared>,
811
    viewer: Viewer,
812
    Path((owner, name)): Path<(String, String)>,
813
    Form(f): Form<super::variables::VariableForm>,
814
) -> Result<Page<RepoSettingsPage>, Error> {
815
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
816
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
817
    csrf(&viewer, &f.csrf_token, &shell)?;
818
    match super::variables::apply(&app, &viewer, &repo_assistants(&row), f).await.map_err(|e| Error::from_db(shell.clone(), e))? {
819
        Ok(notice) => settings_page(&app, shell, row, role, Some(notice), None).await,
820
        Err(error) => settings_page(&app, shell, row, role, None, Some(error)).await,
821
    }
822
}
823
824
/// `POST /{owner}/{name}/settings/secrets`: the repository's Flow secrets
825
/// (#107), for its admins.
826
pub async fn secrets(
827
    State(app): State<Shared>,
828
    viewer: Viewer,
829
    Path((owner, name)): Path<(String, String)>,
830
    Form(f): Form<super::secrets::SecretForm>,
831
) -> Result<Page<RepoSettingsPage>, Error> {
832
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
833
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
834
    csrf(&viewer, &f.csrf_token, &shell)?;
835
    match super::secrets::apply(&app, &viewer, &repo_assistants(&row), f).await.map_err(|e| Error::from_db(shell.clone(), e))? {
836
        Ok(notice) => settings_page(&app, shell, row, role, Some(notice), None).await,
837
        Err(error) => settings_page(&app, shell, row, role, None, Some(error)).await,
838
    }
839
}
840
841
/// `POST /{owner}/{name}/settings/assistants/models`: what a provider offers (#109).
842
pub async fn assistant_models(
843
    State(app): State<Shared>,
844
    viewer: Viewer,
845
    Path((owner, name)): Path<(String, String)>,
846
    Form(f): Form<super::assistants::ModelsForm>,
847
) -> Result<axum::Json<serde_json::Value>, Error> {
848
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
849
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
850
    csrf(&viewer, &f.csrf_token, &shell)?;
851
    Ok(axum::Json(super::assistants::models(&app, &repo_assistants(&row), f).await.map_err(|e| Error::from_db(shell.clone(), e))?))
852
}