Files
harheimertc/server/api/cms/satzung-upload.post.js
Torsten Schulz (local) 33ef5cda5f Improve Satzung content loading and HTML conversion process
This commit ensures that the Satzung content is loaded as a string, enhancing reliability. Additionally, it refines the HTML conversion function by improving the handling of line breaks, merging related lines, and removing empty paragraphs. These changes enhance the overall quality and readability of the generated HTML content.
2026-02-06 13:35:20 +01:00

315 lines
9.8 KiB
JavaScript

import multer from 'multer'
import fs from 'fs/promises'
import path from 'path'
import { exec } from 'child_process'
import { promisify } from 'util'
import { getUserFromToken, hasAnyRole } from '../../utils/auth.js'
import { assertPdfMagicHeader } from '../../utils/upload-validation.js'
const execAsync = promisify(exec)
// Handle both dev and production paths
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
// filename is always a hardcoded constant ('satzung.json'), never user input
const getDataPath = (filename) => {
const cwd = process.cwd()
// In production (.output/server), working dir is .output
if (cwd.endsWith('.output')) {
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
return path.join(cwd, '../server/data', filename)
}
// In development, working dir is project root
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
return path.join(cwd, 'server/data', filename)
}
// Multer-Konfiguration für PDF-Uploads
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, 'public/documents/')
},
filename: (req, file, cb) => {
cb(null, 'satzung.pdf')
}
})
const upload = multer({
storage,
fileFilter: (req, file, cb) => {
if (file.mimetype === 'application/pdf') {
cb(null, true)
} else {
cb(new Error('Nur PDF-Dateien sind erlaubt'), false)
}
},
limits: {
fileSize: 10 * 1024 * 1024 // 10MB Limit
}
})
export default defineEventHandler(async (event) => {
if (event.method !== 'POST') {
throw createError({
statusCode: 405,
statusMessage: 'Method Not Allowed'
})
}
let token = getCookie(event, 'auth_token')
const currentUser = token ? await getUserFromToken(token) : null
if (!currentUser) {
throw createError({
statusCode: 401,
statusMessage: 'Nicht authentifiziert'
})
}
if (!hasAnyRole(currentUser, 'admin', 'vorstand')) {
throw createError({
statusCode: 403,
statusMessage: 'Keine Berechtigung'
})
}
try {
await fs.mkdir(path.join(process.cwd(), 'public', 'documents'), { recursive: true })
// Multer-Middleware für File-Upload
await new Promise((resolve, reject) => {
upload.single('pdf')(event.node.req, event.node.res, (err) => {
if (err) reject(err)
else resolve()
})
})
const file = event.node.req.file
if (!file) {
throw createError({
statusCode: 400,
statusMessage: 'Keine PDF-Datei hochgeladen'
})
}
// Zusätzliche Validierung: Magic-Bytes prüfen (mimetype kann gespooft sein)
await assertPdfMagicHeader(file.path)
// 1. Versuche, den Text mit pdftotext zu extrahieren
let extractedText = ''
try {
// UTF-8 erzwingen, Ausgabe nach stdout
const { stdout } = await execAsync(`pdftotext -enc UTF-8 "${file.path}" -`)
extractedText = stdout || ''
} catch (err) {
console.error('pdftotext Fehler beim Verarbeiten der Satzung:', err)
throw createError({
statusCode: 500,
statusMessage: 'Die Satzung konnte nicht aus dem PDF gelesen werden (pdftotext-Fehler). Bitte den Server-Administrator kontaktieren.'
})
}
// Minimale Plausibilitätsprüfung: genug Text & typische Satzungs-Merkmale
const cleaned = extractedText.trim()
if (!cleaned || cleaned.length < 500 || !cleaned.includes('§')) {
console.error('Satzung: extrahierter Text wirkt unplausibel oder zu kurz:', {
length: cleaned.length
})
throw createError({
statusCode: 500,
statusMessage: 'Die Satzung konnte nicht zuverlässig aus dem PDF gelesen werden. Bitte die PDF-Datei prüfen.'
})
}
// 2. In HTML-Format konvertieren
const htmlContent = convertTextToHtml(cleaned)
// 3. Config aktualisieren (PDF + geparster Inhalt)
const configPath = getDataPath('config.json')
const configData = JSON.parse(await fs.readFile(configPath, 'utf-8'))
if (!configData.seiten) {
configData.seiten = {}
}
configData.seiten.satzung = {
pdfUrl: '/documents/satzung.pdf',
content: htmlContent
}
await fs.writeFile(configPath, JSON.stringify(configData, null, 2), 'utf-8')
return {
success: true,
message: 'Satzung erfolgreich hochgeladen und verarbeitet',
pdfUrl: '/documents/satzung.pdf'
}
} catch (error) {
console.error('PDF Upload Error:', error)
if (error.statusCode) {
throw error
}
throw createError({
statusCode: 500,
statusMessage: error.message || 'Fehler beim Verarbeiten der PDF-Datei'
})
}
})
// PDF-Text zu HTML konvertieren
function convertTextToHtml(text) {
// Text bereinigen und strukturieren
let cleaned = text
.replace(/\r\n/g, '\n') // Windows-Zeilenumbrüche normalisieren
.replace(/\r/g, '\n') // Mac-Zeilenumbrüche normalisieren
.trim()
// Seitenzahlen und Seitenfuß entfernen
cleaned = cleaned
.replace(/^Seite\s+\d+\s+von\s+\d+.*$/gm, '')
.replace(/^-+\d+-+\s*$/gm, '')
.replace(/\n\s*-+\d+-+\s*\n/g, '\n')
.replace(/\s*-+\d+-+\s*/g, '')
.replace(/zuletzt geändert am \d{2}\.\d{2}\.\d{4}.*$/gm, '')
// Zeilenweise aufteilen und leere Zeilen filtern
let rawLines = cleaned.split('\n').map(l => l.trim()).filter(l => {
if (!l || l.length === 0) return false
if (/^-+\d+-+$/.test(l)) return false
if (/^Seite\s+\d+\s+von\s+\d+/.test(l)) return false
return true
})
// ============================================================
// SCHRITT 1: Zusammengehörige Zeilen zusammenführen
// pdftotext trennt oft Nummer/Prefix und Inhalt auf zwei Zeilen
// ============================================================
const merged = []
for (let j = 0; j < rawLines.length; j++) {
const line = rawLines[j]
const next = j + 1 < rawLines.length ? rawLines[j + 1] : null
// Fall 1: "§ 1" (nur Paragraphennummer) + nächste Zeile ist der Titel
// z.B. "§ 1" + "Name, Sitz und Zweck" → "§ 1 Name, Sitz und Zweck"
if (/^§\s*\d+\s*$/.test(line) && next && !next.match(/^§/) && !next.match(/^\d+\.\s/)) {
merged.push(line + ' ' + next)
j++ // nächste Zeile überspringen
continue
}
// Fall 2: "1." (nur Nummer mit Punkt) + nächste Zeile ist der Text
// z.B. "1." + "Der Harheimer TC..." → "1. Der Harheimer TC..."
if (/^\d+\.\s*$/.test(line) && next) {
merged.push(line + ' ' + next)
j++
continue
}
// Fall 3: "a)" (nur Buchstabe mit Klammer) + nächste Zeile ist der Text
// z.B. "a)" + "Die Bestimmungen..." → "a) Die Bestimmungen..."
if (/^[a-z]\)\s*$/i.test(line) && next) {
merged.push(line + ' ' + next)
j++
continue
}
// Keine Zusammenführung nötig
merged.push(line)
}
// ============================================================
// SCHRITT 2: HTML-Elemente erzeugen
// ============================================================
const result = []
let i = 0
while (i < merged.length) {
const line = merged[i]
// Überschriften erkennen (§1, § 2, etc.)
if (line.match(/^§\s*\d+/)) {
result.push(`<h2>${line}</h2>`)
i++
continue
}
// Prüfe ob wir eine Liste mit a), b), c) haben
// Suche nach einem Muster wie "2. Text:" gefolgt von "a) ...", "b) ...", etc.
if (line.match(/^\d+\.\s+.*:$/) && i + 1 < merged.length && merged[i + 1].match(/^[a-z]\)\s+/i)) {
// Einleitender Text für die Liste (ohne Nummer)
const introText = line.replace(/^\d+\.\s+/, '')
const listItems = []
i++
// Sammle alle Listenpunkte a), b), c) ...
while (i < merged.length && merged[i].match(/^[a-z]\)\s+/i)) {
const itemText = merged[i].replace(/^[a-z]\)\s+/i, '').trim()
if (itemText) {
listItems.push(itemText)
}
i++
}
if (listItems.length > 0) {
const listHtml = listItems.map(item => `<li>${item}</li>`).join('')
result.push(`<p><strong>${introText}</strong></p><ul>${listHtml}</ul>`)
} else {
result.push(`<p>${line}</p>`)
}
continue
}
// Einzelne Listenpunkte a), b), c) erkennen
if (line.match(/^[a-z]\)\s+/i)) {
const items = []
while (i < merged.length && merged[i].match(/^[a-z]\)\s+/i)) {
const itemText = merged[i].replace(/^[a-z]\)\s+/i, '').trim()
if (itemText) {
items.push(itemText)
}
i++
}
if (items.length > 0) {
const listHtml = items.map(item => `<li>${item}</li>`).join('')
result.push(`<ul>${listHtml}</ul>`)
}
continue
}
// Nummerierte Listen (1., 2., 3.) - aber nur wenn mehrere aufeinander folgen
if (line.match(/^\d+\.\s+/) && i + 1 < merged.length && merged[i + 1].match(/^\d+\.\s+/)) {
const items = []
while (i < merged.length && merged[i].match(/^\d+\.\s+/)) {
const itemText = merged[i].replace(/^\d+\.\s+/, '').trim()
// Prüfe ob es eine Einleitung für eine Unterliste ist (endet mit ":")
if (itemText.endsWith(':') && i + 1 < merged.length && merged[i + 1].match(/^[a-z]\)\s+/i)) {
break // Wird oben als Einleitung + Unterliste behandelt
}
if (itemText) {
items.push(itemText)
}
i++
}
if (items.length > 0) {
const listHtml = items.map(item => `<li>${item}</li>`).join('')
result.push(`<ol>${listHtml}</ol>`)
}
continue
}
// Normale Absätze
result.push(`<p>${line}</p>`)
i++
}
let html = result.join('\n')
// Leere Absätze entfernen
html = html.replace(/<p>\s*<\/p>/g, '')
html = html.replace(/<p><\/p>/g, '')
return html.trim()
}