Add digital catalog with shareable flipbook and ktg.montzar.ir deploy files.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-19 14:37:49 +03:30
co-authored by Cursor
commit a8374e7d6c
54 changed files with 8462 additions and 0 deletions
+44
View File
@@ -0,0 +1,44 @@
import { Hono } from "hono";
import { serveStatic } from "@hono/node-server/serve-static";
import { readFile } from "node:fs/promises";
import path from "node:path";
import { LIMITS } from "../shared/types";
import { createCatalogRepo } from "./catalog-repo";
import { openDatabase } from "./db";
import type { Env } from "./env";
import { HttpError } from "./errors";
import { createRateLimiter } from "./rate-limit";
import { catalogRoutes } from "./routes/catalogs";
export async function createApp(env: Env, options?: { now?: () => number }) {
const db = await openDatabase(env.databasePath);
const repo = createCatalogRepo(db);
const limiter = createRateLimiter(
LIMITS.maxCreatesPerHour,
LIMITS.rateWindowMs,
options?.now,
);
const app = new Hono();
app.onError((err, c) => {
if (err instanceof HttpError) {
return c.json({ error: err.message }, err.status as 400);
}
console.error(err);
return c.json({ error: "خطای داخلی سرور" }, 500);
});
app.route("/api", catalogRoutes({ env, repo, limiter }));
if (env.isProduction) {
const dist = "./dist";
app.use("/*", serveStatic({ root: dist }));
app.get("*", async (c) => {
const html = await readFile(path.join("dist", "index.html"), "utf8");
return c.html(html);
});
}
return app;
}
+79
View File
@@ -0,0 +1,79 @@
import { eq } from "drizzle-orm";
import type { EffectId } from "../shared/types";
import { DEFAULT_FLIP_MS } from "../shared/types";
import type { DbBundle } from "./db";
import { catalogsTable } from "./schema";
export type CatalogRow = {
id: string;
deleteToken: string;
title: string;
effect: EffectId;
pageCount: number;
flipMs: number;
createdAt: string;
};
export type CatalogInsert = Omit<CatalogRow, "createdAt" | "flipMs"> & {
createdAt?: string;
flipMs?: number;
};
function toRow(record: typeof catalogsTable.$inferSelect): CatalogRow {
return {
id: record.id,
deleteToken: record.deleteToken,
title: record.title,
effect: record.effect as EffectId,
pageCount: record.pageCount,
flipMs: record.flipMs ?? DEFAULT_FLIP_MS,
createdAt: record.createdAt,
};
}
export function createCatalogRepo(db: DbBundle) {
return {
create(input: CatalogInsert): CatalogRow {
const createdAt = input.createdAt ?? new Date().toISOString();
const flipMs = input.flipMs ?? DEFAULT_FLIP_MS;
db.drizzle.insert(catalogsTable).values({
id: input.id,
deleteToken: input.deleteToken,
title: input.title,
effect: input.effect,
pageCount: input.pageCount,
flipMs,
createdAt,
}).run();
db.persist();
return {
id: input.id,
deleteToken: input.deleteToken,
title: input.title,
effect: input.effect,
pageCount: input.pageCount,
flipMs,
createdAt,
};
},
getById(id: string): CatalogRow | null {
const row = db.drizzle
.select()
.from(catalogsTable)
.where(eq(catalogsTable.id, id))
.get();
return row ? toRow(row) : null;
},
deleteById(id: string): boolean {
const existing = this.getById(id);
if (!existing) return false;
db.drizzle.delete(catalogsTable).where(eq(catalogsTable.id, id)).run();
db.persist();
return true;
},
};
}
export type CatalogRepo = ReturnType<typeof createCatalogRepo>;
+58
View File
@@ -0,0 +1,58 @@
import fs from "node:fs";
import path from "node:path";
import { createRequire } from "node:module";
import initSqlJs, { type Database as SqlJsDatabase } from "sql.js";
import { drizzle, type SQLJsDatabase } from "drizzle-orm/sql-js";
import { catalogsTable } from "./schema";
const require = createRequire(import.meta.url);
export type DbBundle = {
sqlite: SqlJsDatabase;
drizzle: SQLJsDatabase;
persist: () => void;
};
export async function openDatabase(databasePath: string): Promise<DbBundle> {
fs.mkdirSync(path.dirname(databasePath), { recursive: true });
const sqlJsDist = path.dirname(require.resolve("sql.js/dist/sql-wasm.js"));
const SQL = await initSqlJs({
locateFile: (file) => path.join(sqlJsDist, file),
});
const sqlite = fs.existsSync(databasePath)
? new SQL.Database(fs.readFileSync(databasePath))
: new SQL.Database();
sqlite.run(`
CREATE TABLE IF NOT EXISTS catalogs (
id TEXT PRIMARY KEY,
delete_token TEXT NOT NULL,
title TEXT NOT NULL,
effect TEXT NOT NULL,
page_count INTEGER NOT NULL,
flip_ms INTEGER NOT NULL DEFAULT 1000,
created_at TEXT NOT NULL
)
`);
const info = sqlite.exec("PRAGMA table_info(catalogs)");
const colNames = new Set((info[0]?.values ?? []).map((row) => String(row[1])));
if (!colNames.has("flip_ms")) {
sqlite.run("ALTER TABLE catalogs ADD COLUMN flip_ms INTEGER NOT NULL DEFAULT 1000");
}
const persist = () => {
fs.mkdirSync(path.dirname(databasePath), { recursive: true });
fs.writeFileSync(databasePath, Buffer.from(sqlite.export()));
};
persist();
return {
sqlite,
drizzle: drizzle(sqlite, { schema: { catalogsTable } }),
persist,
};
}
+30
View File
@@ -0,0 +1,30 @@
import path from "node:path";
export type Env = {
port: number;
apiPort: number;
publicBaseUrl: string;
storageDir: string;
databasePath: string;
isProduction: boolean;
};
export function loadEnv(overrides: Partial<NodeJS.ProcessEnv> = {}): Env {
const read = (key: string, fallback: string) =>
overrides[key] ?? process.env[key] ?? fallback;
const isProduction =
(overrides.NODE_ENV ?? process.env.NODE_ENV) === "production";
return {
port: Number(read("PORT", "3000")),
apiPort: Number(read("API_PORT", "3010")),
publicBaseUrl: read("PUBLIC_BASE_URL", "http://localhost:3000").replace(
/\/$/,
"",
),
storageDir: path.resolve(read("STORAGE_DIR", "./storage")),
databasePath: path.resolve(read("DATABASE_PATH", "./data/catalogs.sqlite")),
isProduction,
};
}
+19
View File
@@ -0,0 +1,19 @@
export class HttpError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
this.name = "HttpError";
}
}
export const messages = {
tooLarge: "حجم یا تعداد صفحه بیش از حد مجاز است",
badType: "نوع فایل پشتیبانی نمی‌شود",
badEffect: "افکت نامعتبر است",
notFound: "کاتالوگ پیدا نشد",
forbiddenDelete: "اجازه حذف ندارید",
rateLimited: "تعداد ساخت از این آدرس بیش از حد مجاز است",
tokenRequired: "لینک حذف نامعتبر است",
} as const;
+30
View File
@@ -0,0 +1,30 @@
import { serve } from "@hono/node-server";
import { createApp } from "./app";
import { loadEnv } from "./env";
const env = loadEnv();
const lifecycle = process.env.npm_lifecycle_event ?? "";
const serveUi = env.isProduction || lifecycle === "start";
const app = await createApp({ ...env, isProduction: serveUi });
const port = serveUi ? env.port : env.apiPort;
try {
serve({ fetch: app.fetch, port, hostname: "127.0.0.1" }, () => {
console.log(
`Listening on http://127.0.0.1:${port}${serveUi ? " (app + api)" : " (api)"}`,
);
});
} catch (err) {
console.error(err);
process.exit(1);
}
process.on("uncaughtException", (err: NodeJS.ErrnoException) => {
if (err.code === "EADDRINUSE") {
console.error(
`Port ${port} is already in use. Stop the other npm run dev / node process, then start again.`,
);
process.exit(1);
}
throw err;
});
+19
View File
@@ -0,0 +1,19 @@
/** JPEG SOI: FF D8 FF */
export function isJpeg(buffer: Buffer): boolean {
return (
buffer.length >= 3 &&
buffer[0] === 0xff &&
buffer[1] === 0xd8 &&
buffer[2] === 0xff
);
}
export function isPng(buffer: Buffer): boolean {
return (
buffer.length >= 8 &&
buffer[0] === 0x89 &&
buffer[1] === 0x50 &&
buffer[2] === 0x4e &&
buffer[3] === 0x47
);
}
+10
View File
@@ -0,0 +1,10 @@
import QRCode from "qrcode";
export async function makeQrPng(text: string): Promise<Buffer> {
return QRCode.toBuffer(text, {
type: "png",
width: 512,
margin: 1,
errorCorrectionLevel: "M",
});
}
+25
View File
@@ -0,0 +1,25 @@
export type RateLimiter = {
check: (ip: string) => boolean;
};
export function createRateLimiter(
limit: number,
windowMs: number,
now: () => number = () => Date.now(),
): RateLimiter {
const hits = new Map<string, number[]>();
return {
check(ip: string) {
const t = now();
const recent = (hits.get(ip) ?? []).filter((ts) => t - ts < windowMs);
if (recent.length >= limit) {
hits.set(ip, recent);
return false;
}
recent.push(t);
hits.set(ip, recent);
return true;
},
};
}
+198
View File
@@ -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;
}
+11
View File
@@ -0,0 +1,11 @@
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
export const catalogsTable = sqliteTable("catalogs", {
id: text("id").primaryKey(),
deleteToken: text("delete_token").notNull(),
title: text("title").notNull(),
effect: text("effect").notNull(),
pageCount: integer("page_count").notNull(),
flipMs: integer("flip_ms").notNull(),
createdAt: text("created_at").notNull(),
});
+75
View File
@@ -0,0 +1,75 @@
import fs from "node:fs/promises";
import path from "node:path";
import { isJpeg } from "./magic";
export class StorageError extends Error {
constructor(message: string) {
super(message);
this.name = "StorageError";
}
}
export function pageFileName(n: number): string {
return `page-${String(n).padStart(3, "0")}.jpg`;
}
export function catalogDir(storageDir: string, id: string): string {
return path.join(storageDir, "catalogs", id);
}
export async function saveCatalogPages(
storageDir: string,
id: string,
pages: Buffer[],
): Promise<void> {
const dir = catalogDir(storageDir, id);
await fs.mkdir(dir, { recursive: true });
let i = 1;
for (const page of pages) {
if (!isJpeg(page)) {
await fs.rm(dir, { recursive: true, force: true });
throw new StorageError("نوع فایل پشتیبانی نمی‌شود");
}
await fs.writeFile(path.join(dir, pageFileName(i)), page);
i += 1;
}
}
export async function saveQr(
storageDir: string,
id: string,
png: Buffer,
): Promise<void> {
const dir = catalogDir(storageDir, id);
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(path.join(dir, "qr.png"), png);
}
export async function readPage(
storageDir: string,
id: string,
n: number,
): Promise<Buffer> {
const file = path.join(catalogDir(storageDir, id), pageFileName(n));
try {
return await fs.readFile(file);
} catch {
throw new StorageError("کاتالوگ پیدا نشد");
}
}
export async function readQr(storageDir: string, id: string): Promise<Buffer> {
const file = path.join(catalogDir(storageDir, id), "qr.png");
try {
return await fs.readFile(file);
} catch {
throw new StorageError("کاتالوگ پیدا نشد");
}
}
export async function deleteCatalogFiles(
storageDir: string,
id: string,
): Promise<void> {
await fs.rm(catalogDir(storageDir, id), { recursive: true, force: true });
}