History shows it. */ class UpdateGuard { const BASELINE = 'orbitstem_seo_guard_baseline'; const ALERT = 'orbitstem_seo_guard_alert'; const PENDING = 'orbitstem_seo_guard_pending'; const HOOK = 'orbitstem_seo_update_guard'; const BASELINE_HOOK = 'orbitstem_seo_guard_baseline_build'; const LOG_ID = 'site_event'; const PAGES = 10; /** * Settings. * * @var Settings */ private $settings; /** * Change log. * * @var ChangeLog */ private $log; /** * Constructor. * * @param Settings $settings Settings. * @param ChangeLog $log Change log. */ public function __construct( Settings $settings, ChangeLog $log ) { $this->settings = $settings; $this->log = $log; } /** * Registers hooks. */ public function register() { add_action( 'upgrader_process_complete', array( $this, 'on_upgrade' ), 20, 2 ); add_action( 'activated_plugin', array( $this, 'on_plugin' ), 20 ); add_action( 'deactivated_plugin', array( $this, 'on_plugin_off' ), 20 ); add_action( 'switch_theme', array( $this, 'on_theme' ), 20 ); add_action( self::HOOK, array( $this, 'check' ) ); add_action( 'orbitstem_seo_daily', array( $this, 'refresh_baseline' ) ); add_action( self::BASELINE_HOOK, array( $this, 'refresh_baseline' ) ); add_action( 'admin_init', array( $this, 'ensure_baseline' ) ); // Sites run only from WP-CLI or a server cron may never open wp-admin. if ( ( defined( 'WP_CLI' ) && WP_CLI ) || wp_doing_cron() ) { add_action( 'init', array( $this, 'ensure_baseline' ) ); } add_action( 'update_option_' . Settings::OPTION, array( $this, 'on_settings' ), 10, 2 ); add_action( 'admin_notices', array( $this, 'notice' ) ); add_action( 'admin_post_orbitstem_seo_guard_dismiss', array( $this, 'dismiss' ) ); // Just before a change: if no baseline exists yet (for example right after re-activation, // before WP-Cron ran), take it now, while the pages are still as they were. add_filter( 'upgrader_pre_install', array( $this, 'before_install' ), 1 ); add_action( 'activate_plugin', array( $this, 'before_change' ), 1 ); add_action( 'deactivate_plugin', array( $this, 'before_change' ), 1 ); } /** * Before files are replaced by an update or install. * * @param mixed $response Upgrader response (passed through). * @return mixed */ public function before_install( $response ) { $this->before_change(); return $response; } /** * Builds the baseline right away when the guard is on and there is none. */ public function before_change() { if ( ! $this->settings->get( 'update_guard' ) || get_option( self::BASELINE ) ) { return; } $this->refresh_baseline(); // A baseline scheduled earlier would now run after the change and save the changed pages as normal. wp_clear_scheduled_hook( self::BASELINE_HOOK ); } /** * Builds the baseline soon after activation, an upgrade or switching the * guard on, so the first update afterwards is compared with a page known * to be fine (not with the page as the update left it). */ public function ensure_baseline() { self::schedule_baseline( $this->settings ); } /** * Schedules a baseline build when the guard is on and there is none yet. * Cheap enough for activation: it only schedules, the pages are fetched by * WP-Cron a few seconds later. * * @param Settings $settings Settings. */ public static function schedule_baseline( Settings $settings ) { if ( $settings->get( 'update_guard' ) && ! get_option( self::BASELINE ) && ! wp_next_scheduled( self::BASELINE_HOOK ) ) { wp_schedule_single_event( time() + 5, self::BASELINE_HOOK ); } } /** * The guard was switched on: the old baseline may be weeks old. * * @param mixed $old_value Old settings. * @param mixed $value New settings. */ public function on_settings( $old_value, $value ) { $was = is_array( $old_value ) && ! empty( $old_value['update_guard'] ); $is = is_array( $value ) && ! empty( $value['update_guard'] ); if ( $is && ! $was ) { delete_option( self::BASELINE ); wp_clear_scheduled_hook( self::BASELINE_HOOK ); wp_schedule_single_event( time() + 5, self::BASELINE_HOOK ); } } /** * Plugins, themes, translations or WordPress updated or installed. * * @param \WP_Upgrader $upgrader Upgrader. * @param array $extra Details: type, action, plugins, themes. */ public function on_upgrade( $upgrader, $extra ) { $type = isset( $extra['type'] ) ? (string) $extra['type'] : ''; $action = isset( $extra['action'] ) ? (string) $extra['action'] : ''; if ( 'translation' === $type || ! in_array( $action, array( 'update', 'install' ), true ) ) { return; } $names = array(); if ( 'plugin' === $type ) { $list = isset( $extra['plugins'] ) ? (array) $extra['plugins'] : ( isset( $extra['plugin'] ) ? array( $extra['plugin'] ) : array() ); foreach ( $list as $file ) { $names[] = $this->plugin_name( $file ); } } elseif ( 'theme' === $type ) { $list = isset( $extra['themes'] ) ? (array) $extra['themes'] : ( isset( $extra['theme'] ) ? array( $extra['theme'] ) : array() ); foreach ( $list as $slug ) { $theme = wp_get_theme( $slug ); $names[] = $theme->exists() ? $theme->get( 'Name' ) . ' ' . $theme->get( 'Version' ) : $slug; } } elseif ( 'core' === $type ) { $names[] = 'WordPress ' . get_bloginfo( 'version' ); } $labels = array( /* translators: %s: plugin names */ 'plugin' => 'update' === $action ? __( 'Plugin updated: %s', 'orbitstem-seo' ) : __( 'Plugin installed: %s', 'orbitstem-seo' ), /* translators: %s: theme names */ 'theme' => 'update' === $action ? __( 'Theme updated: %s', 'orbitstem-seo' ) : __( 'Theme installed: %s', 'orbitstem-seo' ), /* translators: %s: WordPress version */ 'core' => __( 'WordPress updated: %s', 'orbitstem-seo' ), ); if ( isset( $labels[ $type ] ) && $names ) { $this->event( sprintf( $labels[ $type ], implode( ', ', $names ) ) ); } } /** * A plugin was activated. * * @param string $file Plugin file. */ public function on_plugin( $file ) { if ( false !== strpos( (string) $file, 'orbitstem-seo/' ) ) { return; } /* translators: %s: plugin name */ $this->event( sprintf( __( 'Plugin activated: %s', 'orbitstem-seo' ), $this->plugin_name( $file ) ) ); } /** * A plugin was deactivated. * * @param string $file Plugin file. */ public function on_plugin_off( $file ) { if ( false !== strpos( (string) $file, 'orbitstem-seo/' ) ) { return; } /* translators: %s: plugin name */ $this->event( sprintf( __( 'Plugin deactivated: %s', 'orbitstem-seo' ), $this->plugin_name( $file ) ) ); } /** * The theme was switched. * * @param string $name New theme name. */ public function on_theme( $name ) { /* translators: %s: theme name */ $this->event( sprintf( __( 'Theme switched to %s', 'orbitstem-seo' ), $name ) ); } /** * Records a site event and schedules a check. * * @param string $summary What happened. */ private function event( $summary ) { if ( \Orbitstem\SEO\Diagnostics\Tables::v3_ready() ) { $this->log->record( self::LOG_ID, 'site', 0, $summary, null, array( 'kind' => 'site_event' ) ); } if ( ! $this->settings->get( 'update_guard' ) ) { return; } $pending = (array) get_option( self::PENDING, array() ); $pending[] = $summary; update_option( self::PENDING, array_slice( $pending, -20 ), false ); if ( ! wp_next_scheduled( self::HOOK ) ) { wp_schedule_single_event( time() + 30, self::HOOK ); } } /** * Plugin name from its file. * * @param string $file Plugin file. * @return string */ private function plugin_name( $file ) { $path = WP_PLUGIN_DIR . '/' . $file; if ( file_exists( $path ) && function_exists( 'get_plugin_data' ) ) { $data = get_plugin_data( $path, false, false ); if ( ! empty( $data['Name'] ) ) { return $data['Name'] . ( ! empty( $data['Version'] ) ? ' ' . $data['Version'] : '' ); } } return (string) $file; } /** * The key pages: homepage, posts page, newest posts and pages. * * @return array URL => post ID (0 for the homepage listing). */ public function key_pages() { $pages = array( home_url( '/' ) => (int) get_option( 'page_on_front' ) ); $blog = (int) get_option( 'page_for_posts' ); if ( $blog ) { $pages[ (string) get_permalink( $blog ) ] = $blog; } foreach ( array( 'post', 'page' ) as $type ) { $ids = get_posts( array( 'post_type' => $type, 'post_status' => 'publish', 'has_password' => false, 'posts_per_page' => 4, 'orderby' => 'post' === $type ? 'date' : 'modified', 'fields' => 'ids', 'no_found_rows' => true, ) ); foreach ( $ids as $id ) { $pages[ (string) get_permalink( $id ) ] = (int) $id; } } return array_slice( $pages, 0, self::PAGES, true ); } /** * What one page tells search engines right now. * * @param string $url Page URL. * @return array{status:int,title:bool,canonical:string,noindex:bool,h1:int,schema:bool} */ public function signals( $url ) { $response = wp_remote_get( $url, UrlChecker::request_args( 20 ) ); $status = is_wp_error( $response ) ? 0 : (int) wp_remote_retrieve_response_code( $response ); $html = is_wp_error( $response ) ? '' : (string) wp_remote_retrieve_body( $response ); $head = false !== stripos( $html, '' ) ? substr( $html, 0, stripos( $html, '' ) ) : $html; $header = is_wp_error( $response ) ? '' : strtolower( (string) wp_remote_retrieve_header( $response, 'x-robots-tag' ) ); $canon = preg_match( '#]*\brel=["\']canonical["\'][^>]*\bhref=["\']([^"\']+)["\']#i', $head, $m ) ? Url::normalize( html_entity_decode( $m[1] ), $url ) : ''; $robots = preg_match_all( '#]*\bname=["\']robots["\'][^>]*\bcontent=["\']([^"\']*)["\']#i', $head, $r ) ? strtolower( implode( ',', $r[1] ) ) : ''; return array( 'status' => $status, 'title' => (bool) preg_match( '#]*>\s*[^<\s][^<]*#i', $head ), 'canonical' => $canon, 'noindex' => false !== strpos( $robots, 'noindex' ) || false !== strpos( $header, 'noindex' ), 'h1' => (int) preg_match_all( '#]#i', $html ), 'schema' => false !== stripos( $html, 'application/ld+json' ), ); } /** * Saves the current signals as the new baseline. * * @return array Baseline. */ public function refresh_baseline() { $baseline = array( 'time' => time(), 'pages' => array(), ); foreach ( $this->key_pages() as $url => $id ) { $signals = $this->signals( $url ); if ( 200 === $signals['status'] ) { $baseline['pages'][ $url ] = $signals + array( 'id' => $id ); } } update_option( self::BASELINE, $baseline, false ); return $baseline; } /** * Compares the key pages with the baseline after a site event. * * @return array Problems found (also stored as the alert). */ public function check() { $pending = (array) get_option( self::PENDING, array() ); $baseline = get_option( self::BASELINE ); delete_option( self::PENDING ); if ( ! is_array( $baseline ) || empty( $baseline['pages'] ) ) { $this->refresh_baseline(); return array(); } $problems = array(); foreach ( $baseline['pages'] as $url => $before ) { $id = isset( $before['id'] ) ? (int) $before['id'] : 0; if ( $id && strtotime( (string) get_post_field( 'post_modified_gmt', $id ) . ' UTC' ) > (int) $baseline['time'] ) { continue; // Edited since the baseline: the edit explains changes. } $now = $this->signals( $url ); foreach ( $this->compare( $before, $now ) as $text ) { $problems[] = array( 'url' => $url, 'text' => $text, ); } } if ( $problems ) { update_option( self::ALERT, array( 'time' => time(), 'events' => $pending, 'problems' => $problems, ), false ); $this->email( $pending, $problems ); } $this->refresh_baseline(); return $problems; } /** * What got worse on one page, in plain words. * * @param array $before Baseline signals. * @param array $now Current signals. * @return string[] */ public function compare( array $before, array $now ) { $out = array(); if ( 200 !== $now['status'] ) { /* translators: %d: HTTP status */ $out[] = 0 === $now['status'] ? __( 'The page no longer answers.', 'orbitstem-seo' ) : sprintf( __( 'The page now answers with HTTP %d instead of 200.', 'orbitstem-seo' ), $now['status'] ); return $out; } if ( $before['title'] && ! $now['title'] ) { $out[] = __( 'The page lost its title tag.', 'orbitstem-seo' ); } if ( ! $before['noindex'] && $now['noindex'] ) { $out[] = __( 'The page now tells search engines not to index it (noindex).', 'orbitstem-seo' ); } if ( '' !== $before['canonical'] && $before['canonical'] !== $now['canonical'] ) { /* translators: %s: new canonical URL or "none" */ $out[] = sprintf( __( 'The canonical URL changed to %s.', 'orbitstem-seo' ), '' !== $now['canonical'] ? $now['canonical'] : __( 'none', 'orbitstem-seo' ) ); } if ( $before['h1'] <= 1 && $now['h1'] > 1 ) { /* translators: %d: number of H1 headings */ $out[] = sprintf( __( 'The page now has %d H1 headings instead of one.', 'orbitstem-seo' ), $now['h1'] ); } elseif ( 1 === $before['h1'] && 0 === $now['h1'] ) { $out[] = __( 'The page lost its H1 heading.', 'orbitstem-seo' ); } if ( $before['schema'] && ! $now['schema'] ) { $out[] = __( 'The page lost its structured data (schema).', 'orbitstem-seo' ); } return $out; } /** * Emails the admin when Monitor alerts are on. * * @param string[] $events What changed on the site. * @param array $problems Problems. */ private function email( array $events, array $problems ) { if ( ! $this->settings->get( 'monitor_email' ) ) { return; } $lines = array( /* translators: %s: site name */ sprintf( __( 'Orbitstem SEO checked key pages of %s right after a change to the site, and something got worse.', 'orbitstem-seo' ), wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ) ), '', __( 'What changed on the site:', 'orbitstem-seo' ), ); foreach ( $events as $event ) { $lines[] = '- ' . $event; } $lines[] = ''; $lines[] = __( 'What got worse:', 'orbitstem-seo' ); foreach ( $problems as $problem ) { $lines[] = '- ' . $problem['url'] . ': ' . $problem['text']; } $lines[] = ''; $lines[] = admin_url( 'admin.php?page=orbitstem-seo-issues' ); /* translators: %s: site name */ wp_mail( (string) get_option( 'admin_email' ), sprintf( __( '[%s] A site update affected your SEO', 'orbitstem-seo' ), wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ) ), implode( "\n", $lines ) ); } /** * The current alert, if any. * * @return array|null */ public function alert() { $alert = get_option( self::ALERT ); return is_array( $alert ) && ! empty( $alert['problems'] ) ? $alert : null; } /** * The alert as an SEO issue. * * @return array */ public function issues() { $alert = $this->alert(); if ( ! $alert ) { return array(); } $rows = array(); foreach ( $alert['problems'] as $problem ) { $rows[] = array( 'url' => $problem['url'], 'problem' => $problem['text'], ); } return array( array( 'id' => 'update_guard', 'severity' => 'error', 'title' => __( 'A site update changed what pages tell search engines', 'orbitstem-seo' ), /* translators: %s: list of updates */ 'explanation' => sprintf( __( 'Right after this change to the site, key pages got worse: %s', 'orbitstem-seo' ), implode( '; ', (array) $alert['events'] ) ), 'fix' => __( 'Check the theme or plugin that was just updated (its settings, or roll back to the previous version), then dismiss this alert on the dashboard notice.', 'orbitstem-seo' ), 'count' => count( $rows ), 'columns' => array( 'url' => __( 'Page', 'orbitstem-seo' ), 'problem' => __( 'What got worse', 'orbitstem-seo' ), ), 'rows' => $rows, ), ); } /** * Admin notice while an alert is open. */ public function notice() { $alert = $this->alert(); if ( ! $alert || ! current_user_can( 'manage_options' ) ) { return; } $dismiss = wp_nonce_url( admin_url( 'admin-post.php?action=orbitstem_seo_guard_dismiss' ), 'orbitstem_seo_guard_dismiss' ); printf( '

%1$s %2$s %4$s ยท %6$s

', esc_html__( 'Orbitstem SEO update guard:', 'orbitstem-seo' ), /* translators: %d: number of problems */ esc_html( sprintf( _n( 'right after a site update, %d problem appeared on key pages.', 'right after a site update, %d problems appeared on key pages.', count( $alert['problems'] ), 'orbitstem-seo' ), count( $alert['problems'] ) ) ), esc_url( admin_url( 'admin.php?page=orbitstem-seo-issues#update_guard' ) ), esc_html__( 'See what changed', 'orbitstem-seo' ), esc_url( $dismiss ), esc_html__( 'Dismiss', 'orbitstem-seo' ) ); } /** * Dismisses the alert. */ public function dismiss() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to do this.', 'orbitstem-seo' ), 403 ); } check_admin_referer( 'orbitstem_seo_guard_dismiss' ); delete_option( self::ALERT ); wp_safe_redirect( wp_get_referer() ? wp_get_referer() : admin_url( 'admin.php?page=orbitstem-seo' ) ); exit; } }