= 30 ) {
return null;
}
set_transient( 'spacebay_lp_fetches', $fetches + 1, MINUTE_IN_SECONDS );
$preview = self::fetch( $url );
set_transient( $key, $preview ? $preview : array(), $preview ? DAY_IN_SECONDS : HOUR_IN_SECONDS );
return $preview;
}
/**
* Recognises YouTube and Vimeo addresses.
*
* @param string $url URL.
* @return array|null
*/
public static function video( $url ) {
if ( preg_match( '#^https?://(?:www\.|m\.)?(?:youtube\.com/(?:watch\?(?:.*&)?v=|shorts/|embed/)|youtu\.be/)([A-Za-z0-9_-]{11})#', $url, $parts ) ) {
return array(
'type' => 'video',
'provider' => 'youtube',
'id' => $parts[1],
'url' => $url,
);
}
if ( preg_match( '#^https?://(?:www\.)?vimeo\.com/(\d{5,12})#', $url, $parts ) ) {
return array(
'type' => 'video',
'provider' => 'vimeo',
'id' => $parts[1],
'url' => $url,
);
}
return null;
}
/**
* Reads Open Graph data from a page.
*
* @param string $url URL.
* @return array|null
*/
private static function fetch( $url ) {
$response = null;
// The connection is pinned to the checked address with cURL; without
// cURL there is no way to pin it, so nothing is fetched.
if ( ! function_exists( 'curl_init' ) ) {
return null;
}
// Through a proxy the proxy resolves the host itself, so the pin does
// not hold: nothing is fetched.
if ( defined( 'WP_PROXY_HOST' ) ) {
return null;
}
// Redirects are followed one by one so every address is checked.
for ( $hop = 0; $hop <= 3; $hop++ ) {
$ip = self::public_ip( $url );
if ( '' === $ip ) {
return null;
}
// Resolve once: the request connects to the address that was checked,
// so a DNS answer that changes in between (rebinding) cannot reach
// a private server.
$parts = wp_parse_url( $url );
$port = isset( $parts['port'] ) ? (int) $parts['port'] : ( 'https' === strtolower( (string) $parts['scheme'] ) ? 443 : 80 );
$pin = strtolower( trim( (string) $parts['host'], '[]' ) ) . ':' . $port . ':' . ( false !== strpos( $ip, ':' ) ? '[' . $ip . ']' : $ip );
$pinned = false;
$hook = static function ( $handle ) use ( $pin, &$pinned ) {
curl_setopt( $handle, CURLOPT_RESOLVE, array( $pin ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_setopt -- Pins the connection to the checked address.
// No proxy from the environment (http_proxy, https_proxy) either:
// a proxy would resolve the host itself.
curl_setopt( $handle, CURLOPT_PROXY, '' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_setopt -- Keeps the connection direct so the pin holds.
$pinned = true;
};
add_action( 'http_api_curl', $hook );
$response = wp_safe_remote_get(
$url,
array(
'timeout' => 4,
'redirection' => 0,
'limit_response_size' => 300 * KB_IN_BYTES,
'user-agent' => 'Mozilla/5.0 (compatible; SpaceBay/' . SPACEBAY_VERSION . '; +' . home_url( '/' ) . ')',
'headers' => array( 'Accept' => 'text/html' ),
)
);
remove_action( 'http_api_curl', $hook );
// The pin only holds when WordPress sent the request through cURL
// (a proxy is refused above). Another transport or a plugin that
// answered the request itself skips the hook: the answer may come from
// an unchecked address, so it is not used.
if ( ! $pinned ) {
return null;
}
$code = is_wp_error( $response ) ? 0 : (int) wp_remote_retrieve_response_code( $response );
if ( $code < 300 || $code > 399 ) {
break;
}
$location = (string) wp_remote_retrieve_header( $response, 'location' );
$url = esc_url_raw( \WP_Http::make_absolute_url( $location, $url ), array( 'http', 'https' ) );
if ( '' === $url ) {
return null;
}
}
if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
return null;
}
$type = (string) wp_remote_retrieve_header( $response, 'content-type' );
if ( '' !== $type && false === stripos( $type, 'html' ) ) {
return null;
}
$html = (string) wp_remote_retrieve_body( $response );
$title = self::meta( $html, 'og:title' );
if ( '' === $title && preg_match( '#
]*>(.*?)#is', $html, $parts ) ) {
$title = $parts[1];
}
$title = mb_substr( sanitize_text_field( html_entity_decode( $title, ENT_QUOTES, 'UTF-8' ) ), 0, 200 );
if ( '' === $title ) {
return null;
}
$description = self::meta( $html, 'og:description' );
if ( '' === $description ) {
$description = self::meta( $html, 'description' );
}
// Only secure images on public servers. Members' browsers load this image
// from its own server when the preview is shown (see readme, External services).
$image = esc_url_raw( self::meta( $html, 'og:image' ), array( 'https' ) );
$image = ( 0 === strpos( $image, 'https://' ) && self::is_public_url( $image ) ) ? $image : '';
return array(
'type' => 'link',
'url' => $url,
'host' => (string) wp_parse_url( $url, PHP_URL_HOST ),
'title' => $title,
'description' => mb_substr( sanitize_text_field( html_entity_decode( $description, ENT_QUOTES, 'UTF-8' ) ), 0, 300 ),
'image' => $image,
);
}
/**
* Reads one meta tag value by property or name.
*
* @param string $html Page HTML.
* @param string $name Property or name.
* @return string
*/
private static function meta( $html, $name ) {
$quoted = preg_quote( $name, '#' );
if ( preg_match( '#]+(?:property|name)=["\']' . $quoted . '["\'][^>]*content=["\']([^"\']*)["\']#i', $html, $parts )
|| preg_match( '#]+content=["\']([^"\']*)["\'][^>]*(?:property|name)=["\']' . $quoted . '["\']#i', $html, $parts ) ) {
return trim( $parts[1] );
}
return '';
}
/**
* Whether a URL points to a public web server: not this site, not a
* private, reserved, link-local or shared address, and on port 80 or 443.
*
* @param string $url URL.
* @return bool
*/
public static function is_public_url( $url ) {
return '' !== self::public_ip( $url );
}
/**
* Resolves a URL's host once and returns the address to connect to, or an
* empty string when the URL is not allowed: not on port 80 or 443, this
* site, a host that does not resolve, or any resolved address (IPv4 or IPv6)
* that is not a public one.
*
* @param string $url URL.
* @return string IP address.
*/
public static function public_ip( $url ) {
$parts = wp_parse_url( $url );
$host = isset( $parts['host'] ) ? strtolower( trim( $parts['host'], '[]' ) ) : '';
$port = isset( $parts['port'] ) ? (int) $parts['port'] : 0;
if ( '' === $host || ( $port && ! in_array( $port, array( 80, 443 ), true ) ) ) {
return '';
}
if ( strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) ) === $host ) {
return '';
}
$ips = self::resolve( $host );
if ( ! $ips ) {
return '';
}
// Every address must be public: a host with one private address is refused.
foreach ( $ips as $ip ) {
if ( ! self::is_public_ip( $ip ) ) {
return '';
}
}
/**
* Filters whether a link preview may fetch this URL.
*
* @since 1.0.0
*
* @param bool $allowed Whether the URL may be fetched.
* @param string $url URL.
*/
return apply_filters( 'spacebay_link_preview_allowed', true, $url ) ? (string) $ips[0] : '';
}
/**
* IPv4 and IPv6 addresses of a host.
*
* @param string $host Host name or IP address.
* @return string[]
*/
private static function resolve( $host ) {
if ( filter_var( $host, FILTER_VALIDATE_IP ) ) {
return array( $host );
}
/**
* Short-circuits the DNS lookup of a link preview, for tests. Return an
* array of IP addresses to use them instead of asking DNS.
*
* @since 1.0.0
*
* @param string[]|null $ips Addresses, or null to ask DNS.
* @param string $host Host name.
*/
$ips = apply_filters( 'spacebay_link_preview_resolve', null, $host );
if ( ! is_array( $ips ) ) {
$ips = (array) gethostbynamel( $host );
if ( function_exists( 'dns_get_record' ) ) {
foreach ( (array) @dns_get_record( $host, DNS_AAAA ) as $record ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- A failed lookup only means no IPv6 address.
if ( ! empty( $record['ipv6'] ) ) {
$ips[] = $record['ipv6'];
}
}
}
}
return array_values(
array_filter(
array_map( 'strval', $ips ),
static function ( $ip ) {
return (bool) filter_var( $ip, FILTER_VALIDATE_IP );
}
)
);
}
/**
* Whether an IP address is a public internet address. Private, loopback,
* link-local, shared, reserved, documentation, multicast and the IPv6
* forms that carry an IPv4 address (mapped, NAT64, 6to4, Teredo) are not.
*
* @param string $ip IPv4 or IPv6 address.
* @return bool
*/
public static function is_public_ip( $ip ) {
$packed = @inet_pton( (string) $ip ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Invalid input returns false.
if ( false === $packed ) {
return false;
}
// IPv4-mapped (::ffff:a.b.c.d) and IPv4-compatible (::a.b.c.d) addresses: check the IPv4 part.
if ( 16 === strlen( $packed ) && ( str_repeat( "\0", 10 ) . "\xff\xff" === substr( $packed, 0, 12 ) || ( str_repeat( "\0", 12 ) === substr( $packed, 0, 12 ) && "\0\0\0\0" !== substr( $packed, 12 ) && "\0\0\0\1" !== substr( $packed, 12 ) ) ) ) {
return self::is_public_ip( (string) inet_ntop( substr( $packed, 12 ) ) );
}
$blocked = 4 === strlen( $packed )
? array( '0.0.0.0/8', '10.0.0.0/8', '100.64.0.0/10', '127.0.0.0/8', '169.254.0.0/16', '172.16.0.0/12', '192.0.0.0/24', '192.0.2.0/24', '192.88.99.0/24', '192.168.0.0/16', '198.18.0.0/15', '198.51.100.0/24', '203.0.113.0/24', '224.0.0.0/4', '240.0.0.0/4' )
: array( '::/127', '64:ff9b::/96', '64:ff9b:1::/48', '100::/64', '2001::/23', '2001:db8::/32', '2002::/16', 'fc00::/7', 'fe80::/10', 'fec0::/10', 'ff00::/8' );
foreach ( $blocked as $range ) {
list( $network, $bits ) = explode( '/', $range );
if ( self::in_range( $packed, (string) inet_pton( $network ), (int) $bits ) ) {
return false;
}
}
return true;
}
/**
* Whether a packed address is inside a network.
*
* @param string $packed Packed address.
* @param string $network Packed network address of the same family.
* @param int $bits Prefix length.
* @return bool
*/
private static function in_range( $packed, $network, $bits ) {
if ( strlen( $packed ) !== strlen( $network ) ) {
return false;
}
$bytes = intdiv( $bits, 8 );
if ( substr( $packed, 0, $bytes ) !== substr( $network, 0, $bytes ) ) {
return false;
}
$rest = $bits % 8;
if ( 0 === $rest ) {
return true;
}
$mask = ( 0xFF << ( 8 - $rest ) ) & 0xFF;
return ( ord( $packed[ $bytes ] ) & $mask ) === ( ord( $network[ $bytes ] ) & $mask );
}
}