app.ts

6.4 KB · raw

1import type { Database } from "bun:sqlite";2import { join } from "node:path";3import { Hono } from "hono";4import { bodyLimit } from "hono/body-limit";5import { secureHeaders } from "hono/secure-headers";6import { registerAccount } from "../admin/account.ts";7import { registerBackups } from "../admin/backups.ts";8import { registerBucket } from "../admin/bucket.ts";9import { registerStatus } from "../admin/console.ts";10import { registerDownloads } from "../admin/downloads.ts";11import { registerEmail } from "../admin/email.ts";12import { registerIssues } from "../admin/issues.ts";13import { registerNotifications } from "../admin/notifications.ts";14import { registerRepos } from "../admin/repos.ts";15import { registerSite } from "../admin/site.ts";16import { registerRepoText } from "../admin/text.ts";17import { type Bucket, openBucket } from "../bucket/reader.ts";18import { isSafePath } from "../bucket/repo.ts";19import { registerIssuePages } from "../issues/pages.ts";20import { registerKohRemote } from "../mirror/remote.ts";21import { registerArchivesPage } from "../pages/archives.ts";22import { registerDocsPages } from "../pages/docs.ts";23import { registerFileRoutes } from "../pages/files.ts";24import { registerFrontPages } from "../pages/front.ts";25import { registerHousekeeping } from "../pages/housekeeping.ts";26import { registerKepr2Routes } from "../pages/kepr2.ts";27import { registerProfilePage } from "../pages/profile.ts";28import { registerReleasesPage } from "../pages/releases.ts";29import { registerRepoPage } from "../pages/repo.ts";30import { registerSavePage } from "../pages/save.ts";31import { registerReleaseRoutes } from "../releases/routes.ts";32import { registerTarballRoutes } from "../tarball/archive.ts";33import { CONFIG_FILE, type Config } from "./config.ts";34import { connectKeys, noCode, registerConnect } from "./connect.ts";35import { registerLogin } from "./login.ts";3637const MAX_BODY_BYTES = 64 * 1024;38// §25's cap, except for the operator's picture and video uploads, which only a session reaches.39const MAX_MEDIA_BYTES = 64 * 1024 * 1024;40const MEDIA_UPLOAD = /^\/admin\/repos\/[^/]+\/media$/;41export const PUBLIC_DIR = join(import.meta.dir, "../../public");42// §18 allows Bunny Fonts: its stylesheet and the font files it points at.43const FONTS = "https://fonts.bunny.net";4445/** §25's headers: the CSP, with no inline scripts, and no framing. */46export function securityHeaders(imgSrc: (string | (() => string))[] = ["'self'"]) {47	return secureHeaders({48		contentSecurityPolicy: {49			defaultSrc: ["'self'"],50			scriptSrc: ["'self'"],51			styleSrc: ["'self'", FONTS],52			fontSrc: [FONTS],53			imgSrc,54			mediaSrc: imgSrc,55			objectSrc: ["'none'"],56			baseUri: ["'none'"],57			formAction: ["'self'"],58			frameAncestors: ["'none'"],59		},60		xFrameOptions: "DENY",61	});62}6364/**65 * The Hono app with §25's headers and body cap, and every route built so far. The console's Bucket66 * section changes `config.bucket` and `bucket` in place, so everything here reads them per request.67 */68export function createApp(69	config: Config,70	kepr: Database,71	bucket: Bucket = openBucket(config.bucket),72	configPath = CONFIG_FILE,73): Hono {74	const app = new Hono();75	// §25 allows images from the presigned host, which is the bucket endpoint's origin; uploaded76	// videos come from there too.77	const imgSrc = [78		"'self'",79		() => (config.bucket.kind === "s3" ? new URL(config.bucket.endpoint).origin : ""),80	];81	app.use("*", async (c, next) => {82		await next();83		const path = c.req.path;84		if (/^\/(admin(?:\/|$)|sign-in$|connect$)/.test(path))85			c.header("Referrer-Policy", "same-origin");86		if (/^\/[^/]+\/raw\/.+/.test(path) || path.startsWith("/static/assets/")) {87			const policy = c.res.headers.get("Content-Security-Policy");88			c.header("Content-Security-Policy", policy ? `${policy}; sandbox` : "sandbox");89		}90	});91	app.use(securityHeaders(imgSrc));92	const limit = (maxSize: number) =>93		bodyLimit({94			maxSize,95			onError: (c) =>96				// An oversized redemption is one more failed redemption — but only where the route97				// exists: without write keys it is an unknown path and answers as one.98				c.req.path === "/api/connect" && connectKeys(config)99					? noCode(c)100					: c.text("Request body too large", 413),101		});102	const [small, large] = [limit(MAX_BODY_BYTES), limit(MAX_MEDIA_BYTES)];103	app.use((c, next) => (MEDIA_UPLOAD.test(c.req.path) ? large : small)(c, next));104	// `/static/assets/` is the operator's uploads in `data/assets/` (§19), such as the signature.105	const assetsDir = join(config.dataDir, "assets");106	app.get("/static/:path{.+}", async (c, next) => {107		const path = c.req.param("path");108		if (!isSafePath(path)) return c.notFound();109		// A repo's uploads have their own route: as private as the repo (§20), and Range for video.110		if (path.startsWith("assets/media/")) return next();111		const file = Bun.file(112			path.startsWith("assets/") ? join(assetsDir, path.slice(7)) : join(PUBLIC_DIR, path),113		);114		if (!(await file.exists())) return c.notFound();115		c.header("Cache-Control", "public, max-age=3600");116		return c.body(await file.bytes(), 200, { "Content-Type": file.type });117	});118	const mirrorDir = join(config.dataDir, "mirror");119	// A getter, so a domain saved in the console reaches every page and check at once.120	const ctx = {121		get domain() {122			return config.domain;123		},124		kepr,125		mirrorDir,126	};127	// Before every other route, so the admin guard runs ahead of each `/admin` handler.128	registerLogin(app, ctx, config);129	registerConnect(app, config, ctx);130	// Not a spread of `ctx`, which would copy the domain once instead of keeping the getter.131	const admin = {132		get domain() {133			return config.domain;134		},135		kepr,136		mirrorDir,137		config,138		configPath,139		dataDir: config.dataDir,140		sync: { bucket, kepr, mirrorDir },141	};142	registerStatus(app, admin);143	registerRepos(app, admin);144	registerIssues(app, admin);145	registerDownloads(app, admin);146	registerSite(app, admin);147	registerBucket(app, admin);148	registerNotifications(app, admin);149	registerEmail(app, admin);150	registerBackups(app, admin);151	registerAccount(app, admin);152	registerRepoText(app, admin);153	registerHousekeeping(app, ctx);154	registerFrontPages(app, ctx);155	registerDocsPages(app, ctx);156	registerProfilePage(app, ctx);157	registerKepr2Routes(app, ctx);158	registerRepoPage(app, ctx);159	registerFileRoutes(app, ctx);160	registerSavePage(app, ctx);161	registerKohRemote(app, ctx);162	registerArchivesPage(app, ctx);163	registerReleasesPage(app, ctx);164	registerIssuePages(app, ctx);165	registerTarballRoutes(app, ctx, bucket);166	registerReleaseRoutes(app, ctx, bucket);167	return app;168}