Files
opencrm/backend/src/services/backup.service.ts
T
duffyduck 81f0e89058 Security-Hardening Runde 2: Zip-Slip, Mass Assignment, weitere IDORs, Path-Traversal
Nach der ersten Runde habe ich parallel 3 Audit-Agents auf die Codebase
angesetzt. Die fanden noch eine Menge: Zip-Slip, Mass Assignment inkl.
Privilege Escalation, 13 weitere IDOR-Stellen, 2x Path-Traversal.

Alles gefixt. Details + Angriffsvektoren in docs/SECURITY-REVIEW.md.

🔴 KRITISCH gefixt:

1. Zip-Slip im Backup-Upload: extractAllTo() entpackte bösartige ZIPs ohne
   Pfad-Validierung. Ein Angreifer mit Admin-Zugang hätte mit einem ZIP
   mit Entries wie ../../etc/crontab das ganze Filesystem überschreiben
   können. Jetzt wird jeder ZIP-Entry einzeln validiert (path.resolve,
   starts-with-Check). Absolute Pfade + Null-Bytes werden abgelehnt.

2. Mass Assignment bei Customer/User Controllers:
   - updateCustomer/createCustomer: req.body ging komplett an Prisma.
     Angreifer konnte portalPasswordHash, portalPasswordResetToken,
     consentHash, customerNumber direkt setzen.
   - updateUser/createUser: roleIds und isActive waren übernehmbar.
     **Privilege Escalation**: normaler Mitarbeiter konnte sich Admin-Rechte
     durch PUT /users/:id mit {"roleIds":[1]} geben, oder andere User
     deaktivieren.
   Fix: Neue Whitelist-Helper pickCustomerCreate/Update, pickUserCreate/Update
   in utils/sanitize.ts. Nur erlaubte Felder werden durchgelassen.

3. IDOR bei 13 weiteren Endpoints (neben denen aus Runde 1):
   - GET /meters/:meterId/readings
   - GET /emails/:emailId/attachments/:filename
   - GET /emails/:emailId/attachments (Liste)
   - GET /customers/:customerId/emails
   - GET /contracts/:contractId/emails
   - GET /emails/:id (einzelne Email)
   - GET /stressfrei-emails/:id (leakte emailPasswordEncrypted)
   - weitere…
   Fix: accessControl.ts ausgebaut um canAccessAddress, canAccessBankCard,
   canAccessIdentityDocument, canAccessMeter, canAccessStressfreiEmail,
   canAccessCachedEmail. In allen betroffenen Endpoints angewendet.

🟡 WICHTIG gefixt:

4. Path-Traversal bei Backup-Name (GET /settings/backup/:name/*): req.params.name
   wurde ohne Filter in path.join. Neuer isValidBackupName() erlaubt nur
   [A-Za-z0-9_-]+ ohne "..".

5. Path-Traversal bei GDPR-Proof-Download: proofDocument-Pfad aus DB wurde
   ohne Validation gejoined. Jetzt path.resolve + starts-with-uploads-Check.

Neue/erweiterte Files:
- backend/src/utils/accessControl.ts - 6 neue can-Access-Helper
- backend/src/utils/sanitize.ts - 4 neue Whitelist-pick-Helper
- docs/SECURITY-REVIEW.md - Runde 2 dokumentiert

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 22:59:28 +02:00

1384 lines
44 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.
/**
* Datenbank Backup Service
*
* Ermöglicht Backup und Restore der Datenbank und Uploads über die Web-Oberfläche.
*/
import prisma from '../lib/prisma.js';
import * as fs from 'fs';
import * as path from 'path';
import archiver from 'archiver';
import AdmZip from 'adm-zip';
import bcrypt from 'bcryptjs';
// Verzeichnisse
const BACKUPS_DIR = path.join(__dirname, '../../prisma/backups');
const UPLOADS_DIR = path.join(process.cwd(), 'uploads');
// Stelle sicher, dass das Backup-Verzeichnis existiert
if (!fs.existsSync(BACKUPS_DIR)) {
fs.mkdirSync(BACKUPS_DIR, { recursive: true });
}
export interface BackupInfo {
name: string;
timestamp: string;
totalRecords: number;
tables: { table: string; count: number }[];
sizeBytes: number;
hasUploads: boolean;
uploadSizeBytes: number;
}
export interface BackupResult {
success: boolean;
backupName?: string;
error?: string;
}
export interface RestoreResult {
success: boolean;
restoredRecords?: number;
restoredFiles?: number;
error?: string;
}
// Hilfsfunktion: Datum-Strings zu Date-Objekten konvertieren
function convertDates(obj: any): any {
if (obj === null || obj === undefined) return obj;
if (typeof obj === 'string') {
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(obj)) {
return new Date(obj);
}
return obj;
}
if (Array.isArray(obj)) {
return obj.map(convertDates);
}
if (typeof obj === 'object') {
const result: any = {};
for (const [key, value] of Object.entries(obj)) {
result[key] = convertDates(value);
}
return result;
}
return obj;
}
// Hilfsfunktion: JSON-Datei lesen
function readJsonFile<T>(filePath: string): T[] {
if (!fs.existsSync(filePath)) {
return [];
}
const content = fs.readFileSync(filePath, 'utf-8');
return JSON.parse(content);
}
// Hilfsfunktion: Ordnergröße berechnen (rekursiv)
function getDirectorySize(dirPath: string): number {
if (!fs.existsSync(dirPath)) return 0;
let size = 0;
const items = fs.readdirSync(dirPath);
for (const item of items) {
const itemPath = path.join(dirPath, item);
const stats = fs.statSync(itemPath);
if (stats.isFile()) {
size += stats.size;
} else if (stats.isDirectory()) {
size += getDirectorySize(itemPath);
}
}
return size;
}
// Hilfsfunktion: Ordner rekursiv kopieren
function copyDirectory(src: string, dest: string): number {
if (!fs.existsSync(src)) return 0;
let fileCount = 0;
fs.mkdirSync(dest, { recursive: true });
const items = fs.readdirSync(src);
for (const item of items) {
const srcPath = path.join(src, item);
const destPath = path.join(dest, item);
const stats = fs.statSync(srcPath);
if (stats.isDirectory()) {
fileCount += copyDirectory(srcPath, destPath);
} else {
fs.copyFileSync(srcPath, destPath);
fileCount++;
}
}
return fileCount;
}
// Hilfsfunktion: Ordner rekursiv löschen
function deleteDirectory(dirPath: string): void {
if (!fs.existsSync(dirPath)) return;
const items = fs.readdirSync(dirPath);
for (const item of items) {
const itemPath = path.join(dirPath, item);
const stats = fs.statSync(itemPath);
if (stats.isDirectory()) {
deleteDirectory(itemPath);
} else {
fs.unlinkSync(itemPath);
}
}
fs.rmdirSync(dirPath);
}
/**
* Liste aller verfügbaren Backups
*/
export async function listBackups(): Promise<BackupInfo[]> {
const backups: BackupInfo[] = [];
if (!fs.existsSync(BACKUPS_DIR)) {
return backups;
}
const dirs = fs.readdirSync(BACKUPS_DIR)
.filter(f => {
const fullPath = path.join(BACKUPS_DIR, f);
return fs.statSync(fullPath).isDirectory() && f !== '.gitkeep' && !f.startsWith('.');
})
.sort()
.reverse();
for (const dir of dirs) {
const backupDir = path.join(BACKUPS_DIR, dir);
const infoPath = path.join(backupDir, '_backup-info.json');
const uploadsPath = path.join(backupDir, 'uploads');
if (fs.existsSync(infoPath)) {
try {
const info = JSON.parse(fs.readFileSync(infoPath, 'utf-8'));
const hasUploads = fs.existsSync(uploadsPath);
const uploadSize = hasUploads ? getDirectorySize(uploadsPath) : 0;
backups.push({
name: dir,
timestamp: info.timestamp,
totalRecords: info.totalRecords,
tables: info.tables,
sizeBytes: getDirectorySize(backupDir),
hasUploads,
uploadSizeBytes: uploadSize,
});
} catch {
backups.push({
name: dir,
timestamp: dir,
totalRecords: 0,
tables: [],
sizeBytes: getDirectorySize(backupDir),
hasUploads: fs.existsSync(uploadsPath),
uploadSizeBytes: 0,
});
}
}
}
return backups;
}
/**
* Neues Backup erstellen (inkl. Uploads)
*/
export async function createBackup(): Promise<BackupResult> {
try {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const backupDir = path.join(BACKUPS_DIR, timestamp);
fs.mkdirSync(backupDir, { recursive: true });
// Tabellen in Abhängigkeitsreihenfolge
const tables = [
{ name: 'Permission', query: () => prisma.permission.findMany() },
{ name: 'Role', query: () => prisma.role.findMany() },
{ name: 'SalesPlatform', query: () => prisma.salesPlatform.findMany() },
{ name: 'ContractCategory', query: () => prisma.contractCategory.findMany() },
{ name: 'CancellationPeriod', query: () => prisma.cancellationPeriod.findMany() },
{ name: 'ContractDuration', query: () => prisma.contractDuration.findMany() },
{ name: 'AppSetting', query: () => prisma.appSetting.findMany() },
{ name: 'EmailProviderConfig', query: () => prisma.emailProviderConfig.findMany() },
{ name: 'Provider', query: () => prisma.provider.findMany() },
{ name: 'RolePermission', query: () => prisma.rolePermission.findMany() },
{ name: 'User', query: () => prisma.user.findMany() },
{ name: 'Customer', query: () => prisma.customer.findMany() },
{ name: 'Tariff', query: () => prisma.tariff.findMany() },
{ name: 'UserRole', query: () => prisma.userRole.findMany() },
{ name: 'CustomerRepresentative', query: () => prisma.customerRepresentative.findMany() },
{ name: 'StressfreiEmail', query: () => prisma.stressfreiEmail.findMany() },
{ name: 'Contract', query: () => prisma.contract.findMany() },
{ name: 'Meter', query: () => prisma.meter.findMany() },
{ name: 'CachedEmail', query: () => prisma.cachedEmail.findMany() },
{ name: 'ContractTask', query: () => prisma.contractTask.findMany() },
{ name: 'MeterReading', query: () => prisma.meterReading.findMany() },
{ name: 'ContractTaskSubtask', query: () => prisma.contractTaskSubtask.findMany() },
{ name: 'EnergyContractDetails', query: () => prisma.energyContractDetails.findMany() },
{ name: 'InternetContractDetails', query: () => prisma.internetContractDetails.findMany() },
{ name: 'MobileContractDetails', query: () => prisma.mobileContractDetails.findMany() },
{ name: 'TvContractDetails', query: () => prisma.tvContractDetails.findMany() },
{ name: 'CarInsuranceDetails', query: () => prisma.carInsuranceDetails.findMany() },
{ name: 'PhoneNumber', query: () => prisma.phoneNumber.findMany() },
{ name: 'SimCard', query: () => prisma.simCard.findMany() },
{ name: 'Invoice', query: () => prisma.invoice.findMany() },
{ name: 'ContractHistoryEntry', query: () => prisma.contractHistoryEntry.findMany() },
{ name: 'Address', query: () => prisma.address.findMany() },
{ name: 'BankCard', query: () => prisma.bankCard.findMany() },
{ name: 'IdentityDocument', query: () => prisma.identityDocument.findMany() },
// Neue Tabellen
{ name: 'PdfTemplate', query: () => prisma.pdfTemplate.findMany() },
{ name: 'ContractMeter', query: () => prisma.contractMeter.findMany() },
{ name: 'ContractDocument', query: () => prisma.contractDocument.findMany() },
{ name: 'RepresentativeAuthorization', query: () => prisma.representativeAuthorization.findMany() },
{ name: 'CustomerConsent', query: () => prisma.customerConsent.findMany() },
{ name: 'DataDeletionRequest', query: () => prisma.dataDeletionRequest.findMany() },
{ name: 'EmailLog', query: () => prisma.emailLog.findMany() },
{ name: 'AuditRetentionPolicy', query: () => prisma.auditRetentionPolicy.findMany() },
{ name: 'AuditLog', query: () => prisma.auditLog.findMany() },
];
let totalRecords = 0;
const stats: { table: string; count: number }[] = [];
for (const table of tables) {
try {
const data = await table.query();
const count = data.length;
totalRecords += count;
stats.push({ table: table.name, count });
const filePath = path.join(backupDir, `${table.name}.json`);
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
} catch {
stats.push({ table: table.name, count: 0 });
}
}
// Uploads-Ordner kopieren (falls vorhanden)
let uploadFileCount = 0;
if (fs.existsSync(UPLOADS_DIR)) {
const uploadsBackupDir = path.join(backupDir, 'uploads');
uploadFileCount = copyDirectory(UPLOADS_DIR, uploadsBackupDir);
}
// Backup-Info speichern
const backupInfo = {
timestamp: new Date().toISOString(),
totalRecords,
tables: stats,
uploadFiles: uploadFileCount,
};
fs.writeFileSync(path.join(backupDir, '_backup-info.json'), JSON.stringify(backupInfo, null, 2));
return { success: true, backupName: timestamp };
} catch (error: any) {
return { success: false, error: error.message };
}
}
/**
* Backup wiederherstellen (inkl. Uploads)
*/
export async function restoreBackup(backupName: string): Promise<RestoreResult> {
const backupDir = path.join(BACKUPS_DIR, backupName);
if (!fs.existsSync(backupDir)) {
return { success: false, error: 'Backup nicht gefunden' };
}
try {
// Foreign Key Checks deaktivieren
await prisma.$executeRawUnsafe('SET FOREIGN_KEY_CHECKS = 0');
// WICHTIG: Alle Tabellen vor dem Restore leeren, damit keine alten Daten übrig bleiben
console.log('[Restore] Lösche alle bestehenden Daten...');
// Logs & Audit zuerst (hängen an allem)
await prisma.auditLog.deleteMany({});
await prisma.emailLog.deleteMany({});
// Detail-Tabellen
await prisma.carInsuranceDetails.deleteMany({});
await prisma.tvContractDetails.deleteMany({});
await prisma.simCard.deleteMany({});
await prisma.mobileContractDetails.deleteMany({});
await prisma.phoneNumber.deleteMany({});
await prisma.internetContractDetails.deleteMany({});
await prisma.invoice.deleteMany({});
await prisma.energyContractDetails.deleteMany({});
await prisma.meterReading.deleteMany({});
await prisma.contractHistoryEntry.deleteMany({});
// Neue Contract-bezogene Tabellen
await prisma.contractDocument.deleteMany({});
await prisma.contractMeter.deleteMany({});
// E-Mail & Verträge
await prisma.cachedEmail.deleteMany({});
await prisma.contractTaskSubtask.deleteMany({});
await prisma.contractTask.deleteMany({});
await prisma.contract.deleteMany({});
// DSGVO + Vollmachten (abhängig von Customer)
await prisma.representativeAuthorization.deleteMany({});
await prisma.customerConsent.deleteMany({});
await prisma.dataDeletionRequest.deleteMany({});
// Kunden-bezogene Daten
await prisma.stressfreiEmail.deleteMany({});
await prisma.meter.deleteMany({});
await prisma.identityDocument.deleteMany({});
await prisma.bankCard.deleteMany({});
await prisma.address.deleteMany({});
await prisma.customerRepresentative.deleteMany({});
// Benutzer & Rollen
await prisma.userRole.deleteMany({});
await prisma.user.deleteMany({});
await prisma.customer.deleteMany({});
// Stammdaten & Kataloge
await prisma.pdfTemplate.deleteMany({});
await prisma.tariff.deleteMany({});
await prisma.provider.deleteMany({});
await prisma.rolePermission.deleteMany({});
await prisma.role.deleteMany({});
await prisma.permission.deleteMany({});
await prisma.salesPlatform.deleteMany({});
await prisma.cancellationPeriod.deleteMany({});
await prisma.contractDuration.deleteMany({});
await prisma.contractCategory.deleteMany({});
await prisma.emailProviderConfig.deleteMany({});
await prisma.appSetting.deleteMany({});
await prisma.auditRetentionPolicy.deleteMany({});
console.log('[Restore] Alle Daten gelöscht, starte Wiederherstellung...');
// Restore-Reihenfolge
const restoreOrder = [
{
name: 'Permission',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.permission.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'Role',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.role.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'SalesPlatform',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.salesPlatform.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'ContractCategory',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.contractCategory.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'CancellationPeriod',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.cancellationPeriod.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'ContractDuration',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.contractDuration.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'AppSetting',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.appSetting.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'EmailProviderConfig',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.emailProviderConfig.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'Provider',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.provider.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'RolePermission',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.rolePermission.upsert({
where: { roleId_permissionId: { roleId: item.roleId, permissionId: item.permissionId } },
update: {},
create: convertDates(item),
});
}
},
},
{
name: 'User',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.user.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'Customer',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.customer.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'Tariff',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.tariff.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'UserRole',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.userRole.upsert({
where: { userId_roleId: { userId: item.userId, roleId: item.roleId } },
update: {},
create: convertDates(item),
});
}
},
},
{
name: 'CustomerRepresentative',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.customerRepresentative.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'StressfreiEmail',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.stressfreiEmail.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'Contract',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.contract.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'Meter',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.meter.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'CachedEmail',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.cachedEmail.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'ContractTask',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.contractTask.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'MeterReading',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.meterReading.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'ContractTaskSubtask',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.contractTaskSubtask.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'EnergyContractDetails',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.energyContractDetails.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'InternetContractDetails',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.internetContractDetails.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'MobileContractDetails',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.mobileContractDetails.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'TvContractDetails',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.tvContractDetails.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'CarInsuranceDetails',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.carInsuranceDetails.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'PhoneNumber',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.phoneNumber.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'SimCard',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.simCard.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'Invoice',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.invoice.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'ContractHistoryEntry',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.contractHistoryEntry.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'Address',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.address.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'BankCard',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.bankCard.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'IdentityDocument',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.identityDocument.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
// Neue Tabellen
{
name: 'PdfTemplate',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.pdfTemplate.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'ContractMeter',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.contractMeter.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'ContractDocument',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.contractDocument.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'RepresentativeAuthorization',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.representativeAuthorization.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'CustomerConsent',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.customerConsent.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'DataDeletionRequest',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.dataDeletionRequest.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'EmailLog',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.emailLog.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'AuditRetentionPolicy',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.auditRetentionPolicy.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'AuditLog',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.auditLog.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
];
let totalRestored = 0;
for (const table of restoreOrder) {
const filePath = path.join(backupDir, `${table.name}.json`);
const data = readJsonFile(filePath);
if (data.length === 0) continue;
try {
await table.restore(data);
totalRestored += data.length;
} catch {
// Fehler bei einzelner Tabelle, weitermachen
}
}
// Foreign Key Checks wieder aktivieren
await prisma.$executeRawUnsafe('SET FOREIGN_KEY_CHECKS = 1');
// Uploads wiederherstellen
let restoredFiles = 0;
const uploadsBackupDir = path.join(backupDir, 'uploads');
if (fs.existsSync(uploadsBackupDir)) {
// Bestehenden Uploads-Ordner leeren (optional: könnte auch nur überschreiben)
if (fs.existsSync(UPLOADS_DIR)) {
deleteDirectory(UPLOADS_DIR);
}
restoredFiles = copyDirectory(uploadsBackupDir, UPLOADS_DIR);
}
return { success: true, restoredRecords: totalRestored, restoredFiles };
} catch (error: any) {
try {
await prisma.$executeRawUnsafe('SET FOREIGN_KEY_CHECKS = 1');
} catch {}
return { success: false, error: error.message };
}
}
/**
* Backup löschen
*/
export async function deleteBackup(backupName: string): Promise<{ success: boolean; error?: string }> {
const backupDir = path.join(BACKUPS_DIR, backupName);
if (!fs.existsSync(backupDir)) {
return { success: false, error: 'Backup nicht gefunden' };
}
try {
deleteDirectory(backupDir);
return { success: true };
} catch (error: any) {
return { success: false, error: error.message };
}
}
/**
* Backup als ZIP-Datei erstellen und Stream zurückgeben
*/
export async function createBackupZip(backupName: string): Promise<{ stream: archiver.Archiver; filename: string } | { error: string }> {
const backupDir = path.join(BACKUPS_DIR, backupName);
if (!fs.existsSync(backupDir)) {
return { error: 'Backup nicht gefunden' };
}
const archive = archiver('zip', { zlib: { level: 9 } });
// Alle Dateien im Backup-Ordner hinzufügen
archive.directory(backupDir, false);
return {
stream: archive,
filename: `opencrm-backup-${backupName}.zip`,
};
}
/**
* ZIP-Backup hochladen und extrahieren
*/
export async function uploadBackupZip(zipBuffer: Buffer): Promise<BackupResult> {
try {
const zip = new AdmZip(zipBuffer);
const entries = zip.getEntries();
// Prüfen ob gültiges Backup (muss _backup-info.json enthalten)
const hasBackupInfo = entries.some(entry => entry.entryName === '_backup-info.json');
if (!hasBackupInfo) {
return { success: false, error: 'Ungültiges Backup-Archiv: _backup-info.json fehlt' };
}
// Backup-Info lesen um Namen zu bestimmen
const infoEntry = entries.find(entry => entry.entryName === '_backup-info.json');
let backupName: string;
try {
const infoContent = infoEntry!.getData().toString('utf8');
const info = JSON.parse(infoContent);
// Backup-Name aus Timestamp generieren
backupName = new Date(info.timestamp).toISOString().replace(/[:.]/g, '-').slice(0, 19);
} catch {
// Fallback: Aktueller Timestamp
backupName = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19) + '-uploaded';
}
const backupDir = path.join(BACKUPS_DIR, backupName);
// Falls Backup mit diesem Namen existiert, Suffix hinzufügen
let finalBackupDir = backupDir;
let suffix = 1;
while (fs.existsSync(finalBackupDir)) {
finalBackupDir = `${backupDir}-${suffix}`;
suffix++;
}
const finalBackupName = path.basename(finalBackupDir);
// ZIP entpacken mit Schutz gegen Zip-Slip (../../etc/passwd Angriff).
// Jeder Eintragspfad muss innerhalb von finalBackupDir bleiben.
const absBackupDir = path.resolve(finalBackupDir);
fs.mkdirSync(absBackupDir, { recursive: true });
for (const entry of entries) {
// Pfade mit absoluten Pfaden oder Traversal ablehnen
const entryName = entry.entryName;
if (entryName.includes('\0') || path.isAbsolute(entryName)) {
return { success: false, error: `Ungültiger Eintrag im ZIP: ${entryName}` };
}
const targetPath = path.resolve(absBackupDir, entryName);
// Zip-Slip-Check: aufgelöster Pfad muss im Backup-Verzeichnis liegen
if (!targetPath.startsWith(absBackupDir + path.sep) && targetPath !== absBackupDir) {
return {
success: false,
error: `Sicherheitsverletzung im ZIP: Pfad "${entryName}" zeigt außerhalb des Backup-Verzeichnisses`,
};
}
if (entry.isDirectory) {
fs.mkdirSync(targetPath, { recursive: true });
} else {
// Zielverzeichnis sicherstellen
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
// Datei schreiben
fs.writeFileSync(targetPath, entry.getData());
}
}
return { success: true, backupName: finalBackupName };
} catch (error: any) {
return { success: false, error: error.message };
}
}
/**
* Pfad zum Backup-Verzeichnis zurückgeben
*/
export function getBackupsDir(): string {
return BACKUPS_DIR;
}
/**
* Werkseinstellungen - Alle Daten löschen (außer Backups)
*
* Löscht alle Datenbankeinträge und Uploads, behält aber Backups.
* Erstellt einen Admin-Benutzer und notwendige Stammdaten.
*/
export async function factoryReset(): Promise<{ success: boolean; error?: string }> {
try {
// Foreign Key Checks deaktivieren
await prisma.$executeRawUnsafe('SET FOREIGN_KEY_CHECKS = 0');
// Tabellen in korrekter Reihenfolge leeren (abhängige zuerst)
// Detail-Tabellen
await prisma.carInsuranceDetails.deleteMany({});
await prisma.tvContractDetails.deleteMany({});
await prisma.simCard.deleteMany({});
await prisma.mobileContractDetails.deleteMany({});
await prisma.phoneNumber.deleteMany({});
await prisma.internetContractDetails.deleteMany({});
await prisma.invoice.deleteMany({});
await prisma.energyContractDetails.deleteMany({});
await prisma.meterReading.deleteMany({});
await prisma.contractHistoryEntry.deleteMany({});
// E-Mail & Verträge
await prisma.cachedEmail.deleteMany({});
await prisma.contractTaskSubtask.deleteMany({});
await prisma.contractTask.deleteMany({});
await prisma.contract.deleteMany({});
// Kunden-bezogene Daten
await prisma.stressfreiEmail.deleteMany({});
await prisma.meter.deleteMany({});
await prisma.identityDocument.deleteMany({});
await prisma.bankCard.deleteMany({});
await prisma.address.deleteMany({});
await prisma.customerRepresentative.deleteMany({});
// Benutzer & Rollen (außer Admin)
await prisma.userRole.deleteMany({});
await prisma.user.deleteMany({});
await prisma.customer.deleteMany({});
// Stammdaten
await prisma.tariff.deleteMany({});
await prisma.provider.deleteMany({});
await prisma.rolePermission.deleteMany({});
await prisma.role.deleteMany({});
await prisma.permission.deleteMany({});
await prisma.salesPlatform.deleteMany({});
await prisma.cancellationPeriod.deleteMany({});
await prisma.contractDuration.deleteMany({});
await prisma.contractCategory.deleteMany({});
await prisma.emailProviderConfig.deleteMany({});
await prisma.appSetting.deleteMany({});
// Foreign Key Checks wieder aktivieren
await prisma.$executeRawUnsafe('SET FOREIGN_KEY_CHECKS = 1');
// Uploads-Verzeichnis leeren (aber nicht das Verzeichnis selbst)
if (fs.existsSync(UPLOADS_DIR)) {
const items = fs.readdirSync(UPLOADS_DIR);
for (const item of items) {
const itemPath = path.join(UPLOADS_DIR, item);
const stats = fs.statSync(itemPath);
if (stats.isDirectory()) {
deleteDirectory(itemPath);
} else {
fs.unlinkSync(itemPath);
}
}
}
// Grundlegende Stammdaten neu anlegen (aus Seed)
// Berechtigungen - muss mit seed.ts übereinstimmen!
const resourcePermissions: Record<string, string[]> = {
// Haupt-Ressourcen (CRUD)
customers: ['create', 'read', 'update', 'delete'],
contracts: ['create', 'read', 'update', 'delete'],
users: ['create', 'read', 'update', 'delete'],
platforms: ['create', 'read', 'update', 'delete'],
providers: ['create', 'read', 'update', 'delete'],
tariffs: ['create', 'read', 'update', 'delete'],
// Lookup-Tabellen (nur lesen)
'contract-categories': ['read'],
'cancellation-periods': ['read'],
'contract-durations': ['read'],
// Einstellungen (nur lesen/ändern)
settings: ['read', 'update'],
// Spezial-Permissions
developer: ['access'],
emails: ['delete'],
};
for (const [resource, actions] of Object.entries(resourcePermissions)) {
for (const action of actions) {
await prisma.permission.create({
data: { resource, action },
});
}
}
console.log('[FactoryReset] Berechtigungen erstellt');
// Admin-Rolle mit allen Berechtigungen (außer developer:access)
const allPermissions = await prisma.permission.findMany();
const adminRole = await prisma.role.create({
data: {
name: 'Admin',
description: 'Voller Zugriff auf alle Funktionen',
permissions: {
create: allPermissions
.filter(p => !(p.resource === 'developer' && p.action === 'access'))
.map(p => ({ permissionId: p.id })),
},
},
});
// Developer-Rolle - ALLE Berechtigungen inkl. developer:access
await prisma.role.create({
data: {
name: 'Developer',
description: 'Voller Zugriff inkl. Entwickler-Tools',
permissions: {
create: allPermissions.map(p => ({ permissionId: p.id })),
},
},
});
// Mitarbeiter-Rolle - customers, contracts + read-only auf Stammdaten
const employeePermIds = allPermissions
.filter(p =>
p.resource === 'customers' ||
p.resource === 'contracts' ||
(p.action === 'read' && ['platforms', 'providers', 'tariffs', 'contract-categories', 'cancellation-periods', 'contract-durations'].includes(p.resource))
)
.map(p => p.id);
await prisma.role.create({
data: {
name: 'Mitarbeiter',
description: 'Kann Kunden und Verträge verwalten',
permissions: {
create: employeePermIds.map(id => ({ permissionId: id })),
},
},
});
// Nur-Lesen Rolle
const readOnlyResources = ['customers', 'contracts', 'platforms', 'providers', 'tariffs', 'contract-categories', 'cancellation-periods', 'contract-durations'];
const readOnlyPermIds = allPermissions
.filter(p => p.action === 'read' && readOnlyResources.includes(p.resource))
.map(p => p.id);
await prisma.role.create({
data: {
name: 'Mitarbeiter (Nur-Lesen)',
description: 'Kann nur lesen, keine Änderungen',
permissions: {
create: readOnlyPermIds.map(id => ({ permissionId: id })),
},
},
});
// Kunden-Rolle
await prisma.role.create({
data: {
name: 'Kunde',
description: 'Kann nur eigene Daten lesen',
permissions: {
create: readOnlyPermIds.map(id => ({ permissionId: id })),
},
},
});
console.log('[FactoryReset] Rollen erstellt');
// Standard Admin-Benutzer erstellen
console.log('[FactoryReset] Erstelle Admin-Benutzer...');
const hashedPassword = await bcrypt.hash('admin', 10);
console.log('[FactoryReset] Passwort gehasht, Admin-Rolle ID:', adminRole.id);
const adminUser = await prisma.user.create({
data: {
email: 'admin@admin.com',
password: hashedPassword,
firstName: 'Admin',
lastName: 'User',
roles: {
create: [{ roleId: adminRole.id }],
},
},
});
console.log('[FactoryReset] Admin-Benutzer erstellt mit ID:', adminUser.id);
// Standard Kündigungsfristen (wie in seed.ts)
const cancellationPeriods = [
{ code: '14D', description: '14 Tage' },
{ code: '1M', description: '1 Monat' },
{ code: '2M', description: '2 Monate' },
{ code: '3M', description: '3 Monate' },
{ code: '6M', description: '6 Monate' },
{ code: '12M', description: '12 Monate' },
{ code: '1W', description: '1 Woche' },
{ code: '2W', description: '2 Wochen' },
{ code: '4W', description: '4 Wochen' },
{ code: '6W', description: '6 Wochen' },
];
for (const cp of cancellationPeriods) {
await prisma.cancellationPeriod.create({ data: cp });
}
console.log('[FactoryReset] Kündigungsfristen erstellt');
// Standard Vertragslaufzeiten (wie in seed.ts)
const contractDurations = [
{ code: '1M', description: '1 Monat' },
{ code: '3M', description: '3 Monate' },
{ code: '6M', description: '6 Monate' },
{ code: '12M', description: '12 Monate' },
{ code: '24M', description: '24 Monate' },
{ code: '36M', description: '36 Monate' },
{ code: '1J', description: '1 Jahr' },
{ code: '2J', description: '2 Jahre' },
{ code: '3J', description: '3 Jahre' },
{ code: '4J', description: '4 Jahre' },
{ code: '5J', description: '5 Jahre' },
{ code: 'UNBEFRISTET', description: 'Unbefristet' },
];
for (const cd of contractDurations) {
await prisma.contractDuration.create({ data: cd });
}
console.log('[FactoryReset] Vertragslaufzeiten erstellt');
// Standard Vertragstypen (wie in seed.ts - WICHTIG: Diese müssen mit den Formularen übereinstimmen!)
const contractCategories = [
{ code: 'ELECTRICITY', name: 'Strom', icon: 'Zap', color: '#FFC107', sortOrder: 1 },
{ code: 'GAS', name: 'Gas', icon: 'Flame', color: '#FF5722', sortOrder: 2 },
{ code: 'DSL', name: 'DSL', icon: 'Wifi', color: '#2196F3', sortOrder: 3 },
{ code: 'FIBER', name: 'Glasfaser', icon: 'Cable', color: '#9C27B0', sortOrder: 4 },
{ code: 'CABLE', name: 'Kabel Internet (Coax)', icon: 'Cable', color: '#00BCD4', sortOrder: 5 },
{ code: 'MOBILE', name: 'Mobilfunk', icon: 'Smartphone', color: '#4CAF50', sortOrder: 6 },
{ code: 'TV', name: 'TV', icon: 'Tv', color: '#E91E63', sortOrder: 7 },
{ code: 'CAR_INSURANCE', name: 'KFZ-Versicherung', icon: 'Car', color: '#607D8B', sortOrder: 8 },
];
for (const cat of contractCategories) {
await prisma.contractCategory.create({ data: cat });
}
console.log('[FactoryReset] Vertragstypen erstellt');
// Standard Vertriebsplattformen (wie in seed.ts)
const platforms = ['Moon Fachhandel', 'Verivox', 'Check24', 'Eigenvermittlung'];
for (const name of platforms) {
await prisma.salesPlatform.create({ data: { name, isActive: true } });
}
console.log('[FactoryReset] Vertriebsplattformen erstellt');
// Anbieter mit Tarifen (aus bestehender Datenbank)
const providersWithTariffs = [
{
provider: { name: '1&1', portalUrl: 'https://control-center.1und1.de/' },
tariffs: [],
},
{
provider: { name: 'Congstar', portalUrl: 'https://www.congstar.de/login/' },
tariffs: [],
},
{
provider: { name: 'E wie einfach', portalUrl: '' },
tariffs: ['Grün & Günstig Strom'],
},
{
provider: { name: 'EVD Energieversogung Deutschland', portalUrl: 'https://mein.ev-d.de/selfcare' },
tariffs: ['EVD Gas Bonus 12'],
},
{
provider: { name: 'Klarmobil', portalUrl: 'https://www.klarmobil.de/login' },
tariffs: ['Allnet Flat 30GB 5G (+00) Telekom'],
},
{
provider: { name: 'O2', portalUrl: 'https://www.o2online.de/ecare/selfcare' },
tariffs: [],
},
{
provider: { name: 'Otelo', portalUrl: 'https://www.otelo.de/mein-otelo/login' },
tariffs: [],
},
{
provider: { name: 'Telekom', portalUrl: 'https://www.telekom.de/kundencenter/startseite' },
tariffs: [],
},
{
provider: { name: 'Vodafone', portalUrl: 'https://www.vodafone.de/meinvodafone/account/login' },
tariffs: [],
},
];
for (const { provider, tariffs } of providersWithTariffs) {
const createdProvider = await prisma.provider.create({
data: { ...provider, isActive: true },
});
// Tarife für diesen Anbieter anlegen
for (const tariffName of tariffs) {
await prisma.tariff.create({
data: {
providerId: createdProvider.id,
name: tariffName,
isActive: true,
},
});
}
}
console.log('[FactoryReset] Anbieter mit Tarifen erstellt');
// Standard App-Einstellungen (wie in seed.ts)
const appSettings = [
{ key: 'deadlineCriticalDays', value: '14' },
{ key: 'deadlineWarningDays', value: '42' },
{ key: 'deadlineOkDays', value: '90' },
{ key: 'companyName', value: 'OpenCRM' },
{ key: 'defaultEmailDomain', value: 'stressfrei-wechseln.de' },
];
for (const setting of appSettings) {
await prisma.appSetting.create({ data: setting });
}
console.log('[FactoryReset] App-Einstellungen erstellt');
return { success: true };
} catch (error: any) {
try {
await prisma.$executeRawUnsafe('SET FOREIGN_KEY_CHECKS = 1');
} catch {}
return { success: false, error: error.message };
}
}