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
+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 };