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

674 lines 20.1 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 // When user_id=0 (blog actor), get_current_user_id() also returns 0 for unauthenticated
182 // visitors, so the equality check alone is insufficient — we must test login state first.
183 if ( ! \is_user_logged_in() || ( \get_current_user_id() !== (int) $user_id && ! \current_user_can( 'activitypub' ) ) ) {
184 $args['meta_query'][] = array(
185 'key' => '_activitypub_activity_type',
186 'value' => $activity_types,
187 'compare' => 'IN',
188 );
189
190 $args['meta_query'][] = array(
191 'relation' => 'OR',
192 array(
193 'key' => 'activitypub_content_visibility',
194 'compare' => 'NOT EXISTS',
195 ),
196 array(
197 'key' => 'activitypub_content_visibility',
198 'value' => ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC,
199 ),
200 );
201 }
202
203 /**
204 * Filters WP_Query arguments when querying Outbox items via the REST API.
205 *
206 * Enables adding extra arguments or setting defaults for an outbox collection request.
207 *
208 * @param array $args Array of arguments for WP_Query.
209 * @param \WP_REST_Request $request The REST API request.
210 */
211 $args = \apply_filters( 'activitypub_rest_outbox_query', $args, $request );
212
213 $outbox_query = new \WP_Query();
214 $query_result = $outbox_query->query( $args );
215
216 $response = array(
217 '@context' => Base_Object::JSON_LD_CONTEXT,
218 'id' => get_rest_url_by_path( sprintf( 'actors/%d/outbox', $user_id ) ),
219 'generator' => 'https://wordpress.org/?v=' . get_masked_wp_version(),
220 'actor' => $user->get_id(),
221 'type' => 'OrderedCollection',
222 'totalItems' => (int) $outbox_query->found_posts,
223 'eventStream' => $this->get_stream_url( $user_id, 'outbox' ),
224 'orderedItems' => array(),
225 );
226
227 \update_postmeta_cache( \wp_list_pluck( $query_result, 'ID' ) );
228 foreach ( $query_result as $outbox_item ) {
229 if ( ! $outbox_item instanceof \WP_Post ) {
230 /**
231 * Action triggered when an outbox item is not a WP_Post.
232 *
233 * @param mixed $outbox_item The outbox item.
234 * @param array $args The arguments used to query the outbox.
235 * @param array $query_result The result of the query.
236 * @param \WP_REST_Request $request The request object.
237 */
238 \do_action( 'activitypub_rest_outbox_item_error', $outbox_item, $args, $query_result, $request );
239
240 continue;
241 }
242
243 $item = $this->prepare_item_for_response( $outbox_item, $request );
244
245 if ( \is_wp_error( $item ) ) {
246 continue;
247 }
248
249 $response['orderedItems'][] = $item;
250 }
251
252 $response = $this->prepare_collection_response( $response, $request );
253 if ( \is_wp_error( $response ) ) {
254 return $response;
255 }
256
257 /**
258 * Filter the ActivityPub outbox array.
259 *
260 * @param array $response The ActivityPub outbox array.
261 * @param \WP_REST_Request $request The request object.
262 */
263 $response = \apply_filters( 'activitypub_rest_outbox_array', $response, $request );
264
265 /**
266 * Action triggered after the ActivityPub profile has been created and sent to the client.
267 *
268 * @param \WP_REST_Request $request The request object.
269 */
270 \do_action( 'activitypub_rest_outbox_post', $request );
271
272 $response = \rest_ensure_response( $response );
273 $response->header( 'Content-Type', 'application/activity+json; charset=' . \get_option( 'blog_charset' ) );
274
275 return $response;
276 }
277
278 /**
279 * Prepares the item for the REST response.
280 *
281 * @param mixed $item WordPress representation of the item.
282 * @param \WP_REST_Request $request Request object.
283 * @return array Response object on success, or WP_Error object on failure.
284 */
285 public function prepare_item_for_response( $item, $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
286 $activity = Outbox::get_activity( $item->ID );
287
288 if ( \is_wp_error( $activity ) ) {
289 return $activity;
290 }
291
292 return $activity->to_array( false );
293 }
294
295 /**
296 * Retrieves the outbox schema, conforming to JSON Schema.
297 *
298 * @return array Collection schema data.
299 */
300 public function get_item_schema() {
301 if ( $this->schema ) {
302 return $this->add_additional_fields_schema( $this->schema );
303 }
304
305 $item_schema = array(
306 'type' => 'object',
307 );
308
309 $schema = $this->get_collection_schema( $item_schema );
310
311 // Add outbox-specific properties.
312 $schema['title'] = 'outbox';
313 $schema['properties']['actor'] = array(
314 'description' => 'The actor who owns this outbox.',
315 'type' => 'string',
316 'format' => 'uri',
317 'required' => true,
318 );
319 $schema['properties']['generator'] = array(
320 'description' => 'The software used to generate the collection.',
321 'type' => 'string',
322 'format' => 'uri',
323 );
324
325 $this->schema = $schema;
326
327 return $this->add_additional_fields_schema( $this->schema );
328 }
329
330 /**
331 * Overload total items for public requests.
332 *
333 * For unauthenticated (public) requests, the `totalItems` property shows
334 * the overall number of federated posts and comments, which is what
335 * Mastodon expects for display purposes.
336 *
337 * For authenticated C2S requests, we skip this override so that totalItems
338 * accurately reflects the actual outbox collection size.
339 *
340 * @param array $response The response array.
341 * @param \WP_REST_Request $request The request object.
342 *
343 * @return array The modified response array.
344 */
345 public function overload_total_items( $response, $request ) {
346 // For authenticated requests, return accurate totalItems matching orderedItems.
347 if ( \get_current_user_id() ) {
348 return $response;
349 }
350
351 $posts = new \WP_Query(
352 array(
353 'post_status' => 'publish',
354 'author' => $request->get_param( 'user_id' ),
355 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
356 'meta_query' => array(
357 array(
358 'key' => 'activitypub_status',
359 'compare' => 'EXISTS',
360 ),
361 ),
362 'fields' => 'ids',
363 'no_found_rows' => false,
364 'number' => 1,
365 )
366 );
367
368 $user_id = (int) $request->get_param( 'user_id' );
369 $comments = new \WP_Comment_Query(
370 array(
371 'status' => 'approve',
372 'user_id' => $user_id,
373 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
374 'meta_key' => 'activitypub_status',
375 'fields' => 'ids',
376 'no_found_rows' => false,
377 'number' => 1,
378 'author__not_in' => array( 0 ),
379 )
380 );
381
382 $response['totalItems'] = (int) $posts->found_posts + (int) $comments->found_comments;
383
384 return $response;
385 }
386
387 /**
388 * Create an item in the outbox.
389 *
390 * Fires handlers via filter to process the activity. Handlers are responsible
391 * for calling add_to_outbox() and returning the outbox_id.
392 *
393 * @param \WP_REST_Request $request Full details about the request.
394 * @return \WP_REST_Response|\WP_Error Response object on success, or WP_Error on failure.
395 */
396 public function create_item( $request ) {
397 $user_id = $request->get_param( 'user_id' );
398 $user = Actors::get_by_id( $user_id );
399
400 if ( \is_wp_error( $user ) ) {
401 return $user;
402 }
403
404 $data = $request->get_json_params();
405
406 if ( empty( $data ) ) {
407 return new \WP_Error(
408 'activitypub_invalid_request',
409 \__( 'Request body must be a valid ActivityPub object or activity.', 'activitypub' ),
410 array( 'status' => 400 )
411 );
412 }
413
414 // Validate ownership - ensure submitted actor matches authenticated user.
415 $ownership_validation = $this->validate_ownership( $data, $user );
416 if ( \is_wp_error( $ownership_validation ) ) {
417 return $ownership_validation;
418 }
419
420 // Determine if this is an Activity or a bare Object.
421 $type = $data['type'] ?? '';
422 $is_activity = in_array( $type, Activity::TYPES, true );
423
424 // If it's a bare object, wrap it in a Create activity.
425 if ( ! $is_activity ) {
426 $data = $this->wrap_in_create( $data, $user );
427 }
428
429 // Resolve language maps (summaryMap, contentMap, nameMap) to plain strings.
430 $data = $this->localize_language_maps( $data );
431
432 // Default to public addressing if client omits recipients.
433 $data = $this->ensure_addressing( $data, $user );
434
435 // Determine visibility from addressing.
436 $visibility = $this->determine_visibility( $data );
437
438 $type = \strtolower( $data['type'] ?? 'create' );
439
440 // Validate type against known activity types to prevent hook name pollution.
441 $allowed_types = \array_map( 'strtolower', Activity::TYPES );
442 if ( ! \in_array( $type, $allowed_types, true ) ) {
443 $type = 'create';
444 }
445
446 /**
447 * Filters the activity to add to outbox.
448 *
449 * Handlers can process the activity and return:
450 * - WP_Post: A WordPress post was created (scheduler adds to outbox)
451 * - int: An outbox post ID (activity already added to outbox)
452 * - WP_Error: Stop processing and return error
453 * - false: Stop processing (activity not allowed)
454 * - array: Modified activity data (fallback to default handling)
455 * - Other: No handler processed the activity (fallback to default)
456 *
457 * @param array $data The activity data.
458 * @param int $user_id The user ID.
459 * @param string $visibility Content visibility.
460 */
461 $result = \apply_filters( 'activitypub_outbox_' . $type, $data, $user_id, $visibility );
462
463 if ( \is_wp_error( $result ) ) {
464 return $result;
465 }
466
467 // Handler returned false to signal "not allowed" or "stop processing".
468 if ( false === $result ) {
469 return new \WP_Error(
470 'activitypub_activity_not_allowed',
471 \__( 'This activity type is not allowed.', 'activitypub' ),
472 array( 'status' => 403 )
473 );
474 }
475
476 $object_id = get_object_id( $result );
477
478 if ( $object_id ) {
479 // Handler returned a WP_Post or WP_Comment; look up its outbox entry.
480 $activity_type = \ucfirst( $data['type'] ?? 'Create' );
481 $outbox_item = Outbox::get_by_object_id( $object_id, $activity_type );
482 } elseif ( \is_int( $result ) && $result > 0 ) {
483 // Handler returned an outbox post ID directly.
484 $outbox_item = \get_post( $result );
485 } else {
486 // Default handling for raw activities.
487 $data = \is_array( $result ) ? $result : $data;
488 $data = $this->ensure_object_id( $data, $user );
489 $outbox_item = \get_post( add_to_outbox( $data, null, $user_id, $visibility ) );
490 }
491
492 if ( ! $outbox_item ) {
493 return new \WP_Error(
494 'activitypub_outbox_error',
495 \__( 'Failed to add activity to outbox.', 'activitypub' ),
496 array( 'status' => 500 )
497 );
498 }
499
500 // Get the stored activity.
501 $activity = Outbox::get_activity( $outbox_item );
502
503 if ( \is_wp_error( $activity ) ) {
504 return $activity;
505 }
506
507 $result = $activity->to_array( false );
508
509 // Return 201 Created with Location header.
510 $response = new \WP_REST_Response( $result, 201 );
511 $response->header( 'Location', $result['id'] ?? $outbox_item->guid );
512 $response->header( 'Content-Type', 'application/activity+json; charset=' . \get_option( 'blog_charset' ) );
513
514 return $response;
515 }
516
517 /**
518 * Wrap a bare object in a Create activity.
519 *
520 * @param array $object_data The object data.
521 * @param mixed $user The user/actor.
522 * @return array The wrapped Create activity.
523 */
524 private function wrap_in_create( $object_data, $user ) {
525 // Copy addressing from object to activity.
526 $addressing = array();
527 foreach ( array( 'to', 'bto', 'cc', 'bcc', 'audience' ) as $field ) {
528 if ( ! empty( $object_data[ $field ] ) ) {
529 $addressing[ $field ] = $object_data[ $field ];
530 }
531 }
532
533 return array_merge(
534 array(
535 '@context' => Base_Object::JSON_LD_CONTEXT,
536 'type' => 'Create',
537 'actor' => $user->get_id(),
538 'object' => $object_data,
539 ),
540 $addressing
541 );
542 }
543
544 /**
545 * Validate that activity actor matches the authenticated user.
546 *
547 * Ensures clients cannot submit activities with mismatched actor data.
548 *
549 * @param array $data The activity or object data.
550 * @param \Activitypub\Model\User|null $user The authenticated user.
551 * @return true|\WP_Error True if valid, WP_Error otherwise.
552 */
553 private function validate_ownership( $data, $user ) {
554 if ( ! $user ) {
555 return new \WP_Error(
556 'activitypub_invalid_user',
557 \__( 'Invalid user.', 'activitypub' ),
558 array( 'status' => 400 )
559 );
560 }
561
562 $user_actor_id = $user->get_id();
563
564 // Check activity actor if present.
565 if ( ! empty( $data['actor'] ) ) {
566 $actor_id = object_to_uri( $data['actor'] );
567 if ( $actor_id && $actor_id !== $user_actor_id ) {
568 return new \WP_Error(
569 'activitypub_actor_mismatch',
570 \__( 'Activity actor does not match authenticated user.', 'activitypub' ),
571 array( 'status' => 403 )
572 );
573 }
574 }
575
576 // Check object.attributedTo if present.
577 $object = $data['object'] ?? $data;
578 if ( is_array( $object ) && ! empty( $object['attributedTo'] ) ) {
579 $attributed_to = object_to_uri( $object['attributedTo'] );
580 if ( $attributed_to && $attributed_to !== $user_actor_id ) {
581 return new \WP_Error(
582 'activitypub_attribution_mismatch',
583 \__( 'Object attributedTo does not match authenticated user.', 'activitypub' ),
584 array( 'status' => 403 )
585 );
586 }
587 }
588
589 return true;
590 }
591
592 /**
593 * Add default public addressing when the client omits recipients.
594 *
595 * Per the ActivityPub spec, the server adds addressing when the client
596 * does not provide it. Defaults to public with followers in cc.
597 *
598 * @since 8.1.0
599 *
600 * @param array $data The activity data.
601 * @param \Activitypub\Activity\Actor $user The authenticated user.
602 * @return array The activity data with addressing ensured.
603 */
604 private function ensure_addressing( $data, $user ) {
605 $recipients = extract_recipients_from_activity( $data );
606
607 if ( ! empty( $recipients ) ) {
608 return $data;
609 }
610
611 $data['to'] = array( 'https://www.w3.org/ns/activitystreams#Public' );
612 $data['cc'] = array( $user->get_followers() );
613
614 return $data;
615 }
616
617 /**
618 * Determine content visibility from activity addressing.
619 *
620 * @param array $activity The activity data.
621 * @return string Visibility constant.
622 */
623 private function determine_visibility( $activity ) {
624 $public = 'https://www.w3.org/ns/activitystreams#Public';
625 $to = (array) ( $activity['to'] ?? array() );
626 $cc = (array) ( $activity['cc'] ?? array() );
627
628 // Check if public.
629 if ( in_array( $public, $to, true ) ) {
630 return ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC;
631 }
632
633 // Check if unlisted (public in cc).
634 if ( in_array( $public, $cc, true ) ) {
635 return ACTIVITYPUB_CONTENT_VISIBILITY_QUIET_PUBLIC;
636 }
637
638 // Private (no public addressing).
639 return ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE;
640 }
641
642 /**
643 * Ensure the activity object has required fields.
644 *
645 * For C2S activities, clients may not provide all required fields.
646 * The server should fill in attributedTo and published, but object IDs
647 * should only be set by handlers that create WordPress content.
648 *
649 * @param array $data The activity data.
650 * @param \Activitypub\Model\User|null $user The authenticated user.
651 * @return array The activity data with required fields ensured.
652 */
653 private function ensure_object_id( $data, $user ) {
654 // Check if there's an embedded object that needs fields.
655 if ( ! isset( $data['object'] ) || ! is_array( $data['object'] ) ) {
656 return $data;
657 }
658
659 $object = &$data['object'];
660
661 // Set attributedTo if missing.
662 if ( empty( $object['attributedTo'] ) && $user ) {
663 $object['attributedTo'] = $user->get_id();
664 }
665
666 // Set published if missing.
667 if ( empty( $object['published'] ) ) {
668 $object['published'] = \gmdate( 'Y-m-d\TH:i:s\Z' );
669 }
670
671 return $data;
672 }
673 }
674