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,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;
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
Reference in New Issue
Block a user