final_url ) || 'redirect' !== $link->status ) { return false; } $target = preg_replace( '#^https?://#i', '', (string) $link->final_url ); return ! preg_match( '#login|log-in|signin|sign-in|/auth|/oauth|/sso|consent|captcha|cookie#i', $target ); } /** * Hash used for the unique key. * * @param string $url Normalized URL. * @return string */ public static function hash( $url ) { return sha1( $url ); } /** * Returns the link ID for a URL, creating the row if needed. * * @param string $url Normalized absolute URL. * @return int */ public function get_or_create( $url ) { global $wpdb; $table = LinkTender_Install::links_table(); $hash = self::hash( $url ); $id = $wpdb->get_var( $wpdb->prepare( 'SELECT id FROM %i WHERE url_hash = %s', $table, $hash ) ); if ( $id ) { return (int) $id; } $host = wp_parse_url( $url, PHP_URL_HOST ); $now = current_time( 'mysql', true ); $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO %i (url, url_hash, host, status, next_check, created_at) VALUES (%s, %s, %s, 'pending', %s, %s)", $table, $url, $hash, strtolower( (string) $host ), $now, $now ) ); if ( $wpdb->insert_id ) { return (int) $wpdb->insert_id; } return (int) $wpdb->get_var( $wpdb->prepare( 'SELECT id FROM %i WHERE url_hash = %s', $table, $hash ) ); } /** * Fetches one link row. * * @param int $id Link ID. * @return object|null */ public function get( $id ) { global $wpdb; $table = LinkTender_Install::links_table(); return $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE id = %d', $table, $id ) ); } /** * Fetches a link row by URL. * * @param string $url URL. * @return object|null */ public function get_by_url( $url ) { global $wpdb; $table = LinkTender_Install::links_table(); return $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE url_hash = %s', $table, self::hash( self::normalize( $url ) ) ) ); } /** * Updates columns of a link. * * @param int $id Link ID. * @param array $data Column => value. * @return void */ public function update( $id, array $data ) { global $wpdb; $wpdb->update( LinkTender_Install::links_table(), $data, array( 'id' => (int) $id ) ); } /** * Replaces the instances of one source. * * @param string $source_type Source type (post, menu, widget, ...). * @param int $source_id Source ID (0 for widgets). * @param array $found List of arrays with url, raw_url, field, element, anchor. * @param string $field_like Optional: only replace instances whose field starts with this prefix. * @return void */ public function replace_instances( $source_type, $source_id, array $found, $field_like = '' ) { global $wpdb; $table = LinkTender_Install::instances_table(); if ( '' !== $field_like ) { $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE source_type = %s AND source_id = %d AND source_field LIKE %s', $table, $source_type, $source_id, $wpdb->esc_like( $field_like ) . '%' ) ); } else { $wpdb->delete( $table, array( 'source_type' => $source_type, 'source_id' => (int) $source_id, ) ); } $items = array(); foreach ( $found as $item ) { $key = $item['url'] . '|' . $item['field'] . '|' . $item['element'] . '|' . $item['raw_url']; if ( ! isset( $items[ $key ] ) ) { $items[ $key ] = $item; } } if ( empty( $items ) ) { return; } // Bulk: one lookup (plus one insert for new URLs) and one multi-row insert per source. $ids = $this->ids_for_urls( array_column( $items, 'url' ) ); $rows = array(); foreach ( $items as $item ) { if ( empty( $ids[ $item['url'] ] ) ) { continue; } $rows[] = array( (int) $ids[ $item['url'] ], (string) $source_type, (int) $source_id, substr( (string) $item['field'], 0, 191 ), substr( (string) $item['element'], 0, 20 ), mb_substr( (string) $item['anchor'], 0, 250 ), (string) $item['raw_url'], ); } foreach ( array_chunk( $rows, 200 ) as $chunk ) { $placeholders = implode( ',', array_fill( 0, count( $chunk ), '(%d, %s, %d, %s, %s, %s, %s)' ) ); // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Only groups of fixed placeholders are interpolated. $wpdb->query( $wpdb->prepare( "INSERT INTO %i (link_id, source_type, source_id, source_field, element, anchor, raw_url) VALUES {$placeholders}", array_merge( array( $table ), array_merge( ...$chunk ) ) ) ); } } /** * Link IDs for many URLs, creating missing rows in bulk. * * @param string[] $urls Normalized absolute URLs. * @return array URL => link ID. */ public function ids_for_urls( array $urls ) { global $wpdb; $table = LinkTender_Install::links_table(); $hashes = array(); foreach ( array_unique( $urls ) as $url ) { $hashes[ self::hash( $url ) ] = $url; } $ids = $this->ids_by_hash( array_keys( $hashes ) ); $missing = array_diff_key( $hashes, $ids ); if ( $missing ) { $now = current_time( 'mysql', true ); foreach ( array_chunk( $missing, 200, true ) as $chunk ) { $values = array( $table ); foreach ( $chunk as $hash => $url ) { array_push( $values, $url, $hash, strtolower( (string) wp_parse_url( $url, PHP_URL_HOST ) ), $now, $now ); } $placeholders = implode( ',', array_fill( 0, count( $chunk ), "(%s, %s, %s, 'pending', %s, %s)" ) ); // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Only groups of fixed placeholders are interpolated. $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO %i (url, url_hash, host, status, next_check, created_at) VALUES {$placeholders}", $values ) ); } $ids += $this->ids_by_hash( array_keys( $missing ) ); } $out = array(); foreach ( $hashes as $hash => $url ) { if ( isset( $ids[ $hash ] ) ) { $out[ $url ] = $ids[ $hash ]; } } return $out; } /** * Link IDs by URL hash. * * @param string[] $hashes Hashes. * @return array Hash => link ID. */ private function ids_by_hash( array $hashes ) { global $wpdb; $table = LinkTender_Install::links_table(); $ids = array(); foreach ( array_chunk( $hashes, 500 ) as $chunk ) { $placeholders = implode( ',', array_fill( 0, count( $chunk ), '%s' ) ); // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Only a list of %s placeholders is interpolated. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT id, url_hash FROM %i WHERE url_hash IN ({$placeholders})", array_merge( array( $table ), $chunk ) ) ); foreach ( $rows as $row ) { $ids[ $row->url_hash ] = (int) $row->id; } } return $ids; } /** * Removes every instance of one source type (for example all comments when comment scanning is turned off). * * @param string $source_type Source type. * @return void */ public function delete_source_type( $source_type ) { global $wpdb; $wpdb->delete( LinkTender_Install::instances_table(), array( 'source_type' => (string) $source_type ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin table. } /** * Removes all instances of a source. * * @param string $source_type Source type. * @param int $source_id Source ID. * @return void */ public function delete_source( $source_type, $source_id ) { $this->replace_instances( $source_type, $source_id, array() ); } /** * Instances of a link. * * @param int $link_id Link ID. * @return object[] */ public function instances( $link_id ) { global $wpdb; $table = LinkTender_Install::instances_table(); return $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i WHERE link_id = %d ORDER BY source_type, source_id', $table, $link_id ) ); } /** * Instances for many links at once, grouped by link ID. * * @param int[] $link_ids Link IDs. * @return array */ public function instances_for( array $link_ids ) { global $wpdb; $link_ids = array_filter( array_map( 'absint', $link_ids ) ); if ( empty( $link_ids ) ) { return array(); } $table = LinkTender_Install::instances_table(); $placeholders = implode( ',', array_fill( 0, count( $link_ids ), '%d' ) ); // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Only a list of %d placeholders is interpolated. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM %i WHERE link_id IN ({$placeholders}) ORDER BY id", array_merge( array( $table ), $link_ids ) ) ); $grouped = array(); foreach ( $rows as $row ) { $grouped[ (int) $row->link_id ][] = $row; } return $grouped; } /** * Counts instances per link. * * @param int[] $link_ids Link IDs. * @return array */ public function instance_counts( array $link_ids ) { global $wpdb; $link_ids = array_filter( array_map( 'absint', $link_ids ) ); if ( empty( $link_ids ) ) { return array(); } $table = LinkTender_Install::instances_table(); $placeholders = implode( ',', array_fill( 0, count( $link_ids ), '%d' ) ); // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Only a list of %d placeholders is interpolated. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT link_id, COUNT(*) AS c FROM %i WHERE link_id IN ({$placeholders}) GROUP BY link_id", array_merge( array( $table ), $link_ids ) ) ); $counts = array(); foreach ( $rows as $row ) { $counts[ (int) $row->link_id ] = (int) $row->c; } return $counts; } /** * Links that are due for a check. * * @param int $limit Max rows. * @param string[] $exclude_hosts Hosts to leave out (already at their per-batch limit). * @return object[] */ public function due( $limit, array $exclude_hosts = array() ) { global $wpdb; $table = LinkTender_Install::links_table(); $inst = LinkTender_Install::instances_table(); if ( empty( $exclude_hosts ) ) { return $wpdb->get_results( $wpdb->prepare( 'SELECT l.* FROM %i l WHERE l.dismissed = 0 AND l.next_check <= %s AND EXISTS (SELECT 1 FROM %i i WHERE i.link_id = l.id) ORDER BY l.next_check ASC LIMIT %d', $table, current_time( 'mysql', true ), $inst, $limit ) ); } $exclude_hosts = array_values( array_slice( array_map( 'strval', $exclude_hosts ), 0, 200 ) ); $placeholders = implode( ',', array_fill( 0, count( $exclude_hosts ), '%s' ) ); $params = array_merge( array( $table, current_time( 'mysql', true ) ), $exclude_hosts, array( $inst, $limit ) ); // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Only a list of %s placeholders is interpolated; values are passed as an array. return $wpdb->get_results( $wpdb->prepare( "SELECT l.* FROM %i l WHERE l.dismissed = 0 AND l.next_check <= %s AND l.host NOT IN ({$placeholders}) AND EXISTS (SELECT 1 FROM %i i WHERE i.link_id = l.id) ORDER BY l.next_check ASC LIMIT %d", $params ) ); } /** * Number of links due now. * * @return int */ public function count_due() { global $wpdb; $table = LinkTender_Install::links_table(); return (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE dismissed = 0 AND next_check <= %s', $table, current_time( 'mysql', true ) ) ); } /** * Deletes links that no longer appear anywhere (dismissed ones are kept so the choice sticks). * * @param int $limit Max rows per call. * @return int Rows deleted. */ public function purge_orphans( $limit = 500 ) { global $wpdb; $table = LinkTender_Install::links_table(); $inst = LinkTender_Install::instances_table(); $ids = $wpdb->get_col( $wpdb->prepare( 'SELECT l.id FROM %i l WHERE l.dismissed = 0 AND NOT EXISTS (SELECT 1 FROM %i i WHERE i.link_id = l.id) LIMIT %d', $table, $inst, $limit ) ); if ( empty( $ids ) ) { return 0; } $ids = array_map( 'absint', $ids ); $placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) ); // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Only a list of %d placeholders is interpolated. $wpdb->query( $wpdb->prepare( "DELETE FROM %i WHERE id IN ({$placeholders})", array_merge( array( $table ), $ids ) ) ); return count( $ids ); } /** * Counts per status for the list views. Only links that still appear somewhere are counted. * * @return array */ public function status_counts() { global $wpdb; $table = LinkTender_Install::links_table(); $inst = LinkTender_Install::instances_table(); $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT status, dismissed, COUNT(*) AS c FROM %i l WHERE EXISTS (SELECT 1 FROM %i i WHERE i.link_id = l.id) GROUP BY status, dismissed', $table, $inst ) ); $counts = array_fill_keys( array_merge( self::STATUSES, array( 'dismissed', 'all' ) ), 0 ); foreach ( $rows as $row ) { $counts['all'] += (int) $row->c; if ( (int) $row->dismissed ) { $counts['dismissed'] += (int) $row->c; continue; } if ( isset( $counts[ $row->status ] ) ) { $counts[ $row->status ] += (int) $row->c; } } $counts['problems'] = $counts['broken'] + $counts['redirect'] + $counts['unverified']; return $counts; } /** * Lists links for the admin table. * * @param array $args view, search, host, source_type, element, orderby, order, per_page, page. * @return array{items: object[], total: int} */ public function query( array $args ) { global $wpdb; $table = LinkTender_Install::links_table(); $inst = LinkTender_Install::instances_table(); $args = wp_parse_args( $args, array( 'view' => 'problems', 'search' => '', 'host' => '', 'source_type' => '', 'element' => '', 'orderby' => 'status', 'order' => 'ASC', 'per_page' => 30, 'page' => 1, ) ); $where = array( '1=1' ); $params = array(); switch ( $args['view'] ) { case 'problems': $where[] = "l.dismissed = 0 AND l.status IN ('broken','redirect','unverified')"; break; case 'dismissed': $where[] = 'l.dismissed = 1'; break; case 'all': break; default: if ( in_array( $args['view'], self::STATUSES, true ) ) { $where[] = 'l.dismissed = 0 AND l.status = %s'; $params[] = $args['view']; } } if ( '' !== $args['search'] ) { $where[] = 'l.url LIKE %s'; $params[] = '%' . $wpdb->esc_like( $args['search'] ) . '%'; } if ( '' !== $args['host'] ) { $where[] = 'l.host = %s'; $params[] = $args['host']; } $exists = array( 'i.link_id = l.id' ); $e_params = array(); if ( '' !== $args['source_type'] ) { $exists[] = 'i.source_type = %s'; $e_params[] = $args['source_type']; } if ( '' !== $args['element'] ) { $exists[] = 'i.element = %s'; $e_params[] = $args['element']; } $where[] = 'EXISTS (SELECT 1 FROM %i i WHERE ' . implode( ' AND ', $exists ) . ')'; $params = array_merge( $params, array( $inst ), $e_params ); $orderby_map = array( 'url' => 'l.url', 'status' => "FIELD(l.status,'broken','unverified','redirect','pending','ok'), l.http_code", 'last_checked' => 'l.last_checked', 'host' => 'l.host', ); $orderby = isset( $orderby_map[ $args['orderby'] ] ) ? $orderby_map[ $args['orderby'] ] : $orderby_map['status']; $order = 'DESC' === strtoupper( $args['order'] ) ? 'DESC' : 'ASC'; $per_page = max( 1, (int) $args['per_page'] ); $offset = max( 0, ( (int) $args['page'] - 1 ) * $per_page ); $where_sql = implode( ' AND ', $where ); $count_sql = "SELECT COUNT(*) FROM %i l WHERE {$where_sql}"; $list_sql = "SELECT l.* FROM %i l WHERE {$where_sql} ORDER BY {$orderby} {$order}, l.id ASC LIMIT %d OFFSET %d"; $params = array_merge( array( $table ), $params ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Built only from fixed SQL fragments; every value is a placeholder. $total = (int) $wpdb->get_var( $wpdb->prepare( $count_sql, $params ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Built only from fixed SQL fragments; every value is a placeholder. $items = $wpdb->get_results( $wpdb->prepare( $list_sql, array_merge( $params, array( $per_page, $offset ) ) ) ); return array( 'items' => $items, 'total' => $total, ); } /** * Hosts with problem links, for the filter dropdown. * * @return string[] */ public function problem_hosts() { global $wpdb; $table = LinkTender_Install::links_table(); return $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT host FROM %i WHERE dismissed = 0 AND status IN ('broken','redirect','unverified') ORDER BY host LIMIT 200", $table ) ); } /** * Broken URLs that appear in a post, for front-end marking. * * @param int $post_id Post ID. * @return string[] Raw URLs as written in the content. */ public function broken_raw_urls_in_post( $post_id ) { global $wpdb; $table = LinkTender_Install::links_table(); $inst = LinkTender_Install::instances_table(); return $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT i.raw_url FROM %i i INNER JOIN %i l ON l.id = i.link_id WHERE i.source_type = 'post' AND i.source_id = %d AND i.element = 'a' AND l.status = 'broken' AND l.dismissed = 0", $inst, $table, $post_id ) ); } /** * Table sizes for the health screen. * * @return array */ public function table_stats() { global $wpdb; $stats = array(); // Sizes come from information_schema, which not every database offers (e.g. SQLite): stay quiet if missing. $suppress = $wpdb->suppress_errors( true ); foreach ( array( LinkTender_Install::links_table(), LinkTender_Install::instances_table() ) as $table ) { $row = $wpdb->get_row( $wpdb->prepare( 'SELECT TABLE_ROWS AS row_count, DATA_LENGTH + INDEX_LENGTH AS bytes FROM information_schema.TABLES WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s', DB_NAME, $table ) ); $stats[ $table ] = array( 'rows' => (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i', $table ) ), 'bytes' => $row ? (int) $row->bytes : 0, ); } $wpdb->suppress_errors( $suppress ); return $stats; } /** * Schedules links for an immediate recheck. * * @param int[] $ids Link IDs. * @return void */ public function recheck( array $ids ) { foreach ( array_map( 'absint', $ids ) as $id ) { $this->update( $id, array( 'next_check' => '2000-01-01 00:00:00', 'fail_count' => 0, 'first_failure' => null, ) ); } } /** * Marks links as fine (dismissed) or restores them. * * @param int[] $ids Link IDs. * @param bool $dismissed New flag. * @return void */ public function set_dismissed( array $ids, $dismissed ) { foreach ( array_map( 'absint', $ids ) as $id ) { $this->update( $id, array( 'dismissed' => $dismissed ? 1 : 0 ) ); } } }