fix(housekeeping): address task 14 review round 1
This commit is contained in:
1 parent
fd68819d9b
commit
d09eaa33d6
23 files changed
+2503
-488
No files matched your search
@@ -0,0 +1,145 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
|
||||
import { executeContentDatabaseMutation } from "./mutation-runtime-database";
|
||||
|
||||
const database = vi.hoisted(() => {
|
||||
let selected: Record<string, unknown> = { id: 7, title: "Existing" };
|
||||
const set = vi.fn((values: Record<string, unknown>) => ({
|
||||
where: vi.fn(async () => undefined),
|
||||
values,
|
||||
}));
|
||||
const update = vi.fn(() => ({ set }));
|
||||
const limit = vi.fn(async () => [selected]);
|
||||
const where = vi.fn(() => ({ limit }));
|
||||
const from = vi.fn(() => ({ where }));
|
||||
const select = vi.fn(() => ({ from }));
|
||||
return {
|
||||
set,
|
||||
update,
|
||||
select,
|
||||
selected(value: Record<string, unknown>) {
|
||||
selected = value;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("drizzle-orm", () => ({
|
||||
eq: vi.fn(),
|
||||
sql: Object.assign(vi.fn(), { raw: vi.fn() }),
|
||||
}));
|
||||
vi.mock("@/lib/db", () => {
|
||||
const table = new Proxy({}, { get: (_target, key) => String(key) });
|
||||
return {
|
||||
db: {
|
||||
select: database.select,
|
||||
update: database.update,
|
||||
},
|
||||
EmailTemplates: table,
|
||||
Taggables: table,
|
||||
Tags: table,
|
||||
User: table,
|
||||
WebsiteAds: table,
|
||||
WebsiteArticleComments: table,
|
||||
WebsiteArticleReactions: table,
|
||||
WebsiteArticles: table,
|
||||
WebsiteBanner: table,
|
||||
WebsiteEvent: table,
|
||||
WebsiteEventPrize: table,
|
||||
WebsiteEventType: table,
|
||||
WebsiteEventWinner: table,
|
||||
WebsiteHelpCenterCategories: table,
|
||||
WebsitePoll: table,
|
||||
WebsitePollQuestion: table,
|
||||
WebsiteWriteableBoxes: table,
|
||||
};
|
||||
});
|
||||
vi.mock("@/lib/services/staff-activity", () => ({
|
||||
logStaffActivity: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
const capability = {
|
||||
actor: { id: 42, username: "operator", rank: 7 },
|
||||
isSuperAdmin: false,
|
||||
has: () => true,
|
||||
hasAny: () => true,
|
||||
hasAll: () => true,
|
||||
} satisfies HousekeepingCapabilityContext;
|
||||
const context = {
|
||||
capability,
|
||||
correlationId: "database-runtime",
|
||||
legacy: false,
|
||||
};
|
||||
|
||||
describe("Content database mutation runtime partial updates", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
database.selected({ id: 7, title: "Existing" });
|
||||
});
|
||||
|
||||
it("does not reset omitted banner fields", async () => {
|
||||
await executeContentDatabaseMutation(
|
||||
"banner.change",
|
||||
{ action: "update", id: 7, title: "Renamed" },
|
||||
context,
|
||||
undefined,
|
||||
);
|
||||
expect(database.set).toHaveBeenCalledWith({ title: "Renamed" });
|
||||
});
|
||||
|
||||
it("does not reset a tag color omitted by the update form", async () => {
|
||||
await executeContentDatabaseMutation(
|
||||
"tag.change",
|
||||
{ action: "update", id: "7", name: "News" },
|
||||
context,
|
||||
undefined,
|
||||
);
|
||||
const values = database.set.mock.calls[0]?.[0];
|
||||
expect(values).toMatchObject({ name: "News" });
|
||||
expect(values).not.toHaveProperty("backgroundColor");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["help-question.change", { name: "Updated question" }, "name"],
|
||||
["writeable-box.change", { title: "Updated box" }, "title"],
|
||||
["email-template.change", { subject: "Updated subject" }, "subject"],
|
||||
] as const)(
|
||||
"updates only supplied fields for %s",
|
||||
async (operation, patch, expectedKey) => {
|
||||
await executeContentDatabaseMutation(
|
||||
operation,
|
||||
{ action: "update", id: "7", ...patch },
|
||||
context,
|
||||
undefined,
|
||||
);
|
||||
const values = database.set.mock.calls[0]?.[0];
|
||||
expect(values).toHaveProperty(expectedKey);
|
||||
for (const destructiveKey of [
|
||||
"content",
|
||||
"position",
|
||||
"isActive",
|
||||
"body",
|
||||
"variables",
|
||||
"imageUrl",
|
||||
]) {
|
||||
expect(values).not.toHaveProperty(destructiveKey);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["help-question.change", { answer: "Updated answer" }, "content"],
|
||||
["writeable-box.change", { content: "Updated content" }, "content"],
|
||||
["email-template.change", { body: "Updated body" }, "body"],
|
||||
] as const)(
|
||||
"accepts a non-title partial patch for %s",
|
||||
async (operation, patch, expectedKey) => {
|
||||
await executeContentDatabaseMutation(
|
||||
operation,
|
||||
{ action: "update", id: "7", ...patch },
|
||||
context,
|
||||
undefined,
|
||||
);
|
||||
expect(database.set.mock.calls[0]?.[0]).toHaveProperty(expectedKey);
|
||||
},
|
||||
);
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
+126
-8
@@ -10,23 +10,33 @@ const fsMocks = vi.hoisted(() => ({
|
||||
writeFile: vi.fn(),
|
||||
}));
|
||||
|
||||
const dbMocks = vi.hoisted(() => {
|
||||
const onDuplicateKeyUpdate = vi.fn(async () => undefined);
|
||||
const values = vi.fn(() => ({ onDuplicateKeyUpdate }));
|
||||
const insert = vi.fn(() => ({ values }));
|
||||
const where = vi.fn(async () => undefined);
|
||||
const deleteFn = vi.fn(() => ({ where }));
|
||||
return { deleteFn, insert, onDuplicateKeyUpdate, values, where };
|
||||
});
|
||||
const siteMocks = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
reload: vi.fn(),
|
||||
update: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:fs/promises", () => fsMocks);
|
||||
vi.mock("drizzle-orm", () => ({ eq: vi.fn() }));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
CameraWeb: {},
|
||||
EmulatorSettings: {},
|
||||
WebsiteSetting: {},
|
||||
db: {},
|
||||
db: { delete: dbMocks.deleteFn, insert: dbMocks.insert },
|
||||
}));
|
||||
vi.mock("@/lib/services/rcon", () => ({
|
||||
rcon: { updateConfig: vi.fn() },
|
||||
}));
|
||||
vi.mock("@/lib/services/site-settings", () => ({
|
||||
siteSettings: {
|
||||
get: vi.fn(),
|
||||
reload: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
siteSettings: siteMocks,
|
||||
}));
|
||||
vi.mock("@/lib/services/staff-activity", () => ({
|
||||
logStaffActivity: vi.fn(),
|
||||
@@ -51,10 +61,15 @@ const context = {
|
||||
describe("Content external mutation runtime", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
siteMocks.get.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("preserves media upload bytes, type, size, and canonical public URL", async () => {
|
||||
const file = new File(["image"], "photo.PNG", { type: "image/png" });
|
||||
const file = new File(
|
||||
[new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])],
|
||||
"photo.png",
|
||||
{ type: "image/png" },
|
||||
);
|
||||
const snapshot = await executeContentExternalMutation(
|
||||
"media.upload",
|
||||
{ file },
|
||||
@@ -75,7 +90,11 @@ describe("Content external mutation runtime", () => {
|
||||
|
||||
it("propagates a real storage failure before optimistic success", async () => {
|
||||
fsMocks.writeFile.mockRejectedValueOnce(new Error("disk offline"));
|
||||
const file = new File(["image"], "photo.png", { type: "image/png" });
|
||||
const file = new File(
|
||||
[new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])],
|
||||
"photo.png",
|
||||
{ type: "image/png" },
|
||||
);
|
||||
|
||||
await expect(
|
||||
executeContentExternalMutation("media.upload", { file }, context),
|
||||
@@ -95,4 +114,103 @@ describe("Content external mutation runtime", () => {
|
||||
} satisfies Partial<ContentMutationFailure>);
|
||||
expect(fsMocks.unlink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects declared MIME, filename extension, and actual bytes that disagree", async () => {
|
||||
const disguisedSvg = new File(
|
||||
['<svg xmlns="http://www.w3.org/2000/svg"></svg>'],
|
||||
"photo.svg",
|
||||
{ type: "image/png" },
|
||||
);
|
||||
|
||||
await expect(
|
||||
executeContentExternalMutation(
|
||||
"media.upload",
|
||||
{ file: disguisedSvg },
|
||||
context,
|
||||
),
|
||||
).rejects.toMatchObject({ code: "VALIDATION" });
|
||||
expect(fsMocks.writeFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an oversized logo before reading its bytes", async () => {
|
||||
const arrayBuffer = vi.fn();
|
||||
const file = {
|
||||
name: "logo.png",
|
||||
type: "image/png",
|
||||
size: 5 * 1024 * 1024 + 1,
|
||||
arrayBuffer,
|
||||
};
|
||||
|
||||
await expect(
|
||||
executeContentExternalMutation("logo.save", { file }, context),
|
||||
).rejects.toMatchObject({ code: "VALIDATION" });
|
||||
expect(arrayBuffer).not.toHaveBeenCalled();
|
||||
expect(fsMocks.writeFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("removes a newly written favicon when the database write fails", async () => {
|
||||
dbMocks.onDuplicateKeyUpdate.mockRejectedValueOnce(
|
||||
new Error("database offline"),
|
||||
);
|
||||
const file = new File(
|
||||
[new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])],
|
||||
"favicon.png",
|
||||
{ type: "image/png" },
|
||||
);
|
||||
|
||||
await expect(
|
||||
executeContentExternalMutation("favicon.save", { file }, context),
|
||||
).rejects.toThrow("database offline");
|
||||
const writtenPath = fsMocks.writeFile.mock.calls[0]?.[0];
|
||||
expect(fsMocks.unlink).toHaveBeenCalledWith(writtenPath);
|
||||
});
|
||||
|
||||
it("reports a partial favicon result when database failure compensation also fails", async () => {
|
||||
dbMocks.onDuplicateKeyUpdate.mockRejectedValueOnce(
|
||||
new Error("database offline"),
|
||||
);
|
||||
fsMocks.unlink.mockRejectedValueOnce(new Error("cleanup failed"));
|
||||
const file = new File(
|
||||
[new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])],
|
||||
"favicon.png",
|
||||
{ type: "image/png" },
|
||||
);
|
||||
|
||||
await expect(
|
||||
executeContentExternalMutation("favicon.save", { file }, context),
|
||||
).rejects.toMatchObject({
|
||||
name: "ContentCommittedExternalFailure",
|
||||
snapshot: {
|
||||
before: { value: null },
|
||||
after: { value: null },
|
||||
output: { compensation: "failed" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("removes a newly written logo when the database write fails", async () => {
|
||||
dbMocks.onDuplicateKeyUpdate.mockRejectedValueOnce(
|
||||
new Error("database offline"),
|
||||
);
|
||||
const file = new File(
|
||||
[new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])],
|
||||
"logo.png",
|
||||
{ type: "image/png" },
|
||||
);
|
||||
await expect(
|
||||
executeContentExternalMutation("logo.save", { file }, context),
|
||||
).rejects.toThrow("database offline");
|
||||
expect(fsMocks.unlink).toHaveBeenCalledWith(
|
||||
fsMocks.writeFile.mock.calls[0]?.[0],
|
||||
);
|
||||
});
|
||||
|
||||
it("does not delete the existing favicon before the database delete commits", async () => {
|
||||
siteMocks.get.mockResolvedValueOnce("/api/media/favicon/old.png");
|
||||
dbMocks.where.mockRejectedValueOnce(new Error("database offline"));
|
||||
await expect(
|
||||
executeContentExternalMutation("favicon.delete", {}, context),
|
||||
).rejects.toThrow("database offline");
|
||||
expect(fsMocks.unlink).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -34,15 +34,48 @@ import {
|
||||
} from "./mutations";
|
||||
import { ContentCommittedExternalFailure } from "./mutations-production";
|
||||
|
||||
const MEDIA_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
||||
const MEDIA_TYPES = [
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
] as const;
|
||||
const FAVICON_TYPES = [
|
||||
...MEDIA_TYPES,
|
||||
"image/x-icon",
|
||||
"image/svg+xml",
|
||||
];
|
||||
] as const;
|
||||
const IMAGE_EXTENSIONS = {
|
||||
"image/png": [".png"],
|
||||
"image/jpeg": [".jpg", ".jpeg"],
|
||||
"image/gif": [".gif"],
|
||||
"image/webp": [".webp"],
|
||||
"image/x-icon": [".ico"],
|
||||
"image/svg+xml": [".svg"],
|
||||
} as const;
|
||||
const CMS_LOCALES = new Set([
|
||||
"en", "it", "nl", "de", "fr", "es", "pt", "pl", "sv", "tr", "ro",
|
||||
"hu", "cs", "sk", "da", "no", "el", "bg", "hr", "sr", "uk", "ru",
|
||||
"en",
|
||||
"it",
|
||||
"nl",
|
||||
"de",
|
||||
"fr",
|
||||
"es",
|
||||
"pt",
|
||||
"pl",
|
||||
"sv",
|
||||
"tr",
|
||||
"ro",
|
||||
"hu",
|
||||
"cs",
|
||||
"sk",
|
||||
"da",
|
||||
"no",
|
||||
"el",
|
||||
"bg",
|
||||
"hr",
|
||||
"sr",
|
||||
"uk",
|
||||
"ru",
|
||||
]);
|
||||
const COLOR_RE = /^[#a-zA-Z0-9(),.\s%-]+$/;
|
||||
const ADMIN_COLOR_KEYS = [
|
||||
@@ -53,23 +86,37 @@ const ADMIN_COLOR_KEYS = [
|
||||
"admin_border",
|
||||
"admin_sidebar_bg",
|
||||
] as const;
|
||||
const HEADING_KEYS = ["size_heading_h1", "size_heading_h2", "size_heading_h3"] as const;
|
||||
const HEADING_KEYS = [
|
||||
"size_heading_h1",
|
||||
"size_heading_h2",
|
||||
"size_heading_h3",
|
||||
] as const;
|
||||
|
||||
function validation(): ContentMutationFailure {
|
||||
return new ContentMutationFailure("VALIDATION", "errors.housekeeping.validation");
|
||||
return new ContentMutationFailure(
|
||||
"VALIDATION",
|
||||
"errors.housekeeping.validation",
|
||||
);
|
||||
}
|
||||
|
||||
function notFound(): ContentMutationFailure {
|
||||
return new ContentMutationFailure("NOT_FOUND", "errors.housekeeping.notFound");
|
||||
return new ContentMutationFailure(
|
||||
"NOT_FOUND",
|
||||
"errors.housekeeping.notFound",
|
||||
);
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) throw validation();
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value))
|
||||
throw validation();
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function text(value: unknown, maximum: number, required = false): string {
|
||||
const normalized = String(value ?? "").normalize("NFC").trim().slice(0, maximum);
|
||||
const normalized = String(value ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, maximum);
|
||||
if (required && !normalized) throw validation();
|
||||
return normalized;
|
||||
}
|
||||
@@ -99,6 +146,81 @@ function fileValue(value: unknown): File {
|
||||
return value as File;
|
||||
}
|
||||
|
||||
type SupportedImageType = keyof typeof IMAGE_EXTENSIONS;
|
||||
|
||||
function detectedImageType(bytes: Buffer): SupportedImageType | null {
|
||||
if (
|
||||
bytes.length >= 8 &&
|
||||
bytes
|
||||
.subarray(0, 8)
|
||||
.equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
|
||||
)
|
||||
return "image/png";
|
||||
if (
|
||||
bytes.length >= 3 &&
|
||||
bytes[0] === 0xff &&
|
||||
bytes[1] === 0xd8 &&
|
||||
bytes[2] === 0xff
|
||||
)
|
||||
return "image/jpeg";
|
||||
const header = bytes.subarray(0, 12).toString("ascii");
|
||||
if (header.startsWith("GIF87a") || header.startsWith("GIF89a"))
|
||||
return "image/gif";
|
||||
if (header.startsWith("RIFF") && header.slice(8, 12) === "WEBP")
|
||||
return "image/webp";
|
||||
if (
|
||||
bytes.length >= 4 &&
|
||||
bytes[0] === 0 &&
|
||||
bytes[1] === 0 &&
|
||||
bytes[2] === 1 &&
|
||||
bytes[3] === 0
|
||||
)
|
||||
return "image/x-icon";
|
||||
const source = bytes
|
||||
.toString("utf8")
|
||||
.replace(/^\uFEFF/u, "")
|
||||
.trimStart();
|
||||
const svg = source.replace(/^<\?xml[^>]*>\s*/iu, "");
|
||||
if (/^<svg(?:\s|>)/iu.test(svg)) return "image/svg+xml";
|
||||
return null;
|
||||
}
|
||||
|
||||
function isSafeSvg(bytes: Buffer): boolean {
|
||||
const source = bytes.toString("utf8");
|
||||
return (
|
||||
!/<(?:script|foreignObject|iframe|object|embed|link|meta)(?:\s|>)/iu.test(
|
||||
source,
|
||||
) &&
|
||||
!/\son[a-z]+\s*=/iu.test(source) &&
|
||||
!/(?:href|src)\s*=\s*["']?\s*(?:javascript:|data:text\/html)/iu.test(source)
|
||||
);
|
||||
}
|
||||
|
||||
async function validatedImage(
|
||||
value: unknown,
|
||||
maximum: number,
|
||||
allowedTypes: readonly SupportedImageType[],
|
||||
): Promise<{
|
||||
file: File;
|
||||
bytes: Buffer;
|
||||
type: SupportedImageType;
|
||||
extension: string;
|
||||
}> {
|
||||
const file = fileValue(value);
|
||||
if (file.size <= 0 || file.size > maximum) throw validation();
|
||||
const bytes = Buffer.from(await file.arrayBuffer());
|
||||
if (bytes.length <= 0 || bytes.length > maximum || bytes.length !== file.size)
|
||||
throw validation();
|
||||
const type = detectedImageType(bytes);
|
||||
if (!type || !allowedTypes.includes(type) || file.type.toLowerCase() !== type)
|
||||
throw validation();
|
||||
if (type === "image/svg+xml" && !isSafeSvg(bytes)) throw validation();
|
||||
const extension = path.extname(file.name).toLowerCase();
|
||||
if (!(IMAGE_EXTENSIONS[type] as readonly string[]).includes(extension))
|
||||
throw validation();
|
||||
return { file, bytes, type, extension: IMAGE_EXTENSIONS[type][0].slice(1) };
|
||||
}
|
||||
|
||||
async function writeWebsiteSetting(
|
||||
key: string,
|
||||
value: string,
|
||||
@@ -111,17 +233,20 @@ async function writeWebsiteSetting(
|
||||
}
|
||||
|
||||
async function mediaUpload(input: unknown): Promise<ContentMutationSnapshot> {
|
||||
const file = fileValue(record(input).file);
|
||||
if (file.size <= 0 || file.size > 5 * 1024 * 1024 || !MEDIA_TYPES.includes(file.type)) throw validation();
|
||||
const { file, bytes, type, extension } = await validatedImage(
|
||||
record(input).file,
|
||||
5 * 1024 * 1024,
|
||||
MEDIA_TYPES,
|
||||
);
|
||||
await mkdir(MEDIA_ROOT, { recursive: true });
|
||||
const extension = text(file.name.split(".").pop() ?? "png", 10, true).toLowerCase();
|
||||
const name = Date.now() + "-" + Math.random().toString(36).slice(2, 8) + "." + extension;
|
||||
const name =
|
||||
Date.now() + "-" + Math.random().toString(36).slice(2, 8) + "." + extension;
|
||||
const filePath = resolveMediaPath(name);
|
||||
if (!filePath.startsWith(MEDIA_ROOT + path.sep)) throw validation();
|
||||
await writeFile(filePath, Buffer.from(await file.arrayBuffer()));
|
||||
await writeFile(filePath, bytes);
|
||||
return {
|
||||
before: null,
|
||||
after: { name, size: file.size, type: file.type },
|
||||
after: { name, size: file.size, type },
|
||||
output: { name, url: "/api/media/" + name },
|
||||
};
|
||||
}
|
||||
@@ -149,7 +274,11 @@ async function photoDelete(
|
||||
): Promise<ContentMutationSnapshot> {
|
||||
const id = Number(record(input).id);
|
||||
if (!Number.isSafeInteger(id) || id <= 0) throw validation();
|
||||
const [row] = await db.select({ id: CameraWeb.id, url: CameraWeb.url }).from(CameraWeb).where(eq(CameraWeb.id, id)).limit(1);
|
||||
const [row] = await db
|
||||
.select({ id: CameraWeb.id, url: CameraWeb.url })
|
||||
.from(CameraWeb)
|
||||
.where(eq(CameraWeb.id, id))
|
||||
.limit(1);
|
||||
if (!row) throw notFound();
|
||||
await db.delete(CameraWeb).where(eq(CameraWeb.id, id));
|
||||
await tryRemoveLocalPhotoFile(row.url);
|
||||
@@ -163,7 +292,9 @@ async function photoDelete(
|
||||
return { before: { id, url: row.url }, after: null };
|
||||
}
|
||||
|
||||
async function navigationUpdate(input: unknown): Promise<ContentMutationSnapshot> {
|
||||
async function navigationUpdate(
|
||||
input: unknown,
|
||||
): Promise<ContentMutationSnapshot> {
|
||||
const data = record(input);
|
||||
const raw = data.items ?? data.config ?? data;
|
||||
const config = jsonRecord(raw) as AdminNavConfig;
|
||||
@@ -197,9 +328,17 @@ async function themeUpdate(
|
||||
const raw = text(source[databaseKey], 255);
|
||||
if (raw && COLOR_RE.test(raw)) bag[key] = raw;
|
||||
}
|
||||
for (const [key, value] of Object.entries(ensureReadableThemeColors(bag))) {
|
||||
const databaseKey = settingKey(key as (typeof THEME_COLOR_KEYS)[number], mode);
|
||||
await transaction.insert(WebsiteSetting).values({ key: databaseKey, value, comment: "Theme (housekeeping)" }).onDuplicateKeyUpdate({ set: { value } });
|
||||
for (const [key, value] of Object.entries(
|
||||
ensureReadableThemeColors(bag),
|
||||
)) {
|
||||
const databaseKey = settingKey(
|
||||
key as (typeof THEME_COLOR_KEYS)[number],
|
||||
mode,
|
||||
);
|
||||
await transaction
|
||||
.insert(WebsiteSetting)
|
||||
.values({ key: databaseKey, value, comment: "Theme (housekeeping)" })
|
||||
.onDuplicateKeyUpdate({ set: { value } });
|
||||
changed.push(databaseKey);
|
||||
}
|
||||
}
|
||||
@@ -208,63 +347,121 @@ async function themeUpdate(
|
||||
const raw = text(source[key], 255);
|
||||
if (raw && COLOR_RE.test(raw)) adminBag[key] = raw;
|
||||
}
|
||||
for (const [key, value] of Object.entries(ensureReadableThemeColors(adminBag))) {
|
||||
await transaction.insert(WebsiteSetting).values({ key, value, comment: "Theme (housekeeping)" }).onDuplicateKeyUpdate({ set: { value } });
|
||||
for (const [key, value] of Object.entries(
|
||||
ensureReadableThemeColors(adminBag),
|
||||
)) {
|
||||
await transaction
|
||||
.insert(WebsiteSetting)
|
||||
.values({ key, value, comment: "Theme (housekeeping)" })
|
||||
.onDuplicateKeyUpdate({ set: { value } });
|
||||
changed.push(key);
|
||||
}
|
||||
const radius = text(source.border_radius, 3);
|
||||
if (/^\d{1,3}$/u.test(radius)) {
|
||||
await transaction.insert(WebsiteSetting).values({ key: "border_radius", value: radius, comment: "Theme (housekeeping)" }).onDuplicateKeyUpdate({ set: { value: radius } });
|
||||
await transaction
|
||||
.insert(WebsiteSetting)
|
||||
.values({
|
||||
key: "border_radius",
|
||||
value: radius,
|
||||
comment: "Theme (housekeeping)",
|
||||
})
|
||||
.onDuplicateKeyUpdate({ set: { value: radius } });
|
||||
changed.push("border_radius");
|
||||
}
|
||||
const font = text(source.font_family, 100);
|
||||
if (font in FONTS) {
|
||||
await transaction.insert(WebsiteSetting).values({ key: "font_family", value: font, comment: "Theme (housekeeping)" }).onDuplicateKeyUpdate({ set: { value: font } });
|
||||
await transaction
|
||||
.insert(WebsiteSetting)
|
||||
.values({
|
||||
key: "font_family",
|
||||
value: font,
|
||||
comment: "Theme (housekeeping)",
|
||||
})
|
||||
.onDuplicateKeyUpdate({ set: { value: font } });
|
||||
changed.push("font_family");
|
||||
}
|
||||
for (const key of HEADING_KEYS) {
|
||||
const value = text(source[key], 3);
|
||||
if (!/^\d{1,3}$/u.test(value)) continue;
|
||||
await transaction.insert(WebsiteSetting).values({ key, value, comment: "Theme (housekeeping)" }).onDuplicateKeyUpdate({ set: { value } });
|
||||
await transaction
|
||||
.insert(WebsiteSetting)
|
||||
.values({ key, value, comment: "Theme (housekeeping)" })
|
||||
.onDuplicateKeyUpdate({ set: { value } });
|
||||
changed.push(key);
|
||||
}
|
||||
if (Object.hasOwn(source, "custom_css")) {
|
||||
const value = String(source.custom_css ?? "").normalize("NFC").slice(0, 20_000);
|
||||
await transaction.insert(WebsiteSetting).values({ key: "custom_css", value, comment: "Theme (housekeeping)" }).onDuplicateKeyUpdate({ set: { value } });
|
||||
const value = String(source.custom_css ?? "")
|
||||
.normalize("NFC")
|
||||
.slice(0, 20_000);
|
||||
await transaction
|
||||
.insert(WebsiteSetting)
|
||||
.values({ key: "custom_css", value, comment: "Theme (housekeeping)" })
|
||||
.onDuplicateKeyUpdate({ set: { value } });
|
||||
changed.push("custom_css");
|
||||
}
|
||||
});
|
||||
const snapshot: ContentMutationSnapshot = { before: null, after: { changedKeys: changed.sort() } };
|
||||
const snapshot: ContentMutationSnapshot = {
|
||||
before: null,
|
||||
after: { changedKeys: changed.sort() },
|
||||
};
|
||||
try {
|
||||
siteSettings.reload();
|
||||
await logStaffActivity({ staffId: context.capability.actor.id, action: "theme_update", description: "Updated theme settings" });
|
||||
await logStaffActivity({
|
||||
staffId: context.capability.actor.id,
|
||||
action: "theme_update",
|
||||
description: "Updated theme settings",
|
||||
});
|
||||
} catch {
|
||||
throw new ContentCommittedExternalFailure(snapshot);
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
async function themeApplyPreset(input: unknown, context: ContentMutationContext): Promise<ContentMutationSnapshot> {
|
||||
async function themeApplyPreset(
|
||||
input: unknown,
|
||||
context: ContentMutationContext,
|
||||
): Promise<ContentMutationSnapshot> {
|
||||
const name = text(record(input).preset, 100, true);
|
||||
const preset = PRESETS[name];
|
||||
if (!preset) throw validation();
|
||||
await db.transaction(async (transaction) => {
|
||||
for (const [key, value] of presetSettings(preset)) {
|
||||
await transaction.insert(WebsiteSetting).values({ key, value, comment: "Theme (housekeeping)" }).onDuplicateKeyUpdate({ set: { value } });
|
||||
await transaction
|
||||
.insert(WebsiteSetting)
|
||||
.values({ key, value, comment: "Theme (housekeeping)" })
|
||||
.onDuplicateKeyUpdate({ set: { value } });
|
||||
}
|
||||
await transaction.insert(WebsiteSetting).values({ key: "theme_preset", value: name, comment: "Theme (housekeeping)" }).onDuplicateKeyUpdate({ set: { value: name } });
|
||||
await transaction
|
||||
.insert(WebsiteSetting)
|
||||
.values({
|
||||
key: "theme_preset",
|
||||
value: name,
|
||||
comment: "Theme (housekeeping)",
|
||||
})
|
||||
.onDuplicateKeyUpdate({ set: { value: name } });
|
||||
});
|
||||
const snapshot: ContentMutationSnapshot = { before: null, after: { preset: name }, output: { name } };
|
||||
const snapshot: ContentMutationSnapshot = {
|
||||
before: null,
|
||||
after: { preset: name },
|
||||
output: { name },
|
||||
};
|
||||
try {
|
||||
siteSettings.reload();
|
||||
await logStaffActivity({ staffId: context.capability.actor.id, action: "theme_preset", description: "Applied theme preset " + name });
|
||||
await logStaffActivity({
|
||||
staffId: context.capability.actor.id,
|
||||
action: "theme_preset",
|
||||
description: "Applied theme preset " + name,
|
||||
});
|
||||
} catch {
|
||||
throw new ContentCommittedExternalFailure(snapshot);
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
async function themeCustomChange(input: unknown, context: ContentMutationContext): Promise<ContentMutationSnapshot> {
|
||||
async function themeCustomChange(
|
||||
input: unknown,
|
||||
context: ContentMutationContext,
|
||||
): Promise<ContentMutationSnapshot> {
|
||||
const data = record(input);
|
||||
const action = text(data.action, 16, true);
|
||||
const id = text(data.id, 100);
|
||||
@@ -275,43 +472,81 @@ async function themeCustomChange(input: unknown, context: ContentMutationContext
|
||||
await deleteCustomThemeStore(id);
|
||||
return { before: { id, name: existing.name }, after: null };
|
||||
}
|
||||
if (action !== "create" && action !== "update" && action !== "rename") throw validation();
|
||||
if (action !== "create" && action !== "update" && action !== "rename")
|
||||
throw validation();
|
||||
const name = text(data.name, 100, true);
|
||||
const settings = data.values ? Object.fromEntries(Object.entries(jsonRecord(data.values)).map(([key, value]) => [key, String(value)])) : await snapshotCurrentTheme();
|
||||
const settings = data.values
|
||||
? Object.fromEntries(
|
||||
Object.entries(jsonRecord(data.values)).map(([key, value]) => [
|
||||
key,
|
||||
String(value),
|
||||
]),
|
||||
)
|
||||
: await snapshotCurrentTheme();
|
||||
const theme = await upsertCustomTheme(name, settings, id || undefined);
|
||||
await logStaffActivity({ staffId: context.capability.actor.id, action: "theme_preset", description: "Saved custom theme " + theme.name });
|
||||
return { before: id ? { id } : null, after: { id: theme.id, name: theme.name }, output: { id: theme.id, name: theme.name } };
|
||||
await logStaffActivity({
|
||||
staffId: context.capability.actor.id,
|
||||
action: "theme_preset",
|
||||
description: "Saved custom theme " + theme.name,
|
||||
});
|
||||
return {
|
||||
before: id ? { id } : null,
|
||||
after: { id: theme.id, name: theme.name },
|
||||
output: { id: theme.id, name: theme.name },
|
||||
};
|
||||
}
|
||||
|
||||
async function themeApplyCustom(input: unknown, context: ContentMutationContext): Promise<ContentMutationSnapshot> {
|
||||
async function themeApplyCustom(
|
||||
input: unknown,
|
||||
context: ContentMutationContext,
|
||||
): Promise<ContentMutationSnapshot> {
|
||||
const id = text(record(input).id, 100, true);
|
||||
const theme = await getCustomTheme(id);
|
||||
if (!theme) throw notFound();
|
||||
await db.transaction(async (transaction) => {
|
||||
for (const [key, value] of Object.entries(theme.settings)) {
|
||||
if (!value) continue;
|
||||
await transaction.insert(WebsiteSetting).values({ key, value, comment: "Theme (housekeeping)" }).onDuplicateKeyUpdate({ set: { value } });
|
||||
await transaction
|
||||
.insert(WebsiteSetting)
|
||||
.values({ key, value, comment: "Theme (housekeeping)" })
|
||||
.onDuplicateKeyUpdate({ set: { value } });
|
||||
}
|
||||
await transaction.insert(WebsiteSetting).values({ key: "theme_preset", value: theme.name, comment: "Theme (housekeeping)" }).onDuplicateKeyUpdate({ set: { value: theme.name } });
|
||||
await transaction
|
||||
.insert(WebsiteSetting)
|
||||
.values({
|
||||
key: "theme_preset",
|
||||
value: theme.name,
|
||||
comment: "Theme (housekeeping)",
|
||||
})
|
||||
.onDuplicateKeyUpdate({ set: { value: theme.name } });
|
||||
});
|
||||
const snapshot: ContentMutationSnapshot = { before: null, after: { id, name: theme.name }, output: { name: theme.name } };
|
||||
const snapshot: ContentMutationSnapshot = {
|
||||
before: null,
|
||||
after: { id, name: theme.name },
|
||||
output: { name: theme.name },
|
||||
};
|
||||
try {
|
||||
siteSettings.reload();
|
||||
await logStaffActivity({ staffId: context.capability.actor.id, action: "theme_preset", description: "Applied custom theme " + theme.name });
|
||||
await logStaffActivity({
|
||||
staffId: context.capability.actor.id,
|
||||
action: "theme_preset",
|
||||
description: "Applied custom theme " + theme.name,
|
||||
});
|
||||
} catch {
|
||||
throw new ContentCommittedExternalFailure(snapshot);
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function faviconExtension(type: string): string {
|
||||
return ({ "image/png": "png", "image/jpeg": "jpg", "image/gif": "gif", "image/webp": "webp", "image/x-icon": "ico", "image/svg+xml": "svg" } as Record<string, string>)[type] ?? "png";
|
||||
}
|
||||
|
||||
async function removeStoredAsset(url: string | null | undefined, directory: string, prefix: string): Promise<void> {
|
||||
async function removeStoredAsset(
|
||||
url: string | null | undefined,
|
||||
directory: string,
|
||||
prefix: string,
|
||||
): Promise<void> {
|
||||
if (!url?.startsWith(prefix)) return;
|
||||
const name = url.slice(prefix.length);
|
||||
if (!name || name.includes("..") || name.includes("/") || name.includes("\\")) return;
|
||||
if (!name || name.includes("..") || name.includes("/") || name.includes("\\"))
|
||||
return;
|
||||
const filePath = path.resolve(directory, name);
|
||||
if (!filePath.startsWith(directory + path.sep)) return;
|
||||
try {
|
||||
@@ -322,20 +557,40 @@ async function removeStoredAsset(url: string | null | undefined, directory: stri
|
||||
}
|
||||
|
||||
async function faviconSave(input: unknown): Promise<ContentMutationSnapshot> {
|
||||
const file = fileValue(record(input).file);
|
||||
if (file.size <= 0 || file.size > 2 * 1024 * 1024 || !FAVICON_TYPES.includes(file.type)) throw validation();
|
||||
const { bytes, extension } = await validatedImage(
|
||||
record(input).file,
|
||||
2 * 1024 * 1024,
|
||||
FAVICON_TYPES,
|
||||
);
|
||||
const directory = resolveMediaPath("favicon");
|
||||
const filename = "favicon-" + Date.now() + "." + faviconExtension(file.type);
|
||||
const filename = "favicon-" + Date.now() + "." + extension;
|
||||
const filePath = path.resolve(directory, filename);
|
||||
if (!filePath.startsWith(directory + path.sep)) throw validation();
|
||||
const oldUrl = await siteSettings.get("cms_favicon");
|
||||
await mkdir(directory, { recursive: true });
|
||||
await writeFile(filePath, Buffer.from(await file.arrayBuffer()));
|
||||
await removeStoredAsset(oldUrl, directory, "/api/media/favicon/");
|
||||
await writeFile(filePath, bytes);
|
||||
const url = "/api/media/favicon/" + filename;
|
||||
await writeWebsiteSetting("cms_favicon", url, "Favicon URL");
|
||||
const snapshot: ContentMutationSnapshot = { before: { value: oldUrl ?? null }, after: { value: url }, output: { url } };
|
||||
const snapshot: ContentMutationSnapshot = {
|
||||
before: { value: oldUrl ?? null },
|
||||
after: { value: url },
|
||||
output: { url },
|
||||
};
|
||||
try {
|
||||
await writeWebsiteSetting("cms_favicon", url, "Favicon URL");
|
||||
} catch (error) {
|
||||
try {
|
||||
await unlink(filePath);
|
||||
} catch {
|
||||
throw new ContentCommittedExternalFailure({
|
||||
before: { value: oldUrl ?? null },
|
||||
after: { value: oldUrl ?? null },
|
||||
output: { compensation: "failed" },
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
await removeStoredAsset(oldUrl, directory, "/api/media/favicon/");
|
||||
siteSettings.reload();
|
||||
} catch {
|
||||
throw new ContentCommittedExternalFailure(snapshot);
|
||||
@@ -345,10 +600,17 @@ async function faviconSave(input: unknown): Promise<ContentMutationSnapshot> {
|
||||
|
||||
async function faviconDelete(): Promise<ContentMutationSnapshot> {
|
||||
const oldUrl = await siteSettings.get("cms_favicon");
|
||||
await removeStoredAsset(oldUrl, resolveMediaPath("favicon"), "/api/media/favicon/");
|
||||
await db.delete(WebsiteSetting).where(eq(WebsiteSetting.key, "cms_favicon"));
|
||||
const snapshot: ContentMutationSnapshot = { before: { value: oldUrl ?? null }, after: null };
|
||||
const snapshot: ContentMutationSnapshot = {
|
||||
before: { value: oldUrl ?? null },
|
||||
after: null,
|
||||
};
|
||||
try {
|
||||
await removeStoredAsset(
|
||||
oldUrl,
|
||||
resolveMediaPath("favicon"),
|
||||
"/api/media/favicon/",
|
||||
);
|
||||
siteSettings.reload();
|
||||
} catch {
|
||||
throw new ContentCommittedExternalFailure(snapshot);
|
||||
@@ -357,19 +619,46 @@ async function faviconDelete(): Promise<ContentMutationSnapshot> {
|
||||
}
|
||||
|
||||
async function logoSave(input: unknown): Promise<ContentMutationSnapshot> {
|
||||
const file = fileValue(record(input).file);
|
||||
const extension = file.type === "image/png" ? "png" : file.type === "image/gif" ? "gif" : file.type === "image/jpeg" ? "jpg" : file.type === "image/webp" ? "webp" : "png";
|
||||
const { bytes, extension } = await validatedImage(
|
||||
record(input).file,
|
||||
5 * 1024 * 1024,
|
||||
MEDIA_TYPES,
|
||||
);
|
||||
const directory = resolveMediaPath("logo");
|
||||
const filename = "logo-" + Date.now() + "-" + Math.random().toString(36).slice(2, 8) + "." + extension;
|
||||
const filename =
|
||||
"logo-" +
|
||||
Date.now() +
|
||||
"-" +
|
||||
Math.random().toString(36).slice(2, 8) +
|
||||
"." +
|
||||
extension;
|
||||
const filePath = path.resolve(directory, filename);
|
||||
if (!filePath.startsWith(directory + path.sep)) throw validation();
|
||||
const oldUrl = await siteSettings.get("cms_logo");
|
||||
await mkdir(directory, { recursive: true });
|
||||
await writeFile(filePath, Buffer.from(await file.arrayBuffer()));
|
||||
await writeFile(filePath, bytes);
|
||||
const url = "/api/media/logo/" + filename;
|
||||
await writeWebsiteSetting("cms_logo", url, "Logo (generator)");
|
||||
const snapshot: ContentMutationSnapshot = { before: { value: oldUrl ?? null }, after: { value: url }, output: { url } };
|
||||
const snapshot: ContentMutationSnapshot = {
|
||||
before: { value: oldUrl ?? null },
|
||||
after: { value: url },
|
||||
output: { url },
|
||||
};
|
||||
try {
|
||||
await writeWebsiteSetting("cms_logo", url, "Logo (generator)");
|
||||
} catch (error) {
|
||||
try {
|
||||
await unlink(filePath);
|
||||
} catch {
|
||||
throw new ContentCommittedExternalFailure({
|
||||
before: { value: oldUrl ?? null },
|
||||
after: { value: oldUrl ?? null },
|
||||
output: { compensation: "failed" },
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
await removeStoredAsset(oldUrl, directory, "/api/media/logo/");
|
||||
siteSettings.reload();
|
||||
} catch {
|
||||
throw new ContentCommittedExternalFailure(snapshot);
|
||||
@@ -377,66 +666,123 @@ async function logoSave(input: unknown): Promise<ContentMutationSnapshot> {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
async function cmsTranslationSave(input: unknown): Promise<ContentMutationSnapshot> {
|
||||
async function cmsTranslationSave(
|
||||
input: unknown,
|
||||
): Promise<ContentMutationSnapshot> {
|
||||
const data = record(input);
|
||||
const locale = text(data.locale, 16, true);
|
||||
if (!CMS_LOCALES.has(locale)) throw validation();
|
||||
const translations = jsonRecord(data.data);
|
||||
const filePath = path.join(process.cwd(), "src", "messages", locale + ".json");
|
||||
const filePath = path.join(
|
||||
process.cwd(),
|
||||
"src",
|
||||
"messages",
|
||||
locale + ".json",
|
||||
);
|
||||
await writeFile(filePath, JSON.stringify(translations, null, 2), "utf-8");
|
||||
return { before: null, after: { locale, keyCount: Object.keys(translations).length } };
|
||||
return {
|
||||
before: null,
|
||||
after: { locale, keyCount: Object.keys(translations).length },
|
||||
};
|
||||
}
|
||||
|
||||
async function clientTranslationSave(input: unknown): Promise<ContentMutationSnapshot> {
|
||||
async function clientTranslationSave(
|
||||
input: unknown,
|
||||
): Promise<ContentMutationSnapshot> {
|
||||
const data = record(input);
|
||||
const fileId = text(data.fileId, 100, true);
|
||||
const translations = Object.fromEntries(Object.entries(jsonRecord(data.data)).map(([key, value]) => [key, String(value)]));
|
||||
const translations = Object.fromEntries(
|
||||
Object.entries(jsonRecord(data.data)).map(([key, value]) => [
|
||||
key,
|
||||
String(value),
|
||||
]),
|
||||
);
|
||||
const file = getClientTranslationFile(fileId);
|
||||
if (!file || file.readOnly) throw validation();
|
||||
const absolutePath = path.join(process.cwd(), file.relPath);
|
||||
const raw = await readFile(absolutePath, "utf-8");
|
||||
if (file.format === "json") {
|
||||
await writeFile(absolutePath, JSON.stringify(translations, null, 4), "utf-8");
|
||||
return { before: null, after: { fileId, keyCount: Object.keys(translations).length }, output: { commentsLost: false, unpatchedKeys: [] } };
|
||||
await writeFile(
|
||||
absolutePath,
|
||||
JSON.stringify(translations, null, 4),
|
||||
"utf-8",
|
||||
);
|
||||
return {
|
||||
before: null,
|
||||
after: { fileId, keyCount: Object.keys(translations).length },
|
||||
output: { commentsLost: false, unpatchedKeys: [] },
|
||||
};
|
||||
}
|
||||
const original: Record<string, string> = {};
|
||||
const parsed = JSONC.parse(raw);
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
for (const [key, value] of Object.entries(parsed)) original[key] = value == null ? "" : String(value);
|
||||
for (const [key, value] of Object.entries(parsed))
|
||||
original[key] = value == null ? "" : String(value);
|
||||
}
|
||||
const patched = patchJson5(raw, original, translations);
|
||||
if (patched.unpatchedKeys.length === 0) {
|
||||
await writeFile(absolutePath, patched.content, "utf-8");
|
||||
} else {
|
||||
await writeFile(absolutePath, JSON.stringify(translations, null, 4), "utf-8");
|
||||
await writeFile(
|
||||
absolutePath,
|
||||
JSON.stringify(translations, null, 4),
|
||||
"utf-8",
|
||||
);
|
||||
}
|
||||
return {
|
||||
before: null,
|
||||
after: { fileId, keyCount: Object.keys(translations).length },
|
||||
output: { commentsLost: patched.unpatchedKeys.length > 0, unpatchedKeys: patched.unpatchedKeys },
|
||||
output: {
|
||||
commentsLost: patched.unpatchedKeys.length > 0,
|
||||
unpatchedKeys: patched.unpatchedKeys,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function emulatorTranslationSave(input: unknown, context: ContentMutationContext): Promise<ContentMutationSnapshot> {
|
||||
async function emulatorTranslationSave(
|
||||
input: unknown,
|
||||
context: ContentMutationContext,
|
||||
): Promise<ContentMutationSnapshot> {
|
||||
const data = record(input);
|
||||
const source = data.settings ? jsonRecord(data.settings) : data.key ? { [text(data.key, 255, true)]: text(data.value, 20_000) } : jsonRecord(data);
|
||||
const entries = Object.entries(source).map(([key, value]) => [text(key, 255, true), String(value)] as const);
|
||||
const source = data.settings
|
||||
? jsonRecord(data.settings)
|
||||
: data.key
|
||||
? { [text(data.key, 100, true)]: text(data.value, 512) }
|
||||
: jsonRecord(data);
|
||||
const entries = Object.entries(source).map(
|
||||
([key, value]) => [text(key, 100, true), text(value, 512)] as const,
|
||||
);
|
||||
await db.transaction(async (transaction) => {
|
||||
for (const [key, value] of entries) {
|
||||
await transaction.insert(EmulatorSettings).values({ key, value }).onDuplicateKeyUpdate({ set: { value } });
|
||||
await transaction
|
||||
.insert(EmulatorSettings)
|
||||
.values({ key, value })
|
||||
.onDuplicateKeyUpdate({ set: { value } });
|
||||
}
|
||||
});
|
||||
const snapshot: ContentMutationSnapshot = { before: null, after: { keys: entries.map(([key]) => key).sort() } };
|
||||
const snapshot: ContentMutationSnapshot = {
|
||||
before: null,
|
||||
after: { keys: entries.map(([key]) => key).sort() },
|
||||
};
|
||||
try {
|
||||
const delivered = await rcon.updateConfig();
|
||||
if (!context.legacy && !delivered) throw new Error("emulator configuration sync failed");
|
||||
if (!context.legacy && !delivered)
|
||||
throw new Error("emulator configuration sync failed");
|
||||
} catch {
|
||||
throw new ContentCommittedExternalFailure(snapshot);
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
const EXTERNAL_HANDLERS: Partial<Record<ContentMutationOperation, (input: unknown, context: ContentMutationContext) => Promise<ContentMutationSnapshot>>> = {
|
||||
const EXTERNAL_HANDLERS: Partial<
|
||||
Record<
|
||||
ContentMutationOperation,
|
||||
(
|
||||
input: unknown,
|
||||
context: ContentMutationContext,
|
||||
) => Promise<ContentMutationSnapshot>
|
||||
>
|
||||
> = {
|
||||
"media.upload": (input) => mediaUpload(input),
|
||||
"media.delete": (input) => mediaDelete(input),
|
||||
"photo.delete": photoDelete,
|
||||
@@ -453,6 +799,15 @@ const EXTERNAL_HANDLERS: Partial<Record<ContentMutationOperation, (input: unknow
|
||||
"translation.emulator.save": emulatorTranslationSave,
|
||||
};
|
||||
|
||||
export async function executeLegacyBrandAssetMutation(
|
||||
operation: "favicon.save" | "favicon.delete" | "logo.save",
|
||||
input: unknown,
|
||||
): Promise<ContentMutationSnapshot> {
|
||||
if (operation === "favicon.save") return faviconSave(input);
|
||||
if (operation === "favicon.delete") return faviconDelete();
|
||||
return logoSave(input);
|
||||
}
|
||||
|
||||
export async function executeContentExternalMutation(
|
||||
operation: ContentMutationOperation,
|
||||
input: unknown,
|
||||
|
||||
Reference in new issue
Block a user