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 { validateContent } = require('./proto/src/content/schema');
|
||||||
const { renderMarkdown } = require('./scripts/markdown-render');
|
const { renderMarkdown } = require('./scripts/markdown-render');
|
||||||
const { buildPublishCommand, interpretPublishResult } = require('./scripts/cms-publish');
|
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;
|
const PORT = Number(process.env.CONTENT_EDITOR_PORT) || 4001;
|
||||||
// WHY: overridable so the publish integration test can run against a throwaway
|
// WHY: overridable so the publish integration test can run against a throwaway
|
||||||
@@ -49,7 +50,7 @@ const FILE_LABELS = {
|
|||||||
hasznalatiFeltetelek: '⚖️ ÁSZF',
|
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');
|
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.
|
// 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 ───────────────────────────────────────────────────────────────────
|
// ── Server ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const CMS_USER = process.env.CMS_USER;
|
// Security/infra helpers live in scripts/cms-core.js (file-size limits).
|
||||||
const CMS_PASS = process.env.CMS_PASS;
|
const core = require('./scripts/cms-core');
|
||||||
const CMS_DEPLOY_ENV = process.env.CMS_DEPLOY_ENV;
|
const { CMS_USER, CMS_PASS, CMS_DEPLOY_ENV, CSRF_TOKEN, securityConfigIsValid, getClientAddress, hasValidCsrfToken, backupAndWriteAtomically } = core;
|
||||||
const CSRF_TOKEN = process.env.CMS_CSRF_TOKEN || crypto.randomBytes(32).toString('hex');
|
const exceedsRateLimit = (key, limit) => core.exceedsRateLimit(key, limit, RATE_LIMIT_WINDOW_MS);
|
||||||
const rateLimits = new Map();
|
const hasValidCredentials = req => core.hasValidCredentials(req, validateLogin);
|
||||||
|
const isAuthenticated = core.makeIsAuthenticated(hasValidSession, validateLogin);
|
||||||
function securityConfigIsValid() {
|
const isBrowserNavigation = core.isBrowserNavigation;
|
||||||
return Boolean(CMS_USER && CMS_PASS && ['staging', 'production'].includes(CMS_DEPLOY_ENV));
|
const writeAudit = core.makeWriteAudit(AUDIT_LOG_FILE);
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deploy version = git short SHA of the checked-out commit. Read once at startup:
|
// 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.
|
// 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 }));
|
res.end(JSON.stringify({ ok: false, error: validation.errors.join('; '), errors: validation.errors }));
|
||||||
return;
|
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' });
|
writeAudit('content_saved', { clientAddress, user: CMS_USER, file: activeFile, result: 'ok' });
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
res.end(JSON.stringify({ ok: true, backup: path.relative(__dirname, backupFile) }));
|
res.end(JSON.stringify({ ok: true, backup: path.relative(__dirname, backupFile) }));
|
||||||
@@ -369,6 +304,56 @@ const server = http.createServer(async (req, res) => {
|
|||||||
return;
|
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
|
// GET / — editor UI
|
||||||
let message = null;
|
let message = null;
|
||||||
let jsonData = '{}';
|
let jsonData = '{}';
|
||||||
|
|||||||
@@ -62,6 +62,14 @@ A dokumentum a repó része, és **folyamatosan karbantartott**: minden funkció
|
|||||||
- **➕ Új elem hozzáadása** gomb: új elem beszúrása a lista végére (üres, a meglévőkhöz hasonló űrlappal).
|
- **➕ Új elem hozzáadása** gomb: új elem beszúrása a lista végére (üres, a meglévőkhöz hasonló űrlappal).
|
||||||
- Kártyás listáknál (pl. szolgáltatások) minden kártya külön törölhető a kártya alján lévő gombbal.
|
- Kártyás listáknál (pl. szolgáltatások) minden kártya külön törölhető a kártya alján lévő gombbal.
|
||||||
|
|
||||||
|
### 🕘 Verziók — korábbi állapotok
|
||||||
|
|
||||||
|
- Az alsó sáv **🕘 Verziók** gombja megnyitja az éppen szerkesztett fájl mentéseit (minden Mentés automatikus másolatot készít).
|
||||||
|
- **⚖ Összehasonlítás**: megmutatja, mi változott az adott mentéshez képest (piros = a mentésben lévő régi szöveg, zöld = a jelenlegi).
|
||||||
|
- **↩ Visszaállítás**: egy kattintással visszaállítja a mentést. A visszaállítás **előtt a jelenlegi tartalom is mentésre kerül**, tehát a visszaállítás is visszavonható.
|
||||||
|
- A visszaállítás sémaillesztésen megy át — hibás mentést nem lehet visszaállítani.
|
||||||
|
- Visszaállítás után a nyitott szerkesztő fülek frissítést kérnek (a tartalom megváltozott).
|
||||||
|
|
||||||
### 💾 Mentés
|
### 💾 Mentés
|
||||||
|
|
||||||
- A Mentés **ellenőrzi a tartalmat**: hiányzó vagy rossz típusú mező esetén hibaüzenetet kapsz, és a mentés nem történik meg — az oldal így nem tud elromlani.
|
- A Mentés **ellenőrzi a tartalmat**: hiányzó vagy rossz típusú mező esetén hibaüzenetet kapsz, és a mentés nem történik meg — az oldal így nem tud elromlani.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) || ' '}</div>`).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { diffLines, trimContext, renderDiffHtml, escapeHtml };
|
||||||
+82
-1
@@ -113,6 +113,7 @@ ${message ? `<div class="toast ${message.type === 'ok' ? 'ok' : 'err'}">${messag
|
|||||||
<span class="save-status" id="saveStatus"></span>
|
<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="${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="/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>
|
<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>
|
<button class="btn-logout" onclick="logout()">🚪 Kilépés</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -237,4 +238,84 @@ async function login(e) {
|
|||||||
</body>
|
</body>
|
||||||
</html>`;
|
</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 };
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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 */ }
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user