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,79 @@
# 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.9] - 2026-06-29
### Changed
- Internal updates.
## [0.1.8] - 2026-06-25
### Changed
- Update package dependencies. [#49831]
## [0.1.7] - 2026-06-22
### Changed
- Update package dependencies. [#49631] [#49691] [#49757]
## [0.1.6] - 2026-06-15
### Changed
- Update package dependencies. [#49273] [#49492]
## [0.1.5] - 2026-06-08
### Changed
- Update dependencies. [#49354]
## [0.1.4] - 2026-06-01
### Added
- Notices: Add notice when the connection state prevents logs from showing. [#48858]
### Changed
- Update package dependencies. [#48404]
## [0.1.3] - 2026-05-25
### Changed
- Update package dependencies. [#48405]
### Fixed
- Fix upgrade prompt incorrectly shown to sites entitled to the full activity log. [#49067]
## [0.1.2] - 2026-05-19
### Added
- Add a "Performed by" filter for narrowing the log by actor, including MCP agents. Filtering is applied server-side so totals and pagination stay correct. [#48594]
### Changed
- Update dependencies. [#48778]
## [0.1.1] - 2026-05-11
### Changed
- Activity Log: Open the Jetpack Cloud Backup restore flow from the "Manage backup" row action instead of showing a disabled placeholder. [#48531]
- Activity Log: Refresh the free-tier upsell illustration to match Jetpack's branding. [#48531]
- Activity Log: Rename the row action to "Restore backup" so the label matches what clicking it actually does. [#48531]
- Components: Use Link from `@wordpress/ui` instead of ExternalLink. [#48529]
## [0.1.0] - 2026-05-04
### Added
- Initial release of the Activity Log package: Hosts the Activity Log UI and its REST endpoints directly in WP Admin. [#48244]
### Changed
- Activity Log: Opt into `<AdminPage unwrapped>` so DataViews can fill the bounded content slot and scroll its table body internally. Header, date picker, and DataViews toolbar stay pinned on short viewports. [#48244]
### Fixed
- Activity Log: Default the page to the Table layout, load the upsell-callout stylesheet from the main entry, and surface the disabled toolbar + disabled date-range picker on the free tier with upgrade tooltips. [#48418]
## 0.1.0-alpha - unreleased
Initial release.
[0.1.9]: https://github.com/Automattic/jetpack-activity-log/compare/v0.1.8...v0.1.9
[0.1.8]: https://github.com/Automattic/jetpack-activity-log/compare/v0.1.7...v0.1.8
[0.1.7]: https://github.com/Automattic/jetpack-activity-log/compare/v0.1.6...v0.1.7
[0.1.6]: https://github.com/Automattic/jetpack-activity-log/compare/v0.1.5...v0.1.6
[0.1.5]: https://github.com/Automattic/jetpack-activity-log/compare/v0.1.4...v0.1.5
[0.1.4]: https://github.com/Automattic/jetpack-activity-log/compare/v0.1.3...v0.1.4
[0.1.3]: https://github.com/Automattic/jetpack-activity-log/compare/v0.1.2...v0.1.3
[0.1.2]: https://github.com/Automattic/jetpack-activity-log/compare/v0.1.1...v0.1.2
[0.1.1]: https://github.com/Automattic/jetpack-activity-log/compare/v0.1.0...v0.1.1
[0.1.0]: https://github.com/Automattic/jetpack-activity-log/compare/v0.1.0-alpha...v0.1.0
@@ -0,0 +1,45 @@
<svg width="399" height="244" viewBox="0 0 399 244" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="399" height="244" fill="#003010"/>
<path d="M181.201 141.678C191.745 152.222 208.841 152.222 219.385 141.678C229.929 131.134 229.929 114.038 219.385 103.494C208.841 92.9499 191.745 92.9499 181.201 103.494C170.657 114.038 170.657 131.134 181.201 141.678Z" stroke="url(#paint0_linear_6208_69986)"/>
<path d="M172.716 150.163C187.946 165.394 212.64 165.394 227.87 150.163C243.101 134.933 243.101 110.239 227.87 95.0088C212.64 79.7784 187.946 79.7784 172.716 95.0088C157.485 110.239 157.485 134.933 172.716 150.163Z" stroke="url(#paint1_linear_6208_69986)"/>
<path d="M164.231 158.648C184.147 178.565 216.439 178.565 236.355 158.648C256.272 138.732 256.272 106.44 236.355 86.5236C216.439 66.6068 184.147 66.6068 164.231 86.5236C144.314 106.44 144.314 138.732 164.231 158.648Z" stroke="url(#paint2_linear_6208_69986)"/>
<path d="M155.745 167.134C180.348 191.737 220.238 191.737 244.841 167.134C269.444 142.531 269.444 102.641 244.841 78.0383C220.238 53.4352 180.348 53.4352 155.745 78.0383C131.142 102.641 131.142 142.531 155.745 167.134Z" stroke="url(#paint3_linear_6208_69986)"/>
<path d="M147.26 175.619C176.549 204.908 224.037 204.908 253.326 175.619C282.615 146.33 282.615 98.8423 253.326 69.553C224.037 40.2636 176.549 40.2636 147.26 69.553C117.971 98.8423 117.971 146.33 147.26 175.619Z" stroke="url(#paint4_linear_6208_69986)"/>
<path d="M200 123V46.053C200 46.053 216.757 44.923 234.595 53.0974C253.514 61.7673 259.948 70.5405 268.649 83.4428C278.072 97.4179 280 123 280 123H200Z" fill="url(#paint5_linear_6208_69986)"/>
<path d="M303.5 18.5V225.5H96.5V18.5H303.5Z" stroke="#069E08"/>
<path d="M303 24.25C305.347 24.25 307.25 22.3472 307.25 20C307.25 17.6528 305.347 15.75 303 15.75C300.653 15.75 298.75 17.6528 298.75 20C298.75 22.3472 300.653 24.25 303 24.25Z" fill="#003010" stroke="#069E08" stroke-width="1.5"/>
<path d="M303 230.25C305.347 230.25 307.25 228.347 307.25 226C307.25 223.653 305.347 221.75 303 221.75C300.653 221.75 298.75 223.653 298.75 226C298.75 228.347 300.653 230.25 303 230.25Z" fill="#003010" stroke="#069E08" stroke-width="1.5"/>
<path d="M97 23.25C99.3472 23.25 101.25 21.3472 101.25 19C101.25 16.6528 99.3472 14.75 97 14.75C94.6528 14.75 92.75 16.6528 92.75 19C92.75 21.3472 94.6528 23.25 97 23.25Z" fill="#003010" stroke="#069E08" stroke-width="1.5"/>
<path d="M97 229.25C99.3472 229.25 101.25 227.347 101.25 225C101.25 222.653 99.3472 220.75 97 220.75C94.6528 220.75 92.75 222.653 92.75 225C92.75 227.347 94.6528 229.25 97 229.25Z" fill="#003010" stroke="#069E08" stroke-width="1.5"/>
<defs>
<linearGradient id="paint0_linear_6208_69986" x1="219.385" y1="141.678" x2="181.201" y2="103.494" gradientUnits="userSpaceOnUse">
<stop stop-color="#2E38FA"/>
<stop offset="0.528154" stop-color="#48FF50"/>
<stop offset="1" stop-color="#DAFFDC"/>
</linearGradient>
<linearGradient id="paint1_linear_6208_69986" x1="227.87" y1="150.163" x2="172.716" y2="95.0088" gradientUnits="userSpaceOnUse">
<stop stop-color="#2E38FA"/>
<stop offset="0.528154" stop-color="#48FF50"/>
<stop offset="1" stop-color="#DAFFDC"/>
</linearGradient>
<linearGradient id="paint2_linear_6208_69986" x1="236.355" y1="158.648" x2="164.231" y2="86.5236" gradientUnits="userSpaceOnUse">
<stop stop-color="#2E38FA"/>
<stop offset="0.528154" stop-color="#48FF50"/>
<stop offset="1" stop-color="#DAFFDC"/>
</linearGradient>
<linearGradient id="paint3_linear_6208_69986" x1="244.841" y1="167.134" x2="155.745" y2="78.0383" gradientUnits="userSpaceOnUse">
<stop stop-color="#2E38FA"/>
<stop offset="0.528154" stop-color="#48FF50"/>
<stop offset="1" stop-color="#DAFFDC"/>
</linearGradient>
<linearGradient id="paint4_linear_6208_69986" x1="253.326" y1="175.619" x2="147.26" y2="69.553" gradientUnits="userSpaceOnUse">
<stop stop-color="#2E38FA"/>
<stop offset="0.528154" stop-color="#48FF50"/>
<stop offset="1" stop-color="#DAFFDC"/>
</linearGradient>
<linearGradient id="paint5_linear_6208_69986" x1="240.289" y1="121.937" x2="221.82" y2="98.8034" gradientUnits="userSpaceOnUse">
<stop stop-color="#003010" stop-opacity="0"/>
<stop offset="1" stop-color="#003010"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

@@ -0,0 +1,17 @@
<svg width="380" height="183" viewBox="0 0 380 183" fill="none" xmlns="http://www.w3.org/2000/svg">
<g opacity="0.75" filter="url(#filter0_f_5953_11924)">
<path d="M360 -60C360 7.93102 304.931 63 237 63C169.069 63 114 7.93102 114 -60C114 -127.931 169.069 -183 237 -183C304.931 -183 360 -127.931 360 -60Z" fill="url(#paint0_linear_5953_11924)"/>
</g>
<defs>
<filter id="filter0_f_5953_11924" x="-6" y="-303" width="486" height="486" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feGaussianBlur stdDeviation="60" result="effect1_foregroundBlur_5953_11924"/>
</filter>
<linearGradient id="paint0_linear_5953_11924" x1="360" y1="-60" x2="114" y2="-60" gradientUnits="userSpaceOnUse">
<stop stop-color="#48FF50"/>
<stop offset="0.471846" stop-color="#108642"/>
<stop offset="1" stop-color="#2E38FA"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 990 B

@@ -0,0 +1 @@
<?php return array('dependencies' => array('jetpack-connection', 'jetpack-script-data', 'react', 'react-dom', 'react-jsx-runtime', 'wp-a11y', 'wp-api-fetch', 'wp-components', 'wp-compose', 'wp-data', 'wp-date', 'wp-element', 'wp-i18n', 'wp-keycodes', 'wp-polyfill', 'wp-primitives', 'wp-private-apis', 'wp-url', 'wp-warning'), 'version' => '8dd753bb5b65f7782f75');
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,9 @@
/**
* @license React
* use-sync-external-store-shim.production.js
*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
File diff suppressed because one or more lines are too long
@@ -0,0 +1,84 @@
<?php
/**
* The Activity Log React initial state.
*
* @package automattic/jetpack-activity-log
*/
namespace Automattic\Jetpack\Activity_Log;
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 Activity Log 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() ),
// The paid-plan capability check. Drives the free-tier
// upsell callout and matches the server-side clamp in
// REST_Controller::get_activity_log(). The result is
// cached in a site transient by the REST controller
// (5-minute TTL); on a cache miss this call issues a
// synchronous WPCOM request (~200800ms typical, up to
// the 2s internal timeout) that blocks page rendering
// until it returns.
'hasActivityLogsAccess' => REST_Controller::has_activity_logs_access(),
),
'nonces' => array(
// Consumed by `UpsellCallout` to build a `redirect_to`
// URL that invalidates the access cache on return from
// checkout. See `Jetpack_Activity_Log::admin_init()`.
'refreshAccess' => wp_create_nonce( Jetpack_Activity_Log::REFRESH_ACCESS_NONCE_ACTION ),
),
'assets' => array(
'buildUrl' => plugins_url( '../build/', __FILE__ ),
),
);
}
/**
* Render the initial state into a JavaScript variable.
*
* @return string
*/
public function render() {
return 'var JPACTIVITYLOG_INITIAL_STATE=' . wp_json_encode( $this->get_data(), JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ) . ';';
}
}
@@ -0,0 +1,189 @@
<?php
/**
* Primary class for the Jetpack Activity Log package.
*
* @package automattic/jetpack-activity-log
*/
namespace Automattic\Jetpack\Activity_Log;
if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}
use Automattic\Jetpack\Activity_Log\Initial_State as Activity_Log_Initial_State;
use Automattic\Jetpack\Admin_UI\Admin_Menu;
use Automattic\Jetpack\Assets;
use Automattic\Jetpack\Connection\Initial_State as Connection_Initial_State;
use Automattic\Jetpack\Connection\Manager as Connection_Manager;
use function add_action;
use function add_filter;
use function current_user_can;
use function did_action;
use function do_action;
use function is_multisite;
use function sanitize_text_field;
use function wp_add_inline_script;
use function wp_unslash;
use function wp_verify_nonce;
/**
* Class Jetpack_Activity_Log
*
* Registers the Activity Log admin page and its REST routes inside the
* main Jetpack plugin.
*/
class Jetpack_Activity_Log {
/**
* Admin page slug.
*
* @var string
*/
const PAGE_SLUG = 'jetpack-activity-log';
/**
* Script handle for the admin bundle.
*
* @var string
*/
const SCRIPT_HANDLE = 'jetpack-activity-log';
/**
* Nonce action for refreshing the access flag after a checkout
* return. Used by `admin_init()` below and exposed to the client via
* Initial_State so the upsell CTA can embed a valid nonce in its
* `redirect_to`. Same shape as `Social_Admin_Page::REFRESH_PLAN_NONCE_ACTION`.
*
* @var string
*/
const REFRESH_ACCESS_NONCE_ACTION = 'jetpack_activity_log_refresh_access';
/**
* Entry point. Idempotent: safe to call from multiple bootstraps.
*/
public static function initialize() {
if ( did_action( 'jetpack_activity_log_initialized' ) ) {
return;
}
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 Activity Log package has wired its hooks.
*
* @since 0.1.0
*/
do_action( 'jetpack_activity_log_initialized' );
}
/**
* Register the Activity Log submenu under Jetpack.
*
* Mirrors the gating used by the legacy my-jetpack "Activity Log" menu
* item (connected user + non-multisite).
*
* @return string|null The resulting page's hook suffix, if registered.
*/
public static function add_wp_admin_submenu() {
if ( ! self::is_available() ) {
return null;
}
$page_suffix = Admin_Menu::add_menu(
/** "Activity Log" is a product name, do not translate. */
'Activity Log',
'Activity Log',
'manage_options',
self::PAGE_SLUG,
array( __CLASS__, 'render_page' ),
14
);
if ( $page_suffix ) {
add_action( 'load-' . $page_suffix, array( __CLASS__, 'admin_init' ) );
}
return $page_suffix;
}
/**
* Whether the Activity Log 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.
*
* When the user is returning from a successful checkout, the upsell
* CTA appends `?refresh_access=1&_wpnonce=…` to the `redirect_to`
* value it hands off to WordPress.com. Detect that here, verify the
* nonce, and drop the cached paid-plan signal so
* `Initial_State::get_data()` (which runs later in the same request,
* when the bundle is enqueued) rehydrates from WPCOM instead of
* re-serving the pre-checkout value. Mirrors the pattern in
* `Automattic\Jetpack\Publicize\Social_Admin_Page::admin_init()`.
*/
public static function admin_init() {
if ( isset( $_GET['refresh_access'] ) && isset( $_GET['_wpnonce'] ) ) {
$nonce = sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) );
if ( wp_verify_nonce( $nonce, self::REFRESH_ACCESS_NONCE_ACTION ) ) {
REST_Controller::clear_access_cache();
}
}
add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_admin_scripts' ) );
}
/**
* Enqueue the admin bundle and seed initial state.
*/
public static function enqueue_admin_scripts() {
Assets::register_script(
self::SCRIPT_HANDLE,
'../build/index.js',
__FILE__,
array(
'in_footer' => true,
'textdomain' => 'jetpack-activity-log',
)
);
Assets::enqueue_script( self::SCRIPT_HANDLE );
wp_add_inline_script( self::SCRIPT_HANDLE, ( new Activity_Log_Initial_State() )->render(), 'before' );
Connection_Initial_State::render_script( self::SCRIPT_HANDLE );
}
/**
* Render the admin page root node. React mounts into this element.
*/
public static function render_page() {
?>
<div id="jetpack-activity-log-root"></div>
<?php
}
/**
* Register the REST routes backing the Activity Log UI.
*
* Routes are added in Phase 2. This method exists now so that the
* `jetpack/v4/activity-log` namespace is reserved and the hook is wired.
*/
public static function register_rest_routes() {
REST_Controller::register_rest_routes();
}
}
@@ -0,0 +1,35 @@
<?php
/**
* The Package_Version class.
*
* @package automattic/jetpack-activity-log
*/
namespace Automattic\Jetpack\Activity_Log;
/**
* 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.9';
const PACKAGE_SLUG = 'activity-log';
/**
* 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,473 @@
<?php
/**
* The Activity Log REST Controller.
*
* Registers the `/jetpack/v4/activity-log/*` routes backing the admin
* UI. Each route is a thin proxy to the corresponding WPCOM v2
* endpoint, authenticated with the site's blog token.
*
* @package automattic/jetpack-activity-log
*/
namespace Automattic\Jetpack\Activity_Log;
use Automattic\Jetpack\Connection\Client;
use Automattic\Jetpack\Connection\Manager as Connection_Manager;
use Automattic\Jetpack\Status\Visitor;
use Jetpack_Options;
use WP_Error;
use WP_REST_Request;
use WP_REST_Server;
use function current_user_can;
use function delete_site_transient;
use function esc_html__;
use function get_site_transient;
use function http_build_query;
use function is_wp_error;
use function json_decode;
use function register_rest_route;
use function rest_ensure_response;
use function set_site_transient;
use function wp_remote_retrieve_body;
use function wp_remote_retrieve_response_code;
/**
* REST routes for the Activity Log UI.
*/
class REST_Controller {
/**
* REST namespace used by this package.
*
* @var string
*/
const REST_NAMESPACE = 'jetpack/v4';
/**
* Max items returned per request on the free tier. Matches the "20 most
* recent events" copy used by Calypso's upsell callout.
*
* @var int
*/
const FREE_TIER_ITEM_CAP = 20;
/**
* Site-transient TTL for the has-access capability check.
*
* @var int
*/
const CAPABILITY_CACHE_TTL = 5 * MINUTE_IN_SECONDS;
/**
* Transient key prefix for the per-blog access cache.
*
* @var string
*/
const CAPABILITY_CACHE_KEY = 'jetpack_activity_log_has_access_';
/**
* Query params accepted by the list endpoint. Shape matches Calypso's
* ActivityLogParams so the ported UI can forward its filter state
* verbatim.
*
* @return array
*/
private static function list_args() {
return array(
'number' => array(
'description' => __( 'Number of items to return per page.', 'jetpack-activity-log' ),
'type' => 'integer',
'minimum' => 1,
'maximum' => 1000,
),
'page' => array(
'description' => __( '1-indexed page number.', 'jetpack-activity-log' ),
'type' => 'integer',
'minimum' => 1,
),
'sort_order' => array(
'description' => __( 'Sort direction.', 'jetpack-activity-log' ),
'type' => 'string',
'enum' => array( 'asc', 'desc' ),
),
'after' => array(
'description' => __( 'ISO 8601 lower bound on event timestamp.', 'jetpack-activity-log' ),
'type' => 'string',
'format' => 'date-time',
),
'before' => array(
'description' => __( 'ISO 8601 upper bound on event timestamp.', 'jetpack-activity-log' ),
'type' => 'string',
'format' => 'date-time',
),
'group' => array(
'description' => __( 'Only return events in these groups.', 'jetpack-activity-log' ),
'type' => 'array',
'items' => array( 'type' => 'string' ),
),
'not_group' => array(
'description' => __( 'Exclude events in these groups.', 'jetpack-activity-log' ),
'type' => 'array',
'items' => array( 'type' => 'string' ),
),
'text_search' => array(
'description' => __( 'Full-text search string.', 'jetpack-activity-log' ),
'type' => 'string',
),
'actor' => array(
'description' => __( 'Only return events performed by these actor IDs.', 'jetpack-activity-log' ),
'type' => 'array',
'items' => array( 'type' => 'string' ),
),
);
}
/**
* Query params accepted by the actors endpoint. Same date window as the
* counts endpoint (no pagination, no sort, no filters) — we just want
* the distinct set for the "Performed by" dropdown.
*
* @return array
*/
private static function actors_args() {
return array(
'number' => array(
'description' => __( 'Cap on the number of events considered when collecting actors.', 'jetpack-activity-log' ),
'type' => 'integer',
'minimum' => 1,
'maximum' => 1000,
),
'after' => array(
'description' => __( 'ISO 8601 lower bound on event timestamp.', 'jetpack-activity-log' ),
'type' => 'string',
'format' => 'date-time',
),
'before' => array(
'description' => __( 'ISO 8601 upper bound on event timestamp.', 'jetpack-activity-log' ),
'type' => 'string',
'format' => 'date-time',
),
);
}
/**
* Query params accepted by the group-counts endpoint. A subset of the
* list params — no pagination or sort, no text search.
*
* @return array
*/
private static function group_counts_args() {
return array(
'number' => array(
'description' => __( 'Cap on the number of events considered when counting groups.', 'jetpack-activity-log' ),
'type' => 'integer',
'minimum' => 1,
'maximum' => 1000,
),
'after' => array(
'description' => __( 'ISO 8601 lower bound on event timestamp.', 'jetpack-activity-log' ),
'type' => 'string',
'format' => 'date-time',
),
'before' => array(
'description' => __( 'ISO 8601 upper bound on event timestamp.', 'jetpack-activity-log' ),
'type' => 'string',
'format' => 'date-time',
),
'group' => array(
'description' => __( 'Only count events in these groups.', 'jetpack-activity-log' ),
'type' => 'array',
'items' => array( 'type' => 'string' ),
),
'not_group' => array(
'description' => __( 'Exclude events in these groups.', 'jetpack-activity-log' ),
'type' => 'array',
'items' => array( 'type' => 'string' ),
),
);
}
/**
* Register the Activity Log REST routes.
*
* Hooked on `rest_api_init` by {@see Jetpack_Activity_Log::initialize()}.
*/
public static function register_rest_routes() {
register_rest_route(
self::REST_NAMESPACE,
'/activity-log',
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( __CLASS__, 'get_activity_log' ),
'permission_callback' => array( __CLASS__, 'permissions_callback' ),
'args' => self::list_args(),
)
);
register_rest_route(
self::REST_NAMESPACE,
'/activity-log/count/group',
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( __CLASS__, 'get_activity_log_group_counts' ),
'permission_callback' => array( __CLASS__, 'permissions_callback' ),
'args' => self::group_counts_args(),
)
);
register_rest_route(
self::REST_NAMESPACE,
'/activity-log/actors',
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( __CLASS__, 'get_activity_log_actors' ),
'permission_callback' => array( __CLASS__, 'permissions_callback' ),
'args' => self::actors_args(),
)
);
}
/**
* Permission callback. Mirrors the menu gating — any admin on a
* non-multisite install with a user-level WPCOM connection can read
* the log. A user-level connection is required because the upstream
* WPCOM endpoint is user-gated (it needs to identify *which* admin
* is asking); signing as the blog gets rejected with "Only
* Administrators can query information about the current site."
*
* @return bool|WP_Error
*/
public static function permissions_callback() {
if ( ! current_user_can( 'manage_options' ) ) {
return false;
}
if ( ! ( new Connection_Manager() )->is_user_connected() ) {
return new WP_Error(
'activity_log_user_not_connected',
esc_html__( 'Your WordPress.com account is not connected to this site. Connect it to use the Activity Log.', 'jetpack-activity-log' ),
array( 'status' => 403 )
);
}
return true;
}
/**
* Whether the site's current plan unlocks the full activity log.
*
* Checks the WPCOM `/sites/{id}/features` endpoint for the
* `full-activity-log` feature flag and caches the boolean for
* {@see self::CAPABILITY_CACHE_TTL} seconds in a site transient so the
* list endpoint doesn't pay the round-trip on every pagination page.
* The cache is per-blog (fine for multisite) and keyed on `blog_id`.
*
* Checking the feature flag (rather than a specific plan slug or the
* rewind state) means Jetpack Complete, Security, Personal, and all
* standalone Backup plans are covered correctly, regardless of whether
* backup credentials have been configured.
*
* @return bool True when the site has the full-activity-log feature.
*/
public static function has_activity_logs_access() {
$blog_id = (int) Jetpack_Options::get_option( 'id' );
if ( ! $blog_id ) {
return false;
}
$cache_key = self::CAPABILITY_CACHE_KEY . $blog_id;
$cached = get_site_transient( $cache_key );
if ( false !== $cached ) {
return 'yes' === $cached;
}
$response = Client::wpcom_json_api_request_as_blog(
sprintf( '/sites/%d/features', $blog_id ),
'1.1',
array( 'timeout' => 2 )
);
if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
// Fail closed: assume no access if we can't reach WPCOM. Cache for
// a short window to avoid hammering the endpoint on every call.
set_site_transient( $cache_key, 'no', 10 );
return false;
}
$body = json_decode( wp_remote_retrieve_body( $response ) );
$active = is_object( $body ) && isset( $body->active ) && is_array( $body->active ) ? $body->active : array();
$has_it = in_array( 'full-activity-log', $active, true );
set_site_transient( $cache_key, $has_it ? 'yes' : 'no', self::CAPABILITY_CACHE_TTL );
return $has_it;
}
/**
* Clear the cached has-access flag. Exposed so front-end flows that
* know the plan just changed (e.g. a successful checkout redirect) can
* force a refresh on the next request.
*
* @return void
*/
public static function clear_access_cache() {
$blog_id = (int) Jetpack_Options::get_option( 'id' );
if ( $blog_id ) {
delete_site_transient( self::CAPABILITY_CACHE_KEY . $blog_id );
}
}
/**
* Free-tier params that survive the filter strip in
* {@see self::get_activity_log()}. Anything outside this list is
* nulled out before the request reaches WPCOM.
*
* @var string[]
*/
const FREE_TIER_ALLOWED_PARAMS = array( 'number', 'page', 'sort_order' );
/**
* Proxy the paginated activity list.
*
* Enforces the free-tier boundary server-side. When the site doesn't
* have access:
*
* 1. `number` is clamped to {@see self::FREE_TIER_ITEM_CAP}.
* 2. `page` is forced to 1.
* 3. All filter inputs (`after`, `before`, `group`, `not_group`,
* `text_search`, `actor`) are dropped.
*
* Together these mean a client-side bypass (DevTools, direct
* `wp.apiFetch`) is bounded to "the 20 most recent events overall" —
* the same dataset the locked-down UI surfaces. Without (3),
* date-walking via `before` would let a free-tier caller page through
* the entire history 20 rows at a time.
*
* @param WP_REST_Request $request Request.
* @return mixed
*/
public static function get_activity_log( WP_REST_Request $request ) {
if ( ! self::has_activity_logs_access() ) {
// Mutating the request in-place is deliberate: any
// downstream `rest_request_*` filter sees the clamped
// values, not the caller's originals. For a security
// clamp that's the right side of the trade — no filter
// can undo the limit.
$requested = (int) $request->get_param( 'number' );
$request->set_param(
'number',
$requested > 0 ? min( $requested, self::FREE_TIER_ITEM_CAP ) : self::FREE_TIER_ITEM_CAP
);
$request->set_param( 'page', 1 );
foreach ( array_keys( self::list_args() ) as $key ) {
if ( ! in_array( $key, self::FREE_TIER_ALLOWED_PARAMS, true ) ) {
$request->set_param( $key, null );
}
}
}
return self::proxy_get( '/activity', $request, array_keys( self::list_args() ) );
}
/**
* Proxy the group-counts endpoint.
*
* Deliberately not tier-clamped — the free-tier list clamp
* (`number` → 20 / `page` → 1) is the security boundary; the group
* counts are cosmetic metadata that powers the filter dropdown. A
* stable, full-history count keeps the dropdown from flickering as
* users type in the search field, matching Calypso's behavior at
* `wp-calypso:client/dashboard/sites/logs-activity/dataviews/
* index.tsx:100-102`.
*
* @param WP_REST_Request $request Request.
* @return mixed
*/
public static function get_activity_log_group_counts( WP_REST_Request $request ) {
return self::proxy_get( '/activity/count/group', $request, array_keys( self::group_counts_args() ) );
}
/**
* Proxy the actors endpoint. Returns the distinct actors that have at
* least one event in the requested date window — used to populate the
* "Performed by" filter dropdown.
*
* Tier-clamping mirrors the group-counts endpoint: the list clamp at
* {@see self::get_activity_log()} is the security boundary, so the
* actors metadata is fine to serve unconditionally.
*
* @param WP_REST_Request $request Request.
* @return mixed
*/
public static function get_activity_log_actors( WP_REST_Request $request ) {
return self::proxy_get( '/activity/actors', $request, array_keys( self::actors_args() ) );
}
/**
* Shared helper: forward whitelisted query params from $request to the
* equivalent WPCOM v2 path under `/sites/{blog_id}`.
*
* @param string $wpcom_path Path relative to the site, starting with "/".
* @param WP_REST_Request $request Incoming request.
* @param array $allowed_keys Params to forward. Any unset keys are dropped.
* @return mixed Decoded JSON response from WPCOM, or WP_Error on failure.
*/
private static function proxy_get( $wpcom_path, WP_REST_Request $request, array $allowed_keys ) {
$blog_id = Jetpack_Options::get_option( 'id' );
if ( ! $blog_id ) {
return new WP_Error(
'activity_log_not_connected',
esc_html__( 'This site is not connected to WordPress.com.', 'jetpack-activity-log' ),
array( 'status' => 400 )
);
}
$params = array();
foreach ( $allowed_keys as $key ) {
$value = $request->get_param( $key );
if ( $value !== null ) {
$params[ $key ] = $value;
}
}
$path = sprintf( '/sites/%d%s', (int) $blog_id, $wpcom_path );
if ( ! empty( $params ) ) {
$path .= '?' . http_build_query( $params );
}
// Sign as the current user, not the blog: the upstream /sites/{id}/activity
// endpoint checks that a specific admin is asking. Forward the visitor IP
// so WPCOM logs match the existing /jetpack/v4/site/activity proxy.
$response = Client::wpcom_json_api_request_as_user(
$path,
'2',
array(
'method' => 'GET',
'headers' => array(
'X-Forwarded-For' => ( new Visitor() )->get_ip( true ),
),
),
null,
'wpcom'
);
if ( is_wp_error( $response ) ) {
return new WP_Error(
'activity_log_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 ( 200 !== $status ) {
return new WP_Error(
'activity_log_request_failed',
isset( $body['message'] ) ? (string) $body['message'] : esc_html__( 'Unable to fetch activity log.', 'jetpack-activity-log' ),
array( 'status' => $status ? $status : 500 )
);
}
return rest_ensure_response( $body );
}
}