Add CMS site upload and SFTP publish

This commit is contained in:
DCCONSTRUCTIONS 2026-07-06 13:45:08 +03:00
parent cfee9d0c19
commit 808d867dd7
8 changed files with 737 additions and 40 deletions

1
.gitignore vendored
View File

@ -7,5 +7,6 @@ __pycache__/
.env
*.local
infra/.env
projects/*.secrets.json
.cms-smoke.png

View File

@ -704,8 +704,10 @@ body[data-file-picker-active="true"] .editor {
.dirty-state {
display: inline-flex;
min-width: 13.5rem;
min-height: 2rem;
align-items: center;
justify-content: center;
border-radius: var(--launcher-radius-circle);
background: rgba(255, 255, 255, 0.055);
color: var(--text-muted);
@ -970,6 +972,20 @@ body:not([data-admin-mode="knowledge"]) #knowledge-form {
grid-column: 1 / -1;
}
.project-archive-field {
grid-column: 1 / -1;
}
.project-archive-control {
display: flex;
min-height: 2.72rem;
align-items: center;
}
.project-archive-button {
min-width: 13rem;
}
.secret-input-wrap {
position: relative;
display: grid;

View File

@ -165,6 +165,7 @@ const el = {
reload: document.querySelector("#reload"),
save: document.querySelector("#save"),
render: document.querySelector("#render"),
publish: document.querySelector("#publish"),
dirtyState: document.querySelector("#dirty-state"),
status: document.querySelector("#status"),
empty: document.querySelector("#empty-state"),
@ -640,13 +641,6 @@ function syncProjectConfigFromForm() {
async function saveProjectSettings() {
syncProjectConfigFromForm();
const passwordEnv = String(state.projectConfig?.deploy?.passwordEnv || "").trim();
if (passwordEnv && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(passwordEnv)) {
setByPath(state.projectConfig, "deploy.passwordEnv", "MCHOST_PASSWORD");
renderProjectSettings();
setStatus("FTP-пароль не сохраняем в модель. Поле сброшено к имени переменной MCHOST_PASSWORD.");
return;
}
const response = await fetch("/api/project/config", {
method: "PUT",
@ -681,6 +675,29 @@ async function checkProjectConnection() {
setStatus(`Проект доступен. Размер локального сайта: ${size?.label || "не рассчитан"}.`);
}
async function publishProject() {
const response = await fetch("/api/project/publish", { method: "POST" });
const payload = await response.json().catch(() => null);
if (!response.ok || !payload?.ok) {
throw new Error(payload?.error || "Не удалось опубликовать проект");
}
setStatus(publishStatusText(payload).trim() || "Проект опубликован.");
}
function publishStatusText(payload) {
const publish = payload?.publish;
if (!publish) return "";
const uploaded = Number(publish.uploadedFiles || 0).toLocaleString("ru-RU");
const skipped = Number(publish.skippedFiles || 0).toLocaleString("ru-RU");
const total = Number(publish.totalFiles || 0).toLocaleString("ru-RU");
const volume = publish.uploadedLabel || "0 МБ";
return ` Опубликовано на ${publish.host}: загружено ${uploaded}/${total}, пропущено ${skipped}, объем ${volume}.`;
}
function ensurePageSeo() {
if (!state.page) return;
state.page.seo = {
@ -779,6 +796,10 @@ function updateAdminModeSurface() {
el.render.title = isKnowledge ? "Сохранить дерево и пересобрать публичный раздел /knowledge/" : "Сохранить модель и пересобрать публичный index.html";
el.render.disabled = !hasSite && !isProjectSettingsSelected();
}
if (el.publish) {
el.publish.classList.add("hidden");
el.publish.disabled = true;
}
if (el.reload) {
el.reload.disabled = !hasSite && !isProjectSettingsSelected();
}
@ -1992,14 +2013,14 @@ function createProjectField({ path, label, type = "text", placeholder = "", opti
}
control.dataset.projectPath = path;
control.addEventListener("input", () => {
const onProjectFieldChange = () => {
setProjectConfigValue(path, normalizeProjectInputValue(control));
markDirty("Настройки проекта изменены. Нажмите «Сохранить настройки».");
});
control.addEventListener("change", () => {
setProjectConfigValue(path, normalizeProjectInputValue(control));
markDirty("Настройки проекта изменены. Нажмите «Сохранить настройки».");
});
if (path === "deploy.enabled") renderProjectSettings();
};
control.addEventListener("input", onProjectFieldChange);
control.addEventListener("change", onProjectFieldChange);
if (hint && type !== "checkbox") {
const note = document.createElement("small");
@ -2048,6 +2069,62 @@ function createProjectSettingsCard({ kicker, title, note = "", fields = [], runt
return card;
}
async function uploadSiteArchive(file) {
if (!file) return;
const formData = new FormData();
formData.append("file", file, file.name);
setStatus(`Загружаем архив сайта: ${file.name}...`);
const response = await fetch("/api/project/upload-site", {
method: "POST",
body: formData,
});
const payload = await response.json().catch(() => null);
if (!response.ok || !payload?.ok) {
throw new Error(payload?.error || "Не удалось загрузить архив сайта");
}
state.projectConfig = await loadProjectConfig();
renderProjectSettings();
setStatus(`Архив сайта загружен: ${Number(payload.files || 0).toLocaleString("ru-RU")} файлов, workspace ${payload.size?.label || payload.label || "обновлен"}.`);
}
function renderProjectArchiveUpload() {
const field = document.createElement("div");
const title = document.createElement("span");
const badge = document.createElement("span");
const body = document.createElement("div");
const button = document.createElement("button");
const input = document.createElement("input");
const note = document.createElement("small");
field.className = "field-control project-archive-field";
title.textContent = "Архив сайта";
badge.className = "field-kind";
badge.textContent = "ZIP";
body.className = "project-archive-control";
button.className = "ghost-btn project-archive-button";
button.type = "button";
button.textContent = "Загрузить архив сайта";
input.type = "file";
input.accept = ".zip,application/zip";
input.className = "hidden";
note.className = "field-hint";
note.textContent = "ZIP должен содержать content/pages/home.json, content/knowledge.json, templates и tools рендера.";
button.addEventListener("click", () => input.click());
input.addEventListener("change", () => {
const file = input.files?.[0];
input.value = "";
uploadSiteArchive(file).catch((error) => setStatus(error.message));
});
body.append(button, input);
field.append(title, badge, body, note);
return field;
}
function renderProjectRuntimeLine(label, value) {
const row = document.createElement("div");
const code = document.createElement("code");
@ -2119,37 +2196,46 @@ function renderProjectSettings() {
el.render.textContent = "Проверить проект";
el.render.title = "Проверить доступ к модели, базе знаний и файлам проекта";
}
if (el.publish) {
const enabled = Boolean(state.projectConfig?.deploy?.enabled);
el.publish.classList.toggle("hidden", !enabled);
el.publish.disabled = !enabled || !siteReady() || state.projectConfigDirty;
el.publish.title = state.projectConfigDirty
? "Сначала сохраните настройки проекта"
: enabled
? "Опубликовать текущую копию сайта на хостинг"
: "Включите деплой в настройках проекта";
}
const projectCard = createProjectSettingsCard({
kicker: "Проект",
title: "Подключенный сайт",
note: "Эти поля описывают сайт, который сейчас обслуживает CMS. Локальный путь используется сервером, публичный URL нужен для будущего деплоя и ссылок.",
note: "Проект хранится в workspace CMS. Архив сайта загружается один раз и дальше редактируется через админку.",
fields: [
createProjectField({ path: "name", label: "Название проекта" }),
createProjectField({ path: "role", label: "Роль в сайдбаре" }),
createProjectField({ path: "siteRoot", label: "Локальный путь сайта", placeholder: "../NODEDC_SITE" }),
createProjectField({ path: "previewUrl", label: "Preview URL", placeholder: "http://127.0.0.1:8210/" }),
createProjectField({ path: "publicUrl", label: "Публичный домен", placeholder: "https://nodedc.ru/" }),
renderProjectArchiveUpload(),
],
});
const deployCard = createProjectSettingsCard({
kicker: "Хостинг",
title: "Публикация на McHost",
note: "Для McHost обычно используются FTP host вида aXXXX.ftp.mchost.ru, порт 21 для FTP или 22 для SFTP и папка сайта httpdocs. Пароль не пишем в JSON: passwordEnv хранит имя переменной окружения сервиса.",
kicker: "Публикация",
title: "Деплой на хостинг",
note: "Укажите доступ к хостингу. Пароль сохраняется на сервере в зашифрованном виде и не возвращается в браузер.",
fields: [
createProjectField({ path: "deploy.enabled", label: "Включить деплой", type: "checkbox" }),
createProjectField({ path: "deploy.deployOnRender", label: "Деплоить после рендера", type: "checkbox" }),
createProjectField({ path: "deploy.method", label: "Метод", options: ["sftp", "ftp"] }),
createProjectField({ path: "deploy.host", label: "FTP / SFTP host", placeholder: "a0000.ftp.mchost.ru" }),
createProjectField({ path: "deploy.port", label: "Порт", type: "number", placeholder: "22" }),
createProjectField({ path: "deploy.username", label: "Логин" }),
createProjectField({
path: "deploy.passwordEnv",
label: "Имя переменной пароля",
path: "deploy.password",
label: "Пароль",
type: "password",
placeholder: "MCHOST_PASSWORD",
hint: "Не вводите сюда сам FTP-пароль. Укажите имя переменной окружения сервиса, например MCHOST_PASSWORD.",
placeholder: state.projectConfig?.deploy?.passwordConfigured ? "Пароль сохранен. Введите новый для замены" : "Введите пароль",
hint: state.projectConfig?.deploy?.passwordConfigured
? "Пароль уже сохранен на сервере. Поле пустое, потому что секрет не возвращается в браузер."
: "Введите пароль один раз и нажмите «Сохранить настройки».",
}),
createProjectField({ path: "deploy.remotePath", label: "Папка сайта на хостинге", placeholder: "httpdocs" }),
],
@ -5481,7 +5567,9 @@ async function renderKnowledge() {
}
setDirty(false);
setStatus(payload.stdout ? `База знаний сохранена. ${payload.stdout}` : "База знаний сохранена и пересобрана.");
setStatus(
`${payload.stdout ? `База знаний сохранена. ${payload.stdout}` : "База знаний сохранена и пересобрана."}${publishStatusText(payload)}`,
);
}
async function loadPage() {
@ -5546,7 +5634,9 @@ async function renderHome() {
}
setDirty(false);
setStatus(payload.stdout ? `Модель сохранена. ${payload.stdout}` : "Модель сохранена, index.html пересобран.");
setStatus(
`${payload.stdout ? `Модель сохранена. ${payload.stdout}` : "Модель сохранена, index.html пересобран."}${publishStatusText(payload)}`,
);
}
function moveSelected(delta) {
@ -5856,6 +5946,10 @@ el.render.addEventListener("click", () => {
const action = state.mode === "knowledge" ? renderKnowledge : renderHome;
action().catch((error) => setStatus(error.message));
});
el.publish?.addEventListener("click", () => {
if (!isProjectSettingsSelected()) return;
publishProject().catch((error) => setStatus(error.message));
});
el.moveUp.addEventListener("click", () => moveSelected(-1));
el.moveDown.addEventListener("click", () => moveSelected(1));
el.duplicate.addEventListener("click", duplicateSelected);

View File

@ -88,6 +88,7 @@
<button id="reload" class="icon-btn" type="button" title="Перезагрузить данные"></button>
<button id="save" class="primary-btn" type="button" title="Записать текущую модель в content/pages/home.json">Сохранить модель</button>
<button id="render" class="ghost-btn" type="button" title="Сохранить модель и пересобрать публичный index.html">Обновить index.html</button>
<button id="publish" class="ghost-btn hidden" type="button" title="Опубликовать сайт на хостинг">Опубликовать</button>
</div>
</header>

View File

@ -50,5 +50,6 @@ CMS_REQUIRED_GROUPS=dc-cms:owner,dc-cms:admin,dc-cms:editor,dc-cms:publisher
# CMS app session
CMS_SESSION_SECRET=replace-with-random-secret
CMS_SECRETS_KEY=replace-with-random-secret
COOKIE_DOMAIN=
COOKIE_SECURE=true

189
package-lock.json generated
View File

@ -8,9 +8,88 @@
"name": "dc-cms",
"version": "0.1.0",
"dependencies": {
"jose": "^6.2.3"
"adm-zip": "^0.5.18",
"jose": "^6.2.3",
"ssh2-sftp-client": "^12.1.1"
}
},
"node_modules/adm-zip": {
"version": "0.5.18",
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.18.tgz",
"integrity": "sha512-ufJnssQGbxzLNS1Ho9bCtX4rQKCCvoVuDLHoJyc3F9dOGDB4BkWs2Ci0kv53lqocAEQ/Cbi+I2XCsNYGqVYqng==",
"license": "MIT",
"engines": {
"node": ">=12.0"
}
},
"node_modules/asn1": {
"version": "0.2.6",
"resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
"integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==",
"license": "MIT",
"dependencies": {
"safer-buffer": "~2.1.0"
}
},
"node_modules/bcrypt-pbkdf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
"integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==",
"license": "BSD-3-Clause",
"dependencies": {
"tweetnacl": "^0.14.3"
}
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"license": "MIT"
},
"node_modules/buildcheck": {
"version": "0.0.7",
"resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz",
"integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==",
"optional": true,
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/concat-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [
"node >= 6.0"
],
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.0.2",
"typedarray": "^0.0.6"
}
},
"node_modules/cpu-features": {
"version": "0.0.10",
"resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz",
"integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==",
"hasInstallScript": true,
"optional": true,
"dependencies": {
"buildcheck": "~0.0.6",
"nan": "^2.19.0"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/jose": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
@ -19,6 +98,114 @@
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/nan": {
"version": "2.28.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.28.0.tgz",
"integrity": "sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==",
"license": "MIT",
"optional": true
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/ssh2": {
"version": "1.17.0",
"resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz",
"integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==",
"hasInstallScript": true,
"dependencies": {
"asn1": "^0.2.6",
"bcrypt-pbkdf": "^1.0.2"
},
"engines": {
"node": ">=10.16.0"
},
"optionalDependencies": {
"cpu-features": "~0.0.10",
"nan": "^2.23.0"
}
},
"node_modules/ssh2-sftp-client": {
"version": "12.1.1",
"resolved": "https://registry.npmjs.org/ssh2-sftp-client/-/ssh2-sftp-client-12.1.1.tgz",
"integrity": "sha512-wYVDgwkpcKG2iPGQQ+QR33xkWqLFIaVrYvA+uON4pmxTPaPuB81f1aooUEPN75e/9DCK6rrKYXb6zR6zP3+EtA==",
"license": "Apache-2.0",
"dependencies": {
"concat-stream": "^2.0.0",
"ssh2": "^1.16.0"
},
"engines": {
"node": ">=18.20.4"
},
"funding": {
"type": "individual",
"url": "https://square.link/u/4g7sPflL"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/tweetnacl": {
"version": "0.14.5",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
"integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==",
"license": "Unlicense"
},
"node_modules/typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT"
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
}
}
}

View File

@ -8,6 +8,8 @@
"start": "node server/admin-server.mjs"
},
"dependencies": {
"jose": "^6.2.3"
"adm-zip": "^0.5.18",
"jose": "^6.2.3",
"ssh2-sftp-client": "^12.1.1"
}
}

View File

@ -5,14 +5,17 @@ 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 { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
import { createLocalJWKSet, jwtVerify } from "jose";
import SftpClient from "ssh2-sftp-client";
import AdmZip from "adm-zip";
const cmsRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
const port = Number(process.env.PORT || 8090);
const host = process.env.HOST || "127.0.0.1";
const activeProjectId = process.env.CMS_PROJECT || "nodedc";
const projectConfigPath = join(cmsRoot, "projects", `${activeProjectId}.json`);
const projectSecretsPath = join(cmsRoot, "projects", `${activeProjectId}.secrets.json`);
async function loadProjectConfig() {
const source = await readFile(projectConfigPath, "utf8");
@ -125,6 +128,10 @@ const REQUIRED_SITE_FILES = [
{ key: "renderKnowledge", label: "tools/render-knowledge.mjs", path: join(repoRoot, "tools", "render-knowledge.mjs") },
{ key: "homeShell", label: "templates/pages/home/shell.html", path: join(repoRoot, "templates", "pages", "home", "shell.html") },
];
const PUBLIC_DEPLOY_DIRECTORIES = new Set(["_astro", "assets", "knowledge"]);
const PUBLIC_DEPLOY_ROOT_EXTENSIONS = new Set([".html", ".ico", ".json", ".png", ".svg", ".txt", ".webmanifest", ".xml"]);
const PUBLIC_DEPLOY_ROOT_EXCLUDES = new Set(["package.json", "package-lock.json", "README.md", "screenlog.0"]);
const PUBLIC_DEPLOY_ROOT_SPECIAL_FILES = new Set([".htaccess"]);
const MIME = {
".html": "text/html; charset=utf-8",
@ -215,11 +222,6 @@ function trailingSlashUrl(value) {
return trimmed.endsWith("/") ? trimmed : `${trimmed}/`;
}
function normalizeSecretEnvName(value, fallback = "MCHOST_PASSWORD") {
const trimmed = String(value || "").trim();
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(trimmed) ? trimmed : fallback;
}
function fileReady(filePath) {
try {
return statSync(filePath).isFile();
@ -228,6 +230,67 @@ function fileReady(filePath) {
}
}
function secretEncryptionKey() {
const seed = process.env.CMS_SECRETS_KEY || process.env.CMS_SESSION_SECRET || "dc-cms-local-development-secret";
return createHash("sha256").update(seed).digest();
}
function encryptSecret(value) {
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", secretEncryptionKey(), iv);
const encrypted = Buffer.concat([cipher.update(String(value), "utf8"), cipher.final()]);
const tag = cipher.getAuthTag();
return `v1:${iv.toString("base64url")}:${tag.toString("base64url")}:${encrypted.toString("base64url")}`;
}
function decryptSecret(value) {
const [version, ivValue, tagValue, encryptedValue] = String(value || "").split(":");
if (version !== "v1" || !ivValue || !tagValue || !encryptedValue) {
throw new Error("Некорректный формат сохраненного секрета");
}
const decipher = createDecipheriv("aes-256-gcm", secretEncryptionKey(), Buffer.from(ivValue, "base64url"));
decipher.setAuthTag(Buffer.from(tagValue, "base64url"));
return Buffer.concat([
decipher.update(Buffer.from(encryptedValue, "base64url")),
decipher.final(),
]).toString("utf8");
}
async function readProjectSecrets() {
try {
return JSON.parse(await readFile(projectSecretsPath, "utf8"));
} catch (error) {
if (error?.code === "ENOENT") return {};
throw error;
}
}
async function writeProjectSecrets(secrets) {
await writeFile(projectSecretsPath, `${JSON.stringify(secrets, null, 2)}\n`, { mode: 0o600 });
}
async function saveDeployPasswordSecret(payload) {
const password = payload?.deploy?.password;
if (typeof password !== "string" || !password.length) return;
const secrets = await readProjectSecrets();
secrets.deploy = {
...(secrets.deploy || {}),
password: encryptSecret(password),
updatedAt: new Date().toISOString(),
};
await writeProjectSecrets(secrets);
}
async function readDeployPasswordSecret() {
const secrets = await readProjectSecrets();
if (!secrets.deploy?.password) {
throw new Error("Введите и сохраните пароль SFTP в настройках проекта");
}
return decryptSecret(secrets.deploy.password);
}
function currentSiteStatus() {
const files = REQUIRED_SITE_FILES.map((item) => ({
key: item.key,
@ -816,8 +879,11 @@ function authorizeRequest(req, res, url) {
function publicProjectConfig() {
const deploy = {
...(projectConfig.deploy || {}),
passwordEnv: normalizeSecretEnvName(projectConfig.deploy?.passwordEnv),
passwordConfigured: fileReady(projectSecretsPath),
};
delete deploy.password;
delete deploy.passwordEnv;
delete deploy.deployOnRender;
return {
...projectConfig,
@ -840,9 +906,7 @@ function normalizeProjectConfig(payload) {
host: String(payload?.deploy?.host || "").trim(),
port: Number(payload?.deploy?.port || (payload?.deploy?.method === "ftp" ? 21 : 22)),
username: String(payload?.deploy?.username || "").trim(),
passwordEnv: normalizeSecretEnvName(payload?.deploy?.passwordEnv),
remotePath: String(payload?.deploy?.remotePath || "").trim(),
deployOnRender: Boolean(payload?.deploy?.deployOnRender),
};
return {
@ -857,6 +921,7 @@ function normalizeProjectConfig(payload) {
}
async function saveProjectConfig(payload) {
await saveDeployPasswordSecret(payload);
const nextConfig = normalizeProjectConfig(payload);
await writeFile(projectConfigPath, `${JSON.stringify(nextConfig, null, 2)}\n`);
projectConfig = nextConfig;
@ -1451,6 +1516,318 @@ async function calculateProjectSize() {
};
}
function normalizeRemoteRoot(value) {
const rawPath = String(value || "").trim().replace(/\\/g, "/");
if (!rawPath) throw new Error("Укажите папку сайта на хостинге, например httpdocs");
const absolute = rawPath.startsWith("/");
const segments = rawPath
.split("/")
.map((segment) => segment.trim())
.filter(Boolean);
if (!segments.length || segments.some((segment) => segment === "." || segment === "..")) {
throw new Error("Некорректная папка сайта на хостинге");
}
return `${absolute ? "/" : ""}${segments.join("/")}`;
}
function remoteJoin(...parts) {
const rawPath = parts
.filter((part) => part != null && String(part).trim())
.map((part) => String(part).replace(/\\/g, "/"))
.join("/");
const absolute = rawPath.startsWith("/");
const segments = rawPath.split("/").filter(Boolean);
return `${absolute ? "/" : ""}${segments.join("/")}`;
}
function remoteParent(path) {
const normalized = String(path || "").replace(/\\/g, "/");
const absolute = normalized.startsWith("/");
const segments = normalized.split("/").filter(Boolean);
segments.pop();
if (!segments.length) return absolute ? "/" : ".";
return `${absolute ? "/" : ""}${segments.join("/")}`;
}
async function collectPublicDeployFiles() {
const files = [];
async function addFile(absolutePath, relativePath) {
const fileStat = await stat(absolutePath);
if (!fileStat.isFile()) return;
files.push({
absolutePath,
relativePath: relativePath.replace(/\\/g, "/"),
size: fileStat.size,
mtimeMs: fileStat.mtimeMs,
});
}
async function walk(directory, relativeRoot) {
const entries = await readdir(directory, { withFileTypes: true });
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name, "ru"))) {
if (BROWSER_EXCLUDED_DIRS.has(entry.name)) continue;
const absolutePath = join(directory, entry.name);
const relativePath = `${relativeRoot}/${entry.name}`.replace(/^\/+/, "");
if (entry.isSymbolicLink()) continue;
if (entry.isDirectory()) {
await walk(absolutePath, relativePath);
continue;
}
if (entry.isFile()) await addFile(absolutePath, relativePath);
}
}
const rootEntries = await readdir(repoRoot, { withFileTypes: true });
for (const entry of rootEntries.sort((left, right) => left.name.localeCompare(right.name, "ru"))) {
if (BROWSER_EXCLUDED_DIRS.has(entry.name)) continue;
if (entry.name.startsWith(".") && !PUBLIC_DEPLOY_ROOT_SPECIAL_FILES.has(entry.name)) continue;
if (PUBLIC_DEPLOY_ROOT_EXCLUDES.has(entry.name)) continue;
const absolutePath = join(repoRoot, entry.name);
if (entry.isSymbolicLink()) continue;
if (entry.isDirectory()) {
if (PUBLIC_DEPLOY_DIRECTORIES.has(entry.name)) await walk(absolutePath, entry.name);
continue;
}
if (entry.isFile()) {
const extension = extname(entry.name).toLowerCase();
if (PUBLIC_DEPLOY_ROOT_EXTENSIONS.has(extension) || PUBLIC_DEPLOY_ROOT_SPECIAL_FILES.has(entry.name)) {
await addFile(absolutePath, entry.name);
}
}
}
return files;
}
function normalizedZipEntryName(value) {
const name = String(value || "").replace(/\\/g, "/").replace(/^\/+/, "");
if (!name || name.startsWith("__MACOSX/") || name.endsWith("/.DS_Store")) return "";
const parts = name.split("/").filter(Boolean);
if (!parts.length || parts.some((part) => part === "." || part === "..")) {
throw new Error(`Некорректный путь в архиве: ${value}`);
}
return parts.join("/");
}
function zipCommonRoot(entries) {
const names = entries
.map((entry) => normalizedZipEntryName(entry.entryName))
.filter(Boolean);
if (!names.length) return "";
const firstSegments = new Set(names.map((name) => name.split("/")[0]));
if (firstSegments.size !== 1) return "";
const root = [...firstSegments][0];
const hasRootRequiredFiles = names.some((name) => name === "content/pages/home.json" || name === "index.html");
const hasNestedRequiredFiles = names.some((name) => name === `${root}/content/pages/home.json` || name === `${root}/index.html`);
return !hasRootRequiredFiles && hasNestedRequiredFiles ? root : "";
}
async function clearSiteWorkspace() {
const resolvedRoot = resolve(repoRoot);
if (resolvedRoot === "/" || resolvedRoot === cmsRoot || resolvedRoot.startsWith(`${cmsRoot}/`)) {
throw new Error("Опасный путь workspace сайта, очистка запрещена");
}
await mkdir(repoRoot, { recursive: true });
const entries = await readdir(repoRoot, { withFileTypes: true }).catch(() => []);
for (const entry of entries) {
if (entry.name === ".git") continue;
await rm(join(repoRoot, entry.name), { recursive: true, force: true });
}
}
async function unpackSiteArchive({ fileName, fileBuffer }) {
if (!fileName.toLowerCase().endsWith(".zip")) {
throw new Error("Пока поддержан архив сайта в формате .zip");
}
const zip = new AdmZip(fileBuffer);
const entries = zip.getEntries();
const commonRoot = zipCommonRoot(entries);
const archivePaths = new Set(
entries
.map((entry) => normalizedZipEntryName(entry.entryName))
.filter(Boolean)
.map((name) => (commonRoot && name.startsWith(`${commonRoot}/`) ? name.slice(commonRoot.length + 1) : name))
.filter(Boolean),
);
const missingRequired = REQUIRED_SITE_FILES.map((item) => item.label).filter((label) => !archivePaths.has(label));
if (missingRequired.length) {
throw new Error(`Архив сайта неполный. Отсутствует: ${missingRequired.join(", ")}`);
}
await clearSiteWorkspace();
let files = 0;
let bytes = 0;
for (const entry of entries) {
let relativePath = normalizedZipEntryName(entry.entryName);
if (!relativePath) continue;
if (commonRoot) {
if (relativePath === commonRoot) continue;
if (!relativePath.startsWith(`${commonRoot}/`)) continue;
relativePath = relativePath.slice(commonRoot.length + 1);
}
if (!relativePath) continue;
if (pathSegments(relativePath).some((segment) => segment === ".git" || segment === "node_modules")) continue;
const targetPath = join(repoRoot, relativePath);
const resolvedTarget = resolve(targetPath);
const resolvedRoot = resolve(repoRoot);
if (resolvedTarget !== resolvedRoot && !resolvedTarget.startsWith(`${resolvedRoot}/`)) {
throw new Error(`Архив пытается записать файл за пределы сайта: ${relativePath}`);
}
if (entry.isDirectory) {
await mkdir(targetPath, { recursive: true });
continue;
}
const data = entry.getData();
await mkdir(dirname(targetPath), { recursive: true });
await writeFile(targetPath, data);
files += 1;
bytes += data.length;
}
const site = currentSiteStatus();
if (!site.ready) {
throw new Error(`Архив распакован, но сайт неполный. Отсутствует: ${site.missingFiles.join(", ")}`);
}
return {
ok: true,
files,
bytes,
label: formatProjectSize(bytes),
site,
size: await calculateProjectSize(),
};
}
async function readDeployConfig() {
const deploy = projectConfig.deploy || {};
const method = String(deploy.method || "sftp").trim().toLowerCase();
const password = await readDeployPasswordSecret();
if (!deploy.enabled) throw new Error("Деплой выключен в настройках проекта");
if (method !== "sftp") throw new Error("Сейчас поддержан только SFTP. Для McHost используйте метод sftp и порт 22");
if (!deploy.host) throw new Error("Укажите FTP / SFTP host");
if (!deploy.username) throw new Error("Укажите логин SFTP");
return {
method,
host: String(deploy.host).trim(),
port: Number(deploy.port || 22),
username: String(deploy.username).trim(),
password,
remoteRoot: normalizeRemoteRoot(deploy.remotePath || "httpdocs"),
};
}
async function ensureRemoteDirectory(client, path, cache) {
const normalized = remoteJoin(path);
if (!normalized || normalized === "." || cache.has(normalized)) return;
await client.mkdir(normalized, true);
cache.add(normalized);
}
async function shouldSkipRemoteFile(client, localFile, remotePath) {
try {
const remoteStat = await client.stat(remotePath);
if (!remoteStat || Number(remoteStat.size) !== localFile.size) return false;
const remoteMtime = Number(remoteStat.modifyTime || remoteStat.mtime || 0);
return remoteMtime && remoteMtime >= localFile.mtimeMs - 1000;
} catch {
return false;
}
}
async function publishProject({ rendered = false } = {}) {
const deployConfig = await readDeployConfig();
const files = await collectPublicDeployFiles();
if (!files.length) throw new Error("Нет публичных файлов для публикации");
const client = new SftpClient();
const directoryCache = new Set();
const report = {
ok: true,
rendered,
method: deployConfig.method,
host: deployConfig.host,
remoteRoot: deployConfig.remoteRoot,
totalFiles: files.length,
uploadedFiles: 0,
skippedFiles: 0,
uploadedBytes: 0,
totalBytes: files.reduce((sum, file) => sum + file.size, 0),
};
try {
await client.connect({
host: deployConfig.host,
port: deployConfig.port,
username: deployConfig.username,
password: deployConfig.password,
readyTimeout: 30000,
});
await ensureRemoteDirectory(client, deployConfig.remoteRoot, directoryCache);
for (const file of files) {
const remotePath = remoteJoin(deployConfig.remoteRoot, file.relativePath);
await ensureRemoteDirectory(client, remoteParent(remotePath), directoryCache);
if (await shouldSkipRemoteFile(client, file, remotePath)) {
report.skippedFiles += 1;
continue;
}
await client.fastPut(file.absolutePath, remotePath);
report.uploadedFiles += 1;
report.uploadedBytes += file.size;
}
report.totalLabel = formatProjectSize(report.totalBytes);
report.uploadedLabel = formatProjectSize(report.uploadedBytes);
return report;
} finally {
await client.end().catch(() => {});
}
}
async function maybePublishAfterRender(renderResult) {
if (!projectConfig.deploy?.enabled) {
return {
publish: null,
...renderResult,
};
}
const publish = await publishProject({ rendered: true });
return {
publish,
...renderResult,
};
}
function resolveBrowserEntry(value) {
const relativePath = safeBrowserRelativePath(value);
if (!relativePath) {
@ -2221,6 +2598,24 @@ const server = createServer(async (req, res) => {
return;
}
if (req.method === "POST" && url.pathname === "/api/project/publish") {
if (!ensureSiteReady(res)) return;
const publish = await publishProject();
sendJson(res, 200, { ok: true, publish });
return;
}
if (req.method === "POST" && url.pathname === "/api/project/upload-site") {
if (!isMultipartRequest(req)) {
sendJson(res, 400, { ok: false, error: "Ожидается multipart/form-data с ZIP-архивом сайта" });
return;
}
const upload = parseMultipartUpload(req, await readBodyBuffer(req));
const result = await unpackSiteArchive(upload);
sendJson(res, 200, result);
return;
}
if (req.method === "GET" && url.pathname === "/api/page/home") {
if (!ensureSiteReady(res)) return;
send(res, 200, await readFile(pagePath, "utf8"), "application/json; charset=utf-8");
@ -2289,14 +2684,14 @@ const server = createServer(async (req, res) => {
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 });
sendJson(res, 200, { ok: true, ...(await maybePublishAfterRender(result)) });
return;
}
if (req.method === "POST" && url.pathname === "/api/render/knowledge") {
if (!ensureSiteReady(res)) return;
const result = await runNodeScript("render-knowledge.mjs");
sendJson(res, 200, { ok: true, ...result });
sendJson(res, 200, { ok: true, ...(await maybePublishAfterRender(result)) });
return;
}