From 808d867dd7cd7c0414ffa7b64967a5f5e0813507 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Mon, 6 Jul 2026 13:45:08 +0300 Subject: [PATCH] Add CMS site upload and SFTP publish --- .gitignore | 1 + admin/admin.css | 16 ++ admin/admin.js | 148 ++++++++++--- admin/index.html | 1 + infra/.env.synology.example | 1 + package-lock.json | 189 +++++++++++++++- package.json | 4 +- server/admin-server.mjs | 417 +++++++++++++++++++++++++++++++++++- 8 files changed, 737 insertions(+), 40 deletions(-) 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; }