import type { Database } from "bun:sqlite"; import type { Context } from "hono"; import { openMirror } from "../mirror/store.ts"; import { publicRepo, type RepoSettings, setting } from "../server/db.ts"; import { type Html, html } from "./html.ts"; export type PageContext = { domain: string; kepr: Database; mirrorDir: string }; export type PublicRepo = { settings: RepoSettings; mirror: Database }; // Static files are cached for an hour; the release in their URLs makes a new one load at once. const { version } = (await Bun.file(`${import.meta.dir}/../../package.json`).json()) as { version: string; }; const HEAD_CACHE = "public, max-age=60"; // Content at a save never changes, but strip can remove it, so it is long-lived rather than // immutable, and `private`, so no shared cache keeps it after the repo turns private (§20). const SAVE_CACHE = "private, max-age=31536000"; /** * The gate of §20: a private repo and an unknown one both come back null, and so both 404 alike. */ export function openPublicRepo( ctx: Pick, name: string, ): PublicRepo | null { const settings = publicRepo(ctx.kepr, name); return settings ? { settings, mirror: openMirror(ctx.mirrorDir, name) } : null; } export function cacheFor(c: Context, atSave: boolean): void { c.header("Cache-Control", atSave ? SAVE_CACHE : HEAD_CACHE); } export function notFound(c: Context, ctx: PageContext): Response { c.header("Cache-Control", "no-store"); const body = html`

Not found

Nothing lives at this address.

`; return c.html(page(ctx, { title: "Not found", description: "", path: c.req.path, body }), 404); } type PageOptions = { title: string; description: string; path: string; body: Html }; export function page(ctx: PageContext, options: PageOptions): string { const site = setting(ctx.kepr, "site_name") ?? "kepr"; const branding = setting(ctx.kepr, "branding"); // Visitors are counted, never the operator's console or sign-in. const tracked = !/^\/(admin|sign-in|connect)(\/|$)/.test(options.path); const umamiSrc = tracked && setting(ctx.kepr, "umami_src"); const umamiId = setting(ctx.kepr, "umami_id"); // The front page is the site itself, so its title is the bare site name. const title = options.title ? `${options.title} — ${site}` : site; const url = `https://${ctx.domain}${options.path}`; return html` ${title} ${umamiSrc && umamiId && html``}
${options.body}
`.value; } const UNITS: [number, string][] = [ [365 * 86400, "year"], [30 * 86400, "month"], [7 * 86400, "week"], [86400, "day"], [3600, "hour"], [60, "minute"], ]; export function relativeTime(unixSeconds: number, now = Date.now() / 1000): string { const elapsed = now - unixSeconds; for (const [seconds, unit] of UNITS) { const n = Math.floor(elapsed / seconds); if (n >= 1) return `${n} ${unit}${n === 1 ? "" : "s"} ago`; } return "just now"; } export function formatDate(unixSeconds: number): string { return new Date(unixSeconds * 1000).toISOString().slice(0, 16).replace("T", " "); } export function plural(n: number, noun: string): string { return `${n.toLocaleString("en-US")} ${noun}${n === 1 ? "" : "s"}`; } export function formatSize(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / 1024 / 1024).toFixed(1)} MB`; } /** Percent-encodes each segment of a repo path for use in a URL. */ export function pathUrl(path: string): string { return path.split("/").map(encodeURIComponent).join("/"); }