From f65c22987ff0117b29b669eef79541f0af00cab6 Mon Sep 17 00:00:00 2001 From: Do Siki Date: Sun, 23 Aug 2026 10:22:22 +0200 Subject: [PATCH] feat: partners section on the homepage (logo + URL, CMS upload) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- docs/felhasznaloi-utmutato.md | 7 +++ proto/src/app/page.tsx | 40 +++++++++++++++++ proto/src/content/pages/home.json | 5 +++ proto/src/content/schema.js | 1 + proto/src/content/types.ts | 9 ++++ scripts/cms-logo-client.js | 35 +++++++++++++++ scripts/cms-logo-page.js | 15 +++++++ scripts/cms-logo.js | 66 ++++++++++++++++++++++++++++- scripts/test-content-editor-logo.js | 38 +++++++++++++++++ 9 files changed, 215 insertions(+), 1 deletion(-) diff --git a/docs/felhasznaloi-utmutato.md b/docs/felhasznaloi-utmutato.md index 5d0d084..3b851d2 100644 --- a/docs/felhasznaloi-utmutato.md +++ b/docs/felhasznaloi-utmutato.md @@ -66,10 +66,17 @@ A dokumentum a repó része, és **folyamatosan karbantartott**: minden funkció - 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**. +- **Partner logó feltöltése**: ugyanitt — a fájlnév megadása után a feltöltött PNG a `/partners/…` elérési útra kerül; ezt az utat másold a partner **logo** mezőjébe. - 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. +### 🤝 Partnerek + +- A kezdőlap **„Partnereink"** szekciója a Szolgáltatások alatt jelenik meg (logó + név + hivatkozás). +- A **Kezdőlap** fülön a `partners.items` listában szerkeszthetők: `name` (név), `url` (hivatkozás), `logo` (elérési út, pl. `/partners/acme.png`). +- Új logó: a **🎨 Logó** oldalon töltsd fel, a visszaadott utat illeszd a `logo` mezőbe. + ### ⌨️ Gyorsbillentyűk - **Ctrl/Cmd + S** — Mentés diff --git a/proto/src/app/page.tsx b/proto/src/app/page.tsx index 2a62627..3dfb149 100755 --- a/proto/src/app/page.tsx +++ b/proto/src/app/page.tsx @@ -1,4 +1,5 @@ import { content } from '@/content' +import Image from 'next/image' const { home: pageContent } = content.pages @@ -210,6 +211,45 @@ export default function Home() { + {/* Partners Section */} + {pageContent.partners.items.length > 0 && ( +
+
+

+ {pageContent.partners.title} +

+

+ {pageContent.partners.subtitle} +

+
+ {pageContent.partners.items.map((partner) => ( + + + {partner.name} + + + {partner.name} + + + ))} +
+
+
+ )} + {/* CTA Section */}
} + partners: { + title: string + subtitle: string + items: Array<{ + name: string + url: string + logo: string + }> + } cta: { title: string subtitle: string diff --git a/scripts/cms-logo-client.js b/scripts/cms-logo-client.js index 29fcf76..8ed217b 100644 --- a/scripts/cms-logo-client.js +++ b/scripts/cms-logo-client.js @@ -278,3 +278,38 @@ async function saveEditedLogo() { saveBtn.textContent = '💾 Szerkesztett logó mentése'; }, 'image/png'); } + +async function uploadPartner() { + const name = document.getElementById('partner-name').value.trim(); + const file = document.getElementById('file-partner').files[0]; + const msg = document.getElementById('msg-partner'); + const pathOut = document.getElementById('path-partner'); + const btn = document.getElementById('btn-partner'); + pathOut.textContent = ''; + if (!name) { msg.textContent = '❌ Adj meg egy fájlnevet.'; msg.className = 'msg err'; return; } + if (!file) { msg.textContent = '❌ Válassz PNG fájlt.'; msg.className = 'msg err'; return; } + if (file.type !== 'image/png') { msg.textContent = '❌ Csak PNG tölthető fel.'; msg.className = 'msg err'; return; } + btn.disabled = true; + try { + const bytes = new Uint8Array(await file.arrayBuffer()); + const res = await fetch('/partner-logo?name=' + encodeURIComponent(name), { + 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) { + msg.textContent = '✅ Feltöltve.'; + msg.className = 'msg ok'; + pathOut.textContent = 'Elérési út: ' + json.path; + } else { + msg.textContent = '❌ ' + json.error; + msg.className = 'msg err'; + } + } catch (e) { + msg.textContent = '❌ Hálózati hiba'; + msg.className = 'msg err'; + } + btn.disabled = false; +} diff --git a/scripts/cms-logo-page.js b/scripts/cms-logo-page.js index f6a1b94..6f59e73 100644 --- a/scripts/cms-logo-page.js +++ b/scripts/cms-logo-page.js @@ -25,6 +25,9 @@ const LOGO_PAGE = (csrfToken) => ` .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; } + .field-label { display: block; font-size: 13px; color: #94a3b8; margin: 12px 0 6px; } + #partner-name { width: 100%; background: #0f1420; border: 1px solid #2d3748; border-radius: 8px; color: #e2e8f0; padding: 9px 12px; font-size: 14px; margin-bottom: 10px; } + .path-out { font-family: monospace; font-size: 13px; color: #6ee7b7; margin-top: 10px; word-break: break-all; } .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%; } @@ -98,6 +101,18 @@ const LOGO_PAGE = (csrfToken) => `

+ +
+

Partner logó feltöltése

+

Használat: a Kezdőlap „Partnereink" szekciójához. A feltöltés után a visszaadott elérési utat másold a partner „logo" mezőjébe (pl. /partners/nev.png).

+ + + +
+ +

+

+
diff --git a/scripts/cms-logo.js b/scripts/cms-logo.js index 159ec7c..397f724 100644 --- a/scripts/cms-logo.js +++ b/scripts/cms-logo.js @@ -34,6 +34,27 @@ function saveLogoAtomically(publicDir, targetKey, buffer, backupDir) { 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 }) { @@ -86,7 +107,50 @@ function handleLogoRoutes({ req, res, u, publicDir, backupDir, writeAudit, clien 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, handleLogoRoutes }; +module.exports = { MAX_LOGO_BYTES, LOGO_TARGETS, isPng, saveLogoAtomically, savePartnerLogo, slugifyName, handleLogoRoutes }; diff --git a/scripts/test-content-editor-logo.js b/scripts/test-content-editor-logo.js index 833b97b..0f66b9e 100644 --- a/scripts/test-content-editor-logo.js +++ b/scripts/test-content-editor-logo.js @@ -132,6 +132,44 @@ async function main() { { method: 'POST', headers: { 'Content-Type': 'image/png', 'X-CSRF-Token': csrf }, body: TINY_PNG }); assert.equal(anon.status, 401); + // 4. partner logo upload (MITHOME-83) + const partnerDir = path.join(ROOT, 'proto', 'public', 'partners'); + const partnerFile = path.join(partnerDir, 'acme.png'); + try { + const up = await fetch(`${BASE}/partner-logo?name=acme`, { + method: 'POST', + headers: { 'Content-Type': 'image/png', 'X-CSRF-Token': csrf, 'Cookie': cookie }, + body: TINY_PNG, + }); + assert.equal(up.status, 200); + const body = await up.json(); + assert.equal(body.ok, true); + assert.equal(body.path, '/partners/acme.png'); + assert.ok(fs.existsSync(partnerFile), 'partner logo file created'); + + // non-PNG → 415 + const bad = await fetch(`${BASE}/partner-logo?name=x`, { + method: 'POST', + headers: { 'Content-Type': 'image/png', 'X-CSRF-Token': csrf, 'Cookie': cookie }, + body: Buffer.from('not a png'), + }); + assert.equal(bad.status, 415); + + // traversal name is sanitized (no path escape) + const trav = await fetch(`${BASE}/partner-logo?name=../evil`, { + method: 'POST', + headers: { 'Content-Type': 'image/png', 'X-CSRF-Token': csrf, 'Cookie': cookie }, + body: TINY_PNG, + }); + assert.equal(trav.status, 200); + const tBody = await trav.json(); + assert.ok(tBody.path.startsWith('/partners/'), 'traversal name is sanitized to a safe slug'); + assert.ok(!tBody.path.includes('..'), 'no traversal in the returned path'); + } finally { + try { fs.unlinkSync(partnerFile); } catch { /* noop */ } + try { fs.rmdirSync(partnerDir); } catch { /* not empty */ } + } + console.log('Content Editor logo upload test: OK'); } finally { fs.writeFileSync(iconPath, originalIcon);