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,113 @@
<?php
namespace Automattic\Jetpack\WP_JS_Data_Sync;
use Automattic\Jetpack\Schema\Parser;
use Automattic\Jetpack\Schema\Schema_Error;
use Automattic\Jetpack\Schema\Schema_Parser;
use Automattic\Jetpack\WP_JS_Data_Sync\Contracts\Data_Sync_Entry;
use Automattic\Jetpack\WP_JS_Data_Sync\Contracts\Entry_Can_Delete;
use Automattic\Jetpack\WP_JS_Data_Sync\Contracts\Entry_Can_Get;
use Automattic\Jetpack\WP_JS_Data_Sync\Contracts\Entry_Can_Merge;
use Automattic\Jetpack\WP_JS_Data_Sync\Contracts\Entry_Can_Set;
/**
* Data Sync Entry Adapter:
* ========================
* This class takes in any instance that subscribes to one or more "Entry_Can_*" interfaces
* and adapts it to give it a predictable interface.
*
* This makes it possible to have an Entry class that only subscribes to "Entry_Can_Get"
* yet still have all the other methods (set/merge/delete) available.
*
* Entry Adapter will infer whether an object is able to perform actions (get,set,merge,delete)
* based on whether the object is an instance of the corresponding interface (Entry_Can_*).
*/
final class Data_Sync_Entry_Adapter implements Data_Sync_Entry {
/**
* @var Entry_Can_Get&Entry_Can_Set|Entry_Can_Get&Entry_Can_Merge|Entry_Can_Get&Entry_Can_Delete - The data sync entry.
*/
private $entry;
/**
* @var Schema_Parser $parser - The schema for the data sync entry.
*/
private $parser;
/**
* For more explanation, see the class docblock.
*
* @see Data_Sync_Entry_Adapter
* The constructor accepts any entry that subscribes to at least "Entry_Can_Get", but can also
* subscribe to any of the other Entry_Can_* interfaces.
*
* @param Entry_Can_Get $entry - The data sync entry.
* @param Parser $schema - The schema for the data sync entry.
*/
public function __construct( $entry, $schema ) {
$this->entry = $entry;
$this->parser = $schema;
}
public function is( $interface_reference ) {
return $this->entry instanceof $interface_reference;
}
public function get() {
if ( $this->parser->has_fallback() ) {
$default = $this->parser->get_fallback();
$value = $this->entry->get( $default );
return $this->parser->parse( $value );
}
// If WordPress debug is enabled, don't hide exceptions.
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
return $this->parser->parse( $this->entry->get() );
}
// If WordPress debug is disabled, attempt to recover by just returning the value
try {
return $this->parser->parse( $this->entry->get() );
} catch ( Schema_Error $error ) {
return $this->entry->get();
}
}
public function set( $value ) {
if ( $this->is( Entry_Can_Set::class ) ) {
$parsed_value = $this->parser->parse( $value );
$this->entry->set( $parsed_value );
}
return $this->get();
}
public function merge( $partial_value ) {
if ( $this->is( Entry_Can_Merge::class ) ) {
if ( $this->parser->has_fallback() ) {
$default = $this->parser->get_fallback();
$existing_value = $this->entry->get( $default );
} else {
$existing_value = $this->entry->get();
}
$updated_value = $this->entry->merge( $existing_value, $partial_value );
$this->set( $updated_value );
}
return $this->get();
}
public function delete() {
if ( $this->is( Entry_Can_Delete::class ) ) {
$this->entry->delete();
}
return $this->get();
}
/**
* @return Parser
*/
public function get_parser() {
return $this->parser;
}
}
@@ -0,0 +1,33 @@
<?php
namespace Automattic\Jetpack\WP_JS_Data_Sync;
use Automattic\Jetpack\WP_JS_Data_Sync\Contracts\Entry_Can_Delete;
use Automattic\Jetpack\WP_JS_Data_Sync\Contracts\Entry_Can_Get;
use Automattic\Jetpack\WP_JS_Data_Sync\Contracts\Entry_Can_Set;
final class Data_Sync_Option implements Entry_Can_Get, Entry_Can_Set, Entry_Can_Delete {
private $option_key;
public function __construct( $option_key ) {
$this->option_key = $option_key;
}
public function get( $fallback_value = false ) {
// WordPress looks at argument count to figure out if a fallback value was used.
// Only provide the fallback value if it's not the default ( false ).
if ( $fallback_value !== false ) {
return get_option( $this->option_key, $fallback_value );
}
return get_option( $this->option_key );
}
public function set( $value ) {
update_option( $this->option_key, $value, false );
}
public function delete() {
delete_option( $this->option_key );
}
}
@@ -0,0 +1,20 @@
<?php
namespace Automattic\Jetpack\WP_JS_Data_Sync;
use Automattic\Jetpack\WP_JS_Data_Sync\Contracts\Entry_Can_Get;
final class Data_Sync_Readonly implements Entry_Can_Get {
private $callback;
public function __construct( $callback ) {
$this->callback = $callback;
}
public function get( $fallback_value = false ) {
$result = call_user_func( $this->callback );
if ( false !== $fallback_value && empty( $result ) ) {
return $fallback_value;
}
return $result;
}
}
@@ -0,0 +1,256 @@
<?php
/**
* This is the main file for the Data_Sync package.
*
* It's responsible for setting up the registry and the endpoints.
*
* Setting up with something like this:
*
* ```
* class Widget_Status extends Data_Sync_Handler {}
* class Widget_Data extends Data_Sync_Handler {}
*
* $instance = Data_Sync::setup( 'jetpack_boost', 'jetpack-boost' );
* $instance->register( 'widget_status', new Widget_Status() );
* $instance->register( 'widget_data', new Widget_Data() );
*
* ```
*
* This will to create two endpoints: `/wp-json/jetpack-boost/widget-status` and `/wp-json/jetpack-boost/widget-data`
* and pass the following variables to the `jetpack-boost` script handle.
*
* Note that keys for URLs are always automatically transformed to kebab-case, so `widget_status` becomes `widget-status`,
* and it's expected that keys are always in snake_case when referencing options.
* They're only transformed to kebab-case when used in URLs.
*
* ```
* jetpack_boost = {
* rest_api: {
* value: 'https://example.com/wp-json/jetpack-boost',
* nonce: '1234567890'
* },
* widget_status: {
* value: 'active',
* nonce: '1234567890'
* },
* widget_data: {
* value: { ... },
* nonce: '1234567890'
* }
* }
* ```
*
*
* To access the data from WordPress, you can ask the registry for the entry:*
* ```
* $registry = Registry::get_instance( 'jetpack_boost' );
* $entry = $registry->get( 'widget_status' );
* $entry->get(); // 'active'
* ```
*
*
* To make it easier to access the data, you should probably create a dedicated helper function:
* ```
* function jetpack_boost_get_data( $key ) {
* $registry = Registry::get_instance( 'jetpack_boost' );
* $entry = $registry->get( $key );
* return $entry->get();
* }
* ```
*
* @package automattic/jetpack-wp-js-data-sync
*/
namespace Automattic\Jetpack\WP_JS_Data_Sync;
use Automattic\Jetpack\Schema\Parser;
use Automattic\Jetpack\Schema\Schema_Context;
use Automattic\Jetpack\WP_JS_Data_Sync\Contracts\Entry_Can_Get;
use Automattic\Jetpack\WP_JS_Data_Sync\Contracts\Lazy_Entry;
final class Data_Sync {
const PACKAGE_VERSION = '0.6.9';
/**
* @var Registry
*/
private $registry;
/**
* @var string Script Handle name to pass the variables to.
*/
private $script_handle;
/**
* The Registry class is a singleton.
*
* @var Data_Sync[]
*/
private static $instance = array();
/**
* @var string The namespace to use for the registry.
*/
private $namespace;
public function __construct( $namespace ) {
$this->namespace = $namespace;
$this->registry = new Registry( $namespace );
}
public static function get_instance( $namespace ) {
if ( ! isset( self::$instance[ $namespace ] ) ) {
self::$instance[ $namespace ] = new self( $namespace );
}
return self::$instance[ $namespace ];
}
/**
* Retrieve nonces for all action endpoints associated with a given entry.
*
* @param string $entry_key The key for the entry.
*
* @return array An associative array of action nonces.
*/
private function get_action_nonces_for_entry( $entry_key ) {
// Assuming a method in Registry class to retrieve all action names for an entry
$action_names = $this->registry->get_action_names_for_entry( $entry_key );
$nonces = array();
foreach ( $action_names as $action_name ) {
$nonce = $this->registry->get_action_nonce( $entry_key, $action_name );
if ( $nonce ) {
$nonces[ $action_name ] = $nonce;
}
}
return $nonces;
}
/**
* Don't call this method directly.
* It's only public so that it can be called as a hook
*
* @return void
*/
// phpcs:ignore PSR2.Methods.MethodDeclaration.Underscore
public function _print_options_script_tag() {
$data = array(
'rest_api' => array(
'value' => rest_url( $this->registry->get_namespace_http() ),
'nonce' => wp_create_nonce( 'wp_rest' ),
),
);
foreach ( $this->registry->all() as $key => $entry ) {
$data[ $key ] = array(
'value' => $entry->is( Lazy_Entry::class ) ? null : $entry->get(),
'nonce' => $this->registry->get_endpoint( $key )->create_nonce(),
);
if ( DS_Utils::is_debug() ) {
$data[ $key ]['log'] = $entry->get_parser()->get_log();
}
if ( DS_Utils::debug_disable( $key ) ) {
unset( $data[ $key ]['value'] );
}
if ( $entry->is( Lazy_Entry::class ) ) {
$data[ $key ]['lazy'] = true;
}
// Include nonces for action endpoints associated with this entry
$action_nonces = $this->get_action_nonces_for_entry( $key );
if ( ! empty( $action_nonces ) ) {
$data[ $key ]['actions'] = $action_nonces;
}
}
wp_localize_script( $this->script_handle, $this->namespace, $data );
}
public function attach_to_plugin( $script_handle, $plugin_page_hook ) {
$this->script_handle = $script_handle;
add_action( $plugin_page_hook, array( $this, '_print_options_script_tag' ) );
}
public function get_registry() {
return $this->registry;
}
/**
* DataSync entries have to be registered before they can be used.
*
* Typically, entries are stored in WP Options, so this method
* is will default to registering entries as Data_Sync_Option.
*
* However, you can provide an `$entry` instance that subscribes Entry_Can_* methods.
* If you do, `Entry_Can_Get` interface is required, and all other Entry_Can_* interfaces are optional.
*
* @param string $key - The key to register the entry under.
* @param Parser $parser - The parser to use for the entry.
* @param Entry_Can_Get $custom_entry_instance - The entry to register. If null, a new Data_Sync_Option will be created.
*
* @return void
*/
public function register( $key, $parser, $custom_entry_instance = null ) {
$option_key = $this->namespace . '_' . $key;
// If a custom entry instance is provided, and it implements Entry_Can_Get, use that.
// Otherwise, this Entry will store data using Data_Sync_Option (wp_options).
$entry = ( $custom_entry_instance instanceof Entry_Can_Get )
? $custom_entry_instance
: new Data_Sync_Option( $option_key );
/*
* ## Adapter
* This `register` method is inteded to be a shorthand for the most common use case.
*
* Custom entries can implement various interfaces depending on whether they can set, merge, delete, etc.
* However, the Registry expects an object that implements Data_Sync_Entry.
* That's why we wrap the Entry in an Adapter - giving it a guaranteed interface.
*
* ## Customization
* Entries can be flexible because they're wrapped in an Adapter.
* But you can also create a class that implements `Data_Sync_Entry` directly if you need to.
* In that case, you'd need to use:
* ```php
* $Data_Sync->get_registry()->register(...)` instead of `$Data_Sync->register(...)
* ```
*/
if ( method_exists( $parser, 'set_context' ) ) {
// @phan-suppress-next-line PhanUndeclaredMethod -- Phan misses the method_exists(). See https://github.com/phan/phan/issues/1204.
$parser->set_context( new Schema_Context( $key ) );
}
$entry_adapter = new Data_Sync_Entry_Adapter( $entry, $parser );
$this->registry->register( $key, $entry_adapter );
}
/**
* Register a readonly entry.
*
* @param string $key The key to register the entry under.
* @param Parser $parser The parser to use for the entry.
* @param callable $callback The callback to use for the entry.
*
* @return void
*/
public function register_readonly(
$key,
$parser,
$callback
) {
$this->register( $key, $parser, new Data_Sync_Readonly( $callback ) );
}
public function register_action(
$key,
$action_name,
$request_schema,
$instance
) {
$this->registry->register_action( $key, $action_name, $request_schema, $instance );
}
}
@@ -0,0 +1,29 @@
<?php
namespace Automattic\Jetpack\WP_JS_Data_Sync;
use Automattic\Jetpack\Schema\Utils as Schema_Utils;
class DS_Utils {
/**
* Is the current environment a development environment?
*
* @return bool
*/
public static function is_debug(): bool {
return ( defined( 'DATASYNC_DEBUG' ) && \DATASYNC_DEBUG ) || Schema_Utils::is_debug();
}
public static function debug_disable( $name ) {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( isset( $_GET['ds-debug-disable'] ) && ( is_admin() || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) ) {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$values = explode( ',', sanitize_key( $_GET['ds-debug-disable'] ) );
if ( $values === array( 'all' ) ) {
return true;
}
return in_array( $name, $values, true );
}
return false;
}
}
@@ -0,0 +1,213 @@
<?php
/**
* The Registry class is a singleton that stores references to all Data_Sync_Entry instances.
* It also stores references to all Endpoint instances.
* It is namespaced to allow for multiple registries.
*
* @package automattic/jetpack-wp-js-data-sync
*/
namespace Automattic\Jetpack\WP_JS_Data_Sync;
use Automattic\Jetpack\Schema\Schema_Parser;
use Automattic\Jetpack\WP_JS_Data_Sync\Contracts\Data_Sync_Entry;
use Automattic\Jetpack\WP_JS_Data_Sync\Endpoints\Action_Endpoint;
use Automattic\Jetpack\WP_JS_Data_Sync\Endpoints\Endpoint;
class Registry {
/**
* Registry instances are namespaced to allow for multiple registries.
*
* @var string
*/
private $namespace;
/**
* Store a references for every Data_Sync_Entry instance.
*
* @var Data_Sync_Entry[]
*/
private $entries = array();
private $action_endpoints = array();
/**
* Store references for every Endpoint instance.
*
* @var Endpoint[]
*/
private $endpoints = array();
/**
* There can be multiple registries, reference them by namepsace.
* For example "jetpack_boost".
*
* @param string $namespace The namespace for this registry instance.
*/
public function __construct( $namespace ) {
$this->namespace = $namespace;
}
/**
* Sanitize a key.
*
* @param string $key - Keys should only include alphanumeric characters and underscores.
*
* @return string
* @throws \Exception In debug mode, if the key is invalid.
*/
private function sanitize_key( $key ) {
$sanitized_key = sanitize_key( $key );
$sanitized_key = str_replace( '-', '_', $sanitized_key );
if ( DS_Utils::is_debug() && $sanitized_key !== $key ) {
// If the key is invalid,
// Log an error during development
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( "Invalid key '$key'. Keys should only include alphanumeric characters and underscores." );
}
return $sanitized_key;
}
/**
* Sanitize a key for use in a URL.
*
* @param string $key - Keys should only include alphanumeric characters and underscores.
*
* @return string
*/
private function sanitize_url_key( $key ) {
return str_replace( '_', '-', sanitize_key( $key ) );
}
/**
* Register a new entry and add it to the registry.
*
* @param string $key - The name of the entry. For example `widget_status`.
* @param Data_Sync_Entry $entry
*
* @return Data_Sync_Entry
*/
public function register( $key, $entry ) {
$key = $this->sanitize_key( $key );
$this->entries[ $key ] = $entry;
$endpoint = new Endpoint( $this->get_namespace_http(), $this->sanitize_url_key( $key ), $entry );
$this->endpoints[ $key ] = $endpoint;
add_action( 'rest_api_init', array( $endpoint, 'register_rest_routes' ) );
return $entry;
}
/**
* Register an action endpoint.
*
* @param string $key The base key for the endpoint.
* @param string $action_name The name of the action.
* @param Schema_Parser $request_schema The schema for the action's request body.
* @param mixed $action_class The class handling the action logic.
*/
public function register_action( $key, $action_name, $request_schema, $action_class ) {
// Create and store the action endpoint instance
$action_endpoint = new Action_Endpoint(
$this->get_namespace_http(),
$this->sanitize_url_key( $key ),
$this->sanitize_url_key( $action_name ),
$request_schema,
$action_class
);
// Store the action endpoint instance for nonce retrieval
$this->action_endpoints[ $key ][ $action_name ] = $action_endpoint;
// Register the REST route for the action endpoint
add_action( 'rest_api_init', array( $action_endpoint, 'register_rest_routes' ) );
}
/**
* Retrieve all action names for a given entry.
*
* @param string $entry_key The key for the entry.
*
* @return array An array of action names.
*/
public function get_action_names_for_entry( $entry_key ) {
if ( isset( $this->action_endpoints[ $entry_key ] ) ) {
return array_keys( $this->action_endpoints[ $entry_key ] );
}
return array();
}
/**
* Get the nonce for a specific action endpoint.
*
* @param string $key The base key for the endpoint.
* @param string $action_name The name of the action.
*
* @return false|string The nonce or false if not found.
*/
public function get_action_nonce( $key, $action_name ) {
if ( isset( $this->action_endpoints[ $key ][ $action_name ] ) ) {
return $this->action_endpoints[ $key ][ $action_name ]->create_nonce();
}
return false;
}
/**
* Get all registered entries.
*
* @return Data_Sync_Entry[]
*/
public function all() {
return $this->entries;
}
/**
* Get the endpoint for a given key.
*
* @param string $key - The key for the endpoint.
*
* @return Endpoint|false
*/
public function get_endpoint( $key ) {
if ( ! isset( $this->endpoints[ $key ] ) ) {
return false;
}
return $this->endpoints[ $key ];
}
/**
* Get the entry for a given key.
*
* @param string $key - The key for the entry.
*
* @return Data_Sync_Entry|false
*/
public function get_entry( $key ) {
if ( ! isset( $this->entries[ $key ] ) ) {
return false;
}
return $this->entries[ $key ];
}
/**
* Get the namespace for use in a filter.
*
* @return string The namespace key of this registry instance.
*/
public function get_namespace() {
return $this->namespace;
}
/**
* Get the namespace for use in a URL.
*
* @return string The namespace key of this registry instance.
*/
public function get_namespace_http() {
return $this->sanitize_url_key( $this->namespace );
}
}
@@ -0,0 +1,22 @@
<?php
/**
* Interface for action classes in the data sync system.
*
* @package automattic/jetpack-wp-js-data-sync
*/
namespace Automattic\Jetpack\WP_JS_Data_Sync\Contracts;
/**
* Interface Action_Interface
*/
interface Data_Sync_Action {
/**
* Handles the action logic.
*
* @param mixed $data JSON Data passed to the action.
* @param \WP_REST_Request $request The request object.
* @return mixed
*/
public function handle( $data, $request );
}
@@ -0,0 +1,54 @@
<?php
namespace Automattic\Jetpack\WP_JS_Data_Sync\Contracts;
/**
* Data Sync Entry Interface:
* ==========================
* This interface defines what is a Data Sync Entry.
*/
interface Data_Sync_Entry {
/**
* Checks if this Data Sync Entry implements a specific interface.
*
* @param string $interface_reference The name of the method to check (get, set, merge, delete).
* @return bool True if the method is supported, false otherwise.
*/
public function is( $interface_reference );
/**
* Retrieves the current value of the data sync entry.
*
* @return mixed The current value of the data sync entry.
*/
public function get();
/**
* Sets the value of the data sync entry.
*
* @param mixed $value The new value to set for the data sync entry.
* @return mixed The updated value of the data sync entry.
*/
public function set( $value );
/**
* Merges a partial value with the current value of the data sync entry.
*
* @param mixed $partial_value The partial value to merge with the current value.
* @return mixed The updated value of the data sync entry after merging.
*/
public function merge( $partial_value );
/**
* Deletes the data sync entry.
*
* @return mixed The value of the data sync entry after deletion.
*/
public function delete();
/**
* @return mixed The schema of the data sync entry.
*/
public function get_parser();
}
@@ -0,0 +1,8 @@
<?php
namespace Automattic\Jetpack\WP_JS_Data_Sync\Contracts;
interface Entry_Can_Delete {
public function delete();
}
@@ -0,0 +1,8 @@
<?php
namespace Automattic\Jetpack\WP_JS_Data_Sync\Contracts;
interface Entry_Can_Get {
public function get( $fallback_value );
}
@@ -0,0 +1,8 @@
<?php
namespace Automattic\Jetpack\WP_JS_Data_Sync\Contracts;
interface Entry_Can_Merge extends Entry_Can_Set {
public function merge( $previous_value, $partial_value );
}
@@ -0,0 +1,8 @@
<?php
namespace Automattic\Jetpack\WP_JS_Data_Sync\Contracts;
interface Entry_Can_Set {
public function set( $value );
}
@@ -0,0 +1,8 @@
<?php
namespace Automattic\Jetpack\WP_JS_Data_Sync\Contracts;
interface Entry_Has_Custom_Endpoints {
public function set( $value );
}
@@ -0,0 +1,20 @@
<?php
namespace Automattic\Jetpack\WP_JS_Data_Sync\Contracts;
use Automattic\Jetpack\WP_JS_Data_Sync\Data_Sync;
interface Lazy_Entry {
/**
* "Look pal, I'm so lazy I can't even finish this sente.." - Lazy Entry
*
* Entries can tag themselves as "lazy" by implementing this interface.
* By tagging an entry as "lazy" it won't be loaded with `wp_localize_script`.
*
* This is useful when you want DataSync, but getting the data is going
* to slow down the admin dashboard page load.
* For example - uncached network requests.
*
* @see Data_Sync::_print_options_script_tag()
*/
}
@@ -0,0 +1,124 @@
<?php
/**
* Register and handle custom action REST API Endpoints for data sync entries.
*
* @package automattic/jetpack-wp-js-data-sync
*/
namespace Automattic\Jetpack\WP_JS_Data_Sync\Endpoints;
use Automattic\Jetpack\Schema\Schema_Parser;
use Automattic\Jetpack\WP_JS_Data_Sync\Contracts\Data_Sync_Action;
use Automattic\Jetpack\WP_JS_Data_Sync\DS_Utils;
class Action_Endpoint {
/**
* @var Data_Sync_Action - The class that handles the action.
*/
private $action_class;
/**
* @var Schema_Parser $request_schema - The schema for requests to this action.
*/
private $request_schema;
/**
* @var string $rest_namespace - The namespace for the REST API endpoint.
*/
private $rest_namespace;
/**
* @var string $route - The route for the REST API endpoint.
*/
private $route;
/**
* @var Authenticated_Nonce $nonce - The nonce for the REST API endpoint.
*/
private $nonce;
/**
* This class handles endpoints for DataSync actions.
*
* @param string $namespace
* @param string $key
* @param string $action_name
* @param Schema_Parser $request_schema
* @param Data_Sync_Action $action_class
*/
public function __construct( $namespace, $key, $action_name, $request_schema, $action_class ) {
$this->action_class = $action_class;
$this->request_schema = $request_schema;
$this->rest_namespace = $namespace;
$this->route = $key . '/action/' . $action_name;
$this->nonce = new Authenticated_Nonce( "{$namespace}_{$this->route}_action" );
}
public function register_rest_routes() {
register_rest_route(
$this->rest_namespace,
$this->route,
array(
'methods' => \WP_REST_Server::EDITABLE,
'callback' => array( $this, 'handle_action' ),
'permission_callback' => array( $this, 'permissions' ),
)
);
}
private function response_error( $message, $code ) {
return rest_ensure_response(
array(
'status' => 'error',
'message' => $message,
'code' => $code,
)
);
}
private function response_success( $data ) {
return rest_ensure_response(
array(
'status' => 'success',
'JSON' => $data,
)
);
}
public function handle_action( $request ) {
try {
$params = $request->get_json_params();
$data = $params['JSON'] ?? null;
$parsed_data = $this->request_schema->parse( $data );
// Delegate to the action handler
$result = $this->action_class->handle( $parsed_data, $request );
if ( is_wp_error( $result ) ) {
return $this->response_error( $result->get_error_message(), $result->get_error_code() );
}
if ( true === DS_Utils::debug_disable( $this->route ) ) {
// This is a debug request - it's ok to return a different shape from the rest.
// Return 418 I'm a teapot if this is a debug request to the endpoint.
return rest_ensure_response( new \WP_Error( 'teapot', "I'm a teapot.", array( 'status' => 418 ) ) );
}
return $this->response_success( $result );
} catch ( \RuntimeException $e ) {
return $this->response_error( $e->getMessage(), 'runtime_error' );
}
}
public function create_nonce() {
return $this->nonce->create();
}
/**
* @param \WP_REST_Request $request
*/
public function permissions( $request ) {
$nonce = $request->get_header( 'X-Jetpack-WP-JS-Sync-Nonce' );
return $this->nonce->verify( $nonce ) && current_user_can( 'manage_options' );
}
}
@@ -0,0 +1,39 @@
<?php
/**
* Every Data Sync endpoint should be protected by nonce that belongs to an authenticated user.
* and is generated and verified for that specific endpoint.
*
* @package automattic/jetpack-wp-js-data-sync
*/
namespace Automattic\Jetpack\WP_JS_Data_Sync\Endpoints;
use Automattic\Jetpack\WP_JS_Data_Sync\DS_Utils;
class Authenticated_Nonce {
/**
* @var string Which nonce action to verify?
*/
private $action;
public function __construct( $name ) {
$this->action = $name;
}
public function create() {
if ( DS_Utils::is_debug() && ! did_action( 'set_current_user' ) ) {
throw new \RuntimeException( "Debug: Attempting to create {$this->action} nonce before the user is set." );
}
return wp_create_nonce( $this->action );
}
public function verify( $nonce ) {
if ( DS_Utils::is_debug() && ! did_action( 'set_current_user' ) ) {
throw new \RuntimeException( "Debug: Attempting to validate {$this->action} nonce before the user is set." );
}
return wp_verify_nonce( $nonce, $this->action );
}
}
@@ -0,0 +1,216 @@
<?php
/**
* Register and handle REST API Endpoints for each data sync entry.
*
* @package automattic/jetpack-wp-js-data-sync
*/
namespace Automattic\Jetpack\WP_JS_Data_Sync\Endpoints;
use Automattic\Jetpack\WP_JS_Data_Sync\Contracts\Data_Sync_Entry;
use Automattic\Jetpack\WP_JS_Data_Sync\Contracts\Entry_Can_Delete;
use Automattic\Jetpack\WP_JS_Data_Sync\Contracts\Entry_Can_Get;
use Automattic\Jetpack\WP_JS_Data_Sync\Contracts\Entry_Can_Merge;
use Automattic\Jetpack\WP_JS_Data_Sync\Contracts\Entry_Can_Set;
use Automattic\Jetpack\WP_JS_Data_Sync\DS_Utils;
class Endpoint {
/**
* @var Data_Sync_Entry $entry - The data sync entry to register the endpoint for.
*/
private $entry;
/**
* @var string $rest_namespace - The namespace for the REST API endpoint.
*/
private $rest_namespace;
/**
* @var string $route_base - The route for the REST API endpoint.
*/
private $route_base;
/**
* @var Authenticated_Nonce $nonce - The nonce for the REST API endpoint.
*/
private $nonce;
/**
* @param string $namespace - The namespace for the REST API endpoint.
* @param string $route - The route for the REST API endpoint.
* @param Data_Sync_Entry $entry The data sync entry to register the endpoint for.
*/
public function __construct( $namespace, $route, $entry ) {
$this->entry = $entry;
$this->rest_namespace = $namespace;
$this->route_base = $route;
$this->nonce = new Authenticated_Nonce( "{$namespace}_{$route}" );
}
public function register_rest_routes() {
register_rest_route(
$this->rest_namespace,
$this->route_base,
array(
'methods' => 'GET, POST',
'callback' => array( $this, 'handle_get' ),
'permission_callback' => array( $this, 'permissions' ),
)
);
if ( $this->entry->is( Entry_Can_Set::class ) ) {
register_rest_route(
$this->rest_namespace,
$this->route_base . '/set',
array(
'methods' => \WP_REST_Server::EDITABLE,
'callback' => array( $this, 'handle_set' ),
'permission_callback' => array( $this, 'permissions' ),
)
);
}
if ( $this->entry->is( Entry_Can_Merge::class ) ) {
register_rest_route(
$this->rest_namespace,
$this->route_base . '/merge',
array(
'methods' => \WP_REST_Server::EDITABLE,
'callback' => array( $this, 'handle_merge' ),
'permission_callback' => array( $this, 'permissions' ),
)
);
}
if ( $this->entry->is( Entry_Can_Delete::class ) ) {
register_rest_route(
$this->rest_namespace,
$this->route_base . '/delete',
array(
'methods' => 'POST, DELETE',
'callback' => array( $this, 'handle_delete' ),
'permission_callback' => array( $this, 'permissions' ),
)
);
}
}
/**
* Handle GET Requests on /wp-json/<namespace>/<route>
*
* @param \WP_REST_Request $request - The request object.
*/
public function handle_get( $request ) {
return $this->handler( $request, 'get' );
}
/**
* Handle POST, PUT, PATCH Requests on /wp-json/<namespace>/<route>/set
*
* @param \WP_REST_Request $request - The request object.
*/
public function handle_set( $request ) {
return $this->handler( $request, 'set' );
}
/**
* Handle POST, PUT, PATCH Requests on /wp-json/<namespace>/<route>/merge
*
* @param \WP_REST_Request $request - The request object.
*/
public function handle_merge( $request ) {
return $this->handler( $request, 'merge' );
}
/**
* Handle POST, DELETE Requests on /wp-json/<namespace>/<route>/delete
*
* @param \WP_REST_Request $request - The request object.
*/
public function handle_delete( $request ) {
return $this->handler( $request, 'delete' );
}
private function response_error( $message, $code ) {
return rest_ensure_response(
array(
'status' => 'error',
'message' => $message,
'code' => $code,
)
);
}
private function response_success( $data ) {
$response = array(
'status' => 'success',
'JSON' => $data,
);
if ( DS_Utils::is_debug() ) {
$response['log'] = $this->entry->get_parser()->get_log();
}
return rest_ensure_response( $response );
}
/**
* Route the request to the apropriate handler.
*
* @param \WP_REST_Request $request - The request object.
*/
private function handler( $request, $entry_method = 'get' ) {
$available_methods = array(
'get' => Entry_Can_Get::class,
'set' => Entry_Can_Set::class,
'merge' => Entry_Can_Merge::class,
'delete' => Entry_Can_Delete::class,
);
if ( ! isset( $available_methods[ $entry_method ] ) ) {
// Set status 400 because an unsupported method was used.
return rest_ensure_response( new \WP_Error( 'invalid_method', 'Invalid method.', array( 'status' => 400 ) ) );
}
if ( ! $this->entry->is( $available_methods[ $entry_method ] ) ) {
// Set Status 500 because the method is valid but is missing in Data_Sync_Entry.
return rest_ensure_response( new \WP_Error( 'invalid_method', 'Invalid method. "' . $entry_method . '" ' ) );
}
try {
$params = $request->get_json_params();
$data = $params['JSON'] ?? null;
$result = $this->entry->$entry_method( $data );
if ( true === DS_Utils::debug_disable( $this->route_base ) ) {
// Return 418 I'm a teapot if this is a debug request to the endpoint.
return rest_ensure_response( new \WP_Error( 'teapot', "I'm a teapot.", array( 'status' => 418 ) ) );
}
if ( is_wp_error( $result ) ) {
return $this->response_error( $result->get_error_message(), $result->get_error_code() );
}
return $this->response_success( $result );
} catch ( \RuntimeException $e ) {
return $this->response_error( $e->getMessage(), 500 );
}
}
/**
* Create a nonce for this endpoint
*
* @return false|string
*/
public function create_nonce() {
return $this->nonce->create();
}
/**
* @param \WP_REST_Request $request
*/
public function permissions( $request ) {
$nonce = $request->get_header( 'X-Jetpack-WP-JS-Sync-Nonce' );
return $this->nonce->verify( $nonce ) && current_user_can( 'manage_options' );
}
}