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