PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.0
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.0
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.0, at otto/class-metasync-otto-render-strategy.php

1,560 lines 66.4 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'])) {
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