Hygiene R140: Datei-Loesch-Helfer konsolidieren + DB-vor-Datei

Pentester-Hygiene zu a6b1dac:

1) Konsolidierung: neuer utils/fileCleanup.ts mit deleteFileAbsolute
   (absoluter Pfad, z.B. Multer-Temp) + deleteUploadByRelativePath
   (in DB gespeicherter /uploads/-Pfad). Ersetzt die 3x kopierten
   deleteFileIfExists/cleanupFile in creditNote-, upload- und
   customer-Service.

2) Reihenfolge: In deleteCreditNote/updateCreditNote erst die DB-
   Operation, DANN die Datei loeschen. Schlaegt der DB-Schritt fehl,
   bleibt die Datei erhalten (kein ins-Leere-zeigender Zustand).

Verifiziert: Update -> pdfPath null + alte Datei weg; Delete -> gibt
geloeschte Row zurueck (Audit) + Datei weg. Kein Regression.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-08 22:57:40 +02:00
co-authored by Claude Opus 4.8
parent c29ffd7bea
commit 3e2d9395a7
5 changed files with 75 additions and 69 deletions
+19 -23
View File
@@ -15,6 +15,7 @@ import {
import { validateOptionalIsoDate } from '../utils/sanitize.js'; import { validateOptionalIsoDate } from '../utils/sanitize.js';
import { validateUploadedFile } from '../middleware/uploadFileTypeValidator.js'; import { validateUploadedFile } from '../middleware/uploadFileTypeValidator.js';
import { maybeCancelOnCancellationConfirmation } from '../services/contractStatusScheduler.service.js'; import { maybeCancelOnCancellationConfirmation } from '../services/contractStatusScheduler.service.js';
import { deleteFileAbsolute, deleteUploadByRelativePath } from '../utils/fileCleanup.js';
// Pentest 56.1 (HIGH, 2026-06-01): Upload-Endpoints prüften nur die // Pentest 56.1 (HIGH, 2026-06-01): Upload-Endpoints prüften nur die
// Permission, nicht ob die Ziel-Resource zum Caller passt. Helper-Funktion // Permission, nicht ob die Ziel-Resource zum Caller passt. Helper-Funktion
@@ -30,11 +31,6 @@ async function resolveInvoiceContractId(invoiceId: number): Promise<number | nul
return invoice?.contractId ?? invoice?.energyContractDetails?.contractId ?? null; return invoice?.contractId ?? invoice?.energyContractDetails?.contractId ?? null;
} }
function cleanupFile(filePath?: string) {
if (!filePath) return;
try { fs.unlinkSync(filePath); } catch { /* ignore */ }
}
const router = Router(); const router = Router();
// Uploads-Verzeichnis erstellen falls nicht vorhanden // Uploads-Verzeichnis erstellen falls nicht vorhanden
@@ -122,12 +118,12 @@ router.post(
// Pentest 56.1: Existenz- und Ownership-Check VOR DB-Update. // Pentest 56.1: Existenz- und Ownership-Check VOR DB-Update.
const card = await prisma.bankCard.findUnique({ where: { id: bankCardId } }); const card = await prisma.bankCard.findUnique({ where: { id: bankCardId } });
if (!card) { if (!card) {
cleanupFile(req.file.path); deleteFileAbsolute(req.file.path);
res.status(404).json({ success: false, error: 'Bankkarte nicht gefunden' }); res.status(404).json({ success: false, error: 'Bankkarte nicht gefunden' });
return; return;
} }
if (!(await canAccessBankCard(req, res, bankCardId))) { if (!(await canAccessBankCard(req, res, bankCardId))) {
cleanupFile(req.file.path); deleteFileAbsolute(req.file.path);
return; return;
} }
@@ -149,7 +145,7 @@ router.post(
}); });
} catch (error) { } catch (error) {
console.error('Upload error:', error); console.error('Upload error:', error);
cleanupFile(req.file?.path); deleteFileAbsolute(req.file?.path);
res.status(500).json({ success: false, error: 'Upload fehlgeschlagen' }); res.status(500).json({ success: false, error: 'Upload fehlgeschlagen' });
} }
} }
@@ -174,12 +170,12 @@ router.post(
// Pentest 56.1: Existenz- und Ownership-Check VOR DB-Update. // Pentest 56.1: Existenz- und Ownership-Check VOR DB-Update.
const doc = await prisma.identityDocument.findUnique({ where: { id: documentId } }); const doc = await prisma.identityDocument.findUnique({ where: { id: documentId } });
if (!doc) { if (!doc) {
cleanupFile(req.file.path); deleteFileAbsolute(req.file.path);
res.status(404).json({ success: false, error: 'Ausweis nicht gefunden' }); res.status(404).json({ success: false, error: 'Ausweis nicht gefunden' });
return; return;
} }
if (!(await canAccessIdentityDocument(req, res, documentId))) { if (!(await canAccessIdentityDocument(req, res, documentId))) {
cleanupFile(req.file.path); deleteFileAbsolute(req.file.path);
return; return;
} }
@@ -201,7 +197,7 @@ router.post(
}); });
} catch (error) { } catch (error) {
console.error('Upload error:', error); console.error('Upload error:', error);
cleanupFile(req.file?.path); deleteFileAbsolute(req.file?.path);
res.status(500).json({ success: false, error: 'Upload fehlgeschlagen' }); res.status(500).json({ success: false, error: 'Upload fehlgeschlagen' });
} }
} }
@@ -319,7 +315,7 @@ router.post(
const customerId = parseInt(req.params.id); const customerId = parseInt(req.params.id);
// Pentest 56.1: Ownership-Check. // Pentest 56.1: Ownership-Check.
if (!(await canAccessCustomer(req, res, customerId))) { if (!(await canAccessCustomer(req, res, customerId))) {
cleanupFile(req.file.path); deleteFileAbsolute(req.file.path);
return; return;
} }
const relativePath = `/uploads/business-registrations/${req.file.filename}`; const relativePath = `/uploads/business-registrations/${req.file.filename}`;
@@ -373,7 +369,7 @@ router.post(
const customerId = parseInt(req.params.id); const customerId = parseInt(req.params.id);
// Pentest 56.1: Ownership-Check. // Pentest 56.1: Ownership-Check.
if (!(await canAccessCustomer(req, res, customerId))) { if (!(await canAccessCustomer(req, res, customerId))) {
cleanupFile(req.file.path); deleteFileAbsolute(req.file.path);
return; return;
} }
const relativePath = `/uploads/commercial-registers/${req.file.filename}`; const relativePath = `/uploads/commercial-registers/${req.file.filename}`;
@@ -516,7 +512,7 @@ router.post(
// jede beliebige customerId ALLE Einwilligungen auf GRANTED setzen // jede beliebige customerId ALLE Einwilligungen auf GRANTED setzen
// (DSGVO-Eskalation). // (DSGVO-Eskalation).
if (!(await canAccessCustomer(req, res, customerId))) { if (!(await canAccessCustomer(req, res, customerId))) {
cleanupFile(req.file.path); deleteFileAbsolute(req.file.path);
return; return;
} }
const relativePath = `/uploads/privacy-policies/${req.file.filename}`; const relativePath = `/uploads/privacy-policies/${req.file.filename}`;
@@ -674,7 +670,7 @@ async function handleContractDocumentUpload(
try { try {
provided = validateOptionalIsoDate(req.body?.confirmationDate, 'confirmationDate'); provided = validateOptionalIsoDate(req.body?.confirmationDate, 'confirmationDate');
} catch (err) { } catch (err) {
cleanupFile(req.file?.path); deleteFileAbsolute(req.file?.path);
res.status(400).json({ success: false, error: err instanceof Error ? err.message : 'Ungültiges Bestätigungsdatum' }); res.status(400).json({ success: false, error: err instanceof Error ? err.message : 'Ungültiges Bestätigungsdatum' });
return; return;
} }
@@ -851,13 +847,13 @@ router.post(
// Pentest 56.1: Existenz- und Ownership-Check VOR DB-Update. // Pentest 56.1: Existenz- und Ownership-Check VOR DB-Update.
const invoice = await prisma.invoice.findUnique({ where: { id: invoiceId } }); const invoice = await prisma.invoice.findUnique({ where: { id: invoiceId } });
if (!invoice) { if (!invoice) {
cleanupFile(req.file.path); deleteFileAbsolute(req.file.path);
res.status(404).json({ success: false, error: 'Rechnung nicht gefunden' }); res.status(404).json({ success: false, error: 'Rechnung nicht gefunden' });
return; return;
} }
const invoiceContractId = await resolveInvoiceContractId(invoiceId); const invoiceContractId = await resolveInvoiceContractId(invoiceId);
if (invoiceContractId == null || !(await canAccessContract(req, res, invoiceContractId))) { if (invoiceContractId == null || !(await canAccessContract(req, res, invoiceContractId))) {
cleanupFile(req.file.path); deleteFileAbsolute(req.file.path);
return; return;
} }
@@ -946,7 +942,7 @@ router.post(
async (req: AuthRequest, res: Response) => { async (req: AuthRequest, res: Response) => {
try { try {
if (req.user?.isCustomerPortal) { if (req.user?.isCustomerPortal) {
cleanupFile(req.file?.path); deleteFileAbsolute(req.file?.path);
res.status(403).json({ success: false, error: 'Kein Zugriff' }); res.status(403).json({ success: false, error: 'Kein Zugriff' });
return; return;
} }
@@ -960,16 +956,16 @@ router.post(
select: { contractId: true, receiptPath: true }, select: { contractId: true, receiptPath: true },
}); });
if (!cn) { if (!cn) {
cleanupFile(req.file.path); deleteFileAbsolute(req.file.path);
res.status(404).json({ success: false, error: 'Gutschrift nicht gefunden' }); res.status(404).json({ success: false, error: 'Gutschrift nicht gefunden' });
return; return;
} }
if (!(await canAccessContract(req, res, cn.contractId))) { if (!(await canAccessContract(req, res, cn.contractId))) {
cleanupFile(req.file.path); deleteFileAbsolute(req.file.path);
return; return;
} }
const relativePath = `/uploads/credit-note-receipts/${req.file.filename}`; const relativePath = `/uploads/credit-note-receipts/${req.file.filename}`;
if (cn.receiptPath) cleanupFile(path.join(process.cwd(), cn.receiptPath)); deleteUploadByRelativePath(cn.receiptPath);
await prisma.creditNote.update({ where: { id }, data: { receiptPath: relativePath } }); await prisma.creditNote.update({ where: { id }, data: { receiptPath: relativePath } });
await logChange({ await logChange({
req, action: 'UPDATE', resourceType: 'CreditNote', resourceId: id.toString(), req, action: 'UPDATE', resourceType: 'CreditNote', resourceId: id.toString(),
@@ -978,7 +974,7 @@ router.post(
res.json({ success: true, data: { receiptPath: relativePath } }); res.json({ success: true, data: { receiptPath: relativePath } });
} catch (error) { } catch (error) {
console.error('Credit-note receipt upload error:', error); console.error('Credit-note receipt upload error:', error);
cleanupFile(req.file?.path); deleteFileAbsolute(req.file?.path);
res.status(500).json({ success: false, error: 'Upload fehlgeschlagen' }); res.status(500).json({ success: false, error: 'Upload fehlgeschlagen' });
} }
} }
@@ -1004,7 +1000,7 @@ router.delete(
return; return;
} }
if (!(await canAccessContract(req, res, cn.contractId))) return; if (!(await canAccessContract(req, res, cn.contractId))) return;
if (cn.receiptPath) cleanupFile(path.join(process.cwd(), cn.receiptPath)); deleteUploadByRelativePath(cn.receiptPath);
await prisma.creditNote.update({ where: { id }, data: { receiptPath: null } }); await prisma.creditNote.update({ where: { id }, data: { receiptPath: null } });
await logChange({ await logChange({
req, action: 'UPDATE', resourceType: 'CreditNote', resourceId: id.toString(), req, action: 'UPDATE', resourceType: 'CreditNote', resourceId: id.toString(),
+13 -24
View File
@@ -2,26 +2,12 @@
// CRUD für Vertrags-Gutschriften (Subventionen: Geld/Sachwert) inkl. // CRUD für Vertrags-Gutschriften (Subventionen: Geld/Sachwert) inkl.
// USt-Berechnung (pro Gutschrift wählbar: vatRelevant + Basis Netto/Brutto). // USt-Berechnung (pro Gutschrift wählbar: vatRelevant + Basis Netto/Brutto).
import fs from 'fs';
import path from 'path';
import prisma from '../lib/prisma.js'; import prisma from '../lib/prisma.js';
import { ApiError } from '../utils/apiError.js'; import { ApiError } from '../utils/apiError.js';
import { assignNextNumber } from './creditNoteNumberRange.service.js'; import { assignNextNumber } from './creditNoteNumberRange.service.js';
import { deleteUploadByRelativePath } from '../utils/fileCleanup.js';
import { CreditNoteType, CreditNoteCustomerType, CreditNoteAmountBasis } from '@prisma/client'; import { CreditNoteType, CreditNoteCustomerType, CreditNoteAmountBasis } from '@prisma/client';
// Löscht eine hochgeladene/erzeugte Datei von der Platte (best effort).
function deleteFileIfExists(filePath: string | null) {
if (!filePath) return;
const absolute = path.join(process.cwd(), filePath);
if (fs.existsSync(absolute)) {
try {
fs.unlinkSync(absolute);
} catch (error) {
console.error('Fehler beim Löschen der Gutschrift-Datei:', absolute, error);
}
}
}
const round2 = (n: number) => Math.round((n + Number.EPSILON) * 100) / 100; const round2 = (n: number) => Math.round((n + Number.EPSILON) * 100) / 100;
export interface AmountResult { export interface AmountResult {
@@ -245,10 +231,11 @@ export async function updateCreditNote(id: number, input: CreateCreditNoteInput)
} }
// Nummer bleibt unverändert (einmal vergeben = fix). Ein evtl. schon // Nummer bleibt unverändert (einmal vergeben = fix). Ein evtl. schon
// erzeugtes PDF ist nach inhaltlicher Änderung veraltet → Pfad leeren // erzeugtes PDF ist nach inhaltlicher Änderung veraltet → Pfad leeren
// (der User erzeugt es bei Bedarf neu) und die alte Datei entfernen, // (der User erzeugt es bei Bedarf neu). Reihenfolge (R140): erst DB-Update,
// damit sie nicht verwaist liegen bleibt. // DANN die alte Datei löschen schlägt das Update fehl, bleibt die Datei.
if (existing.pdfPath) deleteFileIfExists(existing.pdfPath); const updated = await prisma.creditNote.update({ where: { id }, data: { ...normalized, pdfPath: null } });
return prisma.creditNote.update({ where: { id }, data: { ...normalized, pdfPath: null } }); deleteUploadByRelativePath(existing.pdfPath);
return updated;
} }
export async function deleteCreditNote(id: number) { export async function deleteCreditNote(id: number) {
@@ -256,11 +243,13 @@ export async function deleteCreditNote(id: number) {
if (!existing) { if (!existing) {
throw new ApiError(404, 'Gutschrift nicht gefunden'); throw new ApiError(404, 'Gutschrift nicht gefunden');
} }
// Verwaiste Dateien vermeiden: generiertes PDF + Überweisungsbeleg von der // Reihenfolge (R140): erst den DB-Datensatz löschen, DANN die Dateien
// Platte entfernen (Pentest R138-Hinweis). // schlägt das DB-Delete fehl, bleiben PDF + Beleg erhalten (kein
deleteFileIfExists(existing.pdfPath); // ins-Leere-zeigender Eintrag).
deleteFileIfExists(existing.receiptPath); const deleted = await prisma.creditNote.delete({ where: { id } });
return prisma.creditNote.delete({ where: { id } }); deleteUploadByRelativePath(existing.pdfPath);
deleteUploadByRelativePath(existing.receiptPath);
return deleted;
} }
// Setzt/aktualisiert den Pfad des hochgeladenen Überweisungsbelegs. // Setzt/aktualisiert den Pfad des hochgeladenen Überweisungsbelegs.
+8 -22
View File
@@ -2,21 +2,7 @@ import { CustomerType, ContractStatus } from '@prisma/client';
import prisma from '../lib/prisma.js'; import prisma from '../lib/prisma.js';
import { generateCustomerNumber, paginate, buildPaginationResponse } from '../utils/helpers.js'; import { generateCustomerNumber, paginate, buildPaginationResponse } from '../utils/helpers.js';
import { ApiError } from '../utils/apiError.js'; import { ApiError } from '../utils/apiError.js';
import fs from 'fs'; import { deleteUploadByRelativePath } from '../utils/fileCleanup.js';
import path from 'path';
// Helper zum Löschen von Dateien
function deleteFileIfExists(filePath: string | null) {
if (!filePath) return;
const absolutePath = path.join(process.cwd(), filePath);
if (fs.existsSync(absolutePath)) {
try {
fs.unlinkSync(absolutePath);
} catch (error) {
console.error('Fehler beim Löschen der Datei:', absolutePath, error);
}
}
}
export interface CustomerFilters { export interface CustomerFilters {
search?: string; search?: string;
@@ -205,17 +191,17 @@ export async function deleteCustomer(id: number) {
// Kundendokumente löschen // Kundendokumente löschen
if (customer) { if (customer) {
deleteFileIfExists(customer.businessRegistrationPath); deleteUploadByRelativePath(customer.businessRegistrationPath);
deleteFileIfExists(customer.commercialRegisterPath); deleteUploadByRelativePath(customer.commercialRegisterPath);
deleteFileIfExists(customer.privacyPolicyPath); deleteUploadByRelativePath(customer.privacyPolicyPath);
} }
// Bankkarten- und Ausweisdokumente löschen // Bankkarten- und Ausweisdokumente löschen
for (const card of bankCards) { for (const card of bankCards) {
deleteFileIfExists(card.documentPath); deleteUploadByRelativePath(card.documentPath);
} }
for (const doc of identityDocs) { for (const doc of identityDocs) {
deleteFileIfExists(doc.documentPath); deleteUploadByRelativePath(doc.documentPath);
} }
// Jetzt DB-Eintrag löschen (Cascade löscht die verknüpften Einträge) // Jetzt DB-Eintrag löschen (Cascade löscht die verknüpften Einträge)
@@ -353,7 +339,7 @@ export async function deleteBankCard(id: number) {
// Erst Datei-Pfad holen, dann Datei löschen, dann DB-Eintrag löschen // Erst Datei-Pfad holen, dann Datei löschen, dann DB-Eintrag löschen
const bankCard = await prisma.bankCard.findUnique({ where: { id } }); const bankCard = await prisma.bankCard.findUnique({ where: { id } });
if (bankCard?.documentPath) { if (bankCard?.documentPath) {
deleteFileIfExists(bankCard.documentPath); deleteUploadByRelativePath(bankCard.documentPath);
} }
return prisma.bankCard.delete({ where: { id } }); return prisma.bankCard.delete({ where: { id } });
} }
@@ -417,7 +403,7 @@ export async function deleteDocument(id: number) {
// Erst Datei-Pfad holen, dann Datei löschen, dann DB-Eintrag löschen // Erst Datei-Pfad holen, dann Datei löschen, dann DB-Eintrag löschen
const document = await prisma.identityDocument.findUnique({ where: { id } }); const document = await prisma.identityDocument.findUnique({ where: { id } });
if (document?.documentPath) { if (document?.documentPath) {
deleteFileIfExists(document.documentPath); deleteUploadByRelativePath(document.documentPath);
} }
return prisma.identityDocument.delete({ where: { id } }); return prisma.identityDocument.delete({ where: { id } });
} }
+29
View File
@@ -0,0 +1,29 @@
// ==================== DATEI-AUFRÄUMEN (Uploads) ====================
// Gemeinsame Helfer zum Best-Effort-Löschen von Upload-Dateien. Ersetzt die
// vorher mehrfach kopierten `deleteFileIfExists`/`cleanupFile`-Funktionen
// (Pentest R140, Hygiene: eine Quelle statt Duplikate).
import fs from 'fs';
import path from 'path';
/**
* Löscht eine Datei anhand ihres ABSOLUTEN Pfads. Wirft nie (loggt nur)
* z.B. für Multer-Temp-Dateien (`req.file.path`).
*/
export function deleteFileAbsolute(absolutePath: string | null | undefined): void {
if (!absolutePath) return;
try {
if (fs.existsSync(absolutePath)) fs.unlinkSync(absolutePath);
} catch (error) {
console.error('Fehler beim Löschen der Datei:', absolutePath, error);
}
}
/**
* Löscht eine Datei anhand ihres in der DB gespeicherten RELATIVEN Upload-
* Pfads (z.B. `/uploads/credit-notes/…`), aufgelöst gegen `process.cwd()`.
*/
export function deleteUploadByRelativePath(relativePath: string | null | undefined): void {
if (!relativePath) return;
deleteFileAbsolute(path.join(process.cwd(), relativePath));
}
+6
View File
@@ -143,6 +143,12 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
- **Datei-Cleanup (R138-Hinweis):** Beim Löschen einer Gutschrift werden - **Datei-Cleanup (R138-Hinweis):** Beim Löschen einer Gutschrift werden
PDF + Beleg von der Platte entfernt; beim Bearbeiten (pdfPath wird PDF + Beleg von der Platte entfernt; beim Bearbeiten (pdfPath wird
geleert) wird das alte PDF gelöscht → keine verwaisten Dateien mehr. geleert) wird das alte PDF gelöscht → keine verwaisten Dateien mehr.
- **Hygiene R140:** Datei-Lösch-Helfer in `utils/fileCleanup.ts`
konsolidiert (`deleteFileAbsolute` + `deleteUploadByRelativePath`),
ersetzt die vorher 3× kopierten `deleteFileIfExists`/`cleanupFile`
(creditNote-, upload-, customer-Service). Reihenfolge umgestellt:
erst DB-Delete/-Update, DANN Datei löschen (schlägt DB fehl, bleibt
die Datei). Verifiziert.
- [~] **🧾 Gutschriftsverwaltung (Subventionen am Vertrag) Phase 1: Backend** (2026-08-06) - [~] **🧾 Gutschriftsverwaltung (Subventionen am Vertrag) Phase 1: Backend** (2026-08-06)
- Use-Case: zu einem Vertrag kann eine Subvention gewährt werden **Geld** - Use-Case: zu einem Vertrag kann eine Subvention gewährt werden **Geld**