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

1,193 lines 40.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 // If this file is called directly, abort.
3 if (!defined('ABSPATH')) {
4 exit;
5 }
6
7
8 /**
9 * The file that defines the core plugin class
10 *
11 * A class definition that includes attributes and functions used across both the
12 * public-facing side of the site and the admin area.
13 *
14 * @link https://searchatlas.com
15 * @since 1.0.0
16 *
17 * @package Metasync
18 * @subpackage Metasync/includes
19 */
20
21 /**
22 * The core plugin class.
23 *
24 * This is used to define internationalization, admin-specific hooks, and
25 * public-facing site hooks.
26 *
27 * Also maintains the unique identifier of this plugin as well as the current
28 * version of the plugin.
29 *
30 * @since 1.0.0
31 * @package Metasync
32 * @subpackage Metasync/includes
33 * @author Engineering Team <support@searchatlas.com>
34 */
35 class Metasync
36 {
37
38 /**
39 * The loader that's responsible for maintaining and registering all hooks that power
40 * the plugin.
41 *
42 * @since 1.0.0
43 * @access protected
44 * @var Metasync_Loader $loader Maintains and registers all hooks for the plugin.
45 */
46 protected $loader;
47
48 /**
49 * The unique identifier of this plugin.
50 *
51 * @since 1.0.0
52 * @access protected
53 * @var string $plugin_name The string used to uniquely identify this plugin.
54 */
55 protected $plugin_name;
56
57 /**
58 * The current version of the plugin.
59 *
60 * @since 1.0.0
61 * @access protected
62 * @var string $version The current version of the plugin.
63 */
64 protected $version;
65
66 protected $database;
67
68 protected $db_redirection;
69
70 protected $db_heartbeat_errors;
71
72
73
74 public const option_name = "metasync_options";
75
76 /**
77 * Dedicated option key for heartbeat throttle timestamps.
78 *
79 * Stored separately from the main options blob so writes to last_heart_beat
80 * and last_heartbeat_at do not race with concurrent settings writes during
81 * the 15-second wp_remote_post window in SyncCustomerParams.
82 */
83 public const heartbeat_throttle_option = "metasync_heartbeat_throttle";
84
85 /**
86 * Search Atlas Domain Constants
87 * Centralized constants for all Search Atlas service endpoints
88 */
89 public const HOMEPAGE_DOMAIN = "https://searchatlas.com";
90 public const DASHBOARD_DOMAIN = "https://dashboard.searchatlas.com";
91 public const API_DOMAIN = "https://api.searchatlas.com";
92 public const CA_API_DOMAIN = "https://ca.searchatlas.com";
93 public const SUPPORT_EMAIL = "support@searchatlas.com";
94 public const DOCUMENTATION_DOMAIN = "https://help.searchatlas.com";
95
96 /**
97 * Define the core functionality of the plugin.
98 *
99 * Set the plugin name and the plugin version that can be used throughout the plugin.
100 * Load the dependencies, define the locale, and set the hooks for the admin area and
101 * the public-facing side of the site.
102 *
103 * @since 1.0.0
104 */
105 public function __construct()
106 {
107 if (defined('METASYNC_VERSION')) {
108 $this->version = METASYNC_VERSION;
109 } else {
110 $this->version = '1.0.0';
111 }
112 $this->plugin_name = 'metasync';
113
114 $this->load_dependencies();
115 // $this->set_locale(); // Language support removed - using default only
116 $this->init_api_key_monitor();
117 $this->define_admin_hooks();
118 $this->define_public_hooks();
119 }
120
121 /**
122 * Load the required dependencies for this plugin.
123 *
124 * Include the following files that make up the plugin:
125 *
126 * - Metasync_Loader. Orchestrates the hooks of the plugin.
127 * - Metasync_i18n. Defines internationalization functionality.
128 * - Metasync_Admin. Defines all hooks for the admin area.
129 * - Metasync_Public. Defines all hooks for the public side of the site.
130 *
131 * Create an instance of the loader which will be used to register the hooks
132 * with WordPress.
133 *
134 * @since 1.0.0
135 * @access private
136 */
137 private function load_dependencies()
138 {
139 // WordPress core — cannot be autoloaded.
140 require_once ABSPATH . 'wp-admin/includes/taxonomy.php';
141
142 // Procedural init file — not a class, must stay explicit.
143 if (file_exists(plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php')) {
144 require_once plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php';
145 } else {
146 error_log('MetaSync Google Index: google-index-init.php not found at ' . plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php');
147 }
148
149 // Admin navigation is referenced statically from frontend-reachable
150 // includes (heartbeat/connect managers). Require it explicitly here so
151 // the static call never fatals when wp_head fires before autoload.
152 if (!class_exists('Metasync_Admin_Navigation')) {
153 require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-admin-navigation.php';
154 }
155
156 $this->loader = new Metasync_Loader();
157 $this->db_heartbeat_errors = new Metasync_HeartBeat_Error_Monitor_Database();
158 $this->db_redirection = new Metasync_Redirection_Database();
159 }
160
161 /**
162 * Define the locale for this plugin for internationalization.
163 *
164 * Uses the Metasync_i18n class in order to set the domain and to register the hook
165 * with WordPress.
166 *
167 * @since 1.0.0
168 * @access private
169 */
170 // Language support removed - using default only
171 /*
172 private function set_locale()
173 {
174 $plugin_i18n = new Metasync_i18n();
175
176 $this->loader->add_action('plugins_loaded', $plugin_i18n, 'load_plugin_textdomain');
177 }
178 */
179
180 /**
181 * Initialize the API Key Monitor for comprehensive API key change detection
182 *
183 * @since 1.0.0
184 * @access private
185 */
186 private function init_api_key_monitor()
187 {
188 // Initialize the singleton instance of the API Key Monitor
189 // This will automatically set up hooks to monitor all API key changes
190 Metasync_API_Key_Monitor::get_instance();
191
192 // Log successful initialization
193 #commented out to stop appending this to error.php
194 # error_log('MetaSync: API Key Monitor initialized successfully');
195 }
196
197 /**
198 * Register all of the hooks related to the admin area functionality
199 * of the plugin.
200 *
201 * @since 1.0.0
202 * @access private
203 */
204 private function define_admin_hooks()
205 {
206
207 $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
208
209 // Initialize HTML Visual Editor
210 $html_visual_editor = new Metasync_HTML_Visual_Editor($this->get_plugin_name(), $this->get_version());
211 $html_visual_editor->init();
212
213 // Initialize OTTO Debug class for developers
214 if (class_exists('Metasync_Otto_Debug')) {
215 $otto_debug = new Metasync_Otto_Debug($this->get_plugin_name(), $this->get_version());
216 }
217
218 // Initialize SEO Sidebar for Gutenberg Block Editor
219 if (class_exists('Metasync_SEO_Sidebar')) {
220 new Metasync_SEO_Sidebar($this->get_version());
221 }
222
223 // Initialize Internal Link Suggestions for Gutenberg Block Editor
224 if (class_exists('Metasync_Link_Suggestions')) {
225 new Metasync_Link_Suggestions();
226 }
227
228 $this->loader->add_action('admin_enqueue_scripts', $plugin_admin, 'enqueue_styles');
229 $this->loader->add_action('admin_enqueue_scripts', $plugin_admin, 'enqueue_scripts');
230
231 # Redirection import AJAX handler
232 $redirection_handler = new Metasync_Redirection($this->db_redirection);
233 $this->loader->add_action('wp_ajax_metasync_import_redirections', $redirection_handler, 'handle_import_ajax');
234 $this->loader->add_action('wp_ajax_metasync_check_redirects_health', $redirection_handler, 'handle_health_check_ajax');
235
236 // HeartBeat API Receive Respond and Settings.
237 $this->loader->add_action('heartbeat_settings', $plugin_admin, 'metasync_heartbeat_settings');
238 $this->loader->add_action('heartbeat_received', $plugin_admin, 'metasync_received_data', 10, 2);
239 $this->loader->add_action('wp_ajax_metasync_send_customer_params', $plugin_admin, 'lgSendCustomerParams');
240
241 // Search Atlas Connect endpoints - authenticates with Search Atlas platform to retrieve SA API key and Otto UUID
242 $this->loader->add_action('wp_ajax_metasync_generate_connect_url', $plugin_admin, 'generate_searchatlas_connect_url');
243 $this->loader->add_action('wp_ajax_metasync_check_connect_status', $plugin_admin, 'check_searchatlas_connect_status');
244 $this->loader->add_action('wp_ajax_metasync_reset_authentication', $plugin_admin, 'reset_searchatlas_authentication');
245
246 // Auto-update filter
247 $this->loader->add_filter('auto_update_plugin', $plugin_admin, 'control_plugin_auto_updates', 10, 2);
248
249 // Search Atlas Connect development/testing endpoints
250 $this->loader->add_action('wp_ajax_metasync_test_enhanced_tokens', $plugin_admin, 'test_enhanced_searchatlas_tokens');
251 $this->loader->add_action('wp_ajax_metasync_test_whitelabel_domain', $plugin_admin, 'test_whitelabel_domain');
252 $this->loader->add_action('wp_ajax_metasync_test_ajax_endpoint', $plugin_admin, 'test_searchatlas_ajax_endpoint');
253 $this->loader->add_action('wp_ajax_metasync_simple_ajax_test', $plugin_admin, 'simple_ajax_test');
254
255
256 $post_meta_setting = new Metasync_Post_Meta_Settings();
257 $this->loader->add_action('admin_init', $post_meta_setting, 'add_post_meta_data', 2);
258 $this->loader->add_action('admin_init', $post_meta_setting, 'show_top_admin_bar', 9);
259
260 // SEO Health CSV export: must run on admin_init (before output).
261 // Cheap $_GET check avoids loading the class on every admin page.
262 if (
263 isset($_GET['page'], $_GET['export'], $_GET['_wpnonce']) &&
264 $_GET['export'] === 'csv' &&
265 strpos($_GET['page'], '-seo-health') !== false
266 ) {
267 $this->loader->add_action('admin_init', Metasync_SEO_Health::get_instance(), 'handle_csv_export', 1);
268 }
269 $this->loader->add_action('wp', $post_meta_setting, 'show_top_admin_bar', 9);
270
271 // Initialize XML Sitemap auto-update hooks if enabled
272 // Note: Must not be gated by is_admin() because Gutenberg saves posts
273 // via the REST API where is_admin() returns false, and REST_REQUEST
274 // is not yet defined at plugin load time
275 if (get_option('metasync_sitemap_auto_update', false)) {
276 $sitemap_generator = new Metasync_Sitemap_Generator();
277 $sitemap_generator->setup_auto_update_hooks();
278 }
279 // Initialize Schema Markup functionality
280 $schema_markup = new Metasync_Schema_Markup($this->get_plugin_name(), $this->get_version());
281 $this->loader->add_action('wp_ajax_metasync_get_schema_fields', $schema_markup, 'ajax_get_schema_fields');
282 $this->loader->add_action('wp_ajax_metasync_preview_schema', $schema_markup, 'ajax_preview_schema');
283
284 // Initialize Breadcrumbs functionality
285 if (class_exists('Metasync_Breadcrumbs')) {
286 new Metasync_Breadcrumbs($this->get_plugin_name(), $this->get_version());
287 }
288 if (class_exists('Metasync_Breadcrumbs_Schema')) {
289 new Metasync_Breadcrumbs_Schema($this->get_plugin_name(), $this->get_version());
290 }
291
292 // Initialize Developer Panel (for endpoint switching)
293 if (class_exists('Metasync_Dev_Panel')) {
294 $dev_panel = new Metasync_Dev_Panel($this->get_plugin_name(), $this->get_version());
295 }
296
297 // Initialize Site Health integration
298 if (class_exists('Metasync_Site_Health')) {
299 $site_health = new Metasync_Site_Health();
300 $site_health->register_tests();
301 }
302
303 // Initialize endpoint URL filtering for staging mode
304 $this->init_endpoint_filtering();
305
306 }
307
308 /**
309 * Register all of the hooks related to the public-facing functionality
310 * of the plugin.
311 *
312 * @since 1.0.0
313 * @access private
314 */
315 private function define_public_hooks()
316 {
317 // Header and Footer code snippets
318 $code_snippets = new Metasync_Code_Snippets();
319
320 $this->loader->add_action('wp_head', $code_snippets, 'get_header_snippet');
321 $this->loader->add_action('wp_footer', $code_snippets, 'get_footer_snippet');
322
323
324
325 $optimal_settings = new Metasync_Optimal_Settings();
326 $this->loader->add_filter('wp_robots', $optimal_settings, 'add_robots_meta');
327 $this->loader->add_action('the_content', $optimal_settings, 'add_attributes_external_links');
328
329 $plugin_public = new Metasync_Public($this->get_plugin_name(), $this->get_version());
330 $rest_api = $plugin_public->get_rest_api();
331 $seo_output = $plugin_public->get_seo_output();
332 $get_plugin_basename = sprintf('%1$s/%1$s.php', $this->plugin_name);
333
334 // Asset enqueue hooks (Metasync_Public)
335 $this->loader->add_action('wp_enqueue_scripts', $plugin_public, 'enqueue_styles');
336 $this->loader->add_action('wp_enqueue_scripts', $plugin_public, 'enqueue_scripts');
337 $this->loader->add_action('wp_enqueue_scripts', $plugin_public, 'enqueue_page_custom_css', 999);
338
339 // Elementor editor CSS injection
340 if (class_exists('\Elementor\Plugin')) {
341 $this->loader->add_action('elementor/preview/enqueue_styles', $plugin_public, 'enqueue_elementor_editor_css', 999);
342 }
343
344 // Divi builder CSS injection
345 if (function_exists('et_setup_theme')) {
346 $this->loader->add_action('wp_enqueue_scripts', $plugin_public, 'enqueue_divi_builder_css', 999);
347 }
348
349 // Initialize centralized SEO conflict handler (singleton — suppresses
350 // third-party SEO plugin descriptions when MetaSync provides its own).
351 Metasync_SEO_Conflict_Handler::get_instance();
352
353 // Term-level SEO plugin sync: propagate MetaSync term meta (category/tag
354 // archives) into Yoast/Rank Math/AIOSEO term storage on every write.
355 $this->loader->add_action('updated_term_meta', $this, 'on_term_meta_updated', 10, 4);
356 $this->loader->add_action('added_term_meta', $this, 'on_term_meta_updated', 10, 4);
357
358 // Post-level plugin sync (WP-196): propagate MetaSync post meta into
359 // Yoast/Rank Math/AIOSEO post storage on every write.
360 $this->loader->add_action('updated_post_meta', $this, 'on_post_meta_updated', 10, 4);
361 $this->loader->add_action('added_post_meta', $this, 'on_post_meta_updated', 10, 4);
362
363 // SEO Output hooks (Metasync_Seo_Output)
364 $this->loader->add_action('wp_head', $seo_output, 'hook_metasync_metatags', 1, 1);
365 $this->loader->add_action('template_redirect', $seo_output, 'inject_archive_seo_controls');
366
367 // Hreflang / language alternates output (wp_head @ priority 2).
368 $plugin_hreflang = new Metasync_Hreflang_Output();
369 $this->loader->add_action('wp_head', $plugin_hreflang, 'output_hreflang_tags', 2);
370
371 // Edge Cache: detect Cloudways Varnish and persist for settings UI
372 $this->loader->add_action('init', 'Metasync_Edge_Cache_Purge', 'detect_cloudways');
373
374 // Sitemap exclusions for disabled archive types
375 $this->loader->add_filter('wp_sitemaps_taxonomies', $seo_output, 'filter_sitemap_taxonomies');
376 $this->loader->add_filter('wp_sitemaps_users_entry', $seo_output, 'filter_sitemap_users', 10, 2);
377 $this->loader->add_filter('wp_sitemaps_add_provider', $seo_output, 'filter_sitemap_providers', 10, 2);
378 $this->loader->add_filter('wp_sitemaps_index_entry', $seo_output, 'filter_sitemap_index_entries', 10, 4);
379
380 // AMP cleanup functionality - remove metasync_optimized attribute from head on AMP pages
381 $this->loader->add_action('template_redirect', $seo_output, 'cleanup_amp_head_attribute', 1);
382 $this->loader->add_action('wp_footer', $seo_output, 'end_amp_head_cleanup', 999);
383
384 // Redirection functionality
385 $redirection = new Metasync_Redirection($this->db_redirection);
386 $this->loader->add_action('template_redirect', $redirection, 'handle_template_redirect', 5);
387
388 # Prevent WordPress from redirecting to draft posts via redirect_canonical
389 $this->loader->add_filter('redirect_canonical', $redirection, 'prevent_draft_post_redirects', 10, 2);
390
391 # Prevent WordPress old slug redirects to unpublished posts only
392 $this->loader->add_filter('old_slug_redirect_post_id', $redirection, 'prevent_old_slug_redirect_to_drafts', 10, 1);
393
394 // Auto-redirect on slug change - creates 301 redirect when post/page slug is changed
395 $auto_redirect = new Metasync_Auto_Redirect($this->db_redirection);
396 $auto_redirect->init();
397
398 # Custom HTML Pages functionality
399 # No additional loader hooks needed - class registers its own hooks
400 $custom_pages = new Metasync_Custom_Pages();
401
402 // 404 Error monitoring
403 $this->loader->add_action('template_redirect', $this, 'handle_404_monitoring', 10);
404 $this->loader->add_action('plugin_action_links_' . $get_plugin_basename, $plugin_public, 'metasync_plugin_links');
405
406 // REST API hooks (Metasync_Rest_Api)
407 $this->loader->add_action('rest_api_init', $rest_api, 'metasync_register_rest_routes');
408 $this->loader->add_action('init', $plugin_public, 'metasync_plugin_init', 5);
409 $this->loader->add_action('wp_ajax_metasync_lglogin', $rest_api, 'linkgraph_login');
410
411 // Robots meta filter (Metasync_Seo_Output)
412 $this->loader->add_filter('wp_robots', $seo_output, 'metasync_wp_robots_meta');
413
414
415
416 $metasyncTemplateClass = new Metasync_Template();
417 $this->loader->add_filter('theme_page_templates', $metasyncTemplateClass, 'metasync_template_landing_page', 10, 3);
418 $this->loader->add_filter('template_include', $metasyncTemplateClass, 'metasync_template_landing_page_load', 99 );
419 $templateCrawler = new MetaSyncHiddenPostManager(); # initialize the crawler class
420
421 $this->loader->add_action('wp_trash_post', $templateCrawler , 'prevent_post_deletion'); # Prevent post deletion when moved to trash
422 $this->loader->add_action('before_delete_post', $templateCrawler , 'prevent_post_deletion'); # Prevent permanent deletion
423 # $this->loader->add_filter('metasync_hidden_post_manager', $templateCrawler , 'init'); # run the crawler
424 # Hidden post manager now runs via cron instead of filter (to avoid interfering with post create/update)
425 $this->loader->add_action('metasync_hidden_post_check', $templateCrawler , 'init'); # run the crawler via cron
426
427 // Open Graph and Social Media Tags
428 $opengraph = new Metasync_OpenGraph($this->get_plugin_name(), $this->get_version());
429 $opengraph->init();
430
431 # Save current theme info to database (safe context - admin/init hooks)
432 $this->loader->add_action('after_switch_theme', $this, 'save_current_theme_info');
433 $this->loader->add_action('admin_init', $this, 'ensure_theme_info_saved');
434
435 // OTTO Frontend Toolbar
436 $otto_toolbar = new Metasync_Otto_Frontend_Toolbar($this->get_plugin_name(), $this->get_version());
437 $this->loader->add_action('wp_enqueue_scripts', $otto_toolbar, 'enqueue_styles');
438 $this->loader->add_action('wp_enqueue_scripts', $otto_toolbar, 'enqueue_scripts');
439 $this->loader->add_action('admin_bar_menu', $otto_toolbar, 'add_admin_bar_menu', 100);
440 $this->loader->add_action('wp_footer', $otto_toolbar, 'render_debug_bar', 999);
441
442 // Initialize Sitemap Generator on frontend (for virtual sitemap serving)
443 $sitemap_generator = new Metasync_Sitemap_Generator();
444
445 // Initialize LLMs.txt Generator (for virtual /llms.txt and /llms-full.txt serving)
446 require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-html-to-markdown.php';
447 require_once plugin_dir_path(dirname(__FILE__)) . 'llms-txt/class-metasync-llms-txt-generator.php';
448 $llms_txt_generator = new Metasync_Llms_Txt_Generator();
449
450 // Serve the IndexNow key file virtually at /{key}.txt (WP-511) so it works
451 // on read-only web roots and nginx hosts that 403 direct static .txt access.
452 require_once plugin_dir_path(dirname(__FILE__)) . 'bing-index/class-metasync-bing-instant-index.php';
453 add_action('template_redirect', array('Metasync_Bing_Instant_Index', 'serve_virtual_key_file'), 0);
454
455 // One-time upgrade: regenerate sitemap to remove any Beaver Builder template entries
456 if ( ! get_option( 'metasync_sitemap_bb_exclusion_applied' ) ) {
457 $this->loader->add_action('init', $this, 'maybe_regenerate_sitemap_after_upgrade');
458 }
459 }
460
461 /**
462 * One-time upgrade routine: regenerate the XML sitemap so that Beaver Builder
463 * template post types (fl-builder-template, fl-theme-layout) that were already
464 * present in previously-generated sitemaps are purged.
465 *
466 * Runs once on 'init' and sets a flag so it never runs again.
467 *
468 * @since 1.0.0
469 */
470 public function maybe_regenerate_sitemap_after_upgrade() {
471 $done_key = 'metasync_sitemap_bb_exclusion_applied';
472 if ( get_option( $done_key ) ) {
473 return;
474 }
475
476 // Only regenerate if the custom sitemap feature is actually in use.
477 if ( get_option( 'metasync_sitemap_auto_update', false ) || file_exists( ABSPATH . 'sitemap_index.xml' ) ) {
478 if ( ! class_exists( 'Metasync_Sitemap_Generator' ) ) {
479 require_once plugin_dir_path( dirname( __FILE__ ) ) . 'sitemap/class-metasync-sitemap-generator.php';
480 }
481 $sitemap = new Metasync_Sitemap_Generator();
482 $sitemap->generate_sitemap();
483 update_option( $done_key, true );
484 }
485 // If sitemap is not in use, don't set the flag — retry on next load
486 // so that enabling sitemaps later will still clean up BB templates.
487 }
488
489 /**
490 * Initialize endpoint URL filtering for staging mode
491 * Intercepts HTTP requests and replaces production URLs with staging URLs
492 */
493 private function init_endpoint_filtering() {
494 // Only add filter if Endpoint Manager is available and staging mode is active
495 if (!class_exists('Metasync_Endpoint_Manager') || !Metasync_Endpoint_Manager::is_staging_mode()) {
496 return;
497 }
498
499 // Add filter to intercept HTTP requests before they're sent
500 add_filter('pre_http_request', array($this, 'filter_http_request_urls'), 10, 3);
501 }
502
503 /**
504 * Filter HTTP request URLs to replace production endpoints with staging
505 *
506 * @param false|array|WP_Error $preempt Whether to preempt an HTTP request's return value.
507 * @param array $args HTTP request arguments.
508 * @param string $url The request URL.
509 * @return false|array|WP_Error
510 */
511 public function filter_http_request_urls($preempt, $args, $url) {
512 // Only process if we're not preempting the request
513 if ($preempt !== false) {
514 return $preempt;
515 }
516
517 // Only process if staging mode is active
518 if (!class_exists('Metasync_Endpoint_Manager') || !Metasync_Endpoint_Manager::is_staging_mode()) {
519 return $preempt;
520 }
521
522 // Define URL replacements (production => staging)
523 $url_replacements = array(
524 'https://dashboard.searchatlas.com' => 'https://dashboard.staging.searchatlas.com',
525 'https://api.searchatlas.com' => 'https://api.staging.searchatlas.com',
526 'https://ca.searchatlas.com' => 'https://ca.staging.searchatlas.com',
527 'https://sa.searchatlas.com' => 'https://sa.staging.searchatlas.com',
528 );
529
530 // Check if URL needs to be replaced
531 $original_url = $url;
532 foreach ($url_replacements as $production => $staging) {
533 if (strpos($url, $production) === 0) {
534 $url = str_replace($production, $staging, $url);
535 error_log("MetaSync Endpoint Filter: Replaced {$production} with {$staging} in URL: {$original_url}");
536 break;
537 }
538 }
539
540 // If URL was changed, modify the args and make the request ourselves
541 if ($url !== $original_url) {
542 // Make the request with the modified URL
543 return wp_remote_request($url, $args);
544 }
545
546 return $preempt;
547 }
548
549 /**
550 * Term meta update hook: mirror MetaSync term meta (`_metasync_*`)
551 * into the active third-party SEO plugins' term storage.
552 *
553 * Registered on both `updated_term_meta` and `added_term_meta` so new
554 * fields are synced the first time they are written as well as on
555 * subsequent updates.
556 *
557 * @param int $meta_id Meta row ID (unused).
558 * @param int $object_id Term ID.
559 * @param string $meta_key Meta key being written.
560 * @param mixed $meta_value Meta value being written.
561 */
562 public function on_term_meta_updated($meta_id, $object_id, $meta_key, $meta_value) {
563 if (strncmp($meta_key, '_metasync_', 10) !== 0) {
564 return;
565 }
566
567 if (!class_exists('Metasync_Term_Plugin_Sync')) {
568 return;
569 }
570
571 $term = get_term((int) $object_id);
572 if (!$term || is_wp_error($term)) {
573 return;
574 }
575
576 $canonical_map = [
577 '_metasync_metatitle' => 'title',
578 '_metasync_metadesc' => 'desc',
579 '_metasync_robots_index' => 'noindex',
580 '_metasync_canonical_url' => 'canonical',
581 '_metasync_og_title' => 'og_title',
582 '_metasync_og_description' => 'og_desc',
583 '_metasync_og_image' => 'og_image',
584 '_metasync_twitter_title' => 'twitter_title',
585 '_metasync_twitter_description' => 'twitter_desc',
586 ];
587
588 if (!isset($canonical_map[$meta_key])) {
589 return;
590 }
591
592 $canonical_key = $canonical_map[$meta_key];
593
594 Metasync_Term_Plugin_Sync::get_instance()->sync_term(
595 (int) $object_id,
596 (string) $term->taxonomy,
597 [$canonical_key => $meta_value]
598 );
599 }
600
601 /**
602 * Post meta update hook: mirror MetaSync post meta (`_metasync_*`)
603 * into the active third-party SEO plugins' post storage.
604 *
605 * Registered on both `updated_post_meta` and `added_post_meta` so new
606 * fields are synced the first time they are written as well as on
607 * subsequent updates.
608 *
609 * @param int $meta_id Meta row ID (unused).
610 * @param int $post_id Post ID.
611 * @param string $meta_key Meta key being written.
612 * @param mixed $meta_value Meta value being written.
613 */
614 public function on_post_meta_updated($meta_id, $post_id, $meta_key, $meta_value) {
615 if (!class_exists('Metasync_Plugin_Sync')) {
616 return;
617 }
618
619 Metasync_Plugin_Sync::get_instance()->on_meta_updated($meta_id, $post_id, $meta_key, $meta_value);
620 }
621
622 /**
623 * Save current theme information to MetaSync options
624 * This runs in WordPress admin context, not during REST API requests
625 * Safe to use wp_get_theme() here
626 */
627 public function save_current_theme_info() {
628 $theme = wp_get_theme();
629 $metasync_data = self::get_option();
630
631 if (!isset($metasync_data['general'])) {
632 $metasync_data['general'] = array();
633 }
634
635 $metasync_data['general']['current_theme_name'] = $theme->get('Name');
636 $metasync_data['general']['current_theme_template'] = $theme->get_template();
637 $metasync_data['general']['theme_info_updated'] = time();
638
639 self::set_option($metasync_data);
640 }
641
642 /**
643 * Ensure theme info is saved on admin_init if not already saved
644 * This ensures theme info is available even if theme wasn't switched
645 */
646 public function ensure_theme_info_saved() {
647 $metasync_data = self::get_option('general');
648
649 # Only run once per day to avoid overhead
650 if (empty($metasync_data['theme_info_updated']) ||
651 (time() - $metasync_data['theme_info_updated']) > 86400) {
652 $this->save_current_theme_info();
653 }
654 }
655
656 public static function get_option($key = null, $default = null)
657 {
658 $options = get_option(Metasync::option_name);
659 if (empty($options)) $options = [];
660 if ($key === null) return $options;
661 return $options[$key] ?? ($default !== null ? $default : null);
662 }
663
664 public static function set_option($data)
665 {
666 #return update_option(Metasync::option_name, $data);
667 $result = update_option(Metasync::option_name, $data);
668
669 // NEW: Structured error logging for database errors (only log if it's a real DB error)
670 global $wpdb;
671 if ($result === false && class_exists('Metasync_Error_Logger') && !empty($wpdb->last_error)) {
672 // Check if it's actually a database error (not just same value)
673 $saved_data = get_option(Metasync::option_name);
674 if ($saved_data !== $data) {
675 // Value is different but save failed - this is a real database error
676 Metasync_Error_Logger::log(
677 Metasync_Error_Logger::CATEGORY_DATABASE_ERROR,
678 Metasync_Error_Logger::SEVERITY_ERROR,
679 'Failed to save plugin main options to database',
680 [
681 'option_name' => Metasync::option_name,
682 'wpdb_error' => $wpdb->last_error,
683 'wpdb_last_query' => $wpdb->last_query,
684 'operation' => 'set_option',
685 'has_api_key' => !empty($data['general']['searchatlas_api_key'] ?? null),
686 'has_auth_token' => !empty($data['general']['apikey'] ?? null)
687 ]
688 );
689 }
690 }
691
692 return $result;
693 }
694
695 /**
696 * Read the heartbeat throttle state from its dedicated option.
697 *
698 * Backfills from the legacy location (`metasync_options['general']`) the
699 * first time the dedicated option is empty, so existing installs keep
700 * their throttle history across the migration.
701 */
702 public static function get_heartbeat_throttle(): array
703 {
704 $value = get_option(self::heartbeat_throttle_option, []);
705 if (is_array($value) && !empty($value)) {
706 return $value;
707 }
708
709 $general = self::get_option('general');
710 if (is_array($general) && (array_key_exists('last_heart_beat', $general) || array_key_exists('last_heartbeat_at', $general))) {
711 $throttle = [
712 'last_heart_beat' => $general['last_heart_beat'] ?? 0,
713 'last_heartbeat_at' => $general['last_heartbeat_at'] ?? null,
714 ];
715 update_option(self::heartbeat_throttle_option, $throttle);
716 return $throttle;
717 }
718
719 return [];
720 }
721
722 /**
723 * Merge fields into the dedicated heartbeat throttle option.
724 *
725 * Writes via update_option directly so the main metasync_options blob is
726 * never read or rewritten — avoiding the read-modify-write race with
727 * concurrent settings saves.
728 */
729 public static function set_heartbeat_throttle(array $fields): void
730 {
731 $existing = get_option(self::heartbeat_throttle_option, []);
732 if (!is_array($existing)) {
733 $existing = [];
734 }
735 $merged = array_merge($existing, $fields);
736 update_option(self::heartbeat_throttle_option, $merged);
737 }
738
739 /**
740 * Get whitelabel settings
741 * Helper method to retrieve whitelabel configuration
742 */
743 public static function get_whitelabel_settings()
744 {
745 $whitelabel = self::get_option('whitelabel');
746 return is_array($whitelabel) ? $whitelabel : array(
747 'is_whitelabel' => false,
748 'domain' => '',
749 'logo' => '',
750 'logo_light' => '',
751 'logo_dark' => '',
752 'company_name' => '',
753 'color_palette' => array(),
754 'updated_at' => 0
755 );
756 }
757
758 /**
759 * Check if whitelabel mode is enabled
760 */
761 public static function is_whitelabel_enabled()
762 {
763 $whitelabel = self::get_whitelabel_settings();
764 return isset($whitelabel['is_whitelabel']) && $whitelabel['is_whitelabel'] === true;
765 }
766
767 /**
768 * Get effective dashboard domain for the plugin
769 * Returns whitelabel domain if set (regardless of is_whitelabel flag), otherwise respects staging/production mode
770 */
771 public static function get_dashboard_domain()
772 {
773 $whitelabel = self::get_whitelabel_settings();
774
775 // Priority 1: Use whitelabel domain if it's not empty (regardless of is_whitelabel flag)
776 if (!empty($whitelabel['domain'])) {
777 return $whitelabel['domain'];
778 }
779
780 // Priority 2: Use endpoint manager to respect staging/production mode
781 if (class_exists('Metasync_Endpoint_Manager')) {
782 return Metasync_Endpoint_Manager::get_endpoint('DASHBOARD_DOMAIN');
783 }
784
785 // Priority 3: Fallback to production default domain
786 return self::DASHBOARD_DOMAIN;
787 }
788
789 /**
790 * Get whitelabel logo URL
791 * Returns the whitelabel logo URL if logo is set
792 */
793 public static function get_whitelabel_logo()
794 {
795 $whitelabel = self::get_whitelabel_settings();
796
797 // Return logo if it's set and is a valid URL
798 // Users should be able to set a custom logo without requiring a custom domain
799 if (!empty($whitelabel['logo'])) {
800 return $whitelabel['logo'];
801 }
802
803 return null;
804 }
805
806 /**
807 * Get whitelabel logo URL for light theme
808 * Falls back to legacy 'logo' field if logo_light is not set
809 */
810 public static function get_whitelabel_logo_light()
811 {
812 $whitelabel = self::get_whitelabel_settings();
813
814 if (!empty($whitelabel['logo_light'])) {
815 return $whitelabel['logo_light'];
816 }
817
818 if (!empty($whitelabel['logo'])) {
819 return $whitelabel['logo'];
820 }
821
822 return null;
823 }
824
825 /**
826 * Get whitelabel logo URL for dark theme
827 * Falls back to legacy 'logo' field if logo_dark is not set
828 */
829 public static function get_whitelabel_logo_dark()
830 {
831 $whitelabel = self::get_whitelabel_settings();
832
833 if (!empty($whitelabel['logo_dark'])) {
834 return $whitelabel['logo_dark'];
835 }
836
837 if (!empty($whitelabel['logo'])) {
838 return $whitelabel['logo'];
839 }
840
841 return null;
842 }
843
844 /**
845 * Get whitelabel company name
846 * Returns the whitelabel company name if whitelabel is active and company name is set
847 */
848 public static function get_whitelabel_company_name()
849 {
850 $whitelabel = self::get_whitelabel_settings();
851
852 // Return company name only if whitelabel is active and company name is set
853 if (isset($whitelabel['is_whitelabel']) && $whitelabel['is_whitelabel'] === true && !empty($whitelabel['company_name'])) {
854 return $whitelabel['company_name'];
855 }
856
857 return null;
858 }
859
860 /**
861 * Get whitelabel OTTO name
862 * Returns the custom OTTO name if set, otherwise returns 'OTTO'
863 */
864 public static function get_whitelabel_otto_name()
865 {
866 $general_settings = self::get_option('general');
867
868 // Return custom OTTO name if set, otherwise fallback to 'OTTO'
869 if (!empty($general_settings['whitelabel_otto_name'])) {
870 return $general_settings['whitelabel_otto_name'];
871 }
872
873 return 'OTTO';
874 }
875
876 /**
877 * Check if the current user has access to the plugin based on role settings
878 *
879 * @return bool True if user has access, false otherwise
880 */
881 public static function current_user_has_plugin_access()
882 {
883 $user = wp_get_current_user();
884 if (!$user || !$user->exists()) {
885 return false;
886 }
887
888 // Administrators always have access
889 if (in_array('administrator', (array) $user->roles)) {
890 return true;
891 }
892
893 // Get the plugin access roles setting
894 $general_options = self::get_option('general');
895
896 // If setting not configured, default to admin-only access
897 if (!isset($general_options['plugin_access_roles'])) {
898 return false;
899 }
900
901 $allowed_roles = $general_options['plugin_access_roles'];
902
903 // If it's a string (single role), convert to array
904 if (!is_array($allowed_roles)) {
905 $allowed_roles = array($allowed_roles);
906 }
907
908 // If "all" is selected, allow access
909 if (in_array('all', $allowed_roles)) {
910 return true;
911 }
912
913 // If array is empty, deny access (only admins allowed)
914 if (empty($allowed_roles)) {
915 return false;
916 }
917
918 // Check if user has any of the allowed roles
919 $user_roles = (array) $user->roles;
920 return !empty(array_intersect($user_roles, $allowed_roles));
921 }
922
923 /**
924 * Get active JWT token for Search Atlas API authentication
925 * Convenience method accessible from anywhere in the plugin
926 *
927 * @param bool $force_refresh Force generation of new token even if cached one exists
928 * @return string|false JWT token on success, false on failure
929 */
930 public static function get_jwt_token($force_refresh = false)
931 {
932 // Delegate to admin class method
933 return Metasync_Admin::get_active_jwt_token($force_refresh);
934 }
935
936 /**
937 * Get effective plugin name
938 * Returns plugin name respecting white label settings
939 * Priority: 1) white_label_plugin_name 2) company branding + base_name 3) base_name
940 */
941 public static function get_effective_plugin_name($base_name = 'Search Atlas')
942 {
943 $general_settings = self::get_option('general');
944
945 // Priority 1: Use white_label_plugin_name if set and not empty
946 if (!empty($general_settings['white_label_plugin_name'])) {
947 return $general_settings['white_label_plugin_name'];
948 }
949
950 $whitelabel = self::get_whitelabel_settings();
951
952 // Priority 2: If whitelabel is enabled and company name is provided, enhance the plugin name
953 if (isset($whitelabel['is_whitelabel']) && $whitelabel['is_whitelabel'] === true && !empty($whitelabel['company_name'])) {
954 return $whitelabel['company_name'] . ' ' . $base_name;
955 }
956
957 // Priority 3: Return base_name as fallback
958 return $base_name;
959 }
960
961 /**
962 * Centralized API Key Event Logging
963 * Provides structured logging for all API key related events with consistent formatting
964 *
965 * @since 1.0.0
966 * @param string $event_type Type of event (change, refresh, reset, etc.)
967 * @param string $api_key_type Type of API key (plugin_auth_token, searchatlas_api_key)
968 * @param array $details Additional details about the event
969 * @param string $level Log level (info, warning, error)
970 */
971 public static function log_api_key_event($event_type, $api_key_type, $details = array(), $level = 'info')
972 {
973 try {
974 // Build structured log entry
975 $log_data = array(
976 'timestamp' => current_time('mysql'),
977 'event_type' => $event_type,
978 'api_key_type' => $api_key_type,
979 'level' => $level
980 );
981
982 // Add details if provided
983 if (!empty($details)) {
984 $log_data['details'] = $details;
985 }
986
987 // Format log message with consistent structure
988 $log_prefix = strtoupper($level) . ' - MetaSync API Key Event';
989 $log_message = sprintf('[%s] %s: %s (%s)',
990 $log_data['timestamp'],
991 $log_prefix,
992 $event_type,
993 $api_key_type
994 );
995
996 // Add details to log message if present
997 if (!empty($details)) {
998 $formatted_details = array();
999 foreach ($details as $key => $value) {
1000 $formatted_details[] = $key . ': ' . (is_string($value) ? $value : json_encode($value));
1001 }
1002 $log_message .= ' - ' . implode(', ', $formatted_details);
1003 }
1004
1005
1006 // Optionally store in database for admin dashboard (future enhancement)
1007 // This could be extended to store in a dedicated log table
1008
1009 } catch (Exception $e) {
1010 // Fallback logging if structured logging fails
1011 error_log('MetaSync API Key Event Logging Error: ' . $e->getMessage());
1012 }
1013 }
1014
1015 /**
1016 * Handle 404 error monitoring
1017 */
1018 public function handle_404_monitoring()
1019 {
1020 // Only process on frontend
1021 if (is_admin()) {
1022 return;
1023 }
1024
1025 // Check if this is a 404 error
1026 if (!is_404()) {
1027 return;
1028 }
1029
1030 // PROTECTION 0: Skip WordPress system paths — these are not "broken links"
1031 $request_uri = $_SERVER['REQUEST_URI'] ?? '';
1032 $skip_prefixes = [
1033 '/wp-json/',
1034 '/wp-admin/',
1035 '/feed/',
1036 '/xmlrpc.php',
1037 '/wp-login.php',
1038 '/wp-cron.php',
1039 ];
1040 foreach ($skip_prefixes as $prefix) {
1041 if (stripos($request_uri, $prefix) === 0) {
1042 return;
1043 }
1044 }
1045
1046 // PROTECTION 1: Exclude static assets to reduce noise
1047 $static_extensions = ['.css', '.js', '.jpg', '.jpeg', '.png', '.gif', '.ico', '.svg', '.woff', '.woff2', '.ttf', '.eot', '.map','.webp'];
1048 foreach ($static_extensions as $ext) {
1049 if (stripos($request_uri, $ext) !== false) {
1050 return; // Skip logging static asset 404s
1051 }
1052 }
1053
1054 // PROTECTION 2: Bot detection - Block known bot patterns
1055 $user_agent = $_SERVER['HTTP_USER_AGENT'] ?? '';
1056 $bot_patterns = ['bot', 'crawler', 'spider', 'scraper', 'curl', 'wget', 'python', 'java'];
1057 foreach ($bot_patterns as $pattern) {
1058 if (stripos($user_agent, $pattern) !== false) {
1059 // Rate limit bot 404s more aggressively
1060 $bot_rate_key = 'metasync_404_bot_rate';
1061 $bot_hits = get_transient($bot_rate_key);
1062 if ($bot_hits !== false && $bot_hits >= 10) {
1063 // Bot has hit 10+ 404s in last minute - stop logging
1064 return;
1065 }
1066 set_transient($bot_rate_key, $bot_hits === false ? 1 : $bot_hits + 1, 60);
1067 break;
1068 }
1069 }
1070
1071 // PROTECTION 3: Global rate limiting - Prevent 404 logging storms
1072 $global_rate_key = 'metasync_404_global_rate';
1073 $global_hits = get_transient($global_rate_key);
1074 if ($global_hits !== false && $global_hits >= 50) {
1075 // More than 50 404s per minute - stop logging to protect database
1076 if ($global_hits === 50) {
1077 error_log('MetaSync 404 Monitor: Rate limit exceeded - 50+ 404s per minute. Pausing logging.');
1078 }
1079 set_transient($global_rate_key, $global_hits + 1, 60);
1080 return;
1081 }
1082 set_transient($global_rate_key, $global_hits === false ? 1 : $global_hits + 1, 60);
1083
1084 // Get current URL
1085 $current_url = $this->get_current_url();
1086
1087 // PROTECTION 4: Per-URL caching - Prevent same URL from being logged repeatedly
1088 $url_cache_key = 'metasync_404_cached_' . md5($current_url);
1089 if (get_transient($url_cache_key)) {
1090 // This URL was already logged in last 5 minutes - skip DB write
1091 return;
1092 }
1093
1094 // PROTECTION 5: URL validation - Skip obviously malicious URLs
1095 if (strlen($current_url) > 500 || preg_match('/[<>{}\\\\|]/', $current_url)) {
1096 return; // Skip potentially malicious or malformed URLs
1097 }
1098
1099 // Initialize 404 monitor database
1100 require_once plugin_dir_path(dirname(__FILE__)) . '404-monitor/class-metasync-404-monitor-database.php';
1101 $db_404 = new Metasync_Error_Monitor_Database();
1102
1103 // Get user agent (sanitized)
1104 $user_agent = isset($_SERVER['HTTP_USER_AGENT']) ? sanitize_text_field($_SERVER['HTTP_USER_AGENT']) : '';
1105
1106 // Log the 404 error
1107 $result = $db_404->update([
1108 'uri' => $current_url,
1109 'user_agent' => $user_agent,
1110 'date_time' => current_time('mysql'),
1111 'hits_count' => 1
1112 ]);
1113
1114 // Cache this URL for 5 minutes to prevent repeated DB writes
1115 set_transient($url_cache_key, true, 300);
1116 }
1117
1118 /**
1119 * Get current URL
1120 */
1121 private function get_current_url()
1122 {
1123 $protocol = is_ssl() ? 'https://' : 'http://';
1124
1125 // Safely get HTTP_HOST with fallback
1126 $host = isset($_SERVER['HTTP_HOST']) ? $_SERVER['HTTP_HOST'] : '';
1127 if (empty($host) && isset($_SERVER['SERVER_NAME'])) {
1128 $host = $_SERVER['SERVER_NAME'];
1129 }
1130 if (empty($host)) {
1131 // Fallback to WordPress site URL if available
1132 $host = parse_url(home_url(), PHP_URL_HOST);
1133 }
1134
1135 // Safely get REQUEST_URI with fallback
1136 $uri = isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '/';
1137
1138 // Decode URL-encoded characters
1139 $uri = urldecode($uri);
1140
1141 // Ensure URI starts with /
1142 if (!str_starts_with($uri, '/')) {
1143 $uri = '/' . $uri;
1144 }
1145
1146 return $protocol . $host . $uri;
1147 }
1148
1149 /**
1150 * Run the loader to execute all of the hooks with WordPress.
1151 *
1152 * @since 1.0.0
1153 */
1154 public function run()
1155 {
1156 $this->loader->run();
1157 }
1158
1159 /**
1160 * The name of the plugin used to uniquely identify it within the context of
1161 * WordPress and to define internationalization functionality.
1162 *
1163 * @since 1.0.0
1164 * @return string The name of the plugin.
1165 */
1166 public function get_plugin_name()
1167 {
1168 return $this->plugin_name;
1169 }
1170
1171 /**
1172 * The reference to the class that orchestrates the hooks with the plugin.
1173 *
1174 * @since 1.0.0
1175 * @return Metasync_Loader Orchestrates the hooks of the plugin.
1176 */
1177 public function get_loader()
1178 {
1179 return $this->loader;
1180 }
1181
1182 /**
1183 * Retrieve the version number of the plugin.
1184 *
1185 * @since 1.0.0
1186 * @return string The version number of the plugin.
1187 */
1188 public function get_version()
1189 {
1190 return $this->version;
1191 }
1192 }
1193