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 +449 -316 2.6.21 → 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;
@@ -427,10 +340,10 @@
427 340 return '/' . ltrim((string) $GLOBALS['wp']->query_vars['rest_route'], '/');
428 341 }
429 342
430 343 // ?rest_route= form, used when pretty permalinks are disabled.
431 - if (!empty($_GET['rest_route'])) {
432 - return '/' . ltrim(sanitize_text_field(wp_unslash($_GET['rest_route'])), '/');
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
433 346 }
434 347
435 348 // Last resort: the request path with the /wp-json/ prefix stripped.
436 349 $uri = isset($_SERVER['REQUEST_URI']) ? (string) wp_unslash($_SERVER['REQUEST_URI']) : '';
@@ -437,9 +350,9 @@
437 350 if ($uri === '') {
438 351 return '';
439 352 }
440 353
441 - $path = (string) parse_url($uri, PHP_URL_PATH);
354 + $path = (string) wp_parse_url($uri, PHP_URL_PATH);
442 355 $url_prefix = function_exists('rest_get_url_prefix') ? rest_get_url_prefix() : 'wp-json';
443 356 $needle = '/' . trim((string) $url_prefix, '/') . '/';
444 357
445 358 $pos = strpos($path, $needle);
@@ -453,10 +366,10 @@
453 366 /**
454 367 * Whether the current request carries a valid MetaSync API key.
455 368 *
456 369 * Reads credentials from the request globals so it works in filters that get
457 - * no WP_REST_Request. Accepts the same three sources as
458 - * rest_authorization_middleware().
370 + * no WP_REST_Request. Accepts Authorization: Bearer and X-API-Key headers,
371 + * matching rest_authorization_middleware().
459 372 *
460 373 * @return bool True only when a presented key matches the configured one.
461 374 */
462 375 protected function request_has_valid_api_key()
@@ -479,13 +392,8 @@
479 392 if ($api_key === '' && !empty($_SERVER['HTTP_X_API_KEY'])) {
480 393 $api_key = sanitize_text_field(wp_unslash($_SERVER['HTTP_X_API_KEY']));
481 394 }
482 395
483 - // Fallback: ?apikey= query param (deprecated).
484 - if ($api_key === '' && !empty($_GET['apikey'])) {
485 - $api_key = sanitize_text_field(wp_unslash($_GET['apikey']));
486 - }
487 -
488 396 if ($api_key === '') {
489 397 return false;
490 398 }
491 399
@@ -637,9 +545,9 @@
637 545 array(
638 546 array(
639 547 'methods' => 'GET',
640 548 'callback' => function () {
641 - $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
642 550
643 551 if ($getPostID==0) {
644 552 $response = false;
645 553 }else{
@@ -1168,9 +1076,9 @@
1168 1076 }
1169 1077
1170 1078 public function get_errorlogs()
1171 1079 {
1172 - $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
1173 1081 if (!isset($get_data['limit']))
1174 1082 return false;
1175 1083 $limit = sanitize_text_field($get_data['limit']) ?? null;
1176 1084
@@ -1358,9 +1266,9 @@
1358 1266 }
1359 1267 } catch (Throwable $e) {
1360 1268 // Fallback to DOMDocument if Dom\HTMLDocument fails
1361 1269 if (defined('WP_DEBUG_LOG') && WP_DEBUG_LOG) {
1362 - 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
1363 1271 }
1364 1272 $dom = new DOMDocument();
1365 1273 libxml_use_internal_errors(true);
1366 1274 $encoded_content = $this->encode_numeric_entities($content);
@@ -1560,9 +1468,9 @@
1560 1468 $image_id = attachment_url_to_postid($node->getAttribute('src') );
1561 1469 $image_alt = get_post_meta($image_id, '_wp_attachment_image_alt', TRUE);
1562 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]';
1563 1471
1564 - 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
1565 1473
1566 1474 }
1567 1475 }elseif($node->nodeName === 'iframe'){
1568 1476 $result= '[et_pb_code _builder_version="'.ET_BUILDER_VERSION.'" _module_preset="default" global_colors_info="{}"]'.$node->ownerDocument->saveHTML($node).'[/et_pb_code]' ;
@@ -1690,67 +1598,8 @@
1690 1598 return $attachment_id;
1691 1599 }
1692 1600
1693 1601 /**
1694 - * Index a post with Google Indexing API
1695 - *
1696 - * @param int $post_id WordPress post ID
1697 - * @param string $post_type WordPress post type (post, page, etc.)
1698 - * @param string $post_status WordPress post status (publish, draft, etc.)
1699 - */
1700 - public function metasync_google_index_post($post_id, $post_type, $post_status)
1701 - {
1702 - // Only index published posts/pages
1703 - if ($post_status !== 'publish') {
1704 - return;
1705 - }
1706 -
1707 - // Only index posts and pages (can be extended as needed)
1708 - $allowed_post_types = array('post', 'page');
1709 - if (!in_array($post_type, $allowed_post_types)) {
1710 - return;
1711 - }
1712 -
1713 - try {
1714 - // Load Google Index functionality if not already loaded
1715 - if (!function_exists('google_index_post')) {
1716 - $google_index_path = plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php';
1717 - if (file_exists($google_index_path)) {
1718 - require_once $google_index_path;
1719 - } else {
1720 - error_log('MetaSync Google Index: google-index-init.php not found at ' . $google_index_path);
1721 - return;
1722 - }
1723 - }
1724 -
1725 - // Attempt to index the post with Google
1726 - if (function_exists('google_index_post')) {
1727 - $result = google_index_post($post_id, $post_type, 'update');
1728 -
1729 - if (isset($result['success']) && $result['success']) {
1730 - error_log(sprintf(
1731 - 'MetaSync Google Index: Successfully indexed %s (ID: %d, Type: %s)',
1732 - get_the_title($post_id),
1733 - $post_id,
1734 - $post_type
1735 - ));
1736 - } else {
1737 - error_log(sprintf(
1738 - 'MetaSync Google Index: Failed to index %s (ID: %d, Type: %s) - %s',
1739 - get_the_title($post_id),
1740 - $post_id,
1741 - $post_type,
1742 - isset($result['error']['message']) ? $result['error']['message'] : 'Unknown error'
1743 - ));
1744 - }
1745 - }
1746 - } catch (Exception $e) {
1747 - // Log any exceptions but don't break the main functionality
1748 - error_log('MetaSync Google Index Exception: ' . $e->getMessage());
1749 - }
1750 - }
1751 -
1752 - /**
1753 1602 * Remove a leading <h1> from post content when it duplicates the post title.
1754 1603 *
1755 1604 * Themes that render post_title as an H1 produce a duplicate heading when the
1756 1605 * synced body also opens with the title. Counterpart of the guard, which
@@ -1771,9 +1620,9 @@
1771 1620 return $content;
1772 1621 }
1773 1622
1774 1623 # Entity-decode both sides so "Mobility &amp; Recovery" matches "Mobility & Recovery"
1775 - $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));
1776 1625 $title_text = trim(html_entity_decode((string) $title, ENT_QUOTES | ENT_HTML5));
1777 1626 if ($h1_text === '' || strcasecmp($h1_text, $title_text) !== 0) {
1778 1627 return $content;
1779 1628 }
@@ -1930,8 +1779,65 @@
1930 1779 }
1931 1780 return false;
1932 1781 }
1933 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 +
1934 1840 public function create_item($request)
1935 1841 {
1936 1842 // Checking for type of object for response type
1937 1843 $array_response = true;
@@ -2042,13 +1948,13 @@
2042 1948 $new_post['post_name'] = sanitize_text_field($item['permalink']);
2043 1949 }
2044 1950
2045 1951 if (isset($item['post_date']) && !empty($item['post_date'])) {
2046 - $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'];
2047 1953 if (!$is_valid_date) {
2048 1954 return new WP_Error(
2049 1955 'rest_post_invalid_date',
2050 - esc_html__('Post date is not valid'),
1956 + esc_html__('Post date is not valid', 'metasync'),
2051 1957 array('status' => 400)
2052 1958 );
2053 1959 }
2054 1960
@@ -2081,20 +1987,16 @@
2081 1987 # $getPostID_byURL = @get_page_by_path($item['permalink'], OBJECT, $new_post['post_type'])->ID;
2082 1988 # Fix to avoid PHP error if the get_page_by_path returns null
2083 1989 $getPostID_byURL = @get_page_by_path($item['permalink'], OBJECT, $new_post['post_type']);
2084 1990 $getPostID_byURL = $getPostID_byURL ? $getPostID_byURL->ID : null;
1991 + $matched_by_title = false;
2085 1992 if ($getPostID_byURL == NULL) {
2086 1993 // check if the post_title is set and not empty when called by otto_ai_page
2087 1994 if(isset($new_post['post_title']) && $new_post['post_title']!==''){
2088 - $getPostID_byURL = new WP_Query(
2089 - array(
2090 - 'post_type' => $new_post['post_type'],
2091 - 'title' => $new_post['post_title']
2092 - )
2093 - );
2094 - $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 + }
2095 1998 }
2096 - }
2097 1999
2098 2000 // Allow HTML code for landing page
2099 2001 if (isset($item['is_landing_page']) && $item['is_landing_page'] == true) {
2100 2002 kses_remove_filters();
@@ -2119,11 +2021,17 @@
2119 2021 update_post_meta($post_id, 'metasync_post', 'yes');
2120 2022 }
2121 2023
2122 2024 } else {
2123 - $new_post['ID'] = $post_id = $getPostID_byURL;
2124 - wp_update_post($new_post);
2125 - 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);
2126 2034 $permalink = get_permalink($post_id);
2127 2035 }
2128 2036
2129 2037 if (isset($item['is_landing_page']) && $item['is_landing_page'] == true) {
@@ -2165,13 +2073,17 @@
2165 2073 $post_meta['searchatlas_embed_bottom'] = $item['searchatlas_embed_bottom'];
2166 2074 }
2167 2075
2168 2076 // Add custom fields to posts and pages
2169 - foreach ($post_meta as $key => $value) {
2170 - // if (!empty($value) && !is_null($value)) {
2171 - add_post_meta($post_id, $key, $value, true);
2172 - // }
2173 - }
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 + );
2174 2086
2175 2087
2176 2088 $attachment_id = '';
2177 2089 if (isset($item['hero_image_url']) && !empty($item['hero_image_url'])) {
@@ -2189,8 +2101,14 @@
2189 2101 $redirection['url'] = sanitize_url($item['redirection_url']);
2190 2102 }
2191 2103 if (!empty($redirection)) {
2192 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 + }
2193 2111 }
2194 2112
2195 2113 $post_cattegories = [];
2196 2114 # if ($new_post['post_type'] === 'post' && is_array(@$item['post_categories'])) {
@@ -2246,17 +2164,16 @@
2246 2164 'action' => strtolower($action)
2247 2165 ])
2248 2166 ]);
2249 2167
2250 - # 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.
2251 2170 try {
2252 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']));
2253 2173 } catch (Exception $e) {
2254 - 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
2255 2175 }
2256 -
2257 - // Google Indexing Integration
2258 - $this->metasync_google_index_post($post_id, $new_post['post_type'], $new_post['post_status']);
2259 2176 }
2260 2177
2261 2178 # For standard synced posts, learn (once per post-type) whether the
2262 2179 # theme renders the title, so future syncs prepend only when genuinely needed.
@@ -2265,9 +2182,9 @@
2265 2182 && $new_post['post_status'] === 'publish') {
2266 2183 try {
2267 2184 $this->record_theme_title_verdict($post_id, $permalink, $new_post['post_title'], $new_post['post_type']);
2268 2185 } catch (Exception $e) {
2269 - error_log('MetaSync 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
2270 2187 }
2271 2188 }
2272 2189
2273 2190 $respCreatePosts[$index] = array_merge($new_post, $post_meta);
@@ -2312,9 +2229,9 @@
2312 2229 }
2313 2230
2314 2231 public function delete_item()
2315 2232 {
2316 - $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
2317 2234 if (!isset($get_data['ID']))
2318 2235 return false;
2319 2236
2320 2237 $post_id = sanitize_text_field($get_data['ID']) ?? null;
@@ -2320,18 +2237,18 @@
2320 2237 $post_id = sanitize_text_field($get_data['ID']) ?? null;
2321 2238 $post = get_post($post_id);
2322 2239 if ($post) {
2323 2240 wp_delete_post($post_id);
2241 + // HTTP 204 requires no body for response, so no message is set.
2324 2242 return new WP_Error(
2325 2243 'rest_post_delete_success',
2326 - esc_html__(''),
2327 - // HTTP 204 requires no body for response
2244 + '',
2328 2245 array('status' => 204)
2329 2246 );
2330 2247 }
2331 2248 return new WP_Error(
2332 2249 'rest_post_delete_fail',
2333 - esc_html__('No post found in the database with requested ID.'),
2250 + esc_html__('No post found in the database with requested ID.', 'metasync'),
2334 2251 array('status' => 400)
2335 2252 );
2336 2253 }
2337 2254
@@ -2336,9 +2253,9 @@
2336 2253 }
2337 2254
2338 2255 public function get_items($request)
2339 2256 {
2340 - $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
2341 2258
2342 2259 # let us check if request send post id and is valid int
2343 2260 if(isset($get_data['post_id']) AND intval($get_data['post_id']) > 0){
2344 2261
@@ -2678,9 +2595,9 @@
2678 2595 $post_data = get_post($post_id);
2679 2596 if (!$post_data) {
2680 2597 return new WP_Error(
2681 2598 'rest_post_update_fail',
2682 - esc_html__('No post found in the database with requested ID.'),
2599 + esc_html__('No post found in the database with requested ID.', 'metasync'),
2683 2600 array('status' => 400)
2684 2601 );
2685 2602 }
2686 2603
@@ -2801,9 +2718,8 @@
2801 2718 }
2802 2719
2803 2720 if (isset($post['post_status']) && !empty($post['post_status'])) {
2804 2721 $update_params['post_status'] = $post['post_status'] ? sanitize_text_field($post['post_status']) : 'publish';
2805 - $permalink = get_permalink($post_id);
2806 2722 }
2807 2723 if (isset($post['permalink']) || !empty($post['permalink'])) {
2808 2724 $update_params['post_name'] = sanitize_text_field($post['permalink']);
2809 2725 }
@@ -2818,40 +2734,8 @@
2818 2734 );
2819 2735 }
2820 2736
2821 2737
2822 - if (isset($post['post_date']) && !empty($post['post_date']) && false) {
2823 - $is_valid_date = date('Y-m-d', strtotime($post['post_date'])) == $post['post_date'];
2824 - if (!$is_valid_date) {
2825 - return new WP_Error(
2826 - 'rest_post_invalid_date',
2827 - esc_html__('Post date is not valid'),
2828 - array('status' => 400)
2829 - );
2830 - }
2831 -
2832 - $date_limit_str = strtotime(date('Y-m-d') . '-2 month');
2833 - $post_date_str = strtotime($post['post_date']);
2834 -
2835 - if ($date_limit_str >= $post_date_str) {
2836 - $newDate = date('Y-m-d', strtotime('-2 month'));
2837 - return new WP_Error(
2838 - 'rest_post_greater_date',
2839 - esc_html__("Post date should be greater then " . $newDate),
2840 - array('status' => 400)
2841 - );
2842 - }
2843 -
2844 - if ($post_date_str > strtotime(date('Y-m-d'))) {
2845 - return new WP_Error(
2846 - 'rest_post_greater_date',
2847 - esc_html__('Post date should be less then Today'),
2848 - array('status' => 400)
2849 - );
2850 - }
2851 - $update_params['post_date'] = sanitize_text_field($post['post_date'] . date(' h:i:s'));
2852 - }
2853 -
2854 2738 $post_cattegories = [];
2855 2739 # if ($post_data && $post_data->post_type === 'post' && is_array(@$post['post_categories'])) {
2856 2740
2857 2741 # fixed Undefined array key issue
@@ -2874,12 +2758,13 @@
2874 2758 $attachment_id = $this->metasync_handle_hero_image($post_id, $post['hero_image_url'], $post['hero_image_alt_text']);
2875 2759 }
2876 2760
2877 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);
2878 2765 if(isset($content['elementor_meta_data'])){
2879 - foreach ($content['elementor_meta_data'] as $key => $value) {
2880 - update_post_meta($post_id, $key, $value);
2881 - }
2766 + $this->metasync_apply_builder_meta($post_id, $content['elementor_meta_data']);
2882 2767 if ( did_action( 'elementor/loaded' ) ) {
2883 2768 // Clear Elementor cache for the specified post ID
2884 2769 \Elementor\Plugin::instance()->files_manager->clear_cache();
2885 2770 }
@@ -2894,8 +2779,15 @@
2894 2779 delete_post_meta($post_id, '_elementor_page_assets');
2895 2780 delete_post_meta($post_id, '_elementor_page_settings');
2896 2781 }
2897 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 +
2898 2790 $redirection = array();
2899 2791 if (!empty($post['redirection_enable']) && !is_null($post['redirection_enable'])) {
2900 2792 $redirection['enable'] = sanitize_text_field($post['redirection_enable']);
2901 2793 }
@@ -2906,8 +2798,14 @@
2906 2798 $redirection['url'] = sanitize_url($post['redirection_url']);
2907 2799 }
2908 2800 if (!empty($redirection)) {
2909 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 + }
2910 2808 }
2911 2809
2912 2810
2913 2811 $post_revisions = wp_get_post_revisions($post_id);
@@ -2945,14 +2843,9 @@
2945 2843
2946 2844 }
2947 2845 # update the content
2948 2846 // add and update the post meta
2949 - foreach ($post_meta_data as $key => $value) {
2950 - // if (!empty($value) && !is_null($value)) {
2951 -
2952 - update_post_meta($post_id, $key, $value);
2953 - //
2954 - }
2847 + $this->metasync_apply_builder_meta($post_id, $post_meta_data);
2955 2848 #check if the elementor plugin is active
2956 2849 if ( did_action( 'elementor/loaded' ) ) {
2957 2850 # Clear Elementor cache for the specified post ID
2958 2851 \Elementor\Plugin::instance()->files_manager->clear_cache();
@@ -2985,13 +2878,14 @@
2985 2878 'updated_fields' => array_keys($update_params)
2986 2879 ])
2987 2880 ]);
2988 2881
2989 - # Track Content Genius event in GA4
2882 + # Track Content Genius event in GA4 and PostHog
2990 2883 try {
2991 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']));
2992 2886 } catch (Exception $e) {
2993 - 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
2994 2888 }
2995 2889
2996 2890 # Learn (once per post-type) whether the theme renders the title,
2997 2891 # so future syncs prepend only when the theme genuinely omits it.
@@ -2999,9 +2893,9 @@
2999 2893 && $sync_status === 'published') {
3000 2894 try {
3001 2895 $this->record_theme_title_verdict($post_id, ($permalink ?? get_permalink($post_id)), $post_title, $post_type);
3002 2896 } catch (Exception $e) {
3003 - error_log('MetaSync 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
3004 2898 }
3005 2899 }
3006 2900 }
3007 2901
@@ -3098,20 +2992,9 @@
3098 2992 if($isOttoAiPage){
3099 2993 // Change the page template from default to Metasync Template
3100 2994 $pageTemplate = Metasync_Template::TEMPLATE_NAME;
3101 2995 }
3102 - if ($pageTemplate !== 'default') {
3103 - update_post_meta($post_id, '_wp_page_template', $pageTemplate);
3104 - } else {
3105 - // Clear stale templates that conflict with the active page builder.
3106 - // e.g. metasync-blank from a previous OTTO sync, or elementor_canvas
3107 - // written by the HTML-to-builder converter on an Oxygen site.
3108 - $current = get_post_meta($post_id, '_wp_page_template', true);
3109 - $stale_templates = array(Metasync_Template::TEMPLATE_NAME, 'elementor_canvas', 'elementor_header_footer');
3110 - if (in_array($current, $stale_templates, true)) {
3111 - delete_post_meta($post_id, '_wp_page_template');
3112 - }
3113 - }
2996 + $this->metasync_write_page_template($post_id, $pageTemplate);
3114 2997 }
3115 2998
3116 2999 return rest_ensure_response($createPages);
3117 3000 }
@@ -3134,9 +3017,9 @@
3134 3017 $post_data = get_post($payload['post_id']);
3135 3018 if(!isset($post_data->post_type)){
3136 3019 return new WP_Error(
3137 3020 'rest_page_type_fail',
3138 - esc_html__('No page found in the database with requested ID.'),
3021 + esc_html__('No page found in the database with requested ID.', 'metasync'),
3139 3022 array('status' => 400)
3140 3023 );
3141 3024 }
3142 3025 if ($post_data->post_type !== 'page') {
@@ -3191,20 +3074,225 @@
3191 3074 if($isOttoAiPage){
3192 3075 // Change the page template from default to Metasync Template
3193 3076 $pageTemplate = Metasync_Template::TEMPLATE_NAME;
3194 3077 }
3195 - if ($pageTemplate !== 'default') {
3196 - 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);
3197 3291 } else {
3198 - // Clear stale templates that conflict with the active page builder.
3199 - $current = get_post_meta($post_id, '_wp_page_template', true);
3200 - $stale_templates = array(Metasync_Template::TEMPLATE_NAME, 'elementor_canvas', 'elementor_header_footer');
3201 - if (in_array($current, $stale_templates, true)) {
3202 - delete_post_meta($post_id, '_wp_page_template');
3203 - }
3292 + add_post_meta($post_id, $key, $value, true);
3204 3293 }
3205 3294 }
3206 - return rest_ensure_response($updatePages->data);
3207 3295 }
3208 3296
3209 3297 public function delete_page()
3210 3298 {
@@ -3230,8 +3318,17 @@
3230 3318 }
3231 3319
3232 3320 public function linkgraph_login()
3233 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 +
3234 3331 $post_data = metasync_sanitize_input_array($_POST);
3235 3332 $payload = array(
3236 3333 'username' => wp_unslash(sanitize_email($post_data['username'])),
3237 3334 'password' => wp_unslash(sanitize_text_field($post_data['password']))
@@ -3248,9 +3345,9 @@
3248 3345 ));
3249 3346
3250 3347 # Error handling for timeout or connection failures
3251 3348 if (is_wp_error($response)) {
3252 - 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
3253 3350 wp_send_json_error(array('message' => 'Token API request failed'));
3254 3351 wp_die();
3255 3352 }
3256 3353
@@ -3384,9 +3481,9 @@
3384 3481 $option_name = '_transient_metasync_sa_connect_active_' . $nonce_token;
3385 3482 $timeout_name = '_transient_timeout_metasync_sa_connect_active_' . $nonce_token;
3386 3483
3387 3484 // Check expiry first
3388 - $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
3389 3486 $wpdb->prepare(
3390 3487 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
3391 3488 $timeout_name
3392 3489 )
@@ -3393,14 +3490,14 @@
3393 3490 );
3394 3491
3395 3492 if ($timeout && (int) $timeout < time()) {
3396 3493 // Expired — clean up orphan rows
3397 - $wpdb->delete($wpdb->options, array('option_name' => $option_name));
3398 - $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
3399 3496 return false;
3400 3497 }
3401 3498
3402 - $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
3403 3500 $wpdb->prepare(
3404 3501 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
3405 3502 $option_name
3406 3503 )
@@ -3424,9 +3521,9 @@
3424 3521 $option_name = '_transient_' . $transient_key;
3425 3522 $timeout_name = '_transient_timeout_' . $transient_key;
3426 3523
3427 3524 // Check expiry first
3428 - $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
3429 3526 $wpdb->prepare(
3430 3527 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
3431 3528 $timeout_name
3432 3529 )
@@ -3432,14 +3529,14 @@
3432 3529 )
3433 3530 );
3434 3531
3435 3532 if ($timeout && (int) $timeout < time()) {
3436 - $wpdb->delete($wpdb->options, array('option_name' => $option_name));
3437 - $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
3438 3535 return false;
3439 3536 }
3440 3537
3441 - $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
3442 3539 $wpdb->prepare(
3443 3540 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
3444 3541 $option_name
3445 3542 )
@@ -3466,10 +3563,10 @@
3466 3563 {
3467 3564 delete_transient($transient_name);
3468 3565 if (wp_using_ext_object_cache()) {
3469 3566 global $wpdb;
3470 - $wpdb->delete($wpdb->options, array('option_name' => '_transient_' . $transient_name));
3471 - $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
3472 3569 }
3473 3570 }
3474 3571
3475 3572 /**
@@ -3486,14 +3583,14 @@
3486 3583 {
3487 3584 set_transient($transient_name, $value, $expiration);
3488 3585 if (wp_using_ext_object_cache()) {
3489 3586 global $wpdb;
3490 - $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
3491 3588 'option_name' => '_transient_' . $transient_name,
3492 3589 'option_value' => maybe_serialize($value),
3493 3590 'autoload' => 'no',
3494 3591 ));
3495 - $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
3496 3593 'option_name' => '_transient_timeout_' . $transient_name,
3497 3594 'option_value' => time() + $expiration,
3498 3595 'autoload' => 'no',
3499 3596 ));
@@ -3870,14 +3967,18 @@
3870 3967 $validation_errors['api_key'] = 'API key contains invalid characters. Only alphanumeric, dash, underscore, and dot allowed';
3871 3968 }
3872 3969
3873 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.
3874 3975 if (empty($uuid)) {
3875 3976 $validation_errors['uuid'] = 'UUID is required';
3876 3977 } elseif (!is_string($uuid)) {
3877 3978 $validation_errors['uuid'] = 'UUID must be a string';
3878 - } elseif (strlen($uuid) > 100) {
3879 - $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';
3880 3981 }
3881 3982
3882 3983 // Validate status_code
3883 3984 if (!is_numeric($status_code)) {
@@ -3930,9 +4031,9 @@
3930 4031 } elseif (strlen($whitelabel_domain) > 255) {
3931 4032 $validation_errors['whitelabel_domain'] = 'Whitelabel domain must not exceed 255 characters';
3932 4033 } elseif (!filter_var($whitelabel_domain, FILTER_VALIDATE_URL)) {
3933 4034 $validation_errors['whitelabel_domain'] = 'Whitelabel domain must be a valid URL';
3934 - } 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'])) {
3935 4036 $validation_errors['whitelabel_domain'] = 'Whitelabel domain must use http or https protocol';
3936 4037 }
3937 4038 }
3938 4039
@@ -3945,9 +4046,9 @@
3945 4046 $validation_errors['whitelabel_logo'] = 'Whitelabel logo URL must not exceed 500 characters';
3946 4047 } else {
3947 4048 // If URL is invalid, we'll clear it later but not fail the POST
3948 4049 if (!filter_var($whitelabel_logo, FILTER_VALIDATE_URL) ||
3949 - !in_array(parse_url($whitelabel_logo, PHP_URL_SCHEME), ['http', 'https'])) {
4050 + !in_array(wp_parse_url($whitelabel_logo, PHP_URL_SCHEME), ['http', 'https'])) {
3950 4051 // Don't add to validation_errors - let POST succeed but clear the field
3951 4052 }
3952 4053 }
3953 4054 }
@@ -4040,9 +4141,20 @@
4040 4141 // Only update the API key in settings if status_code is 200 (success)
4041 4142 if ($status_code === 200) {
4042 4143 // Encrypt the API key at rest; never store it in cleartext.
4043 4144 $options['general']['searchatlas_api_key'] = Metasync::encrypt_api_key($new_api_key);
4044 - $options['general']['otto_pixel_uuid'] = $new_otto_uuid;
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 + }
4045 4157 // Note: OTTO SSR is always enabled by default, no need to set
4046 4158
4047 4159 // Granular otto_config_status: record when SSO completed (ISO 8601 UTC)
4048 4160 $options['general']['sso_completed_at'] = gmdate('Y-m-d\TH:i:s\Z');
@@ -4061,35 +4173,43 @@
4061 4173
4062 4174 } else {
4063 4175 }
4064 4176
4065 - // 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.
4066 4197 if ($is_whitelabel) {
4067 - // whitelabel_company_name → Plugin Name (general plugin branding)
4068 - if (!empty($whitelabel_company_name)) {
4069 - $options['general']['white_label_plugin_name'] = $whitelabel_company_name;
4198 + if (!isset($options['whitelabel'])) {
4199 + $options['whitelabel'] = array();
4070 4200 }
4071 -
4072 - // whitelabel_otto → OTTO Features naming (separate from plugin name)
4073 - if (!empty($whitelabel_otto)) {
4074 - $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;
4075 4205 }
4076 - } else {
4077 - // Clear whitelabel fields when not whitelabel
4078 - unset($options['general']['white_label_plugin_name']);
4079 - unset($options['general']['whitelabel_otto_name']);
4206 + if (!empty($whitelabel_logo)) {
4207 + $options['whitelabel']['logo'] = $whitelabel_logo;
4208 + }
4209 + $options['whitelabel']['updated_at'] = time();
4080 4210 }
4081 4211
4082 - // Store whitelabel settings (hidden from UI but accessible to plugin logic)
4083 - if (!isset($options['whitelabel'])) {
4084 - $options['whitelabel'] = array();
4085 - }
4086 -
4087 - $options['whitelabel']['is_whitelabel'] = $is_whitelabel;
4088 - $options['whitelabel']['domain'] = $whitelabel_domain;
4089 - $options['whitelabel']['logo'] = $whitelabel_logo;
4090 - $options['whitelabel']['updated_at'] = time();
4091 -
4092 4212 // Log whitelabel configuration
4093 4213 if ($is_whitelabel) {
4094 4214 $log_parts = array('Whitelabel mode enabled');
4095 4215 if (!empty($whitelabel_domain)) {
@@ -4110,9 +4230,9 @@
4110 4230 $save_result = Metasync::set_option($options);
4111 4231 Metasync::invalidate_api_key_cache();
4112 4232
4113 4233 if (!$save_result) {
4114 - 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
4115 4235 } else {
4116 4236 if ($status_code === 200) {
4117 4237 // Option 1: Set heartbeat cache and last-known to CONNECTED so dashboard shows iframe immediately.
4118 4238 // If the immediate heartbeat then fails (e.g. backoff), Option 2 prevents overwriting this to DISCONNECTED.
@@ -4124,9 +4244,9 @@
4124 4244 );
4125 4245 set_transient('metasync_heartbeat_status_cache', $cache_data, 300);
4126 4246 update_option('metasync_last_known_connection_state', true);
4127 4247 do_action('metasync_heartbeat_state_key_pending'); // PR3: burst mode
4128 - $this->trigger_immediate_heartbeat_after_sa_connect();
4248 + $this->trigger_immediate_heartbeat_after_sa_connect($token);
4129 4249 }
4130 4250 }
4131 4251
4132 4252 return true;
@@ -4131,9 +4251,9 @@
4131 4251
4132 4252 return true;
4133 4253
4134 4254 } catch (Exception $e) {
4135 - 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
4136 4256 return false;
4137 4257 }
4138 4258 }
4139 4259
@@ -4145,9 +4265,9 @@
4145 4265 {
4146 4266 global $wpdb;
4147 4267
4148 4268 // Clear all JWT token transients
4149 - $deleted = $wpdb->query(
4269 + $deleted = $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
4150 4270 $wpdb->prepare(
4151 4271 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
4152 4272 '_transient_metasync_jwt_token_%'
4153 4273 )
@@ -4153,9 +4273,9 @@
4153 4273 )
4154 4274 );
4155 4275
4156 4276 // Also clear timeout transients
4157 - $wpdb->query(
4277 + $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
4158 4278 $wpdb->prepare(
4159 4279 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
4160 4280 '_transient_timeout_metasync_jwt_token_%'
4161 4281 )
@@ -4166,21 +4286,28 @@
4166 4286
4167 4287 /**
4168 4288 * Trigger immediate heartbeat check after successful Search Atlas connect authentication
4169 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.
4170 4292 */
4171 - private function trigger_immediate_heartbeat_after_sa_connect()
4293 + private function trigger_immediate_heartbeat_after_sa_connect($token)
4172 4294 {
4173 4295 try {
4174 4296 // Use WordPress action system to trigger immediate heartbeat check
4175 4297 // This is more reliable than trying to access admin class directly
4176 4298 do_action('metasync_trigger_immediate_heartbeat', 'Search Atlas Connect - API key and UUID retrieved');
4177 -
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 +
4178 4305 // Also ensure heartbeat cron is scheduled now that we have an API key
4179 4306 do_action('metasync_ensure_heartbeat_cron_scheduled');
4180 4307
4181 4308 } catch (Exception $e) {
4182 - 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
4183 4310 }
4184 4311 }
4185 4312
4186 4313 public function get_item_schema()
@@ -4198,15 +4325,15 @@
4198 4325 'type' => 'object',
4199 4326 // In JSON Schema you can specify object properties in the properties attribute.
4200 4327 'properties' => array(
4201 4328 'id' => array(
4202 - 'description' => esc_html__('Unique identifier for the object.', 'my-textdomain'),
4329 + 'description' => esc_html__('Unique identifier for the object.', 'metasync'),
4203 4330 'type' => 'integer',
4204 4331 'context' => array('view', 'edit', 'embed'),
4205 4332 'readonly' => true,
4206 4333 ),
4207 4334 'content' => array(
4208 - 'description' => esc_html__('The content for the object.', 'my-textdomain'),
4335 + 'description' => esc_html__('The content for the object.', 'metasync'),
4209 4336 'type' => 'string',
4210 4337 ),
4211 4338 ),
4212 4339 );
@@ -4297,11 +4424,11 @@
4297 4424
4298 4425 # Try alternative parameter methods
4299 4426 $body_params = $request->get_body_params();
4300 4427 $key_param = $request->get_param('key');
4301 - $post_key = $_POST['key'] ?? null;
4302 - $request_key = $_REQUEST['key'] ?? null;
4303 - $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
4304 4431
4305 4432 # Try to get key from multiple sources
4306 4433 $key_value = null;
4307 4434
@@ -4393,9 +4520,15 @@
4393 4520 # delete it so it stops shadowing the virtual route and let WordPress serve
4394 4521 # the key from PHP instead.
4395 4522 $serving = 'virtual';
4396 4523
4397 - if (is_writable($wp_root) && @file_put_contents($file_path, $safe_key) !== false) {
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) {
4398 4531 # Cache-buster avoids a stale 404 cached by a CDN/nginx for this path.
4399 4532 $probe = wp_remote_get(
4400 4533 home_url('/' . $safe_key . '.txt?_msverify=' . time()),
4401 4534 array('timeout' => 10, 'sslverify' => false)
@@ -4407,9 +4540,9 @@
4407 4540 $serving = 'physical';
4408 4541 } else {
4409 4542 # 403 / 404 / wrong body — a present file shadows the virtual route,
4410 4543 # so remove it and let WordPress serve the key instead.
4411 - @unlink($file_path);
4544 + @wp_delete_file($file_path);
4412 4545 $serving = 'virtual';
4413 4546 }
4414 4547 }
4415 4548