Add optional site archive backups
This commit is contained in:
parent
0579976ffc
commit
e31b247128
|
|
@ -980,12 +980,19 @@ body:not([data-admin-mode="knowledge"]) #knowledge-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: 2.72rem;
|
min-height: 2.72rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-archive-button {
|
.project-archive-button {
|
||||||
min-width: 13rem;
|
min-width: 13rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.project-archive-backup-toggle {
|
||||||
|
min-height: 2.72rem;
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
.secret-input-wrap {
|
.secret-input-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|
@ -1848,15 +1855,6 @@ select {
|
||||||
.publish-progress-card {
|
.publish-progress-card {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.9rem;
|
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 {
|
.publish-progress-head {
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ const state = {
|
||||||
page: null,
|
page: null,
|
||||||
projectConfig: null,
|
projectConfig: null,
|
||||||
projectConfigDirty: false,
|
projectConfigDirty: false,
|
||||||
|
keepSiteArchiveBackup: true,
|
||||||
publishJob: null,
|
publishJob: null,
|
||||||
publishResumeAttempted: false,
|
publishResumeAttempted: false,
|
||||||
knowledge: null,
|
knowledge: null,
|
||||||
|
|
@ -2206,14 +2207,14 @@ function createProjectSettingsCard({ kicker, title, note = "", fields = [], runt
|
||||||
return card;
|
return card;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uploadSiteArchive(file) {
|
async function uploadSiteArchive(file, keepBackup = true) {
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", file, file.name);
|
formData.append("file", file, file.name);
|
||||||
|
|
||||||
const payload = await new Promise((resolve, reject) => {
|
const payload = await new Promise((resolve, reject) => {
|
||||||
const request = new XMLHttpRequest();
|
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.responseType = "json";
|
||||||
request.upload.addEventListener("progress", (event) => {
|
request.upload.addEventListener("progress", (event) => {
|
||||||
if (!event.lengthComputable) {
|
if (!event.lengthComputable) {
|
||||||
|
|
@ -2253,7 +2254,10 @@ async function uploadSiteArchive(file) {
|
||||||
|
|
||||||
state.projectConfig = await loadProjectConfig();
|
state.projectConfig = await loadProjectConfig();
|
||||||
renderProjectSettings();
|
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() {
|
function renderProjectArchiveUpload() {
|
||||||
|
|
@ -2263,6 +2267,9 @@ function renderProjectArchiveUpload() {
|
||||||
const body = document.createElement("div");
|
const body = document.createElement("div");
|
||||||
const button = document.createElement("button");
|
const button = document.createElement("button");
|
||||||
const input = document.createElement("input");
|
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");
|
const note = document.createElement("small");
|
||||||
|
|
||||||
field.className = "field-control project-archive-field";
|
field.className = "field-control project-archive-field";
|
||||||
|
|
@ -2276,17 +2283,25 @@ function renderProjectArchiveUpload() {
|
||||||
input.type = "file";
|
input.type = "file";
|
||||||
input.accept = ".zip,application/zip";
|
input.accept = ".zip,application/zip";
|
||||||
input.className = "hidden";
|
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.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());
|
button.addEventListener("click", () => input.click());
|
||||||
|
backupCheckbox.addEventListener("change", () => {
|
||||||
|
state.keepSiteArchiveBackup = backupCheckbox.checked;
|
||||||
|
});
|
||||||
input.addEventListener("change", () => {
|
input.addEventListener("change", () => {
|
||||||
const file = input.files?.[0];
|
const file = input.files?.[0];
|
||||||
input.value = "";
|
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);
|
field.append(title, badge, body, note);
|
||||||
return field;
|
return field;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ function resolveCmsPath(value) {
|
||||||
|
|
||||||
let projectConfig = await loadProjectConfig();
|
let projectConfig = await loadProjectConfig();
|
||||||
const repoRoot = resolveCmsPath(process.env.SITE_ROOT || projectConfig.siteRoot || "../NODEDC_SITE");
|
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 pagePath = join(repoRoot, "content", "pages", "home.json");
|
||||||
const knowledgePath = join(repoRoot, "content", "knowledge.json");
|
const knowledgePath = join(repoRoot, "content", "knowledge.json");
|
||||||
const blockTemplatesPath = join(repoRoot, "content", "block-templates", "home.json");
|
const blockTemplatesPath = join(repoRoot, "content", "block-templates", "home.json");
|
||||||
|
|
@ -106,7 +107,7 @@ const MEDIA_EXTENSIONS = new Set([
|
||||||
".woff",
|
".woff",
|
||||||
".woff2",
|
".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([
|
const REFERENCE_TEXT_EXTENSIONS = new Set([
|
||||||
".css",
|
".css",
|
||||||
".html",
|
".html",
|
||||||
|
|
@ -1649,12 +1650,28 @@ async function clearSiteWorkspace() {
|
||||||
await mkdir(repoRoot, { recursive: true });
|
await mkdir(repoRoot, { recursive: true });
|
||||||
const entries = await readdir(repoRoot, { withFileTypes: true }).catch(() => []);
|
const entries = await readdir(repoRoot, { withFileTypes: true }).catch(() => []);
|
||||||
for (const entry of entries) {
|
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 });
|
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")) {
|
if (!fileName.toLowerCase().endsWith(".zip")) {
|
||||||
throw new Error("Пока поддержан архив сайта в формате .zip");
|
throw new Error("Пока поддержан архив сайта в формате .zip");
|
||||||
}
|
}
|
||||||
|
|
@ -1674,6 +1691,9 @@ async function unpackSiteArchive({ fileName, fileBuffer }) {
|
||||||
throw new Error(`Архив сайта неполный. Отсутствует: ${missingRequired.join(", ")}`);
|
throw new Error(`Архив сайта неполный. Отсутствует: ${missingRequired.join(", ")}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const backup = keepBackup
|
||||||
|
? await saveSiteArchiveBackup({ fileName, fileBuffer })
|
||||||
|
: { saved: false, fileName: "", path: "", bytes: 0, label: "0 МБ" };
|
||||||
await clearSiteWorkspace();
|
await clearSiteWorkspace();
|
||||||
|
|
||||||
let files = 0;
|
let files = 0;
|
||||||
|
|
@ -1689,7 +1709,7 @@ async function unpackSiteArchive({ fileName, fileBuffer }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!relativePath) continue;
|
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 targetPath = join(repoRoot, relativePath);
|
||||||
const resolvedTarget = resolve(targetPath);
|
const resolvedTarget = resolve(targetPath);
|
||||||
|
|
@ -1720,6 +1740,7 @@ async function unpackSiteArchive({ fileName, fileBuffer }) {
|
||||||
files,
|
files,
|
||||||
bytes,
|
bytes,
|
||||||
label: formatProjectSize(bytes),
|
label: formatProjectSize(bytes),
|
||||||
|
backup,
|
||||||
site,
|
site,
|
||||||
size: await calculateProjectSize(),
|
size: await calculateProjectSize(),
|
||||||
};
|
};
|
||||||
|
|
@ -2845,7 +2866,8 @@ const server = createServer(async (req, res) => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const upload = parseMultipartUpload(req, await readBodyBuffer(req));
|
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);
|
sendJson(res, 200, result);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue