// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. //! The admin dashboard: instance overview, and management of users, repositories, //! groups, sign-up invites, and config overrides. Every route is gated by the //! [`RequireAdmin`] extractor (non-admins get a 404). use std::path::{Path as FsPath, PathBuf}; use axum::Form; use axum::extract::{Path, Query, State}; use axum::http::{HeaderMap, Uri}; use axum::response::{IntoResponse, Redirect, Response}; use axum_extra::extract::cookie::CookieJar; use maud::{Markup, html}; use model::User; use serde::Deserialize; use crate::error::{AppError, AppResult}; use crate::layout::page; use crate::pages::build_chrome; use crate::session::{RequireAdmin, verify_csrf}; use crate::{AppState, setting_keys}; /// The admin dashboard shell: a left tab rail and the active tab's content. /// `show_invites` hides the Invites tab when self-registration is on (invites /// are only useful when registration is otherwise closed). #[allow(clippy::needless_pass_by_value)] // `content` is embedded once. fn admin_shell(active: &str, content: Markup, show_invites: bool) -> Markup { let tab = |href: &str, key: &str, label: &str| { html! { a href=(href) aria-current=[(active == key).then_some("page")] { (label) } } }; html! { div class="settings-layout" { aside class="settings-nav" { h1 { "Admin" } nav { (tab("/admin", "overview", "Overview")) (tab("/admin/users", "users", "Users")) (tab("/admin/repos", "repos", "Repositories")) (tab("/admin/groups", "groups", "Groups")) @if show_invites { (tab("/admin/invites", "invites", "Invites")) } (tab("/admin/settings", "settings", "Settings")) } } div class="settings-content" { (content) } } } } /// Render an admin page with the shell and chrome. fn render( state: &AppState, jar: CookieJar, user: User, uri: &Uri, active: &str, content: Markup, ) -> Response { let show_invites = !state.allow_registration(); let (jar, chrome) = build_chrome(state, jar, Some(user), uri.path().to_string()); ( jar, page(&chrome, "Admin", admin_shell(active, content, show_invites)), ) .into_response() } /// Rows shown per page in the admin list views. const PAGE_SIZE: i64 = 20; /// The `?page=N` (1-based) query parameter shared by the paginated lists. #[derive(Debug, Default, Deserialize)] pub struct Pager { #[serde(default)] page: Option, } impl Pager { /// The clamped 1-based page number and its row offset. fn resolve(&self) -> (i64, i64) { let page = self.page.unwrap_or(1).max(1); (page, (page - 1) * PAGE_SIZE) } } /// Previous/next controls under a list, shown only when `total` spans more than /// one page. `base` is the list path (e.g. `/admin/users`). fn pager_nav(base: &str, page: i64, total: i64) -> Markup { let pages = ((total + PAGE_SIZE - 1) / PAGE_SIZE).max(1); html! { @if pages > 1 { nav class="pagination" aria-label="Pagination" { @if page > 1 { a class="btn" href=(format!("{base}?page={}", page - 1)) { "← Previous" } } @else { span class="btn disabled" aria-disabled="true" { "← Previous" } } span class="muted pagination-page" { "Page " (page) " of " (pages) } @if page < pages { a class="btn" href=(format!("{base}?page={}", page + 1)) { "Next →" } } @else { span class="btn disabled" aria-disabled="true" { "Next →" } } } } } } // ---- Overview ---- /// `GET /admin` — instance statistics. pub async fn overview( State(state): State, RequireAdmin(user): RequireAdmin, jar: CookieJar, uri: Uri, ) -> AppResult { let counts = state.store.admin_counts().await?; let tracked = state.store.total_repo_bytes().await.unwrap_or(0); // Disk usage of the repo tree (best-effort) and the SQLite DB file, if any. let repo_dir = state.config.storage.repo_dir.clone(); let disk = tokio::task::spawn_blocking(move || dir_size(&repo_dir)) .await .unwrap_or(0); let db_bytes = sqlite_db_size(&state.config.database.url); let stat = |label: &str, value: String| { html! { div class="stat-card" { div class="stat-value" { (value) } div class="stat-label muted" { (label) } } } }; let content = html! { h2 { "Overview" } div class="stat-grid" { (stat("Users", counts.users.to_string())) (stat("Administrators", counts.admins.to_string())) (stat("Disabled", counts.disabled_users.to_string())) (stat("Repositories", counts.repos.to_string())) (stat("Groups", counts.groups.to_string())) (stat("Issues (open)", format!("{} ({})", counts.issues, counts.open_issues))) (stat("Pull requests", counts.pulls.to_string())) (stat("SSH/GPG keys", counts.keys.to_string())) (stat("Active API tokens", counts.tokens.to_string())) (stat("Live sessions", counts.sessions.to_string())) (stat("Repository storage", fmt_bytes(disk.max(u64::try_from(tracked).unwrap_or(0))))) @if let Some(db) = db_bytes { (stat("Database size", fmt_bytes(db))) } } }; Ok(render(&state, jar, user, &uri, "overview", content)) } // ---- Users ---- /// `GET /admin/users` — the user list and a create form. pub async fn users( State(state): State, RequireAdmin(user): RequireAdmin, jar: CookieJar, uri: Uri, Query(pager): Query, ) -> AppResult { let (page, offset) = pager.resolve(); let total = state.store.admin_counts().await?.users; let all = state.store.list_users_paged(PAGE_SIZE, offset).await?; // Verified state of each shown user's primary email, so the row can badge it // and only offer "Verify" when it is not yet verified. let mut verified: std::collections::HashSet = std::collections::HashSet::new(); for u in &all { if state .store .primary_email_verified(&u.id) .await .unwrap_or(false) { verified.insert(u.id.clone()); } } // The password picker offers every account, not just the current page. let everyone = state.store.list_users().await?; let (_, chrome) = build_chrome( &state, jar.clone(), Some(user.clone()), uri.path().to_string(), ); let csrf = chrome.csrf.clone(); let content = html! { h2 { "Users" } section class="listing" { div class="card" { ul class="entry-list admin-list" { @for u in &all { li class="entry-row" { div class="entry-row-body" { span class="entry-row-title" { a href={ "/" (u.username) } { (u.username) } @if u.is_admin { " " span class="badge" { "admin" } } @if verified.contains(&u.id) { " " span class="badge badge-verified" { "verified" } } @if u.disabled_at.is_some() { " " span class="badge badge-private" { "disabled" } } } div class="entry-row-meta muted" { (u.email) } } div class="entry-row-actions admin-actions" { @if !verified.contains(&u.id) { (post_button(&format!("/admin/users/{}/verify", u.id), &csrf, "Verify", "btn")) } (toggle_button(&format!("/admin/users/{}/admin", u.id), &csrf, "admin", !u.is_admin, if u.is_admin { "Revoke admin" } else { "Make admin" })) (toggle_button(&format!("/admin/users/{}/disable", u.id), &csrf, "disabled", u.disabled_at.is_none(), if u.disabled_at.is_some() { "Enable" } else { "Disable" })) @if u.id != user.id { (post_button(&format!("/admin/users/{}/delete", u.id), &csrf, "Delete", "btn btn-danger")) } } } } } (pager_nav("/admin/users", page, total)) } } section class="listing" { h3 { "Create a user" } div class="card" { form method="post" action="/admin/users" { input type="hidden" name="_csrf" value=(csrf); div class="admin-form-row" { input type="text" name="username" placeholder="username" required; input type="email" name="email" placeholder="email" required; input type="password" name="password" placeholder="password (optional)"; } p class="muted field-hint" { "Leaving the password blank creates an account that must be activated by the user (they'll need an invite/reset)." } div class="admin-form-actions" { button class="btn btn-primary inline-btn" type="submit" { "Create user" } label class="checkbox" { input type="checkbox" name="admin" value="1"; " Administrator" } } } } } section class="listing" { h3 { "Reset a password" } div class="card" { form method="post" action="/admin/users/password" { input type="hidden" name="_csrf" value=(csrf); div class="admin-form-row admin-form-row-last" { select name="user_id" aria-label="User" { @for u in &everyone { option value=(u.id) { (u.username) } } } input type="password" name="password" placeholder="new password" required minlength="8"; button class="btn inline-btn" type="submit" { "Set password" } } } } } }; Ok(render(&state, jar, user, &uri, "users", content)) } /// Create-user form. #[derive(Debug, Deserialize)] pub struct CreateUserForm { #[serde(default)] username: String, #[serde(default)] email: String, #[serde(default)] password: String, #[serde(default)] admin: Option, #[serde(rename = "_csrf")] csrf: String, } /// `POST /admin/users` — create a user. pub async fn user_create( State(state): State, RequireAdmin(_): RequireAdmin, jar: CookieJar, headers: HeaderMap, Form(form): Form, ) -> Response { if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { return AppError::Forbidden.into_response(); } let hash = if form.password.trim().is_empty() { None } else { match hash_password(&state, form.password.trim()) { Ok(h) => Some(h), Err(e) => return e.into_response(), } }; match state .store .create_user(store::NewUser { username: form.username.trim().to_string(), email: form.email.trim().to_string(), display_name: None, password_hash: hash, is_admin: form.admin.is_some(), must_change_password: false, }) .await { Ok(_) => Redirect::to("/admin/users").into_response(), Err(store::StoreError::Conflict { .. }) => { AppError::BadRequest("That username or email is already taken.".to_string()) .into_response() } Err(store::StoreError::Name(_)) => { AppError::BadRequest("Invalid username.".to_string()).into_response() } Err(err) => AppError::from(err).into_response(), } } /// A `{disabled|admin}=true/false` toggle form. #[derive(Debug, Deserialize)] pub struct ToggleForm { #[serde(default)] disabled: String, #[serde(default)] admin: String, #[serde(rename = "_csrf")] csrf: String, } /// A single-field password form. #[derive(Debug, Deserialize)] pub struct PasswordForm { #[serde(default)] user_id: String, #[serde(default)] password: String, #[serde(rename = "_csrf")] csrf: String, } /// A bare CSRF form for parameter-less actions. #[derive(Debug, Deserialize)] pub struct BareForm { #[serde(rename = "_csrf")] csrf: String, } /// `POST /admin/users/{id}/verify`. pub async fn user_verify( State(state): State, RequireAdmin(_): RequireAdmin, jar: CookieJar, headers: HeaderMap, Path(id): Path, Form(form): Form, ) -> Response { if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { return AppError::Forbidden.into_response(); } let _ = state.store.verify_primary_email(&id).await; Redirect::to("/admin/users").into_response() } /// `POST /admin/users/{id}/disable`. pub async fn user_disable( State(state): State, RequireAdmin(_): RequireAdmin, jar: CookieJar, headers: HeaderMap, Path(id): Path, Form(form): Form, ) -> Response { if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { return AppError::Forbidden.into_response(); } let _ = state.store.set_disabled(&id, form.disabled == "true").await; Redirect::to("/admin/users").into_response() } /// `POST /admin/users/{id}/admin`. pub async fn user_toggle_admin( State(state): State, RequireAdmin(_): RequireAdmin, jar: CookieJar, headers: HeaderMap, Path(id): Path, Form(form): Form, ) -> Response { if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { return AppError::Forbidden.into_response(); } let _ = state.store.set_admin(&id, form.admin == "true").await; Redirect::to("/admin/users").into_response() } /// `POST /admin/users/{id}/delete`. pub async fn user_delete( State(state): State, RequireAdmin(admin): RequireAdmin, jar: CookieJar, headers: HeaderMap, Path(id): Path, Form(form): Form, ) -> Response { if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { return AppError::Forbidden.into_response(); } if id == admin.id { return AppError::BadRequest("You cannot delete your own account here.".to_string()) .into_response(); } let _ = state.store.delete_user(&id).await; Redirect::to("/admin/users").into_response() } /// `POST /admin/users/password` — set a user's password. pub async fn user_set_password( State(state): State, RequireAdmin(_): RequireAdmin, jar: CookieJar, headers: HeaderMap, Form(form): Form, ) -> Response { if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { return AppError::Forbidden.into_response(); } let hash = match hash_password(&state, form.password.trim()) { Ok(h) => h, Err(e) => return e.into_response(), }; let _ = state.store.set_password(&form.user_id, Some(&hash)).await; Redirect::to("/admin/users").into_response() } // ---- Repositories ---- /// `GET /admin/repos`. pub async fn repos( State(state): State, RequireAdmin(user): RequireAdmin, jar: CookieJar, uri: Uri, Query(pager): Query, ) -> AppResult { let (page, offset) = pager.resolve(); let total = state.store.admin_counts().await?.repos; let repos = state.store.list_repos_paged(PAGE_SIZE, offset).await?; // Resolve owner names for links. let mut owners = std::collections::HashMap::new(); for r in &repos { if !owners.contains_key(&r.owner_id) && let Ok(Some(u)) = state.store.user_by_id(&r.owner_id).await { owners.insert(r.owner_id.clone(), u.username); } } let (_, chrome) = build_chrome( &state, jar.clone(), Some(user.clone()), uri.path().to_string(), ); let csrf = chrome.csrf.clone(); let content = html! { h2 { "Repositories" } section class="listing" { div class="card" { @if repos.is_empty() { p class="muted empty-note" { "No repositories." } } ul class="entry-list admin-list" { @for r in &repos { @let owner = owners.get(&r.owner_id).cloned().unwrap_or_else(|| r.owner_id.clone()); li class="entry-row" { div class="entry-row-body" { span class="entry-row-title" { a href={ "/" (owner) "/" (r.path) } { (owner) "/" (r.path) } } div class="entry-row-meta muted" { (r.visibility.as_str()) " · " (r.object_format) } } div class="entry-row-actions admin-actions" { a class="btn" href={ "/" (owner) "/" (r.path) "/-/settings" } { "Manage" } (post_button(&format!("/admin/repos/{}/delete", r.id), &csrf, "Delete", "btn btn-danger")) } } } } (pager_nav("/admin/repos", page, total)) } } }; Ok(render(&state, jar, user, &uri, "repos", content)) } /// `POST /admin/repos/{id}/delete`. pub async fn repo_delete( State(state): State, RequireAdmin(_): RequireAdmin, jar: CookieJar, headers: HeaderMap, Path(id): Path, Form(form): Form, ) -> Response { if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { return AppError::Forbidden.into_response(); } let _ = state.store.delete_repo(&id).await; Redirect::to("/admin/repos").into_response() } // ---- Groups ---- /// `GET /admin/groups`. pub async fn groups( State(state): State, RequireAdmin(user): RequireAdmin, jar: CookieJar, uri: Uri, Query(pager): Query, ) -> AppResult { let (page, offset) = pager.resolve(); let total = state.store.admin_counts().await?.groups; let groups = state.store.list_groups_paged(PAGE_SIZE, offset).await?; let mut owners = std::collections::HashMap::new(); for g in &groups { if !owners.contains_key(&g.owner_id) && let Ok(Some(u)) = state.store.user_by_id(&g.owner_id).await { owners.insert(g.owner_id.clone(), u.username); } } let (_, chrome) = build_chrome( &state, jar.clone(), Some(user.clone()), uri.path().to_string(), ); let csrf = chrome.csrf.clone(); let content = html! { h2 { "Groups" } section class="listing" { div class="card" { @if groups.is_empty() { p class="muted empty-note" { "No groups." } } ul class="entry-list admin-list" { @for g in &groups { @let owner = owners.get(&g.owner_id).cloned().unwrap_or_else(|| g.owner_id.clone()); li class="entry-row" { div class="entry-row-body" { span class="entry-row-title" { a href={ "/" (owner) "/" (g.path) } { (owner) "/" (g.path) } } } div class="entry-row-actions admin-actions" { (post_button(&format!("/admin/groups/{}/delete", g.id), &csrf, "Delete", "btn btn-danger")) } } } } (pager_nav("/admin/groups", page, total)) p class="muted field-hint" { "Deleting a group ungroups its repositories (they are not deleted)." } } } }; Ok(render(&state, jar, user, &uri, "groups", content)) } /// `POST /admin/groups/{id}/delete`. pub async fn group_delete( State(state): State, RequireAdmin(_): RequireAdmin, jar: CookieJar, headers: HeaderMap, Path(id): Path, Form(form): Form, ) -> Response { if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { return AppError::Forbidden.into_response(); } let _ = state.store.delete_group(&id).await; Redirect::to("/admin/groups").into_response() } // ---- Invites ---- /// `GET /admin/invites`. pub async fn invites( State(state): State, RequireAdmin(user): RequireAdmin, jar: CookieJar, uri: Uri, Query(pager): Query, ) -> AppResult { // Invites only matter when self-registration is closed; otherwise the page // is hidden and its route redirects to the overview. if state.allow_registration() { return Ok(Redirect::to("/admin").into_response()); } let (page, offset) = pager.resolve(); let all = state.store.list_signup_invites().await?; let total = i64::try_from(all.len()).unwrap_or(i64::MAX); let invites: Vec<_> = all .into_iter() .skip(usize::try_from(offset).unwrap_or(0)) .take(usize::try_from(PAGE_SIZE).unwrap_or(20)) .collect(); let base = state.config.instance.url.trim_end_matches('/').to_string(); let (_, chrome) = build_chrome( &state, jar.clone(), Some(user.clone()), uri.path().to_string(), ); let csrf = chrome.csrf.clone(); let content = html! { h2 { "Sign-up invites" } p class="muted field-hint" { "Invite links let people register even when self-registration is disabled. The link is shown once at creation." } section class="listing" { div class="card" { @if invites.is_empty() { p class="muted empty-note" { "No invites." } } ul class="entry-list admin-list" { @for iv in &invites { li class="entry-row" { div class="entry-row-body" { span class="entry-row-title" { @if iv.used_at.is_some() { span class="badge badge-used" { "used" } } @else { span class="badge badge-unused" { "unused" } } " " (iv.note.clone().unwrap_or_else(|| "(no note)".to_string())) } @if let Some(exp) = iv.expires_at { div class="entry-row-meta muted" { "expires " (crate::repo::fmt_date(exp)) } } } div class="entry-row-actions admin-actions" { (post_button(&format!("/admin/invites/{}/delete", iv.id), &csrf, "Delete", "btn btn-danger")) } } } } (pager_nav("/admin/invites", page, total)) form method="post" action="/admin/invites" { input type="hidden" name="_csrf" value=(csrf); div class="admin-form-row admin-form-row-last" { input type="text" name="note" placeholder="note (optional, e.g. who it's for)"; button class="btn btn-primary inline-btn" type="submit" { "Create invite" } } } } } @if let Some(link) = jar.get("fabrica_flash_invite").map(|c| c.value().to_string()) { div class="card notice notice-success" { p { strong { "Invite link created." } " Copy it now:" } pre class="token-secret" { code { (base) "/register/" (link) } } } } }; // Clear the flash cookie after showing it once. The removal must carry the // same path the cookie was set with ("/admin/invites"), or the browser keeps // it and the link reappears on reload. let jar = jar.remove( axum_extra::extract::cookie::Cookie::build("fabrica_flash_invite") .path("/admin/invites") .build(), ); Ok(render(&state, jar, user, &uri, "invites", content)) } /// Create-invite form. #[derive(Debug, Deserialize)] pub struct CreateInviteForm { #[serde(default)] note: String, #[serde(rename = "_csrf")] csrf: String, } /// `POST /admin/invites` — create a sign-up invite and show its link once. pub async fn invite_create( State(state): State, RequireAdmin(admin): RequireAdmin, jar: CookieJar, headers: HeaderMap, Form(form): Form, ) -> Response { if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { return AppError::Forbidden.into_response(); } if state.allow_registration() { return Redirect::to("/admin").into_response(); } let token = auth::new_session_token(); let note = form.note.trim(); let created = state .store .create_signup_invite(store::NewSignupInvite { token_hash: token.id, note: (!note.is_empty()).then(|| note.to_string()), expires_at: None, created_by: admin.id, }) .await; if created.is_err() { return AppError::internal("could not create invite").into_response(); } // Stash the raw token in a short-lived cookie so the list page can show it once. let flash = axum_extra::extract::cookie::Cookie::build(("fabrica_flash_invite", token.cookie)) .http_only(true) .same_site(axum_extra::extract::cookie::SameSite::Lax) .secure(state.config.auth.cookie_secure) .path("/admin/invites") .build(); (jar.add(flash), Redirect::to("/admin/invites")).into_response() } /// `POST /admin/invites/{id}/delete`. pub async fn invite_delete( State(state): State, RequireAdmin(_): RequireAdmin, jar: CookieJar, headers: HeaderMap, Path(id): Path, Form(form): Form, ) -> Response { if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { return AppError::Forbidden.into_response(); } let _ = state.store.delete_signup_invite(&id).await; Redirect::to("/admin/invites").into_response() } // ---- Settings ---- /// `GET /admin/settings` — override config values. pub async fn settings( State(state): State, RequireAdmin(user): RequireAdmin, jar: CookieJar, uri: Uri, ) -> AppResult { let (_, chrome) = build_chrome( &state, jar.clone(), Some(user.clone()), uri.path().to_string(), ); let csrf = chrome.csrf.clone(); let name = state.instance_name(); let desc = state.setting_str( setting_keys::DESCRIPTION, &state.config.instance.description, ); let reg = state.allow_registration(); let anon = state.allow_anonymous(); let vis = state.default_visibility_token(); let vis_opt = |v: &str, label: &str| html! { option value=(v) selected[v == vis] { (label) } }; let content = html! { h2 { "Instance settings" } p class="muted field-hint" { "These override the config file. Leaving a field at its default keeps the config value; each row can be reset." } div class="card" { form method="post" action="/admin/settings" class="stacked-form" { input type="hidden" name="_csrf" value=(csrf); div class="form-field" { label for="s-name" { "Instance name" } input type="text" id="s-name" name="name" value=(name); } div class="form-field" { label for="s-desc" { "Description" } input type="text" id="s-desc" name="description" value=(desc); } div class="form-field" { label for="s-vis" { "Default repository visibility" } select id="s-vis" name="default_visibility" { (vis_opt("public", "Public")) (vis_opt("internal", "Internal")) (vis_opt("private", "Private")) } } div class="admin-form-actions" { label class="checkbox" { input type="checkbox" name="allow_registration" value="1" checked[reg]; " Allow self-registration" } label class="checkbox" { input type="checkbox" name="allow_anonymous" value="1" checked[anon]; " Allow anonymous browse/clone" } button class="btn btn-primary inline-btn" type="submit" { "Save settings" } } } } }; Ok(render(&state, jar, user, &uri, "settings", content)) } /// Settings form. #[derive(Debug, Deserialize)] pub struct SettingsForm { #[serde(default)] name: String, #[serde(default)] description: String, #[serde(default)] default_visibility: String, #[serde(default)] allow_registration: Option, #[serde(default)] allow_anonymous: Option, #[serde(rename = "_csrf")] csrf: String, } /// `POST /admin/settings` — persist overrides (a blank name/description clears it). pub async fn settings_save( State(state): State, RequireAdmin(_): RequireAdmin, jar: CookieJar, headers: HeaderMap, Form(form): Form, ) -> Response { if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { return AppError::Forbidden.into_response(); } // A blank text field reverts to the config value (delete the override). for (key, value) in [ (setting_keys::NAME, form.name.trim()), (setting_keys::DESCRIPTION, form.description.trim()), ] { if value.is_empty() { let _ = state.store.delete_setting(key).await; } else { let _ = state.store.set_setting(key, value).await; } } if matches!( form.default_visibility.as_str(), "public" | "internal" | "private" ) { let _ = state .store .set_setting(setting_keys::DEFAULT_VISIBILITY, &form.default_visibility) .await; } let _ = state .store .set_setting( setting_keys::ALLOW_REGISTRATION, if form.allow_registration.is_some() { "true" } else { "false" }, ) .await; let _ = state .store .set_setting( setting_keys::ALLOW_ANONYMOUS, if form.allow_anonymous.is_some() { "true" } else { "false" }, ) .await; state.reload_settings().await; Redirect::to("/admin/settings").into_response() } // ---- Helpers ---- /// A small POST form rendering a single button. fn post_button(action: &str, csrf: &str, label: &str, class: &str) -> Markup { html! { form method="post" action=(action) class="inline-form" { input type="hidden" name="_csrf" value=(csrf); button class=(class) type="submit" { (label) } } } } /// A POST form that sets `field` to `value` (for enable/disable, admin toggles). fn toggle_button(action: &str, csrf: &str, field: &str, value: bool, label: &str) -> Markup { html! { form method="post" action=(action) class="inline-form" { input type="hidden" name="_csrf" value=(csrf); input type="hidden" name=(field) value=(if value { "true" } else { "false" }); button class="btn" type="submit" { (label) } } } } /// Hash a password with the instance Argon2 cost. fn hash_password(state: &AppState, password: &str) -> Result { let params = auth::HashParams { m_cost: state.config.auth.argon2_m_cost, t_cost: state.config.auth.argon2_t_cost, p_cost: state.config.auth.argon2_p_cost, }; auth::hash_password(password, params).map_err(|_| AppError::internal("hash failed")) } /// Recursively sum the sizes of regular files under `dir` (best-effort). pub(crate) fn dir_size(dir: &FsPath) -> u64 { let mut total = 0u64; let mut stack: Vec = vec![dir.to_path_buf()]; while let Some(path) = stack.pop() { let Ok(entries) = std::fs::read_dir(&path) else { continue; }; for entry in entries.flatten() { let Ok(ft) = entry.file_type() else { continue }; if ft.is_dir() { stack.push(entry.path()); } else if ft.is_file() && let Ok(meta) = entry.metadata() { total += meta.len(); } } } total } /// The on-disk size of a `sqlite:` database file, if the URL names one. fn sqlite_db_size(url: &str) -> Option { let path = url .strip_prefix("sqlite://") .or_else(|| url.strip_prefix("sqlite:"))?; let path = path.split('?').next().unwrap_or(path); if path.is_empty() || path == ":memory:" { return None; } std::fs::metadata(path).ok().map(|m| m.len()) } /// Format a byte count as a human-readable string. fn fmt_bytes(bytes: u64) -> String { const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"]; #[allow(clippy::cast_precision_loss)] let mut value = bytes as f64; let mut unit = 0; while value >= 1024.0 && unit < UNITS.len() - 1 { value /= 1024.0; unit += 1; } if unit == 0 { format!("{bytes} B") } else { format!("{value:.1} {}", UNITS[unit]) } }