PluginProbe
ActivityPub / 7.7.0
ActivityPub v7.7.0
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 +79 -232 9.3.07.7.0 View file →
@@ -19,32 +19,8 @@
19 19 * @see https://webfinger.net/
20 20 */
21 21 class Webfinger {
22 22 /**
23 - * Check whether a value looks like an `acct` identifier.
24 - *
25 - * Accepts any of:
26 - *
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.
37 - */
38 - public static function is_acct( $value ) {
39 - if ( ! \is_string( $value ) || '' === $value ) {
40 - return false;
41 - }
42 -
43 - return (bool) \preg_match( '/^(?:acct:)?@?' . ACTIVITYPUB_USERNAME_REGEXP . '$/i', $value );
44 - }
45 -
46 - /**
47 23 * Returns a users WebFinger "resource".
48 24 *
49 25 * @param int $user_id The WordPress user id.
50 26 *
@@ -51,9 +27,9 @@
51 27 * @return string The user-resource.
52 28 */
53 29 public static function get_user_resource( $user_id ) {
54 30 $user = Actors::get_by_id( $user_id );
55 - if ( ! $user || \is_wp_error( $user ) ) {
31 + if ( ! $user || is_wp_error( $user ) ) {
56 32 return '';
57 33 }
58 34
59 35 return $user->get_webfinger();
@@ -72,12 +48,12 @@
72 48 if ( \is_wp_error( $data ) ) {
73 49 return $data;
74 50 }
75 51
76 - if ( ! \is_array( $data ) || empty( $data['links'] ) || ! \is_array( $data['links'] ) ) {
52 + if ( ! is_array( $data ) || empty( $data['links'] ) ) {
77 53 return new \WP_Error(
78 54 'webfinger_missing_links',
79 - \__( 'No valid Link elements found.', 'activitypub' ),
55 + __( 'No valid Link elements found.', 'activitypub' ),
80 56 array(
81 57 'status' => 400,
82 58 'data' => $data,
83 59 )
@@ -85,11 +61,10 @@
85 61 }
86 62
87 63 foreach ( $data['links'] as $link ) {
88 64 if (
89 - isset( $link['rel'], $link['href'], $link['type'] ) &&
90 65 'self' === $link['rel'] &&
91 - \is_string( $link['href'] ) &&
66 + isset( $link['type'] ) &&
92 67 (
93 68 'application/activity+json' === $link['type'] ||
94 69 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"' === $link['type']
95 70 )
@@ -99,9 +74,9 @@
99 74 }
100 75
101 76 return new \WP_Error(
102 77 'webfinger_url_no_activitypub',
103 - \__( 'The Site supports WebFinger but not ActivityPub', 'activitypub' ),
78 + __( 'The Site supports WebFinger but not ActivityPub', 'activitypub' ),
104 79 array(
105 80 'status' => 400,
106 81 'data' => $data,
107 82 )
@@ -119,9 +94,9 @@
119 94 */
120 95 public static function uri_to_acct( $uri ) {
121 96 $data = self::get_data( $uri );
122 97
123 - if ( \is_wp_error( $data ) ) {
98 + if ( is_wp_error( $data ) ) {
124 99 return $data;
125 100 }
126 101
127 102 // Check if subject is an acct URI.
@@ -126,9 +101,8 @@
126 101
127 102 // Check if subject is an acct URI.
128 103 if (
129 104 isset( $data['subject'] ) &&
130 - \is_string( $data['subject'] ) &&
131 105 \str_starts_with( $data['subject'], 'acct:' )
132 106 ) {
133 107 return $data['subject'];
134 108 }
@@ -133,11 +107,11 @@
133 107 return $data['subject'];
134 108 }
135 109
136 110 // Search for an acct URI in the aliases.
137 - if ( isset( $data['aliases'] ) && \is_array( $data['aliases'] ) ) {
111 + if ( isset( $data['aliases'] ) ) {
138 112 foreach ( $data['aliases'] as $alias ) {
139 - if ( \is_string( $alias ) && \str_starts_with( $alias, 'acct:' ) ) {
113 + if ( \str_starts_with( $alias, 'acct:' ) ) {
140 114 return $alias;
141 115 }
142 116 }
143 117 }
@@ -143,9 +117,9 @@
143 117 }
144 118
145 119 return new \WP_Error(
146 120 'webfinger_url_no_acct',
147 - \__( 'No acct URI found.', 'activitypub' ),
121 + __( 'No acct URI found.', 'activitypub' ),
148 122 array(
149 123 'status' => 400,
150 124 'data' => $data,
151 125 )
@@ -163,9 +137,9 @@
163 137 public static function get_identifier_and_host( $url ) {
164 138 if ( ! $url ) {
165 139 return new \WP_Error(
166 140 'webfinger_invalid_identifier',
167 - \__( 'Invalid Identifier', 'activitypub' ),
141 + __( 'Invalid Identifier', 'activitypub' ),
168 142 array(
169 143 'status' => 400,
170 144 'data' => $url,
171 145 )
@@ -172,28 +146,16 @@
172 146 );
173 147 }
174 148
175 149 // Remove leading @.
176 - $url = \ltrim( $url, '@' );
150 + $url = ltrim( $url, '@' );
177 151
178 - if ( \str_starts_with( $url, '//' ) ) {
179 - /*
180 - * A scheme-relative URL is a URL reference, not a handle: treating it as one would
181 - * read the host off the first `@` in its path.
182 - */
183 - $identifier = $url;
184 - $scheme = '';
185 - } elseif (
186 - // Scheme grammar per RFC 3986: a digit or hyphen in it must not read as a handle.
187 - ! \preg_match( '/^([a-zA-Z][a-zA-Z0-9+.\-]*):/', $url, $match )
188 - ) {
152 + if ( ! preg_match( '/^([a-zA-Z+]+):/', $url, $match ) ) {
189 153 $identifier = 'acct:' . $url;
190 154 $scheme = 'acct';
191 155 } else {
192 156 $identifier = $url;
193 -
194 - // Schemes are case-insensitive, and the switch below compares them exactly.
195 - $scheme = \strtolower( $match[1] );
157 + $scheme = $match[1];
196 158 }
197 159
198 160 $host = null;
199 161
@@ -200,23 +162,14 @@
200 162 switch ( $scheme ) {
201 163 case 'acct':
202 164 case 'mailto':
203 165 case 'xmpp':
204 - // Split on the last `@`: a local part may contain one, the host may not.
205 - if ( \strrpos( $identifier, '@' ) !== false ) {
206 - $host = \substr( $identifier, \strrpos( $identifier, '@' ) + 1 );
207 -
208 - /*
209 - * Cut anything a query or fragment starts. `wp_parse_url()` does this for the
210 - * URL forms below, but this branch takes the host off the string itself, so
211 - * `acct:user@example.com#x` would otherwise carry `#x` into the host and miss
212 - * a domain block on `example.com`.
213 - */
214 - $host = \substr( $host, 0, \strcspn( $host, '?#' ) );
166 + if ( strpos( $identifier, '@' ) !== false ) {
167 + $host = substr( $identifier, strpos( $identifier, '@' ) + 1 );
215 168 }
216 169 break;
217 170 default:
218 - $host = \wp_parse_url( $identifier, PHP_URL_HOST );
171 + $host = wp_parse_url( $identifier, PHP_URL_HOST );
219 172 break;
220 173 }
221 174
222 175 if ( empty( $host ) ) {
@@ -221,9 +174,9 @@
221 174
222 175 if ( empty( $host ) ) {
223 176 return new \WP_Error(
224 177 'webfinger_invalid_identifier',
225 - \__( 'Invalid Identifier', 'activitypub' ),
178 + __( 'Invalid Identifier', 'activitypub' ),
226 179 array(
227 180 'status' => 400,
228 181 'data' => $url,
229 182 )
@@ -229,79 +182,64 @@
229 182 )
230 183 );
231 184 }
232 185
233 - return array( $identifier, \strtolower( $host ) );
186 + return array( $identifier, $host );
234 187 }
235 188
236 189 /**
237 - * Get the host of an identifier.
238 - *
239 - * The host half of {@see self::get_identifier_and_host()}, for callers that only need that.
240 - * Prefer this over parsing a host out directly when you need an identifier's host: a handle
241 - * has none to parse, and a URI in a scheme other than `http` can carry an `@` in its path
242 - * that would be read as one.
243 - *
244 - * Not a drop-in for `is_same_host()`, which deliberately fails closed on an identifier with
245 - * no parsable host. Giving an `acct:` keyId a host there would re-admit what 9.2.1 rejected.
246 - *
247 - * @since 9.3.0
248 - *
249 - * @param string $uri The identifier, a URL or a handle.
250 - *
251 - * @return string The host, lowercased, or an empty string when there is none.
252 - */
253 - public static function get_host( $uri ) {
254 - $identifier_and_host = self::get_identifier_and_host( (string) $uri );
255 -
256 - if ( \is_wp_error( $identifier_and_host ) ) {
257 - return '';
258 - }
259 -
260 - /*
261 - * Folded here rather than in `get_identifier_and_host()`, whose host is also used to build
262 - * WebFinger and intent URLs: an IPv6 authority needs its brackets to stay a valid URL.
263 - */
264 - return fold_host( $identifier_and_host[1] );
265 - }
266 -
267 - /**
268 190 * Get the WebFinger data for a given URI.
269 191 *
270 192 * @param string $uri The Identifier: <identifier>@<host> or URI.
271 193 *
272 - * @return \WP_Error|mixed Error reaction, or the decoded document. The remote server picks the
273 - * body, so callers check the shape before indexing it.
194 + * @return \WP_Error|array Error reaction or array with identifier and host as values.
274 195 */
275 196 public static function get_data( $uri ) {
276 197 $identifier_and_host = self::get_identifier_and_host( $uri );
277 198
278 - if ( \is_wp_error( $identifier_and_host ) ) {
199 + if ( is_wp_error( $identifier_and_host ) ) {
279 200 return $identifier_and_host;
280 201 }
281 202
203 + $transient_key = self::generate_cache_key( $uri );
204 +
282 205 list( $identifier, $host ) = $identifier_and_host;
283 206
284 - $webfinger_url = \sprintf(
207 + $data = \get_transient( $transient_key );
208 + if ( $data ) {
209 + return $data;
210 + }
211 +
212 + $webfinger_url = sprintf(
285 213 'https://%s/.well-known/webfinger?resource=%s',
286 214 $host,
287 215 \rawurlencode( $identifier )
288 216 );
289 217
290 - // Use Http::get() which handles all caching (success and errors).
291 - $response = Http::get(
218 + $response = \wp_safe_remote_get(
292 219 $webfinger_url,
293 - array( 'headers' => array( 'Accept' => 'application/jrd+json' ) ),
294 - WEEK_IN_SECONDS
220 + array(
221 + 'headers' => array( 'Accept' => 'application/jrd+json' ),
222 + )
295 223 );
296 224
297 - if ( \is_wp_error( $response ) ) {
298 - return $response;
225 + if ( \is_wp_error( $response ) || \wp_remote_retrieve_response_code( $response ) >= 400 ) {
226 + return new \WP_Error(
227 + 'webfinger_url_not_accessible',
228 + __( 'The WebFinger Resource is not accessible.', 'activitypub' ),
229 + array(
230 + 'status' => 400,
231 + 'data' => $webfinger_url,
232 + )
233 + );
299 234 }
300 235
301 236 $body = \wp_remote_retrieve_body( $response );
237 + $data = \json_decode( $body, true );
302 238
303 - return \json_decode( $body, true );
239 + \set_transient( $transient_key, $data, WEEK_IN_SECONDS );
240 +
241 + return $data;
304 242 }
305 243
306 244 /**
307 245 * Get the Remote-Follow endpoint for a given URI.
@@ -310,9 +248,39 @@
310 248 *
311 249 * @return string|\WP_Error Error or the Remote-Follow endpoint URI.
312 250 */
313 251 public static function get_remote_follow_endpoint( $uri ) {
314 - return self::get_intent_endpoint( $uri, 'follow', true );
252 + $data = self::get_data( $uri );
253 +
254 + if ( is_wp_error( $data ) ) {
255 + return $data;
256 + }
257 +
258 + if ( empty( $data['links'] ) ) {
259 + return new \WP_Error(
260 + 'webfinger_missing_links',
261 + __( 'No valid Link elements found.', 'activitypub' ),
262 + array(
263 + 'status' => 400,
264 + 'data' => $data,
265 + )
266 + );
267 + }
268 +
269 + foreach ( $data['links'] as $link ) {
270 + if ( 'http://ostatus.org/schema/1.0/subscribe' === $link['rel'] ) {
271 + return $link['template'];
272 + }
273 + }
274 +
275 + return new \WP_Error(
276 + 'webfinger_missing_remote_follow_endpoint',
277 + __( 'No valid Remote-Follow endpoint found.', 'activitypub' ),
278 + array(
279 + 'status' => 400,
280 + 'data' => $data,
281 + )
282 + );
315 283 }
316 284
317 285 /**
318 286 * Generate a cache key for a given URI.
@@ -321,15 +289,15 @@
321 289 *
322 290 * @return string The cache key.
323 291 */
324 292 public static function generate_cache_key( $uri ) {
325 - $uri = \ltrim( $uri, '@' );
293 + $uri = ltrim( $uri, '@' );
326 294
327 - if ( \filter_var( $uri, FILTER_VALIDATE_EMAIL ) ) {
295 + if ( filter_var( $uri, FILTER_VALIDATE_EMAIL ) ) {
328 296 $uri = 'acct:' . $uri;
329 297 }
330 298
331 - return 'webfinger_' . \md5( $uri );
299 + return 'webfinger_' . md5( $uri );
332 300 }
333 301
334 302 /**
335 303 * Infer a shortname from the Actor ID or URL. Used only for fallbacks,
@@ -353,127 +321,6 @@
353 321 return $actor_or_uri->get_preferred_username() . '@' . \wp_parse_url( $actor_or_uri->get_id(), PHP_URL_HOST );
354 322 }
355 323
356 324 return extract_name_from_uri( $actor_or_uri->get_id() ) . '@' . \wp_parse_url( $actor_or_uri->get_id(), PHP_URL_HOST );
357 - }
358 -
359 - /**
360 - * Get the Intent endpoint for a given URI and intent.
361 - *
362 - * @since 8.0.0
363 - *
364 - * @see https://codeberg.org/fediverse/fep/src/branch/main/fep/3b86/fep-3b86.md
365 - *
366 - * @param string $uri The WebFinger Resource URI.
367 - * @param string $intent The intent to look for.
368 - * @param bool $fallback Whether to fallback to the Remote-Follow endpoint.
369 - *
370 - * @return string|\WP_Error Error or the Intent endpoint URI (may contain `{uri}` placeholder).
371 - */
372 - public static function get_intent_endpoint( $uri, $intent, $fallback = false ) {
373 - $data = self::get_data( $uri );
374 -
375 - if ( \is_wp_error( $data ) ) {
376 - return $data;
377 - }
378 -
379 - if ( empty( $data['links'] ) || ! \is_array( $data['links'] ) ) {
380 - return new \WP_Error(
381 - 'webfinger_missing_links',
382 - \__( 'No valid Link elements found.', 'activitypub' ),
383 - array(
384 - 'status' => 400,
385 - 'data' => $data,
386 - )
387 - );
388 - }
389 -
390 - // Normalize the links with $rel as key.
391 - $links = array();
392 -
393 - foreach ( $data['links'] as $link ) {
394 - if ( ! isset( $link['rel'], $link['template'] ) || ! \is_string( $link['rel'] ) || ! \is_string( $link['template'] ) ) {
395 - continue;
396 - }
397 -
398 - $template = \trim( $link['template'] );
399 -
400 - /*
401 - * A scheme check alone would pass `//host`, `/path`, `https:///path` and the empty
402 - * string: with no colon there is nothing for `wp_kses_bad_protocol()` to strip, so the
403 - * comparison below always matches. An accepted junk template is also stored under its
404 - * rel, which stops the OStatus and FEP-3b86 fallbacks from ever being reached.
405 - */
406 - if ( ! get_url_authority( $template ) ) {
407 - continue;
408 - }
409 -
410 - // The list is explicit: the `wp_allowed_protocols()` default is wider and filterable.
411 - $allowed = \wp_kses_bad_protocol( $template, array( 'http', 'https' ) );
412 -
413 - // Lowercased both sides like `wp_http_validate_url()`, or `HTTPS://` reads as a rewrite.
414 - if ( \strtolower( $allowed ) !== \strtolower( $template ) ) {
415 - continue;
416 - }
417 -
418 - $links[ \strtolower( $link['rel'] ) ] = $template;
419 - }
420 -
421 - $intent = \sanitize_text_field( $intent );
422 - $intent = \strtolower( $intent );
423 -
424 - if ( ! \filter_var( $intent, FILTER_VALIDATE_URL ) ) {
425 - $intent = 'https://w3id.org/fep/3b86/' . $intent;
426 - }
427 -
428 - if ( isset( $links[ $intent ] ) ) {
429 - return $links[ $intent ];
430 - }
431 -
432 - if ( ! $fallback ) {
433 - return new \WP_Error(
434 - 'webfinger_missing_intent_endpoint',
435 - \__( 'No valid Intent endpoint found.', 'activitypub' ),
436 - array(
437 - 'status' => 400,
438 - 'data' => $data,
439 - )
440 - );
441 - }
442 -
443 - /*
444 - * OStatus subscribe URL (deprecated but still widely supported)
445 - *
446 - * @see https://ostatus.github.io/spec/OStatus%201.0%20Draft%202.html#anchor10
447 - */
448 - if ( isset( $links['http://ostatus.org/schema/1.0/subscribe'] ) ) {
449 - return $links['http://ostatus.org/schema/1.0/subscribe'];
450 - }
451 -
452 - /*
453 - * FEP-3b86 Object Intent — the generic "open this object on my home
454 - * server" link, equivalent to pasting the URL into the home server's
455 - * search box. Useful when no verb-specific intent is advertised.
456 - *
457 - * @see https://codeberg.org/fediverse/fep/src/branch/main/fep/3b86/fep-3b86.md#5-1-object-intent
458 - */
459 - if ( isset( $links['https://w3id.org/fep/3b86/object'] ) ) {
460 - return $links['https://w3id.org/fep/3b86/object'];
461 - }
462 -
463 - // Last-resort: construct a Mastodon-compatible authorize_interaction URL.
464 - $identifier_and_host = self::get_identifier_and_host( $uri );
465 -
466 - if ( \is_wp_error( $identifier_and_host ) ) {
467 - return new \WP_Error(
468 - 'webfinger_missing_intent_endpoint',
469 - \__( 'No valid Intent endpoint found.', 'activitypub' ),
470 - array(
471 - 'status' => 400,
472 - 'data' => $data,
473 - )
474 - );
475 - }
476 -
477 - return 'https://' . $identifier_and_host[1] . '/authorize_interaction?uri={uri}';
478 325 }
479 326 }