PluginProbe
ActivityPub / trunk
ActivityPub vtrunk
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
activitypub / includes / class-webfinger.php

class-webfinger.php in ActivityPub trunk, at includes/class-webfinger.php

480 lines 12.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * WebFinger class file.
4 *
5 * @package Activitypub
6 */
7
8 namespace Activitypub;
9
10 use Activitypub\Activity\Actor;
11 use Activitypub\Collection\Actors;
12 use Activitypub\Collection\Remote_Actors;
13
14 /**
15 * ActivityPub WebFinger Class.
16 *
17 * @author Matthias Pfefferle
18 *
19 * @see https://webfinger.net/
20 */
21 class Webfinger {
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 * 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 ) ) {
56 return '';
57 }
58
59 return $user->get_webfinger();
60 }
61
62 /**
63 * Resolve a WebFinger resource.
64 *
65 * @param string $uri The WebFinger Resource.
66 *
67 * @return string|\WP_Error The URL or WP_Error.
68 */
69 public static function resolve( $uri ) {
70 $data = self::get_data( $uri );
71
72 if ( \is_wp_error( $data ) ) {
73 return $data;
74 }
75
76 if ( ! \is_array( $data ) || empty( $data['links'] ) || ! \is_array( $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 );
85 }
86
87 foreach ( $data['links'] as $link ) {
88 if (
89 isset( $link['rel'], $link['href'], $link['type'] ) &&
90 'self' === $link['rel'] &&
91 \is_string( $link['href'] ) &&
92 (
93 'application/activity+json' === $link['type'] ||
94 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"' === $link['type']
95 )
96 ) {
97 return $link['href'];
98 }
99 }
100
101 return new \WP_Error(
102 'webfinger_url_no_activitypub',
103 \__( 'The Site supports WebFinger but not ActivityPub', 'activitypub' ),
104 array(
105 'status' => 400,
106 'data' => $data,
107 )
108 );
109 }
110
111 /**
112 * Transform a URI to an acct <identifier>@<host>.
113 *
114 * @see https://swicg.github.io/activitypub-webfinger/#reverse-discovery
115 *
116 * @param string $uri The URI (acct:, mailto:, http:, https:).
117 *
118 * @return string|\WP_Error Error or acct URI.
119 */
120 public static function uri_to_acct( $uri ) {
121 $data = self::get_data( $uri );
122
123 if ( \is_wp_error( $data ) ) {
124 return $data;
125 }
126
127 // Check if subject is an acct URI.
128 if (
129 isset( $data['subject'] ) &&
130 \is_string( $data['subject'] ) &&
131 \str_starts_with( $data['subject'], 'acct:' )
132 ) {
133 return $data['subject'];
134 }
135
136 // Search for an acct URI in the aliases.
137 if ( isset( $data['aliases'] ) && \is_array( $data['aliases'] ) ) {
138 foreach ( $data['aliases'] as $alias ) {
139 if ( \is_string( $alias ) && \str_starts_with( $alias, 'acct:' ) ) {
140 return $alias;
141 }
142 }
143 }
144
145 return new \WP_Error(
146 'webfinger_url_no_acct',
147 \__( 'No acct URI found.', 'activitypub' ),
148 array(
149 'status' => 400,
150 'data' => $data,
151 )
152 );
153 }
154
155 /**
156 * Convert a URI string to an identifier and its host.
157 * Automatically adds acct: if it's missing.
158 *
159 * @param string $url The URI (acct:, mailto:, http:, https:).
160 *
161 * @return \WP_Error|array Error reaction or array with identifier and host as values.
162 */
163 public static function get_identifier_and_host( $url ) {
164 if ( ! $url ) {
165 return new \WP_Error(
166 'webfinger_invalid_identifier',
167 \__( 'Invalid Identifier', 'activitypub' ),
168 array(
169 'status' => 400,
170 'data' => $url,
171 )
172 );
173 }
174
175 // Remove leading @.
176 $url = \ltrim( $url, '@' );
177
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 ) {
189 $identifier = 'acct:' . $url;
190 $scheme = 'acct';
191 } else {
192 $identifier = $url;
193
194 // Schemes are case-insensitive, and the switch below compares them exactly.
195 $scheme = \strtolower( $match[1] );
196 }
197
198 $host = null;
199
200 switch ( $scheme ) {
201 case 'acct':
202 case 'mailto':
203 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, '?#' ) );
215 }
216 break;
217 default:
218 $host = \wp_parse_url( $identifier, PHP_URL_HOST );
219 break;
220 }
221
222 if ( empty( $host ) ) {
223 return new \WP_Error(
224 'webfinger_invalid_identifier',
225 \__( 'Invalid Identifier', 'activitypub' ),
226 array(
227 'status' => 400,
228 'data' => $url,
229 )
230 );
231 }
232
233 return array( $identifier, \strtolower( $host ) );
234 }
235
236 /**
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 * Get the WebFinger data for a given URI.
269 *
270 * @param string $uri The Identifier: <identifier>@<host> or URI.
271 *
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.
274 */
275 public static function get_data( $uri ) {
276 $identifier_and_host = self::get_identifier_and_host( $uri );
277
278 if ( \is_wp_error( $identifier_and_host ) ) {
279 return $identifier_and_host;
280 }
281
282 list( $identifier, $host ) = $identifier_and_host;
283
284 $webfinger_url = \sprintf(
285 'https://%s/.well-known/webfinger?resource=%s',
286 $host,
287 \rawurlencode( $identifier )
288 );
289
290 // Use Http::get() which handles all caching (success and errors).
291 $response = Http::get(
292 $webfinger_url,
293 array( 'headers' => array( 'Accept' => 'application/jrd+json' ) ),
294 WEEK_IN_SECONDS
295 );
296
297 if ( \is_wp_error( $response ) ) {
298 return $response;
299 }
300
301 $body = \wp_remote_retrieve_body( $response );
302
303 return \json_decode( $body, true );
304 }
305
306 /**
307 * Get the Remote-Follow endpoint for a given URI.
308 *
309 * @param string $uri The WebFinger Resource URI.
310 *
311 * @return string|\WP_Error Error or the Remote-Follow endpoint URI.
312 */
313 public static function get_remote_follow_endpoint( $uri ) {
314 return self::get_intent_endpoint( $uri, 'follow', true );
315 }
316
317 /**
318 * Generate a cache key for a given URI.
319 *
320 * @param string $uri A WebFinger Resource URI.
321 *
322 * @return string The cache key.
323 */
324 public static function generate_cache_key( $uri ) {
325 $uri = \ltrim( $uri, '@' );
326
327 if ( \filter_var( $uri, FILTER_VALIDATE_EMAIL ) ) {
328 $uri = 'acct:' . $uri;
329 }
330
331 return 'webfinger_' . \md5( $uri );
332 }
333
334 /**
335 * Infer a shortname from the Actor ID or URL. Used only for fallbacks,
336 * we will try to use what's supplied.
337 *
338 * @param Actor|string $actor_or_uri The Actor or URI.
339 *
340 * @return string Hopefully the name of the Follower.
341 */
342 public static function guess( $actor_or_uri ) {
343 if ( ! $actor_or_uri instanceof Actor ) {
344 $actor = Remote_Actors::fetch_by_uri( $actor_or_uri );
345 if ( \is_wp_error( $actor ) ) {
346 return extract_name_from_uri( $actor_or_uri ) . '@' . \wp_parse_url( $actor_or_uri, PHP_URL_HOST );
347 }
348
349 $actor_or_uri = $actor;
350 }
351
352 if ( $actor_or_uri->get_preferred_username() ) {
353 return $actor_or_uri->get_preferred_username() . '@' . \wp_parse_url( $actor_or_uri->get_id(), PHP_URL_HOST );
354 }
355
356 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 }
479 }
480