Files
websitedev/scripts/cms-versions.js
T
Do Siki a55ce53768
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
feat(cms): version history panel with diff view and one-click restore
- GET /versions lists the automatic backups of the selected file (timestamp,
  size); ?show=<backup> renders a line diff against the current content
- POST /restore validates the backup against the content schema and restores
  it atomically; the pre-restore state gets a fresh backup first, so a
  restore itself is reversible; audited as version_restored
- dependency-free LCS line diff (scripts/cms-diff.js) with add/del
  highlighting and context trimming; backup names validated against a strict
  pattern (path traversal impossible)
- new 🕘 Verziók entry in the CMS bottom bar
- refactor: security/infra helpers extracted to scripts/cms-core.js to keep
  content-editor.js under the 400-line hard limit
- integration test: list, diff, restore + reversibility backup, traversal
  rejection, CSRF enforcement, auth

Closes MITHOME-64
2026-08-18 23:45:39 +02:00

60 lines
2.3 KiB
JavaScript

// Version history helpers for the CMS: listing automatic backups from
// .content-backups, safe backup-name validation and diff assembly.
const fs = require('fs');
const path = require('path');
const { renderDiffHtml } = require('./cms-diff');
// 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),
};
}
module.exports = { safeBackupName, listVersions, readBackupContent, buildVersionDiff, formatBackupTimestamp };