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 +660 -336 2.6.16 → 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,10 +191,9 @@
278 191 if (empty($api_key)) {
279 192 return false;
280 193 }
281 194
282 - $getOptions = Metasync::get_option('general');
283 - $stored_key = $getOptions['apikey'] ?? null;
195 + $stored_key = $this->get_stored_api_key();
284 196
285 197 if (empty($stored_key)) {
286 198 return false;
287 199 }
@@ -289,8 +201,23 @@
289 201 return hash_equals($stored_key, $api_key);
290 202 }
291 203
292 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 + /**
293 220 * Coexist with third-party JWT authentication plugins.
294 221 *
295 222 * A global JWT auth plugin (e.g. Tmeister's "JWT Authentication for WP-API")
296 223 * validates any "Authorization: Bearer ..." header as a JWT. MetaSync sends
@@ -340,9 +267,146 @@
340 267
341 268 return $result;
342 269 }
343 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 + }
344 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 +
345 409 public function metasync_register_rest_routes()
346 410 {
347 411 // Critical Routes
348 412 /*
@@ -481,9 +545,9 @@
481 545 array(
482 546 array(
483 547 'methods' => 'GET',
484 548 'callback' => function () {
485 - $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
486 550
487 551 if ($getPostID==0) {
488 552 $response = false;
489 553 }else{
@@ -1012,9 +1076,9 @@
1012 1076 }
1013 1077
1014 1078 public function get_errorlogs()
1015 1079 {
1016 - $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
1017 1081 if (!isset($get_data['limit']))
1018 1082 return false;
1019 1083 $limit = sanitize_text_field($get_data['limit']) ?? null;
1020 1084
@@ -1202,9 +1266,9 @@
1202 1266 }
1203 1267 } catch (Throwable $e) {
1204 1268 // Fallback to DOMDocument if Dom\HTMLDocument fails
1205 1269 if (defined('WP_DEBUG_LOG') && WP_DEBUG_LOG) {
1206 - 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
1207 1271 }
1208 1272 $dom = new DOMDocument();
1209 1273 libxml_use_internal_errors(true);
1210 1274 $encoded_content = $this->encode_numeric_entities($content);
@@ -1404,9 +1468,9 @@
1404 1468 $image_id = attachment_url_to_postid($node->getAttribute('src') );
1405 1469 $image_alt = get_post_meta($image_id, '_wp_attachment_image_alt', TRUE);
1406 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]';
1407 1471
1408 - 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
1409 1473
1410 1474 }
1411 1475 }elseif($node->nodeName === 'iframe'){
1412 1476 $result= '[et_pb_code _builder_version="'.ET_BUILDER_VERSION.'" _module_preset="default" global_colors_info="{}"]'.$node->ownerDocument->saveHTML($node).'[/et_pb_code]' ;
@@ -1534,71 +1598,12 @@
1534 1598 return $attachment_id;
1535 1599 }
1536 1600
1537 1601 /**
1538 - * Index a post with Google Indexing API
1539 - *
1540 - * @param int $post_id WordPress post ID
1541 - * @param string $post_type WordPress post type (post, page, etc.)
1542 - * @param string $post_status WordPress post status (publish, draft, etc.)
1543 - */
1544 - public function metasync_google_index_post($post_id, $post_type, $post_status)
1545 - {
1546 - // Only index published posts/pages
1547 - if ($post_status !== 'publish') {
1548 - return;
1549 - }
1550 -
1551 - // Only index posts and pages (can be extended as needed)
1552 - $allowed_post_types = array('post', 'page');
1553 - if (!in_array($post_type, $allowed_post_types)) {
1554 - return;
1555 - }
1556 -
1557 - try {
1558 - // Load Google Index functionality if not already loaded
1559 - if (!function_exists('google_index_post')) {
1560 - $google_index_path = plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php';
1561 - if (file_exists($google_index_path)) {
1562 - require_once $google_index_path;
1563 - } else {
1564 - error_log('MetaSync Google Index: google-index-init.php not found at ' . $google_index_path);
1565 - return;
1566 - }
1567 - }
1568 -
1569 - // Attempt to index the post with Google
1570 - if (function_exists('google_index_post')) {
1571 - $result = google_index_post($post_id, $post_type, 'update');
1572 -
1573 - if (isset($result['success']) && $result['success']) {
1574 - error_log(sprintf(
1575 - 'MetaSync Google Index: Successfully indexed %s (ID: %d, Type: %s)',
1576 - get_the_title($post_id),
1577 - $post_id,
1578 - $post_type
1579 - ));
1580 - } else {
1581 - error_log(sprintf(
1582 - 'MetaSync Google Index: Failed to index %s (ID: %d, Type: %s) - %s',
1583 - get_the_title($post_id),
1584 - $post_id,
1585 - $post_type,
1586 - isset($result['error']['message']) ? $result['error']['message'] : 'Unknown error'
1587 - ));
1588 - }
1589 - }
1590 - } catch (Exception $e) {
1591 - // Log any exceptions but don't break the main functionality
1592 - error_log('MetaSync Google Index Exception: ' . $e->getMessage());
1593 - }
1594 - }
1595 -
1596 - /**
1597 - * 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.
1598 1603 *
1599 1604 * Themes that render post_title as an H1 produce a duplicate heading when the
1600 - * 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
1601 1606 * only prevents prepending a second H1 but never removes one supplied in the body.
1602 1607 *
1603 1608 * @param string $content Post body HTML
1604 1609 * @param string $title Post title
@@ -1615,9 +1620,9 @@
1615 1620 return $content;
1616 1621 }
1617 1622
1618 1623 # Entity-decode both sides so "Mobility &amp; Recovery" matches "Mobility & Recovery"
1619 - $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));
1620 1625 $title_text = trim(html_entity_decode((string) $title, ENT_QUOTES | ENT_HTML5));
1621 1626 if ($h1_text === '' || strcasecmp($h1_text, $title_text) !== 0) {
1622 1627 return $content;
1623 1628 }
@@ -1625,9 +1630,9 @@
1625 1630 return substr($trimmed, strlen($h1_match[0]));
1626 1631 }
1627 1632
1628 1633 /**
1629 - * 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.
1630 1635 *
1631 1636 * Replaces the unreliable 7-day hidden-post probe (title_in_headings) as the
1632 1637 * source of truth for the prepend decision. We only prepend when we have a
1633 1638 * POSITIVE, render-verified verdict that the active theme does NOT render the
@@ -1651,9 +1656,9 @@
1651 1656 return false;
1652 1657 }
1653 1658
1654 1659 /**
1655 - * 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
1656 1661 * the active theme outputs the post title in a heading. The verdict is cached
1657 1662 * per post-type so subsequent syncs skip the round-trip. Best-effort: any
1658 1663 * inconclusive fetch (non-200, cache/challenge page, wrong post) records
1659 1664 * nothing and is retried on the next sync.
@@ -1693,9 +1698,9 @@
1693 1698 Metasync::set_option($options);
1694 1699 }
1695 1700
1696 1701 /**
1697 - * 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
1698 1703 * title in a heading.
1699 1704 *
1700 1705 * @param string $permalink Public permalink
1701 1706 * @param int $post_id Saved post ID (used to confirm we fetched the right page)
@@ -1741,9 +1746,9 @@
1741 1746 return $this->title_present_in_headings($html, $post_title);
1742 1747 }
1743 1748
1744 1749 /**
1745 - * 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.
1746 1751 *
1747 1752 * @param string $html Rendered page HTML
1748 1753 * @param string $title Title text to match
1749 1754 * @return bool
@@ -1774,8 +1779,65 @@
1774 1779 }
1775 1780 return false;
1776 1781 }
1777 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 +
1778 1840 public function create_item($request)
1779 1841 {
1780 1842 // Checking for type of object for response type
1781 1843 $array_response = true;
@@ -1828,9 +1890,9 @@
1828 1890 } elseif (!empty($theme_template) && stripos($theme_template, 'flatsome') !== false) {
1829 1891 $is_flatsome_theme = true;
1830 1892 }
1831 1893
1832 - # WP-429: Always strip a leading H1 that duplicates the title. The
1894 + # Always strip a leading H1 that duplicates the title. The
1833 1895 # theme renders post_title in its header on virtually every theme, so a
1834 1896 # title H1 at the top of the body is a duplicate. Whether we then put one
1835 1897 # back is decided by should_prepend_title() below.
1836 1898 if (isset($item['post_title'])) {
@@ -1846,9 +1908,9 @@
1846 1908 if(!$title_and_feature_image['image_in_content'] && !empty($item['hero_image_url'])){
1847 1909 $item['post_content'] = '<img src="' . esc_url_raw($item['hero_image_url']) . '" />'.$item['post_content'] ;
1848 1910 }
1849 1911
1850 - # 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
1851 1913 # omit it. Unknown verdict defaults to NOT prepending (see should_prepend_title).
1852 1914 if(isset($item['post_title']) && $this->should_prepend_title($current_post_type)){
1853 1915 $item['post_content'] = '<h1>'.$item['post_title'].'</h1>'.$item['post_content'] ;
1854 1916 }
@@ -1886,13 +1948,13 @@
1886 1948 $new_post['post_name'] = sanitize_text_field($item['permalink']);
1887 1949 }
1888 1950
1889 1951 if (isset($item['post_date']) && !empty($item['post_date'])) {
1890 - $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'];
1891 1953 if (!$is_valid_date) {
1892 1954 return new WP_Error(
1893 1955 'rest_post_invalid_date',
1894 - esc_html__('Post date is not valid'),
1956 + esc_html__('Post date is not valid', 'metasync'),
1895 1957 array('status' => 400)
1896 1958 );
1897 1959 }
1898 1960
@@ -1925,20 +1987,16 @@
1925 1987 # $getPostID_byURL = @get_page_by_path($item['permalink'], OBJECT, $new_post['post_type'])->ID;
1926 1988 # Fix to avoid PHP error if the get_page_by_path returns null
1927 1989 $getPostID_byURL = @get_page_by_path($item['permalink'], OBJECT, $new_post['post_type']);
1928 1990 $getPostID_byURL = $getPostID_byURL ? $getPostID_byURL->ID : null;
1991 + $matched_by_title = false;
1929 1992 if ($getPostID_byURL == NULL) {
1930 1993 // check if the post_title is set and not empty when called by otto_ai_page
1931 1994 if(isset($new_post['post_title']) && $new_post['post_title']!==''){
1932 - $getPostID_byURL = new WP_Query(
1933 - array(
1934 - 'post_type' => $new_post['post_type'],
1935 - 'title' => $new_post['post_title']
1936 - )
1937 - );
1938 - $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 + }
1939 1998 }
1940 - }
1941 1999
1942 2000 // Allow HTML code for landing page
1943 2001 if (isset($item['is_landing_page']) && $item['is_landing_page'] == true) {
1944 2002 kses_remove_filters();
@@ -1963,11 +2021,17 @@
1963 2021 update_post_meta($post_id, 'metasync_post', 'yes');
1964 2022 }
1965 2023
1966 2024 } else {
1967 - $new_post['ID'] = $post_id = $getPostID_byURL;
1968 - wp_update_post($new_post);
1969 - 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);
1970 2034 $permalink = get_permalink($post_id);
1971 2035 }
1972 2036
1973 2037 if (isset($item['is_landing_page']) && $item['is_landing_page'] == true) {
@@ -2009,13 +2073,17 @@
2009 2073 $post_meta['searchatlas_embed_bottom'] = $item['searchatlas_embed_bottom'];
2010 2074 }
2011 2075
2012 2076 // Add custom fields to posts and pages
2013 - foreach ($post_meta as $key => $value) {
2014 - // if (!empty($value) && !is_null($value)) {
2015 - add_post_meta($post_id, $key, $value, true);
2016 - // }
2017 - }
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 + );
2018 2086
2019 2087
2020 2088 $attachment_id = '';
2021 2089 if (isset($item['hero_image_url']) && !empty($item['hero_image_url'])) {
@@ -2033,8 +2101,14 @@
2033 2101 $redirection['url'] = sanitize_url($item['redirection_url']);
2034 2102 }
2035 2103 if (!empty($redirection)) {
2036 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 + }
2037 2111 }
2038 2112
2039 2113 $post_cattegories = [];
2040 2114 # if ($new_post['post_type'] === 'post' && is_array(@$item['post_categories'])) {
@@ -2090,20 +2164,19 @@
2090 2164 'action' => strtolower($action)
2091 2165 ])
2092 2166 ]);
2093 2167
2094 - # 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.
2095 2170 try {
2096 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']));
2097 2173 } catch (Exception $e) {
2098 - 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
2099 2175 }
2100 -
2101 - // Google Indexing Integration
2102 - $this->metasync_google_index_post($post_id, $new_post['post_type'], $new_post['post_status']);
2103 2176 }
2104 2177
2105 - # 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
2106 2179 # theme renders the title, so future syncs prepend only when genuinely needed.
2107 2180 if (!is_wp_error($post_id) && $post_id > 0
2108 2181 && !isset($item['is_landing_page']) && empty($isOttoAiPage) && empty($item['style_data'])
2109 2182 && $new_post['post_status'] === 'publish') {
@@ -2109,9 +2182,9 @@
2109 2182 && $new_post['post_status'] === 'publish') {
2110 2183 try {
2111 2184 $this->record_theme_title_verdict($post_id, $permalink, $new_post['post_title'], $new_post['post_type']);
2112 2185 } catch (Exception $e) {
2113 - 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
2114 2187 }
2115 2188 }
2116 2189
2117 2190 $respCreatePosts[$index] = array_merge($new_post, $post_meta);
@@ -2156,9 +2229,9 @@
2156 2229 }
2157 2230
2158 2231 public function delete_item()
2159 2232 {
2160 - $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
2161 2234 if (!isset($get_data['ID']))
2162 2235 return false;
2163 2236
2164 2237 $post_id = sanitize_text_field($get_data['ID']) ?? null;
@@ -2164,18 +2237,18 @@
2164 2237 $post_id = sanitize_text_field($get_data['ID']) ?? null;
2165 2238 $post = get_post($post_id);
2166 2239 if ($post) {
2167 2240 wp_delete_post($post_id);
2241 + // HTTP 204 requires no body for response, so no message is set.
2168 2242 return new WP_Error(
2169 2243 'rest_post_delete_success',
2170 - esc_html__(''),
2171 - // HTTP 204 requires no body for response
2244 + '',
2172 2245 array('status' => 204)
2173 2246 );
2174 2247 }
2175 2248 return new WP_Error(
2176 2249 'rest_post_delete_fail',
2177 - esc_html__('No post found in the database with requested ID.'),
2250 + esc_html__('No post found in the database with requested ID.', 'metasync'),
2178 2251 array('status' => 400)
2179 2252 );
2180 2253 }
2181 2254
@@ -2180,9 +2253,9 @@
2180 2253 }
2181 2254
2182 2255 public function get_items($request)
2183 2256 {
2184 - $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
2185 2258
2186 2259 # let us check if request send post id and is valid int
2187 2260 if(isset($get_data['post_id']) AND intval($get_data['post_id']) > 0){
2188 2261
@@ -2522,9 +2595,9 @@
2522 2595 $post_data = get_post($post_id);
2523 2596 if (!$post_data) {
2524 2597 return new WP_Error(
2525 2598 'rest_post_update_fail',
2526 - esc_html__('No post found in the database with requested ID.'),
2599 + esc_html__('No post found in the database with requested ID.', 'metasync'),
2527 2600 array('status' => 400)
2528 2601 );
2529 2602 }
2530 2603
@@ -2549,9 +2622,14 @@
2549 2622 $update_params['meta_robots'] = $post['meta_robots'] ?
2550 2623 sanitize_text_field($post['meta_robots']) : $get_robots_meta;
2551 2624 }
2552 2625 if (isset($post['meta_canonical']) && !empty($post['meta_canonical'])) {
2553 - $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 + }
2554 2632 }
2555 2633
2556 2634 $isOttoAiPage = !empty($post['otto_ai_page']) && filter_var($post['otto_ai_page'], FILTER_VALIDATE_BOOLEAN);
2557 2635 $content = [];
@@ -2578,9 +2656,9 @@
2578 2656
2579 2657 # Title to compare against — payload may omit post_title on update
2580 2658 $compare_title = isset($post['post_title']) && !empty($post['post_title']) ? $post['post_title'] : $post_fresh_data->post_title;
2581 2659
2582 - # 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
2583 2661 # renders post_title in its header, so a body title H1 is a duplicate.
2584 2662 $post['post_content'] = $this->strip_leading_title_h1($post['post_content'], $compare_title);
2585 2663
2586 2664 # Skip title/image prepending for Flatsome (it displays them by default)
@@ -2592,9 +2670,9 @@
2592 2670 if(!$title_and_feature_image['image_in_content'] && !empty($post['hero_image_url'])){
2593 2671 $post['post_content'] = '<img src="' . esc_url_raw($post['hero_image_url']) . '" />'.$post['post_content'] ;
2594 2672 }
2595 2673
2596 - # 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
2597 2675 # omit it. Unknown verdict defaults to NOT prepending (see should_prepend_title).
2598 2676 if($this->should_prepend_title($post_fresh_data->post_type)){
2599 2677 $post['post_content'] = '<h1>'.$compare_title.'</h1>'.$post['post_content'] ;
2600 2678 }
@@ -2640,9 +2718,8 @@
2640 2718 }
2641 2719
2642 2720 if (isset($post['post_status']) && !empty($post['post_status'])) {
2643 2721 $update_params['post_status'] = $post['post_status'] ? sanitize_text_field($post['post_status']) : 'publish';
2644 - $permalink = get_permalink($post_id);
2645 2722 }
2646 2723 if (isset($post['permalink']) || !empty($post['permalink'])) {
2647 2724 $update_params['post_name'] = sanitize_text_field($post['permalink']);
2648 2725 }
@@ -2657,40 +2734,8 @@
2657 2734 );
2658 2735 }
2659 2736
2660 2737
2661 - if (isset($post['post_date']) && !empty($post['post_date']) && false) {
2662 - $is_valid_date = date('Y-m-d', strtotime($post['post_date'])) == $post['post_date'];
2663 - if (!$is_valid_date) {
2664 - return new WP_Error(
2665 - 'rest_post_invalid_date',
2666 - esc_html__('Post date is not valid'),
2667 - array('status' => 400)
2668 - );
2669 - }
2670 -
2671 - $date_limit_str = strtotime(date('Y-m-d') . '-2 month');
2672 - $post_date_str = strtotime($post['post_date']);
2673 -
2674 - if ($date_limit_str >= $post_date_str) {
2675 - $newDate = date('Y-m-d', strtotime('-2 month'));
2676 - return new WP_Error(
2677 - 'rest_post_greater_date',
2678 - esc_html__("Post date should be greater then " . $newDate),
2679 - array('status' => 400)
2680 - );
2681 - }
2682 -
2683 - if ($post_date_str > strtotime(date('Y-m-d'))) {
2684 - return new WP_Error(
2685 - 'rest_post_greater_date',
2686 - esc_html__('Post date should be less then Today'),
2687 - array('status' => 400)
2688 - );
2689 - }
2690 - $update_params['post_date'] = sanitize_text_field($post['post_date'] . date(' h:i:s'));
2691 - }
2692 -
2693 2738 $post_cattegories = [];
2694 2739 # if ($post_data && $post_data->post_type === 'post' && is_array(@$post['post_categories'])) {
2695 2740
2696 2741 # fixed Undefined array key issue
@@ -2713,12 +2758,13 @@
2713 2758 $attachment_id = $this->metasync_handle_hero_image($post_id, $post['hero_image_url'], $post['hero_image_alt_text']);
2714 2759 }
2715 2760
2716 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);
2717 2765 if(isset($content['elementor_meta_data'])){
2718 - foreach ($content['elementor_meta_data'] as $key => $value) {
2719 - update_post_meta($post_id, $key, $value);
2720 - }
2766 + $this->metasync_apply_builder_meta($post_id, $content['elementor_meta_data']);
2721 2767 if ( did_action( 'elementor/loaded' ) ) {
2722 2768 // Clear Elementor cache for the specified post ID
2723 2769 \Elementor\Plugin::instance()->files_manager->clear_cache();
2724 2770 }
@@ -2733,8 +2779,15 @@
2733 2779 delete_post_meta($post_id, '_elementor_page_assets');
2734 2780 delete_post_meta($post_id, '_elementor_page_settings');
2735 2781 }
2736 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 +
2737 2790 $redirection = array();
2738 2791 if (!empty($post['redirection_enable']) && !is_null($post['redirection_enable'])) {
2739 2792 $redirection['enable'] = sanitize_text_field($post['redirection_enable']);
2740 2793 }
@@ -2745,8 +2798,14 @@
2745 2798 $redirection['url'] = sanitize_url($post['redirection_url']);
2746 2799 }
2747 2800 if (!empty($redirection)) {
2748 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 + }
2749 2808 }
2750 2809
2751 2810
2752 2811 $post_revisions = wp_get_post_revisions($post_id);
@@ -2753,9 +2812,8 @@
2753 2812 // Sync post categories to customer dashboard
2754 2813 $this->lgSendCustomerPostParams();
2755 2814
2756 2815 unset($update_params['post_name']);
2757 - unset($update_params['post_category']);
2758 2816
2759 2817 $update_params['post_categories'] = $post_cattegories;
2760 2818 $update_params['post_tags'] = $post_tags;
2761 2819 $update_params['post_id'] = (int) $post_id;
@@ -2785,14 +2843,9 @@
2785 2843
2786 2844 }
2787 2845 # update the content
2788 2846 // add and update the post meta
2789 - foreach ($post_meta_data as $key => $value) {
2790 - // if (!empty($value) && !is_null($value)) {
2791 -
2792 - update_post_meta($post_id, $key, $value);
2793 - //
2794 - }
2847 + $this->metasync_apply_builder_meta($post_id, $post_meta_data);
2795 2848 #check if the elementor plugin is active
2796 2849 if ( did_action( 'elementor/loaded' ) ) {
2797 2850 # Clear Elementor cache for the specified post ID
2798 2851 \Elementor\Plugin::instance()->files_manager->clear_cache();
@@ -2825,16 +2878,17 @@
2825 2878 'updated_fields' => array_keys($update_params)
2826 2879 ])
2827 2880 ]);
2828 2881
2829 - # Track Content Genius event in GA4
2882 + # Track Content Genius event in GA4 and PostHog
2830 2883 try {
2831 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']));
2832 2886 } catch (Exception $e) {
2833 - 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
2834 2888 }
2835 2889
2836 - # 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,
2837 2891 # so future syncs prepend only when the theme genuinely omits it.
2838 2892 if ($post_id > 0 && empty($post['is_landing_page']) && empty($isOttoAiPage)
2839 2893 && $sync_status === 'published') {
2840 2894 try {
@@ -2839,9 +2893,9 @@
2839 2893 && $sync_status === 'published') {
2840 2894 try {
2841 2895 $this->record_theme_title_verdict($post_id, ($permalink ?? get_permalink($post_id)), $post_title, $post_type);
2842 2896 } catch (Exception $e) {
2843 - 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
2844 2898 }
2845 2899 }
2846 2900 }
2847 2901
@@ -2938,20 +2992,9 @@
2938 2992 if($isOttoAiPage){
2939 2993 // Change the page template from default to Metasync Template
2940 2994 $pageTemplate = Metasync_Template::TEMPLATE_NAME;
2941 2995 }
2942 - if ($pageTemplate !== 'default') {
2943 - update_post_meta($post_id, '_wp_page_template', $pageTemplate);
2944 - } else {
2945 - // Clear stale templates that conflict with the active page builder.
2946 - // e.g. metasync-blank from a previous OTTO sync, or elementor_canvas
2947 - // written by the HTML-to-builder converter on an Oxygen site.
2948 - $current = get_post_meta($post_id, '_wp_page_template', true);
2949 - $stale_templates = array(Metasync_Template::TEMPLATE_NAME, 'elementor_canvas', 'elementor_header_footer');
2950 - if (in_array($current, $stale_templates, true)) {
2951 - delete_post_meta($post_id, '_wp_page_template');
2952 - }
2953 - }
2996 + $this->metasync_write_page_template($post_id, $pageTemplate);
2954 2997 }
2955 2998
2956 2999 return rest_ensure_response($createPages);
2957 3000 }
@@ -2974,9 +3017,9 @@
2974 3017 $post_data = get_post($payload['post_id']);
2975 3018 if(!isset($post_data->post_type)){
2976 3019 return new WP_Error(
2977 3020 'rest_page_type_fail',
2978 - esc_html__('No page found in the database with requested ID.'),
3021 + esc_html__('No page found in the database with requested ID.', 'metasync'),
2979 3022 array('status' => 400)
2980 3023 );
2981 3024 }
2982 3025 if ($post_data->post_type !== 'page') {
@@ -3031,20 +3074,225 @@
3031 3074 if($isOttoAiPage){
3032 3075 // Change the page template from default to Metasync Template
3033 3076 $pageTemplate = Metasync_Template::TEMPLATE_NAME;
3034 3077 }
3035 - if ($pageTemplate !== 'default') {
3036 - 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);
3037 3291 } else {
3038 - // Clear stale templates that conflict with the active page builder.
3039 - $current = get_post_meta($post_id, '_wp_page_template', true);
3040 - $stale_templates = array(Metasync_Template::TEMPLATE_NAME, 'elementor_canvas', 'elementor_header_footer');
3041 - if (in_array($current, $stale_templates, true)) {
3042 - delete_post_meta($post_id, '_wp_page_template');
3043 - }
3292 + add_post_meta($post_id, $key, $value, true);
3044 3293 }
3045 3294 }
3046 - return rest_ensure_response($updatePages->data);
3047 3295 }
3048 3296
3049 3297 public function delete_page()
3050 3298 {
@@ -3070,8 +3318,17 @@
3070 3318 }
3071 3319
3072 3320 public function linkgraph_login()
3073 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 +
3074 3331 $post_data = metasync_sanitize_input_array($_POST);
3075 3332 $payload = array(
3076 3333 'username' => wp_unslash(sanitize_email($post_data['username'])),
3077 3334 'password' => wp_unslash(sanitize_text_field($post_data['password']))
@@ -3088,9 +3345,9 @@
3088 3345 ));
3089 3346
3090 3347 # Error handling for timeout or connection failures
3091 3348 if (is_wp_error($response)) {
3092 - 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
3093 3350 wp_send_json_error(array('message' => 'Token API request failed'));
3094 3351 wp_die();
3095 3352 }
3096 3353
@@ -3224,9 +3481,9 @@
3224 3481 $option_name = '_transient_metasync_sa_connect_active_' . $nonce_token;
3225 3482 $timeout_name = '_transient_timeout_metasync_sa_connect_active_' . $nonce_token;
3226 3483
3227 3484 // Check expiry first
3228 - $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
3229 3486 $wpdb->prepare(
3230 3487 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
3231 3488 $timeout_name
3232 3489 )
@@ -3233,14 +3490,14 @@
3233 3490 );
3234 3491
3235 3492 if ($timeout && (int) $timeout < time()) {
3236 3493 // Expired — clean up orphan rows
3237 - $wpdb->delete($wpdb->options, array('option_name' => $option_name));
3238 - $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
3239 3496 return false;
3240 3497 }
3241 3498
3242 - $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
3243 3500 $wpdb->prepare(
3244 3501 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
3245 3502 $option_name
3246 3503 )
@@ -3264,9 +3521,9 @@
3264 3521 $option_name = '_transient_' . $transient_key;
3265 3522 $timeout_name = '_transient_timeout_' . $transient_key;
3266 3523
3267 3524 // Check expiry first
3268 - $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
3269 3526 $wpdb->prepare(
3270 3527 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
3271 3528 $timeout_name
3272 3529 )
@@ -3272,14 +3529,14 @@
3272 3529 )
3273 3530 );
3274 3531
3275 3532 if ($timeout && (int) $timeout < time()) {
3276 - $wpdb->delete($wpdb->options, array('option_name' => $option_name));
3277 - $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
3278 3535 return false;
3279 3536 }
3280 3537
3281 - $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
3282 3539 $wpdb->prepare(
3283 3540 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
3284 3541 $option_name
3285 3542 )
@@ -3306,10 +3563,10 @@
3306 3563 {
3307 3564 delete_transient($transient_name);
3308 3565 if (wp_using_ext_object_cache()) {
3309 3566 global $wpdb;
3310 - $wpdb->delete($wpdb->options, array('option_name' => '_transient_' . $transient_name));
3311 - $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
3312 3569 }
3313 3570 }
3314 3571
3315 3572 /**
@@ -3326,14 +3583,14 @@
3326 3583 {
3327 3584 set_transient($transient_name, $value, $expiration);
3328 3585 if (wp_using_ext_object_cache()) {
3329 3586 global $wpdb;
3330 - $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
3331 3588 'option_name' => '_transient_' . $transient_name,
3332 3589 'option_value' => maybe_serialize($value),
3333 3590 'autoload' => 'no',
3334 3591 ));
3335 - $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
3336 3593 'option_name' => '_transient_timeout_' . $transient_name,
3337 3594 'option_value' => time() + $expiration,
3338 3595 'autoload' => 'no',
3339 3596 ));
@@ -3687,9 +3944,9 @@
3687 3944 *
3688 3945 * @param array $body_params Request body parameters
3689 3946 * @return array|WP_Error Validated parameters array or WP_Error
3690 3947 */
3691 - private function extract_and_validate_searchatlas_params($body_params)
3948 + public function extract_and_validate_searchatlas_params($body_params)
3692 3949 {
3693 3950 $validation_errors = array();
3694 3951
3695 3952 // Extract parameters
@@ -3710,14 +3967,18 @@
3710 3967 $validation_errors['api_key'] = 'API key contains invalid characters. Only alphanumeric, dash, underscore, and dot allowed';
3711 3968 }
3712 3969
3713 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.
3714 3975 if (empty($uuid)) {
3715 3976 $validation_errors['uuid'] = 'UUID is required';
3716 3977 } elseif (!is_string($uuid)) {
3717 3978 $validation_errors['uuid'] = 'UUID must be a string';
3718 - } elseif (strlen($uuid) > 100) {
3719 - $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';
3720 3981 }
3721 3982
3722 3983 // Validate status_code
3723 3984 if (!is_numeric($status_code)) {
@@ -3770,9 +4031,9 @@
3770 4031 } elseif (strlen($whitelabel_domain) > 255) {
3771 4032 $validation_errors['whitelabel_domain'] = 'Whitelabel domain must not exceed 255 characters';
3772 4033 } elseif (!filter_var($whitelabel_domain, FILTER_VALIDATE_URL)) {
3773 4034 $validation_errors['whitelabel_domain'] = 'Whitelabel domain must be a valid URL';
3774 - } 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'])) {
3775 4036 $validation_errors['whitelabel_domain'] = 'Whitelabel domain must use http or https protocol';
3776 4037 }
3777 4038 }
3778 4039
@@ -3785,9 +4046,9 @@
3785 4046 $validation_errors['whitelabel_logo'] = 'Whitelabel logo URL must not exceed 500 characters';
3786 4047 } else {
3787 4048 // If URL is invalid, we'll clear it later but not fail the POST
3788 4049 if (!filter_var($whitelabel_logo, FILTER_VALIDATE_URL) ||
3789 - !in_array(parse_url($whitelabel_logo, PHP_URL_SCHEME), ['http', 'https'])) {
4050 + !in_array(wp_parse_url($whitelabel_logo, PHP_URL_SCHEME), ['http', 'https'])) {
3790 4051 // Don't add to validation_errors - let POST succeed but clear the field
3791 4052 }
3792 4053 }
3793 4054 }
@@ -3878,10 +4139,22 @@
3878 4139 $options['general'] = array();
3879 4140 }
3880 4141 // Only update the API key in settings if status_code is 200 (success)
3881 4142 if ($status_code === 200) {
3882 - $options['general']['searchatlas_api_key'] = $new_api_key;
3883 - $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 + }
3884 4157 // Note: OTTO SSR is always enabled by default, no need to set
3885 4158
3886 4159 // Granular otto_config_status: record when SSO completed (ISO 8601 UTC)
3887 4160 $options['general']['sso_completed_at'] = gmdate('Y-m-d\TH:i:s\Z');
@@ -3900,35 +4173,43 @@
3900 4173
3901 4174 } else {
3902 4175 }
3903 4176
3904 - // 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.
3905 4197 if ($is_whitelabel) {
3906 - // whitelabel_company_name → Plugin Name (general plugin branding)
3907 - if (!empty($whitelabel_company_name)) {
3908 - $options['general']['white_label_plugin_name'] = $whitelabel_company_name;
4198 + if (!isset($options['whitelabel'])) {
4199 + $options['whitelabel'] = array();
3909 4200 }
3910 -
3911 - // whitelabel_otto → OTTO Features naming (separate from plugin name)
3912 - if (!empty($whitelabel_otto)) {
3913 - $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;
3914 4205 }
3915 - } else {
3916 - // Clear whitelabel fields when not whitelabel
3917 - unset($options['general']['white_label_plugin_name']);
3918 - unset($options['general']['whitelabel_otto_name']);
4206 + if (!empty($whitelabel_logo)) {
4207 + $options['whitelabel']['logo'] = $whitelabel_logo;
4208 + }
4209 + $options['whitelabel']['updated_at'] = time();
3919 4210 }
3920 4211
3921 - // Store whitelabel settings (hidden from UI but accessible to plugin logic)
3922 - if (!isset($options['whitelabel'])) {
3923 - $options['whitelabel'] = array();
3924 - }
3925 -
3926 - $options['whitelabel']['is_whitelabel'] = $is_whitelabel;
3927 - $options['whitelabel']['domain'] = $whitelabel_domain;
3928 - $options['whitelabel']['logo'] = $whitelabel_logo;
3929 - $options['whitelabel']['updated_at'] = time();
3930 -
3931 4212 // Log whitelabel configuration
3932 4213 if ($is_whitelabel) {
3933 4214 $log_parts = array('Whitelabel mode enabled');
3934 4215 if (!empty($whitelabel_domain)) {
@@ -3946,11 +4227,12 @@
3946 4227
3947 4228 }
3948 4229
3949 4230 $save_result = Metasync::set_option($options);
4231 + Metasync::invalidate_api_key_cache();
3950 4232
3951 4233 if (!$save_result) {
3952 - 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
3953 4235 } else {
3954 4236 if ($status_code === 200) {
3955 4237 // Option 1: Set heartbeat cache and last-known to CONNECTED so dashboard shows iframe immediately.
3956 4238 // If the immediate heartbeat then fails (e.g. backoff), Option 2 prevents overwriting this to DISCONNECTED.
@@ -3962,9 +4244,9 @@
3962 4244 );
3963 4245 set_transient('metasync_heartbeat_status_cache', $cache_data, 300);
3964 4246 update_option('metasync_last_known_connection_state', true);
3965 4247 do_action('metasync_heartbeat_state_key_pending'); // PR3: burst mode
3966 - $this->trigger_immediate_heartbeat_after_sa_connect();
4248 + $this->trigger_immediate_heartbeat_after_sa_connect($token);
3967 4249 }
3968 4250 }
3969 4251
3970 4252 return true;
@@ -3969,9 +4251,9 @@
3969 4251
3970 4252 return true;
3971 4253
3972 4254 } catch (Exception $e) {
3973 - 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
3974 4256 return false;
3975 4257 }
3976 4258 }
3977 4259
@@ -3983,9 +4265,9 @@
3983 4265 {
3984 4266 global $wpdb;
3985 4267
3986 4268 // Clear all JWT token transients
3987 - $deleted = $wpdb->query(
4269 + $deleted = $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
3988 4270 $wpdb->prepare(
3989 4271 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
3990 4272 '_transient_metasync_jwt_token_%'
3991 4273 )
@@ -3991,9 +4273,9 @@
3991 4273 )
3992 4274 );
3993 4275
3994 4276 // Also clear timeout transients
3995 - $wpdb->query(
4277 + $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
3996 4278 $wpdb->prepare(
3997 4279 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
3998 4280 '_transient_timeout_metasync_jwt_token_%'
3999 4281 )
@@ -4004,21 +4286,28 @@
4004 4286
4005 4287 /**
4006 4288 * Trigger immediate heartbeat check after successful Search Atlas connect authentication
4007 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.
4008 4292 */
4009 - private function trigger_immediate_heartbeat_after_sa_connect()
4293 + private function trigger_immediate_heartbeat_after_sa_connect($token)
4010 4294 {
4011 4295 try {
4012 4296 // Use WordPress action system to trigger immediate heartbeat check
4013 4297 // This is more reliable than trying to access admin class directly
4014 4298 do_action('metasync_trigger_immediate_heartbeat', 'Search Atlas Connect - API key and UUID retrieved');
4015 -
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 +
4016 4305 // Also ensure heartbeat cron is scheduled now that we have an API key
4017 4306 do_action('metasync_ensure_heartbeat_cron_scheduled');
4018 4307
4019 4308 } catch (Exception $e) {
4020 - 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
4021 4310 }
4022 4311 }
4023 4312
4024 4313 public function get_item_schema()
@@ -4036,15 +4325,15 @@
4036 4325 'type' => 'object',
4037 4326 // In JSON Schema you can specify object properties in the properties attribute.
4038 4327 'properties' => array(
4039 4328 'id' => array(
4040 - 'description' => esc_html__('Unique identifier for the object.', 'my-textdomain'),
4329 + 'description' => esc_html__('Unique identifier for the object.', 'metasync'),
4041 4330 'type' => 'integer',
4042 4331 'context' => array('view', 'edit', 'embed'),
4043 4332 'readonly' => true,
4044 4333 ),
4045 4334 'content' => array(
4046 - 'description' => esc_html__('The content for the object.', 'my-textdomain'),
4335 + 'description' => esc_html__('The content for the object.', 'metasync'),
4047 4336 'type' => 'string',
4048 4337 ),
4049 4338 ),
4050 4339 );
@@ -4135,11 +4424,11 @@
4135 4424
4136 4425 # Try alternative parameter methods
4137 4426 $body_params = $request->get_body_params();
4138 4427 $key_param = $request->get_param('key');
4139 - $post_key = $_POST['key'] ?? null;
4140 - $request_key = $_REQUEST['key'] ?? null;
4141 - $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
4142 4431
4143 4432 # Try to get key from multiple sources
4144 4433 $key_value = null;
4145 4434
@@ -4203,13 +4492,10 @@
4203 4492 'code' => 'invalid_path'
4204 4493 ), 400);
4205 4494 }
4206 4495
4207 - # Register the key for virtual serving via template_redirect (WP-511).
4208 - # We deliberately do NOT write a physical {key}.txt: on nginx hosts that
4209 - # 403 direct static .txt access, an on-disk file is served statically and
4210 - # blocked before WordPress runs. Serving the key from PHP avoids that and
4211 - # also works on read-only web roots.
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.
4212 4498 if (!class_exists('Metasync_Bing_Instant_Index')) {
4213 4499 $bing_file = plugin_dir_path(dirname(__FILE__)) . 'bing-index/class-metasync-bing-instant-index.php';
4214 4500 if (file_exists($bing_file)) {
4215 4501 require_once $bing_file;
@@ -4218,19 +4504,57 @@
4218 4504 if (class_exists('Metasync_Bing_Instant_Index')) {
4219 4505 Metasync_Bing_Instant_Index::register_virtual_key($safe_key);
4220 4506 }
4221 4507
4222 - # Best-effort removal of any stale physical key file so the virtual route
4223 - # governs — this is what fixes installs previously broken by a host that
4224 - # 403s static .txt (the on-disk file shadows the PHP handler). Safe: the
4225 - # path was validated to stay within ABSPATH above.
4226 - if (file_exists($file_path)) {
4227 - @unlink($file_path);
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 + }
4228 4547 }
4229 4548
4230 - # 'physical' only if the file still exists (e.g. read-only root prevented
4231 - # removal); otherwise the key is served virtually by WordPress.
4232 - $serving = file_exists($file_path) ? 'physical' : 'virtual';
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 +
4233 4557
4234 4558 # Return success response
4235 4559 return new WP_REST_Response(array(
4236 4560 'success' => true,