Add digital catalog with shareable flipbook and ktg.montzar.ir deploy files.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
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<Buffer[]> {
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user