PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.21
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.21
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 2.6.4 2.6.3 2.5.23 All 138 releases
metasync / includes / class-metasync.php

class-metasync.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.6.21, at includes/class-metasync.php

1,655 lines 58.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 // If this file is called directly, abort.
3 if (!defined('ABSPATH')) {
4 exit;
5 }
6
7
8 /**
9 * The file that defines the core plugin class
10 *
11 * A class definition that includes attributes and functions used across both the
12 * public-facing side of the site and the admin area.
13 *
14 * @link https://searchatlas.com
15 * @since 1.0.0
16 *
17 * @package Metasync
18 * @subpackage Metasync/includes
19 */
20
21 /**
22 * The core plugin class.
23 *
24 * This is used to define internationalization, admin-specific hooks, and
25 * public-facing site hooks.
26 *
27 * Also maintains the unique identifier of this plugin as well as the current
28 * version of the plugin.
29 *
30 * @since 1.0.0
31 * @package Metasync
32 * @subpackage Metasync/includes
33 * @author Engineering Team <support@searchatlas.com>
34 */
35 class Metasync
36 {
37
38 /**
39 * The loader that's responsible for maintaining and registering all hooks that power
40 * the plugin.
41 *
42 * @since 1.0.0
43 * @access protected
44 * @var Metasync_Loader $loader Maintains and registers all hooks for the plugin.
45 */
46 protected $loader;
47
48 /**
49 * The unique identifier of this plugin.
50 *
51 * @since 1.0.0
52 * @access protected
53 * @var string $plugin_name The string used to uniquely identify this plugin.
54 */
55 protected $plugin_name;
56
57 /**
58 * The current version of the plugin.
59 *
60 * @since 1.0.0
61 * @access protected
62 * @var string $version The current version of the plugin.
63 */
64 protected $version;
65
66 protected $database;
67
68 protected $db_redirection;
69
70 protected $db_heartbeat_errors;
71
72
73
74 public const option_name = "metasync_options";
75
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 /**
86 * Search Atlas Domain Constants
87 * Centralized constants for all Search Atlas service endpoints
88 */
89 public const HOMEPAGE_DOMAIN = "https://searchatlas.com";
90 public const DASHBOARD_DOMAIN = "https://dashboard.searchatlas.com";
91 public const API_DOMAIN = "https://api.searchatlas.com";
92 public const CA_API_DOMAIN = "https://ca.searchatlas.com";
93 public const SUPPORT_EMAIL = "support@searchatlas.com";
94 public const DOCUMENTATION_DOMAIN = "https://help.searchatlas.com";
95
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 /**
116 * Define the core functionality of the plugin.
117 *
118 * Set the plugin name and the plugin version that can be used throughout the plugin.
119 * Load the dependencies, define the locale, and set the hooks for the admin area and
120 * the public-facing side of the site.
121 *
122 * @since 1.0.0
123 */
124 public function __construct()
125 {
126 if (defined('METASYNC_VERSION')) {
127 $this->version = METASYNC_VERSION;
128 } else {
129 $this->version = '1.0.0';
130 }
131 $this->plugin_name = 'metasync';
132
133 $this->load_dependencies();
134 // $this->set_locale(); // Language support removed - using default only
135 $this->init_api_key_monitor();
136 $this->define_admin_hooks();
137 $this->define_public_hooks();
138 }
139
140 /**
141 * Load the required dependencies for this plugin.
142 *
143 * Include the following files that make up the plugin:
144 *
145 * - Metasync_Loader. Orchestrates the hooks of the plugin.
146 * - Metasync_i18n. Defines internationalization functionality.
147 * - Metasync_Admin. Defines all hooks for the admin area.
148 * - Metasync_Public. Defines all hooks for the public side of the site.
149 *
150 * Create an instance of the loader which will be used to register the hooks
151 * with WordPress.
152 *
153 * @since 1.0.0
154 * @access private
155 */
156 private function load_dependencies()
157 {
158 // WordPress core — cannot be autoloaded.
159 require_once ABSPATH . 'wp-admin/includes/taxonomy.php';
160
161 // Procedural init file — not a class, must stay explicit.
162 if (file_exists(plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php')) {
163 require_once plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php';
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');
166 }
167
168 // Admin navigation is referenced statically from frontend-reachable
169 // includes (heartbeat/connect managers). Require it explicitly here so
170 // the static call never fatals when wp_head fires before autoload.
171 if (!class_exists('Metasync_Admin_Navigation')) {
172 require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-admin-navigation.php';
173 }
174
175 $this->loader = new Metasync_Loader();
176 $this->db_heartbeat_errors = new Metasync_HeartBeat_Error_Monitor_Database();
177 $this->db_redirection = new Metasync_Redirection_Database();
178 }
179
180 /**
181 * Define the locale for this plugin for internationalization.
182 *
183 * Uses the Metasync_i18n class in order to set the domain and to register the hook
184 * with WordPress.
185 *
186 * @since 1.0.0
187 * @access private
188 */
189 // Language support removed - using default only
190 /*
191 private function set_locale()
192 {
193 $plugin_i18n = new Metasync_i18n();
194
195 $this->loader->add_action('plugins_loaded', $plugin_i18n, 'load_plugin_textdomain');
196 }
197 */
198
199 /**
200 * Initialize the API Key Monitor for comprehensive API key change detection
201 *
202 * @since 1.0.0
203 * @access private
204 */
205 private function init_api_key_monitor()
206 {
207 // Initialize the singleton instance of the API Key Monitor
208 // This will automatically set up hooks to monitor all API key changes
209 Metasync_API_Key_Monitor::get_instance();
210
211 // Log successful initialization
212 #commented out to stop appending this to error.php
213 # error_log('MetaSync: API Key Monitor initialized successfully');
214 }
215
216 /**
217 * Register all of the hooks related to the admin area functionality
218 * of the plugin.
219 *
220 * @since 1.0.0
221 * @access private
222 */
223 private function define_admin_hooks()
224 {
225
226 $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
227
228 // Initialize HTML Visual Editor
229 $html_visual_editor = new Metasync_HTML_Visual_Editor($this->get_plugin_name(), $this->get_version());
230 $html_visual_editor->init();
231
232 // Initialize OTTO Debug class for developers
233 if (class_exists('Metasync_Otto_Debug')) {
234 $otto_debug = new Metasync_Otto_Debug($this->get_plugin_name(), $this->get_version());
235 }
236
237 // Initialize SEO Sidebar for Gutenberg Block Editor
238 if (class_exists('Metasync_SEO_Sidebar')) {
239 new Metasync_SEO_Sidebar($this->get_version());
240 }
241
242 // Initialize Internal Link Suggestions for Gutenberg Block Editor
243 if (class_exists('Metasync_Link_Suggestions')) {
244 new Metasync_Link_Suggestions();
245 }
246
247 $this->loader->add_action('admin_enqueue_scripts', $plugin_admin, 'enqueue_styles');
248 $this->loader->add_action('admin_enqueue_scripts', $plugin_admin, 'enqueue_scripts');
249
250 # Redirection import AJAX handler
251 $redirection_handler = new Metasync_Redirection($this->db_redirection);
252 $this->loader->add_action('wp_ajax_metasync_import_redirections', $redirection_handler, 'handle_import_ajax');
253 $this->loader->add_action('wp_ajax_metasync_check_redirects_health', $redirection_handler, 'handle_health_check_ajax');
254
255 // HeartBeat API Receive Respond and Settings.
256 $this->loader->add_action('heartbeat_settings', $plugin_admin, 'metasync_heartbeat_settings');
257 $this->loader->add_action('heartbeat_received', $plugin_admin, 'metasync_received_data', 10, 2);
258 $this->loader->add_action('wp_ajax_metasync_send_customer_params', $plugin_admin, 'lgSendCustomerParams');
259
260 // Search Atlas Connect endpoints - authenticates with Search Atlas platform to retrieve SA API key and Otto UUID
261 $this->loader->add_action('wp_ajax_metasync_generate_connect_url', $plugin_admin, 'generate_searchatlas_connect_url');
262 $this->loader->add_action('wp_ajax_metasync_check_connect_status', $plugin_admin, 'check_searchatlas_connect_status');
263 $this->loader->add_action('wp_ajax_metasync_reset_authentication', $plugin_admin, 'reset_searchatlas_authentication');
264
265 // Auto-update filter
266 $this->loader->add_filter('auto_update_plugin', $plugin_admin, 'control_plugin_auto_updates', 10, 2);
267
268 // Search Atlas Connect development/testing endpoints
269 $this->loader->add_action('wp_ajax_metasync_test_enhanced_tokens', $plugin_admin, 'test_enhanced_searchatlas_tokens');
270 $this->loader->add_action('wp_ajax_metasync_test_whitelabel_domain', $plugin_admin, 'test_whitelabel_domain');
271 $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
274
275 $post_meta_setting = new Metasync_Post_Meta_Settings();
276 $this->loader->add_action('admin_init', $post_meta_setting, 'add_post_meta_data', 2);
277 $this->loader->add_action('admin_init', $post_meta_setting, 'show_top_admin_bar', 9);
278
279 // SEO Health CSV export: must run on admin_init (before output).
280 // Cheap $_GET check avoids loading the class on every admin page.
281 if (
282 isset($_GET['page'], $_GET['export'], $_GET['_wpnonce']) &&
283 $_GET['export'] === 'csv' &&
284 strpos($_GET['page'], '-seo-health') !== false
285 ) {
286 $this->loader->add_action('admin_init', Metasync_SEO_Health::get_instance(), 'handle_csv_export', 1);
287 }
288 $this->loader->add_action('wp', $post_meta_setting, 'show_top_admin_bar', 9);
289
290 // SEO meta columns on the posts/pages list tables (WP-624).
291 // Registered for both the `posts` and `pages` variants of each hook so the
292 // columns reach every supported post type; the callbacks bail on unsupported
293 // ones. Hidden by default — users opt in from Screen Options.
294 $seo_columns = Metasync_SEO_Columns::get_instance();
295 $this->loader->add_filter('manage_posts_columns', $seo_columns, 'add_columns', 10, 2);
296 $this->loader->add_filter('manage_pages_columns', $seo_columns, 'add_columns', 10, 1);
297 $this->loader->add_action('manage_posts_custom_column', $seo_columns, 'render_column', 10, 2);
298 $this->loader->add_action('manage_pages_custom_column', $seo_columns, 'render_column', 10, 2);
299 $this->loader->add_filter('default_hidden_columns', $seo_columns, 'default_hidden_columns', 10, 2);
300 $this->loader->add_filter('hidden_columns', $seo_columns, 'hidden_columns', 10, 3);
301 $this->loader->add_action('admin_head', $seo_columns, 'print_styles');
302 $this->loader->add_action('admin_notices', $seo_columns, 'companion_plugin_notice');
303
304 // Initialize XML Sitemap auto-update hooks if enabled
305 // Note: Must not be gated by is_admin() because Gutenberg saves posts
306 // via the REST API where is_admin() returns false, and REST_REQUEST
307 // is not yet defined at plugin load time
308 if (get_option('metasync_sitemap_auto_update', false)) {
309 $sitemap_generator = new Metasync_Sitemap_Generator();
310 $sitemap_generator->setup_auto_update_hooks();
311 }
312 // Initialize Schema Markup functionality
313 $schema_markup = new Metasync_Schema_Markup($this->get_plugin_name(), $this->get_version());
314 $this->loader->add_action('wp_ajax_metasync_get_schema_fields', $schema_markup, 'ajax_get_schema_fields');
315 $this->loader->add_action('wp_ajax_metasync_preview_schema', $schema_markup, 'ajax_preview_schema');
316
317 // Initialize Breadcrumbs functionality
318 if (class_exists('Metasync_Breadcrumbs')) {
319 new Metasync_Breadcrumbs($this->get_plugin_name(), $this->get_version());
320 }
321 if (class_exists('Metasync_Breadcrumbs_Schema')) {
322 new Metasync_Breadcrumbs_Schema($this->get_plugin_name(), $this->get_version());
323 }
324
325 // Initialize Developer Panel (for endpoint switching)
326 if (class_exists('Metasync_Dev_Panel')) {
327 $dev_panel = new Metasync_Dev_Panel($this->get_plugin_name(), $this->get_version());
328 }
329
330 // Initialize Site Health integration
331 if (class_exists('Metasync_Site_Health')) {
332 $site_health = new Metasync_Site_Health();
333 $site_health->register_tests();
334 }
335
336 // Initialize endpoint URL filtering for staging mode
337 $this->init_endpoint_filtering();
338
339 }
340
341 /**
342 * Register all of the hooks related to the public-facing functionality
343 * of the plugin.
344 *
345 * @since 1.0.0
346 * @access private
347 */
348 private function define_public_hooks()
349 {
350 // Header and Footer code snippets
351 $code_snippets = new Metasync_Code_Snippets();
352
353 $this->loader->add_action('wp_head', $code_snippets, 'get_header_snippet');
354 $this->loader->add_action('wp_footer', $code_snippets, 'get_footer_snippet');
355
356 $plugin_public = new Metasync_Public($this->get_plugin_name(), $this->get_version());
357 $rest_api = $plugin_public->get_rest_api();
358 $seo_output = $plugin_public->get_seo_output();
359 $get_plugin_basename = sprintf('%1$s/%1$s.php', $this->plugin_name);
360
361 // Asset enqueue hooks (Metasync_Public)
362 $this->loader->add_action('wp_enqueue_scripts', $plugin_public, 'enqueue_styles');
363 $this->loader->add_action('wp_enqueue_scripts', $plugin_public, 'enqueue_scripts');
364 $this->loader->add_action('wp_enqueue_scripts', $plugin_public, 'enqueue_page_custom_css', 999);
365
366 // Elementor editor CSS injection
367 if (class_exists('\Elementor\Plugin')) {
368 $this->loader->add_action('elementor/preview/enqueue_styles', $plugin_public, 'enqueue_elementor_editor_css', 999);
369 }
370
371 // Divi builder CSS injection
372 if (function_exists('et_setup_theme')) {
373 $this->loader->add_action('wp_enqueue_scripts', $plugin_public, 'enqueue_divi_builder_css', 999);
374 }
375
376 // Initialize centralized SEO conflict handler (singleton — suppresses
377 // third-party SEO plugin descriptions when MetaSync provides its own).
378 Metasync_SEO_Conflict_Handler::get_instance();
379
380 // Term-level SEO plugin sync: propagate MetaSync term meta (category/tag
381 // archives) into Yoast/Rank Math/AIOSEO term storage on every write.
382 $this->loader->add_action('updated_term_meta', $this, 'on_term_meta_updated', 10, 4);
383 $this->loader->add_action('added_term_meta', $this, 'on_term_meta_updated', 10, 4);
384
385 // Post-level plugin sync: propagate MetaSync post meta into
386 // Yoast/Rank Math/AIOSEO post storage on every write.
387 $this->loader->add_action('updated_post_meta', $this, 'on_post_meta_updated', 10, 4);
388 $this->loader->add_action('added_post_meta', $this, 'on_post_meta_updated', 10, 4);
389 // Deletes matter too: clearing the last checkbox in a meta box removes
390 // the meta row, which fires neither hook above.
391 $this->loader->add_action('deleted_post_meta', $this, 'on_post_meta_deleted', 10, 3);
392
393 // SEO Output hooks (Metasync_Seo_Output)
394 $this->loader->add_action('wp_head', $seo_output, 'hook_metasync_metatags', 1, 1);
395 // LocalBusiness / Organization / Person JSON-LD from the Local Business page.
396 $this->loader->add_action('wp_head', $seo_output, 'output_local_business_schema', 2, 1);
397 $this->loader->add_action('template_redirect', $seo_output, 'inject_archive_seo_controls');
398
399 // Hreflang / language alternates output (wp_head @ priority 2).
400 $plugin_hreflang = new Metasync_Hreflang_Output();
401 $this->loader->add_action('wp_head', $plugin_hreflang, 'output_hreflang_tags', 2);
402
403 // Edge Cache: detect Cloudways Varnish and persist for settings UI
404 $this->loader->add_action('init', 'Metasync_Edge_Cache_Purge', 'detect_cloudways');
405
406 // Sitemap exclusions for disabled archive types
407 $this->loader->add_filter('wp_sitemaps_taxonomies', $seo_output, 'filter_sitemap_taxonomies');
408 $this->loader->add_filter('wp_sitemaps_users_entry', $seo_output, 'filter_sitemap_users', 10, 2);
409 $this->loader->add_filter('wp_sitemaps_add_provider', $seo_output, 'filter_sitemap_providers', 10, 2);
410 $this->loader->add_filter('wp_sitemaps_index_entry', $seo_output, 'filter_sitemap_index_entries', 10, 4);
411
412 // AMP cleanup functionality - remove metasync_optimized attribute from head on AMP pages
413 $this->loader->add_action('template_redirect', $seo_output, 'cleanup_amp_head_attribute', 1);
414 $this->loader->add_action('wp_footer', $seo_output, 'end_amp_head_cleanup', 999);
415
416 // Redirection functionality
417 $redirection = new Metasync_Redirection($this->db_redirection);
418 $this->loader->add_action('template_redirect', $redirection, 'handle_template_redirect', 5);
419
420 # Prevent WordPress from redirecting to draft posts via redirect_canonical
421 $this->loader->add_filter('redirect_canonical', $redirection, 'prevent_draft_post_redirects', 10, 2);
422
423 # Prevent WordPress old slug redirects to unpublished posts only
424 $this->loader->add_filter('old_slug_redirect_post_id', $redirection, 'prevent_old_slug_redirect_to_drafts', 10, 1);
425
426 // Auto-redirect on slug change - creates 301 redirect when post/page slug is changed
427 $auto_redirect = new Metasync_Auto_Redirect($this->db_redirection);
428 $auto_redirect->init();
429
430 # Custom HTML Pages functionality
431 # No additional loader hooks needed - class registers its own hooks
432 $custom_pages = new Metasync_Custom_Pages();
433
434 // 404 Error monitoring
435 $this->loader->add_action('template_redirect', $this, 'handle_404_monitoring', 10);
436 $this->loader->add_action('plugin_action_links_' . $get_plugin_basename, $plugin_public, 'metasync_plugin_links');
437
438 // REST API hooks (Metasync_Rest_Api)
439 $this->loader->add_action('rest_api_init', $rest_api, 'metasync_register_rest_routes');
440 // Coexist with third-party JWT auth plugins: clear their prior auth error
441 // for metasync/v1 requests when our own API key validates. The Tmeister
442 // "JWT Authentication for WP-API" plugin surfaces its jwt_auth_invalid_token
443 // 403 via rest_pre_dispatch (priority 10), so we hook the same filter at a
444 // later priority (11) to clear it for our namespace only.
445 $this->loader->add_filter('rest_pre_dispatch', $rest_api, 'allow_metasync_rest_auth', 11, 3);
446 // Coexist with site-wide "authenticated users only" REST restrictions.
447 // Those hook rest_authentication_errors, which WP applies before dispatch,
448 // so rest_pre_dispatch and permission_callback never run. Hook it late
449 // (99) to clear the error for metasync/v1 requests that present a valid
450 // plugin API key; every other route keeps the site's restriction.
451 $this->loader->add_filter('rest_authentication_errors', $rest_api, 'allow_metasync_rest_authentication', 99, 1);
452 $this->loader->add_action('init', $plugin_public, 'metasync_plugin_init', 5);
453 $this->loader->add_action('wp_ajax_metasync_lglogin', $rest_api, 'linkgraph_login');
454
455 // Robots meta filter (Metasync_Seo_Output)
456 $this->loader->add_filter('wp_robots', $seo_output, 'metasync_wp_robots_meta');
457
458
459
460 $metasyncTemplateClass = new Metasync_Template();
461 $this->loader->add_filter('theme_page_templates', $metasyncTemplateClass, 'metasync_template_landing_page', 10, 3);
462 $this->loader->add_filter('template_include', $metasyncTemplateClass, 'metasync_template_landing_page_load', 99 );
463 $templateCrawler = new MetaSyncHiddenPostManager(); # initialize the crawler class
464
465 $this->loader->add_action('wp_trash_post', $templateCrawler , 'prevent_post_deletion'); # Prevent post deletion when moved to trash
466 $this->loader->add_action('before_delete_post', $templateCrawler , 'prevent_post_deletion'); # Prevent permanent deletion
467 # $this->loader->add_filter('metasync_hidden_post_manager', $templateCrawler , 'init'); # run the crawler
468 # Hidden post manager now runs via cron instead of filter (to avoid interfering with post create/update)
469 $this->loader->add_action('metasync_hidden_post_check', $templateCrawler , 'init'); # run the crawler via cron
470
471 // Open Graph and Social Media Tags
472 $opengraph = new Metasync_OpenGraph($this->get_plugin_name(), $this->get_version());
473 $opengraph->init();
474
475 # Save current theme info to database (safe context - admin/init hooks)
476 $this->loader->add_action('after_switch_theme', $this, 'save_current_theme_info');
477 $this->loader->add_action('admin_init', $this, 'ensure_theme_info_saved');
478
479 // OTTO Frontend Toolbar
480 $otto_toolbar = new Metasync_Otto_Frontend_Toolbar($this->get_plugin_name(), $this->get_version());
481 $this->loader->add_action('wp_enqueue_scripts', $otto_toolbar, 'enqueue_styles');
482 $this->loader->add_action('wp_enqueue_scripts', $otto_toolbar, 'enqueue_scripts');
483 $this->loader->add_action('admin_bar_menu', $otto_toolbar, 'add_admin_bar_menu', 100);
484 $this->loader->add_action('wp_footer', $otto_toolbar, 'render_debug_bar', 999);
485
486 // Initialize Sitemap Generator on frontend (for virtual sitemap serving)
487 $sitemap_generator = new Metasync_Sitemap_Generator();
488
489 // Initialize LLMs.txt Generator (for virtual /llms.txt and /llms-full.txt serving)
490 require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-html-to-markdown.php';
491 require_once plugin_dir_path(dirname(__FILE__)) . 'llms-txt/class-metasync-llms-txt-generator.php';
492 $llms_txt_generator = new Metasync_Llms_Txt_Generator();
493
494 // Serve the IndexNow key file virtually at /{key}.txt so it works
495 // on read-only web roots and nginx hosts that 403 direct static .txt access.
496 require_once plugin_dir_path(dirname(__FILE__)) . 'bing-index/class-metasync-bing-instant-index.php';
497 add_action('template_redirect', array('Metasync_Bing_Instant_Index', 'serve_virtual_key_file'), 0);
498
499 // One-time upgrade: regenerate sitemap to remove any Beaver Builder template entries
500 if ( ! get_option( 'metasync_sitemap_bb_exclusion_applied' ) ) {
501 $this->loader->add_action('init', $this, 'maybe_regenerate_sitemap_after_upgrade');
502 }
503 }
504
505 /**
506 * One-time upgrade routine: regenerate the XML sitemap so that Beaver Builder
507 * template post types (fl-builder-template, fl-theme-layout) that were already
508 * present in previously-generated sitemaps are purged.
509 *
510 * Runs once on 'init' and sets a flag so it never runs again.
511 *
512 * @since 1.0.0
513 */
514 public function maybe_regenerate_sitemap_after_upgrade() {
515 $done_key = 'metasync_sitemap_bb_exclusion_applied';
516 if ( get_option( $done_key ) ) {
517 return;
518 }
519
520 // Only regenerate if the custom sitemap feature is actually in use.
521 if ( get_option( 'metasync_sitemap_auto_update', false ) || file_exists( ABSPATH . 'sitemap_index.xml' ) ) {
522 if ( ! class_exists( 'Metasync_Sitemap_Generator' ) ) {
523 require_once plugin_dir_path( dirname( __FILE__ ) ) . 'sitemap/class-metasync-sitemap-generator.php';
524 }
525 $sitemap = new Metasync_Sitemap_Generator();
526 $sitemap->generate_sitemap();
527 update_option( $done_key, true );
528 }
529 // If sitemap is not in use, don't set the flag — retry on next load
530 // so that enabling sitemaps later will still clean up BB templates.
531 }
532
533 /**
534 * Initialize endpoint URL filtering for staging mode
535 * Intercepts HTTP requests and replaces production URLs with staging URLs
536 */
537 private function init_endpoint_filtering() {
538 // Only add filter if Endpoint Manager is available and staging mode is active
539 if (!class_exists('Metasync_Endpoint_Manager') || !Metasync_Endpoint_Manager::is_staging_mode()) {
540 return;
541 }
542
543 // Add filter to intercept HTTP requests before they're sent
544 add_filter('pre_http_request', array($this, 'filter_http_request_urls'), 10, 3);
545 }
546
547 /**
548 * Filter HTTP request URLs to replace production endpoints with staging
549 *
550 * @param false|array|WP_Error $preempt Whether to preempt an HTTP request's return value.
551 * @param array $args HTTP request arguments.
552 * @param string $url The request URL.
553 * @return false|array|WP_Error
554 */
555 public function filter_http_request_urls($preempt, $args, $url) {
556 // Only process if we're not preempting the request
557 if ($preempt !== false) {
558 return $preempt;
559 }
560
561 // Only process if staging mode is active
562 if (!class_exists('Metasync_Endpoint_Manager') || !Metasync_Endpoint_Manager::is_staging_mode()) {
563 return $preempt;
564 }
565
566 // Define URL replacements (production => staging)
567 $url_replacements = array(
568 'https://dashboard.searchatlas.com' => 'https://dashboard.staging.searchatlas.com',
569 'https://api.searchatlas.com' => 'https://api.staging.searchatlas.com',
570 'https://ca.searchatlas.com' => 'https://ca.staging.searchatlas.com',
571 'https://sa.searchatlas.com' => 'https://sa.staging.searchatlas.com',
572 );
573
574 // Check if URL needs to be replaced
575 $original_url = $url;
576 foreach ($url_replacements as $production => $staging) {
577 if (strpos($url, $production) === 0) {
578 $url = str_replace($production, $staging, $url);
579 error_log("MetaSync Endpoint Filter: Replaced {$production} with {$staging} in URL: {$original_url}");
580 break;
581 }
582 }
583
584 // If URL was changed, modify the args and make the request ourselves
585 if ($url !== $original_url) {
586 // Make the request with the modified URL
587 return wp_remote_request($url, $args);
588 }
589
590 return $preempt;
591 }
592
593 /**
594 * Term meta update hook: mirror MetaSync term meta (`_metasync_*`)
595 * into the active third-party SEO plugins' term storage.
596 *
597 * Registered on both `updated_term_meta` and `added_term_meta` so new
598 * fields are synced the first time they are written as well as on
599 * subsequent updates.
600 *
601 * @param int $meta_id Meta row ID (unused).
602 * @param int $object_id Term ID.
603 * @param string $meta_key Meta key being written.
604 * @param mixed $meta_value Meta value being written.
605 */
606 public function on_term_meta_updated($meta_id, $object_id, $meta_key, $meta_value) {
607 if (strncmp($meta_key, '_metasync_', 10) !== 0) {
608 return;
609 }
610
611 if (!class_exists('Metasync_Term_Plugin_Sync')) {
612 return;
613 }
614
615 $term = get_term((int) $object_id);
616 if (!$term || is_wp_error($term)) {
617 return;
618 }
619
620 $canonical_map = [
621 '_metasync_metatitle' => 'title',
622 '_metasync_metadesc' => 'desc',
623 '_metasync_robots_index' => 'noindex',
624 '_metasync_canonical_url' => 'canonical',
625 '_metasync_og_title' => 'og_title',
626 '_metasync_og_description' => 'og_desc',
627 '_metasync_og_image' => 'og_image',
628 '_metasync_twitter_title' => 'twitter_title',
629 '_metasync_twitter_description' => 'twitter_desc',
630 ];
631
632 if (!isset($canonical_map[$meta_key])) {
633 return;
634 }
635
636 $canonical_key = $canonical_map[$meta_key];
637
638 Metasync_Term_Plugin_Sync::get_instance()->sync_term(
639 (int) $object_id,
640 (string) $term->taxonomy,
641 [$canonical_key => $meta_value]
642 );
643 }
644
645 /**
646 * Post meta update hook: mirror MetaSync post meta (`_metasync_*`)
647 * into the active third-party SEO plugins' post storage.
648 *
649 * Registered on both `updated_post_meta` and `added_post_meta` so new
650 * fields are synced the first time they are written as well as on
651 * subsequent updates.
652 *
653 * @param int $meta_id Meta row ID (unused).
654 * @param int $post_id Post ID.
655 * @param string $meta_key Meta key being written.
656 * @param mixed $meta_value Meta value being written.
657 */
658 public function on_post_meta_updated($meta_id, $post_id, $meta_key, $meta_value) {
659 if (!class_exists('Metasync_Plugin_Sync')) {
660 return;
661 }
662
663 Metasync_Plugin_Sync::get_instance()->on_meta_updated($meta_id, $post_id, $meta_key, $meta_value);
664 }
665
666 /**
667 * Bridge deleted_post_meta to the sync layer.
668 *
669 * Clearing the last checkbox in a meta box deletes its meta row rather than
670 * updating it, so the update hooks above never fire and mirrored values can
671 * go stale. Only the legacy robots keys are acted on; see
672 * Metasync_Plugin_Sync::on_meta_deleted().
673 *
674 * @param array $meta_ids Meta row IDs (unused).
675 * @param int $post_id Post ID.
676 * @param string $meta_key Meta key being deleted.
677 */
678 public function on_post_meta_deleted($meta_ids, $post_id, $meta_key) {
679 if (!class_exists('Metasync_Plugin_Sync')) {
680 return;
681 }
682
683 Metasync_Plugin_Sync::get_instance()->on_meta_deleted($meta_ids, $post_id, $meta_key);
684 }
685
686 /**
687 * Save current theme information to MetaSync options
688 * This runs in WordPress admin context, not during REST API requests
689 * Safe to use wp_get_theme() here
690 */
691 public function save_current_theme_info() {
692 $theme = wp_get_theme();
693 $metasync_data = self::get_option();
694
695 if (!isset($metasync_data['general'])) {
696 $metasync_data['general'] = array();
697 }
698
699 $metasync_data['general']['current_theme_name'] = $theme->get('Name');
700 $metasync_data['general']['current_theme_template'] = $theme->get_template();
701 $metasync_data['general']['theme_info_updated'] = time();
702
703 self::set_option($metasync_data);
704 }
705
706 /**
707 * Ensure theme info is saved on admin_init if not already saved
708 * This ensures theme info is available even if theme wasn't switched
709 */
710 public function ensure_theme_info_saved() {
711 $metasync_data = self::get_option('general');
712
713 # Only run once per day to avoid overhead
714 if (empty($metasync_data['theme_info_updated']) ||
715 (time() - $metasync_data['theme_info_updated']) > 86400) {
716 $this->save_current_theme_info();
717 }
718 }
719
720 public static function get_option($key = null, $default = null)
721 {
722 $options = get_option(Metasync::option_name);
723 if (empty($options)) $options = [];
724 if ($key === null) return $options;
725 return $options[$key] ?? ($default !== null ? $default : null);
726 }
727
728 public static function set_option($data)
729 {
730 #return update_option(Metasync::option_name, $data);
731 $result = update_option(Metasync::option_name, $data);
732
733 // NEW: Structured error logging for database errors (only log if it's a real DB error)
734 global $wpdb;
735 if ($result === false && class_exists('Metasync_Error_Logger') && !empty($wpdb->last_error)) {
736 // Check if it's actually a database error (not just same value)
737 $saved_data = get_option(Metasync::option_name);
738 if ($saved_data !== $data) {
739 // Value is different but save failed - this is a real database error
740 Metasync_Error_Logger::log(
741 Metasync_Error_Logger::CATEGORY_DATABASE_ERROR,
742 Metasync_Error_Logger::SEVERITY_ERROR,
743 'Failed to save plugin main options to database',
744 [
745 'option_name' => Metasync::option_name,
746 'wpdb_error' => $wpdb->last_error,
747 'wpdb_last_query' => $wpdb->last_query,
748 'operation' => 'set_option',
749 'has_api_key' => !empty($data['general']['searchatlas_api_key'] ?? null),
750 'has_auth_token' => !empty($data['general']['apikey'] ?? null)
751 ]
752 );
753 }
754 }
755
756 return $result;
757 }
758
759 /**
760 * Derive the 32-byte AES key from existing WordPress salts.
761 *
762 * No new secret is stored anywhere — the key material is the concatenation
763 * of three WordPress salts, hashed to a fixed 32 bytes. If the salts change
764 * (e.g. wp-config regenerated) the derived key changes and previously
765 * encrypted values can no longer be decrypted, which is handled gracefully
766 * by the callers (re-authenticate state) rather than fataling.
767 *
768 * @return string 32 raw bytes.
769 */
770 private static function api_key_crypto_key()
771 {
772 $material = wp_salt('secure_auth') . wp_salt('logged_in') . wp_salt('nonce');
773 return hash('sha256', $material, true);
774 }
775
776 /**
777 * Determine whether a stored value is in the encrypted-at-rest format.
778 *
779 * @param mixed $value
780 * @return bool
781 */
782 public static function is_encrypted_api_key($value)
783 {
784 return is_string($value) && strncmp($value, self::API_KEY_ENC_PREFIX, strlen(self::API_KEY_ENC_PREFIX)) === 0;
785 }
786
787 /**
788 * Encrypt a plaintext Search Atlas API key for storage at rest.
789 *
790 * Uses AES-256-GCM (authenticated) with a random 12-byte IV. The IV, the
791 * 16-byte GCM tag and the ciphertext are concatenated and base64-encoded
792 * behind an `enc_v1:` prefix. An empty string is stored as-is (no key set).
793 *
794 * When OpenSSL is unavailable or encryption fails the plaintext is stored
795 * unchanged (availability over hard-fail) and the degradation is logged so
796 * it cannot go unnoticed.
797 *
798 * @param string $plaintext
799 * @return string Encrypted blob, or '' when $plaintext is empty.
800 */
801 public static function encrypt_api_key($plaintext)
802 {
803 $plaintext = (string) $plaintext;
804 if ($plaintext === '') {
805 return '';
806 }
807
808 // Already encrypted — do not double-encrypt.
809 if (self::is_encrypted_api_key($plaintext)) {
810 return $plaintext;
811 }
812
813 if (!function_exists('openssl_encrypt')) {
814 // OpenSSL unavailable — store plaintext rather than lose the key.
815 error_log('MetaSync: OpenSSL is unavailable — the Search Atlas API key was stored WITHOUT encryption at rest.');
816 return $plaintext;
817 }
818
819 $key = self::api_key_crypto_key();
820 $iv = random_bytes(12);
821 $tag = '';
822 $ciphertext = openssl_encrypt($plaintext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag, '', 16);
823
824 if ($ciphertext === false) {
825 // Encryption failed — fall back to plaintext storage, but say so.
826 error_log('MetaSync: API key encryption failed — the Search Atlas API key was stored WITHOUT encryption at rest.');
827 return $plaintext;
828 }
829
830 return self::API_KEY_ENC_PREFIX . base64_encode($iv . $tag . $ciphertext);
831 }
832
833 /**
834 * Decrypt a stored Search Atlas API key value.
835 *
836 * Accepts either the encrypted `enc_v1:` format or a legacy plaintext value
837 * (returned unchanged, supporting installs that pre-date encryption). On any
838 * decryption failure (salt change / corruption) returns false so callers can
839 * surface a re-authenticate state instead of using a bad key.
840 *
841 * @param mixed $value
842 * @return string|false Plaintext, or false when an encrypted value cannot be decrypted.
843 */
844 public static function decrypt_api_key($value)
845 {
846 if (!is_string($value) || $value === '') {
847 return '';
848 }
849
850 if (!self::is_encrypted_api_key($value)) {
851 // Legacy plaintext key.
852 return $value;
853 }
854
855 if (!function_exists('openssl_decrypt')) {
856 return false;
857 }
858
859 $raw = base64_decode(substr($value, strlen(self::API_KEY_ENC_PREFIX)), true);
860 if ($raw === false || strlen($raw) < 12 + 16 + 1) {
861 return false;
862 }
863
864 $iv = substr($raw, 0, 12);
865 $tag = substr($raw, 12, 16);
866 $ciphertext = substr($raw, 28);
867
868 $key = self::api_key_crypto_key();
869 $plaintext = openssl_decrypt($ciphertext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag);
870
871 if ($plaintext === false) {
872 return false;
873 }
874
875 return $plaintext;
876 }
877
878 /**
879 * Get the decrypted Search Atlas API key, memoized for the request.
880 *
881 * Decrypts at most once per request and never persists the decrypted value
882 * anywhere. When a legacy plaintext key is found it is migrated to the
883 * encrypted format in place (one-time migration on load). Returns:
884 * - the plaintext key (string, possibly '')
885 * - false when an encrypted value exists but cannot be decrypted
886 * (salts changed / corrupt) — callers should treat this as a
887 * "please re-authenticate" state.
888 *
889 * @return string|false
890 */
891 public static function get_searchatlas_api_key()
892 {
893 if (self::$memo_api_key !== null) {
894 return self::$memo_api_key;
895 }
896
897 $general = self::get_option('general');
898 $stored = is_array($general) ? ($general['searchatlas_api_key'] ?? '') : '';
899
900 // One-time migration: a non-empty legacy plaintext value is encrypted
901 // in place the first time it is read after this feature ships.
902 if ($stored !== '' && is_string($stored) && !self::is_encrypted_api_key($stored)) {
903 $encrypted = self::encrypt_api_key($stored);
904 if (self::is_encrypted_api_key($encrypted)) {
905 $options = self::get_option();
906 if (!is_array($options)) {
907 $options = [];
908 }
909 $options['general']['searchatlas_api_key'] = $encrypted;
910 self::set_option($options);
911 }
912 }
913
914 self::$memo_api_key = self::decrypt_api_key($stored);
915 return self::$memo_api_key;
916 }
917
918 /**
919 * Clear the request-scoped decrypted-key memo.
920 *
921 * Call after any write that changes the stored searchatlas_api_key so a
922 * subsequent read in the same request reflects the new value.
923 */
924 public static function invalidate_api_key_cache()
925 {
926 self::$memo_api_key = null;
927 }
928
929 /**
930 * Read the heartbeat throttle state from its dedicated option.
931 *
932 * Backfills from the legacy location (`metasync_options['general']`) the
933 * first time the dedicated option is empty, so existing installs keep
934 * their throttle history across the migration.
935 */
936 public static function get_heartbeat_throttle(): array
937 {
938 $value = get_option(self::heartbeat_throttle_option, []);
939 if (is_array($value) && !empty($value)) {
940 return $value;
941 }
942
943 $general = self::get_option('general');
944 if (is_array($general) && (array_key_exists('last_heart_beat', $general) || array_key_exists('last_heartbeat_at', $general))) {
945 $throttle = [
946 'last_heart_beat' => $general['last_heart_beat'] ?? 0,
947 'last_heartbeat_at' => $general['last_heartbeat_at'] ?? null,
948 ];
949 update_option(self::heartbeat_throttle_option, $throttle);
950 return $throttle;
951 }
952
953 return [];
954 }
955
956 /**
957 * Merge fields into the dedicated heartbeat throttle option.
958 *
959 * Writes via update_option directly so the main metasync_options blob is
960 * never read or rewritten — avoiding the read-modify-write race with
961 * concurrent settings saves.
962 */
963 public static function set_heartbeat_throttle(array $fields): void
964 {
965 $existing = get_option(self::heartbeat_throttle_option, []);
966 if (!is_array($existing)) {
967 $existing = [];
968 }
969 $merged = array_merge($existing, $fields);
970 update_option(self::heartbeat_throttle_option, $merged);
971 }
972
973 /**
974 * Storage prefix marking a secret value as encrypted at rest.
975 */
976 private const SECRET_ENC_PREFIX = 'enc_v1:';
977
978 /**
979 * Derive the 32-byte AES key from existing WordPress salts.
980 *
981 * No new secret is stored anywhere — the key material is the concatenation
982 * of three WordPress salts, hashed to a fixed 32 bytes. If the salts change
983 * (e.g. wp-config regenerated) the derived key changes and previously
984 * encrypted values can no longer be decrypted, which callers handle
985 * gracefully rather than fataling.
986 *
987 * @return string 32 raw bytes.
988 */
989 private static function secret_crypto_key()
990 {
991 $material = wp_salt('secure_auth') . wp_salt('logged_in') . wp_salt('nonce');
992 return hash('sha256', $material, true);
993 }
994
995 /**
996 * Determine whether a stored value is in the encrypted-at-rest format.
997 *
998 * @param mixed $value
999 * @return bool
1000 */
1001 public static function is_encrypted_secret($value)
1002 {
1003 return is_string($value) && strncmp($value, self::SECRET_ENC_PREFIX, strlen(self::SECRET_ENC_PREFIX)) === 0;
1004 }
1005
1006 /**
1007 * Encrypt a plaintext secret (e.g. the whitelabel settings password) for
1008 * storage at rest.
1009 *
1010 * Uses AES-256-GCM (authenticated) with a random 12-byte IV. The IV, the
1011 * 16-byte GCM tag and the ciphertext are concatenated and base64-encoded
1012 * behind an `enc_v1:` prefix. An empty string is stored as-is (no secret).
1013 *
1014 * When OpenSSL is unavailable or encryption fails the plaintext is stored
1015 * unchanged (availability over hard-fail) and the degradation is logged so
1016 * it cannot go unnoticed.
1017 *
1018 * @param string $plaintext
1019 * @return string Encrypted blob, or '' when $plaintext is empty.
1020 */
1021 public static function encrypt_secret($plaintext)
1022 {
1023 $plaintext = (string) $plaintext;
1024 if ($plaintext === '') {
1025 return '';
1026 }
1027
1028 // Already encrypted — do not double-encrypt.
1029 if (self::is_encrypted_secret($plaintext)) {
1030 return $plaintext;
1031 }
1032
1033 if (!function_exists('openssl_encrypt')) {
1034 // OpenSSL unavailable — store plaintext rather than lose the secret.
1035 error_log('MetaSync: OpenSSL is unavailable — a secret was stored WITHOUT encryption at rest.');
1036 return $plaintext;
1037 }
1038
1039 $key = self::secret_crypto_key();
1040 $iv = random_bytes(12);
1041 $tag = '';
1042 $ciphertext = openssl_encrypt($plaintext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag, '', 16);
1043
1044 if ($ciphertext === false) {
1045 // Encryption failed — fall back to plaintext storage, but say so.
1046 error_log('MetaSync: Secret encryption failed — a secret was stored WITHOUT encryption at rest.');
1047 return $plaintext;
1048 }
1049
1050 return self::SECRET_ENC_PREFIX . base64_encode($iv . $tag . $ciphertext);
1051 }
1052
1053 /**
1054 * Decrypt a stored secret value.
1055 *
1056 * Accepts either the encrypted `enc_v1:` format or a legacy plaintext value
1057 * (returned unchanged, supporting installs that pre-date encryption). On any
1058 * decryption failure (salt change / corruption) returns false so callers can
1059 * degrade gracefully instead of using a bad value.
1060 *
1061 * @param mixed $value
1062 * @return string|false Plaintext, or false when an encrypted value cannot be decrypted.
1063 */
1064 public static function decrypt_secret($value)
1065 {
1066 if (!is_string($value) || $value === '') {
1067 return '';
1068 }
1069
1070 if (!self::is_encrypted_secret($value)) {
1071 // Legacy plaintext value.
1072 return $value;
1073 }
1074
1075 if (!function_exists('openssl_decrypt')) {
1076 return false;
1077 }
1078
1079 $raw = base64_decode(substr($value, strlen(self::SECRET_ENC_PREFIX)), true);
1080 if ($raw === false || strlen($raw) < 12 + 16 + 1) {
1081 return false;
1082 }
1083
1084 $iv = substr($raw, 0, 12);
1085 $tag = substr($raw, 12, 16);
1086 $ciphertext = substr($raw, 28);
1087
1088 $key = self::secret_crypto_key();
1089 $plaintext = openssl_decrypt($ciphertext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag);
1090
1091 if ($plaintext === false) {
1092 return false;
1093 }
1094
1095 return $plaintext;
1096 }
1097
1098 /**
1099 * Get the decrypted whitelabel settings password.
1100 *
1101 * Reads the stored (encrypted) value and returns the plaintext for
1102 * verification or authorized display. A legacy plaintext value found in
1103 * storage is migrated to the encrypted format in place (one-time migration
1104 * on read). Returns '' when no password is set or when an encrypted value
1105 * can no longer be decrypted (salts changed / corrupt) — in that case the
1106 * stored value still counts as "password set" for protection checks, but
1107 * the user password cannot authenticate until it is reset.
1108 *
1109 * @return string
1110 */
1111 public static function get_whitelabel_password()
1112 {
1113 $whitelabel = self::get_whitelabel_settings();
1114 $stored = $whitelabel['settings_password'] ?? '';
1115
1116 if (!is_string($stored) || $stored === '') {
1117 return '';
1118 }
1119
1120 // One-time migration: encrypt a legacy plaintext value in place.
1121 // This is a whole-blob read-modify-write of metasync_options during a
1122 // read request; a concurrent settings save could theoretically clobber
1123 // it, but it fires at most once per legacy install so the window is
1124 // accepted rather than adding a dedicated option.
1125 if (!self::is_encrypted_secret($stored)) {
1126 $encrypted = self::encrypt_secret($stored);
1127 if (self::is_encrypted_secret($encrypted)) {
1128 $options = self::get_option();
1129 if (!is_array($options)) {
1130 $options = [];
1131 }
1132 $options['whitelabel']['settings_password'] = $encrypted;
1133 self::set_option($options);
1134 }
1135 return $stored;
1136 }
1137
1138 $plaintext = self::decrypt_secret($stored);
1139 return $plaintext === false ? '' : $plaintext;
1140 }
1141
1142 /**
1143 * Get whitelabel settings
1144 * Helper method to retrieve whitelabel configuration
1145 */
1146 public static function get_whitelabel_settings()
1147 {
1148 $whitelabel = self::get_option('whitelabel');
1149 return is_array($whitelabel) ? $whitelabel : array(
1150 'is_whitelabel' => false,
1151 'domain' => '',
1152 'logo' => '',
1153 'logo_light' => '',
1154 'logo_dark' => '',
1155 'company_name' => '',
1156 'color_palette' => array(),
1157 'updated_at' => 0
1158 );
1159 }
1160
1161 /**
1162 * Check if whitelabel mode is enabled
1163 */
1164 public static function is_whitelabel_enabled()
1165 {
1166 $whitelabel = self::get_whitelabel_settings();
1167 return isset($whitelabel['is_whitelabel']) && $whitelabel['is_whitelabel'] === true;
1168 }
1169
1170 /**
1171 * Get effective dashboard domain for the plugin
1172 * Returns whitelabel domain if set (regardless of is_whitelabel flag), otherwise respects staging/production mode
1173 */
1174 public static function get_dashboard_domain()
1175 {
1176 $whitelabel = self::get_whitelabel_settings();
1177
1178 // Priority 1: Use whitelabel domain if it's not empty (regardless of is_whitelabel flag)
1179 if (!empty($whitelabel['domain'])) {
1180 return $whitelabel['domain'];
1181 }
1182
1183 // Priority 2: Use endpoint manager to respect staging/production mode
1184 if (class_exists('Metasync_Endpoint_Manager')) {
1185 return Metasync_Endpoint_Manager::get_endpoint('DASHBOARD_DOMAIN');
1186 }
1187
1188 // Priority 3: Fallback to production default domain
1189 return self::DASHBOARD_DOMAIN;
1190 }
1191
1192 /**
1193 * Get whitelabel logo URL
1194 * Returns the whitelabel logo URL if logo is set
1195 */
1196 public static function get_whitelabel_logo()
1197 {
1198 $whitelabel = self::get_whitelabel_settings();
1199
1200 // Return logo if it's set and is a valid URL
1201 // Users should be able to set a custom logo without requiring a custom domain
1202 if (!empty($whitelabel['logo'])) {
1203 return $whitelabel['logo'];
1204 }
1205
1206 return null;
1207 }
1208
1209 /**
1210 * Get whitelabel logo URL for light theme
1211 * Falls back to legacy 'logo' field if logo_light is not set
1212 */
1213 public static function get_whitelabel_logo_light()
1214 {
1215 $whitelabel = self::get_whitelabel_settings();
1216
1217 if (!empty($whitelabel['logo_light'])) {
1218 return $whitelabel['logo_light'];
1219 }
1220
1221 if (!empty($whitelabel['logo'])) {
1222 return $whitelabel['logo'];
1223 }
1224
1225 return null;
1226 }
1227
1228 /**
1229 * Get whitelabel logo URL for dark theme
1230 * Falls back to legacy 'logo' field if logo_dark is not set
1231 */
1232 public static function get_whitelabel_logo_dark()
1233 {
1234 $whitelabel = self::get_whitelabel_settings();
1235
1236 if (!empty($whitelabel['logo_dark'])) {
1237 return $whitelabel['logo_dark'];
1238 }
1239
1240 if (!empty($whitelabel['logo'])) {
1241 return $whitelabel['logo'];
1242 }
1243
1244 return null;
1245 }
1246
1247 /**
1248 * Get whitelabel company name
1249 * Returns the whitelabel company name if whitelabel is active and company name is set
1250 */
1251 public static function get_whitelabel_company_name()
1252 {
1253 $whitelabel = self::get_whitelabel_settings();
1254
1255 // Return company name only if whitelabel is active and company name is set
1256 if (isset($whitelabel['is_whitelabel']) && $whitelabel['is_whitelabel'] === true && !empty($whitelabel['company_name'])) {
1257 return $whitelabel['company_name'];
1258 }
1259
1260 return null;
1261 }
1262
1263 /**
1264 * Get whitelabel OTTO name
1265 * Returns the custom OTTO name if set, otherwise returns 'OTTO'
1266 */
1267 public static function get_whitelabel_otto_name()
1268 {
1269 $general_settings = self::get_option('general');
1270
1271 // Return custom OTTO name if set, otherwise fallback to 'OTTO'
1272 if (!empty($general_settings['whitelabel_otto_name'])) {
1273 return $general_settings['whitelabel_otto_name'];
1274 }
1275
1276 return 'OTTO';
1277 }
1278
1279 /**
1280 * Check if the current user has access to the plugin based on role settings
1281 *
1282 * @return bool True if user has access, false otherwise
1283 */
1284 public static function current_user_has_plugin_access()
1285 {
1286 $user = wp_get_current_user();
1287 if (!$user || !$user->exists()) {
1288 return false;
1289 }
1290
1291 // Administrators always have access
1292 if (in_array('administrator', (array) $user->roles)) {
1293 return true;
1294 }
1295
1296 // Get the plugin access roles setting
1297 $general_options = self::get_option('general');
1298
1299 // If setting not configured, default to admin-only access
1300 if (!isset($general_options['plugin_access_roles'])) {
1301 return false;
1302 }
1303
1304 $allowed_roles = $general_options['plugin_access_roles'];
1305
1306 // If it's a string (single role), convert to array
1307 if (!is_array($allowed_roles)) {
1308 $allowed_roles = array($allowed_roles);
1309 }
1310
1311 // If "all" is selected, allow access
1312 if (in_array('all', $allowed_roles)) {
1313 return true;
1314 }
1315
1316 // If array is empty, deny access (only admins allowed)
1317 if (empty($allowed_roles)) {
1318 return false;
1319 }
1320
1321 // Check if user has any of the allowed roles
1322 $user_roles = (array) $user->roles;
1323 return !empty(array_intersect($user_roles, $allowed_roles));
1324 }
1325
1326 /**
1327 * Get active JWT token for Search Atlas API authentication
1328 * Convenience method accessible from anywhere in the plugin
1329 *
1330 * @param bool $force_refresh Force generation of new token even if cached one exists
1331 * @return string|false JWT token on success, false on failure
1332 */
1333 public static function get_jwt_token($force_refresh = false)
1334 {
1335 // Delegate to admin class method
1336 return Metasync_Admin::get_active_jwt_token($force_refresh);
1337 }
1338
1339 /**
1340 * Get effective plugin name
1341 * Returns plugin name respecting white label settings
1342 * Priority: 1) white_label_plugin_name 2) company branding + base_name 3) base_name
1343 */
1344 public static function get_effective_plugin_name($base_name = 'Search Atlas')
1345 {
1346 $general_settings = self::get_option('general');
1347
1348 // Priority 1: Use white_label_plugin_name if set and not empty
1349 if (!empty($general_settings['white_label_plugin_name'])) {
1350 return $general_settings['white_label_plugin_name'];
1351 }
1352
1353 $whitelabel = self::get_whitelabel_settings();
1354
1355 // Priority 2: If whitelabel is enabled and company name is provided, enhance the plugin name
1356 if (isset($whitelabel['is_whitelabel']) && $whitelabel['is_whitelabel'] === true && !empty($whitelabel['company_name'])) {
1357 return $whitelabel['company_name'] . ' ' . $base_name;
1358 }
1359
1360 // Priority 3: Return base_name as fallback
1361 return $base_name;
1362 }
1363
1364 /**
1365 * Render a standalone info-icon tooltip (the same visual/JS pattern used by
1366 * get_field_tooltips() + render_accordion_sections() in Metasync_Settings_Fields).
1367 * Use this on any admin page whose fields are NOT rendered through that
1368 * accordion field-loop (custom render_callback pages, standalone view files) —
1369 * the trigger/hover/positioning JS in admin/js/metasync-admin.js binds to
1370 * `.metasync-tooltip-trigger` globally, so no extra wiring is needed as long
1371 * as this markup is present on a page where metasync-admin.js is enqueued
1372 * (i.e. any admin page under this plugin's menu).
1373 *
1374 * @param string $tooltip_id Unique id for this tooltip (unique per page).
1375 * @param string $text Plain-English help text (escaped internally).
1376 */
1377 public static function render_tooltip_icon($tooltip_id, $text)
1378 {
1379 echo self::get_tooltip_icon_html($tooltip_id, $text);
1380 }
1381
1382 /**
1383 * Same tooltip markup as render_tooltip_icon(), but RETURNS the HTML string
1384 * instead of echoing it. Use this when the tooltip needs to be concatenated
1385 * into another string — e.g. appended to the $title argument of
1386 * add_settings_field(), which WordPress core echoes raw next to the label.
1387 *
1388 * The whole trigger+popup pair is wrapped in its own small
1389 * `position: relative` anchor span. The popup CSS (.metasync-tooltip) is
1390 * `position: absolute; left: 100%` and positions itself relative to the
1391 * nearest positioned ancestor — on the main settings accordion that's the
1392 * `.metasync-field-label-wrapper` div, but standalone pages (custom
1393 * render_callback templates, add_settings_field titles on plain
1394 * do_settings_sections() pages, etc.) usually have no such ancestor, so
1395 * the popup would escape to whatever distant positioned element exists
1396 * on the page (rendering in the wrong corner of the screen). Wrapping
1397 * here makes every tooltip self-contained regardless of where it's placed.
1398 *
1399 * @param string $tooltip_id Unique id for this tooltip (unique per page).
1400 * @param string $text Plain-English help text (escaped internally).
1401 * @return string HTML markup for the info-icon trigger + tooltip content.
1402 */
1403 public static function get_tooltip_icon_html($tooltip_id, $text)
1404 {
1405 $html = '<span class="metasync-tooltip-anchor" style="position:relative;display:inline-block;vertical-align:middle;margin-left:8px;">';
1406 $html .= '<button type="button" class="metasync-tooltip-trigger" data-tooltip-id="' . esc_attr($tooltip_id) . '" aria-label="More information">';
1407 $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">';
1408 $html .= '<circle cx="12" cy="12" r="10"></circle>';
1409 $html .= '<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"></path>';
1410 $html .= '<line x1="12" y1="17" x2="12.01" y2="17"></line>';
1411 $html .= '</svg>';
1412 $html .= '</button>';
1413
1414 $html .= '<div class="metasync-tooltip" id="tooltip-' . esc_attr($tooltip_id) . '" role="tooltip">';
1415 $html .= '<div class="metasync-tooltip-arrow"></div>';
1416 $html .= '<div class="metasync-tooltip-content">' . esc_html($text) . '</div>';
1417 $html .= '</div>';
1418 $html .= '</span>';
1419
1420 return $html;
1421 }
1422
1423 /**
1424 * Centralized API Key Event Logging
1425 * Provides structured logging for all API key related events with consistent formatting
1426 *
1427 * @since 1.0.0
1428 * @param string $event_type Type of event (change, refresh, reset, etc.)
1429 * @param string $api_key_type Type of API key (plugin_auth_token, searchatlas_api_key)
1430 * @param array $details Additional details about the event
1431 * @param string $level Log level (info, warning, error)
1432 */
1433 public static function log_api_key_event($event_type, $api_key_type, $details = array(), $level = 'info')
1434 {
1435 try {
1436 // Build structured log entry
1437 $log_data = array(
1438 'timestamp' => current_time('mysql'),
1439 'event_type' => $event_type,
1440 'api_key_type' => $api_key_type,
1441 'level' => $level
1442 );
1443
1444 // Add details if provided
1445 if (!empty($details)) {
1446 $log_data['details'] = $details;
1447 }
1448
1449 // Format log message with consistent structure
1450 $log_prefix = strtoupper($level) . ' - MetaSync API Key Event';
1451 $log_message = sprintf('[%s] %s: %s (%s)',
1452 $log_data['timestamp'],
1453 $log_prefix,
1454 $event_type,
1455 $api_key_type
1456 );
1457
1458 // Add details to log message if present
1459 if (!empty($details)) {
1460 $formatted_details = array();
1461 foreach ($details as $key => $value) {
1462 $formatted_details[] = $key . ': ' . (is_string($value) ? $value : json_encode($value));
1463 }
1464 $log_message .= ' - ' . implode(', ', $formatted_details);
1465 }
1466
1467
1468 // Optionally store in database for admin dashboard (future enhancement)
1469 // This could be extended to store in a dedicated log table
1470
1471 } catch (Exception $e) {
1472 // Fallback logging if structured logging fails
1473 error_log('MetaSync API Key Event Logging Error: ' . $e->getMessage());
1474 }
1475 }
1476
1477 /**
1478 * Handle 404 error monitoring
1479 */
1480 public function handle_404_monitoring()
1481 {
1482 // Only process on frontend
1483 if (is_admin()) {
1484 return;
1485 }
1486
1487 // Check if this is a 404 error
1488 if (!is_404()) {
1489 return;
1490 }
1491
1492 // PROTECTION 0: Skip WordPress system paths — these are not "broken links"
1493 $request_uri = $_SERVER['REQUEST_URI'] ?? '';
1494 $skip_prefixes = [
1495 '/wp-json/',
1496 '/wp-admin/',
1497 '/feed/',
1498 '/xmlrpc.php',
1499 '/wp-login.php',
1500 '/wp-cron.php',
1501 ];
1502 foreach ($skip_prefixes as $prefix) {
1503 if (stripos($request_uri, $prefix) === 0) {
1504 return;
1505 }
1506 }
1507
1508 // PROTECTION 1: Exclude static assets to reduce noise
1509 $static_extensions = ['.css', '.js', '.jpg', '.jpeg', '.png', '.gif', '.ico', '.svg', '.woff', '.woff2', '.ttf', '.eot', '.map','.webp'];
1510 foreach ($static_extensions as $ext) {
1511 if (stripos($request_uri, $ext) !== false) {
1512 return; // Skip logging static asset 404s
1513 }
1514 }
1515
1516 // PROTECTION 2: Bot detection - Block known bot patterns
1517 $user_agent = $_SERVER['HTTP_USER_AGENT'] ?? '';
1518 $bot_patterns = ['bot', 'crawler', 'spider', 'scraper', 'curl', 'wget', 'python', 'java'];
1519 foreach ($bot_patterns as $pattern) {
1520 if (stripos($user_agent, $pattern) !== false) {
1521 // Rate limit bot 404s more aggressively
1522 $bot_rate_key = 'metasync_404_bot_rate';
1523 $bot_hits = get_transient($bot_rate_key);
1524 if ($bot_hits !== false && $bot_hits >= 10) {
1525 // Bot has hit 10+ 404s in last minute - stop logging
1526 return;
1527 }
1528 set_transient($bot_rate_key, $bot_hits === false ? 1 : $bot_hits + 1, 60);
1529 break;
1530 }
1531 }
1532
1533 // PROTECTION 3: Global rate limiting - Prevent 404 logging storms
1534 $global_rate_key = 'metasync_404_global_rate';
1535 $global_hits = get_transient($global_rate_key);
1536 if ($global_hits !== false && $global_hits >= 50) {
1537 // More than 50 404s per minute - stop logging to protect database
1538 if ($global_hits === 50) {
1539 error_log('MetaSync 404 Monitor: Rate limit exceeded - 50+ 404s per minute. Pausing logging.');
1540 }
1541 set_transient($global_rate_key, $global_hits + 1, 60);
1542 return;
1543 }
1544 set_transient($global_rate_key, $global_hits === false ? 1 : $global_hits + 1, 60);
1545
1546 // Get current URL
1547 $current_url = $this->get_current_url();
1548
1549 // PROTECTION 4: Per-URL caching - Prevent same URL from being logged repeatedly
1550 $url_cache_key = 'metasync_404_cached_' . md5($current_url);
1551 if (get_transient($url_cache_key)) {
1552 // This URL was already logged in last 5 minutes - skip DB write
1553 return;
1554 }
1555
1556 // PROTECTION 5: URL validation - Skip obviously malicious URLs
1557 if (strlen($current_url) > 500 || preg_match('/[<>{}\\\\|]/', $current_url)) {
1558 return; // Skip potentially malicious or malformed URLs
1559 }
1560
1561 // Initialize 404 monitor database
1562 require_once plugin_dir_path(dirname(__FILE__)) . '404-monitor/class-metasync-404-monitor-database.php';
1563 $db_404 = new Metasync_Error_Monitor_Database();
1564
1565 // Get user agent (sanitized)
1566 $user_agent = isset($_SERVER['HTTP_USER_AGENT']) ? sanitize_text_field($_SERVER['HTTP_USER_AGENT']) : '';
1567
1568 // Log the 404 error
1569 $result = $db_404->update([
1570 'uri' => $current_url,
1571 'user_agent' => $user_agent,
1572 'date_time' => current_time('mysql'),
1573 'hits_count' => 1
1574 ]);
1575
1576 // Cache this URL for 5 minutes to prevent repeated DB writes
1577 set_transient($url_cache_key, true, 300);
1578 }
1579
1580 /**
1581 * Get current URL
1582 */
1583 private function get_current_url()
1584 {
1585 $protocol = is_ssl() ? 'https://' : 'http://';
1586
1587 // Safely get HTTP_HOST with fallback
1588 $host = isset($_SERVER['HTTP_HOST']) ? $_SERVER['HTTP_HOST'] : '';
1589 if (empty($host) && isset($_SERVER['SERVER_NAME'])) {
1590 $host = $_SERVER['SERVER_NAME'];
1591 }
1592 if (empty($host)) {
1593 // Fallback to WordPress site URL if available
1594 $host = parse_url(home_url(), PHP_URL_HOST);
1595 }
1596
1597 // Safely get REQUEST_URI with fallback
1598 $uri = isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '/';
1599
1600 // Decode URL-encoded characters
1601 $uri = urldecode($uri);
1602
1603 // Ensure URI starts with /
1604 if (!str_starts_with($uri, '/')) {
1605 $uri = '/' . $uri;
1606 }
1607
1608 return $protocol . $host . $uri;
1609 }
1610
1611 /**
1612 * Run the loader to execute all of the hooks with WordPress.
1613 *
1614 * @since 1.0.0
1615 */
1616 public function run()
1617 {
1618 $this->loader->run();
1619 }
1620
1621 /**
1622 * The name of the plugin used to uniquely identify it within the context of
1623 * WordPress and to define internationalization functionality.
1624 *
1625 * @since 1.0.0
1626 * @return string The name of the plugin.
1627 */
1628 public function get_plugin_name()
1629 {
1630 return $this->plugin_name;
1631 }
1632
1633 /**
1634 * The reference to the class that orchestrates the hooks with the plugin.
1635 *
1636 * @since 1.0.0
1637 * @return Metasync_Loader Orchestrates the hooks of the plugin.
1638 */
1639 public function get_loader()
1640 {
1641 return $this->loader;
1642 }
1643
1644 /**
1645 * Retrieve the version number of the plugin.
1646 *
1647 * @since 1.0.0
1648 * @return string The version number of the plugin.
1649 */
1650 public function get_version()
1651 {
1652 return $this->version;
1653 }
1654 }
1655