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

785 lines 29.7 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 * Determine the best render method for current environment
83 *
84 * @return string METHOD_BUFFER or METHOD_HTTP
85 */
86 public static function determine_method() {
87 # Skip buffer for internal fetch requests (avoid infinite loop)
88 if (!empty($_GET['is_otto_page_fetch'])) {
89 return self::METHOD_NONE;
90 }
91
92 # Skip for admin, AJAX, REST API
93 if (is_admin() || wp_doing_ajax() || (defined('REST_REQUEST') && REST_REQUEST)) {
94 return self::METHOD_NONE;
95 }
96
97 # Check for known problematic configurations that need HTTP method
98 if (self::should_use_http_method()) {
99 return self::METHOD_HTTP;
100 }
101
102 # Check if output buffering is available and safe to use
103 if (self::is_buffer_available()) {
104 return self::METHOD_BUFFER;
105 }
106
107 # Fallback to HTTP method
108 return self::METHOD_HTTP;
109 }
110
111 /**
112 * Check if output buffering is available and safe
113 *
114 * @return bool
115 */
116 private static function is_buffer_available() {
117 # Check if output buffering is enabled
118 if (!function_exists('ob_start') || !function_exists('ob_get_clean')) {
119 return false;
120 }
121
122 # Check buffer nesting level (too many nested buffers = risky)
123 $current_level = ob_get_level();
124 if ($current_level > 5) {
125 return false;
126 }
127
128 # Check if headers already sent (can't use buffer properly)
129 if (headers_sent($file, $line)) {
130 return false;
131 }
132
133 # Check memory limit (need enough memory to buffer page)
134 $memory_limit = self::get_memory_limit_bytes();
135 $memory_used = memory_get_usage(true);
136 $memory_available = $memory_limit - $memory_used;
137
138 # Need at least 16MB available for buffering
139 if ($memory_available < 16 * 1024 * 1024) {
140 return false;
141 }
142
143 return true;
144 }
145
146 /**
147 * Check if HTTP method should be forced
148 *
149 * @return bool True if HTTP method should be used
150 */
151 private static function should_use_http_method() {
152 # Force HTTP method via constant (for debugging/testing)
153 if (defined('METASYNC_OTTO_FORCE_HTTP') && METASYNC_OTTO_FORCE_HTTP) {
154 return true;
155 }
156
157 # Check for known caching plugins that may conflict with output buffering
158 $conflicting_plugins = [
159 # Heavy output buffering plugins
160 'WP_Rocket' => class_exists('WP_Rocket'),
161 'W3TC' => defined('W3TC'),
162 'LiteSpeed_Cache' => defined('LSCWP_V'),
163
164 # SiteGround Optimizer — its Parser starts a nested ob_start() with callback
165 # that flushes before wp_footer completes, causing Divi's late-injected
166 # module-design CSS (<style id="et-builder-module-design-*">) to be lost.
167 # Affects all themes that defer inline CSS output to wp_footer (Divi 5, etc.).
168 'SG_CachePress' => defined('SiteGround_Optimizer\VERSION') || class_exists('SiteGround_Optimizer\Parser\Parser'),
169
170 # Page builders with aggressive buffering
171 'Brizy_Editor' => class_exists('Brizy_Editor'),
172 'Oxygen_Builder' => defined('CT_VERSION'), # Oxygen manipulates wp_current_filter causing CSS loading issues in PHP 8.3+
173
174 # Optimization plugins
175 'Autoptimize' => class_exists('autoptimizeMain'),
176 ];
177
178 foreach ($conflicting_plugins as $name => $is_active) {
179 if ($is_active) {
180 # OXYGEN BUILDER: Always use HTTP method due to wp_current_filter manipulation
181 # Oxygen manipulates $wp_current_filter during wp_head which breaks CSS loading in PHP 8.3+
182 # HTTP method bypasses the buffer and avoids the conflict entirely
183 if ($name === 'Oxygen_Builder') {
184 return true;
185 }
186
187 # SITEGROUND OPTIMIZER: Use HTTP method — buffer method + SimpleHtmlDom
188 # corrupts Divi module numbering (et_pb_blog_0 → et_pb_blog_1), breaking
189 # AJAX pagination. HTTP method preserves numbering; Divi CSS re-injection
190 # is handled in render_via_http() via et_core_page_resource_get API.
191 if ($name === 'SG_CachePress') {
192 return true;
193 }
194
195 # For logged-in users, buffer is usually safe even with these plugins
196 if (is_user_logged_in()) {
197 continue;
198 }
199
200 # For non-logged-in users with caching plugins, check if page caching is active
201 if ($name === 'WP_Rocket') {
202 # Get WP Rocket compatibility configuration
203 $metasync_options = get_option('metasync_options', []);
204 $wp_rocket_compat_mode = $metasync_options['general']['otto_wp_rocket_compat'] ?? 'auto';
205
206 # Force HTTP if user selected it
207 if ($wp_rocket_compat_mode === 'http') {
208 return true; # Use HTTP method for maximum compatibility
209 }
210
211 # Force buffer if user selected it
212 if ($wp_rocket_compat_mode === 'buffer') {
213 continue; # Use buffer method for speed
214 }
215
216 # For auto mode, use buffer so WP Rocket can continue optimizing the page
217 if ($wp_rocket_compat_mode === 'auto') {
218 continue;
219 }
220 }
221
222 # Default: use HTTP method for safety with caching plugins
223 // Removed logging to reduce noise - uncomment for debugging
224
225 # Actually, let's try buffer first for most cases
226 # Only force HTTP for specific known issues
227 }
228 }
229
230 # Check for managed hosts that may have issues
231 # WP Engine detection
232 if (defined('WPE_APIKEY') || getenv('IS_WPE')) {
233 # WP Engine serves cached pages at edge, but for uncached requests, buffer works
234 if (!is_user_logged_in() && !defined('DONOTCACHEPAGE')) {
235 # This might be a cached request that WP Engine serves from edge
236 # However, if we're here, PHP is executing, so buffer should work
237 }
238 }
239
240 # Kinsta detection
241 if (defined('KINSTAMU_VERSION') || getenv('KINSTA_CACHE_ZONE')) {
242 # Similar to WP Engine - if PHP is executing, buffer should work
243 }
244
245 # Cloudways Varnish
246 if (isset($_SERVER['HTTP_X_VARNISH'])) {
247 # Varnish is in front, but if we're here, request reached PHP
248 }
249
250 # Flywheel detection
251 if (defined('FLYWHEEL_CONFIG_DIR')) {
252 # Flywheel caching similar to WP Engine
253 }
254
255 # No known conflicts detected
256 return false;
257 }
258
259 /**
260 * Get memory limit in bytes
261 *
262 * @return int Memory limit in bytes
263 */
264 private static function get_memory_limit_bytes() {
265 $limit = ini_get('memory_limit');
266
267 if ($limit === '-1') {
268 return PHP_INT_MAX; # Unlimited
269 }
270
271 $unit = strtolower(substr($limit, -1));
272 $value = (int) $limit;
273
274 switch ($unit) {
275 case 'g':
276 $value *= 1024 * 1024 * 1024;
277 break;
278 case 'm':
279 $value *= 1024 * 1024;
280 break;
281 case 'k':
282 $value *= 1024;
283 break;
284 }
285
286 return $value;
287 }
288
289 /**
290 * Resolve the configurable absolute document-size cap (bytes).
291 *
292 * @return int Maximum document size in bytes (0 disables the absolute cap)
293 */
294 public static function get_max_document_bytes() {
295 $cap = defined('METASYNC_OTTO_MAX_HTML_BYTES')
296 ? (int) METASYNC_OTTO_MAX_HTML_BYTES
297 : self::ABSOLUTE_MAX_DOCUMENT_BYTES;
298
299 # Allow per-site tuning via filter (e.g. higher cap on large-memory hosts)
300 if (function_exists('apply_filters')) {
301 $cap = (int) apply_filters('metasync_otto_max_html_bytes', $cap);
302 }
303
304 return max(0, $cap);
305 }
306
307 /**
308 * Decide whether a document is safe to DOM-parse without risking
309 * a fatal out-of-memory error.
310 *
311 * Combines a configurable absolute byte cap with a dynamic check against
312 * the actual memory headroom (memory_limit minus current usage), budgeting
313 * DOM_MEMORY_AMPLIFICATION times the HTML size for the parse + reload cycle.
314 *
315 * @param int $html_length Length of the raw HTML in bytes
316 * @return bool True if processing is safe, false if it should be skipped
317 */
318 public static function is_document_processable($html_length) {
319 $html_length = (int) $html_length;
320
321 if ($html_length <= 0) {
322 return false;
323 }
324
325 # 1. Absolute configurable cap (backstop against pathological pages)
326 $max = self::get_max_document_bytes();
327 if ($max > 0 && $html_length > $max) {
328 return false;
329 }
330
331 # 2. Dynamic memory budget — adapts to the host's memory_limit
332 $limit = self::get_memory_limit_bytes();
333 if ($limit === PHP_INT_MAX) {
334 return true; # Unlimited memory_limit (-1)
335 }
336
337 $available = $limit - memory_get_usage(true);
338 $needed = $html_length * self::DOM_MEMORY_AMPLIFICATION;
339
340 return $needed < $available;
341 }
342
343 /**
344 * Log a skipped oversized document once per request (warning, not fatal).
345 *
346 * @param string $context Where the skip happened (for the log line)
347 * @param int $html_length Raw HTML length in bytes
348 */
349 public static function log_oversized_skip($context, $html_length) {
350 $route = isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : 'unknown';
351 error_log(sprintf(
352 '[MetaSync OTTO] Skipped DOM processing on %s (%s): document %s exceeds safe memory budget (limit %s, used %s, cap %s).',
353 $route,
354 $context,
355 size_format($html_length),
356 ini_get('memory_limit'),
357 size_format(memory_get_usage(true)),
358 size_format(self::get_max_document_bytes())
359 ));
360 }
361
362 /**
363 * Start output buffer for OTTO processing
364 * Called early in WordPress lifecycle (template_redirect)
365 *
366 * @param array $suggestions OTTO suggestions data
367 * @param string $route Current page route
368 * @param Metasync_otto_html $otto_html OTTO HTML processor
369 * @param array $blocking_flags SEO plugin blocking flags
370 * @return bool True if buffer started successfully
371 */
372 public static function start_buffer($suggestions, $route, $otto_html, $blocking_flags = []) {
373 if (self::$buffer_active) {
374 return false; # Already buffering
375 }
376
377 # Store data for later processing
378 self::$pending_suggestions = $suggestions;
379 self::$pending_route = $route;
380 self::$otto_html = $otto_html;
381 self::$blocking_flags = $blocking_flags;
382 self::$buffer_start_level = ob_get_level();
383 self::$current_method = self::METHOD_BUFFER;
384
385 # Start output buffering with our callback
386 $started = ob_start([__CLASS__, 'process_buffer']);
387
388 if ($started) {
389 self::$buffer_active = true;
390
391 # Register shutdown handler to ensure buffer is processed
392 register_shutdown_function([__CLASS__, 'shutdown_handler']);
393
394 return true;
395 }
396
397 # Buffer failed to start, mark for HTTP fallback
398 self::$current_method = self::METHOD_HTTP;
399 return false;
400 }
401
402 /**
403 * Process the captured output buffer
404 * This is the callback for ob_start()
405 *
406 * CRITICAL: This callback MUST return the HTML string (original or modified)
407 * If this fails, WordPress will show a blank page. Always return $html as fallback.
408 *
409 * @param string $html The captured HTML
410 * @return string Modified HTML
411 */
412 public static function process_buffer($html) {
413 # SAFETY FIRST: If anything goes wrong, always return original HTML
414 # Never throw or cause errors - this would break the page
415
416
417 try {
418 # If HTML is empty or too short, WordPress likely crashed before generating output.
419 # Override the cache headers OTTO already sent so downstream caches (WP Engine,
420 # Kinsta, Cloudflare, etc.) don't store and serve this broken response.
421 if (empty($html) || strlen($html) < 100) {
422 if (!headers_sent()) {
423 header('Cache-Control: no-store, no-cache, private');
424 header_remove('Surrogate-Key');
425 header_remove('Cache-Tag');
426 header_remove('Edge-Cache-Tag');
427 }
428 return $html;
429 }
430
431 # Skip processing if no suggestions (page is fine, just no OTTO modifications needed)
432 if (empty(self::$pending_suggestions)) {
433 return $html;
434 }
435
436 # Skip if this doesn't look like HTML (error page, JSON response, etc.)
437 if (stripos($html, '<html') === false && stripos($html, '<!DOCTYPE') === false) {
438 return $html;
439 }
440
441 # Skip if this looks like an error page (WordPress fatal error)
442 if (stripos($html, 'Fatal error') !== false || stripos($html, 'Parse error') !== false) {
443 if (!headers_sent()) {
444 header('Cache-Control: no-store, no-cache, private');
445 header_remove('Surrogate-Key');
446 header_remove('Cache-Tag');
447 header_remove('Edge-Cache-Tag');
448 }
449 return $html;
450 }
451
452
453 # Process the HTML with OTTO modifications
454 $modified_html = self::apply_otto_modifications($html);
455
456
457 if ($modified_html !== false && !empty($modified_html)) {
458 # Validate the modified HTML isn't corrupted
459 if (strlen($modified_html) > strlen($html) * 0.5) {
460 # Modified HTML is at least 50% of original size - seems valid
461
462 # DEBUG: Check if title was actually changed in the HTML
463 if (preg_match('/<title[^>]*>(.*?)<\/title>/is', $modified_html, $matches)) {
464 }
465
466 return $modified_html;
467 } else {
468 }
469 } else {
470 }
471 } catch (Exception $e) {
472 error_log('[MetaSync OTTO] Render exception on ' . ($_SERVER['REQUEST_URI'] ?? 'unknown') . ': ' . $e->getMessage());
473 } catch (Error $e) {
474 error_log('[MetaSync OTTO] Render error on ' . ($_SERVER['REQUEST_URI'] ?? 'unknown') . ': ' . $e->getMessage());
475 }
476
477 # Return original HTML on any failure - NEVER return empty string
478 return $html;
479 }
480
481 /**
482 * Apply OTTO modifications to HTML
483 *
484 * @param string $html Original HTML
485 * @return string|false Modified HTML or false on failure
486 */
487 private static function apply_otto_modifications($html) {
488 # Validate prerequisites
489 if (!self::$otto_html || !self::$pending_suggestions) {
490 return false;
491 }
492
493 # Verify the OTTO HTML processor has the required method
494 if (!method_exists(self::$otto_html, 'process_html_directly')) {
495 return false;
496 }
497
498 try {
499 # Add blocking flags to suggestions
500 $suggestions = self::$pending_suggestions;
501 if (!empty(self::$blocking_flags)) {
502 $suggestions['_otto_blocking'] = self::$blocking_flags;
503 }
504
505 # Process HTML directly (no HTTP request needed!)
506 $result = self::$otto_html->process_html_directly($html, $suggestions);
507
508 if ($result) {
509 # Result is now an HTML string (not DOM object)
510 # Check if it's a string or still a DOM object for backwards compatibility
511 $result_string = is_string($result) ? $result : $result->__toString();
512
513 # Apply any post-processing fixes
514 $result_string = self::apply_post_processing($result_string);
515
516 return $result_string;
517 }
518 } catch (Exception $e) {
519 error_log('[MetaSync OTTO] Modification exception on ' . ($_SERVER['REQUEST_URI'] ?? 'unknown') . ': ' . $e->getMessage());
520 } catch (Error $e) {
521 error_log('[MetaSync OTTO] Modification error on ' . ($_SERVER['REQUEST_URI'] ?? 'unknown') . ': ' . $e->getMessage());
522 }
523
524 return false;
525 }
526
527 /**
528 * Apply post-processing fixes to modified HTML
529 *
530 * @param string $html Modified HTML
531 * @return string Processed HTML
532 */
533 private static function apply_post_processing($html) {
534 # Fix for sliding headline layouts
535 if (strpos($html, 'pix-sliding-headline-2') !== false || strpos($html, 'pix-intro-sliding-text') !== false) {
536 $html = preg_replace(
537 '#(</span></span>)(<span\s+class=["\'][^"\']*slide-in-container[^"\']*["\'][^>]*>)#i',
538 '$1 $2',
539 $html
540 );
541 }
542
543 # Fix for divi-pixel Timeline compatibility
544 # jQuery's .data() method auto-parses JSON, but Timeline script tries to JSON.parse it again
545 # This causes "[object Object]" is not valid JSON errors
546 # Solution: Inject a script that patches jQuery's .data() to return strings for data-config
547 if (strpos($html, 'dipi_timeline_item_custom_classes') !== false && strpos($html, 'Timeline.min.js') !== false) {
548 # Create a script that ensures data-config is read as a string, not auto-parsed object
549 $fix_script = '<script type="text/javascript">' . "\n" .
550 '(function() {' . "\n" .
551 ' if (typeof jQuery !== "undefined") {' . "\n" .
552 ' // Patch jQuery.data() to return string for data-config on timeline items' . "\n" .
553 ' var originalData = jQuery.fn.data;' . "\n" .
554 ' jQuery.fn.data = function(key, value) {' . "\n" .
555 ' // If reading "config" from timeline items, return raw string from attribute' . "\n" .
556 ' if (key === "config" && value === undefined && this.length > 0) {' . "\n" .
557 ' var $first = jQuery(this[0]);' . "\n" .
558 ' if ($first.hasClass("dipi_timeline_item_custom_classes")) {' . "\n" .
559 ' var attrValue = $first.attr("data-config");' . "\n" .
560 ' if (attrValue) {' . "\n" .
561 ' // Decode HTML entities and return as string' . "\n" .
562 ' var tempDiv = document.createElement("div");' . "\n" .
563 ' tempDiv.innerHTML = attrValue;' . "\n" .
564 ' return tempDiv.textContent || tempDiv.innerText || attrValue;' . "\n" .
565 ' }' . "\n" .
566 ' }' . "\n" .
567 ' }' . "\n" .
568 ' // For all other cases, use original jQuery.data()' . "\n" .
569 ' return originalData.apply(this, arguments);' . "\n" .
570 ' };' . "\n" .
571 ' }' . "\n" .
572 '})();' . "\n" .
573 '</script>';
574
575 # Insert before closing body tag (before Timeline script runs)
576 $html = preg_replace(
577 '/(<\/body>)/i',
578 $fix_script . "\n" . '$1',
579 $html,
580 1
581 );
582 }
583
584 # Re-inject Divi's module-design CSS if missing from buffer output.
585 # SG Optimizer's parser strips late-injected CSS from the buffer. We read it
586 # from Divi's page resource manager (available in the current WP context) and
587 # inject it before </body>. This preserves correct module numbering (buffer
588 # approach) while ensuring the CSS is present.
589 if (strpos($html, 'et-builder-module-design') === false
590 && function_exists('et_core_page_resource_get')
591 ) {
592 $post_id = get_the_ID();
593 if ($post_id) {
594 $resource_slug = et_theme_builder_decorate_page_resource_slug($post_id, 'module-design');
595 $manager = et_core_page_resource_get('builder', $resource_slug, $post_id, 40);
596 if ($manager && method_exists($manager, 'get_data')) {
597 $css_data = $manager->get_data('inline');
598 if (!empty($css_data)) {
599 $style_tag = '<style id="et-builder-' . esc_attr($resource_slug) . '-cached-inline-styles">'
600 . wp_strip_all_tags($css_data) . '</style>';
601 $html = str_replace('</body>', $style_tag . "\n" . '</body>', $html);
602 }
603 }
604 }
605 }
606
607 return $html;
608 }
609
610 /**
611 * Shutdown handler to ensure buffer is properly processed
612 */
613 public static function shutdown_handler() {
614 if (!self::$buffer_active) {
615 return;
616 }
617
618 # Get any remaining buffered content
619 $remaining_levels = ob_get_level() - self::$buffer_start_level;
620
621 while ($remaining_levels > 0) {
622 ob_end_flush();
623 $remaining_levels--;
624 }
625
626 self::$buffer_active = false;
627 }
628
629 /**
630 * Get the current render method being used
631 *
632 * @return string Current method (buffer, http, none)
633 */
634 public static function get_current_method() {
635 return self::$current_method ?: self::METHOD_NONE;
636 }
637
638 /**
639 * Set the current render method (for HTTP fallback)
640 *
641 * @param string $method The method being used
642 */
643 public static function set_current_method($method) {
644 self::$current_method = $method;
645 }
646
647 /**
648 * Send response headers indicating render method and status
649 *
650 * @param string $cache_status Cache status (HIT, MISS, etc.)
651 */
652 public static function send_headers($cache_status = '') {
653 if (headers_sent()) {
654 return;
655 }
656
657 # OTTO Render Method header
658 $method = self::get_current_method();
659 if ($method === self::METHOD_BUFFER) {
660 $method_label = 'BUFFER';
661 } elseif ($method === self::METHOD_WP_ROCKET) {
662 $method_label = 'WP_ROCKET_BUFFER'; // WP Rocket caches OTTO-modified HTML
663 } else {
664 $method_label = 'HTTP';
665 }
666 header('X-MetaSync-OTTO-Method: ' . $method_label);
667
668 # OTTO Cache Status header
669 if (!empty($cache_status)) {
670 header('X-MetaSync-OTTO-Cache: ' . $cache_status);
671 }
672
673 # OTTO Processed indicator
674 header('X-MetaSync-OTTO-Processed: true');
675
676 # Check if WP Rocket is active
677 $wp_rocket_active = class_exists('WP_Rocket');
678
679 # Add compatibility indicator
680 if ($wp_rocket_active) {
681 header('X-MetaSync-OTTO-WPRocket: Compatible');
682 }
683
684 # Browser cache header only — let hosting (Kinsta, WP Engine, etc.) manage CDN TTL natively.
685 # Do NOT set s-maxage: it overrides hosting-level CDN purges (kinsta_cache_purge_full,
686 # WpeCommon::purge_varnish_cache) and prevents OTTO updates from appearing after a cache clear.
687 # Only set if WP Rocket is NOT active (let WP Rocket control cache headers)
688 if (!is_user_logged_in() && !$wp_rocket_active) {
689 $cache_duration = 3600; // 1 hour for browsers
690
691 # only the HTTP fallback path must force `private`. That path serves an
692 # internally-fetched copy of the page (handle_route_html) and keeps ONLY the body —
693 # the response `Set-Cookie` (e.g. PHPSESSID from Formidable etc.) is discarded — so the
694 # host/CDN cannot auto-skip caching form/session pages, and a shared copy would re-leak
695 # nonces across visitors (the regression).
696 #
697 # On the BUFFER path the visitor's own request renders the page, so its real `Set-Cookie`
698 # IS on the response; the host/CDN already auto-skips caching any response that sets a
699 # cookie, so form/session pages stay protected without us forcing `private`. Forcing it
700 # there blocked host/CDN caching site-wide → uncached PHP render on every hit → CPU spikes
701 # and slow first-load. So we let those pages be cached normally (no `private`).
702 if (self::get_current_method() === self::METHOD_HTTP) {
703 header('Cache-Control: private, max-age=' . $cache_duration);
704 }
705 header('Vary: Accept-Encoding');
706 }
707
708 # Cache-tag headers for CDN purge-by-tag (Phase 4).
709 # Only emitted on OTTO-processed singular pages when cache_tags_enabled is on.
710 if (is_singular()) {
711 $post_id = get_queried_object_id();
712 if ($post_id > 0) {
713 $edge_options = class_exists('Metasync_Edge_Cache_Settings')
714 ? Metasync_Edge_Cache_Settings::get_settings()
715 : get_option('metasync_edge_cache_options', array());
716 if (!empty($edge_options['cache_tags_enabled'])) {
717 $tag = 'metasync-post-' . $post_id;
718 header('Cache-Tag: ' . $tag); // Cloudflare
719 header('Surrogate-Key: ' . $tag); // Fastly / Pantheon
720 header('Edge-Cache-Tag: ' . $tag); // Akamai
721 }
722 }
723 }
724
725 }
726
727 /**
728 * Check if buffer method is currently active
729 *
730 * @return bool
731 */
732 public static function is_buffer_active() {
733 return self::$buffer_active;
734 }
735
736 /**
737 * Cancel buffer processing (for fallback to HTTP)
738 */
739 public static function cancel_buffer() {
740 if (self::$buffer_active) {
741 # End our buffer without processing
742 if (ob_get_level() > self::$buffer_start_level) {
743 ob_end_flush();
744 }
745 self::$buffer_active = false;
746 self::$current_method = self::METHOD_HTTP;
747 }
748 }
749
750 /**
751 * Get diagnostic information about current environment
752 *
753 * @return array Diagnostic data
754 */
755 public static function get_diagnostics() {
756 return [
757 'php_version' => PHP_VERSION,
758 'wp_version' => get_bloginfo('version'),
759 'memory_limit' => ini_get('memory_limit'),
760 'memory_used' => size_format(memory_get_usage(true)),
761 'buffer_level' => ob_get_level(),
762 'headers_sent' => headers_sent(),
763 'is_admin' => is_admin(),
764 'is_ajax' => wp_doing_ajax(),
765 'is_logged_in' => is_user_logged_in(),
766 'current_method' => self::get_current_method(),
767 'buffer_active' => self::$buffer_active,
768 'detected_plugins' => [
769 'wp_rocket' => class_exists('WP_Rocket'),
770 'w3tc' => defined('W3TC'),
771 'litespeed' => defined('LSCWP_V'),
772 'autoptimize' => class_exists('autoptimizeMain'),
773 'brizy' => class_exists('Brizy_Editor'),
774 ],
775 'detected_hosts' => [
776 'wp_engine' => defined('WPE_APIKEY') || getenv('IS_WPE'),
777 'kinsta' => defined('KINSTAMU_VERSION') || getenv('KINSTA_CACHE_ZONE'),
778 'cloudways' => isset($_SERVER['HTTP_X_VARNISH']),
779 'flywheel' => defined('FLYWHEEL_CONFIG_DIR'),
780 ],
781 ];
782 }
783 }
784
785