feat(cms): version history panel with diff view and one-click restore
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

- 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
This commit is contained in:
Do Siki
2026-08-18 23:45:39 +02:00
parent d15cc05c36
commit a55ce53768
7 changed files with 549 additions and 77 deletions
+61 -76
View File
@@ -16,6 +16,7 @@ const crypto = require('crypto');
const { validateContent } = require('./proto/src/content/schema');
const { renderMarkdown } = require('./scripts/markdown-render');
const { buildPublishCommand, interpretPublishResult } = require('./scripts/cms-publish');
const { safeBackupName, listVersions, readBackupContent, buildVersionDiff } = require('./scripts/cms-versions');
const PORT = Number(process.env.CONTENT_EDITOR_PORT) || 4001;
// WHY: overridable so the publish integration test can run against a throwaway
@@ -49,7 +50,7 @@ const FILE_LABELS = {
hasznalatiFeltetelek: '⚖️ ÁSZF',
};
const { HTML, GUIDE_PAGE, LOGIN_PAGE } = require('./scripts/cms-pages');
const { HTML, GUIDE_PAGE, LOGIN_PAGE, VERSIONS_PAGE } = require('./scripts/cms-pages');
const { validateLogin, createSessionCookie, clearSessionCookie, hasValidSession, deleteSession } = require('./scripts/cms-session');
// Browser script is kept in its own file and inlined into the HTML template at render time.
@@ -58,80 +59,14 @@ const clientJs = fs.readFileSync(path.join(__dirname, 'scripts', 'cms-editor-cli
// ── Server ───────────────────────────────────────────────────────────────────
const CMS_USER = process.env.CMS_USER;
const CMS_PASS = process.env.CMS_PASS;
const CMS_DEPLOY_ENV = process.env.CMS_DEPLOY_ENV;
const CSRF_TOKEN = process.env.CMS_CSRF_TOKEN || crypto.randomBytes(32).toString('hex');
const rateLimits = new Map();
function securityConfigIsValid() {
return Boolean(CMS_USER && CMS_PASS && ['staging', 'production'].includes(CMS_DEPLOY_ENV));
}
function getClientAddress(req) {
// The editor only listens on 127.0.0.1; the staging Nginx proxy supplies this header.
// WHY: take the LAST entry. Nginx ($proxy_add_x_forwarded_for) appends the real client
// IP to the list, so the first entry may be a spoofed value sent by the client — using
// it would let attackers bypass the rate limiter with a fresh "IP" per request.
const forwarded = req.headers['x-forwarded-for'];
if (typeof forwarded === 'string' && forwarded.trim()) {
const parts = forwarded.split(',').map(part => part.trim()).filter(Boolean);
if (parts.length > 0) return parts[parts.length - 1];
}
return req.socket.remoteAddress || 'unknown';
}
function exceedsRateLimit(key, limit) {
const now = Date.now();
const attempts = (rateLimits.get(key) || []).filter(time => now - time < RATE_LIMIT_WINDOW_MS);
attempts.push(now);
rateLimits.set(key, attempts);
return attempts.length > limit;
}
function hasValidCredentials(req) {
const b64auth = (req.headers.authorization || '').split(' ')[1] || '';
const [login = '', password = ''] = Buffer.from(b64auth, 'base64').toString().split(':');
return validateLogin(login, password, CMS_USER, CMS_PASS);
}
function isBrowserNavigation(req) {
return req.method === 'GET' && String(req.headers.accept || '').includes('text/html');
}
// WHY: Safari (and other browsers) cache Basic Auth credentials and resend them
// automatically, which would let an already-logged-out browser straight back in.
// Browser navigations therefore authenticate ONLY via the session cookie, so
// logout is final. Non-browser requests (curl, API clients) keep Basic Auth.
function isAuthenticated(req) {
if (isBrowserNavigation(req)) return hasValidSession(req);
return hasValidCredentials(req) || hasValidSession(req);
}
function hasValidCsrfToken(req) {
const token = req.headers['x-csrf-token'];
return typeof token === 'string'
&& token.length === CSRF_TOKEN.length
&& crypto.timingSafeEqual(Buffer.from(token), Buffer.from(CSRF_TOKEN));
}
function writeAudit(event, details = {}) {
const record = { timestamp: new Date().toISOString(), event, ...details };
fs.appendFileSync(AUDIT_LOG_FILE, JSON.stringify(record) + '\n', { encoding: 'utf8', mode: 0o600 });
}
function backupAndWriteAtomically(targetFile, data, backupDir = BACKUP_DIR) {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const backupName = `${path.basename(targetFile, '.json')}.${timestamp}.json`;
const backupFile = path.join(backupDir, backupName);
const tempFile = `${targetFile}.${process.pid}.${Date.now()}.tmp`;
fs.mkdirSync(backupDir, { recursive: true, mode: 0o700 });
fs.copyFileSync(targetFile, backupFile);
fs.writeFileSync(tempFile, JSON.stringify(data, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
fs.renameSync(tempFile, targetFile);
return backupFile;
}
// Security/infra helpers live in scripts/cms-core.js (file-size limits).
const core = require('./scripts/cms-core');
const { CMS_USER, CMS_PASS, CMS_DEPLOY_ENV, CSRF_TOKEN, securityConfigIsValid, getClientAddress, hasValidCsrfToken, backupAndWriteAtomically } = core;
const exceedsRateLimit = (key, limit) => core.exceedsRateLimit(key, limit, RATE_LIMIT_WINDOW_MS);
const hasValidCredentials = req => core.hasValidCredentials(req, validateLogin);
const isAuthenticated = core.makeIsAuthenticated(hasValidSession, validateLogin);
const isBrowserNavigation = core.isBrowserNavigation;
const writeAudit = core.makeWriteAudit(AUDIT_LOG_FILE);
// Deploy version = git short SHA of the checked-out commit. Read once at startup:
// a CMS "deploy" is git pull + service restart, so this identifies the running code.
@@ -323,7 +258,7 @@ const server = http.createServer(async (req, res) => {
res.end(JSON.stringify({ ok: false, error: validation.errors.join('; '), errors: validation.errors }));
return;
}
const backupFile = backupAndWriteAtomically(FILES[activeFile], data);
const backupFile = backupAndWriteAtomically(FILES[activeFile], data, BACKUP_DIR);
writeAudit('content_saved', { clientAddress, user: CMS_USER, file: activeFile, result: 'ok' });
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true, backup: path.relative(__dirname, backupFile) }));
@@ -369,6 +304,56 @@ const server = http.createServer(async (req, res) => {
return;
}
// GET /versions — backup list + optional diff view (browser page, session-auth).
if (req.method === 'GET' && u.pathname === '/versions') {
const versions = listVersions(BACKUP_DIR, activeFile);
let diff = null;
const showRaw = u.searchParams.get('show');
if (showRaw) {
const safe = safeBackupName(activeFile, showRaw);
if (safe) {
try {
diff = buildVersionDiff(BACKUP_DIR, FILES[activeFile], activeFile, safe);
} catch { /* unreadable backup: render list only */ }
}
}
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
res.end(VERSIONS_PAGE(activeFile, FILE_LABELS[activeFile] || activeFile, versions, diff, CSRF_TOKEN));
return;
}
// POST /restore — restore a backup; the current state is backed up first,
// so the restore itself is reversible. Schema validation guards against
// restoring a structurally broken backup.
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;
}
try {
const content = readBackupContent(BACKUP_DIR, backup);
const data = JSON.parse(content);
const validation = validateContent(activeFile, data);
if (!validation.ok) {
writeAudit('version_restored', { clientAddress, user: CMS_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;
}
backupAndWriteAtomically(FILES[activeFile], data, BACKUP_DIR);
writeAudit('version_restored', { clientAddress, user: CMS_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: CMS_USER, file: activeFile, backup, result: 'error' });
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: e.message }));
}
return;
}
// GET / — editor UI
let message = null;
let jsonData = '{}';