PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.1
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.1
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 +324 -271 2.6.21 → 2.7.1 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
@@ -270,10 +183,10 @@
270 183 $api_key = sanitize_text_field(wp_unslash($_SERVER['HTTP_X_API_KEY']));
271 184 }
272 185
273 186 // Fallback: ?apikey= query param (deprecated)
274 - if (empty($api_key) && isset($_GET['apikey'])) {
275 - $api_key = sanitize_text_field($_GET['apikey']);
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);
@@ -480,10 +393,10 @@
480 393 $api_key = sanitize_text_field(wp_unslash($_SERVER['HTTP_X_API_KEY']));
481 394 }
482 395
483 396 // Fallback: ?apikey= query param (deprecated).
484 - if ($api_key === '' && !empty($_GET['apikey'])) {
485 - $api_key = sanitize_text_field(wp_unslash($_GET['apikey']));
397 + if ($api_key === '' && !empty($_GET['apikey'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- API key credential in REST auth, not cookie auth
398 + $api_key = sanitize_text_field(wp_unslash($_GET['apikey'])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- API key credential in REST auth, not cookie auth
486 399 }
487 400
488 401 if ($api_key === '') {
489 402 return false;
@@ -637,9 +550,9 @@
637 550 array(
638 551 array(
639 552 'methods' => 'GET',
640 553 'callback' => function () {
641 - $getPostID = url_to_postid(sanitize_url($_GET['url']));
554 + $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 555
643 556 if ($getPostID==0) {
644 557 $response = false;
645 558 }else{
@@ -1168,9 +1081,9 @@
1168 1081 }
1169 1082
1170 1083 public function get_errorlogs()
1171 1084 {
1172 - $get_data = metasync_sanitize_input_array($_GET);
1085 + $get_data = metasync_sanitize_input_array($_GET); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- REST route authenticated by API key, not cookies
1173 1086 if (!isset($get_data['limit']))
1174 1087 return false;
1175 1088 $limit = sanitize_text_field($get_data['limit']) ?? null;
1176 1089
@@ -1358,9 +1271,9 @@
1358 1271 }
1359 1272 } catch (Throwable $e) {
1360 1273 // Fallback to DOMDocument if Dom\HTMLDocument fails
1361 1274 if (defined('WP_DEBUG_LOG') && WP_DEBUG_LOG) {
1362 - error_log('MetaSync: Dom\HTMLDocument error, falling back to DOMDocument: ' . $e->getMessage());
1275 + 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 1276 }
1364 1277 $dom = new DOMDocument();
1365 1278 libxml_use_internal_errors(true);
1366 1279 $encoded_content = $this->encode_numeric_entities($content);
@@ -1560,9 +1473,9 @@
1560 1473 $image_id = attachment_url_to_postid($node->getAttribute('src') );
1561 1474 $image_alt = get_post_meta($image_id, '_wp_attachment_image_alt', TRUE);
1562 1475 $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 1476
1564 - error_log(json_encode($e));
1477 + error_log(json_encode($e)); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
1565 1478
1566 1479 }
1567 1480 }elseif($node->nodeName === 'iframe'){
1568 1481 $result= '[et_pb_code _builder_version="'.ET_BUILDER_VERSION.'" _module_preset="default" global_colors_info="{}"]'.$node->ownerDocument->saveHTML($node).'[/et_pb_code]' ;
@@ -1690,67 +1603,8 @@
1690 1603 return $attachment_id;
1691 1604 }
1692 1605
1693 1606 /**
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 1607 * Remove a leading <h1> from post content when it duplicates the post title.
1754 1608 *
1755 1609 * Themes that render post_title as an H1 produce a duplicate heading when the
1756 1610 * synced body also opens with the title. Counterpart of the guard, which
@@ -1771,9 +1625,9 @@
1771 1625 return $content;
1772 1626 }
1773 1627
1774 1628 # 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));
1629 + $h1_text = trim(html_entity_decode(wp_strip_all_tags($h1_match[1]), ENT_QUOTES | ENT_HTML5));
1776 1630 $title_text = trim(html_entity_decode((string) $title, ENT_QUOTES | ENT_HTML5));
1777 1631 if ($h1_text === '' || strcasecmp($h1_text, $title_text) !== 0) {
1778 1632 return $content;
1779 1633 }
@@ -2042,13 +1896,13 @@
2042 1896 $new_post['post_name'] = sanitize_text_field($item['permalink']);
2043 1897 }
2044 1898
2045 1899 if (isset($item['post_date']) && !empty($item['post_date'])) {
2046 - $is_valid_date = date('Y-m-d', strtotime($item['post_date'])) === $item['post_date'];
1900 + $is_valid_date = gmdate('Y-m-d', strtotime($item['post_date'])) === $item['post_date'];
2047 1901 if (!$is_valid_date) {
2048 1902 return new WP_Error(
2049 1903 'rest_post_invalid_date',
2050 - esc_html__('Post date is not valid'),
1904 + esc_html__('Post date is not valid', 'metasync'),
2051 1905 array('status' => 400)
2052 1906 );
2053 1907 }
2054 1908
@@ -2165,13 +2019,17 @@
2165 2019 $post_meta['searchatlas_embed_bottom'] = $item['searchatlas_embed_bottom'];
2166 2020 }
2167 2021
2168 2022 // 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 - }
2023 + $this->metasync_apply_builder_meta($post_id, $post_meta, false);
2024 +
2025 + // A freshly inserted post has no template of its own yet; match it to
2026 + // the one this site uses for its other posts.
2027 + $this->metasync_adopt_site_post_template(
2028 + $post_id,
2029 + isset($new_post['post_type']) ? $new_post['post_type'] : 'post',
2030 + (NULL === $getPostID_byURL)
2031 + );
2174 2032
2175 2033
2176 2034 $attachment_id = '';
2177 2035 if (isset($item['hero_image_url']) && !empty($item['hero_image_url'])) {
@@ -2189,8 +2047,14 @@
2189 2047 $redirection['url'] = sanitize_url($item['redirection_url']);
2190 2048 }
2191 2049 if (!empty($redirection)) {
2192 2050 update_post_meta($post_id, 'metasync_post_redirection_meta', $redirection);
2051 + // REST/Gutenberg saves never reach the metabox nonce path, so
2052 + // the stored meta would never materialize a redirect row.
2053 + // Sync it here, same as the classic save hook does.
2054 + if (class_exists('Metasync_Post_Meta_Settings')) {
2055 + Metasync_Post_Meta_Settings::sync_post_redirect_rule($post_id, $redirection);
2056 + }
2193 2057 }
2194 2058
2195 2059 $post_cattegories = [];
2196 2060 # if ($new_post['post_type'] === 'post' && is_array(@$item['post_categories'])) {
@@ -2250,13 +2114,10 @@
2250 2114 # Track Content Genius event in GA4
2251 2115 try {
2252 2116 Metasync_GA4::get_instance()->track_content_genius_event($post_id, strtolower($action));
2253 2117 } catch (Exception $e) {
2254 - error_log('MetaSync: Analytics tracking failed for Content Genius - ' . $e->getMessage());
2118 + 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 2119 }
2256 -
2257 - // Google Indexing Integration
2258 - $this->metasync_google_index_post($post_id, $new_post['post_type'], $new_post['post_status']);
2259 2120 }
2260 2121
2261 2122 # For standard synced posts, learn (once per post-type) whether the
2262 2123 # theme renders the title, so future syncs prepend only when genuinely needed.
@@ -2265,9 +2126,9 @@
2265 2126 && $new_post['post_status'] === 'publish') {
2266 2127 try {
2267 2128 $this->record_theme_title_verdict($post_id, $permalink, $new_post['post_title'], $new_post['post_type']);
2268 2129 } catch (Exception $e) {
2269 - error_log('MetaSync title verdict failed: ' . $e->getMessage());
2130 + error_log('MetaSync title verdict failed: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
2270 2131 }
2271 2132 }
2272 2133
2273 2134 $respCreatePosts[$index] = array_merge($new_post, $post_meta);
@@ -2312,9 +2173,9 @@
2312 2173 }
2313 2174
2314 2175 public function delete_item()
2315 2176 {
2316 - $get_data = metasync_sanitize_input_array($_GET);
2177 + $get_data = metasync_sanitize_input_array($_GET); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- REST route authenticated by API key, not cookies
2317 2178 if (!isset($get_data['ID']))
2318 2179 return false;
2319 2180
2320 2181 $post_id = sanitize_text_field($get_data['ID']) ?? null;
@@ -2320,18 +2181,18 @@
2320 2181 $post_id = sanitize_text_field($get_data['ID']) ?? null;
2321 2182 $post = get_post($post_id);
2322 2183 if ($post) {
2323 2184 wp_delete_post($post_id);
2185 + // HTTP 204 requires no body for response, so no message is set.
2324 2186 return new WP_Error(
2325 2187 'rest_post_delete_success',
2326 - esc_html__(''),
2327 - // HTTP 204 requires no body for response
2188 + '',
2328 2189 array('status' => 204)
2329 2190 );
2330 2191 }
2331 2192 return new WP_Error(
2332 2193 'rest_post_delete_fail',
2333 - esc_html__('No post found in the database with requested ID.'),
2194 + esc_html__('No post found in the database with requested ID.', 'metasync'),
2334 2195 array('status' => 400)
2335 2196 );
2336 2197 }
2337 2198
@@ -2336,9 +2197,9 @@
2336 2197 }
2337 2198
2338 2199 public function get_items($request)
2339 2200 {
2340 - $get_data = metasync_sanitize_input_array($_GET);
2201 + $get_data = metasync_sanitize_input_array($_GET); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- REST route authenticated by API key, not cookies
2341 2202
2342 2203 # let us check if request send post id and is valid int
2343 2204 if(isset($get_data['post_id']) AND intval($get_data['post_id']) > 0){
2344 2205
@@ -2678,9 +2539,9 @@
2678 2539 $post_data = get_post($post_id);
2679 2540 if (!$post_data) {
2680 2541 return new WP_Error(
2681 2542 'rest_post_update_fail',
2682 - esc_html__('No post found in the database with requested ID.'),
2543 + esc_html__('No post found in the database with requested ID.', 'metasync'),
2683 2544 array('status' => 400)
2684 2545 );
2685 2546 }
2686 2547
@@ -2818,40 +2679,8 @@
2818 2679 );
2819 2680 }
2820 2681
2821 2682
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 2683 $post_cattegories = [];
2855 2684 # if ($post_data && $post_data->post_type === 'post' && is_array(@$post['post_categories'])) {
2856 2685
2857 2686 # fixed Undefined array key issue
@@ -2875,11 +2704,9 @@
2875 2704 }
2876 2705
2877 2706 $resp_update = $this->update_object($post_id, $update_params);
2878 2707 if(isset($content['elementor_meta_data'])){
2879 - foreach ($content['elementor_meta_data'] as $key => $value) {
2880 - update_post_meta($post_id, $key, $value);
2881 - }
2708 + $this->metasync_apply_builder_meta($post_id, $content['elementor_meta_data']);
2882 2709 if ( did_action( 'elementor/loaded' ) ) {
2883 2710 // Clear Elementor cache for the specified post ID
2884 2711 \Elementor\Plugin::instance()->files_manager->clear_cache();
2885 2712 }
@@ -2894,8 +2721,15 @@
2894 2721 delete_post_meta($post_id, '_elementor_page_assets');
2895 2722 delete_post_meta($post_id, '_elementor_page_settings');
2896 2723 }
2897 2724
2725 + // Existing post: records the template decision without changing it.
2726 + $this->metasync_adopt_site_post_template(
2727 + $post_id,
2728 + get_post_type($post_id),
2729 + false
2730 + );
2731 +
2898 2732 $redirection = array();
2899 2733 if (!empty($post['redirection_enable']) && !is_null($post['redirection_enable'])) {
2900 2734 $redirection['enable'] = sanitize_text_field($post['redirection_enable']);
2901 2735 }
@@ -2906,8 +2740,14 @@
2906 2740 $redirection['url'] = sanitize_url($post['redirection_url']);
2907 2741 }
2908 2742 if (!empty($redirection)) {
2909 2743 update_post_meta($post_id, 'metasync_post_redirection_meta', $redirection);
2744 + // REST/Gutenberg saves never reach the metabox nonce path, so
2745 + // the stored meta would never materialize a redirect row.
2746 + // Sync it here, same as the classic save hook does.
2747 + if (class_exists('Metasync_Post_Meta_Settings')) {
2748 + Metasync_Post_Meta_Settings::sync_post_redirect_rule($post_id, $redirection);
2749 + }
2910 2750 }
2911 2751
2912 2752
2913 2753 $post_revisions = wp_get_post_revisions($post_id);
@@ -2945,14 +2785,9 @@
2945 2785
2946 2786 }
2947 2787 # update the content
2948 2788 // 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 - }
2789 + $this->metasync_apply_builder_meta($post_id, $post_meta_data);
2955 2790 #check if the elementor plugin is active
2956 2791 if ( did_action( 'elementor/loaded' ) ) {
2957 2792 # Clear Elementor cache for the specified post ID
2958 2793 \Elementor\Plugin::instance()->files_manager->clear_cache();
@@ -2989,9 +2824,9 @@
2989 2824 # Track Content Genius event in GA4
2990 2825 try {
2991 2826 Metasync_GA4::get_instance()->track_content_genius_event($post_id, 'updated');
2992 2827 } catch (Exception $e) {
2993 - error_log('MetaSync: Analytics tracking failed for Content Genius - ' . $e->getMessage());
2828 + 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 2829 }
2995 2830
2996 2831 # Learn (once per post-type) whether the theme renders the title,
2997 2832 # so future syncs prepend only when the theme genuinely omits it.
@@ -2999,9 +2834,9 @@
2999 2834 && $sync_status === 'published') {
3000 2835 try {
3001 2836 $this->record_theme_title_verdict($post_id, ($permalink ?? get_permalink($post_id)), $post_title, $post_type);
3002 2837 } catch (Exception $e) {
3003 - error_log('MetaSync title verdict failed: ' . $e->getMessage());
2838 + error_log('MetaSync title verdict failed: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
3004 2839 }
3005 2840 }
3006 2841 }
3007 2842
@@ -3098,20 +2933,9 @@
3098 2933 if($isOttoAiPage){
3099 2934 // Change the page template from default to Metasync Template
3100 2935 $pageTemplate = Metasync_Template::TEMPLATE_NAME;
3101 2936 }
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 - }
2937 + $this->metasync_write_page_template($post_id, $pageTemplate);
3114 2938 }
3115 2939
3116 2940 return rest_ensure_response($createPages);
3117 2941 }
@@ -3134,9 +2958,9 @@
3134 2958 $post_data = get_post($payload['post_id']);
3135 2959 if(!isset($post_data->post_type)){
3136 2960 return new WP_Error(
3137 2961 'rest_page_type_fail',
3138 - esc_html__('No page found in the database with requested ID.'),
2962 + esc_html__('No page found in the database with requested ID.', 'metasync'),
3139 2963 array('status' => 400)
3140 2964 );
3141 2965 }
3142 2966 if ($post_data->post_type !== 'page') {
@@ -3191,20 +3015,225 @@
3191 3015 if($isOttoAiPage){
3192 3016 // Change the page template from default to Metasync Template
3193 3017 $pageTemplate = Metasync_Template::TEMPLATE_NAME;
3194 3018 }
3195 - if ($pageTemplate !== 'default') {
3196 - update_post_meta($post_id, '_wp_page_template', $pageTemplate);
3019 + $this->metasync_write_page_template($post_id, $pageTemplate);
3020 + }
3021 + return rest_ensure_response($updatePages->data);
3022 + }
3023 +
3024 + /**
3025 + * The page template this site actually uses for a given post type.
3026 + *
3027 + * Content Genius has no idea which template a site's posts are built on, and
3028 + * neither does the converter. Hardcoding one downgraded every synced post;
3029 + * leaving the field empty is no better, because WordPress then falls back to
3030 + * the theme's default single template -- on a theme-builder site that is a
3031 + * bare, unstyled layout.
3032 + *
3033 + * So infer it: whatever the clear majority of this site's existing published
3034 + * entries of the same type already use.
3035 + *
3036 + * @param string $post_type
3037 + * @return string Template slug, or '' when the site has no clear convention.
3038 + */
3039 + private function metasync_prevailing_post_template($post_type)
3040 + {
3041 + $post_type = sanitize_key($post_type);
3042 +
3043 + if ('' === $post_type) {
3044 + return '';
3045 + }
3046 +
3047 + $cache_key = 'metasync_prevailing_tpl_' . $post_type;
3048 + $cached = get_transient($cache_key);
3049 +
3050 + if (false !== $cached) {
3051 + return is_string($cached) ? $cached : '';
3052 + }
3053 +
3054 + global $wpdb;
3055 +
3056 + // Never propagate a template this plugin wrote itself.
3057 + $owned = $this->metasync_plugin_owned_templates();
3058 +
3059 + $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint over the plugin custom table — no WordPress API exists
3060 + $wpdb->prepare(
3061 + 'SELECT pm.meta_value AS tpl, COUNT(*) AS hits
3062 + FROM ' . $wpdb->postmeta . ' pm
3063 + INNER JOIN ' . $wpdb->posts . " p ON p.ID = pm.post_id
3064 + WHERE pm.meta_key = '_wp_page_template'
3065 + AND pm.meta_value <> ''
3066 + AND pm.meta_value <> 'default'
3067 + AND pm.meta_value NOT IN (" . implode(',', array_fill(0, count($owned), '%s')) . ")
3068 + AND p.post_type = %s
3069 + AND p.post_status = 'publish'
3070 + GROUP BY pm.meta_value
3071 + ORDER BY hits DESC",
3072 + array_merge($owned, array($post_type))
3073 + )
3074 + );
3075 +
3076 + $prevailing = '';
3077 +
3078 + if (!empty($rows)) {
3079 + // Measure the majority against every published entry of this type,
3080 + // not just the ones that set a template. Counting only the latter
3081 + // would let a handful of outliers on a special template read as a
3082 + // site-wide convention on a site whose posts otherwise use the
3083 + // theme default.
3084 + $total = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint over the plugin custom table — no WordPress API exists
3085 + $wpdb->prepare(
3086 + "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = %s AND post_status = 'publish'",
3087 + $post_type
3088 + )
3089 + );
3090 +
3091 + $top = (int) $rows[0]->hits;
3092 +
3093 + // Only adopt a convention that is genuinely a convention: several
3094 + // posts, and a clear majority of the site's published entries.
3095 + if ($top >= 3 && $total > 0 && ($top / $total) >= 0.6) {
3096 + $prevailing = (string) $rows[0]->tpl;
3097 + }
3098 + }
3099 +
3100 + set_transient($cache_key, $prevailing, 6 * HOUR_IN_SECONDS);
3101 +
3102 + return $prevailing;
3103 + }
3104 +
3105 + /**
3106 + * Give a synced post the template the rest of the site uses.
3107 + *
3108 + * Only ever fills a blank -- an existing choice is never touched.
3109 + *
3110 + * Beyond brand-new inserts this also covers two cases that cannot regress
3111 + * anything a visitor is currently seeing: posts this plugin created itself
3112 + * (marked with `metasync_post`), and posts that have never been published.
3113 + * Everything else -- content the site author built by hand -- is left alone.
3114 + *
3115 + * @param int $post_id
3116 + * @param string $post_type
3117 + * @param bool $is_new Whether this sync actually inserted the post.
3118 + * @return void
3119 + */
3120 + private function metasync_adopt_site_post_template($post_id, $post_type, $is_new)
3121 + {
3122 + if (!$post_id) {
3123 + return;
3124 + }
3125 +
3126 + $current = (string) get_post_meta($post_id, '_wp_page_template', true);
3127 +
3128 + if ('' !== $current && 'default' !== $current) {
3129 + return;
3130 + }
3131 +
3132 + $is_plugin_post = ('yes' === get_post_meta($post_id, 'metasync_post', true));
3133 + $never_published = in_array(get_post_status($post_id), array('draft', 'pending', 'auto-draft'), true);
3134 +
3135 + if (!$is_new && !$is_plugin_post && !$never_published) {
3136 + return;
3137 + }
3138 +
3139 + $prevailing = $this->metasync_prevailing_post_template($post_type);
3140 +
3141 + if ('' === $prevailing) {
3142 + return;
3143 + }
3144 +
3145 + update_post_meta($post_id, '_wp_page_template', $prevailing);
3146 + }
3147 +
3148 + /**
3149 + * Page templates this plugin writes itself.
3150 + *
3151 + * Only these may be cleared when a sync carries the neutral 'default' value.
3152 + * Anything else stored in `_wp_page_template` was chosen in the editor and
3153 + * has to survive the sync -- notably 'elementor_header_footer' (Elementor
3154 + * Full Width), which the plugin never writes, so finding it there always
3155 + * means the user selected it.
3156 + *
3157 + * @return array
3158 + */
3159 + private function metasync_plugin_owned_templates()
3160 + {
3161 + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-template.php';
3162 +
3163 + return array(Metasync_Template::TEMPLATE_NAME, 'elementor_canvas');
3164 + }
3165 +
3166 + /**
3167 + * Whether converter-generated builder meta may set `_wp_page_template`.
3168 + *
3169 + * Everything reaching this point was asserted by the converter, not chosen
3170 + * by anyone -- including 'elementor_canvas', which it emits for every page.
3171 + * So the incoming value carries no authority: builder meta may fill a blank
3172 + * and may replace a template this plugin wrote itself, but it must never
3173 + * overwrite one selected in the editor.
3174 + *
3175 + * Deliberate templates (OTTO pages, landing pages) do not come through here;
3176 + * they are written by metasync_write_page_template().
3177 + *
3178 + * @param int $post_id
3179 + * @return bool
3180 + */
3181 + private function metasync_can_write_page_template($post_id)
3182 + {
3183 + $current = get_post_meta($post_id, '_wp_page_template', true);
3184 +
3185 + // Nothing worth keeping.
3186 + if ('' === $current || 'default' === $current) {
3187 + return true;
3188 + }
3189 +
3190 + // Only replace leftovers this plugin put there in the first place.
3191 + return in_array($current, $this->metasync_plugin_owned_templates(), true);
3192 + }
3193 +
3194 + /**
3195 + * Store a page template, preserving one the user selected.
3196 + *
3197 + * @param int $post_id
3198 + * @param string $pageTemplate
3199 + * @return void
3200 + */
3201 + private function metasync_write_page_template($post_id, $pageTemplate)
3202 + {
3203 + if ('default' !== $pageTemplate) {
3204 + update_post_meta($post_id, '_wp_page_template', $pageTemplate);
3205 + return;
3206 + }
3207 +
3208 + $current = get_post_meta($post_id, '_wp_page_template', true);
3209 +
3210 + if ('' !== $current && in_array($current, $this->metasync_plugin_owned_templates(), true)) {
3211 + delete_post_meta($post_id, '_wp_page_template');
3212 + }
3213 + }
3214 +
3215 + /**
3216 + * Apply builder meta while protecting the post's chosen page template.
3217 + *
3218 + * @param int $post_id
3219 + * @param array $meta
3220 + * @param bool $overwrite Replace existing meta, or only add when absent.
3221 + * @return void
3222 + */
3223 + private function metasync_apply_builder_meta($post_id, array $meta, $overwrite = true)
3224 + {
3225 + foreach ($meta as $key => $value) {
3226 + if ('_wp_page_template' === $key && !$this->metasync_can_write_page_template($post_id)) {
3227 + continue;
3228 + }
3229 +
3230 + if ($overwrite) {
3231 + update_post_meta($post_id, $key, $value);
3197 3232 } 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 - }
3233 + add_post_meta($post_id, $key, $value, true);
3204 3234 }
3205 3235 }
3206 - return rest_ensure_response($updatePages->data);
3207 3236 }
3208 3237
3209 3238 public function delete_page()
3210 3239 {
@@ -3230,8 +3259,17 @@
3230 3259 }
3231 3260
3232 3261 public function linkgraph_login()
3233 3262 {
3263 + # Relays caller-supplied credentials to the token API, so verify the
3264 + # nonce sent by admin/js (metasyncLGLogin) and confirm plugin access
3265 + # first; otherwise any logged-in user could use the site as a proxy.
3266 + check_ajax_referer('metasync_lglogin', 'nonce');
3267 +
3268 + if (!Metasync::current_user_has_plugin_access()) {
3269 + wp_send_json_error(['message' => 'Not authorized']);
3270 + }
3271 +
3234 3272 $post_data = metasync_sanitize_input_array($_POST);
3235 3273 $payload = array(
3236 3274 'username' => wp_unslash(sanitize_email($post_data['username'])),
3237 3275 'password' => wp_unslash(sanitize_text_field($post_data['password']))
@@ -3248,9 +3286,9 @@
3248 3286 ));
3249 3287
3250 3288 # Error handling for timeout or connection failures
3251 3289 if (is_wp_error($response)) {
3252 - error_log('MetaSync: Token API failed: ' . $response->get_error_message());
3290 + 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 3291 wp_send_json_error(array('message' => 'Token API request failed'));
3254 3292 wp_die();
3255 3293 }
3256 3294
@@ -3384,9 +3422,9 @@
3384 3422 $option_name = '_transient_metasync_sa_connect_active_' . $nonce_token;
3385 3423 $timeout_name = '_transient_timeout_metasync_sa_connect_active_' . $nonce_token;
3386 3424
3387 3425 // Check expiry first
3388 - $timeout = $wpdb->get_var(
3426 + $timeout = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
3389 3427 $wpdb->prepare(
3390 3428 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
3391 3429 $timeout_name
3392 3430 )
@@ -3393,14 +3431,14 @@
3393 3431 );
3394 3432
3395 3433 if ($timeout && (int) $timeout < time()) {
3396 3434 // 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));
3435 + $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
3436 + $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 3437 return false;
3400 3438 }
3401 3439
3402 - $value = $wpdb->get_var(
3440 + $value = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
3403 3441 $wpdb->prepare(
3404 3442 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
3405 3443 $option_name
3406 3444 )
@@ -3424,9 +3462,9 @@
3424 3462 $option_name = '_transient_' . $transient_key;
3425 3463 $timeout_name = '_transient_timeout_' . $transient_key;
3426 3464
3427 3465 // Check expiry first
3428 - $timeout = $wpdb->get_var(
3466 + $timeout = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
3429 3467 $wpdb->prepare(
3430 3468 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
3431 3469 $timeout_name
3432 3470 )
@@ -3432,14 +3470,14 @@
3432 3470 )
3433 3471 );
3434 3472
3435 3473 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));
3474 + $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
3475 + $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 3476 return false;
3439 3477 }
3440 3478
3441 - $value = $wpdb->get_var(
3479 + $value = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
3442 3480 $wpdb->prepare(
3443 3481 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
3444 3482 $option_name
3445 3483 )
@@ -3466,10 +3504,10 @@
3466 3504 {
3467 3505 delete_transient($transient_name);
3468 3506 if (wp_using_ext_object_cache()) {
3469 3507 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));
3508 + $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
3509 + $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 3510 }
3473 3511 }
3474 3512
3475 3513 /**
@@ -3486,14 +3524,14 @@
3486 3524 {
3487 3525 set_transient($transient_name, $value, $expiration);
3488 3526 if (wp_using_ext_object_cache()) {
3489 3527 global $wpdb;
3490 - $wpdb->replace($wpdb->options, array(
3528 + $wpdb->replace($wpdb->options, array( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
3491 3529 'option_name' => '_transient_' . $transient_name,
3492 3530 'option_value' => maybe_serialize($value),
3493 3531 'autoload' => 'no',
3494 3532 ));
3495 - $wpdb->replace($wpdb->options, array(
3533 + $wpdb->replace($wpdb->options, array( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
3496 3534 'option_name' => '_transient_timeout_' . $transient_name,
3497 3535 'option_value' => time() + $expiration,
3498 3536 'autoload' => 'no',
3499 3537 ));
@@ -3930,9 +3968,9 @@
3930 3968 } elseif (strlen($whitelabel_domain) > 255) {
3931 3969 $validation_errors['whitelabel_domain'] = 'Whitelabel domain must not exceed 255 characters';
3932 3970 } elseif (!filter_var($whitelabel_domain, FILTER_VALIDATE_URL)) {
3933 3971 $validation_errors['whitelabel_domain'] = 'Whitelabel domain must be a valid URL';
3934 - } elseif (!in_array(parse_url($whitelabel_domain, PHP_URL_SCHEME), ['http', 'https'])) {
3972 + } elseif (!in_array(wp_parse_url($whitelabel_domain, PHP_URL_SCHEME), ['http', 'https'])) {
3935 3973 $validation_errors['whitelabel_domain'] = 'Whitelabel domain must use http or https protocol';
3936 3974 }
3937 3975 }
3938 3976
@@ -3945,9 +3983,9 @@
3945 3983 $validation_errors['whitelabel_logo'] = 'Whitelabel logo URL must not exceed 500 characters';
3946 3984 } else {
3947 3985 // If URL is invalid, we'll clear it later but not fail the POST
3948 3986 if (!filter_var($whitelabel_logo, FILTER_VALIDATE_URL) ||
3949 - !in_array(parse_url($whitelabel_logo, PHP_URL_SCHEME), ['http', 'https'])) {
3987 + !in_array(wp_parse_url($whitelabel_logo, PHP_URL_SCHEME), ['http', 'https'])) {
3950 3988 // Don't add to validation_errors - let POST succeed but clear the field
3951 3989 }
3952 3990 }
3953 3991 }
@@ -4073,11 +4111,20 @@
4073 4111 if (!empty($whitelabel_otto)) {
4074 4112 $options['general']['whitelabel_otto_name'] = $whitelabel_otto;
4075 4113 }
4076 4114 } else {
4077 - // Clear whitelabel fields when not whitelabel
4078 - unset($options['general']['white_label_plugin_name']);
4079 - unset($options['general']['whitelabel_otto_name']);
4115 + // Clear whitelabel fields when not whitelabel.
4116 + //
4117 + // Blanked rather than unset. This array is saved through
4118 + // Metasync::set_option(), and the settings sanitizer merges it
4119 + // over the stored option, so a key the array simply omits keeps
4120 + // whatever was stored — an unset here left the previous
4121 + // agency's plugin name and OTTO name in place forever. An empty
4122 + // string is a value the merge carries, and both readers
4123 + // (get_effective_plugin_name(), get_whitelabel_otto_name())
4124 + // test with !empty(), so it reads exactly like an absent key.
4125 + $options['general']['white_label_plugin_name'] = '';
4126 + $options['general']['whitelabel_otto_name'] = '';
4080 4127 }
4081 4128
4082 4129 // Store whitelabel settings (hidden from UI but accessible to plugin logic)
4083 4130 if (!isset($options['whitelabel'])) {
@@ -4110,9 +4157,9 @@
4110 4157 $save_result = Metasync::set_option($options);
4111 4158 Metasync::invalidate_api_key_cache();
4112 4159
4113 4160 if (!$save_result) {
4114 - error_log('MetaSync SA Connect: mark_searchatlas_nonce_used - Failed to save plugin options');
4161 + 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 4162 } else {
4116 4163 if ($status_code === 200) {
4117 4164 // Option 1: Set heartbeat cache and last-known to CONNECTED so dashboard shows iframe immediately.
4118 4165 // If the immediate heartbeat then fails (e.g. backoff), Option 2 prevents overwriting this to DISCONNECTED.
@@ -4131,9 +4178,9 @@
4131 4178
4132 4179 return true;
4133 4180
4134 4181 } catch (Exception $e) {
4135 - error_log('MetaSync SA Connect: mark_searchatlas_nonce_used Error - ' . $e->getMessage());
4182 + 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 4183 return false;
4137 4184 }
4138 4185 }
4139 4186
@@ -4145,9 +4192,9 @@
4145 4192 {
4146 4193 global $wpdb;
4147 4194
4148 4195 // Clear all JWT token transients
4149 - $deleted = $wpdb->query(
4196 + $deleted = $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
4150 4197 $wpdb->prepare(
4151 4198 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
4152 4199 '_transient_metasync_jwt_token_%'
4153 4200 )
@@ -4153,9 +4200,9 @@
4153 4200 )
4154 4201 );
4155 4202
4156 4203 // Also clear timeout transients
4157 - $wpdb->query(
4204 + $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
4158 4205 $wpdb->prepare(
4159 4206 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
4160 4207 '_transient_timeout_metasync_jwt_token_%'
4161 4208 )
@@ -4178,9 +4225,9 @@
4178 4225 // Also ensure heartbeat cron is scheduled now that we have an API key
4179 4226 do_action('metasync_ensure_heartbeat_cron_scheduled');
4180 4227
4181 4228 } catch (Exception $e) {
4182 - error_log('MetaSync SA Connect: Error triggering immediate heartbeat check - ' . $e->getMessage());
4229 + 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 4230 }
4184 4231 }
4185 4232
4186 4233 public function get_item_schema()
@@ -4198,15 +4245,15 @@
4198 4245 'type' => 'object',
4199 4246 // In JSON Schema you can specify object properties in the properties attribute.
4200 4247 'properties' => array(
4201 4248 'id' => array(
4202 - 'description' => esc_html__('Unique identifier for the object.', 'my-textdomain'),
4249 + 'description' => esc_html__('Unique identifier for the object.', 'metasync'),
4203 4250 'type' => 'integer',
4204 4251 'context' => array('view', 'edit', 'embed'),
4205 4252 'readonly' => true,
4206 4253 ),
4207 4254 'content' => array(
4208 - 'description' => esc_html__('The content for the object.', 'my-textdomain'),
4255 + 'description' => esc_html__('The content for the object.', 'metasync'),
4209 4256 'type' => 'string',
4210 4257 ),
4211 4258 ),
4212 4259 );
@@ -4297,11 +4344,11 @@
4297 4344
4298 4345 # Try alternative parameter methods
4299 4346 $body_params = $request->get_body_params();
4300 4347 $key_param = $request->get_param('key');
4301 - $post_key = $_POST['key'] ?? null;
4302 - $request_key = $_REQUEST['key'] ?? null;
4303 - $get_key = $_GET['key'] ?? null;
4348 + $post_key = $_POST['key'] ?? null; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- REST route authenticated by API key, not cookies
4349 + $request_key = $_REQUEST['key'] ?? null; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- REST route authenticated by API key, not cookies
4350 + $get_key = $_GET['key'] ?? null; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- REST route authenticated by API key, not cookies
4304 4351
4305 4352 # Try to get key from multiple sources
4306 4353 $key_value = null;
4307 4354
@@ -4393,9 +4440,15 @@
4393 4440 # delete it so it stops shadowing the virtual route and let WordPress serve
4394 4441 # the key from PHP instead.
4395 4442 $serving = 'virtual';
4396 4443
4397 - if (is_writable($wp_root) && @file_put_contents($file_path, $safe_key) !== false) {
4444 + # ABSPATH is intentional and verified: the Bing site-verification key
4445 + # must be served from the site's web root exactly as Bing fetches it
4446 + # (/{key}.txt). $safe_key is basename()+sanitize_file_name()'d and the
4447 + # realpath check above confines $file_path to the root, so this cannot
4448 + # escape it. Plugin Check's ABSPATHDetected warning is a false
4449 + # positive — a verification file cannot live in the uploads directory.
4450 + if (wp_is_writable($wp_root) && @file_put_contents($file_path, $safe_key) !== false) {
4398 4451 # Cache-buster avoids a stale 404 cached by a CDN/nginx for this path.
4399 4452 $probe = wp_remote_get(
4400 4453 home_url('/' . $safe_key . '.txt?_msverify=' . time()),
4401 4454 array('timeout' => 10, 'sslverify' => false)
@@ -4407,9 +4460,9 @@
4407 4460 $serving = 'physical';
4408 4461 } else {
4409 4462 # 403 / 404 / wrong body — a present file shadows the virtual route,
4410 4463 # so remove it and let WordPress serve the key instead.
4411 - @unlink($file_path);
4464 + @wp_delete_file($file_path);
4412 4465 $serving = 'virtual';
4413 4466 }
4414 4467 }
4415 4468