PluginProbe
ActivityPub / 1.0.0
ActivityPub v1.0.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 +87 -286 8.3.01.0.0 View file →
@@ -1,19 +1,12 @@
1 1 <?php
2 -/**
3 - * WebFinger class file.
4 - *
5 - * @package Activitypub
6 - */
7 -
8 2 namespace Activitypub;
9 3
10 -use Activitypub\Activity\Actor;
11 -use Activitypub\Collection\Actors;
12 -use Activitypub\Collection\Remote_Actors;
4 +use WP_Error;
5 +use Activitypub\Collection\Users;
13 6
14 7 /**
15 - * ActivityPub WebFinger Class.
8 + * ActivityPub WebFinger Class
16 9 *
17 10 * @author Matthias Pfefferle
18 11 *
19 12 * @see https://webfinger.net/
@@ -19,136 +12,88 @@
19 12 * @see https://webfinger.net/
20 13 */
21 14 class Webfinger {
22 15 /**
23 - * Check whether a value looks like an `acct` identifier.
16 + * Returns a users WebFinger "resource"
24 17 *
25 - * Accepts any of:
18 + * @param int $user_id
26 19 *
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.
20 + * @return string The user-resource
37 21 */
38 - public static function is_acct( $value ) {
39 - if ( ! \is_string( $value ) || '' === $value ) {
40 - return false;
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 );
41 26 }
42 27
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 );
28 + $user = Users::get_by_id( $user_id );
55 29 if ( ! $user || is_wp_error( $user ) ) {
56 30 return '';
57 31 }
58 32
59 - return $user->get_webfinger();
33 + return $user->get_resource();
60 34 }
61 35
62 36 /**
63 - * Resolve a WebFinger resource.
37 + * Resolve a WebFinger resource
64 38 *
65 - * @param string $uri The WebFinger Resource.
39 + * @param string $resource The WebFinger resource
66 40 *
67 - * @return string|\WP_Error The URL or WP_Error.
41 + * @return string|WP_Error The URL or WP_Error
68 42 */
69 - public static function resolve( $uri ) {
70 - $data = self::get_data( $uri );
71 -
72 - if ( \is_wp_error( $data ) ) {
73 - return $data;
43 + public static function resolve( $resource ) {
44 + if ( ! preg_match( '/^@?' . ACTIVITYPUB_USERNAME_REGEXP . '$/i', $resource, $m ) ) {
45 + return null;
74 46 }
47 + $transient_key = 'activitypub_resolve_' . ltrim( $resource, '@' );
75 48
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 - );
49 + $link = \get_transient( $transient_key );
50 + if ( $link ) {
51 + return $link;
85 52 }
86 53
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 - }
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;
98 59 }
99 60
100 - return new \WP_Error(
101 - 'webfinger_url_no_activitypub',
102 - __( 'The Site supports WebFinger but not ActivityPub', 'activitypub' ),
61 + // try to access author URL
62 + $response = \wp_remote_get(
63 + $url,
103 64 array(
104 - 'status' => 400,
105 - 'data' => $data,
65 + 'headers' => array( 'Accept' => 'application/jrd+json' ),
66 + 'redirection' => 0,
67 + 'timeout' => 2,
106 68 )
107 69 );
108 - }
109 70
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 );
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;
75 + }
121 76
122 - if ( is_wp_error( $data ) ) {
123 - return $data;
124 - }
77 + $body = \wp_remote_retrieve_body( $response );
78 + $body = \json_decode( $body, true );
125 79
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'];
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;
132 84 }
133 85
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 - }
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'];
140 90 }
141 91 }
142 92
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 - );
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;
151 96 }
152 97
153 98 /**
154 99 * Convert a URI string to an identifier and its host.
@@ -153,33 +98,23 @@
153 98 /**
154 99 * Convert a URI string to an identifier and its host.
155 100 * Automatically adds acct: if it's missing.
156 101 *
157 - * @param string $url The URI (acct:, mailto:, http:, https:).
102 + * @param string $url The URI (acct:, mailto:, http:, https:)
158 103 *
159 - * @return \WP_Error|array Error reaction or array with identifier and host as values.
104 + * @return WP_Error|array Error reaction or array with
105 + * identifier and host as values
160 106 */
161 107 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 @.
108 + // remove leading @
174 109 $url = ltrim( $url, '@' );
175 110
176 111 if ( ! preg_match( '/^([a-zA-Z+]+):/', $url, $match ) ) {
177 112 $identifier = 'acct:' . $url;
178 - $scheme = 'acct';
113 + $scheme = 'acct';
179 114 } else {
180 115 $identifier = $url;
181 - $scheme = $match[1];
116 + $scheme = $match[1];
182 117 }
183 118
184 119 $host = null;
185 120
@@ -196,16 +131,9 @@
196 131 break;
197 132 }
198 133
199 134 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 - );
135 + return new WP_Error( 'invalid_identifier', __( 'Invalid Identifier', 'activitypub' ) );
208 136 }
209 137
210 138 return array( $identifier, $host );
211 139 }
@@ -210,193 +138,66 @@
210 138 return array( $identifier, $host );
211 139 }
212 140
213 141 /**
214 - * Get the WebFinger data for a given URI.
142 + * Get the WebFinger data for a given URI
215 143 *
216 - * @param string $uri The Identifier: <identifier>@<host> or URI.
144 + * @param string $identifier The Identifier: <identifier>@<host>
145 + * @param string $host The Host: <identifier>@<host>
217 146 *
218 - * @return \WP_Error|array Error reaction or array with identifier and host as values.
147 + * @return WP_Error|array Error reaction or array with
148 + * identifier and host as values
219 149 */
220 - public static function get_data( $uri ) {
221 - $identifier_and_host = self::get_identifier_and_host( $uri );
150 + public static function get_data( $identifier, $host ) {
151 + $webfinger_url = 'https://' . $host . '/.well-known/webfinger?resource=' . rawurlencode( $identifier );
222 152
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(
153 + $response = wp_safe_remote_get(
237 154 $webfinger_url,
238 - array( 'headers' => array( 'Accept' => 'application/jrd+json' ) ),
239 - WEEK_IN_SECONDS
155 + array(
156 + 'headers' => array( 'Accept' => 'application/jrd+json' ),
157 + 'redirection' => 0,
158 + 'timeout' => 2,
159 + )
240 160 );
241 161
242 - if ( \is_wp_error( $response ) ) {
243 - return $response;
162 + if ( is_wp_error( $response ) ) {
163 + return new WP_Error( 'webfinger_url_not_accessible', null, $webfinger_url );
244 164 }
245 165
246 - $body = \wp_remote_retrieve_body( $response );
166 + $body = wp_remote_retrieve_body( $response );
247 167
248 - return \json_decode( $body, true );
168 + return json_decode( $body, true );
249 169 }
250 170
251 171 /**
252 - * Get the Remote-Follow endpoint for a given URI.
172 + * Undocumented function
253 173 *
254 - * @param string $uri The WebFinger Resource URI.
255 - *
256 - * @return string|\WP_Error Error or the Remote-Follow endpoint URI.
174 + * @return void
257 175 */
258 176 public static function get_remote_follow_endpoint( $uri ) {
259 - return self::get_intent_endpoint( $uri, 'follow', true );
260 - }
177 + $identifier_and_host = self::get_identifier_and_host( $uri );
261 178
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;
179 + if ( is_wp_error( $identifier_and_host ) ) {
180 + return $identifier_and_host;
274 181 }
275 182
276 - return 'webfinger_' . md5( $uri );
277 - }
183 + list( $identifier, $host ) = $identifier_and_host;
278 184
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 - }
185 + $data = self::get_data( $identifier, $host );
293 186
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 ) ) {
187 + if ( is_wp_error( $data ) ) {
321 188 return $data;
322 189 }
323 190
324 191 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 - );
192 + return new WP_Error( 'webfinger_url_invalid_response', null, $data );
333 193 }
334 194
335 - // Normalize the links with $rel as key.
336 - $links = array();
337 -
338 195 foreach ( $data['links'] as $link ) {
339 - if ( isset( $link['rel'] ) && isset( $link['template'] ) ) {
340 - $links[ \strtolower( $link['rel'] ) ] = $link['template'];
196 + if ( 'http://ostatus.org/schema/1.0/subscribe' === $link['rel'] ) {
197 + return $link['template'];
341 198 }
342 199 }
343 200
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}';
201 + return new WP_Error( 'webfinger_remote_follow_endpoint_invalid', $data, array( 'status' => 417 ) );
401 202 }
402 203 }