320 lines
10 KiB
JavaScript
320 lines
10 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
|
|
// 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(`<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()
|
|
}
|