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 +522 -282 2.6.17 → 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,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,151 @@
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 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();
406 + if (empty($stored_key)) {
407 + return false;
408 + }
409 +
410 + return hash_equals((string) $stored_key, $api_key);
411 + }
412 +
413 +
345 414 public function metasync_register_rest_routes()
346 415 {
347 416 // Critical Routes
348 417 /*
@@ -481,9 +550,9 @@
481 550 array(
482 551 array(
483 552 'methods' => 'GET',
484 553 'callback' => function () {
485 - $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
486 555
487 556 if ($getPostID==0) {
488 557 $response = false;
489 558 }else{
@@ -1012,9 +1081,9 @@
1012 1081 }
1013 1082
1014 1083 public function get_errorlogs()
1015 1084 {
1016 - $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
1017 1086 if (!isset($get_data['limit']))
1018 1087 return false;
1019 1088 $limit = sanitize_text_field($get_data['limit']) ?? null;
1020 1089
@@ -1202,9 +1271,9 @@
1202 1271 }
1203 1272 } catch (Throwable $e) {
1204 1273 // Fallback to DOMDocument if Dom\HTMLDocument fails
1205 1274 if (defined('WP_DEBUG_LOG') && WP_DEBUG_LOG) {
1206 - 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
1207 1276 }
1208 1277 $dom = new DOMDocument();
1209 1278 libxml_use_internal_errors(true);
1210 1279 $encoded_content = $this->encode_numeric_entities($content);
@@ -1404,9 +1473,9 @@
1404 1473 $image_id = attachment_url_to_postid($node->getAttribute('src') );
1405 1474 $image_alt = get_post_meta($image_id, '_wp_attachment_image_alt', TRUE);
1406 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]';
1407 1476
1408 - 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
1409 1478
1410 1479 }
1411 1480 }elseif($node->nodeName === 'iframe'){
1412 1481 $result= '[et_pb_code _builder_version="'.ET_BUILDER_VERSION.'" _module_preset="default" global_colors_info="{}"]'.$node->ownerDocument->saveHTML($node).'[/et_pb_code]' ;
@@ -1534,67 +1603,8 @@
1534 1603 return $attachment_id;
1535 1604 }
1536 1605
1537 1606 /**
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 1607 * Remove a leading <h1> from post content when it duplicates the post title.
1598 1608 *
1599 1609 * Themes that render post_title as an H1 produce a duplicate heading when the
1600 1610 * synced body also opens with the title. Counterpart of the guard, which
@@ -1615,9 +1625,9 @@
1615 1625 return $content;
1616 1626 }
1617 1627
1618 1628 # 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));
1629 + $h1_text = trim(html_entity_decode(wp_strip_all_tags($h1_match[1]), ENT_QUOTES | ENT_HTML5));
1620 1630 $title_text = trim(html_entity_decode((string) $title, ENT_QUOTES | ENT_HTML5));
1621 1631 if ($h1_text === '' || strcasecmp($h1_text, $title_text) !== 0) {
1622 1632 return $content;
1623 1633 }
@@ -1886,13 +1896,13 @@
1886 1896 $new_post['post_name'] = sanitize_text_field($item['permalink']);
1887 1897 }
1888 1898
1889 1899 if (isset($item['post_date']) && !empty($item['post_date'])) {
1890 - $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'];
1891 1901 if (!$is_valid_date) {
1892 1902 return new WP_Error(
1893 1903 'rest_post_invalid_date',
1894 - esc_html__('Post date is not valid'),
1904 + esc_html__('Post date is not valid', 'metasync'),
1895 1905 array('status' => 400)
1896 1906 );
1897 1907 }
1898 1908
@@ -2009,13 +2019,17 @@
2009 2019 $post_meta['searchatlas_embed_bottom'] = $item['searchatlas_embed_bottom'];
2010 2020 }
2011 2021
2012 2022 // 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 - }
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 + );
2018 2032
2019 2033
2020 2034 $attachment_id = '';
2021 2035 if (isset($item['hero_image_url']) && !empty($item['hero_image_url'])) {
@@ -2033,8 +2047,14 @@
2033 2047 $redirection['url'] = sanitize_url($item['redirection_url']);
2034 2048 }
2035 2049 if (!empty($redirection)) {
2036 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 + }
2037 2057 }
2038 2058
2039 2059 $post_cattegories = [];
2040 2060 # if ($new_post['post_type'] === 'post' && is_array(@$item['post_categories'])) {
@@ -2094,13 +2114,10 @@
2094 2114 # Track Content Genius event in GA4
2095 2115 try {
2096 2116 Metasync_GA4::get_instance()->track_content_genius_event($post_id, strtolower($action));
2097 2117 } catch (Exception $e) {
2098 - 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
2099 2119 }
2100 -
2101 - // Google Indexing Integration
2102 - $this->metasync_google_index_post($post_id, $new_post['post_type'], $new_post['post_status']);
2103 2120 }
2104 2121
2105 2122 # For standard synced posts, learn (once per post-type) whether the
2106 2123 # theme renders the title, so future syncs prepend only when genuinely needed.
@@ -2109,9 +2126,9 @@
2109 2126 && $new_post['post_status'] === 'publish') {
2110 2127 try {
2111 2128 $this->record_theme_title_verdict($post_id, $permalink, $new_post['post_title'], $new_post['post_type']);
2112 2129 } catch (Exception $e) {
2113 - error_log('MetaSync title verdict failed: ' . $e->getMessage());
2130 + error_log('MetaSync title verdict failed: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
2114 2131 }
2115 2132 }
2116 2133
2117 2134 $respCreatePosts[$index] = array_merge($new_post, $post_meta);
@@ -2156,9 +2173,9 @@
2156 2173 }
2157 2174
2158 2175 public function delete_item()
2159 2176 {
2160 - $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
2161 2178 if (!isset($get_data['ID']))
2162 2179 return false;
2163 2180
2164 2181 $post_id = sanitize_text_field($get_data['ID']) ?? null;
@@ -2164,18 +2181,18 @@
2164 2181 $post_id = sanitize_text_field($get_data['ID']) ?? null;
2165 2182 $post = get_post($post_id);
2166 2183 if ($post) {
2167 2184 wp_delete_post($post_id);
2185 + // HTTP 204 requires no body for response, so no message is set.
2168 2186 return new WP_Error(
2169 2187 'rest_post_delete_success',
2170 - esc_html__(''),
2171 - // HTTP 204 requires no body for response
2188 + '',
2172 2189 array('status' => 204)
2173 2190 );
2174 2191 }
2175 2192 return new WP_Error(
2176 2193 'rest_post_delete_fail',
2177 - esc_html__('No post found in the database with requested ID.'),
2194 + esc_html__('No post found in the database with requested ID.', 'metasync'),
2178 2195 array('status' => 400)
2179 2196 );
2180 2197 }
2181 2198
@@ -2180,9 +2197,9 @@
2180 2197 }
2181 2198
2182 2199 public function get_items($request)
2183 2200 {
2184 - $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
2185 2202
2186 2203 # let us check if request send post id and is valid int
2187 2204 if(isset($get_data['post_id']) AND intval($get_data['post_id']) > 0){
2188 2205
@@ -2522,9 +2539,9 @@
2522 2539 $post_data = get_post($post_id);
2523 2540 if (!$post_data) {
2524 2541 return new WP_Error(
2525 2542 'rest_post_update_fail',
2526 - esc_html__('No post found in the database with requested ID.'),
2543 + esc_html__('No post found in the database with requested ID.', 'metasync'),
2527 2544 array('status' => 400)
2528 2545 );
2529 2546 }
2530 2547
@@ -2662,40 +2679,8 @@
2662 2679 );
2663 2680 }
2664 2681
2665 2682
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 2683 $post_cattegories = [];
2699 2684 # if ($post_data && $post_data->post_type === 'post' && is_array(@$post['post_categories'])) {
2700 2685
2701 2686 # fixed Undefined array key issue
@@ -2719,11 +2704,9 @@
2719 2704 }
2720 2705
2721 2706 $resp_update = $this->update_object($post_id, $update_params);
2722 2707 if(isset($content['elementor_meta_data'])){
2723 - foreach ($content['elementor_meta_data'] as $key => $value) {
2724 - update_post_meta($post_id, $key, $value);
2725 - }
2708 + $this->metasync_apply_builder_meta($post_id, $content['elementor_meta_data']);
2726 2709 if ( did_action( 'elementor/loaded' ) ) {
2727 2710 // Clear Elementor cache for the specified post ID
2728 2711 \Elementor\Plugin::instance()->files_manager->clear_cache();
2729 2712 }
@@ -2738,8 +2721,15 @@
2738 2721 delete_post_meta($post_id, '_elementor_page_assets');
2739 2722 delete_post_meta($post_id, '_elementor_page_settings');
2740 2723 }
2741 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 +
2742 2732 $redirection = array();
2743 2733 if (!empty($post['redirection_enable']) && !is_null($post['redirection_enable'])) {
2744 2734 $redirection['enable'] = sanitize_text_field($post['redirection_enable']);
2745 2735 }
@@ -2750,8 +2740,14 @@
2750 2740 $redirection['url'] = sanitize_url($post['redirection_url']);
2751 2741 }
2752 2742 if (!empty($redirection)) {
2753 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 + }
2754 2750 }
2755 2751
2756 2752
2757 2753 $post_revisions = wp_get_post_revisions($post_id);
@@ -2789,14 +2785,9 @@
2789 2785
2790 2786 }
2791 2787 # update the content
2792 2788 // 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 - }
2789 + $this->metasync_apply_builder_meta($post_id, $post_meta_data);
2799 2790 #check if the elementor plugin is active
2800 2791 if ( did_action( 'elementor/loaded' ) ) {
2801 2792 # Clear Elementor cache for the specified post ID
2802 2793 \Elementor\Plugin::instance()->files_manager->clear_cache();
@@ -2833,9 +2824,9 @@
2833 2824 # Track Content Genius event in GA4
2834 2825 try {
2835 2826 Metasync_GA4::get_instance()->track_content_genius_event($post_id, 'updated');
2836 2827 } catch (Exception $e) {
2837 - 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
2838 2829 }
2839 2830
2840 2831 # Learn (once per post-type) whether the theme renders the title,
2841 2832 # so future syncs prepend only when the theme genuinely omits it.
@@ -2843,9 +2834,9 @@
2843 2834 && $sync_status === 'published') {
2844 2835 try {
2845 2836 $this->record_theme_title_verdict($post_id, ($permalink ?? get_permalink($post_id)), $post_title, $post_type);
2846 2837 } catch (Exception $e) {
2847 - error_log('MetaSync title verdict failed: ' . $e->getMessage());
2838 + error_log('MetaSync title verdict failed: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
2848 2839 }
2849 2840 }
2850 2841 }
2851 2842
@@ -2942,20 +2933,9 @@
2942 2933 if($isOttoAiPage){
2943 2934 // Change the page template from default to Metasync Template
2944 2935 $pageTemplate = Metasync_Template::TEMPLATE_NAME;
2945 2936 }
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 - }
2937 + $this->metasync_write_page_template($post_id, $pageTemplate);
2958 2938 }
2959 2939
2960 2940 return rest_ensure_response($createPages);
2961 2941 }
@@ -2978,9 +2958,9 @@
2978 2958 $post_data = get_post($payload['post_id']);
2979 2959 if(!isset($post_data->post_type)){
2980 2960 return new WP_Error(
2981 2961 'rest_page_type_fail',
2982 - esc_html__('No page found in the database with requested ID.'),
2962 + esc_html__('No page found in the database with requested ID.', 'metasync'),
2983 2963 array('status' => 400)
2984 2964 );
2985 2965 }
2986 2966 if ($post_data->post_type !== 'page') {
@@ -3035,20 +3015,225 @@
3035 3015 if($isOttoAiPage){
3036 3016 // Change the page template from default to Metasync Template
3037 3017 $pageTemplate = Metasync_Template::TEMPLATE_NAME;
3038 3018 }
3039 - if ($pageTemplate !== 'default') {
3040 - 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);
3041 3232 } 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 - }
3233 + add_post_meta($post_id, $key, $value, true);
3048 3234 }
3049 3235 }
3050 - return rest_ensure_response($updatePages->data);
3051 3236 }
3052 3237
3053 3238 public function delete_page()
3054 3239 {
@@ -3074,8 +3259,17 @@
3074 3259 }
3075 3260
3076 3261 public function linkgraph_login()
3077 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 +
3078 3272 $post_data = metasync_sanitize_input_array($_POST);
3079 3273 $payload = array(
3080 3274 'username' => wp_unslash(sanitize_email($post_data['username'])),
3081 3275 'password' => wp_unslash(sanitize_text_field($post_data['password']))
@@ -3092,9 +3286,9 @@
3092 3286 ));
3093 3287
3094 3288 # Error handling for timeout or connection failures
3095 3289 if (is_wp_error($response)) {
3096 - 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
3097 3291 wp_send_json_error(array('message' => 'Token API request failed'));
3098 3292 wp_die();
3099 3293 }
3100 3294
@@ -3228,9 +3422,9 @@
3228 3422 $option_name = '_transient_metasync_sa_connect_active_' . $nonce_token;
3229 3423 $timeout_name = '_transient_timeout_metasync_sa_connect_active_' . $nonce_token;
3230 3424
3231 3425 // Check expiry first
3232 - $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
3233 3427 $wpdb->prepare(
3234 3428 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
3235 3429 $timeout_name
3236 3430 )
@@ -3237,14 +3431,14 @@
3237 3431 );
3238 3432
3239 3433 if ($timeout && (int) $timeout < time()) {
3240 3434 // 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));
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
3243 3437 return false;
3244 3438 }
3245 3439
3246 - $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
3247 3441 $wpdb->prepare(
3248 3442 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
3249 3443 $option_name
3250 3444 )
@@ -3268,9 +3462,9 @@
3268 3462 $option_name = '_transient_' . $transient_key;
3269 3463 $timeout_name = '_transient_timeout_' . $transient_key;
3270 3464
3271 3465 // Check expiry first
3272 - $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
3273 3467 $wpdb->prepare(
3274 3468 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
3275 3469 $timeout_name
3276 3470 )
@@ -3276,14 +3470,14 @@
3276 3470 )
3277 3471 );
3278 3472
3279 3473 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));
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
3282 3476 return false;
3283 3477 }
3284 3478
3285 - $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
3286 3480 $wpdb->prepare(
3287 3481 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
3288 3482 $option_name
3289 3483 )
@@ -3310,10 +3504,10 @@
3310 3504 {
3311 3505 delete_transient($transient_name);
3312 3506 if (wp_using_ext_object_cache()) {
3313 3507 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));
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
3316 3510 }
3317 3511 }
3318 3512
3319 3513 /**
@@ -3330,14 +3524,14 @@
3330 3524 {
3331 3525 set_transient($transient_name, $value, $expiration);
3332 3526 if (wp_using_ext_object_cache()) {
3333 3527 global $wpdb;
3334 - $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
3335 3529 'option_name' => '_transient_' . $transient_name,
3336 3530 'option_value' => maybe_serialize($value),
3337 3531 'autoload' => 'no',
3338 3532 ));
3339 - $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
3340 3534 'option_name' => '_transient_timeout_' . $transient_name,
3341 3535 'option_value' => time() + $expiration,
3342 3536 'autoload' => 'no',
3343 3537 ));
@@ -3691,9 +3885,9 @@
3691 3885 *
3692 3886 * @param array $body_params Request body parameters
3693 3887 * @return array|WP_Error Validated parameters array or WP_Error
3694 3888 */
3695 - private function extract_and_validate_searchatlas_params($body_params)
3889 + public function extract_and_validate_searchatlas_params($body_params)
3696 3890 {
3697 3891 $validation_errors = array();
3698 3892
3699 3893 // Extract parameters
@@ -3774,9 +3968,9 @@
3774 3968 } elseif (strlen($whitelabel_domain) > 255) {
3775 3969 $validation_errors['whitelabel_domain'] = 'Whitelabel domain must not exceed 255 characters';
3776 3970 } elseif (!filter_var($whitelabel_domain, FILTER_VALIDATE_URL)) {
3777 3971 $validation_errors['whitelabel_domain'] = 'Whitelabel domain must be a valid URL';
3778 - } 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'])) {
3779 3973 $validation_errors['whitelabel_domain'] = 'Whitelabel domain must use http or https protocol';
3780 3974 }
3781 3975 }
3782 3976
@@ -3789,9 +3983,9 @@
3789 3983 $validation_errors['whitelabel_logo'] = 'Whitelabel logo URL must not exceed 500 characters';
3790 3984 } else {
3791 3985 // If URL is invalid, we'll clear it later but not fail the POST
3792 3986 if (!filter_var($whitelabel_logo, FILTER_VALIDATE_URL) ||
3793 - !in_array(parse_url($whitelabel_logo, PHP_URL_SCHEME), ['http', 'https'])) {
3987 + !in_array(wp_parse_url($whitelabel_logo, PHP_URL_SCHEME), ['http', 'https'])) {
3794 3988 // Don't add to validation_errors - let POST succeed but clear the field
3795 3989 }
3796 3990 }
3797 3991 }
@@ -3882,9 +4076,10 @@
3882 4076 $options['general'] = array();
3883 4077 }
3884 4078 // Only update the API key in settings if status_code is 200 (success)
3885 4079 if ($status_code === 200) {
3886 - $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);
3887 4082 $options['general']['otto_pixel_uuid'] = $new_otto_uuid;
3888 4083 // Note: OTTO SSR is always enabled by default, no need to set
3889 4084
3890 4085 // Granular otto_config_status: record when SSO completed (ISO 8601 UTC)
@@ -3916,11 +4111,20 @@
3916 4111 if (!empty($whitelabel_otto)) {
3917 4112 $options['general']['whitelabel_otto_name'] = $whitelabel_otto;
3918 4113 }
3919 4114 } else {
3920 - // Clear whitelabel fields when not whitelabel
3921 - unset($options['general']['white_label_plugin_name']);
3922 - 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'] = '';
3923 4127 }
3924 4128
3925 4129 // Store whitelabel settings (hidden from UI but accessible to plugin logic)
3926 4130 if (!isset($options['whitelabel'])) {
@@ -3950,11 +4154,12 @@
3950 4154
3951 4155 }
3952 4156
3953 4157 $save_result = Metasync::set_option($options);
4158 + Metasync::invalidate_api_key_cache();
3954 4159
3955 4160 if (!$save_result) {
3956 - 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
3957 4162 } else {
3958 4163 if ($status_code === 200) {
3959 4164 // Option 1: Set heartbeat cache and last-known to CONNECTED so dashboard shows iframe immediately.
3960 4165 // If the immediate heartbeat then fails (e.g. backoff), Option 2 prevents overwriting this to DISCONNECTED.
@@ -3973,9 +4178,9 @@
3973 4178
3974 4179 return true;
3975 4180
3976 4181 } catch (Exception $e) {
3977 - 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
3978 4183 return false;
3979 4184 }
3980 4185 }
3981 4186
@@ -3987,9 +4192,9 @@
3987 4192 {
3988 4193 global $wpdb;
3989 4194
3990 4195 // Clear all JWT token transients
3991 - $deleted = $wpdb->query(
4196 + $deleted = $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
3992 4197 $wpdb->prepare(
3993 4198 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
3994 4199 '_transient_metasync_jwt_token_%'
3995 4200 )
@@ -3995,9 +4200,9 @@
3995 4200 )
3996 4201 );
3997 4202
3998 4203 // Also clear timeout transients
3999 - $wpdb->query(
4204 + $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
4000 4205 $wpdb->prepare(
4001 4206 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
4002 4207 '_transient_timeout_metasync_jwt_token_%'
4003 4208 )
@@ -4020,9 +4225,9 @@
4020 4225 // Also ensure heartbeat cron is scheduled now that we have an API key
4021 4226 do_action('metasync_ensure_heartbeat_cron_scheduled');
4022 4227
4023 4228 } catch (Exception $e) {
4024 - 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
4025 4230 }
4026 4231 }
4027 4232
4028 4233 public function get_item_schema()
@@ -4040,15 +4245,15 @@
4040 4245 'type' => 'object',
4041 4246 // In JSON Schema you can specify object properties in the properties attribute.
4042 4247 'properties' => array(
4043 4248 'id' => array(
4044 - 'description' => esc_html__('Unique identifier for the object.', 'my-textdomain'),
4249 + 'description' => esc_html__('Unique identifier for the object.', 'metasync'),
4045 4250 'type' => 'integer',
4046 4251 'context' => array('view', 'edit', 'embed'),
4047 4252 'readonly' => true,
4048 4253 ),
4049 4254 'content' => array(
4050 - 'description' => esc_html__('The content for the object.', 'my-textdomain'),
4255 + 'description' => esc_html__('The content for the object.', 'metasync'),
4051 4256 'type' => 'string',
4052 4257 ),
4053 4258 ),
4054 4259 );
@@ -4139,11 +4344,11 @@
4139 4344
4140 4345 # Try alternative parameter methods
4141 4346 $body_params = $request->get_body_params();
4142 4347 $key_param = $request->get_param('key');
4143 - $post_key = $_POST['key'] ?? null;
4144 - $request_key = $_REQUEST['key'] ?? null;
4145 - $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
4146 4351
4147 4352 # Try to get key from multiple sources
4148 4353 $key_value = null;
4149 4354
@@ -4207,13 +4412,10 @@
4207 4412 'code' => 'invalid_path'
4208 4413 ), 400);
4209 4414 }
4210 4415
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.
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.
4216 4418 if (!class_exists('Metasync_Bing_Instant_Index')) {
4217 4419 $bing_file = plugin_dir_path(dirname(__FILE__)) . 'bing-index/class-metasync-bing-instant-index.php';
4218 4420 if (file_exists($bing_file)) {
4219 4421 require_once $bing_file;
@@ -4222,19 +4424,57 @@
4222 4424 if (class_exists('Metasync_Bing_Instant_Index')) {
4223 4425 Metasync_Bing_Instant_Index::register_virtual_key($safe_key);
4224 4426 }
4225 4427
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);
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 + }
4232 4467 }
4233 4468
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';
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 +
4237 4477
4238 4478 # Return success response
4239 4479 return new WP_REST_Response(array(
4240 4480 'success' => true,