Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
10.53% covered (danger)
10.53%
16 / 152
15.38% covered (danger)
15.38%
2 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
Plugins
10.00% covered (danger)
10.00%
15 / 150
15.38% covered (danger)
15.38%
2 / 13
2179.76
0.00% covered (danger)
0.00%
0 / 1
 name
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 init_listeners
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
1
 populate_plugins
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 on_upgrader_completion
0.00% covered (danger)
0.00%
0 / 41
0.00% covered (danger)
0.00%
0 / 1
272
 get_plugin_info
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 get_errors
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
56
 plugin_edit_ajax
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
156
 delete_plugin
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
6
 deleted_plugin
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 expand_plugin_data
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
12
 sync_plugins_updated
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 sync_plugins_installed
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 sync_plugins_update_failed
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2/**
3 * Plugins sync module.
4 *
5 * @package automattic/jetpack-sync
6 */
7
8namespace Automattic\Jetpack\Sync\Modules;
9
10use Automattic\Jetpack\Constants as Jetpack_Constants;
11use WP_Error;
12
13if ( ! defined( 'ABSPATH' ) ) {
14    exit( 0 );
15}
16
17/**
18 * Class to handle sync for plugins.
19 */
20class Plugins extends Module {
21    /**
22     * Action handler callable.
23     *
24     * @access private
25     *
26     * @var callable
27     */
28    private $action_handler;
29
30    /**
31     * Information about plugins we store temporarily.
32     *
33     * @access private
34     *
35     * @var array
36     */
37    private $plugin_info = array();
38
39    /**
40     * List of all plugins in the installation.
41     *
42     * @access private
43     *
44     * @var array
45     */
46    private $plugins = array();
47
48    /**
49     * List of all updated plugins.
50     *
51     * @access private
52     *
53     * @var array
54     */
55    private $plugins_updated = array();
56
57    /**
58     * List of plugins installed during this request.
59     *
60     * @access private
61     *
62     * @var array
63     */
64    private $plugins_installed = array();
65
66    /**
67     * List of all plugin update failures during this request.
68     *
69     * @access private
70     *
71     * @var array
72     */
73    private $plugins_update_failures = array();
74
75    /**
76     * State
77     *
78     * @access private
79     *
80     * @var array
81     */
82    private $state = array();
83
84    /**
85     * Sync module name.
86     *
87     * @access public
88     *
89     * @return string
90     */
91    public function name() {
92        return 'plugins';
93    }
94
95    /**
96     * Initialize plugins action listeners.
97     *
98     * @access public
99     *
100     * @param callable $callable Action handler callable.
101     */
102    public function init_listeners( $callable ) {
103        $this->action_handler = $callable;
104
105        add_action( 'deleted_plugin', array( $this, 'deleted_plugin' ), 10, 2 );
106        add_action( 'activated_plugin', $callable, 10, 2 );
107        add_action( 'deactivated_plugin', $callable, 10, 2 );
108        add_action( 'delete_plugin', array( $this, 'delete_plugin' ) );
109        add_filter( 'upgrader_pre_install', array( $this, 'populate_plugins' ), 10, 1 );
110        add_action( 'upgrader_process_complete', array( $this, 'on_upgrader_completion' ), 10, 2 );
111        add_action( 'jetpack_plugin_installed', $callable, 10, 1 );
112        add_action( 'jetpack_plugin_update_failed', $callable, 10, 4 );
113        add_action( 'jetpack_plugins_updated', $callable, 10, 2 );
114        add_action( 'jetpack_edited_plugin', $callable, 10, 2 );
115        add_action( 'wp_ajax_edit-theme-plugin-file', array( $this, 'plugin_edit_ajax' ), 0 );
116
117        // Note that we don't simply 'expand_plugin_data' on the 'delete_plugin' action here because the plugin file is deleted when that action finishes.
118        add_filter( 'jetpack_sync_before_enqueue_activated_plugin', array( $this, 'expand_plugin_data' ) );
119        add_filter( 'jetpack_sync_before_enqueue_deactivated_plugin', array( $this, 'expand_plugin_data' ) );
120    }
121
122    /**
123     * Fetch and populate all current plugins before upgrader installation.
124     *
125     * @access public
126     *
127     * @param bool|WP_Error $response Install response, true if successful, WP_Error if not.
128     */
129    public function populate_plugins( $response ) {
130        if ( ! function_exists( 'get_plugins' ) ) {
131            require_once ABSPATH . 'wp-admin/includes/plugin.php';
132        }
133        $this->plugins = get_plugins();
134        return $response;
135    }
136
137    /**
138     * Handler for the upgrader success finishes.
139     *
140     * @access public
141     *
142     * @param \WP_Upgrader $upgrader Upgrader instance.
143     * @param array        $details  Array of bulk item update data.
144     */
145    public function on_upgrader_completion( $upgrader, $details ) {
146        if ( ! isset( $details['type'] ) ) {
147            return;
148        }
149        if ( 'plugin' !== $details['type'] ) {
150            return;
151        }
152
153        if ( ! isset( $details['action'] ) ) {
154            return;
155        }
156
157        $plugins = ( isset( $details['plugins'] ) ? $details['plugins'] : null );
158        if ( empty( $plugins ) ) {
159            $plugins = ( isset( $details['plugin'] ) ? array( $details['plugin'] ) : null );
160        }
161
162        // For plugin installer.
163        if ( empty( $plugins ) && method_exists( $upgrader, 'plugin_info' ) ) {
164            // @phan-suppress-next-line PhanUndeclaredMethod -- Checked above. See also https://github.com/phan/phan/issues/1204.
165            $plugins = array( $upgrader->plugin_info() );
166        }
167
168        if ( empty( $plugins ) ) {
169            return; // We shouldn't be here.
170        }
171
172        switch ( $details['action'] ) {
173            case 'update':
174                $this->state = array(
175                    'is_autoupdate' => Jetpack_Constants::is_true( 'JETPACK_PLUGIN_AUTOUPDATE' ),
176                );
177                $errors      = $this->get_errors( $upgrader->skin );
178                if ( $errors ) {
179                    foreach ( $plugins as $slug ) { // Accumulate failures and defer to shutdown, to reduce request-time lag.
180                        $this->plugins_update_failures[] = array(
181                            'plugin'  => $this->get_plugin_info( $slug ),
182                            'code'    => $errors['code'],
183                            'message' => $errors['message'],
184                            'state'   => $this->state,
185                        );
186                    }
187                    if ( ! has_action( 'shutdown', array( $this, 'sync_plugins_update_failed' ) ) ) {
188                        add_action( 'shutdown', array( $this, 'sync_plugins_update_failed' ), 9 );
189                    }
190
191                    return;
192                }
193
194                $this->plugins_updated = array_map( array( $this, 'get_plugin_info' ), $plugins );
195                add_action( 'shutdown', array( $this, 'sync_plugins_updated' ), 9 );
196
197                break;
198            case 'install':
199                // Accumulate installs and defer to shutdown.
200                $this->plugins_installed = array_merge(
201                    $this->plugins_installed,
202                    array_map( array( $this, 'get_plugin_info' ), $plugins )
203                );
204                if ( ! has_action( 'shutdown', array( $this, 'sync_plugins_installed' ) ) ) {
205                    add_action( 'shutdown', array( $this, 'sync_plugins_installed' ), 9 );
206                }
207
208                break;
209        }
210    }
211
212    /**
213     * Retrieve the plugin information by a plugin slug.
214     *
215     * @access private
216     *
217     * @param string $slug Plugin slug.
218     * @return array Plugin information.
219     */
220    private function get_plugin_info( $slug ) {
221        $plugins = get_plugins(); // Get the most up to date info.
222        if ( isset( $plugins[ $slug ] ) ) {
223            return array_merge( array( 'slug' => $slug ), $plugins[ $slug ] );
224        }
225        // Try grabbing the info from before the update.
226        return isset( $this->plugins[ $slug ] ) ? array_merge( array( 'slug' => $slug ), $this->plugins[ $slug ] ) : array( 'slug' => $slug );
227    }
228
229    /**
230     * Retrieve upgrade errors.
231     *
232     * @access private
233     *
234     * @param \Automatic_Upgrader_Skin|\WP_Upgrader_Skin $skin The upgrader skin being used.
235     * @return array|boolean Error on error, false otherwise.
236     */
237    private function get_errors( $skin ) {
238        // @phan-suppress-next-line PhanUndeclaredMethod -- Checked before being called. See also https://github.com/phan/phan/issues/1204.
239        $errors = method_exists( $skin, 'get_errors' ) ? $skin->get_errors() : null;
240        if ( is_wp_error( $errors ) ) {
241            $error_code = $errors->get_error_code();
242            if ( ! empty( $error_code ) ) {
243                return array(
244                    'code'    => $error_code,
245                    'message' => $errors->get_error_message(),
246                );
247            }
248        }
249
250        if ( isset( $skin->result ) ) {
251            $errors = $skin->result;
252            if ( is_wp_error( $errors ) ) {
253                return array(
254                    'code'    => $errors->get_error_code(),
255                    'message' => $errors->get_error_message(),
256                );
257            }
258
259            if ( empty( $skin->result ) ) {
260                return array(
261                    'code'    => 'unknown',
262                    'message' => __( 'Unknown Plugin Update Failure', 'jetpack-sync' ),
263                );
264            }
265        }
266        return false;
267    }
268
269    /**
270     * Handle plugin ajax edit in the administration.
271     *
272     * @access public
273     *
274     * @todo Update this method to use WP_Filesystem instead of fopen/fclose.
275     */
276    public function plugin_edit_ajax() {
277        // This validation is based on wp_edit_theme_plugin_file().
278        if ( empty( $_POST['file'] ) ) {
279            return;
280        }
281
282        $file = wp_unslash( $_POST['file'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Validated manually just after.
283        if ( 0 !== validate_file( $file ) ) {
284            return;
285        }
286
287        if ( ! isset( $_POST['newcontent'] ) ) {
288            return;
289        }
290
291        if ( ! isset( $_POST['nonce'] ) ) {
292            return;
293        }
294
295        if ( empty( $_POST['plugin'] ) ) {
296            return;
297        }
298
299        $plugin = wp_unslash( $_POST['plugin'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Validated manually just after.
300        if ( ! current_user_can( 'edit_plugins' ) ) {
301            return;
302        }
303
304        if ( ! wp_verify_nonce( $_POST['nonce'], 'edit-plugin_' . $file ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- WP core doesn't pre-sanitize nonces either.
305            return;
306        }
307        $plugins = get_plugins();
308        if ( ! array_key_exists( $plugin, $plugins ) ) {
309            return;
310        }
311
312        if ( 0 !== validate_file( $file, get_plugin_files( $plugin ) ) ) {
313            return;
314        }
315
316        $real_file = WP_PLUGIN_DIR . '/' . $file;
317
318        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable
319        if ( ! is_writable( $real_file ) ) {
320            return;
321        }
322
323        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
324        $file_pointer = fopen( $real_file, 'w+' );
325        if ( false === $file_pointer ) {
326            return;
327        }
328        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
329        fclose( $file_pointer );
330        /**
331         * This action is documented already in this file
332         */
333        do_action( 'jetpack_edited_plugin', $plugin, $plugins[ $plugin ] );
334    }
335
336    /**
337     * Handle plugin deletion.
338     *
339     * @access public
340     *
341     * @param string $plugin_path Path to the plugin main file.
342     */
343    public function delete_plugin( $plugin_path ) {
344        $full_plugin_path = WP_PLUGIN_DIR . DIRECTORY_SEPARATOR . $plugin_path;
345
346        // Checking for file existence because some sync plugin module tests simulate plugin installation and deletion without putting file on disk.
347        if ( file_exists( $full_plugin_path ) ) {
348            $all_plugin_data = get_plugin_data( $full_plugin_path );
349            $data            = array(
350                'name'    => $all_plugin_data['Name'],
351                'version' => $all_plugin_data['Version'],
352            );
353        } else {
354            $data = array(
355                'name'    => $plugin_path,
356                'version' => 'unknown',
357            );
358        }
359
360        $this->plugin_info[ $plugin_path ] = $data;
361    }
362
363    /**
364     * Invoked after plugin deletion.
365     *
366     * @access public
367     *
368     * @param string  $plugin_path Path to the plugin main file.
369     * @param boolean $is_deleted  Whether the plugin was deleted successfully.
370     */
371    public function deleted_plugin( $plugin_path, $is_deleted ) {
372        call_user_func( $this->action_handler, $plugin_path, $is_deleted, $this->plugin_info[ $plugin_path ] );
373        unset( $this->plugin_info[ $plugin_path ] );
374    }
375
376    /**
377     * Expand the plugins within a hook before they are serialized and sent to the server.
378     *
379     * @access public
380     *
381     * @param array $args The hook parameters.
382     * @return array $args The expanded hook parameters.
383     */
384    public function expand_plugin_data( $args ) {
385        $plugin_path = $args[0];
386        $plugin_data = array();
387
388        if ( ! function_exists( 'get_plugins' ) ) {
389            require_once ABSPATH . 'wp-admin/includes/plugin.php';
390        }
391        $all_plugins = get_plugins();
392        if ( isset( $all_plugins[ $plugin_path ] ) ) {
393            $all_plugin_data        = $all_plugins[ $plugin_path ];
394            $plugin_data['name']    = $all_plugin_data['Name'];
395            $plugin_data['version'] = $all_plugin_data['Version'];
396        }
397
398        return array(
399            $args[0],
400            $args[1],
401            $plugin_data,
402        );
403    }
404
405    /**
406     * Helper method for firing the 'jetpack_plugins_updated' action on shutdown.
407     *
408     * @access public
409     */
410    public function sync_plugins_updated() {
411        /**
412         * Sync that a plugin update
413         *
414         * @since 1.6.3
415         * @since-jetpack 5.8.0
416         *
417         * @module sync
418         *
419         * @param array () $plugin, Plugin Data
420         */
421        do_action( 'jetpack_plugins_updated', $this->plugins_updated, $this->state );
422    }
423
424    /**
425     * Helper method for firing the 'jetpack_plugin_installed' action on shutdown.
426     *
427     * @access public
428     */
429    public function sync_plugins_installed() {
430        if ( empty( $this->plugins_installed ) ) {
431            return;
432        }
433        /**
434         * Signals to the sync listener that a plugin was installed and a sync action
435         * reflecting the installation and the plugin info should be sent.
436         *
437         * @since 1.6.3
438         * @since-jetpack 5.8.0
439         *
440         * @module sync
441         *
442         * @param array () $plugin, Plugin Data
443         */
444        do_action( 'jetpack_plugin_installed', $this->plugins_installed );
445    }
446
447    /**
448     * Helper method for firing the 'jetpack_plugin_update_failed' actions on shutdown.
449     *
450     * @access public
451     */
452    public function sync_plugins_update_failed() {
453        if ( empty( $this->plugins_update_failures ) ) {
454            return;
455        }
456        foreach ( $this->plugins_update_failures as $failure ) {
457            /**
458             * Sync that a plugin update failed
459             *
460             * @since 1.6.3
461             * @since-jetpack 5.8.0
462             *
463             * @module sync
464             *
465             * @param array  $plugin Plugin Data
466             * @param string $code   Error code
467             * @param string $message Error message
468             * @param array  $state  State data
469             */
470            do_action( 'jetpack_plugin_update_failed', $failure['plugin'], $failure['code'], $failure['message'], $failure['state'] );
471        }
472    }
473}