Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
84.91% covered (warning)
84.91%
242 / 285
40.00% covered (danger)
40.00%
6 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
Connections_Post_Field
84.91% covered (warning)
84.91%
242 / 285
40.00% covered (danger)
40.00%
6 / 15
139.44
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 register_fields
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
3
 get_schema
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 post_connection_schema
100.00% covered (success)
100.00%
67 / 67
100.00% covered (success)
100.00%
1 / 1
1
 permission_check
23.08% covered (danger)
23.08%
3 / 13
0.00% covered (danger)
0.00%
0 / 1
7.10
 get
74.36% covered (warning)
74.36%
29 / 39
0.00% covered (danger)
0.00%
0 / 1
23.46
 rest_pre_insert
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
6.03
 set_meta_for_new_post
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 rest_insert
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
3.33
 get_meta_to_update
79.07% covered (warning)
79.07%
34 / 43
0.00% covered (danger)
0.00%
0 / 1
25.04
 update
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
6.05
 save_connection_overrides
81.48% covered (warning)
81.48%
22 / 27
0.00% covered (danger)
0.00%
0 / 1
16.43
 sanitize_attached_media
87.50% covered (warning)
87.50%
14 / 16
0.00% covered (danger)
0.00%
0 / 1
8.12
 filter_response_by_context
86.36% covered (warning)
86.36%
19 / 22
0.00% covered (danger)
0.00%
0 / 1
12.37
 is_valid_for_context
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2/**
3 * Registers the API field for Publicize connections.
4 *
5 * @package automattic/jetpack-publicize
6 */
7
8namespace Automattic\Jetpack\Publicize\REST_API;
9
10use Automattic\Jetpack\Current_Plan;
11use Automattic\Jetpack\Publicize\Publicize_Base;
12use WP_Error;
13use WP_Post;
14use WP_REST_Request;
15
16/**
17 * The class to register the field and augment requests
18 * to Publicize supported post types.
19 *
20 * @phan-constructor-used-for-side-effects
21 */
22class Connections_Post_Field {
23
24    const FIELD_NAME = 'jetpack_publicize_connections';
25
26    /**
27     * Array of post IDs that have been updated.
28     *
29     * @var array
30     */
31    private $meta_saved = array();
32
33    /**
34     * Used to memoize the updates for a given post.
35     *
36     * @var array
37     */
38    public $memoized_updates = array();
39
40    /**
41     * Requested connections for a new post being created, kept until the post
42     * ID is known so the skip meta can be persisted before Publicize runs.
43     *
44     * @var array
45     */
46    private $new_post_connections = array();
47
48    /**
49     * The request that is creating a new post, kept alongside
50     * $new_post_connections so per-connection overrides can be saved.
51     *
52     * @var WP_REST_Request|null
53     */
54    private $new_post_request = null;
55
56    /**
57     * Constructor.
58     */
59    public function __construct() {
60        add_action( 'rest_api_init', array( $this, 'register_fields' ) );
61    }
62
63    /**
64     * Registers the jetpack_publicize_connections field. Called
65     * automatically on `rest_api_init()`.
66     */
67    public function register_fields() {
68        $post_types = get_post_types_by_support( 'publicize' );
69        foreach ( $post_types as $post_type ) {
70            // Adds meta support for those post types that don't already have it.
71            // Only runs during REST API requests, so it doesn't impact UI.
72            if ( ! post_type_supports( $post_type, 'custom-fields' ) ) {
73                add_post_type_support( $post_type, 'custom-fields' );
74            }
75
76            // We use these hooks and not the update_callback because we must updateth meta
77            // before we set the post as published, otherwise the wrong connections could be used.
78            add_filter( 'rest_pre_insert_' . $post_type, array( $this, 'rest_pre_insert' ), 10, 2 );
79            add_action( 'rest_insert_' . $post_type, array( $this, 'rest_insert' ), 10, 3 );
80
81            register_rest_field(
82                $post_type,
83                self::FIELD_NAME,
84                array(
85                    'get_callback' => array( $this, 'get' ),
86                    'schema'       => $this->get_schema(),
87                )
88            );
89        }
90    }
91
92    /**
93     * Defines data structure and what elements are visible in which contexts
94     */
95    public function get_schema() {
96        return array(
97            '$schema' => 'http://json-schema.org/draft-04/schema#',
98            'title'   => 'jetpack-publicize-post-connections',
99            'type'    => 'array',
100            'context' => array( 'view', 'edit' ),
101            'items'   => $this->post_connection_schema(),
102            'default' => array(),
103        );
104    }
105
106    /**
107     * Schema for the endpoint.
108     */
109    private function post_connection_schema() {
110        $connection_fields = Connections_Controller::get_the_item_schema();
111        $deprecated_fields = array(
112            'id'             => array(
113                'type'        => 'string',
114                'description' => __( 'Unique identifier for the Jetpack Social connection.', 'jetpack-publicize-pkg' ) . ' ' . sprintf(
115                    /* translators: %s is the new field name */
116                    __( 'Deprecated in favor of %s.', 'jetpack-publicize-pkg' ),
117                    'connection_id'
118                ),
119            ),
120            'username'       => array(
121                'type'        => 'string',
122                'description' => __( 'Username of the connected account.', 'jetpack-publicize-pkg' ) . ' ' . sprintf(
123                    /* translators: %s is the new field name */
124                    __( 'Deprecated in favor of %s.', 'jetpack-publicize-pkg' ),
125                    'external_handle'
126                ),
127            ),
128            'can_disconnect' => array(
129                'description' => __( 'Whether the current user can disconnect this connection.', 'jetpack-publicize-pkg' ) . ' ' . __( 'Deprecated.', 'jetpack-publicize-pkg' ),
130                'type'        => 'boolean',
131                'context'     => array( 'view', 'edit' ),
132                'readonly'    => true,
133            ),
134        );
135
136        return array(
137            '$schema'    => 'http://json-schema.org/draft-04/schema#',
138            'title'      => 'jetpack-publicize-post-connection',
139            'type'       => 'object',
140            'properties' => array_merge(
141                $deprecated_fields,
142                $connection_fields,
143                array(
144                    'enabled'        => array(
145                        'description' => __( 'Whether to share to this connection.', 'jetpack-publicize-pkg' ),
146                        'type'        => 'boolean',
147                        'context'     => array( 'edit' ),
148                    ),
149                    'message'        => array(
150                        'description' => __( 'Custom message to use for this connection instead of the global message.', 'jetpack-publicize-pkg' ),
151                        'type'        => 'string',
152                        'context'     => array( 'edit' ),
153                    ),
154                    'attached_media' => array(
155                        'description' => __( 'Custom media to attach for this connection instead of the global media.', 'jetpack-publicize-pkg' ),
156                        'type'        => 'array',
157                        'context'     => array( 'edit' ),
158                        'items'       => array(
159                            'type'       => 'object',
160                            'properties' => array(
161                                'id'   => array( 'type' => 'number' ),
162                                'url'  => array( 'type' => 'string' ),
163                                'type' => array( 'type' => 'string' ),
164                            ),
165                        ),
166                    ),
167                    'media_source'   => array(
168                        'type' => 'string',
169                        'enum' => array(
170                            'featured-image',
171                            'sig',
172                            'media-library',
173                            'upload-video',
174                            'none',
175                        ),
176                    ),
177                )
178            ),
179        );
180    }
181
182    /**
183     * Permission check, based on module availability and user capabilities.
184     *
185     * @param int $post_id Post ID.
186     *
187     * @return true|WP_Error
188     */
189    public function permission_check( $post_id ) {
190        global $publicize;
191
192        if ( ! $publicize ) {
193            return new WP_Error(
194                'publicize_not_available',
195                __( 'Sorry, Jetpack Social is not available on your site right now.', 'jetpack-publicize-pkg' ),
196                array( 'status' => rest_authorization_required_code() )
197            );
198        }
199
200        if ( $publicize->current_user_can_access_publicize_data( $post_id ) ) {
201            return true;
202        }
203
204        return new WP_Error(
205            'invalid_user_permission_publicize',
206            __( 'Sorry, you are not allowed to access Jetpack Social data for this post.', 'jetpack-publicize-pkg' ),
207            array( 'status' => rest_authorization_required_code() )
208        );
209    }
210
211    /**
212     * The field's wrapped getter. Does permission checks and output preparation.
213     *
214     * This cannot be extended: implement `->get()` instead.
215     *
216     * @param mixed           $post_array Probably an array. Whatever the endpoint returns.
217     * @param string          $field_name  Should always match `->field_name`.
218     * @param WP_REST_Request $request     WP API request.
219     * @param string          $object_type Should always match `->object_type`.
220     *
221     * @return mixed
222     */
223    public function get( $post_array, $field_name, $request, $object_type ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable, Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
224        global $publicize;
225
226        $post_id          = $post_array['id'] ?? 0;
227        $full_schema      = $this->get_schema();
228        $permission_check = $this->permission_check( $post_id );
229        if ( is_wp_error( $permission_check ) ) {
230            return $full_schema['default'];
231        }
232
233        $schema      = $full_schema['items'];
234        $properties  = array_keys( $schema['properties'] );
235        $connections = $publicize->get_filtered_connection_data( $post_id );
236
237        if ( $publicize && $publicize->has_paid_features() ) {
238            // Check if per-network customization is enabled.
239            $customize_per_network = get_post_meta( $post_id, Publicize_Base::POST_CUSTOMIZE_PER_NETWORK, true );
240            // Get per-connection overrides from post meta.
241            $connection_overrides = get_post_meta( $post_id, Publicize_Base::POST_CONNECTION_OVERRIDES, true );
242
243            if ( ! is_array( $connection_overrides ) ) {
244                $connection_overrides = array();
245            }
246        } else {
247            $customize_per_network = false;
248            $connection_overrides  = array();
249        }
250
251        $message_templates_enabled = Current_Plan::supports( 'social-message-templates' );
252
253        $output_connections = array();
254        foreach ( $connections as $connection ) {
255            $output_connection = array();
256            foreach ( $properties as $property ) {
257                if ( isset( $connection[ $property ] ) ) {
258                    $output_connection[ $property ] = $connection[ $property ];
259                }
260            }
261
262            // Default `message` to the connection's own template when set
263            if ( $message_templates_enabled && ! empty( $output_connection['template'] ) ) {
264                $output_connection['message'] = $output_connection['template'];
265            }
266
267            // Merge per-connection overrides if global flag is enabled.
268            $connection_id = $connection['connection_id'] ?? '';
269            if ( $customize_per_network && ! empty( $connection_id ) && isset( $connection_overrides[ $connection_id ] ) ) {
270                $override = $connection_overrides[ $connection_id ];
271                if ( isset( $override['message'] ) ) {
272                    $output_connection['message'] = $override['message'];
273                }
274                if ( isset( $override['attached_media'] ) ) {
275                    $output_connection['attached_media'] = $override['attached_media'];
276                }
277                if ( isset( $override['media_source'] ) ) {
278                    $output_connection['media_source'] = $override['media_source'];
279                }
280            }
281
282            $output_connections[] = $output_connection;
283        }
284
285        // TODO: Work out if this is necessary. We shouldn't be creating an invalid value here.
286        $is_valid = rest_validate_value_from_schema( $output_connections, $full_schema, self::FIELD_NAME );
287        if ( is_wp_error( $is_valid ) ) {
288            return $is_valid;
289        }
290
291        $context = ! empty( $request['context'] ) ? $request['context'] : 'view';
292        return $this->filter_response_by_context( $output_connections, $full_schema, $context );
293    }
294
295    /**
296     * Prior to updating the post, first calculate which Services to
297     * Publicize to and which to skip.
298     *
299     * @param object          $post    Post data to insert/update.
300     * @param WP_REST_Request $request API request.
301     *
302     * @return object|WP_Error Filtered $post
303     */
304    public function rest_pre_insert( $post, $request ) {
305        $request_connections = ! empty( $request['jetpack_publicize_connections'] ) ? $request['jetpack_publicize_connections'] : array();
306
307        $permission_check = $this->permission_check( empty( $post->ID ) ? 0 : $post->ID );
308        if ( is_wp_error( $permission_check ) ) {
309            return empty( $request_connections ) ? $post : $permission_check;
310        }
311        // memoize.
312        $this->get_meta_to_update( $request_connections, $post->ID ?? 0 );
313
314        if ( isset( $post->ID ) ) {
315            // Set the meta before we mark the post as published so that publicize works as expected.
316            // If this is not the case post end up on social media when they are marked as skipped.
317            $this->update( $request_connections, $post, $request );
318        } else {
319            /*
320             * A brand new post has no ID yet, so we can't persist the skip meta here.
321             * Persist it as soon as the post is inserted, before Publicize processes
322             * the publish transition (priority 10). Otherwise a new published post is
323             * shared to every connection regardless of the requested `enabled` state.
324             */
325            $this->new_post_connections = $request_connections;
326            $this->new_post_request     = $request;
327            add_action( 'transition_post_status', array( $this, 'set_meta_for_new_post' ), 1, 3 );
328        }
329
330        return $post;
331    }
332
333    /**
334     * Persist the Publicize skip meta for a newly created post.
335     *
336     * Runs on `transition_post_status` before Publicize flags the post for
337     * sharing, using the memoized data calculated in rest_pre_insert(). This is
338     * the create-time counterpart to the in-place update() done for existing posts.
339     *
340     * @param string  $new_status New post status.
341     * @param string  $old_status Old post status.
342     * @param WP_Post $post       Post object.
343     */
344    public function set_meta_for_new_post( $new_status, $old_status, $post ) {
345        if ( ! isset( $this->memoized_updates[0] ) || wp_is_post_revision( $post->ID ) ) {
346            return;
347        }
348
349        // One-shot: the first non-revision transition is the post we created.
350        remove_action( 'transition_post_status', array( $this, 'set_meta_for_new_post' ), 1 );
351
352        // Move the memoized data to the real post ID now that we know it.
353        $this->memoized_updates[ $post->ID ] = $this->memoized_updates[0];
354        unset( $this->memoized_updates[0] );
355
356        $this->update( $this->new_post_connections, $post, $this->new_post_request );
357
358        $this->new_post_connections = array();
359        $this->new_post_request     = null;
360    }
361
362    /**
363     * After creating a new post, update our cached data to reflect
364     * the new post ID.
365     *
366     * @param WP_Post         $post    Post data to update.
367     * @param WP_REST_Request $request API request.
368     * @param bool            $is_new  Is this a new post.
369     */
370    public function rest_insert( $post, $request, $is_new ) {
371        if ( ! $is_new ) {
372            // An existing post was edited - no need to update
373            // our cache - we started out knowing the correct
374            // post ID.
375            return;
376        }
377
378        if ( ! isset( $this->memoized_updates[0] ) ) {
379            return;
380        }
381
382        $this->memoized_updates[ $post->ID ] = $this->memoized_updates[0];
383        unset( $this->memoized_updates[0] );
384    }
385
386    /**
387     * Get list of meta data to update per post ID.
388     *
389     * @param array $requested_connections Publicize connections to update.
390     *              Items are either `{ id: (string) }` or `{ service_name: (string) }`.
391     * @param int   $post_id    Post ID.
392     */
393    protected function get_meta_to_update( $requested_connections, $post_id = 0 ) {
394        global $publicize;
395
396        if ( ! $publicize || ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) ) {
397            return array();
398        }
399
400        // Return memoized data first: a new post is already 'publish' by the time
401        // we persist its skip meta, so the publish check below must not discard it.
402        if ( isset( $this->memoized_updates[ $post_id ] ) ) {
403            return $this->memoized_updates[ $post_id ];
404        }
405
406        $post = get_post( $post_id );
407        if ( isset( $post->post_status ) && 'publish' === $post->post_status ) {
408            return array();
409        }
410
411        $available_connections = $publicize->get_filtered_connection_data( $post_id );
412
413        $changed_connections = array();
414
415        // Build lookup mappings.
416        $available_connections_by_connection_id = array();
417        $available_connections_by_service_name  = array();
418        foreach ( $available_connections as $available_connection ) {
419            $available_connections_by_connection_id[ $available_connection['connection_id'] ] = $available_connection;
420
421            if ( ! isset( $available_connections_by_service_name[ $available_connection['service_name'] ] ) ) {
422                $available_connections_by_service_name[ $available_connection['service_name'] ] = array();
423            }
424            $available_connections_by_service_name[ $available_connection['service_name'] ][] = $available_connection;
425        }
426
427        // Handle { service_name: $service_name, enabled: (bool) }.
428        // If the service is not available, it will be skipped.
429        foreach ( $requested_connections as $requested_connection ) {
430            if ( ! isset( $requested_connection['service_name'] ) ) {
431                continue;
432            }
433
434            if ( ! isset( $available_connections_by_service_name[ $requested_connection['service_name'] ] ) ) {
435                continue;
436            }
437
438            foreach ( $available_connections_by_service_name[ $requested_connection['service_name'] ] as $available_connection ) {
439                if ( $requested_connection['connection_id'] === $available_connection['connection_id'] ) {
440                    $changed_connections[ $available_connection['connection_id'] ] = $requested_connection['enabled'];
441                    break;
442                }
443            }
444        }
445
446        // Handle { id: $id, enabled: (bool) }
447        // These override the service_name settings.
448        foreach ( $requested_connections as $requested_connection ) {
449            if ( ! isset( $requested_connection['connection_id'] ) ) {
450                continue;
451            }
452
453            if ( ! isset( $available_connections_by_connection_id[ $requested_connection['connection_id'] ] ) ) {
454                continue;
455            }
456
457            $changed_connections[ $requested_connection['connection_id'] ] = $requested_connection['enabled'];
458        }
459
460        // Set all changed connections to their new value.
461        foreach ( $changed_connections as $id => $enabled ) {
462            $connection = $available_connections_by_connection_id[ $id ];
463
464            if ( $connection['done'] ) {
465                continue;
466            }
467
468            $available_connections_by_connection_id[ $id ]['enabled'] = $enabled;
469        }
470
471        $meta_to_update = array();
472        // For all connections, ensure correct post_meta.
473        foreach ( $available_connections_by_connection_id as $connection_id => $available_connection ) {
474            if ( $available_connection['enabled'] ) {
475                $meta_to_update[ $publicize->POST_SKIP_PUBLICIZE . $connection_id ] = null; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
476            } else {
477                $meta_to_update[ $publicize->POST_SKIP_PUBLICIZE . $connection_id ] = 1; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
478            }
479        }
480
481        $this->memoized_updates[ $post_id ] = $meta_to_update;
482
483        return $meta_to_update;
484    }
485
486    /**
487     * Update the connections slated to be shared to.
488     *
489     * @param array           $requested_connections Publicize connections to update.
490     *              Items are either `{ id: (string) }` or `{ service_name: (string) }`.
491     * @param WP_Post         $post    Post data.
492     * @param WP_REST_Request $request API request.
493     */
494    public function update( $requested_connections, $post, $request = null ) {
495        global $publicize;
496
497        if ( isset( $this->meta_saved[ $post->ID ] ) ) { // Make sure we only save it once - per request.
498            return;
499        }
500        foreach ( $this->get_meta_to_update( $requested_connections, $post->ID ) as $meta_key => $meta_value ) {
501            if ( null === $meta_value ) {
502                delete_post_meta( $post->ID, $meta_key );
503            } else {
504                update_post_meta( $post->ID, $meta_key, $meta_value );
505            }
506        }
507
508        // Save per-connection overrides.
509        if ( $publicize && $publicize->has_paid_features() ) {
510            $this->save_connection_overrides( $requested_connections, $post->ID, $request );
511        }
512
513        $this->meta_saved[ $post->ID ] = true;
514    }
515
516    /**
517     * Save per-connection customization overrides.
518     *
519     * Extracts message and attached_media from each connection and persists
520     * them to post meta when per-network customization is enabled.
521     *
522     * @param array           $requested_connections Array of connection data from the request.
523     * @param int             $post_id               Post ID.
524     * @param WP_REST_Request $request               API request.
525     */
526    private function save_connection_overrides( $requested_connections, $post_id, $request = null ) {
527        // Check if per-network customization is enabled - prefer request value over database.
528        $customize_per_network = null;
529        if ( $request && isset( $request['meta'][ Publicize_Base::POST_CUSTOMIZE_PER_NETWORK ] ) ) {
530            $customize_per_network = $request['meta'][ Publicize_Base::POST_CUSTOMIZE_PER_NETWORK ];
531        }
532        if ( null === $customize_per_network ) {
533            $customize_per_network = get_post_meta( $post_id, Publicize_Base::POST_CUSTOMIZE_PER_NETWORK, true );
534        }
535
536        // If customization is disabled, remove any existing overrides.
537        if ( ! $customize_per_network ) {
538            delete_post_meta( $post_id, Publicize_Base::POST_CONNECTION_OVERRIDES );
539            return;
540        }
541
542        // If the request does not have connections, skip.
543        if ( ! isset( $request[ self::FIELD_NAME ] ) ) {
544            return;
545        }
546
547        $overrides = array();
548
549        foreach ( $requested_connections as $connection ) {
550            // Only process if connection has a connection_id.
551            if ( empty( $connection['connection_id'] ) ) {
552                continue;
553            }
554
555            // Only save if connection has custom message or attached_media.
556            if ( ! isset( $connection['message'] ) && ! isset( $connection['attached_media'] ) && ! isset( $connection['media_source'] ) ) {
557                continue;
558            }
559
560            $connection_id               = $connection['connection_id'];
561            $overrides[ $connection_id ] = array();
562
563            // Save message (can be empty to use empty message).
564            if ( isset( $connection['message'] ) ) {
565                $overrides[ $connection_id ]['message'] = sanitize_textarea_field( $connection['message'] );
566            }
567
568            // Save attached_media (can be empty array to clear media).
569            if ( isset( $connection['attached_media'] ) ) {
570                $overrides[ $connection_id ]['attached_media'] = $this->sanitize_attached_media( $connection['attached_media'] );
571            }
572
573            // Save media_source (can be empty to use default).
574            if ( isset( $connection['media_source'] ) ) {
575                $overrides[ $connection_id ]['media_source'] = sanitize_text_field( $connection['media_source'] );
576            }
577        }
578
579        // Only save if there are overrides, otherwise delete the meta.
580        if ( ! empty( $overrides ) ) {
581            update_post_meta( $post_id, Publicize_Base::POST_CONNECTION_OVERRIDES, $overrides );
582        } else {
583            delete_post_meta( $post_id, Publicize_Base::POST_CONNECTION_OVERRIDES );
584        }
585    }
586
587    /**
588     * Sanitize attached media array.
589     *
590     * @param array $attached_media Array of media items.
591     * @return array Sanitized array of media items.
592     */
593    private function sanitize_attached_media( $attached_media ) {
594        if ( empty( $attached_media ) ) {
595            return array();
596        }
597
598        $sanitized = array();
599
600        foreach ( $attached_media as $media_item ) {
601            if ( ! is_array( $media_item ) ) {
602                continue;
603            }
604
605            $sanitized_item = array();
606
607            if ( isset( $media_item['id'] ) ) {
608                $sanitized_item['id'] = absint( $media_item['id'] );
609            }
610
611            if ( isset( $media_item['url'] ) ) {
612                $sanitized_item['url'] = esc_url_raw( $media_item['url'] );
613            }
614
615            if ( isset( $media_item['type'] ) ) {
616                $sanitized_item['type'] = sanitize_text_field( $media_item['type'] );
617            }
618
619            if ( ! empty( $sanitized_item ) ) {
620                $sanitized[] = $sanitized_item;
621            }
622        }
623
624        return $sanitized;
625    }
626
627    /**
628     * Removes properties that should not appear in the current
629     * request's context
630     *
631     * $context is a Core REST API Framework request attribute that is
632     * always one of:
633     * * view (what you see on the blog)
634     * * edit (what you see in an editor)
635     * * embed (what you see in, e.g., an oembed)
636     *
637     * Fields (and sub-fields, and sub-sub-...) can be flagged for a
638     * set of specific contexts via the field's schema.
639     *
640     * The Core API will filter out top-level fields with the wrong
641     * context, but will not recurse deeply enough into arrays/objects
642     * to remove all levels of sub-fields with the wrong context.
643     *
644     * This function handles that recursion.
645     *
646     * @param mixed  $value   Value passed to API request.
647     * @param array  $schema  Schema to validate against.
648     * @param string $context REST API Request context.
649     *
650     * @return mixed Filtered $value
651     */
652    public function filter_response_by_context( $value, $schema, $context ) {
653        if ( ! $this->is_valid_for_context( $schema, $context ) ) {
654            // We use this intentionally odd looking WP_Error object
655            // internally only in this recursive function (see below
656            // in the `object` case). It will never be output by the REST API.
657            // If we return this for the top level object, Core
658            // correctly remove the top level object from the response
659            // for us.
660            return new WP_Error( '__wrong-context__' );
661        }
662
663        $schema_type = $schema['type'] ?? null;
664
665        switch ( $schema_type ) {
666            case 'array':
667                if ( ! isset( $schema['items'] ) ) {
668                    return $value;
669                }
670
671                // Shortcircuit if we know none of the items are valid for this context.
672                // This would only happen in a strangely written schema.
673                if ( ! $this->is_valid_for_context( $schema['items'], $context ) ) {
674                    return array();
675                }
676
677                // Recurse to prune sub-properties of each item.
678                foreach ( $value as $key => $item ) {
679                    $value[ $key ] = $this->filter_response_by_context( $item, $schema['items'], $context );
680                }
681
682                return $value;
683            case 'object':
684                if ( ! isset( $schema['properties'] ) ) {
685                    return $value;
686                }
687
688                foreach ( $value as $field_name => $field_value ) {
689                    if ( isset( $schema['properties'][ $field_name ] ) ) {
690                        $field_value = $this->filter_response_by_context( $field_value, $schema['properties'][ $field_name ], $context );
691                        if ( is_wp_error( $field_value ) && '__wrong-context__' === $field_value->get_error_code() ) {
692                            unset( $value[ $field_name ] );
693                        } else {
694                            // Respect recursion that pruned sub-properties of each property.
695                            $value[ $field_name ] = $field_value;
696                        }
697                    }
698                }
699
700                return (object) $value;
701        }
702
703        return $value;
704    }
705
706    /**
707     * Ensure that our request matches its expected context.
708     *
709     * @param array  $schema  Schema to validate against.
710     * @param string $context REST API Request context.
711     * @return bool
712     */
713    private function is_valid_for_context( $schema, $context ) {
714        return empty( $schema['context'] ) || in_array( $context, $schema['context'], true );
715    }
716}