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
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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user