9482424ade
57.7 (Consent-Hash ohne TTL): - Neues Feld Customer.consentHashExpiresAt + Migration 20260601300000_consent_hash_ttl mit IF NOT EXISTS. Bestandsdaten bekommen NOW()+30d als Default, damit frische Versand-Links nicht sofort sterben. - TTL-Konstante CONSENT_HASH_TTL_DAYS = 30 in consent-public.service. - getCustomerByConsentHash + grantAllConsentsPublic liefern null bzw. klare Fehlermeldung bei Ablauf; consentHashExpiresAt wird nicht in der Response durchgereicht (kein Oracle "unbekannt vs. abgelaufen"). - ensureConsentHash erneuert Hash + Frist, sobald der alte abgelaufen ist – Versand neuer Links bleibt friction-frei. - consentHashExpiresAt in SENSITIVE_CUSTOMER_FIELDS (sanitize), damit der Standard-Customer-Endpoint kein Workflow-Info leakt. 57.8 (Zip-Slip / Zip-Bomb): - Reject zusätzlich: leere Entry-Namen, Backslashes (Cross-OS- Confusion), Home-Dir-Expansion (`~`), explizite `..`-Segmente schon im Original-Namen (vor path.resolve). - Zip-Slip-Check auf path.relative umgestellt – robuster als startsWith(prefix + sep), insbesondere bei nested Resolution. - Zip-Bomb-Schutz: 500 MB pro Entry + 5 GB Gesamt-Uncompressed- Limit; bei Überschreitung Abbruch mit klarer Meldung. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
320 lines
10 KiB
TypeScript
320 lines
10 KiB
TypeScript
import { ConsentType, ConsentStatus } from '@prisma/client';
|
||
import crypto from 'crypto';
|
||
import prisma from '../lib/prisma.js';
|
||
import * as consentService from './consent.service.js';
|
||
import * as appSettingService from './appSetting.service.js';
|
||
import PDFDocument from 'pdfkit';
|
||
|
||
// Pentest 57.7 (MEDIUM, 2026-06-01): Public-Consent-Hashes hatten keine
|
||
// Ablauffrist. Ein versehentlich weitergegebener oder geleakter Link
|
||
// hätte Jahre später noch fremde Einwilligungen erteilen können
|
||
// (DSGVO-Pflicht zur Zweckbindung). 30 Tage ist der Default-Zeitraum,
|
||
// in dem ein Kunde realistisch auf den Versandlink klickt; danach muss
|
||
// ein Mitarbeiter den Link neu generieren (ensureConsentHash() erzeugt
|
||
// einen neuen Hash + neue Frist).
|
||
const CONSENT_HASH_TTL_DAYS = 30;
|
||
|
||
/**
|
||
* Kunden-Lookup per consentHash. Liefert null wenn der Hash unbekannt
|
||
* oder abgelaufen ist – aus Sicht des Aufrufers identisch, damit der
|
||
* Public-Endpoint keine Unterscheidung "ungültig vs. abgelaufen" leakt.
|
||
*/
|
||
export async function getCustomerByConsentHash(hash: string) {
|
||
const customer = await prisma.customer.findUnique({
|
||
where: { consentHash: hash },
|
||
select: {
|
||
id: true,
|
||
firstName: true,
|
||
lastName: true,
|
||
customerNumber: true,
|
||
salutation: true,
|
||
email: true,
|
||
consentHashExpiresAt: true,
|
||
},
|
||
});
|
||
|
||
if (!customer) return null;
|
||
if (customer.consentHashExpiresAt && customer.consentHashExpiresAt.getTime() < Date.now()) {
|
||
return null;
|
||
}
|
||
|
||
const consents = await consentService.getCustomerConsents(customer.id);
|
||
|
||
// consentHashExpiresAt nicht an den Client durchreichen
|
||
const { consentHashExpiresAt: _expires, ...customerWithoutExpiry } = customer;
|
||
void _expires;
|
||
return { customer: customerWithoutExpiry, consents };
|
||
}
|
||
|
||
/**
|
||
* Alle 4 Einwilligungen über den öffentlichen Link erteilen.
|
||
* Wirft bei abgelaufenem oder unbekanntem Hash mit gleicher Meldung,
|
||
* damit kein Oracle "existiert vs. abgelaufen" entsteht.
|
||
*/
|
||
export async function grantAllConsentsPublic(hash: string, ipAddress: string) {
|
||
const customer = await prisma.customer.findUnique({
|
||
where: { consentHash: hash },
|
||
select: { id: true, firstName: true, lastName: true, consentHashExpiresAt: true },
|
||
});
|
||
|
||
if (!customer) {
|
||
throw new Error('Ungültiger Link');
|
||
}
|
||
if (customer.consentHashExpiresAt && customer.consentHashExpiresAt.getTime() < Date.now()) {
|
||
throw new Error('Link ist abgelaufen. Bitte einen neuen Link anfordern.');
|
||
}
|
||
|
||
const results = [];
|
||
for (const type of Object.values(ConsentType)) {
|
||
const result = await consentService.updateConsent(customer.id, type, {
|
||
status: ConsentStatus.GRANTED,
|
||
source: 'public-link',
|
||
ipAddress,
|
||
createdBy: `${customer.firstName} ${customer.lastName} (Public-Link)`,
|
||
});
|
||
results.push(result);
|
||
}
|
||
|
||
return results;
|
||
}
|
||
|
||
/**
|
||
* consentHash generieren oder erneuern. Liefert einen bestehenden Hash
|
||
* nur zurück, wenn dessen TTL noch nicht abgelaufen ist – sonst wird ein
|
||
* neuer Hash + neue Frist gesetzt (Pentest 57.7).
|
||
*/
|
||
export async function ensureConsentHash(customerId: number): Promise<string> {
|
||
const customer = await prisma.customer.findUnique({
|
||
where: { id: customerId },
|
||
select: { consentHash: true, consentHashExpiresAt: true },
|
||
});
|
||
|
||
if (!customer) {
|
||
throw new Error('Kunde nicht gefunden');
|
||
}
|
||
|
||
const stillValid = customer.consentHash
|
||
&& customer.consentHashExpiresAt
|
||
&& customer.consentHashExpiresAt.getTime() > Date.now();
|
||
|
||
if (stillValid) {
|
||
return customer.consentHash!;
|
||
}
|
||
|
||
const hash = crypto.randomUUID();
|
||
const expiresAt = new Date(Date.now() + CONSENT_HASH_TTL_DAYS * 24 * 60 * 60 * 1000);
|
||
await prisma.customer.update({
|
||
where: { id: customerId },
|
||
data: { consentHash: hash, consentHashExpiresAt: expiresAt },
|
||
});
|
||
|
||
return hash;
|
||
}
|
||
|
||
/**
|
||
* Platzhalter in Text ersetzen
|
||
*/
|
||
function replacePlaceholders(html: string, customer: {
|
||
firstName: string;
|
||
lastName: string;
|
||
customerNumber: string;
|
||
salutation?: string | null;
|
||
email?: string | null;
|
||
}): string {
|
||
return html
|
||
.replace(/\{\{vorname\}\}/gi, customer.firstName || '')
|
||
.replace(/\{\{nachname\}\}/gi, customer.lastName || '')
|
||
.replace(/\{\{kundennummer\}\}/gi, customer.customerNumber || '')
|
||
.replace(/\{\{anrede\}\}/gi, customer.salutation || '')
|
||
.replace(/\{\{email\}\}/gi, customer.email || '')
|
||
.replace(/\{\{datum\}\}/gi, new Date().toLocaleDateString('de-DE', {
|
||
day: '2-digit',
|
||
month: '2-digit',
|
||
year: 'numeric',
|
||
}));
|
||
}
|
||
|
||
/**
|
||
* Datenschutzerklärung als HTML abrufen (mit Platzhaltern ersetzt)
|
||
*/
|
||
export async function getPrivacyPolicyHtml(customerId?: number): Promise<string> {
|
||
const html = await appSettingService.getSetting('privacyPolicyHtml');
|
||
|
||
if (!html) {
|
||
return '<p>Keine Datenschutzerklärung hinterlegt.</p>';
|
||
}
|
||
|
||
if (!customerId) return html;
|
||
|
||
const customer = await prisma.customer.findUnique({
|
||
where: { id: customerId },
|
||
select: {
|
||
firstName: true,
|
||
lastName: true,
|
||
customerNumber: true,
|
||
salutation: true,
|
||
email: true,
|
||
},
|
||
});
|
||
|
||
if (!customer) return html;
|
||
|
||
return replacePlaceholders(html, customer);
|
||
}
|
||
|
||
/**
|
||
* HTML zu Plain-Text konvertieren (für PDF)
|
||
*/
|
||
function htmlToText(html: string): string {
|
||
return html
|
||
.replace(/<h[1-6][^>]*>(.*?)<\/h[1-6]>/gi, '\n$1\n')
|
||
.replace(/<br\s*\/?>/gi, '\n')
|
||
.replace(/<\/p>/gi, '\n\n')
|
||
.replace(/<li[^>]*>(.*?)<\/li>/gi, ' • $1\n')
|
||
.replace(/<[^>]+>/g, '')
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, "'")
|
||
.replace(/ /g, ' ')
|
||
.replace(/\n{3,}/g, '\n\n')
|
||
.trim();
|
||
}
|
||
|
||
/**
|
||
* Datenschutzerklärung als PDF generieren
|
||
*/
|
||
export async function generateConsentPdf(customerId: number): Promise<Buffer> {
|
||
const html = await getPrivacyPolicyHtml(customerId);
|
||
const text = htmlToText(html);
|
||
|
||
return new Promise((resolve, reject) => {
|
||
const doc = new PDFDocument({ size: 'A4', margin: 50 });
|
||
const chunks: Buffer[] = [];
|
||
|
||
doc.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||
doc.on('end', () => resolve(Buffer.concat(chunks)));
|
||
doc.on('error', reject);
|
||
|
||
// Titel
|
||
doc.fontSize(18).font('Helvetica-Bold').text('Datenschutzerklärung', { align: 'center' });
|
||
doc.moveDown(1);
|
||
|
||
// Datum
|
||
doc.fontSize(10).font('Helvetica')
|
||
.text(`Erstellt am: ${new Date().toLocaleDateString('de-DE')}`, { align: 'right' });
|
||
doc.moveDown(1);
|
||
|
||
// Inhalt
|
||
doc.fontSize(11).font('Helvetica').text(text, {
|
||
align: 'left',
|
||
lineGap: 4,
|
||
});
|
||
|
||
doc.end();
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Datenschutzerklärung als unterschreibbare PDF (Papierform) generieren.
|
||
* Zusätzlich zum normalen Text wird unten eine Einwilligungs-Klausel +
|
||
* ein Unterschriften-Block angefügt (Ort/Datum + Unterschrift +
|
||
* Name in Druckbuchstaben). Das fertige PDF wird ausgedruckt, vom
|
||
* Kunden unterschrieben und im Tab "Einwilligungen / Datenschutz"
|
||
* wieder hochgeladen.
|
||
*/
|
||
export async function generateSignablePrivacyPdf(customerId: number): Promise<Buffer> {
|
||
const html = await getPrivacyPolicyHtml(customerId);
|
||
const text = htmlToText(html);
|
||
const customer = await prisma.customer.findUnique({
|
||
where: { id: customerId },
|
||
select: {
|
||
firstName: true, lastName: true, customerNumber: true, companyName: true,
|
||
salutation: true,
|
||
},
|
||
});
|
||
const printedName = customer
|
||
? (customer.companyName?.trim()
|
||
? customer.companyName.trim()
|
||
: `${customer.firstName ?? ''} ${customer.lastName ?? ''}`.trim())
|
||
: '';
|
||
|
||
return new Promise((resolve, reject) => {
|
||
const doc = new PDFDocument({ size: 'A4', margin: 50 });
|
||
const chunks: Buffer[] = [];
|
||
|
||
doc.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||
doc.on('end', () => resolve(Buffer.concat(chunks)));
|
||
doc.on('error', reject);
|
||
|
||
// Titel
|
||
doc.fontSize(18).font('Helvetica-Bold').text('Datenschutzerklärung', { align: 'center' });
|
||
doc.moveDown(0.5);
|
||
|
||
// Kundenkopf
|
||
if (printedName) {
|
||
doc.fontSize(11).font('Helvetica-Bold').text(printedName, { align: 'center' });
|
||
}
|
||
if (customer?.customerNumber) {
|
||
doc.fontSize(10).font('Helvetica').text(`Kundennummer: ${customer.customerNumber}`, { align: 'center' });
|
||
}
|
||
doc.moveDown(0.5);
|
||
doc.fontSize(10).font('Helvetica')
|
||
.text(`Erstellt am: ${new Date().toLocaleDateString('de-DE')}`, { align: 'right' });
|
||
doc.moveDown(1);
|
||
|
||
// Inhalt
|
||
doc.fontSize(11).font('Helvetica').text(text, { align: 'left', lineGap: 4 });
|
||
|
||
// Genug Platz vor dem Unterschriftenblock – wenn nicht mehr genug
|
||
// Platz auf der Seite, neue Seite anfangen.
|
||
if (doc.y > doc.page.height - doc.page.margins.bottom - 220) {
|
||
doc.addPage();
|
||
} else {
|
||
doc.moveDown(2);
|
||
}
|
||
|
||
// Einwilligungsklausel
|
||
doc.fontSize(11).font('Helvetica-Bold').text('Einwilligung', { underline: false });
|
||
doc.moveDown(0.3);
|
||
doc.fontSize(10).font('Helvetica').text(
|
||
'Mit meiner Unterschrift bestätige ich, dass ich die vorstehende ' +
|
||
'Datenschutzerklärung gelesen und verstanden habe und mit der ' +
|
||
'Verarbeitung meiner personenbezogenen Daten zum Zweck der ' +
|
||
'Vertragserfüllung einverstanden bin. Diese Einwilligung kann ' +
|
||
'jederzeit für die Zukunft widerrufen werden.',
|
||
{ align: 'left', lineGap: 3 },
|
||
);
|
||
doc.moveDown(1.5);
|
||
|
||
// Unterschriftenblock: links Ort/Datum, rechts Unterschrift
|
||
const startY = doc.y;
|
||
const leftX = doc.page.margins.left;
|
||
const rightX = doc.page.width / 2 + 10;
|
||
const lineWidth = doc.page.width / 2 - doc.page.margins.left - 10;
|
||
|
||
// Linien
|
||
const lineY = startY + 35;
|
||
doc.moveTo(leftX, lineY).lineTo(leftX + lineWidth, lineY).stroke();
|
||
doc.moveTo(rightX, lineY).lineTo(rightX + lineWidth, lineY).stroke();
|
||
|
||
// Labels unter den Linien
|
||
doc.fontSize(9).font('Helvetica');
|
||
doc.text('Ort, Datum', leftX, lineY + 4, { width: lineWidth, align: 'left' });
|
||
doc.text('Unterschrift', rightX, lineY + 4, { width: lineWidth, align: 'left' });
|
||
|
||
// Zweite Zeile: Name in Druckbuchstaben (vorausgefüllt mit Kunde)
|
||
doc.moveDown(3);
|
||
const nameY = doc.y;
|
||
doc.fontSize(11).font('Helvetica');
|
||
if (printedName) {
|
||
doc.text(printedName, rightX, nameY, { width: lineWidth, align: 'left' });
|
||
}
|
||
doc.moveTo(rightX, nameY + 16).lineTo(rightX + lineWidth, nameY + 16).stroke();
|
||
doc.fontSize(9).text('Name in Druckbuchstaben', rightX, nameY + 20, { width: lineWidth, align: 'left' });
|
||
|
||
doc.end();
|
||
});
|
||
}
|