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

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