feat: harden content workflows and staging smoke tests
CI — Test & Build / 🧪 Run Tests & Generate Reports (push) Waiting to run
CI — Test & Build / 🏗️ Build Docker Image (push) Blocked by required conditions
CI — Test & Build / 🐳 Docker integration & API E2E (push) Blocked by required conditions
CI — Test & Build / 🌐 Staging Playwright smoke (push) Waiting to run
CI — Test & Build / 🧪 Run Tests & Generate Reports (push) Waiting to run
CI — Test & Build / 🏗️ Build Docker Image (push) Blocked by required conditions
CI — Test & Build / 🐳 Docker integration & API E2E (push) Blocked by required conditions
CI — Test & Build / 🌐 Staging Playwright smoke (push) Waiting to run
This commit is contained in:
+220
-51
@@ -12,9 +12,17 @@ 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'),
|
||||
@@ -36,7 +44,7 @@ const FILE_LABELS = {
|
||||
hasznalatiFeltetelek: '⚖️ ÁSZF',
|
||||
};
|
||||
|
||||
const HTML = (activeFile, jsonData, message) => `<!DOCTYPE html>
|
||||
const HTML = (activeFile, jsonData, message, csrfToken) => `<!DOCTYPE html>
|
||||
<html lang="hu">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
@@ -145,6 +153,7 @@ ${message ? `<div class="toast ${message.type === 'ok' ? 'ok' : 'err'}">${messag
|
||||
<script>
|
||||
const DATA = JSON.parse(document.getElementById('page-data').textContent);
|
||||
const FILE = "${activeFile}";
|
||||
const CSRF_TOKEN = "${csrfToken}";
|
||||
|
||||
// ── Render ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -170,11 +179,20 @@ function renderPrimitive(path, val, container) {
|
||||
const isLong = String(val).length > 80 || String(val).includes('<');
|
||||
const div = document.createElement('div');
|
||||
div.className = 'field';
|
||||
const type = val === null ? 'null' : typeof val;
|
||||
let control;
|
||||
if (type === 'boolean') {
|
||||
control = \`<input type="checkbox" data-path="\${path}" data-type="boolean" \${val ? 'checked' : ''}>\`;
|
||||
} else if (type === 'number') {
|
||||
control = \`<input type="number" data-path="\${path}" data-type="number" value="\${esc(val)}">\`;
|
||||
} else {
|
||||
control = isLong
|
||||
? \`<textarea data-path="\${path}" data-type="\${type}" rows="\${Math.min(8,Math.max(2,Math.ceil(String(val).length/80)))}">\${esc(val ?? '')}<\/textarea>\`
|
||||
: \`<input type="text" data-path="\${path}" data-type="\${type}" value="\${esc(val ?? '')}">\`;
|
||||
}
|
||||
div.innerHTML = \`
|
||||
<label>\${path}</label>
|
||||
\${isLong
|
||||
? \`<textarea data-path="\${path}" rows="\${Math.min(8,Math.max(2,Math.ceil(String(val).length/80)))}">\${esc(val)}</textarea>\`
|
||||
: \`<input type="text" data-path="\${path}" value="\${esc(val)}">\`}
|
||||
\${control}
|
||||
\`;
|
||||
container.appendChild(div);
|
||||
}
|
||||
@@ -212,7 +230,7 @@ function renderArray(key, arr, container, path) {
|
||||
addBtn.onclick = () => {
|
||||
const idx = items.children.length;
|
||||
if (isObj) {
|
||||
const blank = Object.fromEntries(Object.keys(sample).map(k => [k, '']));
|
||||
const blank = blankLike(sample);
|
||||
items.appendChild(makeObjCard(blank, idx, path));
|
||||
} else {
|
||||
items.appendChild(makeStrItem('', idx, path));
|
||||
@@ -223,13 +241,30 @@ function renderArray(key, arr, container, path) {
|
||||
container.appendChild(section);
|
||||
}
|
||||
|
||||
function blankLike(value) {
|
||||
if (Array.isArray(value)) return [];
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, blankLike(child)]));
|
||||
}
|
||||
if (typeof value === 'boolean') return false;
|
||||
if (typeof value === 'number') return 0;
|
||||
return '';
|
||||
}
|
||||
|
||||
function makeStrItem(val, idx, path) {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'str-item';
|
||||
const ta = document.createElement('textarea');
|
||||
const type = val === null ? 'null' : typeof val;
|
||||
const ta = type === 'boolean' ? document.createElement('input') : document.createElement('textarea');
|
||||
ta.dataset.path = path + '[' + idx + ']';
|
||||
ta.value = val;
|
||||
ta.rows = Math.min(6, Math.max(2, Math.ceil(String(val).length / 80)));
|
||||
ta.dataset.type = type;
|
||||
if (type === 'boolean') {
|
||||
ta.type = 'checkbox';
|
||||
ta.checked = val;
|
||||
} else {
|
||||
ta.value = val ?? '';
|
||||
ta.rows = Math.min(6, Math.max(2, Math.ceil(String(val ?? '').length / 80)));
|
||||
}
|
||||
const del = document.createElement('button');
|
||||
del.className = 'btn-del';
|
||||
del.textContent = '❌';
|
||||
@@ -248,19 +283,7 @@ function makeObjCard(obj, idx, path) {
|
||||
hdr.textContent = path + '[' + idx + ']';
|
||||
card.appendChild(hdr);
|
||||
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
const fieldPath = path + '[' + idx + '].' + k;
|
||||
const isLong = String(v).length > 80 || String(v).includes('<');
|
||||
const fd = document.createElement('div');
|
||||
fd.className = 'inner-field';
|
||||
fd.innerHTML = \`
|
||||
<div class="inner-label">\${k}</div>
|
||||
\${isLong
|
||||
? \`<textarea data-path="\${fieldPath}" rows="\${Math.min(6,Math.max(2,Math.ceil(String(v).length/80)))}">\${esc(v)}</textarea>\`
|
||||
: \`<input type="text" data-path="\${fieldPath}" value="\${esc(v)}">\`}
|
||||
\`;
|
||||
card.appendChild(fd);
|
||||
}
|
||||
renderObject(obj, card, path + '[' + idx + ']');
|
||||
|
||||
const del = document.createElement('button');
|
||||
del.className = 'btn-del-card';
|
||||
@@ -299,11 +322,20 @@ function collect() {
|
||||
clearArrays(result);
|
||||
|
||||
document.querySelectorAll('[data-path]').forEach(el => {
|
||||
setPath(result, el.dataset.path, el.value);
|
||||
setPath(result, el.dataset.path, readValue(el));
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function readValue(el) {
|
||||
switch (el.dataset.type) {
|
||||
case 'boolean': return el.checked;
|
||||
case 'number': return Number(el.value);
|
||||
case 'null': return el.value === '' ? null : el.value;
|
||||
default: return el.value;
|
||||
}
|
||||
}
|
||||
|
||||
function clearArrays(obj) {
|
||||
for (const k of Object.keys(obj)) {
|
||||
if (Array.isArray(obj[k])) obj[k] = [];
|
||||
@@ -312,24 +344,47 @@ function clearArrays(obj) {
|
||||
}
|
||||
|
||||
function setPath(obj, path, value) {
|
||||
// Parse "a.b[0].c" into parts
|
||||
const parts = path.replace(/\[(\d+)\]/g, '.$1').split('.');
|
||||
const parts = parsePath(path);
|
||||
let cur = obj;
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
const p = isNaN(parts[i]) ? parts[i] : +parts[i];
|
||||
const next = isNaN(parts[i+1]) ? {} : (cur[p] || []);
|
||||
if (cur[p] === undefined || cur[p] === null) cur[p] = next;
|
||||
cur = cur[p];
|
||||
const part = parts[i];
|
||||
if (cur[part] === undefined || cur[part] === null) {
|
||||
cur[part] = typeof parts[i + 1] === 'number' ? [] : {};
|
||||
}
|
||||
cur = cur[part];
|
||||
}
|
||||
const last = isNaN(parts[parts.length-1]) ? parts[parts.length-1] : +parts[parts.length-1];
|
||||
cur[last] = value;
|
||||
cur[parts[parts.length - 1]] = value;
|
||||
}
|
||||
|
||||
function parsePath(path) {
|
||||
const parts = [];
|
||||
let token = '';
|
||||
let inIndex = false;
|
||||
for (const char of path) {
|
||||
if (char === '.') {
|
||||
if (!inIndex && token) parts.push(token);
|
||||
token = '';
|
||||
} else if (char === '[') {
|
||||
if (token) parts.push(token);
|
||||
token = '';
|
||||
inIndex = true;
|
||||
} else if (char === ']') {
|
||||
parts.push(Number(token));
|
||||
token = '';
|
||||
inIndex = false;
|
||||
} else {
|
||||
token += char;
|
||||
}
|
||||
}
|
||||
if (token) parts.push(token);
|
||||
return parts;
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const data = collect();
|
||||
const res = await fetch('/save?file=' + FILE, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': CSRF_TOKEN },
|
||||
body: JSON.stringify(data, null, 2)
|
||||
});
|
||||
const json = await res.json();
|
||||
@@ -356,7 +411,7 @@ async function publish() {
|
||||
btn.disabled = true;
|
||||
|
||||
try {
|
||||
const res = await fetch('/publish', { method: 'POST' });
|
||||
const res = await fetch('/publish', { method: 'POST', headers: { 'X-CSRF-Token': CSRF_TOKEN } });
|
||||
const json = await res.json();
|
||||
|
||||
if (json.ok) {
|
||||
@@ -393,15 +448,75 @@ if (toast) setTimeout(() => toast.remove(), 3500);
|
||||
|
||||
// ── Server ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const CMS_USER = process.env.CMS_USER || 'admin';
|
||||
const CMS_PASS = process.env.CMS_PASS || 'mozdit2026';
|
||||
const CMS_USER = process.env.CMS_USER;
|
||||
const CMS_PASS = process.env.CMS_PASS;
|
||||
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);
|
||||
}
|
||||
|
||||
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) => {
|
||||
// Basic Auth verification
|
||||
const b64auth = (req.headers.authorization || '').split(' ')[1] || '';
|
||||
const [login, password] = Buffer.from(b64auth, 'base64').toString().split(':');
|
||||
|
||||
if (login !== CMS_USER || password !== CMS_PASS) {
|
||||
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;
|
||||
@@ -411,17 +526,48 @@ const server = http.createServer(async (req, res) => {
|
||||
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 = '';
|
||||
req.on('data', c => body += c);
|
||||
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);
|
||||
fs.writeFileSync(FILES[activeFile], JSON.stringify(data, null, 2) + '\n', 'utf8');
|
||||
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 }));
|
||||
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 }));
|
||||
}
|
||||
@@ -431,18 +577,27 @@ const server = http.createServer(async (req, res) => {
|
||||
|
||||
// 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 {
|
||||
// If a deploy.sh script exists, run it optionally in background
|
||||
exec('cd ../../../ && ./deploy.sh production > deploy.log 2>&1 &');
|
||||
writeAudit('publish_finished', { clientAddress, user: CMS_USER, result: 'ok' });
|
||||
res.end(JSON.stringify({ ok: true, output: stdout }));
|
||||
}
|
||||
});
|
||||
@@ -459,15 +614,29 @@ const server = http.createServer(async (req, res) => {
|
||||
}
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(HTML(activeFile, jsonData, message));
|
||||
res.end(HTML(activeFile, jsonData, message, CSRF_TOKEN));
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
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}`);
|
||||
if (require.main === module) {
|
||||
if (!securityConfigIsValid()) {
|
||||
throw new Error('CMS_USER és CMS_PASS 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');
|
||||
});
|
||||
console.log('\n Ctrl+C a leállításhoz\n');
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
backupAndWriteAtomically,
|
||||
validateContent,
|
||||
hasValidCredentials,
|
||||
hasValidCsrfToken,
|
||||
securityConfigIsValid,
|
||||
csrfToken: CSRF_TOKEN,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user