false, 'pass' => '', 'cursor' => 0, 'home_done' => false, 'started' => 0, 'finished' => 0, 'done' => 0, 'total' => 0, 'errors' => 0, ), is_array( $state ) ? $state : array() ); } /** * Saves scan progress. * * @param array $state State. */ private static function save_state( array $state ) { update_option( self::STATE, $state, false ); } /** * Daily: start a full pass when the last one is a week old. */ public static function daily() { if ( ! Settings::get( 'background_scan' ) ) { return; } $state = self::state(); if ( $state['running'] ) { self::queue_next(); return; } if ( time() - (int) $state['finished'] > WEEK_IN_SECONDS ) { self::start(); } } /** * Starts a full pass. * * @param bool $queue Whether to let WP-Cron continue it. * @return array State. */ public static function start( $queue = true ) { $state = array_merge( self::state(), array( 'running' => true, 'pass' => wp_generate_password( 12, false, false ), 'cursor' => 0, 'home_done' => false, 'started' => time(), 'done' => 0, 'total' => self::count_targets(), 'errors' => 0, ) ); self::save_state( $state ); if ( $queue ) { self::queue_next( 5 ); } return $state; } /** * Schedules the next batch. * * @param int $delay Seconds from now. */ private static function queue_next( $delay = MINUTE_IN_SECONDS ) { if ( ! wp_next_scheduled( self::CHUNK_HOOK ) ) { wp_schedule_single_event( time() + $delay, self::CHUNK_HOOK ); } } /** * WP-Cron batch. */ public static function cron_chunk() { if ( ! Settings::get( 'background_scan' ) ) { return; // Turned off in Settings while a pass was queued. } $state = self::run_chunk( 20, 10 ); if ( $state['running'] ) { self::queue_next(); } } /** * Runs one batch within a time budget. * * @param int $seconds Time budget. * @param int $max Most pages to fetch. * @return array State after the batch. */ public static function run_chunk( $seconds = 20, $max = 10 ) { $state = self::state(); if ( ! $state['running'] ) { return $state; } // WP-Cron, the Overview screen and WP-CLI can all run batches; only one at a time. $lock = self::lock( $seconds + MINUTE_IN_SECONDS ); if ( '' === $lock ) { $state['busy'] = true; return $state; } $state = self::state( true ); $deadline = microtime( true ) + $seconds; $fetched = 0; while ( $state['running'] && $fetched < $max && microtime( true ) < $deadline ) { $target = self::next_target( $state ); if ( null === $target ) { $state['running'] = false; $state['finished'] = time(); $state['total'] = $state['done']; // Skipped pages (the static front page, off-site addresses) never count. Store::prune_stale( $state['started'] ); // Pages not seen in a whole pass no longer exist. Scan_Token::rotate(); break; } if ( ! self::scan_url( $target['url'], $target['title'], $target['object_id'] ) ) { ++$state['errors']; } $fresh = self::state( true ); if ( $fresh['pass'] !== $state['pass'] ) { // A new pass was started meanwhile (WP-CLI, or the Overview screen): leave it alone. self::unlock( $lock ); return $fresh; } if ( 0 === $target['object_id'] ) { $state['home_done'] = true; } else { $state['cursor'] = $target['object_id']; } ++$state['done']; ++$fetched; self::save_state( $state ); } self::save_state( $state ); self::unlock( $lock ); return $state; } /** * Takes the batch lock. A lock left behind by a batch that died expires. * * @param int $seconds How long the lock lasts. * @return string The lock value to release it with, or '' when another batch holds it. */ private static function lock( $seconds ) { global $wpdb; $now = time(); // Expiry time, zero-padded so it compares as text, then a random part. $value = sprintf( '%012d', $now + $seconds ) . ':' . wp_generate_password( 8, false, false ); // Plain SQL, because add_option() may overwrite a row it believes is missing. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $taken = (int) $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')", self::LOCK, $value ) ); if ( ! $taken ) { $taken = (int) $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->options} SET option_value = %s WHERE option_name = %s AND LEFT( option_value, 12 ) < %s", $value, self::LOCK, sprintf( '%012d', $now ) ) ); } // phpcs:enable wp_cache_delete( self::LOCK, 'options' ); return $taken > 0 ? $value : ''; } /** * Releases the batch lock, if it is still this batch's. * * @param string $value Value from lock(). */ private static function unlock( $value ) { global $wpdb; $wpdb->delete( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- The lock row written by lock(). $wpdb->options, array( 'option_name' => self::LOCK, 'option_value' => $value, ) ); wp_cache_delete( self::LOCK, 'options' ); } /** * Post types whose single pages are public. * * @return string[] */ public static function post_types() { // Page builders' template libraries are public post types, but not pages anyone visits. $skip = array( 'attachment', 'elementor_library', 'et_pb_layout', 'fl-builder-template', 'ct_template', 'brizy_template', 'oceanwp_library', 'astra-advanced-hook', 'kadence_element', 'jet-theme-core', 'bricks_template', 'wp_block' ); $types = array(); foreach ( get_post_types( array( 'public' => true ) ) as $type ) { if ( ! in_array( $type, $skip, true ) && is_post_type_viewable( $type ) ) { $types[] = $type; } } /** * Filters the post types whose pages Fixlight checks. * * @param string[] $types Post type names. */ return (array) apply_filters( 'fixlight_post_types', $types ); } /** * Number of pages in a full pass. * * @return int */ private static function count_targets() { global $wpdb; $types = self::post_types(); if ( ! $types ) { return 1; } $placeholders = implode( ',', array_fill( 0, count( $types ), '%s' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Placeholders are built from the type count. $count = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_status = 'publish' AND post_password = '' AND post_type IN ({$placeholders})", $types ) ); return 1 + $count; } /** * The next page to check: the home page first, then posts by id. * * @param array $state State. * @return array|null url, title, object_id. */ private static function next_target( array $state ) { if ( ! $state['home_done'] ) { return array( 'url' => home_url( '/' ), 'title' => __( 'Home page', 'fixlight' ), 'object_id' => 0, ); } global $wpdb; $types = self::post_types(); if ( ! $types ) { return null; } $placeholders = implode( ',', array_fill( 0, count( $types ), '%s' ) ); $args = array_merge( array( (int) $state['cursor'] ), $types ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Placeholders are built from the type count. $post_id = (int) $wpdb->get_var( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE ID > %d AND post_status = 'publish' AND post_password = '' AND post_type IN ({$placeholders}) ORDER BY ID ASC LIMIT 1", $args ) ); if ( ! $post_id ) { return null; } $url = get_permalink( $post_id ); if ( ! $url || ! self::is_own_url( $url ) || (int) get_option( 'page_on_front' ) === $post_id ) { // The static front page is already checked as the home page, and pages whose // address points to another site (link plugins) are never fetched. return self::next_target( array_merge( $state, array( 'cursor' => $post_id ) ) ); } return array( 'url' => $url, 'title' => get_the_title( $post_id ), 'object_id' => $post_id, ); } /** * Whether an address is one of this site's own pages (same host as the home page). * * @param string $url Address. * @return bool */ public static function is_own_url( $url ) { $scheme = strtolower( (string) wp_parse_url( $url, PHP_URL_SCHEME ) ); $host = strtolower( (string) wp_parse_url( $url, PHP_URL_HOST ) ); return in_array( $scheme, array( 'http', 'https' ), true ) && '' !== $host && strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) ) === $host; } /** * Fetches one page (fixes off) and saves its before/after results. * * @param string $url Page URL. * @param string $title Title for the issue list. * @param int $object_id Post id or 0. * @return bool Whether the page was checked. */ public static function scan_url( $url, $title = '', $object_id = 0 ) { if ( ! self::is_own_url( $url ) ) { return false; } $page_id = Store::page_id( $url, wp_strip_all_tags( (string) $title ), $object_id ); $response = wp_remote_get( add_query_arg( array( 'fixlight_scan' => Scan_Token::get(), 'fixlight_off' => '1', ), $url ), array( 'timeout' => 20, 'redirection' => 0, // A loopback to this site's own address (checked above), so core's loopback rule applies. /** This filter is documented in wp-includes/class-wp-http-streams.php */ 'sslverify' => apply_filters( 'https_local_ssl_verify', false ), // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Core filter for loopback requests. 'headers' => array( 'Cache-Control' => 'no-cache' ), 'user-agent' => 'Fixlight/' . FIXLIGHT_VERSION . '; ' . home_url( '/' ), ) ); if ( is_wp_error( $response ) ) { Store::save_error( $page_id, $response->get_error_message() ); return false; } $code = (int) wp_remote_retrieve_response_code( $response ); if ( $code >= 300 && $code < 400 ) { // Following it would lose the scan token, and the "before" check would see the fixed page. $location = (string) wp_remote_retrieve_header( $response, 'location' ); /* translators: %s: address the page redirects to. */ Store::save_error( $page_id, sprintf( __( 'The page redirects to %s.', 'fixlight' ), '' !== $location ? $location : __( 'another address', 'fixlight' ) ) ); return false; } if ( 200 !== $code ) { /* translators: %d: HTTP status code such as 404. */ Store::save_error( $page_id, sprintf( __( 'The page answered with HTTP %d.', 'fixlight' ), $code ) ); return false; } $type = (string) wp_remote_retrieve_header( $response, 'content-type' ); if ( '' !== $type && false === stripos( $type, 'html' ) ) { Store::save_error( $page_id, __( 'Not an HTML page.', 'fixlight' ) ); return false; } $html = wp_remote_retrieve_body( $response ); $results = Scanner::check_before_after( $html, Settings::fixes(), Front::context( self::document_title( $title, $object_id ) ) ); Store::save_php_results( $page_id, $results ); return true; } /** * The title WordPress would print for a page, for the "after" check. * * @param string $title Page title. * @param int $object_id Post id or 0. * @return string */ private static function document_title( $title, $object_id ) { $site = get_bloginfo( 'name' ); if ( 0 === $object_id ) { $tagline = get_bloginfo( 'description' ); return '' !== $tagline ? $site . ' – ' . $tagline : $site; } return wp_strip_all_tags( (string) $title ) . ' – ' . $site; } /** * Re-checks a post after it is saved. * * @param int $post_id Post id. * @param WP_Post $post Post. */ public static function on_save( $post_id, $post ) { if ( ! Settings::get( 'background_scan' ) || wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) { return; } if ( 'publish' !== $post->post_status || '' !== $post->post_password || ! in_array( $post->post_type, self::post_types(), true ) ) { return; } $args = array( (int) $post_id ); if ( ! wp_next_scheduled( self::POST_HOOK, $args ) ) { wp_schedule_single_event( time() + 30, self::POST_HOOK, $args ); } } /** * WP-Cron: check one post. * * @param int $post_id Post id. */ public static function scan_post( $post_id ) { $post_id = absint( $post_id ); if ( 'publish' !== get_post_status( $post_id ) ) { return; } $url = (int) get_option( 'page_on_front' ) === $post_id ? home_url( '/' ) : get_permalink( $post_id ); if ( $url ) { self::scan_url( $url, get_the_title( $post_id ), $post_id ); } } }