Add CMS site upload and SFTP publish
This commit is contained in:
+406
-11
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user