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 -177 2.7.0 → 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,31 +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 169 public function rest_authorization_middleware($request = null)
198 170 {
199 171 $api_key = '';
200 172
@@ -210,11 +182,11 @@
210 182 if (empty($api_key) && isset($_SERVER['HTTP_X_API_KEY'])) {
211 183 $api_key = sanitize_text_field(wp_unslash($_SERVER['HTTP_X_API_KEY']));
212 184 }
213 185
214 - // Fallback: ?apikey= query param (deprecated)
215 - if (empty($api_key) && isset($_GET['apikey'])) {
216 - $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
217 189 }
218 190
219 191 if (empty($api_key)) {
220 192 return false;
@@ -368,10 +340,10 @@
368 340 return '/' . ltrim((string) $GLOBALS['wp']->query_vars['rest_route'], '/');
369 341 }
370 342
371 343 // ?rest_route= form, used when pretty permalinks are disabled.
372 - if (!empty($_GET['rest_route'])) {
373 - 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
374 346 }
375 347
376 348 // Last resort: the request path with the /wp-json/ prefix stripped.
377 349 $uri = isset($_SERVER['REQUEST_URI']) ? (string) wp_unslash($_SERVER['REQUEST_URI']) : '';
@@ -378,9 +350,9 @@
378 350 if ($uri === '') {
379 351 return '';
380 352 }
381 353
382 - $path = (string) parse_url($uri, PHP_URL_PATH);
354 + $path = (string) wp_parse_url($uri, PHP_URL_PATH);
383 355 $url_prefix = function_exists('rest_get_url_prefix') ? rest_get_url_prefix() : 'wp-json';
384 356 $needle = '/' . trim((string) $url_prefix, '/') . '/';
385 357
386 358 $pos = strpos($path, $needle);
@@ -394,10 +366,10 @@
394 366 /**
395 367 * Whether the current request carries a valid MetaSync API key.
396 368 *
397 369 * Reads credentials from the request globals so it works in filters that get
398 - * no WP_REST_Request. Accepts the same three sources as
399 - * rest_authorization_middleware().
370 + * no WP_REST_Request. Accepts Authorization: Bearer and X-API-Key headers,
371 + * matching rest_authorization_middleware().
400 372 *
401 373 * @return bool True only when a presented key matches the configured one.
402 374 */
403 375 protected function request_has_valid_api_key()
@@ -420,13 +392,8 @@
420 392 if ($api_key === '' && !empty($_SERVER['HTTP_X_API_KEY'])) {
421 393 $api_key = sanitize_text_field(wp_unslash($_SERVER['HTTP_X_API_KEY']));
422 394 }
423 395
424 - // Fallback: ?apikey= query param (deprecated).
425 - if ($api_key === '' && !empty($_GET['apikey'])) {
426 - $api_key = sanitize_text_field(wp_unslash($_GET['apikey']));
427 - }
428 -
429 396 if ($api_key === '') {
430 397 return false;
431 398 }
432 399
@@ -578,9 +545,9 @@
578 545 array(
579 546 array(
580 547 'methods' => 'GET',
581 548 'callback' => function () {
582 - $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
583 550
584 551 if ($getPostID==0) {
585 552 $response = false;
586 553 }else{
@@ -1109,9 +1076,9 @@
1109 1076 }
1110 1077
1111 1078 public function get_errorlogs()
1112 1079 {
1113 - $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
1114 1081 if (!isset($get_data['limit']))
1115 1082 return false;
1116 1083 $limit = sanitize_text_field($get_data['limit']) ?? null;
1117 1084
@@ -1299,9 +1266,9 @@
1299 1266 }
1300 1267 } catch (Throwable $e) {
1301 1268 // Fallback to DOMDocument if Dom\HTMLDocument fails
1302 1269 if (defined('WP_DEBUG_LOG') && WP_DEBUG_LOG) {
1303 - 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
1304 1271 }
1305 1272 $dom = new DOMDocument();
1306 1273 libxml_use_internal_errors(true);
1307 1274 $encoded_content = $this->encode_numeric_entities($content);
@@ -1501,9 +1468,9 @@
1501 1468 $image_id = attachment_url_to_postid($node->getAttribute('src') );
1502 1469 $image_alt = get_post_meta($image_id, '_wp_attachment_image_alt', TRUE);
1503 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]';
1504 1471
1505 - 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
1506 1473
1507 1474 }
1508 1475 }elseif($node->nodeName === 'iframe'){
1509 1476 $result= '[et_pb_code _builder_version="'.ET_BUILDER_VERSION.'" _module_preset="default" global_colors_info="{}"]'.$node->ownerDocument->saveHTML($node).'[/et_pb_code]' ;
@@ -1653,9 +1620,9 @@
1653 1620 return $content;
1654 1621 }
1655 1622
1656 1623 # Entity-decode both sides so "Mobility & Recovery" matches "Mobility & Recovery"
1657 - $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));
1658 1625 $title_text = trim(html_entity_decode((string) $title, ENT_QUOTES | ENT_HTML5));
1659 1626 if ($h1_text === '' || strcasecmp($h1_text, $title_text) !== 0) {
1660 1627 return $content;
1661 1628 }
@@ -1812,8 +1779,65 @@
1812 1779 }
1813 1780 return false;
1814 1781 }
1815 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 +
1816 1840 public function create_item($request)
1817 1841 {
1818 1842 // Checking for type of object for response type
1819 1843 $array_response = true;
@@ -1924,13 +1948,13 @@
1924 1948 $new_post['post_name'] = sanitize_text_field($item['permalink']);
1925 1949 }
1926 1950
1927 1951 if (isset($item['post_date']) && !empty($item['post_date'])) {
1928 - $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'];
1929 1953 if (!$is_valid_date) {
1930 1954 return new WP_Error(
1931 1955 'rest_post_invalid_date',
1932 - esc_html__('Post date is not valid'),
1956 + esc_html__('Post date is not valid', 'metasync'),
1933 1957 array('status' => 400)
1934 1958 );
1935 1959 }
1936 1960
@@ -1963,20 +1987,16 @@
1963 1987 # $getPostID_byURL = @get_page_by_path($item['permalink'], OBJECT, $new_post['post_type'])->ID;
1964 1988 # Fix to avoid PHP error if the get_page_by_path returns null
1965 1989 $getPostID_byURL = @get_page_by_path($item['permalink'], OBJECT, $new_post['post_type']);
1966 1990 $getPostID_byURL = $getPostID_byURL ? $getPostID_byURL->ID : null;
1991 + $matched_by_title = false;
1967 1992 if ($getPostID_byURL == NULL) {
1968 1993 // check if the post_title is set and not empty when called by otto_ai_page
1969 1994 if(isset($new_post['post_title']) && $new_post['post_title']!==''){
1970 - $getPostID_byURL = new WP_Query(
1971 - array(
1972 - 'post_type' => $new_post['post_type'],
1973 - 'title' => $new_post['post_title']
1974 - )
1975 - );
1976 - $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 + }
1977 1998 }
1978 - }
1979 1999
1980 2000 // Allow HTML code for landing page
1981 2001 if (isset($item['is_landing_page']) && $item['is_landing_page'] == true) {
1982 2002 kses_remove_filters();
@@ -2001,11 +2021,17 @@
2001 2021 update_post_meta($post_id, 'metasync_post', 'yes');
2002 2022 }
2003 2023
2004 2024 } else {
2005 - $new_post['ID'] = $post_id = $getPostID_byURL;
2006 - wp_update_post($new_post);
2007 - 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);
2008 2034 $permalink = get_permalink($post_id);
2009 2035 }
2010 2036
2011 2037 if (isset($item['is_landing_page']) && $item['is_landing_page'] == true) {
@@ -2138,13 +2164,15 @@
2138 2164 'action' => strtolower($action)
2139 2165 ])
2140 2166 ]);
2141 2167
2142 - # 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.
2143 2170 try {
2144 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']));
2145 2173 } catch (Exception $e) {
2146 - 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
2147 2175 }
2148 2176 }
2149 2177
2150 2178 # For standard synced posts, learn (once per post-type) whether the
@@ -2154,9 +2182,9 @@
2154 2182 && $new_post['post_status'] === 'publish') {
2155 2183 try {
2156 2184 $this->record_theme_title_verdict($post_id, $permalink, $new_post['post_title'], $new_post['post_type']);
2157 2185 } catch (Exception $e) {
2158 - 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
2159 2187 }
2160 2188 }
2161 2189
2162 2190 $respCreatePosts[$index] = array_merge($new_post, $post_meta);
@@ -2201,9 +2229,9 @@
2201 2229 }
2202 2230
2203 2231 public function delete_item()
2204 2232 {
2205 - $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
2206 2234 if (!isset($get_data['ID']))
2207 2235 return false;
2208 2236
2209 2237 $post_id = sanitize_text_field($get_data['ID']) ?? null;
@@ -2209,18 +2237,18 @@
2209 2237 $post_id = sanitize_text_field($get_data['ID']) ?? null;
2210 2238 $post = get_post($post_id);
2211 2239 if ($post) {
2212 2240 wp_delete_post($post_id);
2241 + // HTTP 204 requires no body for response, so no message is set.
2213 2242 return new WP_Error(
2214 2243 'rest_post_delete_success',
2215 - esc_html__(''),
2216 - // HTTP 204 requires no body for response
2244 + '',
2217 2245 array('status' => 204)
2218 2246 );
2219 2247 }
2220 2248 return new WP_Error(
2221 2249 'rest_post_delete_fail',
2222 - esc_html__('No post found in the database with requested ID.'),
2250 + esc_html__('No post found in the database with requested ID.', 'metasync'),
2223 2251 array('status' => 400)
2224 2252 );
2225 2253 }
2226 2254
@@ -2225,9 +2253,9 @@
2225 2253 }
2226 2254
2227 2255 public function get_items($request)
2228 2256 {
2229 - $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
2230 2258
2231 2259 # let us check if request send post id and is valid int
2232 2260 if(isset($get_data['post_id']) AND intval($get_data['post_id']) > 0){
2233 2261
@@ -2567,9 +2595,9 @@
2567 2595 $post_data = get_post($post_id);
2568 2596 if (!$post_data) {
2569 2597 return new WP_Error(
2570 2598 'rest_post_update_fail',
2571 - esc_html__('No post found in the database with requested ID.'),
2599 + esc_html__('No post found in the database with requested ID.', 'metasync'),
2572 2600 array('status' => 400)
2573 2601 );
2574 2602 }
2575 2603
@@ -2690,9 +2718,8 @@
2690 2718 }
2691 2719
2692 2720 if (isset($post['post_status']) && !empty($post['post_status'])) {
2693 2721 $update_params['post_status'] = $post['post_status'] ? sanitize_text_field($post['post_status']) : 'publish';
2694 - $permalink = get_permalink($post_id);
2695 2722 }
2696 2723 if (isset($post['permalink']) || !empty($post['permalink'])) {
2697 2724 $update_params['post_name'] = sanitize_text_field($post['permalink']);
2698 2725 }
@@ -2707,40 +2734,8 @@
2707 2734 );
2708 2735 }
2709 2736
2710 2737
2711 - if (isset($post['post_date']) && !empty($post['post_date']) && false) {
2712 - $is_valid_date = date('Y-m-d', strtotime($post['post_date'])) == $post['post_date'];
2713 - if (!$is_valid_date) {
2714 - return new WP_Error(
2715 - 'rest_post_invalid_date',
2716 - esc_html__('Post date is not valid'),
2717 - array('status' => 400)
2718 - );
2719 - }
2720 -
2721 - $date_limit_str = strtotime(date('Y-m-d') . '-2 month');
2722 - $post_date_str = strtotime($post['post_date']);
2723 -
2724 - if ($date_limit_str >= $post_date_str) {
2725 - $newDate = date('Y-m-d', strtotime('-2 month'));
2726 - return new WP_Error(
2727 - 'rest_post_greater_date',
2728 - esc_html__("Post date should be greater then " . $newDate),
2729 - array('status' => 400)
2730 - );
2731 - }
2732 -
2733 - if ($post_date_str > strtotime(date('Y-m-d'))) {
2734 - return new WP_Error(
2735 - 'rest_post_greater_date',
2736 - esc_html__('Post date should be less then Today'),
2737 - array('status' => 400)
2738 - );
2739 - }
2740 - $update_params['post_date'] = sanitize_text_field($post['post_date'] . date(' h:i:s'));
2741 - }
2742 -
2743 2738 $post_cattegories = [];
2744 2739 # if ($post_data && $post_data->post_type === 'post' && is_array(@$post['post_categories'])) {
2745 2740
2746 2741 # fixed Undefined array key issue
@@ -2763,8 +2758,11 @@
2763 2758 $attachment_id = $this->metasync_handle_hero_image($post_id, $post['hero_image_url'], $post['hero_image_alt_text']);
2764 2759 }
2765 2760
2766 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);
2767 2765 if(isset($content['elementor_meta_data'])){
2768 2766 $this->metasync_apply_builder_meta($post_id, $content['elementor_meta_data']);
2769 2767 if ( did_action( 'elementor/loaded' ) ) {
2770 2768 // Clear Elementor cache for the specified post ID
@@ -2880,13 +2878,14 @@
2880 2878 'updated_fields' => array_keys($update_params)
2881 2879 ])
2882 2880 ]);
2883 2881
2884 - # Track Content Genius event in GA4
2882 + # Track Content Genius event in GA4 and PostHog
2885 2883 try {
2886 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']));
2887 2886 } catch (Exception $e) {
2888 - 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
2889 2888 }
2890 2889
2891 2890 # Learn (once per post-type) whether the theme renders the title,
2892 2891 # so future syncs prepend only when the theme genuinely omits it.
@@ -2894,9 +2893,9 @@
2894 2893 && $sync_status === 'published') {
2895 2894 try {
2896 2895 $this->record_theme_title_verdict($post_id, ($permalink ?? get_permalink($post_id)), $post_title, $post_type);
2897 2896 } catch (Exception $e) {
2898 - 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
2899 2898 }
2900 2899 }
2901 2900 }
2902 2901
@@ -3018,9 +3017,9 @@
3018 3017 $post_data = get_post($payload['post_id']);
3019 3018 if(!isset($post_data->post_type)){
3020 3019 return new WP_Error(
3021 3020 'rest_page_type_fail',
3022 - esc_html__('No page found in the database with requested ID.'),
3021 + esc_html__('No page found in the database with requested ID.', 'metasync'),
3023 3022 array('status' => 400)
3024 3023 );
3025 3024 }
3026 3025 if ($post_data->post_type !== 'page') {
@@ -3113,20 +3112,19 @@
3113 3112
3114 3113 global $wpdb;
3115 3114
3116 3115 // Never propagate a template this plugin wrote itself.
3117 - $owned = $this->metasync_plugin_owned_templates();
3118 - $placeholders = implode(',', array_fill(0, count($owned), '%s'));
3116 + $owned = $this->metasync_plugin_owned_templates();
3119 3117
3120 - $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
3121 3119 $wpdb->prepare(
3122 - "SELECT pm.meta_value AS tpl, COUNT(*) AS hits
3123 - FROM {$wpdb->postmeta} pm
3124 - 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
3125 3123 WHERE pm.meta_key = '_wp_page_template'
3126 3124 AND pm.meta_value <> ''
3127 3125 AND pm.meta_value <> 'default'
3128 - AND pm.meta_value NOT IN ($placeholders)
3126 + AND pm.meta_value NOT IN (" . implode(',', array_fill(0, count($owned), '%s')) . ")
3129 3127 AND p.post_type = %s
3130 3128 AND p.post_status = 'publish'
3131 3129 GROUP BY pm.meta_value
3132 3130 ORDER BY hits DESC",
@@ -3141,9 +3139,9 @@
3141 3139 // not just the ones that set a template. Counting only the latter
3142 3140 // would let a handful of outliers on a special template read as a
3143 3141 // site-wide convention on a site whose posts otherwise use the
3144 3142 // theme default.
3145 - $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
3146 3144 $wpdb->prepare(
3147 3145 "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = %s AND post_status = 'publish'",
3148 3146 $post_type
3149 3147 )
@@ -3320,8 +3318,17 @@
3320 3318 }
3321 3319
3322 3320 public function linkgraph_login()
3323 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 +
3324 3331 $post_data = metasync_sanitize_input_array($_POST);
3325 3332 $payload = array(
3326 3333 'username' => wp_unslash(sanitize_email($post_data['username'])),
3327 3334 'password' => wp_unslash(sanitize_text_field($post_data['password']))
@@ -3338,9 +3345,9 @@
3338 3345 ));
3339 3346
3340 3347 # Error handling for timeout or connection failures
3341 3348 if (is_wp_error($response)) {
3342 - 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
3343 3350 wp_send_json_error(array('message' => 'Token API request failed'));
3344 3351 wp_die();
3345 3352 }
3346 3353
@@ -3474,9 +3481,9 @@
3474 3481 $option_name = '_transient_metasync_sa_connect_active_' . $nonce_token;
3475 3482 $timeout_name = '_transient_timeout_metasync_sa_connect_active_' . $nonce_token;
3476 3483
3477 3484 // Check expiry first
3478 - $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
3479 3486 $wpdb->prepare(
3480 3487 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
3481 3488 $timeout_name
3482 3489 )
@@ -3483,14 +3490,14 @@
3483 3490 );
3484 3491
3485 3492 if ($timeout && (int) $timeout < time()) {
3486 3493 // Expired — clean up orphan rows
3487 - $wpdb->delete($wpdb->options, array('option_name' => $option_name));
3488 - $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
3489 3496 return false;
3490 3497 }
3491 3498
3492 - $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
3493 3500 $wpdb->prepare(
3494 3501 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
3495 3502 $option_name
3496 3503 )
@@ -3514,9 +3521,9 @@
3514 3521 $option_name = '_transient_' . $transient_key;
3515 3522 $timeout_name = '_transient_timeout_' . $transient_key;
3516 3523
3517 3524 // Check expiry first
3518 - $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
3519 3526 $wpdb->prepare(
3520 3527 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
3521 3528 $timeout_name
3522 3529 )
@@ -3522,14 +3529,14 @@
3522 3529 )
3523 3530 );
3524 3531
3525 3532 if ($timeout && (int) $timeout < time()) {
3526 - $wpdb->delete($wpdb->options, array('option_name' => $option_name));
3527 - $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
3528 3535 return false;
3529 3536 }
3530 3537
3531 - $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
3532 3539 $wpdb->prepare(
3533 3540 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
3534 3541 $option_name
3535 3542 )
@@ -3556,10 +3563,10 @@
3556 3563 {
3557 3564 delete_transient($transient_name);
3558 3565 if (wp_using_ext_object_cache()) {
3559 3566 global $wpdb;
3560 - $wpdb->delete($wpdb->options, array('option_name' => '_transient_' . $transient_name));
3561 - $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
3562 3569 }
3563 3570 }
3564 3571
3565 3572 /**
@@ -3576,14 +3583,14 @@
3576 3583 {
3577 3584 set_transient($transient_name, $value, $expiration);
3578 3585 if (wp_using_ext_object_cache()) {
3579 3586 global $wpdb;
3580 - $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
3581 3588 'option_name' => '_transient_' . $transient_name,
3582 3589 'option_value' => maybe_serialize($value),
3583 3590 'autoload' => 'no',
3584 3591 ));
3585 - $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
3586 3593 'option_name' => '_transient_timeout_' . $transient_name,
3587 3594 'option_value' => time() + $expiration,
3588 3595 'autoload' => 'no',
3589 3596 ));
@@ -3960,14 +3967,18 @@
3960 3967 $validation_errors['api_key'] = 'API key contains invalid characters. Only alphanumeric, dash, underscore, and dot allowed';
3961 3968 }
3962 3969
3963 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.
3964 3975 if (empty($uuid)) {
3965 3976 $validation_errors['uuid'] = 'UUID is required';
3966 3977 } elseif (!is_string($uuid)) {
3967 3978 $validation_errors['uuid'] = 'UUID must be a string';
3968 - } elseif (strlen($uuid) > 100) {
3969 - $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';
3970 3981 }
3971 3982
3972 3983 // Validate status_code
3973 3984 if (!is_numeric($status_code)) {
@@ -4020,9 +4031,9 @@
4020 4031 } elseif (strlen($whitelabel_domain) > 255) {
4021 4032 $validation_errors['whitelabel_domain'] = 'Whitelabel domain must not exceed 255 characters';
4022 4033 } elseif (!filter_var($whitelabel_domain, FILTER_VALIDATE_URL)) {
4023 4034 $validation_errors['whitelabel_domain'] = 'Whitelabel domain must be a valid URL';
4024 - } 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'])) {
4025 4036 $validation_errors['whitelabel_domain'] = 'Whitelabel domain must use http or https protocol';
4026 4037 }
4027 4038 }
4028 4039
@@ -4035,9 +4046,9 @@
4035 4046 $validation_errors['whitelabel_logo'] = 'Whitelabel logo URL must not exceed 500 characters';
4036 4047 } else {
4037 4048 // If URL is invalid, we'll clear it later but not fail the POST
4038 4049 if (!filter_var($whitelabel_logo, FILTER_VALIDATE_URL) ||
4039 - !in_array(parse_url($whitelabel_logo, PHP_URL_SCHEME), ['http', 'https'])) {
4050 + !in_array(wp_parse_url($whitelabel_logo, PHP_URL_SCHEME), ['http', 'https'])) {
4040 4051 // Don't add to validation_errors - let POST succeed but clear the field
4041 4052 }
4042 4053 }
4043 4054 }
@@ -4130,9 +4141,20 @@
4130 4141 // Only update the API key in settings if status_code is 200 (success)
4131 4142 if ($status_code === 200) {
4132 4143 // Encrypt the API key at rest; never store it in cleartext.
4133 4144 $options['general']['searchatlas_api_key'] = Metasync::encrypt_api_key($new_api_key);
4134 - $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 + }
4135 4157 // Note: OTTO SSR is always enabled by default, no need to set
4136 4158
4137 4159 // Granular otto_config_status: record when SSO completed (ISO 8601 UTC)
4138 4160 $options['general']['sso_completed_at'] = gmdate('Y-m-d\TH:i:s\Z');
@@ -4151,44 +4173,43 @@
4151 4173
4152 4174 } else {
4153 4175 }
4154 4176
4155 - // 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.
4156 4197 if ($is_whitelabel) {
4157 - // whitelabel_company_name → Plugin Name (general plugin branding)
4158 - if (!empty($whitelabel_company_name)) {
4159 - $options['general']['white_label_plugin_name'] = $whitelabel_company_name;
4198 + if (!isset($options['whitelabel'])) {
4199 + $options['whitelabel'] = array();
4160 4200 }
4161 -
4162 - // whitelabel_otto → OTTO Features naming (separate from plugin name)
4163 - if (!empty($whitelabel_otto)) {
4164 - $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;
4165 4205 }
4166 - } else {
4167 - // Clear whitelabel fields when not whitelabel.
4168 - //
4169 - // Blanked rather than unset. This array is saved through
4170 - // Metasync::set_option(), and the settings sanitizer merges it
4171 - // over the stored option, so a key the array simply omits keeps
4172 - // whatever was stored — an unset here left the previous
4173 - // agency's plugin name and OTTO name in place forever. An empty
4174 - // string is a value the merge carries, and both readers
4175 - // (get_effective_plugin_name(), get_whitelabel_otto_name())
4176 - // test with !empty(), so it reads exactly like an absent key.
4177 - $options['general']['white_label_plugin_name'] = '';
4178 - $options['general']['whitelabel_otto_name'] = '';
4206 + if (!empty($whitelabel_logo)) {
4207 + $options['whitelabel']['logo'] = $whitelabel_logo;
4208 + }
4209 + $options['whitelabel']['updated_at'] = time();
4179 4210 }
4180 4211
4181 - // Store whitelabel settings (hidden from UI but accessible to plugin logic)
4182 - if (!isset($options['whitelabel'])) {
4183 - $options['whitelabel'] = array();
4184 - }
4185 -
4186 - $options['whitelabel']['is_whitelabel'] = $is_whitelabel;
4187 - $options['whitelabel']['domain'] = $whitelabel_domain;
4188 - $options['whitelabel']['logo'] = $whitelabel_logo;
4189 - $options['whitelabel']['updated_at'] = time();
4190 -
4191 4212 // Log whitelabel configuration
4192 4213 if ($is_whitelabel) {
4193 4214 $log_parts = array('Whitelabel mode enabled');
4194 4215 if (!empty($whitelabel_domain)) {
@@ -4209,9 +4230,9 @@
4209 4230 $save_result = Metasync::set_option($options);
4210 4231 Metasync::invalidate_api_key_cache();
4211 4232
4212 4233 if (!$save_result) {
4213 - 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
4214 4235 } else {
4215 4236 if ($status_code === 200) {
4216 4237 // Option 1: Set heartbeat cache and last-known to CONNECTED so dashboard shows iframe immediately.
4217 4238 // If the immediate heartbeat then fails (e.g. backoff), Option 2 prevents overwriting this to DISCONNECTED.
@@ -4223,9 +4244,9 @@
4223 4244 );
4224 4245 set_transient('metasync_heartbeat_status_cache', $cache_data, 300);
4225 4246 update_option('metasync_last_known_connection_state', true);
4226 4247 do_action('metasync_heartbeat_state_key_pending'); // PR3: burst mode
4227 - $this->trigger_immediate_heartbeat_after_sa_connect();
4248 + $this->trigger_immediate_heartbeat_after_sa_connect($token);
4228 4249 }
4229 4250 }
4230 4251
4231 4252 return true;
@@ -4230,9 +4251,9 @@
4230 4251
4231 4252 return true;
4232 4253
4233 4254 } catch (Exception $e) {
4234 - 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
4235 4256 return false;
4236 4257 }
4237 4258 }
4238 4259
@@ -4244,9 +4265,9 @@
4244 4265 {
4245 4266 global $wpdb;
4246 4267
4247 4268 // Clear all JWT token transients
4248 - $deleted = $wpdb->query(
4269 + $deleted = $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
4249 4270 $wpdb->prepare(
4250 4271 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
4251 4272 '_transient_metasync_jwt_token_%'
4252 4273 )
@@ -4252,9 +4273,9 @@
4252 4273 )
4253 4274 );
4254 4275
4255 4276 // Also clear timeout transients
4256 - $wpdb->query(
4277 + $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
4257 4278 $wpdb->prepare(
4258 4279 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
4259 4280 '_transient_timeout_metasync_jwt_token_%'
4260 4281 )
@@ -4265,21 +4286,28 @@
4265 4286
4266 4287 /**
4267 4288 * Trigger immediate heartbeat check after successful Search Atlas connect authentication
4268 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.
4269 4292 */
4270 - private function trigger_immediate_heartbeat_after_sa_connect()
4293 + private function trigger_immediate_heartbeat_after_sa_connect($token)
4271 4294 {
4272 4295 try {
4273 4296 // Use WordPress action system to trigger immediate heartbeat check
4274 4297 // This is more reliable than trying to access admin class directly
4275 4298 do_action('metasync_trigger_immediate_heartbeat', 'Search Atlas Connect - API key and UUID retrieved');
4276 -
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 +
4277 4305 // Also ensure heartbeat cron is scheduled now that we have an API key
4278 4306 do_action('metasync_ensure_heartbeat_cron_scheduled');
4279 4307
4280 4308 } catch (Exception $e) {
4281 - 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
4282 4310 }
4283 4311 }
4284 4312
4285 4313 public function get_item_schema()
@@ -4297,15 +4325,15 @@
4297 4325 'type' => 'object',
4298 4326 // In JSON Schema you can specify object properties in the properties attribute.
4299 4327 'properties' => array(
4300 4328 'id' => array(
4301 - 'description' => esc_html__('Unique identifier for the object.', 'my-textdomain'),
4329 + 'description' => esc_html__('Unique identifier for the object.', 'metasync'),
4302 4330 'type' => 'integer',
4303 4331 'context' => array('view', 'edit', 'embed'),
4304 4332 'readonly' => true,
4305 4333 ),
4306 4334 'content' => array(
4307 - 'description' => esc_html__('The content for the object.', 'my-textdomain'),
4335 + 'description' => esc_html__('The content for the object.', 'metasync'),
4308 4336 'type' => 'string',
4309 4337 ),
4310 4338 ),
4311 4339 );
@@ -4396,11 +4424,11 @@
4396 4424
4397 4425 # Try alternative parameter methods
4398 4426 $body_params = $request->get_body_params();
4399 4427 $key_param = $request->get_param('key');
4400 - $post_key = $_POST['key'] ?? null;
4401 - $request_key = $_REQUEST['key'] ?? null;
4402 - $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
4403 4431
4404 4432 # Try to get key from multiple sources
4405 4433 $key_value = null;
4406 4434
@@ -4492,9 +4520,15 @@
4492 4520 # delete it so it stops shadowing the virtual route and let WordPress serve
4493 4521 # the key from PHP instead.
4494 4522 $serving = 'virtual';
4495 4523
4496 - 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) {
4497 4531 # Cache-buster avoids a stale 404 cached by a CDN/nginx for this path.
4498 4532 $probe = wp_remote_get(
4499 4533 home_url('/' . $safe_key . '.txt?_msverify=' . time()),
4500 4534 array('timeout' => 10, 'sslverify' => false)
@@ -4506,9 +4540,9 @@
4506 4540 $serving = 'physical';
4507 4541 } else {
4508 4542 # 403 / 404 / wrong body — a present file shadows the virtual route,
4509 4543 # so remove it and let WordPress serve the key instead.
4510 - @unlink($file_path);
4544 + @wp_delete_file($file_path);
4511 4545 $serving = 'virtual';
4512 4546 }
4513 4547 }
4514 4548