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