fix(housekeeping): address task 14 review round 1

This commit is contained in:
Simo committed 2026-08-30 10:53:50 +02:00
1 parent fd68819d9b
commit d09eaa33d6
23 files changed
+2503 -488

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
"use server";
export { createBanner, deleteBanner, updateBanner } from "./banners";
+47 -5
View File
@@ -1,25 +1,44 @@
// @ts-nocheck // @ts-nocheck
import { readFileSync } from "node:fs";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { tryRemoveLocalPhotoFile } from "@/lib/admin/photo-files";
import { deletePhoto } from "./admin-photos"; import { deletePhoto } from "./admin-photos";
const { execute } = vi.hoisted(() => ({ execute: vi.fn() })); 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/admin/guard", () => ({ requirePermission: vi.fn() }));
vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } })); vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } }));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
vi.mocked(requirePermission).mockResolvedValue({ id: 1, rank: 7, username: "admin" }); vi.mocked(requirePermission).mockResolvedValue({
execute.mockResolvedValue({ ok: true, data: { before: { id: 42 }, after: null }, correlationId: "legacy" }); id: 1,
rank: 7,
username: "admin",
});
execute.mockResolvedValue({
ok: true,
data: { before: { id: 42 }, after: null },
correlationId: "legacy",
});
}); });
describe("deletePhoto", () => { describe("deletePhoto", () => {
it("delegates deletion and preserves both revalidations", async () => { it("delegates deletion and preserves both revalidations", async () => {
await deletePhoto({ get: (key) => key === "id" ? "42" : null }); await deletePhoto({ get: (key) => (key === "id" ? "42" : null) });
expect(execute).toHaveBeenCalledWith(expect.anything(), "photo.delete", { id: 42 }); expect(execute).toHaveBeenCalledWith(expect.anything(), "photo.delete", {
id: 42,
});
expect(revalidatePath).toHaveBeenCalledWith("/admin/photos"); expect(revalidatePath).toHaveBeenCalledWith("/admin/photos");
expect(revalidatePath).toHaveBeenCalledWith("/photos"); expect(revalidatePath).toHaveBeenCalledWith("/photos");
}); });
@@ -28,3 +47,26 @@ describe("deletePhoto", () => {
expect(execute).not.toHaveBeenCalled(); 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);
});
});
+69 -10
View File
@@ -2,11 +2,12 @@
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { beforeEach, describe, expect, it, vi } from "vitest"; 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 { createAd } from "./admin-ads";
import { createArticle } from "./admin-articles"; import { createArticle } from "./admin-articles";
import { uploadMedia } from "./admin-media"; 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(() => ({ const { execute } = vi.hoisted(() => ({
execute: vi.fn(async () => ({ execute: vi.fn(async () => ({
@@ -15,6 +16,13 @@ const { execute } = vi.hoisted(() => ({
correlationId: "legacy", 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", () => ({ vi.mock("@/features/housekeeping/domains/content/services/mutations", () => ({
contentMutationService: { execute }, contentMutationService: { execute },
@@ -24,7 +32,16 @@ vi.mock("@/features/housekeeping/domains/content/services/mutations", () => ({
legacy: true, 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", () => ({ vi.mock("@/lib/safe-action", () => ({
adminAction: (_options: unknown, handler: unknown) => handler, adminAction: (_options: unknown, handler: unknown) => handler,
})); }));
@@ -40,6 +57,7 @@ vi.mock("@/lib/permissions", () => ({
NEWS_EDIT: "news.edit", NEWS_EDIT: "news.edit",
PAGES_EDIT: "pages.edit", PAGES_EDIT: "pages.edit",
SETTINGS_EDIT: "settings.edit", SETTINGS_EDIT: "settings.edit",
SETTINGS_VIEW: "settings.view",
}, },
})); }));
vi.mock("@/lib/db", () => ({ vi.mock("@/lib/db", () => ({
@@ -84,6 +102,7 @@ const form = (data: Record<string, FormDataEntryValue>) => ({
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
vi.mocked(requirePermission).mockResolvedValue(staff as never); vi.mocked(requirePermission).mockResolvedValue(staff as never);
vi.mocked(requireStaff).mockResolvedValue(staff as never);
execute.mockResolvedValue({ execute.mockResolvedValue({
ok: true, ok: true,
data: { before: null, after: { id: "1" }, output: { url: "/api/media/x" } }, 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 () => { it("delegates ad creation and keeps the legacy void/redirect contract", async () => {
expect( expect(
await createAd(form({ image: "https://example.test/ad.png" }) as FormData), await createAd(
form({ image: "https://example.test/ad.png" }) as FormData,
),
).toBeUndefined(); ).toBeUndefined();
expect(execute).toHaveBeenCalledWith( expect(execute).toHaveBeenCalledWith(
expect.objectContaining({ expectedActorId: 42, legacy: true }), expect.objectContaining({ expectedActorId: 42, legacy: true }),
@@ -132,17 +153,51 @@ describe("Content legacy wrappers", () => {
"media.upload", "media.upload",
expect.objectContaining({ file }), expect.objectContaining({ file }),
); );
expect(execute).toHaveBeenCalledWith( expect(executeLegacyBrandAssetMutation).toHaveBeenCalledWith(
expect.anything(),
"favicon.save", "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", () => { it("keeps every listed legacy action as a thin shared-service wrapper", () => {
for (const path of [ for (const path of [
"src/actions/admin-ads.ts", "src/actions/admin-ads.ts",
"src/actions/admin-articles.ts", "src/actions/admin-articles.ts",
"src/actions/admin-banners.ts",
"src/actions/admin-email-templates.ts", "src/actions/admin-email-templates.ts",
"src/actions/admin-help.ts", "src/actions/admin-help.ts",
"src/actions/admin-media.ts", "src/actions/admin-media.ts",
@@ -160,9 +215,13 @@ describe("Content legacy wrappers", () => {
"src/actions/translations.ts", "src/actions/translations.ts",
"src/actions/emulator.ts", "src/actions/emulator.ts",
]) { ]) {
expect(readFileSync(path, "utf8"), path).toContain( const source = readFileSync(path, "utf8");
"contentMutationService", expect(
); source.includes("contentMutationService") ||
source.includes("executeLegacyBrandAssetMutation") ||
source.includes('from "./banners"'),
path,
).toBe(true);
} }
}); });
}); });
+55 -30
View File
@@ -1,43 +1,68 @@
"use server"; "use server";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { import { executeLegacyBrandAssetMutation } from "@/features/housekeeping/domains/content/services/mutation-runtime-external";
contentMutationService,
createContentMutationInvocation,
} from "@/features/housekeeping/domains/content/services/mutations";
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
const MAX_SIZE = 2 * 1024 * 1024; 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 }> { export async function saveFavicon(
const staff = await requirePermission(PERMS.SETTINGS_EDIT); formData: FormData,
const file = formData.get("file") as File | null; ): Promise<{ success: boolean; url?: string; error?: string }> {
if (!file || file.size === 0) return { success: false, error: "No file provided" }; await requirePermission(PERMS.SETTINGS_VIEW);
if (file.size > MAX_SIZE) return { success: false, error: "File too large (max 2MB)" }; try {
if (!ALLOWED.includes(file.type)) return { success: false, error: "Invalid file type. Allowed: PNG, JPEG, GIF, WebP, ICO, SVG" }; const file = formData.get("file") as File | null;
const result = await contentMutationService.execute( if (!file || file.size === 0)
createContentMutationInvocation(staff, createCorrelationId()), return { success: false, error: "No file provided" };
"favicon.save", if (file.size > MAX_SIZE)
{ file }, return { success: false, error: "File too large (max 2MB)" };
); if (!ALLOWED.includes(file.type))
if (!result.ok) return { success: false, error: result.error.messageKey }; return {
siteRevalidate(); success: false,
return { success: true, ...(typeof result.data.output?.url === "string" ? { url: result.data.output.url } : {}) }; 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 }> { export async function deleteFavicon(): Promise<{
const staff = await requirePermission(PERMS.SETTINGS_EDIT); success: boolean;
const result = await contentMutationService.execute( error?: string;
createContentMutationInvocation(staff, createCorrelationId()), }> {
"favicon.delete", await requirePermission(PERMS.SETTINGS_VIEW);
{}, try {
); await executeLegacyBrandAssetMutation("favicon.delete", {});
if (!result.ok) return { success: false, error: result.error.messageKey }; siteRevalidate();
siteRevalidate(); return { success: true };
return { success: true }; } catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
};
}
} }
function siteRevalidate(): void { function siteRevalidate(): void {
+23 -19
View File
@@ -1,24 +1,28 @@
"use server"; "use server";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { import { executeLegacyBrandAssetMutation } from "@/features/housekeeping/domains/content/services/mutation-runtime-external";
contentMutationService, import { requireStaff } from "@/lib/admin/guard";
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";
export async function saveLogo(formData: FormData): Promise<{ success: boolean; url?: string; error?: string }> { export async function saveLogo(
const staff = await requirePermission(PERMS.SETTINGS_EDIT); formData: FormData,
const file = formData.get("file") as File | null; ): Promise<{ success: boolean; url?: string; error?: string }> {
if (!file) return { success: false, error: "No file provided" }; await requireStaff();
const result = await contentMutationService.execute( try {
createContentMutationInvocation(staff, createCorrelationId()), const file = formData.get("file") as File | null;
"logo.save", if (!file) return { success: false, error: "No file provided" };
{ file }, const result = await executeLegacyBrandAssetMutation("logo.save", { file });
); revalidatePath("/", "layout");
if (!result.ok) return { success: false, error: result.error.messageKey }; return {
revalidatePath("/", "layout"); success: true,
return { success: true, ...(typeof result.data.output?.url === "string" ? { url: result.data.output.url } : {}) }; ...(typeof result.output?.url === "string"
? { url: result.output.url }
: {}),
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
};
}
} }
+18 -20
View File
@@ -1,30 +1,28 @@
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { tryRemoveLocalPhotoFile } from "@/lib/admin/photo-files";
describe("admin-photos Content service contract", () => { describe("setTradeLock database and live-sync contract", () => {
const wrapper = readFileSync("src/actions/admin-photos.ts", "utf8"); const wrapper = readFileSync("src/actions/bulk-users.ts", "utf8");
const runtime = readFileSync( const service = readFileSync(
"src/features/housekeeping/domains/content/services/mutation-runtime-external.ts", "src/features/housekeeping/domains/people/services/mutations.ts",
"utf8", "utf8",
); );
const rcon = readFileSync("src/lib/services/rcon.ts", "utf8");
it("delegates while the runtime deletes CameraWeb and purges local files", () => { it("retains the legacy action while the owning service writes both trade-lock stores", () => {
expect(wrapper).toContain("contentMutationService.execute"); expect(wrapper).toMatch(/export async function setTradeLock/u);
expect(wrapper).toContain('"photo.delete"'); expect(wrapper).toContain('"user.trade-lock"');
expect(wrapper).toContain('revalidatePath("/photos")'); expect(service).toContain("UsersSettings");
expect(runtime).toContain("@/lib/db"); expect(service).toContain("Sanctions");
expect(runtime).toContain("CameraWeb"); expect(service).toContain("canTrade");
expect(runtime).toContain("tryRemoveLocalPhotoFile"); expect(service).toContain("tradeLockedUntil");
}); });
});
describe("tryRemoveLocalPhotoFile", () => { it("keeps live RCON lock, alert, and disconnect behavior", () => {
it("rejects path traversal and remote CDN urls", async () => { expect(rcon).toContain("settradelock");
expect(await tryRemoveLocalPhotoFile("https://cdn.example/photo.png")).toBe( expect(rcon).toContain("setTradeLock(userId: number, locked: boolean)");
false, expect(service).toContain("rcon.setTradeLock");
); expect(service).toContain("rcon.alertUser");
expect(await tryRemoveLocalPhotoFile("/../../etc/passwd")).toBe(false); expect(service).toContain("rcon.disconnectUser");
expect(await tryRemoveLocalPhotoFile("")).toBe(false);
}); });
}); });
+7
View File
@@ -47,6 +47,13 @@ export async function GET(
headers: { headers: {
// eslint-disable-next-line security/detect-object-injection -- ext validated against ALLOWED_EXT // eslint-disable-next-line security/detect-object-injection -- ext validated against ALLOWED_EXT
"Content-Type": mime[ext] ?? "application/octet-stream", "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", "Cache-Control": "public, max-age=3600, must-revalidate",
}, },
}); });
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingCapabilityContext } from "../../foundation/contracts"; import type { HousekeepingCapabilityContext } from "../../foundation/contracts";
import { loadContentInboxItems } from "./inbox-production"; import { loadContentInboxItems } from "./inbox-production";
import { loadContentSearchCandidates } from "./search-production"; import { loadContentSearchCandidates } from "./search-production";
@@ -18,6 +19,16 @@ const context = {
hasAll: () => true, hasAll: () => true,
} satisfies HousekeepingCapabilityContext; } 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( function result(
routeId: string, routeId: string,
total: number, total: number,
@@ -45,9 +56,9 @@ describe("Content production providers", () => {
it("returns only truthful persisted counts from editorial and localization widgets", async () => { it("returns only truthful persisted counts from editorial and localization widgets", async () => {
const signal = new AbortController().signal; const signal = new AbortController().signal;
await expect(loadContentWidget("editorial", context, signal)).resolves.toEqual( await expect(
{ articles: 7 }, loadContentWidget("editorial", context, signal),
); ).resolves.toEqual({ articles: 7 });
await expect( await expect(
loadContentWidget("localization", context, signal), loadContentWidget("localization", context, signal),
).resolves.toEqual({ stores: 3 }); ).resolves.toEqual({ stores: 3 });
@@ -79,7 +90,9 @@ describe("Content production providers", () => {
]); ]);
expect(run).toHaveBeenCalledWith( expect(run).toHaveBeenCalledWith(
context, 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, context,
new AbortController().signal, new AbortController().signal,
); );
expect(items).toHaveLength(1); expect(items).toEqual([]);
expect(items[0]).toMatchObject({ });
itemId: "5",
sourceId: "content.publication", it("loads only capability-matched media counts", async () => {
href: "/ase/content/editorial/articles/5", 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, anyCapability,
type HousekeepingCapabilityContext, type HousekeepingCapabilityContext,
} from "../../foundation/contracts"; } from "../../foundation/contracts";
import { import { CONTENT_INBOX_SOURCE_IDS, createContentInboxSources } from "./inbox";
CONTENT_INBOX_SOURCE_IDS,
createContentInboxSources,
} from "./inbox";
import { import {
CONTENT_SEARCH_PROVIDER_IDS, CONTENT_SEARCH_PROVIDER_IDS,
createContentSearchProviders, createContentSearchProviders,
@@ -74,27 +71,30 @@ describe("Content search providers", () => {
"/ase/content/%255c..%255csystem", "/ase/content/%255c..%255csystem",
"https://example.test/ase/content/editorial", "https://example.test/ase/content/editorial",
"//example.test/ase/content/editorial", "//example.test/ase/content/editorial",
])("rejects normalized and double-encoded traversal href %s", async (href) => { ])(
const adapters = { "rejects normalized and double-encoded traversal href %s",
articles: async () => [ async (href) => {
{ const adapters = {
id: "unsafe", articles: async () => [
title: "Unsafe", {
href, id: "unsafe",
capability: anyCapability(PERMS.NEWS_VIEW), title: "Unsafe",
}, href,
], capability: anyCapability(PERMS.NEWS_VIEW),
events: async () => [], },
media: async () => [], ],
help: async () => [], events: async () => [],
}; media: async () => [],
const [provider] = createContentSearchProviders(adapters); help: async () => [],
const result = await provider.search(context([PERMS.NEWS_VIEW]), { };
term: "", const [provider] = createContentSearchProviders(adapters);
limit: 25, const result = await provider.search(context([PERMS.NEWS_VIEW]), {
}); term: "",
expect(result).toMatchObject({ ok: true, data: [] }); limit: 25,
}); });
expect(result).toMatchObject({ ok: true, data: [] });
},
);
}); });
describe("Content inbox and widgets", () => { 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 () => { it("keeps editorial mandatory and media/localization optional without preview DB imports", async () => {
const adapters = { const adapters = {
editorial: vi.fn(async () => ({ drafts: 2, scheduled: 1 })), editorial: vi.fn(async () => ({ drafts: 2, scheduled: 1 })),
@@ -10,6 +10,11 @@ import { contentQuery } from "./queries/content-queries";
type ContentInboxKind = "publication" | "attention"; 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) { function time(value: string | null | undefined) {
if (!value) return null; if (!value) return null;
const timestamp = Date.parse(value); const timestamp = Date.parse(value);
@@ -31,7 +36,11 @@ export async function loadContentInboxItems(
const definitions = const definitions =
kind === "publication" kind === "publication"
? ([ ? ([
["content.editorial.articles", PERMS.NEWS_VIEW, "content.publication"], [
"content.editorial.articles",
PERMS.NEWS_VIEW,
"content.publication",
],
] as const) ] as const)
: ([ : ([
["content.engagement.events", PERMS.EVENTS_VIEW, "content.attention"], ["content.engagement.events", PERMS.EVENTS_VIEW, "content.attention"],
@@ -39,12 +48,15 @@ export async function loadContentInboxItems(
] as const); ] as const);
const items: HousekeepingWorkItem[] = []; const items: HousekeepingWorkItem[] = [];
for (const [routeId, permission, sourceId] of definitions) { for (const [routeId, permission, sourceId] of definitions) {
if (!context.has(permission)) continue;
const result = await contentQuery.run(context, { const result = await contentQuery.run(context, {
routeId, routeId,
list: { pageSize: 25, offset: 0 }, list: { pageSize: 25, offset: 0 },
}); });
if (!result.ok) continue; if (!result.ok) continue;
for (const item of result.data.items) { for (const item of result.data.items) {
const status = item.status?.toLocaleLowerCase() ?? "";
if (!ACTIONABLE_STATUS[kind].has(status)) continue;
const date = time(item.updatedAt); const date = time(item.updatedAt);
if (!date || !item.href) continue; if (!date || !item.href) continue;
items.push({ items.push({
@@ -53,10 +65,11 @@ export async function loadContentInboxItems(
deduplicationKey: sourceId + ":" + routeId + ":" + item.id, deduplicationKey: sourceId + ":" + routeId + ":" + item.id,
domain: "content", domain: "content",
capability: anyCapability(permission), capability: anyCapability(permission),
severity: item.status === "failed" ? "warning" : "info", severity:
priority: item.status === "failed" ? "high" : "normal", status === "failed" || status === "cancelled" ? "warning" : "info",
priority: status === "failed" ? "high" : "normal",
...date, ...date,
state: item.status ?? "ready", state: status,
titleKey: "pages.housekeeping.items.content", titleKey: "pages.housekeeping.items.content",
context: { title: item.title }, context: { title: item.title },
href: item.href as `/ase/${string}`, href: item.href as `/ase/${string}`,
@@ -76,12 +76,7 @@ export function createContentInboxSources(
), ),
createSource( createSource(
"content.attention", "content.attention",
anyCapability( anyCapability(PERMS.EVENTS_VIEW, PERMS.POLLS_VIEW),
PERMS.EVENTS_VIEW,
PERMS.POLLS_VIEW,
PERMS.PAGES_VIEW,
PERMS.BANNERS_VIEW,
),
adapters.attention, adapters.attention,
), ),
]; ];
@@ -37,12 +37,19 @@ interface ContentCommandFormProps extends ContentCommandSubmission {
readonly buttonLabel: string; readonly buttonLabel: string;
} }
const OMIT_FIELD = Symbol("omit optional Content command field");
function parseField(field: ContentCommandField, formData: FormData): unknown { function parseField(field: ContentCommandField, formData: FormData): unknown {
const rawValue = formData.get(field.name); const rawValue = formData.get(field.name);
if (rawValue === null && !field.required) return OMIT_FIELD;
if (field.type === "checkbox") return rawValue === "on"; if (field.type === "checkbox") return rawValue === "on";
if (field.type === "file") return rawValue instanceof File ? rawValue : null; 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 (field.type === "number") {
if (!raw) return raw;
const value = Number(raw); const value = Number(raw);
if (!Number.isSafeInteger(value)) return 0; if (!Number.isSafeInteger(value)) return 0;
return Math.min(field.max ?? value, Math.max(field.min ?? value, value)); 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 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; const initialState: HousekeepingResult<unknown> | null = null;
@@ -70,14 +80,13 @@ export async function submitContentCommandForm(
_previous: HousekeepingResult<unknown> | null, _previous: HousekeepingResult<unknown> | null,
formData: FormData, formData: FormData,
): Promise<HousekeepingResult<unknown>> { ): 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 = { const input = {
...configuration.input, ...configuration.input,
...Object.fromEntries( ...Object.fromEntries(submittedFields),
(configuration.fields ?? []).map((field) => [
field.name,
parseField(field, formData),
]),
),
}; };
const reason = String(formData.get("reason") ?? "") const reason = String(formData.get("reason") ?? "")
.normalize("NFC") .normalize("NFC")
@@ -127,7 +136,8 @@ export function ContentCommandForm({
id={`${commandId}-${field.name}`} id={`${commandId}-${field.name}`}
name={field.name} name={field.name}
type="checkbox" type="checkbox"
/> {field.label} />{" "}
{field.label}
</> </>
) : field.type === "select" ? ( ) : field.type === "select" ? (
<> <>
@@ -139,6 +149,7 @@ export function ContentCommandForm({
required={field.required} required={field.required}
className="mt-1 block w-full" className="mt-1 block w-full"
> >
{field.required ? null : <option value="">No change</option>}
{field.options?.map((option) => ( {field.options?.map((option) => (
<option key={option.value} value={option.value}> <option key={option.value} value={option.value}>
{option.label} {option.label}
@@ -53,10 +53,7 @@ describe.each(cases)("Content %s page", (kind, Component, editPermission) => {
).toContain('data-housekeeping-state="forbidden"'); ).toContain('data-housekeeping-state="forbidden"');
expect( expect(
render( render(
ok( ok({ kind, items: [], total: 0, partialDependencies: [] }, "empty"),
{ kind, items: [], total: 0, partialDependencies: [] },
"empty",
),
), ),
).toContain('data-housekeeping-state="empty"'); ).toContain('data-housekeeping-state="empty"');
const ready = render( 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", () => { it("renders mutation forms only with the exact capability", () => {
const result = ok( const result = ok(
{ {
@@ -210,4 +237,93 @@ describe("Content actionable form wiring", () => {
expect(html).toContain("Welcome"); expect(html).toContain("Welcome");
expect(html).not.toContain("secret template body"); 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 { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingPageInput } from "../../../route-handlers"; import type { HousekeepingPageInput } from "../../../route-handlers";
import { contentQuery } from "../queries/content-queries"; import { contentQuery } from "../queries/content-queries";
import { ContentCommandForm } from "./content-command-form"; import {
import { ContentPageFrame, type ContentPageProps, parseContentListInput } from "./content-page-frame"; 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 canEvents = context.has(PERMS.EVENTS_EDIT);
const canPolls = context.has(PERMS.POLLS_EDIT); const canPolls = context.has(PERMS.POLLS_EDIT);
const canPrefixes = context.has(PERMS.PREFIXES_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"> const creatingEvent = routeId === "content.engagement.event-create";
{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} const creatingPoll = routeId === "content.engagement.poll-create";
{canEvents && routeId?.includes("event") && routeId !== "content.engagement.event-types" ? <> const eventFields: readonly ContentCommandField[] = [
<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 }]} /> name: "id",
<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 }]} /> label: "Event ID",
</> : null} type: "identifier",
{canPolls && routeId?.includes("poll") ? <> required: !creatingEvent,
<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} name: "title",
{canPrefixes && routeId === "content.engagement.prefixes" ? <> label: "Title",
<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 }]} /> type: "text",
<ContentCommandForm commandId="content.engagement.prefix-blacklist.change" buttonLabel="Update blacklist" input={{ action: "add" }} fields={[{ name: "word", label: "Word", type: "text", required: true, maxLength: 255 }]} /> required: creatingEvent,
<ContentCommandForm commandId="content.engagement.prefix-settings.update" buttonLabel="Save prefix settings" input={{}} fields={[{ name: "enabled", label: "Enable prefixes", type: "checkbox" }]} /> maxLength: 255,
</> : null} },
</div>} />; {
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 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 ?? {}) }); const result = await contentQuery.run(input.context, {
return <ContentEngagementPage context={input.context} result={result} routeId={routeId} />; 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 type { HousekeepingPageInput } from "../../../route-handlers";
import { contentQuery } from "../queries/content-queries"; import { contentQuery } from "../queries/content-queries";
import { ContentCommandForm } from "./content-command-form"; 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) { export function ContentLocalizationPage({
const forms = context.has(PERMS.SETTINGS_EDIT) ? <div className="grid gap-3 lg:grid-cols-2"> context,
{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} result,
{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,
{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} }: ContentPageProps) {
</div> : null; const forms = context.has(PERMS.SETTINGS_EDIT) ? (
return <ContentPageFrame title="Localization" description="Manage CMS, client, and emulator translation stores." result={result} forms={forms} />; <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 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 ?? {}) }); const result = await contentQuery.run(input.context, {
return <ContentLocalizationPage context={input.context} result={result} routeId={routeId} />; 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 { 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 { 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(); const needle = input.list.search.toLocaleLowerCase();
return ( return (
needle.length === 0 || needle.length === 0 ||
@@ -171,18 +178,20 @@ function mapRows(
definition: QueryDefinition, definition: QueryDefinition,
rawRows: readonly RawRow[], rawRows: readonly RawRow[],
): readonly ContentQueryItem[] { ): readonly ContentQueryItem[] {
let items = rawRows.map((row) => { let items = rawRows
const id = value(row.id); .map((row) => {
if (!id) throw new Error("invalid Content identifier"); const id = value(row.id);
return { if (!id) throw new Error("invalid Content identifier");
id, return {
title: value(row.title, "Untitled content"), id,
description: value(row.description) || undefined, title: value(row.title, "Untitled content"),
status: value(row.status) || undefined, description: value(row.description) || undefined,
updatedAt: updatedAt(row.updated_at), status: value(row.status) || undefined,
href: definition.appendId ? definition.href + id : definition.href, updatedAt: updatedAt(row.updated_at),
}; href: definition.appendId ? definition.href + id : definition.href,
}).filter((item) => matches(input, item)); };
})
.filter((item) => matches(input, item));
if (input.params.id) { if (input.params.id) {
items = items.filter((item) => item.id === input.params.id); items = items.filter((item) => item.id === input.params.id);
} }
@@ -200,7 +209,11 @@ async function databaseRoute(
const items = mapRows( const items = mapRows(
input, input,
definition, 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); return response(input, items, items.length);
} }
@@ -219,54 +232,67 @@ async function mediaLibrary(
/[.](png|jpe?g|gif|webp|svg|bmp)$/iu.test(name), /[.](png|jpe?g|gif|webp|svg|bmp)$/iu.test(name),
); );
} catch (error) { } catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return response(input, []); if ((error as NodeJS.ErrnoException).code === "ENOENT")
return response(input, []);
throw error; throw error;
} }
const entries = await Promise.all( 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); entries.sort((left, right) => right.metadata.mtimeMs - left.metadata.mtimeMs);
const items = entries.map(({ name, metadata }) => ({ const items = entries
id: name, .map(({ name, metadata }) => ({
title: name, id: name,
description: String(metadata.size) + " bytes", title: name,
status: "stored", description: String(metadata.size) + " bytes",
updatedAt: metadata.mtime.toISOString(), status: "stored",
href: "/ase/content/media/library", updatedAt: metadata.mtime.toISOString(),
})).filter((item) => matches(input, item)); href: "/ase/content/media/library",
}))
.filter((item) => matches(input, item));
return response(input, items, items.length); 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([ const [{ siteSettings }, { listCustomThemes }] = await Promise.all([
import("@/lib/services/site-settings"), import("@/lib/services/site-settings"),
import("@/lib/theme-custom-store"), import("@/lib/theme-custom-store"),
]); ]);
if (input.routeId === "content.brand.favicon") { if (input.routeId === "content.brand.favicon") {
const favicon = await siteSettings.get("cms_favicon", null); const favicon = await siteSettings.get("cms_favicon", null);
return response(input, [{ return response(input, [
id: "favicon", {
title: favicon || "Default favicon", id: "favicon",
status: favicon ? "custom" : "default", title: favicon || "Default favicon",
href: "/ase/content/brand/favicon", status: favicon ? "custom" : "default",
}]); href: "/ase/content/brand/favicon",
},
]);
} }
const [preset, customThemes] = await Promise.all([ const [preset, customThemes] = await Promise.all([
siteSettings.get("theme_preset", "Atom (golden)"), siteSettings.get("theme_preset", "Atom (golden)"),
listCustomThemes(), listCustomThemes(),
]); ]);
return response(input, [{ return response(input, [
id: "active-theme", {
title: preset || "Atom (golden)", id: "active-theme",
status: "active", title: preset || "Atom (golden)",
href: "/ase/content/brand/theme", status: "active",
}, ...customThemes.map((theme) => ({ href: "/ase/content/brand/theme",
id: theme.id, },
title: theme.name, ...customThemes.map((theme) => ({
status: "saved", id: theme.id,
updatedAt: new Date(theme.createdAt).toISOString(), title: theme.name,
href: "/ase/content/brand/theme", status: "saved",
}))]); updatedAt: new Date(theme.createdAt).toISOString(),
href: "/ase/content/brand/theme",
})),
]);
} }
async function localizationFiles( async function localizationFiles(
@@ -276,13 +302,16 @@ async function localizationFiles(
const { CLIENT_TRANSLATION_FILES } = await import( const { CLIENT_TRANSLATION_FILES } = await import(
"@/lib/client-translation-files" "@/lib/client-translation-files"
); );
return response(input, CLIENT_TRANSLATION_FILES.map((file) => ({ return response(
id: file.id, input,
title: file.id, CLIENT_TRANSLATION_FILES.map((file) => ({
description: file.relPath, id: file.id,
status: file.readOnly ? "read-only" : "editable", title: file.id,
href: "/ase/content/localization/client", description: file.relPath,
}))); status: file.readOnly ? "read-only" : "editable",
href: "/ase/content/localization/client",
})),
);
} }
const [{ readdir }, { join }] = await Promise.all([ const [{ readdir }, { join }] = await Promise.all([
import("node:fs/promises"), import("node:fs/promises"),
@@ -319,12 +348,15 @@ async function localizationFiles(
}, },
]); ]);
} }
return response(input, locales.map((locale) => ({ return response(
id: locale, input,
title: locale, locales.map((locale) => ({
status: "editable", id: locale,
href: "/ase/content/localization/cms", title: locale,
}))); status: "editable",
href: "/ase/content/localization/cms",
})),
);
} }
export async function loadProductionContentQuery( export async function loadProductionContentQuery(
@@ -340,9 +372,11 @@ export async function loadProductionContentQuery(
) { ) {
return localizationFiles(input); return localizationFiles(input);
} }
const definition = (CONTENT_QUERY_DEFINITIONS as Partial< const definition = (
Record<ContentRouteId, QueryDefinition> CONTENT_QUERY_DEFINITIONS as Partial<
>)[input.routeId]; Record<ContentRouteId, QueryDefinition>
>
)[input.routeId];
if (!definition) throw new Error("missing Content query adapter"); if (!definition) throw new Error("missing Content query adapter");
return databaseRoute(input, definition); return databaseRoute(input, definition);
} }
@@ -7,7 +7,16 @@ import {
type ContentQueryInput, type ContentQueryInput,
createContentQuery, createContentQuery,
} from "./content-queries"; } 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 { function context(granted: readonly string[]): HousekeepingCapabilityContext {
const permissions = new Set(granted); const permissions = new Set(granted);
@@ -35,6 +44,25 @@ const ready: ContentQueryData = {
}; };
describe("Content query", () => { 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", () => { it("never selects email template bodies into summary query results", () => {
const emailTemplates = const emailTemplates =
CONTENT_QUERY_DEFINITIONS["content.help.email-templates"]; 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
@@ -10,23 +10,33 @@ const fsMocks = vi.hoisted(() => ({
writeFile: vi.fn(), 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("node:fs/promises", () => fsMocks);
vi.mock("drizzle-orm", () => ({ eq: vi.fn() })); vi.mock("drizzle-orm", () => ({ eq: vi.fn() }));
vi.mock("@/lib/db", () => ({ vi.mock("@/lib/db", () => ({
CameraWeb: {}, CameraWeb: {},
EmulatorSettings: {}, EmulatorSettings: {},
WebsiteSetting: {}, WebsiteSetting: {},
db: {}, db: { delete: dbMocks.deleteFn, insert: dbMocks.insert },
})); }));
vi.mock("@/lib/services/rcon", () => ({ vi.mock("@/lib/services/rcon", () => ({
rcon: { updateConfig: vi.fn() }, rcon: { updateConfig: vi.fn() },
})); }));
vi.mock("@/lib/services/site-settings", () => ({ vi.mock("@/lib/services/site-settings", () => ({
siteSettings: { siteSettings: siteMocks,
get: vi.fn(),
reload: vi.fn(),
update: vi.fn(),
},
})); }));
vi.mock("@/lib/services/staff-activity", () => ({ vi.mock("@/lib/services/staff-activity", () => ({
logStaffActivity: vi.fn(), logStaffActivity: vi.fn(),
@@ -51,10 +61,15 @@ const context = {
describe("Content external mutation runtime", () => { describe("Content external mutation runtime", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
siteMocks.get.mockResolvedValue(undefined);
}); });
it("preserves media upload bytes, type, size, and canonical public URL", async () => { 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( const snapshot = await executeContentExternalMutation(
"media.upload", "media.upload",
{ file }, { file },
@@ -75,7 +90,11 @@ describe("Content external mutation runtime", () => {
it("propagates a real storage failure before optimistic success", async () => { it("propagates a real storage failure before optimistic success", async () => {
fsMocks.writeFile.mockRejectedValueOnce(new Error("disk offline")); 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( await expect(
executeContentExternalMutation("media.upload", { file }, context), executeContentExternalMutation("media.upload", { file }, context),
@@ -95,4 +114,103 @@ describe("Content external mutation runtime", () => {
} satisfies Partial<ContentMutationFailure>); } satisfies Partial<ContentMutationFailure>);
expect(fsMocks.unlink).not.toHaveBeenCalled(); 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"; } from "./mutations";
import { ContentCommittedExternalFailure } from "./mutations-production"; 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 = [ const FAVICON_TYPES = [
...MEDIA_TYPES, ...MEDIA_TYPES,
"image/x-icon", "image/x-icon",
"image/svg+xml", "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([ const CMS_LOCALES = new Set([
"en", "it", "nl", "de", "fr", "es", "pt", "pl", "sv", "tr", "ro", "en",
"hu", "cs", "sk", "da", "no", "el", "bg", "hr", "sr", "uk", "ru", "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 COLOR_RE = /^[#a-zA-Z0-9(),.\s%-]+$/;
const ADMIN_COLOR_KEYS = [ const ADMIN_COLOR_KEYS = [
@@ -53,23 +86,37 @@ const ADMIN_COLOR_KEYS = [
"admin_border", "admin_border",
"admin_sidebar_bg", "admin_sidebar_bg",
] as const; ] 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 { function validation(): ContentMutationFailure {
return new ContentMutationFailure("VALIDATION", "errors.housekeeping.validation"); return new ContentMutationFailure(
"VALIDATION",
"errors.housekeeping.validation",
);
} }
function notFound(): ContentMutationFailure { 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> { 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>; return value as Record<string, unknown>;
} }
function text(value: unknown, maximum: number, required = false): string { 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(); if (required && !normalized) throw validation();
return normalized; return normalized;
} }
@@ -99,6 +146,81 @@ function fileValue(value: unknown): File {
return value as 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( async function writeWebsiteSetting(
key: string, key: string,
value: string, value: string,
@@ -111,17 +233,20 @@ async function writeWebsiteSetting(
} }
async function mediaUpload(input: unknown): Promise<ContentMutationSnapshot> { async function mediaUpload(input: unknown): Promise<ContentMutationSnapshot> {
const file = fileValue(record(input).file); const { file, bytes, type, extension } = await validatedImage(
if (file.size <= 0 || file.size > 5 * 1024 * 1024 || !MEDIA_TYPES.includes(file.type)) throw validation(); record(input).file,
5 * 1024 * 1024,
MEDIA_TYPES,
);
await mkdir(MEDIA_ROOT, { recursive: true }); await mkdir(MEDIA_ROOT, { recursive: true });
const extension = text(file.name.split(".").pop() ?? "png", 10, true).toLowerCase(); const name =
const name = Date.now() + "-" + Math.random().toString(36).slice(2, 8) + "." + extension; Date.now() + "-" + Math.random().toString(36).slice(2, 8) + "." + extension;
const filePath = resolveMediaPath(name); const filePath = resolveMediaPath(name);
if (!filePath.startsWith(MEDIA_ROOT + path.sep)) throw validation(); if (!filePath.startsWith(MEDIA_ROOT + path.sep)) throw validation();
await writeFile(filePath, Buffer.from(await file.arrayBuffer())); await writeFile(filePath, bytes);
return { return {
before: null, before: null,
after: { name, size: file.size, type: file.type }, after: { name, size: file.size, type },
output: { name, url: "/api/media/" + name }, output: { name, url: "/api/media/" + name },
}; };
} }
@@ -149,7 +274,11 @@ async function photoDelete(
): Promise<ContentMutationSnapshot> { ): Promise<ContentMutationSnapshot> {
const id = Number(record(input).id); const id = Number(record(input).id);
if (!Number.isSafeInteger(id) || id <= 0) throw validation(); 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(); if (!row) throw notFound();
await db.delete(CameraWeb).where(eq(CameraWeb.id, id)); await db.delete(CameraWeb).where(eq(CameraWeb.id, id));
await tryRemoveLocalPhotoFile(row.url); await tryRemoveLocalPhotoFile(row.url);
@@ -163,7 +292,9 @@ async function photoDelete(
return { before: { id, url: row.url }, after: null }; 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 data = record(input);
const raw = data.items ?? data.config ?? data; const raw = data.items ?? data.config ?? data;
const config = jsonRecord(raw) as AdminNavConfig; const config = jsonRecord(raw) as AdminNavConfig;
@@ -197,9 +328,17 @@ async function themeUpdate(
const raw = text(source[databaseKey], 255); const raw = text(source[databaseKey], 255);
if (raw && COLOR_RE.test(raw)) bag[key] = raw; if (raw && COLOR_RE.test(raw)) bag[key] = raw;
} }
for (const [key, value] of Object.entries(ensureReadableThemeColors(bag))) { for (const [key, value] of Object.entries(
const databaseKey = settingKey(key as (typeof THEME_COLOR_KEYS)[number], mode); ensureReadableThemeColors(bag),
await transaction.insert(WebsiteSetting).values({ key: databaseKey, value, comment: "Theme (housekeeping)" }).onDuplicateKeyUpdate({ set: { value } }); )) {
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); changed.push(databaseKey);
} }
} }
@@ -208,63 +347,121 @@ async function themeUpdate(
const raw = text(source[key], 255); const raw = text(source[key], 255);
if (raw && COLOR_RE.test(raw)) adminBag[key] = raw; if (raw && COLOR_RE.test(raw)) adminBag[key] = raw;
} }
for (const [key, value] of Object.entries(ensureReadableThemeColors(adminBag))) { for (const [key, value] of Object.entries(
await transaction.insert(WebsiteSetting).values({ key, value, comment: "Theme (housekeeping)" }).onDuplicateKeyUpdate({ set: { value } }); ensureReadableThemeColors(adminBag),
)) {
await transaction
.insert(WebsiteSetting)
.values({ key, value, comment: "Theme (housekeeping)" })
.onDuplicateKeyUpdate({ set: { value } });
changed.push(key); changed.push(key);
} }
const radius = text(source.border_radius, 3); const radius = text(source.border_radius, 3);
if (/^\d{1,3}$/u.test(radius)) { 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"); changed.push("border_radius");
} }
const font = text(source.font_family, 100); const font = text(source.font_family, 100);
if (font in FONTS) { 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"); changed.push("font_family");
} }
for (const key of HEADING_KEYS) { for (const key of HEADING_KEYS) {
const value = text(source[key], 3); const value = text(source[key], 3);
if (!/^\d{1,3}$/u.test(value)) continue; 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); changed.push(key);
} }
if (Object.hasOwn(source, "custom_css")) { if (Object.hasOwn(source, "custom_css")) {
const value = String(source.custom_css ?? "").normalize("NFC").slice(0, 20_000); const value = String(source.custom_css ?? "")
await transaction.insert(WebsiteSetting).values({ key: "custom_css", value, comment: "Theme (housekeeping)" }).onDuplicateKeyUpdate({ set: { value } }); .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"); changed.push("custom_css");
} }
}); });
const snapshot: ContentMutationSnapshot = { before: null, after: { changedKeys: changed.sort() } }; const snapshot: ContentMutationSnapshot = {
before: null,
after: { changedKeys: changed.sort() },
};
try { try {
siteSettings.reload(); 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 { } catch {
throw new ContentCommittedExternalFailure(snapshot); throw new ContentCommittedExternalFailure(snapshot);
} }
return 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 name = text(record(input).preset, 100, true);
const preset = PRESETS[name]; const preset = PRESETS[name];
if (!preset) throw validation(); if (!preset) throw validation();
await db.transaction(async (transaction) => { await db.transaction(async (transaction) => {
for (const [key, value] of presetSettings(preset)) { 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 { try {
siteSettings.reload(); 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 { } catch {
throw new ContentCommittedExternalFailure(snapshot); throw new ContentCommittedExternalFailure(snapshot);
} }
return 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 data = record(input);
const action = text(data.action, 16, true); const action = text(data.action, 16, true);
const id = text(data.id, 100); const id = text(data.id, 100);
@@ -275,43 +472,81 @@ async function themeCustomChange(input: unknown, context: ContentMutationContext
await deleteCustomThemeStore(id); await deleteCustomThemeStore(id);
return { before: { id, name: existing.name }, after: null }; 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 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); const theme = await upsertCustomTheme(name, settings, id || undefined);
await logStaffActivity({ staffId: context.capability.actor.id, action: "theme_preset", description: "Saved custom theme " + theme.name }); await logStaffActivity({
return { before: id ? { id } : null, after: { id: theme.id, name: theme.name }, output: { id: theme.id, name: theme.name } }; 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 id = text(record(input).id, 100, true);
const theme = await getCustomTheme(id); const theme = await getCustomTheme(id);
if (!theme) throw notFound(); if (!theme) throw notFound();
await db.transaction(async (transaction) => { await db.transaction(async (transaction) => {
for (const [key, value] of Object.entries(theme.settings)) { for (const [key, value] of Object.entries(theme.settings)) {
if (!value) continue; 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 { try {
siteSettings.reload(); 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 { } catch {
throw new ContentCommittedExternalFailure(snapshot); throw new ContentCommittedExternalFailure(snapshot);
} }
return snapshot; return snapshot;
} }
function faviconExtension(type: string): string { async function removeStoredAsset(
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"; url: string | null | undefined,
} directory: string,
prefix: string,
async function removeStoredAsset(url: string | null | undefined, directory: string, prefix: string): Promise<void> { ): Promise<void> {
if (!url?.startsWith(prefix)) return; if (!url?.startsWith(prefix)) return;
const name = url.slice(prefix.length); 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); const filePath = path.resolve(directory, name);
if (!filePath.startsWith(directory + path.sep)) return; if (!filePath.startsWith(directory + path.sep)) return;
try { try {
@@ -322,20 +557,40 @@ async function removeStoredAsset(url: string | null | undefined, directory: stri
} }
async function faviconSave(input: unknown): Promise<ContentMutationSnapshot> { async function faviconSave(input: unknown): Promise<ContentMutationSnapshot> {
const file = fileValue(record(input).file); const { bytes, extension } = await validatedImage(
if (file.size <= 0 || file.size > 2 * 1024 * 1024 || !FAVICON_TYPES.includes(file.type)) throw validation(); record(input).file,
2 * 1024 * 1024,
FAVICON_TYPES,
);
const directory = resolveMediaPath("favicon"); const directory = resolveMediaPath("favicon");
const filename = "favicon-" + Date.now() + "." + faviconExtension(file.type); const filename = "favicon-" + Date.now() + "." + extension;
const filePath = path.resolve(directory, filename); const filePath = path.resolve(directory, filename);
if (!filePath.startsWith(directory + path.sep)) throw validation(); if (!filePath.startsWith(directory + path.sep)) throw validation();
const oldUrl = await siteSettings.get("cms_favicon"); const oldUrl = await siteSettings.get("cms_favicon");
await mkdir(directory, { recursive: true }); await mkdir(directory, { recursive: true });
await writeFile(filePath, Buffer.from(await file.arrayBuffer())); await writeFile(filePath, bytes);
await removeStoredAsset(oldUrl, directory, "/api/media/favicon/");
const url = "/api/media/favicon/" + filename; const url = "/api/media/favicon/" + filename;
await writeWebsiteSetting("cms_favicon", url, "Favicon URL"); const snapshot: ContentMutationSnapshot = {
const snapshot: ContentMutationSnapshot = { before: { value: oldUrl ?? null }, after: { value: url }, output: { url } }; before: { value: oldUrl ?? null },
after: { value: url },
output: { url },
};
try { 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(); siteSettings.reload();
} catch { } catch {
throw new ContentCommittedExternalFailure(snapshot); throw new ContentCommittedExternalFailure(snapshot);
@@ -345,10 +600,17 @@ async function faviconSave(input: unknown): Promise<ContentMutationSnapshot> {
async function faviconDelete(): Promise<ContentMutationSnapshot> { async function faviconDelete(): Promise<ContentMutationSnapshot> {
const oldUrl = await siteSettings.get("cms_favicon"); 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")); 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 { try {
await removeStoredAsset(
oldUrl,
resolveMediaPath("favicon"),
"/api/media/favicon/",
);
siteSettings.reload(); siteSettings.reload();
} catch { } catch {
throw new ContentCommittedExternalFailure(snapshot); throw new ContentCommittedExternalFailure(snapshot);
@@ -357,19 +619,46 @@ async function faviconDelete(): Promise<ContentMutationSnapshot> {
} }
async function logoSave(input: unknown): Promise<ContentMutationSnapshot> { async function logoSave(input: unknown): Promise<ContentMutationSnapshot> {
const file = fileValue(record(input).file); const { bytes, extension } = await validatedImage(
const extension = file.type === "image/png" ? "png" : file.type === "image/gif" ? "gif" : file.type === "image/jpeg" ? "jpg" : file.type === "image/webp" ? "webp" : "png"; record(input).file,
5 * 1024 * 1024,
MEDIA_TYPES,
);
const directory = resolveMediaPath("logo"); 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); const filePath = path.resolve(directory, filename);
if (!filePath.startsWith(directory + path.sep)) throw validation(); if (!filePath.startsWith(directory + path.sep)) throw validation();
const oldUrl = await siteSettings.get("cms_logo"); const oldUrl = await siteSettings.get("cms_logo");
await mkdir(directory, { recursive: true }); await mkdir(directory, { recursive: true });
await writeFile(filePath, Buffer.from(await file.arrayBuffer())); await writeFile(filePath, bytes);
const url = "/api/media/logo/" + filename; const url = "/api/media/logo/" + filename;
await writeWebsiteSetting("cms_logo", url, "Logo (generator)"); const snapshot: ContentMutationSnapshot = {
const snapshot: ContentMutationSnapshot = { before: { value: oldUrl ?? null }, after: { value: url }, output: { url } }; before: { value: oldUrl ?? null },
after: { value: url },
output: { url },
};
try { 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(); siteSettings.reload();
} catch { } catch {
throw new ContentCommittedExternalFailure(snapshot); throw new ContentCommittedExternalFailure(snapshot);
@@ -377,66 +666,123 @@ async function logoSave(input: unknown): Promise<ContentMutationSnapshot> {
return snapshot; return snapshot;
} }
async function cmsTranslationSave(input: unknown): Promise<ContentMutationSnapshot> { async function cmsTranslationSave(
input: unknown,
): Promise<ContentMutationSnapshot> {
const data = record(input); const data = record(input);
const locale = text(data.locale, 16, true); const locale = text(data.locale, 16, true);
if (!CMS_LOCALES.has(locale)) throw validation(); if (!CMS_LOCALES.has(locale)) throw validation();
const translations = jsonRecord(data.data); 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"); 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 data = record(input);
const fileId = text(data.fileId, 100, true); 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); const file = getClientTranslationFile(fileId);
if (!file || file.readOnly) throw validation(); if (!file || file.readOnly) throw validation();
const absolutePath = path.join(process.cwd(), file.relPath); const absolutePath = path.join(process.cwd(), file.relPath);
const raw = await readFile(absolutePath, "utf-8"); const raw = await readFile(absolutePath, "utf-8");
if (file.format === "json") { if (file.format === "json") {
await writeFile(absolutePath, JSON.stringify(translations, null, 4), "utf-8"); await writeFile(
return { before: null, after: { fileId, keyCount: Object.keys(translations).length }, output: { commentsLost: false, unpatchedKeys: [] } }; 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 original: Record<string, string> = {};
const parsed = JSONC.parse(raw); const parsed = JSONC.parse(raw);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { 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); const patched = patchJson5(raw, original, translations);
if (patched.unpatchedKeys.length === 0) { if (patched.unpatchedKeys.length === 0) {
await writeFile(absolutePath, patched.content, "utf-8"); await writeFile(absolutePath, patched.content, "utf-8");
} else { } else {
await writeFile(absolutePath, JSON.stringify(translations, null, 4), "utf-8"); await writeFile(
absolutePath,
JSON.stringify(translations, null, 4),
"utf-8",
);
} }
return { return {
before: null, before: null,
after: { fileId, keyCount: Object.keys(translations).length }, 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 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 source = data.settings
const entries = Object.entries(source).map(([key, value]) => [text(key, 255, true), String(value)] as const); ? 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) => { await db.transaction(async (transaction) => {
for (const [key, value] of entries) { 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 { try {
const delivered = await rcon.updateConfig(); 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 { } catch {
throw new ContentCommittedExternalFailure(snapshot); throw new ContentCommittedExternalFailure(snapshot);
} }
return 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.upload": (input) => mediaUpload(input),
"media.delete": (input) => mediaDelete(input), "media.delete": (input) => mediaDelete(input),
"photo.delete": photoDelete, "photo.delete": photoDelete,
@@ -453,6 +799,15 @@ const EXTERNAL_HANDLERS: Partial<Record<ContentMutationOperation, (input: unknow
"translation.emulator.save": emulatorTranslationSave, "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( export async function executeContentExternalMutation(
operation: ContentMutationOperation, operation: ContentMutationOperation,
input: unknown, input: unknown,
@@ -1,5 +1,6 @@
import "server-only"; import "server-only";
import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingCapabilityContext } from "../../foundation/contracts"; import type { HousekeepingCapabilityContext } from "../../foundation/contracts";
import { contentQuery } from "./queries/content-queries"; import { contentQuery } from "./queries/content-queries";
@@ -9,6 +10,7 @@ async function total(
context: HousekeepingCapabilityContext, context: HousekeepingCapabilityContext,
routeId: routeId:
| "content.editorial.articles" | "content.editorial.articles"
| "content.media.banners"
| "content.media.photos" | "content.media.photos"
| "content.media.library" | "content.media.library"
| "content.localization.overview", | "content.localization.overview",
@@ -17,7 +19,8 @@ async function total(
routeId, routeId,
list: { pageSize: 1, offset: 0 }, 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( export async function loadContentWidget(
@@ -31,11 +34,20 @@ export async function loadContentWidget(
return { articles }; return { articles };
} }
if (kind === "media") { if (kind === "media") {
const [photos, library] = await Promise.all([ const counts: Record<string, number> = {};
total(context, "content.media.photos"), if (context.has(PERMS.PAGES_VIEW)) {
total(context, "content.media.library"), const [photos, library] = await Promise.all([
]); total(context, "content.media.photos"),
return { photos, library, items: photos + library }; 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"); const stores = await total(context, "content.localization.overview");
return { stores }; return { stores };
+2 -2
View File
@@ -125,7 +125,7 @@ describe("admin operations route contract", () => {
expect(source).not.toMatch(/await requireStaffRateLimited\(\)/); 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 dir = "src/actions";
const offenders: string[] = []; const offenders: string[] = [];
for (const name of readdirSync(dir)) { for (const name of readdirSync(dir)) {
@@ -135,7 +135,7 @@ describe("admin operations route contract", () => {
offenders.push(name); offenders.push(name);
} }
} }
expect(offenders).toEqual([]); expect(offenders).toEqual(["save-logo.ts"]);
}); });
it("caches analytics full-scans via redisCache", () => { it("caches analytics full-scans via redisCache", () => {