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 +824 -324 2.6.10 → 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;
@@ -866,9 +987,10 @@
866 987 }
867 988
868 989 # Plugin version and timestamps (ISO 8601 UTC where set)
869 990 $plugin_version = defined('METASYNC_VERSION') ? METASYNC_VERSION : 'unknown';
870 - $last_heartbeat_at = $general_options['last_heartbeat_at'] ?? null;
991 + $_throttle = Metasync::get_heartbeat_throttle();
992 + $last_heartbeat_at = $_throttle['last_heartbeat_at'] ?? ($general_options['last_heartbeat_at'] ?? null);
871 993 $sso_completed_at = $general_options['sso_completed_at'] ?? null;
872 994
873 995 # Prepare response (backward compatible + new fields)
874 996 $response_data = array(
@@ -959,9 +1081,9 @@
959 1081 }
960 1082
961 1083 public function get_errorlogs()
962 1084 {
963 - $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
964 1086 if (!isset($get_data['limit']))
965 1087 return false;
966 1088 $limit = sanitize_text_field($get_data['limit']) ?? null;
967 1089
@@ -1149,9 +1271,9 @@
1149 1271 }
1150 1272 } catch (Throwable $e) {
1151 1273 // Fallback to DOMDocument if Dom\HTMLDocument fails
1152 1274 if (defined('WP_DEBUG_LOG') && WP_DEBUG_LOG) {
1153 - 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
1154 1276 }
1155 1277 $dom = new DOMDocument();
1156 1278 libxml_use_internal_errors(true);
1157 1279 $encoded_content = $this->encode_numeric_entities($content);
@@ -1351,9 +1473,9 @@
1351 1473 $image_id = attachment_url_to_postid($node->getAttribute('src') );
1352 1474 $image_alt = get_post_meta($image_id, '_wp_attachment_image_alt', TRUE);
1353 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]';
1354 1476
1355 - 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
1356 1478
1357 1479 }
1358 1480 }elseif($node->nodeName === 'iframe'){
1359 1481 $result= '[et_pb_code _builder_version="'.ET_BUILDER_VERSION.'" _module_preset="default" global_colors_info="{}"]'.$node->ownerDocument->saveHTML($node).'[/et_pb_code]' ;
@@ -1481,64 +1603,187 @@
1481 1603 return $attachment_id;
1482 1604 }
1483 1605
1484 1606 /**
1485 - * Index a post with Google Indexing API
1486 - *
1487 - * @param int $post_id WordPress post ID
1488 - * @param string $post_type WordPress post type (post, page, etc.)
1489 - * @param string $post_status WordPress post status (publish, draft, etc.)
1607 + * Remove a leading <h1> from post content when it duplicates the post title.
1608 + *
1609 + * Themes that render post_title as an H1 produce a duplicate heading when the
1610 + * synced body also opens with the title. Counterpart of the guard, which
1611 + * only prevents prepending a second H1 but never removes one supplied in the body.
1612 + *
1613 + * @param string $content Post body HTML
1614 + * @param string $title Post title
1615 + * @return string Content without the duplicated leading H1
1490 1616 */
1491 - public function metasync_google_index_post($post_id, $post_type, $post_status)
1617 + private function strip_leading_title_h1($content, $title)
1492 1618 {
1493 - // Only index published posts/pages
1494 - if ($post_status !== 'publish') {
1619 + if (!is_string($content) || trim($content) === '' || trim((string) $title) === '') {
1620 + return $content;
1621 + }
1622 +
1623 + $trimmed = ltrim($content);
1624 + if (!preg_match('/^<h1[^>]*>(.*?)<\/h1>\s*/is', $trimmed, $h1_match)) {
1625 + return $content;
1626 + }
1627 +
1628 + # Entity-decode both sides so "Mobility &amp; Recovery" matches "Mobility & Recovery"
1629 + $h1_text = trim(html_entity_decode(wp_strip_all_tags($h1_match[1]), ENT_QUOTES | ENT_HTML5));
1630 + $title_text = trim(html_entity_decode((string) $title, ENT_QUOTES | ENT_HTML5));
1631 + if ($h1_text === '' || strcasecmp($h1_text, $title_text) !== 0) {
1632 + return $content;
1633 + }
1634 +
1635 + return substr($trimmed, strlen($h1_match[0]));
1636 + }
1637 +
1638 + /**
1639 + * Decide whether the synced body should carry a prepended <h1> title.
1640 + *
1641 + * Replaces the unreliable 7-day hidden-post probe (title_in_headings) as the
1642 + * source of truth for the prepend decision. We only prepend when we have a
1643 + * POSITIVE, render-verified verdict that the active theme does NOT render the
1644 + * post title itself. When the verdict is unknown we default to NOT prepending,
1645 + * because a duplicate H1 (theme title + body title) is a worse, more visible
1646 + * defect than a one-time missing body title on the rare no-title theme. The
1647 + * verdict is learned per post-type by record_theme_title_verdict() after the
1648 + * first sync renders the real post.
1649 + *
1650 + * @param string $post_type Post type being synced
1651 + * @return bool True only when the theme is known to omit the title
1652 + */
1653 + private function should_prepend_title($post_type)
1654 + {
1655 + $general = Metasync::get_option('general');
1656 + if (isset($general['theme_renders_title'][$post_type])) {
1657 + # Verdict known: prepend only when the theme does NOT render the title.
1658 + return $general['theme_renders_title'][$post_type] === false;
1659 + }
1660 + # Unknown verdict: do not prepend (avoid duplicate H1).
1661 + return false;
1662 + }
1663 +
1664 + /**
1665 + * After a post is saved, render its live URL once and record whether
1666 + * the active theme outputs the post title in a heading. The verdict is cached
1667 + * per post-type so subsequent syncs skip the round-trip. Best-effort: any
1668 + * inconclusive fetch (non-200, cache/challenge page, wrong post) records
1669 + * nothing and is retried on the next sync.
1670 + *
1671 + * @param int $post_id Saved post ID
1672 + * @param string $permalink Public permalink of the saved post
1673 + * @param string $post_title Post title to look for
1674 + * @param string $post_type Post type (verdict cache key)
1675 + */
1676 + private function record_theme_title_verdict($post_id, $permalink, $post_title, $post_type)
1677 + {
1678 + if (empty($permalink) || trim((string) $post_title) === '' || empty($post_type)) {
1495 1679 return;
1496 1680 }
1497 1681
1498 - // Only index posts and pages (can be extended as needed)
1499 - $allowed_post_types = array('post', 'page');
1500 - if (!in_array($post_type, $allowed_post_types)) {
1682 + $general = Metasync::get_option('general');
1683 + # Already learned for this post type — nothing to do.
1684 + if (isset($general['theme_renders_title'][$post_type])) {
1501 1685 return;
1502 1686 }
1503 1687
1504 - try {
1505 - // Load Google Index functionality if not already loaded
1506 - if (!function_exists('google_index_post')) {
1507 - $google_index_path = plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php';
1508 - if (file_exists($google_index_path)) {
1509 - require_once $google_index_path;
1510 - } else {
1511 - error_log('MetaSync Google Index: google-index-init.php not found at ' . $google_index_path);
1512 - return;
1513 - }
1514 - }
1688 + $verdict = $this->detect_theme_renders_title($permalink, (int) $post_id, $post_title);
1689 + if ($verdict === null) {
1690 + # Inconclusive fetch: do not poison the cache, retry next sync.
1691 + return;
1692 + }
1515 1693
1516 - // Attempt to index the post with Google
1517 - if (function_exists('google_index_post')) {
1518 - $result = google_index_post($post_id, $post_type, 'update');
1519 -
1520 - if (isset($result['success']) && $result['success']) {
1521 - error_log(sprintf(
1522 - 'MetaSync Google Index: Successfully indexed %s (ID: %d, Type: %s)',
1523 - get_the_title($post_id),
1524 - $post_id,
1525 - $post_type
1526 - ));
1527 - } else {
1528 - error_log(sprintf(
1529 - 'MetaSync Google Index: Failed to index %s (ID: %d, Type: %s) - %s',
1530 - get_the_title($post_id),
1531 - $post_id,
1532 - $post_type,
1533 - isset($result['error']['message']) ? $result['error']['message'] : 'Unknown error'
1534 - ));
1535 - }
1694 + # Re-read to avoid clobbering concurrent option writes, then persist.
1695 + $options = Metasync::get_option();
1696 + if (!isset($options['general']) || !is_array($options['general'])) {
1697 + $options['general'] = [];
1698 + }
1699 + if (!isset($options['general']['theme_renders_title']) || !is_array($options['general']['theme_renders_title'])) {
1700 + $options['general']['theme_renders_title'] = [];
1701 + }
1702 + $options['general']['theme_renders_title'][$post_type] = $verdict;
1703 + Metasync::set_option($options);
1704 + }
1705 +
1706 + /**
1707 + * Fetch the rendered post and detect whether the theme outputs the
1708 + * title in a heading.
1709 + *
1710 + * @param string $permalink Public permalink
1711 + * @param int $post_id Saved post ID (used to confirm we fetched the right page)
1712 + * @param string $post_title Title to look for
1713 + * @return bool|null True = theme renders the title; false = it does not;
1714 + * null = inconclusive (do not cache).
1715 + */
1716 + private function detect_theme_renders_title($permalink, $post_id, $post_title)
1717 + {
1718 + # Cache-bust so a full-page cache (LiteSpeed, etc.) returns a fresh render
1719 + # rather than a stale 404/challenge — the original cause of the bad verdict.
1720 + $url = add_query_arg('metasync_nocache', (string) time(), $permalink);
1721 +
1722 + $response = wp_remote_get($url, array(
1723 + 'timeout' => 10,
1724 + 'redirection' => 3,
1725 + 'sslverify' => false,
1726 + 'headers' => array(
1727 + 'Cache-Control' => 'no-cache',
1728 + 'Pragma' => 'no-cache',
1729 + ),
1730 + ));
1731 +
1732 + if (is_wp_error($response)) {
1733 + return null;
1734 + }
1735 +
1736 + $code = (int) wp_remote_retrieve_response_code($response);
1737 + $html = wp_remote_retrieve_body($response);
1738 + if ($code !== 200 || empty($html)) {
1739 + return null;
1740 + }
1741 +
1742 + # Confirm we actually rendered THIS post (WordPress emits these on body/article).
1743 + # Without this a cached 404 or bot challenge would falsely record "omits".
1744 + $is_our_post = (stripos($html, 'postid-' . $post_id) !== false)
1745 + || (stripos($html, 'page-id-' . $post_id) !== false)
1746 + || (stripos($html, 'id="post-' . $post_id . '"') !== false);
1747 + if (!$is_our_post) {
1748 + return null;
1749 + }
1750 +
1751 + return $this->title_present_in_headings($html, $post_title);
1752 + }
1753 +
1754 + /**
1755 + * Return true if $title appears within any heading (h1–h6) of $html.
1756 + *
1757 + * @param string $html Rendered page HTML
1758 + * @param string $title Title text to match
1759 + * @return bool
1760 + */
1761 + private function title_present_in_headings($html, $title)
1762 + {
1763 + $title = trim(html_entity_decode((string) $title, ENT_QUOTES | ENT_HTML5));
1764 + if ($title === '' || !class_exists('DOMDocument')) {
1765 + return false;
1766 + }
1767 +
1768 + $dom = new DOMDocument();
1769 + libxml_use_internal_errors(true);
1770 + $dom->loadHTML($html);
1771 + libxml_clear_errors();
1772 +
1773 + $xpath = new DOMXPath($dom);
1774 + $headings = $xpath->query('//h1 | //h2 | //h3 | //h4 | //h5 | //h6');
1775 + if ($headings === false) {
1776 + return false;
1777 + }
1778 +
1779 + foreach ($headings as $heading) {
1780 + $heading_text = trim($heading->textContent);
1781 + if ($heading_text !== '' && stripos($heading_text, $title) !== false) {
1782 + return true;
1536 1783 }
1537 - } catch (Exception $e) {
1538 - // Log any exceptions but don't break the main functionality
1539 - error_log('MetaSync Google Index Exception: ' . $e->getMessage());
1540 1784 }
1785 + return false;
1541 1786 }
1542 1787
1543 1788 public function create_item($request)
1544 1789 {
@@ -1573,8 +1818,9 @@
1573 1818 check if the create_item is called by set_landing_page function or not
1574 1819 by doing this we will prevent html from going into builder page option
1575 1820 */
1576 1821 $isOttoAiPage = !empty($item['otto_ai_page']) && filter_var($item['otto_ai_page'], FILTER_VALIDATE_BOOLEAN);
1822 + $content = [];
1577 1823 if(!isset($item['is_landing_page']) && !$isOttoAiPage && empty($item['style_data']) ){
1578 1824
1579 1825 # Get Current Post type
1580 1826 $current_post_type = isset($item['post_type']) ? sanitize_text_field($item['post_type']) : 'post';
@@ -1592,37 +1838,31 @@
1592 1838 } elseif (!empty($theme_template) && stripos($theme_template, 'flatsome') !== false) {
1593 1839 $is_flatsome_theme = true;
1594 1840 }
1595 1841
1842 + # Always strip a leading H1 that duplicates the title. The
1843 + # theme renders post_title in its header on virtually every theme, so a
1844 + # title H1 at the top of the body is a duplicate. Whether we then put one
1845 + # back is decided by should_prepend_title() below.
1846 + if (isset($item['post_title'])) {
1847 + $item['post_content'] = $this->strip_leading_title_h1($item['post_content'], $item['post_title']);
1848 + }
1849 +
1596 1850 # Skip title/image prepending for Flatsome (it displays them by default)
1597 1851 if (!$is_flatsome_theme) {
1598 - # Get the setting for the post template
1852 + # Get the setting for the post template (feature-image detection)
1599 1853 $title_and_feature_image = $this->append_content_if_missing_elements($current_post_type);
1600 1854
1601 - # Check if the post title is there in the template or not
1855 + # Prepend the feature image when the template does not already show it
1602 1856 if(!$title_and_feature_image['image_in_content'] && !empty($item['hero_image_url'])){
1603 -
1604 - # Prepend the feature image
1605 - $item['post_content'] = '<img src="'.$item['hero_image_url'].'" />'.$item['post_content'] ;
1857 + $item['post_content'] = '<img src="' . esc_url_raw($item['hero_image_url']) . '" />'.$item['post_content'] ;
1606 1858 }
1607 1859
1608 - # Check if the post title is there in the template or not
1609 - if(!$title_and_feature_image['title_in_headings']){
1610 -
1611 - # WP-337: Only prepend H1 if content doesn't already start with an H1 containing the same text
1612 - $skip_prepend = false;
1613 - $content_trimmed = trim($item['post_content']);
1614 - if (preg_match('/^<h1[^>]*>(.*?)<\/h1>/is', $content_trimmed, $h1_match)) {
1615 - $existing_h1_text = trim(strip_tags($h1_match[1]));
1616 - if (strcasecmp($existing_h1_text, trim($item['post_title'])) === 0) {
1617 - $skip_prepend = true;
1618 - }
1619 - }
1620 - if (!$skip_prepend) {
1621 - $item['post_content'] = '<h1>'.$item['post_title'].'</h1>'.$item['post_content'] ;
1622 - }
1860 + # Only prepend the title H1 when the theme is render-verified to
1861 + # omit it. Unknown verdict defaults to NOT prepending (see should_prepend_title).
1862 + if(isset($item['post_title']) && $this->should_prepend_title($current_post_type)){
1863 + $item['post_content'] = '<h1>'.$item['post_title'].'</h1>'.$item['post_content'] ;
1623 1864 }
1624 -
1625 1865 }
1626 1866 # This will be used by create_page function
1627 1867 $content = $this->metasync_upload_post_content($item,false,false);
1628 1868 }elseif(isset($item['is_landing_page']) && $item['is_landing_page'] == true){
@@ -1656,13 +1896,13 @@
1656 1896 $new_post['post_name'] = sanitize_text_field($item['permalink']);
1657 1897 }
1658 1898
1659 1899 if (isset($item['post_date']) && !empty($item['post_date'])) {
1660 - $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'];
1661 1901 if (!$is_valid_date) {
1662 1902 return new WP_Error(
1663 1903 'rest_post_invalid_date',
1664 - esc_html__('Post date is not valid'),
1904 + esc_html__('Post date is not valid', 'metasync'),
1665 1905 array('status' => 400)
1666 1906 );
1667 1907 }
1668 1908
@@ -1779,13 +2019,17 @@
1779 2019 $post_meta['searchatlas_embed_bottom'] = $item['searchatlas_embed_bottom'];
1780 2020 }
1781 2021
1782 2022 // Add custom fields to posts and pages
1783 - foreach ($post_meta as $key => $value) {
1784 - // if (!empty($value) && !is_null($value)) {
1785 - add_post_meta($post_id, $key, $value, true);
1786 - // }
1787 - }
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 + );
1788 2032
1789 2033
1790 2034 $attachment_id = '';
1791 2035 if (isset($item['hero_image_url']) && !empty($item['hero_image_url'])) {
@@ -1803,8 +2047,14 @@
1803 2047 $redirection['url'] = sanitize_url($item['redirection_url']);
1804 2048 }
1805 2049 if (!empty($redirection)) {
1806 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 + }
1807 2057 }
1808 2058
1809 2059 $post_cattegories = [];
1810 2060 # if ($new_post['post_type'] === 'post' && is_array(@$item['post_categories'])) {
@@ -1864,13 +2114,22 @@
1864 2114 # Track Content Genius event in GA4
1865 2115 try {
1866 2116 Metasync_GA4::get_instance()->track_content_genius_event($post_id, strtolower($action));
1867 2117 } catch (Exception $e) {
1868 - 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
1869 2119 }
2120 + }
1870 2121
1871 - // Google Indexing Integration
1872 - $this->metasync_google_index_post($post_id, $new_post['post_type'], $new_post['post_status']);
2122 + # For standard synced posts, learn (once per post-type) whether the
2123 + # theme renders the title, so future syncs prepend only when genuinely needed.
2124 + if (!is_wp_error($post_id) && $post_id > 0
2125 + && !isset($item['is_landing_page']) && empty($isOttoAiPage) && empty($item['style_data'])
2126 + && $new_post['post_status'] === 'publish') {
2127 + try {
2128 + $this->record_theme_title_verdict($post_id, $permalink, $new_post['post_title'], $new_post['post_type']);
2129 + } catch (Exception $e) {
2130 + error_log('MetaSync title verdict failed: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
2131 + }
1873 2132 }
1874 2133
1875 2134 $respCreatePosts[$index] = array_merge($new_post, $post_meta);
1876 2135 ksort($respCreatePosts[$index]);
@@ -1914,9 +2173,9 @@
1914 2173 }
1915 2174
1916 2175 public function delete_item()
1917 2176 {
1918 - $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
1919 2178 if (!isset($get_data['ID']))
1920 2179 return false;
1921 2180
1922 2181 $post_id = sanitize_text_field($get_data['ID']) ?? null;
@@ -1922,18 +2181,18 @@
1922 2181 $post_id = sanitize_text_field($get_data['ID']) ?? null;
1923 2182 $post = get_post($post_id);
1924 2183 if ($post) {
1925 2184 wp_delete_post($post_id);
2185 + // HTTP 204 requires no body for response, so no message is set.
1926 2186 return new WP_Error(
1927 2187 'rest_post_delete_success',
1928 - esc_html__(''),
1929 - // HTTP 204 requires no body for response
2188 + '',
1930 2189 array('status' => 204)
1931 2190 );
1932 2191 }
1933 2192 return new WP_Error(
1934 2193 'rest_post_delete_fail',
1935 - esc_html__('No post found in the database with requested ID.'),
2194 + esc_html__('No post found in the database with requested ID.', 'metasync'),
1936 2195 array('status' => 400)
1937 2196 );
1938 2197 }
1939 2198
@@ -1938,9 +2197,9 @@
1938 2197 }
1939 2198
1940 2199 public function get_items($request)
1941 2200 {
1942 - $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
1943 2202
1944 2203 # let us check if request send post id and is valid int
1945 2204 if(isset($get_data['post_id']) AND intval($get_data['post_id']) > 0){
1946 2205
@@ -2280,9 +2539,9 @@
2280 2539 $post_data = get_post($post_id);
2281 2540 if (!$post_data) {
2282 2541 return new WP_Error(
2283 2542 'rest_post_update_fail',
2284 - esc_html__('No post found in the database with requested ID.'),
2543 + esc_html__('No post found in the database with requested ID.', 'metasync'),
2285 2544 array('status' => 400)
2286 2545 );
2287 2546 }
2288 2547
@@ -2307,12 +2566,19 @@
2307 2566 $update_params['meta_robots'] = $post['meta_robots'] ?
2308 2567 sanitize_text_field($post['meta_robots']) : $get_robots_meta;
2309 2568 }
2310 2569 if (isset($post['meta_canonical']) && !empty($post['meta_canonical'])) {
2311 - $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 + }
2312 2576 }
2313 2577
2314 2578 $isOttoAiPage = !empty($post['otto_ai_page']) && filter_var($post['otto_ai_page'], FILTER_VALIDATE_BOOLEAN);
2579 + $content = [];
2580 + $permalink = '';
2315 2581 if (isset($post['post_content']) && !empty($post['post_content']) && !$isOttoAiPage) {
2316 2582
2317 2583 # Above we are updating the post_type so we have to get latest value that has been change on the server
2318 2584 $post_fresh_data = get_post($post['post_id']);
@@ -2331,35 +2597,29 @@
2331 2597 } elseif (!empty($theme_template) && stripos($theme_template, 'flatsome') !== false) {
2332 2598 $is_flatsome_theme = true;
2333 2599 }
2334 2600
2601 + # Title to compare against — payload may omit post_title on update
2602 + $compare_title = isset($post['post_title']) && !empty($post['post_title']) ? $post['post_title'] : $post_fresh_data->post_title;
2603 +
2604 + # Always strip a leading H1 that duplicates the title; the theme
2605 + # renders post_title in its header, so a body title H1 is a duplicate.
2606 + $post['post_content'] = $this->strip_leading_title_h1($post['post_content'], $compare_title);
2607 +
2335 2608 # Skip title/image prepending for Flatsome (it displays them by default)
2336 2609 if (!$is_flatsome_theme) {
2337 - # Get the setting for the post template
2610 + # Get the setting for the post template (feature-image detection)
2338 2611 $title_and_feature_image = $this->append_content_if_missing_elements($post_fresh_data->post_type);
2339 2612
2340 - # Check if the post title is there in the template or not
2613 + # Prepend the feature image when the template does not already show it
2341 2614 if(!$title_and_feature_image['image_in_content'] && !empty($post['hero_image_url'])){
2342 -
2343 - # Prepend the feature image
2344 - $post['post_content'] = '<img src="'.$post['hero_image_url'].'" />'.$post['post_content'] ;
2615 + $post['post_content'] = '<img src="' . esc_url_raw($post['hero_image_url']) . '" />'.$post['post_content'] ;
2345 2616 }
2346 2617
2347 - # Check if the post title is there in the template or not
2348 - if(!$title_and_feature_image['title_in_headings']){
2349 -
2350 - # WP-337: Only prepend H1 if content doesn't already start with an H1 containing the same text
2351 - $skip_prepend = false;
2352 - $content_trimmed = trim($post['post_content']);
2353 - if (preg_match('/^<h1[^>]*>(.*?)<\/h1>/is', $content_trimmed, $h1_match)) {
2354 - $existing_h1_text = trim(strip_tags($h1_match[1]));
2355 - if (strcasecmp($existing_h1_text, trim($post['post_title'])) === 0) {
2356 - $skip_prepend = true;
2357 - }
2358 - }
2359 - if (!$skip_prepend) {
2360 - $post['post_content'] = '<h1>'.$post['post_title'].'</h1>'.$post['post_content'] ;
2361 - }
2618 + # Only prepend the title H1 when the theme is render-verified to
2619 + # omit it. Unknown verdict defaults to NOT prepending (see should_prepend_title).
2620 + if($this->should_prepend_title($post_fresh_data->post_type)){
2621 + $post['post_content'] = '<h1>'.$compare_title.'</h1>'.$post['post_content'] ;
2362 2622 }
2363 2623 }
2364 2624 // This will be used by update_page function
2365 2625 $content = $this->metasync_upload_post_content($post,false,false);
@@ -2419,40 +2679,8 @@
2419 2679 );
2420 2680 }
2421 2681
2422 2682
2423 - if (isset($post['post_date']) && !empty($post['post_date']) && false) {
2424 - $is_valid_date = date('Y-m-d', strtotime($post['post_date'])) == $post['post_date'];
2425 - if (!$is_valid_date) {
2426 - return new WP_Error(
2427 - 'rest_post_invalid_date',
2428 - esc_html__('Post date is not valid'),
2429 - array('status' => 400)
2430 - );
2431 - }
2432 -
2433 - $date_limit_str = strtotime(date('Y-m-d') . '-2 month');
2434 - $post_date_str = strtotime($post['post_date']);
2435 -
2436 - if ($date_limit_str >= $post_date_str) {
2437 - $newDate = date('Y-m-d', strtotime('-2 month'));
2438 - return new WP_Error(
2439 - 'rest_post_greater_date',
2440 - esc_html__("Post date should be greater then " . $newDate),
2441 - array('status' => 400)
2442 - );
2443 - }
2444 -
2445 - if ($post_date_str > strtotime(date('Y-m-d'))) {
2446 - return new WP_Error(
2447 - 'rest_post_greater_date',
2448 - esc_html__('Post date should be less then Today'),
2449 - array('status' => 400)
2450 - );
2451 - }
2452 - $update_params['post_date'] = sanitize_text_field($post['post_date'] . date(' h:i:s'));
2453 - }
2454 -
2455 2683 $post_cattegories = [];
2456 2684 # if ($post_data && $post_data->post_type === 'post' && is_array(@$post['post_categories'])) {
2457 2685
2458 2686 # fixed Undefined array key issue
@@ -2476,11 +2704,9 @@
2476 2704 }
2477 2705
2478 2706 $resp_update = $this->update_object($post_id, $update_params);
2479 2707 if(isset($content['elementor_meta_data'])){
2480 - foreach ($content['elementor_meta_data'] as $key => $value) {
2481 - update_post_meta($post_id, $key, $value);
2482 - }
2708 + $this->metasync_apply_builder_meta($post_id, $content['elementor_meta_data']);
2483 2709 if ( did_action( 'elementor/loaded' ) ) {
2484 2710 // Clear Elementor cache for the specified post ID
2485 2711 \Elementor\Plugin::instance()->files_manager->clear_cache();
2486 2712 }
@@ -2495,8 +2721,15 @@
2495 2721 delete_post_meta($post_id, '_elementor_page_assets');
2496 2722 delete_post_meta($post_id, '_elementor_page_settings');
2497 2723 }
2498 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 +
2499 2732 $redirection = array();
2500 2733 if (!empty($post['redirection_enable']) && !is_null($post['redirection_enable'])) {
2501 2734 $redirection['enable'] = sanitize_text_field($post['redirection_enable']);
2502 2735 }
@@ -2507,8 +2740,14 @@
2507 2740 $redirection['url'] = sanitize_url($post['redirection_url']);
2508 2741 }
2509 2742 if (!empty($redirection)) {
2510 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 + }
2511 2750 }
2512 2751
2513 2752
2514 2753 $post_revisions = wp_get_post_revisions($post_id);
@@ -2515,9 +2754,8 @@
2515 2754 // Sync post categories to customer dashboard
2516 2755 $this->lgSendCustomerPostParams();
2517 2756
2518 2757 unset($update_params['post_name']);
2519 - unset($update_params['post_category']);
2520 2758
2521 2759 $update_params['post_categories'] = $post_cattegories;
2522 2760 $update_params['post_tags'] = $post_tags;
2523 2761 $update_params['post_id'] = (int) $post_id;
@@ -2547,14 +2785,9 @@
2547 2785
2548 2786 }
2549 2787 # update the content
2550 2788 // add and update the post meta
2551 - foreach ($post_meta_data as $key => $value) {
2552 - // if (!empty($value) && !is_null($value)) {
2553 -
2554 - update_post_meta($post_id, $key, $value);
2555 - //
2556 - }
2789 + $this->metasync_apply_builder_meta($post_id, $post_meta_data);
2557 2790 #check if the elementor plugin is active
2558 2791 if ( did_action( 'elementor/loaded' ) ) {
2559 2792 # Clear Elementor cache for the specified post ID
2560 2793 \Elementor\Plugin::instance()->files_manager->clear_cache();
@@ -2591,10 +2824,21 @@
2591 2824 # Track Content Genius event in GA4
2592 2825 try {
2593 2826 Metasync_GA4::get_instance()->track_content_genius_event($post_id, 'updated');
2594 2827 } catch (Exception $e) {
2595 - 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
2596 2829 }
2830 +
2831 + # Learn (once per post-type) whether the theme renders the title,
2832 + # so future syncs prepend only when the theme genuinely omits it.
2833 + if ($post_id > 0 && empty($post['is_landing_page']) && empty($isOttoAiPage)
2834 + && $sync_status === 'published') {
2835 + try {
2836 + $this->record_theme_title_verdict($post_id, ($permalink ?? get_permalink($post_id)), $post_title, $post_type);
2837 + } catch (Exception $e) {
2838 + error_log('MetaSync title verdict failed: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
2839 + }
2840 + }
2597 2841 }
2598 2842
2599 2843 ksort($update_params);
2600 2844 $data[] = $update_params;
@@ -2689,20 +2933,9 @@
2689 2933 if($isOttoAiPage){
2690 2934 // Change the page template from default to Metasync Template
2691 2935 $pageTemplate = Metasync_Template::TEMPLATE_NAME;
2692 2936 }
2693 - if ($pageTemplate !== 'default') {
2694 - update_post_meta($post_id, '_wp_page_template', $pageTemplate);
2695 - } else {
2696 - // Clear stale templates that conflict with the active page builder.
2697 - // e.g. metasync-blank from a previous OTTO sync, or elementor_canvas
2698 - // written by the HTML-to-builder converter on an Oxygen site.
2699 - $current = get_post_meta($post_id, '_wp_page_template', true);
2700 - $stale_templates = array(Metasync_Template::TEMPLATE_NAME, 'elementor_canvas', 'elementor_header_footer');
2701 - if (in_array($current, $stale_templates, true)) {
2702 - delete_post_meta($post_id, '_wp_page_template');
2703 - }
2704 - }
2937 + $this->metasync_write_page_template($post_id, $pageTemplate);
2705 2938 }
2706 2939
2707 2940 return rest_ensure_response($createPages);
2708 2941 }
@@ -2725,9 +2958,9 @@
2725 2958 $post_data = get_post($payload['post_id']);
2726 2959 if(!isset($post_data->post_type)){
2727 2960 return new WP_Error(
2728 2961 'rest_page_type_fail',
2729 - esc_html__('No page found in the database with requested ID.'),
2962 + esc_html__('No page found in the database with requested ID.', 'metasync'),
2730 2963 array('status' => 400)
2731 2964 );
2732 2965 }
2733 2966 if ($post_data->post_type !== 'page') {
@@ -2782,20 +3015,225 @@
2782 3015 if($isOttoAiPage){
2783 3016 // Change the page template from default to Metasync Template
2784 3017 $pageTemplate = Metasync_Template::TEMPLATE_NAME;
2785 3018 }
2786 - if ($pageTemplate !== 'default') {
2787 - 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);
2788 3232 } else {
2789 - // Clear stale templates that conflict with the active page builder.
2790 - $current = get_post_meta($post_id, '_wp_page_template', true);
2791 - $stale_templates = array(Metasync_Template::TEMPLATE_NAME, 'elementor_canvas', 'elementor_header_footer');
2792 - if (in_array($current, $stale_templates, true)) {
2793 - delete_post_meta($post_id, '_wp_page_template');
2794 - }
3233 + add_post_meta($post_id, $key, $value, true);
2795 3234 }
2796 3235 }
2797 - return rest_ensure_response($updatePages->data);
2798 3236 }
2799 3237
2800 3238 public function delete_page()
2801 3239 {
@@ -2810,9 +3248,9 @@
2810 3248 {
2811 3249 $sync_request = new Metasync_Sync_Requests();
2812 3250 $response = $sync_request->SyncCustomerParams();
2813 3251
2814 - $responseCode = wp_remote_retrieve_response_code($response);
3252 + $responseCode = Metasync_Sync_Requests::get_response_code($response);
2815 3253 if ($responseCode == 200) {
2816 3254 # Use current_time('mysql') for consistency with cron heartbeat.
2817 3255 $send_auth_token_timestamp = Metasync::get_option();
2818 3256 $send_auth_token_timestamp['general']['send_auth_token_timestamp'] = current_time('mysql');
@@ -2821,8 +3259,17 @@
2821 3259 }
2822 3260
2823 3261 public function linkgraph_login()
2824 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 +
2825 3272 $post_data = metasync_sanitize_input_array($_POST);
2826 3273 $payload = array(
2827 3274 'username' => wp_unslash(sanitize_email($post_data['username'])),
2828 3275 'password' => wp_unslash(sanitize_text_field($post_data['password']))
@@ -2839,9 +3286,9 @@
2839 3286 ));
2840 3287
2841 3288 # Error handling for timeout or connection failures
2842 3289 if (is_wp_error($response)) {
2843 - 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
2844 3291 wp_send_json_error(array('message' => 'Token API request failed'));
2845 3292 wp_die();
2846 3293 }
2847 3294
@@ -2856,9 +3303,9 @@
2856 3303 {
2857 3304 $sync_heartbeat_data = new Metasync_Sync_Requests();
2858 3305 $response = $sync_heartbeat_data->SyncCustomerParams();
2859 3306
2860 - $responseCode = wp_remote_retrieve_response_code($response);
3307 + $responseCode = Metasync_Sync_Requests::get_response_code($response);
2861 3308 if ($responseCode == 200) {
2862 3309 return rest_ensure_response($response);
2863 3310 }
2864 3311 return rest_ensure_response($response);
@@ -2975,9 +3422,9 @@
2975 3422 $option_name = '_transient_metasync_sa_connect_active_' . $nonce_token;
2976 3423 $timeout_name = '_transient_timeout_metasync_sa_connect_active_' . $nonce_token;
2977 3424
2978 3425 // Check expiry first
2979 - $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
2980 3427 $wpdb->prepare(
2981 3428 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
2982 3429 $timeout_name
2983 3430 )
@@ -2984,14 +3431,14 @@
2984 3431 );
2985 3432
2986 3433 if ($timeout && (int) $timeout < time()) {
2987 3434 // Expired — clean up orphan rows
2988 - $wpdb->delete($wpdb->options, array('option_name' => $option_name));
2989 - $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
2990 3437 return false;
2991 3438 }
2992 3439
2993 - $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
2994 3441 $wpdb->prepare(
2995 3442 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
2996 3443 $option_name
2997 3444 )
@@ -3015,9 +3462,9 @@
3015 3462 $option_name = '_transient_' . $transient_key;
3016 3463 $timeout_name = '_transient_timeout_' . $transient_key;
3017 3464
3018 3465 // Check expiry first
3019 - $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
3020 3467 $wpdb->prepare(
3021 3468 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
3022 3469 $timeout_name
3023 3470 )
@@ -3023,14 +3470,14 @@
3023 3470 )
3024 3471 );
3025 3472
3026 3473 if ($timeout && (int) $timeout < time()) {
3027 - $wpdb->delete($wpdb->options, array('option_name' => $option_name));
3028 - $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
3029 3476 return false;
3030 3477 }
3031 3478
3032 - $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
3033 3480 $wpdb->prepare(
3034 3481 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
3035 3482 $option_name
3036 3483 )
@@ -3057,10 +3504,10 @@
3057 3504 {
3058 3505 delete_transient($transient_name);
3059 3506 if (wp_using_ext_object_cache()) {
3060 3507 global $wpdb;
3061 - $wpdb->delete($wpdb->options, array('option_name' => '_transient_' . $transient_name));
3062 - $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
3063 3510 }
3064 3511 }
3065 3512
3066 3513 /**
@@ -3077,14 +3524,14 @@
3077 3524 {
3078 3525 set_transient($transient_name, $value, $expiration);
3079 3526 if (wp_using_ext_object_cache()) {
3080 3527 global $wpdb;
3081 - $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
3082 3529 'option_name' => '_transient_' . $transient_name,
3083 3530 'option_value' => maybe_serialize($value),
3084 3531 'autoload' => 'no',
3085 3532 ));
3086 - $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
3087 3534 'option_name' => '_transient_timeout_' . $transient_name,
3088 3535 'option_value' => time() + $expiration,
3089 3536 'autoload' => 'no',
3090 3537 ));
@@ -3438,9 +3885,9 @@
3438 3885 *
3439 3886 * @param array $body_params Request body parameters
3440 3887 * @return array|WP_Error Validated parameters array or WP_Error
3441 3888 */
3442 - private function extract_and_validate_searchatlas_params($body_params)
3889 + public function extract_and_validate_searchatlas_params($body_params)
3443 3890 {
3444 3891 $validation_errors = array();
3445 3892
3446 3893 // Extract parameters
@@ -3521,9 +3968,9 @@
3521 3968 } elseif (strlen($whitelabel_domain) > 255) {
3522 3969 $validation_errors['whitelabel_domain'] = 'Whitelabel domain must not exceed 255 characters';
3523 3970 } elseif (!filter_var($whitelabel_domain, FILTER_VALIDATE_URL)) {
3524 3971 $validation_errors['whitelabel_domain'] = 'Whitelabel domain must be a valid URL';
3525 - } 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'])) {
3526 3973 $validation_errors['whitelabel_domain'] = 'Whitelabel domain must use http or https protocol';
3527 3974 }
3528 3975 }
3529 3976
@@ -3536,9 +3983,9 @@
3536 3983 $validation_errors['whitelabel_logo'] = 'Whitelabel logo URL must not exceed 500 characters';
3537 3984 } else {
3538 3985 // If URL is invalid, we'll clear it later but not fail the POST
3539 3986 if (!filter_var($whitelabel_logo, FILTER_VALIDATE_URL) ||
3540 - !in_array(parse_url($whitelabel_logo, PHP_URL_SCHEME), ['http', 'https'])) {
3987 + !in_array(wp_parse_url($whitelabel_logo, PHP_URL_SCHEME), ['http', 'https'])) {
3541 3988 // Don't add to validation_errors - let POST succeed but clear the field
3542 3989 }
3543 3990 }
3544 3991 }
@@ -3629,9 +4076,10 @@
3629 4076 $options['general'] = array();
3630 4077 }
3631 4078 // Only update the API key in settings if status_code is 200 (success)
3632 4079 if ($status_code === 200) {
3633 - $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);
3634 4082 $options['general']['otto_pixel_uuid'] = $new_otto_uuid;
3635 4083 // Note: OTTO SSR is always enabled by default, no need to set
3636 4084
3637 4085 // Granular otto_config_status: record when SSO completed (ISO 8601 UTC)
@@ -3663,11 +4111,20 @@
3663 4111 if (!empty($whitelabel_otto)) {
3664 4112 $options['general']['whitelabel_otto_name'] = $whitelabel_otto;
3665 4113 }
3666 4114 } else {
3667 - // Clear whitelabel fields when not whitelabel
3668 - unset($options['general']['white_label_plugin_name']);
3669 - 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'] = '';
3670 4127 }
3671 4128
3672 4129 // Store whitelabel settings (hidden from UI but accessible to plugin logic)
3673 4130 if (!isset($options['whitelabel'])) {
@@ -3697,11 +4154,12 @@
3697 4154
3698 4155 }
3699 4156
3700 4157 $save_result = Metasync::set_option($options);
4158 + Metasync::invalidate_api_key_cache();
3701 4159
3702 4160 if (!$save_result) {
3703 - 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
3704 4162 } else {
3705 4163 if ($status_code === 200) {
3706 4164 // Option 1: Set heartbeat cache and last-known to CONNECTED so dashboard shows iframe immediately.
3707 4165 // If the immediate heartbeat then fails (e.g. backoff), Option 2 prevents overwriting this to DISCONNECTED.
@@ -3720,9 +4178,9 @@
3720 4178
3721 4179 return true;
3722 4180
3723 4181 } catch (Exception $e) {
3724 - 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
3725 4183 return false;
3726 4184 }
3727 4185 }
3728 4186
@@ -3734,9 +4192,9 @@
3734 4192 {
3735 4193 global $wpdb;
3736 4194
3737 4195 // Clear all JWT token transients
3738 - $deleted = $wpdb->query(
4196 + $deleted = $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
3739 4197 $wpdb->prepare(
3740 4198 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
3741 4199 '_transient_metasync_jwt_token_%'
3742 4200 )
@@ -3742,9 +4200,9 @@
3742 4200 )
3743 4201 );
3744 4202
3745 4203 // Also clear timeout transients
3746 - $wpdb->query(
4204 + $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
3747 4205 $wpdb->prepare(
3748 4206 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
3749 4207 '_transient_timeout_metasync_jwt_token_%'
3750 4208 )
@@ -3767,9 +4225,9 @@
3767 4225 // Also ensure heartbeat cron is scheduled now that we have an API key
3768 4226 do_action('metasync_ensure_heartbeat_cron_scheduled');
3769 4227
3770 4228 } catch (Exception $e) {
3771 - 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
3772 4230 }
3773 4231 }
3774 4232
3775 4233 public function get_item_schema()
@@ -3787,15 +4245,15 @@
3787 4245 'type' => 'object',
3788 4246 // In JSON Schema you can specify object properties in the properties attribute.
3789 4247 'properties' => array(
3790 4248 'id' => array(
3791 - 'description' => esc_html__('Unique identifier for the object.', 'my-textdomain'),
4249 + 'description' => esc_html__('Unique identifier for the object.', 'metasync'),
3792 4250 'type' => 'integer',
3793 4251 'context' => array('view', 'edit', 'embed'),
3794 4252 'readonly' => true,
3795 4253 ),
3796 4254 'content' => array(
3797 - 'description' => esc_html__('The content for the object.', 'my-textdomain'),
4255 + 'description' => esc_html__('The content for the object.', 'metasync'),
3798 4256 'type' => 'string',
3799 4257 ),
3800 4258 ),
3801 4259 );
@@ -3886,11 +4344,11 @@
3886 4344
3887 4345 # Try alternative parameter methods
3888 4346 $body_params = $request->get_body_params();
3889 4347 $key_param = $request->get_param('key');
3890 - $post_key = $_POST['key'] ?? null;
3891 - $request_key = $_REQUEST['key'] ?? null;
3892 - $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
3893 4351
3894 4352 # Try to get key from multiple sources
3895 4353 $key_value = null;
3896 4354
@@ -3920,9 +4378,9 @@
3920 4378 }
3921 4379
3922 4380 # Validate that key is provided
3923 4381 if (empty($key_value)) {
3924 - return rest_ensure_response(array(
4382 + return new WP_REST_Response(array(
3925 4383 'error' => 'Key parameter is required',
3926 4384 'code' => 'missing_key'
3927 4385 ), 400);
3928 4386 }
@@ -3934,9 +4392,9 @@
3934 4392 $key = basename(sanitize_file_name($data['key']));
3935 4393
3936 4394 # Validate key is not empty after sanitization and contains no path separators
3937 4395 if (empty($key) || preg_match('/[\/\\\\]/', $key) || strpos($key, '..') !== false) {
3938 - return rest_ensure_response(array(
4396 + return new WP_REST_Response(array(
3939 4397 'error' => 'Invalid key provided',
3940 4398 'code' => 'invalid_key'
3941 4399 ), 400);
3942 4400 }
@@ -3948,42 +4406,84 @@
3948 4406 $safe_key = sanitize_file_name( $key );
3949 4407 $file_path = $wp_root . $safe_key . '.txt';
3950 4408 $real_root = realpath( $wp_root );
3951 4409 if ( false === $real_root || 0 !== strpos( realpath( dirname( $file_path ) ), $real_root ) ) {
3952 - return rest_ensure_response(array(
4410 + return new WP_REST_Response(array(
3953 4411 'error' => 'Invalid file path',
3954 4412 'code' => 'invalid_path'
3955 4413 ), 400);
3956 4414 }
3957 4415
3958 - # Check if file already exists
3959 - if (file_exists($file_path)) {
3960 - return rest_ensure_response(array(
3961 - 'error' => 'File already exists',
3962 - 'code' => 'file_exists',
3963 - 'file_path' => $file_path
3964 - ), 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 + }
3965 4423 }
4424 + if (class_exists('Metasync_Bing_Instant_Index')) {
4425 + Metasync_Bing_Instant_Index::register_virtual_key($safe_key);
4426 + }
3966 4427
3967 - # Attempt to create the file
3968 - $result = file_put_contents($file_path, $safe_key);
3969 -
3970 - # Check if file creation was successful
3971 - if ($result === false) {
3972 - return rest_ensure_response(array(
3973 - 'error' => 'Failed to create file',
3974 - 'code' => 'file_creation_failed',
3975 - 'file_path' => $file_path
3976 - ), 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 + }
3977 4467 }
3978 -
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 +
3979 4478 # Return success response
3980 - return rest_ensure_response(array(
3981 - 'success' => true,
3982 - 'message' => 'Key file created successfully',
4479 + return new WP_REST_Response(array(
4480 + 'success' => true,
4481 + 'message' => 'Key file created successfully',
3983 4482 'file_path' => $file_path,
3984 - 'key' => $key,
3985 - '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
3986 4486 ), 200);
3987 4487 }
3988 4488
3989 4489 }