#!/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) => `
mozdIT — Content Editor
${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.
`;
// ── 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) {
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,
securityConfigIsValid,
csrfToken: CSRF_TOKEN,
};