diff --git a/privat/CV/andreknie.de/server/index.test.js b/privat/CV/andreknie.de/server/index.test.js index e87442a..a27be55 100644 --- a/privat/CV/andreknie.de/server/index.test.js +++ b/privat/CV/andreknie.de/server/index.test.js @@ -96,4 +96,37 @@ 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() + }) + + it('silently accepts resource honeypot submissions', 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', + company_website: 'https://bot.example.invalid', + }), + }) + + expect(response.status).toBe(201) + expect((await response.json()).ok).toBe(true) + }) }) diff --git a/privat/CV/andreknie.de/server/routes/resource-download.js b/privat/CV/andreknie.de/server/routes/resource-download.js index cf89800..c953018 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() @@ -34,26 +26,18 @@ router.post('/', resourceLimiter, antiSpam, async (req, res) => { if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) errors.email = 'Gültige 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..6003e18 100644 --- a/privat/CV/andreknie.de/server/services/mailer.js +++ b/privat/CV/andreknie.de/server/services/mailer.js @@ -59,20 +59,30 @@ 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 = buildStakeholderMessage(type, data) 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, }) } +export function buildStakeholderMessage(type, data) { + const fields = Object.entries(data).map(([key, val]) => `${key}: ${val}`) + if (type !== 'resource-download') return fields.join('\n') + + return [ + 'Bitte die angefragten Inhalte prüfen und bei positiver Prüfung ein aktuelles Profil von André verschicken.', + '', + ...fields, + ].join('\n') +} + export { STAKEHOLDER_EMAIL, BASE_URL } /** diff --git a/privat/CV/andreknie.de/server/services/mailer.test.js b/privat/CV/andreknie.de/server/services/mailer.test.js index 1e8abf1..798e5bf 100644 --- a/privat/CV/andreknie.de/server/services/mailer.test.js +++ b/privat/CV/andreknie.de/server/services/mailer.test.js @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest' -import { isMailerReady, MailerNotReadyError, sendConfirmationEmail } from './mailer.js' +import { + buildStakeholderMessage, + isMailerReady, + MailerNotReadyError, + sendConfirmationEmail, +} from './mailer.js' describe('mailer readiness', () => { it('reports missing SMTP configuration without exposing credentials', async () => { @@ -10,3 +15,24 @@ describe('mailer readiness', () => { .rejects.toMatchObject({ code: 'MAILER_NOT_READY' }) }) }) + +describe('stakeholder notification content', () => { + const data = { + name: 'Synthetic Test', + email: 'test@example.invalid', + } + + it('adds the manual profile instruction to resource requests', () => { + const message = buildStakeholderMessage('resource-download', data) + + expect(message).toContain('ein aktuelles Profil von André verschicken') + expect(message).toContain('name: Synthetic Test') + }) + + it('does not add the resource instruction to contact notifications', () => { + const message = buildStakeholderMessage('contact', data) + + expect(message).not.toContain('ein aktuelles Profil von André verschicken') + expect(message).toBe('name: Synthetic Test\nemail: test@example.invalid') + }) +}) 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 (
-