import { randomUUID } from "node:crypto"; import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; import { basename, dirname, extname, join, resolve, sep } from "node:path"; const DEFAULT_ACCENT = "#f5f5f5"; export function createDeviceManagerPresentationStore({ layoutPath, uploadRoot, } = {}) { const resolvedLayoutPath = resolve(layoutPath || "runtime-data/device-manager-presentation.json"); const resolvedUploadRoot = resolve(uploadRoot || "runtime-data/device-manager-media"); return { mediaRoot: resolvedUploadRoot, async read() { const raw = await readFile(resolvedLayoutPath, "utf8").catch((error) => { if (error?.code === "ENOENT") return null; throw error; }); if (!raw) return defaultPresentation(); try { return normalizePresentation(JSON.parse(raw)); } catch { throw serviceError("device_manager_presentation_invalid", 500); } }, async write(next) { const normalized = normalizePresentation(next); await mkdir(dirname(resolvedLayoutPath), { recursive: true }); const temporaryPath = `${resolvedLayoutPath}.${process.pid}.${randomUUID()}.tmp`; await writeFile(temporaryPath, `${JSON.stringify(normalized, null, 2)}\n`, { mode: 0o640 }); await rename(temporaryPath, resolvedLayoutPath); return normalized; }, async saveMedia({ bytes, contentType, originalName, kind }) { const extension = allowedExtension(contentType, originalName, kind); await mkdir(resolvedUploadRoot, { recursive: true }); const fileName = `${kind}-${randomUUID()}${extension}`; await writeFile(join(resolvedUploadRoot, fileName), bytes, { flag: "wx", mode: 0o640 }); return { fileName: String(originalName || fileName).slice(0, 180), fileSrc: `/device-manager-media/${fileName}`, }; }, resolveMedia(pathname) { const encodedName = pathname.match(/^\/device-manager-media\/([^/]+)$/)?.[1]; if (!encodedName) return null; const name = basename(decodeURIComponent(encodedName)); if (!/^[a-z]+-[0-9a-f-]+\.(?:png|jpe?g|webp|gif|avif|mp4|webm|mov)$/i.test(name)) return null; const candidate = resolve(resolvedUploadRoot, name); return candidate.startsWith(`${resolvedUploadRoot}${sep}`) ? candidate : null; }, }; } export function defaultPresentation() { return { environment: { theme: "dark", accentHex: DEFAULT_ACCENT, overview: defaultOverview(), }, projects: {}, }; } export function normalizeProjectPresentation(value) { return { icon: normalizeMedia(value?.icon), teaser: normalizeMedia(value?.teaser), }; } export function normalizeEnvironmentPresentation(value) { const legacyTeaser = normalizeMedia(value?.defaultTeaser); return { theme: value?.theme === "light" ? "light" : "dark", accentHex: /^#[0-9a-f]{6}$/i.test(String(value?.accentHex || "")) ? String(value.accentHex).toLowerCase() : DEFAULT_ACCENT, overview: normalizeOverview(value?.overview, legacyTeaser), }; } function defaultOverview() { return { headerLabel: "Device Core", eyebrow: "NODEDC / DEVICE CORE", title: "Device Core", description: "Единый контур подключения, учёта и управления устройствами.", primarySection: "devices", secondarySection: null, background: { enabled: false, imageDurationSeconds: 10, items: [], }, }; } function normalizeOverview(value, legacyTeaser) { const fallback = defaultOverview(); const legacySource = mediaSource(legacyTeaser); const legacyItems = legacySource ? [{ id: "legacy-overview-media", ...legacyTeaser, mediaKind: inferMediaKind(legacySource), }] : []; const sourceItems = Array.isArray(value?.background?.items) ? value.background.items.slice(0, 24) : legacyItems; const items = sourceItems .map(normalizeEnvironmentMediaItem) .filter(Boolean); return { headerLabel: normalizeCopy(value?.headerLabel, fallback.headerLabel, 40), eyebrow: normalizeCopy(value?.eyebrow, fallback.eyebrow, 80), title: normalizeCopy(value?.title, fallback.title, 120), description: normalizeCopy(value?.description, fallback.description, 500), primarySection: normalizeSection(value?.primarySection, fallback.primarySection), secondarySection: normalizeSection(value?.secondarySection, fallback.secondarySection), background: { enabled: value?.background ? Boolean(value.background.enabled) : legacyItems.length > 0, imageDurationSeconds: clampInteger(value?.background?.imageDurationSeconds, 1, 60, 10), items, }, }; } function normalizeEnvironmentMediaItem(value) { const media = normalizeMedia(value); const source = mediaSource(media); if (!source && !value?.url && !value?.fileSrc) return null; return { id: /^[a-z0-9][a-z0-9._:-]{0,127}$/i.test(String(value?.id || "")) ? String(value.id) : randomUUID(), ...media, mediaKind: value?.mediaKind === "image" || value?.mediaKind === "video" ? value.mediaKind : inferMediaKind(source), }; } function normalizeCopy(value, fallback, maxLength) { const normalized = String(value || "").trim(); return (normalized || fallback).slice(0, maxLength); } function normalizeSection(value, fallback) { const allowed = new Set(["overview", "devices", "infrastructure", "management", "administration"]); if (value === undefined) return fallback; if (value === null || value === "none") return null; return allowed.has(value) ? value : fallback; } function clampInteger(value, minimum, maximum, fallback) { const normalized = Number.parseInt(String(value), 10); return Number.isInteger(normalized) ? Math.min(maximum, Math.max(minimum, normalized)) : fallback; } function normalizePresentation(value) { const projects = {}; if (value?.projects && typeof value.projects === "object" && !Array.isArray(value.projects)) { for (const [projectRef, presentation] of Object.entries(value.projects)) { if (/^project:[0-9a-f-]{36}$/i.test(projectRef)) { projects[projectRef.toLowerCase()] = normalizeProjectPresentation(presentation); } } } return { environment: normalizeEnvironmentPresentation(value?.environment), projects, }; } function normalizeMedia(value) { const source = value?.source === "url" ? "url" : "file"; const url = source === "url" ? safeExternalUrl(value?.url) : ""; const fileSrc = source === "file" && /^\/device-manager-media\/[a-z0-9._-]+$/i.test(String(value?.fileSrc || "")) ? String(value.fileSrc) : null; return { source, url, fileName: fileSrc ? String(value?.fileName || basename(fileSrc)).slice(0, 180) : null, fileSrc, }; } function safeExternalUrl(value) { const candidate = String(value || "").trim(); if (!candidate) return ""; try { const url = new URL(candidate); return url.protocol === "https:" || url.protocol === "http:" ? url.toString() : ""; } catch { return ""; } } function emptyMedia() { return { source: "file", url: "", fileName: null, fileSrc: null }; } function mediaSource(value) { if (!value) return null; return value.source === "url" ? value.url || null : value.fileSrc; } function inferMediaKind(value) { const pathname = (() => { try { return new URL(String(value || ""), "http://localhost").pathname; } catch { return String(value || ""); } })(); return /\.(?:png|jpe?g|webp|gif|avif)$/i.test(pathname) ? "image" : "video"; } function allowedExtension(contentType, originalName, kind) { const normalized = String(contentType || "").split(";", 1)[0].trim().toLowerCase(); const imageTypes = new Map([["image/png", ".png"], ["image/jpeg", ".jpg"], ["image/webp", ".webp"], ["image/gif", ".gif"], ["image/avif", ".avif"]]); const videoTypes = new Map([ ["video/mp4", ".mp4"], ["video/webm", ".webm"], ["video/quicktime", ".mov"], ["video/x-quicktime", ".mov"], ]); const allowed = kind === "icon" ? imageTypes : kind === "teaser" ? videoTypes : new Map([...imageTypes, ...videoTypes]); const suppliedExtension = extname(String(originalName || "")).toLowerCase(); const extensionFallback = new Map([ [".png", ".png"], [".jpg", ".jpg"], [".jpeg", ".jpg"], [".webp", ".webp"], [".gif", ".gif"], [".avif", ".avif"], [".mp4", ".mp4"], [".webm", ".webm"], [".mov", ".mov"], ]); const extension = allowed.get(normalized) || (!normalized || normalized === "application/octet-stream" ? extensionFallback.get(suppliedExtension) : null); if (!extension) throw serviceError("device_manager_media_type_forbidden", 415); if (suppliedExtension && kind === "icon" && ![".png", ".jpg", ".jpeg", ".webp", ".gif", ".avif"].includes(suppliedExtension)) { throw serviceError("device_manager_media_extension_forbidden", 415); } if (suppliedExtension && kind === "teaser" && ![".mp4", ".webm", ".mov"].includes(suppliedExtension)) { throw serviceError("device_manager_media_extension_forbidden", 415); } return extension; } function serviceError(code, statusCode) { const error = new Error(code); error.statusCode = statusCode; return error; }