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 { 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 { 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. const FONTS = "https://fonts.bunny.net"; /** §25's headers: the CSP, with no inline scripts, and no framing. */ export function securityHeaders(imgSrc: (string | (() => string))[] = ["'self'"]) { return secureHeaders({ contentSecurityPolicy: { defaultSrc: ["'self'"], scriptSrc: ["'self'"], styleSrc: ["'self'", FONTS], fontSrc: [FONTS], 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, ): 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 (/^\/(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"); } }); app.use(securityHeaders(imgSrc)); 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(); c.header("Cache-Control", "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 }, }; 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; }