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-outbox-controller.php

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

684 lines 20.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Outbox Controller file.
4 *
5 * @package Activitypub
6 */
7
8 namespace Activitypub\Rest;
9
10 use Activitypub\Activity\Activity;
11 use Activitypub\Activity\Base_Object;
12 use Activitypub\Collection\Actors;
13 use Activitypub\Collection\Outbox;
14
15 use function Activitypub\add_to_outbox;
16 use function Activitypub\extract_recipients_from_activity;
17 use function Activitypub\get_masked_wp_version;
18 use function Activitypub\get_object_id;
19 use function Activitypub\get_rest_url_by_path;
20 use function Activitypub\object_to_uri;
21
22 /**
23 * ActivityPub Outbox Controller.
24 *
25 * @author Matthias Pfefferle
26 *
27 * @see https://www.w3.org/TR/activitypub/#outbox
28 */
29 class Outbox_Controller extends \WP_REST_Controller {
30 use Collection;
31 use Event_Stream;
32 use Language_Map;
33 use Verification;
34
35 /**
36 * Activity types accessible as individual outbox items via REST.
37 *
38 * @var string[]
39 */
40 const PUBLIC_ACTIVITY_TYPES = array( 'Announce', 'Arrive', 'Create', 'Like', 'Update' );
41
42 /**
43 * The namespace of this controller's route.
44 *
45 * @var string
46 */
47 protected $namespace = ACTIVITYPUB_REST_NAMESPACE;
48
49 /**
50 * The base of this controller's route.
51 *
52 * @var string
53 */
54 protected $rest_base = '(?:users|actors)/(?P<user_id>[-]?\d+)/outbox';
55
56 /**
57 * Register routes.
58 */
59 public function register_routes() {
60 \register_rest_route(
61 $this->namespace,
62 '/' . $this->rest_base,
63 array(
64 'args' => array(
65 'user_id' => array(
66 'description' => 'The ID of the user or actor.',
67 'type' => 'integer',
68 'validate_callback' => array( $this, 'validate_user_id' ),
69 ),
70 ),
71 array(
72 'methods' => \WP_REST_Server::READABLE,
73 'callback' => array( $this, 'get_items' ),
74 'permission_callback' => array( $this, 'verify_signature' ),
75 'args' => array(
76 'page' => array(
77 'description' => 'Current page of the collection.',
78 'type' => 'integer',
79 'minimum' => 1,
80 // No default so we can differentiate between Collection and CollectionPage requests.
81 ),
82 'per_page' => array(
83 'description' => 'Maximum number of items to be returned in result set.',
84 'type' => 'integer',
85 'default' => 20,
86 'minimum' => 1,
87 'maximum' => 100,
88 ),
89 ),
90 ),
91 array(
92 'methods' => \WP_REST_Server::CREATABLE,
93 'callback' => array( $this, 'create_item' ),
94 'permission_callback' => array( $this, 'verify_authentication' ),
95 ),
96 'schema' => array( $this, 'get_item_schema' ),
97 )
98 );
99
100 \register_rest_route(
101 $this->namespace,
102 '/' . $this->rest_base . '/stream',
103 array(
104 'args' => array(
105 'user_id' => array(
106 'description' => 'The ID of the actor.',
107 'type' => 'integer',
108 'required' => true,
109 'validate_callback' => array( $this, 'validate_user_id' ),
110 ),
111 ),
112 array(
113 'methods' => \WP_REST_Server::READABLE,
114 'callback' => function ( $request ) {
115 $this->stream_collection( $request->get_param( 'user_id' ), 'outbox' );
116 },
117 'permission_callback' => array( $this, 'get_stream_permissions_check' ),
118 ),
119 )
120 );
121
122 \add_filter( 'activitypub_rest_outbox_array', array( $this, 'overload_total_items' ), 10, 2 );
123 }
124
125 /**
126 * Validates the user_id parameter.
127 *
128 * @param mixed $user_id The user_id parameter.
129 * @return bool|\WP_Error True if the user_id is valid, WP_Error otherwise.
130 */
131 public function validate_user_id( $user_id ) {
132 $user = Actors::get_by_id( $user_id );
133 if ( \is_wp_error( $user ) ) {
134 return $user;
135 }
136
137 return true;
138 }
139
140 /**
141 * Retrieves a collection of outbox items.
142 *
143 * @param \WP_REST_Request $request Full details about the request.
144 * @return \WP_REST_Response|\WP_Error Response object on success, or WP_Error object on failure.
145 */
146 public function get_items( $request ) {
147 $page = $request->get_param( 'page' ) ?? 1;
148 $user_id = $request->get_param( 'user_id' );
149 $user = Actors::get_by_id( $user_id );
150
151 /**
152 * Action triggered prior to the ActivityPub profile being created and sent to the client.
153 *
154 * @param \WP_REST_Request $request The request object.
155 */
156 \do_action( 'activitypub_rest_outbox_pre', $request );
157
158 /**
159 * Filters the activity types included in the outbox collection.
160 *
161 * @param string[] $activity_types The activity types.
162 */
163 $activity_types = \apply_filters( 'activitypub_outbox_activity_types', self::PUBLIC_ACTIVITY_TYPES );
164
165 $args = array(
166 'posts_per_page' => $request->get_param( 'per_page' ),
167 'author' => $user_id > 0 ? $user_id : null,
168 'paged' => $page,
169 'post_type' => Outbox::POST_TYPE,
170 'post_status' => 'any',
171
172 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
173 'meta_query' => array(
174 array(
175 'key' => '_activitypub_activity_actor',
176 'value' => Actors::get_type_by_id( $user_id ),
177 ),
178 ),
179 );
180
181 /*
182 * Whether the current user owns the outbox being queried. Owners see private and
183 * non-public activity types; unauthenticated, federation, and non-owner requests are
184 * limited to the public subset by the visibility filter below.
185 *
186 * Reuse the canonical ownership gate (the same check the OAuth C2S path applies via
187 * maybe_verify_owner()) instead of re-deriving it here: it requires an authenticated
188 * session, matches the requested user by identity, and handles the blog actor via
189 * user_can_act_as_blog(). A global capability never stands in for ownership.
190 */
191 $is_outbox_owner = true === $this->verify_owner( $request );
192
193 if ( ! $is_outbox_owner ) {
194 $args['meta_query'][] = array(
195 'key' => '_activitypub_activity_type',
196 'value' => $activity_types,
197 'compare' => 'IN',
198 );
199
200 $args['meta_query'][] = array(
201 'relation' => 'OR',
202 array(
203 'key' => 'activitypub_content_visibility',
204 'compare' => 'NOT EXISTS',
205 ),
206 array(
207 'key' => 'activitypub_content_visibility',
208 'value' => ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC,
209 ),
210 );
211 }
212
213 /**
214 * Filters WP_Query arguments when querying Outbox items via the REST API.
215 *
216 * Enables adding extra arguments or setting defaults for an outbox collection request.
217 *
218 * @param array $args Array of arguments for WP_Query.
219 * @param \WP_REST_Request $request The REST API request.
220 */
221 $args = \apply_filters( 'activitypub_rest_outbox_query', $args, $request );
222
223 $outbox_query = new \WP_Query();
224 $query_result = $outbox_query->query( $args );
225
226 $response = array(
227 '@context' => Base_Object::JSON_LD_CONTEXT,
228 'id' => get_rest_url_by_path( \sprintf( 'actors/%d/outbox', $user_id ) ),
229 'generator' => 'https://wordpress.org/?v=' . get_masked_wp_version(),
230 'actor' => $user->get_id(),
231 'type' => 'OrderedCollection',
232 'totalItems' => (int) $outbox_query->found_posts,
233 'eventStream' => $this->get_stream_url( $user_id, 'outbox' ),
234 'orderedItems' => array(),
235 );
236
237 \update_postmeta_cache( \wp_list_pluck( $query_result, 'ID' ) );
238 foreach ( $query_result as $outbox_item ) {
239 if ( ! $outbox_item instanceof \WP_Post ) {
240 /**
241 * Action triggered when an outbox item is not a WP_Post.
242 *
243 * @param mixed $outbox_item The outbox item.
244 * @param array $args The arguments used to query the outbox.
245 * @param array $query_result The result of the query.
246 * @param \WP_REST_Request $request The request object.
247 */
248 \do_action( 'activitypub_rest_outbox_item_error', $outbox_item, $args, $query_result, $request );
249
250 continue;
251 }
252
253 $item = $this->prepare_item_for_response( $outbox_item, $request );
254
255 if ( \is_wp_error( $item ) ) {
256 continue;
257 }
258
259 $response['orderedItems'][] = $item;
260 }
261
262 $response = $this->prepare_collection_response( $response, $request );
263 if ( \is_wp_error( $response ) ) {
264 return $response;
265 }
266
267 /**
268 * Filter the ActivityPub outbox array.
269 *
270 * @param array $response The ActivityPub outbox array.
271 * @param \WP_REST_Request $request The request object.
272 */
273 $response = \apply_filters( 'activitypub_rest_outbox_array', $response, $request );
274
275 /**
276 * Action triggered after the ActivityPub profile has been created and sent to the client.
277 *
278 * @param \WP_REST_Request $request The request object.
279 */
280 \do_action( 'activitypub_rest_outbox_post', $request );
281
282 $response = \rest_ensure_response( $response );
283 $response->header( 'Content-Type', 'application/activity+json; charset=' . \get_option( 'blog_charset' ) );
284
285 return $response;
286 }
287
288 /**
289 * Prepares the item for the REST response.
290 *
291 * @param mixed $item WordPress representation of the item.
292 * @param \WP_REST_Request $request Request object.
293 * @return array Response object on success, or WP_Error object on failure.
294 */
295 public function prepare_item_for_response( $item, $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
296 $activity = Outbox::get_activity( $item->ID );
297
298 if ( \is_wp_error( $activity ) ) {
299 return $activity;
300 }
301
302 return $activity->to_array( false );
303 }
304
305 /**
306 * Retrieves the outbox schema, conforming to JSON Schema.
307 *
308 * @return array Collection schema data.
309 */
310 public function get_item_schema() {
311 if ( $this->schema ) {
312 return $this->add_additional_fields_schema( $this->schema );
313 }
314
315 $item_schema = array(
316 'type' => 'object',
317 );
318
319 $schema = $this->get_collection_schema( $item_schema );
320
321 // Add outbox-specific properties.
322 $schema['title'] = 'outbox';
323 $schema['properties']['actor'] = array(
324 'description' => 'The actor who owns this outbox.',
325 'type' => 'string',
326 'format' => 'uri',
327 'required' => true,
328 );
329 $schema['properties']['generator'] = array(
330 'description' => 'The software used to generate the collection.',
331 'type' => 'string',
332 'format' => 'uri',
333 );
334
335 $this->schema = $schema;
336
337 return $this->add_additional_fields_schema( $this->schema );
338 }
339
340 /**
341 * Overload total items for public requests.
342 *
343 * For unauthenticated (public) requests, the `totalItems` property shows
344 * the overall number of federated posts and comments, which is what
345 * Mastodon expects for display purposes.
346 *
347 * For authenticated C2S requests, we skip this override so that totalItems
348 * accurately reflects the actual outbox collection size.
349 *
350 * @param array $response The response array.
351 * @param \WP_REST_Request $request The request object.
352 *
353 * @return array The modified response array.
354 */
355 public function overload_total_items( $response, $request ) {
356 // For authenticated requests, return accurate totalItems matching orderedItems.
357 if ( \get_current_user_id() ) {
358 return $response;
359 }
360
361 $posts = new \WP_Query(
362 array(
363 'post_status' => 'publish',
364 'author' => $request->get_param( 'user_id' ),
365 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
366 'meta_query' => array(
367 array(
368 'key' => 'activitypub_status',
369 'compare' => 'EXISTS',
370 ),
371 ),
372 'fields' => 'ids',
373 'no_found_rows' => false,
374 'number' => 1,
375 )
376 );
377
378 $user_id = (int) $request->get_param( 'user_id' );
379 $comments = new \WP_Comment_Query(
380 array(
381 'status' => 'approve',
382 'user_id' => $user_id,
383 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
384 'meta_key' => 'activitypub_status',
385 'fields' => 'ids',
386 'no_found_rows' => false,
387 'number' => 1,
388 'author__not_in' => array( 0 ),
389 )
390 );
391
392 $response['totalItems'] = (int) $posts->found_posts + (int) $comments->found_comments;
393
394 return $response;
395 }
396
397 /**
398 * Create an item in the outbox.
399 *
400 * Fires handlers via filter to process the activity. Handlers are responsible
401 * for calling add_to_outbox() and returning the outbox_id.
402 *
403 * @param \WP_REST_Request $request Full details about the request.
404 * @return \WP_REST_Response|\WP_Error Response object on success, or WP_Error on failure.
405 */
406 public function create_item( $request ) {
407 $user_id = $request->get_param( 'user_id' );
408 $user = Actors::get_by_id( $user_id );
409
410 if ( \is_wp_error( $user ) ) {
411 return $user;
412 }
413
414 $data = $request->get_json_params();
415
416 if ( empty( $data ) ) {
417 return new \WP_Error(
418 'activitypub_invalid_request',
419 \__( 'Request body must be a valid ActivityPub object or activity.', 'activitypub' ),
420 array( 'status' => 400 )
421 );
422 }
423
424 // Validate ownership - ensure submitted actor matches authenticated user.
425 $ownership_validation = $this->validate_ownership( $data, $user );
426 if ( \is_wp_error( $ownership_validation ) ) {
427 return $ownership_validation;
428 }
429
430 // Determine if this is an Activity or a bare Object.
431 $type = $data['type'] ?? '';
432 $is_activity = \in_array( $type, Activity::TYPES, true );
433
434 // If it's a bare object, wrap it in a Create activity.
435 if ( ! $is_activity ) {
436 $data = $this->wrap_in_create( $data, $user );
437 }
438
439 // Resolve language maps (summaryMap, contentMap, nameMap) to plain strings.
440 $data = $this->localize_language_maps( $data );
441
442 // Default to public addressing if client omits recipients.
443 $data = $this->ensure_addressing( $data, $user );
444
445 // Determine visibility from addressing.
446 $visibility = $this->determine_visibility( $data );
447
448 $type = \strtolower( $data['type'] ?? 'create' );
449
450 // Validate type against known activity types to prevent hook name pollution.
451 $allowed_types = \array_map( 'strtolower', Activity::TYPES );
452 if ( ! \in_array( $type, $allowed_types, true ) ) {
453 $type = 'create';
454 }
455
456 /**
457 * Filters the activity to add to outbox.
458 *
459 * Handlers can process the activity and return:
460 * - WP_Post: A WordPress post was created (scheduler adds to outbox)
461 * - int: An outbox post ID (activity already added to outbox)
462 * - WP_Error: Stop processing and return error
463 * - false: Stop processing (activity not allowed)
464 * - array: Modified activity data (fallback to default handling)
465 * - Other: No handler processed the activity (fallback to default)
466 *
467 * @param array $data The activity data.
468 * @param int $user_id The user ID.
469 * @param string $visibility Content visibility.
470 */
471 $result = \apply_filters( 'activitypub_outbox_' . $type, $data, $user_id, $visibility );
472
473 if ( \is_wp_error( $result ) ) {
474 return $result;
475 }
476
477 // Handler returned false to signal "not allowed" or "stop processing".
478 if ( false === $result ) {
479 return new \WP_Error(
480 'activitypub_activity_not_allowed',
481 \__( 'This activity type is not allowed.', 'activitypub' ),
482 array( 'status' => 403 )
483 );
484 }
485
486 $object_id = get_object_id( $result );
487
488 if ( $object_id ) {
489 // Handler returned a WP_Post or WP_Comment; look up its outbox entry.
490 $activity_type = \ucfirst( $data['type'] ?? 'Create' );
491 $outbox_item = Outbox::get_by_object_id( $object_id, $activity_type );
492 } elseif ( \is_int( $result ) && $result > 0 ) {
493 // Handler returned an outbox post ID directly.
494 $outbox_item = \get_post( $result );
495 } else {
496 // Default handling for raw activities.
497 $data = \is_array( $result ) ? $result : $data;
498 $data = $this->ensure_object_id( $data, $user );
499 $outbox_item = \get_post( add_to_outbox( $data, null, $user_id, $visibility ) );
500 }
501
502 if ( ! $outbox_item ) {
503 return new \WP_Error(
504 'activitypub_outbox_error',
505 \__( 'Failed to add activity to outbox.', 'activitypub' ),
506 array( 'status' => 500 )
507 );
508 }
509
510 // Get the stored activity.
511 $activity = Outbox::get_activity( $outbox_item );
512
513 if ( \is_wp_error( $activity ) ) {
514 return $activity;
515 }
516
517 $result = $activity->to_array( false );
518
519 // Return 201 Created with Location header.
520 $response = new \WP_REST_Response( $result, 201 );
521 $response->header( 'Location', $result['id'] ?? $outbox_item->guid );
522 $response->header( 'Content-Type', 'application/activity+json; charset=' . \get_option( 'blog_charset' ) );
523
524 return $response;
525 }
526
527 /**
528 * Wrap a bare object in a Create activity.
529 *
530 * @param array $object_data The object data.
531 * @param mixed $user The user/actor.
532 * @return array The wrapped Create activity.
533 */
534 private function wrap_in_create( $object_data, $user ) {
535 // Copy addressing from object to activity.
536 $addressing = array();
537 foreach ( array( 'to', 'bto', 'cc', 'bcc', 'audience' ) as $field ) {
538 if ( ! empty( $object_data[ $field ] ) ) {
539 $addressing[ $field ] = $object_data[ $field ];
540 }
541 }
542
543 return \array_merge(
544 array(
545 '@context' => Base_Object::JSON_LD_CONTEXT,
546 'type' => 'Create',
547 'actor' => $user->get_id(),
548 'object' => $object_data,
549 ),
550 $addressing
551 );
552 }
553
554 /**
555 * Validate that activity actor matches the authenticated user.
556 *
557 * Ensures clients cannot submit activities with mismatched actor data.
558 *
559 * @param array $data The activity or object data.
560 * @param \Activitypub\Model\User|null $user The authenticated user.
561 * @return true|\WP_Error True if valid, WP_Error otherwise.
562 */
563 private function validate_ownership( $data, $user ) {
564 if ( ! $user ) {
565 return new \WP_Error(
566 'activitypub_invalid_user',
567 \__( 'Invalid user.', 'activitypub' ),
568 array( 'status' => 400 )
569 );
570 }
571
572 $user_actor_id = $user->get_id();
573
574 // Check activity actor if present.
575 if ( ! empty( $data['actor'] ) ) {
576 $actor_id = object_to_uri( $data['actor'] );
577 if ( $actor_id && $actor_id !== $user_actor_id ) {
578 return new \WP_Error(
579 'activitypub_actor_mismatch',
580 \__( 'Activity actor does not match authenticated user.', 'activitypub' ),
581 array( 'status' => 403 )
582 );
583 }
584 }
585
586 // Check object.attributedTo if present.
587 $object = $data['object'] ?? $data;
588 if ( \is_array( $object ) && ! empty( $object['attributedTo'] ) ) {
589 $attributed_to = object_to_uri( $object['attributedTo'] );
590 if ( $attributed_to && $attributed_to !== $user_actor_id ) {
591 return new \WP_Error(
592 'activitypub_attribution_mismatch',
593 \__( 'Object attributedTo does not match authenticated user.', 'activitypub' ),
594 array( 'status' => 403 )
595 );
596 }
597 }
598
599 return true;
600 }
601
602 /**
603 * Add default public addressing when the client omits recipients.
604 *
605 * Per the ActivityPub spec, the server adds addressing when the client
606 * does not provide it. Defaults to public with followers in cc.
607 *
608 * @since 8.1.0
609 *
610 * @param array $data The activity data.
611 * @param \Activitypub\Activity\Actor $user The authenticated user.
612 * @return array The activity data with addressing ensured.
613 */
614 private function ensure_addressing( $data, $user ) {
615 $recipients = extract_recipients_from_activity( $data );
616
617 if ( ! empty( $recipients ) ) {
618 return $data;
619 }
620
621 $data['to'] = array( 'https://www.w3.org/ns/activitystreams#Public' );
622 $data['cc'] = array( $user->get_followers() );
623
624 return $data;
625 }
626
627 /**
628 * Determine content visibility from activity addressing.
629 *
630 * @param array $activity The activity data.
631 * @return string Visibility constant.
632 */
633 private function determine_visibility( $activity ) {
634 $public = 'https://www.w3.org/ns/activitystreams#Public';
635 $to = (array) ( $activity['to'] ?? array() );
636 $cc = (array) ( $activity['cc'] ?? array() );
637
638 // Check if public.
639 if ( \in_array( $public, $to, true ) ) {
640 return ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC;
641 }
642
643 // Check if unlisted (public in cc).
644 if ( \in_array( $public, $cc, true ) ) {
645 return ACTIVITYPUB_CONTENT_VISIBILITY_QUIET_PUBLIC;
646 }
647
648 // Private (no public addressing).
649 return ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE;
650 }
651
652 /**
653 * Ensure the activity object has required fields.
654 *
655 * For C2S activities, clients may not provide all required fields.
656 * The server should fill in attributedTo and published, but object IDs
657 * should only be set by handlers that create WordPress content.
658 *
659 * @param array $data The activity data.
660 * @param \Activitypub\Model\User|null $user The authenticated user.
661 * @return array The activity data with required fields ensured.
662 */
663 private function ensure_object_id( $data, $user ) {
664 // Check if there's an embedded object that needs fields.
665 if ( ! isset( $data['object'] ) || ! \is_array( $data['object'] ) ) {
666 return $data;
667 }
668
669 $object = &$data['object'];
670
671 // Set attributedTo if missing.
672 if ( empty( $object['attributedTo'] ) && $user ) {
673 $object['attributedTo'] = $user->get_id();
674 }
675
676 // Set published if missing.
677 if ( empty( $object['published'] ) ) {
678 $object['published'] = \gmdate( 'Y-m-d\TH:i:s\Z' );
679 }
680
681 return $data;
682 }
683 }
684