// 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 `..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 // `..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 };