//! Site administration (hub spec ยง6.3 `/admin/*`): accounts, registration //! mode and invitations, the audit log. use super::settings::csrf; use super::{filters, Error, Page, Shell}; use crate::auth::{self, Viewer}; use crate::db::accounts; use crate::Shared; use askama::Template; use axum::extract::{Form, State}; use serde::Deserialize; #[derive(Template)] #[template(path = "admin.html")] pub struct AdminPage { shell: Shell, accounts: Vec, orgs: Vec, mode: String, invitations: Vec<(String, Option, i64)>, audit: Vec, repos: usize, notice: Option, } fn require_admin(viewer: &Viewer, shell: &Shell) -> Result { match viewer.account() { Some(a) if a.is_admin => Ok(a.clone()), _ => Err(Error::not_found(shell.clone())), } } async fn admin_page(app: &Shared, shell: Shell, notice: Option) -> Result, Error> { let e = |e| Error::from_db(shell.clone(), e); let accounts_list = accounts::accounts(&app.db).await.map_err(e)?; let orgs = accounts::orgs(&app.db).await.map_err(e)?; let mode = super::session::registration_mode(app).await; let invitations = accounts::invitations(&app.db).await.map_err(e)?; let audit = accounts::audit_rows(&app.db, 100).await.map_err(e)?; let repos = crate::db::repos::list(&app.db).await.map_err(e)?.len(); Ok(Page(AdminPage { shell, accounts: accounts_list, orgs, mode, invitations, audit, repos, notice })) } pub async fn index(State(app): State, viewer: Viewer) -> Result, Error> { let shell = Shell::new(&app, "/admin", &viewer); require_admin(&viewer, &shell)?; admin_page(&app, shell, None).await } #[derive(Deserialize)] pub struct ModeForm { #[serde(rename = "_csrf")] csrf_token: String, mode: String, } pub async fn set_mode(State(app): State, viewer: Viewer, Form(f): Form) -> Result, Error> { let shell = Shell::new(&app, "/admin", &viewer); let me = require_admin(&viewer, &shell)?; csrf(&viewer, &f.csrf_token, &shell)?; if !matches!(f.mode.as_str(), "open" | "invite" | "closed") { return Err(Error::bad(shell, "mode is open, invite or closed")); } accounts::set_setting(&app.db, "registration_mode", &f.mode).await.map_err(|e| Error::from_db(shell.clone(), e))?; accounts::audit(&app.db, Some(&me), "admin.registration", &f.mode, "").await.ok(); admin_page(&app, shell, Some(format!("Registration is {}.", f.mode))).await } pub async fn invite(State(app): State, viewer: Viewer, Form(f): Form) -> Result, Error> { let shell = Shell::new(&app, "/admin", &viewer); let me = require_admin(&viewer, &shell)?; csrf(&viewer, &f.csrf, &shell)?; let code = auth::random_hex(12); accounts::create_invitation(&app.db, &code, Some(me.id)).await.map_err(|e| Error::from_db(shell.clone(), e))?; accounts::audit(&app.db, Some(&me), "admin.invite", &code, "").await.ok(); admin_page(&app, shell, Some(format!("Invitation created: {}/register?invite={code}", app.config.base_url()))).await } #[derive(Deserialize)] pub struct AccountForm { #[serde(rename = "_csrf")] csrf_token: String, username: String, action: String, /// For delete: the username typed again. #[serde(default)] confirm: String, } pub async fn account(State(app): State, viewer: Viewer, Form(f): Form) -> Result, Error> { let shell = Shell::new(&app, "/admin", &viewer); let me = require_admin(&viewer, &shell)?; csrf(&viewer, &f.csrf_token, &shell)?; let Some(target) = accounts::account_by_name(&app.db, f.username.trim()).await.map_err(|e| Error::from_db(shell.clone(), e))? else { return admin_page(&app, shell, Some("No such account.".into())).await; }; if target.id == me.id && matches!(f.action.as_str(), "ban" | "demote" | "disable" | "delete") { return admin_page(&app, shell, Some("Not on yourself.".into())).await; } let e = |e| Error::from_db(shell.clone(), e); // The hub must keep an admin who can sign in (oxygenhub#81). if target.is_admin && matches!(f.action.as_str(), "disable" | "delete") { let others = accounts::accounts(&app.db).await.map_err(e)?.into_iter().filter(|a| a.is_admin && !a.blocked() && a.id != target.id).count(); if others == 0 { return admin_page(&app, shell, Some(format!("{} is the last admin who can sign in.", target.username))).await; } } if f.action == "delete" { if f.confirm.trim() != target.username { return admin_page(&app, shell, Some(format!("Not deleted: type {} to confirm.", target.username))).await; } let repos = crate::db::repos::list_by_owner(&app.db, &target.username).await.map_err(e)?; let orgs = accounts::orgs_owned_only_by(&app.db, target.id).await.map_err(e)?; if !repos.is_empty() || !orgs.is_empty() { let mut held: Vec = repos.iter().map(|r| r.slug()).collect(); held.extend(orgs.iter().map(|o| format!("organisation {o} (sole owner)"))); return admin_page( &app, shell, Some(format!("Not deleted: {} still owns {}. Transfer or delete them first.", target.username, held.join(", "))), ) .await; } accounts::delete_account(&app.db, target.id, &target.username).await.map_err(e)?; accounts::audit(&app.db, Some(&me), "admin.account.delete", &target.username, "").await.ok(); return admin_page(&app, shell, Some(format!("{} deleted; the name stays retired.", target.username))).await; } if f.action == "reset_link" { if target.blocked() { return admin_page(&app, shell, Some("A banned or disabled account cannot be reset.".into())).await; } let url = super::recovery::admin_reset_link(&app, &me, &target).await.map_err(e)?; return admin_page( &app, shell, Some(format!("Reset link for {} (works once, for 24 hours; shown only now): {url}", target.username)), ) .await; } match f.action.as_str() { "ban" => accounts::set_banned(&app.db, target.id, true).await.map_err(e)?, "unban" => accounts::set_banned(&app.db, target.id, false).await.map_err(e)?, "disable" => accounts::set_disabled(&app.db, target.id, true).await.map_err(e)?, "enable" => accounts::set_disabled(&app.db, target.id, false).await.map_err(e)?, "promote" => accounts::set_admin(&app.db, target.id, true).await.map_err(e)?, "demote" => accounts::set_admin(&app.db, target.id, false).await.map_err(e)?, _ => return Err(Error::bad(shell, "unknown action")), } accounts::audit(&app.db, Some(&me), &format!("admin.account.{}", f.action), &target.username, "").await.ok(); admin_page(&app, shell, Some(format!("{}: {}.", target.username, f.action))).await } // ------------------------------------------------------------------ storage /// One repository's footprint on disk. pub struct StorageRow { pub slug: String, pub bytes: u64, /// Objects not yet in a pack. pub loose: usize, pub packs: usize, } #[derive(Template)] #[template(path = "admin_storage.html")] pub struct StoragePage { shell: Shell, rows: Vec, total: u64, notice: Option, error: Option, } /// Size, loose objects and packs under a repository directory. fn footprint(dir: &std::path::Path) -> (u64, usize, usize) { let (mut bytes, mut loose, mut packs) = (0u64, 0usize, 0usize); let mut stack = vec![dir.to_path_buf()]; while let Some(d) = stack.pop() { let Ok(rd) = std::fs::read_dir(&d) else { continue }; for e in rd.flatten() { let Ok(ft) = e.file_type() else { continue }; if ft.is_dir() { stack.push(e.path()); } else if ft.is_file() { bytes += e.metadata().map(|m| m.len()).unwrap_or(0); let p = e.path(); if p.components().any(|c| c.as_os_str() == "loose") { loose += 1; } else if p.extension().is_some_and(|x| x == "pack") { packs += 1; } } } } (bytes, loose, packs) } async fn storage_page(app: &Shared, shell: Shell, notice: Option, error: Option) -> Result, Error> { let repos = crate::db::repos::list(&app.db).await.map_err(|e| Error::from_db(shell.clone(), e))?; let dirs: Vec<(String, std::path::PathBuf)> = repos.iter().filter_map(|r| crate::repos::RepoName::new(&r.owner, &r.name).map(|n| (n.to_string(), app.repos.dir(&n)))).collect(); let mut rows = tokio::task::spawn_blocking(move || { dirs.into_iter() .map(|(slug, dir)| { let (bytes, loose, packs) = footprint(&dir); StorageRow { slug, bytes, loose, packs } }) .collect::>() }) .await .unwrap_or_default(); rows.sort_by_key(|r| std::cmp::Reverse(r.bytes)); let total = rows.iter().map(|r| r.bytes).sum(); Ok(Page(StoragePage { shell, rows, total, notice, error })) } pub async fn storage(State(app): State, viewer: Viewer) -> Result, Error> { let shell = Shell::new(&app, "/admin/storage", &viewer); require_admin(&viewer, &shell)?; storage_page(&app, shell, None, None).await } #[derive(Deserialize)] pub struct MaintainForm { #[serde(rename = "_csrf")] csrf_token: String, /// `owner/name`, or `*` for every repository. repo: String, } /// `POST /admin/storage`: `oxy gc` on one repository or all of them, under /// each repository's write lock, with the grace period kept. pub async fn maintain(State(app): State, viewer: Viewer, Form(f): Form) -> Result, Error> { let shell = Shell::new(&app, "/admin/storage", &viewer); let me = require_admin(&viewer, &shell)?; csrf(&viewer, &f.csrf_token, &shell)?; let names: Vec = if f.repo == "*" { crate::db::repos::list(&app.db) .await .map_err(|e| Error::from_db(shell.clone(), e))? .iter() .filter_map(|r| crate::repos::RepoName::new(&r.owner, &r.name)) .collect() } else { match crate::repos::RepoName::parse(&f.repo) { Some(n) if app.repos.exists(&n) => vec![n], _ => return storage_page(&app, shell, None, Some(format!("No repository {}.", f.repo))).await, } }; let (mut packed, mut pruned, mut freed, mut failed) = (0usize, 0usize, 0u64, Vec::new()); for name in &names { match app.repos.write(name, |r, _| r.gc(false).map_err(crate::repos::RepoError::Oxy)).await { Ok(rep) => { packed += rep.packed.objects; pruned += rep.pruned.objects; freed += rep.pruned.bytes + rep.packed.raw_bytes.saturating_sub(rep.packed.packed_bytes); } Err(e) => failed.push(format!("{name}: {e}")), } } let summary = format!( "Maintained {} repositor{}: {packed} loose objects packed, {pruned} unreachable objects pruned, about {} saved.", names.len(), if names.len() == 1 { "y" } else { "ies" }, crate::render::bytes(freed) ); accounts::audit(&app.db, Some(&me), "admin.gc", &f.repo, &summary).await.ok(); let error = (!failed.is_empty()).then(|| failed.join("; ")); storage_page(&app, shell, Some(summary), error).await }