PluginProbe
ActivityPub / 9.2.1
ActivityPub v9.2.1
9.3.1 9.3.0 9.2.2 9.2.1 9.2.0 9.1.0 9.0.2 9.0.1 9.0.0 8.3.0 8.2.1 8.2.0 8.1.1 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.2.0 1.3.0 2.0.0 2.0.1 2.1.0 2.1.1 All 160 releases
← All changes | includes/class-webfinger.php +292 -93 1.2.09.2.1 View file →
@@ -1,12 +1,19 @@
1 1 <?php
2 +/**
3 + * WebFinger class file.
4 + *
5 + * @package Activitypub
6 + */
7 +
2 8 namespace Activitypub;
3 9
4 -use WP_Error;
5 -use Activitypub\Collection\Users;
10 +use Activitypub\Activity\Actor;
11 +use Activitypub\Collection\Actors;
12 +use Activitypub\Collection\Remote_Actors;
6 13
7 14 /**
8 - * ActivityPub WebFinger Class
15 + * ActivityPub WebFinger Class.
9 16 *
10 17 * @author Matthias Pfefferle
11 18 *
12 19 * @see https://webfinger.net/
@@ -12,88 +19,136 @@
12 19 * @see https://webfinger.net/
13 20 */
14 21 class Webfinger {
15 22 /**
16 - * Returns a users WebFinger "resource"
23 + * Check whether a value looks like an `acct` identifier.
17 24 *
18 - * @param int $user_id
25 + * Accepts any of:
19 26 *
20 - * @return string The user-resource
27 + * - `user@host` — bare WebFinger handle.
28 + * - `@user@host` — Mastodon display form with a leading `@`.
29 + * - `acct:user@host` — full RFC 7565 URI form.
30 + *
31 + * The host/local-part pattern follows `ACTIVITYPUB_USERNAME_REGEXP`.
32 + *
33 + * @since 8.3.0
34 + *
35 + * @param mixed $value The candidate value.
36 + * @return bool True if the value matches the acct identifier pattern.
21 37 */
22 - public static function get_user_resource( $user_id ) {
23 - // use WebFinger plugin if installed
24 - if ( \function_exists( '\get_webfinger_resource' ) ) {
25 - return \get_webfinger_resource( $user_id, false );
38 + public static function is_acct( $value ) {
39 + if ( ! \is_string( $value ) || '' === $value ) {
40 + return false;
26 41 }
27 42
28 - $user = Users::get_by_id( $user_id );
29 - if ( ! $user || is_wp_error( $user ) ) {
43 + return (bool) \preg_match( '/^(?:acct:)?@?' . ACTIVITYPUB_USERNAME_REGEXP . '$/i', $value );
44 + }
45 +
46 + /**
47 + * Returns a users WebFinger "resource".
48 + *
49 + * @param int $user_id The WordPress user id.
50 + *
51 + * @return string The user-resource.
52 + */
53 + public static function get_user_resource( $user_id ) {
54 + $user = Actors::get_by_id( $user_id );
55 + if ( ! $user || \is_wp_error( $user ) ) {
30 56 return '';
31 57 }
32 58
33 - return $user->get_resource();
59 + return $user->get_webfinger();
34 60 }
35 61
36 62 /**
37 - * Resolve a WebFinger resource
63 + * Resolve a WebFinger resource.
38 64 *
39 - * @param string $resource The WebFinger resource
65 + * @param string $uri The WebFinger Resource.
40 66 *
41 - * @return string|WP_Error The URL or WP_Error
67 + * @return string|\WP_Error The URL or WP_Error.
42 68 */
43 - public static function resolve( $resource ) {
44 - if ( ! preg_match( '/^@?' . ACTIVITYPUB_USERNAME_REGEXP . '$/i', $resource, $m ) ) {
45 - return null;
69 + public static function resolve( $uri ) {
70 + $data = self::get_data( $uri );
71 +
72 + if ( \is_wp_error( $data ) ) {
73 + return $data;
46 74 }
47 - $transient_key = 'activitypub_resolve_' . ltrim( $resource, '@' );
48 75
49 - $link = \get_transient( $transient_key );
50 - if ( $link ) {
51 - return $link;
76 + if ( ! \is_array( $data ) || empty( $data['links'] ) ) {
77 + return new \WP_Error(
78 + 'webfinger_missing_links',
79 + \__( 'No valid Link elements found.', 'activitypub' ),
80 + array(
81 + 'status' => 400,
82 + 'data' => $data,
83 + )
84 + );
52 85 }
53 86
54 - $url = \add_query_arg( 'resource', 'acct:' . ltrim( $resource, '@' ), 'https://' . $m[2] . '/.well-known/webfinger' );
55 - if ( ! \wp_http_validate_url( $url ) ) {
56 - $response = new WP_Error( 'invalid_webfinger_url', null, $url );
57 - \set_transient( $transient_key, $response, HOUR_IN_SECONDS ); // Cache the error for a shorter period.
58 - return $response;
87 + foreach ( $data['links'] as $link ) {
88 + if (
89 + 'self' === $link['rel'] &&
90 + isset( $link['type'] ) &&
91 + (
92 + 'application/activity+json' === $link['type'] ||
93 + 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"' === $link['type']
94 + )
95 + ) {
96 + return $link['href'];
97 + }
59 98 }
60 99
61 - // try to access author URL
62 - $response = \wp_remote_get(
63 - $url,
100 + return new \WP_Error(
101 + 'webfinger_url_no_activitypub',
102 + \__( 'The Site supports WebFinger but not ActivityPub', 'activitypub' ),
64 103 array(
65 - 'headers' => array( 'Accept' => 'application/jrd+json' ),
66 - 'redirection' => 2,
67 - 'timeout' => 2,
104 + 'status' => 400,
105 + 'data' => $data,
68 106 )
69 107 );
108 + }
70 109
71 - if ( \is_wp_error( $response ) ) {
72 - $link = new WP_Error( 'webfinger_url_not_accessible', null, $url );
73 - \set_transient( $transient_key, $link, HOUR_IN_SECONDS ); // Cache the error for a shorter period.
74 - return $link;
110 + /**
111 + * Transform a URI to an acct <identifier>@<host>.
112 + *
113 + * @see https://swicg.github.io/activitypub-webfinger/#reverse-discovery
114 + *
115 + * @param string $uri The URI (acct:, mailto:, http:, https:).
116 + *
117 + * @return string|\WP_Error Error or acct URI.
118 + */
119 + public static function uri_to_acct( $uri ) {
120 + $data = self::get_data( $uri );
121 +
122 + if ( \is_wp_error( $data ) ) {
123 + return $data;
75 124 }
76 125
77 - $body = \wp_remote_retrieve_body( $response );
78 - $body = \json_decode( $body, true );
79 -
80 - if ( empty( $body['links'] ) ) {
81 - $link = new WP_Error( 'webfinger_url_invalid_response', null, $url );
82 - \set_transient( $transient_key, $link, HOUR_IN_SECONDS ); // Cache the error for a shorter period.
83 - return $link;
126 + // Check if subject is an acct URI.
127 + if (
128 + isset( $data['subject'] ) &&
129 + \str_starts_with( $data['subject'], 'acct:' )
130 + ) {
131 + return $data['subject'];
84 132 }
85 133
86 - foreach ( $body['links'] as $link ) {
87 - if ( 'self' === $link['rel'] && 'application/activity+json' === $link['type'] ) {
88 - \set_transient( $transient_key, $link['href'], WEEK_IN_SECONDS );
89 - return $link['href'];
134 + // Search for an acct URI in the aliases.
135 + if ( isset( $data['aliases'] ) ) {
136 + foreach ( $data['aliases'] as $alias ) {
137 + if ( \str_starts_with( $alias, 'acct:' ) ) {
138 + return $alias;
139 + }
90 140 }
91 141 }
92 142
93 - $link = new WP_Error( 'webfinger_url_no_activitypub', null, $body );
94 - \set_transient( $transient_key, $link, HOUR_IN_SECONDS ); // Cache the error for a shorter period.
95 - return $link;
143 + return new \WP_Error(
144 + 'webfinger_url_no_acct',
145 + \__( 'No acct URI found.', 'activitypub' ),
146 + array(
147 + 'status' => 400,
148 + 'data' => $data,
149 + )
150 + );
96 151 }
97 152
98 153 /**
99 154 * Convert a URI string to an identifier and its host.
@@ -98,23 +153,33 @@
98 153 /**
99 154 * Convert a URI string to an identifier and its host.
100 155 * Automatically adds acct: if it's missing.
101 156 *
102 - * @param string $url The URI (acct:, mailto:, http:, https:)
157 + * @param string $url The URI (acct:, mailto:, http:, https:).
103 158 *
104 - * @return WP_Error|array Error reaction or array with
105 - * identifier and host as values
159 + * @return \WP_Error|array Error reaction or array with identifier and host as values.
106 160 */
107 161 public static function get_identifier_and_host( $url ) {
108 - // remove leading @
109 - $url = ltrim( $url, '@' );
162 + if ( ! $url ) {
163 + return new \WP_Error(
164 + 'webfinger_invalid_identifier',
165 + \__( 'Invalid Identifier', 'activitypub' ),
166 + array(
167 + 'status' => 400,
168 + 'data' => $url,
169 + )
170 + );
171 + }
110 172
111 - if ( ! preg_match( '/^([a-zA-Z+]+):/', $url, $match ) ) {
173 + // Remove leading @.
174 + $url = \ltrim( $url, '@' );
175 +
176 + if ( ! \preg_match( '/^([a-zA-Z+]+):/', $url, $match ) ) {
112 177 $identifier = 'acct:' . $url;
113 - $scheme = 'acct';
178 + $scheme = 'acct';
114 179 } else {
115 180 $identifier = $url;
116 - $scheme = $match[1];
181 + $scheme = $match[1];
117 182 }
118 183
119 184 $host = null;
120 185
@@ -121,19 +186,26 @@
121 186 switch ( $scheme ) {
122 187 case 'acct':
123 188 case 'mailto':
124 189 case 'xmpp':
125 - if ( strpos( $identifier, '@' ) !== false ) {
126 - $host = substr( $identifier, strpos( $identifier, '@' ) + 1 );
190 + if ( \strpos( $identifier, '@' ) !== false ) {
191 + $host = \substr( $identifier, \strpos( $identifier, '@' ) + 1 );
127 192 }
128 193 break;
129 194 default:
130 - $host = wp_parse_url( $identifier, PHP_URL_HOST );
195 + $host = \wp_parse_url( $identifier, PHP_URL_HOST );
131 196 break;
132 197 }
133 198
134 199 if ( empty( $host ) ) {
135 - return new WP_Error( 'invalid_identifier', __( 'Invalid Identifier', 'activitypub' ) );
200 + return new \WP_Error(
201 + 'webfinger_invalid_identifier',
202 + \__( 'Invalid Identifier', 'activitypub' ),
203 + array(
204 + 'status' => 400,
205 + 'data' => $url,
206 + )
207 + );
136 208 }
137 209
138 210 return array( $identifier, $host );
139 211 }
@@ -138,66 +210,193 @@
138 210 return array( $identifier, $host );
139 211 }
140 212
141 213 /**
142 - * Get the WebFinger data for a given URI
214 + * Get the WebFinger data for a given URI.
143 215 *
144 - * @param string $identifier The Identifier: <identifier>@<host>
145 - * @param string $host The Host: <identifier>@<host>
216 + * @param string $uri The Identifier: <identifier>@<host> or URI.
146 217 *
147 - * @return WP_Error|array Error reaction or array with
148 - * identifier and host as values
218 + * @return \WP_Error|array Error reaction or array with identifier and host as values.
149 219 */
150 - public static function get_data( $identifier, $host ) {
151 - $webfinger_url = 'https://' . $host . '/.well-known/webfinger?resource=' . rawurlencode( $identifier );
220 + public static function get_data( $uri ) {
221 + $identifier_and_host = self::get_identifier_and_host( $uri );
152 222
153 - $response = wp_safe_remote_get(
223 + if ( \is_wp_error( $identifier_and_host ) ) {
224 + return $identifier_and_host;
225 + }
226 +
227 + list( $identifier, $host ) = $identifier_and_host;
228 +
229 + $webfinger_url = \sprintf(
230 + 'https://%s/.well-known/webfinger?resource=%s',
231 + $host,
232 + \rawurlencode( $identifier )
233 + );
234 +
235 + // Use Http::get() which handles all caching (success and errors).
236 + $response = Http::get(
154 237 $webfinger_url,
155 - array(
156 - 'headers' => array( 'Accept' => 'application/jrd+json' ),
157 - 'redirection' => 0,
158 - 'timeout' => 2,
159 - )
238 + array( 'headers' => array( 'Accept' => 'application/jrd+json' ) ),
239 + WEEK_IN_SECONDS
160 240 );
161 241
162 - if ( is_wp_error( $response ) ) {
163 - return new WP_Error( 'webfinger_url_not_accessible', null, $webfinger_url );
242 + if ( \is_wp_error( $response ) ) {
243 + return $response;
164 244 }
165 245
166 - $body = wp_remote_retrieve_body( $response );
246 + $body = \wp_remote_retrieve_body( $response );
167 247
168 - return json_decode( $body, true );
248 + return \json_decode( $body, true );
169 249 }
170 250
171 251 /**
172 - * Undocumented function
252 + * Get the Remote-Follow endpoint for a given URI.
173 253 *
174 - * @return void
254 + * @param string $uri The WebFinger Resource URI.
255 + *
256 + * @return string|\WP_Error Error or the Remote-Follow endpoint URI.
175 257 */
176 258 public static function get_remote_follow_endpoint( $uri ) {
177 - $identifier_and_host = self::get_identifier_and_host( $uri );
259 + return self::get_intent_endpoint( $uri, 'follow', true );
260 + }
178 261
179 - if ( is_wp_error( $identifier_and_host ) ) {
180 - return $identifier_and_host;
262 + /**
263 + * Generate a cache key for a given URI.
264 + *
265 + * @param string $uri A WebFinger Resource URI.
266 + *
267 + * @return string The cache key.
268 + */
269 + public static function generate_cache_key( $uri ) {
270 + $uri = \ltrim( $uri, '@' );
271 +
272 + if ( \filter_var( $uri, FILTER_VALIDATE_EMAIL ) ) {
273 + $uri = 'acct:' . $uri;
181 274 }
182 275
183 - list( $identifier, $host ) = $identifier_and_host;
276 + return 'webfinger_' . \md5( $uri );
277 + }
184 278
185 - $data = self::get_data( $identifier, $host );
279 + /**
280 + * Infer a shortname from the Actor ID or URL. Used only for fallbacks,
281 + * we will try to use what's supplied.
282 + *
283 + * @param Actor|string $actor_or_uri The Actor or URI.
284 + *
285 + * @return string Hopefully the name of the Follower.
286 + */
287 + public static function guess( $actor_or_uri ) {
288 + if ( ! $actor_or_uri instanceof Actor ) {
289 + $actor = Remote_Actors::fetch_by_uri( $actor_or_uri );
290 + if ( \is_wp_error( $actor ) ) {
291 + return extract_name_from_uri( $actor_or_uri ) . '@' . \wp_parse_url( $actor_or_uri, PHP_URL_HOST );
292 + }
186 293
187 - if ( is_wp_error( $data ) ) {
294 + $actor_or_uri = $actor;
295 + }
296 +
297 + if ( $actor_or_uri->get_preferred_username() ) {
298 + return $actor_or_uri->get_preferred_username() . '@' . \wp_parse_url( $actor_or_uri->get_id(), PHP_URL_HOST );
299 + }
300 +
301 + return extract_name_from_uri( $actor_or_uri->get_id() ) . '@' . \wp_parse_url( $actor_or_uri->get_id(), PHP_URL_HOST );
302 + }
303 +
304 + /**
305 + * Get the Intent endpoint for a given URI and intent.
306 + *
307 + * @since 8.0.0
308 + *
309 + * @see https://codeberg.org/fediverse/fep/src/branch/main/fep/3b86/fep-3b86.md
310 + *
311 + * @param string $uri The WebFinger Resource URI.
312 + * @param string $intent The intent to look for.
313 + * @param bool $fallback Whether to fallback to the Remote-Follow endpoint.
314 + *
315 + * @return string|\WP_Error Error or the Intent endpoint URI (may contain `{uri}` placeholder).
316 + */
317 + public static function get_intent_endpoint( $uri, $intent, $fallback = false ) {
318 + $data = self::get_data( $uri );
319 +
320 + if ( \is_wp_error( $data ) ) {
188 321 return $data;
189 322 }
190 323
191 324 if ( empty( $data['links'] ) ) {
192 - return new WP_Error( 'webfinger_url_invalid_response', null, $data );
325 + return new \WP_Error(
326 + 'webfinger_missing_links',
327 + \__( 'No valid Link elements found.', 'activitypub' ),
328 + array(
329 + 'status' => 400,
330 + 'data' => $data,
331 + )
332 + );
193 333 }
194 334
335 + // Normalize the links with $rel as key.
336 + $links = array();
337 +
195 338 foreach ( $data['links'] as $link ) {
196 - if ( 'http://ostatus.org/schema/1.0/subscribe' === $link['rel'] ) {
197 - return $link['template'];
339 + if ( isset( $link['rel'] ) && isset( $link['template'] ) ) {
340 + $links[ \strtolower( $link['rel'] ) ] = $link['template'];
198 341 }
199 342 }
200 343
201 - return new WP_Error( 'webfinger_remote_follow_endpoint_invalid', $data, array( 'status' => 417 ) );
344 + $intent = \sanitize_text_field( $intent );
345 + $intent = \strtolower( $intent );
346 +
347 + if ( ! \filter_var( $intent, FILTER_VALIDATE_URL ) ) {
348 + $intent = 'https://w3id.org/fep/3b86/' . $intent;
349 + }
350 +
351 + if ( isset( $links[ $intent ] ) ) {
352 + return $links[ $intent ];
353 + }
354 +
355 + if ( ! $fallback ) {
356 + return new \WP_Error(
357 + 'webfinger_missing_intent_endpoint',
358 + \__( 'No valid Intent endpoint found.', 'activitypub' ),
359 + array(
360 + 'status' => 400,
361 + 'data' => $data,
362 + )
363 + );
364 + }
365 +
366 + /*
367 + * OStatus subscribe URL (deprecated but still widely supported)
368 + *
369 + * @see https://ostatus.github.io/spec/OStatus%201.0%20Draft%202.html#anchor10
370 + */
371 + if ( isset( $links['http://ostatus.org/schema/1.0/subscribe'] ) ) {
372 + return $links['http://ostatus.org/schema/1.0/subscribe'];
373 + }
374 +
375 + /*
376 + * FEP-3b86 Object Intent — the generic "open this object on my home
377 + * server" link, equivalent to pasting the URL into the home server's
378 + * search box. Useful when no verb-specific intent is advertised.
379 + *
380 + * @see https://codeberg.org/fediverse/fep/src/branch/main/fep/3b86/fep-3b86.md#5-1-object-intent
381 + */
382 + if ( isset( $links['https://w3id.org/fep/3b86/object'] ) ) {
383 + return $links['https://w3id.org/fep/3b86/object'];
384 + }
385 +
386 + // Last-resort: construct a Mastodon-compatible authorize_interaction URL.
387 + $identifier_and_host = self::get_identifier_and_host( $uri );
388 +
389 + if ( \is_wp_error( $identifier_and_host ) ) {
390 + return new \WP_Error(
391 + 'webfinger_missing_intent_endpoint',
392 + \__( 'No valid Intent endpoint found.', 'activitypub' ),
393 + array(
394 + 'status' => 400,
395 + 'data' => $data,
396 + )
397 + );
398 + }
399 +
400 + return 'https://' . $identifier_and_host[1] . '/authorize_interaction?uri={uri}';
202 401 }
203 402 }