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
+49
View File
@@ -0,0 +1,49 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { openDatabase } from "../../server/db";
import { createCatalogRepo } from "../../server/catalog-repo";
const temps: string[] = [];
async function tempDb() {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "dc-db-"));
temps.push(dir);
const db = await openDatabase(path.join(dir, "catalogs.sqlite"));
return createCatalogRepo(db);
}
afterEach(async () => {
await Promise.all(temps.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
});
describe("catalog repo", () => {
it("inserts and reads a catalog", async () => {
const repo = await tempDb();
const created = repo.create({
id: "abc12xyz90",
deleteToken: "token-token-token-token-12",
title: "کاتالوگ",
effect: "magazine",
pageCount: 2,
});
const found = repo.getById("abc12xyz90");
expect(found).toEqual(created);
expect(found?.flipMs).toBe(1000);
expect(found?.deleteToken).toBe("token-token-token-token-12");
});
it("returns null after delete", async () => {
const repo = await tempDb();
repo.create({
id: "id1",
deleteToken: "tok",
title: "کاتالوگ",
effect: "slide",
pageCount: 1,
});
expect(repo.deleteById("id1")).toBe(true);
expect(repo.getById("id1")).toBeNull();
});
});
+225
View File
@@ -0,0 +1,225 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { createApp } from "../../server/app";
import { loadEnv } from "../../server/env";
import { LIMITS } from "../../shared/types";
import { jpegFile, TINY_PNG, TINY_JPEG } from "./helpers";
const temps: string[] = [];
async function testApp(now?: () => number) {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "dc-app-"));
temps.push(dir);
const env = loadEnv({
PUBLIC_BASE_URL: "http://localhost:3000",
STORAGE_DIR: path.join(dir, "storage"),
DATABASE_PATH: path.join(dir, "data", "catalogs.sqlite"),
NODE_ENV: "test",
});
return createApp(env, { now });
}
function form(pages: File[], extra: Record<string, string> = {}) {
const data = new FormData();
data.set("title", extra.title ?? "");
data.set("effect", extra.effect ?? "magazine");
if (extra.flipMs) data.set("flipMs", extra.flipMs);
for (const page of pages) data.append("pages", page);
return data;
}
afterEach(async () => {
await Promise.all(temps.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
});
describe("catalog HTTP API", () => {
it("creates a catalog and hides delete token on public GET", async () => {
const app = await testApp();
const created = await app.request("/api/catalogs", {
method: "POST",
body: form([jpegFile("a.jpg"), jpegFile("b.jpg")], { title: "نمونه" }),
headers: { "x-forwarded-for": "10.0.0.1" },
});
expect(created.status).toBe(201);
const body = await created.json();
expect(body.id).toMatch(/^[A-Za-z0-9_-]{10}$/);
expect(body.viewUrl).toBe(`http://localhost:3000/c/${body.id}`);
expect(body.deleteUrl).toContain("token=");
expect(body.doneUrl).toContain("/done?token=");
expect(body.qrUrl).toBe(`/api/catalogs/${body.id}/qr`);
const pub = await app.request(`/api/catalogs/${body.id}`);
expect(pub.status).toBe(200);
const publicBody = await pub.json();
expect(publicBody.title).toBe("نمونه");
expect(publicBody.pageCount).toBe(2);
expect(publicBody.flipMs).toBe(1000);
expect(publicBody.deleteToken).toBeUndefined();
expect(JSON.stringify(publicBody)).not.toContain("delete");
});
it("stores a custom flip duration", async () => {
const app = await testApp();
const created = await app.request("/api/catalogs", {
method: "POST",
body: form([jpegFile()], { flipMs: "1800" }),
headers: { "x-forwarded-for": "10.0.0.12" },
});
expect(created.status).toBe(201);
const { id } = await created.json();
const pub = await app.request(`/api/catalogs/${id}`);
expect((await pub.json()).flipMs).toBe(1800);
});
it("clamps flip duration to the allowed range", async () => {
const app = await testApp();
const tooFast = await app.request("/api/catalogs", {
method: "POST",
body: form([jpegFile()], { flipMs: "50" }),
headers: { "x-forwarded-for": "10.0.0.13" },
});
const tooSlow = await app.request("/api/catalogs", {
method: "POST",
body: form([jpegFile()], { flipMs: "9000" }),
headers: { "x-forwarded-for": "10.0.0.14" },
});
const fastId = (await tooFast.json()).id;
const slowId = (await tooSlow.json()).id;
expect((await (await app.request(`/api/catalogs/${fastId}`)).json()).flipMs).toBe(
LIMITS.minFlipMs,
);
expect((await (await app.request(`/api/catalogs/${slowId}`)).json()).flipMs).toBe(
LIMITS.maxFlipMs,
);
});
it("serves jpeg pages and png qr", async () => {
const app = await testApp();
const created = await app.request("/api/catalogs", {
method: "POST",
body: form([jpegFile()]),
headers: { "x-forwarded-for": "10.0.0.2" },
});
const { id } = await created.json();
const page = await app.request(`/api/catalogs/${id}/pages/1`);
expect(page.status).toBe(200);
expect(page.headers.get("content-type")).toBe("image/jpeg");
const qr = await app.request(`/api/catalogs/${id}/qr`);
expect(qr.status).toBe(200);
expect(qr.headers.get("content-type")).toBe("image/png");
const qrBytes = Buffer.from(await qr.arrayBuffer());
expect(qrBytes[0]).toBe(0x89);
expect(qrBytes[1]).toBe(0x50);
});
it("rejects delete without token and keeps the catalog", async () => {
const app = await testApp();
const created = await app.request("/api/catalogs", {
method: "POST",
body: form([jpegFile()]),
headers: { "x-forwarded-for": "10.0.0.3" },
});
const { id } = await created.json();
const denied = await app.request(`/api/catalogs/${id}`, { method: "DELETE" });
expect(denied.status).toBe(403);
expect((await app.request(`/api/catalogs/${id}`)).status).toBe(200);
});
it("deletes with token then returns 404", async () => {
const app = await testApp();
const created = await app.request("/api/catalogs", {
method: "POST",
body: form([jpegFile()]),
headers: { "x-forwarded-for": "10.0.0.4" },
});
const body = await created.json();
const token = new URL(body.deleteUrl).searchParams.get("token");
const del = await app.request(`/api/catalogs/${body.id}?token=${token}`, {
method: "DELETE",
});
expect(del.status).toBe(204);
expect((await app.request(`/api/catalogs/${body.id}`)).status).toBe(404);
});
it("rejects more pages than the limit", async () => {
const app = await testApp();
const pages = Array.from({ length: LIMITS.maxPages + 1 }, (_, i) =>
jpegFile(`${i}.jpg`),
);
const res = await app.request("/api/catalogs", {
method: "POST",
body: form(pages),
headers: { "x-forwarded-for": "10.0.0.5" },
});
expect(res.status).toBe(400);
expect((await res.json()).error).toContain("صفحه");
});
it("accepts a jpeg Blob the way the browser sends pages", async () => {
const app = await testApp();
const data = new FormData();
data.set("title", "");
data.set("effect", "slide");
data.append(
"pages",
new Blob([TINY_JPEG], { type: "image/jpeg" }),
"page-001.jpg",
);
const res = await app.request("/api/catalogs", {
method: "POST",
body: data,
headers: { "x-forwarded-for": "10.0.0.11" },
});
expect(res.status).toBe(201);
});
it("rejects an invalid effect", async () => {
const app = await testApp();
const res = await app.request("/api/catalogs", {
method: "POST",
body: form([jpegFile()], { effect: "explode" }),
headers: { "x-forwarded-for": "10.0.0.6" },
});
expect(res.status).toBe(400);
expect((await res.json()).error).toBe("افکت نامعتبر است");
});
it("rejects non-jpeg pages", async () => {
const app = await testApp();
const res = await app.request("/api/catalogs", {
method: "POST",
body: form([new File([TINY_PNG], "x.png", { type: "image/png" })]),
headers: { "x-forwarded-for": "10.0.0.7" },
});
expect(res.status).toBe(400);
expect((await res.json()).error).toBe("نوع فایل پشتیبانی نمی‌شود");
});
it("rate-limits the 11th create from one IP", async () => {
const app = await testApp(() => 1_000);
for (let i = 0; i < 10; i++) {
const res = await app.request("/api/catalogs", {
method: "POST",
body: form([jpegFile()]),
headers: { "x-forwarded-for": "10.0.0.8" },
});
expect(res.status).toBe(201);
}
const blocked = await app.request("/api/catalogs", {
method: "POST",
body: form([jpegFile()]),
headers: { "x-forwarded-for": "10.0.0.8" },
});
expect(blocked.status).toBe(429);
});
it("has no public catalog list", async () => {
const app = await testApp();
const res = await app.request("/api/catalogs");
expect(res.status).toBe(404);
});
});
+10
View File
@@ -0,0 +1,10 @@
/** Minimal JPEG (SOI + EOI) used only for API/storage tests. */
export const TINY_JPEG = Buffer.from([0xff, 0xd8, 0xff, 0xd9]);
export const TINY_PNG = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
]);
export function jpegFile(name = "page.jpg"): File {
return new File([TINY_JPEG], name, { type: "image/jpeg" });
}
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { createRateLimiter } from "../../server/rate-limit";
describe("createRateLimiter", () => {
it("allows the first 10 hits from an IP", () => {
let now = 1_000;
const limiter = createRateLimiter(10, 60 * 60 * 1000, () => now);
for (let i = 0; i < 10; i++) {
expect(limiter.check("1.1.1.1")).toBe(true);
}
});
it("rejects the 11th hit within the window", () => {
let now = 1_000;
const limiter = createRateLimiter(10, 60 * 60 * 1000, () => now);
for (let i = 0; i < 10; i++) {
limiter.check("1.1.1.1");
}
expect(limiter.check("1.1.1.1")).toBe(false);
});
it("allows another hit after the window elapses", () => {
let now = 1_000;
const windowMs = 60 * 60 * 1000;
const limiter = createRateLimiter(10, windowMs, () => now);
for (let i = 0; i < 10; i++) {
limiter.check("1.1.1.1");
}
now += windowMs + 1;
expect(limiter.check("1.1.1.1")).toBe(true);
});
it("tracks IPs independently", () => {
const limiter = createRateLimiter(1, 60_000, () => 1_000);
expect(limiter.check("a")).toBe(true);
expect(limiter.check("a")).toBe(false);
expect(limiter.check("b")).toBe(true);
});
});
+48
View File
@@ -0,0 +1,48 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
deleteCatalogFiles,
readPage,
saveCatalogPages,
StorageError,
} from "../../server/storage";
import { TINY_JPEG, TINY_PNG } from "./helpers";
const temps: string[] = [];
async function tempDir() {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "dc-storage-"));
temps.push(dir);
return dir;
}
afterEach(async () => {
await Promise.all(temps.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
});
describe("saveCatalogPages", () => {
it("writes numbered jpegs and can read them back", async () => {
const dir = await tempDir();
await saveCatalogPages(dir, "abc", [TINY_JPEG, TINY_JPEG]);
const page1 = await readPage(dir, "abc", 1);
expect(page1.equals(TINY_JPEG)).toBe(true);
const page2 = await readPage(dir, "abc", 2);
expect(page2.equals(TINY_JPEG)).toBe(true);
});
it("rejects non-jpeg pages", async () => {
const dir = await tempDir();
await expect(saveCatalogPages(dir, "abc", [TINY_PNG])).rejects.toBeInstanceOf(
StorageError,
);
});
it("deletes files so later reads fail", async () => {
const dir = await tempDir();
await saveCatalogPages(dir, "abc", [TINY_JPEG]);
await deleteCatalogFiles(dir, "abc");
await expect(readPage(dir, "abc", 1)).rejects.toBeInstanceOf(StorageError);
});
});