CI Pipeline with Test Management / 🧪 Run Tests & Generate Reports (push) Waiting to run
CI Pipeline with Test Management / 🐳 Docker Integration Tests (push) Blocked by required conditions
CI Pipeline with Test Management / 🏗️ Build Docker Image (push) Blocked by required conditions
CI Pipeline with Test Management / 📊 Generate Test Summary (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🧪 Run Tests & Generate Reports (push) Waiting to run
Test Reporting & Gherkin Analysis / 📊 Analyze Test Coverage (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🔄 Sync with Linear (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / ⚡ Performance Monitoring (push) Blocked by required conditions
- add .env.staging and .env.production patterns to .gitignore so local env files are ignored - use robust publish command: git add . && (git diff --cached --quiet || git commit ...) && git pull --rebase origin main && git push origin main - expand no-changes detection in publish response handling Closes MITHOME-59
363 lines
15 KiB
JavaScript
363 lines
15 KiB
JavaScript
#!/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 = '<p>Az útmutató jelenleg nem elérhető. Kérlek, szólj a fejlesztőnek.</p>';
|
|
}
|
|
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, Pull Rebase & 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;
|
|
}
|
|
|
|
// WHY: (git diff --cached --quiet || git commit) ensures we only commit when
|
|
// staged changes exist. git pull --rebase origin main integrates remote changes
|
|
// (or unpushed local commits) cleanly before git push origin main.
|
|
const publishCmd = 'git add . && (git diff --cached --quiet || git commit -m "content: frissítve a CMS-ből") && git pull --rebase origin main && git push origin main';
|
|
|
|
exec(publishCmd, { cwd: CONTENT_DIR }, (error, stdout, stderr) => {
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
const combinedOutput = `${stdout}\n${stderr}`;
|
|
const isNoChanges = /nothing to commit|nothing added to commit|working tree clean|everything up-to-date|already up to date/i.test(combinedOutput);
|
|
|
|
if (error && !isNoChanges) {
|
|
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: isNoChanges ? 'no_changes' : 'ok' });
|
|
res.end(JSON.stringify({ ok: true, output: stdout || 'Sikeres publikálás' }));
|
|
}
|
|
});
|
|
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,
|
|
};
|