PluginProbe
ActivityPub / 8.3.0
ActivityPub v8.3.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
activitypub / includes / class-webfinger.php

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

403 lines 9.7 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'] ) ) {
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 '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 }
98 }
99
100 return new \WP_Error(
101 'webfinger_url_no_activitypub',
102 __( 'The Site supports WebFinger but not ActivityPub', 'activitypub' ),
103 array(
104 'status' => 400,
105 'data' => $data,
106 )
107 );
108 }
109
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;
124 }
125
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'];
132 }
133
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 }
140 }
141 }
142
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 );
151 }
152
153 /**
154 * Convert a URI string to an identifier and its host.
155 * Automatically adds acct: if it's missing.
156 *
157 * @param string $url The URI (acct:, mailto:, http:, https:).
158 *
159 * @return \WP_Error|array Error reaction or array with identifier and host as values.
160 */
161 public static function get_identifier_and_host( $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 }
172
173 // Remove leading @.
174 $url = ltrim( $url, '@' );
175
176 if ( ! preg_match( '/^([a-zA-Z+]+):/', $url, $match ) ) {
177 $identifier = 'acct:' . $url;
178 $scheme = 'acct';
179 } else {
180 $identifier = $url;
181 $scheme = $match[1];
182 }
183
184 $host = null;
185
186 switch ( $scheme ) {
187 case 'acct':
188 case 'mailto':
189 case 'xmpp':
190 if ( strpos( $identifier, '@' ) !== false ) {
191 $host = substr( $identifier, strpos( $identifier, '@' ) + 1 );
192 }
193 break;
194 default:
195 $host = wp_parse_url( $identifier, PHP_URL_HOST );
196 break;
197 }
198
199 if ( empty( $host ) ) {
200 return new \WP_Error(
201 'webfinger_invalid_identifier',
202 __( 'Invalid Identifier', 'activitypub' ),
203 array(
204 'status' => 400,
205 'data' => $url,
206 )
207 );
208 }
209
210 return array( $identifier, $host );
211 }
212
213 /**
214 * Get the WebFinger data for a given URI.
215 *
216 * @param string $uri The Identifier: <identifier>@<host> or URI.
217 *
218 * @return \WP_Error|array Error reaction or array with identifier and host as values.
219 */
220 public static function get_data( $uri ) {
221 $identifier_and_host = self::get_identifier_and_host( $uri );
222
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(
237 $webfinger_url,
238 array( 'headers' => array( 'Accept' => 'application/jrd+json' ) ),
239 WEEK_IN_SECONDS
240 );
241
242 if ( \is_wp_error( $response ) ) {
243 return $response;
244 }
245
246 $body = \wp_remote_retrieve_body( $response );
247
248 return \json_decode( $body, true );
249 }
250
251 /**
252 * Get the Remote-Follow endpoint for a given URI.
253 *
254 * @param string $uri The WebFinger Resource URI.
255 *
256 * @return string|\WP_Error Error or the Remote-Follow endpoint URI.
257 */
258 public static function get_remote_follow_endpoint( $uri ) {
259 return self::get_intent_endpoint( $uri, 'follow', true );
260 }
261
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;
274 }
275
276 return 'webfinger_' . md5( $uri );
277 }
278
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 }
293
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 ) ) {
321 return $data;
322 }
323
324 if ( empty( $data['links'] ) ) {
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 );
333 }
334
335 // Normalize the links with $rel as key.
336 $links = array();
337
338 foreach ( $data['links'] as $link ) {
339 if ( isset( $link['rel'] ) && isset( $link['template'] ) ) {
340 $links[ \strtolower( $link['rel'] ) ] = $link['template'];
341 }
342 }
343
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}';
401 }
402 }
403