Files
websitedev/content-editor.js
T
Do Siki 42533ec0aa
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
fix(cms): deploy exec was missing cwd — landed on / and never ran
Root cause of publishes not rebuilding the site: the deploy exec child
started in the process working directory (repo root), so 'cd ../../../'
resolved to the filesystem root, where creating deploy.log failed with
Permission denied. The git publish command already used cwd: CONTENT_DIR;
the deploy child now does too (caught via live cwd/touch diagnostics in
the service context).

Closes MITHOME-74
2026-08-19 17:14:02 +02:00

397 lines
19 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, 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 { 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;
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');
// ── 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 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) => {
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', () => {
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;
}
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;
}
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 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 = '<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);
// Optimistic locking: the editor echoes the fingerprint of the content it
// loaded. If the file changed since (deploy, another tab, git), a blind
// save would silently overwrite those changes — reject with 409 instead.
const clientHash = req.headers['x-content-hash'];
const currentOnDisk = fs.readFileSync(FILES[activeFile], 'utf8').trim();
const currentHash = crypto.createHash('sha256').update(currentOnDisk).digest('hex');
if (typeof clientHash !== 'string' || clientHash !== currentHash) {
writeAudit('content_saved', { clientAddress, user: CMS_USER, file: activeFile, result: 'conflict' });
res.writeHead(409, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: 'A tartalom megváltozott, mióta ezt a lapot megnyitottad (pl. deploy vagy másik fül mentett). Frissítsd az oldalt, és végezd el újra a módosításokat.' }));
return;
}
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, BACKUP_DIR);
// Return the hash of the written content so the editor tab can refresh
// its fingerprint — otherwise the user's OWN next save would trip the
// optimistic-lock 409 (MITHOME-68).
const newHash = crypto.createHash('sha256').update(JSON.stringify(data, null, 2).trim()).digest('hex');
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), contentHash: newHash }));
} 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;
}
// 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) => {
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,
};