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

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