Files
opencrm/backend/src/routes/upload.routes.ts
T
duffyduck a3fef8891a Pentest 2026-05-30 LOW 39.3 + INFO 39.4: Magic-Byte-Check + Endung-Normalisierung
Upload-Endpoints (/api/upload/...) hatten denselben Mismatch-Vektor
wie schon die Vollmacht-Route (Pentest 28.3): multer prüft nur den
client-gemeldeten MIME-Type, eine `.php`-Datei mit
Content-Type: image/gif rutschte durch und landete als
`<unique>.gif.php` (Doppel-Endung) auf Disk – kein RCE in unserem
Setup, aber dreckige Datei + Inkonsistenz zwischen geliefertem MIME
und tatsächlichem Inhalt.

Fix: neue validateUploadedFile-Middleware nach upload.single(...) –
- liest die ersten 12 Bytes der gerade geschriebenen Datei
- erkennt PDF/PNG/JPEG/GIF/WebP per Magic-Bytes
- bei Mismatch: Datei löschen + 415 "Datei-Inhalt entspricht keinem
  zulässigen Typ"
- benennt die Datei auf eine KANONISCHE Endung (.pdf/.jpg/.png/.gif/
  .webp) um, abgeleitet aus dem erkannten Typ (NICHT aus
  file.originalname). Damit verschwindet `evil.gif.php` zu
  `<unique>.gif` (39.4).
- setzt req.file.mimetype auf den erkannten Type, sodass Controller
  konsistente Werte sehen.

Eingehängt in allen 10 upload.single('document')-Routes
(bank-cards, documents, business-registrations, commercial-register,
contract-docs etc.).

Live-verifiziert:
- PHP-Datei als image/gif    → 415 + Datei gelöscht
- HTML-Datei als application/pdf → 415 + Datei gelöscht
- WebP-Inhalt mit MIME image/png → 200, gespeichert als .webp
- echtes WebP/JPG → 200 mit kanonischer Endung

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 11:43:13 +02:00

928 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Router, Response } from 'express';
import multer from 'multer';
import path from 'path';
import fs from 'fs';
import prisma from '../lib/prisma.js';
import { authenticate, requirePermission } from '../middleware/auth.js';
import { AuthRequest } from '../types/index.js';
import { logChange } from '../services/audit.service.js';
import { canAccessContract } from '../utils/accessControl.js';
const router = Router();
// Uploads-Verzeichnis erstellen falls nicht vorhanden
const uploadsDir = path.join(process.cwd(), 'uploads');
if (!fs.existsSync(uploadsDir)) {
fs.mkdirSync(uploadsDir, { recursive: true });
}
// Multer-Konfiguration
const storage = multer.diskStorage({
destination: (req, file, cb) => {
const subDir = (req as any).uploadSubDir || 'misc';
const targetDir = path.join(uploadsDir, subDir);
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true });
}
cb(null, targetDir);
},
filename: (req, file, cb) => {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
const ext = path.extname(file.originalname);
cb(null, `${uniqueSuffix}${ext}`);
},
});
const fileFilter = (
req: Express.Request,
file: Express.Multer.File,
cb: multer.FileFilterCallback
) => {
// PDFs + gängige Web-Bildformate. WebP + GIF nachgezogen 2026-05-30
// (Pentest INFO: WebP/GIF lieferten 500 statt sauberem 4xx, weil
// erlaubter MIME-Type fehlte und der fileFilter dann throwte).
const allowedTypes = [
'application/pdf',
'image/jpeg', 'image/jpg', 'image/png',
'image/gif', 'image/webp',
];
if (allowedTypes.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error('Nur PDF, JPG, PNG, GIF und WebP-Dateien sind erlaubt'));
}
};
const upload = multer({
storage,
fileFilter,
limits: {
fileSize: 10 * 1024 * 1024, // 10MB max
},
});
// Middleware um Subdirectory zu setzen
function setUploadDir(subDir: string) {
return (req: AuthRequest, res: Response, next: Function) => {
(req as any).uploadSubDir = subDir;
next();
};
}
/**
* Post-Upload-Validierung: prüft die Magic-Bytes der gerade geschriebenen
* Datei und vergleicht mit dem fileFilter-Whitelist. Bei Mismatch
* (Pentest 2026-05-30 LOW 39.3: WebP/GIF/JPG/PDF-Spoofing) wird die
* Datei sofort gelöscht + 415 zurück.
*
* Zusätzlich (39.4): die Datei wird auf eine kanonische Endung umbenannt,
* die aus dem ERKANNTEN Typ abgeleitet ist nicht aus dem
* client-gemeldeten file.originalname. Damit verschwindet die
* `evil.gif.php`-Doppel-Endung; gespeicherter Name ist
* `<timestamp-random>.<canonical-ext>` (z.B. `.pdf` / `.png`).
*/
const PDF_MAGIC = Buffer.from('%PDF-', 'latin1');
const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const GIF87 = Buffer.from('GIF87a', 'latin1');
const GIF89 = Buffer.from('GIF89a', 'latin1');
function detectType(buf: Buffer): { mime: string; ext: string } | null {
if (buf.length >= 5 && buf.subarray(0, 5).equals(PDF_MAGIC)) return { mime: 'application/pdf', ext: '.pdf' };
if (buf.length >= 8 && buf.subarray(0, 8).equals(PNG_MAGIC)) return { mime: 'image/png', ext: '.png' };
if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return { mime: 'image/jpeg', ext: '.jpg' };
if (buf.length >= 6 && (buf.subarray(0, 6).equals(GIF87) || buf.subarray(0, 6).equals(GIF89))) return { mime: 'image/gif', ext: '.gif' };
if (buf.length >= 12
&& buf.subarray(0, 4).toString('latin1') === 'RIFF'
&& buf.subarray(8, 12).toString('latin1') === 'WEBP') return { mime: 'image/webp', ext: '.webp' };
return null;
}
function validateUploadedFile(req: AuthRequest, res: Response, next: Function) {
if (!req.file) return next();
try {
const fd = fs.openSync(req.file.path, 'r');
const head = Buffer.alloc(12);
fs.readSync(fd, head, 0, 12, 0);
fs.closeSync(fd);
const detected = detectType(head);
if (!detected) {
try { fs.unlinkSync(req.file.path); } catch { /* ignore */ }
res.status(415).json({
success: false,
error: 'Datei-Inhalt entspricht keinem zulässigen Typ (PDF, JPG, PNG, GIF, WebP).',
});
return;
}
// Filename auf kanonische Extension normalisieren. Multer hat
// `<unique>.gif.php` o.ä. geschrieben wir wollen `<unique>.gif`.
const dir = path.dirname(req.file.path);
const base = path.basename(req.file.path).replace(/\.[^./]+(\.[^./]+)*$/, '');
const newName = base + detected.ext;
const newPath = path.join(dir, newName);
if (newPath !== req.file.path) {
try {
fs.renameSync(req.file.path, newPath);
req.file.path = newPath;
req.file.filename = newName;
} catch (e) {
// Rename hat seltene Edge-Cases (Cross-Device). Sicherheit
// geht vor sollte das fehlschlagen, werfen wir lieber 500
// und putzen die alte Datei.
try { fs.unlinkSync(req.file.path); } catch { /* ignore */ }
console.error('Upload-Rename fehlgeschlagen:', e);
res.status(500).json({ success: false, error: 'Upload konnte nicht abgeschlossen werden' });
return;
}
}
// Mimetype mit dem ERKANNTEN überschreiben, damit die Controller
// den korrekten Typ persistieren (falls sie ihn weiterreichen).
req.file.mimetype = detected.mime;
next();
} catch (e) {
console.error('Magic-Byte-Check fehlgeschlagen:', e);
try { fs.unlinkSync(req.file.path); } catch { /* ignore */ }
res.status(500).json({ success: false, error: 'Upload konnte nicht geprüft werden' });
}
}
// Upload für Bankkarten-Dokumente
router.post(
'/bank-cards/:id',
authenticate,
requirePermission('customers:update'),
setUploadDir('bank-cards'),
upload.single('document'),
validateUploadedFile,
async (req: AuthRequest, res: Response) => {
try {
if (!req.file) {
res.status(400).json({ success: false, error: 'Keine Datei hochgeladen' });
return;
}
const bankCardId = parseInt(req.params.id);
const relativePath = `/uploads/bank-cards/${req.file.filename}`;
// Bankkarte in der DB aktualisieren
await prisma.bankCard.update({
where: { id: bankCardId },
data: { documentPath: relativePath },
});
res.json({
success: true,
data: {
path: relativePath,
filename: req.file.filename,
originalName: req.file.originalname,
size: req.file.size,
},
});
} catch (error) {
console.error('Upload error:', error);
res.status(500).json({ success: false, error: 'Upload fehlgeschlagen' });
}
}
);
// Upload für Ausweis-Dokumente
router.post(
'/documents/:id',
authenticate,
requirePermission('customers:update'),
setUploadDir('documents'),
upload.single('document'),
validateUploadedFile,
async (req: AuthRequest, res: Response) => {
try {
if (!req.file) {
res.status(400).json({ success: false, error: 'Keine Datei hochgeladen' });
return;
}
const documentId = parseInt(req.params.id);
const relativePath = `/uploads/documents/${req.file.filename}`;
// Ausweis in der DB aktualisieren
await prisma.identityDocument.update({
where: { id: documentId },
data: { documentPath: relativePath },
});
res.json({
success: true,
data: {
path: relativePath,
filename: req.file.filename,
originalName: req.file.originalname,
size: req.file.size,
},
});
} catch (error) {
console.error('Upload error:', error);
res.status(500).json({ success: false, error: 'Upload fehlgeschlagen' });
}
}
);
// Löschen von Bankkarten-Dokumenten
router.delete(
'/bank-cards/:id',
authenticate,
requirePermission('customers:update'),
async (req: AuthRequest, res: Response) => {
try {
const bankCardId = parseInt(req.params.id);
// Bankkarte aus DB holen um Dateipfad zu bekommen
const bankCard = await prisma.bankCard.findUnique({
where: { id: bankCardId },
});
if (!bankCard) {
res.status(404).json({ success: false, error: 'Bankkarte nicht gefunden' });
return;
}
if (!bankCard.documentPath) {
res.status(400).json({ success: false, error: 'Kein Dokument vorhanden' });
return;
}
// Datei von Festplatte löschen
const filePath = path.join(process.cwd(), bankCard.documentPath);
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
// documentPath in DB auf null setzen
await prisma.bankCard.update({
where: { id: bankCardId },
data: { documentPath: null },
});
res.json({ success: true });
} catch (error) {
console.error('Delete error:', error);
res.status(500).json({ success: false, error: 'Löschen fehlgeschlagen' });
}
}
);
// Löschen von Ausweis-Dokumenten
router.delete(
'/documents/:id',
authenticate,
requirePermission('customers:update'),
async (req: AuthRequest, res: Response) => {
try {
const documentId = parseInt(req.params.id);
// Ausweis aus DB holen um Dateipfad zu bekommen
const document = await prisma.identityDocument.findUnique({
where: { id: documentId },
});
if (!document) {
res.status(404).json({ success: false, error: 'Ausweis nicht gefunden' });
return;
}
if (!document.documentPath) {
res.status(400).json({ success: false, error: 'Kein Dokument vorhanden' });
return;
}
// Datei von Festplatte löschen
const filePath = path.join(process.cwd(), document.documentPath);
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
// documentPath in DB auf null setzen
await prisma.identityDocument.update({
where: { id: documentId },
data: { documentPath: null },
});
res.json({ success: true });
} catch (error) {
console.error('Delete error:', error);
res.status(500).json({ success: false, error: 'Löschen fehlgeschlagen' });
}
}
);
// ==================== FIRMEN-DOKUMENTE ====================
// Upload für Gewerbeanmeldung
router.post(
'/customers/:id/business-registration',
authenticate,
requirePermission('customers:update'),
setUploadDir('business-registrations'),
upload.single('document'),
validateUploadedFile,
async (req: AuthRequest, res: Response) => {
try {
if (!req.file) {
res.status(400).json({ success: false, error: 'Keine Datei hochgeladen' });
return;
}
const customerId = parseInt(req.params.id);
const relativePath = `/uploads/business-registrations/${req.file.filename}`;
// Alte Datei löschen falls vorhanden
const customer = await prisma.customer.findUnique({ where: { id: customerId } });
if (customer?.businessRegistrationPath) {
const oldPath = path.join(process.cwd(), customer.businessRegistrationPath);
if (fs.existsSync(oldPath)) {
fs.unlinkSync(oldPath);
}
}
// Kunde in der DB aktualisieren
await prisma.customer.update({
where: { id: customerId },
data: { businessRegistrationPath: relativePath },
});
res.json({
success: true,
data: {
path: relativePath,
filename: req.file.filename,
originalName: req.file.originalname,
size: req.file.size,
},
});
} catch (error) {
console.error('Upload error:', error);
res.status(500).json({ success: false, error: 'Upload fehlgeschlagen' });
}
}
);
// Upload für Handelsregisterauszug
router.post(
'/customers/:id/commercial-register',
authenticate,
requirePermission('customers:update'),
setUploadDir('commercial-registers'),
upload.single('document'),
validateUploadedFile,
async (req: AuthRequest, res: Response) => {
try {
if (!req.file) {
res.status(400).json({ success: false, error: 'Keine Datei hochgeladen' });
return;
}
const customerId = parseInt(req.params.id);
const relativePath = `/uploads/commercial-registers/${req.file.filename}`;
// Alte Datei löschen falls vorhanden
const customer = await prisma.customer.findUnique({ where: { id: customerId } });
if (customer?.commercialRegisterPath) {
const oldPath = path.join(process.cwd(), customer.commercialRegisterPath);
if (fs.existsSync(oldPath)) {
fs.unlinkSync(oldPath);
}
}
// Kunde in der DB aktualisieren
await prisma.customer.update({
where: { id: customerId },
data: { commercialRegisterPath: relativePath },
});
res.json({
success: true,
data: {
path: relativePath,
filename: req.file.filename,
originalName: req.file.originalname,
size: req.file.size,
},
});
} catch (error) {
console.error('Upload error:', error);
res.status(500).json({ success: false, error: 'Upload fehlgeschlagen' });
}
}
);
// Löschen der Gewerbeanmeldung
router.delete(
'/customers/:id/business-registration',
authenticate,
requirePermission('customers:update'),
async (req: AuthRequest, res: Response) => {
try {
const customerId = parseInt(req.params.id);
const customer = await prisma.customer.findUnique({ where: { id: customerId } });
if (!customer) {
res.status(404).json({ success: false, error: 'Kunde nicht gefunden' });
return;
}
if (!customer.businessRegistrationPath) {
res.status(400).json({ success: false, error: 'Kein Dokument vorhanden' });
return;
}
// Datei löschen
const filePath = path.join(process.cwd(), customer.businessRegistrationPath);
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
// Pfad in DB auf null setzen
await prisma.customer.update({
where: { id: customerId },
data: { businessRegistrationPath: null },
});
res.json({ success: true });
} catch (error) {
console.error('Delete error:', error);
res.status(500).json({ success: false, error: 'Löschen fehlgeschlagen' });
}
}
);
// Löschen des Handelsregisterauszugs
router.delete(
'/customers/:id/commercial-register',
authenticate,
requirePermission('customers:update'),
async (req: AuthRequest, res: Response) => {
try {
const customerId = parseInt(req.params.id);
const customer = await prisma.customer.findUnique({ where: { id: customerId } });
if (!customer) {
res.status(404).json({ success: false, error: 'Kunde nicht gefunden' });
return;
}
if (!customer.commercialRegisterPath) {
res.status(400).json({ success: false, error: 'Kein Dokument vorhanden' });
return;
}
// Datei löschen
const filePath = path.join(process.cwd(), customer.commercialRegisterPath);
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
// Pfad in DB auf null setzen
await prisma.customer.update({
where: { id: customerId },
data: { commercialRegisterPath: null },
});
res.json({ success: true });
} catch (error) {
console.error('Delete error:', error);
res.status(500).json({ success: false, error: 'Löschen fehlgeschlagen' });
}
}
);
// ==================== DATENSCHUTZERKLÄRUNG (für alle Kunden) ====================
// Upload für Datenschutzerklärung
router.post(
'/customers/:id/privacy-policy',
authenticate,
requirePermission('customers:update'),
setUploadDir('privacy-policies'),
upload.single('document'),
validateUploadedFile,
async (req: AuthRequest, res: Response) => {
try {
if (!req.file) {
res.status(400).json({ success: false, error: 'Keine Datei hochgeladen' });
return;
}
const customerId = parseInt(req.params.id);
const relativePath = `/uploads/privacy-policies/${req.file.filename}`;
// Alte Datei löschen falls vorhanden
const customer = await prisma.customer.findUnique({ where: { id: customerId } });
if (customer?.privacyPolicyPath) {
const oldPath = path.join(process.cwd(), customer.privacyPolicyPath);
if (fs.existsSync(oldPath)) {
fs.unlinkSync(oldPath);
}
}
// Kunde in der DB aktualisieren
await prisma.customer.update({
where: { id: customerId },
data: { privacyPolicyPath: relativePath },
});
// Alle Consents auf GRANTED setzen (PDF = vollständige Einwilligung)
const consentTypes = ['DATA_PROCESSING', 'MARKETING_EMAIL', 'MARKETING_PHONE', 'DATA_SHARING_PARTNER'] as const;
for (const consentType of consentTypes) {
await prisma.customerConsent.upsert({
where: { customerId_consentType: { customerId, consentType } },
update: { status: 'GRANTED', grantedAt: new Date(), source: 'papier' },
create: { customerId, consentType, status: 'GRANTED', grantedAt: new Date(), source: 'papier', createdBy: (req as any).user?.email || 'admin' },
});
}
// Audit
const cust = await prisma.customer.findUnique({ where: { id: customerId }, select: { firstName: true, lastName: true } });
await logChange({
req, action: 'CREATE', resourceType: 'CustomerConsent',
label: `Datenschutzerklärung-PDF hochgeladen für ${cust?.firstName} ${cust?.lastName} alle Einwilligungen erteilt`,
details: { aktion: 'PDF hochgeladen', einwilligungen: 'alle erteilt', quelle: 'papier' },
customerId,
});
res.json({
success: true,
data: {
path: relativePath,
filename: req.file.filename,
originalName: req.file.originalname,
size: req.file.size,
},
});
} catch (error) {
console.error('Upload error:', error);
res.status(500).json({ success: false, error: 'Upload fehlgeschlagen' });
}
}
);
// Löschen der Datenschutzerklärung
router.delete(
'/customers/:id/privacy-policy',
authenticate,
requirePermission('customers:update'),
async (req: AuthRequest, res: Response) => {
try {
const customerId = parseInt(req.params.id);
const customer = await prisma.customer.findUnique({ where: { id: customerId } });
if (!customer) {
res.status(404).json({ success: false, error: 'Kunde nicht gefunden' });
return;
}
if (!customer.privacyPolicyPath) {
res.status(400).json({ success: false, error: 'Kein Dokument vorhanden' });
return;
}
// Datei löschen
const filePath = path.join(process.cwd(), customer.privacyPolicyPath);
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
// Pfad in DB auf null setzen
await prisma.customer.update({
where: { id: customerId },
data: { privacyPolicyPath: null },
});
// Nur Consents widerrufen die per Papier erteilt wurden
await prisma.customerConsent.updateMany({
where: { customerId, status: 'GRANTED', source: 'papier' },
data: { status: 'WITHDRAWN', withdrawnAt: new Date() },
});
// Audit
const cust = await prisma.customer.findUnique({ where: { id: customerId }, select: { firstName: true, lastName: true } });
await logChange({
req, action: 'DELETE', resourceType: 'CustomerConsent',
label: `Datenschutzerklärung-PDF gelöscht für ${cust?.firstName} ${cust?.lastName} Papier-Einwilligungen widerrufen`,
details: { aktion: 'PDF gelöscht', einwilligungen: 'papier-basierte widerrufen' },
customerId,
});
res.json({ success: true });
} catch (error) {
console.error('Delete error:', error);
res.status(500).json({ success: false, error: 'Löschen fehlgeschlagen' });
}
}
);
// ==================== VERTRAGS-DOKUMENTE ====================
// Generische Funktion für Vertrags-Dokument Upload
async function handleContractDocumentUpload(
req: AuthRequest,
res: Response,
fieldName: 'cancellationLetterPath' | 'cancellationConfirmationPath' | 'cancellationLetterOptionsPath' | 'cancellationConfirmationOptionsPath',
subDir: string
) {
try {
if (!req.file) {
res.status(400).json({ success: false, error: 'Keine Datei hochgeladen' });
return;
}
const contractId = parseInt(req.params.id);
if (!(await canAccessContract(req, res, contractId))) return;
const relativePath = `/uploads/${subDir}/${req.file.filename}`;
// Alte Datei löschen falls vorhanden
const contract = await prisma.contract.findUnique({ where: { id: contractId } });
if (!contract) {
res.status(404).json({ success: false, error: 'Vertrag nicht gefunden' });
return;
}
const oldPath = contract[fieldName];
if (oldPath) {
const fullPath = path.join(process.cwd(), oldPath);
if (fs.existsSync(fullPath)) {
fs.unlinkSync(fullPath);
}
}
// Bei Kündigungsbestätigung(s-Optionen): optionales Datum aus multipart
// übernehmen. Ohne Angabe: falls Feld noch leer → heute, sonst nicht anfassen.
const updateData: Record<string, unknown> = { [fieldName]: relativePath };
if (fieldName === 'cancellationConfirmationPath' || fieldName === 'cancellationConfirmationOptionsPath') {
const dateField = fieldName === 'cancellationConfirmationPath'
? 'cancellationConfirmationDate'
: 'cancellationConfirmationOptionsDate';
const provided = typeof req.body?.confirmationDate === 'string' ? req.body.confirmationDate : null;
let target: Date | null = null;
if (provided) {
const parsed = new Date(provided);
if (!isNaN(parsed.getTime())) target = parsed;
}
if (target) {
updateData[dateField] = target;
} else if (!contract[dateField]) {
updateData[dateField] = new Date();
}
}
// Vertrag in der DB aktualisieren
await prisma.contract.update({
where: { id: contractId },
data: updateData,
});
// Wenn eine Kündigungsbestätigung (nicht "Optionen") hochgeladen wurde und
// der Vertrag noch ACTIVE ist → auf CANCELLED umstellen + Audit-Log.
// "Optionen" ist für Vertrags-Änderungen gedacht, nicht für echte Kündigungen.
if (fieldName === 'cancellationConfirmationPath' && contract.status === 'ACTIVE') {
await prisma.contract.update({
where: { id: contractId },
data: { status: 'CANCELLED' },
});
await logChange({
req,
action: 'UPDATE',
resourceType: 'Contract',
resourceId: contractId.toString(),
label: `Vertrag ${contract.contractNumber} automatisch auf CANCELLED gesetzt (Kündigungsbestätigung hochgeladen)`,
details: { vorher: 'ACTIVE', nachher: 'CANCELLED', trigger: 'cancellationConfirmation-Upload' },
customerId: contract.customerId,
});
}
res.json({
success: true,
data: {
path: relativePath,
filename: req.file.filename,
originalName: req.file.originalname,
size: req.file.size,
},
});
} catch (error) {
console.error('Upload error:', error);
res.status(500).json({ success: false, error: 'Upload fehlgeschlagen' });
}
}
// Generische Funktion für Vertrags-Dokument Löschen
async function handleContractDocumentDelete(
req: AuthRequest,
res: Response,
fieldName: 'cancellationLetterPath' | 'cancellationConfirmationPath' | 'cancellationLetterOptionsPath' | 'cancellationConfirmationOptionsPath'
) {
try {
const contractId = parseInt(req.params.id);
if (!(await canAccessContract(req, res, contractId))) return;
const contract = await prisma.contract.findUnique({ where: { id: contractId } });
if (!contract) {
res.status(404).json({ success: false, error: 'Vertrag nicht gefunden' });
return;
}
const documentPath = contract[fieldName];
if (!documentPath) {
res.status(400).json({ success: false, error: 'Kein Dokument vorhanden' });
return;
}
// Datei löschen
const filePath = path.join(process.cwd(), documentPath);
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
// Pfad in DB auf null setzen
await prisma.contract.update({
where: { id: contractId },
data: { [fieldName]: null },
});
res.json({ success: true });
} catch (error) {
console.error('Delete error:', error);
res.status(500).json({ success: false, error: 'Löschen fehlgeschlagen' });
}
}
// Kündigungsschreiben
router.post(
'/contracts/:id/cancellation-letter',
authenticate,
requirePermission('contracts:update'),
setUploadDir('cancellation-letters'),
upload.single('document'),
validateUploadedFile,
(req: AuthRequest, res: Response) => handleContractDocumentUpload(req, res, 'cancellationLetterPath', 'cancellation-letters')
);
router.delete(
'/contracts/:id/cancellation-letter',
authenticate,
requirePermission('contracts:update'),
(req: AuthRequest, res: Response) => handleContractDocumentDelete(req, res, 'cancellationLetterPath')
);
// Kündigungsbestätigung
router.post(
'/contracts/:id/cancellation-confirmation',
authenticate,
requirePermission('contracts:update'),
setUploadDir('cancellation-confirmations'),
upload.single('document'),
validateUploadedFile,
(req: AuthRequest, res: Response) => handleContractDocumentUpload(req, res, 'cancellationConfirmationPath', 'cancellation-confirmations')
);
router.delete(
'/contracts/:id/cancellation-confirmation',
authenticate,
requirePermission('contracts:update'),
(req: AuthRequest, res: Response) => handleContractDocumentDelete(req, res, 'cancellationConfirmationPath')
);
// Kündigungsschreiben Optionen
router.post(
'/contracts/:id/cancellation-letter-options',
authenticate,
requirePermission('contracts:update'),
setUploadDir('cancellation-letters-options'),
upload.single('document'),
validateUploadedFile,
(req: AuthRequest, res: Response) => handleContractDocumentUpload(req, res, 'cancellationLetterOptionsPath', 'cancellation-letters-options')
);
router.delete(
'/contracts/:id/cancellation-letter-options',
authenticate,
requirePermission('contracts:update'),
(req: AuthRequest, res: Response) => handleContractDocumentDelete(req, res, 'cancellationLetterOptionsPath')
);
// Kündigungsbestätigung Optionen
router.post(
'/contracts/:id/cancellation-confirmation-options',
authenticate,
requirePermission('contracts:update'),
setUploadDir('cancellation-confirmations-options'),
upload.single('document'),
validateUploadedFile,
(req: AuthRequest, res: Response) => handleContractDocumentUpload(req, res, 'cancellationConfirmationOptionsPath', 'cancellation-confirmations-options')
);
router.delete(
'/contracts/:id/cancellation-confirmation-options',
authenticate,
requirePermission('contracts:update'),
(req: AuthRequest, res: Response) => handleContractDocumentDelete(req, res, 'cancellationConfirmationOptionsPath')
);
// ==================== RECHNUNGS-DOKUMENTE ====================
// Upload für Rechnungs-Dokument
router.post(
'/invoices/:id',
authenticate,
requirePermission('contracts:update'),
setUploadDir('invoices'),
upload.single('document'),
validateUploadedFile,
async (req: AuthRequest, res: Response) => {
try {
if (!req.file) {
res.status(400).json({ success: false, error: 'Keine Datei hochgeladen' });
return;
}
const invoiceId = parseInt(req.params.id);
const relativePath = `/uploads/invoices/${req.file.filename}`;
// Alte Datei löschen falls vorhanden
const invoice = await prisma.invoice.findUnique({ where: { id: invoiceId } });
if (!invoice) {
res.status(404).json({ success: false, error: 'Rechnung nicht gefunden' });
return;
}
if (invoice.documentPath) {
const oldPath = path.join(process.cwd(), invoice.documentPath);
if (fs.existsSync(oldPath)) {
fs.unlinkSync(oldPath);
}
}
// Invoice in der DB aktualisieren
await prisma.invoice.update({
where: { id: invoiceId },
data: { documentPath: relativePath },
});
res.json({
success: true,
data: {
path: relativePath,
filename: req.file.filename,
originalName: req.file.originalname,
size: req.file.size,
},
});
} catch (error) {
console.error('Invoice upload error:', error);
res.status(500).json({ success: false, error: 'Upload fehlgeschlagen' });
}
}
);
// Löschen von Rechnungs-Dokument
router.delete(
'/invoices/:id',
authenticate,
requirePermission('contracts:update'),
async (req: AuthRequest, res: Response) => {
try {
const invoiceId = parseInt(req.params.id);
const invoice = await prisma.invoice.findUnique({ where: { id: invoiceId } });
if (!invoice) {
res.status(404).json({ success: false, error: 'Rechnung nicht gefunden' });
return;
}
if (!invoice.documentPath) {
res.status(400).json({ success: false, error: 'Kein Dokument vorhanden' });
return;
}
// Datei löschen
const filePath = path.join(process.cwd(), invoice.documentPath);
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
// documentPath in DB auf null setzen
await prisma.invoice.update({
where: { id: invoiceId },
data: { documentPath: null },
});
res.json({ success: true });
} catch (error) {
console.error('Invoice delete error:', error);
res.status(500).json({ success: false, error: 'Löschen fehlgeschlagen' });
}
}
);
export default router;