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

- 🎨 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:
Do Siki
2026-08-19 12:46:55 +02:00
parent fe4a6a1e92
commit d2ee13bb91
8 changed files with 381 additions and 51 deletions
+23 -50
View File
@@ -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;