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