From e31b247128a1691b05a3b3c17a3177deea6a6c47 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Fri, 10 Jul 2026 01:21:30 +0300 Subject: [PATCH] Add optional site archive backups --- admin/admin.css | 16 +++++++--------- admin/admin.js | 27 +++++++++++++++++++++------ server/admin-server.mjs | 32 +++++++++++++++++++++++++++----- 3 files changed, 55 insertions(+), 20 deletions(-) diff --git a/admin/admin.css b/admin/admin.css index c223ffe..2cbda75 100644 --- a/admin/admin.css +++ b/admin/admin.css @@ -980,12 +980,19 @@ body:not([data-admin-mode="knowledge"]) #knowledge-form { display: flex; min-height: 2.72rem; align-items: center; + gap: 1rem; + flex-wrap: wrap; } .project-archive-button { min-width: 13rem; } +.project-archive-backup-toggle { + min-height: 2.72rem; + text-transform: none; +} + .secret-input-wrap { position: relative; display: grid; @@ -1848,15 +1855,6 @@ select { .publish-progress-card { display: grid; gap: 0.9rem; - border: 1px solid rgba(255, 255, 255, 0.1); -} - -.publish-progress-card[data-state="completed"] { - border-color: rgba(210, 255, 220, 0.32); -} - -.publish-progress-card[data-state="failed"] { - border-color: rgba(255, 120, 120, 0.42); } .publish-progress-head { diff --git a/admin/admin.js b/admin/admin.js index 355c470..65407b5 100644 --- a/admin/admin.js +++ b/admin/admin.js @@ -45,6 +45,7 @@ const state = { page: null, projectConfig: null, projectConfigDirty: false, + keepSiteArchiveBackup: true, publishJob: null, publishResumeAttempted: false, knowledge: null, @@ -2206,14 +2207,14 @@ function createProjectSettingsCard({ kicker, title, note = "", fields = [], runt return card; } -async function uploadSiteArchive(file) { +async function uploadSiteArchive(file, keepBackup = true) { if (!file) return; const formData = new FormData(); formData.append("file", file, file.name); const payload = await new Promise((resolve, reject) => { const request = new XMLHttpRequest(); - request.open("POST", "/api/project/upload-site"); + request.open("POST", `/api/project/upload-site?backup=${keepBackup ? "1" : "0"}`); request.responseType = "json"; request.upload.addEventListener("progress", (event) => { if (!event.lengthComputable) { @@ -2253,7 +2254,10 @@ async function uploadSiteArchive(file) { state.projectConfig = await loadProjectConfig(); renderProjectSettings(); - setStatus(`Архив сайта загружен: ${Number(payload.files || 0).toLocaleString("ru-RU")} файлов, workspace ${payload.size?.label || payload.label || "обновлен"}.`); + const backupStatus = payload.backup?.saved + ? ` Бэкап сохранён: ${payload.backup.fileName} (${payload.backup.label}).` + : " Бэкап не создавался."; + setStatus(`Архив сайта загружен: ${Number(payload.files || 0).toLocaleString("ru-RU")} файлов, workspace ${payload.size?.label || payload.label || "обновлен"}.${backupStatus}`); } function renderProjectArchiveUpload() { @@ -2263,6 +2267,9 @@ function renderProjectArchiveUpload() { const body = document.createElement("div"); const button = document.createElement("button"); const input = document.createElement("input"); + const backupToggle = document.createElement("label"); + const backupCheckbox = document.createElement("input"); + const backupLabel = document.createElement("span"); const note = document.createElement("small"); field.className = "field-control project-archive-field"; @@ -2276,17 +2283,25 @@ function renderProjectArchiveUpload() { input.type = "file"; input.accept = ".zip,application/zip"; input.className = "hidden"; + backupToggle.className = "toggle-row project-archive-backup-toggle"; + backupCheckbox.type = "checkbox"; + backupCheckbox.checked = state.keepSiteArchiveBackup; + backupLabel.textContent = "Сохранить бэкап на сервере"; + backupToggle.append(backupCheckbox, backupLabel); note.className = "field-hint"; - note.textContent = "ZIP должен содержать content/pages/home.json, content/knowledge.json, templates и tools рендера."; + note.textContent = "При включённом бэкапе исходный ZIP сохраняется отдельной версией в .cms-backups. ZIP должен содержать content/pages/home.json, content/knowledge.json, templates и tools рендера."; button.addEventListener("click", () => input.click()); + backupCheckbox.addEventListener("change", () => { + state.keepSiteArchiveBackup = backupCheckbox.checked; + }); input.addEventListener("change", () => { const file = input.files?.[0]; input.value = ""; - uploadSiteArchive(file).catch((error) => setStatus(error.message)); + uploadSiteArchive(file, backupCheckbox.checked).catch((error) => setStatus(error.message)); }); - body.append(button, input); + body.append(button, input, backupToggle); field.append(title, badge, body, note); return field; } diff --git a/server/admin-server.mjs b/server/admin-server.mjs index 6530f51..b7a1dad 100644 --- a/server/admin-server.mjs +++ b/server/admin-server.mjs @@ -33,6 +33,7 @@ function resolveCmsPath(value) { let projectConfig = await loadProjectConfig(); const repoRoot = resolveCmsPath(process.env.SITE_ROOT || projectConfig.siteRoot || "../NODEDC_SITE"); +const siteBackupRoot = join(repoRoot, ".cms-backups"); const pagePath = join(repoRoot, "content", "pages", "home.json"); const knowledgePath = join(repoRoot, "content", "knowledge.json"); const blockTemplatesPath = join(repoRoot, "content", "block-templates", "home.json"); @@ -106,7 +107,7 @@ const MEDIA_EXTENSIONS = new Set([ ".woff", ".woff2", ]); -const BROWSER_EXCLUDED_DIRS = new Set([".git", ".trash", "node_modules"]); +const BROWSER_EXCLUDED_DIRS = new Set([".cms-backups", ".git", ".trash", "node_modules"]); const REFERENCE_TEXT_EXTENSIONS = new Set([ ".css", ".html", @@ -1649,12 +1650,28 @@ async function clearSiteWorkspace() { await mkdir(repoRoot, { recursive: true }); const entries = await readdir(repoRoot, { withFileTypes: true }).catch(() => []); for (const entry of entries) { - if (entry.name === ".git") continue; + if (entry.name === ".git" || entry.name === ".cms-backups") continue; await rm(join(repoRoot, entry.name), { recursive: true, force: true }); } } -async function unpackSiteArchive({ fileName, fileBuffer }) { +async function saveSiteArchiveBackup({ fileName, fileBuffer }) { + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + const sourceBase = String(fileName || "site.zip").replace(/\.zip$/i, ""); + const backupName = `${timestamp}-${sanitizePathSegment(sourceBase)}.zip`; + const absolutePath = join(siteBackupRoot, backupName); + await mkdir(siteBackupRoot, { recursive: true }); + await writeFile(absolutePath, fileBuffer); + return { + saved: true, + fileName: backupName, + path: `.cms-backups/${backupName}`, + bytes: fileBuffer.length, + label: formatProjectSize(fileBuffer.length), + }; +} + +async function unpackSiteArchive({ fileName, fileBuffer }, { keepBackup = false } = {}) { if (!fileName.toLowerCase().endsWith(".zip")) { throw new Error("Пока поддержан архив сайта в формате .zip"); } @@ -1674,6 +1691,9 @@ async function unpackSiteArchive({ fileName, fileBuffer }) { throw new Error(`Архив сайта неполный. Отсутствует: ${missingRequired.join(", ")}`); } + const backup = keepBackup + ? await saveSiteArchiveBackup({ fileName, fileBuffer }) + : { saved: false, fileName: "", path: "", bytes: 0, label: "0 МБ" }; await clearSiteWorkspace(); let files = 0; @@ -1689,7 +1709,7 @@ async function unpackSiteArchive({ fileName, fileBuffer }) { } if (!relativePath) continue; - if (pathSegments(relativePath).some((segment) => segment === ".git" || segment === "node_modules")) continue; + if (pathSegments(relativePath).some((segment) => BROWSER_EXCLUDED_DIRS.has(segment))) continue; const targetPath = join(repoRoot, relativePath); const resolvedTarget = resolve(targetPath); @@ -1720,6 +1740,7 @@ async function unpackSiteArchive({ fileName, fileBuffer }) { files, bytes, label: formatProjectSize(bytes), + backup, site, size: await calculateProjectSize(), }; @@ -2845,7 +2866,8 @@ const server = createServer(async (req, res) => { return; } const upload = parseMultipartUpload(req, await readBodyBuffer(req)); - const result = await unpackSiteArchive(upload); + const keepBackup = ["1", "true", "yes"].includes(String(url.searchParams.get("backup") || "").toLowerCase()); + const result = await unpackSiteArchive(upload, { keepBackup }); sendJson(res, 200, result); return; }