diff --git a/.gitignore b/.gitignore
index e037678..257ad5e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,5 +7,6 @@ __pycache__/
.env
*.local
infra/.env
+projects/*.secrets.json
.cms-smoke.png
diff --git a/admin/admin.css b/admin/admin.css
index acda3b7..d90ae74 100644
--- a/admin/admin.css
+++ b/admin/admin.css
@@ -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;
diff --git a/admin/admin.js b/admin/admin.js
index 88d1e2c..eec608d 100644
--- a/admin/admin.js
+++ b/admin/admin.js
@@ -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);
diff --git a/admin/index.html b/admin/index.html
index b21f7bb..989a1fd 100644
--- a/admin/index.html
+++ b/admin/index.html
@@ -88,6 +88,7 @@
+
diff --git a/infra/.env.synology.example b/infra/.env.synology.example
index 754c77b..9f10735 100644
--- a/infra/.env.synology.example
+++ b/infra/.env.synology.example
@@ -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
diff --git a/package-lock.json b/package-lock.json
index 9e78699..3c9ccd5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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"
}
}
}
diff --git a/package.json b/package.json
index 6d904a0..46c7cad 100644
--- a/package.json
+++ b/package.json
@@ -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"
}
}
diff --git a/server/admin-server.mjs b/server/admin-server.mjs
index 06a2bf4..00bf617 100644
--- a/server/admin-server.mjs
+++ b/server/admin-server.mjs
@@ -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;
}