DC_CMS/server/admin-server.mjs

2369 lines
74 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

import { createServer } from "node:http";
import { createReadStream, statSync } from "node:fs";
import { 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 { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
import { createLocalJWKSet, jwtVerify } from "jose";
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`);
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 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 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() {
return {
...projectConfig,
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(),
passwordEnv: String(payload?.deploy?.passwordEnv || "").trim(),
remotePath: String(payload?.deploy?.remotePath || "").trim(),
deployOnRender: Boolean(payload?.deploy?.deployOnRender),
};
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) {
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 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 === "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, ...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, ...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(`DC CMS: http://${host}:${port}/`);
console.log(`Project root: ${repoRoot}`);
});