feat(cms): logo upload with preview, backup and audit
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
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
- 🎨 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
This commit is contained in:
+23
-50
@@ -16,7 +16,8 @@ const crypto = require('crypto');
|
|||||||
const { validateContent } = require('./proto/src/content/schema');
|
const { validateContent } = require('./proto/src/content/schema');
|
||||||
const { renderMarkdown } = require('./scripts/markdown-render');
|
const { renderMarkdown } = require('./scripts/markdown-render');
|
||||||
const { buildPublishCommand, interpretPublishResult } = require('./scripts/cms-publish');
|
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;
|
const PORT = Number(process.env.CONTENT_EDITOR_PORT) || 4001;
|
||||||
// WHY: overridable so the publish integration test can run against a throwaway
|
// 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 { 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');
|
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.
|
// 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.
|
// Public: logo asset for the login page.
|
||||||
if (req.method === 'GET' && u.pathname === '/logo.png') {
|
if (req.method === 'GET' && u.pathname === '/logo.png') {
|
||||||
try {
|
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.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=3600' });
|
||||||
res.end(logo);
|
res.end(logo);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -304,55 +308,24 @@ const server = http.createServer(async (req, res) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /versions — backup list + optional diff view (browser page, session-auth).
|
// GET /versions + POST /restore — handled in scripts/cms-versions.js.
|
||||||
if (req.method === 'GET' && u.pathname === '/versions') {
|
if (handleVersionRoutes({
|
||||||
const versions = listVersions(BACKUP_DIR, activeFile);
|
req, res, u, activeFile,
|
||||||
let diff = null;
|
backupDir: BACKUP_DIR,
|
||||||
const showRaw = u.searchParams.get('show');
|
currentFile: FILES[activeFile],
|
||||||
if (showRaw) {
|
validate: validateContent,
|
||||||
const safe = safeBackupName(activeFile, showRaw);
|
writeAudit, clientAddress, user: CMS_USER,
|
||||||
if (safe) {
|
versionsPage: (fileKey, diff) => VERSIONS_PAGE(fileKey, FILE_LABELS[fileKey] || fileKey, listVersions(BACKUP_DIR, fileKey), diff, CSRF_TOKEN),
|
||||||
try {
|
})) return;
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// POST /restore — restore a backup; the current state is backed up first,
|
// GET /branding + POST /logo — handled in scripts/cms-logo.js.
|
||||||
// so the restore itself is reversible. Schema validation guards against
|
if (handleLogoRoutes({
|
||||||
// restoring a structurally broken backup.
|
req, res, u,
|
||||||
if (req.method === 'POST' && u.pathname === '/restore') {
|
publicDir: path.join(__dirname, 'proto', 'public'),
|
||||||
const backup = safeBackupName(activeFile, u.searchParams.get('backup') || '');
|
backupDir: BACKUP_DIR,
|
||||||
if (!backup) {
|
writeAudit, clientAddress, user: CMS_USER,
|
||||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
logoPage: () => LOGO_PAGE(CSRF_TOKEN),
|
||||||
res.end(JSON.stringify({ ok: false, error: 'Érvénytelen mentésnév.' }));
|
})) return;
|
||||||
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 / — editor UI
|
// GET / — editor UI
|
||||||
let message = null;
|
let message = null;
|
||||||
|
|||||||
@@ -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).
|
- **➕ Ú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.
|
- 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
|
### 🕘 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).
|
- Az alsó sáv **🕘 Verziók** gombja megnyitja az éppen szerkesztett fájl mentéseit (minden Mentés automatikus másolatot készít).
|
||||||
|
|||||||
@@ -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) => `<!DOCTYPE html>
|
||||||
|
<html lang="hu">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>mozdIT — Logó kezelése</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.6; 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: 720px; margin: 0 auto; padding: 28px 24px; }
|
||||||
|
.note { color: #94a3b8; font-size: 14px; margin-bottom: 22px; }
|
||||||
|
.card { background: #1a2035; border: 1px solid #2d3748; border-radius: 12px; padding: 20px 22px; margin-bottom: 18px; }
|
||||||
|
.card h2 { font-size: 16px; color: #93c5fd; margin-bottom: 4px; }
|
||||||
|
.card .where { color: #64748b; font-size: 13px; margin-bottom: 14px; }
|
||||||
|
.preview { background: repeating-conic-gradient(#1e293b 0% 25%, #0f1420 0% 50%) 50% / 22px 22px; border: 1px solid #2d3748; border-radius: 10px; padding: 16px; margin-bottom: 14px; text-align: center; min-height: 90px; }
|
||||||
|
.preview img { max-width: 100%; max-height: 72px; }
|
||||||
|
input[type=file] { color: #94a3b8; font-size: 14px; margin-bottom: 12px; width: 100%; }
|
||||||
|
.meta { font-size: 13px; color: #94a3b8; min-height: 20px; margin-bottom: 12px; }
|
||||||
|
button { background: linear-gradient(135deg,#3b82f6,#8b5cf6); color: #fff; border: none; border-radius: 8px; padding: 10px 22px; font-size: 14px; font-weight: 700; cursor: pointer; }
|
||||||
|
button:hover { filter: brightness(1.1); }
|
||||||
|
button:disabled { opacity: .5; cursor: wait; }
|
||||||
|
.msg { font-size: 14px; margin-top: 12px; min-height: 20px; }
|
||||||
|
.ok { color: #6ee7b7; } .err { color: #fca5a5; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<h1>🎨 Logó kezelése</h1>
|
||||||
|
<a href="/">← Vissza a szerkesztőhöz</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<p class="note">Csak <strong>PNG</strong> 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 <strong>weboldalon a Publikálás (deploy) után</strong> jelenik meg. Ajánlott átlátszó háttérű PNG a sötét fejléchez.</p>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Weboldal fejléc logója (szöveges)</h2>
|
||||||
|
<p class="where">Használat: weboldal fejléc — jelenlegi fájl: /${LOGO_TARGETS.header}</p>
|
||||||
|
<div class="preview"><img id="prev-header" src="/logo.png?variant=header&t=${Date.now()}" alt="fejléc logó előnézet"></div>
|
||||||
|
<input type="file" id="file-header" accept="image/png">
|
||||||
|
<div class="meta" id="meta-header"></div>
|
||||||
|
<button onclick="upload('header')">⬆ Fejléc logó cseréje</button>
|
||||||
|
<p class="msg" id="msg-header"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>CMS logó (ikon)</h2>
|
||||||
|
<p class="where">Használat: CMS bejelentkező oldal — jelenlegi fájl: /${LOGO_TARGETS.icon}</p>
|
||||||
|
<div class="preview"><img id="prev-icon" src="/logo.png?t=${Date.now()}" alt="ikon logó előnézet"></div>
|
||||||
|
<input type="file" id="file-icon" accept="image/png">
|
||||||
|
<div class="meta" id="meta-icon"></div>
|
||||||
|
<button onclick="upload('icon')">⬆ Ikon logó cseréje</button>
|
||||||
|
<p class="msg" id="msg-icon"></p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const CSRF_TOKEN = "${csrfToken}";
|
||||||
|
function setMsg(target, text, ok) {
|
||||||
|
const el = document.getElementById('msg-' + target);
|
||||||
|
el.textContent = text;
|
||||||
|
el.className = 'msg ' + (ok ? 'ok' : 'err');
|
||||||
|
}
|
||||||
|
async function upload(target) {
|
||||||
|
const file = document.getElementById('file-' + target).files[0];
|
||||||
|
const msg = t => setMsg(target, t, false);
|
||||||
|
if (!file) { msg('Először válassz PNG fájlt.'); return; }
|
||||||
|
if (file.type !== 'image/png') { msg('Csak PNG fájl tölthető fel.'); return; }
|
||||||
|
if (file.size > 1024 * 1024) { msg('A fájl nagyobb, mint 1 MB.'); return; }
|
||||||
|
const btn = event.target; btn.disabled = true;
|
||||||
|
try {
|
||||||
|
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||||
|
const res = await fetch('/logo?target=' + target, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'image/png', 'X-CSRF-Token': CSRF_TOKEN },
|
||||||
|
body: bytes
|
||||||
|
});
|
||||||
|
if (res.status === 401) { location.href = '/login'; return; }
|
||||||
|
const json = await res.json();
|
||||||
|
if (json.ok) {
|
||||||
|
setMsg(target, '✅ Cserélve. (A weboldalon a Publikálás után jelenik meg.)', true);
|
||||||
|
document.getElementById('prev-' + target).src = '/logo.png?t=' + Date.now();
|
||||||
|
} else msg('❌ ' + json.error);
|
||||||
|
} catch (e) { msg('❌ Hálózati hiba'); }
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
document.querySelectorAll('input[type=file]').forEach(inp => {
|
||||||
|
inp.addEventListener('change', () => {
|
||||||
|
const f = inp.files[0];
|
||||||
|
const meta = document.getElementById('meta-' + inp.id.replace('file-', ''));
|
||||||
|
if (f) meta.textContent = f.name + ' — ' + (f.size / 1024).toFixed(1) + ' KB';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
module.exports = { LOGO_PAGE };
|
||||||
@@ -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 };
|
||||||
@@ -114,6 +114,7 @@ ${message ? `<div class="toast ${message.type === 'ok' ? 'ok' : 'err'}">${messag
|
|||||||
<a href="${isStaging() ? 'https://stage.mozdit.hu' : 'http://localhost:3000'}" target="_blank" class="preview-link">🔗 Előnézet →</a>
|
<a href="${isStaging() ? '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>
|
<a href="/guide" target="_blank" class="preview-link">❓ Súgó</a>
|
||||||
<a href="/versions?file=${activeFile}" target="_blank" class="preview-link">🕘 Verziók</a>
|
<a href="/versions?file=${activeFile}" target="_blank" class="preview-link">🕘 Verziók</a>
|
||||||
|
<a href="/branding" target="_blank" class="preview-link">🎨 Logó</a>
|
||||||
<span class="version-tag" title="Futó kód verziója (git SHA)">v${deployVersion}</span>
|
<span class="version-tag" title="Futó kód verziója (git SHA)">v${deployVersion}</span>
|
||||||
<button class="btn-logout" onclick="logout()">🚪 Kilépés</button>
|
<button class="btn-logout" onclick="logout()">🚪 Kilépés</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ const NO_CHANGES_MARKER = '__NO_CONTENT_CHANGES__';
|
|||||||
function buildPublishCommand(commitMessage) {
|
function buildPublishCommand(commitMessage) {
|
||||||
return [
|
return [
|
||||||
'git add .',
|
'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 diff --cached --quiet && echo ${NO_CHANGES_MARKER} || git commit -m "${commitMessage}")`,
|
||||||
'(git pull --rebase origin main || (git rebase --abort; false))',
|
'(git pull --rebase origin main || (git rebase --abort; false))',
|
||||||
'git push origin main',
|
'git push origin main',
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const ROOT = path.join(__dirname, '..');
|
|||||||
// ── Unit ─────────────────────────────────────────────────────────────────────
|
// ── Unit ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const cmd = buildPublishCommand('content: frissítve a CMS-ből');
|
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.includes('(git pull --rebase origin main || (git rebase --abort; false))'), 'rebase-abort fallback');
|
||||||
assert.ok(cmd.endsWith('git push origin main'), 'push last');
|
assert.ok(cmd.endsWith('git push origin main'), 'push last');
|
||||||
|
|
||||||
|
|||||||
@@ -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 */ }
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user