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

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