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: