//! Backups' pages (docs/backups.md §5, oxygenhub#169): a user's under //! Settings › Backups, an organisation's under its settings, each with //! where its repositories go, how often, how long runs are kept, "back up //! now" and the recent runs. use super::settings::{csrf, require}; use super::{filters, Error, Page, Shell}; use crate::auth::Viewer; use crate::backups::{self, Overview, Run, Setting}; use crate::Shared; use askama::Template; use axum::extract::{Path, State}; use axum::Form; use serde::Deserialize; #[derive(Template)] #[template(path = "backups.html")] pub struct BackupsPage { shell: Shell, /// `user` or `org`. kind: &'static str, owner: String, /// Where the form posts. base: String, setting: Setting, runs: Vec, /// The directories this hub allows. dirs: Vec, notice: Option, error: Option, } impl BackupsPage { fn every(&self) -> &'static [(&'static str, &'static str)] { backups::EVERY } fn keeps(&self) -> &'static [i64] { backups::KEEP } } async fn page( app: &Shared, shell: Shell, kind: &'static str, owner: &str, notice: Option, error: Option, ) -> Result, Error> { let e = |e| Error::from_db(shell.clone(), e); let setting = backups::setting(&app.db, owner).await.map_err(e)?.unwrap_or_else(|| Setting::off(owner, kind)); let runs = backups::runs(&app.db, owner, 50).await.map_err(e)?; let base = if kind == "org" { format!("/orgs/{owner}/settings/backups") } else { "/settings/backups".into() }; let dirs = app.config.backups.directories.iter().map(|d| d.display().to_string()).collect(); Ok(Page(BackupsPage { shell, kind, owner: owner.into(), base, setting, runs, dirs, notice, error })) } #[derive(Deserialize)] pub struct BackupForm { #[serde(rename = "_csrf")] csrf_token: String, every: String, #[serde(default)] destination: String, #[serde(default)] dir: String, #[serde(default)] bucket: String, #[serde(default)] provider: String, #[serde(default)] prefix: String, #[serde(default)] keep_days: String, /// An S3 destination's bucket, region and the IAM role the hub assumes. #[serde(default)] s3_bucket: String, #[serde(default)] s3_region: String, #[serde(default)] s3_role: String, } /// The form as a setting, or why it cannot be one. fn setting_of(app: &Shared, owner: &str, kind: &str, f: &BackupForm) -> Result { if !backups::EVERY.iter().any(|(v, _)| *v == f.every) { return Err("Pick how often: off, hourly or daily.".into()); } let keep_days: i64 = f.keep_days.parse().unwrap_or(30); if !backups::KEEP.contains(&keep_days) { return Err("Keep runs for 7, 30 or 90 days.".into()); } let prefix = f.prefix.trim().trim_matches('/').to_string(); if prefix.split('/').any(|p| p == ".." || p == ".") { return Err("The prefix is a plain path, without . or .. parts.".into()); } let mut region = String::new(); let (target, provider) = match f.destination.as_str() { "dir" => { if !app.config.backups.directories.iter().any(|d| d.display().to_string() == f.dir) { return Err("Pick one of the directories this hub allows.".into()); } (f.dir.clone(), String::new()) } "s3" => { let bucket = f.s3_bucket.trim().trim_start_matches("s3://").trim_end_matches('/').to_string(); if bucket.is_empty() || !bucket.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || "-.".contains(c)) { return Err("Give the S3 bucket's name, such as andymccall-oxygen-backup.".into()); } region = f.s3_region.trim().to_string(); if region.is_empty() || !region.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') { return Err("Give the bucket's region, such as eu-west-2.".into()); } let role = f.s3_role.trim().to_string(); if !role.starts_with("arn:aws:iam::") || !role.contains(":role/") { return Err("Give the IAM role's ARN, arn:aws:iam::ACCOUNT:role/NAME (docs/backups.md §6).".into()); } (bucket, role) } "gcs" => { let bucket = f.bucket.trim().trim_start_matches("gs://").trim_end_matches('/').to_string(); if bucket.is_empty() || !bucket.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || "-_.".contains(c)) { return Err("Give the bucket's name, such as andymccall-oxygen-backup.".into()); } let provider = f.provider.trim().to_string(); if !provider.starts_with("//iam.googleapis.com/projects/") || !provider.contains("/providers/") { return Err( "Give the workload identity provider's full name, //iam.googleapis.com/projects/…/providers/… (docs/backups.md §6)." .into(), ); } (bucket, provider) } _ => return Err("Pick where backups go.".into()), }; Ok(Setting { owner: owner.into(), kind: kind.into(), every: f.every.clone(), destination: f.destination.clone(), target, prefix, provider, keep_days, updated_ms: 0, region, }) } async fn save(app: &Shared, shell: Shell, kind: &'static str, owner: &str, f: &BackupForm) -> Result, Error> { match setting_of(app, owner, kind, f) { Ok(s) => { backups::save(&app.db, &s).await.map_err(|e| Error::from_db(shell.clone(), e))?; page(app, shell, kind, owner, Some("Saved.".into()), None).await } Err(why) => page(app, shell, kind, owner, None, Some(why)).await, } } async fn run_now(app: &Shared, shell: Shell, kind: &'static str, owner: &str) -> Result, Error> { let set = backups::setting(&app.db, owner).await.map_err(|e| Error::from_db(shell.clone(), e))?; if set.is_none() { return page(app, shell, kind, owner, None, Some("Save where backups go first.".into())).await; } let (app2, owner2) = (app.clone(), owner.to_string()); tokio::spawn(async move { if let Err(e) = backups::run(&app2, &owner2, "now").await { tracing::warn!("backup of {owner2}: {e:#}"); } }); page(app, shell, kind, owner, Some("Backing up now: the run shows below when it finishes.".into()), None).await } /// TEST: reach the saved destination and write, read and remove a probe. async fn test(app: &Shared, shell: Shell, kind: &'static str, owner: &str) -> Result, Error> { let Some(set) = backups::setting(&app.db, owner).await.map_err(|e| Error::from_db(shell.clone(), e))? else { return page(app, shell, kind, owner, None, Some("Save where backups go first.".into())).await; }; let outcome = match backups::destination(app, &set) { Ok(dest) => { tokio::task::spawn_blocking(move || backups::probe(dest.as_ref())).await.unwrap_or_else(|e| Err(anyhow::anyhow!("{e}"))) } Err(e) => Err(e), }; match outcome { Ok(()) => { let place = if set.destination == "gcs" { format!("gs://{}", set.target) } else { format!("{}/{owner}", set.target) }; page(app, shell, kind, owner, Some(format!("TEST passed: wrote, read and removed a probe in {place}.")), None).await } Err(e) => page(app, shell, kind, owner, None, Some(format!("TEST failed: {e:#}"))).await, } } #[derive(Deserialize)] pub struct Csrf { #[serde(rename = "_csrf")] csrf_token: String, } pub async fn user(State(app): State, viewer: Viewer) -> Result, Error> { let shell = Shell::new(&app, "/settings/backups", &viewer); let me = require(&viewer, &shell)?; page(&app, shell, "user", &me.account.username, None, None).await } pub async fn user_save(State(app): State, viewer: Viewer, Form(f): Form) -> Result, Error> { let shell = Shell::new(&app, "/settings/backups", &viewer); let me = require(&viewer, &shell)?; csrf(&viewer, &f.csrf_token, &shell)?; save(&app, shell, "user", &me.account.username, &f).await } pub async fn user_run(State(app): State, viewer: Viewer, Form(f): Form) -> Result, Error> { let shell = Shell::new(&app, "/settings/backups", &viewer); let me = require(&viewer, &shell)?; csrf(&viewer, &f.csrf_token, &shell)?; run_now(&app, shell, "user", &me.account.username).await } pub async fn org(State(app): State, viewer: Viewer, Path(name): Path) -> Result, Error> { let shell = Shell::new(&app, &format!("/orgs/{name}/settings/backups"), &viewer); let (_, org) = super::orgs::owned_org(&app, &shell, &viewer, &name).await?; page(&app, shell, "org", &org.name, None, None).await } pub async fn org_save( State(app): State, viewer: Viewer, Path(name): Path, Form(f): Form, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/orgs/{name}/settings/backups"), &viewer); let (_, org) = super::orgs::owned_org(&app, &shell, &viewer, &name).await?; csrf(&viewer, &f.csrf_token, &shell)?; save(&app, shell, "org", &org.name, &f).await } pub async fn org_run( State(app): State, viewer: Viewer, Path(name): Path, Form(f): Form, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/orgs/{name}/settings/backups"), &viewer); let (_, org) = super::orgs::owned_org(&app, &shell, &viewer, &name).await?; csrf(&viewer, &f.csrf_token, &shell)?; run_now(&app, shell, "org", &org.name).await } pub async fn user_test(State(app): State, viewer: Viewer, Form(f): Form) -> Result, Error> { let shell = Shell::new(&app, "/settings/backups", &viewer); let me = require(&viewer, &shell)?; csrf(&viewer, &f.csrf_token, &shell)?; test(&app, shell, "user", &me.account.username).await } pub async fn org_test( State(app): State, viewer: Viewer, Path(name): Path, Form(f): Form, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/orgs/{name}/settings/backups"), &viewer); let (_, org) = super::orgs::owned_org(&app, &shell, &viewer, &name).await?; csrf(&viewer, &f.csrf_token, &shell)?; test(&app, shell, "org", &org.name).await } #[derive(Template)] #[template(path = "admin_backups.html")] pub struct AdminPage { shell: Shell, owners: Vec, /// Owners of repositories no backup covers, with how many each has. uncovered: Vec<(String, i64)>, } /// Admin › Backups (docs/backups.md §5): every owner's backup, whether it /// ran and how it went, and who has none. Administrators only. pub async fn admin(State(app): State, viewer: Viewer) -> Result, Error> { let shell = Shell::new(&app, "/admin/backups", &viewer); if !viewer.is_admin() { return Err(Error::not_found(shell)); } let (owners, uncovered) = backups::overview(&app.db).await.map_err(|e| Error::from_db(shell.clone(), e))?; Ok(Page(AdminPage { shell, owners, uncovered })) }