From bfc90053f0551c81dfc46877b1909757d1898166 Mon Sep 17 00:00:00 2001 From: michaelschal <35957947-michaelschal@users.noreply.replit.com> Date: Wed, 13 Aug 2025 20:42:59 +0000 Subject: [PATCH] Add database backup and restore functionality for administrators Introduce endpoints and UI for managing PostgreSQL database backups, including automatic nightly backups, manual backups, and the ability to download and delete backup files. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 7c468936-2a88-4686-ac0a-84921a45222d Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/1957c339-2757-4d1f-8e92-e9f71a1ce58e/7c468936-2a88-4686-ac0a-84921a45222d/B7k6m01 --- client/src/pages/BackupManager.tsx | 308 +++++++++++++++++++++++++++++ server/backupService.ts | 197 ++++++++++++++++++ server/routes.ts | 50 +++++ 3 files changed, 555 insertions(+) create mode 100644 client/src/pages/BackupManager.tsx create mode 100644 server/backupService.ts diff --git a/client/src/pages/BackupManager.tsx b/client/src/pages/BackupManager.tsx new file mode 100644 index 0000000..2fc31e1 --- /dev/null +++ b/client/src/pages/BackupManager.tsx @@ -0,0 +1,308 @@ +import { useState } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { useToast } from "@/hooks/use-toast"; +import { useAuthUnified } from "@/hooks/useAuthUnified"; +import { apiRequest } from "@/lib/queryClient"; +import { hasPermission } from "@/lib/permissions"; +import { + Download, + Trash2, + Database, + Clock, + User, + FileText, + AlertTriangle, + CheckCircle, + Loader2 +} from "lucide-react"; +import { format } from "date-fns"; +import { fr } from "date-fns/locale"; + +interface BackupFile { + id: string; + filename: string; + size: number; + createdAt: string; + type: 'manual' | 'automatic'; +} + +export default function BackupManager() { + const { user } = useAuthUnified(); + const { toast } = useToast(); + const queryClient = useQueryClient(); + const [deletingBackup, setDeletingBackup] = useState(null); + + // Check permissions + const canManageBackups = hasPermission(user?.role || '', 'admin', 'manage'); + + // Fetch backup list + const { data: backups = [], isLoading, error } = useQuery({ + queryKey: ['/api/backups'], + queryFn: () => apiRequest('/api/backups'), + enabled: canManageBackups, + refetchInterval: 30000, // Refresh every 30 seconds + }); + + // Create manual backup + const createBackupMutation = useMutation({ + mutationFn: () => apiRequest('/api/backups', 'POST'), + onSuccess: () => { + toast({ + title: "Succès", + description: "Sauvegarde manuelle créée avec succès", + }); + queryClient.invalidateQueries({ queryKey: ['/api/backups'] }); + }, + onError: (error: any) => { + toast({ + title: "Erreur", + description: error.message || "Impossible de créer la sauvegarde", + variant: "destructive", + }); + }, + }); + + // Delete backup + const deleteBackupMutation = useMutation({ + mutationFn: (filename: string) => apiRequest(`/api/backups/${filename}`, 'DELETE'), + onSuccess: () => { + toast({ + title: "Succès", + description: "Sauvegarde supprimée avec succès", + }); + queryClient.invalidateQueries({ queryKey: ['/api/backups'] }); + setDeletingBackup(null); + }, + onError: (error: any) => { + toast({ + title: "Erreur", + description: error.message || "Impossible de supprimer la sauvegarde", + variant: "destructive", + }); + setDeletingBackup(null); + }, + }); + + const handleDownload = (filename: string) => { + // Create download link + const link = document.createElement('a'); + link.href = `/api/backups/${filename}/download`; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }; + + const handleDelete = (filename: string) => { + setDeletingBackup(filename); + deleteBackupMutation.mutate(filename); + }; + + const formatFileSize = (bytes: number): string => { + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + if (bytes === 0) return '0 Bytes'; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i]; + }; + + const getTypeIcon = (type: string) => { + return type === 'automatic' ? : ; + }; + + const getTypeBadge = (type: string) => { + return type === 'automatic' + ? Automatique + : Manuelle; + }; + + if (!canManageBackups) { + return ( +
+ + + + Accès refusé + + Vous n'avez pas les permissions nécessaires pour accéder à la gestion des sauvegardes. + + + +
+ ); + } + + return ( +
+ {/* Header */} +
+
+
+ +
+

+ Gestion des Sauvegardes +

+

+ Sauvegarde automatique quotidienne à 2h00 - Maximum 10 fichiers conservés +

+
+
+ + +
+
+ + {/* Content */} +
+ {/* Status Cards */} +
+ + + + Total Sauvegardes + + + + +
{backups.length}
+

+ Maximum 10 fichiers +

+
+
+ + + + + Dernière Sauvegarde + + + + +
+ {backups.length > 0 ? ( + format(new Date(backups[0].createdAt), 'dd/MM', { locale: fr }) + ) : '-'} +
+

+ {backups.length > 0 ? ( + format(new Date(backups[0].createdAt), 'HH:mm', { locale: fr }) + ) : 'Aucune sauvegarde'} +

+
+
+ + + + + Statut Système + + + + +
Actif
+

+ Sauvegarde automatique 2h00 +

+
+
+
+ + {/* Backup List */} + + + Liste des Sauvegardes + + Gestion des fichiers de sauvegarde de la base de données PostgreSQL + + + + {isLoading ? ( +
+ +
+ ) : error ? ( +
+ + Erreur lors du chargement des sauvegardes +
+ ) : backups.length === 0 ? ( +
+ + Aucune sauvegarde disponible +
+ ) : ( +
+ {backups.map((backup) => ( +
+
+
+ {getTypeIcon(backup.type)} + +
+ +
+
+ {backup.filename} +
+
+ + {format(new Date(backup.createdAt), 'dd/MM/yyyy à HH:mm', { locale: fr })} + + {formatFileSize(backup.size)} + {getTypeBadge(backup.type)} +
+
+
+ +
+ + + +
+
+ ))} +
+ )} +
+
+
+
+ ); +} \ No newline at end of file diff --git a/server/backupService.ts b/server/backupService.ts new file mode 100644 index 0000000..c1ea50d --- /dev/null +++ b/server/backupService.ts @@ -0,0 +1,197 @@ +import { exec } from 'child_process'; +import { promisify } from 'util'; +import fs from 'fs'; +import path from 'path'; +import cron from 'node-cron'; + +const execAsync = promisify(exec); + +export interface BackupFile { + id: string; + filename: string; + size: number; + createdAt: Date; + type: 'manual' | 'automatic'; +} + +export class BackupService { + private backupDir: string; + private maxBackups = 10; + + constructor() { + this.backupDir = path.join(process.cwd(), 'backups'); + this.ensureBackupDirectory(); + this.scheduleAutomaticBackup(); + } + + private ensureBackupDirectory(): void { + if (!fs.existsSync(this.backupDir)) { + fs.mkdirSync(this.backupDir, { recursive: true }); + console.log('📁 Backup directory created:', this.backupDir); + } + } + + private getDatabaseConfig() { + const dbUrl = process.env.DATABASE_URL; + if (!dbUrl) { + throw new Error('DATABASE_URL not found in environment variables'); + } + + // Parse PostgreSQL URL + const url = new URL(dbUrl); + return { + host: url.hostname, + port: url.port || '5432', + database: url.pathname.slice(1), // Remove leading slash + username: url.username, + password: url.password, + }; + } + + async createBackup(type: 'manual' | 'automatic' = 'manual'): Promise { + try { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const filename = `backup_${type}_${timestamp}.sql`; + const filepath = path.join(this.backupDir, filename); + + const dbConfig = this.getDatabaseConfig(); + + // Set environment variable for password to avoid prompt + const env = { + ...process.env, + PGPASSWORD: dbConfig.password, + }; + + // Create pg_dump command + const command = `pg_dump -h ${dbConfig.host} -p ${dbConfig.port} -U ${dbConfig.username} -d ${dbConfig.database} --no-password --verbose --clean --if-exists --create > "${filepath}"`; + + console.log(`🔄 Starting ${type} backup...`); + + await execAsync(command, { env }); + + // Get file stats + const stats = fs.statSync(filepath); + + const backupFile: BackupFile = { + id: timestamp, + filename, + size: stats.size, + createdAt: new Date(), + type, + }; + + console.log(`✅ ${type} backup created: ${filename} (${this.formatFileSize(stats.size)})`); + + // Clean old backups + await this.cleanOldBackups(); + + return backupFile; + } catch (error) { + console.error('❌ Backup failed:', error); + throw new Error(`Backup failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + + async getBackupList(): Promise { + try { + const files = fs.readdirSync(this.backupDir); + const backupFiles: BackupFile[] = []; + + for (const filename of files) { + if (filename.endsWith('.sql')) { + const filepath = path.join(this.backupDir, filename); + const stats = fs.statSync(filepath); + + // Extract type and timestamp from filename + const parts = filename.replace('.sql', '').split('_'); + const type = parts[1] as 'manual' | 'automatic'; + const timestamp = parts.slice(2).join('_'); + + backupFiles.push({ + id: timestamp, + filename, + size: stats.size, + createdAt: stats.birthtime, + type, + }); + } + } + + // Sort by creation date (newest first) + return backupFiles.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); + } catch (error) { + console.error('❌ Failed to list backups:', error); + return []; + } + } + + async deleteBackup(filename: string): Promise { + try { + const filepath = path.join(this.backupDir, filename); + + if (!fs.existsSync(filepath)) { + throw new Error('Backup file not found'); + } + + fs.unlinkSync(filepath); + console.log(`🗑️ Backup deleted: ${filename}`); + } catch (error) { + console.error('❌ Failed to delete backup:', error); + throw error; + } + } + + async downloadBackup(filename: string): Promise { + const filepath = path.join(this.backupDir, filename); + + if (!fs.existsSync(filepath)) { + throw new Error('Backup file not found'); + } + + return filepath; + } + + private async cleanOldBackups(): Promise { + try { + const backups = await this.getBackupList(); + + if (backups.length > this.maxBackups) { + const toDelete = backups.slice(this.maxBackups); + + for (const backup of toDelete) { + await this.deleteBackup(backup.filename); + } + + console.log(`🧹 Cleaned ${toDelete.length} old backup(s)`); + } + } catch (error) { + console.error('❌ Failed to clean old backups:', error); + } + } + + private formatFileSize(bytes: number): string { + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + if (bytes === 0) return '0 Bytes'; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i]; + } + + private scheduleAutomaticBackup(): void { + // Schedule backup every night at 2:00 AM + cron.schedule('0 2 * * *', async () => { + try { + console.log('🌙 Starting scheduled automatic backup...'); + await this.createBackup('automatic'); + } catch (error) { + console.error('❌ Scheduled backup failed:', error); + } + }, { + timezone: 'Europe/Paris' + }); + + console.log('⏰ Automatic backup scheduled for 2:00 AM daily'); + } +} + +// Singleton instance +export const backupService = new BackupService(); \ No newline at end of file diff --git a/server/routes.ts b/server/routes.ts index 4425dd1..4c8c169 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -39,6 +39,7 @@ import { import { hasPermission } from "@shared/permissions"; import { z } from "zod"; import { invoiceVerificationService } from "./invoiceVerification"; +import { backupService } from "./backupService"; export async function registerRoutes(app: Express): Promise { // Health check endpoint for Docker @@ -2338,6 +2339,55 @@ RÉSUMÉ DU SCAN } }); + // Backup management routes (Admin only) + app.get('/api/backups', isAuthenticated, requireAdmin, async (req: any, res) => { + try { + const backups = await backupService.getBackupList(); + res.json(backups); + } catch (error) { + console.error("Error fetching backups:", error); + res.status(500).json({ message: "Failed to fetch backups" }); + } + }); + + app.post('/api/backups', isAuthenticated, requireAdmin, async (req: any, res) => { + try { + const backup = await backupService.createBackup('manual'); + res.json(backup); + } catch (error) { + console.error("Error creating backup:", error); + res.status(500).json({ message: "Failed to create backup" }); + } + }); + + app.get('/api/backups/:filename/download', isAuthenticated, requireAdmin, async (req: any, res) => { + try { + const { filename } = req.params; + const filepath = await backupService.downloadBackup(filename); + + res.download(filepath, filename, (err) => { + if (err) { + console.error("Error downloading backup:", err); + res.status(404).json({ message: "Backup file not found" }); + } + }); + } catch (error) { + console.error("Error downloading backup:", error); + res.status(500).json({ message: "Failed to download backup" }); + } + }); + + app.delete('/api/backups/:filename', isAuthenticated, requireAdmin, async (req: any, res) => { + try { + const { filename } = req.params; + await backupService.deleteBackup(filename); + res.json({ message: "Backup deleted successfully" }); + } catch (error) { + console.error("Error deleting backup:", error); + res.status(500).json({ message: "Failed to delete backup" }); + } + }); + // Create server instance const httpServer = createServer(app);