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

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

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