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

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