OXYGENHUB
# df940d75cd3d oxygenhub / crates / hub / src / web / orgs.rs
13.0 KiB · Rust · last changed Webhooks: Discord and Slack formats (Discord hooks switched over), a masked URL that wraps, the last delivery in the table, an edit form, and web issue events delivered 1 month ago source blame history raw
1
//! Organisations and teams (hub spec §5.2).
2
3
use super::settings::{csrf, require};
4
use super::{Error, Page, Shell};
5
use crate::auth::{roles, Viewer};
6
use crate::db::accounts;
7
use crate::Shared;
8
use askama::Template;
9
use axum::extract::{Form, Path, State};
10
use axum::response::{IntoResponse, Redirect, Response};
11
use serde::Deserialize;
12
13
#[derive(Template)]
14
#[template(path = "org_new.html")]
15
pub struct OrgNewPage {
16
    shell: Shell,
17
    error: Option<String>,
18
}
19
20
pub async fn new_form(State(app): State<Shared>, viewer: Viewer) -> Result<Page<OrgNewPage>, Error> {
21
    let shell = Shell::new(&app, "/orgs/new", &viewer);
22
    require(&viewer, &shell)?;
23
    Ok(Page(OrgNewPage { shell, error: None }))
24
}
25
26
#[derive(Deserialize)]
27
pub struct OrgNewForm {
28
    #[serde(rename = "_csrf")]
29
    csrf_token: String,
30
    name: String,
31
    display_name: String,
32
}
33
34
pub async fn create(State(app): State<Shared>, viewer: Viewer, Form(f): Form<OrgNewForm>) -> Result<Response, Error> {
35
    let shell = Shell::new(&app, "/orgs/new", &viewer);
36
    let me = require(&viewer, &shell)?;
37
    csrf(&viewer, &f.csrf_token, &shell)?;
38
    let name = f.name.trim().to_string();
39
    if !crate::repos::names::valid_owner(&name) {
40
        return Ok(Page(OrgNewPage {
41
            shell,
42
            error: Some("An organisation name is letters, digits, dots, dashes and underscores.".into()),
43
        })
44
        .into_response());
45
    }
46
    let taken = accounts::account_by_name(&app.db, &name).await.map_err(|e| Error::from_db(shell.clone(), e))?.is_some()
47
        || accounts::org_by_name(&app.db, &name).await.map_err(|e| Error::from_db(shell.clone(), e))?.is_some();
48
    if taken {
49
        return Ok(Page(OrgNewPage { shell, error: Some("That name is taken.".into()) }).into_response());
50
    }
51
    let org =
52
        accounts::create_org(&app.db, &name, f.display_name.trim(), me.account.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
53
    accounts::audit(&app.db, Some(&me.account), "org.created", &org.name, "").await.ok();
54
    Ok(Redirect::to(&format!("/orgs/{}/settings", org.name)).into_response())
55
}
56
57
#[derive(Template)]
58
#[template(path = "org_settings.html")]
59
pub struct OrgSettingsPage {
60
    shell: Shell,
61
    org: accounts::Organisation,
62
    members: Vec<(accounts::Account, String)>,
63
    teams: Vec<(accounts::Team, Vec<accounts::Account>)>,
64
    hooks: Vec<super::webhooks::HookRow>,
65
    notice: Option<String>,
66
    error: Option<String>,
67
}
68
69
async fn org_page(
70
    app: &Shared,
71
    shell: Shell,
72
    org: accounts::Organisation,
73
    notice: Option<String>,
74
    error: Option<String>,
75
) -> Result<Page<OrgSettingsPage>, Error> {
76
    let members = accounts::org_members(&app.db, org.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
77
    let mut teams = Vec::new();
78
    for t in accounts::teams(&app.db, org.id).await.map_err(|e| Error::from_db(shell.clone(), e))? {
79
        let members = accounts::team_members(&app.db, t.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
80
        teams.push((t, members));
81
    }
82
    let hooks = crate::db::hooks::hooks_for_org(&app.db, org.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
83
    let hooks = super::webhooks::rows(app, hooks).await.map_err(|e| Error::from_db(shell.clone(), e))?;
84
    Ok(Page(OrgSettingsPage { shell, org, members, teams, hooks, notice, error }))
85
}
86
87
async fn owned_org(app: &Shared, shell: &Shell, viewer: &Viewer, name: &str) -> Result<(accounts::Account, accounts::Organisation), Error> {
88
    let me = require(viewer, shell)?;
89
    let org = accounts::org_by_name(&app.db, name)
90
        .await
91
        .map_err(|e| Error::from_db(shell.clone(), e))?
92
        .ok_or_else(|| Error::not_found(shell.clone()))?;
93
    if !roles::is_org_owner(&app.db, &me.account, org.id).await.map_err(|e| Error::from_db(shell.clone(), e))? {
94
        return Err(Error::not_found(shell.clone()));
95
    }
96
    Ok((me.account, org))
97
}
98
99
pub async fn settings(State(app): State<Shared>, viewer: Viewer, Path(name): Path<String>) -> Result<Page<OrgSettingsPage>, Error> {
100
    let shell = Shell::new(&app, &format!("/orgs/{name}/settings"), &viewer);
101
    let (_, org) = owned_org(&app, &shell, &viewer, &name).await?;
102
    org_page(&app, shell, org, None, None).await
103
}
104
105
#[derive(Deserialize)]
106
pub struct OrgForm {
107
    #[serde(rename = "_csrf")]
108
    csrf_token: String,
109
    display_name: String,
110
    description: String,
111
    default_role: String,
112
}
113
114
pub async fn update(
115
    State(app): State<Shared>,
116
    viewer: Viewer,
117
    Path(name): Path<String>,
118
    Form(f): Form<OrgForm>,
119
) -> Result<Page<OrgSettingsPage>, Error> {
120
    let shell = Shell::new(&app, &format!("/orgs/{name}/settings"), &viewer);
121
    let (me, org) = owned_org(&app, &shell, &viewer, &name).await?;
122
    csrf(&viewer, &f.csrf_token, &shell)?;
123
    let role = if f.default_role == "none" || roles::Role::parse(&f.default_role).is_some() { f.default_role.as_str() } else { "read" };
124
    accounts::set_org(&app.db, org.id, f.display_name.trim(), f.description.trim(), role)
125
        .await
126
        .map_err(|e| Error::from_db(shell.clone(), e))?;
127
    accounts::audit(&app.db, Some(&me), "org.updated", &org.name, "").await.ok();
128
    let org = accounts::org_by_name(&app.db, &name).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(org);
129
    org_page(&app, shell, org, Some("Saved.".into()), None).await
130
}
131
132
#[derive(Deserialize)]
133
pub struct MemberForm {
134
    #[serde(rename = "_csrf")]
135
    csrf_token: String,
136
    username: String,
137
    #[serde(default)]
138
    role: String,
139
    #[serde(default)]
140
    remove: Option<String>,
141
}
142
143
pub async fn member(
144
    State(app): State<Shared>,
145
    viewer: Viewer,
146
    Path(name): Path<String>,
147
    Form(f): Form<MemberForm>,
148
) -> Result<Page<OrgSettingsPage>, Error> {
149
    let shell = Shell::new(&app, &format!("/orgs/{name}/settings"), &viewer);
150
    let (me, org) = owned_org(&app, &shell, &viewer, &name).await?;
151
    csrf(&viewer, &f.csrf_token, &shell)?;
152
    let Some(target) = accounts::account_by_name(&app.db, f.username.trim()).await.map_err(|e| Error::from_db(shell.clone(), e))? else {
153
        return org_page(&app, shell, org, None, Some("No such account.".into())).await;
154
    };
155
    if f.remove.is_some() {
156
        let owners = accounts::org_members(&app.db, org.id)
157
            .await
158
            .map_err(|e| Error::from_db(shell.clone(), e))?
159
            .iter()
160
            .filter(|(_, r)| r == "owner")
161
            .count();
162
        let is_owner =
163
            accounts::org_role(&app.db, org.id, target.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.as_deref() == Some("owner");
164
        if is_owner && owners <= 1 {
165
            return org_page(&app, shell, org, None, Some("An organisation keeps at least one owner.".into())).await;
166
        }
167
        accounts::remove_org_member(&app.db, org.id, target.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
168
        accounts::audit(&app.db, Some(&me), "org.member.removed", &org.name, &target.username).await.ok();
169
        return org_page(&app, shell, org, Some(format!("Removed {}.", target.username)), None).await;
170
    }
171
    let role = if f.role == "owner" { "owner" } else { "member" };
172
    accounts::set_org_member(&app.db, org.id, target.id, role).await.map_err(|e| Error::from_db(shell.clone(), e))?;
173
    accounts::audit(&app.db, Some(&me), "org.member.set", &org.name, &format!("{} {role}", target.username)).await.ok();
174
    org_page(&app, shell, org, Some(format!("{} is now {role}.", target.username)), None).await
175
}
176
177
#[derive(Deserialize)]
178
pub struct TeamForm {
179
    #[serde(rename = "_csrf")]
180
    csrf_token: String,
181
    team: String,
182
    #[serde(default)]
183
    username: String,
184
    #[serde(default)]
185
    action: String,
186
}
187
188
pub async fn team(
189
    State(app): State<Shared>,
190
    viewer: Viewer,
191
    Path(name): Path<String>,
192
    Form(f): Form<TeamForm>,
193
) -> Result<Page<OrgSettingsPage>, Error> {
194
    let shell = Shell::new(&app, &format!("/orgs/{name}/settings"), &viewer);
195
    let (me, org) = owned_org(&app, &shell, &viewer, &name).await?;
196
    csrf(&viewer, &f.csrf_token, &shell)?;
197
    let team_name = f.team.trim().to_string();
198
    if !crate::repos::names::valid_segment(&team_name) {
199
        return org_page(&app, shell, org, None, Some("A team name is letters, digits, dots, dashes and underscores.".into())).await;
200
    }
201
    let existing = accounts::team_by_name(&app.db, org.id, &team_name).await.map_err(|e| Error::from_db(shell.clone(), e))?;
202
    let notice = match f.action.as_str() {
203
        "create" => {
204
            if existing.is_some() {
205
                return org_page(&app, shell, org, None, Some("That team exists.".into())).await;
206
            }
207
            accounts::create_team(&app.db, org.id, &team_name).await.map_err(|e| Error::from_db(shell.clone(), e))?;
208
            format!("Team {team_name} created.")
209
        }
210
        "delete" => {
211
            if let Some(t) = existing {
212
                accounts::delete_team(&app.db, t.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
213
            }
214
            format!("Team {team_name} deleted.")
215
        }
216
        "add" | "remove" => {
217
            let Some(t) = existing else { return org_page(&app, shell, org, None, Some("No such team.".into())).await };
218
            let Some(target) = accounts::account_by_name(&app.db, f.username.trim()).await.map_err(|e| Error::from_db(shell.clone(), e))?
219
            else {
220
                return org_page(&app, shell, org, None, Some("No such account.".into())).await;
221
            };
222
            if f.action == "add" {
223
                accounts::set_org_member(&app.db, org.id, target.id, "member").await.ok();
224
                accounts::add_team_member(&app.db, t.id, target.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
225
                format!("{} added to {team_name}.", target.username)
226
            } else {
227
                accounts::remove_team_member(&app.db, t.id, target.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
228
                format!("{} removed from {team_name}.", target.username)
229
            }
230
        }
231
        _ => return Err(Error::bad(shell, "unknown team action")),
232
    };
233
    accounts::audit(&app.db, Some(&me), "org.team", &org.name, &format!("{} {team_name} {}", f.action, f.username.trim())).await.ok();
234
    org_page(&app, shell, org, Some(notice), None).await
235
}
236
237
/// `POST /orgs/{name}/settings/avatar`: upload or remove the organisation's logo.
238
pub async fn avatar(
239
    State(app): State<Shared>,
240
    viewer: Viewer,
241
    Path(name): Path<String>,
242
    multipart: axum::extract::Multipart,
243
) -> Result<Page<OrgSettingsPage>, Error> {
244
    let shell = Shell::new(&app, &format!("/orgs/{name}/settings"), &viewer);
245
    let (me, org) = owned_org(&app, &shell, &viewer, &name).await?;
246
    let upload = super::avatar::read_upload(multipart).await;
247
    csrf(&viewer, &upload.csrf, &shell)?;
248
    let result = super::avatar::apply(&app, "org", org.id, upload).await;
249
    if result.is_ok() {
250
        accounts::audit(&app.db, Some(&me), "org.picture", &org.name, "").await.ok();
251
    }
252
    let org = accounts::org_by_name(&app.db, &name).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(org);
253
    match result {
254
        Ok(m) => org_page(&app, shell, org, Some(m), None).await,
255
        Err(e) => org_page(&app, shell, org, None, Some(e)).await,
256
    }
257
}
258
259
/// `POST /orgs/{name}/settings/hooks`: webhooks that hear every repository of the organisation.
260
pub async fn hooks(
261
    State(app): State<Shared>,
262
    viewer: Viewer,
263
    Path(name): Path<String>,
264
    Form(f): Form<super::webhooks::HookForm>,
265
) -> Result<Page<OrgSettingsPage>, Error> {
266
    let shell = Shell::new(&app, &format!("/orgs/{name}/settings"), &viewer);
267
    let (_, org) = owned_org(&app, &shell, &viewer, &name).await?;
268
    csrf(&viewer, &f.csrf_token, &shell)?;
269
    let scope = super::webhooks::Scope::Org { id: org.id, name: org.name.clone() };
270
    match super::webhooks::apply(&app, &viewer, &scope, f).await.map_err(|e| Error::from_db(shell.clone(), e))? {
271
        Ok(notice) => org_page(&app, shell, org, Some(notice), None).await,
272
        Err(error) => org_page(&app, shell, org, None, Some(error)).await,
273
    }
274
}
275
276
pub async fn deliveries(
277
    State(app): State<Shared>,
278
    viewer: Viewer,
279
    Path((name, id)): Path<(String, i64)>,
280
) -> Result<Page<super::webhooks::DeliveriesPage>, Error> {
281
    let shell = Shell::new(&app, &format!("/orgs/{name}/settings/hooks/{id}/deliveries"), &viewer);
282
    let (_, org) = owned_org(&app, &shell, &viewer, &name).await?;
283
    let scope = super::webhooks::Scope::Org { id: org.id, name: org.name };
284
    super::webhooks::deliveries_page(&app, shell, &scope, None, id).await
285
}
286
287
pub async fn redeliver(
288
    State(app): State<Shared>,
289
    viewer: Viewer,
290
    Path((name, id, delivery)): Path<(String, i64, i64)>,
291
    Form(f): Form<super::session::CsrfForm>,
292
) -> Result<Page<super::webhooks::DeliveriesPage>, Error> {
293
    let shell = Shell::new(&app, &format!("/orgs/{name}/settings/hooks/{id}/deliveries"), &viewer);
294
    let (_, org) = owned_org(&app, &shell, &viewer, &name).await?;
295
    csrf(&viewer, &f.csrf, &shell)?;
296
    let scope = super::webhooks::Scope::Org { id: org.id, name: org.name };
297
    super::webhooks::redeliver(&app, &scope, id, delivery).await.map_err(|e| Error::from_db(shell.clone(), e))?;
298
    super::webhooks::deliveries_page(&app, shell, &scope, None, id).await
299
}