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