OXYGENHUB
# de505df5cf8d oxygenhub / crates / hub / src / web / repo_settings.rs
11.1 KiB · Rust · last changed Milestone 0.3: proposals and review 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::{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
    notice: Option<String>,
91
    error: Option<String>,
92
}
93
94
async fn settings_page(
95
    app: &Shared,
96
    shell: Shell,
97
    row: db::repos::RepoRow,
98
    role: Role,
99
    notice: Option<String>,
100
    error: Option<String>,
101
) -> Result<Page<RepoSettingsPage>, Error> {
102
    let collaborators = accounts::collaborators(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
103
    let teams = match accounts::org_by_name(&app.db, &row.owner).await.map_err(|e| Error::from_db(shell.clone(), e))? {
104
        Some(org) => accounts::teams(&app.db, org.id).await.map_err(|e| Error::from_db(shell.clone(), e))?,
105
        None => vec![],
106
    };
107
    let repo = RepoShell::new(app, &row, "settings").with_role(role);
108
    let requirements = db::proposals::requirements(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
109
    Ok(Page(RepoSettingsPage { shell, repo, row, collaborators, teams, role, requirements, notice, error }))
110
}
111
112
pub async fn settings(
113
    State(app): State<Shared>,
114
    viewer: Viewer,
115
    Path((owner, name)): Path<(String, String)>,
116
) -> Result<Page<RepoSettingsPage>, Error> {
117
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
118
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
119
    settings_page(&app, shell, row, role, None, None).await
120
}
121
122
#[derive(Deserialize)]
123
pub struct GeneralForm {
124
    #[serde(rename = "_csrf")]
125
    csrf_token: String,
126
    description: String,
127
    default_bookmark: String,
128
    visibility: String,
129
}
130
131
pub async fn update(
132
    State(app): State<Shared>,
133
    viewer: Viewer,
134
    Path((owner, name)): Path<(String, String)>,
135
    Form(f): Form<GeneralForm>,
136
) -> Result<Page<RepoSettingsPage>, Error> {
137
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
138
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
139
    csrf(&viewer, &f.csrf_token, &shell)?;
140
    let e = |e| Error::from_db(shell.clone(), e);
141
    db::repos::set_description(&app.db, row.id, f.description.trim()).await.map_err(e)?;
142
    if crate::repos::names::valid_segment(f.default_bookmark.trim()) {
143
        db::repos::set_default_bookmark(&app.db, row.id, f.default_bookmark.trim()).await.map_err(e)?;
144
    }
145
    if role >= Role::Admin {
146
        if let Some(v) = Visibility::parse(&f.visibility) {
147
            db::repos::set_visibility(&app.db, row.id, v).await.map_err(e)?;
148
        }
149
    }
150
    accounts::audit(&app.db, viewer.account(), "repo.updated", &format!("{owner}/{name}"), "").await.ok();
151
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(e)?.unwrap_or(row);
152
    settings_page(&app, shell, row, role, Some("Saved.".into()), None).await
153
}
154
155
#[derive(Deserialize)]
156
pub struct CollaboratorForm {
157
    #[serde(rename = "_csrf")]
158
    csrf_token: String,
159
    #[serde(default)]
160
    username: String,
161
    #[serde(default)]
162
    team: String,
163
    #[serde(default)]
164
    role: String,
165
    #[serde(default)]
166
    remove: Option<String>,
167
}
168
169
pub async fn collaborator(
170
    State(app): State<Shared>,
171
    viewer: Viewer,
172
    Path((owner, name)): Path<(String, String)>,
173
    Form(f): Form<CollaboratorForm>,
174
) -> Result<Page<RepoSettingsPage>, Error> {
175
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
176
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
177
    csrf(&viewer, &f.csrf_token, &shell)?;
178
    let e = |e| Error::from_db(shell.clone(), e);
179
    let (account_id, team_id, label) = if !f.username.trim().is_empty() {
180
        match accounts::account_by_name(&app.db, f.username.trim()).await.map_err(e)? {
181
            Some(a) => (Some(a.id), None, a.username),
182
            None => return settings_page(&app, shell, row, role, None, Some("No such account.".into())).await,
183
        }
184
    } else if !f.team.trim().is_empty() {
185
        let org = accounts::org_by_name(&app.db, &row.owner).await.map_err(e)?;
186
        let team = match org {
187
            Some(o) => accounts::team_by_name(&app.db, o.id, f.team.trim()).await.map_err(e)?,
188
            None => None,
189
        };
190
        match team {
191
            Some(t) => (None, Some(t.id), format!("team {}", t.name)),
192
            None => return settings_page(&app, shell, row, role, None, Some("No such team in this organisation.".into())).await,
193
        }
194
    } else {
195
        return settings_page(&app, shell, row, role, None, Some("Name an account or a team.".into())).await;
196
    };
197
    if f.remove.is_some() {
198
        accounts::remove_collaborator(&app.db, row.id, account_id, team_id).await.map_err(e)?;
199
        accounts::audit(&app.db, viewer.account(), "repo.collaborator.removed", &format!("{owner}/{name}"), &label).await.ok();
200
        return settings_page(&app, shell, row, role, Some(format!("Removed {label}.")), None).await;
201
    }
202
    let Some(new_role) = Role::parse(&f.role) else {
203
        return settings_page(&app, shell, row, role, None, Some("Pick a role.".into())).await;
204
    };
205
    accounts::set_collaborator(&app.db, row.id, account_id, team_id, new_role.as_str()).await.map_err(e)?;
206
    accounts::audit(
207
        &app.db,
208
        viewer.account(),
209
        "repo.collaborator.set",
210
        &format!("{owner}/{name}"),
211
        &format!("{label} {}", new_role.as_str()),
212
    )
213
    .await
214
    .ok();
215
    settings_page(&app, shell, row, role, Some(format!("{label} now has {} access.", new_role.as_str())), None).await
216
}
217
218
#[derive(Deserialize)]
219
pub struct DeleteForm {
220
    #[serde(rename = "_csrf")]
221
    csrf_token: String,
222
    confirm: String,
223
}
224
225
pub async fn delete(
226
    State(app): State<Shared>,
227
    viewer: Viewer,
228
    Path((owner, name)): Path<(String, String)>,
229
    Form(f): Form<DeleteForm>,
230
) -> Result<Response, Error> {
231
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
232
    let (repo, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
233
    csrf(&viewer, &f.csrf_token, &shell)?;
234
    if f.confirm.trim() != repo.to_string() {
235
        return Ok(settings_page(&app, shell, row, role, None, Some("Type the repository's full name to delete it.".into()))
236
            .await?
237
            .into_response());
238
    }
239
    let _guard = app.repos.write_guard(&repo).await;
240
    app.repos.trash(&repo).map_err(|e| Error::from_repo(shell.clone(), e))?;
241
    db::repos::delete(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
242
    accounts::audit(&app.db, viewer.account(), "repo.deleted", &repo.to_string(), "moved to trash").await.ok();
243
    Ok(Redirect::to(&format!("/{owner}")).into_response())
244
}
245
246
#[derive(Deserialize)]
247
pub struct RequirementsForm {
248
    #[serde(rename = "_csrf")]
249
    csrf_token: String,
250
    #[serde(default)]
251
    required_approvals: i64,
252
    #[serde(default)]
253
    require_owners: Option<String>,
254
    #[serde(default)]
255
    allow_conflicts: Option<String>,
256
}
257
258
pub async fn requirements(
259
    State(app): State<Shared>,
260
    viewer: Viewer,
261
    Path((owner, name)): Path<(String, String)>,
262
    Form(f): Form<RequirementsForm>,
263
) -> Result<Page<RepoSettingsPage>, Error> {
264
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
265
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
266
    csrf(&viewer, &f.csrf_token, &shell)?;
267
    let r = db::proposals::Requirements {
268
        required_approvals: f.required_approvals.max(0),
269
        require_owners: f.require_owners.is_some(),
270
        allow_conflicts: f.allow_conflicts.is_some(),
271
    };
272
    db::proposals::set_requirements(&app.db, row.id, r).await.map_err(|e| Error::from_db(shell.clone(), e))?;
273
    accounts::audit(
274
        &app.db,
275
        viewer.account(),
276
        "repo.requirements",
277
        &format!("{owner}/{name}"),
278
        &format!("{} approvals, owners {}, conflicts {}", r.required_approvals, r.require_owners, r.allow_conflicts),
279
    )
280
    .await
281
    .ok();
282
    settings_page(&app, shell, row, role, Some("Landing requirements saved.".into()), None).await
283
}