resolution, per request. */ private $cache = array(); public function __construct() { $home = wp_parse_url( home_url( '/' ) ); $this->host = self::bare_host( isset( $home['host'] ) ? $home['host'] : '' ); $this->home_path = trailingslashit( isset( $home['path'] ) ? $home['path'] : '/' ); $uploads = wp_upload_dir( null, false ); $this->uploads_path = trailingslashit( (string) wp_parse_url( $uploads['baseurl'], PHP_URL_PATH ) ); } /** * Links in a piece of HTML. * * @param string $html HTML. * @return array List of { href, anchor }. */ public static function extract( $html ) { $links = array(); if ( ! is_string( $html ) || false === stripos( $html, ']*?href\s*=\s*(["\'])(.*?)\1[^>]*>(.*?)#is', $html, $matches, PREG_SET_ORDER ) ) { return $links; } foreach ( $matches as $match ) { $links[] = array( 'href' => html_entity_decode( trim( $match[2] ), ENT_QUOTES | ENT_HTML5, 'UTF-8' ), 'anchor' => trim( preg_replace( '/\s+/', ' ', wp_strip_all_tags( $match[3] ) ) ), ); } return $links; } /** * Resolve one link. Returns null for links Lynkora ignores. * * @param string $href Link as written. * @return array|null { url, target_id, status, new_url } */ public function resolve( $href ) { $url = $this->absolute_internal( $href ); if ( null === $url ) { return null; } if ( isset( $this->cache[ $url ] ) ) { return $this->cache[ $url ]; } $result = $this->lookup( $url ); $this->cache[ $url ] = $result; return $result; } /** * Absolute URL without fragment if the link is internal, else null. * * @param string $href Link. * @return string|null */ public function absolute_internal( $href ) { $href = trim( (string) $href ); if ( '' === $href || '#' === $href[0] || preg_match( '#^(mailto|tel|sms|javascript|data|ftp):#i', $href ) ) { return null; } // Shortcodes or template placeholders, not real URLs. if ( false !== strpos( $href, '[' ) || false !== strpos( $href, '{' ) ) { return null; } $href = preg_replace( '/#.*$/', '', $href ); if ( 0 === strpos( $href, '//' ) ) { $href = ( is_ssl() ? 'https:' : 'http:' ) . $href; } elseif ( 0 === strpos( $href, '/' ) ) { // Root-relative: join with the site's scheme and host (not the // home path, so it also works for sites in a subfolder). $home = wp_parse_url( home_url( '/' ) ); $href = $home['scheme'] . '://' . $home['host'] . ( isset( $home['port'] ) ? ':' . $home['port'] : '' ) . $href; } elseif ( ! preg_match( '#^https?://#i', $href ) ) { // Relative links like "about/" or "?p=12" are resolved against home. $href = home_url( '/' ) . ltrim( $href, '/' ); } $parts = wp_parse_url( $href ); if ( empty( $parts['host'] ) || self::bare_host( $parts['host'] ) !== $this->host ) { return null; } $path = isset( $parts['path'] ) ? $parts['path'] : '/'; // Admin, login, feeds and uploaded files are not content pages. if ( preg_match( '#/(wp-admin|wp-login\.php|wp-json|xmlrpc\.php|feed)(/|$)#', $path ) || 0 === strpos( $path, $this->uploads_path ) || preg_match( '#\.(jpe?g|png|gif|webp|avif|svg|pdf|zip|mp[34]|docx?|xlsx?|pptx?|css|js)$#i', $path ) ) { return null; } return $href; } /** * @param string $url Absolute internal URL. * @return array */ private function lookup( $url ) { $result = array( 'url' => $url, 'target_id' => 0, 'status' => '', 'new_url' => null, ); $path = (string) wp_parse_url( $url, PHP_URL_PATH ); $query = (string) wp_parse_url( $url, PHP_URL_QUERY ); // Homepage. if ( '' === $query && trailingslashit( $path ) === $this->home_path ) { $front = 'page' === get_option( 'show_on_front' ) ? (int) get_option( 'page_on_front' ) : 0; if ( $front ) { $result['target_id'] = $front; } else { $result['status'] = 'home'; } return $result; } $post_id = url_to_postid( $url ); if ( $post_id ) { $result['target_id'] = (int) $post_id; return $result; } $slug = sanitize_title( basename( untrailingslashit( $path ) ) ); if ( '' !== $slug ) { // Trashed posts get "__trashed" added to their slug, and WordPress // keeps the address they had. If several trashed posts had this // address, the most recently trashed one is the one linked to. $trashed = get_posts( array( 'post_type' => 'any', 'post_status' => 'trash', 'meta_key' => '_wp_desired_post_slug', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Only trashed posts carry this key. 'meta_value' => $slug, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value 'orderby' => 'modified', 'order' => 'DESC', 'posts_per_page' => 1, 'fields' => 'ids', 'suppress_filters' => true, ) ); if ( ! $trashed ) { $trashed = get_posts( array( 'name' => $slug . '__trashed', 'post_type' => 'any', 'post_status' => 'trash', 'posts_per_page' => 1, 'fields' => 'ids', 'suppress_filters' => true, ) ); } if ( $trashed ) { $result['target_id'] = (int) $trashed[0]; $result['status'] = 'trashed'; return $result; } // A draft, pending, scheduled or private post with this slug: // WordPress answers "not found" for it, but the real problem is // that it is not published. $unpublished = get_posts( array( 'name' => $slug, 'post_type' => 'any', 'post_status' => array( 'draft', 'pending', 'future', 'private' ), 'posts_per_page' => 1, 'fields' => 'ids', 'suppress_filters' => true, ) ); if ( ! $unpublished ) { $unpublished = $this->draft_by_title_slug( $slug ); } if ( $unpublished ) { $result['target_id'] = (int) $unpublished[0]; return $result; } // Address the post had before its slug was changed. (WordPress // keeps old addresses for posts, not for pages.) $old = get_posts( array( 'post_type' => 'any', 'post_status' => 'publish', 'meta_key' => '_wp_old_slug', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Same lookup WordPress core does in wp_old_slug_redirect(). 'meta_value' => $slug, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value 'posts_per_page' => 1, 'fields' => 'ids', 'suppress_filters' => true, ) ); if ( $old ) { $result['target_id'] = (int) $old[0]; $result['status'] = 'old_slug'; $result['new_url'] = get_permalink( $old[0] ); return $result; } // Old page address remembered by Lynkora (pages, which WordPress // itself does not redirect). if ( Lynkora_Settings::get()['redirects'] ) { $current = Lynkora_Redirects::current_url_for( $url ); if ( null !== $current ) { $result['target_id'] = (int) url_to_postid( $current ); $result['status'] = 'old_slug'; $result['new_url'] = $current; return $result; } } } // Archives, search, custom routes or a missing page: check it. $result['status'] = 'unchecked'; return $result; } /** * Check an address by requesting it from the site itself. * * @param string $url Absolute internal URL. * @return array { status, new_url } */ public static function check( $url ) { $args = array( 'timeout' => 10, 'redirection' => 0, // Core's own filter for requests a site makes to itself. 'sslverify' => apply_filters( 'https_local_ssl_verify', false ), // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound 'user-agent' => 'Lynkora link check; ' . home_url( '/' ), ); $response = wp_remote_head( $url, $args ); $code = is_wp_error( $response ) ? 0 : (int) wp_remote_retrieve_response_code( $response ); // Some routes do not answer HEAD requests properly. if ( 0 === $code || 405 === $code || 501 === $code ) { $response = wp_remote_get( $url, $args ); $code = is_wp_error( $response ) ? 0 : (int) wp_remote_retrieve_response_code( $response ); } if ( $code >= 200 && $code < 300 ) { return array( 'status' => 'ok', 'new_url' => null, ); } if ( $code >= 300 && $code < 400 ) { $location = (string) wp_remote_retrieve_header( $response, 'location' ); return array( 'status' => 'redirect', 'new_url' => $location ? WP_Http::make_absolute_url( $location, $url ) : null, ); } if ( 404 === $code || 410 === $code ) { return array( 'status' => 'not_found', 'new_url' => null, ); } return array( 'status' => 'error', 'new_url' => null, ); } /** @var array|null Slug made from the title => ID, for drafts that have no slug yet. */ private $draft_slugs = null; /** * WordPress gives a draft its slug only when it is first published, so * a link written as /team/ to a never-published "Team" draft can only be * matched by the slug its title would get. * * @param string $slug Slug from the link. * @return array Matching ID, or empty. */ private function draft_by_title_slug( $slug ) { if ( null === $this->draft_slugs ) { $this->draft_slugs = array(); $drafts = get_posts( array( 'post_type' => 'any', 'post_status' => array( 'draft', 'pending', 'future' ), 'posts_per_page' => 500, // phpcs:ignore WordPress.WP.PostsPerPage.posts_per_page_posts_per_page -- Once per request, IDs and titles of unpublished drafts only. 'orderby' => 'modified', 'suppress_filters' => true, ) ); foreach ( $drafts as $draft ) { if ( '' === $draft->post_name && '' !== $draft->post_title ) { $key = sanitize_title( $draft->post_title ); if ( ! isset( $this->draft_slugs[ $key ] ) ) { $this->draft_slugs[ $key ] = (int) $draft->ID; } } } } return isset( $this->draft_slugs[ $slug ] ) ? array( $this->draft_slugs[ $slug ] ) : array(); } private static function bare_host( $host ) { return preg_replace( '/^www\./i', '', strtolower( (string) $host ) ); } }