//! 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, /// Mail settings (#101): the SMTP URL with its password masked, the /// From address, whether mail is on, and the administrator's own /// address for a test message. mail_smtp: String, mail_from: String, mail_enabled: bool, /// The saved mail settings as the panel shows them (oxygenhub#181). mail: crate::mail::Choice, my_email: String, /// The audit log's next page: rows older than this id (#110). audit_older: Option, /// The site's AI assistants and prompt (#97). assistants: super::assistants::Panel, /// The site's Flow variables (#102). variables: super::variables::Panel, /// The site's Flow secrets (#107). secrets: super::secrets::Panel, /// Sign-in providers (#106). providers: Vec, /// The security.txt contact and policy (oxygenhub#224). security_contact: String, security_policy: String, /// The /security-contact page's address and text (oxygenhub#230). security_email: String, security_page: String, security_default: &'static str, } pub(super) 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())), } } pub(super) async fn admin_page(app: &Shared, shell: Shell, notice: Option) -> Result, Error> { admin_page_at(app, shell, notice, None).await } /// The audit log shows this many rows a page. const AUDIT_PAGE: i64 = 50; async fn admin_page_at(app: &Shared, shell: Shell, notice: Option, audit_before: 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 mut audit = accounts::audit_page(&app.db, audit_before, AUDIT_PAGE + 1).await.map_err(e)?; let audit_older = if audit.len() as i64 > AUDIT_PAGE { audit.truncate(AUDIT_PAGE as usize); audit.last().map(|r| r.id) } else { None }; let repos = crate::db::repos::list(&app.db).await.map_err(e)?.len(); let mail = crate::mail::settings(app); let my_email = match shell.user.as_deref() { Some(u) => accounts::account_by_name(&app.db, u).await.map_err(e)?.map(|a| a.email).unwrap_or_default(), None => String::new(), }; let assistants = super::assistants::panel(app, &site_assistants()).await.map_err(e)?; let variables = super::variables::panel(app, &site_assistants()).await.map_err(e)?; let secrets = super::secrets::panel(app, &site_assistants()).await.map_err(e)?; let providers = super::signin::provider_views(app).await.map_err(e)?; let (security_contact, security_policy) = super::security_txt::settings(app).await; let (security_email, security_page) = super::security_txt::page_settings(app).await; Ok(Page(AdminPage { providers, security_contact, security_policy, security_email, security_page, security_default: super::security_txt::DEFAULT_PAGE, assistants, variables, secrets, shell, accounts: accounts_list, orgs, mode, invitations, audit, repos, notice, mail: crate::mail::describe(&mail.smtp), mail_smtp: crate::mail::masked(&mail.smtp), mail_from: mail.from.clone(), mail_enabled: !mail.smtp.is_empty() && !mail.from.is_empty(), my_email, audit_older, })) } impl AdminPage { fn mail_services(&self) -> &'static [(&'static str, &'static str)] { crate::mail::SERVICES } } #[derive(Deserialize)] pub struct MailForm { #[serde(rename = "_csrf")] csrf_token: String, #[serde(default)] smtp: String, #[serde(default)] from: String, /// The panel's service (oxygenhub#181); absent, `smtp` is the URL as /// before. #[serde(default)] service: String, #[serde(default)] region: String, #[serde(default)] user: String, #[serde(default)] secret: String, #[serde(default)] host: String, #[serde(default)] port: String, #[serde(default)] security: String, } /// `POST /admin/mail`: the SMTP URL and From address (#101). The URL /// comes back masked, so a masked value left as it was keeps the stored /// one; an empty URL turns mail off. pub async fn set_mail(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 current = crate::mail::settings(&app); // A service the panel knows: it makes the SMTP URL (oxygenhub#181). let smtp = match f.service.as_str() { "off" => String::new(), "" | "url" => { let smtp = f.smtp.trim(); if !current.smtp.is_empty() && smtp == crate::mail::masked(¤t.smtp) { current.smtp.clone() } else { smtp.to_string() } } _ => { let asked = crate::mail::Asked { service: f.service.clone(), region: f.region.clone(), user: f.user.clone(), secret: f.secret.trim().to_string(), host: f.host.clone(), port: f.port.clone(), security: f.security.clone(), }; match crate::mail::compose(&asked, ¤t.smtp) { Ok(url) => url, Err(why) => return admin_page(&app, shell, Some(why)).await, } } }; let from = f.from.trim().to_string(); if !smtp.is_empty() && !(smtp.starts_with("smtp://") || smtp.starts_with("smtps://") || smtp.starts_with("file://")) { return admin_page( &app, shell, Some("The SMTP URL starts with smtp:// or smtps:// (file:///dir writes messages to a directory, for testing).".into()), ) .await; } if !smtp.is_empty() && from.is_empty() { return admin_page(&app, shell, Some("A From address is needed: OxygenHub .".into())).await; } crate::mail::save(&app, &smtp, &from).await.map_err(|e| Error::from_db(shell.clone(), e))?; accounts::audit(&app.db, Some(&me), "admin.mail", if smtp.is_empty() { "off" } else { "on" }, &from).await.ok(); admin_page(&app, shell, Some(if smtp.is_empty() { "Mail is off.".into() } else { "Mail settings saved.".into() })).await } /// `POST /admin/mail/test`: a message to the administrator's own address. pub async fn test_mail( 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)?; if !crate::mail::enabled(&app) { return admin_page(&app, shell, Some("Mail is off; save an SMTP URL and a From address first.".into())).await; } if me.email.is_empty() { return admin_page(&app, shell, Some("Your account has no email address to send the test to.".into())).await; } let base = app.config.base_url().to_string(); let notice = match crate::mail::send_letter( &app, me.name(), &me.email, "Test message", "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.", Some(crate::mail::Action { label: "Open the hub", url: &base }), None, ) .await { Ok(()) => format!("Sent a test message to {}.", me.email), Err(e) => format!("Sending failed: {e}"), }; admin_page(&app, shell, Some(notice)).await } #[derive(Deserialize)] pub struct IndexQuery { audit_before: Option, } pub async fn index( State(app): State, viewer: Viewer, axum::extract::Query(q): axum::extract::Query, ) -> Result, Error> { let shell = Shell::new(&app, "/admin", &viewer); require_admin(&viewer, &shell)?; admin_page_at(&app, shell, None, q.audit_before).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 } fn site_assistants() -> crate::levels::At { crate::levels::At::site() } /// `POST /admin/assistants`: the site's assistants and prompt (#97). pub async fn assistants( State(app): State, viewer: Viewer, Form(f): Form, ) -> Result, Error> { let shell = Shell::new(&app, "/admin", &viewer); require_admin(&viewer, &shell)?; csrf(&viewer, &f.csrf_token, &shell)?; let outcome = super::assistants::apply(&app, &viewer, &site_assistants(), f).await.map_err(|e| Error::from_db(shell.clone(), e))?; admin_page(&app, shell, Some(outcome.unwrap_or_else(|e| e))).await } /// `POST /admin/variables`: the site's Flow variables (#102). pub async fn variables( State(app): State, viewer: Viewer, Form(f): Form, ) -> Result, Error> { let shell = Shell::new(&app, "/admin", &viewer); require_admin(&viewer, &shell)?; csrf(&viewer, &f.csrf_token, &shell)?; let outcome = super::variables::apply(&app, &viewer, &site_assistants(), f).await.map_err(|e| Error::from_db(shell.clone(), e))?; admin_page(&app, shell, Some(outcome.unwrap_or_else(|e| e))).await } /// `POST /admin/secrets`: the site's Flow secrets (#107). pub async fn secrets( State(app): State, viewer: Viewer, Form(f): Form, ) -> Result, Error> { let shell = Shell::new(&app, "/admin", &viewer); require_admin(&viewer, &shell)?; csrf(&viewer, &f.csrf_token, &shell)?; let outcome = super::secrets::apply(&app, &viewer, &site_assistants(), f).await.map_err(|e| Error::from_db(shell.clone(), e))?; admin_page(&app, shell, Some(outcome.unwrap_or_else(|e| e))).await } /// `POST /admin/signin`: a sign-in provider's client id, secret and switch (#106). pub async fn signin( State(app): State, viewer: Viewer, Form(f): Form, ) -> Result, Error> { let shell = Shell::new(&app, "/admin", &viewer); require_admin(&viewer, &shell)?; csrf(&viewer, &f.csrf_token, &shell)?; let outcome = super::signin::apply(&app, &viewer, f).await.map_err(|e| Error::from_db(shell.clone(), e))?; admin_page(&app, shell, Some(outcome.unwrap_or_else(|e| e))).await } /// `POST /admin/assistants/models`: what a provider offers, as JSON (#109). pub async fn assistant_models( State(app): State, viewer: Viewer, Form(f): Form, ) -> Result, Error> { let shell = Shell::new(&app, "/admin", &viewer); require_admin(&viewer, &shell)?; csrf(&viewer, &f.csrf_token, &shell)?; Ok(axum::Json(super::assistants::models(&app, &site_assistants(), f).await.map_err(|e| Error::from_db(shell.clone(), e))?)) }