OXYGENHUB
# d4792b4a4629 oxygenhub / crates / hub / src / web / admin.rs
11.6 KiB · Rust · last changed Admins disable, enable and delete accounts 1 month ago source blame history raw
19bb31ff Andy McCall ↶ 1
//! Site administration (hub spec §6.3 `/admin/*`): accounts, registration
2
//! mode and invitations, the audit log.
3
4
use super::settings::csrf;
5
use super::{filters, Error, Page, Shell};
6
use crate::auth::{self, Viewer};
7
use crate::db::accounts;
8
use crate::Shared;
9
use askama::Template;
10
use axum::extract::{Form, State};
11
use serde::Deserialize;
12
13
#[derive(Template)]
14
#[template(path = "admin.html")]
15
pub struct AdminPage {
16
    shell: Shell,
17
    accounts: Vec<accounts::Account>,
18
    orgs: Vec<accounts::Organisation>,
19
    mode: String,
20
    invitations: Vec<(String, Option<i64>, i64)>,
21
    audit: Vec<accounts::AuditRow>,
22
    repos: usize,
23
    notice: Option<String>,
24
}
25
26
fn require_admin(viewer: &Viewer, shell: &Shell) -> Result<accounts::Account, Error> {
27
    match viewer.account() {
28
        Some(a) if a.is_admin => Ok(a.clone()),
29
        _ => Err(Error::not_found(shell.clone())),
30
    }
31
}
32
33
async fn admin_page(app: &Shared, shell: Shell, notice: Option<String>) -> Result<Page<AdminPage>, Error> {
34
    let e = |e| Error::from_db(shell.clone(), e);
35
    let accounts_list = accounts::accounts(&app.db).await.map_err(e)?;
36
    let orgs = accounts::orgs(&app.db).await.map_err(e)?;
37
    let mode = super::session::registration_mode(app).await;
38
    let invitations = accounts::invitations(&app.db).await.map_err(e)?;
39
    let audit = accounts::audit_rows(&app.db, 100).await.map_err(e)?;
40
    let repos = crate::db::repos::list(&app.db).await.map_err(e)?.len();
41
    Ok(Page(AdminPage { shell, accounts: accounts_list, orgs, mode, invitations, audit, repos, notice }))
42
}
43
44
pub async fn index(State(app): State<Shared>, viewer: Viewer) -> Result<Page<AdminPage>, Error> {
45
    let shell = Shell::new(&app, "/admin", &viewer);
46
    require_admin(&viewer, &shell)?;
47
    admin_page(&app, shell, None).await
48
}
49
50
#[derive(Deserialize)]
51
pub struct ModeForm {
52
    #[serde(rename = "_csrf")]
53
    csrf_token: String,
54
    mode: String,
55
}
56
57
pub async fn set_mode(State(app): State<Shared>, viewer: Viewer, Form(f): Form<ModeForm>) -> Result<Page<AdminPage>, Error> {
58
    let shell = Shell::new(&app, "/admin", &viewer);
59
    let me = require_admin(&viewer, &shell)?;
60
    csrf(&viewer, &f.csrf_token, &shell)?;
61
    if !matches!(f.mode.as_str(), "open" | "invite" | "closed") {
62
        return Err(Error::bad(shell, "mode is open, invite or closed"));
63
    }
64
    accounts::set_setting(&app.db, "registration_mode", &f.mode).await.map_err(|e| Error::from_db(shell.clone(), e))?;
65
    accounts::audit(&app.db, Some(&me), "admin.registration", &f.mode, "").await.ok();
66
    admin_page(&app, shell, Some(format!("Registration is {}.", f.mode))).await
67
}
68
69
pub async fn invite(State(app): State<Shared>, viewer: Viewer, Form(f): Form<super::session::CsrfForm>) -> Result<Page<AdminPage>, Error> {
70
    let shell = Shell::new(&app, "/admin", &viewer);
71
    let me = require_admin(&viewer, &shell)?;
72
    csrf(&viewer, &f.csrf, &shell)?;
73
    let code = auth::random_hex(12);
74
    accounts::create_invitation(&app.db, &code, Some(me.id)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
75
    accounts::audit(&app.db, Some(&me), "admin.invite", &code, "").await.ok();
76
    admin_page(&app, shell, Some(format!("Invitation created: {}/register?invite={code}", app.config.base_url()))).await
77
}
78
79
#[derive(Deserialize)]
80
pub struct AccountForm {
81
    #[serde(rename = "_csrf")]
82
    csrf_token: String,
83
    username: String,
84
    action: String,
6a178055 Andy McCall ↶ 85
    /// For delete: the username typed again.
86
    #[serde(default)]
87
    confirm: String,
19bb31ff Andy McCall ↶ 88
}
89
90
pub async fn account(State(app): State<Shared>, viewer: Viewer, Form(f): Form<AccountForm>) -> Result<Page<AdminPage>, Error> {
91
    let shell = Shell::new(&app, "/admin", &viewer);
92
    let me = require_admin(&viewer, &shell)?;
93
    csrf(&viewer, &f.csrf_token, &shell)?;
94
    let Some(target) = accounts::account_by_name(&app.db, f.username.trim()).await.map_err(|e| Error::from_db(shell.clone(), e))? else {
95
        return admin_page(&app, shell, Some("No such account.".into())).await;
96
    };
6a178055 Andy McCall ↶ 97
    if target.id == me.id && matches!(f.action.as_str(), "ban" | "demote" | "disable" | "delete") {
19bb31ff Andy McCall ↶ 98
        return admin_page(&app, shell, Some("Not on yourself.".into())).await;
99
    }
100
    let e = |e| Error::from_db(shell.clone(), e);
6a178055 Andy McCall ↶ 101
    // The hub must keep an admin who can sign in (oxygenhub#81).
102
    if target.is_admin && matches!(f.action.as_str(), "disable" | "delete") {
103
        let others =
104
            accounts::accounts(&app.db).await.map_err(e)?.into_iter().filter(|a| a.is_admin && !a.blocked() && a.id != target.id).count();
105
        if others == 0 {
106
            return admin_page(&app, shell, Some(format!("{} is the last admin who can sign in.", target.username))).await;
107
        }
108
    }
109
    if f.action == "delete" {
110
        if f.confirm.trim() != target.username {
111
            return admin_page(&app, shell, Some(format!("Not deleted: type {} to confirm.", target.username))).await;
112
        }
113
        let repos = crate::db::repos::list_by_owner(&app.db, &target.username).await.map_err(e)?;
114
        let orgs = accounts::orgs_owned_only_by(&app.db, target.id).await.map_err(e)?;
115
        if !repos.is_empty() || !orgs.is_empty() {
116
            let mut held: Vec<String> = repos.iter().map(|r| r.slug()).collect();
117
            held.extend(orgs.iter().map(|o| format!("organisation {o} (sole owner)")));
118
            return admin_page(
119
                &app,
120
                shell,
121
                Some(format!("Not deleted: {} still owns {}. Transfer or delete them first.", target.username, held.join(", "))),
122
            )
123
            .await;
124
        }
125
        accounts::delete_account(&app.db, target.id, &target.username).await.map_err(e)?;
126
        accounts::audit(&app.db, Some(&me), "admin.account.delete", &target.username, "").await.ok();
127
        return admin_page(&app, shell, Some(format!("{} deleted; the name stays retired.", target.username))).await;
128
    }
34beb1a7 Andy McCall ↶ 129
    if f.action == "reset_link" {
6a178055 Andy McCall ↶ 130
        if target.blocked() {
131
            return admin_page(&app, shell, Some("A banned or disabled account cannot be reset.".into())).await;
34beb1a7 Andy McCall ↶ 132
        }
133
        let url = super::recovery::admin_reset_link(&app, &me, &target).await.map_err(e)?;
134
        return admin_page(
135
            &app,
136
            shell,
137
            Some(format!("Reset link for {} (works once, for 24 hours; shown only now): {url}", target.username)),
138
        )
139
        .await;
140
    }
19bb31ff Andy McCall ↶ 141
    match f.action.as_str() {
142
        "ban" => accounts::set_banned(&app.db, target.id, true).await.map_err(e)?,
143
        "unban" => accounts::set_banned(&app.db, target.id, false).await.map_err(e)?,
6a178055 Andy McCall ↶ 144
        "disable" => accounts::set_disabled(&app.db, target.id, true).await.map_err(e)?,
145
        "enable" => accounts::set_disabled(&app.db, target.id, false).await.map_err(e)?,
19bb31ff Andy McCall ↶ 146
        "promote" => accounts::set_admin(&app.db, target.id, true).await.map_err(e)?,
147
        "demote" => accounts::set_admin(&app.db, target.id, false).await.map_err(e)?,
148
        _ => return Err(Error::bad(shell, "unknown action")),
149
    }
150
    accounts::audit(&app.db, Some(&me), &format!("admin.account.{}", f.action), &target.username, "").await.ok();
151
    admin_page(&app, shell, Some(format!("{}: {}.", target.username, f.action))).await
152
}
8fb35df7 Andy McCall ↶ 153
154
// ------------------------------------------------------------------ storage
155
156
/// One repository's footprint on disk.
157
pub struct StorageRow {
158
    pub slug: String,
159
    pub bytes: u64,
160
    /// Objects not yet in a pack.
161
    pub loose: usize,
162
    pub packs: usize,
163
}
164
165
#[derive(Template)]
166
#[template(path = "admin_storage.html")]
167
pub struct StoragePage {
168
    shell: Shell,
169
    rows: Vec<StorageRow>,
170
    total: u64,
171
    notice: Option<String>,
172
    error: Option<String>,
173
}
174
175
/// Size, loose objects and packs under a repository directory.
176
fn footprint(dir: &std::path::Path) -> (u64, usize, usize) {
177
    let (mut bytes, mut loose, mut packs) = (0u64, 0usize, 0usize);
178
    let mut stack = vec![dir.to_path_buf()];
179
    while let Some(d) = stack.pop() {
180
        let Ok(rd) = std::fs::read_dir(&d) else { continue };
181
        for e in rd.flatten() {
182
            let Ok(ft) = e.file_type() else { continue };
183
            if ft.is_dir() {
184
                stack.push(e.path());
185
            } else if ft.is_file() {
186
                bytes += e.metadata().map(|m| m.len()).unwrap_or(0);
187
                let p = e.path();
188
                if p.components().any(|c| c.as_os_str() == "loose") {
189
                    loose += 1;
190
                } else if p.extension().is_some_and(|x| x == "pack") {
191
                    packs += 1;
192
                }
193
            }
194
        }
195
    }
196
    (bytes, loose, packs)
197
}
198
199
async fn storage_page(app: &Shared, shell: Shell, notice: Option<String>, error: Option<String>) -> Result<Page<StoragePage>, Error> {
200
    let repos = crate::db::repos::list(&app.db).await.map_err(|e| Error::from_db(shell.clone(), e))?;
201
    let dirs: Vec<(String, std::path::PathBuf)> =
202
        repos.iter().filter_map(|r| crate::repos::RepoName::new(&r.owner, &r.name).map(|n| (n.to_string(), app.repos.dir(&n)))).collect();
203
    let mut rows = tokio::task::spawn_blocking(move || {
204
        dirs.into_iter()
205
            .map(|(slug, dir)| {
206
                let (bytes, loose, packs) = footprint(&dir);
207
                StorageRow { slug, bytes, loose, packs }
208
            })
209
            .collect::<Vec<_>>()
210
    })
211
    .await
212
    .unwrap_or_default();
213
    rows.sort_by_key(|r| std::cmp::Reverse(r.bytes));
214
    let total = rows.iter().map(|r| r.bytes).sum();
215
    Ok(Page(StoragePage { shell, rows, total, notice, error }))
216
}
217
218
pub async fn storage(State(app): State<Shared>, viewer: Viewer) -> Result<Page<StoragePage>, Error> {
219
    let shell = Shell::new(&app, "/admin/storage", &viewer);
220
    require_admin(&viewer, &shell)?;
221
    storage_page(&app, shell, None, None).await
222
}
223
224
#[derive(Deserialize)]
225
pub struct MaintainForm {
226
    #[serde(rename = "_csrf")]
227
    csrf_token: String,
228
    /// `owner/name`, or `*` for every repository.
229
    repo: String,
230
}
231
232
/// `POST /admin/storage`: `oxy gc` on one repository or all of them, under
233
/// each repository's write lock, with the grace period kept.
234
pub async fn maintain(State(app): State<Shared>, viewer: Viewer, Form(f): Form<MaintainForm>) -> Result<Page<StoragePage>, Error> {
235
    let shell = Shell::new(&app, "/admin/storage", &viewer);
236
    let me = require_admin(&viewer, &shell)?;
237
    csrf(&viewer, &f.csrf_token, &shell)?;
238
    let names: Vec<crate::repos::RepoName> = if f.repo == "*" {
239
        crate::db::repos::list(&app.db)
240
            .await
241
            .map_err(|e| Error::from_db(shell.clone(), e))?
242
            .iter()
243
            .filter_map(|r| crate::repos::RepoName::new(&r.owner, &r.name))
244
            .collect()
245
    } else {
246
        match crate::repos::RepoName::parse(&f.repo) {
247
            Some(n) if app.repos.exists(&n) => vec![n],
248
            _ => return storage_page(&app, shell, None, Some(format!("No repository {}.", f.repo))).await,
249
        }
250
    };
251
    let (mut packed, mut pruned, mut freed, mut failed) = (0usize, 0usize, 0u64, Vec::new());
252
    for name in &names {
253
        match app.repos.write(name, |r, _| r.gc(false).map_err(crate::repos::RepoError::Oxy)).await {
254
            Ok(rep) => {
255
                packed += rep.packed.objects;
256
                pruned += rep.pruned.objects;
257
                freed += rep.pruned.bytes + rep.packed.raw_bytes.saturating_sub(rep.packed.packed_bytes);
258
            }
259
            Err(e) => failed.push(format!("{name}: {e}")),
260
        }
261
    }
262
    let summary = format!(
263
        "Maintained {} repositor{}: {packed} loose objects packed, {pruned} unreachable objects pruned, about {} saved.",
264
        names.len(),
265
        if names.len() == 1 { "y" } else { "ies" },
266
        crate::render::bytes(freed)
267
    );
268
    accounts::audit(&app.db, Some(&me), "admin.gc", &f.repo, &summary).await.ok();
269
    let error = (!failed.is_empty()).then(|| failed.join("; "));
270
    storage_page(&app, shell, Some(summary), error).await
271
}