Files
harheimertc/server/api/membership/generate-pdf.post.js
2025-12-20 15:05:49 +01:00

837 lines
38 KiB
JavaScript

import { createRequire } from 'module'
import { exec } from 'child_process'
import { promisify } from 'util'
import fs from 'fs/promises'
import path from 'path'
import { StandardFonts } from 'pdf-lib'
// const require = createRequire(import.meta.url) // Nicht verwendet
const execAsync = promisify(exec)
function mapFieldValue(data, name) {
name = name.toLowerCase()
if (name.includes('sepa_mitglied')) return `${data.vorname || ''} ${data.nachname || ''}`.trim()
if (name.includes('sepa_kontoinhaber')) return data.kontoinhaber || `${data.vorname || ''} ${data.nachname || ''}`.trim()
if (name.includes('sepa_plz_ort')) return `${data.plz || ''} ${data.ort || ''}`.trim()
if (name.includes('page3_anschrift')) return `${data.strasse || ''}, ${data.plz || ''} ${data.ort || ''}`.trim()
if (name.includes('vorname') || name.includes('given')) return data.vorname || ''
if (name.includes('nachname') || name.includes('zuname') || (name.includes('name') && name.indexOf('vorname') === -1 && name.indexOf('given') === -1)) return data.nachname || ''
if (name.includes('str') || name.includes('straße') || name.includes('street')) return data.strasse || ''
if (name.includes('plz')) return data.plz || ''
if (name.includes('ort') || name.includes('stadt')) return data.ort || ''
if (name.includes('geb') || name.includes('geburts')) return new Date(data.geburtsdatum).toLocaleDateString('de-DE')
if (name.includes('telefon') || name.includes('tel')) return data.telefon_privat || data.telefon_mobil || ''
if (name.includes('email')) return data.email || ''
if (name.includes('datum')) return data.sign_datum || data.sepa_datum || data.page3_datum || new Date().toLocaleDateString('de-DE')
if (name.includes('kontoinhaber') || name.includes('kontoinh')) return data.kontoinhaber || ''
if (name.includes('iban')) return data.iban || ''
if (name.includes('bic')) return data.bic || ''
if (name.includes('bank') || name.includes('kreditinstitut')) return data.bank || ''
if (name.includes('mitgliedschaft') || name.includes('art')) return data.mitgliedschaftsart || ''
return ''
}
function setTextFieldIfEmpty(field, val) {
if (typeof field.setText !== 'function') return
try {
if (typeof field.getText === 'function') {
const cur = field.getText()
if (cur && String(cur).trim() !== '') return
}
} catch {
// Feld nicht lesbar, ignorieren
}
if (val != null && String(val).trim() !== '') field.setText(val)
}
function setCheckboxIfNeeded(field, name, data) {
if (!(typeof field.check === 'function' || typeof field.isChecked === 'function')) return
const lower = name.toLowerCase()
try {
if (lower.includes('aktiv') || lower.includes('passiv') || lower.includes('mitglied')) {
if (typeof field.isChecked === 'function' && field.isChecked()) return
if (data.mitgliedschaftsart && lower.includes(data.mitgliedschaftsart)) { field.check && field.check(); return }
if (lower.includes('aktiv') && data.mitgliedschaftsart === 'aktiv') field.check && field.check()
if (lower.includes('passiv') && data.mitgliedschaftsart === 'passiv') field.check && field.check()
return
}
const mapped = mapFieldValue(data, lower)
if (mapped === 'true' || mapped === 'ja' || mapped === 'checked') {
try {
if (!(typeof field.isChecked === 'function' && field.isChecked())) field.check && field.check()
} catch {
field.check && field.check()
}
}
} catch {
// Feld nicht verarbeitbar, ignorieren
}
}
async function fillFormFields(pdfDoc, form, data) {
const fields = form.getFields()
for (const field of fields) {
const fname = field.getName()
const lower = fname.toLowerCase()
if (typeof field.setText === 'function') {
const val = mapFieldValue(data, lower)
setTextFieldIfEmpty(field, val)
continue
}
if (typeof field.check === 'function' || typeof field.isChecked === 'function') {
setCheckboxIfNeeded(field, lower, data)
continue
}
}
try {
const helv2 = await pdfDoc.embedFont(StandardFonts.Helvetica)
form.updateFieldAppearances(helv2)
} catch {
// Schriftart nicht einbettbar, ignorieren
}
}
function generateLaTeXContent(data) {
const heute = new Date().toLocaleDateString('de-DE')
// LaTeX-Inhalt mit korrekten Escapes generieren
let latexContent = `\\documentclass[12pt,a4paper]{article}
\\usepackage[utf8]{inputenc}
\\usepackage[ngerman]{babel}
\\usepackage{geometry}
\\usepackage{enumitem}
\\usepackage{xcolor}
\\usepackage{helvet} % Für Sans-Serif Schriftart
\\renewcommand{\\familydefault}{\\sfdefault} % Setzt Sans-Serif als Standard
\\setlength{\\parindent}{0pt} % Keine Absatzeinrückung
\\geometry{margin=2cm}
\\title{\\textbf{Harheimer Tischtennis-Club 1954 e.V.}\\\\
\\vspace{0.3cm}
\\Large Beitrittserklärung}
\\date{}
\\begin{document}
\\maketitle
\\vspace{0.1cm} % Reduzierter Abstand zwischen Titel und Text (1/3 der ursprünglichen Größe)
Hiermit beantrage ich,
\\vspace{0.5cm}
Name: \\underline{${data.nachname} \\hspace{6cm}} \\\\
\\vspace{0.3cm}
Vorname: \\underline{${data.vorname} \\hspace{6cm}} \\\\
\\vspace{0.3cm}
Straße: \\underline{${data.strasse} \\hspace{6cm}} \\\\
\\vspace{0.3cm}
PLZ/Wohnort: \\underline{${data.plz} ${data.ort} \\hspace{6cm}} \\\\
\\vspace{0.3cm}
Geburtsdatum: \\underline{${new Date(data.geburtsdatum).toLocaleDateString('de-DE')} \\hspace{6cm}} \\\\
\\vspace{0.3cm}
Telefon (privat): \\underline{${data.telefon_privat || ''} \\hspace{6cm}} \\\\
\\vspace{0.3cm}
E-Mail: \\underline{${data.email} \\hspace{6cm}} \\\\
\\vspace{0.3cm}
Telefon (Mobil): \\underline{${data.telefon_mobil || ''} \\hspace{6cm}}
\\vspace{0.5cm}
meine Aufnahme in den Harheimer Tischtennis-Club 1954 e.V. als
\\vspace{0.3cm}
\\begin{itemize}[label={}]
\\item[\\framebox(4,4){}] ${data.mitgliedschaftsart === 'aktiv' ? '\\framebox(4,4){X}' : '\\framebox(4,4){}'} aktives Mitglied
\\item[\\framebox(4,4){}] ${data.mitgliedschaftsart === 'passiv' ? '\\framebox(4,4){X}' : '\\framebox(4,4){}'} passives Mitglied
\\end{itemize}
\\vspace{0.5cm}
Den derzeitigen jährlichen Mitgliedsbeitrag in Höhe von
\\begin{itemize}
\\item € 120,-- (Erwachsene)
\\item € 72,-- (Jugendliche bis zum vollendeten 18. Lebensjahr)
\\item € 30,-- (passive Mitglieder)
\\end{itemize}
bitte ich per Lastschrift jährlich von meinem Konto einzuziehen.
Hierzu erteile ich beigefügtes SEPA-Lastschriftmandat.
\\vspace{0.5cm}
Mir ist bekannt, dass die Mitgliedschaft im Harheimer Tischtennis-Club erst nach Bestätigung durch den Vorstand Wirksamkeit erlangt. Die Beitragspflicht beginnt mit dem darauf folgenden Monat.
Ich erkenne die Vereinssatzung (erhältlich beim Vorstand bzw. auf der Vereinshomepage) an.
\\vspace{1cm}
Frankfurt/Main-Harheim, den \\underline{${heute} \\hspace{3cm}}
\\vspace{0.5cm}
Unterschrift ${data.isVolljaehrig ? '' : '(bei Jugendlichen gesetzlicher Vertreter)'}
\\newpage
\\title{\\textbf{Harheimer Tischtennis-Club 1954 e.V.}\\\\
\\Large Erteilung eines SEPA-Lastschriftmandates}
\\date{}
\\maketitle
\\vspace{0.5cm}
\\textbf{Harheimer Tischtennis-Club 1954 e.V.}\\\\
Unsere Gläubiger-Identifikationsnummer: DE46ZZZ00000745362
\\vspace{0.5cm}
Hiermit ermächtige ich den Harheimer Tischtennis-Club 1954 e.V. die jährlichen Mitgliedsbeiträge von meinem untenstehenden Konto per Lastschrift einzuziehen. Zugleich weise ich mein Kreditinstitut an, die vom Harheimer Tischtennis-Club 1954 e.V. auf mein Konto gezogenen Lastschriften einzulösen.
\\vspace{0.5cm}
Hinweis: Ich kann innerhalb von acht Wochen, beginnend mit dem Belastungsdatum, die Erstattung des belasteten Betrages verlangen. Es gelten dabei die mit meinem Kreditinstitut vereinbarten Bedingungen.
\\vspace{1cm}
Kontoinhaber: \\underline{${data.kontoinhaber} \\hspace{6cm}} \\\\
\\vspace{0.3cm}
IBAN: \\underline{${data.iban} \\hspace{6cm}} \\\\
\\vspace{0.3cm}
BIC: \\underline{${data.bic || ''} \\hspace{6cm}} \\\\
\\vspace{0.3cm}
Kreditinstitut: \\underline{${data.bank} \\hspace{6cm}}
\\vspace{1cm}
Frankfurt/Main-Harheim, den \\underline{${heute} \\hspace{3cm}}
\\vspace{0.5cm}
Unterschrift des Kontoinhabers
\\newpage
\\title{\\textbf{Harheimer Tischtennis-Club 1954 e.V.}\\\\
\\Large Einwilligungserklärung}
\\date{}
\\maketitle
\\vspace{0.5cm}
\\textbf{für die Veröffentlichung von Mitgliederdaten im Internet.}
\\vspace{0.5cm}
Der Vereinsvorstand weist hiermit darauf hin, dass ausreichende technische Maßnahmen zur Gewährleistung des Datenschutzes getroffen wurden. Dennoch kann bei einer Veröffentlichung von personenbezogenen Mitgliederdaten im Internet ein umfassender Datenschutz nicht garantiert werden. Daher nimmt das Vereinsmitglied die Risiken für eine eventuelle Persönlichkeitsrechtsverletzung zur Kenntnis und ist sich bewusst, dass:
\\begin{itemize}
\\item die personenbezogenen Daten auch in Staaten abrufbar sind, die keine der Bundesrepublik Deutschland vergleichbaren Datenschutzbestimmungen kennen,
\\item die Vertraulichkeit, die Integrität (Unverletzlichkeit), die Authentizität (Echtheit) und die Verfügbarkeit der personenbezogenen Daten nicht garantiert ist.
\\end{itemize}
Das Vereinsmitglied trifft die Entscheidung zur Veröffentlichung seiner Daten im Internet freiwillig und kann seine Einwilligung gegenüber dem Vereinsvorstand jederzeit widerrufen.
\\vspace{0.5cm}
\\textbf{Erklärung:}
Ich bestätige das Vorstehende zur Kenntnis genommen zu haben und willige ein, dass der
\\textbf{Harheimer Tischtennis-Club 1954 e.V.}
folgende allgemeine Daten zu meiner Person:
Vorname: \\underline{${data.vorname} \\hspace{4cm}} \\\\
\\vspace{0.3cm}
Zuname: \\underline{${data.nachname} \\hspace{4cm}}
Fotografien, sonstige Daten (Mannschaft, Leistungsergebnisse, Turnierteilnahmen, Lizenzen u.ä.) bzw. spezielle Daten von Funktionsträgern:
Anschrift: \\underline{${data.strasse}, ${data.plz} ${data.ort} \\hspace{4cm}} \\\\
\\vspace{0.3cm}
Telefonnummer: \\underline{${data.telefon_privat || data.telefon_mobil || ''} \\hspace{4cm}} \\\\
\\vspace{0.3cm}
E-Mail-Adresse: \\underline{${data.email} \\hspace{4cm}}
wie angegeben auf der Homepage des Vereins (www.harheimertc.de) veröffentlichen darf.
\\vspace{1cm}
Datum: \\underline{${heute} \\hspace{6cm}}
\\vspace{0.5cm}
Unterschrift ${data.isVolljaehrig ? '' : '(bei Minderjährigen Unterschrift eines Erziehungsberechtigten)'}
\\end{document}`
// Doppelte Backslashes zu einfachen Backslashes konvertieren, aber \\vspace und \\hspace beibehalten
// Erst alle \\vspace und \\hspace temporär ersetzen
let result = latexContent.replace(/\\\\vspace/g, 'TEMP_VSPACE')
result = result.replace(/\\\\hspace/g, 'TEMP_HSPACE')
// Dann alle anderen doppelten Backslashes ersetzen
result = result.replace(/\\\\/g, '\\')
// Dann die temporären Platzhalter wieder zurückersetzen
result = result.replace(/TEMP_VSPACE/g, '\\vspace')
result = result.replace(/TEMP_HSPACE/g, '\\hspace')
return result
}
async function generateSimplePDF(data, filename, _event) {
// Fallback: HTML zu PDF mit puppeteer oder ähnlich
// Für jetzt: Einfache Textdatei
const textContent = `
Harheimer Tischtennis-Club 1954 e.V.
Beitrittserklärung
Antragsteller: ${data.vorname} ${data.nachname}
Mitgliedschaftsart: ${data.mitgliedschaftsart}
Volljährig: ${data.isVolljaehrig ? 'Ja' : 'Nein'}
Das ausgefüllte Formular ist als Anhang verfügbar.`
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
const textPath = path.join(process.cwd(), 'public', 'uploads', `${filename}.txt`)
await fs.writeFile(textPath, textContent, 'utf8')
return `${filename}.txt`
}
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
// filename is always a hardcoded constant (e.g., 'membership-applications'), never user input
function getDataPath(filename) {
// Immer den absoluten Pfad zum Projekt-Root verwenden
// In der Entwicklung: process.cwd() ist bereits das Projekt-Root
// In der Produktion: process.cwd() ist .output, daher ein Verzeichnis zurück
const isDev = process.env.NODE_ENV === 'development'
const projectRoot = isDev ? process.cwd() : path.resolve(process.cwd(), '..')
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
return path.join(projectRoot, 'server', 'data', filename)
}
async function sendMembershipEmail(data, _filename, _event) {
try {
const configPath = getDataPath('config.json')
const configData = await fs.readFile(configPath, 'utf8')
const config = JSON.parse(configData)
let recipients = []
let subject = `Neuer Mitgliedschaftsantrag - ${data.vorname} ${data.nachname}`
// Sammle alle verfügbaren E-Mail-Adressen
const availableEmails = []
// Vorsitzender E-Mail hinzufügen (falls vorhanden)
if (config.vorstand.vorsitzender.email && config.vorstand.vorsitzender.email.trim() !== '') {
availableEmails.push(config.vorstand.vorsitzender.email)
}
// Schriftführer E-Mail hinzufügen (falls vorhanden)
if (config.vorstand.schriftfuehrer.email && config.vorstand.schriftfuehrer.email.trim() !== '') {
availableEmails.push(config.vorstand.schriftfuehrer.email)
}
// Fallback: Wenn keine E-Mails verfügbar sind, verwende tsschulz@tsschulz.de
if (availableEmails.length === 0) {
recipients = ['tsschulz@tsschulz.de']
} else {
recipients = availableEmails
}
// In nicht-Produktionsumgebung: Alle E-Mails an tsschulz@tsschulz.de
if (process.env.NODE_ENV !== 'production') {
recipients = ['tsschulz@tsschulz.de']
}
const message = `Ein neuer Mitgliedschaftsantrag wurde eingereicht.
Antragsteller: ${data.vorname} ${data.nachname}
Mitgliedschaftsart: ${data.mitgliedschaftsart}
Volljährig: ${data.isVolljaehrig ? 'Ja' : 'Nein'}
Das ausgefüllte Formular ist als Anhang verfügbar.`
// E-Mail-Versand implementieren (hier würde normalerweise nodemailer verwendet)
console.log('E-Mail würde gesendet werden an:', recipients)
console.log('Betreff:', subject)
console.log('Nachricht:', message)
return { success: true, recipients, subject, message }
} catch (error) {
console.error('Fehler beim Senden der E-Mail:', error)
return { success: false, error: error.message }
}
}
export default defineEventHandler(async (event) => {
try {
const body = await readBody(event)
// Validierung der Eingabedaten
const requiredFields = ['vorname', 'nachname', 'strasse', 'plz', 'ort', 'geburtsdatum', 'email', 'mitgliedschaftsart', 'kontoinhaber', 'iban']
for (const field of requiredFields) {
if (!body[field]) {
throw createError({
statusCode: 400,
statusMessage: `Feld '${field}' ist erforderlich`
})
}
}
// Volljährigkeit prüfen
const birthDate = new Date(body.geburtsdatum)
const today = new Date()
const age = today.getFullYear() - birthDate.getFullYear()
const monthDiff = today.getMonth() - birthDate.getMonth()
const isVolljaehrig = age > 18 || (age === 18 && monthDiff >= 0)
const data = {
...body,
isVolljaehrig
}
// Eindeutige Datei-ID generieren
const timestamp = Date.now()
const filename = `beitrittserklärung_${timestamp}`
// Temp-Verzeichnis erstellen
const tempDir = path.join(process.cwd(), '.output', 'temp', 'latex')
await fs.mkdir(tempDir, { recursive: true })
try {
// PDF-Template-Funktion aktiv: versuche Original-PDF-Template herunterzuladen und zu befüllen
// Versuch: Original-PDF-Template herunterladen und AcroForm-Felder befüllen
async function fillPdfTemplate(data) {
// Priorität: neues lokales Fillable-Template in server/templates, sonst ursprüngliches Template
const fillablePath = path.join(process.cwd(), 'server', 'templates', 'mitgliedschaft-fillable.pdf')
const localPath = (await fs.stat(fillablePath).then(() => fillablePath).catch(() => null)) || path.join(process.cwd(), 'server', 'templates', 'Aufnahmeantrag 2025.pdf')
let arrayBuffer
try {
const localExists = await fs.stat(localPath).then(() => true).catch(() => false)
if (localExists) {
const buf = await fs.readFile(localPath)
arrayBuffer = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength)
} else {
const TEMPLATE_URL = process.env.MEMBERSHIP_TEMPLATE_URL || 'https://harheimertc.de/Aufnahmeantrag%202025.pdf'
const res = await fetch(TEMPLATE_URL)
if (!res.ok) throw new Error(`Template konnte nicht geladen werden: ${res.status}`)
arrayBuffer = await res.arrayBuffer()
}
} catch (_e) {
throw new Error('Template-Laden fehlgeschlagen: ' + e.message)
}
const pdfDoc = await PDFDocument.load(arrayBuffer)
let form
try {
form = pdfDoc.getForm()
} catch (_e) {
form = null
}
if (!form || form.getFields().length === 0) {
// Keine Formularfelder vorhanden -> positional filler verwenden
// Koordinaten (in PDF-Punkten) müssen ggf. feinjustiert werden.
const pages = pdfDoc.getPages()
const firstPage = pages[0]
firstPage.getSize()
// Schätzwerte: (x, y) in Punkten von linker unteren Ecke
// Diese Werte müssen nach Sichtprüfung justiert werden.
// Angepasste Koordinaten — Text beginnt links an der Linie und sitzt oberhalb der Unterstreichung
// Linke Spalte (nachname, vorname, etc.) etwas weiter links setzen
const leftX = 156
// Rechte Spalte (PLZ/Ort, Telefon, E-Mail) weiter nach rechts für klare Trennung
const rightX = 470
// Baseline für erstes Feld (von oben aus gerechnet)
const baseY = height - 160
const gap = 28
// Kleine vertikale Verschiebung, damit Texte oberhalb der Unterstreichung beginnen
const yOffset = 9
const coords = {
// Row: Name (left) / Vorname (right)
nachname: { x: leftX, y: baseY + yOffset },
vorname: { x: rightX, y: baseY + yOffset },
// Row: Straße (left) / PLZ/Ort (right)
strasse: { x: leftX, y: baseY - gap + yOffset },
plz_ort: { x: rightX, y: baseY - gap + yOffset },
// Row: Geburtsdatum (left) / Telefon(privat) (right)
geburtsdatum: { x: leftX, y: baseY - gap * 2 + yOffset },
telefon: { x: rightX, y: baseY - gap * 2 + yOffset },
// Row: E-Mail (left) / Telefon(Mobil) (right)
email: { x: leftX, y: baseY - gap * 3 + yOffset },
telefon_mobil: { x: rightX, y: baseY - gap * 3 + yOffset },
// Membership checkbox positions (approx.)
mitglied_checkbox_aktiv: { x: leftX - 40, y: baseY - gap * 6 + yOffset },
mitglied_checkbox_passiv: { x: leftX - 40, y: baseY - gap * 7 + yOffset },
// Account details on subsequent page(s) - korrigierte Koordinaten für Seite 2
kontoinhaber: { x: leftX, y: baseY + yOffset },
iban: { x: leftX, y: baseY - gap + yOffset },
bic: { x: leftX, y: baseY - gap * 2 + yOffset },
bank: { x: leftX, y: baseY - gap * 3 + yOffset }
}
const drawText = (page, text, x, y, size = 11) => {
page.drawText(text || '', {
x,
y,
size,
font: pdfDoc.embedStandardFont ? undefined : undefined,
// default black
color: undefined
})
}
// Einbettung der Standard-Schrift (Helvetica)
const helveticaFont = await pdfDoc.embedFont(PDFDocument.PDFName ? 'Helvetica' : 'Helvetica')
// NOTE: pdf-lib's embedFont usage above uses embedFont(fontBytes) in normal case;
// to keep it simple we attempt to embed built-in font via embedFont(StandardFonts)
// Fallback: drawText will work with default font if embed fails.
// Zeichne die Felder
try {
// Zeichne Name / Vorname / Adresse / Kontakt (einmal)
firstPage.drawText(data.nachname || '', { x: coords.nachname.x, y: coords.nachname.y, size: 11, font: helveticaFont })
firstPage.drawText(data.vorname || '', { x: coords.vorname.x, y: coords.vorname.y, size: 11, font: helveticaFont })
firstPage.drawText(data.strasse || '', { x: coords.strasse.x, y: coords.strasse.y, size: 11, font: helveticaFont })
firstPage.drawText(`${data.plz || ''} ${data.ort || ''}`.trim(), { x: coords.plz_ort.x, y: coords.plz_ort.y, size: 11, font: helveticaFont })
firstPage.drawText(new Date(data.geburtsdatum).toLocaleDateString('de-DE') || '', { x: coords.geburtsdatum.x, y: coords.geburtsdatum.y, size: 11, font: helveticaFont })
firstPage.drawText(data.telefon_privat || data.telefon_mobil || '', { x: coords.telefon.x, y: coords.telefon.y, size: 11, font: helveticaFont })
firstPage.drawText(data.email || '', { x: coords.email.x, y: coords.email.y, size: 11, font: helveticaFont })
firstPage.drawText(data.telefon_mobil || '', { x: coords.telefon_mobil.x, y: coords.telefon_mobil.y, size: 11, font: helveticaFont })
// Bankdaten als sichtbarer Text am Ende der ersten Seite (garantiert sichtbar)
const bottomY = 50 // Am unteren Rand der Seite
firstPage.drawText(`BANKDATEN (SEITE 2):`, { x: 50, y: bottomY + 100, size: 12, font: helveticaFont, color: rgb(1, 0, 0) })
firstPage.drawText(`Kontoinhaber: ${data.kontoinhaber || ''}`, { x: 50, y: bottomY + 80, size: 11, font: helveticaFont })
firstPage.drawText(`IBAN: ${data.iban || ''}`, { x: 50, y: bottomY + 60, size: 11, font: helveticaFont })
firstPage.drawText(`BIC: ${data.bic || ''}`, { x: 50, y: bottomY + 40, size: 11, font: helveticaFont })
firstPage.drawText(`Bank: ${data.bank || ''}`, { x: 50, y: bottomY + 20, size: 11, font: helveticaFont })
// Zeichne X in die passende Mitgliedschafts-Checkbox
try {
if (data.mitgliedschaftsart === 'aktiv') {
firstPage.drawText('X', { x: coords.mitglied_checkbox_aktiv.x, y: coords.mitglied_checkbox_aktiv.y, size: 12, font: helveticaFont })
} else if (data.mitgliedschaftsart === 'passiv') {
firstPage.drawText('X', { x: coords.mitglied_checkbox_passiv.x, y: coords.mitglied_checkbox_passiv.y, size: 12, font: helveticaFont })
}
} catch (_e) {
console.warn('Fehler beim Zeichnen der Checkbox:', e.message)
}
// Debug overlay: zeichne Marker an allen Koordinaten, wenn data.debug === true
if (data && data.debug) {
try {
// Auffälliges Debug-Tag oben links
const tagW = 100
const tagH = 22
firstPage.drawRectangle({ x: 40, y: height - 60, width: tagW, height: tagH, color: rgb(1, 0, 0), opacity: 0.25 })
firstPage.drawText('DEBUG', { x: 48, y: height - 56, size: 12, color: rgb(1, 0, 0), font: helveticaFont })
// Markiere alle Koordinaten mit einem gefüllten roten Quadrat und Label
const allCoords = Object.entries(coords)
for (const [key, c] of allCoords) {
// small filled square
firstPage.drawRectangle({ x: c.x - 3, y: c.y - 3, width: 8, height: 8, color: rgb(1, 0, 0), opacity: 1 })
// small label a bit to the right
firstPage.drawText(key, { x: c.x + 8, y: c.y - 1, size: 7, color: rgb(0.6, 0, 0), font: helveticaFont })
}
} catch (_e) {
console.warn('Debug overlay fehlgeschlagen:', e.message)
}
}
} catch (_e) {
console.warn('Fehler beim positional drawing:', e.message)
}
const pdfBytes = await pdfDoc.save()
return Buffer.from(pdfBytes)
}
// Wenn Formularfelder existieren: befülle sie per AcroForm
const fields = form.getFields()
if (fields && fields.length > 0) {
try { await fillFormFields(pdfDoc, form, data) } catch (e) { console.warn('AcroForm-Füllung fehlgeschlagen, fallback auf positional:', e.message) }
}
const mapValue = (name) => {
// einfache Heuristiken für Feldnamen
name = name.toLowerCase()
// specific overrides first
if (name.includes('sepa_mitglied')) return `${data.vorname || ''} ${data.nachname || ''}`.trim()
if (name.includes('sepa_kontoinhaber')) return data.kontoinhaber || `${data.vorname || ''} ${data.nachname || ''}`.trim()
if (name.includes('sepa_plz_ort')) return `${data.plz || ''} ${data.ort || ''}`.trim()
if (name.includes('page3_anschrift')) return `${data.strasse || ''}, ${data.plz || ''} ${data.ort || ''}`.trim()
if (name.includes('nachname') || name.includes('zuname') || name.includes('name')) return data.nachname || ''
if (name.includes('vorname') || name.includes('given')) return data.vorname || ''
if (name.includes('str') || name.includes('straße') || name.includes('street')) return data.strasse || ''
if (name.includes('plz')) return data.plz || ''
if (name.includes('ort') || name.includes('stadt')) return data.ort || ''
if (name.includes('geb') || name.includes('geburts')) return new Date(data.geburtsdatum).toLocaleDateString('de-DE')
if (name.includes('telefon') || name.includes('tel')) return data.telefon_privat || data.telefon_mobil || ''
if (name.includes('email')) return data.email || ''
// general date fields: use provided sign/sepa/page3 date or today's date
if (name.includes('datum')) return data.sign_datum || data.sepa_datum || data.page3_datum || new Date().toLocaleDateString('de-DE')
if (name.includes('kontoinhaber') || name.includes('kontoinh')) return data.kontoinhaber || ''
if (name.includes('iban')) return data.iban || ''
if (name.includes('bic')) return data.bic || ''
if (name.includes('bank') || name.includes('kreditinstitut')) return data.bank || ''
// Do not map generic 'mitglied' to membership type to avoid writing 'aktiv'/'passiv' into text fields.
// Membership selection is handled via checkboxes elsewhere.
if (name.includes('mitgliedschaft') || name.includes('art')) return data.mitgliedschaftsart || ''
return ''
}
for (const field of fields) {
const fname = field.getName()
const lower = fname.toLowerCase()
try {
// Textfelder
if (typeof field.setText === 'function') {
try {
// don't overwrite if already set
if (typeof field.getText === 'function') {
const cur = field.getText()
if (cur && String(cur).trim() !== '') {
continue
}
}
} catch (_e) {
// ignore getter errors and proceed to set
}
const val = mapValue(lower)
if (val != null && String(val).trim() !== '') {
field.setText(val)
}
continue
}
// Checkbox / Radio
if (typeof field.check === 'function' || typeof field.isChecked === 'function') {
// einfache Heuristik: bei Mitgliedschaftsart
if (lower.includes('aktiv') || lower.includes('passiv') || lower.includes('mitglied')) {
try {
if (typeof field.isChecked === 'function' && field.isChecked()) {
// already checked, skip
} else {
if (data.mitgliedschaftsart && lower.includes(data.mitgliedschaftsart)) {
field.check && field.check()
} else {
if (lower.includes('aktiv') && data.mitgliedschaftsart === 'aktiv') field.check && field.check()
if (lower.includes('passiv') && data.mitgliedschaftsart === 'passiv') field.check && field.check()
}
}
} catch (_e) {
// ignore isChecked errors
}
continue
}
const mapped = mapValue(lower)
if (mapped === 'true' || mapped === 'ja' || mapped === 'checked') {
try {
if (!(typeof field.isChecked === 'function' && field.isChecked())) {
field.check && field.check()
}
} catch (_e) {
field.check && field.check()
}
}
}
} catch (_e) {
console.warn('Fehler beim Befüllen Feld', fname, e.message)
}
}
// Ensure appearances are generated after mapping fields
try {
const helv2 = await pdfDoc.embedFont(StandardFonts.Helvetica)
form.updateFieldAppearances(helv2)
} catch (_e) {
console.warn('Warning: could not update field appearances after mapping fields:', e.message)
}
const pdfBytes = await pdfDoc.save()
return Buffer.from(pdfBytes)
}
let usedTemplate = false
const uploadsDir = path.join(process.cwd(), 'public', 'uploads')
await fs.mkdir(uploadsDir, { recursive: true })
try {
const filled = await fillPdfTemplate(data)
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
// filename is generated from timestamp, not user input, path traversal prevented
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
const finalPdfPath = path.join(uploadsDir, `${filename}.pdf`)
await fs.writeFile(finalPdfPath, filled)
// Zusätzlich: Kopie ins repo-root public/uploads legen, falls Nitro cwd anders ist
try {
const repoRoot = path.resolve(process.cwd(), '..')
const repoUploads = path.join(repoRoot, 'public', 'uploads')
await fs.mkdir(repoUploads, { recursive: true })
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
// filename is generated from timestamp, not user input, path traversal prevented
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
await fs.copyFile(finalPdfPath, path.join(repoUploads, `${filename}.pdf`))
} catch (_e) {
console.warn('Kopie in repo public/uploads fehlgeschlagen:', e.message)
}
usedTemplate = true
} catch (templateError) {
// Template konnte nicht verwendet werden -> weiter zum LaTeX-Fallback
console.warn('Template-Füllung fehlgeschlagen, fahre mit LaTeX fort:', templateError.message)
}
let emailResult
if (usedTemplate) {
// E-Mail senden
emailResult = await sendMembershipEmail(data, filename, event)
// Antragsdaten verschlüsselt speichern
const encryptionKey = process.env.ENCRYPTION_KEY || 'local_development_encryption_key_change_in_production'
const encryptedData = encrypt(JSON.stringify(data), encryptionKey)
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
// filename is generated from timestamp, not user input, path traversal prevented
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
const dataPath = path.join(uploadsDir, `${filename}.data`)
await fs.writeFile(dataPath, encryptedData, 'utf8')
// Download-Token setzen
const downloadToken = Buffer.from(`${filename}:${Date.now()}`).toString('base64')
setCookie(event, 'download_token', downloadToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 60 * 60 * 24 // 24 Stunden
})
return {
success: true,
message: 'Beitrittsformular erfolgreich aus Template erzeugt und E-Mail gesendet.',
downloadUrl: `/api/membership/download/${filename}.pdf`,
emailSuccess: emailResult.success,
emailMessage: emailResult.message,
usedTemplate: true
}
}
// Falls Template nicht verwendet: weiter mit LaTeX-Fallback
// LaTeX-Inhalt generieren
const latexContent = generateLaTeXContent(data)
// LaTeX-Datei schreiben
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
// filename is generated from timestamp, not user input, path traversal prevented
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
const texPath = path.join(tempDir, `${filename}.tex`)
await fs.writeFile(texPath, latexContent, 'utf8')
// PDF mit pdflatex generieren
// nosemgrep: javascript.lang.security.detect-child-process.detect-child-process
// filename is generated from timestamp, tempDir is controlled, command injection prevented
const command = `cd "${tempDir}" && pdflatex -interaction=nonstopmode "${filename}.tex"`
await execAsync(command)
// PDF-Datei in Uploads-Verzeichnis kopieren
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
// filename is generated from timestamp, not user input, path traversal prevented
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
const pdfPath = path.join(tempDir, `${filename}.pdf`)
await fs.mkdir(uploadsDir, { recursive: true })
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
// filename is generated from timestamp, not user input, path traversal prevented
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
const finalPdfPath = path.join(uploadsDir, `${filename}.pdf`)
await fs.copyFile(pdfPath, finalPdfPath)
// Kopie ins repo-root public/uploads für bessere Auffindbarkeit
try {
const repoRoot = path.resolve(process.cwd(), '..')
const repoUploads = path.join(repoRoot, 'public', 'uploads')
await fs.mkdir(repoUploads, { recursive: true })
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
// filename is generated from timestamp, not user input, path traversal prevented
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
await fs.copyFile(finalPdfPath, path.join(repoUploads, `${filename}.pdf`))
} catch (e) {
console.warn('Kopie in repo public/uploads fehlgeschlagen:', e.message)
}
// E-Mail senden
emailResult = await sendMembershipEmail(data, filename, event)
// Antragsdaten verschlüsselt speichern
const encryptionKey = process.env.ENCRYPTION_KEY || 'local_development_encryption_key_change_in_production'
const encryptedData = encrypt(JSON.stringify(data), encryptionKey)
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
// filename is generated from timestamp, not user input, path traversal prevented
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
const dataPath = path.join(uploadsDir, `${filename}.data`)
await fs.writeFile(dataPath, encryptedData, 'utf8')
// Download-Berechtigung für den Antragsteller setzen
const downloadToken = Buffer.from(`${filename}:${Date.now()}`).toString('base64')
setCookie(event, 'download_token', downloadToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 60 * 60 * 24 // 24 Stunden
})
return {
success: true,
message: 'Beitrittsformular erfolgreich erstellt und E-Mail gesendet.',
downloadUrl: `/api/membership/download/${filename}.pdf`,
emailSuccess: emailResult.success,
emailMessage: emailResult.message,
}
} catch (latexError) {
console.error('LaTeX-Fehler:', latexError)
// Fallback: Einfache Textdatei generieren
const fallbackFilename = await generateSimplePDF(data, filename, event)
// E-Mail senden (Fallback)
const emailResult = await sendMembershipEmail(data, filename, event)
console.log('LaTeX nicht verfügbar, verwende Fallback-Lösung')
console.log('E-Mail würde gesendet werden an:', emailResult.recipients || [])
console.log('Betreff:', emailResult.subject || '')
console.log('Nachricht:', emailResult.message || '')
console.log('Upload-Verzeichnis:', path.join(process.cwd(), 'public', 'uploads'))
// Verfügbare Dateien auflisten
const uploadsDir = path.join(process.cwd(), 'public', 'uploads')
try {
const files = await fs.readdir(uploadsDir)
console.log('Verfügbare Dateien:', files)
// Gesuchte Datei finden
const targetFile = files.find(file => file.startsWith(filename))
console.log('Gesuchte Datei-ID:', filename)
console.log('Gefundene Datei:', targetFile || 'Nicht gefunden')
} catch (dirError) {
console.error('Fehler beim Lesen des Upload-Verzeichnisses:', dirError)
}
return {
success: true,
message: 'Beitrittsformular erfolgreich erstellt und E-Mail gesendet (Fallback-Lösung).',
downloadUrl: `/api/membership/download/${fallbackFilename}`,
emailSuccess: emailResult.success,
emailMessage: emailResult.message,
}
}
} catch (error) {
console.error('Fehler beim Generieren des PDFs:', error)
throw createError({
statusCode: 500,
statusMessage: 'Fehler beim Generieren des PDFs'
})
}
})