) URL. * Returns null for values that are not web addresses (mailto:, tel:, javascript:, data:, #anchor). * * @param string $value Attribute value. * @param string $base Absolute base URL. */ public static function resolve( string $value, string $base ): ?string { $value = trim( html_entity_decode( $value, ENT_QUOTES | ENT_HTML5, 'UTF-8' ) ); if ( '' === $value || str_starts_with( $value, '#' ) ) { return null; } if ( 1 === preg_match( '/^([a-z][a-z0-9+.-]*):/i', $value, $m ) ) { return in_array( strtolower( $m[1] ), array( 'http', 'https' ), true ) ? self::normalize( $value ) : null; } $b = wp_parse_url( $base ); if ( ! is_array( $b ) || ! isset( $b['scheme'], $b['host'] ) ) { return null; } $origin = $b['scheme'] . '://' . $b['host'] . ( isset( $b['port'] ) ? ':' . $b['port'] : '' ); if ( str_starts_with( $value, '//' ) ) { return self::normalize( $b['scheme'] . ':' . $value ); } if ( str_starts_with( $value, '/' ) ) { return self::normalize( $origin . $value ); } if ( str_starts_with( $value, '?' ) ) { return self::normalize( $origin . ( $b['path'] ?? '/' ) . $value ); } $dir = $b['path'] ?? '/'; $dir = str_ends_with( $dir, '/' ) ? $dir : substr( $dir, 0, (int) strrpos( $dir, '/' ) + 1 ); return self::normalize( $origin . $dir . $value ); } /** * Normalises a URL: lower-case scheme and host, no fragment, resolved dot segments, * empty path as "/". Query strings are kept as they are. * * @param string $url Absolute URL. */ public static function normalize( string $url ): ?string { $p = wp_parse_url( $url ); if ( ! is_array( $p ) || ! isset( $p['scheme'], $p['host'] ) ) { return null; } $path = self::remove_dots( $p['path'] ?? '/' ); return strtolower( $p['scheme'] ) . '://' . strtolower( $p['host'] ) . ( isset( $p['port'] ) ? ':' . $p['port'] : '' ) . ( '' === $path ? '/' : $path ) . ( isset( $p['query'] ) && '' !== $p['query'] ? '?' . $p['query'] : '' ); } /** * Whether a URL is on one of the site's hosts. * * @param string $url Absolute URL. * @param string[] $hosts Site hosts, lower case. */ public static function is_internal( string $url, array $hosts ): bool { $host = wp_parse_url( $url, PHP_URL_HOST ); return is_string( $host ) && in_array( strtolower( $host ), $hosts, true ); } /** * Removes "." and ".." path segments (RFC 3986, 5.2.4). * * @param string $path Path. */ private static function remove_dots( string $path ): string { $segments = explode( '/', $path ); $last = count( $segments ) - 1; $out = array(); foreach ( $segments as $i => $segment ) { if ( '.' === $segment || '..' === $segment ) { if ( '..' === $segment && count( $out ) > 1 ) { array_pop( $out ); } if ( $i === $last ) { $out[] = ''; } continue; } $out[] = $segment; } $result = implode( '/', $out ); return str_starts_with( $result, '/' ) ? $result : '/' . $result; } }