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 +211 -232 2.6.26 → 2.7.2 View file →
@@ -75,16 +75,10 @@
75 75 );
76 76 $this->common = new Metasync_Common();
77 77 // get all options
78 78 $this->metasync_option_data = Metasync::get_option('general');
79 - $this->init_ajax_hooks();
80 79 }
81 80
82 - public function init_ajax_hooks()
83 - {
84 - add_action('wp_ajax_metasync_otto_ajax_action', array($this,'metasyn_otto_ajax'));
85 - }
86 -
87 81 /**
88 82 * Encode non-ASCII characters in a UTF-8 string as numeric HTML entities.
89 83 *
90 84 * Uses mb_encode_numericentity when the mbstring extension is loaded;
@@ -170,86 +164,9 @@
170 164 }
171 165 return $posts;
172 166 }
173 167
174 - public function metasyn_otto_ajax() {
175 - // Check nonce for security
176 - if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'otto_nonce')) {
177 - wp_send_json_error('Invalid nonce');
178 - wp_die();
179 - }
180 - $post_id = sanitize_text_field($_POST['post_id']);
181 - $current_url = get_permalink($post_id);
182 -
183 - $header_html = get_post_meta($post_id, '_otto_header_html_json', true);
184 168
185 - // Example API call using wp_remote_get()
186 -
187 -
188 - if (is_wp_error($header_html)) {
189 - wp_send_json_error('API call failed');
190 - } else {
191 - wp_send_json_success(json_decode($header_html, true));
192 - }
193 -
194 - wp_die(); // Always terminate after an AJAX call
195 - }
196 -
197 - public function otto_header_data() {
198 - global $post;
199 -
200 - // Get the current post ID
201 - $post_id = $post->ID;
202 -
203 - // Get the current time and the last update time from post meta
204 - $current_time = current_time('timestamp');
205 - $last_update_time = get_post_meta($post_id, '_otto_last_update_time', true);
206 -
207 - // Set the interval for 24 hours (in seconds)
208 - $interval = 24 * 60 * 60;
209 -
210 - // Check if the last update time is set or if 24 hours have passed
211 - if (!$last_update_time || ($current_time - $last_update_time) >= $interval) {
212 - // Get the current URL
213 - $current_url = get_permalink($post_id);
214 -
215 - // Use endpoint manager to get the correct API URL
216 - $api_endpoint = class_exists('Metasync_Endpoint_Manager')
217 - ? Metasync_Endpoint_Manager::get_endpoint('OTTO_URL_DETAILS')
218 - : 'https://sa.searchatlas.com/api/v2/otto-url-details';
219 -
220 - // Call the API
221 - $response = wp_remote_get($api_endpoint . '/?url=' . urlencode($current_url));
222 -
223 - // Check if the API call was successful
224 - if (!is_wp_error($response)) {
225 - $body = wp_remote_retrieve_body($response);
226 - $data = json_decode($body, true); // Decode JSON into an associative array
227 -
228 - // Update the post meta with the new data and timestamp
229 - update_post_meta($post_id, '_otto_header_html', $data['header_html_insertion']);
230 - update_post_meta($post_id, '_otto_last_update_time', $current_time);
231 - }
232 - }
233 -
234 - // Get the saved HTML from post meta
235 - $header_html = get_post_meta($post_id, '_otto_header_html', true);
236 -
237 - // Display the HTML with security measures
238 - if ($header_html) {
239 - echo "<!-- Otto Start -->";
240 - // SECURITY FIX: Sanitize HTML to prevent XSS while allowing safe HTML
241 - echo wp_kses($header_html, array(
242 - 'style' => array(),
243 - 'link' => array('rel' => array(), 'href' => array(), 'type' => array()),
244 - 'meta' => array('name' => array(), 'content' => array(), 'property' => array()),
245 - 'script' => array('type' => array(), 'src' => array()),
246 - // Add other safe tags as needed
247 - ));
248 - echo "<!-- Otto End -->";
249 - }
250 - }
251 -
252 169 public function rest_authorization_middleware($request = null)
253 170 {
254 171 $api_key = '';
255 172
@@ -265,11 +182,11 @@
265 182 if (empty($api_key) && isset($_SERVER['HTTP_X_API_KEY'])) {
266 183 $api_key = sanitize_text_field(wp_unslash($_SERVER['HTTP_X_API_KEY']));
267 184 }
268 185
269 - // Fallback: ?apikey= query param (deprecated)
270 - if (empty($api_key) && isset($_GET['apikey'])) {
271 - $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
272 189 }
273 190
274 191 if (empty($api_key)) {
275 192 return false;
@@ -423,10 +340,10 @@
423 340 return '/' . ltrim((string) $GLOBALS['wp']->query_vars['rest_route'], '/');
424 341 }
425 342
426 343 // ?rest_route= form, used when pretty permalinks are disabled.
427 - if (!empty($_GET['rest_route'])) {
428 - 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
429 346 }
430 347
431 348 // Last resort: the request path with the /wp-json/ prefix stripped.
432 349 $uri = isset($_SERVER['REQUEST_URI']) ? (string) wp_unslash($_SERVER['REQUEST_URI']) : '';
@@ -433,9 +350,9 @@
433 350 if ($uri === '') {
434 351 return '';
435 352 }
436 353
437 - $path = (string) parse_url($uri, PHP_URL_PATH);
354 + $path = (string) wp_parse_url($uri, PHP_URL_PATH);
438 355 $url_prefix = function_exists('rest_get_url_prefix') ? rest_get_url_prefix() : 'wp-json';
439 356 $needle = '/' . trim((string) $url_prefix, '/') . '/';
440 357
441 358 $pos = strpos($path, $needle);
@@ -449,10 +366,10 @@
449 366 /**
450 367 * Whether the current request carries a valid MetaSync API key.
451 368 *
452 369 * Reads credentials from the request globals so it works in filters that get
453 - * no WP_REST_Request. Accepts the same three sources as
454 - * rest_authorization_middleware().
370 + * no WP_REST_Request. Accepts Authorization: Bearer and X-API-Key headers,
371 + * matching rest_authorization_middleware().
455 372 *
456 373 * @return bool True only when a presented key matches the configured one.
457 374 */
458 375 protected function request_has_valid_api_key()
@@ -475,13 +392,8 @@
475 392 if ($api_key === '' && !empty($_SERVER['HTTP_X_API_KEY'])) {
476 393 $api_key = sanitize_text_field(wp_unslash($_SERVER['HTTP_X_API_KEY']));
477 394 }
478 395
479 - // Fallback: ?apikey= query param (deprecated).
480 - if ($api_key === '' && !empty($_GET['apikey'])) {
481 - $api_key = sanitize_text_field(wp_unslash($_GET['apikey']));
482 - }
483 -
484 396 if ($api_key === '') {
485 397 return false;
486 398 }
487 399
@@ -633,9 +545,9 @@
633 545 array(
634 546 array(
635 547 'methods' => 'GET',
636 548 'callback' => function () {
637 - $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
638 550
639 551 if ($getPostID==0) {
640 552 $response = false;
641 553 }else{
@@ -1164,9 +1076,9 @@
1164 1076 }
1165 1077
1166 1078 public function get_errorlogs()
1167 1079 {
1168 - $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
1169 1081 if (!isset($get_data['limit']))
1170 1082 return false;
1171 1083 $limit = sanitize_text_field($get_data['limit']) ?? null;
1172 1084
@@ -1354,9 +1266,9 @@
1354 1266 }
1355 1267 } catch (Throwable $e) {
1356 1268 // Fallback to DOMDocument if Dom\HTMLDocument fails
1357 1269 if (defined('WP_DEBUG_LOG') && WP_DEBUG_LOG) {
1358 - 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
1359 1271 }
1360 1272 $dom = new DOMDocument();
1361 1273 libxml_use_internal_errors(true);
1362 1274 $encoded_content = $this->encode_numeric_entities($content);
@@ -1556,9 +1468,9 @@
1556 1468 $image_id = attachment_url_to_postid($node->getAttribute('src') );
1557 1469 $image_alt = get_post_meta($image_id, '_wp_attachment_image_alt', TRUE);
1558 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]';
1559 1471
1560 - 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
1561 1473
1562 1474 }
1563 1475 }elseif($node->nodeName === 'iframe'){
1564 1476 $result= '[et_pb_code _builder_version="'.ET_BUILDER_VERSION.'" _module_preset="default" global_colors_info="{}"]'.$node->ownerDocument->saveHTML($node).'[/et_pb_code]' ;
@@ -1708,9 +1620,9 @@
1708 1620 return $content;
1709 1621 }
1710 1622
1711 1623 # Entity-decode both sides so "Mobility &amp; Recovery" matches "Mobility & Recovery"
1712 - $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));
1713 1625 $title_text = trim(html_entity_decode((string) $title, ENT_QUOTES | ENT_HTML5));
1714 1626 if ($h1_text === '' || strcasecmp($h1_text, $title_text) !== 0) {
1715 1627 return $content;
1716 1628 }
@@ -1867,8 +1779,65 @@
1867 1779 }
1868 1780 return false;
1869 1781 }
1870 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 +
1871 1840 public function create_item($request)
1872 1841 {
1873 1842 // Checking for type of object for response type
1874 1843 $array_response = true;
@@ -1979,13 +1948,13 @@
1979 1948 $new_post['post_name'] = sanitize_text_field($item['permalink']);
1980 1949 }
1981 1950
1982 1951 if (isset($item['post_date']) && !empty($item['post_date'])) {
1983 - $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'];
1984 1953 if (!$is_valid_date) {
1985 1954 return new WP_Error(
1986 1955 'rest_post_invalid_date',
1987 - esc_html__('Post date is not valid'),
1956 + esc_html__('Post date is not valid', 'metasync'),
1988 1957 array('status' => 400)
1989 1958 );
1990 1959 }
1991 1960
@@ -2018,20 +1987,16 @@
2018 1987 # $getPostID_byURL = @get_page_by_path($item['permalink'], OBJECT, $new_post['post_type'])->ID;
2019 1988 # Fix to avoid PHP error if the get_page_by_path returns null
2020 1989 $getPostID_byURL = @get_page_by_path($item['permalink'], OBJECT, $new_post['post_type']);
2021 1990 $getPostID_byURL = $getPostID_byURL ? $getPostID_byURL->ID : null;
1991 + $matched_by_title = false;
2022 1992 if ($getPostID_byURL == NULL) {
2023 1993 // check if the post_title is set and not empty when called by otto_ai_page
2024 1994 if(isset($new_post['post_title']) && $new_post['post_title']!==''){
2025 - $getPostID_byURL = new WP_Query(
2026 - array(
2027 - 'post_type' => $new_post['post_type'],
2028 - 'title' => $new_post['post_title']
2029 - )
2030 - );
2031 - $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 + }
2032 1998 }
2033 - }
2034 1999
2035 2000 // Allow HTML code for landing page
2036 2001 if (isset($item['is_landing_page']) && $item['is_landing_page'] == true) {
2037 2002 kses_remove_filters();
@@ -2056,11 +2021,17 @@
2056 2021 update_post_meta($post_id, 'metasync_post', 'yes');
2057 2022 }
2058 2023
2059 2024 } else {
2060 - $new_post['ID'] = $post_id = $getPostID_byURL;
2061 - wp_update_post($new_post);
2062 - 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);
2063 2034 $permalink = get_permalink($post_id);
2064 2035 }
2065 2036
2066 2037 if (isset($item['is_landing_page']) && $item['is_landing_page'] == true) {
@@ -2193,13 +2164,15 @@
2193 2164 'action' => strtolower($action)
2194 2165 ])
2195 2166 ]);
2196 2167
2197 - # 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.
2198 2170 try {
2199 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']));
2200 2173 } catch (Exception $e) {
2201 - 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
2202 2175 }
2203 2176 }
2204 2177
2205 2178 # For standard synced posts, learn (once per post-type) whether the
@@ -2209,9 +2182,9 @@
2209 2182 && $new_post['post_status'] === 'publish') {
2210 2183 try {
2211 2184 $this->record_theme_title_verdict($post_id, $permalink, $new_post['post_title'], $new_post['post_type']);
2212 2185 } catch (Exception $e) {
2213 - 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
2214 2187 }
2215 2188 }
2216 2189
2217 2190 $respCreatePosts[$index] = array_merge($new_post, $post_meta);
@@ -2256,9 +2229,9 @@
2256 2229 }
2257 2230
2258 2231 public function delete_item()
2259 2232 {
2260 - $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
2261 2234 if (!isset($get_data['ID']))
2262 2235 return false;
2263 2236
2264 2237 $post_id = sanitize_text_field($get_data['ID']) ?? null;
@@ -2264,18 +2237,18 @@
2264 2237 $post_id = sanitize_text_field($get_data['ID']) ?? null;
2265 2238 $post = get_post($post_id);
2266 2239 if ($post) {
2267 2240 wp_delete_post($post_id);
2241 + // HTTP 204 requires no body for response, so no message is set.
2268 2242 return new WP_Error(
2269 2243 'rest_post_delete_success',
2270 - esc_html__(''),
2271 - // HTTP 204 requires no body for response
2244 + '',
2272 2245 array('status' => 204)
2273 2246 );
2274 2247 }
2275 2248 return new WP_Error(
2276 2249 'rest_post_delete_fail',
2277 - esc_html__('No post found in the database with requested ID.'),
2250 + esc_html__('No post found in the database with requested ID.', 'metasync'),
2278 2251 array('status' => 400)
2279 2252 );
2280 2253 }
2281 2254
@@ -2280,9 +2253,9 @@
2280 2253 }
2281 2254
2282 2255 public function get_items($request)
2283 2256 {
2284 - $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
2285 2258
2286 2259 # let us check if request send post id and is valid int
2287 2260 if(isset($get_data['post_id']) AND intval($get_data['post_id']) > 0){
2288 2261
@@ -2622,9 +2595,9 @@
2622 2595 $post_data = get_post($post_id);
2623 2596 if (!$post_data) {
2624 2597 return new WP_Error(
2625 2598 'rest_post_update_fail',
2626 - esc_html__('No post found in the database with requested ID.'),
2599 + esc_html__('No post found in the database with requested ID.', 'metasync'),
2627 2600 array('status' => 400)
2628 2601 );
2629 2602 }
2630 2603
@@ -2745,9 +2718,8 @@
2745 2718 }
2746 2719
2747 2720 if (isset($post['post_status']) && !empty($post['post_status'])) {
2748 2721 $update_params['post_status'] = $post['post_status'] ? sanitize_text_field($post['post_status']) : 'publish';
2749 - $permalink = get_permalink($post_id);
2750 2722 }
2751 2723 if (isset($post['permalink']) || !empty($post['permalink'])) {
2752 2724 $update_params['post_name'] = sanitize_text_field($post['permalink']);
2753 2725 }
@@ -2762,40 +2734,8 @@
2762 2734 );
2763 2735 }
2764 2736
2765 2737
2766 - if (isset($post['post_date']) && !empty($post['post_date']) && false) {
2767 - $is_valid_date = date('Y-m-d', strtotime($post['post_date'])) == $post['post_date'];
2768 - if (!$is_valid_date) {
2769 - return new WP_Error(
2770 - 'rest_post_invalid_date',
2771 - esc_html__('Post date is not valid'),
2772 - array('status' => 400)
2773 - );
2774 - }
2775 -
2776 - $date_limit_str = strtotime(date('Y-m-d') . '-2 month');
2777 - $post_date_str = strtotime($post['post_date']);
2778 -
2779 - if ($date_limit_str >= $post_date_str) {
2780 - $newDate = date('Y-m-d', strtotime('-2 month'));
2781 - return new WP_Error(
2782 - 'rest_post_greater_date',
2783 - esc_html__("Post date should be greater then " . $newDate),
2784 - array('status' => 400)
2785 - );
2786 - }
2787 -
2788 - if ($post_date_str > strtotime(date('Y-m-d'))) {
2789 - return new WP_Error(
2790 - 'rest_post_greater_date',
2791 - esc_html__('Post date should be less then Today'),
2792 - array('status' => 400)
2793 - );
2794 - }
2795 - $update_params['post_date'] = sanitize_text_field($post['post_date'] . date(' h:i:s'));
2796 - }
2797 -
2798 2738 $post_cattegories = [];
2799 2739 # if ($post_data && $post_data->post_type === 'post' && is_array(@$post['post_categories'])) {
2800 2740
2801 2741 # fixed Undefined array key issue
@@ -2818,8 +2758,11 @@
2818 2758 $attachment_id = $this->metasync_handle_hero_image($post_id, $post['hero_image_url'], $post['hero_image_alt_text']);
2819 2759 }
2820 2760
2821 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);
2822 2765 if(isset($content['elementor_meta_data'])){
2823 2766 $this->metasync_apply_builder_meta($post_id, $content['elementor_meta_data']);
2824 2767 if ( did_action( 'elementor/loaded' ) ) {
2825 2768 // Clear Elementor cache for the specified post ID
@@ -2935,13 +2878,14 @@
2935 2878 'updated_fields' => array_keys($update_params)
2936 2879 ])
2937 2880 ]);
2938 2881
2939 - # Track Content Genius event in GA4
2882 + # Track Content Genius event in GA4 and PostHog
2940 2883 try {
2941 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']));
2942 2886 } catch (Exception $e) {
2943 - 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
2944 2888 }
2945 2889
2946 2890 # Learn (once per post-type) whether the theme renders the title,
2947 2891 # so future syncs prepend only when the theme genuinely omits it.
@@ -2949,9 +2893,9 @@
2949 2893 && $sync_status === 'published') {
2950 2894 try {
2951 2895 $this->record_theme_title_verdict($post_id, ($permalink ?? get_permalink($post_id)), $post_title, $post_type);
2952 2896 } catch (Exception $e) {
2953 - 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
2954 2898 }
2955 2899 }
2956 2900 }
2957 2901
@@ -3073,9 +3017,9 @@
3073 3017 $post_data = get_post($payload['post_id']);
3074 3018 if(!isset($post_data->post_type)){
3075 3019 return new WP_Error(
3076 3020 'rest_page_type_fail',
3077 - esc_html__('No page found in the database with requested ID.'),
3021 + esc_html__('No page found in the database with requested ID.', 'metasync'),
3078 3022 array('status' => 400)
3079 3023 );
3080 3024 }
3081 3025 if ($post_data->post_type !== 'page') {
@@ -3168,20 +3112,19 @@
3168 3112
3169 3113 global $wpdb;
3170 3114
3171 3115 // Never propagate a template this plugin wrote itself.
3172 - $owned = $this->metasync_plugin_owned_templates();
3173 - $placeholders = implode(',', array_fill(0, count($owned), '%s'));
3116 + $owned = $this->metasync_plugin_owned_templates();
3174 3117
3175 - $rows = $wpdb->get_results(
3118 + $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint over the plugin custom table — no WordPress API exists
3176 3119 $wpdb->prepare(
3177 - "SELECT pm.meta_value AS tpl, COUNT(*) AS hits
3178 - FROM {$wpdb->postmeta} pm
3179 - INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
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
3180 3123 WHERE pm.meta_key = '_wp_page_template'
3181 3124 AND pm.meta_value <> ''
3182 3125 AND pm.meta_value <> 'default'
3183 - AND pm.meta_value NOT IN ($placeholders)
3126 + AND pm.meta_value NOT IN (" . implode(',', array_fill(0, count($owned), '%s')) . ")
3184 3127 AND p.post_type = %s
3185 3128 AND p.post_status = 'publish'
3186 3129 GROUP BY pm.meta_value
3187 3130 ORDER BY hits DESC",
@@ -3196,9 +3139,9 @@
3196 3139 // not just the ones that set a template. Counting only the latter
3197 3140 // would let a handful of outliers on a special template read as a
3198 3141 // site-wide convention on a site whose posts otherwise use the
3199 3142 // theme default.
3200 - $total = (int) $wpdb->get_var(
3143 + $total = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint over the plugin custom table — no WordPress API exists
3201 3144 $wpdb->prepare(
3202 3145 "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = %s AND post_status = 'publish'",
3203 3146 $post_type
3204 3147 )
@@ -3375,8 +3318,17 @@
3375 3318 }
3376 3319
3377 3320 public function linkgraph_login()
3378 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 +
3379 3331 $post_data = metasync_sanitize_input_array($_POST);
3380 3332 $payload = array(
3381 3333 'username' => wp_unslash(sanitize_email($post_data['username'])),
3382 3334 'password' => wp_unslash(sanitize_text_field($post_data['password']))
@@ -3393,9 +3345,9 @@
3393 3345 ));
3394 3346
3395 3347 # Error handling for timeout or connection failures
3396 3348 if (is_wp_error($response)) {
3397 - 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
3398 3350 wp_send_json_error(array('message' => 'Token API request failed'));
3399 3351 wp_die();
3400 3352 }
3401 3353
@@ -3529,9 +3481,9 @@
3529 3481 $option_name = '_transient_metasync_sa_connect_active_' . $nonce_token;
3530 3482 $timeout_name = '_transient_timeout_metasync_sa_connect_active_' . $nonce_token;
3531 3483
3532 3484 // Check expiry first
3533 - $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
3534 3486 $wpdb->prepare(
3535 3487 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
3536 3488 $timeout_name
3537 3489 )
@@ -3538,14 +3490,14 @@
3538 3490 );
3539 3491
3540 3492 if ($timeout && (int) $timeout < time()) {
3541 3493 // Expired — clean up orphan rows
3542 - $wpdb->delete($wpdb->options, array('option_name' => $option_name));
3543 - $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
3544 3496 return false;
3545 3497 }
3546 3498
3547 - $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
3548 3500 $wpdb->prepare(
3549 3501 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
3550 3502 $option_name
3551 3503 )
@@ -3569,9 +3521,9 @@
3569 3521 $option_name = '_transient_' . $transient_key;
3570 3522 $timeout_name = '_transient_timeout_' . $transient_key;
3571 3523
3572 3524 // Check expiry first
3573 - $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
3574 3526 $wpdb->prepare(
3575 3527 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
3576 3528 $timeout_name
3577 3529 )
@@ -3577,14 +3529,14 @@
3577 3529 )
3578 3530 );
3579 3531
3580 3532 if ($timeout && (int) $timeout < time()) {
3581 - $wpdb->delete($wpdb->options, array('option_name' => $option_name));
3582 - $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
3583 3535 return false;
3584 3536 }
3585 3537
3586 - $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
3587 3539 $wpdb->prepare(
3588 3540 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
3589 3541 $option_name
3590 3542 )
@@ -3611,10 +3563,10 @@
3611 3563 {
3612 3564 delete_transient($transient_name);
3613 3565 if (wp_using_ext_object_cache()) {
3614 3566 global $wpdb;
3615 - $wpdb->delete($wpdb->options, array('option_name' => '_transient_' . $transient_name));
3616 - $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
3617 3569 }
3618 3570 }
3619 3571
3620 3572 /**
@@ -3631,14 +3583,14 @@
3631 3583 {
3632 3584 set_transient($transient_name, $value, $expiration);
3633 3585 if (wp_using_ext_object_cache()) {
3634 3586 global $wpdb;
3635 - $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
3636 3588 'option_name' => '_transient_' . $transient_name,
3637 3589 'option_value' => maybe_serialize($value),
3638 3590 'autoload' => 'no',
3639 3591 ));
3640 - $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
3641 3593 'option_name' => '_transient_timeout_' . $transient_name,
3642 3594 'option_value' => time() + $expiration,
3643 3595 'autoload' => 'no',
3644 3596 ));
@@ -4015,14 +3967,18 @@
4015 3967 $validation_errors['api_key'] = 'API key contains invalid characters. Only alphanumeric, dash, underscore, and dot allowed';
4016 3968 }
4017 3969
4018 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.
4019 3975 if (empty($uuid)) {
4020 3976 $validation_errors['uuid'] = 'UUID is required';
4021 3977 } elseif (!is_string($uuid)) {
4022 3978 $validation_errors['uuid'] = 'UUID must be a string';
4023 - } elseif (strlen($uuid) > 100) {
4024 - $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';
4025 3981 }
4026 3982
4027 3983 // Validate status_code
4028 3984 if (!is_numeric($status_code)) {
@@ -4075,9 +4031,9 @@
4075 4031 } elseif (strlen($whitelabel_domain) > 255) {
4076 4032 $validation_errors['whitelabel_domain'] = 'Whitelabel domain must not exceed 255 characters';
4077 4033 } elseif (!filter_var($whitelabel_domain, FILTER_VALIDATE_URL)) {
4078 4034 $validation_errors['whitelabel_domain'] = 'Whitelabel domain must be a valid URL';
4079 - } 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'])) {
4080 4036 $validation_errors['whitelabel_domain'] = 'Whitelabel domain must use http or https protocol';
4081 4037 }
4082 4038 }
4083 4039
@@ -4090,9 +4046,9 @@
4090 4046 $validation_errors['whitelabel_logo'] = 'Whitelabel logo URL must not exceed 500 characters';
4091 4047 } else {
4092 4048 // If URL is invalid, we'll clear it later but not fail the POST
4093 4049 if (!filter_var($whitelabel_logo, FILTER_VALIDATE_URL) ||
4094 - !in_array(parse_url($whitelabel_logo, PHP_URL_SCHEME), ['http', 'https'])) {
4050 + !in_array(wp_parse_url($whitelabel_logo, PHP_URL_SCHEME), ['http', 'https'])) {
4095 4051 // Don't add to validation_errors - let POST succeed but clear the field
4096 4052 }
4097 4053 }
4098 4054 }
@@ -4185,9 +4141,20 @@
4185 4141 // Only update the API key in settings if status_code is 200 (success)
4186 4142 if ($status_code === 200) {
4187 4143 // Encrypt the API key at rest; never store it in cleartext.
4188 4144 $options['general']['searchatlas_api_key'] = Metasync::encrypt_api_key($new_api_key);
4189 - $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 + }
4190 4157 // Note: OTTO SSR is always enabled by default, no need to set
4191 4158
4192 4159 // Granular otto_config_status: record when SSO completed (ISO 8601 UTC)
4193 4160 $options['general']['sso_completed_at'] = gmdate('Y-m-d\TH:i:s\Z');
@@ -4206,44 +4173,43 @@
4206 4173
4207 4174 } else {
4208 4175 }
4209 4176
4210 - // 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.
4211 4197 if ($is_whitelabel) {
4212 - // whitelabel_company_name → Plugin Name (general plugin branding)
4213 - if (!empty($whitelabel_company_name)) {
4214 - $options['general']['white_label_plugin_name'] = $whitelabel_company_name;
4198 + if (!isset($options['whitelabel'])) {
4199 + $options['whitelabel'] = array();
4215 4200 }
4216 -
4217 - // whitelabel_otto → OTTO Features naming (separate from plugin name)
4218 - if (!empty($whitelabel_otto)) {
4219 - $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;
4220 4205 }
4221 - } else {
4222 - // Clear whitelabel fields when not whitelabel.
4223 - //
4224 - // Blanked rather than unset. This array is saved through
4225 - // Metasync::set_option(), and the settings sanitizer merges it
4226 - // over the stored option, so a key the array simply omits keeps
4227 - // whatever was stored — an unset here left the previous
4228 - // agency's plugin name and OTTO name in place forever. An empty
4229 - // string is a value the merge carries, and both readers
4230 - // (get_effective_plugin_name(), get_whitelabel_otto_name())
4231 - // test with !empty(), so it reads exactly like an absent key.
4232 - $options['general']['white_label_plugin_name'] = '';
4233 - $options['general']['whitelabel_otto_name'] = '';
4206 + if (!empty($whitelabel_logo)) {
4207 + $options['whitelabel']['logo'] = $whitelabel_logo;
4208 + }
4209 + $options['whitelabel']['updated_at'] = time();
4234 4210 }
4235 4211
4236 - // Store whitelabel settings (hidden from UI but accessible to plugin logic)
4237 - if (!isset($options['whitelabel'])) {
4238 - $options['whitelabel'] = array();
4239 - }
4240 -
4241 - $options['whitelabel']['is_whitelabel'] = $is_whitelabel;
4242 - $options['whitelabel']['domain'] = $whitelabel_domain;
4243 - $options['whitelabel']['logo'] = $whitelabel_logo;
4244 - $options['whitelabel']['updated_at'] = time();
4245 -
4246 4212 // Log whitelabel configuration
4247 4213 if ($is_whitelabel) {
4248 4214 $log_parts = array('Whitelabel mode enabled');
4249 4215 if (!empty($whitelabel_domain)) {
@@ -4264,9 +4230,9 @@
4264 4230 $save_result = Metasync::set_option($options);
4265 4231 Metasync::invalidate_api_key_cache();
4266 4232
4267 4233 if (!$save_result) {
4268 - 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
4269 4235 } else {
4270 4236 if ($status_code === 200) {
4271 4237 // Option 1: Set heartbeat cache and last-known to CONNECTED so dashboard shows iframe immediately.
4272 4238 // If the immediate heartbeat then fails (e.g. backoff), Option 2 prevents overwriting this to DISCONNECTED.
@@ -4278,9 +4244,9 @@
4278 4244 );
4279 4245 set_transient('metasync_heartbeat_status_cache', $cache_data, 300);
4280 4246 update_option('metasync_last_known_connection_state', true);
4281 4247 do_action('metasync_heartbeat_state_key_pending'); // PR3: burst mode
4282 - $this->trigger_immediate_heartbeat_after_sa_connect();
4248 + $this->trigger_immediate_heartbeat_after_sa_connect($token);
4283 4249 }
4284 4250 }
4285 4251
4286 4252 return true;
@@ -4285,9 +4251,9 @@
4285 4251
4286 4252 return true;
4287 4253
4288 4254 } catch (Exception $e) {
4289 - 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
4290 4256 return false;
4291 4257 }
4292 4258 }
4293 4259
@@ -4299,9 +4265,9 @@
4299 4265 {
4300 4266 global $wpdb;
4301 4267
4302 4268 // Clear all JWT token transients
4303 - $deleted = $wpdb->query(
4269 + $deleted = $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
4304 4270 $wpdb->prepare(
4305 4271 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
4306 4272 '_transient_metasync_jwt_token_%'
4307 4273 )
@@ -4307,9 +4273,9 @@
4307 4273 )
4308 4274 );
4309 4275
4310 4276 // Also clear timeout transients
4311 - $wpdb->query(
4277 + $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
4312 4278 $wpdb->prepare(
4313 4279 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
4314 4280 '_transient_timeout_metasync_jwt_token_%'
4315 4281 )
@@ -4320,21 +4286,28 @@
4320 4286
4321 4287 /**
4322 4288 * Trigger immediate heartbeat check after successful Search Atlas connect authentication
4323 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.
4324 4292 */
4325 - private function trigger_immediate_heartbeat_after_sa_connect()
4293 + private function trigger_immediate_heartbeat_after_sa_connect($token)
4326 4294 {
4327 4295 try {
4328 4296 // Use WordPress action system to trigger immediate heartbeat check
4329 4297 // This is more reliable than trying to access admin class directly
4330 4298 do_action('metasync_trigger_immediate_heartbeat', 'Search Atlas Connect - API key and UUID retrieved');
4331 -
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 +
4332 4305 // Also ensure heartbeat cron is scheduled now that we have an API key
4333 4306 do_action('metasync_ensure_heartbeat_cron_scheduled');
4334 4307
4335 4308 } catch (Exception $e) {
4336 - 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
4337 4310 }
4338 4311 }
4339 4312
4340 4313 public function get_item_schema()
@@ -4352,15 +4325,15 @@
4352 4325 'type' => 'object',
4353 4326 // In JSON Schema you can specify object properties in the properties attribute.
4354 4327 'properties' => array(
4355 4328 'id' => array(
4356 - 'description' => esc_html__('Unique identifier for the object.', 'my-textdomain'),
4329 + 'description' => esc_html__('Unique identifier for the object.', 'metasync'),
4357 4330 'type' => 'integer',
4358 4331 'context' => array('view', 'edit', 'embed'),
4359 4332 'readonly' => true,
4360 4333 ),
4361 4334 'content' => array(
4362 - 'description' => esc_html__('The content for the object.', 'my-textdomain'),
4335 + 'description' => esc_html__('The content for the object.', 'metasync'),
4363 4336 'type' => 'string',
4364 4337 ),
4365 4338 ),
4366 4339 );
@@ -4451,11 +4424,11 @@
4451 4424
4452 4425 # Try alternative parameter methods
4453 4426 $body_params = $request->get_body_params();
4454 4427 $key_param = $request->get_param('key');
4455 - $post_key = $_POST['key'] ?? null;
4456 - $request_key = $_REQUEST['key'] ?? null;
4457 - $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
4458 4431
4459 4432 # Try to get key from multiple sources
4460 4433 $key_value = null;
4461 4434
@@ -4547,9 +4520,15 @@
4547 4520 # delete it so it stops shadowing the virtual route and let WordPress serve
4548 4521 # the key from PHP instead.
4549 4522 $serving = 'virtual';
4550 4523
4551 - 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) {
4552 4531 # Cache-buster avoids a stale 404 cached by a CDN/nginx for this path.
4553 4532 $probe = wp_remote_get(
4554 4533 home_url('/' . $safe_key . '.txt?_msverify=' . time()),
4555 4534 array('timeout' => 10, 'sslverify' => false)
@@ -4561,9 +4540,9 @@
4561 4540 $serving = 'physical';
4562 4541 } else {
4563 4542 # 403 / 404 / wrong body — a present file shadows the virtual route,
4564 4543 # so remove it and let WordPress serve the key instead.
4565 - @unlink($file_path);
4544 + @wp_delete_file($file_path);
4566 4545 $serving = 'virtual';
4567 4546 }
4568 4547 }
4569 4548