OXYGENHUB
# 749d9a5fcd9f oxygenhub / crates / hub / src / web / orgs.rs
16.2 KiB · Rust · last changed Settings → Runners for repository admins and organisation owners (refs #41, part 2b) 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
    /// Runners serving the organisation: (runner, what it serves, whether
68
    /// it is the organisation's own).
69
    runners: Vec<(crate::db::runners::Runner, String, bool)>,
70
    /// A registration token just made, shown once.
71
    runner_token: Option<String>,
72
}
73
74
async fn org_page(
75
    app: &Shared,
76
    shell: Shell,
77
    org: accounts::Organisation,
78
    notice: Option<String>,
79
    error: Option<String>,
80
) -> Result<Page<OrgSettingsPage>, Error> {
81
    let members = accounts::org_members(&app.db, org.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
82
    let mut teams = Vec::new();
83
    for t in accounts::teams(&app.db, org.id).await.map_err(|e| Error::from_db(shell.clone(), e))? {
84
        let members = accounts::team_members(&app.db, t.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
85
        teams.push((t, members));
86
    }
87
    let hooks = crate::db::hooks::hooks_for_org(&app.db, org.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
88
    let hooks = super::webhooks::rows(app, hooks).await.map_err(|e| Error::from_db(shell.clone(), e))?;
89
    let mut runners = Vec::new();
90
    for r in crate::db::runners::serving_owner(&app.db, &org.name).await.map_err(|e| Error::from_db(shell.clone(), e))? {
91
        let own = r.scope_owner == org.name;
92
        let serves = match r.scope_repo_id {
93
            Some(id) => crate::db::repos::get_by_id(&app.db, id).await.ok().flatten().map(|p| p.slug()).unwrap_or_default(),
94
            None if own => "the organisation's repositories".into(),
95
            None => "the whole site".into(),
96
        };
97
        runners.push((r, serves, own));
98
    }
99
    Ok(Page(OrgSettingsPage { shell, org, members, teams, hooks, notice, error, runners, runner_token: None }))
100
}
101
102
async fn owned_org(app: &Shared, shell: &Shell, viewer: &Viewer, name: &str) -> Result<(accounts::Account, accounts::Organisation), Error> {
103
    let me = require(viewer, shell)?;
104
    let org = accounts::org_by_name(&app.db, name)
105
        .await
106
        .map_err(|e| Error::from_db(shell.clone(), e))?
107
        .ok_or_else(|| Error::not_found(shell.clone()))?;
108
    if !roles::is_org_owner(&app.db, &me.account, org.id).await.map_err(|e| Error::from_db(shell.clone(), e))? {
109
        return Err(Error::not_found(shell.clone()));
110
    }
111
    Ok((me.account, org))
112
}
113
114
pub async fn settings(State(app): State<Shared>, viewer: Viewer, Path(name): Path<String>) -> Result<Page<OrgSettingsPage>, Error> {
115
    let shell = Shell::new(&app, &format!("/orgs/{name}/settings"), &viewer);
116
    let (_, org) = owned_org(&app, &shell, &viewer, &name).await?;
117
    org_page(&app, shell, org, None, None).await
118
}
119
120
#[derive(Deserialize)]
121
pub struct OrgForm {
122
    #[serde(rename = "_csrf")]
123
    csrf_token: String,
124
    display_name: String,
125
    description: String,
126
    default_role: String,
127
}
128
129
pub async fn update(
130
    State(app): State<Shared>,
131
    viewer: Viewer,
132
    Path(name): Path<String>,
133
    Form(f): Form<OrgForm>,
134
) -> Result<Page<OrgSettingsPage>, Error> {
135
    let shell = Shell::new(&app, &format!("/orgs/{name}/settings"), &viewer);
136
    let (me, org) = owned_org(&app, &shell, &viewer, &name).await?;
137
    csrf(&viewer, &f.csrf_token, &shell)?;
138
    let role = if f.default_role == "none" || roles::Role::parse(&f.default_role).is_some() { f.default_role.as_str() } else { "read" };
139
    accounts::set_org(&app.db, org.id, f.display_name.trim(), f.description.trim(), role)
140
        .await
141
        .map_err(|e| Error::from_db(shell.clone(), e))?;
142
    accounts::audit(&app.db, Some(&me), "org.updated", &org.name, "").await.ok();
143
    let org = accounts::org_by_name(&app.db, &name).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(org);
144
    org_page(&app, shell, org, Some("Saved.".into()), None).await
145
}
146
147
#[derive(Deserialize)]
148
pub struct MemberForm {
149
    #[serde(rename = "_csrf")]
150
    csrf_token: String,
151
    username: String,
152
    #[serde(default)]
153
    role: String,
154
    #[serde(default)]
155
    remove: Option<String>,
156
}
157
158
pub async fn member(
159
    State(app): State<Shared>,
160
    viewer: Viewer,
161
    Path(name): Path<String>,
162
    Form(f): Form<MemberForm>,
163
) -> Result<Page<OrgSettingsPage>, Error> {
164
    let shell = Shell::new(&app, &format!("/orgs/{name}/settings"), &viewer);
165
    let (me, org) = owned_org(&app, &shell, &viewer, &name).await?;
166
    csrf(&viewer, &f.csrf_token, &shell)?;
167
    let Some(target) = accounts::account_by_name(&app.db, f.username.trim()).await.map_err(|e| Error::from_db(shell.clone(), e))? else {
168
        return org_page(&app, shell, org, None, Some("No such account.".into())).await;
169
    };
170
    if f.remove.is_some() {
171
        let owners = accounts::org_members(&app.db, org.id)
172
            .await
173
            .map_err(|e| Error::from_db(shell.clone(), e))?
174
            .iter()
175
            .filter(|(_, r)| r == "owner")
176
            .count();
177
        let is_owner =
178
            accounts::org_role(&app.db, org.id, target.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.as_deref() == Some("owner");
179
        if is_owner && owners <= 1 {
180
            return org_page(&app, shell, org, None, Some("An organisation keeps at least one owner.".into())).await;
181
        }
182
        accounts::remove_org_member(&app.db, org.id, target.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
183
        accounts::audit(&app.db, Some(&me), "org.member.removed", &org.name, &target.username).await.ok();
184
        return org_page(&app, shell, org, Some(format!("Removed {}.", target.username)), None).await;
185
    }
186
    let role = if f.role == "owner" { "owner" } else { "member" };
187
    accounts::set_org_member(&app.db, org.id, target.id, role).await.map_err(|e| Error::from_db(shell.clone(), e))?;
188
    accounts::audit(&app.db, Some(&me), "org.member.set", &org.name, &format!("{} {role}", target.username)).await.ok();
189
    org_page(&app, shell, org, Some(format!("{} is now {role}.", target.username)), None).await
190
}
191
192
#[derive(Deserialize)]
193
pub struct TeamForm {
194
    #[serde(rename = "_csrf")]
195
    csrf_token: String,
196
    team: String,
197
    #[serde(default)]
198
    username: String,
199
    #[serde(default)]
200
    action: String,
201
}
202
203
pub async fn team(
204
    State(app): State<Shared>,
205
    viewer: Viewer,
206
    Path(name): Path<String>,
207
    Form(f): Form<TeamForm>,
208
) -> Result<Page<OrgSettingsPage>, Error> {
209
    let shell = Shell::new(&app, &format!("/orgs/{name}/settings"), &viewer);
210
    let (me, org) = owned_org(&app, &shell, &viewer, &name).await?;
211
    csrf(&viewer, &f.csrf_token, &shell)?;
212
    let team_name = f.team.trim().to_string();
213
    if !crate::repos::names::valid_segment(&team_name) {
214
        return org_page(&app, shell, org, None, Some("A team name is letters, digits, dots, dashes and underscores.".into())).await;
215
    }
216
    let existing = accounts::team_by_name(&app.db, org.id, &team_name).await.map_err(|e| Error::from_db(shell.clone(), e))?;
217
    let notice = match f.action.as_str() {
218
        "create" => {
219
            if existing.is_some() {
220
                return org_page(&app, shell, org, None, Some("That team exists.".into())).await;
221
            }
222
            accounts::create_team(&app.db, org.id, &team_name).await.map_err(|e| Error::from_db(shell.clone(), e))?;
223
            format!("Team {team_name} created.")
224
        }
225
        "delete" => {
226
            if let Some(t) = existing {
227
                accounts::delete_team(&app.db, t.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
228
            }
229
            format!("Team {team_name} deleted.")
230
        }
231
        "add" | "remove" => {
232
            let Some(t) = existing else { return org_page(&app, shell, org, None, Some("No such team.".into())).await };
233
            let Some(target) = accounts::account_by_name(&app.db, f.username.trim()).await.map_err(|e| Error::from_db(shell.clone(), e))?
234
            else {
235
                return org_page(&app, shell, org, None, Some("No such account.".into())).await;
236
            };
237
            if f.action == "add" {
238
                accounts::set_org_member(&app.db, org.id, target.id, "member").await.ok();
239
                accounts::add_team_member(&app.db, t.id, target.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
240
                format!("{} added to {team_name}.", target.username)
241
            } else {
242
                accounts::remove_team_member(&app.db, t.id, target.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
243
                format!("{} removed from {team_name}.", target.username)
244
            }
245
        }
246
        _ => return Err(Error::bad(shell, "unknown team action")),
247
    };
248
    accounts::audit(&app.db, Some(&me), "org.team", &org.name, &format!("{} {team_name} {}", f.action, f.username.trim())).await.ok();
249
    org_page(&app, shell, org, Some(notice), None).await
250
}
251
252
/// `POST /orgs/{name}/settings/avatar`: upload or remove the organisation's logo.
253
pub async fn avatar(
254
    State(app): State<Shared>,
255
    viewer: Viewer,
256
    Path(name): Path<String>,
257
    multipart: axum::extract::Multipart,
258
) -> Result<Page<OrgSettingsPage>, Error> {
259
    let shell = Shell::new(&app, &format!("/orgs/{name}/settings"), &viewer);
260
    let (me, org) = owned_org(&app, &shell, &viewer, &name).await?;
261
    let upload = super::avatar::read_upload(multipart).await;
262
    csrf(&viewer, &upload.csrf, &shell)?;
263
    let result = super::avatar::apply(&app, "org", org.id, upload).await;
264
    if result.is_ok() {
265
        accounts::audit(&app.db, Some(&me), "org.picture", &org.name, "").await.ok();
266
    }
267
    let org = accounts::org_by_name(&app.db, &name).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(org);
268
    match result {
269
        Ok(m) => org_page(&app, shell, org, Some(m), None).await,
270
        Err(e) => org_page(&app, shell, org, None, Some(e)).await,
271
    }
272
}
273
274
/// `POST /orgs/{name}/settings/hooks`: webhooks that hear every repository of the organisation.
275
pub async fn hooks(
276
    State(app): State<Shared>,
277
    viewer: Viewer,
278
    Path(name): Path<String>,
279
    Form(f): Form<super::webhooks::HookForm>,
280
) -> Result<Page<OrgSettingsPage>, Error> {
281
    let shell = Shell::new(&app, &format!("/orgs/{name}/settings"), &viewer);
282
    let (_, org) = owned_org(&app, &shell, &viewer, &name).await?;
283
    csrf(&viewer, &f.csrf_token, &shell)?;
284
    let scope = super::webhooks::Scope::Org { id: org.id, name: org.name.clone() };
285
    match super::webhooks::apply(&app, &viewer, &scope, f).await.map_err(|e| Error::from_db(shell.clone(), e))? {
286
        Ok(notice) => org_page(&app, shell, org, Some(notice), None).await,
287
        Err(error) => org_page(&app, shell, org, None, Some(error)).await,
288
    }
289
}
290
291
pub async fn deliveries(
292
    State(app): State<Shared>,
293
    viewer: Viewer,
294
    Path((name, id)): Path<(String, i64)>,
295
) -> Result<Page<super::webhooks::DeliveriesPage>, Error> {
296
    let shell = Shell::new(&app, &format!("/orgs/{name}/settings/hooks/{id}/deliveries"), &viewer);
297
    let (_, org) = owned_org(&app, &shell, &viewer, &name).await?;
298
    let scope = super::webhooks::Scope::Org { id: org.id, name: org.name };
299
    super::webhooks::deliveries_page(&app, shell, &scope, None, id).await
300
}
301
302
pub async fn redeliver(
303
    State(app): State<Shared>,
304
    viewer: Viewer,
305
    Path((name, id, delivery)): Path<(String, i64, i64)>,
306
    Form(f): Form<super::session::CsrfForm>,
307
) -> Result<Page<super::webhooks::DeliveriesPage>, Error> {
308
    let shell = Shell::new(&app, &format!("/orgs/{name}/settings/hooks/{id}/deliveries"), &viewer);
309
    let (_, org) = owned_org(&app, &shell, &viewer, &name).await?;
310
    csrf(&viewer, &f.csrf, &shell)?;
311
    let scope = super::webhooks::Scope::Org { id: org.id, name: org.name };
312
    super::webhooks::redeliver(&app, &scope, id, delivery).await.map_err(|e| Error::from_db(shell.clone(), e))?;
313
    super::webhooks::deliveries_page(&app, shell, &scope, None, id).await
314
}
315
316
/// `POST /orgs/{name}/settings/runners/token`: a one-time registration
317
/// token for a runner that serves the organisation's repositories
318
/// (oxygenhub#41).
319
pub async fn runner_token(
320
    State(app): State<Shared>,
321
    viewer: Viewer,
322
    Path(name): Path<String>,
323
    Form(f): Form<super::session::CsrfForm>,
324
) -> Result<Page<OrgSettingsPage>, Error> {
325
    let shell = Shell::new(&app, &format!("/orgs/{name}/settings"), &viewer);
326
    let (me, org) = owned_org(&app, &shell, &viewer, &name).await?;
327
    csrf(&viewer, &f.csrf, &shell)?;
328
    let token = format!("flowreg_{}", crate::auth::random_hex(24));
329
    let scope = crate::db::runners::Scope { owner: org.name.clone(), repo_id: None };
330
    crate::db::runners::add_registration(&app.db, &blake3::hash(token.as_bytes()).to_hex(), &me.username, &scope)
331
        .await
332
        .map_err(|e| Error::from_db(shell.clone(), e))?;
333
    accounts::audit(&app.db, Some(&me), "flow.runner.token", &org.name, "registration token for the organisation").await.ok();
334
    let mut page = org_page(&app, shell, org, Some("Copy the token now; it is not shown again.".into()), None).await?;
335
    page.0.runner_token = Some(token);
336
    Ok(page)
337
}
338
339
/// `POST /orgs/{name}/settings/runners/{id}/remove`: remove a runner scoped
340
/// to the organisation or one of its repositories, when it runs nothing.
341
pub async fn runner_remove(
342
    State(app): State<Shared>,
343
    viewer: Viewer,
344
    Path((name, id)): Path<(String, i64)>,
345
    Form(f): Form<super::session::CsrfForm>,
346
) -> Result<Page<OrgSettingsPage>, Error> {
347
    let shell = Shell::new(&app, &format!("/orgs/{name}/settings"), &viewer);
348
    let (me, org) = owned_org(&app, &shell, &viewer, &name).await?;
349
    csrf(&viewer, &f.csrf, &shell)?;
350
    let e = |e| Error::from_db(shell.clone(), e);
351
    let Some(r) = crate::db::runners::get(&app.db, id).await.map_err(e)?.filter(|r| r.scope_owner == org.name) else {
352
        return Err(Error::not_found(shell));
353
    };
354
    if crate::db::runners::running(&app.db).await.map_err(e)?.iter().any(|(rid, n)| *rid == id && *n > 0) {
355
        return org_page(&app, shell, org, None, Some(format!("{} is running jobs: wait for them to finish.", r.name))).await;
356
    }
357
    crate::db::runners::remove(&app.db, id).await.map_err(e)?;
358
    accounts::audit(&app.db, Some(&me), "flow.runner.removed", &r.name, &org.name).await.ok();
359
    org_page(&app, shell, org, Some(format!("{} removed: its credential no longer works.", r.name)), None).await
360
}