Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
53.33% covered (warning)
53.33%
184 / 345
35.71% covered (danger)
35.71%
5 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
Connections_Controller
53.35% covered (warning)
53.35%
183 / 343
35.71% covered (danger)
35.71%
5 / 14
221.05
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 register_routes
100.00% covered (success)
100.00%
91 / 91
100.00% covered (success)
100.00%
1 / 1
2
 receive_updated_connections
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 get_item_schema
0.00% covered (danger)
0.00%
0 / 43
0.00% covered (danger)
0.00%
0 / 1
6
 get_the_item_schema
100.00% covered (success)
100.00%
67 / 67
100.00% covered (success)
100.00%
1 / 1
1
 get_items_permissions_check
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_items
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
30
 create_item_permissions_check
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 check_shared_param_permission
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 create_item
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
30
 update_item_permissions_check
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
20
 update_item
0.00% covered (danger)
0.00%
0 / 36
0.00% covered (danger)
0.00%
0 / 1
72
 delete_item_permissions_check
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 delete_item
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2/**
3 * The Publicize Connections Controller class.
4 *
5 * @package automattic/jetpack-publicize
6 */
7
8namespace Automattic\Jetpack\Publicize\REST_API;
9
10use Automattic\Jetpack\Connection\Rest_Authentication;
11use Automattic\Jetpack\Connection\Traits\WPCOM_REST_API_Proxy_Request;
12use Automattic\Jetpack\Publicize\Connections;
13use Automattic\Jetpack\Publicize\Jetpack_Social_Settings\Settings;
14use Automattic\Jetpack\Publicize\Publicize_Utils;
15use WP_Error;
16use WP_REST_Request;
17use WP_REST_Response;
18use WP_REST_Server;
19
20if ( ! defined( 'ABSPATH' ) ) {
21    exit( 0 );
22}
23
24/**
25 * Connections Controller class.
26 *
27 * @phan-constructor-used-for-side-effects
28 */
29class Connections_Controller extends Base_Controller {
30
31    use WPCOM_REST_API_Proxy_Request;
32
33    /**
34     * Constructor.
35     */
36    public function __construct() {
37        parent::__construct();
38
39        $this->base_api_path = 'wpcom';
40        $this->version       = 'v2';
41
42        $this->namespace = "{$this->base_api_path}/{$this->version}";
43        $this->rest_base = 'publicize/connections';
44
45        $this->allow_requests_as_blog = true;
46
47        add_action( 'rest_api_init', array( $this, 'register_routes' ) );
48    }
49
50    /**
51     * Register the routes.
52     */
53    public function register_routes() {
54        register_rest_route(
55            $this->namespace,
56            '/' . $this->rest_base,
57            array(
58                array(
59                    'methods'             => WP_REST_Server::READABLE,
60                    'callback'            => array( $this, 'get_items' ),
61                    'permission_callback' => array( $this, 'get_items_permissions_check' ),
62                    'args'                => array(
63                        'test_connections' => array(
64                            'type'        => 'boolean',
65                            'description' => __( 'Whether to test connections.', 'jetpack-publicize-pkg' ),
66                        ),
67                    ),
68                ),
69                array(
70                    'methods'             => WP_REST_Server::CREATABLE,
71                    'callback'            => array( $this, 'create_item' ),
72                    'permission_callback' => array( $this, 'create_item_permissions_check' ),
73                    'args'                => array(
74                        'keyring_connection_ID' => array(
75                            'description' => __( 'Keyring connection ID.', 'jetpack-publicize-pkg' ),
76                            'type'        => 'integer',
77                            'required'    => true,
78                        ),
79                        'external_user_ID'      => array(
80                            'description' => __( 'External User Id - in case of services like Facebook.', 'jetpack-publicize-pkg' ),
81                            'type'        => 'string',
82                        ),
83                        'shared'                => array(
84                            'description' => __( 'Whether the connection is shared with other users.', 'jetpack-publicize-pkg' ),
85                            'type'        => 'boolean',
86                        ),
87                    ),
88                ),
89                'schema' => array( $this, 'get_public_item_schema' ),
90            )
91        );
92
93        register_rest_route(
94            $this->namespace,
95            '/' . $this->rest_base . '/(?P<connection_id>[0-9]+)',
96            array(
97                'args'   => array(
98                    'connection_id' => array(
99                        'description' => __( 'Unique identifier for the connection.', 'jetpack-publicize-pkg' ),
100                        'type'        => 'string',
101                        'required'    => true,
102                    ),
103                ),
104                array(
105                    'methods'             => WP_REST_Server::EDITABLE,
106                    'callback'            => array( $this, 'update_item' ),
107                    'permission_callback' => array( $this, 'update_item_permissions_check' ),
108                    'args'                => array(
109                        'external_user_ID' => array(
110                            'description' => __( 'External User Id - in case of services like Facebook.', 'jetpack-publicize-pkg' ),
111                            'type'        => 'string',
112                        ),
113                        'shared'           => array(
114                            'description' => __( 'Whether the connection is shared with other users.', 'jetpack-publicize-pkg' ),
115                            'type'        => 'boolean',
116                        ),
117                    ),
118                ),
119                array(
120                    'methods'             => WP_REST_Server::DELETABLE,
121                    'callback'            => array( $this, 'delete_item' ),
122                    'permission_callback' => array( $this, 'delete_item_permissions_check' ),
123
124                ),
125                'schema' => array( $this, 'get_public_item_schema' ),
126            )
127        );
128
129        // This route receives pushes from WPCOM, so it is registered under the
130        // site-local jetpack/v4 namespace and never on WPCOM itself.
131        if ( ! Publicize_Utils::is_wpcom() ) {
132            register_rest_route(
133                'jetpack/v4',
134                '/publicize/connections/sync',
135                array(
136                    array(
137                        'methods'             => WP_REST_Server::CREATABLE,
138                        'callback'            => array( $this, 'receive_updated_connections' ),
139                        'permission_callback' => array( Rest_Authentication::class, 'is_signed_with_user_token' ),
140                        'args'                => array(
141                            // An empty value is accepted on purpose: a site with no connections left
142                            // syncs an empty payload, which arrives here as an empty object.
143                            'connections' => array(
144                                'type'        => 'object',
145                                'required'    => true,
146                                'description' => __( 'The updated Publicize connections, keyed by service name.', 'jetpack-publicize-pkg' ),
147                            ),
148                        ),
149                    ),
150                )
151            );
152        }
153    }
154
155    /**
156     * Receive updated Publicize connections from WPCOM.
157     *
158     * REST replacement for the jetpack.updatePublicizeConnections XML-RPC method.
159     *
160     * Unusable connections are dropped rather than rejected: an error response would send
161     * WPCOM down its XML-RPC fallback, which stores the same payload without the check.
162     *
163     * @param WP_REST_Request $request Full details about the request.
164     * @return WP_REST_Response
165     */
166    public function receive_updated_connections( $request ) {
167        /**
168         * The route only registers on Jetpack sites, where the global is this package's Publicize.
169         *
170         * @var \Automattic\Jetpack\Publicize\Publicize $publicize
171         */
172        global $publicize;
173
174        return rest_ensure_response(
175            $publicize->receive_updated_publicize_connections( $request->get_param( 'connections' ) )
176        );
177    }
178
179    /**
180     * Schema for the endpoint.
181     *
182     * @return array
183     */
184    public function get_item_schema() {
185        if ( $this->schema ) {
186            return $this->add_additional_fields_schema( $this->schema );
187        }
188        $deprecated_fields = array(
189            'id'                   => array(
190                'type'        => 'string',
191                'description' => __( 'Unique identifier for the Jetpack Social connection.', 'jetpack-publicize-pkg' ) . ' ' . sprintf(
192                    /* translators: %s is the new field name */
193                    __( 'Deprecated in favor of %s.', 'jetpack-publicize-pkg' ),
194                    'connection_id'
195                ),
196            ),
197            'username'             => array(
198                'type'        => 'string',
199                'description' => __( 'Username of the connected account.', 'jetpack-publicize-pkg' ) . ' ' . sprintf(
200                    /* translators: %s is the new field name */
201                    __( 'Deprecated in favor of %s.', 'jetpack-publicize-pkg' ),
202                    'external_handle'
203                ),
204            ),
205            'profile_display_name' => array(
206                'type'        => 'string',
207                'description' => __( 'The name to display in the profile of the connected account.', 'jetpack-publicize-pkg' ) . ' ' . sprintf(
208                    /* translators: %s is the new field name */
209                    __( 'Deprecated in favor of %s.', 'jetpack-publicize-pkg' ),
210                    'display_name'
211                ),
212            ),
213            'global'               => array(
214                'type'        => 'boolean',
215                'description' => __( 'Is this connection available to all users?', 'jetpack-publicize-pkg' ) . ' ' . sprintf(
216                    /* translators: %s is the new field name */
217                    __( 'Deprecated in favor of %s.', 'jetpack-publicize-pkg' ),
218                    'shared'
219                ),
220            ),
221        );
222
223        $schema = array(
224            '$schema'    => 'http://json-schema.org/draft-04/schema#',
225            'title'      => 'jetpack-publicize-connection',
226            'type'       => 'object',
227            'properties' => array_merge(
228                $deprecated_fields,
229                self::get_the_item_schema()
230            ),
231        );
232
233        $this->schema = $schema;
234
235        return $this->add_additional_fields_schema( $schema );
236    }
237
238    /**
239     * Get the schema for the connection item.
240     *
241     * @return array
242     */
243    public static function get_the_item_schema() {
244        return array(
245            'connection_id'   => array(
246                'type'        => 'string',
247                'description' => __( 'Connection ID of the connected account.', 'jetpack-publicize-pkg' ),
248            ),
249            'display_name'    => array(
250                'type'        => 'string',
251                'description' => __( 'Display name of the connected account.', 'jetpack-publicize-pkg' ),
252            ),
253            'external_handle' => array(
254                'type'        => array( 'string', 'null' ),
255                'description' => __( 'The external handle or username of the connected account.', 'jetpack-publicize-pkg' ),
256            ),
257            'external_id'     => array(
258                'type'        => 'string',
259                'description' => __( 'The external ID of the connected account.', 'jetpack-publicize-pkg' ),
260            ),
261            'profile_link'    => array(
262                'type'        => 'string',
263                'description' => __( 'Profile link of the connected account.', 'jetpack-publicize-pkg' ),
264            ),
265            'profile_picture' => array(
266                'type'        => 'string',
267                'description' => __( 'URL of the profile picture of the connected account.', 'jetpack-publicize-pkg' ),
268            ),
269            'service_label'   => array(
270                'type'        => 'string',
271                'description' => __( 'Human-readable label for the Jetpack Social service.', 'jetpack-publicize-pkg' ),
272            ),
273            'service_name'    => array(
274                'type'        => 'string',
275                'description' => __( 'Alphanumeric identifier for the Jetpack Social service.', 'jetpack-publicize-pkg' ),
276            ),
277            'shared'          => array(
278                'type'        => 'boolean',
279                'description' => __( 'Whether the connection is shared with other users.', 'jetpack-publicize-pkg' ),
280            ),
281            'status'          => array(
282                'description' => __( 'The connection status.', 'jetpack-publicize-pkg' ),
283                'oneOf'       => array(
284                    array(
285                        'type' => 'string',
286                        'enum' => array(
287                            'ok',
288                            'broken',
289                            'must_reauth',
290                        ),
291                    ),
292                    array(
293                        'type' => 'null',
294                    ),
295                ),
296            ),
297            'template'        => array(
298                'type'        => 'string',
299                'description' => __( 'Per-connection message template override. Empty string means fall back to the global template.', 'jetpack-publicize-pkg' ),
300                'default'     => '',
301                'maxLength'   => Settings::MESSAGE_TEMPLATE_MAX_LENGTH,
302                'arg_options' => array(
303                    'sanitize_callback' => array( Settings::class, 'sanitize_message_template' ),
304                ),
305            ),
306            'wpcom_user_id'   => array(
307                'type'        => 'integer',
308                'description' => __( 'wordpress.com ID of the user the connection belongs to.', 'jetpack-publicize-pkg' ),
309            ),
310        );
311    }
312
313    /**
314     * Verify that the request has access to connectoins list.
315     *
316     * @param WP_REST_Request $request Full details about the request.
317     * @return true|WP_Error
318     */
319    public function get_items_permissions_check( $request ) {// phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
320        return $this->publicize_permissions_check();
321    }
322
323    /**
324     * Get list of connected Publicize connections.
325     *
326     * @param WP_REST_Request $request Full details about the request.
327     *
328     * @return WP_REST_Response suitable for 1-page collection
329     */
330    public function get_items( $request ) {
331        if ( Publicize_Utils::is_wpcom() ) {
332            $args = array(
333                'context'          => self::is_authorized_blog_request() ? 'blog' : 'user',
334                'test_connections' => $request->get_param( 'test_connections' ),
335            );
336
337            $connections = Connections::wpcom_get_connections( $args );
338        } else {
339            $connections = $this->proxy_request_to_wpcom_as_user( $request );
340        }
341
342        if ( is_wp_error( $connections ) ) {
343            return $connections;
344        }
345
346        /*
347         * The Jetpack site path proxies to WPCOM instead of going through Connections::get_all(),
348         * so the filter is applied here too to keep both paths consistent.
349         *
350         * This filter is documented in projects/packages/publicize/src/class-connections.php
351         */
352        $connections = (array) apply_filters( 'jetpack_publicize_connections', $connections );
353
354        $items = array();
355
356        foreach ( $connections as $item ) {
357            $data = $this->prepare_item_for_response( $item, $request );
358
359            $items[] = $this->prepare_response_for_collection( $data );
360        }
361
362        $response = rest_ensure_response( $items );
363        $response->header( 'X-WP-Total', (string) count( $items ) );
364        $response->header( 'X-WP-TotalPages', '1' );
365
366        return $response;
367    }
368
369    /**
370     * Checks if a given request has access to create a connection.
371     *
372     * @param WP_REST_Request $request Full details about the request.
373     * @return true|WP_Error True if the request has access to create items, WP_Error object otherwise.
374     */
375    public function create_item_permissions_check( $request ) {
376        $permissions = parent::publicize_permissions_check();
377
378        if ( is_wp_error( $permissions ) ) {
379            return $permissions;
380        }
381
382        $shared_permission = $this->check_shared_param_permission( $request );
383
384        if ( is_wp_error( $shared_permission ) ) {
385            return $shared_permission;
386        }
387
388        return current_user_can( 'publish_posts' );
389    }
390
391    /**
392     * Check whether the request is allowed to set the `shared` flag on a connection.
393     *
394     * Shared connections are usable by every author on the site, so only editors
395     * and above may set the flag. Used by both the create and the update permission
396     * check, so the rule cannot drift between the two.
397     *
398     * @param WP_REST_Request $request Full details about the request.
399     * @return true|WP_Error True if the request may proceed, WP_Error object otherwise.
400     */
401    protected function check_shared_param_permission( $request ) {
402        if ( ! $request->has_param( 'shared' ) ) {
403            return true;
404        }
405
406        if ( ! current_user_can( 'edit_others_posts' ) ) {
407            return new WP_Error(
408                'rest_cannot_share_connection',
409                __( 'Sorry, you are not allowed to share connections with other users.', 'jetpack-publicize-pkg' ),
410                array( 'status' => rest_authorization_required_code() )
411            );
412        }
413
414        return true;
415    }
416
417    /**
418     * Creates a new connection.
419     *
420     * @param WP_REST_Request $request Full details about the request.
421     * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
422     */
423    public function create_item( $request ) {
424        if ( Publicize_Utils::is_wpcom() ) {
425
426            $input = array(
427                'keyring_connection_ID' => $request->get_param( 'keyring_connection_ID' ),
428                'shared'                => $request->get_param( 'shared' ),
429            );
430
431            $external_user_id = $request->get_param( 'external_user_ID' );
432            if ( ! empty( $external_user_id ) ) {
433                $input['external_user_ID'] = $external_user_id;
434            }
435
436            $result = Connections::wpcom_create_connection( $input );
437
438            if ( is_wp_error( $result ) ) {
439                return $result;
440            }
441
442            $connection = Connections::get_by_id( $result );
443
444            $response = $this->prepare_item_for_response( $connection, $request );
445            $response = rest_ensure_response( $response );
446
447            $response->set_status( 201 );
448
449            return $response;
450
451        }
452
453        $response = $this->proxy_request_to_wpcom_as_user( $request, '', array( 'timeout' => 120 ) );
454
455        if ( is_wp_error( $response ) ) {
456            return new WP_Error(
457                'jp_connection_update_failed',
458                __( 'Something went wrong while creating a connection.', 'jetpack-publicize-pkg' ),
459                $response->get_error_message()
460            );
461        }
462
463        $response = rest_ensure_response( $response );
464
465        $response->set_status( 201 );
466
467        return $response;
468    }
469
470    /**
471     * Checks if a given request has access to update a connection.
472     *
473     * @param WP_REST_Request $request Full details about the request.
474     * @return true|WP_Error True if the request has access to create items, WP_Error object otherwise.
475     */
476    public function update_item_permissions_check( $request ) {
477        $permissions = parent::publicize_permissions_check();
478
479        if ( is_wp_error( $permissions ) ) {
480            return $permissions;
481        }
482
483        // If the user cannot manage the connection, they can't update it either.
484        if ( ! $this->manage_connection_permission_check( $request ) ) {
485            return new WP_Error(
486                'rest_cannot_edit',
487                __( 'Sorry, you are not allowed to update this connection.', 'jetpack-publicize-pkg' ),
488                array( 'status' => rest_authorization_required_code() )
489            );
490        }
491
492        $shared_permission = $this->check_shared_param_permission( $request );
493
494        if ( is_wp_error( $shared_permission ) ) {
495            return $shared_permission;
496        }
497
498        return current_user_can( 'publish_posts' );
499    }
500
501    /**
502     * Update a connection.
503     *
504     * @param WP_REST_Request $request Full details about the request.
505     * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
506     */
507    public function update_item( $request ) {
508        $connection_id = $request->get_param( 'connection_id' );
509
510        if ( Publicize_Utils::is_wpcom() ) {
511
512            $input = array(
513                'shared' => $request->get_param( 'shared' ),
514            );
515
516            $external_user_id = $request->get_param( 'external_user_ID' );
517            if ( ! empty( $external_user_id ) ) {
518                $input['external_user_ID'] = $external_user_id;
519            }
520
521            if ( $request->has_param( 'template' ) ) {
522                require_lib( 'publicize/util/message-templates' );
523
524                $template_value = Settings::sanitize_message_template( $request->get_param( 'template' ) );
525
526                /**
527                 * Only gate non-empty values. Clearing an existing override
528                 * must be allowed regardless of plan — otherwise users who
529                 * downgrade can't remove a previously-set template.
530                 */
531                if ( '' !== $template_value && ! \Publicize\can_use_per_connection_templates() ) {
532                    return new WP_Error(
533                        'rest_forbidden_per_connection_template',
534                        __( 'Per-connection message templates require an upgraded plan.', 'jetpack-publicize-pkg' ),
535                        array( 'status' => rest_authorization_required_code() )
536                    );
537                }
538
539                $input['template'] = $template_value;
540            }
541
542            $result = Connections::wpcom_update_connection( $connection_id, $input );
543
544            if ( is_wp_error( $result ) ) {
545                return $result;
546            }
547
548            $connection = Connections::get_by_id( $connection_id );
549
550            $response = $this->prepare_item_for_response( $connection, $request );
551            $response = rest_ensure_response( $response );
552
553            $response->set_status( 201 );
554
555            return $response;
556        }
557
558        $response = $this->proxy_request_to_wpcom_as_user( $request, $connection_id, array( 'timeout' => 120 ) );
559
560        if ( is_wp_error( $response ) ) {
561            return new WP_Error(
562                'jp_connection_updation_failed',
563                __( 'Something went wrong while updating the connection.', 'jetpack-publicize-pkg' ),
564                $response->get_error_message()
565            );
566        }
567
568        $response = rest_ensure_response( $response );
569
570        $response->set_status( 201 );
571
572        return $response;
573    }
574
575    /**
576     * Checks if a given request has access to delete a connection.
577     *
578     * @param WP_REST_Request $request Full details about the request.
579     * @return true|WP_Error True if the request has access to create items, WP_Error object otherwise.
580     */
581    public function delete_item_permissions_check( $request ) {
582        $permissions = parent::publicize_permissions_check();
583
584        if ( is_wp_error( $permissions ) ) {
585            return $permissions;
586        }
587
588        return $this->manage_connection_permission_check( $request );
589    }
590
591    /**
592     * Delete a connection.
593     *
594     * @param WP_REST_Request $request Full details about the request.
595     * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
596     */
597    public function delete_item( $request ) {
598        $connection_id = $request->get_param( 'connection_id' );
599
600        if ( Publicize_Utils::is_wpcom() ) {
601
602            $result = Connections::wpcom_delete_connection( $connection_id );
603
604            if ( is_wp_error( $result ) ) {
605                return $result;
606            }
607
608            $response = rest_ensure_response( $result );
609
610            $response->set_status( 201 );
611
612            return $response;
613        }
614
615        $response = $this->proxy_request_to_wpcom_as_user( $request, $connection_id, array( 'timeout' => 120 ) );
616
617        if ( is_wp_error( $response ) ) {
618            return new WP_Error(
619                'jp_connection_deletion_failed',
620                __( 'Something went wrong while deleting the connection.', 'jetpack-publicize-pkg' ),
621                $response->get_error_message()
622            );
623        }
624
625        $response = rest_ensure_response( $response );
626
627        $response->set_status( 201 );
628
629        return $response;
630    }
631}