feat: establish standalone Device Core repository
This commit is contained in:
@@ -0,0 +1,261 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user