true ), 'names' ); unset( $types['attachment'] ); /** * Filters the post types Unbreak scans. * * @param string[] $types Post type names. */ $types = apply_filters( 'unbreak_post_types', array_values( $types ) ); return array_values( array_filter( array_map( 'strval', (array) $types ) ) ); } /** * Whether a post should be scanned. * * @param \WP_Post $post Post. */ public static function is_eligible( \WP_Post $post ): bool { return in_array( $post->post_status, self::POST_STATUSES, true ) && in_array( $post->post_type, self::post_types(), true ) && ! wp_is_post_revision( $post ) && ! wp_is_post_autosave( $post ); } /** * Re-index a post (post content + Elementor data). Removes its instances if it is no longer eligible. * * @param int $post_id Post ID. * @return int Number of instances stored. */ public function index_post( int $post_id ): int { $post = get_post( $post_id ); $entries = array(); if ( $post instanceof \WP_Post && self::is_eligible( $post ) ) { $base = (string) get_permalink( $post ); $base = '' !== $base ? $base : home_url( '/' ); $images = (bool) Settings::get( 'include_images' ); $elementor_enabled = Settings::source_enabled( ElementorExtractor::SOURCE_TYPE ); $built_with_el = $elementor_enabled && ElementorExtractor::is_built_with_elementor( $post_id ); // For Elementor pages, post_content is only a rendered copy of the Elementor data. if ( Settings::source_enabled( PostContentExtractor::SOURCE_TYPE ) && ! $built_with_el ) { $entries[] = array( 'source_type' => PostContentExtractor::SOURCE_TYPE, 'source_id' => $post_id, 'links' => PostContentExtractor::extract( (string) $post->post_content, $base, $images ), ); } if ( $elementor_enabled ) { $entries[] = array( 'source_type' => ElementorExtractor::SOURCE_TYPE, 'source_id' => $post_id, 'links' => ElementorExtractor::extract_post( $post_id, $base, $images ), ); } } return $this->replace( array( PostContentExtractor::SOURCE_TYPE, ElementorExtractor::SOURCE_TYPE ), $post_id, $entries ); } /** * Re-index every custom menu link. * * @return int Number of instances stored. */ public function index_menus(): int { $entries = array(); if ( Settings::source_enabled( MenuExtractor::SOURCE_TYPE ) ) { foreach ( MenuExtractor::extract_all() as $item_id => $links ) { $entries[] = array( 'source_type' => MenuExtractor::SOURCE_TYPE, 'source_id' => $item_id, 'links' => $links, ); } } return $this->replace( array( MenuExtractor::SOURCE_TYPE ), null, $entries ); } /** * Re-index every active widget. * * @return int Number of instances stored. */ public function index_widgets(): int { $entries = array(); if ( Settings::source_enabled( WidgetExtractor::SOURCE_TYPE ) ) { foreach ( WidgetExtractor::extract_all( (bool) Settings::get( 'include_images' ) ) as $widget ) { $entries[] = array( 'source_type' => WidgetExtractor::SOURCE_TYPE, 'source_id' => $widget['source_id'], 'links' => $widget['links'], ); } } return $this->replace( array( WidgetExtractor::SOURCE_TYPE ), null, $entries ); } /** * Re-index one comment. * * @param int $comment_id Comment ID. * @return int Number of instances stored. */ public function index_comment( int $comment_id ): int { $entries = array(); if ( Settings::source_enabled( CommentExtractor::SOURCE_TYPE ) ) { $links = CommentExtractor::extract( $comment_id, (bool) Settings::get( 'include_images' ) ); if ( null !== $links ) { $entries[] = array( 'source_type' => CommentExtractor::SOURCE_TYPE, 'source_id' => $comment_id, 'links' => $links, ); } } return $this->replace( array( CommentExtractor::SOURCE_TYPE ), $comment_id, $entries ); } /** * Remove all instances of a source type (used when a source is disabled in settings). * * @param string $source_type Source type. */ public function remove_source_type( string $source_type ): void { $this->replace( array( $source_type ), null, array() ); } /** * Replace the instances in a scope with freshly extracted ones. * * @param string[] $types Source types in scope. * @param int|null $source_id Source ID in scope, or null for every ID of those types. * @param array}> $entries New instances. * @return int Number of instances stored. */ private function replace( array $types, ?int $source_id, array $entries ): int { global $wpdb; $instances = Schema::table( 'instances' ); $type_in = implode( ',', array_fill( 0, count( $types ), '%s' ) ); // "( %d = 0 OR source_id = %d )": the first value is 0 when the scope covers every ID of these types. $params = array_merge( array( $instances ), $types, array( null === $source_id ? 0 : 1, (int) $source_id ) ); // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $type_in only holds %s placeholders. $old_ids = array_map( 'intval', (array) $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT link_id FROM %i WHERE source_type IN ({$type_in}) AND ( %d = 0 OR source_id = %d )", $params ) ) ); $wpdb->query( $wpdb->prepare( "DELETE FROM %i WHERE source_type IN ({$type_in}) AND ( %d = 0 OR source_id = %d )", $params ) ); // phpcs:enable // Admin/auth URLs of this site (dashboard, login, nonce links) are never stored or checked. foreach ( $entries as $i => $entry ) { $entries[ $i ]['links'] = array_values( array_filter( $entry['links'], static function ( array $link ): bool { return ! Url::is_wp_admin( $link['url'] ); } ) ); } $urls = array(); foreach ( $entries as $entry ) { foreach ( $entry['links'] as $link ) { $urls[ $link['url'] ] = true; } } $ids = $this->ensure_links( array_keys( $urls ) ); $rows = array(); foreach ( $entries as $entry ) { foreach ( $entry['links'] as $link ) { if ( ! isset( $ids[ $link['url'] ] ) ) { continue; } $rows[] = array( $ids[ $link['url'] ], $entry['source_type'], $entry['source_id'], '' !== $link['source_key'] ? $link['source_key'] : null, '' !== $link['anchor_text'] ? $link['anchor_text'] : null, $link['element'], '' !== $link['context'] ? $link['context'] : null, ); } } $this->insert_instances( $rows ); $removed = array_diff( $old_ids, array_values( $ids ) ); if ( ! empty( $removed ) ) { $this->delete_orphans( $removed ); } return count( $rows ); } /** * Make sure a link row exists for each URL; returns url => id. * * @param string[] $urls Normalized URLs. * @return array */ public function ensure_links( array $urls ): array { global $wpdb; $table = Schema::table( 'links' ); $ids = array(); if ( empty( $urls ) ) { return $ids; } $by_hash = array(); foreach ( $urls as $url ) { $by_hash[ Url::hash( $url ) ] = $url; } foreach ( array_chunk( array_keys( $by_hash ), self::CHUNK ) as $chunk ) { $ids += $this->lookup_ids( $chunk, $by_hash ); $missing = array(); foreach ( $chunk as $hash ) { if ( ! isset( $ids[ $by_hash[ $hash ] ] ) ) { $missing[] = $hash; } } if ( empty( $missing ) ) { continue; } $now = Clock::mysql(); $placeholders = array(); $values = array(); foreach ( $missing as $hash ) { $url = $by_hash[ $hash ]; $host = substr( Url::host( $url ), 0, 191 ); $placeholders[] = '(%s,%s,%s,%d,%d,%s,%s)'; array_push( $values, $url, $hash, $host, Url::host_bucket( $host ), Url::is_internal( $url ) ? 1 : 0, 'pending', $now ); } // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Placeholders are generated above. $wpdb->query( $wpdb->prepare( 'INSERT IGNORE INTO %i (url, url_hash, host, host_bucket, is_internal, status, next_check_at) VALUES ' . implode( ',', $placeholders ), array_merge( array( $table ), $values ) ) ); $ids += $this->lookup_ids( $missing, $by_hash ); /** * Fires after new links were discovered and stored as "pending". * * @param int $count Number of new links. */ do_action( 'unbreak_links_added', count( $missing ) ); } return $ids; } /** * Look up link IDs by hash. * * @param string[] $hashes Hashes. * @param array $by_hash hash => url. * @return array url => id */ private function lookup_ids( array $hashes, array $by_hash ): array { global $wpdb; $table = Schema::table( 'links' ); $in = implode( ',', array_fill( 0, count( $hashes ), '%s' ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Placeholders are generated above. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT id, url_hash FROM %i WHERE url_hash IN ({$in})", array_merge( array( $table ), $hashes ) ) ); $ids = array(); foreach ( (array) $rows as $row ) { if ( isset( $by_hash[ $row->url_hash ] ) ) { $ids[ $by_hash[ $row->url_hash ] ] = (int) $row->id; } } return $ids; } /** * Multi-row insert of instance rows. * * @param array> $rows Rows: link_id, source_type, source_id, source_key, anchor_text, element, context. */ private function insert_instances( array $rows ): void { global $wpdb; $table = Schema::table( 'instances' ); foreach ( array_chunk( $rows, self::CHUNK ) as $chunk ) { $placeholders = array(); $values = array(); foreach ( $chunk as $row ) { $formats = array( '%d', '%s', '%d', '%s', '%s', '%s', '%s' ); $parts = array(); foreach ( $formats as $i => $format ) { // Nullable columns get a literal NULL instead of an empty string. if ( null === $row[ $i ] ) { $parts[] = 'NULL'; continue; } $parts[] = $format; $values[] = $row[ $i ]; } $placeholders[] = '(' . implode( ',', $parts ) . ')'; } // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Placeholders are generated above. $wpdb->query( $wpdb->prepare( 'INSERT INTO %i (link_id, source_type, source_id, source_key, anchor_text, element, context) VALUES ' . implode( ',', $placeholders ), array_merge( array( $table ), $values ) ) ); } } /** * Delete links that no longer have any instance. * * @param int[] $link_ids Candidate link IDs. */ public function delete_orphans( array $link_ids ): void { global $wpdb; $links = Schema::table( 'links' ); $instances = Schema::table( 'instances' ); foreach ( array_chunk( array_values( array_unique( array_map( 'intval', $link_ids ) ) ), self::CHUNK ) as $chunk ) { $in = implode( ',', array_fill( 0, count( $chunk ), '%d' ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- Placeholders are generated above. $wpdb->query( $wpdb->prepare( "DELETE l FROM %i l LEFT JOIN %i i ON i.link_id = l.id WHERE l.id IN ({$in}) AND i.id IS NULL", array_merge( array( $links, $instances ), $chunk ) ) ); } } }