Changeset 3418578
- Timestamp:
- 12/12/2025 11:04:42 PM (3 months ago)
- Location:
- siteskite/trunk
- Files:
-
- 4 added
- 15 edited
-
assets/css/admin.css (modified) (1 diff)
-
includes/API/RestAPI.php (modified) (5 diffs)
-
includes/Backup/BackupManager.php (modified) (32 diffs)
-
includes/Backup/Incremental/Impl/BackblazeB2ObjectStore.php (modified) (6 diffs)
-
includes/Backup/Incremental/Impl/PCloudObjectStore.php (modified) (1 diff)
-
includes/Backup/Incremental/RetryPolicy.php (modified) (1 diff)
-
includes/Cloud/AWSManager.php (modified) (4 diffs)
-
includes/Cloud/GoogleDriveManager.php (modified) (1 diff)
-
includes/Cloud/PCloudManager.php (modified) (15 diffs)
-
includes/Core/Plugin.php (modified) (10 diffs)
-
includes/Core/Utils.php (added)
-
includes/Cron (added)
-
includes/Cron/CronTriggerHandler.php (added)
-
includes/Cron/ExternalCronManager.php (added)
-
includes/Restore/RestoreManager.php (modified) (23 diffs)
-
includes/Schedule/ScheduleManager.php (modified) (7 diffs)
-
readme.txt (modified) (3 diffs)
-
siteskite-link.php (modified) (2 diffs)
-
templates/admin-page.php (modified) (1 diff)
Legend:
- Unmodified
- Added
- Removed
-
siteskite/trunk/assets/css/admin.css
r3390201 r3418578 105 105 color: #1d2327; 106 106 } 107 108 /* External Cron Settings Styles */ 109 .siteskite-external-cron { 110 margin-top: 30px; 111 } 112 113 .siteskite-external-cron h2 { 114 margin-top: 0; 115 margin-bottom: 10px; 116 font-size: 1.3em; 117 color: #1d2327; 118 } 119 120 .siteskite-external-cron .form-field { 121 margin-bottom: 20px; 122 } 123 124 .siteskite-external-cron .form-field label { 125 font-weight: 600; 126 margin-bottom: 8px; 127 } 128 129 .siteskite-external-cron .form-field input[type="checkbox"] { 130 margin-right: 8px; 131 } 132 133 .siteskite-external-cron .api-key-container { 134 margin-top: 5px; 135 } 136 137 .siteskite-external-cron .description { 138 font-style: normal; 139 color: #646970; 140 margin-top: 5px; 141 } 142 143 .siteskite-external-cron .description strong { 144 color: #1d2327; 145 } -
siteskite/trunk/includes/API/RestAPI.php
r3400843 r3418578 13 13 use SiteSkite\Restore\RestoreManager; 14 14 use SiteSkite\Schedule\ScheduleManager; 15 use SiteSkite\Cron\CronTriggerHandler; 15 16 use SiteSkite\WPCanvas\WPCanvasController; 16 17 … … 49 50 private RestoreManager $restore_manager; 50 51 private ScheduleManager $schedule_manager; 52 private ?CronTriggerHandler $cron_trigger_handler = null; 51 53 private WPCanvasController $wp_canvas_controller; 52 54 … … 63 65 Logger $logger, 64 66 RestoreManager $restore_manager, 65 ScheduleManager $schedule_manager 67 ScheduleManager $schedule_manager, 68 ?CronTriggerHandler $cron_trigger_handler = null 66 69 ) { 67 70 $this->auth_manager = $auth_manager; … … 73 76 $this->restore_manager = $restore_manager; 74 77 $this->schedule_manager = $schedule_manager; 78 $this->cron_trigger_handler = $cron_trigger_handler; 75 79 $this->cleanup_manager = $cleanup_manager; 76 80 $this->wp_canvas_controller = new WPCanvasController($this->logger, $this->auth_manager); … … 620 624 ); 621 625 622 626 // Cron trigger endpoint (for external cron service) 627 if ($this->cron_trigger_handler) { 628 register_rest_route( 629 self::API_NAMESPACE, 630 '/cron/trigger', 631 [ 632 'methods' => WP_REST_Server::CREATABLE, 633 'callback' => [$this->cron_trigger_handler, 'handle_trigger'], 634 'permission_callback' => '__return_true', // Authentication via secret token in header 635 // Don't validate args here - we handle validation in the callback 636 // This allows cron-job.org to send JSON body without WordPress REST API validation issues 637 'args' => [] 638 ] 639 ); 640 } 623 641 624 642 -
siteskite/trunk/includes/Backup/BackupManager.php
r3416301 r3418578 15 15 use SiteSkite\Cloud\BackblazeB2Manager; 16 16 use SiteSkite\Cloud\PCloudManager; 17 use SiteSkite\Cron\ExternalCronManager; 17 18 use SiteSkite\Backup\Incremental\BackupConfig; 18 19 use SiteSkite\Backup\Incremental\Chunker; … … 30 31 use SiteSkite\Backup\Incremental\Impl\PCloudObjectStore; 31 32 use SiteSkite\Backup\Incremental\Impl\S3ObjectStore; 33 use SiteSkite\Core\Utils; 32 34 use function get_option; 33 35 use function update_option; … … 130 132 private PCloudManager $pcloud_manager; 131 133 134 /** 135 * @var ExternalCronManager|null External cron manager instance 136 */ 137 private ?ExternalCronManager $external_cron_manager = null; 138 132 139 // Backup directory is now accessed via SITESKITE_BACKUP_PATH constant 133 140 … … 141 148 private const BACKUP_TIMEOUT = 300; // 5 minutes 142 149 private const EXCLUDED_PATHS = [ 143 '.git',150 '.git', 144 151 'node_modules', 145 152 'wpcodeboxide', 153 'vdconnect-*', 154 'litespeed', 146 155 'backup-migration-*', 147 156 'wp-content/cache', … … 158 167 'wp-content/rb-plugins', 159 168 'wp-content/nginx_cache', 169 'wp-content/wpvivid_*', 170 'wp-content/wpvividbackups', 160 171 161 172 //temporary test 162 //'wp-content/plugins',163 //'wp-admin',164 //'wp-includes',173 'wp-content/plugins', 174 'wp-admin', 175 'wp-includes', 165 176 166 177 … … 186 197 * @param AWSManager $aws_manager AWS manager instance 187 198 * @param \SiteSkite\Backup\Incremental\IncrementalStatus $incremental_status Incremental status manager instance 199 * @param ExternalCronManager|null $external_cron_manager External cron manager instance 188 200 */ 189 201 public function __construct( … … 198 210 PCloudManager $pcloud_manager, 199 211 AWSManager $aws_manager, 200 \SiteSkite\Backup\Incremental\IncrementalStatus $incremental_status 212 \SiteSkite\Backup\Incremental\IncrementalStatus $incremental_status, 213 ?ExternalCronManager $external_cron_manager = null 201 214 ) { 202 215 $this->s3_manager = $s3_manager; … … 211 224 $this->aws_manager = $aws_manager; 212 225 $this->incremental_status = $incremental_status; 226 $this->external_cron_manager = $external_cron_manager; 213 227 $this->init_backup_directory(); 214 228 … … 261 275 throw new \RuntimeException('Failed to create backup directory'); 262 276 } 277 } 278 279 /** 280 * Schedule cron event (uses external cron if enabled, otherwise WordPress cron) 281 */ 282 private function schedule_cron_event(string $hook, array $args, int $delay_seconds = 0, string $title = ''): void 283 { 284 // Try external cron first if enabled 285 if ($this->external_cron_manager && $this->external_cron_manager->is_enabled()) { 286 // Check if job already exists to prevent duplicates 287 $existing_job_id = $this->external_cron_manager->get_job_id_by_action($hook, $args); 288 if ($existing_job_id) { 289 $this->logger->debug('Cron job already exists, skipping creation', [ 290 'hook' => $hook, 291 'existing_job_id' => $existing_job_id 292 ]); 293 return; 294 } 295 296 // For incremental continuation jobs, always use recurring "every_minute" job that deletes after first run 297 // This ensures the job runs as soon as possible and is cleaned up automatically 298 if ($hook === 'siteskite_incremental_continue') { 299 $job_id = $this->external_cron_manager->create_recurring_job($hook, $args, 'every_minute', [], $title); 300 if ($job_id) { 301 // Mark job for deletion after first successful run 302 $this->external_cron_manager->mark_job_for_deletion_after_first_run($hook, $args); 303 304 $this->logger->debug('Scheduled external cron event (recurring, auto-delete)', [ 305 'hook' => $hook, 306 'job_id' => $job_id, 307 'type' => 'recurring_every_minute' 308 ]); 309 return; 310 } else { 311 $this->logger->warning('Failed to schedule external cron, falling back to WordPress cron', [ 312 'hook' => $hook 313 ]); 314 } 315 } else { 316 // For other jobs, use normal single event scheduling 317 $job_id = $this->external_cron_manager->schedule_single_event($hook, $args, $delay_seconds, $title); 318 if ($job_id) { 319 $this->logger->debug('Scheduled external cron event', [ 320 'hook' => $hook, 321 'delay' => $delay_seconds, 322 'job_id' => $job_id 323 ]); 324 return; 325 } else { 326 $this->logger->warning('Failed to schedule external cron, falling back to WordPress cron', [ 327 'hook' => $hook 328 ]); 329 } 330 } 331 } 332 333 // Fallback to WordPress cron 334 wp_schedule_single_event(time() + $delay_seconds, $hook, $args); 263 335 } 264 336 … … 314 386 315 387 // Schedule the actual backup process to run immediately after this request 316 wp_schedule_single_event( 317 time(), 388 $this->schedule_cron_event( 318 389 'process_' . $type . '_backup_cron', 319 390 [ 320 391 'backup_id' => $backup_id, 321 392 'callback_url' => $callback_url 322 ] 393 ], 394 0, // Will be converted to recurring job for external cron, immediate for WordPress cron 395 "SiteSkite Process {$type} Backup" 323 396 ); 324 397 … … 777 850 // Schedule cleanup 778 851 if (!wp_next_scheduled('siteskite_cleanup_backup_files', [$backup_id])) { 779 wp_schedule_single_event(time() + 300, 'siteskite_cleanup_backup_files', [$backup_id]);852 $this->schedule_cron_event('siteskite_cleanup_backup_files', [$backup_id], 300, "SiteSkite Cleanup Backup Files"); 780 853 } 781 854 … … 899 972 // Schedule cleanup 900 973 if (!wp_next_scheduled('siteskite_cleanup_backup_files', [$backup_id])) { 901 wp_schedule_single_event(time() + 300, 'siteskite_cleanup_backup_files', [$backup_id]);974 $this->schedule_cron_event('siteskite_cleanup_backup_files', [$backup_id], 300, "SiteSkite Cleanup Backup Files"); 902 975 } 903 976 … … 988 1061 $status_data = $resolved; 989 1062 990 // If credentials are stale, refresh via centralized approach991 $ credentials_age = time() - ($status_data['initialized_at'] ?? 0);992 if ($ credentials_age > 3600) {1063 // Check if credentials are stale using expiration metadata (if available) or fallback to hardcoded threshold 1064 $needs_refresh = $this->should_refresh_credentials($status_data, $provider, $backup_id); 1065 if ($needs_refresh) { 993 1066 $this->refresh_credentials_for_incremental($backup_id, $provider); 994 1067 return; … … 1162 1235 // Schedule cleanup for classic full backup 1163 1236 if (!wp_next_scheduled('siteskite_cleanup_backup_files', [$backup_id])) { 1164 wp_schedule_single_event(time() + 300, 'siteskite_cleanup_backup_files', [$backup_id]);1237 $this->schedule_cron_event('siteskite_cleanup_backup_files', [$backup_id], 300, "SiteSkite Cleanup Backup Files"); 1165 1238 } 1166 1239 … … 1274 1347 'file_size' => $backup_info['file_size'] ?? 0 1275 1348 ]); 1349 1350 // Clean up any pending incremental continuation jobs for this backup 1351 if ($this->external_cron_manager && $this->external_cron_manager->is_enabled()) { 1352 $deleted_count = $this->external_cron_manager->delete_jobs_by_backup_id('siteskite_incremental_continue', $backup_id); 1353 if ($deleted_count > 0) { 1354 $this->logger->info('Cleaned up incremental continuation jobs after backup completion', [ 1355 'backup_id' => $backup_id, 1356 'deleted_count' => $deleted_count 1357 ]); 1358 } 1359 } 1360 1361 // Also clear WordPress cron events for this backup 1362 wp_clear_scheduled_hook('siteskite_incremental_continue', [$backup_id]); 1276 1363 1277 1364 // For full backups, retry any pending uploads now that _full.zip is ready … … 1429 1516 } 1430 1517 1518 1431 1519 /** 1432 1520 * Execute provider-specific upload logic … … 1434 1522 private function execute_provider_upload(string $backup_id, string $provider, string $file_path, array $status_data): array 1435 1523 { 1524 // Get user_id and site_host for folder structure: {user_id}/{site_host}/{backup_id}_{type}.zip 1525 $user_id = Utils::get_user_id(); 1526 $site_host = wp_parse_url(get_option('siteurl'), PHP_URL_HOST) ?: 'site'; 1527 $file_name = basename($file_path); 1528 1529 // Construct the target path with user_id and site_host folders 1530 $target_path = $user_id . '/' . $site_host . '/' . $file_name; 1531 1436 1532 switch ($provider) { 1437 1533 case 'aws_s3': … … 1442 1538 throw new \RuntimeException('Missing AWS S3 presigned URL'); 1443 1539 } 1540 // For AWS S3, the AWSManager will construct the S3 key with site_host folder structure 1444 1541 return $this->aws_manager->upload_file_to_s3_presigned($presigned_url, $file_path); 1445 1542 … … 1448 1545 throw new \RuntimeException('Missing Dropbox access_token'); 1449 1546 } 1547 // For Dropbox, construct path as: {base_folder}/{user_id}/{site_host}/{filename} 1548 $base_folder = $status_data['folder'] ?? $status_data['target_directory'] ?? ''; 1549 // Construct the full folder path: base_folder/user_id/site_host 1550 $dropbox_target_folder = !empty($base_folder) 1551 ? rtrim($base_folder, '/') . '/' . $user_id . '/' . $site_host 1552 : $user_id . '/' . $site_host; 1450 1553 return $this->dropbox_manager->upload_file( 1451 1554 $file_path, 1452 1555 $status_data['access_token'], 1453 $ status_data['folder'] ?? $status_data['target_directory'] ?? null1556 $dropbox_target_folder 1454 1557 ); 1455 1558 … … 1460 1563 throw new \RuntimeException('Missing Google Drive access_token'); 1461 1564 } 1565 // For Google Drive, find or create the user_id/site_host folder structure under the base folder 1566 $base_folder_id = $status_data['google_drive_folder_id'] ?? null; 1567 // Use find_or_create_folder_by_path to create the full path: user_id/site_host 1568 $folder_path = $user_id . '/' . $site_host; 1569 $target_folder_id = $this->google_drive_manager->find_or_create_folder_by_path($access_token, $folder_path, $base_folder_id); 1570 if (!$target_folder_id) { 1571 throw new \RuntimeException('Failed to create Google Drive folder structure: ' . $folder_path); 1572 } 1462 1573 return $this->google_drive_manager->upload_file( 1463 1574 $file_path, 1464 1575 $access_token, 1465 $ status_data['google_drive_folder_id'] ?? null1576 $target_folder_id 1466 1577 ); 1467 1578 … … 1470 1581 throw new \RuntimeException('Missing Backblaze B2 credentials (keyID/applicationKey/bucketName)'); 1471 1582 } 1583 // For Backblaze B2, use the target_path (user_id/site_host/filename) as the file name 1472 1584 return $this->backblaze_b2_manager->upload_file( 1473 1585 $file_path, … … 1475 1587 (string)$status_data['applicationKey'], 1476 1588 (string)$status_data['bucketName'], 1477 basename($file_path)1589 $target_path 1478 1590 ); 1479 1591 … … 1486 1598 throw new \RuntimeException('Missing pCloud credentials (folderName/access_token)'); 1487 1599 } 1600 // For pCloud, construct the remote path relative to folderName: {folderName}/{user_id}/{site_host}/{filename} 1601 // The folderName is the base folder, and we create user_id/site_host structure inside it 1602 $pcloud_remote_path = $folder_name . '/' . $user_id . '/' . $site_host . '/' . $file_name; 1488 1603 return $this->pcloud_manager->upload_file( 1489 1604 $file_path, 1490 1605 $folder_name, 1491 1606 $access_token, 1492 $ status_data['remote_path'] ?? null1607 $pcloud_remote_path 1493 1608 ); 1494 1609 … … 3520 3635 try { $coordinator->saveRemoteIndex($remoteIndex, $provider); } catch (\Throwable $t) { /* ignore */ } 3521 3636 try { $this->update_incremental_progress($backup_id, $progress); } catch (\Throwable $t) { /* ignore */ } 3522 if (function_exists('wp_schedule_single_event')) { 3523 \wp_schedule_single_event(time() + 60, 'siteskite_incremental_continue', [$backup_id]); 3524 } 3637 $this->schedule_cron_event('siteskite_incremental_continue', [$backup_id], 60, "SiteSkite Incremental Continue"); 3525 3638 return $uploadedBytes; // Halt this pass to avoid spamming 401s 3526 3639 } … … 4003 4116 'total_files' => $totalFiles, 4004 4117 'has_deferred_chunks' => $hasDeferredChunks, 4005 'deferred_count' => count($remainingDeferred) 4006 ]); 4118 'deferred_count' => count($remainingDeferred), 4119 'files_processed_this_cycle' => count($filesToProcess) 4120 ]); 4121 4122 // Check if we processed 150 files in this cycle (indicating more files to process) 4123 $filesProcessedThisCycle = count($filesToProcess); 4124 $processed150Files = $filesProcessedThisCycle >= 150; 4125 4007 4126 // Schedule next chunk if not completed and more files to process OR deferred chunks remain 4008 $this->logger->info('Scheduling next incremental continue chunk', [ 4009 'backup_id' => $backup_id, 4010 'processed_files' => $processedCount, 4011 'total_files' => $totalFiles, 4012 'has_deferred_chunks' => $hasDeferredChunks, 4013 'deferred_count' => count($remainingDeferred) 4014 ]); 4015 4016 // Schedule next chunk upload in 15 seconds (or sooner if we have deferred chunks) 4017 $delay = $hasDeferredChunks ? 10 : 15; 4018 $coordinator->scheduleNext($backup_id, $delay); 4127 if ($processed150Files || $hasDeferredChunks) { 4128 $this->logger->info('Scheduling next incremental continue chunk', [ 4129 'backup_id' => $backup_id, 4130 'processed_files' => $processedCount, 4131 'total_files' => $totalFiles, 4132 'has_deferred_chunks' => $hasDeferredChunks, 4133 'deferred_count' => count($remainingDeferred), 4134 'files_processed_this_cycle' => $filesProcessedThisCycle, 4135 'processed_150_files' => $processed150Files 4136 ]); 4137 4138 // Use external cron if enabled and we processed 150 files (or have deferred chunks) 4139 if ($this->external_cron_manager && $this->external_cron_manager->is_enabled() && ($processed150Files || $hasDeferredChunks)) { 4140 // Check if job already exists to prevent duplicates 4141 $existing_job_id = $this->external_cron_manager->get_job_id_by_action('siteskite_incremental_continue', [$backup_id]); 4142 4143 if (!$existing_job_id) { 4144 // Schedule recurring "every_minute" job that continues until all files are processed 4145 // Do NOT mark for auto-deletion - it will run every minute until completion 4146 $job_id = $this->external_cron_manager->create_recurring_job( 4147 'siteskite_incremental_continue', 4148 [$backup_id], 4149 'every_minute', 4150 [], 4151 "SiteSkite Incremental Continue" 4152 ); 4153 4154 if ($job_id) { 4155 $this->logger->info('Scheduled external cron for incremental continue (recurring every minute)', [ 4156 'backup_id' => $backup_id, 4157 'job_id' => $job_id, 4158 'processed_150_files' => $processed150Files, 4159 'has_deferred_chunks' => $hasDeferredChunks, 4160 'processed_files' => $processedCount, 4161 'total_files' => $totalFiles 4162 ]); 4163 } else { 4164 // Fallback to coordinator scheduling if external cron fails 4165 $this->logger->warning('Failed to schedule external cron, falling back to coordinator', [ 4166 'backup_id' => $backup_id 4167 ]); 4168 $delay = $hasDeferredChunks ? 10 : 15; 4169 $coordinator->scheduleNext($backup_id, $delay); 4170 } 4171 } else { 4172 $this->logger->debug('External cron job already exists for incremental continue, skipping creation', [ 4173 'backup_id' => $backup_id, 4174 'existing_job_id' => $existing_job_id 4175 ]); 4176 } 4177 } else { 4178 // Use coordinator scheduling if external cron is disabled or conditions not met 4179 $delay = $hasDeferredChunks ? 10 : 15; 4180 $coordinator->scheduleNext($backup_id, $delay); 4181 } 4182 } else { 4183 // Not enough files processed and no deferred chunks - use normal scheduling 4184 $delay = $hasDeferredChunks ? 10 : 15; 4185 $coordinator->scheduleNext($backup_id, $delay); 4186 } 4019 4187 } else { 4020 4188 $this->logger->info('Incremental upload completed, no further chunks needed', [ … … 4118 4286 'status' => $backup_status 4119 4287 ]); 4288 4289 // Clean up any pending incremental continuation jobs for this backup 4290 if ($this->external_cron_manager && $this->external_cron_manager->is_enabled()) { 4291 $deleted_count = $this->external_cron_manager->delete_jobs_by_backup_id('siteskite_incremental_continue', $backup_id); 4292 if ($deleted_count > 0) { 4293 $this->logger->info('Cleaned up pending incremental continuation jobs for completed backup', [ 4294 'backup_id' => $backup_id, 4295 'deleted_count' => $deleted_count 4296 ]); 4297 } 4298 } 4299 4300 // Also clear WordPress cron events for this backup 4301 wp_clear_scheduled_hook('siteskite_incremental_continue', [$backup_id]); 4302 4120 4303 // Ensure logs are cleared once more on any stray continue invocations post-completion 4121 4304 try { $this->logger->delete_todays_log(); } catch (\Throwable $t) { /* ignore */ } … … 4148 4331 ]); 4149 4332 // Schedule a short delayed retry 4150 if (function_exists('wp_schedule_single_event')) { 4151 wp_schedule_single_event(time() + 15, 'siteskite_incremental_continue', [$backup_id]); 4152 } 4333 $this->schedule_cron_event('siteskite_incremental_continue', [$backup_id], 15, "SiteSkite Incremental Continue"); 4153 4334 return; 4154 4335 } … … 4272 4453 } 4273 4454 4274 // Also check if credentials are stale (older than 1 hour) 4275 $credentials_age = time() - ($status_data['initialized_at'] ?? 0); 4276 if ($credentials_age > 3600) { // 1 hour 4455 // Check if credentials are stale using expiration metadata (if available) or fallback to hardcoded threshold 4456 $needs_refresh = $this->should_refresh_credentials($status_data, $provider, $backup_id); 4457 if ($needs_refresh) { 4458 $credentials_age = time() - ($status_data['initialized_at'] ?? 0); 4277 4459 $this->logger->info('Credentials are stale, refreshing for incremental backup', [ 4278 4460 'backup_id' => $backup_id, 4279 4461 'provider' => $provider, 4280 'credentials_age' => $credentials_age 4462 'credentials_age' => $credentials_age, 4463 'has_expires' => isset($status_data['expires']), 4464 'expires_at' => $status_data['expires'] ?? null 4281 4465 ]); 4282 4466 … … 4310 4494 if (!empty($progress) && !empty($progress['completed'])) { 4311 4495 $this->complete_incremental_backup($backup_id, $status_data); 4496 4497 // Clean up external cron recurring job after completion check 4498 if ($this->external_cron_manager && $this->external_cron_manager->is_enabled()) { 4499 $deleted_count = $this->external_cron_manager->delete_jobs_by_backup_id('siteskite_incremental_continue', $backup_id); 4500 if ($deleted_count > 0) { 4501 $this->logger->info('Cleaned up external cron recurring job after incremental processing completion', [ 4502 'backup_id' => $backup_id, 4503 'deleted_count' => $deleted_count 4504 ]); 4505 } 4506 } 4312 4507 } 4313 4508 } catch (\Throwable $t) { /* ignore finalize errors here */ } … … 4569 4764 // No presign endpoints present; fall back to generic requirement 4570 4765 return $generic_has_all; 4766 } 4767 4768 /** 4769 * Check if credentials should be refreshed based on expiration metadata or fallback thresholds 4770 * 4771 * All providers now support expiration metadata from backend: 4772 * - expires: Unix timestamp when credentials expire 4773 * - recommended_refresh_before_expiry: Seconds before expiry to refresh proactively 4774 * - recommended_refresh_after_files: Number of files after which to refresh 4775 * 4776 * @param array $status_data Status data containing credentials and metadata 4777 * @param string $provider Provider name (e.g., 'pcloud', 'backblaze_b2', 'dropbox', 'google_drive') 4778 * @param string|null $backup_id Optional backup ID for file count tracking 4779 * @return bool True if credentials should be refreshed 4780 */ 4781 private function should_refresh_credentials(array $status_data, string $provider, ?string $backup_id = null): bool 4782 { 4783 $currentTime = time(); 4784 4785 // Provider-specific fallback thresholds (used if expires field is missing) 4786 $provider_fallbacks = [ 4787 'backblaze_b2' => 82800, // 23 hours (matches B2 token expiration) 4788 'pcloud' => 86400, // 24 hours 4789 'dropbox' => 12600, // 3.5 hours 4790 'google_drive' => 3000, // 50 minutes 4791 'aws' => 3600, // 1 hour (presigned URLs) 4792 'aws_s3' => 3600, // 1 hour (presigned URLs) 4793 ]; 4794 4795 $fallback_threshold = $provider_fallbacks[$provider] ?? 3600; // Default: 1 hour 4796 4797 // Check if expires field is present (backend provides expiration for all providers) 4798 if (isset($status_data['expires']) && is_numeric($status_data['expires'])) { 4799 $expiresAt = (int)$status_data['expires']; 4800 4801 // Check if credentials are actually expired 4802 if ($currentTime >= $expiresAt) { 4803 $this->logger->debug(ucfirst($provider) . ' credentials expired based on expires field', [ 4804 'provider' => $provider, 4805 'expires_at' => $expiresAt, 4806 'current_time' => $currentTime, 4807 'expired_seconds_ago' => $currentTime - $expiresAt 4808 ]); 4809 return true; 4810 } 4811 4812 // Check if we should refresh proactively (before expiry) 4813 $recommended_refresh_before_expiry = isset($status_data['recommended_refresh_before_expiry']) 4814 ? (int)$status_data['recommended_refresh_before_expiry'] 4815 : $this->get_default_refresh_before_expiry($provider); 4816 4817 $timeUntilExpiry = $expiresAt - $currentTime; 4818 if ($timeUntilExpiry <= $recommended_refresh_before_expiry) { 4819 $this->logger->debug(ucfirst($provider) . ' credentials should be refreshed proactively', [ 4820 'provider' => $provider, 4821 'expires_at' => $expiresAt, 4822 'current_time' => $currentTime, 4823 'time_until_expiry' => $timeUntilExpiry, 4824 'recommended_refresh_before_expiry' => $recommended_refresh_before_expiry 4825 ]); 4826 return true; 4827 } 4828 4829 // Check file count threshold if backup_id is provided 4830 if ($backup_id !== null) { 4831 $recommended_refresh_after_files = isset($status_data['recommended_refresh_after_files']) 4832 ? (int)$status_data['recommended_refresh_after_files'] 4833 : 2000; // Default: 2000 files 4834 4835 // Get current upload count from incremental progress (count of processed files) 4836 $progress = $this->get_incremental_progress($backup_id); 4837 $uploaded_files = count($progress['processed_files'] ?? []); 4838 4839 if ($uploaded_files >= $recommended_refresh_after_files) { 4840 $this->logger->info(ucfirst($provider) . ' credentials should be refreshed based on file count threshold', [ 4841 'provider' => $provider, 4842 'uploaded_files' => $uploaded_files, 4843 'recommended_refresh_after_files' => $recommended_refresh_after_files, 4844 'backup_id' => $backup_id 4845 ]); 4846 return true; 4847 } 4848 } 4849 4850 // Credentials are still valid (even if initialized_at is old) 4851 return false; 4852 } 4853 4854 // Fallback: use provider-specific hardcoded threshold if expires field is missing (backward compatibility) 4855 $credentials_age = $currentTime - ($status_data['initialized_at'] ?? 0); 4856 if ($credentials_age > $fallback_threshold) { 4857 $this->logger->debug(ucfirst($provider) . ' credentials stale based on hardcoded threshold (expires field missing)', [ 4858 'provider' => $provider, 4859 'credentials_age' => $credentials_age, 4860 'fallback_threshold' => $fallback_threshold, 4861 'initialized_at' => $status_data['initialized_at'] ?? null 4862 ]); 4863 return true; 4864 } 4865 4866 return false; 4867 } 4868 4869 /** 4870 * Get default refresh before expiry time for a provider (in seconds) 4871 * 4872 * @param string $provider Provider name 4873 * @return int Seconds before expiry to refresh proactively 4874 */ 4875 private function get_default_refresh_before_expiry(string $provider): int 4876 { 4877 $defaults = [ 4878 'backblaze_b2' => 3600, // 1 hour before 23-hour expiration 4879 'pcloud' => 3600, // 1 hour before 24-hour expiration 4880 'dropbox' => 1800, // 30 minutes before 3.5-hour expiration 4881 'google_drive' => 600, // 10 minutes before 50-minute expiration 4882 'aws' => 0, // Presigned URLs don't need proactive refresh 4883 'aws_s3' => 0, // Presigned URLs don't need proactive refresh 4884 ]; 4885 4886 return $defaults[$provider] ?? 3600; // Default: 1 hour 4571 4887 } 4572 4888 … … 4612 4928 4613 4929 // Schedule immediate continuation instead of delayed retry since credentials are now available 4614 if (function_exists('wp_schedule_single_event')) { 4615 \wp_schedule_single_event(time() + 2, 'siteskite_incremental_continue', [$backup_id]); 4616 $this->logger->info('Scheduled immediate incremental continue after credential refresh', [ 4617 'backup_id' => $backup_id, 4618 'provider' => $provider 4619 ]); 4620 } 4930 $this->schedule_cron_event('siteskite_incremental_continue', [$backup_id], 2, "SiteSkite Incremental Continue"); 4931 $this->logger->info('Scheduled immediate incremental continue after credential refresh', [ 4932 'backup_id' => $backup_id, 4933 'provider' => $provider 4934 ]); 4621 4935 return; 4622 4936 } … … 5049 5363 // Cleanup leftover options/transients for this backup (WP-standard APIs) 5050 5364 $this->cleanup_incremental_records($backup_id, $provider); 5365 5366 // Clean up external cron recurring job for incremental continue 5367 if ($this->external_cron_manager && $this->external_cron_manager->is_enabled()) { 5368 $deleted_count = $this->external_cron_manager->delete_jobs_by_backup_id('siteskite_incremental_continue', $backup_id); 5369 if ($deleted_count > 0) { 5370 $this->logger->info('Cleaned up external cron recurring job after incremental backup completion', [ 5371 'backup_id' => $backup_id, 5372 'deleted_count' => $deleted_count 5373 ]); 5374 } 5375 } 5376 5377 // Also clear WordPress cron events for this backup 5378 wp_clear_scheduled_hook('siteskite_incremental_continue', [$backup_id]); 5379 5051 5380 $this->logger->delete_todays_log(); 5052 5381 -
siteskite/trunk/includes/Backup/Incremental/Impl/BackblazeB2ObjectStore.php
r3389896 r3418578 25 25 private ?string $uploadAuthToken = null; 26 26 private ?int $tokenExpiresAt = null; 27 28 // File upload counter for credential refresh 29 private int $uploadCount = 0; 30 private const REFRESH_AFTER_FILES = 1500; // Refresh credentials after this many files 27 31 28 32 public function __construct(Logger $logger, string $keyId, string $applicationKey, string $bucketName, string $algo = 'sha256', string $rootPath = '') … … 66 70 /** 67 71 * Authenticate with Backblaze B2 and cache credentials 72 * Refreshes credentials if: 73 * - Token is expired or missing 74 * - Token expires within 1 hour (proactive refresh) 75 * - Upload count exceeds REFRESH_AFTER_FILES 68 76 */ 69 77 private function ensureAuthenticated(): void 70 78 { 71 // Use cached token if still valid (tokens expire in ~1 hour) 72 if ($this->authToken && $this->tokenExpiresAt && time() < $this->tokenExpiresAt) { 79 $currentTime = time(); 80 $needsRefresh = false; 81 82 // Check if token is expired or missing 83 if (!$this->authToken || !$this->tokenExpiresAt || $currentTime >= $this->tokenExpiresAt) { 84 $needsRefresh = true; 85 $this->logger->debug('Backblaze B2 token expired or missing, refreshing credentials'); 86 } 87 // Check if token expires within 1 hour (proactive refresh) 88 elseif ($this->tokenExpiresAt && ($this->tokenExpiresAt - $currentTime) < 3600) { 89 $needsRefresh = true; 90 $this->logger->debug('Backblaze B2 token expires within 1 hour, proactively refreshing credentials', [ 91 'expires_in' => $this->tokenExpiresAt - $currentTime 92 ]); 93 } 94 // Check if we've uploaded too many files (refresh after ~1500 files) 95 elseif ($this->uploadCount >= self::REFRESH_AFTER_FILES) { 96 $needsRefresh = true; 97 $this->logger->info('Backblaze B2 upload count reached threshold, refreshing credentials', [ 98 'upload_count' => $this->uploadCount, 99 'threshold' => self::REFRESH_AFTER_FILES 100 ]); 101 } 102 103 if (!$needsRefresh) { 73 104 return; 74 105 } 106 107 // Clear cached credentials and upload URL when refreshing 108 $this->clearCredentials(); 75 109 76 110 $this->logger->debug('Authenticating with Backblaze B2', [ … … 102 136 $this->accountId = $auth['accountId'] ?? null; 103 137 $this->tokenExpiresAt = time() + 3600; // Cache for 1 hour 138 139 // Reset upload counter when credentials are refreshed 140 $this->uploadCount = 0; 104 141 105 142 if (!$this->authToken || !$this->apiUrl || !$this->accountId) { … … 108 145 109 146 $this->logger->debug('Backblaze B2 authentication successful', [ 110 'api_url' => $this->apiUrl 111 ]); 147 'api_url' => $this->apiUrl, 148 'upload_count_reset' => true 149 ]); 150 } 151 152 /** 153 * Clear cached credentials and upload URL 154 * Used when refreshing credentials proactively or on errors 155 */ 156 private function clearCredentials(): void 157 { 158 $this->authToken = null; 159 $this->apiUrl = null; 160 $this->accountId = null; 161 $this->tokenExpiresAt = null; 162 $this->bucketId = null; 163 $this->uploadUrl = null; 164 $this->uploadAuthToken = null; 112 165 } 113 166 … … 257 310 258 311 if (\call_user_func('\\is_wp_error', $response)) { 259 // If upload URL expired, clear cache and retry once 260 if (stripos($response->get_error_message(), 'expired') !== false || 261 stripos($response->get_error_message(), '401') !== false) { 312 $errorMessage = $response->get_error_message(); 313 314 // If upload URL expired or 401/403 error, refresh credentials and retry 315 if (stripos($errorMessage, 'expired') !== false || 316 stripos($errorMessage, '401') !== false || 317 stripos($errorMessage, '403') !== false) { 318 319 $this->logger->warning('Backblaze B2 upload URL expired or unauthorized, refreshing credentials', [ 320 'error' => $errorMessage, 321 'upload_count' => $this->uploadCount 322 ]); 323 324 // Clear upload URL cache and refresh credentials 262 325 $this->uploadUrl = null; 263 326 $this->uploadAuthToken = null; 327 328 // Refresh authentication if needed (handles token expiration) 329 $this->ensureAuthenticated(); 330 331 // Get fresh upload URL 264 332 $uploadInfo = $this->getUploadUrl(); 265 333 $uploadUrl = $uploadInfo['upload_url']; 266 334 $uploadAuthToken = $uploadInfo['auth_token']; 267 335 336 // Retry upload with fresh credentials 268 337 $response = \call_user_func('\\wp_remote_post', $uploadUrl, [ 269 338 'headers' => [ … … 286 355 $code = (int)\call_user_func('\\wp_remote_retrieve_response_code', $response); 287 356 if ($code !== 200) { 357 // Handle 401/403 errors - immediately refresh credentials 358 if ($code === 401 || $code === 403) { 359 $this->logger->warning('Backblaze B2 upload failed with 401/403, immediately refreshing credentials', [ 360 'response_code' => $code, 361 'upload_count' => $this->uploadCount 362 ]); 363 364 // Clear all cached credentials 365 $this->clearCredentials(); 366 367 // Retry upload with fresh credentials 368 $this->ensureAuthenticated(); 369 $uploadInfo = $this->getUploadUrl(); 370 $uploadUrl = $uploadInfo['upload_url']; 371 $uploadAuthToken = $uploadInfo['auth_token']; 372 373 // Retry the upload 374 $retryResponse = \call_user_func('\\wp_remote_post', $uploadUrl, [ 375 'headers' => [ 376 'Authorization' => $uploadAuthToken, 377 'X-Bz-File-Name' => $fileName, 378 'X-Bz-Content-Type' => 'application/octet-stream', 379 'X-Bz-Content-Sha1' => $sha1Hash, 380 'Content-Length' => strlen($bytes) 381 ], 382 'body' => $bytes, 383 'timeout' => 300 384 ]); 385 386 if (\call_user_func('\\is_wp_error', $retryResponse)) { 387 throw new \RuntimeException('Backblaze B2 upload failed after credential refresh: ' . \call_user_func('\\esc_html', $retryResponse->get_error_message())); 388 } 389 390 $retryCode = (int)\call_user_func('\\wp_remote_retrieve_response_code', $retryResponse); 391 if ($retryCode !== 200) { 392 $retryBody = \call_user_func('\\wp_remote_retrieve_body', $retryResponse); 393 throw new \RuntimeException('Backblaze B2 upload failed after credential refresh with code ' . $retryCode . ': ' . \call_user_func('\\esc_html', $retryBody)); 394 } 395 396 // Success after retry - increment counter 397 $this->uploadCount++; 398 return; 399 } 400 401 // For other errors, throw exception 288 402 $body = \call_user_func('\\wp_remote_retrieve_body', $response); 289 403 throw new \RuntimeException('Backblaze B2 upload failed with code ' . $code . ': ' . \call_user_func('\\esc_html', $body)); 290 404 } 405 406 // Successful upload - increment counter 407 $this->uploadCount++; 291 408 } 292 409 } -
siteskite/trunk/includes/Backup/Incremental/Impl/PCloudObjectStore.php
r3389896 r3418578 44 44 { 45 45 $path = $this->pathFor($hash); 46 $tmpPath = @tempnam(sys_get_temp_dir(), 'siteskite_-pc-'); 47 if ($tmpPath === false) { throw new \RuntimeException('Failed to create temp file'); } 48 $bytes = stream_get_contents($stream); 49 if ($bytes === false) { wp_delete_file($tmpPath); throw new \RuntimeException('Failed to read stream'); } 50 file_put_contents($tmpPath, $bytes); 46 47 // For small files (< 5MB), use temp file approach (more reliable) 48 // For larger files, use streaming to avoid memory issues 49 $use_streaming = $size > 5 * 1024 * 1024; // 5MB threshold 50 51 if ($use_streaming) { 52 // Streaming upload for large files - avoids loading entire file into memory 53 // Ensure parent folders exist (minimal overhead - cached) 54 $this->manager->ensure_parent_folders_optimized($path, $this->accessToken, $this->folderCache); 55 56 // Use streaming upload directly from stream 57 $res = $this->manager->upload_file_from_stream($stream, $size, '', $this->accessToken, $path); 58 if (!($res['success'] ?? false)) { 59 throw new \RuntimeException('pCloud upload failed: ' . \esc_html($res['error'] ?? 'unknown')); 60 } 61 } else { 62 // Small files: use temp file (more reliable for pCloud API) 63 $tmpPath = @tempnam(sys_get_temp_dir(), 'siteskite_-pc-'); 64 if ($tmpPath === false) { throw new \RuntimeException('Failed to create temp file'); } 65 66 try { 67 $bytes = stream_get_contents($stream); 68 if ($bytes === false) { 69 \wp_delete_file($tmpPath); 70 throw new \RuntimeException('Failed to read stream'); 71 } 72 file_put_contents($tmpPath, $bytes); 51 73 52 // Ensure all parent directories exist before uploading (optimized - just tries creating) 53 $this->manager->ensure_parent_folders_optimized($path, $this->accessToken, $this->folderCache); 54 55 // Small delay to allow pCloud to propagate folder creation (especially for hash prefix folders) 56 // Hash prefix folders (2-character) are often created just before upload 57 usleep(100000); // 100ms 58 59 $res = $this->manager->upload_file($tmpPath, '', $this->accessToken, $path); 60 wp_delete_file($tmpPath); 61 if (!($res['success'] ?? false)) { throw new \RuntimeException('pCloud upload failed: ' . esc_html($res['error'] ?? 'unknown')); } 74 // Ensure all parent directories exist before uploading (optimized - cached) 75 $this->manager->ensure_parent_folders_optimized($path, $this->accessToken, $this->folderCache); 76 77 // Reduced delay - folder creation is cached and idempotent 78 // Only wait if this is a new hash prefix folder (2-character folder) 79 $pathSegments = array_filter(explode('/', dirname($path))); 80 if (count($pathSegments) >= 5) { 81 // Hash prefix folder - minimal delay (folder creation is cached) 82 usleep(50000); // 50ms (reduced from 100ms) 83 } 84 85 $res = $this->manager->upload_file($tmpPath, '', $this->accessToken, $path); 86 if (!($res['success'] ?? false)) { 87 throw new \RuntimeException('pCloud upload failed: ' . \esc_html($res['error'] ?? 'unknown')); 88 } 89 } finally { 90 \wp_delete_file($tmpPath); 91 } 92 } 62 93 } 63 94 } -
siteskite/trunk/includes/Backup/Incremental/RetryPolicy.php
r3389896 r3418578 24 24 if (preg_match('/http\s+5\d\d/', $m)) { return 'retry'; } 25 25 26 // Universal: 401/403 errors should trigger credentials refresh for all providers 27 // This handles expired/invalid tokens across all providers 28 if (strpos($m, 'http 401') !== false || 29 strpos($m, 'http 403') !== false || 30 strpos($m, '401') !== false || 31 strpos($m, '403') !== false || 32 strpos($m, 'unauthorized') !== false || 33 strpos($m, 'forbidden') !== false || 34 strpos($m, 'invalid_access_token') !== false || 35 strpos($m, 'access token invalid or expired') !== false || 36 strpos($m, 'token is invalid or missing') !== false || 37 strpos($m, 'invalid token') !== false || 38 strpos($m, 'expired token') !== false || 39 strpos($m, 'authentication failed') !== false) { 40 return 'refresh'; 41 } 42 26 43 switch ($provider) { 27 44 case 'dropbox': 28 // Invalid/expired token should trigger credentials refresh29 if (strpos($m, 'http 401') !== false || strpos($m, 'invalid_access_token') !== false || strpos($m, 'access token invalid or expired') !== false || strpos($m, 'token is invalid or missing') !== false) {30 return 'refresh';31 }32 45 // Dropbox conflict on overwrite: treat as already uploaded 33 46 if (strpos($m, 'http 409') !== false) { return 'mark_present'; } -
siteskite/trunk/includes/Cloud/AWSManager.php
r3389896 r3418578 6 6 7 7 use SiteSkite\Logger\Logger; 8 use SiteSkite\Core\Utils; 8 9 9 10 class AWSManager … … 364 365 // Call the API to get the actual S3 presigned URL 365 366 // For classic uploads, we need to construct the proper S3 key path 366 // The server expects the full path like: backups/{user_id}/{site_domain}/{backup_id}_{type}.zip 367 // Use user_id/site_host folder structure: {user_id}/{site_host}/{backup_id}_{type}.zip 368 $user_id = Utils::get_user_id(); 367 369 $site_url = get_site_url(); 368 $site_ domain = wp_parse_url($site_url, PHP_URL_HOST);370 $site_host = wp_parse_url($site_url, PHP_URL_HOST) ?: 'site'; 369 371 $backup_id = $this->extract_backup_id_from_filename(basename($file_path)); 370 372 $file_type = $this->extract_file_type_from_filename(basename($file_path)); 371 373 372 // Construct the S3 key path that matches the server's expectation373 $s3_key = " backups/{$this->get_user_id()}/{$site_domain}/{$backup_id}_{$file_type}.zip";374 // Construct the S3 key path with user_id/site_host folder structure 375 $s3_key = "{$user_id}/{$site_host}/{$backup_id}_{$file_type}.zip"; 374 376 375 377 $this->logger->info('Constructed S3 key for classic upload', [ … … 377 379 'backup_id' => $backup_id, 378 380 'file_type' => $file_type, 379 'site_domain' => $site_domain 381 'user_id' => $user_id, 382 'site_host' => $site_host 380 383 ]); 381 384 … … 487 490 } 488 491 489 /**490 * Get user ID for S3 key construction491 */492 private function get_user_id(): string493 {494 // Try to get user ID from various sources495 $user_id = get_option('siteskite_user_id');496 if ($user_id) {497 return $user_id;498 }499 500 // Fallback to a default or extract from callback URL501 $callback_url = get_option('siteskite_callback_url');502 if ($callback_url && preg_match('/userID=(\d+)/', $callback_url, $matches)) {503 return $matches[1];504 }505 506 return '227'; // Default fallback based on logs507 }508 492 509 493 /** -
siteskite/trunk/includes/Cloud/GoogleDriveManager.php
r3397852 r3418578 657 657 return $parent_id; 658 658 } 659 660 /** 661 * Find or create a folder by path (creates folders if they don't exist) 662 * Similar to find_folder_by_path but creates missing folders 663 * 664 * @param string $access_token Google Drive access token 665 * @param string $folder_path Folder path like "user_id/site_host" 666 * @param string|null $base_folder_id Optional base folder ID to start from 667 * @return string|null Folder ID of the final folder in the path, or null on failure 668 */ 669 public function find_or_create_folder_by_path(string $access_token, string $folder_path, ?string $base_folder_id = null): ?string 670 { 671 if (empty(trim($folder_path))) { 672 return $base_folder_id; 673 } 674 675 $segments = array_values(array_filter(explode('/', trim($folder_path, '/')))); 676 if (empty($segments)) { 677 return $base_folder_id; 678 } 679 680 $parent_id = $base_folder_id; 681 682 foreach ($segments as $segment) { 683 // Find existing folder under parent 684 $q = sprintf("name='%s' and mimeType='application/vnd.google-apps.folder' and trashed=false", addslashes($segment)); 685 if ($parent_id) { 686 $q .= sprintf(" and '%s' in parents", addslashes($parent_id)); 687 } 688 689 $url = 'https://www.googleapis.com/drive/v3/files?q=' . rawurlencode($q) . '&fields=files(id,name)'; 690 $resp = wp_remote_get($url, [ 691 'headers' => ['Authorization' => 'Bearer ' . $access_token], 692 'timeout' => 30 693 ]); 694 695 $found_id = null; 696 if (!is_wp_error($resp)) { 697 $body = json_decode(wp_remote_retrieve_body($resp), true); 698 $found_id = $body['files'][0]['id'] ?? null; 699 } 700 701 if (!$found_id) { 702 // Create folder under parent 703 $metadata = [ 704 'name' => $segment, 705 'mimeType' => 'application/vnd.google-apps.folder' 706 ]; 707 708 if ($parent_id) { 709 $metadata['parents'] = [$parent_id]; 710 } 711 712 $create_resp = wp_remote_post('https://www.googleapis.com/drive/v3/files', [ 713 'headers' => [ 714 'Authorization' => 'Bearer ' . $access_token, 715 'Content-Type' => 'application/json' 716 ], 717 'body' => wp_json_encode($metadata), 718 'timeout' => 30 719 ]); 720 721 if (!is_wp_error($create_resp)) { 722 $create_body = json_decode(wp_remote_retrieve_body($create_resp), true); 723 $found_id = $create_body['id'] ?? null; 724 $code = (int) wp_remote_retrieve_response_code($create_resp); 725 726 // 409 (conflict) means folder already exists, retry search 727 if ($code === 409 && $found_id === null) { 728 $retry_resp = wp_remote_get($url, [ 729 'headers' => ['Authorization' => 'Bearer ' . $access_token], 730 'timeout' => 30 731 ]); 732 if (!is_wp_error($retry_resp)) { 733 $retry_body = json_decode(wp_remote_retrieve_body($retry_resp), true); 734 $found_id = $retry_body['files'][0]['id'] ?? null; 735 } 736 } 737 } 738 } 739 740 if (!$found_id) { 741 $this->logger->warning('Failed to create or find Google Drive folder segment', [ 742 'segment' => $segment, 743 'parent_id' => $parent_id ? substr($parent_id, 0, 20) . '...' : 'root', 744 'path' => $folder_path 745 ]); 746 return null; 747 } 748 749 $parent_id = $found_id; 750 } 751 752 return $parent_id; 753 } 659 754 } -
siteskite/trunk/includes/Cloud/PCloudManager.php
r3389896 r3418578 204 204 // Step 3: Upload the file 205 205 if ($custom_remote_path) { 206 // Use custom remote path for incremental uploads (manifests and objects) 207 // Ensure parent folders exist before uploading 208 $this->logger->debug('Ensuring parent folders for incremental upload', [ 209 'custom_remote_path' => $custom_remote_path 206 // Use custom remote path for incremental uploads (manifests and objects) or classic backups 207 // For classic backups, the path already includes folder_name: {folderName}/{user_id}/{site_host}/{filename} 208 // For incremental uploads, the path is absolute from root: siteskite/{site_host}/manifests/... 209 210 // Normalize the custom_remote_path to ensure it starts with / for proper path handling 211 $normalized_custom_path = '/' . trim($custom_remote_path, '/'); 212 213 $this->logger->debug('Ensuring parent folders for upload', [ 214 'custom_remote_path' => $custom_remote_path, 215 'normalized_custom_path' => $normalized_custom_path, 216 'folder_name' => $folder_name 210 217 ]); 211 218 $empty_cache = []; 212 $this->ensure_parent_folders_optimized($custom_remote_path, $access_token, $empty_cache); 213 // Additional delay to allow pCloud to propagate folder creation (especially for manifests folder structure) 214 // Longer delay for manifest paths to ensure folders are fully available 219 $this->ensure_parent_folders_optimized($normalized_custom_path, $access_token, $empty_cache); 220 221 // Extract directory path for verification and folderid retrieval 222 $dir_path = dirname($normalized_custom_path); 223 $dir_path = '/' . trim($dir_path, '/'); 224 225 // For classic backups (3+ level paths), get folderid for more reliable uploads 226 // Classic backup paths: {folderName}/{user_id}/{site_host}/{filename} = 3 levels 215 227 $is_manifest_path = strpos($custom_remote_path, '/manifests/') !== false; 216 usleep($is_manifest_path ? 500000 : 200000); // 500ms for manifests, 200ms for objects 217 $upload_result = $this->upload_file_to_pcloud($file_path, $custom_remote_path, $access_token); 228 $path_segments = array_filter(explode('/', trim($dir_path, '/'))); 229 $path_depth = count($path_segments); 230 $is_classic_backup = !$is_manifest_path && $path_depth >= 3; // folderName/user_id/site_host = 3 segments 231 232 $this->logger->debug('Determining upload strategy', [ 233 'dir_path' => $dir_path, 234 'path_depth' => $path_depth, 235 'is_classic_backup' => $is_classic_backup 236 ]); 237 238 $target_folderid = null; 239 if ($is_classic_backup || $is_manifest_path) { 240 // Get folderid from cache (stored during folder creation) 241 $folderid_cache_key = $dir_path . '_folderid'; 242 if (isset($empty_cache[$folderid_cache_key])) { 243 $cached_folderid = (int)$empty_cache[$folderid_cache_key]; 244 245 // Verify the cached folderid is correct by doing a stat 246 // This ensures we're using the right folderid for the correct path 247 usleep(100000); // 100ms (reduced from 200ms) - folder creation is cached 248 $verify_stat = $this->stat_path($dir_path, $access_token); 249 if ($verify_stat['success'] && ($verify_stat['exists'] ?? false) && isset($verify_stat['metadata']['folderid'])) { 250 $verified_folderid = (int)$verify_stat['metadata']['folderid']; 251 if ($verified_folderid === $cached_folderid) { 252 $target_folderid = $verified_folderid; 253 $this->logger->debug('Using verified folderid from folder creation', [ 254 'dir_path' => $dir_path, 255 'folderid' => $target_folderid, 256 'cache_key' => $folderid_cache_key 257 ]); 258 } else { 259 // Cached folderid doesn't match - use the verified one 260 $target_folderid = $verified_folderid; 261 $this->logger->warning('Cached folderid mismatch, using verified folderid', [ 262 'dir_path' => $dir_path, 263 'cached_folderid' => $cached_folderid, 264 'verified_folderid' => $verified_folderid, 265 'cache_key' => $folderid_cache_key 266 ]); 267 } 268 } else { 269 // Stat failed, use cached folderid as fallback 270 $target_folderid = $cached_folderid; 271 $this->logger->warning('Could not verify cached folderid via stat, using cached value', [ 272 'dir_path' => $dir_path, 273 'folderid' => $target_folderid, 274 'stat_result' => $verify_stat 275 ]); 276 } 277 } else { 278 // Fallback: Try to get folderid via stat (for edge cases) 279 $this->logger->debug('Folderid not in cache, trying stat', [ 280 'dir_path' => $dir_path, 281 'cache_key' => $folderid_cache_key, 282 'cache_keys_available' => array_keys($empty_cache) 283 ]); 284 usleep(150000); // 150ms (reduced from 300ms) - folder creation is cached 285 $stat_result = $this->stat_path($dir_path, $access_token); 286 if ($stat_result['success'] && ($stat_result['exists'] ?? false) && isset($stat_result['metadata']['folderid'])) { 287 $target_folderid = (int)$stat_result['metadata']['folderid']; 288 $this->logger->debug('Retrieved folderid via stat fallback', [ 289 'dir_path' => $dir_path, 290 'folderid' => $target_folderid 291 ]); 292 } else { 293 $this->logger->warning('Could not retrieve folderid via stat', [ 294 'dir_path' => $dir_path, 295 'stat_result' => $stat_result 296 ]); 297 } 298 } 299 300 // Reduced delay for classic backups - folder creation is cached and idempotent 301 if ($is_classic_backup && $target_folderid) { 302 usleep(100000); // 100ms (reduced from 300ms) when we have folderid 303 } elseif ($is_classic_backup) { 304 usleep(200000); // 200ms (reduced from 500ms) if no folderid 305 } 306 } else { 307 // For shallow paths, minimal delay 308 usleep(50000); // 50ms (reduced from 200ms) for objects 309 } 310 311 $upload_result = $this->upload_file_to_pcloud($file_path, $normalized_custom_path, $access_token, $target_folderid); 218 312 } else { 219 313 // Use folder-based upload for regular uploads … … 675 769 // Don't check if exists first - just try creating (idempotent operation) 676 770 $current_path = ''; 771 $segment_index = 0; 677 772 foreach ($segments as $segment) { 773 $segment_index++; 678 774 $current_path .= '/' . $segment; 679 775 … … 692 788 $is_manifest_final = (count($segments) === 4 && ($folder_name === 'files' || $folder_name === 'database')); 693 789 694 $this->logger->debug('Creating pCloud folder in optimized flow', [ 695 'current_path' => $current_path, 696 'parent_path' => $parent_path, 697 'folder_name' => $folder_name, 698 'segment_index' => array_search($segment, $segments) + 1, 699 'total_segments' => count($segments), 700 'is_manifest_final' => $is_manifest_final 701 ]); 702 703 // For manifest final folders, ensure parent exists and get its folderid first 704 if ($is_manifest_final) { 705 // Verify parent exists and get folderid for more reliable creation 706 $parent_stat = $this->stat_path($parent_path, $access_token); 707 if ($parent_stat['success'] && ($parent_stat['exists'] ?? false) && isset($parent_stat['metadata']['folderid'])) { 708 $this->logger->debug('Using folderid for manifest final folder creation', [ 790 // For classic backup: folderName/user_id/site_host (3 segments), the final folder is the 3rd segment 791 // segment_index is 1-based, so for 3 segments, final is when segment_index === 3 792 $is_classic_backup_final = (count($segments) === 3 && $segment_index === 3); 793 794 // $this->logger->debug('Creating pCloud folder in optimized flow', [ 795 // 'current_path' => $current_path, 796 // 'parent_path' => $parent_path, 797 // 'folder_name' => $folder_name, 798 // 'segment_index' => $segment_index, 799 // 'total_segments' => count($segments), 800 // 'is_manifest_final' => $is_manifest_final, 801 // 'is_classic_backup_final' => $is_classic_backup_final 802 // ]); 803 804 // For final folders (manifest or classic backup), use parent folderid if available for better reliability 805 // Get parent folderid from cache first (faster), or via stat if not cached 806 $parent_folderid = null; 807 if ($is_manifest_final || $is_classic_backup_final) { 808 $parent_folderid_cache_key = $parent_path . '_folderid'; 809 if (isset($folderCache[$parent_folderid_cache_key])) { 810 $parent_folderid = (int)$folderCache[$parent_folderid_cache_key]; 811 $this->logger->debug('Using cached parent folderid for final folder creation', [ 709 812 'parent_path' => $parent_path, 710 'parent_folderid' => $parent_ stat['metadata']['folderid'],813 'parent_folderid' => $parent_folderid, 711 814 'folder_name' => $folder_name 712 815 ]); 713 // Create using folderid directly for better reliability 714 $folder_endpoint = $this->get_api_endpoint($access_token, 'createfolderifnotexists'); 715 $folder_url = $folder_endpoint . '?' . http_build_query([ 716 'access_token' => $access_token, 717 'name' => $folder_name, 718 'folderid' => (string)$parent_stat['metadata']['folderid'] 816 } else { 817 // Fallback: get parent folderid via stat 818 $parent_stat = $this->stat_path($parent_path, $access_token); 819 if ($parent_stat['success'] && ($parent_stat['exists'] ?? false) && isset($parent_stat['metadata']['folderid'])) { 820 $parent_folderid = (int)$parent_stat['metadata']['folderid']; 821 } 822 } 823 } 824 825 // Use create_folder_at_path for all folders - it handles verification and retries 826 $create_result = $this->create_folder_at_path($parent_path, $folder_name, $access_token, $parent_folderid); 827 828 // If successful, cache the folder and store folderid if available 829 if ($create_result['success']) { 830 $created_folderid = $create_result['folderid'] ?? null; 831 $folderCache[$current_path] = true; 832 833 // $this->logger->debug('Successfully created pCloud folder (optimized)', [ 834 // 'current_path' => $current_path, 835 // 'folder_name' => $folder_name, 836 // 'folderid' => $created_folderid 837 // ]); 838 839 // Store folderid for classic backup final folder (needed for upload) 840 if ($is_classic_backup_final && $created_folderid) { 841 $folderCache[$current_path . '_folderid'] = $created_folderid; 842 $this->logger->debug('Stored folderid for classic backup final folder', [ 843 'current_path' => $current_path, 844 'folderid' => $created_folderid 719 845 ]); 720 721 $response = wp_remote_get($folder_url, ['timeout' => 30]); 722 if (!is_wp_error($response)) { 723 $response_code = wp_remote_retrieve_response_code($response); 724 $response_body = wp_remote_retrieve_body($response); 725 $decoded = json_decode($response_body, true) ?: []; 726 $result_code = isset($decoded['result']) ? (int)$decoded['result'] : -1; 727 728 if ($response_code === 200 && ($result_code === 0 || $result_code === 2005)) { 729 $create_result = ['success' => true]; 846 } elseif ($is_classic_backup_final && !$created_folderid) { 847 // Log warning - create_folder_at_path already tried to verify, but couldn't get folderid 848 $this->logger->warning('Final classic backup folder created but folderid unavailable', [ 849 'current_path' => $current_path, 850 'folder_name' => $folder_name, 851 'note' => 'Upload will use path instead of folderid' 852 ]); 853 } 854 855 // Optimized: Reduce verification overhead - rely on folder creation being idempotent 856 // Only verify for critical paths, and use minimal delays 857 if (count($segments) >= 5) { 858 // Hash prefix folders - minimal verification (folder creation is cached and idempotent) 859 // Single quick check, then rely on upload retry if needed 860 usleep(50000); // 50ms (reduced from 150ms * 3 attempts) 861 $verify_stat = $this->stat_path($current_path, $access_token); 862 if ($verify_stat['success'] && ($verify_stat['exists'] ?? false)) { 863 $folderCache[$current_path] = true; 864 } else { 865 // Mark as created anyway - upload will retry if folder truly missing 866 $folderCache[$current_path] = true; 867 } 868 } elseif (count($segments) >= 4) { 869 // Manifest folders - reduced verification 870 $is_final_folder = ($folder_name === 'files' || $folder_name === 'database'); 871 if ($is_final_folder) { 872 // Final folder: single verification attempt with minimal delay 873 usleep(100000); // 100ms (reduced from 300ms * 8 attempts) 874 $verify_stat = $this->stat_path($current_path, $access_token); 875 if ($verify_stat['success'] && ($verify_stat['exists'] ?? false)) { 876 $folderCache[$current_path] = true; 730 877 } else { 731 // Fallback to normal method732 $ create_result = $this->create_folder_at_path($parent_path, $folder_name, $access_token);878 // Mark as created - upload will handle retry 879 $folderCache[$current_path] = true; 733 880 } 734 881 } else { 735 // Fallback to normal method 736 $create_result = $this->create_folder_at_path($parent_path, $folder_name, $access_token); 737 } 738 } else { 739 // Parent doesn't exist or we can't get folderid, use normal method 740 $create_result = $this->create_folder_at_path($parent_path, $folder_name, $access_token); 741 } 742 } else { 743 $create_result = $this->create_folder_at_path($parent_path, $folder_name, $access_token); 744 } 745 746 // If successful or folder already exists (result 2005), cache it 747 if ($create_result['success']) { 748 $folderCache[$current_path] = true; 749 $this->logger->debug('Successfully created pCloud folder (optimized)', [ 750 'current_path' => $current_path, 751 'folder_name' => $folder_name 752 ]); 753 754 // Verify folder actually exists (especially important for deep folders) 755 if (count($segments) >= 5) { 756 // Hash prefix folders - verify they exist before continuing 757 $verify_attempts = 0; 758 $verified = false; 759 while ($verify_attempts < 3 && !$verified) { 760 usleep(150000); // 150ms per attempt 761 $verify_stat = $this->stat_path($current_path, $access_token); 762 if ($verify_stat['success'] && ($verify_stat['exists'] ?? false)) { 763 $verified = true; 764 $folderCache[$current_path] = true; // Cache verified folder 765 $this->logger->debug('Hash prefix folder verified after creation', [ 766 'path' => $current_path, 767 'attempts' => $verify_attempts + 1 768 ]); 769 } 770 $verify_attempts++; 771 } 772 773 if (!$verified) { 774 // Even if verification fails, mark as created in cache to avoid repeated attempts 775 // This is expected with pCloud's eventual consistency - folders may take a moment to propagate 776 // The upload itself will handle any real issues 777 $folderCache[$current_path] = true; 778 $this->logger->debug('Hash prefix folder not immediately verified (expected with pCloud eventual consistency)', [ 779 'path' => $current_path, 780 'parent' => $parent_path, 781 'name' => $folder_name, 782 'note' => 'Folder created successfully; verification may lag due to eventual consistency. Upload will handle if folder truly missing' 783 ]); 784 } 785 } elseif (count($segments) >= 4) { 786 // Manifest folders (4 levels: siteskite/site/manifests/files) - verify they exist 787 // The final folder (files/database) often needs more time and retries 788 $is_final_folder = ($folder_name === 'files' || $folder_name === 'database'); 789 $max_attempts = $is_final_folder ? 8 : 5; // More attempts for final folders 790 $verify_attempts = 0; 791 $verified = false; 792 793 // For final folders, try recreating if verification fails 794 while ($verify_attempts < $max_attempts && !$verified) { 795 usleep($is_final_folder ? 300000 : 150000); // 300ms for final, 150ms for others 796 $verify_stat = $this->stat_path($current_path, $access_token); 797 if ($verify_stat['success'] && ($verify_stat['exists'] ?? false)) { 798 $verified = true; 799 $folderCache[$current_path] = true; // Cache verified folder 800 $this->logger->debug('Manifest folder verified after creation', [ 801 'path' => $current_path, 802 'attempts' => $verify_attempts + 1, 803 'is_final_folder' => $is_final_folder 804 ]); 805 } else { 806 // If verification fails and it's a final folder, try recreating it 807 if ($is_final_folder && $verify_attempts > 1 && $verify_attempts % 2 === 0) { 808 $this->logger->debug('Retrying creation of final manifest folder', [ 809 'path' => $current_path, 810 'parent' => $parent_path, 811 'name' => $folder_name, 812 'attempt' => $verify_attempts 813 ]); 814 $this->create_folder_at_path($parent_path, $folder_name, $access_token); 815 usleep(200000); // Wait after recreation 816 } 817 } 818 $verify_attempts++; 819 } 820 821 if (!$verified) { 822 // Even if verification fails, mark as created in cache to avoid repeated attempts 823 // The upload itself will handle any real issues 824 $folderCache[$current_path] = true; 825 $this->logger->warning('Manifest folder not verified after creation', [ 826 'path' => $current_path, 827 'parent' => $parent_path, 828 'name' => $folder_name, 829 'attempts' => $verify_attempts, 830 'is_final_folder' => $is_final_folder, 831 'note' => 'Marking as created in cache; upload will handle if folder truly missing' 832 ]); 833 } elseif ($is_final_folder) { 834 // Additional wait for final folder after verification 835 usleep(400000); // 400ms extra wait for final folder 836 $this->logger->debug('Final manifest folder verified and ready', [ 837 'path' => $current_path 838 ]); 882 // Non-final manifest folder: no verification needed 883 usleep(30000); // 30ms minimal delay 839 884 } 840 885 } elseif (count($segments) > 3) { 841 usleep( 50000); // 50ms for other deep folders886 usleep(20000); // 20ms for other deep folders (reduced from 50ms) 842 887 } 843 888 } else { … … 1064 1109 * @param string $folder_name Folder name to create 1065 1110 * @param string $access_token Access token 1111 * @param int|null $parent_folderid Optional parent folderid (more reliable than path for nested folders) 1066 1112 * @return array Result with success status 1067 1113 */ 1068 private function create_folder_at_path(string $parent_path, string $folder_name, string $access_token ): array1114 private function create_folder_at_path(string $parent_path, string $folder_name, string $access_token, ?int $parent_folderid = null): array 1069 1115 { 1070 1116 try { … … 1088 1134 // Root folder - use folderid=0 1089 1135 $query_params['folderid'] = '0'; 1136 } elseif ($parent_folderid !== null) { 1137 // Use provided parent folderid (most reliable for nested folders) 1138 $query_params['folderid'] = (string)$parent_folderid; 1139 $this->logger->debug('Using provided parent folderid for folder creation', [ 1140 'parent_path' => $parent_path, 1141 'folder_name' => $folder_name, 1142 'parent_folderid' => $parent_folderid 1143 ]); 1090 1144 } else { 1091 1145 // For deep nested folders (especially hash prefix folders), use folderid for better reliability 1092 1146 // Count segments: /siteskite/inc.site/objects/sha256 = 4 segments 1093 1147 $depth = substr_count(trim($parent_path, '/'), '/') + 1; 1094 if ($depth >= 4) {1095 // Deep nested (4+ segments) - get folderid for reliability1096 // This is critical for hash prefix folders like /.../sha256/af1148 if ($depth >= 3) { 1149 // Nested (3+ segments) - get folderid for reliability 1150 // This is critical for classic backup paths: folderName/user_id/site_host 1097 1151 $parent_stat = $this->stat_path($parent_path, $access_token); 1098 1152 if ($parent_stat['success'] && isset($parent_stat['metadata']['folderid'])) { 1099 1153 $query_params['folderid'] = (string)$parent_stat['metadata']['folderid']; 1100 $this->logger->debug('Using folderid for deep nested folder creation', [ 1154 // $this->logger->debug('Using folderid for nested folder creation (from stat)', [ 1155 // 'parent_path' => $parent_path, 1156 // 'folder_name' => $folder_name, 1157 // 'folderid' => $query_params['folderid'], 1158 // 'depth' => $depth 1159 // ]); 1160 } else { 1161 // Fallback to path if stat fails, but log it 1162 $this->logger->warning('Failed to get folderid for nested folder, falling back to path', [ 1101 1163 'parent_path' => $parent_path, 1102 1164 'folder_name' => $folder_name, 1103 'folderid' => $query_params['folderid'] 1104 ]); 1105 } else { 1106 // Fallback to path if stat fails, but log it 1107 $this->logger->warning('Failed to get folderid for deep nested folder, falling back to path', [ 1108 'parent_path' => $parent_path, 1109 'folder_name' => $folder_name, 1110 'stat_result' => $parent_stat 1165 'stat_result' => $parent_stat, 1166 'depth' => $depth 1111 1167 ]); 1112 1168 $query_params['path'] = $parent_path; … … 1137 1193 $result_code = isset($decoded['result']) ? (int)$decoded['result'] : -1; 1138 1194 1195 // Extract folderid from response if available 1196 $folderid = null; 1197 if (isset($decoded['metadata']['folderid'])) { 1198 $folderid = (int)$decoded['metadata']['folderid']; 1199 } elseif (isset($decoded['folderid'])) { 1200 $folderid = (int)$decoded['folderid']; 1201 } 1202 1139 1203 // pCloud API: result 0 = success, result 2005 = folder already exists (also success) 1140 1204 if ($response_code === 200 && ($result_code === 0 || $result_code === 2005)) { 1141 // $this->logger->debug('pCloud folder created or already exists', [ 1142 // 'parent_path' => $parent_path, 1143 // 'folder_name' => $folder_name, 1144 // 'result_code' => $result_code 1145 // ]); 1146 return ['success' => true]; 1205 // Always verify folderid by doing a stat on the full path to ensure we have the correct folderid 1206 // This is critical for classic backup paths where we need the correct folderid for uploads 1207 $full_path = ($parent_path === '/') ? '/' . $folder_name : $parent_path . '/' . $folder_name; 1208 1209 // For newly created folders, wait longer and retry stat multiple times 1210 // pCloud has eventual consistency issues, especially for nested folders 1211 $max_retries = ($result_code === 0) ? 5 : 2; // More retries for newly created folders 1212 $retry_delay = ($result_code === 0) ? 300000 : 200000; // 300ms for new, 200ms for existing 1213 $verified_folderid = null; 1214 $stat_check = null; 1215 1216 for ($retry = 0; $retry < $max_retries; $retry++) { 1217 if ($retry > 0) { 1218 usleep($retry_delay); 1219 } 1220 1221 $stat_check = $this->stat_path($full_path, $access_token); 1222 if ($stat_check['success'] && ($stat_check['exists'] ?? false) && isset($stat_check['metadata']['folderid'])) { 1223 $verified_folderid = (int)$stat_check['metadata']['folderid']; 1224 // $this->logger->debug('pCloud folder created or already exists (verified via stat)', [ 1225 // 'parent_path' => $parent_path, 1226 // 'folder_name' => $folder_name, 1227 // 'full_path' => $full_path, 1228 // 'result_code' => $result_code, 1229 // 'folderid_from_response' => $folderid, 1230 // 'folderid_from_stat' => $verified_folderid, 1231 // 'retry_attempt' => $retry + 1, 1232 // 'using_verified_folderid' => true 1233 // ]); 1234 break; 1235 } 1236 } 1237 1238 if ($verified_folderid) { 1239 return [ 1240 'success' => true, 1241 'folderid' => $verified_folderid 1242 ]; 1243 } else { 1244 // Folder doesn't exist according to stat - this is a problem 1245 // Don't use the response folderid as it might be wrong (parent folderid) 1246 $this->logger->warning('pCloud folder creation reported success but folder does not exist via stat', [ 1247 'parent_path' => $parent_path, 1248 'folder_name' => $folder_name, 1249 'full_path' => $full_path, 1250 'result_code' => $result_code, 1251 'folderid_from_response' => $folderid, 1252 'stat_result' => $stat_check, 1253 'retries' => $max_retries 1254 ]); 1255 1256 // Return success but with null folderid - the caller should handle this 1257 // The folder might exist but stat is failing, or the folder wasn't actually created 1258 return [ 1259 'success' => true, 1260 'folderid' => null, 1261 'warning' => 'Folder creation reported success but could not verify via stat' 1262 ]; 1263 } 1147 1264 } 1148 1265 … … 1150 1267 // 2005 means folder already exists, which is fine 1151 1268 if ($result_code === 2005) { 1152 return ['success' => true]; 1269 // Try to get folderid via stat if not in response 1270 if (!$folderid) { 1271 $full_path = ($parent_path === '/') ? '/' . $folder_name : $parent_path . '/' . $folder_name; 1272 $stat_check = $this->stat_path($full_path, $access_token); 1273 if ($stat_check['success'] && ($stat_check['exists'] ?? false) && isset($stat_check['metadata']['folderid'])) { 1274 $folderid = (int)$stat_check['metadata']['folderid']; 1275 } 1276 } 1277 return [ 1278 'success' => true, 1279 'folderid' => $folderid 1280 ]; 1153 1281 } 1154 1282 … … 1159 1287 $stat_check = $this->stat_path($full_path, $access_token); 1160 1288 if ($stat_check['success'] && ($stat_check['exists'] ?? false)) { 1289 $folderid_from_stat = isset($stat_check['metadata']['folderid']) ? (int)$stat_check['metadata']['folderid'] : null; 1161 1290 // $this->logger->debug('pCloud folder exists despite API error 1001', [ 1162 1291 // 'parent_path' => $parent_path, 1163 1292 // 'folder_name' => $folder_name, 1164 // 'full_path' => $full_path 1293 // 'full_path' => $full_path, 1294 // 'folderid' => $folderid_from_stat 1165 1295 // ]); 1166 return ['success' => true]; 1296 return [ 1297 'success' => true, 1298 'folderid' => $folderid_from_stat 1299 ]; 1167 1300 } 1168 1301 } … … 1365 1498 'nopartial' => '1', 1366 1499 ]; 1367 // Use the directory path (not including filename) 1368 $query['path'] = $upload_path; 1500 1501 // For nested paths (3+ levels), use folderid if available for better reliability 1502 // This is especially important for classic backups: {folderName}/{user_id}/{site_host} 1503 $path_segments = array_filter(explode('/', trim($upload_path, '/'))); 1504 $path_depth = count($path_segments); 1505 1506 if ($folderid !== null && $path_depth >= 2) { 1507 // Verify folderid matches the expected path before using it 1508 // This is critical to ensure files go to the correct location 1509 $verify_stat = $this->stat_path($upload_path, $access_token); 1510 $verified_folderid = null; 1511 if ($verify_stat['success'] && ($verify_stat['exists'] ?? false) && isset($verify_stat['metadata']['folderid'])) { 1512 $verified_folderid = (int)$verify_stat['metadata']['folderid']; 1513 } 1514 1515 if ($verified_folderid && $verified_folderid === $folderid) { 1516 // Folderid matches - safe to use 1517 $query['folderid'] = (string)$folderid; 1518 $this->logger->debug('Using verified folderid for nested path upload', [ 1519 'folderid' => $folderid, 1520 'upload_path' => $upload_path, 1521 'path_depth' => $path_depth, 1522 'verified' => true 1523 ]); 1524 } elseif ($verified_folderid) { 1525 // Folderid mismatch - use the verified one 1526 $query['folderid'] = (string)$verified_folderid; 1527 $this->logger->warning('Folderid mismatch detected, using verified folderid', [ 1528 'original_folderid' => $folderid, 1529 'verified_folderid' => $verified_folderid, 1530 'upload_path' => $upload_path, 1531 'path_depth' => $path_depth 1532 ]); 1533 } else { 1534 // Could not verify folderid - fall back to path for safety 1535 $query['path'] = $upload_path; 1536 $this->logger->warning('Could not verify folderid, falling back to path', [ 1537 'folderid' => $folderid, 1538 'upload_path' => $upload_path, 1539 'path_depth' => $path_depth, 1540 'stat_result' => $verify_stat 1541 ]); 1542 } 1543 } else { 1544 // Use path for shallow paths or when folderid not available 1545 $query['path'] = $upload_path; 1546 $this->logger->debug('Using path for upload', [ 1547 'upload_path' => $upload_path, 1548 'path_depth' => $path_depth, 1549 'folderid_available' => $folderid !== null 1550 ]); 1551 } 1369 1552 1370 1553 $upload_url = $upload_endpoint . '?' . http_build_query($query); … … 1372 1555 $this->logger->debug('Starting pCloud upload request'); 1373 1556 1557 // For large files (>10MB), use streaming to avoid memory issues 1558 // For smaller files, use in-memory approach (more reliable with pCloud API) 1559 $use_streaming = $file_size > 10 * 1024 * 1024; // 10MB threshold 1560 1561 if ($use_streaming) { 1562 // Streaming upload for large files - avoids loading entire file into memory 1563 return $this->upload_file_streaming($file_path, $remote_filename, $upload_url, $mime_type, $file_size, $upload_path, $access_token, $folderid); 1564 } 1565 1566 // Small files: use in-memory approach (more reliable) 1374 1567 // Read file contents for WordPress HTTP API 1375 // Note: For very large files, this might consume memory, but it's the WordPress-standard way1376 1568 $file_contents = file_get_contents($file_path); 1377 1569 if ($file_contents === false) { … … 1470 1662 1471 1663 if (!$file_id && $has_directory_error) { 1472 $this->logger->info('pCloud upload failed with "Directory does not exist", ensuring full directory path and retrying', [1664 $this->logger->info('pCloud upload failed with "Directory does not exist", retrying with folder recreation', [ 1473 1665 'upload_path' => $upload_path, 1474 'error' => $upload_data['error'] ?? 'Directory does not exist' 1475 ]);1476 1477 // Ensure the entire parent path exists (not just the final segment)1478 // This handles cases where intermediate folders might be missing1666 'error' => $upload_data['error'] ?? 'Directory does not exist', 1667 'has_folderid' => $folderid !== null 1668 ]); 1669 1670 // Recreate folders and get folderid if we don't have it 1479 1671 $full_file_path = $upload_path . '/' . $remote_filename; 1480 $empty_cache = []; // Use empty cache to force recreation check 1481 $this->ensure_parent_folders_optimized($full_file_path, $access_token, $empty_cache); 1482 1483 // Verify the directory actually exists before retrying (with retries) 1484 $verify_retries = 0; 1485 $max_verify_retries = 5; 1486 $dir_exists = false; 1487 1488 while ($verify_retries < $max_verify_retries) { 1489 usleep(200000 * ($verify_retries + 1)); // 200ms, 400ms, 600ms, 800ms, 1000ms 1672 $retry_cache = []; 1673 $this->ensure_parent_folders_optimized($full_file_path, $access_token, $retry_cache); 1674 1675 // Get folderid from retry cache or use existing 1676 $retry_folderid = $folderid; 1677 $folderid_cache_key = $upload_path . '_folderid'; 1678 if (isset($retry_cache[$folderid_cache_key])) { 1679 $retry_folderid = (int)$retry_cache[$folderid_cache_key]; 1680 } elseif (!$retry_folderid) { 1681 // Fallback: try stat to get folderid 1682 usleep(500000); // Wait for folder propagation 1490 1683 $stat_result = $this->stat_path($upload_path, $access_token); 1491 1492 if ($stat_result['success'] && ($stat_result['exists'] ?? false)) { 1493 $dir_exists = true; 1494 $this->logger->debug('Upload directory verified after retry folder creation', [ 1495 'upload_path' => $upload_path, 1496 'retries' => $verify_retries + 1 1497 ]); 1498 break; 1499 } 1500 1501 $verify_retries++; 1502 1503 // If still doesn't exist, try creating the final directory again 1504 if ($verify_retries < $max_verify_retries) { 1505 $final_segment = basename($upload_path); 1506 $final_parent = dirname($upload_path); 1507 if ($final_parent && $final_parent !== '/' && $final_parent !== '.') { 1508 $this->create_folder_at_path($final_parent, $final_segment, $access_token); 1509 } 1684 if ($stat_result['success'] && ($stat_result['exists'] ?? false) && isset($stat_result['metadata']['folderid'])) { 1685 $retry_folderid = (int)$stat_result['metadata']['folderid']; 1510 1686 } 1511 1687 } 1512 1688 1513 if (!$dir_exists) { 1514 $this->logger->warning('Upload directory still does not exist after retries, attempting upload anyway', [ 1515 'upload_path' => $upload_path, 1516 'max_retries' => $max_verify_retries 1517 ]); 1689 // Wait before retry 1690 $path_depth_retry = count(array_filter(explode('/', trim($upload_path, '/')))); 1691 usleep($path_depth_retry >= 2 ? 500000 : 300000); 1692 1693 // Rebuild upload URL - prefer folderid if available 1694 $retry_query = [ 1695 'access_token' => $access_token, 1696 'filename' => $remote_filename, 1697 'renameifexists' => '1', 1698 'nopartial' => '1', 1699 ]; 1700 1701 if ($retry_folderid !== null && $path_depth_retry >= 2) { 1702 $retry_query['folderid'] = (string)$retry_folderid; 1703 } else { 1704 $retry_query['path'] = $upload_path; 1518 1705 } 1519 1706 1520 // Additional wait before upload attempt 1521 usleep(300000); // 300ms 1522 1523 // Retry upload once 1524 $retry_response = wp_safe_remote_post($upload_url, [ 1707 $retry_upload_url = $upload_endpoint . '?' . http_build_query($retry_query); 1708 1709 // Retry upload 1710 $retry_response = wp_safe_remote_post($retry_upload_url, [ 1525 1711 'timeout' => max(300, (int) ceil($file_size / (1024 * 1024)) * 10), 1526 1712 'headers' => [ … … 1535 1721 $retry_code = wp_remote_retrieve_response_code($retry_response); 1536 1722 $retry_body = wp_remote_retrieve_body($retry_response); 1537 1538 $this->logger->debug('pCloud retry upload response', [1539 'response_code' => $retry_code,1540 'response_body' => $retry_body1541 ]);1542 1723 1543 1724 if ($retry_code === 200) { … … 1551 1732 ]); 1552 1733 } else { 1553 // Update for error handling below1554 1734 $response_code = $retry_code; 1555 1735 $response_body = $retry_body; … … 1557 1737 } 1558 1738 } else { 1559 // Update response for error handling below1560 1739 $response_code = $retry_code; 1561 1740 $response_body = $retry_body; … … 1618 1797 } 1619 1798 } 1799 } 1800 1801 /** 1802 * Upload file using streaming for large files (avoids loading entire file into memory) 1803 * 1804 * @param string $file_path Local file path 1805 * @param string $remote_filename Remote filename 1806 * @param string $upload_url Upload URL with query parameters 1807 * @param string $mime_type MIME type 1808 * @param int $file_size File size in bytes 1809 * @param string $upload_path Upload path (for retry logic) 1810 * @param string $access_token Access token 1811 * @param int|null $folderid Folder ID (for retry logic) 1812 * @return array Upload result 1813 */ 1814 private function upload_file_streaming(string $file_path, string $remote_filename, string $upload_url, string $mime_type, int $file_size, string $upload_path, string $access_token, ?int $folderid): array 1815 { 1816 $file_handle = fopen($file_path, 'rb'); 1817 if ($file_handle === false) { 1818 return [ 1819 'success' => false, 1820 'error' => 'Failed to open file for streaming upload' 1821 ]; 1822 } 1823 1824 try { 1825 // Create multipart boundary 1826 $boundary = wp_generate_password(24, false); 1827 1828 // Build multipart body header 1829 $multipart_header = '--' . $boundary . "\r\n"; 1830 $multipart_header .= 'Content-Disposition: form-data; name="file"; filename="' . $remote_filename . '"' . "\r\n"; 1831 $multipart_header .= 'Content-Type: ' . $mime_type . "\r\n\r\n"; 1832 $multipart_footer = "\r\n--" . $boundary . "--"; 1833 1834 // Calculate total size for Content-Length 1835 $header_size = strlen($multipart_header); 1836 $footer_size = strlen($multipart_footer); 1837 $total_size = $header_size + $file_size + $footer_size; 1838 1839 // Use cURL directly for streaming upload 1840 $ch = curl_init($upload_url); 1841 if ($ch === false) { 1842 return [ 1843 'success' => false, 1844 'error' => 'Failed to initialize cURL for streaming upload' 1845 ]; 1846 } 1847 1848 // Build multipart body using streams 1849 $body_stream = fopen('php://temp', 'r+'); 1850 if ($body_stream === false) { 1851 curl_close($ch); 1852 return [ 1853 'success' => false, 1854 'error' => 'Failed to create temp stream for multipart body' 1855 ]; 1856 } 1857 1858 // Write header 1859 fwrite($body_stream, $multipart_header); 1860 1861 // Stream file contents in chunks 1862 $chunk_size = 8192; // 8KB chunks 1863 while (!feof($file_handle)) { 1864 $chunk = fread($file_handle, $chunk_size); 1865 if ($chunk !== false) { 1866 fwrite($body_stream, $chunk); 1867 } 1868 } 1869 1870 // Write footer 1871 fwrite($body_stream, $multipart_footer); 1872 1873 // Rewind stream 1874 rewind($body_stream); 1875 1876 // Get stream contents (WordPress HTTP API will handle this efficiently) 1877 $body_contents = stream_get_contents($body_stream); 1878 fclose($body_stream); 1879 1880 // Configure cURL for upload 1881 curl_setopt_array($ch, [ 1882 CURLOPT_POST => true, 1883 CURLOPT_POSTFIELDS => $body_contents, 1884 CURLOPT_RETURNTRANSFER => true, 1885 CURLOPT_TIMEOUT => max(300, (int) ceil($file_size / (1024 * 1024)) * 10), 1886 CURLOPT_SSL_VERIFYPEER => true, 1887 CURLOPT_SSL_VERIFYHOST => 2, 1888 CURLOPT_HTTPHEADER => [ 1889 'Content-Type: multipart/form-data; boundary=' . $boundary, 1890 'Expect:', 1891 'User-Agent: SiteSkite-Link/1.0; WordPress/' . get_bloginfo('version'), 1892 'Content-Length: ' . $total_size 1893 ] 1894 ]); 1895 1896 $response_body = curl_exec($ch); 1897 $response_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); 1898 $curl_error = curl_error($ch); 1899 curl_close($ch); 1900 1901 if ($response_body === false || !empty($curl_error)) { 1902 return [ 1903 'success' => false, 1904 'error' => 'cURL error during streaming upload: ' . $curl_error 1905 ]; 1906 } 1907 1908 if ($response_code !== 200) { 1909 return [ 1910 'success' => false, 1911 'error' => 'Upload failed with status ' . $response_code . ': ' . $response_body 1912 ]; 1913 } 1914 1915 $upload_data = json_decode($response_body, true); 1916 $file_id = $upload_data['fileids'][0] ?? ($upload_data['metadata'][0]['fileid'] ?? null); 1917 1918 // Handle directory errors (same as non-streaming) 1919 $error_result = isset($upload_data['result']) ? (int)$upload_data['result'] : 0; 1920 $error_message = isset($upload_data['error']) ? (string)$upload_data['error'] : ''; 1921 $has_directory_error = ($error_result === 2005) || 1922 (stripos($error_message, 'Directory does not exist') !== false); 1923 1924 if (!$file_id && $has_directory_error) { 1925 // Retry with folder recreation (same logic as non-streaming) 1926 $full_file_path = $upload_path . '/' . $remote_filename; 1927 $retry_cache = []; 1928 $this->ensure_parent_folders_optimized($full_file_path, $access_token, $retry_cache); 1929 1930 // Retry upload (use non-streaming for retry to be more reliable) 1931 usleep(300000); // 300ms wait 1932 return $this->upload_file_to_pcloud($file_path, $upload_path . '/' . $remote_filename, $access_token, $folderid); 1933 } 1934 1935 if (!$file_id) { 1936 return [ 1937 'success' => false, 1938 'error' => 'No file ID returned from pCloud upload. Response: ' . $response_body 1939 ]; 1940 } 1941 1942 return [ 1943 'success' => true, 1944 'file_id' => $file_id, 1945 'file_size' => $file_size 1946 ]; 1947 1948 } finally { 1949 fclose($file_handle); 1950 } 1951 } 1952 1953 /** 1954 * Upload file from stream (for incremental backups) 1955 * 1956 * @param resource $stream Stream resource 1957 * @param int $size Stream size in bytes 1958 * @param string $folder_name Folder name (not used for incremental) 1959 * @param string $access_token Access token 1960 * @param string $custom_remote_path Full remote path 1961 * @return array Upload result 1962 */ 1963 public function upload_file_from_stream($stream, int $size, string $folder_name, string $access_token, string $custom_remote_path): array 1964 { 1965 // Extract directory and filename from custom_remote_path 1966 $upload_path = dirname($custom_remote_path); 1967 $upload_path = '/' . trim($upload_path, '/'); 1968 $remote_filename = basename($custom_remote_path); 1969 1970 // Get upload endpoint 1971 $upload_endpoint = $this->get_api_endpoint($access_token, 'uploadfile'); 1972 1973 // Build query 1974 $query = [ 1975 'access_token' => $access_token, 1976 'filename' => $remote_filename, 1977 'renameifexists' => '1', 1978 'nopartial' => '1', 1979 'path' => $upload_path 1980 ]; 1981 1982 $upload_url = $upload_endpoint . '?' . http_build_query($query); 1983 1984 // Detect MIME type 1985 $detected = wp_check_filetype($remote_filename); 1986 $mime_type = !empty($detected['type']) ? $detected['type'] : 'application/octet-stream'; 1987 1988 // Create multipart boundary 1989 $boundary = wp_generate_password(24, false); 1990 1991 // Build multipart body using streams 1992 $body_stream = fopen('php://temp', 'r+'); 1993 if ($body_stream === false) { 1994 return [ 1995 'success' => false, 1996 'error' => 'Failed to create temp stream for multipart body' 1997 ]; 1998 } 1999 2000 // Write header 2001 $header = '--' . $boundary . "\r\n"; 2002 $header .= 'Content-Disposition: form-data; name="file"; filename="' . $remote_filename . '"' . "\r\n"; 2003 $header .= 'Content-Type: ' . $mime_type . "\r\n\r\n"; 2004 fwrite($body_stream, $header); 2005 2006 // Stream file contents in chunks 2007 $chunk_size = 8192; // 8KB chunks 2008 while (!feof($stream)) { 2009 $chunk = fread($stream, $chunk_size); 2010 if ($chunk !== false) { 2011 fwrite($body_stream, $chunk); 2012 } 2013 } 2014 2015 // Write footer 2016 $footer = "\r\n--" . $boundary . "--"; 2017 fwrite($body_stream, $footer); 2018 2019 // Rewind stream 2020 rewind($body_stream); 2021 $body_contents = stream_get_contents($body_stream); 2022 fclose($body_stream); 2023 2024 // Upload using WordPress HTTP API 2025 $response = wp_safe_remote_post($upload_url, [ 2026 'timeout' => max(300, (int) ceil($size / (1024 * 1024)) * 10), 2027 'headers' => [ 2028 'Content-Type' => 'multipart/form-data; boundary=' . $boundary, 2029 'Expect' => '', 2030 'User-Agent' => 'SiteSkite-Link/1.0; WordPress/' . get_bloginfo('version') 2031 ], 2032 'body' => $body_contents, 2033 ]); 2034 2035 if (is_wp_error($response)) { 2036 return [ 2037 'success' => false, 2038 'error' => 'Upload failed: ' . $response->get_error_message() 2039 ]; 2040 } 2041 2042 $response_code = wp_remote_retrieve_response_code($response); 2043 $response_body = wp_remote_retrieve_body($response); 2044 2045 if ($response_code !== 200) { 2046 return [ 2047 'success' => false, 2048 'error' => 'Upload failed with status ' . $response_code . ': ' . $response_body 2049 ]; 2050 } 2051 2052 $upload_data = json_decode($response_body, true); 2053 $file_id = $upload_data['fileids'][0] ?? ($upload_data['metadata'][0]['fileid'] ?? null); 2054 2055 if (!$file_id) { 2056 return [ 2057 'success' => false, 2058 'error' => 'No file ID returned from pCloud upload. Response: ' . $response_body 2059 ]; 2060 } 2061 2062 return [ 2063 'success' => true, 2064 'file_id' => $file_id, 2065 'file_size' => $size 2066 ]; 1620 2067 } 1621 2068 -
siteskite/trunk/includes/Core/Plugin.php
r3416301 r3418578 25 25 use SiteSkite\Cloud\PCloudManager; 26 26 use SiteSkite\Backup\Incremental\IncrementalStatus; 27 use SiteSkite\Cron\ExternalCronManager; 28 use SiteSkite\Cron\CronTriggerHandler; 27 29 28 30 use function add_action; … … 141 143 142 144 /** 145 * @var ExternalCronManager External cron manager instance 146 */ 147 private ExternalCronManager $external_cron_manager; 148 149 /** 150 * @var CronTriggerHandler Cron trigger handler instance 151 */ 152 private CronTriggerHandler $cron_trigger_handler; 153 154 /** 143 155 * Get plugin instance - Singleton pattern 144 156 */ … … 172 184 $this->cleanup_manager = new CleanupManager($this->logger); 173 185 186 // Initialize external cron manager 187 $this->external_cron_manager = new ExternalCronManager($this->logger); 174 188 175 189 // Initialize cloud storage managers … … 199 213 $this->backblaze_b2_manager, 200 214 $this->pcloud_manager, 201 $this->notification_manager 215 $this->notification_manager, 216 $this->external_cron_manager 202 217 ); 203 218 … … 215 230 $this->pcloud_manager, 216 231 $this->aws_manager, 217 $incremental_status 232 $incremental_status, 233 $this->external_cron_manager 218 234 ); 219 235 … … 223 239 $this->backup_manager, 224 240 $this->cleanup_manager, 225 $this->progress_tracker 241 $this->progress_tracker, 242 $this->external_cron_manager 243 ); 244 245 // Initialize cron trigger handler 246 $this->cron_trigger_handler = new CronTriggerHandler( 247 $this->logger, 248 $this->external_cron_manager, 249 $this->backup_manager, 250 $this->schedule_manager, 251 $this->restore_manager 226 252 ); 227 253 … … 236 262 $this->restore_manager, 237 263 $this->schedule_manager, 238 $this->c leanup_manager264 $this->cron_trigger_handler 239 265 ); 240 266 … … 289 315 ]); 290 316 317 // Register external cron settings save handler 318 319 // Add filter to log REST API requests for debugging cron triggers 320 add_filter('rest_pre_dispatch', [$this, 'log_rest_api_requests'], 10, 3); 321 291 322 // Background cron for continuing incremental uploads until complete 292 323 add_action('siteskite_incremental_continue', [ … … 367 398 'default' => '' 368 399 ]); 400 369 401 } 370 402 … … 422 454 } 423 455 require_once SITESKITE_PATH . 'templates/admin-page.php'; 456 } 457 458 459 /** 460 * Log REST API requests for debugging cron triggers 461 */ 462 public function log_rest_api_requests($result, $server, $request) 463 { 464 $route = $request->get_route(); 465 466 // Log cron trigger requests for debugging 467 if (strpos($route, '/cron/trigger') !== false) { 468 $this->logger->debug('REST API request received', [ 469 'route' => $route, 470 'method' => $request->get_method(), 471 'headers' => $request->get_headers(), 472 'body' => substr($request->get_body(), 0, 500) // First 500 chars 473 ]); 474 } 475 476 return $result; 424 477 } 425 478 -
siteskite/trunk/includes/Restore/RestoreManager.php
r3400025 r3418578 19 19 use SiteSkite\Restore\RestorePlanner; 20 20 use SiteSkite\Notification\NotificationManager; 21 use SiteSkite\Cron\ExternalCronManager; 22 use SiteSkite\Core\Utils; 21 23 22 24 use function get_option; … … 85 87 private NotificationManager $notification_manager; 86 88 /** 89 * @var ExternalCronManager|null External cron manager instance 90 */ 91 private ?ExternalCronManager $external_cron_manager = null; 92 /** 87 93 * Cache provider credentials pulled from stored status/runtime fetches to avoid repeated lookups. 88 94 * @var array<string, array<string, mixed>> … … 100 106 BackblazeB2Manager $backblaze_b2_manager, 101 107 PCloudManager $pcloud_manager, 102 NotificationManager $notification_manager 108 NotificationManager $notification_manager, 109 ?ExternalCronManager $external_cron_manager = null 103 110 ) { 104 111 $this->logger = $logger; … … 112 119 $this->pcloud_manager = $pcloud_manager; 113 120 $this->notification_manager = $notification_manager; 121 $this->external_cron_manager = $external_cron_manager; 114 122 $this->init_restore_directory(); 115 123 … … 143 151 update_option($old_key, $value); 144 152 } 153 154 /** 155 * Schedule cron event (uses external cron if enabled, otherwise WordPress cron) 156 */ 157 private function schedule_cron_event(string $hook, array $args, int $delay_seconds = 0, string $title = ''): void 158 { 159 // Try external cron first if enabled 160 if ($this->external_cron_manager && $this->external_cron_manager->is_enabled()) { 161 $job_id = $this->external_cron_manager->schedule_single_event($hook, $args, $delay_seconds, $title); 162 if ($job_id) { 163 $this->logger->debug('Scheduled external cron event', [ 164 'hook' => $hook, 165 'delay' => $delay_seconds, 166 'job_id' => $job_id 167 ]); 168 return; 169 } else { 170 $this->logger->warning('Failed to schedule external cron, falling back to WordPress cron', [ 171 'hook' => $hook 172 ]); 173 } 174 } 175 176 // Fallback to WordPress cron 177 wp_schedule_single_event(time() + $delay_seconds, $hook, $args); 178 } 179 145 180 146 181 private function init_restore_directory(): void … … 189 224 190 225 // Start background restore process 191 wp_schedule_single_event(time(), 'siteskite_process_restore', [ 192 'backup_id' => $backup_id, 193 'type' => $type, 194 'download_url' => $download_url 195 ]); 226 $this->schedule_cron_event( 227 'siteskite_process_restore', 228 [ 229 'backup_id' => $backup_id, 230 'type' => $type, 231 'download_url' => $download_url 232 ], 233 0, // Immediate execution 234 "SiteSkite Process Restore" 235 ); 196 236 197 237 return new \WP_REST_Response($restore_info); … … 548 588 549 589 // Schedule restore to run via cron (asynchronous) 550 if (function_exists('wp_schedule_single_event')) { 551 wp_schedule_single_event(time(), 'siteskite_process_incremental_restore', [$manifestId]); 552 $this->logger->info('Incremental restore scheduled via cron', [ 553 'manifest_id' => $manifestId, 554 'scope' => $scope 555 ]); 556 } 590 $this->schedule_cron_event( 591 'siteskite_process_incremental_restore', 592 [$manifestId], 593 0, // Immediate execution 594 "SiteSkite Process Incremental Restore" 595 ); 596 $this->logger->info('Incremental restore scheduled via cron', [ 597 'manifest_id' => $manifestId, 598 'scope' => $scope 599 ]); 557 600 558 601 return new \WP_REST_Response([ … … 2172 2215 } 2173 2216 2174 // For PCloud, use the full object path 2217 // For PCloud, use the full object path to get file_id, then download 2175 2218 // The objectPath is: siteskite/{host}/objects/sha256/{hash_prefix}/{hash}.blob 2176 $res = $this->pcloud_manager->download_file($objectPath, $pcloud_access_token, $temp_file); 2219 $normalized_path = '/' . trim($objectPath, '/'); 2220 2221 // Get file_id from path using stat 2222 $stat_result = $this->pcloud_manager->stat_path($normalized_path, $pcloud_access_token); 2223 if (!$stat_result['success'] || !($stat_result['exists'] ?? false) || !isset($stat_result['metadata']['fileid'])) { 2224 $this->logger->error('Failed to get file_id from pCloud path', [ 2225 'object_path' => $objectPath, 2226 'stat_result' => $stat_result 2227 ]); 2228 return null; 2229 } 2230 2231 $file_id = (string)$stat_result['metadata']['fileid']; 2232 2233 // Download file by ID 2234 $res = $this->pcloud_manager->download_file_by_id($file_id, $pcloud_access_token, $temp_file); 2177 2235 if (!($res['success'] ?? false)) { 2178 2236 $this->logger->error('Failed to download object from PCloud', [ 2179 2237 'object_path' => $objectPath, 2238 'file_id' => $file_id, 2180 2239 'error' => $res['error'] ?? 'Unknown error' 2181 2240 ]); … … 3785 3844 } 3786 3845 3846 // Get user_id and site_host folder for classic backups (same pattern as upload) 3847 $user_id = Utils::get_user_id(); 3848 $site_host = wp_parse_url(get_option('siteurl'), PHP_URL_HOST) ?: 'site'; 3849 $base_folder = sanitize_text_field($request->get_param('dropbox_folder') ?? $request->get_param('target_directory') ?? ''); 3850 3851 // Construct Dropbox path with user_id/site_host folder structure 3852 $dropbox_base_path = !empty($base_folder) 3853 ? '/' . trim($base_folder, '/') . '/' . $user_id . '/' . $site_host 3854 : '/' . $user_id . '/' . $site_host; 3855 $dropbox_path = $dropbox_base_path . '/' . $file_name; 3856 3787 3857 // Try to download the specific file first 3788 $dropbox_path = '/' . $file_name;3789 3858 $download_result = $this->dropbox_manager->download_file($dropbox_path, $access_token, $destination); 3790 3859 … … 3794 3863 $file_name, 3795 3864 $destination, 3796 function($full_zip_name, $full_zip_path) use ($access_token ) {3797 $full_dropbox_path = '/' . $full_zip_name;3865 function($full_zip_name, $full_zip_path) use ($access_token, $dropbox_base_path) { 3866 $full_dropbox_path = $dropbox_base_path . '/' . $full_zip_name; 3798 3867 return $this->dropbox_manager->download_file($full_dropbox_path, $access_token, $full_zip_path); 3799 3868 } … … 3825 3894 $final_destination = $this->restore_dir . '/' . $file_name; 3826 3895 3896 // Get user_id and site_host folder for classic backups (same pattern as upload) 3897 $user_id = Utils::get_user_id(); 3898 $site_host = wp_parse_url(get_option('siteurl'), PHP_URL_HOST) ?: 'site'; 3899 $folder_path = $user_id . '/' . $site_host; 3900 $target_folder_id = $this->google_drive_manager->find_folder_by_path($access_token, $folder_path, $google_drive_folder_id); 3901 3902 // Search for file in the user_id/site_host folder 3827 3903 $file_search_result = $this->google_drive_manager->get_file_id_by_name( 3828 3904 $file_name, 3829 3905 $access_token, 3830 $ google_drive_folder_id3906 $target_folder_id 3831 3907 ); 3832 3908 … … 3836 3912 $file_name, 3837 3913 $final_destination, 3838 function($full_zip_name, $full_zip_path) use ($access_token, $google_drive_folder_id) { 3914 function($full_zip_name, $full_zip_path) use ($access_token, $google_drive_folder_id, $user_id, $site_host) { 3915 // Find user_id/site_host folder for full.zip search 3916 $folder_path = $user_id . '/' . $site_host; 3917 $target_folder_id = $this->google_drive_manager->find_folder_by_path($access_token, $folder_path, $google_drive_folder_id); 3839 3918 $full_file_search = $this->google_drive_manager->get_file_id_by_name( 3840 3919 $full_zip_name, 3841 3920 $access_token, 3842 $ google_drive_folder_id3921 $target_folder_id 3843 3922 ); 3844 3923 … … 3978 4057 $file_name = $url_path ? wp_basename($url_path) : wp_basename($download_url); 3979 4058 4059 // Get user_id and site_host folder for classic backups (same pattern as upload) 4060 $user_id = Utils::get_user_id(); 4061 $site_host = wp_parse_url(get_option('siteurl'), PHP_URL_HOST) ?: 'site'; 4062 $file_path_with_folders = $user_id . '/' . $site_host . '/' . $file_name; 4063 3980 4064 $this->logger->info('Extracting filename for Backblaze B2 restore', [ 3981 4065 'download_url' => $download_url, 3982 4066 'url_path' => $url_path, 3983 4067 'extracted_file_name' => $file_name, 4068 'file_path_with_folders' => $file_path_with_folders, 3984 4069 'bucket_name' => $bucket_name 3985 4070 ]); 3986 4071 3987 // Try to find the specific file first 4072 // Try to find the specific file first in user_id/site_host folder 3988 4073 $file_search_result = $this->backblaze_b2_manager->get_file_by_name( 3989 $file_ name,4074 $file_path_with_folders, 3990 4075 $key_id, 3991 4076 $application_key, … … 3998 4083 $file_name, 3999 4084 $destination, 4000 function($full_zip_name, $full_zip_path) use ($key_id, $application_key, $bucket_name) { 4001 // Search for full.zip 4085 function($full_zip_name, $full_zip_path) use ($key_id, $application_key, $bucket_name, $user_id, $site_host) { 4086 // Search for full.zip in user_id/site_host folder 4087 $full_zip_path_with_folders = $user_id . '/' . $site_host . '/' . $full_zip_name; 4002 4088 $full_zip_search = $this->backblaze_b2_manager->get_file_by_name( 4003 $full_zip_ name,4089 $full_zip_path_with_folders, 4004 4090 $key_id, 4005 4091 $application_key, … … 4011 4097 } 4012 4098 4013 // Download the full.zip 4099 // Download the full.zip (use the path with user_id/site_host for download) 4014 4100 $download_result = $this->backblaze_b2_manager->download_file( 4015 4101 $full_zip_search['file_id'], 4016 $full_zip_ name,4102 $full_zip_path_with_folders, 4017 4103 $key_id, 4018 4104 $application_key, … … 4022 4108 return $download_result; 4023 4109 }, 4024 function($full_zip_name) use ($key_id, $application_key, $bucket_name) { 4110 function($full_zip_name) use ($key_id, $application_key, $bucket_name, $user_id, $site_host) { 4111 // Search for full.zip in user_id/site_host folder 4112 $full_zip_path_with_folders = $user_id . '/' . $site_host . '/' . $full_zip_name; 4025 4113 return $this->backblaze_b2_manager->get_file_by_name( 4026 $full_zip_ name,4114 $full_zip_path_with_folders, 4027 4115 $key_id, 4028 4116 $application_key, … … 4047 4135 } 4048 4136 } else { 4049 // Specific file found, download it normally 4137 // Specific file found, download it normally (use path with user_id/site_host) 4050 4138 $download_result = $this->backblaze_b2_manager->download_file( 4051 4139 $file_search_result['file_id'], 4052 $file_ name,4140 $file_path_with_folders, 4053 4141 $key_id, 4054 4142 $application_key, … … 4079 4167 } 4080 4168 4169 // Extract filename from download_url (handle both URL and path formats) 4081 4170 $file_name = basename($download_url); 4171 // Remove any URL scheme if present (e.g., http://Linked_Sites/file.zip -> file.zip) 4172 if (preg_match('/^https?:\/\//', $file_name)) { 4173 $file_name = basename(parse_url($download_url, PHP_URL_PATH)); 4174 } 4175 4176 // Get user_id and site_host for classic backups (same pattern as upload) 4177 // Path structure: {folderName}/{user_id}/{site_host} 4178 $user_id = Utils::get_user_id(); 4179 $site_host = wp_parse_url(get_option('siteurl'), PHP_URL_HOST) ?: 'site'; 4180 $pcloud_target_path = '/' . trim($pcloud_folder_name, '/') . '/' . $user_id . '/' . $site_host; 4181 4182 $this->logger->debug('pCloud restore path construction', [ 4183 'download_url' => $download_url, 4184 'file_name' => $file_name, 4185 'pcloud_folder_name' => $pcloud_folder_name, 4186 'user_id' => $user_id, 4187 'site_host' => $site_host, 4188 'pcloud_target_path' => $pcloud_target_path 4189 ]); 4082 4190 4083 4191 // Validate token and get endpoint … … 4087 4195 } 4088 4196 4197 // Helper function to get folderid from a path (simplified) 4198 $pcloud_manager = $this->pcloud_manager; 4199 $logger = $this->logger; 4200 $get_folderid_from_path = function($path) use ($pcloud_manager, $provided_token, $token_validation, $logger) { 4201 // Try stat first (fastest) 4202 $stat_result = $pcloud_manager->stat_path($path, $provided_token); 4203 if ($stat_result['success'] && ($stat_result['exists'] ?? false) && isset($stat_result['metadata']['folderid'])) { 4204 return (int)$stat_result['metadata']['folderid']; 4205 } 4206 4207 // Fallback: get folderid by listing parent folder 4208 $parent_path = dirname($path); 4209 $folder_name = basename($path); 4210 4211 if ($parent_path && $parent_path !== '/' && $parent_path !== '.') { 4212 // Get parent folderid 4213 $parent_folderid = null; 4214 $parent_stat = $pcloud_manager->stat_path($parent_path, $provided_token); 4215 if ($parent_stat['success'] && ($parent_stat['exists'] ?? false) && isset($parent_stat['metadata']['folderid'])) { 4216 $parent_folderid = (int)$parent_stat['metadata']['folderid']; 4217 } 4218 4219 // List parent folder to find target folder 4220 $parent_list_params = array('access_token' => $provided_token); 4221 if ($parent_folderid !== null) { 4222 $parent_list_params['folderid'] = (string)$parent_folderid; 4223 } else { 4224 $parent_list_params['path'] = $parent_path; 4225 } 4226 4227 $parent_list_url = $token_validation['endpoint'] . '/listfolder?' . http_build_query($parent_list_params); 4228 $parent_list_response = wp_remote_get($parent_list_url, array('timeout' => 30)); 4229 4230 if (!is_wp_error($parent_list_response) && wp_remote_retrieve_response_code($parent_list_response) === 200) { 4231 $parent_list_data = json_decode(wp_remote_retrieve_body($parent_list_response), true); 4232 $parent_contents = $parent_list_data['metadata']['contents'] ?? $parent_list_data['contents'] ?? []; 4233 4234 foreach ($parent_contents as $item) { 4235 if (isset($item['isfolder']) && $item['isfolder'] && 4236 isset($item['name']) && $item['name'] === $folder_name && 4237 isset($item['folderid'])) { 4238 return (int)$item['folderid']; 4239 } 4240 } 4241 } 4242 } 4243 4244 return null; 4245 }; 4246 4089 4247 // Helper function to list folder and find file 4090 $find_file_in_pcloud = function($search_file_name) use ($token_validation, $provided_token, $pcloud_folder_name) { 4091 $list_url = $token_validation['endpoint'] . '/listfolder?' . http_build_query(array( 4092 'access_token' => $provided_token, 4093 'path' => '/' . trim($pcloud_folder_name, '/') 4094 )); 4248 $find_file_in_pcloud = function($search_file_name) use ($token_validation, $provided_token, $pcloud_target_path, $get_folderid_from_path, $logger) { 4249 $path_segments = array_filter(explode('/', trim($pcloud_target_path, '/'))); 4250 $path_depth = count($path_segments); 4251 4252 // Try to get folderid for nested paths (more reliable) 4253 $target_folderid = null; 4254 if ($path_depth >= 3) { 4255 $target_folderid = $get_folderid_from_path($pcloud_target_path); 4256 if ($target_folderid) { 4257 $logger->debug('Using folderid for pCloud restore listfolder', [ 4258 'path' => $pcloud_target_path, 4259 'folderid' => $target_folderid 4260 ]); 4261 } 4262 } 4263 4264 // Build listfolder query - prefer folderid for nested paths 4265 $list_params = array( 4266 'access_token' => $provided_token 4267 ); 4268 4269 if ($target_folderid !== null && $path_depth >= 3) { 4270 $list_params['folderid'] = (string)$target_folderid; 4271 } else { 4272 $list_params['path'] = $pcloud_target_path; 4273 } 4274 4275 $list_url = $token_validation['endpoint'] . '/listfolder?' . http_build_query($list_params); 4095 4276 4096 4277 $response = wp_remote_get($list_url, array('timeout' => 30)); 4097 if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) { 4098 return ['success' => false, 'error' => 'Failed to list pCloud folder']; 4278 if (is_wp_error($response)) { 4279 return ['success' => false, 'error' => 'Failed to list pCloud folder: ' . $response->get_error_message()]; 4280 } 4281 4282 $response_code = wp_remote_retrieve_response_code($response); 4283 if ($response_code !== 200) { 4284 $error_body = wp_remote_retrieve_body($response); 4285 // If folderid approach failed, try with path as fallback 4286 if ($target_folderid !== null && $path_depth >= 3) { 4287 $logger->debug('listfolder with folderid failed, trying with path', [ 4288 'path' => $pcloud_target_path, 4289 'response_code' => $response_code 4290 ]); 4291 $fallback_url = $token_validation['endpoint'] . '/listfolder?' . http_build_query(array( 4292 'access_token' => $provided_token, 4293 'path' => $pcloud_target_path 4294 )); 4295 $fallback_response = wp_remote_get($fallback_url, array('timeout' => 30)); 4296 if (!is_wp_error($fallback_response) && wp_remote_retrieve_response_code($fallback_response) === 200) { 4297 $response = $fallback_response; 4298 } else { 4299 return ['success' => false, 'error' => 'Failed to list pCloud folder. Status: ' . $response_code . ', Response: ' . $error_body]; 4300 } 4301 } else { 4302 return ['success' => false, 'error' => 'Failed to list pCloud folder. Status: ' . $response_code . ', Response: ' . $error_body]; 4303 } 4099 4304 } 4100 4305 4101 4306 $response_data = json_decode(wp_remote_retrieve_body($response), true); 4102 4307 $files = array(); 4308 4309 // Handle different response structures 4103 4310 if (isset($response_data['metadata']['contents'])) { 4104 4311 $files = $response_data['metadata']['contents']; 4312 } elseif (isset($response_data['contents'])) { 4313 $files = $response_data['contents']; 4314 } 4315 4316 $logger->debug('pCloud listfolder response', [ 4317 'path' => $pcloud_target_path, 4318 'folderid' => $target_folderid, 4319 'files_count' => count($files), 4320 'file_names' => array_map(function($f) { return $f['name'] ?? 'unknown'; }, $files), 4321 'search_file_name' => $search_file_name 4322 ]); 4323 4324 // If no files found and we used path (not folderid), try to get folderid and retry 4325 if (empty($files) && $target_folderid === null && $path_depth >= 3) { 4326 $logger->debug('No files found with path, trying to get folderid and retry', [ 4327 'path' => $pcloud_target_path 4328 ]); 4329 4330 // Try to get folderid using helper function 4331 $target_folderid = $get_folderid_from_path($pcloud_target_path); 4332 4333 if ($target_folderid) { 4334 // Retry listfolder with folderid 4335 $retry_list_url = $token_validation['endpoint'] . '/listfolder?' . http_build_query(array( 4336 'access_token' => $provided_token, 4337 'folderid' => (string)$target_folderid 4338 )); 4339 4340 $retry_response = wp_remote_get($retry_list_url, array('timeout' => 30)); 4341 if (!is_wp_error($retry_response) && wp_remote_retrieve_response_code($retry_response) === 200) { 4342 $response = $retry_response; 4343 $response_data = json_decode(wp_remote_retrieve_body($response), true); 4344 $files = array(); 4345 if (isset($response_data['metadata']['contents'])) { 4346 $files = $response_data['metadata']['contents']; 4347 } elseif (isset($response_data['contents'])) { 4348 $files = $response_data['contents']; 4349 } 4350 4351 $logger->debug('pCloud listfolder retry response (with folderid)', [ 4352 'path' => $pcloud_target_path, 4353 'folderid' => $target_folderid, 4354 'files_count' => count($files) 4355 ]); 4356 } 4357 } 4105 4358 } 4106 4359 … … 4110 4363 if (isset($file['name']) && $file['name'] === $search_file_name) { 4111 4364 $file_id = $file['fileid']; 4365 $logger->debug('Found file in pCloud', [ 4366 'file_name' => $search_file_name, 4367 'file_id' => $file_id 4368 ]); 4112 4369 break; 4113 4370 } … … 4115 4372 4116 4373 if (empty($file_id)) { 4117 return ['success' => false, 'error' => 'File not found: ' . $search_file_name]; 4374 $available_files = array_map(function($f) { 4375 return $f['name'] ?? 'unknown'; 4376 }, $files); 4377 $logger->error('File not found in pCloud folder', [ 4378 'search_file_name' => $search_file_name, 4379 'pcloud_target_path' => $pcloud_target_path, 4380 'folderid' => $target_folderid, 4381 'available_files' => $available_files, 4382 'files_count' => count($files) 4383 ]); 4384 return ['success' => false, 'error' => 'File not found: ' . $search_file_name . ' in path: ' . $pcloud_target_path . '. Available files: ' . implode(', ', $available_files)]; 4118 4385 } 4119 4386 … … 4201 4468 4202 4469 // Schedule the restore process 4203 wp_schedule_single_event(time(), 'siteskite_process_restore', [ 4204 'backup_id' => $backup_id, 4205 'type' => $type, 4206 'download_url' => $download_result['file_path'] 4207 ]); 4470 $this->schedule_cron_event( 4471 'siteskite_process_restore', 4472 [ 4473 'backup_id' => $backup_id, 4474 'type' => $type, 4475 'download_url' => $download_result['file_path'] 4476 ], 4477 0, // Immediate execution 4478 "SiteSkite Process Restore" 4479 ); 4208 4480 4209 4481 return new \WP_REST_Response([ … … 4502 4774 4503 4775 // Schedule cleanup of restore status after 5 minutes 4504 if (function_exists('wp_schedule_single_event')) { 4505 wp_schedule_single_event( 4506 time() + 300, 4507 'siteskite_cleanup_restore_status', 4508 [$restore_id] 4509 ); 4510 } 4776 $this->schedule_cron_event( 4777 'siteskite_cleanup_restore_status', 4778 [$restore_id], 4779 300, // 5 minutes delay 4780 "SiteSkite Cleanup Restore Status" 4781 ); 4511 4782 } catch (\Exception $e) { 4512 4783 $this->logger->error('Failed to handle restore completion', [ -
siteskite/trunk/includes/Schedule/ScheduleManager.php
r3389896 r3418578 9 9 use SiteSkite\Cleanup\CleanupManager; 10 10 use SiteSkite\Progress\ProgressTracker; 11 use SiteSkite\Cron\ExternalCronManager; 11 12 use function get_option; 12 13 use function update_option; … … 45 46 private ProgressTracker $progress_tracker; 46 47 48 /** 49 * @var ExternalCronManager|null External cron manager instance 50 */ 51 private ?ExternalCronManager $external_cron_manager = null; 52 47 53 private const SECONDS_IN_MONTH = 2592000; // 30 days 48 54 private const OPTION_KEY_OLD = 'siteskite_backup_schedules'; … … 58 64 BackupManager $backup_manager, 59 65 CleanupManager $cleanup_manager, 60 ProgressTracker $progress_tracker 66 ProgressTracker $progress_tracker, 67 ?ExternalCronManager $external_cron_manager = null 61 68 ) { 62 69 $this->logger = $logger; … … 64 71 $this->cleanup_manager = $cleanup_manager; 65 72 $this->progress_tracker = $progress_tracker; 73 $this->external_cron_manager = $external_cron_manager; 66 74 $this->register_hooks(); 67 75 } … … 102 110 { 103 111 try { 112 // Clear external cron jobs 113 if ($this->external_cron_manager && $this->external_cron_manager->is_enabled()) { 114 $schedule = $this->get_option_bc(self::SINGLE_SCHEDULE_NEW, self::SINGLE_SCHEDULE_OLD); 115 if (isset($schedule['external_job_id'])) { 116 $this->external_cron_manager->delete_job($schedule['external_job_id']); 117 $this->logger->info('Deleted external cron job', [ 118 'job_id' => $schedule['external_job_id'] 119 ]); 120 } 121 } 122 104 123 $this->clear_all_schedules(); 105 124 $this->clear_all_cron_events(); … … 688 707 wp_clear_scheduled_hook('siteskite_scheduled_backup'); 689 708 690 // Create the new schedule - don't pass backup_id, generate it fresh each run 691 wp_schedule_event( 692 $schedule['next_run'], 693 $schedule['frequency'], 694 'siteskite_scheduled_backup', 695 [$schedule_data['type'], 'automatic'] 696 ); 709 // Try external cron first if enabled 710 if ($this->external_cron_manager && $this->external_cron_manager->is_enabled()) { 711 $job_id = $this->external_cron_manager->create_recurring_job( 712 'siteskite_scheduled_backup', 713 [$schedule_data['type'], 'automatic'], 714 $schedule_data['frequency'], 715 [ 716 'time' => $schedule_data['time'] ?? '00:00', 717 'day' => $schedule_data['day'] ?? '', 718 'date' => $schedule_data['date'] ?? 1 719 ], 720 "SiteSkite: Scheduled Backup ({$schedule_data['type']})" 721 ); 722 723 if ($job_id) { 724 // Store job ID in schedule 725 $schedule['external_job_id'] = $job_id; 726 $this->update_option_bc(self::OPTION_KEY_NEW, self::OPTION_KEY_OLD, [$schedule['id'] => $schedule]); 727 $this->update_option_bc(self::SINGLE_SCHEDULE_NEW, self::SINGLE_SCHEDULE_OLD, $schedule); 728 729 $this->logger->info('Created external cron job for scheduled backup', [ 730 'job_id' => $job_id, 731 'type' => $schedule_data['type'], 732 'frequency' => $schedule_data['frequency'] 733 ]); 734 } else { 735 $this->logger->warning('Failed to create external cron job, falling back to WordPress cron'); 736 // Fall through to WordPress cron 737 } 738 } 739 740 // Fallback to WordPress cron if external cron is disabled or failed 741 if (!$this->external_cron_manager || !$this->external_cron_manager->is_enabled() || !isset($schedule['external_job_id'])) { 742 wp_schedule_event( 743 $schedule['next_run'], 744 $schedule['frequency'], 745 'siteskite_scheduled_backup', 746 [$schedule_data['type'], 'automatic'] 747 ); 748 } 697 749 698 750 $this->logger->info('Created new backup schedule', $schedule); … … 712 764 private function cleanup_existing_crons(): void { 713 765 try { 766 // Clear external cron jobs if enabled 767 if ($this->external_cron_manager && $this->external_cron_manager->is_enabled()) { 768 $schedule = $this->get_option_bc(self::SINGLE_SCHEDULE_NEW, self::SINGLE_SCHEDULE_OLD); 769 if (isset($schedule['external_job_id'])) { 770 $this->external_cron_manager->delete_job($schedule['external_job_id']); 771 $this->logger->debug('Deleted external cron job during cleanup', [ 772 'job_id' => $schedule['external_job_id'] 773 ]); 774 } 775 } 776 714 777 // Clear any recurring schedules using public API 715 778 wp_clear_scheduled_hook('siteskite_scheduled_backup'); -
siteskite/trunk/readme.txt
r3416301 r3418578 5 5 Tested up to: 6.9 6 6 Requires PHP: 7.4 7 Stable tag: 1.0. 67 Stable tag: 1.0.7 8 8 License: GPLv2 or later 9 9 License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 189 189 == Changelog == 190 190 191 = 1.0.7 (13 December 2025) = 192 * Added: Better Backup management 193 * Improved: Backup performance 194 191 195 = 1.0.6 (10 December 2025) = 192 196 * Added: pCloud Auth based Authentication … … 213 217 == Upgrade Notice == 214 218 219 = 1.0.7 (13 December 2025) = 220 * Added: Better Backup management 221 * Improved: Backup performance 222 215 223 = 1.0.6 (10 December 2025) = 216 224 * Added: pCloud Auth based Authentication -
siteskite/trunk/siteskite-link.php
r3416301 r3418578 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.0. 66 * Version: 1.0.7 7 7 * Requires at least: 5.3 8 8 * Requires PHP: 7.4 … … 29 29 30 30 // Plugin version 31 define('SITESKITE_VERSION', '1.0. 6');31 define('SITESKITE_VERSION', '1.0.7'); 32 32 33 33 // Plugin file, path, and URL -
siteskite/trunk/templates/admin-page.php
r3389896 r3418578 82 82 </div> 83 83 <?php endif; ?> 84 85 <!-- External Cron Settings Section --> 86 84 87 </div> 85 88
Note: See TracChangeset
for help on using the changeset viewer.