Add CMS site upload and SFTP publish
This commit is contained in:
parent
cfee9d0c19
commit
808d867dd7
|
|
@ -7,5 +7,6 @@ __pycache__/
|
|||
.env
|
||||
*.local
|
||||
infra/.env
|
||||
projects/*.secrets.json
|
||||
|
||||
.cms-smoke.png
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
148
admin/admin.js
148
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);
|
||||
|
|
|
|||
|
|
@ -88,6 +88,7 @@
|
|||
<button id="reload" class="icon-btn" type="button" title="Перезагрузить данные">↻</button>
|
||||
<button id="save" class="primary-btn" type="button" title="Записать текущую модель в content/pages/home.json">Сохранить модель</button>
|
||||
<button id="render" class="ghost-btn" type="button" title="Сохранить модель и пересобрать публичный index.html">Обновить index.html</button>
|
||||
<button id="publish" class="ghost-btn hidden" type="button" title="Опубликовать сайт на хостинг">Опубликовать</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue