PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.2
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.2
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
metasync / otto / class-metasync-otto-render-strategy.php

class-metasync-otto-render-strategy.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.7.2, at otto/class-metasync-otto-render-strategy.php

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