PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.10
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.10
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 2.5.23 All 138 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.10, at public/class-metasync-rest-api.php

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