OXYGENHUB
# e1dc108bd814 oxygenhub / crates / hub / src / web / admin.rs
18.1 KiB · Rust · last changed Sign in with GitHub or Google 1 month ago source blame history raw
19bb31ff Andy McCall ↶ 1
//! Site administration (hub spec §6.3 `/admin/*`): accounts, registration
2
//! mode and invitations, the audit log.
3
4
use super::settings::csrf;
5
use super::{filters, Error, Page, Shell};
6
use crate::auth::{self, Viewer};
7
use crate::db::accounts;
8
use crate::Shared;
9
use askama::Template;
10
use axum::extract::{Form, State};
11
use serde::Deserialize;
12
13
#[derive(Template)]
14
#[template(path = "admin.html")]
15
pub struct AdminPage {
16
    shell: Shell,
17
    accounts: Vec<accounts::Account>,
18
    orgs: Vec<accounts::Organisation>,
19
    mode: String,
20
    invitations: Vec<(String, Option<i64>, i64)>,
21
    audit: Vec<accounts::AuditRow>,
22
    repos: usize,
23
    notice: Option<String>,
3556d08e Andy McCall ↶ 24
    /// Mail settings (#101): the SMTP URL with its password masked, the
25
    /// From address, whether mail is on, and the administrator's own
26
    /// address for a test message.
27
    mail_smtp: String,
28
    mail_from: String,
29
    mail_enabled: bool,
30
    my_email: String,
e71846aa Andy McCall ↶ 31
    /// The site's AI assistants and prompt (#97).
32
    assistants: super::assistants::Panel,
2a83f530 Andy McCall ↶ 33
    /// The site's Flow variables (#102).
34
    variables: super::variables::Panel,
4ac01a73 Andy McCall ↶ 35
    /// The site's Flow secrets (#107).
36
    secrets: super::secrets::Panel,
83940d53 Andy McCall ↶ 37
    /// Sign-in providers (#106).
38
    providers: Vec<super::signin::ProviderView>,
19bb31ff Andy McCall ↶ 39
}
40
41
fn require_admin(viewer: &Viewer, shell: &Shell) -> Result<accounts::Account, Error> {
42
    match viewer.account() {
43
        Some(a) if a.is_admin => Ok(a.clone()),
44
        _ => Err(Error::not_found(shell.clone())),
45
    }
46
}
47
48
async fn admin_page(app: &Shared, shell: Shell, notice: Option<String>) -> Result<Page<AdminPage>, Error> {
49
    let e = |e| Error::from_db(shell.clone(), e);
50
    let accounts_list = accounts::accounts(&app.db).await.map_err(e)?;
51
    let orgs = accounts::orgs(&app.db).await.map_err(e)?;
52
    let mode = super::session::registration_mode(app).await;
53
    let invitations = accounts::invitations(&app.db).await.map_err(e)?;
54
    let audit = accounts::audit_rows(&app.db, 100).await.map_err(e)?;
55
    let repos = crate::db::repos::list(&app.db).await.map_err(e)?.len();
3556d08e Andy McCall ↶ 56
    let mail = crate::mail::settings(app);
57
    let my_email = match shell.user.as_deref() {
58
        Some(u) => accounts::account_by_name(&app.db, u).await.map_err(e)?.map(|a| a.email).unwrap_or_default(),
59
        None => String::new(),
60
    };
e71846aa Andy McCall ↶ 61
    let assistants = super::assistants::panel(app, &site_assistants()).await.map_err(e)?;
2a83f530 Andy McCall ↶ 62
    let variables = super::variables::panel(app, &site_assistants()).await.map_err(e)?;
4ac01a73 Andy McCall ↶ 63
    let secrets = super::secrets::panel(app, &site_assistants()).await.map_err(e)?;
83940d53 Andy McCall ↶ 64
    let providers = super::signin::provider_views(app).await.map_err(e)?;
3556d08e Andy McCall ↶ 65
    Ok(Page(AdminPage {
83940d53 Andy McCall ↶ 66
        providers,
e71846aa Andy McCall ↶ 67
        assistants,
2a83f530 Andy McCall ↶ 68
        variables,
4ac01a73 Andy McCall ↶ 69
        secrets,
3556d08e Andy McCall ↶ 70
        shell,
71
        accounts: accounts_list,
72
        orgs,
73
        mode,
74
        invitations,
75
        audit,
76
        repos,
77
        notice,
78
        mail_smtp: crate::mail::masked(&mail.smtp),
79
        mail_from: mail.from.clone(),
80
        mail_enabled: !mail.smtp.is_empty() && !mail.from.is_empty(),
81
        my_email,
82
    }))
83
}
84
85
#[derive(Deserialize)]
86
pub struct MailForm {
87
    #[serde(rename = "_csrf")]
88
    csrf_token: String,
89
    #[serde(default)]
90
    smtp: String,
91
    #[serde(default)]
92
    from: String,
93
}
94
95
/// `POST /admin/mail`: the SMTP URL and From address (#101). The URL
96
/// comes back masked, so a masked value left as it was keeps the stored
97
/// one; an empty URL turns mail off.
98
pub async fn set_mail(State(app): State<Shared>, viewer: Viewer, Form(f): Form<MailForm>) -> Result<Page<AdminPage>, Error> {
99
    let shell = Shell::new(&app, "/admin", &viewer);
100
    let me = require_admin(&viewer, &shell)?;
101
    csrf(&viewer, &f.csrf_token, &shell)?;
102
    let current = crate::mail::settings(&app);
103
    let smtp = f.smtp.trim();
104
    let smtp = if !current.smtp.is_empty() && smtp == crate::mail::masked(&current.smtp) { current.smtp.clone() } else { smtp.to_string() };
105
    let from = f.from.trim().to_string();
106
    if !smtp.is_empty() && !(smtp.starts_with("smtp://") || smtp.starts_with("smtps://") || smtp.starts_with("file://")) {
107
        return admin_page(
108
            &app,
109
            shell,
110
            Some("The SMTP URL starts with smtp:// or smtps:// (file:///dir writes messages to a directory, for testing).".into()),
111
        )
112
        .await;
113
    }
114
    if !smtp.is_empty() && from.is_empty() {
115
        return admin_page(&app, shell, Some("A From address is needed: OxygenHub <[email protected]>.".into())).await;
116
    }
117
    crate::mail::save(&app, &smtp, &from).await.map_err(|e| Error::from_db(shell.clone(), e))?;
118
    accounts::audit(&app.db, Some(&me), "admin.mail", if smtp.is_empty() { "off" } else { "on" }, &from).await.ok();
119
    admin_page(&app, shell, Some(if smtp.is_empty() { "Mail is off.".into() } else { "Mail settings saved.".into() })).await
120
}
121
122
/// `POST /admin/mail/test`: a message to the administrator's own address.
123
pub async fn test_mail(
124
    State(app): State<Shared>,
125
    viewer: Viewer,
126
    Form(f): Form<super::session::CsrfForm>,
127
) -> Result<Page<AdminPage>, Error> {
128
    let shell = Shell::new(&app, "/admin", &viewer);
129
    let me = require_admin(&viewer, &shell)?;
130
    csrf(&viewer, &f.csrf, &shell)?;
131
    if !crate::mail::enabled(&app) {
132
        return admin_page(&app, shell, Some("Mail is off; save an SMTP URL and a From address first.".into())).await;
133
    }
134
    if me.email.is_empty() {
135
        return admin_page(&app, shell, Some("Your account has no email address to send the test to.".into())).await;
136
    }
137
    let notice = match crate::mail::send(
138
        &app,
139
        me.name(),
140
        &me.email,
141
        "Test message",
142
        "This is a test message from your hub's mail settings. If you can read it, mail works.",
143
    )
144
    .await
145
    {
146
        Ok(()) => format!("Sent a test message to {}.", me.email),
147
        Err(e) => format!("Sending failed: {e}"),
148
    };
149
    admin_page(&app, shell, Some(notice)).await
19bb31ff Andy McCall ↶ 150
}
151
152
pub async fn index(State(app): State<Shared>, viewer: Viewer) -> Result<Page<AdminPage>, Error> {
153
    let shell = Shell::new(&app, "/admin", &viewer);
154
    require_admin(&viewer, &shell)?;
155
    admin_page(&app, shell, None).await
156
}
157
158
#[derive(Deserialize)]
159
pub struct ModeForm {
160
    #[serde(rename = "_csrf")]
161
    csrf_token: String,
162
    mode: String,
163
}
164
165
pub async fn set_mode(State(app): State<Shared>, viewer: Viewer, Form(f): Form<ModeForm>) -> Result<Page<AdminPage>, Error> {
166
    let shell = Shell::new(&app, "/admin", &viewer);
167
    let me = require_admin(&viewer, &shell)?;
168
    csrf(&viewer, &f.csrf_token, &shell)?;
169
    if !matches!(f.mode.as_str(), "open" | "invite" | "closed") {
170
        return Err(Error::bad(shell, "mode is open, invite or closed"));
171
    }
172
    accounts::set_setting(&app.db, "registration_mode", &f.mode).await.map_err(|e| Error::from_db(shell.clone(), e))?;
173
    accounts::audit(&app.db, Some(&me), "admin.registration", &f.mode, "").await.ok();
174
    admin_page(&app, shell, Some(format!("Registration is {}.", f.mode))).await
175
}
176
177
pub async fn invite(State(app): State<Shared>, viewer: Viewer, Form(f): Form<super::session::CsrfForm>) -> Result<Page<AdminPage>, Error> {
178
    let shell = Shell::new(&app, "/admin", &viewer);
179
    let me = require_admin(&viewer, &shell)?;
180
    csrf(&viewer, &f.csrf, &shell)?;
181
    let code = auth::random_hex(12);
182
    accounts::create_invitation(&app.db, &code, Some(me.id)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
183
    accounts::audit(&app.db, Some(&me), "admin.invite", &code, "").await.ok();
184
    admin_page(&app, shell, Some(format!("Invitation created: {}/register?invite={code}", app.config.base_url()))).await
185
}
186
187
#[derive(Deserialize)]
188
pub struct AccountForm {
189
    #[serde(rename = "_csrf")]
190
    csrf_token: String,
191
    username: String,
192
    action: String,
6a178055 Andy McCall ↶ 193
    /// For delete: the username typed again.
194
    #[serde(default)]
195
    confirm: String,
19bb31ff Andy McCall ↶ 196
}
197
198
pub async fn account(State(app): State<Shared>, viewer: Viewer, Form(f): Form<AccountForm>) -> Result<Page<AdminPage>, Error> {
199
    let shell = Shell::new(&app, "/admin", &viewer);
200
    let me = require_admin(&viewer, &shell)?;
201
    csrf(&viewer, &f.csrf_token, &shell)?;
202
    let Some(target) = accounts::account_by_name(&app.db, f.username.trim()).await.map_err(|e| Error::from_db(shell.clone(), e))? else {
203
        return admin_page(&app, shell, Some("No such account.".into())).await;
204
    };
6a178055 Andy McCall ↶ 205
    if target.id == me.id && matches!(f.action.as_str(), "ban" | "demote" | "disable" | "delete") {
19bb31ff Andy McCall ↶ 206
        return admin_page(&app, shell, Some("Not on yourself.".into())).await;
207
    }
208
    let e = |e| Error::from_db(shell.clone(), e);
6a178055 Andy McCall ↶ 209
    // The hub must keep an admin who can sign in (oxygenhub#81).
210
    if target.is_admin && matches!(f.action.as_str(), "disable" | "delete") {
211
        let others =
212
            accounts::accounts(&app.db).await.map_err(e)?.into_iter().filter(|a| a.is_admin && !a.blocked() && a.id != target.id).count();
213
        if others == 0 {
214
            return admin_page(&app, shell, Some(format!("{} is the last admin who can sign in.", target.username))).await;
215
        }
216
    }
217
    if f.action == "delete" {
218
        if f.confirm.trim() != target.username {
219
            return admin_page(&app, shell, Some(format!("Not deleted: type {} to confirm.", target.username))).await;
220
        }
221
        let repos = crate::db::repos::list_by_owner(&app.db, &target.username).await.map_err(e)?;
222
        let orgs = accounts::orgs_owned_only_by(&app.db, target.id).await.map_err(e)?;
223
        if !repos.is_empty() || !orgs.is_empty() {
224
            let mut held: Vec<String> = repos.iter().map(|r| r.slug()).collect();
225
            held.extend(orgs.iter().map(|o| format!("organisation {o} (sole owner)")));
226
            return admin_page(
227
                &app,
228
                shell,
229
                Some(format!("Not deleted: {} still owns {}. Transfer or delete them first.", target.username, held.join(", "))),
230
            )
231
            .await;
232
        }
233
        accounts::delete_account(&app.db, target.id, &target.username).await.map_err(e)?;
234
        accounts::audit(&app.db, Some(&me), "admin.account.delete", &target.username, "").await.ok();
235
        return admin_page(&app, shell, Some(format!("{} deleted; the name stays retired.", target.username))).await;
236
    }
34beb1a7 Andy McCall ↶ 237
    if f.action == "reset_link" {
6a178055 Andy McCall ↶ 238
        if target.blocked() {
239
            return admin_page(&app, shell, Some("A banned or disabled account cannot be reset.".into())).await;
34beb1a7 Andy McCall ↶ 240
        }
241
        let url = super::recovery::admin_reset_link(&app, &me, &target).await.map_err(e)?;
242
        return admin_page(
243
            &app,
244
            shell,
245
            Some(format!("Reset link for {} (works once, for 24 hours; shown only now): {url}", target.username)),
246
        )
247
        .await;
248
    }
19bb31ff Andy McCall ↶ 249
    match f.action.as_str() {
250
        "ban" => accounts::set_banned(&app.db, target.id, true).await.map_err(e)?,
251
        "unban" => accounts::set_banned(&app.db, target.id, false).await.map_err(e)?,
6a178055 Andy McCall ↶ 252
        "disable" => accounts::set_disabled(&app.db, target.id, true).await.map_err(e)?,
253
        "enable" => accounts::set_disabled(&app.db, target.id, false).await.map_err(e)?,
19bb31ff Andy McCall ↶ 254
        "promote" => accounts::set_admin(&app.db, target.id, true).await.map_err(e)?,
255
        "demote" => accounts::set_admin(&app.db, target.id, false).await.map_err(e)?,
256
        _ => return Err(Error::bad(shell, "unknown action")),
257
    }
258
    accounts::audit(&app.db, Some(&me), &format!("admin.account.{}", f.action), &target.username, "").await.ok();
259
    admin_page(&app, shell, Some(format!("{}: {}.", target.username, f.action))).await
260
}
8fb35df7 Andy McCall ↶ 261
262
// ------------------------------------------------------------------ storage
263
264
/// One repository's footprint on disk.
265
pub struct StorageRow {
266
    pub slug: String,
267
    pub bytes: u64,
268
    /// Objects not yet in a pack.
269
    pub loose: usize,
270
    pub packs: usize,
271
}
272
273
#[derive(Template)]
274
#[template(path = "admin_storage.html")]
275
pub struct StoragePage {
276
    shell: Shell,
277
    rows: Vec<StorageRow>,
278
    total: u64,
279
    notice: Option<String>,
280
    error: Option<String>,
281
}
282
283
/// Size, loose objects and packs under a repository directory.
284
fn footprint(dir: &std::path::Path) -> (u64, usize, usize) {
285
    let (mut bytes, mut loose, mut packs) = (0u64, 0usize, 0usize);
286
    let mut stack = vec![dir.to_path_buf()];
287
    while let Some(d) = stack.pop() {
288
        let Ok(rd) = std::fs::read_dir(&d) else { continue };
289
        for e in rd.flatten() {
290
            let Ok(ft) = e.file_type() else { continue };
291
            if ft.is_dir() {
292
                stack.push(e.path());
293
            } else if ft.is_file() {
294
                bytes += e.metadata().map(|m| m.len()).unwrap_or(0);
295
                let p = e.path();
296
                if p.components().any(|c| c.as_os_str() == "loose") {
297
                    loose += 1;
298
                } else if p.extension().is_some_and(|x| x == "pack") {
299
                    packs += 1;
300
                }
301
            }
302
        }
303
    }
304
    (bytes, loose, packs)
305
}
306
307
async fn storage_page(app: &Shared, shell: Shell, notice: Option<String>, error: Option<String>) -> Result<Page<StoragePage>, Error> {
308
    let repos = crate::db::repos::list(&app.db).await.map_err(|e| Error::from_db(shell.clone(), e))?;
309
    let dirs: Vec<(String, std::path::PathBuf)> =
310
        repos.iter().filter_map(|r| crate::repos::RepoName::new(&r.owner, &r.name).map(|n| (n.to_string(), app.repos.dir(&n)))).collect();
311
    let mut rows = tokio::task::spawn_blocking(move || {
312
        dirs.into_iter()
313
            .map(|(slug, dir)| {
314
                let (bytes, loose, packs) = footprint(&dir);
315
                StorageRow { slug, bytes, loose, packs }
316
            })
317
            .collect::<Vec<_>>()
318
    })
319
    .await
320
    .unwrap_or_default();
321
    rows.sort_by_key(|r| std::cmp::Reverse(r.bytes));
322
    let total = rows.iter().map(|r| r.bytes).sum();
323
    Ok(Page(StoragePage { shell, rows, total, notice, error }))
324
}
325
326
pub async fn storage(State(app): State<Shared>, viewer: Viewer) -> Result<Page<StoragePage>, Error> {
327
    let shell = Shell::new(&app, "/admin/storage", &viewer);
328
    require_admin(&viewer, &shell)?;
329
    storage_page(&app, shell, None, None).await
330
}
331
332
#[derive(Deserialize)]
333
pub struct MaintainForm {
334
    #[serde(rename = "_csrf")]
335
    csrf_token: String,
336
    /// `owner/name`, or `*` for every repository.
337
    repo: String,
338
}
339
340
/// `POST /admin/storage`: `oxy gc` on one repository or all of them, under
341
/// each repository's write lock, with the grace period kept.
342
pub async fn maintain(State(app): State<Shared>, viewer: Viewer, Form(f): Form<MaintainForm>) -> Result<Page<StoragePage>, Error> {
343
    let shell = Shell::new(&app, "/admin/storage", &viewer);
344
    let me = require_admin(&viewer, &shell)?;
345
    csrf(&viewer, &f.csrf_token, &shell)?;
346
    let names: Vec<crate::repos::RepoName> = if f.repo == "*" {
347
        crate::db::repos::list(&app.db)
348
            .await
349
            .map_err(|e| Error::from_db(shell.clone(), e))?
350
            .iter()
351
            .filter_map(|r| crate::repos::RepoName::new(&r.owner, &r.name))
352
            .collect()
353
    } else {
354
        match crate::repos::RepoName::parse(&f.repo) {
355
            Some(n) if app.repos.exists(&n) => vec![n],
356
            _ => return storage_page(&app, shell, None, Some(format!("No repository {}.", f.repo))).await,
357
        }
358
    };
359
    let (mut packed, mut pruned, mut freed, mut failed) = (0usize, 0usize, 0u64, Vec::new());
360
    for name in &names {
361
        match app.repos.write(name, |r, _| r.gc(false).map_err(crate::repos::RepoError::Oxy)).await {
362
            Ok(rep) => {
363
                packed += rep.packed.objects;
364
                pruned += rep.pruned.objects;
365
                freed += rep.pruned.bytes + rep.packed.raw_bytes.saturating_sub(rep.packed.packed_bytes);
366
            }
367
            Err(e) => failed.push(format!("{name}: {e}")),
368
        }
369
    }
370
    let summary = format!(
371
        "Maintained {} repositor{}: {packed} loose objects packed, {pruned} unreachable objects pruned, about {} saved.",
372
        names.len(),
373
        if names.len() == 1 { "y" } else { "ies" },
374
        crate::render::bytes(freed)
375
    );
376
    accounts::audit(&app.db, Some(&me), "admin.gc", &f.repo, &summary).await.ok();
377
    let error = (!failed.is_empty()).then(|| failed.join("; "));
378
    storage_page(&app, shell, Some(summary), error).await
379
}
e71846aa Andy McCall ↶ 380
2a83f530 Andy McCall ↶ 381
fn site_assistants() -> crate::levels::At {
382
    crate::levels::At::site()
e71846aa Andy McCall ↶ 383
}
384
385
/// `POST /admin/assistants`: the site's assistants and prompt (#97).
386
pub async fn assistants(
387
    State(app): State<Shared>,
388
    viewer: Viewer,
389
    Form(f): Form<super::assistants::AssistantForm>,
390
) -> Result<Page<AdminPage>, Error> {
391
    let shell = Shell::new(&app, "/admin", &viewer);
392
    require_admin(&viewer, &shell)?;
393
    csrf(&viewer, &f.csrf_token, &shell)?;
394
    let outcome = super::assistants::apply(&app, &viewer, &site_assistants(), f).await.map_err(|e| Error::from_db(shell.clone(), e))?;
395
    admin_page(&app, shell, Some(outcome.unwrap_or_else(|e| e))).await
396
}
2a83f530 Andy McCall ↶ 397
398
/// `POST /admin/variables`: the site's Flow variables (#102).
399
pub async fn variables(
400
    State(app): State<Shared>,
401
    viewer: Viewer,
402
    Form(f): Form<super::variables::VariableForm>,
403
) -> Result<Page<AdminPage>, Error> {
404
    let shell = Shell::new(&app, "/admin", &viewer);
405
    require_admin(&viewer, &shell)?;
406
    csrf(&viewer, &f.csrf_token, &shell)?;
407
    let outcome = super::variables::apply(&app, &viewer, &site_assistants(), f).await.map_err(|e| Error::from_db(shell.clone(), e))?;
408
    admin_page(&app, shell, Some(outcome.unwrap_or_else(|e| e))).await
409
}
4ac01a73 Andy McCall ↶ 410
411
/// `POST /admin/secrets`: the site's Flow secrets (#107).
412
pub async fn secrets(
413
    State(app): State<Shared>,
414
    viewer: Viewer,
415
    Form(f): Form<super::secrets::SecretForm>,
416
) -> Result<Page<AdminPage>, Error> {
417
    let shell = Shell::new(&app, "/admin", &viewer);
418
    require_admin(&viewer, &shell)?;
419
    csrf(&viewer, &f.csrf_token, &shell)?;
420
    let outcome = super::secrets::apply(&app, &viewer, &site_assistants(), f).await.map_err(|e| Error::from_db(shell.clone(), e))?;
421
    admin_page(&app, shell, Some(outcome.unwrap_or_else(|e| e))).await
422
}
83940d53 Andy McCall ↶ 423
424
/// `POST /admin/signin`: a sign-in provider's client id, secret and switch (#106).
425
pub async fn signin(
426
    State(app): State<Shared>,
427
    viewer: Viewer,
428
    Form(f): Form<super::signin::ProviderForm>,
429
) -> Result<Page<AdminPage>, Error> {
430
    let shell = Shell::new(&app, "/admin", &viewer);
431
    require_admin(&viewer, &shell)?;
432
    csrf(&viewer, &f.csrf_token, &shell)?;
433
    let outcome = super::signin::apply(&app, &viewer, f).await.map_err(|e| Error::from_db(shell.clone(), e))?;
434
    admin_page(&app, shell, Some(outcome.unwrap_or_else(|e| e))).await
435
}