Plugin Directory

Changeset 3445801


Ignore:
Timestamp:
01/23/2026 08:10:58 PM (7 weeks ago)
Author:
siteskite
Message:

Update version to 1.2.2

Location:
siteskite/trunk
Files:
8 edited

Legend:

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

    r3431615 r3445801  
    192192                    }
    193193                } elseif ($status >= 200 && $status < 300) {
    194                     wp_send_json_success($data, $status);
     194                    // Send data directly to match wp-json REST API format
     195                    // This ensures consistency between REST API and AJAX responses
     196                    if (!headers_sent()) {
     197                        status_header($status);
     198                        header('Content-Type: application/json; charset=utf-8');
     199                    }
     200                    echo wp_json_encode($data);
     201                    exit;
    195202                } else {
    196203                    wp_send_json_error($data, $status);
     
    236243       
    237244        // Merge GET, POST, and JSON body data
    238         if (!empty($_GET)) {
    239             $params = array_merge($params, $_GET);
    240         }
    241         if (!empty($_POST)) {
    242             $params = array_merge($params, $_POST);
     245        // Remove WordPress admin-ajax routing 'action' (siteskite_api) and 'route' from GET/POST
     246        // But preserve data 'action' parameter if it's not the routing action
     247        $get_params = $_GET ?? [];
     248        $post_params = $_POST ?? [];
     249       
     250        // Only remove the routing action, not data action parameters
     251        if (isset($get_params['action']) && $get_params['action'] === self::AJAX_ACTION) {
     252            unset($get_params['action']);
     253        }
     254        if (isset($post_params['action']) && $post_params['action'] === self::AJAX_ACTION) {
     255            unset($post_params['action']);
     256        }
     257        unset($get_params['route'], $post_params['route']);
     258       
     259        if (!empty($get_params)) {
     260            $params = array_merge($params, $get_params);
     261        }
     262        if (!empty($post_params)) {
     263            $params = array_merge($params, $post_params);
    243264        }
    244265       
    245266        // Handle JSON body (use pre-parsed data if provided)
     267        // Only remove 'route' from JSON body, keep 'action' as it's actual data
     268        // JSON body parameters take precedence over GET/POST to ensure data integrity
    246269        if (is_array($json_data)) {
    247             $params = array_merge($params, $json_data);
    248         }
    249        
    250         // Remove action and route from params (they're routing params, not data)
    251         unset($params['action'], $params['route']);
     270            $json_params = $json_data;
     271            unset($json_params['route']); // Remove route, but keep action
     272            // Merge JSON params last so they override any GET/POST params with same keys
     273            $params = array_merge($params, $json_params);
     274        }
    252275       
    253276        // Set all parameters
     277        // Preserve original value types (don't convert integers to strings prematurely)
    254278        foreach ($params as $key => $value) {
    255279            $request->set_param($key, $value);
     
    266290       
    267291        // Also check for X-SiteSkite-Key in various formats
     292        // Priority: HTTP header > JSON body > $_REQUEST parameter > request params (after merge)
     293        $api_key = null;
    268294        if (isset($headers['X-Siteskite-Key'])) {
    269             $request->add_header('X-SiteSkite-Key', $headers['X-Siteskite-Key']);
     295            $api_key = $headers['X-Siteskite-Key'];
     296        } elseif (is_array($json_data) && isset($json_data['api_key'])) {
     297            $api_key = sanitize_text_field(wp_unslash($json_data['api_key']));
    270298        } elseif (isset($_REQUEST['api_key'])) {
    271             $request->add_header('X-SiteSkite-Key', sanitize_text_field(wp_unslash($_REQUEST['api_key'])));
     299            $api_key = sanitize_text_field(wp_unslash($_REQUEST['api_key']));
     300        } elseif (isset($params['api_key'])) {
     301            // Check params after merge (in case it was in JSON body)
     302            $api_key = is_string($params['api_key']) ? $params['api_key'] : sanitize_text_field(wp_unslash($params['api_key']));
     303        }
     304       
     305        if ($api_key) {
     306            $request->add_header('X-SiteSkite-Key', $api_key);
    272307        }
    273308       
     
    405440            '/get-users' => [$this->wp_canvas_controller, 'get_all_users'],
    406441            '/get-user' => [$this->wp_canvas_controller, 'get_single_user_details_by_id'],
    407             '/get-error-log' => [$this->wp_canvas_controller, 'get_wp_error_logs'],
     442            '/get-error-log' => function($req) {
     443                return $this->wp_canvas_controller->get_wp_error_logs();
     444            },
     445            '/get-custom-error-log' => [$this->wp_canvas_controller, 'get_custom_error_log'],
     446            '/get-debug-log' => function($req) {
     447                return $this->wp_canvas_controller->get_wp_debug_logs();
     448            },
     449            '/delete-error-log' => function($req) {
     450                return $this->wp_canvas_controller->delete_wp_error_logs();
     451            },
     452            '/delete-debug-log' => function($req) {
     453                return $this->wp_canvas_controller->delete_wp_debug_logs();
     454            },
     455            '/delete-custom-error-log' => [$this->wp_canvas_controller, 'delete_custom_error_log'],
    408456            '/count-wp-updates' => [$this->wp_canvas_controller, 'laravel_api_get_update_counts'],
    409457            '/logs' => [$this->wp_canvas_controller, 'get_wp_logs'],
  • siteskite/trunk/includes/Backup/BackupManager.php

    r3445137 r3445801  
    753753        ];
    754754
     755        // Expose database-specific progress details for both classic and incremental backups.
     756        // This allows the progress endpoint/UI to show real progress for large DBs (tables/bytes/chunks).
     757        $db_total_bytes = isset($backup_info['db_total_bytes']) ? (int)$backup_info['db_total_bytes'] : null;
     758        $db_bytes_processed = isset($backup_info['db_bytes_processed']) ? (int)$backup_info['db_bytes_processed'] : null;
     759        $db_chunks_processed = isset($backup_info['db_chunks_processed']) ? (int)$backup_info['db_chunks_processed'] : null;
     760        $db_chunks_total_estimate = isset($backup_info['db_chunks_total_estimate']) ? (int)$backup_info['db_chunks_total_estimate'] : null;
     761        $db_new_chunks_count = isset($backup_info['db_new_chunks_count']) ? (int)$backup_info['db_new_chunks_count'] : null;
     762        $db_reused_chunks_count = isset($backup_info['db_reused_chunks_count']) ? (int)$backup_info['db_reused_chunks_count'] : null;
     763        $db_tables_processed = isset($backup_info['db_tables_processed']) ? (int)$backup_info['db_tables_processed'] : null;
     764        $db_total_tables = isset($backup_info['db_total_tables']) ? (int)$backup_info['db_total_tables'] : null;
     765        $db_current_table = $backup_info['db_current_table'] ?? null;
     766        $db_progress_percent_bytes = null;
     767        if ($db_total_bytes !== null && $db_bytes_processed !== null && $db_total_bytes > 0) {
     768            $db_progress_percent_bytes = round(($db_bytes_processed / $db_total_bytes) * 100, 2);
     769        }
     770        $db_progress_percent_tables = null;
     771        if ($db_total_tables !== null && $db_tables_processed !== null && $db_total_tables > 0) {
     772            $db_progress_percent_tables = round(($db_tables_processed / $db_total_tables) * 100, 2);
     773        }
     774        if ($db_total_bytes !== null || $db_total_tables !== null || $db_chunks_processed !== null) {
     775            $progress_data['database_progress'] = [
     776                'total_bytes' => $db_total_bytes,
     777                'bytes_processed' => $db_bytes_processed,
     778                'progress_percent_bytes' => $db_progress_percent_bytes,
     779                'chunks_processed' => $db_chunks_processed,
     780                'chunks_total_estimate' => $db_chunks_total_estimate,
     781                'new_chunks_count' => $db_new_chunks_count,
     782                'reused_chunks_count' => $db_reused_chunks_count,
     783                'tables_processed' => $db_tables_processed,
     784                'total_tables' => $db_total_tables,
     785                'progress_percent_tables' => $db_progress_percent_tables,
     786                'current_table' => $db_current_table
     787            ];
     788        }
     789
    755790        // Add classic backup detailed progress information
    756791        if ($is_classic_backup) {
     
    778813            $db_backup_complete = $backup_info['db_backup_complete'] ?? false;
    779814            $db_cron_scheduled = $backup_info['cron_job_scheduled_db'] ?? false;
     815            $tables_processed = isset($backup_info['db_tables_processed']) ? (int)$backup_info['db_tables_processed'] : null;
     816            $total_tables = isset($backup_info['db_total_tables']) ? (int)$backup_info['db_total_tables'] : null;
     817            $db_progress_percent = null;
     818            if ($tables_processed !== null && $total_tables !== null && $total_tables > 0) {
     819                $db_progress_percent = round(($tables_processed / $total_tables) * 100, 2);
     820                if ($db_backup_complete) {
     821                    $db_progress_percent = 100.0;
     822                }
     823            }
    780824           
    781825            $classic_progress['database'] = [
     
    787831                'cron_scheduled' => $db_cron_scheduled,
    788832                'sql_file_size' => isset($backup_info['db_sql_size']) ? $backup_info['db_sql_size'] : null,
    789                 'tables_processed' => $backup_info['db_tables_processed'] ?? null,
    790                 'total_tables' => $backup_info['db_total_tables'] ?? null
     833                'tables_processed' => $tables_processed,
     834                'total_tables' => $total_tables,
     835                'progress_percent' => $db_progress_percent,
     836                'current_table' => $backup_info['db_current_table'] ?? null,
     837                'current_operation' => $backup_info['current_operation'] ?? null
    791838            ];
    792839        }
     
    13471394                ]);
    13481395
    1349                 $zip = new \ZipArchive();
    1350                 $zip_result = $zip->open($zip_file, \ZipArchive::CREATE | \ZipArchive::OVERWRITE);
    1351                 if ($zip_result !== TRUE) {
    1352                     throw new \RuntimeException('Failed to create zip file: ' . $zip_result);
    1353                 }
    1354 
    1355                 if (!$zip->addFile($backup_file, basename($backup_file))) {
    1356                 $zip->close();
    1357                     throw new \RuntimeException('Failed to add SQL file to ZIP archive');
    1358                 }
    1359 
    1360                 if (!$zip->close()) {
    1361                     throw new \RuntimeException('Failed to close ZIP archive');
    1362                 }
     1396                // Use helper method with ZipArchive/PclZip fallback
     1397                $this->create_zip_file($zip_file, $backup_file, $backup_id);
    13631398
    13641399                $this->logger->info('ZIP file created successfully', [
    13651400                    'backup_id' => $backup_id,
    13661401                    'zip_file' => $zip_file,
    1367                     'zip_size' => filesize($zip_file)
     1402                    'zip_size' => file_exists($zip_file) ? filesize($zip_file) : 0
    13681403                ]);
    13691404
     
    17011736            $lock_acquired = true;
    17021737
     1738            // Check for and clean up orphaned temp files from previous failed runs
     1739            $temp_zip_file = $zip_file . '.tmp';
     1740            if (file_exists($temp_zip_file)) {
     1741                $temp_file_age = time() - filemtime($temp_zip_file);
     1742                // If temp file is older than 5 minutes, it's likely orphaned
     1743                if ($temp_file_age > 300) {
     1744                    $this->logger->warning('Found orphaned temp ZIP file, attempting recovery', [
     1745                        'backup_id' => $backup_id,
     1746                        'temp_file' => $temp_zip_file,
     1747                        'temp_file_age_seconds' => $temp_file_age,
     1748                        'temp_file_size' => filesize($temp_zip_file),
     1749                        'zip_file_exists' => file_exists($zip_file),
     1750                        'zip_file_size' => file_exists($zip_file) ? filesize($zip_file) : 0
     1751                    ]);
     1752                   
     1753                    // If temp file is larger than existing ZIP, try to recover it
     1754                    if (file_exists($zip_file)) {
     1755                        $temp_size = filesize($temp_zip_file);
     1756                        $zip_size = filesize($zip_file);
     1757                        if ($temp_size > $zip_size) {
     1758                            // Temp file is larger, try to replace ZIP with it
     1759                            @unlink($zip_file);
     1760                            if (rename($temp_zip_file, $zip_file)) {
     1761                                $this->logger->info('Recovered ZIP file from orphaned temp file', [
     1762                                    'backup_id' => $backup_id,
     1763                                    'recovered_size' => filesize($zip_file)
     1764                                ]);
     1765                            } else {
     1766                                // Rename failed, try copy
     1767                                if (copy($temp_zip_file, $zip_file)) {
     1768                                    @unlink($temp_zip_file);
     1769                                    $this->logger->info('Recovered ZIP file from orphaned temp file (via copy)', [
     1770                                        'backup_id' => $backup_id,
     1771                                        'recovered_size' => filesize($zip_file)
     1772                                    ]);
     1773                                } else {
     1774                                    $this->logger->error('Failed to recover ZIP from orphaned temp file', [
     1775                                        'backup_id' => $backup_id,
     1776                                        'temp_file' => $temp_zip_file
     1777                                    ]);
     1778                                    @unlink($temp_zip_file); // Clean up failed temp file
     1779                                }
     1780                            }
     1781                        } else {
     1782                            // Temp file is not larger, just clean it up
     1783                            @unlink($temp_zip_file);
     1784                            $this->logger->info('Removed orphaned temp file (smaller than existing ZIP)', [
     1785                                'backup_id' => $backup_id,
     1786                                'temp_size' => $temp_size,
     1787                                'zip_size' => $zip_size
     1788                            ]);
     1789                        }
     1790                    } else {
     1791                        // No ZIP file exists, try to use temp file
     1792                        if (rename($temp_zip_file, $zip_file)) {
     1793                            $this->logger->info('Recovered ZIP file from orphaned temp file (no existing ZIP)', [
     1794                                'backup_id' => $backup_id,
     1795                                'recovered_size' => filesize($zip_file)
     1796                            ]);
     1797                        } else {
     1798                            // Rename failed, try copy
     1799                            if (copy($temp_zip_file, $zip_file)) {
     1800                                @unlink($temp_zip_file);
     1801                                $this->logger->info('Recovered ZIP file from orphaned temp file (via copy, no existing ZIP)', [
     1802                                    'backup_id' => $backup_id,
     1803                                    'recovered_size' => filesize($zip_file)
     1804                                ]);
     1805                            } else {
     1806                                $this->logger->error('Failed to recover ZIP from orphaned temp file (no existing ZIP)', [
     1807                                    'backup_id' => $backup_id,
     1808                                    'temp_file' => $temp_zip_file
     1809                                ]);
     1810                                @unlink($temp_zip_file);
     1811                            }
     1812                        }
     1813                    }
     1814                }
     1815            }
     1816           
    17031817            // Check if ZIP file exists (resume mode)
    17041818            $zip_exists = file_exists($zip_file);
    1705                 $zip = new \ZipArchive();
    1706            
    1707             if ($zip_exists) {
    1708                 // Open existing ZIP for appending
    1709                 $zip_result = $zip->open($zip_file, \ZipArchive::CREATE);
    1710                 if ($zip_result !== TRUE) {
    1711                     // If we can't open existing ZIP, recreate it
    1712                     $this->logger->warning('Cannot open existing ZIP file, recreating', [
     1819           
     1820            // For files backup, we need to append files incrementally, so we need ZipArchive or PclZip
     1821            // Try ZipArchive first
     1822            $zip = null;
     1823            $use_pclzip = false;
     1824           
     1825            if (class_exists('ZipArchive') || extension_loaded('zip')) {
     1826                try {
     1827                    $zip = new \ZipArchive();
     1828                    if ($zip_exists) {
     1829                        $zip_result = $zip->open($zip_file, \ZipArchive::CREATE);
     1830                        if ($zip_result !== TRUE) {
     1831                            $this->logger->warning('Cannot open existing ZIP file, recreating', [
     1832                                'backup_id' => $backup_id,
     1833                                'zip_file' => $zip_file,
     1834                                'zip_result' => $zip_result
     1835                            ]);
     1836                            @unlink($zip_file);
     1837                            $zip_exists = false;
     1838                        }
     1839                    }
     1840                    if (!$zip_exists) {
     1841                        $zip_result = $zip->open($zip_file, \ZipArchive::CREATE | \ZipArchive::OVERWRITE);
     1842                        if ($zip_result !== TRUE) {
     1843                            throw new \RuntimeException('Failed to create zip file: ' . $zip_result);
     1844                        }
     1845                    }
     1846                } catch (\Exception $e) {
     1847                    $this->logger->warning('ZipArchive failed, will use PclZip', [
    17131848                        'backup_id' => $backup_id,
    1714                         'zip_file' => $zip_file,
    1715                         'zip_result' => $zip_result
     1849                        'error' => $e->getMessage()
    17161850                    ]);
    1717                     @unlink($zip_file);
    1718                     $zip_result = $zip->open($zip_file, \ZipArchive::CREATE | \ZipArchive::OVERWRITE);
    1719                     if ($zip_result !== TRUE) {
    1720                         throw new \RuntimeException('Failed to create zip file: ' . $zip_result);
    1721                     }
    1722                     $zip_exists = false;
     1851                    $zip = null;
     1852                    $use_pclzip = true;
    17231853                }
    17241854            } else {
    1725                 // Create new ZIP file
    1726                 $zip_result = $zip->open($zip_file, \ZipArchive::CREATE | \ZipArchive::OVERWRITE);
    1727                 if ($zip_result !== TRUE) {
    1728                     throw new \RuntimeException('Failed to create zip file: ' . $zip_result);
     1855                $use_pclzip = true;
     1856            }
     1857           
     1858            // Fallback to PclZip if ZipArchive not available
     1859            $pclzip = null;
     1860            if ($use_pclzip) {
     1861                if (file_exists(ABSPATH . 'wp-admin/includes/class-pclzip.php')) {
     1862                    if (!class_exists('PclZip')) {
     1863                        require_once ABSPATH . 'wp-admin/includes/class-pclzip.php';
     1864                    }
     1865                    if (class_exists('PclZip')) {
     1866                        // Check if we have files in pclzip_files array - if so, we can safely recreate
     1867                        // If array is empty but ZIP exists, keep the ZIP (it was created in a previous chunk)
     1868                        $pclzip_files_count = isset($backup_info['pclzip_files']) ? count($backup_info['pclzip_files']) : 0;
     1869                       
     1870                        if ($zip_exists) {
     1871                            if ($pclzip_files_count > 0) {
     1872                                // We have files in array, can safely recreate ZIP
     1873                                $this->logger->info('PclZip mode: recreating ZIP file (append not well supported)', [
     1874                                    'backup_id' => $backup_id,
     1875                                    'pclzip_files_count' => $pclzip_files_count
     1876                                ]);
     1877                                @unlink($zip_file);
     1878                                $zip_exists = false;
     1879                            } else {
     1880                                // ZIP exists but array is empty - keep existing ZIP (from previous chunk)
     1881                                // It will be recreated at end of chunk with all files
     1882                                $this->logger->info('PclZip mode: keeping existing ZIP file (pclzip_files array empty, will recreate at end)', [
     1883                                    'backup_id' => $backup_id,
     1884                                    'existing_zip_size' => filesize($zip_file)
     1885                                ]);
     1886                            }
     1887                        }
     1888                        $pclzip = new \PclZip($zip_file);
     1889                    } else {
     1890                        throw new \RuntimeException(
     1891                            'ZipArchive and PclZip are not available. ' .
     1892                            'Please contact your hosting provider to enable the PHP zip extension.'
     1893                        );
     1894                    }
     1895                } else {
     1896                    throw new \RuntimeException(
     1897                        'ZipArchive and PclZip are not available. ' .
     1898                        'Please contact your hosting provider to enable the PHP zip extension.'
     1899                    );
    17291900                }
    17301901            }
     
    17341905            $processed_files = $backup_info['files_processed'] ?? [];
    17351906            $processed_files_set = array_flip($processed_files); // For fast lookup
     1907           
     1908            // For PclZip, ensure pclzip_files array is initialized and maintained across chunks
     1909            if ($use_pclzip && !isset($backup_info['pclzip_files'])) {
     1910                // If we're resuming (ZIP exists) and have processed files, rebuild pclzip_files array
     1911                // from the processed files list to preserve all previously added files
     1912                if ($zip_exists && !empty($processed_files)) {
     1913                    $this->logger->info('Rebuilding pclzip_files array from processed files list', [
     1914                        'backup_id' => $backup_id,
     1915                        'processed_count' => count($processed_files),
     1916                        'existing_zip_size' => filesize($zip_file)
     1917                    ]);
     1918                   
     1919                    // Rebuild pclzip_files by matching MD5 hashes from processed_files to actual file paths
     1920                    $backup_info['pclzip_files'] = [];
     1921                    $processed_files_set_for_rebuild = array_flip($processed_files);
     1922                    $pclzip_files_set = []; // Track files already added to prevent duplicates
     1923                   
     1924                    foreach ($files as $file) {
     1925                        if ($this->should_exclude($file)) {
     1926                            continue;
     1927                        }
     1928                        $file_key = md5($file);
     1929                        if (isset($processed_files_set_for_rebuild[$file_key])) {
     1930                            // This file was already processed, add it to pclzip_files
     1931                            // Use file path as key to prevent duplicates
     1932                            if (!isset($pclzip_files_set[$file])) {
     1933                                $backup_info['pclzip_files'][] = $file;
     1934                                $pclzip_files_set[$file] = true;
     1935                            }
     1936                        }
     1937                    }
     1938                   
     1939                    $this->logger->info('Rebuilt pclzip_files array', [
     1940                        'backup_id' => $backup_id,
     1941                        'rebuilt_count' => count($backup_info['pclzip_files']),
     1942                        'expected_count' => count($processed_files)
     1943                    ]);
     1944                   
     1945                    // Save the rebuilt array to backup_info
     1946                    update_option(self::OPTION_PREFIX . $backup_id, $backup_info);
     1947                } else {
     1948                    // New backup, initialize empty array
     1949                    $backup_info['pclzip_files'] = [];
     1950                    $this->logger->debug('Initialized pclzip_files array for new backup', [
     1951                        'backup_id' => $backup_id
     1952                    ]);
     1953                }
     1954            }
    17361955           
    17371956            // Store total files count for cron to check completion
     
    20042223
    20052224                    $relative_path = $this->get_relative_path($file, ABSPATH);
    2006                     if ($zip->addFile($file, $relative_path)) {
     2225                    // Add file using ZipArchive or PclZip
     2226                    $file_added = false;
     2227                    if ($zip !== null) {
     2228                        // Use ZipArchive
     2229                        $file_added = $zip->addFile($file, $relative_path);
     2230                    } elseif ($pclzip !== null) {
     2231                        // Use PclZip - add file to array for batch processing
     2232                        // We'll recreate ZIP with all files periodically
     2233                        if (!isset($backup_info['pclzip_files'])) {
     2234                            $backup_info['pclzip_files'] = [];
     2235                        }
     2236                        $backup_info['pclzip_files'][] = $file; // Store full path
     2237                        $file_added = true;
     2238                        // Save to database so we can recreate ZIP later
     2239                        update_option(self::OPTION_PREFIX . $backup_id, $backup_info);
     2240                    }
     2241                   
     2242                    if ($file_added) {
    20072243                    $file_key = md5($file);
    20082244                    $processed_files[] = $file_key;
     
    20222258
    20232259                // Update progress every 50 files (less frequent updates for better performance)
     2260                // For PclZip, recreate ZIP less frequently (every 500 files) to reduce risk of data loss
     2261                // and improve performance on slow hosts
    20242262                if ($processed_in_batch % 50 === 0) {
    20252263                    $processed = $already_processed + $processed_in_batch;
     
    20292267                    $backup_info['files_processed'] = $processed_files;
    20302268                    update_option(self::OPTION_PREFIX . $backup_id, $backup_info);
     2269                   
     2270                    // For PclZip, recreate ZIP periodically (every 500 files) since it doesn't support appending
     2271                    // Reduced frequency from every 50 files to reduce risk and improve performance
     2272                    if ($pclzip !== null && ($processed_in_batch % 500 === 0) && isset($backup_info['pclzip_files']) && !empty($backup_info['pclzip_files'])) {
     2273                        // Store old ZIP size for validation
     2274                        $old_zip_size = file_exists($zip_file) ? filesize($zip_file) : 0;
     2275                        $old_zip_file_count = count($backup_info['pclzip_files']);
     2276                       
     2277                        // Create temporary ZIP first to avoid data loss
     2278                        $temp_zip_file = $zip_file . '.tmp';
     2279                        @unlink($temp_zip_file); // Remove any existing temp file
     2280                        $pclzip_temp = new \PclZip($temp_zip_file);
     2281                        $result = $pclzip_temp->create($backup_info['pclzip_files'], PCLZIP_OPT_REMOVE_ALL_PATH);
     2282                       
     2283                        if ($result === 0) {
     2284                            $error = $pclzip_temp->errorInfo(true);
     2285                            $this->logger->warning('PclZip periodic recreate failed, keeping existing ZIP', [
     2286                                'backup_id' => $backup_id,
     2287                                'error' => $error,
     2288                                'files_count' => $old_zip_file_count,
     2289                                'old_zip_size' => $old_zip_size
     2290                            ]);
     2291                            @unlink($temp_zip_file); // Clean up failed temp file
     2292                            // Don't clear pclzip_files on failure - keep them for retry
     2293                        } else {
     2294                            // Verify temp ZIP was created successfully and has valid size
     2295                            if (file_exists($temp_zip_file)) {
     2296                                $temp_zip_size = filesize($temp_zip_file);
     2297                                if ($temp_zip_size > 0 && $temp_zip_size >= $old_zip_size) {
     2298                                    // New ZIP is valid and not smaller - replace old one
     2299                                    @unlink($zip_file); // Remove old ZIP
     2300                                    $replace_success = false;
     2301                                   
     2302                                    if (rename($temp_zip_file, $zip_file)) {
     2303                                        // Verify the rename succeeded by checking file exists and size
     2304                                        if (file_exists($zip_file) && filesize($zip_file) === $temp_zip_size) {
     2305                                            $replace_success = true;
     2306                                        } else {
     2307                                            $this->logger->warning('Rename appeared to succeed but file verification failed', [
     2308                                                'backup_id' => $backup_id,
     2309                                                'temp_file' => $temp_zip_file,
     2310                                                'zip_file' => $zip_file,
     2311                                                'expected_size' => $temp_zip_size,
     2312                                                'actual_size' => file_exists($zip_file) ? filesize($zip_file) : 0
     2313                                            ]);
     2314                                        }
     2315                                    }
     2316                                   
     2317                                    if (!$replace_success) {
     2318                                        // Rename failed or verification failed, try copy
     2319                                        if (file_exists($temp_zip_file) && copy($temp_zip_file, $zip_file)) {
     2320                                            // Verify copy succeeded
     2321                                            if (file_exists($zip_file) && filesize($zip_file) === $temp_zip_size) {
     2322                                                @unlink($temp_zip_file);
     2323                                                $replace_success = true;
     2324                                            } else {
     2325                                                @unlink($zip_file); // Remove incomplete copy
     2326                                                $this->logger->error('Copy appeared to succeed but file verification failed', [
     2327                                                    'backup_id' => $backup_id,
     2328                                                    'temp_file' => $temp_zip_file,
     2329                                                    'zip_file' => $zip_file,
     2330                                                    'expected_size' => $temp_zip_size,
     2331                                                    'actual_size' => file_exists($zip_file) ? filesize($zip_file) : 0
     2332                                                ]);
     2333                                            }
     2334                                        }
     2335                                    }
     2336                                   
     2337                                    if (!$replace_success) {
     2338                                        $this->logger->error('Failed to replace ZIP file after recreation', [
     2339                                            'backup_id' => $backup_id,
     2340                                            'temp_file' => $temp_zip_file,
     2341                                            'zip_file' => $zip_file,
     2342                                            'temp_size' => $temp_zip_size
     2343                                        ]);
     2344                                        // Keep temp file for potential recovery on next run
     2345                                        // Don't delete it here - let the cleanup logic handle it
     2346                                    } else {
     2347                                        $this->logger->debug('PclZip ZIP recreated with processed files', [
     2348                                            'backup_id' => $backup_id,
     2349                                            'files_count' => count($backup_info['pclzip_files']),
     2350                                            'zip_size' => $temp_zip_size,
     2351                                            'old_zip_size' => $old_zip_size
     2352                                        ]);
     2353                                    }
     2354                                } else {
     2355                                    $this->logger->warning('PclZip periodic recreate created invalid or smaller ZIP, keeping old one', [
     2356                                        'backup_id' => $backup_id,
     2357                                        'files_count' => count($backup_info['pclzip_files']),
     2358                                        'temp_zip_size' => $temp_zip_size,
     2359                                        'old_zip_size' => $old_zip_size
     2360                                    ]);
     2361                                    @unlink($temp_zip_file); // Remove invalid temp file
     2362                                    // Keep old ZIP and pclzip_files array
     2363                                }
     2364                            } else {
     2365                                $this->logger->warning('PclZip periodic recreate reported success but temp ZIP file was not created', [
     2366                                    'backup_id' => $backup_id,
     2367                                    'files_count' => count($backup_info['pclzip_files']),
     2368                                    'temp_zip_file' => $temp_zip_file
     2369                                ]);
     2370                                // Keep old ZIP and pclzip_files array
     2371                            }
     2372                        }
     2373                    }
    20312374                }
    20322375            }
     
    20402383            update_option(self::OPTION_PREFIX . $backup_id, $backup_info);
    20412384
    2042             // Close ZIP file
    2043             if (!$zip->close()) {
    2044                 throw new \RuntimeException('Failed to close ZIP archive');
     2385            // Close ZIP file (ZipArchive) or recreate ZIP (PclZip)
     2386            if ($zip !== null) {
     2387                if (!$zip->close()) {
     2388                    throw new \RuntimeException('Failed to close ZIP archive');
     2389                }
     2390            } elseif ($pclzip !== null) {
     2391                // For PclZip, final recreate ZIP with all processed files
     2392                $pclzip_files = $backup_info['pclzip_files'] ?? [];
     2393               
     2394                // If pclzip_files is empty but ZIP exists, it means files were already added in periodic recreations
     2395                // In this case, verify the existing ZIP is valid
     2396                if (empty($pclzip_files) && file_exists($zip_file) && filesize($zip_file) > 0) {
     2397                    $this->logger->info('PclZip files array empty but ZIP exists - using existing ZIP', [
     2398                        'backup_id' => $backup_id,
     2399                        'zip_size' => filesize($zip_file)
     2400                    ]);
     2401                } elseif (!empty($pclzip_files)) {
     2402                    // Filter to only existing files
     2403                    $existing_files = array_filter($pclzip_files, 'file_exists');
     2404                    if (!empty($existing_files)) {
     2405                        // Recreate ZIP with all files
     2406                        @unlink($zip_file); // Remove old ZIP
     2407                        $pclzip = new \PclZip($zip_file);
     2408                        // Use PCLZIP_OPT_REMOVE_ALL_PATH to add files with relative paths
     2409                        $result = $pclzip->create($existing_files, PCLZIP_OPT_REMOVE_ALL_PATH);
     2410                        if ($result === 0) {
     2411                            $error = $pclzip->errorInfo(true);
     2412                            throw new \RuntimeException('Failed to create ZIP with PclZip: ' . $error);
     2413                        }
     2414                       
     2415                        // Verify ZIP was created successfully
     2416                        if (!file_exists($zip_file)) {
     2417                            throw new \RuntimeException('PclZip reported success but ZIP file was not created: ' . $zip_file);
     2418                        }
     2419                       
     2420                        $zip_size = filesize($zip_file);
     2421                        if ($zip_size === false || $zip_size === 0) {
     2422                            throw new \RuntimeException('PclZip created ZIP file but it is empty or invalid: ' . $zip_file);
     2423                        }
     2424                       
     2425                        $this->logger->info('Final ZIP file recreated using PclZip', [
     2426                            'backup_id' => $backup_id,
     2427                            'files_count' => count($existing_files),
     2428                            'zip_size' => $zip_size
     2429                        ]);
     2430                    } else {
     2431                        $this->logger->warning('PclZip: No existing files found to add to ZIP', [
     2432                            'backup_id' => $backup_id,
     2433                            'pclzip_files_count' => count($pclzip_files)
     2434                        ]);
     2435                    }
     2436                    // Clear the array after successful creation
     2437                    unset($backup_info['pclzip_files']);
     2438                    update_option(self::OPTION_PREFIX . $backup_id, $backup_info);
     2439                } else {
     2440                    $this->logger->warning('PclZip: No files to process and no existing ZIP found', [
     2441                        'backup_id' => $backup_id,
     2442                        'zip_file_exists' => file_exists($zip_file)
     2443                    ]);
     2444                }
    20452445            }
    20462446
     
    20992499            }
    21002500
     2501            // Verify ZIP file exists and is valid before marking as complete
     2502            if (!file_exists($zip_file)) {
     2503                $this->logger->error('Files backup ZIP does not exist after processing completion', [
     2504                    'backup_id' => $backup_id,
     2505                    'zip_file' => $zip_file,
     2506                    'processed' => $processed,
     2507                    'total_files' => $total_files
     2508                ]);
     2509                throw new \RuntimeException('Files backup ZIP file does not exist: ' . $zip_file . '. Backup may have been interrupted during PclZip creation.');
     2510            }
     2511           
     2512            $zip_size = filesize($zip_file);
     2513            if ($zip_size === false || $zip_size === 0) {
     2514                $this->logger->error('Files backup ZIP is invalid or empty after processing completion', [
     2515                    'backup_id' => $backup_id,
     2516                    'zip_file' => $zip_file,
     2517                    'zip_size' => $zip_size,
     2518                    'processed' => $processed,
     2519                    'total_files' => $total_files
     2520                ]);
     2521                throw new \RuntimeException('Files backup ZIP file is invalid or empty: ' . $zip_file . ' (size: ' . ($zip_size === false ? 'false' : $zip_size) . '). Backup may have been interrupted during PclZip creation.');
     2522            }
     2523
    21012524            // Prepare file info for the backup
    21022525                $file_info = [
    2103                     'size' => filesize($zip_file),
     2526                    'size' => $zip_size,
    21042527                    'path' => $zip_file,
    21052528                    'download_url' => null // Will be set after upload
     
    30763499    /**
    30773500     * Backup table
    3078      * Improved version with chunked processing for large tables to prevent memory exhaustion
     3501     * Improved version with chunked processing for large tables, PK-based cursor pagination,
     3502     * and safer, mysqldump-style session handling/escaping (including BLOB-safe hex).
     3503     *
     3504     * Note: $table_name is currently ignored and all tables are dumped. It is kept
     3505     * in the signature for backward compatibility and potential future filtering.
    30793506     */
    30803507    private function backup_table(string $table_name, string $backup_file): void
     
    30823509        global $wpdb;
    30833510
    3084         // Check if file exists and is being written to (resume mode)
    3085         $is_resume = file_exists($backup_file);
    3086         $handle = fopen($backup_file, $is_resume ? 'a' : 'w');
     3511        // Best-effort: infer backup_id from file name so we can update progress for both
     3512        // classic DB backups (SITESKITE_BACKUP_PATH/{id}_db.sql) and incremental temp dumps
     3513        // (uploads/siteskite-backups/tmp/{id}_db.sql).
     3514        $backup_id_guess = null;
     3515        $base = basename($backup_file);
     3516        if (preg_match('/^(.+)_db\.sql$/', $base, $m)) {
     3517            $backup_id_guess = (string)$m[1];
     3518        }
     3519        $total_tables = null;
     3520        $tables_processed = 0;
     3521        $current_table_for_progress = null;
     3522        $last_progress_update = 0;
     3523        $progress_option_key = null;
     3524        if ($backup_id_guess) {
     3525            $progress_option_key = self::OPTION_PREFIX . $backup_id_guess;
     3526        }
     3527
     3528        // Always create a fresh dump file for correctness (resume handled at higher level)
     3529        $handle = fopen($backup_file, 'w');
    30873530        if (!$handle) {
    30883531            throw new \RuntimeException('Failed to create/open backup file');
    30893532        }
    30903533
    3091         // Only write header if starting fresh (not resuming)
    3092         if (!$is_resume) {
    3093         // Write header (deterministic - no timestamp to ensure consistent chunk hashes)
    3094         // Timestamp removed to prevent duplicate chunks with different hashes when database content is identical
     3534        // Header (deterministic - no timestamp) and session setup similar to mysqldump
    30953535        fwrite($handle, "-- SiteSkite Database Backup\n");
    30963536        fwrite($handle, "-- Generated by SiteSkite Backup Plugin\n\n");
    3097         // Removed NO_AUTO_VALUE_ON_ZERO to prevent duplicate key errors when importing
    3098         // This mode allows inserting 0 into auto-increment columns which can cause conflicts
     3537
    30993538        fwrite($handle, "SET AUTOCOMMIT = 0;\n");
    3100         fwrite($handle, "START TRANSACTION;\n");
    3101         fwrite($handle, "SET time_zone = \"+00:00\";\n\n");
    3102         }
     3539        fwrite($handle, "START TRANSACTION /*!40100 WITH CONSISTENT SNAPSHOT */;\n");
     3540
     3541        // Save and adjust session settings to make import behavior consistent
     3542        fwrite($handle, "/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;\n");
     3543        fwrite($handle, "/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;\n");
     3544        fwrite($handle, "/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;\n");
     3545        fwrite($handle, "/*!40101 SET NAMES utf8mb4 */;\n");
     3546        fwrite($handle, "/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;\n");
     3547        fwrite($handle, "/*!40103 SET TIME_ZONE='+00:00' */;\n");
     3548        fwrite($handle, "/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;\n");
     3549        fwrite($handle, "/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;\n");
     3550        fwrite($handle, "/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;\n");
     3551        fwrite($handle, "/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;\n");
     3552        // Also emit non-version-gated statements for maximum compatibility
     3553        fwrite($handle, "SET FOREIGN_KEY_CHECKS=0;\n");
     3554        fwrite($handle, "SET UNIQUE_CHECKS=0;\n\n");
    31033555
    31043556        // Get all tables (ensure deterministic ordering for consistent chunk hashes)
    31053557        $tables = $wpdb->get_results('SHOW TABLES', ARRAY_N);
    31063558        // Sort tables by name to ensure deterministic ordering
    3107         usort($tables, function($a, $b) {
     3559        usort($tables, function ($a, $b) {
    31083560            return strcmp($a[0], $b[0]);
    31093561        });
    3110        
     3562
     3563        $total_tables = is_array($tables) ? count($tables) : 0;
     3564        if ($progress_option_key && $total_tables > 0) {
     3565            $backup_info = get_option($progress_option_key);
     3566            if (is_array($backup_info)) {
     3567                $backup_info['db_total_tables'] = $total_tables;
     3568                $backup_info['db_tables_processed'] = 0;
     3569                $backup_info['db_current_table'] = null;
     3570                $backup_info['db_sql_size'] = 0;
     3571                $backup_info['current_stage'] = $backup_info['current_stage'] ?? 'database';
     3572                $backup_info['current_operation'] = $backup_info['current_operation'] ?? 'Creating database SQL dump';
     3573                update_option($progress_option_key, $backup_info);
     3574            }
     3575        }
     3576
    31113577        foreach ($tables as $table) {
    31123578            $current_table_name = $table[0];
    3113            
    3114             // $this->logger->info('Processing table for backup', [
    3115             //     'table' => $current_table_name,
    3116             //     'backup_file' => $backup_file
    3117             // ]);
    3118            
     3579            $tables_processed++;
     3580            $current_table_for_progress = $current_table_name;
     3581
    31193582            // Get table structure
    31203583            $create_table = $wpdb->get_row("SHOW CREATE TABLE `{$current_table_name}`", ARRAY_N);
    31213584            if ($create_table) {
     3585                fwrite($handle, "\n--\n");
     3586                fwrite($handle, "-- Table structure for table `{$current_table_name}`\n");
     3587                fwrite($handle, "--\n\n");
     3588
    31223589                fwrite($handle, "DROP TABLE IF EXISTS `{$current_table_name}`;\n");
    3123                 fwrite($handle, $create_table[1] . ";\n\n");
    3124             }
    3125 
    3126             // Get table row count to determine if chunking is needed
    3127             $row_count_result = $wpdb->get_var("SELECT COUNT(*) FROM `{$current_table_name}`");
    3128             $row_count = (int)$row_count_result;
    3129            
    3130             // $this->logger->info('Table row count', [
    3131             //     'table' => $current_table_name,
    3132             //     'row_count' => $row_count
    3133             // ]);
    3134 
    3135             // Get table data with deterministic ordering
    3136             // Use primary key or first column for ordering to ensure consistent row order
    3137             // This prevents duplicate chunks with different hashes when data is identical
    3138             $order_by = '';
    3139             $order_column = '';
    3140             $primary_key_info = $wpdb->get_row("SHOW KEYS FROM `{$current_table_name}` WHERE Key_name = 'PRIMARY'", ARRAY_A);
    3141             if ($primary_key_info && !empty($primary_key_info['Column_name'])) {
    3142                 $order_column = $primary_key_info['Column_name'];
    3143                 $order_by = " ORDER BY `{$order_column}`";
    3144             } else {
    3145                 // Fallback: use first column for ordering
    3146                 $first_col = $wpdb->get_col("SHOW COLUMNS FROM `{$current_table_name}` LIMIT 1");
    3147                 if (!empty($first_col)) {
    3148                     $order_column = $first_col[0];
    3149                     $order_by = " ORDER BY `{$order_column}`";
    3150                 }
    3151             }
    3152            
    3153             // Get column names in table order (not sorted) to match INSERT statement expectations
    3154             // We'll explicitly specify column names in INSERT to ensure correct mapping
     3590                fwrite($handle, "/*!40101 SET @saved_cs_client     = @@character_set_client */;\n");
     3591                // Keep character_set_client aligned with utf8mb4 to avoid surprises
     3592                fwrite($handle, "/*!40101 SET character_set_client = utf8mb4 */;\n");
     3593                fwrite($handle, $create_table[1] . ";\n");
     3594                fwrite($handle, "/*!40101 SET character_set_client = @saved_cs_client */;\n\n");
     3595            }
     3596
     3597            // Get column names and types in table order (not sorted)
    31553598            $column_info = $wpdb->get_results("SHOW COLUMNS FROM `{$current_table_name}`", ARRAY_A);
    31563599            $column_names = [];
     3600            $is_binary = [];
     3601            $column_types = [];
    31573602            foreach ($column_info as $col) {
    3158                 $column_names[] = $col['Field'];
    3159             }
    3160             // Keep column names in table order (don't sort) to match table structure
    3161             // We'll specify column names explicitly in INSERT statement for safety
    3162            
    3163             if ($row_count > 0) {
    3164                 fwrite($handle, "LOCK TABLES `{$current_table_name}` WRITE;\n");
    3165                
    3166                 // Process in chunks if table is large (>5000 rows)
    3167                 if ($row_count > 5000) {
    3168                     $this->logger->info('Processing large table in chunks', [
     3603                $field = $col['Field'];
     3604                $column_names[] = $field;
     3605                $type = strtolower((string) $col['Type']);
     3606                $column_types[$field] = $type;
     3607                $is_binary[$field] = (strpos($type, 'blob') !== false || strpos($type, 'binary') !== false);
     3608            }
     3609
     3610            // Short-circuit if there are no columns (should not happen in normal WordPress tables)
     3611            if (empty($column_names)) {
     3612                continue;
     3613            }
     3614
     3615            // Determine ordering/PK information
     3616            $order_column = $column_names[0];
     3617            $primary_key_rows = $wpdb->get_results("SHOW KEYS FROM `{$current_table_name}` WHERE Key_name = 'PRIMARY'", ARRAY_A);
     3618            $has_primary_key = !empty($primary_key_rows);
     3619            $is_composite_pk = $has_primary_key && count($primary_key_rows) > 1;
     3620            $has_single_column_pk = $has_primary_key && !$is_composite_pk && !empty($primary_key_rows[0]['Column_name']);
     3621
     3622            // Check for single-column unique key (for cursor pagination)
     3623            $unique_key_rows = $wpdb->get_results(
     3624                "SHOW KEYS FROM `{$current_table_name}` WHERE Non_unique = 0 AND Key_name != 'PRIMARY'",
     3625                ARRAY_A
     3626            );
     3627            $has_single_column_unique = false;
     3628            $unique_key_column = null;
     3629
     3630            // Group unique key columns by key name to find single-column unique indexes efficiently
     3631            if (!empty($unique_key_rows)) {
     3632                $key_columns = [];
     3633                foreach ($unique_key_rows as $uk_row) {
     3634                    $kname = $uk_row['Key_name'];
     3635                    if (!isset($key_columns[$kname])) {
     3636                        $key_columns[$kname] = [];
     3637                    }
     3638                    if (!empty($uk_row['Column_name'])) {
     3639                        $key_columns[$kname][] = $uk_row['Column_name'];
     3640                    }
     3641                }
     3642
     3643                foreach ($key_columns as $kname => $cols) {
     3644                    if (count($cols) === 1) {
     3645                        $has_single_column_unique = true;
     3646                        $unique_key_column = $cols[0];
     3647                        break;
     3648                    }
     3649                }
     3650            }
     3651
     3652            if ($has_single_column_pk) {
     3653                $order_column = $primary_key_rows[0]['Column_name'];
     3654            } elseif ($has_single_column_unique && $unique_key_column) {
     3655                $order_column = $unique_key_column;
     3656            } else {
     3657                // No single-column PK or unique key; prefer any unique key for ordering (even if composite)
     3658                $unique_key = $wpdb->get_row(
     3659                    "SHOW KEYS FROM `{$current_table_name}` WHERE Non_unique = 0 AND Key_name != 'PRIMARY' LIMIT 1",
     3660                    ARRAY_A
     3661                );
     3662                if ($unique_key && !empty($unique_key['Column_name'])) {
     3663                    $order_column = $unique_key['Column_name'];
     3664                }
     3665            }
     3666
     3667            // Determine if order_column is numeric type for correct cursor comparison
     3668            $order_type = $column_types[$order_column] ?? '';
     3669            $pk_is_numeric = (bool) preg_match('/int|decimal|float|double|bit/i', $order_type);
     3670
     3671            // Check if table has any data (cheap existence check instead of COUNT(*))
     3672            $has_data = (bool) $wpdb->get_var("SELECT 1 FROM `{$current_table_name}` LIMIT 1");
     3673
     3674            // Optionally get approximate row count from SHOW TABLE STATUS for logging
     3675            $table_status = $wpdb->get_row(
     3676                $wpdb->prepare("SHOW TABLE STATUS LIKE %s", $current_table_name),
     3677                ARRAY_A
     3678            );
     3679            $approximate_rows = isset($table_status['Rows']) ? (int) $table_status['Rows'] : 0;
     3680
     3681            if ($has_data) {
     3682                fwrite($handle, "--\n");
     3683                fwrite($handle, "-- Dumping data for table `{$current_table_name}`\n");
     3684                fwrite($handle, "--\n\n");
     3685
     3686                $column_list = '`' . implode('`, `', $column_names) . '`';
     3687                $chunk_size = 1000;
     3688                $max_rows_per_insert = 250;
     3689                $max_statement_bytes = 2 * 1024 * 1024; // ~2MB per INSERT batch
     3690
     3691                // Use cursor pagination when we have a single-column primary key OR single-column unique key
     3692                $use_cursor = $has_single_column_pk || $has_single_column_unique;
     3693
     3694                if ($use_cursor) {
     3695                    $last_pk = null;
     3696                    $cursor_type = $has_single_column_pk ? 'PRIMARY KEY' : 'UNIQUE KEY';
     3697
     3698                    $this->logger->info('Processing table with cursor pagination', [
    31693699                        'table' => $current_table_name,
    3170                         'row_count' => $row_count,
    3171                         'chunk_size' => 1000
     3700                        'approximate_rows' => $approximate_rows,
     3701                        'chunk_size' => $chunk_size,
     3702                        'cursor_column' => $order_column,
     3703                        'cursor_type' => $cursor_type,
    31723704                    ]);
    3173                    
    3174                     $chunk_size = 1000;
    3175                     $offset = 0;
    3176                     $first_chunk = true;
    3177                    
    3178                     while ($offset < $row_count) {
    3179                         // Get chunk of rows
    3180                         $chunk_query = "SELECT * FROM `{$current_table_name}`{$order_by} LIMIT {$chunk_size} OFFSET {$offset}";
    3181                         $rows = $wpdb->get_results($chunk_query, ARRAY_A);
    3182                        
     3705
     3706                    while (true) {
     3707                        // Build WHERE clause for PK cursor
     3708                        if ($last_pk === null) {
     3709                            $where = '';
     3710                        } else {
     3711                            // Use prepare to avoid injection and keep correct quoting, with correct type
     3712                            if ($pk_is_numeric) {
     3713                                $where = $wpdb->prepare(" WHERE `{$order_column}` > %d", (int) $last_pk);
     3714                            } else {
     3715                                $where = $wpdb->prepare(" WHERE `{$order_column}` > %s", (string) $last_pk);
     3716                            }
     3717                        }
     3718
     3719                        $query = "SELECT * FROM `{$current_table_name}`{$where} ORDER BY `{$order_column}` ASC LIMIT {$chunk_size}";
     3720                        $rows = $wpdb->get_results($query, ARRAY_A);
     3721
    31833722                        if (empty($rows)) {
    3184                             break; // No more rows
     3723                            break;
    31853724                        }
    3186                        
    3187                         // Write INSERT statement for this chunk
    3188                         // Explicitly specify column names to ensure correct value mapping
    3189                         if ($first_chunk) {
    3190                             $column_list = '`' . implode('`, `', $column_names) . '`';
    3191                             fwrite($handle, "INSERT INTO `{$current_table_name}` ({$column_list}) VALUES ");
    3192                             $first_chunk = false;
    3193                         } else {
    3194                             fwrite($handle, ",\n");
    3195                         }
    3196                        
     3725
    31973726                        $values = [];
     3727                        $values_count = 0;
     3728                        $statement_bytes = 0;
    31983729                        foreach ($rows as $row) {
    3199                             // Build values array in deterministic column order
    3200                             // Use wpdb->prepare for each value to ensure consistent escaping
    32013730                            $escaped_values = [];
    32023731                            foreach ($column_names as $col_name) {
     
    32043733                                if ($value === null) {
    32053734                                    $escaped_values[] = 'NULL';
    3206                                 } elseif (is_numeric($value) && !is_string($value)) {
    3207                                     // Numeric values (int/float) - use as-is
    3208                                     $escaped_values[] = $value;
     3735                                } elseif ($is_binary[$col_name]) {
     3736                                    // Export binary data as hex, like mysqldump
     3737                                    $escaped_values[] = '0x' . bin2hex((string) $value);
    32093738                                } else {
    3210                                     // String values - use wpdb->prepare for consistent escaping
    3211                                     $escaped_values[] = $wpdb->prepare('%s', $value);
     3739                                    // Prefer mysqli_real_escape_string when available (more robust than private API)
     3740                                    $value_str = (string) $value;
     3741                                    if (is_object($wpdb->dbh) && $wpdb->dbh instanceof \mysqli) {
     3742                                        $escaped = mysqli_real_escape_string($wpdb->dbh, $value_str);
     3743                                    } elseif (method_exists($wpdb, '_real_escape')) {
     3744                                        /** @phpstan-ignore-next-line - internal wpdb API */
     3745                                        $escaped = $wpdb->_real_escape($value_str);
     3746                                    } else {
     3747                                        $escaped = addslashes($value_str);
     3748                                    }
     3749                                    $escaped_values[] = "'" . $escaped . "'";
    32123750                                }
    32133751                            }
    3214                             $values[] = '(' . implode(',', $escaped_values) . ')';
     3752                            $tuple = '(' . implode(',', $escaped_values) . ')';
     3753                            $values[] = $tuple;
     3754                            $values_count++;
     3755                            $statement_bytes += strlen($tuple);
     3756
     3757                            // Flush INSERT in batches to avoid huge packets
     3758                            if ($values_count >= $max_rows_per_insert || $statement_bytes >= $max_statement_bytes) {
     3759                                fwrite($handle, "INSERT INTO `{$current_table_name}` ({$column_list}) VALUES ");
     3760                                fwrite($handle, implode(', ', $values) . ";\n");
     3761                                $values = [];
     3762                                $values_count = 0;
     3763                                $statement_bytes = 0;
     3764                            }
     3765
     3766                            // Track last PK for cursor
     3767                            $last_pk = $row[$order_column];
    32153768                        }
    3216                        
    3217                         fwrite($handle, implode(',', $values));
    3218                        
     3769
     3770                        // Remaining values
     3771                        if ($values_count > 0) {
     3772                            fwrite($handle, "INSERT INTO `{$current_table_name}` ({$column_list}) VALUES ");
     3773                            fwrite($handle, implode(', ', $values) . ";\n");
     3774                        }
     3775
     3776                        unset($rows, $values);
     3777                    }
     3778                } else {
     3779                    // Fallback: OFFSET-based pagination (only when no single-column PK/unique key)
     3780                    // This is acceptable for small/medium tables but may be slow on very large tables
     3781                    $this->logger->warning('Processing table with OFFSET pagination (no single-column PK or unique key found)', [
     3782                        'table' => $current_table_name,
     3783                        'approximate_rows' => $approximate_rows,
     3784                        'chunk_size' => $chunk_size,
     3785                        'order_column' => $order_column,
     3786                    ]);
     3787
     3788                    $offset = 0;
     3789                    $order_by = " ORDER BY `{$order_column}`";
     3790
     3791                    // Use a simple loop that continues until no more rows are returned
     3792                    // (we don't need exact count since we check for empty results)
     3793                    while (true) {
     3794                        $chunk_query = "SELECT * FROM `{$current_table_name}`{$order_by} LIMIT {$chunk_size} OFFSET {$offset}";
     3795                        $rows = $wpdb->get_results($chunk_query, ARRAY_A);
     3796
     3797                        if (empty($rows)) {
     3798                            break;
     3799                        }
     3800
     3801                        $values = [];
     3802                        $values_count = 0;
     3803                        $statement_bytes = 0;
     3804                        foreach ($rows as $row) {
     3805                            $escaped_values = [];
     3806                            foreach ($column_names as $col_name) {
     3807                                $value = $row[$col_name] ?? null;
     3808                                if ($value === null) {
     3809                                    $escaped_values[] = 'NULL';
     3810                                } elseif ($is_binary[$col_name]) {
     3811                                    $escaped_values[] = '0x' . bin2hex((string) $value);
     3812                                } else {
     3813                                    // Prefer mysqli_real_escape_string when available (more robust than private API)
     3814                                    $value_str = (string) $value;
     3815                                    if (is_object($wpdb->dbh) && $wpdb->dbh instanceof \mysqli) {
     3816                                        $escaped = mysqli_real_escape_string($wpdb->dbh, $value_str);
     3817                                    } elseif (method_exists($wpdb, '_real_escape')) {
     3818                                        /** @phpstan-ignore-next-line - internal wpdb API */
     3819                                        $escaped = $wpdb->_real_escape($value_str);
     3820                                    } else {
     3821                                        $escaped = addslashes($value_str);
     3822                                    }
     3823                                    $escaped_values[] = "'" . $escaped . "'";
     3824                                }
     3825                            }
     3826                            $tuple = '(' . implode(',', $escaped_values) . ')';
     3827                            $values[] = $tuple;
     3828                            $values_count++;
     3829                            $statement_bytes += strlen($tuple);
     3830
     3831                            if ($values_count >= $max_rows_per_insert || $statement_bytes >= $max_statement_bytes) {
     3832                                fwrite($handle, "INSERT INTO `{$current_table_name}` ({$column_list}) VALUES ");
     3833                                fwrite($handle, implode(', ', $values) . ";\n");
     3834                                $values = [];
     3835                                $values_count = 0;
     3836                                $statement_bytes = 0;
     3837                            }
     3838                        }
     3839
     3840                        if ($values_count > 0) {
     3841                            fwrite($handle, "INSERT INTO `{$current_table_name}` ({$column_list}) VALUES ");
     3842                            fwrite($handle, implode(', ', $values) . ";\n");
     3843                        }
     3844
     3845                        // Break if we got fewer rows than chunk_size (end of table)
     3846                        if (count($rows) < $chunk_size) {
     3847                            break;
     3848                        }
     3849
    32193850                        $offset += $chunk_size;
    3220                        
    3221                         // Log progress every 10 chunks
    3222                         if (($offset / $chunk_size) % 10 === 0) {
    3223                             // $this->logger->info('Table backup progress', [
    3224                             //     'table' => $current_table_name,
    3225                             //     'processed_rows' => $offset,
    3226                             //     'total_rows' => $row_count,
    3227                             //     'progress_percent' => round(($offset / $row_count) * 100, 2)
    3228                             // ]);
    3229                         }
    3230                        
    3231                         // Clear memory
    32323851                        unset($rows, $values);
    32333852                    }
    3234                    
    3235                     fwrite($handle, ";\n");
    3236                     $this->logger->info('Completed chunked processing for large table', [
    3237                         'table' => $current_table_name,
    3238                         'total_rows' => $row_count
    3239                     ]);
    3240                 } else {
    3241                     // Small table - process all at once (original behavior)
    3242                     $rows = $wpdb->get_results("SELECT * FROM `{$current_table_name}`{$order_by}", ARRAY_A);
    3243                    
    3244                     if (!empty($rows)) {
    3245                         // Explicitly specify column names to ensure correct value mapping
    3246                         $column_list = '`' . implode('`, `', $column_names) . '`';
    3247                         fwrite($handle, "INSERT INTO `{$current_table_name}` ({$column_list}) VALUES ");
    3248                
    3249                 $values = [];
    3250                 foreach ($rows as $row) {
    3251                     // Build values array in deterministic column order
    3252                     // Use wpdb->prepare for each value to ensure consistent escaping
    3253                     $escaped_values = [];
    3254                     foreach ($column_names as $col_name) {
    3255                         $value = $row[$col_name] ?? null;
    3256                         if ($value === null) {
    3257                             $escaped_values[] = 'NULL';
    3258                         } elseif (is_numeric($value) && !is_string($value)) {
    3259                             // Numeric values (int/float) - use as-is
    3260                             $escaped_values[] = $value;
    3261                         } else {
    3262                             // String values - use wpdb->prepare for consistent escaping
    3263                             $escaped_values[] = $wpdb->prepare('%s', $value);
    3264                         }
    3265                     }
    3266                     $values[] = '(' . implode(',', $escaped_values) . ')';
    3267                 }
    3268                
    3269                 fwrite($handle, implode(',', $values) . ";\n");
    3270                     }
    3271                 }
    3272                
    3273                 fwrite($handle, "UNLOCK TABLES;\n\n");
    3274             }
    3275         }
     3853                }
     3854            }
     3855
     3856            // Throttled progress updates (once every ~2s) so the external progress endpoint
     3857            // can show table-level progress during long DB dumps without excessive option writes.
     3858            if ($progress_option_key && $total_tables && (time() - $last_progress_update) >= 2) {
     3859                $backup_info = get_option($progress_option_key);
     3860                if (is_array($backup_info)) {
     3861                    $backup_info['db_tables_processed'] = $tables_processed;
     3862                    $backup_info['db_total_tables'] = $total_tables;
     3863                    $backup_info['db_current_table'] = $current_table_for_progress;
     3864                    $bytes_written = ftell($handle);
     3865                    if ($bytes_written !== false) {
     3866                        $backup_info['db_sql_size'] = (int)$bytes_written;
     3867                    }
     3868                    $backup_info['current_stage'] = $backup_info['current_stage'] ?? 'database';
     3869                    $backup_info['current_operation'] = sprintf(
     3870                        'Dumping database tables: %d/%d (%s)',
     3871                        $tables_processed,
     3872                        $total_tables,
     3873                        $current_table_for_progress
     3874                    );
     3875                    update_option($progress_option_key, $backup_info);
     3876                }
     3877                $last_progress_update = time();
     3878            }
     3879        }
     3880
     3881        // Restore session variables and finalize transaction
     3882        fwrite($handle, "\n");
     3883        fwrite($handle, "/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;\n");
     3884        fwrite($handle, "/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;\n");
     3885        fwrite($handle, "/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;\n");
     3886        fwrite($handle, "/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;\n");
     3887        fwrite($handle, "/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;\n");
     3888        fwrite($handle, "/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;\n");
     3889        fwrite($handle, "/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;\n");
     3890        fwrite($handle, "/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;\n");
     3891        // Ensure checks are re-enabled even if version comments are ignored
     3892        fwrite($handle, "SET FOREIGN_KEY_CHECKS=1;\n");
     3893        fwrite($handle, "SET UNIQUE_CHECKS=1;\n\n");
    32763894
    32773895        fwrite($handle, "COMMIT;\n");
    32783896        fclose($handle);
    3279        
     3897
     3898        // Final progress update for SQL dump stats.
     3899        if ($progress_option_key && $total_tables) {
     3900            $backup_info = get_option($progress_option_key);
     3901            if (is_array($backup_info)) {
     3902                $backup_info['db_tables_processed'] = $tables_processed;
     3903                $backup_info['db_total_tables'] = $total_tables;
     3904                $backup_info['db_current_table'] = null;
     3905                $backup_info['db_sql_size'] = file_exists($backup_file) ? (int)filesize($backup_file) : ($backup_info['db_sql_size'] ?? null);
     3906                $backup_info['current_stage'] = $backup_info['current_stage'] ?? 'database';
     3907                $backup_info['current_operation'] = 'Database SQL dump completed';
     3908                update_option($progress_option_key, $backup_info);
     3909            }
     3910        }
     3911
    32803912        $this->logger->info('Database backup SQL dump completed', [
    32813913            'backup_file' => $backup_file,
    3282             'file_size' => filesize($backup_file)
     3914            'file_size' => filesize($backup_file),
    32833915        ]);
    32843916    }
     
    33744006
    33754007    /**
     4008     * Create ZIP file with ZipArchive or PclZip fallback
     4009     *
     4010     * @param string $zip_file Path to output ZIP file
     4011     * @param string|array $source_files Single file path or array of file paths to add
     4012     * @param string $backup_id Backup ID for logging
     4013     * @param array $file_names Optional array of names to use inside ZIP (same order as source_files)
     4014     * @return bool True if ZIP was created successfully
     4015     * @throws \RuntimeException If both methods fail
     4016     */
     4017    private function create_zip_file($zip_file, $source_files, string $backup_id, array $file_names = []): bool
     4018    {
     4019        // Normalize source files to array
     4020        if (is_string($source_files)) {
     4021            $source_files = [$source_files];
     4022        }
     4023       
     4024        // Method 1: Try ZipArchive (preferred)
     4025        if (class_exists('ZipArchive') || extension_loaded('zip')) {
     4026            try {
     4027                $zip = new \ZipArchive();
     4028                $zip_result = $zip->open($zip_file, \ZipArchive::CREATE | \ZipArchive::OVERWRITE);
     4029                if ($zip_result === TRUE) {
     4030                    foreach ($source_files as $index => $file) {
     4031                        if (file_exists($file)) {
     4032                            // Use provided name or basename
     4033                            $zip_name = isset($file_names[$index]) ? $file_names[$index] : basename($file);
     4034                            $zip->addFile($file, $zip_name);
     4035                        }
     4036                    }
     4037                    if ($zip->close()) {
     4038                        $this->logger->info('ZIP file created using ZipArchive', [
     4039                            'backup_id' => $backup_id,
     4040                            'zip_file' => $zip_file,
     4041                            'method' => 'ZipArchive'
     4042                        ]);
     4043                        return true;
     4044                    }
     4045                }
     4046            } catch (\Exception $e) {
     4047                $this->logger->warning('ZipArchive failed, trying PclZip fallback', [
     4048                    'backup_id' => $backup_id,
     4049                    'error' => $e->getMessage()
     4050                ]);
     4051            }
     4052        }
     4053       
     4054        // Method 2: Try PclZip (WordPress includes this)
     4055        if (file_exists(ABSPATH . 'wp-admin/includes/class-pclzip.php')) {
     4056            try {
     4057                if (!class_exists('PclZip')) {
     4058                    require_once ABSPATH . 'wp-admin/includes/class-pclzip.php';
     4059                }
     4060                if (class_exists('PclZip')) {
     4061                    $zip = new \PclZip($zip_file);
     4062                    $files_to_add = [];
     4063                    $temp_files = []; // Track temp files for cleanup
     4064                   
     4065                    foreach ($source_files as $index => $file) {
     4066                        if (file_exists($file)) {
     4067                            $zip_name = isset($file_names[$index]) ? $file_names[$index] : basename($file);
     4068                           
     4069                            // If we need a custom name, create temp file with that name
     4070                            if (isset($file_names[$index]) && $file_names[$index] !== basename($file)) {
     4071                                $temp_dir = sys_get_temp_dir();
     4072                                $temp_file = $temp_dir . '/siteskite_' . uniqid() . '_' . $zip_name;
     4073                                if (@copy($file, $temp_file)) {
     4074                                    $files_to_add[] = $temp_file;
     4075                                    $temp_files[] = $temp_file;
     4076                                } else {
     4077                                    // Fallback: add original file
     4078                                    $files_to_add[] = $file;
     4079                                }
     4080                            } else {
     4081                                // Use original file
     4082                                $files_to_add[] = $file;
     4083                            }
     4084                        }
     4085                    }
     4086                   
     4087                    if (!empty($files_to_add)) {
     4088                        // Remove all paths so files are added with just their names
     4089                        $result = $zip->create($files_to_add, PCLZIP_OPT_REMOVE_ALL_PATH);
     4090                       
     4091                        // Clean up temp files
     4092                        foreach ($temp_files as $temp_file) {
     4093                            @unlink($temp_file);
     4094                        }
     4095                       
     4096                        if ($result !== 0) {
     4097                            $this->logger->info('ZIP file created using PclZip', [
     4098                                'backup_id' => $backup_id,
     4099                                'zip_file' => $zip_file,
     4100                                'method' => 'PclZip',
     4101                                'files_count' => count($files_to_add)
     4102                            ]);
     4103                            return true;
     4104                        } else {
     4105                            $error = $zip->errorInfo(true);
     4106                            $this->logger->warning('PclZip create failed', [
     4107                                'backup_id' => $backup_id,
     4108                                'error' => $error
     4109                            ]);
     4110                        }
     4111                    }
     4112                }
     4113            } catch (\Exception $e) {
     4114                $this->logger->warning('PclZip failed', [
     4115                    'backup_id' => $backup_id,
     4116                    'error' => $e->getMessage()
     4117                ]);
     4118            }
     4119        }
     4120       
     4121        // All methods failed
     4122        throw new \RuntimeException(
     4123            'Unable to create ZIP file. ZipArchive and PclZip are not available. ' .
     4124            'Please contact your hosting provider to enable the PHP zip extension.'
     4125        );
     4126    }
     4127
     4128    /**
    33764129     * Create lock file for files backup
    33774130     */
     
    33804133        $lock_file = SITESKITE_BACKUP_PATH . '/' . $backup_id . '_files.lock';
    33814134       
    3382         // Check if lock file exists and is stale (older than 1 hour)
     4135        // Check if lock file exists and is stale
    33834136        if (file_exists($lock_file)) {
    33844137            $lock_age = time() - filemtime($lock_file);
    3385             if ($lock_age > 3600) {
     4138           
     4139            // Check if process is actually running by reading PID from lock file
     4140            $lock_content = @file_get_contents($lock_file);
     4141            $is_process_running = false;
     4142            if ($lock_content !== false) {
     4143                $lines = explode("\n", trim($lock_content));
     4144                if (isset($lines[1]) && is_numeric($lines[1])) {
     4145                    $pid = (int)$lines[1];
     4146                    // Check if process is running (works on Unix-like systems)
     4147                    if (function_exists('posix_kill')) {
     4148                        $is_process_running = @posix_kill($pid, 0);
     4149                    } elseif (PHP_OS_FAMILY !== 'Windows') {
     4150                        // Fallback: check if /proc/PID exists (Linux)
     4151                        $is_process_running = file_exists("/proc/{$pid}");
     4152                    }
     4153                }
     4154            }
     4155           
     4156            // Reduced timeout to 20 minutes (1200 seconds) for faster recovery
     4157            // If process is not running, consider lock stale after 5 minutes
     4158            $stale_threshold = $is_process_running ? 1200 : 300;
     4159           
     4160            if ($lock_age > $stale_threshold) {
    33864161                // Lock is stale, remove it
    33874162                $this->logger->warning('Removing stale files backup lock file', [
    33884163                    'backup_id' => $backup_id,
    33894164                    'lock_file' => $lock_file,
    3390                     'lock_age_seconds' => $lock_age
     4165                    'lock_age_seconds' => $lock_age,
     4166                    'process_running' => $is_process_running,
     4167                    'stale_threshold' => $stale_threshold
    33914168                ]);
    33924169                @unlink($lock_file);
     
    33964173                    'backup_id' => $backup_id,
    33974174                    'lock_file' => $lock_file,
    3398                     'lock_age_seconds' => $lock_age
     4175                    'lock_age_seconds' => $lock_age,
     4176                    'process_running' => $is_process_running
    33994177                ]);
    34004178                return false;
     
    46815459        $fileSize = filesize($sqlPath);
    46825460        $this->logger->info('Database dump created', ['file_size' => $fileSize]);
     5461
     5462        // Seed detailed DB progress fields for the progress endpoint/UI.
     5463        // This helps the UI show real progress for large DBs (byte/chunk level), not just "completed".
     5464        $bi_seed = get_option(self::OPTION_PREFIX . $backup_id);
     5465        if (is_array($bi_seed)) {
     5466            $bi_seed['db_total_bytes'] = (int)$fileSize;
     5467            $bi_seed['db_bytes_processed'] = 0;
     5468            $bi_seed['db_chunks_processed'] = 0;
     5469            $bi_seed['db_chunks_total_estimate'] = null; // filled once chunk size known
     5470            $bi_seed['db_new_chunks_count'] = 0;
     5471            $bi_seed['db_reused_chunks_count'] = 0;
     5472            $bi_seed['current_stage'] = 'database';
     5473            $bi_seed['current_operation'] = 'Preparing incremental database chunks';
     5474            update_option(self::OPTION_PREFIX . $backup_id, $bi_seed);
     5475        }
    46835476       
    46845477        // IMPORTANT: Create manifest BEFORE creating chunks on disk
     
    46925485        // Use centralized chunk size from config
    46935486        $chunkSize = $this->backup_config->chunkSizeBytes;
     5487        $chunks_total_estimate = $chunkSize > 0 ? (int)ceil($fileSize / $chunkSize) : null;
     5488        $last_db_progress_update_ts = 0;
    46945489        $stream = fopen($sqlPath, 'rb');
    46955490        if (!$stream) {
     
    48165611                }
    48175612            }
     5613
     5614            // Throttled progress updates (once every ~2 seconds) to expose chunk/byte level progress.
     5615            if ((time() - (int)$last_db_progress_update_ts) >= 2) {
     5616                $bi = get_option(self::OPTION_PREFIX . $backup_id);
     5617                if (is_array($bi)) {
     5618                    $bi['db_total_bytes'] = (int)$fileSize;
     5619                    $bi['db_bytes_processed'] = (int)$bytes_processed;
     5620                    $bi['db_chunks_processed'] = count($chunkHashes);
     5621                    $bi['db_chunks_total_estimate'] = $chunks_total_estimate;
     5622                    $bi['db_new_chunks_count'] = count($new_chunks);
     5623                    $bi['db_reused_chunks_count'] = count($reused_chunks);
     5624
     5625                    $pct = ($fileSize > 0) ? (int)round(($bytes_processed / $fileSize) * 100) : 0;
     5626                    $bi['current_stage'] = 'database';
     5627                    $bi['current_operation'] = sprintf(
     5628                        'Processing incremental DB: %d%% (%s/%s)',
     5629                        $pct,
     5630                        size_format((int)$bytes_processed, 2),
     5631                        size_format((int)$fileSize, 2)
     5632                    );
     5633
     5634                    // Keep overall progress moving for db-only incremental backups (10 -> 70 during chunking).
     5635                    $existing_progress = (int)($bi['progress'] ?? 10);
     5636                    $scaled = 10 + (int)round(($pct / 100) * 60);
     5637                    $bi['progress'] = max($existing_progress, min(70, $scaled));
     5638
     5639                    update_option(self::OPTION_PREFIX . $backup_id, $bi);
     5640                }
     5641                $last_db_progress_update_ts = time();
     5642            }
    48185643        }
    48195644       
    48205645        fclose($stream);
     5646
     5647        // Final progress update for chunking stats.
     5648        $bi = get_option(self::OPTION_PREFIX . $backup_id);
     5649        if (is_array($bi)) {
     5650            $bi['db_total_bytes'] = (int)$fileSize;
     5651            $bi['db_bytes_processed'] = (int)$bytes_processed;
     5652            $bi['db_chunks_processed'] = count($chunkHashes);
     5653            $bi['db_chunks_total_estimate'] = $chunks_total_estimate;
     5654            $bi['db_new_chunks_count'] = count($new_chunks);
     5655            $bi['db_reused_chunks_count'] = count($reused_chunks);
     5656            $bi['current_stage'] = 'database';
     5657            $bi['current_operation'] = 'Incremental DB chunking completed';
     5658            $bi['progress'] = max((int)($bi['progress'] ?? 10), 70);
     5659            update_option(self::OPTION_PREFIX . $backup_id, $bi);
     5660        }
    48215661       
    48225662        $this->logger->info('Database chunking completed', [
     
    55686408            }
    55696409           
    5570             $zip = new \ZipArchive();
    5571             $zipOpenResult = $zip->open($tempZipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE);
    5572            
    5573             if ($zipOpenResult !== true) {
    5574                 $this->logger->error('Failed to open zip archive', [
    5575                     'backup_id' => $backup_id,
    5576                     'bundle_index' => $bundleIndex,
    5577                     'error_code' => $zipOpenResult
    5578                 ]);
    5579                 if (function_exists('wp_delete_file')) {
    5580                     wp_delete_file($tempZipPath);
     6410            // Try ZipArchive first, fallback to PclZip
     6411            $zip = null;
     6412            $pclzip = null;
     6413           
     6414            if (class_exists('ZipArchive') || extension_loaded('zip')) {
     6415                try {
     6416                    $zip = new \ZipArchive();
     6417                } catch (\Exception $e) {
     6418                    $this->logger->warning('ZipArchive failed for bundle, trying PclZip', [
     6419                        'backup_id' => $backup_id,
     6420                        'bundle_index' => $bundleIndex,
     6421                        'error' => $e->getMessage()
     6422                    ]);
     6423                }
     6424            }
     6425           
     6426            // Fallback to PclZip
     6427            if ($zip === null) {
     6428                if (file_exists(ABSPATH . 'wp-admin/includes/class-pclzip.php')) {
     6429                    if (!class_exists('PclZip')) {
     6430                        require_once ABSPATH . 'wp-admin/includes/class-pclzip.php';
     6431                    }
     6432                    if (class_exists('PclZip')) {
     6433                        $pclzip = new \PclZip($tempZipPath);
     6434                    } else {
     6435                        $this->logger->error('Neither ZipArchive nor PclZip available for bundle processing', [
     6436                            'backup_id' => $backup_id,
     6437                            'bundle_index' => $bundleIndex
     6438                        ]);
     6439                        continue; // Skip this bundle
     6440                    }
    55816441                } else {
    5582                     @unlink($tempZipPath);
    5583                 }
    5584                 continue;
     6442                    $this->logger->error('Neither ZipArchive nor PclZip available for bundle processing', [
     6443                        'backup_id' => $backup_id,
     6444                        'bundle_index' => $bundleIndex
     6445                    ]);
     6446                    continue; // Skip this bundle
     6447                }
     6448            }
     6449           
     6450            // Open/create ZIP
     6451            if ($zip !== null) {
     6452                $zipOpenResult = $zip->open($tempZipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE);
     6453                if ($zipOpenResult !== true) {
     6454                    $this->logger->error('Failed to open zip archive', [
     6455                        'backup_id' => $backup_id,
     6456                        'bundle_index' => $bundleIndex,
     6457                        'error_code' => $zipOpenResult
     6458                    ]);
     6459                    if (function_exists('wp_delete_file')) {
     6460                        wp_delete_file($tempZipPath);
     6461                    } else {
     6462                        @unlink($tempZipPath);
     6463                    }
     6464                    continue;
     6465                }
    55856466            }
    55866467           
     
    55896470            $successfulChunks = 0;
    55906471           
     6472            // For PclZip, collect chunk files to add at once
     6473            $pclzip_chunk_files = [];
     6474           
    55916475            $this->logger->debug('Starting to add chunks to bundle ZIP', [
    55926476                'backup_id' => $backup_id,
    55936477                'bundle_index' => $bundleIndex + 1,
    5594                 'total_chunks_to_add' => count($bundleChunkHashes)
     6478                'total_chunks_to_add' => count($bundleChunkHashes),
     6479                'method' => $zip !== null ? 'ZipArchive' : 'PclZip'
    55956480            ]);
    55966481           
     
    56456530                    if ($chunkData !== false && $chunkData !== null && $actualSize > 0 && $actualSize <= $expectedSize) {
    56466531                        // Add to zip with hash as filename
    5647                         $zip->addFromString($chunkHash . '.blob', $chunkData);
     6532                        if ($zip !== null) {
     6533                            // Use ZipArchive
     6534                            $zip->addFromString($chunkHash . '.blob', $chunkData);
     6535                        } elseif ($pclzip !== null) {
     6536                            // Use PclZip - write chunk to temp file with hash as name, collect for batch add
     6537                            $tempChunkFile = sys_get_temp_dir() . '/siteskite_' . $chunkHash . '.blob';
     6538                            if (file_put_contents($tempChunkFile, $chunkData) !== false) {
     6539                                $pclzip_chunk_files[] = $tempChunkFile;
     6540                            } else {
     6541                                $this->logger->warning('Failed to write chunk to temp file for PclZip', [
     6542                                    'backup_id' => $backup_id,
     6543                                    'chunk_hash' => $chunkHash
     6544                                ]);
     6545                                continue; // Skip this chunk
     6546                            }
     6547                        }
    56486548                        $bundleChunks[] = $chunkHash;
    56496549                        $bundleTotalSize += $actualSize;
     
    56776577            ]);
    56786578           
    5679             $zip->close();
     6579            // Close ZIP (ZipArchive) or create ZIP with all chunks (PclZip)
     6580            if ($zip !== null) {
     6581                $zip->close();
     6582            } elseif ($pclzip !== null && !empty($pclzip_chunk_files)) {
     6583                // PclZip: create ZIP with all collected chunk files
     6584                @unlink($tempZipPath); // Remove if exists
     6585                $pclzip = new \PclZip($tempZipPath);
     6586                $result = $pclzip->create($pclzip_chunk_files, PCLZIP_OPT_REMOVE_ALL_PATH);
     6587                // Clean up temp chunk files
     6588                foreach ($pclzip_chunk_files as $temp_file) {
     6589                    @unlink($temp_file);
     6590                }
     6591                if ($result === 0) {
     6592                    $error = $pclzip->errorInfo(true);
     6593                    $this->logger->error('PclZip bundle creation failed', [
     6594                        'backup_id' => $backup_id,
     6595                        'bundle_index' => $bundleIndex,
     6596                        'error' => $error
     6597                    ]);
     6598                    continue; // Skip this bundle
     6599                }
     6600                $this->logger->debug('PclZip bundle created successfully', [
     6601                    'backup_id' => $backup_id,
     6602                    'bundle_index' => $bundleIndex,
     6603                    'chunks_count' => count($pclzip_chunk_files)
     6604                ]);
     6605            }
    56806606           
    56816607            if ($successfulChunks === 0) {
     
    61417067            }
    61427068
     7069            // Check if backup has already failed with a permanent error (e.g., missing ZipArchive)
     7070            if (isset($backup_info['status']) && $backup_info['status'] === 'failed') {
     7071                $error = $backup_info['error'] ?? '';
     7072                // If error indicates missing ZipArchive, don't retry - clean up cron job
     7073                if (stripos($error, 'ZipArchive') !== false || stripos($error, 'zip extension') !== false) {
     7074                    $this->logger->info('Database backup failed with permanent error, cleaning up cron job', [
     7075                        'backup_id' => $backup_id,
     7076                        'error' => $error
     7077                    ]);
     7078                    $this->delete_backup_cron_job('process_database_backup_cron', $backup_id, $callback_url);
     7079                    unset($backup_info['cron_job_scheduled_db']);
     7080                    update_option(self::OPTION_PREFIX . $backup_id, $backup_info);
     7081                    return;
     7082                }
     7083            }
     7084
    61437085            // Check if backup is already in progress (lock file exists)
    61447086            $lock_file = SITESKITE_BACKUP_PATH . '/' . $backup_id . '_db.lock';
     
    61727114            $this->process_database_backup($backup_id);
    61737115        } catch (\Exception $e) {
    6174             $this->logger->error('Database backup cron failed', [
     7116            $this->logger->error('Database backup failed', [
    61757117                'backup_id' => $backup_id,
    6176                 'error' => $e->getMessage()
    6177             ]);
    6178            
    6179             // On error, don't delete the recurring job - let it retry
    6180             // But check if backup is now completed (might have completed despite error)
    6181             $backup_info = get_option(self::OPTION_PREFIX . $backup_id);
    6182             if ($backup_info && isset($backup_info['status']) && $backup_info['status'] === 'completed') {
     7118                'error' => $e->getMessage(),
     7119                'trace' => $e->getTraceAsString()
     7120            ]);
     7121           
     7122            // Mark backup as failed and send notification
     7123            $backup_info = get_option(self::OPTION_PREFIX . $backup_id, []);
     7124            if ($backup_info) {
     7125                $backup_info['status'] = 'failed';
     7126                $backup_info['error'] = $e->getMessage();
     7127                $backup_info['updated_at'] = time();
     7128                $backup_info['completed_at'] = time();
     7129                update_option(self::OPTION_PREFIX . $backup_id, $backup_info);
     7130               
     7131                // Send failure notification
     7132                if ($callback_url) {
     7133                    $notification_data = [
     7134                        'backup_id' => $backup_id,
     7135                        'type' => $backup_info['type'] ?? 'database',
     7136                        'status' => 'failed',
     7137                        'progress' => $backup_info['progress'] ?? 0,
     7138                        'message' => 'Database backup failed: ' . $e->getMessage(),
     7139                        'current_stage' => $backup_info['current_stage'] ?? 'database_backup',
     7140                        'current_operation' => $backup_info['current_operation'] ?? 'Creating ZIP file',
     7141                        'started_at' => $backup_info['started_at'] ?? time(),
     7142                        'updated_at' => $backup_info['updated_at'],
     7143                        'completed_at' => $backup_info['completed_at'],
     7144                        'error' => $e->getMessage(),
     7145                        'process_type' => $backup_info['process_type'] ?? 'manual'
     7146                    ];
     7147                   
     7148                    if ($this->notification_manager) {
     7149                        $this->notification_manager->send_notification($notification_data);
     7150                    }
     7151                }
     7152               
     7153                // Clean up cron job since backup has failed
    61837154                $this->delete_backup_cron_job('process_database_backup_cron', $backup_id, $callback_url);
    61847155               
     
    61867157                unset($backup_info['cron_job_scheduled_db']);
    61877158                update_option(self::OPTION_PREFIX . $backup_id, $backup_info);
     7159            } else {
     7160                // Backup info not found - just clean up the cron job
     7161                $this->delete_backup_cron_job('process_database_backup_cron', $backup_id, $callback_url);
    61887162            }
    61897163        }
     
    62577231            if (file_exists($lock_file)) {
    62587232                $lock_age = time() - filemtime($lock_file);
    6259                 // If lock is fresh (less than 1 hour), backup is in progress
    6260                 if ($lock_age < 3600) {
     7233               
     7234                // Check if process is actually running by reading PID from lock file
     7235                $lock_content = @file_get_contents($lock_file);
     7236                $is_process_running = false;
     7237                if ($lock_content !== false) {
     7238                    $lines = explode("\n", trim($lock_content));
     7239                    if (isset($lines[1]) && is_numeric($lines[1])) {
     7240                        $pid = (int)$lines[1];
     7241                        // Check if process is running (works on Unix-like systems)
     7242                        if (function_exists('posix_kill')) {
     7243                            $is_process_running = @posix_kill($pid, 0);
     7244                        } elseif (PHP_OS_FAMILY !== 'Windows') {
     7245                            // Fallback: check if /proc/PID exists (Linux)
     7246                            $is_process_running = file_exists("/proc/{$pid}");
     7247                        }
     7248                    }
     7249                }
     7250               
     7251                // Reduced timeout to 20 minutes (1200 seconds) for faster recovery from stuck processes
     7252                // If process is not running, consider lock stale after 5 minutes
     7253                $stale_threshold = $is_process_running ? 1200 : 300;
     7254               
     7255                if ($lock_age < $stale_threshold) {
    62617256                    $this->logger->info('Files backup already in progress, skipping duplicate trigger', [
    62627257                        'backup_id' => $backup_id,
    6263                         'lock_age_seconds' => $lock_age
     7258                        'lock_age_seconds' => $lock_age,
     7259                        'process_running' => $is_process_running,
     7260                        'stale_threshold' => $stale_threshold
    62647261                    ]);
    62657262                    return; // Skip this run, but keep the recurring job for retry
    62667263                }
     7264               
     7265                // Lock is stale, log warning
     7266                $this->logger->warning('Stale lock file detected, will be cleaned up', [
     7267                    'backup_id' => $backup_id,
     7268                    'lock_age_seconds' => $lock_age,
     7269                    'process_running' => $is_process_running
     7270                ]);
    62677271                // Stale lock will be handled by process_files_backup
    62687272            }
     
    62827286            // The recurring job will keep running until backup is complete
    62837287
     7288            // Check if backup has already failed with a permanent error (e.g., missing ZipArchive)
     7289            if (isset($backup_info['status']) && $backup_info['status'] === 'failed') {
     7290                $error = $backup_info['error'] ?? '';
     7291                // If error indicates missing ZipArchive, don't retry - clean up cron job
     7292                if (stripos($error, 'ZipArchive') !== false || stripos($error, 'zip extension') !== false) {
     7293                    $this->logger->info('Files backup failed with permanent error, cleaning up cron job', [
     7294                        'backup_id' => $backup_id,
     7295                        'error' => $error
     7296                    ]);
     7297                    $this->delete_backup_cron_job('process_files_backup_cron', $backup_id, $callback_url);
     7298                    unset($backup_info['cron_job_scheduled_files']);
     7299                    update_option(self::OPTION_PREFIX . $backup_id, $backup_info);
     7300                    return;
     7301                }
     7302            }
     7303
    62847304            $this->process_files_backup($backup_id);
    62857305        } catch (\Exception $e) {
    6286             $this->logger->error('Files backup cron failed', [
     7306            $this->logger->error('Files backup failed', [
    62877307                'backup_id' => $backup_id,
    62887308                'error' => $e->getMessage(),
     
    62907310            ]);
    62917311           
    6292             // On error, don't delete the recurring job - let it retry
    6293             // But check if backup is now completed (might have completed despite error)
    6294             $backup_info = get_option(self::OPTION_PREFIX . $backup_id);
    6295             if ($backup_info && isset($backup_info['status']) && $backup_info['status'] === 'completed') {
     7312            // Mark backup as failed and send notification
     7313            $backup_info = get_option(self::OPTION_PREFIX . $backup_id, []);
     7314            if ($backup_info) {
     7315                $backup_info['status'] = 'failed';
     7316                $backup_info['error'] = $e->getMessage();
     7317                $backup_info['updated_at'] = time();
     7318                $backup_info['completed_at'] = time();
     7319                update_option(self::OPTION_PREFIX . $backup_id, $backup_info);
     7320               
     7321                // Send failure notification
     7322                if ($callback_url) {
     7323                    $notification_data = [
     7324                        'backup_id' => $backup_id,
     7325                        'type' => $backup_info['type'] ?? 'files',
     7326                        'status' => 'failed',
     7327                        'progress' => $backup_info['progress'] ?? 0,
     7328                        'message' => 'Files backup failed: ' . $e->getMessage(),
     7329                        'current_stage' => $backup_info['current_stage'] ?? 'files_backup',
     7330                        'current_operation' => $backup_info['current_operation'] ?? 'Creating ZIP file',
     7331                        'started_at' => $backup_info['started_at'] ?? time(),
     7332                        'updated_at' => $backup_info['updated_at'],
     7333                        'completed_at' => $backup_info['completed_at'],
     7334                        'error' => $e->getMessage(),
     7335                        'process_type' => $backup_info['process_type'] ?? 'manual'
     7336                    ];
     7337                   
     7338                    if ($this->notification_manager) {
     7339                        $this->notification_manager->send_notification($notification_data);
     7340                    }
     7341                }
     7342               
     7343                // Clean up cron job since backup has failed
    62967344                $this->delete_backup_cron_job('process_files_backup_cron', $backup_id, $callback_url);
    62977345               
     
    62997347                unset($backup_info['cron_job_scheduled_files']);
    63007348                update_option(self::OPTION_PREFIX . $backup_id, $backup_info);
     7349            } else {
     7350                // Backup info not found - just clean up the cron job
     7351                $this->delete_backup_cron_job('process_files_backup_cron', $backup_id, $callback_url);
    63017352            }
    63027353        }
     
    63177368            }
    63187369
     7370            // Check if backup has already failed with a permanent error (e.g., missing ZipArchive)
     7371            if (isset($backup_info['status']) && $backup_info['status'] === 'failed') {
     7372                $error = $backup_info['error'] ?? '';
     7373                // If error indicates missing ZipArchive, don't retry - clean up cron job
     7374                if (stripos($error, 'ZipArchive') !== false || stripos($error, 'zip extension') !== false) {
     7375                    $this->logger->info('Full backup failed with permanent error, cleaning up cron job', [
     7376                        'backup_id' => $backup_id,
     7377                        'error' => $error
     7378                    ]);
     7379                    $this->delete_backup_cron_job('process_full_backup_final_zip_cron', $backup_id, $callback_url);
     7380                    return;
     7381                }
     7382            }
     7383
    63197384            // Check if backup is already completed
    63207385            if (isset($backup_info['status']) && $backup_info['status'] === 'completed') {
     
    63797444            $final_zip = SITESKITE_BACKUP_PATH . '/' . $backup_id . '_full.zip';
    63807445           
    6381             $zip = new \ZipArchive();
    6382             $zip_result = $zip->open($final_zip, \ZipArchive::CREATE | \ZipArchive::OVERWRITE);
    6383             if ($zip_result !== TRUE) {
    6384                 throw new \RuntimeException('Failed to create final zip file: ' . $zip_result);
    6385             }
    6386 
    6387             // Add database ZIP
    6388             if (!$zip->addFile($db_file, 'database.zip')) {
    6389                 $zip->close();
    6390                 throw new \RuntimeException('Failed to add database.zip to final ZIP archive');
    6391             }
    6392 
    6393             // Add files ZIP
    6394             if (!$zip->addFile($files_file, 'files.zip')) {
    6395                 $zip->close();
    6396                 throw new \RuntimeException('Failed to add files.zip to final ZIP archive');
    6397             }
    6398 
    6399             if (!$zip->close()) {
    6400                 throw new \RuntimeException('Failed to close final ZIP archive');
    6401             }
     7446            // Use helper method with ZipArchive/PclZip fallback
     7447            $this->create_zip_file($final_zip, [$db_file, $files_file], $backup_id, ['database.zip', 'files.zip']);
    64027448
    64037449            // Verify final ZIP was created and is not empty
     
    64957541
    64967542        } catch (\Exception $e) {
    6497             $this->logger->error('Failed to create final ZIP via cron', [
     7543            $this->logger->error('Full backup final ZIP creation failed', [
    64987544                'backup_id' => $backup_id,
    64997545                'error' => $e->getMessage(),
    65007546                'trace' => $e->getTraceAsString()
    65017547            ]);
    6502 
    6503             // Reschedule to retry
    6504             $this->schedule_cron_event(
    6505                 'process_full_backup_final_zip_cron',
    6506                 ['backup_id' => $backup_id, 'callback_url' => $callback_url],
    6507                 300, // Retry in 5 minutes
    6508                 "SiteSkite Process Full Backup Final ZIP Retry"
    6509             );
     7548           
     7549            // Mark backup as failed and send notification
     7550            $backup_info = get_option(self::OPTION_PREFIX . $backup_id, []);
     7551            if ($backup_info) {
     7552                $backup_info['status'] = 'failed';
     7553                $backup_info['error'] = $e->getMessage();
     7554                $backup_info['updated_at'] = time();
     7555                $backup_info['completed_at'] = time();
     7556                update_option(self::OPTION_PREFIX . $backup_id, $backup_info);
     7557               
     7558                // Send failure notification
     7559                if ($callback_url) {
     7560                    $notification_data = [
     7561                        'backup_id' => $backup_id,
     7562                        'type' => $backup_info['type'] ?? 'full',
     7563                        'status' => 'failed',
     7564                        'progress' => $backup_info['progress'] ?? 0,
     7565                        'message' => 'Full backup final ZIP creation failed: ' . $e->getMessage(),
     7566                        'current_stage' => $backup_info['current_stage'] ?? 'final_zip',
     7567                        'current_operation' => $backup_info['current_operation'] ?? 'Creating final ZIP file',
     7568                        'started_at' => $backup_info['started_at'] ?? time(),
     7569                        'updated_at' => $backup_info['updated_at'],
     7570                        'completed_at' => $backup_info['completed_at'],
     7571                        'error' => $e->getMessage(),
     7572                        'process_type' => $backup_info['process_type'] ?? 'manual'
     7573                    ];
     7574                   
     7575                    if ($this->notification_manager) {
     7576                        $this->notification_manager->send_notification($notification_data);
     7577                    }
     7578                }
     7579               
     7580                // Clean up cron jobs since backup has failed
     7581                $this->delete_backup_cron_job('process_full_backup_final_zip_cron', $backup_id, $callback_url);
     7582                $this->delete_backup_cron_job('process_database_backup_cron', $backup_id, $callback_url);
     7583                $this->delete_backup_cron_job('process_files_backup_cron', $backup_id, $callback_url);
     7584               
     7585                // Clear cron job scheduled flags
     7586                unset($backup_info['cron_job_scheduled_db']);
     7587                unset($backup_info['cron_job_scheduled_files']);
     7588                update_option(self::OPTION_PREFIX . $backup_id, $backup_info);
     7589            } else {
     7590                // Backup info not found - just clean up the cron job
     7591                $this->delete_backup_cron_job('process_full_backup_final_zip_cron', $backup_id, $callback_url);
     7592            }
    65107593        }
    65117594    }
     
    65997682        } catch (\Exception $e) {
    66007683            $this->logger->error('Full backup cron failed', [
    6601                     'backup_id' => $backup_id,
    6602                 'error' => $e->getMessage()
    6603             ]);
     7684                'backup_id' => $backup_id,
     7685                'error' => $e->getMessage(),
     7686                'trace' => $e->getTraceAsString()
     7687            ]);
     7688           
     7689            // Mark backup as failed and send notification
     7690            $backup_info = get_option(self::OPTION_PREFIX . $backup_id, []);
     7691            if ($backup_info) {
     7692                $backup_info['status'] = 'failed';
     7693                $backup_info['error'] = $e->getMessage();
     7694                $backup_info['updated_at'] = time();
     7695                $backup_info['completed_at'] = time();
     7696                update_option(self::OPTION_PREFIX . $backup_id, $backup_info);
     7697               
     7698                // Send failure notification
     7699                $callback_url = $backup_info['callback_url'] ?? null;
     7700                if ($callback_url) {
     7701                    $notification_data = [
     7702                        'backup_id' => $backup_id,
     7703                        'type' => $backup_info['type'] ?? 'full',
     7704                        'status' => 'failed',
     7705                        'progress' => $backup_info['progress'] ?? 0,
     7706                        'message' => 'Full backup failed: ' . $e->getMessage(),
     7707                        'current_stage' => $backup_info['current_stage'] ?? 'full_backup',
     7708                        'current_operation' => $backup_info['current_operation'] ?? 'Processing full backup',
     7709                        'started_at' => $backup_info['started_at'] ?? time(),
     7710                        'updated_at' => $backup_info['updated_at'],
     7711                        'completed_at' => $backup_info['completed_at'],
     7712                        'error' => $e->getMessage(),
     7713                        'process_type' => $backup_info['process_type'] ?? 'manual'
     7714                    ];
     7715                   
     7716                    if ($this->notification_manager) {
     7717                        $this->notification_manager->send_notification($notification_data);
     7718                    }
     7719                }
     7720               
     7721                // Clean up cron jobs since backup has failed
     7722                $this->delete_backup_cron_job('process_full_backup_cron', $backup_id, $callback_url);
     7723                $this->delete_backup_cron_job('process_database_backup_cron', $backup_id, $callback_url);
     7724                $this->delete_backup_cron_job('process_files_backup_cron', $backup_id, $callback_url);
     7725                $this->delete_backup_cron_job('process_full_backup_final_zip_cron', $backup_id, $callback_url);
     7726               
     7727                // Clear cron job scheduled flags
     7728                unset($backup_info['cron_job_scheduled_db']);
     7729                unset($backup_info['cron_job_scheduled_files']);
     7730                update_option(self::OPTION_PREFIX . $backup_id, $backup_info);
     7731            }
    66047732        }
    66057733    }
  • siteskite/trunk/includes/Cron/ExternalCronManager.php

    r3445137 r3445801  
    681681     * Get trigger URL for action
    682682     * Includes secret token as query parameter for services that don't support custom headers
     683     * Also includes cache-busting parameters to prevent caching on heavily cached hosting
    683684     */
    684685    private function get_trigger_url(string $action): string
     
    687688        // Always get fresh token using the same method as validation
    688689        $current_token = $this->get_secret_token();
     690       
    689691        // Add secret token as query parameter as fallback (some services don't support custom headers)
    690692        $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);
     701       
    691702        return $url;
    692703    }
  • siteskite/trunk/includes/Cron/HybridCronManager.php

    r3420528 r3445801  
    1515use function maybe_unserialize;
    1616use function site_url;
     17use function add_query_arg;
    1718
    1819/**
     
    220221    /**
    221222     * Get WordPress cron URL with secure SiteSkite token
    222      * Format: wp-cron.php?doing_wp_cron&siteskite_key=SECURE_TOKEN
     223     * Format: wp-cron.php?doing_wp_cron&siteskite_key=SECURE_TOKEN&siteskite_cron=1&nocache=TIMESTAMP
     224     * Includes cache-busting parameters to prevent caching on heavily cached hosting
    223225     */
    224226    public function get_wp_cron_url(): string
    225227    {
    226228        $secret_token = $this->external_cron_manager->get_secret_token();
    227         return site_url('wp-cron.php?doing_wp_cron&siteskite_key=' . urlencode($secret_token));
     229        $url = site_url('wp-cron.php');
     230       
     231        // Add WordPress cron trigger parameter
     232        $url = add_query_arg('doing_wp_cron', '', $url);
     233       
     234        // Add secret token for authentication
     235        $url = add_query_arg('siteskite_key', $secret_token, $url);
     236       
     237        // Add cache-busting parameters to prevent caching on heavily cached hosting
     238        // siteskite_cron=1: Identifier for cache exclusion rules
     239        // nocache=<timestamp>: Unique timestamp to ensure each request is unique
     240        $url = add_query_arg([
     241            'siteskite_cron' => '1',
     242            'nocache' => time()
     243        ], $url);
     244       
     245        return $url;
    228246    }
    229247   
  • siteskite/trunk/includes/Restore/RestoreManager.php

    r3431615 r3445801  
    35513551            // Cleanup
    35523552            wp_delete_file($sql_file);
     3553
     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);
    35533564
    35543565            // Update final status (suppress notification if this is an intermediate step in full restore)
  • siteskite/trunk/languages/siteskite.pot

    r3445137 r3445801  
    22msgid ""
    33msgstr ""
    4 "Project-Id-Version: SiteSkite Link 1.2.1\n"
     4"Project-Id-Version: SiteSkite Link 1.2.2\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

    r3445142 r3445801  
    55Tested up to: 6.9
    66Requires PHP: 7.4
    7 Stable tag: 1.2.1
     7Stable tag: 1.2.2
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
     
    3939✔️ Roll back plugin/theme versions safely 
    4040✔️ Add team members & organize sites into workspaces 
    41 ✔️ Use professional WP tools (Maintenance mode, Debug, Search & Replace, Indexing control, WP Reset & more) 
     41✔️ WP Canvas (Maintenance mode, Debug, Search & Replace, Indexing control, WP Reset & more) 
    4242✔️ Manage WP Admin Users & Roles
    4343✔️ Track Core Web Vitals performance 
     
    223223== Changelog ==
    224224
     225= 1.2.2 (22 January 2026) =
     226* Improved: Backup PCLZIP Support
     227* Improved: Backup performance on caching servers
     228* Improved: Site Connection Experience
     229
    225230= 1.2.1 (22 January 2026) =
    226231* Improved: Backup stability
  • siteskite/trunk/siteskite-link.php

    r3445137 r3445801  
    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.1
     6 * Version: 1.2.2
    77 * Requires at least: 5.3
    88 * Requires PHP: 7.4
     
    2929
    3030// Plugin version
    31 define('SITESKITE_VERSION', '1.2.1');
     31define('SITESKITE_VERSION', '1.2.2');
    3232
    3333// Plugin file, path, and URL
Note: See TracChangeset for help on using the changeset viewer.