1import { createHash } from "node:crypto";2import { mkdirSync, rmSync, writeFileSync } from "node:fs";3import { join } from "node:path";4import type { Context, Hono } from "hono";5import { supportText } from "../pages/docs.ts";6import { html } from "../pages/html.ts";7import { setting } from "../server/db.ts";8import { type AdminContext, adminPage, formField, notice, textFields } from "./console.ts";9import { saveSettings } from "./store.ts";101112const FIELDS: [key: string, label: string, max: number][] = [13 ["site_name", "site name", 100],14 ["tagline", "tagline", 300],15 ["branding", "branding text", 100],16 ["handle", "handle", 39],17 ["bio", "bio", 2000],18 ["location", "location", 100],19 ["link", "link", 500],20 ["building_since", "building since", 100],21 ["support_page", "support page (Markdown)", 4000],22];23const TEXTAREAS = new Set(["bio", "support_page"]);24const HANDLE = /^[A-Za-z0-9._-]+$/;25const HTTP_URL = /^https?:\/\/[^\s]+$/i;2627const MAX_SIGNATURE = 60 * 1024;28const OWN_SIGNATURE = /^signature-[0-9a-f]{16}\.(png|jpg|webp)$/;293031323334function imageType(bytes: Uint8Array): "png" | "jpg" | "webp" | null {35 const starts = (sig: number[], at = 0) => sig.every((byte, i) => bytes[at + i] === byte);36 if (starts([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "png";37 if (starts([0xff, 0xd8, 0xff])) return "jpg";38 if (starts([0x52, 0x49, 0x46, 0x46]) && starts([0x57, 0x45, 0x42, 0x50], 8)) return "webp";39 return null;40}414243export function registerSite(app: Hono, ctx: AdminContext): void {44 const assets = join(ctx.dataDir, "assets");45 app.get("/admin/site", (c) => c.html(sitePage(c, ctx, "")));4647 app.post("/admin/site", async (c) => {48 const fields = await textFields(c);49 const refuse = (error: string) => c.html(sitePage(c, ctx, error), 400);50 const values: Record<string, string> = {};51 for (const [key, label, max] of FIELDS) {52 values[key] = (fields[key] ?? "").trim();53 if (values[key].length > max) return refuse(`The ${label} is over ${max} characters.`);54 }55 if (values.handle && !HANDLE.test(values.handle)) {56 return refuse("A handle is letters, digits, '.', '_' and '-'.");57 }58 59 values.support_off = fields.support === "1" ? "" : "1";60 if (values.link && !HTTP_URL.test(values.link)) {61 return refuse("The link must be an http or https address.");62 }63 const upload = (await c.req.parseBody()).signature;64 const previous = setting(ctx.kepr, "signature");65 if (upload instanceof File && upload.size > MAX_SIGNATURE) {66 return refuse("The signature must be under 60 KB.");67 }68 if (upload instanceof File && upload.size > 0) {69 const bytes = new Uint8Array(await upload.arrayBuffer());70 const type = imageType(bytes);71 if (!type) return refuse("The signature must be a PNG, JPEG or WebP image.");72 73 const hash = createHash("sha256").update(bytes).digest("hex").slice(0, 16);74 values.signature = `signature-${hash}.${type}`;75 mkdirSync(assets, { recursive: true });76 writeFileSync(join(assets, values.signature), bytes);77 } else if (fields.remove_signature === "1") {78 values.signature = "";79 }80 saveSettings(ctx.kepr, values);81 82 if (83 previous &&84 OWN_SIGNATURE.test(previous) &&85 values.signature !== undefined &&86 previous !== values.signature87 ) {88 rmSync(join(assets, previous), { force: true });89 }90 return c.redirect("/admin/site", 303);91 });92}9394function sitePage(c: Context, ctx: AdminContext, error: string): string {95 const signature = setting(ctx.kepr, "signature");96 97 const value = (key: string) =>98 key === "support_page" ? supportText(ctx.kepr) : (setting(ctx.kepr, key) ?? "");99 const input = ([key, label, max]: (typeof FIELDS)[number]) =>100 html`<label class="issues-label" for="${key}">${label}</label>101${102 TEXTAREAS.has(key)103 ? html`<textarea class="issues-textarea" id="${key}" name="${key}" rows="${key === "bio" ? 4 : 12}"104maxlength="${max}">${value(key)}</textarea>`105 : html`<input class="issues-input" id="${key}" name="${key}" maxlength="${max}"106value="${value(key)}">`107}`;108 const body = html`${notice(error)}109<form class="issues-form" method="post" action="/admin/site" enctype="multipart/form-data">110${formField(c)}111${FIELDS.map(input)}112<label class="issues-label"><input type="checkbox" name="support" value="1"${113 !setting(ctx.kepr, "support_off") && " checked"114 }>115show the support page</label>116<label class="issues-label" for="signature">signature image117<span class="muted">(PNG, JPEG or WebP, under 60 KB)</span></label>118${signature && html`<img class="profile-signature" src="/static/assets/${encodeURIComponent(signature)}" alt="current signature">`}119<input class="issues-input" id="signature" name="signature" type="file" accept="image/png,image/jpeg,image/webp">120${121 signature &&122 html`<label class="issues-label"><input type="checkbox" name="remove_signature" value="1">123remove the signature</label>`124}125<button class="issues-submit" type="submit">save</button>126</form>`;127 return adminPage(c, ctx, "site and profile", body);128}