Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 64
0.00% covered (danger)
0.00%
0 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Jetpack_Activity_Log
0.00% covered (danger)
0.00%
0 / 62
0.00% covered (danger)
0.00%
0 / 10
702
0.00% covered (danger)
0.00%
0 / 1
 initialize
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 add_wp_admin_submenu
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
30
 is_available
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 admin_init
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
20
 load_wp_build
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
12
 alias_screen_id_for_wp_build
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 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
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 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\WP_Build_Polyfills\WP_Build_Polyfills;
19use function add_action;
20use function add_filter;
21use function current_user_can;
22use function did_action;
23use function do_action;
24use function is_admin;
25use function is_multisite;
26use function sanitize_text_field;
27use function wp_add_inline_script;
28use function wp_enqueue_script;
29use function wp_register_script;
30use function wp_unslash;
31use function wp_verify_nonce;
32
33/**
34 * Class Jetpack_Activity_Log
35 *
36 * Registers the Activity Log admin page and its REST routes inside the
37 * main Jetpack plugin.
38 */
39class Jetpack_Activity_Log {
40
41    /**
42     * Admin page slug.
43     *
44     * @var string
45     */
46    const PAGE_SLUG = 'jetpack-activity-log';
47
48    /**
49     * Page slug for the wp-build dashboard. Distinct from the wp-admin menu
50     * slug (`PAGE_SLUG`) so the user-facing URL stays `admin.php?page=jetpack-activity-log`;
51     * we alias the current screen id to this value so wp-build's
52     * screen-match enqueue callback fires. Must match the `page` in
53     * `routes/dashboard/package.json` and the `wpPlugin.pages` entry.
54     *
55     * @var string
56     */
57    const WP_BUILD_PAGE_SLUG = 'jetpack-activity-log-dashboard';
58
59    /**
60     * Handle for the classic script that carries the React initial state.
61     * The dashboard is a wp-build script module, so there is no classic
62     * bundle handle to attach inline data to — this empty handle exists
63     * purely to print `JPACTIVITYLOG_INITIAL_STATE` and the Connection
64     * initial state before boot runs.
65     *
66     * @var string
67     */
68    const DATA_SCRIPT_HANDLE = 'jetpack-activity-log-data';
69
70    /**
71     * Nonce action for refreshing the access flag after a checkout
72     * return. Used by `admin_init()` below and exposed to the client via
73     * Initial_State so the upsell CTA can embed a valid nonce in its
74     * `redirect_to`. Same shape as `Social_Admin_Page::REFRESH_PLAN_NONCE_ACTION`.
75     *
76     * @var string
77     */
78    const REFRESH_ACCESS_NONCE_ACTION = 'jetpack_activity_log_refresh_access';
79
80    /**
81     * Entry point. Idempotent: safe to call from multiple bootstraps.
82     */
83    public static function initialize() {
84        if ( did_action( 'jetpack_activity_log_initialized' ) ) {
85            return;
86        }
87
88        add_action( 'admin_menu', array( __CLASS__, 'add_wp_admin_submenu' ) );
89        add_action( 'rest_api_init', array( __CLASS__, 'register_rest_routes' ) );
90        add_filter( 'jetpack_package_versions', array( Package_Version::class, 'send_package_version_to_tracker' ) );
91
92        /**
93         * Fires once the Jetpack Activity Log package has wired its hooks.
94         *
95         * @since 0.1.0
96         */
97        do_action( 'jetpack_activity_log_initialized' );
98    }
99
100    /**
101     * Register the Activity Log submenu under Jetpack.
102     *
103     * Mirrors the gating used by the legacy my-jetpack "Activity Log" menu
104     * item (connected user + non-multisite).
105     *
106     * @return string|null The resulting page's hook suffix, if registered.
107     */
108    public static function add_wp_admin_submenu() {
109        if ( ! self::is_available() ) {
110            return null;
111        }
112
113        // Load wp-build only on the Activity Log request so its generated
114        // render function exists before the menu callback runs, and its
115        // enqueue pipeline/polyfills stay off every other admin page. Alias
116        // the screen id here too — `current_screen` fires before the
117        // `load-{$page_suffix}` hook that runs admin_init(), so registering
118        // the alias from admin_init() would be too late for wp-build's
119        // screen-matched enqueue callback.
120        if ( self::is_activity_log_admin_request() ) {
121            self::load_wp_build();
122            add_action( 'current_screen', array( __CLASS__, 'alias_screen_id_for_wp_build' ) );
123        }
124
125        // The menu item must appear on every admin page, but the generated
126        // render function is only loaded on the Activity Log request (above).
127        // The callback is only ever invoked while rendering our page — where
128        // the function is loaded — so the fallback is purely defensive.
129        $render_callback = function_exists( 'jetpack_activity_log_jetpack_activity_log_dashboard_wp_admin_render_page' )
130            ? 'jetpack_activity_log_jetpack_activity_log_dashboard_wp_admin_render_page'
131            : array( __CLASS__, 'render_fallback' );
132
133        $page_suffix = Admin_Menu::add_menu(
134            /** "Activity Log" is a product name, do not translate. */
135            'Activity Log',
136            'Activity Log',
137            'manage_options',
138            self::PAGE_SLUG,
139            $render_callback,
140            12
141        );
142
143        if ( $page_suffix ) {
144            add_action( 'load-' . $page_suffix, array( __CLASS__, 'admin_init' ) );
145        }
146
147        return $page_suffix;
148    }
149
150    /**
151     * Whether the Activity Log page should be shown to the current user.
152     *
153     * @return bool
154     */
155    public static function is_available() {
156        if ( is_multisite() ) {
157            return false;
158        }
159
160        if ( ! current_user_can( 'manage_options' ) ) {
161            return false;
162        }
163
164        return ( new Connection_Manager() )->is_user_connected();
165    }
166
167    /**
168     * Fires when the admin page is loaded.
169     *
170     * When the user is returning from a successful checkout, the upsell
171     * CTA appends `?refresh_access=1&_wpnonce=…` to the `redirect_to`
172     * value it hands off to WordPress.com. Detect that here, verify the
173     * nonce, and drop the cached paid-plan signal so
174     * `Initial_State::get_data()` (which runs later in the same request,
175     * when the bundle is enqueued) rehydrates from WPCOM instead of
176     * re-serving the pre-checkout value. Mirrors the pattern in
177     * `Automattic\Jetpack\Publicize\Social_Admin_Page::admin_init()`.
178     */
179    public static function admin_init() {
180        if ( isset( $_GET['refresh_access'] ) && isset( $_GET['_wpnonce'] ) ) {
181            $nonce = sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) );
182            if ( wp_verify_nonce( $nonce, self::REFRESH_ACCESS_NONCE_ACTION ) ) {
183                REST_Controller::clear_access_cache();
184            }
185        }
186
187        add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_initial_state' ) );
188    }
189
190    /**
191     * Require the generated wp-build entry and register the script/module
192     * polyfills the boot bundle depends on.
193     *
194     * The boot bundle depends on `@wordpress/*` handles (e.g. `wp-theme`,
195     * pulled in via `@wordpress/ui`) that Core does not register on older
196     * WordPress versions. Without them WP_Scripts silently drops the bundle
197     * and the page renders blank, so register the polyfills here. Scoped to
198     * the Activity Log request by the sole caller, since the register() call
199     * can force-replace Core handles and must not fire on every admin page.
200     *
201     * @return void
202     */
203    private static function load_wp_build() {
204        $build_index = dirname( __DIR__ ) . '/build/build.php';
205
206        if ( ! file_exists( $build_index ) ) {
207            return;
208        }
209
210        require_once $build_index;
211
212        // The generated `modules.php` registers standalone script modules (the
213        // `@jetpack-activity-log/init` i18n bootstrap) on `wp_default_scripts`.
214        // We load wp-build lazily on `admin_menu`, which can run after that
215        // action has already fired — so the hook may be added too late and the
216        // init module never registers, leaving it out of the import map and
217        // breaking boot. Register directly here (mirroring the polyfills call
218        // below); the generated function guards against double-registration.
219        if ( function_exists( 'jetpack_activity_log_register_script_modules' ) ) {
220            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.
221        }
222
223        WP_Build_Polyfills::register(
224            'jetpack-activity-log',
225            array_merge(
226                WP_Build_Polyfills::SCRIPT_HANDLES,
227                WP_Build_Polyfills::MODULE_IDS
228            )
229        );
230    }
231
232    /**
233     * Alias the current screen id to the wp-build page slug.
234     *
235     * The wp-build-generated enqueue callback only fires when the screen id
236     * equals the wp-build page slug. Our menu slug stays `jetpack-activity-log`,
237     * so alias the screen id in place to make the check pass without changing
238     * the user-facing URL. Hooked on `current_screen` only for the Activity Log
239     * request, so this never affects any other screen.
240     *
241     * @param \WP_Screen|null $screen The current screen object (passed by WP).
242     * @return void
243     */
244    public static function alias_screen_id_for_wp_build( $screen ) {
245        if ( is_object( $screen ) ) {
246            $screen->id = self::WP_BUILD_PAGE_SLUG;
247        }
248    }
249
250    /**
251     * Print the React initial state and the Connection initial state, and load
252     * the Tracks transport.
253     *
254     * The initial state is attached to a dedicated empty classic handle because
255     * the dashboard is a wp-build script module — there is no classic bundle
256     * handle to hang the inline data on. Boot defers its own execution to
257     * `DOMContentLoaded`, so this inline data is always set on `window` first.
258     *
259     * `jp-tracks` (stats.wp.com/w.js) is required for analytics: the dashboard's
260     * `@automattic/jetpack-analytics` events only queue into `window._tkq`
261     * (the package's own w.js loader is disabled), so without this handle no
262     * `jetpack_activity_log_*` event ever flushes. Mirrors Newsletter's
263     * `Settings::load_admin_scripts()`.
264     *
265     * @return void
266     */
267    public static function enqueue_initial_state() {
268        wp_register_script( self::DATA_SCRIPT_HANDLE, false, array(), Package_Version::PACKAGE_VERSION, true );
269        wp_enqueue_script( self::DATA_SCRIPT_HANDLE );
270
271        wp_add_inline_script( self::DATA_SCRIPT_HANDLE, ( new Activity_Log_Initial_State() )->render(), 'before' );
272        Connection_Initial_State::render_script( self::DATA_SCRIPT_HANDLE );
273
274        wp_enqueue_script( 'jp-tracks', '//stats.wp.com/w.js', array(), gmdate( 'YW' ), true );
275
276        // The dashboard is a wp-build script module: it externalizes
277        // `@wordpress/i18n` to the shared `wp.i18n` global but has no
278        // `wp_set_script_translations()` equivalent to load its JS catalog.
279        // Enqueue Jetpack's i18n loader (`wp.jpI18nLoader`, from jetpack-assets,
280        // registered on `wp_default_scripts`) so the `@jetpack-activity-log/init`
281        // boot module can fetch and install the translation catalog before the
282        // app renders. Without this the UI ships in English on non-English sites.
283        if ( wp_script_is( 'wp-jp-i18n-loader', 'registered' ) ) {
284            wp_enqueue_script( 'wp-jp-i18n-loader' );
285        }
286    }
287
288    /**
289     * Fallback page body if the generated wp-build render function is
290     * unavailable (e.g. assets not built). Keeps the menu from fataling and
291     * still gives boot its mount container.
292     *
293     * @return void
294     */
295    public static function render_fallback() {
296        echo '<div class="wrap"><div id="jetpack-activity-log-dashboard-wp-admin-app"></div></div>';
297    }
298
299    /**
300     * Whether the current request targets the Activity Log admin page.
301     *
302     * The `$_GET['page']` value is populated by wp-admin/admin.php before any
303     * of our hooks fire, so this check is reliable from `admin_menu` onwards.
304     *
305     * @return bool
306     */
307    private static function is_activity_log_admin_request() {
308        if ( ! is_admin() || ! isset( $_GET['page'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
309            return false;
310        }
311
312        return sanitize_text_field( wp_unslash( $_GET['page'] ) ) === self::PAGE_SLUG; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
313    }
314
315    /**
316     * Register the REST routes backing the Activity Log UI.
317     *
318     * Routes are added in Phase 2. This method exists now so that the
319     * `jetpack/v4/activity-log` namespace is reserved and the hook is wired.
320     */
321    public static function register_rest_routes() {
322        REST_Controller::register_rest_routes();
323    }
324}