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