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

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