feat: partners section on the homepage (logo + URL, CMS upload)
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

- 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
This commit is contained in:
Do Siki
2026-08-23 10:22:22 +02:00
parent 410b4a2e2f
commit f65c22987f
9 changed files with 215 additions and 1 deletions
+7
View File
@@ -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
+40
View File
@@ -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() {
</div>
</section>
{/* Partners Section */}
{pageContent.partners.items.length > 0 && (
<section className="py-16">
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8">
<h2 className="text-3xl font-bold text-center mb-3" style={{ color: 'var(--color-foreground)' }}>
{pageContent.partners.title}
</h2>
<p className="text-center mb-10" style={{ color: 'var(--color-foreground-muted)' }}>
{pageContent.partners.subtitle}
</p>
<div className="flex flex-wrap justify-center items-center gap-10">
{pageContent.partners.items.map((partner) => (
<a
key={partner.name}
href={partner.url}
target="_blank"
rel="noopener noreferrer"
className="group flex flex-col items-center gap-3 opacity-80 hover:opacity-100 transition-opacity duration-200"
title={partner.name}
>
<span className="relative h-12 w-40">
<Image
src={partner.logo}
alt={partner.name}
fill
sizes="160px"
className="object-contain"
/>
</span>
<span className="text-sm" style={{ color: 'var(--color-foreground-muted)' }}>
{partner.name}
</span>
</a>
))}
</div>
</div>
</section>
)}
{/* CTA Section */}
<section
className="py-20 relative overflow-hidden"
+5
View File
@@ -94,6 +94,11 @@
}
]
},
"partners": {
"title": "Partnereink",
"subtitle": "Akikkel együtt dolgozunk",
"items": []
},
"cta": {
"title": "Kérjen személyre szabott ajánlatot",
"subtitle": "Írja meg röviden, mire van szüksége; segítünk kiválasztani a vállalkozásához illő tárhely-, levelezési vagy domainmegoldást.",
+1
View File
@@ -22,6 +22,7 @@ const schemas = {
hero: object({ title: string, subtitle: string, description: string, trustBullets: array(string), cta: object({ primary: ctaLink, secondary: ctaLink }) }),
about: object({ title: string, usps: array(object({ id: string, title: string, description: string, icon: string })) }),
services: object({ title: string, subtitle: string, items: array(object({ id: string, title: string, description: string, icon: string, features: array(string), ctaText: string })) }),
partners: object({ title: string, subtitle: string, items: array(object({ name: string, url: string, logo: string })) }),
cta: object({ title: string, subtitle: string, button: string }),
serviceFeatures: object({ title: string }),
}),
+9
View File
@@ -54,6 +54,15 @@ export interface HomePageContent {
ctaText: string
}>
}
partners: {
title: string
subtitle: string
items: Array<{
name: string
url: string
logo: string
}>
}
cta: {
title: string
subtitle: string
+35
View File
@@ -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;
}
+15
View File
@@ -25,6 +25,9 @@ const LOGO_PAGE = (csrfToken) => `<!DOCTYPE html>
.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) => `<!DOCTYPE html>
</div>
<p class="msg" id="msg-icon"></p>
</div>
<div class="card">
<h2>Partner logó feltöltése</h2>
<p class="where">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).</p>
<label for="partner-name" class="field-label">Fájlnév (szóközök nélkül, pl. „acme")</label>
<input type="text" id="partner-name" placeholder="acme">
<input type="file" id="file-partner" accept="image/png">
<div class="meta" id="meta-partner"></div>
<button class="btn-save" id="btn-partner" onclick="uploadPartner()">⬆ Partner logó feltöltése</button>
<p class="msg" id="msg-partner"></p>
<p class="path-out" id="path-partner"></p>
</div>
</main>
<!-- Editor Modal -->
+65 -1
View File
@@ -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 };
+38
View File
@@ -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);