Add CMS site upload and SFTP publish
This commit is contained in:
parent
cfee9d0c19
commit
808d867dd7
|
|
@ -7,5 +7,6 @@ __pycache__/
|
||||||
.env
|
.env
|
||||||
*.local
|
*.local
|
||||||
infra/.env
|
infra/.env
|
||||||
|
projects/*.secrets.json
|
||||||
|
|
||||||
.cms-smoke.png
|
.cms-smoke.png
|
||||||
|
|
|
||||||
|
|
@ -704,8 +704,10 @@ body[data-file-picker-active="true"] .editor {
|
||||||
|
|
||||||
.dirty-state {
|
.dirty-state {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
min-width: 13.5rem;
|
||||||
min-height: 2rem;
|
min-height: 2rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
border-radius: var(--launcher-radius-circle);
|
border-radius: var(--launcher-radius-circle);
|
||||||
background: rgba(255, 255, 255, 0.055);
|
background: rgba(255, 255, 255, 0.055);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
|
|
@ -970,6 +972,20 @@ body:not([data-admin-mode="knowledge"]) #knowledge-form {
|
||||||
grid-column: 1 / -1;
|
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 {
|
.secret-input-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|
|
||||||
148
admin/admin.js
148
admin/admin.js
|
|
@ -165,6 +165,7 @@ const el = {
|
||||||
reload: document.querySelector("#reload"),
|
reload: document.querySelector("#reload"),
|
||||||
save: document.querySelector("#save"),
|
save: document.querySelector("#save"),
|
||||||
render: document.querySelector("#render"),
|
render: document.querySelector("#render"),
|
||||||
|
publish: document.querySelector("#publish"),
|
||||||
dirtyState: document.querySelector("#dirty-state"),
|
dirtyState: document.querySelector("#dirty-state"),
|
||||||
status: document.querySelector("#status"),
|
status: document.querySelector("#status"),
|
||||||
empty: document.querySelector("#empty-state"),
|
empty: document.querySelector("#empty-state"),
|
||||||
|
|
@ -640,13 +641,6 @@ function syncProjectConfigFromForm() {
|
||||||
|
|
||||||
async function saveProjectSettings() {
|
async function saveProjectSettings() {
|
||||||
syncProjectConfigFromForm();
|
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", {
|
const response = await fetch("/api/project/config", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
|
|
@ -681,6 +675,29 @@ async function checkProjectConnection() {
|
||||||
setStatus(`Проект доступен. Размер локального сайта: ${size?.label || "не рассчитан"}.`);
|
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() {
|
function ensurePageSeo() {
|
||||||
if (!state.page) return;
|
if (!state.page) return;
|
||||||
state.page.seo = {
|
state.page.seo = {
|
||||||
|
|
@ -779,6 +796,10 @@ function updateAdminModeSurface() {
|
||||||
el.render.title = isKnowledge ? "Сохранить дерево и пересобрать публичный раздел /knowledge/" : "Сохранить модель и пересобрать публичный index.html";
|
el.render.title = isKnowledge ? "Сохранить дерево и пересобрать публичный раздел /knowledge/" : "Сохранить модель и пересобрать публичный index.html";
|
||||||
el.render.disabled = !hasSite && !isProjectSettingsSelected();
|
el.render.disabled = !hasSite && !isProjectSettingsSelected();
|
||||||
}
|
}
|
||||||
|
if (el.publish) {
|
||||||
|
el.publish.classList.add("hidden");
|
||||||
|
el.publish.disabled = true;
|
||||||
|
}
|
||||||
if (el.reload) {
|
if (el.reload) {
|
||||||
el.reload.disabled = !hasSite && !isProjectSettingsSelected();
|
el.reload.disabled = !hasSite && !isProjectSettingsSelected();
|
||||||
}
|
}
|
||||||
|
|
@ -1992,14 +2013,14 @@ function createProjectField({ path, label, type = "text", placeholder = "", opti
|
||||||
}
|
}
|
||||||
|
|
||||||
control.dataset.projectPath = path;
|
control.dataset.projectPath = path;
|
||||||
control.addEventListener("input", () => {
|
const onProjectFieldChange = () => {
|
||||||
setProjectConfigValue(path, normalizeProjectInputValue(control));
|
setProjectConfigValue(path, normalizeProjectInputValue(control));
|
||||||
markDirty("Настройки проекта изменены. Нажмите «Сохранить настройки».");
|
markDirty("Настройки проекта изменены. Нажмите «Сохранить настройки».");
|
||||||
});
|
if (path === "deploy.enabled") renderProjectSettings();
|
||||||
control.addEventListener("change", () => {
|
};
|
||||||
setProjectConfigValue(path, normalizeProjectInputValue(control));
|
|
||||||
markDirty("Настройки проекта изменены. Нажмите «Сохранить настройки».");
|
control.addEventListener("input", onProjectFieldChange);
|
||||||
});
|
control.addEventListener("change", onProjectFieldChange);
|
||||||
|
|
||||||
if (hint && type !== "checkbox") {
|
if (hint && type !== "checkbox") {
|
||||||
const note = document.createElement("small");
|
const note = document.createElement("small");
|
||||||
|
|
@ -2048,6 +2069,62 @@ function createProjectSettingsCard({ kicker, title, note = "", fields = [], runt
|
||||||
return card;
|
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) {
|
function renderProjectRuntimeLine(label, value) {
|
||||||
const row = document.createElement("div");
|
const row = document.createElement("div");
|
||||||
const code = document.createElement("code");
|
const code = document.createElement("code");
|
||||||
|
|
@ -2119,37 +2196,46 @@ function renderProjectSettings() {
|
||||||
el.render.textContent = "Проверить проект";
|
el.render.textContent = "Проверить проект";
|
||||||
el.render.title = "Проверить доступ к модели, базе знаний и файлам проекта";
|
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({
|
const projectCard = createProjectSettingsCard({
|
||||||
kicker: "Проект",
|
kicker: "Проект",
|
||||||
title: "Подключенный сайт",
|
title: "Подключенный сайт",
|
||||||
note: "Эти поля описывают сайт, который сейчас обслуживает CMS. Локальный путь используется сервером, публичный URL нужен для будущего деплоя и ссылок.",
|
note: "Проект хранится в workspace CMS. Архив сайта загружается один раз и дальше редактируется через админку.",
|
||||||
fields: [
|
fields: [
|
||||||
createProjectField({ path: "name", label: "Название проекта" }),
|
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/" }),
|
createProjectField({ path: "publicUrl", label: "Публичный домен", placeholder: "https://nodedc.ru/" }),
|
||||||
|
renderProjectArchiveUpload(),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const deployCard = createProjectSettingsCard({
|
const deployCard = createProjectSettingsCard({
|
||||||
kicker: "Хостинг",
|
kicker: "Публикация",
|
||||||
title: "Публикация на McHost",
|
title: "Деплой на хостинг",
|
||||||
note: "Для McHost обычно используются FTP host вида aXXXX.ftp.mchost.ru, порт 21 для FTP или 22 для SFTP и папка сайта httpdocs. Пароль не пишем в JSON: passwordEnv хранит имя переменной окружения сервиса.",
|
note: "Укажите доступ к хостингу. Пароль сохраняется на сервере в зашифрованном виде и не возвращается в браузер.",
|
||||||
fields: [
|
fields: [
|
||||||
createProjectField({ path: "deploy.enabled", label: "Включить деплой", type: "checkbox" }),
|
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.method", label: "Метод", options: ["sftp", "ftp"] }),
|
||||||
createProjectField({ path: "deploy.host", label: "FTP / SFTP host", placeholder: "a0000.ftp.mchost.ru" }),
|
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.port", label: "Порт", type: "number", placeholder: "22" }),
|
||||||
createProjectField({ path: "deploy.username", label: "Логин" }),
|
createProjectField({ path: "deploy.username", label: "Логин" }),
|
||||||
createProjectField({
|
createProjectField({
|
||||||
path: "deploy.passwordEnv",
|
path: "deploy.password",
|
||||||
label: "Имя переменной пароля",
|
label: "Пароль",
|
||||||
type: "password",
|
type: "password",
|
||||||
placeholder: "MCHOST_PASSWORD",
|
placeholder: state.projectConfig?.deploy?.passwordConfigured ? "Пароль сохранен. Введите новый для замены" : "Введите пароль",
|
||||||
hint: "Не вводите сюда сам FTP-пароль. Укажите имя переменной окружения сервиса, например MCHOST_PASSWORD.",
|
hint: state.projectConfig?.deploy?.passwordConfigured
|
||||||
|
? "Пароль уже сохранен на сервере. Поле пустое, потому что секрет не возвращается в браузер."
|
||||||
|
: "Введите пароль один раз и нажмите «Сохранить настройки».",
|
||||||
}),
|
}),
|
||||||
createProjectField({ path: "deploy.remotePath", label: "Папка сайта на хостинге", placeholder: "httpdocs" }),
|
createProjectField({ path: "deploy.remotePath", label: "Папка сайта на хостинге", placeholder: "httpdocs" }),
|
||||||
],
|
],
|
||||||
|
|
@ -5481,7 +5567,9 @@ async function renderKnowledge() {
|
||||||
}
|
}
|
||||||
|
|
||||||
setDirty(false);
|
setDirty(false);
|
||||||
setStatus(payload.stdout ? `База знаний сохранена. ${payload.stdout}` : "База знаний сохранена и пересобрана.");
|
setStatus(
|
||||||
|
`${payload.stdout ? `База знаний сохранена. ${payload.stdout}` : "База знаний сохранена и пересобрана."}${publishStatusText(payload)}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadPage() {
|
async function loadPage() {
|
||||||
|
|
@ -5546,7 +5634,9 @@ async function renderHome() {
|
||||||
}
|
}
|
||||||
|
|
||||||
setDirty(false);
|
setDirty(false);
|
||||||
setStatus(payload.stdout ? `Модель сохранена. ${payload.stdout}` : "Модель сохранена, index.html пересобран.");
|
setStatus(
|
||||||
|
`${payload.stdout ? `Модель сохранена. ${payload.stdout}` : "Модель сохранена, index.html пересобран."}${publishStatusText(payload)}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function moveSelected(delta) {
|
function moveSelected(delta) {
|
||||||
|
|
@ -5856,6 +5946,10 @@ el.render.addEventListener("click", () => {
|
||||||
const action = state.mode === "knowledge" ? renderKnowledge : renderHome;
|
const action = state.mode === "knowledge" ? renderKnowledge : renderHome;
|
||||||
action().catch((error) => setStatus(error.message));
|
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.moveUp.addEventListener("click", () => moveSelected(-1));
|
||||||
el.moveDown.addEventListener("click", () => moveSelected(1));
|
el.moveDown.addEventListener("click", () => moveSelected(1));
|
||||||
el.duplicate.addEventListener("click", duplicateSelected);
|
el.duplicate.addEventListener("click", duplicateSelected);
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,7 @@
|
||||||
<button id="reload" class="icon-btn" type="button" title="Перезагрузить данные">↻</button>
|
<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="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="render" class="ghost-btn" type="button" title="Сохранить модель и пересобрать публичный index.html">Обновить index.html</button>
|
||||||
|
<button id="publish" class="ghost-btn hidden" type="button" title="Опубликовать сайт на хостинг">Опубликовать</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -50,5 +50,6 @@ CMS_REQUIRED_GROUPS=dc-cms:owner,dc-cms:admin,dc-cms:editor,dc-cms:publisher
|
||||||
|
|
||||||
# CMS app session
|
# CMS app session
|
||||||
CMS_SESSION_SECRET=replace-with-random-secret
|
CMS_SESSION_SECRET=replace-with-random-secret
|
||||||
|
CMS_SECRETS_KEY=replace-with-random-secret
|
||||||
COOKIE_DOMAIN=
|
COOKIE_DOMAIN=
|
||||||
COOKIE_SECURE=true
|
COOKIE_SECURE=true
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,88 @@
|
||||||
"name": "dc-cms",
|
"name": "dc-cms",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"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": {
|
"node_modules/jose": {
|
||||||
"version": "6.2.3",
|
"version": "6.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
|
||||||
|
|
@ -19,6 +98,114 @@
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/panva"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@
|
||||||
"start": "node server/admin-server.mjs"
|
"start": "node server/admin-server.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"jose": "^6.2.3"
|
"adm-zip": "^0.5.18",
|
||||||
|
"jose": "^6.2.3",
|
||||||
|
"ssh2-sftp-client": "^12.1.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,17 @@ import { extname, join, normalize, relative, resolve } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import { dirname } from "node:path";
|
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 { createLocalJWKSet, jwtVerify } from "jose";
|
||||||
|
import SftpClient from "ssh2-sftp-client";
|
||||||
|
import AdmZip from "adm-zip";
|
||||||
|
|
||||||
const cmsRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
|
const cmsRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
const port = Number(process.env.PORT || 8090);
|
const port = Number(process.env.PORT || 8090);
|
||||||
const host = process.env.HOST || "127.0.0.1";
|
const host = process.env.HOST || "127.0.0.1";
|
||||||
const activeProjectId = process.env.CMS_PROJECT || "nodedc";
|
const activeProjectId = process.env.CMS_PROJECT || "nodedc";
|
||||||
const projectConfigPath = join(cmsRoot, "projects", `${activeProjectId}.json`);
|
const projectConfigPath = join(cmsRoot, "projects", `${activeProjectId}.json`);
|
||||||
|
const projectSecretsPath = join(cmsRoot, "projects", `${activeProjectId}.secrets.json`);
|
||||||
|
|
||||||
async function loadProjectConfig() {
|
async function loadProjectConfig() {
|
||||||
const source = await readFile(projectConfigPath, "utf8");
|
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: "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") },
|
{ 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 = {
|
const MIME = {
|
||||||
".html": "text/html; charset=utf-8",
|
".html": "text/html; charset=utf-8",
|
||||||
|
|
@ -215,11 +222,6 @@ function trailingSlashUrl(value) {
|
||||||
return trimmed.endsWith("/") ? trimmed : `${trimmed}/`;
|
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) {
|
function fileReady(filePath) {
|
||||||
try {
|
try {
|
||||||
return statSync(filePath).isFile();
|
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() {
|
function currentSiteStatus() {
|
||||||
const files = REQUIRED_SITE_FILES.map((item) => ({
|
const files = REQUIRED_SITE_FILES.map((item) => ({
|
||||||
key: item.key,
|
key: item.key,
|
||||||
|
|
@ -816,8 +879,11 @@ function authorizeRequest(req, res, url) {
|
||||||
function publicProjectConfig() {
|
function publicProjectConfig() {
|
||||||
const deploy = {
|
const deploy = {
|
||||||
...(projectConfig.deploy || {}),
|
...(projectConfig.deploy || {}),
|
||||||
passwordEnv: normalizeSecretEnvName(projectConfig.deploy?.passwordEnv),
|
passwordConfigured: fileReady(projectSecretsPath),
|
||||||
};
|
};
|
||||||
|
delete deploy.password;
|
||||||
|
delete deploy.passwordEnv;
|
||||||
|
delete deploy.deployOnRender;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...projectConfig,
|
...projectConfig,
|
||||||
|
|
@ -840,9 +906,7 @@ function normalizeProjectConfig(payload) {
|
||||||
host: String(payload?.deploy?.host || "").trim(),
|
host: String(payload?.deploy?.host || "").trim(),
|
||||||
port: Number(payload?.deploy?.port || (payload?.deploy?.method === "ftp" ? 21 : 22)),
|
port: Number(payload?.deploy?.port || (payload?.deploy?.method === "ftp" ? 21 : 22)),
|
||||||
username: String(payload?.deploy?.username || "").trim(),
|
username: String(payload?.deploy?.username || "").trim(),
|
||||||
passwordEnv: normalizeSecretEnvName(payload?.deploy?.passwordEnv),
|
|
||||||
remotePath: String(payload?.deploy?.remotePath || "").trim(),
|
remotePath: String(payload?.deploy?.remotePath || "").trim(),
|
||||||
deployOnRender: Boolean(payload?.deploy?.deployOnRender),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -857,6 +921,7 @@ function normalizeProjectConfig(payload) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveProjectConfig(payload) {
|
async function saveProjectConfig(payload) {
|
||||||
|
await saveDeployPasswordSecret(payload);
|
||||||
const nextConfig = normalizeProjectConfig(payload);
|
const nextConfig = normalizeProjectConfig(payload);
|
||||||
await writeFile(projectConfigPath, `${JSON.stringify(nextConfig, null, 2)}\n`);
|
await writeFile(projectConfigPath, `${JSON.stringify(nextConfig, null, 2)}\n`);
|
||||||
projectConfig = nextConfig;
|
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) {
|
function resolveBrowserEntry(value) {
|
||||||
const relativePath = safeBrowserRelativePath(value);
|
const relativePath = safeBrowserRelativePath(value);
|
||||||
if (!relativePath) {
|
if (!relativePath) {
|
||||||
|
|
@ -2221,6 +2598,24 @@ const server = createServer(async (req, res) => {
|
||||||
return;
|
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 (req.method === "GET" && url.pathname === "/api/page/home") {
|
||||||
if (!ensureSiteReady(res)) return;
|
if (!ensureSiteReady(res)) return;
|
||||||
send(res, 200, await readFile(pagePath, "utf8"), "application/json; charset=utf-8");
|
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 (req.method === "POST" && url.pathname === "/api/render/home") {
|
||||||
if (!ensureSiteReady(res)) return;
|
if (!ensureSiteReady(res)) return;
|
||||||
const result = await runNodeScript("render-home.mjs");
|
const result = await runNodeScript("render-home.mjs");
|
||||||
sendJson(res, 200, { ok: true, ...result });
|
sendJson(res, 200, { ok: true, ...(await maybePublishAfterRender(result)) });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === "POST" && url.pathname === "/api/render/knowledge") {
|
if (req.method === "POST" && url.pathname === "/api/render/knowledge") {
|
||||||
if (!ensureSiteReady(res)) return;
|
if (!ensureSiteReady(res)) return;
|
||||||
const result = await runNodeScript("render-knowledge.mjs");
|
const result = await runNodeScript("render-knowledge.mjs");
|
||||||
sendJson(res, 200, { ok: true, ...result });
|
sendJson(res, 200, { ok: true, ...(await maybePublishAfterRender(result)) });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue