819 lines
22 KiB
TypeScript
819 lines
22 KiB
TypeScript
import "server-only";
|
|
|
|
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { eq } from "drizzle-orm";
|
|
import * as JSONC from "jsonc-parser";
|
|
import { tryRemoveLocalPhotoFile } from "@/lib/admin/photo-files";
|
|
import {
|
|
ADMIN_NAV_CONFIG_KEY,
|
|
type AdminNavConfig,
|
|
serializeAdminNavConfig,
|
|
} from "@/lib/admin-nav-config";
|
|
import { getClientTranslationFile } from "@/lib/client-translation-files";
|
|
import { CameraWeb, db, EmulatorSettings, WebsiteSetting } from "@/lib/db";
|
|
import { patchJson5 } from "@/lib/json5-patch";
|
|
import { MEDIA_ROOT, resolveMediaPath } from "@/lib/media-storage";
|
|
import { rcon } from "@/lib/services/rcon";
|
|
import { siteSettings } from "@/lib/services/site-settings";
|
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
|
import { ensureReadableThemeColors } from "@/lib/theme-contrast";
|
|
import {
|
|
deleteCustomThemeStore,
|
|
getCustomTheme,
|
|
snapshotCurrentTheme,
|
|
upsertCustomTheme,
|
|
} from "@/lib/theme-custom-store";
|
|
import { FONTS, PRESETS, THEME_COLOR_KEYS } from "@/lib/theme-presets";
|
|
import { presetSettings, settingKey } from "@/lib/theme-settings";
|
|
import {
|
|
type ContentMutationContext,
|
|
ContentMutationFailure,
|
|
type ContentMutationOperation,
|
|
type ContentMutationSnapshot,
|
|
} from "./mutations";
|
|
import { ContentCommittedExternalFailure } from "./mutations-production";
|
|
|
|
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",
|
|
]);
|
|
const COLOR_RE = /^[#a-zA-Z0-9(),.\s%-]+$/;
|
|
const ADMIN_COLOR_KEYS = [
|
|
"admin_canvas",
|
|
"admin_surface",
|
|
"admin_text",
|
|
"admin_text_muted",
|
|
"admin_border",
|
|
"admin_sidebar_bg",
|
|
] 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",
|
|
);
|
|
}
|
|
|
|
function notFound(): ContentMutationFailure {
|
|
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();
|
|
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);
|
|
if (required && !normalized) throw validation();
|
|
return normalized;
|
|
}
|
|
|
|
function jsonRecord(value: unknown): Record<string, unknown> {
|
|
if (typeof value === "string") {
|
|
try {
|
|
return record(JSON.parse(value));
|
|
} catch {
|
|
throw validation();
|
|
}
|
|
}
|
|
return record(value);
|
|
}
|
|
|
|
function fileValue(value: unknown): File {
|
|
if (
|
|
typeof value !== "object" ||
|
|
value === null ||
|
|
typeof (value as File).arrayBuffer !== "function" ||
|
|
typeof (value as File).name !== "string" ||
|
|
typeof (value as File).type !== "string" ||
|
|
typeof (value as File).size !== "number"
|
|
) {
|
|
throw validation();
|
|
}
|
|
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,
|
|
comment: string,
|
|
): Promise<void> {
|
|
await db
|
|
.insert(WebsiteSetting)
|
|
.values({ key, value, comment })
|
|
.onDuplicateKeyUpdate({ set: { value } });
|
|
}
|
|
|
|
async function mediaUpload(input: unknown): Promise<ContentMutationSnapshot> {
|
|
const { file, bytes, type, extension } = await validatedImage(
|
|
record(input).file,
|
|
5 * 1024 * 1024,
|
|
MEDIA_TYPES,
|
|
);
|
|
await mkdir(MEDIA_ROOT, { recursive: true });
|
|
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, bytes);
|
|
return {
|
|
before: null,
|
|
after: { name, size: file.size, type },
|
|
output: { name, url: "/api/media/" + name },
|
|
};
|
|
}
|
|
|
|
async function mediaDelete(input: unknown): Promise<ContentMutationSnapshot> {
|
|
const name = text(record(input).filename ?? record(input).name, 255, true);
|
|
let filePath: string;
|
|
try {
|
|
filePath = resolveMediaPath(name);
|
|
} catch {
|
|
throw validation();
|
|
}
|
|
if (!filePath.startsWith(MEDIA_ROOT + path.sep)) throw validation();
|
|
try {
|
|
await unlink(filePath);
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
}
|
|
return { before: { name }, after: null };
|
|
}
|
|
|
|
async function photoDelete(
|
|
input: unknown,
|
|
context: ContentMutationContext,
|
|
): 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);
|
|
if (!row) throw notFound();
|
|
await db.delete(CameraWeb).where(eq(CameraWeb.id, id));
|
|
await tryRemoveLocalPhotoFile(row.url);
|
|
await logStaffActivity({
|
|
staffId: context.capability.actor.id,
|
|
action: "photo_delete",
|
|
description: "Deleted camera photo #" + id,
|
|
targetType: "camera_web",
|
|
targetId: id,
|
|
});
|
|
return { before: { id, url: row.url }, after: null };
|
|
}
|
|
|
|
async function navigationUpdate(
|
|
input: unknown,
|
|
): Promise<ContentMutationSnapshot> {
|
|
const data = record(input);
|
|
const raw = data.items ?? data.config ?? data;
|
|
const config = jsonRecord(raw) as AdminNavConfig;
|
|
const value = serializeAdminNavConfig(config);
|
|
const beforeValue = await siteSettings.get(ADMIN_NAV_CONFIG_KEY, null);
|
|
await siteSettings.update(ADMIN_NAV_CONFIG_KEY, value);
|
|
return {
|
|
before: { configured: Boolean(beforeValue) },
|
|
after: {
|
|
configured: true,
|
|
groups: config.groupOrder?.length ?? 0,
|
|
hiddenGroups: config.hiddenGroups?.length ?? 0,
|
|
hiddenItems: config.hiddenItems?.length ?? 0,
|
|
},
|
|
output: { saved: true },
|
|
};
|
|
}
|
|
|
|
async function themeUpdate(
|
|
input: unknown,
|
|
context: ContentMutationContext,
|
|
): Promise<ContentMutationSnapshot> {
|
|
const data = record(input);
|
|
const source = jsonRecord(data.values ?? data);
|
|
const changed: string[] = [];
|
|
await db.transaction(async (transaction) => {
|
|
for (const mode of ["light", "dark"] as const) {
|
|
const bag: Record<string, string> = {};
|
|
for (const key of THEME_COLOR_KEYS) {
|
|
const databaseKey = settingKey(key, mode);
|
|
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 } });
|
|
changed.push(databaseKey);
|
|
}
|
|
}
|
|
const adminBag: Record<string, string> = {};
|
|
for (const key of ADMIN_COLOR_KEYS) {
|
|
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 } });
|
|
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 } });
|
|
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 } });
|
|
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 } });
|
|
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 } });
|
|
changed.push("custom_css");
|
|
}
|
|
});
|
|
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",
|
|
});
|
|
} catch {
|
|
throw new ContentCommittedExternalFailure(snapshot);
|
|
}
|
|
return snapshot;
|
|
}
|
|
|
|
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: "theme_preset",
|
|
value: name,
|
|
comment: "Theme (housekeeping)",
|
|
})
|
|
.onDuplicateKeyUpdate({ set: { value: 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,
|
|
});
|
|
} catch {
|
|
throw new ContentCommittedExternalFailure(snapshot);
|
|
}
|
|
return snapshot;
|
|
}
|
|
|
|
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);
|
|
if (action === "delete") {
|
|
if (!id) throw validation();
|
|
const existing = await getCustomTheme(id);
|
|
if (!existing) throw notFound();
|
|
await deleteCustomThemeStore(id);
|
|
return { before: { id, name: existing.name }, after: null };
|
|
}
|
|
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 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 },
|
|
};
|
|
}
|
|
|
|
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: "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 },
|
|
};
|
|
try {
|
|
siteSettings.reload();
|
|
await logStaffActivity({
|
|
staffId: context.capability.actor.id,
|
|
action: "theme_preset",
|
|
description: "Applied custom theme " + theme.name,
|
|
});
|
|
} catch {
|
|
throw new ContentCommittedExternalFailure(snapshot);
|
|
}
|
|
return snapshot;
|
|
}
|
|
|
|
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;
|
|
const filePath = path.resolve(directory, name);
|
|
if (!filePath.startsWith(directory + path.sep)) return;
|
|
try {
|
|
await unlink(filePath);
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
}
|
|
}
|
|
|
|
async function faviconSave(input: unknown): Promise<ContentMutationSnapshot> {
|
|
const { bytes, extension } = await validatedImage(
|
|
record(input).file,
|
|
2 * 1024 * 1024,
|
|
FAVICON_TYPES,
|
|
);
|
|
const directory = resolveMediaPath("favicon");
|
|
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, bytes);
|
|
const url = "/api/media/favicon/" + filename;
|
|
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);
|
|
}
|
|
return snapshot;
|
|
}
|
|
|
|
async function faviconDelete(): Promise<ContentMutationSnapshot> {
|
|
const oldUrl = await siteSettings.get("cms_favicon");
|
|
await db.delete(WebsiteSetting).where(eq(WebsiteSetting.key, "cms_favicon"));
|
|
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);
|
|
}
|
|
return snapshot;
|
|
}
|
|
|
|
async function logoSave(input: unknown): Promise<ContentMutationSnapshot> {
|
|
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 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, bytes);
|
|
const url = "/api/media/logo/" + filename;
|
|
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);
|
|
}
|
|
return snapshot;
|
|
}
|
|
|
|
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",
|
|
);
|
|
await writeFile(filePath, JSON.stringify(translations, null, 2), "utf-8");
|
|
return {
|
|
before: null,
|
|
after: { locale, keyCount: Object.keys(translations).length },
|
|
};
|
|
}
|
|
|
|
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 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: [] },
|
|
};
|
|
}
|
|
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);
|
|
}
|
|
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",
|
|
);
|
|
}
|
|
return {
|
|
before: null,
|
|
after: { fileId, keyCount: Object.keys(translations).length },
|
|
output: {
|
|
commentsLost: patched.unpatchedKeys.length > 0,
|
|
unpatchedKeys: patched.unpatchedKeys,
|
|
},
|
|
};
|
|
}
|
|
|
|
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, 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 } });
|
|
}
|
|
});
|
|
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");
|
|
} catch {
|
|
throw new ContentCommittedExternalFailure(snapshot);
|
|
}
|
|
return snapshot;
|
|
}
|
|
|
|
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,
|
|
"navigation.update": (input) => navigationUpdate(input),
|
|
"theme.update": themeUpdate,
|
|
"theme.apply-preset": themeApplyPreset,
|
|
"theme.custom-change": themeCustomChange,
|
|
"theme.apply-custom": themeApplyCustom,
|
|
"favicon.save": (input) => faviconSave(input),
|
|
"favicon.delete": () => faviconDelete(),
|
|
"logo.save": (input) => logoSave(input),
|
|
"translation.cms.save": (input) => cmsTranslationSave(input),
|
|
"translation.client.save": (input) => clientTranslationSave(input),
|
|
"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,
|
|
context: ContentMutationContext,
|
|
): Promise<ContentMutationSnapshot | null> {
|
|
const handler = EXTERNAL_HANDLERS[operation];
|
|
return handler ? handler(input, context) : null;
|
|
}
|