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 +788 -33 2.6.10 → 2.7.2 View file →
@@ -71,10 +71,19 @@
71 71
72 72
73 73
74 74 public const option_name = "metasync_options";
75 -
75 +
76 76 /**
77 + * Dedicated option key for heartbeat throttle timestamps.
78 + *
79 + * Stored separately from the main options blob so writes to last_heart_beat
80 + * and last_heartbeat_at do not race with concurrent settings writes during
81 + * the 15-second wp_remote_post window in SyncCustomerParams.
82 + */
83 + public const heartbeat_throttle_option = "metasync_heartbeat_throttle";
84 +
85 + /**
77 86 * Search Atlas Domain Constants
78 87 * Centralized constants for all Search Atlas service endpoints
79 88 */
80 89 public const HOMEPAGE_DOMAIN = "https://searchatlas.com";
@@ -84,8 +93,27 @@
84 93 public const SUPPORT_EMAIL = "[email protected]";
85 94 public const DOCUMENTATION_DOMAIN = "https://help.searchatlas.com";
86 95
87 96 /**
97 + * Storage prefix marking a Search Atlas API key value as encrypted at rest.
98 + */
99 + private const API_KEY_ENC_PREFIX = 'enc_v1:';
100 +
101 + /**
102 + * Request-scoped memo for the decrypted Search Atlas API key.
103 + *
104 + * null = not yet loaded this request
105 + * false = load attempted but decryption failed (salts changed / corrupt)
106 + * string = decrypted plaintext (may be '')
107 + *
108 + * Never written to a transient or the DB — exists only for the lifetime
109 + * of the current request.
110 + *
111 + * @var string|false|null
112 + */
113 + private static $memo_api_key = null;
114 +
115 + /**
88 116 * Define the core functionality of the plugin.
89 117 *
90 118 * Set the plugin name and the plugin version that can be used throughout the plugin.
91 119 * Load the dependencies, define the locale, and set the hooks for the admin area and
@@ -133,12 +161,37 @@
133 161 // Procedural init file — not a class, must stay explicit.
134 162 if (file_exists(plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php')) {
135 163 require_once plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php';
136 164 } else {
137 - 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
138 166 }
139 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 +
178 + // Admin navigation is referenced statically from frontend-reachable
179 + // includes (heartbeat/connect managers). Require it explicitly here so
180 + // the static call never fatals when wp_head fires before autoload.
181 + if (!class_exists('Metasync_Admin_Navigation')) {
182 + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-admin-navigation.php';
183 + }
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 +
140 191 $this->loader = new Metasync_Loader();
192 + // Install aggregate-option password protection in every runtime context.
193 + Metasync_Settings_Registration::instance();
141 194 $this->db_heartbeat_errors = new Metasync_HeartBeat_Error_Monitor_Database();
142 195 $this->db_redirection = new Metasync_Redirection_Database();
143 196 }
144 197
@@ -226,16 +279,12 @@
226 279 $this->loader->add_action('wp_ajax_metasync_generate_connect_url', $plugin_admin, 'generate_searchatlas_connect_url');
227 280 $this->loader->add_action('wp_ajax_metasync_check_connect_status', $plugin_admin, 'check_searchatlas_connect_status');
228 281 $this->loader->add_action('wp_ajax_metasync_reset_authentication', $plugin_admin, 'reset_searchatlas_authentication');
229 282
230 - // Auto-update filter
231 - $this->loader->add_filter('auto_update_plugin', $plugin_admin, 'control_plugin_auto_updates', 10, 2);
232 -
233 283 // Search Atlas Connect development/testing endpoints
234 284 $this->loader->add_action('wp_ajax_metasync_test_enhanced_tokens', $plugin_admin, 'test_enhanced_searchatlas_tokens');
235 285 $this->loader->add_action('wp_ajax_metasync_test_whitelabel_domain', $plugin_admin, 'test_whitelabel_domain');
236 286 $this->loader->add_action('wp_ajax_metasync_test_ajax_endpoint', $plugin_admin, 'test_searchatlas_ajax_endpoint');
237 - $this->loader->add_action('wp_ajax_metasync_simple_ajax_test', $plugin_admin, 'simple_ajax_test');
238 287
239 288
240 289 $post_meta_setting = new Metasync_Post_Meta_Settings();
241 290 $this->loader->add_action('admin_init', $post_meta_setting, 'add_post_meta_data', 2);
@@ -240,19 +289,50 @@
240 289 $post_meta_setting = new Metasync_Post_Meta_Settings();
241 290 $this->loader->add_action('admin_init', $post_meta_setting, 'add_post_meta_data', 2);
242 291 $this->loader->add_action('admin_init', $post_meta_setting, 'show_top_admin_bar', 9);
243 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 +
244 310 // SEO Health CSV export: must run on admin_init (before output).
245 311 // Cheap $_GET check avoids loading the class on every admin page.
246 312 if (
247 - isset($_GET['page'], $_GET['export'], $_GET['_wpnonce']) &&
248 - $_GET['export'] === 'csv' &&
249 - 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
250 316 ) {
251 317 $this->loader->add_action('admin_init', Metasync_SEO_Health::get_instance(), 'handle_csv_export', 1);
252 318 }
253 319 $this->loader->add_action('wp', $post_meta_setting, 'show_top_admin_bar', 9);
254 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 +
255 335 // Initialize XML Sitemap auto-update hooks if enabled
256 336 // Note: Must not be gated by is_admin() because Gutenberg saves posts
257 337 // via the REST API where is_admin() returns false, and REST_REQUEST
258 338 // is not yet defined at plugin load time
@@ -272,13 +352,8 @@
272 352 if (class_exists('Metasync_Breadcrumbs_Schema')) {
273 353 new Metasync_Breadcrumbs_Schema($this->get_plugin_name(), $this->get_version());
274 354 }
275 355
276 - // Initialize Developer Panel (for endpoint switching)
277 - if (class_exists('Metasync_Dev_Panel')) {
278 - $dev_panel = new Metasync_Dev_Panel($this->get_plugin_name(), $this->get_version());
279 - }
280 -
281 356 // Initialize Site Health integration
282 357 if (class_exists('Metasync_Site_Health')) {
283 358 $site_health = new Metasync_Site_Health();
284 359 $site_health->register_tests();
@@ -303,18 +378,15 @@
303 378
304 379 $this->loader->add_action('wp_head', $code_snippets, 'get_header_snippet');
305 380 $this->loader->add_action('wp_footer', $code_snippets, 'get_footer_snippet');
306 381
307 -
308 -
309 - $optimal_settings = new Metasync_Optimal_Settings();
310 - $this->loader->add_filter('wp_robots', $optimal_settings, 'add_robots_meta');
311 - $this->loader->add_action('the_content', $optimal_settings, 'add_attributes_external_links');
312 -
313 382 $plugin_public = new Metasync_Public($this->get_plugin_name(), $this->get_version());
314 383 $rest_api = $plugin_public->get_rest_api();
315 384 $seo_output = $plugin_public->get_seo_output();
316 - $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');
317 389
318 390 // Asset enqueue hooks (Metasync_Public)
319 391 $this->loader->add_action('wp_enqueue_scripts', $plugin_public, 'enqueue_styles');
320 392 $this->loader->add_action('wp_enqueue_scripts', $plugin_public, 'enqueue_scripts');
@@ -319,8 +391,12 @@
319 391 $this->loader->add_action('wp_enqueue_scripts', $plugin_public, 'enqueue_styles');
320 392 $this->loader->add_action('wp_enqueue_scripts', $plugin_public, 'enqueue_scripts');
321 393 $this->loader->add_action('wp_enqueue_scripts', $plugin_public, 'enqueue_page_custom_css', 999);
322 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 +
323 399 // Elementor editor CSS injection
324 400 if (class_exists('\Elementor\Plugin')) {
325 401 $this->loader->add_action('elementor/preview/enqueue_styles', $plugin_public, 'enqueue_elementor_editor_css', 999);
326 402 }
@@ -333,29 +409,60 @@
333 409 // Initialize centralized SEO conflict handler (singleton — suppresses
334 410 // third-party SEO plugin descriptions when MetaSync provides its own).
335 411 Metasync_SEO_Conflict_Handler::get_instance();
336 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 +
337 426 // Term-level SEO plugin sync: propagate MetaSync term meta (category/tag
338 427 // archives) into Yoast/Rank Math/AIOSEO term storage on every write.
339 428 $this->loader->add_action('updated_term_meta', $this, 'on_term_meta_updated', 10, 4);
340 429 $this->loader->add_action('added_term_meta', $this, 'on_term_meta_updated', 10, 4);
341 430
342 - // Post-level plugin sync (WP-196): propagate MetaSync post meta into
431 + // Post-level plugin sync: propagate MetaSync post meta into
343 432 // Yoast/Rank Math/AIOSEO post storage on every write.
344 433 $this->loader->add_action('updated_post_meta', $this, 'on_post_meta_updated', 10, 4);
345 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);
346 438
347 439 // SEO Output hooks (Metasync_Seo_Output)
348 440 $this->loader->add_action('wp_head', $seo_output, 'hook_metasync_metatags', 1, 1);
441 + // LocalBusiness / Organization / Person JSON-LD from the Local Business page.
442 + $this->loader->add_action('wp_head', $seo_output, 'output_local_business_schema', 2, 1);
349 443 $this->loader->add_action('template_redirect', $seo_output, 'inject_archive_seo_controls');
350 444
351 - // Hreflang / language alternates output (wp_head @ priority 2).
352 - $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);
353 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);
354 453
355 454 // Edge Cache: detect Cloudways Varnish and persist for settings UI
356 455 $this->loader->add_action('init', 'Metasync_Edge_Cache_Purge', 'detect_cloudways');
357 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 +
358 465 // Sitemap exclusions for disabled archive types
359 466 $this->loader->add_filter('wp_sitemaps_taxonomies', $seo_output, 'filter_sitemap_taxonomies');
360 467 $this->loader->add_filter('wp_sitemaps_users_entry', $seo_output, 'filter_sitemap_users', 10, 2);
361 468 $this->loader->add_filter('wp_sitemaps_add_provider', $seo_output, 'filter_sitemap_providers', 10, 2);
@@ -388,8 +495,20 @@
388 495 $this->loader->add_action('plugin_action_links_' . $get_plugin_basename, $plugin_public, 'metasync_plugin_links');
389 496
390 497 // REST API hooks (Metasync_Rest_Api)
391 498 $this->loader->add_action('rest_api_init', $rest_api, 'metasync_register_rest_routes');
499 + // Coexist with third-party JWT auth plugins: clear their prior auth error
500 + // for metasync/v1 requests when our own API key validates. The Tmeister
501 + // "JWT Authentication for WP-API" plugin surfaces its jwt_auth_invalid_token
502 + // 403 via rest_pre_dispatch (priority 10), so we hook the same filter at a
503 + // later priority (11) to clear it for our namespace only.
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);
392 511 $this->loader->add_action('init', $plugin_public, 'metasync_plugin_init', 5);
393 512 $this->loader->add_action('wp_ajax_metasync_lglogin', $rest_api, 'linkgraph_login');
394 513
395 514 // Robots meta filter (Metasync_Seo_Output)
@@ -430,12 +549,43 @@
430 549 require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-html-to-markdown.php';
431 550 require_once plugin_dir_path(dirname(__FILE__)) . 'llms-txt/class-metasync-llms-txt-generator.php';
432 551 $llms_txt_generator = new Metasync_Llms_Txt_Generator();
433 552
553 + // Serve the IndexNow key file virtually at /{key}.txt so it works
554 + // on read-only web roots and nginx hosts that 403 direct static .txt access.
555 + require_once plugin_dir_path(dirname(__FILE__)) . 'bing-index/class-metasync-bing-instant-index.php';
556 + add_action('template_redirect', array('Metasync_Bing_Instant_Index', 'serve_virtual_key_file'), 0);
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 +
434 572 // One-time upgrade: regenerate sitemap to remove any Beaver Builder template entries
435 573 if ( ! get_option( 'metasync_sitemap_bb_exclusion_applied' ) ) {
436 574 $this->loader->add_action('init', $this, 'maybe_regenerate_sitemap_after_upgrade');
437 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 + }
438 588 }
439 589
440 590 /**
441 591 * One-time upgrade routine: regenerate the XML sitemap so that Beaver Builder
@@ -465,8 +615,40 @@
465 615 // so that enabling sitemaps later will still clean up BB templates.
466 616 }
467 617
468 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 + /**
469 651 * Initialize endpoint URL filtering for staging mode
470 652 * Intercepts HTTP requests and replaces production URLs with staging URLs
471 653 */
472 654 private function init_endpoint_filtering() {
@@ -510,9 +692,9 @@
510 692 $original_url = $url;
511 693 foreach ($url_replacements as $production => $staging) {
512 694 if (strpos($url, $production) === 0) {
513 695 $url = str_replace($production, $staging, $url);
514 - 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
515 697 break;
516 698 }
517 699 }
518 700
@@ -590,16 +772,86 @@
590 772 * @param string $meta_key Meta key being written.
591 773 * @param mixed $meta_value Meta value being written.
592 774 */
593 775 public function on_post_meta_updated($meta_id, $post_id, $meta_key, $meta_value) {
594 - if (!class_exists('Metasync_Plugin_Sync')) {
776 + if (!self::is_metasync_meta_key($meta_key)) {
595 777 return;
596 778 }
597 779
780 + if (!self::sync_layer_handles('on_meta_updated')) {
781 + return;
782 + }
783 +
598 784 Metasync_Plugin_Sync::get_instance()->on_meta_updated($meta_id, $post_id, $meta_key, $meta_value);
599 785 }
600 786
601 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 + /**
602 854 * Save current theme information to MetaSync options
603 855 * This runs in WordPress admin context, not during REST API requests
604 856 * Safe to use wp_get_theme() here
605 857 */
@@ -666,13 +918,440 @@
666 918 ]
667 919 );
668 920 }
669 921 }
670 -
922 +
671 923 return $result;
672 924 }
673 925
674 926 /**
927 + * Derive the 32-byte AES key from existing WordPress salts.
928 + *
929 + * No new secret is stored anywhere — the key material is the concatenation
930 + * of three WordPress salts, hashed to a fixed 32 bytes. If the salts change
931 + * (e.g. wp-config regenerated) the derived key changes and previously
932 + * encrypted values can no longer be decrypted, which is handled gracefully
933 + * by the callers (re-authenticate state) rather than fataling.
934 + *
935 + * @return string 32 raw bytes.
936 + */
937 + private static function api_key_crypto_key()
938 + {
939 + $material = wp_salt('secure_auth') . wp_salt('logged_in') . wp_salt('nonce');
940 + return hash('sha256', $material, true);
941 + }
942 +
943 + /**
944 + * Determine whether a stored value is in the encrypted-at-rest format.
945 + *
946 + * @param mixed $value
947 + * @return bool
948 + */
949 + public static function is_encrypted_api_key($value)
950 + {
951 + return is_string($value) && strncmp($value, self::API_KEY_ENC_PREFIX, strlen(self::API_KEY_ENC_PREFIX)) === 0;
952 + }
953 +
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 + /**
999 + * Encrypt a plaintext Search Atlas API key for storage at rest.
1000 + *
1001 + * Uses AES-256-GCM (authenticated) with a random 12-byte IV. The IV, the
1002 + * 16-byte GCM tag and the ciphertext are concatenated and base64-encoded
1003 + * behind an `enc_v1:` prefix. An empty string is stored as-is (no key set).
1004 + *
1005 + * When OpenSSL is unavailable or encryption fails the plaintext is stored
1006 + * unchanged (availability over hard-fail) and the degradation is logged so
1007 + * it cannot go unnoticed.
1008 + *
1009 + * @param string $plaintext
1010 + * @return string Encrypted blob, or '' when $plaintext is empty.
1011 + */
1012 + public static function encrypt_api_key($plaintext)
1013 + {
1014 + $plaintext = (string) $plaintext;
1015 + if ($plaintext === '') {
1016 + return '';
1017 + }
1018 +
1019 + // Already encrypted — do not double-encrypt.
1020 + if (self::is_encrypted_api_key($plaintext)) {
1021 + return $plaintext;
1022 + }
1023 +
1024 + if (!function_exists('openssl_encrypt')) {
1025 + // OpenSSL unavailable — store plaintext rather than lose the key.
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
1027 + return $plaintext;
1028 + }
1029 +
1030 + $key = self::api_key_crypto_key();
1031 + $iv = random_bytes(12);
1032 + $tag = '';
1033 + $ciphertext = openssl_encrypt($plaintext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag, '', 16);
1034 +
1035 + if ($ciphertext === false) {
1036 + // Encryption failed — fall back to plaintext storage, but say so.
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
1038 + return $plaintext;
1039 + }
1040 +
1041 + return self::API_KEY_ENC_PREFIX . base64_encode($iv . $tag . $ciphertext);
1042 + }
1043 +
1044 + /**
1045 + * Decrypt a stored Search Atlas API key value.
1046 + *
1047 + * Accepts either the encrypted `enc_v1:` format or a legacy plaintext value
1048 + * (returned unchanged, supporting installs that pre-date encryption). On any
1049 + * decryption failure (salt change / corruption) returns false so callers can
1050 + * surface a re-authenticate state instead of using a bad key.
1051 + *
1052 + * @param mixed $value
1053 + * @return string|false Plaintext, or false when an encrypted value cannot be decrypted.
1054 + */
1055 + public static function decrypt_api_key($value)
1056 + {
1057 + if (!is_string($value) || $value === '') {
1058 + return '';
1059 + }
1060 +
1061 + if (!self::is_encrypted_api_key($value)) {
1062 + // Legacy plaintext key.
1063 + return $value;
1064 + }
1065 +
1066 + if (!function_exists('openssl_decrypt')) {
1067 + return false;
1068 + }
1069 +
1070 + $raw = base64_decode(substr($value, strlen(self::API_KEY_ENC_PREFIX)), true);
1071 + if ($raw === false || strlen($raw) < 12 + 16 + 1) {
1072 + return false;
1073 + }
1074 +
1075 + $iv = substr($raw, 0, 12);
1076 + $tag = substr($raw, 12, 16);
1077 + $ciphertext = substr($raw, 28);
1078 +
1079 + $key = self::api_key_crypto_key();
1080 + $plaintext = openssl_decrypt($ciphertext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag);
1081 +
1082 + if ($plaintext === false) {
1083 + return false;
1084 + }
1085 +
1086 + return $plaintext;
1087 + }
1088 +
1089 + /**
1090 + * Get the decrypted Search Atlas API key, memoized for the request.
1091 + *
1092 + * Decrypts at most once per request and never persists the decrypted value
1093 + * anywhere. When a legacy plaintext key is found it is migrated to the
1094 + * encrypted format in place (one-time migration on load). Returns:
1095 + * - the plaintext key (string, possibly '')
1096 + * - false when an encrypted value exists but cannot be decrypted
1097 + * (salts changed / corrupt) — callers should treat this as a
1098 + * "please re-authenticate" state.
1099 + *
1100 + * @return string|false
1101 + */
1102 + public static function get_searchatlas_api_key()
1103 + {
1104 + if (self::$memo_api_key !== null) {
1105 + return self::$memo_api_key;
1106 + }
1107 +
1108 + $general = self::get_option('general');
1109 + $stored = is_array($general) ? ($general['searchatlas_api_key'] ?? '') : '';
1110 +
1111 + // One-time migration: a non-empty legacy plaintext value is encrypted
1112 + // in place the first time it is read after this feature ships.
1113 + if ($stored !== '' && is_string($stored) && !self::is_encrypted_api_key($stored)) {
1114 + $encrypted = self::encrypt_api_key($stored);
1115 + if (self::is_encrypted_api_key($encrypted)) {
1116 + $options = self::get_option();
1117 + if (!is_array($options)) {
1118 + $options = [];
1119 + }
1120 + $options['general']['searchatlas_api_key'] = $encrypted;
1121 + self::set_option($options);
1122 + }
1123 + }
1124 +
1125 + self::$memo_api_key = self::decrypt_api_key($stored);
1126 + return self::$memo_api_key;
1127 + }
1128 +
1129 + /**
1130 + * Clear the request-scoped decrypted-key memo.
1131 + *
1132 + * Call after any write that changes the stored searchatlas_api_key so a
1133 + * subsequent read in the same request reflects the new value.
1134 + */
1135 + public static function invalidate_api_key_cache()
1136 + {
1137 + self::$memo_api_key = null;
1138 + }
1139 +
1140 + /**
1141 + * Read the heartbeat throttle state from its dedicated option.
1142 + *
1143 + * Backfills from the legacy location (`metasync_options['general']`) the
1144 + * first time the dedicated option is empty, so existing installs keep
1145 + * their throttle history across the migration.
1146 + */
1147 + public static function get_heartbeat_throttle(): array
1148 + {
1149 + $value = get_option(self::heartbeat_throttle_option, []);
1150 + if (is_array($value) && !empty($value)) {
1151 + return $value;
1152 + }
1153 +
1154 + $general = self::get_option('general');
1155 + if (is_array($general) && (array_key_exists('last_heart_beat', $general) || array_key_exists('last_heartbeat_at', $general))) {
1156 + $throttle = [
1157 + 'last_heart_beat' => $general['last_heart_beat'] ?? 0,
1158 + 'last_heartbeat_at' => $general['last_heartbeat_at'] ?? null,
1159 + ];
1160 + update_option(self::heartbeat_throttle_option, $throttle);
1161 + return $throttle;
1162 + }
1163 +
1164 + return [];
1165 + }
1166 +
1167 + /**
1168 + * Merge fields into the dedicated heartbeat throttle option.
1169 + *
1170 + * Writes via update_option directly so the main metasync_options blob is
1171 + * never read or rewritten — avoiding the read-modify-write race with
1172 + * concurrent settings saves.
1173 + */
1174 + public static function set_heartbeat_throttle(array $fields): void
1175 + {
1176 + $existing = get_option(self::heartbeat_throttle_option, []);
1177 + if (!is_array($existing)) {
1178 + $existing = [];
1179 + }
1180 + $merged = array_merge($existing, $fields);
1181 + update_option(self::heartbeat_throttle_option, $merged);
1182 + }
1183 +
1184 + /**
1185 + * Storage prefix marking a secret value as encrypted at rest.
1186 + */
1187 + private const SECRET_ENC_PREFIX = 'enc_v1:';
1188 +
1189 + /**
1190 + * Derive the 32-byte AES key from existing WordPress salts.
1191 + *
1192 + * No new secret is stored anywhere — the key material is the concatenation
1193 + * of three WordPress salts, hashed to a fixed 32 bytes. If the salts change
1194 + * (e.g. wp-config regenerated) the derived key changes and previously
1195 + * encrypted values can no longer be decrypted, which callers handle
1196 + * gracefully rather than fataling.
1197 + *
1198 + * @return string 32 raw bytes.
1199 + */
1200 + private static function secret_crypto_key()
1201 + {
1202 + $material = wp_salt('secure_auth') . wp_salt('logged_in') . wp_salt('nonce');
1203 + return hash('sha256', $material, true);
1204 + }
1205 +
1206 + /**
1207 + * Determine whether a stored value is in the encrypted-at-rest format.
1208 + *
1209 + * @param mixed $value
1210 + * @return bool
1211 + */
1212 + public static function is_encrypted_secret($value)
1213 + {
1214 + return is_string($value) && strncmp($value, self::SECRET_ENC_PREFIX, strlen(self::SECRET_ENC_PREFIX)) === 0;
1215 + }
1216 +
1217 + /**
1218 + * Encrypt a plaintext secret (e.g. the whitelabel settings password) for
1219 + * storage at rest.
1220 + *
1221 + * Uses AES-256-GCM (authenticated) with a random 12-byte IV. The IV, the
1222 + * 16-byte GCM tag and the ciphertext are concatenated and base64-encoded
1223 + * behind an `enc_v1:` prefix. An empty string is stored as-is (no secret).
1224 + *
1225 + * When OpenSSL is unavailable or encryption fails the plaintext is stored
1226 + * unchanged (availability over hard-fail) and the degradation is logged so
1227 + * it cannot go unnoticed.
1228 + *
1229 + * @param string $plaintext
1230 + * @return string Encrypted blob, or '' when $plaintext is empty.
1231 + */
1232 + public static function encrypt_secret($plaintext)
1233 + {
1234 + $plaintext = (string) $plaintext;
1235 + if ($plaintext === '') {
1236 + return '';
1237 + }
1238 +
1239 + // Already encrypted — do not double-encrypt.
1240 + if (self::is_encrypted_secret($plaintext)) {
1241 + return $plaintext;
1242 + }
1243 +
1244 + if (!function_exists('openssl_encrypt')) {
1245 + // OpenSSL unavailable — store plaintext rather than lose the secret.
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
1247 + return $plaintext;
1248 + }
1249 +
1250 + $key = self::secret_crypto_key();
1251 + $iv = random_bytes(12);
1252 + $tag = '';
1253 + $ciphertext = openssl_encrypt($plaintext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag, '', 16);
1254 +
1255 + if ($ciphertext === false) {
1256 + // Encryption failed — fall back to plaintext storage, but say so.
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
1258 + return $plaintext;
1259 + }
1260 +
1261 + return self::SECRET_ENC_PREFIX . base64_encode($iv . $tag . $ciphertext);
1262 + }
1263 +
1264 + /**
1265 + * Decrypt a stored secret value.
1266 + *
1267 + * Accepts either the encrypted `enc_v1:` format or a legacy plaintext value
1268 + * (returned unchanged, supporting installs that pre-date encryption). On any
1269 + * decryption failure (salt change / corruption) returns false so callers can
1270 + * degrade gracefully instead of using a bad value.
1271 + *
1272 + * @param mixed $value
1273 + * @return string|false Plaintext, or false when an encrypted value cannot be decrypted.
1274 + */
1275 + public static function decrypt_secret($value)
1276 + {
1277 + if (!is_string($value) || $value === '') {
1278 + return '';
1279 + }
1280 +
1281 + if (!self::is_encrypted_secret($value)) {
1282 + // Legacy plaintext value.
1283 + return $value;
1284 + }
1285 +
1286 + if (!function_exists('openssl_decrypt')) {
1287 + return false;
1288 + }
1289 +
1290 + $raw = base64_decode(substr($value, strlen(self::SECRET_ENC_PREFIX)), true);
1291 + if ($raw === false || strlen($raw) < 12 + 16 + 1) {
1292 + return false;
1293 + }
1294 +
1295 + $iv = substr($raw, 0, 12);
1296 + $tag = substr($raw, 12, 16);
1297 + $ciphertext = substr($raw, 28);
1298 +
1299 + $key = self::secret_crypto_key();
1300 + $plaintext = openssl_decrypt($ciphertext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag);
1301 +
1302 + if ($plaintext === false) {
1303 + return false;
1304 + }
1305 +
1306 + return $plaintext;
1307 + }
1308 +
1309 + /**
1310 + * Get the decrypted whitelabel settings password.
1311 + *
1312 + * Reads the stored (encrypted) value and returns the plaintext for
1313 + * verification or authorized display. A legacy plaintext value found in
1314 + * storage is migrated to the encrypted format in place (one-time migration
1315 + * on read). Returns '' when no password is set or when an encrypted value
1316 + * can no longer be decrypted (salts changed / corrupt) — in that case the
1317 + * stored value still counts as "password set" for protection checks, but
1318 + * the user password cannot authenticate until it is reset.
1319 + *
1320 + * @return string
1321 + */
1322 + public static function get_whitelabel_password()
1323 + {
1324 + $whitelabel = self::get_whitelabel_settings();
1325 + $stored = $whitelabel['settings_password'] ?? '';
1326 +
1327 + if (!is_string($stored) || $stored === '') {
1328 + return '';
1329 + }
1330 +
1331 + // One-time migration: encrypt a legacy plaintext value in place.
1332 + // This is a whole-blob read-modify-write of metasync_options during a
1333 + // read request; a concurrent settings save could theoretically clobber
1334 + // it, but it fires at most once per legacy install so the window is
1335 + // accepted rather than adding a dedicated option.
1336 + if (!self::is_encrypted_secret($stored)) {
1337 + $encrypted = self::encrypt_secret($stored);
1338 + if (self::is_encrypted_secret($encrypted)) {
1339 + $options = self::get_option();
1340 + if (!is_array($options)) {
1341 + $options = [];
1342 + }
1343 + $options['whitelabel']['settings_password'] = $encrypted;
1344 + self::set_option($options);
1345 + }
1346 + return $stored;
1347 + }
1348 +
1349 + $plaintext = self::decrypt_secret($stored);
1350 + return $plaintext === false ? '' : $plaintext;
1351 + }
1352 +
1353 + /**
675 1354 * Get whitelabel settings
676 1355 * Helper method to retrieve whitelabel configuration
677 1356 */
678 1357 public static function get_whitelabel_settings()
@@ -891,10 +1570,70 @@
891 1570
892 1571 // Priority 3: Return base_name as fallback
893 1572 return $base_name;
894 1573 }
895 -
1574 +
896 1575 /**
1576 + * Render a standalone info-icon tooltip (the same visual/JS pattern used by
1577 + * get_field_tooltips() + render_accordion_sections() in Metasync_Settings_Fields).
1578 + * Use this on any admin page whose fields are NOT rendered through that
1579 + * accordion field-loop (custom render_callback pages, standalone view files) —
1580 + * the trigger/hover/positioning JS in admin/js/metasync-admin.js binds to
1581 + * `.metasync-tooltip-trigger` globally, so no extra wiring is needed as long
1582 + * as this markup is present on a page where metasync-admin.js is enqueued
1583 + * (i.e. any admin page under this plugin's menu).
1584 + *
1585 + * @param string $tooltip_id Unique id for this tooltip (unique per page).
1586 + * @param string $text Plain-English help text (escaped internally).
1587 + */
1588 + public static function render_tooltip_icon($tooltip_id, $text)
1589 + {
1590 + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- get_tooltip_icon_html() escapes every interpolated value internally (esc_attr / esc_html)
1591 + echo self::get_tooltip_icon_html($tooltip_id, $text);
1592 + }
1593 +
1594 + /**
1595 + * Same tooltip markup as render_tooltip_icon(), but RETURNS the HTML string
1596 + * instead of echoing it. Use this when the tooltip needs to be concatenated
1597 + * into another string — e.g. appended to the $title argument of
1598 + * add_settings_field(), which WordPress core echoes raw next to the label.
1599 + *
1600 + * The whole trigger+popup pair is wrapped in its own small
1601 + * `position: relative` anchor span. The popup CSS (.metasync-tooltip) is
1602 + * `position: absolute; left: 100%` and positions itself relative to the
1603 + * nearest positioned ancestor — on the main settings accordion that's the
1604 + * `.metasync-field-label-wrapper` div, but standalone pages (custom
1605 + * render_callback templates, add_settings_field titles on plain
1606 + * do_settings_sections() pages, etc.) usually have no such ancestor, so
1607 + * the popup would escape to whatever distant positioned element exists
1608 + * on the page (rendering in the wrong corner of the screen). Wrapping
1609 + * here makes every tooltip self-contained regardless of where it's placed.
1610 + *
1611 + * @param string $tooltip_id Unique id for this tooltip (unique per page).
1612 + * @param string $text Plain-English help text (escaped internally).
1613 + * @return string HTML markup for the info-icon trigger + tooltip content.
1614 + */
1615 + public static function get_tooltip_icon_html($tooltip_id, $text)
1616 + {
1617 + $html = '<span class="metasync-tooltip-anchor" style="position:relative;display:inline-block;vertical-align:middle;margin-left:8px;">';
1618 + $html .= '<button type="button" class="metasync-tooltip-trigger" data-tooltip-id="' . esc_attr($tooltip_id) . '" aria-label="More information">';
1619 + $html .= '<svg class="metasync-info-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">';
1620 + $html .= '<circle cx="12" cy="12" r="10"></circle>';
1621 + $html .= '<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"></path>';
1622 + $html .= '<line x1="12" y1="17" x2="12.01" y2="17"></line>';
1623 + $html .= '</svg>';
1624 + $html .= '</button>';
1625 +
1626 + $html .= '<div class="metasync-tooltip" id="tooltip-' . esc_attr($tooltip_id) . '" role="tooltip">';
1627 + $html .= '<div class="metasync-tooltip-arrow"></div>';
1628 + $html .= '<div class="metasync-tooltip-content">' . esc_html($text) . '</div>';
1629 + $html .= '</div>';
1630 + $html .= '</span>';
1631 +
1632 + return $html;
1633 + }
1634 +
1635 + /**
897 1636 * Centralized API Key Event Logging
898 1637 * Provides structured logging for all API key related events with consistent formatting
899 1638 *
900 1639 * @since 1.0.0
@@ -942,9 +1681,9 @@
942 1681 // This could be extended to store in a dedicated log table
943 1682
944 1683 } catch (Exception $e) {
945 1684 // Fallback logging if structured logging fails
946 - 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
947 1686 }
948 1687 }
949 1688
950 1689 /**
@@ -961,10 +1700,25 @@
961 1700 if (!is_404()) {
962 1701 return;
963 1702 }
964 1703
1704 + // PROTECTION 0: Skip WordPress system paths — these are not "broken links"
1705 + $request_uri = $_SERVER['REQUEST_URI'] ?? '';
1706 + $skip_prefixes = [
1707 + '/wp-json/',
1708 + '/wp-admin/',
1709 + '/feed/',
1710 + '/xmlrpc.php',
1711 + '/wp-login.php',
1712 + '/wp-cron.php',
1713 + ];
1714 + foreach ($skip_prefixes as $prefix) {
1715 + if (stripos($request_uri, $prefix) === 0) {
1716 + return;
1717 + }
1718 + }
1719 +
965 1720 // PROTECTION 1: Exclude static assets to reduce noise
966 - $request_uri = $_SERVER['REQUEST_URI'] ?? '';
967 1721 $static_extensions = ['.css', '.js', '.jpg', '.jpeg', '.png', '.gif', '.ico', '.svg', '.woff', '.woff2', '.ttf', '.eot', '.map','.webp'];
968 1722 foreach ($static_extensions as $ext) {
969 1723 if (stripos($request_uri, $ext) !== false) {
970 1724 return; // Skip logging static asset 404s
@@ -993,9 +1747,9 @@
993 1747 $global_hits = get_transient($global_rate_key);
994 1748 if ($global_hits !== false && $global_hits >= 50) {
995 1749 // More than 50 404s per minute - stop logging to protect database
996 1750 if ($global_hits === 50) {
997 - 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
998 1752 }
999 1753 set_transient($global_rate_key, $global_hits + 1, 60);
1000 1754 return;
1001 1755 }
@@ -1016,12 +1770,13 @@
1016 1770 return; // Skip potentially malicious or malformed URLs
1017 1771 }
1018 1772
1019 1773 // Initialize 404 monitor database
1774 + require_once plugin_dir_path(dirname(__FILE__)) . '404-monitor/class-metasync-404-monitor-database.php';
1020 1775 $db_404 = new Metasync_Error_Monitor_Database();
1021 1776
1022 1777 // Get user agent (sanitized)
1023 - $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'])) : '';
1024 1779
1025 1780 // Log the 404 error
1026 1781 $result = $db_404->update([
1027 1782 'uri' => $current_url,
@@ -1047,9 +1802,9 @@
1047 1802 $host = $_SERVER['SERVER_NAME'];
1048 1803 }
1049 1804 if (empty($host)) {
1050 1805 // Fallback to WordPress site URL if available
1051 - $host = parse_url(home_url(), PHP_URL_HOST);
1806 + $host = wp_parse_url(home_url(), PHP_URL_HOST);
1052 1807 }
1053 1808
1054 1809 // Safely get REQUEST_URI with fallback
1055 1810 $uri = isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '/';