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

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