#!/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 } = require('child_process'); const crypto = require('crypto'); const { validateContent } = require('./proto/src/content/schema'); const { renderMarkdown } = require('./scripts/markdown-render'); const PORT = Number(process.env.CONTENT_EDITOR_PORT) || 4001; const 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; 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 } = require('./scripts/cms-pages'); 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'); // ── Server ─────────────────────────────────────────────────────────────────── const CMS_USER = process.env.CMS_USER; const CMS_PASS = process.env.CMS_PASS; const CMS_DEPLOY_ENV = process.env.CMS_DEPLOY_ENV; const CSRF_TOKEN = process.env.CMS_CSRF_TOKEN || crypto.randomBytes(32).toString('hex'); const rateLimits = new Map(); function securityConfigIsValid() { return Boolean(CMS_USER && CMS_PASS && ['staging', 'production'].includes(CMS_DEPLOY_ENV)); } function getClientAddress(req) { // The editor only listens on 127.0.0.1; the staging Nginx proxy supplies this header. // WHY: take the LAST entry. Nginx ($proxy_add_x_forwarded_for) appends the real client // IP to the list, so the first entry may be a spoofed value sent by the client — using // it would let attackers bypass the rate limiter with a fresh "IP" per request. const forwarded = req.headers['x-forwarded-for']; if (typeof forwarded === 'string' && forwarded.trim()) { const parts = forwarded.split(',').map(part => part.trim()).filter(Boolean); if (parts.length > 0) return parts[parts.length - 1]; } return req.socket.remoteAddress || 'unknown'; } function exceedsRateLimit(key, limit) { const now = Date.now(); const attempts = (rateLimits.get(key) || []).filter(time => now - time < RATE_LIMIT_WINDOW_MS); attempts.push(now); rateLimits.set(key, attempts); return attempts.length > limit; } function hasValidCredentials(req) { const b64auth = (req.headers.authorization || '').split(' ')[1] || ''; const [login = '', password = ''] = Buffer.from(b64auth, 'base64').toString().split(':'); return validateLogin(login, password, CMS_USER, CMS_PASS); } function isAuthenticated(req) { return hasValidCredentials(req) || hasValidSession(req); } function hasValidCsrfToken(req) { const token = req.headers['x-csrf-token']; return typeof token === 'string' && token.length === CSRF_TOKEN.length && crypto.timingSafeEqual(Buffer.from(token), Buffer.from(CSRF_TOKEN)); } function writeAudit(event, details = {}) { const record = { timestamp: new Date().toISOString(), event, ...details }; fs.appendFileSync(AUDIT_LOG_FILE, JSON.stringify(record) + '\n', { encoding: 'utf8', mode: 0o600 }); } function backupAndWriteAtomically(targetFile, data, backupDir = BACKUP_DIR) { const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const backupName = `${path.basename(targetFile, '.json')}.${timestamp}.json`; const backupFile = path.join(backupDir, backupName); const tempFile = `${targetFile}.${process.pid}.${Date.now()}.tmp`; fs.mkdirSync(backupDir, { recursive: true, mode: 0o700 }); fs.copyFileSync(targetFile, backupFile); fs.writeFileSync(tempFile, JSON.stringify(data, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 }); fs.renameSync(tempFile, targetFile); return backupFile; } const server = http.createServer(async (req, res) => { 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') { res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="mozdIT CMS"' }); res.end('Logged out'); return; } // Public: logo asset for the login page. if (req.method === 'GET' && u.pathname === '/logo.png') { try { const logo = fs.readFileSync(path.join(__dirname, 'proto', 'public', 'mozdit_logo.png')); 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: 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' }); 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') { 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 => { if (body.length + c.length > 1024) { bodyTooLarge = true; return; } body += c; }); req.on('end', () => { 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)) { 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; } 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 (!isAuthenticated(req)) { const limited = exceedsRateLimit(`auth:${clientAddress}`, AUTH_MAX_ATTEMPTS); writeAudit('authentication_failed', { clientAddress, limited }); if (limited) { res.writeHead(429, { 'Retry-After': String(RATE_LIMIT_WINDOW_MS / 1000) }); res.end('Too many authentication attempts'); return; } // Browser navigations land on the styled login page; API/curl keeps the 401 challenge. const acceptsHtml = String(req.headers.accept || '').includes('text/html'); if (acceptsHtml && req.method === 'GET') { res.writeHead(302, { Location: '/login' }); res.end(); return; } res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="mozdIT CMS"' }); 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 — 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); 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); 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) })); } 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 /publish — Git Commit & Push if (req.method === 'POST' && u.pathname === '/publish') { if (exceedsRateLimit(`publish:${clientAddress}`, PUBLISH_MAX_ATTEMPTS)) { 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; } exec('git add . && git commit -m "content: frissítve a CMS-ből" && git push', { cwd: CONTENT_DIR }, (error, stdout, stderr) => { res.writeHead(200, { 'Content-Type': 'application/json' }); if (error) { // If there's nothing to commit, it's fine if (stdout.includes('nothing to commit') || stdout.includes('working tree clean')) { writeAudit('publish_finished', { clientAddress, user: CMS_USER, result: 'no_changes' }); res.end(JSON.stringify({ ok: true, output: 'No changes to commit' })); } else { writeAudit('publish_finished', { clientAddress, user: CMS_USER, result: 'error' }); res.end(JSON.stringify({ ok: false, error: stderr || stdout || error.message })); } } else { // Deploy only the explicitly configured environment; never default to production. exec(`cd ../../../ && ./deploy.sh ${CMS_DEPLOY_ENV} > deploy.log 2>&1 &`); writeAudit('publish_finished', { clientAddress, user: CMS_USER, result: 'ok' }); res.end(JSON.stringify({ ok: true, output: stdout })); } }); 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 }; } res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(HTML(activeFile, jsonData, message, CSRF_TOKEN, FILE_LABELS, clientJs)); }); 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', () => { console.log(`\n✅ mozdIT Content Editor fut: http://localhost:${PORT}\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, };