2280 lines
71 KiB
JavaScript
2280 lines
71 KiB
JavaScript
import { createServer } from "node:http";
|
||
import { createReadStream } from "node:fs";
|
||
import { copyFile, cp, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
||
import { extname, join, normalize, relative, 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 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}/`;
|
||
}
|
||
|
||
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(),
|
||
internalHost: String(process.env.CMS_OIDC_INTERNAL_HOST || "").trim(),
|
||
internalProto: String(process.env.CMS_OIDC_INTERNAL_PROTO || "").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 "/admin/";
|
||
if (/^https?:\/\//i.test(rawValue)) return "/admin/";
|
||
return rawValue.startsWith("/") ? rawValue : "/admin/";
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
function oidcFetchHeaders(headers) {
|
||
const nextHeaders = new Headers(headers || {});
|
||
if (!authConfig.internalBaseUrl) return nextHeaders;
|
||
|
||
const publicIssuer = new URL(authConfig.issuer);
|
||
const forwardedHost = authConfig.internalHost || publicIssuer.host;
|
||
const forwardedProto = authConfig.internalProto || publicIssuer.protocol.replace(/:$/, "");
|
||
nextHeaders.set("host", forwardedHost);
|
||
nextHeaders.set("x-forwarded-host", forwardedHost);
|
||
nextHeaders.set("x-forwarded-proto", forwardedProto);
|
||
return nextHeaders;
|
||
}
|
||
|
||
async function fetchOidc(rawUrl, options = {}) {
|
||
return fetch(oidcInternalFetchUrl(rawUrl), {
|
||
...options,
|
||
headers: oidcFetchHeaders(options.headers),
|
||
});
|
||
}
|
||
|
||
async function fetchOidcJson(rawUrl, options = {}) {
|
||
const response = await fetchOidc(rawUrl, {
|
||
...options,
|
||
headers: {
|
||
accept: "application/json",
|
||
...(options.headers || {}),
|
||
},
|
||
});
|
||
const payload = await response.json().catch(() => null);
|
||
return { response, payload };
|
||
}
|
||
|
||
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 } = await fetchOidcJson(wellKnownUrl);
|
||
if (!response.ok || !payload) {
|
||
throw new Error(`Не удалось загрузить OIDC discovery: ${response.status}`);
|
||
}
|
||
discoveryCache = 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 } = await fetchOidcJson(discovery.jwks_uri);
|
||
if (!response.ok || !payload) {
|
||
throw new Error(`Не удалось загрузить OIDC JWKS: ${response.status}`);
|
||
}
|
||
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, "/admin/"));
|
||
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,
|
||
},
|
||
};
|
||
}
|
||
|
||
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 requestPath =
|
||
withoutQuery === "/"
|
||
? "/index.html"
|
||
: withoutQuery === "/admin" || withoutQuery === "/admin/"
|
||
? "/admin/index.html"
|
||
: 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") {
|
||
send(res, 200, await readFile(pagePath, "utf8"), "application/json; charset=utf-8");
|
||
return;
|
||
}
|
||
|
||
if (req.method === "GET" && url.pathname === "/api/knowledge") {
|
||
send(res, 200, await readFile(knowledgePath, "utf8"), "application/json; charset=utf-8");
|
||
return;
|
||
}
|
||
|
||
if (req.method === "GET" && url.pathname === "/api/landing-targets") {
|
||
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") {
|
||
send(res, 200, await readFile(blockTemplatesPath, "utf8"), "application/json; charset=utf-8");
|
||
return;
|
||
}
|
||
|
||
if (req.method === "GET" && url.pathname === "/api/media/assets") {
|
||
sendJson(res, 200, await listMediaAssets());
|
||
return;
|
||
}
|
||
|
||
if (req.method === "GET" && url.pathname === "/api/media/browse") {
|
||
sendJson(res, 200, await browseProjectDirectory(url.searchParams.get("path") || ""));
|
||
return;
|
||
}
|
||
|
||
if (req.method === "GET" && url.pathname === "/api/media/site-size") {
|
||
sendJson(res, 200, await calculateProjectSize());
|
||
return;
|
||
}
|
||
|
||
if (req.method === "GET" && url.pathname === "/api/media/trash") {
|
||
sendJson(res, 200, await listTrashAssets());
|
||
return;
|
||
}
|
||
|
||
if (req.method === "PUT" && url.pathname === "/api/page/home") {
|
||
const body = await readBody(req);
|
||
const parsed = JSON.parse(body);
|
||
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") {
|
||
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") {
|
||
const result = await runNodeScript("render-home.mjs");
|
||
sendJson(res, 200, { ok: true, ...result });
|
||
return;
|
||
}
|
||
|
||
if (req.method === "POST" && url.pathname === "/api/render/knowledge") {
|
||
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 admin: http://${host}:${port}/admin/`);
|
||
console.log(`Project site: http://${host}:${port}/`);
|
||
console.log(`Project root: ${repoRoot}`);
|
||
});
|