get_webfinger(); } /** * Get the `acct:` URI for a handle. * * An internationalized handle gets the form RFC 7565 asks for: the userpart percent-encoded * and the host as A-label. * * @since 9.4.0 * * @param string $handle The handle, `user@host`. * * @return string The `acct:` URI. */ public static function get_acct( $handle ) { // With the scheme in front, a username that starts with `@` keeps it. $identifier_and_host = self::get_identifier_and_host( 'acct:' . $handle ); if ( \is_wp_error( $identifier_and_host ) ) { return 'acct:' . $handle; } return $identifier_and_host[0]; } /** * Resolve a WebFinger resource. * * @param string $uri The WebFinger Resource. * * @return string|\WP_Error The URL or WP_Error. */ public static function resolve( $uri ) { $data = self::get_data( $uri ); if ( \is_wp_error( $data ) ) { return $data; } if ( ! \is_array( $data ) || empty( $data['links'] ) || ! \is_array( $data['links'] ) ) { return new \WP_Error( 'webfinger_missing_links', \__( 'No valid Link elements found.', 'activitypub' ), array( 'status' => 400, 'data' => $data, ) ); } foreach ( $data['links'] as $link ) { if ( isset( $link['rel'], $link['href'], $link['type'] ) && 'self' === $link['rel'] && \is_string( $link['href'] ) && ( 'application/activity+json' === $link['type'] || 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"' === $link['type'] ) ) { return $link['href']; } } return new \WP_Error( 'webfinger_url_no_activitypub', \__( 'The Site supports WebFinger but not ActivityPub', 'activitypub' ), array( 'status' => 400, 'data' => $data, ) ); } /** * Transform a URI to an acct @. * * @see https://swicg.github.io/activitypub-webfinger/#reverse-discovery * * @param string $uri The URI (acct:, mailto:, http:, https:). * * @return string|\WP_Error Error or acct URI. */ public static function uri_to_acct( $uri ) { $data = self::get_data( $uri ); if ( \is_wp_error( $data ) ) { return $data; } // Check if subject is an acct URI. if ( isset( $data['subject'] ) && \is_string( $data['subject'] ) && \str_starts_with( $data['subject'], 'acct:' ) ) { return $data['subject']; } // Search for an acct URI in the aliases. if ( isset( $data['aliases'] ) && \is_array( $data['aliases'] ) ) { foreach ( $data['aliases'] as $alias ) { if ( \is_string( $alias ) && \str_starts_with( $alias, 'acct:' ) ) { return $alias; } } } return new \WP_Error( 'webfinger_url_no_acct', \__( 'No acct URI found.', 'activitypub' ), array( 'status' => 400, 'data' => $data, ) ); } /** * Convert a URI string to an identifier and its host. * Automatically adds acct: if it's missing. * * @param string $url The URI (acct:, mailto:, http:, https:). * * @return \WP_Error|array Error reaction or array with identifier and host as values. */ public static function get_identifier_and_host( $url ) { if ( ! $url ) { return new \WP_Error( 'webfinger_invalid_identifier', \__( 'Invalid Identifier', 'activitypub' ), array( 'status' => 400, 'data' => $url, ) ); } // Remove leading @. $url = \ltrim( $url, '@' ); if ( \str_starts_with( $url, '//' ) ) { /* * A scheme-relative URL is a URL reference, not a handle: treating it as one would * read the host off the first `@` in its path. */ $identifier = $url; $scheme = ''; } elseif ( // Scheme grammar per RFC 3986: a digit or hyphen in it must not read as a handle. ! \preg_match( '/^([a-zA-Z][a-zA-Z0-9+.\-]*):/', $url, $match ) ) { $identifier = 'acct:' . $url; $scheme = 'acct'; } else { $identifier = $url; // Schemes are case-insensitive, and the switch below compares them exactly. $scheme = \strtolower( $match[1] ); } $host = null; switch ( $scheme ) { case 'acct': case 'mailto': case 'xmpp': // Split on the last `@`: a local part may contain one, the host may not. if ( \strrpos( $identifier, '@' ) !== false ) { $host = \substr( $identifier, \strrpos( $identifier, '@' ) + 1 ); /* * Cut anything a path, query or fragment starts. `wp_parse_url()` does this for * the URL forms below, but this branch takes the host off the string itself, so * `acct:user@example.com#x` would otherwise carry `#x` into the host and miss * a domain block on `example.com`, and `/x` would end up in the lookup URL's path. */ $host = \substr( $host, 0, \strcspn( $host, '/?#' ) ); /* * An internationalized handle is looked up in the form RFC 7565 asks for: the * userpart percent-encoded and the host as A-label. ASCII is left as it is. */ if ( 'acct' === $scheme && \preg_match( '/[^\x00-\x7F]/', $identifier ) ) { $userpart = \substr( $identifier, 0, \strrpos( $identifier, '@' ) ); $userpart = \preg_replace_callback( '/[^\x00-\x7F]+/', static function ( $non_ascii ) { return \rawurlencode( $non_ascii[0] ); }, $userpart ); $identifier = $userpart . '@' . to_ascii_host( $host ); } } break; default: $host = \wp_parse_url( $identifier, PHP_URL_HOST ); break; } if ( empty( $host ) ) { return new \WP_Error( 'webfinger_invalid_identifier', \__( 'Invalid Identifier', 'activitypub' ), array( 'status' => 400, 'data' => $url, ) ); } return array( $identifier, \strtolower( to_ascii_host( $host ) ) ); } /** * Get the host of an identifier. * * The host half of {@see self::get_identifier_and_host()}, for callers that only need that. * Prefer this over parsing a host out directly when you need an identifier's host: a handle * has none to parse, and a URI in a scheme other than `http` can carry an `@` in its path * that would be read as one. * * Not a drop-in for `is_same_host()`, which deliberately fails closed on an identifier with * no parsable host. Giving an `acct:` keyId a host there would re-admit what 9.2.1 rejected. * * @since 9.3.0 * * @param string $uri The identifier, a URL or a handle. * * @return string The host, lowercased, or an empty string when there is none. */ public static function get_host( $uri ) { $identifier_and_host = self::get_identifier_and_host( (string) $uri ); if ( \is_wp_error( $identifier_and_host ) ) { return ''; } /* * Folded here rather than in `get_identifier_and_host()`, whose host is also used to build * WebFinger and intent URLs: an IPv6 authority needs its brackets to stay a valid URL. */ return fold_host( $identifier_and_host[1] ); } /** * Get the WebFinger data for a given URI. * * @param string $uri The Identifier: @ or URI. * * @return \WP_Error|mixed Error reaction, or the decoded document. The remote server picks the * body, so callers check the shape before indexing it. */ public static function get_data( $uri ) { $identifier_and_host = self::get_identifier_and_host( $uri ); if ( \is_wp_error( $identifier_and_host ) ) { return $identifier_and_host; } list( $identifier, $host ) = $identifier_and_host; $webfinger_url = \sprintf( 'https://%s/.well-known/webfinger?resource=%s', $host, \rawurlencode( $identifier ) ); // Use Http::get() which handles all caching (success and errors). $response = Http::get( $webfinger_url, array( 'headers' => array( 'Accept' => 'application/jrd+json' ) ), WEEK_IN_SECONDS ); if ( \is_wp_error( $response ) ) { return $response; } $body = \wp_remote_retrieve_body( $response ); return \json_decode( $body, true ); } /** * Get the Remote-Follow endpoint for a given URI. * * @param string $uri The WebFinger Resource URI. * * @return string|\WP_Error Error or the Remote-Follow endpoint URI. */ public static function get_remote_follow_endpoint( $uri ) { return self::get_intent_endpoint( $uri, 'follow', true ); } /** * Generate a cache key for a given URI. * * @param string $uri A WebFinger Resource URI. * * @return string The cache key. */ public static function generate_cache_key( $uri ) { $uri = \ltrim( $uri, '@' ); if ( \filter_var( $uri, FILTER_VALIDATE_EMAIL ) ) { $uri = 'acct:' . $uri; } return 'webfinger_' . \md5( $uri ); } /** * Infer a shortname from the Actor ID or URL. Used only for fallbacks, * we will try to use what's supplied. * * @param Actor|string $actor_or_uri The Actor or URI. * * @return string Hopefully the name of the Follower. */ public static function guess( $actor_or_uri ) { if ( ! $actor_or_uri instanceof Actor ) { $actor = Remote_Actors::fetch_by_uri( $actor_or_uri ); if ( \is_wp_error( $actor ) ) { return extract_name_from_uri( $actor_or_uri ) . '@' . \wp_parse_url( $actor_or_uri, PHP_URL_HOST ); } $actor_or_uri = $actor; } if ( $actor_or_uri->get_preferred_username() ) { return $actor_or_uri->get_preferred_username() . '@' . \wp_parse_url( $actor_or_uri->get_id(), PHP_URL_HOST ); } return extract_name_from_uri( $actor_or_uri->get_id() ) . '@' . \wp_parse_url( $actor_or_uri->get_id(), PHP_URL_HOST ); } /** * Get the Intent endpoint for a given URI and intent. * * @since 8.0.0 * * @see https://codeberg.org/fediverse/fep/src/branch/main/fep/3b86/fep-3b86.md * * @param string $uri The WebFinger Resource URI. * @param string $intent The intent to look for. * @param bool $fallback Whether to fallback to the Remote-Follow endpoint. * * @return string|\WP_Error Error or the Intent endpoint URI (may contain `{uri}` placeholder). */ public static function get_intent_endpoint( $uri, $intent, $fallback = false ) { $data = self::get_data( $uri ); if ( \is_wp_error( $data ) ) { return $data; } if ( empty( $data['links'] ) || ! \is_array( $data['links'] ) ) { return new \WP_Error( 'webfinger_missing_links', \__( 'No valid Link elements found.', 'activitypub' ), array( 'status' => 400, 'data' => $data, ) ); } // Normalize the links with $rel as key. $links = array(); foreach ( $data['links'] as $link ) { if ( ! isset( $link['rel'], $link['template'] ) || ! \is_string( $link['rel'] ) || ! \is_string( $link['template'] ) ) { continue; } $template = \trim( $link['template'] ); /* * A scheme check alone would pass `//host`, `/path`, `https:///path` and the empty * string: with no colon there is nothing for `wp_kses_bad_protocol()` to strip, so the * comparison below always matches. An accepted junk template is also stored under its * rel, which stops the OStatus and FEP-3b86 fallbacks from ever being reached. */ if ( ! get_url_authority( $template ) ) { continue; } // The list is explicit: the `wp_allowed_protocols()` default is wider and filterable. $allowed = \wp_kses_bad_protocol( $template, array( 'http', 'https' ) ); // Lowercased both sides like `wp_http_validate_url()`, or `HTTPS://` reads as a rewrite. if ( \strtolower( $allowed ) !== \strtolower( $template ) ) { continue; } $links[ \strtolower( $link['rel'] ) ] = $template; } $intent = \sanitize_text_field( $intent ); $intent = \strtolower( $intent ); if ( ! \filter_var( $intent, FILTER_VALIDATE_URL ) ) { $intent = 'https://w3id.org/fep/3b86/' . $intent; } if ( isset( $links[ $intent ] ) ) { return $links[ $intent ]; } if ( ! $fallback ) { return new \WP_Error( 'webfinger_missing_intent_endpoint', \__( 'No valid Intent endpoint found.', 'activitypub' ), array( 'status' => 400, 'data' => $data, ) ); } /* * OStatus subscribe URL (deprecated but still widely supported) * * @see https://ostatus.github.io/spec/OStatus%201.0%20Draft%202.html#anchor10 */ if ( isset( $links['http://ostatus.org/schema/1.0/subscribe'] ) ) { return $links['http://ostatus.org/schema/1.0/subscribe']; } /* * FEP-3b86 Object Intent — the generic "open this object on my home * server" link, equivalent to pasting the URL into the home server's * search box. Useful when no verb-specific intent is advertised. * * @see https://codeberg.org/fediverse/fep/src/branch/main/fep/3b86/fep-3b86.md#5-1-object-intent */ if ( isset( $links['https://w3id.org/fep/3b86/object'] ) ) { return $links['https://w3id.org/fep/3b86/object']; } // Last-resort: construct a Mastodon-compatible authorize_interaction URL. $identifier_and_host = self::get_identifier_and_host( $uri ); if ( \is_wp_error( $identifier_and_host ) ) { return new \WP_Error( 'webfinger_missing_intent_endpoint', \__( 'No valid Intent endpoint found.', 'activitypub' ), array( 'status' => 400, 'data' => $data, ) ); } return 'https://' . $identifier_and_host[1] . '/authorize_interaction?uri={uri}'; } }