OXYGENHUB
◆ v0.7.0 oxygenhub / crates / hub / src / web / backups.rs
11.5 KiB · Rust · last changed Backups API: settings, TEST, back up now and runs 22 days ago source blame history raw
d7d18246 Andy McCall ↶ 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;
1e8a6e8b Andy McCall ↶ 9
use crate::backups::{self, Overview, Run, Setting};
d7d18246 Andy McCall ↶ 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,
2e9188ca Andy McCall ↶ 62
    #[serde(flatten)]
63
    input: SettingInput,
64
}
65
66
/// What a backup setting is made from, by the page's form or the API
67
/// (oxygenhub#179): checked the same way either way.
68
#[derive(Clone, Debug, Default, Deserialize)]
69
pub(crate) struct SettingInput {
70
    pub every: String,
d7d18246 Andy McCall ↶ 71
    #[serde(default)]
2e9188ca Andy McCall ↶ 72
    pub destination: String,
d7d18246 Andy McCall ↶ 73
    #[serde(default)]
2e9188ca Andy McCall ↶ 74
    pub dir: String,
d7d18246 Andy McCall ↶ 75
    #[serde(default)]
2e9188ca Andy McCall ↶ 76
    pub bucket: String,
d7d18246 Andy McCall ↶ 77
    #[serde(default)]
2e9188ca Andy McCall ↶ 78
    pub provider: String,
d7d18246 Andy McCall ↶ 79
    #[serde(default)]
2e9188ca Andy McCall ↶ 80
    pub prefix: String,
d7d18246 Andy McCall ↶ 81
    #[serde(default)]
2e9188ca Andy McCall ↶ 82
    pub keep_days: String,
7b9f4570 Andy McCall ↶ 83
    /// An S3 destination's bucket, region and the IAM role the hub assumes.
84
    #[serde(default)]
2e9188ca Andy McCall ↶ 85
    pub s3_bucket: String,
7b9f4570 Andy McCall ↶ 86
    #[serde(default)]
2e9188ca Andy McCall ↶ 87
    pub s3_region: String,
7b9f4570 Andy McCall ↶ 88
    #[serde(default)]
2e9188ca Andy McCall ↶ 89
    pub s3_role: String,
d7d18246 Andy McCall ↶ 90
}
91
92
/// The form as a setting, or why it cannot be one.
2e9188ca Andy McCall ↶ 93
pub(crate) fn setting_of(app: &Shared, owner: &str, kind: &str, f: &SettingInput) -> Result<Setting, String> {
d7d18246 Andy McCall ↶ 94
    if !backups::EVERY.iter().any(|(v, _)| *v == f.every) {
95
        return Err("Pick how often: off, hourly or daily.".into());
96
    }
97
    let keep_days: i64 = f.keep_days.parse().unwrap_or(30);
98
    if !backups::KEEP.contains(&keep_days) {
99
        return Err("Keep runs for 7, 30 or 90 days.".into());
100
    }
101
    let prefix = f.prefix.trim().trim_matches('/').to_string();
102
    if prefix.split('/').any(|p| p == ".." || p == ".") {
103
        return Err("The prefix is a plain path, without . or .. parts.".into());
104
    }
7b9f4570 Andy McCall ↶ 105
    let mut region = String::new();
d7d18246 Andy McCall ↶ 106
    let (target, provider) = match f.destination.as_str() {
107
        "dir" => {
108
            if !app.config.backups.directories.iter().any(|d| d.display().to_string() == f.dir) {
109
                return Err("Pick one of the directories this hub allows.".into());
110
            }
111
            (f.dir.clone(), String::new())
7b9f4570 Andy McCall ↶ 112
        }
113
        "s3" => {
114
            let bucket = f.s3_bucket.trim().trim_start_matches("s3://").trim_end_matches('/').to_string();
115
            if bucket.is_empty() || !bucket.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || "-.".contains(c)) {
116
                return Err("Give the S3 bucket's name, such as andymccall-oxygen-backup.".into());
117
            }
118
            region = f.s3_region.trim().to_string();
119
            if region.is_empty() || !region.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') {
120
                return Err("Give the bucket's region, such as eu-west-2.".into());
121
            }
122
            let role = f.s3_role.trim().to_string();
123
            if !role.starts_with("arn:aws:iam::") || !role.contains(":role/") {
124
                return Err("Give the IAM role's ARN, arn:aws:iam::ACCOUNT:role/NAME (docs/backups.md §6).".into());
125
            }
126
            (bucket, role)
d7d18246 Andy McCall ↶ 127
        }
128
        "gcs" => {
129
            let bucket = f.bucket.trim().trim_start_matches("gs://").trim_end_matches('/').to_string();
130
            if bucket.is_empty() || !bucket.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || "-_.".contains(c)) {
131
                return Err("Give the bucket's name, such as andymccall-oxygen-backup.".into());
132
            }
133
            let provider = f.provider.trim().to_string();
134
            if !provider.starts_with("//iam.googleapis.com/projects/") || !provider.contains("/providers/") {
135
                return Err(
136
                    "Give the workload identity provider's full name, //iam.googleapis.com/projects/…/providers/… (docs/backups.md §6)."
137
                        .into(),
138
                );
139
            }
140
            (bucket, provider)
141
        }
142
        _ => return Err("Pick where backups go.".into()),
143
    };
144
    Ok(Setting {
145
        owner: owner.into(),
146
        kind: kind.into(),
147
        every: f.every.clone(),
148
        destination: f.destination.clone(),
149
        target,
150
        prefix,
151
        provider,
152
        keep_days,
153
        updated_ms: 0,
7b9f4570 Andy McCall ↶ 154
        region,
d7d18246 Andy McCall ↶ 155
    })
156
}
157
158
async fn save(app: &Shared, shell: Shell, kind: &'static str, owner: &str, f: &BackupForm) -> Result<Page<BackupsPage>, Error> {
2e9188ca Andy McCall ↶ 159
    match setting_of(app, owner, kind, &f.input) {
d7d18246 Andy McCall ↶ 160
        Ok(s) => {
161
            backups::save(&app.db, &s).await.map_err(|e| Error::from_db(shell.clone(), e))?;
162
            page(app, shell, kind, owner, Some("Saved.".into()), None).await
163
        }
164
        Err(why) => page(app, shell, kind, owner, None, Some(why)).await,
165
    }
166
}
167
168
async fn run_now(app: &Shared, shell: Shell, kind: &'static str, owner: &str) -> Result<Page<BackupsPage>, Error> {
169
    let set = backups::setting(&app.db, owner).await.map_err(|e| Error::from_db(shell.clone(), e))?;
170
    if set.is_none() {
171
        return page(app, shell, kind, owner, None, Some("Save where backups go first.".into())).await;
172
    }
173
    let (app2, owner2) = (app.clone(), owner.to_string());
174
    tokio::spawn(async move {
175
        if let Err(e) = backups::run(&app2, &owner2, "now").await {
176
            tracing::warn!("backup of {owner2}: {e:#}");
177
        }
178
    });
179
    page(app, shell, kind, owner, Some("Backing up now: the run shows below when it finishes.".into()), None).await
180
}
181
35fea423 Andy McCall ↶ 182
/// TEST: reach the saved destination and write, read and remove a probe.
183
async fn test(app: &Shared, shell: Shell, kind: &'static str, owner: &str) -> Result<Page<BackupsPage>, Error> {
184
    let Some(set) = backups::setting(&app.db, owner).await.map_err(|e| Error::from_db(shell.clone(), e))? else {
185
        return page(app, shell, kind, owner, None, Some("Save where backups go first.".into())).await;
186
    };
187
    let outcome = match backups::destination(app, &set) {
188
        Ok(dest) => {
189
            tokio::task::spawn_blocking(move || backups::probe(dest.as_ref())).await.unwrap_or_else(|e| Err(anyhow::anyhow!("{e}")))
190
        }
191
        Err(e) => Err(e),
192
    };
193
    match outcome {
194
        Ok(()) => {
2e9188ca Andy McCall ↶ 195
            let place = backups::place(&set);
35fea423 Andy McCall ↶ 196
            page(app, shell, kind, owner, Some(format!("TEST passed: wrote, read and removed a probe in {place}.")), None).await
197
        }
198
        Err(e) => page(app, shell, kind, owner, None, Some(format!("TEST failed: {e:#}"))).await,
199
    }
200
}
201
d7d18246 Andy McCall ↶ 202
#[derive(Deserialize)]
203
pub struct Csrf {
204
    #[serde(rename = "_csrf")]
205
    csrf_token: String,
206
}
207
208
pub async fn user(State(app): State<Shared>, viewer: Viewer) -> Result<Page<BackupsPage>, Error> {
209
    let shell = Shell::new(&app, "/settings/backups", &viewer);
210
    let me = require(&viewer, &shell)?;
211
    page(&app, shell, "user", &me.account.username, None, None).await
212
}
213
214
pub async fn user_save(State(app): State<Shared>, viewer: Viewer, Form(f): Form<BackupForm>) -> Result<Page<BackupsPage>, Error> {
215
    let shell = Shell::new(&app, "/settings/backups", &viewer);
216
    let me = require(&viewer, &shell)?;
217
    csrf(&viewer, &f.csrf_token, &shell)?;
218
    save(&app, shell, "user", &me.account.username, &f).await
219
}
220
221
pub async fn user_run(State(app): State<Shared>, viewer: Viewer, Form(f): Form<Csrf>) -> Result<Page<BackupsPage>, Error> {
222
    let shell = Shell::new(&app, "/settings/backups", &viewer);
223
    let me = require(&viewer, &shell)?;
224
    csrf(&viewer, &f.csrf_token, &shell)?;
225
    run_now(&app, shell, "user", &me.account.username).await
226
}
227
228
pub async fn org(State(app): State<Shared>, viewer: Viewer, Path(name): Path<String>) -> Result<Page<BackupsPage>, Error> {
229
    let shell = Shell::new(&app, &format!("/orgs/{name}/settings/backups"), &viewer);
230
    let (_, org) = super::orgs::owned_org(&app, &shell, &viewer, &name).await?;
231
    page(&app, shell, "org", &org.name, None, None).await
232
}
233
234
pub async fn org_save(
235
    State(app): State<Shared>,
236
    viewer: Viewer,
237
    Path(name): Path<String>,
238
    Form(f): Form<BackupForm>,
239
) -> Result<Page<BackupsPage>, Error> {
240
    let shell = Shell::new(&app, &format!("/orgs/{name}/settings/backups"), &viewer);
241
    let (_, org) = super::orgs::owned_org(&app, &shell, &viewer, &name).await?;
242
    csrf(&viewer, &f.csrf_token, &shell)?;
243
    save(&app, shell, "org", &org.name, &f).await
244
}
245
246
pub async fn org_run(
247
    State(app): State<Shared>,
248
    viewer: Viewer,
249
    Path(name): Path<String>,
250
    Form(f): Form<Csrf>,
251
) -> Result<Page<BackupsPage>, Error> {
252
    let shell = Shell::new(&app, &format!("/orgs/{name}/settings/backups"), &viewer);
253
    let (_, org) = super::orgs::owned_org(&app, &shell, &viewer, &name).await?;
254
    csrf(&viewer, &f.csrf_token, &shell)?;
255
    run_now(&app, shell, "org", &org.name).await
35fea423 Andy McCall ↶ 256
}
257
258
pub async fn user_test(State(app): State<Shared>, viewer: Viewer, Form(f): Form<Csrf>) -> Result<Page<BackupsPage>, Error> {
259
    let shell = Shell::new(&app, "/settings/backups", &viewer);
260
    let me = require(&viewer, &shell)?;
261
    csrf(&viewer, &f.csrf_token, &shell)?;
262
    test(&app, shell, "user", &me.account.username).await
263
}
264
265
pub async fn org_test(
266
    State(app): State<Shared>,
267
    viewer: Viewer,
268
    Path(name): Path<String>,
269
    Form(f): Form<Csrf>,
270
) -> Result<Page<BackupsPage>, Error> {
271
    let shell = Shell::new(&app, &format!("/orgs/{name}/settings/backups"), &viewer);
272
    let (_, org) = super::orgs::owned_org(&app, &shell, &viewer, &name).await?;
273
    csrf(&viewer, &f.csrf_token, &shell)?;
274
    test(&app, shell, "org", &org.name).await
d7d18246 Andy McCall ↶ 275
}
1e8a6e8b Andy McCall ↶ 276
277
#[derive(Template)]
278
#[template(path = "admin_backups.html")]
279
pub struct AdminPage {
280
    shell: Shell,
281
    owners: Vec<Overview>,
282
    /// Owners of repositories no backup covers, with how many each has.
283
    uncovered: Vec<(String, i64)>,
284
}
285
286
/// Admin › Backups (docs/backups.md §5): every owner's backup, whether it
287
/// ran and how it went, and who has none. Administrators only.
288
pub async fn admin(State(app): State<Shared>, viewer: Viewer) -> Result<Page<AdminPage>, Error> {
289
    let shell = Shell::new(&app, "/admin/backups", &viewer);
290
    if !viewer.is_admin() {
291
        return Err(Error::not_found(shell));
292
    }
293
    let (owners, uncovered) = backups::overview(&app.db).await.map_err(|e| Error::from_db(shell.clone(), e))?;
294
    Ok(Page(AdminPage { shell, owners, uncovered }))
295
}