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:
@@ -15,6 +15,7 @@ import {
|
||||
import { validateOptionalIsoDate } from '../utils/sanitize.js';
|
||||
import { validateUploadedFile } from '../middleware/uploadFileTypeValidator.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
|
||||
// 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;
|
||||
}
|
||||
|
||||
function cleanupFile(filePath?: string) {
|
||||
if (!filePath) return;
|
||||
try { fs.unlinkSync(filePath); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Uploads-Verzeichnis erstellen falls nicht vorhanden
|
||||
@@ -122,12 +118,12 @@ router.post(
|
||||
// Pentest 56.1: Existenz- und Ownership-Check VOR DB-Update.
|
||||
const card = await prisma.bankCard.findUnique({ where: { id: bankCardId } });
|
||||
if (!card) {
|
||||
cleanupFile(req.file.path);
|
||||
deleteFileAbsolute(req.file.path);
|
||||
res.status(404).json({ success: false, error: 'Bankkarte nicht gefunden' });
|
||||
return;
|
||||
}
|
||||
if (!(await canAccessBankCard(req, res, bankCardId))) {
|
||||
cleanupFile(req.file.path);
|
||||
deleteFileAbsolute(req.file.path);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -149,7 +145,7 @@ router.post(
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
cleanupFile(req.file?.path);
|
||||
deleteFileAbsolute(req.file?.path);
|
||||
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.
|
||||
const doc = await prisma.identityDocument.findUnique({ where: { id: documentId } });
|
||||
if (!doc) {
|
||||
cleanupFile(req.file.path);
|
||||
deleteFileAbsolute(req.file.path);
|
||||
res.status(404).json({ success: false, error: 'Ausweis nicht gefunden' });
|
||||
return;
|
||||
}
|
||||
if (!(await canAccessIdentityDocument(req, res, documentId))) {
|
||||
cleanupFile(req.file.path);
|
||||
deleteFileAbsolute(req.file.path);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -201,7 +197,7 @@ router.post(
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
cleanupFile(req.file?.path);
|
||||
deleteFileAbsolute(req.file?.path);
|
||||
res.status(500).json({ success: false, error: 'Upload fehlgeschlagen' });
|
||||
}
|
||||
}
|
||||
@@ -319,7 +315,7 @@ router.post(
|
||||
const customerId = parseInt(req.params.id);
|
||||
// Pentest 56.1: Ownership-Check.
|
||||
if (!(await canAccessCustomer(req, res, customerId))) {
|
||||
cleanupFile(req.file.path);
|
||||
deleteFileAbsolute(req.file.path);
|
||||
return;
|
||||
}
|
||||
const relativePath = `/uploads/business-registrations/${req.file.filename}`;
|
||||
@@ -373,7 +369,7 @@ router.post(
|
||||
const customerId = parseInt(req.params.id);
|
||||
// Pentest 56.1: Ownership-Check.
|
||||
if (!(await canAccessCustomer(req, res, customerId))) {
|
||||
cleanupFile(req.file.path);
|
||||
deleteFileAbsolute(req.file.path);
|
||||
return;
|
||||
}
|
||||
const relativePath = `/uploads/commercial-registers/${req.file.filename}`;
|
||||
@@ -516,7 +512,7 @@ router.post(
|
||||
// jede beliebige customerId ALLE Einwilligungen auf GRANTED setzen
|
||||
// (DSGVO-Eskalation).
|
||||
if (!(await canAccessCustomer(req, res, customerId))) {
|
||||
cleanupFile(req.file.path);
|
||||
deleteFileAbsolute(req.file.path);
|
||||
return;
|
||||
}
|
||||
const relativePath = `/uploads/privacy-policies/${req.file.filename}`;
|
||||
@@ -674,7 +670,7 @@ async function handleContractDocumentUpload(
|
||||
try {
|
||||
provided = validateOptionalIsoDate(req.body?.confirmationDate, 'confirmationDate');
|
||||
} 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' });
|
||||
return;
|
||||
}
|
||||
@@ -851,13 +847,13 @@ router.post(
|
||||
// Pentest 56.1: Existenz- und Ownership-Check VOR DB-Update.
|
||||
const invoice = await prisma.invoice.findUnique({ where: { id: invoiceId } });
|
||||
if (!invoice) {
|
||||
cleanupFile(req.file.path);
|
||||
deleteFileAbsolute(req.file.path);
|
||||
res.status(404).json({ success: false, error: 'Rechnung nicht gefunden' });
|
||||
return;
|
||||
}
|
||||
const invoiceContractId = await resolveInvoiceContractId(invoiceId);
|
||||
if (invoiceContractId == null || !(await canAccessContract(req, res, invoiceContractId))) {
|
||||
cleanupFile(req.file.path);
|
||||
deleteFileAbsolute(req.file.path);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -946,7 +942,7 @@ router.post(
|
||||
async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
if (req.user?.isCustomerPortal) {
|
||||
cleanupFile(req.file?.path);
|
||||
deleteFileAbsolute(req.file?.path);
|
||||
res.status(403).json({ success: false, error: 'Kein Zugriff' });
|
||||
return;
|
||||
}
|
||||
@@ -960,16 +956,16 @@ router.post(
|
||||
select: { contractId: true, receiptPath: true },
|
||||
});
|
||||
if (!cn) {
|
||||
cleanupFile(req.file.path);
|
||||
deleteFileAbsolute(req.file.path);
|
||||
res.status(404).json({ success: false, error: 'Gutschrift nicht gefunden' });
|
||||
return;
|
||||
}
|
||||
if (!(await canAccessContract(req, res, cn.contractId))) {
|
||||
cleanupFile(req.file.path);
|
||||
deleteFileAbsolute(req.file.path);
|
||||
return;
|
||||
}
|
||||
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 logChange({
|
||||
req, action: 'UPDATE', resourceType: 'CreditNote', resourceId: id.toString(),
|
||||
@@ -978,7 +974,7 @@ router.post(
|
||||
res.json({ success: true, data: { receiptPath: relativePath } });
|
||||
} catch (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' });
|
||||
}
|
||||
}
|
||||
@@ -1004,7 +1000,7 @@ router.delete(
|
||||
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 logChange({
|
||||
req, action: 'UPDATE', resourceType: 'CreditNote', resourceId: id.toString(),
|
||||
|
||||
@@ -2,26 +2,12 @@
|
||||
// CRUD für Vertrags-Gutschriften (Subventionen: Geld/Sachwert) inkl.
|
||||
// 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 { ApiError } from '../utils/apiError.js';
|
||||
import { assignNextNumber } from './creditNoteNumberRange.service.js';
|
||||
import { deleteUploadByRelativePath } from '../utils/fileCleanup.js';
|
||||
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;
|
||||
|
||||
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
|
||||
// erzeugtes PDF ist nach inhaltlicher Änderung veraltet → Pfad leeren
|
||||
// (der User erzeugt es bei Bedarf neu) und die alte Datei entfernen,
|
||||
// damit sie nicht verwaist liegen bleibt.
|
||||
if (existing.pdfPath) deleteFileIfExists(existing.pdfPath);
|
||||
return prisma.creditNote.update({ where: { id }, data: { ...normalized, pdfPath: null } });
|
||||
// (der User erzeugt es bei Bedarf neu). Reihenfolge (R140): erst DB-Update,
|
||||
// DANN die alte Datei löschen – schlägt das Update fehl, bleibt die Datei.
|
||||
const updated = await prisma.creditNote.update({ where: { id }, data: { ...normalized, pdfPath: null } });
|
||||
deleteUploadByRelativePath(existing.pdfPath);
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function deleteCreditNote(id: number) {
|
||||
@@ -256,11 +243,13 @@ export async function deleteCreditNote(id: number) {
|
||||
if (!existing) {
|
||||
throw new ApiError(404, 'Gutschrift nicht gefunden');
|
||||
}
|
||||
// Verwaiste Dateien vermeiden: generiertes PDF + Überweisungsbeleg von der
|
||||
// Platte entfernen (Pentest R138-Hinweis).
|
||||
deleteFileIfExists(existing.pdfPath);
|
||||
deleteFileIfExists(existing.receiptPath);
|
||||
return prisma.creditNote.delete({ where: { id } });
|
||||
// Reihenfolge (R140): erst den DB-Datensatz löschen, DANN die Dateien –
|
||||
// schlägt das DB-Delete fehl, bleiben PDF + Beleg erhalten (kein
|
||||
// ins-Leere-zeigender Eintrag).
|
||||
const deleted = await prisma.creditNote.delete({ where: { id } });
|
||||
deleteUploadByRelativePath(existing.pdfPath);
|
||||
deleteUploadByRelativePath(existing.receiptPath);
|
||||
return deleted;
|
||||
}
|
||||
|
||||
// Setzt/aktualisiert den Pfad des hochgeladenen Überweisungsbelegs.
|
||||
|
||||
@@ -2,21 +2,7 @@ import { CustomerType, ContractStatus } from '@prisma/client';
|
||||
import prisma from '../lib/prisma.js';
|
||||
import { generateCustomerNumber, paginate, buildPaginationResponse } from '../utils/helpers.js';
|
||||
import { ApiError } from '../utils/apiError.js';
|
||||
import fs from 'fs';
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
import { deleteUploadByRelativePath } from '../utils/fileCleanup.js';
|
||||
|
||||
export interface CustomerFilters {
|
||||
search?: string;
|
||||
@@ -205,17 +191,17 @@ export async function deleteCustomer(id: number) {
|
||||
|
||||
// Kundendokumente löschen
|
||||
if (customer) {
|
||||
deleteFileIfExists(customer.businessRegistrationPath);
|
||||
deleteFileIfExists(customer.commercialRegisterPath);
|
||||
deleteFileIfExists(customer.privacyPolicyPath);
|
||||
deleteUploadByRelativePath(customer.businessRegistrationPath);
|
||||
deleteUploadByRelativePath(customer.commercialRegisterPath);
|
||||
deleteUploadByRelativePath(customer.privacyPolicyPath);
|
||||
}
|
||||
|
||||
// Bankkarten- und Ausweisdokumente löschen
|
||||
for (const card of bankCards) {
|
||||
deleteFileIfExists(card.documentPath);
|
||||
deleteUploadByRelativePath(card.documentPath);
|
||||
}
|
||||
for (const doc of identityDocs) {
|
||||
deleteFileIfExists(doc.documentPath);
|
||||
deleteUploadByRelativePath(doc.documentPath);
|
||||
}
|
||||
|
||||
// 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
|
||||
const bankCard = await prisma.bankCard.findUnique({ where: { id } });
|
||||
if (bankCard?.documentPath) {
|
||||
deleteFileIfExists(bankCard.documentPath);
|
||||
deleteUploadByRelativePath(bankCard.documentPath);
|
||||
}
|
||||
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
|
||||
const document = await prisma.identityDocument.findUnique({ where: { id } });
|
||||
if (document?.documentPath) {
|
||||
deleteFileIfExists(document.documentPath);
|
||||
deleteUploadByRelativePath(document.documentPath);
|
||||
}
|
||||
return prisma.identityDocument.delete({ where: { id } });
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -143,6 +143,12 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
|
||||
- **Datei-Cleanup (R138-Hinweis):** Beim Löschen einer Gutschrift werden
|
||||
PDF + Beleg von der Platte entfernt; beim Bearbeiten (pdfPath wird
|
||||
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)
|
||||
- Use-Case: zu einem Vertrag kann eine Subvention gewährt werden – **Geld**
|
||||
|
||||
Reference in New Issue
Block a user