#!/usr/bin/env node /** * mozdIT Content Editor Server v2 * Szerkesztő felület a JSON tartalom fájlokhoz * Támogatja: szöveg szerkesztés, tömbelem hozzáadás/törlés * Futtatás: node content-editor.js * Megnyitás: http://localhost:4001 */ const http = require('http'); const fs = require('fs'); const path = require('path'); const { exec, execSync } = require('child_process'); const crypto = require('crypto'); 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; // WHY: overridable so the publish integration test can run against a throwaway // git clone instead of the real repository. const CONTENT_DIR = process.env.CONTENT_EDITOR_CONTENT_DIR || path.join(__dirname, 'proto', 'src', 'content'); const BACKUP_DIR = path.join(__dirname, '.content-backups'); const MAX_REQUEST_BODY_BYTES = 256 * 1024; const AUDIT_LOG_FILE = process.env.CONTENT_EDITOR_AUDIT_FILE || path.join(__dirname, '.content-editor-audit.jsonl'); const GUIDE_FILE = process.env.CONTENT_EDITOR_GUIDE_FILE || path.join(__dirname, 'docs', 'felhasznaloi-utmutato.md'); const RATE_LIMIT_WINDOW_MS = 15 * 60 * 1000; const AUTH_MAX_ATTEMPTS = 5; const PUBLISH_MAX_ATTEMPTS = 3; let isPublishing = false; const FILES = { common: path.join(CONTENT_DIR, 'common.json'), home: path.join(CONTENT_DIR, 'pages', 'home.json'), about: path.join(CONTENT_DIR, 'pages', 'about.json'), services: path.join(CONTENT_DIR, 'pages', 'services.json'), contact: path.join(CONTENT_DIR, 'pages', 'contact.json'), adatvedelem: path.join(CONTENT_DIR, 'pages', 'adatvedelem.json'), hasznalatiFeltetelek: path.join(CONTENT_DIR, 'pages', 'hasznalati-feltetelek.json'), }; const FILE_LABELS = { common: '⚙️ Közös szövegek', home: '🏠 Kezdőlap', about: '👥 Rólunk', services: '🛠️ Szolgáltatások', contact: '📬 Kapcsolat', adatvedelem: '🔒 Adatvédelem', hasznalatiFeltetelek: '⚖️ ÁSZF', }; const { HTML, GUIDE_PAGE, LOGIN_PAGE, VERSIONS_PAGE } = require('./scripts/cms-pages'); 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') + '\n' + fs.readFileSync(path.join(__dirname, 'scripts', 'cms-editor-shortcuts.js'), 'utf8'); // ── Server ─────────────────────────────────────────────────────────────────── // Security/infra helpers live in scripts/cms-core.js (file-size limits). const core = require('./scripts/cms-core'); const { CMS_USER, CMS_PASS, CMS_DEPLOY_ENV, CSRF_TOKEN, securityConfigIsValid, getClientAddress, hasValidCsrfToken, backupAndWriteAtomically } = core; const exceedsRateLimit = (key, limit) => core.exceedsRateLimit(key, limit, RATE_LIMIT_WINDOW_MS); const isRateLimited = (key, limit) => core.isRateLimited(key, limit, RATE_LIMIT_WINDOW_MS); const recordRateLimitAttempt = key => core.recordRateLimitAttempt(key, RATE_LIMIT_WINDOW_MS); const hasValidCredentials = req => core.hasValidCredentials(req, validateLogin); const isAuthenticated = core.makeIsAuthenticated(hasValidSession, validateLogin); const isBrowserNavigation = core.isBrowserNavigation; const writeAudit = core.makeWriteAudit(AUDIT_LOG_FILE); // Deploy version = git short SHA of the checked-out commit. Read once at startup: // a CMS "deploy" is git pull + service restart, so this identifies the running code. function readDeployVersion() { try { return execSync('git rev-parse --short HEAD', { cwd: __dirname, encoding: 'utf8' }).trim(); } catch { return 'unknown'; } } const DEPLOY_VERSION = readDeployVersion(); const server = http.createServer(async (req, res) => { res.setHeader('X-Frame-Options', 'DENY'); res.setHeader('X-Content-Type-Options', 'nosniff'); const clientAddress = getClientAddress(req); if (!securityConfigIsValid()) { res.writeHead(503, { 'Content-Type': 'text/plain; charset=utf-8' }); res.end('Content Editor is disabled: CMS_USER and CMS_PASS must be configured.'); return; } const u = new URL(req.url, `http://localhost:${PORT}`); // WHY: Basic Auth credentials are cached by the browser until it closes, so there is // no native logout. The client calls /logout with deliberately invalid credentials, // which overwrites the cached pair; the next navigation prompts for login again. // Deliberately exempt from the auth rate limiter so logging out never locks the user out. if (u.pathname === '/logout' && req.method === 'GET') { // Legacy cache-buster endpoint; no WWW-Authenticate — Safari would show its // native auth dialog on any fetch hitting this challenge. res.writeHead(401, { 'Cache-Control': 'no-store' }); res.end('Logged out'); return; } // Public: logo asset for the login page. if (req.method === 'GET' && u.pathname === '/logo.png') { try { // ?variant=header serves the website header logo (branding page preview). const file = u.searchParams.get('variant') === 'header' ? LOGO_TARGETS.header : LOGO_TARGETS.icon; const logo = fs.readFileSync(path.join(__dirname, 'proto', 'public', file)); res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=3600' }); res.end(logo); } catch { res.writeHead(404); res.end('Not found'); } return; } // Public: deploy version (git SHA only — no secrets) for quick "is the fix live?" checks. if (req.method === 'GET' && u.pathname === '/version') { res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); res.end(JSON.stringify({ version: DEPLOY_VERSION, env: CMS_DEPLOY_ENV })); return; } // Public: styled login page (shown after logout and for unauthenticated browser visits). if (req.method === 'GET' && u.pathname === '/login') { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }); res.end(LOGIN_PAGE()); return; } // 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') { let body = ''; let bodyTooLarge = false; req.on('data', c => { if (body.length + c.length > 1024) { bodyTooLarge = true; return; } body += c; }); req.on('end', () => { if (isRateLimited(`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 user = ''; let pass = ''; try { const parsed = JSON.parse(body); user = String(parsed.user || ''); 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; } recordRateLimitAttempt(`auth:${clientAddress}`); 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ó.' })); }); return; } if (isRateLimited(`auth:${clientAddress}`, AUTH_MAX_ATTEMPTS)) { writeAudit('authentication_failed', { clientAddress, limited: true }); res.writeHead(429, { 'Retry-After': String(RATE_LIMIT_WINDOW_MS / 1000) }); res.end('Too many authentication attempts'); return; } if (!isAuthenticated(req)) { recordRateLimitAttempt(`auth:${clientAddress}`); writeAudit('authentication_failed', { clientAddress, limited: false }); // Browser navigations land on the styled login page; API/curl gets a plain 401. // WHY no WWW-Authenticate: Safari pops its native auth dialog on fetch() calls // that receive a Basic challenge — the styled /login page handles browsers. if (isBrowserNavigation(req)) { res.writeHead(302, { Location: '/login', 'Cache-Control': 'no-store' }); res.end(); return; } res.writeHead(401, { 'Cache-Control': 'no-store' }); res.end('Access denied'); return; } const fileKey = u.searchParams.get('file') || 'home'; const activeFile = FILES[fileKey] ? fileKey : 'home'; if (req.method === 'POST' && !hasValidCsrfToken(req)) { writeAudit('csrf_rejected', { clientAddress, path: u.pathname, file: activeFile }); res.writeHead(403, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: false, error: 'Érvénytelen vagy hiányzó CSRF token' })); return; } // POST /logout — invalidate the browser session (Basic Auth stays valid by design). if (req.method === 'POST' && u.pathname === '/logout') { deleteSession(req); writeAudit('logout', { clientAddress, user: CMS_USER }); res.writeHead(200, { 'Content-Type': 'application/json', 'Set-Cookie': clearSessionCookie() }); res.end(JSON.stringify({ ok: true })); return; } // GET /guide — user guide rendered from the maintained markdown in the repo. if (req.method === 'GET' && u.pathname === '/guide') { let contentHtml; try { contentHtml = renderMarkdown(fs.readFileSync(GUIDE_FILE, 'utf8')); } catch (error) { contentHtml = '

Az útmutató jelenleg nem elérhető. Kérlek, szólj a fejlesztőnek.

'; } res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(GUIDE_PAGE(contentHtml)); 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') { if (isPublishing) { res.writeHead(423, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: false, error: 'Már folyamatban van egy publikálás. Kérlek, várj.' })); return; } isPublishing = true; if (exceedsRateLimit(`publish:${clientAddress}`, PUBLISH_MAX_ATTEMPTS)) { isPublishing = false; writeAudit('publish_rate_limited', { clientAddress, user: CMS_USER }); 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 publikálási kísérlet' })); return; } // Command shape and result classification live in scripts/cms-publish.js // (WHY comments there): commit only when staged changes exist, rebase with // abort-on-failure, deterministic no-changes marker instead of output matching. exec(buildPublishCommand('content: frissítve a CMS-ből'), { cwd: CONTENT_DIR }, (error, stdout, stderr) => { isPublishing = false; res.writeHead(200, { 'Content-Type': 'application/json' }); const outcome = interpretPublishResult(error, stdout, stderr); writeAudit('publish_finished', { clientAddress, user: CMS_USER, result: outcome.result }); if (!outcome.ok) { res.end(JSON.stringify({ ok: false, error: outcome.error })); return; } // Deploy only when content actually changed — a no-op publish must not // trigger a rebuild. Deploy only the explicitly configured environment; // never default to production. Overridable for tests. if (outcome.hadChanges) { // WHY direct child instead of a detached `cmd &`: under the systemd unit's // hardening (NoNewPrivileges/PrivateTmp) the backgrounded grandchild died // silently (observed twice: stale site after a publish). A direct child is // not detached, runs to completion, and the callback turns the audit entry // into a real "deploy finished/failed" signal. The HTTP response is already // sent; deploy output goes to deploy.log so the pipes stay quiet. const deployCmd = process.env.CONTENT_EDITOR_DEPLOY_CMD || `cd ../../../ && ./deploy.sh ${CMS_DEPLOY_ENV} > deploy.log 2>&1`; writeAudit('deploy_spawned', { clientAddress, user: CMS_USER, env: CMS_DEPLOY_ENV }); // WHY cwd: without it the child starts in the process working directory // (repo root), where `cd ../../../` lands on "/" — no write access, so // deploy.log creation failed with Permission denied and the deploy never // ran. CONTENT_DIR is the same base the git publish command uses. exec(deployCmd, { cwd: CONTENT_DIR, maxBuffer: 8 * 1024 * 1024 }, deployError => { writeAudit('deploy_exec_exit', { clientAddress, user: CMS_USER, result: deployError ? 'error' : 'ok', error: deployError ? String(deployError.message).slice(0, 300) : undefined, }); }); } res.end(JSON.stringify({ ok: true, output: outcome.output })); }); return; } // GET /versions + POST /restore — handled in scripts/cms-versions.js. if (handleVersionRoutes({ req, res, u, activeFile, backupDir: BACKUP_DIR, currentFile: FILES[activeFile], validate: validateContent, writeAudit, clientAddress, user: CMS_USER, versionsPage: (fileKey, diff) => VERSIONS_PAGE(fileKey, FILE_LABELS[fileKey] || fileKey, listVersions(BACKUP_DIR, fileKey), diff, CSRF_TOKEN), })) return; // GET /branding + POST /logo — handled in scripts/cms-logo.js. if (handleLogoRoutes({ req, res, u, publicDir: path.join(__dirname, 'proto', 'public'), backupDir: BACKUP_DIR, writeAudit, clientAddress, user: CMS_USER, logoPage: () => LOGO_PAGE(CSRF_TOKEN), })) return; // GET / — editor UI let message = null; let jsonData = '{}'; try { jsonData = fs.readFileSync(FILES[activeFile], 'utf8').trim(); } 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', 'Cache-Control': 'no-store' }); res.end(HTML(activeFile, jsonData, message, CSRF_TOKEN, FILE_LABELS, clientJs, contentHash, DEPLOY_VERSION)); }); if (require.main === module) { if (!securityConfigIsValid()) { throw new Error('CMS_USER, CMS_PASS és érvényes CMS_DEPLOY_ENV nélkül a Content Editor nem indítható el.'); } server.listen(PORT, '127.0.0.1', () => { writeAudit('startup', { version: DEPLOY_VERSION, env: CMS_DEPLOY_ENV }); console.log(`\n✅ mozdIT Content Editor fut: http://localhost:${PORT} (v${DEPLOY_VERSION})\n`); console.log(' Szerkeszthető fájlok:'); Object.entries(FILE_LABELS).forEach(([k, l]) => { const rel = k === 'common' ? 'common.json' : `pages/${k}.json`; console.log(` • ${l}: proto/src/content/${rel}`); }); console.log('\n Ctrl+C a leállításhoz\n'); }); } module.exports = { backupAndWriteAtomically, validateContent, hasValidCredentials, hasValidCsrfToken, getClientAddress, securityConfigIsValid, csrfToken: CSRF_TOKEN, };