*/ class Metasync { /** * The loader that's responsible for maintaining and registering all hooks that power * the plugin. * * @since 1.0.0 * @access protected * @var Metasync_Loader $loader Maintains and registers all hooks for the plugin. */ protected $loader; /** * The unique identifier of this plugin. * * @since 1.0.0 * @access protected * @var string $plugin_name The string used to uniquely identify this plugin. */ protected $plugin_name; /** * The current version of the plugin. * * @since 1.0.0 * @access protected * @var string $version The current version of the plugin. */ protected $version; protected $database; protected $db_redirection; protected $db_heartbeat_errors; public const option_name = "metasync_options"; /** * Dedicated option key for heartbeat throttle timestamps. * * Stored separately from the main options blob so writes to last_heart_beat * and last_heartbeat_at do not race with concurrent settings writes during * the 15-second wp_remote_post window in SyncCustomerParams. */ public const heartbeat_throttle_option = "metasync_heartbeat_throttle"; /** * Search Atlas Domain Constants * Centralized constants for all Search Atlas service endpoints */ public const HOMEPAGE_DOMAIN = "https://searchatlas.com"; public const DASHBOARD_DOMAIN = "https://dashboard.searchatlas.com"; public const API_DOMAIN = "https://api.searchatlas.com"; public const CA_API_DOMAIN = "https://ca.searchatlas.com"; public const SUPPORT_EMAIL = "support@searchatlas.com"; public const DOCUMENTATION_DOMAIN = "https://help.searchatlas.com"; /** * Storage prefix marking a Search Atlas API key value as encrypted at rest. */ private const API_KEY_ENC_PREFIX = 'enc_v1:'; /** * Request-scoped memo for the decrypted Search Atlas API key. * * null = not yet loaded this request * false = load attempted but decryption failed (salts changed / corrupt) * string = decrypted plaintext (may be '') * * Never written to a transient or the DB — exists only for the lifetime * of the current request. * * @var string|false|null */ private static $memo_api_key = null; /** * Define the core functionality of the plugin. * * Set the plugin name and the plugin version that can be used throughout the plugin. * Load the dependencies, define the locale, and set the hooks for the admin area and * the public-facing side of the site. * * @since 1.0.0 */ public function __construct() { if (defined('METASYNC_VERSION')) { $this->version = METASYNC_VERSION; } else { $this->version = '1.0.0'; } $this->plugin_name = 'metasync'; $this->load_dependencies(); // $this->set_locale(); // Language support removed - using default only $this->init_api_key_monitor(); $this->define_admin_hooks(); $this->define_public_hooks(); } /** * Load the required dependencies for this plugin. * * Include the following files that make up the plugin: * * - Metasync_Loader. Orchestrates the hooks of the plugin. * - Metasync_i18n. Defines internationalization functionality. * - Metasync_Admin. Defines all hooks for the admin area. * - Metasync_Public. Defines all hooks for the public side of the site. * * Create an instance of the loader which will be used to register the hooks * with WordPress. * * @since 1.0.0 * @access private */ private function load_dependencies() { // WordPress core — cannot be autoloaded. require_once ABSPATH . 'wp-admin/includes/taxonomy.php'; // Procedural init file — not a class, must stay explicit. if (file_exists(plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php')) { require_once plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php'; } else { error_log('MetaSync Google Index: google-index-init.php not found at ' . plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php'); } // The SEO precedence resolver is referenced statically from the render // filters, the sync layer and four admin screens. Require it explicitly // for the same reason as the admin navigation below: a partial update can // leave newer PHP files beside an older committed autoload classmap, and // a missing class on wp_head or the posts list is a fatal, not a // degradation. if (!class_exists('Metasync_Seo_Precedence')) { require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-seo-precedence.php'; } // Admin navigation is referenced statically from frontend-reachable // includes (heartbeat/connect managers). Require it explicitly here so // the static call never fatals when wp_head fires before autoload. if (!class_exists('Metasync_Admin_Navigation')) { require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-admin-navigation.php'; } // Hooked on init at priority 0, before any theme or plugin callback // runs, so it cannot wait for a lazy autoload. See define_public_hooks. if (!class_exists('Metasync_BookingPress_Compat')) { require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-bookingpress-compat.php'; } $this->loader = new Metasync_Loader(); // Install aggregate-option password protection in every runtime context. Metasync_Settings_Registration::instance(); $this->db_heartbeat_errors = new Metasync_HeartBeat_Error_Monitor_Database(); $this->db_redirection = new Metasync_Redirection_Database(); } /** * Define the locale for this plugin for internationalization. * * Uses the Metasync_i18n class in order to set the domain and to register the hook * with WordPress. * * @since 1.0.0 * @access private */ // Language support removed - using default only /* private function set_locale() { $plugin_i18n = new Metasync_i18n(); $this->loader->add_action('plugins_loaded', $plugin_i18n, 'load_plugin_textdomain'); } */ /** * Initialize the API Key Monitor for comprehensive API key change detection * * @since 1.0.0 * @access private */ private function init_api_key_monitor() { // Initialize the singleton instance of the API Key Monitor // This will automatically set up hooks to monitor all API key changes Metasync_API_Key_Monitor::get_instance(); // Log successful initialization #commented out to stop appending this to error.php # error_log('MetaSync: API Key Monitor initialized successfully'); } /** * Register all of the hooks related to the admin area functionality * of the plugin. * * @since 1.0.0 * @access private */ private function define_admin_hooks() { $plugin_admin = new Metasync_Admin($this->get_plugin_name(), $this->get_version(), $this->database, $this->db_redirection, $this->db_heartbeat_errors); // , $this->data_error_log_list // Initialize HTML Visual Editor $html_visual_editor = new Metasync_HTML_Visual_Editor($this->get_plugin_name(), $this->get_version()); $html_visual_editor->init(); // Initialize OTTO Debug class for developers if (class_exists('Metasync_Otto_Debug')) { $otto_debug = new Metasync_Otto_Debug($this->get_plugin_name(), $this->get_version()); } // Initialize SEO Sidebar for Gutenberg Block Editor if (class_exists('Metasync_SEO_Sidebar')) { new Metasync_SEO_Sidebar($this->get_version()); } // Initialize Internal Link Suggestions for Gutenberg Block Editor if (class_exists('Metasync_Link_Suggestions')) { new Metasync_Link_Suggestions(); } $this->loader->add_action('admin_enqueue_scripts', $plugin_admin, 'enqueue_styles'); $this->loader->add_action('admin_enqueue_scripts', $plugin_admin, 'enqueue_scripts'); # Redirection import AJAX handler $redirection_handler = new Metasync_Redirection($this->db_redirection); $this->loader->add_action('wp_ajax_metasync_import_redirections', $redirection_handler, 'handle_import_ajax'); $this->loader->add_action('wp_ajax_metasync_check_redirects_health', $redirection_handler, 'handle_health_check_ajax'); // HeartBeat API Receive Respond and Settings. $this->loader->add_action('heartbeat_settings', $plugin_admin, 'metasync_heartbeat_settings'); $this->loader->add_action('heartbeat_received', $plugin_admin, 'metasync_received_data', 10, 2); $this->loader->add_action('wp_ajax_metasync_send_customer_params', $plugin_admin, 'lgSendCustomerParams'); // Search Atlas Connect endpoints - authenticates with Search Atlas platform to retrieve SA API key and Otto UUID $this->loader->add_action('wp_ajax_metasync_generate_connect_url', $plugin_admin, 'generate_searchatlas_connect_url'); $this->loader->add_action('wp_ajax_metasync_check_connect_status', $plugin_admin, 'check_searchatlas_connect_status'); $this->loader->add_action('wp_ajax_metasync_reset_authentication', $plugin_admin, 'reset_searchatlas_authentication'); // Auto-update filter $this->loader->add_filter('auto_update_plugin', $plugin_admin, 'control_plugin_auto_updates', 10, 2); // Search Atlas Connect development/testing endpoints $this->loader->add_action('wp_ajax_metasync_test_enhanced_tokens', $plugin_admin, 'test_enhanced_searchatlas_tokens'); $this->loader->add_action('wp_ajax_metasync_test_whitelabel_domain', $plugin_admin, 'test_whitelabel_domain'); $this->loader->add_action('wp_ajax_metasync_test_ajax_endpoint', $plugin_admin, 'test_searchatlas_ajax_endpoint'); $this->loader->add_action('wp_ajax_metasync_simple_ajax_test', $plugin_admin, 'simple_ajax_test'); $post_meta_setting = new Metasync_Post_Meta_Settings(); $this->loader->add_action('admin_init', $post_meta_setting, 'add_post_meta_data', 2); $this->loader->add_action('admin_init', $post_meta_setting, 'show_top_admin_bar', 9); // Unified "SEO Suite" meta box — consolidates the separate Classic-editor // meta boxes above into one tabbed box (presentation-only; save handlers // unchanged). Classic editor only; the block editor keeps its SEO sidebar. // Self-registers its hooks in the constructor. Opt out via the // `metasync_enable_seo_suite` filter. require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-seo-suite.php'; new Metasync_Seo_Suite(); // SEO Health CSV export: must run on admin_init (before output). // Cheap $_GET check avoids loading the class on every admin page. if ( isset($_GET['page'], $_GET['export'], $_GET['_wpnonce']) && $_GET['export'] === 'csv' && strpos($_GET['page'], '-seo-health') !== false ) { $this->loader->add_action('admin_init', Metasync_SEO_Health::get_instance(), 'handle_csv_export', 1); } $this->loader->add_action('wp', $post_meta_setting, 'show_top_admin_bar', 9); // SEO meta columns on the posts/pages list tables (WP-624). // Registered for both the `posts` and `pages` variants of each hook so the // columns reach every supported post type; the callbacks bail on unsupported // ones. Hidden by default — users opt in from Screen Options. $seo_columns = Metasync_SEO_Columns::get_instance(); $this->loader->add_filter('manage_posts_columns', $seo_columns, 'add_columns', 10, 2); $this->loader->add_filter('manage_pages_columns', $seo_columns, 'add_columns', 10, 1); $this->loader->add_action('manage_posts_custom_column', $seo_columns, 'render_column', 10, 2); $this->loader->add_action('manage_pages_custom_column', $seo_columns, 'render_column', 10, 2); $this->loader->add_filter('default_hidden_columns', $seo_columns, 'default_hidden_columns', 10, 2); $this->loader->add_filter('hidden_columns', $seo_columns, 'hidden_columns', 10, 3); $this->loader->add_action('admin_head', $seo_columns, 'print_styles'); $this->loader->add_action('admin_notices', $seo_columns, 'companion_plugin_notice'); // Initialize XML Sitemap auto-update hooks if enabled // Note: Must not be gated by is_admin() because Gutenberg saves posts // via the REST API where is_admin() returns false, and REST_REQUEST // is not yet defined at plugin load time if (get_option('metasync_sitemap_auto_update', false)) { $sitemap_generator = new Metasync_Sitemap_Generator(); $sitemap_generator->setup_auto_update_hooks(); } // Initialize Schema Markup functionality $schema_markup = new Metasync_Schema_Markup($this->get_plugin_name(), $this->get_version()); $this->loader->add_action('wp_ajax_metasync_get_schema_fields', $schema_markup, 'ajax_get_schema_fields'); $this->loader->add_action('wp_ajax_metasync_preview_schema', $schema_markup, 'ajax_preview_schema'); // Initialize Breadcrumbs functionality if (class_exists('Metasync_Breadcrumbs')) { new Metasync_Breadcrumbs($this->get_plugin_name(), $this->get_version()); } if (class_exists('Metasync_Breadcrumbs_Schema')) { new Metasync_Breadcrumbs_Schema($this->get_plugin_name(), $this->get_version()); } // Initialize Developer Panel (for endpoint switching) if (class_exists('Metasync_Dev_Panel')) { $dev_panel = new Metasync_Dev_Panel($this->get_plugin_name(), $this->get_version()); } // Initialize Site Health integration if (class_exists('Metasync_Site_Health')) { $site_health = new Metasync_Site_Health(); $site_health->register_tests(); } // Initialize endpoint URL filtering for staging mode $this->init_endpoint_filtering(); } /** * Register all of the hooks related to the public-facing functionality * of the plugin. * * @since 1.0.0 * @access private */ private function define_public_hooks() { // Header and Footer code snippets $code_snippets = new Metasync_Code_Snippets(); $this->loader->add_action('wp_head', $code_snippets, 'get_header_snippet'); $this->loader->add_action('wp_footer', $code_snippets, 'get_footer_snippet'); $plugin_public = new Metasync_Public($this->get_plugin_name(), $this->get_version()); $rest_api = $plugin_public->get_rest_api(); $seo_output = $plugin_public->get_seo_output(); $get_plugin_basename = sprintf('%1$s/%1$s.php', $this->plugin_name); // Asset enqueue hooks (Metasync_Public) $this->loader->add_action('wp_enqueue_scripts', $plugin_public, 'enqueue_styles'); $this->loader->add_action('wp_enqueue_scripts', $plugin_public, 'enqueue_scripts'); $this->loader->add_action('wp_enqueue_scripts', $plugin_public, 'enqueue_page_custom_css', 999); // Elementor editor CSS injection if (class_exists('\Elementor\Plugin')) { $this->loader->add_action('elementor/preview/enqueue_styles', $plugin_public, 'enqueue_elementor_editor_css', 999); } // Divi builder CSS injection if (function_exists('et_setup_theme')) { $this->loader->add_action('wp_enqueue_scripts', $plugin_public, 'enqueue_divi_builder_css', 999); } // Initialize centralized SEO conflict handler (singleton — suppresses // third-party SEO plugin descriptions when MetaSync provides its own). Metasync_SEO_Conflict_Handler::get_instance(); // Term-level SEO plugin sync: propagate MetaSync term meta (category/tag // archives) into Yoast/Rank Math/AIOSEO term storage on every write. $this->loader->add_action('updated_term_meta', $this, 'on_term_meta_updated', 10, 4); $this->loader->add_action('added_term_meta', $this, 'on_term_meta_updated', 10, 4); // Post-level plugin sync: propagate MetaSync post meta into // Yoast/Rank Math/AIOSEO post storage on every write. $this->loader->add_action('updated_post_meta', $this, 'on_post_meta_updated', 10, 4); $this->loader->add_action('added_post_meta', $this, 'on_post_meta_updated', 10, 4); // Deletes matter too: clearing the last checkbox in a meta box removes // the meta row, which fires neither hook above. $this->loader->add_action('deleted_post_meta', $this, 'on_post_meta_deleted', 10, 3); // SEO Output hooks (Metasync_Seo_Output) $this->loader->add_action('wp_head', $seo_output, 'hook_metasync_metatags', 1, 1); // LocalBusiness / Organization / Person JSON-LD from the Local Business page. $this->loader->add_action('wp_head', $seo_output, 'output_local_business_schema', 2, 1); $this->loader->add_action('template_redirect', $seo_output, 'inject_archive_seo_controls'); // Hreflang / language alternates output (wp_head @ priority 2). $plugin_hreflang = new Metasync_Hreflang_Output(); $this->loader->add_action('wp_head', $plugin_hreflang, 'output_hreflang_tags', 2); // Edge Cache: detect Cloudways Varnish and persist for settings UI $this->loader->add_action('init', 'Metasync_Edge_Cache_Purge', 'detect_cloudways'); // BookingPress starts a PHP session on every front-end request (init // at priority 1), which makes hosts skip page caching site-wide. Its // booking flows manage their own sessions inside their AJAX handlers, // so unhook the global start. Priority 0 keeps this ahead of theirs. // Registered with add_action directly (like Metasync_Oxygen_Compat) // because the loader's $component parameter is typed to objects. add_action('init', ['Metasync_BookingPress_Compat', 'neutralize_bookingpress_session'], 0); // Sitemap exclusions for disabled archive types $this->loader->add_filter('wp_sitemaps_taxonomies', $seo_output, 'filter_sitemap_taxonomies'); $this->loader->add_filter('wp_sitemaps_users_entry', $seo_output, 'filter_sitemap_users', 10, 2); $this->loader->add_filter('wp_sitemaps_add_provider', $seo_output, 'filter_sitemap_providers', 10, 2); $this->loader->add_filter('wp_sitemaps_index_entry', $seo_output, 'filter_sitemap_index_entries', 10, 4); // AMP cleanup functionality - remove metasync_optimized attribute from head on AMP pages $this->loader->add_action('template_redirect', $seo_output, 'cleanup_amp_head_attribute', 1); $this->loader->add_action('wp_footer', $seo_output, 'end_amp_head_cleanup', 999); // Redirection functionality $redirection = new Metasync_Redirection($this->db_redirection); $this->loader->add_action('template_redirect', $redirection, 'handle_template_redirect', 5); # Prevent WordPress from redirecting to draft posts via redirect_canonical $this->loader->add_filter('redirect_canonical', $redirection, 'prevent_draft_post_redirects', 10, 2); # Prevent WordPress old slug redirects to unpublished posts only $this->loader->add_filter('old_slug_redirect_post_id', $redirection, 'prevent_old_slug_redirect_to_drafts', 10, 1); // Auto-redirect on slug change - creates 301 redirect when post/page slug is changed $auto_redirect = new Metasync_Auto_Redirect($this->db_redirection); $auto_redirect->init(); # Custom HTML Pages functionality # No additional loader hooks needed - class registers its own hooks $custom_pages = new Metasync_Custom_Pages(); // 404 Error monitoring $this->loader->add_action('template_redirect', $this, 'handle_404_monitoring', 10); $this->loader->add_action('plugin_action_links_' . $get_plugin_basename, $plugin_public, 'metasync_plugin_links'); // REST API hooks (Metasync_Rest_Api) $this->loader->add_action('rest_api_init', $rest_api, 'metasync_register_rest_routes'); // Coexist with third-party JWT auth plugins: clear their prior auth error // for metasync/v1 requests when our own API key validates. The Tmeister // "JWT Authentication for WP-API" plugin surfaces its jwt_auth_invalid_token // 403 via rest_pre_dispatch (priority 10), so we hook the same filter at a // later priority (11) to clear it for our namespace only. $this->loader->add_filter('rest_pre_dispatch', $rest_api, 'allow_metasync_rest_auth', 11, 3); // Coexist with site-wide "authenticated users only" REST restrictions. // Those hook rest_authentication_errors, which WP applies before dispatch, // so rest_pre_dispatch and permission_callback never run. Hook it late // (99) to clear the error for metasync/v1 requests that present a valid // plugin API key; every other route keeps the site's restriction. $this->loader->add_filter('rest_authentication_errors', $rest_api, 'allow_metasync_rest_authentication', 99, 1); $this->loader->add_action('init', $plugin_public, 'metasync_plugin_init', 5); $this->loader->add_action('wp_ajax_metasync_lglogin', $rest_api, 'linkgraph_login'); // Robots meta filter (Metasync_Seo_Output) $this->loader->add_filter('wp_robots', $seo_output, 'metasync_wp_robots_meta'); $metasyncTemplateClass = new Metasync_Template(); $this->loader->add_filter('theme_page_templates', $metasyncTemplateClass, 'metasync_template_landing_page', 10, 3); $this->loader->add_filter('template_include', $metasyncTemplateClass, 'metasync_template_landing_page_load', 99 ); $templateCrawler = new MetaSyncHiddenPostManager(); # initialize the crawler class $this->loader->add_action('wp_trash_post', $templateCrawler , 'prevent_post_deletion'); # Prevent post deletion when moved to trash $this->loader->add_action('before_delete_post', $templateCrawler , 'prevent_post_deletion'); # Prevent permanent deletion # $this->loader->add_filter('metasync_hidden_post_manager', $templateCrawler , 'init'); # run the crawler # Hidden post manager now runs via cron instead of filter (to avoid interfering with post create/update) $this->loader->add_action('metasync_hidden_post_check', $templateCrawler , 'init'); # run the crawler via cron // Open Graph and Social Media Tags $opengraph = new Metasync_OpenGraph($this->get_plugin_name(), $this->get_version()); $opengraph->init(); # Save current theme info to database (safe context - admin/init hooks) $this->loader->add_action('after_switch_theme', $this, 'save_current_theme_info'); $this->loader->add_action('admin_init', $this, 'ensure_theme_info_saved'); // OTTO Frontend Toolbar $otto_toolbar = new Metasync_Otto_Frontend_Toolbar($this->get_plugin_name(), $this->get_version()); $this->loader->add_action('wp_enqueue_scripts', $otto_toolbar, 'enqueue_styles'); $this->loader->add_action('wp_enqueue_scripts', $otto_toolbar, 'enqueue_scripts'); $this->loader->add_action('admin_bar_menu', $otto_toolbar, 'add_admin_bar_menu', 100); $this->loader->add_action('wp_footer', $otto_toolbar, 'render_debug_bar', 999); // Initialize Sitemap Generator on frontend (for virtual sitemap serving) $sitemap_generator = new Metasync_Sitemap_Generator(); // Initialize LLMs.txt Generator (for virtual /llms.txt and /llms-full.txt serving) require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-html-to-markdown.php'; require_once plugin_dir_path(dirname(__FILE__)) . 'llms-txt/class-metasync-llms-txt-generator.php'; $llms_txt_generator = new Metasync_Llms_Txt_Generator(); // Serve the IndexNow key file virtually at /{key}.txt so it works // on read-only web roots and nginx hosts that 403 direct static .txt access. require_once plugin_dir_path(dirname(__FILE__)) . 'bing-index/class-metasync-bing-instant-index.php'; add_action('template_redirect', array('Metasync_Bing_Instant_Index', 'serve_virtual_key_file'), 0); // Scheduled IndexNow submissions, deferred from save_post so a slow // IndexNow round-trip cannot stall the editor or REST write. Registered // statically because the class is loaded but never instantiated on // cron loads. The hook name matches the uninstall cron sweep exactly. add_action('metasync_bing_indexnow_submit_event', array('Metasync_Bing_Instant_Index', 'run_scheduled_submit'), 10, 1); // Construct the robots.txt manager on the public load path so its // `robots_txt` filter is registered before a plain GET /robots.txt is // served. The filter is attached in the singleton's constructor, and // previously only admin screens built the instance — so the virtual // robots.txt rules and sitemap lines never rendered on the frontend. require_once plugin_dir_path(dirname(__FILE__)) . 'robots-txt/class-metasync-robots-txt.php'; Metasync_Robots_Txt::get_instance(); // One-time upgrade: regenerate sitemap to remove any Beaver Builder template entries if ( ! get_option( 'metasync_sitemap_bb_exclusion_applied' ) ) { $this->loader->add_action('init', $this, 'maybe_regenerate_sitemap_after_upgrade'); } } /** * One-time upgrade routine: regenerate the XML sitemap so that Beaver Builder * template post types (fl-builder-template, fl-theme-layout) that were already * present in previously-generated sitemaps are purged. * * Runs once on 'init' and sets a flag so it never runs again. * * @since 1.0.0 */ public function maybe_regenerate_sitemap_after_upgrade() { $done_key = 'metasync_sitemap_bb_exclusion_applied'; if ( get_option( $done_key ) ) { return; } // Only regenerate if the custom sitemap feature is actually in use. if ( get_option( 'metasync_sitemap_auto_update', false ) || file_exists( ABSPATH . 'sitemap_index.xml' ) ) { if ( ! class_exists( 'Metasync_Sitemap_Generator' ) ) { require_once plugin_dir_path( dirname( __FILE__ ) ) . 'sitemap/class-metasync-sitemap-generator.php'; } $sitemap = new Metasync_Sitemap_Generator(); $sitemap->generate_sitemap(); update_option( $done_key, true ); } // If sitemap is not in use, don't set the flag — retry on next load // so that enabling sitemaps later will still clean up BB templates. } /** * Initialize endpoint URL filtering for staging mode * Intercepts HTTP requests and replaces production URLs with staging URLs */ private function init_endpoint_filtering() { // Only add filter if Endpoint Manager is available and staging mode is active if (!class_exists('Metasync_Endpoint_Manager') || !Metasync_Endpoint_Manager::is_staging_mode()) { return; } // Add filter to intercept HTTP requests before they're sent add_filter('pre_http_request', array($this, 'filter_http_request_urls'), 10, 3); } /** * Filter HTTP request URLs to replace production endpoints with staging * * @param false|array|WP_Error $preempt Whether to preempt an HTTP request's return value. * @param array $args HTTP request arguments. * @param string $url The request URL. * @return false|array|WP_Error */ public function filter_http_request_urls($preempt, $args, $url) { // Only process if we're not preempting the request if ($preempt !== false) { return $preempt; } // Only process if staging mode is active if (!class_exists('Metasync_Endpoint_Manager') || !Metasync_Endpoint_Manager::is_staging_mode()) { return $preempt; } // Define URL replacements (production => staging) $url_replacements = array( 'https://dashboard.searchatlas.com' => 'https://dashboard.staging.searchatlas.com', 'https://api.searchatlas.com' => 'https://api.staging.searchatlas.com', 'https://ca.searchatlas.com' => 'https://ca.staging.searchatlas.com', 'https://sa.searchatlas.com' => 'https://sa.staging.searchatlas.com', ); // Check if URL needs to be replaced $original_url = $url; foreach ($url_replacements as $production => $staging) { if (strpos($url, $production) === 0) { $url = str_replace($production, $staging, $url); error_log("MetaSync Endpoint Filter: Replaced {$production} with {$staging} in URL: {$original_url}"); break; } } // If URL was changed, modify the args and make the request ourselves if ($url !== $original_url) { // Make the request with the modified URL return wp_remote_request($url, $args); } return $preempt; } /** * Term meta update hook: mirror MetaSync term meta (`_metasync_*`) * into the active third-party SEO plugins' term storage. * * Registered on both `updated_term_meta` and `added_term_meta` so new * fields are synced the first time they are written as well as on * subsequent updates. * * @param int $meta_id Meta row ID (unused). * @param int $object_id Term ID. * @param string $meta_key Meta key being written. * @param mixed $meta_value Meta value being written. */ public function on_term_meta_updated($meta_id, $object_id, $meta_key, $meta_value) { if (strncmp($meta_key, '_metasync_', 10) !== 0) { return; } if (!class_exists('Metasync_Term_Plugin_Sync')) { return; } $term = get_term((int) $object_id); if (!$term || is_wp_error($term)) { return; } $canonical_map = [ '_metasync_metatitle' => 'title', '_metasync_metadesc' => 'desc', '_metasync_robots_index' => 'noindex', '_metasync_canonical_url' => 'canonical', '_metasync_og_title' => 'og_title', '_metasync_og_description' => 'og_desc', '_metasync_og_image' => 'og_image', '_metasync_twitter_title' => 'twitter_title', '_metasync_twitter_description' => 'twitter_desc', ]; if (!isset($canonical_map[$meta_key])) { return; } $canonical_key = $canonical_map[$meta_key]; Metasync_Term_Plugin_Sync::get_instance()->sync_term( (int) $object_id, (string) $term->taxonomy, [$canonical_key => $meta_value] ); } /** * Post meta update hook: mirror MetaSync post meta (`_metasync_*`) * into the active third-party SEO plugins' post storage. * * Registered on both `updated_post_meta` and `added_post_meta` so new * fields are synced the first time they are written as well as on * subsequent updates. * * @param int $meta_id Meta row ID (unused). * @param int $post_id Post ID. * @param string $meta_key Meta key being written. * @param mixed $meta_value Meta value being written. */ public function on_post_meta_updated($meta_id, $post_id, $meta_key, $meta_value) { if (!self::is_metasync_meta_key($meta_key)) { return; } if (!self::sync_layer_handles('on_meta_updated')) { return; } Metasync_Plugin_Sync::get_instance()->on_meta_updated($meta_id, $post_id, $meta_key, $meta_value); } /** * Bridge deleted_post_meta to the sync layer. * * Clearing the last checkbox in a meta box deletes its meta row rather than * updating it, so the update hooks above never fire and mirrored values can * go stale. Only the legacy robots keys are acted on; see * Metasync_Plugin_Sync::on_meta_deleted(). * * @param array $meta_ids Meta row IDs (unused). * @param int $post_id Post ID. * @param string $meta_key Meta key being deleted. */ public function on_post_meta_deleted($meta_ids, $post_id, $meta_key) { if (!self::is_metasync_meta_key($meta_key)) { return; } if (!self::sync_layer_handles('on_meta_deleted')) { return; } Metasync_Plugin_Sync::get_instance()->on_meta_deleted($meta_ids, $post_id, $meta_key); } /** * Is this meta key one the post sync layer could possibly care about? * * Cheap string gate, no autoload, no singleton. Every watched post key is * either `_metasync_*` (sidebar, OTTO and the mirrored robots JSON) or * `metasync_*` (the legacy meta box keys), so this keeps third-party meta * writes out of the sync layer entirely — `deleted_post_meta` and * `updated_post_meta` fire for every key on the site, including on front-end * requests, and crossing into the sync layer for keys it will only discard * costs an autoload plus a singleton on the hot path. * * Deliberately broader than the sync layer's own key maps: those stay the * single source of exact truth, so a new MetaSync key needs no change here. * * @param string $meta_key Meta key being written or deleted. * @return bool */ private static function is_metasync_meta_key($meta_key) { return strncmp($meta_key, '_metasync_', 10) === 0 || strncmp($meta_key, 'metasync_', 9) === 0; } /** * Can the post sync layer actually handle this hook right now? * * A partially updated install can leave a newer class-metasync.php beside an * older class-metasync-plugin-sync.php — stale opcache bytecode for one file * is enough. Calling a method the loaded class does not define is a fatal, * and because these bridges run on `wp_head` via third-party meta writes it * takes the front end down rather than degrading. Check before dispatching. * * Checked on the class, not an instance, so a mismatch skips the singleton. * * @param string $method Sync-layer method about to be called. * @return bool */ private static function sync_layer_handles($method) { return class_exists('Metasync_Plugin_Sync') && method_exists('Metasync_Plugin_Sync', 'get_instance') && method_exists('Metasync_Plugin_Sync', $method); } /** * Save current theme information to MetaSync options * This runs in WordPress admin context, not during REST API requests * Safe to use wp_get_theme() here */ public function save_current_theme_info() { $theme = wp_get_theme(); $metasync_data = self::get_option(); if (!isset($metasync_data['general'])) { $metasync_data['general'] = array(); } $metasync_data['general']['current_theme_name'] = $theme->get('Name'); $metasync_data['general']['current_theme_template'] = $theme->get_template(); $metasync_data['general']['theme_info_updated'] = time(); self::set_option($metasync_data); } /** * Ensure theme info is saved on admin_init if not already saved * This ensures theme info is available even if theme wasn't switched */ public function ensure_theme_info_saved() { $metasync_data = self::get_option('general'); # Only run once per day to avoid overhead if (empty($metasync_data['theme_info_updated']) || (time() - $metasync_data['theme_info_updated']) > 86400) { $this->save_current_theme_info(); } } public static function get_option($key = null, $default = null) { $options = get_option(Metasync::option_name); if (empty($options)) $options = []; if ($key === null) return $options; return $options[$key] ?? ($default !== null ? $default : null); } public static function set_option($data) { #return update_option(Metasync::option_name, $data); $result = update_option(Metasync::option_name, $data); // NEW: Structured error logging for database errors (only log if it's a real DB error) global $wpdb; if ($result === false && class_exists('Metasync_Error_Logger') && !empty($wpdb->last_error)) { // Check if it's actually a database error (not just same value) $saved_data = get_option(Metasync::option_name); if ($saved_data !== $data) { // Value is different but save failed - this is a real database error Metasync_Error_Logger::log( Metasync_Error_Logger::CATEGORY_DATABASE_ERROR, Metasync_Error_Logger::SEVERITY_ERROR, 'Failed to save plugin main options to database', [ 'option_name' => Metasync::option_name, 'wpdb_error' => $wpdb->last_error, 'wpdb_last_query' => $wpdb->last_query, 'operation' => 'set_option', 'has_api_key' => !empty($data['general']['searchatlas_api_key'] ?? null), 'has_auth_token' => !empty($data['general']['apikey'] ?? null) ] ); } } return $result; } /** * Derive the 32-byte AES key from existing WordPress salts. * * No new secret is stored anywhere — the key material is the concatenation * of three WordPress salts, hashed to a fixed 32 bytes. If the salts change * (e.g. wp-config regenerated) the derived key changes and previously * encrypted values can no longer be decrypted, which is handled gracefully * by the callers (re-authenticate state) rather than fataling. * * @return string 32 raw bytes. */ private static function api_key_crypto_key() { $material = wp_salt('secure_auth') . wp_salt('logged_in') . wp_salt('nonce'); return hash('sha256', $material, true); } /** * Determine whether a stored value is in the encrypted-at-rest format. * * @param mixed $value * @return bool */ public static function is_encrypted_api_key($value) { return is_string($value) && strncmp($value, self::API_KEY_ENC_PREFIX, strlen(self::API_KEY_ENC_PREFIX)) === 0; } /** * Encrypt a plaintext Search Atlas API key for storage at rest. * * Uses AES-256-GCM (authenticated) with a random 12-byte IV. The IV, the * 16-byte GCM tag and the ciphertext are concatenated and base64-encoded * behind an `enc_v1:` prefix. An empty string is stored as-is (no key set). * * When OpenSSL is unavailable or encryption fails the plaintext is stored * unchanged (availability over hard-fail) and the degradation is logged so * it cannot go unnoticed. * * @param string $plaintext * @return string Encrypted blob, or '' when $plaintext is empty. */ public static function encrypt_api_key($plaintext) { $plaintext = (string) $plaintext; if ($plaintext === '') { return ''; } // Already encrypted — do not double-encrypt. if (self::is_encrypted_api_key($plaintext)) { return $plaintext; } if (!function_exists('openssl_encrypt')) { // OpenSSL unavailable — store plaintext rather than lose the key. error_log('MetaSync: OpenSSL is unavailable — the Search Atlas API key was stored WITHOUT encryption at rest.'); return $plaintext; } $key = self::api_key_crypto_key(); $iv = random_bytes(12); $tag = ''; $ciphertext = openssl_encrypt($plaintext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag, '', 16); if ($ciphertext === false) { // Encryption failed — fall back to plaintext storage, but say so. error_log('MetaSync: API key encryption failed — the Search Atlas API key was stored WITHOUT encryption at rest.'); return $plaintext; } return self::API_KEY_ENC_PREFIX . base64_encode($iv . $tag . $ciphertext); } /** * Decrypt a stored Search Atlas API key value. * * Accepts either the encrypted `enc_v1:` format or a legacy plaintext value * (returned unchanged, supporting installs that pre-date encryption). On any * decryption failure (salt change / corruption) returns false so callers can * surface a re-authenticate state instead of using a bad key. * * @param mixed $value * @return string|false Plaintext, or false when an encrypted value cannot be decrypted. */ public static function decrypt_api_key($value) { if (!is_string($value) || $value === '') { return ''; } if (!self::is_encrypted_api_key($value)) { // Legacy plaintext key. return $value; } if (!function_exists('openssl_decrypt')) { return false; } $raw = base64_decode(substr($value, strlen(self::API_KEY_ENC_PREFIX)), true); if ($raw === false || strlen($raw) < 12 + 16 + 1) { return false; } $iv = substr($raw, 0, 12); $tag = substr($raw, 12, 16); $ciphertext = substr($raw, 28); $key = self::api_key_crypto_key(); $plaintext = openssl_decrypt($ciphertext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag); if ($plaintext === false) { return false; } return $plaintext; } /** * Get the decrypted Search Atlas API key, memoized for the request. * * Decrypts at most once per request and never persists the decrypted value * anywhere. When a legacy plaintext key is found it is migrated to the * encrypted format in place (one-time migration on load). Returns: * - the plaintext key (string, possibly '') * - false when an encrypted value exists but cannot be decrypted * (salts changed / corrupt) — callers should treat this as a * "please re-authenticate" state. * * @return string|false */ public static function get_searchatlas_api_key() { if (self::$memo_api_key !== null) { return self::$memo_api_key; } $general = self::get_option('general'); $stored = is_array($general) ? ($general['searchatlas_api_key'] ?? '') : ''; // One-time migration: a non-empty legacy plaintext value is encrypted // in place the first time it is read after this feature ships. if ($stored !== '' && is_string($stored) && !self::is_encrypted_api_key($stored)) { $encrypted = self::encrypt_api_key($stored); if (self::is_encrypted_api_key($encrypted)) { $options = self::get_option(); if (!is_array($options)) { $options = []; } $options['general']['searchatlas_api_key'] = $encrypted; self::set_option($options); } } self::$memo_api_key = self::decrypt_api_key($stored); return self::$memo_api_key; } /** * Clear the request-scoped decrypted-key memo. * * Call after any write that changes the stored searchatlas_api_key so a * subsequent read in the same request reflects the new value. */ public static function invalidate_api_key_cache() { self::$memo_api_key = null; } /** * Read the heartbeat throttle state from its dedicated option. * * Backfills from the legacy location (`metasync_options['general']`) the * first time the dedicated option is empty, so existing installs keep * their throttle history across the migration. */ public static function get_heartbeat_throttle(): array { $value = get_option(self::heartbeat_throttle_option, []); if (is_array($value) && !empty($value)) { return $value; } $general = self::get_option('general'); if (is_array($general) && (array_key_exists('last_heart_beat', $general) || array_key_exists('last_heartbeat_at', $general))) { $throttle = [ 'last_heart_beat' => $general['last_heart_beat'] ?? 0, 'last_heartbeat_at' => $general['last_heartbeat_at'] ?? null, ]; update_option(self::heartbeat_throttle_option, $throttle); return $throttle; } return []; } /** * Merge fields into the dedicated heartbeat throttle option. * * Writes via update_option directly so the main metasync_options blob is * never read or rewritten — avoiding the read-modify-write race with * concurrent settings saves. */ public static function set_heartbeat_throttle(array $fields): void { $existing = get_option(self::heartbeat_throttle_option, []); if (!is_array($existing)) { $existing = []; } $merged = array_merge($existing, $fields); update_option(self::heartbeat_throttle_option, $merged); } /** * Storage prefix marking a secret value as encrypted at rest. */ private const SECRET_ENC_PREFIX = 'enc_v1:'; /** * Derive the 32-byte AES key from existing WordPress salts. * * No new secret is stored anywhere — the key material is the concatenation * of three WordPress salts, hashed to a fixed 32 bytes. If the salts change * (e.g. wp-config regenerated) the derived key changes and previously * encrypted values can no longer be decrypted, which callers handle * gracefully rather than fataling. * * @return string 32 raw bytes. */ private static function secret_crypto_key() { $material = wp_salt('secure_auth') . wp_salt('logged_in') . wp_salt('nonce'); return hash('sha256', $material, true); } /** * Determine whether a stored value is in the encrypted-at-rest format. * * @param mixed $value * @return bool */ public static function is_encrypted_secret($value) { return is_string($value) && strncmp($value, self::SECRET_ENC_PREFIX, strlen(self::SECRET_ENC_PREFIX)) === 0; } /** * Encrypt a plaintext secret (e.g. the whitelabel settings password) for * storage at rest. * * Uses AES-256-GCM (authenticated) with a random 12-byte IV. The IV, the * 16-byte GCM tag and the ciphertext are concatenated and base64-encoded * behind an `enc_v1:` prefix. An empty string is stored as-is (no secret). * * When OpenSSL is unavailable or encryption fails the plaintext is stored * unchanged (availability over hard-fail) and the degradation is logged so * it cannot go unnoticed. * * @param string $plaintext * @return string Encrypted blob, or '' when $plaintext is empty. */ public static function encrypt_secret($plaintext) { $plaintext = (string) $plaintext; if ($plaintext === '') { return ''; } // Already encrypted — do not double-encrypt. if (self::is_encrypted_secret($plaintext)) { return $plaintext; } if (!function_exists('openssl_encrypt')) { // OpenSSL unavailable — store plaintext rather than lose the secret. error_log('MetaSync: OpenSSL is unavailable — a secret was stored WITHOUT encryption at rest.'); return $plaintext; } $key = self::secret_crypto_key(); $iv = random_bytes(12); $tag = ''; $ciphertext = openssl_encrypt($plaintext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag, '', 16); if ($ciphertext === false) { // Encryption failed — fall back to plaintext storage, but say so. error_log('MetaSync: Secret encryption failed — a secret was stored WITHOUT encryption at rest.'); return $plaintext; } return self::SECRET_ENC_PREFIX . base64_encode($iv . $tag . $ciphertext); } /** * Decrypt a stored secret value. * * Accepts either the encrypted `enc_v1:` format or a legacy plaintext value * (returned unchanged, supporting installs that pre-date encryption). On any * decryption failure (salt change / corruption) returns false so callers can * degrade gracefully instead of using a bad value. * * @param mixed $value * @return string|false Plaintext, or false when an encrypted value cannot be decrypted. */ public static function decrypt_secret($value) { if (!is_string($value) || $value === '') { return ''; } if (!self::is_encrypted_secret($value)) { // Legacy plaintext value. return $value; } if (!function_exists('openssl_decrypt')) { return false; } $raw = base64_decode(substr($value, strlen(self::SECRET_ENC_PREFIX)), true); if ($raw === false || strlen($raw) < 12 + 16 + 1) { return false; } $iv = substr($raw, 0, 12); $tag = substr($raw, 12, 16); $ciphertext = substr($raw, 28); $key = self::secret_crypto_key(); $plaintext = openssl_decrypt($ciphertext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag); if ($plaintext === false) { return false; } return $plaintext; } /** * Get the decrypted whitelabel settings password. * * Reads the stored (encrypted) value and returns the plaintext for * verification or authorized display. A legacy plaintext value found in * storage is migrated to the encrypted format in place (one-time migration * on read). Returns '' when no password is set or when an encrypted value * can no longer be decrypted (salts changed / corrupt) — in that case the * stored value still counts as "password set" for protection checks, but * the user password cannot authenticate until it is reset. * * @return string */ public static function get_whitelabel_password() { $whitelabel = self::get_whitelabel_settings(); $stored = $whitelabel['settings_password'] ?? ''; if (!is_string($stored) || $stored === '') { return ''; } // One-time migration: encrypt a legacy plaintext value in place. // This is a whole-blob read-modify-write of metasync_options during a // read request; a concurrent settings save could theoretically clobber // it, but it fires at most once per legacy install so the window is // accepted rather than adding a dedicated option. if (!self::is_encrypted_secret($stored)) { $encrypted = self::encrypt_secret($stored); if (self::is_encrypted_secret($encrypted)) { $options = self::get_option(); if (!is_array($options)) { $options = []; } $options['whitelabel']['settings_password'] = $encrypted; self::set_option($options); } return $stored; } $plaintext = self::decrypt_secret($stored); return $plaintext === false ? '' : $plaintext; } /** * Get whitelabel settings * Helper method to retrieve whitelabel configuration */ public static function get_whitelabel_settings() { $whitelabel = self::get_option('whitelabel'); return is_array($whitelabel) ? $whitelabel : array( 'is_whitelabel' => false, 'domain' => '', 'logo' => '', 'logo_light' => '', 'logo_dark' => '', 'company_name' => '', 'color_palette' => array(), 'updated_at' => 0 ); } /** * Check if whitelabel mode is enabled */ public static function is_whitelabel_enabled() { $whitelabel = self::get_whitelabel_settings(); return isset($whitelabel['is_whitelabel']) && $whitelabel['is_whitelabel'] === true; } /** * Get effective dashboard domain for the plugin * Returns whitelabel domain if set (regardless of is_whitelabel flag), otherwise respects staging/production mode */ public static function get_dashboard_domain() { $whitelabel = self::get_whitelabel_settings(); // Priority 1: Use whitelabel domain if it's not empty (regardless of is_whitelabel flag) if (!empty($whitelabel['domain'])) { return $whitelabel['domain']; } // Priority 2: Use endpoint manager to respect staging/production mode if (class_exists('Metasync_Endpoint_Manager')) { return Metasync_Endpoint_Manager::get_endpoint('DASHBOARD_DOMAIN'); } // Priority 3: Fallback to production default domain return self::DASHBOARD_DOMAIN; } /** * Get whitelabel logo URL * Returns the whitelabel logo URL if logo is set */ public static function get_whitelabel_logo() { $whitelabel = self::get_whitelabel_settings(); // Return logo if it's set and is a valid URL // Users should be able to set a custom logo without requiring a custom domain if (!empty($whitelabel['logo'])) { return $whitelabel['logo']; } return null; } /** * Get whitelabel logo URL for light theme * Falls back to legacy 'logo' field if logo_light is not set */ public static function get_whitelabel_logo_light() { $whitelabel = self::get_whitelabel_settings(); if (!empty($whitelabel['logo_light'])) { return $whitelabel['logo_light']; } if (!empty($whitelabel['logo'])) { return $whitelabel['logo']; } return null; } /** * Get whitelabel logo URL for dark theme * Falls back to legacy 'logo' field if logo_dark is not set */ public static function get_whitelabel_logo_dark() { $whitelabel = self::get_whitelabel_settings(); if (!empty($whitelabel['logo_dark'])) { return $whitelabel['logo_dark']; } if (!empty($whitelabel['logo'])) { return $whitelabel['logo']; } return null; } /** * Get whitelabel company name * Returns the whitelabel company name if whitelabel is active and company name is set */ public static function get_whitelabel_company_name() { $whitelabel = self::get_whitelabel_settings(); // Return company name only if whitelabel is active and company name is set if (isset($whitelabel['is_whitelabel']) && $whitelabel['is_whitelabel'] === true && !empty($whitelabel['company_name'])) { return $whitelabel['company_name']; } return null; } /** * Get whitelabel OTTO name * Returns the custom OTTO name if set, otherwise returns 'OTTO' */ public static function get_whitelabel_otto_name() { $general_settings = self::get_option('general'); // Return custom OTTO name if set, otherwise fallback to 'OTTO' if (!empty($general_settings['whitelabel_otto_name'])) { return $general_settings['whitelabel_otto_name']; } return 'OTTO'; } /** * Check if the current user has access to the plugin based on role settings * * @return bool True if user has access, false otherwise */ public static function current_user_has_plugin_access() { $user = wp_get_current_user(); if (!$user || !$user->exists()) { return false; } // Administrators always have access if (in_array('administrator', (array) $user->roles)) { return true; } // Get the plugin access roles setting $general_options = self::get_option('general'); // If setting not configured, default to admin-only access if (!isset($general_options['plugin_access_roles'])) { return false; } $allowed_roles = $general_options['plugin_access_roles']; // If it's a string (single role), convert to array if (!is_array($allowed_roles)) { $allowed_roles = array($allowed_roles); } // If "all" is selected, allow access if (in_array('all', $allowed_roles)) { return true; } // If array is empty, deny access (only admins allowed) if (empty($allowed_roles)) { return false; } // Check if user has any of the allowed roles $user_roles = (array) $user->roles; return !empty(array_intersect($user_roles, $allowed_roles)); } /** * Get active JWT token for Search Atlas API authentication * Convenience method accessible from anywhere in the plugin * * @param bool $force_refresh Force generation of new token even if cached one exists * @return string|false JWT token on success, false on failure */ public static function get_jwt_token($force_refresh = false) { // Delegate to admin class method return Metasync_Admin::get_active_jwt_token($force_refresh); } /** * Get effective plugin name * Returns plugin name respecting white label settings * Priority: 1) white_label_plugin_name 2) company branding + base_name 3) base_name */ public static function get_effective_plugin_name($base_name = 'Search Atlas') { $general_settings = self::get_option('general'); // Priority 1: Use white_label_plugin_name if set and not empty if (!empty($general_settings['white_label_plugin_name'])) { return $general_settings['white_label_plugin_name']; } $whitelabel = self::get_whitelabel_settings(); // Priority 2: If whitelabel is enabled and company name is provided, enhance the plugin name if (isset($whitelabel['is_whitelabel']) && $whitelabel['is_whitelabel'] === true && !empty($whitelabel['company_name'])) { return $whitelabel['company_name'] . ' ' . $base_name; } // Priority 3: Return base_name as fallback return $base_name; } /** * Render a standalone info-icon tooltip (the same visual/JS pattern used by * get_field_tooltips() + render_accordion_sections() in Metasync_Settings_Fields). * Use this on any admin page whose fields are NOT rendered through that * accordion field-loop (custom render_callback pages, standalone view files) — * the trigger/hover/positioning JS in admin/js/metasync-admin.js binds to * `.metasync-tooltip-trigger` globally, so no extra wiring is needed as long * as this markup is present on a page where metasync-admin.js is enqueued * (i.e. any admin page under this plugin's menu). * * @param string $tooltip_id Unique id for this tooltip (unique per page). * @param string $text Plain-English help text (escaped internally). */ public static function render_tooltip_icon($tooltip_id, $text) { echo self::get_tooltip_icon_html($tooltip_id, $text); } /** * Same tooltip markup as render_tooltip_icon(), but RETURNS the HTML string * instead of echoing it. Use this when the tooltip needs to be concatenated * into another string — e.g. appended to the $title argument of * add_settings_field(), which WordPress core echoes raw next to the label. * * The whole trigger+popup pair is wrapped in its own small * `position: relative` anchor span. The popup CSS (.metasync-tooltip) is * `position: absolute; left: 100%` and positions itself relative to the * nearest positioned ancestor — on the main settings accordion that's the * `.metasync-field-label-wrapper` div, but standalone pages (custom * render_callback templates, add_settings_field titles on plain * do_settings_sections() pages, etc.) usually have no such ancestor, so * the popup would escape to whatever distant positioned element exists * on the page (rendering in the wrong corner of the screen). Wrapping * here makes every tooltip self-contained regardless of where it's placed. * * @param string $tooltip_id Unique id for this tooltip (unique per page). * @param string $text Plain-English help text (escaped internally). * @return string HTML markup for the info-icon trigger + tooltip content. */ public static function get_tooltip_icon_html($tooltip_id, $text) { $html = ''; $html .= ''; $html .= ''; $html .= ''; return $html; } /** * Centralized API Key Event Logging * Provides structured logging for all API key related events with consistent formatting * * @since 1.0.0 * @param string $event_type Type of event (change, refresh, reset, etc.) * @param string $api_key_type Type of API key (plugin_auth_token, searchatlas_api_key) * @param array $details Additional details about the event * @param string $level Log level (info, warning, error) */ public static function log_api_key_event($event_type, $api_key_type, $details = array(), $level = 'info') { try { // Build structured log entry $log_data = array( 'timestamp' => current_time('mysql'), 'event_type' => $event_type, 'api_key_type' => $api_key_type, 'level' => $level ); // Add details if provided if (!empty($details)) { $log_data['details'] = $details; } // Format log message with consistent structure $log_prefix = strtoupper($level) . ' - MetaSync API Key Event'; $log_message = sprintf('[%s] %s: %s (%s)', $log_data['timestamp'], $log_prefix, $event_type, $api_key_type ); // Add details to log message if present if (!empty($details)) { $formatted_details = array(); foreach ($details as $key => $value) { $formatted_details[] = $key . ': ' . (is_string($value) ? $value : json_encode($value)); } $log_message .= ' - ' . implode(', ', $formatted_details); } // Optionally store in database for admin dashboard (future enhancement) // This could be extended to store in a dedicated log table } catch (Exception $e) { // Fallback logging if structured logging fails error_log('MetaSync API Key Event Logging Error: ' . $e->getMessage()); } } /** * Handle 404 error monitoring */ public function handle_404_monitoring() { // Only process on frontend if (is_admin()) { return; } // Check if this is a 404 error if (!is_404()) { return; } // PROTECTION 0: Skip WordPress system paths — these are not "broken links" $request_uri = $_SERVER['REQUEST_URI'] ?? ''; $skip_prefixes = [ '/wp-json/', '/wp-admin/', '/feed/', '/xmlrpc.php', '/wp-login.php', '/wp-cron.php', ]; foreach ($skip_prefixes as $prefix) { if (stripos($request_uri, $prefix) === 0) { return; } } // PROTECTION 1: Exclude static assets to reduce noise $static_extensions = ['.css', '.js', '.jpg', '.jpeg', '.png', '.gif', '.ico', '.svg', '.woff', '.woff2', '.ttf', '.eot', '.map','.webp']; foreach ($static_extensions as $ext) { if (stripos($request_uri, $ext) !== false) { return; // Skip logging static asset 404s } } // PROTECTION 2: Bot detection - Block known bot patterns $user_agent = $_SERVER['HTTP_USER_AGENT'] ?? ''; $bot_patterns = ['bot', 'crawler', 'spider', 'scraper', 'curl', 'wget', 'python', 'java']; foreach ($bot_patterns as $pattern) { if (stripos($user_agent, $pattern) !== false) { // Rate limit bot 404s more aggressively $bot_rate_key = 'metasync_404_bot_rate'; $bot_hits = get_transient($bot_rate_key); if ($bot_hits !== false && $bot_hits >= 10) { // Bot has hit 10+ 404s in last minute - stop logging return; } set_transient($bot_rate_key, $bot_hits === false ? 1 : $bot_hits + 1, 60); break; } } // PROTECTION 3: Global rate limiting - Prevent 404 logging storms $global_rate_key = 'metasync_404_global_rate'; $global_hits = get_transient($global_rate_key); if ($global_hits !== false && $global_hits >= 50) { // More than 50 404s per minute - stop logging to protect database if ($global_hits === 50) { error_log('MetaSync 404 Monitor: Rate limit exceeded - 50+ 404s per minute. Pausing logging.'); } set_transient($global_rate_key, $global_hits + 1, 60); return; } set_transient($global_rate_key, $global_hits === false ? 1 : $global_hits + 1, 60); // Get current URL $current_url = $this->get_current_url(); // PROTECTION 4: Per-URL caching - Prevent same URL from being logged repeatedly $url_cache_key = 'metasync_404_cached_' . md5($current_url); if (get_transient($url_cache_key)) { // This URL was already logged in last 5 minutes - skip DB write return; } // PROTECTION 5: URL validation - Skip obviously malicious URLs if (strlen($current_url) > 500 || preg_match('/[<>{}\\\\|]/', $current_url)) { return; // Skip potentially malicious or malformed URLs } // Initialize 404 monitor database require_once plugin_dir_path(dirname(__FILE__)) . '404-monitor/class-metasync-404-monitor-database.php'; $db_404 = new Metasync_Error_Monitor_Database(); // Get user agent (sanitized) $user_agent = isset($_SERVER['HTTP_USER_AGENT']) ? sanitize_text_field($_SERVER['HTTP_USER_AGENT']) : ''; // Log the 404 error $result = $db_404->update([ 'uri' => $current_url, 'user_agent' => $user_agent, 'date_time' => current_time('mysql'), 'hits_count' => 1 ]); // Cache this URL for 5 minutes to prevent repeated DB writes set_transient($url_cache_key, true, 300); } /** * Get current URL */ private function get_current_url() { $protocol = is_ssl() ? 'https://' : 'http://'; // Safely get HTTP_HOST with fallback $host = isset($_SERVER['HTTP_HOST']) ? $_SERVER['HTTP_HOST'] : ''; if (empty($host) && isset($_SERVER['SERVER_NAME'])) { $host = $_SERVER['SERVER_NAME']; } if (empty($host)) { // Fallback to WordPress site URL if available $host = parse_url(home_url(), PHP_URL_HOST); } // Safely get REQUEST_URI with fallback $uri = isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '/'; // Decode URL-encoded characters $uri = urldecode($uri); // Ensure URI starts with / if (!str_starts_with($uri, '/')) { $uri = '/' . $uri; } return $protocol . $host . $uri; } /** * Run the loader to execute all of the hooks with WordPress. * * @since 1.0.0 */ public function run() { $this->loader->run(); } /** * The name of the plugin used to uniquely identify it within the context of * WordPress and to define internationalization functionality. * * @since 1.0.0 * @return string The name of the plugin. */ public function get_plugin_name() { return $this->plugin_name; } /** * The reference to the class that orchestrates the hooks with the plugin. * * @since 1.0.0 * @return Metasync_Loader Orchestrates the hooks of the plugin. */ public function get_loader() { return $this->loader; } /** * Retrieve the version number of the plugin. * * @since 1.0.0 * @return string The version number of the plugin. */ public function get_version() { return $this->version; } }