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 +946 -345 2.6.9 → 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,86 +164,9 @@
174 164 }
175 165 return $posts;
176 166 }
177 167
178 - public function metasyn_otto_ajax() {
179 - // Check nonce for security
180 - if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'otto_nonce')) {
181 - wp_send_json_error('Invalid nonce');
182 - wp_die();
183 - }
184 - $post_id = sanitize_text_field($_POST['post_id']);
185 - $current_url = get_permalink($post_id);
186 -
187 - $header_html = get_post_meta($post_id, '_otto_header_html_json', true);
188 168
189 - // Example API call using wp_remote_get()
190 -
191 -
192 - if (is_wp_error($header_html)) {
193 - wp_send_json_error('API call failed');
194 - } else {
195 - wp_send_json_success(json_decode($header_html, true));
196 - }
197 -
198 - wp_die(); // Always terminate after an AJAX call
199 - }
200 -
201 - public function otto_header_data() {
202 - global $post;
203 -
204 - // Get the current post ID
205 - $post_id = $post->ID;
206 -
207 - // Get the current time and the last update time from post meta
208 - $current_time = current_time('timestamp');
209 - $last_update_time = get_post_meta($post_id, '_otto_last_update_time', true);
210 -
211 - // Set the interval for 24 hours (in seconds)
212 - $interval = 24 * 60 * 60;
213 -
214 - // Check if the last update time is set or if 24 hours have passed
215 - if (!$last_update_time || ($current_time - $last_update_time) >= $interval) {
216 - // Get the current URL
217 - $current_url = get_permalink($post_id);
218 -
219 - // Use endpoint manager to get the correct API URL
220 - $api_endpoint = class_exists('Metasync_Endpoint_Manager')
221 - ? Metasync_Endpoint_Manager::get_endpoint('OTTO_URL_DETAILS')
222 - : 'https://sa.searchatlas.com/api/v2/otto-url-details';
223 -
224 - // Call the API
225 - $response = wp_remote_get($api_endpoint . '/?url=' . urlencode($current_url));
226 -
227 - // Check if the API call was successful
228 - if (!is_wp_error($response)) {
229 - $body = wp_remote_retrieve_body($response);
230 - $data = json_decode($body, true); // Decode JSON into an associative array
231 -
232 - // Update the post meta with the new data and timestamp
233 - update_post_meta($post_id, '_otto_header_html', $data['header_html_insertion']);
234 - update_post_meta($post_id, '_otto_last_update_time', $current_time);
235 - }
236 - }
237 -
238 - // Get the saved HTML from post meta
239 - $header_html = get_post_meta($post_id, '_otto_header_html', true);
240 -
241 - // Display the HTML with security measures
242 - if ($header_html) {
243 - echo "<!-- Otto Start -->";
244 - // SECURITY FIX: Sanitize HTML to prevent XSS while allowing safe HTML
245 - echo wp_kses($header_html, array(
246 - 'style' => array(),
247 - 'link' => array('rel' => array(), 'href' => array(), 'type' => array()),
248 - 'meta' => array('name' => array(), 'content' => array(), 'property' => array()),
249 - 'script' => array('type' => array(), 'src' => array()),
250 - // Add other safe tags as needed
251 - ));
252 - echo "<!-- Otto End -->";
253 - }
254 - }
255 -
256 169 public function rest_authorization_middleware($request = null)
257 170 {
258 171 $api_key = '';
259 172
@@ -269,11 +182,11 @@
269 182 if (empty($api_key) && isset($_SERVER['HTTP_X_API_KEY'])) {
270 183 $api_key = sanitize_text_field(wp_unslash($_SERVER['HTTP_X_API_KEY']));
271 184 }
272 185
273 - // Fallback: ?apikey= query param (deprecated)
274 - if (empty($api_key) && isset($_GET['apikey'])) {
275 - $api_key = sanitize_text_field($_GET['apikey']);
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
276 189 }
277 190
278 191 if (empty($api_key)) {
279 192 return false;
@@ -278,16 +191,219 @@
278 191 if (empty($api_key)) {
279 192 return false;
280 193 }
281 194
195 + $stored_key = $this->get_stored_api_key();
196 +
197 + if (empty($stored_key)) {
198 + return false;
199 + }
200 +
201 + return hash_equals($stored_key, $api_key);
202 + }
203 +
204 + /**
205 + * The plugin API key configured in MetaSync settings.
206 + *
207 + * Single source for the stored key so every auth path compares against the
208 + * same value.
209 + *
210 + * @return string|null Configured key, or null when the plugin is unlinked.
211 + */
212 + protected function get_stored_api_key()
213 + {
282 214 $getOptions = Metasync::get_option('general');
283 - $stored_key = $getOptions['apikey'] ?? null;
284 215
216 + return $getOptions['apikey'] ?? null;
217 + }
218 +
219 + /**
220 + * Coexist with third-party JWT authentication plugins.
221 + *
222 + * A global JWT auth plugin (e.g. Tmeister's "JWT Authentication for WP-API")
223 + * validates any "Authorization: Bearer ..." header as a JWT. MetaSync sends
224 + * its plugin API key as a Bearer token, so the JWT plugin rejects it with a
225 + * 403 (jwt_auth_invalid_token). That plugin surfaces the error from its
226 + * rest_pre_dispatch callback (priority 10), short-circuiting the request
227 + * before MetaSync's own permission_callback ever runs.
228 + *
229 + * This filter is registered on rest_pre_dispatch at a later priority (11) so
230 + * it runs after the JWT plugin. When a prior error exists for a metasync/v1
231 + * request and our own API key validates, it clears the error (returns null)
232 + * so dispatch continues to the route and rest_authorization_middleware can
233 + * authorize it. Every other route — and every request without a valid
234 + * MetaSync key — is left untouched, preserving the JWT plugin's behaviour.
235 + *
236 + * @param mixed $result Prior dispatch result (WP_Error to short-circuit, otherwise null).
237 + * @param WP_REST_Server|null $server REST server instance (unused).
238 + * @param WP_REST_Request|null $request Current request.
239 + * @return mixed Cleared (null) only when our key validates on a metasync/v1
240 + * route; otherwise the input $result unchanged.
241 + */
242 + public function allow_metasync_rest_auth($result, $server = null, $request = null)
243 + {
244 + // No prior error means normal flow — never alter it.
245 + if (!is_wp_error($result)) {
246 + return $result;
247 + }
248 +
249 + // We need the request to know which route is being dispatched.
250 + if (!($request instanceof \WP_REST_Request)) {
251 + return $result;
252 + }
253 +
254 + // Only intervene for our own namespace; preserve the JWT plugin's
255 + // behaviour for every other (core/third-party) route.
256 + $route = $request->get_route();
257 + $prefix = '/' . self::namespace;
258 + if (strpos($route, $prefix . '/') !== 0 && $route !== $prefix) {
259 + return $result;
260 + }
261 +
262 + // Clear the error only when our own API key validates, so MetaSync's
263 + // permission_callback gets to authorize the request normally.
264 + if ($this->rest_authorization_middleware($request) === true) {
265 + return null;
266 + }
267 +
268 + return $result;
269 + }
270 +
271 + /**
272 + * Coexist with site-wide "authenticated users only" REST restrictions.
273 + *
274 + * Security plugins and copy-pasted snippets commonly hook
275 + * rest_authentication_errors to reject any request without a logged-in user
276 + * (typically WP_Error 'rest_cannot_access', 401). That filter is applied by
277 + * WP_REST_Server::check_authentication(), which runs BEFORE dispatch:
278 + *
279 + * $result = $this->check_authentication();
280 + * if ( ! is_wp_error( $result ) ) {
281 + * $result = $this->dispatch( $request );
282 + * }
283 + *
284 + * So an error here short-circuits the whole request: rest_pre_dispatch never
285 + * fires, no permission_callback runs, and even routes registered with
286 + * __return_true answer 401. MetaSync authenticates with a plugin API key
287 + * rather than a WordPress session, so is_user_logged_in() is false for every
288 + * legitimate SearchAtlas call and article publishing fails site-wide.
289 + *
290 + * This filter runs late (priority 99) and clears such an error only when the
291 + * request both targets metasync/v1 and carries a valid MetaSync API key —
292 + * the key is itself proof of authentication. Any other namespace, and any
293 + * request without a valid key, keeps the site's restriction intact.
294 + *
295 + * The filter receives no WP_REST_Request (check_authentication() passes only
296 + * the prior result), so route and credentials are read from the request
297 + * globals instead.
298 + *
299 + * @param mixed $result Prior authentication result: null (no opinion), true, or WP_Error.
300 + * @return mixed Cleared (null) only when our key validates on a metasync/v1
301 + * route; otherwise the input $result unchanged.
302 + */
303 + public function allow_metasync_rest_authentication($result)
304 + {
305 + // Only a WP_Error blocks the request; true and null are already passing.
306 + if (!is_wp_error($result)) {
307 + return $result;
308 + }
309 +
310 + $route = $this->get_current_rest_route();
311 + if ($route === '') {
312 + return $result;
313 + }
314 +
315 + // Only intervene for our own namespace, so the site's restriction still
316 + // applies to core and third-party routes.
317 + $prefix = '/' . self::namespace;
318 + if (strpos($route, $prefix . '/') !== 0 && $route !== $prefix) {
319 + return $result;
320 + }
321 +
322 + if ($this->request_has_valid_api_key()) {
323 + return null;
324 + }
325 +
326 + return $result;
327 + }
328 +
329 + /**
330 + * Resolve the REST route being served, without a WP_REST_Request.
331 + *
332 + * @return string Route with a leading slash (e.g. "/metasync/v1/createItem"),
333 + * or '' when this is not a resolvable REST request.
334 + */
335 + protected function get_current_rest_route()
336 + {
337 + // rest_api_loaded() stores the resolved route on the main query.
338 + if (isset($GLOBALS['wp']) && is_object($GLOBALS['wp'])
339 + && !empty($GLOBALS['wp']->query_vars['rest_route'])) {
340 + return '/' . ltrim((string) $GLOBALS['wp']->query_vars['rest_route'], '/');
341 + }
342 +
343 + // ?rest_route= form, used when pretty permalinks are disabled.
344 + if (!empty($_GET['rest_route'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only route check for REST auth
345 + return '/' . ltrim(sanitize_text_field(wp_unslash($_GET['rest_route'])), '/'); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only route check for REST auth
346 + }
347 +
348 + // Last resort: the request path with the /wp-json/ prefix stripped.
349 + $uri = isset($_SERVER['REQUEST_URI']) ? (string) wp_unslash($_SERVER['REQUEST_URI']) : '';
350 + if ($uri === '') {
351 + return '';
352 + }
353 +
354 + $path = (string) wp_parse_url($uri, PHP_URL_PATH);
355 + $url_prefix = function_exists('rest_get_url_prefix') ? rest_get_url_prefix() : 'wp-json';
356 + $needle = '/' . trim((string) $url_prefix, '/') . '/';
357 +
358 + $pos = strpos($path, $needle);
359 + if ($pos === false) {
360 + return '';
361 + }
362 +
363 + return '/' . ltrim(substr($path, $pos + strlen($needle)), '/');
364 + }
365 +
366 + /**
367 + * Whether the current request carries a valid MetaSync API key.
368 + *
369 + * Reads credentials from the request globals so it works in filters that get
370 + * no WP_REST_Request. Accepts 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 === '') {
397 + return false;
398 + }
399 +
400 + $stored_key = $this->get_stored_api_key();
285 401 if (empty($stored_key)) {
286 402 return false;
287 403 }
288 404
289 - return hash_equals($stored_key, $api_key);
405 + return hash_equals((string) $stored_key, $api_key);
290 406 }
291 407
292 408
293 409 public function metasync_register_rest_routes()
@@ -429,9 +545,9 @@
429 545 array(
430 546 array(
431 547 'methods' => 'GET',
432 548 'callback' => function () {
433 - $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
434 550
435 551 if ($getPostID==0) {
436 552 $response = false;
437 553 }else{
@@ -729,14 +845,14 @@
729 845 }
730 846
731 847 # Check if post exists
732 848 if (!$post || $post_id <= 0) {
733 - return rest_ensure_response(array('error' => 'no blog post found'), 404);
849 + return new WP_REST_Response(array('error' => 'no blog post found'), 404);
734 850 }
735 851
736 852 # Check if post type is 'post', return error if not
737 853 if ($post->post_type !== 'post') {
738 - 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);
739 855 }
740 856
741 857 # Render content
742 858 $post_content = $post->post_content;
@@ -866,9 +982,10 @@
866 982 }
867 983
868 984 # Plugin version and timestamps (ISO 8601 UTC where set)
869 985 $plugin_version = defined('METASYNC_VERSION') ? METASYNC_VERSION : 'unknown';
870 - $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);
871 988 $sso_completed_at = $general_options['sso_completed_at'] ?? null;
872 989
873 990 # Prepare response (backward compatible + new fields)
874 991 $response_data = array(
@@ -876,8 +993,9 @@
876 993 'configured' => $is_configured,
877 994 'otto_active' => $is_otto_active,
878 995 'has_api_key' => $has_api_key,
879 996 'has_uuid' => $has_uuid,
997 + 'otto_uuid' => $has_uuid ? $otto_uuid : null,
880 998 'plugin_version' => $plugin_version,
881 999 'last_heartbeat_at' => $last_heartbeat_at,
882 1000 'sso_completed_at' => $sso_completed_at,
883 1001 );
@@ -958,9 +1076,9 @@
958 1076 }
959 1077
960 1078 public function get_errorlogs()
961 1079 {
962 - $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
963 1081 if (!isset($get_data['limit']))
964 1082 return false;
965 1083 $limit = sanitize_text_field($get_data['limit']) ?? null;
966 1084
@@ -1148,9 +1266,9 @@
1148 1266 }
1149 1267 } catch (Throwable $e) {
1150 1268 // Fallback to DOMDocument if Dom\HTMLDocument fails
1151 1269 if (defined('WP_DEBUG_LOG') && WP_DEBUG_LOG) {
1152 - 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
1153 1271 }
1154 1272 $dom = new DOMDocument();
1155 1273 libxml_use_internal_errors(true);
1156 1274 $encoded_content = $this->encode_numeric_entities($content);
@@ -1350,9 +1468,9 @@
1350 1468 $image_id = attachment_url_to_postid($node->getAttribute('src') );
1351 1469 $image_alt = get_post_meta($image_id, '_wp_attachment_image_alt', TRUE);
1352 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]';
1353 1471
1354 - 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
1355 1473
1356 1474 }
1357 1475 }elseif($node->nodeName === 'iframe'){
1358 1476 $result= '[et_pb_code _builder_version="'.ET_BUILDER_VERSION.'" _module_preset="default" global_colors_info="{}"]'.$node->ownerDocument->saveHTML($node).'[/et_pb_code]' ;
@@ -1480,64 +1598,244 @@
1480 1598 return $attachment_id;
1481 1599 }
1482 1600
1483 1601 /**
1484 - * Index a post with Google Indexing API
1485 - *
1486 - * @param int $post_id WordPress post ID
1487 - * @param string $post_type WordPress post type (post, page, etc.)
1488 - * @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
1489 1611 */
1490 - public function metasync_google_index_post($post_id, $post_type, $post_status)
1612 + private function strip_leading_title_h1($content, $title)
1491 1613 {
1492 - // Only index published posts/pages
1493 - 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)) {
1494 1674 return;
1495 1675 }
1496 1676
1497 - // Only index posts and pages (can be extended as needed)
1498 - $allowed_post_types = array('post', 'page');
1499 - 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])) {
1500 1680 return;
1501 1681 }
1502 1682
1503 - try {
1504 - // Load Google Index functionality if not already loaded
1505 - if (!function_exists('google_index_post')) {
1506 - $google_index_path = plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php';
1507 - if (file_exists($google_index_path)) {
1508 - require_once $google_index_path;
1509 - } else {
1510 - error_log('MetaSync Google Index: google-index-init.php not found at ' . $google_index_path);
1511 - return;
1512 - }
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;
1513 1778 }
1779 + }
1780 + return false;
1781 + }
1514 1782
1515 - // Attempt to index the post with Google
1516 - if (function_exists('google_index_post')) {
1517 - $result = google_index_post($post_id, $post_type, 'update');
1518 -
1519 - if (isset($result['success']) && $result['success']) {
1520 - error_log(sprintf(
1521 - 'MetaSync Google Index: Successfully indexed %s (ID: %d, Type: %s)',
1522 - get_the_title($post_id),
1523 - $post_id,
1524 - $post_type
1525 - ));
1526 - } else {
1527 - error_log(sprintf(
1528 - 'MetaSync Google Index: Failed to index %s (ID: %d, Type: %s) - %s',
1529 - get_the_title($post_id),
1530 - $post_id,
1531 - $post_type,
1532 - isset($result['error']['message']) ? $result['error']['message'] : 'Unknown error'
1533 - ));
1534 - }
1535 - }
1536 - } catch (Exception $e) {
1537 - // Log any exceptions but don't break the main functionality
1538 - 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;
1539 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;
1540 1838 }
1541 1839
1542 1840 public function create_item($request)
1543 1841 {
@@ -1572,8 +1870,9 @@
1572 1870 check if the create_item is called by set_landing_page function or not
1573 1871 by doing this we will prevent html from going into builder page option
1574 1872 */
1575 1873 $isOttoAiPage = !empty($item['otto_ai_page']) && filter_var($item['otto_ai_page'], FILTER_VALIDATE_BOOLEAN);
1874 + $content = [];
1576 1875 if(!isset($item['is_landing_page']) && !$isOttoAiPage && empty($item['style_data']) ){
1577 1876
1578 1877 # Get Current Post type
1579 1878 $current_post_type = isset($item['post_type']) ? sanitize_text_field($item['post_type']) : 'post';
@@ -1591,27 +1890,31 @@
1591 1890 } elseif (!empty($theme_template) && stripos($theme_template, 'flatsome') !== false) {
1592 1891 $is_flatsome_theme = true;
1593 1892 }
1594 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 +
1595 1902 # Skip title/image prepending for Flatsome (it displays them by default)
1596 1903 if (!$is_flatsome_theme) {
1597 - # Get the setting for the post template
1904 + # Get the setting for the post template (feature-image detection)
1598 1905 $title_and_feature_image = $this->append_content_if_missing_elements($current_post_type);
1599 1906
1600 - # 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
1601 1908 if(!$title_and_feature_image['image_in_content'] && !empty($item['hero_image_url'])){
1602 -
1603 - # Prepend the feature image
1604 - $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'] ;
1605 1910 }
1606 1911
1607 - # Check if the post title is there in the template or not
1608 - if(!$title_and_feature_image['title_in_headings']){
1609 -
1610 - # 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)){
1611 1915 $item['post_content'] = '<h1>'.$item['post_title'].'</h1>'.$item['post_content'] ;
1612 1916 }
1613 -
1614 1917 }
1615 1918 # This will be used by create_page function
1616 1919 $content = $this->metasync_upload_post_content($item,false,false);
1617 1920 }elseif(isset($item['is_landing_page']) && $item['is_landing_page'] == true){
@@ -1645,13 +1948,13 @@
1645 1948 $new_post['post_name'] = sanitize_text_field($item['permalink']);
1646 1949 }
1647 1950
1648 1951 if (isset($item['post_date']) && !empty($item['post_date'])) {
1649 - $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'];
1650 1953 if (!$is_valid_date) {
1651 1954 return new WP_Error(
1652 1955 'rest_post_invalid_date',
1653 - esc_html__('Post date is not valid'),
1956 + esc_html__('Post date is not valid', 'metasync'),
1654 1957 array('status' => 400)
1655 1958 );
1656 1959 }
1657 1960
@@ -1684,20 +1987,16 @@
1684 1987 # $getPostID_byURL = @get_page_by_path($item['permalink'], OBJECT, $new_post['post_type'])->ID;
1685 1988 # Fix to avoid PHP error if the get_page_by_path returns null
1686 1989 $getPostID_byURL = @get_page_by_path($item['permalink'], OBJECT, $new_post['post_type']);
1687 1990 $getPostID_byURL = $getPostID_byURL ? $getPostID_byURL->ID : null;
1991 + $matched_by_title = false;
1688 1992 if ($getPostID_byURL == NULL) {
1689 1993 // check if the post_title is set and not empty when called by otto_ai_page
1690 1994 if(isset($new_post['post_title']) && $new_post['post_title']!==''){
1691 - $getPostID_byURL = new WP_Query(
1692 - array(
1693 - 'post_type' => $new_post['post_type'],
1694 - 'title' => $new_post['post_title']
1695 - )
1696 - );
1697 - $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 + }
1698 1998 }
1699 - }
1700 1999
1701 2000 // Allow HTML code for landing page
1702 2001 if (isset($item['is_landing_page']) && $item['is_landing_page'] == true) {
1703 2002 kses_remove_filters();
@@ -1722,11 +2021,17 @@
1722 2021 update_post_meta($post_id, 'metasync_post', 'yes');
1723 2022 }
1724 2023
1725 2024 } else {
1726 - $new_post['ID'] = $post_id = $getPostID_byURL;
1727 - wp_update_post($new_post);
1728 - 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);
1729 2034 $permalink = get_permalink($post_id);
1730 2035 }
1731 2036
1732 2037 if (isset($item['is_landing_page']) && $item['is_landing_page'] == true) {
@@ -1768,13 +2073,17 @@
1768 2073 $post_meta['searchatlas_embed_bottom'] = $item['searchatlas_embed_bottom'];
1769 2074 }
1770 2075
1771 2076 // Add custom fields to posts and pages
1772 - foreach ($post_meta as $key => $value) {
1773 - // if (!empty($value) && !is_null($value)) {
1774 - add_post_meta($post_id, $key, $value, true);
1775 - // }
1776 - }
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 + );
1777 2086
1778 2087
1779 2088 $attachment_id = '';
1780 2089 if (isset($item['hero_image_url']) && !empty($item['hero_image_url'])) {
@@ -1792,8 +2101,14 @@
1792 2101 $redirection['url'] = sanitize_url($item['redirection_url']);
1793 2102 }
1794 2103 if (!empty($redirection)) {
1795 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 + }
1796 2111 }
1797 2112
1798 2113 $post_cattegories = [];
1799 2114 # if ($new_post['post_type'] === 'post' && is_array(@$item['post_categories'])) {
@@ -1849,17 +2164,28 @@
1849 2164 'action' => strtolower($action)
1850 2165 ])
1851 2166 ]);
1852 2167
1853 - # 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.
1854 2170 try {
1855 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']));
1856 2173 } catch (Exception $e) {
1857 - 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
1858 2175 }
2176 + }
1859 2177
1860 - // Google Indexing Integration
1861 - $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 + }
1862 2188 }
1863 2189
1864 2190 $respCreatePosts[$index] = array_merge($new_post, $post_meta);
1865 2191 ksort($respCreatePosts[$index]);
@@ -1903,9 +2229,9 @@
1903 2229 }
1904 2230
1905 2231 public function delete_item()
1906 2232 {
1907 - $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
1908 2234 if (!isset($get_data['ID']))
1909 2235 return false;
1910 2236
1911 2237 $post_id = sanitize_text_field($get_data['ID']) ?? null;
@@ -1911,18 +2237,18 @@
1911 2237 $post_id = sanitize_text_field($get_data['ID']) ?? null;
1912 2238 $post = get_post($post_id);
1913 2239 if ($post) {
1914 2240 wp_delete_post($post_id);
2241 + // HTTP 204 requires no body for response, so no message is set.
1915 2242 return new WP_Error(
1916 2243 'rest_post_delete_success',
1917 - esc_html__(''),
1918 - // HTTP 204 requires no body for response
2244 + '',
1919 2245 array('status' => 204)
1920 2246 );
1921 2247 }
1922 2248 return new WP_Error(
1923 2249 'rest_post_delete_fail',
1924 - esc_html__('No post found in the database with requested ID.'),
2250 + esc_html__('No post found in the database with requested ID.', 'metasync'),
1925 2251 array('status' => 400)
1926 2252 );
1927 2253 }
1928 2254
@@ -1927,9 +2253,9 @@
1927 2253 }
1928 2254
1929 2255 public function get_items($request)
1930 2256 {
1931 - $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
1932 2258
1933 2259 # let us check if request send post id and is valid int
1934 2260 if(isset($get_data['post_id']) AND intval($get_data['post_id']) > 0){
1935 2261
@@ -2269,9 +2595,9 @@
2269 2595 $post_data = get_post($post_id);
2270 2596 if (!$post_data) {
2271 2597 return new WP_Error(
2272 2598 'rest_post_update_fail',
2273 - esc_html__('No post found in the database with requested ID.'),
2599 + esc_html__('No post found in the database with requested ID.', 'metasync'),
2274 2600 array('status' => 400)
2275 2601 );
2276 2602 }
2277 2603
@@ -2296,12 +2622,19 @@
2296 2622 $update_params['meta_robots'] = $post['meta_robots'] ?
2297 2623 sanitize_text_field($post['meta_robots']) : $get_robots_meta;
2298 2624 }
2299 2625 if (isset($post['meta_canonical']) && !empty($post['meta_canonical'])) {
2300 - $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 + }
2301 2632 }
2302 2633
2303 2634 $isOttoAiPage = !empty($post['otto_ai_page']) && filter_var($post['otto_ai_page'], FILTER_VALIDATE_BOOLEAN);
2635 + $content = [];
2636 + $permalink = '';
2304 2637 if (isset($post['post_content']) && !empty($post['post_content']) && !$isOttoAiPage) {
2305 2638
2306 2639 # Above we are updating the post_type so we have to get latest value that has been change on the server
2307 2640 $post_fresh_data = get_post($post['post_id']);
@@ -2320,25 +2653,29 @@
2320 2653 } elseif (!empty($theme_template) && stripos($theme_template, 'flatsome') !== false) {
2321 2654 $is_flatsome_theme = true;
2322 2655 }
2323 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 +
2324 2664 # Skip title/image prepending for Flatsome (it displays them by default)
2325 2665 if (!$is_flatsome_theme) {
2326 - # Get the setting for the post template
2666 + # Get the setting for the post template (feature-image detection)
2327 2667 $title_and_feature_image = $this->append_content_if_missing_elements($post_fresh_data->post_type);
2328 2668
2329 - # 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
2330 2670 if(!$title_and_feature_image['image_in_content'] && !empty($post['hero_image_url'])){
2331 -
2332 - # Prepend the feature image
2333 - $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'] ;
2334 2672 }
2335 2673
2336 - # Check if the post title is there in the template or not
2337 - if(!$title_and_feature_image['title_in_headings']){
2338 -
2339 - # Prepend the post title
2340 - $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'] ;
2341 2678 }
2342 2679 }
2343 2680 // This will be used by update_page function
2344 2681 $content = $this->metasync_upload_post_content($post,false,false);
@@ -2381,9 +2718,8 @@
2381 2718 }
2382 2719
2383 2720 if (isset($post['post_status']) && !empty($post['post_status'])) {
2384 2721 $update_params['post_status'] = $post['post_status'] ? sanitize_text_field($post['post_status']) : 'publish';
2385 - $permalink = get_permalink($post_id);
2386 2722 }
2387 2723 if (isset($post['permalink']) || !empty($post['permalink'])) {
2388 2724 $update_params['post_name'] = sanitize_text_field($post['permalink']);
2389 2725 }
@@ -2398,40 +2734,8 @@
2398 2734 );
2399 2735 }
2400 2736
2401 2737
2402 - if (isset($post['post_date']) && !empty($post['post_date']) && false) {
2403 - $is_valid_date = date('Y-m-d', strtotime($post['post_date'])) == $post['post_date'];
2404 - if (!$is_valid_date) {
2405 - return new WP_Error(
2406 - 'rest_post_invalid_date',
2407 - esc_html__('Post date is not valid'),
2408 - array('status' => 400)
2409 - );
2410 - }
2411 -
2412 - $date_limit_str = strtotime(date('Y-m-d') . '-2 month');
2413 - $post_date_str = strtotime($post['post_date']);
2414 -
2415 - if ($date_limit_str >= $post_date_str) {
2416 - $newDate = date('Y-m-d', strtotime('-2 month'));
2417 - return new WP_Error(
2418 - 'rest_post_greater_date',
2419 - esc_html__("Post date should be greater then " . $newDate),
2420 - array('status' => 400)
2421 - );
2422 - }
2423 -
2424 - if ($post_date_str > strtotime(date('Y-m-d'))) {
2425 - return new WP_Error(
2426 - 'rest_post_greater_date',
2427 - esc_html__('Post date should be less then Today'),
2428 - array('status' => 400)
2429 - );
2430 - }
2431 - $update_params['post_date'] = sanitize_text_field($post['post_date'] . date(' h:i:s'));
2432 - }
2433 -
2434 2738 $post_cattegories = [];
2435 2739 # if ($post_data && $post_data->post_type === 'post' && is_array(@$post['post_categories'])) {
2436 2740
2437 2741 # fixed Undefined array key issue
@@ -2454,12 +2758,13 @@
2454 2758 $attachment_id = $this->metasync_handle_hero_image($post_id, $post['hero_image_url'], $post['hero_image_alt_text']);
2455 2759 }
2456 2760
2457 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);
2458 2765 if(isset($content['elementor_meta_data'])){
2459 - foreach ($content['elementor_meta_data'] as $key => $value) {
2460 - update_post_meta($post_id, $key, $value);
2461 - }
2766 + $this->metasync_apply_builder_meta($post_id, $content['elementor_meta_data']);
2462 2767 if ( did_action( 'elementor/loaded' ) ) {
2463 2768 // Clear Elementor cache for the specified post ID
2464 2769 \Elementor\Plugin::instance()->files_manager->clear_cache();
2465 2770 }
@@ -2474,8 +2779,15 @@
2474 2779 delete_post_meta($post_id, '_elementor_page_assets');
2475 2780 delete_post_meta($post_id, '_elementor_page_settings');
2476 2781 }
2477 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 +
2478 2790 $redirection = array();
2479 2791 if (!empty($post['redirection_enable']) && !is_null($post['redirection_enable'])) {
2480 2792 $redirection['enable'] = sanitize_text_field($post['redirection_enable']);
2481 2793 }
@@ -2486,8 +2798,14 @@
2486 2798 $redirection['url'] = sanitize_url($post['redirection_url']);
2487 2799 }
2488 2800 if (!empty($redirection)) {
2489 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 + }
2490 2808 }
2491 2809
2492 2810
2493 2811 $post_revisions = wp_get_post_revisions($post_id);
@@ -2494,9 +2812,8 @@
2494 2812 // Sync post categories to customer dashboard
2495 2813 $this->lgSendCustomerPostParams();
2496 2814
2497 2815 unset($update_params['post_name']);
2498 - unset($update_params['post_category']);
2499 2816
2500 2817 $update_params['post_categories'] = $post_cattegories;
2501 2818 $update_params['post_tags'] = $post_tags;
2502 2819 $update_params['post_id'] = (int) $post_id;
@@ -2526,14 +2843,9 @@
2526 2843
2527 2844 }
2528 2845 # update the content
2529 2846 // add and update the post meta
2530 - foreach ($post_meta_data as $key => $value) {
2531 - // if (!empty($value) && !is_null($value)) {
2532 -
2533 - update_post_meta($post_id, $key, $value);
2534 - //
2535 - }
2847 + $this->metasync_apply_builder_meta($post_id, $post_meta_data);
2536 2848 #check if the elementor plugin is active
2537 2849 if ( did_action( 'elementor/loaded' ) ) {
2538 2850 # Clear Elementor cache for the specified post ID
2539 2851 \Elementor\Plugin::instance()->files_manager->clear_cache();
@@ -2566,14 +2878,26 @@
2566 2878 'updated_fields' => array_keys($update_params)
2567 2879 ])
2568 2880 ]);
2569 2881
2570 - # Track Content Genius event in GA4
2882 + # Track Content Genius event in GA4 and PostHog
2571 2883 try {
2572 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']));
2573 2886 } catch (Exception $e) {
2574 - 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
2575 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 + }
2576 2900 }
2577 2901
2578 2902 ksort($update_params);
2579 2903 $data[] = $update_params;
@@ -2668,20 +2992,9 @@
2668 2992 if($isOttoAiPage){
2669 2993 // Change the page template from default to Metasync Template
2670 2994 $pageTemplate = Metasync_Template::TEMPLATE_NAME;
2671 2995 }
2672 - if ($pageTemplate !== 'default') {
2673 - update_post_meta($post_id, '_wp_page_template', $pageTemplate);
2674 - } else {
2675 - // Clear stale templates that conflict with the active page builder.
2676 - // e.g. metasync-blank from a previous OTTO sync, or elementor_canvas
2677 - // written by the HTML-to-builder converter on an Oxygen site.
2678 - $current = get_post_meta($post_id, '_wp_page_template', true);
2679 - $stale_templates = array(Metasync_Template::TEMPLATE_NAME, 'elementor_canvas', 'elementor_header_footer');
2680 - if (in_array($current, $stale_templates, true)) {
2681 - delete_post_meta($post_id, '_wp_page_template');
2682 - }
2683 - }
2996 + $this->metasync_write_page_template($post_id, $pageTemplate);
2684 2997 }
2685 2998
2686 2999 return rest_ensure_response($createPages);
2687 3000 }
@@ -2704,9 +3017,9 @@
2704 3017 $post_data = get_post($payload['post_id']);
2705 3018 if(!isset($post_data->post_type)){
2706 3019 return new WP_Error(
2707 3020 'rest_page_type_fail',
2708 - esc_html__('No page found in the database with requested ID.'),
3021 + esc_html__('No page found in the database with requested ID.', 'metasync'),
2709 3022 array('status' => 400)
2710 3023 );
2711 3024 }
2712 3025 if ($post_data->post_type !== 'page') {
@@ -2761,20 +3074,225 @@
2761 3074 if($isOttoAiPage){
2762 3075 // Change the page template from default to Metasync Template
2763 3076 $pageTemplate = Metasync_Template::TEMPLATE_NAME;
2764 3077 }
2765 - if ($pageTemplate !== 'default') {
2766 - 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);
2767 3291 } else {
2768 - // Clear stale templates that conflict with the active page builder.
2769 - $current = get_post_meta($post_id, '_wp_page_template', true);
2770 - $stale_templates = array(Metasync_Template::TEMPLATE_NAME, 'elementor_canvas', 'elementor_header_footer');
2771 - if (in_array($current, $stale_templates, true)) {
2772 - delete_post_meta($post_id, '_wp_page_template');
2773 - }
3292 + add_post_meta($post_id, $key, $value, true);
2774 3293 }
2775 3294 }
2776 - return rest_ensure_response($updatePages->data);
2777 3295 }
2778 3296
2779 3297 public function delete_page()
2780 3298 {
@@ -2789,13 +3307,13 @@
2789 3307 {
2790 3308 $sync_request = new Metasync_Sync_Requests();
2791 3309 $response = $sync_request->SyncCustomerParams();
2792 3310
2793 - $responseCode = wp_remote_retrieve_response_code($response);
3311 + $responseCode = Metasync_Sync_Requests::get_response_code($response);
2794 3312 if ($responseCode == 200) {
2795 - $dt = new DateTime();
3313 + # Use current_time('mysql') for consistency with cron heartbeat.
2796 3314 $send_auth_token_timestamp = Metasync::get_option();
2797 - $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');
2798 3316 Metasync::set_option($send_auth_token_timestamp);
2799 3317 }
2800 3318 }
2801 3319
@@ -2800,8 +3318,17 @@
2800 3318 }
2801 3319
2802 3320 public function linkgraph_login()
2803 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 +
2804 3331 $post_data = metasync_sanitize_input_array($_POST);
2805 3332 $payload = array(
2806 3333 'username' => wp_unslash(sanitize_email($post_data['username'])),
2807 3334 'password' => wp_unslash(sanitize_text_field($post_data['password']))
@@ -2818,9 +3345,9 @@
2818 3345 ));
2819 3346
2820 3347 # Error handling for timeout or connection failures
2821 3348 if (is_wp_error($response)) {
2822 - 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
2823 3350 wp_send_json_error(array('message' => 'Token API request failed'));
2824 3351 wp_die();
2825 3352 }
2826 3353
@@ -2835,9 +3362,9 @@
2835 3362 {
2836 3363 $sync_heartbeat_data = new Metasync_Sync_Requests();
2837 3364 $response = $sync_heartbeat_data->SyncCustomerParams();
2838 3365
2839 - $responseCode = wp_remote_retrieve_response_code($response);
3366 + $responseCode = Metasync_Sync_Requests::get_response_code($response);
2840 3367 if ($responseCode == 200) {
2841 3368 return rest_ensure_response($response);
2842 3369 }
2843 3370 return rest_ensure_response($response);
@@ -2954,9 +3481,9 @@
2954 3481 $option_name = '_transient_metasync_sa_connect_active_' . $nonce_token;
2955 3482 $timeout_name = '_transient_timeout_metasync_sa_connect_active_' . $nonce_token;
2956 3483
2957 3484 // Check expiry first
2958 - $timeout = $wpdb->get_var(
3485 + $timeout = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
2959 3486 $wpdb->prepare(
2960 3487 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
2961 3488 $timeout_name
2962 3489 )
@@ -2963,14 +3490,14 @@
2963 3490 );
2964 3491
2965 3492 if ($timeout && (int) $timeout < time()) {
2966 3493 // Expired — clean up orphan rows
2967 - $wpdb->delete($wpdb->options, array('option_name' => $option_name));
2968 - $wpdb->delete($wpdb->options, array('option_name' => $timeout_name));
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
2969 3496 return false;
2970 3497 }
2971 3498
2972 - $value = $wpdb->get_var(
3499 + $value = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
2973 3500 $wpdb->prepare(
2974 3501 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
2975 3502 $option_name
2976 3503 )
@@ -2994,9 +3521,9 @@
2994 3521 $option_name = '_transient_' . $transient_key;
2995 3522 $timeout_name = '_transient_timeout_' . $transient_key;
2996 3523
2997 3524 // Check expiry first
2998 - $timeout = $wpdb->get_var(
3525 + $timeout = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
2999 3526 $wpdb->prepare(
3000 3527 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
3001 3528 $timeout_name
3002 3529 )
@@ -3002,14 +3529,14 @@
3002 3529 )
3003 3530 );
3004 3531
3005 3532 if ($timeout && (int) $timeout < time()) {
3006 - $wpdb->delete($wpdb->options, array('option_name' => $option_name));
3007 - $wpdb->delete($wpdb->options, array('option_name' => $timeout_name));
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
3008 3535 return false;
3009 3536 }
3010 3537
3011 - $value = $wpdb->get_var(
3538 + $value = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
3012 3539 $wpdb->prepare(
3013 3540 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
3014 3541 $option_name
3015 3542 )
@@ -3036,10 +3563,10 @@
3036 3563 {
3037 3564 delete_transient($transient_name);
3038 3565 if (wp_using_ext_object_cache()) {
3039 3566 global $wpdb;
3040 - $wpdb->delete($wpdb->options, array('option_name' => '_transient_' . $transient_name));
3041 - $wpdb->delete($wpdb->options, array('option_name' => '_transient_timeout_' . $transient_name));
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
3042 3569 }
3043 3570 }
3044 3571
3045 3572 /**
@@ -3056,14 +3583,14 @@
3056 3583 {
3057 3584 set_transient($transient_name, $value, $expiration);
3058 3585 if (wp_using_ext_object_cache()) {
3059 3586 global $wpdb;
3060 - $wpdb->replace($wpdb->options, array(
3587 + $wpdb->replace($wpdb->options, array( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
3061 3588 'option_name' => '_transient_' . $transient_name,
3062 3589 'option_value' => maybe_serialize($value),
3063 3590 'autoload' => 'no',
3064 3591 ));
3065 - $wpdb->replace($wpdb->options, array(
3592 + $wpdb->replace($wpdb->options, array( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
3066 3593 'option_name' => '_transient_timeout_' . $transient_name,
3067 3594 'option_value' => time() + $expiration,
3068 3595 'autoload' => 'no',
3069 3596 ));
@@ -3417,9 +3944,9 @@
3417 3944 *
3418 3945 * @param array $body_params Request body parameters
3419 3946 * @return array|WP_Error Validated parameters array or WP_Error
3420 3947 */
3421 - private function extract_and_validate_searchatlas_params($body_params)
3948 + public function extract_and_validate_searchatlas_params($body_params)
3422 3949 {
3423 3950 $validation_errors = array();
3424 3951
3425 3952 // Extract parameters
@@ -3440,14 +3967,18 @@
3440 3967 $validation_errors['api_key'] = 'API key contains invalid characters. Only alphanumeric, dash, underscore, and dot allowed';
3441 3968 }
3442 3969
3443 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.
3444 3975 if (empty($uuid)) {
3445 3976 $validation_errors['uuid'] = 'UUID is required';
3446 3977 } elseif (!is_string($uuid)) {
3447 3978 $validation_errors['uuid'] = 'UUID must be a string';
3448 - } elseif (strlen($uuid) > 100) {
3449 - $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';
3450 3981 }
3451 3982
3452 3983 // Validate status_code
3453 3984 if (!is_numeric($status_code)) {
@@ -3500,9 +4031,9 @@
3500 4031 } elseif (strlen($whitelabel_domain) > 255) {
3501 4032 $validation_errors['whitelabel_domain'] = 'Whitelabel domain must not exceed 255 characters';
3502 4033 } elseif (!filter_var($whitelabel_domain, FILTER_VALIDATE_URL)) {
3503 4034 $validation_errors['whitelabel_domain'] = 'Whitelabel domain must be a valid URL';
3504 - } 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'])) {
3505 4036 $validation_errors['whitelabel_domain'] = 'Whitelabel domain must use http or https protocol';
3506 4037 }
3507 4038 }
3508 4039
@@ -3515,9 +4046,9 @@
3515 4046 $validation_errors['whitelabel_logo'] = 'Whitelabel logo URL must not exceed 500 characters';
3516 4047 } else {
3517 4048 // If URL is invalid, we'll clear it later but not fail the POST
3518 4049 if (!filter_var($whitelabel_logo, FILTER_VALIDATE_URL) ||
3519 - !in_array(parse_url($whitelabel_logo, PHP_URL_SCHEME), ['http', 'https'])) {
4050 + !in_array(wp_parse_url($whitelabel_logo, PHP_URL_SCHEME), ['http', 'https'])) {
3520 4051 // Don't add to validation_errors - let POST succeed but clear the field
3521 4052 }
3522 4053 }
3523 4054 }
@@ -3608,10 +4139,22 @@
3608 4139 $options['general'] = array();
3609 4140 }
3610 4141 // Only update the API key in settings if status_code is 200 (success)
3611 4142 if ($status_code === 200) {
3612 - $options['general']['searchatlas_api_key'] = $new_api_key;
3613 - $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 + }
3614 4157 // Note: OTTO SSR is always enabled by default, no need to set
3615 4158
3616 4159 // Granular otto_config_status: record when SSO completed (ISO 8601 UTC)
3617 4160 $options['general']['sso_completed_at'] = gmdate('Y-m-d\TH:i:s\Z');
@@ -3630,35 +4173,43 @@
3630 4173
3631 4174 } else {
3632 4175 }
3633 4176
3634 - // 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.
3635 4197 if ($is_whitelabel) {
3636 - // whitelabel_company_name → Plugin Name (general plugin branding)
3637 - if (!empty($whitelabel_company_name)) {
3638 - $options['general']['white_label_plugin_name'] = $whitelabel_company_name;
4198 + if (!isset($options['whitelabel'])) {
4199 + $options['whitelabel'] = array();
3639 4200 }
3640 -
3641 - // whitelabel_otto → OTTO Features naming (separate from plugin name)
3642 - if (!empty($whitelabel_otto)) {
3643 - $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;
3644 4205 }
3645 - } else {
3646 - // Clear whitelabel fields when not whitelabel
3647 - unset($options['general']['white_label_plugin_name']);
3648 - unset($options['general']['whitelabel_otto_name']);
4206 + if (!empty($whitelabel_logo)) {
4207 + $options['whitelabel']['logo'] = $whitelabel_logo;
4208 + }
4209 + $options['whitelabel']['updated_at'] = time();
3649 4210 }
3650 4211
3651 - // Store whitelabel settings (hidden from UI but accessible to plugin logic)
3652 - if (!isset($options['whitelabel'])) {
3653 - $options['whitelabel'] = array();
3654 - }
3655 -
3656 - $options['whitelabel']['is_whitelabel'] = $is_whitelabel;
3657 - $options['whitelabel']['domain'] = $whitelabel_domain;
3658 - $options['whitelabel']['logo'] = $whitelabel_logo;
3659 - $options['whitelabel']['updated_at'] = time();
3660 -
3661 4212 // Log whitelabel configuration
3662 4213 if ($is_whitelabel) {
3663 4214 $log_parts = array('Whitelabel mode enabled');
3664 4215 if (!empty($whitelabel_domain)) {
@@ -3676,11 +4227,12 @@
3676 4227
3677 4228 }
3678 4229
3679 4230 $save_result = Metasync::set_option($options);
4231 + Metasync::invalidate_api_key_cache();
3680 4232
3681 4233 if (!$save_result) {
3682 - 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
3683 4235 } else {
3684 4236 if ($status_code === 200) {
3685 4237 // Option 1: Set heartbeat cache and last-known to CONNECTED so dashboard shows iframe immediately.
3686 4238 // If the immediate heartbeat then fails (e.g. backoff), Option 2 prevents overwriting this to DISCONNECTED.
@@ -3692,9 +4244,9 @@
3692 4244 );
3693 4245 set_transient('metasync_heartbeat_status_cache', $cache_data, 300);
3694 4246 update_option('metasync_last_known_connection_state', true);
3695 4247 do_action('metasync_heartbeat_state_key_pending'); // PR3: burst mode
3696 - $this->trigger_immediate_heartbeat_after_sa_connect();
4248 + $this->trigger_immediate_heartbeat_after_sa_connect($token);
3697 4249 }
3698 4250 }
3699 4251
3700 4252 return true;
@@ -3699,9 +4251,9 @@
3699 4251
3700 4252 return true;
3701 4253
3702 4254 } catch (Exception $e) {
3703 - 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
3704 4256 return false;
3705 4257 }
3706 4258 }
3707 4259
@@ -3713,9 +4265,9 @@
3713 4265 {
3714 4266 global $wpdb;
3715 4267
3716 4268 // Clear all JWT token transients
3717 - $deleted = $wpdb->query(
4269 + $deleted = $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
3718 4270 $wpdb->prepare(
3719 4271 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
3720 4272 '_transient_metasync_jwt_token_%'
3721 4273 )
@@ -3721,9 +4273,9 @@
3721 4273 )
3722 4274 );
3723 4275
3724 4276 // Also clear timeout transients
3725 - $wpdb->query(
4277 + $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
3726 4278 $wpdb->prepare(
3727 4279 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
3728 4280 '_transient_timeout_metasync_jwt_token_%'
3729 4281 )
@@ -3734,21 +4286,28 @@
3734 4286
3735 4287 /**
3736 4288 * Trigger immediate heartbeat check after successful Search Atlas connect authentication
3737 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.
3738 4292 */
3739 - private function trigger_immediate_heartbeat_after_sa_connect()
4293 + private function trigger_immediate_heartbeat_after_sa_connect($token)
3740 4294 {
3741 4295 try {
3742 4296 // Use WordPress action system to trigger immediate heartbeat check
3743 4297 // This is more reliable than trying to access admin class directly
3744 4298 do_action('metasync_trigger_immediate_heartbeat', 'Search Atlas Connect - API key and UUID retrieved');
3745 -
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 +
3746 4305 // Also ensure heartbeat cron is scheduled now that we have an API key
3747 4306 do_action('metasync_ensure_heartbeat_cron_scheduled');
3748 4307
3749 4308 } catch (Exception $e) {
3750 - 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
3751 4310 }
3752 4311 }
3753 4312
3754 4313 public function get_item_schema()
@@ -3766,15 +4325,15 @@
3766 4325 'type' => 'object',
3767 4326 // In JSON Schema you can specify object properties in the properties attribute.
3768 4327 'properties' => array(
3769 4328 'id' => array(
3770 - 'description' => esc_html__('Unique identifier for the object.', 'my-textdomain'),
4329 + 'description' => esc_html__('Unique identifier for the object.', 'metasync'),
3771 4330 'type' => 'integer',
3772 4331 'context' => array('view', 'edit', 'embed'),
3773 4332 'readonly' => true,
3774 4333 ),
3775 4334 'content' => array(
3776 - 'description' => esc_html__('The content for the object.', 'my-textdomain'),
4335 + 'description' => esc_html__('The content for the object.', 'metasync'),
3777 4336 'type' => 'string',
3778 4337 ),
3779 4338 ),
3780 4339 );
@@ -3865,11 +4424,11 @@
3865 4424
3866 4425 # Try alternative parameter methods
3867 4426 $body_params = $request->get_body_params();
3868 4427 $key_param = $request->get_param('key');
3869 - $post_key = $_POST['key'] ?? null;
3870 - $request_key = $_REQUEST['key'] ?? null;
3871 - $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
3872 4431
3873 4432 # Try to get key from multiple sources
3874 4433 $key_value = null;
3875 4434
@@ -3899,9 +4458,9 @@
3899 4458 }
3900 4459
3901 4460 # Validate that key is provided
3902 4461 if (empty($key_value)) {
3903 - return rest_ensure_response(array(
4462 + return new WP_REST_Response(array(
3904 4463 'error' => 'Key parameter is required',
3905 4464 'code' => 'missing_key'
3906 4465 ), 400);
3907 4466 }
@@ -3913,9 +4472,9 @@
3913 4472 $key = basename(sanitize_file_name($data['key']));
3914 4473
3915 4474 # Validate key is not empty after sanitization and contains no path separators
3916 4475 if (empty($key) || preg_match('/[\/\\\\]/', $key) || strpos($key, '..') !== false) {
3917 - return rest_ensure_response(array(
4476 + return new WP_REST_Response(array(
3918 4477 'error' => 'Invalid key provided',
3919 4478 'code' => 'invalid_key'
3920 4479 ), 400);
3921 4480 }
@@ -3927,42 +4486,84 @@
3927 4486 $safe_key = sanitize_file_name( $key );
3928 4487 $file_path = $wp_root . $safe_key . '.txt';
3929 4488 $real_root = realpath( $wp_root );
3930 4489 if ( false === $real_root || 0 !== strpos( realpath( dirname( $file_path ) ), $real_root ) ) {
3931 - return rest_ensure_response(array(
4490 + return new WP_REST_Response(array(
3932 4491 'error' => 'Invalid file path',
3933 4492 'code' => 'invalid_path'
3934 4493 ), 400);
3935 4494 }
3936 4495
3937 - # Check if file already exists
3938 - if (file_exists($file_path)) {
3939 - return rest_ensure_response(array(
3940 - 'error' => 'File already exists',
3941 - 'code' => 'file_exists',
3942 - 'file_path' => $file_path
3943 - ), 409);
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 + }
3944 4503 }
4504 + if (class_exists('Metasync_Bing_Instant_Index')) {
4505 + Metasync_Bing_Instant_Index::register_virtual_key($safe_key);
4506 + }
3945 4507
3946 - # Attempt to create the file
3947 - $result = file_put_contents($file_path, $safe_key);
3948 -
3949 - # Check if file creation was successful
3950 - if ($result === false) {
3951 - return rest_ensure_response(array(
3952 - 'error' => 'Failed to create file',
3953 - 'code' => 'file_creation_failed',
3954 - 'file_path' => $file_path
3955 - ), 500);
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 + }
3956 4547 }
3957 -
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 +
3958 4558 # Return success response
3959 - return rest_ensure_response(array(
3960 - 'success' => true,
3961 - 'message' => 'Key file created successfully',
4559 + return new WP_REST_Response(array(
4560 + 'success' => true,
4561 + 'message' => 'Key file created successfully',
3962 4562 'file_path' => $file_path,
3963 - 'key' => $key,
3964 - '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
3965 4566 ), 200);
3966 4567 }
3967 4568
3968 4569 }