// 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 };