HEX
Server: nginx
System: Linux pool64-304-45.dca.atomicsites.net 5.10.0-31-amd64 #1 SMP Debian 5.10.221-1 (2024-07-14) x86_64
User: (0)
PHP: 8.4.18
Disabled: pcntl_fork
Upload Files
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' ) );