PluginProbe
Optimole – Optimize Images | Convert WebP & AVIF | CDN & Lazy Load | Image Optimization / 4.2.13
Optimole – Optimize Images | Convert WebP & AVIF | CDN & Lazy Load | Image Optimization v4.2.13
4.2.13 4.2.12 4.2.11 4.2.10 4.2.9 4.2.8 4.2.7 4.2.6 4.2.5 2.5.5 2.5.6 2.5.7 3.0.0 3.0.1 3.1.0 3.1.1 3.1.2 3.1.3 3.10.0 3.11.0 3.11.1 3.11.2 3.11.3 3.12.0 3.12.1 All 134 releases
← All changes | inc/manager.php +251 -18 4.2.54.2.13 View file →
@@ -103,8 +103,9 @@
103 103 'spectra',
104 104 'wpsp',
105 105 'jetengine',
106 106 'jetpack',
107 + 'jetpack_photon_compatibility',
107 108 'wp_rocket',
108 109 'wp_super_cache',
109 110 'breeze',
110 111 'litespeed_cache',
@@ -121,8 +122,25 @@
121 122 *
122 123 * @var boolean Buffer state.
123 124 */
124 125 private static $ob_started = false;
126 + /**
127 + * The output-buffer nesting level of our capture buffer.
128 + *
129 + * Used to make sure we only ever capture or close our own buffer and not
130 + * one started by a third party.
131 + *
132 + * @var int Buffer nesting level, 0 when no capture buffer is armed.
133 + */
134 + private static $ob_level = 0;
135 + /**
136 + * Whether the captured buffer was already processed at shutdown.
137 + *
138 + * When true, the fallback output handler passes content through untouched.
139 + *
140 + * @var boolean Processed state.
141 + */
142 + private static $ob_processed = false;
125 143
126 144 /**
127 145 * Class instance method.
128 146 *
@@ -407,8 +425,9 @@
407 425 );
408 426 add_action( 'template_redirect', [ $this, 'register_after_setup' ] );
409 427 add_action( 'rest_api_init', [ $this, 'process_template_redirect_content' ], PHP_INT_MIN );
410 428 add_action( 'shutdown', [ $this, 'close_buffer' ], PHP_INT_MIN );
429 + add_action( 'shutdown', [ $this, 'close_final_buffer' ], PHP_INT_MAX );
411 430 foreach ( self::$loaded_compatibilities as $registered_compatibility ) {
412 431 $registered_compatibility->register();
413 432 }
414 433 }
@@ -429,9 +448,51 @@
429 448 */
430 449 public static function should_load_profiler( $default_value = false ) {
431 450 return ! $default_value && apply_filters( 'optml_page_profiler_disable', false ) === false;
432 451 }
452 +
433 453 /**
454 + * Decide if the temporary Cache-Control header can be sent while page profiling is pending.
455 + *
456 + * The header must never be sent on non-cacheable pages: it would replace a
457 + * Cache-Control header already set by WordPress or another plugin (PHP's
458 + * header() replaces same-name headers by default), e.g. WooCommerce's
459 + * no-cache header on cart and checkout, letting proxies cache user-specific
460 + * pages. We back off when DONOTCACHEPAGE is set or when any Cache-Control
461 + * header exists already, and let developers override the decision.
462 + *
463 + * @param array<int, string>|null $sent_headers Headers already set for the response; defaults to headers_list().
464 + * @param bool|null $do_not_cache Whether the page is flagged as non-cacheable; defaults to the DONOTCACHEPAGE constant.
465 + *
466 + * @return bool Whether the header can be sent.
467 + */
468 + public function should_send_temporary_cache_header( $sent_headers = null, $do_not_cache = null ) {
469 + if ( null === $do_not_cache ) {
470 + $do_not_cache = defined( 'DONOTCACHEPAGE' ) && DONOTCACHEPAGE;
471 + }
472 + $send = ! $do_not_cache;
473 +
474 + if ( $send ) {
475 + if ( null === $sent_headers ) {
476 + $sent_headers = headers_list();
477 + }
478 + foreach ( $sent_headers as $header ) {
479 + if ( stripos( $header, 'cache-control:' ) === 0 ) {
480 + $send = false;
481 + break;
482 + }
483 + }
484 + }
485 +
486 + /**
487 + * Filters whether the temporary `Cache-Control: max-age=300` header is sent
488 + * while page profiling is pending for the current page.
489 + *
490 + * @param bool $send Computed decision: false when DONOTCACHEPAGE is set or a Cache-Control header exists already.
491 + */
492 + return apply_filters( 'optml_send_temporary_cache_header', $send ) === true;
493 + }
494 + /**
434 495 * Filter raw HTML content for urls.
435 496 *
436 497 * @param string $html HTML to filter.
437 498 * @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.
@@ -460,9 +521,9 @@
460 521 [ $profile_id, implode( ',', $missing ), strval( $time ), $hmac, $url ],
461 522 $js_optimizer
462 523 );
463 524 $html = str_replace( Optml_Admin::get_optimizer_script( true ), $js_optimizer, $html );
464 - if ( ! headers_sent() ) {
525 + if ( ! headers_sent() && $this->should_send_temporary_cache_header() ) {
465 526 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.
466 527 }
467 528 } else {
468 529 $should_show_comment = isset( $_GET['optml_debug'] ) && $_GET['optml_debug'] === 'true';
@@ -779,16 +840,64 @@
779 840 },
780 841 $urls
781 842 );
782 843
844 + /*
845 + * Replace all URLs in a single pass per chunk instead of one full-page
846 + * preg_replace() per URL, which scanned and rebuilt the whole page for
847 + * every replaced URL. Chunks are bounded by pattern size, not only
848 + * count, so the compiled regex stays within PCRE's ~64KB limit even
849 + * for very long URLs (e.g. signed CDN URLs with kilobyte-sized query
850 + * strings). Each chunk is applied as soon as it fills, so only one
851 + * chunk's bookkeeping is in memory at a time.
852 + */
853 + $chunk = [];
854 + $quoted = [];
855 + $quoted_size = 0;
783 856 foreach ( $urls as $origin => $replace ) {
784 - $html = preg_replace( '/(?<![\/|:|\\w])' . preg_quote( $origin, '/' ) . '/m', $replace, $html );
857 + $quoted_origin = preg_quote( $origin, '/' );
858 + if ( ! empty( $chunk ) && ( count( $chunk ) >= 200 || $quoted_size + strlen( $quoted_origin ) > 24000 ) ) {
859 + $html = $this->replace_urls_chunk( $html, $chunk, $quoted );
860 + $chunk = [];
861 + $quoted = [];
862 + $quoted_size = 0;
863 + }
864 + $chunk[ $origin ] = $replace;
865 + $quoted[] = $quoted_origin;
866 + $quoted_size += strlen( $quoted_origin ) + 1;
785 867 }
868 + if ( ! empty( $chunk ) ) {
869 + $html = $this->replace_urls_chunk( $html, $chunk, $quoted );
870 + }
786 871
787 872 return $html;
788 873 }
789 874
790 875 /**
876 + * Replace one chunk of URLs in the content with a single combined pattern.
877 + *
878 + * @param string $html Content to process.
879 + * @param array<string, string> $chunk Map of origin => replacement URLs.
880 + * @param string[] $quoted The preg_quote()d origins, in the same order.
881 + *
882 + * @return string Processed content, unchanged when the pattern fails.
883 + */
884 + private function replace_urls_chunk( $html, $chunk, $quoted ) {
885 + $result = preg_replace_callback(
886 + '/(?<![\/|:|\\w])(?:' . implode( '|', $quoted ) . ')/m',
887 + function ( $matches ) use ( $chunk ) {
888 + return $chunk[ $matches[0] ];
889 + },
890 + $html
891 + );
892 + if ( $result === null ) {
893 + do_action( 'optml_log', 'URL replacement failed for a chunk of ' . count( $chunk ) . ' URLs, PCRE error ' . preg_last_error() );
894 + return $html;
895 + }
896 + return $result;
897 + }
898 +
899 + /**
791 900 * Init html replacer handler.
792 901 */
793 902 public function process_template_redirect_content() {
794 903 // Early exit if function was already called, we don't want duplicate ob_start
@@ -798,24 +907,71 @@
798 907 self::$ob_started = true;
799 908 // We no longer need this if the handler was started.
800 909 remove_filter( 'the_content', [ $this, 'process_images_from_content' ], PHP_INT_MAX );
801 910
802 - ob_start(
803 - function ( $content ) {
804 - /*
805 - * Wrap the call to replace_content() so that PHP’s output-buffering system
806 - * does not pass its own second argument ($phase bitmask) to our method.
807 - *
808 - * replace_content() expects the second parameter to be a boolean $partial,
809 - * indicating whether the content is a partial replacement (e.g. for
810 - * viewport lazy-load) or a full page. If PHP’s $phase integer is passed
811 - * directly, it would be misinterpreted as $partial and break the logic.
812 - *
813 - * This closure filters the call, forwarding only the captured HTML buffer.
814 - */
911 + $this->start_capture_buffer();
912 + }
913 +
914 + /**
915 + * Start an output buffer that captures the page HTML.
916 + *
917 + * On normal requests the buffer is captured and processed by close_buffer()
918 + * at shutdown, outside of PHP's display-handler context, so callbacks hooked
919 + * into our filters are free to use output buffering themselves and fatal
920 + * errors raised during processing keep their real message instead of being
921 + * masked by "Cannot use output buffering in output buffering display handlers".
922 + *
923 + * The attached handler is only a fallback for buffers flushed outside of
924 + * close_buffer() — third-party force-flush loops, ob_flush() streaming, or
925 + * core's wp_ob_end_flush_all() reaching the re-armed buffer. A named method
926 + * is used instead of a closure so the buffer can be identified as ours via
927 + * ob_get_status()['name'].
928 + *
929 + * @return void
930 + */
931 + private function start_capture_buffer() {
932 + self::$ob_processed = false;
933 + ob_start( [ $this, 'handle_buffer_fallback' ] );
934 + self::$ob_level = ob_get_level();
935 + }
936 +
937 + /**
938 + * The handler name PHP reports for our capture buffer in ob_get_status().
939 + */
940 + const OB_HANDLER_NAME = 'Optml_Manager::handle_buffer_fallback';
941 +
942 + /**
943 + * Output-buffer handler attached to our capture buffer.
944 + *
945 + * Runs only when the buffer is flushed outside of close_buffer(). Content is
946 + * passed through UNPROCESSED here: running the replacement filter graph
947 + * inside a PHP display handler would turn any third-party ob_*() call into
948 + * an uncatchable fatal ("Cannot use output buffering in output buffering
949 + * display handlers") — the very crash this rework removes. The only
950 + * exception is the legacy mode selected via the optml_capture_at_shutdown
951 + * filter, which explicitly restores the previous in-handler processing.
952 + *
953 + * @param string $content The buffered content.
954 + * @param int $phase PHP's output-handler phase bitmask (unused; keeps replace_content()'s $partial parameter shielded from it).
955 + *
956 + * @return string The content to output.
957 + */
958 + public function handle_buffer_fallback( $content, $phase = 0 ) {
959 + if ( self::$ob_processed || $content === '' ) {
960 + return $content;
961 + }
962 + if ( apply_filters( 'optml_capture_at_shutdown', true ) === false ) {
963 + try {
815 964 return $this->replace_content( $content, self::is_ajax_request() );
965 + } catch ( Throwable $t ) {
966 + // Never break the page from inside a display handler.
967 + do_action( 'optml_log', 'replace_content failed inside the output handler: ' . $t->getMessage() );
968 + return $content;
816 969 }
817 - );
970 + }
971 + do_action( 'optml_log', 'Optimole buffer was flushed outside close_buffer(); content passed through unprocessed.' );
972 +
973 + return $content;
818 974 }
819 975
820 976 /**
821 977 * Close the buffer and flush the content.
@@ -820,11 +976,88 @@
820 976 /**
821 977 * Close the buffer and flush the content.
822 978 */
823 979 public function close_buffer() {
824 - if ( self::$ob_started && ob_get_length() ) {
825 - ob_end_flush();
980 + if ( ! self::$ob_started ) {
981 + return;
826 982 }
983 +
984 + /**
985 + * Filters whether the captured page is processed at shutdown, outside of
986 + * PHP's display-handler context. Return false to restore the legacy
987 + * behavior of processing inside the output-buffer handler.
988 + *
989 + * @param bool $capture_at_shutdown Whether to process the buffer at shutdown.
990 + */
991 + if ( apply_filters( 'optml_capture_at_shutdown', true ) === false ) {
992 + if ( ob_get_length() ) {
993 + ob_end_flush();
994 + }
995 + return;
996 + }
997 +
998 + /*
999 + * Flush the buffers other plugins stacked on top of ours so their
1000 + * handlers still transform the page before we process it, preserving
1001 + * the same order as a full top-down flush at request shutdown.
1002 + */
1003 + while ( ob_get_level() > self::$ob_level ) {
1004 + // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a non-flushable buffer must not raise a notice; we stop on failure.
1005 + if ( ! @ob_end_flush() ) {
1006 + break;
1007 + }
1008 + }
1009 +
1010 + if ( ! $this->capture_and_process_buffer() ) {
1011 + do_action( 'optml_log', 'Optimole buffer was closed earlier by third-party code.' );
1012 + return;
1013 + }
1014 +
1015 + /*
1016 + * Re-arm the capture so output echoed by later shutdown callbacks is
1017 + * still processed and unguarded third-party flush calls find a buffer
1018 + * to close instead of raising a notice.
1019 + */
1020 + $this->start_capture_buffer();
1021 + }
1022 +
1023 + /**
1024 + * Close the re-armed buffer at the very end of shutdown.
1025 + *
1026 + * @return void
1027 + */
1028 + public function close_final_buffer() {
1029 + if ( ! self::$ob_started ) {
1030 + return;
1031 + }
1032 + $this->capture_and_process_buffer();
1033 + }
1034 +
1035 + /**
1036 + * Capture our buffer, process it outside the display-handler context and echo the result.
1037 + *
1038 + * Ownership is verified by both nesting level and handler identity, so a
1039 + * buffer another plugin opened at the same level after ours was closed is
1040 + * never captured or closed by us.
1041 + *
1042 + * @return bool Whether our buffer was found and consumed.
1043 + */
1044 + private function capture_and_process_buffer() {
1045 + if ( self::$ob_level === 0 || ob_get_level() !== self::$ob_level ) {
1046 + return false;
1047 + }
1048 + $status = ob_get_status();
1049 + if ( ( $status['name'] ?? '' ) !== self::OB_HANDLER_NAME ) {
1050 + return false;
1051 + }
1052 + $html = ob_get_contents();
1053 + // Set before ob_end_clean() so our handler no-ops during buffer cleanup.
1054 + self::$ob_processed = true;
1055 + ob_end_clean();
1056 + if ( $html !== false && $html !== '' ) {
1057 + echo $this->replace_content( $html, self::is_ajax_request() ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- full page HTML, escaping would break the page.
1058 + }
1059 + return true;
827 1060 }
828 1061 /**
829 1062 * Throw error on object clone
830 1063 *