File: //wordpress/mu-plugins/edge-cache/edge-cache.php
<?php
/*
Plugin name: Edge Cache
Description: Automatically clears the edge cache when necessary
Author: Automattic
Author URI: http://automattic.com/
Version: 1.2
*/
require_once __DIR__ . '/shared/interface-edge-cache-platform.php';
require_once __DIR__ . '/shared/class-edge-cache-purge.php';
require_once __DIR__ . '/class-edge-cache-atomic.php';
class Edge_Cache_Plugin {
private $wp_action;
private $wp_domain;
private $ec_status;
private $purge_count = 0;
private $extra_tags = array();
private $post_ids = array();
private $purge_ratelimit = false;
private $last_error = null;
private $platform = null;
private $purge = null;
public const EC_ERROR = -1;
public const EC_DISABLED = 0;
public const EC_ENABLED = 1;
public const EC_DDOS = 2;
private const ALLOWED_STATUSES = array( self::EC_DISABLED, self::EC_ENABLED );
private const ALLOWED_ACTIONS = array( 'purge', 'toggle' );
/**
* Singleton instance
*
* @var Edge_Cache_Plugin
*/
private static $instance;
public function __construct() {
$this->platform = new Edge_Cache_Atomic();
$this->purge = new Edge_Cache_Purge( $this->platform );
}
/**
* Returns the class singleton.
*
* @return Edge_Cache_Plugin
*/
public static function get_instance() {
if ( ! self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Initialize the class - configures all hooks.
*/
public function init() {
// Can't call a purge if these are not defined.
if ( ! defined( 'SITE_API_BASE' ) || ! defined( 'ATOMIC_SITE_API_KEY' ) ) {
return;
}
// API is disabled, bail
if ( 'disabled' === ATOMIC_SITE_API_KEY ) {
return;
}
// Important! Used by edge cache to verify successful requests.
if ( ! headers_sent() && ! is_admin() ) {
header( 'A8C-Edge-Cache: cache' );
}
// Show Edge Cache options page only for non-WoA sites, See: https://wp.me/pgbkb-9YC#comment-26289
if ( defined( 'ATOMIC_CLIENT_ID' ) && '2' !== ATOMIC_CLIENT_ID ) {
add_action( 'admin_menu', array( $this, 'edge_cache_config_page' ) );
add_action( 'wp_ajax_edge_cache', array( $this, 'edge_cache_admin_action_handler' ) );
add_action( 'admin_print_styles-settings_page_edge-cache', array( $this, 'edge_cache_admin_styles' ) );
add_action( 'admin_print_scripts-settings_page_edge-cache', array( $this, 'edge_cache_admin_scripts' ) );
}
// Purge on WooCommerce coming soon page update
add_action( 'update_option_woocommerce_coming_soon', array( $this, 'handle_woo_option_update' ), 10, 3 );
add_action( 'update_option_woocommerce_store_pages_only', array( $this, 'handle_woo_option_update' ), 10, 3 );
$this->purge->init();
if ( defined( 'WP_CLI' ) && WP_CLI ) {
require_once( __DIR__ . '/class-edge-cache-cli.php' );
}
}
/**
* Add a link to the Edge Cache configuration to the Admin menu.
* Called from admin_menu.
*/
public function edge_cache_config_page() {
if ( ! $this->current_user_can_manage_edge_cache() ) {
return;
}
if ( function_exists('add_options_page') ) {
add_options_page(
__( 'Edge Cache configuration' ),
__( 'Edge Cache' ),
'manage_options',
'edge-cache',
array( $this, 'edge_cache_settings_page' )
);
}
}
/**
* Register styles for the Admin page.
*/
public function edge_cache_admin_styles() {
wp_register_style( 'edge-cache-style', plugins_url( '/css/edge-cache.css', __FILE__ ), [], '1.0.1' );
wp_enqueue_style( 'edge-cache-style' );
}
/**
* Regiser scripts for the Admin page.
*/
public function edge_cache_admin_scripts() {
wp_register_script( 'edge-cache-script', plugins_url( '/js/edge-cache.js', __FILE__ ), array( 'jquery' ), '1.0.1', true );
wp_enqueue_script( 'edge-cache-script' );
}
/**
* Render the title of the admin page.
*/
private function render_page_title() {
$title = sprintf( '<div class="dashicons dashicons-cloud ec-status-%s"></div><h2>Edge Cache</h2>',
$this->get_ec_status()
);
echo sprintf('<div class="card-head">%s<span>%s</span></div>', $title, $this->platform->get_domain_name() );
}
/**
* Render an error page.
*/
private function render_error_page( $content=[] ) {
if ( empty( $content ) ) {
$content = array(
'Edge Cache cannot be managed at the moment.<br>',
'Try refreshing the page in a few minutes.'
);
}
$error_message = sprintf( '<div class="notice notice-error">%s</div>', implode( ' ', $content ) );
?>
<main role="main">
<div class="card">
<?php $this->render_page_title() ?>
<div class="card-mid">
<?php echo $error_message; ?>
</div>
</div>
</main>
<?php
return;
}
/**
* Render a toggle button for enabling / disabling edge cache.
*/
private function render_toggle_action() {
$btn_class = 'button ec-action';
$data_action = 'toggle';
$ec_status = $this->get_ec_status();
if ( 0 === $ec_status ) {
$toggle_label = 'Enable';
$btn_class .= ' is-primary';
} else {
$toggle_label = 'Disable';
$btn_class .= ' button-link-delete';
}
$toggle_label .= ' Edge Cache';
$toggle_btn = sprintf( '<button class="%s" data-action="%s">%s</button>',
$btn_class,
$data_action,
$toggle_label
);
echo sprintf( '<div class="action-status">%s<span></span><div></div></div>', $toggle_btn );
return;
}
/**
* Returns true if the plugin has hit a purge rate limit.
*/
public function has_hit_purge_ratelimit() {
return $this->purge_ratelimit;
}
/**
* Render a button to purge the edge cache.
*/
private function render_purge_action() {
$ec_status = $this->get_ec_status();
if ( true === $this->purge_ratelimit ) {
$message = 'You cleared the cache recently. Please wait a minute and try again.';
echo sprintf( '<div class="notice notice-warning">%s</div>', $message );
return;
}
$btn_label = 'Clear Edge Cache';
$data_action = 'purge';
$btn_class = 'button is-primary ec-action';
$purge_btn = sprintf( '<button class="%s button-link-delete" data-action="%s" %s>%s</button>',
$btn_class,
$data_action,
$this->get_ec_status() !== 1 ? 'disabled' : '',
$btn_label
);
echo sprintf( '<div class="action-status">%s<span></span><div></div></div>', $purge_btn );
}
/**
* Render the Edge Cache settings page.
*/
public function edge_cache_settings_page() {
if ( ! $this->current_user_can_manage_edge_cache() ) {
return;
}
$ajax_url = add_query_arg(
array( '_wpnonce' => wp_create_nonce( 'edge-cache' ) ),
untrailingslashit( admin_url( 'admin-ajax.php' ) )
);
$ec_status = $this->get_ec_status();
if ( ! in_array( $ec_status, self::ALLOWED_STATUSES, true ) ) {
return $this->render_error_page();
}
$form_tag = sprintf( '<form id="ec_form" data-status="%s" action="%s" method="get">',
$ec_status,
esc_url( $ajax_url )
);
?>
<main role="main">
<div class="card">
<?php $this->render_page_title(); ?>
<div class="card-mid">
<?php echo $form_tag ?>
<p>Global edge cache can deliver your site faster by storing content physically closer to end users on our global network.</p>
<?php $this->render_toggle_action() ?>
<div class="divider"></div>
<p>Edge Cache is automatically cleared when the site's content is updated.</p>
<p>Clearing your site’s cache manually can lead to degraded performance and slower loading times for you and your visitors while the cache is rebuilt. To avoid a performance drop, the server-side cache should be only cleared in rare circumstances.</p>
<?php $this->render_purge_action() ?>
</form>
</div>
</div>
</main>
<?php
}
/**
* AJAX callback handler for admin actions.
*/
public function edge_cache_admin_action_handler() {
if ( ! $this->current_user_can_manage_edge_cache() ) {
return;
}
$input_error = 'Please reload the page and try again.';
$wait_action = 'Please wait a minute and try again.';
if ( ! isset( $_POST['action'], $_POST['opt'], $_POST['ec_status'], $_GET['_wpnonce'] ) ) {
wp_send_json_error( $input_error );
}
if ( ! wp_verify_nonce( $_GET['_wpnonce'], 'edge-cache' ) ) {
wp_send_json_error( 'Security check failed. ' . $input_error );
}
if ( ! is_numeric( $_POST['ec_status'] )
|| ! in_array( (int) $_POST['ec_status'], self::ALLOWED_STATUSES, true )
|| ! in_array( $_POST['opt'], self::ALLOWED_ACTIONS, true ) ) {
wp_send_json_error( 'Invalid input. ' . $input_error );
}
$action = $_POST['opt'];
$ec_status = (int) $_POST['ec_status'];
$data = array(
'wp_action' => sprintf( 'manual_%s', $action ),
'wp_domain' => $this->platform->get_domain_name(),
'at_host' => php_uname('n'),
'ip_addr' => $_SERVER['REMOTE_ADDR'],
);
$generic_error = 'Error while processing your request.<br>' . $wait_action;
// Actions are defined in ALLOWED_ACTIONS
if ( 'purge' === $action ) {
if ( $ec_status === 0 ) {
wp_send_json_error( 'Invalid purge request. ' . $input_error );
}
// Clear Batcache before Edge Cache, also log the result.
$data['batcache'] = $this->purge->purge_batcache();
$endpoint = 'purge';
} elseif ( 'toggle' === $action ) {
$new_status = $ec_status === 0 ? 1 : 0;
$endpoint = $new_status === 0 ? 'off' : 'on';
} else {
wp_send_json_error( 'Invalid Edge Cache action. ' . $wait_action );
}
$response = $this->query_ec_backend( $endpoint, array( 'body' => $data ) );
if ( $response['success'] === false ) {
if ( empty( $response['error'] ) ) {
wp_send_json_error( $generic_error . '<br>Please wait a minute, reload the page and try again.' );
}
wp_send_json_error( $response['error'] );
}
if ( $this->purge_ratelimit ) {
wp_send_json_error( 'You cleared the cache recently. ' . $wait_action );
}
if ( 'toggle' === $action ) {
$this->ec_status = $new_status;
}
wp_send_json_success();
}
/**
* Returns true if the current user is allowed to manage the Edge Cache.
*/
private function current_user_can_manage_edge_cache() {
return apply_filters(
'edge_cache_user_can_manage_edge_cache',
current_user_can( 'manage_options' ),
wp_get_current_user()
);
}
/**
* Get the current Edge Cache Status via the API.
* Caches the result for the duration of this request.
*/
public function get_ec_status() {
if ( isset( $this->ec_status ) ) {
return $this->ec_status;
}
$response = $this->query_ec_backend( 'status' );
if ( false === $response['success'] ) {
$this->ec_status = self::EC_ERROR;
return $this->ec_status;
}
if ( ! property_exists( $response['data'], 'status' ) || ! is_numeric( $response['data']->status ) ) {
$this->ec_status = self::EC_ERROR;
return $this->ec_status;
}
$this->ec_status = intval( $response['data']->status );
return $this->ec_status;
}
/**
* Get the current Defensive mode / ddos_until setting.
*/
public function get_ec_ddos_until() {
$response = $this->query_ec_backend( 'ddos_until' );
if ( false === $response['success'] || ! property_exists( $response['data'], 'ddos_until' ) ) {
return self::EC_ERROR;
}
return (int) $response['data']->ddos_until;
}
/**
* Send a query to the Edge Cache back-end.
*/
public function query_ec_backend( $endpoint='status', $args=[] ) {
$base_endpoint = sprintf( '%s/edge-cache/%s', SITE_API_BASE, ATOMIC_SITE_ID );
$base_args = array(
'timeout' => 2,
'method' => empty( $args ) ? 'GET' : 'POST',
'headers' => array( 'Auth' => ATOMIC_SITE_API_KEY ),
);
$wp_domain = $this->platform->get_domain_name();
if ( 'status' === $endpoint ) {
$endpoint = sprintf( '%s/%s/%s', $base_endpoint, 'get', $wp_domain );
} else {
$endpoint = sprintf( '%s/%s/%s', $base_endpoint, $endpoint, $wp_domain );
}
$data = array_merge( $base_args, $args );
$result = wp_remote_request( $endpoint, $data );
if ( is_wp_error( $result ) ) {
return array( 'success' => false, 'error' => $result->get_error_message() );
}
// Get HTTP code, this also checks if it's a WP_Error
$http_code = wp_remote_retrieve_response_code( $result );
if ( empty( $http_code ) ) {
return array( 'success' => false, 'error' => '' );
}
$body = wp_remote_retrieve_body( $result );
if ( empty( $body ) ) {
return array( 'success' => false, 'error' => '' );
}
$api_error_prefix = 'Edge Cache API:';
$lua_error_prefix = 'Edge Cache:';
// Attempt to json_decode the body as this could be atomic_api response with non 200 HTTP code.
$result = json_decode( (string) $result['body'], false );
$json_valid = JSON_ERROR_NONE === json_last_error();
$json_complete = $json_valid && is_object( $result ) && isset( $result->message ) && isset( $result->data );
// Detect rate limit hit.
if ( $json_complete && is_object( $result->data ) && isset( $result->data->purge_ratelimit ) ) {
$this->purge_ratelimit = true;
}
// Happy path.
if ( $json_complete && $result->message === 'OK' ) {
return [
'success' => true,
'data' => $result->data,
];
}
// Work out an appropriate error to report.
$error_message = 'unknown error';
if ( ! $json_valid ) {
$error_message = sprintf( '%s Invalid JSON response.', $api_error_prefix );
} else if ( ! $json_complete ) {
$error_message = sprintf( '%s Error while processing your request.', $api_error_prefix );
} else if ( 200 !== $http_code ) {
if ( in_array( $http_code, [ 400, 403 ], true ) ) {
$error_message = sprintf( '%s Authorization failed.', $lua_error_prefix );
} elseif ( 404 === $http_code ) {
$error_message = sprintf( '%s Authorization error.', $lua_error_prefix );
} else {
$error_message = sprintf( '%s Server error.', $lua_error_prefix );
}
}
$this->last_error = $error_message;
return [ 'success' => false, 'error' => $error_message ];
}
/**
* Return the last error received while communicating with the EC backend.
* @return null|string - Last error string, or null if no error.
*/
public function get_last_ec_error() {
return $this->last_error;
}
/**
* Immediately purge the domain, returning the result.
*/
public function purge_domain_now( $action = null ) {
return $this->purge->purge_domain_now();
}
/**
* Immediately purge the given uris, returning the result.
*/
public function purge_uris_now( $uri, $action = null ) {
return $this->purge->purge_uris_now( $uri, $action );
}
/**
* Get the purge manager object.
*/
public function get_purge_manager() {
return $this->purge;
}
/**
* Callback for action: update_option_woocommerce_coming_soon
* Callback for action: update_option_woocommerce_store_pages_only
* Purge the cache when WooCommerce "coming soon" or "store pages only" options change.
*
* @param $old_value int|null The previous value of option
* @param $value int|null The current value of option
* @param $option string Option name
*/
public function handle_woo_option_update( $old_value, $value, $option ) {
if ( in_array( $option, array( 'woocommerce_coming_soon', 'woocommerce_store_pages_only' ), true ) ) {
$this->purge->enqueue_purge_domain( 'update_option_' . $option );
}
}
}
add_action( 'init', array( Edge_Cache_Plugin::get_instance(), 'init' ) );