export const app = new Hono(); interface APIDirectoryList { path: string; readme: string | null; files: APIFile[]; } interface APIFile { basename: string; dir: boolean; time: number; size: number; duration: number | null; } function checkCotyledonCookie(c: Context) { const cookie = c.req.header("Cookie"); if (!cookie) return false; const cookies = cookie.split("; ").map((x) => x.split("=")); return cookies.some( (kv) => kv[0].trim() === "cotyledon" && kv[1].trim() === "agree", ); } function isCotyledonPath(path: string) { if (path === "/cotyledon") return true; const year = path.match(/^\/(\d{4})($|\/)/); if (!year) return false; const yearInt = parseInt(year[1]); if (yearInt < 2025 && yearInt >= 2017) return true; return false; } app.post("/file/cotyledon", async (c) => { c.res = new Response(null, { status: 200, headers: { "Set-Cookie": "cotyledon=agree; Path=/", }, }); }); app.get("/file/*", async (c, next) => { const ua = c.req.header("User-Agent")?.toLowerCase() ?? ""; const lofi = ua.includes("msie") || false; // Discord ignores 'robots.txt' which violates the license agreement. if (ua.includes("discordbot")) { return next(); } let rawFilePath = c.req.path.slice(5) || "/"; if (rawFilePath.endsWith("$partial")) { return getPartialPage(c, rawFilePath.slice(0, -"$partial".length)); } let hasCotyledonCookie = checkCotyledonCookie(c); if (isCotyledonPath(rawFilePath)) { if (!hasCotyledonCookie) { return serveAsset(c, "/file/cotyledon_speedbump", 403); } else if (rawFilePath === "/cotyledon") { return serveAsset(c, "/file/cotyledon_enterance", 200); } } while (rawFilePath.length > 1 && rawFilePath.endsWith("/")) { rawFilePath = rawFilePath.slice(0, -1); } const file = MediaFile.getByPath(rawFilePath); if (!file) { // perhaps a specific 404 page for media files? return next(); } const permissions = FilePermissions.getByPrefix(rawFilePath); if (permissions !== 0) { const friendAuthChallenge = requireFriendAuth(c); if (friendAuthChallenge) return friendAuthChallenge; } // File listings if (file.kind === MediaFileKind.directory) { if (c.req.header("Accept")?.includes("application/json")) { const json = { path: file.path, files: file.getPublicChildren().map((f) => ({ basename: f.basename, dir: f.kind === MediaFileKind.directory, time: f.date.getTime(), size: f.size, duration: f.duration ? f.duration : null, })), readme: file.contents ? file.contents : null, } satisfies APIDirectoryList; return c.json(json); } c.res = await renderView(c, `file-viewer/${lofi ? "lofi" : "clofi"}`, { file, hasCotyledonCookie, }); return; } // Redirect to directory list for regular files if client accepts HTML let viewMode = c.req.query("view"); if (c.req.query("dl") !== undefined) { viewMode = "download"; } if ( viewMode == undefined && c.req.header("Accept")?.includes("text/html") && !lofi ) { prefetchFile(file.path); c.res = await renderView(c, "file-viewer/clofi", { file, hasCotyledonCookie, }); return; } const download = viewMode === "download"; const etag = file.hash; const filePath = file.path; const expectedSize = file.size; let encoding = decideEncoding(c.req.header("Accept-Encoding")); let sizeHeader = encoding === "raw" ? expectedSize : // Size cannot be known because of compression modes undefined; // Etag { const ifNoneMatch = c.req.header("If-None-Match"); if (ifNoneMatch && etagMatches(etag, ifNoneMatch)) { c.res = new Response(null, { status: 304, statusText: "Not Modified", headers: fileHeaders(file, download, sizeHeader), }); return; } } // Head if (c.req.method === "HEAD") { c.res = new Response(null, { headers: fileHeaders(file, download, sizeHeader), }); return; } // Prevalidate range requests let rangeHeader = c.req.header("Range") ?? null; if (rangeHeader) encoding = "raw"; const ifRangeHeader = c.req.header("If-Range"); if (ifRangeHeader && ifRangeOutdated(file, ifRangeHeader)) { // > If the condition is not fulfilled, the full resource is // > sent back with a 200 OK status. rangeHeader = null; } let foundFile; while (true) { let second = false; try { foundFile = await fetchFile(filePath, encoding); if (second) { console.warn(`File ${filePath} has missing compression: ${encoding}`); } break; } catch (error) { if (encoding !== "raw") { encoding = "raw"; sizeHeader = file.size; second = true; continue; } return c.text( "internal server error: this file is present in the database but could not be fetched", ); } } const [streamOrBuffer, actualEncoding, src] = foundFile; encoding = actualEncoding; // Range requests // https://developer.mozilla.org/en-US/docs/Web/HTTP/Range_requests // Compression is skipped because it's a confusing, but solvable problem. // See https://stackoverflow.com/questions/33947562/is-it-possible-to-send-http-response-using-gzip-and-byte-ranges-at-the-same-time if (rangeHeader) { const ranges = parseRange(rangeHeader, file.size); // TODO: multiple ranges if (ranges && ranges.length === 1) { return (c.res = handleRanges(ranges, file, streamOrBuffer, download)); } } // Respond in a streaming fashion c.res = new Response(streamOrBuffer, { headers: { ...fileHeaders(file, download, sizeHeader), ...(encoding !== "raw" && { "Content-Encoding": encoding, }), "X-Cache": src, }, }); }); app.get("/canvas/:script", async (c, next) => { const script = c.req.param("script"); if (!hasAsset(`/js/canvas/${script}.js`)) { return next(); } return renderView(c, "file-viewer/canvas", { script, }); }); function decideEncoding(encodings: string | undefined): CompressionFormat { if (encodings?.includes("zstd")) return "zstd"; if (encodings?.includes("gzip")) return "gzip"; return "raw"; } function fileHeaders( file: MediaFile, download: boolean, size: number | undefined = file.size, ) { return { Vary: "Accept-Encoding, Accept", "Content-Type": contentTypeFor(file.path), "Content-Length": size.toString(), ETag: file.hash, "Last-Modified": file.date.toUTCString(), ...(download && { "Content-Disposition": `attachment; filename="${file.basename}"`, }), }; } function ifRangeOutdated(file: MediaFile, ifRangeHeader: string) { // etag if (ifRangeHeader[0] === '"') { return ifRangeHeader.slice(1, -1) !== file.hash; } // date return new Date(ifRangeHeader) < file.date; } /** The end is inclusive */ type Ranges = Array<[start: number, end: number]>; function parseRange(rangeHeader: string, fileSize: number): Ranges | null { const [unit, ranges] = rangeHeader.split("="); if (unit !== "bytes") return null; const result: Array<[start: number, end: number]> = []; const rangeParts = ranges.split(","); for (const range of rangeParts) { const split = range.split("-"); if (split.length !== 2) return null; const [start, end] = split; if (start === "" && end === "") return null; const parsedRange: [number, number] = [ start === "" ? fileSize - +end : +start, end === "" ? fileSize - 1 : +end, ]; result.push(parsedRange); } // Validate that ranges do not intersect result.sort((a, b) => a[0] - b[0]); for (let i = 1; i < result.length; i++) { if (result[i][0] <= result[i - 1][1]) { return null; } } return result; } function handleRanges( ranges: Ranges, file: MediaFile, streamOrBuffer: ReadableStream | Buffer, download: boolean, ): Response { // TODO: multiple ranges const rangeSize = ranges.reduce((a, b) => a + (b[1] - b[0] + 1), 0); const rangeBody = streamOrBuffer instanceof ReadableStream ? applySingleRangeToStream(streamOrBuffer, ranges) : applyRangesToBuffer(streamOrBuffer, ranges, rangeSize); return new Response(rangeBody, { status: 206, headers: { ...fileHeaders(file, download, rangeSize), "Content-Range": `bytes ${ranges[0][0]}-${ranges[0][1]}/${file.size}`, }, }); } function applyRangesToBuffer( buffer: Buffer, ranges: Ranges, rangeSize: number, ): Uint8Array { const result = new Uint8Array(rangeSize); let offset = 0; for (const [start, end] of ranges) { result.set(buffer.subarray(start, end + 1), offset); offset += end - start + 1; } return result; } function applySingleRangeToStream( stream: ReadableStream, ranges: Ranges, ): ReadableStream { let reader: ReadableStreamDefaultReader; let position = 0; const [start, end] = ranges[0]; return new ReadableStream({ async start(controller) { reader = stream.getReader(); try { while (position <= end) { const { done, value } = await reader.read(); if (done) { controller.close(); return; } const buffer = new Uint8Array(value); const bufferStart = position; const bufferEnd = position + buffer.length - 1; position += buffer.length; if (bufferEnd < start) { continue; } if (bufferStart > end) { break; } const sendStart = Math.max(0, start - bufferStart); const sendEnd = Math.min(buffer.length - 1, end - bufferStart); if (sendStart <= sendEnd) { controller.enqueue(buffer.slice(sendStart, sendEnd + 1)); } } controller.close(); } catch (error) { controller.error(error); } finally { reader.releaseLock(); } }, cancel() { reader?.releaseLock(); }, }); } function getPartialPage(c: Context, rawFilePath: string) { if (isCotyledonPath(rawFilePath)) { if (!checkCotyledonCookie(c)) { let root = Speedbump(); // Remove the root element, it's created client side! root = root[2].children as render.Element; const html = render.sync(root).text; c.header("X-Cotyledon", "true"); return c.html(html); } } const file = MediaFile.getByPath(rawFilePath); const permissions = FilePermissions.getByPrefix(rawFilePath); if (permissions !== 0) { const friendAuthChallenge = requireFriendAuth(c); if (friendAuthChallenge) return friendAuthChallenge; } if (rawFilePath.endsWith("/")) { rawFilePath = rawFilePath.slice(0, -1); } if (!file) { return c.json({ error: "File not found" }, 404); } let root = MediaPanel({ file, isLast: true, activeFilename: null, hasCotyledonCookie: rawFilePath === "" && checkCotyledonCookie(c), }); // Remove the root element, it's created client side! root = root[2].children as render.Element; const html = render.sync(root).text; return c.html(html); } import { type Context, Hono } from "hono"; import * as render from "#engine/render"; import { etagMatches, hasAsset, serveAsset } from "#sitegen/assets"; import { renderView } from "#sitegen/view"; import { contentTypeFor } from "#sitegen/mime"; import { requireFriendAuth } from "@/friend-auth.ts"; import { MediaFile, MediaFileKind } from "@/file-viewer/models/MediaFile.ts"; import { FilePermissions } from "@/file-viewer/models/FilePermissions.ts"; import { MediaPanel } from "@/file-viewer/views/clofi.tsx"; import { Speedbump } from "@/file-viewer/cotyledon.tsx"; import { type CompressionFormat, fetchFile, prefetchFile, } from "@/file-viewer/cache.ts";