Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
77.87% covered (warning)
77.87%
95 / 122
57.89% covered (warning)
57.89%
11 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
Jetpack_Activity_Log
78.33% covered (warning)
78.33%
94 / 120
57.89% covered (warning)
57.89%
11 / 19
75.43
0.00% covered (danger)
0.00%
0 / 1
 initialize
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 is_module_active
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 register_module
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 add_standalone_module
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 activate_standalone_default
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
5.03
 add_wp_admin_submenu
89.29% covered (warning)
89.29%
25 / 28
0.00% covered (danger)
0.00%
0 / 1
6.04
 is_available
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
4.37
 admin_init
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
20
 load_wp_build
23.08% covered (danger)
23.08%
3 / 13
0.00% covered (danger)
0.00%
0 / 1
7.10
 load_wp_build_with_screen_alias
75.00% covered (warning)
75.00%
9 / 12
0.00% covered (danger)
0.00%
0 / 1
2.06
 alias_screen_id_for_wp_build
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 restore_screen_id_after_wp_build
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 opt_out_of_jitms
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 hide_jitms_on_wp_build_dashboard
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 enqueue_initial_state
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 is_wpcom_simple
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 render_fallback
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 is_activity_log_admin_request
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 register_rest_routes
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * Primary class for the Jetpack Activity Log package.
4 *
5 * @package automattic/jetpack-activity-log
6 */
7
8namespace Automattic\Jetpack\Activity_Log;
9
10if ( ! defined( 'ABSPATH' ) ) {
11    exit( 0 );
12}
13
14use Automattic\Jetpack\Activity_Log\Initial_State as Activity_Log_Initial_State;
15use Automattic\Jetpack\Admin_UI\Admin_Menu;
16use Automattic\Jetpack\Connection\Initial_State as Connection_Initial_State;
17use Automattic\Jetpack\Connection\Manager as Connection_Manager;
18use Automattic\Jetpack\Modules;
19use Automattic\Jetpack\Status\Host;
20use Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills;
21use Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Screen_Id;
22use Jetpack_Options;
23use function add_action;
24use function add_filter;
25use function add_submenu_page;
26use function class_exists;
27use function current_user_can;
28use function did_action;
29use function do_action;
30use function get_current_screen;
31use function is_admin;
32use function is_multisite;
33use function sanitize_text_field;
34use function wp_add_inline_script;
35use function wp_enqueue_script;
36use function wp_json_encode;
37use function wp_register_script;
38use function wp_unslash;
39use function wp_verify_nonce;
40
41/**
42 * Class Jetpack_Activity_Log
43 *
44 * Registers the Activity Log admin page and its REST routes inside the
45 * main Jetpack plugin.
46 */
47class Jetpack_Activity_Log {
48
49    /**
50     * Admin page slug.
51     *
52     * @var string
53     */
54    const PAGE_SLUG = 'jetpack-activity-log';
55
56    /**
57     * Slug of the Jetpack module that turns the Activity Log on and off.
58     *
59     * @var string
60     */
61    const MODULE_SLUG = 'activity-log';
62
63    /**
64     * Jetpack_Options key recording that the module was switched on for a site
65     * with no Jetpack plugin. See `activate_standalone_default()`.
66     *
67     * @var string
68     */
69    const DEFAULT_ACTIVATED_OPTION = 'activity_log_default_activated';
70
71    /**
72     * Page slug for the wp-build dashboard. Distinct from the wp-admin menu
73     * slug (`PAGE_SLUG`) so the user-facing URL stays `admin.php?page=jetpack-activity-log`;
74     * we alias the current screen id to this value so wp-build's
75     * screen-match enqueue callback fires. Must match the `page` in
76     * `routes/dashboard/package.json` and the `wpPlugin.pages` entry.
77     *
78     * @var string
79     */
80    const WP_BUILD_PAGE_SLUG = 'jetpack-activity-log-dashboard';
81
82    /**
83     * Handle for the classic script that carries the React initial state.
84     * The dashboard is a wp-build script module, so there is no classic
85     * bundle handle to attach inline data to — this empty handle exists
86     * purely to print `JPACTIVITYLOG_INITIAL_STATE` and the Connection
87     * initial state before boot runs.
88     *
89     * @var string
90     */
91    const DATA_SCRIPT_HANDLE = 'jetpack-activity-log-data';
92
93    /**
94     * Nonce action for refreshing the access flag after a checkout
95     * return. Used by `admin_init()` below and exposed to the client via
96     * Initial_State so the upsell CTA can embed a valid nonce in its
97     * `redirect_to`. Same shape as `Social_Admin_Page::REFRESH_PLAN_NONCE_ACTION`.
98     *
99     * @var string
100     */
101    const REFRESH_ACCESS_NONCE_ACTION = 'jetpack_activity_log_refresh_access';
102
103    /**
104     * The screen ID alias_screen_id_for_wp_build() replaced, until it is restored.
105     *
106     * @var string|null
107     */
108    private static $wp_build_original_screen_id = null;
109
110    /**
111     * The dashboard screen hide_jitms_on_wp_build_dashboard() opts out of JITMs.
112     *
113     * @var string|null
114     */
115    private static $jitm_opt_out_screen_id = null;
116
117    /**
118     * Whether activate_standalone_default() has run in this request.
119     *
120     * @var bool
121     */
122    private static $standalone_default_attempted = false;
123
124    /**
125     * Entry point. Idempotent: safe to call from multiple bootstraps.
126     *
127     * Bootstraps only while the `activity-log` module is on, so the toggle
128     * means the same thing to the Jetpack plugin and to every standalone
129     * plugin that carries this package.
130     */
131    public static function initialize() {
132        // Simple has no module system, and jetpack-mu-wpcom registers the page itself via add_wp_admin_submenu().
133        if ( self::is_wpcom_simple() ) {
134            return;
135        }
136
137        self::register_module();
138
139        if ( did_action( 'jetpack_activity_log_initialized' ) || ! self::is_module_active() ) {
140            return;
141        }
142
143        add_action( 'admin_menu', array( __CLASS__, 'add_wp_admin_submenu' ) );
144        add_action( 'rest_api_init', array( __CLASS__, 'register_rest_routes' ) );
145        add_filter( 'jetpack_package_versions', array( Package_Version::class, 'send_package_version_to_tracker' ) );
146
147        /**
148         * Fires once the Jetpack Activity Log package has wired its hooks.
149         *
150         * @since 0.1.0
151         */
152        do_action( 'jetpack_activity_log_initialized' );
153    }
154
155    /**
156     * Whether the Activity Log module is switched on.
157     *
158     * @return bool
159     */
160    public static function is_module_active() {
161        return ( new Modules() )->is_active( self::MODULE_SLUG );
162    }
163
164    /**
165     * Make the module controllable, and give a site with no Jetpack plugin the
166     * same default-on state the Jetpack plugin gets from `Auto Activate: Yes`.
167     *
168     * @return void
169     */
170    private static function register_module() {
171        add_filter( 'jetpack_get_available_standalone_modules', array( __CLASS__, 'add_standalone_module' ) );
172
173        // `class_exists( 'Jetpack' )` is only reliable once every plugin file has
174        // loaded: `jetpack-backup/` sorts before `jetpack/` in active_plugins, so
175        // Backup reaches initialize() while the Jetpack class is still undefined.
176        if ( did_action( 'plugins_loaded' ) ) {
177            self::activate_standalone_default();
178        } else {
179            add_action( 'plugins_loaded', array( __CLASS__, 'activate_standalone_default' ) );
180        }
181    }
182
183    /**
184     * Make the module available to the module controller when the Jetpack
185     * plugin is not installed, so `jetpack_active_modules` is not inert there.
186     *
187     * @param array $modules Available standalone module slugs.
188     * @return array
189     */
190    public static function add_standalone_module( $modules ) {
191        $modules[] = self::MODULE_SLUG;
192
193        return array_values( array_unique( $modules ) );
194    }
195
196    /**
197     * Switch the module on once on a site with no Jetpack plugin.
198     *
199     * The Jetpack plugin activates the module for you via `Auto Activate: Yes`;
200     * a standalone install has no equivalent, so without this the page would
201     * disappear from every Backup/Boost/Protect/Search/VideoPress site on
202     * upgrade. Recorded in an option rather than repeated, so a later opt-out
203     * is not undone on the next request.
204     *
205     * @return void
206     */
207    public static function activate_standalone_default() {
208        // initialize() calls back into here, so the option alone cannot stop the cycle if it fails to read back.
209        if ( self::$standalone_default_attempted ) {
210            return;
211        }
212        self::$standalone_default_attempted = true;
213
214        if ( class_exists( 'Jetpack' ) || Jetpack_Options::get_option( self::DEFAULT_ACTIVATED_OPTION ) ) {
215            return;
216        }
217
218        if ( ! ( new Modules() )->activate( self::MODULE_SLUG, false, false ) ) {
219            return;
220        }
221
222        Jetpack_Options::update_option( self::DEFAULT_ACTIVATED_OPTION, true );
223
224        // initialize() ran before this and found the module off, so wire up now
225        // rather than leaving the page missing for the rest of the request.
226        self::initialize();
227    }
228
229    /**
230     * Register the Activity Log submenu under Jetpack.
231     *
232     * Mirrors the gating used by the legacy my-jetpack "Activity Log" menu
233     * item (connected user + non-multisite). On WordPress.com Simple sites,
234     * jetpack-mu-wpcom calls this once it has built the Jetpack menu.
235     *
236     * @since 0.6.0 Registers the page on WordPress.com Simple sites.
237     *
238     * @return string|null The resulting page's hook suffix, if registered.
239     */
240    public static function add_wp_admin_submenu() {
241        if ( ! self::is_available() ) {
242            return null;
243        }
244
245        // Load wp-build only on the Activity Log request so its generated
246        // render function exists before the menu callback runs, and its
247        // enqueue pipeline/polyfills stay off every other admin page.
248        if ( self::is_activity_log_admin_request() ) {
249            self::load_wp_build_with_screen_alias();
250        }
251
252        // The menu item must appear on every admin page, but the generated
253        // render function is only loaded on the Activity Log request (above).
254        // The callback is only ever invoked while rendering our page — where
255        // the function is loaded — so the fallback is purely defensive.
256        $render_callback = function_exists( 'jetpack_activity_log_jetpack_activity_log_dashboard_wp_admin_render_page' )
257            ? 'jetpack_activity_log_jetpack_activity_log_dashboard_wp_admin_render_page'
258            : array( __CLASS__, 'render_fallback' );
259
260        if ( self::is_wpcom_simple() ) {
261            // jetpack-mu-wpcom calls this after Admin_Menu has already flushed its queue.
262            $page_suffix = add_submenu_page(
263                'jetpack',
264                /** "Activity Log" is a product name, do not translate. */
265                'Activity Log',
266                'Activity Log',
267                'manage_options',
268                self::PAGE_SLUG,
269                $render_callback
270            );
271        } else {
272            $page_suffix = Admin_Menu::add_menu(
273                /** "Activity Log" is a product name, do not translate. */
274                'Activity Log',
275                'Activity Log',
276                'manage_options',
277                self::PAGE_SLUG,
278                $render_callback
279            );
280        }
281
282        if ( ! $page_suffix ) {
283            return null;
284        }
285
286        add_action( 'load-' . $page_suffix, array( __CLASS__, 'admin_init' ) );
287        self::opt_out_of_jitms( $page_suffix );
288
289        return $page_suffix;
290    }
291
292    /**
293     * Whether the Activity Log page should be shown to the current user.
294     *
295     * @return bool
296     */
297    public static function is_available() {
298        if ( ! current_user_can( 'manage_options' ) ) {
299            return false;
300        }
301
302        // Simple sites need no Jetpack connection: their users are WordPress.com users.
303        if ( self::is_wpcom_simple() ) {
304            return true;
305        }
306
307        if ( is_multisite() ) {
308            return false;
309        }
310
311        return ( new Connection_Manager() )->is_user_connected();
312    }
313
314    /**
315     * Fires when the admin page is loaded.
316     *
317     * When the user is returning from a successful checkout, the upsell
318     * CTA appends `?refresh_access=1&_wpnonce=…` to the `redirect_to`
319     * value it hands off to WordPress.com. Detect that here, verify the
320     * nonce, and drop the cached paid-plan signal so
321     * `Initial_State::get_data()` (which runs later in the same request,
322     * when the bundle is enqueued) rehydrates from WPCOM instead of
323     * re-serving the pre-checkout value. Mirrors the pattern in
324     * `Automattic\Jetpack\Publicize\Social_Admin_Page::admin_init()`.
325     */
326    public static function admin_init() {
327        if ( isset( $_GET['refresh_access'] ) && isset( $_GET['_wpnonce'] ) ) {
328            $nonce = sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) );
329            if ( wp_verify_nonce( $nonce, self::REFRESH_ACCESS_NONCE_ACTION ) ) {
330                REST_Controller::clear_access_cache();
331            }
332        }
333
334        add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_initial_state' ) );
335    }
336
337    /**
338     * Require the generated wp-build entry and register the script/module
339     * polyfills the boot bundle depends on.
340     *
341     * The boot bundle depends on `@wordpress/*` handles (e.g. `wp-theme`,
342     * pulled in via `@wordpress/ui`) that Core does not register on older
343     * WordPress versions. Without them WP_Scripts silently drops the bundle
344     * and the page renders blank, so register the polyfills here. Scoped to
345     * the Activity Log request by the sole caller, since the register() call
346     * can force-replace Core handles and must not fire on every admin page.
347     *
348     * @return void
349     */
350    private static function load_wp_build() {
351        $build_index = dirname( __DIR__ ) . '/build/build.php';
352
353        if ( ! file_exists( $build_index ) ) {
354            return;
355        }
356
357        require_once $build_index;
358
359        // The generated `modules.php` registers standalone script modules (the
360        // `@jetpack-activity-log/init` i18n bootstrap) on `wp_default_scripts`.
361        // We load wp-build lazily on `admin_menu`, which can run after that
362        // action has already fired — so the hook may be added too late and the
363        // init module never registers, leaving it out of the import map and
364        // breaking boot. Register directly here (mirroring the polyfills call
365        // below); the generated function guards against double-registration.
366        if ( function_exists( 'jetpack_activity_log_register_script_modules' ) ) {
367            jetpack_activity_log_register_script_modules(); // @phan-suppress-current-line PhanUndeclaredFunction -- Checked with function_exists(); defined in the generated build/modules.php, which Phan excludes.
368        }
369
370        WP_Build_Polyfills::register(
371            'jetpack-activity-log',
372            array_merge(
373                WP_Build_Polyfills::SCRIPT_HANDLES,
374                WP_Build_Polyfills::MODULE_IDS
375            )
376        );
377    }
378
379    /**
380     * Load wp-build with the screen ID aliased across its generated enqueue check.
381     *
382     * @see WP_Build_Screen_Id::load_with_alias()
383     * @return void
384     */
385    private static function load_wp_build_with_screen_alias() {
386        // Fallback: an older wp-build-polyfills under the jetpack-autoloader may predate load_with_alias().
387        if ( method_exists( WP_Build_Screen_Id::class, 'load_with_alias' ) ) {
388            WP_Build_Screen_Id::load_with_alias(
389                array( __CLASS__, 'alias_screen_id_for_wp_build' ),
390                array( __CLASS__, 'restore_screen_id_after_wp_build' ),
391                function () {
392                    self::load_wp_build();
393                }
394            );
395            return;
396        }
397
398        add_action( 'admin_enqueue_scripts', array( __CLASS__, 'alias_screen_id_for_wp_build' ) );
399        self::load_wp_build();
400        add_action( 'admin_enqueue_scripts', array( __CLASS__, 'restore_screen_id_after_wp_build' ) );
401    }
402
403    /**
404     * Alias the current screen id to the wp-build page slug.
405     *
406     * The wp-build-generated enqueue callback only fires when the screen id
407     * equals the wp-build page slug. Our menu slug stays `jetpack-activity-log`,
408     * so alias the screen id in place to make the check pass without changing
409     * the user-facing URL. Hooked only for the Activity Log request, so this
410     * never affects any other screen.
411     *
412     * @since 0.4.1 Takes no argument; hooked on `admin_enqueue_scripts`.
413     *
414     * @return void
415     */
416    public static function alias_screen_id_for_wp_build() {
417        $screen = get_current_screen();
418        if ( ! $screen ) {
419            return;
420        }
421
422        self::$wp_build_original_screen_id = $screen->id;
423        $screen->id                        = self::WP_BUILD_PAGE_SLUG;
424    }
425
426    /**
427     * Undo alias_screen_id_for_wp_build(), so code after the generated check sees the real screen ID.
428     *
429     * @since 0.4.1
430     *
431     * @return void
432     */
433    public static function restore_screen_id_after_wp_build() {
434        $screen = get_current_screen();
435        if ( ! $screen || null === self::$wp_build_original_screen_id ) {
436            return;
437        }
438
439        $screen->id                        = self::$wp_build_original_screen_id;
440        self::$wp_build_original_screen_id = null;
441    }
442
443    /**
444     * Opt the dashboard's screen out of JITMs.
445     *
446     * @param string $screen_id The hook suffix the page was registered under, which is its screen ID.
447     * @return void
448     */
449    private static function opt_out_of_jitms( $screen_id ) {
450        self::$jitm_opt_out_screen_id = $screen_id;
451        add_filter( 'jetpack_display_jitms_on_screen', array( __CLASS__, 'hide_jitms_on_wp_build_dashboard' ), 10, 2 );
452    }
453
454    /**
455     * Keep JITMs off the wp-build dashboard, which has no `#jp-admin-notices` to show them in.
456     *
457     * Fetching a JITM records a view, so one the page hides would still be counted.
458     *
459     * @since 0.4.1
460     *
461     * @param bool   $show      Whether to show JITMs on the screen.
462     * @param string $screen_id The screen ID.
463     * @return bool
464     */
465    public static function hide_jitms_on_wp_build_dashboard( $show, $screen_id ) {
466        if ( null !== self::$jitm_opt_out_screen_id && self::$jitm_opt_out_screen_id === $screen_id ) {
467            return false;
468        }
469
470        return $show;
471    }
472
473    /**
474     * Print the React initial state and the Connection initial state, and load
475     * the Tracks transport.
476     *
477     * The initial state is attached to a dedicated empty classic handle because
478     * the dashboard is a wp-build script module — there is no classic bundle
479     * handle to hang the inline data on. Boot defers its own execution to
480     * `DOMContentLoaded`, so this inline data is always set on `window` first.
481     *
482     * `jp-tracks` (stats.wp.com/w.js) is required for analytics: the dashboard's
483     * `@automattic/jetpack-analytics` events only queue into `window._tkq`
484     * (the package's own w.js loader is disabled), so without this handle no
485     * `jetpack_activity_log_*` event ever flushes. Mirrors Newsletter's
486     * `Settings::load_admin_scripts()`.
487     *
488     * @return void
489     */
490    public static function enqueue_initial_state() {
491        wp_register_script( self::DATA_SCRIPT_HANDLE, false, array(), Package_Version::PACKAGE_VERSION, true );
492        wp_enqueue_script( self::DATA_SCRIPT_HANDLE );
493
494        // wp-build has no `jetpackConfig` external, so @automattic/jetpack-config (via AdminPage) reads this global.
495        wp_add_inline_script( self::DATA_SCRIPT_HANDLE, 'window.jetpackConfig = ' . wp_json_encode( array( 'consumer_slug' => self::PAGE_SLUG ), JSON_HEX_TAG | JSON_HEX_AMP | JSON_UNESCAPED_SLASHES ) . ';', 'before' );
496        wp_add_inline_script( self::DATA_SCRIPT_HANDLE, ( new Activity_Log_Initial_State() )->render(), 'before' );
497
498        // Simple has no Jetpack connection to describe; the app treats a missing state as no connection errors.
499        if ( ! self::is_wpcom_simple() ) {
500            Connection_Initial_State::render_script( self::DATA_SCRIPT_HANDLE );
501        }
502
503        wp_enqueue_script( 'jp-tracks', '//stats.wp.com/w.js', array(), gmdate( 'YW' ), true );
504
505        // The dashboard is a wp-build script module: it externalizes
506        // `@wordpress/i18n` to the shared `wp.i18n` global but has no
507        // `wp_set_script_translations()` equivalent to load its JS catalog.
508        // Enqueue Jetpack's i18n loader (`wp.jpI18nLoader`, from jetpack-assets,
509        // registered on `wp_default_scripts`) so the `@jetpack-activity-log/init`
510        // boot module can fetch and install the translation catalog before the
511        // app renders. Without this the UI ships in English on non-English sites.
512        if ( wp_script_is( 'wp-jp-i18n-loader', 'registered' ) ) {
513            wp_enqueue_script( 'wp-jp-i18n-loader' );
514        }
515    }
516
517    /**
518     * Whether this is a WordPress.com Simple site.
519     *
520     * @since 0.6.0
521     *
522     * @return bool
523     */
524    public static function is_wpcom_simple() {
525        return ( new Host() )->is_wpcom_simple();
526    }
527
528    /**
529     * Fallback page body if the generated wp-build render function is
530     * unavailable (e.g. assets not built). Keeps the menu from fataling and
531     * still gives boot its mount container.
532     *
533     * @return void
534     */
535    public static function render_fallback() {
536        echo '<div class="wrap"><div id="jetpack-activity-log-dashboard-wp-admin-app"></div></div>';
537    }
538
539    /**
540     * Whether the current request targets the Activity Log admin page.
541     *
542     * The `$_GET['page']` value is populated by wp-admin/admin.php before any
543     * of our hooks fire, so this check is reliable from `admin_menu` onwards.
544     *
545     * @return bool
546     */
547    private static function is_activity_log_admin_request() {
548        if ( ! is_admin() || ! isset( $_GET['page'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
549            return false;
550        }
551
552        return sanitize_text_field( wp_unslash( $_GET['page'] ) ) === self::PAGE_SLUG; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
553    }
554
555    /**
556     * Register the REST routes backing the Activity Log UI.
557     *
558     * Routes are added in Phase 2. This method exists now so that the
559     * `jetpack/v4/activity-log` namespace is reserved and the hook is wired.
560     */
561    public static function register_rest_routes() {
562        REST_Controller::register_rest_routes();
563    }
564}