diff --git a/inc/admin-pages/class-external-cron-admin-page.php b/inc/admin-pages/class-external-cron-admin-page.php new file mode 100644 index 00000000..90a29a4b --- /dev/null +++ b/inc/admin-pages/class-external-cron-admin-page.php @@ -0,0 +1,417 @@ + 'manage_network', + ]; + + /** + * Service URL for the External Cron Service. + * + * @var string + */ + const SERVICE_URL = 'https://ultimatemultisite.com'; + + /** + * Product slug for the External Cron Service subscription. + * + * @var string + */ + const PRODUCT_SLUG = 'external-cron-service'; + + /** + * Allow child classes to add further initializations. + * + * @since 2.3.0 + * @return void + */ + public function init(): void { + + add_action('wp_ajax_wu_external_cron_connect', [$this, 'ajax_connect']); + add_action('wp_ajax_wu_external_cron_disconnect', [$this, 'ajax_disconnect']); + add_action('wp_ajax_wu_external_cron_sync', [$this, 'ajax_sync']); + add_action('wp_ajax_wu_external_cron_toggle', [$this, 'ajax_toggle']); + } + + /** + * Allow child classes to register scripts and styles. + * + * @since 2.3.0 + * @return void + */ + public function register_scripts(): void { + + wp_enqueue_script('wu-admin'); + } + + /** + * Returns the title of the page. + * + * @since 2.3.0 + * @return string Title of the page. + */ + public function get_title() { + + return __('External Cron Service', 'ultimate-multisite'); + } + + /** + * Returns the title of menu for this page. + * + * @since 2.3.0 + * @return string Menu label of the page. + */ + public function get_menu_title() { + + return __('External Cron', 'ultimate-multisite'); + } + + /** + * Every child class should implement the output method to display the contents of the page. + * + * @since 2.3.0 + * @return void + */ + public function output(): void { + + wu_get_template( + 'external-cron/dashboard', + [ + 'page' => $this, + 'is_connected' => $this->is_connected(), + 'is_enabled' => $this->is_enabled(), + 'site_id' => wu_get_setting('external_cron_site_id', ''), + 'granularity' => wu_get_setting('external_cron_granularity', 'network'), + 'last_sync' => get_site_option('wu_external_cron_last_sync', 0), + 'schedule_count' => $this->get_schedule_count(), + 'recent_logs' => $this->get_recent_logs(), + 'service_status' => $this->get_service_status(), + 'subscription_url' => $this->get_subscription_url(), + 'nonce' => wp_create_nonce('wu_external_cron_nonce'), + ] + ); + } + + /** + * Check if connected to the External Cron Service. + * + * @since 2.3.0 + * @return bool + */ + public function is_connected(): bool { + + $site_id = wu_get_setting('external_cron_site_id', ''); + + return ! empty($site_id); + } + + /** + * Check if External Cron Service is enabled. + * + * @since 2.3.0 + * @return bool + */ + public function is_enabled(): bool { + + return (bool) wu_get_setting('external_cron_enabled', false); + } + + /** + * Get count of scheduled jobs. + * + * @since 2.3.0 + * @return int + */ + public function get_schedule_count(): int { + + $manager = \WP_Ultimo\External_Cron\External_Cron_Manager::get_instance(); + + if (method_exists($manager, 'get_reporter')) { + $reporter = $manager->get_reporter(); + if ($reporter && method_exists($reporter, 'get_schedule_count')) { + return $reporter->get_schedule_count(); + } + } + + return 0; + } + + /** + * Get recent execution logs from the service. + * + * @since 2.3.0 + * @return array + */ + public function get_recent_logs(): array { + + $manager = \WP_Ultimo\External_Cron\External_Cron_Manager::get_instance(); + $client = $manager->get_client(); + + if ( ! $client || ! $this->is_connected()) { + return []; + } + + $logs = $client->get_logs(20); + + if (is_wp_error($logs)) { + return []; + } + + return $logs; + } + + /** + * Get the service status. + * + * @since 2.3.0 + * @return array + */ + public function get_service_status(): array { + + if ( ! $this->is_connected()) { + return [ + 'status' => 'disconnected', + 'label' => __('Not Connected', 'ultimate-multisite'), + 'color' => 'red', + ]; + } + + if ( ! $this->is_enabled()) { + return [ + 'status' => 'disabled', + 'label' => __('Connected but Disabled', 'ultimate-multisite'), + 'color' => 'yellow', + ]; + } + + return [ + 'status' => 'active', + 'label' => __('Active', 'ultimate-multisite'), + 'color' => 'green', + ]; + } + + /** + * Get the subscription purchase URL. + * + * @since 2.3.0 + * @return string + */ + public function get_subscription_url(): string { + + return self::SERVICE_URL . '/addons/' . self::PRODUCT_SLUG . '/'; + } + + /** + * Get the OAuth connect URL. + * + * @since 2.3.0 + * @return string + */ + public function get_connect_url(): string { + + $return_url = add_query_arg( + [ + 'page' => $this->id, + 'action' => 'connect_callback', + ], + network_admin_url('admin.php') + ); + + return add_query_arg( + [ + 'action' => 'external_cron_connect', + 'site_url' => rawurlencode(network_site_url()), + 'return_url' => rawurlencode($return_url), + ], + self::SERVICE_URL . '/wp-json/cron-service/v1/oauth/authorize' + ); + } + + /** + * AJAX handler for connecting to the service. + * + * @since 2.3.0 + * @return void + */ + public function ajax_connect(): void { + + check_ajax_referer('wu_external_cron_nonce', 'nonce'); + + if ( ! current_user_can('manage_network')) { + wp_send_json_error(['message' => __('Permission denied.', 'ultimate-multisite')]); + } + + $manager = \WP_Ultimo\External_Cron\External_Cron_Manager::get_instance(); + $registration = $manager->get_registration(); + + $result = $registration->register_network(); + + if (is_wp_error($result)) { + wp_send_json_error(['message' => $result->get_error_message()]); + } + + // Schedule sync immediately. + if (function_exists('wu_enqueue_async_action')) { + wu_enqueue_async_action('wu_external_cron_sync_schedules'); + } + + wp_send_json_success( + [ + 'message' => __('Network connected successfully!', 'ultimate-multisite'), + 'site_id' => $result['site_id'], + ] + ); + } + + /** + * AJAX handler for disconnecting from the service. + * + * @since 2.3.0 + * @return void + */ + public function ajax_disconnect(): void { + + check_ajax_referer('wu_external_cron_nonce', 'nonce'); + + if ( ! current_user_can('manage_network')) { + wp_send_json_error(['message' => __('Permission denied.', 'ultimate-multisite')]); + } + + $manager = \WP_Ultimo\External_Cron\External_Cron_Manager::get_instance(); + $registration = $manager->get_registration(); + + $result = $registration->unregister(); + + if (is_wp_error($result)) { + wp_send_json_error(['message' => $result->get_error_message()]); + } + + wp_send_json_success( + [ + 'message' => __('Network disconnected successfully.', 'ultimate-multisite'), + ] + ); + } + + /** + * AJAX handler for syncing schedules. + * + * @since 2.3.0 + * @return void + */ + public function ajax_sync(): void { + + check_ajax_referer('wu_external_cron_nonce', 'nonce'); + + if ( ! current_user_can('manage_network')) { + wp_send_json_error(['message' => __('Permission denied.', 'ultimate-multisite')]); + } + + $manager = \WP_Ultimo\External_Cron\External_Cron_Manager::get_instance(); + $manager->sync_schedules(); + + update_site_option('wu_external_cron_last_sync', time()); + + wp_send_json_success( + [ + 'message' => __('Schedules synced successfully!', 'ultimate-multisite'), + ] + ); + } + + /** + * AJAX handler for toggling the service. + * + * @since 2.3.0 + * @return void + */ + public function ajax_toggle(): void { + + check_ajax_referer('wu_external_cron_nonce', 'nonce'); + + if ( ! current_user_can('manage_network')) { + wp_send_json_error(['message' => __('Permission denied.', 'ultimate-multisite')]); + } + + $enabled = (bool) wu_request('enabled', false); + + wu_save_setting('external_cron_enabled', $enabled); + + $message = $enabled + ? __('External Cron Service enabled.', 'ultimate-multisite') + : __('External Cron Service disabled.', 'ultimate-multisite'); + + wp_send_json_success( + [ + 'message' => $message, + 'enabled' => $enabled, + ] + ); + } +} diff --git a/inc/class-wp-ultimo.php b/inc/class-wp-ultimo.php index 0057eff1..11f8df29 100644 --- a/inc/class-wp-ultimo.php +++ b/inc/class-wp-ultimo.php @@ -933,6 +933,11 @@ protected function load_managers(): void { * Loads views overrides */ WP_Ultimo\Views::get_instance(); + + /* + * Loads the External Cron manager. + */ + WP_Ultimo\External_Cron\External_Cron_Manager::get_instance(); } /** diff --git a/inc/external-cron/class-external-cron-manager.php b/inc/external-cron/class-external-cron-manager.php new file mode 100644 index 00000000..257fd4d1 --- /dev/null +++ b/inc/external-cron/class-external-cron-manager.php @@ -0,0 +1,287 @@ +client = new External_Cron_Service_Client(); + $this->reporter = new External_Cron_Schedule_Reporter($this->client); + $this->registration = new External_Cron_Registration($this->client); + + $this->init_hooks(); + } + + /** + * Initialize hooks. + * + * @since 2.3.0 + */ + private function init_hooks(): void { + + // Register the admin page. + add_action('wu_admin_pages_init', [$this, 'register_admin_page']); + + // Maybe disable WordPress cron. + add_action('init', [$this, 'maybe_disable_wp_cron'], 1); + + // Schedule sync action. + add_action('wu_external_cron_sync_schedules', [$this, 'sync_schedules']); + $this->schedule_sync(); + + // Heartbeat. + add_action('wu_external_cron_heartbeat', [$this, 'send_heartbeat']); + $this->schedule_heartbeat(); + + // WP-CLI commands. + if (defined('WP_CLI') && WP_CLI) { + $this->register_cli_commands(); + } + } + + /** + * Register the admin page. + * + * @since 2.3.0 + * @return void + */ + public function register_admin_page(): void { + + new \WP_Ultimo\Admin_Pages\External_Cron_Admin_Page(); + } + + /** + * Maybe disable WordPress cron if service is active. + * + * @since 2.3.0 + */ + public function maybe_disable_wp_cron(): void { + + if ( ! $this->is_service_active()) { + return; + } + + if ( ! defined('DISABLE_WP_CRON')) { + define('DISABLE_WP_CRON', true); + } + } + + /** + * Check if external cron service is active. + * + * @since 2.3.0 + * @return bool + */ + public function is_service_active(): bool { + + return (bool) wu_get_setting('external_cron_enabled', false); + } + + /** + * Check if site is registered with the service. + * + * @since 2.3.0 + * @return bool + */ + public function is_registered(): bool { + + $site_id = wu_get_setting('external_cron_site_id'); + + return ! empty($site_id); + } + + /** + * Schedule the sync action. + * + * @since 2.3.0 + */ + private function schedule_sync(): void { + + if ( ! $this->is_service_active()) { + return; + } + + if ( ! wu_next_scheduled_action('wu_external_cron_sync_schedules')) { + wu_schedule_recurring_action(time() + HOUR_IN_SECONDS, HOUR_IN_SECONDS, 'wu_external_cron_sync_schedules'); + } + } + + /** + * Schedule the heartbeat action. + * + * @since 2.3.0 + */ + private function schedule_heartbeat(): void { + + if ( ! $this->is_service_active()) { + return; + } + + if ( ! wu_next_scheduled_action('wu_external_cron_heartbeat')) { + wu_schedule_recurring_action(time() + 300, 300, 'wu_external_cron_heartbeat'); // Every 5 minutes. + } + } + + /** + * Sync schedules with the service. + * + * @since 2.3.0 + */ + public function sync_schedules(): void { + + if ( ! $this->is_service_active()) { + return; + } + + $this->reporter->report_all_schedules(); + + update_site_option('wu_external_cron_last_sync', time()); + } + + /** + * Send heartbeat to the service. + * + * @since 2.3.0 + */ + public function send_heartbeat(): void { + + if ( ! $this->is_service_active()) { + return; + } + + $this->client->heartbeat(); + } + + /** + * Register WP-CLI commands. + * + * @since 2.3.0 + */ + private function register_cli_commands(): void { + + \WP_CLI::add_command( + 'wu external-cron', + function ($args, $_assoc_args) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed + $subcommand = $args[0] ?? 'status'; + + switch ($subcommand) { + case 'register': + $result = $this->registration->register_network(); + if (is_wp_error($result)) { + \WP_CLI::error($result->get_error_message()); + } + \WP_CLI::success('Network registered with site ID: ' . $result['site_id']); + break; + + case 'unregister': + $result = $this->registration->unregister(); + if (is_wp_error($result)) { + \WP_CLI::error($result->get_error_message()); + } + \WP_CLI::success('Network unregistered from external cron service.'); + break; + + case 'sync': + $this->sync_schedules(); + \WP_CLI::success('Schedules synced successfully.'); + break; + + case 'status': + default: + $enabled = $this->is_service_active(); + $registered = $this->is_registered(); + $site_id = wu_get_setting('external_cron_site_id', 'N/A'); + $granularity = wu_get_setting('external_cron_granularity', 'network'); + + \WP_CLI::log('External Cron Service Status:'); + \WP_CLI::log(' Enabled: ' . ($enabled ? 'Yes' : 'No')); + \WP_CLI::log(' Registered: ' . ($registered ? 'Yes' : 'No')); + \WP_CLI::log(' Site ID: ' . $site_id); + \WP_CLI::log(' Granularity: ' . $granularity); + break; + } + } + ); + } + + /** + * Get the service client. + * + * @since 2.3.0 + * @return External_Cron_Service_Client + */ + public function get_client(): External_Cron_Service_Client { + + return $this->client; + } + + /** + * Get the registration handler. + * + * @since 2.3.0 + * @return External_Cron_Registration + */ + public function get_registration(): External_Cron_Registration { + + return $this->registration; + } + + /** + * Get the schedule reporter. + * + * @since 2.3.0 + * @return External_Cron_Schedule_Reporter + */ + public function get_reporter(): External_Cron_Schedule_Reporter { + + return $this->reporter; + } +} diff --git a/inc/external-cron/class-external-cron-registration.php b/inc/external-cron/class-external-cron-registration.php new file mode 100644 index 00000000..7e78fd08 --- /dev/null +++ b/inc/external-cron/class-external-cron-registration.php @@ -0,0 +1,213 @@ +client = $client; + } + + /** + * Register the network with the service. + * + * @since 2.3.0 + * @return array|\WP_Error + */ + public function register_network() { + + $granularity = wu_get_setting('external_cron_granularity', 'network'); + + $data = [ + 'site_url' => network_site_url(), + 'site_hash' => $this->client->get_site_hash(), + 'is_network_registration' => true, + 'granularity' => $granularity, + 'timezone' => wp_timezone_string(), + 'cron_url' => site_url('wp-cron.php'), + ]; + + $result = $this->client->register_site($data); + + if (is_wp_error($result)) { + return $result; + } + + // Save credentials. + wu_save_setting('external_cron_site_id', $result['site_id']); + wu_save_setting('external_cron_api_key', $result['api_key']); + wu_save_setting('external_cron_api_secret', $result['api_secret']); + wu_save_setting('external_cron_enabled', true); + + // Update client with new credentials. + $this->client->set_credentials($result['api_key'], $result['api_secret']); + + /** + * Fires after network is registered with external cron service. + * + * @since 2.3.0 + * @param array $result Registration result. + */ + do_action('wu_external_cron_network_registered', $result); + + return $result; + } + + /** + * Register a specific subsite with the service. + * + * @since 2.3.0 + * @param int $blog_id Blog ID. + * @return array|\WP_Error + */ + public function register_subsite(int $blog_id) { + + switch_to_blog($blog_id); + + $data = [ + 'site_url' => site_url(), + 'site_hash' => $this->generate_site_hash($blog_id), + 'is_network_registration' => false, + 'network_id' => wu_get_setting('external_cron_site_id'), + 'timezone' => wp_timezone_string(), + 'cron_url' => site_url('wp-cron.php'), + ]; + + restore_current_blog(); + + $result = $this->client->register_site($data); + + if (is_wp_error($result)) { + return $result; + } + + // Save site ID for this blog. + update_blog_option($blog_id, 'external_cron_site_id', $result['site_id']); + + /** + * Fires after subsite is registered with external cron service. + * + * @since 2.3.0 + * @param int $blog_id Blog ID. + * @param array $result Registration result. + */ + do_action('wu_external_cron_subsite_registered', $blog_id, $result); + + return $result; + } + + /** + * Register all subsites in the network. + * + * @since 2.3.0 + * @return array Results for each site. + */ + public function register_all_subsites(): array { + + $sites = get_sites( + [ + 'number' => 0, + 'fields' => 'ids', + 'archived' => 0, + 'deleted' => 0, + ] + ); + $results = []; + + foreach ($sites as $blog_id) { + // Skip if already registered. + $existing = get_blog_option($blog_id, 'external_cron_site_id'); + if (! empty($existing)) { + $results[ $blog_id ] = ['skipped' => true]; + continue; + } + + $results[ $blog_id ] = $this->register_subsite($blog_id); + } + + return $results; + } + + /** + * Unregister from the service. + * + * @since 2.3.0 + * @return array|\WP_Error + */ + public function unregister() { + + $result = $this->client->unregister_site(); + + if (is_wp_error($result)) { + return $result; + } + + // Clear settings. + wu_save_setting('external_cron_site_id', null); + wu_save_setting('external_cron_api_key', null); + wu_save_setting('external_cron_api_secret', null); + wu_save_setting('external_cron_enabled', false); + + // Unschedule actions. + wu_unschedule_all_actions('wu_external_cron_sync_schedules'); + wu_unschedule_all_actions('wu_external_cron_heartbeat'); + + /** + * Fires after network is unregistered from external cron service. + * + * @since 2.3.0 + */ + do_action('wu_external_cron_network_unregistered'); + + return $result; + } + + /** + * Generate a unique site hash for a specific blog. + * + * @since 2.3.0 + * @param int|null $blog_id Blog ID. + * @return string + */ + private function generate_site_hash(?int $blog_id = null): string { + + if ($blog_id) { + $url = get_blog_option($blog_id, 'siteurl'); + } else { + $url = network_site_url(); + } + + return hash('sha256', $url . AUTH_KEY); + } +} diff --git a/inc/external-cron/class-external-cron-schedule-reporter.php b/inc/external-cron/class-external-cron-schedule-reporter.php new file mode 100644 index 00000000..a827f5f4 --- /dev/null +++ b/inc/external-cron/class-external-cron-schedule-reporter.php @@ -0,0 +1,226 @@ +client = $client; + } + + /** + * Report all schedules to the service. + * + * @since 2.3.0 + */ + public function report_all_schedules(): void { + + $granularity = wu_get_setting('external_cron_granularity', 'network'); + + if ('network' === $granularity) { + $this->report_network_schedules(); + } else { + $this->report_per_site_schedules(); + } + } + + /** + * Report schedules for the entire network (single trigger). + * + * @since 2.3.0 + */ + private function report_network_schedules(): void { + + $site_id = wu_get_setting('external_cron_site_id'); + + if (empty($site_id)) { + return; + } + + // Get schedules from main site. + $schedules = $this->get_site_schedules(get_main_site_id()); + + // Include Action Scheduler jobs. + $as_schedules = $this->get_action_scheduler_jobs(); + $schedules = array_merge($schedules, $as_schedules); + + // Send to service. + $this->client->update_schedules((int) $site_id, $schedules); + } + + /** + * Report schedules for each subsite individually. + * + * @since 2.3.0 + */ + private function report_per_site_schedules(): void { + + $sites = get_sites( + [ + 'number' => 0, + 'fields' => 'ids', + 'archived' => 0, + 'deleted' => 0, + ] + ); + + foreach ($sites as $blog_id) { + $this->report_site_schedules($blog_id); + } + } + + /** + * Report schedules for a specific site. + * + * @since 2.3.0 + * @param int $blog_id Blog ID. + */ + private function report_site_schedules(int $blog_id): void { + + // Get the registered site ID for this blog. + $site_id = get_blog_option($blog_id, 'external_cron_site_id'); + + if (empty($site_id)) { + return; + } + + $schedules = $this->get_site_schedules($blog_id); + + // Send to service. + $this->client->update_schedules((int) $site_id, $schedules); + } + + /** + * Get cron schedules for a specific site. + * + * @since 2.3.0 + * @param int $blog_id Blog ID. + * @return array + */ + private function get_site_schedules(int $blog_id): array { + + switch_to_blog($blog_id); + + $crons = _get_cron_array(); + $schedules = []; + + if (empty($crons)) { + restore_current_blog(); + return $schedules; + } + + $wp_schedules = wp_get_schedules(); + + foreach ($crons as $timestamp => $cron_hooks) { + foreach ($cron_hooks as $hook => $events) { + foreach ($events as $key => $event) { + $recurrence = $event['schedule'] ?? null; + $interval = null; + + if ($recurrence && isset($wp_schedules[ $recurrence ])) { + $interval = $wp_schedules[ $recurrence ]['interval']; + } + + $schedules[] = [ + 'hook_name' => $hook, + 'next_run' => gmdate('Y-m-d H:i:s', $timestamp), + 'recurrence' => $recurrence, + 'interval_seconds' => $interval, + 'args' => wp_json_encode($event['args'] ?? []), + ]; + } + } + } + + restore_current_blog(); + + return $schedules; + } + + /** + * Get Action Scheduler pending jobs. + * + * @since 2.3.0 + * @return array + */ + private function get_action_scheduler_jobs(): array { + + if (! function_exists('wu_get_scheduled_actions')) { + return []; + } + + $actions = wu_get_scheduled_actions( + [ + 'status' => \ActionScheduler_Store::STATUS_PENDING, + 'per_page' => 500, + ] + ); + + $schedules = []; + + foreach ($actions as $action) { + $schedule = $action->get_schedule(); + + if (! $schedule || ! $schedule->get_date()) { + continue; + } + + $is_recurring = method_exists($schedule, 'get_recurrence') && $schedule->get_recurrence(); + + $schedules[] = [ + 'hook_name' => $action->get_hook(), + 'next_run' => $schedule->get_date()->format('Y-m-d H:i:s'), + 'recurrence' => $is_recurring ? 'recurring' : null, + 'interval_seconds' => $is_recurring ? $schedule->get_recurrence() : null, + 'args' => wp_json_encode($action->get_args()), + ]; + } + + return $schedules; + } + + /** + * Get count of scheduled jobs. + * + * @since 2.3.0 + * @return int + */ + public function get_schedule_count(): int { + + $schedules = $this->get_site_schedules(get_main_site_id()); + $as_schedules = $this->get_action_scheduler_jobs(); + + return count($schedules) + count($as_schedules); + } +} diff --git a/inc/external-cron/class-external-cron-service-client.php b/inc/external-cron/class-external-cron-service-client.php new file mode 100644 index 00000000..fa1408d4 --- /dev/null +++ b/inc/external-cron/class-external-cron-service-client.php @@ -0,0 +1,284 @@ +api_key = wu_get_setting('external_cron_api_key'); + $this->api_secret = wu_get_setting('external_cron_api_secret'); + } + + /** + * Check if client is authenticated. + * + * @since 2.3.0 + * @return bool + */ + public function is_authenticated(): bool { + + return ! empty($this->api_key) && ! empty($this->api_secret); + } + + /** + * Get the API base URL. + * + * @since 2.3.0 + * @return string + */ + public function get_api_base(): string { + + return apply_filters('wu_external_cron_api_base', self::API_BASE); + } + + /** + * Register a site with the service. + * + * @since 2.3.0 + * @param array $data Registration data. + * @return array|\WP_Error + */ + public function register_site(array $data) { + + return $this->request_with_oauth('POST', '/register', $data); + } + + /** + * Unregister a site from the service. + * + * @since 2.3.0 + * @return array|\WP_Error + */ + public function unregister_site() { + + return $this->request('POST', '/unregister'); + } + + /** + * Update site schedules. + * + * @since 2.3.0 + * @param int $site_id Site ID on the service. + * @param array $schedules Array of schedules. + * @return array|\WP_Error + */ + public function update_schedules(int $site_id, array $schedules) { + + return $this->request('POST', "/sites/{$site_id}/schedules", $schedules); + } + + /** + * Get site execution logs. + * + * @since 2.3.0 + * @param int $site_id Site ID on the service. + * @param int $limit Number of logs to retrieve. + * @param int $offset Offset for pagination. + * @return array|\WP_Error + */ + public function get_logs(int $site_id, int $limit = 50, int $offset = 0) { + + return $this->request( + 'GET', + "/sites/{$site_id}/logs", + [ + 'limit' => $limit, + 'offset' => $offset, + ] + ); + } + + /** + * Send heartbeat to the service. + * + * @since 2.3.0 + * @return array|\WP_Error + */ + public function heartbeat() { + + return $this->request( + 'POST', + '/heartbeat', + [ + 'site_hash' => $this->get_site_hash(), + 'timestamp' => time(), + ] + ); + } + + /** + * Make an authenticated API request. + * + * @since 2.3.0 + * @param string $method HTTP method. + * @param string $endpoint API endpoint. + * @param array $data Request data. + * @return array|\WP_Error + */ + public function request(string $method, string $endpoint, array $data = []) { + + if (! $this->is_authenticated()) { + return new \WP_Error('not_authenticated', __('API credentials not configured.', 'ultimate-multisite')); + } + + $url = $this->get_api_base() . $endpoint; + + $args = [ + 'method' => $method, + 'timeout' => 30, + 'headers' => [ + 'Content-Type' => 'application/json', + 'Authorization' => 'Basic ' . base64_encode($this->api_key . ':' . $this->api_secret), // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Used for HTTP Basic Auth + ], + ]; + + if ( ! empty($data)) { + if ('GET' === $method) { + $url = add_query_arg($data, $url); + } else { + $args['body'] = wp_json_encode($data); + } + } + + $response = wp_remote_request($url, $args); + + return $this->handle_response($response); + } + + /** + * Make an OAuth-authenticated request (for initial registration). + * + * @since 2.3.0 + * @param string $method HTTP method. + * @param string $endpoint API endpoint. + * @param array $data Request data. + * @return array|\WP_Error + */ + public function request_with_oauth(string $method, string $endpoint, array $data = []) { + + // Get OAuth token from addon repository (shares the same OAuth flow). + $addon_repo = \WP_Ultimo::get_instance()->get_addon_repository(); + $access_token = $addon_repo->get_access_token(); + + if (empty($access_token)) { + return new \WP_Error('no_oauth_token', __('Please connect your site first via the Addons page.', 'ultimate-multisite')); + } + + $url = $this->get_api_base() . $endpoint; + + $args = [ + 'method' => $method, + 'timeout' => 30, + 'headers' => [ + 'Content-Type' => 'application/json', + 'Authorization' => 'Bearer ' . $access_token, + ], + ]; + + if (! empty($data)) { + $args['body'] = wp_json_encode($data); + } + + $response = wp_remote_request($url, $args); + + return $this->handle_response($response); + } + + /** + * Handle API response. + * + * @since 2.3.0 + * @param array|\WP_Error $response Response from wp_remote_request. + * @return array|\WP_Error + */ + private function handle_response($response) { + + if (is_wp_error($response)) { + return $response; + } + + $body = wp_remote_retrieve_body($response); + $code = wp_remote_retrieve_response_code($response); + + $decoded = json_decode($body, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + return new \WP_Error('invalid_json', __('Invalid response from server.', 'ultimate-multisite')); + } + + if ($code >= 400) { + $message = $decoded['message'] ?? __('Unknown error.', 'ultimate-multisite'); + return new \WP_Error('api_error', $message, ['status' => $code]); + } + + return $decoded; + } + + /** + * Generate a unique site hash. + * + * @since 2.3.0 + * @return string + */ + public function get_site_hash(): string { + + return hash('sha256', network_site_url() . AUTH_KEY); + } + + /** + * Set API credentials. + * + * @since 2.3.0 + * @param string $api_key API key. + * @param string $api_secret API secret. + */ + public function set_credentials(string $api_key, string $api_secret): void { + + $this->api_key = $api_key; + $this->api_secret = $api_secret; + } +} diff --git a/views/external-cron/dashboard.php b/views/external-cron/dashboard.php new file mode 100644 index 00000000..6045bee9 --- /dev/null +++ b/views/external-cron/dashboard.php @@ -0,0 +1,293 @@ + + +
+
+
+
+ + +
++ + +
+ 0) : ?> ++ + +
+ ++ +
+ ++ Purchase a subscription to get started.', 'ultimate-multisite'), + [ + 'a' => [ + 'href' => [], + 'target' => [], + 'class' => [], + ], + ] + ), + esc_url($subscription_url) + ); + ?> +
+ + ++ +
+| + | + | + | + |
|---|---|---|---|
|
+ + + + | ++ | + |
+ +
+