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

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