PluginProbe
ActivityPub / 8.3.0
ActivityPub v8.3.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 8.3.0, at includes/rest/class-outbox-controller.php

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