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