*/ private function agentTables(): array { global $wpdb; // Le critère vit au cœur (AgentOwnedTables) et non ici : trois agents le // portaient chacun de leur côté, et deux se trompaient — l'un n'excluait rien // sur son chemin mysqldump, l'autre n'excluait nulle part. Au passage, la // comparaison y est insensible à la casse : nos tables sont créées en // majuscules, mais un serveur réglé sur lower_case_table_names=1 les rend en // minuscules, et l'exclusion ne s'appliquait alors PAS — sur des hébergements // qu'aucun test ne reproduit. return AgentOwnedTables::ours( (array) $wpdb->get_col('SHOW TABLES'), AgentOwnedTables::prefixWithin((string) ($wpdb->prefix ?? '')) ); } public function export(string $outputPath): string { if (function_exists('exec')) { $result = $this->exportViaMysqldump($outputPath); if ($result !== null) { return $result; } } return $this->exportViaPhp($outputPath); } private function exportViaMysqldump(string $outputPath): ?string { $host = DB_HOST; $user = DB_USER; $pass = DB_PASSWORD; $name = DB_NAME; // Keep stderr OUT of the dump file: '2>&1' would merge mysqldump warnings // into the SQL output and silently corrupt the backup. Capture it apart. $errFile = $outputPath . '.err'; // mysqldump ne sait pas exclure par motif : une option par table. Le chemin // mysqldump et le repli PHP doivent produire le MÊME périmètre, sinon la // sauvegarde d'un site dépend de la présence d'un binaire. $ignored = ''; foreach ($this->agentTables() as $table) { $ignored .= '--ignore-table=' . escapeshellarg($name . '.' . $table) . ' '; } $cmd = sprintf( 'mysqldump --single-transaction --quick --lock-tables=false %s%s -u %s %s > %s 2> %s', $ignored, MysqlCommand::hostArguments($host), escapeshellarg($user), escapeshellarg($name), escapeshellarg($outputPath), escapeshellarg($errFile) ); exec(MysqlCommand::passwordEnvironment($pass) . $cmd, $output, $returnCode); if ($returnCode === 0 && file_exists($outputPath) && filesize($outputPath) > 0) { @unlink($errFile); Logger::info('backup', 'Database exported via mysqldump'); return $outputPath; } $stderr = is_file($errFile) ? trim((string) file_get_contents($errFile)) : ''; @unlink($errFile); Logger::warning('backup', 'mysqldump failed, falling back to PHP exporter' . ($stderr !== '' ? ': ' . $stderr : '')); return null; } private function exportViaPhp(string $outputPath): string { global $wpdb; $handle = fopen($outputPath, 'wb'); if (!$handle) { throw new \RuntimeException('Cannot open output file for database export'); } fwrite($handle, "-- Sentinelo Backup\n"); fwrite($handle, "-- Date: " . gmdate('c') . "\n"); fwrite($handle, "-- WP Version: " . get_bloginfo('version') . "\n"); fwrite($handle, "-- DB Version: " . $wpdb->db_version() . "\n"); fwrite($handle, "SET FOREIGN_KEY_CHECKS=0;\n"); fwrite($handle, "SET SQL_MODE='NO_AUTO_VALUE_ON_ZERO';\n\n"); $excluded = $this->agentTables(); $tables = array_values(array_diff((array) $wpdb->get_col('SHOW TABLES'), $excluded)); foreach ($tables as $table) { // CREATE TABLE $create = $wpdb->get_row($wpdb->prepare('SHOW CREATE TABLE %i', $table), ARRAY_N); if ($create) { fwrite($handle, "\n-- Table structure for `{$table}`\n"); fwrite($handle, "DROP TABLE IF EXISTS `{$table}`;\n"); fwrite($handle, $create[1] . ";\n\n"); } // INSERT data $columns = $wpdb->get_results($wpdb->prepare('SHOW COLUMNS FROM %i', $table), ARRAY_A); $columnNames = array_map(static fn($c) => '`' . $c['Field'] . '`', $columns); $totalRows = (int) $wpdb->get_var($wpdb->prepare('SELECT COUNT(*) FROM %i', $table)); $batchSize = 5000; $offset = 0; if ($totalRows > 0) { fwrite($handle, "-- Data for `{$table}`\n"); } while ($offset < $totalRows) { $rows = $wpdb->get_results( $wpdb->prepare('SELECT * FROM %i LIMIT %d OFFSET %d', $table, $batchSize, $offset), ARRAY_A ); if (empty($rows)) { break; } $values = []; foreach ($rows as $row) { $rowValues = []; foreach ($row as $value) { if ($value === null) { $rowValues[] = 'NULL'; } else { $rowValues[] = "'" . esc_sql((string) $value) . "'"; } } $values[] = '(' . implode(',', $rowValues) . ')'; } if (!empty($values)) { fwrite($handle, "INSERT INTO `{$table}` (" . implode(',', $columnNames) . ") VALUES\n"); fwrite($handle, implode(",\n", $values) . ";\n"); } $offset += $batchSize; } } fwrite($handle, "\nSET FOREIGN_KEY_CHECKS=1;\n"); fclose($handle); if (filesize($outputPath) === 0) { throw new \RuntimeException('Database export file is empty'); } Logger::info('backup', 'Database exported via PHP'); return $outputPath; } }