backups.ts

7.9 KB · raw

1import type { Context, Hono } from "hono";2import { html } from "../pages/html.ts";3import { formatSize } from "../pages/layout.ts";4import {5	attemptBackup,6	attemptUpload,7	backupBytes,8	backups,9	backupTarball,10	checkBackupBucket,11	makeBackup,12	offsiteBackups,13} from "../server/backup.ts";14import { type BackupBucket, saveConfig } from "../server/config.ts";15import { setting } from "../server/db.ts";16import { s3FieldsProblem } from "./bucket.ts";17import { type AdminContext, adminPage, formField, notice, textFields } from "./console.ts";1819// The backup bucket's fields shown back to the operator; its secret is never among them.20const SHOWN: [key: string, label: string][] = [21	["endpoint", "endpoint"],22	["name", "bucket name"],23	["region", "region"],24	["access_key_id", "access key ID"],25];26const REQUIRED = new Set(SHOWN.map(([key]) => key));2728/** `20260926T031500Z` as `2026-09-26 03:15:00 UTC`. */29function backupTime(name: string): string {30	return name.replace(/^(\d{4})(\d\d)(\d\d)T(\d\d)(\d\d)(\d\d)Z$/, "$1-$2-$3 $4:$5:$6 UTC");31}3233type Shown = { error?: string; status?: string; fields?: Record<string, string> };3435/**36 * §24's Backups section: every kept backup, back up now, the last failure, how to restore, and the37 * optional backup bucket (June, 2026-09-30) with what it holds.38 */39export async function backupsPage(c: Context, ctx: AdminContext, shown: Shown = {}) {40	const kept = backups(ctx.dataDir).reverse();41	const newest = kept[0];42	const failure = setting(ctx.kepr, "backup_error");43	const body = html`${notice(shown.error ?? "")}44${shown.status && html`<p class="admin-notice" role="status">${shown.status}</p>`}45${failure && !shown.error && html`<p class="issues-error mono" role="alert">The last backup failed: ${failure}</p>`}46${47	newest48		? html`<p>Last backup: ${backupTime(newest)}. <a href="/admin/backup">download backup</a></p>`49		: html`<p>No backup yet.</p>`50}51<form method="post" action="/admin/backup">${formField(c)}52<button class="issues-submit" type="submit">back up now</button></form>53<p>Kepr keeps the last 14 daily backups of its database and assets.</p>54<ul class="admin-releases mono">${kept.map(55		(name) => html`<li><a href="/admin/backup?name=${name}">${backupTime(name)}</a>56<span class="muted">${formatSize(backupBytes(ctx.dataDir, name))}</span></li>`,57	)}</ul>58${await bucketSection(c, ctx, shown.fields)}59<section class="repo-section"><h2 class="section-label mono">restoring one</h2>60<div class="markdown"><ol>61<li>Stop Kepr.</li>62<li>Unpack the download: it holds a folder with kepr.db and assets/.</li>63<li>Put kepr.db and assets/ in Kepr's data directory, over the old ones.</li>64<li>Start Kepr. Its first sync rebuilds the mirrors, and everyone signs in again.</li>65</ol>66<p class="muted">Sessions and bucket credentials are not included: after restoring onto a new67server, enter the bucket settings again here.</p></div></section>`;68	return adminPage(c, ctx, "backups", body);69}7071/** The backup bucket's form, its last upload and what it holds. */72async function bucketSection(c: Context, ctx: AdminContext, fields?: Record<string, string>) {73	const current = ctx.config.backup;74	const stored: Record<string, string> = {75		endpoint: current?.endpoint ?? "",76		name: current?.name ?? "",77		region: current?.region ?? "",78		access_key_id: current?.accessKeyId ?? "",79	};80	const value = (key: string) => fields?.[key] ?? stored[key] ?? "";81	const uploadError = setting(ctx.kepr, "backup_upload_error");82	let held: Awaited<ReturnType<typeof offsiteBackups>> | string = [];83	if (current) {84		try {85			held = (await offsiteBackups(current)).reverse();86		} catch (error) {87			held = `Could not list the backup bucket: ${String(error)}`;88		}89	}90	return html`<section class="repo-section"><h2 class="section-label mono">backup bucket</h2>91<p class="admin-intro">An optional second S3 bucket, apart from the one Koh offers to. Each backup is92copied there too, and the newest 14 kept. Its key needs to read, write and delete.</p>93${uploadError && html`<p class="issues-error mono" role="alert">The last upload to the backup bucket failed: ${uploadError}</p>`}94${typeof held === "string" && html`<p class="issues-error mono">${held}</p>`}95${96	Array.isArray(held) &&97	held.length > 0 &&98	html`<p class="muted">${String(held.length)} in the backup bucket:</p>99<ul class="admin-releases mono">${held.map(100		(backup) =>101			html`<li>${backupTime(backup.name)} <span class="muted">${formatSize(backup.size)}</span></li>`,102	)}</ul>`103}104<form class="issues-form" method="post" action="/admin/backup-bucket">105${formField(c)}106${SHOWN.map(107	([key, label]) => html`<label class="issues-label" for="backup-${key}">${label}</label>108<input class="issues-input mono" id="backup-${key}" name="${key}" value="${value(key)}"109autocomplete="off" spellcheck="false">`,110)}111<label class="issues-label" for="backup-secret_access_key">secret key</label>112<input class="issues-input mono" id="backup-secret_access_key" name="secret_access_key"113type="password" autocomplete="new-password" placeholder="${current ? "unchanged" : ""}">114<div class="issues-actions">115<button class="issues-submit" type="submit" name="action" value="test">test connection</button>116<button class="issues-submit" type="submit" name="action" value="save">save</button>117${current && html`<button class="issues-submit" type="submit" name="action" value="remove">remove</button>`}118</div>119</form></section>`;120}121122/** The backup bucket a form describes, a blank secret keeping the stored one; or what is wrong. */123function candidate(fields: Record<string, string>, current?: BackupBucket): BackupBucket | string {124	const problem = s3FieldsProblem(fields, SHOWN, REQUIRED);125	if (problem) return problem;126	const value = (key: string) => (fields[key] ?? "").trim();127	const secretAccessKey = value("secret_access_key") || current?.secretAccessKey;128	if (!secretAccessKey) return "The secret key is required.";129	return {130		endpoint: value("endpoint"),131		name: value("name"),132		region: value("region"),133		accessKeyId: value("access_key_id"),134		secretAccessKey,135	};136}137138export function registerBackups(app: Hono, ctx: AdminContext): void {139	app.get("/admin/backup", (c) => {140		const backup = backupTarball(ctx.dataDir, c.req.query("name"));141		if (!backup) return c.notFound();142		return c.body(backup.bytes, 200, {143			"Content-Type": "application/gzip",144			"Content-Disposition": `attachment; filename="kepr-backup-${backup.name}.tar.gz"`,145		});146	});147148	app.post("/admin/backup", async (c) => {149		const failure = attemptBackup(ctx.kepr, () => makeBackup(ctx.kepr, ctx.dataDir));150		if (failure)151			return c.html(await backupsPage(c, ctx, { error: `The backup failed: ${failure}` }), 500);152		const upload = await attemptUpload(ctx.kepr, ctx.dataDir, ctx.config.backup);153		if (upload) {154			const error = `The backup was made here, but not copied to the backup bucket: ${upload}`;155			return c.html(await backupsPage(c, ctx, { error }), 502);156		}157		return c.redirect("/admin?section=backups&saved=1", 303);158	});159160	app.post("/admin/backup-bucket", async (c) => {161		const fields = await textFields(c);162		const refuse = async (error: string, status: 400 | 500) =>163			c.html(await backupsPage(c, ctx, { error, fields }), status);164		let backup: BackupBucket | undefined;165		if (fields.action !== "remove") {166			const next = candidate(fields, ctx.config.backup);167			if (typeof next === "string") return refuse(next, 400);168			const failure = await checkBackupBucket(next);169			if (failure) return refuse(failure, 400);170			if (fields.action !== "save") {171				const status = "The backup bucket works. Nothing was saved.";172				return c.html(await backupsPage(c, ctx, { status, fields }));173			}174			backup = next;175		}176		const { backup: _, ...rest } = ctx.config;177		try {178			await saveConfig(ctx.configPath, backup ? { ...rest, backup } : rest);179		} catch (error) {180			return refuse(`The settings could not be saved: ${String(error)}`, 500);181		}182		// In place, so the backup loop sees it at its next check.183		if (backup) ctx.config.backup = backup;184		else delete ctx.config.backup;185		return c.redirect("/admin?section=backups&saved=1", 303);186	});187}