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 / functions-activity.php

functions-activity.php in ActivityPub trunk, at includes/functions-activity.php

501 lines 15.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Activity functions.
4 *
5 * Functions for working with ActivityPub activities, objects, and actors.
6 *
7 * @package Activitypub
8 */
9
10 namespace Activitypub;
11
12 use Activitypub\Activity\Activity;
13 use Activitypub\Activity\Actor;
14 use Activitypub\Activity\Base_Object;
15
16 /**
17 * Returns the ActivityPub default JSON-context.
18 *
19 * @return array The activitypub context.
20 *
21 * @deprecated 7.6.0 Use the respective context function instead.
22 */
23 function get_context() {
24 \_deprecated_function( __FUNCTION__, '7.6.0', 'Use the respective context function instead.' );
25
26 $context = Activity::JSON_LD_CONTEXT;
27
28 /**
29 * Filters the ActivityPub JSON-LD context.
30 *
31 * This filter allows developers to modify or extend the JSON-LD context used
32 * in ActivityPub responses. The context defines the vocabulary and terms used
33 * in the ActivityPub JSON objects.
34 *
35 * @param array $context The default ActivityPub JSON-LD context array.
36 */
37 return \apply_filters( 'activitypub_json_context', $context );
38 }
39
40 /**
41 * Extract recipient URLs from Activity object.
42 *
43 * @param array $data The Activity object as array.
44 *
45 * @return array The list of user URLs.
46 */
47 function extract_recipients_from_activity( $data ) {
48 $recipient_items = array();
49
50 foreach ( array( 'to', 'bto', 'cc', 'bcc', 'audience' ) as $i ) {
51 $recipient_items = \array_merge( $recipient_items, extract_recipients_from_activity_property( $i, $data ) );
52 }
53
54 // An Accept/Reject that wraps a Follow is addressed only through the embedded Follow's actor.
55 if (
56 \in_array( $data['type'], array( 'Accept', 'Reject' ), true ) &&
57 ! empty( $data['object'] ) &&
58 \is_array( $data['object'] ) &&
59 ! empty( $data['object']['actor'] )
60 ) {
61 $recipient_items[] = object_to_uri( $data['object']['actor'] );
62 }
63
64 return \array_unique( \array_filter( $recipient_items ) );
65 }
66
67 /**
68 * Extract recipient URLs from a specific property of an Activity object.
69 *
70 * Checks the activity level first, then falls back to the object property,
71 * and finally checks the instrument property (used by QuoteRequest activities).
72 *
73 * @param string $property The property to extract recipients from (e.g., 'to', 'cc').
74 * @param array $data The Activity object as array.
75 *
76 * @return array The list of user URLs.
77 */
78 function extract_recipients_from_activity_property( $property, $data ) {
79 $recipients = array();
80
81 if ( ! empty( $data[ $property ] ) ) {
82 $recipients = $data[ $property ];
83 } elseif ( ! empty( $data['object'][ $property ] ) ) {
84 $recipients = $data['object'][ $property ];
85 } elseif ( ! empty( $data['instrument'][ $property ] ) ) {
86 // QuoteRequest activities have addressing in the instrument (the quoting Note).
87 $recipients = $data['instrument'][ $property ];
88 }
89
90 $recipients = \array_map( '\Activitypub\object_to_uri', (array) $recipients );
91
92 return \array_unique( \array_filter( $recipients ) );
93 }
94
95 /**
96 * Determine the visibility of the activity based on its recipients.
97 *
98 * @param array $activity The activity data.
99 *
100 * @return string One of ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC,
101 * ACTIVITYPUB_CONTENT_VISIBILITY_QUIET_PUBLIC, or
102 * ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE.
103 */
104 function get_activity_visibility( $activity ) {
105 // Set default visibility for specific activity types.
106 if ( ! empty( $activity['type'] ) && \in_array( $activity['type'], array( 'Accept', 'Delete', 'Follow', 'Reject', 'Undo' ), true ) ) {
107 return ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE;
108 }
109
110 // Check 'to' field for public visibility.
111 $to = extract_recipients_from_activity_property( 'to', $activity );
112 if ( ! empty( \array_intersect( $to, ACTIVITYPUB_PUBLIC_AUDIENCE_IDENTIFIERS ) ) ) {
113 return ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC;
114 }
115
116 // Check 'cc' field for quiet public visibility.
117 $cc = extract_recipients_from_activity_property( 'cc', $activity );
118 if ( ! empty( \array_intersect( $cc, ACTIVITYPUB_PUBLIC_AUDIENCE_IDENTIFIERS ) ) ) {
119 return ACTIVITYPUB_CONTENT_VISIBILITY_QUIET_PUBLIC;
120 }
121
122 return ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE;
123 }
124
125 /**
126 * Check if passed Activity is Public.
127 *
128 * @see https://github.com/w3c/activitypub/issues/404#issuecomment-2926310561
129 * @see https://www.w3.org/TR/activitypub/#delivery (Section 7.1, "Silent and private activities")
130 *
131 * @param Base_Object|array $data The Activity object as Base_Object or array.
132 *
133 * @return boolean True if public, false if not.
134 */
135 function is_activity_public( $data ) {
136 if ( $data instanceof Base_Object ) {
137 $data = $data->to_array();
138 }
139
140 $recipients = extract_recipients_from_activity( $data );
141
142 if ( empty( $recipients ) ) {
143 return false;
144 }
145
146 return ! empty( \array_intersect( $recipients, ACTIVITYPUB_PUBLIC_AUDIENCE_IDENTIFIERS ) );
147 }
148
149 /**
150 * Check if passed Activity is a reply.
151 *
152 * @param array $data The Activity object as array.
153 *
154 * @return boolean True if a reply, false if not.
155 */
156 function is_activity_reply( $data ) {
157 return ! empty( $data['object']['inReplyTo'] );
158 }
159
160 /**
161 * Check if passed Activity is a quote.
162 *
163 * Checks for quote properties: quote, quoteUrl, quoteUri, or _misskey_quote.
164 *
165 * @param array $data The Activity object as array.
166 *
167 * @return boolean True if a quote, false if not.
168 */
169 function is_quote_activity( $data ) {
170 return ! empty( $data['object']['quote'] ) ||
171 ! empty( $data['object']['quoteUrl'] ) ||
172 ! empty( $data['object']['quoteUri'] ) ||
173 ! empty( $data['object']['_misskey_quote'] );
174 }
175
176 /**
177 * Get the URI of an ActivityPub object.
178 *
179 * @param array|string $data The ActivityPub object.
180 *
181 * @return string|null The URI of the ActivityPub object.
182 */
183 function object_to_uri( $data ) {
184 // Check whether it is already simple.
185 if ( ! $data || \is_string( $data ) ) {
186 return $data;
187 }
188
189 if ( \is_object( $data ) ) {
190 $data = $data->to_array();
191 }
192
193 /*
194 * Check if it is a list, then take first item.
195 * This plugin does not support collections.
196 */
197 if ( \array_is_list( $data ) ) {
198 $data = $data[0];
199 }
200
201 // Check if it is simplified now.
202 if ( \is_string( $data ) ) {
203 return $data;
204 }
205
206 $type = 'Object';
207 if ( isset( $data['type'] ) ) {
208 $type = $data['type'];
209 }
210
211 // Return part of Object that makes most sense.
212 switch ( $type ) {
213 case 'Audio': // See https://www.w3.org/TR/activitystreams-vocabulary/#dfn-audio.
214 case 'Document': // See https://www.w3.org/TR/activitystreams-vocabulary/#dfn-document.
215 case 'Image': // See https://www.w3.org/TR/activitystreams-vocabulary/#dfn-image.
216 case 'Video': // See https://www.w3.org/TR/activitystreams-vocabulary/#dfn-video.
217 $data = object_to_uri( $data['url'] );
218 break;
219
220 case 'Link': // See https://www.w3.org/TR/activitystreams-vocabulary/#dfn-link.
221 case 'Mention': // See https://www.w3.org/TR/activitystreams-vocabulary/#dfn-mention.
222 $data = $data['href'];
223 break;
224
225 case 'FeaturedItem': // See https://github.com/mastodon/featured_collections/pull/1.
226 $data = object_to_uri( $data['featuredObject'] ?? null );
227 break;
228
229 default:
230 if ( isset( $data['id'] ) ) {
231 $data = $data['id'];
232 } elseif ( isset( $data['url'] ) ) {
233 $data = object_to_uri( $data['url'] );
234 } elseif ( isset( $data['href'] ) ) {
235 $data = $data['href'];
236 } else {
237 $data = null;
238 }
239 break;
240 }
241
242 return $data;
243 }
244
245 /**
246 * Check whether two references point at the same actor.
247 *
248 * Both values are resolved to their canonical URI via object_to_uri() before
249 * comparison. Empty references never match, so a missing actor can never be
250 * mistaken for a match.
251 *
252 * @param array|object|string $a The first actor reference.
253 * @param array|object|string $b The second actor reference.
254 *
255 * @return bool True when both resolve to the same non-empty URI.
256 */
257 function is_same_actor( $a, $b ) {
258 $a = object_to_uri( $a );
259 $b = object_to_uri( $b );
260
261 return ! empty( $a ) && ! empty( $b ) && $a === $b;
262 }
263
264 /**
265 * Check whether two references live on the same host.
266 *
267 * Both values are resolved to their canonical URI via object_to_uri(), then
268 * their hosts are compared case-insensitively. Empty references, or references
269 * without a host, never match.
270 *
271 * @param array|object|string $a The first reference.
272 * @param array|object|string $b The second reference.
273 *
274 * @return bool True when both resolve to a URI on the same host.
275 */
276 function is_same_host( $a, $b ) {
277 $host_a = \wp_parse_url( (string) object_to_uri( $a ), PHP_URL_HOST );
278 $host_b = \wp_parse_url( (string) object_to_uri( $b ), PHP_URL_HOST );
279
280 return ! empty( $host_a ) && ! empty( $host_b ) && \strtolower( $host_a ) === \strtolower( $host_b );
281 }
282
283 /**
284 * Whether an object is served under its own canonical id.
285 *
286 * An object is only trustworthy to cache when its own `id` is the URL it was
287 * actually served from: otherwise one host could serve a document — and its
288 * public key — under another host's id. Reads the raw `id` attribute only
289 * (never the `url`/`href` fallback that object_to_uri() applies), because the
290 * cache is keyed on `id`, so "is this canonical?" must ask the same field the
291 * write uses. The comparison ignores the URL fragment and a trailing slash;
292 * everything else (scheme, host, port, path, query) must match exactly.
293 * Host-level equality is deliberately NOT enough — any different id on the same
294 * host is still a distinct cache entry that a document served elsewhere must not write.
295 *
296 * @param array|string $item The fetched object, or its id.
297 * @param string $url The URL the object was served from.
298 *
299 * @return bool True when the object's id is the canonical URL it was served from.
300 */
301 function id_matches_url( $item, $url ) {
302 if ( \is_array( $item ) ) {
303 $id = isset( $item['id'] ) && \is_string( $item['id'] ) ? $item['id'] : '';
304 } elseif ( \is_string( $item ) ) {
305 $id = $item;
306 } else {
307 $id = '';
308 }
309
310 $id = \strip_fragment_from_url( $id );
311 $url = \strip_fragment_from_url( (string) $url );
312
313 if ( '' === $id || '' === $url ) {
314 return false;
315 }
316
317 return \untrailingslashit( $id ) === \untrailingslashit( $url );
318 }
319
320 /**
321 * Normalize an actor URI so two spellings of the same identity compare equal.
322 *
323 * Folds only what RFC 3986 calls case-insensitive, the scheme and host, plus a default port
324 * and a trailing slash, and drops the fragment. Path and query keep their case, and `http`
325 * stays distinct from `https`. Userinfo is dropped, so a few technically distinct URIs compare
326 * equal, which errs towards matching a block rather than missing one.
327 *
328 * Deliberately not used by `id_matches_url()`, which guards a cache write keyed on the exact
329 * id: folding there would confirm a document under one spelling and store it under another.
330 * A mismatch there is not a rejection, {@see \Activitypub\Http::get_remote_object()} re-fetches
331 * the declared id and requires that to self-confirm, so the strictness costs one request rather
332 * than refusing a document that spells its own host differently.
333 *
334 * @since 9.3.0
335 *
336 * @param string $uri The actor URI.
337 *
338 * @return string The normalized URI, or an empty string when there is nothing to compare.
339 */
340 function normalize_actor_uri( $uri ) {
341 $uri = \is_string( $uri ) ? \trim( $uri ) : '';
342
343 if ( '' === $uri ) {
344 return '';
345 }
346
347 /*
348 * Cut at the first `#`, which is the only place a fragment can start, rather than through
349 * `strip_fragment_from_url()`, which rebuilds from parsed parts and so leaves a hostless
350 * identifier alone. Without this a fragment on a handle survives normalization and
351 * `acct:user@example.com#x` slips past a block on `acct:user@example.com`. The host branch
352 * never re-appends one either way.
353 */
354 $fragment = \strpos( $uri, '#' );
355
356 if ( false !== $fragment ) {
357 $uri = \substr( $uri, 0, $fragment );
358 }
359
360 $parts = \wp_parse_url( $uri );
361
362 /*
363 * A handle has no parsable host, so its own host half is folded on its own. Anything else
364 * without one is malformed and is compared as it came in.
365 */
366 if ( empty( $parts['host'] ) ) {
367 // `acct:` and a leading `@` are spellings of the same handle, so they are dropped before
368 // comparing. The local part keeps its case; only the host half is folded.
369 $handle = \preg_replace( '/^acct:/i', '', $uri );
370 $handle = \ltrim( $handle, '@' );
371
372 if ( \preg_match( '/^(.*@)([^@]+)$/', $handle, $parsed ) ) {
373 return $parsed[1] . fold_host( $parsed[2] );
374 }
375
376 return \untrailingslashit( $uri );
377 }
378
379 static $default = array(
380 'http' => 80,
381 'https' => 443,
382 );
383
384 $scheme = \strtolower( $parts['scheme'] ?? '' );
385
386 // A port that is the scheme's default is the same address written two ways.
387 $is_default = isset( $parts['port'] ) && \array_key_exists( $scheme, $default ) && $default[ $scheme ] === (int) $parts['port'];
388 $port = isset( $parts['port'] ) && ! $is_default ? ':' . (int) $parts['port'] : '';
389
390 // The trailing slash is folded on the path rather than the whole URI so a query cannot hide it.
391 // A scheme-relative reference keeps its `//`; emitting `://` would match nothing.
392 $authority = ( '' === $scheme ? '//' : $scheme . '://' ) . fold_host( $parts['host'] );
393 $normalized = $authority . $port . \untrailingslashit( $parts['path'] ?? '' );
394
395 return isset( $parts['query'] ) ? $normalized . '?' . $parts['query'] : $normalized;
396 }
397
398 /**
399 * Check if an `$data` is an Activity.
400 *
401 * @see https://www.w3.org/ns/activitystreams#activities
402 *
403 * @param array|object|string $data The data to check.
404 *
405 * @return boolean True if the `$data` is an Activity, false otherwise.
406 */
407 function is_activity( $data ) {
408 /**
409 * Filters the activity types.
410 *
411 * @param array $types The activity types.
412 */
413 $types = \apply_filters( 'activitypub_activity_types', Activity::TYPES );
414
415 return _is_type_of( $data, $types );
416 }
417
418 /**
419 * Check if an `$data` is an Activity Object.
420 *
421 * @see https://www.w3.org/TR/activitystreams-vocabulary/#object-types
422 *
423 * @param array|object|string $data The data to check.
424 *
425 * @return boolean True if the `$data` is an Activity Object, false otherwise.
426 */
427 function is_activity_object( $data ) {
428 /**
429 * Filters the activity object types.
430 *
431 * @param array $types The activity object types.
432 */
433 $types = \apply_filters( 'activitypub_activity_object_types', Base_Object::TYPES );
434
435 return _is_type_of( $data, $types );
436 }
437
438 /**
439 * Check if an `$data` is an Actor.
440 *
441 * @see https://www.w3.org/ns/activitystreams#actor
442 *
443 * @param array|object|string $data The data to check.
444 *
445 * @return boolean True if the `$data` is an Actor, false otherwise.
446 */
447 function is_actor( $data ) {
448 /**
449 * Filters the actor types.
450 *
451 * @param array $types The actor types.
452 */
453 $types = \apply_filters( 'activitypub_actor_types', Actor::TYPES );
454
455 return _is_type_of( $data, $types );
456 }
457
458 /**
459 * Check if an `$data` is a Collection.
460 *
461 * @see https://www.w3.org/ns/activitystreams#collections
462 *
463 * @param array|object|string $data The data to check.
464 *
465 * @return boolean True if the `$data` is a Collection, false otherwise.
466 */
467 function is_collection( $data ) {
468 /**
469 * Filters the collection types.
470 *
471 * @param array $types The collection types.
472 */
473 $types = \apply_filters( 'activitypub_collection_types', array( 'Collection', 'OrderedCollection', 'CollectionPage', 'OrderedCollectionPage' ) );
474
475 return _is_type_of( $data, $types );
476 }
477
478 /**
479 * Private helper to check if $data is of a given type set.
480 *
481 * @param array|object|string $data The data to check.
482 * @param array $types The types to check against.
483 *
484 * @return boolean True if $data is of one of the types, false otherwise.
485 */
486 function _is_type_of( $data, $types ) {
487 if ( \is_string( $data ) ) {
488 return \in_array( $data, $types, true );
489 }
490
491 if ( \is_array( $data ) && isset( $data['type'] ) ) {
492 return \in_array( $data['type'], $types, true );
493 }
494
495 if ( $data instanceof Base_Object ) {
496 return \in_array( $data->get_type(), $types, true );
497 }
498
499 return false;
500 }
501