199 lines
5.5 KiB
TypeScript
199 lines
5.5 KiB
TypeScript
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;
|
|
}
|