feat: comic 페이지에 대한 헤더 설정 기능 추가 및 라우터 개선

This commit is contained in:
monoid 2025-10-23 23:41:08 +09:00
parent e2c451c708
commit fe5ed4c4aa
2 changed files with 83 additions and 27 deletions

View file

@ -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<string, string | number>);
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();
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) {

View file

@ -5,10 +5,11 @@ 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 }) => {
@ -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);