OXYGENHUB
# d0fbea2eb46e oxygenhub / crates / hub / src / web / repo_settings.rs
30.2 KiB · Rust · last changed Advanced settings: transfer, archive, delete, wiki placeholder 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
}
49
50
fn default_visibility() -> String {
51
    "public".into()
52
}
53
54
pub async fn create(State(app): State<Shared>, viewer: Viewer, Form(f): Form<NewRepoForm>) -> Result<Response, Error> {
55
    let shell = Shell::new(&app, "/new", &viewer);
56
    let me = require(&viewer, &shell)?;
57
    csrf(&viewer, &f.csrf_token, &shell)?;
58
    let owners = owners_for(&app, &me.account).await;
59
    let fail = |msg: &str| Page(NewRepoPage { shell: shell.clone(), owners: owners.clone(), error: Some(msg.into()) }).into_response();
60
    let Some(name) = RepoName::new(f.owner.trim(), f.name.trim()) else {
61
        return Ok(fail("A repository name is letters, digits, dots, dashes and underscores."));
62
    };
63
    if !roles::may_create_under(&app.db, &me.account, &name.owner).await.map_err(|e| Error::from_db(shell.clone(), e))? {
64
        return Ok(fail("You cannot create repositories under that owner."));
65
    }
66
    let Some(visibility) = Visibility::parse(&f.visibility) else { return Ok(fail("Visibility is public, internal or private.")) };
67
    if db::repos::get(&app.db, &name.owner, &name.name).await.map_err(|e| Error::from_db(shell.clone(), e))?.is_some()
68
        || app.repos.exists(&name)
69
    {
70
        return Ok(fail("That repository exists."));
71
    }
72
    app.repos.create(&name, None).map_err(|e| Error::from_repo(shell.clone(), e))?;
73
    db::repos::create(&app.db, &name.owner, &name.name, f.description.trim(), visibility)
74
        .await
75
        .map_err(|e| Error::from_db(shell.clone(), e))?;
76
    accounts::audit(&app.db, Some(&me.account), "repo.created", &name.to_string(), visibility.as_str()).await.ok();
77
    Ok(Redirect::to(&name.url()).into_response())
78
}
79
80
#[derive(Template)]
81
#[template(path = "repo_settings.html")]
82
pub struct RepoSettingsPage {
83
    shell: Shell,
84
    repo: RepoShell,
85
    row: db::repos::RepoRow,
86
    collaborators: Vec<accounts::Collaborator>,
87
    teams: Vec<accounts::Team>,
88
    role: Role,
89
    requirements: db::proposals::Requirements,
90
    hooks: Vec<super::webhooks::HookRow>,
91
    required_checks: String,
92
    /// Whether hub.toml alone would run Flow here, and whether it runs.
93
    flow_default: bool,
94
    flow_enabled: bool,
95
    site_admin: bool,
96
    notice: Option<String>,
97
    error: Option<String>,
98
    /// Runners that may take this repository's jobs: (runner, what it
99
    /// serves, whether it is this repository's own).
100
    runners: Vec<(db::runners::Runner, String, bool)>,
101
    /// A registration token just made, shown once.
102
    runner_token: Option<String>,
103
    /// The repository's AI assistants and prompt (#97).
104
    assistants: super::assistants::Panel,
105
    /// The repository's Flow variables (#102).
106
    variables: super::variables::Panel,
107
    /// The repository's Flow secrets (#107).
108
    secrets: super::secrets::Panel,
109
}
110
111
async fn settings_page(
112
    app: &Shared,
113
    shell: Shell,
114
    row: db::repos::RepoRow,
115
    role: Role,
116
    notice: Option<String>,
117
    error: Option<String>,
118
) -> Result<Page<RepoSettingsPage>, Error> {
119
    let collaborators = accounts::collaborators(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
120
    let teams = match accounts::org_by_name(&app.db, &row.owner).await.map_err(|e| Error::from_db(shell.clone(), e))? {
121
        Some(org) => accounts::teams(&app.db, org.id).await.map_err(|e| Error::from_db(shell.clone(), e))?,
122
        None => vec![],
123
    };
124
    let repo = RepoShell::new(app, &row, "settings").with_role(role);
125
    let requirements = db::proposals::requirements(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
126
    let hooks = db::hooks::hooks_for_repo(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
127
    let hooks = super::webhooks::rows(app, hooks).await.map_err(|e| Error::from_db(shell.clone(), e))?;
128
    let required_checks = db::hooks::required_checks(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.join(", ");
129
    let flow_default = app.config.flow.allows(&row.owner, &row.name);
130
    let flow_enabled = crate::flow::enabled(app, &row);
131
    let site_admin = shell.is_admin;
132
    let runners = db::runners::serving(&app.db, &row.owner, row.id)
133
        .await
134
        .map_err(|e| Error::from_db(shell.clone(), e))?
135
        .into_iter()
136
        .map(|r| {
137
            let own = r.scope_repo_id == Some(row.id);
138
            let serves = if own {
139
                "this repository".to_string()
140
            } else if r.scope_owner.is_empty() {
141
                "the whole site".to_string()
142
            } else {
143
                format!("{}'s repositories", r.scope_owner)
144
            };
145
            (r, serves, own)
146
        })
147
        .collect();
148
    let assistants = super::assistants::panel(app, &repo_assistants(&row)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
149
    let variables = super::variables::panel(app, &repo_assistants(&row)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
150
    let secrets = super::secrets::panel(app, &repo_assistants(&row)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
151
    Ok(Page(RepoSettingsPage {
152
        assistants,
153
        variables,
154
        secrets,
155
        runners,
156
        runner_token: None,
157
        shell,
158
        repo,
159
        row,
160
        collaborators,
161
        teams,
162
        role,
163
        requirements,
164
        hooks,
165
        required_checks,
166
        flow_default,
167
        flow_enabled,
168
        site_admin,
169
        notice,
170
        error,
171
    }))
172
}
173
174
pub async fn settings(
175
    State(app): State<Shared>,
176
    viewer: Viewer,
177
    Path((owner, name)): Path<(String, String)>,
178
) -> Result<Page<RepoSettingsPage>, Error> {
179
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
180
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
181
    settings_page(&app, shell, row, role, None, None).await
182
}
183
184
#[derive(Deserialize)]
185
pub struct GeneralForm {
186
    #[serde(rename = "_csrf")]
187
    csrf_token: String,
188
    description: String,
189
    default_bookmark: String,
190
    visibility: String,
191
    #[serde(default)]
192
    topics: String,
193
}
194
195
pub async fn update(
196
    State(app): State<Shared>,
197
    viewer: Viewer,
198
    Path((owner, name)): Path<(String, String)>,
199
    Form(f): Form<GeneralForm>,
200
) -> Result<Page<RepoSettingsPage>, Error> {
201
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
202
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
203
    csrf(&viewer, &f.csrf_token, &shell)?;
204
    let e = |e| Error::from_db(shell.clone(), e);
205
    let topics = match db::repos::parse_topics(&f.topics) {
206
        Ok(t) => t,
207
        Err(msg) => return settings_page(&app, shell, row, role, None, Some(msg)).await,
208
    };
209
    db::repos::set_description(&app.db, row.id, f.description.trim()).await.map_err(e)?;
210
    db::repos::set_topics(&app.db, row.id, &topics).await.map_err(e)?;
211
    if crate::repos::names::valid_segment(f.default_bookmark.trim()) {
212
        db::repos::set_default_bookmark(&app.db, row.id, f.default_bookmark.trim()).await.map_err(e)?;
213
    }
214
    if role >= Role::Admin {
215
        if let Some(v) = Visibility::parse(&f.visibility) {
216
            db::repos::set_visibility(&app.db, row.id, v).await.map_err(e)?;
217
        }
218
    }
219
    accounts::audit(&app.db, viewer.account(), "repo.updated", &format!("{owner}/{name}"), "").await.ok();
220
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(e)?.unwrap_or(row);
221
    settings_page(&app, shell, row, role, Some("Saved.".into()), None).await
222
}
223
224
#[derive(Deserialize)]
225
pub struct CollaboratorForm {
226
    #[serde(rename = "_csrf")]
227
    csrf_token: String,
228
    #[serde(default)]
229
    username: String,
230
    #[serde(default)]
231
    team: String,
232
    #[serde(default)]
233
    role: String,
234
    #[serde(default)]
235
    remove: Option<String>,
236
}
237
238
pub async fn collaborator(
239
    State(app): State<Shared>,
240
    viewer: Viewer,
241
    Path((owner, name)): Path<(String, String)>,
242
    Form(f): Form<CollaboratorForm>,
243
) -> Result<Page<RepoSettingsPage>, Error> {
244
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
245
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
246
    csrf(&viewer, &f.csrf_token, &shell)?;
247
    let e = |e| Error::from_db(shell.clone(), e);
248
    let (account_id, team_id, label) = if !f.username.trim().is_empty() {
249
        match accounts::account_by_name(&app.db, f.username.trim()).await.map_err(e)? {
250
            Some(a) => (Some(a.id), None, a.username),
251
            None => return settings_page(&app, shell, row, role, None, Some("No such account.".into())).await,
252
        }
253
    } else if !f.team.trim().is_empty() {
254
        let org = accounts::org_by_name(&app.db, &row.owner).await.map_err(e)?;
255
        let team = match org {
256
            Some(o) => accounts::team_by_name(&app.db, o.id, f.team.trim()).await.map_err(e)?,
257
            None => None,
258
        };
259
        match team {
260
            Some(t) => (None, Some(t.id), format!("team {}", t.name)),
261
            None => return settings_page(&app, shell, row, role, None, Some("No such team in this organisation.".into())).await,
262
        }
263
    } else {
264
        return settings_page(&app, shell, row, role, None, Some("Name an account or a team.".into())).await;
265
    };
266
    if f.remove.is_some() {
267
        accounts::remove_collaborator(&app.db, row.id, account_id, team_id).await.map_err(e)?;
268
        accounts::audit(&app.db, viewer.account(), "repo.collaborator.removed", &format!("{owner}/{name}"), &label).await.ok();
269
        return settings_page(&app, shell, row, role, Some(format!("Removed {label}.")), None).await;
270
    }
271
    let Some(new_role) = Role::parse(&f.role) else {
272
        return settings_page(&app, shell, row, role, None, Some("Pick a role.".into())).await;
273
    };
274
    accounts::set_collaborator(&app.db, row.id, account_id, team_id, new_role.as_str()).await.map_err(e)?;
275
    accounts::audit(
276
        &app.db,
277
        viewer.account(),
278
        "repo.collaborator.set",
279
        &format!("{owner}/{name}"),
280
        &format!("{label} {}", new_role.as_str()),
281
    )
282
    .await
283
    .ok();
284
    settings_page(&app, shell, row, role, Some(format!("{label} now has {} access.", new_role.as_str())), None).await
285
}
286
287
#[derive(Deserialize)]
288
pub struct TransferForm {
289
    #[serde(rename = "_csrf")]
290
    csrf_token: String,
291
    owner: String,
292
    confirm: String,
293
}
294
295
/// `POST /{owner}/{name}/settings/transfer`: move the repository to a
296
/// user or organisation the admin may create repositories under (#108).
297
/// The old URL redirects and oxy remotes keep working.
298
pub async fn transfer(
299
    State(app): State<Shared>,
300
    viewer: Viewer,
301
    Path((owner, name)): Path<(String, String)>,
302
    Form(f): Form<TransferForm>,
303
) -> Result<Response, Error> {
304
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
305
    let (repo, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
306
    csrf(&viewer, &f.csrf_token, &shell)?;
307
    let me = viewer.account().cloned().ok_or_else(|| Error::not_found(shell.clone()))?;
308
    let fail = |msg: &str| settings_page(&app, shell.clone(), row.clone(), role, None, Some(msg.into()));
309
    if f.confirm.trim() != repo.to_string() {
310
        return Ok(fail("Type the repository's full name to confirm.").await?.into_response());
311
    }
312
    let to = f.owner.trim().trim_start_matches('@').to_string();
313
    let Some(dest) = RepoName::new(&to, &row.name) else {
314
        return Ok(fail("That is not an owner name.").await?.into_response());
315
    };
316
    if dest.owner == row.owner {
317
        return Ok(fail("The repository is already there.").await?.into_response());
318
    }
319
    let e = |e| Error::from_db(shell.clone(), e);
320
    let known = accounts::account_by_name(&app.db, &dest.owner).await.map_err(e)?.is_some()
321
        || accounts::org_by_name(&app.db, &dest.owner).await.map_err(e)?.is_some();
322
    if !known {
323
        return Ok(fail("There is no user or organisation of that name.").await?.into_response());
324
    }
325
    if !roles::may_create_under(&app.db, &me, &dest.owner).await.map_err(e)? {
326
        return Ok(fail("You cannot create repositories under that owner, so you cannot move one there.").await?.into_response());
327
    }
328
    if db::repos::get(&app.db, &dest.owner, &dest.name).await.map_err(e)?.is_some() || app.repos.exists(&dest) {
329
        return Ok(fail("That owner already has a repository of this name.").await?.into_response());
330
    }
331
    {
332
        let _guard = app.repos.write_guard(&repo).await;
333
        app.repos.rename(&repo, &dest).map_err(|e| Error::from_repo(shell.clone(), e))?;
334
        db::repos::transfer(&app.db, row.id, &row.owner, &row.name, &dest.owner).await.map_err(e)?;
335
        let mirror = crate::mirror::mirror_dir(&app, &repo);
336
        if mirror.is_dir() {
337
            let to = crate::mirror::mirror_dir(&app, &dest);
338
            if let Some(parent) = to.parent() {
339
                std::fs::create_dir_all(parent).ok();
340
            }
341
            std::fs::rename(&mirror, &to).ok();
342
        }
343
    }
344
    accounts::audit(&app.db, Some(&me), "repo.transferred", &repo.to_string(), &dest.to_string()).await.ok();
345
    let notice = super::settings::urlencode(&format!("Moved from {repo}; the old address redirects here."));
346
    Ok(Redirect::to(&format!("{}/settings?notice={notice}", dest.url())).into_response())
347
}
348
349
#[derive(Deserialize)]
350
pub struct ArchiveForm {
351
    #[serde(rename = "_csrf")]
352
    csrf_token: String,
353
    /// `archive` or `unarchive`.
354
    action: String,
355
    #[serde(default)]
356
    reason: String,
357
}
358
359
/// `POST /{owner}/{name}/settings/archive`: read-only, with a reason, or
360
/// back again (#108).
361
pub async fn archive(
362
    State(app): State<Shared>,
363
    viewer: Viewer,
364
    Path((owner, name)): Path<(String, String)>,
365
    Form(f): Form<ArchiveForm>,
366
) -> Result<Page<RepoSettingsPage>, Error> {
367
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
368
    let (repo, mut row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
369
    csrf(&viewer, &f.csrf_token, &shell)?;
370
    let archive = match f.action.as_str() {
371
        "archive" => true,
372
        "unarchive" => false,
373
        _ => return settings_page(&app, shell, row, role, None, Some("Unknown action.".into())).await,
374
    };
375
    let reason: String = f.reason.trim().chars().take(500).collect();
376
    db::repos::set_archived(&app.db, row.id, archive, &reason).await.map_err(|e| Error::from_db(shell.clone(), e))?;
377
    accounts::audit(&app.db, viewer.account(), if archive { "repo.archived" } else { "repo.unarchived" }, &repo.to_string(), &reason)
378
        .await
379
        .ok();
380
    row.archived = archive;
381
    row.archived_reason = if archive { reason } else { String::new() };
382
    let notice = if archive {
383
        "The repository is archived: read-only until it is brought back."
384
    } else {
385
        "The repository is back: writes are allowed again."
386
    };
387
    settings_page(&app, shell, row, role, Some(notice.into()), None).await
388
}
389
390
#[derive(Deserialize)]
391
pub struct DeleteForm {
392
    #[serde(rename = "_csrf")]
393
    csrf_token: String,
394
    confirm: String,
395
}
396
397
pub async fn delete(
398
    State(app): State<Shared>,
399
    viewer: Viewer,
400
    Path((owner, name)): Path<(String, String)>,
401
    Form(f): Form<DeleteForm>,
402
) -> Result<Response, Error> {
403
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
404
    let (repo, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
405
    csrf(&viewer, &f.csrf_token, &shell)?;
406
    if f.confirm.trim() != repo.to_string() {
407
        return Ok(settings_page(&app, shell, row, role, None, Some("Type the repository's full name to delete it.".into()))
408
            .await?
409
            .into_response());
410
    }
411
    let _guard = app.repos.write_guard(&repo).await;
412
    app.repos.trash(&repo).map_err(|e| Error::from_repo(shell.clone(), e))?;
413
    db::repos::delete(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
414
    crate::assistants::delete_scope(&app.db, crate::levels::Scope::Repo(row.id)).await.ok();
415
    crate::variables::delete_scope(&app.db, crate::levels::Scope::Repo(row.id)).await.ok();
416
    crate::secrets::delete_scope(&app.db, crate::levels::Scope::Repo(row.id)).await.ok();
417
    accounts::audit(&app.db, viewer.account(), "repo.deleted", &repo.to_string(), "moved to trash").await.ok();
418
    Ok(Redirect::to(&format!("/{owner}")).into_response())
419
}
420
421
#[derive(Deserialize)]
422
pub struct RequirementsForm {
423
    #[serde(rename = "_csrf")]
424
    csrf_token: String,
425
    #[serde(default)]
426
    required_approvals: i64,
427
    #[serde(default)]
428
    require_owners: Option<String>,
429
    #[serde(default)]
430
    allow_conflicts: Option<String>,
431
}
432
433
pub async fn requirements(
434
    State(app): State<Shared>,
435
    viewer: Viewer,
436
    Path((owner, name)): Path<(String, String)>,
437
    Form(f): Form<RequirementsForm>,
438
) -> Result<Page<RepoSettingsPage>, Error> {
439
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
440
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
441
    csrf(&viewer, &f.csrf_token, &shell)?;
442
    let r = db::proposals::Requirements {
443
        required_approvals: f.required_approvals.max(0),
444
        require_owners: f.require_owners.is_some(),
445
        allow_conflicts: f.allow_conflicts.is_some(),
446
    };
447
    db::proposals::set_requirements(&app.db, row.id, r).await.map_err(|e| Error::from_db(shell.clone(), e))?;
448
    accounts::audit(
449
        &app.db,
450
        viewer.account(),
451
        "repo.requirements",
452
        &format!("{owner}/{name}"),
453
        &format!("{} approvals, owners {}, conflicts {}", r.required_approvals, r.require_owners, r.allow_conflicts),
454
    )
455
    .await
456
    .ok();
457
    settings_page(&app, shell, row, role, Some("Landing requirements saved.".into()), None).await
458
}
459
460
pub async fn hooks(
461
    State(app): State<Shared>,
462
    viewer: Viewer,
463
    Path((owner, name)): Path<(String, String)>,
464
    Form(f): Form<super::webhooks::HookForm>,
465
) -> Result<Page<RepoSettingsPage>, Error> {
466
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
467
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
468
    csrf(&viewer, &f.csrf_token, &shell)?;
469
    let scope = super::webhooks::Scope::Repo { id: row.id, slug: format!("{owner}/{name}") };
470
    match super::webhooks::apply(&app, &viewer, &scope, f).await.map_err(|e| Error::from_db(shell.clone(), e))? {
471
        Ok(notice) => settings_page(&app, shell, row, role, Some(notice), None).await,
472
        Err(error) => settings_page(&app, shell, row, role, None, Some(error)).await,
473
    }
474
}
475
476
pub async fn deliveries(
477
    State(app): State<Shared>,
478
    viewer: Viewer,
479
    Path((owner, name, id)): Path<(String, String, i64)>,
480
) -> Result<Page<super::webhooks::DeliveriesPage>, Error> {
481
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings/hooks/{id}/deliveries"), &viewer);
482
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
483
    let scope = super::webhooks::Scope::Repo { id: row.id, slug: format!("{owner}/{name}") };
484
    super::webhooks::deliveries_page(&app, shell, &scope, Some(RepoShell::new(&app, &row, "settings").with_role(role)), id).await
485
}
486
487
pub async fn redeliver(
488
    State(app): State<Shared>,
489
    viewer: Viewer,
490
    Path((owner, name, id, delivery)): Path<(String, String, i64, i64)>,
491
    Form(f): Form<super::session::CsrfForm>,
492
) -> Result<Page<super::webhooks::DeliveriesPage>, Error> {
493
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings/hooks/{id}/deliveries"), &viewer);
494
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
495
    csrf(&viewer, &f.csrf, &shell)?;
496
    let scope = super::webhooks::Scope::Repo { id: row.id, slug: format!("{owner}/{name}") };
497
    super::webhooks::redeliver(&app, &scope, id, delivery).await.map_err(|e| Error::from_db(shell.clone(), e))?;
498
    super::webhooks::deliveries_page(&app, shell, &scope, Some(RepoShell::new(&app, &row, "settings").with_role(role)), id).await
499
}
500
501
#[derive(Deserialize)]
502
pub struct ChecksForm {
503
    #[serde(rename = "_csrf")]
504
    csrf_token: String,
505
    #[serde(default)]
506
    required_checks: String,
507
}
508
509
pub async fn required_checks(
510
    State(app): State<Shared>,
511
    viewer: Viewer,
512
    Path((owner, name)): Path<(String, String)>,
513
    Form(f): Form<ChecksForm>,
514
) -> Result<Page<RepoSettingsPage>, Error> {
515
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
516
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
517
    csrf(&viewer, &f.csrf_token, &shell)?;
518
    let names: Vec<String> = f.required_checks.split(',').map(str::trim).filter(|s| !s.is_empty()).map(String::from).collect();
519
    db::hooks::set_required_checks(&app.db, row.id, &names).await.map_err(|e| Error::from_db(shell.clone(), e))?;
520
    settings_page(&app, shell, row, role, Some("Required checks saved.".into()), None).await
521
}
522
523
#[derive(Deserialize)]
524
pub struct MirrorForm {
525
    #[serde(rename = "_csrf")]
526
    csrf_token: String,
527
    #[serde(default)]
528
    enabled: Option<String>,
529
}
530
531
#[derive(Deserialize)]
532
pub struct FlowForm {
533
    #[serde(rename = "_csrf")]
534
    csrf_token: String,
535
    /// `on`, `off`, or `default`.
536
    flow: String,
537
}
538
539
/// Turn OxygenFlow on or off for one repository. Runs use the site's
540
/// runners, so only site administrators decide.
541
pub async fn flow(
542
    State(app): State<Shared>,
543
    viewer: Viewer,
544
    Path((owner, name)): Path<(String, String)>,
545
    Form(f): Form<FlowForm>,
546
) -> Result<Page<RepoSettingsPage>, Error> {
547
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
548
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
549
    csrf(&viewer, &f.csrf_token, &shell)?;
550
    if !viewer.is_admin() {
551
        return settings_page(&app, shell, row, role, None, Some("Only site administrators turn OxygenFlow on or off.".into())).await;
552
    }
553
    let setting = match f.flow.as_str() {
554
        "on" => "on",
555
        "off" => "off",
556
        "default" => "",
557
        _ => return settings_page(&app, shell, row, role, None, Some("Pick on, off or the site default.".into())).await,
558
    };
559
    db::repos::set_flow(&app.db, row.id, setting).await.map_err(|e| Error::from_db(shell.clone(), e))?;
560
    let label = if setting.is_empty() { "default" } else { setting };
561
    accounts::audit(&app.db, viewer.account(), "repo.flow", &format!("{owner}/{name}"), label).await.ok();
562
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
563
    let notice = if crate::flow::enabled(&app, &row) {
564
        "OxygenFlow is on: pushes with a .flow/pipeline.kdl start runs."
565
    } else {
566
        "OxygenFlow is off for this repository."
567
    };
568
    settings_page(&app, shell, row, role, Some(notice.into()), None).await
569
}
570
571
pub async fn mirror(
572
    State(app): State<Shared>,
573
    viewer: Viewer,
574
    Path((owner, name)): Path<(String, String)>,
575
    Form(f): Form<MirrorForm>,
576
) -> Result<Page<RepoSettingsPage>, Error> {
577
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
578
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
579
    csrf(&viewer, &f.csrf_token, &shell)?;
580
    db::repos::set_git_mirror(&app.db, row.id, f.enabled.is_some()).await.map_err(|e| Error::from_db(shell.clone(), e))?;
581
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
582
    if row.git_mirror {
583
        crate::mirror::refresh(&app, &row).await;
584
    }
585
    accounts::audit(&app.db, viewer.account(), "repo.mirror", &format!("{owner}/{name}"), if row.git_mirror { "on" } else { "off" })
586
        .await
587
        .ok();
588
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
589
    let notice = if row.git_mirror { "The Git mirror is on and up to date." } else { "The Git mirror is off." };
590
    settings_page(&app, shell, row, role, Some(notice.into()), None).await
591
}
592
593
/// `POST /{owner}/{name}/settings/runners/token`: a one-time registration
594
/// token for a runner that serves this repository only (oxygenhub#41).
595
pub async fn runner_token(
596
    State(app): State<Shared>,
597
    viewer: Viewer,
598
    Path((owner, name)): Path<(String, String)>,
599
    Form(f): Form<super::session::CsrfForm>,
600
) -> Result<Page<RepoSettingsPage>, Error> {
601
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
602
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
603
    csrf(&viewer, &f.csrf, &shell)?;
604
    let me = viewer.account().map(|a| a.username.clone()).unwrap_or_default();
605
    let token = format!("flowreg_{}", crate::auth::random_hex(24));
606
    let scope = db::runners::Scope { owner: row.owner.clone(), repo_id: Some(row.id) };
607
    db::runners::add_registration(&app.db, &blake3::hash(token.as_bytes()).to_hex(), &me, &scope)
608
        .await
609
        .map_err(|e| Error::from_db(shell.clone(), e))?;
610
    accounts::audit(&app.db, viewer.account(), "flow.runner.token", &row.slug(), "registration token for this repository").await.ok();
611
    let mut page = settings_page(&app, shell, row, role, Some("Copy the token now; it is not shown again.".into()), None).await?;
612
    page.0.runner_token = Some(token);
613
    Ok(page)
614
}
615
616
/// `POST /{owner}/{name}/settings/runners/{id}/remove`: remove one of this
617
/// repository's own runners, when it is running nothing.
618
pub async fn runner_remove(
619
    State(app): State<Shared>,
620
    viewer: Viewer,
621
    Path((owner, name, id)): Path<(String, String, i64)>,
622
    Form(f): Form<super::session::CsrfForm>,
623
) -> Result<Page<RepoSettingsPage>, Error> {
624
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
625
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
626
    csrf(&viewer, &f.csrf, &shell)?;
627
    let e = |e| Error::from_db(shell.clone(), e);
628
    let Some(r) = db::runners::get(&app.db, id).await.map_err(e)?.filter(|r| r.scope_repo_id == Some(row.id)) else {
629
        return Err(Error::not_found(shell));
630
    };
631
    if db::runners::running(&app.db).await.map_err(e)?.iter().any(|(rid, n)| *rid == id && *n > 0) {
632
        return settings_page(&app, shell, row, role, None, Some(format!("{} is running jobs: wait for them to finish.", r.name))).await;
633
    }
634
    db::runners::remove(&app.db, id).await.map_err(e)?;
635
    accounts::audit(&app.db, viewer.account(), "flow.runner.removed", &r.name, &row.slug()).await.ok();
636
    settings_page(&app, shell, row, role, Some(format!("{} removed: its credential no longer works.", r.name)), None).await
637
}
638
639
fn repo_assistants(row: &db::repos::RepoRow) -> crate::levels::At {
640
    crate::levels::At::repo(row)
641
}
642
643
/// `POST /{owner}/{name}/settings/assistants`: the repository's assistants
644
/// and prompt (#97), for its admins.
645
pub async fn assistants(
646
    State(app): State<Shared>,
647
    viewer: Viewer,
648
    Path((owner, name)): Path<(String, String)>,
649
    Form(f): Form<super::assistants::AssistantForm>,
650
) -> Result<Page<RepoSettingsPage>, Error> {
651
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
652
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
653
    csrf(&viewer, &f.csrf_token, &shell)?;
654
    match super::assistants::apply(&app, &viewer, &repo_assistants(&row), f).await.map_err(|e| Error::from_db(shell.clone(), e))? {
655
        Ok(notice) => settings_page(&app, shell, row, role, Some(notice), None).await,
656
        Err(error) => settings_page(&app, shell, row, role, None, Some(error)).await,
657
    }
658
}
659
660
/// `POST /{owner}/{name}/settings/variables`: the repository's Flow
661
/// variables (#102), for its admins.
662
pub async fn variables(
663
    State(app): State<Shared>,
664
    viewer: Viewer,
665
    Path((owner, name)): Path<(String, String)>,
666
    Form(f): Form<super::variables::VariableForm>,
667
) -> Result<Page<RepoSettingsPage>, Error> {
668
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
669
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
670
    csrf(&viewer, &f.csrf_token, &shell)?;
671
    match super::variables::apply(&app, &viewer, &repo_assistants(&row), f).await.map_err(|e| Error::from_db(shell.clone(), e))? {
672
        Ok(notice) => settings_page(&app, shell, row, role, Some(notice), None).await,
673
        Err(error) => settings_page(&app, shell, row, role, None, Some(error)).await,
674
    }
675
}
676
677
/// `POST /{owner}/{name}/settings/secrets`: the repository's Flow secrets
678
/// (#107), for its admins.
679
pub async fn secrets(
680
    State(app): State<Shared>,
681
    viewer: Viewer,
682
    Path((owner, name)): Path<(String, String)>,
683
    Form(f): Form<super::secrets::SecretForm>,
684
) -> Result<Page<RepoSettingsPage>, Error> {
685
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
686
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
687
    csrf(&viewer, &f.csrf_token, &shell)?;
688
    match super::secrets::apply(&app, &viewer, &repo_assistants(&row), f).await.map_err(|e| Error::from_db(shell.clone(), e))? {
689
        Ok(notice) => settings_page(&app, shell, row, role, Some(notice), None).await,
690
        Err(error) => settings_page(&app, shell, row, role, None, Some(error)).await,
691
    }
692
}