From 3818859cc51066ed489d9d2dcd5200056259e34e Mon Sep 17 00:00:00 2001 From: Do Siki Date: Tue, 18 Aug 2026 20:57:08 +0200 Subject: [PATCH] feat(cms): optimistic locking against stale-tab overwrites A Content Editor tab left open across a deploy (or a save from another tab) held the pre-deploy content; one Save would silently overwrite the newer file. The editor page now embeds a SHA-256 fingerprint of the file content at load time, /save requires it back in X-Content-Hash and compares against the current file: mismatch (or a missing header) answers 409 with an explanatory message and writes nothing. The client offers a reload on 409. Integration test covers: matching hash saves, stale hash rejected with the file untouched, missing hash rejected, retry with the fresh hash succeeds. Closes MITHOME-61 --- content-editor.js | 19 +++- docs/felhasznaloi-utmutato.md | 1 + scripts/cms-editor-client.js | 8 +- scripts/cms-pages.js | 3 +- scripts/test-content-editor-conflict.js | 127 ++++++++++++++++++++++ scripts/test-content-editor-serializer.js | 3 +- 6 files changed, 157 insertions(+), 4 deletions(-) create mode 100644 scripts/test-content-editor-conflict.js diff --git a/content-editor.js b/content-editor.js index 1db5b97..6f0e16c 100644 --- a/content-editor.js +++ b/content-editor.js @@ -274,6 +274,18 @@ const server = http.createServer(async (req, res) => { return; } const data = JSON.parse(body); + // Optimistic locking: the editor echoes the fingerprint of the content it + // loaded. If the file changed since (deploy, another tab, git), a blind + // save would silently overwrite those changes — reject with 409 instead. + const clientHash = req.headers['x-content-hash']; + const currentOnDisk = fs.readFileSync(FILES[activeFile], 'utf8').trim(); + const currentHash = crypto.createHash('sha256').update(currentOnDisk).digest('hex'); + if (typeof clientHash !== 'string' || clientHash !== currentHash) { + writeAudit('content_saved', { clientAddress, user: CMS_USER, file: activeFile, result: 'conflict' }); + res.writeHead(409, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: false, error: 'A tartalom megváltozott, mióta ezt a lapot megnyitottad (pl. deploy vagy másik fül mentett). Frissítsd az oldalt, és végezd el újra a módosításokat.' })); + return; + } const validation = validateContent(activeFile, data); if (!validation.ok) { writeAudit('content_saved', { clientAddress, user: CMS_USER, file: activeFile, result: 'validation_failed' }); @@ -335,9 +347,14 @@ const server = http.createServer(async (req, res) => { } catch (e) { message = { type: 'err', text: 'Fájl olvasási hiba: ' + e.message }; } + // WHY: fingerprint of the file content at page load. The editor sends it back + // on save (X-Content-Hash); a mismatch means the file changed since this tab + // was opened (deploy, another tab, git) and a blind save would silently + // overwrite those changes. + const contentHash = crypto.createHash('sha256').update(jsonData).digest('hex'); res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); - res.end(HTML(activeFile, jsonData, message, CSRF_TOKEN, FILE_LABELS, clientJs)); + res.end(HTML(activeFile, jsonData, message, CSRF_TOKEN, FILE_LABELS, clientJs, contentHash)); }); if (require.main === module) { diff --git a/docs/felhasznaloi-utmutato.md b/docs/felhasznaloi-utmutato.md index b013ea4..a234c83 100644 --- a/docs/felhasznaloi-utmutato.md +++ b/docs/felhasznaloi-utmutato.md @@ -65,6 +65,7 @@ A dokumentum a repó része, és **folyamatosan karbantartott**: minden funkció ### 💾 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. +- **Ha a tartalom megváltozott, mióta a lapot megnyitottad** (pl. közben deploy történt vagy egy másik fülben mentett valaki), a Mentés figyelmeztet: ilyenkor döntsd el, hogy frissíted a lapot az új tartalomra (a szerkesztésed elvész), vagy megszakítod. Ezzel a védelemmel nem írható véletlenül felül senki módosítása. - Minden sikeres mentés **biztonsági mentést** készít a szerveren (`.content-backups/`), és naplózza a műveletet. - Ha a Mentés sikeres, a mentett állapotot **Előnézet** gombbal nézheted meg a staging oldalon. diff --git a/scripts/cms-editor-client.js b/scripts/cms-editor-client.js index 8aed686..22c53ea 100644 --- a/scripts/cms-editor-client.js +++ b/scripts/cms-editor-client.js @@ -233,10 +233,16 @@ async function save() { const data = collect(); const res = await fetch('/save?file=' + FILE, { method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': CSRF_TOKEN }, + headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': CSRF_TOKEN, 'X-Content-Hash': CONTENT_HASH }, body: JSON.stringify(data, null, 2) }); if (res.status === 401) { location.href = '/login'; return; } + if (res.status === 409) { + if (confirm('A tartalom megváltozott, mióta ez a lap megnyílt (pl. deploy vagy másik fül mentett).\n\nOK = lap frissítése az új tartalommal (a szerkesztésed elvész)\nMégse = maradsz ezen a lapon, a mentés nem történt meg.')) { + location.reload(); + } + return; + } const json = await res.json(); const status = document.getElementById('saveStatus'); if (json.ok) { diff --git a/scripts/cms-pages.js b/scripts/cms-pages.js index 090e0ed..7c06642 100644 --- a/scripts/cms-pages.js +++ b/scripts/cms-pages.js @@ -4,7 +4,7 @@ const isStaging = () => process.env.CMS_DEPLOY_ENV === 'staging'; // FILE_LABELS is injected to avoid a circular dependency with the main file. -const HTML = (activeFile, jsonData, message, csrfToken, fileLabels, clientJs) => ` +const HTML = (activeFile, jsonData, message, csrfToken, fileLabels, clientJs, contentHash) => ` @@ -120,6 +120,7 @@ ${message ? `
${messag const DATA = JSON.parse(document.getElementById('page-data').textContent); const FILE = "${activeFile}"; const CSRF_TOKEN = "${csrfToken}"; +const CONTENT_HASH = "${contentHash}"; ${clientJs} diff --git a/scripts/test-content-editor-conflict.js b/scripts/test-content-editor-conflict.js new file mode 100644 index 0000000..32a8b08 --- /dev/null +++ b/scripts/test-content-editor-conflict.js @@ -0,0 +1,127 @@ +#!/usr/bin/env node + +/** + * Integration test for optimistic locking on CMS save (MITHOME-61): + * 1. save with the correct X-Content-Hash → 200 + * 2. save with a stale hash (file changed on disk meanwhile) → 409, file untouched + * 3. save without any hash header → 409 (strict: must always send the fingerprint) + * 4. a follow-up save with the NEW hash succeeds → the editor can continue after refresh + */ +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 = 4128; +const BASE = `http://127.0.0.1:${PORT}`; + +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'cms-conflict-')); +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: 'conflict-test-user', + CMS_PASS: 'conflict-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}/logout`); + return; + } catch { + await new Promise(r => setTimeout(r, 200)); + } + } + throw new Error('server did not start'); +} + +const hashOf = s => crypto.createHash('sha256').update(s.trim()).digest('hex'); + +async function call(method, pathName, { body, headers } = {}) { + const login = await fetch(`${BASE}/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ user: 'conflict-test-user', pass: 'conflict-test-pass' }), + }); + const cookie = (login.headers.get('set-cookie') || '').split(';')[0]; + if (method === 'GET') { + return fetch(`${BASE}${pathName}`, { headers: { Cookie: cookie, ...headers } }); + } + const page = await (await fetch(`${BASE}${pathName.split('?')[0] || '/'}?file=contact`, { headers: { Cookie: cookie } })).text(); + const csrf = page.match(/CSRF_TOKEN = "([a-f0-9]+)"/)[1]; + return fetch(`${BASE}${pathName}`, { method, headers: { Cookie: cookie, 'X-CSRF-Token': csrf, ...headers }, body }); +} + +async function main() { + await waitForServer(); + const contentFile = path.join(ROOT, 'proto', 'src', 'content', 'pages', 'contact.json'); + + // Baseline: read the page and capture the served fingerprint + const page = await (await call('GET', '/?file=contact')).text(); + const servedHash = page.match(/const CONTENT_HASH = "([a-f0-9]+)"/)[1]; + const diskBefore = fs.readFileSync(contentFile, 'utf8'); + assert.equal(servedHash, hashOf(diskBefore), 'served fingerprint matches the file on disk'); + + const payload = diskBefore; // unchanged content is still a valid save payload + const jsonHeaders = { 'Content-Type': 'application/json' }; + + // 1. correct hash → 200 + const ok = await call('POST', '/save?file=contact', { + headers: { ...jsonHeaders, 'X-Content-Hash': servedHash }, + body: payload, + }); + assert.equal(ok.status, 200); + + // 2. stale hash: simulate the file changing on disk (deploy/other tab) + const savedContact = fs.readFileSync(contentFile, 'utf8'); // keep the exact bytes + try { + fs.writeFileSync(contentFile, diskBefore.replace('"responseTime"', '"responseTime" /*changed*/')); + const stale = await call('POST', '/save?file=contact', { + headers: { ...jsonHeaders, 'X-Content-Hash': servedHash }, + body: payload, + }); + assert.equal(stale.status, 409); + const body = await stale.json(); + assert.match(body.error, /megváltozott/); + // file untouched by the rejected save (still the "changed" variant) + assert.ok(fs.readFileSync(contentFile, 'utf8').includes('/*changed*/')); + } finally { + fs.writeFileSync(contentFile, savedContact); // restore + } + + // 3. missing hash → 409 (strict) + const noHash = await call('POST', '/save?file=contact', { + headers: jsonHeaders, + body: payload, + }); + assert.equal(noHash.status, 409); + + // 4. save with the fresh hash of the restored file succeeds + const freshHash = hashOf(fs.readFileSync(contentFile, 'utf8')); + const retry = await call('POST', '/save?file=contact', { + headers: { ...jsonHeaders, 'X-Content-Hash': freshHash }, + body: payload, + }); + assert.equal(retry.status, 200); + + console.log('Content Editor optimistic-lock test: OK'); +} + +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 */ } + }); diff --git a/scripts/test-content-editor-serializer.js b/scripts/test-content-editor-serializer.js index 11a1e94..0aa839e 100644 --- a/scripts/test-content-editor-serializer.js +++ b/scripts/test-content-editor-serializer.js @@ -25,7 +25,8 @@ const fixture = { sections: [{ id: 'first', items: ['egy', 'kettő'], settings: { visible: false, weight: 1 } }], }; const clientJs = fs.readFileSync('scripts/cms-editor-client.js', 'utf8'); -const html = serverContext.globalThis.renderContentEditor('home', JSON.stringify(fixture), null, 'csrf-test-token', { common: '⚙️ Közös' }, clientJs); +const html = serverContext.globalThis.renderContentEditor('home', JSON.stringify(fixture), null, 'csrf-test-token', { common: '⚙️ Közös' }, clientJs, 'hash-test-value'); +assert.ok(html.includes('const CONTENT_HASH = "hash-test-value"')); const browserSource = [...html.matchAll(/]*)?>([\s\S]*?)<\/script>/g)].at(-1)[1] .replace("render(DATA, document.getElementById('editor'));", '') .replace("const toast = document.querySelector('.toast');", 'const toast = null;');