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 +944 -364 2.6.10 → 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(
@@ -959,9 +1076,9 @@
959 1076 }
960 1077
961 1078 public function get_errorlogs()
962 1079 {
963 - $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
964 1081 if (!isset($get_data['limit']))
965 1082 return false;
966 1083 $limit = sanitize_text_field($get_data['limit']) ?? null;
967 1084
@@ -1149,9 +1266,9 @@
1149 1266 }
1150 1267 } catch (Throwable $e) {
1151 1268 // Fallback to DOMDocument if Dom\HTMLDocument fails
1152 1269 if (defined('WP_DEBUG_LOG') && WP_DEBUG_LOG) {
1153 - 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
1154 1271 }
1155 1272 $dom = new DOMDocument();
1156 1273 libxml_use_internal_errors(true);
1157 1274 $encoded_content = $this->encode_numeric_entities($content);
@@ -1351,9 +1468,9 @@
1351 1468 $image_id = attachment_url_to_postid($node->getAttribute('src') );
1352 1469 $image_alt = get_post_meta($image_id, '_wp_attachment_image_alt', TRUE);
1353 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]';
1354 1471
1355 - 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
1356 1473
1357 1474 }
1358 1475 }elseif($node->nodeName === 'iframe'){
1359 1476 $result= '[et_pb_code _builder_version="'.ET_BUILDER_VERSION.'" _module_preset="default" global_colors_info="{}"]'.$node->ownerDocument->saveHTML($node).'[/et_pb_code]' ;
@@ -1481,64 +1598,244 @@
1481 1598 return $attachment_id;
1482 1599 }
1483 1600
1484 1601 /**
1485 - * Index a post with Google Indexing API
1486 - *
1487 - * @param int $post_id WordPress post ID
1488 - * @param string $post_type WordPress post type (post, page, etc.)
1489 - * @param string $post_status WordPress post status (publish, draft, etc.)
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
1490 1611 */
1491 - public function metasync_google_index_post($post_id, $post_type, $post_status)
1612 + private function strip_leading_title_h1($content, $title)
1492 1613 {
1493 - // Only index published posts/pages
1494 - 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)) {
1495 1674 return;
1496 1675 }
1497 1676
1498 - // Only index posts and pages (can be extended as needed)
1499 - $allowed_post_types = array('post', 'page');
1500 - if (!in_array($post_type, $allowed_post_types)) {
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])) {
1501 1680 return;
1502 1681 }
1503 1682
1504 - try {
1505 - // Load Google Index functionality if not already loaded
1506 - if (!function_exists('google_index_post')) {
1507 - $google_index_path = plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php';
1508 - if (file_exists($google_index_path)) {
1509 - require_once $google_index_path;
1510 - } else {
1511 - error_log('MetaSync Google Index: google-index-init.php not found at ' . $google_index_path);
1512 - return;
1513 - }
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;
1514 1778 }
1779 + }
1780 + return false;
1781 + }
1515 1782
1516 - // Attempt to index the post with Google
1517 - if (function_exists('google_index_post')) {
1518 - $result = google_index_post($post_id, $post_type, 'update');
1519 -
1520 - if (isset($result['success']) && $result['success']) {
1521 - error_log(sprintf(
1522 - 'MetaSync Google Index: Successfully indexed %s (ID: %d, Type: %s)',
1523 - get_the_title($post_id),
1524 - $post_id,
1525 - $post_type
1526 - ));
1527 - } else {
1528 - error_log(sprintf(
1529 - 'MetaSync Google Index: Failed to index %s (ID: %d, Type: %s) - %s',
1530 - get_the_title($post_id),
1531 - $post_id,
1532 - $post_type,
1533 - isset($result['error']['message']) ? $result['error']['message'] : 'Unknown error'
1534 - ));
1535 - }
1536 - }
1537 - } catch (Exception $e) {
1538 - // Log any exceptions but don't break the main functionality
1539 - 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;
1540 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;
1541 1838 }
1542 1839
1543 1840 public function create_item($request)
1544 1841 {
@@ -1573,8 +1870,9 @@
1573 1870 check if the create_item is called by set_landing_page function or not
1574 1871 by doing this we will prevent html from going into builder page option
1575 1872 */
1576 1873 $isOttoAiPage = !empty($item['otto_ai_page']) && filter_var($item['otto_ai_page'], FILTER_VALIDATE_BOOLEAN);
1874 + $content = [];
1577 1875 if(!isset($item['is_landing_page']) && !$isOttoAiPage && empty($item['style_data']) ){
1578 1876
1579 1877 # Get Current Post type
1580 1878 $current_post_type = isset($item['post_type']) ? sanitize_text_field($item['post_type']) : 'post';
@@ -1592,37 +1890,31 @@
1592 1890 } elseif (!empty($theme_template) && stripos($theme_template, 'flatsome') !== false) {
1593 1891 $is_flatsome_theme = true;
1594 1892 }
1595 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 +
1596 1902 # Skip title/image prepending for Flatsome (it displays them by default)
1597 1903 if (!$is_flatsome_theme) {
1598 - # Get the setting for the post template
1904 + # Get the setting for the post template (feature-image detection)
1599 1905 $title_and_feature_image = $this->append_content_if_missing_elements($current_post_type);
1600 1906
1601 - # 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
1602 1908 if(!$title_and_feature_image['image_in_content'] && !empty($item['hero_image_url'])){
1603 -
1604 - # Prepend the feature image
1605 - $item['post_content'] = '<img src="'.$item['hero_image_url'].'" />'.$item['post_content'] ;
1909 + $item['post_content'] = '<img src="' . esc_url_raw($item['hero_image_url']) . '" />'.$item['post_content'] ;
1606 1910 }
1607 1911
1608 - # Check if the post title is there in the template or not
1609 - if(!$title_and_feature_image['title_in_headings']){
1610 -
1611 - # WP-337: Only prepend H1 if content doesn't already start with an H1 containing the same text
1612 - $skip_prepend = false;
1613 - $content_trimmed = trim($item['post_content']);
1614 - if (preg_match('/^<h1[^>]*>(.*?)<\/h1>/is', $content_trimmed, $h1_match)) {
1615 - $existing_h1_text = trim(strip_tags($h1_match[1]));
1616 - if (strcasecmp($existing_h1_text, trim($item['post_title'])) === 0) {
1617 - $skip_prepend = true;
1618 - }
1619 - }
1620 - if (!$skip_prepend) {
1621 - $item['post_content'] = '<h1>'.$item['post_title'].'</h1>'.$item['post_content'] ;
1622 - }
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)){
1915 + $item['post_content'] = '<h1>'.$item['post_title'].'</h1>'.$item['post_content'] ;
1623 1916 }
1624 -
1625 1917 }
1626 1918 # This will be used by create_page function
1627 1919 $content = $this->metasync_upload_post_content($item,false,false);
1628 1920 }elseif(isset($item['is_landing_page']) && $item['is_landing_page'] == true){
@@ -1656,13 +1948,13 @@
1656 1948 $new_post['post_name'] = sanitize_text_field($item['permalink']);
1657 1949 }
1658 1950
1659 1951 if (isset($item['post_date']) && !empty($item['post_date'])) {
1660 - $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'];
1661 1953 if (!$is_valid_date) {
1662 1954 return new WP_Error(
1663 1955 'rest_post_invalid_date',
1664 - esc_html__('Post date is not valid'),
1956 + esc_html__('Post date is not valid', 'metasync'),
1665 1957 array('status' => 400)
1666 1958 );
1667 1959 }
1668 1960
@@ -1695,20 +1987,16 @@
1695 1987 # $getPostID_byURL = @get_page_by_path($item['permalink'], OBJECT, $new_post['post_type'])->ID;
1696 1988 # Fix to avoid PHP error if the get_page_by_path returns null
1697 1989 $getPostID_byURL = @get_page_by_path($item['permalink'], OBJECT, $new_post['post_type']);
1698 1990 $getPostID_byURL = $getPostID_byURL ? $getPostID_byURL->ID : null;
1991 + $matched_by_title = false;
1699 1992 if ($getPostID_byURL == NULL) {
1700 1993 // check if the post_title is set and not empty when called by otto_ai_page
1701 1994 if(isset($new_post['post_title']) && $new_post['post_title']!==''){
1702 - $getPostID_byURL = new WP_Query(
1703 - array(
1704 - 'post_type' => $new_post['post_type'],
1705 - 'title' => $new_post['post_title']
1706 - )
1707 - );
1708 - $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 + }
1709 1998 }
1710 - }
1711 1999
1712 2000 // Allow HTML code for landing page
1713 2001 if (isset($item['is_landing_page']) && $item['is_landing_page'] == true) {
1714 2002 kses_remove_filters();
@@ -1733,11 +2021,17 @@
1733 2021 update_post_meta($post_id, 'metasync_post', 'yes');
1734 2022 }
1735 2023
1736 2024 } else {
1737 - $new_post['ID'] = $post_id = $getPostID_byURL;
1738 - wp_update_post($new_post);
1739 - 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);
1740 2034 $permalink = get_permalink($post_id);
1741 2035 }
1742 2036
1743 2037 if (isset($item['is_landing_page']) && $item['is_landing_page'] == true) {
@@ -1779,13 +2073,17 @@
1779 2073 $post_meta['searchatlas_embed_bottom'] = $item['searchatlas_embed_bottom'];
1780 2074 }
1781 2075
1782 2076 // Add custom fields to posts and pages
1783 - foreach ($post_meta as $key => $value) {
1784 - // if (!empty($value) && !is_null($value)) {
1785 - add_post_meta($post_id, $key, $value, true);
1786 - // }
1787 - }
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 + );
1788 2086
1789 2087
1790 2088 $attachment_id = '';
1791 2089 if (isset($item['hero_image_url']) && !empty($item['hero_image_url'])) {
@@ -1803,8 +2101,14 @@
1803 2101 $redirection['url'] = sanitize_url($item['redirection_url']);
1804 2102 }
1805 2103 if (!empty($redirection)) {
1806 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 + }
1807 2111 }
1808 2112
1809 2113 $post_cattegories = [];
1810 2114 # if ($new_post['post_type'] === 'post' && is_array(@$item['post_categories'])) {
@@ -1860,17 +2164,28 @@
1860 2164 'action' => strtolower($action)
1861 2165 ])
1862 2166 ]);
1863 2167
1864 - # 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.
1865 2170 try {
1866 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']));
1867 2173 } catch (Exception $e) {
1868 - 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
1869 2175 }
2176 + }
1870 2177
1871 - // Google Indexing Integration
1872 - $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 + }
1873 2188 }
1874 2189
1875 2190 $respCreatePosts[$index] = array_merge($new_post, $post_meta);
1876 2191 ksort($respCreatePosts[$index]);
@@ -1914,9 +2229,9 @@
1914 2229 }
1915 2230
1916 2231 public function delete_item()
1917 2232 {
1918 - $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
1919 2234 if (!isset($get_data['ID']))
1920 2235 return false;
1921 2236
1922 2237 $post_id = sanitize_text_field($get_data['ID']) ?? null;
@@ -1922,18 +2237,18 @@
1922 2237 $post_id = sanitize_text_field($get_data['ID']) ?? null;
1923 2238 $post = get_post($post_id);
1924 2239 if ($post) {
1925 2240 wp_delete_post($post_id);
2241 + // HTTP 204 requires no body for response, so no message is set.
1926 2242 return new WP_Error(
1927 2243 'rest_post_delete_success',
1928 - esc_html__(''),
1929 - // HTTP 204 requires no body for response
2244 + '',
1930 2245 array('status' => 204)
1931 2246 );
1932 2247 }
1933 2248 return new WP_Error(
1934 2249 'rest_post_delete_fail',
1935 - esc_html__('No post found in the database with requested ID.'),
2250 + esc_html__('No post found in the database with requested ID.', 'metasync'),
1936 2251 array('status' => 400)
1937 2252 );
1938 2253 }
1939 2254
@@ -1938,9 +2253,9 @@
1938 2253 }
1939 2254
1940 2255 public function get_items($request)
1941 2256 {
1942 - $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
1943 2258
1944 2259 # let us check if request send post id and is valid int
1945 2260 if(isset($get_data['post_id']) AND intval($get_data['post_id']) > 0){
1946 2261
@@ -2280,9 +2595,9 @@
2280 2595 $post_data = get_post($post_id);
2281 2596 if (!$post_data) {
2282 2597 return new WP_Error(
2283 2598 'rest_post_update_fail',
2284 - esc_html__('No post found in the database with requested ID.'),
2599 + esc_html__('No post found in the database with requested ID.', 'metasync'),
2285 2600 array('status' => 400)
2286 2601 );
2287 2602 }
2288 2603
@@ -2307,12 +2622,19 @@
2307 2622 $update_params['meta_robots'] = $post['meta_robots'] ?
2308 2623 sanitize_text_field($post['meta_robots']) : $get_robots_meta;
2309 2624 }
2310 2625 if (isset($post['meta_canonical']) && !empty($post['meta_canonical'])) {
2311 - $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 + }
2312 2632 }
2313 2633
2314 2634 $isOttoAiPage = !empty($post['otto_ai_page']) && filter_var($post['otto_ai_page'], FILTER_VALIDATE_BOOLEAN);
2635 + $content = [];
2636 + $permalink = '';
2315 2637 if (isset($post['post_content']) && !empty($post['post_content']) && !$isOttoAiPage) {
2316 2638
2317 2639 # Above we are updating the post_type so we have to get latest value that has been change on the server
2318 2640 $post_fresh_data = get_post($post['post_id']);
@@ -2331,35 +2653,29 @@
2331 2653 } elseif (!empty($theme_template) && stripos($theme_template, 'flatsome') !== false) {
2332 2654 $is_flatsome_theme = true;
2333 2655 }
2334 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 +
2335 2664 # Skip title/image prepending for Flatsome (it displays them by default)
2336 2665 if (!$is_flatsome_theme) {
2337 - # Get the setting for the post template
2666 + # Get the setting for the post template (feature-image detection)
2338 2667 $title_and_feature_image = $this->append_content_if_missing_elements($post_fresh_data->post_type);
2339 2668
2340 - # 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
2341 2670 if(!$title_and_feature_image['image_in_content'] && !empty($post['hero_image_url'])){
2342 -
2343 - # Prepend the feature image
2344 - $post['post_content'] = '<img src="'.$post['hero_image_url'].'" />'.$post['post_content'] ;
2671 + $post['post_content'] = '<img src="' . esc_url_raw($post['hero_image_url']) . '" />'.$post['post_content'] ;
2345 2672 }
2346 2673
2347 - # Check if the post title is there in the template or not
2348 - if(!$title_and_feature_image['title_in_headings']){
2349 -
2350 - # WP-337: Only prepend H1 if content doesn't already start with an H1 containing the same text
2351 - $skip_prepend = false;
2352 - $content_trimmed = trim($post['post_content']);
2353 - if (preg_match('/^<h1[^>]*>(.*?)<\/h1>/is', $content_trimmed, $h1_match)) {
2354 - $existing_h1_text = trim(strip_tags($h1_match[1]));
2355 - if (strcasecmp($existing_h1_text, trim($post['post_title'])) === 0) {
2356 - $skip_prepend = true;
2357 - }
2358 - }
2359 - if (!$skip_prepend) {
2360 - $post['post_content'] = '<h1>'.$post['post_title'].'</h1>'.$post['post_content'] ;
2361 - }
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'] ;
2362 2678 }
2363 2679 }
2364 2680 // This will be used by update_page function
2365 2681 $content = $this->metasync_upload_post_content($post,false,false);
@@ -2402,9 +2718,8 @@
2402 2718 }
2403 2719
2404 2720 if (isset($post['post_status']) && !empty($post['post_status'])) {
2405 2721 $update_params['post_status'] = $post['post_status'] ? sanitize_text_field($post['post_status']) : 'publish';
2406 - $permalink = get_permalink($post_id);
2407 2722 }
2408 2723 if (isset($post['permalink']) || !empty($post['permalink'])) {
2409 2724 $update_params['post_name'] = sanitize_text_field($post['permalink']);
2410 2725 }
@@ -2419,40 +2734,8 @@
2419 2734 );
2420 2735 }
2421 2736
2422 2737
2423 - if (isset($post['post_date']) && !empty($post['post_date']) && false) {
2424 - $is_valid_date = date('Y-m-d', strtotime($post['post_date'])) == $post['post_date'];
2425 - if (!$is_valid_date) {
2426 - return new WP_Error(
2427 - 'rest_post_invalid_date',
2428 - esc_html__('Post date is not valid'),
2429 - array('status' => 400)
2430 - );
2431 - }
2432 -
2433 - $date_limit_str = strtotime(date('Y-m-d') . '-2 month');
2434 - $post_date_str = strtotime($post['post_date']);
2435 -
2436 - if ($date_limit_str >= $post_date_str) {
2437 - $newDate = date('Y-m-d', strtotime('-2 month'));
2438 - return new WP_Error(
2439 - 'rest_post_greater_date',
2440 - esc_html__("Post date should be greater then " . $newDate),
2441 - array('status' => 400)
2442 - );
2443 - }
2444 -
2445 - if ($post_date_str > strtotime(date('Y-m-d'))) {
2446 - return new WP_Error(
2447 - 'rest_post_greater_date',
2448 - esc_html__('Post date should be less then Today'),
2449 - array('status' => 400)
2450 - );
2451 - }
2452 - $update_params['post_date'] = sanitize_text_field($post['post_date'] . date(' h:i:s'));
2453 - }
2454 -
2455 2738 $post_cattegories = [];
2456 2739 # if ($post_data && $post_data->post_type === 'post' && is_array(@$post['post_categories'])) {
2457 2740
2458 2741 # fixed Undefined array key issue
@@ -2475,12 +2758,13 @@
2475 2758 $attachment_id = $this->metasync_handle_hero_image($post_id, $post['hero_image_url'], $post['hero_image_alt_text']);
2476 2759 }
2477 2760
2478 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);
2479 2765 if(isset($content['elementor_meta_data'])){
2480 - foreach ($content['elementor_meta_data'] as $key => $value) {
2481 - update_post_meta($post_id, $key, $value);
2482 - }
2766 + $this->metasync_apply_builder_meta($post_id, $content['elementor_meta_data']);
2483 2767 if ( did_action( 'elementor/loaded' ) ) {
2484 2768 // Clear Elementor cache for the specified post ID
2485 2769 \Elementor\Plugin::instance()->files_manager->clear_cache();
2486 2770 }
@@ -2495,8 +2779,15 @@
2495 2779 delete_post_meta($post_id, '_elementor_page_assets');
2496 2780 delete_post_meta($post_id, '_elementor_page_settings');
2497 2781 }
2498 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 +
2499 2790 $redirection = array();
2500 2791 if (!empty($post['redirection_enable']) && !is_null($post['redirection_enable'])) {
2501 2792 $redirection['enable'] = sanitize_text_field($post['redirection_enable']);
2502 2793 }
@@ -2507,8 +2798,14 @@
2507 2798 $redirection['url'] = sanitize_url($post['redirection_url']);
2508 2799 }
2509 2800 if (!empty($redirection)) {
2510 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 + }
2511 2808 }
2512 2809
2513 2810
2514 2811 $post_revisions = wp_get_post_revisions($post_id);
@@ -2515,9 +2812,8 @@
2515 2812 // Sync post categories to customer dashboard
2516 2813 $this->lgSendCustomerPostParams();
2517 2814
2518 2815 unset($update_params['post_name']);
2519 - unset($update_params['post_category']);
2520 2816
2521 2817 $update_params['post_categories'] = $post_cattegories;
2522 2818 $update_params['post_tags'] = $post_tags;
2523 2819 $update_params['post_id'] = (int) $post_id;
@@ -2547,14 +2843,9 @@
2547 2843
2548 2844 }
2549 2845 # update the content
2550 2846 // add and update the post meta
2551 - foreach ($post_meta_data as $key => $value) {
2552 - // if (!empty($value) && !is_null($value)) {
2553 -
2554 - update_post_meta($post_id, $key, $value);
2555 - //
2556 - }
2847 + $this->metasync_apply_builder_meta($post_id, $post_meta_data);
2557 2848 #check if the elementor plugin is active
2558 2849 if ( did_action( 'elementor/loaded' ) ) {
2559 2850 # Clear Elementor cache for the specified post ID
2560 2851 \Elementor\Plugin::instance()->files_manager->clear_cache();
@@ -2587,14 +2878,26 @@
2587 2878 'updated_fields' => array_keys($update_params)
2588 2879 ])
2589 2880 ]);
2590 2881
2591 - # Track Content Genius event in GA4
2882 + # Track Content Genius event in GA4 and PostHog
2592 2883 try {
2593 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']));
2594 2886 } catch (Exception $e) {
2595 - 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
2596 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 + }
2597 2900 }
2598 2901
2599 2902 ksort($update_params);
2600 2903 $data[] = $update_params;
@@ -2689,20 +2992,9 @@
2689 2992 if($isOttoAiPage){
2690 2993 // Change the page template from default to Metasync Template
2691 2994 $pageTemplate = Metasync_Template::TEMPLATE_NAME;
2692 2995 }
2693 - if ($pageTemplate !== 'default') {
2694 - update_post_meta($post_id, '_wp_page_template', $pageTemplate);
2695 - } else {
2696 - // Clear stale templates that conflict with the active page builder.
2697 - // e.g. metasync-blank from a previous OTTO sync, or elementor_canvas
2698 - // written by the HTML-to-builder converter on an Oxygen site.
2699 - $current = get_post_meta($post_id, '_wp_page_template', true);
2700 - $stale_templates = array(Metasync_Template::TEMPLATE_NAME, 'elementor_canvas', 'elementor_header_footer');
2701 - if (in_array($current, $stale_templates, true)) {
2702 - delete_post_meta($post_id, '_wp_page_template');
2703 - }
2704 - }
2996 + $this->metasync_write_page_template($post_id, $pageTemplate);
2705 2997 }
2706 2998
2707 2999 return rest_ensure_response($createPages);
2708 3000 }
@@ -2725,9 +3017,9 @@
2725 3017 $post_data = get_post($payload['post_id']);
2726 3018 if(!isset($post_data->post_type)){
2727 3019 return new WP_Error(
2728 3020 'rest_page_type_fail',
2729 - esc_html__('No page found in the database with requested ID.'),
3021 + esc_html__('No page found in the database with requested ID.', 'metasync'),
2730 3022 array('status' => 400)
2731 3023 );
2732 3024 }
2733 3025 if ($post_data->post_type !== 'page') {
@@ -2782,20 +3074,225 @@
2782 3074 if($isOttoAiPage){
2783 3075 // Change the page template from default to Metasync Template
2784 3076 $pageTemplate = Metasync_Template::TEMPLATE_NAME;
2785 3077 }
2786 - if ($pageTemplate !== 'default') {
2787 - 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);
2788 3291 } else {
2789 - // Clear stale templates that conflict with the active page builder.
2790 - $current = get_post_meta($post_id, '_wp_page_template', true);
2791 - $stale_templates = array(Metasync_Template::TEMPLATE_NAME, 'elementor_canvas', 'elementor_header_footer');
2792 - if (in_array($current, $stale_templates, true)) {
2793 - delete_post_meta($post_id, '_wp_page_template');
2794 - }
3292 + add_post_meta($post_id, $key, $value, true);
2795 3293 }
2796 3294 }
2797 - return rest_ensure_response($updatePages->data);
2798 3295 }
2799 3296
2800 3297 public function delete_page()
2801 3298 {
@@ -2810,9 +3307,9 @@
2810 3307 {
2811 3308 $sync_request = new Metasync_Sync_Requests();
2812 3309 $response = $sync_request->SyncCustomerParams();
2813 3310
2814 - $responseCode = wp_remote_retrieve_response_code($response);
3311 + $responseCode = Metasync_Sync_Requests::get_response_code($response);
2815 3312 if ($responseCode == 200) {
2816 3313 # Use current_time('mysql') for consistency with cron heartbeat.
2817 3314 $send_auth_token_timestamp = Metasync::get_option();
2818 3315 $send_auth_token_timestamp['general']['send_auth_token_timestamp'] = current_time('mysql');
@@ -2821,8 +3318,17 @@
2821 3318 }
2822 3319
2823 3320 public function linkgraph_login()
2824 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 +
2825 3331 $post_data = metasync_sanitize_input_array($_POST);
2826 3332 $payload = array(
2827 3333 'username' => wp_unslash(sanitize_email($post_data['username'])),
2828 3334 'password' => wp_unslash(sanitize_text_field($post_data['password']))
@@ -2839,9 +3345,9 @@
2839 3345 ));
2840 3346
2841 3347 # Error handling for timeout or connection failures
2842 3348 if (is_wp_error($response)) {
2843 - 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
2844 3350 wp_send_json_error(array('message' => 'Token API request failed'));
2845 3351 wp_die();
2846 3352 }
2847 3353
@@ -2856,9 +3362,9 @@
2856 3362 {
2857 3363 $sync_heartbeat_data = new Metasync_Sync_Requests();
2858 3364 $response = $sync_heartbeat_data->SyncCustomerParams();
2859 3365
2860 - $responseCode = wp_remote_retrieve_response_code($response);
3366 + $responseCode = Metasync_Sync_Requests::get_response_code($response);
2861 3367 if ($responseCode == 200) {
2862 3368 return rest_ensure_response($response);
2863 3369 }
2864 3370 return rest_ensure_response($response);
@@ -2975,9 +3481,9 @@
2975 3481 $option_name = '_transient_metasync_sa_connect_active_' . $nonce_token;
2976 3482 $timeout_name = '_transient_timeout_metasync_sa_connect_active_' . $nonce_token;
2977 3483
2978 3484 // Check expiry first
2979 - $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
2980 3486 $wpdb->prepare(
2981 3487 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
2982 3488 $timeout_name
2983 3489 )
@@ -2984,14 +3490,14 @@
2984 3490 );
2985 3491
2986 3492 if ($timeout && (int) $timeout < time()) {
2987 3493 // Expired — clean up orphan rows
2988 - $wpdb->delete($wpdb->options, array('option_name' => $option_name));
2989 - $wpdb->delete($wpdb->options, array('option_name' => $timeout_name));
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
2990 3496 return false;
2991 3497 }
2992 3498
2993 - $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
2994 3500 $wpdb->prepare(
2995 3501 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
2996 3502 $option_name
2997 3503 )
@@ -3015,9 +3521,9 @@
3015 3521 $option_name = '_transient_' . $transient_key;
3016 3522 $timeout_name = '_transient_timeout_' . $transient_key;
3017 3523
3018 3524 // Check expiry first
3019 - $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
3020 3526 $wpdb->prepare(
3021 3527 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
3022 3528 $timeout_name
3023 3529 )
@@ -3023,14 +3529,14 @@
3023 3529 )
3024 3530 );
3025 3531
3026 3532 if ($timeout && (int) $timeout < time()) {
3027 - $wpdb->delete($wpdb->options, array('option_name' => $option_name));
3028 - $wpdb->delete($wpdb->options, array('option_name' => $timeout_name));
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
3029 3535 return false;
3030 3536 }
3031 3537
3032 - $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
3033 3539 $wpdb->prepare(
3034 3540 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
3035 3541 $option_name
3036 3542 )
@@ -3057,10 +3563,10 @@
3057 3563 {
3058 3564 delete_transient($transient_name);
3059 3565 if (wp_using_ext_object_cache()) {
3060 3566 global $wpdb;
3061 - $wpdb->delete($wpdb->options, array('option_name' => '_transient_' . $transient_name));
3062 - $wpdb->delete($wpdb->options, array('option_name' => '_transient_timeout_' . $transient_name));
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
3063 3569 }
3064 3570 }
3065 3571
3066 3572 /**
@@ -3077,14 +3583,14 @@
3077 3583 {
3078 3584 set_transient($transient_name, $value, $expiration);
3079 3585 if (wp_using_ext_object_cache()) {
3080 3586 global $wpdb;
3081 - $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
3082 3588 'option_name' => '_transient_' . $transient_name,
3083 3589 'option_value' => maybe_serialize($value),
3084 3590 'autoload' => 'no',
3085 3591 ));
3086 - $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
3087 3593 'option_name' => '_transient_timeout_' . $transient_name,
3088 3594 'option_value' => time() + $expiration,
3089 3595 'autoload' => 'no',
3090 3596 ));
@@ -3438,9 +3944,9 @@
3438 3944 *
3439 3945 * @param array $body_params Request body parameters
3440 3946 * @return array|WP_Error Validated parameters array or WP_Error
3441 3947 */
3442 - private function extract_and_validate_searchatlas_params($body_params)
3948 + public function extract_and_validate_searchatlas_params($body_params)
3443 3949 {
3444 3950 $validation_errors = array();
3445 3951
3446 3952 // Extract parameters
@@ -3461,14 +3967,18 @@
3461 3967 $validation_errors['api_key'] = 'API key contains invalid characters. Only alphanumeric, dash, underscore, and dot allowed';
3462 3968 }
3463 3969
3464 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.
3465 3975 if (empty($uuid)) {
3466 3976 $validation_errors['uuid'] = 'UUID is required';
3467 3977 } elseif (!is_string($uuid)) {
3468 3978 $validation_errors['uuid'] = 'UUID must be a string';
3469 - } elseif (strlen($uuid) > 100) {
3470 - $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';
3471 3981 }
3472 3982
3473 3983 // Validate status_code
3474 3984 if (!is_numeric($status_code)) {
@@ -3521,9 +4031,9 @@
3521 4031 } elseif (strlen($whitelabel_domain) > 255) {
3522 4032 $validation_errors['whitelabel_domain'] = 'Whitelabel domain must not exceed 255 characters';
3523 4033 } elseif (!filter_var($whitelabel_domain, FILTER_VALIDATE_URL)) {
3524 4034 $validation_errors['whitelabel_domain'] = 'Whitelabel domain must be a valid URL';
3525 - } 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'])) {
3526 4036 $validation_errors['whitelabel_domain'] = 'Whitelabel domain must use http or https protocol';
3527 4037 }
3528 4038 }
3529 4039
@@ -3536,9 +4046,9 @@
3536 4046 $validation_errors['whitelabel_logo'] = 'Whitelabel logo URL must not exceed 500 characters';
3537 4047 } else {
3538 4048 // If URL is invalid, we'll clear it later but not fail the POST
3539 4049 if (!filter_var($whitelabel_logo, FILTER_VALIDATE_URL) ||
3540 - !in_array(parse_url($whitelabel_logo, PHP_URL_SCHEME), ['http', 'https'])) {
4050 + !in_array(wp_parse_url($whitelabel_logo, PHP_URL_SCHEME), ['http', 'https'])) {
3541 4051 // Don't add to validation_errors - let POST succeed but clear the field
3542 4052 }
3543 4053 }
3544 4054 }
@@ -3629,10 +4139,22 @@
3629 4139 $options['general'] = array();
3630 4140 }
3631 4141 // Only update the API key in settings if status_code is 200 (success)
3632 4142 if ($status_code === 200) {
3633 - $options['general']['searchatlas_api_key'] = $new_api_key;
3634 - $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 + }
3635 4157 // Note: OTTO SSR is always enabled by default, no need to set
3636 4158
3637 4159 // Granular otto_config_status: record when SSO completed (ISO 8601 UTC)
3638 4160 $options['general']['sso_completed_at'] = gmdate('Y-m-d\TH:i:s\Z');
@@ -3651,35 +4173,43 @@
3651 4173
3652 4174 } else {
3653 4175 }
3654 4176
3655 - // 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.
3656 4197 if ($is_whitelabel) {
3657 - // whitelabel_company_name → Plugin Name (general plugin branding)
3658 - if (!empty($whitelabel_company_name)) {
3659 - $options['general']['white_label_plugin_name'] = $whitelabel_company_name;
4198 + if (!isset($options['whitelabel'])) {
4199 + $options['whitelabel'] = array();
3660 4200 }
3661 -
3662 - // whitelabel_otto → OTTO Features naming (separate from plugin name)
3663 - if (!empty($whitelabel_otto)) {
3664 - $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;
3665 4205 }
3666 - } else {
3667 - // Clear whitelabel fields when not whitelabel
3668 - unset($options['general']['white_label_plugin_name']);
3669 - unset($options['general']['whitelabel_otto_name']);
4206 + if (!empty($whitelabel_logo)) {
4207 + $options['whitelabel']['logo'] = $whitelabel_logo;
4208 + }
4209 + $options['whitelabel']['updated_at'] = time();
3670 4210 }
3671 4211
3672 - // Store whitelabel settings (hidden from UI but accessible to plugin logic)
3673 - if (!isset($options['whitelabel'])) {
3674 - $options['whitelabel'] = array();
3675 - }
3676 -
3677 - $options['whitelabel']['is_whitelabel'] = $is_whitelabel;
3678 - $options['whitelabel']['domain'] = $whitelabel_domain;
3679 - $options['whitelabel']['logo'] = $whitelabel_logo;
3680 - $options['whitelabel']['updated_at'] = time();
3681 -
3682 4212 // Log whitelabel configuration
3683 4213 if ($is_whitelabel) {
3684 4214 $log_parts = array('Whitelabel mode enabled');
3685 4215 if (!empty($whitelabel_domain)) {
@@ -3697,11 +4227,12 @@
3697 4227
3698 4228 }
3699 4229
3700 4230 $save_result = Metasync::set_option($options);
4231 + Metasync::invalidate_api_key_cache();
3701 4232
3702 4233 if (!$save_result) {
3703 - 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
3704 4235 } else {
3705 4236 if ($status_code === 200) {
3706 4237 // Option 1: Set heartbeat cache and last-known to CONNECTED so dashboard shows iframe immediately.
3707 4238 // If the immediate heartbeat then fails (e.g. backoff), Option 2 prevents overwriting this to DISCONNECTED.
@@ -3713,9 +4244,9 @@
3713 4244 );
3714 4245 set_transient('metasync_heartbeat_status_cache', $cache_data, 300);
3715 4246 update_option('metasync_last_known_connection_state', true);
3716 4247 do_action('metasync_heartbeat_state_key_pending'); // PR3: burst mode
3717 - $this->trigger_immediate_heartbeat_after_sa_connect();
4248 + $this->trigger_immediate_heartbeat_after_sa_connect($token);
3718 4249 }
3719 4250 }
3720 4251
3721 4252 return true;
@@ -3720,9 +4251,9 @@
3720 4251
3721 4252 return true;
3722 4253
3723 4254 } catch (Exception $e) {
3724 - 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
3725 4256 return false;
3726 4257 }
3727 4258 }
3728 4259
@@ -3734,9 +4265,9 @@
3734 4265 {
3735 4266 global $wpdb;
3736 4267
3737 4268 // Clear all JWT token transients
3738 - $deleted = $wpdb->query(
4269 + $deleted = $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
3739 4270 $wpdb->prepare(
3740 4271 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
3741 4272 '_transient_metasync_jwt_token_%'
3742 4273 )
@@ -3742,9 +4273,9 @@
3742 4273 )
3743 4274 );
3744 4275
3745 4276 // Also clear timeout transients
3746 - $wpdb->query(
4277 + $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
3747 4278 $wpdb->prepare(
3748 4279 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
3749 4280 '_transient_timeout_metasync_jwt_token_%'
3750 4281 )
@@ -3755,21 +4286,28 @@
3755 4286
3756 4287 /**
3757 4288 * Trigger immediate heartbeat check after successful Search Atlas connect authentication
3758 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.
3759 4292 */
3760 - private function trigger_immediate_heartbeat_after_sa_connect()
4293 + private function trigger_immediate_heartbeat_after_sa_connect($token)
3761 4294 {
3762 4295 try {
3763 4296 // Use WordPress action system to trigger immediate heartbeat check
3764 4297 // This is more reliable than trying to access admin class directly
3765 4298 do_action('metasync_trigger_immediate_heartbeat', 'Search Atlas Connect - API key and UUID retrieved');
3766 -
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 +
3767 4305 // Also ensure heartbeat cron is scheduled now that we have an API key
3768 4306 do_action('metasync_ensure_heartbeat_cron_scheduled');
3769 4307
3770 4308 } catch (Exception $e) {
3771 - 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
3772 4310 }
3773 4311 }
3774 4312
3775 4313 public function get_item_schema()
@@ -3787,15 +4325,15 @@
3787 4325 'type' => 'object',
3788 4326 // In JSON Schema you can specify object properties in the properties attribute.
3789 4327 'properties' => array(
3790 4328 'id' => array(
3791 - 'description' => esc_html__('Unique identifier for the object.', 'my-textdomain'),
4329 + 'description' => esc_html__('Unique identifier for the object.', 'metasync'),
3792 4330 'type' => 'integer',
3793 4331 'context' => array('view', 'edit', 'embed'),
3794 4332 'readonly' => true,
3795 4333 ),
3796 4334 'content' => array(
3797 - 'description' => esc_html__('The content for the object.', 'my-textdomain'),
4335 + 'description' => esc_html__('The content for the object.', 'metasync'),
3798 4336 'type' => 'string',
3799 4337 ),
3800 4338 ),
3801 4339 );
@@ -3886,11 +4424,11 @@
3886 4424
3887 4425 # Try alternative parameter methods
3888 4426 $body_params = $request->get_body_params();
3889 4427 $key_param = $request->get_param('key');
3890 - $post_key = $_POST['key'] ?? null;
3891 - $request_key = $_REQUEST['key'] ?? null;
3892 - $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
3893 4431
3894 4432 # Try to get key from multiple sources
3895 4433 $key_value = null;
3896 4434
@@ -3920,9 +4458,9 @@
3920 4458 }
3921 4459
3922 4460 # Validate that key is provided
3923 4461 if (empty($key_value)) {
3924 - return rest_ensure_response(array(
4462 + return new WP_REST_Response(array(
3925 4463 'error' => 'Key parameter is required',
3926 4464 'code' => 'missing_key'
3927 4465 ), 400);
3928 4466 }
@@ -3934,9 +4472,9 @@
3934 4472 $key = basename(sanitize_file_name($data['key']));
3935 4473
3936 4474 # Validate key is not empty after sanitization and contains no path separators
3937 4475 if (empty($key) || preg_match('/[\/\\\\]/', $key) || strpos($key, '..') !== false) {
3938 - return rest_ensure_response(array(
4476 + return new WP_REST_Response(array(
3939 4477 'error' => 'Invalid key provided',
3940 4478 'code' => 'invalid_key'
3941 4479 ), 400);
3942 4480 }
@@ -3948,42 +4486,84 @@
3948 4486 $safe_key = sanitize_file_name( $key );
3949 4487 $file_path = $wp_root . $safe_key . '.txt';
3950 4488 $real_root = realpath( $wp_root );
3951 4489 if ( false === $real_root || 0 !== strpos( realpath( dirname( $file_path ) ), $real_root ) ) {
3952 - return rest_ensure_response(array(
4490 + return new WP_REST_Response(array(
3953 4491 'error' => 'Invalid file path',
3954 4492 'code' => 'invalid_path'
3955 4493 ), 400);
3956 4494 }
3957 4495
3958 - # Check if file already exists
3959 - if (file_exists($file_path)) {
3960 - return rest_ensure_response(array(
3961 - 'error' => 'File already exists',
3962 - 'code' => 'file_exists',
3963 - 'file_path' => $file_path
3964 - ), 409);
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 + }
3965 4503 }
4504 + if (class_exists('Metasync_Bing_Instant_Index')) {
4505 + Metasync_Bing_Instant_Index::register_virtual_key($safe_key);
4506 + }
3966 4507
3967 - # Attempt to create the file
3968 - $result = file_put_contents($file_path, $safe_key);
3969 -
3970 - # Check if file creation was successful
3971 - if ($result === false) {
3972 - return rest_ensure_response(array(
3973 - 'error' => 'Failed to create file',
3974 - 'code' => 'file_creation_failed',
3975 - 'file_path' => $file_path
3976 - ), 500);
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 + }
3977 4547 }
3978 -
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 +
3979 4558 # Return success response
3980 - return rest_ensure_response(array(
3981 - 'success' => true,
3982 - 'message' => 'Key file created successfully',
4559 + return new WP_REST_Response(array(
4560 + 'success' => true,
4561 + 'message' => 'Key file created successfully',
3983 4562 'file_path' => $file_path,
3984 - 'key' => $key,
3985 - '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
3986 4566 ), 200);
3987 4567 }
3988 4568
3989 4569 }