//! Page: backups (`/admin/backups`). Instance admins only; anyone else gets 404. //! //! Shows the `[backup]` settings (the healthcheck URL only as its host, since it may hold a token), //! the last 20 backup and fsck runs, and two POST buttons that start a run in the background. //! A button press is audited and redirects back here with a notice. use forge_core::store::backup_runs::{BackupRun, RunKind}; use forge_core::tz::format_in_tz; use forge_core::{AuditAction, Error, User}; use serde::Deserialize; use topcoat::Result; use topcoat::context::{Cx, app_context}; use topcoat::router::content::Form; use topcoat::router::error::{forbidden, see_other}; use topcoat::router::response::Response; use topcoat::router::{page, query_params, route}; use topcoat::view::{View, component, view}; use crate::audit::{self, Actor, Target}; use crate::auth::check_csrf; use crate::backup::scheduler::{configured_dir, start_manual}; use crate::i18n::admin::{self as g, K}; use crate::i18n::{self, Lang}; use crate::pages::admin_users::admin_only; use crate::pages::issues_list::stamp_unix; use crate::state::AppState; use crate::views::{enc, shell}; /// Runs shown on the page. const RUN_LIMIT: u32 = 20; #[query_params(error = bad_request)] pub(crate) struct NoticeQuery { notice: Option, } #[derive(Deserialize)] struct RunForm { #[serde(default)] csrf: String, } /// One table row, already formatted. struct RunRow { kind: String, trigger: String, started: String, duration: String, size: String, status: String, detail: String, } /// The settings block, already formatted. struct Settings { enabled: bool, dir: String, interval: String, keep: String, fsck_interval: String, healthcheck: String, } fn settings_view(lang: Lang, config: &forge_core::config::BackupConfig) -> Settings { let healthcheck = if config.healthcheck_url.is_empty() { g::t(lang, K::HealthcheckNotSet).to_owned() } else { match reqwest::Url::parse(&config.healthcheck_url) { Ok(url) => g::healthcheck_host(lang, url.host_str()), Err(_) => g::t(lang, K::HealthcheckUrlInvalid).to_owned(), } }; Settings { enabled: config.enabled(), dir: config.dir.display().to_string(), interval: g::every_hours(lang, config.interval_hours), keep: g::newest_sets(lang, config.keep), fsck_interval: g::every_days(lang, config.fsck_interval_days), healthcheck, } } /// `"512 B"`, `"12.3 KiB"`, `"4.0 MiB"` and so on. fn human_bytes(bytes: u64) -> String { const UNITS: [&str; 4] = ["KiB", "MiB", "GiB", "TiB"]; if bytes < 1024 { return format!("{bytes} B"); } let mut value = bytes as f64 / 1024.0; let mut unit = 0; while value >= 1024.0 && unit + 1 < UNITS.len() { value /= 1024.0; unit += 1; } format!("{value:.1} {}", UNITS[unit]) } fn duration_text(run: &BackupRun) -> String { let start = stamp_unix(&run.started_at); let Some(end) = run.finished_at.as_deref().map(stamp_unix) else { return "-".to_owned(); }; let secs = (end - start).max(0); if secs < 60 { format!("{secs}s") } else { format!("{}m {}s", secs / 60, secs % 60) } } fn run_row(run: &BackupRun, tz: &str, lang: Lang) -> RunRow { let (status, detail) = match (run.finished_at.is_some(), run.ok) { (false, _) => (g::t(lang, K::StatusInProgress).to_owned(), String::new()), (true, Some(true)) => (g::t(lang, K::StatusOk).to_owned(), String::new()), (true, _) => ( g::t(lang, K::StatusFailed).to_owned(), run.error.clone().unwrap_or_default(), ), }; RunRow { kind: g::kind_name(lang, run.kind).to_owned(), trigger: g::trigger_name(lang, run.trigger).to_owned(), started: format_in_tz(stamp_unix(&run.started_at), tz), duration: duration_text(run), size: run.bytes.map(human_bytes).unwrap_or_default(), status, detail, } } #[component] async fn backups_view( lang: Lang, csrf: String, settings: Settings, rows: Vec, running: Option, notice: Option, ) -> Result { Ok(view! {

(g::t(lang, K::Backups))

(g::t(lang, K::BackupsIntro))

if let Some(message) = notice {

(message.as_str())

}

(g::t(lang, K::Settings))

if settings.enabled {
(g::t(lang, K::Directory))
(settings.dir.as_str())
(g::t(lang, K::BackupInterval))
(settings.interval.as_str())
(g::t(lang, K::Retention))
(settings.keep.as_str())
(g::t(lang, K::Fsck))
(settings.fsck_interval.as_str())
(g::t(lang, K::Healthcheck))
(settings.healthcheck.as_str())
} else {

(g::t(lang, K::ScheduledBackupsOff))

} if let Some(kind) = running {

(g::running_line(lang, kind))

}

(g::t(lang, K::RecentRuns))

if rows.is_empty() {

(g::t(lang, K::NoRunsYet))

} else {
for row in rows.into_iter() { }
(g::t(lang, K::TableKind)) (g::t(lang, K::TableTrigger)) (g::t(lang, K::TableStarted)) (g::t(lang, K::TableDuration)) (g::t(lang, K::TableSize)) (g::t(lang, K::TableResult))
(row.kind.as_str()) (row.trigger.as_str()) (row.started.as_str()) (row.duration.as_str()) (row.size.as_str()) (row.status.as_str()) if !row.detail.is_empty() {
(row.detail.as_str())
}
}
}) } #[page("/admin/backups")] async fn backups_page(cx: &Cx) -> Result { let viewer = admin_only(cx).await?; let query = query_params::(cx)?; let state = app_context::(cx); let lang = i18n::lang(cx, Some(&viewer.0)); let runs = state.store.recent_runs(RUN_LIMIT).await?; let tz = viewer.0.timezone.as_str(); let rows: Vec = runs.iter().map(|run| run_row(run, tz, lang)).collect(); let running = state.jobs.running(); let settings = settings_view(lang, &state.config.backup); let csrf = viewer.1.csrf_token.clone(); let title = g::t(lang, K::Backups); Ok(view! { shell(title: title, viewer: Some(&viewer), backups_view(lang: lang, csrf: csrf, settings: settings, rows: rows, running: running, notice: query.notice.clone())) }) } #[route(POST "/admin/backups/run")] async fn backups_run(cx: &Cx, Form(form): Form) -> Result { start_run(cx, form.csrf, RunKind::Backup).await } #[route(POST "/admin/backups/fsck")] async fn backups_fsck(cx: &Cx, Form(form): Form) -> Result { start_run(cx, form.csrf, RunKind::Fsck).await } /// Starts a run in the background, audits the request and redirects back with a notice. async fn start_run(cx: &Cx, csrf: String, kind: RunKind) -> Result { let viewer = admin_only(cx).await?; if !check_csrf(&viewer.1, &csrf) { return Err(forbidden().into()); } let lang = i18n::lang(cx, Some(&viewer.0)); let state = app_context::(cx); let notice = match launch(cx, state, &viewer.0, kind, lang).await { Ok(()) => g::started_notice(lang, kind), Err(e) => match e { Error::Conflict(message) => message, other => other.to_string(), }, }; let location = format!("/admin/backups?notice={}", enc(¬ice)); Err(see_other(&location).into()) } /// Claims the run slot, records the audit entry and starts the run. Refusals come back as errors. async fn launch( cx: &Cx, state: &AppState, user: &User, kind: RunKind, lang: Lang, ) -> forge_core::Result<()> { if !state.config.backup.enabled() { return Err(Error::Config( g::t(lang, K::ErrBackupsNotConfigured).to_owned(), )); } // Refuse a bad directory here, not in the background task. configured_dir(state)?; let guard = state.jobs.claim(kind)?; let (action, target_kind) = match kind { RunKind::Backup => (AuditAction::BackupStarted, "backup"), RunKind::Fsck => (AuditAction::FsckStarted, "fsck"), }; audit::record( &state.store, Some(cx), Actor::User(user), action, Target { kind: target_kind, id: "", name: "", }, serde_json::json!({ "trigger": "manual" }), ) .await; start_manual(state, guard) } #[cfg(test)] mod tests { use super::*; use forge_core::model::Visibility; use forge_core::store::backup_runs::RunTrigger; use topcoat::router::request::Request; use topcoat::router::{Body, Router, to_bytes}; use crate::auth::SESSION_COOKIE; use crate::test_support::{app_router, create_session, test_state}; const ADMIN_TOKEN: &str = "backups-admin-session"; const USER_TOKEN: &str = "backups-user-session"; const CSRF: &str = "backups-csrf"; struct Fixture { _dir: tempfile::TempDir, router: Router, state: AppState, } /// An instance with an admin, a plain user, one repo, and backups configured under the temp /// directory. async fn fixture(configured: bool) -> Fixture { let dir = tempfile::tempdir().expect("tempdir"); let data_dir = dir.path().join("data"); std::fs::create_dir_all(&data_dir).expect("data dir"); let mut state = test_state(&data_dir).await; let mut config = crate::test_support::test_config(&data_dir); if configured { config.backup.dir = dir.path().join("out"); config.backup.keep = 2; } state.config = std::sync::Arc::new(config); let admin = state .store .create_user("root", None, "h", true) .await .expect("admin"); let user = state .store .create_user("plain", None, "h", false) .await .expect("user"); create_session(&state.store, &admin.id, ADMIN_TOKEN, CSRF).await; create_session(&state.store, &user.id, USER_TOKEN, CSRF).await; let repo = state .store .create_repo(&admin.id, "demo", "", Visibility::Private, "main") .await .expect("repo"); // fsck reads the repo from disk, so it must exist for the run to pass. let path = forge_git::repo::repo_path(&data_dir, &repo.id).expect("path"); forge_git::repo::init_bare(&state.git, &path, "main") .await .expect("init"); let router = app_router(state.clone()); Fixture { _dir: dir, router, state, } } async fn send( router: &Router, method: &str, uri: &str, token: Option<&str>, form: Option, ) -> (u16, String) { let mut builder = Request::builder().method(method).uri(uri); if let Some(token) = token { builder = builder.header("cookie", format!("__Host-{SESSION_COOKIE}={token}")); } let body = match form { Some(form) => { builder = builder.header("content-type", "application/x-www-form-urlencoded"); Body::from(form) } None => Body::empty(), }; let res = router.handle(builder.body(body).expect("request")).await; let status = res.status().as_u16(); let bytes = to_bytes(res.into_body(), usize::MAX).await.expect("body"); (status, String::from_utf8_lossy(&bytes).into_owned()) } #[tokio::test] async fn page_is_404_for_anonymous_and_non_admins() { let fx = fixture(true).await; let (anon, _) = send(&fx.router, "GET", "/admin/backups", None, None).await; assert_eq!(anon, 404); let (user, _) = send(&fx.router, "GET", "/admin/backups", Some(USER_TOKEN), None).await; assert_eq!(user, 404); let (posted, _) = send( &fx.router, "POST", "/admin/backups/run", Some(USER_TOKEN), Some(format!("csrf={CSRF}")), ) .await; assert_eq!(posted, 404); } #[tokio::test] async fn page_lists_runs_and_hides_the_healthcheck_token() { let fx = fixture(true).await; let mut config = (*fx.state.config).clone(); config.backup.healthcheck_url = "https://hc.example.test/ping/SECRET-TOKEN".into(); let mut state = fx.state.clone(); state.config = std::sync::Arc::new(config); let router = app_router(state.clone()); let id = state .store .start_run(RunKind::Backup, RunTrigger::Schedule) .await .expect("start"); state .store .finish_run(&id, false, None, None, Some("disk full")) .await .expect("finish"); let (status, body) = send(&router, "GET", "/admin/backups", Some(ADMIN_TOKEN), None).await; assert_eq!(status, 200); assert!(body.contains("schedule")); assert!(body.contains("disk full")); assert!(body.contains("hc.example.test")); assert!( !body.contains("SECRET-TOKEN"), "the healthcheck token must not be shown" ); assert!(body.contains("Run backup now")); assert!(body.contains("Run fsck now")); } #[tokio::test] async fn post_starts_a_run_audits_it_and_records_it() { let fx = fixture(true).await; // Refused without the CSRF token. let (bad, _) = send( &fx.router, "POST", "/admin/backups/fsck", Some(ADMIN_TOKEN), Some("csrf=wrong".into()), ) .await; assert_eq!(bad, 403); let (status, _) = send( &fx.router, "POST", "/admin/backups/fsck", Some(ADMIN_TOKEN), Some(format!("csrf={CSRF}")), ) .await; assert_eq!(status, 303); // Wait for the background fsck to finish and record itself. let mut runs = Vec::new(); for _ in 0..500 { runs = fx.state.store.recent_runs(5).await.expect("runs"); if runs.iter().any(|r| r.finished_at.is_some()) { break; } tokio::time::sleep(std::time::Duration::from_millis(20)).await; } assert_eq!(runs.len(), 1, "{runs:?}"); assert_eq!(runs[0].kind, RunKind::Fsck); assert_eq!(runs[0].trigger, RunTrigger::Manual); assert_eq!(runs[0].ok, Some(true)); let audit = fx .state .store .list_audit(&Default::default(), None, 10) .await .expect("audit"); assert!(audit.iter().any(|e| e.action == AuditAction::FsckStarted)); let (_, page) = send(&fx.router, "GET", "/admin/backups", Some(ADMIN_TOKEN), None).await; assert!(page.contains("fsck")); } #[tokio::test] async fn a_backup_run_while_one_is_active_says_already_running() { let fx = fixture(true).await; let _held = fx.state.jobs.claim(RunKind::Backup).expect("claim"); let (status, _) = send( &fx.router, "POST", "/admin/backups/run", Some(ADMIN_TOKEN), Some(format!("csrf={CSRF}")), ) .await; assert_eq!(status, 303); let (_, page) = send( &fx.router, "GET", "/admin/backups?notice=already%20running%3A%20x", Some(ADMIN_TOKEN), None, ) .await; assert!(page.contains("already running")); assert!( fx.state .store .recent_runs(5) .await .expect("runs") .is_empty() ); } #[tokio::test] async fn unconfigured_instance_refuses_manual_runs() { let fx = fixture(false).await; let (status, _) = send( &fx.router, "POST", "/admin/backups/run", Some(ADMIN_TOKEN), Some(format!("csrf={CSRF}")), ) .await; assert_eq!(status, 303); assert!( fx.state .store .recent_runs(5) .await .expect("runs") .is_empty() ); assert_eq!(fx.state.jobs.running(), None); } #[test] fn human_bytes_scales() { assert_eq!(human_bytes(512), "512 B"); assert_eq!(human_bytes(2048), "2.0 KiB"); assert_eq!(human_bytes(3 * 1024 * 1024), "3.0 MiB"); } }