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

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