tigerstyle-heat/includes/backup/class-storage-manager.php
Ryan Malloy 0028738e33 Initial commit: TigerStyle Heat v2.0.0
Make your WordPress site irresistible. Natural SEO attraction with:
- robots.txt management
- sitemap.xml generation
- LLMs.txt support
- Google integration (Analytics, Search Console, Tag Manager)
- Schema.org structured data
- Open Graph / Twitter Card meta tags
- AMP support
- Visual elements gallery
- Built-in backup/restore module

Includes build.sh and .distignore for WordPress-installable release ZIPs.
2026-05-27 13:41:35 -06:00

839 lines
29 KiB
PHP

<?php
/**
* Storage Manager for TigerStyle SEO Backup System
* Handles local and S3-compatible cloud storage with encryption
*/
// Prevent direct access
if (!defined('ABSPATH')) {
exit;
}
class TigerStyleSEO_Storage_Manager {
/**
* Single instance
*/
private static $instance = null;
/**
* Storage backends
*/
private $storage_backends = array();
/**
* Logger instance
*/
private $logger = null;
/**
* Get instance
*/
public static function instance() {
if (is_null(self::$instance)) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Constructor
*/
private function __construct() {
$this->init();
}
/**
* Initialize storage manager
*/
private function init() {
$this->logger = TigerStyleSEO_Backup_Logger::instance();
$this->register_storage_backends();
}
/**
* Register available storage backends
*/
private function register_storage_backends() {
// Local storage (always available)
$this->storage_backends['local'] = array(
'name' => 'Local Storage',
'description' => 'Store backups locally on server',
'available' => true,
'settings' => array(
'path' => get_option('backup_local_path', WP_CONTENT_DIR . '/tigerstyle-backups/')
)
);
// S3 Compatible storage
$s3_enabled = get_option('backup_s3_enabled', false);
$this->storage_backends['s3'] = array(
'name' => 'S3 Compatible Storage',
'description' => 'Store backups in AWS S3 or compatible services (MinIO, DigitalOcean Spaces, etc.)',
'available' => $s3_enabled && $this->check_s3_requirements(),
'settings' => array(
'endpoint' => get_option('backup_s3_endpoint', ''),
'bucket' => get_option('backup_s3_bucket', ''),
'access_key' => get_option('backup_s3_access_key', ''),
'secret_key' => get_option('backup_s3_secret_key', ''),
'region' => get_option('backup_s3_region', 'us-east-1'),
'storage_class' => get_option('backup_s3_storage_class', 'STANDARD_IA'),
'encryption' => get_option('backup_s3_encryption', true),
'prefix' => get_option('backup_s3_prefix', 'tigerstyle-backups/')
)
);
}
/**
* Check S3 requirements
*/
private function check_s3_requirements() {
return extension_loaded('curl') && function_exists('openssl_encrypt');
}
/**
* Store backup using configured storage backends
*/
public function store_backup($backup_file, $backup_id, $manifest) {
$storage_results = array();
$primary_backend = get_option('backup_primary_storage', 'local');
$secondary_backends = get_option('backup_secondary_storage', array());
// Store to primary backend
if (isset($this->storage_backends[$primary_backend]) && $this->storage_backends[$primary_backend]['available']) {
$this->logger->info("Storing backup to primary storage", array(
'backend' => $primary_backend,
'backup_id' => $backup_id
));
$result = $this->store_to_backend($backup_file, $backup_id, $manifest, $primary_backend);
if ($result['success']) {
$storage_results['primary'] = $result;
} else {
$this->logger->error("Primary storage failed", array(
'backend' => $primary_backend,
'error' => $result['error']
));
}
}
// Store to secondary backends
foreach ($secondary_backends as $backend) {
if (isset($this->storage_backends[$backend]) && $this->storage_backends[$backend]['available']) {
$this->logger->info("Storing backup to secondary storage", array(
'backend' => $backend,
'backup_id' => $backup_id
));
$result = $this->store_to_backend($backup_file, $backup_id, $manifest, $backend);
if ($result['success']) {
$storage_results['secondary'][$backend] = $result;
} else {
$this->logger->warning("Secondary storage failed", array(
'backend' => $backend,
'error' => $result['error']
));
}
}
}
return $storage_results;
}
/**
* Store backup to specific backend
*/
private function store_to_backend($backup_file, $backup_id, $manifest, $backend) {
$start_time = microtime(true);
try {
switch ($backend) {
case 'local':
$result = $this->store_to_local($backup_file, $backup_id, $manifest);
break;
case 's3':
$result = $this->store_to_s3($backup_file, $backup_id, $manifest);
break;
default:
throw new Exception("Unknown storage backend: " . $backend);
}
$duration = microtime(true) - $start_time;
$this->logger->info("Backup stored successfully", array(
'backend' => $backend,
'backup_id' => $backup_id,
'duration' => round($duration, 2) . 's',
'location' => $result['location'] ?? 'unknown'
));
return array(
'success' => true,
'backend' => $backend,
'location' => $result['location'],
'duration' => $duration,
'metadata' => $result['metadata'] ?? array()
);
} catch (Exception $e) {
$this->logger->error("Storage backend failed", array(
'backend' => $backend,
'backup_id' => $backup_id,
'error' => $e->getMessage()
));
return array(
'success' => false,
'backend' => $backend,
'error' => $e->getMessage()
);
}
}
/**
* Store backup to local storage
*/
private function store_to_local($backup_file, $backup_id, $manifest) {
$settings = $this->storage_backends['local']['settings'];
$destination_dir = $settings['path'];
// Ensure destination directory exists
if (!is_dir($destination_dir)) {
wp_mkdir_p($destination_dir);
// Protect backup directory
$htaccess_content = "Order deny,allow\nDeny from all\n";
file_put_contents($destination_dir . '.htaccess', $htaccess_content);
}
$destination_file = $destination_dir . basename($backup_file);
// Copy backup file
if (!copy($backup_file, $destination_file)) {
throw new Exception("Failed to copy backup to local storage");
}
// Create manifest file
$manifest_file = $destination_dir . $backup_id . '-manifest.json';
file_put_contents($manifest_file, json_encode($manifest, JSON_PRETTY_PRINT));
return array(
'location' => $destination_file,
'manifest_file' => $manifest_file,
'metadata' => array(
'size' => filesize($destination_file),
'checksum' => md5_file($destination_file)
)
);
}
/**
* Store backup to S3 compatible storage
*/
private function store_to_s3($backup_file, $backup_id, $manifest) {
$settings = $this->storage_backends['s3']['settings'];
if (empty($settings['bucket']) || empty($settings['access_key']) || empty($settings['secret_key'])) {
throw new Exception("S3 storage not properly configured");
}
$s3_key = $settings['prefix'] . $backup_id . '/' . basename($backup_file);
$manifest_key = $settings['prefix'] . $backup_id . '/manifest.json';
// Upload backup file
$backup_upload = $this->s3_put_object($backup_file, $s3_key, $settings);
if (!$backup_upload['success']) {
throw new Exception("Failed to upload backup to S3: " . $backup_upload['error']);
}
// Upload manifest file
$manifest_content = json_encode($manifest, JSON_PRETTY_PRINT);
$manifest_upload = $this->s3_put_object_content($manifest_content, $manifest_key, $settings);
if (!$manifest_upload['success']) {
$this->logger->warning("Failed to upload manifest to S3", array(
'error' => $manifest_upload['error']
));
}
return array(
'location' => $s3_key,
'manifest_location' => $manifest_key,
'metadata' => array(
'bucket' => $settings['bucket'],
'region' => $settings['region'],
'storage_class' => $settings['storage_class'],
'encryption' => $settings['encryption'],
'etag' => $backup_upload['etag'] ?? '',
'version_id' => $backup_upload['version_id'] ?? ''
)
);
}
/**
* Upload file to S3
*/
private function s3_put_object($file_path, $key, $settings) {
if (!file_exists($file_path)) {
return array('success' => false, 'error' => 'File not found');
}
$file_content = file_get_contents($file_path);
return $this->s3_put_object_content($file_content, $key, $settings);
}
/**
* Upload content to S3
*/
private function s3_put_object_content($content, $key, $settings) {
$endpoint = $settings['endpoint'] ?: 'https://s3.' . $settings['region'] . '.amazonaws.com';
$bucket = $settings['bucket'];
$url = $endpoint . '/' . $bucket . '/' . $key;
// Prepare headers
$headers = array();
$headers['Date'] = gmdate('D, d M Y H:i:s T');
$headers['Content-Type'] = 'application/octet-stream';
$headers['Content-Length'] = strlen($content);
if ($settings['storage_class'] && $settings['storage_class'] !== 'STANDARD') {
$headers['x-amz-storage-class'] = $settings['storage_class'];
}
if ($settings['encryption']) {
$headers['x-amz-server-side-encryption'] = 'AES256';
}
// Calculate content hash for authentication
$content_hash = hash('sha256', $content);
$headers['x-amz-content-sha256'] = $content_hash;
// Create authorization signature
$auth_header = $this->create_s3_auth_header('PUT', $key, $headers, $settings);
$headers['Authorization'] = $auth_header;
// Prepare curl request
$ch = curl_init();
curl_setopt_array($ch, array(
CURLOPT_URL => $url,
CURLOPT_CUSTOMREQUEST => 'PUT',
CURLOPT_POSTFIELDS => $content,
CURLOPT_HTTPHEADER => $this->format_headers($headers),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HEADER => true,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_TIMEOUT => 300,
CURLOPT_FOLLOWLOCATION => false
));
$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curl_error = curl_error($ch);
curl_close($ch);
if ($curl_error) {
return array('success' => false, 'error' => 'CURL error: ' . $curl_error);
}
if ($http_code >= 200 && $http_code < 300) {
// Extract ETag from response headers
$etag = '';
if (preg_match('/ETag: "([^"]+)"/', $response, $matches)) {
$etag = $matches[1];
}
return array(
'success' => true,
'http_code' => $http_code,
'etag' => $etag,
'response' => $response
);
} else {
return array(
'success' => false,
'error' => 'HTTP ' . $http_code . ': ' . $this->extract_s3_error($response)
);
}
}
/**
* Create S3 authorization header (AWS Signature Version 4)
*/
private function create_s3_auth_header($method, $key, $headers, $settings) {
$access_key = $settings['access_key'];
$secret_key = $settings['secret_key'];
$region = $settings['region'];
$service = 's3';
$timestamp = gmdate('Ymd\THis\Z');
$date = gmdate('Ymd');
// Create canonical request
$canonical_uri = '/' . $key;
$canonical_querystring = '';
$canonical_headers = '';
$signed_headers = array();
ksort($headers);
foreach ($headers as $name => $value) {
$name_lower = strtolower($name);
$canonical_headers .= $name_lower . ':' . $value . "\n";
$signed_headers[] = $name_lower;
}
$signed_headers_string = implode(';', $signed_headers);
$payload_hash = $headers['x-amz-content-sha256'];
$canonical_request = $method . "\n" .
$canonical_uri . "\n" .
$canonical_querystring . "\n" .
$canonical_headers . "\n" .
$signed_headers_string . "\n" .
$payload_hash;
// Create string to sign
$algorithm = 'AWS4-HMAC-SHA256';
$credential_scope = $date . '/' . $region . '/' . $service . '/aws4_request';
$string_to_sign = $algorithm . "\n" .
$timestamp . "\n" .
$credential_scope . "\n" .
hash('sha256', $canonical_request);
// Calculate signature
$k_date = hash_hmac('sha256', $date, 'AWS4' . $secret_key, true);
$k_region = hash_hmac('sha256', $region, $k_date, true);
$k_service = hash_hmac('sha256', $service, $k_region, true);
$k_signing = hash_hmac('sha256', 'aws4_request', $k_service, true);
$signature = hash_hmac('sha256', $string_to_sign, $k_signing);
// Create authorization header
$authorization = $algorithm . ' ' .
'Credential=' . $access_key . '/' . $credential_scope . ', ' .
'SignedHeaders=' . $signed_headers_string . ', ' .
'Signature=' . $signature;
return $authorization;
}
/**
* Format headers for curl
*/
private function format_headers($headers) {
$formatted = array();
foreach ($headers as $name => $value) {
$formatted[] = $name . ': ' . $value;
}
return $formatted;
}
/**
* Extract error message from S3 response
*/
private function extract_s3_error($response) {
if (preg_match('/<Code>([^<]+)<\/Code>/', $response, $matches)) {
$code = $matches[1];
if (preg_match('/<Message>([^<]+)<\/Message>/', $response, $msg_matches)) {
return $code . ': ' . $msg_matches[1];
}
return $code;
}
return 'Unknown S3 error';
}
/**
* Test S3 connection
*/
public function test_s3_connection($settings = null) {
if (!$settings) {
$settings = $this->storage_backends['s3']['settings'];
}
try {
// Test by trying to list bucket contents
$test_content = 'TigerStyle SEO Backup Connection Test';
$test_key = $settings['prefix'] . 'connection-test.txt';
$result = $this->s3_put_object_content($test_content, $test_key, $settings);
if ($result['success']) {
// Clean up test file
$this->s3_delete_object($test_key, $settings);
return array('success' => true, 'message' => 'S3 connection successful');
} else {
return array('success' => false, 'error' => $result['error']);
}
} catch (Exception $e) {
return array('success' => false, 'error' => $e->getMessage());
}
}
/**
* Delete object from S3
*/
private function s3_delete_object($key, $settings) {
$endpoint = $settings['endpoint'] ?: 'https://s3.' . $settings['region'] . '.amazonaws.com';
$bucket = $settings['bucket'];
$url = $endpoint . '/' . $bucket . '/' . $key;
$headers = array();
$headers['Date'] = gmdate('D, d M Y H:i:s T');
$headers['x-amz-content-sha256'] = hash('sha256', '');
$auth_header = $this->create_s3_auth_header('DELETE', $key, $headers, $settings);
$headers['Authorization'] = $auth_header;
$ch = curl_init();
curl_setopt_array($ch, array(
CURLOPT_URL => $url,
CURLOPT_CUSTOMREQUEST => 'DELETE',
CURLOPT_HTTPHEADER => $this->format_headers($headers),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_TIMEOUT => 60
));
$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return $http_code >= 200 && $http_code < 300;
}
/**
* Retrieve backup from storage
*/
public function retrieve_backup($backup_id, $destination_path, $backend = null) {
if (!$backend) {
$backend = $this->get_backup_location($backup_id);
}
if (!$backend) {
throw new Exception("Cannot determine backup location for: " . $backup_id);
}
$this->logger->info("Retrieving backup from storage", array(
'backup_id' => $backup_id,
'backend' => $backend,
'destination' => $destination_path
));
switch ($backend) {
case 'local':
return $this->retrieve_from_local($backup_id, $destination_path);
case 's3':
return $this->retrieve_from_s3($backup_id, $destination_path);
default:
throw new Exception("Unknown storage backend: " . $backend);
}
}
/**
* Retrieve backup from local storage
*/
private function retrieve_from_local($backup_id, $destination_path) {
$settings = $this->storage_backends['local']['settings'];
$backup_dir = $settings['path'];
// Find backup file
$pattern = $backup_dir . $backup_id . '.*';
$files = glob($pattern);
if (empty($files)) {
throw new Exception("Backup file not found: " . $backup_id);
}
$backup_file = $files[0];
if (!copy($backup_file, $destination_path)) {
throw new Exception("Failed to copy backup from local storage");
}
return array(
'success' => true,
'source' => $backup_file,
'destination' => $destination_path,
'size' => filesize($destination_path)
);
}
/**
* Retrieve backup from S3
*/
private function retrieve_from_s3($backup_id, $destination_path) {
// Implementation would go here for S3 download
// For brevity, this is a placeholder
throw new Exception("S3 backup retrieval not yet implemented");
}
/**
* Get backup location from database
*/
private function get_backup_location($backup_id) {
global $wpdb;
$table_name = $wpdb->prefix . 'tigerstyle_backups';
$backup = $wpdb->get_row(
$wpdb->prepare("SELECT storage_location FROM {$table_name} WHERE backup_id = %s", $backup_id),
ARRAY_A
);
return $backup ? $backup['storage_location'] : null;
}
/**
* Get available storage backends
*/
public function get_available_backends() {
return array_filter($this->storage_backends, function($backend) {
return $backend['available'];
});
}
/**
* Get storage backend settings
*/
public function get_backend_settings($backend) {
return isset($this->storage_backends[$backend]) ? $this->storage_backends[$backend]['settings'] : null;
}
/**
* List all available backups
*/
public function list_backups($limit = 50, $offset = 0) {
global $wpdb;
$table_name = $wpdb->prefix . 'tigerstyle_backup_metadata';
// Get total count
$total_count = $wpdb->get_var("SELECT COUNT(*) FROM {$table_name}");
// Get backup list with pagination
$backups = $wpdb->get_results(
$wpdb->prepare(
"SELECT backup_id, storage_type, file_path, s3_bucket, s3_key, s3_url,
file_size, created_at, metadata_json
FROM {$table_name}
ORDER BY created_at DESC
LIMIT %d OFFSET %d",
$limit,
$offset
),
ARRAY_A
);
// Process backup data
$processed_backups = array();
foreach ($backups as $backup) {
$metadata = array();
if (!empty($backup['metadata_json'])) {
$metadata = json_decode($backup['metadata_json'], true) ?: array();
}
// Determine storage location and availability
$storage_info = $this->get_backup_storage_info($backup);
$processed_backups[] = array(
'backup_id' => $backup['backup_id'],
'storage_type' => $backup['storage_type'],
'file_size' => intval($backup['file_size']),
'file_size_formatted' => $this->format_file_size($backup['file_size']),
'created_at' => $backup['created_at'],
'created_at_formatted' => $this->format_backup_date($backup['created_at']),
'storage_location' => $storage_info['location'],
'is_available' => $storage_info['available'],
'metadata' => $metadata,
'actions' => $this->get_backup_actions($backup)
);
}
return array(
'backups' => $processed_backups,
'total_count' => intval($total_count),
'limit' => $limit,
'offset' => $offset,
'has_more' => ($offset + $limit) < $total_count
);
}
/**
* Get backup storage information
*/
private function get_backup_storage_info($backup) {
$info = array(
'location' => '',
'available' => false
);
switch ($backup['storage_type']) {
case 'local':
if (!empty($backup['file_path'])) {
$info['location'] = basename($backup['file_path']);
$info['available'] = file_exists($backup['file_path']);
} else {
// Fallback: check local storage directory
$local_path = $this->storage_backends['local']['settings']['path'];
$pattern = $local_path . $backup['backup_id'] . '.*';
$files = glob($pattern);
if (!empty($files)) {
$info['location'] = basename($files[0]);
$info['available'] = true;
}
}
break;
case 's3':
if (!empty($backup['s3_key'])) {
$info['location'] = $backup['s3_bucket'] . '/' . $backup['s3_key'];
$info['available'] = true; // Assume available unless proven otherwise
} elseif (!empty($backup['s3_url'])) {
$info['location'] = $backup['s3_url'];
$info['available'] = true;
}
break;
default:
$info['location'] = __('Unknown storage type', 'tigerstyle-heat');
break;
}
return $info;
}
/**
* Format file size for display
*/
private function format_file_size($bytes) {
$bytes = floatval($bytes);
$units = array('B', 'KB', 'MB', 'GB', 'TB');
for ($i = 0; $bytes > 1024 && $i < count($units) - 1; $i++) {
$bytes /= 1024;
}
return round($bytes, 2) . ' ' . $units[$i];
}
/**
* Format backup date for display
*/
private function format_backup_date($datetime) {
$timestamp = strtotime($datetime);
if (!$timestamp) {
return $datetime;
}
$now = current_time('timestamp');
$diff = $now - $timestamp;
if ($diff < HOUR_IN_SECONDS) {
$minutes = floor($diff / MINUTE_IN_SECONDS);
return sprintf(_n('%d minute ago', '%d minutes ago', $minutes, 'tigerstyle-heat'), $minutes);
} elseif ($diff < DAY_IN_SECONDS) {
$hours = floor($diff / HOUR_IN_SECONDS);
return sprintf(_n('%d hour ago', '%d hours ago', $hours, 'tigerstyle-heat'), $hours);
} elseif ($diff < WEEK_IN_SECONDS) {
$days = floor($diff / DAY_IN_SECONDS);
return sprintf(_n('%d day ago', '%d days ago', $days, 'tigerstyle-heat'), $days);
} else {
return date_i18n(get_option('date_format') . ' ' . get_option('time_format'), $timestamp);
}
}
/**
* Get available actions for a backup
*/
private function get_backup_actions($backup) {
$actions = array();
// Restore action
$actions['restore'] = array(
'label' => __('Restore', 'tigerstyle-heat'),
'url' => admin_url('admin.php?page=tigerstyle-heat&action=restore&backup_id=' . urlencode($backup['backup_id'])),
'class' => 'button button-primary'
);
// Download action (for local backups)
if ($backup['storage_type'] === 'local' && !empty($backup['file_path']) && file_exists($backup['file_path'])) {
$actions['download'] = array(
'label' => __('Download', 'tigerstyle-heat'),
'url' => admin_url('admin.php?page=tigerstyle-heat&action=download&backup_id=' . urlencode($backup['backup_id'])),
'class' => 'button'
);
}
// Delete action
$actions['delete'] = array(
'label' => __('Delete', 'tigerstyle-heat'),
'url' => admin_url('admin.php?page=tigerstyle-heat&action=delete&backup_id=' . urlencode($backup['backup_id'])),
'class' => 'button button-link-delete',
'confirm' => __('Are you sure you want to delete this backup? This action cannot be undone.', 'tigerstyle-heat')
);
return $actions;
}
/**
* Get storage statistics
*/
public function get_storage_stats() {
$stats = array(
'total_count' => 0,
'total_size' => 0,
'usage_percent' => 0,
'available_space' => 0,
'backends' => array()
);
// Calculate local storage stats
if (isset($this->storage_backends['local']) && $this->storage_backends['local']['available']) {
$local_path = $this->storage_backends['local']['settings']['path'];
if (is_dir($local_path)) {
$total_size = 0;
$file_count = 0;
$files = glob($local_path . '*.{zip,tar,gz,bz2}', GLOB_BRACE);
if ($files) {
foreach ($files as $file) {
if (is_file($file)) {
$total_size += filesize($file);
$file_count++;
}
}
}
$stats['total_count'] = $file_count;
$stats['total_size'] = $total_size;
// Calculate disk usage percentage
$disk_total = disk_total_space($local_path);
$disk_free = disk_free_space($local_path);
if ($disk_total && $disk_free) {
$stats['usage_percent'] = round((($disk_total - $disk_free) / $disk_total) * 100, 2);
$stats['available_space'] = $disk_free;
}
$stats['backends']['local'] = array(
'count' => $file_count,
'size' => $total_size,
'path' => $local_path
);
}
}
// Add S3 stats if available (placeholder for now)
if (isset($this->storage_backends['s3']) && $this->storage_backends['s3']['available']) {
$stats['backends']['s3'] = array(
'count' => 0,
'size' => 0,
'bucket' => $this->storage_backends['s3']['settings']['bucket']
);
}
return $stats;
}
}