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: "webflow-images", label: "Webflow images", rootPath: join(repoRoot, "assets", "webflow", "images"), urlPrefix: "./assets/webflow/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", ".jpeg", ".jpg", ".m4v", ".mov", ".mp4", ".png", ".svg", ".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", ".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", ".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 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/png": ".png", "image/jpeg": ".jpg", "image/gif": ".gif", "image/avif": ".avif", "image/webp": ".webp", "video/mp4": ".mp4", "video/webm": ".webm", "video/quicktime": ".mov", "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", ".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 ext = extname(filePath); const contentType = MIME[ext] || "application/octet-stream"; 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}/`); });