$cursor Cursor. * @return array|null */ public function step( int $scan_id, array $cursor ): ?array { if ( array() === $this->domains ) { return null; } $stage = (int) ( $cursor['stage'] ?? 0 ); $after = (int) ( $cursor['after'] ?? 0 ); if ( ! isset( self::STAGES[ $stage ] ) ) { return null; } $rows = $this->rows( self::STAGES[ $stage ], $after ); if ( array() === $rows ) { return array( 'stage' => $stage + 1, 'after' => 0, ); } $labels = new SourceLabels(); $last = $after; foreach ( $rows as $row ) { $last = (int) $row['id']; foreach ( $this->domains as $domain ) { $at = stripos( (string) $row['value'], $domain ); if ( false === $at ) { continue; } [ $source, $url ] = $this->locate( self::STAGES[ $stage ], $row ); $observation = new Observation( self::ID, Priority::MEDIUM, Priority::ERROR, /* translators: %s: domain name. */ sprintf( __( 'Reference to the old domain %s', 'forensix-digital' ), $domain ), $domain, $source->type, $source->ref, $labels->label( $source ), array( 'count' => substr_count( strtolower( (string) $row['value'] ), $domain ) ) ); $this->store->record( $scan_id, $this->app, $observation, $url, self::snippet( (string) $row['value'], $at ) ); } } return array( 'stage' => $stage, 'after' => $last, ); } /** * Host names found in uploaded-file addresses that are not this site: a strong sign of a * previous domain or a staging copy. * * @param string[] $hosts Hosts of the site itself. * @return string[] */ public static function suggestions( array $hosts ): array { global $wpdb; $values = $wpdb->get_col( $wpdb->prepare( "SELECT post_content FROM %i WHERE post_status IN ('publish', 'private', 'draft', 'future') AND post_content LIKE %s ORDER BY ID DESC LIMIT 500", $wpdb->posts, '%' . $wpdb->esc_like( '/wp-content/uploads/' ) . '%' ) ); $found = array(); foreach ( (array) $values as $content ) { if ( preg_match_all( '#https?://([a-z0-9.-]+(?::\d+)?)/wp-content/uploads/#i', (string) $content, $m ) ) { foreach ( $m[1] as $host ) { $host = strtolower( $host ); if ( ! in_array( $host, $hosts, true ) ) { $found[ $host ] = ( $found[ $host ] ?? 0 ) + 1; } } } } arsort( $found ); return array_slice( array_keys( $found ), 0, 5 ); } /** * Cleans a list of domains entered by the user: host names only, lower case, no duplicates, * never the site's own hosts. * * @param mixed $value Text with one domain per line or comma, or a list. * @param string[] $hosts Hosts of the site itself. * @return string[] */ public static function sanitize_domains( $value, array $hosts = array() ): array { $items = is_array( $value ) ? $value : preg_split( '/[\s,]+/', (string) $value ); $out = array(); foreach ( (array) $items as $item ) { $item = strtolower( trim( (string) $item ) ); if ( '' === $item ) { continue; } $host = str_contains( $item, '://' ) ? (string) wp_parse_url( $item, PHP_URL_HOST ) : explode( '/', $item )[0]; if ( 1 === preg_match( '/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/', $host ) && ! in_array( $host, $hosts, true ) ) { $out[ $host ] = true; } } return array_slice( array_keys( $out ), 0, 10 ); } /** * One batch of rows that mention any of the domains. * * @param string $stage posts, meta or options. * @param int $after Last id handled. * @return array> */ private function rows( string $stage, int $after ): array { global $wpdb; $likes = array(); foreach ( $this->domains as $domain ) { $likes[] = '%' . $wpdb->esc_like( $domain ) . '%'; } switch ( $stage ) { case 'posts': $sql = "SELECT ID AS id, post_type, post_content AS value FROM %i WHERE ID > %d AND post_type NOT IN ('revision', 'nav_menu_item') AND post_status NOT IN ('trash', 'auto-draft', 'inherit') AND (" . self::likes( 'post_content', count( $likes ) ) . ') ORDER BY ID LIMIT %d'; $table = $wpdb->posts; break; case 'meta': $sql = "SELECT meta_id AS id, post_id, meta_key, meta_value AS value FROM %i WHERE meta_id > %d AND meta_key NOT IN ('_edit_lock', '_edit_last', '_wp_old_slug') AND (" . self::likes( 'meta_value', count( $likes ) ) . ') ORDER BY meta_id LIMIT %d'; $table = $wpdb->postmeta; break; default: $sql = "SELECT option_id AS id, option_name, option_value AS value FROM %i WHERE option_id > %d AND option_name NOT LIKE '\\_transient%%' AND option_name NOT LIKE '\\_site\\_transient%%' AND option_name NOT LIKE 'forensix\\_%%' AND (" . self::likes( 'option_value', count( $likes ) ) . ') ORDER BY option_id LIMIT %d'; $table = $wpdb->options; } // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- Fixed SQL with one %s per domain, built above. $rows = $wpdb->get_results( $wpdb->prepare( $sql, array_merge( array( $table, $after ), $likes, array( self::BATCH ) ) ), ARRAY_A ); // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber return is_array( $rows ) ? $rows : array(); } /** * "column LIKE %s OR column LIKE %s ..." for a fixed column name. * * @param string $column Column name. * @param int $count Number of patterns. */ private static function likes( string $column, int $count ): string { return implode( ' OR ', array_fill( 0, max( 1, $count ), $column . ' LIKE %s' ) ); } /** * Where a row lives, and the address shown for it. * * @param string $stage posts, meta or options. * @param array $row Row. * @return array{0: Source, 1: string} */ private function locate( string $stage, array $row ): array { if ( 'posts' === $stage ) { $id = (int) $row['id']; return array( new Source( Source::POST_CONTENT, (string) $id ), self::post_url( $id ) ); } if ( 'meta' === $stage ) { $post_id = (int) $row['post_id']; $key = (string) $row['meta_key']; $source = '_elementor_data' === $key ? new Source( Source::ELEMENTOR, $post_id . ':' ) : new Source( Source::POST_META, $post_id . ':' . $key ); return array( $source, self::post_url( $post_id ) ); } SourceStore::allow_option( (string) $row['option_name'] ); return array( new Source( Source::OPTION, (string) $row['option_name'] ), admin_url( 'options.php#' . rawurlencode( (string) $row['option_name'] ) ) ); } /** * Public address of a post, or its edit screen when it has none. * * @param int $post_id Post id. */ private static function post_url( int $post_id ): string { $post = get_post( $post_id ); if ( $post instanceof \WP_Post && 'publish' === $post->post_status && is_post_type_viewable( $post->post_type ) ) { $link = get_permalink( $post ); if ( is_string( $link ) ) { return $link; } } return admin_url( 'post.php?post=' . $post_id . '&action=edit' ); } /** * Short text around a match. * * @param string $value Text. * @param int $at Match position. */ private static function snippet( string $value, int $at ): string { $start = max( 0, $at - 40 ); return trim( (string) preg_replace( '/\s+/', ' ', mb_strcut( $value, $start, 120, 'UTF-8' ) ) ); } }