links = $links; } /** * Registers content-change hooks. * * Nothing is tracked until an administrator has started checking: the first full scan * reads all content anyway. * * @return void */ public function hooks() { if ( ! LinkTender_Install::is_installed() ) { return; } // Deleted content is removed from the plugin's own tables at once (local only). add_action( 'deleted_post', array( $this, 'forget_post' ) ); add_action( 'deleted_comment', array( $this, 'forget_comment' ) ); if ( ! LinkTender_Install::checking_started() ) { return; } add_action( 'save_post', array( $this, 'mark_post' ), 20 ); add_action( 'added_post_meta', array( $this, 'meta_changed' ), 10, 2 ); add_action( 'updated_post_meta', array( $this, 'meta_changed' ), 10, 2 ); add_action( 'deleted_post_meta', array( $this, 'meta_changed' ), 10, 2 ); add_action( 'updated_option', array( $this, 'option_changed' ) ); add_action( 'added_option', array( $this, 'option_changed' ) ); add_action( 'wp_insert_comment', array( $this, 'mark_comment' ) ); add_action( 'edit_comment', array( $this, 'mark_comment' ) ); add_action( 'wp_set_comment_status', array( $this, 'mark_comment' ) ); add_action( 'shutdown', array( $this, 'flush_dirty' ) ); } /** * Starts a new full scan from the beginning. * * @return void */ public static function request_full_scan() { update_option( self::STATE_OPTION, array( 'phase' => 'posts', 'cursor' => 0, 'started' => time(), 'finished' => 0, 'parsed' => 0, ), false ); } /** * Current scan state. * * @return array */ public static function state() { $state = get_option( self::STATE_OPTION, array() ); return wp_parse_args( is_array( $state ) ? $state : array(), array( 'phase' => 'done', 'cursor' => 0, 'started' => 0, 'finished' => 0, 'parsed' => 0, ) ); } /** * Whether a full scan is in progress. * * @return bool */ public static function is_running() { return 'done' !== self::state()['phase']; } /** * Advances the full scan until the deadline. * * @param float $deadline microtime(true) deadline. * @return bool True when the scan is finished. */ public function run( $deadline ) { $state = self::state(); if ( 'done' === $state['phase'] ) { return true; } /** * Filters how many posts a single batch reads during a full scan. Keeps the number of * database queries and the memory used by one background request small on large sites. * * @param int $max Posts per batch. */ $max_posts = max( 1, (int) apply_filters( 'linktender_scan_posts_per_batch', 250 ) ); $posts = 0; while ( microtime( true ) < $deadline && 'done' !== $state['phase'] ) { switch ( $state['phase'] ) { case 'posts': if ( $posts >= $max_posts ) { break 2; } $ids = $this->next_post_ids( (int) $state['cursor'], min( 50, $max_posts - $posts ) ); if ( empty( $ids ) ) { $state['phase'] = 'comments'; $state['cursor'] = 0; break; } // Load the posts and their custom fields in two queries instead of two per post. _prime_post_caches( $ids, false, true ); foreach ( $ids as $id ) { $this->parse_post( $id ); $state['cursor'] = $id; ++$state['parsed']; ++$posts; if ( microtime( true ) >= $deadline ) { break; } } break; case 'comments': if ( $posts >= $max_posts ) { break 2; } $ids = $this->next_comment_ids( (int) $state['cursor'], min( 100, $max_posts - $posts ) ); if ( 0 === (int) $state['cursor'] && ! LinkTender_Settings::get( 'scan_comments' ) && ! LinkTender_Settings::get( 'scan_comment_urls' ) ) { $this->links->delete_source_type( 'comment' ); } if ( empty( $ids ) ) { $state['phase'] = 'menus'; $state['cursor'] = 0; break; } _prime_comment_caches( $ids, false ); foreach ( $ids as $id ) { $this->parse_comment( $id ); $state['cursor'] = $id; ++$posts; if ( microtime( true ) >= $deadline ) { break; } } break; case 'menus': $ids = $this->next_menu_item_ids( (int) $state['cursor'], 100 ); if ( empty( $ids ) ) { $state['phase'] = 'widgets'; $state['cursor'] = 0; break; } foreach ( $ids as $id ) { $this->parse_menu_item( $id ); $state['cursor'] = $id; } break; case 'widgets': $this->parse_widgets(); $state['phase'] = 'extra'; break; case 'extra': /** * Lets add-ons scan their own sources during a full scan. * Use $links->replace_instances() with a custom source_type. * * @param LinkTender_Links $links Repository. */ do_action( 'linktender_scan_extra_sources', $this->links ); $state['phase'] = 'done'; $state['finished'] = time(); $this->links->purge_orphans( 5000 ); break; default: $state['phase'] = 'done'; } } update_option( self::STATE_OPTION, $state, false ); if ( 'done' === $state['phase'] ) { /** * Fires when a full content scan has finished. * * @param array $state Scan state. */ do_action( 'linktender_scan_complete', $state ); return true; } return false; } /** * Next batch of post IDs to parse. * * @param int $after Last ID. * @param int $limit Max. * @return int[] */ private function next_post_ids( $after, $limit ) { $settings = LinkTender_Settings::all(); $types = array_values( (array) $settings['post_types'] ); if ( empty( $types ) ) { return array(); } global $wpdb; $type_ph = implode( ',', array_fill( 0, count( $types ), '%s' ) ); $statuses = array_values( (array) $settings['post_statuses'] ); $status_ph = implode( ',', array_fill( 0, count( $statuses ), '%s' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Read-only ID cursor over posts; placeholders built above. $ids = $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE ID > %d AND post_type IN ({$type_ph}) AND post_status IN ({$status_ph}) ORDER BY ID ASC LIMIT %d", array_merge( array( $after ), $types, $statuses, array( $limit ) ) ) ); return array_map( 'intval', $ids ); } /** * Next batch of approved comment IDs (pingbacks and trackbacks are left out). * * @param int $after Last ID. * @param int $limit Max. * @return int[] */ private function next_comment_ids( $after, $limit ) { $settings = LinkTender_Settings::all(); if ( empty( $settings['scan_comments'] ) && empty( $settings['scan_comment_urls'] ) ) { return array(); } global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Read-only ID cursor. $ids = $wpdb->get_col( $wpdb->prepare( "SELECT comment_ID FROM {$wpdb->comments} WHERE comment_ID > %d AND comment_approved = '1' AND comment_type IN ('comment', '') ORDER BY comment_ID ASC LIMIT %d", $after, $limit ) ); return array_map( 'intval', $ids ); } /** * Parses one comment and stores its links. Only approved comments are indexed. * * @param int $comment_id Comment ID. * @return void */ public function parse_comment( $comment_id ) { $comment = get_comment( $comment_id ); $settings = LinkTender_Settings::all(); if ( ! $comment || '1' !== (string) $comment->comment_approved || ! in_array( $comment->comment_type, array( 'comment', '' ), true ) ) { $this->links->delete_source( 'comment', $comment_id ); return; } $base = get_permalink( (int) $comment->comment_post_ID ); $extractor = ( new LinkTender_Extractor() )->with_base( $base ? $base : home_url( '/' ) ); $found = array(); if ( ! empty( $settings['scan_comments'] ) ) { // Comment text is HTML; shortcodes are not expanded in comments, so only HTML and bare URLs count. $found = $extractor->from_html( $comment->comment_content, 'comment_content' ); $found = array_merge( $found, $this->bare_urls( $extractor, $comment->comment_content ) ); $found = $extractor->finish( $found, 'comment_content' ); } if ( ! empty( $settings['scan_comment_urls'] ) && '' !== trim( $comment->comment_author_url ) ) { foreach ( $extractor->from_value( $comment->comment_author_url, 'comment_author_url' ) as $item ) { $item['element'] = 'author'; $item['anchor'] = $comment->comment_author; $found[] = $item; } } /** * Filters the links found in one comment before they are saved. * * @param array[] $found Items. * @param WP_Comment $comment Comment. */ $found = apply_filters( 'linktender_comment_links', $found, $comment ); $this->links->replace_instances( 'comment', $comment_id, $found ); } /** * Plain-text URLs in comment text, which WordPress turns into links when it shows the comment. * * @param LinkTender_Extractor $extractor Extractor. * @param string $text Comment text. * @return array[] */ private function bare_urls( LinkTender_Extractor $extractor, $text ) { $items = array(); // Drop tags first so URLs inside attributes are not counted twice. $plain = wp_strip_all_tags( (string) $text ); if ( preg_match_all( '#(?"\']+#i', $plain, $m ) ) { foreach ( $m[0] as $url ) { $url = rtrim( $url, '.,;:!?)' ); $items[] = array( 'raw_url' => $url, 'field' => 'comment_content', 'element' => 'a', 'anchor' => '', ); } } return $items; } /** * Next batch of menu item IDs. * * @param int $after Last ID. * @param int $limit Max. * @return int[] */ private function next_menu_item_ids( $after, $limit ) { if ( ! LinkTender_Settings::get( 'scan_menus' ) ) { return array(); } global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Read-only ID cursor. $ids = $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE ID > %d AND post_type = 'nav_menu_item' AND post_status = 'publish' ORDER BY ID ASC LIMIT %d", $after, $limit ) ); return array_map( 'intval', $ids ); } /** * Parses one post (any type) and stores its links. * * @param int $post_id Post ID. * @return void */ public function parse_post( $post_id ) { $post = get_post( $post_id ); if ( ! $post ) { $this->links->delete_source( 'post', $post_id ); $this->links->delete_source( 'menu', $post_id ); return; } if ( 'nav_menu_item' === $post->post_type ) { $this->parse_menu_item( $post_id ); return; } $settings = LinkTender_Settings::all(); if ( ! in_array( $post->post_type, (array) $settings['post_types'], true ) || ! in_array( $post->post_status, (array) $settings['post_statuses'], true ) ) { $this->links->delete_source( 'post', $post_id ); return; } $base = get_permalink( $post ); $extractor = ( new LinkTender_Extractor() )->with_base( $base ? $base : home_url( '/' ) ); $found = $extractor->from_content( $post->post_content, 'post_content' ); if ( ! empty( $settings['scan_excerpts'] ) && '' !== $post->post_excerpt ) { $found = array_merge( $found, $extractor->from_content( $post->post_excerpt, 'post_excerpt' ) ); } if ( ! empty( $settings['scan_custom_fields'] ) ) { $found = array_merge( $found, $this->parse_post_meta( $post_id, $extractor ) ); } /** * Filters the links found in one post before they are saved. * * @param array[] $found Items. * @param WP_Post $post Post. */ $found = apply_filters( 'linktender_post_links', $found, $post ); $this->links->replace_instances( 'post', $post_id, $found ); } /** * Custom fields of a post. * * @param int $post_id Post ID. * @param LinkTender_Extractor $extractor Extractor. * @return array[] */ private function parse_post_meta( $post_id, LinkTender_Extractor $extractor ) { $found = array(); $meta = get_post_meta( $post_id ); if ( ! is_array( $meta ) ) { return $found; } /** * Filters which protected (underscore) meta keys are scanned. * * @param string[] $keys Meta keys. */ $protected_allowed = apply_filters( 'linktender_protected_meta_keys', array( '_elementor_data' ) ); /** * Filters meta keys that are never scanned. * * @param string[] $keys Meta keys. */ $skip = apply_filters( 'linktender_skip_meta_keys', array( 'enclosure', 'classic-editor-remember' ) ); foreach ( $meta as $key => $values ) { if ( in_array( $key, $skip, true ) ) { continue; } if ( '_' === substr( $key, 0, 1 ) && ! in_array( $key, $protected_allowed, true ) ) { continue; } foreach ( (array) $values as $raw ) { if ( '_elementor_data' === $key ) { $found = array_merge( $found, $extractor->from_elementor( $raw, 'meta:' . $key ) ); continue; } $found = array_merge( $found, $extractor->from_value( maybe_unserialize( $raw ), 'meta:' . $key ) ); } } return $found; } /** * Parses a custom-link menu item. * * @param int $item_id Menu item post ID. * @return void */ public function parse_menu_item( $item_id ) { if ( ! LinkTender_Settings::get( 'scan_menus' ) || 'publish' !== get_post_status( $item_id ) ) { $this->links->delete_source( 'menu', $item_id ); return; } $found = array(); if ( 'custom' === get_post_meta( $item_id, '_menu_item_type', true ) ) { $url = get_post_meta( $item_id, '_menu_item_url', true ); $items = ( new LinkTender_Extractor() )->with_base( home_url( '/' ) )->from_value( $url, '_menu_item_url' ); foreach ( $items as $item ) { $item['element'] = 'menu'; $item['anchor'] = get_the_title( $item_id ); $found[] = $item; } } $this->links->replace_instances( 'menu', $item_id, $found ); } /** * Parses all active widgets. * * @return void */ public function parse_widgets() { $found = array(); if ( LinkTender_Settings::get( 'scan_widgets' ) ) { $extractor = ( new LinkTender_Extractor() )->with_base( home_url( '/' ) ); foreach ( $this->active_widget_ids() as $widget_id ) { if ( ! preg_match( '/^(.+)-(\d+)$/', $widget_id, $m ) ) { continue; } $option = get_option( 'widget_' . $m[1] ); if ( ! is_array( $option ) || ! isset( $option[ (int) $m[2] ] ) ) { continue; } $found = array_merge( $found, $extractor->from_value( $option[ (int) $m[2] ], 'widget:' . $widget_id ) ); } } $this->links->replace_instances( 'widget', 0, $found ); } /** * IDs of widgets placed in a sidebar. * * @return string[] */ private function active_widget_ids() { $sidebars = get_option( 'sidebars_widgets', array() ); $ids = array(); if ( ! is_array( $sidebars ) ) { return $ids; } foreach ( $sidebars as $sidebar => $widgets ) { if ( 'wp_inactive_widgets' === $sidebar || ! is_array( $widgets ) ) { continue; } $ids = array_merge( $ids, $widgets ); } return array_unique( $ids ); } /** * Queues a post for re-parsing. * * @param int $post_id Post ID. * @return void */ public function mark_post( $post_id ) { if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) { return; } $this->dirty_posts[ (int) $post_id ] = true; } /** * Post meta changed. * * @param int|int[] $meta_id Meta ID(s). * @param int $post_id Post ID. * @return void */ public function meta_changed( $meta_id, $post_id ) { $this->mark_post( (int) $post_id ); } /** * Forgets a deleted post. * * @param int $post_id Post ID. * @return void */ public function forget_post( $post_id ) { unset( $this->dirty_posts[ (int) $post_id ] ); $this->links->delete_source( 'post', $post_id ); $this->links->delete_source( 'menu', $post_id ); } /** * Queues a comment for re-parsing. * * @param int $comment_id Comment ID. * @return void */ public function mark_comment( $comment_id ) { $this->dirty_comments[ (int) $comment_id ] = true; } /** * Forgets a deleted comment. * * @param int $comment_id Comment ID. * @return void */ public function forget_comment( $comment_id ) { unset( $this->dirty_comments[ (int) $comment_id ] ); $this->links->delete_source( 'comment', $comment_id ); } /** * Widget option changed. * * @param string $option Option name. * @return void */ public function option_changed( $option ) { if ( 'sidebars_widgets' === $option || 0 === strpos( $option, 'widget_' ) ) { $this->dirty_widgets = true; } } /** * Re-parses everything that changed during this request. * * In the admin, REST API, WP-CLI and background (cron) requests this happens at the end of * the request. Front-end requests (for example a visitor's comment, or WordPress creating a * default navigation menu on the first page view) only remember what changed; the next * background batch parses it. A front-end page view never parses content itself. * * @return void */ public function flush_dirty() { if ( empty( $this->dirty_posts ) && ! $this->dirty_widgets && empty( $this->dirty_comments ) ) { return; } $posts = array_keys( $this->dirty_posts ); $comments = array_keys( $this->dirty_comments ); $widgets = $this->dirty_widgets; $this->dirty_posts = array(); $this->dirty_widgets = false; $this->dirty_comments = array(); if ( ! LinkTender_Install::is_installed() || ! LinkTender_Install::checking_started() ) { return; } if ( ! self::is_backend_request() ) { self::remember_pending( $posts, $comments, $widgets ); return; } try { $this->parse_changes( $posts, $comments, $widgets ); } catch ( Throwable $e ) { // Never break the request that saved the content: let the background scan catch up. self::request_full_scan(); } } /** * Parses changed posts, comments and widgets. * * @param int[] $posts Post IDs. * @param int[] $comments Comment IDs. * @param bool $widgets Whether widgets changed. * @return void */ private function parse_changes( array $posts, array $comments, $widgets ) { if ( count( $posts ) > 50 ) { // Bulk import: let the background scan pick everything up instead. self::request_full_scan(); $posts = array(); } foreach ( $posts as $id ) { $this->parse_post( (int) $id ); } if ( $widgets ) { $this->parse_widgets(); } foreach ( array_slice( $comments, 0, 50 ) as $comment_id ) { $this->parse_comment( (int) $comment_id ); } } /** * Whether this request may parse content right away (admin, REST API, WP-CLI or cron). * * @return bool */ public static function is_backend_request() { return is_admin() || wp_doing_cron() || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) || ( defined( 'WP_CLI' ) && WP_CLI ); } /** * Stores content changes from a front-end request for the next background batch. * * @param int[] $posts Post IDs. * @param int[] $comments Comment IDs. * @param bool $widgets Whether widgets changed. * @return void */ private static function remember_pending( array $posts, array $comments, $widgets ) { $pending = self::pending(); $pending['posts'] = array_values( array_unique( array_merge( $pending['posts'], array_map( 'intval', $posts ) ) ) ); $pending['comments'] = array_values( array_unique( array_merge( $pending['comments'], array_map( 'intval', $comments ) ) ) ); $pending['widgets'] = $pending['widgets'] || $widgets; if ( count( $pending['posts'] ) > self::PENDING_MAX || count( $pending['comments'] ) > self::PENDING_MAX ) { delete_option( self::PENDING_OPTION ); self::request_full_scan(); return; } update_option( self::PENDING_OPTION, $pending, false ); } /** * Content changes waiting for the next background batch. * * @return array{posts: int[], comments: int[], widgets: bool} */ public static function pending() { $pending = get_option( self::PENDING_OPTION, array() ); $pending = is_array( $pending ) ? $pending : array(); return array( 'posts' => isset( $pending['posts'] ) ? array_map( 'intval', (array) $pending['posts'] ) : array(), 'comments' => isset( $pending['comments'] ) ? array_map( 'intval', (array) $pending['comments'] ) : array(), 'widgets' => ! empty( $pending['widgets'] ), ); } /** * Parses content changes remembered from front-end requests (called by background batches). * * @return void */ public function process_pending() { if ( false === get_option( self::PENDING_OPTION, false ) ) { return; } $pending = self::pending(); delete_option( self::PENDING_OPTION ); $this->parse_changes( $pending['posts'], $pending['comments'], $pending['widgets'] ); } }