From 8236cce85c89e06428898abf7166275e2fe798d0 Mon Sep 17 00:00:00 2001 From: DoctoDre Date: Tue, 28 Jul 2026 20:54:08 +0200 Subject: [PATCH] fix(andreknie.de): improve mobile dock and resource requests --- privat/CV/andreknie.de/server/index.test.js | 16 +++ .../server/routes/resource-download.js | 52 +++------ .../CV/andreknie.de/server/services/mailer.js | 11 +- .../src/components/BottomDock.css | 21 +++- .../src/components/BottomDock.jsx | 17 ++- .../src/components/BottomDock.test.jsx | 30 +++++ privat/CV/andreknie.de/src/index.css | 10 ++ .../CV/andreknie.de/src/pages/Datenschutz.jsx | 4 +- privat/CV/andreknie.de/src/pages/Home.css | 6 + .../CV/andreknie.de/src/pages/Resources.jsx | 105 ++++++++++++++++-- .../CV/andreknie.de/src/utils/validation.js | 2 +- .../andreknie.de/src/utils/validation.test.js | 6 + 12 files changed, 219 insertions(+), 61 deletions(-) create mode 100644 privat/CV/andreknie.de/src/components/BottomDock.test.jsx diff --git a/privat/CV/andreknie.de/server/index.test.js b/privat/CV/andreknie.de/server/index.test.js index e87442a..487b784 100644 --- a/privat/CV/andreknie.de/server/index.test.js +++ b/privat/CV/andreknie.de/server/index.test.js @@ -96,4 +96,20 @@ describe('backend mail safety', () => { expect(await reserveToken(token)).toMatchObject({ status: 'processing' }) await releaseToken(token) }) + + it('rejects unknown resources before any notification is sent', async () => { + const response = await fetch(`${baseUrl}/api/resource-download`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + name: 'Test', + email: 'test@example.invalid', + company: 'Synthetic Test', + resource_id: 'does-not-exist', + }), + }) + + expect(response.status).toBe(400) + expect((await response.json()).errors.resource_id).toBeTruthy() + }) }) diff --git a/privat/CV/andreknie.de/server/routes/resource-download.js b/privat/CV/andreknie.de/server/routes/resource-download.js index cf89800..70c641a 100644 --- a/privat/CV/andreknie.de/server/routes/resource-download.js +++ b/privat/CV/andreknie.de/server/routes/resource-download.js @@ -1,27 +1,19 @@ import { Router } from 'express' -import { readFileSync, writeFileSync, existsSync } from 'fs' +import { existsSync, readdirSync } from 'fs' import { join, dirname } from 'path' import { fileURLToPath } from 'url' -import { Mutex } from 'async-mutex' import { resourceLimiter } from '../middleware/rateLimiter.js' import { antiSpam } from '../middleware/antiSpam.js' +import { sendStakeholderNotification, MailerNotReadyError } from '../services/mailer.js' const __dirname = dirname(fileURLToPath(import.meta.url)) -const LEADS_FILE = join(__dirname, '..', 'data', 'leads.json') -const leadsMutex = new Mutex() +const RESOURCES_DIR = join(__dirname, '..', '..', 'public', 'resources') -// Only alphanumeric + hyphen resource IDs are allowed. This prevents path -// traversal (e.g. "../../etc/passwd") when building the download URL. -const RESOURCE_ID_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/ - -function loadLeads() { - if (!existsSync(LEADS_FILE)) return [] - try { return JSON.parse(readFileSync(LEADS_FILE, 'utf-8')) } - catch { return [] } -} - -function saveLeads(leads) { - writeFileSync(LEADS_FILE, JSON.stringify(leads, null, 2), 'utf-8') +export function getAvailableResourceIds() { + if (!existsSync(RESOURCES_DIR)) return [] + return readdirSync(RESOURCES_DIR, { withFileTypes: true }) + .filter(entry => entry.isFile() && entry.name.endsWith('.pdf') && entry.name !== 'speaker-cv-andre-knie.pdf') + .map(entry => entry.name.slice(0, -4)) } const router = Router() @@ -31,29 +23,21 @@ router.post('/', resourceLimiter, antiSpam, async (req, res) => { const errors = {} if (!name || name.trim().length === 0) errors.name = 'Name ist erforderlich.' if (name && name.length > 100) errors.name = 'Max. 100 Zeichen.' - if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) errors.email = 'Gültige E-Mail erforderlich.' + if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) errors.email = 'Gueltige E-Mail erforderlich.' if (!company || company.trim().length === 0) errors.company = 'Unternehmen ist erforderlich.' if (company && company.length > 150) errors.company = 'Max. 150 Zeichen.' - if (!resource_id || !RESOURCE_ID_PATTERN.test(resource_id)) errors.resource_id = 'Ungültige Resource-ID.' - - if (Object.keys(errors).length > 0) { - return res.status(400).json({ errors }) + if (!resource_id || !getAvailableResourceIds().includes(resource_id)) { + errors.resource_id = 'Diese Ressource ist derzeit nicht verfügbar.' } - await leadsMutex.runExclusive(async () => { - const leads = loadLeads() - leads.push({ - name, - email, - company, - resource_id, - timestamp: new Date().toISOString(), - }) - saveLeads(leads) - }) + if (Object.keys(errors).length > 0) return res.status(400).json({ errors }) - // Return download URL (resource files are in public/resources/) - res.json({ ok: true, download_url: `/resources/${resource_id}.pdf` }) + try { + await sendStakeholderNotification('resource-download', { name, email, company, resource_id }) + res.status(202).json({ ok: true, message: 'Danke! Wir prüfen die Anfrage und melden uns bei dir.' }) + } catch (error) { + res.status(error instanceof MailerNotReadyError ? 503 : 502).json({ error: 'Die Anfrage konnte derzeit nicht übermittelt werden.' }) + } }) export default router diff --git a/privat/CV/andreknie.de/server/services/mailer.js b/privat/CV/andreknie.de/server/services/mailer.js index 147c5da..300c6a5 100644 --- a/privat/CV/andreknie.de/server/services/mailer.js +++ b/privat/CV/andreknie.de/server/services/mailer.js @@ -59,15 +59,18 @@ export async function sendStakeholderNotification(type, data) { const subjects = { contact: `Neue Kontaktanfrage von ${data.name}`, 'talk-request': `Neue Vortragsanfrage von ${data.name}`, + 'resource-download': `Ressourcenanfrage von ${data.name}`, } - const body = Object.entries(data) - .map(([key, val]) => `${key}: ${val}`) - .join('\n') + const body = [ + 'Bitte die angefragten Inhalte prüfen und bei positiver Prüfung ein aktuelles Profil von André verschicken.', + '', + ...Object.entries(data).map(([key, val]) => `${key}: ${val}`), + ].join('\n') await t.sendMail({ from: SMTP_USER, - to: STAKEHOLDER_EMAIL, + to: type === 'resource-download' ? 'kontakt@d-hive.de' : STAKEHOLDER_EMAIL, subject: subjects[type] || `Neue Anfrage (${type})`, text: body, }) diff --git a/privat/CV/andreknie.de/src/components/BottomDock.css b/privat/CV/andreknie.de/src/components/BottomDock.css index 4dc9043..48a008b 100644 --- a/privat/CV/andreknie.de/src/components/BottomDock.css +++ b/privat/CV/andreknie.de/src/components/BottomDock.css @@ -114,6 +114,23 @@ } @media (max-width: 600px) { - .dock { gap: 6px; padding: 6px 8px; } - .dock-item { width: 38px; height: 38px; } + .dock-container { + bottom: max(12px, env(safe-area-inset-bottom)); + max-width: calc(100vw - 24px); + } + + .dock { + gap: 6px; + padding: 6px 8px; + max-width: 100%; + } + + .dock-item-wrapper-secondary { + display: none; + } + + .dock-item { + width: 44px; + height: 44px; + } } diff --git a/privat/CV/andreknie.de/src/components/BottomDock.jsx b/privat/CV/andreknie.de/src/components/BottomDock.jsx index 178a981..65cb610 100644 --- a/privat/CV/andreknie.de/src/components/BottomDock.jsx +++ b/privat/CV/andreknie.de/src/components/BottomDock.jsx @@ -10,22 +10,27 @@ export default function BottomDock() { { label: 'Home', path: '/', icon: }, { label: 'Artikel', path: '/artikel', icon: }, { label: 'Kniepunkt', path: '/kniepunkt', icon: K }, - { label: 'Podcast', path: '/podcast', icon: }, + { label: 'Podcast', path: '/podcast', icon: , secondary: true }, { label: 'Speaking', path: '/speaking', icon: }, - { label: 'Beratung', path: '/consulting', icon: }, - { label: 'Über mich', path: '/ueber-mich', icon: }, + { label: 'Beratung', path: '/consulting', icon: , secondary: true }, + { label: 'Über mich', path: '/ueber-mich', icon: , secondary: true }, { label: 'Kontakt', path: '/kontakt', icon: }, ] return (
-