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 ? `