PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.19
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.19
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.19, at includes/class-metasync.php

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