| @@ -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 | * |