diff --git a/privat/CV/andreknie.de/server/index.js b/privat/CV/andreknie.de/server/index.js index ac1c501..5e40577 100644 --- a/privat/CV/andreknie.de/server/index.js +++ b/privat/CV/andreknie.de/server/index.js @@ -8,6 +8,7 @@ import talkRequestRouter from './routes/talk-request.js' import newsletterRouter from './routes/newsletter.js' import resourceDownloadRouter from './routes/resource-download.js' import speakerCvRouter from './routes/speaker-cv.js' +import { isMailerReady } from './services/mailer.js' const __dirname = dirname(fileURLToPath(import.meta.url)) const PORT = process.env.PORT || 3003 @@ -30,7 +31,11 @@ app.use(express.json({ limit: '10kb' })) // Routes 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) @@ -45,6 +50,10 @@ app.use((err, _req, res, _next) => { res.status(500).json({ error: 'Interner Serverfehler.' }) }) -app.listen(PORT, () => { - console.log(`[andreknie.de Backend] Running on port ${PORT}`) -}) +if (process.env.NODE_ENV !== 'test') { + app.listen(PORT, () => { + console.log(`[andreknie.de Backend] Running on port ${PORT}`) + }) +} + +export { app } diff --git a/privat/CV/andreknie.de/server/index.test.js b/privat/CV/andreknie.de/server/index.test.js new file mode 100644 index 0000000..0762404 --- /dev/null +++ b/privat/CV/andreknie.de/server/index.test.js @@ -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() + }) +}) diff --git a/privat/CV/andreknie.de/server/routes/contact.js b/privat/CV/andreknie.de/server/routes/contact.js index 5b6a823..836b94b 100644 --- a/privat/CV/andreknie.de/server/routes/contact.js +++ b/privat/CV/andreknie.de/server/routes/contact.js @@ -1,8 +1,8 @@ import { Router } from 'express' import { contactLimiter } from '../middleware/rateLimiter.js' import { antiSpam } from '../middleware/antiSpam.js' -import { createToken, verifyToken } from '../services/confirmationToken.js' -import { sendConfirmationEmail, sendStakeholderNotification } from '../services/mailer.js' +import { createToken, reserveToken, completeToken, releaseToken } from '../services/confirmationToken.js' +import { sendConfirmationEmail, sendStakeholderNotification, MailerNotReadyError } from '../services/mailer.js' const router = Router() @@ -11,27 +11,34 @@ router.post('/', contactLimiter, 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 (!message || message.trim().length === 0) errors.message = 'Nachricht ist erforderlich.' if (message && message.length > 2000) errors.message = 'Max. 2000 Zeichen.' - if (Object.keys(errors).length > 0) { - return res.status(400).json({ errors }) - } + if (Object.keys(errors).length > 0) return res.status(400).json({ errors }) - const token = await createToken('contact', { name, email, message }, 24) - await sendConfirmationEmail(email, 'contact', token) - res.status(201).json({ ok: true, message: 'Bestätigungs-E-Mail gesendet.' }) + try { + const token = await createToken('contact', { name, email, message }, 24) + await sendConfirmationEmail(email, 'contact', token) + 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) => { const baseUrl = process.env.BASE_URL || '' - const entry = await verifyToken(req.params.token) - if (!entry) { - return res.redirect(baseUrl + '/bestaetigung?status=error') + const entry = await reserveToken(req.params.token) + if (!entry) return res.redirect(baseUrl + '/bestaetigung?status=error') + + try { + await sendStakeholderNotification('contact', entry.data) + await completeToken(req.params.token) + 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') } - await sendStakeholderNotification('contact', entry.data) - res.redirect(baseUrl + '/bestaetigung?status=success') }) export default router diff --git a/privat/CV/andreknie.de/server/routes/newsletter.js b/privat/CV/andreknie.de/server/routes/newsletter.js index 98f089a..9e0492e 100644 --- a/privat/CV/andreknie.de/server/routes/newsletter.js +++ b/privat/CV/andreknie.de/server/routes/newsletter.js @@ -5,8 +5,8 @@ import { fileURLToPath } from 'url' import { Mutex } from 'async-mutex' import { newsletterLimiter } from '../middleware/rateLimiter.js' import { antiSpam } from '../middleware/antiSpam.js' -import { createToken, verifyToken } from '../services/confirmationToken.js' -import { sendConfirmationEmail } from '../services/mailer.js' +import { createToken, reserveToken, completeToken, releaseToken } from '../services/confirmationToken.js' +import { sendConfirmationEmail, MailerNotReadyError } from '../services/mailer.js' const __dirname = dirname(fileURLToPath(import.meta.url)) const SUBS_FILE = join(__dirname, '..', 'data', 'subscribers.json') @@ -27,38 +27,46 @@ const router = Router() router.post('/', newsletterLimiter, antiSpam, async (req, res) => { const { email } = req.body 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() if (subs.find(s => s.email === email && s.active)) { return res.status(409).json({ error: 'Diese E-Mail ist bereits abonniert.' }) } - const token = await createToken('newsletter', { email }, 48) - await sendConfirmationEmail(email, 'newsletter', token) - res.status(201).json({ ok: true, message: 'Bestätigungs-E-Mail gesendet.' }) + try { + const token = await createToken('newsletter', { email }, 48) + await sendConfirmationEmail(email, 'newsletter', token) + 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) => { const baseUrl = process.env.BASE_URL || '' - const entry = await verifyToken(req.params.token) - if (!entry) { - return res.redirect(baseUrl + '/bestaetigung?status=error') - } + const entry = await reserveToken(req.params.token) + if (!entry) return res.redirect(baseUrl + '/bestaetigung?status=error') - await subsMutex.runExclusive(async () => { - const subs = loadSubscribers() - subs.push({ - email: entry.data.email, - subscribedAt: new Date().toISOString(), - active: true, + try { + await subsMutex.runExclusive(async () => { + const subs = loadSubscribers() + if (!subs.find(s => s.email === entry.data.email && s.active)) { + subs.push({ + email: entry.data.email, + subscribedAt: new Date().toISOString(), + active: true, + }) + saveSubscribers(subs) + } }) - saveSubscribers(subs) - }) - - res.redirect(baseUrl + '/bestaetigung?status=success') + await completeToken(req.params.token) + res.redirect(baseUrl + '/bestaetigung?status=success') + } catch { + await releaseToken(req.params.token) + res.status(502).redirect(baseUrl + '/bestaetigung?status=error') + } }) export default router diff --git a/privat/CV/andreknie.de/server/routes/speaker-cv.js b/privat/CV/andreknie.de/server/routes/speaker-cv.js index c68b6f4..a41f506 100644 --- a/privat/CV/andreknie.de/server/routes/speaker-cv.js +++ b/privat/CV/andreknie.de/server/routes/speaker-cv.js @@ -38,15 +38,22 @@ router.post('/', resourceLimiter, antiSpam, async (req, res) => { } const lead = { name, email, message, timestamp: new Date().toISOString() } - await leadsMutex.runExclusive(async () => { - const leads = loadLeads() - leads.push(lead) - saveLeads(leads) - }) + 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 sendSpeakerCv(email, lead, PDF_PATH) + await leadsMutex.runExclusive(async () => { + const leads = loadLeads() + leads.push(lead) + saveLeads(leads) + }) - 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 diff --git a/privat/CV/andreknie.de/server/routes/talk-request.js b/privat/CV/andreknie.de/server/routes/talk-request.js index 735622a..e014c06 100644 --- a/privat/CV/andreknie.de/server/routes/talk-request.js +++ b/privat/CV/andreknie.de/server/routes/talk-request.js @@ -1,8 +1,8 @@ import { Router } from 'express' import { talkRequestLimiter } from '../middleware/rateLimiter.js' import { antiSpam } from '../middleware/antiSpam.js' -import { createToken, verifyToken } from '../services/confirmationToken.js' -import { sendConfirmationEmail, sendStakeholderNotification } from '../services/mailer.js' +import { createToken, reserveToken, completeToken, releaseToken } from '../services/confirmationToken.js' +import { sendConfirmationEmail, sendStakeholderNotification, MailerNotReadyError } from '../services/mailer.js' const router = Router() @@ -10,29 +10,36 @@ router.post('/', talkRequestLimiter, antiSpam, async (req, res) => { const { name, email, event_name, topic, message } = req.body const errors = {} 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 (!topic || topic.trim().length === 0) errors.topic = 'Thema ist erforderlich.' if (!message || message.trim().length === 0) errors.message = 'Nachricht ist erforderlich.' if (message && message.length > 2000) errors.message = 'Max. 2000 Zeichen.' - if (Object.keys(errors).length > 0) { - return res.status(400).json({ errors }) - } + if (Object.keys(errors).length > 0) return res.status(400).json({ errors }) - const token = await createToken('talk-request', { name, email, event_name, topic, message }, 48) - await sendConfirmationEmail(email, 'talk-request', token) - res.status(201).json({ ok: true, message: 'Bestätigungs-E-Mail gesendet.' }) + try { + const token = await createToken('talk-request', { name, email, event_name, topic, message }, 48) + await sendConfirmationEmail(email, 'talk-request', token) + 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) => { const baseUrl = process.env.BASE_URL || '' - const entry = await verifyToken(req.params.token) - if (!entry) { - return res.redirect(baseUrl + '/bestaetigung?status=error') + const entry = await reserveToken(req.params.token) + if (!entry) return res.redirect(baseUrl + '/bestaetigung?status=error') + + try { + await sendStakeholderNotification('talk-request', entry.data) + await completeToken(req.params.token) + 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') } - await sendStakeholderNotification('talk-request', entry.data) - res.redirect(baseUrl + '/bestaetigung?status=success') }) export default router diff --git a/privat/CV/andreknie.de/server/services/confirmationToken.js b/privat/CV/andreknie.de/server/services/confirmationToken.js index 23a7ef4..f1aefdf 100644 --- a/privat/CV/andreknie.de/server/services/confirmationToken.js +++ b/privat/CV/andreknie.de/server/services/confirmationToken.js @@ -9,7 +9,6 @@ const DATA_DIR = join(__dirname, '..', 'data') const TOKENS_FILE = join(DATA_DIR, 'pending-tokens.json') const tokenMutex = new Mutex() -// Ensure data directory exists mkdirSync(DATA_DIR, { recursive: true }) function loadTokens() { @@ -22,13 +21,6 @@ function saveTokens(tokens) { 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) { const token = crypto.randomUUID() await tokenMutex.runExclusive(async () => { @@ -39,51 +31,64 @@ export async function createToken(type, data, expiresInHours = 48) { data, createdAt: new Date().toISOString(), expiresAt: new Date(Date.now() + expiresInHours * 60 * 60 * 1000).toISOString(), - confirmed: false, + status: 'pending', }) saveTokens(tokens) }) return token } -/** - * Verify and consume a token. - * @returns {object|null} The stored data if valid, null if expired/invalid - */ -export async function verifyToken(token) { +/** Reserve a valid token for exactly one confirmation flow. */ +export async function reserveToken(token) { return await tokenMutex.runExclusive(async () => { 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 const entry = tokens[idx] - if (new Date(entry.expiresAt) < new Date()) { - // Expired — remove it + if (new Date(entry.expiresAt) <= new Date()) { tokens.splice(idx, 1) saveTokens(tokens) return null } - // Mark as confirmed - tokens[idx].confirmed = true + tokens[idx].status = 'processing' saveTokens(tokens) return entry }) } -/** - * Clean up expired tokens (run periodically). - */ +/** Remove a successfully processed token and its personal payload. */ +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() { await tokenMutex.runExclusive(async () => { const tokens = loadTokens() const now = new Date() - const valid = tokens.filter(t => new Date(t.expiresAt) > now || t.confirmed) - if (valid.length !== tokens.length) { - saveTokens(valid) - } + const valid = tokens.filter(t => !t.confirmed && new Date(t.expiresAt) > now) + if (valid.length !== tokens.length) saveTokens(valid) }) } -// Run cleanup every hour -setInterval(cleanupExpiredTokens, 60 * 60 * 1000) +const cleanupTimer = setInterval(cleanupExpiredTokens, 60 * 60 * 1000) +cleanupTimer.unref?.() diff --git a/privat/CV/andreknie.de/server/services/confirmationToken.test.js b/privat/CV/andreknie.de/server/services/confirmationToken.test.js new file mode 100644 index 0000000..7ece0fa --- /dev/null +++ b/privat/CV/andreknie.de/server/services/confirmationToken.test.js @@ -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([]) + }) +}) diff --git a/privat/CV/andreknie.de/server/services/mailer.js b/privat/CV/andreknie.de/server/services/mailer.js index 309b31a..147c5da 100644 --- a/privat/CV/andreknie.de/server/services/mailer.js +++ b/privat/CV/andreknie.de/server/services/mailer.js @@ -9,6 +9,18 @@ const BASE_URL = process.env.BASE_URL || 'https://andreknie.de' 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() { if (!transporter && SMTP_HOST && SMTP_USER && SMTP_PASS) { transporter = nodemailer.createTransport({ @@ -23,10 +35,7 @@ function getTransporter() { export async function sendConfirmationEmail(to, type, token) { const t = getTransporter() - if (!t) { - console.log(`[Mailer] SMTP not configured. Would send ${type} confirmation to ${to}`) - return - } + if (!t) throw new MailerNotReadyError() const confirmUrl = `${BASE_URL}/api/${type}/confirm/${token}` const subjects = { @@ -45,10 +54,7 @@ export async function sendConfirmationEmail(to, type, token) { export async function sendStakeholderNotification(type, data) { const t = getTransporter() - if (!t) { - console.log(`[Mailer] SMTP not configured. Would notify stakeholder about ${type}`) - return - } + if (!t) throw new MailerNotReadyError() const subjects = { contact: `Neue Kontaktanfrage von ${data.name}`, @@ -80,11 +86,7 @@ export async function sendSpeakerCv(to, lead, pdfPath) { const t = getTransporter() const pdfMissing = !pdfPath || !existsSync(pdfPath) - if (!t) { - console.log(`[Mailer] SMTP not configured. Would send speaker CV to ${to} (pdfMissing=${pdfMissing})`) - await notifyStakeholderSpeakerCv(lead, pdfMissing) - return { sent: false, pdfMissing } - } + if (!t) throw new MailerNotReadyError() if (!pdfMissing) { await t.sendMail({ @@ -112,10 +114,7 @@ async function notifyStakeholderSpeakerCv(lead, pdfMissing) { : 'CV wurde automatisch an den Anfragenden gesendet.', ].join('\n') - if (!t) { - console.log(`[Mailer] SMTP not configured. Would notify stakeholder about speaker-cv lead:\n${body}`) - return - } + if (!t) throw new MailerNotReadyError() await t.sendMail({ from: SMTP_USER, diff --git a/privat/CV/andreknie.de/server/services/mailer.test.js b/privat/CV/andreknie.de/server/services/mailer.test.js new file mode 100644 index 0000000..1e8abf1 --- /dev/null +++ b/privat/CV/andreknie.de/server/services/mailer.test.js @@ -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' }) + }) +}) diff --git a/privat/CV/andreknie.de/src/pages/Datenschutz.jsx b/privat/CV/andreknie.de/src/pages/Datenschutz.jsx index 0ca5a89..ac54c16 100644 --- a/privat/CV/andreknie.de/src/pages/Datenschutz.jsx +++ b/privat/CV/andreknie.de/src/pages/Datenschutz.jsx @@ -14,32 +14,29 @@ export default function Datenschutz() { E-Mail: kontakt@d-hive.de

-

2. Erhebung und Verarbeitung personenbezogener Daten

-

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.

+

2. Erhebung und Zweck

+

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.

+

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.

-

3. Zweck der Datenverarbeitung

-

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.

+

3. Kontakt- und Vortragsanfragen

+

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.

-

4. Rechtsgrundlage

-

Die Verarbeitung erfolgt auf Grundlage Ihrer Einwilligung (Art. 6 Abs. 1 lit. a DSGVO) bzw. zur Vertragsanbahnung (Art. 6 Abs. 1 lit. b DSGVO).

+

4. Newsletter (Double-Opt-In)

+

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 kontakt@d-hive.de mit dem Hinweis „Newsletter abbestellen“ veranlassen.

-

5. Newsletter (Double Opt-In)

-

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.

+

5. Ressourcen-Downloads

+

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 datenschutz@d-hive.de anfordern.

-

6. Kontaktformulare

-

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.

+

6. Speaker-CV-Anfrage

+

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 datenschutz@d-hive.de anfordern.

-

7. Resource-Downloads & Speaker CV

-

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.

+

7. Rechtsgrundlage und Ihre Rechte

+

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 datenschutz@d-hive.de.

-

8. Hosting

-

Diese Webseite wird auf Servern in der EU gehostet. Es erfolgt keine Datenübertragung in Drittländer.

+

8. Hosting und Webanalyse

+

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 stats.andreknie.de 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.

-

9. Ihre Rechte

-

Sie haben das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung und Datenübertragbarkeit. Wenden Sie sich an: datenschutz@d-hive.de

- -

10. Cookies und Webanalyse

-

Diese Webseite verwendet keine Cookies zur Analyse Ihres Nutzerverhaltens. Um die Nutzung unserer Website statistisch auszuwerten und das Angebot zu verbessern, setzen wir die selbstgehostete Open-Source-Software Umami unter stats.andreknie.de 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.

+

Die konkreten Aufbewahrungs- und Löschfristen sollten vor dem Produktivbetrieb fachlich und rechtlich geprüft werden.