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