PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.13
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.13
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.13, at public/class-metasync-rest-api.php

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