Changeset 3445801
- Timestamp:
- 01/23/2026 08:10:58 PM (7 weeks ago)
- Location:
- siteskite/trunk
- Files:
-
- 8 edited
-
includes/API/FallbackAPI.php (modified) (4 diffs)
-
includes/Backup/BackupManager.php (modified) (35 diffs)
-
includes/Cron/ExternalCronManager.php (modified) (2 diffs)
-
includes/Cron/HybridCronManager.php (modified) (2 diffs)
-
includes/Restore/RestoreManager.php (modified) (1 diff)
-
languages/siteskite.pot (modified) (1 diff)
-
readme.txt (modified) (3 diffs)
-
siteskite-link.php (modified) (2 diffs)
Legend:
- Unmodified
- Added
- Removed
-
siteskite/trunk/includes/API/FallbackAPI.php
r3431615 r3445801 192 192 } 193 193 } 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; 195 202 } else { 196 203 wp_send_json_error($data, $status); … … 236 243 237 244 // 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); 243 264 } 244 265 245 266 // 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 246 269 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 } 252 275 253 276 // Set all parameters 277 // Preserve original value types (don't convert integers to strings prematurely) 254 278 foreach ($params as $key => $value) { 255 279 $request->set_param($key, $value); … … 266 290 267 291 // 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; 268 294 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'])); 270 298 } 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); 272 307 } 273 308 … … 405 440 '/get-users' => [$this->wp_canvas_controller, 'get_all_users'], 406 441 '/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'], 408 456 '/count-wp-updates' => [$this->wp_canvas_controller, 'laravel_api_get_update_counts'], 409 457 '/logs' => [$this->wp_canvas_controller, 'get_wp_logs'], -
siteskite/trunk/includes/Backup/BackupManager.php
r3445137 r3445801 753 753 ]; 754 754 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 755 790 // Add classic backup detailed progress information 756 791 if ($is_classic_backup) { … … 778 813 $db_backup_complete = $backup_info['db_backup_complete'] ?? false; 779 814 $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 } 780 824 781 825 $classic_progress['database'] = [ … … 787 831 'cron_scheduled' => $db_cron_scheduled, 788 832 '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 791 838 ]; 792 839 } … … 1347 1394 ]); 1348 1395 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); 1363 1398 1364 1399 $this->logger->info('ZIP file created successfully', [ 1365 1400 'backup_id' => $backup_id, 1366 1401 'zip_file' => $zip_file, 1367 'zip_size' => file size($zip_file)1402 'zip_size' => file_exists($zip_file) ? filesize($zip_file) : 0 1368 1403 ]); 1369 1404 … … 1701 1736 $lock_acquired = true; 1702 1737 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 1703 1817 // Check if ZIP file exists (resume mode) 1704 1818 $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', [ 1713 1848 'backup_id' => $backup_id, 1714 'zip_file' => $zip_file, 1715 'zip_result' => $zip_result 1849 'error' => $e->getMessage() 1716 1850 ]); 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; 1723 1853 } 1724 1854 } 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 ); 1729 1900 } 1730 1901 } … … 1734 1905 $processed_files = $backup_info['files_processed'] ?? []; 1735 1906 $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 } 1736 1955 1737 1956 // Store total files count for cron to check completion … … 2004 2223 2005 2224 $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) { 2007 2243 $file_key = md5($file); 2008 2244 $processed_files[] = $file_key; … … 2022 2258 2023 2259 // 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 2024 2262 if ($processed_in_batch % 50 === 0) { 2025 2263 $processed = $already_processed + $processed_in_batch; … … 2029 2267 $backup_info['files_processed'] = $processed_files; 2030 2268 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 } 2031 2374 } 2032 2375 } … … 2040 2383 update_option(self::OPTION_PREFIX . $backup_id, $backup_info); 2041 2384 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 } 2045 2445 } 2046 2446 … … 2099 2499 } 2100 2500 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 2101 2524 // Prepare file info for the backup 2102 2525 $file_info = [ 2103 'size' => filesize($zip_file),2526 'size' => $zip_size, 2104 2527 'path' => $zip_file, 2105 2528 'download_url' => null // Will be set after upload … … 3076 3499 /** 3077 3500 * 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. 3079 3506 */ 3080 3507 private function backup_table(string $table_name, string $backup_file): void … … 3082 3509 global $wpdb; 3083 3510 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'); 3087 3530 if (!$handle) { 3088 3531 throw new \RuntimeException('Failed to create/open backup file'); 3089 3532 } 3090 3533 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 3095 3535 fwrite($handle, "-- SiteSkite Database Backup\n"); 3096 3536 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 3099 3538 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"); 3103 3555 3104 3556 // Get all tables (ensure deterministic ordering for consistent chunk hashes) 3105 3557 $tables = $wpdb->get_results('SHOW TABLES', ARRAY_N); 3106 3558 // Sort tables by name to ensure deterministic ordering 3107 usort($tables, function ($a, $b) {3559 usort($tables, function ($a, $b) { 3108 3560 return strcmp($a[0], $b[0]); 3109 3561 }); 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 3111 3577 foreach ($tables as $table) { 3112 3578 $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 3119 3582 // Get table structure 3120 3583 $create_table = $wpdb->get_row("SHOW CREATE TABLE `{$current_table_name}`", ARRAY_N); 3121 3584 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 3122 3589 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) 3155 3598 $column_info = $wpdb->get_results("SHOW COLUMNS FROM `{$current_table_name}`", ARRAY_A); 3156 3599 $column_names = []; 3600 $is_binary = []; 3601 $column_types = []; 3157 3602 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', [ 3169 3699 '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, 3172 3704 ]); 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 3183 3722 if (empty($rows)) { 3184 break; // No more rows3723 break; 3185 3724 } 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 3197 3726 $values = []; 3727 $values_count = 0; 3728 $statement_bytes = 0; 3198 3729 foreach ($rows as $row) { 3199 // Build values array in deterministic column order3200 // Use wpdb->prepare for each value to ensure consistent escaping3201 3730 $escaped_values = []; 3202 3731 foreach ($column_names as $col_name) { … … 3204 3733 if ($value === null) { 3205 3734 $escaped_values[] = 'NULL'; 3206 } elseif ( is_numeric($value) && !is_string($value)) {3207 // Numeric values (int/float) - use as-is3208 $escaped_values[] = $value;3735 } elseif ($is_binary[$col_name]) { 3736 // Export binary data as hex, like mysqldump 3737 $escaped_values[] = '0x' . bin2hex((string) $value); 3209 3738 } 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 . "'"; 3212 3750 } 3213 3751 } 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]; 3215 3768 } 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 3219 3850 $offset += $chunk_size; 3220 3221 // Log progress every 10 chunks3222 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 memory3232 3851 unset($rows, $values); 3233 3852 } 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"); 3276 3894 3277 3895 fwrite($handle, "COMMIT;\n"); 3278 3896 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 3280 3912 $this->logger->info('Database backup SQL dump completed', [ 3281 3913 'backup_file' => $backup_file, 3282 'file_size' => filesize($backup_file) 3914 'file_size' => filesize($backup_file), 3283 3915 ]); 3284 3916 } … … 3374 4006 3375 4007 /** 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 /** 3376 4129 * Create lock file for files backup 3377 4130 */ … … 3380 4133 $lock_file = SITESKITE_BACKUP_PATH . '/' . $backup_id . '_files.lock'; 3381 4134 3382 // Check if lock file exists and is stale (older than 1 hour)4135 // Check if lock file exists and is stale 3383 4136 if (file_exists($lock_file)) { 3384 4137 $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) { 3386 4161 // Lock is stale, remove it 3387 4162 $this->logger->warning('Removing stale files backup lock file', [ 3388 4163 'backup_id' => $backup_id, 3389 4164 '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 3391 4168 ]); 3392 4169 @unlink($lock_file); … … 3396 4173 'backup_id' => $backup_id, 3397 4174 'lock_file' => $lock_file, 3398 'lock_age_seconds' => $lock_age 4175 'lock_age_seconds' => $lock_age, 4176 'process_running' => $is_process_running 3399 4177 ]); 3400 4178 return false; … … 4681 5459 $fileSize = filesize($sqlPath); 4682 5460 $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 } 4683 5476 4684 5477 // IMPORTANT: Create manifest BEFORE creating chunks on disk … … 4692 5485 // Use centralized chunk size from config 4693 5486 $chunkSize = $this->backup_config->chunkSizeBytes; 5487 $chunks_total_estimate = $chunkSize > 0 ? (int)ceil($fileSize / $chunkSize) : null; 5488 $last_db_progress_update_ts = 0; 4694 5489 $stream = fopen($sqlPath, 'rb'); 4695 5490 if (!$stream) { … … 4816 5611 } 4817 5612 } 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 } 4818 5643 } 4819 5644 4820 5645 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 } 4821 5661 4822 5662 $this->logger->info('Database chunking completed', [ … … 5568 6408 } 5569 6409 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 } 5581 6441 } 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 } 5585 6466 } 5586 6467 … … 5589 6470 $successfulChunks = 0; 5590 6471 6472 // For PclZip, collect chunk files to add at once 6473 $pclzip_chunk_files = []; 6474 5591 6475 $this->logger->debug('Starting to add chunks to bundle ZIP', [ 5592 6476 'backup_id' => $backup_id, 5593 6477 'bundle_index' => $bundleIndex + 1, 5594 'total_chunks_to_add' => count($bundleChunkHashes) 6478 'total_chunks_to_add' => count($bundleChunkHashes), 6479 'method' => $zip !== null ? 'ZipArchive' : 'PclZip' 5595 6480 ]); 5596 6481 … … 5645 6530 if ($chunkData !== false && $chunkData !== null && $actualSize > 0 && $actualSize <= $expectedSize) { 5646 6531 // 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 } 5648 6548 $bundleChunks[] = $chunkHash; 5649 6549 $bundleTotalSize += $actualSize; … … 5677 6577 ]); 5678 6578 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 } 5680 6606 5681 6607 if ($successfulChunks === 0) { … … 6141 7067 } 6142 7068 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 6143 7085 // Check if backup is already in progress (lock file exists) 6144 7086 $lock_file = SITESKITE_BACKUP_PATH . '/' . $backup_id . '_db.lock'; … … 6172 7114 $this->process_database_backup($backup_id); 6173 7115 } catch (\Exception $e) { 6174 $this->logger->error('Database backup cronfailed', [7116 $this->logger->error('Database backup failed', [ 6175 7117 '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 6183 7154 $this->delete_backup_cron_job('process_database_backup_cron', $backup_id, $callback_url); 6184 7155 … … 6186 7157 unset($backup_info['cron_job_scheduled_db']); 6187 7158 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); 6188 7162 } 6189 7163 } … … 6257 7231 if (file_exists($lock_file)) { 6258 7232 $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) { 6261 7256 $this->logger->info('Files backup already in progress, skipping duplicate trigger', [ 6262 7257 '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 6264 7261 ]); 6265 7262 return; // Skip this run, but keep the recurring job for retry 6266 7263 } 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 ]); 6267 7271 // Stale lock will be handled by process_files_backup 6268 7272 } … … 6282 7286 // The recurring job will keep running until backup is complete 6283 7287 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 6284 7304 $this->process_files_backup($backup_id); 6285 7305 } catch (\Exception $e) { 6286 $this->logger->error('Files backup cronfailed', [7306 $this->logger->error('Files backup failed', [ 6287 7307 'backup_id' => $backup_id, 6288 7308 'error' => $e->getMessage(), … … 6290 7310 ]); 6291 7311 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 6296 7344 $this->delete_backup_cron_job('process_files_backup_cron', $backup_id, $callback_url); 6297 7345 … … 6299 7347 unset($backup_info['cron_job_scheduled_files']); 6300 7348 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); 6301 7352 } 6302 7353 } … … 6317 7368 } 6318 7369 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 6319 7384 // Check if backup is already completed 6320 7385 if (isset($backup_info['status']) && $backup_info['status'] === 'completed') { … … 6379 7444 $final_zip = SITESKITE_BACKUP_PATH . '/' . $backup_id . '_full.zip'; 6380 7445 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']); 6402 7448 6403 7449 // Verify final ZIP was created and is not empty … … 6495 7541 6496 7542 } catch (\Exception $e) { 6497 $this->logger->error('F ailed to create final ZIP via cron', [7543 $this->logger->error('Full backup final ZIP creation failed', [ 6498 7544 'backup_id' => $backup_id, 6499 7545 'error' => $e->getMessage(), 6500 7546 'trace' => $e->getTraceAsString() 6501 7547 ]); 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 } 6510 7593 } 6511 7594 } … … 6599 7682 } catch (\Exception $e) { 6600 7683 $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 } 6604 7732 } 6605 7733 } -
siteskite/trunk/includes/Cron/ExternalCronManager.php
r3445137 r3445801 681 681 * Get trigger URL for action 682 682 * 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 683 684 */ 684 685 private function get_trigger_url(string $action): string … … 687 688 // Always get fresh token using the same method as validation 688 689 $current_token = $this->get_secret_token(); 690 689 691 // Add secret token as query parameter as fallback (some services don't support custom headers) 690 692 $url = add_query_arg('siteskite_key', $current_token, $url); 693 694 // Add cache-busting parameters to prevent caching on heavily cached hosting 695 // siteskite_cron=1: Identifier for cache exclusion rules 696 // nocache=<timestamp>: Unique timestamp to ensure each request is unique 697 $url = add_query_arg([ 698 'siteskite_cron' => '1', 699 'nocache' => time() 700 ], $url); 701 691 702 return $url; 692 703 } -
siteskite/trunk/includes/Cron/HybridCronManager.php
r3420528 r3445801 15 15 use function maybe_unserialize; 16 16 use function site_url; 17 use function add_query_arg; 17 18 18 19 /** … … 220 221 /** 221 222 * 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 223 225 */ 224 226 public function get_wp_cron_url(): string 225 227 { 226 228 $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; 228 246 } 229 247 -
siteskite/trunk/includes/Restore/RestoreManager.php
r3431615 r3445801 3551 3551 // Cleanup 3552 3552 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); 3553 3564 3554 3565 // Update final status (suppress notification if this is an intermediate step in full restore) -
siteskite/trunk/languages/siteskite.pot
r3445137 r3445801 2 2 msgid "" 3 3 msgstr "" 4 "Project-Id-Version: SiteSkite Link 1.2. 1\n"4 "Project-Id-Version: SiteSkite Link 1.2.2\n" 5 5 "Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/siteskite\n" 6 6 "POT-Creation-Date: 2024-01-01T00:00:00+00:00\n" -
siteskite/trunk/readme.txt
r3445142 r3445801 5 5 Tested up to: 6.9 6 6 Requires PHP: 7.4 7 Stable tag: 1.2. 17 Stable tag: 1.2.2 8 8 License: GPLv2 or later 9 9 License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 39 39 ✔️ Roll back plugin/theme versions safely 40 40 ✔️ 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) 42 42 ✔️ Manage WP Admin Users & Roles 43 43 ✔️ Track Core Web Vitals performance … … 223 223 == Changelog == 224 224 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 225 230 = 1.2.1 (22 January 2026) = 226 231 * Improved: Backup stability -
siteskite/trunk/siteskite-link.php
r3445137 r3445801 4 4 * Plugin URI: https://siteskite.com 5 5 * Description: Link your WordPress site with SiteSkite for effortless updates, backups, monitoring, and maintenance—everything in one place. 6 * Version: 1.2. 16 * Version: 1.2.2 7 7 * Requires at least: 5.3 8 8 * Requires PHP: 7.4 … … 29 29 30 30 // Plugin version 31 define('SITESKITE_VERSION', '1.2. 1');31 define('SITESKITE_VERSION', '1.2.2'); 32 32 33 33 // Plugin file, path, and URL
Note: See TracChangeset
for help on using the changeset viewer.