Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 157
0.00% covered (danger)
0.00%
0 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
External_Connections
0.00% covered (danger)
0.00%
0 / 157
0.00% covered (danger)
0.00%
0 / 8
1980
0.00% covered (danger)
0.00%
0 / 1
 get_connect_url
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
30
 get_connection
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
72
 delete_connection
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
30
 get_connection_data
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
30
 register_settings
0.00% covered (danger)
0.00%
0 / 67
0.00% covered (danger)
0.00%
0 / 1
182
 ajax_delete_connection
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 ajax_get_connection
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
6
 add_settings_for_service
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2/**
3 * Management of external connections.
4 *
5 * @package automattic/jetpack-external-connections
6 */
7
8namespace Automattic\Jetpack;
9
10use Automattic\Jetpack\Connection\Client;
11use Automattic\Jetpack\Connection\Manager as Connection_Manager;
12use Automattic\Jetpack\Status\Host;
13
14/**
15 * Main class.
16 */
17class External_Connections {
18
19    const PACKAGE_VERSION = '0.1.34';
20    const BASE_FILE       = __FILE__;
21
22    /**
23     * List of services whose connections are managed in settings pages.
24     *
25     * Each item has a key with the slug of the settings page, and a value with an array of services.
26     *
27     * Each service has the following keys:
28     * - service: The service identifier.
29     * - title: The title of the service.
30     * - signup_link: The URL to the service's signup page.
31     * - description: The description of the service.
32     * - support_link: An array with the following keys:
33     *     - jetpack: The URL handler registered in jetpack.com/redirect/.
34     *     - wpcom: The URL of the support page for the service on WordPress.com.
35     * - script: An optional script handle to enqueue.
36     *
37     * @example
38     * ```php
39     * self::$services = array(
40     *     'media' => array(
41     *         array(
42     *             'service'      => 'facebook',
43     *             'title'        => 'Facebook',
44     *             'signup_link'  => 'https://facebook.com/signup?ref=jetpack',
45     *             'description'  => 'Connect your site to your Facebook account',
46     *             'support_link' => array(
47     *                 'jetpack' => 'facebook-connection',
48     *                 'wpcom'   => 'https://wordpress.com/support/facebook/',
49     *             ),
50     *         ),
51     *     ),
52     * );
53     * ```
54     * @var array
55     */
56    private static $services = array();
57
58    /**
59     * Gets the connect URL for a given service.
60     *
61     * @param string $service The service identifier.
62     * @return string|null The connect URL, or `null` if the service is not supported.
63     */
64    public static function get_connect_url( $service ) {
65        if ( ( new Host() )->is_wpcom_simple() ) {
66            require_lib( 'external-connections' );
67            $connections = \WPCOM_External_Connections::init();
68            $service     = $connections->get_external_service_item( $service );
69            return $service ? $service['connect_URL'] : null;
70        }
71
72        $site_id = Connection_Manager::get_site_id();
73        if ( is_wp_error( $site_id ) ) {
74            return null;
75        }
76
77        $path     = sprintf( '/sites/%d/external-services', $site_id );
78        $response = Client::wpcom_json_api_request_as_user( $path );
79        if ( is_wp_error( $response ) ) {
80            return null;
81        }
82
83        $body = json_decode( wp_remote_retrieve_body( $response ) );
84
85        // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
86        return $body->services->$service->connect_URL ?? null;
87    }
88
89    /**
90     * Retrieves a connection of the provided service.
91     *
92     * @param string $service The service identifier.
93     * @return array|null The connection details, or `null` if no matching connection is found.
94     */
95    public static function get_connection( $service ) {
96        if ( ( new Host() )->is_wpcom_simple() ) {
97            require_lib( 'external-media-service/external-media-list' );
98            require_lib( 'external-connections' );
99
100            $connections = \WPCOM_External_Connections::init();
101            $token       = \ExternalMediaService::get_service_token( $service, get_current_user_id() );
102
103            if ( ! empty( $token->unique_id ) ) {
104                // @phan-suppress-next-line PhanTypeMismatchArgument -- $token->unique_id is an int.
105                return $connections->get_keyring_connection_item( $token->unique_id );
106            }
107        } else {
108            $response = Client::wpcom_json_api_request_as_user( '/me/connections' );
109            if ( is_wp_error( $response ) ) {
110                return null;
111            }
112            $body = json_decode( wp_remote_retrieve_body( $response ) );
113            if ( isset( $body->connections ) && is_array( $body->connections ) ) {
114                foreach ( $body->connections as $connection ) {
115                    if ( $service === $connection->service ) {
116                        return (array) $connection;
117                    }
118                }
119            }
120        }
121
122        return null;
123    }
124
125    /**
126     * Deletes a connection for the provided service.
127     *
128     * @param string $service The service identifier.
129     * @return boolean Whether the connection was deleted.
130     */
131    public static function delete_connection( $service ) {
132        $connection = self::get_connection( $service );
133        if ( empty( $connection ) ) {
134            return false;
135        }
136
137        if ( ( new Host() )->is_wpcom_simple() ) {
138            if ( get_current_user_id() === $connection['user_ID'] ) {
139                require_lib( 'external-connections' );
140                $connections = \WPCOM_External_Connections::init();
141                return $connections->delete_keyring_connection( $connection['ID'] );
142            }
143            return false;
144        }
145
146        $response = Client::wpcom_json_api_request_as_user(
147            '/me/connections/' . $connection['ID'],
148            '2',
149            array( 'method' => 'DELETE' )
150        );
151        if ( is_wp_error( $response ) ) {
152            return false;
153        }
154        $body = json_decode( wp_remote_retrieve_body( $response ) );
155        return $body->deleted ?? false;
156    }
157
158    /**
159     * Gets the connection data for the provided service.
160     *
161     * @param string $service The service identifier.
162     * @return array The connection data.
163     */
164    public static function get_connection_data( $service ) {
165        $connection = self::get_connection( $service );
166        if ( empty( $connection ) ) {
167            return array(
168                'is_connected'  => false,
169                'account_name'  => '',
170                'profile_image' => '',
171            );
172        }
173
174        $is_connected  = isset( $connection['status'] ) && $connection['status'] === 'ok';
175        $account_name  = $is_connected ? ( $connection['external_display'] ?? $connection['external_name'] ) : '';
176        $profile_image = $is_connected ? $connection['external_profile_picture'] : '';
177
178        return array(
179            'is_connected'  => $is_connected,
180            'account_name'  => $account_name,
181            'profile_image' => $profile_image,
182        );
183    }
184
185    /**
186     * Registers connection settings.
187     */
188    public static function register_settings() {
189        global $pagenow;
190        $host = new Host();
191
192        if ( ! $host->is_wpcom_simple() ) {
193            $connection = new Connection_Manager( 'jetpack' );
194            $status     = new Status();
195
196            if ( $status->is_offline_mode() || ! $connection->has_connected_owner() || ! $connection->is_user_connected() ) {
197                return;
198            }
199        }
200
201        foreach ( self::$services as $page => $services ) {
202            if ( $pagenow !== "options-$page.php" ) {
203                continue;
204            }
205
206            add_settings_section(
207                'jetpack_external_connections_section',
208                __( 'Integrations', 'jetpack-external-connections' ),
209                '__return_false',
210                $page
211            );
212
213            $asset_name = 'jetpack-external-connections-settings';
214            Assets::register_script(
215                $asset_name,
216                "build/$asset_name/$asset_name.js",
217                self::BASE_FILE,
218                array(
219                    'in_footer'  => true,
220                    'textdomain' => 'jetpack-external-connections',
221                )
222            );
223            Assets::enqueue_script( $asset_name );
224
225            $script_data = array();
226
227            foreach ( $services as $service ) {
228                if ( $host->is_wpcom_platform() ) {
229                    $support_link = $service['support_link']['wpcom'];
230                    if ( function_exists( 'localized_wpcom_url' ) ) {
231                        $support_link = localized_wpcom_url( $support_link );
232                    }
233                } else {
234                    $support_link = Redirect::get_url( $service['support_link']['jetpack'] );
235                }
236
237                $connect_url     = self::get_connect_url( $service['service'] );
238                $connection_data = self::get_connection_data( $service['service'] );
239
240                if ( ( empty( $connect_url ) && ! $connection_data['is_connected'] ) ) {
241                    continue;
242                }
243
244                add_settings_field(
245                    'jetpack_external_connections_field_' . $service['service'],
246                    $service['title'],
247                    function () use ( $service, $support_link ) {
248                        ?>
249                        <div class="jetpack-external-connection" data-service="<?php echo esc_attr( $service['service'] ); ?>"><em><?php esc_html_e( 'Loading…', 'jetpack-external-connections' ); ?></em></div>
250                        <p class="description">
251                            <?php echo esc_html( $service['description'] ); ?>
252                            <a href="<?php echo esc_url( $support_link ); ?>" target="_blank" data-target="wpcom-help-center"><?php esc_html_e( 'Learn more', 'jetpack-external-connections' ); ?></a>
253                        </p>
254                        <?php
255                    },
256                    $page,
257                    'jetpack_external_connections_section'
258                );
259
260                $script_data[ $service['service'] ] = array(
261                    'accountName'  => $connection_data['account_name'],
262                    'connectUrl'   => $connect_url,
263                    'deleteNonce'  => wp_create_nonce( 'jetpack_delete_external_connection_' . $service['service'] ),
264                    'getNonce'     => wp_create_nonce( 'jetpack_get_external_connection_' . $service['service'] ),
265                    'isConnected'  => $connection_data['is_connected'],
266                    'profileImage' => $connection_data['profile_image'],
267                    'supportLink'  => $support_link,
268                    'signupLink'   => $service['signup_link'] ?? '',
269                );
270
271                if ( isset( $service['script'] ) ) {
272                    Assets::enqueue_script( $service['script'] );
273                }
274            }
275
276            wp_add_inline_script(
277                $asset_name,
278                'const jetpackExternalConnectionsData = ' . wp_json_encode( $script_data, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ) . ';',
279                'before'
280            );
281        }
282    }
283
284    /**
285     * Handles the AJAX request to delete an external connection.
286     */
287    public static function ajax_delete_connection() {
288        if ( ! isset( $_REQUEST['service'] ) ) {
289            // @phan-suppress-next-line PhanTypeMismatchArgumentProbablyReal -- It takes null, but its phpdoc only says int.
290            wp_send_json( array( 'deleted' => 'false' ), null, JSON_UNESCAPED_SLASHES );
291        }
292
293        $service = sanitize_text_field( wp_unslash( $_REQUEST['service'] ) );
294        check_ajax_referer( 'jetpack_delete_external_connection_' . $service );
295
296        $is_deleted = self::delete_connection( $service );
297        // @phan-suppress-next-line PhanTypeMismatchArgumentProbablyReal -- It takes null, but its phpdoc only says int.
298        wp_send_json( array( 'deleted' => $is_deleted ), null, JSON_UNESCAPED_SLASHES );
299    }
300
301    /**
302     * Handles the AJAX request to delete an external connection.
303     */
304    public static function ajax_get_connection() {
305        if ( ! isset( $_REQUEST['service'] ) ) {
306            // @phan-suppress-next-line PhanTypeMismatchArgumentProbablyReal -- It takes null, but its phpdoc only says int.
307            wp_send_json( array( 'isConnected' => 'false' ), null, JSON_UNESCAPED_SLASHES );
308        }
309
310        $service = sanitize_text_field( wp_unslash( $_REQUEST['service'] ) );
311        check_ajax_referer( 'jetpack_get_external_connection_' . $service );
312
313        $connection_data = self::get_connection_data( $service );
314        wp_send_json(
315            array(
316                'accountName'  => $connection_data['account_name'],
317                'isConnected'  => $connection_data['is_connected'],
318                'profileImage' => $connection_data['profile_image'],
319            ),
320            null, // @phan-suppress-current-line PhanTypeMismatchArgumentProbablyReal -- It takes null, but its phpdoc only says int.
321            JSON_UNESCAPED_SLASHES
322        );
323    }
324
325    /**
326     * Registers settings and hooks for a specified service on a given admin page.
327     *
328     * @param string $page The identifier of the admin page where the service settings are added.
329     * @param array  $service The service to be associated with the specified admin page.
330     */
331    public static function add_settings_for_service( $page, $service ) {
332        self::$services[ $page ][] = $service;
333
334        if ( ! has_action( 'admin_init', array( __CLASS__, 'register_settings' ) ) ) {
335            add_action( 'admin_init', array( __CLASS__, 'register_settings' ), 15 );
336        }
337
338        if ( ! has_action( 'wp_ajax_jetpack_delete_external_connection', array( __CLASS__, 'ajax_delete_connection' ) ) ) {
339            add_action( 'wp_ajax_jetpack_delete_external_connection', array( __CLASS__, 'ajax_delete_connection' ) );
340        }
341
342        if ( ! has_action( 'wp_ajax_jetpack_get_external_connection', array( __CLASS__, 'ajax_get_connection' ) ) ) {
343            add_action( 'wp_ajax_jetpack_get_external_connection', array( __CLASS__, 'ajax_get_connection' ) );
344        }
345    }
346}