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 // Store uploads in internal data directory instead of public/ const DOCUMENTS_DIR = getDataPath('documents') const storage = multer.diskStorage({ destination: (req, file, cb) => { cb(null, DOCUMENTS_DIR) }, 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 { // Ensure internal documents dir exists await fs.mkdir(DOCUMENTS_DIR, { 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 = {} } // Serve the uploaded statute via internal media proxy configData.seiten.satzung = { pdfUrl: '/api/media/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(`
${introText}
${line}
`) } 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 => `${line}
`) i++ } let html = result.join('\n') // Leere Absätze entfernen html = html.replace(/\s*<\/p>/g, '') html = html.replace(/
<\/p>/g, '') return html.trim() }