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
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:
+61
-76
@@ -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 = '{}';
|
||||
|
||||
Reference in New Issue
Block a user