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 +616 -310 2.6.12 → 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;
@@ -278,16 +191,224 @@
278 191 if (empty($api_key)) {
279 192 return false;
280 193 }
281 194
195 + $stored_key = $this->get_stored_api_key();
196 +
197 + if (empty($stored_key)) {
198 + return false;
199 + }
200 +
201 + return hash_equals($stored_key, $api_key);
202 + }
203 +
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 + {
282 214 $getOptions = Metasync::get_option('general');
283 - $stored_key = $getOptions['apikey'] ?? null;
284 215
216 + return $getOptions['apikey'] ?? null;
217 + }
218 +
219 + /**
220 + * Coexist with third-party JWT authentication plugins.
221 + *
222 + * A global JWT auth plugin (e.g. Tmeister's "JWT Authentication for WP-API")
223 + * validates any "Authorization: Bearer ..." header as a JWT. MetaSync sends
224 + * its plugin API key as a Bearer token, so the JWT plugin rejects it with a
225 + * 403 (jwt_auth_invalid_token). That plugin surfaces the error from its
226 + * rest_pre_dispatch callback (priority 10), short-circuiting the request
227 + * before MetaSync's own permission_callback ever runs.
228 + *
229 + * This filter is registered on rest_pre_dispatch at a later priority (11) so
230 + * it runs after the JWT plugin. When a prior error exists for a metasync/v1
231 + * request and our own API key validates, it clears the error (returns null)
232 + * so dispatch continues to the route and rest_authorization_middleware can
233 + * authorize it. Every other route — and every request without a valid
234 + * MetaSync key — is left untouched, preserving the JWT plugin's behaviour.
235 + *
236 + * @param mixed $result Prior dispatch result (WP_Error to short-circuit, otherwise null).
237 + * @param WP_REST_Server|null $server REST server instance (unused).
238 + * @param WP_REST_Request|null $request Current request.
239 + * @return mixed Cleared (null) only when our key validates on a metasync/v1
240 + * route; otherwise the input $result unchanged.
241 + */
242 + public function allow_metasync_rest_auth($result, $server = null, $request = null)
243 + {
244 + // No prior error means normal flow — never alter it.
245 + if (!is_wp_error($result)) {
246 + return $result;
247 + }
248 +
249 + // We need the request to know which route is being dispatched.
250 + if (!($request instanceof \WP_REST_Request)) {
251 + return $result;
252 + }
253 +
254 + // Only intervene for our own namespace; preserve the JWT plugin's
255 + // behaviour for every other (core/third-party) route.
256 + $route = $request->get_route();
257 + $prefix = '/' . self::namespace;
258 + if (strpos($route, $prefix . '/') !== 0 && $route !== $prefix) {
259 + return $result;
260 + }
261 +
262 + // Clear the error only when our own API key validates, so MetaSync's
263 + // permission_callback gets to authorize the request normally.
264 + if ($this->rest_authorization_middleware($request) === true) {
265 + return null;
266 + }
267 +
268 + return $result;
269 + }
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 + }
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 the same three sources as
371 + * 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 + // Fallback: ?apikey= query param (deprecated).
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
399 + }
400 +
401 + if ($api_key === '') {
402 + return false;
403 + }
404 +
405 + $stored_key = $this->get_stored_api_key();
285 406 if (empty($stored_key)) {
286 407 return false;
287 408 }
288 409
289 - return hash_equals($stored_key, $api_key);
410 + return hash_equals((string) $stored_key, $api_key);
290 411 }
291 412
292 413
293 414 public function metasync_register_rest_routes()
@@ -429,9 +550,9 @@
429 550 array(
430 551 array(
431 552 'methods' => 'GET',
432 553 'callback' => function () {
433 - $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
434 555
435 556 if ($getPostID==0) {
436 557 $response = false;
437 558 }else{
@@ -729,14 +850,14 @@
729 850 }
730 851
731 852 # Check if post exists
732 853 if (!$post || $post_id <= 0) {
733 - return rest_ensure_response(array('error' => 'no blog post found'), 404);
854 + return new WP_REST_Response(array('error' => 'no blog post found'), 404);
734 855 }
735 856
736 857 # Check if post type is 'post', return error if not
737 858 if ($post->post_type !== 'post') {
738 - return rest_ensure_response(array('error' => 'only post type is supported'), 400);
859 + return new WP_REST_Response(array('error' => 'only post type is supported'), 400);
739 860 }
740 861
741 862 # Render content
742 863 $post_content = $post->post_content;
@@ -960,9 +1081,9 @@
960 1081 }
961 1082
962 1083 public function get_errorlogs()
963 1084 {
964 - $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
965 1086 if (!isset($get_data['limit']))
966 1087 return false;
967 1088 $limit = sanitize_text_field($get_data['limit']) ?? null;
968 1089
@@ -1150,9 +1271,9 @@
1150 1271 }
1151 1272 } catch (Throwable $e) {
1152 1273 // Fallback to DOMDocument if Dom\HTMLDocument fails
1153 1274 if (defined('WP_DEBUG_LOG') && WP_DEBUG_LOG) {
1154 - 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
1155 1276 }
1156 1277 $dom = new DOMDocument();
1157 1278 libxml_use_internal_errors(true);
1158 1279 $encoded_content = $this->encode_numeric_entities($content);
@@ -1352,9 +1473,9 @@
1352 1473 $image_id = attachment_url_to_postid($node->getAttribute('src') );
1353 1474 $image_alt = get_post_meta($image_id, '_wp_attachment_image_alt', TRUE);
1354 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]';
1355 1476
1356 - 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
1357 1478
1358 1479 }
1359 1480 }elseif($node->nodeName === 'iframe'){
1360 1481 $result= '[et_pb_code _builder_version="'.ET_BUILDER_VERSION.'" _module_preset="default" global_colors_info="{}"]'.$node->ownerDocument->saveHTML($node).'[/et_pb_code]' ;
@@ -1482,71 +1603,12 @@
1482 1603 return $attachment_id;
1483 1604 }
1484 1605
1485 1606 /**
1486 - * Index a post with Google Indexing API
1487 - *
1488 - * @param int $post_id WordPress post ID
1489 - * @param string $post_type WordPress post type (post, page, etc.)
1490 - * @param string $post_status WordPress post status (publish, draft, etc.)
1491 - */
1492 - public function metasync_google_index_post($post_id, $post_type, $post_status)
1493 - {
1494 - // Only index published posts/pages
1495 - if ($post_status !== 'publish') {
1496 - return;
1497 - }
1498 -
1499 - // Only index posts and pages (can be extended as needed)
1500 - $allowed_post_types = array('post', 'page');
1501 - if (!in_array($post_type, $allowed_post_types)) {
1502 - return;
1503 - }
1504 -
1505 - try {
1506 - // Load Google Index functionality if not already loaded
1507 - if (!function_exists('google_index_post')) {
1508 - $google_index_path = plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php';
1509 - if (file_exists($google_index_path)) {
1510 - require_once $google_index_path;
1511 - } else {
1512 - error_log('MetaSync Google Index: google-index-init.php not found at ' . $google_index_path);
1513 - return;
1514 - }
1515 - }
1516 -
1517 - // Attempt to index the post with Google
1518 - if (function_exists('google_index_post')) {
1519 - $result = google_index_post($post_id, $post_type, 'update');
1520 -
1521 - if (isset($result['success']) && $result['success']) {
1522 - error_log(sprintf(
1523 - 'MetaSync Google Index: Successfully indexed %s (ID: %d, Type: %s)',
1524 - get_the_title($post_id),
1525 - $post_id,
1526 - $post_type
1527 - ));
1528 - } else {
1529 - error_log(sprintf(
1530 - 'MetaSync Google Index: Failed to index %s (ID: %d, Type: %s) - %s',
1531 - get_the_title($post_id),
1532 - $post_id,
1533 - $post_type,
1534 - isset($result['error']['message']) ? $result['error']['message'] : 'Unknown error'
1535 - ));
1536 - }
1537 - }
1538 - } catch (Exception $e) {
1539 - // Log any exceptions but don't break the main functionality
1540 - error_log('MetaSync Google Index Exception: ' . $e->getMessage());
1541 - }
1542 - }
1543 -
1544 - /**
1545 - * WP-429: Remove a leading <h1> from post content when it duplicates the post title.
1607 + * Remove a leading <h1> from post content when it duplicates the post title.
1546 1608 *
1547 1609 * Themes that render post_title as an H1 produce a duplicate heading when the
1548 - * synced body also opens with the title. Counterpart of the WP-337 guard, which
1610 + * synced body also opens with the title. Counterpart of the guard, which
1549 1611 * only prevents prepending a second H1 but never removes one supplied in the body.
1550 1612 *
1551 1613 * @param string $content Post body HTML
1552 1614 * @param string $title Post title
@@ -1563,9 +1625,9 @@
1563 1625 return $content;
1564 1626 }
1565 1627
1566 1628 # Entity-decode both sides so "Mobility &amp; Recovery" matches "Mobility & Recovery"
1567 - $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));
1568 1630 $title_text = trim(html_entity_decode((string) $title, ENT_QUOTES | ENT_HTML5));
1569 1631 if ($h1_text === '' || strcasecmp($h1_text, $title_text) !== 0) {
1570 1632 return $content;
1571 1633 }
@@ -1573,9 +1635,9 @@
1573 1635 return substr($trimmed, strlen($h1_match[0]));
1574 1636 }
1575 1637
1576 1638 /**
1577 - * WP-429: Decide whether the synced body should carry a prepended <h1> title.
1639 + * Decide whether the synced body should carry a prepended <h1> title.
1578 1640 *
1579 1641 * Replaces the unreliable 7-day hidden-post probe (title_in_headings) as the
1580 1642 * source of truth for the prepend decision. We only prepend when we have a
1581 1643 * POSITIVE, render-verified verdict that the active theme does NOT render the
@@ -1599,9 +1661,9 @@
1599 1661 return false;
1600 1662 }
1601 1663
1602 1664 /**
1603 - * WP-429: After a post is saved, render its live URL once and record whether
1665 + * After a post is saved, render its live URL once and record whether
1604 1666 * the active theme outputs the post title in a heading. The verdict is cached
1605 1667 * per post-type so subsequent syncs skip the round-trip. Best-effort: any
1606 1668 * inconclusive fetch (non-200, cache/challenge page, wrong post) records
1607 1669 * nothing and is retried on the next sync.
@@ -1641,9 +1703,9 @@
1641 1703 Metasync::set_option($options);
1642 1704 }
1643 1705
1644 1706 /**
1645 - * WP-429: Fetch the rendered post and detect whether the theme outputs the
1707 + * Fetch the rendered post and detect whether the theme outputs the
1646 1708 * title in a heading.
1647 1709 *
1648 1710 * @param string $permalink Public permalink
1649 1711 * @param int $post_id Saved post ID (used to confirm we fetched the right page)
@@ -1689,9 +1751,9 @@
1689 1751 return $this->title_present_in_headings($html, $post_title);
1690 1752 }
1691 1753
1692 1754 /**
1693 - * WP-429: Return true if $title appears within any heading (h1–h6) of $html.
1755 + * Return true if $title appears within any heading (h1–h6) of $html.
1694 1756 *
1695 1757 * @param string $html Rendered page HTML
1696 1758 * @param string $title Title text to match
1697 1759 * @return bool
@@ -1756,8 +1818,9 @@
1756 1818 check if the create_item is called by set_landing_page function or not
1757 1819 by doing this we will prevent html from going into builder page option
1758 1820 */
1759 1821 $isOttoAiPage = !empty($item['otto_ai_page']) && filter_var($item['otto_ai_page'], FILTER_VALIDATE_BOOLEAN);
1822 + $content = [];
1760 1823 if(!isset($item['is_landing_page']) && !$isOttoAiPage && empty($item['style_data']) ){
1761 1824
1762 1825 # Get Current Post type
1763 1826 $current_post_type = isset($item['post_type']) ? sanitize_text_field($item['post_type']) : 'post';
@@ -1775,9 +1838,9 @@
1775 1838 } elseif (!empty($theme_template) && stripos($theme_template, 'flatsome') !== false) {
1776 1839 $is_flatsome_theme = true;
1777 1840 }
1778 1841
1779 - # WP-429: Always strip a leading H1 that duplicates the title. The
1842 + # Always strip a leading H1 that duplicates the title. The
1780 1843 # theme renders post_title in its header on virtually every theme, so a
1781 1844 # title H1 at the top of the body is a duplicate. Whether we then put one
1782 1845 # back is decided by should_prepend_title() below.
1783 1846 if (isset($item['post_title'])) {
@@ -1793,9 +1856,9 @@
1793 1856 if(!$title_and_feature_image['image_in_content'] && !empty($item['hero_image_url'])){
1794 1857 $item['post_content'] = '<img src="' . esc_url_raw($item['hero_image_url']) . '" />'.$item['post_content'] ;
1795 1858 }
1796 1859
1797 - # WP-429: Only prepend the title H1 when the theme is render-verified to
1860 + # Only prepend the title H1 when the theme is render-verified to
1798 1861 # omit it. Unknown verdict defaults to NOT prepending (see should_prepend_title).
1799 1862 if(isset($item['post_title']) && $this->should_prepend_title($current_post_type)){
1800 1863 $item['post_content'] = '<h1>'.$item['post_title'].'</h1>'.$item['post_content'] ;
1801 1864 }
@@ -1833,13 +1896,13 @@
1833 1896 $new_post['post_name'] = sanitize_text_field($item['permalink']);
1834 1897 }
1835 1898
1836 1899 if (isset($item['post_date']) && !empty($item['post_date'])) {
1837 - $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'];
1838 1901 if (!$is_valid_date) {
1839 1902 return new WP_Error(
1840 1903 'rest_post_invalid_date',
1841 - esc_html__('Post date is not valid'),
1904 + esc_html__('Post date is not valid', 'metasync'),
1842 1905 array('status' => 400)
1843 1906 );
1844 1907 }
1845 1908
@@ -1956,13 +2019,17 @@
1956 2019 $post_meta['searchatlas_embed_bottom'] = $item['searchatlas_embed_bottom'];
1957 2020 }
1958 2021
1959 2022 // Add custom fields to posts and pages
1960 - foreach ($post_meta as $key => $value) {
1961 - // if (!empty($value) && !is_null($value)) {
1962 - add_post_meta($post_id, $key, $value, true);
1963 - // }
1964 - }
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 + );
1965 2032
1966 2033
1967 2034 $attachment_id = '';
1968 2035 if (isset($item['hero_image_url']) && !empty($item['hero_image_url'])) {
@@ -1980,8 +2047,14 @@
1980 2047 $redirection['url'] = sanitize_url($item['redirection_url']);
1981 2048 }
1982 2049 if (!empty($redirection)) {
1983 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 + }
1984 2057 }
1985 2058
1986 2059 $post_cattegories = [];
1987 2060 # if ($new_post['post_type'] === 'post' && is_array(@$item['post_categories'])) {
@@ -2041,16 +2114,13 @@
2041 2114 # Track Content Genius event in GA4
2042 2115 try {
2043 2116 Metasync_GA4::get_instance()->track_content_genius_event($post_id, strtolower($action));
2044 2117 } catch (Exception $e) {
2045 - 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
2046 2119 }
2047 -
2048 - // Google Indexing Integration
2049 - $this->metasync_google_index_post($post_id, $new_post['post_type'], $new_post['post_status']);
2050 2120 }
2051 2121
2052 - # WP-429: For standard synced posts, learn (once per post-type) whether the
2122 + # For standard synced posts, learn (once per post-type) whether the
2053 2123 # theme renders the title, so future syncs prepend only when genuinely needed.
2054 2124 if (!is_wp_error($post_id) && $post_id > 0
2055 2125 && !isset($item['is_landing_page']) && empty($isOttoAiPage) && empty($item['style_data'])
2056 2126 && $new_post['post_status'] === 'publish') {
@@ -2056,9 +2126,9 @@
2056 2126 && $new_post['post_status'] === 'publish') {
2057 2127 try {
2058 2128 $this->record_theme_title_verdict($post_id, $permalink, $new_post['post_title'], $new_post['post_type']);
2059 2129 } catch (Exception $e) {
2060 - error_log('MetaSync WP-429 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
2061 2131 }
2062 2132 }
2063 2133
2064 2134 $respCreatePosts[$index] = array_merge($new_post, $post_meta);
@@ -2103,9 +2173,9 @@
2103 2173 }
2104 2174
2105 2175 public function delete_item()
2106 2176 {
2107 - $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
2108 2178 if (!isset($get_data['ID']))
2109 2179 return false;
2110 2180
2111 2181 $post_id = sanitize_text_field($get_data['ID']) ?? null;
@@ -2111,18 +2181,18 @@
2111 2181 $post_id = sanitize_text_field($get_data['ID']) ?? null;
2112 2182 $post = get_post($post_id);
2113 2183 if ($post) {
2114 2184 wp_delete_post($post_id);
2185 + // HTTP 204 requires no body for response, so no message is set.
2115 2186 return new WP_Error(
2116 2187 'rest_post_delete_success',
2117 - esc_html__(''),
2118 - // HTTP 204 requires no body for response
2188 + '',
2119 2189 array('status' => 204)
2120 2190 );
2121 2191 }
2122 2192 return new WP_Error(
2123 2193 'rest_post_delete_fail',
2124 - esc_html__('No post found in the database with requested ID.'),
2194 + esc_html__('No post found in the database with requested ID.', 'metasync'),
2125 2195 array('status' => 400)
2126 2196 );
2127 2197 }
2128 2198
@@ -2127,9 +2197,9 @@
2127 2197 }
2128 2198
2129 2199 public function get_items($request)
2130 2200 {
2131 - $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
2132 2202
2133 2203 # let us check if request send post id and is valid int
2134 2204 if(isset($get_data['post_id']) AND intval($get_data['post_id']) > 0){
2135 2205
@@ -2469,9 +2539,9 @@
2469 2539 $post_data = get_post($post_id);
2470 2540 if (!$post_data) {
2471 2541 return new WP_Error(
2472 2542 'rest_post_update_fail',
2473 - esc_html__('No post found in the database with requested ID.'),
2543 + esc_html__('No post found in the database with requested ID.', 'metasync'),
2474 2544 array('status' => 400)
2475 2545 );
2476 2546 }
2477 2547
@@ -2496,12 +2566,19 @@
2496 2566 $update_params['meta_robots'] = $post['meta_robots'] ?
2497 2567 sanitize_text_field($post['meta_robots']) : $get_robots_meta;
2498 2568 }
2499 2569 if (isset($post['meta_canonical']) && !empty($post['meta_canonical'])) {
2500 - $update_params['meta_canonical'] = sanitize_text_field($post['meta_canonical']);
2570 + // Validate as a URL: drop unusable values instead of
2571 + // storing text that would later be emitted as a canonical.
2572 + $clean_canonical = Metasync_Canonical_Sanitizer::sanitize_for_save($post['meta_canonical']);
2573 + if ($clean_canonical !== '') {
2574 + $update_params['meta_canonical'] = $clean_canonical;
2575 + }
2501 2576 }
2502 2577
2503 2578 $isOttoAiPage = !empty($post['otto_ai_page']) && filter_var($post['otto_ai_page'], FILTER_VALIDATE_BOOLEAN);
2579 + $content = [];
2580 + $permalink = '';
2504 2581 if (isset($post['post_content']) && !empty($post['post_content']) && !$isOttoAiPage) {
2505 2582
2506 2583 # Above we are updating the post_type so we have to get latest value that has been change on the server
2507 2584 $post_fresh_data = get_post($post['post_id']);
@@ -2523,9 +2600,9 @@
2523 2600
2524 2601 # Title to compare against — payload may omit post_title on update
2525 2602 $compare_title = isset($post['post_title']) && !empty($post['post_title']) ? $post['post_title'] : $post_fresh_data->post_title;
2526 2603
2527 - # WP-429: Always strip a leading H1 that duplicates the title; the theme
2604 + # Always strip a leading H1 that duplicates the title; the theme
2528 2605 # renders post_title in its header, so a body title H1 is a duplicate.
2529 2606 $post['post_content'] = $this->strip_leading_title_h1($post['post_content'], $compare_title);
2530 2607
2531 2608 # Skip title/image prepending for Flatsome (it displays them by default)
@@ -2537,9 +2614,9 @@
2537 2614 if(!$title_and_feature_image['image_in_content'] && !empty($post['hero_image_url'])){
2538 2615 $post['post_content'] = '<img src="' . esc_url_raw($post['hero_image_url']) . '" />'.$post['post_content'] ;
2539 2616 }
2540 2617
2541 - # WP-429: Only prepend the title H1 when the theme is render-verified to
2618 + # Only prepend the title H1 when the theme is render-verified to
2542 2619 # omit it. Unknown verdict defaults to NOT prepending (see should_prepend_title).
2543 2620 if($this->should_prepend_title($post_fresh_data->post_type)){
2544 2621 $post['post_content'] = '<h1>'.$compare_title.'</h1>'.$post['post_content'] ;
2545 2622 }
@@ -2602,40 +2679,8 @@
2602 2679 );
2603 2680 }
2604 2681
2605 2682
2606 - if (isset($post['post_date']) && !empty($post['post_date']) && false) {
2607 - $is_valid_date = date('Y-m-d', strtotime($post['post_date'])) == $post['post_date'];
2608 - if (!$is_valid_date) {
2609 - return new WP_Error(
2610 - 'rest_post_invalid_date',
2611 - esc_html__('Post date is not valid'),
2612 - array('status' => 400)
2613 - );
2614 - }
2615 -
2616 - $date_limit_str = strtotime(date('Y-m-d') . '-2 month');
2617 - $post_date_str = strtotime($post['post_date']);
2618 -
2619 - if ($date_limit_str >= $post_date_str) {
2620 - $newDate = date('Y-m-d', strtotime('-2 month'));
2621 - return new WP_Error(
2622 - 'rest_post_greater_date',
2623 - esc_html__("Post date should be greater then " . $newDate),
2624 - array('status' => 400)
2625 - );
2626 - }
2627 -
2628 - if ($post_date_str > strtotime(date('Y-m-d'))) {
2629 - return new WP_Error(
2630 - 'rest_post_greater_date',
2631 - esc_html__('Post date should be less then Today'),
2632 - array('status' => 400)
2633 - );
2634 - }
2635 - $update_params['post_date'] = sanitize_text_field($post['post_date'] . date(' h:i:s'));
2636 - }
2637 -
2638 2683 $post_cattegories = [];
2639 2684 # if ($post_data && $post_data->post_type === 'post' && is_array(@$post['post_categories'])) {
2640 2685
2641 2686 # fixed Undefined array key issue
@@ -2659,11 +2704,9 @@
2659 2704 }
2660 2705
2661 2706 $resp_update = $this->update_object($post_id, $update_params);
2662 2707 if(isset($content['elementor_meta_data'])){
2663 - foreach ($content['elementor_meta_data'] as $key => $value) {
2664 - update_post_meta($post_id, $key, $value);
2665 - }
2708 + $this->metasync_apply_builder_meta($post_id, $content['elementor_meta_data']);
2666 2709 if ( did_action( 'elementor/loaded' ) ) {
2667 2710 // Clear Elementor cache for the specified post ID
2668 2711 \Elementor\Plugin::instance()->files_manager->clear_cache();
2669 2712 }
@@ -2678,8 +2721,15 @@
2678 2721 delete_post_meta($post_id, '_elementor_page_assets');
2679 2722 delete_post_meta($post_id, '_elementor_page_settings');
2680 2723 }
2681 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 +
2682 2732 $redirection = array();
2683 2733 if (!empty($post['redirection_enable']) && !is_null($post['redirection_enable'])) {
2684 2734 $redirection['enable'] = sanitize_text_field($post['redirection_enable']);
2685 2735 }
@@ -2690,8 +2740,14 @@
2690 2740 $redirection['url'] = sanitize_url($post['redirection_url']);
2691 2741 }
2692 2742 if (!empty($redirection)) {
2693 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 + }
2694 2750 }
2695 2751
2696 2752
2697 2753 $post_revisions = wp_get_post_revisions($post_id);
@@ -2698,9 +2754,8 @@
2698 2754 // Sync post categories to customer dashboard
2699 2755 $this->lgSendCustomerPostParams();
2700 2756
2701 2757 unset($update_params['post_name']);
2702 - unset($update_params['post_category']);
2703 2758
2704 2759 $update_params['post_categories'] = $post_cattegories;
2705 2760 $update_params['post_tags'] = $post_tags;
2706 2761 $update_params['post_id'] = (int) $post_id;
@@ -2730,14 +2785,9 @@
2730 2785
2731 2786 }
2732 2787 # update the content
2733 2788 // add and update the post meta
2734 - foreach ($post_meta_data as $key => $value) {
2735 - // if (!empty($value) && !is_null($value)) {
2736 -
2737 - update_post_meta($post_id, $key, $value);
2738 - //
2739 - }
2789 + $this->metasync_apply_builder_meta($post_id, $post_meta_data);
2740 2790 #check if the elementor plugin is active
2741 2791 if ( did_action( 'elementor/loaded' ) ) {
2742 2792 # Clear Elementor cache for the specified post ID
2743 2793 \Elementor\Plugin::instance()->files_manager->clear_cache();
@@ -2774,12 +2824,12 @@
2774 2824 # Track Content Genius event in GA4
2775 2825 try {
2776 2826 Metasync_GA4::get_instance()->track_content_genius_event($post_id, 'updated');
2777 2827 } catch (Exception $e) {
2778 - 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
2779 2829 }
2780 2830
2781 - # WP-429: Learn (once per post-type) whether the theme renders the title,
2831 + # Learn (once per post-type) whether the theme renders the title,
2782 2832 # so future syncs prepend only when the theme genuinely omits it.
2783 2833 if ($post_id > 0 && empty($post['is_landing_page']) && empty($isOttoAiPage)
2784 2834 && $sync_status === 'published') {
2785 2835 try {
@@ -2784,9 +2834,9 @@
2784 2834 && $sync_status === 'published') {
2785 2835 try {
2786 2836 $this->record_theme_title_verdict($post_id, ($permalink ?? get_permalink($post_id)), $post_title, $post_type);
2787 2837 } catch (Exception $e) {
2788 - error_log('MetaSync WP-429 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
2789 2839 }
2790 2840 }
2791 2841 }
2792 2842
@@ -2883,20 +2933,9 @@
2883 2933 if($isOttoAiPage){
2884 2934 // Change the page template from default to Metasync Template
2885 2935 $pageTemplate = Metasync_Template::TEMPLATE_NAME;
2886 2936 }
2887 - if ($pageTemplate !== 'default') {
2888 - update_post_meta($post_id, '_wp_page_template', $pageTemplate);
2889 - } else {
2890 - // Clear stale templates that conflict with the active page builder.
2891 - // e.g. metasync-blank from a previous OTTO sync, or elementor_canvas
2892 - // written by the HTML-to-builder converter on an Oxygen site.
2893 - $current = get_post_meta($post_id, '_wp_page_template', true);
2894 - $stale_templates = array(Metasync_Template::TEMPLATE_NAME, 'elementor_canvas', 'elementor_header_footer');
2895 - if (in_array($current, $stale_templates, true)) {
2896 - delete_post_meta($post_id, '_wp_page_template');
2897 - }
2898 - }
2937 + $this->metasync_write_page_template($post_id, $pageTemplate);
2899 2938 }
2900 2939
2901 2940 return rest_ensure_response($createPages);
2902 2941 }
@@ -2919,9 +2958,9 @@
2919 2958 $post_data = get_post($payload['post_id']);
2920 2959 if(!isset($post_data->post_type)){
2921 2960 return new WP_Error(
2922 2961 'rest_page_type_fail',
2923 - esc_html__('No page found in the database with requested ID.'),
2962 + esc_html__('No page found in the database with requested ID.', 'metasync'),
2924 2963 array('status' => 400)
2925 2964 );
2926 2965 }
2927 2966 if ($post_data->post_type !== 'page') {
@@ -2976,20 +3015,225 @@
2976 3015 if($isOttoAiPage){
2977 3016 // Change the page template from default to Metasync Template
2978 3017 $pageTemplate = Metasync_Template::TEMPLATE_NAME;
2979 3018 }
2980 - if ($pageTemplate !== 'default') {
2981 - 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);
2982 3232 } else {
2983 - // Clear stale templates that conflict with the active page builder.
2984 - $current = get_post_meta($post_id, '_wp_page_template', true);
2985 - $stale_templates = array(Metasync_Template::TEMPLATE_NAME, 'elementor_canvas', 'elementor_header_footer');
2986 - if (in_array($current, $stale_templates, true)) {
2987 - delete_post_meta($post_id, '_wp_page_template');
2988 - }
3233 + add_post_meta($post_id, $key, $value, true);
2989 3234 }
2990 3235 }
2991 - return rest_ensure_response($updatePages->data);
2992 3236 }
2993 3237
2994 3238 public function delete_page()
2995 3239 {
@@ -3015,8 +3259,17 @@
3015 3259 }
3016 3260
3017 3261 public function linkgraph_login()
3018 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 +
3019 3272 $post_data = metasync_sanitize_input_array($_POST);
3020 3273 $payload = array(
3021 3274 'username' => wp_unslash(sanitize_email($post_data['username'])),
3022 3275 'password' => wp_unslash(sanitize_text_field($post_data['password']))
@@ -3033,9 +3286,9 @@
3033 3286 ));
3034 3287
3035 3288 # Error handling for timeout or connection failures
3036 3289 if (is_wp_error($response)) {
3037 - 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
3038 3291 wp_send_json_error(array('message' => 'Token API request failed'));
3039 3292 wp_die();
3040 3293 }
3041 3294
@@ -3169,9 +3422,9 @@
3169 3422 $option_name = '_transient_metasync_sa_connect_active_' . $nonce_token;
3170 3423 $timeout_name = '_transient_timeout_metasync_sa_connect_active_' . $nonce_token;
3171 3424
3172 3425 // Check expiry first
3173 - $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
3174 3427 $wpdb->prepare(
3175 3428 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
3176 3429 $timeout_name
3177 3430 )
@@ -3178,14 +3431,14 @@
3178 3431 );
3179 3432
3180 3433 if ($timeout && (int) $timeout < time()) {
3181 3434 // Expired — clean up orphan rows
3182 - $wpdb->delete($wpdb->options, array('option_name' => $option_name));
3183 - $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
3184 3437 return false;
3185 3438 }
3186 3439
3187 - $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
3188 3441 $wpdb->prepare(
3189 3442 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
3190 3443 $option_name
3191 3444 )
@@ -3209,9 +3462,9 @@
3209 3462 $option_name = '_transient_' . $transient_key;
3210 3463 $timeout_name = '_transient_timeout_' . $transient_key;
3211 3464
3212 3465 // Check expiry first
3213 - $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
3214 3467 $wpdb->prepare(
3215 3468 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
3216 3469 $timeout_name
3217 3470 )
@@ -3217,14 +3470,14 @@
3217 3470 )
3218 3471 );
3219 3472
3220 3473 if ($timeout && (int) $timeout < time()) {
3221 - $wpdb->delete($wpdb->options, array('option_name' => $option_name));
3222 - $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
3223 3476 return false;
3224 3477 }
3225 3478
3226 - $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
3227 3480 $wpdb->prepare(
3228 3481 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
3229 3482 $option_name
3230 3483 )
@@ -3251,10 +3504,10 @@
3251 3504 {
3252 3505 delete_transient($transient_name);
3253 3506 if (wp_using_ext_object_cache()) {
3254 3507 global $wpdb;
3255 - $wpdb->delete($wpdb->options, array('option_name' => '_transient_' . $transient_name));
3256 - $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
3257 3510 }
3258 3511 }
3259 3512
3260 3513 /**
@@ -3271,14 +3524,14 @@
3271 3524 {
3272 3525 set_transient($transient_name, $value, $expiration);
3273 3526 if (wp_using_ext_object_cache()) {
3274 3527 global $wpdb;
3275 - $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
3276 3529 'option_name' => '_transient_' . $transient_name,
3277 3530 'option_value' => maybe_serialize($value),
3278 3531 'autoload' => 'no',
3279 3532 ));
3280 - $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
3281 3534 'option_name' => '_transient_timeout_' . $transient_name,
3282 3535 'option_value' => time() + $expiration,
3283 3536 'autoload' => 'no',
3284 3537 ));
@@ -3632,9 +3885,9 @@
3632 3885 *
3633 3886 * @param array $body_params Request body parameters
3634 3887 * @return array|WP_Error Validated parameters array or WP_Error
3635 3888 */
3636 - private function extract_and_validate_searchatlas_params($body_params)
3889 + public function extract_and_validate_searchatlas_params($body_params)
3637 3890 {
3638 3891 $validation_errors = array();
3639 3892
3640 3893 // Extract parameters
@@ -3715,9 +3968,9 @@
3715 3968 } elseif (strlen($whitelabel_domain) > 255) {
3716 3969 $validation_errors['whitelabel_domain'] = 'Whitelabel domain must not exceed 255 characters';
3717 3970 } elseif (!filter_var($whitelabel_domain, FILTER_VALIDATE_URL)) {
3718 3971 $validation_errors['whitelabel_domain'] = 'Whitelabel domain must be a valid URL';
3719 - } 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'])) {
3720 3973 $validation_errors['whitelabel_domain'] = 'Whitelabel domain must use http or https protocol';
3721 3974 }
3722 3975 }
3723 3976
@@ -3730,9 +3983,9 @@
3730 3983 $validation_errors['whitelabel_logo'] = 'Whitelabel logo URL must not exceed 500 characters';
3731 3984 } else {
3732 3985 // If URL is invalid, we'll clear it later but not fail the POST
3733 3986 if (!filter_var($whitelabel_logo, FILTER_VALIDATE_URL) ||
3734 - !in_array(parse_url($whitelabel_logo, PHP_URL_SCHEME), ['http', 'https'])) {
3987 + !in_array(wp_parse_url($whitelabel_logo, PHP_URL_SCHEME), ['http', 'https'])) {
3735 3988 // Don't add to validation_errors - let POST succeed but clear the field
3736 3989 }
3737 3990 }
3738 3991 }
@@ -3823,9 +4076,10 @@
3823 4076 $options['general'] = array();
3824 4077 }
3825 4078 // Only update the API key in settings if status_code is 200 (success)
3826 4079 if ($status_code === 200) {
3827 - $options['general']['searchatlas_api_key'] = $new_api_key;
4080 + // Encrypt the API key at rest; never store it in cleartext.
4081 + $options['general']['searchatlas_api_key'] = Metasync::encrypt_api_key($new_api_key);
3828 4082 $options['general']['otto_pixel_uuid'] = $new_otto_uuid;
3829 4083 // Note: OTTO SSR is always enabled by default, no need to set
3830 4084
3831 4085 // Granular otto_config_status: record when SSO completed (ISO 8601 UTC)
@@ -3857,11 +4111,20 @@
3857 4111 if (!empty($whitelabel_otto)) {
3858 4112 $options['general']['whitelabel_otto_name'] = $whitelabel_otto;
3859 4113 }
3860 4114 } else {
3861 - // Clear whitelabel fields when not whitelabel
3862 - unset($options['general']['white_label_plugin_name']);
3863 - 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'] = '';
3864 4127 }
3865 4128
3866 4129 // Store whitelabel settings (hidden from UI but accessible to plugin logic)
3867 4130 if (!isset($options['whitelabel'])) {
@@ -3891,11 +4154,12 @@
3891 4154
3892 4155 }
3893 4156
3894 4157 $save_result = Metasync::set_option($options);
4158 + Metasync::invalidate_api_key_cache();
3895 4159
3896 4160 if (!$save_result) {
3897 - 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
3898 4162 } else {
3899 4163 if ($status_code === 200) {
3900 4164 // Option 1: Set heartbeat cache and last-known to CONNECTED so dashboard shows iframe immediately.
3901 4165 // If the immediate heartbeat then fails (e.g. backoff), Option 2 prevents overwriting this to DISCONNECTED.
@@ -3914,9 +4178,9 @@
3914 4178
3915 4179 return true;
3916 4180
3917 4181 } catch (Exception $e) {
3918 - 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
3919 4183 return false;
3920 4184 }
3921 4185 }
3922 4186
@@ -3928,9 +4192,9 @@
3928 4192 {
3929 4193 global $wpdb;
3930 4194
3931 4195 // Clear all JWT token transients
3932 - $deleted = $wpdb->query(
4196 + $deleted = $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
3933 4197 $wpdb->prepare(
3934 4198 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
3935 4199 '_transient_metasync_jwt_token_%'
3936 4200 )
@@ -3936,9 +4200,9 @@
3936 4200 )
3937 4201 );
3938 4202
3939 4203 // Also clear timeout transients
3940 - $wpdb->query(
4204 + $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
3941 4205 $wpdb->prepare(
3942 4206 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
3943 4207 '_transient_timeout_metasync_jwt_token_%'
3944 4208 )
@@ -3961,9 +4225,9 @@
3961 4225 // Also ensure heartbeat cron is scheduled now that we have an API key
3962 4226 do_action('metasync_ensure_heartbeat_cron_scheduled');
3963 4227
3964 4228 } catch (Exception $e) {
3965 - 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
3966 4230 }
3967 4231 }
3968 4232
3969 4233 public function get_item_schema()
@@ -3981,15 +4245,15 @@
3981 4245 'type' => 'object',
3982 4246 // In JSON Schema you can specify object properties in the properties attribute.
3983 4247 'properties' => array(
3984 4248 'id' => array(
3985 - 'description' => esc_html__('Unique identifier for the object.', 'my-textdomain'),
4249 + 'description' => esc_html__('Unique identifier for the object.', 'metasync'),
3986 4250 'type' => 'integer',
3987 4251 'context' => array('view', 'edit', 'embed'),
3988 4252 'readonly' => true,
3989 4253 ),
3990 4254 'content' => array(
3991 - 'description' => esc_html__('The content for the object.', 'my-textdomain'),
4255 + 'description' => esc_html__('The content for the object.', 'metasync'),
3992 4256 'type' => 'string',
3993 4257 ),
3994 4258 ),
3995 4259 );
@@ -4080,11 +4344,11 @@
4080 4344
4081 4345 # Try alternative parameter methods
4082 4346 $body_params = $request->get_body_params();
4083 4347 $key_param = $request->get_param('key');
4084 - $post_key = $_POST['key'] ?? null;
4085 - $request_key = $_REQUEST['key'] ?? null;
4086 - $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
4087 4351
4088 4352 # Try to get key from multiple sources
4089 4353 $key_value = null;
4090 4354
@@ -4114,9 +4378,9 @@
4114 4378 }
4115 4379
4116 4380 # Validate that key is provided
4117 4381 if (empty($key_value)) {
4118 - return rest_ensure_response(array(
4382 + return new WP_REST_Response(array(
4119 4383 'error' => 'Key parameter is required',
4120 4384 'code' => 'missing_key'
4121 4385 ), 400);
4122 4386 }
@@ -4128,9 +4392,9 @@
4128 4392 $key = basename(sanitize_file_name($data['key']));
4129 4393
4130 4394 # Validate key is not empty after sanitization and contains no path separators
4131 4395 if (empty($key) || preg_match('/[\/\\\\]/', $key) || strpos($key, '..') !== false) {
4132 - return rest_ensure_response(array(
4396 + return new WP_REST_Response(array(
4133 4397 'error' => 'Invalid key provided',
4134 4398 'code' => 'invalid_key'
4135 4399 ), 400);
4136 4400 }
@@ -4142,42 +4406,84 @@
4142 4406 $safe_key = sanitize_file_name( $key );
4143 4407 $file_path = $wp_root . $safe_key . '.txt';
4144 4408 $real_root = realpath( $wp_root );
4145 4409 if ( false === $real_root || 0 !== strpos( realpath( dirname( $file_path ) ), $real_root ) ) {
4146 - return rest_ensure_response(array(
4410 + return new WP_REST_Response(array(
4147 4411 'error' => 'Invalid file path',
4148 4412 'code' => 'invalid_path'
4149 4413 ), 400);
4150 4414 }
4151 4415
4152 - # Check if file already exists
4153 - if (file_exists($file_path)) {
4154 - return rest_ensure_response(array(
4155 - 'error' => 'File already exists',
4156 - 'code' => 'file_exists',
4157 - 'file_path' => $file_path
4158 - ), 409);
4416 + # Register the key for virtual serving via template_redirect as a
4417 + # FALLBACK for read-only web roots and hosts that 403 direct static .txt.
4418 + if (!class_exists('Metasync_Bing_Instant_Index')) {
4419 + $bing_file = plugin_dir_path(dirname(__FILE__)) . 'bing-index/class-metasync-bing-instant-index.php';
4420 + if (file_exists($bing_file)) {
4421 + require_once $bing_file;
4422 + }
4159 4423 }
4424 + if (class_exists('Metasync_Bing_Instant_Index')) {
4425 + Metasync_Bing_Instant_Index::register_virtual_key($safe_key);
4426 + }
4160 4427
4161 - # Attempt to create the file
4162 - $result = file_put_contents($file_path, $safe_key);
4163 -
4164 - # Check if file creation was successful
4165 - if ($result === false) {
4166 - return rest_ensure_response(array(
4167 - 'error' => 'Failed to create file',
4168 - 'code' => 'file_creation_failed',
4169 - 'file_path' => $file_path
4170 - ), 500);
4428 + # Decide how {key}.txt is served, then confirm it end-to-end.
4429 + #
4430 + # Two host families must both work:
4431 + # - Static-serving hosts (e.g. cPanel Apache-behind-nginx): a missing .txt
4432 + # is 404'd by the web server before WordPress runs, so ONLY a physical
4433 + # file at the root is reachable.
4434 + # - nginx hosts that 403 direct static .txt: a physical file is blocked AND
4435 + # shadows the PHP route, so the key must be served virtually via
4436 + # template_redirect (no physical file present).
4437 + #
4438 + # Strategy: write the physical file when the root is writable, then probe
4439 + # it. Keep it only on a clean 200-with-matching-body; on 403/404/wrong body
4440 + # delete it so it stops shadowing the virtual route and let WordPress serve
4441 + # the key from PHP instead.
4442 + $serving = 'virtual';
4443 +
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) {
4451 + # Cache-buster avoids a stale 404 cached by a CDN/nginx for this path.
4452 + $probe = wp_remote_get(
4453 + home_url('/' . $safe_key . '.txt?_msverify=' . time()),
4454 + array('timeout' => 10, 'sslverify' => false)
4455 + );
4456 + $code = is_wp_error($probe) ? 0 : (int) wp_remote_retrieve_response_code($probe);
4457 + $body = is_wp_error($probe) ? '' : trim((string) wp_remote_retrieve_body($probe));
4458 +
4459 + if ($code === 200 && $body === $safe_key) {
4460 + $serving = 'physical';
4461 + } else {
4462 + # 403 / 404 / wrong body — a present file shadows the virtual route,
4463 + # so remove it and let WordPress serve the key instead.
4464 + @wp_delete_file($file_path);
4465 + $serving = 'virtual';
4466 + }
4171 4467 }
4172 -
4468 +
4469 + # Persist the winning strategy per key so activation stays deterministic
4470 + # and we can tell when a rotated key needs re-probing.
4471 + update_option('metasync_indexnow_serving', array(
4472 + 'key' => $safe_key,
4473 + 'strategy' => $serving,
4474 + 'ts' => time(),
4475 + ), false);
4476 +
4477 +
4173 4478 # Return success response
4174 - return rest_ensure_response(array(
4175 - 'success' => true,
4176 - 'message' => 'Key file created successfully',
4479 + return new WP_REST_Response(array(
4480 + 'success' => true,
4481 + 'message' => 'Key file created successfully',
4177 4482 'file_path' => $file_path,
4178 - 'key' => $key,
4179 - 'file_size' => $result
4483 + 'file_url' => home_url('/' . $safe_key . '.txt'),
4484 + 'key' => $key,
4485 + 'serving' => $serving, // 'virtual' = served by WordPress, 'physical' = on-disk file remains
4180 4486 ), 200);
4181 4487 }
4182 4488
4183 4489 }