PluginProbe
ActivityPub / 8.0.2
ActivityPub v8.0.2
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 +205 -79 2.0.18.0.2 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,16 +19,16 @@
12 19 * @see https://webfinger.net/
13 20 */
14 21 class Webfinger {
15 22 /**
16 - * Returns a users WebFinger "resource"
23 + * Returns a users WebFinger "resource".
17 24 *
18 - * @param int $user_id The WordPress user id
25 + * @param int $user_id The WordPress user id.
19 26 *
20 - * @return string The user-resource
27 + * @return string The user-resource.
21 28 */
22 29 public static function get_user_resource( $user_id ) {
23 - $user = Users::get_by_id( $user_id );
30 + $user = Actors::get_by_id( $user_id );
24 31 if ( ! $user || is_wp_error( $user ) ) {
25 32 return '';
26 33 }
27 34
@@ -28,13 +35,13 @@
28 35 return $user->get_webfinger();
29 36 }
30 37
31 38 /**
32 - * Resolve a WebFinger resource
39 + * Resolve a WebFinger resource.
33 40 *
34 - * @param string $uri The WebFinger Resource
41 + * @param string $uri The WebFinger Resource.
35 42 *
36 - * @return string|WP_Error The URL or WP_Error
43 + * @return string|\WP_Error The URL or WP_Error.
37 44 */
38 45 public static function resolve( $uri ) {
39 46 $data = self::get_data( $uri );
40 47
@@ -41,26 +48,50 @@
41 48 if ( \is_wp_error( $data ) ) {
42 49 return $data;
43 50 }
44 51
52 + if ( ! is_array( $data ) || empty( $data['links'] ) ) {
53 + return new \WP_Error(
54 + 'webfinger_missing_links',
55 + __( 'No valid Link elements found.', 'activitypub' ),
56 + array(
57 + 'status' => 400,
58 + 'data' => $data,
59 + )
60 + );
61 + }
62 +
45 63 foreach ( $data['links'] as $link ) {
46 64 if (
47 65 'self' === $link['rel'] &&
48 - 'application/activity+json' === $link['type']
66 + isset( $link['type'] ) &&
67 + (
68 + 'application/activity+json' === $link['type'] ||
69 + 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"' === $link['type']
70 + )
49 71 ) {
50 72 return $link['href'];
51 73 }
52 74 }
53 75
54 - return new WP_Error( 'webfinger_url_no_activitypub', null, $data );
76 + return new \WP_Error(
77 + 'webfinger_url_no_activitypub',
78 + __( 'The Site supports WebFinger but not ActivityPub', 'activitypub' ),
79 + array(
80 + 'status' => 400,
81 + 'data' => $data,
82 + )
83 + );
55 84 }
56 85
57 86 /**
58 - * Transform a URI to an acct <identifier>@<host>
87 + * Transform a URI to an acct <identifier>@<host>.
59 88 *
60 - * @param string $uri The URI (acct:, mailto:, http:, https:)
89 + * @see https://swicg.github.io/activitypub-webfinger/#reverse-discovery
61 90 *
62 - * @return string|WP_Error Error or acct URI
91 + * @param string $uri The URI (acct:, mailto:, http:, https:).
92 + *
93 + * @return string|\WP_Error Error or acct URI.
63 94 */
64 95 public static function uri_to_acct( $uri ) {
65 96 $data = self::get_data( $uri );
66 97
@@ -67,9 +98,9 @@
67 98 if ( is_wp_error( $data ) ) {
68 99 return $data;
69 100 }
70 101
71 - // check if subject is an acct URI
102 + // Check if subject is an acct URI.
72 103 if (
73 104 isset( $data['subject'] ) &&
74 105 \str_starts_with( $data['subject'], 'acct:' )
75 106 ) {
@@ -75,9 +106,9 @@
75 106 ) {
76 107 return $data['subject'];
77 108 }
78 109
79 - // search for an acct URI in the aliases
110 + // Search for an acct URI in the aliases.
80 111 if ( isset( $data['aliases'] ) ) {
81 112 foreach ( $data['aliases'] as $alias ) {
82 113 if ( \str_starts_with( $alias, 'acct:' ) ) {
83 114 return $alias;
@@ -84,12 +115,15 @@
84 115 }
85 116 }
86 117 }
87 118
88 - return new WP_Error(
119 + return new \WP_Error(
89 120 'webfinger_url_no_acct',
90 121 __( 'No acct URI found.', 'activitypub' ),
91 - $data
122 + array(
123 + 'status' => 400,
124 + 'data' => $data,
125 + )
92 126 );
93 127 }
94 128
95 129 /**
@@ -95,23 +129,33 @@
95 129 /**
96 130 * Convert a URI string to an identifier and its host.
97 131 * Automatically adds acct: if it's missing.
98 132 *
99 - * @param string $url The URI (acct:, mailto:, http:, https:)
133 + * @param string $url The URI (acct:, mailto:, http:, https:).
100 134 *
101 - * @return WP_Error|array Error reaction or array with
102 - * identifier and host as values
135 + * @return \WP_Error|array Error reaction or array with identifier and host as values.
103 136 */
104 137 public static function get_identifier_and_host( $url ) {
105 - // remove leading @
138 + if ( ! $url ) {
139 + return new \WP_Error(
140 + 'webfinger_invalid_identifier',
141 + __( 'Invalid Identifier', 'activitypub' ),
142 + array(
143 + 'status' => 400,
144 + 'data' => $url,
145 + )
146 + );
147 + }
148 +
149 + // Remove leading @.
106 150 $url = ltrim( $url, '@' );
107 151
108 152 if ( ! preg_match( '/^([a-zA-Z+]+):/', $url, $match ) ) {
109 153 $identifier = 'acct:' . $url;
110 - $scheme = 'acct';
154 + $scheme = 'acct';
111 155 } else {
112 156 $identifier = $url;
113 - $scheme = $match[1];
157 + $scheme = $match[1];
114 158 }
115 159
116 160 $host = null;
117 161
@@ -128,9 +172,16 @@
128 172 break;
129 173 }
130 174
131 175 if ( empty( $host ) ) {
132 - return new WP_Error( 'webfinger_invalid_identifier', __( 'Invalid Identifier', 'activitypub' ) );
176 + return new \WP_Error(
177 + 'webfinger_invalid_identifier',
178 + __( 'Invalid Identifier', 'activitypub' ),
179 + array(
180 + 'status' => 400,
181 + 'data' => $url,
182 + )
183 + );
133 184 }
134 185
135 186 return array( $identifier, $host );
136 187 }
@@ -135,14 +186,13 @@
135 186 return array( $identifier, $host );
136 187 }
137 188
138 189 /**
139 - * Get the WebFinger data for a given URI
190 + * Get the WebFinger data for a given URI.
140 191 *
141 - * @param string $uri The Identifier: <identifier>@<host> or URI
192 + * @param string $uri The Identifier: <identifier>@<host> or URI.
142 193 *
143 - * @return WP_Error|array Error reaction or array with
144 - * identifier and host as values
194 + * @return \WP_Error|array Error reaction or array with identifier and host as values.
145 195 */
146 196 public static function get_data( $uri ) {
147 197 $identifier_and_host = self::get_identifier_and_host( $uri );
148 198
@@ -149,88 +199,164 @@
149 199 if ( is_wp_error( $identifier_and_host ) ) {
150 200 return $identifier_and_host;
151 201 }
152 202
153 - $transient_key = self::generate_cache_key( $uri );
203 + list( $identifier, $host ) = $identifier_and_host;
154 204
155 - list( $identifier, $host ) = $identifier_and_host;
205 + $webfinger_url = sprintf(
206 + 'https://%s/.well-known/webfinger?resource=%s',
207 + $host,
208 + \rawurlencode( $identifier )
209 + );
156 210
157 - $data = \get_transient( $transient_key );
158 - if ( $data ) {
159 - return $data;
211 + // Use Http::get() which handles all caching (success and errors).
212 + $response = Http::get(
213 + $webfinger_url,
214 + array( 'headers' => array( 'Accept' => 'application/jrd+json' ) ),
215 + WEEK_IN_SECONDS
216 + );
217 +
218 + if ( \is_wp_error( $response ) ) {
219 + return $response;
160 220 }
161 221
162 - $webfinger_url = 'https://' . $host . '/.well-known/webfinger?resource=' . rawurlencode( $identifier );
222 + $body = \wp_remote_retrieve_body( $response );
163 223
164 - $response = wp_safe_remote_get(
165 - $webfinger_url,
166 - array(
167 - 'headers' => array( 'Accept' => 'application/jrd+json' ),
168 - )
169 - );
224 + return \json_decode( $body, true );
225 + }
170 226
171 - if ( is_wp_error( $response ) ) {
172 - return new WP_Error(
173 - 'webfinger_url_not_accessible',
174 - __( 'The WebFinger Resource is not accessible.', 'activitypub' ),
175 - $webfinger_url
176 - );
227 + /**
228 + * Get the Remote-Follow endpoint for a given URI.
229 + *
230 + * @param string $uri The WebFinger Resource URI.
231 + *
232 + * @return string|\WP_Error Error or the Remote-Follow endpoint URI.
233 + */
234 + public static function get_remote_follow_endpoint( $uri ) {
235 + return self::get_intent_endpoint( $uri, 'http://ostatus.org/schema/1.0/subscribe' );
236 + }
237 +
238 + /**
239 + * Generate a cache key for a given URI.
240 + *
241 + * @param string $uri A WebFinger Resource URI.
242 + *
243 + * @return string The cache key.
244 + */
245 + public static function generate_cache_key( $uri ) {
246 + $uri = ltrim( $uri, '@' );
247 +
248 + if ( filter_var( $uri, FILTER_VALIDATE_EMAIL ) ) {
249 + $uri = 'acct:' . $uri;
177 250 }
178 251
179 - $body = wp_remote_retrieve_body( $response );
180 - $data = json_decode( $body, true );
252 + return 'webfinger_' . md5( $uri );
253 + }
181 254
182 - \set_transient( $transient_key, $data, WEEK_IN_SECONDS );
255 + /**
256 + * Infer a shortname from the Actor ID or URL. Used only for fallbacks,
257 + * we will try to use what's supplied.
258 + *
259 + * @param Actor|string $actor_or_uri The Actor or URI.
260 + *
261 + * @return string Hopefully the name of the Follower.
262 + */
263 + public static function guess( $actor_or_uri ) {
264 + if ( ! $actor_or_uri instanceof Actor ) {
265 + $actor = Remote_Actors::fetch_by_uri( $actor_or_uri );
266 + if ( \is_wp_error( $actor ) ) {
267 + return extract_name_from_uri( $actor_or_uri ) . '@' . \wp_parse_url( $actor_or_uri, PHP_URL_HOST );
268 + }
183 269
184 - return $data;
270 + $actor_or_uri = $actor;
271 + }
272 +
273 + if ( $actor_or_uri->get_preferred_username() ) {
274 + return $actor_or_uri->get_preferred_username() . '@' . \wp_parse_url( $actor_or_uri->get_id(), PHP_URL_HOST );
275 + }
276 +
277 + return extract_name_from_uri( $actor_or_uri->get_id() ) . '@' . \wp_parse_url( $actor_or_uri->get_id(), PHP_URL_HOST );
185 278 }
186 279
187 280 /**
188 - * Get the Remote-Follow endpoint for a given URI
281 + * Get the Intent endpoint for a given URI and intent.
189 282 *
190 - * @return string|WP_Error Error or the Remote-Follow endpoint URI.
283 + * @since 8.0.0
284 + *
285 + * @see https://codeberg.org/fediverse/fep/src/branch/main/fep/3b86/fep-3b86.md
286 + *
287 + * @param string $uri The WebFinger Resource URI.
288 + * @param string $intent The intent to look for.
289 + * @param bool $fallback Whether to fallback to the Remote-Follow endpoint.
290 + *
291 + * @return string|\WP_Error Error or the Intent endpoint URI (may contain `{uri}` placeholder).
191 292 */
192 - public static function get_remote_follow_endpoint( $uri ) {
293 + public static function get_intent_endpoint( $uri, $intent, $fallback = false ) {
193 294 $data = self::get_data( $uri );
194 295
195 - if ( is_wp_error( $data ) ) {
296 + if ( \is_wp_error( $data ) ) {
196 297 return $data;
197 298 }
198 299
199 300 if ( empty( $data['links'] ) ) {
200 - return new WP_Error(
301 + return new \WP_Error(
201 302 'webfinger_missing_links',
202 - __( 'No valid Link elements found.', 'activitypub' ),
203 - $data
303 + \__( 'No valid Link elements found.', 'activitypub' ),
304 + array(
305 + 'status' => 400,
306 + 'data' => $data,
307 + )
204 308 );
205 309 }
206 310
311 + // Normalize the links with $rel as key.
312 + $links = array();
313 +
207 314 foreach ( $data['links'] as $link ) {
208 - if ( 'http://ostatus.org/schema/1.0/subscribe' === $link['rel'] ) {
209 - return $link['template'];
315 + if ( isset( $link['rel'] ) && isset( $link['template'] ) ) {
316 + $links[ \strtolower( $link['rel'] ) ] = $link['template'];
210 317 }
211 318 }
212 319
213 - return new WP_Error(
214 - 'webfinger_missing_remote_follow_endpoint',
215 - __( 'No valid Remote-Follow endpoint found.', 'activitypub' ),
216 - $data
217 - );
218 - }
320 + $intent = \sanitize_text_field( $intent );
321 + $intent = \strtolower( $intent );
219 322
220 - /**
221 - * Generate a cache key for a given URI
222 - *
223 - * @param string $uri A WebFinger Resource URI
224 - *
225 - * @return string The cache key
226 - */
227 - public static function generate_cache_key( $uri ) {
228 - $uri = ltrim( $uri, '@' );
323 + if ( ! \filter_var( $intent, FILTER_VALIDATE_URL ) ) {
324 + $intent = 'https://w3id.org/fep/3b86/' . $intent;
325 + }
229 326
230 - if ( filter_var( $uri, FILTER_VALIDATE_EMAIL ) ) {
231 - $uri = 'acct:' . $uri;
327 + if ( isset( $links[ $intent ] ) ) {
328 + return $links[ $intent ];
232 329 }
233 330
234 - return 'webfinger_' . md5( $uri );
331 + if ( ! $fallback ) {
332 + return new \WP_Error(
333 + 'webfinger_missing_intent_endpoint',
334 + \__( 'No valid Intent endpoint found.', 'activitypub' ),
335 + array(
336 + 'status' => 400,
337 + 'data' => $data,
338 + )
339 + );
340 + }
341 +
342 + if ( isset( $links['http://ostatus.org/schema/1.0/subscribe'] ) ) {
343 + return $links['http://ostatus.org/schema/1.0/subscribe'];
344 + }
345 +
346 + // Last-resort: construct a Mastodon-compatible authorize_interaction URL.
347 + $identifier_and_host = self::get_identifier_and_host( $uri );
348 +
349 + if ( \is_wp_error( $identifier_and_host ) ) {
350 + return new \WP_Error(
351 + 'webfinger_missing_intent_endpoint',
352 + \__( 'No valid Intent endpoint found.', 'activitypub' ),
353 + array(
354 + 'status' => 400,
355 + 'data' => $data,
356 + )
357 + );
358 + }
359 +
360 + return 'https://' . $identifier_and_host[1] . '/authorize_interaction?uri={uri}';
235 361 }
236 362 }