OXYGENHUB
◆ diffusion-pin-bookmark oxygenhub / crates / hub / src / web / admin.rs
28.5 KiB · Rust · last changed Runner updates: policy, test groups and the offer 10 days 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
737de0fa Andy McCall ↶ 13
/// A page of the admin area (oxygenhub#288): grouped in a sidebar, one
14
/// page per section, as a repository's settings are (#278).
15
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
16
pub enum AdminSection {
17
    Overview,
18
    Accounts,
19
    Registration,
20
    Organisations,
21
    SignIn,
22
    SecurityContact,
23
    Mail,
24
    Runners,
25
    Images,
26
    RunnerSettings,
8501fd58 Andy McCall ↶ 27
    RunnerUpdates,
737de0fa Andy McCall ↶ 28
    Insights,
29
    Variables,
30
    Secrets,
31
    Assistants,
32
    Diffusion,
33
    Storage,
34
    Backups,
35
    Audit,
36
}
37
38
/// The sidebar: each group's heading and its pages, in order.
39
const ADMIN_GROUPS: &[(&str, &[AdminSection])] = &[
40
    ("Overview", &[AdminSection::Overview]),
41
    ("People", &[AdminSection::Accounts, AdminSection::Registration, AdminSection::Organisations]),
42
    ("Sign-in", &[AdminSection::SignIn, AdminSection::SecurityContact]),
43
    ("Mail", &[AdminSection::Mail]),
44
    (
45
        "Flow",
46
        &[
47
            AdminSection::Runners,
48
            AdminSection::Images,
49
            AdminSection::RunnerSettings,
8501fd58 Andy McCall ↶ 50
            AdminSection::RunnerUpdates,
737de0fa Andy McCall ↶ 51
            AdminSection::Insights,
52
            AdminSection::Variables,
53
            AdminSection::Secrets,
54
        ],
55
    ),
56
    ("Integrations", &[AdminSection::Assistants, AdminSection::Diffusion]),
57
    ("Data", &[AdminSection::Storage, AdminSection::Backups]),
58
    ("Audit", &[AdminSection::Audit]),
59
];
60
61
/// The admin sidebar's groups, for templates.
62
pub fn admin_groups() -> &'static [(&'static str, &'static [AdminSection])] {
63
    ADMIN_GROUPS
64
}
65
66
impl AdminSection {
67
    /// Its page's address.
68
    pub fn href(self) -> &'static str {
69
        match self {
70
            AdminSection::Overview => "/admin",
71
            AdminSection::Accounts => "/admin/accounts",
72
            AdminSection::Registration => "/admin/registration",
73
            AdminSection::Organisations => "/admin/organisations",
74
            AdminSection::SignIn => "/admin/signin",
75
            AdminSection::SecurityContact => "/admin/security",
76
            AdminSection::Mail => "/admin/mail",
77
            AdminSection::Runners => "/admin/runners",
78
            AdminSection::Images => "/admin/runners/images",
79
            AdminSection::RunnerSettings => "/admin/runners/settings",
8501fd58 Andy McCall ↶ 80
            AdminSection::RunnerUpdates => "/admin/runners/updates",
737de0fa Andy McCall ↶ 81
            AdminSection::Insights => "/admin/insights",
82
            AdminSection::Variables => "/admin/variables",
83
            AdminSection::Secrets => "/admin/secrets",
84
            AdminSection::Assistants => "/admin/assistants",
85
            AdminSection::Diffusion => "/admin/diffusion",
86
            AdminSection::Storage => "/admin/storage",
87
            AdminSection::Backups => "/admin/backups",
88
            AdminSection::Audit => "/admin/audit",
89
        }
90
    }
91
92
    /// The anchor it had on the one-page admin, so a kept `/admin#mail`
93
    /// link opens its page (hub.js).
94
    pub fn slug(self) -> &'static str {
95
        match self {
96
            AdminSection::Overview => "overview",
97
            AdminSection::Accounts => "accounts",
98
            AdminSection::Registration => "registration",
99
            AdminSection::Organisations => "organisations",
100
            AdminSection::SignIn => "signin",
101
            AdminSection::SecurityContact => "security",
102
            AdminSection::Mail => "mail",
103
            AdminSection::Runners => "runners",
104
            AdminSection::Images => "images",
105
            AdminSection::RunnerSettings => "runner-settings",
8501fd58 Andy McCall ↶ 106
            AdminSection::RunnerUpdates => "runner-updates",
737de0fa Andy McCall ↶ 107
            AdminSection::Insights => "insights",
108
            AdminSection::Variables => "variables",
109
            AdminSection::Secrets => "secrets",
110
            AdminSection::Assistants => "assistants",
111
            AdminSection::Diffusion => "diffusion",
112
            AdminSection::Storage => "storage",
113
            AdminSection::Backups => "backups",
114
            AdminSection::Audit => "audit",
115
        }
116
    }
117
118
    pub fn title(self) -> &'static str {
119
        match self {
120
            AdminSection::Overview => "Overview",
121
            AdminSection::Accounts => "Accounts",
122
            AdminSection::Registration => "Registration & invitations",
123
            AdminSection::Organisations => "Organisations",
124
            AdminSection::SignIn => "Sign in with",
125
            AdminSection::SecurityContact => "Security contact",
126
            AdminSection::Mail => "Mail",
127
            AdminSection::Runners => "Runners",
128
            AdminSection::Images => "Images",
129
            AdminSection::RunnerSettings => "Runner settings",
8501fd58 Andy McCall ↶ 130
            AdminSection::RunnerUpdates => "Runner updates",
737de0fa Andy McCall ↶ 131
            AdminSection::Insights => "Insights",
132
            AdminSection::Variables => "Variables",
133
            AdminSection::Secrets => "Secrets",
134
            AdminSection::Assistants => "AI assistants",
135
            AdminSection::Diffusion => "Diffusion",
136
            AdminSection::Storage => "Storage & maintenance",
137
            AdminSection::Backups => "Backups",
138
            AdminSection::Audit => "Audit log",
139
        }
140
    }
141
142
    /// The section a page of admin.html shows, by its address.
143
    fn of_path(path: &str) -> Option<AdminSection> {
144
        ADMIN_GROUPS.iter().flat_map(|(_, pages)| pages.iter().copied()).find(|s| s.href() == path)
145
    }
146
}
147
19bb31ff Andy McCall ↶ 148
#[derive(Template)]
149
#[template(path = "admin.html")]
150
pub struct AdminPage {
151
    shell: Shell,
737de0fa Andy McCall ↶ 152
    /// The page shown (oxygenhub#288).
153
    section: AdminSection,
19bb31ff Andy McCall ↶ 154
    accounts: Vec<accounts::Account>,
155
    orgs: Vec<accounts::Organisation>,
156
    mode: String,
157
    invitations: Vec<(String, Option<i64>, i64)>,
158
    audit: Vec<accounts::AuditRow>,
159
    repos: usize,
160
    notice: Option<String>,
3556d08e Andy McCall ↶ 161
    /// Mail settings (#101): the SMTP URL with its password masked, the
162
    /// From address, whether mail is on, and the administrator's own
163
    /// address for a test message.
164
    mail_smtp: String,
165
    mail_from: String,
166
    mail_enabled: bool,
cd46e56b Andy McCall ↶ 167
    /// The saved mail settings as the panel shows them (oxygenhub#181).
168
    mail: crate::mail::Choice,
3556d08e Andy McCall ↶ 169
    my_email: String,
e8e7158b Andy McCall ↶ 170
    /// The audit log's next page: rows older than this id (#110).
171
    audit_older: Option<i64>,
e71846aa Andy McCall ↶ 172
    /// The site's AI assistants and prompt (#97).
173
    assistants: super::assistants::Panel,
2a83f530 Andy McCall ↶ 174
    /// The site's Flow variables (#102).
175
    variables: super::variables::Panel,
4ac01a73 Andy McCall ↶ 176
    /// The site's Flow secrets (#107).
177
    secrets: super::secrets::Panel,
83940d53 Andy McCall ↶ 178
    /// Sign-in providers (#106).
179
    providers: Vec<super::signin::ProviderView>,
3dd6582d Andy McCall ↶ 180
    /// The security.txt contact and policy (oxygenhub#224).
181
    security_contact: String,
182
    security_policy: String,
2cbf6f34 Andy McCall ↶ 183
    /// The /security-contact page's address and text (oxygenhub#230).
184
    security_email: String,
185
    security_page: String,
186
    security_default: &'static str,
19bb31ff Andy McCall ↶ 187
}
188
3dd6582d Andy McCall ↶ 189
pub(super) fn require_admin(viewer: &Viewer, shell: &Shell) -> Result<accounts::Account, Error> {
19bb31ff Andy McCall ↶ 190
    match viewer.account() {
191
        Some(a) if a.is_admin => Ok(a.clone()),
192
        _ => Err(Error::not_found(shell.clone())),
193
    }
194
}
195
737de0fa Andy McCall ↶ 196
pub(super) async fn admin_page(
197
    app: &Shared,
198
    shell: Shell,
199
    section: AdminSection,
200
    notice: Option<String>,
201
) -> Result<Page<AdminPage>, Error> {
202
    admin_page_at(app, shell, section, notice, None).await
e8e7158b Andy McCall ↶ 203
}
204
205
/// The audit log shows this many rows a page.
206
const AUDIT_PAGE: i64 = 50;
207
737de0fa Andy McCall ↶ 208
async fn admin_page_at(
209
    app: &Shared,
210
    shell: Shell,
211
    section: AdminSection,
212
    notice: Option<String>,
213
    audit_before: Option<i64>,
214
) -> Result<Page<AdminPage>, Error> {
19bb31ff Andy McCall ↶ 215
    let e = |e| Error::from_db(shell.clone(), e);
216
    let accounts_list = accounts::accounts(&app.db).await.map_err(e)?;
217
    let orgs = accounts::orgs(&app.db).await.map_err(e)?;
218
    let mode = super::session::registration_mode(app).await;
219
    let invitations = accounts::invitations(&app.db).await.map_err(e)?;
e8e7158b Andy McCall ↶ 220
    let mut audit = accounts::audit_page(&app.db, audit_before, AUDIT_PAGE + 1).await.map_err(e)?;
221
    let audit_older = if audit.len() as i64 > AUDIT_PAGE {
222
        audit.truncate(AUDIT_PAGE as usize);
223
        audit.last().map(|r| r.id)
224
    } else {
225
        None
226
    };
19bb31ff Andy McCall ↶ 227
    let repos = crate::db::repos::list(&app.db).await.map_err(e)?.len();
3556d08e Andy McCall ↶ 228
    let mail = crate::mail::settings(app);
229
    let my_email = match shell.user.as_deref() {
230
        Some(u) => accounts::account_by_name(&app.db, u).await.map_err(e)?.map(|a| a.email).unwrap_or_default(),
231
        None => String::new(),
232
    };
e71846aa Andy McCall ↶ 233
    let assistants = super::assistants::panel(app, &site_assistants()).await.map_err(e)?;
2a83f530 Andy McCall ↶ 234
    let variables = super::variables::panel(app, &site_assistants()).await.map_err(e)?;
4ac01a73 Andy McCall ↶ 235
    let secrets = super::secrets::panel(app, &site_assistants()).await.map_err(e)?;
83940d53 Andy McCall ↶ 236
    let providers = super::signin::provider_views(app).await.map_err(e)?;
3dd6582d Andy McCall ↶ 237
    let (security_contact, security_policy) = super::security_txt::settings(app).await;
2cbf6f34 Andy McCall ↶ 238
    let (security_email, security_page) = super::security_txt::page_settings(app).await;
3556d08e Andy McCall ↶ 239
    Ok(Page(AdminPage {
737de0fa Andy McCall ↶ 240
        section,
83940d53 Andy McCall ↶ 241
        providers,
3dd6582d Andy McCall ↶ 242
        security_contact,
243
        security_policy,
2cbf6f34 Andy McCall ↶ 244
        security_email,
245
        security_page,
246
        security_default: super::security_txt::DEFAULT_PAGE,
e71846aa Andy McCall ↶ 247
        assistants,
2a83f530 Andy McCall ↶ 248
        variables,
4ac01a73 Andy McCall ↶ 249
        secrets,
3556d08e Andy McCall ↶ 250
        shell,
251
        accounts: accounts_list,
252
        orgs,
253
        mode,
254
        invitations,
255
        audit,
256
        repos,
257
        notice,
cd46e56b Andy McCall ↶ 258
        mail: crate::mail::describe(&mail.smtp),
3556d08e Andy McCall ↶ 259
        mail_smtp: crate::mail::masked(&mail.smtp),
260
        mail_from: mail.from.clone(),
261
        mail_enabled: !mail.smtp.is_empty() && !mail.from.is_empty(),
262
        my_email,
e8e7158b Andy McCall ↶ 263
        audit_older,
3556d08e Andy McCall ↶ 264
    }))
265
}
266
cd46e56b Andy McCall ↶ 267
impl AdminPage {
268
    fn mail_services(&self) -> &'static [(&'static str, &'static str)] {
269
        crate::mail::SERVICES
270
    }
271
}
272
3556d08e Andy McCall ↶ 273
#[derive(Deserialize)]
274
pub struct MailForm {
275
    #[serde(rename = "_csrf")]
276
    csrf_token: String,
277
    #[serde(default)]
278
    smtp: String,
279
    #[serde(default)]
280
    from: String,
cd46e56b Andy McCall ↶ 281
    /// The panel's service (oxygenhub#181); absent, `smtp` is the URL as
282
    /// before.
283
    #[serde(default)]
284
    service: String,
285
    #[serde(default)]
286
    region: String,
287
    #[serde(default)]
288
    user: String,
289
    #[serde(default)]
290
    secret: String,
291
    #[serde(default)]
292
    host: String,
293
    #[serde(default)]
294
    port: String,
295
    #[serde(default)]
296
    security: String,
3556d08e Andy McCall ↶ 297
}
298
299
/// `POST /admin/mail`: the SMTP URL and From address (#101). The URL
300
/// comes back masked, so a masked value left as it was keeps the stored
301
/// one; an empty URL turns mail off.
302
pub async fn set_mail(State(app): State<Shared>, viewer: Viewer, Form(f): Form<MailForm>) -> Result<Page<AdminPage>, Error> {
303
    let shell = Shell::new(&app, "/admin", &viewer);
304
    let me = require_admin(&viewer, &shell)?;
305
    csrf(&viewer, &f.csrf_token, &shell)?;
306
    let current = crate::mail::settings(&app);
cd46e56b Andy McCall ↶ 307
    // A service the panel knows: it makes the SMTP URL (oxygenhub#181).
308
    let smtp = match f.service.as_str() {
309
        "off" => String::new(),
310
        "" | "url" => {
311
            let smtp = f.smtp.trim();
312
            if !current.smtp.is_empty() && smtp == crate::mail::masked(&current.smtp) {
313
                current.smtp.clone()
314
            } else {
315
                smtp.to_string()
316
            }
317
        }
318
        _ => {
319
            let asked = crate::mail::Asked {
320
                service: f.service.clone(),
321
                region: f.region.clone(),
322
                user: f.user.clone(),
323
                secret: f.secret.trim().to_string(),
324
                host: f.host.clone(),
325
                port: f.port.clone(),
326
                security: f.security.clone(),
327
            };
328
            match crate::mail::compose(&asked, &current.smtp) {
329
                Ok(url) => url,
737de0fa Andy McCall ↶ 330
                Err(why) => return admin_page(&app, shell, AdminSection::Mail, Some(why)).await,
cd46e56b Andy McCall ↶ 331
            }
332
        }
333
    };
3556d08e Andy McCall ↶ 334
    let from = f.from.trim().to_string();
335
    if !smtp.is_empty() && !(smtp.starts_with("smtp://") || smtp.starts_with("smtps://") || smtp.starts_with("file://")) {
336
        return admin_page(
337
            &app,
338
            shell,
737de0fa Andy McCall ↶ 339
            AdminSection::Mail,
3556d08e Andy McCall ↶ 340
            Some("The SMTP URL starts with smtp:// or smtps:// (file:///dir writes messages to a directory, for testing).".into()),
341
        )
342
        .await;
343
    }
344
    if !smtp.is_empty() && from.is_empty() {
737de0fa Andy McCall ↶ 345
        return admin_page(&app, shell, AdminSection::Mail, Some("A From address is needed: OxygenHub <[email protected]>.".into()))
346
            .await;
3556d08e Andy McCall ↶ 347
    }
348
    crate::mail::save(&app, &smtp, &from).await.map_err(|e| Error::from_db(shell.clone(), e))?;
349
    accounts::audit(&app.db, Some(&me), "admin.mail", if smtp.is_empty() { "off" } else { "on" }, &from).await.ok();
737de0fa Andy McCall ↶ 350
    admin_page(&app, shell, AdminSection::Mail, Some(if smtp.is_empty() { "Mail is off.".into() } else { "Mail settings saved.".into() }))
351
        .await
3556d08e Andy McCall ↶ 352
}
353
354
/// `POST /admin/mail/test`: a message to the administrator's own address.
355
pub async fn test_mail(
356
    State(app): State<Shared>,
357
    viewer: Viewer,
358
    Form(f): Form<super::session::CsrfForm>,
359
) -> Result<Page<AdminPage>, Error> {
360
    let shell = Shell::new(&app, "/admin", &viewer);
361
    let me = require_admin(&viewer, &shell)?;
362
    csrf(&viewer, &f.csrf, &shell)?;
363
    if !crate::mail::enabled(&app) {
737de0fa Andy McCall ↶ 364
        return admin_page(&app, shell, AdminSection::Mail, Some("Mail is off; save an SMTP URL and a From address first.".into())).await;
3556d08e Andy McCall ↶ 365
    }
366
    if me.email.is_empty() {
737de0fa Andy McCall ↶ 367
        return admin_page(&app, shell, AdminSection::Mail, Some("Your account has no email address to send the test to.".into())).await;
3556d08e Andy McCall ↶ 368
    }
cd46e56b Andy McCall ↶ 369
    let base = app.config.base_url().to_string();
370
    let notice = match crate::mail::send_letter(
3556d08e Andy McCall ↶ 371
        &app,
372
        me.name(),
373
        &me.email,
b91e2d81 Andy McCall ↶ 374
        "🧪 Test message",
cd46e56b Andy McCall ↶ 375
        "This is a test message from your hub's mail settings. If you can read it, mail works: verification links, password resets and notifications will reach people.",
376
        Some(crate::mail::Action { label: "Open the hub", url: &base }),
377
        None,
3556d08e Andy McCall ↶ 378
    )
379
    .await
380
    {
381
        Ok(()) => format!("Sent a test message to {}.", me.email),
382
        Err(e) => format!("Sending failed: {e}"),
383
    };
737de0fa Andy McCall ↶ 384
    admin_page(&app, shell, AdminSection::Mail, Some(notice)).await
19bb31ff Andy McCall ↶ 385
}
386
e8e7158b Andy McCall ↶ 387
#[derive(Deserialize)]
388
pub struct IndexQuery {
389
    audit_before: Option<i64>,
390
}
391
392
pub async fn index(
393
    State(app): State<Shared>,
394
    viewer: Viewer,
395
    axum::extract::Query(q): axum::extract::Query<IndexQuery>,
396
) -> Result<Page<AdminPage>, Error> {
19bb31ff Andy McCall ↶ 397
    let shell = Shell::new(&app, "/admin", &viewer);
737de0fa Andy McCall ↶ 398
    require_admin(&viewer, &shell)?;
399
    admin_page_at(&app, shell, AdminSection::Overview, None, q.audit_before).await
400
}
401
402
/// `GET /admin/{page}` for the sections admin.html shows (oxygenhub#288):
403
/// accounts, registration, organisations, sign-in, the security contact,
404
/// mail, variables, secrets, AI assistants and the audit log.
405
pub async fn section(
406
    State(app): State<Shared>,
407
    viewer: Viewer,
408
    uri: axum::http::Uri,
409
    axum::extract::Query(q): axum::extract::Query<IndexQuery>,
410
) -> Result<Page<AdminPage>, Error> {
411
    let shell = Shell::new(&app, uri.path(), &viewer);
19bb31ff Andy McCall ↶ 412
    require_admin(&viewer, &shell)?;
737de0fa Andy McCall ↶ 413
    let section = AdminSection::of_path(uri.path()).ok_or_else(|| Error::not_found(shell.clone()))?;
414
    admin_page_at(&app, shell, section, None, q.audit_before).await
19bb31ff Andy McCall ↶ 415
}
416
417
#[derive(Deserialize)]
418
pub struct ModeForm {
419
    #[serde(rename = "_csrf")]
420
    csrf_token: String,
421
    mode: String,
422
}
423
424
pub async fn set_mode(State(app): State<Shared>, viewer: Viewer, Form(f): Form<ModeForm>) -> Result<Page<AdminPage>, Error> {
425
    let shell = Shell::new(&app, "/admin", &viewer);
426
    let me = require_admin(&viewer, &shell)?;
427
    csrf(&viewer, &f.csrf_token, &shell)?;
428
    if !matches!(f.mode.as_str(), "open" | "invite" | "closed") {
429
        return Err(Error::bad(shell, "mode is open, invite or closed"));
430
    }
431
    accounts::set_setting(&app.db, "registration_mode", &f.mode).await.map_err(|e| Error::from_db(shell.clone(), e))?;
432
    accounts::audit(&app.db, Some(&me), "admin.registration", &f.mode, "").await.ok();
737de0fa Andy McCall ↶ 433
    admin_page(&app, shell, AdminSection::Registration, Some(format!("Registration is {}.", f.mode))).await
19bb31ff Andy McCall ↶ 434
}
435
436
pub async fn invite(State(app): State<Shared>, viewer: Viewer, Form(f): Form<super::session::CsrfForm>) -> Result<Page<AdminPage>, Error> {
437
    let shell = Shell::new(&app, "/admin", &viewer);
438
    let me = require_admin(&viewer, &shell)?;
439
    csrf(&viewer, &f.csrf, &shell)?;
440
    let code = auth::random_hex(12);
441
    accounts::create_invitation(&app.db, &code, Some(me.id)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
442
    accounts::audit(&app.db, Some(&me), "admin.invite", &code, "").await.ok();
737de0fa Andy McCall ↶ 443
    admin_page(
444
        &app,
445
        shell,
446
        AdminSection::Registration,
447
        Some(format!("Invitation created: {}/register?invite={code}", app.config.base_url())),
448
    )
449
    .await
19bb31ff Andy McCall ↶ 450
}
451
452
#[derive(Deserialize)]
453
pub struct AccountForm {
454
    #[serde(rename = "_csrf")]
455
    csrf_token: String,
456
    username: String,
457
    action: String,
6a178055 Andy McCall ↶ 458
    /// For delete: the username typed again.
459
    #[serde(default)]
460
    confirm: String,
19bb31ff Andy McCall ↶ 461
}
462
463
pub async fn account(State(app): State<Shared>, viewer: Viewer, Form(f): Form<AccountForm>) -> Result<Page<AdminPage>, Error> {
464
    let shell = Shell::new(&app, "/admin", &viewer);
465
    let me = require_admin(&viewer, &shell)?;
466
    csrf(&viewer, &f.csrf_token, &shell)?;
467
    let Some(target) = accounts::account_by_name(&app.db, f.username.trim()).await.map_err(|e| Error::from_db(shell.clone(), e))? else {
737de0fa Andy McCall ↶ 468
        return admin_page(&app, shell, AdminSection::Accounts, Some("No such account.".into())).await;
19bb31ff Andy McCall ↶ 469
    };
6a178055 Andy McCall ↶ 470
    if target.id == me.id && matches!(f.action.as_str(), "ban" | "demote" | "disable" | "delete") {
737de0fa Andy McCall ↶ 471
        return admin_page(&app, shell, AdminSection::Accounts, Some("Not on yourself.".into())).await;
19bb31ff Andy McCall ↶ 472
    }
473
    let e = |e| Error::from_db(shell.clone(), e);
6a178055 Andy McCall ↶ 474
    // The hub must keep an admin who can sign in (oxygenhub#81).
475
    if target.is_admin && matches!(f.action.as_str(), "disable" | "delete") {
476
        let others =
477
            accounts::accounts(&app.db).await.map_err(e)?.into_iter().filter(|a| a.is_admin && !a.blocked() && a.id != target.id).count();
478
        if others == 0 {
737de0fa Andy McCall ↶ 479
            return admin_page(
480
                &app,
481
                shell,
482
                AdminSection::Accounts,
483
                Some(format!("{} is the last admin who can sign in.", target.username)),
484
            )
485
            .await;
6a178055 Andy McCall ↶ 486
        }
487
    }
488
    if f.action == "delete" {
489
        if f.confirm.trim() != target.username {
737de0fa Andy McCall ↶ 490
            return admin_page(&app, shell, AdminSection::Accounts, Some(format!("Not deleted: type {} to confirm.", target.username)))
491
                .await;
6a178055 Andy McCall ↶ 492
        }
493
        let repos = crate::db::repos::list_by_owner(&app.db, &target.username).await.map_err(e)?;
494
        let orgs = accounts::orgs_owned_only_by(&app.db, target.id).await.map_err(e)?;
495
        if !repos.is_empty() || !orgs.is_empty() {
496
            let mut held: Vec<String> = repos.iter().map(|r| r.slug()).collect();
497
            held.extend(orgs.iter().map(|o| format!("organisation {o} (sole owner)")));
498
            return admin_page(
499
                &app,
500
                shell,
737de0fa Andy McCall ↶ 501
                AdminSection::Accounts,
6a178055 Andy McCall ↶ 502
                Some(format!("Not deleted: {} still owns {}. Transfer or delete them first.", target.username, held.join(", "))),
503
            )
504
            .await;
505
        }
506
        accounts::delete_account(&app.db, target.id, &target.username).await.map_err(e)?;
507
        accounts::audit(&app.db, Some(&me), "admin.account.delete", &target.username, "").await.ok();
737de0fa Andy McCall ↶ 508
        return admin_page(&app, shell, AdminSection::Accounts, Some(format!("{} deleted; the name stays retired.", target.username)))
509
            .await;
6a178055 Andy McCall ↶ 510
    }
34beb1a7 Andy McCall ↶ 511
    if f.action == "reset_link" {
6a178055 Andy McCall ↶ 512
        if target.blocked() {
737de0fa Andy McCall ↶ 513
            return admin_page(&app, shell, AdminSection::Accounts, Some("A banned or disabled account cannot be reset.".into())).await;
34beb1a7 Andy McCall ↶ 514
        }
515
        let url = super::recovery::admin_reset_link(&app, &me, &target).await.map_err(e)?;
516
        return admin_page(
517
            &app,
518
            shell,
737de0fa Andy McCall ↶ 519
            AdminSection::Accounts,
34beb1a7 Andy McCall ↶ 520
            Some(format!("Reset link for {} (works once, for 24 hours; shown only now): {url}", target.username)),
521
        )
522
        .await;
523
    }
19bb31ff Andy McCall ↶ 524
    match f.action.as_str() {
525
        "ban" => accounts::set_banned(&app.db, target.id, true).await.map_err(e)?,
526
        "unban" => accounts::set_banned(&app.db, target.id, false).await.map_err(e)?,
6a178055 Andy McCall ↶ 527
        "disable" => accounts::set_disabled(&app.db, target.id, true).await.map_err(e)?,
528
        "enable" => accounts::set_disabled(&app.db, target.id, false).await.map_err(e)?,
19bb31ff Andy McCall ↶ 529
        "promote" => accounts::set_admin(&app.db, target.id, true).await.map_err(e)?,
530
        "demote" => accounts::set_admin(&app.db, target.id, false).await.map_err(e)?,
531
        _ => return Err(Error::bad(shell, "unknown action")),
532
    }
533
    accounts::audit(&app.db, Some(&me), &format!("admin.account.{}", f.action), &target.username, "").await.ok();
737de0fa Andy McCall ↶ 534
    admin_page(&app, shell, AdminSection::Accounts, Some(format!("{}: {}.", target.username, f.action))).await
19bb31ff Andy McCall ↶ 535
}
8fb35df7 Andy McCall ↶ 536
537
// ------------------------------------------------------------------ storage
538
539
/// One repository's footprint on disk.
540
pub struct StorageRow {
541
    pub slug: String,
166f6e9b Andy McCall 542
    /// A drop (#279), labelled as one.
543
    pub drop: bool,
8fb35df7 Andy McCall ↶ 544
    pub bytes: u64,
545
    /// Objects not yet in a pack.
546
    pub loose: usize,
547
    pub packs: usize,
548
}
549
550
#[derive(Template)]
551
#[template(path = "admin_storage.html")]
552
pub struct StoragePage {
553
    shell: Shell,
554
    rows: Vec<StorageRow>,
555
    total: u64,
556
    notice: Option<String>,
557
    error: Option<String>,
558
}
559
560
/// Size, loose objects and packs under a repository directory.
561
fn footprint(dir: &std::path::Path) -> (u64, usize, usize) {
562
    let (mut bytes, mut loose, mut packs) = (0u64, 0usize, 0usize);
563
    let mut stack = vec![dir.to_path_buf()];
564
    while let Some(d) = stack.pop() {
565
        let Ok(rd) = std::fs::read_dir(&d) else { continue };
566
        for e in rd.flatten() {
567
            let Ok(ft) = e.file_type() else { continue };
568
            if ft.is_dir() {
569
                stack.push(e.path());
570
            } else if ft.is_file() {
571
                bytes += e.metadata().map(|m| m.len()).unwrap_or(0);
572
                let p = e.path();
573
                if p.components().any(|c| c.as_os_str() == "loose") {
574
                    loose += 1;
575
                } else if p.extension().is_some_and(|x| x == "pack") {
576
                    packs += 1;
577
                }
578
            }
579
        }
580
    }
581
    (bytes, loose, packs)
582
}
583
584
async fn storage_page(app: &Shared, shell: Shell, notice: Option<String>, error: Option<String>) -> Result<Page<StoragePage>, Error> {
166f6e9b Andy McCall 585
    // Drops too, labelled as such (#279).
586
    let repos = crate::db::repos::list_with_drops(&app.db).await.map_err(|e| Error::from_db(shell.clone(), e))?;
587
    let dirs: Vec<(String, bool, std::path::PathBuf)> = repos
588
        .iter()
589
        .filter_map(|r| crate::repos::RepoName::new(&r.owner, &r.name).map(|n| (n.to_string(), r.is_drop(), app.repos.dir(&n))))
590
        .collect();
8fb35df7 Andy McCall ↶ 591
    let mut rows = tokio::task::spawn_blocking(move || {
592
        dirs.into_iter()
166f6e9b Andy McCall 593
            .map(|(slug, drop, dir)| {
8fb35df7 Andy McCall ↶ 594
                let (bytes, loose, packs) = footprint(&dir);
166f6e9b Andy McCall 595
                StorageRow { slug, drop, bytes, loose, packs }
8fb35df7 Andy McCall ↶ 596
            })
597
            .collect::<Vec<_>>()
598
    })
599
    .await
600
    .unwrap_or_default();
601
    rows.sort_by_key(|r| std::cmp::Reverse(r.bytes));
602
    let total = rows.iter().map(|r| r.bytes).sum();
603
    Ok(Page(StoragePage { shell, rows, total, notice, error }))
604
}
605
606
pub async fn storage(State(app): State<Shared>, viewer: Viewer) -> Result<Page<StoragePage>, Error> {
607
    let shell = Shell::new(&app, "/admin/storage", &viewer);
608
    require_admin(&viewer, &shell)?;
609
    storage_page(&app, shell, None, None).await
610
}
611
612
#[derive(Deserialize)]
613
pub struct MaintainForm {
614
    #[serde(rename = "_csrf")]
615
    csrf_token: String,
616
    /// `owner/name`, or `*` for every repository.
617
    repo: String,
618
}
619
620
/// `POST /admin/storage`: `oxy gc` on one repository or all of them, under
621
/// each repository's write lock, with the grace period kept.
622
pub async fn maintain(State(app): State<Shared>, viewer: Viewer, Form(f): Form<MaintainForm>) -> Result<Page<StoragePage>, Error> {
623
    let shell = Shell::new(&app, "/admin/storage", &viewer);
624
    let me = require_admin(&viewer, &shell)?;
625
    csrf(&viewer, &f.csrf_token, &shell)?;
626
    let names: Vec<crate::repos::RepoName> = if f.repo == "*" {
166f6e9b Andy McCall 627
        crate::db::repos::list_with_drops(&app.db)
8fb35df7 Andy McCall ↶ 628
            .await
629
            .map_err(|e| Error::from_db(shell.clone(), e))?
630
            .iter()
631
            .filter_map(|r| crate::repos::RepoName::new(&r.owner, &r.name))
632
            .collect()
633
    } else {
634
        match crate::repos::RepoName::parse(&f.repo) {
635
            Some(n) if app.repos.exists(&n) => vec![n],
636
            _ => return storage_page(&app, shell, None, Some(format!("No repository {}.", f.repo))).await,
637
        }
638
    };
639
    let (mut packed, mut pruned, mut freed, mut failed) = (0usize, 0usize, 0u64, Vec::new());
640
    for name in &names {
641
        match app.repos.write(name, |r, _| r.gc(false).map_err(crate::repos::RepoError::Oxy)).await {
642
            Ok(rep) => {
643
                packed += rep.packed.objects;
644
                pruned += rep.pruned.objects;
645
                freed += rep.pruned.bytes + rep.packed.raw_bytes.saturating_sub(rep.packed.packed_bytes);
646
            }
647
            Err(e) => failed.push(format!("{name}: {e}")),
648
        }
649
    }
650
    let summary = format!(
651
        "Maintained {} repositor{}: {packed} loose objects packed, {pruned} unreachable objects pruned, about {} saved.",
652
        names.len(),
653
        if names.len() == 1 { "y" } else { "ies" },
654
        crate::render::bytes(freed)
655
    );
656
    accounts::audit(&app.db, Some(&me), "admin.gc", &f.repo, &summary).await.ok();
657
    let error = (!failed.is_empty()).then(|| failed.join("; "));
658
    storage_page(&app, shell, Some(summary), error).await
659
}
e71846aa Andy McCall ↶ 660
2a83f530 Andy McCall ↶ 661
fn site_assistants() -> crate::levels::At {
662
    crate::levels::At::site()
e71846aa Andy McCall ↶ 663
}
664
665
/// `POST /admin/assistants`: the site's assistants and prompt (#97).
666
pub async fn assistants(
667
    State(app): State<Shared>,
668
    viewer: Viewer,
669
    Form(f): Form<super::assistants::AssistantForm>,
670
) -> Result<Page<AdminPage>, Error> {
671
    let shell = Shell::new(&app, "/admin", &viewer);
672
    require_admin(&viewer, &shell)?;
673
    csrf(&viewer, &f.csrf_token, &shell)?;
674
    let outcome = super::assistants::apply(&app, &viewer, &site_assistants(), f).await.map_err(|e| Error::from_db(shell.clone(), e))?;
737de0fa Andy McCall ↶ 675
    admin_page(&app, shell, AdminSection::Assistants, Some(outcome.unwrap_or_else(|e| e))).await
e71846aa Andy McCall ↶ 676
}
2a83f530 Andy McCall ↶ 677
678
/// `POST /admin/variables`: the site's Flow variables (#102).
679
pub async fn variables(
680
    State(app): State<Shared>,
681
    viewer: Viewer,
682
    Form(f): Form<super::variables::VariableForm>,
683
) -> Result<Page<AdminPage>, Error> {
684
    let shell = Shell::new(&app, "/admin", &viewer);
685
    require_admin(&viewer, &shell)?;
686
    csrf(&viewer, &f.csrf_token, &shell)?;
687
    let outcome = super::variables::apply(&app, &viewer, &site_assistants(), f).await.map_err(|e| Error::from_db(shell.clone(), e))?;
737de0fa Andy McCall ↶ 688
    admin_page(&app, shell, AdminSection::Variables, Some(outcome.unwrap_or_else(|e| e))).await
2a83f530 Andy McCall ↶ 689
}
4ac01a73 Andy McCall ↶ 690
691
/// `POST /admin/secrets`: the site's Flow secrets (#107).
692
pub async fn secrets(
693
    State(app): State<Shared>,
694
    viewer: Viewer,
695
    Form(f): Form<super::secrets::SecretForm>,
696
) -> Result<Page<AdminPage>, Error> {
697
    let shell = Shell::new(&app, "/admin", &viewer);
698
    require_admin(&viewer, &shell)?;
699
    csrf(&viewer, &f.csrf_token, &shell)?;
700
    let outcome = super::secrets::apply(&app, &viewer, &site_assistants(), f).await.map_err(|e| Error::from_db(shell.clone(), e))?;
737de0fa Andy McCall ↶ 701
    admin_page(&app, shell, AdminSection::Secrets, Some(outcome.unwrap_or_else(|e| e))).await
4ac01a73 Andy McCall ↶ 702
}
83940d53 Andy McCall ↶ 703
704
/// `POST /admin/signin`: a sign-in provider's client id, secret and switch (#106).
705
pub async fn signin(
706
    State(app): State<Shared>,
707
    viewer: Viewer,
708
    Form(f): Form<super::signin::ProviderForm>,
709
) -> Result<Page<AdminPage>, Error> {
710
    let shell = Shell::new(&app, "/admin", &viewer);
711
    require_admin(&viewer, &shell)?;
712
    csrf(&viewer, &f.csrf_token, &shell)?;
713
    let outcome = super::signin::apply(&app, &viewer, f).await.map_err(|e| Error::from_db(shell.clone(), e))?;
737de0fa Andy McCall ↶ 714
    admin_page(&app, shell, AdminSection::SignIn, Some(outcome.unwrap_or_else(|e| e))).await
83940d53 Andy McCall ↶ 715
}
155cab14 Andy McCall ↶ 716
717
/// `POST /admin/assistants/models`: what a provider offers, as JSON (#109).
718
pub async fn assistant_models(
719
    State(app): State<Shared>,
720
    viewer: Viewer,
721
    Form(f): Form<super::assistants::ModelsForm>,
722
) -> Result<axum::Json<serde_json::Value>, Error> {
723
    let shell = Shell::new(&app, "/admin", &viewer);
724
    require_admin(&viewer, &shell)?;
725
    csrf(&viewer, &f.csrf_token, &shell)?;
726
    Ok(axum::Json(super::assistants::models(&app, &site_assistants(), f).await.map_err(|e| Error::from_db(shell.clone(), e))?))
727
}