262 lines
9.1 KiB
JavaScript
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;
|
|
}
|