sitegen/src/file-viewer/backend.tsx
clover caruso 30ad9c27ff chore: rework Clover Engine API, remove "SSR" term
"server side rendering" is a misleading term since it implies there is a
server. that isn't neccecarily the case here, since it supports running
in the browser. I think "clover engine" is cute, short for "clover html
rendering engine". Instead of "server side rendering", it's just rendering.

This commit makes things a lot more concise, such as `ssr.ssrAsync`
being renamed to `render.async` to play nicely with namespaced imports.
`getCurrentRender` and `setCurrentRender` are just `current` and
`setCurrent`, and the addon interface has been redesigned to force
symbols with a wrapping helper.
2025-08-02 22:22:07 -04:00

435 lines
12 KiB
TypeScript

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<Uint8Array>;
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";