v1.13: PDF-Upload und -Download fuer Dokumente (multer, Upload-Volume, Rechtepruefung)
This commit is contained in:
Generated
+44
@@ -9,6 +9,7 @@
|
||||
"version": "1.3.0",
|
||||
"dependencies": {
|
||||
"express": "^4.21.2",
|
||||
"multer": "^2.0.2",
|
||||
"pg": "^8.13.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -28,6 +29,12 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/append-field": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
|
||||
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/array-flatten": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||
@@ -58,6 +65,17 @@
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/busboy": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
||||
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
|
||||
"dependencies": {
|
||||
"streamsearch": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
@@ -520,6 +538,24 @@
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/multer": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.4.0.tgz",
|
||||
"integrity": "sha512-7dqa0ZcFfzbefdTuIkzOSMvZWC0J7FLqBOjJUZvDCXShIURWKxAyTT1wHhnE5q19c7jOJf43IYYKjBmZVZmvhg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"append-field": "^1.0.0",
|
||||
"busboy": "^1.6.0",
|
||||
"type-is": "^1.6.18"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.16.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/negotiator": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||
@@ -920,6 +956,14 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/streamsearch": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
|
||||
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/toidentifier": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.21.2",
|
||||
"multer": "^2.0.2",
|
||||
"pg": "^8.13.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,8 @@ CREATE TABLE IF NOT EXISTS documents (
|
||||
size TEXT NOT NULL,
|
||||
date TEXT NOT NULL
|
||||
);
|
||||
ALTER TABLE documents ADD COLUMN IF NOT EXISTS file_name TEXT; -- Originalname beim Upload
|
||||
ALTER TABLE documents ADD COLUMN IF NOT EXISTS file_path TEXT; -- Dateiname im Upload-Verzeichnis
|
||||
|
||||
CREATE TABLE IF NOT EXISTS threads (
|
||||
id TEXT PRIMARY KEY,
|
||||
|
||||
+81
-2
@@ -8,6 +8,9 @@
|
||||
|
||||
const express = require('express')
|
||||
const crypto = require('crypto')
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const multer = require('multer')
|
||||
const { pool, init, sessionsAufraeumen } = require('./db')
|
||||
const { seed, seedeZaehler, seedeHaeuser } = require('./seed')
|
||||
const mail = require('./mail')
|
||||
@@ -18,11 +21,40 @@ const COOKIE_NAME = 'mp_session'
|
||||
// KI-Antworten auf Schadensmeldungen über n8n (leer = deaktiviert)
|
||||
const N8N_TICKET_WEBHOOK = process.env.N8N_TICKET_WEBHOOK || ''
|
||||
const KI_API_TOKEN = process.env.KI_API_TOKEN || ''
|
||||
// PDF-Ablage für das Dokumentenmodul (im Container per Volume persistiert)
|
||||
const UPLOAD_DIR = process.env.UPLOAD_DIR || path.join(__dirname, '..', 'uploads')
|
||||
const UPLOAD_MAX_BYTES = 25 * 1024 * 1024 // 25 MB pro Datei
|
||||
fs.mkdirSync(UPLOAD_DIR, { recursive: true })
|
||||
|
||||
const app = express()
|
||||
app.use(express.json({ limit: '256kb' }))
|
||||
app.set('trust proxy', true)
|
||||
|
||||
// PDF-Uploads (nur application/pdf, 25 MB); der Dateiname wird erst nach dem
|
||||
// Dokument-Lookup vergeben → die Datei heißt wie die Dokument-ID (<id>.pdf).
|
||||
const upload = multer({
|
||||
storage: multer.diskStorage({
|
||||
destination: UPLOAD_DIR,
|
||||
filename: (req, _file, cb) => cb(null, `${req.params.id}.pdf`),
|
||||
}),
|
||||
limits: { fileSize: UPLOAD_MAX_BYTES, files: 1 },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
const istPdf = file.mimetype === 'application/pdf' || /\.pdf$/i.test(file.originalname || '')
|
||||
cb(istPdf ? null : new Error('Nur PDF-Dateien sind erlaubt.'), istPdf)
|
||||
},
|
||||
})
|
||||
|
||||
/** Deutsche Größenangabe, z. B. 1,8 MB. */
|
||||
function formatBytes(bytes) {
|
||||
if (!Number.isFinite(bytes) || bytes < 0) return '–'
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
const einheiten = ['KB', 'MB', 'GB']
|
||||
let wert = bytes / 1024
|
||||
let i = 0
|
||||
while (wert >= 1024 && i < einheiten.length - 1) { wert /= 1024; i += 1 }
|
||||
return `${wert.toLocaleString('de-DE', { maximumFractionDigits: 1 })} ${einheiten[i]}`
|
||||
}
|
||||
|
||||
// Prozess nie durch einen einzelnen Fehler killen lassen (z. B. defekter
|
||||
// JSON-Body von außen); Fehler werden geloggt, der Dienst läuft weiter.
|
||||
process.on('uncaughtException', (e) => console.error('[api] uncaughtException:', e))
|
||||
@@ -127,6 +159,7 @@ const mapTicket = (t) => ({
|
||||
})
|
||||
const mapDocument = (d) => ({
|
||||
id: d.id, unitId: d.unit_id, title: d.title, category: d.category, size: d.size, date: d.date,
|
||||
fileName: d.file_name ?? null, hasFile: Boolean(d.file_path),
|
||||
})
|
||||
const mapMessage = (m) => ({ id: m.id, from: m.from_role, author: m.author, text: m.text, at: m.at })
|
||||
const mapThread = (t) => ({
|
||||
@@ -540,12 +573,58 @@ app.patch('/api/documents/:id', auth, adminOnly, async (req, res, next) => {
|
||||
|
||||
app.delete('/api/documents/:id', auth, adminOnly, async (req, res, next) => {
|
||||
try {
|
||||
const { rowCount } = await pool.query('DELETE FROM documents WHERE id = $1', [req.params.id])
|
||||
if (!rowCount) return res.status(404).json({ fehler: 'Dokument nicht gefunden.' })
|
||||
const { rows } = await pool.query('DELETE FROM documents WHERE id = $1 RETURNING file_path', [req.params.id])
|
||||
if (!rows.length) return res.status(404).json({ fehler: 'Dokument nicht gefunden.' })
|
||||
if (rows[0].file_path) {
|
||||
// Datei best-effort mitlöschen (Fehler hier dürfen den Request nicht killen)
|
||||
await fs.promises.unlink(path.join(UPLOAD_DIR, rows[0].file_path)).catch(() => {})
|
||||
}
|
||||
res.json({ ok: true })
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
// PDF-Upload zum Dokument (ersetzt eine vorhandene Datei; nur Verwaltung)
|
||||
app.post('/api/documents/:id/upload', auth, adminOnly, async (req, res, next) => {
|
||||
const { rows } = await pool.query('SELECT id, file_path FROM documents WHERE id = $1', [req.params.id])
|
||||
if (!rows.length) return res.status(404).json({ fehler: 'Dokument nicht gefunden.' })
|
||||
const alteDatei = rows[0].file_path
|
||||
upload.single('file')(req, res, async (fehler) => {
|
||||
try {
|
||||
if (fehler) {
|
||||
const meldung = fehler.code === 'LIMIT_FILE_SIZE'
|
||||
? 'Die Datei ist größer als 25 MB.'
|
||||
: (fehler.message || 'Der Upload ist fehlgeschlagen.')
|
||||
return res.status(fehler.code === 'LIMIT_FILE_SIZE' ? 413 : 400).json({ fehler: meldung })
|
||||
}
|
||||
if (!req.file) return res.status(400).json({ fehler: 'Keine Datei übertragen.' })
|
||||
if (alteDatei && alteDatei !== req.file.filename) {
|
||||
await fs.promises.unlink(path.join(UPLOAD_DIR, alteDatei)).catch(() => {})
|
||||
}
|
||||
const originalname = Buffer.from(req.file.originalname, 'latin1').toString('utf8') // MSIE-/Chrome-Kodierung geraderücken
|
||||
const { rows: aktualisiert } = await pool.query(
|
||||
'UPDATE documents SET file_name = $1, file_path = $2, size = $3 WHERE id = $4 RETURNING *',
|
||||
[originalname, req.file.filename, formatBytes(req.file.size), req.params.id],
|
||||
)
|
||||
res.json({ dokument: mapDocument(aktualisiert[0]) })
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
})
|
||||
|
||||
// PDF-Download: Verwaltung immer, Mieter nur bei eigener Wohnung oder Hausdokument
|
||||
app.get('/api/documents/:id/download', auth, async (req, res, next) => {
|
||||
try {
|
||||
const { rows } = await pool.query('SELECT * FROM documents WHERE id = $1', [req.params.id])
|
||||
if (!rows.length) return res.status(404).json({ fehler: 'Dokument nicht gefunden.' })
|
||||
const d = rows[0]
|
||||
const erlaubt = req.user.rolle === 'verwaltung' || d.unit_id === null || d.unit_id === req.user.einheit_id
|
||||
if (!erlaubt) return res.status(403).json({ fehler: 'Kein Zugriff auf dieses Dokument.' })
|
||||
if (!d.file_path) return res.status(404).json({ fehler: 'Zu diesem Eintrag ist noch keine Datei hinterlegt.' })
|
||||
const vollerPfad = path.join(UPLOAD_DIR, path.basename(d.file_path)) // basename: Pfad-Traversal unterbinden
|
||||
if (!fs.existsSync(vollerPfad)) return res.status(404).json({ fehler: 'Datei nicht gefunden.' })
|
||||
res.download(vollerPfad, d.file_name || `${d.title}.pdf`)
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
// ── Bestand: Häuser & Wohnungen (Verwaltung) ────────────────
|
||||
function pruefeHaus(b) {
|
||||
const fehler = []
|
||||
|
||||
Reference in New Issue
Block a user