import { Hono } from "hono"; import { nanoid } from "nanoid"; import { DEFAULT_TITLE, clampFlipMs, fileTooLargeMessage, isEffectId, LIMITS, tooManyPagesMessage, type CreateCatalogResponse, type CatalogPublic, } from "../../shared/types"; import type { CatalogRepo } from "../catalog-repo"; import type { Env } from "../env"; import { HttpError, messages } from "../errors"; import { isJpeg } from "../magic"; import { makeQrPng } from "../qr"; import type { RateLimiter } from "../rate-limit"; import { deleteCatalogFiles, readPage, readQr, saveCatalogPages, saveQr, StorageError, } from "../storage"; function clientIp(c: { req: { header: (name: string) => string | undefined } }): string { return ( c.req.header("x-forwarded-for")?.split(",")[0]?.trim() || c.req.header("x-real-ip") || "127.0.0.1" ); } function toPublic(row: { id: string; title: string; effect: CatalogPublic["effect"]; pageCount: number; flipMs: number; createdAt: string; }): CatalogPublic { return { id: row.id, title: row.title, effect: row.effect, pageCount: row.pageCount, flipMs: row.flipMs, createdAt: row.createdAt, }; } async function readPageBuffers(pages: Blob[]): Promise { const buffers: Buffer[] = []; for (const file of pages) { if (file.size > LIMITS.maxFileBytes) { throw new HttpError(400, fileTooLargeMessage(file.size)); } const buf = Buffer.from(await file.arrayBuffer()); if (!isJpeg(buf)) { throw new HttpError(400, messages.badType); } buffers.push(buf); } return buffers; } function isBlobLike(value: unknown): value is Blob { return ( typeof value === "object" && value !== null && typeof (value as Blob).arrayBuffer === "function" && typeof (value as Blob).size === "number" ); } function asFileArray(value: unknown): Blob[] { if (!value) return []; const list = Array.isArray(value) ? value : [value]; return list.filter(isBlobLike); } export function catalogRoutes(opts: { env: Env; repo: CatalogRepo; limiter: RateLimiter; }) { const app = new Hono(); app.post("/catalogs", async (c) => { const ip = clientIp(c); if (!opts.limiter.check(ip)) { throw new HttpError(429, messages.rateLimited); } const body = await c.req.parseBody({ all: true }); const titleRaw = typeof body.title === "string" ? body.title.trim() : ""; const title = titleRaw || DEFAULT_TITLE; const effectRaw = typeof body.effect === "string" ? body.effect : ""; if (!isEffectId(effectRaw)) { throw new HttpError(400, messages.badEffect); } const flipMs = clampFlipMs(body.flipMs); const pages = asFileArray(body.pages); if (pages.length < 1) { throw new HttpError(400, "هیچ صفحه‌ای دریافت نشد."); } if (pages.length > LIMITS.maxPages) { throw new HttpError(400, tooManyPagesMessage(pages.length)); } const buffers = await readPageBuffers(pages); const id = nanoid(10); const deleteToken = nanoid(24); await saveCatalogPages(opts.env.storageDir, id, buffers); const viewUrl = `${opts.env.publicBaseUrl}/c/${id}`; const qrPng = await makeQrPng(viewUrl); await saveQr(opts.env.storageDir, id, qrPng); opts.repo.create({ id, deleteToken, title, effect: effectRaw, pageCount: buffers.length, flipMs, }); const payload: CreateCatalogResponse = { id, viewUrl, deleteUrl: `${opts.env.publicBaseUrl}/c/${id}/delete?token=${deleteToken}`, qrUrl: `/api/catalogs/${id}/qr`, doneUrl: `${opts.env.publicBaseUrl}/c/${id}/done?token=${deleteToken}`, }; return c.json(payload, 201); }); app.get("/catalogs/:id", (c) => { const row = opts.repo.getById(c.req.param("id")); if (!row) throw new HttpError(404, messages.notFound); return c.json(toPublic(row)); }); app.get("/catalogs/:id/pages/:n", async (c) => { const row = opts.repo.getById(c.req.param("id")); if (!row) throw new HttpError(404, messages.notFound); const n = Number(c.req.param("n")); if (!Number.isInteger(n) || n < 1 || n > row.pageCount) { throw new HttpError(404, messages.notFound); } try { const buf = await readPage(opts.env.storageDir, row.id, n); return new Response(buf, { headers: { "Content-Type": "image/jpeg", "Cache-Control": "public, max-age=31536000, immutable", }, }); } catch (err) { if (err instanceof StorageError) throw new HttpError(404, messages.notFound); throw err; } }); app.get("/catalogs/:id/qr", async (c) => { const row = opts.repo.getById(c.req.param("id")); if (!row) throw new HttpError(404, messages.notFound); try { const buf = await readQr(opts.env.storageDir, row.id); return new Response(buf, { headers: { "Content-Type": "image/png" }, }); } catch (err) { if (err instanceof StorageError) throw new HttpError(404, messages.notFound); throw err; } }); app.delete("/catalogs/:id", async (c) => { const token = c.req.query("token") ?? ""; if (!token) throw new HttpError(403, messages.forbiddenDelete); const row = opts.repo.getById(c.req.param("id")); if (!row) throw new HttpError(404, messages.notFound); if (row.deleteToken !== token) { throw new HttpError(403, messages.forbiddenDelete); } opts.repo.deleteById(row.id); await deleteCatalogFiles(opts.env.storageDir, row.id); return c.body(null, 204); }); return app; }