import fs from "fs/promises"; import path from "path"; import mysql, { type RowDataPacket } from "mysql2/promise"; /** Number of rows exported per INSERT statement to bound memory usage. */ const EXPORT_BATCH_SIZE = 500; /** Keep a short local history while preventing unbounded disk growth. */ const MAX_BACKUP_FILES = 10; export type DatabaseBackupResult = { fileName: string; filePath: string; size: number; tableCount: number; }; /** * Converts one MySQL value to a portable SQL literal. * Binary values, booleans, dates, quotes and backslashes are handled explicitly * so a generated dump can be imported without corrupting invoice data. */ export function toSqlLiteral(value: unknown): string { if (value === null || value === undefined) return "NULL"; if (Buffer.isBuffer(value)) return `X'${value.toString("hex")}'`; if (value instanceof Date) { return `'${value.toISOString().replace("T", " ").replace("Z", "")}'`; } if (typeof value === "number") return Number.isFinite(value) ? String(value) : "NULL"; if (typeof value === "boolean") return value ? "1" : "0"; return `'${String(value) .replace(/\\/g, "\\\\") .replace(/'/g, "\\'") .replace(/\u0000/g, "\\0") .replace(/\n/g, "\\n") .replace(/\r/g, "\\r")}'`; } function quoteIdentifier(identifier: string): string { if (!/^[A-Za-z0-9_$]+$/.test(identifier)) { throw new Error("Identifiant SQL inattendu lors de la sauvegarde"); } return `\`${identifier}\``; } function buildFileName(database: string, now = new Date()): string { const safeDatabase = database.replace(/[^A-Za-z0-9_-]/g, "_"); const timestamp = now.toISOString().replace(/[:.]/g, "-").slice(0, 19); return `backup-${safeDatabase}-${timestamp}.sql`; } function buildSslConfig(databaseUrl: URL) { const sslMode = databaseUrl.searchParams.get("ssl-mode")?.toUpperCase(); const sslParameter = databaseUrl.searchParams.get("ssl"); if (sslMode === "REQUIRED" || sslMode === "VERIFY_CA" || sslMode === "VERIFY_IDENTITY") { return { rejectUnauthorized: sslMode === "VERIFY_IDENTITY" }; } if (sslParameter && sslParameter !== "false") { try { return JSON.parse(sslParameter) as { rejectUnauthorized?: boolean }; } catch { return { rejectUnauthorized: false }; } } return undefined; } async function pruneOldBackups(backupDir: string): Promise { const entries = await fs.readdir(backupDir, { withFileTypes: true }); const backups = await Promise.all( entries .filter(entry => entry.isFile() && entry.name.endsWith(".sql")) .map(async entry => ({ name: entry.name, modifiedAt: (await fs.stat(path.join(backupDir, entry.name))).mtimeMs, })) ); backups.sort((a, b) => b.modifiedAt - a.modifiedAt); await Promise.all( backups.slice(MAX_BACKUP_FILES).map(backup => fs.unlink(path.join(backupDir, backup.name))) ); } /** * Creates a self-contained SQL dump without requiring the mysqldump binary. * Rows are exported in batches to avoid keeping the entire database in memory. */ export async function createDatabaseBackup( databaseUrlValue: string | undefined, backupDir: string ): Promise { if (!databaseUrlValue) { throw new Error("DATABASE_URL est absente"); } const databaseUrl = new URL(databaseUrlValue); if (!databaseUrl.protocol.startsWith("mysql")) { throw new Error("La sauvegarde requiert une base de données MySQL compatible"); } const database = databaseUrl.pathname.replace(/^\//, ""); if (!database) { throw new Error("Nom de base de données absent de DATABASE_URL"); } await fs.mkdir(backupDir, { recursive: true }); const fileName = buildFileName(database); const filePath = path.join(backupDir, fileName); const connection = await mysql.createConnection({ host: databaseUrl.hostname, port: Number(databaseUrl.port || "3306"), user: decodeURIComponent(databaseUrl.username), password: decodeURIComponent(databaseUrl.password), database, ssl: buildSslConfig(databaseUrl), }); try { await fs.writeFile( filePath, `-- Backup généré le ${new Date().toISOString()}\n-- Base : ${database}\nSET FOREIGN_KEY_CHECKS=0;\n\n`, "utf8" ); const [tables] = await connection.query("SHOW TABLES"); const tableNames = tables.map(row => String(Object.values(row)[0])); for (const tableName of tableNames) { const table = quoteIdentifier(tableName); const [createRows] = await connection.query(`SHOW CREATE TABLE ${table}`); const createStatement = String(createRows[0]?.["Create Table"] ?? ""); if (!createStatement) throw new Error(`Structure introuvable pour la table ${tableName}`); await fs.appendFile( filePath, `-- Table: ${tableName}\nDROP TABLE IF EXISTS ${table};\n${createStatement};\n\n`, "utf8" ); let offset = 0; while (true) { const [rows] = await connection.query( `SELECT * FROM ${table} LIMIT ${EXPORT_BATCH_SIZE} OFFSET ${offset}` ); if (rows.length === 0) break; const columns = Object.keys(rows[0]!).map(quoteIdentifier).join(", "); const values = rows .map(row => `(${Object.values(row).map(toSqlLiteral).join(", ")})`) .join(",\n"); await fs.appendFile(filePath, `INSERT INTO ${table} (${columns}) VALUES\n${values};\n\n`, "utf8"); offset += rows.length; } } await fs.appendFile(filePath, "SET FOREIGN_KEY_CHECKS=1;\n-- Fin du dump\n", "utf8"); await pruneOldBackups(backupDir); const { size } = await fs.stat(filePath); return { fileName, filePath, size, tableCount: tableNames.length }; } catch (error) { await fs.rm(filePath, { force: true }); throw error; } finally { await connection.end(); } }