diff --git a/packages/server/src/route/comic.ts b/packages/server/src/route/comic.ts index f2193c1..d40b0c4 100644 --- a/packages/server/src/route/comic.ts +++ b/packages/server/src/route/comic.ts @@ -1,6 +1,7 @@ import type { Context as ElysiaContext } from "elysia"; import { Readable } from "node:stream"; import { createReadableStreamFromZip, entriesByNaturalOrder, readZip } from "../util/zipwrap.ts"; +import { Entry } from "@zip.js/zip.js"; const imageExtensions = new Set(["gif", "png", "jpeg", "bmp", "webp", "jpg", "avif"]); @@ -18,6 +19,50 @@ type RenderOptions = { set: ResponseSet; }; +async function setHeadersForEntry(entry: Entry, reqHeaders: Headers, set: ResponseSet) { + const ext = entry.filename.split(".").pop()?.toLowerCase() ?? "jpeg"; + + set.headers["content-type"] = extensionToMime(ext); + if (typeof entry.uncompressedSize === "number") { + set.headers["content-length"] = entry.uncompressedSize; + } + + const lastModified = entry.lastModDate ?? new Date(); + const ifModifiedSince = reqHeaders.get("if-modified-since"); + set.headers["date"] = new Date().toUTCString(); + set.headers["last-modified"] = lastModified.toUTCString(); + if (ifModifiedSince) { + const cachedDate = new Date(ifModifiedSince); + if (!Number.isNaN(cachedDate.valueOf()) && lastModified <= cachedDate) { + set.status = 304; + // client's cache is valid + return true; + } + } + set.status = 200; + return false; +} + +export async function headComicPage({ path, page, reqHeaders, set }: RenderOptions) { + const zip = await readZip(path); + try { + const entries = (await entriesByNaturalOrder(zip.reader)).filter((entry) => { + const ext = entry.filename.split(".").pop()?.toLowerCase(); + return ext !== undefined && imageExtensions.has(ext); + }); + if (page < 0 || page >= entries.length) { + set.status = 404; + return; + } + const entry = entries[page]; + if (await setHeadersForEntry(entry, reqHeaders, set)) { + return; + } + } finally { + await zip.reader.close(); + } +} + export async function renderComicPage({ path, page, reqHeaders, set }: RenderOptions) { const zip = await readZip(path); @@ -29,37 +74,16 @@ export async function renderComicPage({ path, page, reqHeaders, set }: RenderOpt if (page < 0 || page >= entries.length) { set.status = 404; - await zip.reader.close(); return null; } const entry = entries[page]; - const lastModified = entry.lastModDate ?? new Date(); - const ifModifiedSince = reqHeaders.get("if-modified-since"); - - const headers = (set.headers ??= {} as Record); - headers["Date"] = new Date().toUTCString(); - headers["Last-Modified"] = lastModified.toUTCString(); - - if (ifModifiedSince) { - const cachedDate = new Date(ifModifiedSince); - if (!Number.isNaN(cachedDate.valueOf()) && lastModified <= cachedDate) { - set.status = 304; - await zip.reader.close(); - return null; - } + if (await setHeadersForEntry(entry, reqHeaders, set)) { + return null; } const readStream = await createReadableStreamFromZip(zip.reader, entry); - - const ext = entry.filename.split(".").pop()?.toLowerCase() ?? "jpeg"; - headers["Content-Type"] = extensionToMime(ext); - if (typeof entry.uncompressedSize === "number") { - headers["Content-Length"] = entry.uncompressedSize.toString(); - } - set.status = 200; - // Ensure zip file is closed after stream ends const streamWithCleanup = new ReadableStream({ async start(controller) { @@ -82,7 +106,7 @@ export async function renderComicPage({ path, page, reqHeaders, set }: RenderOpt await zip.reader.close(); } }); - + return streamWithCleanup } catch (error) { await zip.reader.close(); diff --git a/packages/server/src/route/contents.ts b/packages/server/src/route/contents.ts index cdfffb7..347f119 100644 --- a/packages/server/src/route/contents.ts +++ b/packages/server/src/route/contents.ts @@ -5,12 +5,13 @@ import type { DocumentAccessor } from "../model/doc.ts"; import { AdminOnly, createPermissionCheck, Permission as Per } from "../permission/permission.ts"; import { sendError } from "./error_handler.ts"; import { oshash } from "src/util/oshash.ts"; -import { renderComicPage } from "./comic.ts"; +import { headComicPage, renderComicPage } from "./comic.ts"; export const getContentRouter = (controller: DocumentAccessor) => { - return new Elysia({ name: "content-router", + return new Elysia({ + name: "content-router", prefix: "/doc", - }) + }) .get("/search", async ({ query }) => { const limit = Math.min(Number(query.limit ?? 20), 100); const option: QueryListOption = { @@ -156,6 +157,21 @@ export const getContentRouter = (controller: DocumentAccessor) => { } return { document, docId }; }) + .head("/comic/thumbnail", async ({ document, request, set }) => { + if (document.content_type !== "comic") { + throw sendError(404); + } + const path = join(document.basepath, document.filename); + await headComicPage({ + path, + page: 0, + reqHeaders: request.headers, + set, + }); + }, { + beforeHandle: createPermissionCheck(Per.QueryContent), + params: t.Object({ num: t.Numeric() }), + }) .get("/comic/thumbnail", async ({ document, request, set }) => { if (document.content_type !== "comic") { throw sendError(404); @@ -172,6 +188,22 @@ export const getContentRouter = (controller: DocumentAccessor) => { beforeHandle: createPermissionCheck(Per.QueryContent), params: t.Object({ num: t.Numeric() }), }) + .head("/comic/:page", async ({ document, params: { page }, request, set }) => { + if (document.content_type !== "comic") { + throw sendError(404); + } + const pageIndex = page; + const path = join(document.basepath, document.filename); + await headComicPage({ + path, + page: pageIndex, + reqHeaders: request.headers, + set, + }); + }, { + beforeHandle: createPermissionCheck(Per.QueryContent), + params: t.Object({ num: t.Numeric(), page: t.Numeric() }), + }) .get("/comic/:page", async ({ document, params: { page }, request, set }) => { if (document.content_type !== "comic") { throw sendError(404);