Files
MahdiandCursor c236b0f621 Add display frames and improve create-page page management.
Store phone/tablet/photo-frame shells with catalogs, append pages from extra images or PDFs, and reorder drafts with drag-and-drop.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 14:20:16 +03:30

208 lines
5.8 KiB
TypeScript

import { Hono } from "hono";
import { nanoid } from "nanoid";
import {
DEFAULT_TITLE,
clampFlipMs,
fileTooLargeMessage,
isEffectId,
isFrameId,
DEFAULT_FRAME,
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"];
frame: CatalogPublic["frame"];
pageCount: number;
flipMs: number;
createdAt: string;
}): CatalogPublic {
return {
id: row.id,
title: row.title,
effect: row.effect,
frame: row.frame,
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 frameRaw = typeof body.frame === "string" && body.frame ? body.frame : DEFAULT_FRAME;
if (!isFrameId(frameRaw)) {
throw new HttpError(400, messages.badFrame);
}
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,
frame: frameRaw,
});
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;
}