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 | includes/class-metasync.php +281 -26 2.6.20 → 2.7.2 View file →
@@ -161,11 +161,21 @@
161 161 // Procedural init file — not a class, must stay explicit.
162 162 if (file_exists(plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php')) {
163 163 require_once plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php';
164 164 } else {
165 - error_log('MetaSync Google Index: google-index-init.php not found at ' . plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php');
165 + error_log('MetaSync Google Index: google-index-init.php not found at ' . plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
166 166 }
167 167
168 + // The SEO precedence resolver is referenced statically from the render
169 + // filters, the sync layer and four admin screens. Require it explicitly
170 + // for the same reason as the admin navigation below: a partial update can
171 + // leave newer PHP files beside an older committed autoload classmap, and
172 + // a missing class on wp_head or the posts list is a fatal, not a
173 + // degradation.
174 + if (!class_exists('Metasync_Seo_Precedence')) {
175 + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-seo-precedence.php';
176 + }
177 +
168 178 // Admin navigation is referenced statically from frontend-reachable
169 179 // includes (heartbeat/connect managers). Require it explicitly here so
170 180 // the static call never fatals when wp_head fires before autoload.
171 181 if (!class_exists('Metasync_Admin_Navigation')) {
@@ -171,9 +181,17 @@
171 181 if (!class_exists('Metasync_Admin_Navigation')) {
172 182 require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-admin-navigation.php';
173 183 }
174 184
185 + // Hooked on init at priority 0, before any theme or plugin callback
186 + // runs, so it cannot wait for a lazy autoload. See define_public_hooks.
187 + if (!class_exists('Metasync_BookingPress_Compat')) {
188 + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-bookingpress-compat.php';
189 + }
190 +
175 191 $this->loader = new Metasync_Loader();
192 + // Install aggregate-option password protection in every runtime context.
193 + Metasync_Settings_Registration::instance();
176 194 $this->db_heartbeat_errors = new Metasync_HeartBeat_Error_Monitor_Database();
177 195 $this->db_redirection = new Metasync_Redirection_Database();
178 196 }
179 197
@@ -261,16 +279,12 @@
261 279 $this->loader->add_action('wp_ajax_metasync_generate_connect_url', $plugin_admin, 'generate_searchatlas_connect_url');
262 280 $this->loader->add_action('wp_ajax_metasync_check_connect_status', $plugin_admin, 'check_searchatlas_connect_status');
263 281 $this->loader->add_action('wp_ajax_metasync_reset_authentication', $plugin_admin, 'reset_searchatlas_authentication');
264 282
265 - // Auto-update filter
266 - $this->loader->add_filter('auto_update_plugin', $plugin_admin, 'control_plugin_auto_updates', 10, 2);
267 -
268 283 // Search Atlas Connect development/testing endpoints
269 284 $this->loader->add_action('wp_ajax_metasync_test_enhanced_tokens', $plugin_admin, 'test_enhanced_searchatlas_tokens');
270 285 $this->loader->add_action('wp_ajax_metasync_test_whitelabel_domain', $plugin_admin, 'test_whitelabel_domain');
271 286 $this->loader->add_action('wp_ajax_metasync_test_ajax_endpoint', $plugin_admin, 'test_searchatlas_ajax_endpoint');
272 - $this->loader->add_action('wp_ajax_metasync_simple_ajax_test', $plugin_admin, 'simple_ajax_test');
273 287
274 288
275 289 $post_meta_setting = new Metasync_Post_Meta_Settings();
276 290 $this->loader->add_action('admin_init', $post_meta_setting, 'add_post_meta_data', 2);
@@ -275,19 +289,50 @@
275 289 $post_meta_setting = new Metasync_Post_Meta_Settings();
276 290 $this->loader->add_action('admin_init', $post_meta_setting, 'add_post_meta_data', 2);
277 291 $this->loader->add_action('admin_init', $post_meta_setting, 'show_top_admin_bar', 9);
278 292
293 + // Unified "SEO Suite" meta box — consolidates the separate Classic-editor
294 + // meta boxes above into one tabbed box (presentation-only; save handlers
295 + // unchanged). Classic editor only; the block editor keeps its SEO sidebar.
296 + // Self-registers its hooks in the constructor. Opt out via the
297 + // `metasync_enable_seo_suite` filter.
298 + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-seo-suite.php';
299 + new Metasync_Seo_Suite();
300 +
301 + // Classic-editor save path for the three fields that exist only as
302 + // Gutenberg sidebar (REST) meta otherwise: breadcrumb title, language
303 + // alternates, primary category. The Suite renders the first two and
304 + // emits the nonce; this handler persists them from the classic form.
305 + // It guards itself against block-editor and managed-page context, so
306 + // it is safe to register unconditionally.
307 + require_once plugin_dir_path(dirname(__FILE__)) . 'admin/class-metasync-classic-seo-fields.php';
308 + new Metasync_Classic_Seo_Fields();
309 +
279 310 // SEO Health CSV export: must run on admin_init (before output).
280 311 // Cheap $_GET check avoids loading the class on every admin page.
281 312 if (
282 - isset($_GET['page'], $_GET['export'], $_GET['_wpnonce']) &&
283 - $_GET['export'] === 'csv' &&
284 - strpos($_GET['page'], '-seo-health') !== false
313 + isset($_GET['page'], $_GET['export'], $_GET['_wpnonce']) && // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- routing check, the handler verifies nonce and capability
314 + $_GET['export'] === 'csv' && // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- routing check, the handler verifies nonce and capability
315 + strpos($_GET['page'], '-seo-health') !== false // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- routing check, the handler verifies nonce and capability
285 316 ) {
286 317 $this->loader->add_action('admin_init', Metasync_SEO_Health::get_instance(), 'handle_csv_export', 1);
287 318 }
288 319 $this->loader->add_action('wp', $post_meta_setting, 'show_top_admin_bar', 9);
289 320
321 + // SEO meta columns on the posts/pages list tables (WP-624).
322 + // Registered for both the `posts` and `pages` variants of each hook so the
323 + // columns reach every supported post type; the callbacks bail on unsupported
324 + // ones. Hidden by default — users opt in from Screen Options.
325 + $seo_columns = Metasync_SEO_Columns::get_instance();
326 + $this->loader->add_filter('manage_posts_columns', $seo_columns, 'add_columns', 10, 2);
327 + $this->loader->add_filter('manage_pages_columns', $seo_columns, 'add_columns', 10, 1);
328 + $this->loader->add_action('manage_posts_custom_column', $seo_columns, 'render_column', 10, 2);
329 + $this->loader->add_action('manage_pages_custom_column', $seo_columns, 'render_column', 10, 2);
330 + $this->loader->add_filter('default_hidden_columns', $seo_columns, 'default_hidden_columns', 10, 2);
331 + $this->loader->add_filter('hidden_columns', $seo_columns, 'hidden_columns', 10, 3);
332 + $this->loader->add_action('admin_head', $seo_columns, 'print_styles');
333 + $this->loader->add_action('admin_notices', $seo_columns, 'companion_plugin_notice');
334 +
290 335 // Initialize XML Sitemap auto-update hooks if enabled
291 336 // Note: Must not be gated by is_admin() because Gutenberg saves posts
292 337 // via the REST API where is_admin() returns false, and REST_REQUEST
293 338 // is not yet defined at plugin load time
@@ -307,13 +352,8 @@
307 352 if (class_exists('Metasync_Breadcrumbs_Schema')) {
308 353 new Metasync_Breadcrumbs_Schema($this->get_plugin_name(), $this->get_version());
309 354 }
310 355
311 - // Initialize Developer Panel (for endpoint switching)
312 - if (class_exists('Metasync_Dev_Panel')) {
313 - $dev_panel = new Metasync_Dev_Panel($this->get_plugin_name(), $this->get_version());
314 - }
315 -
316 356 // Initialize Site Health integration
317 357 if (class_exists('Metasync_Site_Health')) {
318 358 $site_health = new Metasync_Site_Health();
319 359 $site_health->register_tests();
@@ -341,9 +381,12 @@
341 381
342 382 $plugin_public = new Metasync_Public($this->get_plugin_name(), $this->get_version());
343 383 $rest_api = $plugin_public->get_rest_api();
344 384 $seo_output = $plugin_public->get_seo_output();
345 - $get_plugin_basename = sprintf('%1$s/%1$s.php', $this->plugin_name);
385 + // Derive the basename from the actual file location: the plugin
386 + // folder is not guaranteed to be named after the plugin (renamed
387 + // installs), and a hardcoded name makes the settings links vanish.
388 + $get_plugin_basename = plugin_basename(dirname(__DIR__) . '/metasync.php');
346 389
347 390 // Asset enqueue hooks (Metasync_Public)
348 391 $this->loader->add_action('wp_enqueue_scripts', $plugin_public, 'enqueue_styles');
349 392 $this->loader->add_action('wp_enqueue_scripts', $plugin_public, 'enqueue_scripts');
@@ -348,8 +391,12 @@
348 391 $this->loader->add_action('wp_enqueue_scripts', $plugin_public, 'enqueue_styles');
349 392 $this->loader->add_action('wp_enqueue_scripts', $plugin_public, 'enqueue_scripts');
350 393 $this->loader->add_action('wp_enqueue_scripts', $plugin_public, 'enqueue_page_custom_css', 999);
351 394
395 + // Registered here rather than from inside enqueue_scripts(), which would
396 + // re-register the same filter on every front-end request.
397 + $this->loader->add_filter('script_loader_tag', $plugin_public, 'add_defer_attribute', 10, 2);
398 +
352 399 // Elementor editor CSS injection
353 400 if (class_exists('\Elementor\Plugin')) {
354 401 $this->loader->add_action('elementor/preview/enqueue_styles', $plugin_public, 'enqueue_elementor_editor_css', 999);
355 402 }
@@ -362,8 +409,21 @@
362 409 // Initialize centralized SEO conflict handler (singleton — suppresses
363 410 // third-party SEO plugin descriptions when MetaSync provides its own).
364 411 Metasync_SEO_Conflict_Handler::get_instance();
365 412
413 + // Headless delivery: register the GraphQL SEO field. This only adds a
414 + // callback on `graphql_register_types`, which never fires unless WPGraphQL
415 + // is active, and the callback returns immediately unless Headless Mode is
416 + // on — so with the mode off nothing about this request changes.
417 + Metasync_Headless_Graphql::init();
418 +
419 + // Headless stale-while-revalidate OTTO refresh: background safety net for
420 + // missed/delayed OTTO deployment webhooks. Every branch is gated on
421 + // Metasync_Headless_Config::is_active(), so with headless mode off this
422 + // schedules no cron event and registers no route beyond an inert REST
423 + // callback that itself checks the flag before doing anything.
424 + Metasync_Headless_Refresh_Job::init();
425 +
366 426 // Term-level SEO plugin sync: propagate MetaSync term meta (category/tag
367 427 // archives) into Yoast/Rank Math/AIOSEO term storage on every write.
368 428 $this->loader->add_action('updated_term_meta', $this, 'on_term_meta_updated', 10, 4);
369 429 $this->loader->add_action('added_term_meta', $this, 'on_term_meta_updated', 10, 4);
@@ -371,8 +431,11 @@
371 431 // Post-level plugin sync: propagate MetaSync post meta into
372 432 // Yoast/Rank Math/AIOSEO post storage on every write.
373 433 $this->loader->add_action('updated_post_meta', $this, 'on_post_meta_updated', 10, 4);
374 434 $this->loader->add_action('added_post_meta', $this, 'on_post_meta_updated', 10, 4);
435 + // Deletes matter too: clearing the last checkbox in a meta box removes
436 + // the meta row, which fires neither hook above.
437 + $this->loader->add_action('deleted_post_meta', $this, 'on_post_meta_deleted', 10, 3);
375 438
376 439 // SEO Output hooks (Metasync_Seo_Output)
377 440 $this->loader->add_action('wp_head', $seo_output, 'hook_metasync_metatags', 1, 1);
378 441 // LocalBusiness / Organization / Person JSON-LD from the Local Business page.
@@ -378,15 +441,28 @@
378 441 // LocalBusiness / Organization / Person JSON-LD from the Local Business page.
379 442 $this->loader->add_action('wp_head', $seo_output, 'output_local_business_schema', 2, 1);
380 443 $this->loader->add_action('template_redirect', $seo_output, 'inject_archive_seo_controls');
381 444
382 - // Hreflang / language alternates output (wp_head @ priority 2).
383 - $plugin_hreflang = new Metasync_Hreflang_Output();
445 + // Hreflang / language alternates output (wp_head @ priority 2). The
446 + // emitter shares the SEO output instance for the noindex check.
447 + $plugin_hreflang = new Metasync_Hreflang_Output($seo_output);
384 448 $this->loader->add_action('wp_head', $plugin_hreflang, 'output_hreflang_tags', 2);
449 + // WPML prints its own hreflang set at wp_head priority 1, ahead of
450 + // MetaSync's output at priority 2 — the suppression filter has to be
451 + // in place before wp_head starts, hence template_redirect.
452 + $this->loader->add_action('template_redirect', $plugin_hreflang, 'register_wpml_suppression', 1);
385 453
386 454 // Edge Cache: detect Cloudways Varnish and persist for settings UI
387 455 $this->loader->add_action('init', 'Metasync_Edge_Cache_Purge', 'detect_cloudways');
388 456
457 + // BookingPress starts a PHP session on every front-end request (init
458 + // at priority 1), which makes hosts skip page caching site-wide. Its
459 + // booking flows manage their own sessions inside their AJAX handlers,
460 + // so unhook the global start. Priority 0 keeps this ahead of theirs.
461 + // Registered with add_action directly (like Metasync_Oxygen_Compat)
462 + // because the loader's $component parameter is typed to objects.
463 + add_action('init', ['Metasync_BookingPress_Compat', 'neutralize_bookingpress_session'], 0);
464 +
389 465 // Sitemap exclusions for disabled archive types
390 466 $this->loader->add_filter('wp_sitemaps_taxonomies', $seo_output, 'filter_sitemap_taxonomies');
391 467 $this->loader->add_filter('wp_sitemaps_users_entry', $seo_output, 'filter_sitemap_users', 10, 2);
392 468 $this->loader->add_filter('wp_sitemaps_add_provider', $seo_output, 'filter_sitemap_providers', 10, 2);
@@ -425,8 +501,14 @@
425 501 // "JWT Authentication for WP-API" plugin surfaces its jwt_auth_invalid_token
426 502 // 403 via rest_pre_dispatch (priority 10), so we hook the same filter at a
427 503 // later priority (11) to clear it for our namespace only.
428 504 $this->loader->add_filter('rest_pre_dispatch', $rest_api, 'allow_metasync_rest_auth', 11, 3);
505 + // Coexist with site-wide "authenticated users only" REST restrictions.
506 + // Those hook rest_authentication_errors, which WP applies before dispatch,
507 + // so rest_pre_dispatch and permission_callback never run. Hook it late
508 + // (99) to clear the error for metasync/v1 requests that present a valid
509 + // plugin API key; every other route keeps the site's restriction.
510 + $this->loader->add_filter('rest_authentication_errors', $rest_api, 'allow_metasync_rest_authentication', 99, 1);
429 511 $this->loader->add_action('init', $plugin_public, 'metasync_plugin_init', 5);
430 512 $this->loader->add_action('wp_ajax_metasync_lglogin', $rest_api, 'linkgraph_login');
431 513
432 514 // Robots meta filter (Metasync_Seo_Output)
@@ -472,12 +554,38 @@
472 554 // on read-only web roots and nginx hosts that 403 direct static .txt access.
473 555 require_once plugin_dir_path(dirname(__FILE__)) . 'bing-index/class-metasync-bing-instant-index.php';
474 556 add_action('template_redirect', array('Metasync_Bing_Instant_Index', 'serve_virtual_key_file'), 0);
475 557
558 + // Scheduled IndexNow submissions, deferred from save_post so a slow
559 + // IndexNow round-trip cannot stall the editor or REST write. Registered
560 + // statically because the class is loaded but never instantiated on
561 + // cron loads. The hook name matches the uninstall cron sweep exactly.
562 + add_action('metasync_bing_indexnow_submit_event', array('Metasync_Bing_Instant_Index', 'run_scheduled_submit'), 10, 1);
563 +
564 + // Construct the robots.txt manager on the public load path so its
565 + // `robots_txt` filter is registered before a plain GET /robots.txt is
566 + // served. The filter is attached in the singleton's constructor, and
567 + // previously only admin screens built the instance — so the virtual
568 + // robots.txt rules and sitemap lines never rendered on the frontend.
569 + require_once plugin_dir_path(dirname(__FILE__)) . 'robots-txt/class-metasync-robots-txt.php';
570 + Metasync_Robots_Txt::get_instance();
571 +
476 572 // One-time upgrade: regenerate sitemap to remove any Beaver Builder template entries
477 573 if ( ! get_option( 'metasync_sitemap_bb_exclusion_applied' ) ) {
478 574 $this->loader->add_action('init', $this, 'maybe_regenerate_sitemap_after_upgrade');
479 575 }
576 +
577 + // One-time upgrade: delete the legacy Otto crawl-data option. Nothing
578 + // reads or writes it anymore (the webhook and SEO sync job stopped
579 + // persisting crawl data), and on sites where OTTO crawled many URLs
580 + // the row could grow to megabytes while being autoloaded on every
581 + // request before WordPress 6.6. The guard below runs on every request,
582 + // so the flag must be autoloaded (like the sitemap flag above): a
583 + // boolean true costs ~2 bytes inside the alloptions query WordPress
584 + // already always runs, keeping the steady-state cost a cache hit.
585 + if ( ! get_option( 'metasync_otto_crawldata_removed' ) ) {
586 + $this->loader->add_action('init', $this, 'maybe_delete_legacy_otto_crawl_data');
587 + }
480 588 }
481 589
482 590 /**
483 591 * One-time upgrade routine: regenerate the XML sitemap so that Beaver Builder
@@ -507,8 +615,40 @@
507 615 // so that enabling sitemaps later will still clean up BB templates.
508 616 }
509 617
510 618 /**
619 + * One-time upgrade routine: delete the legacy Otto crawl-data option.
620 + *
621 + * The option stored every URL Otto ever sent and grew without bound;
622 + * nothing writes it anymore and nothing in production reads it (per-URL
623 + * state lives in Metasync_Otto_Job_Status, delivery receipts in
624 + * Metasync_Otto_Webhook_Audit, both bounded). This drops the leftover
625 + * row — which on long-running sites can be megabytes of autoloaded data.
626 + *
627 + * Runs once on 'init' and sets a flag so it never runs again. The flag
628 + * is autoloaded deliberately: the registration guard reads it on every
629 + * request, and an autoloaded boolean is a free cache hit inside the
630 + * alloptions query WordPress always performs — a non-autoloaded flag
631 + * would cost a dedicated SELECT per request forever, the exact waste
632 + * this routine exists to remove. delete_option() is called directly
633 + * without reading the option first: loading a multi-megabyte value into
634 + * memory just to delete it would be the one way this cleanup could hurt
635 + * a site, and the SQL DELETE never needs the value.
636 + */
637 + public function maybe_delete_legacy_otto_crawl_data() {
638 + $done_key = 'metasync_otto_crawldata_removed';
639 + if ( get_option( $done_key ) ) {
640 + return;
641 + }
642 +
643 + // No "feature in use" conditional here, unlike the sitemap routine:
644 + // the row is dead weight whether or not Otto is configured, and
645 + // delete_option() on an absent row is a cheap no-op.
646 + delete_option( 'metasync_otto_crawldata' );
647 + update_option( $done_key, true );
648 + }
649 +
650 + /**
511 651 * Initialize endpoint URL filtering for staging mode
512 652 * Intercepts HTTP requests and replaces production URLs with staging URLs
513 653 */
514 654 private function init_endpoint_filtering() {
@@ -552,9 +692,9 @@
552 692 $original_url = $url;
553 693 foreach ($url_replacements as $production => $staging) {
554 694 if (strpos($url, $production) === 0) {
555 695 $url = str_replace($production, $staging, $url);
556 - error_log("MetaSync Endpoint Filter: Replaced {$production} with {$staging} in URL: {$original_url}");
696 + error_log("MetaSync Endpoint Filter: Replaced {$production} with {$staging} in URL: {$original_url}"); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-gated, no secrets
557 697 break;
558 698 }
559 699 }
560 700
@@ -632,16 +772,86 @@
632 772 * @param string $meta_key Meta key being written.
633 773 * @param mixed $meta_value Meta value being written.
634 774 */
635 775 public function on_post_meta_updated($meta_id, $post_id, $meta_key, $meta_value) {
636 - if (!class_exists('Metasync_Plugin_Sync')) {
776 + if (!self::is_metasync_meta_key($meta_key)) {
637 777 return;
638 778 }
639 779
780 + if (!self::sync_layer_handles('on_meta_updated')) {
781 + return;
782 + }
783 +
640 784 Metasync_Plugin_Sync::get_instance()->on_meta_updated($meta_id, $post_id, $meta_key, $meta_value);
641 785 }
642 786
643 787 /**
788 + * Bridge deleted_post_meta to the sync layer.
789 + *
790 + * Clearing the last checkbox in a meta box deletes its meta row rather than
791 + * updating it, so the update hooks above never fire and mirrored values can
792 + * go stale. Only the legacy robots keys are acted on; see
793 + * Metasync_Plugin_Sync::on_meta_deleted().
794 + *
795 + * @param array $meta_ids Meta row IDs (unused).
796 + * @param int $post_id Post ID.
797 + * @param string $meta_key Meta key being deleted.
798 + */
799 + public function on_post_meta_deleted($meta_ids, $post_id, $meta_key) {
800 + if (!self::is_metasync_meta_key($meta_key)) {
801 + return;
802 + }
803 +
804 + if (!self::sync_layer_handles('on_meta_deleted')) {
805 + return;
806 + }
807 +
808 + Metasync_Plugin_Sync::get_instance()->on_meta_deleted($meta_ids, $post_id, $meta_key);
809 + }
810 +
811 + /**
812 + * Is this meta key one the post sync layer could possibly care about?
813 + *
814 + * Cheap string gate, no autoload, no singleton. Every watched post key is
815 + * either `_metasync_*` (sidebar, OTTO and the mirrored robots JSON) or
816 + * `metasync_*` (the legacy meta box keys), so this keeps third-party meta
817 + * writes out of the sync layer entirely — `deleted_post_meta` and
818 + * `updated_post_meta` fire for every key on the site, including on front-end
819 + * requests, and crossing into the sync layer for keys it will only discard
820 + * costs an autoload plus a singleton on the hot path.
821 + *
822 + * Deliberately broader than the sync layer's own key maps: those stay the
823 + * single source of exact truth, so a new MetaSync key needs no change here.
824 + *
825 + * @param string $meta_key Meta key being written or deleted.
826 + * @return bool
827 + */
828 + private static function is_metasync_meta_key($meta_key) {
829 + return strncmp($meta_key, '_metasync_', 10) === 0
830 + || strncmp($meta_key, 'metasync_', 9) === 0;
831 + }
832 +
833 + /**
834 + * Can the post sync layer actually handle this hook right now?
835 + *
836 + * A partially updated install can leave a newer class-metasync.php beside an
837 + * older class-metasync-plugin-sync.php — stale opcache bytecode for one file
838 + * is enough. Calling a method the loaded class does not define is a fatal,
839 + * and because these bridges run on `wp_head` via third-party meta writes it
840 + * takes the front end down rather than degrading. Check before dispatching.
841 + *
842 + * Checked on the class, not an instance, so a mismatch skips the singleton.
843 + *
844 + * @param string $method Sync-layer method about to be called.
845 + * @return bool
846 + */
847 + private static function sync_layer_handles($method) {
848 + return class_exists('Metasync_Plugin_Sync')
849 + && method_exists('Metasync_Plugin_Sync', 'get_instance')
850 + && method_exists('Metasync_Plugin_Sync', $method);
851 + }
852 +
853 + /**
644 854 * Save current theme information to MetaSync options
645 855 * This runs in WordPress admin context, not during REST API requests
646 856 * Safe to use wp_get_theme() here
647 857 */
@@ -741,8 +951,52 @@
741 951 return is_string($value) && strncmp($value, self::API_KEY_ENC_PREFIX, strlen(self::API_KEY_ENC_PREFIX)) === 0;
742 952 }
743 953
744 954 /**
955 + * Whether a value is a strictly valid UUID (versions 1-8).
956 + *
957 + * The single shared validator for every write path that persists
958 + * `general.otto_pixel_uuid` (SSO push callback, pull-based connect,
959 + * heartbeat writes, settings sanitizer). The OTTO Pixel UUID must never
960 + * hold arbitrary text — usernames such as `raza.imran` landing in it
961 + * break OTTO SSR authentication — so every write path funnels through
962 + * this check instead of a scattered one-off regex.
963 + *
964 + * Deliberately stricter than wp_is_uuid(), which only checks the
965 + * 8-4-4-4-12 hex shape and would accept placeholder values such as
966 + * `11111111-2222-3333-4444-555555555555`. The version nibble (first hex
967 + * of the third group) must be 1-8 — RFC 4122 defined 1-5 and RFC 9562
968 + * added 6/7/8, which modern generators increasingly emit — and the
969 + * variant nibble (first hex of the fourth group) must be 8/9/a/b. That
970 + * keeps behaviour identical with and without the WP helper loaded (unit
971 + * tests run without it).
972 + *
973 + * An empty string is NOT valid: clearing the field is a separate concern
974 + * owned by the disconnect flow (unset + write_options_without_stored_merge),
975 + * so '' must not masquerade as a usable UUID here.
976 + *
977 + * @param mixed $value
978 + * @return bool True only for a canonical 8-4-4-4-12 hex UUID of version 1-8
979 + * with an RFC-4122 variant (8/9/a/b) in the fourth group.
980 + */
981 + public static function is_valid_uuid($value)
982 + {
983 + if (!is_string($value)) {
984 + return false;
985 + }
986 +
987 + $value = trim($value);
988 + if ($value === '') {
989 + return false;
990 + }
991 +
992 + return (bool) preg_match(
993 + '/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i',
994 + $value
995 + );
996 + }
997 +
998 + /**
745 999 * Encrypt a plaintext Search Atlas API key for storage at rest.
746 1000 *
747 1001 * Uses AES-256-GCM (authenticated) with a random 12-byte IV. The IV, the
748 1002 * 16-byte GCM tag and the ciphertext are concatenated and base64-encoded
@@ -768,9 +1022,9 @@
768 1022 }
769 1023
770 1024 if (!function_exists('openssl_encrypt')) {
771 1025 // OpenSSL unavailable — store plaintext rather than lose the key.
772 - error_log('MetaSync: OpenSSL is unavailable — the Search Atlas API key was stored WITHOUT encryption at rest.');
1026 + error_log('MetaSync: OpenSSL is unavailable — the Search Atlas API key was stored WITHOUT encryption at rest.'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
773 1027 return $plaintext;
774 1028 }
775 1029
776 1030 $key = self::api_key_crypto_key();
@@ -779,9 +1033,9 @@
779 1033 $ciphertext = openssl_encrypt($plaintext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag, '', 16);
780 1034
781 1035 if ($ciphertext === false) {
782 1036 // Encryption failed — fall back to plaintext storage, but say so.
783 - error_log('MetaSync: API key encryption failed — the Search Atlas API key was stored WITHOUT encryption at rest.');
1037 + error_log('MetaSync: API key encryption failed — the Search Atlas API key was stored WITHOUT encryption at rest.'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
784 1038 return $plaintext;
785 1039 }
786 1040
787 1041 return self::API_KEY_ENC_PREFIX . base64_encode($iv . $tag . $ciphertext);
@@ -988,9 +1242,9 @@
988 1242 }
989 1243
990 1244 if (!function_exists('openssl_encrypt')) {
991 1245 // OpenSSL unavailable — store plaintext rather than lose the secret.
992 - error_log('MetaSync: OpenSSL is unavailable — a secret was stored WITHOUT encryption at rest.');
1246 + error_log('MetaSync: OpenSSL is unavailable — a secret was stored WITHOUT encryption at rest.'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
993 1247 return $plaintext;
994 1248 }
995 1249
996 1250 $key = self::secret_crypto_key();
@@ -999,9 +1253,9 @@
999 1253 $ciphertext = openssl_encrypt($plaintext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag, '', 16);
1000 1254
1001 1255 if ($ciphertext === false) {
1002 1256 // Encryption failed — fall back to plaintext storage, but say so.
1003 - error_log('MetaSync: Secret encryption failed — a secret was stored WITHOUT encryption at rest.');
1257 + error_log('MetaSync: Secret encryption failed — a secret was stored WITHOUT encryption at rest.'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
1004 1258 return $plaintext;
1005 1259 }
1006 1260
1007 1261 return self::SECRET_ENC_PREFIX . base64_encode($iv . $tag . $ciphertext);
@@ -1332,8 +1586,9 @@
1332 1586 * @param string $text Plain-English help text (escaped internally).
1333 1587 */
1334 1588 public static function render_tooltip_icon($tooltip_id, $text)
1335 1589 {
1590 + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- get_tooltip_icon_html() escapes every interpolated value internally (esc_attr / esc_html)
1336 1591 echo self::get_tooltip_icon_html($tooltip_id, $text);
1337 1592 }
1338 1593
1339 1594 /**
@@ -1426,9 +1681,9 @@
1426 1681 // This could be extended to store in a dedicated log table
1427 1682
1428 1683 } catch (Exception $e) {
1429 1684 // Fallback logging if structured logging fails
1430 - error_log('MetaSync API Key Event Logging Error: ' . $e->getMessage());
1685 + error_log('MetaSync API Key Event Logging Error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
1431 1686 }
1432 1687 }
1433 1688
1434 1689 /**
@@ -1492,9 +1747,9 @@
1492 1747 $global_hits = get_transient($global_rate_key);
1493 1748 if ($global_hits !== false && $global_hits >= 50) {
1494 1749 // More than 50 404s per minute - stop logging to protect database
1495 1750 if ($global_hits === 50) {
1496 - error_log('MetaSync 404 Monitor: Rate limit exceeded - 50+ 404s per minute. Pausing logging.');
1751 + error_log('MetaSync 404 Monitor: Rate limit exceeded - 50+ 404s per minute. Pausing logging.'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
1497 1752 }
1498 1753 set_transient($global_rate_key, $global_hits + 1, 60);
1499 1754 return;
1500 1755 }
@@ -1519,9 +1774,9 @@
1519 1774 require_once plugin_dir_path(dirname(__FILE__)) . '404-monitor/class-metasync-404-monitor-database.php';
1520 1775 $db_404 = new Metasync_Error_Monitor_Database();
1521 1776
1522 1777 // Get user agent (sanitized)
1523 - $user_agent = isset($_SERVER['HTTP_USER_AGENT']) ? sanitize_text_field($_SERVER['HTTP_USER_AGENT']) : '';
1778 + $user_agent = isset($_SERVER['HTTP_USER_AGENT']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_USER_AGENT'])) : '';
1524 1779
1525 1780 // Log the 404 error
1526 1781 $result = $db_404->update([
1527 1782 'uri' => $current_url,
@@ -1547,9 +1802,9 @@
1547 1802 $host = $_SERVER['SERVER_NAME'];
1548 1803 }
1549 1804 if (empty($host)) {
1550 1805 // Fallback to WordPress site URL if available
1551 - $host = parse_url(home_url(), PHP_URL_HOST);
1806 + $host = wp_parse_url(home_url(), PHP_URL_HOST);
1552 1807 }
1553 1808
1554 1809 // Safely get REQUEST_URI with fallback
1555 1810 $uri = isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '/';