1import { createHash } from "node:crypto";2import { mkdirSync, rmSync, writeFileSync } from "node:fs";3import { join } from "node:path";4import type { Context, Hono } from "hono";5import { isSafePath } from "../bucket/repo.ts";6import { supportText } from "../pages/docs.ts";7import { html } from "../pages/html.ts";8import { ignoredNames, setting } from "../server/db.ts";9import { type AdminContext, adminPage, formField, notice, textFields } from "./console.ts";10import { allRepos, saveSettings } from "./store.ts";111213const FIELDS: [key: string, label: string, max: number][] = [14 ["site_name", "site name", 100],15 ["tagline", "tagline", 300],16 ["branding", "branding text", 100],17 ["handle", "handle", 39],18 ["bio", "bio", 2000],19 ["location", "location", 100],20 ["link", "link", 500],21 ["building_since", "building since", 100],22 ["support_page", "support page (Markdown)", 4000],23];24const TEXTAREAS = new Set(["bio", "support_page"]);25const PROFILE_FIELDS = new Set(["handle", "bio", "location", "link", "building_since"]);26const HANDLE = /^[A-Za-z0-9._-]+$/;27const HTTP_URL = /^https?:\/\/[^\s]+$/i;2829const MAX_SIGNATURE = 60 * 1024;30const OWN_SIGNATURE = /^signature-[0-9a-f]{16}\.(png|jpg|webp)$/;31const MAX_IGNORED = 50;32const MAX_IGNORED_NAME = 255;333435363738function imageType(bytes: Uint8Array): "png" | "jpg" | "webp" | null {39 const starts = (sig: number[], at = 0) => sig.every((byte, i) => bytes[at + i] === byte);40 if (starts([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "png";41 if (starts([0xff, 0xd8, 0xff])) return "jpg";42 if (starts([0x52, 0x49, 0x46, 0x46]) && starts([0x57, 0x45, 0x42, 0x50], 8)) return "webp";43 return null;44}454647export function registerSite(app: Hono, ctx: AdminContext): void {48 const assets = join(ctx.dataDir, "assets");49 app.get("/admin/site", (c) => c.html(sitePage(c, ctx, "")));5051 app.post("/admin/site", async (c) => {52 const fields = await textFields(c);53 const section = fields.section;54 if (section && section !== "profile" && section !== "site")55 return c.text("Unknown section.", 400);56 const refuse = (error: string) => c.html(sitePage(c, ctx, error, fields), 400);57 const values: Record<string, string> = {};58 for (const [key, label, max] of FIELDS) {59 if (section && PROFILE_FIELDS.has(key) !== (section === "profile")) continue;60 values[key] = (fields[key] ?? "").trim();61 if (values[key].length > max) return refuse(`The ${label} is over ${max} characters.`);62 }63 if (values.handle && !HANDLE.test(values.handle)) {64 return refuse("A handle is letters, digits, '.', '_' and '-'.");65 }66 67 if (section !== "profile") values.support_off = fields.support === "1" ? "" : "1";68 const ignored = [69 ...new Set(70 (fields.ignored ?? "")71 .split(/\r?\n/)72 .map((name) => name.trim())73 .filter(Boolean),74 ),75 ];76 if (ignored.length > MAX_IGNORED) return refuse(`Up to ${MAX_IGNORED} ignored names.`);77 const bad = ignored.find(78 (name) => name.length > MAX_IGNORED_NAME || name.includes("/") || !isSafePath(name),79 );80 if (bad !== undefined) return refuse(`"${bad}" is not a file or folder name.`);81 const previousIgnored = ignoredNames(ctx.kepr);82 if (section !== "profile") values.ignored = ignored.join("\n");83 if (values.link && !HTTP_URL.test(values.link)) {84 return refuse("The link must be an http or https address.");85 }86 const upload = (await c.req.parseBody()).signature;87 const previous = setting(ctx.kepr, "signature");88 if (89 section === "site" &&90 ((upload instanceof File && upload.size > 0) || fields.remove_signature === "1")91 )92 return refuse("Edit your signature in profile settings.");93 if (upload instanceof File && upload.size > MAX_SIGNATURE) {94 return refuse("The signature must be under 60 KB.");95 }96 if (upload instanceof File && upload.size > 0) {97 const bytes = new Uint8Array(await upload.arrayBuffer());98 const type = imageType(bytes);99 if (!type) return refuse("The signature must be a PNG, JPEG or WebP image.");100 101 const hash = createHash("sha256").update(bytes).digest("hex").slice(0, 16);102 values.signature = `signature-${hash}.${type}`;103 mkdirSync(assets, { recursive: true });104 writeFileSync(join(assets, values.signature), bytes);105 } else if (fields.remove_signature === "1") {106 values.signature = "";107 }108 saveSettings(ctx.kepr, values);109 110 if (111 previous &&112 OWN_SIGNATURE.test(previous) &&113 values.signature !== undefined &&114 previous !== values.signature115 ) {116 rmSync(join(assets, previous), { force: true });117 }118 if (values.ignored !== undefined && values.ignored !== previousIgnored.join("\n")) {119 await ctx.syncer.applyHidden(allRepos(ctx.kepr).map((repo) => repo.name));120 }121 return c.redirect(`/admin/site?${section === "profile" ? "section=profile&" : ""}saved=1`, 303);122 });123}124125function sitePage(126 c: Context,127 ctx: AdminContext,128 error: string,129 fields?: Record<string, string>,130): string {131 const profile = (fields?.section ?? c.req.query("section")) === "profile";132 133 const value = (key: string) =>134 fields135 ? (fields[key] ?? "")136 : key === "support_page"137 ? supportText(ctx.kepr)138 : (setting(ctx.kepr, key) ?? "");139 const input = ([key, label, max]: (typeof FIELDS)[number]) =>140 html`<label class="issues-label" for="${key}">${label}</label>141${142 TEXTAREAS.has(key)143 ? html`<textarea class="issues-textarea" id="${key}" name="${key}" rows="${key === "bio" ? 4 : 12}"144maxlength="${max}">${value(key)}</textarea>`145 : html`<input class="issues-input" id="${key}" name="${key}" maxlength="${max}"146value="${value(key)}">`147}`;148 const body = html`${notice(error)}149<p class="admin-intro">${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."}</p>150${profile && setting(ctx.kepr, "handle") && html`<p><a href="/@${setting(ctx.kepr, "handle")}">view profile ↗</a></p>`}151<form class="issues-form" method="post" action="/admin/site" enctype="multipart/form-data">152${formField(c)}153<input type="hidden" name="section" value="${profile ? "profile" : "site"}">154${155 !profile &&156 html`<section id="global-hide">157<h2 class="section-label mono">global hide</h2>158<label class="issues-label" for="ignored">file and folder names</label>159<p class="admin-intro">One file or folder name per line, such as .agent or AGENTS.md: hidden at160any depth in every repo, from pages and tarballs. Applied after any sync under way.</p>161<textarea class="issues-textarea mono" id="ignored" name="ignored" rows="4">${162 fields ? (fields.ignored ?? "") : ignoredNames(ctx.kepr).join("\n")163 }</textarea>164</section>`165}166${FIELDS.filter(([key]) => PROFILE_FIELDS.has(key) === profile).map(input)}167${168 !profile &&169 html`170<label class="issues-label"><input type="checkbox" name="support" value="1"${171 (fields ? fields.support === "1" : !setting(ctx.kepr, "support_off")) && " checked"172 }>173show the support page</label>174`175}176<button class="issues-submit" type="submit">save ${profile ? "profile" : "site"}</button>177</form>`;178 return adminPage(c, ctx, profile ? "profile" : "site", body);179}