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 | includes/class-metasync.php +183 -21 2.6.20 → 2.7.1 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,41 @@
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 +
279 301 // SEO Health CSV export: must run on admin_init (before output).
280 302 // Cheap $_GET check avoids loading the class on every admin page.
281 303 if (
282 - isset($_GET['page'], $_GET['export'], $_GET['_wpnonce']) &&
283 - $_GET['export'] === 'csv' &&
284 - strpos($_GET['page'], '-seo-health') !== false
304 + isset($_GET['page'], $_GET['export'], $_GET['_wpnonce']) && // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- routing check, the handler verifies nonce and capability
305 + $_GET['export'] === 'csv' && // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- routing check, the handler verifies nonce and capability
306 + strpos($_GET['page'], '-seo-health') !== false // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- routing check, the handler verifies nonce and capability
285 307 ) {
286 308 $this->loader->add_action('admin_init', Metasync_SEO_Health::get_instance(), 'handle_csv_export', 1);
287 309 }
288 310 $this->loader->add_action('wp', $post_meta_setting, 'show_top_admin_bar', 9);
289 311
312 + // SEO meta columns on the posts/pages list tables (WP-624).
313 + // Registered for both the `posts` and `pages` variants of each hook so the
314 + // columns reach every supported post type; the callbacks bail on unsupported
315 + // ones. Hidden by default — users opt in from Screen Options.
316 + $seo_columns = Metasync_SEO_Columns::get_instance();
317 + $this->loader->add_filter('manage_posts_columns', $seo_columns, 'add_columns', 10, 2);
318 + $this->loader->add_filter('manage_pages_columns', $seo_columns, 'add_columns', 10, 1);
319 + $this->loader->add_action('manage_posts_custom_column', $seo_columns, 'render_column', 10, 2);
320 + $this->loader->add_action('manage_pages_custom_column', $seo_columns, 'render_column', 10, 2);
321 + $this->loader->add_filter('default_hidden_columns', $seo_columns, 'default_hidden_columns', 10, 2);
322 + $this->loader->add_filter('hidden_columns', $seo_columns, 'hidden_columns', 10, 3);
323 + $this->loader->add_action('admin_head', $seo_columns, 'print_styles');
324 + $this->loader->add_action('admin_notices', $seo_columns, 'companion_plugin_notice');
325 +
290 326 // Initialize XML Sitemap auto-update hooks if enabled
291 327 // Note: Must not be gated by is_admin() because Gutenberg saves posts
292 328 // via the REST API where is_admin() returns false, and REST_REQUEST
293 329 // is not yet defined at plugin load time
@@ -341,9 +377,12 @@
341 377
342 378 $plugin_public = new Metasync_Public($this->get_plugin_name(), $this->get_version());
343 379 $rest_api = $plugin_public->get_rest_api();
344 380 $seo_output = $plugin_public->get_seo_output();
345 - $get_plugin_basename = sprintf('%1$s/%1$s.php', $this->plugin_name);
381 + // Derive the basename from the actual file location: the plugin
382 + // folder is not guaranteed to be named after the plugin (renamed
383 + // installs), and a hardcoded name makes the settings links vanish.
384 + $get_plugin_basename = plugin_basename(dirname(__DIR__) . '/metasync.php');
346 385
347 386 // Asset enqueue hooks (Metasync_Public)
348 387 $this->loader->add_action('wp_enqueue_scripts', $plugin_public, 'enqueue_styles');
349 388 $this->loader->add_action('wp_enqueue_scripts', $plugin_public, 'enqueue_scripts');
@@ -348,8 +387,12 @@
348 387 $this->loader->add_action('wp_enqueue_scripts', $plugin_public, 'enqueue_styles');
349 388 $this->loader->add_action('wp_enqueue_scripts', $plugin_public, 'enqueue_scripts');
350 389 $this->loader->add_action('wp_enqueue_scripts', $plugin_public, 'enqueue_page_custom_css', 999);
351 390
391 + // Registered here rather than from inside enqueue_scripts(), which would
392 + // re-register the same filter on every front-end request.
393 + $this->loader->add_filter('script_loader_tag', $plugin_public, 'add_defer_attribute', 10, 2);
394 +
352 395 // Elementor editor CSS injection
353 396 if (class_exists('\Elementor\Plugin')) {
354 397 $this->loader->add_action('elementor/preview/enqueue_styles', $plugin_public, 'enqueue_elementor_editor_css', 999);
355 398 }
@@ -362,8 +405,21 @@
362 405 // Initialize centralized SEO conflict handler (singleton — suppresses
363 406 // third-party SEO plugin descriptions when MetaSync provides its own).
364 407 Metasync_SEO_Conflict_Handler::get_instance();
365 408
409 + // Headless delivery: register the GraphQL SEO field. This only adds a
410 + // callback on `graphql_register_types`, which never fires unless WPGraphQL
411 + // is active, and the callback returns immediately unless Headless Mode is
412 + // on — so with the mode off nothing about this request changes.
413 + Metasync_Headless_Graphql::init();
414 +
415 + // Headless stale-while-revalidate OTTO refresh: background safety net for
416 + // missed/delayed OTTO deployment webhooks. Every branch is gated on
417 + // Metasync_Headless_Config::is_active(), so with headless mode off this
418 + // schedules no cron event and registers no route beyond an inert REST
419 + // callback that itself checks the flag before doing anything.
420 + Metasync_Headless_Refresh_Job::init();
421 +
366 422 // Term-level SEO plugin sync: propagate MetaSync term meta (category/tag
367 423 // archives) into Yoast/Rank Math/AIOSEO term storage on every write.
368 424 $this->loader->add_action('updated_term_meta', $this, 'on_term_meta_updated', 10, 4);
369 425 $this->loader->add_action('added_term_meta', $this, 'on_term_meta_updated', 10, 4);
@@ -371,8 +427,11 @@
371 427 // Post-level plugin sync: propagate MetaSync post meta into
372 428 // Yoast/Rank Math/AIOSEO post storage on every write.
373 429 $this->loader->add_action('updated_post_meta', $this, 'on_post_meta_updated', 10, 4);
374 430 $this->loader->add_action('added_post_meta', $this, 'on_post_meta_updated', 10, 4);
431 + // Deletes matter too: clearing the last checkbox in a meta box removes
432 + // the meta row, which fires neither hook above.
433 + $this->loader->add_action('deleted_post_meta', $this, 'on_post_meta_deleted', 10, 3);
375 434
376 435 // SEO Output hooks (Metasync_Seo_Output)
377 436 $this->loader->add_action('wp_head', $seo_output, 'hook_metasync_metatags', 1, 1);
378 437 // LocalBusiness / Organization / Person JSON-LD from the Local Business page.
@@ -378,15 +437,28 @@
378 437 // LocalBusiness / Organization / Person JSON-LD from the Local Business page.
379 438 $this->loader->add_action('wp_head', $seo_output, 'output_local_business_schema', 2, 1);
380 439 $this->loader->add_action('template_redirect', $seo_output, 'inject_archive_seo_controls');
381 440
382 - // Hreflang / language alternates output (wp_head @ priority 2).
383 - $plugin_hreflang = new Metasync_Hreflang_Output();
441 + // Hreflang / language alternates output (wp_head @ priority 2). The
442 + // emitter shares the SEO output instance for the noindex check.
443 + $plugin_hreflang = new Metasync_Hreflang_Output($seo_output);
384 444 $this->loader->add_action('wp_head', $plugin_hreflang, 'output_hreflang_tags', 2);
445 + // WPML prints its own hreflang set at wp_head priority 1, ahead of
446 + // MetaSync's output at priority 2 — the suppression filter has to be
447 + // in place before wp_head starts, hence template_redirect.
448 + $this->loader->add_action('template_redirect', $plugin_hreflang, 'register_wpml_suppression', 1);
385 449
386 450 // Edge Cache: detect Cloudways Varnish and persist for settings UI
387 451 $this->loader->add_action('init', 'Metasync_Edge_Cache_Purge', 'detect_cloudways');
388 452
453 + // BookingPress starts a PHP session on every front-end request (init
454 + // at priority 1), which makes hosts skip page caching site-wide. Its
455 + // booking flows manage their own sessions inside their AJAX handlers,
456 + // so unhook the global start. Priority 0 keeps this ahead of theirs.
457 + // Registered with add_action directly (like Metasync_Oxygen_Compat)
458 + // because the loader's $component parameter is typed to objects.
459 + add_action('init', ['Metasync_BookingPress_Compat', 'neutralize_bookingpress_session'], 0);
460 +
389 461 // Sitemap exclusions for disabled archive types
390 462 $this->loader->add_filter('wp_sitemaps_taxonomies', $seo_output, 'filter_sitemap_taxonomies');
391 463 $this->loader->add_filter('wp_sitemaps_users_entry', $seo_output, 'filter_sitemap_users', 10, 2);
392 464 $this->loader->add_filter('wp_sitemaps_add_provider', $seo_output, 'filter_sitemap_providers', 10, 2);
@@ -425,8 +497,14 @@
425 497 // "JWT Authentication for WP-API" plugin surfaces its jwt_auth_invalid_token
426 498 // 403 via rest_pre_dispatch (priority 10), so we hook the same filter at a
427 499 // later priority (11) to clear it for our namespace only.
428 500 $this->loader->add_filter('rest_pre_dispatch', $rest_api, 'allow_metasync_rest_auth', 11, 3);
501 + // Coexist with site-wide "authenticated users only" REST restrictions.
502 + // Those hook rest_authentication_errors, which WP applies before dispatch,
503 + // so rest_pre_dispatch and permission_callback never run. Hook it late
504 + // (99) to clear the error for metasync/v1 requests that present a valid
505 + // plugin API key; every other route keeps the site's restriction.
506 + $this->loader->add_filter('rest_authentication_errors', $rest_api, 'allow_metasync_rest_authentication', 99, 1);
429 507 $this->loader->add_action('init', $plugin_public, 'metasync_plugin_init', 5);
430 508 $this->loader->add_action('wp_ajax_metasync_lglogin', $rest_api, 'linkgraph_login');
431 509
432 510 // Robots meta filter (Metasync_Seo_Output)
@@ -472,8 +550,22 @@
472 550 // on read-only web roots and nginx hosts that 403 direct static .txt access.
473 551 require_once plugin_dir_path(dirname(__FILE__)) . 'bing-index/class-metasync-bing-instant-index.php';
474 552 add_action('template_redirect', array('Metasync_Bing_Instant_Index', 'serve_virtual_key_file'), 0);
475 553
554 + // Scheduled IndexNow submissions, deferred from save_post so a slow
555 + // IndexNow round-trip cannot stall the editor or REST write. Registered
556 + // statically because the class is loaded but never instantiated on
557 + // cron loads. The hook name matches the uninstall cron sweep exactly.
558 + add_action('metasync_bing_indexnow_submit_event', array('Metasync_Bing_Instant_Index', 'run_scheduled_submit'), 10, 1);
559 +
560 + // Construct the robots.txt manager on the public load path so its
561 + // `robots_txt` filter is registered before a plain GET /robots.txt is
562 + // served. The filter is attached in the singleton's constructor, and
563 + // previously only admin screens built the instance — so the virtual
564 + // robots.txt rules and sitemap lines never rendered on the frontend.
565 + require_once plugin_dir_path(dirname(__FILE__)) . 'robots-txt/class-metasync-robots-txt.php';
566 + Metasync_Robots_Txt::get_instance();
567 +
476 568 // One-time upgrade: regenerate sitemap to remove any Beaver Builder template entries
477 569 if ( ! get_option( 'metasync_sitemap_bb_exclusion_applied' ) ) {
478 570 $this->loader->add_action('init', $this, 'maybe_regenerate_sitemap_after_upgrade');
479 571 }
@@ -552,9 +644,9 @@
552 644 $original_url = $url;
553 645 foreach ($url_replacements as $production => $staging) {
554 646 if (strpos($url, $production) === 0) {
555 647 $url = str_replace($production, $staging, $url);
556 - error_log("MetaSync Endpoint Filter: Replaced {$production} with {$staging} in URL: {$original_url}");
648 + 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 649 break;
558 650 }
559 651 }
560 652
@@ -632,16 +724,86 @@
632 724 * @param string $meta_key Meta key being written.
633 725 * @param mixed $meta_value Meta value being written.
634 726 */
635 727 public function on_post_meta_updated($meta_id, $post_id, $meta_key, $meta_value) {
636 - if (!class_exists('Metasync_Plugin_Sync')) {
728 + if (!self::is_metasync_meta_key($meta_key)) {
637 729 return;
638 730 }
639 731
732 + if (!self::sync_layer_handles('on_meta_updated')) {
733 + return;
734 + }
735 +
640 736 Metasync_Plugin_Sync::get_instance()->on_meta_updated($meta_id, $post_id, $meta_key, $meta_value);
641 737 }
642 738
643 739 /**
740 + * Bridge deleted_post_meta to the sync layer.
741 + *
742 + * Clearing the last checkbox in a meta box deletes its meta row rather than
743 + * updating it, so the update hooks above never fire and mirrored values can
744 + * go stale. Only the legacy robots keys are acted on; see
745 + * Metasync_Plugin_Sync::on_meta_deleted().
746 + *
747 + * @param array $meta_ids Meta row IDs (unused).
748 + * @param int $post_id Post ID.
749 + * @param string $meta_key Meta key being deleted.
750 + */
751 + public function on_post_meta_deleted($meta_ids, $post_id, $meta_key) {
752 + if (!self::is_metasync_meta_key($meta_key)) {
753 + return;
754 + }
755 +
756 + if (!self::sync_layer_handles('on_meta_deleted')) {
757 + return;
758 + }
759 +
760 + Metasync_Plugin_Sync::get_instance()->on_meta_deleted($meta_ids, $post_id, $meta_key);
761 + }
762 +
763 + /**
764 + * Is this meta key one the post sync layer could possibly care about?
765 + *
766 + * Cheap string gate, no autoload, no singleton. Every watched post key is
767 + * either `_metasync_*` (sidebar, OTTO and the mirrored robots JSON) or
768 + * `metasync_*` (the legacy meta box keys), so this keeps third-party meta
769 + * writes out of the sync layer entirely — `deleted_post_meta` and
770 + * `updated_post_meta` fire for every key on the site, including on front-end
771 + * requests, and crossing into the sync layer for keys it will only discard
772 + * costs an autoload plus a singleton on the hot path.
773 + *
774 + * Deliberately broader than the sync layer's own key maps: those stay the
775 + * single source of exact truth, so a new MetaSync key needs no change here.
776 + *
777 + * @param string $meta_key Meta key being written or deleted.
778 + * @return bool
779 + */
780 + private static function is_metasync_meta_key($meta_key) {
781 + return strncmp($meta_key, '_metasync_', 10) === 0
782 + || strncmp($meta_key, 'metasync_', 9) === 0;
783 + }
784 +
785 + /**
786 + * Can the post sync layer actually handle this hook right now?
787 + *
788 + * A partially updated install can leave a newer class-metasync.php beside an
789 + * older class-metasync-plugin-sync.php — stale opcache bytecode for one file
790 + * is enough. Calling a method the loaded class does not define is a fatal,
791 + * and because these bridges run on `wp_head` via third-party meta writes it
792 + * takes the front end down rather than degrading. Check before dispatching.
793 + *
794 + * Checked on the class, not an instance, so a mismatch skips the singleton.
795 + *
796 + * @param string $method Sync-layer method about to be called.
797 + * @return bool
798 + */
799 + private static function sync_layer_handles($method) {
800 + return class_exists('Metasync_Plugin_Sync')
801 + && method_exists('Metasync_Plugin_Sync', 'get_instance')
802 + && method_exists('Metasync_Plugin_Sync', $method);
803 + }
804 +
805 + /**
644 806 * Save current theme information to MetaSync options
645 807 * This runs in WordPress admin context, not during REST API requests
646 808 * Safe to use wp_get_theme() here
647 809 */
@@ -768,9 +930,9 @@
768 930 }
769 931
770 932 if (!function_exists('openssl_encrypt')) {
771 933 // 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.');
934 + 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 935 return $plaintext;
774 936 }
775 937
776 938 $key = self::api_key_crypto_key();
@@ -779,9 +941,9 @@
779 941 $ciphertext = openssl_encrypt($plaintext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag, '', 16);
780 942
781 943 if ($ciphertext === false) {
782 944 // 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.');
945 + 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 946 return $plaintext;
785 947 }
786 948
787 949 return self::API_KEY_ENC_PREFIX . base64_encode($iv . $tag . $ciphertext);
@@ -988,9 +1150,9 @@
988 1150 }
989 1151
990 1152 if (!function_exists('openssl_encrypt')) {
991 1153 // OpenSSL unavailable — store plaintext rather than lose the secret.
992 - error_log('MetaSync: OpenSSL is unavailable — a secret was stored WITHOUT encryption at rest.');
1154 + 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 1155 return $plaintext;
994 1156 }
995 1157
996 1158 $key = self::secret_crypto_key();
@@ -999,9 +1161,9 @@
999 1161 $ciphertext = openssl_encrypt($plaintext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag, '', 16);
1000 1162
1001 1163 if ($ciphertext === false) {
1002 1164 // 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.');
1165 + 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 1166 return $plaintext;
1005 1167 }
1006 1168
1007 1169 return self::SECRET_ENC_PREFIX . base64_encode($iv . $tag . $ciphertext);
@@ -1426,9 +1588,9 @@
1426 1588 // This could be extended to store in a dedicated log table
1427 1589
1428 1590 } catch (Exception $e) {
1429 1591 // Fallback logging if structured logging fails
1430 - error_log('MetaSync API Key Event Logging Error: ' . $e->getMessage());
1592 + 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 1593 }
1432 1594 }
1433 1595
1434 1596 /**
@@ -1492,9 +1654,9 @@
1492 1654 $global_hits = get_transient($global_rate_key);
1493 1655 if ($global_hits !== false && $global_hits >= 50) {
1494 1656 // More than 50 404s per minute - stop logging to protect database
1495 1657 if ($global_hits === 50) {
1496 - error_log('MetaSync 404 Monitor: Rate limit exceeded - 50+ 404s per minute. Pausing logging.');
1658 + 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 1659 }
1498 1660 set_transient($global_rate_key, $global_hits + 1, 60);
1499 1661 return;
1500 1662 }
@@ -1519,9 +1681,9 @@
1519 1681 require_once plugin_dir_path(dirname(__FILE__)) . '404-monitor/class-metasync-404-monitor-database.php';
1520 1682 $db_404 = new Metasync_Error_Monitor_Database();
1521 1683
1522 1684 // Get user agent (sanitized)
1523 - $user_agent = isset($_SERVER['HTTP_USER_AGENT']) ? sanitize_text_field($_SERVER['HTTP_USER_AGENT']) : '';
1685 + $user_agent = isset($_SERVER['HTTP_USER_AGENT']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_USER_AGENT'])) : '';
1524 1686
1525 1687 // Log the 404 error
1526 1688 $result = $db_404->update([
1527 1689 'uri' => $current_url,
@@ -1547,9 +1709,9 @@
1547 1709 $host = $_SERVER['SERVER_NAME'];
1548 1710 }
1549 1711 if (empty($host)) {
1550 1712 // Fallback to WordPress site URL if available
1551 - $host = parse_url(home_url(), PHP_URL_HOST);
1713 + $host = wp_parse_url(home_url(), PHP_URL_HOST);
1552 1714 }
1553 1715
1554 1716 // Safely get REQUEST_URI with fallback
1555 1717 $uri = isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '/';