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 +642 -322 2.6.17 → 2.7.2 View file →
@@ -46,10 +46,8 @@
46 46 private $version;
47 47
48 48 private const namespace = "metasync/v1";
49 49
50 - private $escapers;
51 - private $replacements;
52 50 private $common;
53 51 private $allowed_attributes;
54 52 private $schema;
55 53 private $metasync_option_data;
@@ -74,21 +72,13 @@
74 72 'post_name',
75 73 'post_parent',
76 74 'post_status',
77 75 );
78 - $this->escapers = array("\\", "/", "\"");
79 - $this->replacements = array("", "", "");
80 76 $this->common = new Metasync_Common();
81 77 // get all options
82 78 $this->metasync_option_data = Metasync::get_option('general');
83 - $this->init_ajax_hooks();
84 79 }
85 80
86 - public function init_ajax_hooks()
87 - {
88 - add_action('wp_ajax_metasync_otto_ajax_action', array($this,'metasyn_otto_ajax'));
89 - }
90 -
91 81 /**
92 82 * Encode non-ASCII characters in a UTF-8 string as numeric HTML entities.
93 83 *
94 84 * Uses mb_encode_numericentity when the mbstring extension is loaded;
@@ -174,86 +164,9 @@
174 164 }
175 165 return $posts;
176 166 }
177 167
178 - public function metasyn_otto_ajax() {
179 - // Check nonce for security
180 - if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'otto_nonce')) {
181 - wp_send_json_error('Invalid nonce');
182 - wp_die();
183 - }
184 - $post_id = sanitize_text_field($_POST['post_id']);
185 - $current_url = get_permalink($post_id);
186 -
187 - $header_html = get_post_meta($post_id, '_otto_header_html_json', true);
188 168
189 - // Example API call using wp_remote_get()
190 -
191 -
192 - if (is_wp_error($header_html)) {
193 - wp_send_json_error('API call failed');
194 - } else {
195 - wp_send_json_success(json_decode($header_html, true));
196 - }
197 -
198 - wp_die(); // Always terminate after an AJAX call
199 - }
200 -
201 - public function otto_header_data() {
202 - global $post;
203 -
204 - // Get the current post ID
205 - $post_id = $post->ID;
206 -
207 - // Get the current time and the last update time from post meta
208 - $current_time = current_time('timestamp');
209 - $last_update_time = get_post_meta($post_id, '_otto_last_update_time', true);
210 -
211 - // Set the interval for 24 hours (in seconds)
212 - $interval = 24 * 60 * 60;
213 -
214 - // Check if the last update time is set or if 24 hours have passed
215 - if (!$last_update_time || ($current_time - $last_update_time) >= $interval) {
216 - // Get the current URL
217 - $current_url = get_permalink($post_id);
218 -
219 - // Use endpoint manager to get the correct API URL
220 - $api_endpoint = class_exists('Metasync_Endpoint_Manager')
221 - ? Metasync_Endpoint_Manager::get_endpoint('OTTO_URL_DETAILS')
222 - : 'https://sa.searchatlas.com/api/v2/otto-url-details';
223 -
224 - // Call the API
225 - $response = wp_remote_get($api_endpoint . '/?url=' . urlencode($current_url));
226 -
227 - // Check if the API call was successful
228 - if (!is_wp_error($response)) {
229 - $body = wp_remote_retrieve_body($response);
230 - $data = json_decode($body, true); // Decode JSON into an associative array
231 -
232 - // Update the post meta with the new data and timestamp
233 - update_post_meta($post_id, '_otto_header_html', $data['header_html_insertion']);
234 - update_post_meta($post_id, '_otto_last_update_time', $current_time);
235 - }
236 - }
237 -
238 - // Get the saved HTML from post meta
239 - $header_html = get_post_meta($post_id, '_otto_header_html', true);
240 -
241 - // Display the HTML with security measures
242 - if ($header_html) {
243 - echo "<!-- Otto Start -->";
244 - // SECURITY FIX: Sanitize HTML to prevent XSS while allowing safe HTML
245 - echo wp_kses($header_html, array(
246 - 'style' => array(),
247 - 'link' => array('rel' => array(), 'href' => array(), 'type' => array()),
248 - 'meta' => array('name' => array(), 'content' => array(), 'property' => array()),
249 - 'script' => array('type' => array(), 'src' => array()),
250 - // Add other safe tags as needed
251 - ));
252 - echo "<!-- Otto End -->";
253 - }
254 - }
255 -
256 169 public function rest_authorization_middleware($request = null)
257 170 {
258 171 $api_key = '';
259 172
@@ -269,11 +182,11 @@
269 182 if (empty($api_key) && isset($_SERVER['HTTP_X_API_KEY'])) {
270 183 $api_key = sanitize_text_field(wp_unslash($_SERVER['HTTP_X_API_KEY']));
271 184 }
272 185
273 - // Fallback: ?apikey= query param (deprecated)
274 - if (empty($api_key) && isset($_GET['apikey'])) {
275 - $api_key = sanitize_text_field($_GET['apikey']);
186 + // Fallback: ?apikey= query param (deprecated, kept for platform compatibility)
187 + if (empty($api_key) && isset($_GET['apikey'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- API key credential in REST auth, not cookie auth
188 + $api_key = sanitize_text_field(wp_unslash($_GET['apikey'])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- API key credential in REST auth, not cookie auth
276 189 }
277 190
278 191 if (empty($api_key)) {
279 192 return false;
@@ -278,10 +191,9 @@
278 191 if (empty($api_key)) {
279 192 return false;
280 193 }
281 194
282 - $getOptions = Metasync::get_option('general');
283 - $stored_key = $getOptions['apikey'] ?? null;
195 + $stored_key = $this->get_stored_api_key();
284 196
285 197 if (empty($stored_key)) {
286 198 return false;
287 199 }
@@ -289,8 +201,23 @@
289 201 return hash_equals($stored_key, $api_key);
290 202 }
291 203
292 204 /**
205 + * The plugin API key configured in MetaSync settings.
206 + *
207 + * Single source for the stored key so every auth path compares against the
208 + * same value.
209 + *
210 + * @return string|null Configured key, or null when the plugin is unlinked.
211 + */
212 + protected function get_stored_api_key()
213 + {
214 + $getOptions = Metasync::get_option('general');
215 +
216 + return $getOptions['apikey'] ?? null;
217 + }
218 +
219 + /**
293 220 * Coexist with third-party JWT authentication plugins.
294 221 *
295 222 * A global JWT auth plugin (e.g. Tmeister's "JWT Authentication for WP-API")
296 223 * validates any "Authorization: Bearer ..." header as a JWT. MetaSync sends
@@ -340,9 +267,146 @@
340 267
341 268 return $result;
342 269 }
343 270
271 + /**
272 + * Coexist with site-wide "authenticated users only" REST restrictions.
273 + *
274 + * Security plugins and copy-pasted snippets commonly hook
275 + * rest_authentication_errors to reject any request without a logged-in user
276 + * (typically WP_Error 'rest_cannot_access', 401). That filter is applied by
277 + * WP_REST_Server::check_authentication(), which runs BEFORE dispatch:
278 + *
279 + * $result = $this->check_authentication();
280 + * if ( ! is_wp_error( $result ) ) {
281 + * $result = $this->dispatch( $request );
282 + * }
283 + *
284 + * So an error here short-circuits the whole request: rest_pre_dispatch never
285 + * fires, no permission_callback runs, and even routes registered with
286 + * __return_true answer 401. MetaSync authenticates with a plugin API key
287 + * rather than a WordPress session, so is_user_logged_in() is false for every
288 + * legitimate SearchAtlas call and article publishing fails site-wide.
289 + *
290 + * This filter runs late (priority 99) and clears such an error only when the
291 + * request both targets metasync/v1 and carries a valid MetaSync API key —
292 + * the key is itself proof of authentication. Any other namespace, and any
293 + * request without a valid key, keeps the site's restriction intact.
294 + *
295 + * The filter receives no WP_REST_Request (check_authentication() passes only
296 + * the prior result), so route and credentials are read from the request
297 + * globals instead.
298 + *
299 + * @param mixed $result Prior authentication result: null (no opinion), true, or WP_Error.
300 + * @return mixed Cleared (null) only when our key validates on a metasync/v1
301 + * route; otherwise the input $result unchanged.
302 + */
303 + public function allow_metasync_rest_authentication($result)
304 + {
305 + // Only a WP_Error blocks the request; true and null are already passing.
306 + if (!is_wp_error($result)) {
307 + return $result;
308 + }
344 309
310 + $route = $this->get_current_rest_route();
311 + if ($route === '') {
312 + return $result;
313 + }
314 +
315 + // Only intervene for our own namespace, so the site's restriction still
316 + // applies to core and third-party routes.
317 + $prefix = '/' . self::namespace;
318 + if (strpos($route, $prefix . '/') !== 0 && $route !== $prefix) {
319 + return $result;
320 + }
321 +
322 + if ($this->request_has_valid_api_key()) {
323 + return null;
324 + }
325 +
326 + return $result;
327 + }
328 +
329 + /**
330 + * Resolve the REST route being served, without a WP_REST_Request.
331 + *
332 + * @return string Route with a leading slash (e.g. "/metasync/v1/createItem"),
333 + * or '' when this is not a resolvable REST request.
334 + */
335 + protected function get_current_rest_route()
336 + {
337 + // rest_api_loaded() stores the resolved route on the main query.
338 + if (isset($GLOBALS['wp']) && is_object($GLOBALS['wp'])
339 + && !empty($GLOBALS['wp']->query_vars['rest_route'])) {
340 + return '/' . ltrim((string) $GLOBALS['wp']->query_vars['rest_route'], '/');
341 + }
342 +
343 + // ?rest_route= form, used when pretty permalinks are disabled.
344 + if (!empty($_GET['rest_route'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only route check for REST auth
345 + return '/' . ltrim(sanitize_text_field(wp_unslash($_GET['rest_route'])), '/'); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only route check for REST auth
346 + }
347 +
348 + // Last resort: the request path with the /wp-json/ prefix stripped.
349 + $uri = isset($_SERVER['REQUEST_URI']) ? (string) wp_unslash($_SERVER['REQUEST_URI']) : '';
350 + if ($uri === '') {
351 + return '';
352 + }
353 +
354 + $path = (string) wp_parse_url($uri, PHP_URL_PATH);
355 + $url_prefix = function_exists('rest_get_url_prefix') ? rest_get_url_prefix() : 'wp-json';
356 + $needle = '/' . trim((string) $url_prefix, '/') . '/';
357 +
358 + $pos = strpos($path, $needle);
359 + if ($pos === false) {
360 + return '';
361 + }
362 +
363 + return '/' . ltrim(substr($path, $pos + strlen($needle)), '/');
364 + }
365 +
366 + /**
367 + * Whether the current request carries a valid MetaSync API key.
368 + *
369 + * Reads credentials from the request globals so it works in filters that get
370 + * no WP_REST_Request. Accepts Authorization: Bearer and X-API-Key headers,
371 + * matching rest_authorization_middleware().
372 + *
373 + * @return bool True only when a presented key matches the configured one.
374 + */
375 + protected function request_has_valid_api_key()
376 + {
377 + $api_key = '';
378 +
379 + // Primary: Authorization: Bearer <token>. Some Apache/FastCGI setups
380 + // only expose the header under the REDIRECT_ prefix.
381 + $auth_header = '';
382 + if (!empty($_SERVER['HTTP_AUTHORIZATION'])) {
383 + $auth_header = (string) wp_unslash($_SERVER['HTTP_AUTHORIZATION']);
384 + } elseif (!empty($_SERVER['REDIRECT_HTTP_AUTHORIZATION'])) {
385 + $auth_header = (string) wp_unslash($_SERVER['REDIRECT_HTTP_AUTHORIZATION']);
386 + }
387 + if ($auth_header !== '' && preg_match('/^Bearer\s+(.+)$/i', $auth_header, $matches)) {
388 + $api_key = sanitize_text_field($matches[1]);
389 + }
390 +
391 + // Secondary: X-API-Key header.
392 + if ($api_key === '' && !empty($_SERVER['HTTP_X_API_KEY'])) {
393 + $api_key = sanitize_text_field(wp_unslash($_SERVER['HTTP_X_API_KEY']));
394 + }
395 +
396 + if ($api_key === '') {
397 + return false;
398 + }
399 +
400 + $stored_key = $this->get_stored_api_key();
401 + if (empty($stored_key)) {
402 + return false;
403 + }
404 +
405 + return hash_equals((string) $stored_key, $api_key);
406 + }
407 +
408 +
345 409 public function metasync_register_rest_routes()
346 410 {
347 411 // Critical Routes
348 412 /*
@@ -481,9 +545,9 @@
481 545 array(
482 546 array(
483 547 'methods' => 'GET',
484 548 'callback' => function () {
485 - $getPostID = url_to_postid(sanitize_url($_GET['url']));
549 + $getPostID = url_to_postid(sanitize_url(wp_unslash($_GET['url']))); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- REST route authenticated by API key, not cookies
486 550
487 551 if ($getPostID==0) {
488 552 $response = false;
489 553 }else{
@@ -1012,9 +1076,9 @@
1012 1076 }
1013 1077
1014 1078 public function get_errorlogs()
1015 1079 {
1016 - $get_data = metasync_sanitize_input_array($_GET);
1080 + $get_data = metasync_sanitize_input_array($_GET); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- REST route authenticated by API key, not cookies
1017 1081 if (!isset($get_data['limit']))
1018 1082 return false;
1019 1083 $limit = sanitize_text_field($get_data['limit']) ?? null;
1020 1084
@@ -1202,9 +1266,9 @@
1202 1266 }
1203 1267 } catch (Throwable $e) {
1204 1268 // Fallback to DOMDocument if Dom\HTMLDocument fails
1205 1269 if (defined('WP_DEBUG_LOG') && WP_DEBUG_LOG) {
1206 - error_log('MetaSync: Dom\HTMLDocument error, falling back to DOMDocument: ' . $e->getMessage());
1270 + error_log('MetaSync: Dom\HTMLDocument error, falling back to DOMDocument: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-gated, no secrets
1207 1271 }
1208 1272 $dom = new DOMDocument();
1209 1273 libxml_use_internal_errors(true);
1210 1274 $encoded_content = $this->encode_numeric_entities($content);
@@ -1404,9 +1468,9 @@
1404 1468 $image_id = attachment_url_to_postid($node->getAttribute('src') );
1405 1469 $image_alt = get_post_meta($image_id, '_wp_attachment_image_alt', TRUE);
1406 1470 $result ='[et_pb_image src="'.$node->getAttribute('src') .'" url="'.$node->getAttribute('src'). '" _builder_version="'.ET_BUILDER_VERSION.'" _module_preset="default" hover_enabled="0" global_colors_info="{}" sticky_enabled="0"][/et_pb_image]';
1407 1471
1408 - error_log(json_encode($e));
1472 + error_log(json_encode($e)); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
1409 1473
1410 1474 }
1411 1475 }elseif($node->nodeName === 'iframe'){
1412 1476 $result= '[et_pb_code _builder_version="'.ET_BUILDER_VERSION.'" _module_preset="default" global_colors_info="{}"]'.$node->ownerDocument->saveHTML($node).'[/et_pb_code]' ;
@@ -1534,67 +1598,8 @@
1534 1598 return $attachment_id;
1535 1599 }
1536 1600
1537 1601 /**
1538 - * Index a post with Google Indexing API
1539 - *
1540 - * @param int $post_id WordPress post ID
1541 - * @param string $post_type WordPress post type (post, page, etc.)
1542 - * @param string $post_status WordPress post status (publish, draft, etc.)
1543 - */
1544 - public function metasync_google_index_post($post_id, $post_type, $post_status)
1545 - {
1546 - // Only index published posts/pages
1547 - if ($post_status !== 'publish') {
1548 - return;
1549 - }
1550 -
1551 - // Only index posts and pages (can be extended as needed)
1552 - $allowed_post_types = array('post', 'page');
1553 - if (!in_array($post_type, $allowed_post_types)) {
1554 - return;
1555 - }
1556 -
1557 - try {
1558 - // Load Google Index functionality if not already loaded
1559 - if (!function_exists('google_index_post')) {
1560 - $google_index_path = plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php';
1561 - if (file_exists($google_index_path)) {
1562 - require_once $google_index_path;
1563 - } else {
1564 - error_log('MetaSync Google Index: google-index-init.php not found at ' . $google_index_path);
1565 - return;
1566 - }
1567 - }
1568 -
1569 - // Attempt to index the post with Google
1570 - if (function_exists('google_index_post')) {
1571 - $result = google_index_post($post_id, $post_type, 'update');
1572 -
1573 - if (isset($result['success']) && $result['success']) {
1574 - error_log(sprintf(
1575 - 'MetaSync Google Index: Successfully indexed %s (ID: %d, Type: %s)',
1576 - get_the_title($post_id),
1577 - $post_id,
1578 - $post_type
1579 - ));
1580 - } else {
1581 - error_log(sprintf(
1582 - 'MetaSync Google Index: Failed to index %s (ID: %d, Type: %s) - %s',
1583 - get_the_title($post_id),
1584 - $post_id,
1585 - $post_type,
1586 - isset($result['error']['message']) ? $result['error']['message'] : 'Unknown error'
1587 - ));
1588 - }
1589 - }
1590 - } catch (Exception $e) {
1591 - // Log any exceptions but don't break the main functionality
1592 - error_log('MetaSync Google Index Exception: ' . $e->getMessage());
1593 - }
1594 - }
1595 -
1596 - /**
1597 1602 * Remove a leading <h1> from post content when it duplicates the post title.
1598 1603 *
1599 1604 * Themes that render post_title as an H1 produce a duplicate heading when the
1600 1605 * synced body also opens with the title. Counterpart of the guard, which
@@ -1615,9 +1620,9 @@
1615 1620 return $content;
1616 1621 }
1617 1622
1618 1623 # Entity-decode both sides so "Mobility &amp; Recovery" matches "Mobility & Recovery"
1619 - $h1_text = trim(html_entity_decode(strip_tags($h1_match[1]), ENT_QUOTES | ENT_HTML5));
1624 + $h1_text = trim(html_entity_decode(wp_strip_all_tags($h1_match[1]), ENT_QUOTES | ENT_HTML5));
1620 1625 $title_text = trim(html_entity_decode((string) $title, ENT_QUOTES | ENT_HTML5));
1621 1626 if ($h1_text === '' || strcasecmp($h1_text, $title_text) !== 0) {
1622 1627 return $content;
1623 1628 }
@@ -1774,8 +1779,65 @@
1774 1779 }
1775 1780 return false;
1776 1781 }
1777 1782
1783 + /**
1784 + * Find a post this plugin created that has the given title.
1785 + *
1786 + * Only posts flagged metasync_post=yes are considered, so a customer's own
1787 + * page that happens to share the title is never picked up and overwritten.
1788 + *
1789 + * @param string $title Post title to match exactly
1790 + * @param string $post_type Post type to search
1791 + * @return int|null Matching post ID, or null when there is none
1792 + */
1793 + private function find_plugin_post_by_title($title, $post_type)
1794 + {
1795 + $query = new WP_Query(
1796 + array(
1797 + 'post_type' => $post_type,
1798 + 'title' => $title,
1799 + 'post_status' => array('publish', 'draft', 'pending'),
1800 + 'meta_key' => 'metasync_post',
1801 + 'meta_value' => 'yes',
1802 + 'posts_per_page' => 2,
1803 + 'no_found_rows' => true,
1804 + 'fields' => 'ids',
1805 + 'orderby' => 'ID',
1806 + 'order' => 'ASC',
1807 + )
1808 + );
1809 +
1810 + // Never guess when duplicate flagged posts share a title.
1811 + return 1 === count($query->posts) ? (int) $query->posts[0] : null;
1812 + }
1813 +
1814 + /**
1815 + * Limit what a create call may change on a post it only matched by title.
1816 + *
1817 + * A title match is a guess, so it must never take a page offline or move
1818 + * its URL. A post that is live keeps its status and slug. A post that has
1819 + * never been published may be published, and may take the requested slug
1820 + * because nothing links to it yet.
1821 + *
1822 + * @param array $post_args Arguments about to be passed to wp_update_post()
1823 + * @param string $existing_status Current status of the matched post
1824 + * @return array Arguments with any unsafe status/slug change removed
1825 + */
1826 + private function protect_title_matched_post(array $post_args, $existing_status)
1827 + {
1828 + if (!in_array($existing_status, array('draft', 'pending'), true)) {
1829 + unset($post_args['post_status'], $post_args['post_name']);
1830 + return $post_args;
1831 + }
1832 +
1833 + if (isset($post_args['post_status']) && 'publish' !== $post_args['post_status']) {
1834 + unset($post_args['post_status']);
1835 + }
1836 +
1837 + return $post_args;
1838 + }
1839 +
1778 1840 public function create_item($request)
1779 1841 {
1780 1842 // Checking for type of object for response type
1781 1843 $array_response = true;
@@ -1886,13 +1948,13 @@
1886 1948 $new_post['post_name'] = sanitize_text_field($item['permalink']);
1887 1949 }
1888 1950
1889 1951 if (isset($item['post_date']) && !empty($item['post_date'])) {
1890 - $is_valid_date = date('Y-m-d', strtotime($item['post_date'])) === $item['post_date'];
1952 + $is_valid_date = gmdate('Y-m-d', strtotime($item['post_date'])) === $item['post_date'];
1891 1953 if (!$is_valid_date) {
1892 1954 return new WP_Error(
1893 1955 'rest_post_invalid_date',
1894 - esc_html__('Post date is not valid'),
1956 + esc_html__('Post date is not valid', 'metasync'),
1895 1957 array('status' => 400)
1896 1958 );
1897 1959 }
1898 1960
@@ -1925,20 +1987,16 @@
1925 1987 # $getPostID_byURL = @get_page_by_path($item['permalink'], OBJECT, $new_post['post_type'])->ID;
1926 1988 # Fix to avoid PHP error if the get_page_by_path returns null
1927 1989 $getPostID_byURL = @get_page_by_path($item['permalink'], OBJECT, $new_post['post_type']);
1928 1990 $getPostID_byURL = $getPostID_byURL ? $getPostID_byURL->ID : null;
1991 + $matched_by_title = false;
1929 1992 if ($getPostID_byURL == NULL) {
1930 1993 // check if the post_title is set and not empty when called by otto_ai_page
1931 1994 if(isset($new_post['post_title']) && $new_post['post_title']!==''){
1932 - $getPostID_byURL = new WP_Query(
1933 - array(
1934 - 'post_type' => $new_post['post_type'],
1935 - 'title' => $new_post['post_title']
1936 - )
1937 - );
1938 - $getPostID_byURL = $getPostID_byURL->posts[0]->ID ?? null;
1995 + $getPostID_byURL = $this->find_plugin_post_by_title($new_post['post_title'], $new_post['post_type']);
1996 + $matched_by_title = (NULL !== $getPostID_byURL);
1997 + }
1939 1998 }
1940 - }
1941 1999
1942 2000 // Allow HTML code for landing page
1943 2001 if (isset($item['is_landing_page']) && $item['is_landing_page'] == true) {
1944 2002 kses_remove_filters();
@@ -1963,11 +2021,17 @@
1963 2021 update_post_meta($post_id, 'metasync_post', 'yes');
1964 2022 }
1965 2023
1966 2024 } else {
1967 - $new_post['ID'] = $post_id = $getPostID_byURL;
1968 - wp_update_post($new_post);
1969 - unset($new_post['ID']);
2025 + $post_id = $getPostID_byURL;
2026 + $update_post = $new_post;
2027 + $update_post['ID'] = $post_id;
2028 + if ($matched_by_title) {
2029 + $update_post = $this->protect_title_matched_post($update_post, get_post_status($post_id));
2030 + }
2031 + wp_update_post($update_post);
2032 + // Report what was actually saved, not what was requested.
2033 + $new_post['post_status'] = get_post_status($post_id);
1970 2034 $permalink = get_permalink($post_id);
1971 2035 }
1972 2036
1973 2037 if (isset($item['is_landing_page']) && $item['is_landing_page'] == true) {
@@ -2009,13 +2073,17 @@
2009 2073 $post_meta['searchatlas_embed_bottom'] = $item['searchatlas_embed_bottom'];
2010 2074 }
2011 2075
2012 2076 // Add custom fields to posts and pages
2013 - foreach ($post_meta as $key => $value) {
2014 - // if (!empty($value) && !is_null($value)) {
2015 - add_post_meta($post_id, $key, $value, true);
2016 - // }
2017 - }
2077 + $this->metasync_apply_builder_meta($post_id, $post_meta, false);
2078 +
2079 + // A freshly inserted post has no template of its own yet; match it to
2080 + // the one this site uses for its other posts.
2081 + $this->metasync_adopt_site_post_template(
2082 + $post_id,
2083 + isset($new_post['post_type']) ? $new_post['post_type'] : 'post',
2084 + (NULL === $getPostID_byURL)
2085 + );
2018 2086
2019 2087
2020 2088 $attachment_id = '';
2021 2089 if (isset($item['hero_image_url']) && !empty($item['hero_image_url'])) {
@@ -2033,8 +2101,14 @@
2033 2101 $redirection['url'] = sanitize_url($item['redirection_url']);
2034 2102 }
2035 2103 if (!empty($redirection)) {
2036 2104 update_post_meta($post_id, 'metasync_post_redirection_meta', $redirection);
2105 + // REST/Gutenberg saves never reach the metabox nonce path, so
2106 + // the stored meta would never materialize a redirect row.
2107 + // Sync it here, same as the classic save hook does.
2108 + if (class_exists('Metasync_Post_Meta_Settings')) {
2109 + Metasync_Post_Meta_Settings::sync_post_redirect_rule($post_id, $redirection);
2110 + }
2037 2111 }
2038 2112
2039 2113 $post_cattegories = [];
2040 2114 # if ($new_post['post_type'] === 'post' && is_array(@$item['post_categories'])) {
@@ -2090,17 +2164,16 @@
2090 2164 'action' => strtolower($action)
2091 2165 ])
2092 2166 ]);
2093 2167
2094 - # Track Content Genius event in GA4
2168 + # Both pipelines roll Content Genius syncs up to one hourly event
2169 + # with counts so a bulk sync of N articles stays a single send.
2095 2170 try {
2096 2171 Metasync_GA4::get_instance()->track_content_genius_event($post_id, strtolower($action));
2172 + Metasync_PostHog::content_genius_sync($new_post['post_type'], strtolower($action), !empty($item['is_landing_page']));
2097 2173 } catch (Exception $e) {
2098 - error_log('MetaSync: Analytics tracking failed for Content Genius - ' . $e->getMessage());
2174 + error_log('MetaSync: Analytics tracking failed for Content Genius - ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
2099 2175 }
2100 -
2101 - // Google Indexing Integration
2102 - $this->metasync_google_index_post($post_id, $new_post['post_type'], $new_post['post_status']);
2103 2176 }
2104 2177
2105 2178 # For standard synced posts, learn (once per post-type) whether the
2106 2179 # theme renders the title, so future syncs prepend only when genuinely needed.
@@ -2109,9 +2182,9 @@
2109 2182 && $new_post['post_status'] === 'publish') {
2110 2183 try {
2111 2184 $this->record_theme_title_verdict($post_id, $permalink, $new_post['post_title'], $new_post['post_type']);
2112 2185 } catch (Exception $e) {
2113 - error_log('MetaSync title verdict failed: ' . $e->getMessage());
2186 + error_log('MetaSync title verdict failed: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
2114 2187 }
2115 2188 }
2116 2189
2117 2190 $respCreatePosts[$index] = array_merge($new_post, $post_meta);
@@ -2156,9 +2229,9 @@
2156 2229 }
2157 2230
2158 2231 public function delete_item()
2159 2232 {
2160 - $get_data = metasync_sanitize_input_array($_GET);
2233 + $get_data = metasync_sanitize_input_array($_GET); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- REST route authenticated by API key, not cookies
2161 2234 if (!isset($get_data['ID']))
2162 2235 return false;
2163 2236
2164 2237 $post_id = sanitize_text_field($get_data['ID']) ?? null;
@@ -2164,18 +2237,18 @@
2164 2237 $post_id = sanitize_text_field($get_data['ID']) ?? null;
2165 2238 $post = get_post($post_id);
2166 2239 if ($post) {
2167 2240 wp_delete_post($post_id);
2241 + // HTTP 204 requires no body for response, so no message is set.
2168 2242 return new WP_Error(
2169 2243 'rest_post_delete_success',
2170 - esc_html__(''),
2171 - // HTTP 204 requires no body for response
2244 + '',
2172 2245 array('status' => 204)
2173 2246 );
2174 2247 }
2175 2248 return new WP_Error(
2176 2249 'rest_post_delete_fail',
2177 - esc_html__('No post found in the database with requested ID.'),
2250 + esc_html__('No post found in the database with requested ID.', 'metasync'),
2178 2251 array('status' => 400)
2179 2252 );
2180 2253 }
2181 2254
@@ -2180,9 +2253,9 @@
2180 2253 }
2181 2254
2182 2255 public function get_items($request)
2183 2256 {
2184 - $get_data = metasync_sanitize_input_array($_GET);
2257 + $get_data = metasync_sanitize_input_array($_GET); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- REST route authenticated by API key, not cookies
2185 2258
2186 2259 # let us check if request send post id and is valid int
2187 2260 if(isset($get_data['post_id']) AND intval($get_data['post_id']) > 0){
2188 2261
@@ -2522,9 +2595,9 @@
2522 2595 $post_data = get_post($post_id);
2523 2596 if (!$post_data) {
2524 2597 return new WP_Error(
2525 2598 'rest_post_update_fail',
2526 - esc_html__('No post found in the database with requested ID.'),
2599 + esc_html__('No post found in the database with requested ID.', 'metasync'),
2527 2600 array('status' => 400)
2528 2601 );
2529 2602 }
2530 2603
@@ -2645,9 +2718,8 @@
2645 2718 }
2646 2719
2647 2720 if (isset($post['post_status']) && !empty($post['post_status'])) {
2648 2721 $update_params['post_status'] = $post['post_status'] ? sanitize_text_field($post['post_status']) : 'publish';
2649 - $permalink = get_permalink($post_id);
2650 2722 }
2651 2723 if (isset($post['permalink']) || !empty($post['permalink'])) {
2652 2724 $update_params['post_name'] = sanitize_text_field($post['permalink']);
2653 2725 }
@@ -2662,40 +2734,8 @@
2662 2734 );
2663 2735 }
2664 2736
2665 2737
2666 - if (isset($post['post_date']) && !empty($post['post_date']) && false) {
2667 - $is_valid_date = date('Y-m-d', strtotime($post['post_date'])) == $post['post_date'];
2668 - if (!$is_valid_date) {
2669 - return new WP_Error(
2670 - 'rest_post_invalid_date',
2671 - esc_html__('Post date is not valid'),
2672 - array('status' => 400)
2673 - );
2674 - }
2675 -
2676 - $date_limit_str = strtotime(date('Y-m-d') . '-2 month');
2677 - $post_date_str = strtotime($post['post_date']);
2678 -
2679 - if ($date_limit_str >= $post_date_str) {
2680 - $newDate = date('Y-m-d', strtotime('-2 month'));
2681 - return new WP_Error(
2682 - 'rest_post_greater_date',
2683 - esc_html__("Post date should be greater then " . $newDate),
2684 - array('status' => 400)
2685 - );
2686 - }
2687 -
2688 - if ($post_date_str > strtotime(date('Y-m-d'))) {
2689 - return new WP_Error(
2690 - 'rest_post_greater_date',
2691 - esc_html__('Post date should be less then Today'),
2692 - array('status' => 400)
2693 - );
2694 - }
2695 - $update_params['post_date'] = sanitize_text_field($post['post_date'] . date(' h:i:s'));
2696 - }
2697 -
2698 2738 $post_cattegories = [];
2699 2739 # if ($post_data && $post_data->post_type === 'post' && is_array(@$post['post_categories'])) {
2700 2740
2701 2741 # fixed Undefined array key issue
@@ -2718,12 +2758,13 @@
2718 2758 $attachment_id = $this->metasync_handle_hero_image($post_id, $post['hero_image_url'], $post['hero_image_alt_text']);
2719 2759 }
2720 2760
2721 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);
2722 2765 if(isset($content['elementor_meta_data'])){
2723 - foreach ($content['elementor_meta_data'] as $key => $value) {
2724 - update_post_meta($post_id, $key, $value);
2725 - }
2766 + $this->metasync_apply_builder_meta($post_id, $content['elementor_meta_data']);
2726 2767 if ( did_action( 'elementor/loaded' ) ) {
2727 2768 // Clear Elementor cache for the specified post ID
2728 2769 \Elementor\Plugin::instance()->files_manager->clear_cache();
2729 2770 }
@@ -2738,8 +2779,15 @@
2738 2779 delete_post_meta($post_id, '_elementor_page_assets');
2739 2780 delete_post_meta($post_id, '_elementor_page_settings');
2740 2781 }
2741 2782
2783 + // Existing post: records the template decision without changing it.
2784 + $this->metasync_adopt_site_post_template(
2785 + $post_id,
2786 + get_post_type($post_id),
2787 + false
2788 + );
2789 +
2742 2790 $redirection = array();
2743 2791 if (!empty($post['redirection_enable']) && !is_null($post['redirection_enable'])) {
2744 2792 $redirection['enable'] = sanitize_text_field($post['redirection_enable']);
2745 2793 }
@@ -2750,8 +2798,14 @@
2750 2798 $redirection['url'] = sanitize_url($post['redirection_url']);
2751 2799 }
2752 2800 if (!empty($redirection)) {
2753 2801 update_post_meta($post_id, 'metasync_post_redirection_meta', $redirection);
2802 + // REST/Gutenberg saves never reach the metabox nonce path, so
2803 + // the stored meta would never materialize a redirect row.
2804 + // Sync it here, same as the classic save hook does.
2805 + if (class_exists('Metasync_Post_Meta_Settings')) {
2806 + Metasync_Post_Meta_Settings::sync_post_redirect_rule($post_id, $redirection);
2807 + }
2754 2808 }
2755 2809
2756 2810
2757 2811 $post_revisions = wp_get_post_revisions($post_id);
@@ -2789,14 +2843,9 @@
2789 2843
2790 2844 }
2791 2845 # update the content
2792 2846 // add and update the post meta
2793 - foreach ($post_meta_data as $key => $value) {
2794 - // if (!empty($value) && !is_null($value)) {
2795 -
2796 - update_post_meta($post_id, $key, $value);
2797 - //
2798 - }
2847 + $this->metasync_apply_builder_meta($post_id, $post_meta_data);
2799 2848 #check if the elementor plugin is active
2800 2849 if ( did_action( 'elementor/loaded' ) ) {
2801 2850 # Clear Elementor cache for the specified post ID
2802 2851 \Elementor\Plugin::instance()->files_manager->clear_cache();
@@ -2829,13 +2878,14 @@
2829 2878 'updated_fields' => array_keys($update_params)
2830 2879 ])
2831 2880 ]);
2832 2881
2833 - # Track Content Genius event in GA4
2882 + # Track Content Genius event in GA4 and PostHog
2834 2883 try {
2835 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']));
2836 2886 } catch (Exception $e) {
2837 - 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
2838 2888 }
2839 2889
2840 2890 # Learn (once per post-type) whether the theme renders the title,
2841 2891 # so future syncs prepend only when the theme genuinely omits it.
@@ -2843,9 +2893,9 @@
2843 2893 && $sync_status === 'published') {
2844 2894 try {
2845 2895 $this->record_theme_title_verdict($post_id, ($permalink ?? get_permalink($post_id)), $post_title, $post_type);
2846 2896 } catch (Exception $e) {
2847 - 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
2848 2898 }
2849 2899 }
2850 2900 }
2851 2901
@@ -2942,20 +2992,9 @@
2942 2992 if($isOttoAiPage){
2943 2993 // Change the page template from default to Metasync Template
2944 2994 $pageTemplate = Metasync_Template::TEMPLATE_NAME;
2945 2995 }
2946 - if ($pageTemplate !== 'default') {
2947 - update_post_meta($post_id, '_wp_page_template', $pageTemplate);
2948 - } else {
2949 - // Clear stale templates that conflict with the active page builder.
2950 - // e.g. metasync-blank from a previous OTTO sync, or elementor_canvas
2951 - // written by the HTML-to-builder converter on an Oxygen site.
2952 - $current = get_post_meta($post_id, '_wp_page_template', true);
2953 - $stale_templates = array(Metasync_Template::TEMPLATE_NAME, 'elementor_canvas', 'elementor_header_footer');
2954 - if (in_array($current, $stale_templates, true)) {
2955 - delete_post_meta($post_id, '_wp_page_template');
2956 - }
2957 - }
2996 + $this->metasync_write_page_template($post_id, $pageTemplate);
2958 2997 }
2959 2998
2960 2999 return rest_ensure_response($createPages);
2961 3000 }
@@ -2978,9 +3017,9 @@
2978 3017 $post_data = get_post($payload['post_id']);
2979 3018 if(!isset($post_data->post_type)){
2980 3019 return new WP_Error(
2981 3020 'rest_page_type_fail',
2982 - esc_html__('No page found in the database with requested ID.'),
3021 + esc_html__('No page found in the database with requested ID.', 'metasync'),
2983 3022 array('status' => 400)
2984 3023 );
2985 3024 }
2986 3025 if ($post_data->post_type !== 'page') {
@@ -3035,20 +3074,225 @@
3035 3074 if($isOttoAiPage){
3036 3075 // Change the page template from default to Metasync Template
3037 3076 $pageTemplate = Metasync_Template::TEMPLATE_NAME;
3038 3077 }
3039 - if ($pageTemplate !== 'default') {
3040 - update_post_meta($post_id, '_wp_page_template', $pageTemplate);
3078 + $this->metasync_write_page_template($post_id, $pageTemplate);
3079 + }
3080 + return rest_ensure_response($updatePages->data);
3081 + }
3082 +
3083 + /**
3084 + * The page template this site actually uses for a given post type.
3085 + *
3086 + * Content Genius has no idea which template a site's posts are built on, and
3087 + * neither does the converter. Hardcoding one downgraded every synced post;
3088 + * leaving the field empty is no better, because WordPress then falls back to
3089 + * the theme's default single template -- on a theme-builder site that is a
3090 + * bare, unstyled layout.
3091 + *
3092 + * So infer it: whatever the clear majority of this site's existing published
3093 + * entries of the same type already use.
3094 + *
3095 + * @param string $post_type
3096 + * @return string Template slug, or '' when the site has no clear convention.
3097 + */
3098 + private function metasync_prevailing_post_template($post_type)
3099 + {
3100 + $post_type = sanitize_key($post_type);
3101 +
3102 + if ('' === $post_type) {
3103 + return '';
3104 + }
3105 +
3106 + $cache_key = 'metasync_prevailing_tpl_' . $post_type;
3107 + $cached = get_transient($cache_key);
3108 +
3109 + if (false !== $cached) {
3110 + return is_string($cached) ? $cached : '';
3111 + }
3112 +
3113 + global $wpdb;
3114 +
3115 + // Never propagate a template this plugin wrote itself.
3116 + $owned = $this->metasync_plugin_owned_templates();
3117 +
3118 + $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint over the plugin custom table — no WordPress API exists
3119 + $wpdb->prepare(
3120 + 'SELECT pm.meta_value AS tpl, COUNT(*) AS hits
3121 + FROM ' . $wpdb->postmeta . ' pm
3122 + INNER JOIN ' . $wpdb->posts . " p ON p.ID = pm.post_id
3123 + WHERE pm.meta_key = '_wp_page_template'
3124 + AND pm.meta_value <> ''
3125 + AND pm.meta_value <> 'default'
3126 + AND pm.meta_value NOT IN (" . implode(',', array_fill(0, count($owned), '%s')) . ")
3127 + AND p.post_type = %s
3128 + AND p.post_status = 'publish'
3129 + GROUP BY pm.meta_value
3130 + ORDER BY hits DESC",
3131 + array_merge($owned, array($post_type))
3132 + )
3133 + );
3134 +
3135 + $prevailing = '';
3136 +
3137 + if (!empty($rows)) {
3138 + // Measure the majority against every published entry of this type,
3139 + // not just the ones that set a template. Counting only the latter
3140 + // would let a handful of outliers on a special template read as a
3141 + // site-wide convention on a site whose posts otherwise use the
3142 + // theme default.
3143 + $total = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint over the plugin custom table — no WordPress API exists
3144 + $wpdb->prepare(
3145 + "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = %s AND post_status = 'publish'",
3146 + $post_type
3147 + )
3148 + );
3149 +
3150 + $top = (int) $rows[0]->hits;
3151 +
3152 + // Only adopt a convention that is genuinely a convention: several
3153 + // posts, and a clear majority of the site's published entries.
3154 + if ($top >= 3 && $total > 0 && ($top / $total) >= 0.6) {
3155 + $prevailing = (string) $rows[0]->tpl;
3156 + }
3157 + }
3158 +
3159 + set_transient($cache_key, $prevailing, 6 * HOUR_IN_SECONDS);
3160 +
3161 + return $prevailing;
3162 + }
3163 +
3164 + /**
3165 + * Give a synced post the template the rest of the site uses.
3166 + *
3167 + * Only ever fills a blank -- an existing choice is never touched.
3168 + *
3169 + * Beyond brand-new inserts this also covers two cases that cannot regress
3170 + * anything a visitor is currently seeing: posts this plugin created itself
3171 + * (marked with `metasync_post`), and posts that have never been published.
3172 + * Everything else -- content the site author built by hand -- is left alone.
3173 + *
3174 + * @param int $post_id
3175 + * @param string $post_type
3176 + * @param bool $is_new Whether this sync actually inserted the post.
3177 + * @return void
3178 + */
3179 + private function metasync_adopt_site_post_template($post_id, $post_type, $is_new)
3180 + {
3181 + if (!$post_id) {
3182 + return;
3183 + }
3184 +
3185 + $current = (string) get_post_meta($post_id, '_wp_page_template', true);
3186 +
3187 + if ('' !== $current && 'default' !== $current) {
3188 + return;
3189 + }
3190 +
3191 + $is_plugin_post = ('yes' === get_post_meta($post_id, 'metasync_post', true));
3192 + $never_published = in_array(get_post_status($post_id), array('draft', 'pending', 'auto-draft'), true);
3193 +
3194 + if (!$is_new && !$is_plugin_post && !$never_published) {
3195 + return;
3196 + }
3197 +
3198 + $prevailing = $this->metasync_prevailing_post_template($post_type);
3199 +
3200 + if ('' === $prevailing) {
3201 + return;
3202 + }
3203 +
3204 + update_post_meta($post_id, '_wp_page_template', $prevailing);
3205 + }
3206 +
3207 + /**
3208 + * Page templates this plugin writes itself.
3209 + *
3210 + * Only these may be cleared when a sync carries the neutral 'default' value.
3211 + * Anything else stored in `_wp_page_template` was chosen in the editor and
3212 + * has to survive the sync -- notably 'elementor_header_footer' (Elementor
3213 + * Full Width), which the plugin never writes, so finding it there always
3214 + * means the user selected it.
3215 + *
3216 + * @return array
3217 + */
3218 + private function metasync_plugin_owned_templates()
3219 + {
3220 + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-template.php';
3221 +
3222 + return array(Metasync_Template::TEMPLATE_NAME, 'elementor_canvas');
3223 + }
3224 +
3225 + /**
3226 + * Whether converter-generated builder meta may set `_wp_page_template`.
3227 + *
3228 + * Everything reaching this point was asserted by the converter, not chosen
3229 + * by anyone -- including 'elementor_canvas', which it emits for every page.
3230 + * So the incoming value carries no authority: builder meta may fill a blank
3231 + * and may replace a template this plugin wrote itself, but it must never
3232 + * overwrite one selected in the editor.
3233 + *
3234 + * Deliberate templates (OTTO pages, landing pages) do not come through here;
3235 + * they are written by metasync_write_page_template().
3236 + *
3237 + * @param int $post_id
3238 + * @return bool
3239 + */
3240 + private function metasync_can_write_page_template($post_id)
3241 + {
3242 + $current = get_post_meta($post_id, '_wp_page_template', true);
3243 +
3244 + // Nothing worth keeping.
3245 + if ('' === $current || 'default' === $current) {
3246 + return true;
3247 + }
3248 +
3249 + // Only replace leftovers this plugin put there in the first place.
3250 + return in_array($current, $this->metasync_plugin_owned_templates(), true);
3251 + }
3252 +
3253 + /**
3254 + * Store a page template, preserving one the user selected.
3255 + *
3256 + * @param int $post_id
3257 + * @param string $pageTemplate
3258 + * @return void
3259 + */
3260 + private function metasync_write_page_template($post_id, $pageTemplate)
3261 + {
3262 + if ('default' !== $pageTemplate) {
3263 + update_post_meta($post_id, '_wp_page_template', $pageTemplate);
3264 + return;
3265 + }
3266 +
3267 + $current = get_post_meta($post_id, '_wp_page_template', true);
3268 +
3269 + if ('' !== $current && in_array($current, $this->metasync_plugin_owned_templates(), true)) {
3270 + delete_post_meta($post_id, '_wp_page_template');
3271 + }
3272 + }
3273 +
3274 + /**
3275 + * Apply builder meta while protecting the post's chosen page template.
3276 + *
3277 + * @param int $post_id
3278 + * @param array $meta
3279 + * @param bool $overwrite Replace existing meta, or only add when absent.
3280 + * @return void
3281 + */
3282 + private function metasync_apply_builder_meta($post_id, array $meta, $overwrite = true)
3283 + {
3284 + foreach ($meta as $key => $value) {
3285 + if ('_wp_page_template' === $key && !$this->metasync_can_write_page_template($post_id)) {
3286 + continue;
3287 + }
3288 +
3289 + if ($overwrite) {
3290 + update_post_meta($post_id, $key, $value);
3041 3291 } else {
3042 - // Clear stale templates that conflict with the active page builder.
3043 - $current = get_post_meta($post_id, '_wp_page_template', true);
3044 - $stale_templates = array(Metasync_Template::TEMPLATE_NAME, 'elementor_canvas', 'elementor_header_footer');
3045 - if (in_array($current, $stale_templates, true)) {
3046 - delete_post_meta($post_id, '_wp_page_template');
3047 - }
3292 + add_post_meta($post_id, $key, $value, true);
3048 3293 }
3049 3294 }
3050 - return rest_ensure_response($updatePages->data);
3051 3295 }
3052 3296
3053 3297 public function delete_page()
3054 3298 {
@@ -3074,8 +3318,17 @@
3074 3318 }
3075 3319
3076 3320 public function linkgraph_login()
3077 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 +
3078 3331 $post_data = metasync_sanitize_input_array($_POST);
3079 3332 $payload = array(
3080 3333 'username' => wp_unslash(sanitize_email($post_data['username'])),
3081 3334 'password' => wp_unslash(sanitize_text_field($post_data['password']))
@@ -3092,9 +3345,9 @@
3092 3345 ));
3093 3346
3094 3347 # Error handling for timeout or connection failures
3095 3348 if (is_wp_error($response)) {
3096 - 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
3097 3350 wp_send_json_error(array('message' => 'Token API request failed'));
3098 3351 wp_die();
3099 3352 }
3100 3353
@@ -3228,9 +3481,9 @@
3228 3481 $option_name = '_transient_metasync_sa_connect_active_' . $nonce_token;
3229 3482 $timeout_name = '_transient_timeout_metasync_sa_connect_active_' . $nonce_token;
3230 3483
3231 3484 // Check expiry first
3232 - $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
3233 3486 $wpdb->prepare(
3234 3487 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
3235 3488 $timeout_name
3236 3489 )
@@ -3237,14 +3490,14 @@
3237 3490 );
3238 3491
3239 3492 if ($timeout && (int) $timeout < time()) {
3240 3493 // Expired — clean up orphan rows
3241 - $wpdb->delete($wpdb->options, array('option_name' => $option_name));
3242 - $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
3243 3496 return false;
3244 3497 }
3245 3498
3246 - $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
3247 3500 $wpdb->prepare(
3248 3501 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
3249 3502 $option_name
3250 3503 )
@@ -3268,9 +3521,9 @@
3268 3521 $option_name = '_transient_' . $transient_key;
3269 3522 $timeout_name = '_transient_timeout_' . $transient_key;
3270 3523
3271 3524 // Check expiry first
3272 - $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
3273 3526 $wpdb->prepare(
3274 3527 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
3275 3528 $timeout_name
3276 3529 )
@@ -3276,14 +3529,14 @@
3276 3529 )
3277 3530 );
3278 3531
3279 3532 if ($timeout && (int) $timeout < time()) {
3280 - $wpdb->delete($wpdb->options, array('option_name' => $option_name));
3281 - $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
3282 3535 return false;
3283 3536 }
3284 3537
3285 - $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
3286 3539 $wpdb->prepare(
3287 3540 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
3288 3541 $option_name
3289 3542 )
@@ -3310,10 +3563,10 @@
3310 3563 {
3311 3564 delete_transient($transient_name);
3312 3565 if (wp_using_ext_object_cache()) {
3313 3566 global $wpdb;
3314 - $wpdb->delete($wpdb->options, array('option_name' => '_transient_' . $transient_name));
3315 - $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
3316 3569 }
3317 3570 }
3318 3571
3319 3572 /**
@@ -3330,14 +3583,14 @@
3330 3583 {
3331 3584 set_transient($transient_name, $value, $expiration);
3332 3585 if (wp_using_ext_object_cache()) {
3333 3586 global $wpdb;
3334 - $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
3335 3588 'option_name' => '_transient_' . $transient_name,
3336 3589 'option_value' => maybe_serialize($value),
3337 3590 'autoload' => 'no',
3338 3591 ));
3339 - $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
3340 3593 'option_name' => '_transient_timeout_' . $transient_name,
3341 3594 'option_value' => time() + $expiration,
3342 3595 'autoload' => 'no',
3343 3596 ));
@@ -3691,9 +3944,9 @@
3691 3944 *
3692 3945 * @param array $body_params Request body parameters
3693 3946 * @return array|WP_Error Validated parameters array or WP_Error
3694 3947 */
3695 - private function extract_and_validate_searchatlas_params($body_params)
3948 + public function extract_and_validate_searchatlas_params($body_params)
3696 3949 {
3697 3950 $validation_errors = array();
3698 3951
3699 3952 // Extract parameters
@@ -3714,14 +3967,18 @@
3714 3967 $validation_errors['api_key'] = 'API key contains invalid characters. Only alphanumeric, dash, underscore, and dot allowed';
3715 3968 }
3716 3969
3717 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.
3718 3975 if (empty($uuid)) {
3719 3976 $validation_errors['uuid'] = 'UUID is required';
3720 3977 } elseif (!is_string($uuid)) {
3721 3978 $validation_errors['uuid'] = 'UUID must be a string';
3722 - } elseif (strlen($uuid) > 100) {
3723 - $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';
3724 3981 }
3725 3982
3726 3983 // Validate status_code
3727 3984 if (!is_numeric($status_code)) {
@@ -3774,9 +4031,9 @@
3774 4031 } elseif (strlen($whitelabel_domain) > 255) {
3775 4032 $validation_errors['whitelabel_domain'] = 'Whitelabel domain must not exceed 255 characters';
3776 4033 } elseif (!filter_var($whitelabel_domain, FILTER_VALIDATE_URL)) {
3777 4034 $validation_errors['whitelabel_domain'] = 'Whitelabel domain must be a valid URL';
3778 - } 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'])) {
3779 4036 $validation_errors['whitelabel_domain'] = 'Whitelabel domain must use http or https protocol';
3780 4037 }
3781 4038 }
3782 4039
@@ -3789,9 +4046,9 @@
3789 4046 $validation_errors['whitelabel_logo'] = 'Whitelabel logo URL must not exceed 500 characters';
3790 4047 } else {
3791 4048 // If URL is invalid, we'll clear it later but not fail the POST
3792 4049 if (!filter_var($whitelabel_logo, FILTER_VALIDATE_URL) ||
3793 - !in_array(parse_url($whitelabel_logo, PHP_URL_SCHEME), ['http', 'https'])) {
4050 + !in_array(wp_parse_url($whitelabel_logo, PHP_URL_SCHEME), ['http', 'https'])) {
3794 4051 // Don't add to validation_errors - let POST succeed but clear the field
3795 4052 }
3796 4053 }
3797 4054 }
@@ -3882,10 +4139,22 @@
3882 4139 $options['general'] = array();
3883 4140 }
3884 4141 // Only update the API key in settings if status_code is 200 (success)
3885 4142 if ($status_code === 200) {
3886 - $options['general']['searchatlas_api_key'] = $new_api_key;
3887 - $options['general']['otto_pixel_uuid'] = $new_otto_uuid;
4143 + // Encrypt the API key at rest; never store it in cleartext.
4144 + $options['general']['searchatlas_api_key'] = Metasync::encrypt_api_key($new_api_key);
4145 +
4146 + // Defense-in-depth: the validator upstream already rejects a
4147 + // non-UUID with 422, but this method is also called directly
4148 + // (pull-based connect, tests, future callers), so the UUID is
4149 + // only stored when it passes the shared shape check. Anything
4150 + // else — a username landing here from an unregistered-project
4151 + // SSO — fails safe: the key is saved, the UUID is not.
4152 + if (Metasync::is_valid_uuid($new_otto_uuid)) {
4153 + $options['general']['otto_pixel_uuid'] = $new_otto_uuid;
4154 + } else {
4155 + error_log('MetaSync SA Connect: mark_searchatlas_nonce_used - refusing to store invalid OTTO Pixel UUID');
4156 + }
3888 4157 // Note: OTTO SSR is always enabled by default, no need to set
3889 4158
3890 4159 // Granular otto_config_status: record when SSO completed (ISO 8601 UTC)
3891 4160 $options['general']['sso_completed_at'] = gmdate('Y-m-d\TH:i:s\Z');
@@ -3904,35 +4173,43 @@
3904 4173
3905 4174 } else {
3906 4175 }
3907 4176
3908 - // 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.
3909 4197 if ($is_whitelabel) {
3910 - // whitelabel_company_name → Plugin Name (general plugin branding)
3911 - if (!empty($whitelabel_company_name)) {
3912 - $options['general']['white_label_plugin_name'] = $whitelabel_company_name;
4198 + if (!isset($options['whitelabel'])) {
4199 + $options['whitelabel'] = array();
3913 4200 }
3914 -
3915 - // whitelabel_otto → OTTO Features naming (separate from plugin name)
3916 - if (!empty($whitelabel_otto)) {
3917 - $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;
3918 4205 }
3919 - } else {
3920 - // Clear whitelabel fields when not whitelabel
3921 - unset($options['general']['white_label_plugin_name']);
3922 - unset($options['general']['whitelabel_otto_name']);
4206 + if (!empty($whitelabel_logo)) {
4207 + $options['whitelabel']['logo'] = $whitelabel_logo;
4208 + }
4209 + $options['whitelabel']['updated_at'] = time();
3923 4210 }
3924 4211
3925 - // Store whitelabel settings (hidden from UI but accessible to plugin logic)
3926 - if (!isset($options['whitelabel'])) {
3927 - $options['whitelabel'] = array();
3928 - }
3929 -
3930 - $options['whitelabel']['is_whitelabel'] = $is_whitelabel;
3931 - $options['whitelabel']['domain'] = $whitelabel_domain;
3932 - $options['whitelabel']['logo'] = $whitelabel_logo;
3933 - $options['whitelabel']['updated_at'] = time();
3934 -
3935 4212 // Log whitelabel configuration
3936 4213 if ($is_whitelabel) {
3937 4214 $log_parts = array('Whitelabel mode enabled');
3938 4215 if (!empty($whitelabel_domain)) {
@@ -3950,11 +4227,12 @@
3950 4227
3951 4228 }
3952 4229
3953 4230 $save_result = Metasync::set_option($options);
4231 + Metasync::invalidate_api_key_cache();
3954 4232
3955 4233 if (!$save_result) {
3956 - 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
3957 4235 } else {
3958 4236 if ($status_code === 200) {
3959 4237 // Option 1: Set heartbeat cache and last-known to CONNECTED so dashboard shows iframe immediately.
3960 4238 // If the immediate heartbeat then fails (e.g. backoff), Option 2 prevents overwriting this to DISCONNECTED.
@@ -3966,9 +4244,9 @@
3966 4244 );
3967 4245 set_transient('metasync_heartbeat_status_cache', $cache_data, 300);
3968 4246 update_option('metasync_last_known_connection_state', true);
3969 4247 do_action('metasync_heartbeat_state_key_pending'); // PR3: burst mode
3970 - $this->trigger_immediate_heartbeat_after_sa_connect();
4248 + $this->trigger_immediate_heartbeat_after_sa_connect($token);
3971 4249 }
3972 4250 }
3973 4251
3974 4252 return true;
@@ -3973,9 +4251,9 @@
3973 4251
3974 4252 return true;
3975 4253
3976 4254 } catch (Exception $e) {
3977 - 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
3978 4256 return false;
3979 4257 }
3980 4258 }
3981 4259
@@ -3987,9 +4265,9 @@
3987 4265 {
3988 4266 global $wpdb;
3989 4267
3990 4268 // Clear all JWT token transients
3991 - $deleted = $wpdb->query(
4269 + $deleted = $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
3992 4270 $wpdb->prepare(
3993 4271 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
3994 4272 '_transient_metasync_jwt_token_%'
3995 4273 )
@@ -3995,9 +4273,9 @@
3995 4273 )
3996 4274 );
3997 4275
3998 4276 // Also clear timeout transients
3999 - $wpdb->query(
4277 + $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
4000 4278 $wpdb->prepare(
4001 4279 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
4002 4280 '_transient_timeout_metasync_jwt_token_%'
4003 4281 )
@@ -4008,21 +4286,28 @@
4008 4286
4009 4287 /**
4010 4288 * Trigger immediate heartbeat check after successful Search Atlas connect authentication
4011 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.
4012 4292 */
4013 - private function trigger_immediate_heartbeat_after_sa_connect()
4293 + private function trigger_immediate_heartbeat_after_sa_connect($token)
4014 4294 {
4015 4295 try {
4016 4296 // Use WordPress action system to trigger immediate heartbeat check
4017 4297 // This is more reliable than trying to access admin class directly
4018 4298 do_action('metasync_trigger_immediate_heartbeat', 'Search Atlas Connect - API key and UUID retrieved');
4019 -
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 +
4020 4305 // Also ensure heartbeat cron is scheduled now that we have an API key
4021 4306 do_action('metasync_ensure_heartbeat_cron_scheduled');
4022 4307
4023 4308 } catch (Exception $e) {
4024 - 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
4025 4310 }
4026 4311 }
4027 4312
4028 4313 public function get_item_schema()
@@ -4040,15 +4325,15 @@
4040 4325 'type' => 'object',
4041 4326 // In JSON Schema you can specify object properties in the properties attribute.
4042 4327 'properties' => array(
4043 4328 'id' => array(
4044 - 'description' => esc_html__('Unique identifier for the object.', 'my-textdomain'),
4329 + 'description' => esc_html__('Unique identifier for the object.', 'metasync'),
4045 4330 'type' => 'integer',
4046 4331 'context' => array('view', 'edit', 'embed'),
4047 4332 'readonly' => true,
4048 4333 ),
4049 4334 'content' => array(
4050 - 'description' => esc_html__('The content for the object.', 'my-textdomain'),
4335 + 'description' => esc_html__('The content for the object.', 'metasync'),
4051 4336 'type' => 'string',
4052 4337 ),
4053 4338 ),
4054 4339 );
@@ -4139,11 +4424,11 @@
4139 4424
4140 4425 # Try alternative parameter methods
4141 4426 $body_params = $request->get_body_params();
4142 4427 $key_param = $request->get_param('key');
4143 - $post_key = $_POST['key'] ?? null;
4144 - $request_key = $_REQUEST['key'] ?? null;
4145 - $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
4146 4431
4147 4432 # Try to get key from multiple sources
4148 4433 $key_value = null;
4149 4434
@@ -4207,13 +4492,10 @@
4207 4492 'code' => 'invalid_path'
4208 4493 ), 400);
4209 4494 }
4210 4495
4211 - # Register the key for virtual serving via template_redirect.
4212 - # We deliberately do NOT write a physical {key}.txt: on nginx hosts that
4213 - # 403 direct static .txt access, an on-disk file is served statically and
4214 - # blocked before WordPress runs. Serving the key from PHP avoids that and
4215 - # also works on read-only web roots.
4496 + # Register the key for virtual serving via template_redirect as a
4497 + # FALLBACK for read-only web roots and hosts that 403 direct static .txt.
4216 4498 if (!class_exists('Metasync_Bing_Instant_Index')) {
4217 4499 $bing_file = plugin_dir_path(dirname(__FILE__)) . 'bing-index/class-metasync-bing-instant-index.php';
4218 4500 if (file_exists($bing_file)) {
4219 4501 require_once $bing_file;
@@ -4222,19 +4504,57 @@
4222 4504 if (class_exists('Metasync_Bing_Instant_Index')) {
4223 4505 Metasync_Bing_Instant_Index::register_virtual_key($safe_key);
4224 4506 }
4225 4507
4226 - # Best-effort removal of any stale physical key file so the virtual route
4227 - # governs — this is what fixes installs previously broken by a host that
4228 - # 403s static .txt (the on-disk file shadows the PHP handler). Safe: the
4229 - # path was validated to stay within ABSPATH above.
4230 - if (file_exists($file_path)) {
4231 - @unlink($file_path);
4508 + # Decide how {key}.txt is served, then confirm it end-to-end.
4509 + #
4510 + # Two host families must both work:
4511 + # - Static-serving hosts (e.g. cPanel Apache-behind-nginx): a missing .txt
4512 + # is 404'd by the web server before WordPress runs, so ONLY a physical
4513 + # file at the root is reachable.
4514 + # - nginx hosts that 403 direct static .txt: a physical file is blocked AND
4515 + # shadows the PHP route, so the key must be served virtually via
4516 + # template_redirect (no physical file present).
4517 + #
4518 + # Strategy: write the physical file when the root is writable, then probe
4519 + # it. Keep it only on a clean 200-with-matching-body; on 403/404/wrong body
4520 + # delete it so it stops shadowing the virtual route and let WordPress serve
4521 + # the key from PHP instead.
4522 + $serving = 'virtual';
4523 +
4524 + # ABSPATH is intentional and verified: the Bing site-verification key
4525 + # must be served from the site's web root exactly as Bing fetches it
4526 + # (/{key}.txt). $safe_key is basename()+sanitize_file_name()'d and the
4527 + # realpath check above confines $file_path to the root, so this cannot
4528 + # escape it. Plugin Check's ABSPATHDetected warning is a false
4529 + # positive — a verification file cannot live in the uploads directory.
4530 + if (wp_is_writable($wp_root) && @file_put_contents($file_path, $safe_key) !== false) {
4531 + # Cache-buster avoids a stale 404 cached by a CDN/nginx for this path.
4532 + $probe = wp_remote_get(
4533 + home_url('/' . $safe_key . '.txt?_msverify=' . time()),
4534 + array('timeout' => 10, 'sslverify' => false)
4535 + );
4536 + $code = is_wp_error($probe) ? 0 : (int) wp_remote_retrieve_response_code($probe);
4537 + $body = is_wp_error($probe) ? '' : trim((string) wp_remote_retrieve_body($probe));
4538 +
4539 + if ($code === 200 && $body === $safe_key) {
4540 + $serving = 'physical';
4541 + } else {
4542 + # 403 / 404 / wrong body — a present file shadows the virtual route,
4543 + # so remove it and let WordPress serve the key instead.
4544 + @wp_delete_file($file_path);
4545 + $serving = 'virtual';
4546 + }
4232 4547 }
4233 4548
4234 - # 'physical' only if the file still exists (e.g. read-only root prevented
4235 - # removal); otherwise the key is served virtually by WordPress.
4236 - $serving = file_exists($file_path) ? 'physical' : 'virtual';
4549 + # Persist the winning strategy per key so activation stays deterministic
4550 + # and we can tell when a rotated key needs re-probing.
4551 + update_option('metasync_indexnow_serving', array(
4552 + 'key' => $safe_key,
4553 + 'strategy' => $serving,
4554 + 'ts' => time(),
4555 + ), false);
4556 +
4237 4557
4238 4558 # Return success response
4239 4559 return new WP_REST_Response(array(
4240 4560 'success' => true,