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
- docs/felhasznaloi-utmutato.md: user guide for the website and the CMS
(login, editing, arrays, save/validation, publish, security)
- /guide endpoint renders the markdown auth-protected via a dependency-free
renderer (scripts/markdown-render.js) in the CMS dark theme
- new ❓ Súgó entry in the CMS bottom bar
- steering rule: the guide must be updated in the same commit as any CMS or
website feature change
Closes MITHOME-57
742 lines
32 KiB
JavaScript
742 lines
32 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 = (activeFile, jsonData, message, csrfToken) => `<!DOCTYPE html>
|
||
<html lang="hu">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>${CMS_DEPLOY_ENV === 'staging' ? 'STAGING — ' : ''}mozdIT Content Editor</title>
|
||
<style>
|
||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f1117; color: #e2e8f0; min-height: 100vh; }
|
||
.environment-banner { background: #f59e0b; color: #111827; padding: 9px 32px; text-align: center; font-size: 13px; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
|
||
|
||
header { background: linear-gradient(135deg,#1a1f2e,#252d40); border-bottom: 1px solid #2d3748; padding: 14px 32px; display: flex; align-items: center; gap: 12px; }
|
||
header h1 { font-size: 17px; font-weight: 700; background: linear-gradient(135deg,#60a5fa,#a78bfa); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
||
header span { color: #64748b; font-size: 13px; }
|
||
|
||
.tabs { display: flex; gap: 2px; padding: 14px 32px 0; border-bottom: 1px solid #2d3748; background: #13192a; }
|
||
.tab { text-decoration: none; color: #94a3b8; padding: 9px 16px; border-radius: 8px 8px 0 0; font-size: 13px; font-weight: 500; transition: all .2s; border: 1px solid transparent; border-bottom: none; margin-bottom: -1px; }
|
||
.tab:hover { color: #e2e8f0; background: #1e2535; }
|
||
.tab.active { color: #60a5fa; background: #0f1117; border-color: #2d3748; }
|
||
|
||
.page { max-width: 860px; margin: 28px auto 120px; padding: 0 24px; }
|
||
.hint { color: #475569; font-size: 12px; margin-bottom: 20px; }
|
||
|
||
/* Primitive field */
|
||
.field { background: #1a2035; border: 1px solid #2d3748; border-radius: 10px; padding: 14px 16px; transition: border-color .2s; margin-bottom: 10px; }
|
||
.field:focus-within { border-color: #60a5fa; }
|
||
.field label { display: block; font-size: 11px; font-weight: 600; color: #60a5fa; text-transform: uppercase; letter-spacing:.05em; margin-bottom: 7px; font-family: monospace; }
|
||
.field input, .field textarea { width: 100%; background: transparent; border: none; outline: none; color: #e2e8f0; font-size: 14px; line-height: 1.6; resize: vertical; font-family: inherit; }
|
||
.field textarea { min-height: 52px; }
|
||
|
||
/* Array section */
|
||
.array-section { margin-bottom: 20px; }
|
||
.array-label { font-size: 12px; font-weight: 700; color: #a78bfa; text-transform: uppercase; letter-spacing:.06em; font-family: monospace; margin-bottom: 10px; display: flex; align-items: center; gap: 8px; }
|
||
.array-label::after { content:''; flex: 1; height: 1px; background: #2d3748; }
|
||
|
||
.array-items { display: flex; flex-direction: column; gap: 8px; }
|
||
|
||
/* Simple string array item */
|
||
.str-item { display: flex; gap: 8px; align-items: flex-start; }
|
||
.str-item textarea { flex: 1; background: #1a2035; border: 1px solid #2d3748; border-radius: 8px; padding: 10px 12px; color: #e2e8f0; font-size: 14px; font-family: inherit; outline: none; resize: vertical; min-height: 44px; transition: border-color .2s; }
|
||
.str-item textarea:focus { border-color: #60a5fa; }
|
||
|
||
/* Object array item (card) */
|
||
.obj-card { background: #1a2035; border: 1px solid #2d3748; border-radius: 10px; padding: 14px; position: relative; }
|
||
.obj-card .card-header { font-size: 11px; color: #64748b; font-family: monospace; margin-bottom: 10px; }
|
||
.obj-card .inner-field { margin-bottom: 8px; }
|
||
.obj-card .inner-field:last-child { margin-bottom: 0; }
|
||
.obj-card .inner-label { font-size: 10px; font-weight: 600; color: #94a3b8; text-transform: uppercase; letter-spacing:.05em; font-family: monospace; margin-bottom: 4px; }
|
||
.obj-card input, .obj-card textarea { width: 100%; background: #0f1420; border: 1px solid #2d3748; border-radius: 6px; padding: 8px 10px; color: #e2e8f0; font-size: 13px; font-family: inherit; outline: none; resize: vertical; transition: border-color .2s; }
|
||
.obj-card input:focus, .obj-card textarea:focus { border-color: #60a5fa; }
|
||
|
||
/* Buttons */
|
||
.btn-del { background: transparent; border: 1px solid #3f1c1c; color: #f87171; border-radius: 7px; padding: 6px 10px; cursor: pointer; font-size: 13px; transition: all .2s; white-space: nowrap; flex-shrink: 0; }
|
||
.btn-del:hover { background: #3f1c1c; }
|
||
.btn-del-card { position: absolute; top: 10px; right: 10px; background: transparent; border: 1px solid #3f1c1c; color: #f87171; border-radius: 6px; padding: 4px 8px; cursor: pointer; font-size: 12px; transition: all .2s; }
|
||
.btn-del-card:hover { background: #3f1c1c; }
|
||
|
||
.btn-add { background: transparent; border: 1px dashed #334155; color: #64748b; border-radius: 8px; padding: 9px 16px; cursor: pointer; font-size: 13px; width: 100%; text-align: center; transition: all .2s; margin-top: 6px; }
|
||
.btn-add:hover { border-color: #a78bfa; color: #a78bfa; background: #1a1535; }
|
||
|
||
/* Bottom bar */
|
||
.bottom-bar { position: fixed; bottom: 0; left: 0; right: 0; background: #0f1117; border-top: 1px solid #2d3748; padding: 14px 32px; display: flex; gap: 14px; align-items: center; z-index: 50; }
|
||
.btn-logout { margin-left: auto; background: #1f2937; color: #e2e8f0; border: 1px solid #374151; border-radius: 8px; padding: 9px 16px; font-size: 14px; cursor: pointer; }
|
||
.btn-logout:hover { background: #374151; }
|
||
.btn-save { background: linear-gradient(135deg,#3b82f6,#6366f1); color: #fff; border: none; padding: 11px 26px; border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer; transition: opacity .2s, transform .1s; }
|
||
.btn-save:hover { opacity: .9; transform: translateY(-1px); }
|
||
.btn-save:active { transform: translateY(0); }
|
||
.btn-publish { background: linear-gradient(135deg,#10b981,#059669); color: #fff; border: none; padding: 11px 26px; border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer; transition: opacity .2s, transform .1s; }
|
||
.btn-publish:hover { opacity: .9; transform: translateY(-1px); }
|
||
.btn-publish:active { transform: translateY(0); }
|
||
.preview-link { color: #64748b; font-size: 13px; text-decoration: none; margin-left: auto; }
|
||
.preview-link:hover { color: #94a3b8; }
|
||
.save-status { font-size: 13px; font-weight: 500; display: none; margin-left: 8px; }
|
||
|
||
/* Toast */
|
||
.toast { position: fixed; top: 20px; right: 20px; padding: 13px 18px; border-radius: 9px; font-size: 14px; font-weight: 500; z-index: 200; animation: slideIn .3s ease; }
|
||
.toast.ok { background: #064e3b; border: 1px solid #10b981; color: #6ee7b7; }
|
||
.toast.err { background: #450a0a; border: 1px solid #ef4444; color: #fca5a5; }
|
||
@keyframes slideIn { from { opacity:0; transform: translateX(20px); } to { opacity:1; transform: translateX(0); } }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
${CMS_DEPLOY_ENV === 'staging' ? '<div class="environment-banner">⚠ STAGING / TESZTKÖRNYEZET — itt végzett publikálás csak a staging oldalt frissíti</div>' : ''}
|
||
${message ? `<div class="toast ${message.type === 'ok' ? 'ok' : 'err'}">${message.text}</div>` : ''}
|
||
|
||
<header>
|
||
<h1>mozdIT Content Editor</h1>
|
||
<span>— JSON fájlok szerkesztése vizuálisan</span>
|
||
</header>
|
||
|
||
<nav class="tabs">
|
||
${Object.entries(FILE_LABELS).map(([k, l]) =>
|
||
`<a href="/?file=${k}" class="tab ${activeFile === k ? 'active' : ''}">${l}</a>`
|
||
).join('')}
|
||
</nav>
|
||
|
||
<div class="page">
|
||
<p class="hint">📝 Szerkeszd a mezőket. Tömbökből elemet törölhetsz (❌) vagy hozzáadhatsz (➕). Mentés gomb menti a fájlt.</p>
|
||
<div id="editor"></div>
|
||
</div>
|
||
|
||
<div class="bottom-bar">
|
||
<button class="btn-save" onclick="save()">💾 Mentés</button>
|
||
<button class="btn-publish" onclick="publish()" id="publishBtn">🚀 Publikálás & ${CMS_DEPLOY_ENV === 'staging' ? 'Staging deploy' : 'Élesítés'}</button>
|
||
<span class="save-status" id="saveStatus"></span>
|
||
<a href="${CMS_DEPLOY_ENV === 'staging' ? 'https://stage.mozdit.hu' : 'http://localhost:3000'}" target="_blank" class="preview-link">🔗 Előnézet →</a>
|
||
<a href="/guide" target="_blank" class="preview-link">❓ Súgó</a>
|
||
<button class="btn-logout" onclick="logout()">🚪 Kilépés</button>
|
||
</div>
|
||
|
||
<script id="page-data" type="application/json">${jsonData.replace(/<\//g, '<\\/')}</script>
|
||
<script>
|
||
const DATA = JSON.parse(document.getElementById('page-data').textContent);
|
||
const FILE = "${activeFile}";
|
||
const CSRF_TOKEN = "${csrfToken}";
|
||
|
||
// ── Render ──────────────────────────────────────────────────────────────────
|
||
|
||
function render(obj, container) {
|
||
container.innerHTML = '';
|
||
renderObject(obj, container, '');
|
||
}
|
||
|
||
function renderObject(obj, container, prefix) {
|
||
for (const [key, val] of Object.entries(obj)) {
|
||
const path = prefix ? prefix + '.' + key : key;
|
||
if (Array.isArray(val)) {
|
||
renderArray(key, val, container, path);
|
||
} else if (typeof val === 'object' && val !== null) {
|
||
renderObject(val, container, path);
|
||
} else {
|
||
renderPrimitive(path, val, container);
|
||
}
|
||
}
|
||
}
|
||
|
||
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>
|
||
\${control}
|
||
\`;
|
||
container.appendChild(div);
|
||
}
|
||
|
||
function renderArray(key, arr, container, path) {
|
||
const section = document.createElement('div');
|
||
section.className = 'array-section';
|
||
section.dataset.arrayPath = path;
|
||
|
||
const label = document.createElement('div');
|
||
label.className = 'array-label';
|
||
label.textContent = path;
|
||
section.appendChild(label);
|
||
|
||
const items = document.createElement('div');
|
||
items.className = 'array-items';
|
||
items.dataset.arrayItems = path;
|
||
section.appendChild(items);
|
||
|
||
arr.forEach((item, i) => {
|
||
if (typeof item === 'object' && item !== null) {
|
||
items.appendChild(makeObjCard(item, i, path));
|
||
} else {
|
||
items.appendChild(makeStrItem(item, i, path));
|
||
}
|
||
});
|
||
|
||
// Template for adding new items
|
||
const sample = arr.length > 0 ? arr[arr.length - 1] : '';
|
||
const isObj = typeof sample === 'object' && sample !== null;
|
||
|
||
const addBtn = document.createElement('button');
|
||
addBtn.className = 'btn-add';
|
||
addBtn.textContent = '➕ Új elem hozzáadása';
|
||
addBtn.onclick = () => {
|
||
const idx = items.children.length;
|
||
if (isObj) {
|
||
const blank = blankLike(sample);
|
||
items.appendChild(makeObjCard(blank, idx, path));
|
||
} else {
|
||
items.appendChild(makeStrItem('', idx, path));
|
||
}
|
||
reindexItems(items);
|
||
};
|
||
section.appendChild(addBtn);
|
||
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 type = val === null ? 'null' : typeof val;
|
||
const ta = type === 'boolean' ? document.createElement('input') : document.createElement('textarea');
|
||
ta.dataset.path = path + '[' + idx + ']';
|
||
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 = '❌';
|
||
del.title = 'Törlés';
|
||
del.onclick = () => { wrap.remove(); reindexItems(wrap.closest('.array-items')); };
|
||
wrap.appendChild(ta);
|
||
wrap.appendChild(del);
|
||
return wrap;
|
||
}
|
||
|
||
function makeObjCard(obj, idx, path) {
|
||
const card = document.createElement('div');
|
||
card.className = 'obj-card';
|
||
const hdr = document.createElement('div');
|
||
hdr.className = 'card-header';
|
||
hdr.textContent = path + '[' + idx + ']';
|
||
card.appendChild(hdr);
|
||
|
||
renderObject(obj, card, path + '[' + idx + ']');
|
||
|
||
const del = document.createElement('button');
|
||
del.className = 'btn-del-card';
|
||
del.textContent = '❌ Törlés';
|
||
del.onclick = () => { card.remove(); reindexItems(card.closest('.array-items')); };
|
||
card.appendChild(del);
|
||
return card;
|
||
}
|
||
|
||
function reindexItems(itemsEl) {
|
||
if (!itemsEl) return;
|
||
const path = itemsEl.dataset.arrayItems;
|
||
// WHY: rewrite only the index that directly follows THIS array's own path prefix.
|
||
// A generic "replace first [n]" rule corrupts nested arrays (e.g. deleting from
|
||
// services[1].specs.items rewrites the OUTER services index and scatters paths
|
||
// across services[0..n], producing sparse arrays and schema errors).
|
||
const prefix = path + '[';
|
||
Array.from(itemsEl.children).forEach((child, i) => {
|
||
child.querySelectorAll('[data-path]').forEach(el => {
|
||
const old = el.dataset.path;
|
||
if (typeof old !== 'string' || !old.startsWith(prefix)) return;
|
||
const rest = old.slice(prefix.length);
|
||
const bracketEnd = rest.indexOf(']');
|
||
const suffix = bracketEnd === -1 ? '' : rest.slice(bracketEnd);
|
||
el.dataset.path = prefix + i + suffix;
|
||
});
|
||
// Update card header
|
||
const hdr = child.querySelector('.card-header');
|
||
if (hdr) hdr.textContent = path + '[' + i + ']';
|
||
});
|
||
}
|
||
|
||
// ── Collect & Save ───────────────────────────────────────────────────────────
|
||
|
||
function collect() {
|
||
const result = JSON.parse(JSON.stringify(DATA)); // deep clone as base
|
||
// Wipe all arrays so we rebuild them from DOM
|
||
clearArrays(result);
|
||
|
||
document.querySelectorAll('[data-path]').forEach(el => {
|
||
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] = [];
|
||
else if (typeof obj[k] === 'object' && obj[k] !== null) clearArrays(obj[k]);
|
||
}
|
||
}
|
||
|
||
function setPath(obj, path, value) {
|
||
const parts = parsePath(path);
|
||
let cur = obj;
|
||
for (let i = 0; i < parts.length - 1; i++) {
|
||
const part = parts[i];
|
||
if (cur[part] === undefined || cur[part] === null) {
|
||
cur[part] = typeof parts[i + 1] === 'number' ? [] : {};
|
||
}
|
||
cur = cur[part];
|
||
}
|
||
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', 'X-CSRF-Token': CSRF_TOKEN },
|
||
body: JSON.stringify(data, null, 2)
|
||
});
|
||
const json = await res.json();
|
||
const status = document.getElementById('saveStatus');
|
||
if (json.ok) {
|
||
status.textContent = '✅ Mentve!';
|
||
status.style.color = '#10b981';
|
||
} else {
|
||
status.textContent = '❌ Hiba: ' + json.error;
|
||
status.style.color = '#f87171';
|
||
}
|
||
status.style.display = 'inline';
|
||
setTimeout(() => status.style.display = 'none', 3000);
|
||
}
|
||
|
||
async function publish() {
|
||
const btn = document.getElementById('publishBtn');
|
||
const status = document.getElementById('saveStatus');
|
||
|
||
// Save first
|
||
await save();
|
||
|
||
btn.textContent = '⏳ Élesítés folyamatban...';
|
||
btn.disabled = true;
|
||
|
||
try {
|
||
const res = await fetch('/publish', { method: 'POST', headers: { 'X-CSRF-Token': CSRF_TOKEN } });
|
||
const json = await res.json();
|
||
|
||
if (json.ok) {
|
||
status.textContent = '🚀 Sikeresen elküldve a szerverre!';
|
||
status.style.color = '#10b981';
|
||
} else {
|
||
status.textContent = '❌ Hiba az élesítésnél: ' + json.error;
|
||
status.style.color = '#f87171';
|
||
}
|
||
} catch (e) {
|
||
status.textContent = '❌ Hálózati hiba';
|
||
status.style.color = '#f87171';
|
||
}
|
||
|
||
btn.textContent = '🚀 Publikálás & Élesítés';
|
||
btn.disabled = false;
|
||
status.style.display = 'inline';
|
||
setTimeout(() => status.style.display = 'none', 5000);
|
||
}
|
||
|
||
async function logout() {
|
||
// WHY: the browser caches Basic Auth credentials until it closes. One request with
|
||
// deliberately invalid credentials overwrites the cache, so the reload below
|
||
// prompts for login again. fetch() never triggers the native auth dialog.
|
||
try {
|
||
await fetch('/logout', { headers: { 'Authorization': 'Basic ' + btoa('logout:logout') } });
|
||
} catch (e) { /* network error — reload anyway */ }
|
||
location.reload();
|
||
}
|
||
|
||
function esc(v) {
|
||
return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
}
|
||
|
||
// Boot
|
||
render(DATA, document.getElementById('editor'));
|
||
|
||
// Auto-dismiss toast
|
||
const toast = document.querySelector('.toast');
|
||
if (toast) setTimeout(() => toast.remove(), 3500);
|
||
</script>
|
||
</body>
|
||
</html>`;
|
||
|
||
// User guide page — renders docs/felhasznaloi-utmutato.md with the shared dark theme.
|
||
const GUIDE_PAGE = (contentHtml) => `<!DOCTYPE html>
|
||
<html lang="hu">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>mozdIT — Felhasználói útmutató</title>
|
||
<style>
|
||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f1117; color: #e2e8f0; line-height: 1.65; padding-bottom: 64px; }
|
||
header { background: linear-gradient(135deg,#1a1f2e,#252d40); border-bottom: 1px solid #2d3748; padding: 14px 32px; display: flex; align-items: center; gap: 12px; position: sticky; top: 0; z-index: 10; }
|
||
header h1 { font-size: 17px; font-weight: 700; background: linear-gradient(135deg,#60a5fa,#a78bfa); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
||
header a { color: #94a3b8; text-decoration: none; font-size: 14px; margin-left: auto; }
|
||
header a:hover { color: #e2e8f0; }
|
||
main { max-width: 760px; margin: 0 auto; padding: 32px 24px; }
|
||
h1 { font-size: 24px; margin: 16px 0 12px; color: #f1f5f9; }
|
||
h2 { font-size: 20px; margin: 28px 0 10px; color: #93c5fd; border-bottom: 1px solid #2d3748; padding-bottom: 6px; }
|
||
h3 { font-size: 16px; margin: 20px 0 8px; color: #c4b5fd; }
|
||
h4 { font-size: 14px; margin: 16px 0 6px; color: #c4b5fd; }
|
||
p { margin: 8px 0; }
|
||
ul, ol { margin: 8px 0 8px 22px; }
|
||
li { margin: 4px 0; }
|
||
a { color: #7dd3fc; }
|
||
code { background: #1e293b; border-radius: 4px; padding: 1px 6px; font-size: 0.9em; color: #fbbf24; }
|
||
pre { background: #1e293b; border: 1px solid #2d3748; border-radius: 8px; padding: 12px 16px; overflow-x: auto; margin: 12px 0; }
|
||
pre code { background: none; padding: 0; color: #e2e8f0; }
|
||
hr { border: none; border-top: 1px solid #2d3748; margin: 24px 0; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<header>
|
||
<h1>mozdIT — Felhasználói útmutató</h1>
|
||
<a href="/">← Vissza a szerkesztőhöz</a>
|
||
</header>
|
||
|
||
<main>
|
||
${contentHtml}
|
||
</main>
|
||
|
||
</body>
|
||
</html>`;
|
||
|
||
// ── 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;
|
||
}
|
||
|
||
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') {
|
||
res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="mozdIT CMS"' });
|
||
res.end('Logged out');
|
||
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 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;
|
||
}
|
||
|
||
// 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 & 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,
|
||
};
|