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