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