app.ts

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