fix(andreknie.de): harden personal data lifecycle
This commit is contained in:
@@ -8,6 +8,7 @@ import talkRequestRouter from './routes/talk-request.js'
|
|||||||
import newsletterRouter from './routes/newsletter.js'
|
import newsletterRouter from './routes/newsletter.js'
|
||||||
import resourceDownloadRouter from './routes/resource-download.js'
|
import resourceDownloadRouter from './routes/resource-download.js'
|
||||||
import speakerCvRouter from './routes/speaker-cv.js'
|
import speakerCvRouter from './routes/speaker-cv.js'
|
||||||
|
import { isMailerReady } from './services/mailer.js'
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
const PORT = process.env.PORT || 3003
|
const PORT = process.env.PORT || 3003
|
||||||
@@ -30,7 +31,11 @@ app.use(express.json({ limit: '10kb' }))
|
|||||||
|
|
||||||
// Routes
|
// Routes
|
||||||
app.get('/api/health', (_req, res) => {
|
app.get('/api/health', (_req, res) => {
|
||||||
res.json({ status: 'ok', time: new Date().toISOString() })
|
res.json({
|
||||||
|
status: isMailerReady() ? 'ok' : 'degraded',
|
||||||
|
mailer: { ready: isMailerReady() },
|
||||||
|
time: new Date().toISOString(),
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
app.use('/api/contact', contactRouter)
|
app.use('/api/contact', contactRouter)
|
||||||
@@ -45,6 +50,10 @@ app.use((err, _req, res, _next) => {
|
|||||||
res.status(500).json({ error: 'Interner Serverfehler.' })
|
res.status(500).json({ error: 'Interner Serverfehler.' })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== 'test') {
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`[andreknie.de Backend] Running on port ${PORT}`)
|
console.log(`[andreknie.de Backend] Running on port ${PORT}`)
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export { app }
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { beforeAll, afterAll, describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
let server
|
||||||
|
let baseUrl
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const { app } = await import('./index.js')
|
||||||
|
server = app.listen(0)
|
||||||
|
await new Promise(resolve => server.once('listening', resolve))
|
||||||
|
baseUrl = `http://127.0.0.1:${server.address().port}`
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(() => server?.close())
|
||||||
|
|
||||||
|
describe('backend mail safety', () => {
|
||||||
|
it('reports degraded health without SMTP configuration', async () => {
|
||||||
|
const response = await fetch(`${baseUrl}/api/health`)
|
||||||
|
const body = await response.json()
|
||||||
|
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
expect(body.status).toBe('degraded')
|
||||||
|
expect(body.mailer).toEqual({ ready: false })
|
||||||
|
expect(JSON.stringify(body)).not.toContain('SMTP_PASS')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not report success when contact mail cannot be sent', async () => {
|
||||||
|
const response = await fetch(`${baseUrl}/api/contact`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: 'Test', email: 'test@example.invalid', message: 'Synthetic test' }),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(response.status).toBe(503)
|
||||||
|
expect((await response.json()).error).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Router } from 'express'
|
import { Router } from 'express'
|
||||||
import { contactLimiter } from '../middleware/rateLimiter.js'
|
import { contactLimiter } from '../middleware/rateLimiter.js'
|
||||||
import { antiSpam } from '../middleware/antiSpam.js'
|
import { antiSpam } from '../middleware/antiSpam.js'
|
||||||
import { createToken, verifyToken } from '../services/confirmationToken.js'
|
import { createToken, reserveToken, completeToken, releaseToken } from '../services/confirmationToken.js'
|
||||||
import { sendConfirmationEmail, sendStakeholderNotification } from '../services/mailer.js'
|
import { sendConfirmationEmail, sendStakeholderNotification, MailerNotReadyError } from '../services/mailer.js'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
|
|
||||||
@@ -11,27 +11,34 @@ router.post('/', contactLimiter, antiSpam, async (req, res) => {
|
|||||||
const errors = {}
|
const errors = {}
|
||||||
if (!name || name.trim().length === 0) errors.name = 'Name ist erforderlich.'
|
if (!name || name.trim().length === 0) errors.name = 'Name ist erforderlich.'
|
||||||
if (name && name.length > 100) errors.name = 'Max. 100 Zeichen.'
|
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 (!message || message.trim().length === 0) errors.message = 'Nachricht ist erforderlich.'
|
if (!message || message.trim().length === 0) errors.message = 'Nachricht ist erforderlich.'
|
||||||
if (message && message.length > 2000) errors.message = 'Max. 2000 Zeichen.'
|
if (message && message.length > 2000) errors.message = 'Max. 2000 Zeichen.'
|
||||||
|
|
||||||
if (Object.keys(errors).length > 0) {
|
if (Object.keys(errors).length > 0) return res.status(400).json({ errors })
|
||||||
return res.status(400).json({ errors })
|
|
||||||
}
|
|
||||||
|
|
||||||
|
try {
|
||||||
const token = await createToken('contact', { name, email, message }, 24)
|
const token = await createToken('contact', { name, email, message }, 24)
|
||||||
await sendConfirmationEmail(email, 'contact', token)
|
await sendConfirmationEmail(email, 'contact', token)
|
||||||
res.status(201).json({ ok: true, message: 'Bestätigungs-E-Mail gesendet.' })
|
res.status(201).json({ ok: true, message: 'Bestaetigungs-E-Mail gesendet.' })
|
||||||
|
} catch (error) {
|
||||||
|
res.status(error instanceof MailerNotReadyError ? 503 : 502).json({ error: 'Die Anfrage konnte derzeit nicht versendet werden.' })
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
router.get('/confirm/:token', async (req, res) => {
|
router.get('/confirm/:token', async (req, res) => {
|
||||||
const baseUrl = process.env.BASE_URL || ''
|
const baseUrl = process.env.BASE_URL || ''
|
||||||
const entry = await verifyToken(req.params.token)
|
const entry = await reserveToken(req.params.token)
|
||||||
if (!entry) {
|
if (!entry) return res.redirect(baseUrl + '/bestaetigung?status=error')
|
||||||
return res.redirect(baseUrl + '/bestaetigung?status=error')
|
|
||||||
}
|
try {
|
||||||
await sendStakeholderNotification('contact', entry.data)
|
await sendStakeholderNotification('contact', entry.data)
|
||||||
|
await completeToken(req.params.token)
|
||||||
res.redirect(baseUrl + '/bestaetigung?status=success')
|
res.redirect(baseUrl + '/bestaetigung?status=success')
|
||||||
|
} catch (error) {
|
||||||
|
await releaseToken(req.params.token)
|
||||||
|
res.status(error instanceof MailerNotReadyError ? 503 : 502).redirect(baseUrl + '/bestaetigung?status=error')
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { fileURLToPath } from 'url'
|
|||||||
import { Mutex } from 'async-mutex'
|
import { Mutex } from 'async-mutex'
|
||||||
import { newsletterLimiter } from '../middleware/rateLimiter.js'
|
import { newsletterLimiter } from '../middleware/rateLimiter.js'
|
||||||
import { antiSpam } from '../middleware/antiSpam.js'
|
import { antiSpam } from '../middleware/antiSpam.js'
|
||||||
import { createToken, verifyToken } from '../services/confirmationToken.js'
|
import { createToken, reserveToken, completeToken, releaseToken } from '../services/confirmationToken.js'
|
||||||
import { sendConfirmationEmail } from '../services/mailer.js'
|
import { sendConfirmationEmail, MailerNotReadyError } from '../services/mailer.js'
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
const SUBS_FILE = join(__dirname, '..', 'data', 'subscribers.json')
|
const SUBS_FILE = join(__dirname, '..', 'data', 'subscribers.json')
|
||||||
@@ -27,38 +27,46 @@ const router = Router()
|
|||||||
router.post('/', newsletterLimiter, antiSpam, async (req, res) => {
|
router.post('/', newsletterLimiter, antiSpam, async (req, res) => {
|
||||||
const { email } = req.body
|
const { email } = req.body
|
||||||
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||||
return res.status(400).json({ errors: { email: 'Gültige E-Mail erforderlich.' } })
|
return res.status(400).json({ errors: { email: 'Gueltige E-Mail erforderlich.' } })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for existing subscription
|
|
||||||
const subs = loadSubscribers()
|
const subs = loadSubscribers()
|
||||||
if (subs.find(s => s.email === email && s.active)) {
|
if (subs.find(s => s.email === email && s.active)) {
|
||||||
return res.status(409).json({ error: 'Diese E-Mail ist bereits abonniert.' })
|
return res.status(409).json({ error: 'Diese E-Mail ist bereits abonniert.' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
const token = await createToken('newsletter', { email }, 48)
|
const token = await createToken('newsletter', { email }, 48)
|
||||||
await sendConfirmationEmail(email, 'newsletter', token)
|
await sendConfirmationEmail(email, 'newsletter', token)
|
||||||
res.status(201).json({ ok: true, message: 'Bestätigungs-E-Mail gesendet.' })
|
res.status(201).json({ ok: true, message: 'Bestaetigungs-E-Mail gesendet.' })
|
||||||
|
} catch (error) {
|
||||||
|
res.status(error instanceof MailerNotReadyError ? 503 : 502).json({ error: 'Die Anfrage konnte derzeit nicht versendet werden.' })
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
router.get('/confirm/:token', async (req, res) => {
|
router.get('/confirm/:token', async (req, res) => {
|
||||||
const baseUrl = process.env.BASE_URL || ''
|
const baseUrl = process.env.BASE_URL || ''
|
||||||
const entry = await verifyToken(req.params.token)
|
const entry = await reserveToken(req.params.token)
|
||||||
if (!entry) {
|
if (!entry) return res.redirect(baseUrl + '/bestaetigung?status=error')
|
||||||
return res.redirect(baseUrl + '/bestaetigung?status=error')
|
|
||||||
}
|
|
||||||
|
|
||||||
|
try {
|
||||||
await subsMutex.runExclusive(async () => {
|
await subsMutex.runExclusive(async () => {
|
||||||
const subs = loadSubscribers()
|
const subs = loadSubscribers()
|
||||||
|
if (!subs.find(s => s.email === entry.data.email && s.active)) {
|
||||||
subs.push({
|
subs.push({
|
||||||
email: entry.data.email,
|
email: entry.data.email,
|
||||||
subscribedAt: new Date().toISOString(),
|
subscribedAt: new Date().toISOString(),
|
||||||
active: true,
|
active: true,
|
||||||
})
|
})
|
||||||
saveSubscribers(subs)
|
saveSubscribers(subs)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
await completeToken(req.params.token)
|
||||||
res.redirect(baseUrl + '/bestaetigung?status=success')
|
res.redirect(baseUrl + '/bestaetigung?status=success')
|
||||||
|
} catch {
|
||||||
|
await releaseToken(req.params.token)
|
||||||
|
res.status(502).redirect(baseUrl + '/bestaetigung?status=error')
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
@@ -38,15 +38,22 @@ router.post('/', resourceLimiter, antiSpam, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const lead = { name, email, message, timestamp: new Date().toISOString() }
|
const lead = { name, email, message, timestamp: new Date().toISOString() }
|
||||||
|
try {
|
||||||
|
const result = await sendSpeakerCv(email, lead, PDF_PATH)
|
||||||
|
if (result.pdfMissing) {
|
||||||
|
return res.status(502).json({ error: 'Der Speaker CV ist derzeit nicht verfügbar.' })
|
||||||
|
}
|
||||||
|
|
||||||
await leadsMutex.runExclusive(async () => {
|
await leadsMutex.runExclusive(async () => {
|
||||||
const leads = loadLeads()
|
const leads = loadLeads()
|
||||||
leads.push(lead)
|
leads.push(lead)
|
||||||
saveLeads(leads)
|
saveLeads(leads)
|
||||||
})
|
})
|
||||||
|
|
||||||
await sendSpeakerCv(email, lead, PDF_PATH)
|
|
||||||
|
|
||||||
res.status(201).json({ ok: true, message: 'Danke! Der Speaker CV ist auf dem Weg in dein Postfach.' })
|
res.status(201).json({ ok: true, message: 'Danke! Der Speaker CV ist auf dem Weg in dein Postfach.' })
|
||||||
|
} catch {
|
||||||
|
res.status(503).json({ error: 'Der Versand ist derzeit nicht verfügbar.' })
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Router } from 'express'
|
import { Router } from 'express'
|
||||||
import { talkRequestLimiter } from '../middleware/rateLimiter.js'
|
import { talkRequestLimiter } from '../middleware/rateLimiter.js'
|
||||||
import { antiSpam } from '../middleware/antiSpam.js'
|
import { antiSpam } from '../middleware/antiSpam.js'
|
||||||
import { createToken, verifyToken } from '../services/confirmationToken.js'
|
import { createToken, reserveToken, completeToken, releaseToken } from '../services/confirmationToken.js'
|
||||||
import { sendConfirmationEmail, sendStakeholderNotification } from '../services/mailer.js'
|
import { sendConfirmationEmail, sendStakeholderNotification, MailerNotReadyError } from '../services/mailer.js'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
|
|
||||||
@@ -10,29 +10,36 @@ router.post('/', talkRequestLimiter, antiSpam, async (req, res) => {
|
|||||||
const { name, email, event_name, topic, message } = req.body
|
const { name, email, event_name, topic, message } = req.body
|
||||||
const errors = {}
|
const errors = {}
|
||||||
if (!name || name.trim().length === 0) errors.name = 'Name ist erforderlich.'
|
if (!name || name.trim().length === 0) errors.name = 'Name ist erforderlich.'
|
||||||
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 (!event_name || event_name.trim().length === 0) errors.event_name = 'Eventname ist erforderlich.'
|
if (!event_name || event_name.trim().length === 0) errors.event_name = 'Eventname ist erforderlich.'
|
||||||
if (!topic || topic.trim().length === 0) errors.topic = 'Thema ist erforderlich.'
|
if (!topic || topic.trim().length === 0) errors.topic = 'Thema ist erforderlich.'
|
||||||
if (!message || message.trim().length === 0) errors.message = 'Nachricht ist erforderlich.'
|
if (!message || message.trim().length === 0) errors.message = 'Nachricht ist erforderlich.'
|
||||||
if (message && message.length > 2000) errors.message = 'Max. 2000 Zeichen.'
|
if (message && message.length > 2000) errors.message = 'Max. 2000 Zeichen.'
|
||||||
|
|
||||||
if (Object.keys(errors).length > 0) {
|
if (Object.keys(errors).length > 0) return res.status(400).json({ errors })
|
||||||
return res.status(400).json({ errors })
|
|
||||||
}
|
|
||||||
|
|
||||||
|
try {
|
||||||
const token = await createToken('talk-request', { name, email, event_name, topic, message }, 48)
|
const token = await createToken('talk-request', { name, email, event_name, topic, message }, 48)
|
||||||
await sendConfirmationEmail(email, 'talk-request', token)
|
await sendConfirmationEmail(email, 'talk-request', token)
|
||||||
res.status(201).json({ ok: true, message: 'Bestätigungs-E-Mail gesendet.' })
|
res.status(201).json({ ok: true, message: 'Bestaetigungs-E-Mail gesendet.' })
|
||||||
|
} catch (error) {
|
||||||
|
res.status(error instanceof MailerNotReadyError ? 503 : 502).json({ error: 'Die Anfrage konnte derzeit nicht versendet werden.' })
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
router.get('/confirm/:token', async (req, res) => {
|
router.get('/confirm/:token', async (req, res) => {
|
||||||
const baseUrl = process.env.BASE_URL || ''
|
const baseUrl = process.env.BASE_URL || ''
|
||||||
const entry = await verifyToken(req.params.token)
|
const entry = await reserveToken(req.params.token)
|
||||||
if (!entry) {
|
if (!entry) return res.redirect(baseUrl + '/bestaetigung?status=error')
|
||||||
return res.redirect(baseUrl + '/bestaetigung?status=error')
|
|
||||||
}
|
try {
|
||||||
await sendStakeholderNotification('talk-request', entry.data)
|
await sendStakeholderNotification('talk-request', entry.data)
|
||||||
|
await completeToken(req.params.token)
|
||||||
res.redirect(baseUrl + '/bestaetigung?status=success')
|
res.redirect(baseUrl + '/bestaetigung?status=success')
|
||||||
|
} catch (error) {
|
||||||
|
await releaseToken(req.params.token)
|
||||||
|
res.status(error instanceof MailerNotReadyError ? 503 : 502).redirect(baseUrl + '/bestaetigung?status=error')
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ const DATA_DIR = join(__dirname, '..', 'data')
|
|||||||
const TOKENS_FILE = join(DATA_DIR, 'pending-tokens.json')
|
const TOKENS_FILE = join(DATA_DIR, 'pending-tokens.json')
|
||||||
const tokenMutex = new Mutex()
|
const tokenMutex = new Mutex()
|
||||||
|
|
||||||
// Ensure data directory exists
|
|
||||||
mkdirSync(DATA_DIR, { recursive: true })
|
mkdirSync(DATA_DIR, { recursive: true })
|
||||||
|
|
||||||
function loadTokens() {
|
function loadTokens() {
|
||||||
@@ -22,13 +21,6 @@ function saveTokens(tokens) {
|
|||||||
writeFileSync(TOKENS_FILE, JSON.stringify(tokens, null, 2), 'utf-8')
|
writeFileSync(TOKENS_FILE, JSON.stringify(tokens, null, 2), 'utf-8')
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a confirmation token.
|
|
||||||
* @param {string} type - 'contact' | 'talk-request' | 'newsletter'
|
|
||||||
* @param {object} data - The form data to store
|
|
||||||
* @param {number} expiresInHours - Token expiration (default: 48h)
|
|
||||||
* @returns {string} The generated token
|
|
||||||
*/
|
|
||||||
export async function createToken(type, data, expiresInHours = 48) {
|
export async function createToken(type, data, expiresInHours = 48) {
|
||||||
const token = crypto.randomUUID()
|
const token = crypto.randomUUID()
|
||||||
await tokenMutex.runExclusive(async () => {
|
await tokenMutex.runExclusive(async () => {
|
||||||
@@ -39,51 +31,64 @@ export async function createToken(type, data, expiresInHours = 48) {
|
|||||||
data,
|
data,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
expiresAt: new Date(Date.now() + expiresInHours * 60 * 60 * 1000).toISOString(),
|
expiresAt: new Date(Date.now() + expiresInHours * 60 * 60 * 1000).toISOString(),
|
||||||
confirmed: false,
|
status: 'pending',
|
||||||
})
|
})
|
||||||
saveTokens(tokens)
|
saveTokens(tokens)
|
||||||
})
|
})
|
||||||
return token
|
return token
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Reserve a valid token for exactly one confirmation flow. */
|
||||||
* Verify and consume a token.
|
export async function reserveToken(token) {
|
||||||
* @returns {object|null} The stored data if valid, null if expired/invalid
|
|
||||||
*/
|
|
||||||
export async function verifyToken(token) {
|
|
||||||
return await tokenMutex.runExclusive(async () => {
|
return await tokenMutex.runExclusive(async () => {
|
||||||
const tokens = loadTokens()
|
const tokens = loadTokens()
|
||||||
const idx = tokens.findIndex(t => t.token === token && !t.confirmed)
|
const idx = tokens.findIndex(t => t.token === token && (t.status || (t.confirmed ? 'completed' : 'pending')) === 'pending')
|
||||||
if (idx === -1) return null
|
if (idx === -1) return null
|
||||||
|
|
||||||
const entry = tokens[idx]
|
const entry = tokens[idx]
|
||||||
if (new Date(entry.expiresAt) < new Date()) {
|
if (new Date(entry.expiresAt) <= new Date()) {
|
||||||
// Expired — remove it
|
|
||||||
tokens.splice(idx, 1)
|
tokens.splice(idx, 1)
|
||||||
saveTokens(tokens)
|
saveTokens(tokens)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark as confirmed
|
tokens[idx].status = 'processing'
|
||||||
tokens[idx].confirmed = true
|
|
||||||
saveTokens(tokens)
|
saveTokens(tokens)
|
||||||
return entry
|
return entry
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Remove a successfully processed token and its personal payload. */
|
||||||
* Clean up expired tokens (run periodically).
|
export async function completeToken(token) {
|
||||||
*/
|
await tokenMutex.runExclusive(async () => {
|
||||||
|
const tokens = loadTokens()
|
||||||
|
const remaining = tokens.filter(t => t.token !== token)
|
||||||
|
if (remaining.length !== tokens.length) saveTokens(remaining)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return a failed confirmation to pending so it can be retried. */
|
||||||
|
export async function releaseToken(token) {
|
||||||
|
await tokenMutex.runExclusive(async () => {
|
||||||
|
const tokens = loadTokens()
|
||||||
|
const entry = tokens.find(t => t.token === token)
|
||||||
|
if (!entry) return
|
||||||
|
entry.status = 'pending'
|
||||||
|
saveTokens(tokens)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compatibility for code that only needs the reservation behavior.
|
||||||
|
export const verifyToken = reserveToken
|
||||||
|
|
||||||
export async function cleanupExpiredTokens() {
|
export async function cleanupExpiredTokens() {
|
||||||
await tokenMutex.runExclusive(async () => {
|
await tokenMutex.runExclusive(async () => {
|
||||||
const tokens = loadTokens()
|
const tokens = loadTokens()
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const valid = tokens.filter(t => new Date(t.expiresAt) > now || t.confirmed)
|
const valid = tokens.filter(t => !t.confirmed && new Date(t.expiresAt) > now)
|
||||||
if (valid.length !== tokens.length) {
|
if (valid.length !== tokens.length) saveTokens(valid)
|
||||||
saveTokens(valid)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run cleanup every hour
|
const cleanupTimer = setInterval(cleanupExpiredTokens, 60 * 60 * 1000)
|
||||||
setInterval(cleanupExpiredTokens, 60 * 60 * 1000)
|
cleanupTimer.unref?.()
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||||
|
import { existsSync, readFileSync, unlinkSync } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { createToken, reserveToken, completeToken, releaseToken, cleanupExpiredTokens } from './confirmationToken.js'
|
||||||
|
|
||||||
|
const tokensFile = join(process.cwd(), 'server', 'data', 'pending-tokens.json')
|
||||||
|
|
||||||
|
function removeTokensFile() {
|
||||||
|
if (existsSync(tokensFile)) unlinkSync(tokensFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(removeTokensFile)
|
||||||
|
afterEach(removeTokensFile)
|
||||||
|
|
||||||
|
describe('confirmation token lifecycle', () => {
|
||||||
|
it('creates a pending token and reserves it once', async () => {
|
||||||
|
const token = await createToken('contact', { name: 'Test', email: 'test@example.invalid' }, 1)
|
||||||
|
const first = await reserveToken(token)
|
||||||
|
const second = await reserveToken(token)
|
||||||
|
|
||||||
|
expect(first.status).toBe('processing')
|
||||||
|
expect(second).toBeNull()
|
||||||
|
expect(JSON.parse(readFileSync(tokensFile, 'utf8')).find(entry => entry.token === token).status).toBe('processing')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removes an expired token instead of returning it', async () => {
|
||||||
|
const token = await createToken('contact', { email: 'expired@example.invalid' }, -1)
|
||||||
|
|
||||||
|
expect(await reserveToken(token)).toBeNull()
|
||||||
|
expect(JSON.parse(readFileSync(tokensFile, 'utf8'))).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removes a successfully processed token and its payload', async () => {
|
||||||
|
const token = await createToken('contact', { email: 'done@example.invalid' })
|
||||||
|
await reserveToken(token)
|
||||||
|
await completeToken(token)
|
||||||
|
|
||||||
|
expect(JSON.parse(readFileSync(tokensFile, 'utf8'))).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('releases a failed processing attempt for a controlled retry', async () => {
|
||||||
|
const token = await createToken('contact', { email: 'retry@example.invalid' })
|
||||||
|
await reserveToken(token)
|
||||||
|
await releaseToken(token)
|
||||||
|
|
||||||
|
expect((await reserveToken(token)).status).toBe('processing')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows exactly one parallel reservation', async () => {
|
||||||
|
const token = await createToken('contact', { email: 'parallel@example.invalid' })
|
||||||
|
const results = await Promise.all([reserveToken(token), reserveToken(token)])
|
||||||
|
|
||||||
|
expect(results.filter(Boolean)).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cleans expired pending and processing entries', async () => {
|
||||||
|
const pending = await createToken('contact', { email: 'pending@example.invalid' }, -1)
|
||||||
|
const processing = await createToken('contact', { email: 'processing@example.invalid' }, -1)
|
||||||
|
await reserveToken(processing)
|
||||||
|
await cleanupExpiredTokens()
|
||||||
|
|
||||||
|
expect(pending).toBeTruthy()
|
||||||
|
expect(JSON.parse(readFileSync(tokensFile, 'utf8'))).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -9,6 +9,18 @@ const BASE_URL = process.env.BASE_URL || 'https://andreknie.de'
|
|||||||
|
|
||||||
let transporter = null
|
let transporter = null
|
||||||
|
|
||||||
|
export class MailerNotReadyError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('Mailer ist nicht konfiguriert.')
|
||||||
|
this.name = 'MailerNotReadyError'
|
||||||
|
this.code = 'MAILER_NOT_READY'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMailerReady() {
|
||||||
|
return Boolean(SMTP_HOST && SMTP_USER && SMTP_PASS)
|
||||||
|
}
|
||||||
|
|
||||||
function getTransporter() {
|
function getTransporter() {
|
||||||
if (!transporter && SMTP_HOST && SMTP_USER && SMTP_PASS) {
|
if (!transporter && SMTP_HOST && SMTP_USER && SMTP_PASS) {
|
||||||
transporter = nodemailer.createTransport({
|
transporter = nodemailer.createTransport({
|
||||||
@@ -23,10 +35,7 @@ function getTransporter() {
|
|||||||
|
|
||||||
export async function sendConfirmationEmail(to, type, token) {
|
export async function sendConfirmationEmail(to, type, token) {
|
||||||
const t = getTransporter()
|
const t = getTransporter()
|
||||||
if (!t) {
|
if (!t) throw new MailerNotReadyError()
|
||||||
console.log(`[Mailer] SMTP not configured. Would send ${type} confirmation to ${to}`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const confirmUrl = `${BASE_URL}/api/${type}/confirm/${token}`
|
const confirmUrl = `${BASE_URL}/api/${type}/confirm/${token}`
|
||||||
const subjects = {
|
const subjects = {
|
||||||
@@ -45,10 +54,7 @@ export async function sendConfirmationEmail(to, type, token) {
|
|||||||
|
|
||||||
export async function sendStakeholderNotification(type, data) {
|
export async function sendStakeholderNotification(type, data) {
|
||||||
const t = getTransporter()
|
const t = getTransporter()
|
||||||
if (!t) {
|
if (!t) throw new MailerNotReadyError()
|
||||||
console.log(`[Mailer] SMTP not configured. Would notify stakeholder about ${type}`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const subjects = {
|
const subjects = {
|
||||||
contact: `Neue Kontaktanfrage von ${data.name}`,
|
contact: `Neue Kontaktanfrage von ${data.name}`,
|
||||||
@@ -80,11 +86,7 @@ export async function sendSpeakerCv(to, lead, pdfPath) {
|
|||||||
const t = getTransporter()
|
const t = getTransporter()
|
||||||
const pdfMissing = !pdfPath || !existsSync(pdfPath)
|
const pdfMissing = !pdfPath || !existsSync(pdfPath)
|
||||||
|
|
||||||
if (!t) {
|
if (!t) throw new MailerNotReadyError()
|
||||||
console.log(`[Mailer] SMTP not configured. Would send speaker CV to ${to} (pdfMissing=${pdfMissing})`)
|
|
||||||
await notifyStakeholderSpeakerCv(lead, pdfMissing)
|
|
||||||
return { sent: false, pdfMissing }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!pdfMissing) {
|
if (!pdfMissing) {
|
||||||
await t.sendMail({
|
await t.sendMail({
|
||||||
@@ -112,10 +114,7 @@ async function notifyStakeholderSpeakerCv(lead, pdfMissing) {
|
|||||||
: 'CV wurde automatisch an den Anfragenden gesendet.',
|
: 'CV wurde automatisch an den Anfragenden gesendet.',
|
||||||
].join('\n')
|
].join('\n')
|
||||||
|
|
||||||
if (!t) {
|
if (!t) throw new MailerNotReadyError()
|
||||||
console.log(`[Mailer] SMTP not configured. Would notify stakeholder about speaker-cv lead:\n${body}`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await t.sendMail({
|
await t.sendMail({
|
||||||
from: SMTP_USER,
|
from: SMTP_USER,
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { isMailerReady, MailerNotReadyError, sendConfirmationEmail } from './mailer.js'
|
||||||
|
|
||||||
|
describe('mailer readiness', () => {
|
||||||
|
it('reports missing SMTP configuration without exposing credentials', async () => {
|
||||||
|
expect(isMailerReady()).toBe(false)
|
||||||
|
await expect(sendConfirmationEmail('test@example.invalid', 'contact', 'synthetic-token'))
|
||||||
|
.rejects.toBeInstanceOf(MailerNotReadyError)
|
||||||
|
await expect(sendConfirmationEmail('test@example.invalid', 'contact', 'synthetic-token'))
|
||||||
|
.rejects.toMatchObject({ code: 'MAILER_NOT_READY' })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -14,32 +14,29 @@ export default function Datenschutz() {
|
|||||||
E-Mail: <a href="mailto:kontakt@d-hive.de">kontakt@d-hive.de</a>
|
E-Mail: <a href="mailto:kontakt@d-hive.de">kontakt@d-hive.de</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p><strong style={{ color: 'var(--text-primary)' }}>2. Erhebung und Verarbeitung personenbezogener Daten</strong></p>
|
<p><strong style={{ color: 'var(--text-primary)' }}>2. Erhebung und Zweck</strong></p>
|
||||||
<p>Die Nutzung dieser Webseite ist in der Regel ohne Angabe personenbezogener Daten möglich. Personenbezogene Daten werden nur erhoben, wenn Sie diese aktiv über Formulare (Kontakt, Vortragsanfrage, Newsletter, Resource-Download) übermitteln.</p>
|
<p>Diese Website kann grundsätzlich ohne Angabe personenbezogener Daten genutzt werden. Daten werden erhoben, wenn Sie ein Formular für eine Kontaktaufnahme, Vortragsanfrage, Newsletter-Anmeldung, einen Ressourcen-Download oder die Speaker-CV-Anfrage absenden.</p>
|
||||||
|
<p>Die Daten werden ausschließlich zur Bearbeitung der jeweiligen Anfrage, zur Zustellung angeforderter Inhalte oder zur Verwaltung des Newsletter-Abonnements verwendet. Eine Weitergabe erfolgt nur, soweit sie für diesen Zweck erforderlich ist oder Sie zugestimmt haben.</p>
|
||||||
|
|
||||||
<p><strong style={{ color: 'var(--text-primary)' }}>3. Zweck der Datenverarbeitung</strong></p>
|
<p><strong style={{ color: 'var(--text-primary)' }}>3. Kontakt- und Vortragsanfragen</strong></p>
|
||||||
<p>Ihre Daten werden ausschließlich zur Bearbeitung Ihrer Anfrage bzw. zur Zustellung des Newsletters verwendet. Eine Weitergabe an Dritte ohne Ihre ausdrückliche Zustimmung erfolgt nicht.</p>
|
<p>Name, E-Mail-Adresse und Nachricht werden bis zur Bestätigung in einer temporären Token-Datei gespeichert. Der Bestätigungslink ist 24 Stunden (Kontakt) beziehungsweise 48 Stunden (Vortragsanfrage) gültig. Nicht bestätigte, abgelaufene und erfolgreich verarbeitete Anfragen werden einschließlich ihrer Formulardaten gelöscht. Bei einem technischen Fehler bleibt die Anfrage vorübergehend für einen kontrollierten erneuten Verarbeitungsversuch erhalten.</p>
|
||||||
|
|
||||||
<p><strong style={{ color: 'var(--text-primary)' }}>4. Rechtsgrundlage</strong></p>
|
<p><strong style={{ color: 'var(--text-primary)' }}>4. Newsletter (Double-Opt-In)</strong></p>
|
||||||
<p>Die Verarbeitung erfolgt auf Grundlage Ihrer Einwilligung (Art. 6 Abs. 1 lit. a DSGVO) bzw. zur Vertragsanbahnung (Art. 6 Abs. 1 lit. b DSGVO).</p>
|
<p>Für die Newsletter-Anmeldung wird Ihre E-Mail-Adresse bis zur Bestätigung temporär gespeichert. Nach erfolgreicher Bestätigung wird sie als aktives Abonnement gespeichert, bis Sie sich abmelden. Eine Abmeldung können Sie jederzeit per E-Mail an <a href="mailto:kontakt@d-hive.de">kontakt@d-hive.de</a> mit dem Hinweis „Newsletter abbestellen“ veranlassen.</p>
|
||||||
|
|
||||||
<p><strong style={{ color: 'var(--text-primary)' }}>5. Newsletter (Double Opt-In)</strong></p>
|
<p><strong style={{ color: 'var(--text-primary)' }}>5. Ressourcen-Downloads</strong></p>
|
||||||
<p>Bei Anmeldung zum Newsletter wird Ihre E-Mail-Adresse gespeichert. Die Anmeldung erfolgt im Double-Opt-In-Verfahren: Sie erhalten eine Bestätigungs-E-Mail und müssen die Anmeldung aktiv bestätigen. Sie können das Abonnement jederzeit widerrufen.</p>
|
<p>Für einen Ressourcen-Download werden Name, E-Mail-Adresse, Unternehmen, angeforderte Ressource und Zeitpunkt gespeichert. Die Daten dienen der Bearbeitung der Download-Anfrage und der Beziehungspflege. Sie werden gelöscht, sobald der Zweck entfällt oder Sie die Löschung unter <a href="mailto:datenschutz@d-hive.de">datenschutz@d-hive.de</a> anfordern.</p>
|
||||||
|
|
||||||
<p><strong style={{ color: 'var(--text-primary)' }}>6. Kontaktformulare</strong></p>
|
<p><strong style={{ color: 'var(--text-primary)' }}>6. Speaker-CV-Anfrage</strong></p>
|
||||||
<p>Bei Nutzung der Kontakt- oder Vortragsanfrage-Formulare werden die übermittelten Daten (Name, E-Mail, Nachricht) zur Bearbeitung der Anfrage gespeichert. Eine Bestätigungs-E-Mail wird zur Verifizierung versendet. Nicht bestätigte Anfragen werden nach 48 Stunden gelöscht.</p>
|
<p>Für die Speaker-CV-Anfrage werden Name, E-Mail-Adresse, Nachricht und Zeitpunkt gespeichert. Die Daten werden nach erfolgreichem Versand des CV zur Bearbeitung und Beziehungspflege gespeichert und gelöscht, sobald der Zweck entfällt oder Sie die Löschung unter <a href="mailto:datenschutz@d-hive.de">datenschutz@d-hive.de</a> anfordern.</p>
|
||||||
|
|
||||||
<p><strong style={{ color: 'var(--text-primary)' }}>7. Resource-Downloads & Speaker CV</strong></p>
|
<p><strong style={{ color: 'var(--text-primary)' }}>7. Rechtsgrundlage und Ihre Rechte</strong></p>
|
||||||
<p>Für den Download bestimmter Ressourcen sowie für die Anforderung des Speaker CV werden Name, E-Mail und Ihre Nachricht erhoben. Diese Daten werden zur Bearbeitung Ihrer Anfrage, zur Zusendung der angeforderten Unterlagen und zur Beziehungspflege verwendet.</p>
|
<p>Die Verarbeitung erfolgt je nach Vorgang auf Grundlage Ihrer Einwilligung oder zur Bearbeitung einer von Ihnen angefragten Leistung. Sie haben das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung und Datenübertragbarkeit. Wenden Sie sich dafür an <a href="mailto:datenschutz@d-hive.de">datenschutz@d-hive.de</a>.</p>
|
||||||
|
|
||||||
<p><strong style={{ color: 'var(--text-primary)' }}>8. Hosting</strong></p>
|
<p><strong style={{ color: 'var(--text-primary)' }}>8. Hosting und Webanalyse</strong></p>
|
||||||
<p>Diese Webseite wird auf Servern in der EU gehostet. Es erfolgt keine Datenübertragung in Drittländer.</p>
|
<p>Diese Webseite wird auf Servern in der EU gehostet. Es werden keine Analyse-Cookies gesetzt. Zur statistischen Auswertung wird die selbstgehostete Open-Source-Software Umami unter <strong>stats.andreknie.de</strong> eingesetzt. Der Tracker ist auf andreknie.de und www.andreknie.de begrenzt, respektiert die Do-Not-Track-Einstellung und arbeitet ohne Cookies; die IP-Adresse wird anonymisiert.</p>
|
||||||
|
|
||||||
<p><strong style={{ color: 'var(--text-primary)' }}>9. Ihre Rechte</strong></p>
|
<p style={{ marginTop: '32px', fontSize: '0.9rem' }}>Die konkreten Aufbewahrungs- und Löschfristen sollten vor dem Produktivbetrieb fachlich und rechtlich geprüft werden.</p>
|
||||||
<p>Sie haben das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung und Datenübertragbarkeit. Wenden Sie sich an: <a href="mailto:datenschutz@d-hive.de">datenschutz@d-hive.de</a></p>
|
|
||||||
|
|
||||||
<p><strong style={{ color: 'var(--text-primary)' }}>10. Cookies und Webanalyse</strong></p>
|
|
||||||
<p>Diese Webseite verwendet <strong>keine</strong> Cookies zur Analyse Ihres Nutzerverhaltens. Um die Nutzung unserer Website statistisch auszuwerten und das Angebot zu verbessern, setzen wir die selbstgehostete Open-Source-Software <strong>Umami</strong> unter <strong>stats.andreknie.de</strong> ein. Der Tracker ist auf andreknie.de und www.andreknie.de begrenzt, respektiert die Do-Not-Track-Einstellung Ihres Browsers und übermittelt keine Suchparameter oder URL-Hash-Werte. Umami arbeitet ohne Cookies; Ihre IP-Adresse wird anonymisiert. Ein Rückschluss auf Ihre Person ist nicht möglich.</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
Reference in New Issue
Block a user