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 { if (typeof value !== "object" || value === null || Array.isArray(value)) throw validation(); return value as Record; } 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 { 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 (/^)/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 { await db .insert(WebsiteSetting) .values({ key, value, comment }) .onDuplicateKeyUpdate({ set: { value } }); } async function mediaUpload(input: unknown): Promise { 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 { 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 { 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 { 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 { 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 = {}; 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 = {}; 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 = {}; 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 { 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 > > = { "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 { 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 { const handler = EXTERNAL_HANDLERS[operation]; return handler ? handler(input, context) : null; }