Changeset 3453079
- Timestamp:
- 02/03/2026 04:52:49 PM (5 weeks ago)
- Location:
- siteskite/trunk
- Files:
-
- 2 added
- 14 edited
-
assets/img/recover-mode.svg (added)
-
includes/API/FallbackAPI.php (modified) (8 diffs)
-
includes/API/RestAPI.php (modified) (2 diffs)
-
includes/Backup/BackupManager.php (modified) (19 diffs)
-
includes/Cleanup/CleanupManager.php (modified) (6 diffs)
-
includes/Core/Activator.php (modified) (1 diff)
-
includes/Core/AutoLoginController.php (modified) (5 diffs)
-
includes/Core/Plugin.php (modified) (6 diffs)
-
includes/Core/SiteSkiteSafetyCore.php (added)
-
includes/Cron/CronTriggerHandler.php (modified) (7 diffs)
-
includes/Cron/ExternalCronManager.php (modified) (3 diffs)
-
includes/Restore/RestoreManager.php (modified) (47 diffs)
-
languages/siteskite.pot (modified) (1 diff)
-
readme.txt (modified) (6 diffs)
-
siteskite-link.php (modified) (2 diffs)
-
uninstall.php (modified) (1 diff)
Legend:
- Unmodified
- Added
- Removed
-
siteskite/trunk/includes/API/FallbackAPI.php
r3445801 r3453079 14 14 use SiteSkite\Schedule\ScheduleManager; 15 15 use SiteSkite\WPCanvas\WPCanvasController; 16 use SiteSkite\Cron\CronTriggerHandler; 16 17 17 18 use WP_REST_Request; … … 49 50 private WPCanvasController $wp_canvas_controller; 50 51 private RestAPI $rest_api; 52 private ?CronTriggerHandler $cron_trigger_handler; 51 53 52 54 /** … … 62 64 RestoreManager $restore_manager, 63 65 ScheduleManager $schedule_manager, 64 RestAPI $rest_api 66 RestAPI $rest_api, 67 ?CronTriggerHandler $cron_trigger_handler = null 65 68 ) { 66 69 $this->auth_manager = $auth_manager; … … 73 76 $this->schedule_manager = $schedule_manager; 74 77 $this->rest_api = $rest_api; 78 $this->cron_trigger_handler = $cron_trigger_handler; 75 79 $this->wp_canvas_controller = new WPCanvasController($this->logger, $this->auth_manager); 76 80 $this->init(); … … 147 151 148 152 // Create a mock WP_REST_Request from admin-ajax data 149 $rest_request = $this->create_rest_request($route, $method, $json_data); 150 151 // Verify authentication 152 $auth_result = $this->auth_manager->verify_request($rest_request); 153 if (is_wp_error($auth_result)) { 154 $this->logger->warning('Fallback API authentication failed', [ 155 'route' => $route, 156 'error' => $auth_result->get_error_message() 157 ]); 158 159 ob_end_clean(); 160 wp_send_json_error([ 161 'code' => $auth_result->get_error_code(), 162 'message' => $auth_result->get_error_message(), 163 'data' => ['status' => 401] 164 ], 401); 165 return; 153 // Pass raw JSON input for cron endpoints so they can parse the body correctly 154 $is_cron_endpoint = strpos($route, '/cron/') === 0; 155 $rest_request = $this->create_rest_request($route, $method, $json_data, $is_cron_endpoint ? $json_input : null); 156 157 // Cron endpoints use different authentication (X-SiteSkite-Cron-Secret) 158 // Skip regular API key authentication for cron endpoints - they handle auth internally 159 160 if (!$is_cron_endpoint) { 161 // Verify authentication for non-cron endpoints 162 $auth_result = $this->auth_manager->verify_request($rest_request); 163 if (is_wp_error($auth_result)) { 164 $this->logger->warning('Fallback API authentication failed', [ 165 'route' => $route, 166 'error' => $auth_result->get_error_message() 167 ]); 168 169 ob_end_clean(); 170 wp_send_json_error([ 171 'code' => $auth_result->get_error_code(), 172 'message' => $auth_result->get_error_message(), 173 'data' => ['status' => 401] 174 ], 401); 175 return; 176 } 166 177 } 167 178 … … 235 246 * Create a WP_REST_Request from admin-ajax data 236 247 */ 237 private function create_rest_request(string $route, string $method, ?array $json_data = null ): WP_REST_Request248 private function create_rest_request(string $route, string $method, ?array $json_data = null, ?string $raw_body = null): WP_REST_Request 238 249 { 239 250 $request = new WP_REST_Request($method, '/' . self::API_NAMESPACE . $route); … … 307 318 } 308 319 309 return $request; 320 // Extract and set X-SiteSkite-Cron-Secret header for cron endpoints 321 // PHP converts headers to uppercase in $_SERVER: X-SiteSkite-Cron-Secret -> HTTP_X_SITESKITE_CRON_SECRET 322 $cron_secret = null; 323 // Check $_SERVER directly first (most reliable) 324 if (isset($_SERVER['HTTP_X_SITESKITE_CRON_SECRET'])) { 325 $cron_secret = $_SERVER['HTTP_X_SITESKITE_CRON_SECRET']; 326 } else { 327 // Check converted headers array (case-insensitive search) 328 foreach ($headers as $header_name => $header_value) { 329 if (strcasecmp($header_name, 'X-SiteSkite-Cron-Secret') === 0) { 330 $cron_secret = $header_value; 331 break; 332 } 333 } 334 } 335 336 if ($cron_secret) { 337 // Set header with exact case that handler expects 338 $request->add_header('X-SiteSkite-Cron-Secret', $cron_secret); 339 } 340 341 // For cron endpoints, set the raw body so get_body() and get_json_params() work correctly 342 // This is needed because CronTriggerHandler uses these methods to parse the JSON body 343 if ($raw_body !== null && !empty($raw_body)) { 344 $request->set_body($raw_body); 345 } 346 347 return $request; 310 348 } 311 349 … … 384 422 '/schedule/cleanup' => [[$this->schedule_manager, 'cleanup_all'], ['POST']], 385 423 ]; 424 425 // Add cron endpoints if handler is available 426 if ($this->cron_trigger_handler) { 427 $routes['/cron/trigger'] = [[$this->cron_trigger_handler, 'handle_trigger'], ['POST']]; 428 $routes['/cron/check-continuity'] = [[$this->cron_trigger_handler, 'handle_continuity_check'], ['GET']]; 429 } 386 430 387 431 // Check if route exists -
siteskite/trunk/includes/API/RestAPI.php
r3431615 r3453079 374 374 ] 375 375 ] 376 ] 377 ); 378 379 // Disable recovery mode (and clear restore-in-progress state). Use when user wants to go back to normal without restoring. 380 register_rest_route( 381 self::API_NAMESPACE, 382 '/recovery/disable', 383 [ 384 'methods' => WP_REST_Server::CREATABLE, 385 'callback' => [$this, 'handle_recovery_disable'], 386 'permission_callback' => [$this->auth_manager, 'verify_request'], 376 387 ] 377 388 ); … … 1197 1208 * Handle force cleanup of corrupted backups 1198 1209 */ 1210 /** 1211 * Disable recovery mode and clear restore-in-progress state so the site loads all plugins again. 1212 * Call when the user wants to go back to normal without completing a restore. 1213 */ 1214 public function handle_recovery_disable(\WP_REST_Request $request): \WP_REST_Response 1215 { 1216 $this->restore_manager->clear_recovery_and_restore_state(); 1217 return new \WP_REST_Response([ 1218 'success' => true, 1219 'message' => 'Recovery mode disabled. Next request will load all plugins.', 1220 ], 200); 1221 } 1222 1199 1223 public function handle_force_cleanup(\WP_REST_Request $request): \WP_REST_Response 1200 1224 { -
siteskite/trunk/includes/Backup/BackupManager.php
r3446096 r3453079 159 159 // Dependencies 160 160 'node_modules', 161 'vendor',162 161 163 162 // IDE/Editor files … … 174 173 'wp-content/cache', 175 174 'wp-content/uploads/cache', 176 'wp-content/cache/w3tc',177 'wp-content/cache/wp-rocket',178 'wp-content/cache/wp-super-cache',179 'wp-content/cache/hyper-cache',180 'wp-content/cache/comet-cache',181 'wp-content/cache/endurance-page-cache',182 'wp-content/cache/supercache',183 'wp-content/cache/breeze',184 'wp-content/cache/wp-optimize',185 'wp-content/cache/autoptimize',186 'wp-content/cache/fastest-cache',187 'wp-content/cache/hummingbird',188 'wp-content/cache/rocket',189 'wp-content/cache/wp-fastest-cache',190 'wp-content/cache/wp-super-cache',191 'wp-content/cache/wp-cache',192 'wp-content/cache/wp-minify',193 'wp-content/cache/wp-ffpc',194 'wp-content/cache/wp-file-cache',195 'wp-content/cache/wp-object-cache',196 'wp-content/cache/wp-total-cache',197 'wp-content/cache/wp-w3tc',198 'wp-content/cache/wp-w3-total-cache',199 'wp-content/cache/wp-wp-super-cache',200 'wp-content/cache/wp-wp-rocket',201 'wp-content/cache/wp-litespeed',202 'wp-content/cache/wp-breeze',203 'wp-content/cache/wp-fastest',204 'wp-content/cache/wp-hummingbird',205 'wp-content/cache/wp-optimize',206 'wp-content/cache/wp-autoptimize',207 'wp-content/cache/wp-fastest-cache',208 'wp-content/cache/wp-rocket',209 'wp-content/cache/wp-super-cache',210 'wp-content/cache/wp-w3tc',211 'wp-content/cache/wp-w3-total-cache',212 'wp-content/cache/wp-wp-super-cache',213 'wp-content/cache/wp-wp-rocket',214 'wp-content/cache/wp-litespeed',215 'wp-content/cache/wp-breeze',216 'wp-content/cache/wp-fastest',217 'wp-content/cache/wp-hummingbird',218 'wp-content/cache/wp-optimize',219 'wp-content/cache/wp-autoptimize',220 175 'wp-content/nginx_cache', 221 176 … … 242 197 // Temporary files 243 198 '*.tmp', 199 '*.blob', 200 '*.zip', 201 '*.rar', 202 '*.7z', 203 '*.tar', 204 '*.gz', 205 '*.bz2', 206 '*.xz', 207 '*.git', 208 '*.tar.gz', 209 '*.tar.bz2', 210 '*.tar.xz', 211 '*.wpress', 212 '*.sgbp', 244 213 '*.log', 214 'debug.log', 215 'error_log', 216 'backwpup', 217 "*.sql", 245 218 '*.cache', 246 219 '*.swp', … … 255 228 // Session files 256 229 'wp-content/uploads/sess_', 230 'wp-content/uploads/sess_*', 257 231 'wp-content/sessions', 258 232 … … 388 362 if ($hook === 'siteskite_incremental_continue') { 389 363 if ($this->external_cron_manager && $this->external_cron_manager->is_enabled()) { 364 // Normalize args to canonical [backup_id] so create/lookup/delete use the same key (prevents duplicate jobs) 365 $incremental_backup_id = $args[0] ?? $args['backup_id'] ?? null; 366 $canonical_args = $incremental_backup_id !== null ? [$incremental_backup_id] : $args; 367 390 368 // Check if job already exists to prevent duplicates 391 $existing_job_id = $this->external_cron_manager->get_job_id_by_action($hook, $ args);369 $existing_job_id = $this->external_cron_manager->get_job_id_by_action($hook, $canonical_args); 392 370 if ($existing_job_id) { 393 371 $this->logger->debug('Incremental continue recurring job already exists, skipping creation', [ … … 397 375 return; 398 376 } 399 377 400 378 // Use recurring job that runs every minute until work is complete 401 379 // The handler will automatically clean up the job when backup is complete 402 380 $job_id = $this->external_cron_manager->create_recurring_job( 403 381 $hook, 404 $ args,382 $canonical_args, 405 383 'every_minute', // Run every minute 406 384 [], … … 1352 1330 1353 1331 if ($this->external_cron_manager && $this->external_cron_manager->is_enabled()) { 1354 // Check both flag and external cron manager to prevent duplicates 1355 $existing_job_id = $this->external_cron_manager->get_job_id_by_action('process_database_backup_cron', ['backup_id' => $backup_id]); 1332 // Check both flag and external cron manager to prevent duplicates. 1333 // Use same args as stored at backup start (backup_id + callback_url) so we find the existing job. 1334 $lookup_args = ['backup_id' => $backup_id, 'callback_url' => $callback_url]; 1335 $existing_job_id = $this->external_cron_manager->get_job_id_by_action('process_database_backup_cron', $lookup_args); 1356 1336 1357 1337 if (!$cron_job_scheduled_db && !$existing_job_id) { 1358 1338 $job_id = $this->external_cron_manager->create_recurring_job( 1359 1339 'process_database_backup_cron', 1360 ['backup_id' => $backup_id, 'callback_url' => $callback_url],1340 $lookup_args, 1361 1341 'every_minute', 1362 1342 [], … … 1783 1763 1784 1764 if ($this->external_cron_manager && $this->external_cron_manager->is_enabled()) { 1785 // Check both flag and external cron manager to prevent duplicates 1786 $existing_job_id = $this->external_cron_manager->get_job_id_by_action('process_files_backup_cron', ['backup_id' => $backup_id]); 1765 // Check both flag and external cron manager to prevent duplicates. 1766 // Use same args as stored at backup start (backup_id + callback_url) so we find the existing job. 1767 $lookup_args = ['backup_id' => $backup_id, 'callback_url' => $callback_url]; 1768 $existing_job_id = $this->external_cron_manager->get_job_id_by_action('process_files_backup_cron', $lookup_args); 1787 1769 1788 1770 if (!$cron_job_scheduled_files && !$existing_job_id) { 1789 1771 $job_id = $this->external_cron_manager->create_recurring_job( 1790 1772 'process_files_backup_cron', 1791 ['backup_id' => $backup_id, 'callback_url' => $callback_url],1773 $lookup_args, 1792 1774 'every_minute', 1793 1775 [], … … 2667 2649 2668 2650 if (!$all_files_processed) { 2669 // Files backup not complete - recurring cron job is already active 2670 // It will continue processing in the next run 2651 // Files backup not complete - recurring external cron job will continue 2671 2652 $this->logger->info('Files backup not complete, recurring cron will continue', [ 2672 2653 'backup_id' => $backup_id, … … 3946 3927 3947 3928 // Determine ordering/PK information 3929 // Use SHOW KEYS without WHERE/LIMIT (MariaDB does not support LIMIT on SHOW) and filter in PHP 3948 3930 $order_column = $column_names[0]; 3949 $primary_key_rows = $wpdb->get_results("SHOW KEYS FROM `{$current_table_name}` WHERE Key_name = 'PRIMARY'", ARRAY_A); 3931 $all_keys = $wpdb->get_results("SHOW KEYS FROM `{$current_table_name}`", ARRAY_A); 3932 $primary_key_rows = $all_keys ? array_values(array_filter($all_keys, function ($r) { 3933 return ($r['Key_name'] ?? '') === 'PRIMARY'; 3934 })) : []; 3950 3935 $has_primary_key = !empty($primary_key_rows); 3951 3936 $is_composite_pk = $has_primary_key && count($primary_key_rows) > 1; … … 3953 3938 3954 3939 // Check for single-column unique key (for cursor pagination) 3955 $unique_key_rows = $wpdb->get_results( 3956 "SHOW KEYS FROM `{$current_table_name}` WHERE Non_unique = 0 AND Key_name != 'PRIMARY'", 3957 ARRAY_A 3958 ); 3940 $unique_key_rows = $all_keys ? array_values(array_filter($all_keys, function ($r) { 3941 return (int)($r['Non_unique'] ?? 1) === 0 && ($r['Key_name'] ?? '') !== 'PRIMARY'; 3942 })) : []; 3959 3943 $has_single_column_unique = false; 3960 3944 $unique_key_column = null; … … 3988 3972 } else { 3989 3973 // No single-column PK or unique key; prefer any unique key for ordering (even if composite) 3990 $unique_key = $wpdb->get_row( 3991 "SHOW KEYS FROM `{$current_table_name}` WHERE Non_unique = 0 AND Key_name != 'PRIMARY' LIMIT 1", 3992 ARRAY_A 3993 ); 3974 $unique_key = !empty($unique_key_rows) ? $unique_key_rows[0] : null; 3994 3975 if ($unique_key && !empty($unique_key['Column_name'])) { 3995 3976 $order_column = $unique_key['Column_name']; … … 5327 5308 $existingBlobs = glob($subdir . '/*.blob'); 5328 5309 foreach ($existingBlobs as $existingBlob) { 5329 // Quick size check first (faster than full content comparison)5330 if (filesize($existingBlob) !== strlen($chunk)) {5310 // Quick size check first (faster than full content comparison); skip if file gone (e.g. race with restore) 5311 if (!is_readable($existingBlob) || filesize($existingBlob) !== strlen($chunk)) { 5331 5312 continue; 5332 5313 } … … 6610 6591 // When no chunks to upload, check if file scan is complete and if backup should be completed 6611 6592 $scan_progress_key = SITESKITE_OPTION_PREFIX . 'files_scan_progress_' . $backup_id; 6593 $all_file_paths_key = SITESKITE_OPTION_PREFIX . 'all_file_paths_' . $backup_id; 6612 6594 $scan_progress = get_option($scan_progress_key); 6613 $scan_completed = $scan_progress && is_array($scan_progress) && ($scan_progress['scan_completed'] ?? false); 6595 6596 // If scan_progress option doesn't exist, check if all_file_paths was also deleted 6597 // Both are deleted when scan completes, so if both are missing, scan is complete 6598 if (!$scan_progress || !is_array($scan_progress)) { 6599 $all_file_paths = get_option($all_file_paths_key); 6600 // If both options are deleted/missing, scan was completed and cleaned up 6601 $scan_completed = ($all_file_paths === false); 6602 } else { 6603 $scan_completed = (bool)($scan_progress['scan_completed'] ?? false); 6604 } 6614 6605 6615 6606 if ($scan_completed) { … … 6643 6634 } else { 6644 6635 // Scan not complete - log that scan needs to finish first 6645 $files_processed = (int)($scan_progress['files_processed'] ?? 0); 6646 $processed_paths = (array)($scan_progress['processed_paths'] ?? []); 6647 $all_file_paths_key = SITESKITE_OPTION_PREFIX . 'all_file_paths_' . $backup_id; 6648 $all_file_paths = get_option($all_file_paths_key, []); 6649 $total_files = count($all_file_paths); 6650 $remaining_files = $total_files - count($processed_paths); 6636 if ($scan_progress && is_array($scan_progress)) { 6637 $files_processed = (int)($scan_progress['files_processed'] ?? 0); 6638 $processed_paths = (array)($scan_progress['processed_paths'] ?? []); 6639 $all_file_paths = get_option($all_file_paths_key, []); 6640 $total_files = count($all_file_paths); 6641 $remaining_files = $total_files - count($processed_paths); 6642 } else { 6643 // Scan progress option doesn't exist but scan is not complete 6644 // This shouldn't happen, but handle gracefully 6645 $files_processed = 0; 6646 $total_files = 0; 6647 $remaining_files = 0; 6648 } 6651 6649 6652 6650 $this->logger->info('No chunks to upload but scan not complete, scan will continue', [ … … 10270 10268 } 10271 10269 10272 // Preserve backup info for completed backups so progress endpoint can still return status 10273 // Only delete if backup is not completed 10274 $backup_info = get_option(self::OPTION_PREFIX . $backup_id); 10275 $is_completed = $backup_info && isset($backup_info['status']) && $backup_info['status'] === 'completed'; 10276 10277 if (!$is_completed) { 10278 if (\delete_option(self::OPTION_PREFIX . $backup_id)) { 10279 $deleted_options[] = 'backup_info'; 10280 } 10281 } else { 10282 // Keep backup info for completed backups, but mark it as cleaned up 10283 $this->logger->debug('Preserving backup info for completed backup', [ 10284 'backup_id' => $backup_id, 10285 'status' => $backup_info['status'] ?? 'unknown' 10286 ]); 10270 // Clean up SQL dump path option (if it still exists) 10271 $sqlDumpKey = SITESKITE_OPTION_PREFIX . 'db_dump_path_' . $backup_id; 10272 if (\delete_option($sqlDumpKey)) { 10273 $deleted_options[] = 'db_dump_path'; 10274 } 10275 10276 // Clean up upload tracking flags (if they exist for incremental backups) 10277 if (\delete_option(SITESKITE_OPTION_PREFIX . 'full_uploaded_' . $backup_id)) { 10278 $deleted_options[] = 'full_uploaded'; 10279 } 10280 if (\delete_option(SITESKITE_OPTION_PREFIX . 'files_uploaded_' . $backup_id)) { 10281 $deleted_options[] = 'files_uploaded'; 10282 } 10283 if (\delete_option(SITESKITE_OPTION_PREFIX . 'database_uploaded_' . $backup_id)) { 10284 $deleted_options[] = 'database_uploaded'; 10285 } 10286 10287 // Clean up pending upload options (pattern: siteskite_pending_upload_{backup_id}_{provider}) 10288 global $wpdb; 10289 $pending_upload_pattern = SITESKITE_OPTION_PREFIX . 'pending_upload_' . $backup_id . '_%'; 10290 $pending_upload_options = $wpdb->get_col($wpdb->prepare( 10291 "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", 10292 $pending_upload_pattern 10293 )); 10294 foreach ($pending_upload_options as $option_name) { 10295 if (\delete_option($option_name)) { 10296 $deleted_options[] = basename($option_name); 10297 } 10298 } 10299 10300 // Remove backup status option (same as classic backup cleanup) 10301 // Progress endpoint can still access completed backup info via cloud manifests fallback 10302 if (\delete_option(self::OPTION_PREFIX . $backup_id)) { 10303 $deleted_options[] = 'backup_info'; 10287 10304 } 10288 10305 … … 10305 10322 } 10306 10323 10324 // Clean up hybrid cron job options (pattern: siteskite_hybrid_cron_*) 10325 // Get all options and filter for hybrid cron jobs for this backup 10326 $hybrid_cron_pattern = SITESKITE_OPTION_PREFIX . 'hybrid_cron_%'; 10327 $hybrid_cron_options = $wpdb->get_col($wpdb->prepare( 10328 "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", 10329 $hybrid_cron_pattern 10330 )); 10331 10332 foreach ($hybrid_cron_options as $option_name) { 10333 // Check if this hybrid cron option is related to this backup 10334 // Hybrid cron options may contain backup_id or be associated with it 10335 $option_value = \get_option($option_name); 10336 if (is_array($option_value) && isset($option_value['backup_id']) && $option_value['backup_id'] === $backup_id) { 10337 if (\delete_option($option_name)) { 10338 $deleted_options[] = basename($option_name); 10339 } 10340 } elseif (strpos($option_name, $backup_id) !== false) { 10341 // If backup_id appears in option name, delete it 10342 if (\delete_option($option_name)) { 10343 $deleted_options[] = basename($option_name); 10344 } 10345 } 10346 } 10347 10307 10348 // Runtime and throttling transients 10308 10349 if (function_exists('delete_transient')) { … … 11101 11142 if ($requires_files) { 11102 11143 $scan_progress_key = SITESKITE_OPTION_PREFIX . 'files_scan_progress_' . $backup_id; 11144 $all_file_paths_key = SITESKITE_OPTION_PREFIX . 'all_file_paths_' . $backup_id; 11103 11145 $scan_progress = get_option($scan_progress_key); 11104 if ($scan_progress && is_array($scan_progress)) { 11146 11147 // If scan_progress option doesn't exist, check if all_file_paths was also deleted 11148 // Both are deleted when scan completes, so if both are missing, scan is complete 11149 if (!$scan_progress || !is_array($scan_progress)) { 11150 $all_file_paths = get_option($all_file_paths_key); 11151 // If both options are deleted/missing, scan was completed and cleaned up 11152 $scan_completed = ($all_file_paths === false); 11153 } else { 11105 11154 $scan_completed = (bool)($scan_progress['scan_completed'] ?? false); 11106 if (!$scan_completed) { 11155 } 11156 11157 if (!$scan_completed) { 11158 if ($scan_progress && is_array($scan_progress)) { 11107 11159 $files_processed = (int)($scan_progress['files_processed'] ?? 0); 11108 11160 $processed_paths = (array)($scan_progress['processed_paths'] ?? []); 11109 $all_file_paths_key = SITESKITE_OPTION_PREFIX . 'all_file_paths_' . $backup_id;11110 11161 $all_file_paths = get_option($all_file_paths_key, []); 11111 11162 $total_files = count($all_file_paths); 11112 11113 $this->logger->info('Cannot check completion: file scan not complete', [ 11114 'backup_id' => $backup_id, 11115 'provider' => $provider, 11116 'backup_type' => $backup_type, 11117 'scan_completed' => $scan_completed, 11118 'files_processed' => $files_processed, 11119 'total_files' => $total_files, 11120 'remaining_files' => $total_files - count($processed_paths), 11121 'note' => 'File scan must be complete before backup can be marked as complete' 11122 ]); 11123 return false; 11124 } 11163 $remaining_files = $total_files - count($processed_paths); 11164 } else { 11165 // Scan progress option doesn't exist but scan is not complete 11166 // This shouldn't happen, but handle gracefully 11167 $files_processed = 0; 11168 $total_files = 0; 11169 $remaining_files = 0; 11170 } 11171 11172 $this->logger->info('Cannot check completion: file scan not complete', [ 11173 'backup_id' => $backup_id, 11174 'provider' => $provider, 11175 'backup_type' => $backup_type, 11176 'scan_completed' => $scan_completed, 11177 'files_processed' => $files_processed, 11178 'total_files' => $total_files, 11179 'remaining_files' => $remaining_files, 11180 'note' => 'File scan must be complete before backup can be marked as complete' 11181 ]); 11182 return false; 11125 11183 } 11126 11184 } … … 11520 11578 continue; 11521 11579 } 11580 11581 if (!is_readable($blobFile)) { 11582 continue; 11583 } 11522 11584 11523 11585 $chunksToUpload[$chunkHash] = [ -
siteskite/trunk/includes/Cleanup/CleanupManager.php
r3443472 r3453079 70 70 $log_count = $this->cleanup_log_files(); 71 71 72 // Clean up old manifest files 72 // Clean up classic backup manifest files (e.g. siteskite_backup_XXX_files.manifest) 73 $classic_manifest_count = $this->cleanup_classic_manifest_files(); 74 75 // Clean up old manifest files (incremental manifests in uploads/siteskite-backups/manifests) 73 76 $manifest_count = $this->cleanup_old_manifests(); 74 77 … … 76 79 'backup_files' => $backup_count, 77 80 'log_files' => $log_count, 81 'classic_manifest_files' => $classic_manifest_count, 78 82 'manifest_files' => $manifest_count 79 83 ]); … … 118 122 119 123 /** 124 * Clean up classic backup manifest files (e.g. siteskite_backup_XXX_files.manifest) 125 * from the backup directory when the siteskite cleanup cron runs. 126 */ 127 private function cleanup_classic_manifest_files(): int { 128 $cleaned_count = 0; 129 $files = glob($this->backup_dir . '/*.manifest'); 130 if ($files) { 131 foreach ($files as $file) { 132 if (file_exists($file) && wp_delete_file($file)) { 133 $cleaned_count++; 134 $this->logger->debug('Deleted classic manifest file', [ 135 'file' => basename($file) 136 ]); 137 } else { 138 $this->logger->warning('Failed to delete classic manifest file', [ 139 'file' => basename($file) 140 ]); 141 } 142 } 143 } 144 return $cleaned_count; 145 } 146 147 /** 120 148 * Clean up log files 121 149 */ … … 197 225 * Clean up backup files and logs 198 226 */ 227 199 228 public function cleanup_backup(string $backup_id): void 200 229 { … … 217 246 'file' => basename($file) 218 247 ]); 248 } 249 } 250 251 // Clean up classic backup manifest files (e.g. siteskite_backup_XXX_files.manifest) 252 $manifest_pattern = $this->backup_dir . '/' . $backup_id . '_*.manifest'; 253 $manifest_files = glob($manifest_pattern); 254 if ($manifest_files) { 255 foreach ($manifest_files as $manifest_file) { 256 if (file_exists($manifest_file) && wp_delete_file($manifest_file)) { 257 $this->logger->debug('Removed manifest file', [ 258 'file' => basename($manifest_file) 259 ]); 260 } 219 261 } 220 262 } … … 346 388 } 347 389 } 348 390 349 391 /** 350 392 * Clean up restore files and logs -
siteskite/trunk/includes/Core/Activator.php
r3389896 r3453079 33 33 // Flush rewrite rules 34 34 flush_rewrite_rules(); 35 36 // Ensure safety mu-plugin and recovery script exist (skips broken plugins, enables recovery when site is broken) 37 require_once __DIR__ . '/SiteSkiteSafetyCore.php'; 38 SiteSkiteSafetyCore::ensure_exists(); 39 SiteSkiteSafetyCore::ensure_recovery_script_exists(); 40 SiteSkiteSafetyCore::ensure_recovery_disable_script_exists(); 41 $api_key = get_option('siteskite_api_key', ''); 42 if ($api_key !== '') { 43 $site_url = function_exists('home_url') ? home_url() : null; 44 SiteSkiteSafetyCore::write_recovery_key_file($api_key, $site_url); 45 } 35 46 } 36 47 } -
siteskite/trunk/includes/Core/AutoLoginController.php
r3389896 r3453079 5 5 6 6 use function add_action; 7 use function add_filter; 7 8 use function admin_url; 8 9 use function do_action; 10 use function esc_html__; 9 11 use function esc_url_raw; 10 12 use function get_option; 11 13 use function get_users; 14 use function home_url; 12 15 use function is_user_logged_in; 13 16 use function is_wp_error; 14 17 use function sanitize_text_field; 15 18 use function wp_die; 16 use function wp_redirect;17 19 use function wp_remote_get; 18 20 use function wp_remote_retrieve_body; 21 use function wp_safe_redirect; 19 22 use function wp_set_auth_cookie; 20 23 use function wp_set_current_user; 24 use function wp_unslash; 21 25 22 26 class AutoLoginController 23 27 { 28 private const AUTOLOGIN_ENDPOINT = 'siteskite-autologin'; 29 24 30 public function register(): void 25 31 { 32 // Register custom autologin endpoint - fires early to bypass security plugins 33 add_action('init', [$this, 'add_rewrite_rule'], 1); 34 add_action('template_redirect', [$this, 'handle_autologin_endpoint'], 1); 35 36 // Legacy support: handle token on any page (lower priority) 26 37 add_action('wp_loaded', [$this, 'maybe_auto_login']); 27 add_action('login_init', [$this, 'handle_token_login']); 38 add_action('wp_loaded', [$this, 'handle_token_login'], 5); 39 } 40 41 /** 42 * Add rewrite rule for custom autologin endpoint 43 */ 44 public function add_rewrite_rule(): void 45 { 46 add_rewrite_rule( 47 '^' . self::AUTOLOGIN_ENDPOINT . '/?$', 48 'index.php?siteskite_autologin=1', 49 'top' 50 ); 51 52 // Add query var for rewrite rule 53 add_filter('query_vars', function($vars) { 54 $vars[] = 'siteskite_autologin'; 55 return $vars; 56 }); 57 } 58 59 /** 60 * Handle the custom autologin endpoint 61 * Fires early via template_redirect to bypass security plugins 62 */ 63 public function handle_autologin_endpoint(): void 64 { 65 // Check if this is our autologin endpoint 66 $request_uri = isset($_SERVER['REQUEST_URI']) ? sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'])) : ''; 67 $is_autologin_endpoint = false; 68 69 // Check rewrite rule query var 70 if (get_query_var('siteskite_autologin') === '1') { 71 $is_autologin_endpoint = true; 72 } 73 // Fallback: check request URI directly (works even if rewrite rules aren't flushed) 74 elseif (strpos($request_uri, '/' . self::AUTOLOGIN_ENDPOINT) !== false) { 75 $is_autologin_endpoint = true; 76 } 77 78 if (!$is_autologin_endpoint) { 79 return; 80 } 81 82 // Handle the autologin 83 $this->process_autologin(); 84 } 85 86 /** 87 * Get the appropriate admin redirect URL, compatible with Security Hide Backend 88 * 89 * 90 * @return string Admin dashboard URL or home URL as fallback 91 */ 92 private function get_admin_redirect_url(): string 93 { 94 // Check if Security ( Security) Hide Backend is enabled 95 $hide_backend_enabled = false; 96 $custom_login_slug = ''; 97 98 // Check for Security / Security settings 99 // Security stores settings in '' option 100 $itsec_settings = get_option('itsec_settings', []); 101 if (is_array($itsec_settings) && !empty($itsec_settings)) { 102 // Check for hide-backend module 103 if (isset($itsec_settings['hide-backend']['enabled']) && $itsec_settings['hide-backend']['enabled'] === true) { 104 $hide_backend_enabled = true; 105 // Get custom login slug if set 106 if (isset($itsec_settings['hide-backend']['slug'])) { 107 $custom_login_slug = sanitize_text_field($itsec_settings['hide-backend']['slug']); 108 } 109 } 110 } 111 112 // After successful authentication, redirect to admin dashboard 113 // Security only blocks unauthenticated access, not authenticated users 114 // So admin_url() will work fine for logged-in users 115 $admin_url = admin_url(); 116 117 // If custom login slug exists and we want to be extra safe, we could construct: 118 // home_url('/' . $custom_login_slug) but this is the login URL, not admin 119 // Since user is already authenticated, admin_url() is the correct destination 120 121 return $admin_url; 28 122 } 29 123 … … 62 156 wp_set_current_user($user->ID, $user->user_login); 63 157 do_action('wp_login', $user->user_login, $user); 64 wp_redirect(admin_url()); 158 159 // Get redirect URL - compatible with Security Hide Backend 160 // After authentication, admin_url() works fine since authenticated users aren't blocked 161 $redirect_url = $this->get_admin_redirect_url(); 162 163 // Allow redirect parameter if provided (takes priority) 164 if (isset($_GET['redirect']) && !empty($_GET['redirect'])) { 165 $custom_redirect = esc_url_raw(wp_unslash($_GET['redirect'])); 166 // Validate that it's an absolute URL 167 if (filter_var($custom_redirect, FILTER_VALIDATE_URL)) { 168 $redirect_url = $custom_redirect; 169 } 170 } 171 172 wp_safe_redirect($redirect_url); 65 173 exit; 66 174 } … … 68 176 } 69 177 70 public function handle_token_login(): void 71 { 178 /** 179 * Process autologin with token validation 180 * Centralized method used by both endpoint and legacy token handler 181 */ 182 private function process_autologin(): void 183 { 184 // Check if user is already logged in 185 if (is_user_logged_in()) { 186 // Already logged in, redirect to admin 187 $redirect_url = $this->get_admin_redirect_url(); 188 if (isset($_GET['redirect']) && !empty($_GET['redirect'])) { 189 $custom_redirect = esc_url_raw(wp_unslash($_GET['redirect'])); 190 if (filter_var($custom_redirect, FILTER_VALIDATE_URL)) { 191 $redirect_url = $custom_redirect; 192 } 193 } 194 wp_safe_redirect($redirect_url); 195 exit; 196 } 197 198 // Check for token parameter 72 199 // phpcs:ignore WordPress.Security.NonceVerification.Recommended 73 if (!isset($_GET['token'])) { 74 return; 200 if (!isset($_GET['token']) || empty($_GET['token'])) { 201 wp_die( 202 esc_html__('Token parameter is required. Usage: /siteskite-autologin?token=YOUR_TOKEN', 'siteskite'), 203 esc_html__('Missing Token', 'siteskite'), 204 ['response' => 400] 205 ); 75 206 } 76 207 … … 81 212 $stored_token = get_option('siteskite_api_key', ''); 82 213 214 if (empty($stored_token)) { 215 wp_die( 216 esc_html__('Auto-login is not configured. Please set your API key in SiteSkite settings.', 'siteskite'), 217 esc_html__('Configuration Error', 'siteskite'), 218 ['response' => 500] 219 ); 220 } 221 83 222 if ($token !== $stored_token) { 84 wp_die(esc_html__('Invalid token. Please try again.', 'siteskite')); 223 wp_die( 224 esc_html__('Invalid token. Please check your token and try again.', 'siteskite'), 225 esc_html__('Invalid Token', 'siteskite'), 226 ['response' => 403] 227 ); 85 228 } 86 229 … … 93 236 94 237 if (empty($admin_users)) { 95 wp_die(esc_html__('No administrator user found to log in.', 'siteskite')); 238 wp_die( 239 esc_html__('No administrator user found to log in.', 'siteskite'), 240 esc_html__('User Error', 'siteskite'), 241 ['response' => 500] 242 ); 96 243 } 97 244 98 245 $user = $admin_users[0]; 99 246 wp_set_current_user($user->ID); 100 wp_set_auth_cookie($user->ID); 101 wp_redirect(admin_url()); 247 wp_set_auth_cookie($user->ID, true); 248 do_action('wp_login', $user->user_login, $user); 249 250 // Get redirect URL - compatible with Security Hide Backend 251 // After authentication, admin_url() works fine since authenticated users aren't blocked 252 $redirect_url = $this->get_admin_redirect_url(); 253 254 // Allow redirect parameter if provided (takes priority) 255 if (isset($_GET['redirect']) && !empty($_GET['redirect'])) { 256 $custom_redirect = esc_url_raw(wp_unslash($_GET['redirect'])); 257 // Validate that it's an absolute URL 258 if (filter_var($custom_redirect, FILTER_VALIDATE_URL)) { 259 $redirect_url = $custom_redirect; 260 } 261 } 262 263 wp_safe_redirect($redirect_url); 102 264 exit; 103 265 } 266 267 /** 268 * Legacy token login handler - works on any page with ?token parameter 269 */ 270 public function handle_token_login(): void 271 { 272 // Skip if already on autologin endpoint (handled by handle_autologin_endpoint) 273 $request_uri = isset($_SERVER['REQUEST_URI']) ? sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'])) : ''; 274 if (strpos($request_uri, '/' . self::AUTOLOGIN_ENDPOINT) !== false) { 275 return; 276 } 277 278 // phpcs:ignore WordPress.Security.NonceVerification.Recommended 279 if (!isset($_GET['token'])) { 280 return; 281 } 282 283 // Use centralized autologin processor 284 $this->process_autologin(); 285 } 104 286 } 105 287 -
siteskite/trunk/includes/Core/Plugin.php
r3431615 r3453079 177 177 private function init_components(): void { 178 178 try { 179 // Ensure safety mu-plugin and recovery script exist (skips broken plugins, enables recovery when site is broken) 180 SiteSkiteSafetyCore::ensure_exists(); 181 SiteSkiteSafetyCore::ensure_recovery_script_exists(); 182 SiteSkiteSafetyCore::ensure_recovery_disable_script_exists(); 183 // Upgrade recovery key file to include site_url if API key is set (so recovery auth works without re-saving) 184 $api_key = get_option('siteskite_api_key', ''); 185 if ($api_key !== '' && function_exists('home_url')) { 186 SiteSkiteSafetyCore::write_recovery_key_file($api_key, home_url()); 187 } 188 179 189 $this->logger = new Logger(); 180 190 $this->progress_tracker = new ProgressTracker($this->logger); … … 301 311 add_action('admin_init', [$this, 'register_settings']); 302 312 add_action('admin_enqueue_scripts', [$this, 'enqueue_admin_assets']); 313 add_action('admin_head', [$this, 'maybe_print_recovery_mode_admin_bar_styles'], 1); 314 add_action('wp_head', [$this, 'maybe_print_recovery_mode_admin_bar_styles'], 1); 315 316 // Keep recovery key file in sync with API key so recovery endpoint can validate without loading WordPress 317 add_action('update_option_siteskite_api_key', [$this, 'sync_recovery_key_file'], 10, 3); 303 318 304 319 // Add global HTTP header augmentation for all outgoing plugin requests … … 403 418 ]); 404 419 420 } 421 422 /** 423 * Sync recovery key file when API key is updated so recovery endpoint can validate without loading WordPress. 424 * 425 * @param mixed $old_value Old option value 426 * @param mixed $value New option value 427 * @param string $option Option name 428 */ 429 public function sync_recovery_key_file($old_value, $value, $option): void { 430 if ($option === 'siteskite_api_key' && is_string($value)) { 431 $site_url = function_exists('home_url') ? home_url() : null; 432 SiteSkiteSafetyCore::write_recovery_key_file($value, $site_url); 433 } 405 434 } 406 435 … … 448 477 'disconnectFailed' => esc_html__('Failed to disconnect. Please try again.', 'siteskite') 449 478 ]); 479 } 480 481 /** 482 * When recovery mode is active, print CSS to style the WordPress admin bar and sidebar green so users see they are in recovery mode. 483 */ 484 public function maybe_print_recovery_mode_admin_bar_styles(): void { 485 if (! SiteSkiteSafetyCore::is_recovery_mode()) { 486 return; 487 } 488 $green = '#1d7a43'; 489 $green_dark = '#166534'; 490 ?> 491 <style id="siteskite-recovery-mode-styles"> 492 /* Admin bar (top toolbar) */ 493 <?php if (is_admin_bar_showing()) : ?> 494 #wpadminbar, 495 #wpadminbar .quicklinks .menupop ul { 496 background: <?php echo esc_attr($green); ?> !important; 497 } 498 #wpadminbar .ab-item, 499 #wpadminbar a.ab-item, 500 #wpadminbar > #wp-toolbar span.ab-label, 501 #wpadminbar > #wp-toolbar span.noticon { 502 color: #fff !important; 503 } 504 #wpadminbar .ab-item:hover, 505 #wpadminbar a.ab-item:hover, 506 #wpadminbar li:hover > .ab-item, 507 #wpadminbar li.hover > .ab-item { 508 background: <?php echo esc_attr($green_dark); ?> !important; 509 color: #fff !important; 510 } 511 #wpadminbar .menupop .ab-sub-wrapper { 512 background: <?php echo esc_attr($green); ?> !important; 513 } 514 #wpadminbar .ab-submenu .ab-item { 515 color: rgba(255,255,255,0.9) !important; 516 } 517 #wpadminbar:before { 518 content: ''; 519 display: block; 520 position: absolute; 521 left: 0; 522 right: 0; 523 top: 0; 524 height: 3px; 525 background: #fff; 526 opacity: 0.4; 527 } 528 <?php endif; ?> 529 /* Admin sidebar (dashboard left menu) */ 530 <?php if (is_admin()) : ?> 531 #adminmenuwrap, 532 #adminmenu, 533 #adminmenu .wp-submenu { 534 background: <?php echo esc_attr($green); ?> !important; 535 } 536 #adminmenu .wp-menu-name, 537 #adminmenu a.wp-has-current-submenu .wp-submenu .wp-submenu-head, 538 #adminmenu .wp-submenu a { 539 color: rgba(255,255,255,0.95) !important; 540 } 541 #adminmenu li.wp-has-submenu.wp-not-current-submenu:hover, 542 #adminmenu li.wp-has-submenu.wp-not-current-submenu.opensub:hover { 543 background: <?php echo esc_attr($green_dark); ?> !important; 544 } 545 #adminmenu li.current, 546 #adminmenu li.wp-has-current-submenu { 547 background: <?php echo esc_attr($green_dark); ?> !important; 548 } 549 #adminmenu .wp-menu-image:before { 550 color: rgba(255,255,255,0.9) !important; 551 } 552 #adminmenu a:hover .wp-menu-image:before, 553 #adminmenu li.current .wp-menu-image:before { 554 color: #fff !important; 555 } 556 #adminmenu .wp-submenu li:hover a { 557 color: #fff !important; 558 } 559 <?php endif; ?> 560 </style> 561 <?php 450 562 } 451 563 … … 502 614 $this->restore_manager, 503 615 $this->schedule_manager, 504 $this->rest_api 616 $this->rest_api, 617 $this->cron_trigger_handler 505 618 ); 506 619 … … 524 637 $this->restore_manager, 525 638 $this->schedule_manager, 526 $this->rest_api 639 $this->rest_api, 640 $this->cron_trigger_handler 527 641 ); 528 642 -
siteskite/trunk/includes/Cron/CronTriggerHandler.php
r3431615 r3453079 54 54 try { 55 55 // Enhanced security: Verify secret token from header or query parameter 56 // Check header with different case variations (case-insensitive matching) 56 57 $header_secret = $request->get_header('X-SiteSkite-Cron-Secret'); 58 if (empty($header_secret)) { 59 // Try lowercase version in case server normalizes headers 60 $header_secret = $request->get_header('X-Siteskite-Cron-Secret'); 61 } 62 if (empty($header_secret)) { 63 // Try all lowercase 64 $header_secret = $request->get_header('x-siteskite-cron-secret'); 65 } 66 57 67 $query_secret = $request->get_param('siteskite_key'); 58 68 … … 151 161 $args = $body['args'] ?? []; 152 162 163 // Normalize incremental continue args to canonical [backup_id] so lookup/delete match stored job key (prevents duplicate jobs) 164 if ($action === 'siteskite_incremental_continue' && is_array($args)) { 165 $backup_id = $args[0] ?? $args['backup_id'] ?? null; 166 if ($backup_id !== null) { 167 $args = [$backup_id]; 168 } 169 } 170 153 171 if (empty($action)) { 154 172 $this->logger->error('Missing action in cron trigger', [ … … 173 191 // External cron handles scheduling directly 174 192 175 // Check if job should be deleted after successful run 176 if ($this->cron_manager->should_delete_after_first_run($action, $args)) { 193 // Never delete siteskite_incremental_continue after first run - it must keep running until backup completion cleanup 194 $is_incremental_continue = ($action === 'siteskite_incremental_continue'); 195 $should_delete = !$is_incremental_continue && $this->cron_manager->should_delete_after_first_run($action, $args); 196 197 if ($should_delete) { 177 198 $job_id = $this->cron_manager->get_job_id_by_action($action, $args); 178 199 if ($job_id) { … … 248 269 return $this->handle_incremental_restore($args); 249 270 271 250 272 case 'siteskite_cleanup_restore_status': 251 273 return $this->handle_restore_cleanup($args); 252 274 253 275 case 'siteskite_cleanup_incremental_restore_records': 254 276 return $this->handle_incremental_restore_cleanup($args); … … 472 494 * Handle restore cleanup 473 495 */ 496 474 497 private function handle_restore_cleanup(array $args): array 475 498 { … … 484 507 ]; 485 508 } 486 509 487 510 /** 488 511 * Handle incremental restore records cleanup … … 513 536 try { 514 537 // Enhanced security: Verify secret token from header or query parameter 538 // Check header with different case variations (case-insensitive matching) 515 539 $secret = $request->get_header('X-SiteSkite-Cron-Secret'); 540 if (empty($secret)) { 541 // Try lowercase version in case server normalizes headers 542 $secret = $request->get_header('X-Siteskite-Cron-Secret'); 543 } 544 if (empty($secret)) { 545 // Try all lowercase 546 $secret = $request->get_header('x-siteskite-cron-secret'); 547 } 516 548 if (empty($secret)) { 517 549 $secret = $request->get_param('siteskite_key'); -
siteskite/trunk/includes/Cron/ExternalCronManager.php
r3445801 r3453079 463 463 464 464 // For immediate execution (delay = 0), create recurring job that runs every 1 minute 465 // This job will be deleted after first successful execution465 // process_files_backup_cron must keep running every minute until backup completes - do NOT delete after first run 466 466 if ($delay_seconds === 0) { 467 467 $job_id = $this->create_recurring_job($action, $args, 'every_minute', [], $title); 468 468 469 // Mark job for deletion after first successful run 470 if ($job_id) { 469 // Mark job for deletion after first run only for one-off actions (not process_files_backup_cron) 470 $is_files_backup_continuation = ($action === 'process_files_backup_cron'); 471 if ($job_id && !$is_files_backup_continuation) { 471 472 $key = $this->generate_job_key($action, $args); 472 473 $jobs = get_option(self::OPTION_JOBS, []); … … 679 680 680 681 /** 682 * Check if REST API is actually functional (not just that rest_url() works) 683 * 684 * @return bool True if REST API is functional, false otherwise 685 */ 686 private function is_rest_api_functional(): bool 687 { 688 // Check if REST API is explicitly disabled via filter 689 if (has_filter('rest_enabled', '__return_false')) { 690 return false; 691 } 692 693 // Check if rest_api_init has fired (routes are registered) 694 // This is the most reliable indicator that REST API is actually working 695 // If rest_api_init hasn't fired, routes aren't registered yet, so REST API won't work 696 // even though rest_url() might still generate a URL 697 if (!did_action('rest_api_init')) { 698 return false; 699 } 700 701 // Check if REST API server class exists 702 if (!class_exists('WP_REST_Server')) { 703 return false; 704 } 705 706 // Check if REST API routes can be registered (check if rest_api_init hook has callbacks) 707 global $wp_filter; 708 if (isset($wp_filter['rest_api_init']) && empty($wp_filter['rest_api_init']->callbacks)) { 709 return false; 710 } 711 712 // If we get here, REST API should be functional 713 return true; 714 } 715 716 717 718 /** 681 719 * Get trigger URL for action 682 720 * Includes secret token as query parameter for services that don't support custom headers … … 685 723 private function get_trigger_url(string $action): string 686 724 { 687 $url = rest_url('siteskite/v1/cron/trigger'); 688 // Always get fresh token using the same method as validation 689 $current_token = $this->get_secret_token(); 690 691 // Add secret token as query parameter as fallback (some services don't support custom headers) 692 $url = add_query_arg('siteskite_key', $current_token, $url); 693 694 // Add cache-busting parameters to prevent caching on heavily cached hosting 695 // siteskite_cron=1: Identifier for cache exclusion rules 696 // nocache=<timestamp>: Unique timestamp to ensure each request is unique 697 $url = add_query_arg([ 698 'siteskite_cron' => '1', 699 'nocache' => time() 700 ], $url); 725 // Check if REST API is actually functional, otherwise use fallback admin-ajax endpoint 726 if ($this->is_rest_api_functional()) { 727 // Use REST API endpoint (JSON format) 728 $url = rest_url(\SiteSkite\API\RestAPI::API_NAMESPACE . '/cron/trigger'); 729 $current_token = $this->get_secret_token(); 730 // Add secret token as query parameter as fallback (some services don't support custom headers) 731 $url = add_query_arg('siteskite_key', $current_token, $url); 732 // Add cache-busting parameters to prevent caching on heavily cached hosting 733 $url = add_query_arg([ 734 'siteskite_cron' => '1', 735 'nocache' => time() 736 ], $url); 737 } else { 738 // Use fallback admin-ajax endpoint 739 $url = admin_url('admin-ajax.php'); 740 $current_token = $this->get_secret_token(); 741 // Add required parameters for fallback API 742 $url = add_query_arg([ 743 'action' => 'siteskite_api', 744 'route' => '/cron/trigger', 745 'siteskite_key' => $current_token, 746 'siteskite_cron' => '1', 747 'nocache' => time() 748 ], $url); 749 } 750 701 751 702 752 return $url; -
siteskite/trunk/includes/Restore/RestoreManager.php
r3445801 r3453079 58 58 private const CHUNK_SIZE = 2 * 1024 * 1024; // 2MB chunks 59 59 private const RESTORE_TIMEOUT = 300; // 5 minutes 60 /** Chunk size for streaming SQL import (1MB) - avoids loading 100MB+ dumps into memory */ 61 private const DB_IMPORT_READ_CHUNK_BYTES = 1024 * 1024; 62 /** Retry transient DB errors (lock wait, timeout, connection) up to this many times per query */ 63 private const DB_IMPORT_QUERY_RETRY_ATTEMPTS = 3; 64 /** Milliseconds to sleep between retries */ 65 private const DB_IMPORT_QUERY_RETRY_SLEEP_MS = 500; 66 /** Extend time limit every N queries to prevent script timeout on large restores */ 67 private const DB_IMPORT_EXTEND_TIME_LIMIT_EVERY = 500; 68 /** Flush wpdb and update progress every N queries */ 69 private const DB_IMPORT_PROGRESS_INTERVAL = 250; 70 /** Max buffer size (bytes) - if exceeded, process up to last statement boundary to avoid OOM on huge single INSERT */ 71 private const DB_IMPORT_MAX_BUFFER_BYTES = 20 * 1024 * 1024; 60 72 private const OPTION_PREFIX = SITESKITE_OPTION_PREFIX . 'restore_'; 61 73 private const EXCLUDED_PATHS = [ … … 67 79 'wp-content/uploads/siteskite-logs', 68 80 'wp-content/debug.log', 69 'wp-content/plugins/siteskite -link',81 'wp-content/plugins/siteskite', 70 82 'wp-content/upgrade', 71 83 'wp-content/uploads/backup*', … … 581 593 update_option($restore_params_key, $restore_params); 582 594 595 // Clear webhook_sent for this backup/scope so when cron runs it doesn't see a previous run as "already completed" 596 // and skip the restore (fixes files-only / database-only incremental restore being interrupted by old webhook). 597 $this->prepare_incremental_restore_run($manifestId, $scope); 598 583 599 // Initialize restore status 584 600 $this->update_restore_status($manifestId, [ … … 808 824 ], true); 809 825 826 // Clear webhook_sent for this backup/scope so the restore runs and isn't seen as "already completed". 827 // Without this, a previous run's webhook record (e.g. siteskite_restore_webhook_sent_*_files) can 828 // interrupt files-only (and database-only) incremental restores. 829 $this->prepare_incremental_restore_run($manifestId, $scope); 810 830 811 831 switch ($scope) { … … 992 1012 // Cleanup progress tracking data 993 1013 $this->clear_incremental_restore_progress($manifestId); 994 1014 1015 $this->clear_restore_in_progress_and_guard(); 995 1016 996 1017 } catch (\Throwable $e) { 1018 $this->clear_restore_in_progress_and_guard(); 997 1019 $this->logger->error('Incremental restore cron failed', [ 998 1020 'manifest_id' => $manifestId, … … 1333 1355 ]); 1334 1356 1335 // Package into a ZIP to reuse existing restore_database logic 1357 // Package into a ZIP to reuse existing restore_database logic (ZipArchive or PclZip fallback) 1336 1358 $zip_path = $this->restore_dir . '/incdb_' . $manifestId . '.zip'; 1337 $zip = new \ZipArchive(); 1338 if ($zip->open($zip_path, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) { 1359 try { 1360 $this->create_zip_with_single_file($zip_path, $sql_path, basename($sql_path)); 1361 } catch (\RuntimeException $e) { 1339 1362 if (file_exists($sql_path)) { 1340 1363 wp_delete_file($sql_path); 1341 1364 } 1342 throw new \RuntimeException('Failed to create ZIP for database incremental restore'); 1343 } 1344 $zip->addFile($sql_path, basename($sql_path)); 1345 $zip->close(); 1365 throw new \RuntimeException('Failed to create ZIP for database incremental restore: ' . $e->getMessage()); 1366 } 1346 1367 1347 1368 // Switch to restoring stage for database … … 1477 1498 } 1478 1499 1479 // Fail if any chunks are not in bundles 1500 // Chunks not in bundle metadata: try fallback to single-blob download (objects_prefix/xx/hash.blob) 1501 // Some manifests (e.g. parent with 0 bundles) or older backups may store chunks as standalone blobs 1480 1502 if (!empty($chunksNotInBundles)) { 1481 $this->logger-> error('Chunks not found in bundle metadata', [1503 $this->logger->warning('Some chunks not in bundle metadata, attempting fallback single-blob download', [ 1482 1504 'chunks_not_in_bundles' => count($chunksNotInBundles), 1483 1505 'sample_hashes' => array_slice($chunksNotInBundles, 0, 5) 1484 1506 ]); 1485 throw new \RuntimeException('Some chunks are not available in bundles. Bundle metadata is required for restore.');1486 1507 } 1487 1508 … … 1508 1529 // This matches how ObjectStore stores bundles: objects/sha256/{first2chars}/bundle_{hash}.zip 1509 1530 $bundlePath = $objects_prefix . '/' . substr($bundleHash, 0, 2) . '/' . $bundleKey . '.zip'; 1531 1532 // Extra logging to debug hosting-specific path / download issues 1533 $this->logger->info('Attempting to download bundle for incremental restore', [ 1534 'provider' => $provider, 1535 'bundle_hash' => $bundleHash, 1536 'bundle_key' => $bundleKey, 1537 'bundle_path' => $bundlePath, 1538 'objects_prefix' => $objects_prefix, 1539 'chunks_in_bundle' => count($chunkHashes), 1540 ]); 1541 1510 1542 $bundleData = $this->download_object_from_provider($provider, $bundlePath, $request); 1511 1543 … … 1536 1568 } 1537 1569 file_put_contents($tempFile, $bundleData); 1570 $this->logger->info('Bundle temp file created', [ 1571 'bundle_hash' => $bundleHash, 1572 'temp_file' => $tempFile, 1573 'file_exists' => file_exists($tempFile), 1574 'file_size' => file_exists($tempFile) ? filesize($tempFile) : null, 1575 'sys_temp_dir'=> sys_get_temp_dir(), 1576 ]); 1538 1577 $downloadedBundles[$bundleHash] = $tempFile; 1539 1578 $tempFiles[] = $tempFile; … … 1545 1584 ]); 1546 1585 1547 // Extract chunks from bundle 1548 $zip = new \ZipArchive(); 1549 if ($zip->open($tempFile) === true) { 1586 // Extract chunks from bundle (ZipArchive or PclZip fallback) 1587 try { 1588 $zip = $this->open_zip_for_restore($tempFile); 1589 $this->logger->info('Opened bundle ZIP', [ 1590 'bundle_hash' => $bundleHash, 1591 'temp_file' => $tempFile, 1592 'num_files' => $zip->numFiles, 1593 ]); 1550 1594 foreach ($chunkHashes as $chunkHash) { 1551 1595 $entryName = $chunkHash . '.blob'; … … 1564 1608 } 1565 1609 $zip->close(); 1566 } else{1610 } catch (\RuntimeException $e) { 1567 1611 $this->logger->error('Failed to open bundle ZIP file', [ 1568 1612 'bundle_hash' => $bundleHash, 1569 'temp_file' => $tempFile 1613 'temp_file' => $tempFile, 1614 'error' => $e->getMessage() 1570 1615 ]); 1571 1616 $failedBundles[] = $bundleHash; … … 1583 1628 } 1584 1629 1630 // Fallback 1: chunks not in chunk_to_bundle may still be inside an already-downloaded bundle 1631 // (e.g. parent manifest has 0 bundles so no mapping, but chunks live in child manifest bundles) 1632 $chunksStillMissingAfterBundles = []; 1633 foreach ($chunksNotInBundles as $hash) { 1634 $foundInBundle = false; 1635 foreach ($downloadedBundles as $bundleHash => $tempFile) { 1636 if (!is_file($tempFile)) { 1637 continue; 1638 } 1639 try { 1640 $zip = $this->open_zip_for_restore($tempFile); 1641 $chunkData = $zip->getFromName($hash . '.blob'); 1642 $zip->close(); 1643 if ($chunkData !== false) { 1644 $results[$hash] = $chunkData; 1645 $foundInBundle = true; 1646 $this->logger->info('Found chunk in already-downloaded bundle (not in chunk_to_bundle)', [ 1647 'chunk_hash' => substr($hash, 0, 16) . '...', 1648 'bundle_hash' => substr($bundleHash, 0, 16) . '...' 1649 ]); 1650 break; 1651 } 1652 } catch (\RuntimeException $e) { 1653 continue; 1654 } 1655 } 1656 if (!$foundInBundle) { 1657 $chunksStillMissingAfterBundles[] = $hash; 1658 } 1659 } 1660 1661 // Fallback 1b: missing chunks may be in a bundle we did not need for mapped chunks (e.g. third bundle in chain) 1662 $bundlesDownloaded = array_keys($downloadedBundles); 1663 foreach (array_keys($bundleMetadata) as $bundleHash) { 1664 if (in_array($bundleHash, $bundlesDownloaded, true) || empty($chunksStillMissingAfterBundles)) { 1665 continue; 1666 } 1667 $bundleInfo = $bundleMetadata[$bundleHash]; 1668 $bundleKey = $bundleInfo['bundle_key'] ?? 'bundle_' . $bundleHash; 1669 $bundlePath = $objects_prefix . '/' . substr($bundleHash, 0, 2) . '/' . $bundleKey . '.zip'; 1670 $bundleData = $this->download_object_from_provider($provider, $bundlePath, $request); 1671 if ($bundleData === null || $bundleData === '') { 1672 continue; 1673 } 1674 $tempFile = (function_exists('\wp_tempnam')) ? \wp_tempnam('siteskite_bundle_') : (sys_get_temp_dir() . '/siteskite_bundle_' . uniqid('', true) . '.zip'); 1675 if ($tempFile === false) { 1676 $tempFile = sys_get_temp_dir() . '/siteskite_bundle_' . uniqid('', true) . '.zip'; 1677 } 1678 file_put_contents($tempFile, $bundleData); 1679 $tempFiles[] = $tempFile; 1680 try { 1681 $zip = $this->open_zip_for_restore($tempFile); 1682 foreach ($chunksStillMissingAfterBundles as $idx => $hash) { 1683 $chunkData = $zip->getFromName($hash . '.blob'); 1684 if ($chunkData !== false) { 1685 $results[$hash] = $chunkData; 1686 unset($chunksStillMissingAfterBundles[$idx]); 1687 $this->logger->info('Found chunk in extra bundle (not in chunk_to_bundle)', [ 1688 'chunk_hash' => substr($hash, 0, 16) . '...', 1689 'bundle_hash' => substr($bundleHash, 0, 16) . '...' 1690 ]); 1691 } 1692 } 1693 $zip->close(); 1694 $chunksStillMissingAfterBundles = array_values($chunksStillMissingAfterBundles); 1695 } catch (\RuntimeException $e) { 1696 // Skip this bundle on open failure 1697 } 1698 } 1699 1585 1700 // Cleanup temp files 1586 1701 foreach ($tempFiles as $tempFile) { … … 1590 1705 } 1591 1706 1592 // Fail if any bundles failed to download or chunks are missing 1707 // Fallback 2: try single-blob download for any still missing (standalone blob in cloud) 1708 foreach ($chunksStillMissingAfterBundles as $hash) { 1709 $objectPath = $objects_prefix . '/' . substr($hash, 0, 2) . '/' . $hash . '.blob'; 1710 $chunkData = $this->download_object_from_provider($provider, $objectPath, $request); 1711 if ($chunkData !== null && $chunkData !== '') { 1712 $results[$hash] = $chunkData; 1713 $this->logger->debug('Downloaded chunk via fallback single-blob', [ 1714 'chunk_hash' => substr($hash, 0, 16) . '...', 1715 'object_path' => $objectPath 1716 ]); 1717 } else { 1718 $this->logger->warning('Fallback single-blob download failed for chunk', [ 1719 'chunk_hash' => substr($hash, 0, 16) . '...', 1720 'object_path' => $objectPath 1721 ]); 1722 $missingChunks[] = $hash; 1723 } 1724 } 1725 1726 // Fail if any bundles failed to download or chunks are still missing (after bundle + fallback) 1593 1727 if (!empty($failedBundles) || !empty($missingChunks)) { 1594 $this->logger->error('Bundle download/extraction failed', [1728 $this->logger->error('Bundle download/extraction or fallback failed', [ 1595 1729 'failed_bundles' => count($failedBundles), 1596 1730 'missing_chunks' => count($missingChunks), 1597 'bundles' => $failedBundles 1598 ]); 1599 throw new \RuntimeException('Failed to download or extract chunks from bundles. Bundle-based restore is required.'); 1731 'bundles' => $failedBundles, 1732 'missing_sample' => array_slice($missingChunks, 0, 5) 1733 ]); 1734 throw new \RuntimeException( 1735 'Failed to download or extract chunks. Missing: ' . count($missingChunks) . ' chunk(s) after bundle download and single-blob fallback.' 1736 ); 1600 1737 } 1601 1738 1602 1739 $this->logger->info('Bundle-based download completed', [ 1603 1740 'total_requested' => count($hashes), 1604 'total_retrieved' => count($results) 1741 'total_retrieved' => count($results), 1742 'from_bundles_mapped' => count($hashes) - count($chunksNotInBundles), 1743 'from_fallback_bundles_or_blobs' => count($chunksNotInBundles) 1605 1744 ]); 1606 1745 … … 2690 2829 } 2691 2830 2692 // Read the downloaded file 2693 $data = file_get_contents($temp_file); 2831 // Read the downloaded file with extra diagnostics for hosting differences 2832 $data = @file_get_contents($temp_file); 2833 $data_size = is_string($data) ? strlen($data) : -1; 2834 2835 $this->logger->info('download_object_from_provider read temp file', [ 2836 'provider' => $provider, 2837 'object_path' => $objectPath, 2838 'temp_file' => $temp_file, 2839 'data_size' => $data_size, 2840 'file_exists' => file_exists($temp_file), 2841 'file_size' => file_exists($temp_file) ? filesize($temp_file) : null, 2842 'sys_temp_dir' => sys_get_temp_dir(), 2843 ]); 2844 2845 if ($data === false || $data_size === 0) { 2846 $this->logger->warning('download_object_from_provider returned empty data', [ 2847 'provider' => $provider, 2848 'object_path' => $objectPath, 2849 'temp_file' => $temp_file, 2850 'file_exists' => file_exists($temp_file), 2851 'file_size' => file_exists($temp_file) ? filesize($temp_file) : null, 2852 ]); 2853 } 2854 2694 2855 wp_delete_file($temp_file); 2695 2856 return $data; … … 2830 2991 fclose($fh); 2831 2992 2832 // Package into a ZIP to reuse existing restore_database logic 2993 // Package into a ZIP to reuse existing restore_database logic (ZipArchive or PclZip fallback) 2833 2994 $zip_path = $this->restore_dir . '/incdb_' . $manifestId . '.zip'; 2834 $zip = new \ZipArchive(); 2835 if ($zip->open($zip_path, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) { 2836 throw new \RuntimeException('Failed to create ZIP for database incremental restore'); 2837 } 2838 $zip->addFile($sql_path, basename($sql_path)); 2839 $zip->close(); 2995 $this->create_zip_with_single_file($zip_path, $sql_path, basename($sql_path)); 2840 2996 2841 2997 // Import using existing path … … 2850 3006 { 2851 3007 try { 2852 // Clear webhook sent keys for all possible scopes when starting a new restore 2853 // This allows retries to send notifications again 2854 $scopes = ['files', 'database', 'full']; 2855 foreach ($scopes as $scope) { 2856 $webhook_sent_key = 'siteskite_restore_webhook_sent_' . $backup_id . '_' . $scope; 2857 delete_option($webhook_sent_key); 2858 } 2859 3008 // If a duplicate cron fires after restore already completed (e.g. file cleaned up), skip so we 3009 // don't overwrite completion with "Failed to open backup file" / error and don't clear webhook_sent. 3010 // Use multiple sources of truth: option (completed/cleanup), webhook_sent from DB, filesystem state file. 3011 $scope = ($type === 'database' || $type === 'files') ? $type : 'full'; 3012 if ($this->is_restore_finalized($backup_id, $scope)) { 3013 $this->logger->info('Restore already finalized for this backup_id/scope, skipping duplicate cron execution', [ 3014 'backup_id' => $backup_id, 3015 'scope' => $scope 3016 ]); 3017 return; 3018 } 3019 3020 // If another process_restore is already running for this backup_id+scope (e.g. at 301/321 queries), 3021 // skip so we don't start a second attempt. Lock is file-based so it survives DB import overwriting options. 3022 if ($this->is_restore_running_lock_active($backup_id, $scope)) { 3023 $this->logger->info('Restore already in progress for this backup_id/scope, skipping second attempt', [ 3024 'backup_id' => $backup_id, 3025 'scope' => $scope 3026 ]); 3027 return; 3028 } 3029 3030 $this->prepare_classic_restore_run($backup_id, $scope); 3031 2860 3032 // Set a flag at the start of restore 2861 3033 $this->update_option_bc(SITESKITE_OPTION_PREFIX . 'restore_in_progress', SITESKITE_OPTION_PREFIX . 'restore_in_progress', true); 3034 $this->ensure_restore_guard_mu_plugin(); 2862 3035 2863 3036 // Check if this is an incremental restore 2864 3037 if ($this->is_incremental_restore($backup_id, $type, $download_url)) { 3038 $this->clear_restore_running_lock($backup_id, $scope); 2865 3039 $this->process_incremental_restore($backup_id, $type, $download_url); 2866 3040 return; … … 2935 3109 ], $suppress_notification); 2936 3110 2937 // TRICK: Save final state to JSON file before cleanup so external app can still poll it 2938 // Get the final restore info before it's deleted 2939 $final_restore_info = get_option(self::OPTION_PREFIX . $backup_id, []); 2940 if (!empty($final_restore_info) && isset($final_restore_info['status']) && $final_restore_info['status'] === 'completed') { 2941 // Build final progress data similar to get_restore_progress response 2942 $final_restore_type = $final_restore_info['type'] ?? $type; 2943 $final_scope = $final_restore_info['scope'] ?? $type; 2944 2945 $final_progress_data = [ 2946 'status' => 'completed', 2947 'progress' => 100, 2948 'message' => $final_restore_info['message'] ?? 'Restore completed successfully', 2949 'backup_id' => $backup_id, 2950 'completed_at' => $final_restore_info['completed_at'] ?? time(), 2951 'operation_type' => 'restore', 2952 'is_restore' => true, 2953 'type' => $final_restore_type, 2954 'scope' => $final_scope 2955 ]; 2956 2957 // Add classic restore progress 2958 $classic_restore_progress = $this->get_classic_restore_progress($backup_id, $final_restore_info, $final_restore_type); 2959 $final_progress_data['classic_restore'] = $classic_restore_progress; 2960 2961 // Ensure all progress shows as completed 2962 if (isset($final_progress_data['classic_restore']['database'])) { 2963 $final_progress_data['classic_restore']['database']['status'] = 'completed'; 2964 $final_progress_data['classic_restore']['database']['complete'] = true; 2965 $final_progress_data['classic_restore']['database']['progress_percent'] = 100.0; 2966 } 2967 if (isset($final_progress_data['classic_restore']['files'])) { 2968 $final_progress_data['classic_restore']['files']['status'] = 'completed'; 2969 $final_progress_data['classic_restore']['files']['complete'] = true; 2970 $final_progress_data['classic_restore']['files']['progress_percent'] = 100.0; 2971 } 2972 if (isset($final_progress_data['classic_restore']['full'])) { 2973 $final_progress_data['classic_restore']['full']['status'] = 'completed'; 2974 $final_progress_data['classic_restore']['full']['complete'] = true; 2975 $final_progress_data['classic_restore']['full']['overall_progress'] = 100.0; 2976 } 2977 2978 $this->save_last_classic_restore_state_to_file($backup_id, $final_progress_data); 2979 } 2980 2981 // Clear the in progress flag 2982 delete_option(SITESKITE_OPTION_PREFIX . 'restore_in_progress'); 2983 delete_option(SITESKITE_OPTION_PREFIX . 'restore_in_progress'); 3111 $this->build_and_save_completed_restore_state_file($backup_id, $type); 3112 3113 $this->finish_classic_restore_run($backup_id, $scope); 2984 3114 2985 3115 $this->cleanup_manager->cleanup_restore_files($backup_id); 2986 3116 2987 3117 } catch (\Exception $e) { 2988 delete_option(SITESKITE_OPTION_PREFIX . 'restore_in_progress'); 2989 delete_option(SITESKITE_OPTION_PREFIX . 'restore_in_progress'); 3118 $this->finish_classic_restore_run($backup_id, $scope); 2990 3119 $this->update_restore_status($backup_id, [ 2991 3120 'status' => 'error', … … 3112 3241 3113 3242 /** 3243 * Ensure PclZip class is loaded (WordPress wp-admin/includes/class-pclzip.php). 3244 * Used as fallback when ZipArchive extension is not available for restore extraction. 3245 * 3246 * @throws \RuntimeException If PclZip file does not exist or class not available 3247 */ 3248 private function ensure_pclzip_loaded(): void 3249 { 3250 if (class_exists('PclZip')) { 3251 return; 3252 } 3253 $path = ABSPATH . 'wp-admin/includes/class-pclzip.php'; 3254 if (!file_exists($path)) { 3255 throw new \RuntimeException( 3256 'ZipArchive is not available and PclZip could not be loaded (missing: wp-admin/includes/class-pclzip.php). ' . 3257 'Please enable the PHP zip extension or use a full WordPress installation.' 3258 ); 3259 } 3260 require_once $path; 3261 if (!class_exists('PclZip')) { 3262 throw new \RuntimeException( 3263 'ZipArchive is not available and PclZip could not be loaded. ' . 3264 'Please enable the PHP zip extension.' 3265 ); 3266 } 3267 } 3268 3269 /** 3270 * Open a ZIP file for reading (extract/list). Uses ZipArchive when available, otherwise PclZip. 3271 * Caller must call close() when done. 3272 * 3273 * @param string $zip_path Full path to the ZIP file 3274 * @return object Object with: numFiles (int), getNameIndex(int $i), extractTo(string $path, string|array|null $entries = null), getFromName(string $name), close() 3275 * @throws \RuntimeException If ZIP cannot be opened or neither ZipArchive nor PclZip is available 3276 */ 3277 private function open_zip_for_restore(string $zip_path) 3278 { 3279 if (class_exists('ZipArchive') || extension_loaded('zip')) { 3280 $zip = new \ZipArchive(); 3281 if ($zip->open($zip_path) === true) { 3282 return $zip; 3283 } 3284 } 3285 3286 $this->ensure_pclzip_loaded(); 3287 $pclzip = new \PclZip($zip_path); 3288 $list = $pclzip->listContent(); 3289 if ($list === 0 || !is_array($list)) { 3290 $err = $pclzip->errorInfo(true); 3291 throw new \RuntimeException('Failed to open ZIP file for restore: ' . (is_string($err) ? $err : 'unknown error')); 3292 } 3293 3294 $numFiles = count($list); 3295 $filenames = []; 3296 foreach ($list as $entry) { 3297 $filenames[] = isset($entry['filename']) ? $entry['filename'] : (isset($entry['stored_filename']) ? $entry['stored_filename'] : ''); 3298 } 3299 3300 return new class($pclzip, $filenames, $numFiles, $this->restore_dir) { 3301 private $pclzip; 3302 private $filenames; 3303 public $numFiles; 3304 private $restore_dir; 3305 3306 public function __construct($pclzip, $filenames, $numFiles, $restore_dir) 3307 { 3308 $this->pclzip = $pclzip; 3309 $this->filenames = $filenames; 3310 $this->numFiles = $numFiles; 3311 $this->restore_dir = $restore_dir; 3312 } 3313 3314 public function getNameIndex(int $i): string 3315 { 3316 return $this->filenames[$i] ?? ''; 3317 } 3318 3319 public function extractTo(string $path, $entries = null): bool 3320 { 3321 if ($entries === null) { 3322 $result = $this->pclzip->extract(\PCLZIP_OPT_PATH, $path); 3323 } elseif (is_array($entries)) { 3324 $result = $this->pclzip->extract(\PCLZIP_OPT_PATH, $path, \PCLZIP_OPT_BY_NAME, $entries); 3325 } else { 3326 $result = $this->pclzip->extract(\PCLZIP_OPT_PATH, $path, \PCLZIP_OPT_BY_NAME, $entries); 3327 } 3328 return $result !== 0; 3329 } 3330 3331 public function getFromName(string $name) 3332 { 3333 $temp_dir = $this->restore_dir . '/pclzip_' . uniqid('', true); 3334 if (!wp_mkdir_p($temp_dir)) { 3335 return false; 3336 } 3337 $result = $this->pclzip->extract(\PCLZIP_OPT_PATH, $temp_dir, \PCLZIP_OPT_BY_NAME, $name); 3338 if ($result === 0) { 3339 if (is_dir($temp_dir)) { 3340 array_map('wp_delete_file', glob($temp_dir . '/*')); 3341 @rmdir($temp_dir); 3342 } 3343 return false; 3344 } 3345 $extracted_file = $temp_dir . '/' . $name; 3346 if (!file_exists($extracted_file)) { 3347 $extracted_file = $temp_dir . '/' . basename($name); 3348 } 3349 $content = file_exists($extracted_file) ? file_get_contents($extracted_file) : false; 3350 if (is_dir($temp_dir)) { 3351 array_map('wp_delete_file', glob($temp_dir . '/*')); 3352 @rmdir($temp_dir); 3353 } 3354 return $content; 3355 } 3356 3357 public function close(): bool 3358 { 3359 return true; 3360 } 3361 }; 3362 } 3363 3364 /** 3365 * Create a ZIP file containing a single file (for incremental DB restore when ZipArchive is unavailable). 3366 * 3367 * @param string $zip_path Path for the new ZIP file 3368 * @param string $file_path Path to the file to add 3369 * @param string $entry_name Name of the entry inside the ZIP 3370 * @throws \RuntimeException If creation fails 3371 */ 3372 private function create_zip_with_single_file(string $zip_path, string $file_path, string $entry_name): void 3373 { 3374 if (class_exists('ZipArchive') || extension_loaded('zip')) { 3375 $zip = new \ZipArchive(); 3376 if ($zip->open($zip_path, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) === true) { 3377 if ($zip->addFile($file_path, $entry_name) && $zip->close()) { 3378 return; 3379 } 3380 $zip->close(); 3381 } 3382 } 3383 3384 $this->ensure_pclzip_loaded(); 3385 $pclzip = new \PclZip($zip_path); 3386 $result = $pclzip->create($file_path, \PCLZIP_OPT_REMOVE_ALL_PATH); 3387 if ($result === 0) { 3388 $err = $pclzip->errorInfo(true); 3389 throw new \RuntimeException('Failed to create ZIP for restore: ' . (is_string($err) ? $err : 'unknown error')); 3390 } 3391 } 3392 3393 /** 3114 3394 * Extract file from full backup package if specific file not found 3115 * 3395 * 3116 3396 * @param string $file_name The requested file name (e.g., siteskite_backup_XXX_db.zip) 3117 3397 * @param string $destination Final destination path for the extracted file (will be updated to actual extracted file path) … … 3188 3468 ]); 3189 3469 3190 // Extract the needed file from full.zip 3191 $zip = new \ZipArchive(); 3192 if ($zip->open($full_zip_path) !== true) { 3470 // Extract the needed file from full.zip (ZipArchive or PclZip fallback) 3471 try { 3472 $zip = $this->open_zip_for_restore($full_zip_path); 3473 } catch (\RuntimeException $e) { 3193 3474 if (file_exists($full_zip_path)) { 3194 3475 wp_delete_file($full_zip_path); 3195 3476 } 3196 throw new \RuntimeException('Failed to open full backup package ');3477 throw new \RuntimeException('Failed to open full backup package: ' . $e->getMessage()); 3197 3478 } 3198 3479 … … 3352 3633 3353 3634 /** 3635 * Execute a single restore query with retries for transient errors (lock wait, timeout, connection). 3636 * 3637 * @param \wpdb $wpdb WordPress database instance 3638 * @param string $query SQL query to execute 3639 * @param bool $is_incremental Whether this is an incremental restore (duplicate key errors are acceptable) 3640 * @return bool True if query succeeded or was ignorable; false if failed after retries 3641 */ 3642 private function execute_restore_query_with_retry($wpdb, string $query, bool $is_incremental): bool 3643 { 3644 $attempt = 0; 3645 $max_attempts = self::DB_IMPORT_QUERY_RETRY_ATTEMPTS; 3646 $sleep_ms = self::DB_IMPORT_QUERY_RETRY_SLEEP_MS; 3647 3648 while ($attempt < $max_attempts) { 3649 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared 3650 $result = $wpdb->query($query); 3651 if ($result !== false) { 3652 return true; 3653 } 3654 $last_error = $wpdb->last_error; 3655 $is_duplicate = ( 3656 strpos($last_error, 'Duplicate entry') !== false || 3657 strpos($last_error, 'PRIMARY') !== false 3658 ); 3659 if ($is_duplicate && $is_incremental) { 3660 return true; 3661 } 3662 $is_transient = ( 3663 stripos($last_error, 'Lock wait timeout') !== false || 3664 stripos($last_error, 'Deadlock') !== false || 3665 stripos($last_error, 'connection') !== false || 3666 stripos($last_error, 'gone away') !== false || 3667 stripos($last_error, 'max_allowed_packet') !== false || 3668 stripos($last_error, 'try again') !== false 3669 ); 3670 if (!$is_transient || $attempt === $max_attempts - 1) { 3671 $this->logger->warning('Query failed', [ 3672 'query' => substr($query, 0, 100) . '...', 3673 'error' => $last_error, 3674 'attempt' => $attempt + 1, 3675 ]); 3676 return false; 3677 } 3678 $attempt++; 3679 usleep($sleep_ms * 1000); 3680 } 3681 return false; 3682 } 3683 3684 /** 3685 * Import SQL file using streaming read so large dumps (100MB–1GB+) don't exhaust memory or time out. 3686 * Parses statements from a chunked buffer and executes with retries and periodic time-limit extension. 3687 * 3688 * @param string $sql_file Path to extracted .sql file 3689 * @param string $backup_id Backup/restore ID 3690 * @param bool $is_incremental Whether this is an incremental restore 3691 * @return int Number of queries executed successfully (for progress/completion) 3692 */ 3693 private function import_sql_file_streaming( 3694 string $sql_file, 3695 string $backup_id, 3696 bool $is_incremental 3697 ): int { 3698 global $wpdb; 3699 3700 $file_size = (int) @filesize($sql_file); 3701 if ($file_size <= 0) { 3702 throw new \RuntimeException('SQL file is empty or unreadable'); 3703 } 3704 3705 $fh = fopen($sql_file, 'rb'); 3706 if ($fh === false) { 3707 throw new \RuntimeException('Failed to open SQL file for streaming'); 3708 } 3709 3710 $chunk_size = self::DB_IMPORT_READ_CHUNK_BYTES; 3711 $buffer = ''; 3712 $bytes_read = 0; 3713 $total_queries = 0; 3714 $progress_interval = self::DB_IMPORT_PROGRESS_INTERVAL; 3715 $extend_every = self::DB_IMPORT_EXTEND_TIME_LIMIT_EVERY; 3716 3717 try { 3718 while (!feof($fh)) { 3719 $chunk = fread($fh, $chunk_size); 3720 if ($chunk === false) { 3721 break; 3722 } 3723 $bytes_read += strlen($chunk); 3724 $buffer .= $chunk; 3725 3726 // Cap buffer size: if one huge statement exceeds limit, process up to last ;\n to avoid OOM 3727 $max_buf = self::DB_IMPORT_MAX_BUFFER_BYTES; 3728 if (strlen($buffer) > $max_buf) { 3729 $last_delim = strrpos($buffer, ";\n"); 3730 if ($last_delim === false) { 3731 $last_delim = strrpos($buffer, ";\r\n"); 3732 } 3733 if ($last_delim !== false) { 3734 $to_process = substr($buffer, 0, $last_delim + 1); 3735 $buffer = substr($buffer, $last_delim + 1); 3736 $buffer = ltrim($buffer, "\r\n"); 3737 $statements = preg_split('/;\s*\r?\n/', $to_process, -1, PREG_SPLIT_NO_EMPTY); 3738 if ($statements !== false) { 3739 foreach ($statements as $raw) { 3740 $query = trim($raw); 3741 if ($query === '') continue; 3742 if ($is_incremental && stripos($query, 'DROP TABLE') === 0) continue; 3743 if ($is_incremental && preg_match('/^\s*INSERT\s+INTO\s+/i', $query)) { 3744 $query = preg_replace('/^\s*INSERT\s+INTO\s+/i', 'INSERT IGNORE INTO ', $query); 3745 } 3746 if (!$this->is_safe_restore_query($query)) continue; 3747 $this->execute_restore_query_with_retry($wpdb, $query, $is_incremental); 3748 $total_queries++; 3749 if ($total_queries % $extend_every === 0) { 3750 if (function_exists('set_time_limit')) set_time_limit(30); 3751 $wpdb->flush(); 3752 } 3753 if ($total_queries % $progress_interval === 0 || $total_queries === 1) { 3754 $pct = $file_size > 0 ? min(100, (int) (($bytes_read / $file_size) * 100)) : 0; 3755 $progress_pct = min(90, 60 + (int) (($bytes_read / $file_size) * 30)); 3756 $this->update_restore_status($backup_id, [ 3757 'progress' => $progress_pct, 3758 'db_queries_processed' => $total_queries, 3759 'current_operation' => sprintf( 3760 'Restoring database: %d queries processed (~%d%% of file)', 3761 $total_queries, 3762 $pct 3763 ), 3764 ]); 3765 } 3766 } 3767 } 3768 } 3769 } 3770 3771 // Extract complete statements (delimited by ; followed by newline) 3772 $statements = preg_split('/;\s*\r?\n/', $buffer, -1, PREG_SPLIT_NO_EMPTY); 3773 if ($statements === false) { 3774 continue; 3775 } 3776 // Last segment may be incomplete (no trailing ;\n yet) 3777 $last = array_pop($statements); 3778 $buffer = $last !== '' ? $last : ''; 3779 3780 foreach ($statements as $raw) { 3781 $query = trim($raw); 3782 if ($query === '') { 3783 continue; 3784 } 3785 if ($is_incremental && stripos($query, 'DROP TABLE') === 0) { 3786 $this->logger->warning('Skipping DROP TABLE during incremental restore', [ 3787 'query_preview' => substr($query, 0, 80) . '...', 3788 'backup_id' => $backup_id, 3789 ]); 3790 continue; 3791 } 3792 if ($is_incremental && preg_match('/^\s*INSERT\s+INTO\s+/i', $query)) { 3793 $query = preg_replace('/^\s*INSERT\s+INTO\s+/i', 'INSERT IGNORE INTO ', $query); 3794 } 3795 if (!$this->is_safe_restore_query($query)) { 3796 $this->logger->warning('Unsafe query skipped during restore', [ 3797 'query' => substr($query, 0, 100) . '...', 3798 ]); 3799 continue; 3800 } 3801 $this->execute_restore_query_with_retry($wpdb, $query, $is_incremental); 3802 $total_queries++; 3803 3804 if ($total_queries % $extend_every === 0) { 3805 if (function_exists('set_time_limit')) { 3806 set_time_limit(30); 3807 } 3808 $wpdb->flush(); 3809 } 3810 if ($total_queries % $progress_interval === 0 || $total_queries === 1) { 3811 $pct = $file_size > 0 ? min(100, (int) (($bytes_read / $file_size) * 100)) : 0; 3812 $progress = 60 + (int) (($bytes_read / $file_size) * 30); 3813 $progress = min(90, (int) $progress); 3814 $this->update_restore_status($backup_id, [ 3815 'progress' => $progress, 3816 'db_queries_processed' => $total_queries, 3817 'current_operation' => sprintf( 3818 'Restoring database: %d queries processed (~%d%% of file)', 3819 $total_queries, 3820 $pct 3821 ), 3822 ]); 3823 } 3824 } 3825 } 3826 3827 // Process any remaining buffer (last statement(s) after final ;\n or EOF) 3828 if ($buffer !== '') { 3829 $statements = preg_split('/;\s*\r?\n/', $buffer, -1, PREG_SPLIT_NO_EMPTY); 3830 if ($statements !== false) { 3831 foreach ($statements as $raw) { 3832 $query = trim($raw); 3833 if ($query === '') { 3834 continue; 3835 } 3836 if ($is_incremental && stripos($query, 'DROP TABLE') === 0) { 3837 continue; 3838 } 3839 if ($is_incremental && preg_match('/^\s*INSERT\s+INTO\s+/i', $query)) { 3840 $query = preg_replace('/^\s*INSERT\s+INTO\s+/i', 'INSERT IGNORE INTO ', $query); 3841 } 3842 if (!$this->is_safe_restore_query($query)) { 3843 continue; 3844 } 3845 $this->execute_restore_query_with_retry($wpdb, $query, $is_incremental); 3846 $total_queries++; 3847 } 3848 } 3849 } 3850 } finally { 3851 fclose($fh); 3852 } 3853 3854 return $total_queries; 3855 } 3856 3857 /** 3354 3858 * Restore database 3355 3859 * … … 3363 3867 3364 3868 try { 3869 // Snapshot SiteSkite connection keys and permalink FIRST (before any DB writes or import) 3870 // so portal connection survives after DB import; original code captured these before running SQL 3871 $connection_keys_to_preserve = [ 3872 SITESKITE_OPTION_PREFIX . 'api_key', 3873 'siteskite_callback_url', 3874 'siteskite_cron_secret_token', 3875 ]; 3876 $connection_keys_before = []; 3877 foreach ($connection_keys_to_preserve as $opt_key) { 3878 $val = get_option($opt_key, null); 3879 if ($val !== null && $val !== '') { 3880 $connection_keys_before[ $opt_key ] = $val; 3881 } 3882 } 3883 $original_permalink = get_option('permalink_structure'); 3884 3365 3885 // Update status to show database restore starting 3366 3886 $this->update_restore_status($backup_id, [ … … 3372 3892 ]); 3373 3893 3374 $original_permalink = get_option('permalink_structure');3375 3376 3894 $this->logger->info('permalink: ', [ 3377 3895 'Permalink' => $original_permalink 3378 3896 ]); 3379 3897 3380 // Extract SQL file from ZIP 3381 $zip = new \ZipArchive(); 3382 if ($zip->open($download_url) !== true) { 3383 throw new \RuntimeException('Failed to open backup file'); 3898 if (!$this->check_database_restore_file_readable($backup_id, $download_url)) { 3899 return; 3900 } 3901 3902 try { 3903 $zip = $this->open_zip_for_restore($download_url); 3904 } catch (\RuntimeException $e) { 3905 if ($this->is_restore_finalized($backup_id, 'database')) { 3906 $this->logger->info('Failed to open backup file but restore already finalized, skipping (duplicate cron)', [ 3907 'backup_id' => $backup_id 3908 ]); 3909 return; 3910 } 3911 throw new \RuntimeException('Failed to open backup file: ' . $e->getMessage()); 3384 3912 } 3385 3913 … … 3399 3927 3400 3928 $sql_file = $this->restore_dir . '/temp_' . $backup_id . '.sql'; 3401 $ zip->extractTo($this->restore_dir, $sql_filename);3929 $extract_ok = $zip->extractTo($this->restore_dir, $sql_filename); 3402 3930 $zip->close(); 3931 if ($extract_ok === false) { 3932 throw new \RuntimeException('Failed to extract SQL file from backup'); 3933 } 3403 3934 3404 3935 // Rename extracted file to our temp name if needed … … 3412 3943 } 3413 3944 3414 // Update status for SQL processing 3945 // Update status for SQL processing (streaming import avoids loading full file into memory) 3415 3946 $this->update_restore_status($backup_id, [ 3416 3947 'progress' => 50, 3417 'current_operation' => 'Reading SQL file' 3418 ]); 3419 3420 // Read SQL file 3421 $sql_contents = file_get_contents($sql_file); 3422 if ($sql_contents === false) { 3423 throw new \RuntimeException('Failed to read SQL file'); 3424 } 3425 3426 // Detect if this is an incremental restore 3948 'current_operation' => 'Importing database (streaming)' 3949 ]); 3950 3951 // Detect if this is an incremental restore (before any file read) 3427 3952 // Incremental restores use file paths like '/incdb_' or manifest IDs 3428 $is_incremental = (strpos($download_url, '/incdb_') !== false) || 3953 $is_incremental = (strpos($download_url, '/incdb_') !== false) || 3429 3954 (strpos($download_url, 'incdb_') !== false) || 3430 3955 (strpos($backup_id, 'database:') === 0); 3431 3956 3432 // For incremental restores, clean up ALL existing restore-related records BEFORE importing 3433 // This prevents duplicate notifications from old restore records in the database 3434 // We delete ALL restore-related records completely - they will be recreated as needed 3957 // For incremental restores, clean up existing restore-related records for THIS job BEFORE importing 3435 3958 if ($is_incremental) { 3436 $this->logger->info('Cleaning up ALL existing restore-related recordsbefore database import', [3959 $this->logger->info('Cleaning up existing restore-related records for this job before database import', [ 3437 3960 'backup_id' => $backup_id 3438 3961 ]); 3439 // Clean up ALL restore-related records completely (preserve webhook flags)3440 3962 $this->cleanup_restore_records($backup_id, true); 3441 3963 } 3442 3443 // For incremental restores, modify INSERT statements to handle duplicates3444 3964 if ($is_incremental) { 3445 // Convert INSERT INTO to INSERT IGNORE INTO to handle duplicate keys 3446 // This prevents "Duplicate entry" errors for incremental restores 3447 $sql_contents = preg_replace( 3448 '/^INSERT\s+INTO\s+/im', 3449 'INSERT IGNORE INTO ', 3450 $sql_contents 3451 ); 3452 $this->logger->info('Modified SQL for incremental restore (using INSERT IGNORE)', [ 3965 $this->logger->info('Using streaming import with INSERT IGNORE for incremental restore', [ 3453 3966 'backup_id' => $backup_id, 3454 3967 'download_url' => basename($download_url) … … 3456 3969 } 3457 3970 3458 // Split into queries 3459 $queries = array_filter( 3460 explode(";\n", $sql_contents), 3461 'trim' 3462 ); 3463 $total_queries = count($queries); 3464 3465 // Store total queries for progress tracking 3466 $this->update_restore_status($backup_id, [ 3467 'db_total_queries' => $total_queries, 3468 'db_queries_processed' => 0 3469 ]); 3470 3471 // Update status for database restore 3472 $this->update_restore_status($backup_id, [ 3473 'progress' => 60, 3474 'current_operation' => 'Restoring database tables' 3475 ]); 3476 3477 // Process queries 3478 foreach ($queries as $index => $query) { 3479 $query = trim($query); 3480 if (empty($query)) continue; 3481 3482 // Validate query for security - only allow specific SQL operations 3483 if (!$this->is_safe_restore_query($query)) { 3484 $this->logger->warning('Unsafe query skipped during restore', [ 3485 'query' => substr($query, 0, 100) . '...' 3486 ]); 3487 continue; 3488 } 3489 3490 // Execute query 3491 // Note: This is for SQL restore operations via REST API 3492 // Queries are validated for safety before execution 3493 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared 3494 $result = $wpdb->query($query); 3495 if ($result === false) { 3496 // For incremental restores, duplicate key errors are expected and can be ignored 3497 // as we're using INSERT IGNORE, but log other errors 3498 $is_duplicate_error = ( 3499 strpos($wpdb->last_error, 'Duplicate entry') !== false || 3500 strpos($wpdb->last_error, 'PRIMARY') !== false 3501 ); 3502 3503 if (!$is_duplicate_error || !$is_incremental) { 3504 $this->logger->warning('Query failed', [ 3505 'query' => substr($query, 0, 100) . '...', 3506 'error' => $wpdb->last_error, 3507 'is_incremental' => $is_incremental 3508 ]); 3509 } 3510 } 3511 3512 // Update progress every 10% or 100 queries 3513 if ($index % max(ceil($total_queries / 10), 100) === 0) { 3514 $progress = 60 + (($index / $total_queries) * 30); // Progress from 60% to 90% 3515 $this->update_restore_status($backup_id, [ 3516 'progress' => (int)$progress, 3517 'db_queries_processed' => $index + 1, 3518 'current_operation' => sprintf( 3519 'Restoring database: %d/%d queries processed', 3520 $index + 1, 3521 $total_queries 3522 ) 3523 ]); 3524 } 3525 3526 // Prevent timeouts on large restores 3527 if (($index + 1) % 1000 === 0) { 3528 // Note: This is necessary for large database restore operations via REST API 3529 // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged 3530 if (function_exists('set_time_limit')) { 3531 // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged 3532 set_time_limit(30); 3533 } 3534 $wpdb->flush(); 3535 } 3536 } 3537 3538 // For incremental restores, clean up ALL imported restore-related records AFTER database import 3971 // Stream SQL file in chunks: no full-file load, retries on transient errors, time-limit extension 3972 $total_queries = $this->import_sql_file_streaming($sql_file, $backup_id, $is_incremental); 3973 3974 // For incremental restores, clean up imported restore-related records for THIS job after import 3539 3975 // but BEFORE status update (which triggers notifications) 3540 // This prevents duplicate notifications from restore records that were imported from the backup3541 // We delete ALL restore-related records completely - they will be recreated by the status update below3542 3976 if ($is_incremental) { 3543 $this->logger->info('Cleaning up ALL imported restore-related recordsafter database import', [3977 $this->logger->info('Cleaning up imported restore-related records for this job after database import', [ 3544 3978 'backup_id' => $backup_id 3545 3979 ]); 3546 // Clean up ALL restore-related records completely (no preservation)3547 // This removes any restore records that were imported from the backup3548 3980 $this->cleanup_restore_records($backup_id, true); 3549 3981 } 3550 3982 3551 // Cleanup 3983 // Restore SiteSkite connection keys so portal connection survives even if SQL had older/empty values 3984 foreach ($connection_keys_before as $opt_key => $value) { 3985 update_option($opt_key, $value, true); 3986 } 3987 if (!empty($connection_keys_before)) { 3988 $this->logger->info('Restored SiteSkite connection keys after database import', [ 3989 'backup_id' => $backup_id, 3990 'keys_restored' => array_keys($connection_keys_before) 3991 ]); 3992 } 3993 3552 3994 wp_delete_file($sql_file); 3553 3995 3554 /** 3555 * Important: a database restore can import the plugin's own status/options table rows 3556 * (including `siteskite_restore_*`). That can overwrite the in-memory/previous restore 3557 * status and scope (e.g. setting scope back to 'full'), which causes completion 3558 * notifications to be suppressed for standalone database restores. 3559 * 3560 * Reset the restore status option right before the terminal status update so the 3561 * notification logic uses the current scope ('database') and reliably fires. 3562 */ 3563 delete_option(self::OPTION_PREFIX . $backup_id); 3564 3565 // Update final status (suppress notification if this is an intermediate step in full restore) 3566 $this->update_restore_status($backup_id, [ 3567 'status' => 'completed', 3568 'progress' => 100, 3569 'completed_at' => time(), 3570 'current_operation' => 'Database restore completed successfully', 3571 'scope' => 'database', 3572 'db_queries_processed' => $total_queries, 3573 'db_restore_complete' => true 3574 ], $suppress_notification); 3996 $this->persist_database_restore_completed($backup_id, $total_queries, $suppress_notification); 3575 3997 3576 3998 global $wpdb; … … 3612 4034 ]); 3613 4035 3614 // Open ZIP file 3615 $zip = new \ZipArchive(); 3616 if ($zip->open($download_url) !== true) { 3617 throw new \RuntimeException('Failed to open backup file'); 3618 } 4036 // Open ZIP file (ZipArchive or PclZip fallback) 4037 $zip = $this->open_zip_for_restore($download_url); 3619 4038 3620 4039 // Get total files and prepare file list … … 3756 4175 } 3757 4176 3758 // Extract main zip containing both database and files zips 3759 $zip = new \ZipArchive(); 3760 if ($zip->open($download_url) !== true) { 4177 // Extract main zip containing both database and files zips (ZipArchive or PclZip fallback) 4178 try { 4179 $zip = $this->open_zip_for_restore($download_url); 4180 } catch (\RuntimeException $e) { 3761 4181 $this->logger->error('Failed to open backup file', [ 3762 4182 'backup_id' => $backup_id, 3763 4183 'file_path' => $download_url, 3764 4184 'file_exists' => file_exists($download_url), 3765 'file_size' => file_exists($download_url) ? filesize($download_url) : 0 4185 'file_size' => file_exists($download_url) ? filesize($download_url) : 0, 4186 'error' => $e->getMessage() 3766 4187 ]); 3767 throw new \RuntimeException('Failed to open backup file ');4188 throw new \RuntimeException('Failed to open backup file: ' . $e->getMessage()); 3768 4189 } 3769 4190 3770 4191 // Extract both database and files zips to temp directory 3771 $zip->extractTo($temp_dir); 4192 $extract_ok = $zip->extractTo($temp_dir); 4193 if ($extract_ok === false) { 4194 $zip->close(); 4195 throw new \RuntimeException('Failed to extract backup file to temp directory'); 4196 } 3772 4197 3773 4198 // Debug: List all files in the extracted directory … … 4160 4585 } 4161 4586 4587 // Extra diagnostics: log when copying a zero-byte file from restore directory 4588 $source_size = @filesize($source_path); 4589 if ($source_size === 0) { 4590 $this->logger->warning('Copying zero-byte file from restore directory', [ 4591 'source' => $source_path, 4592 'destination' => $destination_path 4593 ]); 4594 } 4595 4162 4596 // Use WP_Filesystem to copy file 4163 4597 if ($wp_filesystem->copy($source_path, $destination_path, true)) { … … 4274 4708 } 4275 4709 4276 // If we have restore info but not completed, return current status 4710 $completed_fallback = $this->get_completed_restore_response_if_stale($backup_id, $restore_info, $restore_type, $is_classic_restore); 4711 if ($completed_fallback !== null) { 4712 return $completed_fallback; 4713 } 4714 4715 // Return current in-progress status 4277 4716 if ($restore_info) { 4278 4717 $response = [ … … 4338 4777 ]; 4339 4778 } 4779 } 4780 4781 /** 4782 * If restore actually completed but option was overwritten (e.g. by SQL import), return completed response. 4783 * Checks webhook_sent (all scopes) and state file so frontend sees completion when option is stale. 4784 * 4785 * @param string $backup_id Backup ID 4786 * @param array|null $restore_info Current option value (may be stale) 4787 * @param string $restore_type Type from option or request 4788 * @param bool $is_classic_restore Whether this is classic (not incremental) restore 4789 * @return array|null Completed response array or null if no completion evidence 4790 */ 4791 private function get_completed_restore_response_if_stale(?string $backup_id, $restore_info, string $restore_type, bool $is_classic_restore): ?array 4792 { 4793 if (!$backup_id) { 4794 return null; 4795 } 4796 $webhook_check = $this->check_restore_completion_webhook($backup_id, null); 4797 if ($webhook_check['found']) { 4798 $this->logger->debug('Restore completion confirmed via webhook_sent fallback', [ 4799 'backup_id' => $backup_id, 4800 'scope' => $webhook_check['scope'] 4801 ]); 4802 $fallback_scope = $webhook_check['scope']; 4803 $response = [ 4804 'status' => 'completed', 4805 'progress' => 100, 4806 'message' => 'Restore completed successfully', 4807 'backup_id' => $backup_id, 4808 'completed_at' => time(), 4809 'operation_type' => 'restore', 4810 'is_restore' => true, 4811 'type' => $restore_type, 4812 'scope' => $fallback_scope 4813 ]; 4814 if ($is_classic_restore && $restore_info) { 4815 $response['classic_restore'] = $this->get_classic_restore_progress( 4816 $backup_id, 4817 array_merge($restore_info ?: [], ['status' => 'completed', 'progress' => 100, 'scope' => $fallback_scope]), 4818 $restore_type 4819 ); 4820 } 4821 return $response; 4822 } 4823 if ($is_classic_restore) { 4824 $last_state = $this->get_last_classic_restore_state_from_file($backup_id); 4825 if ($last_state !== null && isset($last_state['status']) && $last_state['status'] === 'completed') { 4826 $this->logger->debug('Restore completion confirmed via state file (option was stale)', [ 4827 'backup_id' => $backup_id 4828 ]); 4829 return $last_state; 4830 } 4831 } 4832 return null; 4340 4833 } 4341 4834 … … 4813 5306 throw new \RuntimeException('Missing required parameters: backup_id and type are required'); 4814 5307 } 5308 5309 // User explicitly started a new restore: clear completion state so this run proceeds. 5310 // Otherwise DB records (webhook_sent, state file, option "completed") would make the cron skip or frontend see "completed". 5311 $this->clear_restore_completion_state_for_retry($backup_id); 5312 5313 // Reset restore option so is_restore_finalized and progress API don't return "completed" during this run. 5314 $this->update_restore_status($backup_id, [ 5315 'status' => 'downloading', 5316 'progress' => 10, 5317 'current_operation' => 'Preparing download', 5318 'scope' => $type 5319 ]); 4815 5320 4816 5321 // Create restore directory - extract clean filename from URL … … 5609 6114 } 5610 6115 5611 // Clear notification flag if starting a new restore (status changing from completed/failed to a new status) 6116 // Clear notification flag if starting a new restore (status changing from completed/failed to a new status). 6117 // Do NOT clear when new_status is 'cleanup' - cleanup is a phase of the same restore, not a new one. 6118 // Clearing here would remove the webhook_sent key and break front-end completion detection. 5612 6119 $old_status = $restore_info['status'] ?? ''; 5613 6120 $new_status = $status_update['status'] ?? $old_status; 5614 6121 if (in_array($old_status, ['completed', 'failed'], true) && 5615 6122 !in_array($new_status, ['completed', 'failed'], true) && 5616 $new_status !== $old_status) { 6123 $new_status !== $old_status && 6124 $new_status !== 'cleanup') { 5617 6125 // New restore starting - clear the notification flags 5618 6126 // Clear the old notification sent key (for backward compatibility) … … 5955 6463 } 5956 6464 5957 // Clean up ALL backup info records that are restore-related 6465 // Clean up backup info records for THIS manifest only (never global wildcards) 6466 $backup_info_like = SITESKITE_OPTION_PREFIX . 'backup_' . $manifestId . '%'; 5958 6467 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching 5959 $result = $wpdb->query( 5960 "DELETE FROM {$wpdb->options} WHERE option_name LIKE 'siteskite_backup_siteskite_backup_%'" 5961 ); 6468 $result = $wpdb->query($wpdb->prepare( 6469 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s", 6470 $backup_info_like 6471 )); 5962 6472 if ($result !== false && $result > 0) { 5963 6473 $deleted_count += $result; 5964 6474 } 5965 6475 5966 // Clean up restore progress and provider status records 5967 $ patterns = [5968 SITESKITE_OPTION_PREFIX . 'progress_ siteskite_backup_%',5969 SITESKITE_OPTION_PREFIX . 'incremental_progress_ siteskite_backup_%',5970 SITESKITE_OPTION_PREFIX . 'dropbox_status_ siteskite_backup_%',5971 SITESKITE_OPTION_PREFIX . 'gdrive_status_ siteskite_backup_%',5972 SITESKITE_OPTION_PREFIX . 'backblaze_b2_status_ siteskite_backup_%',5973 SITESKITE_OPTION_PREFIX . 'pcloud_status_ siteskite_backup_%',5974 SITESKITE_OPTION_PREFIX . 'aws_status_ siteskite_backup_%',5975 SITESKITE_OPTION_PREFIX . 'full_backup_lock_ siteskite_backup_%',5976 SITESKITE_OPTION_PREFIX . 'last_stage_ siteskite_backup_%',6476 // Clean up restore progress and provider status records for THIS manifest only 6477 $manifest_patterns = [ 6478 SITESKITE_OPTION_PREFIX . 'progress_' . $manifestId . '%', 6479 SITESKITE_OPTION_PREFIX . 'incremental_progress_' . $manifestId . '%', 6480 SITESKITE_OPTION_PREFIX . 'dropbox_status_' . $manifestId . '%', 6481 SITESKITE_OPTION_PREFIX . 'gdrive_status_' . $manifestId . '%', 6482 SITESKITE_OPTION_PREFIX . 'backblaze_b2_status_' . $manifestId . '%', 6483 SITESKITE_OPTION_PREFIX . 'pcloud_status_' . $manifestId . '%', 6484 SITESKITE_OPTION_PREFIX . 'aws_status_' . $manifestId . '%', 6485 SITESKITE_OPTION_PREFIX . 'full_backup_lock_' . $manifestId . '%', 6486 SITESKITE_OPTION_PREFIX . 'last_stage_' . $manifestId . '%', 5977 6487 ]; 5978 6488 5979 foreach ($ patterns as $pattern) {6489 foreach ($manifest_patterns as $pattern) { 5980 6490 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching 5981 6491 $result = $wpdb->query($wpdb->prepare( … … 6152 6662 6153 6663 /** 6664 * Read option value directly from DB (bypasses object cache). 6665 * Use when we must see the value set by another request (e.g. duplicate cron after first run completed). 6666 * 6667 * @param string $option_name Option name 6668 * @return string|false Option value or false if not found 6669 */ 6670 private function get_option_from_db(string $option_name) 6671 { 6672 global $wpdb; 6673 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching 6674 $value = $wpdb->get_var($wpdb->prepare( 6675 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1", 6676 $option_name 6677 )); 6678 return $value !== null ? $value : false; 6679 } 6680 6681 /** 6682 * Check if restore is finalized (completed or cleanup) using multiple sources of truth. 6683 * Use this to skip duplicate cron: DB import can overwrite wp_options, so we also check 6684 * webhook_sent (written after import) and the filesystem state file (immune to DB overwrite). 6685 * 6686 * @param string $backup_id Backup ID 6687 * @param string $scope Scope: 'database', 'files', or 'full' 6688 * @return bool True if restore is already finalized for this backup_id/scope 6689 */ 6690 private function is_restore_finalized(string $backup_id, string $scope): bool 6691 { 6692 $status_key = self::OPTION_PREFIX . $backup_id; 6693 $status_raw = $this->get_option_from_db($status_key); 6694 if ($status_raw !== false) { 6695 $info = @unserialize($status_raw); 6696 if (is_array($info) && !empty($info['status']) && in_array($info['status'], ['completed', 'cleanup'], true)) { 6697 $stored_scope = $info['scope'] ?? ''; 6698 if ($stored_scope === $scope || $scope === 'full' || $stored_scope === 'full') { 6699 return true; 6700 } 6701 } 6702 } 6703 6704 $webhook_key = 'siteskite_restore_webhook_sent_' . $backup_id . '_' . $scope; 6705 $webhook_val = $this->get_option_from_db($webhook_key); 6706 if ($webhook_val !== false && $webhook_val !== '' && is_numeric($webhook_val) && (int) $webhook_val > 0) { 6707 return true; 6708 } 6709 6710 $last = $this->get_last_classic_restore_state_from_file($backup_id); 6711 if (is_array($last) && !empty($last['status']) && $last['status'] === 'completed') { 6712 $file_scope = $last['scope'] ?? ''; 6713 if ($file_scope === $scope || $scope === 'full' || $file_scope === 'full') { 6714 return true; 6715 } 6716 } 6717 6718 // Database phase already finished (all queries processed) but status not yet "completed" 6719 // (e.g. option overwritten by SQL import before final update). Skip second attempt. 6720 if (($scope === 'database' || $scope === 'full') && $status_raw !== false) { 6721 $info = @unserialize($status_raw); 6722 if (is_array($info)) { 6723 $processed = (int)($info['db_queries_processed'] ?? 0); 6724 $total = (int)($info['db_total_queries'] ?? 0); 6725 if ($total > 0 && $processed >= $total) { 6726 return true; 6727 } 6728 } 6729 } 6730 6731 return false; 6732 } 6733 6734 /** 6735 * Path for the per-backup_id+scope running lock file (filesystem, not overwritten by DB import). 6736 * 6737 * @param string $backup_id Backup ID 6738 * @param string $scope Scope: 'database', 'files', or 'full' 6739 * @return string Lock file path 6740 */ 6741 private function get_restore_running_lock_path(string $backup_id, string $scope): string 6742 { 6743 $safe = preg_replace('/[^a-zA-Z0-9_\-]/', '_', $backup_id); 6744 return $this->restore_dir . '/.restore_running_' . $safe . '_' . $scope . '.lock'; 6745 } 6746 6747 /** 6748 * Check if a restore is already in progress for this backup_id+scope (lock file exists and is recent). 6749 * 6750 * @param string $backup_id Backup ID 6751 * @param string $scope Scope: 'database', 'files', or 'full' 6752 * @param int $max_age_seconds Lock older than this is ignored (default 90 minutes) 6753 * @return bool True if another run is in progress 6754 */ 6755 private function is_restore_running_lock_active(string $backup_id, string $scope, int $max_age_seconds = 5400): bool 6756 { 6757 $path = $this->get_restore_running_lock_path($backup_id, $scope); 6758 if (!file_exists($path)) { 6759 return false; 6760 } 6761 $mtime = (int) @filemtime($path); 6762 return $mtime > 0 && (time() - $mtime) < $max_age_seconds; 6763 } 6764 6765 /** 6766 * Set the running lock so duplicate process_restore calls skip. 6767 * 6768 * @param string $backup_id Backup ID 6769 * @param string $scope Scope: 'database', 'files', or 'full' 6770 */ 6771 private function set_restore_running_lock(string $backup_id, string $scope): void 6772 { 6773 $path = $this->get_restore_running_lock_path($backup_id, $scope); 6774 if (function_exists('wp_mkdir_p')) { 6775 wp_mkdir_p(dirname($path)); 6776 } 6777 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents 6778 @file_put_contents($path, (string) time()); 6779 } 6780 6781 /** 6782 * Clear the running lock after restore finishes (success or failure). 6783 * 6784 * @param string $backup_id Backup ID 6785 * @param string $scope Scope: 'database', 'files', or 'full' 6786 */ 6787 private function clear_restore_running_lock(string $backup_id, string $scope): void 6788 { 6789 $path = $this->get_restore_running_lock_path($backup_id, $scope); 6790 if (file_exists($path)) { 6791 wp_delete_file($path); 6792 } 6793 } 6794 6795 /** 6796 * Prepare for a classic restore run: set running lock and clear webhook_sent so notifications can be sent again. 6797 * 6798 * @param string $backup_id Backup ID 6799 * @param string $scope Scope: 'database', 'files', or 'full' 6800 */ 6801 private function prepare_classic_restore_run(string $backup_id, string $scope): void 6802 { 6803 $this->set_restore_running_lock($backup_id, $scope); 6804 foreach (['files', 'database', 'full'] as $s) { 6805 delete_option('siteskite_restore_webhook_sent_' . $backup_id . '_' . $s); 6806 } 6807 } 6808 6809 /** 6810 * Prepare for an incremental restore run: clear webhook_sent for this backup/scope so the restore runs 6811 * and notifications can be sent. Without this, a previous run's webhook record can make the restore 6812 * appear "already completed" and interrupt files-only (and database-only) incremental restores. 6813 * 6814 * @param string $manifestId Manifest ID (e.g. siteskite_backup_6981efd23f312_incremental) 6815 * @param string $scope Scope: 'database', 'files', or 'full' 6816 */ 6817 private function prepare_incremental_restore_run(string $manifestId, string $scope): void 6818 { 6819 $backup_id = $this->extract_backup_id_from_manifest_id($manifestId) ?? $manifestId; 6820 $scopes_to_clear = ($scope === 'full') ? ['files', 'database', 'full'] : [$scope]; 6821 foreach ($scopes_to_clear as $s) { 6822 delete_option('siteskite_restore_webhook_sent_' . $backup_id . '_' . $s); 6823 } 6824 if (function_exists('wp_cache_delete')) { 6825 foreach ($scopes_to_clear as $s) { 6826 wp_cache_delete('siteskite_restore_webhook_sent_' . $backup_id . '_' . $s, 'options'); 6827 } 6828 } 6829 } 6830 6831 /** 6832 * Finish a classic restore run: clear running lock, restore_in_progress option, and restore guard mu-plugin. 6833 * 6834 * @param string $backup_id Backup ID 6835 * @param string $scope Scope: 'database', 'files', or 'full' 6836 */ 6837 private function finish_classic_restore_run(string $backup_id, string $scope): void 6838 { 6839 $this->clear_restore_in_progress_and_guard(); 6840 $this->clear_restore_running_lock($backup_id, $scope); 6841 } 6842 6843 /** 6844 * Clear restore_in_progress option and remove the restore guard mu-plugin so plugins load again. 6845 */ 6846 private function clear_restore_in_progress_and_guard(): void 6847 { 6848 $this->clear_recovery_and_restore_state(); 6849 } 6850 6851 /** 6852 * Clear recovery mode (flag file) and any restore-in-progress state (options + restore guard mu-plugin). 6853 * Use this when the user wants to disable recovery / go back to normal without completing a restore. 6854 * Safe to call from REST or elsewhere; idempotent. 6855 */ 6856 public function clear_recovery_and_restore_state(): void 6857 { 6858 delete_option(SITESKITE_OPTION_PREFIX . 'restore_in_progress'); 6859 delete_option('siteskite_restore_in_progress'); 6860 $this->remove_restore_guard_mu_plugin(); 6861 \SiteSkite\Core\SiteSkiteSafetyCore::clear_recovery_mode(); 6862 } 6863 6864 /** 6865 * Path to the must-use plugin that prevents loading other plugins during restore. 6866 * Prevents fatals when DB lists active plugins whose files were deleted (e.g. test restore). 6867 * 6868 * @return string Absolute path to mu-plugin file 6869 */ 6870 private function get_restore_guard_mu_plugin_path(): string 6871 { 6872 $content_dir = defined('WP_CONTENT_DIR') ? WP_CONTENT_DIR : (dirname(__DIR__, 2) . '/wp-content'); 6873 return $content_dir . '/mu-plugins/siteskite-restore-guard.php'; 6874 } 6875 6876 /** 6877 * Create the restore guard must-use plugin so subsequent requests don't load plugins during restore. 6878 * Prevents "Failed to open stream" fatals when active_plugins lists plugins that aren't on disk yet. 6879 */ 6880 private function ensure_restore_guard_mu_plugin(): void 6881 { 6882 $path = $this->get_restore_guard_mu_plugin_path(); 6883 $dir = dirname($path); 6884 if (!is_dir($dir) && function_exists('wp_mkdir_p')) { 6885 wp_mkdir_p($dir); 6886 } 6887 if (!is_writable($dir) && file_exists($path)) { 6888 return; 6889 } 6890 $siteskite_basename = defined('SITESKITE_FILE') && function_exists('plugin_basename') 6891 ? plugin_basename(SITESKITE_FILE) 6892 : 'siteskite/siteskite-link.php'; 6893 $content = <<<PHP 6894 <?php 6895 // SiteSkite restore guard: during restore only load SiteSkite so missing plugin files do not cause fatals. 6896 if (!defined('ABSPATH')) { 6897 return; 6898 } 6899 \$only_siteskite = ['{$siteskite_basename}']; 6900 add_filter('pre_option_active_plugins', function (\$pre, \$option, \$default) use (\$only_siteskite) { 6901 return get_option('siteskite_restore_in_progress') ? \$only_siteskite : \$pre; 6902 }, 1, 3); 6903 add_filter('pre_site_option_active_sitewide_plugins', function (\$pre, \$option, \$default) use (\$only_siteskite) { 6904 if (!get_option('siteskite_restore_in_progress')) { 6905 return \$pre; 6906 } 6907 return \$only_siteskite ? [ \$only_siteskite[0] => time() ] : \$pre; 6908 }, 1, 3); 6909 PHP; 6910 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents 6911 @file_put_contents($path, $content); 6912 } 6913 6914 /** 6915 * Remove the restore guard must-use plugin so plugins load normally again. 6916 */ 6917 private function remove_restore_guard_mu_plugin(): void 6918 { 6919 $path = $this->get_restore_guard_mu_plugin_path(); 6920 if (file_exists($path) && is_writable($path)) { 6921 wp_delete_file($path); 6922 } 6923 } 6924 6925 /** 6926 * Check if database restore file exists and is readable. 6927 * If file is missing and restore is already finalized, logs and returns false (caller should return). 6928 * 6929 * @param string $backup_id Backup ID 6930 * @param string $download_url Path to database zip 6931 * @return bool False if caller should return early (file missing and finalized); true if file is ok 6932 */ 6933 private function check_database_restore_file_readable(string $backup_id, string $download_url): bool 6934 { 6935 if (is_readable($download_url) && filesize($download_url)) { 6936 return true; 6937 } 6938 if ($this->is_restore_finalized($backup_id, 'database')) { 6939 $this->logger->info('Backup file no longer present but restore already finalized, skipping (duplicate cron)', [ 6940 'backup_id' => $backup_id 6941 ]); 6942 return false; 6943 } 6944 return true; 6945 } 6946 6947 /** 6948 * Persist database restore completed state (option cache clear + full payload). 6949 * Survives SQL import overwriting the option by writing a complete payload. 6950 * 6951 * @param string $backup_id Backup ID 6952 * @param int $total_queries Total queries executed 6953 * @param bool $suppress_notification Whether to suppress completion notification 6954 */ 6955 private function persist_database_restore_completed(string $backup_id, int $total_queries, bool $suppress_notification = false): void 6956 { 6957 $option_key = self::OPTION_PREFIX . $backup_id; 6958 if (function_exists('wp_cache_delete')) { 6959 wp_cache_delete($option_key, 'options'); 6960 } 6961 $this->update_restore_status($backup_id, [ 6962 'status' => 'completed', 6963 'progress' => 100, 6964 'completed_at' => time(), 6965 'message' => 'Database restore completed successfully', 6966 'current_operation' => 'Database restore completed successfully', 6967 'scope' => 'database', 6968 'db_total_queries' => $total_queries, 6969 'db_queries_processed' => $total_queries, 6970 'db_restore_complete' => true 6971 ], $suppress_notification); 6972 } 6973 6974 /** 6975 * Build and save completed classic restore state to file (for progress API fallback when option is overwritten). 6976 * 6977 * @param string $backup_id Backup ID 6978 * @param string $type Restore type (database, files, full) 6979 */ 6980 private function build_and_save_completed_restore_state_file(string $backup_id, string $type): void 6981 { 6982 $final_restore_info = get_option(self::OPTION_PREFIX . $backup_id, []); 6983 $final_restore_type = $final_restore_info['type'] ?? $type; 6984 $final_scope = $final_restore_info['scope'] ?? $type; 6985 $final_progress_data = [ 6986 'status' => 'completed', 6987 'progress' => 100, 6988 'message' => $final_restore_info['message'] ?? 'Restore completed successfully', 6989 'backup_id' => $backup_id, 6990 'completed_at' => $final_restore_info['completed_at'] ?? time(), 6991 'operation_type' => 'restore', 6992 'is_restore' => true, 6993 'type' => $final_restore_type, 6994 'scope' => $final_scope 6995 ]; 6996 $classic_restore_progress = $this->get_classic_restore_progress( 6997 $backup_id, 6998 array_merge($final_restore_info ?: [], ['status' => 'completed', 'progress' => 100, 'scope' => $final_scope]), 6999 $final_restore_type 7000 ); 7001 $final_progress_data['classic_restore'] = $classic_restore_progress; 7002 if (isset($final_progress_data['classic_restore']['database'])) { 7003 $final_progress_data['classic_restore']['database']['status'] = 'completed'; 7004 $final_progress_data['classic_restore']['database']['complete'] = true; 7005 $final_progress_data['classic_restore']['database']['progress_percent'] = 100.0; 7006 } 7007 if (isset($final_progress_data['classic_restore']['files'])) { 7008 $final_progress_data['classic_restore']['files']['status'] = 'completed'; 7009 $final_progress_data['classic_restore']['files']['complete'] = true; 7010 $final_progress_data['classic_restore']['files']['progress_percent'] = 100.0; 7011 } 7012 if (isset($final_progress_data['classic_restore']['full'])) { 7013 $final_progress_data['classic_restore']['full']['status'] = 'completed'; 7014 $final_progress_data['classic_restore']['full']['complete'] = true; 7015 $final_progress_data['classic_restore']['full']['overall_progress'] = 100.0; 7016 } 7017 $this->save_last_classic_restore_state_to_file($backup_id, $final_progress_data); 7018 } 7019 7020 /** 6154 7021 * Check if restore was completed by checking webhook sent flags 6155 7022 * 6156 7023 * @param string $backup_id Backup ID (not manifest ID) 7024 * @param bool $from_db If true, read webhook_sent from DB to bypass cache (use at start of process_restore) 6157 7025 * @return array{found: bool, scope: string|null} Whether webhook was found and the scope 6158 7026 */ 6159 private function check_restore_completion_webhook(string $backup_id, ?string $scope = null ): array7027 private function check_restore_completion_webhook(string $backup_id, ?string $scope = null, bool $from_db = false): array 6160 7028 { 6161 7029 // If scope is specified, only check that scope … … 6171 7039 $webhook_sent_key = 'siteskite_restore_webhook_sent_' . $backup_id . '_' . $check_scope; 6172 7040 6173 // Clear options cache to ensure we get the latest value 6174 if (function_exists('wp_cache_delete')) { 6175 wp_cache_delete($webhook_sent_key, 'options'); 6176 } 6177 6178 // Check if webhook was sent (value is a timestamp, so it should be numeric > 0) 6179 $webhook_value = get_option($webhook_sent_key, false); 7041 if ($from_db) { 7042 $webhook_value = $this->get_option_from_db($webhook_sent_key); 7043 } else { 7044 if (function_exists('wp_cache_delete')) { 7045 wp_cache_delete($webhook_sent_key, 'options'); 7046 } 7047 $webhook_value = get_option($webhook_sent_key, false); 7048 } 6180 7049 if ($webhook_value !== false && $webhook_value !== '' && is_numeric($webhook_value) && $webhook_value > 0) { 6181 7050 return ['found' => true, 'scope' => $check_scope]; … … 6195 7064 private function check_restore_completion_status(string $manifestId, ?string $scope = null): array 6196 7065 { 7066 // Clear options cache so we read from DB (avoids stale cache after SQL import or duplicate cron) 7067 $option_key = self::OPTION_PREFIX . $manifestId; 7068 if (function_exists('wp_cache_delete')) { 7069 wp_cache_delete($option_key, 'options'); 7070 } 7071 $backup_id = $this->extract_backup_id_from_manifest_id($manifestId) ?? $manifestId; 7072 if ($scope) { 7073 $webhook_key = 'siteskite_restore_webhook_sent_' . $backup_id . '_' . $scope; 7074 if (function_exists('wp_cache_delete')) { 7075 wp_cache_delete($webhook_key, 'options'); 7076 } 7077 } else { 7078 foreach (['database', 'files', 'full'] as $s) { 7079 if (function_exists('wp_cache_delete')) { 7080 wp_cache_delete('siteskite_restore_webhook_sent_' . $backup_id . '_' . $s, 'options'); 7081 } 7082 } 7083 } 7084 6197 7085 // Check restore status first 6198 $existing_status = get_option( self::OPTION_PREFIX . $manifestId);7086 $existing_status = get_option($option_key); 6199 7087 if (!empty($existing_status) && is_array($existing_status) && isset($existing_status['status'])) { 6200 7088 if ($existing_status['status'] === 'completed') { … … 6215 7103 } 6216 7104 6217 // Check webhook sent flags (scope-aware) 6218 $backup_id = $this->extract_backup_id_from_manifest_id($manifestId) ?? $manifestId;6219 $webhook_check = $this->check_restore_completion_webhook($backup_id, $scope );7105 // Check webhook sent flags (scope-aware) - backup_id already set above. 7106 // Read from DB so we see value set by a previous request (e.g. duplicate cron after first run completed). 7107 $webhook_check = $this->check_restore_completion_webhook($backup_id, $scope, true); 6220 7108 6221 7109 if ($webhook_check['found']) { … … 8024 8912 } 8025 8913 8914 $code = (int) wp_remote_retrieve_response_code($chunk_response); 8915 if ($code < 200 || $code >= 300) { 8916 $this->logger->warning('Chunk download returned non-2xx', [ 8917 'chunk_hash' => substr($chunk_hash, 0, 16) . '...', 8918 'http_code' => $code, 8919 'file_path' => $file_path 8920 ]); 8921 continue; 8922 } 8923 8026 8924 $chunk_data = wp_remote_retrieve_body($chunk_response); 8925 $chunk_data = is_string($chunk_data) ? $chunk_data : ''; 8926 if ($chunk_data === '') { 8927 $this->logger->warning('Chunk body empty (hosting may strip or buffer response)', [ 8928 'chunk_hash' => substr($chunk_hash, 0, 16) . '...', 8929 'file_path' => $file_path, 8930 'http_code' => $code 8931 ]); 8932 continue; 8933 } 8934 8027 8935 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- Required for file chunk streaming 8028 fwrite($file_handle, $chunk_data); 8936 $written = fwrite($file_handle, $chunk_data); 8937 if ($written !== strlen($chunk_data)) { 8938 $this->logger->warning('fwrite wrote fewer bytes than chunk (hosting disk/limits?)', [ 8939 'expected' => strlen($chunk_data), 8940 'written' => $written, 8941 'file_path' => $file_path 8942 ]); 8943 } 8029 8944 } 8030 8945 8946 // Flush so data is on disk before close (hosting compatibility) 8947 if (is_resource($file_handle)) { 8948 fflush($file_handle); 8949 } 8031 8950 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Required for file chunk streaming 8032 8951 fclose($file_handle); 8033 8952 8953 // Extra diagnostics: log reconstructed file size and chunk coverage 8954 $size_on_disk = file_exists($target_file) ? filesize($target_file) : null; 8955 $this->logger->info('Reconstructed file from chunks with provider', [ 8956 'backup_id' => $backup_id, 8957 'provider' => $provider, 8958 'file_path' => $file_path, 8959 'target_file' => $target_file, 8960 'chunks_expected' => count($chunk_hashes), 8961 'chunks_downloaded' => count(array_intersect($chunk_hashes, array_keys($downloaded_chunks))), 8962 'size_on_disk' => $size_on_disk, 8963 ]); 8964 8034 8965 // Set file modification time 8035 8966 if (isset($file_info['mtime'])) { … … 8302 9233 } 8303 9234 9235 $chunk_data = $downloaded_chunks[$chunk_hash]; 8304 9236 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- Required for file chunk streaming 8305 fwrite($file_handle, $downloaded_chunks[$chunk_hash]); 9237 $written = fwrite($file_handle, $chunk_data); 9238 if ($written !== strlen($chunk_data)) { 9239 $this->logger->warning('fwrite wrote fewer bytes than chunk (provider)', [ 9240 'expected' => strlen($chunk_data), 9241 'written' => $written, 9242 'file_path' => $file_path 9243 ]); 9244 } 8306 9245 } 8307 9246 9247 if (is_resource($file_handle)) { 9248 fflush($file_handle); 9249 } 8308 9250 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Required for file chunk streaming 8309 9251 fclose($file_handle); 8310 9252 9253 // Extra diagnostics: log reconstructed adaptive file size and chunk coverage 9254 $size_on_disk = file_exists($target_file) ? filesize($target_file) : null; 9255 $this->logger->info('Reconstructed adaptive file with provider', [ 9256 'provider' => $provider, 9257 'file_path' => $file_path, 9258 'target_file' => $target_file, 9259 'chunks_expected' => count($chunk_hashes), 9260 'chunks_downloaded' => count(array_intersect($chunk_hashes, array_keys($downloaded_chunks))), 9261 'size_on_disk' => $size_on_disk, 9262 ]); 9263 8311 9264 // Set file modification time 8312 9265 $file_info = $filtered_manifest['files'][$file_path] ?? []; … … 8455 9408 foreach ($chunk_hashes as $chunk_hash) { 8456 9409 if (isset($downloaded_chunks[$chunk_hash])) { 9410 $chunk_data = $downloaded_chunks[$chunk_hash]; 8457 9411 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- Required for file chunk streaming 8458 fwrite($file_handle, $downloaded_chunks[$chunk_hash]); 9412 $written = fwrite($file_handle, $chunk_data); 9413 if ($written !== strlen($chunk_data)) { 9414 $this->logger->warning('fwrite wrote fewer bytes than chunk (adaptive provider)', [ 9415 'expected' => strlen($chunk_data), 9416 'written' => $written, 9417 'file_path' => $file_path 9418 ]); 9419 } 8459 9420 } 8460 9421 } 8461 9422 9423 if (is_resource($file_handle)) { 9424 fflush($file_handle); 9425 } 8462 9426 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Required for file chunk streaming 8463 9427 fclose($file_handle); … … 8540 9504 } 8541 9505 9506 $code = (int) wp_remote_retrieve_response_code($chunk_response); 9507 if ($code < 200 || $code >= 300) { 9508 $this->logger->warning('Chunk download returned non-2xx (adaptive)', [ 9509 'chunk_hash' => substr($chunk_hash, 0, 16) . '...', 9510 'http_code' => $code, 9511 'file_path' => $file_path 9512 ]); 9513 continue; 9514 } 9515 8542 9516 $chunk_data = wp_remote_retrieve_body($chunk_response); 9517 $chunk_data = is_string($chunk_data) ? $chunk_data : ''; 9518 if ($chunk_data === '') { 9519 $this->logger->warning('Chunk body empty (adaptive)', [ 9520 'chunk_hash' => substr($chunk_hash, 0, 16) . '...', 9521 'file_path' => $file_path 9522 ]); 9523 continue; 9524 } 9525 8543 9526 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- Required for file chunk streaming 8544 fwrite($file_handle, $chunk_data); 8545 } 8546 9527 $written = fwrite($file_handle, $chunk_data); 9528 if ($written !== strlen($chunk_data)) { 9529 $this->logger->warning('fwrite wrote fewer bytes than chunk (adaptive)', [ 9530 'expected' => strlen($chunk_data), 9531 'written' => $written, 9532 'file_path' => $file_path 9533 ]); 9534 } 9535 } 9536 9537 // Flush so data is on disk before close (hosting compatibility) 9538 if (is_resource($file_handle)) { 9539 fflush($file_handle); 9540 } 8547 9541 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Required for file chunk streaming 8548 9542 fclose($file_handle); … … 8614 9608 file_put_contents($temp_archive, $archive_data); 8615 9609 8616 // Extract archive 8617 $zip = new \ZipArchive(); 8618 if ($zip->open($temp_archive) === TRUE) { 8619 $zip->extractTo($restore_dir); 9610 // Extract archive (ZipArchive or PclZip fallback) 9611 try { 9612 $zip = $this->open_zip_for_restore($temp_archive); 9613 $num_files = $zip->numFiles; 9614 $extract_ok = $zip->extractTo($restore_dir); 8620 9615 $zip->close(); 8621 8622 $this->logger->info('Extracted batch archive', [ 8623 'batch_id' => $batch_id, 8624 'files_extracted' => $zip->numFiles 8625 ]); 8626 } else { 9616 if ($extract_ok !== false) { 9617 $this->logger->info('Extracted batch archive', [ 9618 'batch_id' => $batch_id, 9619 'files_extracted' => $num_files 9620 ]); 9621 } else { 9622 $this->logger->error('Failed to extract batch archive', [ 9623 'batch_id' => $batch_id, 9624 'temp_archive' => $temp_archive 9625 ]); 9626 } 9627 } catch (\RuntimeException $e) { 8627 9628 $this->logger->error('Failed to open batch archive', [ 8628 9629 'batch_id' => $batch_id, 8629 'temp_archive' => $temp_archive 9630 'temp_archive' => $temp_archive, 9631 'error' => $e->getMessage() 8630 9632 ]); 8631 9633 } … … 8654 9656 private function is_safe_restore_query(string $query): bool 8655 9657 { 8656 // Remove comments and normalize whitespace9658 // Remove line comments (-- to EOL) 8657 9659 $query = preg_replace('/--.*$/m', '', $query); 9660 $query = trim($query); 9661 9662 // MySQL conditional comments /*!40101 SET ... */: extract inner part so we can allow safe SET/options 9663 while (preg_match('/^\s*\/\*![\d]*\s*(.*?)\*\//s', $query, $m)) { 9664 $inner = trim($m[1]); 9665 $query = $inner . substr($query, strlen($m[0])); 9666 $query = trim($query); 9667 } 9668 9669 // Remove remaining block comments (non-conditional) 8658 9670 $query = preg_replace('/\/\*.*?\*\//s', '', $query); 8659 9671 $query = trim($query); 8660 9672 8661 9673 if (empty($query)) { 8662 9674 return false; … … 8669 9681 $allowed_operations = [ 8670 9682 'CREATE TABLE', 8671 'DROP TABLE ',9683 'DROP TABLE IF EXISTS', 8672 9684 'INSERT IGNORE INTO', 8673 9685 'INSERT INTO', 8674 9686 'UPDATE ', 8675 'DELETE FROM',8676 'ALTER TABLE',8677 9687 'CREATE INDEX', 8678 'DROP INDEX',8679 9688 'LOCK TABLES', 8680 9689 'UNLOCK TABLES', … … 8705 9714 private function validate_query_security(string $query): bool 8706 9715 { 8707 // Block potentially dangerous operations 9716 // Only check dangerous patterns in the statement header (first ~400 chars). 9717 // INSERT/UPDATE data can contain words like TRUNCATE or RENAME; we must not reject based on payload. 9718 $header_len = 400; 9719 $query_header = strlen($query) > $header_len ? substr($query, 0, $header_len) : $query; 9720 8708 9721 $dangerous_patterns = [ 8709 9722 '/\bDROP\s+DATABASE\b/i', … … 8723 9736 8724 9737 foreach ($dangerous_patterns as $pattern) { 8725 if (preg_match($pattern, $query )) {9738 if (preg_match($pattern, $query_header)) { 8726 9739 return false; 8727 9740 } 8728 9741 } 8729 9742 8730 // Block queries that try to access system tables 9743 // Block queries that try to access system tables (check full query) 8731 9744 $system_tables = [ 8732 9745 'mysql.', … … 8973 9986 8974 9987 /** 9988 * Clear completion state for a backup_id so a user-initiated second restore can run. 9989 * Call this when the user explicitly starts a new restore (e.g. "Restore" again). 9990 * Clears webhook_sent, state file, and running lock so is_restore_finalized is false and progress doesn't return "completed". 9991 * 9992 * @param string $backup_id Backup ID 9993 */ 9994 public function clear_restore_completion_state_for_retry(string $backup_id): void 9995 { 9996 $scopes = ['files', 'database', 'full']; 9997 foreach ($scopes as $scope) { 9998 $webhook_sent_key = 'siteskite_restore_webhook_sent_' . $backup_id . '_' . $scope; 9999 delete_option($webhook_sent_key); 10000 $this->clear_restore_running_lock($backup_id, $scope); 10001 } 10002 $this->remove_last_classic_restore_state_from_file($backup_id); 10003 $this->logger->info('Cleared restore completion state for retry', [ 10004 'backup_id' => $backup_id 10005 ]); 10006 } 10007 10008 /** 8975 10009 * Remove last classic restore state from JSON file 8976 10010 * Similar to remove_last_restore_state_from_file but for classic restores (uses backup_id instead of manifestId) -
siteskite/trunk/languages/siteskite.pot
r3446096 r3453079 2 2 msgid "" 3 3 msgstr "" 4 "Project-Id-Version: SiteSkite Link 1.2. 3\n"4 "Project-Id-Version: SiteSkite Link 1.2.5\n" 5 5 "Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/siteskite\n" 6 6 "POT-Creation-Date: 2024-01-01T00:00:00+00:00\n" -
siteskite/trunk/readme.txt
r3446096 r3453079 5 5 Tested up to: 6.9 6 6 Requires PHP: 7.4 7 Stable tag: 1.2. 37 Stable tag: 1.2.5 8 8 License: GPLv2 or later 9 9 License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 223 223 == Changelog == 224 224 225 = 1.2.3 (23 January 2026) = 226 * Improved: Backup performance 225 = 1.2.5 (03 February 2026) = 226 * New: Recovery Mode: Regain WordPress admin access after fatal errors or failed updates.” 227 * Improved: 1 Click WP Admin Login 228 * Improved: Site Connection Experience (RestAPI Fallback) 229 * Improved: Smart Cron Jobs 230 * Improved: Classic Backup & Restore 231 * Improved: Cache Busting 232 * Improved: Site Data Syncing 233 * Improved: Add new site connection experience 234 * Improved: Incremental Backup & Restore 235 * Fixed: Trigger new database when restore job done 236 * Fixed: Trigger new database when making files only backup 237 238 239 = 1.2.3 (25 January 2026) = 240 Improved: Backup performance 227 241 228 242 … … 257 271 258 272 = 1.0.7 (13 December 2025) = 259 * Added: Better Backup management273 * New: Better Backup management 260 274 * Improved: Backup performance 261 275 262 276 = 1.0.6 (10 December 2025) = 263 * Added: pCloud Auth based Authentication277 * New: pCloud Auth based Authentication 264 278 * Improved: Backup performance 265 279 … … 282 296 * Initial release 283 297 298 284 299 == Upgrade Notice == 285 300 286 = 1.0.9 = 287 * Fixed: Subdirectory site paths now preserved when adding domain 301 = 1.2.5 (03 February 2026) = 302 * New: Recovery Mode: Regain WordPress admin access after fatal errors or failed updates.” 303 * Improved: 1 Click WP Admin Login 304 * Improved: Site Connection Experience (RestAPI Fallback) 305 * Improved: Smart Cron Jobs 306 * Improved: Classic Backup & Restore 307 * Improved: Cache Busting 308 * Improved: Incremental Backup & Restore 309 * Improved: Incremental/Classic Backup & Restore DB records cleanup 310 * Fixed: Trigger new database when restore job done 311 * Fixed: Trigger new database when making files only backup. 312 313 = 1.2.3 (25 January 2026) = 314 Improved: Backup performance 315 316 317 = 1.2.2 (23 January 2026) = 318 * Improved: Backup PCLZIP Support 319 * Improved: Backup performance on caching servers 320 * Improved: Site Connection Experience 321 322 = 1.2.1 (22 January 2026) = 323 * Improved: Backup stability 324 325 326 = 1.2.0 (20 January 2026) = 327 * New: Create Sandbox site from existing backup 328 * New: Create Blueprints via Sandbox site 329 * Improved: Backup performance 330 * Improved: Site data sync 331 * Improved: UI/UX - Site thumbnail and logo 332 333 334 = 1.1.0 (1 January 2026) = 335 * Improvied: Classic & Incremental Backup performance 336 * New: Blueprints, Create reusable sandbox sites from blueprints. · https://knowledgebase.siteskite.com/articles/siteskite-blueprints 337 * New: Preety Error logs: Error log, Debug log, or Custom error log paths. 338 * Improvements: Overall SiteSkite platform performance. 339 340 = 1.0.9 (25 December 2025) = 341 * Fixed: Subdirectory site paths now preserved when adding domain (e.g., abc.com/newsite instead of abc.com) 288 342 289 343 = 1.0.8 (16 December 2025) = … … 291 345 292 346 = 1.0.7 (13 December 2025) = 293 * Added: Better Backup management347 * New: Better Backup management 294 348 * Improved: Backup performance 295 349 296 350 = 1.0.6 (10 December 2025) = 297 * Added: pCloud Auth based Authentication351 * New: pCloud Auth based Authentication 298 352 * Improved: Backup performance 299 353 … … 304 358 * Improved: Notifications 305 359 306 = 1.0.3 (1 5November 2025) =307 * Improved: SiteSkiteWPCanvas Improvements360 = 1.0.3 (18 November 2025) = 361 * Improved: WPCanvas Improvements 308 362 309 363 = 1.0.2 (15 November 2025) = 310 364 * Improved: Backup & Restore Service for CDN enabled sites 311 365 312 = 1.0.1 =366 = 1.0.1 (08 November 2025) = 313 367 * Admin view update 314 368 315 369 = 1.0.0 = 316 First release of SiteSkite Link. Connect your WordPress site to SiteSkite for backups, updates, monitoring, and more. 370 * Initial release -
siteskite/trunk/siteskite-link.php
r3446096 r3453079 4 4 * Plugin URI: https://siteskite.com 5 5 * Description: Link your WordPress site with SiteSkite for effortless updates, backups, monitoring, and maintenance—everything in one place. 6 * Version: 1.2. 36 * Version: 1.2.5 7 7 * Requires at least: 5.3 8 8 * Requires PHP: 7.4 … … 29 29 30 30 // Plugin version 31 define('SITESKITE_VERSION', '1.2. 3');31 define('SITESKITE_VERSION', '1.2.5'); 32 32 33 33 // Plugin file, path, and URL -
siteskite/trunk/uninstall.php
r3389896 r3453079 43 43 44 44 // Clear any rewrite rules 45 flush_rewrite_rules(); 45 flush_rewrite_rules(); 46 47 // Remove SiteSkite safety mu-plugin and wp-content/siteskite/ (recovery script, key, flag) 48 $content_dir = defined('WP_CONTENT_DIR') ? WP_CONTENT_DIR : dirname(dirname(__DIR__)); 49 $siteskite_dir = $content_dir . '/siteskite'; 50 $files = [ 51 $content_dir . '/mu-plugins/siteskite-plugin-safety.php', 52 $siteskite_dir . '/siteskite-recovery.php', 53 $siteskite_dir . '/siteskite-recovery-disable.php', 54 $siteskite_dir . '/siteskite-recovery-key.php', 55 $siteskite_dir . '/recovery.flag', 56 ]; 57 foreach ($files as $file) { 58 if (file_exists($file) && is_writable($file)) { 59 wp_delete_file($file); 60 } 61 } 62 if (is_dir($siteskite_dir) && @rmdir($siteskite_dir)) { 63 // Removed empty wp-content/siteskite/ 64 }
Note: See TracChangeset
for help on using the changeset viewer.