tigerstyle-life9/includes/class-backup-engine.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

861 lines
27 KiB
PHP

<?php
/**
* Backup Engine
*
* Core backup functionality with security-first approach
* Addresses all backup-related vulnerabilities found in XCloner
*
* @package TigerStyleLife9
* @since 1.0.0
*/
// Exit if accessed directly
if (!defined('ABSPATH')) {
exit;
}
/**
* Backup engine class
*
* @since 1.0.0
*/
class TigerStyle_Life9_Backup_Engine {
/**
* Security instance
*
* @var TigerStyle_Life9_Security
*/
private $security;
/**
* File scanner instance
*
* @var TigerStyle_Life9_File_Scanner
*/
private $file_scanner;
/**
* Database backup instance
*
* @var TigerStyle_Life9_Database_Backup
*/
private $database_backup;
/**
* Storage manager instance
*
* @var TigerStyle_Life9_Storage_Manager
*/
private $storage_manager;
/**
* Current backup ID
*
* @var int
*/
private $backup_id;
/**
* Backup configuration
*
* @var array
*/
private $config;
/**
* Backup progress
*
* @var array
*/
private $progress;
/**
* Constructor
*/
public function __construct() {
$this->security = tigerstyle_life9()->get_security();
$this->file_scanner = new TigerStyle_Life9_File_Scanner();
$this->database_backup = new TigerStyle_Life9_Database_Backup();
$this->storage_manager = new TigerStyle_Life9_Storage_Manager();
$this->progress = [
'stage' => 'idle',
'progress' => 0,
'files_processed' => 0,
'total_files' => 0,
'current_file' => '',
'bytes_processed' => 0,
'total_bytes' => 0
];
}
/**
* Start backup process
*
* @param array $config Backup configuration
* @return int|false Backup ID or false on failure
*/
public function start_backup($config) {
try {
// Validate configuration
$validator = new TigerStyle_Life9_Validator();
if (!$validator->validate_backup_config($config)) {
$this->log_error('Invalid backup configuration', $validator->get_errors());
return false;
}
$this->config = $config;
// Create backup record
$this->backup_id = $this->create_backup_record();
if (!$this->backup_id) {
return false;
}
// Log backup start
$this->security->log_security_event('backup_started', [
'backup_id' => $this->backup_id,
'backup_name' => $config['backup_name'] ?? 'Unnamed',
'backup_type' => $config['backup_type'] ?? 'full'
]);
// Execute backup asynchronously
wp_schedule_single_event(time(), 'tigerstyle_life9_execute_backup', [$this->backup_id, $config]);
return $this->backup_id;
} catch (Exception $e) {
$this->log_error('Backup start failed', ['error' => $e->getMessage()]);
return false;
}
}
/**
* Execute backup process
*
* @param int $backup_id Backup ID
* @param array $config Backup configuration
*/
public function execute_backup($backup_id, $config) {
$this->backup_id = $backup_id;
$this->config = $config;
try {
$this->update_backup_status('running');
$this->log_info('Backup execution started');
// Create temporary working directory
$temp_dir = $this->create_temp_directory();
if (!$temp_dir) {
throw new Exception('Failed to create temporary directory');
}
$backup_parts = [];
// Stage 1: Database backup
if (!empty($config['include_database'])) {
$this->update_progress('database', 0);
$db_file = $this->backup_database($temp_dir);
if ($db_file) {
$backup_parts['database'] = $db_file;
$this->log_info('Database backup completed');
} else {
throw new Exception('Database backup failed');
}
$this->update_progress('database', 100);
}
// Stage 2: Files backup
if (!empty($config['include_files'])) {
$this->update_progress('files', 0);
$files_archive = $this->backup_files($temp_dir);
if ($files_archive) {
$backup_parts['files'] = $files_archive;
$this->log_info('Files backup completed');
} else {
throw new Exception('Files backup failed');
}
$this->update_progress('files', 100);
}
// Stage 3: Create final archive
$this->update_progress('archive', 0);
$final_archive = $this->create_final_archive($backup_parts, $temp_dir);
if (!$final_archive) {
throw new Exception('Failed to create final archive');
}
$this->update_progress('archive', 100);
// Stage 4: Storage and cleanup
$this->update_progress('storage', 0);
$stored_path = $this->store_backup($final_archive);
if (!$stored_path) {
throw new Exception('Failed to store backup');
}
// Generate checksum
$encryption = new TigerStyle_Life9_Encryption();
$checksum = $encryption->file_checksum($stored_path);
// Update backup record
$this->finalize_backup_record($stored_path, filesize($stored_path), $checksum);
// Cleanup temporary files
$this->cleanup_temp_directory($temp_dir);
$this->update_backup_status('completed');
$this->log_info('Backup completed successfully');
// Log completion
$this->security->log_security_event('backup_completed', [
'backup_id' => $this->backup_id,
'file_size' => filesize($stored_path),
'checksum' => $checksum
]);
} catch (Exception $e) {
$this->log_error('Backup failed', ['error' => $e->getMessage()]);
$this->update_backup_status('failed');
// Cleanup on failure
if (isset($temp_dir)) {
$this->cleanup_temp_directory($temp_dir);
}
if (isset($stored_path) && file_exists($stored_path)) {
unlink($stored_path);
}
}
}
/**
* Cancel backup process
*
* @param int $backup_id Backup ID
* @return bool Success status
*/
public function cancel_backup($backup_id) {
try {
global $wpdb;
$backup = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM {$wpdb->prefix}tigerstyle_life9_backups WHERE id = %d",
$backup_id
));
if (!$backup) {
return false;
}
if (!in_array($backup->status, ['pending', 'running'])) {
return false; // Cannot cancel completed/failed backups
}
// Update status
$wpdb->update(
$wpdb->prefix . 'tigerstyle_life9_backups',
['status' => 'cancelled', 'completed_at' => current_time('mysql')],
['id' => $backup_id],
['%s', '%s'],
['%d']
);
// Log cancellation
$this->log_info('Backup cancelled by user', $backup_id);
$this->security->log_security_event('backup_cancelled', [
'backup_id' => $backup_id
]);
return true;
} catch (Exception $e) {
error_log('TigerStyle Life9: Cancel backup error - ' . $e->getMessage());
return false;
}
}
/**
* Create backup record in database
*
* @return int|false Backup ID or false on failure
*/
private function create_backup_record() {
try {
global $wpdb;
$result = $wpdb->insert(
$wpdb->prefix . 'tigerstyle_life9_backups',
[
'name' => $this->config['backup_name'] ?? 'Backup ' . date('Y-m-d H:i:s'),
'status' => 'pending',
'created_at' => current_time('mysql'),
'backup_type' => $this->config['backup_type'] ?? 'full',
'includes_files' => !empty($this->config['include_files']) ? 1 : 0,
'includes_database' => !empty($this->config['include_database']) ? 1 : 0,
'compression' => $this->config['compression_method'] ?? 'zip',
'settings' => wp_json_encode($this->config)
],
['%s', '%s', '%s', '%s', '%d', '%d', '%s', '%s']
);
return $result ? $wpdb->insert_id : false;
} catch (Exception $e) {
error_log('TigerStyle Life9: Create backup record error - ' . $e->getMessage());
return false;
}
}
/**
* Backup database
*
* @param string $temp_dir Temporary directory
* @return string|false Database backup file path or false on failure
*/
private function backup_database($temp_dir) {
try {
$this->log_info('Starting database backup');
$db_config = [
'include_tables' => $this->config['database_tables'] ?? [],
'exclude_tables' => $this->config['exclude_database_tables'] ?? [],
'add_drop_table' => true,
'add_if_not_exists' => false,
'disable_keys' => true,
'where_conditions' => $this->config['database_where'] ?? []
];
$db_file = $temp_dir . '/database.sql';
$success = $this->database_backup->export_database($db_file, $db_config);
if ($success && file_exists($db_file)) {
// Compress database file
$compressed_file = $temp_dir . '/database.sql.gz';
if ($this->compress_file($db_file, $compressed_file)) {
unlink($db_file); // Remove uncompressed version
return $compressed_file;
}
return $db_file;
}
return false;
} catch (Exception $e) {
$this->log_error('Database backup failed', ['error' => $e->getMessage()]);
return false;
}
}
/**
* Backup files
*
* @param string $temp_dir Temporary directory
* @return string|false Files backup archive path or false on failure
*/
private function backup_files($temp_dir) {
try {
$this->log_info('Starting files backup');
// Scan files to backup
$scan_config = [
'include_paths' => $this->config['include_paths'] ?? [ABSPATH],
'exclude_patterns' => $this->get_exclude_patterns(),
'follow_symlinks' => false,
'max_file_size' => $this->get_max_file_size()
];
$files = $this->file_scanner->scan_files($scan_config);
if (empty($files)) {
$this->log_error('No files found to backup');
return false;
}
$this->progress['total_files'] = count($files);
$this->progress['total_bytes'] = array_sum(array_column($files, 'size'));
// Create files archive
$archive_file = $temp_dir . '/files.' . $this->get_compression_extension();
switch ($this->config['compression_method'] ?? 'zip') {
case 'zip':
$success = $this->create_zip_archive($files, $archive_file);
break;
case 'tar':
$success = $this->create_tar_archive($files, $archive_file);
break;
default:
$success = $this->create_zip_archive($files, $archive_file);
}
return $success ? $archive_file : false;
} catch (Exception $e) {
$this->log_error('Files backup failed', ['error' => $e->getMessage()]);
return false;
}
}
/**
* Create ZIP archive
*
* @param array $files List of files
* @param string $archive_path Archive file path
* @return bool Success status
*/
private function create_zip_archive($files, $archive_path) {
if (!class_exists('ZipArchive')) {
$this->log_error('ZipArchive class not available');
return false;
}
$zip = new ZipArchive();
$result = $zip->open($archive_path, ZipArchive::CREATE | ZipArchive::OVERWRITE);
if ($result !== true) {
$this->log_error('Failed to create ZIP archive', ['error_code' => $result]);
return false;
}
$processed = 0;
$base_path = rtrim(ABSPATH, '/');
foreach ($files as $file) {
if (!$this->security->validate_path($file['path'], ABSPATH)) {
$this->log_error('Invalid file path skipped', ['path' => $file['path']]);
continue;
}
if (!file_exists($file['path']) || !is_readable($file['path'])) {
$this->log_error('File not readable, skipped', ['path' => $file['path']]);
continue;
}
// Get relative path for archive
$relative_path = ltrim(str_replace($base_path, '', $file['path']), '/');
if ($file['type'] === 'directory') {
$zip->addEmptyDir($relative_path);
} else {
$zip->addFile($file['path'], $relative_path);
}
$processed++;
$this->progress['files_processed'] = $processed;
$this->progress['current_file'] = $relative_path;
$this->progress['progress'] = round(($processed / $this->progress['total_files']) * 100);
// Update progress periodically
if ($processed % 100 === 0) {
$this->update_progress('files', $this->progress['progress']);
}
}
$result = $zip->close();
if (!$result) {
$this->log_error('Failed to finalize ZIP archive');
return false;
}
$this->log_info('ZIP archive created successfully', [
'file_count' => $processed,
'archive_size' => filesize($archive_path)
]);
return true;
}
/**
* Create TAR archive
*
* @param array $files List of files
* @param string $archive_path Archive file path
* @return bool Success status
*/
private function create_tar_archive($files, $archive_path) {
try {
$tar_command = 'tar -czf ' . escapeshellarg($archive_path);
$base_path = rtrim(ABSPATH, '/');
// Create file list
$file_list = tempnam(sys_get_temp_dir(), 'tigerstyle_life9_files_');
$handle = fopen($file_list, 'w');
if (!$handle) {
throw new Exception('Failed to create file list');
}
foreach ($files as $file) {
if (!$this->security->validate_path($file['path'], ABSPATH)) {
continue;
}
$relative_path = ltrim(str_replace($base_path, '', $file['path']), '/');
fwrite($handle, $relative_path . "\n");
}
fclose($handle);
// Execute tar command
$tar_command .= ' -C ' . escapeshellarg($base_path) . ' -T ' . escapeshellarg($file_list);
exec($tar_command . ' 2>&1', $output, $return_code);
// Cleanup file list
unlink($file_list);
if ($return_code !== 0) {
$this->log_error('TAR command failed', [
'command' => $tar_command,
'output' => implode("\n", $output),
'return_code' => $return_code
]);
return false;
}
$this->log_info('TAR archive created successfully', [
'archive_size' => filesize($archive_path)
]);
return true;
} catch (Exception $e) {
$this->log_error('TAR archive creation failed', ['error' => $e->getMessage()]);
return false;
}
}
/**
* Create final backup archive
*
* @param array $backup_parts Individual backup parts
* @param string $temp_dir Temporary directory
* @return string|false Final archive path or false on failure
*/
private function create_final_archive($backup_parts, $temp_dir) {
try {
$final_archive = $temp_dir . '/backup_' . date('Y-m-d_H-i-s') . '.zip';
$zip = new ZipArchive();
$result = $zip->open($final_archive, ZipArchive::CREATE | ZipArchive::OVERWRITE);
if ($result !== true) {
$this->log_error('Failed to create final archive', ['error_code' => $result]);
return false;
}
// Add backup parts to final archive
foreach ($backup_parts as $type => $file_path) {
if (file_exists($file_path)) {
$zip->addFile($file_path, basename($file_path));
}
}
// Add backup manifest
$manifest = [
'backup_id' => $this->backup_id,
'created_at' => current_time('mysql'),
'wordpress_version' => get_bloginfo('version'),
'php_version' => PHP_VERSION,
'plugin_version' => TIGERSTYLE_LIFE9_VERSION,
'backup_type' => $this->config['backup_type'] ?? 'full',
'includes_files' => !empty($this->config['include_files']),
'includes_database' => !empty($this->config['include_database']),
'parts' => array_keys($backup_parts)
];
$zip->addFromString('backup-manifest.json', wp_json_encode($manifest, JSON_PRETTY_PRINT));
$result = $zip->close();
if (!$result) {
$this->log_error('Failed to finalize backup archive');
return false;
}
return $final_archive;
} catch (Exception $e) {
$this->log_error('Final archive creation failed', ['error' => $e->getMessage()]);
return false;
}
}
/**
* Store backup in final location
*
* @param string $archive_path Temporary archive path
* @return string|false Final storage path or false on failure
*/
private function store_backup($archive_path) {
try {
$upload_dir = wp_upload_dir();
$backup_dir = $upload_dir['basedir'] . '/tigerstyle-life9/backups';
// Ensure backup directory exists
if (!file_exists($backup_dir)) {
wp_mkdir_p($backup_dir);
}
$filename = 'backup_' . $this->backup_id . '_' . date('Y-m-d_H-i-s') . '.zip';
$final_path = $backup_dir . '/' . $filename;
// Move archive to final location
if (!rename($archive_path, $final_path)) {
throw new Exception('Failed to move backup to final location');
}
// Set proper permissions
chmod($final_path, 0644);
$this->log_info('Backup stored successfully', [
'path' => $final_path,
'size' => filesize($final_path)
]);
return $final_path;
} catch (Exception $e) {
$this->log_error('Backup storage failed', ['error' => $e->getMessage()]);
return false;
}
}
/**
* Finalize backup record
*
* @param string $file_path Final backup file path
* @param int $file_size File size in bytes
* @param string $checksum File checksum
*/
private function finalize_backup_record($file_path, $file_size, $checksum) {
global $wpdb;
$wpdb->update(
$wpdb->prefix . 'tigerstyle_life9_backups',
[
'file_path' => $file_path,
'file_size' => $file_size,
'hash' => $checksum,
'completed_at' => current_time('mysql')
],
['id' => $this->backup_id],
['%s', '%d', '%s', '%s'],
['%d']
);
}
/**
* Create temporary directory
*
* @return string|false Temporary directory path or false on failure
*/
private function create_temp_directory() {
$upload_dir = wp_upload_dir();
$temp_base = $upload_dir['basedir'] . '/tigerstyle-life9/temp';
if (!file_exists($temp_base)) {
wp_mkdir_p($temp_base);
}
$temp_dir = $temp_base . '/backup_' . $this->backup_id . '_' . time();
if (wp_mkdir_p($temp_dir)) {
return $temp_dir;
}
return false;
}
/**
* Cleanup temporary directory
*
* @param string $temp_dir Temporary directory path
*/
private function cleanup_temp_directory($temp_dir) {
if (file_exists($temp_dir)) {
$this->recursive_rmdir($temp_dir);
}
}
/**
* Recursively remove directory
*
* @param string $dir Directory path
*/
private function recursive_rmdir($dir) {
if (is_dir($dir)) {
$objects = scandir($dir);
foreach ($objects as $object) {
if ($object != "." && $object != "..") {
if (is_dir($dir . "/" . $object)) {
$this->recursive_rmdir($dir . "/" . $object);
} else {
unlink($dir . "/" . $object);
}
}
}
rmdir($dir);
}
}
/**
* Get exclude patterns
*
* @return array
*/
private function get_exclude_patterns() {
$default_patterns = [
'*.log',
'*.tmp',
'*~',
'.DS_Store',
'Thumbs.db',
'wp-content/cache/*',
'wp-content/backup/*',
'wp-content/uploads/tigerstyle-life9/*'
];
$custom_patterns = $this->config['exclude_patterns'] ?? [];
return array_merge($default_patterns, $custom_patterns);
}
/**
* Get maximum file size for backup
*
* @return int Maximum file size in bytes
*/
private function get_max_file_size() {
$max_size_mb = get_option('tigerstyle_life9_max_backup_size_mb', 1000);
return $max_size_mb * 1024 * 1024;
}
/**
* Get compression file extension
*
* @return string
*/
private function get_compression_extension() {
switch ($this->config['compression_method'] ?? 'zip') {
case 'tar':
return 'tar.gz';
case 'gzip':
return 'gz';
default:
return 'zip';
}
}
/**
* Compress file using gzip
*
* @param string $source Source file path
* @param string $destination Destination file path
* @return bool Success status
*/
private function compress_file($source, $destination) {
if (!function_exists('gzencode')) {
return false;
}
$data = file_get_contents($source);
if ($data === false) {
return false;
}
$compressed = gzencode($data, 9);
if ($compressed === false) {
return false;
}
return file_put_contents($destination, $compressed) !== false;
}
/**
* Update backup status
*
* @param string $status New status
*/
private function update_backup_status($status) {
global $wpdb;
$wpdb->update(
$wpdb->prefix . 'tigerstyle_life9_backups',
['status' => $status],
['id' => $this->backup_id],
['%s'],
['%d']
);
}
/**
* Update backup progress
*
* @param string $stage Current stage
* @param int $progress Progress percentage
*/
private function update_progress($stage, $progress) {
$this->progress['stage'] = $stage;
$this->progress['progress'] = $progress;
// Store progress in database or cache for real-time updates
set_transient('tigerstyle_life9_backup_progress_' . $this->backup_id, $this->progress, 300);
}
/**
* Log info message
*
* @param string $message Log message
* @param array $context Additional context
*/
private function log_info($message, $context = []) {
$this->log_message('info', $message, $context);
}
/**
* Log error message
*
* @param string $message Log message
* @param array $context Additional context
*/
private function log_error($message, $context = []) {
$this->log_message('error', $message, $context);
}
/**
* Log message to database
*
* @param string $level Log level
* @param string $message Log message
* @param array $context Additional context
*/
private function log_message($level, $message, $context = []) {
global $wpdb;
$wpdb->insert(
$wpdb->prefix . 'tigerstyle_life9_logs',
[
'backup_id' => $this->backup_id ?? 0,
'level' => $level,
'message' => $message,
'context' => wp_json_encode($context),
'created_at' => current_time('mysql')
],
['%d', '%s', '%s', '%s', '%s']
);
// Also log to WordPress error log for debugging
error_log("TigerStyle Life9 [{$level}]: {$message}");
}
}
// Register backup execution hook
add_action('tigerstyle_life9_execute_backup', function($backup_id, $config) {
$backup_engine = new TigerStyle_Life9_Backup_Engine();
$backup_engine->execute_backup($backup_id, $config);
}, 10, 2);