fix(housekeeping): address task 14 review round 1
This commit is contained in:
1 parent
fd68819d9b
commit
d09eaa33d6
23 files changed
+2503
-488
No files matched your search
@@ -0,0 +1,3 @@
|
||||
"use server";
|
||||
|
||||
export { createBanner, deleteBanner, updateBanner } from "./banners";
|
||||
@@ -1,25 +1,44 @@
|
||||
// @ts-nocheck
|
||||
import { readFileSync } from "node:fs";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { tryRemoveLocalPhotoFile } from "@/lib/admin/photo-files";
|
||||
import { deletePhoto } from "./admin-photos";
|
||||
|
||||
const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
|
||||
vi.mock("@/features/housekeeping/domains/content/services/mutations", () => ({ contentMutationService: { execute }, createContentMutationInvocation: (actor, correlationId) => ({ expectedActorId: actor.id, correlationId, legacy: true }) }));
|
||||
vi.mock("@/features/housekeeping/domains/content/services/mutations", () => ({
|
||||
contentMutationService: { execute },
|
||||
createContentMutationInvocation: (actor, correlationId) => ({
|
||||
expectedActorId: actor.id,
|
||||
correlationId,
|
||||
legacy: true,
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
||||
vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } }));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requirePermission).mockResolvedValue({ id: 1, rank: 7, username: "admin" });
|
||||
execute.mockResolvedValue({ ok: true, data: { before: { id: 42 }, after: null }, correlationId: "legacy" });
|
||||
vi.mocked(requirePermission).mockResolvedValue({
|
||||
id: 1,
|
||||
rank: 7,
|
||||
username: "admin",
|
||||
});
|
||||
execute.mockResolvedValue({
|
||||
ok: true,
|
||||
data: { before: { id: 42 }, after: null },
|
||||
correlationId: "legacy",
|
||||
});
|
||||
});
|
||||
|
||||
describe("deletePhoto", () => {
|
||||
it("delegates deletion and preserves both revalidations", async () => {
|
||||
await deletePhoto({ get: (key) => key === "id" ? "42" : null });
|
||||
expect(execute).toHaveBeenCalledWith(expect.anything(), "photo.delete", { id: 42 });
|
||||
await deletePhoto({ get: (key) => (key === "id" ? "42" : null) });
|
||||
expect(execute).toHaveBeenCalledWith(expect.anything(), "photo.delete", {
|
||||
id: 42,
|
||||
});
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/photos");
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/photos");
|
||||
});
|
||||
@@ -28,3 +47,26 @@ describe("deletePhoto", () => {
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("admin-photos extracted runtime contract", () => {
|
||||
it("keeps the wrapper and owning runtime responsible for purge and audit", () => {
|
||||
const wrapper = readFileSync("src/actions/admin-photos.ts", "utf8");
|
||||
const runtime = readFileSync(
|
||||
"src/features/housekeeping/domains/content/services/mutation-runtime-external.ts",
|
||||
"utf8",
|
||||
);
|
||||
expect(wrapper).toContain('"photo.delete"');
|
||||
expect(wrapper).toContain('revalidatePath("/photos")');
|
||||
expect(runtime).toContain("CameraWeb");
|
||||
expect(runtime).toContain("tryRemoveLocalPhotoFile");
|
||||
expect(runtime).toContain("logStaffActivity");
|
||||
});
|
||||
|
||||
it("rejects traversal and remote photo purge targets", async () => {
|
||||
expect(await tryRemoveLocalPhotoFile("https://cdn.example/photo.png")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(await tryRemoveLocalPhotoFile("/../../etc/passwd")).toBe(false);
|
||||
expect(await tryRemoveLocalPhotoFile("")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -2,11 +2,12 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { redirect } from "next/navigation";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { requirePermission, requireStaff } from "@/lib/admin/guard";
|
||||
import { createAd } from "./admin-ads";
|
||||
import { createArticle } from "./admin-articles";
|
||||
import { uploadMedia } from "./admin-media";
|
||||
import { saveFavicon } from "./save-favicon";
|
||||
import { deleteFavicon, saveFavicon } from "./save-favicon";
|
||||
import { saveLogo } from "./save-logo";
|
||||
|
||||
const { execute } = vi.hoisted(() => ({
|
||||
execute: vi.fn(async () => ({
|
||||
@@ -15,6 +16,13 @@ const { execute } = vi.hoisted(() => ({
|
||||
correlationId: "legacy",
|
||||
})),
|
||||
}));
|
||||
const { executeLegacyBrandAssetMutation } = vi.hoisted(() => ({
|
||||
executeLegacyBrandAssetMutation: vi.fn(async () => ({
|
||||
before: null,
|
||||
after: { value: "/api/media/x" },
|
||||
output: { url: "/api/media/x" },
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/housekeeping/domains/content/services/mutations", () => ({
|
||||
contentMutationService: { execute },
|
||||
@@ -24,7 +32,16 @@ vi.mock("@/features/housekeeping/domains/content/services/mutations", () => ({
|
||||
legacy: true,
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
||||
vi.mock(
|
||||
"@/features/housekeeping/domains/content/services/mutation-runtime-external",
|
||||
() => ({
|
||||
executeLegacyBrandAssetMutation,
|
||||
}),
|
||||
);
|
||||
vi.mock("@/lib/admin/guard", () => ({
|
||||
requirePermission: vi.fn(),
|
||||
requireStaff: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/safe-action", () => ({
|
||||
adminAction: (_options: unknown, handler: unknown) => handler,
|
||||
}));
|
||||
@@ -40,6 +57,7 @@ vi.mock("@/lib/permissions", () => ({
|
||||
NEWS_EDIT: "news.edit",
|
||||
PAGES_EDIT: "pages.edit",
|
||||
SETTINGS_EDIT: "settings.edit",
|
||||
SETTINGS_VIEW: "settings.view",
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
@@ -84,6 +102,7 @@ const form = (data: Record<string, FormDataEntryValue>) => ({
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requirePermission).mockResolvedValue(staff as never);
|
||||
vi.mocked(requireStaff).mockResolvedValue(staff as never);
|
||||
execute.mockResolvedValue({
|
||||
ok: true,
|
||||
data: { before: null, after: { id: "1" }, output: { url: "/api/media/x" } },
|
||||
@@ -111,7 +130,9 @@ describe("Content legacy wrappers", () => {
|
||||
|
||||
it("delegates ad creation and keeps the legacy void/redirect contract", async () => {
|
||||
expect(
|
||||
await createAd(form({ image: "https://example.test/ad.png" }) as FormData),
|
||||
await createAd(
|
||||
form({ image: "https://example.test/ad.png" }) as FormData,
|
||||
),
|
||||
).toBeUndefined();
|
||||
expect(execute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ expectedActorId: 42, legacy: true }),
|
||||
@@ -132,17 +153,51 @@ describe("Content legacy wrappers", () => {
|
||||
"media.upload",
|
||||
expect.objectContaining({ file }),
|
||||
);
|
||||
expect(execute).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect(executeLegacyBrandAssetMutation).toHaveBeenCalledWith(
|
||||
"favicon.save",
|
||||
expect.objectContaining({ file }),
|
||||
{ file },
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves the legacy favicon page gate and establishes a staff logo floor", async () => {
|
||||
vi.clearAllMocks();
|
||||
const file = new File(["bytes"], "image.png", { type: "image/png" });
|
||||
await saveFavicon(form({ file }) as FormData);
|
||||
await deleteFavicon();
|
||||
await saveLogo(form({ file }) as FormData);
|
||||
expect(requirePermission).toHaveBeenNthCalledWith(1, "settings.view");
|
||||
expect(requirePermission).toHaveBeenNthCalledWith(2, "settings.view");
|
||||
expect(requirePermission).not.toHaveBeenCalledWith("settings.edit");
|
||||
expect(requireStaff).toHaveBeenCalledOnce();
|
||||
expect(
|
||||
executeLegacyBrandAssetMutation.mock.calls.map(
|
||||
([operation]) => operation,
|
||||
),
|
||||
).toEqual(["favicon.save", "favicon.delete", "logo.save"]);
|
||||
});
|
||||
|
||||
it("does not mutate brand assets when either legacy guard denies access", async () => {
|
||||
const file = new File(["bytes"], "image.png", { type: "image/png" });
|
||||
vi.mocked(requirePermission).mockRejectedValueOnce(
|
||||
new Error("favicon denied"),
|
||||
);
|
||||
await expect(saveFavicon(form({ file }) as FormData)).rejects.toThrow(
|
||||
"favicon denied",
|
||||
);
|
||||
expect(executeLegacyBrandAssetMutation).not.toHaveBeenCalled();
|
||||
|
||||
vi.mocked(requireStaff).mockRejectedValueOnce(new Error("logo denied"));
|
||||
await expect(saveLogo(form({ file }) as FormData)).rejects.toThrow(
|
||||
"logo denied",
|
||||
);
|
||||
expect(executeLegacyBrandAssetMutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps every listed legacy action as a thin shared-service wrapper", () => {
|
||||
for (const path of [
|
||||
"src/actions/admin-ads.ts",
|
||||
"src/actions/admin-articles.ts",
|
||||
"src/actions/admin-banners.ts",
|
||||
"src/actions/admin-email-templates.ts",
|
||||
"src/actions/admin-help.ts",
|
||||
"src/actions/admin-media.ts",
|
||||
@@ -160,9 +215,13 @@ describe("Content legacy wrappers", () => {
|
||||
"src/actions/translations.ts",
|
||||
"src/actions/emulator.ts",
|
||||
]) {
|
||||
expect(readFileSync(path, "utf8"), path).toContain(
|
||||
"contentMutationService",
|
||||
);
|
||||
const source = readFileSync(path, "utf8");
|
||||
expect(
|
||||
source.includes("contentMutationService") ||
|
||||
source.includes("executeLegacyBrandAssetMutation") ||
|
||||
source.includes('from "./banners"'),
|
||||
path,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
+55
-30
@@ -1,43 +1,68 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import {
|
||||
contentMutationService,
|
||||
createContentMutationInvocation,
|
||||
} from "@/features/housekeeping/domains/content/services/mutations";
|
||||
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
|
||||
import { executeLegacyBrandAssetMutation } from "@/features/housekeeping/domains/content/services/mutation-runtime-external";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
|
||||
const MAX_SIZE = 2 * 1024 * 1024;
|
||||
const ALLOWED = ["image/png", "image/jpeg", "image/gif", "image/webp", "image/x-icon", "image/svg+xml"];
|
||||
const ALLOWED = [
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"image/x-icon",
|
||||
"image/svg+xml",
|
||||
];
|
||||
|
||||
export async function saveFavicon(formData: FormData): Promise<{ success: boolean; url?: string; error?: string }> {
|
||||
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
|
||||
const file = formData.get("file") as File | null;
|
||||
if (!file || file.size === 0) return { success: false, error: "No file provided" };
|
||||
if (file.size > MAX_SIZE) return { success: false, error: "File too large (max 2MB)" };
|
||||
if (!ALLOWED.includes(file.type)) return { success: false, error: "Invalid file type. Allowed: PNG, JPEG, GIF, WebP, ICO, SVG" };
|
||||
const result = await contentMutationService.execute(
|
||||
createContentMutationInvocation(staff, createCorrelationId()),
|
||||
"favicon.save",
|
||||
{ file },
|
||||
);
|
||||
if (!result.ok) return { success: false, error: result.error.messageKey };
|
||||
siteRevalidate();
|
||||
return { success: true, ...(typeof result.data.output?.url === "string" ? { url: result.data.output.url } : {}) };
|
||||
export async function saveFavicon(
|
||||
formData: FormData,
|
||||
): Promise<{ success: boolean; url?: string; error?: string }> {
|
||||
await requirePermission(PERMS.SETTINGS_VIEW);
|
||||
try {
|
||||
const file = formData.get("file") as File | null;
|
||||
if (!file || file.size === 0)
|
||||
return { success: false, error: "No file provided" };
|
||||
if (file.size > MAX_SIZE)
|
||||
return { success: false, error: "File too large (max 2MB)" };
|
||||
if (!ALLOWED.includes(file.type))
|
||||
return {
|
||||
success: false,
|
||||
error: "Invalid file type. Allowed: PNG, JPEG, GIF, WebP, ICO, SVG",
|
||||
};
|
||||
const result = await executeLegacyBrandAssetMutation("favicon.save", {
|
||||
file,
|
||||
});
|
||||
siteRevalidate();
|
||||
return {
|
||||
success: true,
|
||||
...(typeof result.output?.url === "string"
|
||||
? { url: result.output.url }
|
||||
: {}),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteFavicon(): Promise<{ success: boolean; error?: string }> {
|
||||
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
|
||||
const result = await contentMutationService.execute(
|
||||
createContentMutationInvocation(staff, createCorrelationId()),
|
||||
"favicon.delete",
|
||||
{},
|
||||
);
|
||||
if (!result.ok) return { success: false, error: result.error.messageKey };
|
||||
siteRevalidate();
|
||||
return { success: true };
|
||||
export async function deleteFavicon(): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
await requirePermission(PERMS.SETTINGS_VIEW);
|
||||
try {
|
||||
await executeLegacyBrandAssetMutation("favicon.delete", {});
|
||||
siteRevalidate();
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function siteRevalidate(): void {
|
||||
|
||||
+23
-19
@@ -1,24 +1,28 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import {
|
||||
contentMutationService,
|
||||
createContentMutationInvocation,
|
||||
} from "@/features/housekeeping/domains/content/services/mutations";
|
||||
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { executeLegacyBrandAssetMutation } from "@/features/housekeeping/domains/content/services/mutation-runtime-external";
|
||||
import { requireStaff } from "@/lib/admin/guard";
|
||||
|
||||
export async function saveLogo(formData: FormData): Promise<{ success: boolean; url?: string; error?: string }> {
|
||||
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
|
||||
const file = formData.get("file") as File | null;
|
||||
if (!file) return { success: false, error: "No file provided" };
|
||||
const result = await contentMutationService.execute(
|
||||
createContentMutationInvocation(staff, createCorrelationId()),
|
||||
"logo.save",
|
||||
{ file },
|
||||
);
|
||||
if (!result.ok) return { success: false, error: result.error.messageKey };
|
||||
revalidatePath("/", "layout");
|
||||
return { success: true, ...(typeof result.data.output?.url === "string" ? { url: result.data.output.url } : {}) };
|
||||
export async function saveLogo(
|
||||
formData: FormData,
|
||||
): Promise<{ success: boolean; url?: string; error?: string }> {
|
||||
await requireStaff();
|
||||
try {
|
||||
const file = formData.get("file") as File | null;
|
||||
if (!file) return { success: false, error: "No file provided" };
|
||||
const result = await executeLegacyBrandAssetMutation("logo.save", { file });
|
||||
revalidatePath("/", "layout");
|
||||
return {
|
||||
success: true,
|
||||
...(typeof result.output?.url === "string"
|
||||
? { url: result.output.url }
|
||||
: {}),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,30 +1,28 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { tryRemoveLocalPhotoFile } from "@/lib/admin/photo-files";
|
||||
|
||||
describe("admin-photos Content service contract", () => {
|
||||
const wrapper = readFileSync("src/actions/admin-photos.ts", "utf8");
|
||||
const runtime = readFileSync(
|
||||
"src/features/housekeeping/domains/content/services/mutation-runtime-external.ts",
|
||||
describe("setTradeLock database and live-sync contract", () => {
|
||||
const wrapper = readFileSync("src/actions/bulk-users.ts", "utf8");
|
||||
const service = readFileSync(
|
||||
"src/features/housekeeping/domains/people/services/mutations.ts",
|
||||
"utf8",
|
||||
);
|
||||
const rcon = readFileSync("src/lib/services/rcon.ts", "utf8");
|
||||
|
||||
it("delegates while the runtime deletes CameraWeb and purges local files", () => {
|
||||
expect(wrapper).toContain("contentMutationService.execute");
|
||||
expect(wrapper).toContain('"photo.delete"');
|
||||
expect(wrapper).toContain('revalidatePath("/photos")');
|
||||
expect(runtime).toContain("@/lib/db");
|
||||
expect(runtime).toContain("CameraWeb");
|
||||
expect(runtime).toContain("tryRemoveLocalPhotoFile");
|
||||
it("retains the legacy action while the owning service writes both trade-lock stores", () => {
|
||||
expect(wrapper).toMatch(/export async function setTradeLock/u);
|
||||
expect(wrapper).toContain('"user.trade-lock"');
|
||||
expect(service).toContain("UsersSettings");
|
||||
expect(service).toContain("Sanctions");
|
||||
expect(service).toContain("canTrade");
|
||||
expect(service).toContain("tradeLockedUntil");
|
||||
});
|
||||
});
|
||||
|
||||
describe("tryRemoveLocalPhotoFile", () => {
|
||||
it("rejects path traversal and remote CDN urls", async () => {
|
||||
expect(await tryRemoveLocalPhotoFile("https://cdn.example/photo.png")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(await tryRemoveLocalPhotoFile("/../../etc/passwd")).toBe(false);
|
||||
expect(await tryRemoveLocalPhotoFile("")).toBe(false);
|
||||
it("keeps live RCON lock, alert, and disconnect behavior", () => {
|
||||
expect(rcon).toContain("settradelock");
|
||||
expect(rcon).toContain("setTradeLock(userId: number, locked: boolean)");
|
||||
expect(service).toContain("rcon.setTradeLock");
|
||||
expect(service).toContain("rcon.alertUser");
|
||||
expect(service).toContain("rcon.disconnectUser");
|
||||
});
|
||||
});
|
||||
@@ -47,6 +47,13 @@ export async function GET(
|
||||
headers: {
|
||||
// eslint-disable-next-line security/detect-object-injection -- ext validated against ALLOWED_EXT
|
||||
"Content-Type": mime[ext] ?? "application/octet-stream",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
...(ext === ".svg"
|
||||
? {
|
||||
"Content-Security-Policy":
|
||||
"sandbox; default-src 'none'; style-src 'unsafe-inline'",
|
||||
}
|
||||
: {}),
|
||||
"Cache-Control": "public, max-age=3600, must-revalidate",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import type { HousekeepingCapabilityContext } from "../../foundation/contracts";
|
||||
import { loadContentInboxItems } from "./inbox-production";
|
||||
import { loadContentSearchCandidates } from "./search-production";
|
||||
@@ -18,6 +19,16 @@ const context = {
|
||||
hasAll: () => true,
|
||||
} satisfies HousekeepingCapabilityContext;
|
||||
|
||||
function capability(granted: readonly string[]): HousekeepingCapabilityContext {
|
||||
const permissions = new Set(granted);
|
||||
return {
|
||||
...context,
|
||||
has: (slug) => permissions.has(slug),
|
||||
hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)),
|
||||
hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)),
|
||||
};
|
||||
}
|
||||
|
||||
function result(
|
||||
routeId: string,
|
||||
total: number,
|
||||
@@ -45,9 +56,9 @@ describe("Content production providers", () => {
|
||||
|
||||
it("returns only truthful persisted counts from editorial and localization widgets", async () => {
|
||||
const signal = new AbortController().signal;
|
||||
await expect(loadContentWidget("editorial", context, signal)).resolves.toEqual(
|
||||
{ articles: 7 },
|
||||
);
|
||||
await expect(
|
||||
loadContentWidget("editorial", context, signal),
|
||||
).resolves.toEqual({ articles: 7 });
|
||||
await expect(
|
||||
loadContentWidget("localization", context, signal),
|
||||
).resolves.toEqual({ stores: 3 });
|
||||
@@ -79,7 +90,9 @@ describe("Content production providers", () => {
|
||||
]);
|
||||
expect(run).toHaveBeenCalledWith(
|
||||
context,
|
||||
expect.objectContaining({ list: { search: "launch", pageSize: 25, offset: 0 } }),
|
||||
expect.objectContaining({
|
||||
list: { search: "launch", pageSize: 25, offset: 0 },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -101,11 +114,59 @@ describe("Content production providers", () => {
|
||||
context,
|
||||
new AbortController().signal,
|
||||
);
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]).toMatchObject({
|
||||
itemId: "5",
|
||||
sourceId: "content.publication",
|
||||
href: "/ase/content/editorial/articles/5",
|
||||
expect(items).toEqual([]);
|
||||
});
|
||||
|
||||
it("loads only capability-matched media counts", async () => {
|
||||
const result = await loadContentWidget(
|
||||
"media",
|
||||
capability([PERMS.BANNERS_VIEW]),
|
||||
new AbortController().signal,
|
||||
);
|
||||
expect(result).toEqual({ banners: 3 });
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
expect(run).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ routeId: "content.media.banners" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not invent a zero when a widget dependency is unavailable", async () => {
|
||||
run.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
error: { code: "DEPENDENCY_UNAVAILABLE", messageKey: "dependency" },
|
||||
correlationId: "unavailable",
|
||||
});
|
||||
await expect(
|
||||
loadContentWidget("editorial", context, new AbortController().signal),
|
||||
).rejects.toThrow("Content widget query unavailable");
|
||||
});
|
||||
|
||||
it("emits only actionable publication statuses", async () => {
|
||||
run.mockResolvedValueOnce(
|
||||
result("content.editorial.articles", 2, [
|
||||
{
|
||||
id: "draft",
|
||||
title: "Draft",
|
||||
status: "draft",
|
||||
updatedAt: new Date().toISOString(),
|
||||
href: "/ase/content/editorial/articles/draft",
|
||||
},
|
||||
{
|
||||
id: "published",
|
||||
title: "Published",
|
||||
status: "published",
|
||||
updatedAt: new Date().toISOString(),
|
||||
href: "/ase/content/editorial/articles/published",
|
||||
},
|
||||
]),
|
||||
);
|
||||
const items = await loadContentInboxItems(
|
||||
"publication",
|
||||
context,
|
||||
new AbortController().signal,
|
||||
);
|
||||
expect(items.map((item) => item.itemId)).toEqual(["draft"]);
|
||||
expect(items[0]?.state).toBe("draft");
|
||||
});
|
||||
});
|
||||
@@ -4,10 +4,7 @@ import {
|
||||
anyCapability,
|
||||
type HousekeepingCapabilityContext,
|
||||
} from "../../foundation/contracts";
|
||||
import {
|
||||
CONTENT_INBOX_SOURCE_IDS,
|
||||
createContentInboxSources,
|
||||
} from "./inbox";
|
||||
import { CONTENT_INBOX_SOURCE_IDS, createContentInboxSources } from "./inbox";
|
||||
import {
|
||||
CONTENT_SEARCH_PROVIDER_IDS,
|
||||
createContentSearchProviders,
|
||||
@@ -74,27 +71,30 @@ describe("Content search providers", () => {
|
||||
"/ase/content/%255c..%255csystem",
|
||||
"https://example.test/ase/content/editorial",
|
||||
"//example.test/ase/content/editorial",
|
||||
])("rejects normalized and double-encoded traversal href %s", async (href) => {
|
||||
const adapters = {
|
||||
articles: async () => [
|
||||
{
|
||||
id: "unsafe",
|
||||
title: "Unsafe",
|
||||
href,
|
||||
capability: anyCapability(PERMS.NEWS_VIEW),
|
||||
},
|
||||
],
|
||||
events: async () => [],
|
||||
media: async () => [],
|
||||
help: async () => [],
|
||||
};
|
||||
const [provider] = createContentSearchProviders(adapters);
|
||||
const result = await provider.search(context([PERMS.NEWS_VIEW]), {
|
||||
term: "",
|
||||
limit: 25,
|
||||
});
|
||||
expect(result).toMatchObject({ ok: true, data: [] });
|
||||
});
|
||||
])(
|
||||
"rejects normalized and double-encoded traversal href %s",
|
||||
async (href) => {
|
||||
const adapters = {
|
||||
articles: async () => [
|
||||
{
|
||||
id: "unsafe",
|
||||
title: "Unsafe",
|
||||
href,
|
||||
capability: anyCapability(PERMS.NEWS_VIEW),
|
||||
},
|
||||
],
|
||||
events: async () => [],
|
||||
media: async () => [],
|
||||
help: async () => [],
|
||||
};
|
||||
const [provider] = createContentSearchProviders(adapters);
|
||||
const result = await provider.search(context([PERMS.NEWS_VIEW]), {
|
||||
term: "",
|
||||
limit: 25,
|
||||
});
|
||||
expect(result).toMatchObject({ ok: true, data: [] });
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("Content inbox and widgets", () => {
|
||||
@@ -119,6 +119,20 @@ describe("Content inbox and widgets", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does not advertise media permissions for an event-and-poll attention source", async () => {
|
||||
const attention = vi.fn(async () => []);
|
||||
const [, source] = createContentInboxSources({
|
||||
publication: async () => [],
|
||||
attention,
|
||||
});
|
||||
const result = await source.getItems(
|
||||
context([PERMS.PAGES_VIEW, PERMS.BANNERS_VIEW]),
|
||||
new AbortController().signal,
|
||||
);
|
||||
expect(result).toMatchObject({ ok: false, error: { code: "FORBIDDEN" } });
|
||||
expect(attention).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps editorial mandatory and media/localization optional without preview DB imports", async () => {
|
||||
const adapters = {
|
||||
editorial: vi.fn(async () => ({ drafts: 2, scheduled: 1 })),
|
||||
|
||||
@@ -10,6 +10,11 @@ import { contentQuery } from "./queries/content-queries";
|
||||
|
||||
type ContentInboxKind = "publication" | "attention";
|
||||
|
||||
const ACTIONABLE_STATUS = {
|
||||
publication: new Set(["draft", "scheduled", "pending", "failed"]),
|
||||
attention: new Set(["draft", "cancelled", "closed", "failed"]),
|
||||
} as const;
|
||||
|
||||
function time(value: string | null | undefined) {
|
||||
if (!value) return null;
|
||||
const timestamp = Date.parse(value);
|
||||
@@ -31,7 +36,11 @@ export async function loadContentInboxItems(
|
||||
const definitions =
|
||||
kind === "publication"
|
||||
? ([
|
||||
["content.editorial.articles", PERMS.NEWS_VIEW, "content.publication"],
|
||||
[
|
||||
"content.editorial.articles",
|
||||
PERMS.NEWS_VIEW,
|
||||
"content.publication",
|
||||
],
|
||||
] as const)
|
||||
: ([
|
||||
["content.engagement.events", PERMS.EVENTS_VIEW, "content.attention"],
|
||||
@@ -39,12 +48,15 @@ export async function loadContentInboxItems(
|
||||
] as const);
|
||||
const items: HousekeepingWorkItem[] = [];
|
||||
for (const [routeId, permission, sourceId] of definitions) {
|
||||
if (!context.has(permission)) continue;
|
||||
const result = await contentQuery.run(context, {
|
||||
routeId,
|
||||
list: { pageSize: 25, offset: 0 },
|
||||
});
|
||||
if (!result.ok) continue;
|
||||
for (const item of result.data.items) {
|
||||
const status = item.status?.toLocaleLowerCase() ?? "";
|
||||
if (!ACTIONABLE_STATUS[kind].has(status)) continue;
|
||||
const date = time(item.updatedAt);
|
||||
if (!date || !item.href) continue;
|
||||
items.push({
|
||||
@@ -53,10 +65,11 @@ export async function loadContentInboxItems(
|
||||
deduplicationKey: sourceId + ":" + routeId + ":" + item.id,
|
||||
domain: "content",
|
||||
capability: anyCapability(permission),
|
||||
severity: item.status === "failed" ? "warning" : "info",
|
||||
priority: item.status === "failed" ? "high" : "normal",
|
||||
severity:
|
||||
status === "failed" || status === "cancelled" ? "warning" : "info",
|
||||
priority: status === "failed" ? "high" : "normal",
|
||||
...date,
|
||||
state: item.status ?? "ready",
|
||||
state: status,
|
||||
titleKey: "pages.housekeeping.items.content",
|
||||
context: { title: item.title },
|
||||
href: item.href as `/ase/${string}`,
|
||||
|
||||
@@ -76,12 +76,7 @@ export function createContentInboxSources(
|
||||
),
|
||||
createSource(
|
||||
"content.attention",
|
||||
anyCapability(
|
||||
PERMS.EVENTS_VIEW,
|
||||
PERMS.POLLS_VIEW,
|
||||
PERMS.PAGES_VIEW,
|
||||
PERMS.BANNERS_VIEW,
|
||||
),
|
||||
anyCapability(PERMS.EVENTS_VIEW, PERMS.POLLS_VIEW),
|
||||
adapters.attention,
|
||||
),
|
||||
];
|
||||
|
||||
@@ -37,12 +37,19 @@ interface ContentCommandFormProps extends ContentCommandSubmission {
|
||||
readonly buttonLabel: string;
|
||||
}
|
||||
|
||||
const OMIT_FIELD = Symbol("omit optional Content command field");
|
||||
|
||||
function parseField(field: ContentCommandField, formData: FormData): unknown {
|
||||
const rawValue = formData.get(field.name);
|
||||
if (rawValue === null && !field.required) return OMIT_FIELD;
|
||||
if (field.type === "checkbox") return rawValue === "on";
|
||||
if (field.type === "file") return rawValue instanceof File ? rawValue : null;
|
||||
const raw = String(rawValue ?? "").normalize("NFC").trim();
|
||||
const raw = String(rawValue ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!raw && !field.required) return OMIT_FIELD;
|
||||
if (field.type === "number") {
|
||||
if (!raw) return raw;
|
||||
const value = Number(raw);
|
||||
if (!Number.isSafeInteger(value)) return 0;
|
||||
return Math.min(field.max ?? value, Math.max(field.min ?? value, value));
|
||||
@@ -60,7 +67,10 @@ function parseField(field: ContentCommandField, formData: FormData): unknown {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return raw.slice(0, field.maxLength ?? (field.type === "textarea" ? 20_000 : 500));
|
||||
return raw.slice(
|
||||
0,
|
||||
field.maxLength ?? (field.type === "textarea" ? 20_000 : 500),
|
||||
);
|
||||
}
|
||||
|
||||
const initialState: HousekeepingResult<unknown> | null = null;
|
||||
@@ -70,14 +80,13 @@ export async function submitContentCommandForm(
|
||||
_previous: HousekeepingResult<unknown> | null,
|
||||
formData: FormData,
|
||||
): Promise<HousekeepingResult<unknown>> {
|
||||
const submittedFields = (configuration.fields ?? []).flatMap((field) => {
|
||||
const value = parseField(field, formData);
|
||||
return value === OMIT_FIELD ? [] : [[field.name, value] as const];
|
||||
});
|
||||
const input = {
|
||||
...configuration.input,
|
||||
...Object.fromEntries(
|
||||
(configuration.fields ?? []).map((field) => [
|
||||
field.name,
|
||||
parseField(field, formData),
|
||||
]),
|
||||
),
|
||||
...Object.fromEntries(submittedFields),
|
||||
};
|
||||
const reason = String(formData.get("reason") ?? "")
|
||||
.normalize("NFC")
|
||||
@@ -127,7 +136,8 @@ export function ContentCommandForm({
|
||||
id={`${commandId}-${field.name}`}
|
||||
name={field.name}
|
||||
type="checkbox"
|
||||
/> {field.label}
|
||||
/>{" "}
|
||||
{field.label}
|
||||
</>
|
||||
) : field.type === "select" ? (
|
||||
<>
|
||||
@@ -139,6 +149,7 @@ export function ContentCommandForm({
|
||||
required={field.required}
|
||||
className="mt-1 block w-full"
|
||||
>
|
||||
{field.required ? null : <option value="">No change</option>}
|
||||
{field.options?.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
|
||||
@@ -53,10 +53,7 @@ describe.each(cases)("Content %s page", (kind, Component, editPermission) => {
|
||||
).toContain('data-housekeeping-state="forbidden"');
|
||||
expect(
|
||||
render(
|
||||
ok(
|
||||
{ kind, items: [], total: 0, partialDependencies: [] },
|
||||
"empty",
|
||||
),
|
||||
ok({ kind, items: [], total: 0, partialDependencies: [] }, "empty"),
|
||||
),
|
||||
).toContain('data-housekeeping-state="empty"');
|
||||
const ready = render(
|
||||
@@ -154,6 +151,36 @@ describe("Content actionable form wiring", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("omits untouched optional fields from partial-update submissions", async () => {
|
||||
vi.mocked(executeHousekeepingCommand).mockResolvedValue(
|
||||
ok({ before: null, after: { id: "7" } }, "partial-form"),
|
||||
);
|
||||
const formData = new FormData();
|
||||
formData.set("id", "7");
|
||||
formData.set("title", "Renamed");
|
||||
formData.set("image", "");
|
||||
|
||||
await submitContentCommandForm(
|
||||
{
|
||||
commandId: "content.media.banner.change",
|
||||
input: { action: "update" },
|
||||
fields: [
|
||||
{ name: "id", label: "ID", type: "identifier", required: true },
|
||||
{ name: "title", label: "Title", type: "text" },
|
||||
{ name: "image", label: "Image", type: "text" },
|
||||
{ name: "isActive", label: "Active", type: "checkbox" },
|
||||
],
|
||||
},
|
||||
null,
|
||||
formData,
|
||||
);
|
||||
|
||||
expect(executeHousekeepingCommand).toHaveBeenCalledWith({
|
||||
commandId: "content.media.banner.change",
|
||||
input: { action: "update", id: "7", title: "Renamed" },
|
||||
});
|
||||
});
|
||||
|
||||
it("renders mutation forms only with the exact capability", () => {
|
||||
const result = ok(
|
||||
{
|
||||
@@ -210,4 +237,93 @@ describe("Content actionable form wiring", () => {
|
||||
expect(html).toContain("Welcome");
|
||||
expect(html).not.toContain("secret template body");
|
||||
});
|
||||
|
||||
it("renders create-event fields required by the production validator", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ContentEngagementPage
|
||||
context={context([PERMS.EVENTS_EDIT])}
|
||||
result={ok(
|
||||
{ kind: "engagement", items: [], total: 0, partialDependencies: [] },
|
||||
"event-form",
|
||||
)}
|
||||
routeId="content.engagement.event-create"
|
||||
/>,
|
||||
);
|
||||
|
||||
for (const name of ["title", "description", "typeId", "startsAt"]) {
|
||||
expect(html).toContain(`name="${name}"`);
|
||||
}
|
||||
});
|
||||
|
||||
it("renders validator-shaped prize, question, and prefix inputs", () => {
|
||||
const eventHtml = renderToStaticMarkup(
|
||||
<ContentEngagementPage
|
||||
context={context([PERMS.EVENTS_EDIT])}
|
||||
result={ok(
|
||||
{ kind: "engagement", items: [], total: 0, partialDependencies: [] },
|
||||
"event-prize-form",
|
||||
)}
|
||||
routeId="content.engagement.event-detail"
|
||||
/>,
|
||||
);
|
||||
for (const name of [
|
||||
"position",
|
||||
"prizeType",
|
||||
"badgeCode",
|
||||
"credits",
|
||||
"pixels",
|
||||
"points",
|
||||
"itemId",
|
||||
"description",
|
||||
]) {
|
||||
expect(eventHtml).toContain(`name="${name}"`);
|
||||
}
|
||||
expect(eventHtml).not.toContain('name="prize"');
|
||||
|
||||
const pollHtml = renderToStaticMarkup(
|
||||
<ContentEngagementPage
|
||||
context={context([PERMS.POLLS_EDIT])}
|
||||
result={ok(
|
||||
{ kind: "engagement", items: [], total: 0, partialDependencies: [] },
|
||||
"poll-question-form",
|
||||
)}
|
||||
routeId="content.engagement.poll-detail"
|
||||
/>,
|
||||
);
|
||||
expect(pollHtml).toContain('name="options"');
|
||||
|
||||
const prefixHtml = renderToStaticMarkup(
|
||||
<ContentEngagementPage
|
||||
context={context([PERMS.PREFIXES_EDIT])}
|
||||
result={ok(
|
||||
{ kind: "engagement", items: [], total: 0, partialDependencies: [] },
|
||||
"prefix-form",
|
||||
)}
|
||||
routeId="content.engagement.prefixes"
|
||||
/>,
|
||||
);
|
||||
expect(prefixHtml).toContain('name="color"');
|
||||
});
|
||||
|
||||
it("matches emulator setting fields to the database column bounds", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ContentLocalizationPage
|
||||
context={context([PERMS.SETTINGS_EDIT])}
|
||||
result={ok(
|
||||
{
|
||||
kind: "localization",
|
||||
items: [],
|
||||
total: 0,
|
||||
partialDependencies: [],
|
||||
},
|
||||
"emulator-form",
|
||||
)}
|
||||
routeId="content.localization.emulator"
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('name="key"');
|
||||
expect(html).toContain('maxLength="100"');
|
||||
expect(html).toContain('name="value"');
|
||||
expect(html).toContain('maxLength="512"');
|
||||
});
|
||||
});
|
||||
@@ -1,34 +1,407 @@
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import type { HousekeepingPageInput } from "../../../route-handlers";
|
||||
import { contentQuery } from "../queries/content-queries";
|
||||
import { ContentCommandForm } from "./content-command-form";
|
||||
import { ContentPageFrame, type ContentPageProps, parseContentListInput } from "./content-page-frame";
|
||||
import {
|
||||
type ContentCommandField,
|
||||
ContentCommandForm,
|
||||
} from "./content-command-form";
|
||||
import {
|
||||
ContentPageFrame,
|
||||
type ContentPageProps,
|
||||
parseContentListInput,
|
||||
} from "./content-page-frame";
|
||||
|
||||
export function ContentEngagementPage({ context, result, routeId }: ContentPageProps) {
|
||||
export function ContentEngagementPage({
|
||||
context,
|
||||
result,
|
||||
routeId,
|
||||
}: ContentPageProps) {
|
||||
const canEvents = context.has(PERMS.EVENTS_EDIT);
|
||||
const canPolls = context.has(PERMS.POLLS_EDIT);
|
||||
const canPrefixes = context.has(PERMS.PREFIXES_EDIT);
|
||||
return <ContentPageFrame title="Engagement" description="Manage events, polls, and community prefixes." result={result} forms={<div className="grid gap-3 lg:grid-cols-2">
|
||||
{canEvents && routeId === "content.engagement.event-types" ? <ContentCommandForm commandId="content.engagement.event-type.change" buttonLabel="Save event type" input={{ action: "update" }} fields={[{ name: "id", label: "Type ID", type: "identifier" }, { name: "name", label: "Name", type: "text", required: true, maxLength: 255 }]} /> : null}
|
||||
{canEvents && routeId?.includes("event") && routeId !== "content.engagement.event-types" ? <>
|
||||
<ContentCommandForm commandId="content.engagement.event.change" buttonLabel="Save event" input={{ action: routeId.endsWith("create") ? "create" : "update" }} fields={[{ name: "id", label: "Event ID", type: "identifier" }, { name: "title", label: "Title", type: "text", required: true, maxLength: 255 }]} />
|
||||
<ContentCommandForm commandId="content.engagement.event-prize.change" buttonLabel="Save prize" input={{ action: "create" }} fields={[{ name: "eventId", label: "Event ID", type: "identifier", required: true }, { name: "prize", label: "Prize", type: "text", required: true, maxLength: 255 }]} />
|
||||
<ContentCommandForm commandId="content.engagement.event-winner.add" buttonLabel="Add winner" input={{}} fields={[{ name: "eventId", label: "Event ID", type: "identifier", required: true }, { name: "userId", label: "User ID", type: "identifier", required: true }]} />
|
||||
</> : null}
|
||||
{canPolls && routeId?.includes("poll") ? <>
|
||||
<ContentCommandForm commandId="content.engagement.poll.change" buttonLabel="Save poll" input={{ action: routeId.endsWith("create") ? "create" : "update" }} fields={[{ name: "id", label: "Poll ID", type: "identifier" }, { name: "title", label: "Title", type: "text", required: true, maxLength: 255 }]} />
|
||||
<ContentCommandForm commandId="content.engagement.poll-question.change" buttonLabel="Save question" input={{ action: "create" }} fields={[{ name: "pollId", label: "Poll ID", type: "identifier", required: true }, { name: "question", label: "Question", type: "text", required: true, maxLength: 500 }]} />
|
||||
</> : null}
|
||||
{canPrefixes && routeId === "content.engagement.prefixes" ? <>
|
||||
<ContentCommandForm commandId="content.engagement.prefix.change" buttonLabel="Save prefix" input={{ action: "update" }} fields={[{ name: "id", label: "Prefix ID", type: "identifier" }, { name: "text", label: "Text", type: "text", required: true, maxLength: 64 }]} />
|
||||
<ContentCommandForm commandId="content.engagement.prefix-blacklist.change" buttonLabel="Update blacklist" input={{ action: "add" }} fields={[{ name: "word", label: "Word", type: "text", required: true, maxLength: 255 }]} />
|
||||
<ContentCommandForm commandId="content.engagement.prefix-settings.update" buttonLabel="Save prefix settings" input={{}} fields={[{ name: "enabled", label: "Enable prefixes", type: "checkbox" }]} />
|
||||
</> : null}
|
||||
</div>} />;
|
||||
const creatingEvent = routeId === "content.engagement.event-create";
|
||||
const creatingPoll = routeId === "content.engagement.poll-create";
|
||||
const eventFields: readonly ContentCommandField[] = [
|
||||
{
|
||||
name: "id",
|
||||
label: "Event ID",
|
||||
type: "identifier",
|
||||
required: !creatingEvent,
|
||||
},
|
||||
{
|
||||
name: "title",
|
||||
label: "Title",
|
||||
type: "text",
|
||||
required: creatingEvent,
|
||||
maxLength: 255,
|
||||
},
|
||||
{
|
||||
name: "description",
|
||||
label: "Description",
|
||||
type: "textarea",
|
||||
required: creatingEvent,
|
||||
maxLength: 20_000,
|
||||
},
|
||||
{
|
||||
name: "typeId",
|
||||
label: "Event type ID",
|
||||
type: "identifier",
|
||||
required: creatingEvent,
|
||||
},
|
||||
{ name: "roomId", label: "Room ID", type: "identifier" },
|
||||
{
|
||||
name: "startsAt",
|
||||
label: "Starts at",
|
||||
type: "text",
|
||||
required: creatingEvent,
|
||||
maxLength: 50,
|
||||
},
|
||||
{ name: "endsAt", label: "Ends at", type: "text", maxLength: 50 },
|
||||
{ name: "maxPlayers", label: "Maximum players", type: "number", min: 1 },
|
||||
{ name: "isRecurring", label: "Recurring", type: "checkbox" },
|
||||
{
|
||||
name: "recurrenceRule",
|
||||
label: "Recurrence rule",
|
||||
type: "text",
|
||||
maxLength: 255,
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
label: "Status",
|
||||
type: "select",
|
||||
options: [
|
||||
{ value: "draft", label: "Draft" },
|
||||
{ value: "published", label: "Published" },
|
||||
{ value: "cancelled", label: "Cancelled" },
|
||||
{ value: "completed", label: "Completed" },
|
||||
],
|
||||
},
|
||||
{ name: "image", label: "Image URL", type: "text", maxLength: 500 },
|
||||
];
|
||||
const pollFields: readonly ContentCommandField[] = [
|
||||
{
|
||||
name: "id",
|
||||
label: "Poll ID",
|
||||
type: "identifier",
|
||||
required: !creatingPoll,
|
||||
},
|
||||
{
|
||||
name: "title",
|
||||
label: "Title",
|
||||
type: "text",
|
||||
required: creatingPoll,
|
||||
maxLength: 255,
|
||||
},
|
||||
{
|
||||
name: "description",
|
||||
label: "Description",
|
||||
type: "textarea",
|
||||
maxLength: 2_000,
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
label: "Status",
|
||||
type: "select",
|
||||
options: [
|
||||
{ value: "draft", label: "Draft" },
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "closed", label: "Closed" },
|
||||
],
|
||||
},
|
||||
{ name: "showResults", label: "Show results", type: "checkbox" },
|
||||
{
|
||||
name: "multipleChoice",
|
||||
label: "Allow multiple choices",
|
||||
type: "checkbox",
|
||||
},
|
||||
{ name: "startsAt", label: "Starts at", type: "text", maxLength: 50 },
|
||||
{ name: "endsAt", label: "Ends at", type: "text", maxLength: 50 },
|
||||
];
|
||||
return (
|
||||
<ContentPageFrame
|
||||
title="Engagement"
|
||||
description="Manage events, polls, and community prefixes."
|
||||
result={result}
|
||||
forms={
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
{canEvents && routeId === "content.engagement.event-types" ? (
|
||||
<ContentCommandForm
|
||||
commandId="content.engagement.event-type.change"
|
||||
buttonLabel="Save event type"
|
||||
input={{ action: "update" }}
|
||||
fields={[
|
||||
{
|
||||
name: "id",
|
||||
label: "Type ID",
|
||||
type: "identifier",
|
||||
required: true,
|
||||
},
|
||||
{ name: "name", label: "Name", type: "text", maxLength: 100 },
|
||||
{ name: "slug", label: "Slug", type: "text", maxLength: 100 },
|
||||
{
|
||||
name: "description",
|
||||
label: "Description",
|
||||
type: "textarea",
|
||||
maxLength: 500,
|
||||
},
|
||||
{ name: "color", label: "Color", type: "text", maxLength: 20 },
|
||||
{ name: "icon", label: "Icon", type: "text", maxLength: 50 },
|
||||
{ name: "isActive", label: "Active", type: "checkbox" },
|
||||
{
|
||||
name: "minRank",
|
||||
label: "Minimum rank",
|
||||
type: "number",
|
||||
min: 0,
|
||||
max: 7,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
{canEvents &&
|
||||
routeId?.includes("event") &&
|
||||
routeId !== "content.engagement.event-types" ? (
|
||||
<>
|
||||
<ContentCommandForm
|
||||
commandId="content.engagement.event.change"
|
||||
buttonLabel="Save event"
|
||||
input={{ action: creatingEvent ? "create" : "update" }}
|
||||
fields={eventFields}
|
||||
/>
|
||||
<ContentCommandForm
|
||||
commandId="content.engagement.event-prize.change"
|
||||
buttonLabel="Save prize"
|
||||
input={{ action: "create" }}
|
||||
fields={[
|
||||
{
|
||||
name: "eventId",
|
||||
label: "Event ID",
|
||||
type: "identifier",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "position",
|
||||
label: "Position",
|
||||
type: "number",
|
||||
min: 1,
|
||||
defaultValue: 1,
|
||||
},
|
||||
{
|
||||
name: "prizeType",
|
||||
label: "Prize type",
|
||||
type: "select",
|
||||
options: [
|
||||
{ value: "badge", label: "Badge" },
|
||||
{ value: "credits", label: "Credits" },
|
||||
{ value: "pixels", label: "Pixels" },
|
||||
{ value: "points", label: "Points" },
|
||||
{ value: "item", label: "Item" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "badgeCode",
|
||||
label: "Badge code",
|
||||
type: "text",
|
||||
maxLength: 50,
|
||||
},
|
||||
{
|
||||
name: "credits",
|
||||
label: "Credits",
|
||||
type: "number",
|
||||
min: 0,
|
||||
defaultValue: 0,
|
||||
},
|
||||
{
|
||||
name: "pixels",
|
||||
label: "Pixels",
|
||||
type: "number",
|
||||
min: 0,
|
||||
defaultValue: 0,
|
||||
},
|
||||
{
|
||||
name: "points",
|
||||
label: "Points",
|
||||
type: "number",
|
||||
min: 0,
|
||||
defaultValue: 0,
|
||||
},
|
||||
{ name: "itemId", label: "Item ID", type: "identifier" },
|
||||
{
|
||||
name: "description",
|
||||
label: "Description",
|
||||
type: "text",
|
||||
maxLength: 255,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<ContentCommandForm
|
||||
commandId="content.engagement.event-winner.add"
|
||||
buttonLabel="Add winner"
|
||||
input={{}}
|
||||
fields={[
|
||||
{
|
||||
name: "eventId",
|
||||
label: "Event ID",
|
||||
type: "identifier",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "userId",
|
||||
label: "User ID",
|
||||
type: "identifier",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "position",
|
||||
label: "Position",
|
||||
type: "number",
|
||||
min: 1,
|
||||
defaultValue: 1,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{canPolls && routeId?.includes("poll") ? (
|
||||
<>
|
||||
<ContentCommandForm
|
||||
commandId="content.engagement.poll.change"
|
||||
buttonLabel="Save poll"
|
||||
input={{ action: creatingPoll ? "create" : "update" }}
|
||||
fields={pollFields}
|
||||
/>
|
||||
<ContentCommandForm
|
||||
commandId="content.engagement.poll-question.change"
|
||||
buttonLabel="Save question"
|
||||
input={{ action: "create" }}
|
||||
fields={[
|
||||
{
|
||||
name: "pollId",
|
||||
label: "Poll ID",
|
||||
type: "identifier",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "question",
|
||||
label: "Question",
|
||||
type: "text",
|
||||
required: true,
|
||||
maxLength: 500,
|
||||
},
|
||||
{
|
||||
name: "type",
|
||||
label: "Question type",
|
||||
type: "select",
|
||||
options: [
|
||||
{ value: "single", label: "Single choice" },
|
||||
{ value: "multiple", label: "Multiple choice" },
|
||||
{ value: "text", label: "Free text" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "sortOrder",
|
||||
label: "Sort order",
|
||||
type: "number",
|
||||
min: 0,
|
||||
defaultValue: 0,
|
||||
},
|
||||
{
|
||||
name: "options",
|
||||
label: "Options",
|
||||
type: "textarea",
|
||||
required: true,
|
||||
maxLength: 20_000,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{canPrefixes && routeId === "content.engagement.prefixes" ? (
|
||||
<>
|
||||
<ContentCommandForm
|
||||
commandId="content.engagement.prefix.change"
|
||||
buttonLabel="Save prefix"
|
||||
input={{ action: "update" }}
|
||||
fields={[
|
||||
{
|
||||
name: "id",
|
||||
label: "Prefix ID",
|
||||
type: "identifier",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "text",
|
||||
label: "Text",
|
||||
type: "text",
|
||||
required: true,
|
||||
maxLength: 255,
|
||||
},
|
||||
{
|
||||
name: "color",
|
||||
label: "Color",
|
||||
type: "text",
|
||||
required: true,
|
||||
maxLength: 32,
|
||||
},
|
||||
{ name: "icon", label: "Icon", type: "text", maxLength: 255 },
|
||||
{
|
||||
name: "effect",
|
||||
label: "Effect",
|
||||
type: "text",
|
||||
maxLength: 255,
|
||||
},
|
||||
{ name: "active", label: "Active", type: "checkbox" },
|
||||
]}
|
||||
/>
|
||||
<ContentCommandForm
|
||||
commandId="content.engagement.prefix-blacklist.change"
|
||||
buttonLabel="Update blacklist"
|
||||
input={{ action: "add" }}
|
||||
fields={[
|
||||
{
|
||||
name: "word",
|
||||
label: "Word",
|
||||
type: "text",
|
||||
required: true,
|
||||
maxLength: 255,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<ContentCommandForm
|
||||
commandId="content.engagement.prefix-settings.update"
|
||||
buttonLabel="Save prefix settings"
|
||||
input={{}}
|
||||
fields={[
|
||||
{
|
||||
name: "settings",
|
||||
label: "Prefix settings JSON",
|
||||
type: "json",
|
||||
required: true,
|
||||
maxLength: 20_000,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export async function renderContentEngagementPage(input: HousekeepingPageInput) {
|
||||
export async function renderContentEngagementPage(
|
||||
input: HousekeepingPageInput,
|
||||
) {
|
||||
const routeId = input.match.routeId as ContentPageProps["routeId"];
|
||||
const result = await contentQuery.run(input.context, { routeId: routeId ?? "content.engagement.events", params: input.match.params, list: parseContentListInput(input.searchParams ?? {}) });
|
||||
return <ContentEngagementPage context={input.context} result={result} routeId={routeId} />;
|
||||
const result = await contentQuery.run(input.context, {
|
||||
routeId: routeId ?? "content.engagement.events",
|
||||
params: input.match.params,
|
||||
list: parseContentListInput(input.searchParams ?? {}),
|
||||
});
|
||||
return (
|
||||
<ContentEngagementPage
|
||||
context={input.context}
|
||||
result={result}
|
||||
routeId={routeId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -2,19 +2,117 @@ import { PERMS } from "@/lib/permission-slugs";
|
||||
import type { HousekeepingPageInput } from "../../../route-handlers";
|
||||
import { contentQuery } from "../queries/content-queries";
|
||||
import { ContentCommandForm } from "./content-command-form";
|
||||
import { ContentPageFrame, type ContentPageProps, parseContentListInput } from "./content-page-frame";
|
||||
import {
|
||||
ContentPageFrame,
|
||||
type ContentPageProps,
|
||||
parseContentListInput,
|
||||
} from "./content-page-frame";
|
||||
|
||||
export function ContentLocalizationPage({ context, result, routeId }: ContentPageProps) {
|
||||
const forms = context.has(PERMS.SETTINGS_EDIT) ? <div className="grid gap-3 lg:grid-cols-2">
|
||||
{routeId === "content.localization.cms" ? <ContentCommandForm commandId="content.localization.cms.save" buttonLabel="Save CMS translations" input={{}} fields={[{ name: "locale", label: "Locale", type: "text", required: true, maxLength: 16 }, { name: "data", label: "Translation JSON", type: "json", required: true, maxLength: 500_000 }]} requiresReason /> : null}
|
||||
{routeId === "content.localization.client" ? <ContentCommandForm commandId="content.localization.client.save" buttonLabel="Save client translations" input={{}} fields={[{ name: "fileId", label: "Translation file", type: "text", required: true, maxLength: 100 }, { name: "data", label: "Translation JSON", type: "json", required: true, maxLength: 500_000 }]} requiresReason /> : null}
|
||||
{routeId === "content.localization.emulator" ? <ContentCommandForm commandId="content.localization.emulator.save" buttonLabel="Save emulator translation" input={{}} fields={[{ name: "key", label: "Key", type: "text", required: true, maxLength: 255 }, { name: "value", label: "Value", type: "textarea", required: true, maxLength: 20_000 }]} requiresReason /> : null}
|
||||
</div> : null;
|
||||
return <ContentPageFrame title="Localization" description="Manage CMS, client, and emulator translation stores." result={result} forms={forms} />;
|
||||
export function ContentLocalizationPage({
|
||||
context,
|
||||
result,
|
||||
routeId,
|
||||
}: ContentPageProps) {
|
||||
const forms = context.has(PERMS.SETTINGS_EDIT) ? (
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
{routeId === "content.localization.cms" ? (
|
||||
<ContentCommandForm
|
||||
commandId="content.localization.cms.save"
|
||||
buttonLabel="Save CMS translations"
|
||||
input={{}}
|
||||
fields={[
|
||||
{
|
||||
name: "locale",
|
||||
label: "Locale",
|
||||
type: "text",
|
||||
required: true,
|
||||
maxLength: 16,
|
||||
},
|
||||
{
|
||||
name: "data",
|
||||
label: "Translation JSON",
|
||||
type: "json",
|
||||
required: true,
|
||||
maxLength: 500_000,
|
||||
},
|
||||
]}
|
||||
requiresReason
|
||||
/>
|
||||
) : null}
|
||||
{routeId === "content.localization.client" ? (
|
||||
<ContentCommandForm
|
||||
commandId="content.localization.client.save"
|
||||
buttonLabel="Save client translations"
|
||||
input={{}}
|
||||
fields={[
|
||||
{
|
||||
name: "fileId",
|
||||
label: "Translation file",
|
||||
type: "text",
|
||||
required: true,
|
||||
maxLength: 100,
|
||||
},
|
||||
{
|
||||
name: "data",
|
||||
label: "Translation JSON",
|
||||
type: "json",
|
||||
required: true,
|
||||
maxLength: 500_000,
|
||||
},
|
||||
]}
|
||||
requiresReason
|
||||
/>
|
||||
) : null}
|
||||
{routeId === "content.localization.emulator" ? (
|
||||
<ContentCommandForm
|
||||
commandId="content.localization.emulator.save"
|
||||
buttonLabel="Save emulator translation"
|
||||
input={{}}
|
||||
fields={[
|
||||
{
|
||||
name: "key",
|
||||
label: "Key",
|
||||
type: "text",
|
||||
required: true,
|
||||
maxLength: 100,
|
||||
},
|
||||
{
|
||||
name: "value",
|
||||
label: "Value",
|
||||
type: "textarea",
|
||||
required: true,
|
||||
maxLength: 512,
|
||||
},
|
||||
]}
|
||||
requiresReason
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null;
|
||||
return (
|
||||
<ContentPageFrame
|
||||
title="Localization"
|
||||
description="Manage CMS, client, and emulator translation stores."
|
||||
result={result}
|
||||
forms={forms}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export async function renderContentLocalizationPage(input: HousekeepingPageInput) {
|
||||
export async function renderContentLocalizationPage(
|
||||
input: HousekeepingPageInput,
|
||||
) {
|
||||
const routeId = input.match.routeId as ContentPageProps["routeId"];
|
||||
const result = await contentQuery.run(input.context, { routeId: routeId ?? "content.localization.overview", params: input.match.params, list: parseContentListInput(input.searchParams ?? {}) });
|
||||
return <ContentLocalizationPage context={input.context} result={result} routeId={routeId} />;
|
||||
const result = await contentQuery.run(input.context, {
|
||||
routeId: routeId ?? "content.localization.overview",
|
||||
params: input.match.params,
|
||||
list: parseContentListInput(input.searchParams ?? {}),
|
||||
});
|
||||
return (
|
||||
<ContentLocalizationPage
|
||||
context={input.context}
|
||||
result={result}
|
||||
routeId={routeId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -134,7 +134,11 @@ function rows(result: unknown): readonly RawRow[] {
|
||||
}
|
||||
|
||||
function value(value: unknown, fallback = ""): string {
|
||||
return typeof value === "string" ? value : value == null ? fallback : String(value);
|
||||
return typeof value === "string"
|
||||
? value
|
||||
: value == null
|
||||
? fallback
|
||||
: String(value);
|
||||
}
|
||||
|
||||
function updatedAt(value: unknown): string | null {
|
||||
@@ -157,7 +161,10 @@ function response(
|
||||
};
|
||||
}
|
||||
|
||||
function matches(input: NormalizedContentQueryInput, item: ContentQueryItem): boolean {
|
||||
function matches(
|
||||
input: NormalizedContentQueryInput,
|
||||
item: ContentQueryItem,
|
||||
): boolean {
|
||||
const needle = input.list.search.toLocaleLowerCase();
|
||||
return (
|
||||
needle.length === 0 ||
|
||||
@@ -171,18 +178,20 @@ function mapRows(
|
||||
definition: QueryDefinition,
|
||||
rawRows: readonly RawRow[],
|
||||
): readonly ContentQueryItem[] {
|
||||
let items = rawRows.map((row) => {
|
||||
const id = value(row.id);
|
||||
if (!id) throw new Error("invalid Content identifier");
|
||||
return {
|
||||
id,
|
||||
title: value(row.title, "Untitled content"),
|
||||
description: value(row.description) || undefined,
|
||||
status: value(row.status) || undefined,
|
||||
updatedAt: updatedAt(row.updated_at),
|
||||
href: definition.appendId ? definition.href + id : definition.href,
|
||||
};
|
||||
}).filter((item) => matches(input, item));
|
||||
let items = rawRows
|
||||
.map((row) => {
|
||||
const id = value(row.id);
|
||||
if (!id) throw new Error("invalid Content identifier");
|
||||
return {
|
||||
id,
|
||||
title: value(row.title, "Untitled content"),
|
||||
description: value(row.description) || undefined,
|
||||
status: value(row.status) || undefined,
|
||||
updatedAt: updatedAt(row.updated_at),
|
||||
href: definition.appendId ? definition.href + id : definition.href,
|
||||
};
|
||||
})
|
||||
.filter((item) => matches(input, item));
|
||||
if (input.params.id) {
|
||||
items = items.filter((item) => item.id === input.params.id);
|
||||
}
|
||||
@@ -200,7 +209,11 @@ async function databaseRoute(
|
||||
const items = mapRows(
|
||||
input,
|
||||
definition,
|
||||
rows(await db.execute(sql.raw(definition.statement))),
|
||||
rows(
|
||||
await db.execute(
|
||||
sql.raw(definition.statement.replace(/\s+LIMIT 500$/u, "")),
|
||||
),
|
||||
),
|
||||
);
|
||||
return response(input, items, items.length);
|
||||
}
|
||||
@@ -219,54 +232,67 @@ async function mediaLibrary(
|
||||
/[.](png|jpe?g|gif|webp|svg|bmp)$/iu.test(name),
|
||||
);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return response(input, []);
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT")
|
||||
return response(input, []);
|
||||
throw error;
|
||||
}
|
||||
const entries = await Promise.all(
|
||||
names.map(async (name) => ({ name, metadata: await stat(resolve(MEDIA_ROOT, name)) })),
|
||||
names.map(async (name) => ({
|
||||
name,
|
||||
metadata: await stat(resolve(MEDIA_ROOT, name)),
|
||||
})),
|
||||
);
|
||||
entries.sort((left, right) => right.metadata.mtimeMs - left.metadata.mtimeMs);
|
||||
const items = entries.map(({ name, metadata }) => ({
|
||||
id: name,
|
||||
title: name,
|
||||
description: String(metadata.size) + " bytes",
|
||||
status: "stored",
|
||||
updatedAt: metadata.mtime.toISOString(),
|
||||
href: "/ase/content/media/library",
|
||||
})).filter((item) => matches(input, item));
|
||||
const items = entries
|
||||
.map(({ name, metadata }) => ({
|
||||
id: name,
|
||||
title: name,
|
||||
description: String(metadata.size) + " bytes",
|
||||
status: "stored",
|
||||
updatedAt: metadata.mtime.toISOString(),
|
||||
href: "/ase/content/media/library",
|
||||
}))
|
||||
.filter((item) => matches(input, item));
|
||||
return response(input, items, items.length);
|
||||
}
|
||||
|
||||
async function brand(input: NormalizedContentQueryInput): Promise<ContentQueryData> {
|
||||
async function brand(
|
||||
input: NormalizedContentQueryInput,
|
||||
): Promise<ContentQueryData> {
|
||||
const [{ siteSettings }, { listCustomThemes }] = await Promise.all([
|
||||
import("@/lib/services/site-settings"),
|
||||
import("@/lib/theme-custom-store"),
|
||||
]);
|
||||
if (input.routeId === "content.brand.favicon") {
|
||||
const favicon = await siteSettings.get("cms_favicon", null);
|
||||
return response(input, [{
|
||||
id: "favicon",
|
||||
title: favicon || "Default favicon",
|
||||
status: favicon ? "custom" : "default",
|
||||
href: "/ase/content/brand/favicon",
|
||||
}]);
|
||||
return response(input, [
|
||||
{
|
||||
id: "favicon",
|
||||
title: favicon || "Default favicon",
|
||||
status: favicon ? "custom" : "default",
|
||||
href: "/ase/content/brand/favicon",
|
||||
},
|
||||
]);
|
||||
}
|
||||
const [preset, customThemes] = await Promise.all([
|
||||
siteSettings.get("theme_preset", "Atom (golden)"),
|
||||
listCustomThemes(),
|
||||
]);
|
||||
return response(input, [{
|
||||
id: "active-theme",
|
||||
title: preset || "Atom (golden)",
|
||||
status: "active",
|
||||
href: "/ase/content/brand/theme",
|
||||
}, ...customThemes.map((theme) => ({
|
||||
id: theme.id,
|
||||
title: theme.name,
|
||||
status: "saved",
|
||||
updatedAt: new Date(theme.createdAt).toISOString(),
|
||||
href: "/ase/content/brand/theme",
|
||||
}))]);
|
||||
return response(input, [
|
||||
{
|
||||
id: "active-theme",
|
||||
title: preset || "Atom (golden)",
|
||||
status: "active",
|
||||
href: "/ase/content/brand/theme",
|
||||
},
|
||||
...customThemes.map((theme) => ({
|
||||
id: theme.id,
|
||||
title: theme.name,
|
||||
status: "saved",
|
||||
updatedAt: new Date(theme.createdAt).toISOString(),
|
||||
href: "/ase/content/brand/theme",
|
||||
})),
|
||||
]);
|
||||
}
|
||||
|
||||
async function localizationFiles(
|
||||
@@ -276,13 +302,16 @@ async function localizationFiles(
|
||||
const { CLIENT_TRANSLATION_FILES } = await import(
|
||||
"@/lib/client-translation-files"
|
||||
);
|
||||
return response(input, CLIENT_TRANSLATION_FILES.map((file) => ({
|
||||
id: file.id,
|
||||
title: file.id,
|
||||
description: file.relPath,
|
||||
status: file.readOnly ? "read-only" : "editable",
|
||||
href: "/ase/content/localization/client",
|
||||
})));
|
||||
return response(
|
||||
input,
|
||||
CLIENT_TRANSLATION_FILES.map((file) => ({
|
||||
id: file.id,
|
||||
title: file.id,
|
||||
description: file.relPath,
|
||||
status: file.readOnly ? "read-only" : "editable",
|
||||
href: "/ase/content/localization/client",
|
||||
})),
|
||||
);
|
||||
}
|
||||
const [{ readdir }, { join }] = await Promise.all([
|
||||
import("node:fs/promises"),
|
||||
@@ -319,12 +348,15 @@ async function localizationFiles(
|
||||
},
|
||||
]);
|
||||
}
|
||||
return response(input, locales.map((locale) => ({
|
||||
id: locale,
|
||||
title: locale,
|
||||
status: "editable",
|
||||
href: "/ase/content/localization/cms",
|
||||
})));
|
||||
return response(
|
||||
input,
|
||||
locales.map((locale) => ({
|
||||
id: locale,
|
||||
title: locale,
|
||||
status: "editable",
|
||||
href: "/ase/content/localization/cms",
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
export async function loadProductionContentQuery(
|
||||
@@ -340,9 +372,11 @@ export async function loadProductionContentQuery(
|
||||
) {
|
||||
return localizationFiles(input);
|
||||
}
|
||||
const definition = (CONTENT_QUERY_DEFINITIONS as Partial<
|
||||
Record<ContentRouteId, QueryDefinition>
|
||||
>)[input.routeId];
|
||||
const definition = (
|
||||
CONTENT_QUERY_DEFINITIONS as Partial<
|
||||
Record<ContentRouteId, QueryDefinition>
|
||||
>
|
||||
)[input.routeId];
|
||||
if (!definition) throw new Error("missing Content query adapter");
|
||||
return databaseRoute(input, definition);
|
||||
}
|
||||
@@ -7,7 +7,16 @@ import {
|
||||
type ContentQueryInput,
|
||||
createContentQuery,
|
||||
} from "./content-queries";
|
||||
import { CONTENT_QUERY_DEFINITIONS } from "./content-queries-production";
|
||||
import {
|
||||
CONTENT_QUERY_DEFINITIONS,
|
||||
loadProductionContentQuery,
|
||||
} from "./content-queries-production";
|
||||
|
||||
const queryMocks = vi.hoisted(() => ({ execute: vi.fn() }));
|
||||
vi.mock("drizzle-orm", () => ({
|
||||
sql: { raw: (statement: string) => statement },
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({ db: { execute: queryMocks.execute } }));
|
||||
|
||||
function context(granted: readonly string[]): HousekeepingCapabilityContext {
|
||||
const permissions = new Set(granted);
|
||||
@@ -35,6 +44,25 @@ const ready: ContentQueryData = {
|
||||
};
|
||||
|
||||
describe("Content query", () => {
|
||||
it("reports totals beyond the former 500-row adapter cap", async () => {
|
||||
const allRows = Array.from({ length: 600 }, (_, index) => ({
|
||||
id: index + 1,
|
||||
title: `Article ${index + 1}`,
|
||||
status: "published",
|
||||
updated_at: null,
|
||||
}));
|
||||
queryMocks.execute.mockImplementationOnce(async (statement: string) => [
|
||||
statement.includes("LIMIT 500") ? allRows.slice(0, 500) : allRows,
|
||||
]);
|
||||
const result = await loadProductionContentQuery({
|
||||
routeId: "content.editorial.articles",
|
||||
params: {},
|
||||
list: { search: "", pageSize: 25, offset: 0 },
|
||||
});
|
||||
expect(result.total).toBe(600);
|
||||
expect(result.items).toHaveLength(25);
|
||||
});
|
||||
|
||||
it("never selects email template bodies into summary query results", () => {
|
||||
const emailTemplates =
|
||||
CONTENT_QUERY_DEFINITIONS["content.help.email-templates"];
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
|
||||
import { executeContentDatabaseMutation } from "./mutation-runtime-database";
|
||||
|
||||
const database = vi.hoisted(() => {
|
||||
let selected: Record<string, unknown> = { id: 7, title: "Existing" };
|
||||
const set = vi.fn((values: Record<string, unknown>) => ({
|
||||
where: vi.fn(async () => undefined),
|
||||
values,
|
||||
}));
|
||||
const update = vi.fn(() => ({ set }));
|
||||
const limit = vi.fn(async () => [selected]);
|
||||
const where = vi.fn(() => ({ limit }));
|
||||
const from = vi.fn(() => ({ where }));
|
||||
const select = vi.fn(() => ({ from }));
|
||||
return {
|
||||
set,
|
||||
update,
|
||||
select,
|
||||
selected(value: Record<string, unknown>) {
|
||||
selected = value;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("drizzle-orm", () => ({
|
||||
eq: vi.fn(),
|
||||
sql: Object.assign(vi.fn(), { raw: vi.fn() }),
|
||||
}));
|
||||
vi.mock("@/lib/db", () => {
|
||||
const table = new Proxy({}, { get: (_target, key) => String(key) });
|
||||
return {
|
||||
db: {
|
||||
select: database.select,
|
||||
update: database.update,
|
||||
},
|
||||
EmailTemplates: table,
|
||||
Taggables: table,
|
||||
Tags: table,
|
||||
User: table,
|
||||
WebsiteAds: table,
|
||||
WebsiteArticleComments: table,
|
||||
WebsiteArticleReactions: table,
|
||||
WebsiteArticles: table,
|
||||
WebsiteBanner: table,
|
||||
WebsiteEvent: table,
|
||||
WebsiteEventPrize: table,
|
||||
WebsiteEventType: table,
|
||||
WebsiteEventWinner: table,
|
||||
WebsiteHelpCenterCategories: table,
|
||||
WebsitePoll: table,
|
||||
WebsitePollQuestion: table,
|
||||
WebsiteWriteableBoxes: table,
|
||||
};
|
||||
});
|
||||
vi.mock("@/lib/services/staff-activity", () => ({
|
||||
logStaffActivity: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
const capability = {
|
||||
actor: { id: 42, username: "operator", rank: 7 },
|
||||
isSuperAdmin: false,
|
||||
has: () => true,
|
||||
hasAny: () => true,
|
||||
hasAll: () => true,
|
||||
} satisfies HousekeepingCapabilityContext;
|
||||
const context = {
|
||||
capability,
|
||||
correlationId: "database-runtime",
|
||||
legacy: false,
|
||||
};
|
||||
|
||||
describe("Content database mutation runtime partial updates", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
database.selected({ id: 7, title: "Existing" });
|
||||
});
|
||||
|
||||
it("does not reset omitted banner fields", async () => {
|
||||
await executeContentDatabaseMutation(
|
||||
"banner.change",
|
||||
{ action: "update", id: 7, title: "Renamed" },
|
||||
context,
|
||||
undefined,
|
||||
);
|
||||
expect(database.set).toHaveBeenCalledWith({ title: "Renamed" });
|
||||
});
|
||||
|
||||
it("does not reset a tag color omitted by the update form", async () => {
|
||||
await executeContentDatabaseMutation(
|
||||
"tag.change",
|
||||
{ action: "update", id: "7", name: "News" },
|
||||
context,
|
||||
undefined,
|
||||
);
|
||||
const values = database.set.mock.calls[0]?.[0];
|
||||
expect(values).toMatchObject({ name: "News" });
|
||||
expect(values).not.toHaveProperty("backgroundColor");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["help-question.change", { name: "Updated question" }, "name"],
|
||||
["writeable-box.change", { title: "Updated box" }, "title"],
|
||||
["email-template.change", { subject: "Updated subject" }, "subject"],
|
||||
] as const)(
|
||||
"updates only supplied fields for %s",
|
||||
async (operation, patch, expectedKey) => {
|
||||
await executeContentDatabaseMutation(
|
||||
operation,
|
||||
{ action: "update", id: "7", ...patch },
|
||||
context,
|
||||
undefined,
|
||||
);
|
||||
const values = database.set.mock.calls[0]?.[0];
|
||||
expect(values).toHaveProperty(expectedKey);
|
||||
for (const destructiveKey of [
|
||||
"content",
|
||||
"position",
|
||||
"isActive",
|
||||
"body",
|
||||
"variables",
|
||||
"imageUrl",
|
||||
]) {
|
||||
expect(values).not.toHaveProperty(destructiveKey);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["help-question.change", { answer: "Updated answer" }, "content"],
|
||||
["writeable-box.change", { content: "Updated content" }, "content"],
|
||||
["email-template.change", { body: "Updated body" }, "body"],
|
||||
] as const)(
|
||||
"accepts a non-title partial patch for %s",
|
||||
async (operation, patch, expectedKey) => {
|
||||
await executeContentDatabaseMutation(
|
||||
operation,
|
||||
{ action: "update", id: "7", ...patch },
|
||||
context,
|
||||
undefined,
|
||||
);
|
||||
expect(database.set.mock.calls[0]?.[0]).toHaveProperty(expectedKey);
|
||||
},
|
||||
);
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
+126
-8
@@ -10,23 +10,33 @@ const fsMocks = vi.hoisted(() => ({
|
||||
writeFile: vi.fn(),
|
||||
}));
|
||||
|
||||
const dbMocks = vi.hoisted(() => {
|
||||
const onDuplicateKeyUpdate = vi.fn(async () => undefined);
|
||||
const values = vi.fn(() => ({ onDuplicateKeyUpdate }));
|
||||
const insert = vi.fn(() => ({ values }));
|
||||
const where = vi.fn(async () => undefined);
|
||||
const deleteFn = vi.fn(() => ({ where }));
|
||||
return { deleteFn, insert, onDuplicateKeyUpdate, values, where };
|
||||
});
|
||||
const siteMocks = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
reload: vi.fn(),
|
||||
update: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:fs/promises", () => fsMocks);
|
||||
vi.mock("drizzle-orm", () => ({ eq: vi.fn() }));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
CameraWeb: {},
|
||||
EmulatorSettings: {},
|
||||
WebsiteSetting: {},
|
||||
db: {},
|
||||
db: { delete: dbMocks.deleteFn, insert: dbMocks.insert },
|
||||
}));
|
||||
vi.mock("@/lib/services/rcon", () => ({
|
||||
rcon: { updateConfig: vi.fn() },
|
||||
}));
|
||||
vi.mock("@/lib/services/site-settings", () => ({
|
||||
siteSettings: {
|
||||
get: vi.fn(),
|
||||
reload: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
siteSettings: siteMocks,
|
||||
}));
|
||||
vi.mock("@/lib/services/staff-activity", () => ({
|
||||
logStaffActivity: vi.fn(),
|
||||
@@ -51,10 +61,15 @@ const context = {
|
||||
describe("Content external mutation runtime", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
siteMocks.get.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("preserves media upload bytes, type, size, and canonical public URL", async () => {
|
||||
const file = new File(["image"], "photo.PNG", { type: "image/png" });
|
||||
const file = new File(
|
||||
[new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])],
|
||||
"photo.png",
|
||||
{ type: "image/png" },
|
||||
);
|
||||
const snapshot = await executeContentExternalMutation(
|
||||
"media.upload",
|
||||
{ file },
|
||||
@@ -75,7 +90,11 @@ describe("Content external mutation runtime", () => {
|
||||
|
||||
it("propagates a real storage failure before optimistic success", async () => {
|
||||
fsMocks.writeFile.mockRejectedValueOnce(new Error("disk offline"));
|
||||
const file = new File(["image"], "photo.png", { type: "image/png" });
|
||||
const file = new File(
|
||||
[new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])],
|
||||
"photo.png",
|
||||
{ type: "image/png" },
|
||||
);
|
||||
|
||||
await expect(
|
||||
executeContentExternalMutation("media.upload", { file }, context),
|
||||
@@ -95,4 +114,103 @@ describe("Content external mutation runtime", () => {
|
||||
} satisfies Partial<ContentMutationFailure>);
|
||||
expect(fsMocks.unlink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects declared MIME, filename extension, and actual bytes that disagree", async () => {
|
||||
const disguisedSvg = new File(
|
||||
['<svg xmlns="http://www.w3.org/2000/svg"></svg>'],
|
||||
"photo.svg",
|
||||
{ type: "image/png" },
|
||||
);
|
||||
|
||||
await expect(
|
||||
executeContentExternalMutation(
|
||||
"media.upload",
|
||||
{ file: disguisedSvg },
|
||||
context,
|
||||
),
|
||||
).rejects.toMatchObject({ code: "VALIDATION" });
|
||||
expect(fsMocks.writeFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an oversized logo before reading its bytes", async () => {
|
||||
const arrayBuffer = vi.fn();
|
||||
const file = {
|
||||
name: "logo.png",
|
||||
type: "image/png",
|
||||
size: 5 * 1024 * 1024 + 1,
|
||||
arrayBuffer,
|
||||
};
|
||||
|
||||
await expect(
|
||||
executeContentExternalMutation("logo.save", { file }, context),
|
||||
).rejects.toMatchObject({ code: "VALIDATION" });
|
||||
expect(arrayBuffer).not.toHaveBeenCalled();
|
||||
expect(fsMocks.writeFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("removes a newly written favicon when the database write fails", async () => {
|
||||
dbMocks.onDuplicateKeyUpdate.mockRejectedValueOnce(
|
||||
new Error("database offline"),
|
||||
);
|
||||
const file = new File(
|
||||
[new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])],
|
||||
"favicon.png",
|
||||
{ type: "image/png" },
|
||||
);
|
||||
|
||||
await expect(
|
||||
executeContentExternalMutation("favicon.save", { file }, context),
|
||||
).rejects.toThrow("database offline");
|
||||
const writtenPath = fsMocks.writeFile.mock.calls[0]?.[0];
|
||||
expect(fsMocks.unlink).toHaveBeenCalledWith(writtenPath);
|
||||
});
|
||||
|
||||
it("reports a partial favicon result when database failure compensation also fails", async () => {
|
||||
dbMocks.onDuplicateKeyUpdate.mockRejectedValueOnce(
|
||||
new Error("database offline"),
|
||||
);
|
||||
fsMocks.unlink.mockRejectedValueOnce(new Error("cleanup failed"));
|
||||
const file = new File(
|
||||
[new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])],
|
||||
"favicon.png",
|
||||
{ type: "image/png" },
|
||||
);
|
||||
|
||||
await expect(
|
||||
executeContentExternalMutation("favicon.save", { file }, context),
|
||||
).rejects.toMatchObject({
|
||||
name: "ContentCommittedExternalFailure",
|
||||
snapshot: {
|
||||
before: { value: null },
|
||||
after: { value: null },
|
||||
output: { compensation: "failed" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("removes a newly written logo when the database write fails", async () => {
|
||||
dbMocks.onDuplicateKeyUpdate.mockRejectedValueOnce(
|
||||
new Error("database offline"),
|
||||
);
|
||||
const file = new File(
|
||||
[new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])],
|
||||
"logo.png",
|
||||
{ type: "image/png" },
|
||||
);
|
||||
await expect(
|
||||
executeContentExternalMutation("logo.save", { file }, context),
|
||||
).rejects.toThrow("database offline");
|
||||
expect(fsMocks.unlink).toHaveBeenCalledWith(
|
||||
fsMocks.writeFile.mock.calls[0]?.[0],
|
||||
);
|
||||
});
|
||||
|
||||
it("does not delete the existing favicon before the database delete commits", async () => {
|
||||
siteMocks.get.mockResolvedValueOnce("/api/media/favicon/old.png");
|
||||
dbMocks.where.mockRejectedValueOnce(new Error("database offline"));
|
||||
await expect(
|
||||
executeContentExternalMutation("favicon.delete", {}, context),
|
||||
).rejects.toThrow("database offline");
|
||||
expect(fsMocks.unlink).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -34,15 +34,48 @@ import {
|
||||
} from "./mutations";
|
||||
import { ContentCommittedExternalFailure } from "./mutations-production";
|
||||
|
||||
const MEDIA_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
||||
const MEDIA_TYPES = [
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
] as const;
|
||||
const FAVICON_TYPES = [
|
||||
...MEDIA_TYPES,
|
||||
"image/x-icon",
|
||||
"image/svg+xml",
|
||||
];
|
||||
] as const;
|
||||
const IMAGE_EXTENSIONS = {
|
||||
"image/png": [".png"],
|
||||
"image/jpeg": [".jpg", ".jpeg"],
|
||||
"image/gif": [".gif"],
|
||||
"image/webp": [".webp"],
|
||||
"image/x-icon": [".ico"],
|
||||
"image/svg+xml": [".svg"],
|
||||
} as const;
|
||||
const CMS_LOCALES = new Set([
|
||||
"en", "it", "nl", "de", "fr", "es", "pt", "pl", "sv", "tr", "ro",
|
||||
"hu", "cs", "sk", "da", "no", "el", "bg", "hr", "sr", "uk", "ru",
|
||||
"en",
|
||||
"it",
|
||||
"nl",
|
||||
"de",
|
||||
"fr",
|
||||
"es",
|
||||
"pt",
|
||||
"pl",
|
||||
"sv",
|
||||
"tr",
|
||||
"ro",
|
||||
"hu",
|
||||
"cs",
|
||||
"sk",
|
||||
"da",
|
||||
"no",
|
||||
"el",
|
||||
"bg",
|
||||
"hr",
|
||||
"sr",
|
||||
"uk",
|
||||
"ru",
|
||||
]);
|
||||
const COLOR_RE = /^[#a-zA-Z0-9(),.\s%-]+$/;
|
||||
const ADMIN_COLOR_KEYS = [
|
||||
@@ -53,23 +86,37 @@ const ADMIN_COLOR_KEYS = [
|
||||
"admin_border",
|
||||
"admin_sidebar_bg",
|
||||
] as const;
|
||||
const HEADING_KEYS = ["size_heading_h1", "size_heading_h2", "size_heading_h3"] as const;
|
||||
const HEADING_KEYS = [
|
||||
"size_heading_h1",
|
||||
"size_heading_h2",
|
||||
"size_heading_h3",
|
||||
] as const;
|
||||
|
||||
function validation(): ContentMutationFailure {
|
||||
return new ContentMutationFailure("VALIDATION", "errors.housekeeping.validation");
|
||||
return new ContentMutationFailure(
|
||||
"VALIDATION",
|
||||
"errors.housekeeping.validation",
|
||||
);
|
||||
}
|
||||
|
||||
function notFound(): ContentMutationFailure {
|
||||
return new ContentMutationFailure("NOT_FOUND", "errors.housekeeping.notFound");
|
||||
return new ContentMutationFailure(
|
||||
"NOT_FOUND",
|
||||
"errors.housekeeping.notFound",
|
||||
);
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) throw validation();
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value))
|
||||
throw validation();
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function text(value: unknown, maximum: number, required = false): string {
|
||||
const normalized = String(value ?? "").normalize("NFC").trim().slice(0, maximum);
|
||||
const normalized = String(value ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, maximum);
|
||||
if (required && !normalized) throw validation();
|
||||
return normalized;
|
||||
}
|
||||
@@ -99,6 +146,81 @@ function fileValue(value: unknown): File {
|
||||
return value as File;
|
||||
}
|
||||
|
||||
type SupportedImageType = keyof typeof IMAGE_EXTENSIONS;
|
||||
|
||||
function detectedImageType(bytes: Buffer): SupportedImageType | null {
|
||||
if (
|
||||
bytes.length >= 8 &&
|
||||
bytes
|
||||
.subarray(0, 8)
|
||||
.equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
|
||||
)
|
||||
return "image/png";
|
||||
if (
|
||||
bytes.length >= 3 &&
|
||||
bytes[0] === 0xff &&
|
||||
bytes[1] === 0xd8 &&
|
||||
bytes[2] === 0xff
|
||||
)
|
||||
return "image/jpeg";
|
||||
const header = bytes.subarray(0, 12).toString("ascii");
|
||||
if (header.startsWith("GIF87a") || header.startsWith("GIF89a"))
|
||||
return "image/gif";
|
||||
if (header.startsWith("RIFF") && header.slice(8, 12) === "WEBP")
|
||||
return "image/webp";
|
||||
if (
|
||||
bytes.length >= 4 &&
|
||||
bytes[0] === 0 &&
|
||||
bytes[1] === 0 &&
|
||||
bytes[2] === 1 &&
|
||||
bytes[3] === 0
|
||||
)
|
||||
return "image/x-icon";
|
||||
const source = bytes
|
||||
.toString("utf8")
|
||||
.replace(/^\uFEFF/u, "")
|
||||
.trimStart();
|
||||
const svg = source.replace(/^<\?xml[^>]*>\s*/iu, "");
|
||||
if (/^<svg(?:\s|>)/iu.test(svg)) return "image/svg+xml";
|
||||
return null;
|
||||
}
|
||||
|
||||
function isSafeSvg(bytes: Buffer): boolean {
|
||||
const source = bytes.toString("utf8");
|
||||
return (
|
||||
!/<(?:script|foreignObject|iframe|object|embed|link|meta)(?:\s|>)/iu.test(
|
||||
source,
|
||||
) &&
|
||||
!/\son[a-z]+\s*=/iu.test(source) &&
|
||||
!/(?:href|src)\s*=\s*["']?\s*(?:javascript:|data:text\/html)/iu.test(source)
|
||||
);
|
||||
}
|
||||
|
||||
async function validatedImage(
|
||||
value: unknown,
|
||||
maximum: number,
|
||||
allowedTypes: readonly SupportedImageType[],
|
||||
): Promise<{
|
||||
file: File;
|
||||
bytes: Buffer;
|
||||
type: SupportedImageType;
|
||||
extension: string;
|
||||
}> {
|
||||
const file = fileValue(value);
|
||||
if (file.size <= 0 || file.size > maximum) throw validation();
|
||||
const bytes = Buffer.from(await file.arrayBuffer());
|
||||
if (bytes.length <= 0 || bytes.length > maximum || bytes.length !== file.size)
|
||||
throw validation();
|
||||
const type = detectedImageType(bytes);
|
||||
if (!type || !allowedTypes.includes(type) || file.type.toLowerCase() !== type)
|
||||
throw validation();
|
||||
if (type === "image/svg+xml" && !isSafeSvg(bytes)) throw validation();
|
||||
const extension = path.extname(file.name).toLowerCase();
|
||||
if (!(IMAGE_EXTENSIONS[type] as readonly string[]).includes(extension))
|
||||
throw validation();
|
||||
return { file, bytes, type, extension: IMAGE_EXTENSIONS[type][0].slice(1) };
|
||||
}
|
||||
|
||||
async function writeWebsiteSetting(
|
||||
key: string,
|
||||
value: string,
|
||||
@@ -111,17 +233,20 @@ async function writeWebsiteSetting(
|
||||
}
|
||||
|
||||
async function mediaUpload(input: unknown): Promise<ContentMutationSnapshot> {
|
||||
const file = fileValue(record(input).file);
|
||||
if (file.size <= 0 || file.size > 5 * 1024 * 1024 || !MEDIA_TYPES.includes(file.type)) throw validation();
|
||||
const { file, bytes, type, extension } = await validatedImage(
|
||||
record(input).file,
|
||||
5 * 1024 * 1024,
|
||||
MEDIA_TYPES,
|
||||
);
|
||||
await mkdir(MEDIA_ROOT, { recursive: true });
|
||||
const extension = text(file.name.split(".").pop() ?? "png", 10, true).toLowerCase();
|
||||
const name = Date.now() + "-" + Math.random().toString(36).slice(2, 8) + "." + extension;
|
||||
const name =
|
||||
Date.now() + "-" + Math.random().toString(36).slice(2, 8) + "." + extension;
|
||||
const filePath = resolveMediaPath(name);
|
||||
if (!filePath.startsWith(MEDIA_ROOT + path.sep)) throw validation();
|
||||
await writeFile(filePath, Buffer.from(await file.arrayBuffer()));
|
||||
await writeFile(filePath, bytes);
|
||||
return {
|
||||
before: null,
|
||||
after: { name, size: file.size, type: file.type },
|
||||
after: { name, size: file.size, type },
|
||||
output: { name, url: "/api/media/" + name },
|
||||
};
|
||||
}
|
||||
@@ -149,7 +274,11 @@ async function photoDelete(
|
||||
): Promise<ContentMutationSnapshot> {
|
||||
const id = Number(record(input).id);
|
||||
if (!Number.isSafeInteger(id) || id <= 0) throw validation();
|
||||
const [row] = await db.select({ id: CameraWeb.id, url: CameraWeb.url }).from(CameraWeb).where(eq(CameraWeb.id, id)).limit(1);
|
||||
const [row] = await db
|
||||
.select({ id: CameraWeb.id, url: CameraWeb.url })
|
||||
.from(CameraWeb)
|
||||
.where(eq(CameraWeb.id, id))
|
||||
.limit(1);
|
||||
if (!row) throw notFound();
|
||||
await db.delete(CameraWeb).where(eq(CameraWeb.id, id));
|
||||
await tryRemoveLocalPhotoFile(row.url);
|
||||
@@ -163,7 +292,9 @@ async function photoDelete(
|
||||
return { before: { id, url: row.url }, after: null };
|
||||
}
|
||||
|
||||
async function navigationUpdate(input: unknown): Promise<ContentMutationSnapshot> {
|
||||
async function navigationUpdate(
|
||||
input: unknown,
|
||||
): Promise<ContentMutationSnapshot> {
|
||||
const data = record(input);
|
||||
const raw = data.items ?? data.config ?? data;
|
||||
const config = jsonRecord(raw) as AdminNavConfig;
|
||||
@@ -197,9 +328,17 @@ async function themeUpdate(
|
||||
const raw = text(source[databaseKey], 255);
|
||||
if (raw && COLOR_RE.test(raw)) bag[key] = raw;
|
||||
}
|
||||
for (const [key, value] of Object.entries(ensureReadableThemeColors(bag))) {
|
||||
const databaseKey = settingKey(key as (typeof THEME_COLOR_KEYS)[number], mode);
|
||||
await transaction.insert(WebsiteSetting).values({ key: databaseKey, value, comment: "Theme (housekeeping)" }).onDuplicateKeyUpdate({ set: { value } });
|
||||
for (const [key, value] of Object.entries(
|
||||
ensureReadableThemeColors(bag),
|
||||
)) {
|
||||
const databaseKey = settingKey(
|
||||
key as (typeof THEME_COLOR_KEYS)[number],
|
||||
mode,
|
||||
);
|
||||
await transaction
|
||||
.insert(WebsiteSetting)
|
||||
.values({ key: databaseKey, value, comment: "Theme (housekeeping)" })
|
||||
.onDuplicateKeyUpdate({ set: { value } });
|
||||
changed.push(databaseKey);
|
||||
}
|
||||
}
|
||||
@@ -208,63 +347,121 @@ async function themeUpdate(
|
||||
const raw = text(source[key], 255);
|
||||
if (raw && COLOR_RE.test(raw)) adminBag[key] = raw;
|
||||
}
|
||||
for (const [key, value] of Object.entries(ensureReadableThemeColors(adminBag))) {
|
||||
await transaction.insert(WebsiteSetting).values({ key, value, comment: "Theme (housekeeping)" }).onDuplicateKeyUpdate({ set: { value } });
|
||||
for (const [key, value] of Object.entries(
|
||||
ensureReadableThemeColors(adminBag),
|
||||
)) {
|
||||
await transaction
|
||||
.insert(WebsiteSetting)
|
||||
.values({ key, value, comment: "Theme (housekeeping)" })
|
||||
.onDuplicateKeyUpdate({ set: { value } });
|
||||
changed.push(key);
|
||||
}
|
||||
const radius = text(source.border_radius, 3);
|
||||
if (/^\d{1,3}$/u.test(radius)) {
|
||||
await transaction.insert(WebsiteSetting).values({ key: "border_radius", value: radius, comment: "Theme (housekeeping)" }).onDuplicateKeyUpdate({ set: { value: radius } });
|
||||
await transaction
|
||||
.insert(WebsiteSetting)
|
||||
.values({
|
||||
key: "border_radius",
|
||||
value: radius,
|
||||
comment: "Theme (housekeeping)",
|
||||
})
|
||||
.onDuplicateKeyUpdate({ set: { value: radius } });
|
||||
changed.push("border_radius");
|
||||
}
|
||||
const font = text(source.font_family, 100);
|
||||
if (font in FONTS) {
|
||||
await transaction.insert(WebsiteSetting).values({ key: "font_family", value: font, comment: "Theme (housekeeping)" }).onDuplicateKeyUpdate({ set: { value: font } });
|
||||
await transaction
|
||||
.insert(WebsiteSetting)
|
||||
.values({
|
||||
key: "font_family",
|
||||
value: font,
|
||||
comment: "Theme (housekeeping)",
|
||||
})
|
||||
.onDuplicateKeyUpdate({ set: { value: font } });
|
||||
changed.push("font_family");
|
||||
}
|
||||
for (const key of HEADING_KEYS) {
|
||||
const value = text(source[key], 3);
|
||||
if (!/^\d{1,3}$/u.test(value)) continue;
|
||||
await transaction.insert(WebsiteSetting).values({ key, value, comment: "Theme (housekeeping)" }).onDuplicateKeyUpdate({ set: { value } });
|
||||
await transaction
|
||||
.insert(WebsiteSetting)
|
||||
.values({ key, value, comment: "Theme (housekeeping)" })
|
||||
.onDuplicateKeyUpdate({ set: { value } });
|
||||
changed.push(key);
|
||||
}
|
||||
if (Object.hasOwn(source, "custom_css")) {
|
||||
const value = String(source.custom_css ?? "").normalize("NFC").slice(0, 20_000);
|
||||
await transaction.insert(WebsiteSetting).values({ key: "custom_css", value, comment: "Theme (housekeeping)" }).onDuplicateKeyUpdate({ set: { value } });
|
||||
const value = String(source.custom_css ?? "")
|
||||
.normalize("NFC")
|
||||
.slice(0, 20_000);
|
||||
await transaction
|
||||
.insert(WebsiteSetting)
|
||||
.values({ key: "custom_css", value, comment: "Theme (housekeeping)" })
|
||||
.onDuplicateKeyUpdate({ set: { value } });
|
||||
changed.push("custom_css");
|
||||
}
|
||||
});
|
||||
const snapshot: ContentMutationSnapshot = { before: null, after: { changedKeys: changed.sort() } };
|
||||
const snapshot: ContentMutationSnapshot = {
|
||||
before: null,
|
||||
after: { changedKeys: changed.sort() },
|
||||
};
|
||||
try {
|
||||
siteSettings.reload();
|
||||
await logStaffActivity({ staffId: context.capability.actor.id, action: "theme_update", description: "Updated theme settings" });
|
||||
await logStaffActivity({
|
||||
staffId: context.capability.actor.id,
|
||||
action: "theme_update",
|
||||
description: "Updated theme settings",
|
||||
});
|
||||
} catch {
|
||||
throw new ContentCommittedExternalFailure(snapshot);
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
async function themeApplyPreset(input: unknown, context: ContentMutationContext): Promise<ContentMutationSnapshot> {
|
||||
async function themeApplyPreset(
|
||||
input: unknown,
|
||||
context: ContentMutationContext,
|
||||
): Promise<ContentMutationSnapshot> {
|
||||
const name = text(record(input).preset, 100, true);
|
||||
const preset = PRESETS[name];
|
||||
if (!preset) throw validation();
|
||||
await db.transaction(async (transaction) => {
|
||||
for (const [key, value] of presetSettings(preset)) {
|
||||
await transaction.insert(WebsiteSetting).values({ key, value, comment: "Theme (housekeeping)" }).onDuplicateKeyUpdate({ set: { value } });
|
||||
await transaction
|
||||
.insert(WebsiteSetting)
|
||||
.values({ key, value, comment: "Theme (housekeeping)" })
|
||||
.onDuplicateKeyUpdate({ set: { value } });
|
||||
}
|
||||
await transaction.insert(WebsiteSetting).values({ key: "theme_preset", value: name, comment: "Theme (housekeeping)" }).onDuplicateKeyUpdate({ set: { value: name } });
|
||||
await transaction
|
||||
.insert(WebsiteSetting)
|
||||
.values({
|
||||
key: "theme_preset",
|
||||
value: name,
|
||||
comment: "Theme (housekeeping)",
|
||||
})
|
||||
.onDuplicateKeyUpdate({ set: { value: name } });
|
||||
});
|
||||
const snapshot: ContentMutationSnapshot = { before: null, after: { preset: name }, output: { name } };
|
||||
const snapshot: ContentMutationSnapshot = {
|
||||
before: null,
|
||||
after: { preset: name },
|
||||
output: { name },
|
||||
};
|
||||
try {
|
||||
siteSettings.reload();
|
||||
await logStaffActivity({ staffId: context.capability.actor.id, action: "theme_preset", description: "Applied theme preset " + name });
|
||||
await logStaffActivity({
|
||||
staffId: context.capability.actor.id,
|
||||
action: "theme_preset",
|
||||
description: "Applied theme preset " + name,
|
||||
});
|
||||
} catch {
|
||||
throw new ContentCommittedExternalFailure(snapshot);
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
async function themeCustomChange(input: unknown, context: ContentMutationContext): Promise<ContentMutationSnapshot> {
|
||||
async function themeCustomChange(
|
||||
input: unknown,
|
||||
context: ContentMutationContext,
|
||||
): Promise<ContentMutationSnapshot> {
|
||||
const data = record(input);
|
||||
const action = text(data.action, 16, true);
|
||||
const id = text(data.id, 100);
|
||||
@@ -275,43 +472,81 @@ async function themeCustomChange(input: unknown, context: ContentMutationContext
|
||||
await deleteCustomThemeStore(id);
|
||||
return { before: { id, name: existing.name }, after: null };
|
||||
}
|
||||
if (action !== "create" && action !== "update" && action !== "rename") throw validation();
|
||||
if (action !== "create" && action !== "update" && action !== "rename")
|
||||
throw validation();
|
||||
const name = text(data.name, 100, true);
|
||||
const settings = data.values ? Object.fromEntries(Object.entries(jsonRecord(data.values)).map(([key, value]) => [key, String(value)])) : await snapshotCurrentTheme();
|
||||
const settings = data.values
|
||||
? Object.fromEntries(
|
||||
Object.entries(jsonRecord(data.values)).map(([key, value]) => [
|
||||
key,
|
||||
String(value),
|
||||
]),
|
||||
)
|
||||
: await snapshotCurrentTheme();
|
||||
const theme = await upsertCustomTheme(name, settings, id || undefined);
|
||||
await logStaffActivity({ staffId: context.capability.actor.id, action: "theme_preset", description: "Saved custom theme " + theme.name });
|
||||
return { before: id ? { id } : null, after: { id: theme.id, name: theme.name }, output: { id: theme.id, name: theme.name } };
|
||||
await logStaffActivity({
|
||||
staffId: context.capability.actor.id,
|
||||
action: "theme_preset",
|
||||
description: "Saved custom theme " + theme.name,
|
||||
});
|
||||
return {
|
||||
before: id ? { id } : null,
|
||||
after: { id: theme.id, name: theme.name },
|
||||
output: { id: theme.id, name: theme.name },
|
||||
};
|
||||
}
|
||||
|
||||
async function themeApplyCustom(input: unknown, context: ContentMutationContext): Promise<ContentMutationSnapshot> {
|
||||
async function themeApplyCustom(
|
||||
input: unknown,
|
||||
context: ContentMutationContext,
|
||||
): Promise<ContentMutationSnapshot> {
|
||||
const id = text(record(input).id, 100, true);
|
||||
const theme = await getCustomTheme(id);
|
||||
if (!theme) throw notFound();
|
||||
await db.transaction(async (transaction) => {
|
||||
for (const [key, value] of Object.entries(theme.settings)) {
|
||||
if (!value) continue;
|
||||
await transaction.insert(WebsiteSetting).values({ key, value, comment: "Theme (housekeeping)" }).onDuplicateKeyUpdate({ set: { value } });
|
||||
await transaction
|
||||
.insert(WebsiteSetting)
|
||||
.values({ key, value, comment: "Theme (housekeeping)" })
|
||||
.onDuplicateKeyUpdate({ set: { value } });
|
||||
}
|
||||
await transaction.insert(WebsiteSetting).values({ key: "theme_preset", value: theme.name, comment: "Theme (housekeeping)" }).onDuplicateKeyUpdate({ set: { value: theme.name } });
|
||||
await transaction
|
||||
.insert(WebsiteSetting)
|
||||
.values({
|
||||
key: "theme_preset",
|
||||
value: theme.name,
|
||||
comment: "Theme (housekeeping)",
|
||||
})
|
||||
.onDuplicateKeyUpdate({ set: { value: theme.name } });
|
||||
});
|
||||
const snapshot: ContentMutationSnapshot = { before: null, after: { id, name: theme.name }, output: { name: theme.name } };
|
||||
const snapshot: ContentMutationSnapshot = {
|
||||
before: null,
|
||||
after: { id, name: theme.name },
|
||||
output: { name: theme.name },
|
||||
};
|
||||
try {
|
||||
siteSettings.reload();
|
||||
await logStaffActivity({ staffId: context.capability.actor.id, action: "theme_preset", description: "Applied custom theme " + theme.name });
|
||||
await logStaffActivity({
|
||||
staffId: context.capability.actor.id,
|
||||
action: "theme_preset",
|
||||
description: "Applied custom theme " + theme.name,
|
||||
});
|
||||
} catch {
|
||||
throw new ContentCommittedExternalFailure(snapshot);
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function faviconExtension(type: string): string {
|
||||
return ({ "image/png": "png", "image/jpeg": "jpg", "image/gif": "gif", "image/webp": "webp", "image/x-icon": "ico", "image/svg+xml": "svg" } as Record<string, string>)[type] ?? "png";
|
||||
}
|
||||
|
||||
async function removeStoredAsset(url: string | null | undefined, directory: string, prefix: string): Promise<void> {
|
||||
async function removeStoredAsset(
|
||||
url: string | null | undefined,
|
||||
directory: string,
|
||||
prefix: string,
|
||||
): Promise<void> {
|
||||
if (!url?.startsWith(prefix)) return;
|
||||
const name = url.slice(prefix.length);
|
||||
if (!name || name.includes("..") || name.includes("/") || name.includes("\\")) return;
|
||||
if (!name || name.includes("..") || name.includes("/") || name.includes("\\"))
|
||||
return;
|
||||
const filePath = path.resolve(directory, name);
|
||||
if (!filePath.startsWith(directory + path.sep)) return;
|
||||
try {
|
||||
@@ -322,20 +557,40 @@ async function removeStoredAsset(url: string | null | undefined, directory: stri
|
||||
}
|
||||
|
||||
async function faviconSave(input: unknown): Promise<ContentMutationSnapshot> {
|
||||
const file = fileValue(record(input).file);
|
||||
if (file.size <= 0 || file.size > 2 * 1024 * 1024 || !FAVICON_TYPES.includes(file.type)) throw validation();
|
||||
const { bytes, extension } = await validatedImage(
|
||||
record(input).file,
|
||||
2 * 1024 * 1024,
|
||||
FAVICON_TYPES,
|
||||
);
|
||||
const directory = resolveMediaPath("favicon");
|
||||
const filename = "favicon-" + Date.now() + "." + faviconExtension(file.type);
|
||||
const filename = "favicon-" + Date.now() + "." + extension;
|
||||
const filePath = path.resolve(directory, filename);
|
||||
if (!filePath.startsWith(directory + path.sep)) throw validation();
|
||||
const oldUrl = await siteSettings.get("cms_favicon");
|
||||
await mkdir(directory, { recursive: true });
|
||||
await writeFile(filePath, Buffer.from(await file.arrayBuffer()));
|
||||
await removeStoredAsset(oldUrl, directory, "/api/media/favicon/");
|
||||
await writeFile(filePath, bytes);
|
||||
const url = "/api/media/favicon/" + filename;
|
||||
await writeWebsiteSetting("cms_favicon", url, "Favicon URL");
|
||||
const snapshot: ContentMutationSnapshot = { before: { value: oldUrl ?? null }, after: { value: url }, output: { url } };
|
||||
const snapshot: ContentMutationSnapshot = {
|
||||
before: { value: oldUrl ?? null },
|
||||
after: { value: url },
|
||||
output: { url },
|
||||
};
|
||||
try {
|
||||
await writeWebsiteSetting("cms_favicon", url, "Favicon URL");
|
||||
} catch (error) {
|
||||
try {
|
||||
await unlink(filePath);
|
||||
} catch {
|
||||
throw new ContentCommittedExternalFailure({
|
||||
before: { value: oldUrl ?? null },
|
||||
after: { value: oldUrl ?? null },
|
||||
output: { compensation: "failed" },
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
await removeStoredAsset(oldUrl, directory, "/api/media/favicon/");
|
||||
siteSettings.reload();
|
||||
} catch {
|
||||
throw new ContentCommittedExternalFailure(snapshot);
|
||||
@@ -345,10 +600,17 @@ async function faviconSave(input: unknown): Promise<ContentMutationSnapshot> {
|
||||
|
||||
async function faviconDelete(): Promise<ContentMutationSnapshot> {
|
||||
const oldUrl = await siteSettings.get("cms_favicon");
|
||||
await removeStoredAsset(oldUrl, resolveMediaPath("favicon"), "/api/media/favicon/");
|
||||
await db.delete(WebsiteSetting).where(eq(WebsiteSetting.key, "cms_favicon"));
|
||||
const snapshot: ContentMutationSnapshot = { before: { value: oldUrl ?? null }, after: null };
|
||||
const snapshot: ContentMutationSnapshot = {
|
||||
before: { value: oldUrl ?? null },
|
||||
after: null,
|
||||
};
|
||||
try {
|
||||
await removeStoredAsset(
|
||||
oldUrl,
|
||||
resolveMediaPath("favicon"),
|
||||
"/api/media/favicon/",
|
||||
);
|
||||
siteSettings.reload();
|
||||
} catch {
|
||||
throw new ContentCommittedExternalFailure(snapshot);
|
||||
@@ -357,19 +619,46 @@ async function faviconDelete(): Promise<ContentMutationSnapshot> {
|
||||
}
|
||||
|
||||
async function logoSave(input: unknown): Promise<ContentMutationSnapshot> {
|
||||
const file = fileValue(record(input).file);
|
||||
const extension = file.type === "image/png" ? "png" : file.type === "image/gif" ? "gif" : file.type === "image/jpeg" ? "jpg" : file.type === "image/webp" ? "webp" : "png";
|
||||
const { bytes, extension } = await validatedImage(
|
||||
record(input).file,
|
||||
5 * 1024 * 1024,
|
||||
MEDIA_TYPES,
|
||||
);
|
||||
const directory = resolveMediaPath("logo");
|
||||
const filename = "logo-" + Date.now() + "-" + Math.random().toString(36).slice(2, 8) + "." + extension;
|
||||
const filename =
|
||||
"logo-" +
|
||||
Date.now() +
|
||||
"-" +
|
||||
Math.random().toString(36).slice(2, 8) +
|
||||
"." +
|
||||
extension;
|
||||
const filePath = path.resolve(directory, filename);
|
||||
if (!filePath.startsWith(directory + path.sep)) throw validation();
|
||||
const oldUrl = await siteSettings.get("cms_logo");
|
||||
await mkdir(directory, { recursive: true });
|
||||
await writeFile(filePath, Buffer.from(await file.arrayBuffer()));
|
||||
await writeFile(filePath, bytes);
|
||||
const url = "/api/media/logo/" + filename;
|
||||
await writeWebsiteSetting("cms_logo", url, "Logo (generator)");
|
||||
const snapshot: ContentMutationSnapshot = { before: { value: oldUrl ?? null }, after: { value: url }, output: { url } };
|
||||
const snapshot: ContentMutationSnapshot = {
|
||||
before: { value: oldUrl ?? null },
|
||||
after: { value: url },
|
||||
output: { url },
|
||||
};
|
||||
try {
|
||||
await writeWebsiteSetting("cms_logo", url, "Logo (generator)");
|
||||
} catch (error) {
|
||||
try {
|
||||
await unlink(filePath);
|
||||
} catch {
|
||||
throw new ContentCommittedExternalFailure({
|
||||
before: { value: oldUrl ?? null },
|
||||
after: { value: oldUrl ?? null },
|
||||
output: { compensation: "failed" },
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
await removeStoredAsset(oldUrl, directory, "/api/media/logo/");
|
||||
siteSettings.reload();
|
||||
} catch {
|
||||
throw new ContentCommittedExternalFailure(snapshot);
|
||||
@@ -377,66 +666,123 @@ async function logoSave(input: unknown): Promise<ContentMutationSnapshot> {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
async function cmsTranslationSave(input: unknown): Promise<ContentMutationSnapshot> {
|
||||
async function cmsTranslationSave(
|
||||
input: unknown,
|
||||
): Promise<ContentMutationSnapshot> {
|
||||
const data = record(input);
|
||||
const locale = text(data.locale, 16, true);
|
||||
if (!CMS_LOCALES.has(locale)) throw validation();
|
||||
const translations = jsonRecord(data.data);
|
||||
const filePath = path.join(process.cwd(), "src", "messages", locale + ".json");
|
||||
const filePath = path.join(
|
||||
process.cwd(),
|
||||
"src",
|
||||
"messages",
|
||||
locale + ".json",
|
||||
);
|
||||
await writeFile(filePath, JSON.stringify(translations, null, 2), "utf-8");
|
||||
return { before: null, after: { locale, keyCount: Object.keys(translations).length } };
|
||||
return {
|
||||
before: null,
|
||||
after: { locale, keyCount: Object.keys(translations).length },
|
||||
};
|
||||
}
|
||||
|
||||
async function clientTranslationSave(input: unknown): Promise<ContentMutationSnapshot> {
|
||||
async function clientTranslationSave(
|
||||
input: unknown,
|
||||
): Promise<ContentMutationSnapshot> {
|
||||
const data = record(input);
|
||||
const fileId = text(data.fileId, 100, true);
|
||||
const translations = Object.fromEntries(Object.entries(jsonRecord(data.data)).map(([key, value]) => [key, String(value)]));
|
||||
const translations = Object.fromEntries(
|
||||
Object.entries(jsonRecord(data.data)).map(([key, value]) => [
|
||||
key,
|
||||
String(value),
|
||||
]),
|
||||
);
|
||||
const file = getClientTranslationFile(fileId);
|
||||
if (!file || file.readOnly) throw validation();
|
||||
const absolutePath = path.join(process.cwd(), file.relPath);
|
||||
const raw = await readFile(absolutePath, "utf-8");
|
||||
if (file.format === "json") {
|
||||
await writeFile(absolutePath, JSON.stringify(translations, null, 4), "utf-8");
|
||||
return { before: null, after: { fileId, keyCount: Object.keys(translations).length }, output: { commentsLost: false, unpatchedKeys: [] } };
|
||||
await writeFile(
|
||||
absolutePath,
|
||||
JSON.stringify(translations, null, 4),
|
||||
"utf-8",
|
||||
);
|
||||
return {
|
||||
before: null,
|
||||
after: { fileId, keyCount: Object.keys(translations).length },
|
||||
output: { commentsLost: false, unpatchedKeys: [] },
|
||||
};
|
||||
}
|
||||
const original: Record<string, string> = {};
|
||||
const parsed = JSONC.parse(raw);
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
for (const [key, value] of Object.entries(parsed)) original[key] = value == null ? "" : String(value);
|
||||
for (const [key, value] of Object.entries(parsed))
|
||||
original[key] = value == null ? "" : String(value);
|
||||
}
|
||||
const patched = patchJson5(raw, original, translations);
|
||||
if (patched.unpatchedKeys.length === 0) {
|
||||
await writeFile(absolutePath, patched.content, "utf-8");
|
||||
} else {
|
||||
await writeFile(absolutePath, JSON.stringify(translations, null, 4), "utf-8");
|
||||
await writeFile(
|
||||
absolutePath,
|
||||
JSON.stringify(translations, null, 4),
|
||||
"utf-8",
|
||||
);
|
||||
}
|
||||
return {
|
||||
before: null,
|
||||
after: { fileId, keyCount: Object.keys(translations).length },
|
||||
output: { commentsLost: patched.unpatchedKeys.length > 0, unpatchedKeys: patched.unpatchedKeys },
|
||||
output: {
|
||||
commentsLost: patched.unpatchedKeys.length > 0,
|
||||
unpatchedKeys: patched.unpatchedKeys,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function emulatorTranslationSave(input: unknown, context: ContentMutationContext): Promise<ContentMutationSnapshot> {
|
||||
async function emulatorTranslationSave(
|
||||
input: unknown,
|
||||
context: ContentMutationContext,
|
||||
): Promise<ContentMutationSnapshot> {
|
||||
const data = record(input);
|
||||
const source = data.settings ? jsonRecord(data.settings) : data.key ? { [text(data.key, 255, true)]: text(data.value, 20_000) } : jsonRecord(data);
|
||||
const entries = Object.entries(source).map(([key, value]) => [text(key, 255, true), String(value)] as const);
|
||||
const source = data.settings
|
||||
? jsonRecord(data.settings)
|
||||
: data.key
|
||||
? { [text(data.key, 100, true)]: text(data.value, 512) }
|
||||
: jsonRecord(data);
|
||||
const entries = Object.entries(source).map(
|
||||
([key, value]) => [text(key, 100, true), text(value, 512)] as const,
|
||||
);
|
||||
await db.transaction(async (transaction) => {
|
||||
for (const [key, value] of entries) {
|
||||
await transaction.insert(EmulatorSettings).values({ key, value }).onDuplicateKeyUpdate({ set: { value } });
|
||||
await transaction
|
||||
.insert(EmulatorSettings)
|
||||
.values({ key, value })
|
||||
.onDuplicateKeyUpdate({ set: { value } });
|
||||
}
|
||||
});
|
||||
const snapshot: ContentMutationSnapshot = { before: null, after: { keys: entries.map(([key]) => key).sort() } };
|
||||
const snapshot: ContentMutationSnapshot = {
|
||||
before: null,
|
||||
after: { keys: entries.map(([key]) => key).sort() },
|
||||
};
|
||||
try {
|
||||
const delivered = await rcon.updateConfig();
|
||||
if (!context.legacy && !delivered) throw new Error("emulator configuration sync failed");
|
||||
if (!context.legacy && !delivered)
|
||||
throw new Error("emulator configuration sync failed");
|
||||
} catch {
|
||||
throw new ContentCommittedExternalFailure(snapshot);
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
const EXTERNAL_HANDLERS: Partial<Record<ContentMutationOperation, (input: unknown, context: ContentMutationContext) => Promise<ContentMutationSnapshot>>> = {
|
||||
const EXTERNAL_HANDLERS: Partial<
|
||||
Record<
|
||||
ContentMutationOperation,
|
||||
(
|
||||
input: unknown,
|
||||
context: ContentMutationContext,
|
||||
) => Promise<ContentMutationSnapshot>
|
||||
>
|
||||
> = {
|
||||
"media.upload": (input) => mediaUpload(input),
|
||||
"media.delete": (input) => mediaDelete(input),
|
||||
"photo.delete": photoDelete,
|
||||
@@ -453,6 +799,15 @@ const EXTERNAL_HANDLERS: Partial<Record<ContentMutationOperation, (input: unknow
|
||||
"translation.emulator.save": emulatorTranslationSave,
|
||||
};
|
||||
|
||||
export async function executeLegacyBrandAssetMutation(
|
||||
operation: "favicon.save" | "favicon.delete" | "logo.save",
|
||||
input: unknown,
|
||||
): Promise<ContentMutationSnapshot> {
|
||||
if (operation === "favicon.save") return faviconSave(input);
|
||||
if (operation === "favicon.delete") return faviconDelete();
|
||||
return logoSave(input);
|
||||
}
|
||||
|
||||
export async function executeContentExternalMutation(
|
||||
operation: ContentMutationOperation,
|
||||
input: unknown,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import "server-only";
|
||||
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import type { HousekeepingCapabilityContext } from "../../foundation/contracts";
|
||||
import { contentQuery } from "./queries/content-queries";
|
||||
|
||||
@@ -9,6 +10,7 @@ async function total(
|
||||
context: HousekeepingCapabilityContext,
|
||||
routeId:
|
||||
| "content.editorial.articles"
|
||||
| "content.media.banners"
|
||||
| "content.media.photos"
|
||||
| "content.media.library"
|
||||
| "content.localization.overview",
|
||||
@@ -17,7 +19,8 @@ async function total(
|
||||
routeId,
|
||||
list: { pageSize: 1, offset: 0 },
|
||||
});
|
||||
return result.ok ? result.data.total : 0;
|
||||
if (!result.ok) throw new Error("Content widget query unavailable");
|
||||
return result.data.total;
|
||||
}
|
||||
|
||||
export async function loadContentWidget(
|
||||
@@ -31,11 +34,20 @@ export async function loadContentWidget(
|
||||
return { articles };
|
||||
}
|
||||
if (kind === "media") {
|
||||
const [photos, library] = await Promise.all([
|
||||
total(context, "content.media.photos"),
|
||||
total(context, "content.media.library"),
|
||||
]);
|
||||
return { photos, library, items: photos + library };
|
||||
const counts: Record<string, number> = {};
|
||||
if (context.has(PERMS.PAGES_VIEW)) {
|
||||
const [photos, library] = await Promise.all([
|
||||
total(context, "content.media.photos"),
|
||||
total(context, "content.media.library"),
|
||||
]);
|
||||
counts.photos = photos;
|
||||
counts.library = library;
|
||||
counts.items = photos + library;
|
||||
}
|
||||
if (context.has(PERMS.BANNERS_VIEW)) {
|
||||
counts.banners = await total(context, "content.media.banners");
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
const stores = await total(context, "content.localization.overview");
|
||||
return { stores };
|
||||
|
||||
@@ -125,7 +125,7 @@ describe("admin operations route contract", () => {
|
||||
expect(source).not.toMatch(/await requireStaffRateLimited\(\)/);
|
||||
});
|
||||
|
||||
it("has no requireStaff left in admin action modules", () => {
|
||||
it("keeps requireStaff only for the approved legacy logo security floor", () => {
|
||||
const dir = "src/actions";
|
||||
const offenders: string[] = [];
|
||||
for (const name of readdirSync(dir)) {
|
||||
@@ -135,7 +135,7 @@ describe("admin operations route contract", () => {
|
||||
offenders.push(name);
|
||||
}
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
expect(offenders).toEqual(["save-logo.ts"]);
|
||||
});
|
||||
|
||||
it("caches analytics full-scans via redisCache", () => {
|
||||
|
||||
Reference in new issue
Block a user