checker = $checker; } /** * SQL condition on links l / urls u selecting this fixer's links (constant string). * * @return string */ abstract protected function condition(); /** * Builds one candidate from a query row. * * @param array $row Row with post_id, url, url_hash, redirect_to, status. * @return array */ abstract protected function to_item( array $row ); /** * Candidates from the link index. * * @param int $limit Maximum items. * @return array */ public function candidates( $limit ) { return array_map( array( $this, 'to_item' ), $this->rows( 0, '', $limit ) ); } /** * Current version of one candidate; key is "post_id:url_hash". * * @param string $key Key. * @return array|null */ public function find( $key ) { $parts = explode( ':', (string) $key ); if ( 2 !== count( $parts ) || ! ctype_digit( $parts[0] ) || ! preg_match( '/^[a-f0-9]{32}$/', $parts[1] ) ) { return null; } $rows = $this->rows( (int) $parts[0], $parts[1], 1 ); return $rows ? $this->to_item( $rows[0] ) : null; } /** * Whether the current user may edit the post. * * @param array $item Candidate. * @return bool */ public function can_apply( array $item ) { return current_user_can( 'edit_post', (int) $item['object_id'] ); } /** * Restores the previous content if the post still has the content this fixer wrote. * * @param array $change Change-log row. * @return true|WP_Error */ public function rollback( array $change ) { $post = get_post( $change['object_id'] ); if ( ! $post ) { return new WP_Error( 'missing', __( 'The post no longer exists.', 'orbitstem-seo' ) ); } if ( ! current_user_can( 'edit_post', $post->ID ) ) { return new WP_Error( 'permission', __( 'You cannot edit this post.', 'orbitstem-seo' ) ); } if ( md5( $post->post_content ) !== $change['state_after']['hash'] ) { return new WP_Error( 'conflict', __( 'The post was edited after this repair, so it was not rolled back. Restore an earlier revision from the post editor if needed.', 'orbitstem-seo' ) ); } $saved = $this->save_content( $post->ID, $change['state_before']['content'] ); return is_wp_error( $saved ) ? $saved : true; } /** * Matching rows from the link index, one per post and URL. * * @param int $post_id Only this post (0 for all). * @param string $hash Only this URL hash ('' for all). * @param int $limit Rows. * @return array */ protected function rows( $post_id, $hash, $limit ) { global $wpdb; $filter = $wpdb->prepare( ' AND ( %d = 0 OR l.source_id = %d ) AND ( %s = \'\' OR u.url_hash = %s )', (int) $post_id, (int) $post_id, (string) $hash, (string) $hash ); $from = $wpdb->prepare( 'FROM %i l INNER JOIN %i u ON u.url_hash = l.url_hash INNER JOIN %i p ON p.ID = l.source_id', Tables::links(), Tables::urls(), $wpdb->posts ); $where = $this->condition(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $from and $filter are prepared above; $where is a fixed string from the fixer class. return $wpdb->get_results( $wpdb->prepare( "SELECT DISTINCT l.source_id AS post_id, p.post_title, u.url, u.url_hash, u.redirect_to, u.status {$from} WHERE {$where} {$filter} ORDER BY l.source_id DESC LIMIT %d", (int) $limit ), ARRAY_A ); } /** * Saves new content without kses filtering: the change is a link edit made * by this code, so markup the post already had (e.g. embeds) must survive. * * @param int $post_id Post ID. * @param string $content New content. * @return int|WP_Error */ protected function save_content( $post_id, $content ) { $filtered = false !== has_filter( 'content_save_pre', 'wp_filter_post_kses' ); if ( $filtered ) { kses_remove_filters(); } $result = wp_update_post( wp_slash( array( 'ID' => (int) $post_id, 'post_content' => $content, ) ), true ); if ( $filtered ) { kses_init_filters(); } return $result; } /** * Normalized hrefs of all links in content. * * @param string $content Content. * @param string $base Page URL. * @return string[] */ protected function hrefs( $content, $base ) { $urls = array(); foreach ( Parser::extract( $content, $base ) as $item ) { if ( 'link' === $item['kind'] ) { $urls[] = $item['url']; } } return $urls; } /** * Points every link to $from at $to. * * @param string $content Content. * @param string $from Normalized URL to replace. * @param string $to New URL. * @param string $base Page URL for relative links. * @return array{0:string,1:int} New content and number of links changed. */ protected function retarget( $content, $from, $to, $base ) { $processor = new \WP_HTML_Tag_Processor( $content ); $changed = 0; while ( $processor->next_tag( 'A' ) ) { $href = $processor->get_attribute( 'href' ); if ( is_string( $href ) && Url::normalize( $href, $base ) === $from ) { // The #fragment never reaches the server, so the redirect cannot carry it: keep it. $fragment = false !== strpos( $href, '#' ) ? substr( $href, strpos( $href, '#' ) ) : ''; $processor->set_attribute( 'href', $to . ( false === strpos( $to, '#' ) ? $fragment : '' ) ); ++$changed; } } return array( $processor->get_updated_html(), $changed ); } /** * Removes links to $from, keeping their text and inner markup. Links in * HTML comments are left alone, and so are button links (removing the link * would leave an empty, invalid button block). * * @param string $content Content. * @param string $from Normalized URL. * @param string $base Page URL for relative links. * @return array{0:string,1:int,2:int} New content, links removed, button links skipped. */ protected function unwrap( $content, $from, $base ) { $changed = 0; $buttons = 0; $parts = preg_split( '/()/s', (string) $content, -1, PREG_SPLIT_DELIM_CAPTURE ); foreach ( $parts as $index => $part ) { if ( 0 === strpos( $part, '