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);