client = $client; $this->store = $store; $this->consent = $consent; } /** * Registers cron callbacks. */ public function register(): void { add_action( self::DAILY_HOOK, array( $this, 'run_daily' ) ); add_action( self::BATCH_HOOK, array( $this, 'run_batch' ) ); } /** * Consent given: remember it and schedule the daily check. * * The first check is started by the caller (see run_now()). * * @param int $user_id User who agreed. */ public function enable( int $user_id ): void { $this->consent->grant( $user_id, $this->now() ); $this->maybe_schedule(); } /** * Consent withdrawn: remove scheduled events and all stored results. */ public function disable(): void { $this->consent->revoke(); self::deactivate(); $this->store->clear(); } /** * Keeps the schedule in line with the consent, e.g. after the plugin was reactivated. */ public function maybe_schedule(): void { if ( ! $this->consent->granted() ) { if ( wp_next_scheduled( self::DAILY_HOOK ) || wp_next_scheduled( self::BATCH_HOOK ) ) { self::deactivate(); } return; } if ( ! wp_next_scheduled( self::DAILY_HOOK ) ) { wp_schedule_event( $this->now() + DAY_IN_SECONDS, 'daily', self::DAILY_HOOK ); } } /** * Deactivation: remove scheduled events. */ public static function deactivate(): void { wp_clear_scheduled_hook( self::DAILY_HOOK ); wp_clear_scheduled_hook( self::BATCH_HOOK ); } /** * Daily cron callback. */ public function run_daily(): void { if ( ! $this->consent->granted() ) { self::deactivate(); return; } $this->start(); $this->run_batch(); } /** * Batch cron callback: processes one batch and schedules the next one. */ public function run_batch(): void { if ( $this->process_batch() > 0 ) { $this->schedule_next_batch(); } } /** * Runs a full check immediately, within a time budget. * * Whatever is left after the budget is handed over to WP-Cron. * * @param int $budget Seconds. * @return int Number of plugins still waiting. */ public function run_now( int $budget = 20 ): int { if ( ! $this->consent->granted() ) { return 0; } $deadline = microtime( true ) + $budget; $this->start(); do { $remaining = $this->process_batch(); } while ( $remaining > 0 && microtime( true ) < $deadline ); if ( $remaining > 0 ) { $this->schedule_next_batch(); } return $remaining; } /** * Starts a new scan, replacing an unfinished one. */ public function start(): void { if ( ! $this->consent->granted() ) { return; } $queue = array_keys( $this->installed_plugins() ); $this->store->save_scan( array( 'started' => $this->now(), 'latest_wp' => WpVersions::latest_known( (string) get_bloginfo( 'version' ), get_site_transient( 'update_core' ) ), 'total' => count( $queue ), 'queue' => $queue, 'plugins' => array(), ) ); } /** * Processes the next batch of the running scan. * * @return int Number of plugins still waiting. */ public function process_batch(): int { $scan = $this->store->get_scan(); if ( null === $scan || ! $this->consent->granted() ) { return 0; } $installed = $this->installed_plugins(); $update_plugins = get_site_transient( 'update_plugins' ); $previous = $this->store->get_results(); $batch = array_splice( $scan['queue'], 0, self::BATCH_SIZE ); $unreachable = ''; foreach ( $batch as $plugin_file ) { if ( isset( $installed[ $plugin_file ] ) ) { $scan['plugins'][ $plugin_file ] = $this->check_plugin( $plugin_file, $installed[ $plugin_file ], $scan, $update_plugins, $previous, $unreachable ); } } if ( '' !== $unreachable ) { // Keep previous results for everything left in the queue and finish. foreach ( $scan['queue'] as $plugin_file ) { if ( isset( $installed[ $plugin_file ] ) ) { $scan['plugins'][ $plugin_file ] = $this->check_plugin( $plugin_file, $installed[ $plugin_file ], $scan, $update_plugins, $previous, $unreachable ); } } $scan['queue'] = array(); $this->store->save_error( $unreachable, $this->now() ); } if ( empty( $scan['queue'] ) ) { $this->finish( $scan, '' === $unreachable ); return 0; } $this->store->save_scan( $scan ); return count( $scan['queue'] ); } /** * Checks a single plugin. * * @param string $plugin_file Plugin basename. * @param array $plugin_data Plugin headers. * @param array $scan Scan state. * @param object|false|null $update_plugins Value of the update_plugins site transient. * @param array|null $previous Previous results. * @param string $unreachable Set to the error message once WordPress.org is unreachable. * @return array */ private function check_plugin( string $plugin_file, array $plugin_data, array $scan, $update_plugins, ?array $previous, string &$unreachable ): array { $slug = SlugResolver::resolve( $plugin_file, $plugin_data, $update_plugins ); if ( null === $slug ) { return array( 'status' => Status::UNKNOWN, 'slug' => '', ); } if ( '' !== $unreachable ) { return $this->fallback( $plugin_file, $slug, $previous ); } $info = $this->client->fetch( $slug ); if ( is_wp_error( $info ) ) { if ( 'http_request_failed' === $info->get_error_code() ) { // WordPress.org is not reachable; don't wait for more timeouts. $unreachable = $info->get_error_message(); } return $this->fallback( $plugin_file, $slug, $previous ); } return array( 'slug' => $slug ) + Evaluator::evaluate( $info, $this->now(), (string) $scan['latest_wp'] ); } /** * Result to use when WordPress.org could not be asked. * * @param string $plugin_file Plugin basename. * @param string $slug Slug. * @param array|null $previous Previous results. * @return array */ private function fallback( string $plugin_file, string $slug, ?array $previous ): array { if ( isset( $previous['plugins'][ $plugin_file ]['status'] ) && Status::ERROR !== $previous['plugins'][ $plugin_file ]['status'] ) { return array( 'stale' => true ) + $previous['plugins'][ $plugin_file ]; } return array( 'status' => Status::ERROR, 'slug' => $slug, ); } /** * Stores the finished scan. * * @param array $scan Scan state. * @param bool $successful Whether WordPress.org was reachable. */ private function finish( array $scan, bool $successful ): void { $this->store->save_results( array( 'checked_at' => $this->now(), 'latest_wp' => (string) $scan['latest_wp'], 'plugins' => $scan['plugins'], ) ); $this->store->clear_scan(); if ( $successful ) { $this->store->clear_error(); } } /** * Schedules the next batch unless one is already queued. */ private function schedule_next_batch(): void { if ( ! wp_next_scheduled( self::BATCH_HOOK ) ) { wp_schedule_single_event( $this->now() + self::BATCH_DELAY, self::BATCH_HOOK ); } } /** * Installed plugins keyed by basename. * * @return array> */ protected function installed_plugins(): array { if ( ! function_exists( 'get_plugins' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } return get_plugins(); } /** * Current time, overridable in tests. * * @return int */ protected function now(): int { return time(); } }