diff --git a/content-editor.js b/content-editor.js
index 85f9efc..98ca466 100644
--- a/content-editor.js
+++ b/content-editor.js
@@ -16,7 +16,8 @@ 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 { safeBackupName, listVersions, readBackupContent, buildVersionDiff } = require('./scripts/cms-versions');
+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
@@ -51,6 +52,7 @@ const FILE_LABELS = {
};
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.
@@ -104,7 +106,9 @@ const server = http.createServer(async (req, res) => {
// 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'));
+ // ?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 {
@@ -304,55 +308,24 @@ const server = http.createServer(async (req, res) => {
return;
}
- // GET /versions — backup list + optional diff view (browser page, session-auth).
- if (req.method === 'GET' && u.pathname === '/versions') {
- const versions = listVersions(BACKUP_DIR, activeFile);
- let diff = null;
- const showRaw = u.searchParams.get('show');
- if (showRaw) {
- const safe = safeBackupName(activeFile, showRaw);
- if (safe) {
- try {
- diff = buildVersionDiff(BACKUP_DIR, FILES[activeFile], activeFile, safe);
- } catch { /* unreadable backup: render list only */ }
- }
- }
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
- res.end(VERSIONS_PAGE(activeFile, FILE_LABELS[activeFile] || activeFile, versions, diff, CSRF_TOKEN));
- 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;
- // POST /restore — restore a backup; the current state is backed up first,
- // so the restore itself is reversible. Schema validation guards against
- // restoring a structurally broken backup.
- if (req.method === 'POST' && u.pathname === '/restore') {
- const backup = safeBackupName(activeFile, u.searchParams.get('backup') || '');
- if (!backup) {
- res.writeHead(400, { 'Content-Type': 'application/json' });
- res.end(JSON.stringify({ ok: false, error: 'Érvénytelen mentésnév.' }));
- return;
- }
- try {
- const content = readBackupContent(BACKUP_DIR, backup);
- const data = JSON.parse(content);
- const validation = validateContent(activeFile, data);
- if (!validation.ok) {
- writeAudit('version_restored', { clientAddress, user: CMS_USER, file: activeFile, backup, result: 'validation_failed' });
- res.writeHead(422, { 'Content-Type': 'application/json' });
- res.end(JSON.stringify({ ok: false, error: 'A mentés nem felel meg a sémának: ' + validation.errors.join('; ') }));
- return;
- }
- backupAndWriteAtomically(FILES[activeFile], data, BACKUP_DIR);
- writeAudit('version_restored', { clientAddress, user: CMS_USER, file: activeFile, backup, result: 'ok' });
- res.writeHead(200, { 'Content-Type': 'application/json' });
- res.end(JSON.stringify({ ok: true }));
- } catch (e) {
- writeAudit('version_restored', { clientAddress, user: CMS_USER, file: activeFile, backup, result: 'error' });
- res.writeHead(500, { 'Content-Type': 'application/json' });
- res.end(JSON.stringify({ ok: false, error: e.message }));
- }
- 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;
diff --git a/docs/felhasznaloi-utmutato.md b/docs/felhasznaloi-utmutato.md
index d48cd4d..c35bbce 100644
--- a/docs/felhasznaloi-utmutato.md
+++ b/docs/felhasznaloi-utmutato.md
@@ -62,6 +62,14 @@ A dokumentum a repó része, és **folyamatosan karbantartott**: minden funkció
- **➕ Új elem hozzáadása** gomb: új elem beszúrása a lista végére (üres, a meglévőkhöz hasonló űrlappal).
- Kártyás listáknál (pl. szolgáltatások) minden kártya külön törölhető a kártya alján lévő gombbal.
+### 🎨 Logó kezelése
+
+- Az alsó sáv **🎨 Logó** gombja megnyitja a logókezelő oldalt.
+- Két logó cserélhető: a **weboldal fejléclogója** (szöveges) és a **CMS bejelentkező oldal ikonja**.
+- Csak **PNG**, max. **1 MB**; ajánlott átlátszó háttér a sötét fejléchez.
+- A régi logó mentésre kerül — a csere biztonságos és visszavonható (a mentések a `.content-backups` mappában).
+- A **CMS azonnal** az új logót mutatja; a **weboldalon a Publikálás (deploy) után** jelenik meg.
+
### 🕘 Verziók — korábbi állapotok
- Az alsó sáv **🕘 Verziók** gombja megnyitja az éppen szerkesztett fájl mentéseit (minden Mentés automatikus másolatot készít).
diff --git a/scripts/cms-logo-page.js b/scripts/cms-logo-page.js
new file mode 100644
index 0000000..487b0e4
--- /dev/null
+++ b/scripts/cms-logo-page.js
@@ -0,0 +1,106 @@
+// Branding page for the Content Editor: upload/replace the two logos with
+// client-side preview. Kept separate from cms-pages.js (file-size limits).
+const { LOGO_TARGETS } = require('./cms-logo');
+
+const LOGO_PAGE = (csrfToken) => `
+
+
+
+
+ mozdIT — Logó kezelése
+
+
+
+
+
+
+
+ Csak PNG fájl, max. 1 MB. A régi logó mentésre kerül (a 🕘 Verziókhoz hasonlóan visszavonható). A CMS-belei változás azonnal, a weboldalon a Publikálás (deploy) után jelenik meg. Ajánlott átlátszó háttérű PNG a sötét fejléchez.
+
+
+
Weboldal fejléc logója (szöveges)
+
Használat: weboldal fejléc — jelenlegi fájl: /${LOGO_TARGETS.header}
+
+
+
+
⬆ Fejléc logó cseréje
+
+
+
+
+
CMS logó (ikon)
+
Használat: CMS bejelentkező oldal — jelenlegi fájl: /${LOGO_TARGETS.icon}
+
+
+
+
⬆ Ikon logó cseréje
+
+
+
+
+
+
+`;
+
+module.exports = { LOGO_PAGE };
diff --git a/scripts/cms-logo.js b/scripts/cms-logo.js
new file mode 100644
index 0000000..159ec7c
--- /dev/null
+++ b/scripts/cms-logo.js
@@ -0,0 +1,92 @@
+// Logo upload handling for the Content Editor: PNG validation, timestamped
+// backup and atomic binary replace.
+const fs = require('fs');
+const path = require('path');
+
+const MAX_LOGO_BYTES = 1024 * 1024; // 1 MiB — plenty for a logo
+const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
+
+// WHY fixed targets instead of a client-supplied filename: arbitrary write
+// paths would be a traversal risk; the two known logos are the only assets
+// the site consumes.
+const LOGO_TARGETS = {
+ icon: 'mozdit_logo.png', // CMS login page
+ header: 'mozdit_logo_text.png', // website Header
+};
+
+function isPng(buffer) {
+ return Buffer.isBuffer(buffer) && buffer.length >= PNG_MAGIC.length && buffer.subarray(0, PNG_MAGIC.length).equals(PNG_MAGIC);
+}
+
+function saveLogoAtomically(publicDir, targetKey, buffer, backupDir) {
+ const fileName = LOGO_TARGETS[targetKey];
+ if (!fileName) throw new Error('Ismeretlen logó célpont');
+ const targetFile = path.join(publicDir, fileName);
+
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
+ const backupName = `${fileName}.${timestamp}.bak`;
+ fs.mkdirSync(backupDir, { recursive: true, mode: 0o700 });
+ fs.copyFileSync(targetFile, path.join(backupDir, backupName));
+
+ const tempFile = `${targetFile}.${process.pid}.${Date.now()}.tmp`;
+ fs.writeFileSync(tempFile, buffer, { mode: 0o644 });
+ fs.renameSync(tempFile, targetFile);
+ return { targetFile, backupName };
+}
+
+// WHY: route handling lives here so content-editor.js stays under the
+// 400-line limit. Returns true when the request was handled.
+function handleLogoRoutes({ req, res, u, publicDir, backupDir, writeAudit, clientAddress, user, logoPage }) {
+ if (req.method === 'GET' && u.pathname === '/branding') {
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
+ res.end(logoPage());
+ return true;
+ }
+
+ if (req.method === 'POST' && u.pathname === '/logo') {
+ const target = u.searchParams.get('target') || '';
+ if (!LOGO_TARGETS[target]) {
+ res.writeHead(400, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ ok: false, error: 'Ismeretlen logó célpont.' }));
+ return true;
+ }
+ const chunks = [];
+ let total = 0;
+ let tooLarge = false;
+ req.on('data', c => {
+ total += c.length;
+ if (total > MAX_LOGO_BYTES) { tooLarge = true; return; }
+ chunks.push(c);
+ });
+ req.on('end', () => {
+ const buffer = Buffer.concat(chunks);
+ if (tooLarge) {
+ writeAudit('logo_updated', { clientAddress, user, target, result: 'request_too_large' });
+ res.writeHead(413, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ ok: false, error: `A fájl túl nagy (maximum ${MAX_LOGO_BYTES} byte).` }));
+ return;
+ }
+ if (!isPng(buffer)) {
+ writeAudit('logo_updated', { clientAddress, user, target, result: 'invalid_type' });
+ res.writeHead(415, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ ok: false, error: 'Csak érvényes PNG fájl tölthető fel.' }));
+ return;
+ }
+ try {
+ const { backupName } = saveLogoAtomically(publicDir, target, buffer, backupDir);
+ writeAudit('logo_updated', { clientAddress, user, target, result: 'ok', backup: backupName });
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ ok: true, backup: backupName }));
+ } catch (e) {
+ writeAudit('logo_updated', { clientAddress, user, target, result: 'error' });
+ res.writeHead(500, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ ok: false, error: e.message }));
+ }
+ });
+ return true;
+ }
+
+ return false;
+}
+
+module.exports = { MAX_LOGO_BYTES, LOGO_TARGETS, isPng, saveLogoAtomically, handleLogoRoutes };
diff --git a/scripts/cms-pages.js b/scripts/cms-pages.js
index 5ab40bb..d1d5e12 100644
--- a/scripts/cms-pages.js
+++ b/scripts/cms-pages.js
@@ -114,6 +114,7 @@ ${message ? `
diff --git a/scripts/cms-publish.js b/scripts/cms-publish.js
index f2606a9..86dc94a 100644
--- a/scripts/cms-publish.js
+++ b/scripts/cms-publish.js
@@ -18,6 +18,9 @@ const NO_CHANGES_MARKER = '__NO_CONTENT_CHANGES__';
function buildPublishCommand(commitMessage) {
return [
'git add .',
+ // WHY: logo uploads live in proto/public — outside the content cwd — so
+ // stage them too (tolerant: optional path in test throwaway repos).
+ '(git add ../public || true)',
`(git diff --cached --quiet && echo ${NO_CHANGES_MARKER} || git commit -m "${commitMessage}")`,
'(git pull --rebase origin main || (git rebase --abort; false))',
'git push origin main',
diff --git a/scripts/test-cms-publish.js b/scripts/test-cms-publish.js
index eb03154..9de401f 100644
--- a/scripts/test-cms-publish.js
+++ b/scripts/test-cms-publish.js
@@ -25,7 +25,7 @@ const ROOT = path.join(__dirname, '..');
// ── Unit ─────────────────────────────────────────────────────────────────────
const cmd = buildPublishCommand('content: frissítve a CMS-ből');
-assert.ok(cmd.startsWith('git add . && (git diff --cached --quiet && echo ' + NO_CHANGES_MARKER), 'conditional commit with marker');
+assert.ok(cmd.startsWith('git add . && (git add ../public || true) && (git diff --cached --quiet && echo ' + NO_CHANGES_MARKER), 'conditional commit with marker');
assert.ok(cmd.includes('(git pull --rebase origin main || (git rebase --abort; false))'), 'rebase-abort fallback');
assert.ok(cmd.endsWith('git push origin main'), 'push last');
diff --git a/scripts/test-content-editor-logo.js b/scripts/test-content-editor-logo.js
new file mode 100644
index 0000000..833b97b
--- /dev/null
+++ b/scripts/test-content-editor-logo.js
@@ -0,0 +1,147 @@
+#!/usr/bin/env node
+
+/**
+ * Integration test for CMS logo upload (MITHOME-65):
+ * 1. GET /branding serves the logo page (session-auth)
+ * 2. POST /logo?target=icon with a valid PNG replaces the file, backs up the
+ * old one into .content-backups and audits logo_updated
+ * 3. non-PNG bytes → 415; >1 MiB → 413; bad target → 400; no CSRF → 403;
+ * unauthenticated → 401
+ * Original logo files are restored at the end.
+ */
+const assert = require('assert/strict');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { spawn } = require('child_process');
+
+const ROOT = path.join(__dirname, '..');
+const PORT = 4132;
+const BASE = `http://127.0.0.1:${PORT}`;
+const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'cms-logo-'));
+const auditFile = path.join(tmp, 'audit.jsonl');
+
+// Minimal valid 1x1 transparent PNG
+const TINY_PNG = Buffer.from(
+ '89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d4944415478da636460f8ff9f0001040100c9fe92ef0000000049454e44ae426082',
+ 'hex'
+);
+
+const child = spawn('node', ['content-editor.js'], {
+ cwd: ROOT,
+ env: {
+ ...process.env,
+ CONTENT_EDITOR_PORT: String(PORT),
+ CONTENT_EDITOR_AUDIT_FILE: auditFile,
+ CMS_USER: 'logo-test-user',
+ CMS_PASS: 'logo-test-pass',
+ CMS_DEPLOY_ENV: 'staging',
+ },
+ stdio: 'ignore',
+});
+
+async function waitForServer(timeoutMs = 10000) {
+ const deadline = Date.now() + timeoutMs;
+ while (Date.now() < deadline) {
+ try {
+ await fetch(`${BASE}/version`);
+ return;
+ } catch {
+ await new Promise(r => setTimeout(r, 200));
+ }
+ }
+ throw new Error('server did not start');
+}
+
+async function main() {
+ await waitForServer();
+ const login = await fetch(`${BASE}/login`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ user: 'logo-test-user', pass: 'logo-test-pass' }),
+ });
+ const cookie = (login.headers.get('set-cookie') || '').split(';')[0];
+ const page = await (await fetch(`${BASE}/`, { headers: { Cookie: cookie } })).text();
+ const csrf = page.match(/CSRF_TOKEN = "([a-f0-9]+)"/)[1];
+
+ const iconPath = path.join(ROOT, 'proto', 'public', 'mozdit_logo.png');
+ const headerPath = path.join(ROOT, 'proto', 'public', 'mozdit_logo_text.png');
+ const originalIcon = fs.readFileSync(iconPath);
+ const originalHeader = fs.readFileSync(headerPath);
+ const backupDir = path.join(ROOT, '.content-backups');
+
+ try {
+ // 1. branding page
+ const branding = await fetch(`${BASE}/branding`, { headers: { Cookie: cookie } });
+ assert.equal(branding.status, 200);
+ assert.match(await branding.text(), /Logó kezelése/);
+
+ // 2. valid upload replaces the file and creates a backup
+ const before = fs.readdirSync(backupDir).filter(n => n.startsWith('mozdit_logo.png.'));
+ const up = await fetch(`${BASE}/logo?target=icon`, {
+ method: 'POST',
+ headers: { Cookie: cookie, 'Content-Type': 'image/png', 'X-CSRF-Token': csrf },
+ body: TINY_PNG,
+ });
+ assert.equal(up.status, 200);
+ const upBody = await up.json();
+ assert.equal(upBody.ok, true);
+ assert.match(upBody.backup, /^mozdit_logo\.png\./);
+ assert.deepEqual(fs.readFileSync(iconPath), TINY_PNG, 'icon file replaced');
+ const after = fs.readdirSync(backupDir).filter(n => n.startsWith('mozdit_logo.png.'));
+ assert.equal(after.length, before.length + 1, 'old logo backed up');
+ // audit entry
+ const audit = fs.readFileSync(auditFile, 'utf8').trim().split('\n').map(l => JSON.parse(l));
+ assert.ok(audit.some(e => e.event === 'logo_updated' && e.result === 'ok'));
+
+ // variant preview route serves the header logo
+ const headerPreview = await fetch(`${BASE}/logo.png?variant=header`);
+ assert.equal(headerPreview.status, 200);
+ assert.deepEqual(Buffer.from(await headerPreview.arrayBuffer()), originalHeader);
+
+ // 3a. non-PNG → 415
+ const bad = await fetch(`${BASE}/logo?target=icon`, {
+ method: 'POST',
+ headers: { Cookie: cookie, 'Content-Type': 'image/png', 'X-CSRF-Token': csrf },
+ body: Buffer.from('definitely not a png'),
+ });
+ assert.equal(bad.status, 415);
+
+ // 3b. oversized → 413
+ const big = Buffer.alloc(1024 * 1024 + 1);
+ big.set(TINY_PNG.subarray(0, 8));
+ const tooBig = await fetch(`${BASE}/logo?target=icon`, {
+ method: 'POST',
+ headers: { Cookie: cookie, 'Content-Type': 'image/png', 'X-CSRF-Token': csrf },
+ body: big,
+ });
+ assert.equal(tooBig.status, 413);
+
+ // 3c. bad target → 400
+ const badTarget = await fetch(`${BASE}/logo?target=../../etc`,
+ { method: 'POST', headers: { Cookie: cookie, 'Content-Type': 'image/png', 'X-CSRF-Token': csrf }, body: TINY_PNG });
+ assert.equal(badTarget.status, 400);
+
+ // 3d. authenticated but no CSRF → 403
+ const noCsrf = await fetch(`${BASE}/logo?target=icon`,
+ { method: 'POST', headers: { Cookie: cookie, 'Content-Type': 'image/png' }, body: TINY_PNG });
+ assert.equal(noCsrf.status, 403);
+
+ // 3e. unauthenticated (valid CSRF token but no session) → 401
+ const anon = await fetch(`${BASE}/logo?target=icon`,
+ { method: 'POST', headers: { 'Content-Type': 'image/png', 'X-CSRF-Token': csrf }, body: TINY_PNG });
+ assert.equal(anon.status, 401);
+
+ console.log('Content Editor logo upload test: OK');
+ } finally {
+ fs.writeFileSync(iconPath, originalIcon);
+ fs.writeFileSync(headerPath, originalHeader);
+ }
+}
+
+main()
+ .catch(err => { console.error('❌', err.message); process.exitCode = 1; })
+ .finally(() => {
+ child.kill('SIGTERM');
+ try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* best effort */ }
+ });