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

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