Files
NODEDC_DEVICE_CORE/apps/device-manager/server/device-manager-presentation.mjs
T

262 lines
9.1 KiB
JavaScript

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 = "#b9ff4a";
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;
}