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 +989 -305 2.6.8 → 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,113 +164,251 @@
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();
168 +
169 + public function rest_authorization_middleware($request = null)
170 + {
171 + $api_key = '';
172 +
173 + // Primary: Authorization: Bearer <token>
174 + if ($request instanceof \WP_REST_Request) {
175 + $auth_header = $request->get_header('authorization');
176 + if (!empty($auth_header) && preg_match('/^Bearer\s+(.+)$/i', $auth_header, $matches)) {
177 + $api_key = sanitize_text_field($matches[1]);
178 + }
183 179 }
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 180
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));
181 + // Secondary: X-API-Key header
182 + if (empty($api_key) && isset($_SERVER['HTTP_X_API_KEY'])) {
183 + $api_key = sanitize_text_field(wp_unslash($_SERVER['HTTP_X_API_KEY']));
196 184 }
197 -
198 - wp_die(); // Always terminate after an AJAX call
185 +
186 + // Fallback: ?apikey= query param (deprecated)
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
189 + }
190 +
191 + if (empty($api_key)) {
192 + return false;
193 + }
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);
199 202 }
200 203
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);
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');
218 215
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';
216 + return $getOptions['apikey'] ?? null;
217 + }
223 218
224 - // Call the API
225 - $response = wp_remote_get($api_endpoint . '/?url=' . urlencode($current_url));
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 + }
226 248
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 - }
249 + // We need the request to know which route is being dispatched.
250 + if (!($request instanceof \WP_REST_Request)) {
251 + return $result;
236 252 }
237 -
238 - // Get the saved HTML from post meta
239 - $header_html = get_post_meta($post_id, '_otto_header_html', true);
240 253
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 -->";
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;
253 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;
254 327 }
255 328
256 - public function rest_authorization_middleware()
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()
257 336 {
258 - # Accept API key from header (preferred) or query string (deprecated fallback)
259 - $api_key = isset($_SERVER['HTTP_X_API_KEY']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_X_API_KEY'])) : '';
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 + }
260 342
261 - if (empty($api_key) && isset($_GET['apikey'])) {
262 - $api_key = sanitize_text_field($_GET['apikey']);
263 - if (!empty($api_key)) {
264 - error_log('MetaSync: REST API key via query string is deprecated. Use X-API-Key header instead.');
265 - }
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
266 346 }
267 347
268 - if (empty($api_key)) {
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 === '') {
269 402 return false;
270 403 }
271 404
272 - $getOptions = Metasync::get_option('general');
273 - $stored_key = $getOptions['apikey'] ?? null;
274 -
405 + $stored_key = $this->get_stored_api_key();
275 406 if (empty($stored_key)) {
276 407 return false;
277 408 }
278 409
279 - if (hash_equals($stored_key, $api_key)) {
280 - return true;
281 - }
282 - return false;
410 + return hash_equals((string) $stored_key, $api_key);
283 411 }
284 412
285 413
286 414 public function metasync_register_rest_routes()
@@ -422,9 +550,9 @@
422 550 array(
423 551 array(
424 552 'methods' => 'GET',
425 553 'callback' => function () {
426 - $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
427 555
428 556 if ($getPostID==0) {
429 557 $response = false;
430 558 }else{
@@ -722,14 +850,14 @@
722 850 }
723 851
724 852 # Check if post exists
725 853 if (!$post || $post_id <= 0) {
726 - return rest_ensure_response(array('error' => 'no blog post found'), 404);
854 + return new WP_REST_Response(array('error' => 'no blog post found'), 404);
727 855 }
728 856
729 857 # Check if post type is 'post', return error if not
730 858 if ($post->post_type !== 'post') {
731 - 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);
732 860 }
733 861
734 862 # Render content
735 863 $post_content = $post->post_content;
@@ -859,9 +987,10 @@
859 987 }
860 988
861 989 # Plugin version and timestamps (ISO 8601 UTC where set)
862 990 $plugin_version = defined('METASYNC_VERSION') ? METASYNC_VERSION : 'unknown';
863 - $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);
864 993 $sso_completed_at = $general_options['sso_completed_at'] ?? null;
865 994
866 995 # Prepare response (backward compatible + new fields)
867 996 $response_data = array(
@@ -869,8 +998,9 @@
869 998 'configured' => $is_configured,
870 999 'otto_active' => $is_otto_active,
871 1000 'has_api_key' => $has_api_key,
872 1001 'has_uuid' => $has_uuid,
1002 + 'otto_uuid' => $has_uuid ? $otto_uuid : null,
873 1003 'plugin_version' => $plugin_version,
874 1004 'last_heartbeat_at' => $last_heartbeat_at,
875 1005 'sso_completed_at' => $sso_completed_at,
876 1006 );
@@ -951,9 +1081,9 @@
951 1081 }
952 1082
953 1083 public function get_errorlogs()
954 1084 {
955 - $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
956 1086 if (!isset($get_data['limit']))
957 1087 return false;
958 1088 $limit = sanitize_text_field($get_data['limit']) ?? null;
959 1089
@@ -1141,9 +1271,9 @@
1141 1271 }
1142 1272 } catch (Throwable $e) {
1143 1273 // Fallback to DOMDocument if Dom\HTMLDocument fails
1144 1274 if (defined('WP_DEBUG_LOG') && WP_DEBUG_LOG) {
1145 - 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
1146 1276 }
1147 1277 $dom = new DOMDocument();
1148 1278 libxml_use_internal_errors(true);
1149 1279 $encoded_content = $this->encode_numeric_entities($content);
@@ -1343,9 +1473,9 @@
1343 1473 $image_id = attachment_url_to_postid($node->getAttribute('src') );
1344 1474 $image_alt = get_post_meta($image_id, '_wp_attachment_image_alt', TRUE);
1345 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]';
1346 1476
1347 - 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
1348 1478
1349 1479 }
1350 1480 }elseif($node->nodeName === 'iframe'){
1351 1481 $result= '[et_pb_code _builder_version="'.ET_BUILDER_VERSION.'" _module_preset="default" global_colors_info="{}"]'.$node->ownerDocument->saveHTML($node).'[/et_pb_code]' ;
@@ -1473,64 +1603,187 @@
1473 1603 return $attachment_id;
1474 1604 }
1475 1605
1476 1606 /**
1477 - * Index a post with Google Indexing API
1478 - *
1479 - * @param int $post_id WordPress post ID
1480 - * @param string $post_type WordPress post type (post, page, etc.)
1481 - * @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
1482 1616 */
1483 - public function metasync_google_index_post($post_id, $post_type, $post_status)
1617 + private function strip_leading_title_h1($content, $title)
1484 1618 {
1485 - // Only index published posts/pages
1486 - 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)) {
1487 1679 return;
1488 1680 }
1489 1681
1490 - // Only index posts and pages (can be extended as needed)
1491 - $allowed_post_types = array('post', 'page');
1492 - 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])) {
1493 1685 return;
1494 1686 }
1495 1687
1496 - try {
1497 - // Load Google Index functionality if not already loaded
1498 - if (!function_exists('google_index_post')) {
1499 - $google_index_path = plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php';
1500 - if (file_exists($google_index_path)) {
1501 - require_once $google_index_path;
1502 - } else {
1503 - error_log('MetaSync Google Index: google-index-init.php not found at ' . $google_index_path);
1504 - return;
1505 - }
1506 - }
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 + }
1507 1693
1508 - // Attempt to index the post with Google
1509 - if (function_exists('google_index_post')) {
1510 - $result = google_index_post($post_id, $post_type, 'update');
1511 -
1512 - if (isset($result['success']) && $result['success']) {
1513 - error_log(sprintf(
1514 - 'MetaSync Google Index: Successfully indexed %s (ID: %d, Type: %s)',
1515 - get_the_title($post_id),
1516 - $post_id,
1517 - $post_type
1518 - ));
1519 - } else {
1520 - error_log(sprintf(
1521 - 'MetaSync Google Index: Failed to index %s (ID: %d, Type: %s) - %s',
1522 - get_the_title($post_id),
1523 - $post_id,
1524 - $post_type,
1525 - isset($result['error']['message']) ? $result['error']['message'] : 'Unknown error'
1526 - ));
1527 - }
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;
1528 1783 }
1529 - } catch (Exception $e) {
1530 - // Log any exceptions but don't break the main functionality
1531 - error_log('MetaSync Google Index Exception: ' . $e->getMessage());
1532 1784 }
1785 + return false;
1533 1786 }
1534 1787
1535 1788 public function create_item($request)
1536 1789 {
@@ -1565,8 +1818,9 @@
1565 1818 check if the create_item is called by set_landing_page function or not
1566 1819 by doing this we will prevent html from going into builder page option
1567 1820 */
1568 1821 $isOttoAiPage = !empty($item['otto_ai_page']) && filter_var($item['otto_ai_page'], FILTER_VALIDATE_BOOLEAN);
1822 + $content = [];
1569 1823 if(!isset($item['is_landing_page']) && !$isOttoAiPage && empty($item['style_data']) ){
1570 1824
1571 1825 # Get Current Post type
1572 1826 $current_post_type = isset($item['post_type']) ? sanitize_text_field($item['post_type']) : 'post';
@@ -1584,27 +1838,31 @@
1584 1838 } elseif (!empty($theme_template) && stripos($theme_template, 'flatsome') !== false) {
1585 1839 $is_flatsome_theme = true;
1586 1840 }
1587 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 +
1588 1850 # Skip title/image prepending for Flatsome (it displays them by default)
1589 1851 if (!$is_flatsome_theme) {
1590 - # Get the setting for the post template
1852 + # Get the setting for the post template (feature-image detection)
1591 1853 $title_and_feature_image = $this->append_content_if_missing_elements($current_post_type);
1592 1854
1593 - # 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
1594 1856 if(!$title_and_feature_image['image_in_content'] && !empty($item['hero_image_url'])){
1595 -
1596 - # Prepend the feature image
1597 - $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'] ;
1598 1858 }
1599 1859
1600 - # Check if the post title is there in the template or not
1601 - if(!$title_and_feature_image['title_in_headings']){
1602 -
1603 - # Prepend the post title
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)){
1604 1863 $item['post_content'] = '<h1>'.$item['post_title'].'</h1>'.$item['post_content'] ;
1605 1864 }
1606 -
1607 1865 }
1608 1866 # This will be used by create_page function
1609 1867 $content = $this->metasync_upload_post_content($item,false,false);
1610 1868 }elseif(isset($item['is_landing_page']) && $item['is_landing_page'] == true){
@@ -1638,13 +1896,13 @@
1638 1896 $new_post['post_name'] = sanitize_text_field($item['permalink']);
1639 1897 }
1640 1898
1641 1899 if (isset($item['post_date']) && !empty($item['post_date'])) {
1642 - $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'];
1643 1901 if (!$is_valid_date) {
1644 1902 return new WP_Error(
1645 1903 'rest_post_invalid_date',
1646 - esc_html__('Post date is not valid'),
1904 + esc_html__('Post date is not valid', 'metasync'),
1647 1905 array('status' => 400)
1648 1906 );
1649 1907 }
1650 1908
@@ -1761,13 +2019,17 @@
1761 2019 $post_meta['searchatlas_embed_bottom'] = $item['searchatlas_embed_bottom'];
1762 2020 }
1763 2021
1764 2022 // Add custom fields to posts and pages
1765 - foreach ($post_meta as $key => $value) {
1766 - // if (!empty($value) && !is_null($value)) {
1767 - add_post_meta($post_id, $key, $value, true);
1768 - // }
1769 - }
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 + );
1770 2032
1771 2033
1772 2034 $attachment_id = '';
1773 2035 if (isset($item['hero_image_url']) && !empty($item['hero_image_url'])) {
@@ -1785,8 +2047,14 @@
1785 2047 $redirection['url'] = sanitize_url($item['redirection_url']);
1786 2048 }
1787 2049 if (!empty($redirection)) {
1788 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 + }
1789 2057 }
1790 2058
1791 2059 $post_cattegories = [];
1792 2060 # if ($new_post['post_type'] === 'post' && is_array(@$item['post_categories'])) {
@@ -1846,13 +2114,22 @@
1846 2114 # Track Content Genius event in GA4
1847 2115 try {
1848 2116 Metasync_GA4::get_instance()->track_content_genius_event($post_id, strtolower($action));
1849 2117 } catch (Exception $e) {
1850 - 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
1851 2119 }
2120 + }
1852 2121
1853 - // Google Indexing Integration
1854 - $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 + }
1855 2132 }
1856 2133
1857 2134 $respCreatePosts[$index] = array_merge($new_post, $post_meta);
1858 2135 ksort($respCreatePosts[$index]);
@@ -1896,9 +2173,9 @@
1896 2173 }
1897 2174
1898 2175 public function delete_item()
1899 2176 {
1900 - $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
1901 2178 if (!isset($get_data['ID']))
1902 2179 return false;
1903 2180
1904 2181 $post_id = sanitize_text_field($get_data['ID']) ?? null;
@@ -1904,18 +2181,18 @@
1904 2181 $post_id = sanitize_text_field($get_data['ID']) ?? null;
1905 2182 $post = get_post($post_id);
1906 2183 if ($post) {
1907 2184 wp_delete_post($post_id);
2185 + // HTTP 204 requires no body for response, so no message is set.
1908 2186 return new WP_Error(
1909 2187 'rest_post_delete_success',
1910 - esc_html__(''),
1911 - // HTTP 204 requires no body for response
2188 + '',
1912 2189 array('status' => 204)
1913 2190 );
1914 2191 }
1915 2192 return new WP_Error(
1916 2193 'rest_post_delete_fail',
1917 - esc_html__('No post found in the database with requested ID.'),
2194 + esc_html__('No post found in the database with requested ID.', 'metasync'),
1918 2195 array('status' => 400)
1919 2196 );
1920 2197 }
1921 2198
@@ -1920,9 +2197,9 @@
1920 2197 }
1921 2198
1922 2199 public function get_items($request)
1923 2200 {
1924 - $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
1925 2202
1926 2203 # let us check if request send post id and is valid int
1927 2204 if(isset($get_data['post_id']) AND intval($get_data['post_id']) > 0){
1928 2205
@@ -2262,9 +2539,9 @@
2262 2539 $post_data = get_post($post_id);
2263 2540 if (!$post_data) {
2264 2541 return new WP_Error(
2265 2542 'rest_post_update_fail',
2266 - esc_html__('No post found in the database with requested ID.'),
2543 + esc_html__('No post found in the database with requested ID.', 'metasync'),
2267 2544 array('status' => 400)
2268 2545 );
2269 2546 }
2270 2547
@@ -2289,12 +2566,19 @@
2289 2566 $update_params['meta_robots'] = $post['meta_robots'] ?
2290 2567 sanitize_text_field($post['meta_robots']) : $get_robots_meta;
2291 2568 }
2292 2569 if (isset($post['meta_canonical']) && !empty($post['meta_canonical'])) {
2293 - $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 + }
2294 2576 }
2295 2577
2296 2578 $isOttoAiPage = !empty($post['otto_ai_page']) && filter_var($post['otto_ai_page'], FILTER_VALIDATE_BOOLEAN);
2579 + $content = [];
2580 + $permalink = '';
2297 2581 if (isset($post['post_content']) && !empty($post['post_content']) && !$isOttoAiPage) {
2298 2582
2299 2583 # Above we are updating the post_type so we have to get latest value that has been change on the server
2300 2584 $post_fresh_data = get_post($post['post_id']);
@@ -2313,25 +2597,29 @@
2313 2597 } elseif (!empty($theme_template) && stripos($theme_template, 'flatsome') !== false) {
2314 2598 $is_flatsome_theme = true;
2315 2599 }
2316 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 +
2317 2608 # Skip title/image prepending for Flatsome (it displays them by default)
2318 2609 if (!$is_flatsome_theme) {
2319 - # Get the setting for the post template
2610 + # Get the setting for the post template (feature-image detection)
2320 2611 $title_and_feature_image = $this->append_content_if_missing_elements($post_fresh_data->post_type);
2321 2612
2322 - # 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
2323 2614 if(!$title_and_feature_image['image_in_content'] && !empty($post['hero_image_url'])){
2324 -
2325 - # Prepend the feature image
2326 - $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'] ;
2327 2616 }
2328 2617
2329 - # Check if the post title is there in the template or not
2330 - if(!$title_and_feature_image['title_in_headings']){
2331 -
2332 - # Prepend the post title
2333 - $post['post_content'] = '<h1>'.$post['post_title'].'</h1>'.$post['post_content'] ;
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'] ;
2334 2622 }
2335 2623 }
2336 2624 // This will be used by update_page function
2337 2625 $content = $this->metasync_upload_post_content($post,false,false);
@@ -2391,40 +2679,8 @@
2391 2679 );
2392 2680 }
2393 2681
2394 2682
2395 - if (isset($post['post_date']) && !empty($post['post_date']) && false) {
2396 - $is_valid_date = date('Y-m-d', strtotime($post['post_date'])) == $post['post_date'];
2397 - if (!$is_valid_date) {
2398 - return new WP_Error(
2399 - 'rest_post_invalid_date',
2400 - esc_html__('Post date is not valid'),
2401 - array('status' => 400)
2402 - );
2403 - }
2404 -
2405 - $date_limit_str = strtotime(date('Y-m-d') . '-2 month');
2406 - $post_date_str = strtotime($post['post_date']);
2407 -
2408 - if ($date_limit_str >= $post_date_str) {
2409 - $newDate = date('Y-m-d', strtotime('-2 month'));
2410 - return new WP_Error(
2411 - 'rest_post_greater_date',
2412 - esc_html__("Post date should be greater then " . $newDate),
2413 - array('status' => 400)
2414 - );
2415 - }
2416 -
2417 - if ($post_date_str > strtotime(date('Y-m-d'))) {
2418 - return new WP_Error(
2419 - 'rest_post_greater_date',
2420 - esc_html__('Post date should be less then Today'),
2421 - array('status' => 400)
2422 - );
2423 - }
2424 - $update_params['post_date'] = sanitize_text_field($post['post_date'] . date(' h:i:s'));
2425 - }
2426 -
2427 2683 $post_cattegories = [];
2428 2684 # if ($post_data && $post_data->post_type === 'post' && is_array(@$post['post_categories'])) {
2429 2685
2430 2686 # fixed Undefined array key issue
@@ -2448,11 +2704,9 @@
2448 2704 }
2449 2705
2450 2706 $resp_update = $this->update_object($post_id, $update_params);
2451 2707 if(isset($content['elementor_meta_data'])){
2452 - foreach ($content['elementor_meta_data'] as $key => $value) {
2453 - update_post_meta($post_id, $key, $value);
2454 - }
2708 + $this->metasync_apply_builder_meta($post_id, $content['elementor_meta_data']);
2455 2709 if ( did_action( 'elementor/loaded' ) ) {
2456 2710 // Clear Elementor cache for the specified post ID
2457 2711 \Elementor\Plugin::instance()->files_manager->clear_cache();
2458 2712 }
@@ -2467,8 +2721,15 @@
2467 2721 delete_post_meta($post_id, '_elementor_page_assets');
2468 2722 delete_post_meta($post_id, '_elementor_page_settings');
2469 2723 }
2470 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 +
2471 2732 $redirection = array();
2472 2733 if (!empty($post['redirection_enable']) && !is_null($post['redirection_enable'])) {
2473 2734 $redirection['enable'] = sanitize_text_field($post['redirection_enable']);
2474 2735 }
@@ -2479,8 +2740,14 @@
2479 2740 $redirection['url'] = sanitize_url($post['redirection_url']);
2480 2741 }
2481 2742 if (!empty($redirection)) {
2482 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 + }
2483 2750 }
2484 2751
2485 2752
2486 2753 $post_revisions = wp_get_post_revisions($post_id);
@@ -2487,9 +2754,8 @@
2487 2754 // Sync post categories to customer dashboard
2488 2755 $this->lgSendCustomerPostParams();
2489 2756
2490 2757 unset($update_params['post_name']);
2491 - unset($update_params['post_category']);
2492 2758
2493 2759 $update_params['post_categories'] = $post_cattegories;
2494 2760 $update_params['post_tags'] = $post_tags;
2495 2761 $update_params['post_id'] = (int) $post_id;
@@ -2519,14 +2785,9 @@
2519 2785
2520 2786 }
2521 2787 # update the content
2522 2788 // add and update the post meta
2523 - foreach ($post_meta_data as $key => $value) {
2524 - // if (!empty($value) && !is_null($value)) {
2525 -
2526 - update_post_meta($post_id, $key, $value);
2527 - //
2528 - }
2789 + $this->metasync_apply_builder_meta($post_id, $post_meta_data);
2529 2790 #check if the elementor plugin is active
2530 2791 if ( did_action( 'elementor/loaded' ) ) {
2531 2792 # Clear Elementor cache for the specified post ID
2532 2793 \Elementor\Plugin::instance()->files_manager->clear_cache();
@@ -2563,10 +2824,21 @@
2563 2824 # Track Content Genius event in GA4
2564 2825 try {
2565 2826 Metasync_GA4::get_instance()->track_content_genius_event($post_id, 'updated');
2566 2827 } catch (Exception $e) {
2567 - 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
2568 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 + }
2569 2841 }
2570 2842
2571 2843 ksort($update_params);
2572 2844 $data[] = $update_params;
@@ -2661,20 +2933,9 @@
2661 2933 if($isOttoAiPage){
2662 2934 // Change the page template from default to Metasync Template
2663 2935 $pageTemplate = Metasync_Template::TEMPLATE_NAME;
2664 2936 }
2665 - if ($pageTemplate !== 'default') {
2666 - update_post_meta($post_id, '_wp_page_template', $pageTemplate);
2667 - } else {
2668 - // Clear stale templates that conflict with the active page builder.
2669 - // e.g. metasync-blank from a previous OTTO sync, or elementor_canvas
2670 - // written by the HTML-to-builder converter on an Oxygen site.
2671 - $current = get_post_meta($post_id, '_wp_page_template', true);
2672 - $stale_templates = array(Metasync_Template::TEMPLATE_NAME, 'elementor_canvas', 'elementor_header_footer');
2673 - if (in_array($current, $stale_templates, true)) {
2674 - delete_post_meta($post_id, '_wp_page_template');
2675 - }
2676 - }
2937 + $this->metasync_write_page_template($post_id, $pageTemplate);
2677 2938 }
2678 2939
2679 2940 return rest_ensure_response($createPages);
2680 2941 }
@@ -2697,9 +2958,9 @@
2697 2958 $post_data = get_post($payload['post_id']);
2698 2959 if(!isset($post_data->post_type)){
2699 2960 return new WP_Error(
2700 2961 'rest_page_type_fail',
2701 - esc_html__('No page found in the database with requested ID.'),
2962 + esc_html__('No page found in the database with requested ID.', 'metasync'),
2702 2963 array('status' => 400)
2703 2964 );
2704 2965 }
2705 2966 if ($post_data->post_type !== 'page') {
@@ -2754,20 +3015,225 @@
2754 3015 if($isOttoAiPage){
2755 3016 // Change the page template from default to Metasync Template
2756 3017 $pageTemplate = Metasync_Template::TEMPLATE_NAME;
2757 3018 }
2758 - if ($pageTemplate !== 'default') {
2759 - 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);
2760 3232 } else {
2761 - // Clear stale templates that conflict with the active page builder.
2762 - $current = get_post_meta($post_id, '_wp_page_template', true);
2763 - $stale_templates = array(Metasync_Template::TEMPLATE_NAME, 'elementor_canvas', 'elementor_header_footer');
2764 - if (in_array($current, $stale_templates, true)) {
2765 - delete_post_meta($post_id, '_wp_page_template');
2766 - }
3233 + add_post_meta($post_id, $key, $value, true);
2767 3234 }
2768 3235 }
2769 - return rest_ensure_response($updatePages->data);
2770 3236 }
2771 3237
2772 3238 public function delete_page()
2773 3239 {
@@ -2782,13 +3248,13 @@
2782 3248 {
2783 3249 $sync_request = new Metasync_Sync_Requests();
2784 3250 $response = $sync_request->SyncCustomerParams();
2785 3251
2786 - $responseCode = wp_remote_retrieve_response_code($response);
3252 + $responseCode = Metasync_Sync_Requests::get_response_code($response);
2787 3253 if ($responseCode == 200) {
2788 - $dt = new DateTime();
3254 + # Use current_time('mysql') for consistency with cron heartbeat.
2789 3255 $send_auth_token_timestamp = Metasync::get_option();
2790 - $send_auth_token_timestamp['general']['send_auth_token_timestamp'] = $dt->format('M d, Y h:i:s A');
3256 + $send_auth_token_timestamp['general']['send_auth_token_timestamp'] = current_time('mysql');
2791 3257 Metasync::set_option($send_auth_token_timestamp);
2792 3258 }
2793 3259 }
2794 3260
@@ -2793,8 +3259,17 @@
2793 3259 }
2794 3260
2795 3261 public function linkgraph_login()
2796 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 +
2797 3272 $post_data = metasync_sanitize_input_array($_POST);
2798 3273 $payload = array(
2799 3274 'username' => wp_unslash(sanitize_email($post_data['username'])),
2800 3275 'password' => wp_unslash(sanitize_text_field($post_data['password']))
@@ -2811,9 +3286,9 @@
2811 3286 ));
2812 3287
2813 3288 # Error handling for timeout or connection failures
2814 3289 if (is_wp_error($response)) {
2815 - 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
2816 3291 wp_send_json_error(array('message' => 'Token API request failed'));
2817 3292 wp_die();
2818 3293 }
2819 3294
@@ -2828,9 +3303,9 @@
2828 3303 {
2829 3304 $sync_heartbeat_data = new Metasync_Sync_Requests();
2830 3305 $response = $sync_heartbeat_data->SyncCustomerParams();
2831 3306
2832 - $responseCode = wp_remote_retrieve_response_code($response);
3307 + $responseCode = Metasync_Sync_Requests::get_response_code($response);
2833 3308 if ($responseCode == 200) {
2834 3309 return rest_ensure_response($response);
2835 3310 }
2836 3311 return rest_ensure_response($response);
@@ -2876,9 +3351,15 @@
2876 3351 );
2877 3352 }
2878 3353
2879 3354 // Check token exists in transients (read-only - don't modify)
3355 + // Try transient first, then fall back to wp_options (handles object cache issues)
2880 3356 $transient_key = get_transient('metasync_sa_connect_active_' . $nonce_token);
3357 +
3358 + if (empty($transient_key) && wp_using_ext_object_cache()) {
3359 + $transient_key = $this->get_sso_token_from_db($nonce_token);
3360 + }
3361 +
2881 3362 if (empty($transient_key)) {
2882 3363 return new WP_Error(
2883 3364 'invalid_nonce_token',
2884 3365 'Invalid or expired nonce token',
@@ -2887,8 +3368,13 @@
2887 3368 }
2888 3369
2889 3370 // Check token metadata exists (read-only)
2890 3371 $token_metadata = get_transient($transient_key);
3372 +
3373 + if ((empty($token_metadata) || !is_array($token_metadata)) && wp_using_ext_object_cache()) {
3374 + $token_metadata = $this->get_sso_metadata_from_db($transient_key);
3375 + }
3376 +
2891 3377 if (empty($token_metadata) || !is_array($token_metadata)) {
2892 3378 return new WP_Error(
2893 3379 'invalid_nonce_token',
2894 3380 'Invalid nonce token',
@@ -2916,8 +3402,143 @@
2916 3402 );
2917 3403 }
2918 3404 }
2919 3405
3406 + /**
3407 + * Read SSO active token directly from DB, bypassing object cache.
3408 + *
3409 + * On sites with external object cache (Redis, Memcached, LiteSpeed), the
3410 + * transient set during the admin AJAX request may not be visible to the
3411 + * REST callback from SA servers (different process/cache context).
3412 + *
3413 + * Checks the companion _transient_timeout_ row to honor expiry.
3414 + *
3415 + * @param string $nonce_token The nonce token from the x-api-key header
3416 + * @return string|false The metadata transient key, or false
3417 + */
3418 + private function get_sso_token_from_db($nonce_token)
3419 + {
3420 + global $wpdb;
3421 +
3422 + $option_name = '_transient_metasync_sa_connect_active_' . $nonce_token;
3423 + $timeout_name = '_transient_timeout_metasync_sa_connect_active_' . $nonce_token;
3424 +
3425 + // Check expiry first
3426 + $timeout = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
3427 + $wpdb->prepare(
3428 + "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
3429 + $timeout_name
3430 + )
3431 + );
3432 +
3433 + if ($timeout && (int) $timeout < time()) {
3434 + // Expired — clean up orphan rows
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
3437 + return false;
3438 + }
3439 +
3440 + $value = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
3441 + $wpdb->prepare(
3442 + "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
3443 + $option_name
3444 + )
3445 + );
3446 +
3447 + return $value ?: false;
3448 + }
3449 +
3450 + /**
3451 + * Read SSO token metadata directly from DB, bypassing object cache.
3452 + *
3453 + * Checks the companion _transient_timeout_ row to honor expiry.
3454 + *
3455 + * @param string $transient_key The transient key holding the token metadata
3456 + * @return array|false The token metadata array, or false
3457 + */
3458 + private function get_sso_metadata_from_db($transient_key)
3459 + {
3460 + global $wpdb;
3461 +
3462 + $option_name = '_transient_' . $transient_key;
3463 + $timeout_name = '_transient_timeout_' . $transient_key;
3464 +
3465 + // Check expiry first
3466 + $timeout = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
3467 + $wpdb->prepare(
3468 + "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
3469 + $timeout_name
3470 + )
3471 + );
3472 +
3473 + if ($timeout && (int) $timeout < time()) {
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
3476 + return false;
3477 + }
3478 +
3479 + $value = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
3480 + $wpdb->prepare(
3481 + "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
3482 + $option_name
3483 + )
3484 + );
3485 +
3486 + if ($value) {
3487 + $unserialized = maybe_unserialize($value);
3488 + return is_array($unserialized) ? $unserialized : false;
3489 + }
3490 +
3491 + return false;
3492 + }
3493 +
3494 + /**
3495 + * Delete an SSO transient and its DB fallback rows.
3496 + *
3497 + * When external object cache is active, delete_transient() only removes
3498 + * the cache entry. This also removes the wp_options rows written by the
3499 + * DB fallback in create_searchatlas_nonce_token().
3500 + *
3501 + * @param string $transient_name Transient name (without _transient_ prefix)
3502 + */
3503 + private function delete_sso_transient($transient_name)
3504 + {
3505 + delete_transient($transient_name);
3506 + if (wp_using_ext_object_cache()) {
3507 + global $wpdb;
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
3510 + }
3511 + }
3512 +
3513 + /**
3514 + * Set an SSO transient and its DB fallback rows.
3515 + *
3516 + * When external object cache is active, set_transient() only writes to
3517 + * cache. This also writes to wp_options so cross-process reads work.
3518 + *
3519 + * @param string $transient_name Transient name (without _transient_ prefix)
3520 + * @param mixed $value Value to store
3521 + * @param int $expiration Expiration in seconds
3522 + */
3523 + private function set_sso_transient($transient_name, $value, $expiration)
3524 + {
3525 + set_transient($transient_name, $value, $expiration);
3526 + if (wp_using_ext_object_cache()) {
3527 + global $wpdb;
3528 + $wpdb->replace($wpdb->options, array( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
3529 + 'option_name' => '_transient_' . $transient_name,
3530 + 'option_value' => maybe_serialize($value),
3531 + 'autoload' => 'no',
3532 + ));
3533 + $wpdb->replace($wpdb->options, array( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
3534 + 'option_name' => '_transient_timeout_' . $transient_name,
3535 + 'option_value' => time() + $expiration,
3536 + 'autoload' => 'no',
3537 + ));
3538 + }
3539 + }
3540 +
2920 3541 /**
2921 3542 * Validate Search Atlas connect token for callback
2922 3543 * SECURITY FIX (CVE-2025-14386): Only validates against time-limited transient tokens
2923 3544 * Tokens must be created by generate_searchatlas_connect_url() and stored in transients
@@ -2930,26 +3551,35 @@
2930 3551
2931 3552 // SECURITY FIX: Token MUST exist in transients (created by generate_searchatlas_connect_url)
2932 3553 // We do NOT fall back to apikey - this was the vulnerability!
2933 3554 $transient_key = get_transient('metasync_sa_connect_active_' . $token);
2934 -
3555 +
3556 + // Fallback: read directly from DB when object cache misses
3557 + if (empty($transient_key) && wp_using_ext_object_cache()) {
3558 + $transient_key = $this->get_sso_token_from_db($token);
3559 + }
3560 +
2935 3561 if (empty($transient_key)) {
2936 3562 return false;
2937 3563 }
2938 3564
2939 - // Token found in transients - validate metadata
3565 + // Token found - validate metadata
2940 3566 $token_metadata = get_transient($transient_key);
2941 3567
3568 + // Fallback: read metadata directly from DB
3569 + if ((empty($token_metadata) || !is_array($token_metadata)) && wp_using_ext_object_cache()) {
3570 + $token_metadata = $this->get_sso_metadata_from_db($transient_key);
3571 + }
3572 +
2942 3573 if (empty($token_metadata) || !is_array($token_metadata)) {
2943 - delete_transient('metasync_sa_connect_active_' . $token);
3574 + $this->delete_sso_transient('metasync_sa_connect_active_' . $token);
2944 3575 return false;
2945 3576 }
2946 3577
2947 -
2948 3578 // Check expiration
2949 3579 if (isset($token_metadata['expires']) && time() > $token_metadata['expires']) {
2950 - delete_transient($transient_key);
2951 - delete_transient('metasync_sa_connect_active_' . $token);
3580 + $this->delete_sso_transient($transient_key);
3581 + $this->delete_sso_transient('metasync_sa_connect_active_' . $token);
2952 3582 return false;
2953 3583 }
2954 3584
2955 3585 // Check if already used for callback (single-use enforcement)
@@ -2955,20 +3585,20 @@
2955 3585 // Check if already used for callback (single-use enforcement)
2956 3586 if (isset($token_metadata['callback_used']) && $token_metadata['callback_used'] === true) {
2957 3587 return false;
2958 3588 }
2959 -
3589 +
2960 3590 // Mark token as used for callback (single-use)
3591 + // Uses set_sso_transient to propagate callback_used to DB on object cache sites
2961 3592 $token_metadata['callback_used'] = true;
2962 3593 $token_metadata['callback_at'] = time();
2963 - // BUGFIX: Keep metadata for 5 minutes to allow user login to complete
2964 - set_transient($transient_key, $token_metadata, 300); // Keep for 5 minutes for user login
3594 + $this->set_sso_transient($transient_key, $token_metadata, 300);
2965 3595
2966 3596 // BUGFIX: Only delete the active token mapping if BOTH operations are complete
2967 3597 // This allows user login and API callback to happen in any order without race conditions
2968 3598 if (isset($token_metadata['used']) && $token_metadata['used'] === true) {
2969 3599 // Both callback and login are done - safe to delete mapping
2970 - delete_transient('metasync_sa_connect_active_' . $token);
3600 + $this->delete_sso_transient('metasync_sa_connect_active_' . $token);
2971 3601 }
2972 3602 // Otherwise, keep the mapping so user login can still find the token
2973 3603
2974 3604 return true;
@@ -3255,9 +3885,9 @@
3255 3885 *
3256 3886 * @param array $body_params Request body parameters
3257 3887 * @return array|WP_Error Validated parameters array or WP_Error
3258 3888 */
3259 - private function extract_and_validate_searchatlas_params($body_params)
3889 + public function extract_and_validate_searchatlas_params($body_params)
3260 3890 {
3261 3891 $validation_errors = array();
3262 3892
3263 3893 // Extract parameters
@@ -3338,9 +3968,9 @@
3338 3968 } elseif (strlen($whitelabel_domain) > 255) {
3339 3969 $validation_errors['whitelabel_domain'] = 'Whitelabel domain must not exceed 255 characters';
3340 3970 } elseif (!filter_var($whitelabel_domain, FILTER_VALIDATE_URL)) {
3341 3971 $validation_errors['whitelabel_domain'] = 'Whitelabel domain must be a valid URL';
3342 - } 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'])) {
3343 3973 $validation_errors['whitelabel_domain'] = 'Whitelabel domain must use http or https protocol';
3344 3974 }
3345 3975 }
3346 3976
@@ -3353,9 +3983,9 @@
3353 3983 $validation_errors['whitelabel_logo'] = 'Whitelabel logo URL must not exceed 500 characters';
3354 3984 } else {
3355 3985 // If URL is invalid, we'll clear it later but not fail the POST
3356 3986 if (!filter_var($whitelabel_logo, FILTER_VALIDATE_URL) ||
3357 - !in_array(parse_url($whitelabel_logo, PHP_URL_SCHEME), ['http', 'https'])) {
3987 + !in_array(wp_parse_url($whitelabel_logo, PHP_URL_SCHEME), ['http', 'https'])) {
3358 3988 // Don't add to validation_errors - let POST succeed but clear the field
3359 3989 }
3360 3990 }
3361 3991 }
@@ -3446,9 +4076,10 @@
3446 4076 $options['general'] = array();
3447 4077 }
3448 4078 // Only update the API key in settings if status_code is 200 (success)
3449 4079 if ($status_code === 200) {
3450 - $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);
3451 4082 $options['general']['otto_pixel_uuid'] = $new_otto_uuid;
3452 4083 // Note: OTTO SSR is always enabled by default, no need to set
3453 4084
3454 4085 // Granular otto_config_status: record when SSO completed (ISO 8601 UTC)
@@ -3456,12 +4087,13 @@
3456 4087
3457 4088 // Update authentication timestamp for polling detection (legacy - keeping for compatibility)
3458 4089 $options['general']['send_auth_token_timestamp'] = current_time('mysql');
3459 4090
3460 - // ✅ NEW: Set nonce-specific success flag for polling detection
4091 + // Set nonce-specific success flag for polling detection
3461 4092 // This ensures only the specific nonce that was authenticated reports success
4093 + // Uses set_sso_transient to also write to DB on object cache sites
3462 4094 $success_key = 'metasync_sa_connect_success_' . md5($token);
3463 - set_transient($success_key, true, 300); // 5 minutes expiry
4095 + $this->set_sso_transient($success_key, true, 300);
3464 4096
3465 4097 // Clear JWT token cache when API key is updated to ensure fresh tokens
3466 4098 $this->clear_jwt_token_cache();
3467 4099
@@ -3479,11 +4111,20 @@
3479 4111 if (!empty($whitelabel_otto)) {
3480 4112 $options['general']['whitelabel_otto_name'] = $whitelabel_otto;
3481 4113 }
3482 4114 } else {
3483 - // Clear whitelabel fields when not whitelabel
3484 - unset($options['general']['white_label_plugin_name']);
3485 - 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'] = '';
3486 4127 }
3487 4128
3488 4129 // Store whitelabel settings (hidden from UI but accessible to plugin logic)
3489 4130 if (!isset($options['whitelabel'])) {
@@ -3513,11 +4154,12 @@
3513 4154
3514 4155 }
3515 4156
3516 4157 $save_result = Metasync::set_option($options);
4158 + Metasync::invalidate_api_key_cache();
3517 4159
3518 4160 if (!$save_result) {
3519 - 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
3520 4162 } else {
3521 4163 if ($status_code === 200) {
3522 4164 // Option 1: Set heartbeat cache and last-known to CONNECTED so dashboard shows iframe immediately.
3523 4165 // If the immediate heartbeat then fails (e.g. backoff), Option 2 prevents overwriting this to DISCONNECTED.
@@ -3536,9 +4178,9 @@
3536 4178
3537 4179 return true;
3538 4180
3539 4181 } catch (Exception $e) {
3540 - 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
3541 4183 return false;
3542 4184 }
3543 4185 }
3544 4186
@@ -3550,9 +4192,9 @@
3550 4192 {
3551 4193 global $wpdb;
3552 4194
3553 4195 // Clear all JWT token transients
3554 - $deleted = $wpdb->query(
4196 + $deleted = $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
3555 4197 $wpdb->prepare(
3556 4198 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
3557 4199 '_transient_metasync_jwt_token_%'
3558 4200 )
@@ -3558,9 +4200,9 @@
3558 4200 )
3559 4201 );
3560 4202
3561 4203 // Also clear timeout transients
3562 - $wpdb->query(
4204 + $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
3563 4205 $wpdb->prepare(
3564 4206 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
3565 4207 '_transient_timeout_metasync_jwt_token_%'
3566 4208 )
@@ -3583,9 +4225,9 @@
3583 4225 // Also ensure heartbeat cron is scheduled now that we have an API key
3584 4226 do_action('metasync_ensure_heartbeat_cron_scheduled');
3585 4227
3586 4228 } catch (Exception $e) {
3587 - 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
3588 4230 }
3589 4231 }
3590 4232
3591 4233 public function get_item_schema()
@@ -3603,15 +4245,15 @@
3603 4245 'type' => 'object',
3604 4246 // In JSON Schema you can specify object properties in the properties attribute.
3605 4247 'properties' => array(
3606 4248 'id' => array(
3607 - 'description' => esc_html__('Unique identifier for the object.', 'my-textdomain'),
4249 + 'description' => esc_html__('Unique identifier for the object.', 'metasync'),
3608 4250 'type' => 'integer',
3609 4251 'context' => array('view', 'edit', 'embed'),
3610 4252 'readonly' => true,
3611 4253 ),
3612 4254 'content' => array(
3613 - 'description' => esc_html__('The content for the object.', 'my-textdomain'),
4255 + 'description' => esc_html__('The content for the object.', 'metasync'),
3614 4256 'type' => 'string',
3615 4257 ),
3616 4258 ),
3617 4259 );
@@ -3702,11 +4344,11 @@
3702 4344
3703 4345 # Try alternative parameter methods
3704 4346 $body_params = $request->get_body_params();
3705 4347 $key_param = $request->get_param('key');
3706 - $post_key = $_POST['key'] ?? null;
3707 - $request_key = $_REQUEST['key'] ?? null;
3708 - $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
3709 4351
3710 4352 # Try to get key from multiple sources
3711 4353 $key_value = null;
3712 4354
@@ -3736,9 +4378,9 @@
3736 4378 }
3737 4379
3738 4380 # Validate that key is provided
3739 4381 if (empty($key_value)) {
3740 - return rest_ensure_response(array(
4382 + return new WP_REST_Response(array(
3741 4383 'error' => 'Key parameter is required',
3742 4384 'code' => 'missing_key'
3743 4385 ), 400);
3744 4386 }
@@ -3750,9 +4392,9 @@
3750 4392 $key = basename(sanitize_file_name($data['key']));
3751 4393
3752 4394 # Validate key is not empty after sanitization and contains no path separators
3753 4395 if (empty($key) || preg_match('/[\/\\\\]/', $key) || strpos($key, '..') !== false) {
3754 - return rest_ensure_response(array(
4396 + return new WP_REST_Response(array(
3755 4397 'error' => 'Invalid key provided',
3756 4398 'code' => 'invalid_key'
3757 4399 ), 400);
3758 4400 }
@@ -3764,42 +4406,84 @@
3764 4406 $safe_key = sanitize_file_name( $key );
3765 4407 $file_path = $wp_root . $safe_key . '.txt';
3766 4408 $real_root = realpath( $wp_root );
3767 4409 if ( false === $real_root || 0 !== strpos( realpath( dirname( $file_path ) ), $real_root ) ) {
3768 - return rest_ensure_response(array(
4410 + return new WP_REST_Response(array(
3769 4411 'error' => 'Invalid file path',
3770 4412 'code' => 'invalid_path'
3771 4413 ), 400);
3772 4414 }
3773 4415
3774 - # Check if file already exists
3775 - if (file_exists($file_path)) {
3776 - return rest_ensure_response(array(
3777 - 'error' => 'File already exists',
3778 - 'code' => 'file_exists',
3779 - 'file_path' => $file_path
3780 - ), 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 + }
3781 4423 }
4424 + if (class_exists('Metasync_Bing_Instant_Index')) {
4425 + Metasync_Bing_Instant_Index::register_virtual_key($safe_key);
4426 + }
3782 4427
3783 - # Attempt to create the file
3784 - $result = file_put_contents($file_path, $safe_key);
3785 -
3786 - # Check if file creation was successful
3787 - if ($result === false) {
3788 - return rest_ensure_response(array(
3789 - 'error' => 'Failed to create file',
3790 - 'code' => 'file_creation_failed',
3791 - 'file_path' => $file_path
3792 - ), 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 + }
3793 4467 }
3794 -
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 +
3795 4478 # Return success response
3796 - return rest_ensure_response(array(
3797 - 'success' => true,
3798 - 'message' => 'Key file created successfully',
4479 + return new WP_REST_Response(array(
4480 + 'success' => true,
4481 + 'message' => 'Key file created successfully',
3799 4482 'file_path' => $file_path,
3800 - 'key' => $key,
3801 - '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
3802 4486 ), 200);
3803 4487 }
3804 4488
3805 4489 }