* * @phpstan-import-type PresizedDivSlot from PresizedDiv */ final class Cron { /** * Transient key tracking the ads.txt follow-up polling attempt count. */ const ADSTXT_FOLLOWUP_ATTEMPT_TRANSIENT = 'actirise_adstxt_followup_attempt'; /** * Delay before the next ads.txt follow-up poll, in seconds. */ const ADSTXT_FOLLOWUP_DELAY = 10; /** * Maximum number of ads.txt follow-up polls before giving up. */ const ADSTXT_FOLLOWUP_MAX_ATTEMPTS = 10; /** * Lifetime of the ads.txt follow-up attempt transient, in seconds. */ const ADSTXT_FOLLOWUP_TRANSIENT_TTL = 1800; /** * Schedule cron jobs * * @since 2.0.0 * @return void */ public function schedule() { if ( ACTIRISE_CRON === 'true' ) { add_filter( 'cron_schedules', function ( $schedules ) { $schedules['five_minutes'] = array( 'interval' => 300, 'display' => esc_html__( 'Every Five Minutes', 'actirise' ), ); return $schedules; } ); if ( ! wp_next_scheduled( 'actirise_cron_update_adstxt' ) ) { wp_schedule_event( time(), 'hourly', 'actirise_cron_update_adstxt' ); $this->update_adstxt(); } if ( ! wp_next_scheduled( 'actirise_cron_update_presized_div' ) ) { wp_schedule_event( time(), 'hourly', 'actirise_cron_update_presized_div' ); $this->check_presized_div(); } if ( ! wp_next_scheduled( 'actirise_cron_update_fast_cmp' ) ) { wp_schedule_event( time(), 'hourly', 'actirise_cron_update_fast_cmp' ); $this->get_fast_cmp(); } if ( ! wp_next_scheduled( 'actirise_cron_check_adstxt' ) ) { wp_schedule_event( time(), 'hourly', 'actirise_cron_check_adstxt' ); $this->check_adstxt(); } if ( ! wp_next_scheduled( 'actirise_cron_check_slots' ) ) { wp_schedule_event( time(), 'hourly', 'actirise_cron_check_slots' ); $this->check_slots(); } if ( ! wp_next_scheduled( 'actirise_cron_event_annotation' ) ) { wp_schedule_event( time(), 'five_minutes', 'actirise_cron_event_annotation' ); $this->upload_event_annotation(); } if ( Options::get( 'contract-type' ) === 'essential' && ! wp_next_scheduled( 'actirise_cron_check_optimization' ) ) { wp_schedule_event( time(), 'five_minutes', 'actirise_cron_check_optimization' ); } // Never run inline, unlike the polls above. schedule() executes while // the plugin file is still being included, long before WordPress has // created $wp_rewrite, and rest_url() dereferences that global on any // site using pretty permalinks — a fatal that takes the whole request // down, wp-admin/upgrade.php included. An event due at time() is // picked up by the cron spawn of this very request, once WordPress is // fully loaded, exactly as queue_rest_selftest() relies on. if ( ! wp_next_scheduled( 'actirise_cron_rest_selftest' ) ) { wp_schedule_event( time(), 'hourly', 'actirise_cron_rest_selftest' ); } // Never run inline either: migrations write files and call the API, // and schedule() runs while the plugin file is still being included. if ( ! wp_next_scheduled( 'actirise_cron_migrations' ) ) { wp_schedule_event( time(), 'hourly', 'actirise_cron_migrations' ); } } } /** * Run the migrations an update left pending. * * Migrations run on `admin_init`, so an install that auto-updated and whose * dashboard nobody opens would otherwise never play them: a whole release's * one-off work, the FastCMP first publication among it, silently skipped. * This hourly fallback plays them without an admin visit. It is cron-only * on purpose: an install with WP-Cron disabled keeps waiting for its * dashboard, rather than running migrations inside a visitor's request. * * Costs two option reads when nothing is pending: the gate and the lock * shared with the admin path live in {@see Migrations::migrate()}. * * @since 4.3.2 * @return void */ public function run_migrations() { $migrations = new Migrations( 'actirise', ACTIRISE_VERSION ); $migrations->migrate(); } /** * Queue the one-off FastCMP first publication a moment from now. * * Deferred rather than run inline for the same reason as the self-test: the * callers are an update or a sign-up request, and a slow API must not hang * them. The two-minute delay leaves the requests that follow a sign-up time * to store the credentials the publication needs. * * @since 4.3.2 * @return void */ public function queue_fastcmp_initial_sync() { if ( ! wp_next_scheduled( 'actirise_cron_fastcmp_initial_sync' ) ) { wp_schedule_single_event( time() + 120, 'actirise_cron_fastcmp_initial_sync' ); } } /** * Run the plugin's REST API self-test. * * Probes the loopback ping route, persists the reachability verdict, and * reports `rest_api_enabled` / `rest_url` to the Actirise API. Fired daily * via `actirise_cron_rest_selftest` and once on activation. * * @since 4.3.0 * @return void */ public function rest_selftest() { $self_test = new RestSelfTest(); $self_test->run(); } /** * Queue the REST API self-test to run on the next cron spawn. * * The self-test has nothing to report until the site is known to the API * (bearer token + website uuid), which only happens once the publisher has * signed up or logged in — long after the activation run. Left to the daily * schedule alone, the API would stay unaware the plugin can receive pushes * for up to a day and keep the site on the polling fallback. Callers signal * the prerequisites just became available; the run is deferred to a one-off * event so the loopback probe (up to RestSelfTest::PROBE_TIMEOUT seconds on * a host that blocks it) never blocks the request that triggered it. * WordPress drops a single event queued on the same hook within ten * minutes, so repeated calls still yield one run. * * @since 4.3.0 * @return void */ public function queue_rest_selftest() { if ( ACTIRISE_CRON !== 'true' ) { // No cron on this install: nothing would ever pick the event up. $this->rest_selftest(); return; } wp_schedule_single_event( time(), 'actirise_cron_rest_selftest' ); } /** * Update ads.txt file * * @since 2.0.0 * @return void */ public function update_adstxt() { if ( ! Options::get( 'settings-uuid' ) ) { return; } $ads_txt = AdsTxt::get_from_api(); if ( ! is_string( $ads_txt ) ) { Logger::add_log( 'adstxt get_from_api is false', 'include/cron', 'error' ); return; } // check_adstxt() polls the live ads.txt monitoring endpoint; it reads // neither the adstxt-actirise option nor the freshly written file, so it // keeps its long-standing position ahead of the file rewrite performed by // AdsTxt::apply_pushed_content(). Ordering it ahead of the option write is // behaviour-preserving and keeps the cron path identical after extraction. $this->check_adstxt(); AdsTxt::apply_pushed_content( $ads_txt ); } /** * Check lines in ads.txt file * * @since 3.0.0 * @return bool|array */ public function check_adstxt() { if ( ! Helpers::is_user_authenticated() ) { return false; } // Hosted mode: ads.txt is served by Actirise, no monitoring API call needed. if ( Options::get( 'hosted-adstxt-enabled', false ) === true ) { $this->check_hosted_adstxt_redirect(); return false; } $adstxt_status = AdsTxt::check_from_api( 'true' ); if ( is_array( $adstxt_status ) && isset( $adstxt_status['state'] ) && $adstxt_status['state'] === 'pending' ) { // With push active the API delivers the final verdict directly // (contract §5.2), so the plugin skips its sub-hour "pending -> // result" follow-up poll; the hourly cron stays as the fallback. // When push is off the follow-up loop is unchanged. if ( ! self::is_push_active() ) { $this->schedule_adstxt_followup( 1 ); } return $adstxt_status; } self::finalize_adstxt_check( $adstxt_status ); return $adstxt_status; } /** * Follow-up cron event for ads.txt monitoring polling. * * Fired once the initial refresh request returned `pending`. Polls the * adstxt monitoring endpoint without the refresh flag and either * reschedules itself (until ADSTXT_FOLLOWUP_MAX_ATTEMPTS is reached) or * finalizes the check when a definitive state is returned. * * @since 4.2.0 * @return void */ public function check_adstxt_followup() { if ( ! Helpers::is_user_authenticated() ) { delete_transient( self::ADSTXT_FOLLOWUP_ATTEMPT_TRANSIENT ); return; } // Push may have become active mid-chain; the API now pushes the final // verdict directly (contract §5.2), so abort the redundant follow-up poll. if ( self::is_push_active() ) { delete_transient( self::ADSTXT_FOLLOWUP_ATTEMPT_TRANSIENT ); return; } // Hosted mode may have been enabled mid-chain; abort the API polling. if ( Options::get( 'hosted-adstxt-enabled', false ) === true ) { delete_transient( self::ADSTXT_FOLLOWUP_ATTEMPT_TRANSIENT ); return; } /** @var mixed $stored_attempt */ $stored_attempt = get_transient( self::ADSTXT_FOLLOWUP_ATTEMPT_TRANSIENT ); $attempt = is_numeric( $stored_attempt ) ? (int) $stored_attempt : 0; $adstxt_status = AdsTxt::check_from_api( 'false' ); $still_pending = is_array( $adstxt_status ) && isset( $adstxt_status['state'] ) && $adstxt_status['state'] === 'pending'; if ( $still_pending && $attempt < self::ADSTXT_FOLLOWUP_MAX_ATTEMPTS ) { $this->schedule_adstxt_followup( $attempt + 1 ); return; } delete_transient( self::ADSTXT_FOLLOWUP_ATTEMPT_TRANSIENT ); if ( $still_pending ) { Logger::add_log( 'check_adstxt_followup max attempts reached, state still pending', 'include/cron', 'error' ); return; } self::finalize_adstxt_check( $adstxt_status ); } /** * Schedule a single follow-up event to re-poll the ads.txt monitoring * endpoint without the refresh flag. * * @since 4.2.0 * @param int $attempt The 1-based attempt counter to persist for the next run. * @return void */ private function schedule_adstxt_followup( $attempt ) { set_transient( self::ADSTXT_FOLLOWUP_ATTEMPT_TRANSIENT, $attempt, self::ADSTXT_FOLLOWUP_TRANSIENT_TTL ); wp_schedule_single_event( time() + self::ADSTXT_FOLLOWUP_DELAY, 'actirise_cron_check_adstxt_followup' ); } /** * Persist the ads.txt validation verdict's `adstxt-update` flag. * * Single source of the health-badge flag shared by the polling fallback * (`check_adstxt()` / `check_adstxt_followup()`) and the push handler * (`Actirise\Includes\Push\Handler\AdstxtMonitoringPushHandler`), so both * paths derive the flag identically from the final `state`: `ok` clears it, * any other state raises it. Static and side-effect-only on the option store. * * @since 4.2.0 * @param bool|array $adstxt_status Final verdict (polling response or push payload). * @return void */ public static function finalize_adstxt_check( $adstxt_status ) { if ( is_array( $adstxt_status ) && isset( $adstxt_status['state'] ) ) { if ( $adstxt_status['state'] === 'ok' ) { Options::update( 'adstxt-update', false ); } else { Options::update( 'adstxt-update', true ); } } } /** * Whether the plugin's push (REST) channel is active. * * Two conditions, both required. The self-test must have positively * confirmed the plugin's REST API is reachable (`rest-api-enabled` === * true), and the capability must have reached the API * (`rest-api-reported` === true): a reachable endpoint the API was never * told about receives nothing, so treating it as active would switch the * polling fallback off while no channel is live at all — which on a * freshly registered site would also stop the very page-load GET that * stores the website uuid the report needs. Any other value on either * option — never tested, unreachable, or reported without success — keeps * push inactive so the polling fallback stays in charge. * * This is the single reader of the self-test state, shared by the ads.txt * follow-up poll suppression above and the admin page-load GET gating in * {@see \ActiriseAdmin\Includes\Core::maybe_check_website_status()}. * * @since 4.3.0 * @return bool */ public static function is_push_active() { return Options::get( RestSelfTest::OPTION_ENABLED, false ) === true && Options::get( RestSelfTest::OPTION_REPORTED, false ) === true; } /** * Check hosted ads.txt redirect * * @since 3.3.5 * @return void */ public function check_hosted_adstxt_redirect() { $is_hosted = Options::get( 'hosted-adstxt-enabled', false ) === true; if ( ! $is_hosted ) { // Not in hosted mode, nothing to check Options::delete( 'hosted-adstxt-redirect-error' ); return; } $uuid = Options::get( 'website-uuid' ); if ( ! $uuid ) { return; } $site_ads_txt_url = get_site_url() . '/ads.txt'; $flashbid_url = 'https://gateway.actirise.com/services/adstxt/' . $uuid; $response = wp_remote_get( $site_ads_txt_url, array( 'timeout' => 10, 'redirection' => 0, // Don't follow redirects 'sslverify' => true, ) ); if ( is_wp_error( $response ) ) { // Network error, mark as error Options::update( 'hosted-adstxt-redirect-error', array( 'error_type' => 'manual_required', 'flashbid_url' => $flashbid_url, ) ); return; } $status_code = wp_remote_retrieve_response_code( $response ); if ( $status_code === 302 || $status_code === 301 ) { $redirect_location = wp_remote_retrieve_header( $response, 'location' ); // Ignore WordPress trailing slash redirects (ads.txt -> ads.txt/) $is_trailing_slash_redirect = ( $redirect_location === trailingslashit( $site_ads_txt_url ) ); if ( $redirect_location === $flashbid_url ) { // Redirect is correct (don't check destination status), clear error Options::delete( 'hosted-adstxt-redirect-error' ); } elseif ( $is_trailing_slash_redirect ) { // WordPress trailing slash redirect - not an error, clear any existing error Options::delete( 'hosted-adstxt-redirect-error' ); } else { // Wrong redirect URL Options::update( 'hosted-adstxt-redirect-error', array( 'error_type' => 'wrong_url', 'flashbid_url' => $flashbid_url, ) ); } } elseif ( $status_code === 200 ) { // File is being served directly instead of redirected // Check if it's a physical file or generated by another plugin/system $path_ads_txt = ABSPATH . 'ads.txt'; $wp_fs = Helpers::get_wp_fs(); $file_exists = ! is_wp_error( $wp_fs ) && $wp_fs->exists( $path_ads_txt ); if ( $file_exists ) { // Physical file exists Options::update( 'hosted-adstxt-redirect-error', array( 'error_type' => 'file_exists', 'flashbid_url' => $flashbid_url, ) ); } else { // File is generated by third-party plugin/system Options::update( 'hosted-adstxt-redirect-error', array( 'error_type' => 'third_party', 'flashbid_url' => $flashbid_url, ) ); } } else { // Other error Options::update( 'hosted-adstxt-redirect-error', array( 'error_type' => 'manual_required', 'flashbid_url' => $flashbid_url, ) ); } } /** * Check for presized div * * @since 2.0.0 * @return void */ public function check_presized_div() { if ( ! Helpers::is_user_authenticated() ) { return; } if ( Options::get( 'settings-uuid' ) ) { /** @var array | bool $presized_div */ $presized_div = PresizedDiv::get_from_api(); if ( $presized_div !== false ) { /** @var array $presized_div */ self::merge_presized_div_config( $presized_div ); } else { Options::update( 'presizeddiv-actirise', array() ); } } } /** * Merge a full presized-div configuration into local storage, preserving * publisher slot preferences. * * Single source of the presized-div apply, shared by the hourly polling * fallback ({@see check_presized_div()}) and the push handler * (`Actirise\Includes\Push\Handler\PresizedDivPushHandler`), so both paths * write identical state. The incoming array fully replaces the Actirise-side * config, but the publisher's per-slot `active` toggles in * `presizeddiv-selected` are never overwritten: a slot unknown to the local * selection is added as inactive and queued into `presizeddiv-notif` for the * admin notice, and a slot absent from the incoming config is dropped. Writes * `presizeddiv-selected`, `presizeddiv-actirise`, `presizeddiv-notif` and * `presizeddiv-lastupdate`. Static and side-effect-only on the option store. * * @since 4.3.0 * @param array $incoming The full presized-div configuration (polling response or push payload). * @return void */ public static function merge_presized_div_config( $incoming ) { /** @var array $incoming */ /** @var mixed $presized_div_active */ $presized_div_active = Options::get( 'presizeddiv-selected', array() ); /** @var mixed $presized_div_notif */ $presized_div_notif = Options::get( 'presizeddiv-notif', array() ); if ( ! is_array( $presized_div_active ) ) { $presized_div_active = array(); } /** @var array $presized_div_active */ if ( ! is_array( $presized_div_notif ) ) { $presized_div_notif = array(); } foreach ( $incoming as $presized_div_item ) { /** @var string $slot_name */ $slot_name = $presized_div_item['slotName']; if ( ! in_array( $slot_name, array_column( $presized_div_active, 'slotName' ), true ) ) { $presized_div_active[] = array( 'slotName' => $slot_name, 'active' => false, ); /** @var array<\stdclass> $presized_div_notif */ $presized_div_notif[] = $slot_name; } } /** @var array{slotName: string, active: bool} $presized_div_active_item */ foreach ( $presized_div_active as $key => $presized_div_active_item ) { $slot_name = $presized_div_active_item['slotName']; if ( ! in_array( $slot_name, array_column( $incoming, 'slotName' ), true ) ) { unset( $presized_div_active[ $key ] ); } } Options::update( 'presizeddiv-selected', $presized_div_active ); Options::update( 'presizeddiv-actirise', $incoming ); Options::update( 'presizeddiv-notif', $presized_div_notif ); Options::update( 'presizeddiv-lastupdate', time() ); } /** * Get FastCmp stub light * * @since 2.3.0 * @return void */ public function get_fast_cmp() { $this->get_fastcmp_stublight(); $this->get_fastcmp_vendors(); if ( Options::get( 'fastcmp-uuid', '' ) === '' ) { $this->get_fastcmp_uuid(); } // Converge on Actirise when the push channel cannot reach us. Independent // of the domain uid resolved above: that one identifies the domain to // FastCMP for the tag, while the sync addresses its resource by the // Actirise website uuid. // // A pending first publication takes this run's place: it reads before it // writes, on the same resource, so polling as well would spend a second // request to learn what it has just been told. $sync = new ConfigSync(); if ( ! $sync->run_seed() && ConfigSync::should_poll() ) { $sync->pull(); } Options::update( 'fastcmp-lastupdate', time() ); } /** * Perform the one-off FastCMP first publication, if one is still owed. * * A three-line adapter for `actirise_cron_fastcmp_initial_sync`: the decision * table lives with the version and the transport, in ConfigSync. * * @since 4.3.0 * @return void */ public function fastcmp_initial_sync() { $sync = new ConfigSync(); $sync->run_seed(); } /** * Set auto update * * @since 2.4.0 * @param bool $enabled * @return void */ public function set_auto_update( $enabled ) { if ( ACTIRISE_CRON !== 'true' ) { return; } if ( $enabled === Helpers::has_auto_update() ) { return; } /** @var string $plugin_file */ $plugin_file = plugin_basename( ACTIRISE_FILE ); /** @var array $auto_updates */ $auto_updates = get_site_option( 'auto_update_plugins', array() ); if ( $enabled ) { $plugins = array_unique( array_merge( $auto_updates, array( plugin_basename( ACTIRISE_FILE ) ) ) ); } else { $plugins = array_values( array_diff( $auto_updates, array( plugin_basename( ACTIRISE_FILE ) ) ) ); } update_site_option( 'auto_update_plugins', $plugins ); } /** * Check if the slots XPath are present and valid in website pages * * @since 3.2.0 * @return void */ public function check_slots() { if ( ! Helpers::is_user_authenticated() ) { return; } if ( Options::get( 'contract-type' ) !== 'essential' ) { return; } $ad_format = new AdFormat(); $ad_format->validate_xpath_and_notify(); } /** * Uploads event annotations via cron task. * * This method is called by the cron system to trigger the event * annotation upload process. It delegates the actual work to the * EventAnnotation class. * * @return void * * @since 3.2.0 */ public function upload_event_annotation() { EventAnnotation::send_events(); } /** * Get FastCmp stub light * * @since 2.2.0 * @return void */ private function get_fastcmp_stublight() { $stub_light_url = 'https://static.fastcmp.com/fast-cmp-stub-light.js'; $response = wp_remote_request( $stub_light_url, array( 'method' => 'GET', 'timeout' => 10, 'sslverify' => true, ) ); if ( is_wp_error( $response ) ) { Logger::add_log( 'get_fastcmp_stublight error ' . $response->get_error_code(), 'include/cron', 'error' ); return; } /** @var string|boolean $stub_light */ $stub_light = wp_remote_retrieve_body( $response ); if ( $stub_light === false ) { return; } Options::update( 'fastcmp-stub-light', $stub_light ); } /** * Get FastCmp vendors * * @since 2.2.0 * @return void */ private function get_fastcmp_vendors() { $vendors_api_url = 'https://eu.fastcmp.com/wp/vendor-list'; $response = wp_remote_request( $vendors_api_url, array( 'method' => 'GET', 'timeout' => 10, 'sslverify' => true, ) ); if ( is_wp_error( $response ) ) { return; } /** @var string|boolean $vendors */ $vendors = wp_remote_retrieve_body( $response ); if ( $vendors === false ) { return; } /** @var string $vendors */ $vendors_json = json_decode( $vendors, true ); if ( $vendors_json === null ) { return; } Options::update( 'fastcmp-vendors', $vendors_json ); } /** * Get FastCmp uuid * * @since 2.2.0 * @return void */ private function get_fastcmp_uuid() { $fast_cmp_url = 'https://eu.fastcmp.com/wp/domain-uid?domain='; $domain = rawurlencode( Helpers::get_server_details()['host'] ); $domain = str_replace( 'www.', '', $domain ); $response = wp_remote_request( $fast_cmp_url . $domain, array( 'method' => 'GET', 'timeout' => 10, 'sslverify' => true, ) ); if ( is_wp_error( $response ) ) { return; } /** @var string|boolean $uuid */ $uuid = wp_remote_retrieve_body( $response ); if ( $uuid === false ) { return; } /** @var string $uuid */ $uuid_json = json_decode( $uuid, true ); /** @var null|array{domain?: string, domainUid?: string, error?: string} $uuid_json */ if ( $uuid_json === null ) { return; } /** @var array{domain?: string, domainUid?: string, error?: string} $uuid_json */ if ( isset( $uuid_json['error'] ) || ! isset( $uuid_json['domainUid'] ) ) { return; } Options::update( 'fastcmp-uuid', $uuid_json['domainUid'] ); } /** * Check optimization checklist and cache health issues. * * @since 4.1.0 * @return void */ public function check_optimization() { $health_check = new HealthCheck(); $issues = $health_check->get_issues(); Options::update( 'health-check-cache', $issues ); } /** * Checks if a scheduled task needs to be executed based on transient expiration. * * This method uses WordPress transients to determine if a task should run. * If the transient doesn't exist (expired or never set), it creates a new one * with the specified duration and executes the callback function. * * @param string $transient_name The name of the transient to check. * @param callable $callback The function to execute if transient has expired. * @param int $duration Duration in seconds for the transient (default: 3600). * * @return bool True if the task was executed, false if it was skipped. * * @since 3.2.0 */ public function check_scheduled_task_with_transient( $transient_name, callable $callback, $duration = 3600 ) { if ( false === get_transient( $transient_name ) ) { set_transient( $transient_name, true, $duration ); call_user_func( $callback ); return true; } return false; } }