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
activitypub / includes / class-webfinger.php

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

363 lines 8.5 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 * Returns a users WebFinger "resource".
24 *
25 * @param int $user_id The WordPress user id.
26 *
27 * @return string The user-resource.
28 */
29 public static function get_user_resource( $user_id ) {
30 $user = Actors::get_by_id( $user_id );
31 if ( ! $user || is_wp_error( $user ) ) {
32 return '';
33 }
34
35 return $user->get_webfinger();
36 }
37
38 /**
39 * Resolve a WebFinger resource.
40 *
41 * @param string $uri The WebFinger Resource.
42 *
43 * @return string|\WP_Error The URL or WP_Error.
44 */
45 public static function resolve( $uri ) {
46 $data = self::get_data( $uri );
47
48 if ( \is_wp_error( $data ) ) {
49 return $data;
50 }
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
63 foreach ( $data['links'] as $link ) {
64 if (
65 'self' === $link['rel'] &&
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 )
71 ) {
72 return $link['href'];
73 }
74 }
75
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 );
84 }
85
86 /**
87 * Transform a URI to an acct <identifier>@<host>.
88 *
89 * @see https://swicg.github.io/activitypub-webfinger/#reverse-discovery
90 *
91 * @param string $uri The URI (acct:, mailto:, http:, https:).
92 *
93 * @return string|\WP_Error Error or acct URI.
94 */
95 public static function uri_to_acct( $uri ) {
96 $data = self::get_data( $uri );
97
98 if ( is_wp_error( $data ) ) {
99 return $data;
100 }
101
102 // Check if subject is an acct URI.
103 if (
104 isset( $data['subject'] ) &&
105 \str_starts_with( $data['subject'], 'acct:' )
106 ) {
107 return $data['subject'];
108 }
109
110 // Search for an acct URI in the aliases.
111 if ( isset( $data['aliases'] ) ) {
112 foreach ( $data['aliases'] as $alias ) {
113 if ( \str_starts_with( $alias, 'acct:' ) ) {
114 return $alias;
115 }
116 }
117 }
118
119 return new \WP_Error(
120 'webfinger_url_no_acct',
121 __( 'No acct URI found.', 'activitypub' ),
122 array(
123 'status' => 400,
124 'data' => $data,
125 )
126 );
127 }
128
129 /**
130 * Convert a URI string to an identifier and its host.
131 * Automatically adds acct: if it's missing.
132 *
133 * @param string $url The URI (acct:, mailto:, http:, https:).
134 *
135 * @return \WP_Error|array Error reaction or array with identifier and host as values.
136 */
137 public static function get_identifier_and_host( $url ) {
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 @.
150 $url = ltrim( $url, '@' );
151
152 if ( ! preg_match( '/^([a-zA-Z+]+):/', $url, $match ) ) {
153 $identifier = 'acct:' . $url;
154 $scheme = 'acct';
155 } else {
156 $identifier = $url;
157 $scheme = $match[1];
158 }
159
160 $host = null;
161
162 switch ( $scheme ) {
163 case 'acct':
164 case 'mailto':
165 case 'xmpp':
166 if ( strpos( $identifier, '@' ) !== false ) {
167 $host = substr( $identifier, strpos( $identifier, '@' ) + 1 );
168 }
169 break;
170 default:
171 $host = wp_parse_url( $identifier, PHP_URL_HOST );
172 break;
173 }
174
175 if ( empty( $host ) ) {
176 return new \WP_Error(
177 'webfinger_invalid_identifier',
178 __( 'Invalid Identifier', 'activitypub' ),
179 array(
180 'status' => 400,
181 'data' => $url,
182 )
183 );
184 }
185
186 return array( $identifier, $host );
187 }
188
189 /**
190 * Get the WebFinger data for a given URI.
191 *
192 * @param string $uri The Identifier: <identifier>@<host> or URI.
193 *
194 * @return \WP_Error|array Error reaction or array with identifier and host as values.
195 */
196 public static function get_data( $uri ) {
197 $identifier_and_host = self::get_identifier_and_host( $uri );
198
199 if ( is_wp_error( $identifier_and_host ) ) {
200 return $identifier_and_host;
201 }
202
203 list( $identifier, $host ) = $identifier_and_host;
204
205 $webfinger_url = sprintf(
206 'https://%s/.well-known/webfinger?resource=%s',
207 $host,
208 \rawurlencode( $identifier )
209 );
210
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;
220 }
221
222 $body = \wp_remote_retrieve_body( $response );
223
224 return \json_decode( $body, true );
225 }
226
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;
250 }
251
252 return 'webfinger_' . md5( $uri );
253 }
254
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 }
269
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 );
278 }
279
280 /**
281 * Get the Intent endpoint for a given URI and intent.
282 *
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).
292 */
293 public static function get_intent_endpoint( $uri, $intent, $fallback = false ) {
294 $data = self::get_data( $uri );
295
296 if ( \is_wp_error( $data ) ) {
297 return $data;
298 }
299
300 if ( empty( $data['links'] ) ) {
301 return new \WP_Error(
302 'webfinger_missing_links',
303 \__( 'No valid Link elements found.', 'activitypub' ),
304 array(
305 'status' => 400,
306 'data' => $data,
307 )
308 );
309 }
310
311 // Normalize the links with $rel as key.
312 $links = array();
313
314 foreach ( $data['links'] as $link ) {
315 if ( isset( $link['rel'] ) && isset( $link['template'] ) ) {
316 $links[ \strtolower( $link['rel'] ) ] = $link['template'];
317 }
318 }
319
320 $intent = \sanitize_text_field( $intent );
321 $intent = \strtolower( $intent );
322
323 if ( ! \filter_var( $intent, FILTER_VALIDATE_URL ) ) {
324 $intent = 'https://w3id.org/fep/3b86/' . $intent;
325 }
326
327 if ( isset( $links[ $intent ] ) ) {
328 return $links[ $intent ];
329 }
330
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}';
361 }
362 }
363