From 162e5782e99670093938f8334d1afdf26c1e541b Mon Sep 17 00:00:00 2001 From: Do Siki Date: Sat, 22 Aug 2026 12:31:28 +0200 Subject: [PATCH] refactor(cms): split oversized modules below the 400-line limit - content-editor.js: extract the /save handler to scripts/cms-save.js - cms-logo-page.js: inline the ~280-line canvas editor script to scripts/cms-logo-client.js (page is now the HTML/CSS shell only) - cms-editor-client.js: move the keyboard-shortcut section to scripts/cms-editor-shortcuts.js, inlined after the main client All modules now under the hard limit; full pre-deploy suite green. Closes MITHOME-80 --- content-editor.js | 67 +--- .../__tests__/cms-editor-shortcuts.test.ts | 1 + scripts/cms-editor-client.js | 55 ---- scripts/cms-editor-shortcuts.js | 56 ++++ scripts/cms-logo-client.js | 280 +++++++++++++++++ scripts/cms-logo-page.js | 286 +----------------- scripts/cms-save.js | 65 ++++ 7 files changed, 417 insertions(+), 393 deletions(-) create mode 100644 scripts/cms-editor-shortcuts.js create mode 100644 scripts/cms-logo-client.js create mode 100644 scripts/cms-save.js diff --git a/content-editor.js b/content-editor.js index d675bfb..416e982 100644 --- a/content-editor.js +++ b/content-editor.js @@ -17,6 +17,7 @@ const { validateContent } = require('./proto/src/content/schema'); const { renderMarkdown } = require('./scripts/markdown-render'); const { buildPublishCommand, interpretPublishResult } = require('./scripts/cms-publish'); const { handleVersionRoutes, listVersions } = require('./scripts/cms-versions'); +const { handleSaveRoute } = require('./scripts/cms-save'); const { LOGO_TARGETS, handleLogoRoutes } = require('./scripts/cms-logo'); const PORT = Number(process.env.CONTENT_EDITOR_PORT) || 4001; @@ -57,7 +58,8 @@ const { LOGO_PAGE } = require('./scripts/cms-logo-page'); 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. -const clientJs = fs.readFileSync(path.join(__dirname, 'scripts', 'cms-editor-client.js'), 'utf8'); +const clientJs = fs.readFileSync(path.join(__dirname, 'scripts', 'cms-editor-client.js'), 'utf8') + + '\n' + fs.readFileSync(path.join(__dirname, 'scripts', 'cms-editor-shortcuts.js'), 'utf8'); // ── Server ─────────────────────────────────────────────────────────────────── @@ -230,63 +232,12 @@ const server = http.createServer(async (req, res) => { return; } - // POST /save — JSON body - if (req.method === 'POST' && u.pathname === '/save') { - let body = ''; - let bodyTooLarge = false; - let bodySize = 0; - req.on('data', c => { - bodySize += c.length; - if (bodySize > MAX_REQUEST_BODY_BYTES) { - bodyTooLarge = true; - return; - } - body += c; - }); - req.on('end', () => { - try { - if (bodyTooLarge) { - writeAudit('content_saved', { clientAddress, user: CMS_USER, file: activeFile, result: 'request_too_large' }); - res.writeHead(413, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ ok: false, error: `A kérés túl nagy (maximum ${MAX_REQUEST_BODY_BYTES} byte)` })); - 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' }); - res.writeHead(422, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ ok: false, error: validation.errors.join('; '), errors: validation.errors })); - 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), contentHash: newHash })); - } catch (e) { - writeAudit('content_saved', { clientAddress, user: CMS_USER, file: activeFile, result: 'error' }); - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ ok: false, error: e.message })); - } - }); - return; - } + // POST /save — handled in scripts/cms-save.js (optimistic lock + validation + backup). + if (handleSaveRoute({ + req, res, u, activeFile, files: FILES, maxBodyBytes: MAX_REQUEST_BODY_BYTES, + validate: validateContent, writeAudit, backupAndWrite: backupAndWriteAtomically, + backupDir: BACKUP_DIR, user: CMS_USER, clientAddress, cmsDirname: __dirname, + })) return; // POST /publish — Git Commit, Pull Rebase & Push if (req.method === 'POST' && u.pathname === '/publish') { diff --git a/proto/src/__tests__/cms-editor-shortcuts.test.ts b/proto/src/__tests__/cms-editor-shortcuts.test.ts index e036e94..fb9ba26 100644 --- a/proto/src/__tests__/cms-editor-shortcuts.test.ts +++ b/proto/src/__tests__/cms-editor-shortcuts.test.ts @@ -13,6 +13,7 @@ import fs from 'fs' import path from 'path' const clientJs = fs.readFileSync(path.join(__dirname, '../../../scripts/cms-editor-client.js'), 'utf8') + + '\n' + fs.readFileSync(path.join(__dirname, '../../../scripts/cms-editor-shortcuts.js'), 'utf8') const makeData = () => ({ hero: { title: 'T', subtitle: 'S', description: 'D' }, diff --git a/scripts/cms-editor-client.js b/scripts/cms-editor-client.js index d7f651e..a2e6c27 100644 --- a/scripts/cms-editor-client.js +++ b/scripts/cms-editor-client.js @@ -343,58 +343,3 @@ render(DATA, document.getElementById('editor')); const toast = document.querySelector('.toast'); if (toast) setTimeout(() => toast.remove(), 3500); -// ── Keyboard shortcuts ─────────────────────────────────────────────────────── -// Ctrl/Cmd+S save · Ctrl/Cmd+P publish · Ctrl/Cmd+Shift+V versions · ? help -// Plain typing in inputs never triggers actions — the handler requires the -// modifier key (or, for '?', a non-editing target). - -function showShortcutsOverlay() { - const existing = document.getElementById('shortcuts-overlay'); - if (existing) { existing.remove(); return; } - const overlay = document.createElement('div'); - overlay.id = 'shortcuts-overlay'; - overlay.style.cssText = 'position:fixed;inset:0;background:rgba(15,17,23,.75);z-index:300;display:flex;align-items:center;justify-content:center;padding:24px;'; - overlay.innerHTML = ` -
-

⌨️ Gyorsbillentyűk

-
Ctrl/Cmd + S — Mentés
-
Ctrl/Cmd + P — Publikálás
-
Ctrl/Cmd + Shift + V — Verziók
-
? — ez a súgó (Esc: bezárás)
-
`; - overlay.addEventListener('click', () => overlay.remove()); - document.body.appendChild(overlay); -} - -document.addEventListener('keydown', e => { - // Esc closes the shortcut overlay if open - if (e.key === 'Escape') { - const overlay = document.getElementById('shortcuts-overlay'); - if (overlay) { overlay.remove(); e.preventDefault(); } - return; - } - const mod = e.ctrlKey || e.metaKey; - if (mod && !e.shiftKey && !e.altKey && (e.key === 's' || e.key === 'S')) { - e.preventDefault(); - save(); - return; - } - if (mod && !e.shiftKey && !e.altKey && (e.key === 'p' || e.key === 'P')) { - e.preventDefault(); - publish(); - return; - } - if (mod && e.shiftKey && (e.key === 'v' || e.key === 'V')) { - e.preventDefault(); - window.open('/versions?file=' + encodeURIComponent(FILE), '_blank'); - return; - } - if (!mod && !e.ctrlKey && !e.metaKey && !e.altKey && e.key === '?') { - const target = e.target; - const isEditing = target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable); - if (!isEditing) { - e.preventDefault(); - showShortcutsOverlay(); - } - } -}); diff --git a/scripts/cms-editor-shortcuts.js b/scripts/cms-editor-shortcuts.js new file mode 100644 index 0000000..a7cfc91 --- /dev/null +++ b/scripts/cms-editor-shortcuts.js @@ -0,0 +1,56 @@ +// Keyboard shortcuts for the Content Editor editor page. Inlined after the +// main client script; all referenced functions are global at that point. +// Ctrl/Cmd+S save · Ctrl/Cmd+P publish · Ctrl/Cmd+Shift+V versions · ? help +// Plain typing in inputs never triggers actions — the handler requires the +// modifier key (or, for '?', a non-editing target). + +function showShortcutsOverlay() { + const existing = document.getElementById('shortcuts-overlay'); + if (existing) { existing.remove(); return; } + const overlay = document.createElement('div'); + overlay.id = 'shortcuts-overlay'; + overlay.style.cssText = 'position:fixed;inset:0;background:rgba(15,17,23,.75);z-index:300;display:flex;align-items:center;justify-content:center;padding:24px;'; + overlay.innerHTML = ` +
+

⌨️ Gyorsbillentyűk

+
Ctrl/Cmd + S — Mentés
+
Ctrl/Cmd + P — Publikálás
+
Ctrl/Cmd + Shift + V — Verziók
+
? — ez a súgó (Esc: bezárás)
+
`; + overlay.addEventListener('click', () => overlay.remove()); + document.body.appendChild(overlay); +} + +document.addEventListener('keydown', e => { + // Esc closes the shortcut overlay if open + if (e.key === 'Escape') { + const overlay = document.getElementById('shortcuts-overlay'); + if (overlay) { overlay.remove(); e.preventDefault(); } + return; + } + const mod = e.ctrlKey || e.metaKey; + if (mod && !e.shiftKey && !e.altKey && (e.key === 's' || e.key === 'S')) { + e.preventDefault(); + save(); + return; + } + if (mod && !e.shiftKey && !e.altKey && (e.key === 'p' || e.key === 'P')) { + e.preventDefault(); + publish(); + return; + } + if (mod && e.shiftKey && (e.key === 'v' || e.key === 'V')) { + e.preventDefault(); + window.open('/versions?file=' + encodeURIComponent(FILE), '_blank'); + return; + } + if (!mod && !e.ctrlKey && !e.metaKey && !e.altKey && e.key === '?') { + const target = e.target; + const isEditing = target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable); + if (!isEditing) { + e.preventDefault(); + showShortcutsOverlay(); + } + } +}); diff --git a/scripts/cms-logo-client.js b/scripts/cms-logo-client.js new file mode 100644 index 0000000..29fcf76 --- /dev/null +++ b/scripts/cms-logo-client.js @@ -0,0 +1,280 @@ +function setMsg(target, text, ok) { + const el = document.getElementById('msg-' + target); + el.textContent = text; + el.className = 'msg ' + (ok ? 'ok' : 'err'); +} + +async function upload(target) { + const file = document.getElementById('file-' + target).files[0]; + const btn = document.getElementById('btn-' + target); + const origText = btn.textContent; + const msg = t => setMsg(target, t, false); + if (!file) { msg('Először válassz egy új PNG fájlt a mentéshez.'); return; } + if (file.type !== 'image/png') { msg('Csak PNG fájl tölthető fel.'); return; } + if (file.size > 1024 * 1024) { msg('A fájl nagyobb, mint 1 MB.'); return; } + btn.disabled = true; + btn.textContent = '⏳ Mentés folyamatban...'; + try { + const bytes = new Uint8Array(await file.arrayBuffer()); + await sendLogoBinary(target, bytes); + } catch (e) { msg('❌ Hálózati hiba mentés közben'); } + btn.disabled = false; + btn.textContent = origText; +} + +async function sendLogoBinary(target, bytes) { + const res = await fetch('/logo?target=' + target, { + method: 'POST', + headers: { 'Content-Type': 'image/png', 'X-CSRF-Token': CSRF_TOKEN }, + body: bytes + }); + if (res.status === 401) { location.href = '/login'; return; } + const json = await res.json(); + if (json.ok) { + setMsg(target, '✅ Logó sikeresen elmentve! (A weboldalon a Publikálás után jelenik meg.)', true); + const variantParam = target === 'header' ? 'variant=header&' : ''; + document.getElementById('prev-' + target).src = '/logo.png?' + variantParam + 't=' + Date.now(); + const meta = document.getElementById('meta-' + target); + if (meta) meta.textContent = 'Módosítva (' + (bytes.length / 1024).toFixed(1) + ' KB) — elmentve'; + } else setMsg(target, '❌ ' + json.error, false); +} + +document.querySelectorAll('input[type=file]').forEach(inp => { + inp.addEventListener('change', () => { + const target = inp.id.replace('file-', ''); + const f = inp.files[0]; + const meta = document.getElementById('meta-' + target); + const prev = document.getElementById('prev-' + target); + if (!f) return; + if (f.type !== 'image/png') { + setMsg(target, 'Csak PNG formátumú kép választható ki.', false); + if (meta) meta.textContent = ''; + return; + } + if (f.size > 1024 * 1024) { + setMsg(target, 'A fájl nagyobb 1 MB-nál.', false); + if (meta) meta.textContent = ''; + return; + } + setMsg(target, 'Új fájl kiválasztva. Kattints a Mentés vagy a ✏️ Szerkesztés gombra.', true); + if (meta) meta.textContent = f.name + ' — ' + (f.size / 1024).toFixed(1) + ' KB (még nincs mentve)'; + prev.src = URL.createObjectURL(f); + }); +}); + +/* ── Interactive Canvas Editor Logic ────────────────────────────── */ +let currentEditTarget = 'header'; +let editImg = new Image(); +let editState = { + zoom: 1, panX: 0, panY: 0, rotation: 0, + flipH: 1, flipV: 1, padding: 0, aspect: 0, + brightness: 100, contrast: 100, invert: false +}; +const canvas = document.getElementById('edit-canvas'); +const ctx = canvas.getContext('2d'); +const wrap = document.getElementById('canvas-wrap'); +let isDragging = false, startX = 0, startY = 0; + +function openEditor(target) { + currentEditTarget = target; + document.getElementById('modal-title').textContent = '🎨 Logó szerkesztése — ' + (target === 'header' ? 'Weboldal fejléc' : 'CMS ikon'); + editState.aspect = (target === 'icon' ? 1 : 0); + updateAspectBtns(); + resetFilters(); + resetPan(); + + const fileInput = document.getElementById('file-' + target); + if (fileInput.files && fileInput.files[0]) { + const reader = new FileReader(); + reader.onload = e => { loadImg(e.target.result); }; + reader.readAsDataURL(fileInput.files[0]); + } else { + const previewSrc = document.getElementById('prev-' + target).src; + loadImg(previewSrc); + } +} + +function loadImg(src) { + editImg = new Image(); + editImg.crossOrigin = 'anonymous'; + editImg.onload = () => { + document.getElementById('editor-modal').classList.add('open'); + fitToCrop(); + render(); + }; + editImg.src = src; +} + +function closeEditor() { + document.getElementById('editor-modal').classList.remove('open'); +} + +function setAspect(ratio) { + editState.aspect = ratio; + updateAspectBtns(); + render(); +} + +function updateAspectBtns() { + document.querySelectorAll('#aspect-btns button').forEach(b => { + const a = parseFloat(b.dataset.aspect); + b.classList.toggle('active', (editState.aspect === 0 && a === 0) || (Math.abs(editState.aspect - a) < 0.01)); + }); +} + +function setZoom(val) { + editState.zoom = parseFloat(val); + document.getElementById('zoom-val').textContent = Math.round(editState.zoom * 100) + '%'; + render(); +} + +function setPadding(val) { + editState.padding = parseInt(val, 10); + document.getElementById('pad-val').textContent = editState.padding + 'px'; + render(); +} + +function setFilter(name, val) { + editState[name] = parseInt(val, 10); + document.getElementById(name.slice(0, 6) + '-val').textContent = val + '%'; + render(); +} + +function toggleInvert() { + editState.invert = !editState.invert; + document.getElementById('btn-invert').classList.toggle('active', editState.invert); + render(); +} + +function resetFilters() { + editState.brightness = 100; editState.contrast = 100; editState.invert = false; editState.padding = 0; + document.getElementById('bright-range').value = 100; document.getElementById('bright-val').textContent = '100%'; + document.getElementById('contrast-range').value = 100; document.getElementById('contrast-val').textContent = '100%'; + document.getElementById('pad-range').value = 0; document.getElementById('pad-val').textContent = '0px'; + document.getElementById('btn-invert').classList.remove('active'); + render(); +} + +function rotate(deg) { + editState.rotation = (editState.rotation + deg) % 360; + render(); +} + +function toggleFlip(dir) { + if (dir === 'h') editState.flipH *= -1; + if (dir === 'v') editState.flipV *= -1; + render(); +} + +function resetPan() { + editState.panX = 0; editState.panY = 0; + render(); +} + +function getCropRect() { + const cw = canvas.width, ch = canvas.height; + let rw = cw * 0.85, rh = ch * 0.85; + if (editState.aspect > 0) { + if (rw / rh > editState.aspect) rw = rh * editState.aspect; + else rh = rw / editState.aspect; + } + return { x: (cw - rw) / 2, y: (ch - rh) / 2, w: rw, h: rh }; +} + +function fitToCrop() { + if (!editImg.width || !editImg.height) return; + const crop = getCropRect(); + const isRotated = Math.abs(editState.rotation) === 90 || Math.abs(editState.rotation) === 270; + const iw = isRotated ? editImg.height : editImg.width; + const ih = isRotated ? editImg.width : editImg.height; + const scale = Math.min(crop.w / iw, crop.h / ih); + editState.zoom = Math.max(0.3, Math.min(3, scale)); + document.getElementById('zoom-range').value = editState.zoom; + document.getElementById('zoom-val').textContent = Math.round(editState.zoom * 100) + '%'; + editState.panX = 0; editState.panY = 0; + render(); +} + +function render() { + if (!editImg.width) return; + ctx.clearRect(0, 0, canvas.width, canvas.height); + const crop = getCropRect(); + + // Draw image + ctx.save(); + ctx.filter = 'brightness(' + editState.brightness + '%) contrast(' + editState.contrast + '%)' + (editState.invert ? ' invert(100%)' : ''); + ctx.translate(canvas.width / 2 + editState.panX, canvas.height / 2 + editState.panY); + ctx.rotate((editState.rotation * Math.PI) / 180); + ctx.scale(editState.zoom * editState.flipH, editState.zoom * editState.flipV); + + const pad = editState.padding / (editState.zoom || 1); + const dw = Math.max(10, editImg.width - pad * 2); + const dh = Math.max(10, editImg.height - pad * 2); + ctx.drawImage(editImg, -dw / 2, -dh / 2, dw, dh); + ctx.restore(); + + // Dark overlay outside crop rect + ctx.save(); + ctx.fillStyle = 'rgba(15, 17, 23, 0.75)'; + ctx.fillRect(0, 0, canvas.width, crop.y); + ctx.fillRect(0, crop.y + crop.h, canvas.width, canvas.height - (crop.y + crop.h)); + ctx.fillRect(0, crop.y, crop.x, crop.h); + ctx.fillRect(crop.x + crop.w, crop.y, canvas.width - (crop.x + crop.w), crop.h); + + // Crop border + ctx.strokeStyle = '#3b82f6'; + ctx.lineWidth = 2; + ctx.setLineDash([6, 4]); + ctx.strokeRect(crop.x, crop.y, crop.w, crop.h); + ctx.restore(); +} + +// Drag & Pan handlers +wrap.addEventListener('mousedown', e => { isDragging = true; startX = e.clientX - editState.panX; startY = e.clientY - editState.panY; wrap.classList.add('grabbing'); }); +window.addEventListener('mousemove', e => { if (!isDragging) return; editState.panX = e.clientX - startX; editState.panY = e.clientY - startY; render(); }); +window.addEventListener('mouseup', () => { isDragging = false; wrap.classList.remove('grabbing'); }); +wrap.addEventListener('wheel', e => { + e.preventDefault(); + const delta = e.deltaY < 0 ? 0.05 : -0.05; + setZoom(Math.max(0.3, Math.min(3, editState.zoom + delta))); + document.getElementById('zoom-range').value = editState.zoom; +}, { passive: false }); + +async function saveEditedLogo() { + const crop = getCropRect(); + const outCanvas = document.createElement('canvas'); + outCanvas.width = Math.round(crop.w * 2); // 2x for retina sharpness + outCanvas.height = Math.round(crop.h * 2); + const octx = outCanvas.getContext('2d'); + + octx.save(); + octx.scale(2, 2); + octx.translate(-crop.x, -crop.y); + octx.filter = 'brightness(' + editState.brightness + '%) contrast(' + editState.contrast + '%)' + (editState.invert ? ' invert(100%)' : ''); + octx.translate(canvas.width / 2 + editState.panX, canvas.height / 2 + editState.panY); + octx.rotate((editState.rotation * Math.PI) / 180); + octx.scale(editState.zoom * editState.flipH, editState.zoom * editState.flipV); + + const pad = editState.padding / (editState.zoom || 1); + const dw = Math.max(10, editImg.width - pad * 2); + const dh = Math.max(10, editImg.height - pad * 2); + octx.drawImage(editImg, -dw / 2, -dh / 2, dw, dh); + octx.restore(); + + const saveBtn = document.getElementById('modal-save-btn'); + saveBtn.disabled = true; + saveBtn.textContent = '⏳ Mentés folyamatban...'; + + outCanvas.toBlob(async blob => { + if (!blob) { alert('Hiba a kép exportálásakor'); saveBtn.disabled = false; return; } + try { + const bytes = new Uint8Array(await blob.arrayBuffer()); + await sendLogoBinary(currentEditTarget, bytes); + closeEditor(); + } catch (e) { + alert('Hiba történt a mentés során.'); + } + saveBtn.disabled = false; + saveBtn.textContent = '💾 Szerkesztett logó mentése'; + }, 'image/png'); +} diff --git a/scripts/cms-logo-page.js b/scripts/cms-logo-page.js index bba78f8..f6a1b94 100644 --- a/scripts/cms-logo-page.js +++ b/scripts/cms-logo-page.js @@ -1,6 +1,11 @@ // Branding page for the Content Editor: upload/replace and edit logos with // interactive Canvas editor (crop, zoom/pan, rotate/flip, padding, filters). +// The browser-side editor script is inlined from cms-logo-client.js. +const fs = require('fs'); +const path = require('path'); const { LOGO_TARGETS } = require('./cms-logo'); +const logoClientJs = fs.readFileSync(path.join(__dirname, 'cms-logo-client.js'), 'utf8'); + const LOGO_PAGE = (csrfToken) => ` @@ -174,286 +179,7 @@ const LOGO_PAGE = (csrfToken) => ` `; diff --git a/scripts/cms-save.js b/scripts/cms-save.js new file mode 100644 index 0000000..335f488 --- /dev/null +++ b/scripts/cms-save.js @@ -0,0 +1,65 @@ +// POST /save handler for the Content Editor — extracted to keep content-editor.js +// under the 400-line hard limit. Returns true when the request was handled. +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); + +function handleSaveRoute({ + req, res, u, activeFile, files, maxBodyBytes, validate, + writeAudit, backupAndWrite, backupDir, user, clientAddress, cmsDirname, +}) { + if (req.method !== 'POST' || u.pathname !== '/save') return false; + + let body = ''; + let bodyTooLarge = false; + let bodySize = 0; + req.on('data', c => { + bodySize += c.length; + if (bodySize > maxBodyBytes) { bodyTooLarge = true; return; } + body += c; + }); + req.on('end', () => { + try { + if (bodyTooLarge) { + writeAudit('content_saved', { clientAddress, user, file: activeFile, result: 'request_too_large' }); + res.writeHead(413, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: false, error: `A kérés túl nagy (maximum ${maxBodyBytes} byte)` })); + 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, 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 = validate(activeFile, data); + if (!validation.ok) { + writeAudit('content_saved', { clientAddress, user, file: activeFile, result: 'validation_failed' }); + res.writeHead(422, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: false, error: validation.errors.join('; '), errors: validation.errors })); + return; + } + const backupFile = backupAndWrite(files[activeFile], data, backupDir); + // 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 lock. + const newHash = crypto.createHash('sha256').update(JSON.stringify(data, null, 2).trim()).digest('hex'); + writeAudit('content_saved', { clientAddress, user, file: activeFile, result: 'ok' }); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, backup: path.relative(cmsDirname, backupFile), contentHash: newHash })); + } catch (e) { + writeAudit('content_saved', { clientAddress, user, file: activeFile, result: 'error' }); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: false, error: e.message })); + } + }); + return true; +} + +module.exports = { handleSaveRoute };