initial
This commit is contained in:
+84
@@ -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 (~200–800ms 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 ) . ';';
|
||||
}
|
||||
}
|
||||
+189
@@ -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();
|
||||
}
|
||||
}
|
||||
+35
@@ -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;
|
||||
}
|
||||
}
|
||||
+473
@@ -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 );
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user