PluginProbe
ActivityPub / 9.2.0
ActivityPub v9.2.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 / rest / class-inbox-controller.php

class-inbox-controller.php in ActivityPub 9.2.0, at includes/rest/class-inbox-controller.php

539 lines 17.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Inbox_Controller file.
4 *
5 * @package Activitypub
6 */
7
8 namespace Activitypub\Rest;
9
10 use Activitypub\Activity\Activity;
11 use Activitypub\Collection\Actors;
12 use Activitypub\Collection\Following;
13 use Activitypub\Collection\Inbox;
14 use Activitypub\Collection\Remote_Actors;
15 use Activitypub\Http;
16 use Activitypub\Moderation;
17
18 use function Activitypub\camel_to_snake_case;
19 use function Activitypub\extract_recipients_from_activity;
20 use function Activitypub\is_activity_public;
21 use function Activitypub\is_collection;
22 use function Activitypub\is_same_domain;
23 use function Activitypub\object_to_uri;
24 use function Activitypub\user_can_activitypub;
25
26 /**
27 * Inbox_Controller class.
28 *
29 * @author Matthias Pfefferle
30 *
31 * @see https://www.w3.org/TR/activitypub/#inbox
32 */
33 class Inbox_Controller extends \WP_REST_Controller {
34 use Verification;
35 use Language_Map;
36
37 /**
38 * The namespace of this controller's route.
39 *
40 * @var string
41 */
42 protected $namespace = ACTIVITYPUB_REST_NAMESPACE;
43
44 /**
45 * The base of this controller's route.
46 *
47 * @var string
48 */
49 protected $rest_base = 'inbox';
50
51 /**
52 * Register routes.
53 */
54 public function register_routes() {
55 \register_rest_route(
56 $this->namespace,
57 '/' . $this->rest_base,
58 array(
59 array(
60 'methods' => \WP_REST_Server::CREATABLE,
61 'callback' => array( $this, 'create_item' ),
62 'permission_callback' => array( $this, 'verify_signature' ),
63 'args' => array(
64 'id' => array(
65 'description' => 'The unique identifier for the activity.',
66 'type' => 'string',
67 'format' => 'uri',
68 'required' => true,
69 ),
70 'actor' => array(
71 'description' => 'The actor performing the activity.',
72 'type' => 'string',
73 'required' => true,
74 'sanitize_callback' => '\Activitypub\object_to_uri',
75 ),
76 'type' => array(
77 'description' => 'The type of the activity.',
78 'type' => 'string',
79 'required' => true,
80 'sanitize_callback' => 'sanitize_html_class',
81 'validate_callback' => static function ( $param ) {
82 // Reject values that sanitize to empty so dynamic hook names always have a suffix.
83 return '' !== \sanitize_html_class( (string) $param );
84 },
85 ),
86 'object' => array(
87 'description' => 'The object of the activity.',
88 'required' => true,
89 'sanitize_callback' => array( $this, 'localize_language_maps' ),
90 'validate_callback' => static function ( $param, $request, $key ) {
91 /**
92 * Filter the ActivityPub object validation.
93 *
94 * @param bool $validate The validation result.
95 * @param array $param The object data.
96 * @param \WP_REST_Request $request The request object.
97 * @param string $key The key.
98 */
99 return \apply_filters( 'activitypub_validate_object', true, $param, $request, $key );
100 },
101 ),
102 'to' => array(
103 'description' => 'The primary recipients of the activity.',
104 'type' => array( 'string', 'array' ),
105 'required' => false,
106 'sanitize_callback' => static function ( $param ) {
107 if ( \is_string( $param ) ) {
108 $param = array( $param );
109 }
110
111 return $param;
112 },
113 ),
114 'cc' => array(
115 'description' => 'The secondary recipients of the activity.',
116 'type' => array( 'string', 'array' ),
117 'sanitize_callback' => static function ( $param ) {
118 if ( \is_string( $param ) ) {
119 $param = array( $param );
120 }
121
122 return $param;
123 },
124 ),
125 'bcc' => array(
126 'description' => 'The private recipients of the activity.',
127 'type' => array( 'string', 'array' ),
128 'sanitize_callback' => static function ( $param ) {
129 if ( \is_string( $param ) ) {
130 $param = array( $param );
131 }
132
133 return $param;
134 },
135 ),
136 ),
137 ),
138 'schema' => array( $this, 'get_item_schema' ),
139 )
140 );
141 }
142
143 /**
144 * The shared inbox.
145 *
146 * @param \WP_REST_Request $request The request object.
147 *
148 * @return \WP_REST_Response|\WP_Error Response object or WP_Error.
149 */
150 public function create_item( $request ) {
151 $data = $request->get_json_params();
152 $type = camel_to_snake_case( $request->get_param( 'type' ) );
153
154 /* @var Activity $activity Activity object.*/
155 $activity = Activity::init_from_array( $data );
156
157 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
158 if ( Moderation::activity_is_blocked( $activity ) ) {
159 /**
160 * ActivityPub inbox disallowed activity.
161 *
162 * @param array $data The data array.
163 * @param null $user_id The user ID.
164 * @param string $type The type of the activity.
165 * @param Activity|\WP_Error $activity The Activity object.
166 */
167 \do_action( 'activitypub_rest_inbox_disallowed', $data, null, $type, $activity );
168 } else {
169 $recipients = $this->get_local_recipients( $data );
170
171 // Filter out blocked recipients.
172 $allowed_recipients = array();
173 foreach ( $recipients as $user_id ) {
174 if ( Moderation::activity_is_blocked_for_user( $activity, $user_id ) ) {
175 /**
176 * ActivityPub inbox disallowed activity for specific user.
177 *
178 * @param array $data The data array.
179 * @param int $user_id The user ID.
180 * @param string $type The type of the activity.
181 * @param Activity|\WP_Error $activity The Activity object.
182 */
183 \do_action( 'activitypub_rest_inbox_disallowed', $data, $user_id, $type, $activity );
184 } else {
185 $allowed_recipients[] = $user_id;
186
187 /**
188 * ActivityPub inbox action.
189 *
190 * @deprecated 7.6.0 Support activitypub_inbox_shared instead to avoid duplicate processing.
191 *
192 * @param array $data The data array.
193 * @param int $user_id The user ID.
194 * @param string $type The type of the activity.
195 * @param Activity|\WP_Error $activity The Activity object.
196 * @param string $context The context of the request (shared_inbox when called from shared inbox endpoint).
197 */
198 \do_action( 'activitypub_inbox', $data, $user_id, $type, $activity, Inbox::CONTEXT_SHARED_INBOX );
199
200 /**
201 * ActivityPub inbox action for specific activity types.
202 *
203 * @deprecated 7.6.0 Support activitypub_inbox_shared_{type} instead to avoid duplicate processing.
204 *
205 * @param array $data The data array.
206 * @param int $user_id The user ID.
207 * @param Activity|\WP_Error $activity The Activity object.
208 * @param string $context The context of the request (shared_inbox when called from shared inbox endpoint).
209 */
210 \do_action( 'activitypub_inbox_' . $type, $data, $user_id, $activity, Inbox::CONTEXT_SHARED_INBOX );
211 }
212 }
213
214 /**
215 * ActivityPub shared inbox action.
216 *
217 * This hook fires once per activity with all recipients.
218 * Preferred for new implementations to avoid duplication.
219 *
220 * @since 7.6.0
221 *
222 * @param array $data The data array.
223 * @param array $recipients Array of user IDs.
224 * @param string $type The type of the activity.
225 * @param Activity|\WP_Error $activity The Activity object.
226 * @param string $context The context of the request.
227 */
228 \do_action( 'activitypub_inbox_shared', $data, $allowed_recipients, $type, $activity, Inbox::CONTEXT_SHARED_INBOX );
229
230 /**
231 * ActivityPub shared inbox action for specific activity types.
232 *
233 * This hook fires once per activity with all recipients.
234 * Preferred for new implementations to avoid duplication.
235 *
236 * @since 7.6.0
237 *
238 * @param array $data The data array.
239 * @param array $recipients Array of user IDs.
240 * @param Activity|\WP_Error $activity The Activity object.
241 * @param string $context The context of the request.
242 */
243 \do_action( 'activitypub_inbox_shared_' . $type, $data, $allowed_recipients, $activity, Inbox::CONTEXT_SHARED_INBOX );
244
245 /**
246 * Filter to skip inbox storage.
247 *
248 * Skip inbox storage for debugging purposes or to reduce load for
249 * certain Activity-Types, like "Delete".
250 *
251 * @param bool $skip Whether to skip inbox storage.
252 * @param array $data The activity data array.
253 *
254 * @return bool Whether to skip inbox storage.
255 */
256 $skip = \apply_filters( 'activitypub_skip_inbox_storage', false, $data );
257
258 if ( ! $skip ) {
259 $result = Inbox::add( $activity, $allowed_recipients );
260
261 /**
262 * Fires after an ActivityPub Inbox activity has been handled.
263 *
264 * @param array $data The data array.
265 * @param array $user_ids The user IDs.
266 * @param string $type The type of the activity.
267 * @param Activity|\WP_Error $activity The Activity object.
268 * @param \WP_Error|int $result The ID of the inbox item that was created, or WP_Error if failed.
269 * @param string $context The context of the request ('inbox' or 'shared_inbox').
270 */
271 \do_action( 'activitypub_handled_inbox', $data, $allowed_recipients, $type, $activity, $result, Inbox::CONTEXT_SHARED_INBOX );
272
273 /**
274 * Fires after an ActivityPub Inbox activity has been handled.
275 *
276 * @param array $data The data array.
277 * @param array $user_ids The user IDs.
278 * @param Activity|\WP_Error $activity The Activity object.
279 * @param \WP_Error|int $result The ID of the inbox item that was created, or WP_Error if failed.
280 * @param string $context The context of the request ('inbox' or 'shared_inbox').
281 */
282 \do_action( 'activitypub_handled_inbox_' . $type, $data, $allowed_recipients, $activity, $result, Inbox::CONTEXT_SHARED_INBOX );
283 }
284 }
285
286 $response = \rest_ensure_response(
287 array(
288 'type' => 'https://w3id.org/fep/c180#approval-required',
289 'title' => 'Approval Required',
290 'status' => '202',
291 'detail' => 'This activity requires approval before it can be processed.',
292 )
293 );
294 $response->set_status( 202 );
295 $response->header( 'Content-Type', 'application/activity+json; charset=' . \get_option( 'blog_charset' ) );
296
297 return $response;
298 }
299
300 /**
301 * Retrieves the schema for a single inbox item, conforming to JSON Schema.
302 *
303 * @return array Item schema data.
304 */
305 public function get_item_schema() {
306 if ( $this->schema ) {
307 return $this->add_additional_fields_schema( $this->schema );
308 }
309
310 $schema = array(
311 '$schema' => 'https://json-schema.org/draft-04/schema#',
312 'title' => 'activity',
313 'type' => 'object',
314 'properties' => array(
315 '@context' => array(
316 'description' => 'The JSON-LD context for the activity.',
317 'type' => array( 'string', 'array', 'object' ),
318 'required' => true,
319 ),
320 'id' => array(
321 'description' => 'The unique identifier for the activity.',
322 'type' => 'string',
323 'format' => 'uri',
324 'required' => true,
325 ),
326 'type' => array(
327 'description' => 'The type of the activity.',
328 'type' => 'string',
329 'required' => true,
330 ),
331 'actor' => array(
332 'description' => 'The actor performing the activity.',
333 'type' => array( 'string', 'object' ),
334 'format' => 'uri',
335 'required' => true,
336 ),
337 'object' => array(
338 'description' => 'The object of the activity.',
339 'type' => array( 'string', 'object' ),
340 'required' => true,
341 ),
342 'to' => array(
343 'description' => 'The primary recipients of the activity.',
344 'type' => 'array',
345 'items' => array(
346 'type' => 'string',
347 'format' => 'uri',
348 ),
349 ),
350 'cc' => array(
351 'description' => 'The secondary recipients of the activity.',
352 'type' => 'array',
353 'items' => array(
354 'type' => 'string',
355 'format' => 'uri',
356 ),
357 ),
358 'bcc' => array(
359 'description' => 'The private recipients of the activity.',
360 'type' => 'array',
361 'items' => array(
362 'type' => 'string',
363 'format' => 'uri',
364 ),
365 ),
366 ),
367 );
368
369 $this->schema = $schema;
370
371 return $this->add_additional_fields_schema( $this->schema );
372 }
373
374 /**
375 * Extract recipients from the given Activity.
376 *
377 * @param array $activity The activity data.
378 *
379 * @return array An array of user IDs who are the recipients of the activity.
380 */
381 private function get_local_recipients( $activity ) {
382 $user_ids = array();
383 $remote_fetches = 0;
384 $cap_notified = false;
385
386 /**
387 * Filters the maximum number of remote recipient URLs that can be
388 * fetched per incoming activity.
389 *
390 * @since 8.2.1
391 *
392 * @param int $max_remote_fetches Maximum number of remote fetches. Default 10.
393 */
394 $max_remote_fetches = (int) \apply_filters( 'activitypub_max_remote_recipient_fetches', 10 );
395
396 // AS2 allows actor and followers to be either an IRI string or an inline object; normalize to a URI.
397 $actor_uri = ! empty( $activity['actor'] ) ? object_to_uri( $activity['actor'] ) : null;
398 $actor_followers_url = $this->get_cached_followers_url( $actor_uri );
399
400 if ( is_activity_public( $activity ) ) {
401 $user_ids = Following::get_follower_ids( $actor_uri );
402 }
403
404 $recipients = extract_recipients_from_activity( $activity );
405
406 /*
407 * Pre-compute which recipients are already known remote actors so the
408 * cached-actor short-circuit becomes an O(1) array lookup rather than
409 * one DB query per recipient. This bounds the DB cost of a flood of
410 * unknown recipient URIs to one batched SELECT (chunked) regardless
411 * of how many were sent.
412 */
413 $candidate_uris = array();
414 foreach ( $recipients as $recipient ) {
415 if (
416 ! \is_string( $recipient )
417 || \in_array( $recipient, ACTIVITYPUB_PUBLIC_AUDIENCE_IDENTIFIERS, true )
418 || is_same_domain( $recipient )
419 || $recipient === $actor_followers_url
420 ) {
421 continue;
422 }
423 $candidate_uris[] = $recipient;
424 }
425 $cached_uris = $candidate_uris ? Remote_Actors::get_existing_uris( $candidate_uris ) : array();
426
427 foreach ( $recipients as $recipient ) {
428 // Skip public audience identifiers - they're not actual recipients to fetch.
429 if ( \in_array( $recipient, ACTIVITYPUB_PUBLIC_AUDIENCE_IDENTIFIERS, true ) ) {
430 continue;
431 }
432
433 if ( ! is_same_domain( $recipient ) ) {
434 // Known followers collection: resolve from local DB, no fetch needed.
435 if ( $recipient === $actor_followers_url ) {
436 $user_ids = \array_merge( $user_ids, Following::get_follower_ids( $actor_uri ) );
437 continue;
438 }
439
440 // Already cached as a remote actor: not a collection, so no local recipients to add.
441 if ( isset( $cached_uris[ $recipient ] ) ) {
442 continue;
443 }
444
445 // Unknown URL: cap remote fetches to prevent abuse via large audience/recipient fields.
446 if ( $remote_fetches >= $max_remote_fetches ) {
447 if ( ! $cap_notified ) {
448 $cap_notified = true;
449
450 /**
451 * Fires when an incoming activity hits the remote recipient fetch cap.
452 *
453 * Fires once per activity on the first recipient that exceeds the cap,
454 * not for each subsequent skipped recipient. Hook this to surface
455 * cap hits in your logging system of choice (Jetpack, Sentry, syslog, etc.).
456 *
457 * @since 8.2.1
458 *
459 * @param array $activity The incoming activity data.
460 * @param string $recipient The recipient URI that was skipped.
461 * @param int $cap The configured cap.
462 */
463 \do_action( 'activitypub_remote_recipient_fetch_cap_reached', $activity, $recipient, $max_remote_fetches );
464 }
465 continue;
466 }
467 ++$remote_fetches;
468
469 $collection = Http::get_remote_object( $recipient );
470
471 if ( \is_wp_error( $collection ) ) {
472 continue;
473 }
474
475 if ( is_collection( $collection ) ) {
476 $user_ids = \array_merge( $user_ids, Following::get_follower_ids( $actor_uri ) );
477 continue;
478 }
479 }
480
481 $user_id = Actors::get_id_by_resource( $recipient );
482
483 if ( \is_wp_error( $user_id ) ) {
484 continue;
485 }
486
487 if ( ! user_can_activitypub( $user_id ) ) {
488 continue;
489 }
490
491 $user_ids[] = $user_id;
492 }
493
494 // Check for an Actor in the Object field.
495 if ( empty( $user_ids ) && ! empty( $activity['object'] ) ) {
496 $user_id = Actors::get_id_by_resource( $activity['object'] );
497
498 if ( ! \is_wp_error( $user_id ) && user_can_activitypub( $user_id ) ) {
499 $user_ids[] = $user_id;
500 }
501 }
502
503 return \array_unique( \array_map( 'intval', $user_ids ) );
504 }
505
506 /**
507 * Look up an actor's followers collection URL from the cached profile.
508 *
509 * Used to detect followers-addressed recipients without an outbound fetch.
510 *
511 * @param string|null $actor_uri Normalized actor URI.
512 *
513 * @return string|null The followers collection URL, or null if not cached/available.
514 */
515 private function get_cached_followers_url( $actor_uri ) {
516 if ( empty( $actor_uri ) ) {
517 return null;
518 }
519
520 $actor_post = Remote_Actors::get_by_uri( $actor_uri );
521 if ( \is_wp_error( $actor_post ) ) {
522 return null;
523 }
524
525 // Match Remote_Actors::get_actor()'s storage fallback: legacy actor JSON lives in postmeta when post_content is empty.
526 $json = $actor_post->post_content;
527 if ( empty( $json ) ) {
528 $json = \get_post_meta( $actor_post->ID, '_activitypub_actor_json', true );
529 }
530
531 $actor_data = \json_decode( $json, true );
532 if ( empty( $actor_data['followers'] ) ) {
533 return null;
534 }
535
536 return object_to_uri( $actor_data['followers'] );
537 }
538 }
539