'auto_redirected_links', 'redirect_chains' => 'auto_redirect_chains', ); /** * Settings service. * * @var Settings */ private $settings; /** * Background jobs. * * @var Jobs */ private $jobs; /** * Diagnostics scanner. * * @var Scanner */ private $scanner; /** * Repair Center. * * @var RepairCenter */ private $repair; /** * Change log. * * @var ChangeLog */ private $log; /** * URL checker. * * @var UrlChecker */ private $checker; /** * Constructor. * * @param Settings $settings Settings. * @param Jobs $jobs Jobs. * @param Scanner $scanner Scanner. * @param RepairCenter $repair Repair Center. * @param ChangeLog $log Change log. * @param UrlChecker $checker URL checker. */ public function __construct( Settings $settings, Jobs $jobs, Scanner $scanner, RepairCenter $repair, ChangeLog $log, UrlChecker $checker ) { $this->settings = $settings; $this->jobs = $jobs; $this->scanner = $scanner; $this->repair = $repair; $this->log = $log; $this->checker = $checker; } /** * Registers hooks and keeps the scan schedule in line with the setting. */ public function register() { add_action( self::SCAN_HOOK, array( $this, 'scheduled_scan' ) ); add_action( 'orbitstem_seo_register_jobs', array( $this, 'register_job' ) ); add_action( 'orbitstem_seo_scan_complete', array( $this, 'on_scan_complete' ) ); add_action( 'update_option_' . Settings::OPTION, array( $this, 'settings_updated' ), 10, 2 ); add_action( 'add_option_' . Settings::OPTION, array( $this, 'settings_added' ), 10, 2 ); $this->reschedule(); } /** * Registers the repair job. * * @param Jobs $jobs Job runner. */ public function register_job( Jobs $jobs ) { $jobs->register( self::JOB, array( $this, 'step' ), 5 ); } /** * Matches the scan cron event to the frequency setting. */ public function reschedule() { $frequency = (string) $this->settings->get( 'scan_frequency' ); $next = wp_next_scheduled( self::SCAN_HOOK ); if ( 'off' === $frequency ) { if ( $next ) { wp_clear_scheduled_hook( self::SCAN_HOOK ); } return; } if ( ! $next || wp_get_schedule( self::SCAN_HOOK ) !== $frequency ) { wp_clear_scheduled_hook( self::SCAN_HOOK ); // Offset from the monitor so both do not start in the same request. wp_schedule_event( time() + 2 * HOUR_IN_SECONDS, $frequency, self::SCAN_HOOK ); } } /** * Cron handler: starts a scan unless one is running. */ public function scheduled_scan() { $this->scanner->start(); } /** * After a scan, queues automatic repairs when any are switched on. */ public function on_scan_complete() { if ( $this->enabled_fixers() ) { $this->jobs->start( self::JOB ); } } /** * Settings saved: reschedule, and remember who switched automatic repairs on. * * @param mixed $old_value Previous settings. * @param mixed $value New settings. */ public function settings_updated( $old_value, $value ) { $this->reschedule(); $this->remember_user( is_array( $old_value ) ? $old_value : array(), is_array( $value ) ? $value : array() ); } /** * Settings stored for the first time. * * @param string $option Option name. * @param mixed $value Settings. */ public function settings_added( $option, $value ) { $this->settings_updated( array(), $value ); } /** * The user who switches an automatic repair on becomes the user the * repairs run as. Switching all of them off forgets the user. * * @param array $old_value Previous settings. * @param array $value New settings. */ private function remember_user( array $old_value, array $value ) { $on = false; $newly_on = false; $user_valid = $this->run_user() > 0; foreach ( self::FIXERS as $setting ) { if ( ! empty( $value[ $setting ] ) ) { $on = true; $newly_on = $newly_on || empty( $old_value[ $setting ] ); } } if ( ! $on ) { delete_option( self::USER_OPTION ); return; } if ( ( $newly_on || ! $user_valid ) && get_current_user_id() && current_user_can( Admin::capability() ) ) { update_option( self::USER_OPTION, get_current_user_id(), false ); } } /** * Fixer ids switched on in the settings. * * @return string[] */ public function enabled_fixers() { $ids = array(); foreach ( self::FIXERS as $id => $setting ) { if ( $this->settings->get( $setting ) && isset( $this->repair->fixers()[ $id ] ) ) { $ids[] = $id; } } return $ids; } /** * The user automatic repairs run as, or 0 when there is none that may. * * @return int */ public function run_user() { $user_id = (int) get_option( self::USER_OPTION, 0 ); return $user_id && user_can( $user_id, Admin::capability() ) ? $user_id : 0; } /** * Result of the last automatic run, or null. * * @return array|null */ public function last() { $last = get_option( self::LAST_OPTION, null ); return is_array( $last ) ? $last : null; } /** * Current job state, or null. * * @return array|null */ public function status() { return $this->jobs->status( self::JOB ); } /** * Job handler: applies up to $batch queued repairs. * * @param mixed $cursor Cursor. * @param int $batch Items per step. * @return array{cursor:array,done:bool} */ public function step( $cursor, $batch = 5 ) { if ( ! is_array( $cursor ) ) { $cursor = array( 'started' => time(), 'queue' => array(), 'applied' => 0, 'rolled_back' => 0, 'skipped' => 0, 'failures' => 0, 'changes' => array(), 'notes' => array(), 'stopped' => '', ); foreach ( $this->enabled_fixers() as $fixer_id ) { foreach ( $this->repair->fixers()[ $fixer_id ]->candidates( self::MAX_PER_RUN ) as $item ) { $cursor['queue'][] = array( $fixer_id, $item['key'] ); } } /** * Filters the repairs an automatic run will apply. * * @param array $queue List of array( fixer id, candidate key ). */ $queue = apply_filters( 'orbitstem_seo_autopilot_queue', $cursor['queue'] ); $cursor['queue'] = array_slice( is_array( $queue ) ? array_values( $queue ) : array(), 0, self::MAX_PER_RUN ); } $user_id = $this->run_user(); if ( ! $user_id ) { if ( $cursor['queue'] ) { $cursor['stopped'] = __( 'No administrator is set to run automatic repairs. Save Orbitstem SEO > Settings once to take this over.', 'orbitstem-seo' ); $cursor['queue'] = array(); } return $this->finish( $cursor ); } $previous_user = get_current_user_id(); wp_set_current_user( $user_id ); try { foreach ( array_splice( $cursor['queue'], 0, max( 1, (int) $batch ) ) as $entry ) { $this->apply_one( $cursor, $entry[0], $entry[1] ); if ( $cursor['failures'] >= self::MAX_FAILURES ) { /* translators: %d: number of failed checks */ $cursor['stopped'] = sprintf( __( 'Stopped after %d repairs failed their check. Nothing else was changed in this run.', 'orbitstem-seo' ), self::MAX_FAILURES ); $cursor['queue'] = array(); break; } } } finally { wp_set_current_user( $previous_user ); } if ( ! $cursor['queue'] ) { return $this->finish( $cursor ); } return array( 'cursor' => $cursor, 'done' => false, ); } /** * Applies one repair; rolls it back at once if its check fails. * * @param array $cursor Cursor, by reference. * @param string $fixer_id Fixer. * @param string $key Candidate key. */ private function apply_one( array &$cursor, $fixer_id, $key ) { if ( 'redirected_links' === $fixer_id ) { $permanent = $this->permanent_chain( $key ); if ( is_wp_error( $permanent ) ) { ++$cursor['skipped']; $this->note( $cursor, $permanent->get_error_message() ); return; } } $results = $this->repair->apply( $fixer_id, array( $key ) ); $result = is_array( $results ) ? reset( $results ) : null; if ( ! $result || ! $result['ok'] ) { ++$cursor['skipped']; $this->note( $cursor, $result ? $result['message'] : $results->get_error_message() ); return; } $this->log->prefix_summary( $result['change_id'], __( 'Automatic:', 'orbitstem-seo' ) . ' ' ); if ( $result['verified'] ) { ++$cursor['applied']; $cursor['changes'][] = (int) $result['change_id']; return; } ++$cursor['failures']; $undone = $this->repair->rollback( $result['change_id'] ); if ( true === $undone ) { ++$cursor['rolled_back']; /* translators: %s: what the repair did and why its check failed */ $this->note( $cursor, sprintf( __( 'Undone because its check failed: %s', 'orbitstem-seo' ), $result['message'] ) ); } else { $cursor['changes'][] = (int) $result['change_id']; /* translators: 1: repair, 2: error */ $this->note( $cursor, sprintf( __( 'Check failed and automatic undo was refused (%2$s): %1$s. Review it in History.', 'orbitstem-seo' ), $result['message'], $undone->get_error_message() ) ); } } /** * For a redirected-link candidate, checks that every hop to the final * address is a permanent redirect and that the final address answers 200. * * @param string $key Candidate key "post_id:url_hash". * @return true|WP_Error */ private function permanent_chain( $key ) { global $wpdb; $parts = explode( ':', $key, 2 ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery $url = (string) $wpdb->get_var( $wpdb->prepare( 'SELECT url FROM %i WHERE url_hash = %s', Tables::urls(), isset( $parts[1] ) ? $parts[1] : '' ) ); if ( '' === $url ) { return new WP_Error( 'gone', __( 'Skipped a link that is no longer in the scan results.', 'orbitstem-seo' ) ); } $start = $url; for ( $hop = 0; $hop <= self::MAX_HOPS; $hop++ ) { $result = $this->checker->check( $url, Url::is_internal( $url ) ); if ( 200 === $result['status'] ) { return true; } if ( in_array( $result['status'], array( 301, 308 ), true ) && '' !== $result['redirect'] ) { $url = $result['redirect']; continue; } /* translators: 1: link address, 2: HTTP status */ return new WP_Error( 'temporary', sprintf( __( 'Left %1$s alone: its redirect is not permanent or does not end at a working page (HTTP %2$d). Review it in the Repair Center.', 'orbitstem-seo' ), $start, $result['status'] ) ); } /* translators: %s: link address */ return new WP_Error( 'hops', sprintf( __( 'Left %s alone: too many redirect steps.', 'orbitstem-seo' ), $start ) ); } /** * Adds a note to the run summary (at most 20 are kept). * * @param array $cursor Cursor, by reference. * @param string $note Note. */ private function note( array &$cursor, $note ) { if ( count( $cursor['notes'] ) < 20 ) { $cursor['notes'][] = (string) $note; } } /** * Stores the run summary and sends the optional email. * * @param array $cursor Cursor. * @return array{cursor:array,done:bool} */ private function finish( array $cursor ) { $summary = array( 'time' => time(), 'started' => (int) $cursor['started'], 'applied' => (int) $cursor['applied'], 'rolled_back' => (int) $cursor['rolled_back'], 'skipped' => (int) $cursor['skipped'], 'changes' => array_slice( $cursor['changes'], 0, self::MAX_PER_RUN ), 'notes' => $cursor['notes'], 'stopped' => (string) $cursor['stopped'], ); update_option( self::LAST_OPTION, $summary, false ); delete_transient( Issues::SUMMARY_TRANSIENT ); if ( $this->settings->get( 'auto_repair_email' ) && ( $summary['applied'] || $summary['rolled_back'] || '' !== $summary['stopped'] ) ) { $this->email( $summary ); } return array( 'cursor' => array(), 'done' => true, ); } /** * Emails the run summary to the site admin address. * * @param array $summary Run summary. * @return bool */ private function email( array $summary ) { $lines = array(); foreach ( $summary['changes'] as $change_id ) { $change = $this->log->get( $change_id ); if ( $change ) { $lines[] = '- ' . $change['summary']; } } foreach ( $summary['notes'] as $note ) { $lines[] = '- ' . $note; } if ( '' !== $summary['stopped'] ) { $lines[] = $summary['stopped']; } $body = sprintf( /* translators: 1: site name, 2: repairs applied, 3: repairs undone, 4: details, 5: admin URL */ __( "Orbitstem SEO ran its automatic repairs on %1\$s.\n\nApplied and verified: %2\$d\nUndone after a failed check: %3\$d\n\n%4\$s\n\nEvery change can be rolled back from History: %5\$s", 'orbitstem-seo' ), wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ), $summary['applied'], $summary['rolled_back'], implode( "\n", array_slice( $lines, 0, 40 ) ), admin_url( 'admin.php?page=orbitstem-seo-repair&tab=history' ) ); /* translators: %s: site name */ return wp_mail( get_option( 'admin_email' ), sprintf( __( '[%s] Automatic SEO repairs', 'orbitstem-seo' ), wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ) ), $body ); } }