import type { Context, Hono } from "hono"; import { html } from "../pages/html.ts"; import { formatSize } from "../pages/layout.ts"; import { attemptBackup, attemptUpload, backupBytes, backups, backupTarball, checkBackupBucket, makeBackup, offsiteBackups, } from "../server/backup.ts"; import { type BackupBucket, saveConfig } from "../server/config.ts"; import { setting } from "../server/db.ts"; import { s3FieldsProblem } from "./bucket.ts"; import { type AdminContext, adminPage, formField, notice, textFields } from "./console.ts"; // The backup bucket's fields shown back to the operator; its secret is never among them. const SHOWN: [key: string, label: string][] = [ ["endpoint", "endpoint"], ["name", "bucket name"], ["region", "region"], ["access_key_id", "access key ID"], ]; const REQUIRED = new Set(SHOWN.map(([key]) => key)); /** `20260926T031500Z` as `2026-09-26 03:15:00 UTC`. */ function backupTime(name: string): string { return name.replace(/^(\d{4})(\d\d)(\d\d)T(\d\d)(\d\d)(\d\d)Z$/, "$1-$2-$3 $4:$5:$6 UTC"); } type Shown = { error?: string; status?: string; fields?: Record }; /** * ยง24's Backups section: every kept backup, back up now, the last failure, how to restore, and the * optional backup bucket (June, 2026-09-30) with what it holds. */ export async function backupsPage(c: Context, ctx: AdminContext, shown: Shown = {}) { const kept = backups(ctx.dataDir).reverse(); const newest = kept[0]; const failure = setting(ctx.kepr, "backup_error"); const body = html`${notice(shown.error ?? "")} ${shown.status && html`

${shown.status}

`} ${failure && !shown.error && html``} ${ newest ? html`

Last backup: ${backupTime(newest)}. download backup

` : html`

No backup yet.

` }
${formField(c)}

Kepr keeps the last 14 daily backups of its database and assets.

${await bucketSection(c, ctx, shown.fields)}
  1. Stop Kepr.
  2. Unpack the download: it holds a folder with kepr.db and assets/.
  3. Put kepr.db and assets/ in Kepr's data directory, over the old ones.
  4. Start Kepr. Its first sync rebuilds the mirrors, and everyone signs in again.

Sessions and bucket credentials are not included: after restoring onto a new server, enter the bucket settings again here.

`; return adminPage(c, ctx, "backups", body); } /** The backup bucket's form, its last upload and what it holds. */ async function bucketSection(c: Context, ctx: AdminContext, fields?: Record) { const current = ctx.config.backup; const stored: Record = { endpoint: current?.endpoint ?? "", name: current?.name ?? "", region: current?.region ?? "", access_key_id: current?.accessKeyId ?? "", }; const value = (key: string) => fields?.[key] ?? stored[key] ?? ""; const uploadError = setting(ctx.kepr, "backup_upload_error"); let held: Awaited> | string = []; if (current) { try { held = (await offsiteBackups(current)).reverse(); } catch (error) { held = `Could not list the backup bucket: ${String(error)}`; } } return html`

An optional second S3 bucket, apart from the one Koh offers to. Each backup is copied there too, and the newest 14 kept. Its key needs to read, write and delete.

${uploadError && html``} ${typeof held === "string" && html`

${held}

`} ${ Array.isArray(held) && held.length > 0 && html`

${String(held.length)} in the backup bucket:

    ${held.map( (backup) => html`
  • ${backupTime(backup.name)} ${formatSize(backup.size)}
  • `, )}
` }
${formField(c)} ${SHOWN.map( ([key, label]) => html` `, )}
${current && html``}
`; } /** The backup bucket a form describes, a blank secret keeping the stored one; or what is wrong. */ function candidate(fields: Record, current?: BackupBucket): BackupBucket | string { const problem = s3FieldsProblem(fields, SHOWN, REQUIRED); if (problem) return problem; const value = (key: string) => (fields[key] ?? "").trim(); const secretAccessKey = value("secret_access_key") || current?.secretAccessKey; if (!secretAccessKey) return "The secret key is required."; return { endpoint: value("endpoint"), name: value("name"), region: value("region"), accessKeyId: value("access_key_id"), secretAccessKey, }; } export function registerBackups(app: Hono, ctx: AdminContext): void { app.get("/admin/backup", (c) => { const backup = backupTarball(ctx.dataDir, c.req.query("name")); if (!backup) return c.notFound(); return c.body(backup.bytes, 200, { "Content-Type": "application/gzip", "Content-Disposition": `attachment; filename="kepr-backup-${backup.name}.tar.gz"`, }); }); app.post("/admin/backup", async (c) => { const failure = attemptBackup(ctx.kepr, () => makeBackup(ctx.kepr, ctx.dataDir)); if (failure) return c.html(await backupsPage(c, ctx, { error: `The backup failed: ${failure}` }), 500); const upload = await attemptUpload(ctx.kepr, ctx.dataDir, ctx.config.backup); if (upload) { const error = `The backup was made here, but not copied to the backup bucket: ${upload}`; return c.html(await backupsPage(c, ctx, { error }), 502); } return c.redirect("/admin?section=backups&saved=1", 303); }); app.post("/admin/backup-bucket", async (c) => { const fields = await textFields(c); const refuse = async (error: string, status: 400 | 500) => c.html(await backupsPage(c, ctx, { error, fields }), status); let backup: BackupBucket | undefined; if (fields.action !== "remove") { const next = candidate(fields, ctx.config.backup); if (typeof next === "string") return refuse(next, 400); const failure = await checkBackupBucket(next); if (failure) return refuse(failure, 400); if (fields.action !== "save") { const status = "The backup bucket works. Nothing was saved."; return c.html(await backupsPage(c, ctx, { status, fields })); } backup = next; } const { backup: _, ...rest } = ctx.config; try { await saveConfig(ctx.configPath, backup ? { ...rest, backup } : rest); } catch (error) { return refuse(`The settings could not be saved: ${String(error)}`, 500); } // In place, so the backup loop sees it at its next check. if (backup) ctx.config.backup = backup; else delete ctx.config.backup; return c.redirect("/admin?section=backups&saved=1", 303); }); }