This commit is contained in:
2026-07-02 15:54:39 -06:00
commit 9883323161
17470 changed files with 4470592 additions and 0 deletions
@@ -0,0 +1,84 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.1.8] - 2026-06-29
### Changed
- Update package dependencies. [#49271]
### Fixed
- Admin: Align Text, Notice and Tabs.Panel props with the `@wordpress/ui` 0.15 API. [#49796]
## [0.1.7] - 2026-06-25
### Changed
- Update dependencies. [#49857]
## [0.1.6] - 2026-06-22
### Changed
- Update package dependencies. [#49631] [#49691] [#49757]
## [0.1.5] - 2026-06-15
### Changed
- Update package dependencies. [#49273]
## [0.1.4] - 2026-06-08
### Changed
- Update dependencies. [#49354]
## [0.1.3] - 2026-06-01
### Changed
- Update package dependencies. [#48404] [#49152]
## [0.1.2] - 2026-05-25
### Changed
- Normalize page tabs onto the shared minimal variant and `jp-admin-page-tabs--minimal` wrapper modifier. [#48964]
- Update package dependencies. [#48405] [#49012]
## [0.1.1] - 2026-05-19
### Changed
- Build: Remove redundant dependency build script. [#48794]
- Exclude development files from production builds. [#47365]
- Update dependencies. [#48778]
## [0.1.0] - 2026-05-11
### Added
- Add initial package with page shell, REST namespace placeholder, and Active threats / History views. [#48458]
- Analytics: Add Tracks events for DataViews interactions, Scan now, auto-fix, modal opens, successes, and failures. [#48458]
- API: Add WPCOM bridge endpoints for scan reads, scan counts, scan enqueue, threat actions, and fix-status polling. [#48458]
- DataViews: Persist search, filters, sort, pagination, and layout across reloads for Active threats and Scan history. [#48458]
- Tests: Add PHPUnit route-registration coverage and Jest coverage for fix-status polling helpers. [#48458]
- Threats: Add per-threat fix, ignore, unignore, and view-details modal flows. [#48458]
- Threats: Add bulk auto-fix and Scan now flows with progress handling. [#48458]
### Changed
- Admin: Adopt shared Jetpack AdminPage chrome, tab layout, footer handling, and full-height WP Admin page structure. [#48458]
- Admin: Migrate the page from the webpack pipeline to a wp-build route. [#48458]
- Admin: Silence standard WP Admin notices on the Scan page to avoid layout shifts during scans and fix flows. [#48458]
- Admin: Update `@wordpress/admin-ui` to 2.0.0. [#48410]
- DataViews: Use DataViews-managed empty states and remove duplicate in-table status filtering from the Scan panels. [#48458]
- UI: Move tabs, empty states, stacks, buttons, dialog content, and notices toward `@wordpress/ui`. [#48458]
### Fixed
- Admin: Fix full-height table and empty-state layout issues. [#48458]
- API: Use blog-scoped WPCOM authentication for site-level scan reads and scan enqueue requests. [#48458]
- Availability: Fix a connection-store crash by relying on server-side Scan availability checks. [#48458]
- Build: Fix the wp-build production script and dependency build order so Scan builds from a fresh checkout. [#48458]
- Styles: Use double quotes in the `[role="tabpanel"]` selector to satisfy style checks. [#48458]
- Threats: Handle fix-status polling errors so fix modals no longer remain stuck in progress states. [#48458]
## 0.1.0-alpha - unreleased
Initial release.
[0.1.8]: https://github.com/Automattic/jetpack-scan-page/compare/v0.1.7...v0.1.8
[0.1.7]: https://github.com/Automattic/jetpack-scan-page/compare/v0.1.6...v0.1.7
[0.1.6]: https://github.com/Automattic/jetpack-scan-page/compare/v0.1.5...v0.1.6
[0.1.5]: https://github.com/Automattic/jetpack-scan-page/compare/v0.1.4...v0.1.5
[0.1.4]: https://github.com/Automattic/jetpack-scan-page/compare/v0.1.3...v0.1.4
[0.1.3]: https://github.com/Automattic/jetpack-scan-page/compare/v0.1.2...v0.1.3
[0.1.2]: https://github.com/Automattic/jetpack-scan-page/compare/v0.1.1...v0.1.2
[0.1.1]: https://github.com/Automattic/jetpack-scan-page/compare/v0.1.0...v0.1.1
[0.1.0]: https://github.com/Automattic/jetpack-scan-page/compare/v0.1.0-alpha...v0.1.0
@@ -0,0 +1,43 @@
<?php
/**
* Main entry point for auto-generated asset registration.
* Do not edit this file manually.
*
* @package jetpack_scan
*/
// Load script module registration.
$modules_file = __DIR__ . '/modules.php';
if ( file_exists( $modules_file ) ) {
require_once $modules_file;
}
// Load script registration.
$scripts_file = __DIR__ . '/scripts.php';
if ( file_exists( $scripts_file ) ) {
require_once $scripts_file;
}
// Load style registration.
$styles_file = __DIR__ . '/styles.php';
if ( file_exists( $styles_file ) ) {
require_once $styles_file;
}
// Load routes registration.
$routes_file = __DIR__ . '/routes.php';
if ( file_exists( $routes_file ) ) {
require_once $routes_file;
}
// Load widgets registration.
$widgets_file = __DIR__ . '/widgets.php';
if ( file_exists( $widgets_file ) ) {
require_once $widgets_file;
}
// Load pages registration.
$pages_file = __DIR__ . '/pages.php';
if ( file_exists( $pages_file ) ) {
require_once $pages_file;
}
@@ -0,0 +1,14 @@
<?php
/**
* Plugin constants - Auto-generated by build process.
* Do not edit this file manually.
*
* Returns an array of constants for use in other templates.
*
* @package jetpack_scan
*/
return array(
'version' => 'undefined',
'build_url' => plugin_dir_url( __FILE__ ),
);
@@ -0,0 +1,63 @@
<?php
/**
* Script module registration - Auto-generated by build process.
* Do not edit this file manually.
*
* @package jetpack_scan
*/
/**
* Register all script modules.
*/
function jetpack_scan_register_script_modules() {
// Ensure this only runs once. wp_default_scripts can fire multiple times,
// and each wp_deregister_script_module() call also dequeues the module.
// If a module was enqueued between calls, repeated deregister/register
// cycles would lose the enqueue state.
static $already_registered = false;
if ( $already_registered ) {
return;
}
$already_registered = true;
// Load build constants
$constants_file = __DIR__ . '/constants.php';
if ( ! file_exists( $constants_file ) ) {
return;
}
$build_constants = require $constants_file;
$modules_dir = __DIR__ . '/modules';
$modules_file = $modules_dir . '/registry.php';
if ( ! file_exists( $modules_file ) ) {
return;
}
$modules = require $modules_file;
$base_url = $build_constants['build_url'] . 'modules/';
foreach ( $modules as $module ) {
// WASM-only modules (e.g., vips worker) only have .min.js; use it even when SCRIPT_DEBUG is `true`.
$extension = '.min.js';
$asset_path = $modules_dir . '/' . $module['asset'];
$asset = file_exists( $asset_path ) ? require $asset_path : array();
// Deregister first to override any previously registered version
// (e.g., Core's default modules when running as a plugin).
wp_deregister_script_module( $module['id'] );
wp_register_script_module(
$module['id'],
$base_url . $module['path'] . $extension,
$asset['module_dependencies'] ?? array(),
$asset['version'] ?? false,
array(
'fetchpriority' => 'low',
'in_footer' => true,
)
);
}
}
add_action( 'wp_default_scripts', 'jetpack_scan_register_script_modules' );
@@ -0,0 +1 @@
<?php return array('dependencies' => array('react', 'react-dom', 'react-jsx-runtime', 'wp-commands', 'wp-components', 'wp-compose', 'wp-core-data', 'wp-data', 'wp-editor', 'wp-element', 'wp-html-entities', 'wp-i18n', 'wp-keyboard-shortcuts', 'wp-keycodes', 'wp-notices', 'wp-primitives', 'wp-private-apis', 'wp-theme', 'wp-url'), 'module_dependencies' => array(array('id' => '@wordpress/a11y', 'import' => 'static'), array('id' => '@wordpress/lazy-editor', 'import' => 'dynamic'), array('id' => '@wordpress/route', 'import' => 'static')), 'version' => '188faf6c64fdd1b4');
@@ -0,0 +1,11 @@
<?php
/**
* Script module registry - Auto-generated by build process.
* Do not edit this file manually.
*
* @package jetpack_scan
*/
return array(
);
@@ -0,0 +1,16 @@
<?php
/**
* Pages loader - Auto-generated by build process.
* Do not edit this file manually.
*
* @package jetpack_scan
*/
foreach ( [
__DIR__ . '/pages/jetpack-scan/page.php',
__DIR__ . '/pages/jetpack-scan/page-wp-admin.php',
] as $file ) {
if ( file_exists( $file ) ) {
require_once $file;
}
}
@@ -0,0 +1 @@
// Empty module loader for page dependencies
@@ -0,0 +1,317 @@
<?php
/**
* Page: jetpack-scan (wp-admin integrated)
* Auto-generated by build process.
* Do not edit this file manually.
*
* This version integrates with the standard WordPress admin interface,
* keeping the wp-admin sidebar and scripts/styles intact.
*
* @package jetpack_scan
*/
// Global storage for jetpack-scan routes and menu items
global $jetpack_scan_jetpack_scan_wp_admin_routes, $jetpack_scan_jetpack_scan_wp_admin_menu_items;
$jetpack_scan_jetpack_scan_wp_admin_routes = array();
$jetpack_scan_jetpack_scan_wp_admin_menu_items = array();
/**
* Register a route for the jetpack-scan-wp-admin page.
*
* @param string $path Route path (e.g., '/types/$type/edit/$id').
* @param string|null $content_module Script module ID for content (stage/inspector).
* @param string|null $route_module Script module ID for route lifecycle hooks.
*/
function jetpack_scan_register_jetpack_scan_wp_admin_route( $path, $content_module = null, $route_module = null ) {
global $jetpack_scan_jetpack_scan_wp_admin_routes;
$route = array( 'path' => $path );
if ( ! empty( $content_module ) ) {
$route['content_module'] = $content_module;
}
if ( ! empty( $route_module ) ) {
$route['route_module'] = $route_module;
}
$jetpack_scan_jetpack_scan_wp_admin_routes[] = $route;
}
/**
* Register a menu item for the jetpack-scan-wp-admin page.
* Note: Menu items are registered but not displayed in single-page mode.
*
* @param string $id Menu item ID.
* @param string $label Display label.
* @param string $to Route path to navigate to.
* @param string $parent_id Optional. Parent menu item ID.
*/
function jetpack_scan_register_jetpack_scan_wp_admin_menu_item( $id, $label, $to, $parent_id = '' ) {
global $jetpack_scan_jetpack_scan_wp_admin_menu_items;
$menu_item = array(
'id' => $id,
'label' => $label,
'to' => $to,
);
if ( ! empty( $parent_id ) ) {
$menu_item['parent'] = $parent_id;
}
$jetpack_scan_jetpack_scan_wp_admin_menu_items[] = $menu_item;
}
/**
* Get all registered routes for the jetpack-scan-wp-admin page.
*
* @return array Array of route objects.
*/
function jetpack_scan_get_jetpack_scan_wp_admin_routes() {
global $jetpack_scan_jetpack_scan_wp_admin_routes;
return $jetpack_scan_jetpack_scan_wp_admin_routes ?? array();
}
/**
* Get all registered menu items for the jetpack-scan-wp-admin page.
*
* @return array Array of menu item objects.
*/
function jetpack_scan_get_jetpack_scan_wp_admin_menu_items() {
global $jetpack_scan_jetpack_scan_wp_admin_menu_items;
return $jetpack_scan_jetpack_scan_wp_admin_menu_items ?? array();
}
/**
* Preload REST API data for the jetpack-scan-wp-admin page.
* Automatically called during page rendering.
*/
function jetpack_scan_jetpack_scan_wp_admin_preload_data() {
// Define paths to preload - same for all pages
// Please also change packages/core-data/src/entities.js when changing this.
$preload_paths = array(
'/?_fields=description,gmt_offset,home,image_sizes,image_size_threshold,name,site_icon,site_icon_url,site_logo,timezone_string,url,page_for_posts,page_on_front,show_on_front',
array( '/wp/v2/settings', 'OPTIONS' ),
);
// Use rest_preload_api_request to gather the preloaded data
$preload_data = array_reduce(
$preload_paths,
'rest_preload_api_request',
array()
);
// Register the preloading middleware with wp-api-fetch
wp_add_inline_script(
'wp-api-fetch',
sprintf(
'wp.apiFetch.use( wp.apiFetch.createPreloadingMiddleware( %s ) );',
wp_json_encode( $preload_data )
),
'after'
);
}
/**
* Enqueue scripts and styles for the jetpack-scan-wp-admin page.
* Hooked to admin_enqueue_scripts.
*
* @param string $hook_suffix The current admin page.
*/
function jetpack_scan_jetpack_scan_wp_admin_enqueue_scripts( $hook_suffix ) {
// Check all possible ways this page can be accessed:
// 1. Menu page via admin.php?page=jetpack-scan-wp-admin (plugin)
// 2. Direct file via jetpack-scan.php (Core) - screen ID will be 'jetpack-scan'
$current_screen = get_current_screen();
$is_our_page = (
( isset( $_GET['page'] ) && 'jetpack-scan-wp-admin' === $_GET['page'] ) || // phpcs:ignore WordPress.Security.NonceVerification.Recommended
( $current_screen && 'jetpack-scan' === $current_screen->id )
);
if ( ! $is_our_page ) {
return;
}
// Load build constants
$build_constants = require __DIR__ . '/../../constants.php';
/**
* Fires when the jetpack-scan admin page is initialized so extensions can register routes and menu items.
*/
do_action( 'jetpack-scan-wp-admin_init' );
// Preload REST API data
jetpack_scan_jetpack_scan_wp_admin_preload_data();
// Get all registered routes
$routes = jetpack_scan_get_jetpack_scan_wp_admin_routes();
// Get boot module asset file for dependencies
$asset_file = __DIR__ . '/../../modules/boot/index.min.asset.php';
if ( file_exists( $asset_file ) ) {
$asset = require $asset_file;
// This script serves two purposes:
// 1. It ensures all the globals that are made available to the modules are loaded.
// 2. It initializes the boot module as an inline script.
wp_register_script( 'jetpack-scan-wp-admin-prerequisites', '', $asset['dependencies'], $asset['version'], true );
/*
* Add inline script to initialize the app using initSinglePage (no menuItems).
* The dynamic import is deferred until DOMContentLoaded so that all classic
* script dependencies of @wordpress/boot (wp-private-apis, wp-components,
* wp-theme, etc.) have finished parsing and executing before the boot module
* evaluates. Otherwise, a modulepreloaded @wordpress/boot can win the race
* against the classic-script-printing pass on fast CDN-fronted hosts in
* Chrome, evaluating before wp.theme.privateApis is defined and throwing
* "Cannot unlock an undefined object". See <https://core.trac.wordpress.org/ticket/65103>.
*/
$init_js_function = <<<'JS'
( mountId, routes ) => {
const run = async () => {
const mod = await import( "@wordpress/boot" );
mod.initSinglePage( { mountId, routes } );
};
if ( document.readyState === "loading" ) {
document.addEventListener( "DOMContentLoaded", run );
} else {
run();
}
}
JS;
wp_add_inline_script(
'jetpack-scan-wp-admin-prerequisites',
sprintf(
'( %s )( %s, %s );',
$init_js_function,
wp_json_encode( 'jetpack-scan-wp-admin-app', JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ),
wp_json_encode( $routes, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES )
)
);
// Register prerequisites style by filtering script dependencies to find registered styles
$style_dependencies = array_filter(
$asset['dependencies'],
function ( $handle ) {
return wp_style_is( $handle, 'registered' );
}
);
wp_register_style( 'jetpack-scan-wp-admin-prerequisites', false, $style_dependencies, $asset['version'] );
// Build dependencies for jetpack-scan-wp-admin module
$boot_dependencies = array(
array(
'import' => 'static',
'id' => '@wordpress/boot',
),
);
// Add all registered routes as dependencies
foreach ( $routes as $route ) {
if ( isset( $route['route_module'] ) ) {
$boot_dependencies[] = array(
'import' => 'static',
'id' => $route['route_module'],
);
}
if ( isset( $route['content_module'] ) ) {
$boot_dependencies[] = array(
'import' => 'dynamic',
'id' => $route['content_module'],
);
}
}
/**
* Filters the boot script-module dependencies for the
* jetpack-scan-wp-admin page.
*
* Surfaces extending this page can append entries to the boot
* dependency list. Each entry is an array with 'import' (string
* 'static' or 'dynamic') and 'id' (script-module handle) keys.
*
* @param array $boot_dependencies Boot dependencies for the page.
*/
$boot_dependencies = apply_filters(
'jetpack-scan-wp-admin_boot_dependencies',
$boot_dependencies
);
// Dummy script module to ensure dependencies are loaded
wp_register_script_module(
'jetpack-scan-wp-admin',
$build_constants['build_url'] . 'pages/jetpack-scan/loader.js',
$boot_dependencies
);
// Enqueue the boot scripts and styles
wp_enqueue_script( 'jetpack-scan-wp-admin-prerequisites' );
wp_enqueue_script_module( 'jetpack-scan-wp-admin' );
wp_enqueue_style( 'jetpack-scan-wp-admin-prerequisites' );
}
}
/**
* Render the jetpack-scan-wp-admin page.
* Call this function from add_menu_page or add_submenu_page.
* This renders within the normal WordPress admin interface.
*/
function jetpack_scan_jetpack_scan_wp_admin_render_page() {
?>
<style>
/* Critical styles to prevent layout shifts - inlined for immediate application */
#wpwrap {
overflow-y: auto;
}
body {
background: #fff;
}
/* Reset wp-admin padding */
#wpcontent {
padding-inline-start: 0;
}
#wpbody-content {
padding-bottom: 0;
}
/* Hide legacy admin elements */
#wpbody-content > div:not(.boot-layout-container):not(#screen-meta) {
display: none;
}
#wpfooter {
display: none;
}
/* Accessibility regions */
.a11y-speak-region {
inset-inline-start: -1px;
top: -1px;
}
/* Admin menu indicators */
ul#adminmenu a.wp-has-current-submenu::after,
ul#adminmenu > li.current > a.current::after {
border-inline-end-color: #fff;
}
/* Media frame fix */
.media-frame select.attachment-filters:last-of-type {
width: auto;
max-width: 100%;
}
/* Responsive overflow fix for #wpwrap */
@media (min-width: 782px) {
#wpwrap {
overflow-y: initial;
}
}
</style>
<div id="jetpack-scan-wp-admin-app" class="boot-layout-container"></div>
<?php
}
// Hook the enqueue function to admin_enqueue_scripts
add_action( 'admin_enqueue_scripts', 'jetpack_scan_jetpack_scan_wp_admin_enqueue_scripts' );
@@ -0,0 +1,318 @@
<?php
/**
* Page: jetpack-scan
* Auto-generated by build process.
* Do not edit this file manually.
*
* @package jetpack_scan
*/
// Global storage for jetpack-scan routes and menu items
global $jetpack_scan_jetpack_scan_routes, $jetpack_scan_jetpack_scan_menu_items;
$jetpack_scan_jetpack_scan_routes = array();
$jetpack_scan_jetpack_scan_menu_items = array();
/**
* Register a route for the jetpack-scan page.
*
* @param string $path Route path (e.g., '/types/$type/edit/$id').
* @param string|null $content_module Script module ID for content (stage/inspector).
* @param string|null $route_module Script module ID for route lifecycle hooks.
*/
function jetpack_scan_register_jetpack_scan_route( $path, $content_module = null, $route_module = null ) {
global $jetpack_scan_jetpack_scan_routes;
$route = array( 'path' => $path );
if ( ! empty( $content_module ) ) {
$route['content_module'] = $content_module;
}
if ( ! empty( $route_module ) ) {
$route['route_module'] = $route_module;
}
$jetpack_scan_jetpack_scan_routes[] = $route;
}
/**
* Register a menu item for the jetpack-scan page.
*
* @param string $id Menu item ID.
* @param string $label Display label.
* @param string $to Route path to navigate to.
* @param string $parent_id Optional. Parent menu item ID.
* @param string $parent_type Optional. Parent type: 'drilldown' or 'dropdown'.
*/
function jetpack_scan_register_jetpack_scan_menu_item( $id, $label, $to, $parent_id = '', $parent_type = '' ) {
global $jetpack_scan_jetpack_scan_menu_items;
$menu_item = array(
'id' => $id,
'label' => $label,
'to' => $to,
);
if ( ! empty( $parent_id ) ) {
$menu_item['parent'] = $parent_id;
}
if ( ! empty( $parent_type ) && in_array( $parent_type, array( 'drilldown', 'dropdown' ), true ) ) {
$menu_item['parent_type'] = $parent_type;
}
$jetpack_scan_jetpack_scan_menu_items[] = $menu_item;
}
/**
* Get all registered routes for the jetpack-scan page.
*
* @return array Array of route objects.
*/
function jetpack_scan_get_jetpack_scan_routes() {
global $jetpack_scan_jetpack_scan_routes;
return $jetpack_scan_jetpack_scan_routes ?? array();
}
/**
* Get all registered menu items for the jetpack-scan page.
*
* @return array Array of menu item objects.
*/
function jetpack_scan_get_jetpack_scan_menu_items() {
global $jetpack_scan_jetpack_scan_menu_items;
return $jetpack_scan_jetpack_scan_menu_items ?? array();
}
/**
* Preload REST API data for the jetpack-scan page.
* Automatically called during page rendering.
*/
function jetpack_scan_jetpack_scan_preload_data() {
// Define paths to preload - same for all pages
// Please also change packages/core-data/src/entities.js when changing this.
$preload_paths = array(
'/?_fields=description,gmt_offset,home,image_sizes,image_size_threshold,name,site_icon,site_icon_url,site_logo,timezone_string,url,page_for_posts,page_on_front,show_on_front',
array( '/wp/v2/settings', 'OPTIONS' ),
);
// Use rest_preload_api_request to gather the preloaded data
$preload_data = array_reduce(
$preload_paths,
'rest_preload_api_request',
array()
);
// Register the preloading middleware with wp-api-fetch
wp_add_inline_script(
'wp-api-fetch',
sprintf(
'wp.apiFetch.use( wp.apiFetch.createPreloadingMiddleware( %s ) );',
wp_json_encode( $preload_data )
),
'after'
);
}
/**
* Render the jetpack-scan page.
* Call this function from add_menu_page or add_submenu_page.
*/
function jetpack_scan_jetpack_scan_render_page() {
// Load build constants
$build_constants = require __DIR__ . '/../../constants.php';
// Set current screen
set_current_screen();
// Remove unwanted deprecated handler
remove_action( 'admin_head', 'wp_admin_bar_header' );
// Remove unwanted scripts and styles that were enqueued during `admin_init`
foreach ( wp_scripts()->queue as $script ) {
wp_dequeue_script( $script );
}
foreach ( wp_styles()->queue as $style ) {
wp_dequeue_style( $style );
}
/**
* Fires when the jetpack-scan page is initialized so extensions can register routes and menu items.
*/
do_action( 'jetpack-scan_init' );
// Enqueue command palette assets for boot-based pages
if ( function_exists( 'wp_enqueue_command_palette_assets' ) ) {
wp_enqueue_command_palette_assets();
}
// Preload REST API data
jetpack_scan_jetpack_scan_preload_data();
// Get all registered routes and menu items
$menu_items = jetpack_scan_get_jetpack_scan_menu_items();
$routes = jetpack_scan_get_jetpack_scan_routes();
// Get boot module asset file for dependencies
$asset_file = __DIR__ . '/../../modules/boot/index.min.asset.php';
if ( file_exists( $asset_file ) ) {
$asset = require $asset_file;
// This script serves two purposes:
// 1. It ensures all the globals that are made available to the modules are loaded.
// 2. It initializes the boot module as an inline script.
wp_register_script( 'jetpack-scan-prerequisites', '', $asset['dependencies'], $asset['version'], true );
// Add inline script to initialize the app
$init_modules = [];
wp_add_inline_script(
'jetpack-scan-prerequisites',
sprintf(
'import("@wordpress/boot").then(mod => mod.init({mountId: "%s", menuItems: %s, routes: %s, initModules: %s, dashboardLink: "%s"}));',
'jetpack-scan-app',
wp_json_encode( $menu_items, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ),
wp_json_encode( $routes, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ),
wp_json_encode( $init_modules, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ),
esc_url( admin_url( '/' ) )
)
);
// Register prerequisites style by filtering script dependencies to find registered styles
$style_dependencies = array_filter(
$asset['dependencies'],
function ( $handle ) {
return wp_style_is( $handle, 'registered' );
}
);
wp_register_style( 'jetpack-scan-prerequisites', false, $style_dependencies, $asset['version'] );
// Build dependencies for jetpack-scan module
$boot_dependencies = array(
array(
'import' => 'static',
'id' => '@wordpress/boot',
),
);
// Add init modules as static dependencies
// No init modules configured
// Add all registered routes as dependencies
foreach ( $routes as $route ) {
if ( isset( $route['route_module'] ) ) {
$boot_dependencies[] = array(
'import' => 'static',
'id' => $route['route_module'],
);
}
if ( isset( $route['content_module'] ) ) {
$boot_dependencies[] = array(
'import' => 'dynamic',
'id' => $route['content_module'],
);
}
}
/**
* Filters the boot script-module dependencies for the
* jetpack-scan page.
*
* Surfaces extending this page can append entries to the boot
* dependency list. Each entry is an array with 'import' (string
* 'static' or 'dynamic') and 'id' (script-module handle) keys.
*
* @param array $boot_dependencies Boot dependencies for the page.
*/
$boot_dependencies = apply_filters(
'jetpack-scan_boot_dependencies',
$boot_dependencies
);
// Dummy script module to ensure dependencies are loaded
wp_register_script_module(
'jetpack-scan',
$build_constants['build_url'] . 'pages/jetpack-scan/loader.js',
$boot_dependencies
);
// Enqueue the boot scripts and styles
wp_enqueue_script( 'jetpack-scan-prerequisites' );
wp_enqueue_script_module( 'jetpack-scan' );
wp_enqueue_style( 'jetpack-scan-prerequisites' );
}
// Output the HTML
?>
<!DOCTYPE html>
<html <?php language_attributes(); ?>>
<head>
<meta charset="<?php bloginfo( 'charset' ); ?>">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?php echo esc_html( get_admin_page_title() ); ?></title>
<style>
html {
background: #f1f1f1;
color: #444;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
font-size: 13px;
line-height: 1.4em;
}
body {
margin: 0;
}
#wpadminbar { display: none; }
</style>
<?php
global $hook_suffix;
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
$hook_suffix = 'jetpack-scan';
// BEGIN see wp-admin/admin-header.php
print_admin_styles();
print_head_scripts();
/** This action is documented in wp-admin/admin-header.php */
do_action( "admin_head-{$hook_suffix}" ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores
/** This action is documented in wp-admin/admin-header.php */
do_action( 'admin_head' );
// END see wp-admin/admin-header.php
?>
</head>
<body class="jetpack-scan">
<div id="jetpack-scan-app" style="height: 100vh; box-sizing: border-box;"></div>
<?php
// BEGIN see wp-admin/admin-footer.php
/** This action is documented in wp-admin/admin-footer.php */
do_action( 'admin_footer', '' );
// Print import map first so it's available for inline scripts
wp_script_modules()->print_import_map();
print_footer_scripts();
wp_script_modules()->print_enqueued_script_modules();
wp_script_modules()->print_script_module_preloads();
wp_script_modules()->print_script_module_data();
/** This action is documented in wp-admin/admin-footer.php */
do_action( "admin_footer-{$hook_suffix}" ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores
// END see wp-admin/admin-footer.php
?>
</body>
</html>
<?php
exit;
}
/**
* Intercept admin_init to render the page early.
* This bypasses the default WordPress admin template.
*/
function jetpack_scan_jetpack_scan_intercept_render() {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( isset( $_GET['page'] ) && 'jetpack-scan' === $_GET['page'] ) {
jetpack_scan_jetpack_scan_render_page();
exit;
}
}
// Hook the interceptor to admin_init
add_action( 'admin_init', 'jetpack_scan_jetpack_scan_intercept_render' );
@@ -0,0 +1,113 @@
<?php
/**
* Routes registration - Auto-generated by build process.
* Registers all routes on their respective page init hooks.
* Do not edit this file manually.
*
* @package jetpack_scan
*/
// Load routes registry
$routes_file = __DIR__ . '/routes/registry.php';
if ( ! file_exists( $routes_file ) ) {
return;
}
$routes = require $routes_file;
// Group routes by page and store in globals for page-specific functions
$routes_by_page = array();
foreach ( $routes as $route ) {
$page_slug = $route['page'];
if ( ! isset( $routes_by_page[ $page_slug ] ) ) {
$routes_by_page[ $page_slug ] = array();
}
$routes_by_page[ $page_slug ][] = $route;
}
// Store routes data in globals for each page
foreach ( $routes_by_page as $page_slug => $page_routes ) {
$page_slug_underscore = str_replace( '-', '_', $page_slug );
$global_name = 'jetpack_scan_' . $page_slug_underscore . '_routes_data';
$GLOBALS[ $global_name ] = $page_routes;
}
/**
* Generic helper function to register routes for a page.
*
* @param array $page_routes Array of route data for the page.
* @param string $register_function_name Name of the function to call for registering each route.
*/
function jetpack_scan_register_page_routes( $page_routes, $register_function_name ) {
// Load build constants
$build_constants = require __DIR__ . '/constants.php';
foreach ( $page_routes as $route ) {
$content_handle = null;
$route_handle = null;
// Register content module if exists
if ( $route['has_content'] ) {
$content_asset_path = __DIR__ . "/routes/{$route['name']}/content.min.asset.php";
if ( file_exists( $content_asset_path ) ) {
$content_asset = require $content_asset_path;
$content_handle = 'jetpack-scan/routes/' . $route['name'] . '/content';
$extension = '.min.js';
// Deregister first to override any previously registered version
// (e.g., Core's default modules when running as a plugin).
wp_deregister_script_module( $content_handle );
wp_register_script_module(
$content_handle,
$build_constants['build_url'] . 'routes/' . $route['name'] . '/content' . $extension,
$content_asset['module_dependencies'] ?? array(),
$content_asset['version'] ?? false
);
}
}
// Register route module if exists
if ( $route['has_route'] ) {
$route_asset_path = __DIR__ . "/routes/{$route['name']}/route.min.asset.php";
if ( file_exists( $route_asset_path ) ) {
$route_asset = require $route_asset_path;
$route_handle = 'jetpack-scan/routes/' . $route['name'] . '/route';
$extension = '.min.js';
// Deregister first to override any previously registered version
// (e.g., Core's default modules when running as a plugin).
wp_deregister_script_module( $route_handle );
wp_register_script_module(
$route_handle,
$build_constants['build_url'] . 'routes/' . $route['name'] . '/route' . $extension,
$route_asset['module_dependencies'] ?? array(),
$route_asset['version'] ?? false
);
}
}
// Register route with page
if ( function_exists( $register_function_name ) ) {
call_user_func( $register_function_name, $route['path'], $content_handle, $route_handle );
}
}
}
// Page-specific route registration functions
// Page-specific route registration functions for jetpack-scan
/**
* Register routes for jetpack-scan page (full-page mode).
*/
function jetpack_scan_register_jetpack_scan_page_routes() {
global $jetpack_scan_jetpack_scan_routes_data;
jetpack_scan_register_page_routes( $jetpack_scan_jetpack_scan_routes_data, 'jetpack_scan_register_jetpack_scan_route' );
}
add_action( 'jetpack-scan_init', 'jetpack_scan_register_jetpack_scan_page_routes' );
/**
* Register routes for jetpack-scan page (wp-admin mode).
*/
function jetpack_scan_register_jetpack_scan_wp_admin_page_routes() {
global $jetpack_scan_jetpack_scan_routes_data;
jetpack_scan_register_page_routes( $jetpack_scan_jetpack_scan_routes_data, 'jetpack_scan_register_jetpack_scan_wp_admin_route' );
}
add_action( 'jetpack-scan-wp-admin_init', 'jetpack_scan_register_jetpack_scan_wp_admin_page_routes' );
@@ -0,0 +1 @@
<?php return array('dependencies' => array('react', 'react-dom', 'react-jsx-runtime', 'wp-api-fetch', 'wp-components', 'wp-compose', 'wp-data', 'wp-date', 'wp-deprecated', 'wp-element', 'wp-i18n', 'wp-keycodes', 'wp-notices', 'wp-primitives', 'wp-private-apis', 'wp-theme', 'wp-url', 'wp-warning'), 'module_dependencies' => array(array('id' => '@wordpress/a11y', 'import' => 'static'), array('id' => '@wordpress/route', 'import' => 'static')), 'version' => '63b5fff8850fad3669d1');
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
<?php return array('dependencies' => array(), 'version' => '051fa939bb02df0ad906');
@@ -0,0 +1 @@
var e={inspector:()=>!1};export{e as route};
@@ -0,0 +1,17 @@
<?php
/**
* Route registry - Auto-generated by build process.
* Do not edit this file manually.
*
* @package jetpack_scan
*/
return array(
array(
'name' => 'index',
'path' => '/',
'page' => 'jetpack-scan',
'has_route' => true,
'has_content' => true,
)
);
@@ -0,0 +1,85 @@
<?php
/**
* Script registration - Auto-generated by build process.
* Do not edit this file manually.
*
* @package jetpack_scan
*/
/**
* Registers a script according to `wp_register_script`. Honors this request by
* reassigning internal dependency properties of any script handle already
* registered by that name. It does not deregister the original script, to
* avoid losing inline scripts which may have been attached.
*
* @param WP_Scripts $scripts WP_Scripts instance.
* @param string $handle Name of the script. Should be unique.
* @param string $src Full URL of the script, or path of the script relative to the WordPress root directory.
* @param array $deps Optional. An array of registered script handles this script depends on. Default empty array.
* @param string|bool|null $ver Optional. String specifying script version number, if it has one, which is added to the URL
* as a query string for cache busting purposes. If version is set to false, a version
* number is automatically added equal to current installed WordPress version.
* If set to null, no version is added.
* @param bool $in_footer Optional. Whether to enqueue the script before </body> instead of in the <head>.
* Default 'false'.
*/
function jetpack_scan_override_script( $scripts, $handle, $src, $deps = array(), $ver = false, $in_footer = false ) {
$script = $scripts->query( $handle, 'registered' );
if ( $script ) {
/*
* In many ways, this is a reimplementation of `wp_register_script` but
* bypassing consideration of whether a script by the given handle had
* already been registered.
*/
// See: `_WP_Dependency::__construct` .
$script->src = $src;
$script->deps = $deps;
$script->ver = $ver;
$script->args = $in_footer ? 1 : null;
} else {
$scripts->add( $handle, $src, $deps, $ver, ( $in_footer ? 1 : null ) );
}
if ( in_array( 'wp-i18n', $deps, true ) ) {
$scripts->set_translations( $handle );
}
}
/**
* Register all package scripts.
*/
function jetpack_scan_register_package_scripts( $scripts ) {
// Load build constants
$build_constants = require __DIR__ . '/constants.php';
$default_version = ! SCRIPT_DEBUG ? $build_constants['version'] : time();
$extension = '.min.js';
$scripts_dir = __DIR__ . '/scripts';
$scripts_file = $scripts_dir . '/registry.php';
if ( ! file_exists( $scripts_file ) ) {
return;
}
$scripts_data = require $scripts_file;
$plugin_dir = dirname( __FILE__ );
foreach ( $scripts_data as $script_data ) {
$asset_file = $scripts_dir . '/' . $script_data['asset'];
$asset = file_exists( $asset_file ) ? require $asset_file : array();
$dependencies = $asset['dependencies'] ?? array();
$version = $asset['version'] ?? $default_version;
jetpack_scan_override_script(
$scripts,
$script_data['handle'],
$build_constants['build_url'] . 'scripts/' . $script_data['path'] . $extension,
$dependencies,
$version,
true
);
}
}
add_action( 'wp_default_scripts', 'jetpack_scan_register_package_scripts' );
@@ -0,0 +1,11 @@
<?php
/**
* Script registry - Auto-generated by build process.
* Do not edit this file manually.
*
* @package jetpack_scan
*/
return array(
);
@@ -0,0 +1,73 @@
<?php
/**
* Style registration - Auto-generated by build process.
* Do not edit this file manually.
*
* Note: These styles can be overridden by calling jetpack_scan_override_style()
* in lib/client-assets.php for complex cases (multiple files, conditional deps, etc.)
*
* @package jetpack_scan
*/
/**
* Registers a style according to `wp_register_style`. Honors this request by
* deregistering any style by the same handler before registration.
*
* @param WP_Styles $styles WP_Styles instance.
* @param string $handle Name of the stylesheet. Should be unique.
* @param string $src Full URL of the stylesheet, or path of the stylesheet relative to the WordPress root directory.
* @param array $deps Optional. An array of registered stylesheet handles this stylesheet depends on. Default empty array.
* @param string|bool|null $ver Optional. String specifying stylesheet version number, if it has one, which is added to the URL
* as a query string for cache busting purposes. If version is set to false, a version
* number is automatically added equal to current installed WordPress version.
* If set to null, no version is added.
* @param string $media Optional. The media for which this stylesheet has been defined.
* Default 'all'. Accepts media types like 'all', 'print' and 'screen', or media queries like
* '(orientation: portrait)' and '(max-width: 640px)'.
*/
function jetpack_scan_override_style( $styles, $handle, $src, $deps = array(), $ver = false, $media = 'all' ) {
$style = $styles->query( $handle, 'registered' );
if ( $style ) {
$styles->remove( $handle );
}
$styles->add( $handle, $src, $deps, $ver, $media );
}
/**
* Register all package styles (simple cases only).
* Complex cases should be manually registered in lib/client-assets.php.
*
* @param WP_Styles $styles WP_Styles instance.
*/
function jetpack_scan_register_package_styles( $styles ) {
// Load build constants
$build_constants = require __DIR__ . '/constants.php';
$default_version = ! SCRIPT_DEBUG ? $build_constants['version'] : time();
$suffix = '.min';
$styles_dir = __DIR__ . '/styles';
$styles_file = $styles_dir . '/registry.php';
if ( ! file_exists( $styles_file ) ) {
return;
}
$styles_data = require $styles_file;
$plugin_dir = dirname( __FILE__ );
foreach ( $styles_data as $style_data ) {
jetpack_scan_override_style(
$styles,
$style_data['handle'],
$build_constants['build_url'] . 'styles/' . $style_data['path'] . $suffix . '.css',
$style_data['dependencies'],
$default_version
);
// Enable RTL support (WordPress automatically loads -rtl.css variant)
$styles->add_data( $style_data['handle'], 'rtl', 'replace' );
$styles->add_data( $style_data['handle'], 'suffix', $suffix );
}
}
add_action( 'wp_default_styles', 'jetpack_scan_register_package_styles' );
@@ -0,0 +1,11 @@
<?php
/**
* Style registry - Auto-generated by build process.
* Do not edit this file manually.
*
* @package jetpack_scan
*/
return array(
);
@@ -0,0 +1,69 @@
<?php
/**
* The Scan React initial state.
*
* @package automattic/jetpack-scan-page
*/
namespace Automattic\Jetpack\Scan_Page;
use Automattic\Jetpack\Status;
use Jetpack_Options;
use function admin_url;
use function esc_url_raw;
use function get_bloginfo;
use function get_locale;
use function get_option;
use function get_site_url;
use function plugins_url;
use function rest_url;
use function wp_create_nonce;
use function wp_json_encode;
use function wp_parse_url;
/**
* The Scan React initial state.
*/
class Initial_State {
/**
* Get the initial state data.
*
* @return array
*/
private function get_data() {
$gmt_offset = get_option( 'gmt_offset' );
$timezone_string = get_option( 'timezone_string' );
$home_host = wp_parse_url( get_site_url(), PHP_URL_HOST );
return array(
'API' => array(
'WP_API_root' => esc_url_raw( rest_url() ),
'WP_API_nonce' => wp_create_nonce( 'wp_rest' ),
),
'jetpackStatus' => array(
'calypsoSlug' => ( new Status() )->get_site_suffix(),
),
'siteData' => array(
'id' => Jetpack_Options::get_option( 'id' ),
'title' => get_bloginfo( 'name' ) ? get_bloginfo( 'name' ) : get_site_url(),
'adminUrl' => esc_url_raw( admin_url() ),
'slug' => is_string( $home_host ) ? $home_host : '',
'gmtOffset' => is_numeric( $gmt_offset ) ? (float) $gmt_offset : 0.0,
'timezoneString' => is_string( $timezone_string ) ? $timezone_string : '',
'locale' => str_replace( '_', '-', (string) get_locale() ),
),
'assets' => array(
'buildUrl' => plugins_url( '../build/', __FILE__ ),
),
);
}
/**
* Render the initial state into a JavaScript variable.
*
* @return string
*/
public function render() {
return 'var JPSCAN_INITIAL_STATE=' . wp_json_encode( $this->get_data(), JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ) . ';';
}
}
@@ -0,0 +1,329 @@
<?php
/**
* Primary class for the Jetpack Scan package.
*
* @package automattic/jetpack-scan-page
*/
namespace Automattic\Jetpack\Scan_Page;
if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}
use Automattic\Jetpack\Admin_UI\Admin_Menu;
use Automattic\Jetpack\Connection\Manager as Connection_Manager;
use Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills;
use function add_action;
use function add_filter;
use function apply_filters;
use function call_user_func;
use function current_user_can;
use function did_action;
use function do_action;
use function function_exists;
use function is_admin;
use function is_multisite;
use function remove_action;
use function remove_all_actions;
use function sanitize_text_field;
use function wp_print_inline_script_tag;
use function wp_scripts;
use function wp_unslash;
/**
* Class Jetpack_Scan
*
* Registers the Scan admin page and its REST routes inside the main
* Jetpack plugin. The page bundle is built by `@wordpress/build`
* (mirroring Newsletter / Forms); this class wires the wp-admin menu
* + the bridges that route our user-facing slug to wp-build's
* auto-generated enqueue / render functions.
*/
class Jetpack_Scan {
/**
* URL-facing menu slug.
*
* @var string
*/
const PAGE_SLUG = 'jetpack-scan';
/**
* Internal slug emitted by `@wordpress/build` (`wpPlugin.pages[0]`
* plus the `-wp-admin` suffix the build template appends). Used to
* find the auto-generated render / enqueue functions.
*
* @var string
*/
const WP_BUILD_SLUG = 'jetpack-scan-wp-admin';
/**
* Filter name that gates the wp-buildbased Scan dashboard.
*
* When this filter returns true, the new wp-admin Scan page is
* registered and rendered. Default false during the modernization
* roll-out — the package registers no admin menu and changes
* nothing about the existing Jetpack UI when this filter is off.
*
* @var string
*/
const MODERNIZATION_FILTER = 'rsm_jetpack_ui_modernization_scan';
/**
* Entry point. Idempotent: safe to call from multiple bootstraps.
*/
public static function initialize() {
if ( did_action( 'jetpack_scan_page_initialized' ) ) {
return;
}
if ( ! (bool) apply_filters( self::MODERNIZATION_FILTER, false ) ) {
return;
}
self::load_wp_build();
self::fix_boot_import_map_ordering();
self::bridge_wp_build_enqueue();
add_action( 'admin_menu', array( __CLASS__, 'add_wp_admin_submenu' ) );
add_action( 'rest_api_init', array( __CLASS__, 'register_rest_routes' ) );
add_filter( 'jetpack_package_versions', array( Package_Version::class, 'send_package_version_to_tracker' ) );
/**
* Fires once the Jetpack Scan package has wired its hooks.
*
* @since 0.1.0
*/
do_action( 'jetpack_scan_page_initialized' );
}
/**
* Load wp-build generated registration files. Mirrors Newsletter / Forms.
*/
public static function load_wp_build() {
// The polyfills force-replace core script handles (notably
// `wp-private-apis`, a stateful singleton shared by every @wordpress
// package on the page) during `wp_default_scripts`. Scope registration
// to the Scan admin page so it never runs on other admin pages such as
// the block editor. `$_GET['page']` is reliable this early — it's the
// raw query param, available well before `current_screen` exists.
if ( self::is_scan_admin_request() ) {
WP_Build_Polyfills::register(
'jetpack-scan',
array_merge( WP_Build_Polyfills::SCRIPT_HANDLES, WP_Build_Polyfills::MODULE_IDS )
);
}
$wp_build_index = dirname( __DIR__ ) . '/build/build.php';
if ( file_exists( $wp_build_index ) ) {
require_once $wp_build_index;
}
// `page.php` ships an `admin_init` interceptor that takes over our
// slug with a standalone (non-wp-admin) render. We want the
// wp-admin integrated experience, so unregister it as soon as it's
// loaded.
remove_action(
'admin_init',
'jetpack_scan_jetpack_scan_intercept_render'
);
}
/**
* Whether the current request targets the Scan admin page.
*
* Used to scope the wp-build polyfill registration (which force-replaces
* core script handles) to this one page, so it never affects other admin
* pages. Reads the menu page slug directly so it is cheap and safe to call
* at plugin-load time, before `current_screen` exists.
*
* @return bool True when serving the Scan page in wp-admin.
*/
public static function is_scan_admin_request() {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( ! is_admin() || ! isset( $_GET['page'] ) ) {
return false;
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
return self::PAGE_SLUG === sanitize_text_field( wp_unslash( $_GET['page'] ) );
}
/**
* Bridge wp-build's auto-generated enqueue function — which checks for
* `?page=jetpack-scan-wp-admin` — to our user-facing slug
* `?page=jetpack-scan`. Hooked at priority 9 so the wp-build copy
* (registered at priority 10) sees the original `$_GET['page']` and
* skips its own enqueue.
*/
public static function bridge_wp_build_enqueue() {
add_action(
'admin_enqueue_scripts',
static function ( $hook_suffix ) {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( ! isset( $_GET['page'] ) || self::PAGE_SLUG !== $_GET['page'] ) {
return;
}
$enqueue_fn = 'jetpack_scan_jetpack_scan_wp_admin_enqueue_scripts';
if ( ! function_exists( $enqueue_fn ) ) {
return;
}
// phpcs:disable WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
$original = isset( $_GET['page'] ) ? sanitize_text_field( wp_unslash( $_GET['page'] ) ) : null;
$_GET['page'] = self::WP_BUILD_SLUG;
// @phan-suppress-next-line PhanUndeclaredFunctionInCallable -- Function is generated by @wordpress/build into build/pages/jetpack-scan/page-wp-admin.php, which is outside Phan's analysis scope. The function_exists() guard above protects the call at runtime.
call_user_func( $enqueue_fn, $hook_suffix );
if ( null === $original ) {
unset( $_GET['page'] );
} else {
$_GET['page'] = $original;
}
// phpcs:enable WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
},
9
);
}
/**
* Fix import map ordering for the wp-build boot script.
*
* In wp-admin, `_wp_footer_scripts` (classic scripts) and
* `print_import_map` both hook into `admin_print_footer_scripts` at
* priority 10, but `_wp_footer_scripts` is registered first. This
* causes the inline `import("@wordpress/boot")` to execute before
* the import map exists.
*
* This fix moves the import() call from the classic inline script to
* a `<script type="module">` printed at priority 20 (after the import
* map).
*
* @todo Remove once @wordpress/build ships with the loader.js fix
* upstream (WordPress/gutenberg#76870) and Jetpack updates the
* dependency.
*/
public static function fix_boot_import_map_ordering() {
$handle = self::WP_BUILD_SLUG . '-prerequisites';
add_action(
'admin_enqueue_scripts',
static function () use ( $handle ) {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( ! isset( $_GET['page'] ) || self::PAGE_SLUG !== $_GET['page'] ) {
return;
}
$data = wp_scripts()->get_data( $handle, 'after' );
if ( empty( $data ) ) {
return;
}
$boot_script = null;
$remaining = array();
foreach ( $data as $line ) {
if ( strpos( $line, '@wordpress/boot' ) !== false ) {
$boot_script = $line;
} else {
$remaining[] = $line;
}
}
if ( null === $boot_script ) {
return;
}
wp_scripts()->add_data( $handle, 'after', $remaining );
add_action(
'admin_print_footer_scripts',
static function () use ( $boot_script ) {
wp_print_inline_script_tag( $boot_script, array( 'type' => 'module' ) );
},
20
);
},
PHP_INT_MAX
);
}
/**
* Register the Scan submenu under Jetpack.
*
* @return string|null The resulting page's hook suffix, if registered.
*/
public static function add_wp_admin_submenu() {
if ( ! self::is_available() ) {
return null;
}
$render_fn = 'jetpack_scan_jetpack_scan_wp_admin_render_page';
$render = function_exists( $render_fn )
? $render_fn
: array( __CLASS__, 'render_page_fallback' );
$page_suffix = Admin_Menu::add_menu(
/** "Scan" is a product name, do not translate. */
'Scan',
'Scan',
'manage_options',
self::PAGE_SLUG,
$render,
6
);
if ( $page_suffix ) {
add_action( 'load-' . $page_suffix, array( __CLASS__, 'admin_init' ) );
}
return $page_suffix;
}
/**
* Whether the Scan page should be shown to the current user.
*
* @return bool
*/
public static function is_available() {
if ( is_multisite() ) {
return false;
}
if ( ! current_user_can( 'manage_options' ) ) {
return false;
}
return ( new Connection_Manager() )->is_user_connected();
}
/**
* Fires when the admin page is loaded.
*
* Silences the standard wp-admin notice channels so JITMs and
* plugin-update messages don't reflow the focused Scan layout
* mid-scan or while a fix modal is open.
*/
public static function admin_init() {
remove_all_actions( 'admin_notices' );
remove_all_actions( 'all_admin_notices' );
}
/**
* Fallback render — used only if the wp-build registration file
* isn't loaded (e.g. the package wasn't built yet). Renders a bare
* mount node so the page doesn't 500 in dev.
*/
public static function render_page_fallback() {
?>
<div id="jetpack-scan-page-root"></div>
<?php
}
/**
* Register the REST routes backing the Scan UI.
*/
public static function register_rest_routes() {
REST_Controller::register_rest_routes();
}
}
@@ -0,0 +1,35 @@
<?php
/**
* The Package_Version class.
*
* @package automattic/jetpack-scan-page
*/
namespace Automattic\Jetpack\Scan_Page;
/**
* The Package_Version class.
*
* Does *not* use namespaced versioning ("VXXXX") because send_package_version_to_tracker() is used as a
* "jetpack_package_versions" filter, and said filter gets run during a plugin upgrade, so it always expects to
* find the "Package_Version" class with the same namespace, name, and interface.
*/
class Package_Version {
const PACKAGE_VERSION = '0.1.8';
const PACKAGE_SLUG = 'scan-page';
/**
* Adds the package slug and version to the package version tracker's data.
*
* @param array $package_versions The package version array.
*
* @return array The package version array.
*/
public static function send_package_version_to_tracker( $package_versions ) {
$package_versions[ self::PACKAGE_SLUG ] = self::PACKAGE_VERSION;
return $package_versions;
}
}
@@ -0,0 +1,460 @@
<?php
/**
* The Scan REST Controller.
*
* Registers the `/jetpack/v4/site/scan/*` routes backing the admin UI.
* Read routes proxy to the corresponding WPCOM v2 endpoint, mutation
* routes proxy to the user-scoped `wpcom/v2 /sites/:siteId/alerts/*`
* surface that the Calypso Scan dashboard already uses.
*
* @package automattic/jetpack-scan-page
*/
namespace Automattic\Jetpack\Scan_Page;
use Automattic\Jetpack\Connection\Client;
use Automattic\Jetpack\Status\Visitor;
use Jetpack_Options;
use WP_Error;
use WP_REST_Request;
use WP_REST_Server;
use function add_query_arg;
use function current_user_can;
use function esc_html__;
use function is_wp_error;
use function json_decode;
use function register_rest_route;
use function rest_ensure_response;
use function wp_json_encode;
use function wp_remote_retrieve_body;
use function wp_remote_retrieve_response_code;
/**
* REST routes for the Scan UI.
*/
class REST_Controller {
/**
* REST namespace used by this package.
*
* @var string
*/
const REST_NAMESPACE = 'jetpack/v4';
/**
* REST route prefix used by this package.
*
* @var string
*/
const REST_ROUTE_PREFIX = 'site/scan';
/**
* Register the REST routes backing the Scan UI.
*
* Read paths land in Phase 1 (this controller). Mutation paths
* (threat-id `ignore` / `unignore`, bulk `threats/fix`,
* `threats/fix-status`) land in Phase 3. The scan-enqueue mutation
* lands in Phase 5.
*/
public static function register_rest_routes() {
register_rest_route(
self::REST_NAMESPACE,
'/' . self::REST_ROUTE_PREFIX,
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( __CLASS__, 'get_site_scan' ),
'permission_callback' => array( __CLASS__, 'permissions_check' ),
)
);
register_rest_route(
self::REST_NAMESPACE,
'/' . self::REST_ROUTE_PREFIX . '/history',
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( __CLASS__, 'get_site_scan_history' ),
'permission_callback' => array( __CLASS__, 'permissions_check' ),
)
);
register_rest_route(
self::REST_NAMESPACE,
'/' . self::REST_ROUTE_PREFIX . '/counts',
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( __CLASS__, 'get_site_scan_counts' ),
'permission_callback' => array( __CLASS__, 'permissions_check' ),
)
);
register_rest_route(
self::REST_NAMESPACE,
'/' . self::REST_ROUTE_PREFIX . '/threat/(?P<id>[\w\-]+)/ignore',
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( __CLASS__, 'post_threat_ignore' ),
'permission_callback' => array( __CLASS__, 'permissions_check' ),
'args' => array(
'id' => array(
'type' => 'string',
'required' => true,
'sanitize_callback' => 'sanitize_text_field',
),
),
)
);
register_rest_route(
self::REST_NAMESPACE,
'/' . self::REST_ROUTE_PREFIX . '/threat/(?P<id>[\w\-]+)/unignore',
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( __CLASS__, 'post_threat_unignore' ),
'permission_callback' => array( __CLASS__, 'permissions_check' ),
'args' => array(
'id' => array(
'type' => 'string',
'required' => true,
'sanitize_callback' => 'sanitize_text_field',
),
),
)
);
register_rest_route(
self::REST_NAMESPACE,
'/' . self::REST_ROUTE_PREFIX . '/threats/fix',
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( __CLASS__, 'post_threats_fix' ),
'permission_callback' => array( __CLASS__, 'permissions_check' ),
'args' => array(
'threat_ids' => array(
'type' => 'array',
'required' => true,
'items' => array(
'type' => 'string',
),
),
),
)
);
register_rest_route(
self::REST_NAMESPACE,
'/' . self::REST_ROUTE_PREFIX . '/enqueue',
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( __CLASS__, 'post_scan_enqueue' ),
'permission_callback' => array( __CLASS__, 'permissions_check' ),
)
);
register_rest_route(
self::REST_NAMESPACE,
'/' . self::REST_ROUTE_PREFIX . '/threats/fix-status',
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( __CLASS__, 'get_threats_fix_status' ),
'permission_callback' => array( __CLASS__, 'permissions_check' ),
'args' => array(
'threat_ids' => array(
'type' => 'array',
'required' => true,
'items' => array(
'type' => 'string',
),
),
),
)
);
}
/**
* Permission callback: admin-only. Mirrors the gate in
* `Jetpack_Scan::is_available()`.
*
* @return bool|WP_Error
*/
public static function permissions_check() {
if ( ! current_user_can( 'manage_options' ) ) {
return new WP_Error(
'rest_forbidden',
esc_html__( 'You do not have permission to access this resource.', 'jetpack-scan-page' ),
array( 'status' => 401 )
);
}
return true;
}
/**
* GET /site/scan — current scan state + active threats.
*
* Proxies WPCOM `/sites/:siteId/scan` with blog auth (matches Protect
* plugin's `Threats::fetch_status()`).
*
* @return \WP_REST_Response|WP_Error
*/
public static function get_site_scan() {
return self::proxy_get( '/scan', 'scan', true );
}
/**
* GET /site/scan/history — past scan runs and their threats.
*
* Proxies WPCOM `/sites/:siteId/scan/history` with blog auth (matches
* Protect plugin's `Threats::history()`).
*
* @return \WP_REST_Response|WP_Error
*/
public static function get_site_scan_history() {
return self::proxy_get( '/scan/history', 'scan_history', true );
}
/**
* GET /site/scan/counts — threat counts for the overview tabs.
*
* Proxies WPCOM `/sites/:siteId/scan/counts` with blog auth.
*
* @return \WP_REST_Response|WP_Error
*/
public static function get_site_scan_counts() {
return self::proxy_get( '/scan/counts', 'scan_counts', true );
}
/**
* POST /site/scan/threat/{id}/ignore — mark a threat as ignored.
*
* Proxies WPCOM `POST /sites/:siteId/alerts/:threatId` with
* `{ ignore: true }`. Same shape Protect plugin's
* `Threats::ignore_threat()` already uses.
*
* @param WP_REST_Request $request The REST request.
* @return \WP_REST_Response|WP_Error
*/
public static function post_threat_ignore( WP_REST_Request $request ) {
$threat_id = (string) $request->get_param( 'id' );
return self::proxy_post(
sprintf( '/alerts/%s', rawurlencode( $threat_id ) ),
array( 'ignore' => true ),
'scan_threat_ignore'
);
}
/**
* POST /site/scan/threat/{id}/unignore — reactivate a previously-ignored
* threat.
*
* Proxies WPCOM `POST /sites/:siteId/alerts/:threatId` with
* `{ unignore: true }`.
*
* @param WP_REST_Request $request The REST request.
* @return \WP_REST_Response|WP_Error
*/
public static function post_threat_unignore( WP_REST_Request $request ) {
$threat_id = (string) $request->get_param( 'id' );
return self::proxy_post(
sprintf( '/alerts/%s', rawurlencode( $threat_id ) ),
array( 'unignore' => true ),
'scan_threat_unignore'
);
}
/**
* POST /site/scan/threats/fix — kick auto-fix for one or more threats.
*
* Proxies WPCOM `POST /sites/:siteId/alerts/fix` with
* `{ threat_ids: [...] }`. Same endpoint handles single + bulk fix.
*
* @param WP_REST_Request $request The REST request.
* @return \WP_REST_Response|WP_Error
*/
public static function post_threats_fix( WP_REST_Request $request ) {
$ids = (array) $request->get_param( 'threat_ids' );
$ids = array_values( array_filter( array_map( 'strval', $ids ) ) );
return self::proxy_post(
'/alerts/fix',
array( 'threat_ids' => $ids ),
'scan_threats_fix'
);
}
/**
* POST /site/scan/enqueue — trigger an immediate scan run.
*
* Proxies WPCOM `POST /sites/:siteId/scan/enqueue` with blog auth
* (matches Protect plugin's `Threats::scan()`).
*
* @return \WP_REST_Response|WP_Error
*/
public static function post_scan_enqueue() {
return self::proxy_post( '/scan/enqueue', array(), 'scan_enqueue', true );
}
/**
* GET /site/scan/threats/fix-status — poll the auto-fixer for the
* current state of one or more threats.
*
* Proxies WPCOM `GET /sites/:siteId/alerts/fix?threat_ids[]=…`. Body
* shape mirrors `post_threats_fix` so the UI hook can poll until each
* threat reaches a terminal state.
*
* @param WP_REST_Request $request The REST request.
* @return \WP_REST_Response|WP_Error
*/
public static function get_threats_fix_status( WP_REST_Request $request ) {
$ids = (array) $request->get_param( 'threat_ids' );
$ids = array_values( array_filter( array_map( 'strval', $ids ) ) );
$path = add_query_arg( array( 'threat_ids' => $ids ), '/alerts/fix' );
return self::proxy_get( $path, 'scan_threats_fix_status' );
}
/**
* Proxy a GET request to the WPCOM v2 Scan endpoint and pass the JSON
* body through (or surface a WP_Error mapping the upstream status
* code).
*
* Site-level reads (`/scan`, `/scan/history`, `/scan/counts`) sign
* with blog auth — the same contract Protect plugin's `Threats::*`
* helpers use, and what WPCOM expects for these endpoints. Alert /
* fix-status endpoints stay on user auth so per-user permissions on
* threat mutations carry through.
*
* Forwarding the visitor IP keeps WPCOM-side audit logs aligned with
* the existing `/jetpack/v4/site/activity` proxy in `activity-log`.
*
* @param string $upstream_path WPCOM path suffix (e.g. `/scan`, `/alerts/fix`).
* @param string $error_slug Slug used when synthesising WP_Error codes.
* @param bool $as_blog Sign with blog auth instead of user auth.
* @return \WP_REST_Response|WP_Error
*/
private static function proxy_get( $upstream_path, $error_slug, $as_blog = false ) {
$path = self::resolve_blog_path( $upstream_path );
if ( is_wp_error( $path ) ) {
return $path;
}
$args = array(
'method' => 'GET',
'headers' => array(
'X-Forwarded-For' => ( new Visitor() )->get_ip( true ),
),
);
$response = $as_blog
? Client::wpcom_json_api_request_as_blog(
$path,
'2',
$args,
null,
'wpcom'
)
: Client::wpcom_json_api_request_as_user(
$path,
'2',
$args,
null,
'wpcom'
);
return self::map_response( $response, $error_slug );
}
/**
* Proxy a POST request to the user-scoped WPCOM v2 Scan endpoint
* with a JSON body and pass the response through (or surface a
* WP_Error mapping the upstream status code).
*
* @param string $upstream_path WPCOM path suffix (e.g. `/alerts/fix`).
* @param array $body Body payload sent as JSON.
* @param string $error_slug Slug used when synthesising WP_Error codes.
* @param bool $as_blog Sign with blog auth instead of user auth.
* @return \WP_REST_Response|WP_Error
*/
private static function proxy_post( $upstream_path, array $body, $error_slug, $as_blog = false ) {
$path = self::resolve_blog_path( $upstream_path );
if ( is_wp_error( $path ) ) {
return $path;
}
$args = array(
'method' => 'POST',
'headers' => array(
'Content-Type' => 'application/json',
'X-Forwarded-For' => ( new Visitor() )->get_ip( true ),
),
);
$encoded_body = wp_json_encode( $body, JSON_UNESCAPED_SLASHES );
$response = $as_blog
? Client::wpcom_json_api_request_as_blog(
$path,
'2',
$args,
$encoded_body,
'wpcom'
)
: Client::wpcom_json_api_request_as_user(
$path,
'2',
$args,
$encoded_body,
'wpcom'
);
return self::map_response( $response, $error_slug );
}
/**
* Resolve the connected blog id and prefix it onto the WPCOM path
* suffix. Surfaces a 400 WP_Error if the site isn't connected.
*
* @param string $upstream_path WPCOM path suffix.
* @return string|WP_Error
*/
private static function resolve_blog_path( $upstream_path ) {
$blog_id = (int) Jetpack_Options::get_option( 'id' );
if ( $blog_id <= 0 ) {
return new WP_Error(
'jetpack_scan_no_blog_id',
esc_html__( 'Site is not connected to WordPress.com.', 'jetpack-scan-page' ),
array( 'status' => 400 )
);
}
return sprintf( '/sites/%d%s', $blog_id, $upstream_path );
}
/**
* Translate a WPCOM HTTP response into a `WP_REST_Response` /
* `WP_Error` for the local `/jetpack/v4/*` route to return.
*
* @param array|WP_Error $response Result of `wpcom_json_api_request_as_user`.
* @param string $error_slug Slug used when synthesising WP_Error codes.
* @return \WP_REST_Response|WP_Error
*/
private static function map_response( $response, $error_slug ) {
if ( is_wp_error( $response ) ) {
return new WP_Error(
'jetpack_' . $error_slug . '_request_failed',
$response->get_error_message(),
array( 'status' => 500 )
);
}
$status = (int) wp_remote_retrieve_response_code( $response );
$body = json_decode( wp_remote_retrieve_body( $response ), true );
if ( $status < 200 || $status >= 300 ) {
return new WP_Error(
'jetpack_' . $error_slug . '_request_failed',
isset( $body['message'] ) ? (string) $body['message'] : esc_html__( 'Unable to fetch Scan data.', 'jetpack-scan-page' ),
array( 'status' => $status > 0 ? $status : 500 )
);
}
return rest_ensure_response( $body );
}
}