CI Pipeline with Test Management / 🧪 Run Tests & Generate Reports (push) Waiting to run
CI Pipeline with Test Management / 🐳 Docker Integration Tests (push) Blocked by required conditions
CI Pipeline with Test Management / 🏗️ Build Docker Image (push) Blocked by required conditions
CI Pipeline with Test Management / 📊 Generate Test Summary (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🧪 Run Tests & Generate Reports (push) Waiting to run
Test Reporting & Gherkin Analysis / 📊 Analyze Test Coverage (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🔄 Sync with Linear (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / ⚡ Performance Monitoring (push) Blocked by required conditions
Companion to the cms-logo.js extraction: keeps content-editor.js under the 400-line hard limit. No behavior change — covered by test-content-editor-versions.js.
113 lines
4.6 KiB
JavaScript
113 lines
4.6 KiB
JavaScript
// Version history helpers for the CMS: listing automatic backups from
|
|
// .content-backups, safe backup-name validation, diff assembly and the
|
|
// /versions + /restore route handlers.
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { renderDiffHtml } = require('./cms-diff');
|
|
const { backupAndWriteAtomically: backupAndWrite } = require('./cms-core');
|
|
|
|
// Backup files are named `<fileKey>.<ISO-ish timestamp>.json`
|
|
const BACKUP_NAME_RE = /^(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z$/;
|
|
|
|
// WHY: the backup name arrives as a query parameter — only allow the exact
|
|
// `<fileKey>.<timestamp>.json` shape so path traversal (`../`) is impossible.
|
|
function safeBackupName(fileKey, candidate) {
|
|
if (typeof candidate !== 'string' || !candidate.startsWith(`${fileKey}.`) || !candidate.endsWith('.json')) return null;
|
|
const ts = candidate.slice(fileKey.length + 1, -5);
|
|
if (!BACKUP_NAME_RE.test(ts)) return null;
|
|
return candidate;
|
|
}
|
|
|
|
function formatBackupTimestamp(fileKey, backupName) {
|
|
const ts = backupName.slice(fileKey.length + 1, -5);
|
|
const m = ts.match(BACKUP_NAME_RE);
|
|
if (!m) return ts;
|
|
return `${m[1]} ${m[2]}:${m[3]}:${m[4]}`;
|
|
}
|
|
|
|
function listVersions(backupDir, fileKey) {
|
|
try {
|
|
return fs.readdirSync(backupDir)
|
|
.filter(name => safeBackupName(fileKey, name))
|
|
.map(name => {
|
|
const full = path.join(backupDir, name);
|
|
const stat = fs.statSync(full);
|
|
return { name, size: stat.size, when: formatBackupTimestamp(fileKey, name) };
|
|
})
|
|
.sort((a, b) => b.name.localeCompare(a.name)); // newest first
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function readBackupContent(backupDir, backupName) {
|
|
return fs.readFileSync(path.join(backupDir, backupName), 'utf8');
|
|
}
|
|
|
|
// Compare a backup with the current file content; returns both pretty texts and
|
|
// the rendered diff HTML (backup = old/left, current = new/right).
|
|
function buildVersionDiff(backupDir, currentFilePath, fileKey, backupName) {
|
|
const backupText = readBackupContent(backupDir, backupName);
|
|
const currentText = fs.readFileSync(currentFilePath, 'utf8');
|
|
return {
|
|
backupName,
|
|
when: formatBackupTimestamp(fileKey, backupName),
|
|
backupText: backupText.trim(),
|
|
currentText: currentText.trim(),
|
|
diffHtml: renderDiffHtml(backupText, currentText),
|
|
};
|
|
}
|
|
|
|
// WHY: route handling extracted here so content-editor.js stays under the
|
|
// 400-line limit. Returns true when the request was handled.
|
|
function handleVersionRoutes({ req, res, u, activeFile, backupDir, currentFile, validate, writeAudit, csrfOk, clientAddress, user, versionsPage }) {
|
|
if (req.method === 'GET' && u.pathname === '/versions') {
|
|
const versions = listVersions(backupDir, activeFile);
|
|
let diff = null;
|
|
const showRaw = u.searchParams.get('show');
|
|
if (showRaw) {
|
|
const safe = safeBackupName(activeFile, showRaw);
|
|
if (safe) {
|
|
try {
|
|
diff = buildVersionDiff(backupDir, currentFile, activeFile, safe);
|
|
} catch { /* unreadable backup: render list only */ }
|
|
}
|
|
}
|
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
|
|
res.end(versionsPage(activeFile, diff));
|
|
return true;
|
|
}
|
|
|
|
if (req.method === 'POST' && u.pathname === '/restore') {
|
|
const backup = safeBackupName(activeFile, u.searchParams.get('backup') || '');
|
|
if (!backup) {
|
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ ok: false, error: 'Érvénytelen mentésnév.' }));
|
|
return true;
|
|
}
|
|
try {
|
|
const data = JSON.parse(readBackupContent(backupDir, backup));
|
|
const validation = validate(activeFile, data);
|
|
if (!validation.ok) {
|
|
writeAudit('version_restored', { clientAddress, user, file: activeFile, backup, result: 'validation_failed' });
|
|
res.writeHead(422, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ ok: false, error: 'A mentés nem felel meg a sémának: ' + validation.errors.join('; ') }));
|
|
return true;
|
|
}
|
|
backupAndWrite(currentFile, data, backupDir);
|
|
writeAudit('version_restored', { clientAddress, user, file: activeFile, backup, result: 'ok' });
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ ok: true }));
|
|
} catch (e) {
|
|
writeAudit('version_restored', { clientAddress, user, file: activeFile, backup, result: 'error' });
|
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
}
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
module.exports = { safeBackupName, listVersions, readBackupContent, buildVersionDiff, formatBackupTimestamp, handleVersionRoutes };
|