This commit is contained in:
Your Name committed 2026-07-25 00:54:35 -04:00
1 parent 94cbb52348
commit 167d07f0c2
7 files changed
+1284 -21

No files matched your search

@@ -0,0 +1,169 @@
import { describe, expect, test } from "bun:test";
import {
createMenuTranslations,
executeVersionedMenuWrite,
fingerprintMenu,
isValidMenuUrl,
lockMenuForTransaction,
menusRoute,
resolveMenuItemLabel,
} from "./menus";
const menu = {
id: 10,
name: "footer-explore",
items: [
{
id: 2,
label: "Contact",
url: "/contact",
order: 1,
columnImage: null,
hoverImage: null,
translations: [
{ langCode: "zh", label: "联系我们" },
{ langCode: "th", label: "ติดต่อเรา" },
],
},
{
id: 1,
label: "Shop",
url: "/shop",
order: 0,
columnImage: null,
hoverImage: null,
translations: [],
},
],
};
describe("footer menu helpers", () => {
test("creates a deterministic SHA-256 fingerprint independent of query ordering", () => {
const reordered = {
...menu,
items: [...menu.items].reverse().map((item) => ({
...item,
translations: [...item.translations].reverse(),
})),
};
expect(fingerprintMenu(menu)).toBe(fingerprintMenu(reordered));
expect(fingerprintMenu(menu)).toMatch(/^sha256:[a-f0-9]{64}$/);
});
test("changes the fingerprint when editable content changes", () => {
const changed = structuredClone(menu);
changed.items[0]!.url = "/support";
expect(fingerprintMenu(changed)).not.toBe(fingerprintMenu(menu));
});
test("resolves a requested translation without returning all languages", () => {
expect(resolveMenuItemLabel(menu.items[0]!, "zh", false)).toEqual({
label: "联系我们",
usedFallback: false,
});
});
test("reports fallback use and can suppress the base label", () => {
expect(resolveMenuItemLabel(menu.items[1]!, "zh", true)).toEqual({
label: "Shop",
usedFallback: true,
});
expect(resolveMenuItemLabel(menu.items[1]!, "zh", false)).toEqual({
label: null,
usedFallback: true,
});
});
test("accepts safe menu URL schemes and rejects unsafe values", () => {
expect(isValidMenuUrl("/collections/fine-fragrance")).toBe(true);
expect(isValidMenuUrl("https://example.com/contact")).toBe(true);
expect(isValidMenuUrl("http://example.com/contact")).toBe(true);
expect(isValidMenuUrl("mailto:test@example.com")).toBe(true);
expect(isValidMenuUrl("mailto:test@example.com?subject=Support")).toBe(true);
expect(isValidMenuUrl("tel:+1-555-123-4567")).toBe(true);
expect(isValidMenuUrl("tel:+66(0)2123-4567")).toBe(true);
expect(isValidMenuUrl("mailto:not-an-address")).toBe(false);
expect(isValidMenuUrl("tel:call-us")).toBe(false);
expect(isValidMenuUrl("//example.com")).toBe(false);
expect(isValidMenuUrl("javascript:alert(1)")).toBe(false);
expect(isValidMenuUrl("/bad path")).toBe(false);
expect(isValidMenuUrl("mailto:test@example.com%0d%0aBcc:attacker@example.com")).toBe(false);
});
test("maps supported translation keys for atomic item creation", () => {
expect(createMenuTranslations({
th: "สินค้า",
zh: "产品",
})).toEqual({
translations: [
{ langCode: "th", label: "สินค้า" },
{ langCode: "zh", label: "产品" },
],
});
expect(createMenuTranslations({ de: "Produkte" })).toEqual({
error: "Unsupported locale: de",
translations: [],
});
});
test("does not invoke the write callback for a stale version", async () => {
let writes = 0;
const result = await executeVersionedMenuWrite(
menu,
"sha256:stale",
async () => {
writes += 1;
return "written";
},
);
expect(result.written).toBe(false);
expect(writes).toBe(0);
expect(result.currentVersion).toBe(fingerprintMenu(menu));
});
test("takes a transaction-scoped PostgreSQL advisory lock using the menu id", async () => {
let query = "";
let values: unknown[] = [];
await lockMenuForTransaction({
$queryRaw: async (strings: TemplateStringsArray, ...parameters: unknown[]) => {
query = strings.join("?");
values = parameters;
return [];
},
}, menu.id);
expect(query).toBe("SELECT pg_advisory_xact_lock(?)");
expect(values).toEqual([menu.id]);
});
});
describe("footer menu route validation", () => {
test("rejects unsupported menu names before querying the database", async () => {
const response = await menusRoute.handle(
new Request("http://localhost/menus/footer/header"),
);
expect(response.status).toBe(400);
expect(await response.json()).toMatchObject({
success: false,
code: "INVALID_FOOTER_MENU",
partialWrite: false,
});
});
test("rejects unsupported locales before querying the database", async () => {
const response = await menusRoute.handle(
new Request("http://localhost/menus/footer/footer-explore?lang=de"),
);
expect(response.status).toBe(400);
expect(await response.json()).toMatchObject({
success: false,
code: "INVALID_LOCALE",
partialWrite: false,
});
});
});
+538
View File
@@ -2,6 +2,233 @@ import { Elysia, t } from "elysia";
import prisma from "../lib/prisma.js";
import { adminOnly } from "../middleware/auth.js";
export const FOOTER_MENU_NAMES = ["footer-explore", "footer-support"] as const;
export const MENU_LANGUAGES = ["th", "en", "ja", "zh", "vi", "ko", "lo", "it"] as const;
type FooterMenuName = typeof FOOTER_MENU_NAMES[number];
type MenuLanguage = typeof MENU_LANGUAGES[number];
type MenuTranslation = { langCode: string; label: string };
type VersionedMenuItem = {
id: number;
label: string;
url: string;
order: number;
columnImage: string | null;
hoverImage: string | null;
translations: MenuTranslation[];
};
type VersionedMenu = {
id: number;
name: string;
items: VersionedMenuItem[];
};
const footerMenuInclude = {
items: {
orderBy: [{ order: "asc" as const }, { id: "asc" as const }],
include: { translations: true },
},
};
const isFooterMenuName = (name: string): name is FooterMenuName =>
FOOTER_MENU_NAMES.includes(name as FooterMenuName);
const isMenuLanguage = (lang: string): lang is MenuLanguage =>
MENU_LANGUAGES.includes(lang as MenuLanguage);
const normalizeMenuForVersion = (menu: VersionedMenu) => ({
name: menu.name,
items: [...menu.items]
.sort((a, b) => a.order - b.order || a.id - b.id)
.map((item) => ({
id: item.id,
label: item.label,
url: item.url,
order: item.order,
columnImage: item.columnImage,
hoverImage: item.hoverImage,
translations: [...item.translations]
.sort((a, b) => a.langCode.localeCompare(b.langCode))
.map(({ langCode, label }) => ({ langCode, label })),
})),
});
export function fingerprintMenu(menu: VersionedMenu): string {
const serialized = JSON.stringify(normalizeMenuForVersion(menu));
return `sha256:${new Bun.CryptoHasher("sha256").update(serialized).digest("hex")}`;
}
export function resolveMenuItemLabel(
item: Pick<VersionedMenuItem, "label" | "translations">,
lang: MenuLanguage,
fallback = true,
) {
const translation = item.translations.find((entry) => entry.langCode === lang);
if (translation) return { label: translation.label, usedFallback: false };
return { label: fallback ? item.label : null, usedFallback: true };
}
export function isValidMenuUrl(url: string): boolean {
if (!url || /\s/.test(url) || /%0a|%0d/i.test(url)) return false;
if (url.startsWith("/") && !url.startsWith("//")) return true;
try {
const parsed = new URL(url);
if (parsed.protocol === "https:" || parsed.protocol === "http:") return true;
if (parsed.protocol === "mailto:") {
return /^[^@/?#]+@[^@/?#]+$/.test(parsed.pathname);
}
if (parsed.protocol === "tel:") {
return /^\+?[0-9().-]+$/.test(parsed.pathname);
}
return false;
} catch {
return false;
}
}
export function createMenuTranslations(translations: Record<string, string> = {}) {
const entries = Object.entries(translations);
const invalidLocale = entries.find(([lang]) => !isMenuLanguage(lang));
if (invalidLocale) {
return { error: `Unsupported locale: ${invalidLocale[0]}`, translations: [] };
}
return {
translations: entries.map(([langCode, label]) => ({ langCode, label })),
};
}
const errorResponse = (set: { status?: number | string }, status: number, code: string, error: string) => {
set.status = status;
return { success: false, code, error, partialWrite: false };
};
const getFooterMenu = async (
name: FooterMenuName,
db: any = prisma,
): Promise<VersionedMenu | null> =>
db.menu.findUnique({
where: { name },
include: footerMenuInclude,
});
export const lockMenuForTransaction = async (tx: any, menuId: number) => {
await tx.$queryRaw`SELECT pg_advisory_xact_lock(${menuId})`;
};
export async function executeVersionedMenuWrite<T>(
menu: VersionedMenu,
expectedVersion: unknown,
write: () => Promise<T>,
) {
const currentVersion = fingerprintMenu(menu);
if (typeof expectedVersion === "string" && expectedVersion !== currentVersion) {
return {
written: false as const,
currentVersion,
};
}
return {
written: true as const,
currentVersion,
value: await write(),
};
}
const checkVersion = (
menu: VersionedMenu,
expectedVersion: unknown,
set: { status?: number | string },
) => {
const currentVersion = fingerprintMenu(menu);
if (typeof expectedVersion === "string" && expectedVersion !== currentVersion) {
return {
conflict: errorResponse(
set,
409,
"MENU_VERSION_CONFLICT",
"Menu changed after it was read. Fetch the menu again before writing.",
),
currentVersion,
};
}
return { currentVersion };
};
const runMenuTransaction = async <T>(
name: FooterMenuName,
expectedVersion: unknown,
write: (tx: any, menu: VersionedMenu) => Promise<T>,
) =>
prisma.$transaction(async (tx: any) => {
const identity = await tx.menu.findUnique({
where: { name },
select: { id: true },
});
if (!identity) return { missing: true as const };
await lockMenuForTransaction(tx, identity.id);
const menu = await getFooterMenu(name, tx);
if (!menu) return { missing: true as const };
return executeVersionedMenuWrite(menu, expectedVersion, async () => {
const value = await write(tx, menu);
const updated = await getFooterMenu(name, tx);
if (!updated) throw new Error(`Menu ${name} disappeared during transaction.`);
return { value, version: fingerprintMenu(updated) };
});
});
const unwrapTransaction = (
result: Awaited<ReturnType<typeof runMenuTransaction>>,
name: FooterMenuName,
set: { status?: number | string },
) => {
if ("missing" in result) {
return { error: errorResponse(set, 404, "MENU_NOT_FOUND", `Menu ${name} was not found.`) };
}
if (!result.written) {
return {
error: errorResponse(
set,
409,
"MENU_VERSION_CONFLICT",
"Menu changed after it was read. Fetch the menu again before writing.",
),
};
}
return { value: result.value };
};
const compactMenu = (
menu: VersionedMenu,
lang: MenuLanguage | "all",
fallback: boolean,
) => ({
name: menu.name,
lang,
version: fingerprintMenu(menu),
items: menu.items.map((item) => {
const base = {
id: item.id,
url: item.url,
order: item.order,
columnImage: item.columnImage,
hoverImage: item.hoverImage,
};
if (lang === "all") {
return {
...base,
label: item.label,
translations: Object.fromEntries(
[...item.translations]
.sort((a, b) => a.langCode.localeCompare(b.langCode))
.map((translation) => [translation.langCode, translation.label]),
),
};
}
return { ...base, ...resolveMenuItemLabel(item, lang, fallback) };
}),
});
export const menusRoute = new Elysia({ prefix: "/menus" })
.get("/", async () => {
return await prisma.menu.findMany({
@@ -13,6 +240,20 @@ export const menusRoute = new Elysia({ prefix: "/menus" })
}
});
})
.get("/footer/:name", async ({ params: { name }, query, set }: any) => {
if (!isFooterMenuName(name)) {
return errorResponse(set, 400, "INVALID_FOOTER_MENU", "Only footer-explore and footer-support are supported.");
}
const lang = query.lang ?? "en";
if (lang !== "all" && !isMenuLanguage(lang)) {
return errorResponse(set, 400, "INVALID_LOCALE", `Unsupported locale: ${lang}`);
}
const menu = await getFooterMenu(name);
if (!menu) {
return errorResponse(set, 404, "MENU_NOT_FOUND", `Menu ${name} was not found.`);
}
return compactMenu(menu, lang, query.fallback !== "false");
})
.get("/:name", async ({ params: { name }, set }) => {
const menu = await prisma.menu.findUnique({
where: { name },
@@ -29,6 +270,302 @@ export const menusRoute = new Elysia({ prefix: "/menus" })
return menu;
})
.use(adminOnly)
.post("/footer/:name/items", async ({ params: { name }, body, set }: any) => {
if (!isFooterMenuName(name)) {
return errorResponse(set, 400, "INVALID_FOOTER_MENU", "Only footer-explore and footer-support are supported.");
}
const input = body as any;
if (!isValidMenuUrl(input.url)) {
return errorResponse(set, 400, "INVALID_MENU_URL", "URL must be a root-relative path or an HTTP(S) URL.");
}
const parsedTranslations = createMenuTranslations(input.translations);
if (parsedTranslations.error) {
return errorResponse(set, 400, "INVALID_LOCALE", parsedTranslations.error);
}
const menu = await getFooterMenu(name);
if (!menu) return errorResponse(set, 404, "MENU_NOT_FOUND", `Menu ${name} was not found.`);
const versionCheck = checkVersion(menu, input.expectedVersion, set);
if (versionCheck.conflict) return versionCheck.conflict;
const preview = {
label: input.label,
url: input.url,
order: input.order ?? menu.items.length,
columnImage: input.columnImage ?? null,
hoverImage: input.hoverImage ?? null,
};
if (input.dryRun === true) {
return {
success: true,
dryRun: true,
operation: "add",
version: versionCheck.currentVersion,
item: { ...preview, translations: input.translations ?? {} },
};
}
const transaction = unwrapTransaction(
await runMenuTransaction(name, input.expectedVersion, (tx, currentMenu) =>
tx.menuItem.create({
data: {
menuId: currentMenu.id,
...preview,
order: input.order ?? currentMenu.items.length,
translations: parsedTranslations.translations.length
? {
create: parsedTranslations.translations,
}
: undefined,
},
include: { translations: true },
})),
name,
set,
);
if (transaction.error) return transaction.error;
return {
success: true,
dryRun: false,
item: transaction.value!.value,
version: transaction.value!.version,
};
}, {
body: t.Object({
label: t.String({ minLength: 1 }),
url: t.String({ minLength: 1 }),
order: t.Optional(t.Integer({ minimum: 0 })),
columnImage: t.Optional(t.Nullable(t.String())),
hoverImage: t.Optional(t.Nullable(t.String())),
translations: t.Optional(t.Record(t.String(), t.String({ minLength: 1 }))),
dryRun: t.Optional(t.Boolean()),
expectedVersion: t.Optional(t.String()),
}),
})
.put("/footer/:name/items/reorder", async ({ params: { name }, body, set }: any) => {
if (!isFooterMenuName(name)) {
return errorResponse(set, 400, "INVALID_FOOTER_MENU", "Only footer-explore and footer-support are supported.");
}
const input = body as { itemIds: number[]; dryRun?: boolean; expectedVersion?: string };
const menu = await getFooterMenu(name);
if (!menu) return errorResponse(set, 404, "MENU_NOT_FOUND", `Menu ${name} was not found.`);
const versionCheck = checkVersion(menu, input.expectedVersion, set);
if (versionCheck.conflict) return versionCheck.conflict;
const currentIds = menu.items.map((item) => item.id).sort((a, b) => a - b);
const requestedIds = [...input.itemIds].sort((a, b) => a - b);
const hasDuplicates = new Set(input.itemIds).size !== input.itemIds.length;
const hasSameMembers = currentIds.length === requestedIds.length
&& currentIds.every((id, index) => id === requestedIds[index]);
if (hasDuplicates || !hasSameMembers) {
return errorResponse(set, 400, "INVALID_REORDER_ITEMS", "itemIds must contain every menu item exactly once.");
}
if (input.dryRun === true) {
return { success: true, dryRun: true, operation: "reorder", version: versionCheck.currentVersion, itemIds: input.itemIds };
}
const transaction = unwrapTransaction(
await runMenuTransaction(name, input.expectedVersion, async (tx, currentMenu) => {
const currentIdsInTransaction = currentMenu.items.map((item) => item.id).sort((a, b) => a - b);
if (
currentIdsInTransaction.length !== requestedIds.length
|| !currentIdsInTransaction.every((id, index) => id === requestedIds[index])
) {
throw new Error("Menu membership changed during reorder transaction.");
}
await Promise.all(
input.itemIds.map((id, order) => tx.menuItem.update({ where: { id }, data: { order } })),
);
return input.itemIds;
}),
name,
set,
);
if (transaction.error) return transaction.error;
return {
success: true,
dryRun: false,
itemIds: transaction.value!.value,
version: transaction.value!.version,
};
}, {
body: t.Object({
itemIds: t.Array(t.Integer({ minimum: 1 }), { minItems: 1 }),
dryRun: t.Optional(t.Boolean()),
expectedVersion: t.Optional(t.String()),
}),
})
.patch("/footer/:name/items/:itemId/translations/:lang", async ({ params, body, set }: any) => {
const { name, lang } = params;
const itemId = Number(params.itemId);
if (!isFooterMenuName(name)) {
return errorResponse(set, 400, "INVALID_FOOTER_MENU", "Only footer-explore and footer-support are supported.");
}
if (!isMenuLanguage(lang)) {
return errorResponse(set, 400, "INVALID_LOCALE", `Unsupported locale: ${lang}`);
}
if (!Number.isInteger(itemId) || itemId < 1) {
return errorResponse(set, 400, "INVALID_MENU_ITEM_ID", "Menu item ID must be a positive integer.");
}
const input = body as { label: string; dryRun?: boolean; expectedVersion?: string };
const menu = await getFooterMenu(name);
if (!menu) return errorResponse(set, 404, "MENU_NOT_FOUND", `Menu ${name} was not found.`);
const item = menu.items.find((entry) => entry.id === itemId);
if (!item) return errorResponse(set, 404, "MENU_ITEM_NOT_FOUND", "Menu item does not belong to this menu.");
const versionCheck = checkVersion(menu, input.expectedVersion, set);
if (versionCheck.conflict) return versionCheck.conflict;
const previous = item.translations.find((entry) => entry.langCode === lang)?.label ?? null;
if (input.dryRun === true) {
return {
success: true,
dryRun: true,
operation: "translate",
version: versionCheck.currentVersion,
itemId,
lang,
before: previous,
after: input.label,
};
}
const transaction = unwrapTransaction(
await runMenuTransaction(name, input.expectedVersion, async (tx, currentMenu) => {
if (!currentMenu.items.some((entry) => entry.id === itemId)) {
throw new Error("Menu item membership changed during translation transaction.");
}
return tx.menuItemTranslation.upsert({
where: { menuItemId_langCode: { menuItemId: itemId, langCode: lang } },
update: { label: input.label },
create: { menuItemId: itemId, langCode: lang, label: input.label },
});
}),
name,
set,
);
if (transaction.error) return transaction.error;
return {
success: true,
dryRun: false,
itemId,
lang,
label: input.label,
version: transaction.value!.version,
};
}, {
body: t.Object({
label: t.String({ minLength: 1 }),
dryRun: t.Optional(t.Boolean()),
expectedVersion: t.Optional(t.String()),
}),
})
.patch("/footer/:name/items/:itemId", async ({ params, body, set }: any) => {
const { name } = params;
const itemId = Number(params.itemId);
if (!isFooterMenuName(name)) {
return errorResponse(set, 400, "INVALID_FOOTER_MENU", "Only footer-explore and footer-support are supported.");
}
if (!Number.isInteger(itemId) || itemId < 1) {
return errorResponse(set, 400, "INVALID_MENU_ITEM_ID", "Menu item ID must be a positive integer.");
}
const input = body as any;
if (input.url !== undefined && !isValidMenuUrl(input.url)) {
return errorResponse(set, 400, "INVALID_MENU_URL", "URL must be a root-relative path or an HTTP(S) URL.");
}
const menu = await getFooterMenu(name);
if (!menu) return errorResponse(set, 404, "MENU_NOT_FOUND", `Menu ${name} was not found.`);
const item = menu.items.find((entry) => entry.id === itemId);
if (!item) return errorResponse(set, 404, "MENU_ITEM_NOT_FOUND", "Menu item does not belong to this menu.");
const versionCheck = checkVersion(menu, input.expectedVersion, set);
if (versionCheck.conflict) return versionCheck.conflict;
const data = Object.fromEntries(
["label", "url", "order", "columnImage", "hoverImage"]
.filter((field) => input[field] !== undefined)
.map((field) => [field, input[field]]),
);
if (Object.keys(data).length === 0) {
return errorResponse(set, 400, "NO_MENU_ITEM_CHANGES", "Provide at least one item field to update.");
}
if (input.dryRun === true) {
return {
success: true,
dryRun: true,
operation: "update",
version: versionCheck.currentVersion,
itemId,
changes: Object.fromEntries(Object.entries(data).map(([key, value]) => [
key,
{ before: item[key as keyof typeof item], after: value },
])),
};
}
const transaction = unwrapTransaction(
await runMenuTransaction(name, input.expectedVersion, async (tx, currentMenu) => {
if (!currentMenu.items.some((entry) => entry.id === itemId)) {
throw new Error("Menu item membership changed during update transaction.");
}
return tx.menuItem.update({ where: { id: itemId }, data });
}),
name,
set,
);
if (transaction.error) return transaction.error;
return {
success: true,
dryRun: false,
item: transaction.value!.value,
version: transaction.value!.version,
};
}, {
body: t.Object({
label: t.Optional(t.String({ minLength: 1 })),
url: t.Optional(t.String({ minLength: 1 })),
order: t.Optional(t.Integer({ minimum: 0 })),
columnImage: t.Optional(t.Nullable(t.String())),
hoverImage: t.Optional(t.Nullable(t.String())),
dryRun: t.Optional(t.Boolean()),
expectedVersion: t.Optional(t.String()),
}),
})
.delete("/footer/:name/items/:itemId", async ({ params, body, set }: any) => {
const { name } = params;
const itemId = Number(params.itemId);
if (!isFooterMenuName(name)) {
return errorResponse(set, 400, "INVALID_FOOTER_MENU", "Only footer-explore and footer-support are supported.");
}
if (!Number.isInteger(itemId) || itemId < 1) {
return errorResponse(set, 400, "INVALID_MENU_ITEM_ID", "Menu item ID must be a positive integer.");
}
const input = (body ?? {}) as { dryRun?: boolean; expectedVersion?: string };
const menu = await getFooterMenu(name);
if (!menu) return errorResponse(set, 404, "MENU_NOT_FOUND", `Menu ${name} was not found.`);
const item = menu.items.find((entry) => entry.id === itemId);
if (!item) return errorResponse(set, 404, "MENU_ITEM_NOT_FOUND", "Menu item does not belong to this menu.");
const versionCheck = checkVersion(menu, input.expectedVersion, set);
if (versionCheck.conflict) return versionCheck.conflict;
if (input.dryRun === true) {
return { success: true, dryRun: true, operation: "delete", version: versionCheck.currentVersion, item };
}
const transaction = unwrapTransaction(
await runMenuTransaction(name, input.expectedVersion, async (tx, currentMenu) => {
if (!currentMenu.items.some((entry) => entry.id === itemId)) {
throw new Error("Menu item membership changed during delete transaction.");
}
await tx.menuItem.delete({ where: { id: itemId } });
return itemId;
}),
name,
set,
);
if (transaction.error) return transaction.error;
return {
success: true,
dryRun: false,
deletedItemId: transaction.value!.value,
version: transaction.value!.version,
};
}, {
body: t.Optional(t.Object({
dryRun: t.Optional(t.Boolean()),
expectedVersion: t.Optional(t.String()),
})),
})
.post("/", async ({ body, set }: { body: any; set: any }) => {
try {
const { name, rootImage, items } = body as {
@@ -104,6 +641,7 @@ export const menusRoute = new Elysia({ prefix: "/menus" })
}
await prisma.$transaction(async (tx: any) => {
await lockMenuForTransaction(tx, menu!.id);
const existingItems = await tx.menuItem.findMany({ where: { menuId: menu!.id } });
const existingItemIds = existingItems.map((i: any) => i.id);
const payloadItemIds = items.map(i => i.id).filter(id => id != null);