import type { Database } from "bun:sqlite"; import { join } from "node:path"; import { Hono } from "hono"; import { bodyLimit } from "hono/body-limit"; import { secureHeaders } from "hono/secure-headers"; import { registerAccount } from "../admin/account.ts"; import { registerBackups } from "../admin/backups.ts"; import { registerBucket } from "../admin/bucket.ts"; import { registerStatus } from "../admin/console.ts"; import { registerDownloads } from "../admin/downloads.ts"; import { registerEmail } from "../admin/email.ts"; import { registerIssues } from "../admin/issues.ts"; import { registerNotifications } from "../admin/notifications.ts"; import { registerRepos } from "../admin/repos.ts"; import { registerSite } from "../admin/site.ts"; import { registerStorage } from "../admin/storage.ts"; import { registerRepoText } from "../admin/text.ts"; import { type Bucket, openBucket } from "../bucket/reader.ts"; import { isSafePath } from "../bucket/repo.ts"; import { localSyncer, type Syncer } from "../bucket/sync.ts"; import { registerIssuePages } from "../issues/pages.ts"; import { registerKohRemote } from "../mirror/remote.ts"; import { registerArchivesPage } from "../pages/archives.ts"; import { registerDocsPages } from "../pages/docs.ts"; import { registerFileRoutes } from "../pages/files.ts"; import { registerFrontPages } from "../pages/front.ts"; import { registerHousekeeping } from "../pages/housekeeping.ts"; import { registerKepr2Routes } from "../pages/kepr2.ts"; import { registerProfilePage } from "../pages/profile.ts"; import { registerReleasesPage } from "../pages/releases.ts"; import { registerRepoPage } from "../pages/repo.ts"; import { registerSavePage } from "../pages/save.ts"; import { registerReleaseRoutes } from "../releases/routes.ts"; import { registerTarballRoutes } from "../tarball/archive.ts"; import { CONFIG_FILE, type Config } from "./config.ts"; import { connectKeys, noCode, registerConnect } from "./connect.ts"; import { renamedTo, setting } from "./db.ts"; import { registerLogin } from "./login.ts"; const MAX_BODY_BYTES = 64 * 1024; // §25's cap, except for the operator's picture and video uploads, which only a session reaches. const MAX_MEDIA_BYTES = 64 * 1024 * 1024; const MEDIA_UPLOAD = /^\/admin\/repos\/[^/]+\/media$/; export const PUBLIC_DIR = join(import.meta.dir, "../../public"); // §18 allows Bunny Fonts: its stylesheet and the font files it points at. /** §25's headers: the CSP, with no inline scripts, and no framing. */ export function securityHeaders( imgSrc: (string | (() => string))[] = ["'self'"], scriptSrc: (string | (() => string))[] = ["'self'"], ) { return secureHeaders({ contentSecurityPolicy: { defaultSrc: ["'self'"], scriptSrc, // Umami's script reports to the origin it loads from. connectSrc: scriptSrc, styleSrc: ["'self'"], fontSrc: ["'self'"], imgSrc, mediaSrc: imgSrc, objectSrc: ["'none'"], baseUri: ["'none'"], formAction: ["'self'"], frameAncestors: ["'none'"], }, xFrameOptions: "DENY", }); } /** * The Hono app with §25's headers and body cap, and every route built so far. The console's Bucket * section changes `config.bucket` and `bucket` in place, so everything here reads them per request. */ export function createApp( config: Config, kepr: Database, bucket: Bucket = openBucket(config.bucket), configPath = CONFIG_FILE, syncer?: Syncer, ): Hono { const app = new Hono(); // §25 allows images from the presigned host, which is the bucket endpoint's origin; uploaded // videos come from there too. const imgSrc = [ "'self'", () => (config.bucket.kind === "s3" ? new URL(config.bucket.endpoint).origin : ""), ]; app.use("*", async (c, next) => { await next(); const path = c.req.path; if (c.res.headers.get("Content-Type")?.startsWith("text/html")) { c.header("Speculation-Rules", '"/speculation.json"'); } if (/^\/(admin(?:\/|$)|sign-in$|connect$)/.test(path)) c.header("Referrer-Policy", "same-origin"); if (/^\/[^/]+\/raw\/.+/.test(path) || path.startsWith("/static/assets/")) { const policy = c.res.headers.get("Content-Security-Policy"); c.header("Content-Security-Policy", policy ? `${policy}; sandbox` : "sandbox"); } }); // The operator's Umami script, when set (June, 2026-10-04). const scriptSrc = [ "'self'", () => { const src = setting(kepr, "umami_src"); return src ? new URL(src).origin : ""; }, ]; app.use(securityHeaders(imgSrc, scriptSrc)); // A renamed repo's old pages, which now 404, move to its new name (June, 2026-10-01). Koh's // routes keep their 404, so a machine follows the bucket's rename marker instead. app.use("*", async (c, next) => { await next(); if (c.req.method !== "GET" || c.res.status !== 404) return; const [, first = "", second, ...rest] = c.req.path.split("/"); const to = second !== "koh" && renamedTo(kepr, first); if (!to) return; const query = new URL(c.req.url).search; const path = ["", to, ...(second === undefined ? [] : [second, ...rest])].join("/"); c.res = c.redirect(`${path}${query}`, 301); }); const limit = (maxSize: number) => bodyLimit({ maxSize, onError: (c) => // An oversized redemption is one more failed redemption — but only where the route // exists: without write keys it is an unknown path and answers as one. c.req.path === "/api/connect" && connectKeys(config) ? noCode(c) : c.text("Request body too large", 413), }); const [small, large] = [limit(MAX_BODY_BYTES), limit(MAX_MEDIA_BYTES)]; app.use((c, next) => (MEDIA_UPLOAD.test(c.req.path) ? large : small)(c, next)); // `/static/assets/` is the operator's uploads in `data/assets/` (§19), such as the signature. const assetsDir = join(config.dataDir, "assets"); app.get("/static/:path{.+}", async (c, next) => { const path = c.req.param("path"); if (!isSafePath(path)) return c.notFound(); // A repo's uploads have their own route: as private as the repo (§20), and Range for video. if (path.startsWith("assets/media/")) return next(); const file = Bun.file( path.startsWith("assets/") ? join(assetsDir, path.slice(7)) : join(PUBLIC_DIR, path), ); if (!(await file.exists())) return c.notFound(); // A versioned URL names one build's file, and a font file never changes under its name, so // neither needs asking again. const versioned = c.req.query("v") !== undefined || path.startsWith("fonts/"); c.header( "Cache-Control", versioned ? "public, max-age=31536000, immutable" : "public, max-age=3600", ); return c.body(await file.bytes(), 200, { "Content-Type": file.type }); }); const mirrorDir = join(config.dataDir, "mirror"); // A getter, so a domain saved in the console reaches every page and check at once. const ctx = { get domain() { return config.domain; }, kepr, mirrorDir, }; // Before every other route, so the admin guard runs ahead of each `/admin` handler. registerLogin(app, ctx, config); registerConnect(app, config, ctx); // Not a spread of `ctx`, which would copy the domain once instead of keeping the getter. const admin = { get domain() { return config.domain; }, kepr, mirrorDir, config, configPath, dataDir: config.dataDir, sync: { bucket, kepr, mirrorDir }, syncer: syncer ?? localSyncer({ bucket, kepr, mirrorDir }), }; registerStatus(app, admin); registerRepos(app, admin); registerIssues(app, admin); registerDownloads(app, admin); registerStorage(app, admin); registerSite(app, admin); registerBucket(app, admin); registerNotifications(app, admin); registerEmail(app, admin); registerBackups(app, admin); registerAccount(app, admin); registerRepoText(app, admin); registerHousekeeping(app, ctx); registerFrontPages(app, ctx); registerDocsPages(app, ctx); registerProfilePage(app, ctx); registerKepr2Routes(app, ctx); registerRepoPage(app, ctx); registerFileRoutes(app, ctx); registerSavePage(app, ctx); registerKohRemote(app, ctx); registerArchivesPage(app, ctx); registerReleasesPage(app, ctx); registerIssuePages(app, ctx); registerTarballRoutes(app, ctx, bucket); registerReleaseRoutes(app, ctx, bucket); return app; }