From 4ce2d2877adb7eae8e4886933be608d6d520e4ac Mon Sep 17 00:00:00 2001 From: Manus Date: Sat, 8 Aug 2026 10:04:57 +0000 Subject: [PATCH] =?UTF-8?q?Checkpoint:=20Ajout=20onglet=20Sauvegarde=20DB?= =?UTF-8?q?=20dans=20ImportSettings=20:=20endpoint=20POST=20/api/db-backup?= =?UTF-8?q?=20(mysqldump),=20GET=20/api/db-backup/:filename,=20proc=C3=A9d?= =?UTF-8?q?ures=20tRPC=20backup.list=20et=20backup.delete,=20composant=20B?= =?UTF-8?q?ackupSection=20avec=20liste=20des=20sauvegardes=20et=20t=C3=A9l?= =?UTF-8?q?=C3=A9chargement.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/pages/ImportSettings.tsx | 148 +++++++++++++++++++++++++++- server/_core/index.ts | 67 +++++++++++++ server/routers.ts | 33 +++++++ 3 files changed, 246 insertions(+), 2 deletions(-) diff --git a/client/src/pages/ImportSettings.tsx b/client/src/pages/ImportSettings.tsx index 6f93eeb..5a6b9d3 100644 --- a/client/src/pages/ImportSettings.tsx +++ b/client/src/pages/ImportSettings.tsx @@ -11,10 +11,146 @@ import { toast } from "sonner"; import { Loader2, Save, Upload, FolderOpen, Mail, Play, Square, Download, Inbox, CheckCircle2, Monitor, FolderOutput, Wifi, AlertTriangle, Calendar, - ArrowDownToLine, Share2 + ArrowDownToLine, Share2, DatabaseBackup, HardDrive, CheckCircle, Clock } from "lucide-react"; import DashboardLayout from "@/components/DashboardLayout"; +// ============= COMPOSANT SAUVEGARDE DB ============= +function BackupSection() { + const [isGenerating, setIsGenerating] = useState(false); + const { data: backupList, refetch: refetchList } = trpc.backup.list.useQuery(); + const deleteBackupMutation = trpc.backup.delete.useMutation({ + onSuccess: () => { refetchList(); toast.success("Sauvegarde supprimée"); }, + onError: (e) => toast.error("Erreur : " + e.message), + }); + + const handleGenerateBackup = async () => { + setIsGenerating(true); + try { + const response = await fetch("/api/db-backup", { method: "POST", credentials: "include" }); + if (!response.ok) { + const err = await response.json().catch(() => ({ error: "Erreur inconnue" })); + toast.error("Erreur : " + (err.error || response.statusText)); + return; + } + // Déclencher le téléchargement + const blob = await response.blob(); + const contentDisposition = response.headers.get("Content-Disposition") || ""; + const match = contentDisposition.match(/filename\*?=(?:UTF-8'')?["']?([^"';\n]+)/i); + const fileName = match ? decodeURIComponent(match[1]) : `backup-${new Date().toISOString().slice(0,10)}.sql`; + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = fileName; + a.click(); + URL.revokeObjectURL(url); + toast.success("Sauvegarde générée et téléchargée"); + refetchList(); + } catch (e: any) { + toast.error("Erreur : " + e.message); + } finally { + setIsGenerating(false); + } + }; + + const formatSize = (bytes: number) => { + if (bytes < 1024) return bytes + " o"; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " Ko"; + return (bytes / (1024 * 1024)).toFixed(1) + " Mo"; + }; + + return ( + + +
+
+ +
+
+ Sauvegarde de la base de données + + Générer un dump SQL de la base de données et l'enregistrer localement dans le dossier backups/ + +
+
+
+ + {/* Bouton générer */} +
+ +

+ Le dump SQL sera généré, enregistré dans backups/ et téléchargé automatiquement. +

+
+ + {/* Liste des sauvegardes existantes */} + {backupList && backupList.length > 0 && ( +
+
+ + Sauvegardes enregistrées ({backupList.length}) +
+
+ {backupList.map((backup) => ( +
+
+ +
+

{backup.name}

+

+ {new Date(backup.createdAt).toLocaleString("fr-FR")} — {formatSize(backup.size)} +

+
+
+
+ + +
+
+ ))} +
+
+ )} + + {backupList && backupList.length === 0 && ( +
+ + Aucune sauvegarde enregistrée +
+ )} +
+
+ ); +} + + export default function ImportSettings() { // Chargement différé : on ne charge les statuts de services qu'après le montage const { data: settings, isLoading } = trpc.importSettings.get.useQuery(undefined, { @@ -207,7 +343,7 @@ export default function ImportSettings() { {/* Onglets Import / Export */} - + Paramètres d'import @@ -216,6 +352,10 @@ export default function ImportSettings() { Paramètres d'export + + + Sauvegarde DB + {/* ===== ONGLET IMPORT ===== */} @@ -955,6 +1095,10 @@ export default function ImportSettings() { + {/* ===== ONGLET SAUVEGARDE ===== */} + + + diff --git a/server/_core/index.ts b/server/_core/index.ts index f40d858..529c79c 100644 --- a/server/_core/index.ts +++ b/server/_core/index.ts @@ -5,6 +5,9 @@ import net from "net"; import path from "path"; import fs from "fs"; import archiver from "archiver"; +import { exec as execCb } from "child_process"; +import { promisify } from "util"; +const execAsync = promisify(execCb); import { createExpressMiddleware } from "@trpc/server/adapters/express"; import { registerOAuthRoutes } from "./oauth"; import { appRouter } from "../routers"; @@ -225,6 +228,70 @@ async function startServer() { }); // ============= WEB IMPORT SOURCES - Endpoint pour script cron externe ============= + + // ============= DB BACKUP - Génération et téléchargement dump MySQL ============= + app.post("/api/db-backup", async (req, res) => { + // Vérifier l'auth JWT + const { verifyToken } = await import("../auth"); + const token = req.cookies?.auth_token; + if (!token) { res.status(401).json({ error: "Non authentifié" }); return; } + const user = verifyToken(token); + if (!user || user.role !== "admin") { res.status(403).json({ error: "Accès réservé aux admins" }); return; } + + try { + const dbUrl = new URL(process.env.DATABASE_URL || ""); + const host = dbUrl.hostname; + const port = dbUrl.port || "3306"; + const username = dbUrl.username; + const password = dbUrl.password; + const database = dbUrl.pathname.slice(1); + + // Créer le dossier backups/ + const backupDir = path.resolve("backups"); + if (!fs.existsSync(backupDir)) fs.mkdirSync(backupDir, { recursive: true }); + + const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); + const fileName = `backup-${database}-${timestamp}.sql`; + const filePath = path.join(backupDir, fileName); + + // Construire la commande mysqldump + const sslFlag = dbUrl.searchParams.get("ssl-mode") === "DISABLED" ? "" : "--ssl-mode=REQUIRED"; + const cmd = `mysqldump ${sslFlag} -h "${host}" -P ${port} -u "${username}" --password="${password}" "${database}" > "${filePath}"`; + + console.log(`[Backup] Generating dump for database ${database}...`); + await execAsync(cmd); + console.log(`[Backup] Dump saved to ${filePath}`); + + // Retourner le fichier en téléchargement + const encodedName = encodeURIComponent(fileName); + res.setHeader("Content-Disposition", `attachment; filename="${encodedName}"; filename*=UTF-8''${encodedName}`); + res.setHeader("Content-Type", "application/sql"); + res.sendFile(filePath, (err) => { + if (err) console.error("[Backup] Error sending file:", err); + }); + } catch (err: any) { + console.error("[Backup] Error:", err.message); + res.status(500).json({ error: "Erreur lors de la génération du dump : " + err.message }); + } + }); + + // Télécharger une sauvegarde existante + app.get("/api/db-backup/:filename", async (req, res) => { + const { verifyToken } = await import("../auth"); + const token = req.cookies?.auth_token; + if (!token) { res.status(401).json({ error: "Non authentifié" }); return; } + const user = verifyToken(token); + if (!user || user.role !== "admin") { res.status(403).json({ error: "Accès réservé aux admins" }); return; } + + const fileName = path.basename(req.params.filename); + const filePath = path.join(path.resolve("backups"), fileName); + if (!fs.existsSync(filePath)) { res.status(404).json({ error: "Fichier introuvable" }); return; } + const encodedName = encodeURIComponent(fileName); + res.setHeader("Content-Disposition", `attachment; filename="${encodedName}"; filename*=UTF-8''${encodedName}`); + res.setHeader("Content-Type", "application/sql"); + res.sendFile(filePath); + }); + app.post("/api/web-import/push-invoice", async (req, res) => { try { const { apiToken, fileName, fileBase64, mimeType } = req.body; diff --git a/server/routers.ts b/server/routers.ts index 91d7070..a3af323 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -85,6 +85,11 @@ import { updateWebImportSourceStatus, } from "./db"; import { loginLocal, hashPassword, getAzureAuthUrl, isAzureAdConfigured, generateToken } from "./auth"; +import { exec as execCb } from "child_process"; +import { promisify } from "util"; +import fsSync from "fs"; +import pathSync from "path"; +const execAsync = promisify(execCb); import { extractInvoicesWithMistral, generateMetadataJSON } from "./invoiceExtractor"; import { localStoragePut, generateStorageKey } from "./localStorage"; import { testSftpConnection, exportInvoiceToSftp, getUserSftpConfig } from "./sftpExport"; @@ -2678,5 +2683,33 @@ export const appRouter = router({ return { apiToken: source.apiToken }; }), }), + + // ============= BACKUP ROUTES ============= + backup: router({ + // Liste les sauvegardes existantes dans le dossier backups/ + list: adminProcedure.query(async () => { + const backupDir = pathSync.resolve("backups"); + if (!fsSync.existsSync(backupDir)) return []; + const files = fsSync.readdirSync(backupDir) + .filter(f => f.endsWith(".sql") || f.endsWith(".sql.gz")) + .map(f => { + const stat = fsSync.statSync(pathSync.join(backupDir, f)); + return { name: f, size: stat.size, createdAt: stat.mtime }; + }) + .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); + return files; + }), + + // Supprime une sauvegarde + delete: adminProcedure + .input(z.object({ name: z.string() })) + .mutation(async ({ input }) => { + const backupDir = pathSync.resolve("backups"); + const filePath = pathSync.join(backupDir, pathSync.basename(input.name)); + if (!fsSync.existsSync(filePath)) throw new TRPCError({ code: 'NOT_FOUND', message: 'Fichier introuvable' }); + fsSync.unlinkSync(filePath); + return { success: true }; + }), + }), }); export type AppRouter = typeof appRouter;