*/ final class Optml_Manager { /** * Holds allowed compatibilities objects. * * @var Optml_compatibility[] Compatibilities objects. */ public static $loaded_compatibilities = []; /** * Cached object instance. * * @var Optml_Manager */ protected static $instance = null; /** * Holds the url replacer class. * * @access public * @since 1.0.0 * @var Optml_Url_Replacer Replacer instance. */ public $url_replacer; /** * Holds the tag replacer class. * * @access public * @since 1.0.0 * @var Optml_Tag_Replacer Replacer instance. */ public $tag_replacer; /** * Holds the lazyload replacer class. * * @access public * @since 1.0.0 * @var Optml_Lazyload_Replacer Replacer instance. */ public $lazyload_replacer; /** * Holds the page profiler class. * * @access public * @since 1.0.0 * @var Profile Page profiler instance. */ public $page_profiler; /** * Holds plugin settings. * * @var Optml_Settings WordPress settings. */ protected $settings; /** * Possible integrations with different plugins. * * @var array Integrations classes. */ private $possible_compatibilities = [ 'kadence_blocks', 'essential_blocks', 'shortcode_ultimate', 'foogallery', 'envira', 'beaver_builder', 'jet_elements', 'revslider', 'metaslider', 'essential_grid', 'yith_quick_view', 'cache_enabler', 'elementor_builder', 'divi_builder', 'thrive', 'master_slider', 'pinterest', 'sg_optimizer', 'wp_fastest_cache', 'swift_performance', 'w3_total_cache', 'translate_press', 'give_wp', 'woocommerce', 'smart_search_woocommerce', 'facetwp', 'wp_rest_cache', 'wp_bakery', 'elementor_builder_late', 'wpml', 'otter_blocks', 'spectra', 'wpsp', 'jetengine', 'jetpack', 'jetpack_photon_compatibility', 'wp_rocket', 'wp_super_cache', 'breeze', 'litespeed_cache', 'autoptimize_cache', 'endurance_cache', 'rocketnet', 'speedycache', 'hummingbird', 'aruba_hsc', 'spc', ]; /** * The current state of the buffer. * * @var boolean Buffer state. */ private static $ob_started = false; /** * The output-buffer nesting level of our capture buffer. * * Used to make sure we only ever capture or close our own buffer and not * one started by a third party. * * @var int Buffer nesting level, 0 when no capture buffer is armed. */ private static $ob_level = 0; /** * Whether the captured buffer was already processed at shutdown. * * When true, the fallback output handler passes content through untouched. * * @var boolean Processed state. */ private static $ob_processed = false; /** * Class instance method. * * @codeCoverageIgnore * @static * @return Optml_Manager * @since 1.0.0 * @access public */ public static function instance() { if ( null === self::$instance ) { self::$instance = new self(); self::$instance->url_replacer = Optml_Url_Replacer::instance(); self::$instance->tag_replacer = Optml_Tag_Replacer::instance(); self::$instance->lazyload_replacer = Optml_Lazyload_Replacer::instance(); self::$instance->page_profiler = new Profile(); add_action( 'after_setup_theme', [ self::$instance, 'init' ] ); add_action( 'wp_footer', [ self::$instance, 'banner' ] ); } return self::$instance; } /** * The initialize method. */ public function init() { $this->settings = new Optml_Settings(); $added_sizes = $this->settings->get( 'defined_image_sizes' ); foreach ( $added_sizes as $key => $value ) { add_image_size( $key, $value['width'], $value['height'], true ); } foreach ( $this->possible_compatibilities as $compatibility_class ) { $compatibility_class = 'Optml_' . $compatibility_class; $compatibility = new $compatibility_class(); /** * Check if we should load compatibility. * * @var Optml_compatibility $compatibility Class to register. */ if ( $compatibility->will_load() ) { if ( $compatibility->should_load_early() ) { $compatibility->register(); continue; } self::$loaded_compatibilities[ $compatibility_class ] = $compatibility; } } if ( ! $this->should_replace() ) { return; } $this->register_hooks(); } /** * Render banner. * * @return void */ public function banner() { if ( defined( 'REST_REQUEST' ) ) { return; } $has_banner = $this->settings->get( 'banner_frontend' ) === 'enabled'; if ( ! $has_banner ) { return; } $position = $this->settings->get( 'badge_position' ) ?? 'right'; $show_icon_only = $this->settings->get( 'show_badge_icon' ) === 'enabled'; $string = __( 'Optimized by Optimole', 'optimole-wp' ); $div_style = [ 'display' => 'flex', 'position' => 'fixed', 'align-items' => 'center', 'bottom' => '15px', 'background-color' => '#fff', 'padding' => '8px 6px', 'font-size' => '12px', 'font-weight' => '600', 'color' => '#444', 'border' => '1px solid #E9E9E9', 'z-index' => '9999999999', 'border-radius' => '4px', 'text-decoration' => 'none', 'font-family' => 'Arial, Helvetica, sans-serif', ]; $div_style[ $position ] = '15px'; $logo = OPTML_URL . 'assets/img/logo.svg'; $link = tsdk_translate_link( 'https://optimole.com/wordpress/?from=badgeOn' ); $css = ''; foreach ( $div_style as $key => $value ) { $css .= $key . ':' . $value . ';'; } $output = ''; $output .= ' '; if ( ! $show_icon_only ) { $output .= '' . esc_html( $string ) . ''; } $output .= ''; echo $output; } /** * Check if we should rewrite the urls. * * @return bool If we can replace the image. */ public function should_replace() { if ( apply_filters( 'optml_should_replace_page', false ) ) { return false; } if ( apply_filters( 'optml_force_replacement', false ) === true ) { return true; } if ( is_customize_preview() && $this->settings->get( 'offload_media' ) !== 'enabled' ) { return false; } if ( ( is_admin() && ! self::is_ajax_request() ) || ! $this->settings->is_connected() || ! $this->settings->is_enabled() ) { return false; // @codeCoverageIgnore } if ( array_key_exists( 'preview', $_GET ) && ! empty( $_GET['preview'] ) && ! $this->settings->is_offload_enabled() ) { return false; // @codeCoverageIgnore } if ( array_key_exists( 'optml_off', $_GET ) && 'true' === $_GET['optml_off'] ) { return false; // @codeCoverageIgnore } if ( array_key_exists( 'elementor-preview', $_GET ) && ! empty( $_GET['elementor-preview'] ) ) { return false; // @codeCoverageIgnore } if ( array_key_exists( 'ct_builder', $_GET ) && ! empty( $_GET['ct_builder'] ) ) { return false; // @codeCoverageIgnore } if ( array_key_exists( 'et_fb', $_GET ) && ! empty( $_GET['et_fb'] ) ) { return false; // @codeCoverageIgnore } if ( array_key_exists( 'tve', $_GET ) && $_GET['tve'] === 'true' ) { return false; // @codeCoverageIgnore } if ( array_key_exists( 'trp-edit-translation', $_GET ) && ( $_GET['trp-edit-translation'] === 'true' || $_GET['trp-edit-translation'] === 'preview' ) ) { return false; // @codeCoverageIgnore } if ( array_key_exists( 'context', $_GET ) && $_GET['context'] === 'edit' ) { return false; // @codeCoverageIgnore } // avada if ( array_key_exists( 'fb-edit', $_GET ) && ! empty( $_GET['fb-edit'] ) ) { return false; // @codeCoverageIgnore } if ( array_key_exists( 'builder', $_GET ) && ! empty( $_GET['builder'] ) && array_key_exists( 'builder_id', $_GET ) && ! empty( $_GET['builder_id'] ) ) { return false; // @codeCoverageIgnore } // Motion.page iFrame & builder if ( ( array_key_exists( 'motionpage_iframe', $_GET ) && $_GET['motionpage_iframe'] === 'true' ) || ( array_key_exists( 'page', $_GET ) && $_GET['page'] === 'motionpage' ) ) { // phpcs:ignore WordPress.PHP.StrictComparisons.LooseComparison return false; // @codeCoverageIgnore } /** * Disable replacement on POST request and when user is logged in, but allows for sample image call widget in dashboard */ if ( isset( $_SERVER['REQUEST_METHOD'] ) && $_SERVER['REQUEST_METHOD'] === 'POST' && is_user_logged_in() && ( ! isset( $_GET['quality'] ) || ! current_user_can( 'manage_options' ) ) ) { return false; // @codeCoverageIgnore } if ( class_exists( 'FLBuilderModel', false ) ) { $post_data = FLBuilderModel::get_post_data(); if ( isset( $_GET['fl_builder'] ) || isset( $post_data['fl_builder'] ) ) { return false; } } $filters = $this->settings->get_filters(); return Optml_Filters::should_do_page( $filters[ Optml_Settings::FILTER_TYPE_OPTIMIZE ][ Optml_Settings::FILTER_URL ], $filters[ Optml_Settings::FILTER_TYPE_OPTIMIZE ][ Optml_Settings::FILTER_URL_MATCH ] ); } /** * Check if we are in a ajax contex where we should enable replacement. * * @return bool Is ajax request? */ public static function is_ajax_request() { if ( apply_filters( 'optml_force_replacement_on', false ) === true ) { return true; } if ( ! function_exists( 'is_user_logged_in' ) ) { return false; } // Disable for logged in users to avoid unexpected results. if ( is_user_logged_in() ) { return false; } if ( ! function_exists( 'wp_doing_ajax' ) ) { return false; } if ( ! wp_doing_ajax() ) { return false; } if ( isset( $_REQUEST['action'] ) && strpos( $_REQUEST['action'], 'wpmdb' ) !== false ) { return false; } return true; } /** * Register frontend replacer hooks. */ public function register_hooks() { do_action( 'optml_replacer_setup' ); if ( $this->settings->get( 'native_lazyload' ) === 'disabled' ) { add_filter( 'wp_lazy_loading_enabled', '__return_false' ); } add_filter( 'the_content', [ $this, 'process_images_from_content' ], PHP_INT_MAX ); /** * When we have to process cdn images, i.e MIRROR is defined, * we need this as late as possible for other replacers to occur. * Otherwise, we can hook first to avoid any other plugins to take care of replacement. */ add_action( self::is_ajax_request() ? 'init' : 'template_redirect', [ $this, 'process_template_redirect_content', ], defined( 'OPTML_SITE_MIRROR' ) ? PHP_INT_MAX : PHP_INT_MIN ); add_action( 'template_redirect', [ $this, 'register_after_setup' ] ); add_action( 'rest_api_init', [ $this, 'process_template_redirect_content' ], PHP_INT_MIN ); add_action( 'shutdown', [ $this, 'close_buffer' ], PHP_INT_MIN ); add_action( 'shutdown', [ $this, 'close_final_buffer' ], PHP_INT_MAX ); foreach ( self::$loaded_compatibilities as $registered_compatibility ) { $registered_compatibility->register(); } } /** * Run after Optimole is fully setup. */ public function register_after_setup() { do_action( 'optml_after_setup' ); } /** * Check if we should load the profiler. * * @param bool $default_value Default value. * * @return bool Should load the profiler. */ public static function should_load_profiler( $default_value = false ) { return ! $default_value && apply_filters( 'optml_page_profiler_disable', false ) === false; } /** * Decide if the temporary Cache-Control header can be sent while page profiling is pending. * * The header must never be sent on non-cacheable pages: it would replace a * Cache-Control header already set by WordPress or another plugin (PHP's * header() replaces same-name headers by default), e.g. WooCommerce's * no-cache header on cart and checkout, letting proxies cache user-specific * pages. We back off when DONOTCACHEPAGE is set or when any Cache-Control * header exists already, and let developers override the decision. * * @param array|null $sent_headers Headers already set for the response; defaults to headers_list(). * @param bool|null $do_not_cache Whether the page is flagged as non-cacheable; defaults to the DONOTCACHEPAGE constant. * * @return bool Whether the header can be sent. */ public function should_send_temporary_cache_header( $sent_headers = null, $do_not_cache = null ) { if ( null === $do_not_cache ) { $do_not_cache = defined( 'DONOTCACHEPAGE' ) && DONOTCACHEPAGE; } $send = ! $do_not_cache; if ( $send ) { if ( null === $sent_headers ) { $sent_headers = headers_list(); } foreach ( $sent_headers as $header ) { if ( stripos( $header, 'cache-control:' ) === 0 ) { $send = false; break; } } } /** * Filters whether the temporary `Cache-Control: max-age=300` header is sent * while page profiling is pending for the current page. * * @param bool $send Computed decision: false when DONOTCACHEPAGE is set or a Cache-Control header exists already. */ return apply_filters( 'optml_send_temporary_cache_header', $send ) === true; } /** * Filter raw HTML content for urls. * * @param string $html HTML to filter. * @param bool $partial If this is a partial content replacement and not a full page. It matters when we are are doing full page optimization like viewport lazyload. * * @return mixed Filtered content. */ public function replace_content( $html, $partial = false ) { if ( defined( 'REST_REQUEST' ) && REST_REQUEST && is_user_logged_in() && ( apply_filters( 'optml_force_replacement', false ) !== true ) ) { return $html; } if ( self::should_load_profiler( $partial ) ) { $profile_id = Profile::generate_id( $html ); $should_show_comment = false; // We disable the optimizer for logged in users. if ( ! is_user_logged_in() || ! apply_filters( 'optml_force_page_profiler', false ) !== true ) { $js_optimizer = Optml_Admin::get_optimizer_script( false ); if ( ! $this->page_profiler->exists_all( $profile_id ) ) { $missing = $this->page_profiler->missing_devices( $profile_id ); $time = time(); $url = esc_url( esc_js( $this->get_current_url() ) ); $hmac = wp_hash( $profile_id . $time . $url, 'nonce' ); $js_optimizer = str_replace( [ Profile::PLACEHOLDER, Profile::PLACEHOLDER_MISSING, Profile::PLACEHOLDER_TIME, Profile::PLACEHOLDER_HMAC, Profile::PLACEHOLDER_URL ], [ $profile_id, implode( ',', $missing ), strval( $time ), $hmac, $url ], $js_optimizer ); $html = str_replace( Optml_Admin::get_optimizer_script( true ), $js_optimizer, $html ); if ( ! headers_sent() && $this->should_send_temporary_cache_header() ) { header( 'Cache-Control: max-age=300' ); // Attempt to cache the page just for 5 mins until the optimizer is done. Once the optimizer is done, the page will load optimized. } } else { $should_show_comment = isset( $_GET['optml_debug'] ) && $_GET['optml_debug'] === 'true'; } } Profile::set_current_profile_id( $profile_id ); $this->page_profiler->set_current_profile_data(); if ( $should_show_comment ) { $html = str_replace( '', '' . $this->page_profiler->get_current_profile_html_comment(), $html ); } } if ( ! $partial ) { $html = $this->add_html_class( $html ); } $html = $this->process_images_from_content( $html ); if ( $this->settings->get( 'video_lazyload' ) === 'enabled' ) { $html = apply_filters( 'optml_video_replace', $html ); if ( Optml_Lazyload_Replacer::found_iframe() === true ) { if ( strpos( $html, Optml_Lazyload_Replacer::IFRAME_TEMP_COMMENT ) !== false ) { $html = str_replace( Optml_Lazyload_Replacer::IFRAME_TEMP_COMMENT, Optml_Lazyload_Replacer::IFRAME_PLACEHOLDER_CLASS, $html ); } else { $html = preg_replace( '/(.*)<\/head>/ism', ' $1' . Optml_Lazyload_Replacer::IFRAME_PLACEHOLDER_STYLE . '', $html ); } } } if ( $this->settings->is_lazyload_type_viewport() && ! $partial ) { $personalized_bg_css = Lazyload::get_current_personalized_css(); if ( OPTML_DEBUG ) { do_action( 'optml_log', 'viewport_bgselectorsdata: ' . print_r( $personalized_bg_css, true ) ); } if ( ! empty( $personalized_bg_css ) && ( $start_pos = strpos( $html, Lazyload::MARKER ) ) !== false ) { // phpcs:ignore Generic.CodeAnalysis.AssignmentInCondition.Found // We replace the general bg css with the personalized one. if ( ( $end_pos = strpos( $html, Lazyload::MARKER, $start_pos + strlen( Lazyload::MARKER ) ) ) !== false ) { // phpcs:ignore Generic.CodeAnalysis.AssignmentInCondition.Found $html = substr_replace( $html, $personalized_bg_css, $start_pos, $end_pos + strlen( Lazyload::MARKER ) - $start_pos ); } } } if ( ! $partial ) { // WE need this last since during bg personalized CSS we collect preload urls if ( Links::get_links_count() > 0 ) { if ( OPTML_DEBUG ) { do_action( 'optml_log', 'preload_links: ' . print_r( Links::get_links(), true ) ); } $html = str_replace( Optml_Admin::get_preload_links_placeholder(), Links::get_links_html(), $html ); } else { $html = str_replace( Optml_Admin::get_preload_links_placeholder(), '', $html ); } } $html = apply_filters( 'optml_url_pre_process', $html ); $html = $this->process_urls_from_content( $html ); $html = apply_filters( 'optml_url_post_process', $html ); if ( self::should_load_profiler( $partial ) ) { Profile::reset_current_profile(); } return $html; } /** * Get the current url. * * @return string The current url. */ private function get_current_url() { global $wp; return home_url( add_query_arg( [], $wp->request ) ); } /** * Adds a filter that allows adding classes to the HTML tag. * * @param string $content The HTML content. * * @return mixed */ public function add_html_class( $content ) { if ( empty( $content ) ) { return $content; } $additional_html_classes = apply_filters( 'optml_additional_html_classes', [] ); if ( ! $additional_html_classes ) { return $content; } if ( preg_match( '//ismU', $content, $matches, PREG_OFFSET_CAPTURE ) === 1 ) { $add_classes = implode( ' ', $additional_html_classes ); foreach ( $matches as $match ) { if ( strpos( $match[0], 'class' ) !== false ) { $new_tag = str_replace( [ 'class="', "class='" ], [ 'class="' . $add_classes, "class='" . $add_classes ], $match[0] ); } else { $new_tag = str_replace( 'html ', 'html class="' . $add_classes . '" ', $match[0] ); } $content = str_replace( $match[0], $new_tag, $content ); } } return $content; } /** * Adds a filter with detected images tags and the content. * * @param string $content The HTML content. * * @return mixed */ public function process_images_from_content( $content ) { if ( self::should_ignore_image_tags() ) { return $content; } $images = self::parse_images_from_html( $content ); if ( empty( $images ) ) { return $content; } return apply_filters( 'optml_content_images_tags', $content, $images ); } /** * Check if we are on a amp endpoint. * * IMPORTANT: This needs to be used after parse_query hook, otherwise will return false positives. * * @return bool */ public static function should_ignore_image_tags() { // Ignore image tag replacement in feed context as we don't need it. if ( is_feed() ) { return true; } // Ignore image tags replacement in amp context as they are not available. if ( function_exists( 'is_amp_endpoint' ) ) { return is_amp_endpoint(); } if ( function_exists( 'ampforwp_is_amp_endpoint' ) ) { return ampforwp_is_amp_endpoint(); } return apply_filters( 'optml_should_ignore_image_tags', false ) === true; } /** * Match all images and any relevant tags in a block of HTML. * * @param string $content Some HTML. * * @return array An array of $images matches, where $images[0] is * an array of full matches, and the link_url, img_tag, * and img_url keys are arrays of those matches. */ public static function parse_images_from_html( $content ) { $images = []; if ( OPTML_DEBUG ) { do_action( 'optml_log', 'Content to parse images from: ' . $content ); } $regex = '/(?:]+?href=["|\'](?P[^\s]+?)["|\'][^>]*?>\s*)?(?P(?:\s*)?]*?\s?(?:' . implode( '|', array_merge( [ 'src' ], Optml_Tag_Replacer::possible_src_attributes() ) ) . ')=["\'\\\\]*?(?P[' . Optml_Config::$chars . ']{10,}).*?>(?:\s*<\/noscript\s*>)?){1}(?:\s*<\/a>)?/ismu'; if ( preg_match_all( $regex, $content, $images, PREG_OFFSET_CAPTURE ) ) { if ( OPTML_DEBUG ) { do_action( 'optml_log', 'Image tags parsed: ' . print_r( $images, true ) ); } foreach ( $images as $key => $unused ) { // Simplify the output as much as possible, mostly for confirming test results. if ( is_numeric( $key ) && $key > 0 ) { unset( $images[ $key ] ); continue; } $is_no_script = false; foreach ( $unused as $url_key => $url_value ) { if ( $key === 'img_url' ) { $images[ $key ][ $url_key ] = rtrim( $url_value[0], '\\' ); continue; } $images[ $key ][ $url_key ] = $url_value[0]; if ( $key === 0 ) { $images['in_no_script'][ $url_key ] = false; // Check if we are in the noscript context. if ( $is_no_script === false ) { $is_no_script = strpos( $images[0][ $url_key ], 'extract_urls_from_content( $html ); if ( OPTML_DEBUG ) { do_action( 'optml_log', 'Extracted image urls from content: ' . print_r( $extracted_urls, true ) ); } return $this->do_url_replacement( $html, $extracted_urls ); } /** * Method to extract assets from content. * * @param string $content The HTML content. * * @return array */ public function extract_urls_from_content( $content ) { $extensions = array_keys( Optml_Config::$image_extensions ); if ( $this->settings->use_cdn() && ! self::should_ignore_image_tags() ) { $extensions = array_merge( $extensions, array_keys( Optml_Config::$assets_extensions ) ); } $regex = '/(?:[(|\s\';",=\]])((?:http|\/|\\\\){1}(?:[' . Optml_Config::$chars . ']{10,}\.(?:' . implode( '|', $extensions ) . ')))(?=(?:http|>|%3F|\?|"|&|,|\s|\'|\)|\||\\\\|}|\[))/Uu'; preg_match_all( $regex, $content, $urls ); return $this->normalize_urls( $urls[1] ); } /** * Normalize extracted urls. * * @param array $urls Raw urls extracted. * * @return array Normalized array. */ private function normalize_urls( $urls ) { $urls = array_map( function ( $value ) { $value = str_replace( '"', '', $value ); return rtrim( $value, '\\";\'' ); }, $urls ); $urls = array_unique( $urls ); return array_values( $urls ); } /** * Process string content and replace possible urls. * * @param string $html String content. * @param array $extracted_urls Urls to check. * * @return string Processed html. */ public function do_url_replacement( $html, $extracted_urls ) { $extracted_urls = apply_filters( 'optml_extracted_urls', $extracted_urls ); if ( empty( $extracted_urls ) ) { return $html; } $slashed_config = addcslashes( Optml_Config::$service_url, '/' ); $extracted_urls = array_filter( $extracted_urls, function ( $value ) use ( $slashed_config ) { return strpos( $value, Optml_Config::$service_url ) === false && strpos( $value, $slashed_config ) === false || Optml_Media_Offload::is_not_processed_image( $value ) || $this->tag_replacer->url_has_dam_flag( $value ); } ); $upload_resource = $this->tag_replacer->get_upload_resource(); $urls = array_combine( $extracted_urls, $extracted_urls ); $urls = array_map( function ( $url ) use ( $upload_resource ) { $is_slashed = strpos( $url, '\/' ) !== false; $is_relative = strpos( $url, $is_slashed ? addcslashes( $upload_resource['content_path'], '/' ) : $upload_resource['content_path'] ) === 0; if ( $is_relative ) { $url = $upload_resource['content_host'] . $url; } return apply_filters( 'optml_content_url', $url ); }, $urls ); /* * Replace all URLs in a single pass per chunk instead of one full-page * preg_replace() per URL, which scanned and rebuilt the whole page for * every replaced URL. Chunks are bounded by pattern size, not only * count, so the compiled regex stays within PCRE's ~64KB limit even * for very long URLs (e.g. signed CDN URLs with kilobyte-sized query * strings). Each chunk is applied as soon as it fills, so only one * chunk's bookkeeping is in memory at a time. */ $chunk = []; $quoted = []; $quoted_size = 0; foreach ( $urls as $origin => $replace ) { $quoted_origin = preg_quote( $origin, '/' ); if ( ! empty( $chunk ) && ( count( $chunk ) >= 200 || $quoted_size + strlen( $quoted_origin ) > 24000 ) ) { $html = $this->replace_urls_chunk( $html, $chunk, $quoted ); $chunk = []; $quoted = []; $quoted_size = 0; } $chunk[ $origin ] = $replace; $quoted[] = $quoted_origin; $quoted_size += strlen( $quoted_origin ) + 1; } if ( ! empty( $chunk ) ) { $html = $this->replace_urls_chunk( $html, $chunk, $quoted ); } return $html; } /** * Replace one chunk of URLs in the content with a single combined pattern. * * @param string $html Content to process. * @param array $chunk Map of origin => replacement URLs. * @param string[] $quoted The preg_quote()d origins, in the same order. * * @return string Processed content, unchanged when the pattern fails. */ private function replace_urls_chunk( $html, $chunk, $quoted ) { $result = preg_replace_callback( '/(?start_capture_buffer(); } /** * Start an output buffer that captures the page HTML. * * On normal requests the buffer is captured and processed by close_buffer() * at shutdown, outside of PHP's display-handler context, so callbacks hooked * into our filters are free to use output buffering themselves and fatal * errors raised during processing keep their real message instead of being * masked by "Cannot use output buffering in output buffering display handlers". * * The attached handler is only a fallback for buffers flushed outside of * close_buffer() — third-party force-flush loops, ob_flush() streaming, or * core's wp_ob_end_flush_all() reaching the re-armed buffer. A named method * is used instead of a closure so the buffer can be identified as ours via * ob_get_status()['name']. * * @return void */ private function start_capture_buffer() { self::$ob_processed = false; ob_start( [ $this, 'handle_buffer_fallback' ] ); self::$ob_level = ob_get_level(); } /** * The handler name PHP reports for our capture buffer in ob_get_status(). */ const OB_HANDLER_NAME = 'Optml_Manager::handle_buffer_fallback'; /** * Output-buffer handler attached to our capture buffer. * * Runs only when the buffer is flushed outside of close_buffer(). Content is * passed through UNPROCESSED here: running the replacement filter graph * inside a PHP display handler would turn any third-party ob_*() call into * an uncatchable fatal ("Cannot use output buffering in output buffering * display handlers") — the very crash this rework removes. The only * exception is the legacy mode selected via the optml_capture_at_shutdown * filter, which explicitly restores the previous in-handler processing. * * @param string $content The buffered content. * @param int $phase PHP's output-handler phase bitmask (unused; keeps replace_content()'s $partial parameter shielded from it). * * @return string The content to output. */ public function handle_buffer_fallback( $content, $phase = 0 ) { if ( self::$ob_processed || $content === '' ) { return $content; } if ( apply_filters( 'optml_capture_at_shutdown', true ) === false ) { try { return $this->replace_content( $content, self::is_ajax_request() ); } catch ( Throwable $t ) { // Never break the page from inside a display handler. do_action( 'optml_log', 'replace_content failed inside the output handler: ' . $t->getMessage() ); return $content; } } do_action( 'optml_log', 'Optimole buffer was flushed outside close_buffer(); content passed through unprocessed.' ); return $content; } /** * Close the buffer and flush the content. */ public function close_buffer() { if ( ! self::$ob_started ) { return; } /** * Filters whether the captured page is processed at shutdown, outside of * PHP's display-handler context. Return false to restore the legacy * behavior of processing inside the output-buffer handler. * * @param bool $capture_at_shutdown Whether to process the buffer at shutdown. */ if ( apply_filters( 'optml_capture_at_shutdown', true ) === false ) { if ( ob_get_length() ) { ob_end_flush(); } return; } /* * Flush the buffers other plugins stacked on top of ours so their * handlers still transform the page before we process it, preserving * the same order as a full top-down flush at request shutdown. */ while ( ob_get_level() > self::$ob_level ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a non-flushable buffer must not raise a notice; we stop on failure. if ( ! @ob_end_flush() ) { break; } } if ( ! $this->capture_and_process_buffer() ) { do_action( 'optml_log', 'Optimole buffer was closed earlier by third-party code.' ); return; } /* * Re-arm the capture so output echoed by later shutdown callbacks is * still processed and unguarded third-party flush calls find a buffer * to close instead of raising a notice. */ $this->start_capture_buffer(); } /** * Close the re-armed buffer at the very end of shutdown. * * @return void */ public function close_final_buffer() { if ( ! self::$ob_started ) { return; } $this->capture_and_process_buffer(); } /** * Capture our buffer, process it outside the display-handler context and echo the result. * * Ownership is verified by both nesting level and handler identity, so a * buffer another plugin opened at the same level after ours was closed is * never captured or closed by us. * * @return bool Whether our buffer was found and consumed. */ private function capture_and_process_buffer() { if ( self::$ob_level === 0 || ob_get_level() !== self::$ob_level ) { return false; } $status = ob_get_status(); if ( ( $status['name'] ?? '' ) !== self::OB_HANDLER_NAME ) { return false; } $html = ob_get_contents(); // Set before ob_end_clean() so our handler no-ops during buffer cleanup. self::$ob_processed = true; ob_end_clean(); if ( $html !== false && $html !== '' ) { echo $this->replace_content( $html, self::is_ajax_request() ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- full page HTML, escaping would break the page. } return true; } /** * Throw error on object clone * * The whole idea of the singleton design pattern is that there is a single * object therefore, we don't want the object to be cloned. * * @codeCoverageIgnore * @access public * @return void * @since 1.0.0 */ public function __clone() { // Cloning instances of the class is forbidden. _doing_it_wrong( __FUNCTION__, esc_html__( 'Cheatin’ huh?', 'optimole-wp' ), '1.0.0' ); } /** * Disable unserializing of the class * * @codeCoverageIgnore * @access public * @return void * @since 1.0.0 */ public function __wakeup() { // Unserializing instances of the class is forbidden. _doing_it_wrong( __FUNCTION__, esc_html__( 'Cheatin’ huh?', 'optimole-wp' ), '1.0.0' ); } }