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

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