#!/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 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 = path.join(__dirname, '.content-editor-audit.jsonl'); 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 = (activeFile, jsonData, message, csrfToken) => ` ${CMS_DEPLOY_ENV === 'staging' ? 'STAGING — ' : ''}mozdIT Content Editor ${CMS_DEPLOY_ENV === 'staging' ? '
⚠ STAGING / TESZTKÖRNYEZET — itt végzett publikálás csak a staging oldalt frissíti
' : ''} ${message ? `
${message.text}
` : ''}

mozdIT Content Editor

— JSON fájlok szerkesztése vizuálisan

📝 Szerkeszd a mezőket. Tömbökből elemet törölhetsz (❌) vagy hozzáadhatsz (➕). Mentés gomb menti a fájlt.

🔗 Előnézet →
`; // ── 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(':'); if (!CMS_USER || !CMS_PASS || login.length !== CMS_USER.length || password.length !== CMS_PASS.length) return false; return crypto.timingSafeEqual(Buffer.from(login), Buffer.from(CMS_USER)) && crypto.timingSafeEqual(Buffer.from(password), Buffer.from(CMS_PASS)); } 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; } if (!hasValidCredentials(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; } res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="mozdIT CMS"' }); res.end('Access denied'); return; } const u = new URL(req.url, `http://localhost:${PORT}`); 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 /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)); }); 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, };