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

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