OXYGENHUB
# dc59d1b56c3d oxygenhub / crates / hub / src / web / backups.rs
9.4 KiB · Rust · last changed feat: keyless GCS backups, and TEST (closes #169) 22 days ago source blame history raw
1
//! Backups' pages (docs/backups.md §5, oxygenhub#169): a user's under
2
//! Settings › Backups, an organisation's under its settings, each with
3
//! where its repositories go, how often, how long runs are kept, "back up
4
//! now" and the recent runs.
5
6
use super::settings::{csrf, require};
7
use super::{filters, Error, Page, Shell};
8
use crate::auth::Viewer;
9
use crate::backups::{self, Run, Setting};
10
use crate::Shared;
11
use askama::Template;
12
use axum::extract::{Path, State};
13
use axum::Form;
14
use serde::Deserialize;
15
16
#[derive(Template)]
17
#[template(path = "backups.html")]
18
pub struct BackupsPage {
19
    shell: Shell,
20
    /// `user` or `org`.
21
    kind: &'static str,
22
    owner: String,
23
    /// Where the form posts.
24
    base: String,
25
    setting: Setting,
26
    runs: Vec<Run>,
27
    /// The directories this hub allows.
28
    dirs: Vec<String>,
29
    notice: Option<String>,
30
    error: Option<String>,
31
}
32
33
impl BackupsPage {
34
    fn every(&self) -> &'static [(&'static str, &'static str)] {
35
        backups::EVERY
36
    }
37
    fn keeps(&self) -> &'static [i64] {
38
        backups::KEEP
39
    }
40
}
41
42
async fn page(
43
    app: &Shared,
44
    shell: Shell,
45
    kind: &'static str,
46
    owner: &str,
47
    notice: Option<String>,
48
    error: Option<String>,
49
) -> Result<Page<BackupsPage>, Error> {
50
    let e = |e| Error::from_db(shell.clone(), e);
51
    let setting = backups::setting(&app.db, owner).await.map_err(e)?.unwrap_or_else(|| Setting::off(owner, kind));
52
    let runs = backups::runs(&app.db, owner, 50).await.map_err(e)?;
53
    let base = if kind == "org" { format!("/orgs/{owner}/settings/backups") } else { "/settings/backups".into() };
54
    let dirs = app.config.backups.directories.iter().map(|d| d.display().to_string()).collect();
55
    Ok(Page(BackupsPage { shell, kind, owner: owner.into(), base, setting, runs, dirs, notice, error }))
56
}
57
58
#[derive(Deserialize)]
59
pub struct BackupForm {
60
    #[serde(rename = "_csrf")]
61
    csrf_token: String,
62
    every: String,
63
    #[serde(default)]
64
    destination: String,
65
    #[serde(default)]
66
    dir: String,
67
    #[serde(default)]
68
    bucket: String,
69
    #[serde(default)]
70
    provider: String,
71
    #[serde(default)]
72
    prefix: String,
73
    #[serde(default)]
74
    keep_days: String,
75
}
76
77
/// The form as a setting, or why it cannot be one.
78
fn setting_of(app: &Shared, owner: &str, kind: &str, f: &BackupForm) -> Result<Setting, String> {
79
    if !backups::EVERY.iter().any(|(v, _)| *v == f.every) {
80
        return Err("Pick how often: off, hourly or daily.".into());
81
    }
82
    let keep_days: i64 = f.keep_days.parse().unwrap_or(30);
83
    if !backups::KEEP.contains(&keep_days) {
84
        return Err("Keep runs for 7, 30 or 90 days.".into());
85
    }
86
    let prefix = f.prefix.trim().trim_matches('/').to_string();
87
    if prefix.split('/').any(|p| p == ".." || p == ".") {
88
        return Err("The prefix is a plain path, without . or .. parts.".into());
89
    }
90
    let (target, provider) = match f.destination.as_str() {
91
        "dir" => {
92
            if !app.config.backups.directories.iter().any(|d| d.display().to_string() == f.dir) {
93
                return Err("Pick one of the directories this hub allows.".into());
94
            }
95
            (f.dir.clone(), String::new())
96
        }
97
        "gcs" => {
98
            let bucket = f.bucket.trim().trim_start_matches("gs://").trim_end_matches('/').to_string();
99
            if bucket.is_empty() || !bucket.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || "-_.".contains(c)) {
100
                return Err("Give the bucket's name, such as andymccall-oxygen-backup.".into());
101
            }
102
            let provider = f.provider.trim().to_string();
103
            if !provider.starts_with("//iam.googleapis.com/projects/") || !provider.contains("/providers/") {
104
                return Err(
105
                    "Give the workload identity provider's full name, //iam.googleapis.com/projects/…/providers/… (docs/backups.md §6)."
106
                        .into(),
107
                );
108
            }
109
            (bucket, provider)
110
        }
111
        _ => return Err("Pick where backups go.".into()),
112
    };
113
    Ok(Setting {
114
        owner: owner.into(),
115
        kind: kind.into(),
116
        every: f.every.clone(),
117
        destination: f.destination.clone(),
118
        target,
119
        prefix,
120
        provider,
121
        keep_days,
122
        updated_ms: 0,
123
    })
124
}
125
126
async fn save(app: &Shared, shell: Shell, kind: &'static str, owner: &str, f: &BackupForm) -> Result<Page<BackupsPage>, Error> {
127
    match setting_of(app, owner, kind, f) {
128
        Ok(s) => {
129
            backups::save(&app.db, &s).await.map_err(|e| Error::from_db(shell.clone(), e))?;
130
            page(app, shell, kind, owner, Some("Saved.".into()), None).await
131
        }
132
        Err(why) => page(app, shell, kind, owner, None, Some(why)).await,
133
    }
134
}
135
136
async fn run_now(app: &Shared, shell: Shell, kind: &'static str, owner: &str) -> Result<Page<BackupsPage>, Error> {
137
    let set = backups::setting(&app.db, owner).await.map_err(|e| Error::from_db(shell.clone(), e))?;
138
    if set.is_none() {
139
        return page(app, shell, kind, owner, None, Some("Save where backups go first.".into())).await;
140
    }
141
    let (app2, owner2) = (app.clone(), owner.to_string());
142
    tokio::spawn(async move {
143
        if let Err(e) = backups::run(&app2, &owner2, "now").await {
144
            tracing::warn!("backup of {owner2}: {e:#}");
145
        }
146
    });
147
    page(app, shell, kind, owner, Some("Backing up now: the run shows below when it finishes.".into()), None).await
148
}
149
150
/// TEST: reach the saved destination and write, read and remove a probe.
151
async fn test(app: &Shared, shell: Shell, kind: &'static str, owner: &str) -> Result<Page<BackupsPage>, Error> {
152
    let Some(set) = backups::setting(&app.db, owner).await.map_err(|e| Error::from_db(shell.clone(), e))? else {
153
        return page(app, shell, kind, owner, None, Some("Save where backups go first.".into())).await;
154
    };
155
    let outcome = match backups::destination(app, &set) {
156
        Ok(dest) => {
157
            tokio::task::spawn_blocking(move || backups::probe(dest.as_ref())).await.unwrap_or_else(|e| Err(anyhow::anyhow!("{e}")))
158
        }
159
        Err(e) => Err(e),
160
    };
161
    match outcome {
162
        Ok(()) => {
163
            let place = if set.destination == "gcs" { format!("gs://{}", set.target) } else { format!("{}/{owner}", set.target) };
164
            page(app, shell, kind, owner, Some(format!("TEST passed: wrote, read and removed a probe in {place}.")), None).await
165
        }
166
        Err(e) => page(app, shell, kind, owner, None, Some(format!("TEST failed: {e:#}"))).await,
167
    }
168
}
169
170
#[derive(Deserialize)]
171
pub struct Csrf {
172
    #[serde(rename = "_csrf")]
173
    csrf_token: String,
174
}
175
176
pub async fn user(State(app): State<Shared>, viewer: Viewer) -> Result<Page<BackupsPage>, Error> {
177
    let shell = Shell::new(&app, "/settings/backups", &viewer);
178
    let me = require(&viewer, &shell)?;
179
    page(&app, shell, "user", &me.account.username, None, None).await
180
}
181
182
pub async fn user_save(State(app): State<Shared>, viewer: Viewer, Form(f): Form<BackupForm>) -> Result<Page<BackupsPage>, Error> {
183
    let shell = Shell::new(&app, "/settings/backups", &viewer);
184
    let me = require(&viewer, &shell)?;
185
    csrf(&viewer, &f.csrf_token, &shell)?;
186
    save(&app, shell, "user", &me.account.username, &f).await
187
}
188
189
pub async fn user_run(State(app): State<Shared>, viewer: Viewer, Form(f): Form<Csrf>) -> Result<Page<BackupsPage>, Error> {
190
    let shell = Shell::new(&app, "/settings/backups", &viewer);
191
    let me = require(&viewer, &shell)?;
192
    csrf(&viewer, &f.csrf_token, &shell)?;
193
    run_now(&app, shell, "user", &me.account.username).await
194
}
195
196
pub async fn org(State(app): State<Shared>, viewer: Viewer, Path(name): Path<String>) -> Result<Page<BackupsPage>, Error> {
197
    let shell = Shell::new(&app, &format!("/orgs/{name}/settings/backups"), &viewer);
198
    let (_, org) = super::orgs::owned_org(&app, &shell, &viewer, &name).await?;
199
    page(&app, shell, "org", &org.name, None, None).await
200
}
201
202
pub async fn org_save(
203
    State(app): State<Shared>,
204
    viewer: Viewer,
205
    Path(name): Path<String>,
206
    Form(f): Form<BackupForm>,
207
) -> Result<Page<BackupsPage>, Error> {
208
    let shell = Shell::new(&app, &format!("/orgs/{name}/settings/backups"), &viewer);
209
    let (_, org) = super::orgs::owned_org(&app, &shell, &viewer, &name).await?;
210
    csrf(&viewer, &f.csrf_token, &shell)?;
211
    save(&app, shell, "org", &org.name, &f).await
212
}
213
214
pub async fn org_run(
215
    State(app): State<Shared>,
216
    viewer: Viewer,
217
    Path(name): Path<String>,
218
    Form(f): Form<Csrf>,
219
) -> Result<Page<BackupsPage>, Error> {
220
    let shell = Shell::new(&app, &format!("/orgs/{name}/settings/backups"), &viewer);
221
    let (_, org) = super::orgs::owned_org(&app, &shell, &viewer, &name).await?;
222
    csrf(&viewer, &f.csrf_token, &shell)?;
223
    run_now(&app, shell, "org", &org.name).await
224
}
225
226
pub async fn user_test(State(app): State<Shared>, viewer: Viewer, Form(f): Form<Csrf>) -> Result<Page<BackupsPage>, Error> {
227
    let shell = Shell::new(&app, "/settings/backups", &viewer);
228
    let me = require(&viewer, &shell)?;
229
    csrf(&viewer, &f.csrf_token, &shell)?;
230
    test(&app, shell, "user", &me.account.username).await
231
}
232
233
pub async fn org_test(
234
    State(app): State<Shared>,
235
    viewer: Viewer,
236
    Path(name): Path<String>,
237
    Form(f): Form<Csrf>,
238
) -> Result<Page<BackupsPage>, Error> {
239
    let shell = Shell::new(&app, &format!("/orgs/{name}/settings/backups"), &viewer);
240
    let (_, org) = super::orgs::owned_org(&app, &shell, &viewer, &name).await?;
241
    csrf(&viewer, &f.csrf_token, &shell)?;
242
    test(&app, shell, "org", &org.name).await
243
}