tigerstyle-life9/includes/class-database-backup.php
Ryan Malloy e92b7f8700 Initial commit: TigerStyle Life9 v1.0.0
Because cats have 9 lives, but servers don't - so they need
backup-restore! Complete backup solution with S3/MinIO support.

- Full WordPress backup (files + database)
- S3 / MinIO / S3-compatible storage backends
- Scheduled automatic backups
- Disaster recovery / one-click restore
- Backup integrity validation
- Cat-themed admin interface

Includes build.sh and .distignore for WordPress-installable release ZIPs.
2026-05-27 14:32:00 -06:00

672 lines
20 KiB
PHP

<?php
/**
* Database Backup
*
* Secure database backup functionality with prepared statements
* Addresses SQL injection vulnerabilities found in XCloner
*
* @package TigerStyleLife9
* @since 1.0.0
*/
// Exit if accessed directly
if (!defined('ABSPATH')) {
exit;
}
/**
* Database backup class
*
* @since 1.0.0
*/
class TigerStyle_Life9_Database_Backup {
/**
* Security instance
*
* @var TigerStyle_Life9_Security
*/
private $security;
/**
* Database connection
*
* @var wpdb
*/
private $wpdb;
/**
* Backup progress callback
*
* @var callable
*/
private $progress_callback;
/**
* Constructor
*/
public function __construct() {
global $wpdb;
$this->wpdb = $wpdb;
$this->security = tigerstyle_life9()->get_security();
}
/**
* Export database to SQL file
*
* @param string $output_file Output file path
* @param array $config Export configuration
* @return bool Success status
*/
public function export_database($output_file, $config = []) {
$defaults = [
'include_tables' => [],
'exclude_tables' => [],
'add_drop_table' => true,
'add_if_not_exists' => false,
'disable_keys' => true,
'single_transaction' => true,
'lock_tables' => false,
'where_conditions' => [],
'max_query_size' => 1048576, // 1MB
'compress_output' => false
];
$config = array_merge($defaults, $config);
// Validate output file path
if (!$this->security->validate_path(dirname($output_file))) {
throw new Exception('Invalid output file path');
}
try {
$tables = $this->get_tables_to_backup($config);
if (empty($tables)) {
throw new Exception('No tables found to backup');
}
$this->log_info('Starting database backup', [
'tables_count' => count($tables),
'output_file' => $output_file
]);
// Open output file
$handle = fopen($output_file, 'w');
if (!$handle) {
throw new Exception('Cannot open output file for writing');
}
// Write SQL header
$this->write_sql_header($handle, $config);
// Begin transaction if configured
if ($config['single_transaction']) {
fwrite($handle, "START TRANSACTION;\n");
fwrite($handle, "SET SQL_MODE = 'NO_AUTO_VALUE_ON_ZERO';\n");
fwrite($handle, "SET AUTOCOMMIT = 0;\n\n");
}
// Disable foreign key checks temporarily
fwrite($handle, "SET FOREIGN_KEY_CHECKS = 0;\n\n");
$total_tables = count($tables);
$processed_tables = 0;
// Export each table
foreach ($tables as $table) {
$this->export_table($handle, $table, $config);
$processed_tables++;
if ($this->progress_callback) {
call_user_func($this->progress_callback, 'database',
round(($processed_tables / $total_tables) * 100), $table);
}
}
// Re-enable foreign key checks
fwrite($handle, "\nSET FOREIGN_KEY_CHECKS = 1;\n");
// Commit transaction if configured
if ($config['single_transaction']) {
fwrite($handle, "COMMIT;\n");
}
// Write SQL footer
$this->write_sql_footer($handle);
fclose($handle);
// Compress if requested
if ($config['compress_output']) {
$this->compress_sql_file($output_file);
}
$this->log_info('Database backup completed successfully', [
'file_size' => filesize($output_file),
'tables_exported' => count($tables)
]);
return true;
} catch (Exception $e) {
if (isset($handle) && $handle) {
fclose($handle);
}
$this->log_error('Database backup failed', ['error' => $e->getMessage()]);
// Clean up partial file
if (file_exists($output_file)) {
unlink($output_file);
}
throw $e;
}
}
/**
* Get list of tables to backup
*
* @param array $config Backup configuration
* @return array Array of table names
*/
private function get_tables_to_backup($config) {
$all_tables = $this->get_all_tables();
// If specific tables are included, use only those
if (!empty($config['include_tables'])) {
$tables = array_intersect($all_tables, $config['include_tables']);
} else {
$tables = $all_tables;
}
// Remove excluded tables
if (!empty($config['exclude_tables'])) {
$tables = array_diff($tables, $config['exclude_tables']);
}
// Validate table names for security
$sanitizer = new TigerStyle_Life9_Sanitizer();
$valid_tables = [];
foreach ($tables as $table) {
$clean_table = $sanitizer->sanitize_table_name($table);
if ($clean_table && $this->table_exists($clean_table)) {
$valid_tables[] = $clean_table;
}
}
return $valid_tables;
}
/**
* Get all tables in database
*
* @return array Array of table names
*/
private function get_all_tables() {
$tables = [];
$results = $this->wpdb->get_results("SHOW TABLES", ARRAY_N);
foreach ($results as $row) {
if (isset($row[0])) {
$tables[] = $row[0];
}
}
return $tables;
}
/**
* Check if table exists
*
* @param string $table_name Table name
* @return bool True if table exists
*/
private function table_exists($table_name) {
$table = $this->wpdb->get_var($this->wpdb->prepare(
"SHOW TABLES LIKE %s",
$table_name
));
return $table === $table_name;
}
/**
* Export single table
*
* @param resource $handle File handle
* @param string $table Table name
* @param array $config Export configuration
*/
private function export_table($handle, $table, $config) {
$this->log_info('Exporting table: ' . $table);
// Get table structure
$create_table = $this->get_table_structure($table, $config);
if ($config['add_drop_table']) {
fwrite($handle, "DROP TABLE IF EXISTS `{$table}`;\n");
}
fwrite($handle, $create_table . "\n\n");
// Get table data
$this->export_table_data($handle, $table, $config);
fwrite($handle, "\n");
}
/**
* Get table structure (CREATE TABLE statement)
*
* @param string $table Table name
* @param array $config Export configuration
* @return string CREATE TABLE statement
*/
private function get_table_structure($table, $config) {
$result = $this->wpdb->get_row($this->wpdb->prepare(
"SHOW CREATE TABLE `%s`",
$table
), ARRAY_A);
if (!$result || !isset($result['Create Table'])) {
throw new Exception("Failed to get structure for table: {$table}");
}
$create_table = $result['Create Table'];
// Modify CREATE statement if needed
if ($config['add_if_not_exists']) {
$create_table = str_replace(
'CREATE TABLE `' . $table . '`',
'CREATE TABLE IF NOT EXISTS `' . $table . '`',
$create_table
);
}
return $create_table . ';';
}
/**
* Export table data
*
* @param resource $handle File handle
* @param string $table Table name
* @param array $config Export configuration
*/
private function export_table_data($handle, $table, $config) {
// Get column information
$columns = $this->get_table_columns($table);
if (empty($columns)) {
return; // No columns, skip data export
}
// Build column list
$column_list = '`' . implode('`, `', array_keys($columns)) . '`';
// Add DISABLE KEYS for MyISAM tables if configured
if ($config['disable_keys']) {
fwrite($handle, "ALTER TABLE `{$table}` DISABLE KEYS;\n");
}
// Prepare base query
$base_query = "SELECT {$column_list} FROM `{$table}`";
// Add WHERE conditions if specified
$where_clause = '';
if (isset($config['where_conditions'][$table])) {
$where_condition = $config['where_conditions'][$table];
// Validate WHERE condition for security
if ($this->validate_where_condition($where_condition)) {
$where_clause = " WHERE {$where_condition}";
}
}
$query = $base_query . $where_clause;
// Get total row count for progress tracking
$count_query = "SELECT COUNT(*) FROM `{$table}`" . $where_clause;
$total_rows = $this->wpdb->get_var($count_query);
if ($total_rows == 0) {
return; // No data to export
}
// Export data in chunks to manage memory
$chunk_size = 1000;
$offset = 0;
$current_insert = '';
$current_size = 0;
while ($offset < $total_rows) {
$chunk_query = $query . " LIMIT {$chunk_size} OFFSET {$offset}";
$rows = $this->wpdb->get_results($chunk_query, ARRAY_A);
if (empty($rows)) {
break;
}
foreach ($rows as $row) {
$values = $this->prepare_row_values($row, $columns);
$insert_line = "({$values})";
// Start new INSERT statement if needed
if (empty($current_insert)) {
$current_insert = "INSERT INTO `{$table}` ({$column_list}) VALUES\n";
$current_size = strlen($current_insert);
}
// Check if adding this row would exceed max query size
if ($current_size + strlen($insert_line) > $config['max_query_size']) {
// Write current INSERT and start new one
fwrite($handle, rtrim($current_insert, ",\n") . ";\n");
$current_insert = "INSERT INTO `{$table}` ({$column_list}) VALUES\n";
$current_size = strlen($current_insert);
}
$current_insert .= $insert_line . ",\n";
$current_size += strlen($insert_line) + 2;
}
$offset += $chunk_size;
}
// Write final INSERT statement
if (!empty($current_insert)) {
fwrite($handle, rtrim($current_insert, ",\n") . ";\n");
}
// Re-enable keys if configured
if ($config['disable_keys']) {
fwrite($handle, "ALTER TABLE `{$table}` ENABLE KEYS;\n");
}
}
/**
* Get table columns information
*
* @param string $table Table name
* @return array Column information
*/
private function get_table_columns($table) {
$columns = [];
$results = $this->wpdb->get_results($this->wpdb->prepare(
"SHOW COLUMNS FROM `%s`",
$table
), ARRAY_A);
foreach ($results as $column) {
$columns[$column['Field']] = [
'type' => $column['Type'],
'null' => $column['Null'] === 'YES',
'key' => $column['Key'],
'default' => $column['Default'],
'extra' => $column['Extra']
];
}
return $columns;
}
/**
* Prepare row values for INSERT statement
*
* @param array $row Row data
* @param array $columns Column information
* @return string Formatted values string
*/
private function prepare_row_values($row, $columns) {
$values = [];
foreach ($row as $column => $value) {
if ($value === null) {
$values[] = 'NULL';
} else {
// Escape value based on column type
$column_info = $columns[$column] ?? [];
$escaped_value = $this->escape_value($value, $column_info);
$values[] = $escaped_value;
}
}
return implode(', ', $values);
}
/**
* Escape value for SQL
*
* @param mixed $value Value to escape
* @param array $column_info Column information
* @return string Escaped value
*/
private function escape_value($value, $column_info) {
// Use WordPress's built-in escaping
if (is_numeric($value) && !empty($column_info['type'])) {
$type = strtolower($column_info['type']);
// For numeric types, don't quote if it's actually numeric
if (strpos($type, 'int') !== false ||
strpos($type, 'decimal') !== false ||
strpos($type, 'float') !== false ||
strpos($type, 'double') !== false) {
return $value;
}
}
// For all other types, escape and quote
return "'" . $this->wpdb->_escape($value) . "'";
}
/**
* Validate WHERE condition for security
*
* @param string $condition WHERE condition
* @return bool True if condition is safe
*/
private function validate_where_condition($condition) {
// Basic validation to prevent SQL injection
$dangerous_keywords = [
'DROP', 'DELETE', 'UPDATE', 'INSERT', 'ALTER', 'CREATE',
'EXEC', 'EXECUTE', 'UNION', 'SCRIPT', '--', '/*', '*/'
];
$upper_condition = strtoupper($condition);
foreach ($dangerous_keywords as $keyword) {
if (strpos($upper_condition, $keyword) !== false) {
return false;
}
}
return true;
}
/**
* Write SQL file header
*
* @param resource $handle File handle
* @param array $config Export configuration
*/
private function write_sql_header($handle, $config) {
$header = "-- TigerStyle Life9 Database Backup\n";
$header .= "-- Generated on: " . date('Y-m-d H:i:s') . "\n";
$header .= "-- WordPress Version: " . get_bloginfo('version') . "\n";
$header .= "-- Database: " . DB_NAME . "\n";
$header .= "-- Host: " . DB_HOST . "\n";
$header .= "-- PHP Version: " . PHP_VERSION . "\n";
$header .= "-- Plugin Version: " . TIGERSTYLE_LIFE9_VERSION . "\n";
$header .= "--\n";
$header .= "-- WARNING: This file contains sensitive data.\n";
$header .= "-- Do not share or store in public locations.\n";
$header .= "--\n\n";
$header .= "SET SQL_MODE = 'NO_AUTO_VALUE_ON_ZERO';\n";
$header .= "SET time_zone = '+00:00';\n\n";
fwrite($handle, $header);
}
/**
* Write SQL file footer
*
* @param resource $handle File handle
*/
private function write_sql_footer($handle) {
$footer = "\n-- Backup completed successfully\n";
$footer .= "-- End of TigerStyle Life9 Database Backup\n";
fwrite($handle, $footer);
}
/**
* Compress SQL file using gzip
*
* @param string $file_path SQL file path
* @return bool Success status
*/
private function compress_sql_file($file_path) {
if (!function_exists('gzencode')) {
return false;
}
$data = file_get_contents($file_path);
if ($data === false) {
return false;
}
$compressed = gzencode($data, 9);
if ($compressed === false) {
return false;
}
$compressed_file = $file_path . '.gz';
$result = file_put_contents($compressed_file, $compressed) !== false;
if ($result) {
// Remove original file and rename compressed file
unlink($file_path);
rename($compressed_file, $file_path);
}
return $result;
}
/**
* Set progress callback
*
* @param callable $callback Progress callback function
*/
public function set_progress_callback($callback) {
$this->progress_callback = $callback;
}
/**
* Get database size
*
* @return array Database size information
*/
public function get_database_size() {
$query = "SELECT
table_schema as 'database_name',
SUM(data_length + index_length) as 'size_bytes',
COUNT(*) as 'table_count'
FROM information_schema.tables
WHERE table_schema = %s
GROUP BY table_schema";
$result = $this->wpdb->get_row($this->wpdb->prepare($query, DB_NAME), ARRAY_A);
if (!$result) {
return [
'database_name' => DB_NAME,
'size_bytes' => 0,
'table_count' => 0,
'formatted_size' => '0 B'
];
}
$result['formatted_size'] = $this->format_bytes($result['size_bytes']);
return $result;
}
/**
* Get table sizes
*
* @return array Array of table size information
*/
public function get_table_sizes() {
$query = "SELECT
table_name,
table_rows,
data_length,
index_length,
(data_length + index_length) as total_size
FROM information_schema.tables
WHERE table_schema = %s
ORDER BY total_size DESC";
$results = $this->wpdb->get_results($this->wpdb->prepare($query, DB_NAME), ARRAY_A);
foreach ($results as &$table) {
$table['formatted_size'] = $this->format_bytes($table['total_size']);
}
return $results;
}
/**
* Test database connection
*
* @return bool True if connection is working
*/
public function test_connection() {
try {
$result = $this->wpdb->get_var("SELECT 1");
return $result === '1';
} catch (Exception $e) {
return false;
}
}
/**
* Format bytes to human readable format
*
* @param int $bytes Number of bytes
* @return string Formatted size
*/
private function format_bytes($bytes) {
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
for ($i = 0; $bytes > 1024; $i++) {
$bytes /= 1024;
}
return round($bytes, 2) . ' ' . $units[$i];
}
/**
* Log info message
*
* @param string $message Log message
* @param array $context Additional context
*/
private function log_info($message, $context = []) {
error_log("TigerStyle Life9 DB Backup [INFO]: {$message}");
}
/**
* Log error message
*
* @param string $message Log message
* @param array $context Additional context
*/
private function log_error($message, $context = []) {
error_log("TigerStyle Life9 DB Backup [ERROR]: {$message}");
}
}