PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.1
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.1
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
← All changes | public/class-metasync-rest-api.php +540 -296 2.6.16 → 2.7.1 View file →
@@ -46,10 +46,8 @@
46 46 private $version;
47 47
48 48 private const namespace = "metasync/v1";
49 49
50 - private $escapers;
51 - private $replacements;
52 50 private $common;
53 51 private $allowed_attributes;
54 52 private $schema;
55 53 private $metasync_option_data;
@@ -74,21 +72,13 @@
74 72 'post_name',
75 73 'post_parent',
76 74 'post_status',
77 75 );
78 - $this->escapers = array("\\", "/", "\"");
79 - $this->replacements = array("", "", "");
80 76 $this->common = new Metasync_Common();
81 77 // get all options
82 78 $this->metasync_option_data = Metasync::get_option('general');
83 - $this->init_ajax_hooks();
84 79 }
85 80
86 - public function init_ajax_hooks()
87 - {
88 - add_action('wp_ajax_metasync_otto_ajax_action', array($this,'metasyn_otto_ajax'));
89 - }
90 -
91 81 /**
92 82 * Encode non-ASCII characters in a UTF-8 string as numeric HTML entities.
93 83 *
94 84 * Uses mb_encode_numericentity when the mbstring extension is loaded;
@@ -174,86 +164,9 @@
174 164 }
175 165 return $posts;
176 166 }
177 167
178 - public function metasyn_otto_ajax() {
179 - // Check nonce for security
180 - if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'otto_nonce')) {
181 - wp_send_json_error('Invalid nonce');
182 - wp_die();
183 - }
184 - $post_id = sanitize_text_field($_POST['post_id']);
185 - $current_url = get_permalink($post_id);
186 -
187 - $header_html = get_post_meta($post_id, '_otto_header_html_json', true);
188 168
189 - // Example API call using wp_remote_get()
190 -
191 -
192 - if (is_wp_error($header_html)) {
193 - wp_send_json_error('API call failed');
194 - } else {
195 - wp_send_json_success(json_decode($header_html, true));
196 - }
197 -
198 - wp_die(); // Always terminate after an AJAX call
199 - }
200 -
201 - public function otto_header_data() {
202 - global $post;
203 -
204 - // Get the current post ID
205 - $post_id = $post->ID;
206 -
207 - // Get the current time and the last update time from post meta
208 - $current_time = current_time('timestamp');
209 - $last_update_time = get_post_meta($post_id, '_otto_last_update_time', true);
210 -
211 - // Set the interval for 24 hours (in seconds)
212 - $interval = 24 * 60 * 60;
213 -
214 - // Check if the last update time is set or if 24 hours have passed
215 - if (!$last_update_time || ($current_time - $last_update_time) >= $interval) {
216 - // Get the current URL
217 - $current_url = get_permalink($post_id);
218 -
219 - // Use endpoint manager to get the correct API URL
220 - $api_endpoint = class_exists('Metasync_Endpoint_Manager')
221 - ? Metasync_Endpoint_Manager::get_endpoint('OTTO_URL_DETAILS')
222 - : 'https://sa.searchatlas.com/api/v2/otto-url-details';
223 -
224 - // Call the API
225 - $response = wp_remote_get($api_endpoint . '/?url=' . urlencode($current_url));
226 -
227 - // Check if the API call was successful
228 - if (!is_wp_error($response)) {
229 - $body = wp_remote_retrieve_body($response);
230 - $data = json_decode($body, true); // Decode JSON into an associative array
231 -
232 - // Update the post meta with the new data and timestamp
233 - update_post_meta($post_id, '_otto_header_html', $data['header_html_insertion']);
234 - update_post_meta($post_id, '_otto_last_update_time', $current_time);
235 - }
236 - }
237 -
238 - // Get the saved HTML from post meta
239 - $header_html = get_post_meta($post_id, '_otto_header_html', true);
240 -
241 - // Display the HTML with security measures
242 - if ($header_html) {
243 - echo "<!-- Otto Start -->";
244 - // SECURITY FIX: Sanitize HTML to prevent XSS while allowing safe HTML
245 - echo wp_kses($header_html, array(
246 - 'style' => array(),
247 - 'link' => array('rel' => array(), 'href' => array(), 'type' => array()),
248 - 'meta' => array('name' => array(), 'content' => array(), 'property' => array()),
249 - 'script' => array('type' => array(), 'src' => array()),
250 - // Add other safe tags as needed
251 - ));
252 - echo "<!-- Otto End -->";
253 - }
254 - }
255 -
256 169 public function rest_authorization_middleware($request = null)
257 170 {
258 171 $api_key = '';
259 172
@@ -270,10 +183,10 @@
270 183 $api_key = sanitize_text_field(wp_unslash($_SERVER['HTTP_X_API_KEY']));
271 184 }
272 185
273 186 // Fallback: ?apikey= query param (deprecated)
274 - if (empty($api_key) && isset($_GET['apikey'])) {
275 - $api_key = sanitize_text_field($_GET['apikey']);
187 + if (empty($api_key) && isset($_GET['apikey'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- API key credential in REST auth, not cookie auth
188 + $api_key = sanitize_text_field(wp_unslash($_GET['apikey'])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- API key credential in REST auth, not cookie auth
276 189 }
277 190
278 191 if (empty($api_key)) {
279 192 return false;
@@ -278,10 +191,9 @@
278 191 if (empty($api_key)) {
279 192 return false;
280 193 }
281 194
282 - $getOptions = Metasync::get_option('general');
283 - $stored_key = $getOptions['apikey'] ?? null;
195 + $stored_key = $this->get_stored_api_key();
284 196
285 197 if (empty($stored_key)) {
286 198 return false;
287 199 }
@@ -289,8 +201,23 @@
289 201 return hash_equals($stored_key, $api_key);
290 202 }
291 203
292 204 /**
205 + * The plugin API key configured in MetaSync settings.
206 + *
207 + * Single source for the stored key so every auth path compares against the
208 + * same value.
209 + *
210 + * @return string|null Configured key, or null when the plugin is unlinked.
211 + */
212 + protected function get_stored_api_key()
213 + {
214 + $getOptions = Metasync::get_option('general');
215 +
216 + return $getOptions['apikey'] ?? null;
217 + }
218 +
219 + /**
293 220 * Coexist with third-party JWT authentication plugins.
294 221 *
295 222 * A global JWT auth plugin (e.g. Tmeister's "JWT Authentication for WP-API")
296 223 * validates any "Authorization: Bearer ..." header as a JWT. MetaSync sends
@@ -340,9 +267,151 @@
340 267
341 268 return $result;
342 269 }
343 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 + }
344 309
310 + $route = $this->get_current_rest_route();
311 + if ($route === '') {
312 + return $result;
313 + }
314 +
315 + // Only intervene for our own namespace, so the site's restriction still
316 + // applies to core and third-party routes.
317 + $prefix = '/' . self::namespace;
318 + if (strpos($route, $prefix . '/') !== 0 && $route !== $prefix) {
319 + return $result;
320 + }
321 +
322 + if ($this->request_has_valid_api_key()) {
323 + return null;
324 + }
325 +
326 + return $result;
327 + }
328 +
329 + /**
330 + * Resolve the REST route being served, without a WP_REST_Request.
331 + *
332 + * @return string Route with a leading slash (e.g. "/metasync/v1/createItem"),
333 + * or '' when this is not a resolvable REST request.
334 + */
335 + protected function get_current_rest_route()
336 + {
337 + // rest_api_loaded() stores the resolved route on the main query.
338 + if (isset($GLOBALS['wp']) && is_object($GLOBALS['wp'])
339 + && !empty($GLOBALS['wp']->query_vars['rest_route'])) {
340 + return '/' . ltrim((string) $GLOBALS['wp']->query_vars['rest_route'], '/');
341 + }
342 +
343 + // ?rest_route= form, used when pretty permalinks are disabled.
344 + if (!empty($_GET['rest_route'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only route check for REST auth
345 + return '/' . ltrim(sanitize_text_field(wp_unslash($_GET['rest_route'])), '/'); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only route check for REST auth
346 + }
347 +
348 + // Last resort: the request path with the /wp-json/ prefix stripped.
349 + $uri = isset($_SERVER['REQUEST_URI']) ? (string) wp_unslash($_SERVER['REQUEST_URI']) : '';
350 + if ($uri === '') {
351 + return '';
352 + }
353 +
354 + $path = (string) wp_parse_url($uri, PHP_URL_PATH);
355 + $url_prefix = function_exists('rest_get_url_prefix') ? rest_get_url_prefix() : 'wp-json';
356 + $needle = '/' . trim((string) $url_prefix, '/') . '/';
357 +
358 + $pos = strpos($path, $needle);
359 + if ($pos === false) {
360 + return '';
361 + }
362 +
363 + return '/' . ltrim(substr($path, $pos + strlen($needle)), '/');
364 + }
365 +
366 + /**
367 + * Whether the current request carries a valid MetaSync API key.
368 + *
369 + * Reads credentials from the request globals so it works in filters that get
370 + * no WP_REST_Request. Accepts the same three sources as
371 + * rest_authorization_middleware().
372 + *
373 + * @return bool True only when a presented key matches the configured one.
374 + */
375 + protected function request_has_valid_api_key()
376 + {
377 + $api_key = '';
378 +
379 + // Primary: Authorization: Bearer <token>. Some Apache/FastCGI setups
380 + // only expose the header under the REDIRECT_ prefix.
381 + $auth_header = '';
382 + if (!empty($_SERVER['HTTP_AUTHORIZATION'])) {
383 + $auth_header = (string) wp_unslash($_SERVER['HTTP_AUTHORIZATION']);
384 + } elseif (!empty($_SERVER['REDIRECT_HTTP_AUTHORIZATION'])) {
385 + $auth_header = (string) wp_unslash($_SERVER['REDIRECT_HTTP_AUTHORIZATION']);
386 + }
387 + if ($auth_header !== '' && preg_match('/^Bearer\s+(.+)$/i', $auth_header, $matches)) {
388 + $api_key = sanitize_text_field($matches[1]);
389 + }
390 +
391 + // Secondary: X-API-Key header.
392 + if ($api_key === '' && !empty($_SERVER['HTTP_X_API_KEY'])) {
393 + $api_key = sanitize_text_field(wp_unslash($_SERVER['HTTP_X_API_KEY']));
394 + }
395 +
396 + // Fallback: ?apikey= query param (deprecated).
397 + if ($api_key === '' && !empty($_GET['apikey'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- API key credential in REST auth, not cookie auth
398 + $api_key = sanitize_text_field(wp_unslash($_GET['apikey'])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- API key credential in REST auth, not cookie auth
399 + }
400 +
401 + if ($api_key === '') {
402 + return false;
403 + }
404 +
405 + $stored_key = $this->get_stored_api_key();
406 + if (empty($stored_key)) {
407 + return false;
408 + }
409 +
410 + return hash_equals((string) $stored_key, $api_key);
411 + }
412 +
413 +
345 414 public function metasync_register_rest_routes()
346 415 {
347 416 // Critical Routes
348 417 /*
@@ -481,9 +550,9 @@
481 550 array(
482 551 array(
483 552 'methods' => 'GET',
484 553 'callback' => function () {
485 - $getPostID = url_to_postid(sanitize_url($_GET['url']));
554 + $getPostID = url_to_postid(sanitize_url(wp_unslash($_GET['url']))); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- REST route authenticated by API key, not cookies
486 555
487 556 if ($getPostID==0) {
488 557 $response = false;
489 558 }else{
@@ -1012,9 +1081,9 @@
1012 1081 }
1013 1082
1014 1083 public function get_errorlogs()
1015 1084 {
1016 - $get_data = metasync_sanitize_input_array($_GET);
1085 + $get_data = metasync_sanitize_input_array($_GET); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- REST route authenticated by API key, not cookies
1017 1086 if (!isset($get_data['limit']))
1018 1087 return false;
1019 1088 $limit = sanitize_text_field($get_data['limit']) ?? null;
1020 1089
@@ -1202,9 +1271,9 @@
1202 1271 }
1203 1272 } catch (Throwable $e) {
1204 1273 // Fallback to DOMDocument if Dom\HTMLDocument fails
1205 1274 if (defined('WP_DEBUG_LOG') && WP_DEBUG_LOG) {
1206 - error_log('MetaSync: Dom\HTMLDocument error, falling back to DOMDocument: ' . $e->getMessage());
1275 + error_log('MetaSync: Dom\HTMLDocument error, falling back to DOMDocument: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-gated, no secrets
1207 1276 }
1208 1277 $dom = new DOMDocument();
1209 1278 libxml_use_internal_errors(true);
1210 1279 $encoded_content = $this->encode_numeric_entities($content);
@@ -1404,9 +1473,9 @@
1404 1473 $image_id = attachment_url_to_postid($node->getAttribute('src') );
1405 1474 $image_alt = get_post_meta($image_id, '_wp_attachment_image_alt', TRUE);
1406 1475 $result ='[et_pb_image src="'.$node->getAttribute('src') .'" url="'.$node->getAttribute('src'). '" _builder_version="'.ET_BUILDER_VERSION.'" _module_preset="default" hover_enabled="0" global_colors_info="{}" sticky_enabled="0"][/et_pb_image]';
1407 1476
1408 - error_log(json_encode($e));
1477 + error_log(json_encode($e)); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
1409 1478
1410 1479 }
1411 1480 }elseif($node->nodeName === 'iframe'){
1412 1481 $result= '[et_pb_code _builder_version="'.ET_BUILDER_VERSION.'" _module_preset="default" global_colors_info="{}"]'.$node->ownerDocument->saveHTML($node).'[/et_pb_code]' ;
@@ -1534,71 +1603,12 @@
1534 1603 return $attachment_id;
1535 1604 }
1536 1605
1537 1606 /**
1538 - * Index a post with Google Indexing API
1539 - *
1540 - * @param int $post_id WordPress post ID
1541 - * @param string $post_type WordPress post type (post, page, etc.)
1542 - * @param string $post_status WordPress post status (publish, draft, etc.)
1543 - */
1544 - public function metasync_google_index_post($post_id, $post_type, $post_status)
1545 - {
1546 - // Only index published posts/pages
1547 - if ($post_status !== 'publish') {
1548 - return;
1549 - }
1550 -
1551 - // Only index posts and pages (can be extended as needed)
1552 - $allowed_post_types = array('post', 'page');
1553 - if (!in_array($post_type, $allowed_post_types)) {
1554 - return;
1555 - }
1556 -
1557 - try {
1558 - // Load Google Index functionality if not already loaded
1559 - if (!function_exists('google_index_post')) {
1560 - $google_index_path = plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php';
1561 - if (file_exists($google_index_path)) {
1562 - require_once $google_index_path;
1563 - } else {
1564 - error_log('MetaSync Google Index: google-index-init.php not found at ' . $google_index_path);
1565 - return;
1566 - }
1567 - }
1568 -
1569 - // Attempt to index the post with Google
1570 - if (function_exists('google_index_post')) {
1571 - $result = google_index_post($post_id, $post_type, 'update');
1572 -
1573 - if (isset($result['success']) && $result['success']) {
1574 - error_log(sprintf(
1575 - 'MetaSync Google Index: Successfully indexed %s (ID: %d, Type: %s)',
1576 - get_the_title($post_id),
1577 - $post_id,
1578 - $post_type
1579 - ));
1580 - } else {
1581 - error_log(sprintf(
1582 - 'MetaSync Google Index: Failed to index %s (ID: %d, Type: %s) - %s',
1583 - get_the_title($post_id),
1584 - $post_id,
1585 - $post_type,
1586 - isset($result['error']['message']) ? $result['error']['message'] : 'Unknown error'
1587 - ));
1588 - }
1589 - }
1590 - } catch (Exception $e) {
1591 - // Log any exceptions but don't break the main functionality
1592 - error_log('MetaSync Google Index Exception: ' . $e->getMessage());
1593 - }
1594 - }
1595 -
1596 - /**
1597 - * WP-429: Remove a leading <h1> from post content when it duplicates the post title.
1607 + * Remove a leading <h1> from post content when it duplicates the post title.
1598 1608 *
1599 1609 * Themes that render post_title as an H1 produce a duplicate heading when the
1600 - * synced body also opens with the title. Counterpart of the WP-337 guard, which
1610 + * synced body also opens with the title. Counterpart of the guard, which
1601 1611 * only prevents prepending a second H1 but never removes one supplied in the body.
1602 1612 *
1603 1613 * @param string $content Post body HTML
1604 1614 * @param string $title Post title
@@ -1615,9 +1625,9 @@
1615 1625 return $content;
1616 1626 }
1617 1627
1618 1628 # Entity-decode both sides so "Mobility &amp; Recovery" matches "Mobility & Recovery"
1619 - $h1_text = trim(html_entity_decode(strip_tags($h1_match[1]), ENT_QUOTES | ENT_HTML5));
1629 + $h1_text = trim(html_entity_decode(wp_strip_all_tags($h1_match[1]), ENT_QUOTES | ENT_HTML5));
1620 1630 $title_text = trim(html_entity_decode((string) $title, ENT_QUOTES | ENT_HTML5));
1621 1631 if ($h1_text === '' || strcasecmp($h1_text, $title_text) !== 0) {
1622 1632 return $content;
1623 1633 }
@@ -1625,9 +1635,9 @@
1625 1635 return substr($trimmed, strlen($h1_match[0]));
1626 1636 }
1627 1637
1628 1638 /**
1629 - * WP-429: Decide whether the synced body should carry a prepended <h1> title.
1639 + * Decide whether the synced body should carry a prepended <h1> title.
1630 1640 *
1631 1641 * Replaces the unreliable 7-day hidden-post probe (title_in_headings) as the
1632 1642 * source of truth for the prepend decision. We only prepend when we have a
1633 1643 * POSITIVE, render-verified verdict that the active theme does NOT render the
@@ -1651,9 +1661,9 @@
1651 1661 return false;
1652 1662 }
1653 1663
1654 1664 /**
1655 - * WP-429: After a post is saved, render its live URL once and record whether
1665 + * After a post is saved, render its live URL once and record whether
1656 1666 * the active theme outputs the post title in a heading. The verdict is cached
1657 1667 * per post-type so subsequent syncs skip the round-trip. Best-effort: any
1658 1668 * inconclusive fetch (non-200, cache/challenge page, wrong post) records
1659 1669 * nothing and is retried on the next sync.
@@ -1693,9 +1703,9 @@
1693 1703 Metasync::set_option($options);
1694 1704 }
1695 1705
1696 1706 /**
1697 - * WP-429: Fetch the rendered post and detect whether the theme outputs the
1707 + * Fetch the rendered post and detect whether the theme outputs the
1698 1708 * title in a heading.
1699 1709 *
1700 1710 * @param string $permalink Public permalink
1701 1711 * @param int $post_id Saved post ID (used to confirm we fetched the right page)
@@ -1741,9 +1751,9 @@
1741 1751 return $this->title_present_in_headings($html, $post_title);
1742 1752 }
1743 1753
1744 1754 /**
1745 - * WP-429: Return true if $title appears within any heading (h1–h6) of $html.
1755 + * Return true if $title appears within any heading (h1–h6) of $html.
1746 1756 *
1747 1757 * @param string $html Rendered page HTML
1748 1758 * @param string $title Title text to match
1749 1759 * @return bool
@@ -1828,9 +1838,9 @@
1828 1838 } elseif (!empty($theme_template) && stripos($theme_template, 'flatsome') !== false) {
1829 1839 $is_flatsome_theme = true;
1830 1840 }
1831 1841
1832 - # WP-429: Always strip a leading H1 that duplicates the title. The
1842 + # Always strip a leading H1 that duplicates the title. The
1833 1843 # theme renders post_title in its header on virtually every theme, so a
1834 1844 # title H1 at the top of the body is a duplicate. Whether we then put one
1835 1845 # back is decided by should_prepend_title() below.
1836 1846 if (isset($item['post_title'])) {
@@ -1846,9 +1856,9 @@
1846 1856 if(!$title_and_feature_image['image_in_content'] && !empty($item['hero_image_url'])){
1847 1857 $item['post_content'] = '<img src="' . esc_url_raw($item['hero_image_url']) . '" />'.$item['post_content'] ;
1848 1858 }
1849 1859
1850 - # WP-429: Only prepend the title H1 when the theme is render-verified to
1860 + # Only prepend the title H1 when the theme is render-verified to
1851 1861 # omit it. Unknown verdict defaults to NOT prepending (see should_prepend_title).
1852 1862 if(isset($item['post_title']) && $this->should_prepend_title($current_post_type)){
1853 1863 $item['post_content'] = '<h1>'.$item['post_title'].'</h1>'.$item['post_content'] ;
1854 1864 }
@@ -1886,13 +1896,13 @@
1886 1896 $new_post['post_name'] = sanitize_text_field($item['permalink']);
1887 1897 }
1888 1898
1889 1899 if (isset($item['post_date']) && !empty($item['post_date'])) {
1890 - $is_valid_date = date('Y-m-d', strtotime($item['post_date'])) === $item['post_date'];
1900 + $is_valid_date = gmdate('Y-m-d', strtotime($item['post_date'])) === $item['post_date'];
1891 1901 if (!$is_valid_date) {
1892 1902 return new WP_Error(
1893 1903 'rest_post_invalid_date',
1894 - esc_html__('Post date is not valid'),
1904 + esc_html__('Post date is not valid', 'metasync'),
1895 1905 array('status' => 400)
1896 1906 );
1897 1907 }
1898 1908
@@ -2009,13 +2019,17 @@
2009 2019 $post_meta['searchatlas_embed_bottom'] = $item['searchatlas_embed_bottom'];
2010 2020 }
2011 2021
2012 2022 // Add custom fields to posts and pages
2013 - foreach ($post_meta as $key => $value) {
2014 - // if (!empty($value) && !is_null($value)) {
2015 - add_post_meta($post_id, $key, $value, true);
2016 - // }
2017 - }
2023 + $this->metasync_apply_builder_meta($post_id, $post_meta, false);
2024 +
2025 + // A freshly inserted post has no template of its own yet; match it to
2026 + // the one this site uses for its other posts.
2027 + $this->metasync_adopt_site_post_template(
2028 + $post_id,
2029 + isset($new_post['post_type']) ? $new_post['post_type'] : 'post',
2030 + (NULL === $getPostID_byURL)
2031 + );
2018 2032
2019 2033
2020 2034 $attachment_id = '';
2021 2035 if (isset($item['hero_image_url']) && !empty($item['hero_image_url'])) {
@@ -2033,8 +2047,14 @@
2033 2047 $redirection['url'] = sanitize_url($item['redirection_url']);
2034 2048 }
2035 2049 if (!empty($redirection)) {
2036 2050 update_post_meta($post_id, 'metasync_post_redirection_meta', $redirection);
2051 + // REST/Gutenberg saves never reach the metabox nonce path, so
2052 + // the stored meta would never materialize a redirect row.
2053 + // Sync it here, same as the classic save hook does.
2054 + if (class_exists('Metasync_Post_Meta_Settings')) {
2055 + Metasync_Post_Meta_Settings::sync_post_redirect_rule($post_id, $redirection);
2056 + }
2037 2057 }
2038 2058
2039 2059 $post_cattegories = [];
2040 2060 # if ($new_post['post_type'] === 'post' && is_array(@$item['post_categories'])) {
@@ -2094,16 +2114,13 @@
2094 2114 # Track Content Genius event in GA4
2095 2115 try {
2096 2116 Metasync_GA4::get_instance()->track_content_genius_event($post_id, strtolower($action));
2097 2117 } catch (Exception $e) {
2098 - error_log('MetaSync: Analytics tracking failed for Content Genius - ' . $e->getMessage());
2118 + error_log('MetaSync: Analytics tracking failed for Content Genius - ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
2099 2119 }
2100 -
2101 - // Google Indexing Integration
2102 - $this->metasync_google_index_post($post_id, $new_post['post_type'], $new_post['post_status']);
2103 2120 }
2104 2121
2105 - # WP-429: For standard synced posts, learn (once per post-type) whether the
2122 + # For standard synced posts, learn (once per post-type) whether the
2106 2123 # theme renders the title, so future syncs prepend only when genuinely needed.
2107 2124 if (!is_wp_error($post_id) && $post_id > 0
2108 2125 && !isset($item['is_landing_page']) && empty($isOttoAiPage) && empty($item['style_data'])
2109 2126 && $new_post['post_status'] === 'publish') {
@@ -2109,9 +2126,9 @@
2109 2126 && $new_post['post_status'] === 'publish') {
2110 2127 try {
2111 2128 $this->record_theme_title_verdict($post_id, $permalink, $new_post['post_title'], $new_post['post_type']);
2112 2129 } catch (Exception $e) {
2113 - error_log('MetaSync WP-429 title verdict failed: ' . $e->getMessage());
2130 + error_log('MetaSync title verdict failed: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
2114 2131 }
2115 2132 }
2116 2133
2117 2134 $respCreatePosts[$index] = array_merge($new_post, $post_meta);
@@ -2156,9 +2173,9 @@
2156 2173 }
2157 2174
2158 2175 public function delete_item()
2159 2176 {
2160 - $get_data = metasync_sanitize_input_array($_GET);
2177 + $get_data = metasync_sanitize_input_array($_GET); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- REST route authenticated by API key, not cookies
2161 2178 if (!isset($get_data['ID']))
2162 2179 return false;
2163 2180
2164 2181 $post_id = sanitize_text_field($get_data['ID']) ?? null;
@@ -2164,18 +2181,18 @@
2164 2181 $post_id = sanitize_text_field($get_data['ID']) ?? null;
2165 2182 $post = get_post($post_id);
2166 2183 if ($post) {
2167 2184 wp_delete_post($post_id);
2185 + // HTTP 204 requires no body for response, so no message is set.
2168 2186 return new WP_Error(
2169 2187 'rest_post_delete_success',
2170 - esc_html__(''),
2171 - // HTTP 204 requires no body for response
2188 + '',
2172 2189 array('status' => 204)
2173 2190 );
2174 2191 }
2175 2192 return new WP_Error(
2176 2193 'rest_post_delete_fail',
2177 - esc_html__('No post found in the database with requested ID.'),
2194 + esc_html__('No post found in the database with requested ID.', 'metasync'),
2178 2195 array('status' => 400)
2179 2196 );
2180 2197 }
2181 2198
@@ -2180,9 +2197,9 @@
2180 2197 }
2181 2198
2182 2199 public function get_items($request)
2183 2200 {
2184 - $get_data = metasync_sanitize_input_array($_GET);
2201 + $get_data = metasync_sanitize_input_array($_GET); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- REST route authenticated by API key, not cookies
2185 2202
2186 2203 # let us check if request send post id and is valid int
2187 2204 if(isset($get_data['post_id']) AND intval($get_data['post_id']) > 0){
2188 2205
@@ -2522,9 +2539,9 @@
2522 2539 $post_data = get_post($post_id);
2523 2540 if (!$post_data) {
2524 2541 return new WP_Error(
2525 2542 'rest_post_update_fail',
2526 - esc_html__('No post found in the database with requested ID.'),
2543 + esc_html__('No post found in the database with requested ID.', 'metasync'),
2527 2544 array('status' => 400)
2528 2545 );
2529 2546 }
2530 2547
@@ -2549,9 +2566,14 @@
2549 2566 $update_params['meta_robots'] = $post['meta_robots'] ?
2550 2567 sanitize_text_field($post['meta_robots']) : $get_robots_meta;
2551 2568 }
2552 2569 if (isset($post['meta_canonical']) && !empty($post['meta_canonical'])) {
2553 - $update_params['meta_canonical'] = sanitize_text_field($post['meta_canonical']);
2570 + // Validate as a URL: drop unusable values instead of
2571 + // storing text that would later be emitted as a canonical.
2572 + $clean_canonical = Metasync_Canonical_Sanitizer::sanitize_for_save($post['meta_canonical']);
2573 + if ($clean_canonical !== '') {
2574 + $update_params['meta_canonical'] = $clean_canonical;
2575 + }
2554 2576 }
2555 2577
2556 2578 $isOttoAiPage = !empty($post['otto_ai_page']) && filter_var($post['otto_ai_page'], FILTER_VALIDATE_BOOLEAN);
2557 2579 $content = [];
@@ -2578,9 +2600,9 @@
2578 2600
2579 2601 # Title to compare against — payload may omit post_title on update
2580 2602 $compare_title = isset($post['post_title']) && !empty($post['post_title']) ? $post['post_title'] : $post_fresh_data->post_title;
2581 2603
2582 - # WP-429: Always strip a leading H1 that duplicates the title; the theme
2604 + # Always strip a leading H1 that duplicates the title; the theme
2583 2605 # renders post_title in its header, so a body title H1 is a duplicate.
2584 2606 $post['post_content'] = $this->strip_leading_title_h1($post['post_content'], $compare_title);
2585 2607
2586 2608 # Skip title/image prepending for Flatsome (it displays them by default)
@@ -2592,9 +2614,9 @@
2592 2614 if(!$title_and_feature_image['image_in_content'] && !empty($post['hero_image_url'])){
2593 2615 $post['post_content'] = '<img src="' . esc_url_raw($post['hero_image_url']) . '" />'.$post['post_content'] ;
2594 2616 }
2595 2617
2596 - # WP-429: Only prepend the title H1 when the theme is render-verified to
2618 + # Only prepend the title H1 when the theme is render-verified to
2597 2619 # omit it. Unknown verdict defaults to NOT prepending (see should_prepend_title).
2598 2620 if($this->should_prepend_title($post_fresh_data->post_type)){
2599 2621 $post['post_content'] = '<h1>'.$compare_title.'</h1>'.$post['post_content'] ;
2600 2622 }
@@ -2657,40 +2679,8 @@
2657 2679 );
2658 2680 }
2659 2681
2660 2682
2661 - if (isset($post['post_date']) && !empty($post['post_date']) && false) {
2662 - $is_valid_date = date('Y-m-d', strtotime($post['post_date'])) == $post['post_date'];
2663 - if (!$is_valid_date) {
2664 - return new WP_Error(
2665 - 'rest_post_invalid_date',
2666 - esc_html__('Post date is not valid'),
2667 - array('status' => 400)
2668 - );
2669 - }
2670 -
2671 - $date_limit_str = strtotime(date('Y-m-d') . '-2 month');
2672 - $post_date_str = strtotime($post['post_date']);
2673 -
2674 - if ($date_limit_str >= $post_date_str) {
2675 - $newDate = date('Y-m-d', strtotime('-2 month'));
2676 - return new WP_Error(
2677 - 'rest_post_greater_date',
2678 - esc_html__("Post date should be greater then " . $newDate),
2679 - array('status' => 400)
2680 - );
2681 - }
2682 -
2683 - if ($post_date_str > strtotime(date('Y-m-d'))) {
2684 - return new WP_Error(
2685 - 'rest_post_greater_date',
2686 - esc_html__('Post date should be less then Today'),
2687 - array('status' => 400)
2688 - );
2689 - }
2690 - $update_params['post_date'] = sanitize_text_field($post['post_date'] . date(' h:i:s'));
2691 - }
2692 -
2693 2683 $post_cattegories = [];
2694 2684 # if ($post_data && $post_data->post_type === 'post' && is_array(@$post['post_categories'])) {
2695 2685
2696 2686 # fixed Undefined array key issue
@@ -2714,11 +2704,9 @@
2714 2704 }
2715 2705
2716 2706 $resp_update = $this->update_object($post_id, $update_params);
2717 2707 if(isset($content['elementor_meta_data'])){
2718 - foreach ($content['elementor_meta_data'] as $key => $value) {
2719 - update_post_meta($post_id, $key, $value);
2720 - }
2708 + $this->metasync_apply_builder_meta($post_id, $content['elementor_meta_data']);
2721 2709 if ( did_action( 'elementor/loaded' ) ) {
2722 2710 // Clear Elementor cache for the specified post ID
2723 2711 \Elementor\Plugin::instance()->files_manager->clear_cache();
2724 2712 }
@@ -2733,8 +2721,15 @@
2733 2721 delete_post_meta($post_id, '_elementor_page_assets');
2734 2722 delete_post_meta($post_id, '_elementor_page_settings');
2735 2723 }
2736 2724
2725 + // Existing post: records the template decision without changing it.
2726 + $this->metasync_adopt_site_post_template(
2727 + $post_id,
2728 + get_post_type($post_id),
2729 + false
2730 + );
2731 +
2737 2732 $redirection = array();
2738 2733 if (!empty($post['redirection_enable']) && !is_null($post['redirection_enable'])) {
2739 2734 $redirection['enable'] = sanitize_text_field($post['redirection_enable']);
2740 2735 }
@@ -2745,8 +2740,14 @@
2745 2740 $redirection['url'] = sanitize_url($post['redirection_url']);
2746 2741 }
2747 2742 if (!empty($redirection)) {
2748 2743 update_post_meta($post_id, 'metasync_post_redirection_meta', $redirection);
2744 + // REST/Gutenberg saves never reach the metabox nonce path, so
2745 + // the stored meta would never materialize a redirect row.
2746 + // Sync it here, same as the classic save hook does.
2747 + if (class_exists('Metasync_Post_Meta_Settings')) {
2748 + Metasync_Post_Meta_Settings::sync_post_redirect_rule($post_id, $redirection);
2749 + }
2749 2750 }
2750 2751
2751 2752
2752 2753 $post_revisions = wp_get_post_revisions($post_id);
@@ -2753,9 +2754,8 @@
2753 2754 // Sync post categories to customer dashboard
2754 2755 $this->lgSendCustomerPostParams();
2755 2756
2756 2757 unset($update_params['post_name']);
2757 - unset($update_params['post_category']);
2758 2758
2759 2759 $update_params['post_categories'] = $post_cattegories;
2760 2760 $update_params['post_tags'] = $post_tags;
2761 2761 $update_params['post_id'] = (int) $post_id;
@@ -2785,14 +2785,9 @@
2785 2785
2786 2786 }
2787 2787 # update the content
2788 2788 // add and update the post meta
2789 - foreach ($post_meta_data as $key => $value) {
2790 - // if (!empty($value) && !is_null($value)) {
2791 -
2792 - update_post_meta($post_id, $key, $value);
2793 - //
2794 - }
2789 + $this->metasync_apply_builder_meta($post_id, $post_meta_data);
2795 2790 #check if the elementor plugin is active
2796 2791 if ( did_action( 'elementor/loaded' ) ) {
2797 2792 # Clear Elementor cache for the specified post ID
2798 2793 \Elementor\Plugin::instance()->files_manager->clear_cache();
@@ -2829,12 +2824,12 @@
2829 2824 # Track Content Genius event in GA4
2830 2825 try {
2831 2826 Metasync_GA4::get_instance()->track_content_genius_event($post_id, 'updated');
2832 2827 } catch (Exception $e) {
2833 - error_log('MetaSync: Analytics tracking failed for Content Genius - ' . $e->getMessage());
2828 + error_log('MetaSync: Analytics tracking failed for Content Genius - ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
2834 2829 }
2835 2830
2836 - # WP-429: Learn (once per post-type) whether the theme renders the title,
2831 + # Learn (once per post-type) whether the theme renders the title,
2837 2832 # so future syncs prepend only when the theme genuinely omits it.
2838 2833 if ($post_id > 0 && empty($post['is_landing_page']) && empty($isOttoAiPage)
2839 2834 && $sync_status === 'published') {
2840 2835 try {
@@ -2839,9 +2834,9 @@
2839 2834 && $sync_status === 'published') {
2840 2835 try {
2841 2836 $this->record_theme_title_verdict($post_id, ($permalink ?? get_permalink($post_id)), $post_title, $post_type);
2842 2837 } catch (Exception $e) {
2843 - error_log('MetaSync WP-429 title verdict failed: ' . $e->getMessage());
2838 + error_log('MetaSync title verdict failed: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
2844 2839 }
2845 2840 }
2846 2841 }
2847 2842
@@ -2938,20 +2933,9 @@
2938 2933 if($isOttoAiPage){
2939 2934 // Change the page template from default to Metasync Template
2940 2935 $pageTemplate = Metasync_Template::TEMPLATE_NAME;
2941 2936 }
2942 - if ($pageTemplate !== 'default') {
2943 - update_post_meta($post_id, '_wp_page_template', $pageTemplate);
2944 - } else {
2945 - // Clear stale templates that conflict with the active page builder.
2946 - // e.g. metasync-blank from a previous OTTO sync, or elementor_canvas
2947 - // written by the HTML-to-builder converter on an Oxygen site.
2948 - $current = get_post_meta($post_id, '_wp_page_template', true);
2949 - $stale_templates = array(Metasync_Template::TEMPLATE_NAME, 'elementor_canvas', 'elementor_header_footer');
2950 - if (in_array($current, $stale_templates, true)) {
2951 - delete_post_meta($post_id, '_wp_page_template');
2952 - }
2953 - }
2937 + $this->metasync_write_page_template($post_id, $pageTemplate);
2954 2938 }
2955 2939
2956 2940 return rest_ensure_response($createPages);
2957 2941 }
@@ -2974,9 +2958,9 @@
2974 2958 $post_data = get_post($payload['post_id']);
2975 2959 if(!isset($post_data->post_type)){
2976 2960 return new WP_Error(
2977 2961 'rest_page_type_fail',
2978 - esc_html__('No page found in the database with requested ID.'),
2962 + esc_html__('No page found in the database with requested ID.', 'metasync'),
2979 2963 array('status' => 400)
2980 2964 );
2981 2965 }
2982 2966 if ($post_data->post_type !== 'page') {
@@ -3031,20 +3015,225 @@
3031 3015 if($isOttoAiPage){
3032 3016 // Change the page template from default to Metasync Template
3033 3017 $pageTemplate = Metasync_Template::TEMPLATE_NAME;
3034 3018 }
3035 - if ($pageTemplate !== 'default') {
3036 - update_post_meta($post_id, '_wp_page_template', $pageTemplate);
3019 + $this->metasync_write_page_template($post_id, $pageTemplate);
3020 + }
3021 + return rest_ensure_response($updatePages->data);
3022 + }
3023 +
3024 + /**
3025 + * The page template this site actually uses for a given post type.
3026 + *
3027 + * Content Genius has no idea which template a site's posts are built on, and
3028 + * neither does the converter. Hardcoding one downgraded every synced post;
3029 + * leaving the field empty is no better, because WordPress then falls back to
3030 + * the theme's default single template -- on a theme-builder site that is a
3031 + * bare, unstyled layout.
3032 + *
3033 + * So infer it: whatever the clear majority of this site's existing published
3034 + * entries of the same type already use.
3035 + *
3036 + * @param string $post_type
3037 + * @return string Template slug, or '' when the site has no clear convention.
3038 + */
3039 + private function metasync_prevailing_post_template($post_type)
3040 + {
3041 + $post_type = sanitize_key($post_type);
3042 +
3043 + if ('' === $post_type) {
3044 + return '';
3045 + }
3046 +
3047 + $cache_key = 'metasync_prevailing_tpl_' . $post_type;
3048 + $cached = get_transient($cache_key);
3049 +
3050 + if (false !== $cached) {
3051 + return is_string($cached) ? $cached : '';
3052 + }
3053 +
3054 + global $wpdb;
3055 +
3056 + // Never propagate a template this plugin wrote itself.
3057 + $owned = $this->metasync_plugin_owned_templates();
3058 +
3059 + $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint over the plugin custom table — no WordPress API exists
3060 + $wpdb->prepare(
3061 + 'SELECT pm.meta_value AS tpl, COUNT(*) AS hits
3062 + FROM ' . $wpdb->postmeta . ' pm
3063 + INNER JOIN ' . $wpdb->posts . " p ON p.ID = pm.post_id
3064 + WHERE pm.meta_key = '_wp_page_template'
3065 + AND pm.meta_value <> ''
3066 + AND pm.meta_value <> 'default'
3067 + AND pm.meta_value NOT IN (" . implode(',', array_fill(0, count($owned), '%s')) . ")
3068 + AND p.post_type = %s
3069 + AND p.post_status = 'publish'
3070 + GROUP BY pm.meta_value
3071 + ORDER BY hits DESC",
3072 + array_merge($owned, array($post_type))
3073 + )
3074 + );
3075 +
3076 + $prevailing = '';
3077 +
3078 + if (!empty($rows)) {
3079 + // Measure the majority against every published entry of this type,
3080 + // not just the ones that set a template. Counting only the latter
3081 + // would let a handful of outliers on a special template read as a
3082 + // site-wide convention on a site whose posts otherwise use the
3083 + // theme default.
3084 + $total = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint over the plugin custom table — no WordPress API exists
3085 + $wpdb->prepare(
3086 + "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = %s AND post_status = 'publish'",
3087 + $post_type
3088 + )
3089 + );
3090 +
3091 + $top = (int) $rows[0]->hits;
3092 +
3093 + // Only adopt a convention that is genuinely a convention: several
3094 + // posts, and a clear majority of the site's published entries.
3095 + if ($top >= 3 && $total > 0 && ($top / $total) >= 0.6) {
3096 + $prevailing = (string) $rows[0]->tpl;
3097 + }
3098 + }
3099 +
3100 + set_transient($cache_key, $prevailing, 6 * HOUR_IN_SECONDS);
3101 +
3102 + return $prevailing;
3103 + }
3104 +
3105 + /**
3106 + * Give a synced post the template the rest of the site uses.
3107 + *
3108 + * Only ever fills a blank -- an existing choice is never touched.
3109 + *
3110 + * Beyond brand-new inserts this also covers two cases that cannot regress
3111 + * anything a visitor is currently seeing: posts this plugin created itself
3112 + * (marked with `metasync_post`), and posts that have never been published.
3113 + * Everything else -- content the site author built by hand -- is left alone.
3114 + *
3115 + * @param int $post_id
3116 + * @param string $post_type
3117 + * @param bool $is_new Whether this sync actually inserted the post.
3118 + * @return void
3119 + */
3120 + private function metasync_adopt_site_post_template($post_id, $post_type, $is_new)
3121 + {
3122 + if (!$post_id) {
3123 + return;
3124 + }
3125 +
3126 + $current = (string) get_post_meta($post_id, '_wp_page_template', true);
3127 +
3128 + if ('' !== $current && 'default' !== $current) {
3129 + return;
3130 + }
3131 +
3132 + $is_plugin_post = ('yes' === get_post_meta($post_id, 'metasync_post', true));
3133 + $never_published = in_array(get_post_status($post_id), array('draft', 'pending', 'auto-draft'), true);
3134 +
3135 + if (!$is_new && !$is_plugin_post && !$never_published) {
3136 + return;
3137 + }
3138 +
3139 + $prevailing = $this->metasync_prevailing_post_template($post_type);
3140 +
3141 + if ('' === $prevailing) {
3142 + return;
3143 + }
3144 +
3145 + update_post_meta($post_id, '_wp_page_template', $prevailing);
3146 + }
3147 +
3148 + /**
3149 + * Page templates this plugin writes itself.
3150 + *
3151 + * Only these may be cleared when a sync carries the neutral 'default' value.
3152 + * Anything else stored in `_wp_page_template` was chosen in the editor and
3153 + * has to survive the sync -- notably 'elementor_header_footer' (Elementor
3154 + * Full Width), which the plugin never writes, so finding it there always
3155 + * means the user selected it.
3156 + *
3157 + * @return array
3158 + */
3159 + private function metasync_plugin_owned_templates()
3160 + {
3161 + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-template.php';
3162 +
3163 + return array(Metasync_Template::TEMPLATE_NAME, 'elementor_canvas');
3164 + }
3165 +
3166 + /**
3167 + * Whether converter-generated builder meta may set `_wp_page_template`.
3168 + *
3169 + * Everything reaching this point was asserted by the converter, not chosen
3170 + * by anyone -- including 'elementor_canvas', which it emits for every page.
3171 + * So the incoming value carries no authority: builder meta may fill a blank
3172 + * and may replace a template this plugin wrote itself, but it must never
3173 + * overwrite one selected in the editor.
3174 + *
3175 + * Deliberate templates (OTTO pages, landing pages) do not come through here;
3176 + * they are written by metasync_write_page_template().
3177 + *
3178 + * @param int $post_id
3179 + * @return bool
3180 + */
3181 + private function metasync_can_write_page_template($post_id)
3182 + {
3183 + $current = get_post_meta($post_id, '_wp_page_template', true);
3184 +
3185 + // Nothing worth keeping.
3186 + if ('' === $current || 'default' === $current) {
3187 + return true;
3188 + }
3189 +
3190 + // Only replace leftovers this plugin put there in the first place.
3191 + return in_array($current, $this->metasync_plugin_owned_templates(), true);
3192 + }
3193 +
3194 + /**
3195 + * Store a page template, preserving one the user selected.
3196 + *
3197 + * @param int $post_id
3198 + * @param string $pageTemplate
3199 + * @return void
3200 + */
3201 + private function metasync_write_page_template($post_id, $pageTemplate)
3202 + {
3203 + if ('default' !== $pageTemplate) {
3204 + update_post_meta($post_id, '_wp_page_template', $pageTemplate);
3205 + return;
3206 + }
3207 +
3208 + $current = get_post_meta($post_id, '_wp_page_template', true);
3209 +
3210 + if ('' !== $current && in_array($current, $this->metasync_plugin_owned_templates(), true)) {
3211 + delete_post_meta($post_id, '_wp_page_template');
3212 + }
3213 + }
3214 +
3215 + /**
3216 + * Apply builder meta while protecting the post's chosen page template.
3217 + *
3218 + * @param int $post_id
3219 + * @param array $meta
3220 + * @param bool $overwrite Replace existing meta, or only add when absent.
3221 + * @return void
3222 + */
3223 + private function metasync_apply_builder_meta($post_id, array $meta, $overwrite = true)
3224 + {
3225 + foreach ($meta as $key => $value) {
3226 + if ('_wp_page_template' === $key && !$this->metasync_can_write_page_template($post_id)) {
3227 + continue;
3228 + }
3229 +
3230 + if ($overwrite) {
3231 + update_post_meta($post_id, $key, $value);
3037 3232 } else {
3038 - // Clear stale templates that conflict with the active page builder.
3039 - $current = get_post_meta($post_id, '_wp_page_template', true);
3040 - $stale_templates = array(Metasync_Template::TEMPLATE_NAME, 'elementor_canvas', 'elementor_header_footer');
3041 - if (in_array($current, $stale_templates, true)) {
3042 - delete_post_meta($post_id, '_wp_page_template');
3043 - }
3233 + add_post_meta($post_id, $key, $value, true);
3044 3234 }
3045 3235 }
3046 - return rest_ensure_response($updatePages->data);
3047 3236 }
3048 3237
3049 3238 public function delete_page()
3050 3239 {
@@ -3070,8 +3259,17 @@
3070 3259 }
3071 3260
3072 3261 public function linkgraph_login()
3073 3262 {
3263 + # Relays caller-supplied credentials to the token API, so verify the
3264 + # nonce sent by admin/js (metasyncLGLogin) and confirm plugin access
3265 + # first; otherwise any logged-in user could use the site as a proxy.
3266 + check_ajax_referer('metasync_lglogin', 'nonce');
3267 +
3268 + if (!Metasync::current_user_has_plugin_access()) {
3269 + wp_send_json_error(['message' => 'Not authorized']);
3270 + }
3271 +
3074 3272 $post_data = metasync_sanitize_input_array($_POST);
3075 3273 $payload = array(
3076 3274 'username' => wp_unslash(sanitize_email($post_data['username'])),
3077 3275 'password' => wp_unslash(sanitize_text_field($post_data['password']))
@@ -3088,9 +3286,9 @@
3088 3286 ));
3089 3287
3090 3288 # Error handling for timeout or connection failures
3091 3289 if (is_wp_error($response)) {
3092 - error_log('MetaSync: Token API failed: ' . $response->get_error_message());
3290 + error_log('MetaSync: Token API failed: ' . $response->get_error_message()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
3093 3291 wp_send_json_error(array('message' => 'Token API request failed'));
3094 3292 wp_die();
3095 3293 }
3096 3294
@@ -3224,9 +3422,9 @@
3224 3422 $option_name = '_transient_metasync_sa_connect_active_' . $nonce_token;
3225 3423 $timeout_name = '_transient_timeout_metasync_sa_connect_active_' . $nonce_token;
3226 3424
3227 3425 // Check expiry first
3228 - $timeout = $wpdb->get_var(
3426 + $timeout = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
3229 3427 $wpdb->prepare(
3230 3428 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
3231 3429 $timeout_name
3232 3430 )
@@ -3233,14 +3431,14 @@
3233 3431 );
3234 3432
3235 3433 if ($timeout && (int) $timeout < time()) {
3236 3434 // Expired — clean up orphan rows
3237 - $wpdb->delete($wpdb->options, array('option_name' => $option_name));
3238 - $wpdb->delete($wpdb->options, array('option_name' => $timeout_name));
3435 + $wpdb->delete($wpdb->options, array('option_name' => $option_name)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
3436 + $wpdb->delete($wpdb->options, array('option_name' => $timeout_name)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
3239 3437 return false;
3240 3438 }
3241 3439
3242 - $value = $wpdb->get_var(
3440 + $value = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
3243 3441 $wpdb->prepare(
3244 3442 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
3245 3443 $option_name
3246 3444 )
@@ -3264,9 +3462,9 @@
3264 3462 $option_name = '_transient_' . $transient_key;
3265 3463 $timeout_name = '_transient_timeout_' . $transient_key;
3266 3464
3267 3465 // Check expiry first
3268 - $timeout = $wpdb->get_var(
3466 + $timeout = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
3269 3467 $wpdb->prepare(
3270 3468 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
3271 3469 $timeout_name
3272 3470 )
@@ -3272,14 +3470,14 @@
3272 3470 )
3273 3471 );
3274 3472
3275 3473 if ($timeout && (int) $timeout < time()) {
3276 - $wpdb->delete($wpdb->options, array('option_name' => $option_name));
3277 - $wpdb->delete($wpdb->options, array('option_name' => $timeout_name));
3474 + $wpdb->delete($wpdb->options, array('option_name' => $option_name)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
3475 + $wpdb->delete($wpdb->options, array('option_name' => $timeout_name)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
3278 3476 return false;
3279 3477 }
3280 3478
3281 - $value = $wpdb->get_var(
3479 + $value = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
3282 3480 $wpdb->prepare(
3283 3481 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
3284 3482 $option_name
3285 3483 )
@@ -3306,10 +3504,10 @@
3306 3504 {
3307 3505 delete_transient($transient_name);
3308 3506 if (wp_using_ext_object_cache()) {
3309 3507 global $wpdb;
3310 - $wpdb->delete($wpdb->options, array('option_name' => '_transient_' . $transient_name));
3311 - $wpdb->delete($wpdb->options, array('option_name' => '_transient_timeout_' . $transient_name));
3508 + $wpdb->delete($wpdb->options, array('option_name' => '_transient_' . $transient_name)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
3509 + $wpdb->delete($wpdb->options, array('option_name' => '_transient_timeout_' . $transient_name)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
3312 3510 }
3313 3511 }
3314 3512
3315 3513 /**
@@ -3326,14 +3524,14 @@
3326 3524 {
3327 3525 set_transient($transient_name, $value, $expiration);
3328 3526 if (wp_using_ext_object_cache()) {
3329 3527 global $wpdb;
3330 - $wpdb->replace($wpdb->options, array(
3528 + $wpdb->replace($wpdb->options, array( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
3331 3529 'option_name' => '_transient_' . $transient_name,
3332 3530 'option_value' => maybe_serialize($value),
3333 3531 'autoload' => 'no',
3334 3532 ));
3335 - $wpdb->replace($wpdb->options, array(
3533 + $wpdb->replace($wpdb->options, array( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
3336 3534 'option_name' => '_transient_timeout_' . $transient_name,
3337 3535 'option_value' => time() + $expiration,
3338 3536 'autoload' => 'no',
3339 3537 ));
@@ -3687,9 +3885,9 @@
3687 3885 *
3688 3886 * @param array $body_params Request body parameters
3689 3887 * @return array|WP_Error Validated parameters array or WP_Error
3690 3888 */
3691 - private function extract_and_validate_searchatlas_params($body_params)
3889 + public function extract_and_validate_searchatlas_params($body_params)
3692 3890 {
3693 3891 $validation_errors = array();
3694 3892
3695 3893 // Extract parameters
@@ -3770,9 +3968,9 @@
3770 3968 } elseif (strlen($whitelabel_domain) > 255) {
3771 3969 $validation_errors['whitelabel_domain'] = 'Whitelabel domain must not exceed 255 characters';
3772 3970 } elseif (!filter_var($whitelabel_domain, FILTER_VALIDATE_URL)) {
3773 3971 $validation_errors['whitelabel_domain'] = 'Whitelabel domain must be a valid URL';
3774 - } elseif (!in_array(parse_url($whitelabel_domain, PHP_URL_SCHEME), ['http', 'https'])) {
3972 + } elseif (!in_array(wp_parse_url($whitelabel_domain, PHP_URL_SCHEME), ['http', 'https'])) {
3775 3973 $validation_errors['whitelabel_domain'] = 'Whitelabel domain must use http or https protocol';
3776 3974 }
3777 3975 }
3778 3976
@@ -3785,9 +3983,9 @@
3785 3983 $validation_errors['whitelabel_logo'] = 'Whitelabel logo URL must not exceed 500 characters';
3786 3984 } else {
3787 3985 // If URL is invalid, we'll clear it later but not fail the POST
3788 3986 if (!filter_var($whitelabel_logo, FILTER_VALIDATE_URL) ||
3789 - !in_array(parse_url($whitelabel_logo, PHP_URL_SCHEME), ['http', 'https'])) {
3987 + !in_array(wp_parse_url($whitelabel_logo, PHP_URL_SCHEME), ['http', 'https'])) {
3790 3988 // Don't add to validation_errors - let POST succeed but clear the field
3791 3989 }
3792 3990 }
3793 3991 }
@@ -3878,9 +4076,10 @@
3878 4076 $options['general'] = array();
3879 4077 }
3880 4078 // Only update the API key in settings if status_code is 200 (success)
3881 4079 if ($status_code === 200) {
3882 - $options['general']['searchatlas_api_key'] = $new_api_key;
4080 + // Encrypt the API key at rest; never store it in cleartext.
4081 + $options['general']['searchatlas_api_key'] = Metasync::encrypt_api_key($new_api_key);
3883 4082 $options['general']['otto_pixel_uuid'] = $new_otto_uuid;
3884 4083 // Note: OTTO SSR is always enabled by default, no need to set
3885 4084
3886 4085 // Granular otto_config_status: record when SSO completed (ISO 8601 UTC)
@@ -3912,11 +4111,20 @@
3912 4111 if (!empty($whitelabel_otto)) {
3913 4112 $options['general']['whitelabel_otto_name'] = $whitelabel_otto;
3914 4113 }
3915 4114 } else {
3916 - // Clear whitelabel fields when not whitelabel
3917 - unset($options['general']['white_label_plugin_name']);
3918 - unset($options['general']['whitelabel_otto_name']);
4115 + // Clear whitelabel fields when not whitelabel.
4116 + //
4117 + // Blanked rather than unset. This array is saved through
4118 + // Metasync::set_option(), and the settings sanitizer merges it
4119 + // over the stored option, so a key the array simply omits keeps
4120 + // whatever was stored — an unset here left the previous
4121 + // agency's plugin name and OTTO name in place forever. An empty
4122 + // string is a value the merge carries, and both readers
4123 + // (get_effective_plugin_name(), get_whitelabel_otto_name())
4124 + // test with !empty(), so it reads exactly like an absent key.
4125 + $options['general']['white_label_plugin_name'] = '';
4126 + $options['general']['whitelabel_otto_name'] = '';
3919 4127 }
3920 4128
3921 4129 // Store whitelabel settings (hidden from UI but accessible to plugin logic)
3922 4130 if (!isset($options['whitelabel'])) {
@@ -3946,11 +4154,12 @@
3946 4154
3947 4155 }
3948 4156
3949 4157 $save_result = Metasync::set_option($options);
4158 + Metasync::invalidate_api_key_cache();
3950 4159
3951 4160 if (!$save_result) {
3952 - error_log('MetaSync SA Connect: mark_searchatlas_nonce_used - Failed to save plugin options');
4161 + error_log('MetaSync SA Connect: mark_searchatlas_nonce_used - Failed to save plugin options'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
3953 4162 } else {
3954 4163 if ($status_code === 200) {
3955 4164 // Option 1: Set heartbeat cache and last-known to CONNECTED so dashboard shows iframe immediately.
3956 4165 // If the immediate heartbeat then fails (e.g. backoff), Option 2 prevents overwriting this to DISCONNECTED.
@@ -3969,9 +4178,9 @@
3969 4178
3970 4179 return true;
3971 4180
3972 4181 } catch (Exception $e) {
3973 - error_log('MetaSync SA Connect: mark_searchatlas_nonce_used Error - ' . $e->getMessage());
4182 + error_log('MetaSync SA Connect: mark_searchatlas_nonce_used Error - ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
3974 4183 return false;
3975 4184 }
3976 4185 }
3977 4186
@@ -3983,9 +4192,9 @@
3983 4192 {
3984 4193 global $wpdb;
3985 4194
3986 4195 // Clear all JWT token transients
3987 - $deleted = $wpdb->query(
4196 + $deleted = $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
3988 4197 $wpdb->prepare(
3989 4198 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
3990 4199 '_transient_metasync_jwt_token_%'
3991 4200 )
@@ -3991,9 +4200,9 @@
3991 4200 )
3992 4201 );
3993 4202
3994 4203 // Also clear timeout transients
3995 - $wpdb->query(
4204 + $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- REST admin endpoint — manual transient/option surgery with no API equivalent
3996 4205 $wpdb->prepare(
3997 4206 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
3998 4207 '_transient_timeout_metasync_jwt_token_%'
3999 4208 )
@@ -4016,9 +4225,9 @@
4016 4225 // Also ensure heartbeat cron is scheduled now that we have an API key
4017 4226 do_action('metasync_ensure_heartbeat_cron_scheduled');
4018 4227
4019 4228 } catch (Exception $e) {
4020 - error_log('MetaSync SA Connect: Error triggering immediate heartbeat check - ' . $e->getMessage());
4229 + error_log('MetaSync SA Connect: Error triggering immediate heartbeat check - ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
4021 4230 }
4022 4231 }
4023 4232
4024 4233 public function get_item_schema()
@@ -4036,15 +4245,15 @@
4036 4245 'type' => 'object',
4037 4246 // In JSON Schema you can specify object properties in the properties attribute.
4038 4247 'properties' => array(
4039 4248 'id' => array(
4040 - 'description' => esc_html__('Unique identifier for the object.', 'my-textdomain'),
4249 + 'description' => esc_html__('Unique identifier for the object.', 'metasync'),
4041 4250 'type' => 'integer',
4042 4251 'context' => array('view', 'edit', 'embed'),
4043 4252 'readonly' => true,
4044 4253 ),
4045 4254 'content' => array(
4046 - 'description' => esc_html__('The content for the object.', 'my-textdomain'),
4255 + 'description' => esc_html__('The content for the object.', 'metasync'),
4047 4256 'type' => 'string',
4048 4257 ),
4049 4258 ),
4050 4259 );
@@ -4135,11 +4344,11 @@
4135 4344
4136 4345 # Try alternative parameter methods
4137 4346 $body_params = $request->get_body_params();
4138 4347 $key_param = $request->get_param('key');
4139 - $post_key = $_POST['key'] ?? null;
4140 - $request_key = $_REQUEST['key'] ?? null;
4141 - $get_key = $_GET['key'] ?? null;
4348 + $post_key = $_POST['key'] ?? null; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- REST route authenticated by API key, not cookies
4349 + $request_key = $_REQUEST['key'] ?? null; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- REST route authenticated by API key, not cookies
4350 + $get_key = $_GET['key'] ?? null; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- REST route authenticated by API key, not cookies
4142 4351
4143 4352 # Try to get key from multiple sources
4144 4353 $key_value = null;
4145 4354
@@ -4203,13 +4412,10 @@
4203 4412 'code' => 'invalid_path'
4204 4413 ), 400);
4205 4414 }
4206 4415
4207 - # Register the key for virtual serving via template_redirect (WP-511).
4208 - # We deliberately do NOT write a physical {key}.txt: on nginx hosts that
4209 - # 403 direct static .txt access, an on-disk file is served statically and
4210 - # blocked before WordPress runs. Serving the key from PHP avoids that and
4211 - # also works on read-only web roots.
4416 + # Register the key for virtual serving via template_redirect as a
4417 + # FALLBACK for read-only web roots and hosts that 403 direct static .txt.
4212 4418 if (!class_exists('Metasync_Bing_Instant_Index')) {
4213 4419 $bing_file = plugin_dir_path(dirname(__FILE__)) . 'bing-index/class-metasync-bing-instant-index.php';
4214 4420 if (file_exists($bing_file)) {
4215 4421 require_once $bing_file;
@@ -4218,19 +4424,57 @@
4218 4424 if (class_exists('Metasync_Bing_Instant_Index')) {
4219 4425 Metasync_Bing_Instant_Index::register_virtual_key($safe_key);
4220 4426 }
4221 4427
4222 - # Best-effort removal of any stale physical key file so the virtual route
4223 - # governs — this is what fixes installs previously broken by a host that
4224 - # 403s static .txt (the on-disk file shadows the PHP handler). Safe: the
4225 - # path was validated to stay within ABSPATH above.
4226 - if (file_exists($file_path)) {
4227 - @unlink($file_path);
4428 + # Decide how {key}.txt is served, then confirm it end-to-end.
4429 + #
4430 + # Two host families must both work:
4431 + # - Static-serving hosts (e.g. cPanel Apache-behind-nginx): a missing .txt
4432 + # is 404'd by the web server before WordPress runs, so ONLY a physical
4433 + # file at the root is reachable.
4434 + # - nginx hosts that 403 direct static .txt: a physical file is blocked AND
4435 + # shadows the PHP route, so the key must be served virtually via
4436 + # template_redirect (no physical file present).
4437 + #
4438 + # Strategy: write the physical file when the root is writable, then probe
4439 + # it. Keep it only on a clean 200-with-matching-body; on 403/404/wrong body
4440 + # delete it so it stops shadowing the virtual route and let WordPress serve
4441 + # the key from PHP instead.
4442 + $serving = 'virtual';
4443 +
4444 + # ABSPATH is intentional and verified: the Bing site-verification key
4445 + # must be served from the site's web root exactly as Bing fetches it
4446 + # (/{key}.txt). $safe_key is basename()+sanitize_file_name()'d and the
4447 + # realpath check above confines $file_path to the root, so this cannot
4448 + # escape it. Plugin Check's ABSPATHDetected warning is a false
4449 + # positive — a verification file cannot live in the uploads directory.
4450 + if (wp_is_writable($wp_root) && @file_put_contents($file_path, $safe_key) !== false) {
4451 + # Cache-buster avoids a stale 404 cached by a CDN/nginx for this path.
4452 + $probe = wp_remote_get(
4453 + home_url('/' . $safe_key . '.txt?_msverify=' . time()),
4454 + array('timeout' => 10, 'sslverify' => false)
4455 + );
4456 + $code = is_wp_error($probe) ? 0 : (int) wp_remote_retrieve_response_code($probe);
4457 + $body = is_wp_error($probe) ? '' : trim((string) wp_remote_retrieve_body($probe));
4458 +
4459 + if ($code === 200 && $body === $safe_key) {
4460 + $serving = 'physical';
4461 + } else {
4462 + # 403 / 404 / wrong body — a present file shadows the virtual route,
4463 + # so remove it and let WordPress serve the key instead.
4464 + @wp_delete_file($file_path);
4465 + $serving = 'virtual';
4466 + }
4228 4467 }
4229 4468
4230 - # 'physical' only if the file still exists (e.g. read-only root prevented
4231 - # removal); otherwise the key is served virtually by WordPress.
4232 - $serving = file_exists($file_path) ? 'physical' : 'virtual';
4469 + # Persist the winning strategy per key so activation stays deterministic
4470 + # and we can tell when a rotated key needs re-probing.
4471 + update_option('metasync_indexnow_serving', array(
4472 + 'key' => $safe_key,
4473 + 'strategy' => $serving,
4474 + 'ts' => time(),
4475 + ), false);
4476 +
4233 4477
4234 4478 # Return success response
4235 4479 return new WP_REST_Response(array(
4236 4480 'success' => true,