PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.2
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.2
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
← All changes | public/class-metasync-rest-api.php +1131 -349 2.6.6 → 2.7.2 View file →
@@ -46,10 +46,8 @@
46 46 private $version;
47 47
48 48 private const namespace = "metasync/v1";
49 49
50 - private $escapers;
51 - private $replacements;
52 50 private $common;
53 51 private $allowed_attributes;
54 52 private $schema;
55 53 private $metasync_option_data;
@@ -74,21 +72,13 @@
74 72 'post_name',
75 73 'post_parent',
76 74 'post_status',
77 75 );
78 - $this->escapers = array("\\", "/", "\"");
79 - $this->replacements = array("", "", "");
80 76 $this->common = new Metasync_Common();
81 77 // get all options
82 78 $this->metasync_option_data = Metasync::get_option('general');
83 - $this->init_ajax_hooks();
84 79 }
85 80
86 - public function init_ajax_hooks()
87 - {
88 - add_action('wp_ajax_metasync_otto_ajax_action', array($this,'metasyn_otto_ajax'));
89 - }
90 -
91 81 /**
92 82 * Encode non-ASCII characters in a UTF-8 string as numeric HTML entities.
93 83 *
94 84 * Uses mb_encode_numericentity when the mbstring extension is loaded;
@@ -174,103 +164,246 @@
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, kept for platform compatibility)
187 + if (empty($api_key) && isset($_GET['apikey'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- API key credential in REST auth, not cookie auth
188 + $api_key = sanitize_text_field(wp_unslash($_GET['apikey'])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- API key credential in REST auth, not cookie auth
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 - # $get_data = metasync_sanitize_input_array($_GET);
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 + }
259 342
260 - # mailchamp causing fatal error if sanitize_post used
261 - $get_data = array_map('sanitize_text_field', $_GET);
262 - if (!isset($get_data['apikey']))
343 + // ?rest_route= form, used when pretty permalinks are disabled.
344 + if (!empty($_GET['rest_route'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only route check for REST auth
345 + return '/' . ltrim(sanitize_text_field(wp_unslash($_GET['rest_route'])), '/'); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only route check for REST auth
346 + }
347 +
348 + // Last resort: the request path with the /wp-json/ prefix stripped.
349 + $uri = isset($_SERVER['REQUEST_URI']) ? (string) wp_unslash($_SERVER['REQUEST_URI']) : '';
350 + if ($uri === '') {
351 + return '';
352 + }
353 +
354 + $path = (string) wp_parse_url($uri, PHP_URL_PATH);
355 + $url_prefix = function_exists('rest_get_url_prefix') ? rest_get_url_prefix() : 'wp-json';
356 + $needle = '/' . trim((string) $url_prefix, '/') . '/';
357 +
358 + $pos = strpos($path, $needle);
359 + if ($pos === false) {
360 + return '';
361 + }
362 +
363 + return '/' . ltrim(substr($path, $pos + strlen($needle)), '/');
364 + }
365 +
366 + /**
367 + * Whether the current request carries a valid MetaSync API key.
368 + *
369 + * Reads credentials from the request globals so it works in filters that get
370 + * no WP_REST_Request. Accepts Authorization: Bearer and X-API-Key headers,
371 + * matching rest_authorization_middleware().
372 + *
373 + * @return bool True only when a presented key matches the configured one.
374 + */
375 + protected function request_has_valid_api_key()
376 + {
377 + $api_key = '';
378 +
379 + // Primary: Authorization: Bearer <token>. Some Apache/FastCGI setups
380 + // only expose the header under the REDIRECT_ prefix.
381 + $auth_header = '';
382 + if (!empty($_SERVER['HTTP_AUTHORIZATION'])) {
383 + $auth_header = (string) wp_unslash($_SERVER['HTTP_AUTHORIZATION']);
384 + } elseif (!empty($_SERVER['REDIRECT_HTTP_AUTHORIZATION'])) {
385 + $auth_header = (string) wp_unslash($_SERVER['REDIRECT_HTTP_AUTHORIZATION']);
386 + }
387 + if ($auth_header !== '' && preg_match('/^Bearer\s+(.+)$/i', $auth_header, $matches)) {
388 + $api_key = sanitize_text_field($matches[1]);
389 + }
390 +
391 + // Secondary: X-API-Key header.
392 + if ($api_key === '' && !empty($_SERVER['HTTP_X_API_KEY'])) {
393 + $api_key = sanitize_text_field(wp_unslash($_SERVER['HTTP_X_API_KEY']));
394 + }
395 +
396 + if ($api_key === '') {
263 397 return false;
264 - # $apiKey = sanitize_text_field($get_data['apikey']) ?? null;
265 - $apiKey = $get_data['apikey'] ?? null;
398 + }
266 399
267 - $getOptions = Metasync::get_option('general');
268 - $getApiKeyFromSettings = $getOptions['apikey'] ?? null;
400 + $stored_key = $this->get_stored_api_key();
401 + if (empty($stored_key)) {
402 + return false;
403 + }
269 404
270 - if ($apiKey === $getApiKeyFromSettings)
271 - return true;
272 - return false;
405 + return hash_equals((string) $stored_key, $api_key);
273 406 }
274 407
275 408
276 409 public function metasync_register_rest_routes()
@@ -412,9 +545,9 @@
412 545 array(
413 546 array(
414 547 'methods' => 'GET',
415 548 'callback' => function () {
416 - $getPostID = url_to_postid(sanitize_url($_GET['url']));
549 + $getPostID = url_to_postid(sanitize_url(wp_unslash($_GET['url']))); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- REST route authenticated by API key, not cookies
417 550
418 551 if ($getPostID==0) {
419 552 $response = false;
420 553 }else{
@@ -712,14 +845,14 @@
712 845 }
713 846
714 847 # Check if post exists
715 848 if (!$post || $post_id <= 0) {
716 - return rest_ensure_response(array('error' => 'no blog post found'), 404);
849 + return new WP_REST_Response(array('error' => 'no blog post found'), 404);
717 850 }
718 851
719 852 # Check if post type is 'post', return error if not
720 853 if ($post->post_type !== 'post') {
721 - return rest_ensure_response(array('error' => 'only post type is supported'), 400);
854 + return new WP_REST_Response(array('error' => 'only post type is supported'), 400);
722 855 }
723 856
724 857 # Render content
725 858 $post_content = $post->post_content;
@@ -849,9 +982,10 @@
849 982 }
850 983
851 984 # Plugin version and timestamps (ISO 8601 UTC where set)
852 985 $plugin_version = defined('METASYNC_VERSION') ? METASYNC_VERSION : 'unknown';
853 - $last_heartbeat_at = $general_options['last_heartbeat_at'] ?? null;
986 + $_throttle = Metasync::get_heartbeat_throttle();
987 + $last_heartbeat_at = $_throttle['last_heartbeat_at'] ?? ($general_options['last_heartbeat_at'] ?? null);
854 988 $sso_completed_at = $general_options['sso_completed_at'] ?? null;
855 989
856 990 # Prepare response (backward compatible + new fields)
857 991 $response_data = array(
@@ -859,8 +993,9 @@
859 993 'configured' => $is_configured,
860 994 'otto_active' => $is_otto_active,
861 995 'has_api_key' => $has_api_key,
862 996 'has_uuid' => $has_uuid,
997 + 'otto_uuid' => $has_uuid ? $otto_uuid : null,
863 998 'plugin_version' => $plugin_version,
864 999 'last_heartbeat_at' => $last_heartbeat_at,
865 1000 'sso_completed_at' => $sso_completed_at,
866 1001 );
@@ -941,9 +1076,9 @@
941 1076 }
942 1077
943 1078 public function get_errorlogs()
944 1079 {
945 - $get_data = metasync_sanitize_input_array($_GET);
1080 + $get_data = metasync_sanitize_input_array($_GET); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- REST route authenticated by API key, not cookies
946 1081 if (!isset($get_data['limit']))
947 1082 return false;
948 1083 $limit = sanitize_text_field($get_data['limit']) ?? null;
949 1084
@@ -1131,9 +1266,9 @@
1131 1266 }
1132 1267 } catch (Throwable $e) {
1133 1268 // Fallback to DOMDocument if Dom\HTMLDocument fails
1134 1269 if (defined('WP_DEBUG_LOG') && WP_DEBUG_LOG) {
1135 - error_log('MetaSync: Dom\HTMLDocument error, falling back to DOMDocument: ' . $e->getMessage());
1270 + error_log('MetaSync: Dom\HTMLDocument error, falling back to DOMDocument: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-gated, no secrets
1136 1271 }
1137 1272 $dom = new DOMDocument();
1138 1273 libxml_use_internal_errors(true);
1139 1274 $encoded_content = $this->encode_numeric_entities($content);
@@ -1333,9 +1468,9 @@
1333 1468 $image_id = attachment_url_to_postid($node->getAttribute('src') );
1334 1469 $image_alt = get_post_meta($image_id, '_wp_attachment_image_alt', TRUE);
1335 1470 $result ='[et_pb_image src="'.$node->getAttribute('src') .'" url="'.$node->getAttribute('src'). '" _builder_version="'.ET_BUILDER_VERSION.'" _module_preset="default" hover_enabled="0" global_colors_info="{}" sticky_enabled="0"][/et_pb_image]';
1336 1471
1337 - error_log(json_encode($e));
1472 + error_log(json_encode($e)); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
1338 1473
1339 1474 }
1340 1475 }elseif($node->nodeName === 'iframe'){
1341 1476 $result= '[et_pb_code _builder_version="'.ET_BUILDER_VERSION.'" _module_preset="default" global_colors_info="{}"]'.$node->ownerDocument->saveHTML($node).'[/et_pb_code]' ;
@@ -1463,64 +1598,244 @@
1463 1598 return $attachment_id;
1464 1599 }
1465 1600
1466 1601 /**
1467 - * Index a post with Google Indexing API
1468 - *
1469 - * @param int $post_id WordPress post ID
1470 - * @param string $post_type WordPress post type (post, page, etc.)
1471 - * @param string $post_status WordPress post status (publish, draft, etc.)
1602 + * Remove a leading <h1> from post content when it duplicates the post title.
1603 + *
1604 + * Themes that render post_title as an H1 produce a duplicate heading when the
1605 + * synced body also opens with the title. Counterpart of the guard, which
1606 + * only prevents prepending a second H1 but never removes one supplied in the body.
1607 + *
1608 + * @param string $content Post body HTML
1609 + * @param string $title Post title
1610 + * @return string Content without the duplicated leading H1
1472 1611 */
1473 - public function metasync_google_index_post($post_id, $post_type, $post_status)
1612 + private function strip_leading_title_h1($content, $title)
1474 1613 {
1475 - // Only index published posts/pages
1476 - if ($post_status !== 'publish') {
1614 + if (!is_string($content) || trim($content) === '' || trim((string) $title) === '') {
1615 + return $content;
1616 + }
1617 +
1618 + $trimmed = ltrim($content);
1619 + if (!preg_match('/^<h1[^>]*>(.*?)<\/h1>\s*/is', $trimmed, $h1_match)) {
1620 + return $content;
1621 + }
1622 +
1623 + # Entity-decode both sides so "Mobility &amp; Recovery" matches "Mobility & Recovery"
1624 + $h1_text = trim(html_entity_decode(wp_strip_all_tags($h1_match[1]), ENT_QUOTES | ENT_HTML5));
1625 + $title_text = trim(html_entity_decode((string) $title, ENT_QUOTES | ENT_HTML5));
1626 + if ($h1_text === '' || strcasecmp($h1_text, $title_text) !== 0) {
1627 + return $content;
1628 + }
1629 +
1630 + return substr($trimmed, strlen($h1_match[0]));
1631 + }
1632 +
1633 + /**
1634 + * Decide whether the synced body should carry a prepended <h1> title.
1635 + *
1636 + * Replaces the unreliable 7-day hidden-post probe (title_in_headings) as the
1637 + * source of truth for the prepend decision. We only prepend when we have a
1638 + * POSITIVE, render-verified verdict that the active theme does NOT render the
1639 + * post title itself. When the verdict is unknown we default to NOT prepending,
1640 + * because a duplicate H1 (theme title + body title) is a worse, more visible
1641 + * defect than a one-time missing body title on the rare no-title theme. The
1642 + * verdict is learned per post-type by record_theme_title_verdict() after the
1643 + * first sync renders the real post.
1644 + *
1645 + * @param string $post_type Post type being synced
1646 + * @return bool True only when the theme is known to omit the title
1647 + */
1648 + private function should_prepend_title($post_type)
1649 + {
1650 + $general = Metasync::get_option('general');
1651 + if (isset($general['theme_renders_title'][$post_type])) {
1652 + # Verdict known: prepend only when the theme does NOT render the title.
1653 + return $general['theme_renders_title'][$post_type] === false;
1654 + }
1655 + # Unknown verdict: do not prepend (avoid duplicate H1).
1656 + return false;
1657 + }
1658 +
1659 + /**
1660 + * After a post is saved, render its live URL once and record whether
1661 + * the active theme outputs the post title in a heading. The verdict is cached
1662 + * per post-type so subsequent syncs skip the round-trip. Best-effort: any
1663 + * inconclusive fetch (non-200, cache/challenge page, wrong post) records
1664 + * nothing and is retried on the next sync.
1665 + *
1666 + * @param int $post_id Saved post ID
1667 + * @param string $permalink Public permalink of the saved post
1668 + * @param string $post_title Post title to look for
1669 + * @param string $post_type Post type (verdict cache key)
1670 + */
1671 + private function record_theme_title_verdict($post_id, $permalink, $post_title, $post_type)
1672 + {
1673 + if (empty($permalink) || trim((string) $post_title) === '' || empty($post_type)) {
1477 1674 return;
1478 1675 }
1479 1676
1480 - // Only index posts and pages (can be extended as needed)
1481 - $allowed_post_types = array('post', 'page');
1482 - if (!in_array($post_type, $allowed_post_types)) {
1677 + $general = Metasync::get_option('general');
1678 + # Already learned for this post type — nothing to do.
1679 + if (isset($general['theme_renders_title'][$post_type])) {
1483 1680 return;
1484 1681 }
1485 1682
1486 - try {
1487 - // Load Google Index functionality if not already loaded
1488 - if (!function_exists('google_index_post')) {
1489 - $google_index_path = plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php';
1490 - if (file_exists($google_index_path)) {
1491 - require_once $google_index_path;
1492 - } else {
1493 - error_log('MetaSync Google Index: google-index-init.php not found at ' . $google_index_path);
1494 - return;
1495 - }
1683 + $verdict = $this->detect_theme_renders_title($permalink, (int) $post_id, $post_title);
1684 + if ($verdict === null) {
1685 + # Inconclusive fetch: do not poison the cache, retry next sync.
1686 + return;
1687 + }
1688 +
1689 + # Re-read to avoid clobbering concurrent option writes, then persist.
1690 + $options = Metasync::get_option();
1691 + if (!isset($options['general']) || !is_array($options['general'])) {
1692 + $options['general'] = [];
1693 + }
1694 + if (!isset($options['general']['theme_renders_title']) || !is_array($options['general']['theme_renders_title'])) {
1695 + $options['general']['theme_renders_title'] = [];
1696 + }
1697 + $options['general']['theme_renders_title'][$post_type] = $verdict;
1698 + Metasync::set_option($options);
1699 + }
1700 +
1701 + /**
1702 + * Fetch the rendered post and detect whether the theme outputs the
1703 + * title in a heading.
1704 + *
1705 + * @param string $permalink Public permalink
1706 + * @param int $post_id Saved post ID (used to confirm we fetched the right page)
1707 + * @param string $post_title Title to look for
1708 + * @return bool|null True = theme renders the title; false = it does not;
1709 + * null = inconclusive (do not cache).
1710 + */
1711 + private function detect_theme_renders_title($permalink, $post_id, $post_title)
1712 + {
1713 + # Cache-bust so a full-page cache (LiteSpeed, etc.) returns a fresh render
1714 + # rather than a stale 404/challenge — the original cause of the bad verdict.
1715 + $url = add_query_arg('metasync_nocache', (string) time(), $permalink);
1716 +
1717 + $response = wp_remote_get($url, array(
1718 + 'timeout' => 10,
1719 + 'redirection' => 3,
1720 + 'sslverify' => false,
1721 + 'headers' => array(
1722 + 'Cache-Control' => 'no-cache',
1723 + 'Pragma' => 'no-cache',
1724 + ),
1725 + ));
1726 +
1727 + if (is_wp_error($response)) {
1728 + return null;
1729 + }
1730 +
1731 + $code = (int) wp_remote_retrieve_response_code($response);
1732 + $html = wp_remote_retrieve_body($response);
1733 + if ($code !== 200 || empty($html)) {
1734 + return null;
1735 + }
1736 +
1737 + # Confirm we actually rendered THIS post (WordPress emits these on body/article).
1738 + # Without this a cached 404 or bot challenge would falsely record "omits".
1739 + $is_our_post = (stripos($html, 'postid-' . $post_id) !== false)
1740 + || (stripos($html, 'page-id-' . $post_id) !== false)
1741 + || (stripos($html, 'id="post-' . $post_id . '"') !== false);
1742 + if (!$is_our_post) {
1743 + return null;
1744 + }
1745 +
1746 + return $this->title_present_in_headings($html, $post_title);
1747 + }
1748 +
1749 + /**
1750 + * Return true if $title appears within any heading (h1–h6) of $html.
1751 + *
1752 + * @param string $html Rendered page HTML
1753 + * @param string $title Title text to match
1754 + * @return bool
1755 + */
1756 + private function title_present_in_headings($html, $title)
1757 + {
1758 + $title = trim(html_entity_decode((string) $title, ENT_QUOTES | ENT_HTML5));
1759 + if ($title === '' || !class_exists('DOMDocument')) {
1760 + return false;
1761 + }
1762 +
1763 + $dom = new DOMDocument();
1764 + libxml_use_internal_errors(true);
1765 + $dom->loadHTML($html);
1766 + libxml_clear_errors();
1767 +
1768 + $xpath = new DOMXPath($dom);
1769 + $headings = $xpath->query('//h1 | //h2 | //h3 | //h4 | //h5 | //h6');
1770 + if ($headings === false) {
1771 + return false;
1772 + }
1773 +
1774 + foreach ($headings as $heading) {
1775 + $heading_text = trim($heading->textContent);
1776 + if ($heading_text !== '' && stripos($heading_text, $title) !== false) {
1777 + return true;
1496 1778 }
1779 + }
1780 + return false;
1781 + }
1497 1782
1498 - // Attempt to index the post with Google
1499 - if (function_exists('google_index_post')) {
1500 - $result = google_index_post($post_id, $post_type, 'update');
1501 -
1502 - if (isset($result['success']) && $result['success']) {
1503 - error_log(sprintf(
1504 - 'MetaSync Google Index: Successfully indexed %s (ID: %d, Type: %s)',
1505 - get_the_title($post_id),
1506 - $post_id,
1507 - $post_type
1508 - ));
1509 - } else {
1510 - error_log(sprintf(
1511 - 'MetaSync Google Index: Failed to index %s (ID: %d, Type: %s) - %s',
1512 - get_the_title($post_id),
1513 - $post_id,
1514 - $post_type,
1515 - isset($result['error']['message']) ? $result['error']['message'] : 'Unknown error'
1516 - ));
1517 - }
1518 - }
1519 - } catch (Exception $e) {
1520 - // Log any exceptions but don't break the main functionality
1521 - error_log('MetaSync Google Index Exception: ' . $e->getMessage());
1783 + /**
1784 + * Find a post this plugin created that has the given title.
1785 + *
1786 + * Only posts flagged metasync_post=yes are considered, so a customer's own
1787 + * page that happens to share the title is never picked up and overwritten.
1788 + *
1789 + * @param string $title Post title to match exactly
1790 + * @param string $post_type Post type to search
1791 + * @return int|null Matching post ID, or null when there is none
1792 + */
1793 + private function find_plugin_post_by_title($title, $post_type)
1794 + {
1795 + $query = new WP_Query(
1796 + array(
1797 + 'post_type' => $post_type,
1798 + 'title' => $title,
1799 + 'post_status' => array('publish', 'draft', 'pending'),
1800 + 'meta_key' => 'metasync_post',
1801 + 'meta_value' => 'yes',
1802 + 'posts_per_page' => 2,
1803 + 'no_found_rows' => true,
1804 + 'fields' => 'ids',
1805 + 'orderby' => 'ID',
1806 + 'order' => 'ASC',
1807 + )
1808 + );
1809 +
1810 + // Never guess when duplicate flagged posts share a title.
1811 + return 1 === count($query->posts) ? (int) $query->posts[0] : null;
1812 + }
1813 +
1814 + /**
1815 + * Limit what a create call may change on a post it only matched by title.
1816 + *
1817 + * A title match is a guess, so it must never take a page offline or move
1818 + * its URL. A post that is live keeps its status and slug. A post that has
1819 + * never been published may be published, and may take the requested slug
1820 + * because nothing links to it yet.
1821 + *
1822 + * @param array $post_args Arguments about to be passed to wp_update_post()
1823 + * @param string $existing_status Current status of the matched post
1824 + * @return array Arguments with any unsafe status/slug change removed
1825 + */
1826 + private function protect_title_matched_post(array $post_args, $existing_status)
1827 + {
1828 + if (!in_array($existing_status, array('draft', 'pending'), true)) {
1829 + unset($post_args['post_status'], $post_args['post_name']);
1830 + return $post_args;
1522 1831 }
1832 +
1833 + if (isset($post_args['post_status']) && 'publish' !== $post_args['post_status']) {
1834 + unset($post_args['post_status']);
1835 + }
1836 +
1837 + return $post_args;
1523 1838 }
1524 1839
1525 1840 public function create_item($request)
1526 1841 {
@@ -1555,8 +1870,9 @@
1555 1870 check if the create_item is called by set_landing_page function or not
1556 1871 by doing this we will prevent html from going into builder page option
1557 1872 */
1558 1873 $isOttoAiPage = !empty($item['otto_ai_page']) && filter_var($item['otto_ai_page'], FILTER_VALIDATE_BOOLEAN);
1874 + $content = [];
1559 1875 if(!isset($item['is_landing_page']) && !$isOttoAiPage && empty($item['style_data']) ){
1560 1876
1561 1877 # Get Current Post type
1562 1878 $current_post_type = isset($item['post_type']) ? sanitize_text_field($item['post_type']) : 'post';
@@ -1574,27 +1890,31 @@
1574 1890 } elseif (!empty($theme_template) && stripos($theme_template, 'flatsome') !== false) {
1575 1891 $is_flatsome_theme = true;
1576 1892 }
1577 1893
1894 + # Always strip a leading H1 that duplicates the title. The
1895 + # theme renders post_title in its header on virtually every theme, so a
1896 + # title H1 at the top of the body is a duplicate. Whether we then put one
1897 + # back is decided by should_prepend_title() below.
1898 + if (isset($item['post_title'])) {
1899 + $item['post_content'] = $this->strip_leading_title_h1($item['post_content'], $item['post_title']);
1900 + }
1901 +
1578 1902 # Skip title/image prepending for Flatsome (it displays them by default)
1579 1903 if (!$is_flatsome_theme) {
1580 - # Get the setting for the post template
1904 + # Get the setting for the post template (feature-image detection)
1581 1905 $title_and_feature_image = $this->append_content_if_missing_elements($current_post_type);
1582 1906
1583 - # Check if the post title is there in the template or not
1907 + # Prepend the feature image when the template does not already show it
1584 1908 if(!$title_and_feature_image['image_in_content'] && !empty($item['hero_image_url'])){
1585 -
1586 - # Prepend the feature image
1587 - $item['post_content'] = '<img src="'.$item['hero_image_url'].'" />'.$item['post_content'] ;
1909 + $item['post_content'] = '<img src="' . esc_url_raw($item['hero_image_url']) . '" />'.$item['post_content'] ;
1588 1910 }
1589 1911
1590 - # Check if the post title is there in the template or not
1591 - if(!$title_and_feature_image['title_in_headings']){
1592 -
1593 - # Prepend the post title
1912 + # Only prepend the title H1 when the theme is render-verified to
1913 + # omit it. Unknown verdict defaults to NOT prepending (see should_prepend_title).
1914 + if(isset($item['post_title']) && $this->should_prepend_title($current_post_type)){
1594 1915 $item['post_content'] = '<h1>'.$item['post_title'].'</h1>'.$item['post_content'] ;
1595 1916 }
1596 -
1597 1917 }
1598 1918 # This will be used by create_page function
1599 1919 $content = $this->metasync_upload_post_content($item,false,false);
1600 1920 }elseif(isset($item['is_landing_page']) && $item['is_landing_page'] == true){
@@ -1628,13 +1948,13 @@
1628 1948 $new_post['post_name'] = sanitize_text_field($item['permalink']);
1629 1949 }
1630 1950
1631 1951 if (isset($item['post_date']) && !empty($item['post_date'])) {
1632 - $is_valid_date = date('Y-m-d', strtotime($item['post_date'])) === $item['post_date'];
1952 + $is_valid_date = gmdate('Y-m-d', strtotime($item['post_date'])) === $item['post_date'];
1633 1953 if (!$is_valid_date) {
1634 1954 return new WP_Error(
1635 1955 'rest_post_invalid_date',
1636 - esc_html__('Post date is not valid'),
1956 + esc_html__('Post date is not valid', 'metasync'),
1637 1957 array('status' => 400)
1638 1958 );
1639 1959 }
1640 1960
@@ -1667,20 +1987,16 @@
1667 1987 # $getPostID_byURL = @get_page_by_path($item['permalink'], OBJECT, $new_post['post_type'])->ID;
1668 1988 # Fix to avoid PHP error if the get_page_by_path returns null
1669 1989 $getPostID_byURL = @get_page_by_path($item['permalink'], OBJECT, $new_post['post_type']);
1670 1990 $getPostID_byURL = $getPostID_byURL ? $getPostID_byURL->ID : null;
1991 + $matched_by_title = false;
1671 1992 if ($getPostID_byURL == NULL) {
1672 1993 // check if the post_title is set and not empty when called by otto_ai_page
1673 1994 if(isset($new_post['post_title']) && $new_post['post_title']!==''){
1674 - $getPostID_byURL = new WP_Query(
1675 - array(
1676 - 'post_type' => $new_post['post_type'],
1677 - 'title' => $new_post['post_title']
1678 - )
1679 - );
1680 - $getPostID_byURL = $getPostID_byURL->posts[0]->ID ?? null;
1995 + $getPostID_byURL = $this->find_plugin_post_by_title($new_post['post_title'], $new_post['post_type']);
1996 + $matched_by_title = (NULL !== $getPostID_byURL);
1997 + }
1681 1998 }
1682 - }
1683 1999
1684 2000 // Allow HTML code for landing page
1685 2001 if (isset($item['is_landing_page']) && $item['is_landing_page'] == true) {
1686 2002 kses_remove_filters();
@@ -1705,11 +2021,17 @@
1705 2021 update_post_meta($post_id, 'metasync_post', 'yes');
1706 2022 }
1707 2023
1708 2024 } else {
1709 - $new_post['ID'] = $post_id = $getPostID_byURL;
1710 - wp_update_post($new_post);
1711 - unset($new_post['ID']);
2025 + $post_id = $getPostID_byURL;
2026 + $update_post = $new_post;
2027 + $update_post['ID'] = $post_id;
2028 + if ($matched_by_title) {
2029 + $update_post = $this->protect_title_matched_post($update_post, get_post_status($post_id));
2030 + }
2031 + wp_update_post($update_post);
2032 + // Report what was actually saved, not what was requested.
2033 + $new_post['post_status'] = get_post_status($post_id);
1712 2034 $permalink = get_permalink($post_id);
1713 2035 }
1714 2036
1715 2037 if (isset($item['is_landing_page']) && $item['is_landing_page'] == true) {
@@ -1751,13 +2073,17 @@
1751 2073 $post_meta['searchatlas_embed_bottom'] = $item['searchatlas_embed_bottom'];
1752 2074 }
1753 2075
1754 2076 // Add custom fields to posts and pages
1755 - foreach ($post_meta as $key => $value) {
1756 - // if (!empty($value) && !is_null($value)) {
1757 - add_post_meta($post_id, $key, $value, true);
1758 - // }
1759 - }
2077 + $this->metasync_apply_builder_meta($post_id, $post_meta, false);
2078 +
2079 + // A freshly inserted post has no template of its own yet; match it to
2080 + // the one this site uses for its other posts.
2081 + $this->metasync_adopt_site_post_template(
2082 + $post_id,
2083 + isset($new_post['post_type']) ? $new_post['post_type'] : 'post',
2084 + (NULL === $getPostID_byURL)
2085 + );
1760 2086
1761 2087
1762 2088 $attachment_id = '';
1763 2089 if (isset($item['hero_image_url']) && !empty($item['hero_image_url'])) {
@@ -1775,8 +2101,14 @@
1775 2101 $redirection['url'] = sanitize_url($item['redirection_url']);
1776 2102 }
1777 2103 if (!empty($redirection)) {
1778 2104 update_post_meta($post_id, 'metasync_post_redirection_meta', $redirection);
2105 + // REST/Gutenberg saves never reach the metabox nonce path, so
2106 + // the stored meta would never materialize a redirect row.
2107 + // Sync it here, same as the classic save hook does.
2108 + if (class_exists('Metasync_Post_Meta_Settings')) {
2109 + Metasync_Post_Meta_Settings::sync_post_redirect_rule($post_id, $redirection);
2110 + }
1779 2111 }
1780 2112
1781 2113 $post_cattegories = [];
1782 2114 # if ($new_post['post_type'] === 'post' && is_array(@$item['post_categories'])) {
@@ -1832,17 +2164,28 @@
1832 2164 'action' => strtolower($action)
1833 2165 ])
1834 2166 ]);
1835 2167
1836 - # Track Content Genius event in GA4
2168 + # Both pipelines roll Content Genius syncs up to one hourly event
2169 + # with counts so a bulk sync of N articles stays a single send.
1837 2170 try {
1838 2171 Metasync_GA4::get_instance()->track_content_genius_event($post_id, strtolower($action));
2172 + Metasync_PostHog::content_genius_sync($new_post['post_type'], strtolower($action), !empty($item['is_landing_page']));
1839 2173 } catch (Exception $e) {
1840 - error_log('MetaSync: Analytics tracking failed for Content Genius - ' . $e->getMessage());
2174 + error_log('MetaSync: Analytics tracking failed for Content Genius - ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
1841 2175 }
2176 + }
1842 2177
1843 - // Google Indexing Integration
1844 - $this->metasync_google_index_post($post_id, $new_post['post_type'], $new_post['post_status']);
2178 + # For standard synced posts, learn (once per post-type) whether the
2179 + # theme renders the title, so future syncs prepend only when genuinely needed.
2180 + if (!is_wp_error($post_id) && $post_id > 0
2181 + && !isset($item['is_landing_page']) && empty($isOttoAiPage) && empty($item['style_data'])
2182 + && $new_post['post_status'] === 'publish') {
2183 + try {
2184 + $this->record_theme_title_verdict($post_id, $permalink, $new_post['post_title'], $new_post['post_type']);
2185 + } catch (Exception $e) {
2186 + error_log('MetaSync title verdict failed: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
2187 + }
1845 2188 }
1846 2189
1847 2190 $respCreatePosts[$index] = array_merge($new_post, $post_meta);
1848 2191 ksort($respCreatePosts[$index]);
@@ -1886,9 +2229,9 @@
1886 2229 }
1887 2230
1888 2231 public function delete_item()
1889 2232 {
1890 - $get_data = metasync_sanitize_input_array($_GET);
2233 + $get_data = metasync_sanitize_input_array($_GET); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- REST route authenticated by API key, not cookies
1891 2234 if (!isset($get_data['ID']))
1892 2235 return false;
1893 2236
1894 2237 $post_id = sanitize_text_field($get_data['ID']) ?? null;
@@ -1894,18 +2237,18 @@
1894 2237 $post_id = sanitize_text_field($get_data['ID']) ?? null;
1895 2238 $post = get_post($post_id);
1896 2239 if ($post) {
1897 2240 wp_delete_post($post_id);
2241 + // HTTP 204 requires no body for response, so no message is set.
1898 2242 return new WP_Error(
1899 2243 'rest_post_delete_success',
1900 - esc_html__(''),
1901 - // HTTP 204 requires no body for response
2244 + '',
1902 2245 array('status' => 204)
1903 2246 );
1904 2247 }
1905 2248 return new WP_Error(
1906 2249 'rest_post_delete_fail',
1907 - esc_html__('No post found in the database with requested ID.'),
2250 + esc_html__('No post found in the database with requested ID.', 'metasync'),
1908 2251 array('status' => 400)
1909 2252 );
1910 2253 }
1911 2254
@@ -1910,9 +2253,9 @@
1910 2253 }
1911 2254
1912 2255 public function get_items($request)
1913 2256 {
1914 - $get_data = metasync_sanitize_input_array($_GET);
2257 + $get_data = metasync_sanitize_input_array($_GET); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- REST route authenticated by API key, not cookies
1915 2258
1916 2259 # let us check if request send post id and is valid int
1917 2260 if(isset($get_data['post_id']) AND intval($get_data['post_id']) > 0){
1918 2261
@@ -2252,9 +2595,9 @@
2252 2595 $post_data = get_post($post_id);
2253 2596 if (!$post_data) {
2254 2597 return new WP_Error(
2255 2598 'rest_post_update_fail',
2256 - esc_html__('No post found in the database with requested ID.'),
2599 + esc_html__('No post found in the database with requested ID.', 'metasync'),
2257 2600 array('status' => 400)
2258 2601 );
2259 2602 }
2260 2603
@@ -2279,12 +2622,19 @@
2279 2622 $update_params['meta_robots'] = $post['meta_robots'] ?
2280 2623 sanitize_text_field($post['meta_robots']) : $get_robots_meta;
2281 2624 }
2282 2625 if (isset($post['meta_canonical']) && !empty($post['meta_canonical'])) {
2283 - $update_params['meta_canonical'] = sanitize_text_field($post['meta_canonical']);
2626 + // Validate as a URL: drop unusable values instead of
2627 + // storing text that would later be emitted as a canonical.
2628 + $clean_canonical = Metasync_Canonical_Sanitizer::sanitize_for_save($post['meta_canonical']);
2629 + if ($clean_canonical !== '') {
2630 + $update_params['meta_canonical'] = $clean_canonical;
2631 + }
2284 2632 }
2285 2633
2286 2634 $isOttoAiPage = !empty($post['otto_ai_page']) && filter_var($post['otto_ai_page'], FILTER_VALIDATE_BOOLEAN);
2635 + $content = [];
2636 + $permalink = '';
2287 2637 if (isset($post['post_content']) && !empty($post['post_content']) && !$isOttoAiPage) {
2288 2638
2289 2639 # Above we are updating the post_type so we have to get latest value that has been change on the server
2290 2640 $post_fresh_data = get_post($post['post_id']);
@@ -2303,25 +2653,29 @@
2303 2653 } elseif (!empty($theme_template) && stripos($theme_template, 'flatsome') !== false) {
2304 2654 $is_flatsome_theme = true;
2305 2655 }
2306 2656
2657 + # Title to compare against — payload may omit post_title on update
2658 + $compare_title = isset($post['post_title']) && !empty($post['post_title']) ? $post['post_title'] : $post_fresh_data->post_title;
2659 +
2660 + # Always strip a leading H1 that duplicates the title; the theme
2661 + # renders post_title in its header, so a body title H1 is a duplicate.
2662 + $post['post_content'] = $this->strip_leading_title_h1($post['post_content'], $compare_title);
2663 +
2307 2664 # Skip title/image prepending for Flatsome (it displays them by default)
2308 2665 if (!$is_flatsome_theme) {
2309 - # Get the setting for the post template
2666 + # Get the setting for the post template (feature-image detection)
2310 2667 $title_and_feature_image = $this->append_content_if_missing_elements($post_fresh_data->post_type);
2311 2668
2312 - # Check if the post title is there in the template or not
2669 + # Prepend the feature image when the template does not already show it
2313 2670 if(!$title_and_feature_image['image_in_content'] && !empty($post['hero_image_url'])){
2314 -
2315 - # Prepend the feature image
2316 - $post['post_content'] = '<img src="'.$post['hero_image_url'].'" />'.$post['post_content'] ;
2671 + $post['post_content'] = '<img src="' . esc_url_raw($post['hero_image_url']) . '" />'.$post['post_content'] ;
2317 2672 }
2318 2673
2319 - # Check if the post title is there in the template or not
2320 - if(!$title_and_feature_image['title_in_headings']){
2321 -
2322 - # Prepend the post title
2323 - $post['post_content'] = '<h1>'.$post['post_title'].'</h1>'.$post['post_content'] ;
2674 + # Only prepend the title H1 when the theme is render-verified to
2675 + # omit it. Unknown verdict defaults to NOT prepending (see should_prepend_title).
2676 + if($this->should_prepend_title($post_fresh_data->post_type)){
2677 + $post['post_content'] = '<h1>'.$compare_title.'</h1>'.$post['post_content'] ;
2324 2678 }
2325 2679 }
2326 2680 // This will be used by update_page function
2327 2681 $content = $this->metasync_upload_post_content($post,false,false);
@@ -2364,9 +2718,8 @@
2364 2718 }
2365 2719
2366 2720 if (isset($post['post_status']) && !empty($post['post_status'])) {
2367 2721 $update_params['post_status'] = $post['post_status'] ? sanitize_text_field($post['post_status']) : 'publish';
2368 - $permalink = get_permalink($post_id);
2369 2722 }
2370 2723 if (isset($post['permalink']) || !empty($post['permalink'])) {
2371 2724 $update_params['post_name'] = sanitize_text_field($post['permalink']);
2372 2725 }
@@ -2381,40 +2734,8 @@
2381 2734 );
2382 2735 }
2383 2736
2384 2737
2385 - if (isset($post['post_date']) && !empty($post['post_date']) && false) {
2386 - $is_valid_date = date('Y-m-d', strtotime($post['post_date'])) == $post['post_date'];
2387 - if (!$is_valid_date) {
2388 - return new WP_Error(
2389 - 'rest_post_invalid_date',
2390 - esc_html__('Post date is not valid'),
2391 - array('status' => 400)
2392 - );
2393 - }
2394 -
2395 - $date_limit_str = strtotime(date('Y-m-d') . '-2 month');
2396 - $post_date_str = strtotime($post['post_date']);
2397 -
2398 - if ($date_limit_str >= $post_date_str) {
2399 - $newDate = date('Y-m-d', strtotime('-2 month'));
2400 - return new WP_Error(
2401 - 'rest_post_greater_date',
2402 - esc_html__("Post date should be greater then " . $newDate),
2403 - array('status' => 400)
2404 - );
2405 - }
2406 -
2407 - if ($post_date_str > strtotime(date('Y-m-d'))) {
2408 - return new WP_Error(
2409 - 'rest_post_greater_date',
2410 - esc_html__('Post date should be less then Today'),
2411 - array('status' => 400)
2412 - );
2413 - }
2414 - $update_params['post_date'] = sanitize_text_field($post['post_date'] . date(' h:i:s'));
2415 - }
2416 -
2417 2738 $post_cattegories = [];
2418 2739 # if ($post_data && $post_data->post_type === 'post' && is_array(@$post['post_categories'])) {
2419 2740
2420 2741 # fixed Undefined array key issue
@@ -2437,12 +2758,13 @@
2437 2758 $attachment_id = $this->metasync_handle_hero_image($post_id, $post['hero_image_url'], $post['hero_image_alt_text']);
2438 2759 }
2439 2760
2440 2761 $resp_update = $this->update_object($post_id, $update_params);
2762 + // Read the permalink only after the new slug and status are saved;
2763 + // before that it is the old URL (or ?p=ID for a draft).
2764 + $permalink = get_permalink($post_id);
2441 2765 if(isset($content['elementor_meta_data'])){
2442 - foreach ($content['elementor_meta_data'] as $key => $value) {
2443 - update_post_meta($post_id, $key, $value);
2444 - }
2766 + $this->metasync_apply_builder_meta($post_id, $content['elementor_meta_data']);
2445 2767 if ( did_action( 'elementor/loaded' ) ) {
2446 2768 // Clear Elementor cache for the specified post ID
2447 2769 \Elementor\Plugin::instance()->files_manager->clear_cache();
2448 2770 }
@@ -2457,8 +2779,15 @@
2457 2779 delete_post_meta($post_id, '_elementor_page_assets');
2458 2780 delete_post_meta($post_id, '_elementor_page_settings');
2459 2781 }
2460 2782
2783 + // Existing post: records the template decision without changing it.
2784 + $this->metasync_adopt_site_post_template(
2785 + $post_id,
2786 + get_post_type($post_id),
2787 + false
2788 + );
2789 +
2461 2790 $redirection = array();
2462 2791 if (!empty($post['redirection_enable']) && !is_null($post['redirection_enable'])) {
2463 2792 $redirection['enable'] = sanitize_text_field($post['redirection_enable']);
2464 2793 }
@@ -2469,8 +2798,14 @@
2469 2798 $redirection['url'] = sanitize_url($post['redirection_url']);
2470 2799 }
2471 2800 if (!empty($redirection)) {
2472 2801 update_post_meta($post_id, 'metasync_post_redirection_meta', $redirection);
2802 + // REST/Gutenberg saves never reach the metabox nonce path, so
2803 + // the stored meta would never materialize a redirect row.
2804 + // Sync it here, same as the classic save hook does.
2805 + if (class_exists('Metasync_Post_Meta_Settings')) {
2806 + Metasync_Post_Meta_Settings::sync_post_redirect_rule($post_id, $redirection);
2807 + }
2473 2808 }
2474 2809
2475 2810
2476 2811 $post_revisions = wp_get_post_revisions($post_id);
@@ -2477,9 +2812,8 @@
2477 2812 // Sync post categories to customer dashboard
2478 2813 $this->lgSendCustomerPostParams();
2479 2814
2480 2815 unset($update_params['post_name']);
2481 - unset($update_params['post_category']);
2482 2816
2483 2817 $update_params['post_categories'] = $post_cattegories;
2484 2818 $update_params['post_tags'] = $post_tags;
2485 2819 $update_params['post_id'] = (int) $post_id;
@@ -2509,14 +2843,9 @@
2509 2843
2510 2844 }
2511 2845 # update the content
2512 2846 // add and update the post meta
2513 - foreach ($post_meta_data as $key => $value) {
2514 - // if (!empty($value) && !is_null($value)) {
2515 -
2516 - update_post_meta($post_id, $key, $value);
2517 - //
2518 - }
2847 + $this->metasync_apply_builder_meta($post_id, $post_meta_data);
2519 2848 #check if the elementor plugin is active
2520 2849 if ( did_action( 'elementor/loaded' ) ) {
2521 2850 # Clear Elementor cache for the specified post ID
2522 2851 \Elementor\Plugin::instance()->files_manager->clear_cache();
@@ -2549,14 +2878,26 @@
2549 2878 'updated_fields' => array_keys($update_params)
2550 2879 ])
2551 2880 ]);
2552 2881
2553 - # Track Content Genius event in GA4
2882 + # Track Content Genius event in GA4 and PostHog
2554 2883 try {
2555 2884 Metasync_GA4::get_instance()->track_content_genius_event($post_id, 'updated');
2885 + Metasync_PostHog::content_genius_sync($post_type, 'updated', !empty($post['is_landing_page']));
2556 2886 } catch (Exception $e) {
2557 - error_log('MetaSync: Analytics tracking failed for Content Genius - ' . $e->getMessage());
2887 + error_log('MetaSync: Analytics tracking failed for Content Genius - ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
2558 2888 }
2889 +
2890 + # Learn (once per post-type) whether the theme renders the title,
2891 + # so future syncs prepend only when the theme genuinely omits it.
2892 + if ($post_id > 0 && empty($post['is_landing_page']) && empty($isOttoAiPage)
2893 + && $sync_status === 'published') {
2894 + try {
2895 + $this->record_theme_title_verdict($post_id, ($permalink ?? get_permalink($post_id)), $post_title, $post_type);
2896 + } catch (Exception $e) {
2897 + error_log('MetaSync title verdict failed: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
2898 + }
2899 + }
2559 2900 }
2560 2901
2561 2902 ksort($update_params);
2562 2903 $data[] = $update_params;
@@ -2651,20 +2992,9 @@
2651 2992 if($isOttoAiPage){
2652 2993 // Change the page template from default to Metasync Template
2653 2994 $pageTemplate = Metasync_Template::TEMPLATE_NAME;
2654 2995 }
2655 - if ($pageTemplate !== 'default') {
2656 - update_post_meta($post_id, '_wp_page_template', $pageTemplate);
2657 - } else {
2658 - // Clear stale templates that conflict with the active page builder.
2659 - // e.g. metasync-blank from a previous OTTO sync, or elementor_canvas
2660 - // written by the HTML-to-builder converter on an Oxygen site.
2661 - $current = get_post_meta($post_id, '_wp_page_template', true);
2662 - $stale_templates = array(Metasync_Template::TEMPLATE_NAME, 'elementor_canvas', 'elementor_header_footer');
2663 - if (in_array($current, $stale_templates, true)) {
2664 - delete_post_meta($post_id, '_wp_page_template');
2665 - }
2666 - }
2996 + $this->metasync_write_page_template($post_id, $pageTemplate);
2667 2997 }
2668 2998
2669 2999 return rest_ensure_response($createPages);
2670 3000 }
@@ -2687,9 +3017,9 @@
2687 3017 $post_data = get_post($payload['post_id']);
2688 3018 if(!isset($post_data->post_type)){
2689 3019 return new WP_Error(
2690 3020 'rest_page_type_fail',
2691 - esc_html__('No page found in the database with requested ID.'),
3021 + esc_html__('No page found in the database with requested ID.', 'metasync'),
2692 3022 array('status' => 400)
2693 3023 );
2694 3024 }
2695 3025 if ($post_data->post_type !== 'page') {
@@ -2744,20 +3074,225 @@
2744 3074 if($isOttoAiPage){
2745 3075 // Change the page template from default to Metasync Template
2746 3076 $pageTemplate = Metasync_Template::TEMPLATE_NAME;
2747 3077 }
2748 - if ($pageTemplate !== 'default') {
2749 - update_post_meta($post_id, '_wp_page_template', $pageTemplate);
3078 + $this->metasync_write_page_template($post_id, $pageTemplate);
3079 + }
3080 + return rest_ensure_response($updatePages->data);
3081 + }
3082 +
3083 + /**
3084 + * The page template this site actually uses for a given post type.
3085 + *
3086 + * Content Genius has no idea which template a site's posts are built on, and
3087 + * neither does the converter. Hardcoding one downgraded every synced post;
3088 + * leaving the field empty is no better, because WordPress then falls back to
3089 + * the theme's default single template -- on a theme-builder site that is a
3090 + * bare, unstyled layout.
3091 + *
3092 + * So infer it: whatever the clear majority of this site's existing published
3093 + * entries of the same type already use.
3094 + *
3095 + * @param string $post_type
3096 + * @return string Template slug, or '' when the site has no clear convention.
3097 + */
3098 + private function metasync_prevailing_post_template($post_type)
3099 + {
3100 + $post_type = sanitize_key($post_type);
3101 +
3102 + if ('' === $post_type) {
3103 + return '';
3104 + }
3105 +
3106 + $cache_key = 'metasync_prevailing_tpl_' . $post_type;
3107 + $cached = get_transient($cache_key);
3108 +
3109 + if (false !== $cached) {
3110 + return is_string($cached) ? $cached : '';
3111 + }
3112 +
3113 + global $wpdb;
3114 +
3115 + // Never propagate a template this plugin wrote itself.
3116 + $owned = $this->metasync_plugin_owned_templates();
3117 +
3118 + $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint over the plugin custom table — no WordPress API exists
3119 + $wpdb->prepare(
3120 + 'SELECT pm.meta_value AS tpl, COUNT(*) AS hits
3121 + FROM ' . $wpdb->postmeta . ' pm
3122 + INNER JOIN ' . $wpdb->posts . " p ON p.ID = pm.post_id
3123 + WHERE pm.meta_key = '_wp_page_template'
3124 + AND pm.meta_value <> ''
3125 + AND pm.meta_value <> 'default'
3126 + AND pm.meta_value NOT IN (" . implode(',', array_fill(0, count($owned), '%s')) . ")
3127 + AND p.post_type = %s
3128 + AND p.post_status = 'publish'
3129 + GROUP BY pm.meta_value
3130 + ORDER BY hits DESC",
3131 + array_merge($owned, array($post_type))
3132 + )
3133 + );
3134 +
3135 + $prevailing = '';
3136 +
3137 + if (!empty($rows)) {
3138 + // Measure the majority against every published entry of this type,
3139 + // not just the ones that set a template. Counting only the latter
3140 + // would let a handful of outliers on a special template read as a
3141 + // site-wide convention on a site whose posts otherwise use the
3142 + // theme default.
3143 + $total = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint over the plugin custom table — no WordPress API exists
3144 + $wpdb->prepare(
3145 + "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = %s AND post_status = 'publish'",
3146 + $post_type
3147 + )
3148 + );
3149 +
3150 + $top = (int) $rows[0]->hits;
3151 +
3152 + // Only adopt a convention that is genuinely a convention: several
3153 + // posts, and a clear majority of the site's published entries.
3154 + if ($top >= 3 && $total > 0 && ($top / $total) >= 0.6) {
3155 + $prevailing = (string) $rows[0]->tpl;
3156 + }
3157 + }
3158 +
3159 + set_transient($cache_key, $prevailing, 6 * HOUR_IN_SECONDS);
3160 +
3161 + return $prevailing;
3162 + }
3163 +
3164 + /**
3165 + * Give a synced post the template the rest of the site uses.
3166 + *
3167 + * Only ever fills a blank -- an existing choice is never touched.
3168 + *
3169 + * Beyond brand-new inserts this also covers two cases that cannot regress
3170 + * anything a visitor is currently seeing: posts this plugin created itself
3171 + * (marked with `metasync_post`), and posts that have never been published.
3172 + * Everything else -- content the site author built by hand -- is left alone.
3173 + *
3174 + * @param int $post_id
3175 + * @param string $post_type
3176 + * @param bool $is_new Whether this sync actually inserted the post.
3177 + * @return void
3178 + */
3179 + private function metasync_adopt_site_post_template($post_id, $post_type, $is_new)
3180 + {
3181 + if (!$post_id) {
3182 + return;
3183 + }
3184 +
3185 + $current = (string) get_post_meta($post_id, '_wp_page_template', true);
3186 +
3187 + if ('' !== $current && 'default' !== $current) {
3188 + return;
3189 + }
3190 +
3191 + $is_plugin_post = ('yes' === get_post_meta($post_id, 'metasync_post', true));
3192 + $never_published = in_array(get_post_status($post_id), array('draft', 'pending', 'auto-draft'), true);
3193 +
3194 + if (!$is_new && !$is_plugin_post && !$never_published) {
3195 + return;
3196 + }
3197 +
3198 + $prevailing = $this->metasync_prevailing_post_template($post_type);
3199 +
3200 + if ('' === $prevailing) {
3201 + return;
3202 + }
3203 +
3204 + update_post_meta($post_id, '_wp_page_template', $prevailing);
3205 + }
3206 +
3207 + /**
3208 + * Page templates this plugin writes itself.
3209 + *
3210 + * Only these may be cleared when a sync carries the neutral 'default' value.
3211 + * Anything else stored in `_wp_page_template` was chosen in the editor and
3212 + * has to survive the sync -- notably 'elementor_header_footer' (Elementor
3213 + * Full Width), which the plugin never writes, so finding it there always
3214 + * means the user selected it.
3215 + *
3216 + * @return array
3217 + */
3218 + private function metasync_plugin_owned_templates()
3219 + {
3220 + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-template.php';
3221 +
3222 + return array(Metasync_Template::TEMPLATE_NAME, 'elementor_canvas');
3223 + }
3224 +
3225 + /**
3226 + * Whether converter-generated builder meta may set `_wp_page_template`.
3227 + *
3228 + * Everything reaching this point was asserted by the converter, not chosen
3229 + * by anyone -- including 'elementor_canvas', which it emits for every page.
3230 + * So the incoming value carries no authority: builder meta may fill a blank
3231 + * and may replace a template this plugin wrote itself, but it must never
3232 + * overwrite one selected in the editor.
3233 + *
3234 + * Deliberate templates (OTTO pages, landing pages) do not come through here;
3235 + * they are written by metasync_write_page_template().
3236 + *
3237 + * @param int $post_id
3238 + * @return bool
3239 + */
3240 + private function metasync_can_write_page_template($post_id)
3241 + {
3242 + $current = get_post_meta($post_id, '_wp_page_template', true);
3243 +
3244 + // Nothing worth keeping.
3245 + if ('' === $current || 'default' === $current) {
3246 + return true;
3247 + }
3248 +
3249 + // Only replace leftovers this plugin put there in the first place.
3250 + return in_array($current, $this->metasync_plugin_owned_templates(), true);
3251 + }
3252 +
3253 + /**
3254 + * Store a page template, preserving one the user selected.
3255 + *
3256 + * @param int $post_id
3257 + * @param string $pageTemplate
3258 + * @return void
3259 + */
3260 + private function metasync_write_page_template($post_id, $pageTemplate)
3261 + {
3262 + if ('default' !== $pageTemplate) {
3263 + update_post_meta($post_id, '_wp_page_template', $pageTemplate);
3264 + return;
3265 + }
3266 +
3267 + $current = get_post_meta($post_id, '_wp_page_template', true);
3268 +
3269 + if ('' !== $current && in_array($current, $this->metasync_plugin_owned_templates(), true)) {
3270 + delete_post_meta($post_id, '_wp_page_template');
3271 + }
3272 + }
3273 +
3274 + /**
3275 + * Apply builder meta while protecting the post's chosen page template.
3276 + *
3277 + * @param int $post_id
3278 + * @param array $meta
3279 + * @param bool $overwrite Replace existing meta, or only add when absent.
3280 + * @return void
3281 + */
3282 + private function metasync_apply_builder_meta($post_id, array $meta, $overwrite = true)
3283 + {
3284 + foreach ($meta as $key => $value) {
3285 + if ('_wp_page_template' === $key && !$this->metasync_can_write_page_template($post_id)) {
3286 + continue;
3287 + }
3288 +
3289 + if ($overwrite) {
3290 + update_post_meta($post_id, $key, $value);
2750 3291 } else {
2751 - // Clear stale templates that conflict with the active page builder.
2752 - $current = get_post_meta($post_id, '_wp_page_template', true);
2753 - $stale_templates = array(Metasync_Template::TEMPLATE_NAME, 'elementor_canvas', 'elementor_header_footer');
2754 - if (in_array($current, $stale_templates, true)) {
2755 - delete_post_meta($post_id, '_wp_page_template');
2756 - }
3292 + add_post_meta($post_id, $key, $value, true);
2757 3293 }
2758 3294 }
2759 - return rest_ensure_response($updatePages->data);
2760 3295 }
2761 3296
2762 3297 public function delete_page()
2763 3298 {
@@ -2772,13 +3307,13 @@
2772 3307 {
2773 3308 $sync_request = new Metasync_Sync_Requests();
2774 3309 $response = $sync_request->SyncCustomerParams();
2775 3310
2776 - $responseCode = wp_remote_retrieve_response_code($response);
3311 + $responseCode = Metasync_Sync_Requests::get_response_code($response);
2777 3312 if ($responseCode == 200) {
2778 - $dt = new DateTime();
3313 + # Use current_time('mysql') for consistency with cron heartbeat.
2779 3314 $send_auth_token_timestamp = Metasync::get_option();
2780 - $send_auth_token_timestamp['general']['send_auth_token_timestamp'] = $dt->format('M d, Y h:i:s A');
3315 + $send_auth_token_timestamp['general']['send_auth_token_timestamp'] = current_time('mysql');
2781 3316 Metasync::set_option($send_auth_token_timestamp);
2782 3317 }
2783 3318 }
2784 3319
@@ -2783,8 +3318,17 @@
2783 3318 }
2784 3319
2785 3320 public function linkgraph_login()
2786 3321 {
3322 + # Relays caller-supplied credentials to the token API, so verify the
3323 + # nonce sent by admin/js (metasyncLGLogin) and confirm plugin access
3324 + # first; otherwise any logged-in user could use the site as a proxy.
3325 + check_ajax_referer('metasync_lglogin', 'nonce');
3326 +
3327 + if (!Metasync::current_user_has_plugin_access()) {
3328 + wp_send_json_error(['message' => 'Not authorized']);
3329 + }
3330 +
2787 3331 $post_data = metasync_sanitize_input_array($_POST);
2788 3332 $payload = array(
2789 3333 'username' => wp_unslash(sanitize_email($post_data['username'])),
2790 3334 'password' => wp_unslash(sanitize_text_field($post_data['password']))
@@ -2801,9 +3345,9 @@
2801 3345 ));
2802 3346
2803 3347 # Error handling for timeout or connection failures
2804 3348 if (is_wp_error($response)) {
2805 - error_log('MetaSync: Token API failed: ' . $response->get_error_message());
3349 + error_log('MetaSync: Token API failed: ' . $response->get_error_message()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
2806 3350 wp_send_json_error(array('message' => 'Token API request failed'));
2807 3351 wp_die();
2808 3352 }
2809 3353
@@ -2818,9 +3362,9 @@
2818 3362 {
2819 3363 $sync_heartbeat_data = new Metasync_Sync_Requests();
2820 3364 $response = $sync_heartbeat_data->SyncCustomerParams();
2821 3365
2822 - $responseCode = wp_remote_retrieve_response_code($response);
3366 + $responseCode = Metasync_Sync_Requests::get_response_code($response);
2823 3367 if ($responseCode == 200) {
2824 3368 return rest_ensure_response($response);
2825 3369 }
2826 3370 return rest_ensure_response($response);
@@ -2866,9 +3410,15 @@
2866 3410 );
2867 3411 }
2868 3412
2869 3413 // Check token exists in transients (read-only - don't modify)
3414 + // Try transient first, then fall back to wp_options (handles object cache issues)
2870 3415 $transient_key = get_transient('metasync_sa_connect_active_' . $nonce_token);
3416 +
3417 + if (empty($transient_key) && wp_using_ext_object_cache()) {
3418 + $transient_key = $this->get_sso_token_from_db($nonce_token);
3419 + }
3420 +
2871 3421 if (empty($transient_key)) {
2872 3422 return new WP_Error(
2873 3423 'invalid_nonce_token',
2874 3424 'Invalid or expired nonce token',
@@ -2877,8 +3427,13 @@
2877 3427 }
2878 3428
2879 3429 // Check token metadata exists (read-only)
2880 3430 $token_metadata = get_transient($transient_key);
3431 +
3432 + if ((empty($token_metadata) || !is_array($token_metadata)) && wp_using_ext_object_cache()) {
3433 + $token_metadata = $this->get_sso_metadata_from_db($transient_key);
3434 + }
3435 +
2881 3436 if (empty($token_metadata) || !is_array($token_metadata)) {
2882 3437 return new WP_Error(
2883 3438 'invalid_nonce_token',
2884 3439 'Invalid nonce token',
@@ -2906,8 +3461,143 @@
2906 3461 );
2907 3462 }
2908 3463 }
2909 3464
3465 + /**
3466 + * Read SSO active token directly from DB, bypassing object cache.
3467 + *
3468 + * On sites with external object cache (Redis, Memcached, LiteSpeed), the
3469 + * transient set during the admin AJAX request may not be visible to the
3470 + * REST callback from SA servers (different process/cache context).
3471 + *
3472 + * Checks the companion _transient_timeout_ row to honor expiry.
3473 + *
3474 + * @param string $nonce_token The nonce token from the x-api-key header
3475 + * @return string|false The metadata transient key, or false
3476 + */
3477 + private function get_sso_token_from_db($nonce_token)
3478 + {
3479 + global $wpdb;
3480 +
3481 + $option_name = '_transient_metasync_sa_connect_active_' . $nonce_token;
3482 + $timeout_name = '_transient_timeout_metasync_sa_connect_active_' . $nonce_token;
3483 +
3484 + // Check expiry first
3485 + $timeout = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
3486 + $wpdb->prepare(
3487 + "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
3488 + $timeout_name
3489 + )
3490 + );
3491 +
3492 + if ($timeout && (int) $timeout < time()) {
3493 + // Expired — clean up orphan rows
3494 + $wpdb->delete($wpdb->options, array('option_name' => $option_name)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
3495 + $wpdb->delete($wpdb->options, array('option_name' => $timeout_name)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
3496 + return false;
3497 + }
3498 +
3499 + $value = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
3500 + $wpdb->prepare(
3501 + "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
3502 + $option_name
3503 + )
3504 + );
3505 +
3506 + return $value ?: false;
3507 + }
3508 +
3509 + /**
3510 + * Read SSO token metadata directly from DB, bypassing object cache.
3511 + *
3512 + * Checks the companion _transient_timeout_ row to honor expiry.
3513 + *
3514 + * @param string $transient_key The transient key holding the token metadata
3515 + * @return array|false The token metadata array, or false
3516 + */
3517 + private function get_sso_metadata_from_db($transient_key)
3518 + {
3519 + global $wpdb;
3520 +
3521 + $option_name = '_transient_' . $transient_key;
3522 + $timeout_name = '_transient_timeout_' . $transient_key;
3523 +
3524 + // Check expiry first
3525 + $timeout = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
3526 + $wpdb->prepare(
3527 + "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
3528 + $timeout_name
3529 + )
3530 + );
3531 +
3532 + if ($timeout && (int) $timeout < time()) {
3533 + $wpdb->delete($wpdb->options, array('option_name' => $option_name)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
3534 + $wpdb->delete($wpdb->options, array('option_name' => $timeout_name)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
3535 + return false;
3536 + }
3537 +
3538 + $value = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
3539 + $wpdb->prepare(
3540 + "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
3541 + $option_name
3542 + )
3543 + );
3544 +
3545 + if ($value) {
3546 + $unserialized = maybe_unserialize($value);
3547 + return is_array($unserialized) ? $unserialized : false;
3548 + }
3549 +
3550 + return false;
3551 + }
3552 +
3553 + /**
3554 + * Delete an SSO transient and its DB fallback rows.
3555 + *
3556 + * When external object cache is active, delete_transient() only removes
3557 + * the cache entry. This also removes the wp_options rows written by the
3558 + * DB fallback in create_searchatlas_nonce_token().
3559 + *
3560 + * @param string $transient_name Transient name (without _transient_ prefix)
3561 + */
3562 + private function delete_sso_transient($transient_name)
3563 + {
3564 + delete_transient($transient_name);
3565 + if (wp_using_ext_object_cache()) {
3566 + global $wpdb;
3567 + $wpdb->delete($wpdb->options, array('option_name' => '_transient_' . $transient_name)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
3568 + $wpdb->delete($wpdb->options, array('option_name' => '_transient_timeout_' . $transient_name)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
3569 + }
3570 + }
3571 +
3572 + /**
3573 + * Set an SSO transient and its DB fallback rows.
3574 + *
3575 + * When external object cache is active, set_transient() only writes to
3576 + * cache. This also writes to wp_options so cross-process reads work.
3577 + *
3578 + * @param string $transient_name Transient name (without _transient_ prefix)
3579 + * @param mixed $value Value to store
3580 + * @param int $expiration Expiration in seconds
3581 + */
3582 + private function set_sso_transient($transient_name, $value, $expiration)
3583 + {
3584 + set_transient($transient_name, $value, $expiration);
3585 + if (wp_using_ext_object_cache()) {
3586 + global $wpdb;
3587 + $wpdb->replace($wpdb->options, array( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
3588 + 'option_name' => '_transient_' . $transient_name,
3589 + 'option_value' => maybe_serialize($value),
3590 + 'autoload' => 'no',
3591 + ));
3592 + $wpdb->replace($wpdb->options, array( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
3593 + 'option_name' => '_transient_timeout_' . $transient_name,
3594 + 'option_value' => time() + $expiration,
3595 + 'autoload' => 'no',
3596 + ));
3597 + }
3598 + }
3599 +
2910 3600 /**
2911 3601 * Validate Search Atlas connect token for callback
2912 3602 * SECURITY FIX (CVE-2025-14386): Only validates against time-limited transient tokens
2913 3603 * Tokens must be created by generate_searchatlas_connect_url() and stored in transients
@@ -2920,26 +3610,35 @@
2920 3610
2921 3611 // SECURITY FIX: Token MUST exist in transients (created by generate_searchatlas_connect_url)
2922 3612 // We do NOT fall back to apikey - this was the vulnerability!
2923 3613 $transient_key = get_transient('metasync_sa_connect_active_' . $token);
2924 -
3614 +
3615 + // Fallback: read directly from DB when object cache misses
3616 + if (empty($transient_key) && wp_using_ext_object_cache()) {
3617 + $transient_key = $this->get_sso_token_from_db($token);
3618 + }
3619 +
2925 3620 if (empty($transient_key)) {
2926 3621 return false;
2927 3622 }
2928 3623
2929 - // Token found in transients - validate metadata
3624 + // Token found - validate metadata
2930 3625 $token_metadata = get_transient($transient_key);
2931 3626
3627 + // Fallback: read metadata directly from DB
3628 + if ((empty($token_metadata) || !is_array($token_metadata)) && wp_using_ext_object_cache()) {
3629 + $token_metadata = $this->get_sso_metadata_from_db($transient_key);
3630 + }
3631 +
2932 3632 if (empty($token_metadata) || !is_array($token_metadata)) {
2933 - delete_transient('metasync_sa_connect_active_' . $token);
3633 + $this->delete_sso_transient('metasync_sa_connect_active_' . $token);
2934 3634 return false;
2935 3635 }
2936 3636
2937 -
2938 3637 // Check expiration
2939 3638 if (isset($token_metadata['expires']) && time() > $token_metadata['expires']) {
2940 - delete_transient($transient_key);
2941 - delete_transient('metasync_sa_connect_active_' . $token);
3639 + $this->delete_sso_transient($transient_key);
3640 + $this->delete_sso_transient('metasync_sa_connect_active_' . $token);
2942 3641 return false;
2943 3642 }
2944 3643
2945 3644 // Check if already used for callback (single-use enforcement)
@@ -2945,20 +3644,20 @@
2945 3644 // Check if already used for callback (single-use enforcement)
2946 3645 if (isset($token_metadata['callback_used']) && $token_metadata['callback_used'] === true) {
2947 3646 return false;
2948 3647 }
2949 -
3648 +
2950 3649 // Mark token as used for callback (single-use)
3650 + // Uses set_sso_transient to propagate callback_used to DB on object cache sites
2951 3651 $token_metadata['callback_used'] = true;
2952 3652 $token_metadata['callback_at'] = time();
2953 - // BUGFIX: Keep metadata for 5 minutes to allow user login to complete
2954 - set_transient($transient_key, $token_metadata, 300); // Keep for 5 minutes for user login
3653 + $this->set_sso_transient($transient_key, $token_metadata, 300);
2955 3654
2956 3655 // BUGFIX: Only delete the active token mapping if BOTH operations are complete
2957 3656 // This allows user login and API callback to happen in any order without race conditions
2958 3657 if (isset($token_metadata['used']) && $token_metadata['used'] === true) {
2959 3658 // Both callback and login are done - safe to delete mapping
2960 - delete_transient('metasync_sa_connect_active_' . $token);
3659 + $this->delete_sso_transient('metasync_sa_connect_active_' . $token);
2961 3660 }
2962 3661 // Otherwise, keep the mapping so user login can still find the token
2963 3662
2964 3663 return true;
@@ -3245,9 +3944,9 @@
3245 3944 *
3246 3945 * @param array $body_params Request body parameters
3247 3946 * @return array|WP_Error Validated parameters array or WP_Error
3248 3947 */
3249 - private function extract_and_validate_searchatlas_params($body_params)
3948 + public function extract_and_validate_searchatlas_params($body_params)
3250 3949 {
3251 3950 $validation_errors = array();
3252 3951
3253 3952 // Extract parameters
@@ -3268,14 +3967,18 @@
3268 3967 $validation_errors['api_key'] = 'API key contains invalid characters. Only alphanumeric, dash, underscore, and dot allowed';
3269 3968 }
3270 3969
3271 3970 // Validate uuid
3971 + // The OTTO Pixel UUID must be a real UUID. Anything else (a WordPress
3972 + // username such as "raza.imran" from an unregistered-project SSO, or any
3973 + // other arbitrary text) breaks OTTO SSR authentication once stored, so a
3974 + // non-UUID is rejected with 422 and nothing is persisted.
3272 3975 if (empty($uuid)) {
3273 3976 $validation_errors['uuid'] = 'UUID is required';
3274 3977 } elseif (!is_string($uuid)) {
3275 3978 $validation_errors['uuid'] = 'UUID must be a string';
3276 - } elseif (strlen($uuid) > 100) {
3277 - $validation_errors['uuid'] = 'UUID must not exceed 100 characters';
3979 + } elseif (!Metasync::is_valid_uuid($uuid)) {
3980 + $validation_errors['uuid'] = 'UUID must be a valid UUID';
3278 3981 }
3279 3982
3280 3983 // Validate status_code
3281 3984 if (!is_numeric($status_code)) {
@@ -3328,9 +4031,9 @@
3328 4031 } elseif (strlen($whitelabel_domain) > 255) {
3329 4032 $validation_errors['whitelabel_domain'] = 'Whitelabel domain must not exceed 255 characters';
3330 4033 } elseif (!filter_var($whitelabel_domain, FILTER_VALIDATE_URL)) {
3331 4034 $validation_errors['whitelabel_domain'] = 'Whitelabel domain must be a valid URL';
3332 - } elseif (!in_array(parse_url($whitelabel_domain, PHP_URL_SCHEME), ['http', 'https'])) {
4035 + } elseif (!in_array(wp_parse_url($whitelabel_domain, PHP_URL_SCHEME), ['http', 'https'])) {
3333 4036 $validation_errors['whitelabel_domain'] = 'Whitelabel domain must use http or https protocol';
3334 4037 }
3335 4038 }
3336 4039
@@ -3343,9 +4046,9 @@
3343 4046 $validation_errors['whitelabel_logo'] = 'Whitelabel logo URL must not exceed 500 characters';
3344 4047 } else {
3345 4048 // If URL is invalid, we'll clear it later but not fail the POST
3346 4049 if (!filter_var($whitelabel_logo, FILTER_VALIDATE_URL) ||
3347 - !in_array(parse_url($whitelabel_logo, PHP_URL_SCHEME), ['http', 'https'])) {
4050 + !in_array(wp_parse_url($whitelabel_logo, PHP_URL_SCHEME), ['http', 'https'])) {
3348 4051 // Don't add to validation_errors - let POST succeed but clear the field
3349 4052 }
3350 4053 }
3351 4054 }
@@ -3436,10 +4139,22 @@
3436 4139 $options['general'] = array();
3437 4140 }
3438 4141 // Only update the API key in settings if status_code is 200 (success)
3439 4142 if ($status_code === 200) {
3440 - $options['general']['searchatlas_api_key'] = $new_api_key;
3441 - $options['general']['otto_pixel_uuid'] = $new_otto_uuid;
4143 + // Encrypt the API key at rest; never store it in cleartext.
4144 + $options['general']['searchatlas_api_key'] = Metasync::encrypt_api_key($new_api_key);
4145 +
4146 + // Defense-in-depth: the validator upstream already rejects a
4147 + // non-UUID with 422, but this method is also called directly
4148 + // (pull-based connect, tests, future callers), so the UUID is
4149 + // only stored when it passes the shared shape check. Anything
4150 + // else — a username landing here from an unregistered-project
4151 + // SSO — fails safe: the key is saved, the UUID is not.
4152 + if (Metasync::is_valid_uuid($new_otto_uuid)) {
4153 + $options['general']['otto_pixel_uuid'] = $new_otto_uuid;
4154 + } else {
4155 + error_log('MetaSync SA Connect: mark_searchatlas_nonce_used - refusing to store invalid OTTO Pixel UUID');
4156 + }
3442 4157 // Note: OTTO SSR is always enabled by default, no need to set
3443 4158
3444 4159 // Granular otto_config_status: record when SSO completed (ISO 8601 UTC)
3445 4160 $options['general']['sso_completed_at'] = gmdate('Y-m-d\TH:i:s\Z');
@@ -3446,12 +4161,13 @@
3446 4161
3447 4162 // Update authentication timestamp for polling detection (legacy - keeping for compatibility)
3448 4163 $options['general']['send_auth_token_timestamp'] = current_time('mysql');
3449 4164
3450 - // ✅ NEW: Set nonce-specific success flag for polling detection
4165 + // Set nonce-specific success flag for polling detection
3451 4166 // This ensures only the specific nonce that was authenticated reports success
4167 + // Uses set_sso_transient to also write to DB on object cache sites
3452 4168 $success_key = 'metasync_sa_connect_success_' . md5($token);
3453 - set_transient($success_key, true, 300); // 5 minutes expiry
4169 + $this->set_sso_transient($success_key, true, 300);
3454 4170
3455 4171 // Clear JWT token cache when API key is updated to ensure fresh tokens
3456 4172 $this->clear_jwt_token_cache();
3457 4173
@@ -3457,35 +4173,43 @@
3457 4173
3458 4174 } else {
3459 4175 }
3460 4176
3461 - // Map whitelabel fields consistently (regardless of status_code)
4177 + // Map whitelabel fields consistently (regardless of status_code).
4178 + // Platform values only ever OVERWRITE local branding when they are
4179 + // actually sent — a plain (non-whitelabel) connect must not blank
4180 + // locally configured white-label identity, the same preservation
4181 + // rule the reset flows follow. Both readers
4182 + // (get_effective_plugin_name(), get_whitelabel_otto_name()) test
4183 + // with !empty(), so an omitted key keeps whatever was stored.
4184 + if (!empty($whitelabel_company_name)) {
4185 + $options['general']['white_label_plugin_name'] = $whitelabel_company_name;
4186 + }
4187 +
4188 + // whitelabel_otto → OTTO Features naming (separate from plugin name)
4189 + if (!empty($whitelabel_otto)) {
4190 + $options['general']['whitelabel_otto_name'] = $whitelabel_otto;
4191 + }
4192 +
4193 + // Store whitelabel settings (hidden from UI but accessible to plugin logic).
4194 + // Same preservation rule: a non-whitelabel connect leaves an
4195 + // existing local white-label configuration untouched; platform
4196 + // values only land when the platform actually reports them.
3462 4197 if ($is_whitelabel) {
3463 - // whitelabel_company_name → Plugin Name (general plugin branding)
3464 - if (!empty($whitelabel_company_name)) {
3465 - $options['general']['white_label_plugin_name'] = $whitelabel_company_name;
4198 + if (!isset($options['whitelabel'])) {
4199 + $options['whitelabel'] = array();
3466 4200 }
3467 -
3468 - // whitelabel_otto → OTTO Features naming (separate from plugin name)
3469 - if (!empty($whitelabel_otto)) {
3470 - $options['general']['whitelabel_otto_name'] = $whitelabel_otto;
4201 +
4202 + $options['whitelabel']['is_whitelabel'] = true;
4203 + if (!empty($whitelabel_domain)) {
4204 + $options['whitelabel']['domain'] = $whitelabel_domain;
3471 4205 }
3472 - } else {
3473 - // Clear whitelabel fields when not whitelabel
3474 - unset($options['general']['white_label_plugin_name']);
3475 - unset($options['general']['whitelabel_otto_name']);
4206 + if (!empty($whitelabel_logo)) {
4207 + $options['whitelabel']['logo'] = $whitelabel_logo;
4208 + }
4209 + $options['whitelabel']['updated_at'] = time();
3476 4210 }
3477 4211
3478 - // Store whitelabel settings (hidden from UI but accessible to plugin logic)
3479 - if (!isset($options['whitelabel'])) {
3480 - $options['whitelabel'] = array();
3481 - }
3482 -
3483 - $options['whitelabel']['is_whitelabel'] = $is_whitelabel;
3484 - $options['whitelabel']['domain'] = $whitelabel_domain;
3485 - $options['whitelabel']['logo'] = $whitelabel_logo;
3486 - $options['whitelabel']['updated_at'] = time();
3487 -
3488 4212 // Log whitelabel configuration
3489 4213 if ($is_whitelabel) {
3490 4214 $log_parts = array('Whitelabel mode enabled');
3491 4215 if (!empty($whitelabel_domain)) {
@@ -3503,11 +4227,12 @@
3503 4227
3504 4228 }
3505 4229
3506 4230 $save_result = Metasync::set_option($options);
4231 + Metasync::invalidate_api_key_cache();
3507 4232
3508 4233 if (!$save_result) {
3509 - error_log('MetaSync SA Connect: mark_searchatlas_nonce_used - Failed to save plugin options');
4234 + error_log('MetaSync SA Connect: mark_searchatlas_nonce_used - Failed to save plugin options'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
3510 4235 } else {
3511 4236 if ($status_code === 200) {
3512 4237 // Option 1: Set heartbeat cache and last-known to CONNECTED so dashboard shows iframe immediately.
3513 4238 // If the immediate heartbeat then fails (e.g. backoff), Option 2 prevents overwriting this to DISCONNECTED.
@@ -3519,9 +4244,9 @@
3519 4244 );
3520 4245 set_transient('metasync_heartbeat_status_cache', $cache_data, 300);
3521 4246 update_option('metasync_last_known_connection_state', true);
3522 4247 do_action('metasync_heartbeat_state_key_pending'); // PR3: burst mode
3523 - $this->trigger_immediate_heartbeat_after_sa_connect();
4248 + $this->trigger_immediate_heartbeat_after_sa_connect($token);
3524 4249 }
3525 4250 }
3526 4251
3527 4252 return true;
@@ -3526,9 +4251,9 @@
3526 4251
3527 4252 return true;
3528 4253
3529 4254 } catch (Exception $e) {
3530 - error_log('MetaSync SA Connect: mark_searchatlas_nonce_used Error - ' . $e->getMessage());
4255 + error_log('MetaSync SA Connect: mark_searchatlas_nonce_used Error - ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
3531 4256 return false;
3532 4257 }
3533 4258 }
3534 4259
@@ -3540,9 +4265,9 @@
3540 4265 {
3541 4266 global $wpdb;
3542 4267
3543 4268 // Clear all JWT token transients
3544 - $deleted = $wpdb->query(
4269 + $deleted = $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
3545 4270 $wpdb->prepare(
3546 4271 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
3547 4272 '_transient_metasync_jwt_token_%'
3548 4273 )
@@ -3548,9 +4273,9 @@
3548 4273 )
3549 4274 );
3550 4275
3551 4276 // Also clear timeout transients
3552 - $wpdb->query(
4277 + $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
3553 4278 $wpdb->prepare(
3554 4279 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
3555 4280 '_transient_timeout_metasync_jwt_token_%'
3556 4281 )
@@ -3561,21 +4286,28 @@
3561 4286
3562 4287 /**
3563 4288 * Trigger immediate heartbeat check after successful Search Atlas connect authentication
3564 4289 * This provides immediate feedback to the user about connection status
4290 + *
4291 + * @param string $token Connect token; keys the once-per-connect analytics dedupe.
3565 4292 */
3566 - private function trigger_immediate_heartbeat_after_sa_connect()
4293 + private function trigger_immediate_heartbeat_after_sa_connect($token)
3567 4294 {
3568 4295 try {
3569 4296 // Use WordPress action system to trigger immediate heartbeat check
3570 4297 // This is more reliable than trying to access admin class directly
3571 4298 do_action('metasync_trigger_immediate_heartbeat', 'Search Atlas Connect - API key and UUID retrieved');
3572 -
4299 +
4300 + // Consent-gated product analytics: the real activation moment.
4301 + // The pull-based poll can persist the same staged result more than once;
4302 + // one event per connect attempt.
4303 + Metasync_PostHog::feature_once('searchatlas_connected', array('success' => true), md5('connected|' . $token), 15 * MINUTE_IN_SECONDS);
4304 +
3573 4305 // Also ensure heartbeat cron is scheduled now that we have an API key
3574 4306 do_action('metasync_ensure_heartbeat_cron_scheduled');
3575 4307
3576 4308 } catch (Exception $e) {
3577 - error_log('MetaSync SA Connect: Error triggering immediate heartbeat check - ' . $e->getMessage());
4309 + error_log('MetaSync SA Connect: Error triggering immediate heartbeat check - ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
3578 4310 }
3579 4311 }
3580 4312
3581 4313 public function get_item_schema()
@@ -3593,15 +4325,15 @@
3593 4325 'type' => 'object',
3594 4326 // In JSON Schema you can specify object properties in the properties attribute.
3595 4327 'properties' => array(
3596 4328 'id' => array(
3597 - 'description' => esc_html__('Unique identifier for the object.', 'my-textdomain'),
4329 + 'description' => esc_html__('Unique identifier for the object.', 'metasync'),
3598 4330 'type' => 'integer',
3599 4331 'context' => array('view', 'edit', 'embed'),
3600 4332 'readonly' => true,
3601 4333 ),
3602 4334 'content' => array(
3603 - 'description' => esc_html__('The content for the object.', 'my-textdomain'),
4335 + 'description' => esc_html__('The content for the object.', 'metasync'),
3604 4336 'type' => 'string',
3605 4337 ),
3606 4338 ),
3607 4339 );
@@ -3692,11 +4424,11 @@
3692 4424
3693 4425 # Try alternative parameter methods
3694 4426 $body_params = $request->get_body_params();
3695 4427 $key_param = $request->get_param('key');
3696 - $post_key = $_POST['key'] ?? null;
3697 - $request_key = $_REQUEST['key'] ?? null;
3698 - $get_key = $_GET['key'] ?? null;
4428 + $post_key = $_POST['key'] ?? null; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- REST route authenticated by API key, not cookies
4429 + $request_key = $_REQUEST['key'] ?? null; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- REST route authenticated by API key, not cookies
4430 + $get_key = $_GET['key'] ?? null; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- REST route authenticated by API key, not cookies
3699 4431
3700 4432 # Try to get key from multiple sources
3701 4433 $key_value = null;
3702 4434
@@ -3726,9 +4458,9 @@
3726 4458 }
3727 4459
3728 4460 # Validate that key is provided
3729 4461 if (empty($key_value)) {
3730 - return rest_ensure_response(array(
4462 + return new WP_REST_Response(array(
3731 4463 'error' => 'Key parameter is required',
3732 4464 'code' => 'missing_key'
3733 4465 ), 400);
3734 4466 }
@@ -3736,52 +4468,102 @@
3736 4468 # Use the found key value
3737 4469 $data['key'] = $key_value;
3738 4470
3739 4471 # Sanitize the key to ensure it's safe for filename
3740 - $key = sanitize_file_name($data['key']);
3741 -
3742 - # Validate key is not empty after sanitization
3743 - if (empty($key)) {
3744 - return rest_ensure_response(array(
4472 + $key = basename(sanitize_file_name($data['key']));
4473 +
4474 + # Validate key is not empty after sanitization and contains no path separators
4475 + if (empty($key) || preg_match('/[\/\\\\]/', $key) || strpos($key, '..') !== false) {
4476 + return new WP_REST_Response(array(
3745 4477 'error' => 'Invalid key provided',
3746 4478 'code' => 'invalid_key'
3747 4479 ), 400);
3748 4480 }
3749 -
4481 +
3750 4482 # Get WordPress root directory
3751 4483 $wp_root = ABSPATH;
3752 -
3753 - # Construct the file path
3754 - $file_path = $wp_root . $key . '.txt';
3755 -
3756 - # Check if file already exists
3757 - if (file_exists($file_path)) {
3758 - return rest_ensure_response(array(
3759 - 'error' => 'File already exists',
3760 - 'code' => 'file_exists',
3761 - 'file_path' => $file_path
3762 - ), 409);
4484 +
4485 + # Sanitize the key for use as a filename and validate path stays within ABSPATH
4486 + $safe_key = sanitize_file_name( $key );
4487 + $file_path = $wp_root . $safe_key . '.txt';
4488 + $real_root = realpath( $wp_root );
4489 + if ( false === $real_root || 0 !== strpos( realpath( dirname( $file_path ) ), $real_root ) ) {
4490 + return new WP_REST_Response(array(
4491 + 'error' => 'Invalid file path',
4492 + 'code' => 'invalid_path'
4493 + ), 400);
3763 4494 }
3764 -
3765 - # Attempt to create the file
3766 - $result = file_put_contents($file_path, $key);
3767 -
3768 - # Check if file creation was successful
3769 - if ($result === false) {
3770 - return rest_ensure_response(array(
3771 - 'error' => 'Failed to create file',
3772 - 'code' => 'file_creation_failed',
3773 - 'file_path' => $file_path
3774 - ), 500);
4495 +
4496 + # Register the key for virtual serving via template_redirect as a
4497 + # FALLBACK for read-only web roots and hosts that 403 direct static .txt.
4498 + if (!class_exists('Metasync_Bing_Instant_Index')) {
4499 + $bing_file = plugin_dir_path(dirname(__FILE__)) . 'bing-index/class-metasync-bing-instant-index.php';
4500 + if (file_exists($bing_file)) {
4501 + require_once $bing_file;
4502 + }
3775 4503 }
3776 -
4504 + if (class_exists('Metasync_Bing_Instant_Index')) {
4505 + Metasync_Bing_Instant_Index::register_virtual_key($safe_key);
4506 + }
4507 +
4508 + # Decide how {key}.txt is served, then confirm it end-to-end.
4509 + #
4510 + # Two host families must both work:
4511 + # - Static-serving hosts (e.g. cPanel Apache-behind-nginx): a missing .txt
4512 + # is 404'd by the web server before WordPress runs, so ONLY a physical
4513 + # file at the root is reachable.
4514 + # - nginx hosts that 403 direct static .txt: a physical file is blocked AND
4515 + # shadows the PHP route, so the key must be served virtually via
4516 + # template_redirect (no physical file present).
4517 + #
4518 + # Strategy: write the physical file when the root is writable, then probe
4519 + # it. Keep it only on a clean 200-with-matching-body; on 403/404/wrong body
4520 + # delete it so it stops shadowing the virtual route and let WordPress serve
4521 + # the key from PHP instead.
4522 + $serving = 'virtual';
4523 +
4524 + # ABSPATH is intentional and verified: the Bing site-verification key
4525 + # must be served from the site's web root exactly as Bing fetches it
4526 + # (/{key}.txt). $safe_key is basename()+sanitize_file_name()'d and the
4527 + # realpath check above confines $file_path to the root, so this cannot
4528 + # escape it. Plugin Check's ABSPATHDetected warning is a false
4529 + # positive — a verification file cannot live in the uploads directory.
4530 + if (wp_is_writable($wp_root) && @file_put_contents($file_path, $safe_key) !== false) {
4531 + # Cache-buster avoids a stale 404 cached by a CDN/nginx for this path.
4532 + $probe = wp_remote_get(
4533 + home_url('/' . $safe_key . '.txt?_msverify=' . time()),
4534 + array('timeout' => 10, 'sslverify' => false)
4535 + );
4536 + $code = is_wp_error($probe) ? 0 : (int) wp_remote_retrieve_response_code($probe);
4537 + $body = is_wp_error($probe) ? '' : trim((string) wp_remote_retrieve_body($probe));
4538 +
4539 + if ($code === 200 && $body === $safe_key) {
4540 + $serving = 'physical';
4541 + } else {
4542 + # 403 / 404 / wrong body — a present file shadows the virtual route,
4543 + # so remove it and let WordPress serve the key instead.
4544 + @wp_delete_file($file_path);
4545 + $serving = 'virtual';
4546 + }
4547 + }
4548 +
4549 + # Persist the winning strategy per key so activation stays deterministic
4550 + # and we can tell when a rotated key needs re-probing.
4551 + update_option('metasync_indexnow_serving', array(
4552 + 'key' => $safe_key,
4553 + 'strategy' => $serving,
4554 + 'ts' => time(),
4555 + ), false);
4556 +
4557 +
3777 4558 # Return success response
3778 - return rest_ensure_response(array(
3779 - 'success' => true,
3780 - 'message' => 'Key file created successfully',
4559 + return new WP_REST_Response(array(
4560 + 'success' => true,
4561 + 'message' => 'Key file created successfully',
3781 4562 'file_path' => $file_path,
3782 - 'key' => $key,
3783 - 'file_size' => $result
4563 + 'file_url' => home_url('/' . $safe_key . '.txt'),
4564 + 'key' => $key,
4565 + 'serving' => $serving, // 'virtual' = served by WordPress, 'physical' = on-disk file remains
3784 4566 ), 200);
3785 4567 }
3786 4568
3787 4569 }