OXYGENHUB
# bf3d07541368 oxygenhub / crates / hub / src / web / repo_settings.rs
18.5 KiB · Rust · last changed Turn OxygenFlow on or off per repository in settings 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
}
99
100
async fn settings_page(
101
    app: &Shared,
102
    shell: Shell,
103
    row: db::repos::RepoRow,
104
    role: Role,
105
    notice: Option<String>,
106
    error: Option<String>,
107
) -> Result<Page<RepoSettingsPage>, Error> {
108
    let collaborators = accounts::collaborators(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
109
    let teams = match accounts::org_by_name(&app.db, &row.owner).await.map_err(|e| Error::from_db(shell.clone(), e))? {
110
        Some(org) => accounts::teams(&app.db, org.id).await.map_err(|e| Error::from_db(shell.clone(), e))?,
111
        None => vec![],
112
    };
113
    let repo = RepoShell::new(app, &row, "settings").with_role(role);
114
    let requirements = db::proposals::requirements(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
115
    let hooks = db::hooks::hooks_for_repo(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
116
    let hooks = super::webhooks::rows(app, hooks).await.map_err(|e| Error::from_db(shell.clone(), e))?;
117
    let required_checks = db::hooks::required_checks(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.join(", ");
118
    let flow_default = app.config.flow.allows(&row.owner, &row.name);
119
    let flow_enabled = crate::flow::enabled(app, &row);
120
    let site_admin = shell.is_admin;
121
    Ok(Page(RepoSettingsPage {
122
        shell,
123
        repo,
124
        row,
125
        collaborators,
126
        teams,
127
        role,
128
        requirements,
129
        hooks,
130
        required_checks,
131
        flow_default,
132
        flow_enabled,
133
        site_admin,
134
        notice,
135
        error,
136
    }))
137
}
138
139
pub async fn settings(
140
    State(app): State<Shared>,
141
    viewer: Viewer,
142
    Path((owner, name)): Path<(String, String)>,
143
) -> Result<Page<RepoSettingsPage>, Error> {
144
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
145
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
146
    settings_page(&app, shell, row, role, None, None).await
147
}
148
149
#[derive(Deserialize)]
150
pub struct GeneralForm {
151
    #[serde(rename = "_csrf")]
152
    csrf_token: String,
153
    description: String,
154
    default_bookmark: String,
155
    visibility: String,
156
    #[serde(default)]
157
    topics: String,
158
}
159
160
pub async fn update(
161
    State(app): State<Shared>,
162
    viewer: Viewer,
163
    Path((owner, name)): Path<(String, String)>,
164
    Form(f): Form<GeneralForm>,
165
) -> Result<Page<RepoSettingsPage>, Error> {
166
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
167
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
168
    csrf(&viewer, &f.csrf_token, &shell)?;
169
    let e = |e| Error::from_db(shell.clone(), e);
170
    let topics = match db::repos::parse_topics(&f.topics) {
171
        Ok(t) => t,
172
        Err(msg) => return settings_page(&app, shell, row, role, None, Some(msg)).await,
173
    };
174
    db::repos::set_description(&app.db, row.id, f.description.trim()).await.map_err(e)?;
175
    db::repos::set_topics(&app.db, row.id, &topics).await.map_err(e)?;
176
    if crate::repos::names::valid_segment(f.default_bookmark.trim()) {
177
        db::repos::set_default_bookmark(&app.db, row.id, f.default_bookmark.trim()).await.map_err(e)?;
178
    }
179
    if role >= Role::Admin {
180
        if let Some(v) = Visibility::parse(&f.visibility) {
181
            db::repos::set_visibility(&app.db, row.id, v).await.map_err(e)?;
182
        }
183
    }
184
    accounts::audit(&app.db, viewer.account(), "repo.updated", &format!("{owner}/{name}"), "").await.ok();
185
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(e)?.unwrap_or(row);
186
    settings_page(&app, shell, row, role, Some("Saved.".into()), None).await
187
}
188
189
#[derive(Deserialize)]
190
pub struct CollaboratorForm {
191
    #[serde(rename = "_csrf")]
192
    csrf_token: String,
193
    #[serde(default)]
194
    username: String,
195
    #[serde(default)]
196
    team: String,
197
    #[serde(default)]
198
    role: String,
199
    #[serde(default)]
200
    remove: Option<String>,
201
}
202
203
pub async fn collaborator(
204
    State(app): State<Shared>,
205
    viewer: Viewer,
206
    Path((owner, name)): Path<(String, String)>,
207
    Form(f): Form<CollaboratorForm>,
208
) -> Result<Page<RepoSettingsPage>, Error> {
209
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
210
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
211
    csrf(&viewer, &f.csrf_token, &shell)?;
212
    let e = |e| Error::from_db(shell.clone(), e);
213
    let (account_id, team_id, label) = if !f.username.trim().is_empty() {
214
        match accounts::account_by_name(&app.db, f.username.trim()).await.map_err(e)? {
215
            Some(a) => (Some(a.id), None, a.username),
216
            None => return settings_page(&app, shell, row, role, None, Some("No such account.".into())).await,
217
        }
218
    } else if !f.team.trim().is_empty() {
219
        let org = accounts::org_by_name(&app.db, &row.owner).await.map_err(e)?;
220
        let team = match org {
221
            Some(o) => accounts::team_by_name(&app.db, o.id, f.team.trim()).await.map_err(e)?,
222
            None => None,
223
        };
224
        match team {
225
            Some(t) => (None, Some(t.id), format!("team {}", t.name)),
226
            None => return settings_page(&app, shell, row, role, None, Some("No such team in this organisation.".into())).await,
227
        }
228
    } else {
229
        return settings_page(&app, shell, row, role, None, Some("Name an account or a team.".into())).await;
230
    };
231
    if f.remove.is_some() {
232
        accounts::remove_collaborator(&app.db, row.id, account_id, team_id).await.map_err(e)?;
233
        accounts::audit(&app.db, viewer.account(), "repo.collaborator.removed", &format!("{owner}/{name}"), &label).await.ok();
234
        return settings_page(&app, shell, row, role, Some(format!("Removed {label}.")), None).await;
235
    }
236
    let Some(new_role) = Role::parse(&f.role) else {
237
        return settings_page(&app, shell, row, role, None, Some("Pick a role.".into())).await;
238
    };
239
    accounts::set_collaborator(&app.db, row.id, account_id, team_id, new_role.as_str()).await.map_err(e)?;
240
    accounts::audit(
241
        &app.db,
242
        viewer.account(),
243
        "repo.collaborator.set",
244
        &format!("{owner}/{name}"),
245
        &format!("{label} {}", new_role.as_str()),
246
    )
247
    .await
248
    .ok();
249
    settings_page(&app, shell, row, role, Some(format!("{label} now has {} access.", new_role.as_str())), None).await
250
}
251
252
#[derive(Deserialize)]
253
pub struct DeleteForm {
254
    #[serde(rename = "_csrf")]
255
    csrf_token: String,
256
    confirm: String,
257
}
258
259
pub async fn delete(
260
    State(app): State<Shared>,
261
    viewer: Viewer,
262
    Path((owner, name)): Path<(String, String)>,
263
    Form(f): Form<DeleteForm>,
264
) -> Result<Response, Error> {
265
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
266
    let (repo, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
267
    csrf(&viewer, &f.csrf_token, &shell)?;
268
    if f.confirm.trim() != repo.to_string() {
269
        return Ok(settings_page(&app, shell, row, role, None, Some("Type the repository's full name to delete it.".into()))
270
            .await?
271
            .into_response());
272
    }
273
    let _guard = app.repos.write_guard(&repo).await;
274
    app.repos.trash(&repo).map_err(|e| Error::from_repo(shell.clone(), e))?;
275
    db::repos::delete(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
276
    accounts::audit(&app.db, viewer.account(), "repo.deleted", &repo.to_string(), "moved to trash").await.ok();
277
    Ok(Redirect::to(&format!("/{owner}")).into_response())
278
}
279
280
#[derive(Deserialize)]
281
pub struct RequirementsForm {
282
    #[serde(rename = "_csrf")]
283
    csrf_token: String,
284
    #[serde(default)]
285
    required_approvals: i64,
286
    #[serde(default)]
287
    require_owners: Option<String>,
288
    #[serde(default)]
289
    allow_conflicts: Option<String>,
290
}
291
292
pub async fn requirements(
293
    State(app): State<Shared>,
294
    viewer: Viewer,
295
    Path((owner, name)): Path<(String, String)>,
296
    Form(f): Form<RequirementsForm>,
297
) -> Result<Page<RepoSettingsPage>, Error> {
298
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
299
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
300
    csrf(&viewer, &f.csrf_token, &shell)?;
301
    let r = db::proposals::Requirements {
302
        required_approvals: f.required_approvals.max(0),
303
        require_owners: f.require_owners.is_some(),
304
        allow_conflicts: f.allow_conflicts.is_some(),
305
    };
306
    db::proposals::set_requirements(&app.db, row.id, r).await.map_err(|e| Error::from_db(shell.clone(), e))?;
307
    accounts::audit(
308
        &app.db,
309
        viewer.account(),
310
        "repo.requirements",
311
        &format!("{owner}/{name}"),
312
        &format!("{} approvals, owners {}, conflicts {}", r.required_approvals, r.require_owners, r.allow_conflicts),
313
    )
314
    .await
315
    .ok();
316
    settings_page(&app, shell, row, role, Some("Landing requirements saved.".into()), None).await
317
}
318
319
pub async fn hooks(
320
    State(app): State<Shared>,
321
    viewer: Viewer,
322
    Path((owner, name)): Path<(String, String)>,
323
    Form(f): Form<super::webhooks::HookForm>,
324
) -> Result<Page<RepoSettingsPage>, Error> {
325
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
326
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
327
    csrf(&viewer, &f.csrf_token, &shell)?;
328
    let scope = super::webhooks::Scope::Repo { id: row.id, slug: format!("{owner}/{name}") };
329
    match super::webhooks::apply(&app, &viewer, &scope, f).await.map_err(|e| Error::from_db(shell.clone(), e))? {
330
        Ok(notice) => settings_page(&app, shell, row, role, Some(notice), None).await,
331
        Err(error) => settings_page(&app, shell, row, role, None, Some(error)).await,
332
    }
333
}
334
335
pub async fn deliveries(
336
    State(app): State<Shared>,
337
    viewer: Viewer,
338
    Path((owner, name, id)): Path<(String, String, i64)>,
339
) -> Result<Page<super::webhooks::DeliveriesPage>, Error> {
340
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings/hooks/{id}/deliveries"), &viewer);
341
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
342
    let scope = super::webhooks::Scope::Repo { id: row.id, slug: format!("{owner}/{name}") };
343
    super::webhooks::deliveries_page(&app, shell, &scope, Some(RepoShell::new(&app, &row, "settings").with_role(role)), id).await
344
}
345
346
pub async fn redeliver(
347
    State(app): State<Shared>,
348
    viewer: Viewer,
349
    Path((owner, name, id, delivery)): Path<(String, String, i64, i64)>,
350
    Form(f): Form<super::session::CsrfForm>,
351
) -> Result<Page<super::webhooks::DeliveriesPage>, Error> {
352
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings/hooks/{id}/deliveries"), &viewer);
353
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
354
    csrf(&viewer, &f.csrf, &shell)?;
355
    let scope = super::webhooks::Scope::Repo { id: row.id, slug: format!("{owner}/{name}") };
356
    super::webhooks::redeliver(&app, &scope, id, delivery).await.map_err(|e| Error::from_db(shell.clone(), e))?;
357
    super::webhooks::deliveries_page(&app, shell, &scope, Some(RepoShell::new(&app, &row, "settings").with_role(role)), id).await
358
}
359
360
#[derive(Deserialize)]
361
pub struct ChecksForm {
362
    #[serde(rename = "_csrf")]
363
    csrf_token: String,
364
    #[serde(default)]
365
    required_checks: String,
366
}
367
368
pub async fn required_checks(
369
    State(app): State<Shared>,
370
    viewer: Viewer,
371
    Path((owner, name)): Path<(String, String)>,
372
    Form(f): Form<ChecksForm>,
373
) -> Result<Page<RepoSettingsPage>, Error> {
374
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
375
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
376
    csrf(&viewer, &f.csrf_token, &shell)?;
377
    let names: Vec<String> = f.required_checks.split(',').map(str::trim).filter(|s| !s.is_empty()).map(String::from).collect();
378
    db::hooks::set_required_checks(&app.db, row.id, &names).await.map_err(|e| Error::from_db(shell.clone(), e))?;
379
    settings_page(&app, shell, row, role, Some("Required checks saved.".into()), None).await
380
}
381
382
#[derive(Deserialize)]
383
pub struct MirrorForm {
384
    #[serde(rename = "_csrf")]
385
    csrf_token: String,
386
    #[serde(default)]
387
    enabled: Option<String>,
388
}
389
390
#[derive(Deserialize)]
391
pub struct FlowForm {
392
    #[serde(rename = "_csrf")]
393
    csrf_token: String,
394
    /// `on`, `off`, or `default`.
395
    flow: String,
396
}
397
398
/// Turn OxygenFlow on or off for one repository. Runs use the site's
399
/// runners, so only site administrators decide.
400
pub async fn flow(
401
    State(app): State<Shared>,
402
    viewer: Viewer,
403
    Path((owner, name)): Path<(String, String)>,
404
    Form(f): Form<FlowForm>,
405
) -> Result<Page<RepoSettingsPage>, Error> {
406
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
407
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
408
    csrf(&viewer, &f.csrf_token, &shell)?;
409
    if !viewer.is_admin() {
410
        return settings_page(&app, shell, row, role, None, Some("Only site administrators turn OxygenFlow on or off.".into())).await;
411
    }
412
    let setting = match f.flow.as_str() {
413
        "on" => "on",
414
        "off" => "off",
415
        "default" => "",
416
        _ => return settings_page(&app, shell, row, role, None, Some("Pick on, off or the site default.".into())).await,
417
    };
418
    db::repos::set_flow(&app.db, row.id, setting).await.map_err(|e| Error::from_db(shell.clone(), e))?;
419
    let label = if setting.is_empty() { "default" } else { setting };
420
    accounts::audit(&app.db, viewer.account(), "repo.flow", &format!("{owner}/{name}"), label).await.ok();
421
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
422
    let notice = if crate::flow::enabled(&app, &row) {
423
        "OxygenFlow is on: pushes with a .flow/pipeline.kdl start runs."
424
    } else {
425
        "OxygenFlow is off for this repository."
426
    };
427
    settings_page(&app, shell, row, role, Some(notice.into()), None).await
428
}
429
430
pub async fn mirror(
431
    State(app): State<Shared>,
432
    viewer: Viewer,
433
    Path((owner, name)): Path<(String, String)>,
434
    Form(f): Form<MirrorForm>,
435
) -> Result<Page<RepoSettingsPage>, Error> {
436
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
437
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
438
    csrf(&viewer, &f.csrf_token, &shell)?;
439
    db::repos::set_git_mirror(&app.db, row.id, f.enabled.is_some()).await.map_err(|e| Error::from_db(shell.clone(), e))?;
440
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
441
    if row.git_mirror {
442
        crate::mirror::refresh(&app, &row).await;
443
    }
444
    accounts::audit(&app.db, viewer.account(), "repo.mirror", &format!("{owner}/{name}"), if row.git_mirror { "on" } else { "off" })
445
        .await
446
        .ok();
447
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
448
    let notice = if row.git_mirror { "The Git mirror is on and up to date." } else { "The Git mirror is off." };
449
    settings_page(&app, shell, row, role, Some(notice.into()), None).await
450
}