OXYGENHUB

Coverage / crates/hub/src/web/admin.rs 88.1%

444 of 504 lines run, 53 of 146 functions · run #468 on main · run missed

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
/// 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,
27
    Insights,
28
    Variables,
29
    Secrets,
30
    Assistants,
31
    Diffusion,
32
    Storage,
33
    Backups,
34
    Audit,
35
}
36
37
/// The sidebar: each group's heading and its pages, in order.
38
const ADMIN_GROUPS: &[(&str, &[AdminSection])] = &[
39
    ("Overview", &[AdminSection::Overview]),
40
    ("People", &[AdminSection::Accounts, AdminSection::Registration, AdminSection::Organisations]),
41
    ("Sign-in", &[AdminSection::SignIn, AdminSection::SecurityContact]),
42
    ("Mail", &[AdminSection::Mail]),
43
    (
44
        "Flow",
45
        &[
46
            AdminSection::Runners,
47
            AdminSection::Images,
48
            AdminSection::RunnerSettings,
49
            AdminSection::Insights,
50
            AdminSection::Variables,
51
            AdminSection::Secrets,
52
        ],
53
    ),
54
    ("Integrations", &[AdminSection::Assistants, AdminSection::Diffusion]),
55
    ("Data", &[AdminSection::Storage, AdminSection::Backups]),
56
    ("Audit", &[AdminSection::Audit]),
57
];
58
59
/// The admin sidebar's groups, for templates.
60145×
pub fn admin_groups() -> &'static [(&'static str, &'static [AdminSection])] {
61145×
    ADMIN_GROUPS
62145×
}
63
64
impl AdminSection {
65
    /// Its page's address.
662769×
    pub fn href(self) -> &'static str {
672769×
        match self {
68143×
            AdminSection::Overview => "/admin",
69164×
            AdminSection::Accounts => "/admin/accounts",
70162×
            AdminSection::Registration => "/admin/registration",
71161×
            AdminSection::Organisations => "/admin/organisations",
72160×
            AdminSection::SignIn => "/admin/signin",
73158×
            AdminSection::SecurityContact => "/admin/security",
74156×
            AdminSection::Mail => "/admin/mail",
75154×
            AdminSection::Runners => "/admin/runners",
76154×
            AdminSection::Images => "/admin/runners/images",
77154×
            AdminSection::RunnerSettings => "/admin/runners/settings",
78154×
            AdminSection::Insights => "/admin/insights",
79154×
            AdminSection::Variables => "/admin/variables",
80152×
            AdminSection::Secrets => "/admin/secrets",
81151×
            AdminSection::Assistants => "/admin/assistants",
82148×
            AdminSection::Diffusion => "/admin/diffusion",
83148×
            AdminSection::Storage => "/admin/storage",
84148×
            AdminSection::Backups => "/admin/backups",
85148×
            AdminSection::Audit => "/admin/audit",
86
        }
872769×
    }
88
89
    /// The anchor it had on the one-page admin, so a kept `/admin#mail`
90
    /// link opens its page (hub.js).
912232×
    pub fn slug(self) -> &'static str {
922232×
        match self {
93124×
            AdminSection::Overview => "overview",
94124×
            AdminSection::Accounts => "accounts",
95124×
            AdminSection::Registration => "registration",
96124×
            AdminSection::Organisations => "organisations",
97124×
            AdminSection::SignIn => "signin",
98124×
            AdminSection::SecurityContact => "security",
99124×
            AdminSection::Mail => "mail",
100124×
            AdminSection::Runners => "runners",
101124×
            AdminSection::Images => "images",
102124×
            AdminSection::RunnerSettings => "runner-settings",
103124×
            AdminSection::Insights => "insights",
104124×
            AdminSection::Variables => "variables",
105124×
            AdminSection::Secrets => "secrets",
106124×
            AdminSection::Assistants => "assistants",
107124×
            AdminSection::Diffusion => "diffusion",
108124×
            AdminSection::Storage => "storage",
109124×
            AdminSection::Backups => "backups",
110124×
            AdminSection::Audit => "audit",
111
        }
1122232×
    }
113
1142664×
    pub fn title(self) -> &'static str {
1152664×
        match self {
116124×
            AdminSection::Overview => "Overview",
117155×
            AdminSection::Accounts => "Accounts",
118156×
            AdminSection::Registration => "Registration & invitations",
119146×
            AdminSection::Organisations => "Organisations",
120153×
            AdminSection::SignIn => "Sign in with",
121151×
            AdminSection::SecurityContact => "Security contact",
122158×
            AdminSection::Mail => "Mail",
123145×
            AdminSection::Runners => "Runners",
124145×
            AdminSection::Images => "Images",
125145×
            AdminSection::RunnerSettings => "Runner settings",
126145×
            AdminSection::Insights => "Insights",
127148×
            AdminSection::Variables => "Variables",
128148×
            AdminSection::Secrets => "Secrets",
129162×
            AdminSection::Assistants => "AI assistants",
130145×
            AdminSection::Diffusion => "Diffusion",
131145×
            AdminSection::Storage => "Storage & maintenance",
132145×
            AdminSection::Backups => "Backups",
133148×
            AdminSection::Audit => "Audit log",
134
        }
1352664×
    }
136
137
    /// The section a page of admin.html shows, by its address.
13819×
    fn of_path(path: &str) -> Option<AdminSection> {
139180×
        ADMIN_GROUPS.iter().flat_map(|(_, pages)| pages.iter().copied()).find(|s| s.href() == path)
14019×
    }
141
}
142
143
#[derive(Template)]
144
#[template(path = "admin.html")]
145
pub struct AdminPage {
146
    shell: Shell,
147
    /// The page shown (oxygenhub#288).
148
    section: AdminSection,
149
    accounts: Vec<accounts::Account>,
150
    orgs: Vec<accounts::Organisation>,
151
    mode: String,
152
    invitations: Vec<(String, Option<i64>, i64)>,
153
    audit: Vec<accounts::AuditRow>,
154
    repos: usize,
155
    notice: Option<String>,
156
    /// Mail settings (#101): the SMTP URL with its password masked, the
157
    /// From address, whether mail is on, and the administrator's own
158
    /// address for a test message.
159
    mail_smtp: String,
160
    mail_from: String,
161
    mail_enabled: bool,
162
    /// The saved mail settings as the panel shows them (oxygenhub#181).
163
    mail: crate::mail::Choice,
164
    my_email: String,
165
    /// The audit log's next page: rows older than this id (#110).
166
    audit_older: Option<i64>,
167
    /// The site's AI assistants and prompt (#97).
168
    assistants: super::assistants::Panel,
169
    /// The site's Flow variables (#102).
170
    variables: super::variables::Panel,
171
    /// The site's Flow secrets (#107).
172
    secrets: super::secrets::Panel,
173
    /// Sign-in providers (#106).
174
    providers: Vec<super::signin::ProviderView>,
175
    /// The security.txt contact and policy (oxygenhub#224).
176
    security_contact: String,
177
    security_policy: String,
178
    /// The /security-contact page's address and text (oxygenhub#230).
179
    security_email: String,
180
    security_page: String,
181
    security_default: &'static str,
182
}
183
184112×
pub(super) fn require_admin(viewer: &Viewer, shell: &Shell) -> Result<accounts::Account, Error> {
185112×
    match viewer.account() {
186106×
        Some(a) if a.is_admin => Ok(a.clone()),
1876×
        _ => Err(Error::not_found(shell.clone())),
188
    }
189112×
}
190
19156×
pub(super) async fn admin_page(
19256×
    app: &Shared,
19356×
    shell: Shell,
19456×
    section: AdminSection,
19556×
    notice: Option<String>,
19656×
) -> Result<Page<AdminPage>, Error> {
19756×
    admin_page_at(app, shell, section, notice, None).await
19856×
}
199
200
/// The audit log shows this many rows a page.
201
const AUDIT_PAGE: i64 = 50;
202
20396×
async fn admin_page_at(
20496×
    app: &Shared,
20596×
    shell: Shell,
20696×
    section: AdminSection,
20796×
    notice: Option<String>,
20896×
    audit_before: Option<i64>,
20996×
) -> Result<Page<AdminPage>, Error> {
21096×
    let e = |e| Error::from_db(shell.clone(), e);
21196×
    let accounts_list = accounts::accounts(&app.db).await.map_err(e)?;
21296×
    let orgs = accounts::orgs(&app.db).await.map_err(e)?;
21396×
    let mode = super::session::registration_mode(app).await;
21496×
    let invitations = accounts::invitations(&app.db).await.map_err(e)?;
21596×
    let mut audit = accounts::audit_page(&app.db, audit_before, AUDIT_PAGE + 1).await.map_err(e)?;
21696×
    let audit_older = if audit.len() as i64 > AUDIT_PAGE {
21712×
        audit.truncate(AUDIT_PAGE as usize);
21812×
        audit.last().map(|r| r.id)
219
    } else {
22084×
        None
221
    };
22296×
    let repos = crate::db::repos::list(&app.db).await.map_err(e)?.len();
22396×
    let mail = crate::mail::settings(app);
22496×
    let my_email = match shell.user.as_deref() {
22596×
        Some(u) => accounts::account_by_name(&app.db, u).await.map_err(e)?.map(|a| a.email).unwrap_or_default(),
2260×
        None => String::new(),
227
    };
22896×
    let assistants = super::assistants::panel(app, &site_assistants()).await.map_err(e)?;
22996×
    let variables = super::variables::panel(app, &site_assistants()).await.map_err(e)?;
23096×
    let secrets = super::secrets::panel(app, &site_assistants()).await.map_err(e)?;
23196×
    let providers = super::signin::provider_views(app).await.map_err(e)?;
23296×
    let (security_contact, security_policy) = super::security_txt::settings(app).await;
23396×
    let (security_email, security_page) = super::security_txt::page_settings(app).await;
234
    Ok(Page(AdminPage {
23596×
        section,
23696×
        providers,
23796×
        security_contact,
23896×
        security_policy,
23996×
        security_email,
24096×
        security_page,
241
        security_default: super::security_txt::DEFAULT_PAGE,
24296×
        assistants,
24396×
        variables,
24496×
        secrets,
24596×
        shell,
24696×
        accounts: accounts_list,
24796×
        orgs,
24896×
        mode,
24996×
        invitations,
25096×
        audit,
25196×
        repos,
25296×
        notice,
25396×
        mail: crate::mail::describe(&mail.smtp),
25496×
        mail_smtp: crate::mail::masked(&mail.smtp),
25596×
        mail_from: mail.from.clone(),
25696×
        mail_enabled: !mail.smtp.is_empty() && !mail.from.is_empty(),
25796×
        my_email,
25896×
        audit_older,
259
    }))
26096×
}
261
262
impl AdminPage {
26313×
    fn mail_services(&self) -> &'static [(&'static str, &'static str)] {
26413×
        crate::mail::SERVICES
26513×
    }
266
}
267
268
#[derive(Deserialize)]
269
pub struct MailForm {
270
    #[serde(rename = "_csrf")]
271
    csrf_token: String,
272
    #[serde(default)]
273
    smtp: String,
274
    #[serde(default)]
275
    from: String,
276
    /// The panel's service (oxygenhub#181); absent, `smtp` is the URL as
277
    /// before.
278
    #[serde(default)]
279
    service: String,
280
    #[serde(default)]
281
    region: String,
282
    #[serde(default)]
283
    user: String,
284
    #[serde(default)]
285
    secret: String,
286
    #[serde(default)]
287
    host: String,
288
    #[serde(default)]
289
    port: String,
290
    #[serde(default)]
291
    security: String,
292
}
293
294
/// `POST /admin/mail`: the SMTP URL and From address (#101). The URL
295
/// comes back masked, so a masked value left as it was keeps the stored
296
/// one; an empty URL turns mail off.
29711×
pub async fn set_mail(State(app): State<Shared>, viewer: Viewer, Form(f): Form<MailForm>) -> Result<Page<AdminPage>, Error> {
29811×
    let shell = Shell::new(&app, "/admin", &viewer);
29911×
    let me = require_admin(&viewer, &shell)?;
30010×
    csrf(&viewer, &f.csrf_token, &shell)?;
30110×
    let current = crate::mail::settings(&app);
302
    // A service the panel knows: it makes the SMTP URL (oxygenhub#181).
30310×
    let smtp = match f.service.as_str() {
30410×
        "off" => String::new(),
3059×
        "" | "url" => {
3065×
            let smtp = f.smtp.trim();
3075×
            if !current.smtp.is_empty() && smtp == crate::mail::masked(&current.smtp) {
3081×
                current.smtp.clone()
309
            } else {
3104×
                smtp.to_string()
311
            }
312
        }
313
        _ => {
3144×
            let asked = crate::mail::Asked {
3154×
                service: f.service.clone(),
3164×
                region: f.region.clone(),
3174×
                user: f.user.clone(),
3184×
                secret: f.secret.trim().to_string(),
3194×
                host: f.host.clone(),
3204×
                port: f.port.clone(),
3214×
                security: f.security.clone(),
3224×
            };
3234×
            match crate::mail::compose(&asked, &current.smtp) {
3243×
                Ok(url) => url,
3251×
                Err(why) => return admin_page(&app, shell, AdminSection::Mail, Some(why)).await,
326
            }
327
        }
328
    };
3299×
    let from = f.from.trim().to_string();
3309×
    if !smtp.is_empty() && !(smtp.starts_with("smtp://") || smtp.starts_with("smtps://") || smtp.starts_with("file://")) {
3311×
        return admin_page(
3321×
            &app,
3331×
            shell,
3341×
            AdminSection::Mail,
3351×
            Some("The SMTP URL starts with smtp:// or smtps:// (file:///dir writes messages to a directory, for testing).".into()),
3361×
        )
3371×
        .await;
3388×
    }
3398×
    if !smtp.is_empty() && from.is_empty() {
3400×
        return admin_page(&app, shell, AdminSection::Mail, Some("A From address is needed: OxygenHub <[email protected]>.".into()))
3410×
            .await;
3428×
    }
3438×
    crate::mail::save(&app, &smtp, &from).await.map_err(|e| Error::from_db(shell.clone(), e))?;
3448×
    accounts::audit(&app.db, Some(&me), "admin.mail", if smtp.is_empty() { "off" } else { "on" }, &from).await.ok();
3458×
    admin_page(&app, shell, AdminSection::Mail, Some(if smtp.is_empty() { "Mail is off.".into() } else { "Mail settings saved.".into() }))
3468×
        .await
34711×
}
348
349
/// `POST /admin/mail/test`: a message to the administrator's own address.
3501×
pub async fn test_mail(
3511×
    State(app): State<Shared>,
3521×
    viewer: Viewer,
3531×
    Form(f): Form<super::session::CsrfForm>,
3541×
) -> Result<Page<AdminPage>, Error> {
3551×
    let shell = Shell::new(&app, "/admin", &viewer);
3561×
    let me = require_admin(&viewer, &shell)?;
3571×
    csrf(&viewer, &f.csrf, &shell)?;
3581×
    if !crate::mail::enabled(&app) {
3590×
        return admin_page(&app, shell, AdminSection::Mail, Some("Mail is off; save an SMTP URL and a From address first.".into())).await;
3601×
    }
3611×
    if me.email.is_empty() {
3620×
        return admin_page(&app, shell, AdminSection::Mail, Some("Your account has no email address to send the test to.".into())).await;
3631×
    }
3641×
    let base = app.config.base_url().to_string();
3651×
    let notice = match crate::mail::send_letter(
3661×
        &app,
3671×
        me.name(),
3681×
        &me.email,
3691×
        "🧪 Test message",
3701×
        "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.",
3711×
        Some(crate::mail::Action { label: "Open the hub", url: &base }),
3721×
        None,
373
    )
3741×
    .await
375
    {
3761×
        Ok(()) => format!("Sent a test message to {}.", me.email),
3770×
        Err(e) => format!("Sending failed: {e}"),
378
    };
3791×
    admin_page(&app, shell, AdminSection::Mail, Some(notice)).await
3801×
}
381
382
#[derive(Deserialize)]
383
pub struct IndexQuery {
384
    audit_before: Option<i64>,
385
}
386
38721×
pub async fn index(
38821×
    State(app): State<Shared>,
38921×
    viewer: Viewer,
39021×
    axum::extract::Query(q): axum::extract::Query<IndexQuery>,
39121×
) -> Result<Page<AdminPage>, Error> {
39221×
    let shell = Shell::new(&app, "/admin", &viewer);
39321×
    require_admin(&viewer, &shell)?;
39421×
    admin_page_at(&app, shell, AdminSection::Overview, None, q.audit_before).await
39521×
}
396
397
/// `GET /admin/{page}` for the sections admin.html shows (oxygenhub#288):
398
/// accounts, registration, organisations, sign-in, the security contact,
399
/// mail, variables, secrets, AI assistants and the audit log.
40021×
pub async fn section(
40121×
    State(app): State<Shared>,
40221×
    viewer: Viewer,
40321×
    uri: axum::http::Uri,
40421×
    axum::extract::Query(q): axum::extract::Query<IndexQuery>,
40521×
) -> Result<Page<AdminPage>, Error> {
40621×
    let shell = Shell::new(&app, uri.path(), &viewer);
40721×
    require_admin(&viewer, &shell)?;
40819×
    let section = AdminSection::of_path(uri.path()).ok_or_else(|| Error::not_found(shell.clone()))?;
40919×
    admin_page_at(&app, shell, section, None, q.audit_before).await
41021×
}
411
412
#[derive(Deserialize)]
413
pub struct ModeForm {
414
    #[serde(rename = "_csrf")]
415
    csrf_token: String,
416
    mode: String,
417
}
418
4190×
pub async fn set_mode(State(app): State<Shared>, viewer: Viewer, Form(f): Form<ModeForm>) -> Result<Page<AdminPage>, Error> {
4200×
    let shell = Shell::new(&app, "/admin", &viewer);
4210×
    let me = require_admin(&viewer, &shell)?;
4220×
    csrf(&viewer, &f.csrf_token, &shell)?;
4230×
    if !matches!(f.mode.as_str(), "open" | "invite" | "closed") {
4240×
        return Err(Error::bad(shell, "mode is open, invite or closed"));
4250×
    }
4260×
    accounts::set_setting(&app.db, "registration_mode", &f.mode).await.map_err(|e| Error::from_db(shell.clone(), e))?;
4270×
    accounts::audit(&app.db, Some(&me), "admin.registration", &f.mode, "").await.ok();
4280×
    admin_page(&app, shell, AdminSection::Registration, Some(format!("Registration is {}.", f.mode))).await
4290×
}
430
43110×
pub async fn invite(State(app): State<Shared>, viewer: Viewer, Form(f): Form<super::session::CsrfForm>) -> Result<Page<AdminPage>, Error> {
43210×
    let shell = Shell::new(&app, "/admin", &viewer);
43310×
    let me = require_admin(&viewer, &shell)?;
43410×
    csrf(&viewer, &f.csrf, &shell)?;
43510×
    let code = auth::random_hex(12);
43610×
    accounts::create_invitation(&app.db, &code, Some(me.id)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
43710×
    accounts::audit(&app.db, Some(&me), "admin.invite", &code, "").await.ok();
43810×
    admin_page(
43910×
        &app,
44010×
        shell,
44110×
        AdminSection::Registration,
44210×
        Some(format!("Invitation created: {}/register?invite={code}", app.config.base_url())),
44310×
    )
44410×
    .await
44510×
}
446
447
#[derive(Deserialize)]
448
pub struct AccountForm {
449
    #[serde(rename = "_csrf")]
450
    csrf_token: String,
451
    username: String,
452
    action: String,
453
    /// For delete: the username typed again.
454
    #[serde(default)]
455
    confirm: String,
456
}
457
4588×
pub async fn account(State(app): State<Shared>, viewer: Viewer, Form(f): Form<AccountForm>) -> Result<Page<AdminPage>, Error> {
4598×
    let shell = Shell::new(&app, "/admin", &viewer);
4608×
    let me = require_admin(&viewer, &shell)?;
4618×
    csrf(&viewer, &f.csrf_token, &shell)?;
4628×
    let Some(target) = accounts::account_by_name(&app.db, f.username.trim()).await.map_err(|e| Error::from_db(shell.clone(), e))? else {
4630×
        return admin_page(&app, shell, AdminSection::Accounts, Some("No such account.".into())).await;
464
    };
4658×
    if target.id == me.id && matches!(f.action.as_str(), "ban" | "demote" | "disable" | "delete") {
4662×
        return admin_page(&app, shell, AdminSection::Accounts, Some("Not on yourself.".into())).await;
4676×
    }
4686×
    let e = |e| Error::from_db(shell.clone(), e);
469
    // The hub must keep an admin who can sign in (oxygenhub#81).
4706×
    if target.is_admin && matches!(f.action.as_str(), "disable" | "delete") {
4710×
        let others =
4720×
            accounts::accounts(&app.db).await.map_err(e)?.into_iter().filter(|a| a.is_admin && !a.blocked() && a.id != target.id).count();
4730×
        if others == 0 {
4740×
            return admin_page(
4750×
                &app,
4760×
                shell,
4770×
                AdminSection::Accounts,
4780×
                Some(format!("{} is the last admin who can sign in.", target.username)),
4790×
            )
4800×
            .await;
4810×
        }
4826×
    }
4836×
    if f.action == "delete" {
4843×
        if f.confirm.trim() != target.username {
4851×
            return admin_page(&app, shell, AdminSection::Accounts, Some(format!("Not deleted: type {} to confirm.", target.username)))
4861×
                .await;
4872×
        }
4882×
        let repos = crate::db::repos::list_by_owner(&app.db, &target.username).await.map_err(e)?;
4892×
        let orgs = accounts::orgs_owned_only_by(&app.db, target.id).await.map_err(e)?;
4902×
        if !repos.is_empty() || !orgs.is_empty() {
4911×
            let mut held: Vec<String> = repos.iter().map(|r| r.slug()).collect();
4921×
            held.extend(orgs.iter().map(|o| format!("organisation {o} (sole owner)")));
4931×
            return admin_page(
4941×
                &app,
4951×
                shell,
4961×
                AdminSection::Accounts,
4971×
                Some(format!("Not deleted: {} still owns {}. Transfer or delete them first.", target.username, held.join(", "))),
4981×
            )
4991×
            .await;
5001×
        }
5011×
        accounts::delete_account(&app.db, target.id, &target.username).await.map_err(e)?;
5021×
        accounts::audit(&app.db, Some(&me), "admin.account.delete", &target.username, "").await.ok();
5031×
        return admin_page(&app, shell, AdminSection::Accounts, Some(format!("{} deleted; the name stays retired.", target.username)))
5041×
            .await;
5053×
    }
5063×
    if f.action == "reset_link" {
5071×
        if target.blocked() {
5080×
            return admin_page(&app, shell, AdminSection::Accounts, Some("A banned or disabled account cannot be reset.".into())).await;
5091×
        }
5101×
        let url = super::recovery::admin_reset_link(&app, &me, &target).await.map_err(e)?;
5111×
        return admin_page(
5121×
            &app,
5131×
            shell,
5141×
            AdminSection::Accounts,
5151×
            Some(format!("Reset link for {} (works once, for 24 hours; shown only now): {url}", target.username)),
5161×
        )
5171×
        .await;
5182×
    }
5192×
    match f.action.as_str() {
5202×
        "ban" => accounts::set_banned(&app.db, target.id, true).await.map_err(e)?,
5212×
        "unban" => accounts::set_banned(&app.db, target.id, false).await.map_err(e)?,
5222×
        "disable" => accounts::set_disabled(&app.db, target.id, true).await.map_err(e)?,
5231×
        "enable" => accounts::set_disabled(&app.db, target.id, false).await.map_err(e)?,
5240×
        "promote" => accounts::set_admin(&app.db, target.id, true).await.map_err(e)?,
5250×
        "demote" => accounts::set_admin(&app.db, target.id, false).await.map_err(e)?,
5260×
        _ => return Err(Error::bad(shell, "unknown action")),
527
    }
5282×
    accounts::audit(&app.db, Some(&me), &format!("admin.account.{}", f.action), &target.username, "").await.ok();
5292×
    admin_page(&app, shell, AdminSection::Accounts, Some(format!("{}: {}.", target.username, f.action))).await
5308×
}
531
532
// ------------------------------------------------------------------ storage
533
534
/// One repository's footprint on disk.
535
pub struct StorageRow {
536
    pub slug: String,
537
    /// A drop (#279), labelled as one.
538
    pub drop: bool,
539
    pub bytes: u64,
540
    /// Objects not yet in a pack.
541
    pub loose: usize,
542
    pub packs: usize,
543
}
544
545
#[derive(Template)]
546
#[template(path = "admin_storage.html")]
547
pub struct StoragePage {
548
    shell: Shell,
549
    rows: Vec<StorageRow>,
550
    total: u64,
551
    notice: Option<String>,
552
    error: Option<String>,
553
}
554
555
/// Size, loose objects and packs under a repository directory.
55612×
fn footprint(dir: &std::path::Path) -> (u64, usize, usize) {
55712×
    let (mut bytes, mut loose, mut packs) = (0u64, 0usize, 0usize);
55812×
    let mut stack = vec![dir.to_path_buf()];
55951×
    while let Some(d) = stack.pop() {
56039×
        let Ok(rd) = std::fs::read_dir(&d) else { continue };
56166×
        for e in rd.flatten() {
56266×
            let Ok(ft) = e.file_type() else { continue };
56366×
            if ft.is_dir() {
56427×
                stack.push(e.path());
56539×
            } else if ft.is_file() {
56639×
                bytes += e.metadata().map(|m| m.len()).unwrap_or(0);
56739×
                let p = e.path();
568339×
                if p.components().any(|c| c.as_os_str() == "loose") {
56915×
                    loose += 1;
57024×
                } else if p.extension().is_some_and(|x| x == "pack") {
5712×
                    packs += 1;
57222×
                }
5730×
            }
574
        }
575
    }
57612×
    (bytes, loose, packs)
57712×
}
578
5794×
async fn storage_page(app: &Shared, shell: Shell, notice: Option<String>, error: Option<String>) -> Result<Page<StoragePage>, Error> {
580
    // Drops too, labelled as such (#279).
5814×
    let repos = crate::db::repos::list_with_drops(&app.db).await.map_err(|e| Error::from_db(shell.clone(), e))?;
5824×
    let dirs: Vec<(String, bool, std::path::PathBuf)> = repos
5834×
        .iter()
58412×
        .filter_map(|r| crate::repos::RepoName::new(&r.owner, &r.name).map(|n| (n.to_string(), r.is_drop(), app.repos.dir(&n))))
5854×
        .collect();
5864×
    let mut rows = tokio::task::spawn_blocking(move || {
5874×
        dirs.into_iter()
58812×
            .map(|(slug, drop, dir)| {
58912×
                let (bytes, loose, packs) = footprint(&dir);
59012×
                StorageRow { slug, drop, bytes, loose, packs }
59112×
            })
5924×
            .collect::<Vec<_>>()
5934×
    })
5944×
    .await
5954×
    .unwrap_or_default();
59618×
    rows.sort_by_key(|r| std::cmp::Reverse(r.bytes));
5974×
    let total = rows.iter().map(|r| r.bytes).sum();
5984×
    Ok(Page(StoragePage { shell, rows, total, notice, error }))
5994×
}
600
6012×
pub async fn storage(State(app): State<Shared>, viewer: Viewer) -> Result<Page<StoragePage>, Error> {
6022×
    let shell = Shell::new(&app, "/admin/storage", &viewer);
6032×
    require_admin(&viewer, &shell)?;
6042×
    storage_page(&app, shell, None, None).await
6052×
}
606
607
#[derive(Deserialize)]
608
pub struct MaintainForm {
609
    #[serde(rename = "_csrf")]
610
    csrf_token: String,
611
    /// `owner/name`, or `*` for every repository.
612
    repo: String,
613
}
614
615
/// `POST /admin/storage`: `oxy gc` on one repository or all of them, under
616
/// each repository's write lock, with the grace period kept.
6172×
pub async fn maintain(State(app): State<Shared>, viewer: Viewer, Form(f): Form<MaintainForm>) -> Result<Page<StoragePage>, Error> {
6182×
    let shell = Shell::new(&app, "/admin/storage", &viewer);
6192×
    let me = require_admin(&viewer, &shell)?;
6202×
    csrf(&viewer, &f.csrf_token, &shell)?;
6212×
    let names: Vec<crate::repos::RepoName> = if f.repo == "*" {
6220×
        crate::db::repos::list_with_drops(&app.db)
6230×
            .await
6240×
            .map_err(|e| Error::from_db(shell.clone(), e))?
6250×
            .iter()
6260×
            .filter_map(|r| crate::repos::RepoName::new(&r.owner, &r.name))
6270×
            .collect()
628
    } else {
6292×
        match crate::repos::RepoName::parse(&f.repo) {
6302×
            Some(n) if app.repos.exists(&n) => vec![n],
6311×
            _ => return storage_page(&app, shell, None, Some(format!("No repository {}.", f.repo))).await,
632
        }
633
    };
6341×
    let (mut packed, mut pruned, mut freed, mut failed) = (0usize, 0usize, 0u64, Vec::new());
6351×
    for name in &names {
6361×
        match app.repos.write(name, |r, _| r.gc(false).map_err(crate::repos::RepoError::Oxy)).await {
6371×
            Ok(rep) => {
6381×
                packed += rep.packed.objects;
6391×
                pruned += rep.pruned.objects;
6401×
                freed += rep.pruned.bytes + rep.packed.raw_bytes.saturating_sub(rep.packed.packed_bytes);
6411×
            }
6420×
            Err(e) => failed.push(format!("{name}: {e}")),
643
        }
644
    }
6451×
    let summary = format!(
646
        "Maintained {} repositor{}: {packed} loose objects packed, {pruned} unreachable objects pruned, about {} saved.",
6471×
        names.len(),
6481×
        if names.len() == 1 { "y" } else { "ies" },
6491×
        crate::render::bytes(freed)
650
    );
6511×
    accounts::audit(&app.db, Some(&me), "admin.gc", &f.repo, &summary).await.ok();
6521×
    let error = (!failed.is_empty()).then(|| failed.join("; "));
6531×
    storage_page(&app, shell, Some(summary), error).await
6542×
}
655
656307×
fn site_assistants() -> crate::levels::At {
657307×
    crate::levels::At::site()
658307×
}
659
660
/// `POST /admin/assistants`: the site's assistants and prompt (#97).
66115×
pub async fn assistants(
66215×
    State(app): State<Shared>,
66315×
    viewer: Viewer,
66415×
    Form(f): Form<super::assistants::AssistantForm>,
66515×
) -> Result<Page<AdminPage>, Error> {
66615×
    let shell = Shell::new(&app, "/admin", &viewer);
66715×
    require_admin(&viewer, &shell)?;
66814×
    csrf(&viewer, &f.csrf_token, &shell)?;
66914×
    let outcome = super::assistants::apply(&app, &viewer, &site_assistants(), f).await.map_err(|e| Error::from_db(shell.clone(), e))?;
67014×
    admin_page(&app, shell, AdminSection::Assistants, Some(outcome.unwrap_or_else(|e| e))).await
67115×
}
672
673
/// `POST /admin/variables`: the site's Flow variables (#102).
6741×
pub async fn variables(
6751×
    State(app): State<Shared>,
6761×
    viewer: Viewer,
6771×
    Form(f): Form<super::variables::VariableForm>,
6781×
) -> Result<Page<AdminPage>, Error> {
6791×
    let shell = Shell::new(&app, "/admin", &viewer);
6801×
    require_admin(&viewer, &shell)?;
6811×
    csrf(&viewer, &f.csrf_token, &shell)?;
6821×
    let outcome = super::variables::apply(&app, &viewer, &site_assistants(), f).await.map_err(|e| Error::from_db(shell.clone(), e))?;
6831×
    admin_page(&app, shell, AdminSection::Variables, Some(outcome.unwrap_or_else(|e| e))).await
6841×
}
685
686
/// `POST /admin/secrets`: the site's Flow secrets (#107).
6872×
pub async fn secrets(
6882×
    State(app): State<Shared>,
6892×
    viewer: Viewer,
6902×
    Form(f): Form<super::secrets::SecretForm>,
6912×
) -> Result<Page<AdminPage>, Error> {
6922×
    let shell = Shell::new(&app, "/admin", &viewer);
6932×
    require_admin(&viewer, &shell)?;
6942×
    csrf(&viewer, &f.csrf_token, &shell)?;
6952×
    let outcome = super::secrets::apply(&app, &viewer, &site_assistants(), f).await.map_err(|e| Error::from_db(shell.clone(), e))?;
6962×
    admin_page(&app, shell, AdminSection::Secrets, Some(outcome.unwrap_or_else(|e| e))).await
6972×
}
698
699
/// `POST /admin/signin`: a sign-in provider's client id, secret and switch (#106).
7006×
pub async fn signin(
7016×
    State(app): State<Shared>,
7026×
    viewer: Viewer,
7036×
    Form(f): Form<super::signin::ProviderForm>,
7046×
) -> Result<Page<AdminPage>, Error> {
7056×
    let shell = Shell::new(&app, "/admin", &viewer);
7066×
    require_admin(&viewer, &shell)?;
7076×
    csrf(&viewer, &f.csrf_token, &shell)?;
7086×
    let outcome = super::signin::apply(&app, &viewer, f).await.map_err(|e| Error::from_db(shell.clone(), e))?;
7096×
    admin_page(&app, shell, AdminSection::SignIn, Some(outcome.unwrap_or_else(|e| e))).await
7106×
}
711
712
/// `POST /admin/assistants/models`: what a provider offers, as JSON (#109).
7132×
pub async fn assistant_models(
7142×
    State(app): State<Shared>,
7152×
    viewer: Viewer,
7162×
    Form(f): Form<super::assistants::ModelsForm>,
7172×
) -> Result<axum::Json<serde_json::Value>, Error> {
7182×
    let shell = Shell::new(&app, "/admin", &viewer);
7192×
    require_admin(&viewer, &shell)?;
7202×
    csrf(&viewer, &f.csrf_token, &shell)?;
7212×
    Ok(axum::Json(super::assistants::models(&app, &site_assistants(), f).await.map_err(|e| Error::from_db(shell.clone(), e))?))
7222×
}