Files
websitedev/scripts/cms-logo.js
T
Do Siki f65c22987f
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
feat: partners section on the homepage (logo + URL, CMS upload)
- home.json gains a partners block (title/subtitle/items: name, url, logo),
  rendered on the homepage under the services section (next/image logos
  linking out with rel=noopener)
- CMS: partner logos uploadable from the 🎨 Logó page via POST /partner-logo
  (PNG, 1 MiB cap, filename sanitized to a slug, written to public/partners/)
- schema + types extended; guide updated

Closes MITHOME-83
2026-08-23 10:22:22 +02:00

157 lines
6.4 KiB
JavaScript

// 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: partner logos are a variable set — the filename comes from the editor,
// so it must be sanitized to a safe slug (no traversal, no separators).
function slugifyName(raw) {
return String(raw)
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 64);
}
function savePartnerLogo(publicDir, filename, buffer) {
const slug = slugifyName(filename) || `partner-${Date.now()}`;
const dir = path.join(publicDir, 'partners');
fs.mkdirSync(dir, { recursive: true, mode: 0o755 });
const targetFile = path.join(dir, `${slug}.png`);
const tempFile = `${targetFile}.${process.pid}.${Date.now()}.tmp`;
fs.writeFileSync(tempFile, buffer, { mode: 0o644 });
fs.renameSync(tempFile, targetFile);
return `/partners/${slug}.png`;
}
// 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;
}
if (req.method === 'POST' && u.pathname === '/partner-logo') {
const filename = u.searchParams.get('name') || '';
if (!slugifyName(filename)) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: 'Adj meg egy érvényes fájlnevet.' }));
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('partner_logo_upload', { clientAddress, user, 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('partner_logo_upload', { clientAddress, user, 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 publicPath = savePartnerLogo(publicDir, filename, buffer);
writeAudit('partner_logo_upload', { clientAddress, user, result: 'ok', path: publicPath });
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true, path: publicPath }));
} catch (e) {
writeAudit('partner_logo_upload', { clientAddress, user, 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, savePartnerLogo, slugifyName, handleLogoRoutes };