diff --git a/content-editor.js b/content-editor.js index 98ca466..3ad4175 100644 --- a/content-editor.js +++ b/content-editor.js @@ -134,12 +134,6 @@ const server = http.createServer(async (req, res) => { // Public: login form endpoint. Shares the auth rate-limit budget with failed // Basic attempts so the form cannot be brute-forced either. if (req.method === 'POST' && u.pathname === '/login') { - if (exceedsRateLimit(`auth:${clientAddress}`, AUTH_MAX_ATTEMPTS)) { - writeAudit('login_failed', { clientAddress, result: 'rate_limited' }); - res.writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': String(RATE_LIMIT_WINDOW_MS / 1000) }); - res.end(JSON.stringify({ ok: false, error: 'Túl sok belépési kísérlet — próbáld újra később.' })); - return; - } let body = ''; let bodyTooLarge = false; req.on('data', c => { @@ -155,12 +149,20 @@ const server = http.createServer(async (req, res) => { pass = String(parsed.pass || ''); } catch { /* empty credentials fail validation below */ } if (!bodyTooLarge && validateLogin(user, pass, CMS_USER, CMS_PASS)) { + // WHY: successful logins must not consume the failure budget — tests and + // multi-tab users log in repeatedly and would lock themselves out. const isSecure = req.headers['x-forwarded-proto'] === 'https'; writeAudit('login_success', { clientAddress }); res.writeHead(200, { 'Content-Type': 'application/json', 'Set-Cookie': createSessionCookie(isSecure) }); res.end(JSON.stringify({ ok: true })); return; } + if (exceedsRateLimit(`auth:${clientAddress}`, AUTH_MAX_ATTEMPTS)) { + writeAudit('login_failed', { clientAddress, result: 'rate_limited' }); + res.writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': String(RATE_LIMIT_WINDOW_MS / 1000) }); + res.end(JSON.stringify({ ok: false, error: 'Túl sok belépési kísérlet — próbáld újra később.' })); + return; + } writeAudit('login_failed', { clientAddress, result: bodyTooLarge ? 'request_too_large' : 'invalid_credentials' }); res.writeHead(401, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: false, error: 'Hibás felhasználónév vagy jelszó.' })); @@ -263,9 +265,13 @@ const server = http.createServer(async (req, res) => { return; } const backupFile = backupAndWriteAtomically(FILES[activeFile], data, BACKUP_DIR); + // Return the hash of the written content so the editor tab can refresh + // its fingerprint — otherwise the user's OWN next save would trip the + // optimistic-lock 409 (MITHOME-68). + const newHash = crypto.createHash('sha256').update(JSON.stringify(data, null, 2).trim()).digest('hex'); 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) })); + res.end(JSON.stringify({ ok: true, backup: path.relative(__dirname, backupFile), contentHash: newHash })); } catch (e) { writeAudit('content_saved', { clientAddress, user: CMS_USER, file: activeFile, result: 'error' }); res.writeHead(500, { 'Content-Type': 'application/json' }); diff --git a/scripts/cms-editor-client.js b/scripts/cms-editor-client.js index d904953..dc6a50e 100644 --- a/scripts/cms-editor-client.js +++ b/scripts/cms-editor-client.js @@ -258,14 +258,17 @@ async function save() { const json = await res.json(); const status = document.getElementById('saveStatus'); if (json.ok) { + // Refresh the optimistic-lock fingerprint with the server-computed hash of + // the saved content, so the user's own subsequent saves don't trip 409. + if (json.contentHash) CONTENT_HASH = json.contentHash; status.textContent = '✅ Mentve!'; status.style.color = '#10b981'; } else { status.textContent = '❌ Hiba: ' + json.error; status.style.color = '#f87171'; } - status.style.display = 'inline'; - setTimeout(() => status.style.display = 'none', 3000); + status.style.visibility = 'visible'; + setTimeout(() => status.style.visibility = 'hidden', 3000); } async function publish() { @@ -274,7 +277,11 @@ async function publish() { // Save first await save(); - + + // WHY: lock the button width and remember the label so the running state + // neither resizes the bottom bar nor permanently swaps the env-specific label. + const originalLabel = btn.textContent; + btn.style.minWidth = btn.offsetWidth + 'px'; btn.textContent = '⏳ Élesítés folyamatban...'; btn.disabled = true; @@ -295,10 +302,11 @@ async function publish() { status.style.color = '#f87171'; } - btn.textContent = '🚀 Publikálás & Élesítés'; + btn.textContent = originalLabel; + btn.style.minWidth = ''; btn.disabled = false; - status.style.display = 'inline'; - setTimeout(() => status.style.display = 'none', 5000); + status.style.visibility = 'visible'; + setTimeout(() => status.style.visibility = 'hidden', 5000); } async function logout() { diff --git a/scripts/cms-pages.js b/scripts/cms-pages.js index d1d5e12..3f35f46 100644 --- a/scripts/cms-pages.js +++ b/scripts/cms-pages.js @@ -66,7 +66,7 @@ const HTML = (activeFile, jsonData, message, csrfToken, fileLabels, clientJs, co /* Bottom bar */ .bottom-bar { position: fixed; bottom: 0; left: 0; right: 0; background: #0f1117; border-top: 1px solid #2d3748; padding: 14px 32px; display: flex; gap: 14px; align-items: center; z-index: 50; } - .btn-logout { margin-left: auto; background: #1f2937; color: #e2e8f0; border: 1px solid #374151; border-radius: 8px; padding: 9px 16px; font-size: 14px; cursor: pointer; } + .btn-logout { background: #1f2937; color: #e2e8f0; border: 1px solid #374151; border-radius: 8px; padding: 9px 16px; font-size: 14px; cursor: pointer; } .btn-logout:hover { background: #374151; } .version-tag { color: #475569; font-size: 12px; font-family: monospace; } .btn-save { background: linear-gradient(135deg,#3b82f6,#6366f1); color: #fff; border: none; padding: 11px 26px; border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer; transition: opacity .2s, transform .1s; } @@ -75,9 +75,11 @@ const HTML = (activeFile, jsonData, message, csrfToken, fileLabels, clientJs, co .btn-publish { background: linear-gradient(135deg,#10b981,#059669); color: #fff; border: none; padding: 11px 26px; border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer; transition: opacity .2s, transform .1s; } .btn-publish:hover { opacity: .9; transform: translateY(-1px); } .btn-publish:active { transform: translateY(0); } - .preview-link { color: #64748b; font-size: 13px; text-decoration: none; margin-left: auto; } + .preview-link { color: #64748b; font-size: 13px; text-decoration: none; } .preview-link:hover { color: #94a3b8; } - .save-status { font-size: 13px; font-weight: 500; display: none; margin-left: 8px; } + /* WHY: the status slot always occupies the same flex space (visibility, not + display) so showing/hiding messages never shifts the other bar items. */ + .save-status { flex: 1 1 0; min-width: 0; margin: 0 8px; font-size: 13px; font-weight: 500; visibility: hidden; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } /* Toast */ .toast { position: fixed; top: 20px; right: 20px; padding: 13px 18px; border-radius: 9px; font-size: 14px; font-weight: 500; z-index: 200; animation: slideIn .3s ease; } @@ -124,7 +126,7 @@ ${message ? `
${messag const DATA = JSON.parse(document.getElementById('page-data').textContent); const FILE = "${activeFile}"; const CSRF_TOKEN = "${csrfToken}"; -const CONTENT_HASH = "${contentHash}"; +let CONTENT_HASH = "${contentHash}"; ${clientJs} diff --git a/scripts/test-content-editor-bottombar.js b/scripts/test-content-editor-bottombar.js new file mode 100644 index 0000000..c087d99 --- /dev/null +++ b/scripts/test-content-editor-bottombar.js @@ -0,0 +1,33 @@ +#!/usr/bin/env node + +/** + * Layout guard for the CMS bottom bar (MITHOME-69): the bar items must not + * shift while saving/publishing. Asserts the invariants that keep the layout + * stable: + * - the status slot reserves constant space (flex + visibility, not display) + * - no auto margins redistribute free space between bar items + * - the publish handler locks the button width and restores its label + * - save refreshes the optimistic-lock fingerprint (MITHOME-68) + */ +const assert = require('assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const pages = fs.readFileSync(path.join(__dirname, '../scripts/cms-pages.js'), 'utf8'); +const client = fs.readFileSync(path.join(__dirname, '../scripts/cms-editor-client.js'), 'utf8'); + +const statusRule = pages.match(/\.save-status \{[^}]*\}/)[0]; +assert.match(statusRule, /flex: 1 1 0/, 'status slot must reserve constant space'); +assert.match(statusRule, /visibility: hidden/, 'status must hide via visibility (keeps layout slot)'); +assert.doesNotMatch(statusRule, /display: none/, 'display:none would collapse the slot and shift items'); + +const previewRule = pages.match(/\.preview-link \{[^}]*\}/)[0]; +assert.doesNotMatch(previewRule, /margin-left: auto/, 'auto margins redistribute space on width changes'); +const logoutRule = pages.match(/\.btn-logout \{[^}]*\}/)[0]; +assert.doesNotMatch(logoutRule, /margin-left: auto/, 'auto margins redistribute space on width changes'); + +assert.match(client, /btn\.style\.minWidth = btn\.offsetWidth \+ 'px'/, 'publish must lock the button width'); +assert.match(client, /const originalLabel = btn\.textContent/, 'publish must restore the env-specific label'); +assert.match(client, /if \(json\.contentHash\) CONTENT_HASH = json\.contentHash/, 'save must refresh the lock fingerprint'); + +console.log('Content Editor bottom bar layout guard: OK'); diff --git a/scripts/test-content-editor-conflict.js b/scripts/test-content-editor-conflict.js index 32a8b08..7e6cb6e 100644 --- a/scripts/test-content-editor-conflict.js +++ b/scripts/test-content-editor-conflict.js @@ -67,17 +67,74 @@ async function call(method, pathName, { body, headers } = {}) { async function main() { await waitForServer(); const contentFile = path.join(ROOT, 'proto', 'src', 'content', 'pages', 'contact.json'); + const original = fs.readFileSync(contentFile, 'utf8'); + + 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]; + const csrfPage = await (await fetch(`${BASE}/?file=contact`, { headers: { Cookie: cookie } })).text(); + const csrf = csrfPage.match(/CSRF_TOKEN = "([a-f0-9]+)"/)[1]; // 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 servedHash = page.match(/let 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 + // MITHOME-68: the save response returns the new content hash; a tab that + // adopts it can save again — only a genuinely external change may 409. + const v3 = JSON.parse(original); + v3.hero.subtitle = 'Hash frissítés teszt ' + Date.now(); + const first = 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(v3, null, 2), + }); + assert.equal(first.status, 200); + const firstBody = await first.json(); + assert.match(firstBody.contentHash, /^[a-f0-9]{64}$/, 'save must return the new content hash'); + + // same tab continues with the returned hash → 200 + const again = await fetch(`${BASE}/save?file=contact`, { + method: 'POST', + headers: { + Cookie: cookie, + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrf, + 'X-Content-Hash': firstBody.contentHash, + }, + body: JSON.stringify(v3, null, 2), + }); + assert.equal(again.status, 200, 'save with the refreshed hash must succeed'); + + // a stale (pre-save) hash still 409s + const stale = await fetch(`${BASE}/save?file=contact`, { + method: 'POST', + headers: { + Cookie: cookie, + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrf, + 'X-Content-Hash': hashOf(original), + }, + body: JSON.stringify(v3, null, 2), + }); + assert.equal(stale.status, 409, 'stale hash must still be rejected'); + + // leave the disk as the following sections expect it + fs.writeFileSync(contentFile, original); + + // 1. correct hash → 200 const ok = await call('POST', '/save?file=contact', { headers: { ...jsonHeaders, 'X-Content-Hash': servedHash }, body: payload, diff --git a/scripts/test-content-editor-serializer.js b/scripts/test-content-editor-serializer.js index 0aa839e..102d364 100644 --- a/scripts/test-content-editor-serializer.js +++ b/scripts/test-content-editor-serializer.js @@ -26,7 +26,7 @@ const fixture = { }; 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, 'hash-test-value'); -assert.ok(html.includes('const CONTENT_HASH = "hash-test-value"')); +assert.ok(html.includes('let 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;');