Plugin Directory

Changeset 3418578


Ignore:
Timestamp:
12/12/2025 11:04:42 PM (3 months ago)
Author:
siteskite
Message:

Update to version 1.0.7

Location:
siteskite/trunk
Files:
4 added
15 edited

Legend:

Unmodified
Added
Removed
  • siteskite/trunk/assets/css/admin.css

    r3390201 r3418578  
    105105    color: #1d2327;
    106106}
     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  
    1313use SiteSkite\Restore\RestoreManager;
    1414use SiteSkite\Schedule\ScheduleManager;
     15use SiteSkite\Cron\CronTriggerHandler;
    1516use SiteSkite\WPCanvas\WPCanvasController;
    1617
     
    4950    private RestoreManager $restore_manager;
    5051    private ScheduleManager $schedule_manager;
     52    private ?CronTriggerHandler $cron_trigger_handler = null;
    5153    private WPCanvasController $wp_canvas_controller;
    5254
     
    6365        Logger $logger,
    6466        RestoreManager $restore_manager,
    65         ScheduleManager $schedule_manager
     67        ScheduleManager $schedule_manager,
     68        ?CronTriggerHandler $cron_trigger_handler = null
    6669    ) {
    6770        $this->auth_manager = $auth_manager;
     
    7376        $this->restore_manager = $restore_manager;
    7477        $this->schedule_manager = $schedule_manager;
     78        $this->cron_trigger_handler = $cron_trigger_handler;
    7579        $this->cleanup_manager = $cleanup_manager;
    7680        $this->wp_canvas_controller = new WPCanvasController($this->logger, $this->auth_manager);
     
    620624        );
    621625
    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        }
    623641
    624642       
  • siteskite/trunk/includes/Backup/BackupManager.php

    r3416301 r3418578  
    1515use SiteSkite\Cloud\BackblazeB2Manager;
    1616use SiteSkite\Cloud\PCloudManager;
     17use SiteSkite\Cron\ExternalCronManager;
    1718use SiteSkite\Backup\Incremental\BackupConfig;
    1819use SiteSkite\Backup\Incremental\Chunker;
     
    3031use SiteSkite\Backup\Incremental\Impl\PCloudObjectStore;
    3132use SiteSkite\Backup\Incremental\Impl\S3ObjectStore;
     33use SiteSkite\Core\Utils;
    3234use function get_option;
    3335use function update_option;
     
    130132    private PCloudManager $pcloud_manager;
    131133
     134    /**
     135     * @var ExternalCronManager|null External cron manager instance
     136     */
     137    private ?ExternalCronManager $external_cron_manager = null;
     138
    132139    // Backup directory is now accessed via SITESKITE_BACKUP_PATH constant
    133140
     
    141148    private const BACKUP_TIMEOUT = 300; // 5 minutes
    142149    private const EXCLUDED_PATHS = [
    143         '.git',
     150       '.git',
    144151        'node_modules',
    145152        'wpcodeboxide',
     153        'vdconnect-*',
     154        'litespeed',
    146155        'backup-migration-*',
    147156        'wp-content/cache',
     
    158167        'wp-content/rb-plugins',
    159168        'wp-content/nginx_cache',
     169        'wp-content/wpvivid_*',
     170        'wp-content/wpvividbackups',
    160171
    161172        //temporary test
    162         //'wp-content/plugins',
    163         //'wp-admin',
    164         //'wp-includes',
     173        'wp-content/plugins',
     174        'wp-admin',
     175        'wp-includes',
    165176       
    166177
     
    186197     * @param AWSManager $aws_manager AWS manager instance
    187198     * @param \SiteSkite\Backup\Incremental\IncrementalStatus $incremental_status Incremental status manager instance
     199     * @param ExternalCronManager|null $external_cron_manager External cron manager instance
    188200     */
    189201    public function __construct(
     
    198210        PCloudManager $pcloud_manager,
    199211        AWSManager $aws_manager,
    200         \SiteSkite\Backup\Incremental\IncrementalStatus $incremental_status
     212        \SiteSkite\Backup\Incremental\IncrementalStatus $incremental_status,
     213        ?ExternalCronManager $external_cron_manager = null
    201214    ) {
    202215        $this->s3_manager = $s3_manager;
     
    211224        $this->aws_manager = $aws_manager;
    212225        $this->incremental_status = $incremental_status;
     226        $this->external_cron_manager = $external_cron_manager;
    213227        $this->init_backup_directory();
    214228
     
    261275            throw new \RuntimeException('Failed to create backup directory');
    262276        }
     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);
    263335    }
    264336
     
    314386
    315387            // Schedule the actual backup process to run immediately after this request
    316             wp_schedule_single_event(
    317                 time(),
     388            $this->schedule_cron_event(
    318389                'process_' . $type . '_backup_cron',
    319390                [
    320391                    'backup_id' => $backup_id,
    321392                    '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"
    323396            );
    324397
     
    777850            // Schedule cleanup
    778851                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");
    780853            }
    781854
     
    899972            // Schedule cleanup
    900973                    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");
    902975                    }
    903976           
     
    9881061                $status_data = $resolved;
    9891062
    990                 // If credentials are stale, refresh via centralized approach
    991                 $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) {
    9931066                    $this->refresh_credentials_for_incremental($backup_id, $provider);
    9941067                    return;
     
    11621235            // Schedule cleanup for classic full backup
    11631236            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");
    11651238            }
    11661239           
     
    12741347            'file_size' => $backup_info['file_size'] ?? 0
    12751348        ]);
     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]);
    12761363
    12771364            // For full backups, retry any pending uploads now that _full.zip is ready
     
    14291516    }
    14301517
     1518
    14311519    /**
    14321520     * Execute provider-specific upload logic
     
    14341522    private function execute_provider_upload(string $backup_id, string $provider, string $file_path, array $status_data): array
    14351523    {
     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       
    14361532        switch ($provider) {
    14371533            case 'aws_s3':
     
    14421538                    throw new \RuntimeException('Missing AWS S3 presigned URL');
    14431539                }
     1540                // For AWS S3, the AWSManager will construct the S3 key with site_host folder structure
    14441541                return $this->aws_manager->upload_file_to_s3_presigned($presigned_url, $file_path);
    14451542               
     
    14481545                    throw new \RuntimeException('Missing Dropbox access_token');
    14491546                }
     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;
    14501553                return $this->dropbox_manager->upload_file(
    14511554                    $file_path,
    14521555                    $status_data['access_token'],
    1453                     $status_data['folder'] ?? $status_data['target_directory'] ?? null
     1556                    $dropbox_target_folder
    14541557                );
    14551558               
     
    14601563                    throw new \RuntimeException('Missing Google Drive access_token');
    14611564                }
     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                }
    14621573                return $this->google_drive_manager->upload_file(
    14631574                    $file_path,
    14641575                    $access_token,
    1465                     $status_data['google_drive_folder_id'] ?? null
     1576                    $target_folder_id
    14661577                );
    14671578               
     
    14701581                    throw new \RuntimeException('Missing Backblaze B2 credentials (keyID/applicationKey/bucketName)');
    14711582                }
     1583                // For Backblaze B2, use the target_path (user_id/site_host/filename) as the file name
    14721584                return $this->backblaze_b2_manager->upload_file(
    14731585                    $file_path,
     
    14751587                    (string)$status_data['applicationKey'],
    14761588                    (string)$status_data['bucketName'],
    1477                     basename($file_path)
     1589                    $target_path
    14781590                );
    14791591               
     
    14861598                    throw new \RuntimeException('Missing pCloud credentials (folderName/access_token)');
    14871599                }
     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;
    14881603                return $this->pcloud_manager->upload_file(
    14891604                    $file_path,
    14901605                    $folder_name,
    14911606                    $access_token,
    1492                     $status_data['remote_path'] ?? null
     1607                    $pcloud_remote_path
    14931608                );
    14941609               
     
    35203635                        try { $coordinator->saveRemoteIndex($remoteIndex, $provider); } catch (\Throwable $t) { /* ignore */ }
    35213636                        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");
    35253638                        return $uploadedBytes; // Halt this pass to avoid spamming 401s
    35263639                    }
     
    40034116                'total_files' => $totalFiles,
    40044117                '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           
    40074126            // 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            }
    40194187        } else {
    40204188            $this->logger->info('Incremental upload completed, no further chunks needed', [
     
    41184286                'status' => $backup_status
    41194287            ]);
     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           
    41204303            // Ensure logs are cleared once more on any stray continue invocations post-completion
    41214304            try { $this->logger->delete_todays_log(); } catch (\Throwable $t) { /* ignore */ }
     
    41484331                ]);
    41494332                // 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");
    41534334                return;
    41544335            }
     
    42724453            }
    42734454           
    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);
    42774459                $this->logger->info('Credentials are stale, refreshing for incremental backup', [
    42784460                    'backup_id' => $backup_id,
    42794461                    '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
    42814465                ]);
    42824466               
     
    43104494                        if (!empty($progress) && !empty($progress['completed'])) {
    43114495                            $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                            }
    43124507                        }
    43134508                    } catch (\Throwable $t) { /* ignore finalize errors here */ }
     
    45694764        // No presign endpoints present; fall back to generic requirement
    45704765        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
    45714887    }
    45724888
     
    46124928               
    46134929                // 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                ]);
    46214935                return;
    46224936            }
     
    50495363            // Cleanup leftover options/transients for this backup (WP-standard APIs)
    50505364            $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           
    50515380            $this->logger->delete_todays_log();
    50525381
  • siteskite/trunk/includes/Backup/Incremental/Impl/BackblazeB2ObjectStore.php

    r3389896 r3418578  
    2525    private ?string $uploadAuthToken = null;
    2626    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
    2731
    2832    public function __construct(Logger $logger, string $keyId, string $applicationKey, string $bucketName, string $algo = 'sha256', string $rootPath = '')
     
    6670    /**
    6771     * 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
    6876     */
    6977    private function ensureAuthenticated(): void
    7078    {
    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) {
    73104            return;
    74105        }
     106       
     107        // Clear cached credentials and upload URL when refreshing
     108        $this->clearCredentials();
    75109
    76110        $this->logger->debug('Authenticating with Backblaze B2', [
     
    102136        $this->accountId = $auth['accountId'] ?? null;
    103137        $this->tokenExpiresAt = time() + 3600; // Cache for 1 hour
     138       
     139        // Reset upload counter when credentials are refreshed
     140        $this->uploadCount = 0;
    104141
    105142        if (!$this->authToken || !$this->apiUrl || !$this->accountId) {
     
    108145
    109146        $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;
    112165    }
    113166
     
    257310
    258311        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
    262325                $this->uploadUrl = null;
    263326                $this->uploadAuthToken = null;
     327               
     328                // Refresh authentication if needed (handles token expiration)
     329                $this->ensureAuthenticated();
     330               
     331                // Get fresh upload URL
    264332                $uploadInfo = $this->getUploadUrl();
    265333                $uploadUrl = $uploadInfo['upload_url'];
    266334                $uploadAuthToken = $uploadInfo['auth_token'];
    267335
     336                // Retry upload with fresh credentials
    268337                $response = \call_user_func('\\wp_remote_post', $uploadUrl, [
    269338                    'headers' => [
     
    286355        $code = (int)\call_user_func('\\wp_remote_retrieve_response_code', $response);
    287356        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
    288402            $body = \call_user_func('\\wp_remote_retrieve_body', $response);
    289403            throw new \RuntimeException('Backblaze B2 upload failed with code ' . $code . ': ' . \call_user_func('\\esc_html', $body));
    290404        }
     405       
     406        // Successful upload - increment counter
     407        $this->uploadCount++;
    291408    }
    292409}
  • siteskite/trunk/includes/Backup/Incremental/Impl/PCloudObjectStore.php

    r3389896 r3418578  
    4444    {
    4545        $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);
    5173
    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        }
    6293    }
    6394}
  • siteskite/trunk/includes/Backup/Incremental/RetryPolicy.php

    r3389896 r3418578  
    2424        if (preg_match('/http\s+5\d\d/', $m)) { return 'retry'; }
    2525
     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
    2643        switch ($provider) {
    2744            case 'dropbox':
    28                 // Invalid/expired token should trigger credentials refresh
    29                 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                 }
    3245                // Dropbox conflict on overwrite: treat as already uploaded
    3346                if (strpos($m, 'http 409') !== false) { return 'mark_present'; }
  • siteskite/trunk/includes/Cloud/AWSManager.php

    r3389896 r3418578  
    66
    77use SiteSkite\Logger\Logger;
     8use SiteSkite\Core\Utils;
    89
    910class AWSManager
     
    364365                // Call the API to get the actual S3 presigned URL
    365366                // 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();
    367369                $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';
    369371                $backup_id = $this->extract_backup_id_from_filename(basename($file_path));
    370372                $file_type = $this->extract_file_type_from_filename(basename($file_path));
    371373               
    372                 // Construct the S3 key path that matches the server's expectation
    373                 $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";
    374376               
    375377                $this->logger->info('Constructed S3 key for classic upload', [
     
    377379                    'backup_id' => $backup_id,
    378380                    'file_type' => $file_type,
    379                     'site_domain' => $site_domain
     381                    'user_id' => $user_id,
     382                    'site_host' => $site_host
    380383                ]);
    381384               
     
    487490    }
    488491
    489     /**
    490      * Get user ID for S3 key construction
    491      */
    492     private function get_user_id(): string
    493     {
    494         // Try to get user ID from various sources
    495         $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 URL
    501         $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 logs
    507     }
    508492
    509493    /**
  • siteskite/trunk/includes/Cloud/GoogleDriveManager.php

    r3397852 r3418578  
    657657        return $parent_id;
    658658    }
     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    }
    659754}
  • siteskite/trunk/includes/Cloud/PCloudManager.php

    r3389896 r3418578  
    204204            // Step 3: Upload the file
    205205            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
    210217                ]);
    211218                $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
    215227                $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);
    218312            } else {
    219313                // Use folder-based upload for regular uploads
     
    675769            // Don't check if exists first - just try creating (idempotent operation)
    676770            $current_path = '';
     771            $segment_index = 0;
    677772            foreach ($segments as $segment) {
     773                $segment_index++;
    678774                $current_path .= '/' . $segment;
    679775               
     
    692788                $is_manifest_final = (count($segments) === 4 && ($folder_name === 'files' || $folder_name === 'database'));
    693789               
    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', [
    709812                            'parent_path' => $parent_path,
    710                             'parent_folderid' => $parent_stat['metadata']['folderid'],
     813                            'parent_folderid' => $parent_folderid,
    711814                            'folder_name' => $folder_name
    712815                        ]);
    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
    719845                        ]);
    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;
    730877                            } else {
    731                                 // Fallback to normal method
    732                                 $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;
    733880                            }
    734881                        } 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
    839884                        }
    840885                    } elseif (count($segments) > 3) {
    841                         usleep(50000); // 50ms for other deep folders
     886                        usleep(20000); // 20ms for other deep folders (reduced from 50ms)
    842887                    }
    843888                } else {
     
    10641109     * @param string $folder_name Folder name to create
    10651110     * @param string $access_token Access token
     1111     * @param int|null $parent_folderid Optional parent folderid (more reliable than path for nested folders)
    10661112     * @return array Result with success status
    10671113     */
    1068     private function create_folder_at_path(string $parent_path, string $folder_name, string $access_token): array
     1114    private function create_folder_at_path(string $parent_path, string $folder_name, string $access_token, ?int $parent_folderid = null): array
    10691115    {
    10701116        try {
     
    10881134                // Root folder - use folderid=0
    10891135                $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                ]);
    10901144            } else {
    10911145                // For deep nested folders (especially hash prefix folders), use folderid for better reliability
    10921146                // Count segments: /siteskite/inc.site/objects/sha256 = 4 segments
    10931147                $depth = substr_count(trim($parent_path, '/'), '/') + 1;
    1094                 if ($depth >= 4) {
    1095                     // Deep nested (4+ segments) - get folderid for reliability
    1096                     // This is critical for hash prefix folders like /.../sha256/af
     1148                if ($depth >= 3) {
     1149                    // Nested (3+ segments) - get folderid for reliability
     1150                    // This is critical for classic backup paths: folderName/user_id/site_host
    10971151                    $parent_stat = $this->stat_path($parent_path, $access_token);
    10981152                    if ($parent_stat['success'] && isset($parent_stat['metadata']['folderid'])) {
    10991153                        $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', [
    11011163                            'parent_path' => $parent_path,
    11021164                            '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
    11111167                        ]);
    11121168                        $query_params['path'] = $parent_path;
     
    11371193            $result_code = isset($decoded['result']) ? (int)$decoded['result'] : -1;
    11381194           
     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           
    11391203            // pCloud API: result 0 = success, result 2005 = folder already exists (also success)
    11401204            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                }
    11471264            }
    11481265           
     
    11501267            // 2005 means folder already exists, which is fine
    11511268            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                ];
    11531281            }
    11541282           
     
    11591287                $stat_check = $this->stat_path($full_path, $access_token);
    11601288                if ($stat_check['success'] && ($stat_check['exists'] ?? false)) {
     1289                    $folderid_from_stat = isset($stat_check['metadata']['folderid']) ? (int)$stat_check['metadata']['folderid'] : null;
    11611290                    // $this->logger->debug('pCloud folder exists despite API error 1001', [
    11621291                    //     'parent_path' => $parent_path,
    11631292                    //     'folder_name' => $folder_name,
    1164                     //     'full_path' => $full_path
     1293                    //     'full_path' => $full_path,
     1294                    //     'folderid' => $folderid_from_stat
    11651295                    // ]);
    1166                     return ['success' => true];
     1296                    return [
     1297                        'success' => true,
     1298                        'folderid' => $folderid_from_stat
     1299                    ];
    11671300                }
    11681301            }
     
    13651498                'nopartial' => '1',
    13661499            ];
    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            }
    13691552
    13701553            $upload_url = $upload_endpoint . '?' . http_build_query($query);
     
    13721555            $this->logger->debug('Starting pCloud upload request');
    13731556
     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)
    13741567            // Read file contents for WordPress HTTP API
    1375             // Note: For very large files, this might consume memory, but it's the WordPress-standard way
    13761568            $file_contents = file_get_contents($file_path);
    13771569            if ($file_contents === false) {
     
    14701662           
    14711663            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', [
    14731665                    '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 missing
     1666                    '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
    14791671                $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
    14901683                    $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'];
    15101686                    }
    15111687                }
    15121688               
    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;
    15181705                }
    15191706               
    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, [
    15251711                    'timeout' => max(300, (int) ceil($file_size / (1024 * 1024)) * 10),
    15261712                    'headers' => [
     
    15351721                    $retry_code = wp_remote_retrieve_response_code($retry_response);
    15361722                    $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_body
    1541                     ]);
    15421723                   
    15431724                    if ($retry_code === 200) {
     
    15511732                            ]);
    15521733                        } else {
    1553                             // Update for error handling below
    15541734                            $response_code = $retry_code;
    15551735                            $response_body = $retry_body;
     
    15571737                        }
    15581738                    } else {
    1559                         // Update response for error handling below
    15601739                        $response_code = $retry_code;
    15611740                        $response_body = $retry_body;
     
    16181797            }
    16191798        }
     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        ];
    16202067    }
    16212068
  • siteskite/trunk/includes/Core/Plugin.php

    r3416301 r3418578  
    2525use SiteSkite\Cloud\PCloudManager;
    2626use SiteSkite\Backup\Incremental\IncrementalStatus;
     27use SiteSkite\Cron\ExternalCronManager;
     28use SiteSkite\Cron\CronTriggerHandler;
    2729
    2830use function add_action;
     
    141143
    142144    /**
     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    /**
    143155     * Get plugin instance - Singleton pattern
    144156     */
     
    172184            $this->cleanup_manager = new CleanupManager($this->logger);
    173185
     186            // Initialize external cron manager
     187            $this->external_cron_manager = new ExternalCronManager($this->logger);
    174188           
    175189            // Initialize cloud storage managers
     
    199213                $this->backblaze_b2_manager,
    200214                $this->pcloud_manager,
    201                 $this->notification_manager
     215                $this->notification_manager,
     216                $this->external_cron_manager
    202217            );
    203218
     
    215230                $this->pcloud_manager,
    216231                $this->aws_manager,
    217                 $incremental_status
     232                $incremental_status,
     233                $this->external_cron_manager
    218234            );
    219235
     
    223239                $this->backup_manager,
    224240                $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
    226252            );
    227253
     
    236262                $this->restore_manager,
    237263                $this->schedule_manager,
    238                 $this->cleanup_manager 
     264                $this->cron_trigger_handler
    239265            );
    240266
     
    289315        ]);
    290316
     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
    291322        // Background cron for continuing incremental uploads until complete
    292323        add_action('siteskite_incremental_continue', [
     
    367398            'default' => ''
    368399        ]);
     400
    369401    }
    370402
     
    422454        }
    423455        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;
    424477    }
    425478
  • siteskite/trunk/includes/Restore/RestoreManager.php

    r3400025 r3418578  
    1919use SiteSkite\Restore\RestorePlanner;
    2020use SiteSkite\Notification\NotificationManager;
     21use SiteSkite\Cron\ExternalCronManager;
     22use SiteSkite\Core\Utils;
    2123
    2224use function get_option;
     
    8587    private NotificationManager $notification_manager;
    8688    /**
     89     * @var ExternalCronManager|null External cron manager instance
     90     */
     91    private ?ExternalCronManager $external_cron_manager = null;
     92    /**
    8793     * Cache provider credentials pulled from stored status/runtime fetches to avoid repeated lookups.
    8894     * @var array<string, array<string, mixed>>
     
    100106        BackblazeB2Manager $backblaze_b2_manager,
    101107        PCloudManager $pcloud_manager,
    102         NotificationManager $notification_manager
     108        NotificationManager $notification_manager,
     109        ?ExternalCronManager $external_cron_manager = null
    103110    ) {
    104111        $this->logger = $logger;
     
    112119        $this->pcloud_manager = $pcloud_manager;
    113120        $this->notification_manager = $notification_manager;
     121        $this->external_cron_manager = $external_cron_manager;
    114122        $this->init_restore_directory();
    115123
     
    143151        update_option($old_key, $value);
    144152    }
     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
    145180
    146181    private function init_restore_directory(): void
     
    189224
    190225            // 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            );
    196236
    197237            return new \WP_REST_Response($restore_info);
     
    548588
    549589            // 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            ]);
    557600
    558601            return new \WP_REST_Response([
     
    21722215                    }
    21732216                   
    2174                     // For PCloud, use the full object path
     2217                    // For PCloud, use the full object path to get file_id, then download
    21752218                    // 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);
    21772235                    if (!($res['success'] ?? false)) {
    21782236                        $this->logger->error('Failed to download object from PCloud', [
    21792237                            'object_path' => $objectPath,
     2238                            'file_id' => $file_id,
    21802239                            'error' => $res['error'] ?? 'Unknown error'
    21812240                        ]);
     
    37853844                    }
    37863845
     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
    37873857                    // Try to download the specific file first
    3788                     $dropbox_path = '/' . $file_name;
    37893858                    $download_result = $this->dropbox_manager->download_file($dropbox_path, $access_token, $destination);
    37903859
     
    37943863                            $file_name,
    37953864                            $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;
    37983867                                return $this->dropbox_manager->download_file($full_dropbox_path, $access_token, $full_zip_path);
    37993868                            }
     
    38253894                    $final_destination = $this->restore_dir . '/' . $file_name;
    38263895
     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
    38273903                    $file_search_result = $this->google_drive_manager->get_file_id_by_name(
    38283904                        $file_name,
    38293905                        $access_token,
    3830                         $google_drive_folder_id
     3906                        $target_folder_id
    38313907                    );
    38323908
     
    38363912                            $file_name,
    38373913                            $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);
    38393918                                $full_file_search = $this->google_drive_manager->get_file_id_by_name(
    38403919                                    $full_zip_name,
    38413920                                    $access_token,
    3842                                     $google_drive_folder_id
     3921                                    $target_folder_id
    38433922                                );
    38443923                               
     
    39784057                    $file_name = $url_path ? wp_basename($url_path) : wp_basename($download_url);
    39794058
     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
    39804064                    $this->logger->info('Extracting filename for Backblaze B2 restore', [
    39814065                        'download_url' => $download_url,
    39824066                        'url_path' => $url_path,
    39834067                        'extracted_file_name' => $file_name,
     4068                        'file_path_with_folders' => $file_path_with_folders,
    39844069                        'bucket_name' => $bucket_name
    39854070                    ]);
    39864071
    3987                     // Try to find the specific file first
     4072                    // Try to find the specific file first in user_id/site_host folder
    39884073                    $file_search_result = $this->backblaze_b2_manager->get_file_by_name(
    3989                         $file_name,
     4074                        $file_path_with_folders,
    39904075                        $key_id,
    39914076                        $application_key,
     
    39984083                            $file_name,
    39994084                            $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;
    40024088                                $full_zip_search = $this->backblaze_b2_manager->get_file_by_name(
    4003                                     $full_zip_name,
     4089                                    $full_zip_path_with_folders,
    40044090                                    $key_id,
    40054091                                    $application_key,
     
    40114097                                }
    40124098                               
    4013                                 // Download the full.zip
     4099                                // Download the full.zip (use the path with user_id/site_host for download)
    40144100                                $download_result = $this->backblaze_b2_manager->download_file(
    40154101                                    $full_zip_search['file_id'],
    4016                                     $full_zip_name,
     4102                                    $full_zip_path_with_folders,
    40174103                                    $key_id,
    40184104                                    $application_key,
     
    40224108                                return $download_result;
    40234109                            },
    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;
    40254113                                return $this->backblaze_b2_manager->get_file_by_name(
    4026                                     $full_zip_name,
     4114                                    $full_zip_path_with_folders,
    40274115                                    $key_id,
    40284116                                    $application_key,
     
    40474135                        }
    40484136                    } else {
    4049                         // Specific file found, download it normally
     4137                        // Specific file found, download it normally (use path with user_id/site_host)
    40504138                        $download_result = $this->backblaze_b2_manager->download_file(
    40514139                            $file_search_result['file_id'],
    4052                             $file_name,
     4140                            $file_path_with_folders,
    40534141                            $key_id,
    40544142                            $application_key,
     
    40794167                    }
    40804168
     4169                    // Extract filename from download_url (handle both URL and path formats)
    40814170                    $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                    ]);
    40824190
    40834191                    // Validate token and get endpoint
     
    40874195                    }
    40884196
     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                   
    40894247                    // 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);
    40954276                       
    40964277                        $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                            }
    40994304                        }
    41004305                       
    41014306                        $response_data = json_decode(wp_remote_retrieve_body($response), true);
    41024307                        $files = array();
     4308                       
     4309                        // Handle different response structures
    41034310                        if (isset($response_data['metadata']['contents'])) {
    41044311                            $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                            }
    41054358                        }
    41064359
     
    41104363                            if (isset($file['name']) && $file['name'] === $search_file_name) {
    41114364                                $file_id = $file['fileid'];
     4365                                $logger->debug('Found file in pCloud', [
     4366                                    'file_name' => $search_file_name,
     4367                                    'file_id' => $file_id
     4368                                ]);
    41124369                                break;
    41134370                            }
     
    41154372
    41164373                        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)];
    41184385                        }
    41194386
     
    42014468
    42024469             // 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            );
    42084480
    42094481            return new \WP_REST_Response([
     
    45024774
    45034775            // 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            );
    45114782        } catch (\Exception $e) {
    45124783            $this->logger->error('Failed to handle restore completion', [
  • siteskite/trunk/includes/Schedule/ScheduleManager.php

    r3389896 r3418578  
    99use SiteSkite\Cleanup\CleanupManager;
    1010use SiteSkite\Progress\ProgressTracker;
     11use SiteSkite\Cron\ExternalCronManager;
    1112use function get_option;
    1213use function update_option;
     
    4546    private ProgressTracker $progress_tracker;
    4647
     48    /**
     49     * @var ExternalCronManager|null External cron manager instance
     50     */
     51    private ?ExternalCronManager $external_cron_manager = null;
     52
    4753    private const SECONDS_IN_MONTH = 2592000; // 30 days
    4854    private const OPTION_KEY_OLD = 'siteskite_backup_schedules';
     
    5864        BackupManager $backup_manager,
    5965        CleanupManager $cleanup_manager,
    60         ProgressTracker $progress_tracker
     66        ProgressTracker $progress_tracker,
     67        ?ExternalCronManager $external_cron_manager = null
    6168    ) {
    6269        $this->logger = $logger;
     
    6471        $this->cleanup_manager = $cleanup_manager;
    6572        $this->progress_tracker = $progress_tracker;
     73        $this->external_cron_manager = $external_cron_manager;
    6674        $this->register_hooks();
    6775    }
     
    102110    {
    103111        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
    104123            $this->clear_all_schedules();
    105124            $this->clear_all_cron_events();
     
    688707            wp_clear_scheduled_hook('siteskite_scheduled_backup');
    689708           
    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            }
    697749   
    698750            $this->logger->info('Created new backup schedule', $schedule);
     
    712764    private function cleanup_existing_crons(): void {
    713765        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
    714777            // Clear any recurring schedules using public API
    715778            wp_clear_scheduled_hook('siteskite_scheduled_backup');
  • siteskite/trunk/readme.txt

    r3416301 r3418578  
    55Tested up to: 6.9
    66Requires PHP: 7.4
    7 Stable tag: 1.0.6
     7Stable tag: 1.0.7
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
     
    189189== Changelog ==
    190190
     191= 1.0.7 (13 December 2025) = 
     192* Added: Better Backup management
     193* Improved: Backup performance
     194
    191195= 1.0.6 (10 December 2025) = 
    192196* Added: pCloud Auth based Authentication
     
    213217== Upgrade Notice ==
    214218
     219= 1.0.7 (13 December 2025) = 
     220* Added: Better Backup management
     221* Improved: Backup performance
     222
    215223= 1.0.6 (10 December 2025) = 
    216224* Added: pCloud Auth based Authentication
  • siteskite/trunk/siteskite-link.php

    r3416301 r3418578  
    44 * Plugin URI: https://siteskite.com
    55 * Description: Link your WordPress site with SiteSkite for effortless updates, backups, monitoring, and maintenance—everything in one place.
    6  * Version: 1.0.6
     6 * Version: 1.0.7
    77 * Requires at least: 5.3
    88 * Requires PHP: 7.4
     
    2929
    3030// Plugin version
    31 define('SITESKITE_VERSION', '1.0.6');
     31define('SITESKITE_VERSION', '1.0.7');
    3232
    3333// Plugin file, path, and URL
  • siteskite/trunk/templates/admin-page.php

    r3389896 r3418578  
    8282    </div>
    8383    <?php endif; ?>
     84
     85    <!-- External Cron Settings Section -->
     86 
    8487</div>
    8588
Note: See TracChangeset for help on using the changeset viewer.