PluginProbe
Optimole – Optimize Images | Convert WebP & AVIF | CDN & Lazy Load | Image Optimization / trunk
Optimole – Optimize Images | Convert WebP & AVIF | CDN & Lazy Load | Image Optimization vtrunk
4.2.14 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 All 135 releases
← All changes | inc/manager.php +283 -19 4.2.5 → trunk 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',
@@ -114,8 +115,9 @@
114 115 'speedycache',
115 116 'hummingbird',
116 117 'aruba_hsc',
117 118 'spc',
119 + 'groovy_menu',
118 120 ];
119 121 /**
120 122 * The current state of the buffer.
121 123 *
@@ -121,8 +123,25 @@
121 123 *
122 124 * @var boolean Buffer state.
123 125 */
124 126 private static $ob_started = false;
127 + /**
128 + * The output-buffer nesting level of our capture buffer.
129 + *
130 + * Used to make sure we only ever capture or close our own buffer and not
131 + * one started by a third party.
132 + *
133 + * @var int Buffer nesting level, 0 when no capture buffer is armed.
134 + */
135 + private static $ob_level = 0;
136 + /**
137 + * Whether the captured buffer was already processed at shutdown.
138 + *
139 + * When true, the fallback output handler passes content through untouched.
140 + *
141 + * @var boolean Processed state.
142 + */
143 + private static $ob_processed = false;
125 144
126 145 /**
127 146 * Class instance method.
128 147 *
@@ -374,9 +393,9 @@
374 393 }
375 394 if ( ! wp_doing_ajax() ) {
376 395 return false;
377 396 }
378 - if ( isset( $_REQUEST['action'] ) && strpos( $_REQUEST['action'], 'wpmdb' ) !== false ) {
397 + if ( isset( $_REQUEST['action'] ) && is_string( $_REQUEST['action'] ) && strpos( $_REQUEST['action'], 'wpmdb' ) !== false ) {
379 398 return false;
380 399 }
381 400
382 401 return true;
@@ -407,8 +426,9 @@
407 426 );
408 427 add_action( 'template_redirect', [ $this, 'register_after_setup' ] );
409 428 add_action( 'rest_api_init', [ $this, 'process_template_redirect_content' ], PHP_INT_MIN );
410 429 add_action( 'shutdown', [ $this, 'close_buffer' ], PHP_INT_MIN );
430 + add_action( 'shutdown', [ $this, 'close_final_buffer' ], PHP_INT_MAX );
411 431 foreach ( self::$loaded_compatibilities as $registered_compatibility ) {
412 432 $registered_compatibility->register();
413 433 }
414 434 }
@@ -429,9 +449,51 @@
429 449 */
430 450 public static function should_load_profiler( $default_value = false ) {
431 451 return ! $default_value && apply_filters( 'optml_page_profiler_disable', false ) === false;
432 452 }
453 +
433 454 /**
455 + * Decide if the temporary Cache-Control header can be sent while page profiling is pending.
456 + *
457 + * The header must never be sent on non-cacheable pages: it would replace a
458 + * Cache-Control header already set by WordPress or another plugin (PHP's
459 + * header() replaces same-name headers by default), e.g. WooCommerce's
460 + * no-cache header on cart and checkout, letting proxies cache user-specific
461 + * pages. We back off when DONOTCACHEPAGE is set or when any Cache-Control
462 + * header exists already, and let developers override the decision.
463 + *
464 + * @param array<int, string>|null $sent_headers Headers already set for the response; defaults to headers_list().
465 + * @param bool|null $do_not_cache Whether the page is flagged as non-cacheable; defaults to the DONOTCACHEPAGE constant.
466 + *
467 + * @return bool Whether the header can be sent.
468 + */
469 + public function should_send_temporary_cache_header( $sent_headers = null, $do_not_cache = null ) {
470 + if ( null === $do_not_cache ) {
471 + $do_not_cache = defined( 'DONOTCACHEPAGE' ) && DONOTCACHEPAGE;
472 + }
473 + $send = ! $do_not_cache;
474 +
475 + if ( $send ) {
476 + if ( null === $sent_headers ) {
477 + $sent_headers = headers_list();
478 + }
479 + foreach ( $sent_headers as $header ) {
480 + if ( stripos( $header, 'cache-control:' ) === 0 ) {
481 + $send = false;
482 + break;
483 + }
484 + }
485 + }
486 +
487 + /**
488 + * Filters whether the temporary `Cache-Control: max-age=300` header is sent
489 + * while page profiling is pending for the current page.
490 + *
491 + * @param bool $send Computed decision: false when DONOTCACHEPAGE is set or a Cache-Control header exists already.
492 + */
493 + return apply_filters( 'optml_send_temporary_cache_header', $send ) === true;
494 + }
495 + /**
434 496 * Filter raw HTML content for urls.
435 497 *
436 498 * @param string $html HTML to filter.
437 499 * @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 +522,9 @@
460 522 [ $profile_id, implode( ',', $missing ), strval( $time ), $hmac, $url ],
461 523 $js_optimizer
462 524 );
463 525 $html = str_replace( Optml_Admin::get_optimizer_script( true ), $js_optimizer, $html );
464 - if ( ! headers_sent() ) {
526 + if ( ! headers_sent() && $this->should_send_temporary_cache_header() ) {
465 527 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 528 }
467 529 } else {
468 530 $should_show_comment = isset( $_GET['optml_debug'] ) && $_GET['optml_debug'] === 'true';
@@ -779,16 +841,64 @@
779 841 },
780 842 $urls
781 843 );
782 844
845 + /*
846 + * Replace all URLs in a single pass per chunk instead of one full-page
847 + * preg_replace() per URL, which scanned and rebuilt the whole page for
848 + * every replaced URL. Chunks are bounded by pattern size, not only
849 + * count, so the compiled regex stays within PCRE's ~64KB limit even
850 + * for very long URLs (e.g. signed CDN URLs with kilobyte-sized query
851 + * strings). Each chunk is applied as soon as it fills, so only one
852 + * chunk's bookkeeping is in memory at a time.
853 + */
854 + $chunk = [];
855 + $quoted = [];
856 + $quoted_size = 0;
783 857 foreach ( $urls as $origin => $replace ) {
784 - $html = preg_replace( '/(?<![\/|:|\\w])' . preg_quote( $origin, '/' ) . '/m', $replace, $html );
858 + $quoted_origin = preg_quote( $origin, '/' );
859 + if ( ! empty( $chunk ) && ( count( $chunk ) >= 200 || $quoted_size + strlen( $quoted_origin ) > 24000 ) ) {
860 + $html = $this->replace_urls_chunk( $html, $chunk, $quoted );
861 + $chunk = [];
862 + $quoted = [];
863 + $quoted_size = 0;
864 + }
865 + $chunk[ $origin ] = $replace;
866 + $quoted[] = $quoted_origin;
867 + $quoted_size += strlen( $quoted_origin ) + 1;
785 868 }
869 + if ( ! empty( $chunk ) ) {
870 + $html = $this->replace_urls_chunk( $html, $chunk, $quoted );
871 + }
786 872
787 873 return $html;
788 874 }
789 875
790 876 /**
877 + * Replace one chunk of URLs in the content with a single combined pattern.
878 + *
879 + * @param string $html Content to process.
880 + * @param array<string, string> $chunk Map of origin => replacement URLs.
881 + * @param string[] $quoted The preg_quote()d origins, in the same order.
882 + *
883 + * @return string Processed content, unchanged when the pattern fails.
884 + */
885 + private function replace_urls_chunk( $html, $chunk, $quoted ) {
886 + $result = preg_replace_callback(
887 + '/(?<![\/|:|\\w])(?:' . implode( '|', $quoted ) . ')/m',
888 + function ( $matches ) use ( $chunk ) {
889 + return $chunk[ $matches[0] ];
890 + },
891 + $html
892 + );
893 + if ( $result === null ) {
894 + do_action( 'optml_log', 'URL replacement failed for a chunk of ' . count( $chunk ) . ' URLs, PCRE error ' . preg_last_error() );
895 + return $html;
896 + }
897 + return $result;
898 + }
899 +
900 + /**
791 901 * Init html replacer handler.
792 902 */
793 903 public function process_template_redirect_content() {
794 904 // Early exit if function was already called, we don't want duplicate ob_start
@@ -798,24 +908,94 @@
798 908 self::$ob_started = true;
799 909 // We no longer need this if the handler was started.
800 910 remove_filter( 'the_content', [ $this, 'process_images_from_content' ], PHP_INT_MAX );
801 911
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 - */
912 + $this->start_capture_buffer();
913 + }
914 +
915 + /**
916 + * Start the output buffer that holds the page HTML.
917 + *
918 + * By default the page is processed by the attached handler when the buffer
919 + * is flushed, the way it worked up to 4.2.11. We never flush other buffers
920 + * and open no buffer at shutdown, so code that opens a buffer early and reads
921 + * it back with ob_get_clean() at shutdown (FacetWP, Groovy Menu) keeps working.
922 + *
923 + * When the optml_capture_at_shutdown filter returns true, close_buffer()
924 + * captures and processes the buffer at shutdown instead, outside of PHP's
925 + * display-handler context. Callbacks hooked into our filters can then use
926 + * output buffering themselves, and fatal errors raised during processing
927 + * keep their real message instead of being masked by "Cannot use output
928 + * buffering in output buffering display handlers".
929 + *
930 + * A named method is used instead of a closure so the buffer can be
931 + * identified as ours via ob_get_status()['name'].
932 + *
933 + * @return void
934 + */
935 + private function start_capture_buffer() {
936 + self::$ob_processed = false;
937 + ob_start( [ $this, 'handle_buffer_fallback' ] );
938 + self::$ob_level = ob_get_level();
939 + }
940 +
941 + /**
942 + * The handler name PHP reports for our capture buffer in ob_get_status().
943 + */
944 + const OB_HANDLER_NAME = 'Optml_Manager::handle_buffer_fallback';
945 +
946 + /**
947 + * Whether the page is captured and processed at shutdown, outside of PHP's display-handler context.
948 + *
949 + * @return bool
950 + */
951 + private function captures_at_shutdown() {
952 + /**
953 + * Filters whether the page is captured and processed at shutdown, outside
954 + * of PHP's display-handler context, instead of inside the output-buffer
955 + * handler.
956 + *
957 + * Off by default: the shutdown capture flushes the buffers
958 + * stacked above ours and opens a new buffer afterwards, which breaks code
959 + * that reads its own buffer back at shutdown. Return true to opt in.
960 + *
961 + * @param bool $capture_at_shutdown Whether to process the buffer at shutdown.
962 + */
963 + return apply_filters( 'optml_capture_at_shutdown', false ) === true;
964 + }
965 +
966 + /**
967 + * Output-buffer handler attached to our buffer.
968 + *
969 + * By default this is where the page is processed. An exception thrown by the
970 + * replacement never breaks the page: the content is returned untouched.
971 + *
972 + * With the optml_capture_at_shutdown opt-in the handler runs only when the
973 + * buffer is flushed outside of close_buffer(), and the content is passed
974 + * through unprocessed, because running the replacement filter graph inside a
975 + * display handler is what that mode exists to avoid.
976 + *
977 + * @param string $content The buffered content.
978 + * @param int $phase PHP's output-handler phase bitmask (unused; keeps replace_content()'s $partial parameter shielded from it).
979 + *
980 + * @return string The content to output.
981 + */
982 + public function handle_buffer_fallback( $content, $phase = 0 ) {
983 + if ( self::$ob_processed || $content === '' ) {
984 + return $content;
985 + }
986 + if ( ! $this->captures_at_shutdown() ) {
987 + try {
815 988 return $this->replace_content( $content, self::is_ajax_request() );
989 + } catch ( Throwable $t ) {
990 + // Never break the page from inside a display handler.
991 + do_action( 'optml_log', 'replace_content failed inside the output handler: ' . $t->getMessage() );
992 + return $content;
816 993 }
817 - );
994 + }
995 + do_action( 'optml_log', 'Optimole buffer was flushed outside close_buffer(); content passed through unprocessed.' );
996 +
997 + return $content;
818 998 }
819 999
820 1000 /**
821 1001 * Close the buffer and flush the content.
@@ -820,11 +1000,95 @@
820 1000 /**
821 1001 * Close the buffer and flush the content.
822 1002 */
823 1003 public function close_buffer() {
824 - if ( self::$ob_started && ob_get_length() ) {
825 - ob_end_flush();
1004 + if ( ! self::$ob_started ) {
1005 + return;
826 1006 }
1007 +
1008 + if ( ! $this->captures_at_shutdown() ) {
1009 + if ( ob_get_length() ) {
1010 + ob_end_flush();
1011 + }
1012 + return;
1013 + }
1014 +
1015 + /*
1016 + * Flush the buffers other plugins stacked on top of ours so their
1017 + * handlers still transform the page before we process it, preserving
1018 + * the same order as a full top-down flush at request shutdown.
1019 + */
1020 + while ( ob_get_level() > self::$ob_level ) {
1021 + // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a non-flushable buffer must not raise a notice; we stop on failure.
1022 + if ( ! @ob_end_flush() ) {
1023 + break;
1024 + }
1025 + }
1026 +
1027 + if ( ! $this->capture_and_process_buffer() ) {
1028 + do_action( 'optml_log', 'Optimole buffer was closed earlier by third-party code.' );
1029 + return;
1030 + }
1031 +
1032 + /*
1033 + * Re-arm the capture so output echoed by later shutdown callbacks is
1034 + * still processed and unguarded third-party flush calls find a buffer
1035 + * to close instead of raising a notice.
1036 + */
1037 + $this->start_capture_buffer();
1038 + }
1039 +
1040 + /**
1041 + * Close the re-armed buffer at the very end of shutdown.
1042 + *
1043 + * @return void
1044 + */
1045 + public function close_final_buffer() {
1046 + if ( ! self::$ob_started || ! $this->captures_at_shutdown() ) {
1047 + return;
1048 + }
1049 + $this->capture_and_process_buffer( false );
1050 + }
1051 +
1052 + /**
1053 + * Capture our buffer, process it outside the display-handler context and echo the result.
1054 + *
1055 + * Ownership is verified by both nesting level and handler identity, so a
1056 + * buffer another plugin opened at the same level after ours was closed is
1057 + * never captured or closed by us.
1058 + *
1059 + * @param bool $is_page Whether this is the page capture (true) or the late shutdown output (false).
1060 + *
1061 + * @return bool Whether our buffer was found and consumed.
1062 + */
1063 + private function capture_and_process_buffer( $is_page = true ) {
1064 + if ( self::$ob_level === 0 || ob_get_level() !== self::$ob_level ) {
1065 + return false;
1066 + }
1067 + $status = ob_get_status();
1068 + if ( ( $status['name'] ?? '' ) !== self::OB_HANDLER_NAME ) {
1069 + return false;
1070 + }
1071 + $html = ob_get_contents();
1072 + // Set before ob_end_clean() so our handler no-ops during buffer cleanup.
1073 + self::$ob_processed = true;
1074 + ob_end_clean();
1075 + if ( $html !== false && $html !== '' ) {
1076 + if ( $is_page ) {
1077 + /**
1078 + * Filters the captured page HTML before Optimole processes it.
1079 + *
1080 + * Runs once per request, on the buffer captured at shutdown, outside of
1081 + * PHP's display-handler context. Late output echoed by other shutdown
1082 + * callbacks is not passed through this filter.
1083 + *
1084 + * @param string $html The full page HTML.
1085 + */
1086 + $html = apply_filters( 'optml_captured_page_html', $html );
1087 + }
1088 + echo $this->replace_content( $html, self::is_ajax_request() ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- full page HTML, escaping would break the page.
1089 + }
1090 + return true;
827 1091 }
828 1092 /**
829 1093 * Throw error on object clone
830 1094 *