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";181920const 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));272829function 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> };343536373839export 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}707172async 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}121122123function 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 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}