| 1 |
<?php |
| 2 |
/** |
| 3 |
* OTTO Render Strategy Manager |
| 4 |
* Implements hybrid approach: Output Buffer (fast) with wp_remote_get fallback |
| 5 |
* |
| 6 |
* Features: |
| 7 |
* - Automatic method selection based on environment |
| 8 |
* - Output buffer approach (eliminates internal HTTP request) |
| 9 |
* - Fallback to wp_remote_get for problematic environments |
| 10 |
* - Response headers indicating method used |
| 11 |
* - Detection of caching plugins and managed hosts |
| 12 |
* |
| 13 |
* @since 2.6.0 |
| 14 |
*/ |
| 15 |
|
| 16 |
if (!defined('ABSPATH')) { |
| 17 |
exit; |
| 18 |
} |
| 19 |
|
| 20 |
class Metasync_Otto_Render_Strategy { |
| 21 |
|
| 22 |
/** |
| 23 |
* Render method constants |
| 24 |
*/ |
| 25 |
const METHOD_BUFFER = 'buffer'; |
| 26 |
const METHOD_HTTP = 'http'; |
| 27 |
const METHOD_NONE = 'none'; |
| 28 |
const METHOD_WP_ROCKET = 'rocket_buffer'; |
| 29 |
|
| 30 |
/** |
| 31 |
* Memory amplification factor for SimpleHtmlDom. |
| 32 |
* Parsing a page into a SimpleHtmlDom node tree costs ~18x the raw HTML |
| 33 |
* size, and the save()/load() reload cycle adds another ~40% transient |
| 34 |
* peak. We budget 25x to keep a safety margin before the PHP memory limit. |
| 35 |
*/ |
| 36 |
const DOM_MEMORY_AMPLIFICATION = 25; |
| 37 |
|
| 38 |
/** |
| 39 |
* Absolute hard cap (bytes) on documents OTTO will DOM-parse, |
| 40 |
* regardless of available memory. Backstop against pathological pages. |
| 41 |
* Default 16 MB; override with the METASYNC_OTTO_MAX_HTML_BYTES constant |
| 42 |
* or the 'metasync_otto_max_html_bytes' filter. |
| 43 |
*/ |
| 44 |
const ABSOLUTE_MAX_DOCUMENT_BYTES = 16777216; |
| 45 |
|
| 46 |
/** |
| 47 |
* Current render method being used |
| 48 |
*/ |
| 49 |
private static $current_method = null; |
| 50 |
|
| 51 |
/** |
| 52 |
* Buffer level when OTTO started |
| 53 |
*/ |
| 54 |
private static $buffer_start_level = null; |
| 55 |
|
| 56 |
/** |
| 57 |
* Flag to track if buffer is active |
| 58 |
*/ |
| 59 |
private static $buffer_active = false; |
| 60 |
|
| 61 |
/** |
| 62 |
* OTTO suggestions data (stored for buffer approach) |
| 63 |
*/ |
| 64 |
private static $pending_suggestions = null; |
| 65 |
|
| 66 |
/** |
| 67 |
* Current route (stored for buffer approach) |
| 68 |
*/ |
| 69 |
private static $pending_route = null; |
| 70 |
|
| 71 |
/** |
| 72 |
* OTTO HTML processor instance |
| 73 |
*/ |
| 74 |
private static $otto_html = null; |
| 75 |
|
| 76 |
/** |
| 77 |
* Blocking flags for SEO plugins |
| 78 |
*/ |
| 79 |
private static $blocking_flags = null; |
| 80 |
|
| 81 |
/** |
| 82 |
/** |
| 83 |
* Whether this request's document was declined as unprocessable (oversized / |
| 84 |
* over the memory budget) rather than attempted and failed. Set by |
| 85 |
* log_oversized_skip(); read via document_was_unprocessable(). |
| 86 |
*/ |
| 87 |
private static $document_unprocessable = false; |
| 88 |
|
| 89 |
/** |
| 90 |
* Raw `Set-Cookie` header line(s) returned by the HTTP-path internal fetch, kept so |
| 91 |
* the visitor can be handed the session their page was built around (on the HTTP path |
| 92 |
* the fetch cookie is otherwise discarded and session-based forms silently fail). |
| 93 |
* Only populated when the fetch was same-origin as the visitor. |
| 94 |
*/ |
| 95 |
private static $http_fetch_cookies = array(); |
| 96 |
|
| 97 |
/** |
| 98 |
* Whether the HTTP-path internal fetch response set any cookie (a form/session signal). |
| 99 |
*/ |
| 100 |
private static $http_fetch_had_cookie = false; |
| 101 |
|
| 102 |
/** |
| 103 |
* Whether the HTTP-path internal fetch response asked not to be cached. PHP emits its |
| 104 |
* session cache-limiter headers from session_start() on both new AND resumed sessions, |
| 105 |
* so this is server-side truth about the page being session-driven — including the |
| 106 |
* returning-visitor case where no new cookie is set. Signal only, never forwarded. |
| 107 |
*/ |
| 108 |
private static $http_fetch_no_cache = false; |
| 109 |
|
| 110 |
/** |
| 111 |
* User-Agent OTTO stamps on its own internal page fetch. Shared with |
| 112 |
* Otto_html_class so the sender and the detector cannot drift apart. |
| 113 |
*/ |
| 114 |
const INTERNAL_FETCH_USER_AGENT = 'MetaSync-OTTO-SSR'; |
| 115 |
|
| 116 |
/** |
| 117 |
* Is the current request OTTO's own internal page fetch? |
| 118 |
* |
| 119 |
* The is_otto_page_fetch query parameter cannot be the only signal. Any |
| 120 |
* redirect that rebuilds the destination URL - our own Redirections module, |
| 121 |
* a host rule, a redirect plugin - drops the query string, and the |
| 122 |
* redirected request then looks like ordinary visitor traffic. OTTO fires a |
| 123 |
* second internal fetch, that one is redirected too, and the site feeds |
| 124 |
* itself until it runs out of workers. |
| 125 |
* |
| 126 |
* The request header and the User-Agent are re-sent on every redirect hop, |
| 127 |
* so they survive what the query string cannot. Any one of the three is |
| 128 |
* enough to recognise our own traffic. |
| 129 |
* |
| 130 |
* @return bool |
| 131 |
*/ |
| 132 |
public static function is_internal_fetch() { |
| 133 |
if (!empty($_GET['is_otto_page_fetch'])) { |
| 134 |
return true; |
| 135 |
} |
| 136 |
|
| 137 |
if (!empty($_SERVER['HTTP_X_OTTO_INTERNAL_FETCH'])) { |
| 138 |
return true; |
| 139 |
} |
| 140 |
|
| 141 |
$user_agent = isset($_SERVER['HTTP_USER_AGENT']) |
| 142 |
? sanitize_text_field(wp_unslash($_SERVER['HTTP_USER_AGENT'])) |
| 143 |
: ''; |
| 144 |
|
| 145 |
return $user_agent !== '' && stripos($user_agent, self::INTERNAL_FETCH_USER_AGENT) !== false; |
| 146 |
} |
| 147 |
|
| 148 |
/** |
| 149 |
* Determine the best render method for current environment |
| 150 |
* |
| 151 |
* @return string METHOD_BUFFER or METHOD_HTTP |
| 152 |
*/ |
| 153 |
public static function determine_method() { |
| 154 |
# Headless sites have no public HTML response for OTTO to rewrite. |
| 155 |
if (Metasync_Headless_Config::is_active()) { |
| 156 |
return self::METHOD_NONE; |
| 157 |
} |
| 158 |
|
| 159 |
# Skip buffer for internal fetch requests (avoid infinite loop) |
| 160 |
if (self::is_internal_fetch()) { |
| 161 |
return self::METHOD_NONE; |
| 162 |
} |
| 163 |
|
| 164 |
# Skip for admin, AJAX, REST API |
| 165 |
if (is_admin() || wp_doing_ajax() || (defined('REST_REQUEST') && REST_REQUEST)) { |
| 166 |
return self::METHOD_NONE; |
| 167 |
} |
| 168 |
|
| 169 |
# Check for known problematic configurations that need HTTP method |
| 170 |
if (self::should_use_http_method()) { |
| 171 |
return self::METHOD_HTTP; |
| 172 |
} |
| 173 |
|
| 174 |
# Check if output buffering is available and safe to use |
| 175 |
if (self::is_buffer_available()) { |
| 176 |
return self::METHOD_BUFFER; |
| 177 |
} |
| 178 |
|
| 179 |
# Fallback to HTTP method |
| 180 |
return self::METHOD_HTTP; |
| 181 |
} |
| 182 |
|
| 183 |
/** |
| 184 |
* Check if output buffering is available and safe |
| 185 |
* |
| 186 |
* @return bool |
| 187 |
*/ |
| 188 |
private static function is_buffer_available() { |
| 189 |
# Check if output buffering is enabled |
| 190 |
if (!function_exists('ob_start') || !function_exists('ob_get_clean')) { |
| 191 |
return false; |
| 192 |
} |
| 193 |
|
| 194 |
# Check buffer nesting level (too many nested buffers = risky) |
| 195 |
$current_level = ob_get_level(); |
| 196 |
if ($current_level > 5) { |
| 197 |
return false; |
| 198 |
} |
| 199 |
|
| 200 |
# Check if headers already sent (can't use buffer properly) |
| 201 |
if (headers_sent($file, $line)) { |
| 202 |
return false; |
| 203 |
} |
| 204 |
|
| 205 |
# Check memory limit (need enough memory to buffer page) |
| 206 |
$memory_limit = self::get_memory_limit_bytes(); |
| 207 |
$memory_used = memory_get_usage(true); |
| 208 |
$memory_available = $memory_limit - $memory_used; |
| 209 |
|
| 210 |
# Need at least 16MB available for buffering |
| 211 |
if ($memory_available < 16 * 1024 * 1024) { |
| 212 |
return false; |
| 213 |
} |
| 214 |
|
| 215 |
return true; |
| 216 |
} |
| 217 |
|
| 218 |
/** |
| 219 |
* Check if HTTP method should be forced |
| 220 |
* |
| 221 |
* @return bool True if HTTP method should be used |
| 222 |
*/ |
| 223 |
private static function should_use_http_method() { |
| 224 |
# Force HTTP method via constant (for debugging/testing) |
| 225 |
if (defined('METASYNC_OTTO_FORCE_HTTP') && METASYNC_OTTO_FORCE_HTTP) { |
| 226 |
return true; |
| 227 |
} |
| 228 |
|
| 229 |
# Check for known caching plugins that may conflict with output buffering |
| 230 |
$conflicting_plugins = [ |
| 231 |
# Heavy output buffering plugins |
| 232 |
'WP_Rocket' => class_exists('WP_Rocket'), |
| 233 |
'W3TC' => defined('W3TC'), |
| 234 |
'LiteSpeed_Cache' => defined('LSCWP_V'), |
| 235 |
|
| 236 |
# SiteGround Optimizer — its Parser starts a nested ob_start() with callback |
| 237 |
# that flushes before wp_footer completes, causing Divi's late-injected |
| 238 |
# module-design CSS (<style id="et-builder-module-design-*">) to be lost. |
| 239 |
# Affects all themes that defer inline CSS output to wp_footer (Divi 5, etc.). |
| 240 |
'SG_CachePress' => defined('SiteGround_Optimizer\VERSION') || class_exists('SiteGround_Optimizer\Parser\Parser'), |
| 241 |
|
| 242 |
# Page builders with aggressive buffering |
| 243 |
'Brizy_Editor' => class_exists('Brizy_Editor'), |
| 244 |
'Oxygen_Builder' => defined('CT_VERSION'), # Oxygen manipulates wp_current_filter causing CSS loading issues in PHP 8.3+ |
| 245 |
|
| 246 |
# Optimization plugins |
| 247 |
'Autoptimize' => class_exists('autoptimizeMain'), |
| 248 |
]; |
| 249 |
|
| 250 |
foreach ($conflicting_plugins as $name => $is_active) { |
| 251 |
if ($is_active) { |
| 252 |
# OXYGEN BUILDER: Always use HTTP method due to wp_current_filter manipulation |
| 253 |
# Oxygen manipulates $wp_current_filter during wp_head which breaks CSS loading in PHP 8.3+ |
| 254 |
# HTTP method bypasses the buffer and avoids the conflict entirely |
| 255 |
if ($name === 'Oxygen_Builder') { |
| 256 |
return true; |
| 257 |
} |
| 258 |
|
| 259 |
# SITEGROUND OPTIMIZER: Use HTTP method — buffer method + SimpleHtmlDom |
| 260 |
# corrupts Divi module numbering (et_pb_blog_0 → et_pb_blog_1), breaking |
| 261 |
# AJAX pagination. HTTP method preserves numbering; Divi CSS re-injection |
| 262 |
# is handled in render_via_http() via et_core_page_resource_get API. |
| 263 |
if ($name === 'SG_CachePress') { |
| 264 |
# Escape hatch: when every page of a site is served uncacheable no |
| 265 |
# matter what (most commonly another plugin calling session_start() |
| 266 |
# on init, which makes the host skip its cache on every response), |
| 267 |
# the internal fetch doubles render cost while buying no cache |
| 268 |
# safety. The "buffer" compatibility mode opts such a site into |
| 269 |
# the render path without the loopback; any other value keeps the |
| 270 |
# HTTP force. |
| 271 |
$metasync_options = get_option('metasync_options', []); |
| 272 |
$sg_compat_mode = $metasync_options['general']['otto_sg_optimizer_compat'] ?? 'auto'; |
| 273 |
if ($sg_compat_mode === 'buffer') { |
| 274 |
continue; |
| 275 |
} |
| 276 |
return true; |
| 277 |
} |
| 278 |
|
| 279 |
# For logged-in users, buffer is usually safe even with these plugins |
| 280 |
if (is_user_logged_in()) { |
| 281 |
continue; |
| 282 |
} |
| 283 |
|
| 284 |
# For non-logged-in users with caching plugins, check if page caching is active |
| 285 |
if ($name === 'WP_Rocket') { |
| 286 |
# Get WP Rocket compatibility configuration |
| 287 |
$metasync_options = get_option('metasync_options', []); |
| 288 |
$wp_rocket_compat_mode = $metasync_options['general']['otto_wp_rocket_compat'] ?? 'auto'; |
| 289 |
|
| 290 |
# Force HTTP if user selected it |
| 291 |
if ($wp_rocket_compat_mode === 'http') { |
| 292 |
return true; # Use HTTP method for maximum compatibility |
| 293 |
} |
| 294 |
|
| 295 |
# Force buffer if user selected it |
| 296 |
if ($wp_rocket_compat_mode === 'buffer') { |
| 297 |
continue; # Use buffer method for speed |
| 298 |
} |
| 299 |
|
| 300 |
# For auto mode, use buffer so WP Rocket can continue optimizing the page |
| 301 |
if ($wp_rocket_compat_mode === 'auto') { |
| 302 |
continue; |
| 303 |
} |
| 304 |
} |
| 305 |
|
| 306 |
# Default: use HTTP method for safety with caching plugins |
| 307 |
// Removed logging to reduce noise - uncomment for debugging |
| 308 |
|
| 309 |
# Actually, let's try buffer first for most cases |
| 310 |
# Only force HTTP for specific known issues |
| 311 |
} |
| 312 |
} |
| 313 |
|
| 314 |
# Check for managed hosts that may have issues |
| 315 |
# WP Engine detection |
| 316 |
if (defined('WPE_APIKEY') || getenv('IS_WPE')) { |
| 317 |
# WP Engine serves cached pages at edge, but for uncached requests, buffer works |
| 318 |
if (!is_user_logged_in() && !defined('DONOTCACHEPAGE')) { |
| 319 |
# This might be a cached request that WP Engine serves from edge |
| 320 |
# However, if we're here, PHP is executing, so buffer should work |
| 321 |
} |
| 322 |
} |
| 323 |
|
| 324 |
# Kinsta detection |
| 325 |
if (defined('KINSTAMU_VERSION') || getenv('KINSTA_CACHE_ZONE')) { |
| 326 |
# Similar to WP Engine - if PHP is executing, buffer should work |
| 327 |
} |
| 328 |
|
| 329 |
# Cloudways Varnish |
| 330 |
if (isset($_SERVER['HTTP_X_VARNISH'])) { |
| 331 |
# Varnish is in front, but if we're here, request reached PHP |
| 332 |
} |
| 333 |
|
| 334 |
# Flywheel detection |
| 335 |
if (defined('FLYWHEEL_CONFIG_DIR')) { |
| 336 |
# Flywheel caching similar to WP Engine |
| 337 |
} |
| 338 |
|
| 339 |
# No known conflicts detected |
| 340 |
return false; |
| 341 |
} |
| 342 |
|
| 343 |
/** |
| 344 |
* Get memory limit in bytes |
| 345 |
* |
| 346 |
* @return int Memory limit in bytes |
| 347 |
*/ |
| 348 |
private static function get_memory_limit_bytes() { |
| 349 |
$limit = ini_get('memory_limit'); |
| 350 |
|
| 351 |
if ($limit === '-1') { |
| 352 |
return PHP_INT_MAX; # Unlimited |
| 353 |
} |
| 354 |
|
| 355 |
$unit = strtolower(substr($limit, -1)); |
| 356 |
$value = (int) $limit; |
| 357 |
|
| 358 |
switch ($unit) { |
| 359 |
case 'g': |
| 360 |
$value *= 1024 * 1024 * 1024; |
| 361 |
break; |
| 362 |
case 'm': |
| 363 |
$value *= 1024 * 1024; |
| 364 |
break; |
| 365 |
case 'k': |
| 366 |
$value *= 1024; |
| 367 |
break; |
| 368 |
} |
| 369 |
|
| 370 |
return $value; |
| 371 |
} |
| 372 |
|
| 373 |
/** |
| 374 |
* Resolve the configurable absolute document-size cap (bytes). |
| 375 |
* |
| 376 |
* @return int Maximum document size in bytes (0 disables the absolute cap) |
| 377 |
*/ |
| 378 |
public static function get_max_document_bytes() { |
| 379 |
$cap = defined('METASYNC_OTTO_MAX_HTML_BYTES') |
| 380 |
? (int) METASYNC_OTTO_MAX_HTML_BYTES |
| 381 |
: self::ABSOLUTE_MAX_DOCUMENT_BYTES; |
| 382 |
|
| 383 |
# Allow per-site tuning via filter (e.g. higher cap on large-memory hosts) |
| 384 |
if (function_exists('apply_filters')) { |
| 385 |
$cap = (int) apply_filters('metasync_otto_max_html_bytes', $cap); |
| 386 |
} |
| 387 |
|
| 388 |
return max(0, $cap); |
| 389 |
} |
| 390 |
|
| 391 |
/** |
| 392 |
* Decide whether a document is safe to DOM-parse without risking |
| 393 |
* a fatal out-of-memory error. |
| 394 |
* |
| 395 |
* Combines a configurable absolute byte cap with a dynamic check against |
| 396 |
* the actual memory headroom (memory_limit minus current usage), budgeting |
| 397 |
* DOM_MEMORY_AMPLIFICATION times the HTML size for the parse + reload cycle. |
| 398 |
* |
| 399 |
* @param int $html_length Length of the raw HTML in bytes |
| 400 |
* @return bool True if processing is safe, false if it should be skipped |
| 401 |
*/ |
| 402 |
public static function is_document_processable($html_length) { |
| 403 |
$html_length = (int) $html_length; |
| 404 |
|
| 405 |
if ($html_length <= 0) { |
| 406 |
return false; |
| 407 |
} |
| 408 |
|
| 409 |
# 1. Absolute configurable cap (backstop against pathological pages) |
| 410 |
$max = self::get_max_document_bytes(); |
| 411 |
if ($max > 0 && $html_length > $max) { |
| 412 |
return false; |
| 413 |
} |
| 414 |
|
| 415 |
# 2. Dynamic memory budget — adapts to the host's memory_limit |
| 416 |
$limit = self::get_memory_limit_bytes(); |
| 417 |
if ($limit === PHP_INT_MAX) { |
| 418 |
return true; # Unlimited memory_limit (-1) |
| 419 |
} |
| 420 |
|
| 421 |
$available = $limit - memory_get_usage(true); |
| 422 |
$needed = $html_length * self::DOM_MEMORY_AMPLIFICATION; |
| 423 |
|
| 424 |
return $needed < $available; |
| 425 |
} |
| 426 |
|
| 427 |
/** |
| 428 |
* Log a skipped oversized document once per request (warning, not fatal). |
| 429 |
* |
| 430 |
* @param string $context Where the skip happened (for the log line) |
| 431 |
* @param int $html_length Raw HTML length in bytes |
| 432 |
*/ |
| 433 |
public static function log_oversized_skip($context, $html_length) { |
| 434 |
# Record that this request's document could not be processed at all, as |
| 435 |
# opposed to a rewrite that was attempted and failed. The two are |
| 436 |
# indistinguishable to callers otherwise — both surface as |
| 437 |
# process_html_directly() returning false — but they need opposite |
| 438 |
# cache handling: a rewrite failure may succeed on the next request, while |
| 439 |
# an oversized document will exceed the budget every single time. Treating |
| 440 |
# the latter as a transient failure would cap that page's cache lifetime |
| 441 |
# permanently, and it is the most expensive page on the site to render. |
| 442 |
self::$document_unprocessable = true; |
| 443 |
|
| 444 |
$route = isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : 'unknown'; |
| 445 |
error_log(sprintf( |
| 446 |
'[MetaSync OTTO] Skipped DOM processing on %s (%s): document %s exceeds safe memory budget (limit %s, used %s, cap %s).', |
| 447 |
$route, |
| 448 |
$context, |
| 449 |
size_format($html_length), |
| 450 |
ini_get('memory_limit'), |
| 451 |
size_format(memory_get_usage(true)), |
| 452 |
size_format(self::get_max_document_bytes()) |
| 453 |
)); |
| 454 |
} |
| 455 |
|
| 456 |
/** |
| 457 |
* Build the response headers that cap how long an un-optimized response may |
| 458 |
* be cached. |
| 459 |
* |
| 460 |
* A pure function so the header set can be asserted directly: header() is a |
| 461 |
* no-op under the CLI SAPI and headers_list() is always empty there, so |
| 462 |
* emitting and then inspecting is not testable. It lives on this class rather |
| 463 |
* than beside its caller in otto_pixel.php because that file cannot be loaded |
| 464 |
* in a unit-test context (it pulls in simplehtmldom and the whole OTTO stack). |
| 465 |
* |
| 466 |
* Each header addresses a different layer: |
| 467 |
* |
| 468 |
* - Cache-Control CDNs, host edge caches, browsers. max-age=0 keeps |
| 469 |
* browsers revalidating while s-maxage lets shared |
| 470 |
* caches absorb a burst for the ceiling's duration. |
| 471 |
* - X-Accel-Expires Nginx's own directive, evaluated with higher |
| 472 |
* precedence than Cache-Control and not covered by the |
| 473 |
* `fastcgi_ignore_headers Cache-Control Expires` recipe |
| 474 |
* common on managed hosts, so it can survive a config |
| 475 |
* that discards Cache-Control. Nginx strips X-Accel-* |
| 476 |
* before the client sees it. |
| 477 |
* - Surrogate-Control Varnish and Fastly. On stock Varnish the mere presence |
| 478 |
* of this header suppresses its Cache-Control check, so |
| 479 |
* it must carry a real lifetime. |
| 480 |
* |
| 481 |
* Cache-tagging headers (Surrogate-Key, Cache-Tag, Edge-Cache-Tag) are |
| 482 |
* deliberately absent from this list and must not be removed elsewhere: they |
| 483 |
* are purge-targeting labels, not cacheability directives, and on a layer that |
| 484 |
* stores this response anyway they are the only handle a later purge has on it. |
| 485 |
* |
| 486 |
* @param int $ttl Cache ceiling in seconds. |
| 487 |
* @param string $reason Optional machine-readable reason for the debug header. |
| 488 |
* @param int|null $cache_control_ttl Shared lifetime for Cache-Control only, |
| 489 |
* when it must stay stricter than the |
| 490 |
* lifetime given to the other layers. |
| 491 |
* Defaults to $ttl. |
| 492 |
* @return array Header name => value. |
| 493 |
*/ |
| 494 |
public static function fallback_cache_headers($ttl, $reason = '', $cache_control_ttl = null) { |
| 495 |
$ttl = max(0, (int) $ttl); |
| 496 |
$cache_control_ttl = $cache_control_ttl === null ? $ttl : max(0, (int) $cache_control_ttl); |
| 497 |
|
| 498 |
$headers = [ |
| 499 |
'Cache-Control' => 'max-age=0, s-maxage=' . $cache_control_ttl . ', must-revalidate', |
| 500 |
'X-Accel-Expires' => (string) $ttl, |
| 501 |
'Surrogate-Control' => 'max-age=' . $ttl, |
| 502 |
]; |
| 503 |
|
| 504 |
if ($reason !== '' && self::diagnostics_enabled()) { |
| 505 |
$headers['X-MetaSync-OTTO-Fallback'] = $reason; |
| 506 |
} |
| 507 |
|
| 508 |
return $headers; |
| 509 |
} |
| 510 |
|
| 511 |
/** |
| 512 |
* Headers that forbid storage outright. |
| 513 |
* |
| 514 |
* Used instead of the capped set when the response must not be shared at all — |
| 515 |
* currently for logged-in users, whose page may carry session-specific or |
| 516 |
* membership-gated content. Capping such a response at 60 seconds would still |
| 517 |
* permit a shared cache to store and re-serve it. |
| 518 |
* |
| 519 |
* @param string $reason Optional machine-readable reason for the debug header. |
| 520 |
* @return array Header name => value. |
| 521 |
*/ |
| 522 |
public static function no_store_headers($reason = '') { |
| 523 |
$headers = [ |
| 524 |
'Cache-Control' => 'no-store, no-cache, private, must-revalidate, max-age=0', |
| 525 |
'X-Accel-Expires' => '0', |
| 526 |
'Surrogate-Control' => 'no-store', |
| 527 |
]; |
| 528 |
|
| 529 |
if ($reason !== '' && self::diagnostics_enabled()) { |
| 530 |
$headers['X-MetaSync-OTTO-Fallback'] = $reason; |
| 531 |
} |
| 532 |
|
| 533 |
return $headers; |
| 534 |
} |
| 535 |
|
| 536 |
/** |
| 537 |
* Build cache headers for a failed render without loosening an earlier host |
| 538 |
* cache decision. |
| 539 |
* |
| 540 |
* The headers that matter here are interpreted by different cache layers, |
| 541 |
* so the smallest known lifetime must be applied consistently to all of |
| 542 |
* them. A no-store directive anywhere wins outright. If a relevant existing |
| 543 |
* directive is not one we understand, leave it untouched rather than risk |
| 544 |
* replacing a stricter host policy. |
| 545 |
* |
| 546 |
* Cache-Control and Surrogate-Control carry a comma-separated list, so every |
| 547 |
* directive is judged on its own. Testing the recognised-directive list |
| 548 |
* against the whole header value instead rejects the multi-directive headers |
| 549 |
* hosts actually send — `public, must-revalidate` is two safe directives, not |
| 550 |
* one unknown one — and skips the cap the caller asked for. |
| 551 |
* |
| 552 |
* The recognised list covers the directives that do not extend how long a |
| 553 |
* response stays fresh: the cacheability and transform flags, plus |
| 554 |
* stale-while-revalidate and stale-if-error. Those two do widen the window in |
| 555 |
* which a stale copy may be served, but the set we emit replaces the host's |
| 556 |
* Cache-Control outright and carries must-revalidate, so neither survives to |
| 557 |
* act on the capped response. Anything genuinely unrecognised still backs off, |
| 558 |
* because it may be stricter than the cap and there is no safe way to tell. |
| 559 |
* |
| 560 |
* Surrogate-Control `content="..."` is deliberately NOT on the list. It |
| 561 |
* selects a processing mode (ESI) rather than a lifetime, and the header we |
| 562 |
* emit would replace it — disabling page assembly on sites that rely on it. |
| 563 |
* Backing off costs those sites the cap; allowlisting it could break how their |
| 564 |
* pages are built, which is the worse trade. |
| 565 |
* |
| 566 |
* A bail-out still returns the diagnostic reason header when Debug Mode is |
| 567 |
* on. Without it the response is indistinguishable from one where the |
| 568 |
* failure path never ran at all, which makes a missing cap invisible to |
| 569 |
* exactly the person trying to confirm it. |
| 570 |
* |
| 571 |
* One asymmetry is deliberate. A bare Cache-Control `max-age=0`, with no |
| 572 |
* s-maxage beside it, is honoured in Cache-Control — a host asking for |
| 573 |
* revalidation gets it — but is NOT copied into X-Accel-Expires or |
| 574 |
* Surrogate-Control. Writing 0 into those converts "revalidate before reuse" |
| 575 |
* into "never store", and every request lands on the origin for as long as |
| 576 |
* the render keeps failing. Writing nothing is worse still: those layers fall |
| 577 |
* back to their own default, often 24 hours, which is the exact pinning this |
| 578 |
* method exists to prevent. They therefore get the ordinary ceiling, which is |
| 579 |
* stricter than any default they would otherwise apply. |
| 580 |
* |
| 581 |
* The two headers do not stand on equal ground here, and the difference |
| 582 |
* matters when reading the header list documented on fallback_cache_headers(): |
| 583 |
* |
| 584 |
* - X-Accel-Expires nginx configured to discard Cache-Control never saw the |
| 585 |
* host's max-age at all, so nothing is being overridden. |
| 586 |
* - Surrogate-Control Varnish and Fastly CAN read Cache-Control; they simply |
| 587 |
* prefer Surrogate-Control when it is present. So this one |
| 588 |
* IS a deliberate, bounded exception: on those layers we |
| 589 |
* grant 60 seconds the host did not ask for. It is capped, |
| 590 |
* it only applies while a render is failing, and our set |
| 591 |
* carries must-revalidate — whereas honouring the 0 there |
| 592 |
* reinstates the origin-load problem this asymmetry exists |
| 593 |
* to solve. Chosen knowingly; not an oversight. |
| 594 |
* |
| 595 |
* An explicit X-Accel-Expires or Surrogate-Control from the host is a |
| 596 |
* different matter and is always honoured, 0 included: there the host did |
| 597 |
* speak in that layer's own channel. |
| 598 |
* |
| 599 |
* Cache-Control `no-cache` is handled on the same principle: it permits |
| 600 |
* storage and demands revalidation before reuse, so it is passed through |
| 601 |
* intact rather than flattened into no-store. `no-store` and `private` do |
| 602 |
* refuse a shared copy and still produce the no-store set. |
| 603 |
* |
| 604 |
* @param int $ttl Maximum fallback cache lifetime in seconds. |
| 605 |
* @param string $reason Optional machine-readable fallback reason. |
| 606 |
* @param array|null $headers Header lines to inspect; defaults to headers_list(). |
| 607 |
* @return array Header name => value. Carries no cache directives when an |
| 608 |
* existing one cannot be safely reconciled. |
| 609 |
*/ |
| 610 |
public static function fallback_cache_headers_for_response($ttl, $reason = '', $headers = null) { |
| 611 |
if ($headers === null) { |
| 612 |
$headers = function_exists('headers_list') ? headers_list() : []; |
| 613 |
} |
| 614 |
|
| 615 |
$ttl = max(0, (int) $ttl); |
| 616 |
|
| 617 |
# Lifetimes the host declared through a channel a shared cache reads as a |
| 618 |
# shared lifetime. These bind every layer we emit. |
| 619 |
$shared_ttls = []; |
| 620 |
|
| 621 |
# A bare Cache-Control max-age=0 means "revalidate before reuse", not |
| 622 |
# "never store". It binds Cache-Control, but must not be copied into |
| 623 |
# X-Accel-Expires or Surrogate-Control: those exist to reach layers that |
| 624 |
# ignore Cache-Control, and a 0 there converts the host's revalidate into a |
| 625 |
# storage ban those layers were never given. See the note below. |
| 626 |
$revalidate_only = []; |
| 627 |
|
| 628 |
$revalidate_required = false; |
| 629 |
|
| 630 |
foreach ((array) $headers as $line) { |
| 631 |
if (!is_string($line) || strpos($line, ':') === false) { |
| 632 |
continue; |
| 633 |
} |
| 634 |
|
| 635 |
list($name, $value) = explode(':', $line, 2); |
| 636 |
$name = strtolower(trim($name)); |
| 637 |
$value = trim($value); |
| 638 |
|
| 639 |
if ($name === 'cache-control' || $name === 'surrogate-control') { |
| 640 |
# no-store and private forbid a shared copy outright. no-cache does |
| 641 |
# not: it permits storage and requires revalidation before reuse, |
| 642 |
# which is the same distinction the bare max-age=0 handling rests |
| 643 |
# on. Flattening it to no-store would be stricter than the host |
| 644 |
# asked and would contradict that decision. |
| 645 |
if (preg_match('/\b(no-store|private)\b/i', $value)) { |
| 646 |
return self::no_store_headers($reason); |
| 647 |
} |
| 648 |
|
| 649 |
if (preg_match('/\bno-cache\b/i', $value)) { |
| 650 |
if ($name === 'cache-control') { |
| 651 |
$revalidate_required = true; |
| 652 |
continue; |
| 653 |
} |
| 654 |
|
| 655 |
# Surrogate-Control defines no-store, not no-cache. A surrogate |
| 656 |
# emitting it non-standardly is not something to reinterpret |
| 657 |
# generously, so it keeps the strict reading. |
| 658 |
return self::no_store_headers($reason); |
| 659 |
} |
| 660 |
|
| 661 |
$shared_ttl = null; |
| 662 |
$any_ttl = null; |
| 663 |
|
| 664 |
foreach (preg_split('/\s*,\s*/', $value, -1, PREG_SPLIT_NO_EMPTY) as $directive) { |
| 665 |
if (preg_match('/^s-maxage\s*=\s*(\d+)$/i', $directive, $matches)) { |
| 666 |
$shared_ttl = (int) $matches[1]; |
| 667 |
} elseif (preg_match('/^max-age\s*=\s*(\d+)$/i', $directive, $matches)) { |
| 668 |
$any_ttl = (int) $matches[1]; |
| 669 |
} elseif (!preg_match('/^(?:public|must-revalidate|proxy-revalidate|immutable|no-transform|stale-while-revalidate\s*=\s*\d+|stale-if-error\s*=\s*\d+)$/i', $directive)) { |
| 670 |
return self::diagnostics_only_headers($reason); |
| 671 |
} |
| 672 |
} |
| 673 |
|
| 674 |
# s-maxage governs shared caches and overrides max-age there, so it |
| 675 |
# wins when a header carries both. Surrogate-Control is only ever |
| 676 |
# read by a surrogate, so its max-age is a shared lifetime too. |
| 677 |
if ($shared_ttl !== null) { |
| 678 |
$shared_ttls[] = $shared_ttl; |
| 679 |
} elseif ($any_ttl !== null) { |
| 680 |
if ($name === 'surrogate-control' || $any_ttl > 0) { |
| 681 |
$shared_ttls[] = $any_ttl; |
| 682 |
} else { |
| 683 |
$revalidate_only[] = $any_ttl; |
| 684 |
} |
| 685 |
} |
| 686 |
continue; |
| 687 |
} |
| 688 |
|
| 689 |
if ($name === 'x-accel-expires') { |
| 690 |
if (preg_match('/^\d+$/', $value)) { |
| 691 |
$shared_ttls[] = (int) $value; |
| 692 |
} else { |
| 693 |
return self::diagnostics_only_headers($reason); |
| 694 |
} |
| 695 |
} |
| 696 |
} |
| 697 |
|
| 698 |
$shared = $shared_ttls ? min(array_merge([$ttl], $shared_ttls)) : $ttl; |
| 699 |
|
| 700 |
if ($revalidate_required) { |
| 701 |
return self::revalidate_headers($shared, $reason); |
| 702 |
} |
| 703 |
|
| 704 |
$cache_control = $revalidate_only |
| 705 |
? min(array_merge([$shared], $revalidate_only)) |
| 706 |
: $shared; |
| 707 |
|
| 708 |
return self::fallback_cache_headers($shared, $reason, $cache_control); |
| 709 |
} |
| 710 |
|
| 711 |
/** |
| 712 |
* Headers for a host that asked for revalidation rather than for storage to |
| 713 |
* be refused. |
| 714 |
* |
| 715 |
* A host sending Cache-Control: no-cache is saying a cache may keep this |
| 716 |
* response but must check with the origin before reusing it. That instruction |
| 717 |
* is passed through untouched, so any cache reading Cache-Control behaves |
| 718 |
* exactly as the host asked. The other two headers carry the ordinary ceiling |
| 719 |
* for the same reason a bare max-age=0 does not zero them: writing 0 there |
| 720 |
* turns a revalidation request into a storage ban for layers that were never |
| 721 |
* given one, and writing nothing hands those layers their own multi-hour |
| 722 |
* default back. |
| 723 |
* |
| 724 |
* @param int $ttl Cache ceiling in seconds for the non-Cache-Control layers. |
| 725 |
* @param string $reason Optional machine-readable reason for the debug header. |
| 726 |
* @return array Header name => value. |
| 727 |
*/ |
| 728 |
public static function revalidate_headers($ttl, $reason = '') { |
| 729 |
$ttl = max(0, (int) $ttl); |
| 730 |
|
| 731 |
$headers = [ |
| 732 |
'Cache-Control' => 'no-cache, must-revalidate', |
| 733 |
'X-Accel-Expires' => (string) $ttl, |
| 734 |
'Surrogate-Control' => 'max-age=' . $ttl, |
| 735 |
]; |
| 736 |
|
| 737 |
if ($reason !== '' && self::diagnostics_enabled()) { |
| 738 |
$headers['X-MetaSync-OTTO-Fallback'] = $reason; |
| 739 |
} |
| 740 |
|
| 741 |
return $headers; |
| 742 |
} |
| 743 |
|
| 744 |
/** |
| 745 |
* The diagnostic reason header on its own, with no cache directives. |
| 746 |
* |
| 747 |
* Used by the paths that deliberately emit nothing that affects caching, so |
| 748 |
* that "we ran and declined to touch the headers" is still distinguishable |
| 749 |
* from "we never ran". Debug Mode gates it, so a production response is |
| 750 |
* unchanged. |
| 751 |
* |
| 752 |
* @param string $reason Machine-readable fallback reason. |
| 753 |
* @return array Header name => value; empty unless Debug Mode is on. |
| 754 |
*/ |
| 755 |
private static function diagnostics_only_headers($reason = '') { |
| 756 |
if ($reason === '' || !self::diagnostics_enabled()) { |
| 757 |
return []; |
| 758 |
} |
| 759 |
|
| 760 |
return ['X-MetaSync-OTTO-Fallback' => $reason]; |
| 761 |
} |
| 762 |
|
| 763 |
/** |
| 764 |
* Clear per-request state held in statics. |
| 765 |
* |
| 766 |
* Under mod_php/PHP-FPM the process ends with the request, so these reset |
| 767 |
* themselves. Under a persistent-worker SAPI (FrankenPHP or Swoole worker mode, |
| 768 |
* `wp server`) they do not — and a latched $document_unprocessable would |
| 769 |
* suppress cache capping and failure reporting for every subsequent request in |
| 770 |
* that worker, which is the hardest kind of fault to notice because it makes |
| 771 |
* things go quiet rather than break. |
| 772 |
* |
| 773 |
* Called once per request from the single entry point all three render paths |
| 774 |
* pass through. |
| 775 |
*/ |
| 776 |
public static function reset_request_state() { |
| 777 |
self::$document_unprocessable = false; |
| 778 |
# HTTP-path cacheability signals: a latched value would carry one request's |
| 779 |
# session/cookie state into the next under a persistent-worker SAPI, which could |
| 780 |
# let a session page be treated as cacheable (or the reverse). |
| 781 |
self::$http_fetch_cookies = array(); |
| 782 |
self::$http_fetch_had_cookie = false; |
| 783 |
self::$http_fetch_no_cache = false; |
| 784 |
} |
| 785 |
|
| 786 |
/** |
| 787 |
* Whether this request's document was skipped as unprocessable. |
| 788 |
* |
| 789 |
* True means OTTO declined to parse the document (oversized / memory budget), |
| 790 |
* which is a permanent property of that page rather than a failure that might |
| 791 |
* clear on a retry. |
| 792 |
* |
| 793 |
* @return bool |
| 794 |
*/ |
| 795 |
public static function document_was_unprocessable() { |
| 796 |
return self::$document_unprocessable; |
| 797 |
} |
| 798 |
|
| 799 |
/** |
| 800 |
* Start output buffer for OTTO processing |
| 801 |
* Called early in WordPress lifecycle (template_redirect) |
| 802 |
* |
| 803 |
* @param array $suggestions OTTO suggestions data |
| 804 |
* @param string $route Current page route |
| 805 |
* @param Metasync_otto_html $otto_html OTTO HTML processor |
| 806 |
* @param array $blocking_flags SEO plugin blocking flags |
| 807 |
* @return bool True if buffer started successfully |
| 808 |
*/ |
| 809 |
public static function start_buffer($suggestions, $route, $otto_html, $blocking_flags = []) { |
| 810 |
if (self::$buffer_active) { |
| 811 |
return false; # Already buffering |
| 812 |
} |
| 813 |
|
| 814 |
# Store data for later processing |
| 815 |
self::$pending_suggestions = $suggestions; |
| 816 |
self::$pending_route = $route; |
| 817 |
self::$otto_html = $otto_html; |
| 818 |
self::$blocking_flags = $blocking_flags; |
| 819 |
self::$buffer_start_level = ob_get_level(); |
| 820 |
self::$current_method = self::METHOD_BUFFER; |
| 821 |
|
| 822 |
# Start output buffering with our callback |
| 823 |
$started = ob_start([__CLASS__, 'process_buffer']); |
| 824 |
|
| 825 |
if ($started) { |
| 826 |
self::$buffer_active = true; |
| 827 |
|
| 828 |
# Register shutdown handler to ensure buffer is processed |
| 829 |
register_shutdown_function([__CLASS__, 'shutdown_handler']); |
| 830 |
|
| 831 |
return true; |
| 832 |
} |
| 833 |
|
| 834 |
# Buffer failed to start, mark for HTTP fallback |
| 835 |
self::$current_method = self::METHOD_HTTP; |
| 836 |
return false; |
| 837 |
} |
| 838 |
|
| 839 |
/** |
| 840 |
* Process the captured output buffer |
| 841 |
* This is the callback for ob_start() |
| 842 |
* |
| 843 |
* CRITICAL: This callback MUST return the HTML string (original or modified) |
| 844 |
* If this fails, WordPress will show a blank page. Always return $html as fallback. |
| 845 |
* |
| 846 |
* @param string $html The captured HTML |
| 847 |
* @return string Modified HTML |
| 848 |
*/ |
| 849 |
public static function process_buffer($html) { |
| 850 |
# SAFETY FIRST: If anything goes wrong, always return original HTML |
| 851 |
# Never throw or cause errors - this would break the page |
| 852 |
|
| 853 |
|
| 854 |
try { |
| 855 |
# If HTML is empty or too short, WordPress likely crashed before generating output. |
| 856 |
# Override the cache headers OTTO already sent so downstream caches (WP Engine, |
| 857 |
# Kinsta, Cloudflare, etc.) don't store and serve this broken response. |
| 858 |
if (empty($html) || strlen($html) < 100) { |
| 859 |
if (!headers_sent()) { |
| 860 |
header('Cache-Control: no-store, no-cache, private'); |
| 861 |
header_remove('Surrogate-Key'); |
| 862 |
header_remove('Cache-Tag'); |
| 863 |
header_remove('Edge-Cache-Tag'); |
| 864 |
} |
| 865 |
return $html; |
| 866 |
} |
| 867 |
|
| 868 |
# Skip processing if no suggestions (page is fine, just no OTTO modifications needed) |
| 869 |
if (empty(self::$pending_suggestions)) { |
| 870 |
return $html; |
| 871 |
} |
| 872 |
|
| 873 |
# Skip if this doesn't look like HTML (error page, JSON response, etc.) |
| 874 |
if (stripos($html, '<html') === false && stripos($html, '<!DOCTYPE') === false) { |
| 875 |
return $html; |
| 876 |
} |
| 877 |
|
| 878 |
# Skip if this looks like an error page (WordPress fatal error) |
| 879 |
if (stripos($html, 'Fatal error') !== false || stripos($html, 'Parse error') !== false) { |
| 880 |
if (!headers_sent()) { |
| 881 |
header('Cache-Control: no-store, no-cache, private'); |
| 882 |
header_remove('Surrogate-Key'); |
| 883 |
header_remove('Cache-Tag'); |
| 884 |
header_remove('Edge-Cache-Tag'); |
| 885 |
} |
| 886 |
return $html; |
| 887 |
} |
| 888 |
|
| 889 |
|
| 890 |
# Process the HTML with OTTO modifications |
| 891 |
$modified_html = self::apply_otto_modifications($html); |
| 892 |
|
| 893 |
|
| 894 |
if ($modified_html !== false && !empty($modified_html)) { |
| 895 |
# Validate the modified HTML isn't corrupted |
| 896 |
if (strlen($modified_html) > strlen($html) * 0.5) { |
| 897 |
# Modified HTML is at least 50% of original size - seems valid |
| 898 |
|
| 899 |
# DEBUG: Check if title was actually changed in the HTML |
| 900 |
if (preg_match('/<title[^>]*>(.*?)<\/title>/is', $modified_html, $matches)) { |
| 901 |
} |
| 902 |
|
| 903 |
return $modified_html; |
| 904 |
} else { |
| 905 |
# The rewrite ran but the result is less than half the size of |
| 906 |
# the input, so it is assumed to be mangled and discarded. This |
| 907 |
# guard is deliberate, but it means a correct-looking request |
| 908 |
# still serves un-optimized markup — and until now it did so |
| 909 |
# silently, with normal cache headers, so a caching layer could |
| 910 |
# store it. Report it and keep it out of any cache. |
| 911 |
self::handle_render_failure( |
| 912 |
'RENDER_DISCARDED', |
| 913 |
'OTTO rewrite output failed the 50% size check and was discarded', |
| 914 |
[ |
| 915 |
'original_bytes' => strlen($html), |
| 916 |
'result_bytes' => strlen($modified_html), |
| 917 |
], |
| 918 |
'warning' |
| 919 |
); |
| 920 |
} |
| 921 |
} else { |
| 922 |
# apply_otto_modifications() returned false: either its |
| 923 |
# prerequisites were missing or it caught an exception internally. |
| 924 |
self::handle_render_failure( |
| 925 |
'RENDER_NO_OUTPUT', |
| 926 |
'OTTO rewrite produced no output', |
| 927 |
['original_bytes' => strlen($html)], |
| 928 |
'warning' |
| 929 |
); |
| 930 |
} |
| 931 |
} catch (Exception $e) { |
| 932 |
error_log('[MetaSync OTTO] Render exception on ' . ($_SERVER['REQUEST_URI'] ?? 'unknown') . ': ' . $e->getMessage()); |
| 933 |
self::handle_render_failure( |
| 934 |
'RENDER_EXCEPTION', |
| 935 |
'OTTO rewrite threw: ' . $e->getMessage(), |
| 936 |
['exception' => get_class($e)], |
| 937 |
'error' |
| 938 |
); |
| 939 |
} catch (Error $e) { |
| 940 |
error_log('[MetaSync OTTO] Render error on ' . ($_SERVER['REQUEST_URI'] ?? 'unknown') . ': ' . $e->getMessage()); |
| 941 |
self::handle_render_failure( |
| 942 |
'RENDER_EXCEPTION', |
| 943 |
'OTTO rewrite errored: ' . $e->getMessage(), |
| 944 |
['error' => get_class($e)], |
| 945 |
'error' |
| 946 |
); |
| 947 |
} |
| 948 |
|
| 949 |
# Return original HTML on any failure - NEVER return empty string |
| 950 |
return $html; |
| 951 |
} |
| 952 |
|
| 953 |
/** |
| 954 |
* Handle a render failure that is about to return un-optimized HTML. |
| 955 |
* |
| 956 |
* Marks the response uncacheable and reports the failure. Split out so the |
| 957 |
* several exit points in process_buffer() behave identically. |
| 958 |
* |
| 959 |
* Both helpers are looked up with function_exists() because this is a static |
| 960 |
* output-buffer callback and may run in contexts where otto_pixel.php's |
| 961 |
* function definitions are not loaded. |
| 962 |
* |
| 963 |
* Note on timing: this runs inside the output-buffer callback, so the response |
| 964 |
* body has been captured but not yet flushed — headers are still settable, and |
| 965 |
* headers_sent() is still false. The one case this cannot cover is a hard fatal |
| 966 |
* or OOM kill, where PHP dies without running any cleanup code at all. |
| 967 |
* |
| 968 |
* @param string $reason Machine-readable failure reason. |
| 969 |
* @param string $message Human-readable description. |
| 970 |
* @param array $context Extra context for the report. |
| 971 |
* @param string $level 'error' or 'warning'. |
| 972 |
*/ |
| 973 |
private static function handle_render_failure($reason, $message, $context = [], $level = 'error') { |
| 974 |
# An unprocessable document is not a failure to retry — it will exceed the |
| 975 |
# budget on every request. Capping its cache lifetime would make the most |
| 976 |
# expensive page on the site permanently uncacheable and re-report it every |
| 977 |
# throttle window forever, so leave it fully cacheable. log_oversized_skip() |
| 978 |
# has already written a line explaining the skip. |
| 979 |
if (self::document_was_unprocessable()) { |
| 980 |
return; |
| 981 |
} |
| 982 |
|
| 983 |
if (function_exists('metasync_otto_limit_response_cache')) { |
| 984 |
metasync_otto_limit_response_cache($reason); |
| 985 |
} |
| 986 |
|
| 987 |
if (function_exists('metasync_otto_report_render_failure')) { |
| 988 |
$context['render_method'] = self::$current_method ?? 'buffer'; |
| 989 |
metasync_otto_report_render_failure($reason, $message, $context, $level); |
| 990 |
} |
| 991 |
} |
| 992 |
|
| 993 |
/** |
| 994 |
* Apply OTTO modifications to HTML |
| 995 |
* |
| 996 |
* @param string $html Original HTML |
| 997 |
* @return string|false Modified HTML or false on failure |
| 998 |
*/ |
| 999 |
private static function apply_otto_modifications($html) { |
| 1000 |
# Validate prerequisites |
| 1001 |
if (!self::$otto_html || !self::$pending_suggestions) { |
| 1002 |
return false; |
| 1003 |
} |
| 1004 |
|
| 1005 |
# Verify the OTTO HTML processor has the required method |
| 1006 |
if (!method_exists(self::$otto_html, 'process_html_directly')) { |
| 1007 |
return false; |
| 1008 |
} |
| 1009 |
|
| 1010 |
try { |
| 1011 |
# Add blocking flags to suggestions |
| 1012 |
$suggestions = self::$pending_suggestions; |
| 1013 |
if (!empty(self::$blocking_flags)) { |
| 1014 |
$suggestions['_otto_blocking'] = self::$blocking_flags; |
| 1015 |
} |
| 1016 |
|
| 1017 |
# Process HTML directly (no HTTP request needed!) |
| 1018 |
$result = self::$otto_html->process_html_directly($html, $suggestions); |
| 1019 |
|
| 1020 |
if ($result) { |
| 1021 |
# Result is now an HTML string (not DOM object) |
| 1022 |
# Check if it's a string or still a DOM object for backwards compatibility |
| 1023 |
$result_string = is_string($result) ? $result : $result->__toString(); |
| 1024 |
|
| 1025 |
# Apply any post-processing fixes |
| 1026 |
$result_string = self::apply_post_processing($result_string); |
| 1027 |
|
| 1028 |
return $result_string; |
| 1029 |
} |
| 1030 |
} catch (Exception $e) { |
| 1031 |
error_log('[MetaSync OTTO] Modification exception on ' . ($_SERVER['REQUEST_URI'] ?? 'unknown') . ': ' . $e->getMessage()); |
| 1032 |
} catch (Error $e) { |
| 1033 |
error_log('[MetaSync OTTO] Modification error on ' . ($_SERVER['REQUEST_URI'] ?? 'unknown') . ': ' . $e->getMessage()); |
| 1034 |
} |
| 1035 |
|
| 1036 |
return false; |
| 1037 |
} |
| 1038 |
|
| 1039 |
/** |
| 1040 |
* Apply post-processing fixes to modified HTML |
| 1041 |
* |
| 1042 |
* @param string $html Modified HTML |
| 1043 |
* @return string Processed HTML |
| 1044 |
*/ |
| 1045 |
private static function apply_post_processing($html) { |
| 1046 |
# Fix for sliding headline layouts |
| 1047 |
if (strpos($html, 'pix-sliding-headline-2') !== false || strpos($html, 'pix-intro-sliding-text') !== false) { |
| 1048 |
$html = preg_replace( |
| 1049 |
'#(</span></span>)(<span\s+class=["\'][^"\']*slide-in-container[^"\']*["\'][^>]*>)#i', |
| 1050 |
'$1 $2', |
| 1051 |
$html |
| 1052 |
); |
| 1053 |
} |
| 1054 |
|
| 1055 |
# Fix for divi-pixel Timeline compatibility |
| 1056 |
# jQuery's .data() method auto-parses JSON, but Timeline script tries to JSON.parse it again |
| 1057 |
# This causes "[object Object]" is not valid JSON errors |
| 1058 |
# Solution: Inject a script that patches jQuery's .data() to return strings for data-config |
| 1059 |
if (strpos($html, 'dipi_timeline_item_custom_classes') !== false && strpos($html, 'Timeline.min.js') !== false) { |
| 1060 |
# Create a script that ensures data-config is read as a string, not auto-parsed object |
| 1061 |
$fix_script = '<script type="text/javascript">' . "\n" . |
| 1062 |
'(function() {' . "\n" . |
| 1063 |
' if (typeof jQuery !== "undefined") {' . "\n" . |
| 1064 |
' // Patch jQuery.data() to return string for data-config on timeline items' . "\n" . |
| 1065 |
' var originalData = jQuery.fn.data;' . "\n" . |
| 1066 |
' jQuery.fn.data = function(key, value) {' . "\n" . |
| 1067 |
' // If reading "config" from timeline items, return raw string from attribute' . "\n" . |
| 1068 |
' if (key === "config" && value === undefined && this.length > 0) {' . "\n" . |
| 1069 |
' var $first = jQuery(this[0]);' . "\n" . |
| 1070 |
' if ($first.hasClass("dipi_timeline_item_custom_classes")) {' . "\n" . |
| 1071 |
' var attrValue = $first.attr("data-config");' . "\n" . |
| 1072 |
' if (attrValue) {' . "\n" . |
| 1073 |
' // Decode HTML entities and return as string' . "\n" . |
| 1074 |
' var tempDiv = document.createElement("div");' . "\n" . |
| 1075 |
' tempDiv.innerHTML = attrValue;' . "\n" . |
| 1076 |
' return tempDiv.textContent || tempDiv.innerText || attrValue;' . "\n" . |
| 1077 |
' }' . "\n" . |
| 1078 |
' }' . "\n" . |
| 1079 |
' }' . "\n" . |
| 1080 |
' // For all other cases, use original jQuery.data()' . "\n" . |
| 1081 |
' return originalData.apply(this, arguments);' . "\n" . |
| 1082 |
' };' . "\n" . |
| 1083 |
' }' . "\n" . |
| 1084 |
'})();' . "\n" . |
| 1085 |
'</script>'; |
| 1086 |
|
| 1087 |
# Insert before closing body tag (before Timeline script runs) |
| 1088 |
$html = preg_replace( |
| 1089 |
'/(<\/body>)/i', |
| 1090 |
$fix_script . "\n" . '$1', |
| 1091 |
$html, |
| 1092 |
1 |
| 1093 |
); |
| 1094 |
} |
| 1095 |
|
| 1096 |
# Re-inject Divi's module-design CSS if missing from buffer output. |
| 1097 |
# SG Optimizer's parser strips late-injected CSS from the buffer. We read it |
| 1098 |
# from Divi's page resource manager (available in the current WP context) and |
| 1099 |
# inject it before </body>. This preserves correct module numbering (buffer |
| 1100 |
# approach) while ensuring the CSS is present. |
| 1101 |
if (strpos($html, 'et-builder-module-design') === false |
| 1102 |
&& function_exists('et_core_page_resource_get') |
| 1103 |
) { |
| 1104 |
$post_id = get_the_ID(); |
| 1105 |
if ($post_id) { |
| 1106 |
$resource_slug = et_theme_builder_decorate_page_resource_slug($post_id, 'module-design'); |
| 1107 |
$manager = et_core_page_resource_get('builder', $resource_slug, $post_id, 40); |
| 1108 |
if ($manager && method_exists($manager, 'get_data')) { |
| 1109 |
$css_data = $manager->get_data('inline'); |
| 1110 |
if (!empty($css_data)) { |
| 1111 |
$style_tag = '<style id="et-builder-' . esc_attr($resource_slug) . '-cached-inline-styles">' |
| 1112 |
. wp_strip_all_tags($css_data) . '</style>'; |
| 1113 |
$html = str_replace('</body>', $style_tag . "\n" . '</body>', $html); |
| 1114 |
} |
| 1115 |
} |
| 1116 |
} |
| 1117 |
} |
| 1118 |
|
| 1119 |
return $html; |
| 1120 |
} |
| 1121 |
|
| 1122 |
/** |
| 1123 |
* Shutdown handler to ensure buffer is properly processed |
| 1124 |
*/ |
| 1125 |
public static function shutdown_handler() { |
| 1126 |
if (!self::$buffer_active) { |
| 1127 |
return; |
| 1128 |
} |
| 1129 |
|
| 1130 |
# Get any remaining buffered content |
| 1131 |
$remaining_levels = ob_get_level() - self::$buffer_start_level; |
| 1132 |
|
| 1133 |
while ($remaining_levels > 0) { |
| 1134 |
ob_end_flush(); |
| 1135 |
$remaining_levels--; |
| 1136 |
} |
| 1137 |
|
| 1138 |
self::$buffer_active = false; |
| 1139 |
} |
| 1140 |
|
| 1141 |
/** |
| 1142 |
* Get the current render method being used |
| 1143 |
* |
| 1144 |
* @return string Current method (buffer, http, none) |
| 1145 |
*/ |
| 1146 |
public static function get_current_method() { |
| 1147 |
return self::$current_method ?: self::METHOD_NONE; |
| 1148 |
} |
| 1149 |
|
| 1150 |
/** |
| 1151 |
* Set the current render method (for HTTP fallback) |
| 1152 |
* |
| 1153 |
* @param string $method The method being used |
| 1154 |
*/ |
| 1155 |
public static function set_current_method($method) { |
| 1156 |
self::$current_method = $method; |
| 1157 |
} |
| 1158 |
|
| 1159 |
/** |
| 1160 |
* Non-alarming cache-status wording for the diagnostic header. |
| 1161 |
* |
| 1162 |
* RATE_LIMITED and API_ERROR both describe normal operation on a healthy |
| 1163 |
* site — the plugin's own call budget was reached, or OTTO has nothing for |
| 1164 |
* this URL — but they read as site failures and prompt false-alarm reports. |
| 1165 |
* Softened here, at emission only: the internal status strings that the |
| 1166 |
* cache and its tests rely on are never rewritten. |
| 1167 |
* |
| 1168 |
* @param string $cache_status Internal cache status |
| 1169 |
* @return string Wording to put on the wire |
| 1170 |
*/ |
| 1171 |
public static function display_cache_status($cache_status) { |
| 1172 |
$soft_wording = array( |
| 1173 |
'RATE_LIMITED' => 'THROTTLED', |
| 1174 |
'API_ERROR' => 'SOURCE_UNAVAILABLE', |
| 1175 |
); |
| 1176 |
|
| 1177 |
return $soft_wording[$cache_status] ?? $cache_status; |
| 1178 |
} |
| 1179 |
|
| 1180 |
/** |
| 1181 |
* Whether the X-MetaSync-OTTO-* diagnostic headers should be emitted |
| 1182 |
* |
| 1183 |
* These headers are observability-only — nothing in the plugin or the |
| 1184 |
* SearchAtlas backend reads them — so they stay off on public responses and |
| 1185 |
* are only sent while an operator has Debug Mode on. That keeps internal |
| 1186 |
* render/cache state (including values like RATE_LIMITED and API_ERROR, |
| 1187 |
* which read as failures on a perfectly healthy site) and the hardcoded |
| 1188 |
* product name on whitelabel installs off every front-end response. |
| 1189 |
* |
| 1190 |
* @return bool True when the diagnostic headers should be sent |
| 1191 |
*/ |
| 1192 |
public static function diagnostics_enabled() { |
| 1193 |
return class_exists('Metasync_Debug_Mode_Manager') |
| 1194 |
&& Metasync_Debug_Mode_Manager::is_enabled(); |
| 1195 |
} |
| 1196 |
|
| 1197 |
/** |
| 1198 |
* Record the HTTP-path internal fetch's Set-Cookie, used for two things: |
| 1199 |
* - the caching decision (a response cookie means a form/session page), and |
| 1200 |
* - pass-through to the visitor so their session actually reaches the browser. |
| 1201 |
* $same_origin gates pass-through only: a fetch rewritten to localhost/a tunnel host |
| 1202 |
* must never hand the visitor a cookie scoped to the wrong domain. |
| 1203 |
* |
| 1204 |
* @param string|array $set_cookie_header Raw Set-Cookie header line(s), or ''. |
| 1205 |
* @param bool $same_origin Fetch scheme+host matched the visitor's. |
| 1206 |
*/ |
| 1207 |
public static function set_http_fetch_cookie($set_cookie_header, $same_origin) { |
| 1208 |
self::$http_fetch_had_cookie = !empty($set_cookie_header); |
| 1209 |
self::$http_fetch_cookies = ($same_origin && !empty($set_cookie_header)) |
| 1210 |
? (array) $set_cookie_header |
| 1211 |
: array(); |
| 1212 |
} |
| 1213 |
|
| 1214 |
/** |
| 1215 |
* Whether the HTTP-path internal fetch response set any cookie. |
| 1216 |
* |
| 1217 |
* @return bool |
| 1218 |
*/ |
| 1219 |
public static function http_fetch_had_cookie() { |
| 1220 |
return self::$http_fetch_had_cookie; |
| 1221 |
} |
| 1222 |
|
| 1223 |
/** |
| 1224 |
* Record the internal fetch response's own `Cache-Control`. |
| 1225 |
* |
| 1226 |
* A `no-store` / `no-cache` / `private` there means the page's own render declared |
| 1227 |
* itself uncacheable — most commonly PHP's session cache-limiter firing from |
| 1228 |
* session_start(), which happens on resumed sessions too. Read as a signal only; the |
| 1229 |
* header is never passed on to the visitor. |
| 1230 |
* |
| 1231 |
* @param string|array $cache_control Raw Cache-Control header value(s), or ''. |
| 1232 |
*/ |
| 1233 |
public static function set_http_fetch_cache_control($cache_control) { |
| 1234 |
$value = is_array($cache_control) ? implode(', ', $cache_control) : (string) $cache_control; |
| 1235 |
|
| 1236 |
self::$http_fetch_no_cache = $value !== '' |
| 1237 |
&& preg_match('/\b(no-store|no-cache|private)\b/i', $value) === 1; |
| 1238 |
} |
| 1239 |
|
| 1240 |
/** |
| 1241 |
* Whether the internal fetch response declared itself uncacheable. |
| 1242 |
* |
| 1243 |
* @return bool |
| 1244 |
*/ |
| 1245 |
public static function http_fetch_no_cache() { |
| 1246 |
return self::$http_fetch_no_cache; |
| 1247 |
} |
| 1248 |
|
| 1249 |
/** |
| 1250 |
* Re-emit the internal fetch's Set-Cookie header(s) to the visitor so a form/session |
| 1251 |
* page rendered on the HTTP path actually gives the browser its session (without this |
| 1252 |
* the fetch cookie is discarded and such forms silently fail). Appends, never |
| 1253 |
* replaces. A page that sets a cookie is never cached (see block_http_cache()). |
| 1254 |
*/ |
| 1255 |
public static function passthrough_http_fetch_cookies() { |
| 1256 |
if (headers_sent() || empty(self::$http_fetch_cookies)) { |
| 1257 |
return; |
| 1258 |
} |
| 1259 |
foreach (self::$http_fetch_cookies as $line) { |
| 1260 |
if (is_string($line) && $line !== '') { |
| 1261 |
header('Set-Cookie: ' . $line, false); |
| 1262 |
} |
| 1263 |
} |
| 1264 |
} |
| 1265 |
|
| 1266 |
/** |
| 1267 |
* Does the current request carry a cookie that makes the page's CONTENT specific to |
| 1268 |
* this visitor — an active cart, a logged-in user, a password-protected post, or a |
| 1269 |
* remembered comment author? Those change the rendered markup (mini-cart totals, |
| 1270 |
* admin bar, protected content, pre-filled comment form), which no response header |
| 1271 |
* reliably announces, so the cookie is the only signal available. |
| 1272 |
* |
| 1273 |
* PHP session cookies are deliberately NOT checked here. A session that actually |
| 1274 |
* drives the page makes the render emit PHP's session cache-limiter headers, which |
| 1275 |
* http_fetch_no_cache() detects as server-side truth — on resumed sessions too. |
| 1276 |
* Guessing from the cookie instead over-blocked every visitor who merely carried a |
| 1277 |
* stale PHPSESSID from some unrelated page, costing caching while protecting nothing. |
| 1278 |
* |
| 1279 |
* Analytics and live-chat cookies (Hotjar _hjSession*, intercom-session-*, crisp |
| 1280 |
* .../session/...) and browsing-only cookies (woocommerce_recently_viewed) are ignored |
| 1281 |
* for the same reason: they do not change what the server renders. |
| 1282 |
* |
| 1283 |
* @return bool |
| 1284 |
*/ |
| 1285 |
public static function request_has_visitor_specific_cookie() { |
| 1286 |
# Plugins can mutate superglobals despite PHPStan's built-in array type. |
| 1287 |
# @phpstan-ignore-next-line function.alreadyNarrowedType |
| 1288 |
if (empty($_COOKIE) || !is_array($_COOKIE)) { |
| 1289 |
return false; |
| 1290 |
} |
| 1291 |
|
| 1292 |
foreach (array_keys($_COOKIE) as $name) { |
| 1293 |
# An active cart/session store, or an authenticated / privileged visitor. |
| 1294 |
# Deliberately NOT a broad `woocommerce_` prefix: `woocommerce_recently_viewed` |
| 1295 |
# is set by ordinary product browsing with no cart at all, and would make most |
| 1296 |
# of a store's traffic uncacheable. |
| 1297 |
if (strpos($name, 'wp_woocommerce_session_') === 0 |
| 1298 |
|| $name === 'woocommerce_cart_hash' |
| 1299 |
|| $name === 'woocommerce_items_in_cart' |
| 1300 |
|| $name === 'edd_items_in_cart' |
| 1301 |
|| $name === 'edd_cart_token' |
| 1302 |
|| strpos($name, 'wordpress_logged_in_') === 0 |
| 1303 |
|| strpos($name, 'wordpress_sec_') === 0 |
| 1304 |
|| strpos($name, 'wp-postpass_') === 0 |
| 1305 |
|| strpos($name, 'comment_author_') === 0 |
| 1306 |
) { |
| 1307 |
return true; |
| 1308 |
} |
| 1309 |
} |
| 1310 |
|
| 1311 |
return false; |
| 1312 |
} |
| 1313 |
|
| 1314 |
/** |
| 1315 |
* Is OTTO's final HTTP-path output complete enough to serve from a shared cache? A |
| 1316 |
* truncated or blank render must never be pinned for the cache TTL. Pragmatic tail |
| 1317 |
* check only (closing tags present); mid-body truncation is caught upstream by the |
| 1318 |
* builder-specific checks in render_via_http(). |
| 1319 |
* |
| 1320 |
* @param mixed $html OTTO's final serialized output. Typed mixed, not string, |
| 1321 |
* because a failed render hands this a non-string (null on |
| 1322 |
* a dropped response) and rejecting that is the point of |
| 1323 |
* the guard below — not a caller error to be assumed away. |
| 1324 |
* @return bool |
| 1325 |
*/ |
| 1326 |
public static function http_output_is_complete($html) { |
| 1327 |
if (!is_string($html) || strlen($html) < 255) { |
| 1328 |
return false; |
| 1329 |
} |
| 1330 |
|
| 1331 |
# Closing tags sit at the very end, so check the tail first: scanning the whole |
| 1332 |
# document costs ~0.5ms on a 450KB page, the tail ~1us. The full scan stays as a |
| 1333 |
# fallback so a page with unusual trailing output still gets the same answer. |
| 1334 |
$tail = substr($html, -8192); |
| 1335 |
if (stripos($tail, '</body>') !== false && stripos($tail, '</html>') !== false) { |
| 1336 |
return true; |
| 1337 |
} |
| 1338 |
|
| 1339 |
return stripos($html, '</body>') !== false && stripos($html, '</html>') !== false; |
| 1340 |
} |
| 1341 |
|
| 1342 |
/** |
| 1343 |
* Host-agnostic "do not cache" for the HTTP path: emit no-store headers and strip |
| 1344 |
* cache-tag headers so NO shared cache (SiteGround, LiteSpeed, W3TC, Varnish, |
| 1345 |
* Cloudflare, …) stores this page, and flip SiteGround's server switch too (SG |
| 1346 |
* ignores response headers for its own cache). Used for session/form pages and for |
| 1347 |
* renders that came back incomplete. |
| 1348 |
*/ |
| 1349 |
public static function block_http_cache($reason = 'HTTP_NOT_CACHEABLE') { |
| 1350 |
# Flip SiteGround's server switch FIRST (it sets its own sgo_* filters and |
| 1351 |
# DONOTCACHEPAGE — the only things SG honours), then emit the canonical no-store |
| 1352 |
# set LAST so it is the final word for every other shared cache. Reuses |
| 1353 |
# no_store_headers() rather than hand-rolling a second, weaker set: that one also |
| 1354 |
# carries X-Accel-Expires (nginx reads it ahead of Cache-Control) and |
| 1355 |
# Surrogate-Control, which a bare Cache-Control would miss on non-SiteGround hosts. |
| 1356 |
if (function_exists('metasync_otto_disable_sg_page_cache')) { |
| 1357 |
metasync_otto_disable_sg_page_cache(); |
| 1358 |
} |
| 1359 |
if (!headers_sent()) { |
| 1360 |
foreach (self::no_store_headers($reason) as $name => $value) { |
| 1361 |
header($name . ': ' . $value); |
| 1362 |
} |
| 1363 |
header('Pragma: no-cache'); |
| 1364 |
# Purge-by-tag headers would otherwise advertise this response as a cacheable, |
| 1365 |
# taggable object to a CDN that keys on them. |
| 1366 |
header_remove('Cache-Tag'); |
| 1367 |
header_remove('Surrogate-Key'); |
| 1368 |
header_remove('Edge-Cache-Tag'); |
| 1369 |
} |
| 1370 |
} |
| 1371 |
|
| 1372 |
/** |
| 1373 |
* Send response headers indicating render method and status |
| 1374 |
* |
| 1375 |
* @param string $cache_status Cache status (HIT, MISS, etc.) |
| 1376 |
*/ |
| 1377 |
public static function send_headers($cache_status = '') { |
| 1378 |
if (headers_sent()) { |
| 1379 |
return; |
| 1380 |
} |
| 1381 |
|
| 1382 |
# Check if WP Rocket is active — drives both the diagnostic header below |
| 1383 |
# and the Cache-Control decision further down |
| 1384 |
$wp_rocket_active = class_exists('WP_Rocket'); |
| 1385 |
|
| 1386 |
# Diagnostic headers: observability only, so gated behind Debug Mode. |
| 1387 |
# Everything after this block is functional (browser/CDN caching) and |
| 1388 |
# must keep running whether or not Debug Mode is on. |
| 1389 |
if (self::diagnostics_enabled()) { |
| 1390 |
# OTTO Render Method header |
| 1391 |
$method = self::get_current_method(); |
| 1392 |
if ($method === self::METHOD_BUFFER) { |
| 1393 |
$method_label = 'BUFFER'; |
| 1394 |
} elseif ($method === self::METHOD_WP_ROCKET) { |
| 1395 |
$method_label = 'WP_ROCKET_BUFFER'; // WP Rocket caches OTTO-modified HTML |
| 1396 |
} else { |
| 1397 |
$method_label = 'HTTP'; |
| 1398 |
} |
| 1399 |
header('X-MetaSync-OTTO-Method: ' . $method_label); |
| 1400 |
|
| 1401 |
# OTTO Cache Status header |
| 1402 |
if (!empty($cache_status)) { |
| 1403 |
header('X-MetaSync-OTTO-Cache: ' . self::display_cache_status($cache_status)); |
| 1404 |
} |
| 1405 |
|
| 1406 |
# OTTO Processed indicator |
| 1407 |
header('X-MetaSync-OTTO-Processed: true'); |
| 1408 |
|
| 1409 |
# Add compatibility indicator |
| 1410 |
if ($wp_rocket_active) { |
| 1411 |
header('X-MetaSync-OTTO-WPRocket: Compatible'); |
| 1412 |
} |
| 1413 |
} |
| 1414 |
|
| 1415 |
# Browser cache header only — let hosting (Kinsta, WP Engine, etc.) manage CDN TTL natively. |
| 1416 |
# Do NOT set s-maxage: it overrides hosting-level CDN purges (kinsta_cache_purge_full, |
| 1417 |
# WpeCommon::purge_varnish_cache) and prevents OTTO updates from appearing after a cache clear. |
| 1418 |
# Only set if WP Rocket is NOT active (let WP Rocket control cache headers) |
| 1419 |
# No cache or Vary header is emitted for anonymous responses. |
| 1420 |
# |
| 1421 |
# The HTTP path deliberately emits NO `Cache-Control`, matching the BUFFER path: |
| 1422 |
# a plain page is left cacheable by the host, and any session/form page or |
| 1423 |
# incomplete render is blocked separately via block_http_cache() in |
| 1424 |
# render_via_http(). Forcing `private` here previously blocked host caching |
| 1425 |
# site-wide → uncached PHP render on every hit → CPU spikes and slow first load. |
| 1426 |
# |
| 1427 |
# `Vary: Accept-Encoding` is likewise left to the transport layer. Compression is |
| 1428 |
# owned by the web server / compression module (mod_deflate, nginx `gzip_vary`, |
| 1429 |
# Cloudflare), which already appends this header whenever it gzip/brotli-encodes |
| 1430 |
# the response. OTTO's HTML does not vary by Accept-Encoding at the application |
| 1431 |
# level, so emitting it from PHP was purely redundant — and on hosts where the |
| 1432 |
# server ALSO sets it (the common case) the response carried a duplicate/merged |
| 1433 |
# `Vary: Accept-Encoding`, which makes Cloudflare APO and similar edge caches |
| 1434 |
# treat the page as non-cacheable and send every visitor to origin PHP. Leaving |
| 1435 |
# it to the compression layer yields exactly one, correct Vary. If a stricter |
| 1436 |
# Vary is ever required, add it through a single deduplicated writer, never a |
| 1437 |
# blind second `header()` call. |
| 1438 |
|
| 1439 |
# Cache-tag headers for CDN purge-by-tag (Phase 4). |
| 1440 |
# Only emitted on OTTO-processed singular pages when cache_tags_enabled is on. |
| 1441 |
if (is_singular()) { |
| 1442 |
$post_id = get_queried_object_id(); |
| 1443 |
if ($post_id > 0) { |
| 1444 |
$edge_options = class_exists('Metasync_Edge_Cache_Settings') |
| 1445 |
? Metasync_Edge_Cache_Settings::get_settings() |
| 1446 |
: get_option('metasync_edge_cache_options', array()); |
| 1447 |
if (!empty($edge_options['cache_tags_enabled'])) { |
| 1448 |
$tag = 'metasync-post-' . $post_id; |
| 1449 |
header('Cache-Tag: ' . $tag); // Cloudflare |
| 1450 |
header('Surrogate-Key: ' . $tag); // Fastly / Pantheon |
| 1451 |
header('Edge-Cache-Tag: ' . $tag); // Akamai |
| 1452 |
} |
| 1453 |
} |
| 1454 |
} |
| 1455 |
|
| 1456 |
} |
| 1457 |
|
| 1458 |
/** |
| 1459 |
* Check if buffer method is currently active |
| 1460 |
* |
| 1461 |
* @return bool |
| 1462 |
*/ |
| 1463 |
public static function is_buffer_active() { |
| 1464 |
return self::$buffer_active; |
| 1465 |
} |
| 1466 |
|
| 1467 |
/** |
| 1468 |
* Cancel buffer processing (for fallback to HTTP) |
| 1469 |
*/ |
| 1470 |
public static function cancel_buffer() { |
| 1471 |
if (self::$buffer_active) { |
| 1472 |
# End our buffer without processing |
| 1473 |
if (ob_get_level() > self::$buffer_start_level) { |
| 1474 |
ob_end_flush(); |
| 1475 |
} |
| 1476 |
self::$buffer_active = false; |
| 1477 |
self::$current_method = self::METHOD_HTTP; |
| 1478 |
} |
| 1479 |
} |
| 1480 |
|
| 1481 |
/** |
| 1482 |
* Get diagnostic information about current environment |
| 1483 |
* |
| 1484 |
* @return array Diagnostic data |
| 1485 |
*/ |
| 1486 |
public static function get_diagnostics() { |
| 1487 |
return [ |
| 1488 |
'php_version' => PHP_VERSION, |
| 1489 |
'wp_version' => get_bloginfo('version'), |
| 1490 |
'memory_limit' => ini_get('memory_limit'), |
| 1491 |
'memory_used' => size_format(memory_get_usage(true)), |
| 1492 |
'buffer_level' => ob_get_level(), |
| 1493 |
'headers_sent' => headers_sent(), |
| 1494 |
'is_admin' => is_admin(), |
| 1495 |
'is_ajax' => wp_doing_ajax(), |
| 1496 |
'is_logged_in' => is_user_logged_in(), |
| 1497 |
'current_method' => self::get_current_method(), |
| 1498 |
'buffer_active' => self::$buffer_active, |
| 1499 |
'detected_plugins' => [ |
| 1500 |
'wp_rocket' => class_exists('WP_Rocket'), |
| 1501 |
'w3tc' => defined('W3TC'), |
| 1502 |
'litespeed' => defined('LSCWP_V'), |
| 1503 |
'autoptimize' => class_exists('autoptimizeMain'), |
| 1504 |
'brizy' => class_exists('Brizy_Editor'), |
| 1505 |
], |
| 1506 |
'detected_hosts' => [ |
| 1507 |
'wp_engine' => defined('WPE_APIKEY') || getenv('IS_WPE'), |
| 1508 |
'kinsta' => defined('KINSTAMU_VERSION') || getenv('KINSTA_CACHE_ZONE'), |
| 1509 |
'cloudways' => isset($_SERVER['HTTP_X_VARNISH']), |
| 1510 |
'flywheel' => defined('FLYWHEEL_CONFIG_DIR'), |
| 1511 |
], |
| 1512 |
]; |
| 1513 |
} |
| 1514 |
|
| 1515 |
/** |
| 1516 |
* Is this request path something OTTO can never have suggestions for? |
| 1517 |
* |
| 1518 |
* Covers two families: |
| 1519 |
* |
| 1520 |
* - Protocol paths under /.well-known/ (ACME challenges, Cloudflare |
| 1521 |
* custom-hostname challenges). Such a challenge path was observed in |
| 1522 |
* production triggering a live, blocking OTTO API call. |
| 1523 |
* - Static assets. These normally never reach PHP, but they do on sites |
| 1524 |
* whose rewrites send missing files through WordPress. |
| 1525 |
* |
| 1526 |
* Both patterns are deliberately bounded. The /.well-known/ match requires a |
| 1527 |
* separator or end-of-string so a page slug that merely shares the prefix |
| 1528 |
* (/.well-knownsecrets/) is not swallowed, and the asset match is anchored |
| 1529 |
* at the end so a directory URL (/style.css/) or a non-final extension |
| 1530 |
* (/a.css.backup) stays OTTO-able. |
| 1531 |
* |
| 1532 |
* Deliberately does NOT cover robots.txt or favicon.ico: WordPress has exact |
| 1533 |
* conditionals for those, and a path pattern would misfire on real content |
| 1534 |
* such as /robots.txt-explained/. |
| 1535 |
* |
| 1536 |
* Deliberately typed loosely: the caller passes strtok($uri, '?'), which |
| 1537 |
* returns string|false, and the underlying $_SERVER value is not guaranteed |
| 1538 |
* to be a string at all. The guard below is therefore load-bearing, not |
| 1539 |
* defensive noise — preg_match() on a non-string would raise a TypeError. |
| 1540 |
* |
| 1541 |
* @param mixed $request_path Request path, query string already stripped. |
| 1542 |
* @return bool True when OTTO should be skipped for this request. |
| 1543 |
*/ |
| 1544 |
public static function is_non_content_path($request_path) { |
| 1545 |
if (empty($request_path) || !is_string($request_path)) { |
| 1546 |
return false; |
| 1547 |
} |
| 1548 |
|
| 1549 |
if (preg_match('#^/\\.well-known(?:/|$)#i', $request_path)) { |
| 1550 |
return true; |
| 1551 |
} |
| 1552 |
|
| 1553 |
return (bool) preg_match( |
| 1554 |
'#\\.(?:css|js|json|txt|map|ico|png|jpe?g|gif|svg|webp|avif|woff2?|ttf|eot|pdf|zip|mp4|webm)$#i', |
| 1555 |
$request_path |
| 1556 |
); |
| 1557 |
} |
| 1558 |
} |
| 1559 |
|
| 1560 |
|