Checkpoint: Audit technique : suppression des composants, scripts et dépendances inutilisés ; chargement différé des pages ; génération de sauvegardes SQL robuste par lots ; centralisation des contrôles d’accès ; sécurisation des cookies Azure et imports web ; documentation de maintenance et 5 tests de non-régression ajoutés.
This commit is contained in:
170
server/databaseBackup.ts
Normal file
170
server/databaseBackup.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
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<void> {
|
||||
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<DatabaseBackupResult> {
|
||||
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<RowDataPacket[]>("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<RowDataPacket[]>(`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<RowDataPacket[]>(
|
||||
`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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user