forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
18.7 KiB
1//! Page: backups (`/admin/backups`). Instance admins only; anyone else gets 404.
2//!
3//! Shows the `[backup]` settings (the healthcheck URL only as its host, since it may hold a token),
4//! the last 20 backup and fsck runs, and two POST buttons that start a run in the background.
5//! A button press is audited and redirects back here with a notice.
6
7use forge_core::store::backup_runs::{BackupRun, RunKind};
8use forge_core::tz::format_in_tz;
9use forge_core::{AuditAction, Error, User};
10use serde::Deserialize;
11use topcoat::Result;
12use topcoat::context::{Cx, app_context};
13use topcoat::router::content::Form;
14use topcoat::router::error::{forbidden, see_other};
15use topcoat::router::response::Response;
16use topcoat::router::{page, query_params, route};
17use topcoat::view::{View, component, view};
18
19use crate::audit::{self, Actor, Target};
20use crate::auth::check_csrf;
21use crate::backup::scheduler::{configured_dir, start_manual};
22use crate::i18n::admin::{self as g, K};
23use crate::i18n::{self, Lang};
24use crate::pages::admin_users::admin_only;
25use crate::pages::issues_list::stamp_unix;
26use crate::state::AppState;
27use crate::views::{enc, shell};
28
29/// Runs shown on the page.
30const RUN_LIMIT: u32 = 20;
31
32#[query_params(error = bad_request)]
33pub(crate) struct NoticeQuery {
34 notice: Option<String>,
35}
36
37#[derive(Deserialize)]
38struct RunForm {
39 #[serde(default)]
40 csrf: String,
41}
42
43/// One table row, already formatted.
44struct RunRow {
45 kind: String,
46 trigger: String,
47 started: String,
48 duration: String,
49 size: String,
50 status: String,
51 detail: String,
52}
53
54/// The settings block, already formatted.
55struct Settings {
56 enabled: bool,
57 dir: String,
58 interval: String,
59 keep: String,
60 fsck_interval: String,
61 healthcheck: String,
62}
63
64fn settings_view(lang: Lang, config: &forge_core::config::BackupConfig) -> Settings {
65 let healthcheck = if config.healthcheck_url.is_empty() {
66 g::t(lang, K::HealthcheckNotSet).to_owned()
67 } else {
68 match reqwest::Url::parse(&config.healthcheck_url) {
69 Ok(url) => g::healthcheck_host(lang, url.host_str()),
70 Err(_) => g::t(lang, K::HealthcheckUrlInvalid).to_owned(),
71 }
72 };
73 Settings {
74 enabled: config.enabled(),
75 dir: config.dir.display().to_string(),
76 interval: g::every_hours(lang, config.interval_hours),
77 keep: g::newest_sets(lang, config.keep),
78 fsck_interval: g::every_days(lang, config.fsck_interval_days),
79 healthcheck,
80 }
81}
82
83/// `"512 B"`, `"12.3 KiB"`, `"4.0 MiB"` and so on.
84fn human_bytes(bytes: u64) -> String {
85 const UNITS: [&str; 4] = ["KiB", "MiB", "GiB", "TiB"];
86 if bytes < 1024 {
87 return format!("{bytes} B");
88 }
89 let mut value = bytes as f64 / 1024.0;
90 let mut unit = 0;
91 while value >= 1024.0 && unit + 1 < UNITS.len() {
92 value /= 1024.0;
93 unit += 1;
94 }
95 format!("{value:.1} {}", UNITS[unit])
96}
97
98fn duration_text(run: &BackupRun) -> String {
99 let start = stamp_unix(&run.started_at);
100 let Some(end) = run.finished_at.as_deref().map(stamp_unix) else {
101 return "-".to_owned();
102 };
103 let secs = (end - start).max(0);
104 if secs < 60 {
105 format!("{secs}s")
106 } else {
107 format!("{}m {}s", secs / 60, secs % 60)
108 }
109}
110
111fn run_row(run: &BackupRun, tz: &str, lang: Lang) -> RunRow {
112 let (status, detail) = match (run.finished_at.is_some(), run.ok) {
113 (false, _) => (g::t(lang, K::StatusInProgress).to_owned(), String::new()),
114 (true, Some(true)) => (g::t(lang, K::StatusOk).to_owned(), String::new()),
115 (true, _) => (
116 g::t(lang, K::StatusFailed).to_owned(),
117 run.error.clone().unwrap_or_default(),
118 ),
119 };
120 RunRow {
121 kind: g::kind_name(lang, run.kind).to_owned(),
122 trigger: g::trigger_name(lang, run.trigger).to_owned(),
123 started: format_in_tz(stamp_unix(&run.started_at), tz),
124 duration: duration_text(run),
125 size: run.bytes.map(human_bytes).unwrap_or_default(),
126 status,
127 detail,
128 }
129}
130
131#[component]
132async fn backups_view(
133 lang: Lang,
134 csrf: String,
135 settings: Settings,
136 rows: Vec<RunRow>,
137 running: Option<RunKind>,
138 notice: Option<String>,
139) -> Result<impl View> {
140 Ok(view! {
141 <section class="page-head">
142 <h1>(g::t(lang, K::Backups))</h1>
143 <p class="muted">(g::t(lang, K::BackupsIntro))</p>
144 </section>
145 if let Some(message) = notice {
146 <section class="card notice" role="status">
147 <p>(message.as_str())</p>
148 </section>
149 }
150 <section class="card">
151 <h2>(g::t(lang, K::Settings))</h2>
152 if settings.enabled {
153 <dl class="details">
154 <dt>(g::t(lang, K::Directory))</dt><dd><code>(settings.dir.as_str())</code></dd>
155 <dt>(g::t(lang, K::BackupInterval))</dt><dd>(settings.interval.as_str())</dd>
156 <dt>(g::t(lang, K::Retention))</dt><dd>(settings.keep.as_str())</dd>
157 <dt>(g::t(lang, K::Fsck))</dt><dd>(settings.fsck_interval.as_str())</dd>
158 <dt>(g::t(lang, K::Healthcheck))</dt><dd>(settings.healthcheck.as_str())</dd>
159 </dl>
160 } else {
161 <p>(g::t(lang, K::ScheduledBackupsOff))</p>
162 }
163 if let Some(kind) = running {
164 <p class="muted">(g::running_line(lang, kind))</p>
165 }
166 <div class="actions">
167 <form method="post" action="/admin/backups/run">
168 <input type="hidden" name="csrf" value=(csrf.as_str())>
169 <button type="submit" class="primary">(g::t(lang, K::RunBackupNow))</button>
170 </form>
171 <form method="post" action="/admin/backups/fsck">
172 <input type="hidden" name="csrf" value=(csrf.as_str())>
173 <button type="submit">(g::t(lang, K::RunFsckNow))</button>
174 </form>
175 </div>
176 </section>
177 <section class="card">
178 <h2>(g::t(lang, K::RecentRuns))</h2>
179 if rows.is_empty() {
180 <p class="muted">(g::t(lang, K::NoRunsYet))</p>
181 } else {
182 <div class="table-wrap">
183 <table class="table">
184 <thead>
185 <tr>
186 <th>(g::t(lang, K::TableKind))</th>
187 <th>(g::t(lang, K::TableTrigger))</th>
188 <th>(g::t(lang, K::TableStarted))</th>
189 <th>(g::t(lang, K::TableDuration))</th>
190 <th>(g::t(lang, K::TableSize))</th>
191 <th>(g::t(lang, K::TableResult))</th>
192 </tr>
193 </thead>
194 <tbody>
195 for row in rows.into_iter() {
196 <tr>
197 <td>(row.kind.as_str())</td>
198 <td>(row.trigger.as_str())</td>
199 <td class="small">(row.started.as_str())</td>
200 <td class="small">(row.duration.as_str())</td>
201 <td class="small">(row.size.as_str())</td>
202 <td>
203 (row.status.as_str())
204 if !row.detail.is_empty() {
205 <div class="small muted">(row.detail.as_str())</div>
206 }
207 </td>
208 </tr>
209 }
210 </tbody>
211 </table>
212 </div>
213 }
214 </section>
215 })
216}
217
218#[page("/admin/backups")]
219async fn backups_page(cx: &Cx) -> Result<impl View> {
220 let viewer = admin_only(cx).await?;
221 let query = query_params::<NoticeQuery>(cx)?;
222 let state = app_context::<AppState>(cx);
223 let lang = i18n::lang(cx, Some(&viewer.0));
224 let runs = state.store.recent_runs(RUN_LIMIT).await?;
225 let tz = viewer.0.timezone.as_str();
226 let rows: Vec<RunRow> = runs.iter().map(|run| run_row(run, tz, lang)).collect();
227 let running = state.jobs.running();
228 let settings = settings_view(lang, &state.config.backup);
229 let csrf = viewer.1.csrf_token.clone();
230 let title = g::t(lang, K::Backups);
231 Ok(view! {
232 shell(title: title, viewer: Some(&viewer), backups_view(lang: lang, csrf: csrf, settings: settings, rows: rows, running: running, notice: query.notice.clone()))
233 })
234}
235
236#[route(POST "/admin/backups/run")]
237async fn backups_run(cx: &Cx, Form(form): Form<RunForm>) -> Result<Response> {
238 start_run(cx, form.csrf, RunKind::Backup).await
239}
240
241#[route(POST "/admin/backups/fsck")]
242async fn backups_fsck(cx: &Cx, Form(form): Form<RunForm>) -> Result<Response> {
243 start_run(cx, form.csrf, RunKind::Fsck).await
244}
245
246/// Starts a run in the background, audits the request and redirects back with a notice.
247async fn start_run(cx: &Cx, csrf: String, kind: RunKind) -> Result<Response> {
248 let viewer = admin_only(cx).await?;
249 if !check_csrf(&viewer.1, &csrf) {
250 return Err(forbidden().into());
251 }
252 let lang = i18n::lang(cx, Some(&viewer.0));
253 let state = app_context::<AppState>(cx);
254 let notice = match launch(cx, state, &viewer.0, kind, lang).await {
255 Ok(()) => g::started_notice(lang, kind),
256 Err(e) => match e {
257 Error::Conflict(message) => message,
258 other => other.to_string(),
259 },
260 };
261 let location = format!("/admin/backups?notice={}", enc(&notice));
262 Err(see_other(&location).into())
263}
264
265/// Claims the run slot, records the audit entry and starts the run. Refusals come back as errors.
266async fn launch(
267 cx: &Cx,
268 state: &AppState,
269 user: &User,
270 kind: RunKind,
271 lang: Lang,
272) -> forge_core::Result<()> {
273 if !state.config.backup.enabled() {
274 return Err(Error::Config(
275 g::t(lang, K::ErrBackupsNotConfigured).to_owned(),
276 ));
277 }
278 // Refuse a bad directory here, not in the background task.
279 configured_dir(state)?;
280 let guard = state.jobs.claim(kind)?;
281 let (action, target_kind) = match kind {
282 RunKind::Backup => (AuditAction::BackupStarted, "backup"),
283 RunKind::Fsck => (AuditAction::FsckStarted, "fsck"),
284 };
285 audit::record(
286 &state.store,
287 Some(cx),
288 Actor::User(user),
289 action,
290 Target {
291 kind: target_kind,
292 id: "",
293 name: "",
294 },
295 serde_json::json!({ "trigger": "manual" }),
296 )
297 .await;
298 start_manual(state, guard)
299}
300
301#[cfg(test)]
302mod tests {
303 use super::*;
304 use forge_core::model::Visibility;
305 use forge_core::store::backup_runs::RunTrigger;
306 use topcoat::router::request::Request;
307 use topcoat::router::{Body, Router, to_bytes};
308
309 use crate::auth::SESSION_COOKIE;
310 use crate::test_support::{app_router, create_session, test_state};
311
312 const ADMIN_TOKEN: &str = "backups-admin-session";
313 const USER_TOKEN: &str = "backups-user-session";
314 const CSRF: &str = "backups-csrf";
315
316 struct Fixture {
317 _dir: tempfile::TempDir,
318 router: Router,
319 state: AppState,
320 }
321
322 /// An instance with an admin, a plain user, one repo, and backups configured under the temp
323 /// directory.
324 async fn fixture(configured: bool) -> Fixture {
325 let dir = tempfile::tempdir().expect("tempdir");
326 let data_dir = dir.path().join("data");
327 std::fs::create_dir_all(&data_dir).expect("data dir");
328 let mut state = test_state(&data_dir).await;
329 let mut config = crate::test_support::test_config(&data_dir);
330 if configured {
331 config.backup.dir = dir.path().join("out");
332 config.backup.keep = 2;
333 }
334 state.config = std::sync::Arc::new(config);
335 let admin = state
336 .store
337 .create_user("root", None, "h", true)
338 .await
339 .expect("admin");
340 let user = state
341 .store
342 .create_user("plain", None, "h", false)
343 .await
344 .expect("user");
345 create_session(&state.store, &admin.id, ADMIN_TOKEN, CSRF).await;
346 create_session(&state.store, &user.id, USER_TOKEN, CSRF).await;
347 let repo = state
348 .store
349 .create_repo(&admin.id, "demo", "", Visibility::Private, "main")
350 .await
351 .expect("repo");
352 // fsck reads the repo from disk, so it must exist for the run to pass.
353 let path = forge_git::repo::repo_path(&data_dir, &repo.id).expect("path");
354 forge_git::repo::init_bare(&state.git, &path, "main")
355 .await
356 .expect("init");
357 let router = app_router(state.clone());
358 Fixture {
359 _dir: dir,
360 router,
361 state,
362 }
363 }
364
365 async fn send(
366 router: &Router,
367 method: &str,
368 uri: &str,
369 token: Option<&str>,
370 form: Option<String>,
371 ) -> (u16, String) {
372 let mut builder = Request::builder().method(method).uri(uri);
373 if let Some(token) = token {
374 builder = builder.header("cookie", format!("__Host-{SESSION_COOKIE}={token}"));
375 }
376 let body = match form {
377 Some(form) => {
378 builder = builder.header("content-type", "application/x-www-form-urlencoded");
379 Body::from(form)
380 }
381 None => Body::empty(),
382 };
383 let res = router.handle(builder.body(body).expect("request")).await;
384 let status = res.status().as_u16();
385 let bytes = to_bytes(res.into_body(), usize::MAX).await.expect("body");
386 (status, String::from_utf8_lossy(&bytes).into_owned())
387 }
388
389 #[tokio::test]
390 async fn page_is_404_for_anonymous_and_non_admins() {
391 let fx = fixture(true).await;
392 let (anon, _) = send(&fx.router, "GET", "/admin/backups", None, None).await;
393 assert_eq!(anon, 404);
394 let (user, _) = send(&fx.router, "GET", "/admin/backups", Some(USER_TOKEN), None).await;
395 assert_eq!(user, 404);
396 let (posted, _) = send(
397 &fx.router,
398 "POST",
399 "/admin/backups/run",
400 Some(USER_TOKEN),
401 Some(format!("csrf={CSRF}")),
402 )
403 .await;
404 assert_eq!(posted, 404);
405 }
406
407 #[tokio::test]
408 async fn page_lists_runs_and_hides_the_healthcheck_token() {
409 let fx = fixture(true).await;
410 let mut config = (*fx.state.config).clone();
411 config.backup.healthcheck_url = "https://hc.example.test/ping/SECRET-TOKEN".into();
412 let mut state = fx.state.clone();
413 state.config = std::sync::Arc::new(config);
414 let router = app_router(state.clone());
415
416 let id = state
417 .store
418 .start_run(RunKind::Backup, RunTrigger::Schedule)
419 .await
420 .expect("start");
421 state
422 .store
423 .finish_run(&id, false, None, None, Some("disk full"))
424 .await
425 .expect("finish");
426
427 let (status, body) = send(&router, "GET", "/admin/backups", Some(ADMIN_TOKEN), None).await;
428 assert_eq!(status, 200);
429 assert!(body.contains("schedule"));
430 assert!(body.contains("disk full"));
431 assert!(body.contains("hc.example.test"));
432 assert!(
433 !body.contains("SECRET-TOKEN"),
434 "the healthcheck token must not be shown"
435 );
436 assert!(body.contains("Run backup now"));
437 assert!(body.contains("Run fsck now"));
438 }
439
440 #[tokio::test]
441 async fn post_starts_a_run_audits_it_and_records_it() {
442 let fx = fixture(true).await;
443 // Refused without the CSRF token.
444 let (bad, _) = send(
445 &fx.router,
446 "POST",
447 "/admin/backups/fsck",
448 Some(ADMIN_TOKEN),
449 Some("csrf=wrong".into()),
450 )
451 .await;
452 assert_eq!(bad, 403);
453
454 let (status, _) = send(
455 &fx.router,
456 "POST",
457 "/admin/backups/fsck",
458 Some(ADMIN_TOKEN),
459 Some(format!("csrf={CSRF}")),
460 )
461 .await;
462 assert_eq!(status, 303);
463
464 // Wait for the background fsck to finish and record itself.
465 let mut runs = Vec::new();
466 for _ in 0..500 {
467 runs = fx.state.store.recent_runs(5).await.expect("runs");
468 if runs.iter().any(|r| r.finished_at.is_some()) {
469 break;
470 }
471 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
472 }
473 assert_eq!(runs.len(), 1, "{runs:?}");
474 assert_eq!(runs[0].kind, RunKind::Fsck);
475 assert_eq!(runs[0].trigger, RunTrigger::Manual);
476 assert_eq!(runs[0].ok, Some(true));
477
478 let audit = fx
479 .state
480 .store
481 .list_audit(&Default::default(), None, 10)
482 .await
483 .expect("audit");
484 assert!(audit.iter().any(|e| e.action == AuditAction::FsckStarted));
485
486 let (_, page) = send(&fx.router, "GET", "/admin/backups", Some(ADMIN_TOKEN), None).await;
487 assert!(page.contains("fsck"));
488 }
489
490 #[tokio::test]
491 async fn a_backup_run_while_one_is_active_says_already_running() {
492 let fx = fixture(true).await;
493 let _held = fx.state.jobs.claim(RunKind::Backup).expect("claim");
494 let (status, _) = send(
495 &fx.router,
496 "POST",
497 "/admin/backups/run",
498 Some(ADMIN_TOKEN),
499 Some(format!("csrf={CSRF}")),
500 )
501 .await;
502 assert_eq!(status, 303);
503 let (_, page) = send(
504 &fx.router,
505 "GET",
506 "/admin/backups?notice=already%20running%3A%20x",
507 Some(ADMIN_TOKEN),
508 None,
509 )
510 .await;
511 assert!(page.contains("already running"));
512 assert!(
513 fx.state
514 .store
515 .recent_runs(5)
516 .await
517 .expect("runs")
518 .is_empty()
519 );
520 }
521
522 #[tokio::test]
523 async fn unconfigured_instance_refuses_manual_runs() {
524 let fx = fixture(false).await;
525 let (status, _) = send(
526 &fx.router,
527 "POST",
528 "/admin/backups/run",
529 Some(ADMIN_TOKEN),
530 Some(format!("csrf={CSRF}")),
531 )
532 .await;
533 assert_eq!(status, 303);
534 assert!(
535 fx.state
536 .store
537 .recent_runs(5)
538 .await
539 .expect("runs")
540 .is_empty()
541 );
542 assert_eq!(fx.state.jobs.running(), None);
543 }
544
545 #[test]
546 fn human_bytes_scales() {
547 assert_eq!(human_bytes(512), "512 B");
548 assert_eq!(human_bytes(2048), "2.0 KiB");
549 assert_eq!(human_bytes(3 * 1024 * 1024), "3.0 MiB");
550 }
551}