Checkpoint: Ajout onglet Sauvegarde DB dans ImportSettings : endpoint POST /api/db-backup (mysqldump), GET /api/db-backup/:filename, procédures tRPC backup.list et backup.delete, composant BackupSection avec liste des sauvegardes et téléchargement.

This commit is contained in:
Manus
2026-08-08 10:04:57 +00:00
parent 711ce6b83a
commit 4ce2d2877a
3 changed files with 246 additions and 2 deletions

View File

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

View File

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