import { createServer } from "node:http"; import { createReadStream, statSync } from "node:fs"; import { appendFile, copyFile, cp, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises"; import { extname, join, normalize, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { spawn } from "node:child_process"; import { dirname } from "node:path"; import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto"; import { createLocalJWKSet, jwtVerify } from "jose"; import SftpClient from "ssh2-sftp-client"; import AdmZip from "adm-zip"; const cmsRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); const port = Number(process.env.PORT || 8090); const host = process.env.HOST || "127.0.0.1"; const activeProjectId = process.env.CMS_PROJECT || "nodedc"; const projectConfigPath = join(cmsRoot, "projects", `${activeProjectId}.json`); const projectSecretsPath = join(cmsRoot, "projects", `${activeProjectId}.secrets.json`); const publishLogPath = join(cmsRoot, "projects", `${activeProjectId}.publish.log.jsonl`); const publishJobs = new Map(); const PUBLISH_JOB_RETENTION_MS = 24 * 60 * 60 * 1000; async function loadProjectConfig() { const source = await readFile(projectConfigPath, "utf8"); return JSON.parse(source); } function resolveCmsPath(value) { const rawPath = String(value || "").trim(); if (!rawPath) return cmsRoot; return resolve(cmsRoot, rawPath); } let projectConfig = await loadProjectConfig(); const repoRoot = resolveCmsPath(process.env.SITE_ROOT || projectConfig.siteRoot || "../NODEDC_SITE"); const pagePath = join(repoRoot, "content", "pages", "home.json"); const knowledgePath = join(repoRoot, "content", "knowledge.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 REQUIRED_SITE_FILES = [ { key: "page", label: "content/pages/home.json", path: pagePath }, { key: "knowledge", label: "content/knowledge.json", path: knowledgePath }, { key: "blockTemplates", label: "content/block-templates/home.json", path: blockTemplatesPath }, { key: "renderHome", label: "tools/render-home.mjs", path: join(repoRoot, "tools", "render-home.mjs") }, { key: "renderKnowledge", label: "tools/render-knowledge.mjs", path: join(repoRoot, "tools", "render-knowledge.mjs") }, { key: "homeShell", label: "templates/pages/home/shell.html", path: join(repoRoot, "templates", "pages", "home", "shell.html") }, ]; const PUBLIC_DEPLOY_DIRECTORIES = new Set(["_astro", "assets", "knowledge"]); const PUBLIC_DEPLOY_ROOT_EXTENSIONS = new Set([".html", ".ico", ".json", ".png", ".svg", ".txt", ".webmanifest", ".xml"]); const PUBLIC_DEPLOY_ROOT_EXCLUDES = new Set(["package.json", "package-lock.json", "README.md", "screenlog.0"]); const PUBLIC_DEPLOY_ROOT_SPECIAL_FILES = new Set([".htaccess"]); 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"); } function boolEnv(value, fallback = false) { if (value === undefined || value === null || value === "") return fallback; return ["1", "true", "yes", "on"].includes(String(value).trim().toLowerCase()); } function csvEnv(value) { return String(value || "") .split(",") .map((item) => item.trim()) .filter(Boolean); } function trailingSlashUrl(value) { const trimmed = String(value || "").trim(); if (!trimmed) return ""; return trimmed.endsWith("/") ? trimmed : `${trimmed}/`; } function fileReady(filePath) { try { return statSync(filePath).isFile(); } catch { return false; } } function secretEncryptionKey() { const seed = process.env.CMS_SECRETS_KEY || process.env.CMS_SESSION_SECRET || "dc-cms-local-development-secret"; return createHash("sha256").update(seed).digest(); } function encryptSecret(value) { const iv = randomBytes(12); const cipher = createCipheriv("aes-256-gcm", secretEncryptionKey(), iv); const encrypted = Buffer.concat([cipher.update(String(value), "utf8"), cipher.final()]); const tag = cipher.getAuthTag(); return `v1:${iv.toString("base64url")}:${tag.toString("base64url")}:${encrypted.toString("base64url")}`; } function decryptSecret(value) { const [version, ivValue, tagValue, encryptedValue] = String(value || "").split(":"); if (version !== "v1" || !ivValue || !tagValue || !encryptedValue) { throw new Error("Некорректный формат сохраненного секрета"); } const decipher = createDecipheriv("aes-256-gcm", secretEncryptionKey(), Buffer.from(ivValue, "base64url")); decipher.setAuthTag(Buffer.from(tagValue, "base64url")); return Buffer.concat([ decipher.update(Buffer.from(encryptedValue, "base64url")), decipher.final(), ]).toString("utf8"); } async function readProjectSecrets() { try { return JSON.parse(await readFile(projectSecretsPath, "utf8")); } catch (error) { if (error?.code === "ENOENT") return {}; throw error; } } async function writeProjectSecrets(secrets) { await writeFile(projectSecretsPath, `${JSON.stringify(secrets, null, 2)}\n`, { mode: 0o600 }); } async function saveDeployPasswordSecret(payload) { const password = payload?.deploy?.password; if (typeof password !== "string" || !password.length) return; const secrets = await readProjectSecrets(); secrets.deploy = { ...(secrets.deploy || {}), password: encryptSecret(password), updatedAt: new Date().toISOString(), }; await writeProjectSecrets(secrets); } async function readDeployPasswordSecret() { const secrets = await readProjectSecrets(); if (!secrets.deploy?.password) { throw new Error("Введите и сохраните пароль SFTP в настройках проекта"); } return decryptSecret(secrets.deploy.password); } function currentSiteStatus() { const files = REQUIRED_SITE_FILES.map((item) => ({ key: item.key, label: item.label, exists: fileReady(item.path), })); const missingFiles = files.filter((item) => !item.exists).map((item) => item.label); return { ready: missingFiles.length === 0, root: repoRoot, requiredFiles: files, missingFiles, }; } function ensureSiteReady(res) { const site = currentSiteStatus(); if (site.ready) return true; sendJson(res, 409, { ok: false, code: "SITE_NOT_READY", error: "Сайт не загружен в локальный workspace CMS.", site, }); return false; } const authConfig = { enabled: boolEnv(process.env.CMS_AUTH_ENABLED, false), baseUrl: String(process.env.CMS_BASE_URL || "").trim(), issuer: trailingSlashUrl(process.env.CMS_OIDC_ISSUER), internalBaseUrl: String(process.env.CMS_OIDC_INTERNAL_BASE_URL || "").trim(), clientId: String(process.env.CMS_OIDC_CLIENT_ID || "").trim(), clientSecret: String(process.env.CMS_OIDC_CLIENT_SECRET || "").trim(), redirectUri: String(process.env.CMS_OIDC_REDIRECT_URI || "").trim(), loggedOutRedirectUri: String(process.env.CMS_OIDC_LOGGED_OUT_REDIRECT_URI || "").trim(), scope: String(process.env.CMS_OIDC_SCOPE || "openid email profile groups offline_access").trim(), requiredGroups: csvEnv(process.env.CMS_REQUIRED_GROUPS || "dc-cms:owner,dc-cms:admin"), sessionSecret: String(process.env.CMS_SESSION_SECRET || "dc-cms-local-session-secret").trim(), sessionCookie: String(process.env.CMS_SESSION_COOKIE || "dc_cms_session").trim(), stateCookie: String(process.env.CMS_STATE_COOKIE || "dc_cms_oidc_state").trim(), cookieDomain: String(process.env.COOKIE_DOMAIN || "").trim(), cookieSecure: boolEnv(process.env.COOKIE_SECURE, false), sessionTtlMs: Number(process.env.CMS_SESSION_TTL_MS || 12 * 60 * 60 * 1000), loginTtlMs: Number(process.env.CMS_LOGIN_TTL_MS || 10 * 60 * 1000), }; const pendingLogins = new Map(); const sessions = new Map(); let discoveryCache = null; let discoveryPromise = null; let jwksCache = null; let jwksUriCache = ""; let jwksPromise = null; function randomToken(byteLength = 32) { return randomBytes(byteLength).toString("base64url"); } function codeChallengeForVerifier(verifier) { return createHash("sha256").update(verifier).digest("base64url"); } function signatureForValue(value) { return createHmac("sha256", authConfig.sessionSecret).update(value).digest("base64url"); } function safeEqual(left, right) { const leftBuffer = Buffer.from(String(left || "")); const rightBuffer = Buffer.from(String(right || "")); return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer); } function signValue(value) { return `${value}.${signatureForValue(value)}`; } function verifySignedValue(value) { const rawValue = String(value || ""); const separatorIndex = rawValue.lastIndexOf("."); if (separatorIndex <= 0) return ""; const payload = rawValue.slice(0, separatorIndex); const signature = rawValue.slice(separatorIndex + 1); return safeEqual(signature, signatureForValue(payload)) ? payload : ""; } function parseCookies(req) { const cookies = {}; for (const part of String(req.headers.cookie || "").split(";")) { const index = part.indexOf("="); if (index === -1) continue; const name = part.slice(0, index).trim(); const value = part.slice(index + 1).trim(); if (!name) continue; try { cookies[name] = decodeURIComponent(value); } catch { cookies[name] = value; } } return cookies; } function serializeCookie(name, value, options = {}) { const parts = [`${name}=${encodeURIComponent(value)}`, "Path=/", "SameSite=Lax"]; if (options.httpOnly !== false) parts.push("HttpOnly"); if (authConfig.cookieSecure) parts.push("Secure"); if (authConfig.cookieDomain) parts.push(`Domain=${authConfig.cookieDomain}`); if (Number.isFinite(options.maxAge)) parts.push(`Max-Age=${Math.trunc(options.maxAge)}`); if (options.expires) parts.push(`Expires=${options.expires.toUTCString()}`); return parts.join("; "); } function appendCookie(res, cookie) { const current = res.getHeader("set-cookie"); if (!current) { res.setHeader("set-cookie", cookie); return; } res.setHeader("set-cookie", Array.isArray(current) ? [...current, cookie] : [current, cookie]); } function setCookie(res, name, value, options = {}) { appendCookie(res, serializeCookie(name, value, options)); } function clearCookie(res, name) { appendCookie(res, serializeCookie(name, "", { maxAge: 0, expires: new Date(0) })); } function requestOrigin(req) { const forwardedProto = String(req.headers["x-forwarded-proto"] || "").split(",")[0].trim(); const forwardedHost = String(req.headers["x-forwarded-host"] || "").split(",")[0].trim(); const protocol = forwardedProto || (authConfig.cookieSecure ? "https" : "http"); const requestHost = forwardedHost || req.headers.host || `${host}:${port}`; return `${protocol}://${requestHost}`; } function publicBaseUrl(req) { return authConfig.baseUrl || requestOrigin(req); } function callbackUri(req) { return authConfig.redirectUri || new URL("/auth/callback", publicBaseUrl(req)).toString(); } function sanitizeReturnTo(value) { const rawValue = String(value || "").trim(); if (!rawValue || rawValue.startsWith("//")) return "/"; if (/^https?:\/\//i.test(rawValue)) return "/"; return rawValue.startsWith("/") ? rawValue : "/"; } function sendRedirect(res, target, status = 302) { res.writeHead(status, { location: target, "cache-control": "no-store", }); res.end(); } function authLoginUrl(req, returnTo) { const loginUrl = new URL("/auth/login", publicBaseUrl(req)); loginUrl.searchParams.set("returnTo", sanitizeReturnTo(returnTo)); return loginUrl.toString(); } function assertAuthConfigured() { if (!authConfig.issuer || !authConfig.clientId || !authConfig.clientSecret) { throw new Error("CMS Authentik OIDC не настроен: проверьте CMS_OIDC_ISSUER, CMS_OIDC_CLIENT_ID и CMS_OIDC_CLIENT_SECRET"); } } function oidcInternalFetchUrl(rawUrl) { const publicUrl = new URL(rawUrl); if (!authConfig.internalBaseUrl) return publicUrl; const internalBaseUrl = new URL(authConfig.internalBaseUrl); internalBaseUrl.pathname = publicUrl.pathname; internalBaseUrl.search = publicUrl.search; internalBaseUrl.hash = ""; return internalBaseUrl; } async function fetchOidc(rawUrl, options = {}) { return fetch(oidcInternalFetchUrl(rawUrl), { redirect: "manual", ...options, headers: options.headers, }); } async function fetchOidcJson(rawUrl, options = {}) { const fetchUrl = oidcInternalFetchUrl(rawUrl); const response = await fetchOidc(rawUrl, { ...options, headers: { accept: "application/json", ...(options.headers || {}), }, }); const body = await response.text().catch(() => ""); let payload = null; try { payload = body ? JSON.parse(body) : null; } catch { payload = null; } return { response, payload, body, fetchUrl }; } function oidcJsonError(label, response, body, fetchUrl) { const contentType = response.headers.get("content-type") || ""; const location = response.headers.get("location") || ""; const bodyPreview = String(body || "") .replace(/\s+/g, " ") .trim() .slice(0, 180); const parts = [ `${label}: ${response.status}`, `url=${fetchUrl}`, contentType ? `content-type=${contentType}` : "", location ? `location=${location}` : "", bodyPreview ? `body=${bodyPreview}` : "", ].filter(Boolean); return parts.join("; "); } function publicOidcUrl(rawUrl) { if (!rawUrl) return ""; const publicIssuerUrl = new URL(authConfig.issuer); const sourceUrl = new URL(rawUrl, publicIssuerUrl); publicIssuerUrl.pathname = sourceUrl.pathname; publicIssuerUrl.search = sourceUrl.search; publicIssuerUrl.hash = ""; return publicIssuerUrl.toString(); } function normalizeDiscovery(payload) { return { ...payload, issuer: authConfig.issuer, authorization_endpoint: publicOidcUrl(payload.authorization_endpoint), token_endpoint: publicOidcUrl(payload.token_endpoint), userinfo_endpoint: publicOidcUrl(payload.userinfo_endpoint), end_session_endpoint: publicOidcUrl(payload.end_session_endpoint), introspection_endpoint: publicOidcUrl(payload.introspection_endpoint), revocation_endpoint: publicOidcUrl(payload.revocation_endpoint), device_authorization_endpoint: publicOidcUrl(payload.device_authorization_endpoint), jwks_uri: publicOidcUrl(payload.jwks_uri), }; } async function loadOidcDiscovery() { assertAuthConfigured(); if (discoveryCache) return discoveryCache; if (!discoveryPromise) { discoveryPromise = (async () => { const wellKnownUrl = new URL(".well-known/openid-configuration", authConfig.issuer); const { response, payload, body, fetchUrl } = await fetchOidcJson(wellKnownUrl); if (!response.ok || !payload) { throw new Error(oidcJsonError("Не удалось загрузить OIDC discovery", response, body, fetchUrl)); } discoveryCache = normalizeDiscovery(payload); return discoveryCache; })().finally(() => { discoveryPromise = null; }); } return discoveryPromise; } async function jwksForDiscovery(discovery) { if (jwksCache && jwksUriCache === discovery.jwks_uri) return jwksCache; if (!jwksPromise || jwksUriCache !== discovery.jwks_uri) { jwksUriCache = discovery.jwks_uri; jwksPromise = (async () => { const { response, payload, body, fetchUrl } = await fetchOidcJson(discovery.jwks_uri); if (!response.ok || !payload) { throw new Error(oidcJsonError("Не удалось загрузить OIDC JWKS", response, body, fetchUrl)); } jwksCache = createLocalJWKSet(payload); return jwksCache; })().finally(() => { jwksPromise = null; }); } return jwksPromise; } async function exchangeAuthorizationCode(code, codeVerifier, redirectUri) { const discovery = await loadOidcDiscovery(); const body = new URLSearchParams({ grant_type: "authorization_code", code, redirect_uri: redirectUri, code_verifier: codeVerifier, }); const credentials = Buffer.from(`${encodeURIComponent(authConfig.clientId)}:${encodeURIComponent(authConfig.clientSecret)}`).toString("base64"); const response = await fetchOidc(discovery.token_endpoint, { method: "POST", headers: { authorization: `Basic ${credentials}`, "content-type": "application/x-www-form-urlencoded", accept: "application/json", }, body, }); const payload = await response.json().catch(() => null); if (!response.ok || !payload) { throw new Error(payload?.error_description || payload?.error || `OIDC token exchange failed: ${response.status}`); } return { discovery, tokens: payload }; } function normalizeGroups(value) { if (Array.isArray(value)) return value.map((item) => String(item || "").trim()).filter(Boolean); if (typeof value === "string") return value.split(/[,\n]/).map((item) => item.trim()).filter(Boolean); return []; } function userFromClaims(claims) { const groups = normalizeGroups(claims.groups || claims.group || claims["https://authentik.io/groups"]); return { sub: String(claims.sub || ""), email: String(claims.email || ""), name: String(claims.name || claims.preferred_username || claims.email || "CMS user"), username: String(claims.preferred_username || claims.nickname || claims.email || claims.sub || ""), picture: String(claims.picture || claims.avatar_url || ""), groups, }; } async function verifyTokens(tokens, pendingLogin) { if (!tokens.id_token) { throw new Error("OIDC provider did not return id_token"); } const discovery = await loadOidcDiscovery(); const issuer = discovery.issuer || authConfig.issuer.replace(/\/?$/, "/"); const { payload } = await jwtVerify(tokens.id_token, await jwksForDiscovery(discovery), { issuer, audience: authConfig.clientId, }); if (pendingLogin.nonce && payload.nonce !== pendingLogin.nonce) { throw new Error("OIDC nonce mismatch"); } return userFromClaims(payload); } function cleanupAuthStores() { const now = Date.now(); for (const [state, pendingLogin] of pendingLogins) { if (now - pendingLogin.createdAt > authConfig.loginTtlMs) pendingLogins.delete(state); } for (const [sessionId, session] of sessions) { if (session.expiresAt <= now) sessions.delete(sessionId); } } function createSession(user, tokens) { const sessionId = randomToken(); const now = Date.now(); sessions.set(sessionId, { id: sessionId, user, idToken: String(tokens.id_token || ""), createdAt: now, expiresAt: now + authConfig.sessionTtlMs, }); return sessionId; } function currentSessionFromRequest(req) { const cookieValue = parseCookies(req)[authConfig.sessionCookie]; const sessionId = verifySignedValue(cookieValue); if (!sessionId) return null; const session = sessions.get(sessionId); if (!session || session.expiresAt <= Date.now()) { sessions.delete(sessionId); return null; } return session; } function sessionHasAccess(session) { if (!authConfig.requiredGroups.length) return true; const userGroups = new Set(session.user.groups || []); return authConfig.requiredGroups.some((group) => userGroups.has(group)); } function sendAuthRequired(req, res, url) { const returnTo = `${url.pathname}${url.search}`; if (url.pathname.startsWith("/api/")) { sendJson(res, 401, { ok: false, error: "Auth required", loginUrl: authLoginUrl(req, returnTo) }); return; } if (req.method === "GET" || req.method === "HEAD") { sendRedirect(res, authLoginUrl(req, returnTo)); return; } sendJson(res, 401, { ok: false, error: "Auth required" }); } function sendAccessDenied(res, url) { if (url.pathname.startsWith("/api/")) { sendJson(res, 403, { ok: false, error: "Forbidden" }); return; } send(res, 403, "Forbidden"); } async function startLogin(req, res, url) { cleanupAuthStores(); const discovery = await loadOidcDiscovery(); const state = randomToken(); const nonce = randomToken(); const codeVerifier = randomToken(48); const redirectUri = callbackUri(req); const returnTo = sanitizeReturnTo(url.searchParams.get("returnTo")); pendingLogins.set(state, { nonce, codeVerifier, redirectUri, returnTo, createdAt: Date.now(), }); setCookie(res, authConfig.stateCookie, signValue(state), { maxAge: Math.floor(authConfig.loginTtlMs / 1000), }); const authUrl = new URL(discovery.authorization_endpoint); authUrl.searchParams.set("client_id", authConfig.clientId); authUrl.searchParams.set("redirect_uri", redirectUri); authUrl.searchParams.set("response_type", "code"); authUrl.searchParams.set("scope", authConfig.scope); authUrl.searchParams.set("state", state); authUrl.searchParams.set("nonce", nonce); authUrl.searchParams.set("code_challenge", codeChallengeForVerifier(codeVerifier)); authUrl.searchParams.set("code_challenge_method", "S256"); sendRedirect(res, authUrl.toString()); } async function completeLogin(req, res, url) { cleanupAuthStores(); const error = url.searchParams.get("error"); if (error) { send(res, 401, url.searchParams.get("error_description") || error); return; } const state = url.searchParams.get("state") || ""; const code = url.searchParams.get("code") || ""; const cookieState = verifySignedValue(parseCookies(req)[authConfig.stateCookie]); clearCookie(res, authConfig.stateCookie); if (!state || !code || state !== cookieState) { send(res, 400, "Invalid OIDC callback state"); return; } const pendingLogin = pendingLogins.get(state); pendingLogins.delete(state); if (!pendingLogin) { send(res, 400, "OIDC login request expired"); return; } const { tokens } = await exchangeAuthorizationCode(code, pendingLogin.codeVerifier, pendingLogin.redirectUri); const user = await verifyTokens(tokens, pendingLogin); const sessionId = createSession(user, tokens); setCookie(res, authConfig.sessionCookie, signValue(sessionId), { maxAge: Math.floor(authConfig.sessionTtlMs / 1000), }); sendRedirect(res, pendingLogin.returnTo); } async function logout(req, res) { const session = currentSessionFromRequest(req); if (session) sessions.delete(session.id); clearCookie(res, authConfig.sessionCookie); clearCookie(res, authConfig.stateCookie); const fallbackRedirect = authConfig.loggedOutRedirectUri || new URL("/auth/logged-out", publicBaseUrl(req)).toString(); try { const discovery = await loadOidcDiscovery(); if (discovery.end_session_endpoint) { const logoutUrl = new URL(discovery.end_session_endpoint); logoutUrl.searchParams.set("client_id", authConfig.clientId); logoutUrl.searchParams.set("post_logout_redirect_uri", fallbackRedirect); if (session?.idToken) logoutUrl.searchParams.set("id_token_hint", session.idToken); sendRedirect(res, logoutUrl.toString()); return; } } catch { // Local logout must keep working even when Authentik is temporarily unavailable. } sendRedirect(res, fallbackRedirect); } async function handleAuthRoute(req, res, url) { if (req.method === "GET" && url.pathname === "/healthz") { sendJson(res, 200, { ok: true, service: "dc-cms", authEnabled: authConfig.enabled }); return true; } if (req.method === "GET" && url.pathname === "/api/me") { const session = currentSessionFromRequest(req); if (!authConfig.enabled) { sendJson(res, 200, { ok: true, authEnabled: false, user: null }); return true; } if (!session) { sendAuthRequired(req, res, url); return true; } if (!sessionHasAccess(session)) { sendAccessDenied(res, url); return true; } sendJson(res, 200, { ok: true, authEnabled: true, user: session.user }); return true; } if (!authConfig.enabled) return false; if (req.method === "GET" && url.pathname === "/auth/login") { await startLogin(req, res, url); return true; } if (req.method === "GET" && url.pathname === "/auth/callback") { await completeLogin(req, res, url); return true; } if ((req.method === "GET" || req.method === "POST") && url.pathname === "/auth/logout") { await logout(req, res); return true; } if (req.method === "GET" && url.pathname === "/auth/logged-out") { sendRedirect(res, authLoginUrl(req, "/")); return true; } return false; } function authorizeRequest(req, res, url) { if (!authConfig.enabled) return true; cleanupAuthStores(); const session = currentSessionFromRequest(req); if (!session) { clearCookie(res, authConfig.sessionCookie); sendAuthRequired(req, res, url); return false; } if (!sessionHasAccess(session)) { sendAccessDenied(res, url); return false; } session.expiresAt = Date.now() + authConfig.sessionTtlMs; req.cmsUser = session.user; return true; } function publicProjectConfig() { const deploy = { ...(projectConfig.deploy || {}), passwordConfigured: fileReady(projectSecretsPath), }; delete deploy.password; delete deploy.passwordEnv; delete deploy.deployOnRender; return { ...projectConfig, deploy, id: projectConfig.id || activeProjectId, runtime: { cmsRoot, siteRoot: repoRoot, configPath: projectConfigPath, siteRootChangesRequireRestart: true, }, site: currentSiteStatus(), }; } function normalizeProjectConfig(payload) { const nextDeploy = { enabled: Boolean(payload?.deploy?.enabled), method: String(payload?.deploy?.method || "sftp").trim() || "sftp", host: String(payload?.deploy?.host || "").trim(), port: Number(payload?.deploy?.port || (payload?.deploy?.method === "ftp" ? 21 : 22)), username: String(payload?.deploy?.username || "").trim(), remotePath: String(payload?.deploy?.remotePath || "").trim(), }; return { id: String(payload?.id || projectConfig.id || activeProjectId).trim() || activeProjectId, name: String(payload?.name || "NODE.DC").trim() || "NODE.DC", role: String(payload?.role || "Администратор сайта").trim() || "Администратор сайта", siteRoot: String(payload?.siteRoot || projectConfig.siteRoot || "../NODEDC_SITE").trim() || "../NODEDC_SITE", previewUrl: String(payload?.previewUrl || "").trim(), publicUrl: String(payload?.publicUrl || "").trim(), deploy: nextDeploy, }; } async function saveProjectConfig(payload) { await saveDeployPasswordSecret(payload); const nextConfig = normalizeProjectConfig(payload); await writeFile(projectConfigPath, `${JSON.stringify(nextConfig, null, 2)}\n`); projectConfig = nextConfig; return publicProjectConfig(); } function allPageBlocks(page) { return (page.regions || []).flatMap((region) => region.blocks || []); } function normalizeLandingTarget(value) { const raw = String(value || "").trim(); if (!raw || /^javascript:/i.test(raw) || /^(https?:)?\/\//i.test(raw) || /^(mailto:|tel:)/i.test(raw)) return ""; const hashValue = raw.includes("#") ? raw.slice(raw.indexOf("#") + 1) : raw; return hashValue .replace(/^\/+/, "") .replace(/^index\.html#?/i, "") .replace(/^#+/, "") .trim(); } function landingTargetsForPage(page) { return allPageBlocks(page) .filter((block) => block.enabled !== false) .map((block) => ({ id: block.id, label: block.adminLabel || block.id, template: block.template || block.type || "", target: normalizeLandingTarget(block.anchor), })) .filter((item) => item.target); } function validateLandingTargets(page) { const targets = new Map(); for (const item of landingTargetsForPage(page)) { if (targets.has(item.target)) { const first = targets.get(item.target); throw new Error(`Target "${item.target}" уже используется в блоках "${first.label}" и "${item.label}"`); } targets.set(item.target, item); } } 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, knowledgeSource, indexSource, knowledgeIndexSource] = await Promise.all([ readFile(pagePath, "utf8").catch(() => "{}"), readFile(knowledgePath, "utf8").catch(() => "{}"), readFile(join(repoRoot, "index.html"), "utf8").catch(() => ""), readFile(join(repoRoot, "knowledge", "index.html"), "utf8").catch(() => ""), ]); const pageRefs = collectAssetRefsFromValue(JSON.parse(pageSource)); collectAssetRefsFromValue(JSON.parse(knowledgeSource), pageRefs); const renderedRefs = collectAssetRefsFromText(`${indexSource}\n${knowledgeIndexSource}`); 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 normalizeRemoteRoot(value) { const rawPath = String(value || "").trim().replace(/\\/g, "/"); if (!rawPath) throw new Error("Укажите папку сайта на хостинге, например httpdocs"); const absolute = rawPath.startsWith("/"); const segments = rawPath .split("/") .map((segment) => segment.trim()) .filter(Boolean); if (!segments.length || segments.some((segment) => segment === "." || segment === "..")) { throw new Error("Некорректная папка сайта на хостинге"); } return `${absolute ? "/" : ""}${segments.join("/")}`; } function remoteJoin(...parts) { const rawPath = parts .filter((part) => part != null && String(part).trim()) .map((part) => String(part).replace(/\\/g, "/")) .join("/"); const absolute = rawPath.startsWith("/"); const segments = rawPath.split("/").filter(Boolean); return `${absolute ? "/" : ""}${segments.join("/")}`; } function remoteParent(path) { const normalized = String(path || "").replace(/\\/g, "/"); const absolute = normalized.startsWith("/"); const segments = normalized.split("/").filter(Boolean); segments.pop(); if (!segments.length) return absolute ? "/" : "."; return `${absolute ? "/" : ""}${segments.join("/")}`; } async function collectPublicDeployFiles() { const files = []; async function addFile(absolutePath, relativePath) { const fileStat = await stat(absolutePath); if (!fileStat.isFile()) return; files.push({ absolutePath, relativePath: relativePath.replace(/\\/g, "/"), size: fileStat.size, mtimeMs: fileStat.mtimeMs, }); } async function walk(directory, relativeRoot) { const entries = await readdir(directory, { withFileTypes: true }); for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name, "ru"))) { if (BROWSER_EXCLUDED_DIRS.has(entry.name)) continue; const absolutePath = join(directory, entry.name); const relativePath = `${relativeRoot}/${entry.name}`.replace(/^\/+/, ""); if (entry.isSymbolicLink()) continue; if (entry.isDirectory()) { await walk(absolutePath, relativePath); continue; } if (entry.isFile()) await addFile(absolutePath, relativePath); } } const rootEntries = await readdir(repoRoot, { withFileTypes: true }); for (const entry of rootEntries.sort((left, right) => left.name.localeCompare(right.name, "ru"))) { if (BROWSER_EXCLUDED_DIRS.has(entry.name)) continue; if (entry.name.startsWith(".") && !PUBLIC_DEPLOY_ROOT_SPECIAL_FILES.has(entry.name)) continue; if (PUBLIC_DEPLOY_ROOT_EXCLUDES.has(entry.name)) continue; const absolutePath = join(repoRoot, entry.name); if (entry.isSymbolicLink()) continue; if (entry.isDirectory()) { if (PUBLIC_DEPLOY_DIRECTORIES.has(entry.name)) await walk(absolutePath, entry.name); continue; } if (entry.isFile()) { const extension = extname(entry.name).toLowerCase(); if (PUBLIC_DEPLOY_ROOT_EXTENSIONS.has(extension) || PUBLIC_DEPLOY_ROOT_SPECIAL_FILES.has(entry.name)) { await addFile(absolutePath, entry.name); } } } return files; } function normalizedZipEntryName(value) { const name = String(value || "").replace(/\\/g, "/").replace(/^\/+/, ""); if (!name || name.startsWith("__MACOSX/") || name.endsWith("/.DS_Store")) return ""; const parts = name.split("/").filter(Boolean); if (!parts.length || parts.some((part) => part === "." || part === "..")) { throw new Error(`Некорректный путь в архиве: ${value}`); } return parts.join("/"); } function zipCommonRoot(entries) { const names = entries .map((entry) => normalizedZipEntryName(entry.entryName)) .filter(Boolean); if (!names.length) return ""; const firstSegments = new Set(names.map((name) => name.split("/")[0])); if (firstSegments.size !== 1) return ""; const root = [...firstSegments][0]; const hasRootRequiredFiles = names.some((name) => name === "content/pages/home.json" || name === "index.html"); const hasNestedRequiredFiles = names.some((name) => name === `${root}/content/pages/home.json` || name === `${root}/index.html`); return !hasRootRequiredFiles && hasNestedRequiredFiles ? root : ""; } async function clearSiteWorkspace() { const resolvedRoot = resolve(repoRoot); if (resolvedRoot === "/" || resolvedRoot === cmsRoot || resolvedRoot.startsWith(`${cmsRoot}/`)) { throw new Error("Опасный путь workspace сайта, очистка запрещена"); } await mkdir(repoRoot, { recursive: true }); const entries = await readdir(repoRoot, { withFileTypes: true }).catch(() => []); for (const entry of entries) { if (entry.name === ".git") continue; await rm(join(repoRoot, entry.name), { recursive: true, force: true }); } } async function unpackSiteArchive({ fileName, fileBuffer }) { if (!fileName.toLowerCase().endsWith(".zip")) { throw new Error("Пока поддержан архив сайта в формате .zip"); } const zip = new AdmZip(fileBuffer); const entries = zip.getEntries(); const commonRoot = zipCommonRoot(entries); const archivePaths = new Set( entries .map((entry) => normalizedZipEntryName(entry.entryName)) .filter(Boolean) .map((name) => (commonRoot && name.startsWith(`${commonRoot}/`) ? name.slice(commonRoot.length + 1) : name)) .filter(Boolean), ); const missingRequired = REQUIRED_SITE_FILES.map((item) => item.label).filter((label) => !archivePaths.has(label)); if (missingRequired.length) { throw new Error(`Архив сайта неполный. Отсутствует: ${missingRequired.join(", ")}`); } await clearSiteWorkspace(); let files = 0; let bytes = 0; for (const entry of entries) { let relativePath = normalizedZipEntryName(entry.entryName); if (!relativePath) continue; if (commonRoot) { if (relativePath === commonRoot) continue; if (!relativePath.startsWith(`${commonRoot}/`)) continue; relativePath = relativePath.slice(commonRoot.length + 1); } if (!relativePath) continue; if (pathSegments(relativePath).some((segment) => segment === ".git" || segment === "node_modules")) continue; const targetPath = join(repoRoot, relativePath); const resolvedTarget = resolve(targetPath); const resolvedRoot = resolve(repoRoot); if (resolvedTarget !== resolvedRoot && !resolvedTarget.startsWith(`${resolvedRoot}/`)) { throw new Error(`Архив пытается записать файл за пределы сайта: ${relativePath}`); } if (entry.isDirectory) { await mkdir(targetPath, { recursive: true }); continue; } const data = entry.getData(); await mkdir(dirname(targetPath), { recursive: true }); await writeFile(targetPath, data); files += 1; bytes += data.length; } const site = currentSiteStatus(); if (!site.ready) { throw new Error(`Архив распакован, но сайт неполный. Отсутствует: ${site.missingFiles.join(", ")}`); } return { ok: true, files, bytes, label: formatProjectSize(bytes), site, size: await calculateProjectSize(), }; } async function readDeployConfig() { const deploy = projectConfig.deploy || {}; const method = String(deploy.method || "sftp").trim().toLowerCase(); if (!deploy.enabled) throw new Error("Деплой выключен в настройках проекта"); if (method !== "sftp") throw new Error("Сейчас поддержан только SFTP. Для McHost используйте метод sftp и порт 22"); if (!deploy.host) throw new Error("Укажите FTP / SFTP host"); if (!deploy.username) throw new Error("Укажите логин SFTP"); const password = await readDeployPasswordSecret(); return { method, host: String(deploy.host).trim(), port: Number(deploy.port || 22), username: String(deploy.username).trim(), password, remoteRoot: normalizeRemoteRoot(deploy.remotePath || "httpdocs"), }; } async function ensureRemoteDirectory(client, path, cache) { const normalized = remoteJoin(path); if (!normalized || normalized === "." || cache.has(normalized)) return; await client.mkdir(normalized, true); cache.add(normalized); } async function shouldSkipRemoteFile(client, localFile, remotePath) { try { const remoteStat = await client.stat(remotePath); if (!remoteStat || Number(remoteStat.size) !== localFile.size) return false; const remoteMtime = Number(remoteStat.modifyTime || remoteStat.mtime || 0); return remoteMtime && remoteMtime >= localFile.mtimeMs - 1000; } catch { return false; } } function publishJobPercent(job) { if (job.state === "completed") return 100; if (!job.totalBytes) return 0; return Math.max(0, Math.min(100, Math.round((job.processedBytes / job.totalBytes) * 1000) / 10)); } function publicPublishJob(job) { if (!job) return null; return { id: job.id, state: job.state, rendered: job.rendered, host: job.host, remoteRoot: job.remoteRoot, totalFiles: job.totalFiles, totalBytes: job.totalBytes, totalLabel: formatProjectSize(job.totalBytes), processedFiles: job.processedFiles, processedBytes: job.processedBytes, processedLabel: formatProjectSize(job.processedBytes), uploadedFiles: job.uploadedFiles, uploadedBytes: job.uploadedBytes, uploadedLabel: formatProjectSize(job.uploadedBytes), skippedFiles: job.skippedFiles, skippedBytes: job.skippedBytes, skippedLabel: formatProjectSize(job.skippedBytes), currentFile: job.currentFile, currentFileBytes: job.currentFileBytes, currentFileSize: job.currentFileSize, percent: publishJobPercent(job), error: job.error, startedAt: job.startedAt, updatedAt: job.updatedAt, completedAt: job.completedAt, }; } function cleanupPublishJobs() { const cutoff = Date.now() - PUBLISH_JOB_RETENTION_MS; for (const [id, job] of publishJobs) { const timestamp = Date.parse(job.completedAt || job.updatedAt || job.startedAt || 0); if (timestamp && timestamp < cutoff) publishJobs.delete(id); } } function activePublishJob() { return [...publishJobs.values()].find((job) => !["completed", "failed"].includes(job.state)) || null; } function latestPublishJob() { return [...publishJobs.values()].sort((left, right) => Date.parse(right.startedAt) - Date.parse(left.startedAt))[0] || null; } async function writePublishLog(job, event, extra = {}) { const entry = { timestamp: new Date().toISOString(), event, project: activeProjectId, job: publicPublishJob(job), ...extra, }; await appendFile(publishLogPath, `${JSON.stringify(entry)}\n`, "utf8").catch((error) => { console.error(`Publish log write failed: ${error.message}`); }); } function touchPublishJob(job, patch = {}) { Object.assign(job, patch, { updatedAt: new Date().toISOString() }); } async function publishProject({ rendered = false, job = null } = {}) { const deployConfig = await readDeployConfig(); const files = await collectPublicDeployFiles(); if (!files.length) throw new Error("Нет публичных файлов для публикации"); const client = new SftpClient(); const directoryCache = new Set(); const report = { ok: true, rendered, method: deployConfig.method, host: deployConfig.host, remoteRoot: deployConfig.remoteRoot, totalFiles: files.length, uploadedFiles: 0, skippedFiles: 0, uploadedBytes: 0, skippedBytes: 0, processedFiles: 0, processedBytes: 0, totalBytes: files.reduce((sum, file) => sum + file.size, 0), }; if (job) { touchPublishJob(job, { state: "connecting", host: report.host, remoteRoot: report.remoteRoot, totalFiles: report.totalFiles, totalBytes: report.totalBytes, }); } try { await client.connect({ host: deployConfig.host, port: deployConfig.port, username: deployConfig.username, password: deployConfig.password, readyTimeout: 30000, }); if (job) touchPublishJob(job, { state: "publishing" }); await ensureRemoteDirectory(client, deployConfig.remoteRoot, directoryCache); for (const file of files) { const remotePath = remoteJoin(deployConfig.remoteRoot, file.relativePath); const completedBytes = report.processedBytes; if (job) { touchPublishJob(job, { currentFile: file.relativePath, currentFileBytes: 0, currentFileSize: file.size, }); } await ensureRemoteDirectory(client, remoteParent(remotePath), directoryCache); if (await shouldSkipRemoteFile(client, file, remotePath)) { report.skippedFiles += 1; report.skippedBytes += file.size; report.processedFiles += 1; report.processedBytes += file.size; if (job) { touchPublishJob(job, { skippedFiles: report.skippedFiles, skippedBytes: report.skippedBytes, processedFiles: report.processedFiles, processedBytes: report.processedBytes, currentFileBytes: file.size, }); } continue; } await client.fastPut(file.absolutePath, remotePath, { step: (transferred) => { if (!job) return; const currentFileBytes = Math.max(0, Math.min(file.size, Number(transferred || 0))); touchPublishJob(job, { currentFileBytes, processedBytes: completedBytes + currentFileBytes, }); }, }); report.uploadedFiles += 1; report.uploadedBytes += file.size; report.processedFiles += 1; report.processedBytes += file.size; if (job) { touchPublishJob(job, { uploadedFiles: report.uploadedFiles, uploadedBytes: report.uploadedBytes, processedFiles: report.processedFiles, processedBytes: report.processedBytes, currentFileBytes: file.size, }); } } report.totalLabel = formatProjectSize(report.totalBytes); report.uploadedLabel = formatProjectSize(report.uploadedBytes); report.skippedLabel = formatProjectSize(report.skippedBytes); return report; } finally { await client.end().catch(() => {}); } } function startPublishJob({ rendered = false } = {}) { cleanupPublishJobs(); const activeJob = activePublishJob(); if (activeJob) return { job: publicPublishJob(activeJob), reused: true }; const now = new Date().toISOString(); const job = { id: `${Date.now().toString(36)}-${randomBytes(5).toString("hex")}`, state: "queued", rendered, host: "", remoteRoot: "", totalFiles: 0, totalBytes: 0, processedFiles: 0, processedBytes: 0, uploadedFiles: 0, uploadedBytes: 0, skippedFiles: 0, skippedBytes: 0, currentFile: "", currentFileBytes: 0, currentFileSize: 0, error: "", startedAt: now, updatedAt: now, completedAt: "", }; publishJobs.set(job.id, job); writePublishLog(job, "started"); void (async () => { try { touchPublishJob(job, { state: "scanning" }); const report = await publishProject({ rendered, job }); touchPublishJob(job, { state: "completed", processedFiles: report.totalFiles, processedBytes: report.totalBytes, uploadedFiles: report.uploadedFiles, uploadedBytes: report.uploadedBytes, skippedFiles: report.skippedFiles, skippedBytes: report.skippedBytes, currentFile: "", currentFileBytes: 0, currentFileSize: 0, completedAt: new Date().toISOString(), }); await writePublishLog(job, "completed"); } catch (error) { touchPublishJob(job, { state: "failed", error: error?.message || String(error), completedAt: new Date().toISOString(), }); console.error(`Publish job ${job.id} failed`, error); await writePublishLog(job, "failed", { stack: error?.stack || "" }); } })(); return { job: publicPublishJob(job), reused: false }; } async function recentPublishLog(limit = 20) { const source = await readFile(publishLogPath, "utf8").catch((error) => { if (error?.code === "ENOENT") return ""; throw error; }); return source .trim() .split("\n") .filter(Boolean) .slice(-Math.max(1, Math.min(100, Number(limit || 20)))) .map((line) => { try { return JSON.parse(line); } catch { return { timestamp: "", event: "invalid-log-entry", message: line }; } }) .reverse(); } async function maybePublishAfterRender(renderResult) { if (!projectConfig.deploy?.enabled) { return { publish: null, ...renderResult, }; } const publish = await publishProject({ rendered: true }); return { publish, ...renderResult, }; } 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 adminRootFiles = new Set(["/admin.css", "/admin.js", "/nodedc-logo.svg"]); const requestPath = withoutQuery === "/" || withoutQuery === "/admin" || withoutQuery === "/admin/" ? "/admin/index.html" : adminRootFiles.has(withoutQuery) ? `/admin${withoutQuery}` : withoutQuery; const normalized = normalize(requestPath).replace(/^(\.\.[/\\])+/, ""); const isAdminAsset = normalized === "/admin/index.html" || normalized.startsWith("/admin/"); const root = isAdminAsset ? cmsRoot : repoRoot; const absolute = join(root, normalized); const pathFromRoot = relative(root, absolute); if (pathFromRoot.startsWith("..") || pathFromRoot === "..") { return null; } return absolute; } async function serveStatic(req, res) { let filePath = safePublicPath(new URL(req.url, `http://${host}:${port}`).pathname); if (!filePath) { send(res, 403, "Forbidden"); return; } try { let fileStat = await stat(filePath); if (fileStat.isDirectory()) { filePath = join(filePath, "index.html"); 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 (await handleAuthRoute(req, res, url)) { return; } if (!authorizeRequest(req, res, url)) { return; } if (req.method === "GET" && url.pathname === "/api/project/config") { sendJson(res, 200, publicProjectConfig()); return; } if (req.method === "PUT" && url.pathname === "/api/project/config") { const payload = JSON.parse(await readBody(req)); sendJson(res, 200, { ok: true, project: await saveProjectConfig(payload) }); return; } if (req.method === "POST" && url.pathname === "/api/project/publish") { if (!ensureSiteReady(res)) return; const publish = await publishProject(); sendJson(res, 200, { ok: true, publish }); return; } if (req.method === "POST" && url.pathname === "/api/project/publish/start") { if (!ensureSiteReady(res)) return; sendJson(res, 202, { ok: true, ...startPublishJob() }); return; } if (req.method === "GET" && url.pathname === "/api/project/publish/status") { const id = String(url.searchParams.get("jobId") || "").trim(); const job = id ? publishJobs.get(id) : latestPublishJob(); if (!job) { sendJson(res, 404, { ok: false, error: "Публикация не найдена" }); return; } sendJson(res, 200, { ok: true, job: publicPublishJob(job) }); return; } if (req.method === "GET" && url.pathname === "/api/project/publish/log") { sendJson(res, 200, { ok: true, entries: await recentPublishLog(url.searchParams.get("limit")) }); return; } if (req.method === "POST" && url.pathname === "/api/project/upload-site") { if (!isMultipartRequest(req)) { sendJson(res, 400, { ok: false, error: "Ожидается multipart/form-data с ZIP-архивом сайта" }); return; } const upload = parseMultipartUpload(req, await readBodyBuffer(req)); const result = await unpackSiteArchive(upload); sendJson(res, 200, result); return; } if (req.method === "GET" && url.pathname === "/api/page/home") { if (!ensureSiteReady(res)) return; send(res, 200, await readFile(pagePath, "utf8"), "application/json; charset=utf-8"); return; } if (req.method === "GET" && url.pathname === "/api/knowledge") { if (!ensureSiteReady(res)) return; send(res, 200, await readFile(knowledgePath, "utf8"), "application/json; charset=utf-8"); return; } if (req.method === "GET" && url.pathname === "/api/landing-targets") { if (!ensureSiteReady(res)) return; const page = JSON.parse(await readFile(pagePath, "utf8")); validateLandingTargets(page); sendJson(res, 200, { ok: true, targets: landingTargetsForPage(page) }); return; } if (req.method === "GET" && url.pathname === "/api/block-templates/home") { if (!ensureSiteReady(res)) return; 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") { if (!ensureSiteReady(res)) return; const body = await readBody(req); const parsed = JSON.parse(body); validateLandingTargets(parsed); await writeFile(pagePath, `${JSON.stringify(parsed, null, 2)}\n`); sendJson(res, 200, { ok: true }); return; } if (req.method === "PUT" && url.pathname === "/api/knowledge") { if (!ensureSiteReady(res)) return; const body = await readBody(req); const parsed = JSON.parse(body); await writeFile(knowledgePath, `${JSON.stringify(parsed, null, 2)}\n`); sendJson(res, 200, { ok: true }); return; } if (req.method === "POST" && url.pathname === "/api/render/home") { if (!ensureSiteReady(res)) return; const result = await runNodeScript("render-home.mjs"); sendJson(res, 200, { ok: true, ...(await maybePublishAfterRender(result)) }); return; } if (req.method === "POST" && url.pathname === "/api/render/knowledge") { if (!ensureSiteReady(res)) return; const result = await runNodeScript("render-knowledge.mjs"); sendJson(res, 200, { ok: true, ...(await maybePublishAfterRender(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) { console.error(`${req.method || "REQUEST"} ${req.url || "/"} failed`, error); sendJson(res, 500, { ok: false, error: error.message }); } }); server.listen(port, host, () => { console.log(`DC CMS: http://${host}:${port}/`); console.log(`Project root: ${repoRoot}`); });