import { createHash } from "node:crypto";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import type { Context, Hono } from "hono";
import { isSafePath } from "../bucket/repo.ts";
import { supportText } from "../pages/docs.ts";
import { html } from "../pages/html.ts";
import { ignoredNames, setting } from "../server/db.ts";
import { type AdminContext, adminPage, formField, notice, textFields } from "./console.ts";
import { allRepos, saveSettings } from "./store.ts";
// The settings Phase 6's pages read, with their labels and length caps.
const FIELDS: [key: string, label: string, max: number][] = [
["site_name", "site name", 100],
["tagline", "tagline", 300],
["branding", "branding text", 100],
["handle", "handle", 39],
["bio", "bio", 2000],
["location", "location", 100],
["link", "link", 500],
["building_since", "building since", 100],
["support_page", "support page (Markdown)", 4000],
];
const TEXTAREAS = new Set(["bio", "support_page"]);
const PROFILE_FIELDS = new Set(["handle", "bio", "location", "link", "building_since"]);
const HANDLE = /^[A-Za-z0-9._-]+$/;
const HTTP_URL = /^https?:\/\/[^\s]+$/i;
// Under the 64 KiB body cap with room for the form's other fields; the docs promise 60 KB.
const MAX_SIGNATURE = 60 * 1024;
const OWN_SIGNATURE = /^signature-[0-9a-f]{16}\.(png|jpg|webp)$/;
const MAX_IGNORED = 50;
const MAX_IGNORED_NAME = 255;
const MAX_TRACKING = 1000;
const UMAMI_SRC = /\bsrc="(https:\/\/[^\s"'<>]+)"/;
const UMAMI_ID =
/\bdata-website-id="([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})"/i;
/** The tag pages load, rebuilt from the two settings so nothing pasted reaches a page as is. */
function trackingCode(src: string | null, id: string | null): string {
return src && id ? `` : "";
}
/**
* The signature's type, from its first bytes, never its name or declared type. PNG, JPEG and WebP
* only: SVG is refused, since it can carry script and links and would need a sandbox of its own.
*/
function imageType(bytes: Uint8Array): "png" | "jpg" | "webp" | null {
const starts = (sig: number[], at = 0) => sig.every((byte, i) => bytes[at + i] === byte);
if (starts([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "png";
if (starts([0xff, 0xd8, 0xff])) return "jpg";
if (starts([0x52, 0x49, 0x46, 0x46]) && starts([0x57, 0x45, 0x42, 0x50], 8)) return "webp";
return null;
}
/** §24's Site and profile section, with the signature image stored under `data/assets/` (§19). */
export function registerSite(app: Hono, ctx: AdminContext): void {
const assets = join(ctx.dataDir, "assets");
app.get("/admin/site", (c) => c.html(sitePage(c, ctx, "")));
app.post("/admin/site", async (c) => {
const fields = await textFields(c);
const section = fields.section;
if (section && section !== "profile" && section !== "site")
return c.text("Unknown section.", 400);
const refuse = (error: string) => c.html(sitePage(c, ctx, error, fields), 400);
const values: Record = {};
for (const [key, label, max] of FIELDS) {
if (section && PROFILE_FIELDS.has(key) !== (section === "profile")) continue;
values[key] = (fields[key] ?? "").trim();
if (values[key].length > max) return refuse(`The ${label} is over ${max} characters.`);
}
if (values.handle && !HANDLE.test(values.handle)) {
return refuse("A handle is letters, digits, '.', '_' and '-'.");
}
// Shown by default: the setting records only that it was switched off.
if (section !== "profile") values.support_off = fields.support === "1" ? "" : "1";
const ignored = [
...new Set(
(fields.ignored ?? "")
.split(/\r?\n/)
.map((name) => name.trim())
.filter(Boolean),
),
];
if (ignored.length > MAX_IGNORED) return refuse(`Up to ${MAX_IGNORED} ignored names.`);
const bad = ignored.find(
(name) => name.length > MAX_IGNORED_NAME || name.includes("/") || !isSafePath(name),
);
if (bad !== undefined) return refuse(`"${bad}" is not a file or folder name.`);
const previousIgnored = ignoredNames(ctx.kepr);
if (section !== "profile") {
values.ignored = ignored.join("\n");
const tracking = (fields.tracking ?? "").trim();
const src = tracking.match(UMAMI_SRC)?.[1];
const id = tracking.match(UMAMI_ID)?.[1];
if (tracking.length > MAX_TRACKING || (tracking && !(src && URL.canParse(src) && id))) {
return refuse("Paste Umami's tracking code: an https src and a data-website-id.");
}
values.umami_src = src ?? "";
values.umami_id = id ?? "";
}
if (values.link && !HTTP_URL.test(values.link)) {
return refuse("The link must be an http or https address.");
}
const upload = (await c.req.parseBody()).signature;
const previous = setting(ctx.kepr, "signature");
if (
section === "site" &&
((upload instanceof File && upload.size > 0) || fields.remove_signature === "1")
)
return refuse("Edit your signature in profile settings.");
if (upload instanceof File && upload.size > MAX_SIGNATURE) {
return refuse("The signature must be under 60 KB.");
}
if (upload instanceof File && upload.size > 0) {
const bytes = new Uint8Array(await upload.arrayBuffer());
const type = imageType(bytes);
if (!type) return refuse("The signature must be a PNG, JPEG or WebP image.");
// Named by content, so the hour-long /static cache never serves an old signature.
const hash = createHash("sha256").update(bytes).digest("hex").slice(0, 16);
values.signature = `signature-${hash}.${type}`;
mkdirSync(assets, { recursive: true });
writeFileSync(join(assets, values.signature), bytes);
} else if (fields.remove_signature === "1") {
values.signature = "";
}
saveSettings(ctx.kepr, values);
// Only a file this console wrote is removed, whatever the setting holds.
if (
previous &&
OWN_SIGNATURE.test(previous) &&
values.signature !== undefined &&
previous !== values.signature
) {
rmSync(join(assets, previous), { force: true });
}
if (values.ignored !== undefined && values.ignored !== previousIgnored.join("\n")) {
await ctx.syncer.applyHidden(allRepos(ctx.kepr).map((repo) => repo.name));
}
return c.redirect(`/admin/site?${section === "profile" ? "section=profile&" : ""}saved=1`, 303);
});
}
function sitePage(
c: Context,
ctx: AdminContext,
error: string,
fields?: Record,
): string {
const profile = (fields?.section ?? c.req.query("section")) === "profile";
// The support page's box starts from the words it shows today.
const value = (key: string) =>
fields
? (fields[key] ?? "")
: key === "support_page"
? supportText(ctx.kepr)
: (setting(ctx.kepr, key) ?? "");
const input = ([key, label, max]: (typeof FIELDS)[number]) =>
html`
${
TEXTAREAS.has(key)
? html``
: html``
}`;
const body = html`${notice(error)}
${profile ? "Your public profile brings your work together. A handle gives it an address beginning with @." : "The name and words visitors see across your site."}