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
← All changes | public/class-metasync-rest-api.php +736 -350 2.6.14 → 2.7.2 View file →
@@ -46,10 +46,8 @@
46 46 private $version;
47 47
48 48 private const namespace = "metasync/v1";
49 49
50 - private $escapers;
51 - private $replacements;
52 50 private $common;
53 51 private $allowed_attributes;
54 52 private $schema;
55 53 private $metasync_option_data;
@@ -74,21 +72,13 @@
74 72 'post_name',
75 73 'post_parent',
76 74 'post_status',
77 75 );
78 - $this->escapers = array("\\", "/", "\"");
79 - $this->replacements = array("", "", "");
80 76 $this->common = new Metasync_Common();
81 77 // get all options
82 78 $this->metasync_option_data = Metasync::get_option('general');
83 - $this->init_ajax_hooks();
84 79 }
85 80
86 - public function init_ajax_hooks()
87 - {
88 - add_action('wp_ajax_metasync_otto_ajax_action', array($this,'metasyn_otto_ajax'));
89 - }
90 -
91 81 /**
92 82 * Encode non-ASCII characters in a UTF-8 string as numeric HTML entities.
93 83 *
94 84 * Uses mb_encode_numericentity when the mbstring extension is loaded;
@@ -174,86 +164,9 @@
174 164 }
175 165 return $posts;
176 166 }
177 167
178 - public function metasyn_otto_ajax() {
179 - // Check nonce for security
180 - if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'otto_nonce')) {
181 - wp_send_json_error('Invalid nonce');
182 - wp_die();
183 - }
184 - $post_id = sanitize_text_field($_POST['post_id']);
185 - $current_url = get_permalink($post_id);
186 -
187 - $header_html = get_post_meta($post_id, '_otto_header_html_json', true);
188 168
189 - // Example API call using wp_remote_get()
190 -
191 -
192 - if (is_wp_error($header_html)) {
193 - wp_send_json_error('API call failed');
194 - } else {
195 - wp_send_json_success(json_decode($header_html, true));
196 - }
197 -
198 - wp_die(); // Always terminate after an AJAX call
199 - }
200 -
201 - public function otto_header_data() {
202 - global $post;
203 -
204 - // Get the current post ID
205 - $post_id = $post->ID;
206 -
207 - // Get the current time and the last update time from post meta
208 - $current_time = current_time('timestamp');
209 - $last_update_time = get_post_meta($post_id, '_otto_last_update_time', true);
210 -
211 - // Set the interval for 24 hours (in seconds)
212 - $interval = 24 * 60 * 60;
213 -
214 - // Check if the last update time is set or if 24 hours have passed
215 - if (!$last_update_time || ($current_time - $last_update_time) >= $interval) {
216 - // Get the current URL
217 - $current_url = get_permalink($post_id);
218 -
219 - // Use endpoint manager to get the correct API URL
220 - $api_endpoint = class_exists('Metasync_Endpoint_Manager')
221 - ? Metasync_Endpoint_Manager::get_endpoint('OTTO_URL_DETAILS')
222 - : 'https://sa.searchatlas.com/api/v2/otto-url-details';
223 -
224 - // Call the API
225 - $response = wp_remote_get($api_endpoint . '/?url=' . urlencode($current_url));
226 -
227 - // Check if the API call was successful
228 - if (!is_wp_error($response)) {
229 - $body = wp_remote_retrieve_body($response);
230 - $data = json_decode($body, true); // Decode JSON into an associative array
231 -
232 - // Update the post meta with the new data and timestamp
233 - update_post_meta($post_id, '_otto_header_html', $data['header_html_insertion']);
234 - update_post_meta($post_id, '_otto_last_update_time', $current_time);
235 - }
236 - }
237 -
238 - // Get the saved HTML from post meta
239 - $header_html = get_post_meta($post_id, '_otto_header_html', true);
240 -
241 - // Display the HTML with security measures
242 - if ($header_html) {
243 - echo "<!-- Otto Start -->";
244 - // SECURITY FIX: Sanitize HTML to prevent XSS while allowing safe HTML
245 - echo wp_kses($header_html, array(
246 - 'style' => array(),
247 - 'link' => array('rel' => array(), 'href' => array(), 'type' => array()),
248 - 'meta' => array('name' => array(), 'content' => array(), 'property' => array()),
249 - 'script' => array('type' => array(), 'src' => array()),
250 - // Add other safe tags as needed
251 - ));
252 - echo "<!-- Otto End -->";
253 - }
254 - }
255 -
256 169 public function rest_authorization_middleware($request = null)
257 170 {
258 171 $api_key = '';
259 172
@@ -269,11 +182,11 @@
269 182 if (empty($api_key) && isset($_SERVER['HTTP_X_API_KEY'])) {
270 183 $api_key = sanitize_text_field(wp_unslash($_SERVER['HTTP_X_API_KEY']));
271 184 }
272 185
273 - // Fallback: ?apikey= query param (deprecated)
274 - if (empty($api_key) && isset($_GET['apikey'])) {
275 - $api_key = sanitize_text_field($_GET['apikey']);
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
276 189 }
277 190
278 191 if (empty($api_key)) {
279 192 return false;
@@ -278,16 +191,219 @@
278 191 if (empty($api_key)) {
279 192 return false;
280 193 }
281 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 + {
282 214 $getOptions = Metasync::get_option('general');
283 - $stored_key = $getOptions['apikey'] ?? null;
284 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();
285 401 if (empty($stored_key)) {
286 402 return false;
287 403 }
288 404
289 - return hash_equals($stored_key, $api_key);
405 + return hash_equals((string) $stored_key, $api_key);
290 406 }
291 407
292 408
293 409 public function metasync_register_rest_routes()
@@ -429,9 +545,9 @@
429 545 array(
430 546 array(
431 547 'methods' => 'GET',
432 548 'callback' => function () {
433 - $getPostID = url_to_postid(sanitize_url($_GET['url']));
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
434 550
435 551 if ($getPostID==0) {
436 552 $response = false;
437 553 }else{
@@ -729,14 +845,14 @@
729 845 }
730 846
731 847 # Check if post exists
732 848 if (!$post || $post_id <= 0) {
733 - return rest_ensure_response(array('error' => 'no blog post found'), 404);
849 + return new WP_REST_Response(array('error' => 'no blog post found'), 404);
734 850 }
735 851
736 852 # Check if post type is 'post', return error if not
737 853 if ($post->post_type !== 'post') {
738 - return rest_ensure_response(array('error' => 'only post type is supported'), 400);
854 + return new WP_REST_Response(array('error' => 'only post type is supported'), 400);
739 855 }
740 856
741 857 # Render content
742 858 $post_content = $post->post_content;
@@ -960,9 +1076,9 @@
960 1076 }
961 1077
962 1078 public function get_errorlogs()
963 1079 {
964 - $get_data = metasync_sanitize_input_array($_GET);
1080 + $get_data = metasync_sanitize_input_array($_GET); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- REST route authenticated by API key, not cookies
965 1081 if (!isset($get_data['limit']))
966 1082 return false;
967 1083 $limit = sanitize_text_field($get_data['limit']) ?? null;
968 1084
@@ -1150,9 +1266,9 @@
1150 1266 }
1151 1267 } catch (Throwable $e) {
1152 1268 // Fallback to DOMDocument if Dom\HTMLDocument fails
1153 1269 if (defined('WP_DEBUG_LOG') && WP_DEBUG_LOG) {
1154 - error_log('MetaSync: Dom\HTMLDocument error, falling back to DOMDocument: ' . $e->getMessage());
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
1155 1271 }
1156 1272 $dom = new DOMDocument();
1157 1273 libxml_use_internal_errors(true);
1158 1274 $encoded_content = $this->encode_numeric_entities($content);
@@ -1352,9 +1468,9 @@
1352 1468 $image_id = attachment_url_to_postid($node->getAttribute('src') );
1353 1469 $image_alt = get_post_meta($image_id, '_wp_attachment_image_alt', TRUE);
1354 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]';
1355 1471
1356 - error_log(json_encode($e));
1472 + error_log(json_encode($e)); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
1357 1473
1358 1474 }
1359 1475 }elseif($node->nodeName === 'iframe'){
1360 1476 $result= '[et_pb_code _builder_version="'.ET_BUILDER_VERSION.'" _module_preset="default" global_colors_info="{}"]'.$node->ownerDocument->saveHTML($node).'[/et_pb_code]' ;
@@ -1482,71 +1598,12 @@
1482 1598 return $attachment_id;
1483 1599 }
1484 1600
1485 1601 /**
1486 - * Index a post with Google Indexing API
1487 - *
1488 - * @param int $post_id WordPress post ID
1489 - * @param string $post_type WordPress post type (post, page, etc.)
1490 - * @param string $post_status WordPress post status (publish, draft, etc.)
1491 - */
1492 - public function metasync_google_index_post($post_id, $post_type, $post_status)
1493 - {
1494 - // Only index published posts/pages
1495 - if ($post_status !== 'publish') {
1496 - return;
1497 - }
1498 -
1499 - // Only index posts and pages (can be extended as needed)
1500 - $allowed_post_types = array('post', 'page');
1501 - if (!in_array($post_type, $allowed_post_types)) {
1502 - return;
1503 - }
1504 -
1505 - try {
1506 - // Load Google Index functionality if not already loaded
1507 - if (!function_exists('google_index_post')) {
1508 - $google_index_path = plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php';
1509 - if (file_exists($google_index_path)) {
1510 - require_once $google_index_path;
1511 - } else {
1512 - error_log('MetaSync Google Index: google-index-init.php not found at ' . $google_index_path);
1513 - return;
1514 - }
1515 - }
1516 -
1517 - // Attempt to index the post with Google
1518 - if (function_exists('google_index_post')) {
1519 - $result = google_index_post($post_id, $post_type, 'update');
1520 -
1521 - if (isset($result['success']) && $result['success']) {
1522 - error_log(sprintf(
1523 - 'MetaSync Google Index: Successfully indexed %s (ID: %d, Type: %s)',
1524 - get_the_title($post_id),
1525 - $post_id,
1526 - $post_type
1527 - ));
1528 - } else {
1529 - error_log(sprintf(
1530 - 'MetaSync Google Index: Failed to index %s (ID: %d, Type: %s) - %s',
1531 - get_the_title($post_id),
1532 - $post_id,
1533 - $post_type,
1534 - isset($result['error']['message']) ? $result['error']['message'] : 'Unknown error'
1535 - ));
1536 - }
1537 - }
1538 - } catch (Exception $e) {
1539 - // Log any exceptions but don't break the main functionality
1540 - error_log('MetaSync Google Index Exception: ' . $e->getMessage());
1541 - }
1542 - }
1543 -
1544 - /**
1545 - * WP-429: Remove a leading <h1> from post content when it duplicates the post title.
1602 + * Remove a leading <h1> from post content when it duplicates the post title.
1546 1603 *
1547 1604 * Themes that render post_title as an H1 produce a duplicate heading when the
1548 - * synced body also opens with the title. Counterpart of the WP-337 guard, which
1605 + * synced body also opens with the title. Counterpart of the guard, which
1549 1606 * only prevents prepending a second H1 but never removes one supplied in the body.
1550 1607 *
1551 1608 * @param string $content Post body HTML
1552 1609 * @param string $title Post title
@@ -1563,9 +1620,9 @@
1563 1620 return $content;
1564 1621 }
1565 1622
1566 1623 # Entity-decode both sides so "Mobility &amp; Recovery" matches "Mobility & Recovery"
1567 - $h1_text = trim(html_entity_decode(strip_tags($h1_match[1]), ENT_QUOTES | ENT_HTML5));
1624 + $h1_text = trim(html_entity_decode(wp_strip_all_tags($h1_match[1]), ENT_QUOTES | ENT_HTML5));
1568 1625 $title_text = trim(html_entity_decode((string) $title, ENT_QUOTES | ENT_HTML5));
1569 1626 if ($h1_text === '' || strcasecmp($h1_text, $title_text) !== 0) {
1570 1627 return $content;
1571 1628 }
@@ -1573,9 +1630,9 @@
1573 1630 return substr($trimmed, strlen($h1_match[0]));
1574 1631 }
1575 1632
1576 1633 /**
1577 - * WP-429: Decide whether the synced body should carry a prepended <h1> title.
1634 + * Decide whether the synced body should carry a prepended <h1> title.
1578 1635 *
1579 1636 * Replaces the unreliable 7-day hidden-post probe (title_in_headings) as the
1580 1637 * source of truth for the prepend decision. We only prepend when we have a
1581 1638 * POSITIVE, render-verified verdict that the active theme does NOT render the
@@ -1599,9 +1656,9 @@
1599 1656 return false;
1600 1657 }
1601 1658
1602 1659 /**
1603 - * WP-429: After a post is saved, render its live URL once and record whether
1660 + * After a post is saved, render its live URL once and record whether
1604 1661 * the active theme outputs the post title in a heading. The verdict is cached
1605 1662 * per post-type so subsequent syncs skip the round-trip. Best-effort: any
1606 1663 * inconclusive fetch (non-200, cache/challenge page, wrong post) records
1607 1664 * nothing and is retried on the next sync.
@@ -1641,9 +1698,9 @@
1641 1698 Metasync::set_option($options);
1642 1699 }
1643 1700
1644 1701 /**
1645 - * WP-429: Fetch the rendered post and detect whether the theme outputs the
1702 + * Fetch the rendered post and detect whether the theme outputs the
1646 1703 * title in a heading.
1647 1704 *
1648 1705 * @param string $permalink Public permalink
1649 1706 * @param int $post_id Saved post ID (used to confirm we fetched the right page)
@@ -1689,9 +1746,9 @@
1689 1746 return $this->title_present_in_headings($html, $post_title);
1690 1747 }
1691 1748
1692 1749 /**
1693 - * WP-429: Return true if $title appears within any heading (h1–h6) of $html.
1750 + * Return true if $title appears within any heading (h1–h6) of $html.
1694 1751 *
1695 1752 * @param string $html Rendered page HTML
1696 1753 * @param string $title Title text to match
1697 1754 * @return bool
@@ -1722,8 +1779,65 @@
1722 1779 }
1723 1780 return false;
1724 1781 }
1725 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 +
1726 1840 public function create_item($request)
1727 1841 {
1728 1842 // Checking for type of object for response type
1729 1843 $array_response = true;
@@ -1756,8 +1870,9 @@
1756 1870 check if the create_item is called by set_landing_page function or not
1757 1871 by doing this we will prevent html from going into builder page option
1758 1872 */
1759 1873 $isOttoAiPage = !empty($item['otto_ai_page']) && filter_var($item['otto_ai_page'], FILTER_VALIDATE_BOOLEAN);
1874 + $content = [];
1760 1875 if(!isset($item['is_landing_page']) && !$isOttoAiPage && empty($item['style_data']) ){
1761 1876
1762 1877 # Get Current Post type
1763 1878 $current_post_type = isset($item['post_type']) ? sanitize_text_field($item['post_type']) : 'post';
@@ -1775,9 +1890,9 @@
1775 1890 } elseif (!empty($theme_template) && stripos($theme_template, 'flatsome') !== false) {
1776 1891 $is_flatsome_theme = true;
1777 1892 }
1778 1893
1779 - # WP-429: Always strip a leading H1 that duplicates the title. The
1894 + # Always strip a leading H1 that duplicates the title. The
1780 1895 # theme renders post_title in its header on virtually every theme, so a
1781 1896 # title H1 at the top of the body is a duplicate. Whether we then put one
1782 1897 # back is decided by should_prepend_title() below.
1783 1898 if (isset($item['post_title'])) {
@@ -1793,9 +1908,9 @@
1793 1908 if(!$title_and_feature_image['image_in_content'] && !empty($item['hero_image_url'])){
1794 1909 $item['post_content'] = '<img src="' . esc_url_raw($item['hero_image_url']) . '" />'.$item['post_content'] ;
1795 1910 }
1796 1911
1797 - # WP-429: Only prepend the title H1 when the theme is render-verified to
1912 + # Only prepend the title H1 when the theme is render-verified to
1798 1913 # omit it. Unknown verdict defaults to NOT prepending (see should_prepend_title).
1799 1914 if(isset($item['post_title']) && $this->should_prepend_title($current_post_type)){
1800 1915 $item['post_content'] = '<h1>'.$item['post_title'].'</h1>'.$item['post_content'] ;
1801 1916 }
@@ -1833,13 +1948,13 @@
1833 1948 $new_post['post_name'] = sanitize_text_field($item['permalink']);
1834 1949 }
1835 1950
1836 1951 if (isset($item['post_date']) && !empty($item['post_date'])) {
1837 - $is_valid_date = date('Y-m-d', strtotime($item['post_date'])) === $item['post_date'];
1952 + $is_valid_date = gmdate('Y-m-d', strtotime($item['post_date'])) === $item['post_date'];
1838 1953 if (!$is_valid_date) {
1839 1954 return new WP_Error(
1840 1955 'rest_post_invalid_date',
1841 - esc_html__('Post date is not valid'),
1956 + esc_html__('Post date is not valid', 'metasync'),
1842 1957 array('status' => 400)
1843 1958 );
1844 1959 }
1845 1960
@@ -1872,20 +1987,16 @@
1872 1987 # $getPostID_byURL = @get_page_by_path($item['permalink'], OBJECT, $new_post['post_type'])->ID;
1873 1988 # Fix to avoid PHP error if the get_page_by_path returns null
1874 1989 $getPostID_byURL = @get_page_by_path($item['permalink'], OBJECT, $new_post['post_type']);
1875 1990 $getPostID_byURL = $getPostID_byURL ? $getPostID_byURL->ID : null;
1991 + $matched_by_title = false;
1876 1992 if ($getPostID_byURL == NULL) {
1877 1993 // check if the post_title is set and not empty when called by otto_ai_page
1878 1994 if(isset($new_post['post_title']) && $new_post['post_title']!==''){
1879 - $getPostID_byURL = new WP_Query(
1880 - array(
1881 - 'post_type' => $new_post['post_type'],
1882 - 'title' => $new_post['post_title']
1883 - )
1884 - );
1885 - $getPostID_byURL = $getPostID_byURL->posts[0]->ID ?? null;
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 + }
1886 1998 }
1887 - }
1888 1999
1889 2000 // Allow HTML code for landing page
1890 2001 if (isset($item['is_landing_page']) && $item['is_landing_page'] == true) {
1891 2002 kses_remove_filters();
@@ -1910,11 +2021,17 @@
1910 2021 update_post_meta($post_id, 'metasync_post', 'yes');
1911 2022 }
1912 2023
1913 2024 } else {
1914 - $new_post['ID'] = $post_id = $getPostID_byURL;
1915 - wp_update_post($new_post);
1916 - unset($new_post['ID']);
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);
1917 2034 $permalink = get_permalink($post_id);
1918 2035 }
1919 2036
1920 2037 if (isset($item['is_landing_page']) && $item['is_landing_page'] == true) {
@@ -1956,13 +2073,17 @@
1956 2073 $post_meta['searchatlas_embed_bottom'] = $item['searchatlas_embed_bottom'];
1957 2074 }
1958 2075
1959 2076 // Add custom fields to posts and pages
1960 - foreach ($post_meta as $key => $value) {
1961 - // if (!empty($value) && !is_null($value)) {
1962 - add_post_meta($post_id, $key, $value, true);
1963 - // }
1964 - }
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 + );
1965 2086
1966 2087
1967 2088 $attachment_id = '';
1968 2089 if (isset($item['hero_image_url']) && !empty($item['hero_image_url'])) {
@@ -1980,8 +2101,14 @@
1980 2101 $redirection['url'] = sanitize_url($item['redirection_url']);
1981 2102 }
1982 2103 if (!empty($redirection)) {
1983 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 + }
1984 2111 }
1985 2112
1986 2113 $post_cattegories = [];
1987 2114 # if ($new_post['post_type'] === 'post' && is_array(@$item['post_categories'])) {
@@ -2037,20 +2164,19 @@
2037 2164 'action' => strtolower($action)
2038 2165 ])
2039 2166 ]);
2040 2167
2041 - # Track Content Genius event in GA4
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.
2042 2170 try {
2043 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']));
2044 2173 } catch (Exception $e) {
2045 - error_log('MetaSync: Analytics tracking failed for Content Genius - ' . $e->getMessage());
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
2046 2175 }
2047 -
2048 - // Google Indexing Integration
2049 - $this->metasync_google_index_post($post_id, $new_post['post_type'], $new_post['post_status']);
2050 2176 }
2051 2177
2052 - # WP-429: For standard synced posts, learn (once per post-type) whether the
2178 + # For standard synced posts, learn (once per post-type) whether the
2053 2179 # theme renders the title, so future syncs prepend only when genuinely needed.
2054 2180 if (!is_wp_error($post_id) && $post_id > 0
2055 2181 && !isset($item['is_landing_page']) && empty($isOttoAiPage) && empty($item['style_data'])
2056 2182 && $new_post['post_status'] === 'publish') {
@@ -2056,9 +2182,9 @@
2056 2182 && $new_post['post_status'] === 'publish') {
2057 2183 try {
2058 2184 $this->record_theme_title_verdict($post_id, $permalink, $new_post['post_title'], $new_post['post_type']);
2059 2185 } catch (Exception $e) {
2060 - error_log('MetaSync WP-429 title verdict failed: ' . $e->getMessage());
2186 + error_log('MetaSync title verdict failed: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
2061 2187 }
2062 2188 }
2063 2189
2064 2190 $respCreatePosts[$index] = array_merge($new_post, $post_meta);
@@ -2103,9 +2229,9 @@
2103 2229 }
2104 2230
2105 2231 public function delete_item()
2106 2232 {
2107 - $get_data = metasync_sanitize_input_array($_GET);
2233 + $get_data = metasync_sanitize_input_array($_GET); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- REST route authenticated by API key, not cookies
2108 2234 if (!isset($get_data['ID']))
2109 2235 return false;
2110 2236
2111 2237 $post_id = sanitize_text_field($get_data['ID']) ?? null;
@@ -2111,18 +2237,18 @@
2111 2237 $post_id = sanitize_text_field($get_data['ID']) ?? null;
2112 2238 $post = get_post($post_id);
2113 2239 if ($post) {
2114 2240 wp_delete_post($post_id);
2241 + // HTTP 204 requires no body for response, so no message is set.
2115 2242 return new WP_Error(
2116 2243 'rest_post_delete_success',
2117 - esc_html__(''),
2118 - // HTTP 204 requires no body for response
2244 + '',
2119 2245 array('status' => 204)
2120 2246 );
2121 2247 }
2122 2248 return new WP_Error(
2123 2249 'rest_post_delete_fail',
2124 - esc_html__('No post found in the database with requested ID.'),
2250 + esc_html__('No post found in the database with requested ID.', 'metasync'),
2125 2251 array('status' => 400)
2126 2252 );
2127 2253 }
2128 2254
@@ -2127,9 +2253,9 @@
2127 2253 }
2128 2254
2129 2255 public function get_items($request)
2130 2256 {
2131 - $get_data = metasync_sanitize_input_array($_GET);
2257 + $get_data = metasync_sanitize_input_array($_GET); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- REST route authenticated by API key, not cookies
2132 2258
2133 2259 # let us check if request send post id and is valid int
2134 2260 if(isset($get_data['post_id']) AND intval($get_data['post_id']) > 0){
2135 2261
@@ -2469,9 +2595,9 @@
2469 2595 $post_data = get_post($post_id);
2470 2596 if (!$post_data) {
2471 2597 return new WP_Error(
2472 2598 'rest_post_update_fail',
2473 - esc_html__('No post found in the database with requested ID.'),
2599 + esc_html__('No post found in the database with requested ID.', 'metasync'),
2474 2600 array('status' => 400)
2475 2601 );
2476 2602 }
2477 2603
@@ -2496,12 +2622,19 @@
2496 2622 $update_params['meta_robots'] = $post['meta_robots'] ?
2497 2623 sanitize_text_field($post['meta_robots']) : $get_robots_meta;
2498 2624 }
2499 2625 if (isset($post['meta_canonical']) && !empty($post['meta_canonical'])) {
2500 - $update_params['meta_canonical'] = sanitize_text_field($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 + }
2501 2632 }
2502 2633
2503 2634 $isOttoAiPage = !empty($post['otto_ai_page']) && filter_var($post['otto_ai_page'], FILTER_VALIDATE_BOOLEAN);
2635 + $content = [];
2636 + $permalink = '';
2504 2637 if (isset($post['post_content']) && !empty($post['post_content']) && !$isOttoAiPage) {
2505 2638
2506 2639 # Above we are updating the post_type so we have to get latest value that has been change on the server
2507 2640 $post_fresh_data = get_post($post['post_id']);
@@ -2523,9 +2656,9 @@
2523 2656
2524 2657 # Title to compare against — payload may omit post_title on update
2525 2658 $compare_title = isset($post['post_title']) && !empty($post['post_title']) ? $post['post_title'] : $post_fresh_data->post_title;
2526 2659
2527 - # WP-429: Always strip a leading H1 that duplicates the title; the theme
2660 + # Always strip a leading H1 that duplicates the title; the theme
2528 2661 # renders post_title in its header, so a body title H1 is a duplicate.
2529 2662 $post['post_content'] = $this->strip_leading_title_h1($post['post_content'], $compare_title);
2530 2663
2531 2664 # Skip title/image prepending for Flatsome (it displays them by default)
@@ -2537,9 +2670,9 @@
2537 2670 if(!$title_and_feature_image['image_in_content'] && !empty($post['hero_image_url'])){
2538 2671 $post['post_content'] = '<img src="' . esc_url_raw($post['hero_image_url']) . '" />'.$post['post_content'] ;
2539 2672 }
2540 2673
2541 - # WP-429: Only prepend the title H1 when the theme is render-verified to
2674 + # Only prepend the title H1 when the theme is render-verified to
2542 2675 # omit it. Unknown verdict defaults to NOT prepending (see should_prepend_title).
2543 2676 if($this->should_prepend_title($post_fresh_data->post_type)){
2544 2677 $post['post_content'] = '<h1>'.$compare_title.'</h1>'.$post['post_content'] ;
2545 2678 }
@@ -2585,9 +2718,8 @@
2585 2718 }
2586 2719
2587 2720 if (isset($post['post_status']) && !empty($post['post_status'])) {
2588 2721 $update_params['post_status'] = $post['post_status'] ? sanitize_text_field($post['post_status']) : 'publish';
2589 - $permalink = get_permalink($post_id);
2590 2722 }
2591 2723 if (isset($post['permalink']) || !empty($post['permalink'])) {
2592 2724 $update_params['post_name'] = sanitize_text_field($post['permalink']);
2593 2725 }
@@ -2602,40 +2734,8 @@
2602 2734 );
2603 2735 }
2604 2736
2605 2737
2606 - if (isset($post['post_date']) && !empty($post['post_date']) && false) {
2607 - $is_valid_date = date('Y-m-d', strtotime($post['post_date'])) == $post['post_date'];
2608 - if (!$is_valid_date) {
2609 - return new WP_Error(
2610 - 'rest_post_invalid_date',
2611 - esc_html__('Post date is not valid'),
2612 - array('status' => 400)
2613 - );
2614 - }
2615 -
2616 - $date_limit_str = strtotime(date('Y-m-d') . '-2 month');
2617 - $post_date_str = strtotime($post['post_date']);
2618 -
2619 - if ($date_limit_str >= $post_date_str) {
2620 - $newDate = date('Y-m-d', strtotime('-2 month'));
2621 - return new WP_Error(
2622 - 'rest_post_greater_date',
2623 - esc_html__("Post date should be greater then " . $newDate),
2624 - array('status' => 400)
2625 - );
2626 - }
2627 -
2628 - if ($post_date_str > strtotime(date('Y-m-d'))) {
2629 - return new WP_Error(
2630 - 'rest_post_greater_date',
2631 - esc_html__('Post date should be less then Today'),
2632 - array('status' => 400)
2633 - );
2634 - }
2635 - $update_params['post_date'] = sanitize_text_field($post['post_date'] . date(' h:i:s'));
2636 - }
2637 -
2638 2738 $post_cattegories = [];
2639 2739 # if ($post_data && $post_data->post_type === 'post' && is_array(@$post['post_categories'])) {
2640 2740
2641 2741 # fixed Undefined array key issue
@@ -2658,12 +2758,13 @@
2658 2758 $attachment_id = $this->metasync_handle_hero_image($post_id, $post['hero_image_url'], $post['hero_image_alt_text']);
2659 2759 }
2660 2760
2661 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);
2662 2765 if(isset($content['elementor_meta_data'])){
2663 - foreach ($content['elementor_meta_data'] as $key => $value) {
2664 - update_post_meta($post_id, $key, $value);
2665 - }
2766 + $this->metasync_apply_builder_meta($post_id, $content['elementor_meta_data']);
2666 2767 if ( did_action( 'elementor/loaded' ) ) {
2667 2768 // Clear Elementor cache for the specified post ID
2668 2769 \Elementor\Plugin::instance()->files_manager->clear_cache();
2669 2770 }
@@ -2678,8 +2779,15 @@
2678 2779 delete_post_meta($post_id, '_elementor_page_assets');
2679 2780 delete_post_meta($post_id, '_elementor_page_settings');
2680 2781 }
2681 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 +
2682 2790 $redirection = array();
2683 2791 if (!empty($post['redirection_enable']) && !is_null($post['redirection_enable'])) {
2684 2792 $redirection['enable'] = sanitize_text_field($post['redirection_enable']);
2685 2793 }
@@ -2690,8 +2798,14 @@
2690 2798 $redirection['url'] = sanitize_url($post['redirection_url']);
2691 2799 }
2692 2800 if (!empty($redirection)) {
2693 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 + }
2694 2808 }
2695 2809
2696 2810
2697 2811 $post_revisions = wp_get_post_revisions($post_id);
@@ -2698,9 +2812,8 @@
2698 2812 // Sync post categories to customer dashboard
2699 2813 $this->lgSendCustomerPostParams();
2700 2814
2701 2815 unset($update_params['post_name']);
2702 - unset($update_params['post_category']);
2703 2816
2704 2817 $update_params['post_categories'] = $post_cattegories;
2705 2818 $update_params['post_tags'] = $post_tags;
2706 2819 $update_params['post_id'] = (int) $post_id;
@@ -2730,14 +2843,9 @@
2730 2843
2731 2844 }
2732 2845 # update the content
2733 2846 // add and update the post meta
2734 - foreach ($post_meta_data as $key => $value) {
2735 - // if (!empty($value) && !is_null($value)) {
2736 -
2737 - update_post_meta($post_id, $key, $value);
2738 - //
2739 - }
2847 + $this->metasync_apply_builder_meta($post_id, $post_meta_data);
2740 2848 #check if the elementor plugin is active
2741 2849 if ( did_action( 'elementor/loaded' ) ) {
2742 2850 # Clear Elementor cache for the specified post ID
2743 2851 \Elementor\Plugin::instance()->files_manager->clear_cache();
@@ -2770,16 +2878,17 @@
2770 2878 'updated_fields' => array_keys($update_params)
2771 2879 ])
2772 2880 ]);
2773 2881
2774 - # Track Content Genius event in GA4
2882 + # Track Content Genius event in GA4 and PostHog
2775 2883 try {
2776 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']));
2777 2886 } catch (Exception $e) {
2778 - error_log('MetaSync: Analytics tracking failed for Content Genius - ' . $e->getMessage());
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
2779 2888 }
2780 2889
2781 - # WP-429: Learn (once per post-type) whether the theme renders the title,
2890 + # Learn (once per post-type) whether the theme renders the title,
2782 2891 # so future syncs prepend only when the theme genuinely omits it.
2783 2892 if ($post_id > 0 && empty($post['is_landing_page']) && empty($isOttoAiPage)
2784 2893 && $sync_status === 'published') {
2785 2894 try {
@@ -2784,9 +2893,9 @@
2784 2893 && $sync_status === 'published') {
2785 2894 try {
2786 2895 $this->record_theme_title_verdict($post_id, ($permalink ?? get_permalink($post_id)), $post_title, $post_type);
2787 2896 } catch (Exception $e) {
2788 - error_log('MetaSync WP-429 title verdict failed: ' . $e->getMessage());
2897 + error_log('MetaSync title verdict failed: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
2789 2898 }
2790 2899 }
2791 2900 }
2792 2901
@@ -2883,20 +2992,9 @@
2883 2992 if($isOttoAiPage){
2884 2993 // Change the page template from default to Metasync Template
2885 2994 $pageTemplate = Metasync_Template::TEMPLATE_NAME;
2886 2995 }
2887 - if ($pageTemplate !== 'default') {
2888 - update_post_meta($post_id, '_wp_page_template', $pageTemplate);
2889 - } else {
2890 - // Clear stale templates that conflict with the active page builder.
2891 - // e.g. metasync-blank from a previous OTTO sync, or elementor_canvas
2892 - // written by the HTML-to-builder converter on an Oxygen site.
2893 - $current = get_post_meta($post_id, '_wp_page_template', true);
2894 - $stale_templates = array(Metasync_Template::TEMPLATE_NAME, 'elementor_canvas', 'elementor_header_footer');
2895 - if (in_array($current, $stale_templates, true)) {
2896 - delete_post_meta($post_id, '_wp_page_template');
2897 - }
2898 - }
2996 + $this->metasync_write_page_template($post_id, $pageTemplate);
2899 2997 }
2900 2998
2901 2999 return rest_ensure_response($createPages);
2902 3000 }
@@ -2919,9 +3017,9 @@
2919 3017 $post_data = get_post($payload['post_id']);
2920 3018 if(!isset($post_data->post_type)){
2921 3019 return new WP_Error(
2922 3020 'rest_page_type_fail',
2923 - esc_html__('No page found in the database with requested ID.'),
3021 + esc_html__('No page found in the database with requested ID.', 'metasync'),
2924 3022 array('status' => 400)
2925 3023 );
2926 3024 }
2927 3025 if ($post_data->post_type !== 'page') {
@@ -2976,20 +3074,225 @@
2976 3074 if($isOttoAiPage){
2977 3075 // Change the page template from default to Metasync Template
2978 3076 $pageTemplate = Metasync_Template::TEMPLATE_NAME;
2979 3077 }
2980 - if ($pageTemplate !== 'default') {
2981 - update_post_meta($post_id, '_wp_page_template', $pageTemplate);
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);
2982 3291 } else {
2983 - // Clear stale templates that conflict with the active page builder.
2984 - $current = get_post_meta($post_id, '_wp_page_template', true);
2985 - $stale_templates = array(Metasync_Template::TEMPLATE_NAME, 'elementor_canvas', 'elementor_header_footer');
2986 - if (in_array($current, $stale_templates, true)) {
2987 - delete_post_meta($post_id, '_wp_page_template');
2988 - }
3292 + add_post_meta($post_id, $key, $value, true);
2989 3293 }
2990 3294 }
2991 - return rest_ensure_response($updatePages->data);
2992 3295 }
2993 3296
2994 3297 public function delete_page()
2995 3298 {
@@ -3015,8 +3318,17 @@
3015 3318 }
3016 3319
3017 3320 public function linkgraph_login()
3018 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 +
3019 3331 $post_data = metasync_sanitize_input_array($_POST);
3020 3332 $payload = array(
3021 3333 'username' => wp_unslash(sanitize_email($post_data['username'])),
3022 3334 'password' => wp_unslash(sanitize_text_field($post_data['password']))
@@ -3033,9 +3345,9 @@
3033 3345 ));
3034 3346
3035 3347 # Error handling for timeout or connection failures
3036 3348 if (is_wp_error($response)) {
3037 - error_log('MetaSync: Token API failed: ' . $response->get_error_message());
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
3038 3350 wp_send_json_error(array('message' => 'Token API request failed'));
3039 3351 wp_die();
3040 3352 }
3041 3353
@@ -3169,9 +3481,9 @@
3169 3481 $option_name = '_transient_metasync_sa_connect_active_' . $nonce_token;
3170 3482 $timeout_name = '_transient_timeout_metasync_sa_connect_active_' . $nonce_token;
3171 3483
3172 3484 // Check expiry first
3173 - $timeout = $wpdb->get_var(
3485 + $timeout = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
3174 3486 $wpdb->prepare(
3175 3487 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
3176 3488 $timeout_name
3177 3489 )
@@ -3178,14 +3490,14 @@
3178 3490 );
3179 3491
3180 3492 if ($timeout && (int) $timeout < time()) {
3181 3493 // Expired — clean up orphan rows
3182 - $wpdb->delete($wpdb->options, array('option_name' => $option_name));
3183 - $wpdb->delete($wpdb->options, array('option_name' => $timeout_name));
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
3184 3496 return false;
3185 3497 }
3186 3498
3187 - $value = $wpdb->get_var(
3499 + $value = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
3188 3500 $wpdb->prepare(
3189 3501 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
3190 3502 $option_name
3191 3503 )
@@ -3209,9 +3521,9 @@
3209 3521 $option_name = '_transient_' . $transient_key;
3210 3522 $timeout_name = '_transient_timeout_' . $transient_key;
3211 3523
3212 3524 // Check expiry first
3213 - $timeout = $wpdb->get_var(
3525 + $timeout = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
3214 3526 $wpdb->prepare(
3215 3527 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
3216 3528 $timeout_name
3217 3529 )
@@ -3217,14 +3529,14 @@
3217 3529 )
3218 3530 );
3219 3531
3220 3532 if ($timeout && (int) $timeout < time()) {
3221 - $wpdb->delete($wpdb->options, array('option_name' => $option_name));
3222 - $wpdb->delete($wpdb->options, array('option_name' => $timeout_name));
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
3223 3535 return false;
3224 3536 }
3225 3537
3226 - $value = $wpdb->get_var(
3538 + $value = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
3227 3539 $wpdb->prepare(
3228 3540 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
3229 3541 $option_name
3230 3542 )
@@ -3251,10 +3563,10 @@
3251 3563 {
3252 3564 delete_transient($transient_name);
3253 3565 if (wp_using_ext_object_cache()) {
3254 3566 global $wpdb;
3255 - $wpdb->delete($wpdb->options, array('option_name' => '_transient_' . $transient_name));
3256 - $wpdb->delete($wpdb->options, array('option_name' => '_transient_timeout_' . $transient_name));
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
3257 3569 }
3258 3570 }
3259 3571
3260 3572 /**
@@ -3271,14 +3583,14 @@
3271 3583 {
3272 3584 set_transient($transient_name, $value, $expiration);
3273 3585 if (wp_using_ext_object_cache()) {
3274 3586 global $wpdb;
3275 - $wpdb->replace($wpdb->options, array(
3587 + $wpdb->replace($wpdb->options, array( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
3276 3588 'option_name' => '_transient_' . $transient_name,
3277 3589 'option_value' => maybe_serialize($value),
3278 3590 'autoload' => 'no',
3279 3591 ));
3280 - $wpdb->replace($wpdb->options, array(
3592 + $wpdb->replace($wpdb->options, array( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
3281 3593 'option_name' => '_transient_timeout_' . $transient_name,
3282 3594 'option_value' => time() + $expiration,
3283 3595 'autoload' => 'no',
3284 3596 ));
@@ -3632,9 +3944,9 @@
3632 3944 *
3633 3945 * @param array $body_params Request body parameters
3634 3946 * @return array|WP_Error Validated parameters array or WP_Error
3635 3947 */
3636 - private function extract_and_validate_searchatlas_params($body_params)
3948 + public function extract_and_validate_searchatlas_params($body_params)
3637 3949 {
3638 3950 $validation_errors = array();
3639 3951
3640 3952 // Extract parameters
@@ -3655,14 +3967,18 @@
3655 3967 $validation_errors['api_key'] = 'API key contains invalid characters. Only alphanumeric, dash, underscore, and dot allowed';
3656 3968 }
3657 3969
3658 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.
3659 3975 if (empty($uuid)) {
3660 3976 $validation_errors['uuid'] = 'UUID is required';
3661 3977 } elseif (!is_string($uuid)) {
3662 3978 $validation_errors['uuid'] = 'UUID must be a string';
3663 - } elseif (strlen($uuid) > 100) {
3664 - $validation_errors['uuid'] = 'UUID must not exceed 100 characters';
3979 + } elseif (!Metasync::is_valid_uuid($uuid)) {
3980 + $validation_errors['uuid'] = 'UUID must be a valid UUID';
3665 3981 }
3666 3982
3667 3983 // Validate status_code
3668 3984 if (!is_numeric($status_code)) {
@@ -3715,9 +4031,9 @@
3715 4031 } elseif (strlen($whitelabel_domain) > 255) {
3716 4032 $validation_errors['whitelabel_domain'] = 'Whitelabel domain must not exceed 255 characters';
3717 4033 } elseif (!filter_var($whitelabel_domain, FILTER_VALIDATE_URL)) {
3718 4034 $validation_errors['whitelabel_domain'] = 'Whitelabel domain must be a valid URL';
3719 - } elseif (!in_array(parse_url($whitelabel_domain, PHP_URL_SCHEME), ['http', 'https'])) {
4035 + } elseif (!in_array(wp_parse_url($whitelabel_domain, PHP_URL_SCHEME), ['http', 'https'])) {
3720 4036 $validation_errors['whitelabel_domain'] = 'Whitelabel domain must use http or https protocol';
3721 4037 }
3722 4038 }
3723 4039
@@ -3730,9 +4046,9 @@
3730 4046 $validation_errors['whitelabel_logo'] = 'Whitelabel logo URL must not exceed 500 characters';
3731 4047 } else {
3732 4048 // If URL is invalid, we'll clear it later but not fail the POST
3733 4049 if (!filter_var($whitelabel_logo, FILTER_VALIDATE_URL) ||
3734 - !in_array(parse_url($whitelabel_logo, PHP_URL_SCHEME), ['http', 'https'])) {
4050 + !in_array(wp_parse_url($whitelabel_logo, PHP_URL_SCHEME), ['http', 'https'])) {
3735 4051 // Don't add to validation_errors - let POST succeed but clear the field
3736 4052 }
3737 4053 }
3738 4054 }
@@ -3823,10 +4139,22 @@
3823 4139 $options['general'] = array();
3824 4140 }
3825 4141 // Only update the API key in settings if status_code is 200 (success)
3826 4142 if ($status_code === 200) {
3827 - $options['general']['searchatlas_api_key'] = $new_api_key;
3828 - $options['general']['otto_pixel_uuid'] = $new_otto_uuid;
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 + }
3829 4157 // Note: OTTO SSR is always enabled by default, no need to set
3830 4158
3831 4159 // Granular otto_config_status: record when SSO completed (ISO 8601 UTC)
3832 4160 $options['general']['sso_completed_at'] = gmdate('Y-m-d\TH:i:s\Z');
@@ -3845,35 +4173,43 @@
3845 4173
3846 4174 } else {
3847 4175 }
3848 4176
3849 - // Map whitelabel fields consistently (regardless of status_code)
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.
3850 4197 if ($is_whitelabel) {
3851 - // whitelabel_company_name → Plugin Name (general plugin branding)
3852 - if (!empty($whitelabel_company_name)) {
3853 - $options['general']['white_label_plugin_name'] = $whitelabel_company_name;
4198 + if (!isset($options['whitelabel'])) {
4199 + $options['whitelabel'] = array();
3854 4200 }
3855 -
3856 - // whitelabel_otto → OTTO Features naming (separate from plugin name)
3857 - if (!empty($whitelabel_otto)) {
3858 - $options['general']['whitelabel_otto_name'] = $whitelabel_otto;
4201 +
4202 + $options['whitelabel']['is_whitelabel'] = true;
4203 + if (!empty($whitelabel_domain)) {
4204 + $options['whitelabel']['domain'] = $whitelabel_domain;
3859 4205 }
3860 - } else {
3861 - // Clear whitelabel fields when not whitelabel
3862 - unset($options['general']['white_label_plugin_name']);
3863 - unset($options['general']['whitelabel_otto_name']);
4206 + if (!empty($whitelabel_logo)) {
4207 + $options['whitelabel']['logo'] = $whitelabel_logo;
4208 + }
4209 + $options['whitelabel']['updated_at'] = time();
3864 4210 }
3865 4211
3866 - // Store whitelabel settings (hidden from UI but accessible to plugin logic)
3867 - if (!isset($options['whitelabel'])) {
3868 - $options['whitelabel'] = array();
3869 - }
3870 -
3871 - $options['whitelabel']['is_whitelabel'] = $is_whitelabel;
3872 - $options['whitelabel']['domain'] = $whitelabel_domain;
3873 - $options['whitelabel']['logo'] = $whitelabel_logo;
3874 - $options['whitelabel']['updated_at'] = time();
3875 -
3876 4212 // Log whitelabel configuration
3877 4213 if ($is_whitelabel) {
3878 4214 $log_parts = array('Whitelabel mode enabled');
3879 4215 if (!empty($whitelabel_domain)) {
@@ -3891,11 +4227,12 @@
3891 4227
3892 4228 }
3893 4229
3894 4230 $save_result = Metasync::set_option($options);
4231 + Metasync::invalidate_api_key_cache();
3895 4232
3896 4233 if (!$save_result) {
3897 - error_log('MetaSync SA Connect: mark_searchatlas_nonce_used - Failed to save plugin options');
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
3898 4235 } else {
3899 4236 if ($status_code === 200) {
3900 4237 // Option 1: Set heartbeat cache and last-known to CONNECTED so dashboard shows iframe immediately.
3901 4238 // If the immediate heartbeat then fails (e.g. backoff), Option 2 prevents overwriting this to DISCONNECTED.
@@ -3907,9 +4244,9 @@
3907 4244 );
3908 4245 set_transient('metasync_heartbeat_status_cache', $cache_data, 300);
3909 4246 update_option('metasync_last_known_connection_state', true);
3910 4247 do_action('metasync_heartbeat_state_key_pending'); // PR3: burst mode
3911 - $this->trigger_immediate_heartbeat_after_sa_connect();
4248 + $this->trigger_immediate_heartbeat_after_sa_connect($token);
3912 4249 }
3913 4250 }
3914 4251
3915 4252 return true;
@@ -3914,9 +4251,9 @@
3914 4251
3915 4252 return true;
3916 4253
3917 4254 } catch (Exception $e) {
3918 - error_log('MetaSync SA Connect: mark_searchatlas_nonce_used Error - ' . $e->getMessage());
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
3919 4256 return false;
3920 4257 }
3921 4258 }
3922 4259
@@ -3928,9 +4265,9 @@
3928 4265 {
3929 4266 global $wpdb;
3930 4267
3931 4268 // Clear all JWT token transients
3932 - $deleted = $wpdb->query(
4269 + $deleted = $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
3933 4270 $wpdb->prepare(
3934 4271 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
3935 4272 '_transient_metasync_jwt_token_%'
3936 4273 )
@@ -3936,9 +4273,9 @@
3936 4273 )
3937 4274 );
3938 4275
3939 4276 // Also clear timeout transients
3940 - $wpdb->query(
4277 + $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
3941 4278 $wpdb->prepare(
3942 4279 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
3943 4280 '_transient_timeout_metasync_jwt_token_%'
3944 4281 )
@@ -3949,21 +4286,28 @@
3949 4286
3950 4287 /**
3951 4288 * Trigger immediate heartbeat check after successful Search Atlas connect authentication
3952 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.
3953 4292 */
3954 - private function trigger_immediate_heartbeat_after_sa_connect()
4293 + private function trigger_immediate_heartbeat_after_sa_connect($token)
3955 4294 {
3956 4295 try {
3957 4296 // Use WordPress action system to trigger immediate heartbeat check
3958 4297 // This is more reliable than trying to access admin class directly
3959 4298 do_action('metasync_trigger_immediate_heartbeat', 'Search Atlas Connect - API key and UUID retrieved');
3960 -
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 +
3961 4305 // Also ensure heartbeat cron is scheduled now that we have an API key
3962 4306 do_action('metasync_ensure_heartbeat_cron_scheduled');
3963 4307
3964 4308 } catch (Exception $e) {
3965 - error_log('MetaSync SA Connect: Error triggering immediate heartbeat check - ' . $e->getMessage());
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
3966 4310 }
3967 4311 }
3968 4312
3969 4313 public function get_item_schema()
@@ -3981,15 +4325,15 @@
3981 4325 'type' => 'object',
3982 4326 // In JSON Schema you can specify object properties in the properties attribute.
3983 4327 'properties' => array(
3984 4328 'id' => array(
3985 - 'description' => esc_html__('Unique identifier for the object.', 'my-textdomain'),
4329 + 'description' => esc_html__('Unique identifier for the object.', 'metasync'),
3986 4330 'type' => 'integer',
3987 4331 'context' => array('view', 'edit', 'embed'),
3988 4332 'readonly' => true,
3989 4333 ),
3990 4334 'content' => array(
3991 - 'description' => esc_html__('The content for the object.', 'my-textdomain'),
4335 + 'description' => esc_html__('The content for the object.', 'metasync'),
3992 4336 'type' => 'string',
3993 4337 ),
3994 4338 ),
3995 4339 );
@@ -4080,11 +4424,11 @@
4080 4424
4081 4425 # Try alternative parameter methods
4082 4426 $body_params = $request->get_body_params();
4083 4427 $key_param = $request->get_param('key');
4084 - $post_key = $_POST['key'] ?? null;
4085 - $request_key = $_REQUEST['key'] ?? null;
4086 - $get_key = $_GET['key'] ?? null;
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
4087 4431
4088 4432 # Try to get key from multiple sources
4089 4433 $key_value = null;
4090 4434
@@ -4114,9 +4458,9 @@
4114 4458 }
4115 4459
4116 4460 # Validate that key is provided
4117 4461 if (empty($key_value)) {
4118 - return rest_ensure_response(array(
4462 + return new WP_REST_Response(array(
4119 4463 'error' => 'Key parameter is required',
4120 4464 'code' => 'missing_key'
4121 4465 ), 400);
4122 4466 }
@@ -4128,9 +4472,9 @@
4128 4472 $key = basename(sanitize_file_name($data['key']));
4129 4473
4130 4474 # Validate key is not empty after sanitization and contains no path separators
4131 4475 if (empty($key) || preg_match('/[\/\\\\]/', $key) || strpos($key, '..') !== false) {
4132 - return rest_ensure_response(array(
4476 + return new WP_REST_Response(array(
4133 4477 'error' => 'Invalid key provided',
4134 4478 'code' => 'invalid_key'
4135 4479 ), 400);
4136 4480 }
@@ -4142,42 +4486,84 @@
4142 4486 $safe_key = sanitize_file_name( $key );
4143 4487 $file_path = $wp_root . $safe_key . '.txt';
4144 4488 $real_root = realpath( $wp_root );
4145 4489 if ( false === $real_root || 0 !== strpos( realpath( dirname( $file_path ) ), $real_root ) ) {
4146 - return rest_ensure_response(array(
4490 + return new WP_REST_Response(array(
4147 4491 'error' => 'Invalid file path',
4148 4492 'code' => 'invalid_path'
4149 4493 ), 400);
4150 4494 }
4151 4495
4152 - # Check if file already exists
4153 - if (file_exists($file_path)) {
4154 - return rest_ensure_response(array(
4155 - 'error' => 'File already exists',
4156 - 'code' => 'file_exists',
4157 - 'file_path' => $file_path
4158 - ), 409);
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 + }
4159 4503 }
4504 + if (class_exists('Metasync_Bing_Instant_Index')) {
4505 + Metasync_Bing_Instant_Index::register_virtual_key($safe_key);
4506 + }
4160 4507
4161 - # Attempt to create the file
4162 - $result = file_put_contents($file_path, $safe_key);
4163 -
4164 - # Check if file creation was successful
4165 - if ($result === false) {
4166 - return rest_ensure_response(array(
4167 - 'error' => 'Failed to create file',
4168 - 'code' => 'file_creation_failed',
4169 - 'file_path' => $file_path
4170 - ), 500);
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 + }
4171 4547 }
4172 -
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 +
4173 4558 # Return success response
4174 - return rest_ensure_response(array(
4175 - 'success' => true,
4176 - 'message' => 'Key file created successfully',
4559 + return new WP_REST_Response(array(
4560 + 'success' => true,
4561 + 'message' => 'Key file created successfully',
4177 4562 'file_path' => $file_path,
4178 - 'key' => $key,
4179 - 'file_size' => $result
4563 + 'file_url' => home_url('/' . $safe_key . '.txt'),
4564 + 'key' => $key,
4565 + 'serving' => $serving, // 'virtual' = served by WordPress, 'physical' = on-disk file remains
4180 4566 ), 200);
4181 4567 }
4182 4568
4183 4569 }