OXYGENHUB

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

472 of 533 lines run, 55 of 150 functions · run #545 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
    RunnerUpdates,
28
    Insights,
29
    Variables,
30
    Secrets,
31
    Assistants,
32
    Diffusion,
33
    Storage,
34
    Backups,
35
    NewRepositories,
36
    Audit,
37
}
38
39
/// The sidebar: each group's heading and its pages, in order.
40
const ADMIN_GROUPS: &[(&str, &[AdminSection])] = &[
41
    ("Overview", &[AdminSection::Overview]),
42
    ("People", &[AdminSection::Accounts, AdminSection::Registration, AdminSection::Organisations]),
43
    ("Sign-in", &[AdminSection::SignIn, AdminSection::SecurityContact]),
44
    ("Mail", &[AdminSection::Mail]),
45
    (
46
        "Flow",
47
        &[
48
            AdminSection::Runners,
49
            AdminSection::Images,
50
            AdminSection::RunnerSettings,
51
            AdminSection::RunnerUpdates,
52
            AdminSection::Insights,
53
            AdminSection::Variables,
54
            AdminSection::Secrets,
55
        ],
56
    ),
57
    ("Integrations", &[AdminSection::Assistants, AdminSection::Diffusion]),
58
    ("Data", &[AdminSection::NewRepositories, AdminSection::Storage, AdminSection::Backups]),
59
    ("Audit", &[AdminSection::Audit]),
60
];
61
62
/// The admin sidebar's groups, for templates.
63180×
pub fn admin_groups() -> &'static [(&'static str, &'static [AdminSection])] {
64180×
    ADMIN_GROUPS
65180×
}
66
67
impl AdminSection {
68
    /// Its page's address.
693788×
    pub fn href(self) -> &'static str {
703788×
        match self {
71179×
            AdminSection::Overview => "/admin",
72200×
            AdminSection::Accounts => "/admin/accounts",
73198×
            AdminSection::Registration => "/admin/registration",
74197×
            AdminSection::Organisations => "/admin/organisations",
75196×
            AdminSection::SignIn => "/admin/signin",
76194×
            AdminSection::SecurityContact => "/admin/security",
77192×
            AdminSection::Mail => "/admin/mail",
78190×
            AdminSection::Runners => "/admin/runners",
79190×
            AdminSection::Images => "/admin/runners/images",
80190×
            AdminSection::RunnerSettings => "/admin/runners/settings",
81190×
            AdminSection::RunnerUpdates => "/admin/runners/updates",
82190×
            AdminSection::Insights => "/admin/insights",
83190×
            AdminSection::Variables => "/admin/variables",
84188×
            AdminSection::Secrets => "/admin/secrets",
85187×
            AdminSection::Assistants => "/admin/assistants",
86184×
            AdminSection::Diffusion => "/admin/diffusion",
87183×
            AdminSection::Storage => "/admin/storage",
88183×
            AdminSection::Backups => "/admin/backups",
89184×
            AdminSection::NewRepositories => "/admin/repositories",
90183×
            AdminSection::Audit => "/admin/audit",
91
        }
923788×
    }
93
94
    /// The anchor it had on the one-page admin, so a kept `/admin#mail`
95
    /// link opens its page (hub.js).
963180×
    pub fn slug(self) -> &'static str {
973180×
        match self {
98159×
            AdminSection::Overview => "overview",
99159×
            AdminSection::Accounts => "accounts",
100159×
            AdminSection::Registration => "registration",
101159×
            AdminSection::Organisations => "organisations",
102159×
            AdminSection::SignIn => "signin",
103159×
            AdminSection::SecurityContact => "security",
104159×
            AdminSection::Mail => "mail",
105159×
            AdminSection::Runners => "runners",
106159×
            AdminSection::Images => "images",
107159×
            AdminSection::RunnerSettings => "runner-settings",
108159×
            AdminSection::RunnerUpdates => "runner-updates",
109159×
            AdminSection::Insights => "insights",
110159×
            AdminSection::Variables => "variables",
111159×
            AdminSection::Secrets => "secrets",
112159×
            AdminSection::Assistants => "assistants",
113159×
            AdminSection::Diffusion => "diffusion",
114159×
            AdminSection::Storage => "storage",
115159×
            AdminSection::Backups => "backups",
116159×
            AdminSection::NewRepositories => "repositories",
117159×
            AdminSection::Audit => "audit",
118
        }
1193180×
    }
120
1213656×
    pub fn title(self) -> &'static str {
1223656×
        match self {
123159×
            AdminSection::Overview => "Overview",
124190×
            AdminSection::Accounts => "Accounts",
125191×
            AdminSection::Registration => "Registration & invitations",
126181×
            AdminSection::Organisations => "Organisations",
127188×
            AdminSection::SignIn => "Sign in with",
128186×
            AdminSection::SecurityContact => "Security contact",
129193×
            AdminSection::Mail => "Mail",
130180×
            AdminSection::Runners => "Runners",
131180×
            AdminSection::Images => "Images",
132180×
            AdminSection::RunnerSettings => "Runner settings",
133180×
            AdminSection::RunnerUpdates => "Runner updates",
134180×
            AdminSection::Insights => "Insights",
135183×
            AdminSection::Variables => "Variables",
136183×
            AdminSection::Secrets => "Secrets",
137197×
            AdminSection::Assistants => "AI assistants",
138180×
            AdminSection::Diffusion => "Diffusion",
139180×
            AdminSection::Storage => "Storage & maintenance",
140180×
            AdminSection::Backups => "Backups",
141182×
            AdminSection::NewRepositories => "New repositories",
142183×
            AdminSection::Audit => "Audit log",
143
        }
1443656×
    }
145
146
    /// The section a page of admin.html shows, by its address.
14720×
    fn of_path(path: &str) -> Option<AdminSection> {
148209×
        ADMIN_GROUPS.iter().flat_map(|(_, pages)| pages.iter().copied()).find(|s| s.href() == path)
14920×
    }
150
}
151
152
#[derive(Template)]
153
#[template(path = "admin.html")]
154
pub struct AdminPage {
155
    shell: Shell,
156
    /// The page shown (oxygenhub#288).
157
    section: AdminSection,
158
    accounts: Vec<accounts::Account>,
159
    orgs: Vec<accounts::Organisation>,
160
    mode: String,
161
    invitations: Vec<(String, Option<i64>, i64)>,
162
    /// New repositories' default bookmark across the site (#371); empty, `main`.
163
    site_bookmark: String,
164
    audit: Vec<accounts::AuditRow>,
165
    repos: usize,
166
    notice: Option<String>,
167
    /// Mail settings (#101): the SMTP URL with its password masked, the
168
    /// From address, whether mail is on, and the administrator's own
169
    /// address for a test message.
170
    mail_smtp: String,
171
    mail_from: String,
172
    mail_enabled: bool,
173
    /// The saved mail settings as the panel shows them (oxygenhub#181).
174
    mail: crate::mail::Choice,
175
    my_email: String,
176
    /// The audit log's next page: rows older than this id (#110).
177
    audit_older: Option<i64>,
178
    /// The site's AI assistants and prompt (#97).
179
    assistants: super::assistants::Panel,
180
    /// The site's Flow variables (#102).
181
    variables: super::variables::Panel,
182
    /// The site's Flow secrets (#107).
183
    secrets: super::secrets::Panel,
184
    /// Sign-in providers (#106).
185
    providers: Vec<super::signin::ProviderView>,
186
    /// The security.txt contact and policy (oxygenhub#224).
187
    security_contact: String,
188
    security_policy: String,
189
    /// The /security-contact page's address and text (oxygenhub#230).
190
    security_email: String,
191
    security_page: String,
192
    security_default: &'static str,
193
}
194
195114×
pub(super) fn require_admin(viewer: &Viewer, shell: &Shell) -> Result<accounts::Account, Error> {
196114×
    match viewer.account() {
197108×
        Some(a) if a.is_admin => Ok(a.clone()),
1986×
        _ => Err(Error::not_found(shell.clone())),
199
    }
200114×
}
201
20257×
pub(super) async fn admin_page(
20357×
    app: &Shared,
20457×
    shell: Shell,
20557×
    section: AdminSection,
20657×
    notice: Option<String>,
20757×
) -> Result<Page<AdminPage>, Error> {
20857×
    admin_page_at(app, shell, section, notice, None).await
20957×
}
210
211
/// The audit log shows this many rows a page.
212
const AUDIT_PAGE: i64 = 50;
213
21498×
async fn admin_page_at(
21598×
    app: &Shared,
21698×
    shell: Shell,
21798×
    section: AdminSection,
21898×
    notice: Option<String>,
21998×
    audit_before: Option<i64>,
22098×
) -> Result<Page<AdminPage>, Error> {
22198×
    let e = |e| Error::from_db(shell.clone(), e);
22298×
    let accounts_list = accounts::accounts(&app.db).await.map_err(e)?;
22398×
    let orgs = accounts::orgs(&app.db).await.map_err(e)?;
22498×
    let mode = super::session::registration_mode(app).await;
22598×
    let invitations = accounts::invitations(&app.db).await.map_err(e)?;
22698×
    let site_bookmark =
22798×
        crate::repo_setup::bookmark_setting(&app.db, crate::repo_setup::BookmarkScope::Site).await.map_err(e)?.unwrap_or_default();
22898×
    let mut audit = accounts::audit_page(&app.db, audit_before, AUDIT_PAGE + 1).await.map_err(e)?;
22998×
    let audit_older = if audit.len() as i64 > AUDIT_PAGE {
23012×
        audit.truncate(AUDIT_PAGE as usize);
23112×
        audit.last().map(|r| r.id)
232
    } else {
23386×
        None
234
    };
23598×
    let repos = crate::db::repos::list(&app.db).await.map_err(e)?.len();
23698×
    let mail = crate::mail::settings(app);
23798×
    let my_email = match shell.user.as_deref() {
23898×
        Some(u) => accounts::account_by_name(&app.db, u).await.map_err(e)?.map(|a| a.email).unwrap_or_default(),
2390×
        None => String::new(),
240
    };
24198×
    let assistants = super::assistants::panel(app, &site_assistants()).await.map_err(e)?;
24298×
    let variables = super::variables::panel(app, &site_assistants()).await.map_err(e)?;
24398×
    let secrets = super::secrets::panel(app, &site_assistants()).await.map_err(e)?;
24498×
    let providers = super::signin::provider_views(app).await.map_err(e)?;
24598×
    let (security_contact, security_policy) = super::security_txt::settings(app).await;
24698×
    let (security_email, security_page) = super::security_txt::page_settings(app).await;
247
    Ok(Page(AdminPage {
24898×
        section,
24998×
        providers,
25098×
        security_contact,
25198×
        security_policy,
25298×
        security_email,
25398×
        security_page,
254
        security_default: super::security_txt::DEFAULT_PAGE,
25598×
        assistants,
25698×
        variables,
25798×
        secrets,
25898×
        shell,
25998×
        accounts: accounts_list,
26098×
        orgs,
26198×
        mode,
26298×
        site_bookmark,
26398×
        invitations,
26498×
        audit,
26598×
        repos,
26698×
        notice,
26798×
        mail: crate::mail::describe(&mail.smtp),
26898×
        mail_smtp: crate::mail::masked(&mail.smtp),
26998×
        mail_from: mail.from.clone(),
27098×
        mail_enabled: !mail.smtp.is_empty() && !mail.from.is_empty(),
27198×
        my_email,
27298×
        audit_older,
273
    }))
27498×
}
275
276
impl AdminPage {
27713×
    fn mail_services(&self) -> &'static [(&'static str, &'static str)] {
27813×
        crate::mail::SERVICES
27913×
    }
280
}
281
282
#[derive(Deserialize)]
283
pub struct MailForm {
284
    #[serde(rename = "_csrf")]
285
    csrf_token: String,
286
    #[serde(default)]
287
    smtp: String,
288
    #[serde(default)]
289
    from: String,
290
    /// The panel's service (oxygenhub#181); absent, `smtp` is the URL as
291
    /// before.
292
    #[serde(default)]
293
    service: String,
294
    #[serde(default)]
295
    region: String,
296
    #[serde(default)]
297
    user: String,
298
    #[serde(default)]
299
    secret: String,
300
    #[serde(default)]
301
    host: String,
302
    #[serde(default)]
303
    port: String,
304
    #[serde(default)]
305
    security: String,
306
}
307
308
/// `POST /admin/mail`: the SMTP URL and From address (#101). The URL
309
/// comes back masked, so a masked value left as it was keeps the stored
310
/// one; an empty URL turns mail off.
31111×
pub async fn set_mail(State(app): State<Shared>, viewer: Viewer, Form(f): Form<MailForm>) -> Result<Page<AdminPage>, Error> {
31211×
    let shell = Shell::new(&app, "/admin", &viewer);
31311×
    let me = require_admin(&viewer, &shell)?;
31410×
    csrf(&viewer, &f.csrf_token, &shell)?;
31510×
    let current = crate::mail::settings(&app);
316
    // A service the panel knows: it makes the SMTP URL (oxygenhub#181).
31710×
    let smtp = match f.service.as_str() {
31810×
        "off" => String::new(),
3199×
        "" | "url" => {
3205×
            let smtp = f.smtp.trim();
3215×
            if !current.smtp.is_empty() && smtp == crate::mail::masked(&current.smtp) {
3221×
                current.smtp.clone()
323
            } else {
3244×
                smtp.to_string()
325
            }
326
        }
327
        _ => {
3284×
            let asked = crate::mail::Asked {
3294×
                service: f.service.clone(),
3304×
                region: f.region.clone(),
3314×
                user: f.user.clone(),
3324×
                secret: f.secret.trim().to_string(),
3334×
                host: f.host.clone(),
3344×
                port: f.port.clone(),
3354×
                security: f.security.clone(),
3364×
            };
3374×
            match crate::mail::compose(&asked, &current.smtp) {
3383×
                Ok(url) => url,
3391×
                Err(why) => return admin_page(&app, shell, AdminSection::Mail, Some(why)).await,
340
            }
341
        }
342
    };
3439×
    let from = f.from.trim().to_string();
3449×
    if !smtp.is_empty() && !(smtp.starts_with("smtp://") || smtp.starts_with("smtps://") || smtp.starts_with("file://")) {
3451×
        return admin_page(
3461×
            &app,
3471×
            shell,
3481×
            AdminSection::Mail,
3491×
            Some("The SMTP URL starts with smtp:// or smtps:// (file:///dir writes messages to a directory, for testing).".into()),
3501×
        )
3511×
        .await;
3528×
    }
3538×
    if !smtp.is_empty() && from.is_empty() {
3540×
        return admin_page(&app, shell, AdminSection::Mail, Some("A From address is needed: OxygenHub <[email protected]>.".into()))
3550×
            .await;
3568×
    }
3578×
    crate::mail::save(&app, &smtp, &from).await.map_err(|e| Error::from_db(shell.clone(), e))?;
3588×
    accounts::audit(&app.db, Some(&me), "admin.mail", if smtp.is_empty() { "off" } else { "on" }, &from).await.ok();
3598×
    admin_page(&app, shell, AdminSection::Mail, Some(if smtp.is_empty() { "Mail is off.".into() } else { "Mail settings saved.".into() }))
3608×
        .await
36111×
}
362
363
/// `POST /admin/mail/test`: a message to the administrator's own address.
3641×
pub async fn test_mail(
3651×
    State(app): State<Shared>,
3661×
    viewer: Viewer,
3671×
    Form(f): Form<super::session::CsrfForm>,
3681×
) -> Result<Page<AdminPage>, Error> {
3691×
    let shell = Shell::new(&app, "/admin", &viewer);
3701×
    let me = require_admin(&viewer, &shell)?;
3711×
    csrf(&viewer, &f.csrf, &shell)?;
3721×
    if !crate::mail::enabled(&app) {
3730×
        return admin_page(&app, shell, AdminSection::Mail, Some("Mail is off; save an SMTP URL and a From address first.".into())).await;
3741×
    }
3751×
    if me.email.is_empty() {
3760×
        return admin_page(&app, shell, AdminSection::Mail, Some("Your account has no email address to send the test to.".into())).await;
3771×
    }
3781×
    let base = app.config.base_url().to_string();
3791×
    let notice = match crate::mail::send_letter(
3801×
        &app,
3811×
        me.name(),
3821×
        &me.email,
3831×
        "🧪 Test message",
3841×
        "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.",
3851×
        Some(crate::mail::Action { label: "Open the hub", url: &base }),
3861×
        None,
387
    )
3881×
    .await
389
    {
3901×
        Ok(()) => format!("Sent a test message to {}.", me.email),
3910×
        Err(e) => format!("Sending failed: {e}"),
392
    };
3931×
    admin_page(&app, shell, AdminSection::Mail, Some(notice)).await
3941×
}
395
396
#[derive(Deserialize)]
397
pub struct IndexQuery {
398
    audit_before: Option<i64>,
399
}
400
40121×
pub async fn index(
40221×
    State(app): State<Shared>,
40321×
    viewer: Viewer,
40421×
    axum::extract::Query(q): axum::extract::Query<IndexQuery>,
40521×
) -> Result<Page<AdminPage>, Error> {
40621×
    let shell = Shell::new(&app, "/admin", &viewer);
40721×
    require_admin(&viewer, &shell)?;
40821×
    admin_page_at(&app, shell, AdminSection::Overview, None, q.audit_before).await
40921×
}
410
411
/// `GET /admin/{page}` for the sections admin.html shows (oxygenhub#288):
412
/// accounts, registration, organisations, sign-in, the security contact,
413
/// mail, variables, secrets, AI assistants and the audit log.
41422×
pub async fn section(
41522×
    State(app): State<Shared>,
41622×
    viewer: Viewer,
41722×
    uri: axum::http::Uri,
41822×
    axum::extract::Query(q): axum::extract::Query<IndexQuery>,
41922×
) -> Result<Page<AdminPage>, Error> {
42022×
    let shell = Shell::new(&app, uri.path(), &viewer);
42122×
    require_admin(&viewer, &shell)?;
42220×
    let section = AdminSection::of_path(uri.path()).ok_or_else(|| Error::not_found(shell.clone()))?;
42320×
    admin_page_at(&app, shell, section, None, q.audit_before).await
42422×
}
425
426
#[derive(Deserialize)]
427
pub struct ModeForm {
428
    #[serde(rename = "_csrf")]
429
    csrf_token: String,
430
    mode: String,
431
}
432
4330×
pub async fn set_mode(State(app): State<Shared>, viewer: Viewer, Form(f): Form<ModeForm>) -> Result<Page<AdminPage>, Error> {
4340×
    let shell = Shell::new(&app, "/admin", &viewer);
4350×
    let me = require_admin(&viewer, &shell)?;
4360×
    csrf(&viewer, &f.csrf_token, &shell)?;
4370×
    if !matches!(f.mode.as_str(), "open" | "invite" | "closed") {
4380×
        return Err(Error::bad(shell, "mode is open, invite or closed"));
4390×
    }
4400×
    accounts::set_setting(&app.db, "registration_mode", &f.mode).await.map_err(|e| Error::from_db(shell.clone(), e))?;
4410×
    accounts::audit(&app.db, Some(&me), "admin.registration", &f.mode, "").await.ok();
4420×
    admin_page(&app, shell, AdminSection::Registration, Some(format!("Registration is {}.", f.mode))).await
4430×
}
444
445
#[derive(Deserialize)]
446
pub struct BookmarkForm {
447
    #[serde(rename = "_csrf")]
448
    csrf_token: String,
449
    #[serde(default)]
450
    default_bookmark: String,
451
}
452
453
/// `POST /admin/repositories` (oxygenhub#371): the default bookmark of new
454
/// repositories whose owner has not chosen one; empty, `main`.
4551×
pub async fn set_bookmark(State(app): State<Shared>, viewer: Viewer, Form(f): Form<BookmarkForm>) -> Result<Page<AdminPage>, Error> {
4561×
    let shell = Shell::new(&app, "/admin", &viewer);
4571×
    let me = require_admin(&viewer, &shell)?;
4581×
    csrf(&viewer, &f.csrf_token, &shell)?;
4591×
    let scope = crate::repo_setup::BookmarkScope::Site;
4601×
    if let Err(m) = crate::repo_setup::set_bookmark_setting(&app.db, scope, &f.default_bookmark).await {
4610×
        return Err(Error::bad(shell, &m));
4621×
    }
4631×
    let b = f.default_bookmark.trim();
4641×
    accounts::audit(&app.db, Some(&me), "admin.default_bookmark", b, "").await.ok();
4651×
    let b = if b.is_empty() { crate::repo_setup::MAIN } else { b };
4661×
    admin_page(
4671×
        &app,
4681×
        shell,
4691×
        AdminSection::NewRepositories,
4701×
        Some(format!("New repositories start on {b} unless their owner chooses another.")),
4711×
    )
4721×
    .await
4731×
}
474
47510×
pub async fn invite(State(app): State<Shared>, viewer: Viewer, Form(f): Form<super::session::CsrfForm>) -> Result<Page<AdminPage>, Error> {
47610×
    let shell = Shell::new(&app, "/admin", &viewer);
47710×
    let me = require_admin(&viewer, &shell)?;
47810×
    csrf(&viewer, &f.csrf, &shell)?;
47910×
    let code = auth::random_hex(12);
48010×
    accounts::create_invitation(&app.db, &code, Some(me.id)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
48110×
    accounts::audit(&app.db, Some(&me), "admin.invite", &code, "").await.ok();
48210×
    admin_page(
48310×
        &app,
48410×
        shell,
48510×
        AdminSection::Registration,
48610×
        Some(format!("Invitation created: {}/register?invite={code}", app.config.base_url())),
48710×
    )
48810×
    .await
48910×
}
490
491
#[derive(Deserialize)]
492
pub struct AccountForm {
493
    #[serde(rename = "_csrf")]
494
    csrf_token: String,
495
    username: String,
496
    action: String,
497
    /// For delete: the username typed again.
498
    #[serde(default)]
499
    confirm: String,
500
}
501
5028×
pub async fn account(State(app): State<Shared>, viewer: Viewer, Form(f): Form<AccountForm>) -> Result<Page<AdminPage>, Error> {
5038×
    let shell = Shell::new(&app, "/admin", &viewer);
5048×
    let me = require_admin(&viewer, &shell)?;
5058×
    csrf(&viewer, &f.csrf_token, &shell)?;
5068×
    let Some(target) = accounts::account_by_name(&app.db, f.username.trim()).await.map_err(|e| Error::from_db(shell.clone(), e))? else {
5070×
        return admin_page(&app, shell, AdminSection::Accounts, Some("No such account.".into())).await;
508
    };
5098×
    if target.id == me.id && matches!(f.action.as_str(), "ban" | "demote" | "disable" | "delete") {
5102×
        return admin_page(&app, shell, AdminSection::Accounts, Some("Not on yourself.".into())).await;
5116×
    }
5126×
    let e = |e| Error::from_db(shell.clone(), e);
513
    // The hub must keep an admin who can sign in (oxygenhub#81).
5146×
    if target.is_admin && matches!(f.action.as_str(), "disable" | "delete") {
5150×
        let others =
5160×
            accounts::accounts(&app.db).await.map_err(e)?.into_iter().filter(|a| a.is_admin && !a.blocked() && a.id != target.id).count();
5170×
        if others == 0 {
5180×
            return admin_page(
5190×
                &app,
5200×
                shell,
5210×
                AdminSection::Accounts,
5220×
                Some(format!("{} is the last admin who can sign in.", target.username)),
5230×
            )
5240×
            .await;
5250×
        }
5266×
    }
5276×
    if f.action == "delete" {
5283×
        if f.confirm.trim() != target.username {
5291×
            return admin_page(&app, shell, AdminSection::Accounts, Some(format!("Not deleted: type {} to confirm.", target.username)))
5301×
                .await;
5312×
        }
5322×
        let repos = crate::db::repos::list_by_owner(&app.db, &target.username).await.map_err(e)?;
5332×
        let orgs = accounts::orgs_owned_only_by(&app.db, target.id).await.map_err(e)?;
5342×
        if !repos.is_empty() || !orgs.is_empty() {
5351×
            let mut held: Vec<String> = repos.iter().map(|r| r.slug()).collect();
5361×
            held.extend(orgs.iter().map(|o| format!("organisation {o} (sole owner)")));
5371×
            return admin_page(
5381×
                &app,
5391×
                shell,
5401×
                AdminSection::Accounts,
5411×
                Some(format!("Not deleted: {} still owns {}. Transfer or delete them first.", target.username, held.join(", "))),
5421×
            )
5431×
            .await;
5441×
        }
5451×
        accounts::delete_account(&app.db, target.id, &target.username).await.map_err(e)?;
5461×
        accounts::audit(&app.db, Some(&me), "admin.account.delete", &target.username, "").await.ok();
5471×
        return admin_page(&app, shell, AdminSection::Accounts, Some(format!("{} deleted; the name stays retired.", target.username)))
5481×
            .await;
5493×
    }
5503×
    if f.action == "reset_link" {
5511×
        if target.blocked() {
5520×
            return admin_page(&app, shell, AdminSection::Accounts, Some("A banned or disabled account cannot be reset.".into())).await;
5531×
        }
5541×
        let url = super::recovery::admin_reset_link(&app, &me, &target).await.map_err(e)?;
5551×
        return admin_page(
5561×
            &app,
5571×
            shell,
5581×
            AdminSection::Accounts,
5591×
            Some(format!("Reset link for {} (works once, for 24 hours; shown only now): {url}", target.username)),
5601×
        )
5611×
        .await;
5622×
    }
5632×
    match f.action.as_str() {
5642×
        "ban" => accounts::set_banned(&app.db, target.id, true).await.map_err(e)?,
5652×
        "unban" => accounts::set_banned(&app.db, target.id, false).await.map_err(e)?,
5662×
        "disable" => accounts::set_disabled(&app.db, target.id, true).await.map_err(e)?,
5671×
        "enable" => accounts::set_disabled(&app.db, target.id, false).await.map_err(e)?,
5680×
        "promote" => accounts::set_admin(&app.db, target.id, true).await.map_err(e)?,
5690×
        "demote" => accounts::set_admin(&app.db, target.id, false).await.map_err(e)?,
5700×
        _ => return Err(Error::bad(shell, "unknown action")),
571
    }
5722×
    accounts::audit(&app.db, Some(&me), &format!("admin.account.{}", f.action), &target.username, "").await.ok();
5732×
    admin_page(&app, shell, AdminSection::Accounts, Some(format!("{}: {}.", target.username, f.action))).await
5748×
}
575
576
// ------------------------------------------------------------------ storage
577
578
/// One repository's footprint on disk.
579
pub struct StorageRow {
580
    pub slug: String,
581
    /// A drop (#279), labelled as one.
582
    pub drop: bool,
583
    pub bytes: u64,
584
    /// Objects not yet in a pack.
585
    pub loose: usize,
586
    pub packs: usize,
587
}
588
589
#[derive(Template)]
590
#[template(path = "admin_storage.html")]
591
pub struct StoragePage {
592
    shell: Shell,
593
    rows: Vec<StorageRow>,
594
    total: u64,
595
    notice: Option<String>,
596
    error: Option<String>,
597
}
598
599
/// Size, loose objects and packs under a repository directory.
60012×
fn footprint(dir: &std::path::Path) -> (u64, usize, usize) {
60112×
    let (mut bytes, mut loose, mut packs) = (0u64, 0usize, 0usize);
60212×
    let mut stack = vec![dir.to_path_buf()];
60351×
    while let Some(d) = stack.pop() {
60439×
        let Ok(rd) = std::fs::read_dir(&d) else { continue };
60566×
        for e in rd.flatten() {
60666×
            let Ok(ft) = e.file_type() else { continue };
60766×
            if ft.is_dir() {
60827×
                stack.push(e.path());
60939×
            } else if ft.is_file() {
61039×
                bytes += e.metadata().map(|m| m.len()).unwrap_or(0);
61139×
                let p = e.path();
612339×
                if p.components().any(|c| c.as_os_str() == "loose") {
61315×
                    loose += 1;
61424×
                } else if p.extension().is_some_and(|x| x == "pack") {
6152×
                    packs += 1;
61622×
                }
6170×
            }
618
        }
619
    }
62012×
    (bytes, loose, packs)
62112×
}
622
6234×
async fn storage_page(app: &Shared, shell: Shell, notice: Option<String>, error: Option<String>) -> Result<Page<StoragePage>, Error> {
624
    // Drops too, labelled as such (#279).
6254×
    let repos = crate::db::repos::list_with_drops(&app.db).await.map_err(|e| Error::from_db(shell.clone(), e))?;
6264×
    let dirs: Vec<(String, bool, std::path::PathBuf)> = repos
6274×
        .iter()
62812×
        .filter_map(|r| crate::repos::RepoName::new(&r.owner, &r.name).map(|n| (n.to_string(), r.is_drop(), app.repos.dir(&n))))
6294×
        .collect();
6304×
    let mut rows = tokio::task::spawn_blocking(move || {
6314×
        dirs.into_iter()
63212×
            .map(|(slug, drop, dir)| {
63312×
                let (bytes, loose, packs) = footprint(&dir);
63412×
                StorageRow { slug, drop, bytes, loose, packs }
63512×
            })
6364×
            .collect::<Vec<_>>()
6374×
    })
6384×
    .await
6394×
    .unwrap_or_default();
64018×
    rows.sort_by_key(|r| std::cmp::Reverse(r.bytes));
6414×
    let total = rows.iter().map(|r| r.bytes).sum();
6424×
    Ok(Page(StoragePage { shell, rows, total, notice, error }))
6434×
}
644
6452×
pub async fn storage(State(app): State<Shared>, viewer: Viewer) -> Result<Page<StoragePage>, Error> {
6462×
    let shell = Shell::new(&app, "/admin/storage", &viewer);
6472×
    require_admin(&viewer, &shell)?;
6482×
    storage_page(&app, shell, None, None).await
6492×
}
650
651
#[derive(Deserialize)]
652
pub struct MaintainForm {
653
    #[serde(rename = "_csrf")]
654
    csrf_token: String,
655
    /// `owner/name`, or `*` for every repository.
656
    repo: String,
657
}
658
659
/// `POST /admin/storage`: `oxy gc` on one repository or all of them, under
660
/// each repository's write lock, with the grace period kept.
6612×
pub async fn maintain(State(app): State<Shared>, viewer: Viewer, Form(f): Form<MaintainForm>) -> Result<Page<StoragePage>, Error> {
6622×
    let shell = Shell::new(&app, "/admin/storage", &viewer);
6632×
    let me = require_admin(&viewer, &shell)?;
6642×
    csrf(&viewer, &f.csrf_token, &shell)?;
6652×
    let names: Vec<crate::repos::RepoName> = if f.repo == "*" {
6660×
        crate::db::repos::list_with_drops(&app.db)
6670×
            .await
6680×
            .map_err(|e| Error::from_db(shell.clone(), e))?
6690×
            .iter()
6700×
            .filter_map(|r| crate::repos::RepoName::new(&r.owner, &r.name))
6710×
            .collect()
672
    } else {
6732×
        match crate::repos::RepoName::parse(&f.repo) {
6742×
            Some(n) if app.repos.exists(&n) => vec![n],
6751×
            _ => return storage_page(&app, shell, None, Some(format!("No repository {}.", f.repo))).await,
676
        }
677
    };
6781×
    let (mut packed, mut pruned, mut freed, mut failed) = (0usize, 0usize, 0u64, Vec::new());
6791×
    for name in &names {
6801×
        match app.repos.write(name, |r, _| r.gc(false).map_err(crate::repos::RepoError::Oxy)).await {
6811×
            Ok(rep) => {
6821×
                packed += rep.packed.objects;
6831×
                pruned += rep.pruned.objects;
6841×
                freed += rep.pruned.bytes + rep.packed.raw_bytes.saturating_sub(rep.packed.packed_bytes);
6851×
            }
6860×
            Err(e) => failed.push(format!("{name}: {e}")),
687
        }
688
    }
6891×
    let summary = format!(
690
        "Maintained {} repositor{}: {packed} loose objects packed, {pruned} unreachable objects pruned, about {} saved.",
6911×
        names.len(),
6921×
        if names.len() == 1 { "y" } else { "ies" },
6931×
        crate::render::bytes(freed)
694
    );
6951×
    accounts::audit(&app.db, Some(&me), "admin.gc", &f.repo, &summary).await.ok();
6961×
    let error = (!failed.is_empty()).then(|| failed.join("; "));
6971×
    storage_page(&app, shell, Some(summary), error).await
6982×
}
699
700313×
fn site_assistants() -> crate::levels::At {
701313×
    crate::levels::At::site()
702313×
}
703
704
/// `POST /admin/assistants`: the site's assistants and prompt (#97).
70515×
pub async fn assistants(
70615×
    State(app): State<Shared>,
70715×
    viewer: Viewer,
70815×
    Form(f): Form<super::assistants::AssistantForm>,
70915×
) -> Result<Page<AdminPage>, Error> {
71015×
    let shell = Shell::new(&app, "/admin", &viewer);
71115×
    require_admin(&viewer, &shell)?;
71214×
    csrf(&viewer, &f.csrf_token, &shell)?;
71314×
    let outcome = super::assistants::apply(&app, &viewer, &site_assistants(), f).await.map_err(|e| Error::from_db(shell.clone(), e))?;
71414×
    admin_page(&app, shell, AdminSection::Assistants, Some(outcome.unwrap_or_else(|e| e))).await
71515×
}
716
717
/// `POST /admin/variables`: the site's Flow variables (#102).
7181×
pub async fn variables(
7191×
    State(app): State<Shared>,
7201×
    viewer: Viewer,
7211×
    Form(f): Form<super::variables::VariableForm>,
7221×
) -> Result<Page<AdminPage>, Error> {
7231×
    let shell = Shell::new(&app, "/admin", &viewer);
7241×
    require_admin(&viewer, &shell)?;
7251×
    csrf(&viewer, &f.csrf_token, &shell)?;
7261×
    let outcome = super::variables::apply(&app, &viewer, &site_assistants(), f).await.map_err(|e| Error::from_db(shell.clone(), e))?;
7271×
    admin_page(&app, shell, AdminSection::Variables, Some(outcome.unwrap_or_else(|e| e))).await
7281×
}
729
730
/// `POST /admin/secrets`: the site's Flow secrets (#107).
7312×
pub async fn secrets(
7322×
    State(app): State<Shared>,
7332×
    viewer: Viewer,
7342×
    Form(f): Form<super::secrets::SecretForm>,
7352×
) -> Result<Page<AdminPage>, Error> {
7362×
    let shell = Shell::new(&app, "/admin", &viewer);
7372×
    require_admin(&viewer, &shell)?;
7382×
    csrf(&viewer, &f.csrf_token, &shell)?;
7392×
    let outcome = super::secrets::apply(&app, &viewer, &site_assistants(), f).await.map_err(|e| Error::from_db(shell.clone(), e))?;
7402×
    admin_page(&app, shell, AdminSection::Secrets, Some(outcome.unwrap_or_else(|e| e))).await
7412×
}
742
743
/// `POST /admin/signin`: a sign-in provider's client id, secret and switch (#106).
7446×
pub async fn signin(
7456×
    State(app): State<Shared>,
7466×
    viewer: Viewer,
7476×
    Form(f): Form<super::signin::ProviderForm>,
7486×
) -> Result<Page<AdminPage>, Error> {
7496×
    let shell = Shell::new(&app, "/admin", &viewer);
7506×
    require_admin(&viewer, &shell)?;
7516×
    csrf(&viewer, &f.csrf_token, &shell)?;
7526×
    let outcome = super::signin::apply(&app, &viewer, f).await.map_err(|e| Error::from_db(shell.clone(), e))?;
7536×
    admin_page(&app, shell, AdminSection::SignIn, Some(outcome.unwrap_or_else(|e| e))).await
7546×
}
755
756
/// `POST /admin/assistants/models`: what a provider offers, as JSON (#109).
7572×
pub async fn assistant_models(
7582×
    State(app): State<Shared>,
7592×
    viewer: Viewer,
7602×
    Form(f): Form<super::assistants::ModelsForm>,
7612×
) -> Result<axum::Json<serde_json::Value>, Error> {
7622×
    let shell = Shell::new(&app, "/admin", &viewer);
7632×
    require_admin(&viewer, &shell)?;
7642×
    csrf(&viewer, &f.csrf_token, &shell)?;
7652×
    Ok(axum::Json(super::assistants::models(&app, &site_assistants(), f).await.map_err(|e| Error::from_db(shell.clone(), e))?))
7662×
}