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

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