NODEDC_SITE/tools/admin-server.mjs

1581 lines
48 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { createServer } from "node:http";
import { createReadStream } from "node:fs";
import { copyFile, cp, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
import { extname, join, normalize, relative } from "node:path";
import { fileURLToPath } from "node:url";
import { spawn } from "node:child_process";
import { dirname } from "node:path";
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
const port = Number(process.env.PORT || 8090);
const host = process.env.HOST || "127.0.0.1";
const pagePath = join(repoRoot, "content", "pages", "home.json");
const blockTemplatesPath = join(repoRoot, "content", "block-templates", "home.json");
const assetRoot = join(repoRoot, "assets");
const assetTrashRoot = join(assetRoot, ".trash");
const uploadRoot = join(repoRoot, "assets", "uploads");
const legacyUploadTrashRoot = join(uploadRoot, ".trash");
const trashStores = [
{
id: "assets",
rootPath: assetTrashRoot,
restoreBasePath: assetRoot,
urlPrefix: "./assets/.trash",
displayPrefix: "assets/.trash",
},
{
id: "uploads-legacy",
rootPath: legacyUploadTrashRoot,
restoreBasePath: uploadRoot,
urlPrefix: "./assets/uploads/.trash",
displayPrefix: "assets/uploads/.trash",
},
];
const mediaRoots = [
{
id: "uploads",
label: "Uploads",
rootPath: uploadRoot,
urlPrefix: "./assets/uploads",
writable: true,
},
{
id: "media",
label: "Legacy media",
rootPath: join(repoRoot, "assets", "media"),
urlPrefix: "./assets/media",
writable: true,
},
{
id: "site-images",
label: "Site images",
rootPath: join(repoRoot, "assets", "site", "images"),
urlPrefix: "./assets/site/images",
writable: true,
},
{
id: "custom-assets",
label: "Custom assets",
rootPath: join(repoRoot, "assets", "custom"),
urlPrefix: "./assets/custom",
writable: true,
},
];
const MEDIA_EXTENSIONS = new Set([
".avif",
".gif",
".glb",
".gltf",
".ico",
".json",
".jpeg",
".jpg",
".m4v",
".mov",
".mp4",
".png",
".svg",
".webmanifest",
".webm",
".webp",
".woff",
".woff2",
]);
const BROWSER_EXCLUDED_DIRS = new Set([".git", ".trash", "node_modules"]);
const REFERENCE_TEXT_EXTENSIONS = new Set([
".css",
".html",
".js",
".json",
".jsx",
".md",
".mjs",
".svg",
".ts",
".tsx",
".txt",
".webmanifest",
".xml",
]);
const MIME = {
".html": "text/html; charset=utf-8",
".css": "text/css; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".svg": "image/svg+xml",
".ico": "image/x-icon",
".webmanifest": "application/manifest+json; charset=utf-8",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".avif": "image/avif",
".webp": "image/webp",
".mp4": "video/mp4",
".webm": "video/webm",
".mov": "video/quicktime",
".m4v": "video/x-m4v",
".avi": "video/x-msvideo",
".mkv": "video/x-matroska",
".glb": "model/gltf-binary",
".gltf": "model/gltf+json",
".woff": "font/woff",
".woff2": "font/woff2",
};
function send(res, status, body, contentType = "text/plain; charset=utf-8") {
res.writeHead(status, {
"content-type": contentType,
"cache-control": "no-store",
});
res.end(body);
}
function sendStream(req, res, status, stream, headers) {
res.writeHead(status, {
"cache-control": "no-store",
"accept-ranges": "bytes",
...headers,
});
if (req.method === "HEAD") {
stream.destroy();
res.end();
return;
}
stream.on("error", () => {
if (!res.headersSent) {
send(res, 500, "Failed to read file");
return;
}
res.destroy();
});
stream.pipe(res);
}
function contentTypeForPath(filePath) {
const normalizedPath = String(filePath || "").toLowerCase();
if (normalizedPath.endsWith(".webmanifest") || normalizedPath.endsWith(".webmanifest.json")) {
return "application/manifest+json; charset=utf-8";
}
return MIME[extname(filePath).toLowerCase()] || "application/octet-stream";
}
function sendJson(res, status, payload) {
send(res, status, `${JSON.stringify(payload, null, 2)}\n`, "application/json; charset=utf-8");
}
async function readBodyBuffer(req) {
const chunks = [];
for await (const chunk of req) {
chunks.push(chunk);
}
return Buffer.concat(chunks);
}
async function readBody(req) {
return (await readBodyBuffer(req)).toString("utf8");
}
function runNodeScript(scriptName) {
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, [join(repoRoot, "tools", scriptName)], {
cwd: repoRoot,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk) => {
stdout += chunk.toString();
});
child.stderr.on("data", (chunk) => {
stderr += chunk.toString();
});
child.on("error", reject);
child.on("close", (code) => {
if (code === 0) {
resolve({ stdout, stderr });
} else {
reject(new Error(stderr || stdout || `${scriptName} exited with ${code}`));
}
});
});
}
function isUploadPayload(payload) {
return (
payload &&
typeof payload.fileName === "string" &&
typeof payload.dataUrl === "string" &&
(!payload.mimeType || typeof payload.mimeType === "string") &&
(!payload.bucket || typeof payload.bucket === "string")
);
}
function sanitizePathSegment(value) {
const safe = value
.normalize("NFKD")
.replace(/[^\w.-]+/g, "-")
.replace(/^-+|-+$/g, "")
.toLowerCase();
return safe || "media";
}
function sanitizeUploadBucket(value) {
const segments = String(value || "media")
.split(/[\\/]+/)
.map((segment) => sanitizePathSegment(segment))
.filter(Boolean)
.slice(0, 4);
return segments.length ? segments.join("/") : "media";
}
function extensionFromMimeType(mimeType) {
const map = {
"image/svg+xml": ".svg",
"image/x-icon": ".ico",
"image/vnd.microsoft.icon": ".ico",
"image/png": ".png",
"image/jpeg": ".jpg",
"image/gif": ".gif",
"image/avif": ".avif",
"image/webp": ".webp",
"video/mp4": ".mp4",
"video/webm": ".webm",
"video/quicktime": ".mov",
"application/manifest+json": ".webmanifest",
"application/json": ".json",
"model/gltf-binary": ".glb",
"model/gltf+json": ".gltf",
"font/woff": ".woff",
"font/woff2": ".woff2",
};
return map[mimeType] || "";
}
function buildStoredFileName(fileName, mimeType) {
const extension = (extname(fileName) || extensionFromMimeType(mimeType) || ".bin").toLowerCase();
const base = fileName.slice(0, extension ? -extension.length : undefined);
const safeBase =
base
.normalize("NFKD")
.replace(/[^\w.-]+/g, "-")
.replace(/^-+|-+$/g, "")
.toLowerCase()
.slice(0, 72) || "asset";
return `${safeBase}-${Date.now().toString(36)}${extension}`;
}
async function saveUploadedAssetBuffer({ fileName, mimeType, bucket, fileBuffer }) {
if (typeof fileName !== "string" || !fileName || !Buffer.isBuffer(fileBuffer)) {
throw new Error("Некорректный файл загрузки");
}
const resolvedMimeType = mimeType || "application/octet-stream";
const resolvedBucket = sanitizeUploadBucket(bucket || "media");
const storedName = buildStoredFileName(fileName, resolvedMimeType);
const directory = join(uploadRoot, ...resolvedBucket.split("/"));
await mkdir(directory, { recursive: true });
await writeFile(join(directory, storedName), fileBuffer);
return {
ok: true,
url: `./assets/uploads/${resolvedBucket}/${storedName}`,
fileName: storedName,
originalFileName: fileName,
mimeType: resolvedMimeType,
bucket: resolvedBucket,
};
}
async function saveUploadedAsset(payload) {
if (!isUploadPayload(payload)) {
throw new Error("Некорректный payload загрузки");
}
const match = /^data:([^;,]+)?(?:;[^,]*)?;base64,(.*)$/s.exec(payload.dataUrl);
if (!match) {
throw new Error("Файл должен прийти data-url с base64");
}
const mimeType = payload.mimeType || match[1] || "application/octet-stream";
const fileBuffer = Buffer.from(match[2], "base64");
return saveUploadedAssetBuffer({
fileName: payload.fileName,
mimeType,
bucket: payload.bucket || "media",
fileBuffer,
});
}
function normalizeAssetRef(value) {
let url = String(value || "").trim();
if (!url) return null;
url = url.split("#")[0].split("?")[0].replace(/^\/+/, "");
if (url.startsWith("./")) url = url.slice(2);
try {
url = decodeURIComponent(url);
} catch {
// Keep the raw path when it contains a malformed escape sequence.
}
if (!url.startsWith("assets/")) return null;
const normalized = normalize(url).replace(/\\/g, "/");
if (!normalized.startsWith("assets/") || normalized.includes("../")) return null;
return normalized;
}
function collectAssetRefsFromString(value, refs) {
for (const part of String(value || "").split(",")) {
const token = part.trim().split(/\s+/)[0];
const ref = normalizeAssetRef(token);
if (ref) refs.add(ref);
}
}
function collectAssetRefsFromValue(value, refs = new Set()) {
if (Array.isArray(value)) {
value.forEach((item) => collectAssetRefsFromValue(item, refs));
return refs;
}
if (value && typeof value === "object") {
Object.values(value).forEach((item) => collectAssetRefsFromValue(item, refs));
return refs;
}
if (typeof value === "string") {
collectAssetRefsFromString(value, refs);
}
return refs;
}
function collectAssetRefsFromText(text) {
const refs = new Set();
const matches = String(text || "").matchAll(/(?:\.\/)?assets\/[^\s"'<>),]+/g);
for (const match of matches) {
collectAssetRefsFromString(match[0], refs);
}
return refs;
}
async function buildMediaUsage() {
const [pageSource, indexSource] = await Promise.all([
readFile(pagePath, "utf8").catch(() => "{}"),
readFile(join(repoRoot, "index.html"), "utf8").catch(() => ""),
]);
const pageRefs = collectAssetRefsFromValue(JSON.parse(pageSource));
const renderedRefs = collectAssetRefsFromText(indexSource);
return { pageRefs, renderedRefs };
}
function assetReferenceReplacementPairs(fromRef, toRef) {
const encodedFrom = encodeUrlPath(fromRef);
const encodedTo = encodeUrlPath(toRef);
const rawPairs = [
[`./${encodedFrom}`, `./${encodedTo}`],
[`./${fromRef}`, `./${toRef}`],
[`/${encodedFrom}`, `/${encodedTo}`],
[`/${fromRef}`, `/${toRef}`],
[encodedFrom, encodedTo],
[fromRef, toRef],
];
const seen = new Set();
return rawPairs
.filter(([from]) => {
if (seen.has(from)) return false;
seen.add(from);
return true;
})
.sort((left, right) => right[0].length - left[0].length);
}
function replaceAssetReferencesInString(value, replacements) {
let nextValue = value;
for (const replacement of replacements) {
for (const [fromVariant, toVariant] of assetReferenceReplacementPairs(replacement.from, replacement.to)) {
nextValue = nextValue.split(fromVariant).join(toVariant);
}
}
return nextValue;
}
function shouldScanReferenceFile(relativePath) {
const segments = pathSegments(relativePath);
if (segments.some((segment) => BROWSER_EXCLUDED_DIRS.has(segment))) return false;
return REFERENCE_TEXT_EXTENSIONS.has(extname(relativePath).toLowerCase());
}
async function applyAssetReferenceReplacements(replacements) {
const normalizedReplacements = replacements
.map(({ from, to }) => ({ from: normalizeAssetRef(from), to: normalizeAssetRef(to) }))
.filter((replacement) => replacement.from && replacement.to && replacement.from !== replacement.to);
if (!normalizedReplacements.length) return [];
const updatedFiles = [];
async function walk(directory) {
const entries = await readdir(directory, { withFileTypes: true });
for (const entry of entries) {
if (BROWSER_EXCLUDED_DIRS.has(entry.name)) continue;
const absolutePath = join(directory, entry.name);
const relativePath = relative(repoRoot, absolutePath).replace(/\\/g, "/");
if (entry.isDirectory()) {
await walk(absolutePath);
continue;
}
if (!entry.isFile() || !shouldScanReferenceFile(relativePath)) continue;
const source = await readFile(absolutePath, "utf8").catch(() => null);
if (source === null) continue;
const nextSource = replaceAssetReferencesInString(source, normalizedReplacements);
if (nextSource === source) continue;
await writeFile(absolutePath, nextSource);
updatedFiles.push(relativePath);
}
}
await walk(repoRoot);
return updatedFiles.sort((left, right) => left.localeCompare(right, "ru", { numeric: true, sensitivity: "base" }));
}
async function assetReferenceReplacementsForMove(relativePath, nextRelativePath, absolutePath, entryStat) {
const sourceRef = normalizeAssetRef(relativePath);
const targetRef = normalizeAssetRef(nextRelativePath);
if (!sourceRef) return [];
if (!targetRef) {
throw new Error("Медиа из assets можно переносить только внутри assets, чтобы публичные ссылки оставались рабочими");
}
if (entryStat.isFile()) {
return [{ from: sourceRef, to: targetRef }];
}
if (!entryStat.isDirectory()) return [];
const replacements = [];
async function walk(directory) {
const entries = await readdir(directory, { withFileTypes: true });
for (const entry of entries) {
if (BROWSER_EXCLUDED_DIRS.has(entry.name)) continue;
const entryAbsolutePath = join(directory, entry.name);
if (entry.isDirectory()) {
await walk(entryAbsolutePath);
continue;
}
if (!entry.isFile()) continue;
const entryRelativePath = relative(repoRoot, entryAbsolutePath).replace(/\\/g, "/");
const entryRef = normalizeAssetRef(entryRelativePath);
if (!entryRef) continue;
const relativeSuffix = entryRelativePath.slice(relativePath.length).replace(/^\/+/, "");
const nextEntryRef = normalizeAssetRef(relativeSuffix ? `${nextRelativePath}/${relativeSuffix}` : nextRelativePath);
if (nextEntryRef) replacements.push({ from: entryRef, to: nextEntryRef });
}
}
await walk(absolutePath);
return replacements;
}
function serializedAssetReplacements(replacements) {
return replacements.map(({ from, to }) => ({
from,
to,
fromUrl: `./${encodeUrlPath(from)}`,
toUrl: `./${encodeUrlPath(to)}`,
}));
}
function mediaKindFromExtension(extension) {
if ([".avif", ".gif", ".ico", ".jpeg", ".jpg", ".png", ".svg", ".webp"].includes(extension)) return "image";
if ([".m4v", ".mov", ".mp4", ".webm"].includes(extension)) return "video";
if ([".glb", ".gltf"].includes(extension)) return "model";
if ([".woff", ".woff2"].includes(extension)) return "font";
return "other";
}
function encodeUrlPath(value) {
return value
.split("/")
.map((part) => encodeURIComponent(part))
.join("/")
.replaceAll("%2F", "/");
}
function buildMediaUrl(root, relativePath) {
return `${root.urlPrefix}/${encodeUrlPath(relativePath)}`;
}
async function scanMediaRoot(root) {
const files = [];
async function walk(directory) {
let entries = [];
try {
entries = await readdir(directory, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const filePath = join(directory, entry.name);
const relativePath = relative(root.rootPath, filePath).replace(/\\/g, "/");
if (!relativePath || relativePath.split("/").includes(".trash")) continue;
if (entry.isDirectory()) {
await walk(filePath);
continue;
}
if (!entry.isFile()) continue;
const extension = extname(entry.name).toLowerCase();
if (!MEDIA_EXTENSIONS.has(extension)) continue;
const fileStat = await stat(filePath);
const url = buildMediaUrl(root, relativePath);
const normalizedRef = normalizeAssetRef(url);
files.push({
id: `${root.id}:${relativePath}`,
rootId: root.id,
rootLabel: root.label,
writable: root.writable,
name: entry.name,
directory: dirname(relativePath) === "." ? "" : dirname(relativePath).replace(/\\/g, "/"),
relativePath,
url,
normalizedRef,
extension: extension.slice(1),
kind: mediaKindFromExtension(extension),
size: fileStat.size,
mtimeMs: fileStat.mtimeMs,
});
}
}
await walk(root.rootPath);
return files;
}
async function listMediaAssets() {
const [usage, rootFiles] = await Promise.all([buildMediaUsage(), Promise.all(mediaRoots.map(scanMediaRoot))]);
const files = rootFiles
.flat()
.map((file) => {
const rendered = usage.renderedRefs.has(file.normalizedRef);
const referenced = usage.pageRefs.has(file.normalizedRef);
const status = rendered ? "rendered" : referenced ? "referenced" : "unused";
return {
...file,
usage: {
status,
referenced,
rendered,
label: status === "rendered" ? "В верстке" : status === "referenced" ? "В модели" : "Не используется",
},
};
})
.sort((left, right) => right.mtimeMs - left.mtimeMs);
return {
ok: true,
roots: mediaRoots.map(({ id, label, writable, urlPrefix }) => ({ id, label, writable, urlPrefix })),
files,
};
}
function safeBrowserRelativePath(value) {
let rawValue = String(value || "").trim().replace(/^\/+/, "");
try {
rawValue = decodeURIComponent(rawValue);
} catch {
// Keep the raw value when the browser sends a malformed escape sequence.
}
rawValue = rawValue.replace(/\\/g, "/");
const normalized = normalize(rawValue).replace(/\\/g, "/");
if (!normalized || normalized === ".") return "";
if (normalized.startsWith("../") || normalized === ".." || normalized.includes("/../")) {
throw new Error("Некорректный путь");
}
return normalized;
}
function resolveBrowserDirectory(value) {
const relativePath = safeBrowserRelativePath(value);
const absolutePath = join(repoRoot, relativePath);
const pathFromRoot = relative(repoRoot, absolutePath);
if (pathFromRoot.startsWith("..") || pathFromRoot === "..") {
throw new Error("Путь вне проекта запрещён");
}
return { relativePath, absolutePath };
}
function pathSegments(value) {
return String(value || "")
.split("/")
.filter(Boolean);
}
function ensureNoManagedDirectory(relativePath) {
const blockedSegment = pathSegments(relativePath).find((segment) => BROWSER_EXCLUDED_DIRS.has(segment));
if (blockedSegment) {
throw new Error(`Операции внутри ${blockedSegment} запрещены`);
}
}
function formatProjectSize(bytes) {
const value = Number(bytes);
if (!Number.isFinite(value) || value <= 0) return "0 МБ";
const gib = 1024 ** 3;
const mib = 1024 ** 2;
if (value < gib) {
const mb = value / mib;
return `${mb >= 10 ? mb.toFixed(0) : mb.toFixed(1)} МБ`;
}
const gb = value / gib;
return `${gb >= 10 ? gb.toFixed(0) : gb.toFixed(1)} ГБ`;
}
async function calculateProjectSize() {
async function walk(directory) {
let total = 0;
const entries = await readdir(directory, { withFileTypes: true });
for (const entry of entries) {
if (BROWSER_EXCLUDED_DIRS.has(entry.name)) continue;
const absolutePath = join(directory, entry.name);
if (entry.isSymbolicLink()) continue;
if (entry.isDirectory()) {
total += await walk(absolutePath);
continue;
}
if (!entry.isFile()) continue;
total += (await stat(absolutePath)).size;
}
return total;
}
const bytes = await walk(repoRoot);
return {
ok: true,
bytes,
label: formatProjectSize(bytes),
};
}
function resolveBrowserEntry(value) {
const relativePath = safeBrowserRelativePath(value);
if (!relativePath) {
throw new Error("Некорректный путь");
}
ensureNoManagedDirectory(relativePath);
const absolutePath = join(repoRoot, relativePath);
const pathFromRoot = relative(repoRoot, absolutePath);
if (pathFromRoot.startsWith("..") || pathFromRoot === "..") {
throw new Error("Путь вне проекта запрещён");
}
return { relativePath, absolutePath };
}
function safeEntryName(value) {
const name = String(value || "").trim();
if (!name || name === "." || name === "..") {
throw new Error("Введите корректное имя");
}
if (/[\\/]/.test(name) || BROWSER_EXCLUDED_DIRS.has(name)) {
throw new Error("Имя содержит запрещённые символы");
}
return name;
}
async function ensurePathAvailable(absolutePath) {
try {
await stat(absolutePath);
} catch (error) {
if (error.code === "ENOENT") return;
throw error;
}
throw new Error("Файл или папка с таким именем уже существует");
}
async function availableSiblingPath(directory, fileName) {
const extension = extname(fileName);
const baseName = fileName.slice(0, extension ? -extension.length : undefined);
let candidate = join(directory, fileName);
let index = 2;
while (true) {
try {
await stat(candidate);
} catch (error) {
if (error.code === "ENOENT") return candidate;
throw error;
}
candidate = join(directory, `${baseName}-${index}${extension}`);
index += 1;
}
}
async function assertAssetFileCanMove(relativePath) {
const normalizedRef = normalizeAssetRef(relativePath);
if (!normalizedRef) return;
const usage = await buildMediaUsage();
if (usage.pageRefs.has(normalizedRef) || usage.renderedRefs.has(normalizedRef)) {
throw new Error("Файл используется в модели или текущей верстке");
}
}
function browserBreadcrumbs(relativePath) {
const breadcrumbs = [{ name: "NODEDC_SITE", path: "" }];
const parts = relativePath.split("/").filter(Boolean);
let current = "";
for (const part of parts) {
current = current ? `${current}/${part}` : part;
breadcrumbs.push({ name: part, path: current });
}
return breadcrumbs;
}
function publicUrlForBrowserPath(relativePath) {
if (!relativePath.startsWith("assets/")) return "";
return `./${encodeUrlPath(relativePath)}`;
}
async function browseProjectDirectory(pathValue) {
const { relativePath, absolutePath } = resolveBrowserDirectory(pathValue);
const usage = await buildMediaUsage();
let entries = [];
try {
const currentStat = await stat(absolutePath);
if (!currentStat.isDirectory()) {
throw new Error("Путь не является директорией");
}
const rawEntries = await readdir(absolutePath, { withFileTypes: true });
entries = await Promise.all(
rawEntries
.filter((entry) => !BROWSER_EXCLUDED_DIRS.has(entry.name))
.map(async (entry) => {
const entryRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
const entryAbsolutePath = join(absolutePath, entry.name);
const entryStat = await stat(entryAbsolutePath);
const isDirectory = entry.isDirectory();
const extension = isDirectory ? "" : extname(entry.name).toLowerCase();
const kind = isDirectory ? "folder" : mediaKindFromExtension(extension);
const url = isDirectory ? "" : publicUrlForBrowserPath(entryRelativePath);
const normalizedRef = url ? normalizeAssetRef(url) : null;
const rendered = normalizedRef ? usage.renderedRefs.has(normalizedRef) : false;
const referenced = normalizedRef ? usage.pageRefs.has(normalizedRef) : false;
const status = rendered ? "rendered" : referenced ? "referenced" : normalizedRef ? "unused" : "local";
return {
id: entryRelativePath || entry.name,
name: entry.name,
path: entryRelativePath,
directory: relativePath,
type: isDirectory ? "directory" : "file",
kind,
extension: extension ? extension.slice(1) : "",
size: isDirectory ? 0 : entryStat.size,
mtimeMs: entryStat.mtimeMs,
url,
selectable: Boolean(url),
writable: Boolean(normalizedRef && !normalizedRef.split("/").includes(".trash")),
usage: {
status,
referenced,
rendered,
label:
status === "rendered"
? "В верстке"
: status === "referenced"
? "В модели"
: status === "unused"
? "Не используется"
: "Локальный файл",
},
};
}),
);
} catch (error) {
if (error.code !== "ENOENT") throw error;
entries = [];
}
entries.sort((left, right) => {
if (left.type !== right.type) return left.type === "directory" ? -1 : 1;
return left.name.localeCompare(right.name, "ru", { numeric: true, sensitivity: "base" });
});
return {
ok: true,
path: relativePath,
breadcrumbs: browserBreadcrumbs(relativePath),
entries,
};
}
function resolveDeletableAssetFile(url) {
const normalizedRef = normalizeAssetRef(url);
if (!normalizedRef || !normalizedRef.startsWith("assets/") || normalizedRef.split("/").includes(".trash")) {
throw new Error("Удалять можно только файлы из assets");
}
const filePath = join(repoRoot, normalizedRef);
const pathFromAssets = relative(assetRoot, filePath);
if (pathFromAssets.startsWith("..") || pathFromAssets === "..") {
throw new Error("Некорректный путь файла");
}
return { normalizedRef, filePath };
}
function resolveDeletableAssetEntry(pathValue) {
const relativePath = safeBrowserRelativePath(pathValue);
if (!relativePath || relativePath === "assets" || !relativePath.startsWith("assets/") || pathSegments(relativePath).includes(".trash")) {
throw new Error("Удалять можно только файлы и папки внутри assets");
}
ensureNoManagedDirectory(relativePath);
const absolutePath = join(repoRoot, relativePath);
const pathFromAssets = relative(assetRoot, absolutePath);
if (pathFromAssets.startsWith("..") || pathFromAssets === "..") {
throw new Error("Некорректный путь файла");
}
return { relativePath, absolutePath };
}
async function assertDirectoryCanDelete(directoryPath) {
const usage = await buildMediaUsage();
async function walk(currentPath) {
const entries = await readdir(currentPath, { withFileTypes: true });
for (const entry of entries) {
if (BROWSER_EXCLUDED_DIRS.has(entry.name)) continue;
const entryPath = join(currentPath, entry.name);
if (entry.isDirectory()) {
await walk(entryPath);
continue;
}
if (!entry.isFile()) continue;
const entryRelativePath = relative(repoRoot, entryPath).replace(/\\/g, "/");
const normalizedRef = normalizeAssetRef(entryRelativePath);
if (normalizedRef && (usage.pageRefs.has(normalizedRef) || usage.renderedRefs.has(normalizedRef))) {
throw new Error("В папке есть файлы, которые используются в модели или текущей верстке");
}
}
}
await walk(directoryPath);
}
async function softDeleteMediaAsset(url) {
const { normalizedRef, filePath } = resolveDeletableAssetFile(url);
const usage = await buildMediaUsage();
if (usage.pageRefs.has(normalizedRef) || usage.renderedRefs.has(normalizedRef)) {
throw new Error("Файл используется в модели или текущей верстке");
}
const fileStat = await stat(filePath);
if (!fileStat.isFile()) {
throw new Error("Удалять можно только файлы");
}
const assetRelative = relative(assetRoot, filePath).replace(/\\/g, "/");
const parsedExtension = extname(assetRelative);
const basePath = assetRelative.slice(0, parsedExtension ? -parsedExtension.length : undefined);
const trashPath = join(assetTrashRoot, `${basePath}-${Date.now().toString(36)}${parsedExtension}`);
await mkdir(dirname(trashPath), { recursive: true });
await rename(filePath, trashPath);
return {
ok: true,
deleted: `./assets/${assetRelative}`,
trashPath: `assets/.trash/${relative(assetTrashRoot, trashPath).replace(/\\/g, "/")}`,
};
}
async function softDeleteProjectEntry(pathValue) {
const { relativePath, absolutePath } = resolveDeletableAssetEntry(pathValue);
const entryStat = await stat(absolutePath);
if (entryStat.isFile()) {
await assertAssetFileCanMove(relativePath);
} else if (entryStat.isDirectory()) {
await assertDirectoryCanDelete(absolutePath);
} else {
throw new Error("Удалять можно только файл или папку");
}
const assetRelative = relative(assetRoot, absolutePath).replace(/\\/g, "/");
const parsedExtension = entryStat.isFile() ? extname(assetRelative) : "";
const basePath = assetRelative.slice(0, parsedExtension ? -parsedExtension.length : undefined);
const trashPath = join(assetTrashRoot, `${basePath}-${Date.now().toString(36)}${parsedExtension}`);
await mkdir(dirname(trashPath), { recursive: true });
await rename(absolutePath, trashPath);
return {
ok: true,
deleted: relativePath,
type: entryStat.isDirectory() ? "directory" : "file",
trashPath: `assets/.trash/${relative(assetTrashRoot, trashPath).replace(/\\/g, "/")}`,
};
}
function originalNameFromTrashName(fileName) {
const extension = extname(fileName);
const baseName = fileName.slice(0, extension ? -extension.length : undefined);
return `${baseName.replace(/-[a-z0-9]{6,}$/i, "")}${extension}`;
}
function trashOriginalRelativePath(store, trashRelativePath) {
const directory = dirname(trashRelativePath);
const restoredName = originalNameFromTrashName(trashRelativePath.split("/").pop() || "asset");
const restoreRelative = directory === "." ? restoredName : `${directory}/${restoredName}`;
return relative(repoRoot, join(store.restoreBasePath, restoreRelative)).replace(/\\/g, "/");
}
async function scanTrashStore(store) {
const files = [];
async function walk(directory) {
let entries = [];
try {
entries = await readdir(directory, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const absolutePath = join(directory, entry.name);
const trashRelativePath = relative(store.rootPath, absolutePath).replace(/\\/g, "/");
if (entry.isDirectory()) {
await walk(absolutePath);
continue;
}
if (!entry.isFile()) continue;
const extension = extname(entry.name).toLowerCase();
const fileStat = await stat(absolutePath);
const url = `${store.urlPrefix}/${encodeUrlPath(trashRelativePath)}`;
const originalPath = trashOriginalRelativePath(store, trashRelativePath);
files.push({
id: `${store.id}:${trashRelativePath}`,
storeId: store.id,
name: entry.name,
originalName: originalNameFromTrashName(entry.name),
path: `${store.displayPrefix}/${trashRelativePath}`,
trashPath: `${store.displayPrefix}/${trashRelativePath}`,
originalPath,
directory: dirname(trashRelativePath) === "." ? "" : dirname(trashRelativePath).replace(/\\/g, "/"),
type: "file",
kind: mediaKindFromExtension(extension),
extension: extension ? extension.slice(1) : "",
size: fileStat.size,
mtimeMs: fileStat.mtimeMs,
url,
selectable: true,
writable: true,
restorable: true,
usage: {
status: "trash",
referenced: false,
rendered: false,
label: "Удалён",
},
});
}
}
await walk(store.rootPath);
return files;
}
async function listTrashAssets() {
const files = (await Promise.all(trashStores.map(scanTrashStore)))
.flat()
.sort((left, right) => right.mtimeMs - left.mtimeMs);
return {
ok: true,
files,
};
}
function resolveTrashFile(trashPathValue) {
const relativePath = safeBrowserRelativePath(trashPathValue);
const store = trashStores.find(
(candidate) => relativePath === candidate.displayPrefix || relativePath.startsWith(`${candidate.displayPrefix}/`),
);
if (!store) {
throw new Error("Файл не найден в корзине");
}
const trashRelativePath = relativePath.slice(store.displayPrefix.length).replace(/^\/+/, "");
if (!trashRelativePath || pathSegments(trashRelativePath).includes(".trash")) {
throw new Error("Некорректный путь корзины");
}
const absolutePath = join(store.rootPath, trashRelativePath);
const pathFromStore = relative(store.rootPath, absolutePath);
if (pathFromStore.startsWith("..") || pathFromStore === "..") {
throw new Error("Некорректный путь корзины");
}
return { store, trashRelativePath, absolutePath };
}
async function restoreTrashAsset(trashPathValue) {
const { store, trashRelativePath, absolutePath } = resolveTrashFile(trashPathValue);
const fileStat = await stat(absolutePath);
if (!fileStat.isFile()) {
throw new Error("Восстановить можно только файл");
}
const originalRelativePath = trashOriginalRelativePath(store, trashRelativePath);
const targetPath = await availableSiblingPath(
dirname(join(repoRoot, originalRelativePath)),
originalRelativePath.split("/").pop() || "asset",
);
await mkdir(dirname(targetPath), { recursive: true });
await rename(absolutePath, targetPath);
const restoredPath = relative(repoRoot, targetPath).replace(/\\/g, "/");
return {
ok: true,
path: restoredPath,
url: publicUrlForBrowserPath(restoredPath),
};
}
async function pruneEmptyTrashParents(startPath, rootPath) {
let currentPath = dirname(startPath);
while (currentPath !== rootPath && currentPath.startsWith(rootPath)) {
try {
const entries = await readdir(currentPath);
if (entries.length) return;
await rm(currentPath, { recursive: false, force: true });
} catch {
return;
}
currentPath = dirname(currentPath);
}
}
async function deleteTrashAsset(trashPathValue) {
const { store, absolutePath } = resolveTrashFile(trashPathValue);
const fileStat = await stat(absolutePath);
if (!fileStat.isFile()) {
throw new Error("Окончательно удалить можно только файл");
}
await rm(absolutePath, { force: true });
await pruneEmptyTrashParents(absolutePath, store.rootPath);
return { ok: true };
}
async function clearTrashAssets() {
await Promise.all(trashStores.map((store) => rm(store.rootPath, { recursive: true, force: true })));
return { ok: true };
}
async function createProjectFolder(payload) {
const { relativePath, absolutePath } = resolveBrowserDirectory(payload?.path || "");
ensureNoManagedDirectory(relativePath);
const folderName = safeEntryName(payload?.name);
const targetPath = join(absolutePath, folderName);
const targetRelativePath = relativePath ? `${relativePath}/${folderName}` : folderName;
ensureNoManagedDirectory(targetRelativePath);
await ensurePathAvailable(targetPath);
await mkdir(targetPath);
return {
ok: true,
path: targetRelativePath,
};
}
async function renameProjectEntry(payload) {
const { relativePath, absolutePath } = resolveBrowserEntry(payload?.path || "");
const entryStat = await stat(absolutePath);
const nextName = safeEntryName(payload?.name);
const parentPath = dirname(relativePath) === "." ? "" : dirname(relativePath).replace(/\\/g, "/");
const nextRelativePath = parentPath ? `${parentPath}/${nextName}` : nextName;
const nextAbsolutePath = join(repoRoot, nextRelativePath);
ensureNoManagedDirectory(nextRelativePath);
await ensurePathAvailable(nextAbsolutePath);
const replacements = await assetReferenceReplacementsForMove(relativePath, nextRelativePath, absolutePath, entryStat);
await rename(absolutePath, nextAbsolutePath);
const updatedFiles = await applyAssetReferenceReplacements(replacements);
return {
ok: true,
path: nextRelativePath,
type: entryStat.isDirectory() ? "directory" : "file",
url: publicUrlForBrowserPath(nextRelativePath),
replacements: serializedAssetReplacements(replacements),
updatedFiles,
};
}
async function moveProjectEntry(payload) {
const { relativePath, absolutePath } = resolveBrowserEntry(payload?.path || "");
const { relativePath: targetRelativePath, absolutePath: targetAbsolutePath } = resolveBrowserDirectory(payload?.targetPath || "");
const entryStat = await stat(absolutePath);
const targetStat = await stat(targetAbsolutePath);
if (!entryStat.isFile() && !entryStat.isDirectory()) {
throw new Error("Переносить можно только файл или папку");
}
if (!targetStat.isDirectory()) {
throw new Error("Целевая директория не найдена");
}
ensureNoManagedDirectory(targetRelativePath);
if (entryStat.isDirectory() && (targetRelativePath === relativePath || targetRelativePath.startsWith(`${relativePath}/`))) {
throw new Error("Нельзя перенести папку внутрь самой себя");
}
const entryName = relativePath.split("/").pop() || "";
const nextRelativePath = targetRelativePath ? `${targetRelativePath}/${entryName}` : entryName;
const nextAbsolutePath = join(repoRoot, nextRelativePath);
if (nextRelativePath === relativePath) {
return {
ok: true,
path: relativePath,
type: entryStat.isDirectory() ? "directory" : "file",
url: publicUrlForBrowserPath(relativePath),
replacements: [],
updatedFiles: [],
};
}
ensureNoManagedDirectory(nextRelativePath);
await ensurePathAvailable(nextAbsolutePath);
const replacements = await assetReferenceReplacementsForMove(relativePath, nextRelativePath, absolutePath, entryStat);
await rename(absolutePath, nextAbsolutePath);
const updatedFiles = await applyAssetReferenceReplacements(replacements);
return {
ok: true,
path: nextRelativePath,
type: entryStat.isDirectory() ? "directory" : "file",
url: publicUrlForBrowserPath(nextRelativePath),
replacements: serializedAssetReplacements(replacements),
updatedFiles,
};
}
async function duplicateProjectFile(payload) {
const { relativePath, absolutePath } = resolveBrowserEntry(payload?.path || "");
const entryStat = await stat(absolutePath);
if (!entryStat.isFile() && !entryStat.isDirectory()) {
throw new Error("Дублировать можно только файл или папку");
}
const extension = entryStat.isFile() ? extname(relativePath) : "";
const directory = dirname(absolutePath);
const baseName = relativePath.split("/").pop()?.slice(0, extension ? -extension.length : undefined) || "asset";
const targetPath = await availableSiblingPath(directory, `${baseName}-copy${extension}`);
if (entryStat.isDirectory()) {
await cp(absolutePath, targetPath, { recursive: true, errorOnExist: true });
} else {
await copyFile(absolutePath, targetPath);
}
const duplicatedPath = relative(repoRoot, targetPath).replace(/\\/g, "/");
return {
ok: true,
path: duplicatedPath,
type: entryStat.isDirectory() ? "directory" : "file",
url: publicUrlForBrowserPath(duplicatedPath),
};
}
function isMultipartRequest(req) {
return String(req.headers["content-type"] || "").toLowerCase().startsWith("multipart/form-data");
}
function multipartBoundary(req) {
const contentType = String(req.headers["content-type"] || "");
const match = /boundary=(?:"([^"]+)"|([^;]+))/i.exec(contentType);
return match?.[1] || match?.[2] || "";
}
function parseHeaderParams(value) {
const params = {};
const parts = String(value || "").split(";");
for (const part of parts.slice(1)) {
const [rawKey, ...rawValueParts] = part.trim().split("=");
if (!rawKey || !rawValueParts.length) continue;
const rawValue = rawValueParts.join("=").trim();
params[rawKey.toLowerCase()] = rawValue.replace(/^"|"$/g, "");
}
return params;
}
function parseMultipartUpload(req, bodyBuffer) {
const boundary = multipartBoundary(req);
if (!boundary) {
throw new Error("Не найден multipart boundary");
}
const fields = {};
let filePart = null;
const body = bodyBuffer.toString("latin1");
const delimiter = `--${boundary}`;
for (const rawPart of body.split(delimiter)) {
let part = rawPart;
if (!part || part === "--" || part === "--\r\n") continue;
if (part.startsWith("\r\n")) part = part.slice(2);
if (part.endsWith("--")) part = part.slice(0, -2);
if (part.endsWith("\r\n")) part = part.slice(0, -2);
const headerEnd = part.indexOf("\r\n\r\n");
if (headerEnd < 0) continue;
const headerLines = part.slice(0, headerEnd).split("\r\n");
const headers = Object.fromEntries(
headerLines
.map((line) => {
const separator = line.indexOf(":");
if (separator < 0) return null;
return [line.slice(0, separator).trim().toLowerCase(), line.slice(separator + 1).trim()];
})
.filter(Boolean),
);
const disposition = headers["content-disposition"];
const params = parseHeaderParams(disposition);
const fieldName = params.name;
const value = part.slice(headerEnd + 4);
if (!fieldName) continue;
if (Object.prototype.hasOwnProperty.call(params, "filename")) {
filePart = {
fileName: params.filename || fields.fileName || "asset.bin",
mimeType: headers["content-type"] || fields.mimeType || "application/octet-stream",
fileBuffer: Buffer.from(value, "latin1"),
};
continue;
}
fields[fieldName] = Buffer.from(value, "latin1").toString("utf8");
}
if (!filePart) {
throw new Error("Файл не найден в multipart payload");
}
return {
fileName: fields.fileName || filePart.fileName,
mimeType: fields.mimeType || filePart.mimeType,
bucket: fields.bucket || "media",
fileBuffer: filePart.fileBuffer,
};
}
function safePublicPath(urlPath) {
const withoutQuery = decodeURIComponent(urlPath.split("?")[0]);
const requestPath =
withoutQuery === "/"
? "/index.html"
: withoutQuery === "/admin" || withoutQuery === "/admin/"
? "/admin/index.html"
: withoutQuery;
const normalized = normalize(requestPath).replace(/^(\.\.[/\\])+/, "");
const absolute = join(repoRoot, normalized);
if (!absolute.startsWith(repoRoot)) {
return null;
}
return absolute;
}
async function serveStatic(req, res) {
const filePath = safePublicPath(new URL(req.url, `http://${host}:${port}`).pathname);
if (!filePath) {
send(res, 403, "Forbidden");
return;
}
try {
const fileStat = await stat(filePath);
if (!fileStat.isFile()) {
send(res, 404, "Not found");
return;
}
const contentType = contentTypeForPath(filePath);
const range = req.headers.range;
if (range) {
const match = /^bytes=(\d*)-(\d*)$/.exec(range);
if (!match) {
res.writeHead(416, {
"cache-control": "no-store",
"content-range": `bytes */${fileStat.size}`,
});
res.end();
return;
}
const start = match[1] ? Number(match[1]) : 0;
const end = match[2] ? Number(match[2]) : fileStat.size - 1;
if (
!Number.isInteger(start) ||
!Number.isInteger(end) ||
start < 0 ||
end < start ||
start >= fileStat.size
) {
res.writeHead(416, {
"cache-control": "no-store",
"content-range": `bytes */${fileStat.size}`,
});
res.end();
return;
}
const cappedEnd = Math.min(end, fileStat.size - 1);
sendStream(req, res, 206, createReadStream(filePath, { start, end: cappedEnd }), {
"content-type": contentType,
"content-length": cappedEnd - start + 1,
"content-range": `bytes ${start}-${cappedEnd}/${fileStat.size}`,
});
return;
}
sendStream(req, res, 200, createReadStream(filePath), {
"content-type": contentType,
"content-length": fileStat.size,
});
} catch {
send(res, 404, "Not found");
}
}
const server = createServer(async (req, res) => {
try {
const url = new URL(req.url, `http://${host}:${port}`);
if (req.method === "GET" && url.pathname === "/api/page/home") {
send(res, 200, await readFile(pagePath, "utf8"), "application/json; charset=utf-8");
return;
}
if (req.method === "GET" && url.pathname === "/api/block-templates/home") {
send(res, 200, await readFile(blockTemplatesPath, "utf8"), "application/json; charset=utf-8");
return;
}
if (req.method === "GET" && url.pathname === "/api/media/assets") {
sendJson(res, 200, await listMediaAssets());
return;
}
if (req.method === "GET" && url.pathname === "/api/media/browse") {
sendJson(res, 200, await browseProjectDirectory(url.searchParams.get("path") || ""));
return;
}
if (req.method === "GET" && url.pathname === "/api/media/site-size") {
sendJson(res, 200, await calculateProjectSize());
return;
}
if (req.method === "GET" && url.pathname === "/api/media/trash") {
sendJson(res, 200, await listTrashAssets());
return;
}
if (req.method === "PUT" && url.pathname === "/api/page/home") {
const body = await readBody(req);
const parsed = JSON.parse(body);
await writeFile(pagePath, `${JSON.stringify(parsed, null, 2)}\n`);
sendJson(res, 200, { ok: true });
return;
}
if (req.method === "POST" && url.pathname === "/api/render/home") {
const result = await runNodeScript("render-home.mjs");
sendJson(res, 200, { ok: true, ...result });
return;
}
if (req.method === "POST" && url.pathname === "/api/upload/asset") {
const result = isMultipartRequest(req)
? await saveUploadedAssetBuffer(parseMultipartUpload(req, await readBodyBuffer(req)))
: await saveUploadedAsset(JSON.parse(await readBody(req)));
sendJson(res, 200, result);
return;
}
if (req.method === "POST" && url.pathname === "/api/media/delete") {
const payload = JSON.parse(await readBody(req));
sendJson(res, 200, await softDeleteMediaAsset(payload.url));
return;
}
if (req.method === "POST" && url.pathname === "/api/media/delete-entry") {
const payload = JSON.parse(await readBody(req));
sendJson(res, 200, await softDeleteProjectEntry(payload.path));
return;
}
if (req.method === "POST" && url.pathname === "/api/media/restore") {
const payload = JSON.parse(await readBody(req));
sendJson(res, 200, await restoreTrashAsset(payload.trashPath));
return;
}
if (req.method === "POST" && url.pathname === "/api/media/trash/delete") {
const payload = JSON.parse(await readBody(req));
sendJson(res, 200, await deleteTrashAsset(payload.trashPath));
return;
}
if (req.method === "POST" && url.pathname === "/api/media/trash/clear") {
sendJson(res, 200, await clearTrashAssets());
return;
}
if (req.method === "POST" && url.pathname === "/api/media/folder") {
const payload = JSON.parse(await readBody(req));
sendJson(res, 200, await createProjectFolder(payload));
return;
}
if (req.method === "POST" && url.pathname === "/api/media/rename") {
const payload = JSON.parse(await readBody(req));
sendJson(res, 200, await renameProjectEntry(payload));
return;
}
if (req.method === "POST" && url.pathname === "/api/media/move") {
const payload = JSON.parse(await readBody(req));
sendJson(res, 200, await moveProjectEntry(payload));
return;
}
if (req.method === "POST" && url.pathname === "/api/media/duplicate") {
const payload = JSON.parse(await readBody(req));
sendJson(res, 200, await duplicateProjectFile(payload));
return;
}
if (req.method === "POST" && url.pathname === "/api/extract/home") {
const result = await runNodeScript("extract-home-blocks.mjs");
sendJson(res, 200, { ok: true, ...result });
return;
}
await serveStatic(req, res);
} catch (error) {
sendJson(res, 500, { ok: false, error: error.message });
}
});
server.listen(port, host, () => {
console.log(`NODE.DC admin: http://${host}:${port}/admin/`);
console.log(`NODE.DC site: http://${host}:${port}/`);
});