From d2ee13bb91d3e0d576758f9551018ae1b190ed33 Mon Sep 17 00:00:00 2001 From: Do Siki Date: Wed, 19 Aug 2026 12:46:55 +0200 Subject: [PATCH] feat(cms): logo upload with preview, backup and audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 🎹 LogĂł page in the CMS bottom bar: replace the website header logo and the CMS login icon with a PNG upload (magic-byte validation, 1 MiB cap) - the replaced logo gets a timestamped backup in .content-backups; every upload is audited (logo_updated) - /logo.png?variant=header serves the header variant for the preview - publish stages proto/public too, so logo changes ride the same commit+deploy pipeline as content - route handling extracted to scripts/cms-logo.js to stay under the 400-line limit - integration test: upload+replace+backup, variant preview, 415/413/400, CSRF, auth Closes MITHOME-65 --- content-editor.js | 73 +++++--------- docs/felhasznaloi-utmutato.md | 8 ++ scripts/cms-logo-page.js | 106 ++++++++++++++++++++ scripts/cms-logo.js | 92 +++++++++++++++++ scripts/cms-pages.js | 1 + scripts/cms-publish.js | 3 + scripts/test-cms-publish.js | 2 +- scripts/test-content-editor-logo.js | 147 ++++++++++++++++++++++++++++ 8 files changed, 381 insertions(+), 51 deletions(-) create mode 100644 scripts/cms-logo-page.js create mode 100644 scripts/cms-logo.js create mode 100644 scripts/test-content-editor-logo.js 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 + + + + +
+

🎹 LogĂł kezelĂ©se

+ ← Vissza a szerkesztƑhöz +
+ +
+

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Ăł elƑnĂ©zet
+ +
+ +

+
+ +
+

CMS logĂł (ikon)

+

Használat: CMS bejelentkezƑ oldal — jelenlegi fájl: /${LOGO_TARGETS.icon}

+
ikon logĂł elƑnĂ©zet
+ +
+ +

+
+
+ + + +`; + +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 ? `
${messag 🔗 ElƑnĂ©zet → ❓ SĂșgĂł 🕘 VerziĂłk + 🎹 LogĂł v${deployVersion}
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 */ } + });