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 @@
<?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 );
}
}