2025-06-21 16:04:57 -07:00
|
|
|
export const app = new Hono();
|
2025-06-15 23:42:10 -07:00
|
|
|
|
|
|
|
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) => {
|
2025-06-22 14:38:36 -07:00
|
|
|
const ua = c.req.header("User-Agent")?.toLowerCase() ?? "";
|
2025-07-08 23:10:41 -07:00
|
|
|
const lofi = ua.includes("msie") || false;
|
2025-06-22 14:38:36 -07:00
|
|
|
|
|
|
|
// Discord ignores 'robots.txt' which violates the license agreement.
|
|
|
|
if (ua.includes("discordbot")) {
|
2025-06-15 23:42:10 -07:00
|
|
|
return next();
|
|
|
|
}
|
2025-06-22 14:38:36 -07:00
|
|
|
|
2025-06-15 23:42:10 -07:00
|
|
|
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
|
2025-06-21 16:04:57 -07:00
|
|
|
if (file.kind === MediaFileKind.directory) {
|
2025-06-15 23:42:10 -07:00
|
|
|
if (c.req.header("Accept")?.includes("application/json")) {
|
|
|
|
const json = {
|
|
|
|
path: file.path,
|
|
|
|
files: file.getPublicChildren().map((f) => ({
|
|
|
|
basename: f.basename,
|
2025-06-21 16:04:57 -07:00
|
|
|
dir: f.kind === MediaFileKind.directory,
|
2025-06-15 23:42:10 -07:00
|
|
|
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);
|
|
|
|
}
|
2025-06-22 14:38:36 -07:00
|
|
|
c.res = await renderView(c, `file-viewer/${lofi ? "lofi" : "clofi"}`, {
|
2025-06-15 23:42:10 -07:00
|
|
|
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";
|
|
|
|
}
|
2025-06-22 14:38:36 -07:00
|
|
|
if (
|
|
|
|
viewMode == undefined &&
|
|
|
|
c.req.header("Accept")?.includes("text/html") &&
|
|
|
|
!lofi
|
|
|
|
) {
|
2025-06-15 23:42:10 -07:00
|
|
|
prefetchFile(file.path);
|
2025-06-21 16:04:57 -07:00
|
|
|
c.res = await renderView(c, "file-viewer/clofi", {
|
2025-06-15 23:42:10 -07:00
|
|
|
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"));
|
|
|
|
|
2025-08-02 19:22:07 -07:00
|
|
|
let sizeHeader =
|
|
|
|
encoding === "raw"
|
|
|
|
? expectedSize
|
|
|
|
: // Size cannot be known because of compression modes
|
|
|
|
undefined;
|
2025-06-15 23:42:10 -07:00
|
|
|
|
|
|
|
// 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();
|
|
|
|
}
|
2025-06-21 16:04:57 -07:00
|
|
|
return renderView(c, "file-viewer/canvas", {
|
2025-06-15 23:42:10 -07:00
|
|
|
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",
|
2025-06-21 16:04:57 -07:00
|
|
|
"Content-Type": contentTypeFor(file.path),
|
2025-06-15 23:42:10 -07:00
|
|
|
"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);
|
2025-08-02 19:22:07 -07:00
|
|
|
const rangeBody =
|
|
|
|
streamOrBuffer instanceof ReadableStream
|
|
|
|
? applySingleRangeToStream(streamOrBuffer, ranges)
|
|
|
|
: applyRangesToBuffer(streamOrBuffer, ranges, rangeSize);
|
2025-06-15 23:42:10 -07:00
|
|
|
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) {
|
2025-06-21 16:04:57 -07:00
|
|
|
result.set(buffer.subarray(start, end + 1), offset);
|
2025-06-15 23:42:10 -07:00
|
|
|
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!
|
2025-08-02 19:22:07 -07:00
|
|
|
root = root[2].children as render.Element;
|
2025-06-15 23:42:10 -07:00
|
|
|
|
2025-08-02 19:22:07 -07:00
|
|
|
const html = render.sync(root).text;
|
2025-06-15 23:42:10 -07:00
|
|
|
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!
|
2025-08-02 19:22:07 -07:00
|
|
|
root = root[2].children as render.Element;
|
2025-06-15 23:42:10 -07:00
|
|
|
|
2025-08-02 19:22:07 -07:00
|
|
|
const html = render.sync(root).text;
|
2025-06-15 23:42:10 -07:00
|
|
|
return c.html(html);
|
|
|
|
}
|
|
|
|
|
2025-06-21 16:04:57 -07:00
|
|
|
import { type Context, Hono } from "hono";
|
|
|
|
|
2025-08-02 19:22:07 -07:00
|
|
|
import * as render from "#engine/render";
|
2025-06-21 16:04:57 -07:00
|
|
|
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";
|