PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.18
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.18
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 2.6.4 2.6.3 All 139 releases
metasync / public / class-metasync-rest-api.php

class-metasync-rest-api.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.6.18, at public/class-metasync-rest-api.php

4,281 lines 144.5 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 * REST API functionality of the plugin.
9 *
10 * @link https://searchatlas.com
11 * @since 1.0.0
12 *
13 * @package Metasync
14 * @subpackage Metasync/public
15 */
16
17 /**
18 * REST API functionality extracted from Metasync_Public.
19 *
20 * Handles all REST API route registration and endpoint callbacks.
21 *
22 * @package Metasync
23 * @subpackage Metasync/public
24 * @author Engineering Team <support@searchatlas.com>
25 */
26
27 class Metasync_Rest_Api
28 {
29
30 /**
31 * The ID of this plugin.
32 *
33 * @since 1.0.0
34 * @access private
35 * @var string $plugin_name The ID of this plugin.
36 */
37 private $plugin_name;
38
39 /**
40 * The version of this plugin.
41 *
42 * @since 1.0.0
43 * @access private
44 * @var string $version The current version of this plugin.
45 */
46 private $version;
47
48 private const namespace = "metasync/v1";
49
50 private $escapers;
51 private $replacements;
52 private $common;
53 private $allowed_attributes;
54 private $schema;
55 private $metasync_option_data;
56
57 public function __construct($plugin_name, $version)
58 {
59 $this->plugin_name = $plugin_name;
60 $this->version = $version;
61 $this->allowed_attributes = array(
62 'ID',
63 'meta_description',
64 'meta_robots',
65 'meta_canonical',
66 'permalink',
67 'post_id',
68 'post_title',
69 'post_type',
70 'post_content',
71 'post_author',
72 'post_date',
73 'post_modified',
74 'post_name',
75 'post_parent',
76 'post_status',
77 );
78 $this->escapers = array("\\", "/", "\"");
79 $this->replacements = array("", "", "");
80 $this->common = new Metasync_Common();
81 // get all options
82 $this->metasync_option_data = Metasync::get_option('general');
83 $this->init_ajax_hooks();
84 }
85
86 public function init_ajax_hooks()
87 {
88 add_action('wp_ajax_metasync_otto_ajax_action', array($this,'metasyn_otto_ajax'));
89 }
90
91 /**
92 * Encode non-ASCII characters in a UTF-8 string as numeric HTML entities.
93 *
94 * Uses mb_encode_numericentity when the mbstring extension is loaded;
95 * otherwise walks the UTF-8 byte sequence manually so DOMDocument loading
96 * still works on hosts without mbstring.
97 *
98 * @param string $str UTF-8 input.
99 * @return string Same string with codepoints >= 0x80 replaced by &#NNN; entities.
100 */
101 private function encode_numeric_entities($str)
102 {
103 if (function_exists('mb_encode_numericentity')) {
104 return mb_encode_numericentity($str, [0x80, 0xFFFF, 0, 0xFFFF], 'UTF-8');
105 }
106
107 if ($str === '' || $str === null) {
108 return '';
109 }
110
111 $out = '';
112 $len = strlen($str);
113 $i = 0;
114 while ($i < $len) {
115 $byte = ord($str[$i]);
116
117 if ($byte < 0x80) {
118 $out .= $str[$i];
119 $i++;
120 continue;
121 }
122
123 if (($byte & 0xE0) === 0xC0 && $i + 1 < $len) {
124 $b2 = ord($str[$i + 1]);
125 if (($b2 & 0xC0) === 0x80) {
126 $cp = (($byte & 0x1F) << 6) | ($b2 & 0x3F);
127 $out .= '&#' . $cp . ';';
128 $i += 2;
129 continue;
130 }
131 } elseif (($byte & 0xF0) === 0xE0 && $i + 2 < $len) {
132 $b2 = ord($str[$i + 1]);
133 $b3 = ord($str[$i + 2]);
134 if (($b2 & 0xC0) === 0x80 && ($b3 & 0xC0) === 0x80) {
135 $cp = (($byte & 0x0F) << 12) | (($b2 & 0x3F) << 6) | ($b3 & 0x3F);
136 $out .= '&#' . $cp . ';';
137 $i += 3;
138 continue;
139 }
140 } elseif (($byte & 0xF8) === 0xF0 && $i + 3 < $len) {
141 $b2 = ord($str[$i + 1]);
142 $b3 = ord($str[$i + 2]);
143 $b4 = ord($str[$i + 3]);
144 if (($b2 & 0xC0) === 0x80 && ($b3 & 0xC0) === 0x80 && ($b4 & 0xC0) === 0x80) {
145 $cp = (($byte & 0x07) << 18) | (($b2 & 0x3F) << 12) | (($b3 & 0x3F) << 6) | ($b4 & 0x3F);
146 $out .= '&#' . $cp . ';';
147 $i += 4;
148 continue;
149 }
150 }
151
152 $out .= $str[$i];
153 $i++;
154 }
155
156 return $out;
157 }
158
159 private function filter_post_attributes($posts)
160 {
161 $pi = -1;
162 foreach ($posts as $post) {
163 $pi++;
164 if ($post == null)
165 return false; // post not found
166
167 foreach ($post as $key => $value) {
168 if (!in_array($key, $this->allowed_attributes)) {
169 unset($posts[$pi]->{$key});
170 }
171 }
172 $posts[$pi]->post_id = $posts[$pi]->ID;
173 $posts[$pi]->permalink = get_permalink($posts[$pi]->ID);
174 }
175 return $posts;
176 }
177
178 public function metasyn_otto_ajax() {
179 // Check nonce for security
180 if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'otto_nonce')) {
181 wp_send_json_error('Invalid nonce');
182 wp_die();
183 }
184 $post_id = sanitize_text_field($_POST['post_id']);
185 $current_url = get_permalink($post_id);
186
187 $header_html = get_post_meta($post_id, '_otto_header_html_json', true);
188
189 // Example API call using wp_remote_get()
190
191
192 if (is_wp_error($header_html)) {
193 wp_send_json_error('API call failed');
194 } else {
195 wp_send_json_success(json_decode($header_html, true));
196 }
197
198 wp_die(); // Always terminate after an AJAX call
199 }
200
201 public function otto_header_data() {
202 global $post;
203
204 // Get the current post ID
205 $post_id = $post->ID;
206
207 // Get the current time and the last update time from post meta
208 $current_time = current_time('timestamp');
209 $last_update_time = get_post_meta($post_id, '_otto_last_update_time', true);
210
211 // Set the interval for 24 hours (in seconds)
212 $interval = 24 * 60 * 60;
213
214 // Check if the last update time is set or if 24 hours have passed
215 if (!$last_update_time || ($current_time - $last_update_time) >= $interval) {
216 // Get the current URL
217 $current_url = get_permalink($post_id);
218
219 // Use endpoint manager to get the correct API URL
220 $api_endpoint = class_exists('Metasync_Endpoint_Manager')
221 ? Metasync_Endpoint_Manager::get_endpoint('OTTO_URL_DETAILS')
222 : 'https://sa.searchatlas.com/api/v2/otto-url-details';
223
224 // Call the API
225 $response = wp_remote_get($api_endpoint . '/?url=' . urlencode($current_url));
226
227 // Check if the API call was successful
228 if (!is_wp_error($response)) {
229 $body = wp_remote_retrieve_body($response);
230 $data = json_decode($body, true); // Decode JSON into an associative array
231
232 // Update the post meta with the new data and timestamp
233 update_post_meta($post_id, '_otto_header_html', $data['header_html_insertion']);
234 update_post_meta($post_id, '_otto_last_update_time', $current_time);
235 }
236 }
237
238 // Get the saved HTML from post meta
239 $header_html = get_post_meta($post_id, '_otto_header_html', true);
240
241 // Display the HTML with security measures
242 if ($header_html) {
243 echo "<!-- Otto Start -->";
244 // SECURITY FIX: Sanitize HTML to prevent XSS while allowing safe HTML
245 echo wp_kses($header_html, array(
246 'style' => array(),
247 'link' => array('rel' => array(), 'href' => array(), 'type' => array()),
248 'meta' => array('name' => array(), 'content' => array(), 'property' => array()),
249 'script' => array('type' => array(), 'src' => array()),
250 // Add other safe tags as needed
251 ));
252 echo "<!-- Otto End -->";
253 }
254 }
255
256 public function rest_authorization_middleware($request = null)
257 {
258 $api_key = '';
259
260 // Primary: Authorization: Bearer <token>
261 if ($request instanceof \WP_REST_Request) {
262 $auth_header = $request->get_header('authorization');
263 if (!empty($auth_header) && preg_match('/^Bearer\s+(.+)$/i', $auth_header, $matches)) {
264 $api_key = sanitize_text_field($matches[1]);
265 }
266 }
267
268 // Secondary: X-API-Key header
269 if (empty($api_key) && isset($_SERVER['HTTP_X_API_KEY'])) {
270 $api_key = sanitize_text_field(wp_unslash($_SERVER['HTTP_X_API_KEY']));
271 }
272
273 // Fallback: ?apikey= query param (deprecated)
274 if (empty($api_key) && isset($_GET['apikey'])) {
275 $api_key = sanitize_text_field($_GET['apikey']);
276 }
277
278 if (empty($api_key)) {
279 return false;
280 }
281
282 $getOptions = Metasync::get_option('general');
283 $stored_key = $getOptions['apikey'] ?? null;
284
285 if (empty($stored_key)) {
286 return false;
287 }
288
289 return hash_equals($stored_key, $api_key);
290 }
291
292 /**
293 * Coexist with third-party JWT authentication plugins.
294 *
295 * A global JWT auth plugin (e.g. Tmeister's "JWT Authentication for WP-API")
296 * validates any "Authorization: Bearer ..." header as a JWT. MetaSync sends
297 * its plugin API key as a Bearer token, so the JWT plugin rejects it with a
298 * 403 (jwt_auth_invalid_token). That plugin surfaces the error from its
299 * rest_pre_dispatch callback (priority 10), short-circuiting the request
300 * before MetaSync's own permission_callback ever runs.
301 *
302 * This filter is registered on rest_pre_dispatch at a later priority (11) so
303 * it runs after the JWT plugin. When a prior error exists for a metasync/v1
304 * request and our own API key validates, it clears the error (returns null)
305 * so dispatch continues to the route and rest_authorization_middleware can
306 * authorize it. Every other route — and every request without a valid
307 * MetaSync key — is left untouched, preserving the JWT plugin's behaviour.
308 *
309 * @param mixed $result Prior dispatch result (WP_Error to short-circuit, otherwise null).
310 * @param WP_REST_Server|null $server REST server instance (unused).
311 * @param WP_REST_Request|null $request Current request.
312 * @return mixed Cleared (null) only when our key validates on a metasync/v1
313 * route; otherwise the input $result unchanged.
314 */
315 public function allow_metasync_rest_auth($result, $server = null, $request = null)
316 {
317 // No prior error means normal flow — never alter it.
318 if (!is_wp_error($result)) {
319 return $result;
320 }
321
322 // We need the request to know which route is being dispatched.
323 if (!($request instanceof \WP_REST_Request)) {
324 return $result;
325 }
326
327 // Only intervene for our own namespace; preserve the JWT plugin's
328 // behaviour for every other (core/third-party) route.
329 $route = $request->get_route();
330 $prefix = '/' . self::namespace;
331 if (strpos($route, $prefix . '/') !== 0 && $route !== $prefix) {
332 return $result;
333 }
334
335 // Clear the error only when our own API key validates, so MetaSync's
336 // permission_callback gets to authorize the request normally.
337 if ($this->rest_authorization_middleware($request) === true) {
338 return null;
339 }
340
341 return $result;
342 }
343
344
345 public function metasync_register_rest_routes()
346 {
347 // Critical Routes
348 /*
349 createItem
350 createPage
351 updateItems
352 updatePage
353 deleteItem
354 getPagesList
355 getPostByURL
356 */
357 register_rest_route(
358 $this::namespace ,
359 'getItems',
360 array(
361 array(
362 'methods' => 'GET',
363 'callback' => array($this, 'get_items'),
364 'permission_callback' => array($this, 'rest_authorization_middleware')
365 ),
366 'schema' => array($this, 'get_item_schema'),
367 )
368 );
369
370
371
372 register_rest_route($this::namespace , 'postCategories',array(
373 array(
374 'methods' => 'GET',
375 'callback' => array($this, 'post_categories'),
376 'permission_callback' => array($this, 'rest_authorization_middleware')
377 ),
378 'schema' => array($this, 'get_item_schema'),
379 )
380 );
381
382
383 register_rest_route(
384 $this::namespace ,
385 'updateItems',
386 array(
387 array(
388 'methods' => 'POST',
389 'callback' => array($this, 'update_items'),
390 'permission_callback' => array($this, 'rest_authorization_middleware')
391 ),
392 'schema' => array($this, 'get_item_schema'),
393 )
394 );
395
396 # add otto pixel rest route
397 register_rest_route(
398 $this::namespace ,
399 'otto_crawl_notify',
400 array(
401 array(
402 'methods' => 'POST',
403 'callback' => 'metasync_otto_crawl_notify',
404 'permission_callback' => array($this, 'rest_authorization_middleware')
405 ),
406 'schema' => array($this, 'get_item_schema'),
407 )
408 );
409
410 register_rest_route(
411 $this::namespace ,
412 'createItem',
413 array(
414 array(
415 'methods' => 'POST',
416 'callback' => array($this, 'create_item'),
417 'permission_callback' => array($this, 'rest_authorization_middleware')
418 ),
419 'schema' => array($this, 'get_item_schema'),
420 )
421 );
422
423 register_rest_route(
424 $this::namespace ,
425 'setLandingPage',
426 array(
427 array(
428 'methods' => 'POST',
429 'callback' => array($this, 'set_landing_page'),
430 'permission_callback' => array($this, 'rest_authorization_middleware')
431 ),
432 'schema' => array($this, 'get_item_schema'),
433 )
434 );
435
436 register_rest_route(
437 $this::namespace ,
438 'deleteItem',
439 array(
440 array(
441 'methods' => 'DELETE',
442 'callback' => array($this, 'delete_item'),
443 'permission_callback' => array($this, 'rest_authorization_middleware')
444 ),
445 'schema' => array($this, 'get_item_schema'),
446 )
447 );
448
449 register_rest_route(
450 $this::namespace ,
451 'getPagesList',
452 array(
453 array(
454 'methods' => 'GET',
455 'callback' => function () {
456 $pagesList = array();
457 $pages = get_posts([
458 'post_type' => 'page',
459 'post_status' => array('publish'),
460 'nopaging' => true
461 ]);
462 foreach ($pages as $page) {
463 array_push($pagesList, array(
464 'post_id' => $page->ID,
465 'post_title' => $page->post_title,
466 'post_url' => get_permalink($page->ID), //$page->guid
467 )
468 );
469 }
470 return rest_ensure_response($pagesList);
471 },
472 'permission_callback' => array($this, 'rest_authorization_middleware')
473 ),
474 'schema' => array($this, 'get_item_schema'),
475 )
476 );
477
478 register_rest_route(
479 $this::namespace ,
480 'getPostByURL',
481 array(
482 array(
483 'methods' => 'GET',
484 'callback' => function () {
485 $getPostID = url_to_postid(sanitize_url($_GET['url']));
486
487 if ($getPostID==0) {
488 $response = false;
489 }else{
490 $response = $this->filter_post_attributes([
491 get_post($getPostID)
492 ]);
493 }
494
495 if ($response == false) {
496 $response = ['post_id' => -1];
497 } else {
498 $response = $response[0];
499 }
500 return rest_ensure_response($response);
501 },
502 'permission_callback' => array($this, 'rest_authorization_middleware')
503 ),
504 'schema' => array($this, 'get_item_schema'),
505 )
506 );
507
508 register_rest_route(
509 $this::namespace ,
510 'createPage',
511 array(
512 array(
513 'methods' => 'POST',
514 'callback' => array($this, 'create_page'),
515 'permission_callback' => array($this, 'rest_authorization_middleware')
516 ),
517 'schema' => array($this, 'get_item_schema'),
518 )
519 );
520
521 register_rest_route(
522 $this::namespace ,
523 'updatePage',
524 array(
525 array(
526 'methods' => 'POST',
527 'callback' => array($this, 'update_page'),
528 'permission_callback' => array($this, 'rest_authorization_middleware')
529 ),
530 'schema' => array($this, 'get_item_schema'),
531 )
532 );
533
534 register_rest_route(
535 $this::namespace ,
536 'deletePage',
537 array(
538 array(
539 'methods' => 'DELETE',
540 'callback' => array($this, 'delete_page'),
541 'permission_callback' => array($this, 'rest_authorization_middleware')
542 ),
543 'schema' => array($this, 'get_item_schema'),
544 )
545 );
546
547 register_rest_route(
548 $this::namespace ,
549 'posts',
550 array(
551 array(
552 'methods' => 'GET',
553 'callback' => function () {
554 $query = new WP_Query(
555 array(
556 'nopaging' => true,
557 'post_type' => array('post', 'page')
558 )
559 );
560 return rest_ensure_response(
561 $this->filter_post_attributes(
562 $query->posts
563 )
564 );
565 },
566 'permission_callback' => array($this, 'rest_authorization_middleware')
567 ),
568 'schema' => array($this, 'get_item_schema'),
569 )
570 );
571
572
573
574 register_rest_route(
575 $this::namespace ,
576 'lglogin',
577 array(
578 array(
579 'methods' => 'POST',
580 'callback' => array($this, 'linkgraph_login'),
581 'permission_callback' => array($this, 'rest_authorization_middleware')
582 ),
583 'schema' => array($this, 'get_item_schema'),
584 )
585 );
586
587 register_rest_route(
588 $this::namespace ,
589 'syncHeartbeatData',
590 array(
591 array(
592 'methods' => 'POST',
593 'callback' => array($this, 'sync_heartbeat_data'),
594 'permission_callback' => array($this, 'rest_authorization_middleware')
595 ),
596 'schema' => array($this, 'get_item_schema'),
597 )
598 );
599
600 register_rest_route(
601 $this::namespace ,
602 'getHeartbeatErrorLogs',
603 array(
604 array(
605 'methods' => 'GET',
606 'callback' => array($this, 'get_heartbeat_errorlogs'),
607 'permission_callback' => array($this, 'rest_authorization_middleware')
608 ),
609 'schema' => array($this, 'get_item_schema'),
610 )
611 );
612
613 register_rest_route(
614 $this::namespace ,
615 'getErrorLogs',
616 array(
617 array(
618 'methods' => 'GET',
619 'callback' => array($this, 'get_errorlogs'),
620 'permission_callback' => array($this, 'rest_authorization_middleware')
621 ),
622 'schema' => array($this, 'get_item_schema'),
623 )
624 );
625
626 register_rest_route(
627 $this::namespace ,
628 'getPostByID',
629 array(
630 array(
631 'methods' => 'GET',
632 'callback' => function ($request) {
633 $getPostID = $request->get_param('ID');
634 if (is_null($getPostID) || !is_numeric($getPostID)) {
635 wp_send_json_error(array('message' => 'ID is missing or invalid'), 400);
636 }
637 $post = get_post($getPostID);
638 if ($post === null) {
639 wp_send_json_error(array('message' => 'Post not found'), 400);
640
641 }else{
642 wp_send_json_success(array('message' => 'ID is valid'),200);
643 }
644
645
646 },
647 'permission_callback' => array($this, 'rest_authorization_middleware')
648 ),
649 'schema' => array($this, 'get_item_schema'),
650 )
651 );
652
653 register_rest_route($this::namespace, 'pageList', array(
654 'methods' => 'POST',
655 'callback' => array($this, 'get_pages_list'),
656 'args' => array(
657 'post_type' => array(
658 'required' => true,
659 'validate_callback' => function ($param, $request, $key) {
660 return is_string($param) && ($param === 'page' || $param === 'post');
661 }
662 )
663 ),
664 'permission_callback' => array($this, 'rest_authorization_middleware'),
665 'schema' => array($this, 'get_item_schema'),
666 ));
667
668 # Add the new getPostData endpoint
669 register_rest_route(
670 $this::namespace,
671 'getPostData',
672 array(
673 array(
674 'methods' => 'GET',
675 'callback' => array($this, 'get_post_data'),
676 'permission_callback' => array($this, 'rest_authorization_middleware')
677 ),
678 'schema' => array($this, 'get_item_schema'),
679 )
680 );
681
682 # Search Atlas Connect callback endpoint - SA platform calls this with API key and Otto UUID
683 register_rest_route(
684 $this::namespace,
685 'searchatlas/connect/callback',
686 array(
687 array(
688 'methods' => 'POST',
689 'callback' => array($this, 'handle_searchatlas_api_callback'),
690 'permission_callback' => array($this, 'validate_searchatlas_callback_permission')
691 ),
692 'schema' => array($this, 'get_item_schema'),
693 )
694 );
695
696 # Key file creation endpoint
697 register_rest_route(
698 $this::namespace,
699 'createKeyFile',
700 array(
701 array(
702 'methods' => 'POST',
703 'callback' => array($this, 'create_key_file'),
704 'permission_callback' => array($this, 'rest_authorization_middleware')
705 ),
706 'schema' => array($this, 'get_item_schema'),
707 )
708 );
709
710 # OTTO SSR Status endpoint - Public endpoint to check if OTTO SSR is enabled
711 register_rest_route(
712 $this::namespace,
713 'otto_ssr_status',
714 array(
715 array(
716 'methods' => 'GET',
717 'callback' => array($this, 'get_otto_ssr_status'),
718 'permission_callback' => '__return_true' // Public access
719 ),
720 array(
721 'methods' => 'POST',
722 'callback' => array($this, 'set_otto_ssr_status'),
723 'permission_callback' => array($this, 'rest_authorization_middleware')
724 ),
725 'schema' => array($this, 'get_item_schema'),
726 )
727 );
728
729 # OTTO Configuration Status endpoint - Check if OTTO is active and properly configured
730 register_rest_route(
731 $this::namespace,
732 'otto_config_status',
733 array(
734 array(
735 'methods' => 'GET',
736 'callback' => array($this, 'get_otto_config_status'),
737 'permission_callback' => '__return_true' // Public access
738 ),
739 'schema' => array($this, 'get_item_schema'),
740 )
741 );
742
743 # Plugin Version endpoint - Returns the active plugin version
744 register_rest_route(
745 $this::namespace,
746 'version',
747 array(
748 array(
749 'methods' => 'GET',
750 'callback' => array($this, 'get_plugin_version'),
751 'permission_callback' => '__return_true' // Public access
752 ),
753 'schema' => array($this, 'get_item_schema'),
754 )
755 );
756
757 # Support Token Authentication endpoint
758
759 }
760
761
762 /**
763 * Get post data endpoint
764 * Accepts post_id or post_url and returns post details
765 * Only returns data for post type 'post'
766 */
767 public function get_post_data($request) {
768 $get_data = $request->get_params();
769 $post_id = null;
770 $post = null;
771
772 # Fetch post
773 if (!empty($get_data['post_id'])) {
774 $post_id = intval($get_data['post_id']);
775 $post = get_post($post_id);
776 } elseif (!empty($get_data['post_url'])) {
777 $post_id = url_to_postid(sanitize_url($get_data['post_url']));
778 if ($post_id > 0) {
779 $post = get_post($post_id);
780 }
781 }
782
783 # Check if post exists
784 if (!$post || $post_id <= 0) {
785 return new WP_REST_Response(array('error' => 'no blog post found'), 404);
786 }
787
788 # Check if post type is 'post', return error if not
789 if ($post->post_type !== 'post') {
790 return new WP_REST_Response(array('error' => 'only post type is supported'), 400);
791 }
792
793 # Render content
794 $post_content = $post->post_content;
795 if (strpos($post_content, '[et_pb_') !== false) {
796 # Load Divi modules if available
797 if (function_exists('et_builder_add_main_elements')) {
798 et_builder_add_main_elements();
799 }
800 # Render Divi content to HTML
801 if (function_exists('et_builder_render_layout')) {
802 $post_content = et_builder_render_layout($post_content);
803 } else {
804 $post_content = 'Divi render function not found';
805 }
806 } else {
807 # Standard WP content filter
808 $post_content = apply_filters('the_content', $post_content);
809 }
810
811 # Get featured image URL (full size, or null)
812 $featured_image = null;
813 if (has_post_thumbnail($post_id)) {
814 $featured_image = [
815 'url' => get_the_post_thumbnail_url($post_id, 'full'),
816 'id' => get_post_thumbnail_id($post_id),
817 'alt' => get_post_meta(get_post_thumbnail_id($post_id), '_wp_attachment_image_alt', true) ?: ''
818 ];
819 }
820
821 # Get post categories
822 $categories = get_the_category($post_id);
823 $category_names = array();
824 if (!empty($categories)) {
825 foreach ($categories as $category) {
826 $category_names[] = $category->name;
827 }
828 }
829
830 # Prepare response data
831 $response_data = array(
832 'post_content' => $post_content,
833 'post_title' => $post->post_title,
834 'post_status' => $post->post_status,
835 'otto_ai_page' => false,
836 'comment_status' => $post->comment_status,
837 'permalink' => $post->post_name,
838 'is_landing_page' => false,
839 'post_categories' => $category_names,
840 'post_id' => $post_id,
841 'post_type' => $post->post_type,
842 'post_parent' => $post->post_parent,
843 'featured_image' => $featured_image
844 );
845
846 return rest_ensure_response($response_data);
847 }
848
849 /**
850 * Get OTTO SSR status endpoint
851 * Public endpoint to check if OTTO Server Side Rendering is enabled
852 *
853 * @param WP_REST_Request $request Request object
854 * @return WP_REST_Response Response with active status
855 */
856 public function get_otto_ssr_status($request) {
857 # OTTO SSR is always enabled by default
858 # Prepare response - always return active as true
859 $response_data = array(
860 'active' => 'true'
861 );
862
863 return rest_ensure_response($response_data);
864 }
865
866 /**
867 * Set OTTO SSR status endpoint
868 * Authenticated endpoint to activate or deactivate OTTO Server Side Rendering
869 *
870 * @param WP_REST_Request $request Request object
871 * @return WP_REST_Response Response with updated active status
872 */
873 public function set_otto_ssr_status($request) {
874 # OTTO SSR is always enabled by default - this endpoint is kept for backwards compatibility
875 # Prepare response - SSR is always active
876 $response_data = array(
877 'active' => 'true',
878 'message' => 'OTTO SSR is always enabled by default.',
879 'success' => true
880 );
881
882 return rest_ensure_response($response_data);
883 }
884
885 /**
886 * Get OTTO Configuration Status endpoint
887 * Public endpoint to check if OTTO is active and API Key/UUID are correctly set
888 *
889 * @param WP_REST_Request $request Request object
890 * @return WP_REST_Response Response with configuration status
891 */
892 public function get_otto_config_status($request) {
893 # Get MetaSync options
894 $metasync_options = get_option('metasync_options');
895 $general_options = $metasync_options['general'] ?? array();
896
897 # OTTO SSR is always enabled by default
898 $is_otto_active = true;
899
900 # Check if API Key is set (not empty)
901 $api_key = $general_options['searchatlas_api_key'] ?? '';
902 $has_api_key = !empty($api_key);
903
904 # Check if UUID is set (not empty)
905 $otto_uuid = $general_options['otto_pixel_uuid'] ?? '';
906 $has_uuid = !empty($otto_uuid);
907
908 # Determine if fully configured (API key and UUID set - SSR always active)
909 $is_configured = $has_api_key && $has_uuid;
910
911 # Granular status for backend messaging (PR1: heartbeat reliability)
912 if ($has_api_key && $has_uuid) {
913 $status = 'configured';
914 } elseif ($has_api_key && !$has_uuid) {
915 $status = 'plugin_active_sso_partial';
916 } else {
917 $status = 'plugin_active_no_sso';
918 }
919
920 # Plugin version and timestamps (ISO 8601 UTC where set)
921 $plugin_version = defined('METASYNC_VERSION') ? METASYNC_VERSION : 'unknown';
922 $_throttle = Metasync::get_heartbeat_throttle();
923 $last_heartbeat_at = $_throttle['last_heartbeat_at'] ?? ($general_options['last_heartbeat_at'] ?? null);
924 $sso_completed_at = $general_options['sso_completed_at'] ?? null;
925
926 # Prepare response (backward compatible + new fields)
927 $response_data = array(
928 'status' => $status,
929 'configured' => $is_configured,
930 'otto_active' => $is_otto_active,
931 'has_api_key' => $has_api_key,
932 'has_uuid' => $has_uuid,
933 'otto_uuid' => $has_uuid ? $otto_uuid : null,
934 'plugin_version' => $plugin_version,
935 'last_heartbeat_at' => $last_heartbeat_at,
936 'sso_completed_at' => $sso_completed_at,
937 );
938
939 return rest_ensure_response($response_data);
940 }
941
942 /**
943 * Get Plugin Version endpoint
944 * Public endpoint that returns the active plugin version
945 *
946 * @param WP_REST_Request $request Request object
947 * @return WP_REST_Response Response with version information
948 * @since 2.5.15
949 */
950 public function get_plugin_version($request) {
951 # Get the plugin version from the constant
952 $plugin_version = defined('METASYNC_VERSION') ? METASYNC_VERSION : 'unknown';
953
954 # Get plugin name and other metadata
955 $plugin_name = Metasync::get_effective_plugin_name();
956 $plugin_slug = 'metasync';
957
958 # Get plugin file path to retrieve additional metadata if needed
959 $plugin_file = plugin_dir_path(dirname(__FILE__)) . 'metasync.php';
960 $plugin_data = array();
961
962 if (file_exists($plugin_file) && function_exists('get_plugin_data')) {
963 require_once ABSPATH . 'wp-admin/includes/plugin.php';
964 $plugin_data = get_plugin_data($plugin_file, false, false);
965 }
966
967 # Prepare response
968 $response_data = array(
969 'version' => $plugin_version,
970 'plugin_name' => $plugin_name,
971 'plugin_slug' => $plugin_slug,
972 'wordpress_version' => get_bloginfo('version'),
973 'php_version' => PHP_VERSION,
974 'plugin_uri' => !empty($plugin_data['PluginURI']) ? $plugin_data['PluginURI'] : '',
975 'author' => !empty($plugin_data['Author']) ? $plugin_data['Author'] : 'Search Atlas',
976 'author_uri' => !empty($plugin_data['AuthorURI']) ? $plugin_data['AuthorURI'] : 'https://searchatlas.com',
977 );
978
979 return rest_ensure_response($response_data);
980 }
981
982 public function post_categories() {
983 $categories = get_categories(array(
984 'hide_empty' => false,
985 ));
986
987 $categories = array_map(function($category) {
988 return [
989 'id' => $category->term_id,
990 'name' => $category->name,
991 'parent' => $category->parent,
992 ];
993 }, $categories);
994
995 $hierarchy = $this->build_category_hierarchy($categories);
996
997 return new WP_REST_Response($hierarchy, 200);
998 }
999
1000 public function build_category_hierarchy($categories, $parentId = 0) {
1001 $result = [];
1002 foreach ($categories as $category) {
1003 if ($category['parent'] == $parentId) {
1004 $children = $this->build_category_hierarchy($categories, $category['id']);
1005 if ($children) {
1006 $category['children'] = $children;
1007 }
1008 $result[] = $category;
1009 }
1010 }
1011 return $result;
1012 }
1013
1014 public function get_errorlogs()
1015 {
1016 $get_data = metasync_sanitize_input_array($_GET);
1017 if (!isset($get_data['limit']))
1018 return false;
1019 $limit = sanitize_text_field($get_data['limit']) ?? null;
1020
1021 require_once plugin_dir_path(__DIR__) . 'includes/class-metasync-errorlogs.php';
1022
1023 $errorLogClass = new ErrorLog();
1024 $response = $errorLogClass->getParsedLogFile();
1025
1026 if (!empty($response)) {
1027 // If no limit is specified, return all logs, otherwise, return the last $limit entries.
1028 $logsToReturn = ($limit === -1) ? $response : array_slice($response, -$limit);
1029
1030 // Reverse the order of the logs
1031 $logsToReturn = array_reverse($logsToReturn);
1032
1033 return rest_ensure_response($logsToReturn);
1034 }
1035 }
1036
1037 private function get_post_author_id($post)
1038 {
1039 $post_author = isset($post['post_author']) ? sanitize_text_field($post['post_author']) : 1;
1040 wp_set_current_user($post_author);
1041
1042 $current_user = 1;
1043 if (get_current_user_id()) {
1044 return wp_get_current_user()->ID;
1045 }
1046
1047 return $current_user;
1048 }
1049
1050 private function get_random_user_id_by_roles(?array $roles = [])
1051 {
1052 $users = get_users(array('role__in' => $roles, 'fields' => 'ids'));
1053 $post_author = 1;
1054 if (!empty($users)) {
1055 $key = array_rand($users);
1056 $post_author = $users[$key];
1057 }
1058
1059 return $post_author;
1060 }
1061
1062
1063 private function htmlToElementorBlock($node) {
1064 $result = [];
1065
1066 if ($node->nodeType === XML_TEXT_NODE) {
1067 // Text node
1068 return $node->nodeValue;
1069 } else{
1070 // Element node
1071 $result['id'] = uniqid(); // Generate unique ID for the element
1072 $result['elType'] = 'widget'; // Assume all elements are widgets
1073 if (in_array(strtolower($node->nodeName), array('h1', 'h2', 'h3', 'h4', 'h5','h6'))) {
1074 // Handle heading elements
1075 $result['settings']['title'] = $node->nodeValue;
1076 $result['settings']['header_size'] = $node->nodeName;
1077
1078 # Check if the heading already has an ID
1079 $existing_id = $node->getAttribute('id');
1080
1081 # If the ID exists, assign it as _element_id so Elementor renders it in HTML
1082 if (!empty($existing_id)) {
1083 $result['settings']['_element_id'] = $existing_id;
1084 }
1085 if(isset($this->metasync_option_data['enabled_elementor_plugin_css']) && isset($this->metasync_option_data['enabled_elementor_plugin_css_color']) && $this->metasync_option_data['enabled_elementor_plugin_css']!=="default"){
1086 $result['settings']['title_color'] = $this->metasync_option_data['enabled_elementor_plugin_css_color']; // Set default title color
1087 }
1088 $result['settings']['typography_typography'] = 'custom';
1089 $result['settings']['typography_font_family'] = 'Roboto';
1090 $result['settings']['typography_font_weight'] = '600';
1091 $result['widgetType'] = 'heading';
1092 }elseif($node->nodeName==='iframe'){ // Correction in the name
1093 $result["settings"]= array('html'=> $node->ownerDocument->saveHTML($node));
1094 $result['widgetType'] = 'html';
1095 }elseif ($node->nodeName === 'img') {
1096 // Handle image elements source, title and alternative text
1097 $src_url = $node->getAttribute('src');
1098 $alt_text = $node->getAttribute('alt');
1099 $title_text = $node->getAttribute('title');
1100 // upload the image to wordpress and the id of the image and url
1101 $attachment_id = $this->common->upload_image_by_url($src_url,$alt_text,$title_text);
1102 $new_src_url = wp_get_attachment_url($attachment_id);
1103 // use the new image url to elementor
1104 $result['settings']['image']['url'] = $new_src_url;
1105
1106 $result['settings']['image']['id'] =$attachment_id; // Generate unique ID for the image
1107 $result['settings']['image']['size'] = '';
1108 $result['settings']['image']['alt'] = $alt_text; // Set default alt text
1109 $result['settings']['image']['source'] = 'library';
1110 // check if the title is empty or not
1111 if($title_text !== ""){
1112 $result['settings']['image']['title'] = $title_text;
1113 }
1114 $result['widgetType'] = 'image';
1115 } elseif ($node->nodeName === 'p') {
1116 // Handle paragraph elements
1117 $node->setAttribute('class', 'metasyncPara');
1118 $result["settings"]= array('editor'=> $node->ownerDocument->saveHTML($node));
1119 $result["elements"]= array();
1120 $result['widgetType'] = 'text-editor';
1121 } elseif($node->nodeName === 'table'|| $node->nodeName === 'ul' || $node->nodeName === 'ol') {
1122 if($node->nodeName === 'table'){
1123 $node->setAttribute('class', 'metasyncTable');
1124 }
1125 $result["settings"]= array('editor'=> $node->ownerDocument->saveHTML($node));
1126 $result["elements"]= array();
1127 $result['widgetType'] = 'text-editor';
1128 }elseif ($node->nodeName === 'blockquote') {
1129 # Add a class
1130 $node->setAttribute('class', 'metasyncBlockquote');
1131 # Set the HTML content inside the Elementor "text-editor" widget
1132 $html = $node->ownerDocument->saveHTML($node);
1133 # Insert blockquote into editor content
1134 $result["settings"] = array('editor' => $html);
1135 # No child widgets or inner elements inside this block
1136 $result["elements"] = array();
1137 # Specify that this content should use the "text-editor" widget type
1138 $result['widgetType'] = 'text-editor';
1139 }
1140
1141 if(isset($result['widgetType'])){
1142 return $result;
1143 }
1144
1145 }
1146 }
1147 private function elementorBlockData($content){
1148 $dom = new DOMDocument();
1149 @$dom->loadHTML($this->encode_numeric_entities($content));
1150
1151 $outputArray = [];
1152 foreach ( $dom->getElementsByTagName('*') as $rootElement) {
1153 if($rootElement->nodeName!=='html' && $rootElement->nodeName!=='body' &&
1154 $rootElement->nodeName!=='tbody'&& $rootElement->nodeName!=='tfoot' && $rootElement->nodeName!=='tr' && $rootElement->nodeName!=='th' && $rootElement->nodeName!=='td'){
1155 $htmlArray = $this->htmlToElementorBlock($rootElement);
1156 # $outputArray[] = $htmlArray;
1157 # Only add non-null values to the output array
1158 # non-null changed to not-empty
1159 if (!empty($htmlArray)) {
1160 $outputArray[] = $htmlArray;
1161 }
1162 }
1163 }
1164 return $outputArray;
1165 }
1166
1167 private function gutenbergBlockData($content) {
1168 // Validate and sanitize content before parsing
1169 if (empty($content) || !is_string($content)) {
1170 return [];
1171 }
1172
1173 // Trim and ensure content is valid
1174 $content = trim($content);
1175 if (empty($content)) {
1176 return [];
1177 }
1178
1179 // Clean content: remove any leading/trailing whitespace and ensure it starts with a valid HTML tag
1180 // If content starts with an entity or invalid character, wrap it
1181 $content = preg_replace('/^[\s\x{200B}-\x{200D}\x{FEFF}]+/u', '', $content);
1182 $content = preg_replace('/[\s\x{200B}-\x{200D}\x{FEFF}]+$/u', '', $content);
1183
1184 // Use modern Dom\HTMLDocument for PHP 8.4+ which natively supports HTML5
1185 // Otherwise fall back to DOMDocument with error suppression
1186 $dom = null;
1187 if (class_exists('Dom\HTMLDocument')) {
1188 // PHP 8.4+ with native HTML5 support
1189 // Wrap content in HTML structure if it's not already a complete document
1190 $wrapped_content = trim($content);
1191 $isCompleteDocument = (stripos($wrapped_content, '<!DOCTYPE') === 0) || (stripos($wrapped_content, '<html') === 0);
1192
1193 if (!$isCompleteDocument) {
1194 // Ensure content is properly formatted before wrapping
1195 $wrapped_content = '<!DOCTYPE html><html><head><meta charset="UTF-8"></head><body>' . $wrapped_content . '</body></html>';
1196 }
1197
1198 try {
1199 $dom = @Dom\HTMLDocument::createFromString($wrapped_content);
1200 if ($dom === null) {
1201 throw new Exception('Dom\HTMLDocument::createFromString returned null');
1202 }
1203 } catch (Throwable $e) {
1204 // Fallback to DOMDocument if Dom\HTMLDocument fails
1205 if (defined('WP_DEBUG_LOG') && WP_DEBUG_LOG) {
1206 error_log('MetaSync: Dom\HTMLDocument error, falling back to DOMDocument: ' . $e->getMessage());
1207 }
1208 $dom = new DOMDocument();
1209 libxml_use_internal_errors(true);
1210 $encoded_content = $this->encode_numeric_entities($content);
1211 @$dom->loadHTML($encoded_content);
1212 libxml_clear_errors();
1213 libxml_use_internal_errors(false);
1214 }
1215 } else {
1216 // Fallback for older PHP versions
1217 $dom = new DOMDocument();
1218
1219 // Suppress libxml errors for HTML5 tags that aren't recognized in older libxml
1220 libxml_use_internal_errors(true);
1221
1222 // Ensure content is wrapped in a proper HTML structure
1223 $htmlContent = $content;
1224 if (!preg_match('/^\s*<(!DOCTYPE|html|body)/i', $content)) {
1225 $htmlContent = '<!DOCTYPE html><html><body>' . $content . '</body></html>';
1226 }
1227
1228 $dom->loadHTML($this->encode_numeric_entities($htmlContent));
1229
1230 // Clear any libxml errors and restore error handling
1231 libxml_clear_errors();
1232 libxml_use_internal_errors(false);
1233 }
1234
1235 $outputArray = [];
1236
1237 // Iterate through each element in the HTML
1238 foreach ($dom->getElementsByTagName('*') as $rootElement) {
1239 // If the element is not one of the specified HTML tags, convert it to a Gutenberg block
1240 if (!in_array($rootElement->nodeName, ['html', 'body', 'tr', 'th', 'td'])) {
1241 $htmlArray = $this->htmlToGutenbergBlock($rootElement);
1242 if(!is_null($htmlArray)){
1243 $outputArray[] = $htmlArray;
1244 }
1245 }
1246 }
1247
1248 return $outputArray;
1249 }
1250
1251 private function htmlToGutenbergBlock($node) {
1252 $nodeName = strtolower($node->nodeName);
1253
1254 $result = [];
1255
1256 if ($node->nodeType === XML_TEXT_NODE) {
1257 // Text node
1258 return $node->nodeValue;
1259 } else{
1260 if (in_array($nodeName, array('h1', 'h2', 'h3', 'h4', 'h5','h6'))) {
1261 $level = intval(substr($nodeName, 1));
1262 return [
1263 "blockName" => "core/heading",
1264 "attrs" => [
1265 "level" => $level
1266 ],
1267 "innerBlocks" => [],
1268 "innerHTML" => $node->ownerDocument->saveHTML($node),
1269 "innerContent" => [
1270 $node->ownerDocument->saveHTML($node)
1271 ]
1272 ];
1273 } elseif ($nodeName === 'img') {
1274 $src_url = $node->getAttribute('src');
1275 // get alt text from the image tag
1276 $alt_text = $node->getAttribute('alt');
1277 //get title text from the image tag
1278 $title_text = $node->getAttribute('title');
1279 // upload the image to wordpress and the id of the image and url
1280 $attachment_id = $this->common->upload_image_by_url($src_url,$alt_text,$title_text);
1281 // get new source url after upload
1282 $new_src_url = wp_get_attachment_url($attachment_id);
1283
1284
1285 $alt_attr = $node->getAttribute('alt');
1286 $node->setAttribute('alt', $alt_attr !== null ? $alt_attr : '');
1287 $node->setAttribute('src', $src_url);
1288 $node->setAttribute('class', "wp-image-".$attachment_id);
1289 //format the inner content for the image tag
1290 return [
1291 "blockName" => "core/image",
1292 "attrs" => [
1293 "id" => $attachment_id ,
1294 "sizeSlug" => "large",
1295 "linkDestination" => "none"
1296 ],
1297 "innerBlocks" => [],
1298 "innerHTML" => '' ,
1299 "innerContent" => [
1300 sprintf('<figure class="wp-block-image size-large"><img src="%s" alt="%s" class="wp-image-%d" /></figure>',
1301 esc_url($new_src_url),
1302 esc_attr($node->getAttribute('alt')),
1303 $attachment_id
1304 ),
1305 ]
1306 ];
1307 }elseif ($nodeName === 'iframe') {
1308 return [
1309 "blockName" => "core/html",
1310 "attrs" => [],
1311 "innerBlocks" => [],
1312 "innerHTML" => $node->ownerDocument->saveHTML($node) ,
1313 "innerContent" => [
1314 $node->ownerDocument->saveHTML($node)
1315 ]
1316 ];
1317 }elseif ($nodeName === 'p') {
1318 return [
1319 "blockName" => "core/paragraph",
1320 "attrs" => [],
1321 "innerBlocks" => [],
1322 "innerHTML" => $node->ownerDocument->saveHTML($node) ,
1323 "innerContent" => [
1324 $node->ownerDocument->saveHTML($node)
1325 ]
1326 ];
1327 } elseif ($nodeName === 'table') {
1328 $tableHtml = $node->ownerDocument->saveHTML($node);
1329 if($nodeName === 'table'){
1330 $node->setAttribute('class', 'metasyncTable-block');
1331 }
1332 return [
1333 "blockName" => "core/table",
1334 "attrs" => [],
1335 "innerBlocks" => [],
1336 "innerHTML" =>'<figure class="wp-block-table meta-block-tabel">'.$tableHtml.'</figure>',
1337 "innerContent" => [
1338 '<figure class="wp-block-table meta-block-tabel">'.$tableHtml.'</figure>'
1339 ]
1340 ];
1341
1342 }elseif($nodeName === 'ol'||$nodeName === 'ul'){
1343 //list-item
1344 return [
1345 "blockName" => "core/list",
1346 "attrs" => [
1347 'ordered'=> ($nodeName === 'ol'?true:false)
1348 ],
1349 "innerBlocks" => [],
1350 "innerHTML" => $node->ownerDocument->saveHTML($node) ,
1351 "innerContent" => [
1352 $node->ownerDocument->saveHTML($node)
1353 ]
1354 ];
1355 }elseif ($nodeName === 'blockquote') {
1356 # Add the standard Gutenberg quote class to ensure proper styling
1357 $node->setAttribute('class', 'wp-block-quote');
1358 # Convert the full blockquote HTML
1359 $quote_html = $node->ownerDocument->saveHTML($node);
1360 # Return a properly structured Gutenberg "core/quote" block
1361 return [
1362 "blockName" => "core/quote",
1363 "attrs" => [],
1364 "innerBlocks" => [],
1365 "innerHTML" => $quote_html,
1366 "innerContent" => [
1367 $quote_html
1368 ]
1369 ];
1370 }
1371 }
1372
1373 }
1374
1375
1376 private function htmlToDiviBlock($node) {
1377 $result = [];
1378
1379 if ($node->nodeType === XML_TEXT_NODE) {
1380 // Text node
1381 return $node->nodeValue;
1382 } else{
1383 // Element node
1384 $result['id'] = uniqid(); // Generate unique ID for the element
1385 $result['elType'] = 'widget'; // Assume all elements are widgets
1386 if (in_array(strtolower($node->nodeName), array('h1', 'h2', 'h3', 'h4', 'h5','h6'))) {
1387
1388 # Fetch the existing ID from the heading element (if any)
1389 $existing_id = $node->getAttribute('id');
1390
1391 # If ID exists, prepare it as a valid Divi module attribute; otherwise, leave it out
1392 $extra_id_attr = !empty($existing_id) ? ' module_id="' . $existing_id . '"' : '';
1393
1394 # Handle heading elements embedding the ID only when it's available
1395 $result =' [et_pb_heading title="'.$node->nodeValue.'" _builder_version="'.ET_BUILDER_VERSION.'" _module_preset="default" title_level="'.$node->nodeName.'" hover_enabled="0" sticky_enabled="0"'. $extra_id_attr .'][/et_pb_heading]';
1396 } elseif ($node->nodeName === 'img') {
1397 // Handle image elements
1398 try{
1399 $image_id = attachment_url_to_postid($node->getAttribute('src') );
1400 $image_alt = get_post_meta($image_id, '_wp_attachment_image_alt', TRUE);
1401 $result ='[et_pb_image src="'.$node->getAttribute('src') .'" url="'.$node->getAttribute('src'). '" _builder_version="'.ET_BUILDER_VERSION.'" _module_preset="default" hover_enabled="0" global_colors_info="{}" sticky_enabled="0"][/et_pb_image]';
1402
1403 }catch(Error $e){
1404 $image_id = attachment_url_to_postid($node->getAttribute('src') );
1405 $image_alt = get_post_meta($image_id, '_wp_attachment_image_alt', TRUE);
1406 $result ='[et_pb_image src="'.$node->getAttribute('src') .'" url="'.$node->getAttribute('src'). '" _builder_version="'.ET_BUILDER_VERSION.'" _module_preset="default" hover_enabled="0" global_colors_info="{}" sticky_enabled="0"][/et_pb_image]';
1407
1408 error_log(json_encode($e));
1409
1410 }
1411 }elseif($node->nodeName === 'iframe'){
1412 $result= '[et_pb_code _builder_version="'.ET_BUILDER_VERSION.'" _module_preset="default" global_colors_info="{}"]'.$node->ownerDocument->saveHTML($node).'[/et_pb_code]' ;
1413 }elseif ($node->nodeName === 'p') {
1414 // Handle paragraph elements
1415 $node->setAttribute('class', 'metasyncPara');
1416 $result = '[et_pb_text _builder_version="'.ET_BUILDER_VERSION.'" _module_preset="default" global_colors_info="{}"]'. $node->ownerDocument->saveHTML($node).'[/et_pb_text]';
1417 } elseif ($node->nodeName === 'table'||$node->nodeName === 'ul' || $node->nodeName === 'ol') {
1418 if($node->nodeName === 'table'){
1419 $node->setAttribute('class', 'metasyncTable');
1420 }
1421 $result= '[et_pb_code _builder_version="'.ET_BUILDER_VERSION.'" _module_preset="default" global_colors_info="{}"]'.$node->ownerDocument->saveHTML($node).'[/et_pb_code]' ;
1422 }elseif ($node->nodeName === 'blockquote') {
1423 # Add class
1424 $node->setAttribute('class', 'metasyncQuote');
1425 # Convert the <blockquote> node and its contents (including tags like <em>) to HTML
1426 $quote_html = $node->ownerDocument->saveHTML($node);
1427 # Wrap the blockquote content inside a Divi Text Module since Divi has no native quote module
1428 $result = '[et_pb_text _builder_version="'.ET_BUILDER_VERSION.'" _module_preset="default" global_colors_info="{}"]'.$quote_html.'[/et_pb_text]';
1429 }
1430 return $result;
1431 }
1432 }
1433 private function diviBlockData($content){
1434 $dom = new DOMDocument();
1435 @$dom->loadHTML($this->encode_numeric_entities($content));
1436
1437 $outputArray = '[et_pb_section fb_built="1" _builder_version="'.ET_BUILDER_VERSION.'" _module_preset="default" global_colors_info="{}"][et_pb_row _builder_version="'.ET_BUILDER_VERSION.'" _module_preset="default" global_colors_info="{}"][et_pb_column type="4_4" _builder_version="'.ET_BUILDER_VERSION.'" _module_preset="default" global_colors_info="{}"]';
1438 foreach ( $dom->getElementsByTagName('*') as $rootElement) {
1439 if($rootElement->nodeName!=='html' && $rootElement->nodeName!=='body' &&
1440 $rootElement->nodeName!=='tbody'&& $rootElement->nodeName!=='tfoot' && $rootElement->nodeName!=='tr' && $rootElement->nodeName!=='th' && $rootElement->nodeName!=='td'){
1441 $htmlArray = $this->htmlToDiviBlock($rootElement);
1442 if(gettype($htmlArray)!=='array'){
1443 $outputArray .= $htmlArray;
1444 }
1445 }
1446 }
1447 $outputArray .='[/et_pb_column][/et_pb_row][/et_pb_section]';
1448 return $outputArray;
1449 }
1450 /*
1451 Added $landing_page_option variable and set default value false to check
1452 if metasync_upload_post_content is called for set_landing_page by following function that are below
1453 # create_item
1454 # update_items
1455 Added $otto_enable variable and set default value false to check
1456 if metasync_upload_post_content is called otto AI landing page by following function that are below
1457 # create_page
1458 # update_page
1459 */
1460 /**
1461 * Upload post content and convert to builder format
1462 *
1463 * This method now delegates to the new HTML to Builder Converter class
1464 * for improved maintainability and CSS preservation.
1465 *
1466 * @param array $item Item data with 'post_content' key
1467 * @param bool $landing_page_option Whether this is for a landing page
1468 * @param bool $otto_enable Whether this is for Otto AI
1469 * @return array Result with 'content' and optional builder meta data
1470 */
1471 public function metasync_upload_post_content($item,$landing_page_option=false,$otto_enable=false)
1472 {
1473 // Load the new converter class
1474 require_once plugin_dir_path(dirname(__FILE__)) . 'custom-pages/class-metasync-html-to-builder-converter.php';
1475 $converter = new Metasync_HTML_To_Builder_Converter();
1476
1477 // Delegate to converter's legacy method for backward compatibility
1478 return $converter->convert_legacy($item, $landing_page_option, $otto_enable);
1479 }
1480
1481 public function metasync_handle_post_category($post_id, $post_categories, $append)
1482 {
1483 $post_categories = array_map('sanitize_text_field', $post_categories);
1484 $post_categories = wp_create_categories($post_categories, $post_id);
1485 wp_set_post_categories($post_id, $post_categories, $append);
1486
1487 $categories = get_the_category($post_id);
1488 $fine_categories = array();
1489 foreach ($categories as $category) {
1490 $fine_categories[] = [
1491 "id" => $category->cat_ID,
1492 "name" => $category->name
1493 ];
1494 }
1495 return $fine_categories;
1496 }
1497
1498 public function metasync_set_post_tags($post_id, $post_tags, $append_tags)
1499 {
1500 $post_tags = array_map('sanitize_text_field', $post_tags);
1501 wp_set_post_tags($post_id, $post_tags, $append_tags);
1502
1503 $tags = wp_get_post_tags(
1504 $post_id,
1505 array(
1506 'orderby' => 'name'
1507 )
1508 );
1509
1510 $parse_tags = array();
1511 foreach ($tags as $tag) {
1512 $parse_tags[] = [
1513 "id" => $tag->term_id,
1514 "name" => $tag->name
1515 ];
1516 }
1517 return $parse_tags;
1518 }
1519
1520 public function metasync_handle_hero_image($post_id, $hero_image_url, $hero_image_alt_text)
1521 {
1522 $attachment_id = '';
1523 $hero_image_url = sanitize_url($hero_image_url);
1524 if (filter_var($hero_image_url, FILTER_VALIDATE_URL)) {
1525 $attachment_id = $this->common->upload_image_by_url($hero_image_url);
1526 if ($attachment_id) {
1527 set_post_thumbnail($post_id, $attachment_id);
1528 }
1529 }
1530 if (has_post_thumbnail($post_id) && isset($hero_image_alt_text) && !empty($hero_image_alt_text)) {
1531 $hero_image_id = get_post_thumbnail_id($post_id);
1532 update_post_meta($hero_image_id, '_wp_attachment_image_alt', $hero_image_alt_text);
1533 }
1534 return $attachment_id;
1535 }
1536
1537 /**
1538 * Index a post with Google Indexing API
1539 *
1540 * @param int $post_id WordPress post ID
1541 * @param string $post_type WordPress post type (post, page, etc.)
1542 * @param string $post_status WordPress post status (publish, draft, etc.)
1543 */
1544 public function metasync_google_index_post($post_id, $post_type, $post_status)
1545 {
1546 // Only index published posts/pages
1547 if ($post_status !== 'publish') {
1548 return;
1549 }
1550
1551 // Only index posts and pages (can be extended as needed)
1552 $allowed_post_types = array('post', 'page');
1553 if (!in_array($post_type, $allowed_post_types)) {
1554 return;
1555 }
1556
1557 try {
1558 // Load Google Index functionality if not already loaded
1559 if (!function_exists('google_index_post')) {
1560 $google_index_path = plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php';
1561 if (file_exists($google_index_path)) {
1562 require_once $google_index_path;
1563 } else {
1564 error_log('MetaSync Google Index: google-index-init.php not found at ' . $google_index_path);
1565 return;
1566 }
1567 }
1568
1569 // Attempt to index the post with Google
1570 if (function_exists('google_index_post')) {
1571 $result = google_index_post($post_id, $post_type, 'update');
1572
1573 if (isset($result['success']) && $result['success']) {
1574 error_log(sprintf(
1575 'MetaSync Google Index: Successfully indexed %s (ID: %d, Type: %s)',
1576 get_the_title($post_id),
1577 $post_id,
1578 $post_type
1579 ));
1580 } else {
1581 error_log(sprintf(
1582 'MetaSync Google Index: Failed to index %s (ID: %d, Type: %s) - %s',
1583 get_the_title($post_id),
1584 $post_id,
1585 $post_type,
1586 isset($result['error']['message']) ? $result['error']['message'] : 'Unknown error'
1587 ));
1588 }
1589 }
1590 } catch (Exception $e) {
1591 // Log any exceptions but don't break the main functionality
1592 error_log('MetaSync Google Index Exception: ' . $e->getMessage());
1593 }
1594 }
1595
1596 /**
1597 * Remove a leading <h1> from post content when it duplicates the post title.
1598 *
1599 * Themes that render post_title as an H1 produce a duplicate heading when the
1600 * synced body also opens with the title. Counterpart of the guard, which
1601 * only prevents prepending a second H1 but never removes one supplied in the body.
1602 *
1603 * @param string $content Post body HTML
1604 * @param string $title Post title
1605 * @return string Content without the duplicated leading H1
1606 */
1607 private function strip_leading_title_h1($content, $title)
1608 {
1609 if (!is_string($content) || trim($content) === '' || trim((string) $title) === '') {
1610 return $content;
1611 }
1612
1613 $trimmed = ltrim($content);
1614 if (!preg_match('/^<h1[^>]*>(.*?)<\/h1>\s*/is', $trimmed, $h1_match)) {
1615 return $content;
1616 }
1617
1618 # Entity-decode both sides so "Mobility &amp; Recovery" matches "Mobility & Recovery"
1619 $h1_text = trim(html_entity_decode(strip_tags($h1_match[1]), ENT_QUOTES | ENT_HTML5));
1620 $title_text = trim(html_entity_decode((string) $title, ENT_QUOTES | ENT_HTML5));
1621 if ($h1_text === '' || strcasecmp($h1_text, $title_text) !== 0) {
1622 return $content;
1623 }
1624
1625 return substr($trimmed, strlen($h1_match[0]));
1626 }
1627
1628 /**
1629 * Decide whether the synced body should carry a prepended <h1> title.
1630 *
1631 * Replaces the unreliable 7-day hidden-post probe (title_in_headings) as the
1632 * source of truth for the prepend decision. We only prepend when we have a
1633 * POSITIVE, render-verified verdict that the active theme does NOT render the
1634 * post title itself. When the verdict is unknown we default to NOT prepending,
1635 * because a duplicate H1 (theme title + body title) is a worse, more visible
1636 * defect than a one-time missing body title on the rare no-title theme. The
1637 * verdict is learned per post-type by record_theme_title_verdict() after the
1638 * first sync renders the real post.
1639 *
1640 * @param string $post_type Post type being synced
1641 * @return bool True only when the theme is known to omit the title
1642 */
1643 private function should_prepend_title($post_type)
1644 {
1645 $general = Metasync::get_option('general');
1646 if (isset($general['theme_renders_title'][$post_type])) {
1647 # Verdict known: prepend only when the theme does NOT render the title.
1648 return $general['theme_renders_title'][$post_type] === false;
1649 }
1650 # Unknown verdict: do not prepend (avoid duplicate H1).
1651 return false;
1652 }
1653
1654 /**
1655 * After a post is saved, render its live URL once and record whether
1656 * the active theme outputs the post title in a heading. The verdict is cached
1657 * per post-type so subsequent syncs skip the round-trip. Best-effort: any
1658 * inconclusive fetch (non-200, cache/challenge page, wrong post) records
1659 * nothing and is retried on the next sync.
1660 *
1661 * @param int $post_id Saved post ID
1662 * @param string $permalink Public permalink of the saved post
1663 * @param string $post_title Post title to look for
1664 * @param string $post_type Post type (verdict cache key)
1665 */
1666 private function record_theme_title_verdict($post_id, $permalink, $post_title, $post_type)
1667 {
1668 if (empty($permalink) || trim((string) $post_title) === '' || empty($post_type)) {
1669 return;
1670 }
1671
1672 $general = Metasync::get_option('general');
1673 # Already learned for this post type — nothing to do.
1674 if (isset($general['theme_renders_title'][$post_type])) {
1675 return;
1676 }
1677
1678 $verdict = $this->detect_theme_renders_title($permalink, (int) $post_id, $post_title);
1679 if ($verdict === null) {
1680 # Inconclusive fetch: do not poison the cache, retry next sync.
1681 return;
1682 }
1683
1684 # Re-read to avoid clobbering concurrent option writes, then persist.
1685 $options = Metasync::get_option();
1686 if (!isset($options['general']) || !is_array($options['general'])) {
1687 $options['general'] = [];
1688 }
1689 if (!isset($options['general']['theme_renders_title']) || !is_array($options['general']['theme_renders_title'])) {
1690 $options['general']['theme_renders_title'] = [];
1691 }
1692 $options['general']['theme_renders_title'][$post_type] = $verdict;
1693 Metasync::set_option($options);
1694 }
1695
1696 /**
1697 * Fetch the rendered post and detect whether the theme outputs the
1698 * title in a heading.
1699 *
1700 * @param string $permalink Public permalink
1701 * @param int $post_id Saved post ID (used to confirm we fetched the right page)
1702 * @param string $post_title Title to look for
1703 * @return bool|null True = theme renders the title; false = it does not;
1704 * null = inconclusive (do not cache).
1705 */
1706 private function detect_theme_renders_title($permalink, $post_id, $post_title)
1707 {
1708 # Cache-bust so a full-page cache (LiteSpeed, etc.) returns a fresh render
1709 # rather than a stale 404/challenge — the original cause of the bad verdict.
1710 $url = add_query_arg('metasync_nocache', (string) time(), $permalink);
1711
1712 $response = wp_remote_get($url, array(
1713 'timeout' => 10,
1714 'redirection' => 3,
1715 'sslverify' => false,
1716 'headers' => array(
1717 'Cache-Control' => 'no-cache',
1718 'Pragma' => 'no-cache',
1719 ),
1720 ));
1721
1722 if (is_wp_error($response)) {
1723 return null;
1724 }
1725
1726 $code = (int) wp_remote_retrieve_response_code($response);
1727 $html = wp_remote_retrieve_body($response);
1728 if ($code !== 200 || empty($html)) {
1729 return null;
1730 }
1731
1732 # Confirm we actually rendered THIS post (WordPress emits these on body/article).
1733 # Without this a cached 404 or bot challenge would falsely record "omits".
1734 $is_our_post = (stripos($html, 'postid-' . $post_id) !== false)
1735 || (stripos($html, 'page-id-' . $post_id) !== false)
1736 || (stripos($html, 'id="post-' . $post_id . '"') !== false);
1737 if (!$is_our_post) {
1738 return null;
1739 }
1740
1741 return $this->title_present_in_headings($html, $post_title);
1742 }
1743
1744 /**
1745 * Return true if $title appears within any heading (h1–h6) of $html.
1746 *
1747 * @param string $html Rendered page HTML
1748 * @param string $title Title text to match
1749 * @return bool
1750 */
1751 private function title_present_in_headings($html, $title)
1752 {
1753 $title = trim(html_entity_decode((string) $title, ENT_QUOTES | ENT_HTML5));
1754 if ($title === '' || !class_exists('DOMDocument')) {
1755 return false;
1756 }
1757
1758 $dom = new DOMDocument();
1759 libxml_use_internal_errors(true);
1760 $dom->loadHTML($html);
1761 libxml_clear_errors();
1762
1763 $xpath = new DOMXPath($dom);
1764 $headings = $xpath->query('//h1 | //h2 | //h3 | //h4 | //h5 | //h6');
1765 if ($headings === false) {
1766 return false;
1767 }
1768
1769 foreach ($headings as $heading) {
1770 $heading_text = trim($heading->textContent);
1771 if ($heading_text !== '' && stripos($heading_text, $title) !== false) {
1772 return true;
1773 }
1774 }
1775 return false;
1776 }
1777
1778 public function create_item($request)
1779 {
1780 // Checking for type of object for response type
1781 $array_response = true;
1782 if (gettype($request) == "object")
1783 $array_response = false;
1784
1785 // Getting JSON Params
1786 $request_data = array($request);
1787 if ($array_response == false)
1788 $request_data = $request->get_json_params();
1789
1790 // Looping for payload for posts
1791 $respCreatePosts = array();
1792 foreach ($request_data as $index => $item) {
1793 $post_author = isset($item['post_author']) ? sanitize_text_field($item['post_author']) : '1';
1794 wp_set_current_user($post_author);
1795 $current_user = wp_get_current_user();
1796 $current_user_id = '1';
1797 if ($current_user->ID > 0) {
1798 $current_user_id = $current_user->ID;
1799 }
1800
1801 $users = get_users(array('role__in' => array('author'), 'fields' => 'ids'));
1802 $post_author = $current_user_id;
1803 if (!empty($users)) {
1804 $key = array_rand($users);
1805 $post_author = $users[$key];
1806 }
1807 /*
1808 check if the create_item is called by set_landing_page function or not
1809 by doing this we will prevent html from going into builder page option
1810 */
1811 $isOttoAiPage = !empty($item['otto_ai_page']) && filter_var($item['otto_ai_page'], FILTER_VALIDATE_BOOLEAN);
1812 $content = [];
1813 if(!isset($item['is_landing_page']) && !$isOttoAiPage && empty($item['style_data']) ){
1814
1815 # Get Current Post type
1816 $current_post_type = isset($item['post_type']) ? sanitize_text_field($item['post_type']) : 'post';
1817 /**
1818 * Check if current theme is Flatsome using SAVED theme info (not wp_get_theme())
1819 * Theme info is saved by admin hooks, so no security triggers during REST API
1820 */
1821 $metasync_general = Metasync::get_option('general');
1822 $theme_name = $metasync_general['current_theme_name'] ?? '';
1823 $theme_template = $metasync_general['current_theme_template'] ?? '';
1824
1825 $is_flatsome_theme = false;
1826 if (!empty($theme_name) && stripos($theme_name, 'Flatsome') !== false) {
1827 $is_flatsome_theme = true;
1828 } elseif (!empty($theme_template) && stripos($theme_template, 'flatsome') !== false) {
1829 $is_flatsome_theme = true;
1830 }
1831
1832 # Always strip a leading H1 that duplicates the title. The
1833 # theme renders post_title in its header on virtually every theme, so a
1834 # title H1 at the top of the body is a duplicate. Whether we then put one
1835 # back is decided by should_prepend_title() below.
1836 if (isset($item['post_title'])) {
1837 $item['post_content'] = $this->strip_leading_title_h1($item['post_content'], $item['post_title']);
1838 }
1839
1840 # Skip title/image prepending for Flatsome (it displays them by default)
1841 if (!$is_flatsome_theme) {
1842 # Get the setting for the post template (feature-image detection)
1843 $title_and_feature_image = $this->append_content_if_missing_elements($current_post_type);
1844
1845 # Prepend the feature image when the template does not already show it
1846 if(!$title_and_feature_image['image_in_content'] && !empty($item['hero_image_url'])){
1847 $item['post_content'] = '<img src="' . esc_url_raw($item['hero_image_url']) . '" />'.$item['post_content'] ;
1848 }
1849
1850 # Only prepend the title H1 when the theme is render-verified to
1851 # omit it. Unknown verdict defaults to NOT prepending (see should_prepend_title).
1852 if(isset($item['post_title']) && $this->should_prepend_title($current_post_type)){
1853 $item['post_content'] = '<h1>'.$item['post_title'].'</h1>'.$item['post_content'] ;
1854 }
1855 }
1856 # This will be used by create_page function
1857 $content = $this->metasync_upload_post_content($item,false,false);
1858 }elseif(isset($item['is_landing_page']) && $item['is_landing_page'] == true){
1859 $content = $this->metasync_upload_post_content($item,true); // This will be used by set_landing_page function
1860 }
1861 /*
1862 Check if the otto_ai_page is payload is set in the api or not.
1863 If it is please set the third parameter to true.
1864 */
1865 if($isOttoAiPage && !empty($item['style_data'])){
1866 $content = $this->metasync_upload_post_content($item,true,true);
1867 }
1868
1869 $new_post = array(
1870 'post_author' => $post_author,
1871 'post_title' => sanitize_text_field($item['post_title']),
1872 'post_content' => $content['content'] ? $content['content'] : $item['post_content'],
1873 'post_excerpt' => isset($item['meta_description']) ? sanitize_text_field($item['meta_description']) : '',
1874 'post_type' => isset($item['post_type']) ? sanitize_text_field($item['post_type']) : 'post',
1875 'post_status' => isset($item['post_status']) ? sanitize_text_field($item['post_status']) : 'publish',
1876 'comment_status' => isset($item['comment_status']) ? sanitize_text_field($item['comment_status']) : 'open',
1877 'post_parent' => isset($item['post_parent']) ?$item['post_parent'] : 0
1878 );
1879
1880 if (isset($item['post_author']) && !empty($item['post_author'])) {
1881 $new_post['post_author'] = sanitize_text_field($item['post_author']);
1882 }
1883
1884 // adding custom permalink
1885 if (isset($item['permalink']) && !empty($item['permalink'])) {
1886 $new_post['post_name'] = sanitize_text_field($item['permalink']);
1887 }
1888
1889 if (isset($item['post_date']) && !empty($item['post_date'])) {
1890 $is_valid_date = date('Y-m-d', strtotime($item['post_date'])) === $item['post_date'];
1891 if (!$is_valid_date) {
1892 return new WP_Error(
1893 'rest_post_invalid_date',
1894 esc_html__('Post date is not valid'),
1895 array('status' => 400)
1896 );
1897 }
1898
1899 // $date_limit_str = strtotime(date('Y-m-d') . '-2 month');
1900 // $post_date_str = strtotime($item['post_date']);
1901 // if ($date_limit_str >= $post_date_str) {
1902 // $newDate = date('Y-m-d', strtotime('-2 month'));
1903 // return new WP_Error(
1904 // 'rest_post_greater_date',
1905 // esc_html__("Post date should be greater then " . $newDate),
1906 // array('status' => 400)
1907 // );
1908 // }
1909
1910 // if ($post_date_str > strtotime(date('Y-m-d'))) {
1911 // return new WP_Error(
1912 // 'rest_post_less_date',
1913 // esc_html__("Post date should be less then Today"),
1914 // array('status' => 400)
1915 // );
1916 // }
1917
1918 // $new_post['post_date'] = sanitize_text_field($item['post_date'] . date(' h:i:s'));
1919 }
1920
1921 // Adding condition to check if the post is already exist
1922 $post_status_new = isset($item['post_status']) ? sanitize_text_field($item['post_status']) : 'publish';
1923 $post_permalink = $item['permalink'] = isset($item['permalink']) ? $item['permalink'] : sanitize_title($new_post['post_title']);
1924
1925 # $getPostID_byURL = @get_page_by_path($item['permalink'], OBJECT, $new_post['post_type'])->ID;
1926 # Fix to avoid PHP error if the get_page_by_path returns null
1927 $getPostID_byURL = @get_page_by_path($item['permalink'], OBJECT, $new_post['post_type']);
1928 $getPostID_byURL = $getPostID_byURL ? $getPostID_byURL->ID : null;
1929 if ($getPostID_byURL == NULL) {
1930 // check if the post_title is set and not empty when called by otto_ai_page
1931 if(isset($new_post['post_title']) && $new_post['post_title']!==''){
1932 $getPostID_byURL = new WP_Query(
1933 array(
1934 'post_type' => $new_post['post_type'],
1935 'title' => $new_post['post_title']
1936 )
1937 );
1938 $getPostID_byURL = $getPostID_byURL->posts[0]->ID ?? null;
1939 }
1940 }
1941
1942 // Allow HTML code for landing page
1943 if (isset($item['is_landing_page']) && $item['is_landing_page'] == true) {
1944 kses_remove_filters();
1945 }
1946
1947 if (isset($item['post_parent']) && !empty($item['post_parent']) && $item['post_parent'] != 0) {
1948 if ($new_post['post_type'] == 'page') {
1949 $new_post['post_parent'] = isset($item['post_parent']) ? $item['post_parent'] : 0;
1950 } else {
1951 $item['post_parent'] = isset($item['post_parent']) ? $item['post_parent'] : 0;
1952 }
1953 }
1954
1955 if ($getPostID_byURL === NULL) {
1956 $post_id = wp_insert_post($new_post);
1957 $permalink = get_permalink($post_id);
1958
1959 # If the post was successfully created (no WP error)
1960 if (!is_wp_error($post_id)) {
1961
1962 # Add a custom meta field
1963 update_post_meta($post_id, 'metasync_post', 'yes');
1964 }
1965
1966 } else {
1967 $new_post['ID'] = $post_id = $getPostID_byURL;
1968 wp_update_post($new_post);
1969 unset($new_post['ID']);
1970 $permalink = get_permalink($post_id);
1971 }
1972
1973 if (isset($item['is_landing_page']) && $item['is_landing_page'] == true) {
1974 kses_remove_filters();
1975 }
1976
1977 $post_meta = array();
1978 if(isset($content['elementor_meta_data'])){
1979 $post_meta = array_merge($post_meta,$content['elementor_meta_data']);
1980 }else if(isset($content['divi_meta_data'])){
1981 $post_meta = array_merge($post_meta,$content['divi_meta_data']);
1982 $post_meta['_et_pb_ab_current_shortcode']='[et_pb_split_track id="'.$post_id.'" /]';
1983 $post_meta['_et_pb_use_builder']='on';
1984 $post_meta['_et_pb_built_for_post_type']=isset($item['post_type']) ? sanitize_text_field($item['post_type']) : 'post';
1985 }
1986
1987 if (isset($item['meta_description']) && !empty($item['meta_description'])) {
1988 $post_meta['meta_description'] = sanitize_text_field($item['meta_description']);
1989 }
1990 if (isset($item['meta_robots']) && !empty($item['meta_robots'])) {
1991 $post_meta['meta_robots'] = sanitize_text_field($item['meta_robots']);
1992 }
1993
1994 // Add custom field for post header section
1995 if (isset($item['custom_post_header'])) { // && !empty($item['custom_post_header'])
1996 $post_meta['custom_post_header'] = $item['custom_post_header'];
1997 }
1998 // Add custom field for post footer section
1999 if (isset($item['custom_post_footer'])) { // && !empty($item['custom_post_footer'])
2000 $post_meta['custom_post_footer'] = $item['custom_post_footer'];
2001 }
2002
2003 // Add custom field for searchatlas top
2004 if (isset($item['searchatlas_embed_top'])) { // && !empty($item['searchatlas_embed_top'])
2005 $post_meta['searchatlas_embed_top'] = $item['searchatlas_embed_top'];
2006 }
2007 // Add custom field for searchatlas bottom
2008 if (isset($item['searchatlas_embed_bottom'])) { // && !empty($item['searchatlas_embed_bottom'])
2009 $post_meta['searchatlas_embed_bottom'] = $item['searchatlas_embed_bottom'];
2010 }
2011
2012 // Add custom fields to posts and pages
2013 foreach ($post_meta as $key => $value) {
2014 // if (!empty($value) && !is_null($value)) {
2015 add_post_meta($post_id, $key, $value, true);
2016 // }
2017 }
2018
2019
2020 $attachment_id = '';
2021 if (isset($item['hero_image_url']) && !empty($item['hero_image_url'])) {
2022 $attachment_id = $this->metasync_handle_hero_image($post_id, $item['hero_image_url'], $item['hero_image_alt_text']);
2023 }
2024
2025 $redirection = array();
2026 if (isset($item['redirection_enable']) && !empty($item['redirection_enable'])) {
2027 $redirection['enable'] = sanitize_text_field($item['redirection_enable']);
2028 }
2029 if (isset($item['redirection_type']) && !empty($item['redirection_type'])) {
2030 $redirection['type'] = sanitize_text_field($item['redirection_type']);
2031 }
2032 if (isset($item['redirection_url']) && !empty($item['redirection_url'])) {
2033 $redirection['url'] = sanitize_url($item['redirection_url']);
2034 }
2035 if (!empty($redirection)) {
2036 update_post_meta($post_id, 'metasync_post_redirection_meta', $redirection);
2037 }
2038
2039 $post_cattegories = [];
2040 # if ($new_post['post_type'] === 'post' && is_array(@$item['post_categories'])) {
2041
2042 # fixed Undefined array key issue
2043 if ($new_post['post_type'] === 'post' && isset($item['post_categories']) && is_array($item['post_categories'])) {
2044 $append_categories = isset($item['append_categories']) && $item['append_categories'] == true ? true : false;
2045 $post_cattegories = $this->metasync_handle_post_category($post_id, $item['post_categories'], $append_categories);
2046 }
2047 if (isset($content['elementor_meta_data']) && did_action( 'elementor/loaded' )) {
2048 // Clear Elementor cache for the specified post ID
2049 \Elementor\Plugin::instance()->files_manager->clear_cache();
2050
2051 }
2052
2053 $post_tags = [];
2054 # if ($new_post['post_type'] === 'post' && is_array(@$item['post_tags'])) {
2055
2056 # fixed Undefined array key 'post_tags issue
2057 if ($new_post['post_type'] === 'post' && isset($item['post_tags']) && is_array($item['post_tags'])) {
2058 $append_tags = isset($item['append_tags']) && $item['append_tags'] == true ? true : false;
2059 $post_tags = $this->metasync_set_post_tags($post_id, $item['post_tags'], $append_tags);
2060 }
2061
2062 $new_post['post_categories'] = $post_cattegories;
2063 $new_post['post_tags'] = $post_tags;
2064 unset($new_post['post_name']);
2065 $new_post['post_id'] = $post_id;
2066 $new_post['permalink'] = $permalink;
2067 $new_post['hero_image_url'] = wp_get_attachment_url($attachment_id);
2068 $new_post['hero_image_alt_text'] = get_post_meta($attachment_id, '_wp_attachment_image_alt', true);
2069
2070 # Log sync history for Content Genius post creation/update
2071 if (!is_wp_error($post_id) && $post_id > 0) {
2072 $action = ($getPostID_byURL === NULL) ? 'Created' : 'Updated';
2073 $title_preview = mb_strlen($new_post['post_title']) > 30 ? mb_substr($new_post['post_title'], 0, 30) . '...' : $new_post['post_title'];
2074
2075 # Use appropriate title based on post type
2076 $content_type_label = ($new_post['post_type'] === 'page') ? 'Page' : 'Post';
2077 $sync_status = ($new_post['post_status'] === 'publish') ? 'published' : $new_post['post_status'];
2078
2079 metasync_log_sync_history([
2080 'title' => "{$content_type_label} {$action} ({$title_preview})",
2081 'source' => 'Content Genius',
2082 'status' => $sync_status,
2083 'content_type' => ucfirst($new_post['post_type']),
2084 'url' => $permalink,
2085 'meta_data' => json_encode([
2086 'post_id' => $post_id,
2087 'post_title' => $new_post['post_title'],
2088 'post_type' => $new_post['post_type'],
2089 'post_status' => $new_post['post_status'],
2090 'action' => strtolower($action)
2091 ])
2092 ]);
2093
2094 # Track Content Genius event in GA4
2095 try {
2096 Metasync_GA4::get_instance()->track_content_genius_event($post_id, strtolower($action));
2097 } catch (Exception $e) {
2098 error_log('MetaSync: Analytics tracking failed for Content Genius - ' . $e->getMessage());
2099 }
2100
2101 // Google Indexing Integration
2102 $this->metasync_google_index_post($post_id, $new_post['post_type'], $new_post['post_status']);
2103 }
2104
2105 # For standard synced posts, learn (once per post-type) whether the
2106 # theme renders the title, so future syncs prepend only when genuinely needed.
2107 if (!is_wp_error($post_id) && $post_id > 0
2108 && !isset($item['is_landing_page']) && empty($isOttoAiPage) && empty($item['style_data'])
2109 && $new_post['post_status'] === 'publish') {
2110 try {
2111 $this->record_theme_title_verdict($post_id, $permalink, $new_post['post_title'], $new_post['post_type']);
2112 } catch (Exception $e) {
2113 error_log('MetaSync title verdict failed: ' . $e->getMessage());
2114 }
2115 }
2116
2117 $respCreatePosts[$index] = array_merge($new_post, $post_meta);
2118 ksort($respCreatePosts[$index]);
2119 }
2120
2121 if ($array_response == false)
2122 return rest_ensure_response($respCreatePosts);
2123 return $respCreatePosts;
2124 }
2125
2126 public function set_landing_page($request)
2127 {
2128 $params = $request->get_json_params();
2129
2130 if (!isset($params[0]) || empty($params[0])) {
2131 return new WP_Error(
2132 'validation_error',
2133 'Invalid request data. Empty Payload Provided',
2134 array('status' => 400)
2135 );
2136 }
2137
2138 $payload = $params[0];
2139 $payload['permalink'] = "metasync-landing-page"; // hardcoding to avoid duplicates
2140 $payload['post_type'] = "page";
2141 $payload['post_status'] = "publish";
2142 $payload['is_landing_page'] = true;
2143 $createPages = $this->create_item($payload); // creating landing page
2144
2145 if (is_wp_error($createPages)) {
2146 return $createPages;
2147 }
2148
2149 $post_id = $createPages[0]['post_id'];
2150 update_option('page_on_front', $post_id);
2151 update_option('show_on_front', 'page');
2152
2153 require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-template.php';
2154 update_post_meta($post_id, '_wp_page_template', Metasync_Template::TEMPLATE_NAME);
2155 return rest_ensure_response($createPages);
2156 }
2157
2158 public function delete_item()
2159 {
2160 $get_data = metasync_sanitize_input_array($_GET);
2161 if (!isset($get_data['ID']))
2162 return false;
2163
2164 $post_id = sanitize_text_field($get_data['ID']) ?? null;
2165 $post = get_post($post_id);
2166 if ($post) {
2167 wp_delete_post($post_id);
2168 return new WP_Error(
2169 'rest_post_delete_success',
2170 esc_html__(''),
2171 // HTTP 204 requires no body for response
2172 array('status' => 204)
2173 );
2174 }
2175 return new WP_Error(
2176 'rest_post_delete_fail',
2177 esc_html__('No post found in the database with requested ID.'),
2178 array('status' => 400)
2179 );
2180 }
2181
2182 public function get_items($request)
2183 {
2184 $get_data = metasync_sanitize_input_array($_GET);
2185
2186 # let us check if request send post id and is valid int
2187 if(isset($get_data['post_id']) AND intval($get_data['post_id']) > 0){
2188
2189 #get the post id
2190 $post_id = $get_data['post_id'];
2191
2192 #get the elementor items of the post
2193 $elementor_items = $this->elementor_getItems($post_id);
2194
2195 #return the elementor items in response
2196 return rest_ensure_response($elementor_items);
2197 }
2198
2199 return rest_ensure_response(
2200 array(
2201 'posts' => $this->filter_post_attributes(
2202 get_posts(
2203 array(
2204 'numberposts' => -1
2205 )
2206 )
2207 ),
2208 'pages' => $this->filter_post_attributes(
2209 get_pages(
2210 array(
2211 'numberposts' => -1
2212 )
2213 )
2214 )
2215 )
2216 );
2217 }
2218
2219 private function elementor_getItems($post_id)
2220 {
2221 $data_array = array();
2222 $elementorData = get_post_meta($post_id, '_elementor_data', true);
2223 if (!empty($elementorData)) {
2224 $elementorData = json_decode($elementorData);
2225 $this->elementor_getElement($elementorData, $data_array);
2226 }
2227 // echo $this->elementor_convertToXML($data_array);
2228 return $this->elementor_convertToDraftJS($data_array);
2229 }
2230
2231 private function elementor_getElement($elements, &$data_array)
2232 {
2233 $elements_allowedWidgetTypes = ['heading', 'text-editor', 'image'];
2234 $elements_groupItems = ['section', 'column'];
2235 foreach ($elements as $element) {
2236 if (in_array($element->elType, $elements_groupItems)) {
2237 $this->elementor_getElement($element->elements, $data_array);
2238 continue;
2239 }
2240
2241 #check that we process only widgets
2242 if($element->elType !== 'widget'){
2243
2244 #go to next
2245 continue;
2246 }
2247
2248 switch ($element->widgetType) {
2249 case 'heading':
2250 $data_array[$element->id] = ['value' => trim($element->settings->title), 'type' => 'heading'];
2251 break;
2252 case 'image':
2253 $data_array[$element->id] = ['value' => $element->settings->image->url, 'type' => 'url'];
2254 break;
2255 case 'text-editor':
2256 $data_array[$element->id] = ['value' => trim($element->settings->editor), 'type' => 'text-editor'];
2257 break;
2258
2259 default:
2260 }
2261 }
2262 }
2263
2264 private function elementor_convertToDraftJS($data_array)
2265 {
2266 $response = array(
2267 "blocks" => [],
2268 );
2269
2270 foreach ($data_array as $id => $item) {
2271 // array_push($response['blocks'],
2272 // array(
2273 // "key" => "$id",
2274 // "text" => $item['value'],
2275 // "type" => "unstyled",
2276 // "depth" => 0,
2277 // "inlineStyleRanges" => [],
2278 // "entityRanges" => [],
2279 // "data" => []
2280 // )
2281 // );
2282 $this->convertFromHTMLToContentBlocks($id, $item['value'], $response['blocks']);
2283 }
2284 return $response;
2285 }
2286
2287 private function convertFromHTMLToContentBlocks($key, $html, &$contentBlocks)
2288 {
2289 $dom = new DOMDocument();
2290 libxml_use_internal_errors(true); // Disable error reporting for HTML5 tags
2291 $dom->loadHTML($html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
2292 libxml_use_internal_errors(false); // Enable error reporting again
2293
2294 $blockLevel = [];
2295 // Iterate through each node in the body
2296 foreach ($dom->getElementsByTagName('*')->item(0)->childNodes as $node) {
2297 // Process each node and convert it to a content block
2298 $block = $this->convertNodeToContentBlock($key, $node, $blockLevel);
2299 if ($block) {
2300 $contentBlocks[] = $block;
2301 }
2302 }
2303 // return $contentBlocks;
2304 }
2305
2306 private function convertNodeToContentBlock($id, $node, &$blockLevel)
2307 {
2308 if($blockLevel[$id] == null) {
2309 $blockLevel[$id] = 0;
2310 }
2311 $blockLevel[$id]++;
2312 $id = $id . '-' . $blockLevel[$id];
2313
2314
2315 // Check node type
2316 switch ($node->nodeType) {
2317 case XML_TEXT_NODE:
2318 // Text node
2319 $text = trim($node->nodeValue);
2320 if ($text !== '') {
2321 return [
2322 'key' => $id,
2323 'type' => 'unstyled',
2324 'text' => $text,
2325 'depth' => 0,
2326 'inlineStyleRanges' => [],
2327 'entityRanges' => [],
2328 'data' => [],
2329 ];
2330 }
2331 break;
2332
2333 case XML_ELEMENT_NODE:
2334 // Element node
2335 $tagName = strtolower($node->tagName);
2336
2337 // Map HTML tags to Draft.js block types
2338 $blockTypeMap = [
2339 'p' => 'unstyled',
2340 'h1' => 'header-one',
2341 'h2' => 'header-two',
2342 'h3' => 'header-three',
2343 // Add more block types as needed
2344 ];
2345
2346 $blockType = isset($blockTypeMap[$tagName]) ? $blockTypeMap[$tagName] : $tagName; //'unstyled';
2347
2348 $block = [
2349 'key' => $id,
2350 'type' => $blockType,
2351 'text' => '',
2352 'depth' => 0,
2353 'inlineStyleRanges' => [],
2354 'entityRanges' => [],
2355 'data' => [],
2356 ];
2357
2358 // Process child nodes recursively
2359 foreach ($node->childNodes as $childNode) {
2360 $childBlock = $this->convertNodeToContentBlock($id, $childNode, $blockLevel);
2361 if ($childBlock) {
2362 // Append child block's text and inline styles
2363 $block['text'] .= $childBlock['text'];
2364 $block['inlineStyleRanges'] = array_merge(
2365 $block['inlineStyleRanges'],
2366 $childBlock['inlineStyleRanges']
2367 );
2368 }
2369 }
2370
2371 // Handle inline styles
2372 $inlineStyleMap = [
2373 'strong' => 'BOLD',
2374 'em' => 'ITALIC',
2375 // Add more inline styles as needed
2376 ];
2377
2378 if (isset($inlineStyleMap[$tagName])) {
2379 $inlineStyle = $inlineStyleMap[$tagName];
2380 $startIndex = strlen($block['text']);
2381 $endIndex = $startIndex + strlen($node->textContent);
2382
2383 $block['inlineStyleRanges'][] = [
2384 'offset' => $startIndex,
2385 'length' => $endIndex - $startIndex,
2386 'style' => $inlineStyle,
2387 ];
2388 }
2389
2390 return $block;
2391 }
2392
2393 return null;
2394 }
2395
2396
2397 private function update_object($object_id, $update_params)
2398 {
2399 $post_params = ['ID' => $object_id];
2400
2401 if (!empty($update_params['post_title']) && !is_null($update_params['post_title'])) {
2402 $post_params['post_title'] = $update_params['post_title'];
2403 unset($update_params['post_title']);
2404 }
2405 if (!empty($update_params['post_excerpt']) && !is_null($update_params['post_excerpt'])) {
2406 $post_params['post_excerpt'] = $update_params['post_excerpt'];
2407 unset($update_params['post_excerpt']);
2408 }
2409 if (!empty($update_params['post_content']) && !is_null($update_params['post_content'])) {
2410 $post_params['post_content'] = $update_params['post_content'];
2411 unset($update_params['post_content']);
2412 }
2413 if (!empty($update_params['post_status']) && !is_null($update_params['post_status'])) {
2414 $post_params['post_status'] = $update_params['post_status'];
2415 unset($update_params['post_status']);
2416 }
2417 if (!empty($update_params['post_name']) && !is_null($update_params['post_name'])) {
2418 $post_params['post_name'] = $update_params['post_name'];
2419 unset($update_params['post_name']);
2420 }
2421 if (!empty($update_params['post_category']) && !is_null($update_params['post_category'])) {
2422 $post_params['post_category'] = $update_params['post_category'];
2423 unset($update_params['post_category']);
2424 }
2425 if (!empty($update_params['post_author']) && !is_null($update_params['post_author'])) {
2426 $post_params['post_author'] = $update_params['post_author'];
2427 unset($update_params['post_author']);
2428 }
2429 if (!empty($update_params['comment_status']) && !is_null($update_params['comment_status'])) {
2430 $post_params['comment_status'] = $update_params['comment_status'];
2431 unset($update_params['comment_status']);
2432 }
2433 if (!empty($update_params['post_date']) && !is_null($update_params['post_date'])) {
2434 $post_params['post_date'] = $update_params['post_date'];
2435 unset($update_params['post_date']);
2436 }
2437 if (!empty($update_params['post_parent']) && !is_null($update_params['post_parent'])) {
2438 $update_params['post_parent'] = isset($update_params['post_parent']) ? $update_params['post_parent']: 0;
2439 $post_params['post_parent'] = $update_params['post_parent'];
2440 unset($update_params['post_parent']);
2441 }
2442 // Update Post and Page content
2443
2444 $tryUpdatePost = wp_update_post($post_params);
2445 // Update Elementor post content
2446 // $this->elementor_update_content($object_id, $post_params['post_content']);
2447 // Update Post and Page meta data
2448 $resp_meta = array(
2449 'post_content' => false,
2450 'post_meta' => array()
2451 );
2452
2453 if ($tryUpdatePost !== 0 && $tryUpdatePost !== false)
2454 $resp_meta['post_content'] = true;
2455
2456 foreach ($update_params as $key => $value) {
2457 // var_dump($object_id, $key, $value);
2458 // if (!empty($value) && !is_null($value)) {
2459 $response = update_post_meta($object_id, $key, $value);
2460 if ($response == false) {
2461 $resp_meta['post_meta'][$object_id][$key] = 'NO_CHANGE';
2462 } else {
2463 $resp_meta['post_meta'][$object_id][$key] = 'UPDATED'; //$response;
2464 }
2465 // }
2466 }
2467 return $resp_meta;
2468 }
2469
2470 public function update_items($request)
2471 {
2472 $data = array();
2473
2474 $array_response = true;
2475 if (gettype($request) == "object")
2476 $array_response = false;
2477
2478 $request_data = array($request);
2479 if ($array_response == false)
2480 $request_data = $request->get_json_params();
2481
2482 foreach ($request_data as $post) {
2483 $update_params = array();
2484 $post_id = 0;
2485
2486 // Gettin post id from payload
2487 if ($post_id == 0 && isset($post['post_id']) && !empty($post['post_id'])) {
2488 $post_id = sanitize_text_field($post['post_id']);
2489 } else {
2490 // Getting post id via URL
2491 if (isset($post['post_url']) && !empty($post['post_url'])) {
2492 $safe_url = sanitize_url($post['post_url']);
2493 $post_id = url_to_postid($safe_url);
2494
2495 if ($post_id == 0) {
2496 // try to get post_id by permalink
2497 # $post_id = @get_page_by_path(sanitize_text_field($post['permalink']), OBJECT, 'post')->ID;
2498 # Fix to avoid PHP error if the get_page_by_path returns null
2499 $post_by_path = @get_page_by_path(sanitize_text_field($post['permalink']), OBJECT, 'post');
2500 $post_id = $post_by_path ? $post_by_path->ID : 0;
2501 }
2502
2503 if ($post_id == 0) {
2504 // try to get permalink from URL
2505 $url_to_permalink = $this->common->get_permalink_from_url($safe_url);
2506 # $post_id = @get_page_by_path($url_to_permalink, OBJECT, 'post')->ID;
2507 # Fix to avoid PHP error if the get_page_by_path returns null
2508 $post_by_path = @get_page_by_path($url_to_permalink, OBJECT, 'post');
2509 $post_id = $post_by_path ? $post_by_path->ID : 0;
2510 }
2511 }
2512 }
2513 if(!$array_response){
2514 $post_data = get_post($post['post_id']);
2515 if ($post_data->post_type !== $post['post_type']) {
2516 if ($post_data) {
2517 $post_data->post_type = 'post';
2518 wp_update_post($post_data);
2519 }
2520 }
2521 }
2522 $post_data = get_post($post_id);
2523 if (!$post_data) {
2524 return new WP_Error(
2525 'rest_post_update_fail',
2526 esc_html__('No post found in the database with requested ID.'),
2527 array('status' => 400)
2528 );
2529 }
2530
2531 $post_author = isset($post['post_author']) && !empty($post['post_author']) ?
2532 $update_params['post_author'] = sanitize_text_field($post['post_author']) :
2533 $update_params['post_author'] = $post_data->post_author;
2534 wp_set_current_user($post_author);
2535
2536 if (isset($post['post_title']) && !empty($post['post_title'])) {
2537 $update_params['post_title'] = sanitize_text_field($post['post_title']);
2538 }
2539 if (isset($post['post_parent']) && !empty($post['post_parent'])) {
2540 $update_params['post_parent'] = (int) $post['post_parent'];
2541 }
2542 if (isset($post['meta_description']) && !empty($post['meta_description'])) {
2543 $get_desc_meta = get_post_meta($post['post_id'], 'meta_description', true);
2544 $update_params['meta_description'] = $post['meta_description'] ?
2545 sanitize_text_field($post['meta_description']) : $get_desc_meta;
2546 }
2547 if (isset($post['meta_robots']) && !empty($post['meta_robots'])) {
2548 $get_robots_meta = get_post_meta($post['post_id'], 'meta_robots', true);
2549 $update_params['meta_robots'] = $post['meta_robots'] ?
2550 sanitize_text_field($post['meta_robots']) : $get_robots_meta;
2551 }
2552 if (isset($post['meta_canonical']) && !empty($post['meta_canonical'])) {
2553 // Validate as a URL: drop unusable values instead of
2554 // storing text that would later be emitted as a canonical.
2555 $clean_canonical = Metasync_Canonical_Sanitizer::sanitize_for_save($post['meta_canonical']);
2556 if ($clean_canonical !== '') {
2557 $update_params['meta_canonical'] = $clean_canonical;
2558 }
2559 }
2560
2561 $isOttoAiPage = !empty($post['otto_ai_page']) && filter_var($post['otto_ai_page'], FILTER_VALIDATE_BOOLEAN);
2562 $content = [];
2563 $permalink = '';
2564 if (isset($post['post_content']) && !empty($post['post_content']) && !$isOttoAiPage) {
2565
2566 # Above we are updating the post_type so we have to get latest value that has been change on the server
2567 $post_fresh_data = get_post($post['post_id']);
2568
2569 /**
2570 * Check if current theme is Flatsome using SAVED theme info (not wp_get_theme())
2571 * Theme info is saved by admin hooks, so no security triggers during REST API
2572 */
2573 $metasync_general = Metasync::get_option('general');
2574 $theme_name = $metasync_general['current_theme_name'] ?? '';
2575 $theme_template = $metasync_general['current_theme_template'] ?? '';
2576
2577 $is_flatsome_theme = false;
2578 if (!empty($theme_name) && stripos($theme_name, 'Flatsome') !== false) {
2579 $is_flatsome_theme = true;
2580 } elseif (!empty($theme_template) && stripos($theme_template, 'flatsome') !== false) {
2581 $is_flatsome_theme = true;
2582 }
2583
2584 # Title to compare against — payload may omit post_title on update
2585 $compare_title = isset($post['post_title']) && !empty($post['post_title']) ? $post['post_title'] : $post_fresh_data->post_title;
2586
2587 # Always strip a leading H1 that duplicates the title; the theme
2588 # renders post_title in its header, so a body title H1 is a duplicate.
2589 $post['post_content'] = $this->strip_leading_title_h1($post['post_content'], $compare_title);
2590
2591 # Skip title/image prepending for Flatsome (it displays them by default)
2592 if (!$is_flatsome_theme) {
2593 # Get the setting for the post template (feature-image detection)
2594 $title_and_feature_image = $this->append_content_if_missing_elements($post_fresh_data->post_type);
2595
2596 # Prepend the feature image when the template does not already show it
2597 if(!$title_and_feature_image['image_in_content'] && !empty($post['hero_image_url'])){
2598 $post['post_content'] = '<img src="' . esc_url_raw($post['hero_image_url']) . '" />'.$post['post_content'] ;
2599 }
2600
2601 # Only prepend the title H1 when the theme is render-verified to
2602 # omit it. Unknown verdict defaults to NOT prepending (see should_prepend_title).
2603 if($this->should_prepend_title($post_fresh_data->post_type)){
2604 $post['post_content'] = '<h1>'.$compare_title.'</h1>'.$post['post_content'] ;
2605 }
2606 }
2607 // This will be used by update_page function
2608 $content = $this->metasync_upload_post_content($post,false,false);
2609 $update_params['post_content'] = $content['content'];
2610 }
2611 /*
2612 check if the create_item is called by set_landing_page function or not
2613 by doing this we will prevent html from going into builder page option
2614 */
2615 if($isOttoAiPage){
2616 $content = $this->metasync_upload_post_content($post,true,true);
2617 $update_params['post_content'] = $content['content'];
2618 // delete the elementor related meta data so that it won't get proccess by elementor
2619 delete_post_meta( $post_id, '_elementor_data' );
2620 delete_post_meta( $post_id, '_elementor_version' );
2621 delete_post_meta( $post_id, '_elementor_css' );
2622 delete_post_meta( $post_id, '_elementor_page_assets' );
2623 }
2624
2625 // Add custom field for post header section
2626 if (isset($post['custom_post_header'])) { // && !empty($post['custom_post_header'])
2627 $update_params['custom_post_header'] = $post['custom_post_header'];
2628 }
2629
2630 if (isset($post['custom_post_footer'])) { // && !empty($post['custom_post_footer'])
2631 $update_params['custom_post_footer'] = $post['custom_post_footer'];
2632 }
2633
2634 if (isset($post['searchatlas_embed_top'])) { // && !empty($post['searchatlas_embed_top'])
2635 $update_params['searchatlas_embed_top'] = $post['searchatlas_embed_top'];
2636 }
2637
2638 if (isset($post['searchatlas_embed_bottom'])) { // && !empty($post['searchatlas_embed_bottom'])
2639 $update_params['searchatlas_embed_bottom'] = $post['searchatlas_embed_bottom'];
2640 }
2641
2642 if (isset($post['meta_description']) && !empty($post['meta_description'])) {
2643 $update_params['post_excerpt'] = sanitize_text_field($post['meta_description']);
2644
2645 }
2646
2647 if (isset($post['post_status']) && !empty($post['post_status'])) {
2648 $update_params['post_status'] = $post['post_status'] ? sanitize_text_field($post['post_status']) : 'publish';
2649 $permalink = get_permalink($post_id);
2650 }
2651 if (isset($post['permalink']) || !empty($post['permalink'])) {
2652 $update_params['post_name'] = sanitize_text_field($post['permalink']);
2653 }
2654 if (isset($post['post_parent']) ) {
2655 $update_params['post_parent'] = isset($post['post_parent']) ? sanitize_text_field($post['post_parent']) : 0;
2656
2657 wp_update_post(
2658 array(
2659 'ID' =>$post_id,
2660 'post_parent' => $update_params['post_parent']
2661 )
2662 );
2663 }
2664
2665
2666 if (isset($post['post_date']) && !empty($post['post_date']) && false) {
2667 $is_valid_date = date('Y-m-d', strtotime($post['post_date'])) == $post['post_date'];
2668 if (!$is_valid_date) {
2669 return new WP_Error(
2670 'rest_post_invalid_date',
2671 esc_html__('Post date is not valid'),
2672 array('status' => 400)
2673 );
2674 }
2675
2676 $date_limit_str = strtotime(date('Y-m-d') . '-2 month');
2677 $post_date_str = strtotime($post['post_date']);
2678
2679 if ($date_limit_str >= $post_date_str) {
2680 $newDate = date('Y-m-d', strtotime('-2 month'));
2681 return new WP_Error(
2682 'rest_post_greater_date',
2683 esc_html__("Post date should be greater then " . $newDate),
2684 array('status' => 400)
2685 );
2686 }
2687
2688 if ($post_date_str > strtotime(date('Y-m-d'))) {
2689 return new WP_Error(
2690 'rest_post_greater_date',
2691 esc_html__('Post date should be less then Today'),
2692 array('status' => 400)
2693 );
2694 }
2695 $update_params['post_date'] = sanitize_text_field($post['post_date'] . date(' h:i:s'));
2696 }
2697
2698 $post_cattegories = [];
2699 # if ($post_data && $post_data->post_type === 'post' && is_array(@$post['post_categories'])) {
2700
2701 # fixed Undefined array key issue
2702 if ($post_data && $post_data->post_type === 'post' && isset($post['post_categories']) && is_array($post['post_categories'])) {
2703 $append_categories = isset($post['append_categories']) && $post['append_categories'] == true ? true : false;
2704 $post_cattegories = $this->metasync_handle_post_category($post_id, $post['post_categories'], $append_categories);
2705 }
2706
2707 $post_tags = [];
2708 # if ($post_data && $post_data->post_type === 'post' && is_array(@$post['post_tags'])) {
2709
2710 # fixed Undefined array key 'post_tags' issue
2711 if ($post_data && $post_data->post_type === 'post' && isset($post['post_tags']) && is_array($post['post_tags'])) {
2712 $append_tags = isset($post['append_tags']) && $post['append_tags'] == true ? true : false;
2713 $post_tags = $this->metasync_set_post_tags($post_id, $post['post_tags'], $append_tags);
2714 }
2715
2716 $attachment_id = '';
2717 if (isset($post['hero_image_url']) && !empty($post['hero_image_url'])) {
2718 $attachment_id = $this->metasync_handle_hero_image($post_id, $post['hero_image_url'], $post['hero_image_alt_text']);
2719 }
2720
2721 $resp_update = $this->update_object($post_id, $update_params);
2722 if(isset($content['elementor_meta_data'])){
2723 foreach ($content['elementor_meta_data'] as $key => $value) {
2724 update_post_meta($post_id, $key, $value);
2725 }
2726 if ( did_action( 'elementor/loaded' ) ) {
2727 // Clear Elementor cache for the specified post ID
2728 \Elementor\Plugin::instance()->files_manager->clear_cache();
2729 }
2730 } elseif (!$isOttoAiPage && get_post_meta($post_id, '_elementor_data', true)) {
2731 // Content was NOT converted to Elementor format (e.g. Oxygen is the active
2732 // builder), but stale Elementor meta exists from a previous sync. Clear it
2733 // so Elementor doesn't override the page builder's rendering.
2734 delete_post_meta($post_id, '_elementor_data');
2735 delete_post_meta($post_id, '_elementor_edit_mode');
2736 delete_post_meta($post_id, '_elementor_version');
2737 delete_post_meta($post_id, '_elementor_css');
2738 delete_post_meta($post_id, '_elementor_page_assets');
2739 delete_post_meta($post_id, '_elementor_page_settings');
2740 }
2741
2742 $redirection = array();
2743 if (!empty($post['redirection_enable']) && !is_null($post['redirection_enable'])) {
2744 $redirection['enable'] = sanitize_text_field($post['redirection_enable']);
2745 }
2746 if (!empty($post['redirection_type']) && !is_null($post['redirection_type'])) {
2747 $redirection['type'] = sanitize_text_field($post['redirection_type']);
2748 }
2749 if (!empty($post['redirection_url']) && !is_null($post['redirection_url'])) {
2750 $redirection['url'] = sanitize_url($post['redirection_url']);
2751 }
2752 if (!empty($redirection)) {
2753 update_post_meta($post_id, 'metasync_post_redirection_meta', $redirection);
2754 }
2755
2756
2757 $post_revisions = wp_get_post_revisions($post_id);
2758 // Sync post categories to customer dashboard
2759 $this->lgSendCustomerPostParams();
2760
2761 unset($update_params['post_name']);
2762
2763 $update_params['post_categories'] = $post_cattegories;
2764 $update_params['post_tags'] = $post_tags;
2765 $update_params['post_id'] = (int) $post_id;
2766 $update_params['permalink'] = $permalink;
2767
2768 $update_params['hero_image_url'] = wp_get_attachment_url($attachment_id);
2769 $update_params['hero_image_alt_text'] = get_post_meta($attachment_id, '_wp_attachment_image_alt', true);
2770 $update_params['post_revisions'] = gettype($post_revisions) == 'array' ? count($post_revisions) : (int)$post_revisions;
2771 $update_params['post_updated'] = $resp_update;
2772
2773
2774 // check if the content is added or not
2775 if(empty($post['is_landing_page']) ){
2776
2777 # update the content
2778 $postContent = array(
2779 'ID' => $post_id,
2780 'post_content' => ($content['content'] ? $content['content'] : $post['post_content']),
2781 );
2782 wp_update_post($postContent );
2783 #rename the variable to avoide confusion
2784 $post_meta_data = array();
2785 if(isset($content['elementor_meta_data'])){
2786 $post_meta_data = array_merge($post_meta_data,$content['elementor_meta_data']);
2787 }else if(isset($content['divi_meta_data'])){
2788 $post_meta_data = array_merge($post_meta_data,$content['divi_meta_data']);
2789
2790 }
2791 # update the content
2792 // add and update the post meta
2793 foreach ($post_meta_data as $key => $value) {
2794 // if (!empty($value) && !is_null($value)) {
2795
2796 update_post_meta($post_id, $key, $value);
2797 //
2798 }
2799 #check if the elementor plugin is active
2800 if ( did_action( 'elementor/loaded' ) ) {
2801 # Clear Elementor cache for the specified post ID
2802 \Elementor\Plugin::instance()->files_manager->clear_cache();
2803
2804 }
2805 }
2806 # Log sync history for Content Genius post update
2807 if ($post_id > 0 && !empty($update_params)) {
2808 $post_title = $update_params['post_title'] ?? $post_data->post_title ?? 'Untitled';
2809 $title_preview = mb_strlen($post_title) > 30 ? mb_substr($post_title, 0, 30) . '...' : $post_title;
2810 $post_status = $update_params['post_status'] ?? $post_data->post_status ?? 'draft';
2811 $post_type = $post_data->post_type ?? 'post';
2812
2813 # Use appropriate title based on post type
2814 $content_type_label = ($post_type === 'page') ? 'Page' : 'Post';
2815 $sync_status = ($post_status === 'publish') ? 'published' : $post_status;
2816
2817 metasync_log_sync_history([
2818 'title' => "{$content_type_label} Updated ({$title_preview})",
2819 'source' => 'Content Genius',
2820 'status' => $sync_status,
2821 'content_type' => ucfirst($post_type),
2822 'url' => $permalink ?? get_permalink($post_id),
2823 'meta_data' => json_encode([
2824 'post_id' => $post_id,
2825 'post_title' => $post_title,
2826 'post_type' => $post_type,
2827 'post_status' => $post_status,
2828 'action' => 'updated',
2829 'updated_fields' => array_keys($update_params)
2830 ])
2831 ]);
2832
2833 # Track Content Genius event in GA4
2834 try {
2835 Metasync_GA4::get_instance()->track_content_genius_event($post_id, 'updated');
2836 } catch (Exception $e) {
2837 error_log('MetaSync: Analytics tracking failed for Content Genius - ' . $e->getMessage());
2838 }
2839
2840 # Learn (once per post-type) whether the theme renders the title,
2841 # so future syncs prepend only when the theme genuinely omits it.
2842 if ($post_id > 0 && empty($post['is_landing_page']) && empty($isOttoAiPage)
2843 && $sync_status === 'published') {
2844 try {
2845 $this->record_theme_title_verdict($post_id, ($permalink ?? get_permalink($post_id)), $post_title, $post_type);
2846 } catch (Exception $e) {
2847 error_log('MetaSync title verdict failed: ' . $e->getMessage());
2848 }
2849 }
2850 }
2851
2852 ksort($update_params);
2853 $data[] = $update_params;
2854 }
2855
2856 return rest_ensure_response($data);
2857 }
2858 /*
2859 Populate the style data into post meta or update the data
2860 */
2861 private function style_meta_data($styleData,$post_id,$update = false){
2862 // check if $styleData is an array
2863 if(is_array($styleData)){
2864 //loop through every key present in the $styleData
2865 foreach($styleData as $key=> $styleItem){
2866 // store the post meta on the basis of the key check if it comes from page_update or page_create function
2867 if($update){
2868 update_post_meta((int)$post_id, $key, json_encode($styleItem)); // update the style data
2869 }else{
2870 add_post_meta((int)$post_id, $key, json_encode($styleItem), true ); // store the style data
2871 }
2872
2873 }
2874 }
2875 }
2876
2877 public function create_page($request)
2878 {
2879 $payload = $request->get_json_params();
2880
2881 #check if we have the params set
2882 if (!isset($payload[0]) || empty($payload[0])) {
2883 # Return an error response for invalid request data
2884 return new WP_Error(
2885 'validation_error',
2886 'Invalid request data. Empty Payload Provided',
2887 array('status' => 400)
2888 );
2889 }
2890
2891 #set the payload
2892 $payload = $payload[0];
2893
2894 $payload['post_type'] = "page";
2895 $createPages = $this->create_item($payload); // creating page
2896
2897 if (is_wp_error($createPages)) {
2898 return $createPages;
2899 }
2900
2901 $post_ids = array();
2902
2903 if (is_array($createPages) !== true) {
2904 $createPages = $createPages->data;
2905 }
2906 foreach ($createPages as $item) {
2907 array_push($post_ids, $item['post_id']);
2908 }
2909
2910 $payloadIndex = 0;
2911 $pageTemplate = 'default';
2912 $isOttoAiPage = !empty($payload['otto_ai_page']) && filter_var($payload['otto_ai_page'], FILTER_VALIDATE_BOOLEAN);
2913 foreach ($post_ids as $post_id) {
2914 /*
2915 check if the payload for style_data and otto_ai_page is set or not
2916 Also Check if the otto_ai_page is true or not if set true set Metasync Template for the page
2917 */
2918 if(isset($payload['style_data']) && $isOttoAiPage){
2919 // Change the page template from default to Metasync Template
2920 $pageTemplate = Metasync_Template::TEMPLATE_NAME;
2921 // store the style_date in a variable to ease the process
2922 $styleData = $payload['style_data'];
2923 // check if $styleData is an array
2924 if(is_array($styleData)){
2925 // add the post meta by calling the style_meta_data function
2926 $this->style_meta_data($styleData,$post_id);
2927 }
2928 // delete the elementor data so that it won't create problem in rendering
2929 delete_post_meta( $post_id, '_elementor_data' );
2930 delete_post_meta( $post_id, '_elementor_version' );
2931 delete_post_meta( $post_id, '_elementor_css' );
2932 delete_post_meta( $post_id, '_elementor_page_assets' );
2933 }
2934 if (
2935 isset($payload[$payloadIndex]['is_blank']) &&
2936 !empty($payload[$payloadIndex]['is_blank']) &&
2937 $payload[$payloadIndex]['is_blank'] != 'false'
2938 ) {
2939 require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-template.php';
2940 $pageTemplate = Metasync_Template::TEMPLATE_NAME;
2941 }
2942 if($isOttoAiPage){
2943 // Change the page template from default to Metasync Template
2944 $pageTemplate = Metasync_Template::TEMPLATE_NAME;
2945 }
2946 if ($pageTemplate !== 'default') {
2947 update_post_meta($post_id, '_wp_page_template', $pageTemplate);
2948 } else {
2949 // Clear stale templates that conflict with the active page builder.
2950 // e.g. metasync-blank from a previous OTTO sync, or elementor_canvas
2951 // written by the HTML-to-builder converter on an Oxygen site.
2952 $current = get_post_meta($post_id, '_wp_page_template', true);
2953 $stale_templates = array(Metasync_Template::TEMPLATE_NAME, 'elementor_canvas', 'elementor_header_footer');
2954 if (in_array($current, $stale_templates, true)) {
2955 delete_post_meta($post_id, '_wp_page_template');
2956 }
2957 }
2958 }
2959
2960 return rest_ensure_response($createPages);
2961 }
2962
2963 public function update_page($request)
2964 {
2965 $payload = $request->get_json_params();
2966
2967 if (!isset($payload[0]) || empty($payload[0])) {
2968 return new WP_Error(
2969 'validation_error',
2970 'Invalid request data. Empty Payload Provided',
2971 array('status' => 400)
2972 );
2973 }
2974
2975 $payload = $payload[0];
2976 $payload['post_type'] = "page";
2977
2978 $post_data = get_post($payload['post_id']);
2979 if(!isset($post_data->post_type)){
2980 return new WP_Error(
2981 'rest_page_type_fail',
2982 esc_html__('No page found in the database with requested ID.'),
2983 array('status' => 400)
2984 );
2985 }
2986 if ($post_data->post_type !== 'page') {
2987 // Verify if the post exists
2988 if ($post_data) {
2989 // Update the post type
2990 $post_data->post_type = 'page';
2991 // Save the changes
2992 wp_update_post($post_data);
2993 }
2994 }
2995
2996 $updatePages = $this->update_items($payload); // updating page
2997
2998 if (is_wp_error($updatePages)) {
2999 return $updatePages;
3000 }
3001
3002 $post_ids = array();
3003 foreach ($updatePages->data as $item) {
3004 array_push($post_ids, $item['post_id']);
3005 }
3006
3007 $payloadIndex = 0;
3008 $pageTemplate = 'default';
3009 $isOttoAiPage = !empty($payload['otto_ai_page']) && filter_var($payload['otto_ai_page'], FILTER_VALIDATE_BOOLEAN);
3010 foreach ($post_ids as $post_id) {
3011 /*
3012 check if the payload for style_data and otto_ai_page is set or not
3013 Also Check if the otto_ai_page is true or not if set true
3014 Update the Metasync Template for the page with css and js
3015 */
3016 if(isset($payload['style_data']) && $isOttoAiPage){
3017 // Change the page template from default to Metasync Template
3018 $pageTemplate = Metasync_Template::TEMPLATE_NAME;
3019 // store the style_date in a variable to ease the process
3020 $styleData = $payload['style_data'];
3021 // check if $styleData is an array
3022 if(is_array($styleData)){
3023 // update the post meta by calling the style_meta_data function
3024 $this->style_meta_data($styleData,$post_id,true);
3025 }
3026 }
3027 if (
3028 isset($payload[$payloadIndex]['is_blank']) &&
3029 !empty($payload[$payloadIndex]['is_blank']) &&
3030 $payload[$payloadIndex]['is_blank'] != 'false'
3031 ) {
3032 require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-template.php';
3033 $pageTemplate = Metasync_Template::TEMPLATE_NAME;
3034 }
3035 if($isOttoAiPage){
3036 // Change the page template from default to Metasync Template
3037 $pageTemplate = Metasync_Template::TEMPLATE_NAME;
3038 }
3039 if ($pageTemplate !== 'default') {
3040 update_post_meta($post_id, '_wp_page_template', $pageTemplate);
3041 } else {
3042 // Clear stale templates that conflict with the active page builder.
3043 $current = get_post_meta($post_id, '_wp_page_template', true);
3044 $stale_templates = array(Metasync_Template::TEMPLATE_NAME, 'elementor_canvas', 'elementor_header_footer');
3045 if (in_array($current, $stale_templates, true)) {
3046 delete_post_meta($post_id, '_wp_page_template');
3047 }
3048 }
3049 }
3050 return rest_ensure_response($updatePages->data);
3051 }
3052
3053 public function delete_page()
3054 {
3055 $deletePage = $this->delete_item(); // deleting page
3056 return rest_ensure_response($deletePage);
3057 }
3058
3059 /**
3060 * Data or Response received from HeartBeat API for admin area.
3061 */
3062 public function lgSendCustomerPostParams()
3063 {
3064 $sync_request = new Metasync_Sync_Requests();
3065 $response = $sync_request->SyncCustomerParams();
3066
3067 $responseCode = Metasync_Sync_Requests::get_response_code($response);
3068 if ($responseCode == 200) {
3069 # Use current_time('mysql') for consistency with cron heartbeat.
3070 $send_auth_token_timestamp = Metasync::get_option();
3071 $send_auth_token_timestamp['general']['send_auth_token_timestamp'] = current_time('mysql');
3072 Metasync::set_option($send_auth_token_timestamp);
3073 }
3074 }
3075
3076 public function linkgraph_login()
3077 {
3078 $post_data = metasync_sanitize_input_array($_POST);
3079 $payload = array(
3080 'username' => wp_unslash(sanitize_email($post_data['username'])),
3081 'password' => wp_unslash(sanitize_text_field($post_data['password']))
3082 );
3083
3084 $api_domain = class_exists('Metasync_Endpoint_Manager')
3085 ? Metasync_Endpoint_Manager::get_endpoint('API_DOMAIN')
3086 : Metasync::API_DOMAIN;
3087
3088 # PERFORMANCE OPTIMIZATION: Add timeout to prevent hung requests
3089 $response = wp_remote_post($api_domain . '/api/token/', array(
3090 'body' => $payload,
3091 'timeout' => 10,
3092 ));
3093
3094 # Error handling for timeout or connection failures
3095 if (is_wp_error($response)) {
3096 error_log('MetaSync: Token API failed: ' . $response->get_error_message());
3097 wp_send_json_error(array('message' => 'Token API request failed'));
3098 wp_die();
3099 }
3100
3101 $get_object = isset($response['body']) ? json_decode($response['body']) : array();
3102 if (!empty($get_object)) {
3103 wp_send_json($get_object);
3104 }
3105 wp_die();
3106 }
3107
3108 public function sync_heartbeat_data()
3109 {
3110 $sync_heartbeat_data = new Metasync_Sync_Requests();
3111 $response = $sync_heartbeat_data->SyncCustomerParams();
3112
3113 $responseCode = Metasync_Sync_Requests::get_response_code($response);
3114 if ($responseCode == 200) {
3115 return rest_ensure_response($response);
3116 }
3117 return rest_ensure_response($response);
3118 }
3119
3120 public function get_heartbeat_errorlogs()
3121 {
3122 $heartbeat_error_db = new Metasync_HeartBeat_Error_Monitor_Database();
3123 $response = $heartbeat_error_db->getAllRecords();
3124
3125 if (!empty($response)) {
3126 return rest_ensure_response($response);
3127 }
3128 return rest_ensure_response(['Error logs not found']);
3129 }
3130
3131 /**
3132 * Search Atlas Connect Callback Permission Validation
3133 *
3134 * Validates the nonce token before Search Atlas delivers the API key and Otto UUID.
3135 * Does NOT create a WordPress login session.
3136 * Validates the nonce token in the x-api-key header
3137 */
3138 public function validate_searchatlas_callback_permission($request)
3139 {
3140 try {
3141 // Step 1: Validate nonce token format from header
3142 $nonce_token = $request->get_header('x-api-key');
3143 $format_validation = $this->validate_searchatlas_nonce_format($nonce_token);
3144
3145 if (is_wp_error($format_validation)) {
3146 return $format_validation;
3147 }
3148
3149 // Step 2: Validate token exists and is not expired (READ-ONLY check)
3150 // BUGFIX: Don't call validate_deterministic_searchatlas_token() here because it marks token as used!
3151 // Just check if token exists and hasn't expired
3152 if (empty($nonce_token) || strlen($nonce_token) < 32) {
3153 return new WP_Error(
3154 'invalid_nonce_token',
3155 'Invalid nonce token format',
3156 array('status' => 401)
3157 );
3158 }
3159
3160 // Check token exists in transients (read-only - don't modify)
3161 // Try transient first, then fall back to wp_options (handles object cache issues)
3162 $transient_key = get_transient('metasync_sa_connect_active_' . $nonce_token);
3163
3164 if (empty($transient_key) && wp_using_ext_object_cache()) {
3165 $transient_key = $this->get_sso_token_from_db($nonce_token);
3166 }
3167
3168 if (empty($transient_key)) {
3169 return new WP_Error(
3170 'invalid_nonce_token',
3171 'Invalid or expired nonce token',
3172 array('status' => 401)
3173 );
3174 }
3175
3176 // Check token metadata exists (read-only)
3177 $token_metadata = get_transient($transient_key);
3178
3179 if ((empty($token_metadata) || !is_array($token_metadata)) && wp_using_ext_object_cache()) {
3180 $token_metadata = $this->get_sso_metadata_from_db($transient_key);
3181 }
3182
3183 if (empty($token_metadata) || !is_array($token_metadata)) {
3184 return new WP_Error(
3185 'invalid_nonce_token',
3186 'Invalid nonce token',
3187 array('status' => 401)
3188 );
3189 }
3190
3191 // Check not expired (read-only)
3192 if (isset($token_metadata['expires']) && time() > $token_metadata['expires']) {
3193 return new WP_Error(
3194 'invalid_nonce_token',
3195 'Nonce token expired',
3196 array('status' => 401)
3197 );
3198 }
3199
3200 // Permission granted - but don't mark token as used yet!
3201 // The main handler will do that after successful processing
3202 return true;
3203 } catch (Exception $e) {
3204 return new WP_Error(
3205 'permission_validation_error',
3206 'Internal error during permission validation',
3207 array('status' => 500)
3208 );
3209 }
3210 }
3211
3212 /**
3213 * Read SSO active token directly from DB, bypassing object cache.
3214 *
3215 * On sites with external object cache (Redis, Memcached, LiteSpeed), the
3216 * transient set during the admin AJAX request may not be visible to the
3217 * REST callback from SA servers (different process/cache context).
3218 *
3219 * Checks the companion _transient_timeout_ row to honor expiry.
3220 *
3221 * @param string $nonce_token The nonce token from the x-api-key header
3222 * @return string|false The metadata transient key, or false
3223 */
3224 private function get_sso_token_from_db($nonce_token)
3225 {
3226 global $wpdb;
3227
3228 $option_name = '_transient_metasync_sa_connect_active_' . $nonce_token;
3229 $timeout_name = '_transient_timeout_metasync_sa_connect_active_' . $nonce_token;
3230
3231 // Check expiry first
3232 $timeout = $wpdb->get_var(
3233 $wpdb->prepare(
3234 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
3235 $timeout_name
3236 )
3237 );
3238
3239 if ($timeout && (int) $timeout < time()) {
3240 // Expired — clean up orphan rows
3241 $wpdb->delete($wpdb->options, array('option_name' => $option_name));
3242 $wpdb->delete($wpdb->options, array('option_name' => $timeout_name));
3243 return false;
3244 }
3245
3246 $value = $wpdb->get_var(
3247 $wpdb->prepare(
3248 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
3249 $option_name
3250 )
3251 );
3252
3253 return $value ?: false;
3254 }
3255
3256 /**
3257 * Read SSO token metadata directly from DB, bypassing object cache.
3258 *
3259 * Checks the companion _transient_timeout_ row to honor expiry.
3260 *
3261 * @param string $transient_key The transient key holding the token metadata
3262 * @return array|false The token metadata array, or false
3263 */
3264 private function get_sso_metadata_from_db($transient_key)
3265 {
3266 global $wpdb;
3267
3268 $option_name = '_transient_' . $transient_key;
3269 $timeout_name = '_transient_timeout_' . $transient_key;
3270
3271 // Check expiry first
3272 $timeout = $wpdb->get_var(
3273 $wpdb->prepare(
3274 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
3275 $timeout_name
3276 )
3277 );
3278
3279 if ($timeout && (int) $timeout < time()) {
3280 $wpdb->delete($wpdb->options, array('option_name' => $option_name));
3281 $wpdb->delete($wpdb->options, array('option_name' => $timeout_name));
3282 return false;
3283 }
3284
3285 $value = $wpdb->get_var(
3286 $wpdb->prepare(
3287 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
3288 $option_name
3289 )
3290 );
3291
3292 if ($value) {
3293 $unserialized = maybe_unserialize($value);
3294 return is_array($unserialized) ? $unserialized : false;
3295 }
3296
3297 return false;
3298 }
3299
3300 /**
3301 * Delete an SSO transient and its DB fallback rows.
3302 *
3303 * When external object cache is active, delete_transient() only removes
3304 * the cache entry. This also removes the wp_options rows written by the
3305 * DB fallback in create_searchatlas_nonce_token().
3306 *
3307 * @param string $transient_name Transient name (without _transient_ prefix)
3308 */
3309 private function delete_sso_transient($transient_name)
3310 {
3311 delete_transient($transient_name);
3312 if (wp_using_ext_object_cache()) {
3313 global $wpdb;
3314 $wpdb->delete($wpdb->options, array('option_name' => '_transient_' . $transient_name));
3315 $wpdb->delete($wpdb->options, array('option_name' => '_transient_timeout_' . $transient_name));
3316 }
3317 }
3318
3319 /**
3320 * Set an SSO transient and its DB fallback rows.
3321 *
3322 * When external object cache is active, set_transient() only writes to
3323 * cache. This also writes to wp_options so cross-process reads work.
3324 *
3325 * @param string $transient_name Transient name (without _transient_ prefix)
3326 * @param mixed $value Value to store
3327 * @param int $expiration Expiration in seconds
3328 */
3329 private function set_sso_transient($transient_name, $value, $expiration)
3330 {
3331 set_transient($transient_name, $value, $expiration);
3332 if (wp_using_ext_object_cache()) {
3333 global $wpdb;
3334 $wpdb->replace($wpdb->options, array(
3335 'option_name' => '_transient_' . $transient_name,
3336 'option_value' => maybe_serialize($value),
3337 'autoload' => 'no',
3338 ));
3339 $wpdb->replace($wpdb->options, array(
3340 'option_name' => '_transient_timeout_' . $transient_name,
3341 'option_value' => time() + $expiration,
3342 'autoload' => 'no',
3343 ));
3344 }
3345 }
3346
3347 /**
3348 * Validate Search Atlas connect token for callback
3349 * SECURITY FIX (CVE-2025-14386): Only validates against time-limited transient tokens
3350 * Tokens must be created by generate_searchatlas_connect_url() and stored in transients
3351 */
3352 private function validate_deterministic_searchatlas_token($token)
3353 {
3354 if (empty($token) || strlen($token) < 32) {
3355 return false;
3356 }
3357
3358 // SECURITY FIX: Token MUST exist in transients (created by generate_searchatlas_connect_url)
3359 // We do NOT fall back to apikey - this was the vulnerability!
3360 $transient_key = get_transient('metasync_sa_connect_active_' . $token);
3361
3362 // Fallback: read directly from DB when object cache misses
3363 if (empty($transient_key) && wp_using_ext_object_cache()) {
3364 $transient_key = $this->get_sso_token_from_db($token);
3365 }
3366
3367 if (empty($transient_key)) {
3368 return false;
3369 }
3370
3371 // Token found - validate metadata
3372 $token_metadata = get_transient($transient_key);
3373
3374 // Fallback: read metadata directly from DB
3375 if ((empty($token_metadata) || !is_array($token_metadata)) && wp_using_ext_object_cache()) {
3376 $token_metadata = $this->get_sso_metadata_from_db($transient_key);
3377 }
3378
3379 if (empty($token_metadata) || !is_array($token_metadata)) {
3380 $this->delete_sso_transient('metasync_sa_connect_active_' . $token);
3381 return false;
3382 }
3383
3384 // Check expiration
3385 if (isset($token_metadata['expires']) && time() > $token_metadata['expires']) {
3386 $this->delete_sso_transient($transient_key);
3387 $this->delete_sso_transient('metasync_sa_connect_active_' . $token);
3388 return false;
3389 }
3390
3391 // Check if already used for callback (single-use enforcement)
3392 if (isset($token_metadata['callback_used']) && $token_metadata['callback_used'] === true) {
3393 return false;
3394 }
3395
3396 // Mark token as used for callback (single-use)
3397 // Uses set_sso_transient to propagate callback_used to DB on object cache sites
3398 $token_metadata['callback_used'] = true;
3399 $token_metadata['callback_at'] = time();
3400 $this->set_sso_transient($transient_key, $token_metadata, 300);
3401
3402 // BUGFIX: Only delete the active token mapping if BOTH operations are complete
3403 // This allows user login and API callback to happen in any order without race conditions
3404 if (isset($token_metadata['used']) && $token_metadata['used'] === true) {
3405 // Both callback and login are done - safe to delete mapping
3406 $this->delete_sso_transient('metasync_sa_connect_active_' . $token);
3407 }
3408 // Otherwise, keep the mapping so user login can still find the token
3409
3410 return true;
3411 }
3412
3413 /**
3414 * Check if token is an enhanced SALT-based token
3415 */
3416 private function is_enhanced_token($token)
3417 {
3418 // Enhanced tokens are exactly 64 characters (SHA256 hash)
3419 // and include SALT-based entropy
3420 if (strlen($token) !== 64 || !ctype_xdigit($token)) {
3421 return false;
3422 }
3423
3424 // Check if token data indicates enhanced version
3425 $nonce_data = get_option('metasync_sa_connect_nonce_' . $token);
3426 if ($nonce_data) {
3427 $data = json_decode($nonce_data, true);
3428 return isset($data['enhanced']) && $data['enhanced'] === true;
3429 }
3430
3431 return false;
3432 }
3433
3434 /**
3435 * Validate enhanced SALT-based Search Atlas connect token
3436 */
3437 private function validate_enhanced_searchatlas_token($token)
3438 {
3439 $nonce_data = get_option('metasync_sa_connect_nonce_' . $token);
3440
3441 if (!$nonce_data) {
3442 return false;
3443 }
3444
3445 $nonce_data = json_decode($nonce_data, true);
3446
3447 if (!is_array($nonce_data)) {
3448 return false;
3449 }
3450
3451 // Check if token has expired
3452 if (isset($nonce_data['expires']) && $nonce_data['expires'] < time()) {
3453 delete_option('metasync_sa_connect_nonce_' . $token);
3454 return false;
3455 }
3456
3457 // Check if token has already been used
3458 if (isset($nonce_data['used']) && $nonce_data['used']) {
3459 return false;
3460 }
3461
3462 // Additional validation for enhanced tokens
3463 if (isset($nonce_data['enhanced']) && $nonce_data['enhanced']) {
3464 // Perform additional security checks for enhanced tokens
3465 if (!$this->validate_enhanced_token_security($token, $nonce_data)) {
3466 return false;
3467 }
3468 }
3469
3470 return $nonce_data;
3471 }
3472
3473 /**
3474 * Validate legacy Search Atlas connect token (backward compatibility)
3475 */
3476 private function validate_legacy_searchatlas_token($token)
3477 {
3478 $nonce_data = get_option('metasync_sa_connect_nonce_' . $token);
3479
3480 if (!$nonce_data) {
3481 return false;
3482 }
3483
3484 $nonce_data = json_decode($nonce_data, true);
3485
3486 // Check if token has expired
3487 if (isset($nonce_data['expires']) && $nonce_data['expires'] < time()) {
3488 delete_option('metasync_sa_connect_nonce_' . $token);
3489 return false;
3490 }
3491
3492 // Check if token has already been used
3493 if (isset($nonce_data['used']) && $nonce_data['used']) {
3494 return false;
3495 }
3496
3497 return $nonce_data;
3498 }
3499
3500 /**
3501 * Additional security validation for enhanced tokens
3502 */
3503 private function validate_enhanced_token_security($token, $nonce_data)
3504 {
3505 // Rate limiting check (optional)
3506 if ($this->is_token_rate_limited($token)) {
3507 return false;
3508 }
3509
3510 // Time-based validation (ensure token is not too old for creation time)
3511 if (isset($nonce_data['created'])) {
3512 $creation_time = $nonce_data['created'];
3513 $current_time = time();
3514
3515 // Token shouldn't be older than 35 minutes (5 min buffer)
3516 if (($current_time - $creation_time) > 2100) {
3517 return false;
3518 }
3519 }
3520
3521 return true;
3522 }
3523
3524 /**
3525 * Simple rate limiting for token validation attempts
3526 */
3527 private function is_token_rate_limited($token)
3528 {
3529 $rate_limit_key = 'sa_connect_rate_limit_' . substr($token, 0, 8);
3530 $attempts = get_transient($rate_limit_key);
3531
3532 if ($attempts === false) {
3533 set_transient($rate_limit_key, 1, 300); // 5 minutes
3534 return false;
3535 }
3536
3537 if ($attempts >= 10) { // Max 10 attempts per 5 minutes
3538 return true;
3539 }
3540
3541 set_transient($rate_limit_key, $attempts + 1, 300);
3542 return false;
3543 }
3544
3545
3546 /**
3547 * Handle Search Atlas Connect Callback (REST API)
3548 *
3549 * Called by the Search Atlas platform after admin authenticates on their dashboard.
3550 * Receives the Search Atlas API key and Otto UUID and stores them in WordPress options.
3551 * Does NOT create a WordPress login session.
3552 * Processes the callback from Search Atlas platform with new API key
3553 */
3554 public function handle_searchatlas_api_callback($request)
3555 {
3556 try {
3557 // Step 1: Validate nonce token from header
3558 $nonce_token = $request->get_header('x-api-key');
3559 $nonce_validation = $this->validate_searchatlas_nonce_format($nonce_token);
3560
3561 if (is_wp_error($nonce_validation)) {
3562 return $nonce_validation;
3563 }
3564
3565 // Step 2: Validate request body structure
3566 $body_params = $request->get_json_params();
3567 $body_validation = $this->validate_searchatlas_request_body($body_params);
3568
3569 if (is_wp_error($body_validation)) {
3570 return $body_validation;
3571 }
3572
3573 // Step 3: Extract and validate individual parameters
3574 $validated_params = $this->extract_and_validate_searchatlas_params($body_params);
3575
3576 if (is_wp_error($validated_params)) {
3577 return $validated_params;
3578 }
3579
3580 // Step 4: Validate nonce token by regenerating it
3581 if (!$this->validate_deterministic_searchatlas_token($nonce_token)) {
3582 return new WP_Error(
3583 'invalid_nonce',
3584 'Invalid nonce token',
3585 array('status' => 401)
3586 );
3587 }
3588
3589 // Step 5: Process the callback and update settings
3590 $success = $this->mark_searchatlas_nonce_used(
3591 $nonce_token,
3592 $validated_params['api_key'],
3593 $validated_params['uuid'],
3594 $validated_params['status_code'],
3595 $validated_params['is_whitelabel'],
3596 $validated_params['whitelabel_domain'],
3597 $validated_params['whitelabel_logo'],
3598 $validated_params['whitelabel_company_name'],
3599 $validated_params['whitelabel_otto']
3600 );
3601
3602 if (!$success) {
3603 return new WP_Error(
3604 'update_failed',
3605 'Failed to update plugin settings',
3606 array('status' => 500)
3607 );
3608 }
3609
3610 // Step 6: Return success response
3611 return rest_ensure_response(array(
3612 'success' => true,
3613 'message' => 'Search Atlas connect callback processed successfully',
3614 'data' => array(
3615 'status_code' => $validated_params['status_code'],
3616 'api_key_updated' => $validated_params['status_code'] === 200,
3617 'whitelabel_enabled' => $validated_params['is_whitelabel'],
3618 'effective_domain' => Metasync::get_dashboard_domain()
3619 )
3620 ));
3621
3622 } catch (Exception $e) {
3623 return new WP_Error(
3624 'internal_error',
3625 'Internal server error occurred while processing Search Atlas connect callback',
3626 array('status' => 500)
3627 );
3628 }
3629 }
3630
3631 /**
3632 * Validate Search Atlas connect nonce token format
3633 *
3634 * @param string $nonce_token The nonce token to validate
3635 * @return true|WP_Error True if valid, WP_Error if invalid
3636 */
3637 private function validate_searchatlas_nonce_format($nonce_token)
3638 {
3639 // Check if nonce token is provided
3640 if (empty($nonce_token)) {
3641 return new WP_Error(
3642 'missing_nonce_token',
3643 'Missing x-api-key header with nonce token',
3644 array('status' => 401, 'field' => 'x-api-key')
3645 );
3646 }
3647
3648 // Check nonce token format (should be Plugin Auth Token - at least 8 characters)
3649 if (strlen($nonce_token) < 8) {
3650 return new WP_Error(
3651 'invalid_nonce_format',
3652 'Invalid nonce token format. Token too short',
3653 array('status' => 400, 'field' => 'x-api-key')
3654 );
3655 }
3656
3657 return true;
3658 }
3659
3660 /**
3661 * Validate Search Atlas connect request body structure
3662 *
3663 * @param mixed $body_params Request body parameters
3664 * @return true|WP_Error True if valid, WP_Error if invalid
3665 */
3666 private function validate_searchatlas_request_body($body_params)
3667 {
3668 // Check if body exists and is valid JSON
3669 if (empty($body_params)) {
3670 return new WP_Error(
3671 'empty_request_body',
3672 'Request body is empty or invalid JSON',
3673 array('status' => 400)
3674 );
3675 }
3676
3677 // Check if body is an array (parsed JSON object)
3678 if (!is_array($body_params)) {
3679 return new WP_Error(
3680 'invalid_request_body',
3681 'Request body must be a valid JSON object',
3682 array('status' => 400)
3683 );
3684 }
3685
3686 return true;
3687 }
3688
3689 /**
3690 * Extract and validate individual Search Atlas connect parameters
3691 *
3692 * @param array $body_params Request body parameters
3693 * @return array|WP_Error Validated parameters array or WP_Error
3694 */
3695 private function extract_and_validate_searchatlas_params($body_params)
3696 {
3697 $validation_errors = array();
3698
3699 // Extract parameters
3700 $api_key = isset($body_params['api_key']) ? trim($body_params['api_key']) : '';
3701 $uuid = isset($body_params['uuid']) ? trim($body_params['uuid']) : '';
3702 $status_code = isset($body_params['status_code']) ? $body_params['status_code'] : 200;
3703
3704 // Validate api_key
3705 if (empty($api_key)) {
3706 $validation_errors['api_key'] = 'API key is required';
3707 } elseif (!is_string($api_key)) {
3708 $validation_errors['api_key'] = 'API key must be a string';
3709 } elseif (strlen($api_key) < 10) {
3710 $validation_errors['api_key'] = 'API key must be at least 10 characters long';
3711 } elseif (strlen($api_key) > 255) {
3712 $validation_errors['api_key'] = 'API key must not exceed 255 characters';
3713 } elseif (!preg_match('/^[a-zA-Z0-9\-_\.]+$/', $api_key)) {
3714 $validation_errors['api_key'] = 'API key contains invalid characters. Only alphanumeric, dash, underscore, and dot allowed';
3715 }
3716
3717 // Validate uuid
3718 if (empty($uuid)) {
3719 $validation_errors['uuid'] = 'UUID is required';
3720 } elseif (!is_string($uuid)) {
3721 $validation_errors['uuid'] = 'UUID must be a string';
3722 } elseif (strlen($uuid) > 100) {
3723 $validation_errors['uuid'] = 'UUID must not exceed 100 characters';
3724 }
3725
3726 // Validate status_code
3727 if (!is_numeric($status_code)) {
3728 $validation_errors['status_code'] = 'Status code must be a number';
3729 } else {
3730 $status_code = intval($status_code);
3731 if ($status_code < 100 || $status_code >= 600) {
3732 $validation_errors['status_code'] = 'Status code must be between 100 and 599';
3733 }
3734 }
3735
3736 // Extract and validate whitelabel fields
3737 $is_whitelabel = isset($body_params['is_whitelabel']) ? $body_params['is_whitelabel'] : false;
3738
3739 // Validate is_whitelabel
3740 if (isset($body_params['is_whitelabel']) && !is_bool($body_params['is_whitelabel'])) {
3741 // Handle string representations of boolean
3742 if (is_string($body_params['is_whitelabel'])) {
3743 $whitelabel_string = strtolower($body_params['is_whitelabel']);
3744 if (in_array($whitelabel_string, ['true', '1', 'yes', 'on'])) {
3745 $is_whitelabel = true;
3746 } elseif (in_array($whitelabel_string, ['false', '0', 'no', 'off', ''])) {
3747 $is_whitelabel = false;
3748 } else {
3749 $validation_errors['is_whitelabel'] = 'is_whitelabel must be a boolean value (true/false)';
3750 }
3751 } else {
3752 $validation_errors['is_whitelabel'] = 'is_whitelabel must be a boolean value';
3753 }
3754 }
3755
3756 // BUSINESS RULE: If is_whitelabel is false/null, disregard all other whitelabel fields
3757 if (!$is_whitelabel) {
3758 // Force all whitelabel fields to empty when is_whitelabel is false
3759 $whitelabel_domain = '';
3760 $whitelabel_logo = '';
3761 $whitelabel_company_name = '';
3762 $whitelabel_otto = '';
3763 } else {
3764 // Only extract and validate whitelabel fields when is_whitelabel is true
3765 $whitelabel_domain = isset($body_params['whitelabel_domain']) ? trim($body_params['whitelabel_domain']) : '';
3766 $whitelabel_logo = isset($body_params['whitelabel_logo']) ? trim($body_params['whitelabel_logo']) : '';
3767 $whitelabel_company_name = isset($body_params['whitelabel_company_name']) ? trim($body_params['whitelabel_company_name']) : '';
3768 $whitelabel_otto = isset($body_params['whitelabel_otto']) ? trim($body_params['whitelabel_otto']) : '';
3769
3770 // Validate whitelabel_domain (optional but must be valid URL if provided)
3771 if (!empty($whitelabel_domain)) {
3772 if (!is_string($whitelabel_domain)) {
3773 $validation_errors['whitelabel_domain'] = 'Whitelabel domain must be a string';
3774 } elseif (strlen($whitelabel_domain) > 255) {
3775 $validation_errors['whitelabel_domain'] = 'Whitelabel domain must not exceed 255 characters';
3776 } elseif (!filter_var($whitelabel_domain, FILTER_VALIDATE_URL)) {
3777 $validation_errors['whitelabel_domain'] = 'Whitelabel domain must be a valid URL';
3778 } elseif (!in_array(parse_url($whitelabel_domain, PHP_URL_SCHEME), ['http', 'https'])) {
3779 $validation_errors['whitelabel_domain'] = 'Whitelabel domain must use http or https protocol';
3780 }
3781 }
3782
3783 // Validate whitelabel_logo (permissive validation - invalid URLs won't fail POST)
3784 if (!empty($whitelabel_logo)) {
3785 // Basic validation - only fail POST for serious issues
3786 if (!is_string($whitelabel_logo)) {
3787 $validation_errors['whitelabel_logo'] = 'Whitelabel logo must be a string';
3788 } elseif (strlen($whitelabel_logo) > 1000) {
3789 $validation_errors['whitelabel_logo'] = 'Whitelabel logo URL must not exceed 500 characters';
3790 } else {
3791 // If URL is invalid, we'll clear it later but not fail the POST
3792 if (!filter_var($whitelabel_logo, FILTER_VALIDATE_URL) ||
3793 !in_array(parse_url($whitelabel_logo, PHP_URL_SCHEME), ['http', 'https'])) {
3794 // Don't add to validation_errors - let POST succeed but clear the field
3795 }
3796 }
3797 }
3798
3799 // Validate whitelabel_company_name (maps to Plugin Name)
3800 if (!empty($whitelabel_company_name)) {
3801 if (!is_string($whitelabel_company_name)) {
3802 $validation_errors['whitelabel_company_name'] = 'Whitelabel company name must be a string';
3803 } elseif (strlen($whitelabel_company_name) > 100) {
3804 $validation_errors['whitelabel_company_name'] = 'Whitelabel company name must not exceed 100 characters';
3805 } elseif (!preg_match('/^[a-zA-Z0-9\s\-\.\&\(\)\,\'\"]+$/', $whitelabel_company_name)) {
3806 $validation_errors['whitelabel_company_name'] = 'Whitelabel company name contains invalid characters. Only letters, numbers, spaces, and common punctuation allowed';
3807 }
3808 }
3809 }
3810
3811 // Return validation errors if any
3812 if (!empty($validation_errors)) {
3813 return new WP_Error(
3814 'validation_failed',
3815 'Request validation failed',
3816 array(
3817 'status' => 422,
3818 'validation_errors' => $validation_errors
3819 )
3820 );
3821 }
3822
3823 // Return sanitized and validated parameters
3824 return array(
3825 'api_key' => sanitize_text_field($api_key),
3826 'uuid' => sanitize_text_field($uuid),
3827 'status_code' => $status_code,
3828 'is_whitelabel' => $is_whitelabel,
3829 'whitelabel_domain' => !empty($whitelabel_domain) ? esc_url_raw($whitelabel_domain) : '',
3830 // Only store logo if it's a valid URL, otherwise store empty string
3831 'whitelabel_logo' => (!empty($whitelabel_logo) && filter_var($whitelabel_logo, FILTER_VALIDATE_URL)) ? esc_url_raw($whitelabel_logo) : '',
3832 'whitelabel_company_name' => !empty($whitelabel_company_name) ? sanitize_text_field($whitelabel_company_name) : '',
3833 'whitelabel_otto' => !empty($whitelabel_otto) ? sanitize_text_field($whitelabel_otto) : ''
3834 );
3835 }
3836
3837 /**
3838 * Create standardized error response for Search Atlas connect endpoints
3839 *
3840 * @param string $error_code Error code identifier
3841 * @param string $message Human-readable error message
3842 * @param int $status_code HTTP status code
3843 * @param array $additional_data Additional error context
3844 * @return WP_Error Formatted error response
3845 */
3846 private function create_sso_error_response($error_code, $message, $status_code = 400, $additional_data = array())
3847 {
3848 $error_data = array_merge(array(
3849 'status' => $status_code,
3850 'timestamp' => current_time('mysql', true),
3851 'endpoint' => 'searchatlas/connect/callback'
3852 ), $additional_data);
3853
3854 return new WP_Error($error_code, $message, $error_data);
3855 }
3856
3857 /**
3858 * Mark Search Atlas connect nonce as used and store the API key and Otto UUID
3859 * Enhanced with whitelabel support including logo and company name
3860 */
3861 public function mark_searchatlas_nonce_used($token, $new_api_key, $new_otto_uuid, $status_code = 200, $is_whitelabel = false, $whitelabel_domain = '', $whitelabel_logo = '', $whitelabel_company_name = '', $whitelabel_otto = '')
3862 {
3863 try {
3864 // DEBUG: Log that callback processing started
3865
3866 // Validate token parameter
3867 if (empty($token)) {
3868 return false;
3869 }
3870
3871 // No need to validate stored token data - token is deterministic
3872 // Simply proceed with updating plugin settings
3873
3874 // Update plugin settings
3875 $options = Metasync::get_option();
3876
3877 if (!is_array($options)) {
3878 $options = array();
3879 }
3880
3881 if (!isset($options['general'])) {
3882 $options['general'] = array();
3883 }
3884 // Only update the API key in settings if status_code is 200 (success)
3885 if ($status_code === 200) {
3886 // Encrypt the API key at rest; never store it in cleartext.
3887 $options['general']['searchatlas_api_key'] = Metasync::encrypt_api_key($new_api_key);
3888 $options['general']['otto_pixel_uuid'] = $new_otto_uuid;
3889 // Note: OTTO SSR is always enabled by default, no need to set
3890
3891 // Granular otto_config_status: record when SSO completed (ISO 8601 UTC)
3892 $options['general']['sso_completed_at'] = gmdate('Y-m-d\TH:i:s\Z');
3893
3894 // Update authentication timestamp for polling detection (legacy - keeping for compatibility)
3895 $options['general']['send_auth_token_timestamp'] = current_time('mysql');
3896
3897 // Set nonce-specific success flag for polling detection
3898 // This ensures only the specific nonce that was authenticated reports success
3899 // Uses set_sso_transient to also write to DB on object cache sites
3900 $success_key = 'metasync_sa_connect_success_' . md5($token);
3901 $this->set_sso_transient($success_key, true, 300);
3902
3903 // Clear JWT token cache when API key is updated to ensure fresh tokens
3904 $this->clear_jwt_token_cache();
3905
3906 } else {
3907 }
3908
3909 // Map whitelabel fields consistently (regardless of status_code)
3910 if ($is_whitelabel) {
3911 // whitelabel_company_name → Plugin Name (general plugin branding)
3912 if (!empty($whitelabel_company_name)) {
3913 $options['general']['white_label_plugin_name'] = $whitelabel_company_name;
3914 }
3915
3916 // whitelabel_otto → OTTO Features naming (separate from plugin name)
3917 if (!empty($whitelabel_otto)) {
3918 $options['general']['whitelabel_otto_name'] = $whitelabel_otto;
3919 }
3920 } else {
3921 // Clear whitelabel fields when not whitelabel
3922 unset($options['general']['white_label_plugin_name']);
3923 unset($options['general']['whitelabel_otto_name']);
3924 }
3925
3926 // Store whitelabel settings (hidden from UI but accessible to plugin logic)
3927 if (!isset($options['whitelabel'])) {
3928 $options['whitelabel'] = array();
3929 }
3930
3931 $options['whitelabel']['is_whitelabel'] = $is_whitelabel;
3932 $options['whitelabel']['domain'] = $whitelabel_domain;
3933 $options['whitelabel']['logo'] = $whitelabel_logo;
3934 $options['whitelabel']['updated_at'] = time();
3935
3936 // Log whitelabel configuration
3937 if ($is_whitelabel) {
3938 $log_parts = array('Whitelabel mode enabled');
3939 if (!empty($whitelabel_domain)) {
3940 $log_parts[] = 'domain: ' . $whitelabel_domain;
3941 }
3942 if (!empty($whitelabel_logo)) {
3943 $log_parts[] = 'logo: ' . $whitelabel_logo;
3944 }
3945 if (!empty($whitelabel_company_name)) {
3946 $log_parts[] = 'company: ' . $whitelabel_company_name;
3947 }
3948 if (!empty($whitelabel_otto)) {
3949 $log_parts[] = 'otto: ' . $whitelabel_otto;
3950 }
3951
3952 }
3953
3954 $save_result = Metasync::set_option($options);
3955 Metasync::invalidate_api_key_cache();
3956
3957 if (!$save_result) {
3958 error_log('MetaSync SA Connect: mark_searchatlas_nonce_used - Failed to save plugin options');
3959 } else {
3960 if ($status_code === 200) {
3961 // Option 1: Set heartbeat cache and last-known to CONNECTED so dashboard shows iframe immediately.
3962 // If the immediate heartbeat then fails (e.g. backoff), Option 2 prevents overwriting this to DISCONNECTED.
3963 $cache_data = array(
3964 'status' => true,
3965 'timestamp' => time(),
3966 'cached_until' => time() + 300,
3967 'updated_by' => 'callback_success_optimistic',
3968 );
3969 set_transient('metasync_heartbeat_status_cache', $cache_data, 300);
3970 update_option('metasync_last_known_connection_state', true);
3971 do_action('metasync_heartbeat_state_key_pending'); // PR3: burst mode
3972 $this->trigger_immediate_heartbeat_after_sa_connect();
3973 }
3974 }
3975
3976 return true;
3977
3978 } catch (Exception $e) {
3979 error_log('MetaSync SA Connect: mark_searchatlas_nonce_used Error - ' . $e->getMessage());
3980 return false;
3981 }
3982 }
3983
3984 /**
3985 * Clear cached JWT tokens
3986 * Useful when authentication is reset or API key changes
3987 */
3988 private function clear_jwt_token_cache()
3989 {
3990 global $wpdb;
3991
3992 // Clear all JWT token transients
3993 $deleted = $wpdb->query(
3994 $wpdb->prepare(
3995 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
3996 '_transient_metasync_jwt_token_%'
3997 )
3998 );
3999
4000 // Also clear timeout transients
4001 $wpdb->query(
4002 $wpdb->prepare(
4003 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
4004 '_transient_timeout_metasync_jwt_token_%'
4005 )
4006 );
4007
4008
4009 }
4010
4011 /**
4012 * Trigger immediate heartbeat check after successful Search Atlas connect authentication
4013 * This provides immediate feedback to the user about connection status
4014 */
4015 private function trigger_immediate_heartbeat_after_sa_connect()
4016 {
4017 try {
4018 // Use WordPress action system to trigger immediate heartbeat check
4019 // This is more reliable than trying to access admin class directly
4020 do_action('metasync_trigger_immediate_heartbeat', 'Search Atlas Connect - API key and UUID retrieved');
4021
4022 // Also ensure heartbeat cron is scheduled now that we have an API key
4023 do_action('metasync_ensure_heartbeat_cron_scheduled');
4024
4025 } catch (Exception $e) {
4026 error_log('MetaSync SA Connect: Error triggering immediate heartbeat check - ' . $e->getMessage());
4027 }
4028 }
4029
4030 public function get_item_schema()
4031 {
4032 if (isset($this->schema)) {
4033 // Since WordPress 5.3, the schema can be cached in the $schema property.
4034 return $this->schema;
4035 }
4036
4037 $this->schema = array(
4038 // This tells the spec of JSON Schema we are using which is draft 4.
4039 '$schema' => 'http://json-schema.org/draft-04/schema#',
4040 // The title property marks the identity of the resource.
4041 'title' => 'post',
4042 'type' => 'object',
4043 // In JSON Schema you can specify object properties in the properties attribute.
4044 'properties' => array(
4045 'id' => array(
4046 'description' => esc_html__('Unique identifier for the object.', 'my-textdomain'),
4047 'type' => 'integer',
4048 'context' => array('view', 'edit', 'embed'),
4049 'readonly' => true,
4050 ),
4051 'content' => array(
4052 'description' => esc_html__('The content for the object.', 'my-textdomain'),
4053 'type' => 'string',
4054 ),
4055 ),
4056 );
4057
4058 return $this->schema;
4059 }
4060
4061 // Callback function to retrieve pages tree
4062 public function get_pages_list($data) {
4063 $post_type = $data['post_type'];
4064
4065 // Fetch the top-level posts or pages
4066 $query = new WP_Query(array(
4067 'post_type' => $post_type,
4068 'post_status' => array('publish', 'draft'),
4069 'order' => 'ASC',
4070 'posts_per_page' => -1,
4071 ));
4072
4073 $posts_array = array();
4074
4075 // Build the array of posts
4076 while ($query->have_posts()) {
4077 $query->the_post();
4078 $posts_array[] = array(
4079 'id' => get_the_ID(),
4080 'title' => get_the_title(),
4081 'parent' => wp_get_post_parent_id(get_the_ID()),
4082 );
4083 }
4084
4085 // Reset post data
4086 wp_reset_postdata();
4087 return new WP_REST_Response($posts_array, 200);
4088 }
4089
4090 /*
4091 * Get post title and post feature image setting
4092 * Add a New key to return value on the basis of post type
4093 */
4094 public function append_content_if_missing_elements($post_type) {
4095
4096 # Run the MetaSyncHiddenPostManager folder
4097 # apply_filters('metasync_hidden_post_manager', '');
4098 # Get Latest Metasync Option
4099 $metasyncData = Metasync::get_option();
4100
4101 # Default value for post title setting
4102 $title_in_headings = true;
4103
4104 # Default value for post feature image setting
4105 $image_in_content = true;
4106
4107 # Check if the title setting is added in the setting
4108 if(isset($metasyncData['general']['title_in_headings'])){
4109
4110 # Change the default value from the setting
4111 $title_in_headings = $metasyncData['general']['title_in_headings'][$post_type];
4112
4113 }
4114
4115 # Check if the post feature setting is added in the setting
4116 if (isset($metasyncData['general']['image_in_content'])) {
4117
4118 # Change the default value from the setting
4119 $image_in_content = $metasyncData['general']['image_in_content'][$post_type];;
4120
4121 }
4122 # Return the array of setting
4123 return array(
4124 'title_in_headings'=>$title_in_headings,
4125 'image_in_content'=>$image_in_content
4126 );
4127
4128
4129 }
4130
4131 /**
4132 * Create key file endpoint for Bing Webmaster Tools. It's called by OTTO/UCMS.
4133 * Creates a .txt file in WordPress root with the provided key as filename and content
4134 *
4135 * @param WP_REST_Request $request The REST request object
4136 * @return WP_REST_Response|WP_Error Response object
4137 */
4138 public function create_key_file($request) {
4139 # Get the JSON data from the request
4140 $data = $request->get_json_params();
4141
4142 # Try alternative parameter methods
4143 $body_params = $request->get_body_params();
4144 $key_param = $request->get_param('key');
4145 $post_key = $_POST['key'] ?? null;
4146 $request_key = $_REQUEST['key'] ?? null;
4147 $get_key = $_GET['key'] ?? null;
4148
4149 # Try to get key from multiple sources
4150 $key_value = null;
4151
4152 # Try JSON first
4153 if (!empty($data['key'])) {
4154 $key_value = $data['key'];
4155 }
4156 # Try body params (form data)
4157 elseif (!empty($body_params['key'])) {
4158 $key_value = $body_params['key'];
4159 }
4160 # Try direct parameter
4161 elseif (!empty($key_param)) {
4162 $key_value = $key_param;
4163 }
4164 # Try $_POST (for multipart/form-data)
4165 elseif (!empty($post_key)) {
4166 $key_value = $post_key;
4167 }
4168 # Try $_REQUEST (fallback)
4169 elseif (!empty($request_key)) {
4170 $key_value = $request_key;
4171 }
4172 # Try $_GET (query parameters)
4173 elseif (!empty($get_key)) {
4174 $key_value = $get_key;
4175 }
4176
4177 # Validate that key is provided
4178 if (empty($key_value)) {
4179 return new WP_REST_Response(array(
4180 'error' => 'Key parameter is required',
4181 'code' => 'missing_key'
4182 ), 400);
4183 }
4184
4185 # Use the found key value
4186 $data['key'] = $key_value;
4187
4188 # Sanitize the key to ensure it's safe for filename
4189 $key = basename(sanitize_file_name($data['key']));
4190
4191 # Validate key is not empty after sanitization and contains no path separators
4192 if (empty($key) || preg_match('/[\/\\\\]/', $key) || strpos($key, '..') !== false) {
4193 return new WP_REST_Response(array(
4194 'error' => 'Invalid key provided',
4195 'code' => 'invalid_key'
4196 ), 400);
4197 }
4198
4199 # Get WordPress root directory
4200 $wp_root = ABSPATH;
4201
4202 # Sanitize the key for use as a filename and validate path stays within ABSPATH
4203 $safe_key = sanitize_file_name( $key );
4204 $file_path = $wp_root . $safe_key . '.txt';
4205 $real_root = realpath( $wp_root );
4206 if ( false === $real_root || 0 !== strpos( realpath( dirname( $file_path ) ), $real_root ) ) {
4207 return new WP_REST_Response(array(
4208 'error' => 'Invalid file path',
4209 'code' => 'invalid_path'
4210 ), 400);
4211 }
4212
4213 # Register the key for virtual serving via template_redirect as a
4214 # FALLBACK for read-only web roots and hosts that 403 direct static .txt.
4215 if (!class_exists('Metasync_Bing_Instant_Index')) {
4216 $bing_file = plugin_dir_path(dirname(__FILE__)) . 'bing-index/class-metasync-bing-instant-index.php';
4217 if (file_exists($bing_file)) {
4218 require_once $bing_file;
4219 }
4220 }
4221 if (class_exists('Metasync_Bing_Instant_Index')) {
4222 Metasync_Bing_Instant_Index::register_virtual_key($safe_key);
4223 }
4224
4225 # Decide how {key}.txt is served, then confirm it end-to-end.
4226 #
4227 # Two host families must both work:
4228 # - Static-serving hosts (e.g. cPanel Apache-behind-nginx): a missing .txt
4229 # is 404'd by the web server before WordPress runs, so ONLY a physical
4230 # file at the root is reachable.
4231 # - nginx hosts that 403 direct static .txt: a physical file is blocked AND
4232 # shadows the PHP route, so the key must be served virtually via
4233 # template_redirect (no physical file present).
4234 #
4235 # Strategy: write the physical file when the root is writable, then probe
4236 # it. Keep it only on a clean 200-with-matching-body; on 403/404/wrong body
4237 # delete it so it stops shadowing the virtual route and let WordPress serve
4238 # the key from PHP instead.
4239 $serving = 'virtual';
4240
4241 if (is_writable($wp_root) && @file_put_contents($file_path, $safe_key) !== false) {
4242 # Cache-buster avoids a stale 404 cached by a CDN/nginx for this path.
4243 $probe = wp_remote_get(
4244 home_url('/' . $safe_key . '.txt?_msverify=' . time()),
4245 array('timeout' => 10, 'sslverify' => false)
4246 );
4247 $code = is_wp_error($probe) ? 0 : (int) wp_remote_retrieve_response_code($probe);
4248 $body = is_wp_error($probe) ? '' : trim((string) wp_remote_retrieve_body($probe));
4249
4250 if ($code === 200 && $body === $safe_key) {
4251 $serving = 'physical';
4252 } else {
4253 # 403 / 404 / wrong body — a present file shadows the virtual route,
4254 # so remove it and let WordPress serve the key instead.
4255 @unlink($file_path);
4256 $serving = 'virtual';
4257 }
4258 }
4259
4260 # Persist the winning strategy per key so activation stays deterministic
4261 # and we can tell when a rotated key needs re-probing.
4262 update_option('metasync_indexnow_serving', array(
4263 'key' => $safe_key,
4264 'strategy' => $serving,
4265 'ts' => time(),
4266 ), false);
4267
4268
4269 # Return success response
4270 return new WP_REST_Response(array(
4271 'success' => true,
4272 'message' => 'Key file created successfully',
4273 'file_path' => $file_path,
4274 'file_url' => home_url('/' . $safe_key . '.txt'),
4275 'key' => $key,
4276 'serving' => $serving, // 'virtual' = served by WordPress, 'physical' = on-disk file remains
4277 ), 200);
4278 }
4279
4280 }
4281