Files
harheimertc/server/api/spielplan/pdf.get.js

311 lines
10 KiB
JavaScript
Executable File

import fs from 'fs/promises'
import path from 'path'
import { readSpielplanData, validateSeasonSlug } from '../../utils/spielplan-data.js'
import { info as loggerInfo, error as loggerError } from '../../utils/logger.js'
import { filterSpielplanRows } from '../../../utils/spielplan-filter.js'
function seasonSlugToLabel(slug) {
const match = String(slug || '').match(/^(\d{2})--(\d{2})$/)
if (!match) return ''
return `20${match[1]}/${match[2]}`
}
export default defineEventHandler(async (event) => {
try {
const query = getQuery(event)
const team = query.team
if (query.season && !validateSeasonSlug(query.season)) {
loggerError('Ungueltiger Saison-Slug angefragt (PDF)', { season: query.season })
throw createError({ statusCode: 400, statusMessage: 'Ungueltiger Saison-Slug' })
}
if (!team) {
throw createError({
statusCode: 400,
statusMessage: 'Team-Parameter fehlt'
})
}
const spielplan = await readSpielplanData({ season: query.season })
if (!spielplan.data.length || !spielplan.headers.length) {
throw createError({
statusCode: 404,
statusMessage: 'Spielplandaten nicht gefunden'
})
}
const dataRows = spielplan.data
const wettbewerb = String(query.wettbewerb || 'punktrunde').toLowerCase().trim()
const filteredData = filterSpielplanRows(dataRows, { team, wettbewerb })
// Sammle Halle-Informationen für die jeweilige Mannschaft
const hallenMap = new Map()
// Debug: Zeige verfügbare Spalten
loggerInfo('Verfügbare Spalten in gefilterten Daten', { columns: Object.keys(filteredData[0] || {}) })
filteredData.forEach((row, index) => {
// Suche Halle-Spalten mit verschiedenen möglichen Namen
const halleName = row.HalleName || row.halleName || row.Halle || row.halle || ''
const halleStrasse = row.HalleStrasse || row.halleStrasse || row.HalleStrasse || row.halleStrasse || ''
const hallePLZ = row.HallePLZ || row.hallePLZ || row.HallePLZ || row.hallePLZ || ''
const halleOrt = row.HalleOrt || row.halleOrt || row.HalleOrt || row.halleOrt || ''
const heimMannschaft = row.HeimMannschaft || ''
// Debug: Zeige Halle-Daten für erste paar Zeilen
if (index < 3) {
loggerInfo('Spielplan-Zeile Halle-Daten', { index, halleName, halleStrasse, hallePLZ, halleOrt, heimMannschaft })
}
if (halleName && halleStrasse && hallePLZ && halleOrt) {
const halleKey = `${halleName}|${halleStrasse}|${hallePLZ}|${halleOrt}`
if (!hallenMap.has(halleKey)) {
hallenMap.set(halleKey, {
name: halleName,
strasse: halleStrasse,
plz: hallePLZ,
ort: halleOrt,
mannschaften: new Set()
})
}
// Füge Heimmannschaft hinzu (unabhängig von Harheimer TC)
hallenMap.get(halleKey).mannschaften.add(heimMannschaft)
}
})
const hallenListe = Array.from(hallenMap.values())
loggerInfo('Gefundene Hallen', { count: hallenListe.length, hallen: hallenListe })
// Generiere LaTeX-Code für PDF
const teamName = team.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())
const currentDate = new Date().toLocaleDateString('de-DE')
const seasonLabel = seasonSlugToLabel(spielplan.season) || 'Unbekannt'
loggerInfo('Spielplan-PDF Filterergebnis', {
team,
wettbewerb,
season: spielplan.season,
totalRows: dataRows.length,
filteredRows: filteredData.length
})
let latexContent = `
\\documentclass[9pt,a4paper]{article}
\\usepackage[utf8]{inputenc}
\\usepackage[german]{babel}
\\usepackage{geometry}
\\usepackage{array}
\\usepackage{longtable}
\\usepackage{helvet}
\\renewcommand{\\familydefault}{\\sfdefault}
\\geometry{margin=2cm}
\\title{Spielplan ${teamName}}
\\author{Harheimer TC}
\\date{Saison ${seasonLabel} - Stand: ${currentDate}}
% PDF-Metadaten für Sicherheit
\\usepackage[pdftex]{hyperref}
\\hypersetup{
pdfauthor={Harheimer TC},
pdftitle={Spielplan ${teamName} - Saison ${seasonLabel}},
pdfsubject={Vereinsspielplan},
pdfkeywords={Tischtennis, Spielplan, Harheimer TC, ${teamName}},
pdfcreator={Harheimer TC CMS},
pdfproducer={LaTeX mit pdflatex},
pdfcreationdate={${new Date().toISOString()}},
pdfmoddate={${new Date().toISOString()}},
colorlinks=false,
pdfborder={0 0 0}
}
\\begin{document}
\\maketitle
\\section*{Spielplan ${teamName}}
\\vspace{0.5cm}
${filteredData.length > 0 ? `
\\renewcommand{\\arraystretch}{1.3}
\\begin{longtable}{|p{1.8cm}|p{1.1cm}|p{6.5cm}|p{6.5cm}|}
\\hline
\\textbf{Datum} & \\textbf{Uhrzeit} & \\textbf{Heim} & \\textbf{Gast} \\\\
\\hline
\\endhead
${filteredData.map(row => {
const termin = row.Termin || ''
const datum = termin.includes(' ') ? termin.split(' ')[0] : termin
const uhrzeit = termin.includes(' ') ? termin.split(' ')[1] : ''
const heim = (row.HeimMannschaft || '').replace(/&/g, '\\&')
const gast = (row.GastMannschaft || '').replace(/&/g, '\\&')
const heimAltersklasse = (row.HeimMannschaftAltersklasse || '').toLowerCase()
const gastAltersklasse = (row.GastMannschaftAltersklasse || '').toLowerCase()
// Prüfe ob es Jugend-Mannschaften sind
const isHeimJugend = heimAltersklasse.includes('jugend') || heim.toLowerCase().includes('jugend')
const isGastJugend = gastAltersklasse.includes('jugend') || gast.toLowerCase().includes('jugend')
// Füge "(J) " vor Jugendmannschaften hinzu (nur bei Gesamtspielplan)
let heimFormatted = heim
let gastFormatted = gast
if (team === 'all') {
if (isHeimJugend) {
heimFormatted = `(J) ${heim}`
}
if (isGastJugend) {
gastFormatted = `(J) ${gast}`
}
}
const heimBold = heimFormatted.toLowerCase().includes('harheimer tc') ? `\\textbf{${heimFormatted}}` : heimFormatted
const gastBold = gastFormatted.toLowerCase().includes('harheimer tc') ? `\\textbf{${gastFormatted}}` : gastFormatted
return `${datum} & ${uhrzeit} & ${heimBold} & ${gastBold} \\\\ \\hline`
}).join('\n')}
\\end{longtable}
` : `
\\begin{center}
\\textit{Keine Spiele für ${teamName} gefunden.}
\\end{center}
`}
${hallenListe.length > 0 ? `
\\newpage
\\section*{Spielstätten}
\\vspace{0.5cm}
\\begin{longtable}{|p{5cm}|p{4cm}|p{8cm}|}
\\hline
\\textbf{Heimmannschaft} & \\textbf{Halle} & \\textbf{Adresse} \\\\
\\hline
\\endhead
${hallenListe.map(halle => {
const halleName = halle.name.replace(/&/g, '\\&')
const adresse = `${halle.strasse.replace(/&/g, '\\&')}, ${halle.plz} ${halle.ort.replace(/&/g, '\\&')}`
const mannschaften = Array.from(halle.mannschaften).map(m => m.replace(/&/g, '\\&')).join(', ')
return `${mannschaften} & ${halleName} & ${adresse} \\\\ \\hline`
}).join('\n')}
\\end{longtable}
` : ''}
\\vfill
\\begin{center}
\\small Generiert am ${currentDate} | Harheimer TC
\\end{center}
\\end{document}`
// Schreibe LaTeX-Datei temporär
const tempDir = path.join(process.cwd(), 'temp')
try {
await fs.mkdir(tempDir, { recursive: true })
} catch (_error) {
// Verzeichnis existiert bereits
}
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
const tempTexFile = path.join(tempDir, `spielplan_${team}_${Date.now()}.tex`)
await fs.writeFile(tempTexFile, latexContent, 'utf-8')
// Kompiliere LaTeX zu PDF
const { exec } = await import('child_process')
const { promisify } = await import('util')
const execAsync = promisify(exec)
try {
const pdfFile = tempTexFile.replace('.tex', '.pdf')
try {
await execAsync(`pdflatex -interaction=nonstopmode -output-directory=${tempDir} "${tempTexFile}"`)
} catch (pdflatexError) {
const pdfExists = await fs.stat(pdfFile).then(() => true).catch(() => false)
if (!pdfExists) {
throw pdflatexError
}
loggerError('pdflatex meldete Fehlercode, aber PDF wurde erzeugt. Fahre fort.', { error: pdflatexError })
}
// PDF-Datei lesen
const pdfBuffer = await fs.readFile(pdfFile)
// Cleanup: Lösche temporäre Dateien
setTimeout(async () => {
try {
await fs.unlink(tempTexFile)
await fs.unlink(pdfFile)
await fs.unlink(tempTexFile.replace('.tex', '.log'))
await fs.unlink(tempTexFile.replace('.tex', '.aux'))
} catch (_error) {
loggerError('Fehler beim Löschen temporärer Dateien:', { error: _error })
}
}, 5000)
// Setze PDF-Headers
setHeader(event, 'Content-Type', 'application/pdf')
setHeader(event, 'Content-Disposition', `attachment; filename="spielplan_${team}.pdf"`)
setHeader(event, 'Content-Length', pdfBuffer.length.toString())
// Füge Sicherheits-Header hinzu (global auch via server/middleware/security-headers.js)
setHeader(event, 'X-Content-Type-Options', 'nosniff')
setHeader(event, 'X-Frame-Options', 'SAMEORIGIN')
setHeader(event, 'X-XSS-Protection', '0')
setHeader(event, 'Cache-Control', 'no-cache, no-store, must-revalidate')
setHeader(event, 'Pragma', 'no-cache')
setHeader(event, 'Expires', '0')
return pdfBuffer
} catch (compileError) {
loggerError('LaTeX-Kompilierung fehlgeschlagen:', { error: compileError })
// Fallback: HTML-Response mit Fehlermeldung
const errorHtml = `
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>PDF-Generierung fehlgeschlagen</title>
<style>
body { font-family: Arial, sans-serif; margin: 40px; background-color: #f5f5f5; }
.container { background: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
.error { color: #d32f2f; font-size: 24px; margin-bottom: 20px; }
.message { color: #333; font-size: 16px; line-height: 1.6; }
</style>
</head>
<body>
<div class="container">
<div class="error">❌ PDF-Generierung fehlgeschlagen</div>
<div class="message">
Die PDF-Generierung für <strong>"${teamName}"</strong> ist fehlgeschlagen.<br>
Bitte kontaktieren Sie den Administrator.
</div>
</div>
</body>
</html>`
setHeader(event, 'Content-Type', 'text/html; charset=utf-8')
return errorHtml
}
} catch (error) {
loggerError('Fehler beim Generieren des Spielplans:', { error })
if (error.statusCode) {
throw error
}
throw createError({
statusCode: 500,
statusMessage: 'Fehler beim Generieren des Spielplans'
})
}
})