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

Otto_pixel_class.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.7.2, at otto/Otto_pixel_class.php

777 lines 37.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 // If this file is called directly, abort.
3 if (!defined('ABSPATH')) {
4 exit;
5 }
6
7 /**
8 * This class handles the Otto Pixel Functions
9 */
10 Class Metasync_otto_pixel{
11
12 #otto html class
13 public $o_html;
14
15 # no cache wp pages
16 public $no_cache_pages = ['wp-login.php'];
17
18 # OTTO UUID
19 private $otto_uuid;
20
21 #
22 function __construct($otto_uuid){
23
24 # store the UUID
25 $this->otto_uuid = $otto_uuid;
26
27 # load the html class
28 $this->o_html = new Metasync_otto_html($otto_uuid);
29
30 }
31
32 # method to handle cache refresh
33 # NOTE: Cache system removed - always process in real-time
34 function refresh_cache($route){
35 # No-op: Cache system has been removed
36 # All pages are processed in real-time from OTTO API
37 return true;
38 }
39
40 # get the current route
41 function get_route(){
42
43 # check if we're in an HTTP context
44 if(empty($_SERVER['HTTP_HOST']) || empty($_SERVER['REQUEST_URI'])){
45 # not in HTTP context (CLI, cron, etc.), return false
46 return false;
47 }
48
49 # get req scheme
50 $scheme = ( is_ssl() ? 'https' : 'http' );
51
52 # get req host
53 $host = $_SERVER['HTTP_HOST'];
54
55 # get the uri — strip query string before building the route.
56 # OTTO suggestions are canonical-URL-based; query parameters (Kinsta cache-bypass
57 # tokens, UTM params, tracking params, etc.) never produce different OTTO content.
58 # Stripping them ensures that:
59 # - The transient populated via a Kinsta BYPASS request (always has ?params) is
60 # shared with the clean-URL MISS request (no params).
61 # - The OTTO API is called with the canonical URL, not a noisy variant.
62 $request_uri = strtok($_SERVER['REQUEST_URI'], '?') ?: $_SERVER['REQUEST_URI'];
63
64 # return the formatted url
65 return $scheme . '://' . $host . $request_uri;
66 }
67
68 # get the html for a route
69 # OPTION 1 IMPLEMENTATION: Use transient cache for suggestions
70 function get_route_html($route, $cache_track_key = null, $suggestions = null){
71 # If suggestions already provided (from render_route_html), use them directly
72 if ($suggestions !== null && is_array($suggestions)) {
73 # Process route with provided suggestions data
74 return $this->o_html->process_route_with_data($route, $suggestions, '');
75 }
76
77 # Get OTTO UUID from options
78 global $metasync_options;
79 $otto_uuid = $metasync_options['general']['otto_pixel_uuid'] ?? '';
80
81 if (empty($otto_uuid)) {
82 return false;
83 }
84
85 # Get suggestions from transient cache (with API fallback)
86 $transient_cache = new Metasync_Otto_Transient_Cache($otto_uuid);
87 $track_key = $cache_track_key ?: md5($route);
88 $suggestions = $transient_cache->get_suggestions($route, $track_key);
89
90 if (!$suggestions || !$transient_cache->has_payload($suggestions)) {
91 # No suggestions available
92 return false;
93 }
94
95 # Process route with cached suggestions data
96 return $this->o_html->process_route_with_data($route, $suggestions, '');
97 }
98
99
100 # render route html
101 function render_route_html(){
102
103 # Headless frontends do not consume WordPress-rendered HTML, so avoid
104 # suggestion lookups, buffering and internal fetches altogether.
105 # Falls back to a guarded direct read rather than skipping the check:
106 # silently skipping would render OTTO markup on a headless site.
107 # @phpstan-ignore-next-line function.alreadyNarrowedType
108 $headless = function_exists('metasync_headless_is_active')
109 ? metasync_headless_is_active()
110 : (class_exists('Metasync_Headless_Config') && Metasync_Headless_Config::is_active());
111
112 if ($headless) {
113 return;
114 }
115
116 # Clear per-request statics before any path runs. Harmless under PHP-FPM
117 # (the process ends with the request) but required under persistent-worker
118 # SAPIs, where a latched flag would silently disable cache capping and
119 # failure reporting for every later request in that worker.
120 #
121 # method_exists() is redundant to static analysis — this MR adds the method,
122 # so PHPStan proves the call always true. It is kept for the upgrade window:
123 # during a plugin update an opcache can still hold the previous
124 # Metasync_Otto_Render_Strategy, where class_exists() passes but the method
125 # is absent, and an unguarded static call would fatal the page.
126 # @phpstan-ignore-next-line function.alreadyNarrowedType
127 if (class_exists('Metasync_Otto_Render_Strategy') && method_exists('Metasync_Otto_Render_Strategy', 'reset_request_state')) {
128 Metasync_Otto_Render_Strategy::reset_request_state();
129 }
130
131 # Disable SG Cache for Brizy pages FIRST - before any other processing
132 # Using global function defined in otto_pixel.php
133 if (function_exists('metasync_otto_disable_sg_cache_for_brizy')) {
134 metasync_otto_disable_sg_cache_for_brizy();
135 }
136
137 # get the route
138 $route = $this->get_route();
139
140 # get the current page from globas
141 # this is to help us exclude the login page
142
143 $page_now = $GLOBALS['pagenow'] ?? false;
144
145 # check whether page now in excluded pages
146 if(in_array($page_now , $this->no_cache_pages)){
147
148 # stop otto
149 return;
150 }
151
152 /*
153 #uncomment to test on local hosts
154 if(!empty($_GET['otto_test'])){
155
156 # @dev
157 $route = 'https://staging-perm.wp65.qa.internal.searchatlas.com/';
158 }
159 */
160
161 # OPTION 1 IMPLEMENTATION: Check transient cache instead of notification data
162 # This makes the system self-healing - works even if notifications fail
163
164 # Get OTTO UUID from options
165 global $metasync_options;
166 $otto_uuid = $metasync_options['general']['otto_pixel_uuid'] ?? '';
167
168 if (empty($otto_uuid)) {
169 return;
170 }
171
172 $transient_cache = new Metasync_Otto_Transient_Cache($otto_uuid);
173
174 # Create tracking key for cache status
175 $cache_track_key = 'otto_' . md5($route);
176
177 # Check if URL has OTTO suggestions (checks transient, calls API if needed)
178 # Use get_suggestions directly to track cache status
179 $suggestions = $transient_cache->get_suggestions($route, $cache_track_key);
180
181 if (!$suggestions || !$transient_cache->has_payload($suggestions)) {
182 # No suggestions available for this URL.
183 $cache_status = Metasync_Otto_Transient_Cache::get_cache_status($cache_track_key);
184
185 # Two very different situations reach this point, and they must be
186 # treated differently.
187 #
188 # A transient failure means the suggestions exist upstream but could
189 # not be retrieved on this request — the API errored or timed out, the
190 # per-minute budget was spent, or another worker held the lock. The
191 # next request will probably succeed. Serving the un-optimized page
192 # with normal cache headers lets a caching layer store it and hand it
193 # to everyone until its TTL expires, so cap how long it may be kept.
194 #
195 # Everything else here is a legitimate answer: OTTO simply has nothing
196 # for this URL (NO_SUGGESTIONS, including the 404 case). Most URLs on
197 # most sites are in that state permanently, so capping those would
198 # strip caching from the bulk of the site and move that load onto the
199 # origin. Those stay fully cacheable.
200 $transient_failures = ['RATE_LIMITED', 'LOCKED', 'API_ERROR'];
201
202 if (in_array($cache_status, $transient_failures, true)
203 && function_exists('metasync_otto_limit_response_cache')) {
204 metasync_otto_limit_response_cache($cache_status);
205 }
206
207 # Set diagnostic headers only while Debug Mode is enabled.
208 if (!headers_sent() && Metasync_Otto_Render_Strategy::diagnostics_enabled()) {
209 header('X-MetaSync-OTTO-Cache: ' . Metasync_Otto_Render_Strategy::display_cache_status($cache_status ?: 'NO_SUGGESTIONS'));
210 header('X-MetaSync-OTTO-Method: NONE');
211 }
212 return;
213 }
214
215 # comment out for fixing pagination issues
216 # $route = rtrim($route, '/');
217
218 # SiteGround's page cache is no longer disabled up-front for every OTTO page.
219 # The cacheability decision now happens on the HTTP path in render_via_http(),
220 # AFTER the internal fetch — only session/form pages and incomplete renders are
221 # blocked, so plain content pages are finally cacheable. (SiteGround always uses
222 # the HTTP path; the disable is a no-op off SiteGround, so nothing else loses it.)
223
224 # Analyze what OTTO is providing for SEO plugin blocking
225 $blocking_flags = $this->analyze_otto_blocking($suggestions);
226
227 # Get cache status for headers
228 $cache_status = Metasync_Otto_Transient_Cache::get_cache_status($cache_track_key);
229
230 # POSTMETA BACKSTOP: a MISS means this request already paid for a live
231 # OTTO fetch, which makes it the cheapest possible place to repair the
232 # stored SEO meta when the crawl-notify webhook never ran. The sync is
233 # deferred to a shutdown callback that runs after the response HTML is
234 # built, so the rendered page and its timing are untouched. A HIT never
235 # reaches this branch — steady-state requests keep doing zero extra
236 # work. The class_exists()/method_exists() pair mirrors the guard
237 # above: during a plugin update an opcache can hold this file without
238 # the new class, and an unguarded static call would fatal the page.
239 # The whole hook sits in try/catch: in the mixed-opcache window a missing
240 # class constant is an Error, and the backstop must never fatal the page.
241 try {
242 $is_cache_miss = ($cache_status === Metasync_Otto_Transient_Cache::STATUS_MISS);
243 # @phpstan-ignore-next-line function.alreadyNarrowedType
244 if ($is_cache_miss && class_exists('Metasync_Otto_Backstop_Sync') && method_exists('Metasync_Otto_Backstop_Sync', 'maybe_defer_sync')) {
245 Metasync_Otto_Backstop_Sync::maybe_defer_sync($route, $suggestions, $cache_status);
246 }
247 } catch (Throwable $backstop_error) {
248 // Best-effort repair only; rendering continues untouched.
249 unset($backstop_error);
250 }
251
252 # RENDER PRIORITY:
253 # 1. WP Rocket rocket_buffer filter — WP Rocket caches OTTO-modified HTML,
254 # so Kinsta also caches the correct version. No exit(), no cache bypass.
255 # 2. Output Buffer (fast) — for environments without WP Rocket.
256 # 3. HTTP Request (last resort) — exits early, bypasses all caching.
257 try {
258 # Safety check: ensure render strategy class exists
259 if (!class_exists('Metasync_Otto_Render_Strategy')) {
260 # Class not loaded - use HTTP method as fallback
261 $this->render_via_http($route, $suggestions, $cache_track_key, $cache_status);
262 return;
263 }
264
265 # WP ROCKET PATH: highest priority when WP Rocket is active.
266 # rocket_buffer fires inside WP Rocket's own cache pipeline, so the
267 # cache file WP Rocket writes (and that Kinsta stores) is already
268 # OTTO-modified. Eliminates the race where WP Rocket saved pre-OTTO HTML.
269 if (defined('WP_ROCKET_VERSION') || class_exists('WP_Rocket')) {
270 $wp_rocket_compat_mode = Metasync_Otto_Config::get_wp_rocket_compat_mode();
271 if ($wp_rocket_compat_mode !== 'http') {
272 # disable_otto exits in metasync_start_otto() before we get here, so
273 # only 'http' compat mode needs to skip the rocket_buffer path.
274 $this->render_via_rocket_buffer($suggestions, $blocking_flags, $cache_status);
275 return;
276 }
277 }
278
279 $render_method = Metasync_Otto_Render_Strategy::determine_method();
280
281 if ($render_method === Metasync_Otto_Render_Strategy::METHOD_BUFFER) {
282 # FAST PATH: Use output buffer approach
283 $result = $this->render_via_buffer($route, $suggestions, $blocking_flags, $cache_status);
284
285 if ($result === true) {
286 # Buffer is now active, WordPress will continue rendering
287 # OTTO modifications will be applied when buffer flushes
288 return;
289 }
290
291 # Buffer approach failed, fall back to HTTP method
292 $render_method = Metasync_Otto_Render_Strategy::METHOD_HTTP;
293 }
294
295 if ($render_method === Metasync_Otto_Render_Strategy::METHOD_HTTP) {
296 # FALLBACK PATH: Use traditional HTTP request approach
297 $this->render_via_http($route, $suggestions, $cache_track_key, $cache_status);
298 }
299 } catch (Exception $e) {
300 # Any exception in the render strategy - fall back to HTTP method
301 $this->render_via_http($route, $suggestions, $cache_track_key, $cache_status);
302 } catch (Error $e) {
303 # PHP 7+ Error (like TypeError) - fall back to HTTP method
304 $this->render_via_http($route, $suggestions, $cache_track_key, $cache_status);
305 }
306 }
307
308 /**
309 * Stop WP Rocket writing the current response to its disk cache.
310 *
311 * DONOTCACHEPAGE is not sufficient from inside the rocket_buffer filter: WP
312 * Rocket reads that constant when it decides whether to buffer the request,
313 * which has already happened by the time the filter runs. It evaluates
314 * do_rocket_generate_caching_files immediately before writing the file, so
315 * that is the only lever still effective at this point.
316 *
317 * This matters more than the response headers do. A page served from WP
318 * Rocket's disk cache is delivered by rewrite rules without invoking PHP, so
319 * once a bad file is on disk no header can override it — only deleting the
320 * file or waiting out Rocket's own expiry will clear it.
321 *
322 * PHP_INT_MAX priority so a site's own filters cannot re-enable the write.
323 */
324 private static function block_rocket_cache_write() {
325 if (!defined('DONOTCACHEPAGE')) {
326 define('DONOTCACHEPAGE', true);
327 }
328
329 add_filter('do_rocket_generate_caching_files', '__return_false', PHP_INT_MAX);
330 }
331
332 /**
333 * Render page using WP Rocket's rocket_buffer filter (PREFERRED for WP Rocket sites)
334 *
335 * When WP Rocket is active, hooking into rocket_buffer ensures WP Rocket
336 * caches the OTTO-modified HTML. This means:
337 * - WP Rocket's cache file = OTTO-modified HTML ✓
338 * - Kinsta FastCGI cache = WP Rocket output = OTTO-modified HTML ✓
339 * - No exit() = proper cache lifecycle ✓
340 *
341 * The filter fires on every uncached PHP request. Once WP Rocket has cached
342 * the result, subsequent requests are served directly from cache with zero PHP.
343 *
344 * @param array $suggestions OTTO suggestions data
345 * @param array $blocking_flags SEO plugin blocking flags
346 * @param string $cache_status Cache status for X-MetaSync-* headers
347 */
348 private function render_via_rocket_buffer($suggestions, $blocking_flags, $cache_status) {
349 $o_html = $this->o_html;
350
351 # Hook into WP Rocket's HTML buffer at priority 1 so other rocket_buffer
352 # filters (minifiers, CDN rewriters, etc.) run on already-OTTO-modified HTML.
353 add_filter('rocket_buffer', function($html) use ($o_html, $suggestions, $blocking_flags) {
354 if (empty($html) || strlen($html) < 100) {
355 // Tell WP Rocket not to cache this empty/broken response. The
356 // constant alone is too late here (see block_rocket_cache_write),
357 // so the write-time filter is applied as well.
358 self::block_rocket_cache_write();
359 return $html;
360 }
361
362 # Only process full HTML documents — skip partials, JSON, error responses
363 if (stripos($html, '<html') === false && stripos($html, '<!DOCTYPE') === false) {
364 return $html;
365 }
366
367 # Pass blocking context to the HTML processor (suppresses Yoast/Rank Math tags
368 # that OTTO is replacing, preventing duplicates in the final HTML)
369 $data = $suggestions;
370 if (!empty($blocking_flags)) {
371 $data['_otto_blocking'] = $blocking_flags;
372 }
373
374 # From here on we hold usable suggestions, so any exit that returns
375 # $html unmodified is a genuine failure: WP Rocket would write the
376 # un-optimized markup to its cache file, and whatever sits in front of
377 # it would then serve that copy. This is worse than the plain buffer
378 # path, because the bad page is persisted on disk as well as at the
379 # edge — and this route exists specifically to make the cached copy the
380 # optimized one.
381 #
382 # Note that DONOTCACHEPAGE alone does NOT prevent that write. WP Rocket
383 # evaluates the constant when it decides whether to buffer the page,
384 # which has already happened by the time this filter runs. The
385 # do_rocket_generate_caching_files filter is evaluated immediately
386 # before the file is written, so it is the only lever still available
387 # from here. A disk-cached page is served by rewrite rules without
388 # invoking PHP, so no response header can undo it afterwards.
389 try {
390 $modified = $o_html->process_html_directly($html, $data);
391 # Sanity check: modified HTML must be at least 50% the size of original
392 if ($modified && strlen($modified) > strlen($html) * 0.5) {
393 return $modified;
394 }
395
396 # An unprocessable document (oversized / over the memory budget) is
397 # a permanent property of the page, not a failure to retry. Leave it
398 # fully cacheable — capping it would make the most expensive page on
399 # the site uncacheable forever for no gain.
400 if (class_exists('Metasync_Otto_Render_Strategy')
401 && Metasync_Otto_Render_Strategy::document_was_unprocessable()) {
402 return $html;
403 }
404
405 # Either the rewrite returned nothing usable, or the result was so
406 # much smaller than the input that it is assumed to be mangled.
407 self::block_rocket_cache_write();
408 if (function_exists('metasync_otto_limit_response_cache')) {
409 metasync_otto_limit_response_cache('RENDER_DISCARDED_ROCKET');
410 }
411 if (function_exists('metasync_otto_report_render_failure')) {
412 metasync_otto_report_render_failure(
413 'RENDER_DISCARDED_ROCKET',
414 'OTTO rewrite produced no usable output on the WP Rocket path',
415 [
416 'original_bytes' => strlen($html),
417 'result_bytes' => is_string($modified) ? strlen($modified) : 0,
418 'render_method' => 'wp_rocket',
419 ],
420 'warning'
421 );
422 }
423 } catch (Exception $e) {
424 // Fall through — return original HTML on any failure
425 self::block_rocket_cache_write();
426 if (function_exists('metasync_otto_limit_response_cache')) {
427 metasync_otto_limit_response_cache('RENDER_EXCEPTION_ROCKET');
428 }
429 if (function_exists('metasync_otto_report_render_failure')) {
430 metasync_otto_report_render_failure(
431 'RENDER_EXCEPTION_ROCKET',
432 'OTTO rewrite threw on the WP Rocket path: ' . $e->getMessage(),
433 ['render_method' => 'wp_rocket', 'exception' => get_class($e)],
434 'error'
435 );
436 }
437 } catch (Error $e) {
438 // Fall through — return original HTML on any failure
439 self::block_rocket_cache_write();
440 if (function_exists('metasync_otto_limit_response_cache')) {
441 metasync_otto_limit_response_cache('RENDER_EXCEPTION_ROCKET');
442 }
443 if (function_exists('metasync_otto_report_render_failure')) {
444 metasync_otto_report_render_failure(
445 'RENDER_EXCEPTION_ROCKET',
446 'OTTO rewrite errored on the WP Rocket path: ' . $e->getMessage(),
447 ['render_method' => 'wp_rocket', 'error' => get_class($e)],
448 'error'
449 );
450 }
451 }
452
453 return $html;
454 }, 1);
455
456 # Block SEO plugins before wp_head fires so duplicate tags aren't output
457 $description_tags = $blocking_flags['block_description_tags'] ?? [];
458 $has_description_tags = !empty($description_tags);
459 if ($blocking_flags['block_title'] || $has_description_tags) {
460 if (function_exists('metasync_otto_block_seo_plugins')) {
461 metasync_otto_block_seo_plugins(
462 $blocking_flags['block_title'],
463 $has_description_tags,
464 $description_tags
465 );
466 }
467 }
468
469 # Send X-MetaSync-* diagnostic headers before WordPress outputs anything
470 if (!headers_sent()) {
471 Metasync_Otto_Render_Strategy::set_current_method(Metasync_Otto_Render_Strategy::METHOD_WP_ROCKET);
472 Metasync_Otto_Render_Strategy::send_headers($cache_status);
473 }
474
475 # Return — WordPress continues its normal render lifecycle.
476 # WP Rocket's buffer captures the full HTML, fires rocket_buffer,
477 # our callback modifies it, and WP Rocket caches the OTTO version.
478 }
479
480 /**
481 * Analyze OTTO suggestions to determine what to block from SEO plugins
482 *
483 * @param array $suggestions OTTO suggestions data
484 * @return array Blocking flags
485 */
486 private function analyze_otto_blocking($suggestions) {
487 $has_otto_title = false;
488 $otto_description_tags = []; // Track specific description tags Otto provides
489
490 if (!empty($suggestions['header_replacements']) && is_array($suggestions['header_replacements'])) {
491 foreach ($suggestions['header_replacements'] as $item) {
492 if (!empty($item['type'])) {
493 # Check if OTTO has title
494 if ($item['type'] == 'title' && !empty($item['recommended_value'])) {
495 $has_otto_title = true;
496 }
497 # Check if OTTO has description - track specific tag types
498 if ($item['type'] == 'meta') {
499 # Check for meta[name=description]
500 if (!empty($item['name']) && $item['name'] == 'description' && !empty($item['recommended_value'])) {
501 $otto_description_tags[] = 'meta[name=description]';
502 }
503 # Check for meta[property=og:description]
504 if (!empty($item['property']) && $item['property'] == 'og:description' && !empty($item['recommended_value'])) {
505 $otto_description_tags[] = 'meta[property=og:description]';
506 }
507 # Check for meta[name=twitter:description]
508 if (!empty($item['name']) && $item['name'] == 'twitter:description' && !empty($item['recommended_value'])) {
509 $otto_description_tags[] = 'meta[name=twitter:description]';
510 }
511 }
512 }
513 }
514 }
515
516 # Check header_html_insertion for description
517 # Must have a non-empty content value, otherwise Yoast would be blocked with nothing to replace it
518 if (!empty($suggestions['header_html_insertion'])) {
519 if (preg_match('/<meta[^>]*name=["\']description["\'][^>]*content=["\']([^"\']+)["\'][^>]*>/i', $suggestions['header_html_insertion'])) {
520 $otto_description_tags[] = 'meta[name=description]';
521 }
522 }
523
524 # Remove duplicates
525 $otto_description_tags = array_unique($otto_description_tags);
526
527 return [
528 'block_title' => $has_otto_title,
529 'block_description_tags' => $otto_description_tags, // Pass array of specific tags
530 ];
531 }
532
533 /**
534 * Render page using output buffer approach (FAST)
535 * Eliminates the internal HTTP request by capturing WordPress output directly
536 *
537 * @param string $route Current page route
538 * @param array $suggestions OTTO suggestions data
539 * @param array $blocking_flags SEO plugin blocking flags
540 * @param string $cache_status Cache status for headers
541 * @return bool True if buffer started successfully, false to fall back to HTTP
542 */
543 private function render_via_buffer($route, $suggestions, $blocking_flags, $cache_status) {
544 # Try to start output buffer
545 $buffer_started = Metasync_Otto_Render_Strategy::start_buffer(
546 $suggestions,
547 $route,
548 $this->o_html,
549 $blocking_flags
550 );
551
552 if (!$buffer_started) {
553 # Buffer failed to start
554 return false;
555 }
556
557 # Buffer is active - send headers now (before any output)
558 if (!headers_sent()) {
559 Metasync_Otto_Render_Strategy::send_headers($cache_status);
560 }
561
562 # Block SEO plugins if needed (for the buffered output)
563 $description_tags = $blocking_flags['block_description_tags'] ?? [];
564 $has_description_tags = !empty($description_tags);
565 if ($blocking_flags['block_title'] || $has_description_tags) {
566 if (function_exists('metasync_otto_block_seo_plugins')) {
567 metasync_otto_block_seo_plugins(
568 $blocking_flags['block_title'],
569 $has_description_tags,
570 $description_tags
571 );
572 }
573 }
574
575 # Return true - WordPress will continue rendering, buffer will capture and process
576 return true;
577 }
578
579 /**
580 * Render page using HTTP request approach (FALLBACK)
581 * Makes internal wp_remote_get request to fetch page HTML
582 *
583 * @param string $route Current page route
584 * @param array $suggestions OTTO suggestions data
585 * @param string $cache_track_key Cache tracking key
586 * @param string $cache_status Cache status for headers
587 */
588 private function render_via_http($route, $suggestions, $cache_track_key, $cache_status) {
589 # Set method indicator
590 Metasync_Otto_Render_Strategy::set_current_method(Metasync_Otto_Render_Strategy::METHOD_HTTP);
591
592 # On Divi sites, skip OTTO for the first HTTP render per page
593 # per 24h. OTTO's internal wp_remote_get creates corrupted CSS cache
594 # files in et-cache/{post_id}/ (wrong server context). By returning
595 # false once, WordPress renders the page normally — building correct
596 # Divi CSS caches. Subsequent OTTO renders within 24h use those caches.
597 # The transient stores the activation timestamp so it auto-invalidates
598 # when the plugin is deactivated/reactivated.
599 if (defined('ET_CORE_VERSION')) {
600 $cache_key = 'otto_divi_css_fix_' . md5($route);
601 $activated = get_option('metasync_activated_at', '');
602 $cached = get_transient($cache_key);
603 if ($cached === false || $cached !== $activated) {
604 set_transient($cache_key, $activated, DAY_IN_SECONDS);
605 # Deliberate one-off skip, and the next request renders correctly —
606 # so cap how long this un-optimized copy may be cached rather than
607 # letting a caching layer pin it for its full TTL.
608 if (function_exists('metasync_otto_limit_response_cache')) {
609 metasync_otto_limit_response_cache('DIVI_FIRST_RENDER_SKIP');
610 }
611 return false;
612 }
613 }
614
615 # check if we have the route html (pass suggestions to avoid duplicate API call)
616 $route_html = $this->get_route_html($route, $cache_track_key, $suggestions);
617
618 # check that route html is valid
619 if(empty($route_html)){
620 # Fetch failed / empty (e.g. SG Optimizer truncated the internal response) —
621 # WordPress will serve the un-optimized page. Cap how long that copy may be
622 # cached, using the same mechanism as the deliberate skips above rather than a
623 # second, competing one: it reconciles existing directives, keeps logged-in
624 # responses fully no-store, and never grants caching nothing had granted.
625 if (function_exists('metasync_otto_limit_response_cache')) {
626 metasync_otto_limit_response_cache('HTTP_FETCH_FAILED');
627 }
628 return false;
629 }
630
631 $route_html_string = is_string($route_html) ? $route_html : $route_html->__toString();
632
633 # Detect incomplete Divi rendering in OTTO's HTTP render output.
634 # OTTO's internal wp_remote_get runs in a different server context than the
635 # normal page load. This can cause Divi to produce incomplete output:
636 #
637 # 1. Cold CSS cache: Divi outputs inline <style> blocks instead of external
638 # <link> tags (TB CSS files not yet generated)
639 # 2. Missing Google Fonts: Divi's font enqueue depends on request context
640 # (cookies, headers) that differ in the internal fetch
641 #
642 # If OTTO serves this incomplete HTML via exit(), SG Optimizer caches it —
643 # breaking CSS for all visitors until manual cache purge.
644 #
645 # Fix: skip OTTO for this request (return false), letting WordPress render
646 # normally. This builds Divi's caches so the next OTTO request works correctly.
647 #
648 # Both checks below are self-resolving — the next render finds warm caches —
649 # so the un-optimized copy served now gets a capped cache lifetime rather
650 # than being pinned for a caching layer's full TTL.
651 #
652 # The remaining fail-open exits on this path are deliberately NOT capped.
653 # They mean the site could not fetch its own page (blocked loopback request,
654 # HTTP Basic Auth, a firewall rejecting the internal user-agent) or that the
655 # document cannot be processed at all. Those are whole-site, permanent
656 # conditions: OTTO cannot work on any page, so capping cache lifetime would
657 # rescue nothing while moving the entire site's traffic to the origin.
658 if (defined('ET_CORE_VERSION')) {
659 # Check 1: Cold TB CSS cache (inline styles instead of external link)
660 if (preg_match('/<style\s[^>]*id=["\']et-core-unified-tb-[^"\']*-cached-inline-styles["\']/', $route_html_string)) {
661 if (function_exists('metasync_otto_limit_response_cache')) {
662 metasync_otto_limit_response_cache('DIVI_COLD_CSS_CACHE');
663 }
664 return false;
665 }
666 # Check 2: Missing Google Fonts CSS (Divi enqueues this on every page)
667 # Normal render has: <link ... id='et-builder-googlefonts-cached-css' ...>
668 # or <link ... id='et-builder-googlefonts-css' ...>
669 # If neither is present, the internal fetch didn't load fonts properly.
670 if (strpos($route_html_string, 'et-builder-googlefonts') === false
671 && strpos($route_html_string, 'fonts.googleapis.com') === false
672 ) {
673 if (function_exists('metasync_otto_limit_response_cache')) {
674 metasync_otto_limit_response_cache('DIVI_MISSING_FONTS');
675 }
676 return false;
677 }
678 }
679
680 if (strpos($route_html_string, 'pix-sliding-headline-2') !== false || strpos($route_html_string, 'pix-intro-sliding-text') !== false) {
681 # Only apply fix within sliding text contexts to avoid breaking other layouts
682 $route_html_string = preg_replace('#(</span></span>)(<span\s+class=["\'][^"\']*slide-in-container[^"\']*["\'][^>]*>)#i', '$1 $2', $route_html_string);
683 }
684
685 # Fix for Elementor widgets - preserve whitespace between inline spans
686 # This prevents text/elements from appearing merged when HTML is minified
687
688 # Fix for Elementor social icons (elementor-grid-item)
689 if (strpos($route_html_string, 'elementor-social-icons-wrapper') !== false) {
690 # Add whitespace between closing and opening span tags within elementor-grid-item
691 $route_html_string = preg_replace(
692 '#(</span>)(<span\s+class=["\'][^"\']*elementor-grid-item[^"\']*["\'][^>]*>)#i',
693 '$1 $2',
694 $route_html_string
695 );
696 }
697
698 # Fix for Elementor animated headline (elementor-headline-text-wrapper)
699 if (strpos($route_html_string, 'elementor-headline') !== false) {
700 # Add whitespace between closing and opening span tags with elementor-headline-text-wrapper
701 $route_html_string = preg_replace(
702 '#(</span>)(<span\s+class=["\'][^"\']*elementor-headline-text-wrapper[^"\']*["\'][^>]*>)#i',
703 '$1 $2',
704 $route_html_string
705 );
706 }
707
708 # Check for Revolution Slider to determine if special handling is needed
709 # Check for both Revolution Slider 6 (<rs-module-wrap>) and Revolution Slider 7 (<sr7-module>)
710 $has_revslider = (strpos($route_html_string, '<rs-module-wrap') !== false || strpos($route_html_string, '<sr7-module') !== false);
711
712 if($has_revslider){
713 # Revolution Slider detected - fire WordPress hooks to ensure proper initialization
714 # Use output buffering to prevent hooks from corrupting Otto's processed HTML
715 ob_start();
716 do_action('wp_enqueue_scripts');
717 $discarded_output = ob_get_clean();
718
719 }
720
721 # If Divi's module-design CSS is missing from OTTO output,
722 # fetch it directly and inject it. This handles the case where the
723 # internal wp_remote_get response was truncated by SG Optimizer's parser.
724 if (strpos($route_html_string, 'et-builder-module-design') === false
725 && function_exists('et_theme_builder_decorate_page_resource_slug')
726 && function_exists('et_core_page_resource_get')
727 ) {
728 # Try to get Divi's inline CSS from its page resource manager
729 $post_id = get_the_ID();
730 if ($post_id) {
731 $resource_slug = et_theme_builder_decorate_page_resource_slug($post_id, 'module-design');
732 $manager = et_core_page_resource_get('builder', $resource_slug, $post_id, 40);
733 if ($manager && method_exists($manager, 'get_data')) {
734 $css_data = $manager->get_data('inline');
735 if (!empty($css_data)) {
736 $style_tag = '<style id="et-builder-' . esc_attr($resource_slug) . '-cached-inline-styles">'
737 . wp_strip_all_tags($css_data) . '</style>';
738 $route_html_string = str_replace('</body>', $style_tag . "\n" . '</body>', $route_html_string);
739 }
740 }
741 }
742 }
743
744 # Decide cacheability from the internal fetch + the final output, then serve.
745 # 1) Hand the visitor the session their page was built around (fixes forms that
746 # were silently failing on the HTTP path because the fetch cookie was dropped).
747 # 2) Block shared caching when the fetched page set a cookie, when its own render
748 # declared itself uncacheable (PHP's session cache-limiter — true for resumed
749 # sessions too), when the visitor carries cart/auth cookies that change the
750 # rendered content, or when the render came back incomplete.
751 # 3) A plain, complete page gets no cache header at all → the host finally caches
752 # it (the SiteGround/Oxygen relief this ticket is about).
753 Metasync_Otto_Render_Strategy::passthrough_http_fetch_cookies();
754
755 $must_not_cache =
756 Metasync_Otto_Render_Strategy::http_fetch_had_cookie()
757 || Metasync_Otto_Render_Strategy::http_fetch_no_cache()
758 || Metasync_Otto_Render_Strategy::request_has_visitor_specific_cookie()
759 || !Metasync_Otto_Render_Strategy::http_output_is_complete($route_html_string);
760
761 # Send response headers
762 Metasync_Otto_Render_Strategy::send_headers($cache_status);
763
764 if ($must_not_cache) {
765 Metasync_Otto_Render_Strategy::block_http_cache();
766 }
767
768 # continue to render the html
769 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- server-rendered route HTML from the OTTO render strategy; escaping would corrupt the document
770 echo $route_html_string;
771
772 # prevent further wp execution
773 exit();
774 }
775
776 }
777