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
+101
View File
@@ -0,0 +1,101 @@
// Security and infrastructure helpers for the Content Editor, extracted so
// content-editor.js stays focused on HTTP routing (file-size limits).
// Dependencies (validateLogin, hasValidSession) are injected to avoid cycles.
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
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, windowMs) {
const now = Date.now();
const attempts = (rateLimits.get(key) || []).filter(time => now - time < windowMs);
attempts.push(now);
rateLimits.set(key, attempts);
return attempts.length > limit;
}
function hasValidCredentials(req, validateLogin) {
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 makeIsAuthenticated(hasValidSession, validateLogin) {
return function isAuthenticated(req) {
if (isBrowserNavigation(req)) return hasValidSession(req);
return hasValidCredentials(req, validateLogin) || 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 makeWriteAudit(auditFile) {
return function writeAudit(event, details = {}) {
const record = { timestamp: new Date().toISOString(), event, ...details };
fs.appendFileSync(auditFile, JSON.stringify(record) + '\n', { encoding: 'utf8', mode: 0o600 });
};
}
function backupAndWriteAtomically(targetFile, data, backupDir) {
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;
}
module.exports = {
CMS_USER,
CMS_PASS,
CMS_DEPLOY_ENV,
CSRF_TOKEN,
securityConfigIsValid,
getClientAddress,
exceedsRateLimit,
hasValidCredentials,
isBrowserNavigation,
makeIsAuthenticated,
hasValidCsrfToken,
makeWriteAudit,
backupAndWriteAtomically,
};
+65
View File
@@ -0,0 +1,65 @@
// Dependency-free line diff (LCS) for the CMS version comparison view.
// Input lines are plain text; output entries are typed add/del/ctx rows.
function diffLines(oldLines, newLines) {
const n = oldLines.length;
const m = newLines.length;
// LCS lengths DP (files are small, a few hundred lines — O(n*m) is fine)
const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
for (let i = n - 1; i >= 0; i--) {
for (let j = m - 1; j >= 0; j--) {
dp[i][j] = oldLines[i] === newLines[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
}
}
const out = [];
let i = 0;
let j = 0;
while (i < n && j < m) {
if (oldLines[i] === newLines[j]) {
out.push({ type: 'ctx', text: oldLines[i] });
i++;
j++;
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
out.push({ type: 'del', text: oldLines[i] });
i++;
} else {
out.push({ type: 'add', text: newLines[j] });
j++;
}
}
while (i < n) { out.push({ type: 'del', text: oldLines[i] }); i++; }
while (j < m) { out.push({ type: 'add', text: newLines[j] }); j++; }
return out;
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// Keep only ±contextAround context lines around changes to keep pages small.
function trimContext(entries, contextAround = 3) {
const keep = new Array(entries.length).fill(false);
entries.forEach((e, idx) => {
if (e.type !== 'ctx') {
for (let k = Math.max(0, idx - contextAround); k <= Math.min(entries.length - 1, idx + contextAround); k++) keep[k] = true;
}
});
const out = [];
let skipping = false;
entries.forEach((e, idx) => {
if (keep[idx]) { out.push(e); skipping = false; }
else if (!skipping) { out.push({ type: 'skip', text: '…' }); skipping = true; }
});
return out;
}
function renderDiffHtml(oldText, newText) {
const entries = trimContext(diffLines(oldText.split('\n'), newText.split('\n')));
return entries.map(e => `<div class="diff-${e.type}">${escapeHtml(e.text) || '&nbsp;'}</div>`).join('\n');
}
module.exports = { diffLines, trimContext, renderDiffHtml, escapeHtml };
+82 -1
View File
@@ -113,6 +113,7 @@ ${message ? `<div class="toast ${message.type === 'ok' ? 'ok' : 'err'}">${messag
<span class="save-status" id="saveStatus"></span>
<a href="${isStaging() ? 'https://stage.mozdit.hu' : 'http://localhost:3000'}" target="_blank" class="preview-link">🔗 Előnézet →</a>
<a href="/guide" target="_blank" class="preview-link">❓ Súgó</a>
<a href="/versions?file=${activeFile}" target="_blank" class="preview-link">🕘 Verziók</a>
<span class="version-tag" title="Futó kód verziója (git SHA)">v${deployVersion}</span>
<button class="btn-logout" onclick="logout()">🚪 Kilépés</button>
</div>
@@ -237,4 +238,84 @@ async function login(e) {
</body>
</html>`;
module.exports = { HTML, GUIDE_PAGE, LOGIN_PAGE };
// Version history page: lists automatic backups of the selected file with a
// diff view (?show=) and one-click restore (POST /restore).
const VERSIONS_PAGE = (fileKey, fileLabel, versions, diff, csrfToken) => `<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>mozdIT — Verziók: ${fileLabel}</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f1117; color: #e2e8f0; line-height: 1.6; padding-bottom: 64px; }
header { background: linear-gradient(135deg,#1a1f2e,#252d40); border-bottom: 1px solid #2d3748; padding: 14px 32px; display: flex; align-items: center; gap: 12px; position: sticky; top: 0; z-index: 10; }
header h1 { font-size: 17px; font-weight: 700; background: linear-gradient(135deg,#60a5fa,#a78bfa); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
header a { color: #94a3b8; text-decoration: none; font-size: 14px; margin-left: auto; }
header a:hover { color: #e2e8f0; }
main { max-width: 860px; margin: 0 auto; padding: 28px 24px; }
.note { color: #94a3b8; font-size: 14px; margin-bottom: 18px; }
.ver { background: #1a2035; border: 1px solid #2d3748; border-radius: 10px; padding: 14px 18px; margin-bottom: 10px; display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
.ver .when { font-family: monospace; font-size: 14px; color: #93c5fd; }
.ver .size { color: #64748b; font-size: 13px; }
.ver .actions { margin-left: auto; display: flex; gap: 8px; }
.btn { background: #1f2937; color: #e2e8f0; border: 1px solid #374151; border-radius: 8px; padding: 8px 14px; font-size: 13px; cursor: pointer; text-decoration: none; }
.btn:hover { background: #374151; }
.btn-restore { background: #14532d; border-color: #10b981; color: #6ee7b7; }
.btn-restore:hover { background: #166534; }
h2 { font-size: 16px; margin: 26px 0 10px; color: #93c5fd; }
.diff { background: #0f1420; border: 1px solid #2d3748; border-radius: 10px; padding: 14px; font-family: monospace; font-size: 13px; overflow-x: auto; }
.diff div { padding: 1px 10px; white-space: pre-wrap; word-break: break-all; }
.diff-add { background: #064e3b; color: #6ee7b7; }
.diff-del { background: #450a0a; color: #fca5a5; text-decoration: line-through; }
.diff-skip { color: #475569; }
.diff-ctx { color: #94a3b8; }
.empty { color: #64748b; padding: 24px; text-align: center; }
</style>
</head>
<body>
<header>
<h1>🕘 Verziók — ${fileLabel}</h1>
<a href="/?file=${fileKey}">← Vissza a szerkesztőhöz</a>
</header>
<main>
<p class="note">Minden Mentés automatikus másolatot készít. A ⚖ Összehasonlítás megmutatja az adott mentés és a <strong>jelenlegi</strong> tartalom különbségét (piros = mentésben volt, zöld = most van). A visszaállítás előtt a jelenlegi állapot is mentésre kerül, tehát a visszaállítás is visszavonható.</p>
${versions.length === 0 ? '<div class="empty">Ehhez a fájlhoz még nincs mentés.</div>' : versions.map(v => `
<div class="ver">
<span class="when">${v.when}</span>
<span class="size">${v.size} B</span>
<span class="actions">
<a class="btn" href="/versions?file=${fileKey}&show=${v.name}">⚖ Összehasonlítás</a>
<button class="btn btn-restore" onclick="restore('${v.name}')">↩ Visszaállítás</button>
</span>
</div>`).join('')}
${diff ? `
<h2>Különbség: mentés (${diff.when}) → jelenlegi tartalom</h2>
<div class="diff">${diff.diffHtml}</div>` : ''}
</main>
<script>
const CSRF_TOKEN = "${csrfToken}";
const FILE = "${fileKey}";
async function restore(name) {
if (!confirm('Biztosan visszaállítod ezt a mentést?\\nA jelenlegi tartalom mentésre kerül, így ez később is visszavonható.')) return;
try {
const res = await fetch('/restore?file=' + FILE + '&backup=' + encodeURIComponent(name), {
method: 'POST',
headers: { 'X-CSRF-Token': CSRF_TOKEN }
});
if (res.status === 401) { location.href = '/login'; return; }
const json = await res.json();
if (json.ok) { alert('✅ Visszaállítva.'); location.href = '/?file=' + FILE; }
else alert('❌ Hiba: ' + json.error);
} catch (e) { alert('❌ Hálózati hiba'); }
}
</script>
</body>
</html>`;
module.exports = { HTML, GUIDE_PAGE, LOGIN_PAGE, VERSIONS_PAGE };
+59
View File
@@ -0,0 +1,59 @@
// 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 };
+173
View File
@@ -0,0 +1,173 @@
#!/usr/bin/env node
/**
* Integration test for the CMS Versions panel (MITHOME-64):
* 1. GET /versions lists the backups of the file (auth required)
* 2. GET /versions?show=<backup> renders a diff vs the current content
* 3. POST /restore restores an older backup; the pre-restore state gets a
* fresh backup too (restore is reversible)
* 4. path traversal backup names are rejected (400)
* 5. restore without CSRF is rejected (403)
*/
const assert = require('assert/strict');
const crypto = require('crypto');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawn } = require('child_process');
const ROOT = path.join(__dirname, '..');
const PORT = 4131;
const BASE = `http://127.0.0.1:${PORT}`;
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'cms-versions-'));
const auditFile = path.join(tmp, 'audit.jsonl');
const child = spawn('node', ['content-editor.js'], {
cwd: ROOT,
env: {
...process.env,
CONTENT_EDITOR_PORT: String(PORT),
CONTENT_EDITOR_AUDIT_FILE: auditFile,
CMS_USER: 'versions-test-user',
CMS_PASS: 'versions-test-pass',
CMS_DEPLOY_ENV: 'staging',
},
stdio: 'ignore',
});
async function waitForServer(timeoutMs = 10000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
await fetch(`${BASE}/version`);
return;
} catch {
await new Promise(r => setTimeout(r, 200));
}
}
throw new Error('server did not start');
}
async function session() {
const login = await fetch(`${BASE}/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user: 'versions-test-user', pass: 'versions-test-pass' }),
});
return (login.headers.get('set-cookie') || '').split(';')[0];
}
async function csrfOf(cookie) {
const page = await (await fetch(`${BASE}/?file=contact`, { headers: { Cookie: cookie } })).text();
return page.match(/CSRF_TOKEN = "([a-f0-9]+)"/)[1];
}
const hashOf = s => crypto.createHash('sha256').update(s.trim()).digest('hex');
async function main() {
await waitForServer();
const cookie = await session();
const csrf = await csrfOf(cookie);
const contentFile = path.join(ROOT, 'proto', 'src', 'content', 'pages', 'contact.json');
const original = fs.readFileSync(contentFile, 'utf8');
const backupDir = path.join(ROOT, '.content-backups');
const testStartedAt = Date.now();
try {
// Create two saves → two backups of intermediate states
const v1 = JSON.parse(original);
v1.hero.subtitle = 'Verzió teszt #1';
const v2 = JSON.parse(original);
v2.hero.subtitle = 'Verzió teszt #2';
for (const variant of [v1, v2]) {
const res = await fetch(`${BASE}/save?file=contact`, {
method: 'POST',
headers: {
Cookie: cookie,
'Content-Type': 'application/json',
'X-CSRF-Token': csrf,
'X-Content-Hash': hashOf(fs.readFileSync(contentFile, 'utf8')),
},
body: JSON.stringify(variant, null, 2),
});
assert.equal(res.status, 200, 'seed save must succeed');
}
// restore the pristine original as the "current" state for the diff assertion
const third = await fetch(`${BASE}/save?file=contact`, {
method: 'POST',
headers: {
Cookie: cookie,
'Content-Type': 'application/json',
'X-CSRF-Token': csrf,
'X-Content-Hash': hashOf(fs.readFileSync(contentFile, 'utf8')),
},
body: original,
});
assert.equal(third.status, 200);
// 1. versions page lists backups (names appear in the show= comparison links)
const versionsPage = await (await fetch(`${BASE}/versions?file=contact`, { headers: { Cookie: cookie } })).text();
assert.match(versionsPage, /Verziók/);
assert.match(versionsPage, /Visszaállítás/);
const names = [...versionsPage.matchAll(/restore\('([^']+)'\)/g)].map(m => m[1]);
assert.ok(names.length >= 3, `expected at least 3 backups, got ${names.length}`);
// backups of v1 (the oldest seeded state) — pick the one that contains subtitle #1
// (backups hold the state BEFORE each save: original, v1, v2)
// 2. diff view: pick the backup that holds "Verzió teszt #1" (created during
// this run) and compare it with the current (original) content
const backupHoldingV1 = fs.readdirSync(backupDir)
.filter(name => name.startsWith('contact.'))
.filter(name => fs.statSync(path.join(backupDir, name)).mtimeMs >= testStartedAt)
.find(name => fs.readFileSync(path.join(backupDir, name), 'utf8').includes('Verzió teszt #1'));
assert.ok(backupHoldingV1, 'seeded backup holding v1 must exist');
const diffPage = await (await fetch(`${BASE}/versions?file=contact&show=${backupHoldingV1}`, { headers: { Cookie: cookie } })).text();
assert.match(diffPage, /diff-del/, 'diff must contain removed lines (backup side)');
assert.match(diffPage, /diff-add/, 'diff must contain added lines (current side)');
assert.match(diffPage, /Verzió teszt #1/);
// 3. restore the v1 backup → file content becomes v1
const restore = await fetch(`${BASE}/restore?file=contact&backup=${backupHoldingV1}`, {
method: 'POST',
headers: { Cookie: cookie, 'X-CSRF-Token': csrf },
});
assert.equal(restore.status, 200);
assert.ok(fs.readFileSync(contentFile, 'utf8').includes('Verzió teszt #1'));
// restore created a new backup of the pre-restore state (reversibility)
const afterPage = await (await fetch(`${BASE}/versions?file=contact`, { headers: { Cookie: cookie } })).text();
const namesAfter = [...afterPage.matchAll(/restore\('([^']+)'\)/g)].map(m => m[1]);
assert.equal(namesAfter.length, names.length + 1, 'restore must back up the current state first');
// 4. traversal is rejected
const evil = await fetch(`${BASE}/restore?file=contact&backup=${encodeURIComponent('../../package.json')}`, {
method: 'POST',
headers: { Cookie: cookie, 'X-CSRF-Token': csrf },
});
assert.equal(evil.status, 400);
// 5. no CSRF → 403
const noCsrf = await fetch(`${BASE}/restore?file=contact&backup=${backupHoldingV1}`, {
method: 'POST',
headers: { Cookie: cookie },
});
assert.equal(noCsrf.status, 403);
// unauthenticated listing is redirected for browsers / 401 otherwise
const anon = await fetch(`${BASE}/versions?file=contact`);
assert.equal(anon.status, 401);
console.log('Content Editor versions panel test: OK');
} finally {
fs.writeFileSync(contentFile, original); // leave the repo pristine
}
}
main()
.catch(err => { console.error('❌', err.message); process.exitCode = 1; })
.finally(() => {
child.kill('SIGTERM');
try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* best effort */ }
});