Plugin Directory

Changeset 3453079


Ignore:
Timestamp:
02/03/2026 04:52:49 PM (5 weeks ago)
Author:
siteskite
Message:

Release 1.2.5 - PclZip fallback for restore when ZipArchive unavailable

Location:
siteskite/trunk
Files:
2 added
14 edited

Legend:

Unmodified
Added
Removed
  • siteskite/trunk/includes/API/FallbackAPI.php

    r3445801 r3453079  
    1414use SiteSkite\Schedule\ScheduleManager;
    1515use SiteSkite\WPCanvas\WPCanvasController;
     16use SiteSkite\Cron\CronTriggerHandler;
    1617
    1718use WP_REST_Request;
     
    4950    private WPCanvasController $wp_canvas_controller;
    5051    private RestAPI $rest_api;
     52    private ?CronTriggerHandler $cron_trigger_handler;
    5153
    5254    /**
     
    6264        RestoreManager $restore_manager,
    6365        ScheduleManager $schedule_manager,
    64         RestAPI $rest_api
     66        RestAPI $rest_api,
     67        ?CronTriggerHandler $cron_trigger_handler = null
    6568    ) {
    6669        $this->auth_manager = $auth_manager;
     
    7376        $this->schedule_manager = $schedule_manager;
    7477        $this->rest_api = $rest_api;
     78        $this->cron_trigger_handler = $cron_trigger_handler;
    7579        $this->wp_canvas_controller = new WPCanvasController($this->logger, $this->auth_manager);
    7680        $this->init();
     
    147151           
    148152            // 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                }
    166177            }
    167178
     
    235246     * Create a WP_REST_Request from admin-ajax data
    236247     */
    237     private function create_rest_request(string $route, string $method, ?array $json_data = null): WP_REST_Request
     248    private function create_rest_request(string $route, string $method, ?array $json_data = null, ?string $raw_body = null): WP_REST_Request
    238249    {
    239250        $request = new WP_REST_Request($method, '/' . self::API_NAMESPACE . $route);
     
    307318        }
    308319       
    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;
    310348    }
    311349
     
    384422            '/schedule/cleanup' => [[$this->schedule_manager, 'cleanup_all'], ['POST']],
    385423        ];
     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        }
    386430       
    387431        // Check if route exists
  • siteskite/trunk/includes/API/RestAPI.php

    r3431615 r3453079  
    374374                    ]
    375375                ]
     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'],
    376387            ]
    377388        );
     
    11971208     * Handle force cleanup of corrupted backups
    11981209     */
     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
    11991223    public function handle_force_cleanup(\WP_REST_Request $request): \WP_REST_Response
    12001224    {
  • siteskite/trunk/includes/Backup/BackupManager.php

    r3446096 r3453079  
    159159        // Dependencies
    160160        'node_modules',
    161         'vendor',
    162161       
    163162        // IDE/Editor files
     
    174173        'wp-content/cache',
    175174        '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',
    220175        'wp-content/nginx_cache',
    221176       
     
    242197        // Temporary files
    243198        '*.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',
    244213        '*.log',
     214        'debug.log',
     215        'error_log',
     216        'backwpup',
     217        "*.sql",
    245218        '*.cache',
    246219        '*.swp',
     
    255228        // Session files
    256229        'wp-content/uploads/sess_',
     230        'wp-content/uploads/sess_*',
    257231        'wp-content/sessions',
    258232       
     
    388362        if ($hook === 'siteskite_incremental_continue') {
    389363            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
    390368                // 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);
    392370                if ($existing_job_id) {
    393371                    $this->logger->debug('Incremental continue recurring job already exists, skipping creation', [
     
    397375                    return;
    398376                }
    399                
     377
    400378                // Use recurring job that runs every minute until work is complete
    401379                // The handler will automatically clean up the job when backup is complete
    402380                $job_id = $this->external_cron_manager->create_recurring_job(
    403381                    $hook,
    404                     $args,
     382                    $canonical_args,
    405383                    'every_minute', // Run every minute
    406384                    [],
     
    13521330               
    13531331                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);
    13561336                   
    13571337                    if (!$cron_job_scheduled_db && !$existing_job_id) {
    13581338                        $job_id = $this->external_cron_manager->create_recurring_job(
    13591339                            'process_database_backup_cron',
    1360                             ['backup_id' => $backup_id, 'callback_url' => $callback_url],
     1340                            $lookup_args,
    13611341                            'every_minute',
    13621342                            [],
     
    17831763           
    17841764            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);
    17871769               
    17881770                if (!$cron_job_scheduled_files && !$existing_job_id) {
    17891771                    $job_id = $this->external_cron_manager->create_recurring_job(
    17901772                        'process_files_backup_cron',
    1791                         ['backup_id' => $backup_id, 'callback_url' => $callback_url],
     1773                        $lookup_args,
    17921774                        'every_minute',
    17931775                        [],
     
    26672649           
    26682650            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
    26712652                $this->logger->info('Files backup not complete, recurring cron will continue', [
    26722653                    'backup_id' => $backup_id,
     
    39463927
    39473928            // Determine ordering/PK information
     3929            // Use SHOW KEYS without WHERE/LIMIT (MariaDB does not support LIMIT on SHOW) and filter in PHP
    39483930            $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            })) : [];
    39503935            $has_primary_key = !empty($primary_key_rows);
    39513936            $is_composite_pk = $has_primary_key && count($primary_key_rows) > 1;
     
    39533938
    39543939            // 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            })) : [];
    39593943            $has_single_column_unique = false;
    39603944            $unique_key_column = null;
     
    39883972            } else {
    39893973                // 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;
    39943975                if ($unique_key && !empty($unique_key['Column_name'])) {
    39953976                    $order_column = $unique_key['Column_name'];
     
    53275308                        $existingBlobs = glob($subdir . '/*.blob');
    53285309                        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)) {
    53315312                                continue;
    53325313                            }
     
    66106591            // When no chunks to upload, check if file scan is complete and if backup should be completed
    66116592            $scan_progress_key = SITESKITE_OPTION_PREFIX . 'files_scan_progress_' . $backup_id;
     6593            $all_file_paths_key = SITESKITE_OPTION_PREFIX . 'all_file_paths_' . $backup_id;
    66126594            $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            }
    66146605           
    66156606            if ($scan_completed) {
     
    66436634            } else {
    66446635                // 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                }
    66516649               
    66526650                $this->logger->info('No chunks to upload but scan not complete, scan will continue', [
     
    1027010268            }
    1027110269           
    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';
    1028710304            }
    1028810305
     
    1030510322            }
    1030610323
     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           
    1030710348            // Runtime and throttling transients
    1030810349            if (function_exists('delete_transient')) {
     
    1110111142        if ($requires_files) {
    1110211143            $scan_progress_key = SITESKITE_OPTION_PREFIX . 'files_scan_progress_' . $backup_id;
     11144            $all_file_paths_key = SITESKITE_OPTION_PREFIX . 'all_file_paths_' . $backup_id;
    1110311145            $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 {
    1110511154                $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)) {
    1110711159                    $files_processed = (int)($scan_progress['files_processed'] ?? 0);
    1110811160                    $processed_paths = (array)($scan_progress['processed_paths'] ?? []);
    11109                     $all_file_paths_key = SITESKITE_OPTION_PREFIX . 'all_file_paths_' . $backup_id;
    1111011161                    $all_file_paths = get_option($all_file_paths_key, []);
    1111111162                    $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;
    1112511183            }
    1112611184        }
     
    1152011578                                continue;
    1152111579                            }
     11580
     11581                            if (!is_readable($blobFile)) {
     11582                                continue;
     11583                            }
    1152211584                           
    1152311585                            $chunksToUpload[$chunkHash] = [
  • siteskite/trunk/includes/Cleanup/CleanupManager.php

    r3443472 r3453079  
    7070            $log_count = $this->cleanup_log_files();
    7171
    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)
    7376            $manifest_count = $this->cleanup_old_manifests();
    7477
     
    7679                'backup_files' => $backup_count,
    7780                'log_files' => $log_count,
     81                'classic_manifest_files' => $classic_manifest_count,
    7882                'manifest_files' => $manifest_count
    7983            ]);
     
    118122
    119123    /**
     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    /**
    120148     * Clean up log files
    121149     */
     
    197225     * Clean up backup files and logs
    198226     */
     227   
    199228    public function cleanup_backup(string $backup_id): void
    200229    {
     
    217246                        'file' => basename($file)
    218247                    ]);
     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                    }
    219261                }
    220262            }
     
    346388        }
    347389    }
    348 
     390   
    349391    /**
    350392     * Clean up restore files and logs
  • siteskite/trunk/includes/Core/Activator.php

    r3389896 r3453079  
    3333        // Flush rewrite rules
    3434        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        }
    3546    }
    3647}
  • siteskite/trunk/includes/Core/AutoLoginController.php

    r3389896 r3453079  
    55
    66use function add_action;
     7use function add_filter;
    78use function admin_url;
    89use function do_action;
     10use function esc_html__;
    911use function esc_url_raw;
    1012use function get_option;
    1113use function get_users;
     14use function home_url;
    1215use function is_user_logged_in;
    1316use function is_wp_error;
    1417use function sanitize_text_field;
    1518use function wp_die;
    16 use function wp_redirect;
    1719use function wp_remote_get;
    1820use function wp_remote_retrieve_body;
     21use function wp_safe_redirect;
    1922use function wp_set_auth_cookie;
    2023use function wp_set_current_user;
     24use function wp_unslash;
    2125
    2226class AutoLoginController
    2327{
     28    private const AUTOLOGIN_ENDPOINT = 'siteskite-autologin';
     29
    2430    public function register(): void
    2531    {
     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)
    2637        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;
    28122    }
    29123
     
    62156                wp_set_current_user($user->ID, $user->user_login);
    63157                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);
    65173                exit;
    66174            }
     
    68176    }
    69177
    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
    72199        // 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            );
    75206        }
    76207
     
    81212        $stored_token = get_option('siteskite_api_key', '');
    82213
     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
    83222        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            );
    85228        }
    86229
     
    93236
    94237        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            );
    96243        }
    97244
    98245        $user = $admin_users[0];
    99246        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);
    102264        exit;
    103265    }
     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    }
    104286}
    105287
  • siteskite/trunk/includes/Core/Plugin.php

    r3431615 r3453079  
    177177    private function init_components(): void {
    178178        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
    179189            $this->logger = new Logger();
    180190            $this->progress_tracker = new ProgressTracker($this->logger);
     
    301311        add_action('admin_init', [$this, 'register_settings']);
    302312        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);
    303318
    304319        // Add global HTTP header augmentation for all outgoing plugin requests
     
    403418        ]);
    404419
     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        }
    405434    }
    406435
     
    448477            'disconnectFailed' => esc_html__('Failed to disconnect. Please try again.', 'siteskite')
    449478        ]);
     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
    450562    }
    451563
     
    502614                    $this->restore_manager,
    503615                    $this->schedule_manager,
    504                     $this->rest_api
     616                    $this->rest_api,
     617                    $this->cron_trigger_handler
    505618                );
    506619               
     
    524637                    $this->restore_manager,
    525638                    $this->schedule_manager,
    526                     $this->rest_api
     639                    $this->rest_api,
     640                    $this->cron_trigger_handler
    527641                );
    528642               
  • siteskite/trunk/includes/Cron/CronTriggerHandler.php

    r3431615 r3453079  
    5454        try {
    5555            // Enhanced security: Verify secret token from header or query parameter
     56            // Check header with different case variations (case-insensitive matching)
    5657            $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           
    5767            $query_secret = $request->get_param('siteskite_key');
    5868           
     
    151161            $args = $body['args'] ?? [];
    152162
     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
    153171            if (empty($action)) {
    154172                $this->logger->error('Missing action in cron trigger', [
     
    173191            // External cron handles scheduling directly
    174192
    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) {
    177198                $job_id = $this->cron_manager->get_job_id_by_action($action, $args);
    178199                if ($job_id) {
     
    248269                return $this->handle_incremental_restore($args);
    249270
     271           
    250272            case 'siteskite_cleanup_restore_status':
    251273                return $this->handle_restore_cleanup($args);
    252 
     274           
    253275            case 'siteskite_cleanup_incremental_restore_records':
    254276                return $this->handle_incremental_restore_cleanup($args);
     
    472494     * Handle restore cleanup
    473495     */
     496
    474497    private function handle_restore_cleanup(array $args): array
    475498    {
     
    484507        ];
    485508    }
    486 
     509   
    487510    /**
    488511     * Handle incremental restore records cleanup
     
    513536        try {
    514537            // Enhanced security: Verify secret token from header or query parameter
     538            // Check header with different case variations (case-insensitive matching)
    515539            $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            }
    516548            if (empty($secret)) {
    517549                $secret = $request->get_param('siteskite_key');
  • siteskite/trunk/includes/Cron/ExternalCronManager.php

    r3445801 r3453079  
    463463
    464464        // For immediate execution (delay = 0), create recurring job that runs every 1 minute
    465         // This job will be deleted after first successful execution
     465        // process_files_backup_cron must keep running every minute until backup completes - do NOT delete after first run
    466466        if ($delay_seconds === 0) {
    467467            $job_id = $this->create_recurring_job($action, $args, 'every_minute', [], $title);
    468468           
    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) {
    471472                $key = $this->generate_job_key($action, $args);
    472473                $jobs = get_option(self::OPTION_JOBS, []);
     
    679680
    680681    /**
     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    /**
    681719     * Get trigger URL for action
    682720     * Includes secret token as query parameter for services that don't support custom headers
     
    685723    private function get_trigger_url(string $action): string
    686724    {
    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   
    701751       
    702752        return $url;
  • siteskite/trunk/includes/Restore/RestoreManager.php

    r3445801 r3453079  
    5858    private const CHUNK_SIZE = 2 * 1024 * 1024; // 2MB chunks
    5959    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;
    6072    private const OPTION_PREFIX = SITESKITE_OPTION_PREFIX . 'restore_';
    6173    private const EXCLUDED_PATHS = [
     
    6779        'wp-content/uploads/siteskite-logs',
    6880        'wp-content/debug.log',
    69         'wp-content/plugins/siteskite-link',
     81        'wp-content/plugins/siteskite',
    7082        'wp-content/upgrade',
    7183        'wp-content/uploads/backup*',
     
    581593            update_option($restore_params_key, $restore_params);
    582594
     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
    583599            // Initialize restore status
    584600            $this->update_restore_status($manifestId, [
     
    808824            ], true);
    809825
     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);
    810830
    811831            switch ($scope) {
     
    9921012            // Cleanup progress tracking data
    9931013            $this->clear_incremental_restore_progress($manifestId);
    994            
     1014
     1015            $this->clear_restore_in_progress_and_guard();
    9951016
    9961017        } catch (\Throwable $e) {
     1018            $this->clear_restore_in_progress_and_guard();
    9971019            $this->logger->error('Incremental restore cron failed', [
    9981020                'manifest_id' => $manifestId,
     
    13331355            ]);
    13341356
    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)
    13361358            $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) {
    13391362                if (file_exists($sql_path)) {
    13401363                    wp_delete_file($sql_path);
    13411364                }
    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            }
    13461367
    13471368            // Switch to restoring stage for database
     
    14771498        }
    14781499       
    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
    14801502        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', [
    14821504                'chunks_not_in_bundles' => count($chunksNotInBundles),
    14831505                'sample_hashes' => array_slice($chunksNotInBundles, 0, 5)
    14841506            ]);
    1485             throw new \RuntimeException('Some chunks are not available in bundles. Bundle metadata is required for restore.');
    14861507        }
    14871508       
     
    15081529                // This matches how ObjectStore stores bundles: objects/sha256/{first2chars}/bundle_{hash}.zip
    15091530                $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
    15101542                $bundleData = $this->download_object_from_provider($provider, $bundlePath, $request);
    15111543               
     
    15361568                }
    15371569                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                ]);
    15381577                $downloadedBundles[$bundleHash] = $tempFile;
    15391578                $tempFiles[] = $tempFile;
     
    15451584                ]);
    15461585               
    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                    ]);
    15501594                    foreach ($chunkHashes as $chunkHash) {
    15511595                        $entryName = $chunkHash . '.blob';
     
    15641608                    }
    15651609                    $zip->close();
    1566                 } else {
     1610                } catch (\RuntimeException $e) {
    15671611                    $this->logger->error('Failed to open bundle ZIP file', [
    15681612                        'bundle_hash' => $bundleHash,
    1569                         'temp_file' => $tempFile
     1613                        'temp_file' => $tempFile,
     1614                        'error' => $e->getMessage()
    15701615                    ]);
    15711616                    $failedBundles[] = $bundleHash;
     
    15831628        }
    15841629       
     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       
    15851700        // Cleanup temp files
    15861701        foreach ($tempFiles as $tempFile) {
     
    15901705        }
    15911706       
    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)
    15931727        if (!empty($failedBundles) || !empty($missingChunks)) {
    1594             $this->logger->error('Bundle download/extraction failed', [
     1728            $this->logger->error('Bundle download/extraction or fallback failed', [
    15951729                'failed_bundles' => count($failedBundles),
    15961730                '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            );
    16001737        }
    16011738       
    16021739        $this->logger->info('Bundle-based download completed', [
    16031740            '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)
    16051744        ]);
    16061745       
     
    26902829            }
    26912830           
    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
    26942855            wp_delete_file($temp_file);
    26952856            return $data;
     
    28302991        fclose($fh);
    28312992
    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)
    28332994        $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));
    28402996
    28412997        // Import using existing path
     
    28503006    {
    28513007        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
    28603032            // Set a flag at the start of restore
    28613033            $this->update_option_bc(SITESKITE_OPTION_PREFIX . 'restore_in_progress', SITESKITE_OPTION_PREFIX . 'restore_in_progress', true);
     3034            $this->ensure_restore_guard_mu_plugin();
    28623035
    28633036            // Check if this is an incremental restore
    28643037            if ($this->is_incremental_restore($backup_id, $type, $download_url)) {
     3038                $this->clear_restore_running_lock($backup_id, $scope);
    28653039                $this->process_incremental_restore($backup_id, $type, $download_url);
    28663040                return;
     
    29353109            ], $suppress_notification);
    29363110
    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);
    29843114
    29853115            $this->cleanup_manager->cleanup_restore_files($backup_id);
    29863116
    29873117        } 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);
    29903119            $this->update_restore_status($backup_id, [
    29913120                'status' => 'error',
     
    31123241
    31133242    /**
     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    /**
    31143394     * Extract file from full backup package if specific file not found
    3115      * 
     3395     *
    31163396     * @param string $file_name The requested file name (e.g., siteskite_backup_XXX_db.zip)
    31173397     * @param string $destination Final destination path for the extracted file (will be updated to actual extracted file path)
     
    31883468        ]);
    31893469
    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) {
    31933474            if (file_exists($full_zip_path)) {
    31943475                wp_delete_file($full_zip_path);
    31953476            }
    3196             throw new \RuntimeException('Failed to open full backup package');
     3477            throw new \RuntimeException('Failed to open full backup package: ' . $e->getMessage());
    31973478        }
    31983479
     
    33523633
    33533634    /**
     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    /**
    33543858     * Restore database
    33553859     *
     
    33633867
    33643868        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
    33653885            // Update status to show database restore starting
    33663886            $this->update_restore_status($backup_id, [
     
    33723892            ]);
    33733893
    3374             $original_permalink = get_option('permalink_structure');
    3375 
    33763894            $this->logger->info('permalink: ', [
    33773895                'Permalink' => $original_permalink
    33783896            ]);
    33793897
    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());
    33843912            }
    33853913
     
    33993927
    34003928            $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);
    34023930            $zip->close();
     3931            if ($extract_ok === false) {
     3932                throw new \RuntimeException('Failed to extract SQL file from backup');
     3933            }
    34033934
    34043935            // Rename extracted file to our temp name if needed
     
    34123943            }
    34133944
    3414             // Update status for SQL processing
     3945            // Update status for SQL processing (streaming import avoids loading full file into memory)
    34153946            $this->update_restore_status($backup_id, [
    34163947                '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)
    34273952            // 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) ||
    34293954                             (strpos($download_url, 'incdb_') !== false) ||
    34303955                             (strpos($backup_id, 'database:') === 0);
    34313956
    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
    34353958            if ($is_incremental) {
    3436                 $this->logger->info('Cleaning up ALL existing restore-related records before database import', [
     3959                $this->logger->info('Cleaning up existing restore-related records for this job before database import', [
    34373960                    'backup_id' => $backup_id
    34383961                ]);
    3439                 // Clean up ALL restore-related records completely (preserve webhook flags)
    34403962                $this->cleanup_restore_records($backup_id, true);
    34413963            }
    3442 
    3443             // For incremental restores, modify INSERT statements to handle duplicates
    34443964            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', [
    34533966                    'backup_id' => $backup_id,
    34543967                    'download_url' => basename($download_url)
     
    34563969            }
    34573970
    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
    35393975            // but BEFORE status update (which triggers notifications)
    3540             // This prevents duplicate notifications from restore records that were imported from the backup
    3541             // We delete ALL restore-related records completely - they will be recreated by the status update below
    35423976            if ($is_incremental) {
    3543                 $this->logger->info('Cleaning up ALL imported restore-related records after database import', [
     3977                $this->logger->info('Cleaning up imported restore-related records for this job after database import', [
    35443978                    'backup_id' => $backup_id
    35453979                ]);
    3546                 // Clean up ALL restore-related records completely (no preservation)
    3547                 // This removes any restore records that were imported from the backup
    35483980                $this->cleanup_restore_records($backup_id, true);
    35493981            }
    35503982
    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
    35523994            wp_delete_file($sql_file);
    35533995
    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);
    35753997
    35763998            global $wpdb;
     
    36124034            ]);
    36134035
    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);
    36194038
    36204039            // Get total files and prepare file list
     
    37564175            }
    37574176
    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) {
    37614181                $this->logger->error('Failed to open backup file', [
    37624182                    'backup_id' => $backup_id,
    37634183                    'file_path' => $download_url,
    37644184                    '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()
    37664187                ]);
    3767                 throw new \RuntimeException('Failed to open backup file');
     4188                throw new \RuntimeException('Failed to open backup file: ' . $e->getMessage());
    37684189            }
    37694190
    37704191            // 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            }
    37724197           
    37734198            // Debug: List all files in the extracted directory
     
    41604585                    }
    41614586
     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
    41624596                    // Use WP_Filesystem to copy file
    41634597                    if ($wp_filesystem->copy($source_path, $destination_path, true)) {
     
    42744708            }
    42754709
    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
    42774716            if ($restore_info) {
    42784717                $response = [
     
    43384777            ];
    43394778        }
     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;
    43404833    }
    43414834
     
    48135306                throw new \RuntimeException('Missing required parameters: backup_id and type are required');
    48145307            }
     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            ]);
    48155320
    48165321            // Create restore directory - extract clean filename from URL
     
    56096114            }
    56106115
    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.
    56126119            $old_status = $restore_info['status'] ?? '';
    56136120            $new_status = $status_update['status'] ?? $old_status;
    56146121            if (in_array($old_status, ['completed', 'failed'], true) &&
    56156122                !in_array($new_status, ['completed', 'failed'], true) &&
    5616                 $new_status !== $old_status) {
     6123                $new_status !== $old_status &&
     6124                $new_status !== 'cleanup') {
    56176125                // New restore starting - clear the notification flags
    56186126                // Clear the old notification sent key (for backward compatibility)
     
    59556463            }
    59566464           
    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 . '%';
    59586467            // 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            ));
    59626472            if ($result !== false && $result > 0) {
    59636473                $deleted_count += $result;
    59646474            }
    59656475           
    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 . '%',
    59776487            ];
    59786488           
    5979             foreach ($patterns as $pattern) {
     6489            foreach ($manifest_patterns as $pattern) {
    59806490                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
    59816491                $result = $wpdb->query($wpdb->prepare(
     
    61526662
    61536663    /**
     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.
     6896if (!defined('ABSPATH')) {
     6897    return;
     6898}
     6899\$only_siteskite = ['{$siteskite_basename}'];
     6900add_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);
     6903add_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);
     6909PHP;
     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    /**
    61547021     * Check if restore was completed by checking webhook sent flags
    61557022     *
    61567023     * @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)
    61577025     * @return array{found: bool, scope: string|null} Whether webhook was found and the scope
    61587026     */
    6159     private function check_restore_completion_webhook(string $backup_id, ?string $scope = null): array
     7027    private function check_restore_completion_webhook(string $backup_id, ?string $scope = null, bool $from_db = false): array
    61607028    {
    61617029        // If scope is specified, only check that scope
     
    61717039            $webhook_sent_key = 'siteskite_restore_webhook_sent_' . $backup_id . '_' . $check_scope;
    61727040           
    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            }
    61807049            if ($webhook_value !== false && $webhook_value !== '' && is_numeric($webhook_value) && $webhook_value > 0) {
    61817050                return ['found' => true, 'scope' => $check_scope];
     
    61957064    private function check_restore_completion_status(string $manifestId, ?string $scope = null): array
    61967065    {
     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
    61977085        // Check restore status first
    6198         $existing_status = get_option(self::OPTION_PREFIX . $manifestId);
     7086        $existing_status = get_option($option_key);
    61997087        if (!empty($existing_status) && is_array($existing_status) && isset($existing_status['status'])) {
    62007088            if ($existing_status['status'] === 'completed') {
     
    62157103        }
    62167104       
    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);
    62207108       
    62217109        if ($webhook_check['found']) {
     
    80248912                            }
    80258913
     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
    80268924                            $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
    80278935                            // 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                            }
    80298944                        }
    80308945
     8946                        // Flush so data is on disk before close (hosting compatibility)
     8947                        if (is_resource($file_handle)) {
     8948                            fflush($file_handle);
     8949                        }
    80318950                        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Required for file chunk streaming
    80328951                        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
    80348965                        // Set file modification time
    80358966                        if (isset($file_info['mtime'])) {
     
    83029233                            }
    83039234
     9235                            $chunk_data = $downloaded_chunks[$chunk_hash];
    83049236                            // 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                            }
    83069245                        }
    83079246
     9247                        if (is_resource($file_handle)) {
     9248                            fflush($file_handle);
     9249                        }
    83089250                        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Required for file chunk streaming
    83099251                        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
    83119264                        // Set file modification time
    83129265                        $file_info = $filtered_manifest['files'][$file_path] ?? [];
     
    84559408                        foreach ($chunk_hashes as $chunk_hash) {
    84569409                            if (isset($downloaded_chunks[$chunk_hash])) {
     9410                                $chunk_data = $downloaded_chunks[$chunk_hash];
    84579411                                // 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                                }
    84599420                            }
    84609421                        }
    84619422
     9423                        if (is_resource($file_handle)) {
     9424                            fflush($file_handle);
     9425                        }
    84629426                        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Required for file chunk streaming
    84639427                        fclose($file_handle);
     
    85409504                        }
    85419505
     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
    85429516                        $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
    85439526                        // 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                    }
    85479541                    // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Required for file chunk streaming
    85489542                    fclose($file_handle);
     
    86149608                file_put_contents($temp_archive, $archive_data);
    86159609
    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);
    86209615                    $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) {
    86279628                    $this->logger->error('Failed to open batch archive', [
    86289629                        'batch_id' => $batch_id,
    8629                         'temp_archive' => $temp_archive
     9630                        'temp_archive' => $temp_archive,
     9631                        'error' => $e->getMessage()
    86309632                    ]);
    86319633                }
     
    86549656    private function is_safe_restore_query(string $query): bool
    86559657    {
    8656         // Remove comments and normalize whitespace
     9658        // Remove line comments (-- to EOL)
    86579659        $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)
    86589670        $query = preg_replace('/\/\*.*?\*\//s', '', $query);
    86599671        $query = trim($query);
    8660        
     9672
    86619673        if (empty($query)) {
    86629674            return false;
     
    86699681        $allowed_operations = [
    86709682            'CREATE TABLE',
    8671             'DROP TABLE',
     9683            'DROP TABLE IF EXISTS',
    86729684            'INSERT IGNORE INTO',
    86739685            'INSERT INTO',
    86749686            'UPDATE ',
    8675             'DELETE FROM',
    8676             'ALTER TABLE',
    86779687            'CREATE INDEX',
    8678             'DROP INDEX',
    86799688            'LOCK TABLES',
    86809689            'UNLOCK TABLES',
     
    87059714    private function validate_query_security(string $query): bool
    87069715    {
    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
    87089721        $dangerous_patterns = [
    87099722            '/\bDROP\s+DATABASE\b/i',
     
    87239736
    87249737        foreach ($dangerous_patterns as $pattern) {
    8725             if (preg_match($pattern, $query)) {
     9738            if (preg_match($pattern, $query_header)) {
    87269739                return false;
    87279740            }
    87289741        }
    87299742
    8730         // Block queries that try to access system tables
     9743        // Block queries that try to access system tables (check full query)
    87319744        $system_tables = [
    87329745            'mysql.',
     
    89739986
    89749987    /**
     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    /**
    897510009     * Remove last classic restore state from JSON file
    897610010     * 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  
    22msgid ""
    33msgstr ""
    4 "Project-Id-Version: SiteSkite Link 1.2.3\n"
     4"Project-Id-Version: SiteSkite Link 1.2.5\n"
    55"Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/siteskite\n"
    66"POT-Creation-Date: 2024-01-01T00:00:00+00:00\n"
  • siteskite/trunk/readme.txt

    r3446096 r3453079  
    55Tested up to: 6.9
    66Requires PHP: 7.4
    7 Stable tag: 1.2.3
     7Stable tag: 1.2.5
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
     
    223223== Changelog ==
    224224
    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) =
     240Improved: Backup performance
    227241
    228242
     
    257271
    258272= 1.0.7 (13 December 2025) = 
    259 * Added: Better Backup management
     273* New: Better Backup management
    260274* Improved: Backup performance
    261275
    262276= 1.0.6 (10 December 2025) = 
    263 * Added: pCloud Auth based Authentication
     277* New: pCloud Auth based Authentication
    264278* Improved: Backup performance
    265279
     
    282296* Initial release 
    283297
     298
    284299== Upgrade Notice ==
    285300
    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) =
     314Improved: 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)
    288342
    289343= 1.0.8 (16 December 2025) = 
     
    291345
    292346= 1.0.7 (13 December 2025) = 
    293 * Added: Better Backup management
     347* New: Better Backup management
    294348* Improved: Backup performance
    295349
    296350= 1.0.6 (10 December 2025) = 
    297 * Added: pCloud Auth based Authentication
     351* New: pCloud Auth based Authentication
    298352* Improved: Backup performance
    299353
     
    304358* Improved: Notifications
    305359
    306 = 1.0.3 (15 November 2025) = 
    307 * Improved: SiteSkite WPCanvas Improvements
     360= 1.0.3 (18 November 2025) = 
     361* Improved: WPCanvas Improvements
    308362
    309363= 1.0.2 (15 November 2025) = 
    310364* Improved: Backup & Restore Service for CDN enabled sites
    311365
    312 = 1.0.1
     366= 1.0.1 (08 November 2025)
    313367* Admin view update
    314368
    315369= 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  
    44 * Plugin URI: https://siteskite.com
    55 * Description: Link your WordPress site with SiteSkite for effortless updates, backups, monitoring, and maintenance—everything in one place.
    6  * Version: 1.2.3
     6 * Version: 1.2.5
    77 * Requires at least: 5.3
    88 * Requires PHP: 7.4
     
    2929
    3030// Plugin version
    31 define('SITESKITE_VERSION', '1.2.3');
     31define('SITESKITE_VERSION', '1.2.5');
    3232
    3333// Plugin file, path, and URL
  • siteskite/trunk/uninstall.php

    r3389896 r3453079  
    4343
    4444// Clear any rewrite rules
    45 flush_rewrite_rules();
     45flush_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];
     57foreach ($files as $file) {
     58    if (file_exists($file) && is_writable($file)) {
     59        wp_delete_file($file);
     60    }
     61}
     62if (is_dir($siteskite_dir) && @rmdir($siteskite_dir)) {
     63    // Removed empty wp-content/siteskite/
     64}
Note: See TracChangeset for help on using the changeset viewer.