$method, 'timeout' => $timeout, 'headers' => array( 'Content-Type' => 'application/json', 'Accept' => 'application/json', 'X-ZC-Site' => (string) $o['site_id'], 'X-ZC-Version' => ZEROCONFIG_VERSION, 'Authorization' => 'Bearer ' . $o['site_token'], ), ); if ( null !== $body ) { $args['body'] = wp_json_encode( $body ); } $res = wp_remote_request( self::url( $path ), $args ); if ( is_wp_error( $res ) ) { ZeroConfig_Options::set( 'last_error', $res->get_error_message() ); return $res; } $code = (int) wp_remote_retrieve_response_code( $res ); $data = json_decode( wp_remote_retrieve_body( $res ), true ); if ( $code >= 400 ) { $msg = is_array( $data ) && ! empty( $data['error'] ) && is_string( $data['error'] ) ? sanitize_text_field( $data['error'] ) : 'HTTP ' . $code; ZeroConfig_Options::set( 'last_error', $msg ); return new WP_Error( 'zc_api', $msg, array( 'status' => $code, 'data' => $data ) ); } return is_array( $data ) ? $data : array(); } const EDGE_CHECK = 'zeroconfig_edge_check'; /** * Is the edge reachable? Answered from the stored state only: a page render never waits on the * network. When the state is older than its lifetime (5 minutes up, 1 minute down), a background * check is queued. While the edge is down the rewriter leaves original URLs in place. */ public static function edge_healthy() { if ( false === get_transient( 'zeroconfig_edge' ) && ! wp_next_scheduled( self::EDGE_CHECK ) ) { wp_schedule_single_event( time(), self::EDGE_CHECK ); } return 'down' !== get_option( 'zeroconfig_edge_last', 'up' ); } /** * Background check (WP-Cron), 2-second timeout. When the state flips, the page cache (which embeds * edge URLs) is purged once, so visitors never see broken images because of us. */ public static function check_edge() { if ( ! ZeroConfig_Options::get( 'engine' ) ) { return; // queued while the engine was on; nothing is sent once it is off } $res = wp_remote_get( rtrim( ZEROCONFIG_API, '/' ) . '/healthz', array( 'timeout' => 2 ) ); $ok = ! is_wp_error( $res ) && 200 === (int) wp_remote_retrieve_response_code( $res ); $prev = get_option( 'zeroconfig_edge_last', 'up' ); set_transient( 'zeroconfig_edge', $ok ? 'up' : 'down', $ok ? 5 * MINUTE_IN_SECONDS : MINUTE_IN_SECONDS ); if ( ( $ok ? 'up' : 'down' ) !== $prev ) { update_option( 'zeroconfig_edge_last', $ok ? 'up' : 'down', false ); ZeroConfig_Page_Cache::purge_all( 'edge' ); // cached HTML must not carry URLs for the other state } return $ok; } /** Free sites self-register. No form, no e-mail confirmation. */ /** Host we compare against, e.g. "example.com" or "localhost:8085" (www. ignored). */ public static function current_host() { $p = wp_parse_url( home_url() ); $h = strtolower( isset( $p['host'] ) ? $p['host'] : '' ) . ( isset( $p['port'] ) ? ':' . $p['port'] : '' ); return preg_replace( '/^www\./', '', $h ); } /** * A database copied to staging or a new domain carries the production site's * token. If this install now answers on another address, drop the connection: * the copy must prove ownership of its own address before using the edge. * Returns true when it disconnected. */ public static function guard_address( $reason = '' ) { $o = ZeroConfig_Options::all(); if ( empty( $o['site_token'] ) ) { return false; } $now = self::current_host(); if ( empty( $o['connected_host'] ) ) { ZeroConfig_Options::set( 'connected_host', $now ); // installs from before this check return false; } if ( $o['connected_host'] === $now && 'site_mismatch' !== $reason ) { return false; } ZeroConfig_Options::update( array( 'site_id' => '', 'site_token' => '', 'connected_host' => '', 'pending_reg' => array(), 'critical_css' => array(), 'last_selftest' => array(), 'quota' => array(), 'cdn_paused' => false, 'plan' => 'free', 'last_error' => sprintf( /* translators: 1: old address, 2: new address */ __( 'This site moved from %1$s to %2$s (a staging copy or a migration), so it was disconnected from the edge. Press Try again to confirm the new address.', 'zeroconfig' ), $o['connected_host'], $now ), ) ); ZeroConfig_Page_Cache::purge_all( 'settings' ); // cached pages carry the old site's edge URLs return true; } /** Admin e-mail is shared only when the admin ticked the box under "Turn on". */ public static function shared_email() { return ZeroConfig_Options::get( 'share_email' ) ? (string) get_option( 'admin_email' ) : ''; } public static function register_site() { if ( ! ZeroConfig_Options::get( 'engine' ) ) { return new WP_Error( 'zc_off', 'engine is off; the site is not connected' ); } $data = self::request( 'POST', 'sites/register', array( 'url' => home_url(), 'name' => get_bloginfo( 'name' ), 'email' => self::shared_email(), 'wp_version' => get_bloginfo( 'version' ), 'php_version' => PHP_VERSION, 'plugin_version' => ZEROCONFIG_VERSION, 'fingerprint' => ZeroConfig_Compat::fingerprint(), ), 10 ); if ( is_wp_error( $data ) ) { return $data; } if ( empty( $data['challenge'] ) || empty( $data['code'] ) || ! preg_match( '/^r_[A-Za-z0-9_-]{10,64}$/', (string) $data['challenge'] ) || ! preg_match( '/^[A-Za-z0-9_-]{16,128}$/', (string) $data['code'] ) ) { return new WP_Error( 'zc_api', 'unexpected registration response' ); } // The edge will fetch /?zc_verify= from this site and expects this code back. $ttl = isset( $data['expires_in'] ) ? max( 60, min( 3600, (int) $data['expires_in'] ) ) : 900; set_transient( self::verify_key( $data['challenge'] ), (string) $data['code'], $ttl ); ZeroConfig_Options::update( array( 'pending_reg' => array( 'challenge' => (string) $data['challenge'], 'code' => (string) $data['code'], 'at' => time(), 'ttl' => $ttl ), 'last_error' => '' ) ); wp_schedule_single_event( time() + 15, ZeroConfig_Cron::CLAIM ); // finish in the background if nobody reloads the page return array( 'status' => 'pending' ); } public static function verify_key( $challenge ) { return 'zeroconfig_verify_' . md5( (string) $challenge ); } /** Answer the edge's ownership check. Runs before themes and most plugins load. */ public static function serve_verification() { $c = isset( $_GET['zc_verify'] ) ? sanitize_text_field( wp_unslash( $_GET['zc_verify'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- public ownership check answered with a one-time code, no state change if ( ! preg_match( '/^r_[A-Za-z0-9_-]{10,64}$/', $c ) ) { return; } $code = get_transient( self::verify_key( $c ) ); nocache_headers(); header( 'X-Robots-Tag: noindex' ); header( 'Content-Type: text/plain; charset=utf-8' ); if ( ! $code ) { status_header( 404 ); exit; } echo esc_html( $code ); exit; } /** * Step 2: exchange a verified registration for the site token. * Returns 'connected', 'pending', or WP_Error when verification failed. */ public static function claim_pending() { $p = ZeroConfig_Options::get( 'pending_reg' ); if ( empty( $p['challenge'] ) ) { return ZeroConfig_Options::is_connected() ? 'connected' : new WP_Error( 'zc_api', 'no registration in progress' ); } $clear = function () use ( $p ) { delete_transient( self::verify_key( $p['challenge'] ) ); ZeroConfig_Options::set( 'pending_reg', array() ); }; if ( time() - (int) $p['at'] > (int) $p['ttl'] ) { $clear(); ZeroConfig_Options::set( 'last_error', __( 'Site verification timed out. Press Try again.', 'zeroconfig' ) ); return new WP_Error( 'zc_api', 'verification expired' ); } $data = self::request( 'POST', 'sites/claim', array( 'challenge' => $p['challenge'], 'code' => $p['code'] ), 10 ); if ( is_wp_error( $data ) ) { $status = (int) ( $data->get_error_data()['status'] ?? 0 ); if ( in_array( $status, array( 404, 422 ), true ) ) { $clear(); } return $data; } if ( empty( $data['site_token'] ) ) { return 'pending'; } $clear(); self::apply_site_payload( $data ); ZeroConfig_Options::set( 'connected_host', self::current_host() ); ZeroConfig_Engine::on_connected(); return 'connected'; } public static function activate_license( $key ) { $key = trim( (string) $key ); if ( ! ZeroConfig_Options::is_connected() || ! ZeroConfig_Options::get( 'engine' ) ) { // Not connected: the key stays here only. on_connected() sends it after Turn on. ZeroConfig_Options::set( 'license_key', $key ); return array( 'status' => 'saved' ); } $data = self::request( 'POST', 'license/activate', array( 'key' => $key, 'url' => home_url() ) ); if ( is_wp_error( $data ) ) { return $data; } ZeroConfig_Options::set( 'license_key', $key ); self::apply_site_payload( $data ); return $data; } public static function deactivate_license() { $connected = ZeroConfig_Options::is_connected() && ZeroConfig_Options::get( 'engine' ); if ( $connected ) { self::request( 'POST', 'license/deactivate', array( 'url' => home_url() ) ); } ZeroConfig_Options::update( array( 'license_key' => '', 'plan' => 'free' ) ); if ( $connected ) { self::heartbeat(); } } private static function heartbeat_body() { return array( 'url' => home_url(), 'plugin_version' => ZEROCONFIG_VERSION, 'wp_version' => get_bloginfo( 'version' ), 'engine' => (bool) ZeroConfig_Options::get( 'engine' ), 'email' => self::shared_email(), // '' clears it on the service 'badge' => (bool) ZeroConfig_Options::get( 'badge' ), 'disabled' => ZeroConfig_Options::get( 'disabled' ), 'fingerprint' => ZeroConfig_Compat::fingerprint(), ); } /** * Turn off (and deactivation): one last heartbeat with engine false, so the service stops its * scheduled visits to this site. After it nothing is sent until Turn on. */ public static function heartbeat_off() { if ( ! ZeroConfig_Options::is_connected() ) { return; } self::$final_call = true; self::request( 'POST', 'sites/heartbeat', self::heartbeat_body(), 5 ); self::$final_call = false; } public static function heartbeat() { if ( ! ZeroConfig_Options::is_connected() ) { $p = ZeroConfig_Options::get( 'pending_reg' ); return empty( $p['challenge'] ) ? self::register_site() : self::claim_pending(); } $data = self::request( 'POST', 'sites/heartbeat', self::heartbeat_body() ); if ( is_wp_error( $data ) ) { // Token no longer valid (site forgotten, or re-verified from another install): reconnect with a fresh proof. $status = (int) ( $data->get_error_data()['status'] ?? 0 ); if ( 401 === $status ) { ZeroConfig_Options::update( array( 'site_token' => '' ) ); return self::register_site(); } // The service saw this token arrive from another address than the one it was issued for. if ( 409 === $status && 'site_mismatch' === ( $data->get_error_data()['data']['code'] ?? '' ) ) { self::guard_address( 'site_mismatch' ); } return $data; } self::apply_site_payload( $data ); return $data; } public static function fetch_rules() { $data = self::request( 'POST', 'rules', array( 'fingerprint' => ZeroConfig_Compat::fingerprint() ) ); if ( is_wp_error( $data ) ) { return $data; } ZeroConfig_Options::update( array( 'rules' => ZeroConfig_Compat::clean_rules( $data ), 'rules_fetched' => time() ) ); return $data; } public static function request_critical( array $urls ) { return self::request( 'POST', 'critical', array( 'urls' => $urls ) ); } public static function get_critical() { return self::request( 'GET', 'critical' ); } public static function request_selftest( array $urls, $mode = 'normal' ) { return self::request( 'POST', 'selftest', array( 'urls' => $urls, 'mode' => $mode ) ); } public static function get_selftest( $job_id ) { return self::request( 'GET', 'selftest/' . rawurlencode( $job_id ) ); } public static function purge() { return self::request( 'POST', 'purge', array( 'url' => home_url() ), 10 ); } public static function report_issue( $message, $url = '' ) { return self::request( 'POST', 'issues', array( 'message' => (string) $message, 'url' => (string) $url, 'fingerprint' => ZeroConfig_Compat::fingerprint(), 'state' => array( 'features' => ZeroConfig_Options::get( 'features' ), 'disabled' => ZeroConfig_Options::get( 'disabled' ), ), ) ); } /** * Critical CSS is printed raw inside