From fe5ed4c4aa8c647e42e6daf31247700f67eafab5 Mon Sep 17 00:00:00 2001 From: monoid Date: Thu, 23 Oct 2025 23:41:08 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20comic=20=ED=8E=98=EC=9D=B4=EC=A7=80?= =?UTF-8?q?=EC=97=90=20=EB=8C=80=ED=95=9C=20=ED=97=A4=EB=8D=94=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20=EB=B0=8F?= =?UTF-8?q?=20=EB=9D=BC=EC=9A=B0=ED=84=B0=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/server/src/route/comic.ts | 72 ++++++++++++++++++--------- packages/server/src/route/contents.ts | 38 ++++++++++++-- 2 files changed, 83 insertions(+), 27 deletions(-) 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);