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

1,188 lines 40.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 // If this file is called directly, abort.
3 if (!defined('ABSPATH')) {
4 exit;
5 }
6
7
8 /**
9 * The file that defines the core plugin class
10 *
11 * A class definition that includes attributes and functions used across both the
12 * public-facing side of the site and the admin area.
13 *
14 * @link https://searchatlas.com
15 * @since 1.0.0
16 *
17 * @package Metasync
18 * @subpackage Metasync/includes
19 */
20
21 /**
22 * The core plugin class.
23 *
24 * This is used to define internationalization, admin-specific hooks, and
25 * public-facing site hooks.
26 *
27 * Also maintains the unique identifier of this plugin as well as the current
28 * version of the plugin.
29 *
30 * @since 1.0.0
31 * @package Metasync
32 * @subpackage Metasync/includes
33 * @author Engineering Team <support@searchatlas.com>
34 */
35 class Metasync
36 {
37
38 /**
39 * The loader that's responsible for maintaining and registering all hooks that power
40 * the plugin.
41 *
42 * @since 1.0.0
43 * @access protected
44 * @var Metasync_Loader $loader Maintains and registers all hooks for the plugin.
45 */
46 protected $loader;
47
48 /**
49 * The unique identifier of this plugin.
50 *
51 * @since 1.0.0
52 * @access protected
53 * @var string $plugin_name The string used to uniquely identify this plugin.
54 */
55 protected $plugin_name;
56
57 /**
58 * The current version of the plugin.
59 *
60 * @since 1.0.0
61 * @access protected
62 * @var string $version The current version of the plugin.
63 */
64 protected $version;
65
66 protected $database;
67
68 protected $db_redirection;
69
70 protected $db_heartbeat_errors;
71
72
73
74 public const option_name = "metasync_options";
75
76 /**
77 * Dedicated option key for heartbeat throttle timestamps.
78 *
79 * Stored separately from the main options blob so writes to last_heart_beat
80 * and last_heartbeat_at do not race with concurrent settings writes during
81 * the 15-second wp_remote_post window in SyncCustomerParams.
82 */
83 public const heartbeat_throttle_option = "metasync_heartbeat_throttle";
84
85 /**
86 * Search Atlas Domain Constants
87 * Centralized constants for all Search Atlas service endpoints
88 */
89 public const HOMEPAGE_DOMAIN = "https://searchatlas.com";
90 public const DASHBOARD_DOMAIN = "https://dashboard.searchatlas.com";
91 public const API_DOMAIN = "https://api.searchatlas.com";
92 public const CA_API_DOMAIN = "https://ca.searchatlas.com";
93 public const SUPPORT_EMAIL = "support@searchatlas.com";
94 public const DOCUMENTATION_DOMAIN = "https://help.searchatlas.com";
95
96 /**
97 * 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 // One-time upgrade: regenerate sitemap to remove any Beaver Builder template entries
451 if ( ! get_option( 'metasync_sitemap_bb_exclusion_applied' ) ) {
452 $this->loader->add_action('init', $this, 'maybe_regenerate_sitemap_after_upgrade');
453 }
454 }
455
456 /**
457 * One-time upgrade routine: regenerate the XML sitemap so that Beaver Builder
458 * template post types (fl-builder-template, fl-theme-layout) that were already
459 * present in previously-generated sitemaps are purged.
460 *
461 * Runs once on 'init' and sets a flag so it never runs again.
462 *
463 * @since 1.0.0
464 */
465 public function maybe_regenerate_sitemap_after_upgrade() {
466 $done_key = 'metasync_sitemap_bb_exclusion_applied';
467 if ( get_option( $done_key ) ) {
468 return;
469 }
470
471 // Only regenerate if the custom sitemap feature is actually in use.
472 if ( get_option( 'metasync_sitemap_auto_update', false ) || file_exists( ABSPATH . 'sitemap_index.xml' ) ) {
473 if ( ! class_exists( 'Metasync_Sitemap_Generator' ) ) {
474 require_once plugin_dir_path( dirname( __FILE__ ) ) . 'sitemap/class-metasync-sitemap-generator.php';
475 }
476 $sitemap = new Metasync_Sitemap_Generator();
477 $sitemap->generate_sitemap();
478 update_option( $done_key, true );
479 }
480 // If sitemap is not in use, don't set the flag — retry on next load
481 // so that enabling sitemaps later will still clean up BB templates.
482 }
483
484 /**
485 * Initialize endpoint URL filtering for staging mode
486 * Intercepts HTTP requests and replaces production URLs with staging URLs
487 */
488 private function init_endpoint_filtering() {
489 // Only add filter if Endpoint Manager is available and staging mode is active
490 if (!class_exists('Metasync_Endpoint_Manager') || !Metasync_Endpoint_Manager::is_staging_mode()) {
491 return;
492 }
493
494 // Add filter to intercept HTTP requests before they're sent
495 add_filter('pre_http_request', array($this, 'filter_http_request_urls'), 10, 3);
496 }
497
498 /**
499 * Filter HTTP request URLs to replace production endpoints with staging
500 *
501 * @param false|array|WP_Error $preempt Whether to preempt an HTTP request's return value.
502 * @param array $args HTTP request arguments.
503 * @param string $url The request URL.
504 * @return false|array|WP_Error
505 */
506 public function filter_http_request_urls($preempt, $args, $url) {
507 // Only process if we're not preempting the request
508 if ($preempt !== false) {
509 return $preempt;
510 }
511
512 // Only process if staging mode is active
513 if (!class_exists('Metasync_Endpoint_Manager') || !Metasync_Endpoint_Manager::is_staging_mode()) {
514 return $preempt;
515 }
516
517 // Define URL replacements (production => staging)
518 $url_replacements = array(
519 'https://dashboard.searchatlas.com' => 'https://dashboard.staging.searchatlas.com',
520 'https://api.searchatlas.com' => 'https://api.staging.searchatlas.com',
521 'https://ca.searchatlas.com' => 'https://ca.staging.searchatlas.com',
522 'https://sa.searchatlas.com' => 'https://sa.staging.searchatlas.com',
523 );
524
525 // Check if URL needs to be replaced
526 $original_url = $url;
527 foreach ($url_replacements as $production => $staging) {
528 if (strpos($url, $production) === 0) {
529 $url = str_replace($production, $staging, $url);
530 error_log("MetaSync Endpoint Filter: Replaced {$production} with {$staging} in URL: {$original_url}");
531 break;
532 }
533 }
534
535 // If URL was changed, modify the args and make the request ourselves
536 if ($url !== $original_url) {
537 // Make the request with the modified URL
538 return wp_remote_request($url, $args);
539 }
540
541 return $preempt;
542 }
543
544 /**
545 * Term meta update hook: mirror MetaSync term meta (`_metasync_*`)
546 * into the active third-party SEO plugins' term storage.
547 *
548 * Registered on both `updated_term_meta` and `added_term_meta` so new
549 * fields are synced the first time they are written as well as on
550 * subsequent updates.
551 *
552 * @param int $meta_id Meta row ID (unused).
553 * @param int $object_id Term ID.
554 * @param string $meta_key Meta key being written.
555 * @param mixed $meta_value Meta value being written.
556 */
557 public function on_term_meta_updated($meta_id, $object_id, $meta_key, $meta_value) {
558 if (strncmp($meta_key, '_metasync_', 10) !== 0) {
559 return;
560 }
561
562 if (!class_exists('Metasync_Term_Plugin_Sync')) {
563 return;
564 }
565
566 $term = get_term((int) $object_id);
567 if (!$term || is_wp_error($term)) {
568 return;
569 }
570
571 $canonical_map = [
572 '_metasync_metatitle' => 'title',
573 '_metasync_metadesc' => 'desc',
574 '_metasync_robots_index' => 'noindex',
575 '_metasync_canonical_url' => 'canonical',
576 '_metasync_og_title' => 'og_title',
577 '_metasync_og_description' => 'og_desc',
578 '_metasync_og_image' => 'og_image',
579 '_metasync_twitter_title' => 'twitter_title',
580 '_metasync_twitter_description' => 'twitter_desc',
581 ];
582
583 if (!isset($canonical_map[$meta_key])) {
584 return;
585 }
586
587 $canonical_key = $canonical_map[$meta_key];
588
589 Metasync_Term_Plugin_Sync::get_instance()->sync_term(
590 (int) $object_id,
591 (string) $term->taxonomy,
592 [$canonical_key => $meta_value]
593 );
594 }
595
596 /**
597 * Post meta update hook: mirror MetaSync post meta (`_metasync_*`)
598 * into the active third-party SEO plugins' post storage.
599 *
600 * Registered on both `updated_post_meta` and `added_post_meta` so new
601 * fields are synced the first time they are written as well as on
602 * subsequent updates.
603 *
604 * @param int $meta_id Meta row ID (unused).
605 * @param int $post_id Post ID.
606 * @param string $meta_key Meta key being written.
607 * @param mixed $meta_value Meta value being written.
608 */
609 public function on_post_meta_updated($meta_id, $post_id, $meta_key, $meta_value) {
610 if (!class_exists('Metasync_Plugin_Sync')) {
611 return;
612 }
613
614 Metasync_Plugin_Sync::get_instance()->on_meta_updated($meta_id, $post_id, $meta_key, $meta_value);
615 }
616
617 /**
618 * Save current theme information to MetaSync options
619 * This runs in WordPress admin context, not during REST API requests
620 * Safe to use wp_get_theme() here
621 */
622 public function save_current_theme_info() {
623 $theme = wp_get_theme();
624 $metasync_data = self::get_option();
625
626 if (!isset($metasync_data['general'])) {
627 $metasync_data['general'] = array();
628 }
629
630 $metasync_data['general']['current_theme_name'] = $theme->get('Name');
631 $metasync_data['general']['current_theme_template'] = $theme->get_template();
632 $metasync_data['general']['theme_info_updated'] = time();
633
634 self::set_option($metasync_data);
635 }
636
637 /**
638 * Ensure theme info is saved on admin_init if not already saved
639 * This ensures theme info is available even if theme wasn't switched
640 */
641 public function ensure_theme_info_saved() {
642 $metasync_data = self::get_option('general');
643
644 # Only run once per day to avoid overhead
645 if (empty($metasync_data['theme_info_updated']) ||
646 (time() - $metasync_data['theme_info_updated']) > 86400) {
647 $this->save_current_theme_info();
648 }
649 }
650
651 public static function get_option($key = null, $default = null)
652 {
653 $options = get_option(Metasync::option_name);
654 if (empty($options)) $options = [];
655 if ($key === null) return $options;
656 return $options[$key] ?? ($default !== null ? $default : null);
657 }
658
659 public static function set_option($data)
660 {
661 #return update_option(Metasync::option_name, $data);
662 $result = update_option(Metasync::option_name, $data);
663
664 // NEW: Structured error logging for database errors (only log if it's a real DB error)
665 global $wpdb;
666 if ($result === false && class_exists('Metasync_Error_Logger') && !empty($wpdb->last_error)) {
667 // Check if it's actually a database error (not just same value)
668 $saved_data = get_option(Metasync::option_name);
669 if ($saved_data !== $data) {
670 // Value is different but save failed - this is a real database error
671 Metasync_Error_Logger::log(
672 Metasync_Error_Logger::CATEGORY_DATABASE_ERROR,
673 Metasync_Error_Logger::SEVERITY_ERROR,
674 'Failed to save plugin main options to database',
675 [
676 'option_name' => Metasync::option_name,
677 'wpdb_error' => $wpdb->last_error,
678 'wpdb_last_query' => $wpdb->last_query,
679 'operation' => 'set_option',
680 'has_api_key' => !empty($data['general']['searchatlas_api_key'] ?? null),
681 'has_auth_token' => !empty($data['general']['apikey'] ?? null)
682 ]
683 );
684 }
685 }
686
687 return $result;
688 }
689
690 /**
691 * Read the heartbeat throttle state from its dedicated option.
692 *
693 * Backfills from the legacy location (`metasync_options['general']`) the
694 * first time the dedicated option is empty, so existing installs keep
695 * their throttle history across the migration.
696 */
697 public static function get_heartbeat_throttle(): array
698 {
699 $value = get_option(self::heartbeat_throttle_option, []);
700 if (is_array($value) && !empty($value)) {
701 return $value;
702 }
703
704 $general = self::get_option('general');
705 if (is_array($general) && (array_key_exists('last_heart_beat', $general) || array_key_exists('last_heartbeat_at', $general))) {
706 $throttle = [
707 'last_heart_beat' => $general['last_heart_beat'] ?? 0,
708 'last_heartbeat_at' => $general['last_heartbeat_at'] ?? null,
709 ];
710 update_option(self::heartbeat_throttle_option, $throttle);
711 return $throttle;
712 }
713
714 return [];
715 }
716
717 /**
718 * Merge fields into the dedicated heartbeat throttle option.
719 *
720 * Writes via update_option directly so the main metasync_options blob is
721 * never read or rewritten — avoiding the read-modify-write race with
722 * concurrent settings saves.
723 */
724 public static function set_heartbeat_throttle(array $fields): void
725 {
726 $existing = get_option(self::heartbeat_throttle_option, []);
727 if (!is_array($existing)) {
728 $existing = [];
729 }
730 $merged = array_merge($existing, $fields);
731 update_option(self::heartbeat_throttle_option, $merged);
732 }
733
734 /**
735 * Get whitelabel settings
736 * Helper method to retrieve whitelabel configuration
737 */
738 public static function get_whitelabel_settings()
739 {
740 $whitelabel = self::get_option('whitelabel');
741 return is_array($whitelabel) ? $whitelabel : array(
742 'is_whitelabel' => false,
743 'domain' => '',
744 'logo' => '',
745 'logo_light' => '',
746 'logo_dark' => '',
747 'company_name' => '',
748 'color_palette' => array(),
749 'updated_at' => 0
750 );
751 }
752
753 /**
754 * Check if whitelabel mode is enabled
755 */
756 public static function is_whitelabel_enabled()
757 {
758 $whitelabel = self::get_whitelabel_settings();
759 return isset($whitelabel['is_whitelabel']) && $whitelabel['is_whitelabel'] === true;
760 }
761
762 /**
763 * Get effective dashboard domain for the plugin
764 * Returns whitelabel domain if set (regardless of is_whitelabel flag), otherwise respects staging/production mode
765 */
766 public static function get_dashboard_domain()
767 {
768 $whitelabel = self::get_whitelabel_settings();
769
770 // Priority 1: Use whitelabel domain if it's not empty (regardless of is_whitelabel flag)
771 if (!empty($whitelabel['domain'])) {
772 return $whitelabel['domain'];
773 }
774
775 // Priority 2: Use endpoint manager to respect staging/production mode
776 if (class_exists('Metasync_Endpoint_Manager')) {
777 return Metasync_Endpoint_Manager::get_endpoint('DASHBOARD_DOMAIN');
778 }
779
780 // Priority 3: Fallback to production default domain
781 return self::DASHBOARD_DOMAIN;
782 }
783
784 /**
785 * Get whitelabel logo URL
786 * Returns the whitelabel logo URL if logo is set
787 */
788 public static function get_whitelabel_logo()
789 {
790 $whitelabel = self::get_whitelabel_settings();
791
792 // Return logo if it's set and is a valid URL
793 // Users should be able to set a custom logo without requiring a custom domain
794 if (!empty($whitelabel['logo'])) {
795 return $whitelabel['logo'];
796 }
797
798 return null;
799 }
800
801 /**
802 * Get whitelabel logo URL for light theme
803 * Falls back to legacy 'logo' field if logo_light is not set
804 */
805 public static function get_whitelabel_logo_light()
806 {
807 $whitelabel = self::get_whitelabel_settings();
808
809 if (!empty($whitelabel['logo_light'])) {
810 return $whitelabel['logo_light'];
811 }
812
813 if (!empty($whitelabel['logo'])) {
814 return $whitelabel['logo'];
815 }
816
817 return null;
818 }
819
820 /**
821 * Get whitelabel logo URL for dark theme
822 * Falls back to legacy 'logo' field if logo_dark is not set
823 */
824 public static function get_whitelabel_logo_dark()
825 {
826 $whitelabel = self::get_whitelabel_settings();
827
828 if (!empty($whitelabel['logo_dark'])) {
829 return $whitelabel['logo_dark'];
830 }
831
832 if (!empty($whitelabel['logo'])) {
833 return $whitelabel['logo'];
834 }
835
836 return null;
837 }
838
839 /**
840 * Get whitelabel company name
841 * Returns the whitelabel company name if whitelabel is active and company name is set
842 */
843 public static function get_whitelabel_company_name()
844 {
845 $whitelabel = self::get_whitelabel_settings();
846
847 // Return company name only if whitelabel is active and company name is set
848 if (isset($whitelabel['is_whitelabel']) && $whitelabel['is_whitelabel'] === true && !empty($whitelabel['company_name'])) {
849 return $whitelabel['company_name'];
850 }
851
852 return null;
853 }
854
855 /**
856 * Get whitelabel OTTO name
857 * Returns the custom OTTO name if set, otherwise returns 'OTTO'
858 */
859 public static function get_whitelabel_otto_name()
860 {
861 $general_settings = self::get_option('general');
862
863 // Return custom OTTO name if set, otherwise fallback to 'OTTO'
864 if (!empty($general_settings['whitelabel_otto_name'])) {
865 return $general_settings['whitelabel_otto_name'];
866 }
867
868 return 'OTTO';
869 }
870
871 /**
872 * Check if the current user has access to the plugin based on role settings
873 *
874 * @return bool True if user has access, false otherwise
875 */
876 public static function current_user_has_plugin_access()
877 {
878 $user = wp_get_current_user();
879 if (!$user || !$user->exists()) {
880 return false;
881 }
882
883 // Administrators always have access
884 if (in_array('administrator', (array) $user->roles)) {
885 return true;
886 }
887
888 // Get the plugin access roles setting
889 $general_options = self::get_option('general');
890
891 // If setting not configured, default to admin-only access
892 if (!isset($general_options['plugin_access_roles'])) {
893 return false;
894 }
895
896 $allowed_roles = $general_options['plugin_access_roles'];
897
898 // If it's a string (single role), convert to array
899 if (!is_array($allowed_roles)) {
900 $allowed_roles = array($allowed_roles);
901 }
902
903 // If "all" is selected, allow access
904 if (in_array('all', $allowed_roles)) {
905 return true;
906 }
907
908 // If array is empty, deny access (only admins allowed)
909 if (empty($allowed_roles)) {
910 return false;
911 }
912
913 // Check if user has any of the allowed roles
914 $user_roles = (array) $user->roles;
915 return !empty(array_intersect($user_roles, $allowed_roles));
916 }
917
918 /**
919 * Get active JWT token for Search Atlas API authentication
920 * Convenience method accessible from anywhere in the plugin
921 *
922 * @param bool $force_refresh Force generation of new token even if cached one exists
923 * @return string|false JWT token on success, false on failure
924 */
925 public static function get_jwt_token($force_refresh = false)
926 {
927 // Delegate to admin class method
928 return Metasync_Admin::get_active_jwt_token($force_refresh);
929 }
930
931 /**
932 * Get effective plugin name
933 * Returns plugin name respecting white label settings
934 * Priority: 1) white_label_plugin_name 2) company branding + base_name 3) base_name
935 */
936 public static function get_effective_plugin_name($base_name = 'Search Atlas')
937 {
938 $general_settings = self::get_option('general');
939
940 // Priority 1: Use white_label_plugin_name if set and not empty
941 if (!empty($general_settings['white_label_plugin_name'])) {
942 return $general_settings['white_label_plugin_name'];
943 }
944
945 $whitelabel = self::get_whitelabel_settings();
946
947 // Priority 2: If whitelabel is enabled and company name is provided, enhance the plugin name
948 if (isset($whitelabel['is_whitelabel']) && $whitelabel['is_whitelabel'] === true && !empty($whitelabel['company_name'])) {
949 return $whitelabel['company_name'] . ' ' . $base_name;
950 }
951
952 // Priority 3: Return base_name as fallback
953 return $base_name;
954 }
955
956 /**
957 * Centralized API Key Event Logging
958 * Provides structured logging for all API key related events with consistent formatting
959 *
960 * @since 1.0.0
961 * @param string $event_type Type of event (change, refresh, reset, etc.)
962 * @param string $api_key_type Type of API key (plugin_auth_token, searchatlas_api_key)
963 * @param array $details Additional details about the event
964 * @param string $level Log level (info, warning, error)
965 */
966 public static function log_api_key_event($event_type, $api_key_type, $details = array(), $level = 'info')
967 {
968 try {
969 // Build structured log entry
970 $log_data = array(
971 'timestamp' => current_time('mysql'),
972 'event_type' => $event_type,
973 'api_key_type' => $api_key_type,
974 'level' => $level
975 );
976
977 // Add details if provided
978 if (!empty($details)) {
979 $log_data['details'] = $details;
980 }
981
982 // Format log message with consistent structure
983 $log_prefix = strtoupper($level) . ' - MetaSync API Key Event';
984 $log_message = sprintf('[%s] %s: %s (%s)',
985 $log_data['timestamp'],
986 $log_prefix,
987 $event_type,
988 $api_key_type
989 );
990
991 // Add details to log message if present
992 if (!empty($details)) {
993 $formatted_details = array();
994 foreach ($details as $key => $value) {
995 $formatted_details[] = $key . ': ' . (is_string($value) ? $value : json_encode($value));
996 }
997 $log_message .= ' - ' . implode(', ', $formatted_details);
998 }
999
1000
1001 // Optionally store in database for admin dashboard (future enhancement)
1002 // This could be extended to store in a dedicated log table
1003
1004 } catch (Exception $e) {
1005 // Fallback logging if structured logging fails
1006 error_log('MetaSync API Key Event Logging Error: ' . $e->getMessage());
1007 }
1008 }
1009
1010 /**
1011 * Handle 404 error monitoring
1012 */
1013 public function handle_404_monitoring()
1014 {
1015 // Only process on frontend
1016 if (is_admin()) {
1017 return;
1018 }
1019
1020 // Check if this is a 404 error
1021 if (!is_404()) {
1022 return;
1023 }
1024
1025 // PROTECTION 0: Skip WordPress system paths — these are not "broken links"
1026 $request_uri = $_SERVER['REQUEST_URI'] ?? '';
1027 $skip_prefixes = [
1028 '/wp-json/',
1029 '/wp-admin/',
1030 '/feed/',
1031 '/xmlrpc.php',
1032 '/wp-login.php',
1033 '/wp-cron.php',
1034 ];
1035 foreach ($skip_prefixes as $prefix) {
1036 if (stripos($request_uri, $prefix) === 0) {
1037 return;
1038 }
1039 }
1040
1041 // PROTECTION 1: Exclude static assets to reduce noise
1042 $static_extensions = ['.css', '.js', '.jpg', '.jpeg', '.png', '.gif', '.ico', '.svg', '.woff', '.woff2', '.ttf', '.eot', '.map','.webp'];
1043 foreach ($static_extensions as $ext) {
1044 if (stripos($request_uri, $ext) !== false) {
1045 return; // Skip logging static asset 404s
1046 }
1047 }
1048
1049 // PROTECTION 2: Bot detection - Block known bot patterns
1050 $user_agent = $_SERVER['HTTP_USER_AGENT'] ?? '';
1051 $bot_patterns = ['bot', 'crawler', 'spider', 'scraper', 'curl', 'wget', 'python', 'java'];
1052 foreach ($bot_patterns as $pattern) {
1053 if (stripos($user_agent, $pattern) !== false) {
1054 // Rate limit bot 404s more aggressively
1055 $bot_rate_key = 'metasync_404_bot_rate';
1056 $bot_hits = get_transient($bot_rate_key);
1057 if ($bot_hits !== false && $bot_hits >= 10) {
1058 // Bot has hit 10+ 404s in last minute - stop logging
1059 return;
1060 }
1061 set_transient($bot_rate_key, $bot_hits === false ? 1 : $bot_hits + 1, 60);
1062 break;
1063 }
1064 }
1065
1066 // PROTECTION 3: Global rate limiting - Prevent 404 logging storms
1067 $global_rate_key = 'metasync_404_global_rate';
1068 $global_hits = get_transient($global_rate_key);
1069 if ($global_hits !== false && $global_hits >= 50) {
1070 // More than 50 404s per minute - stop logging to protect database
1071 if ($global_hits === 50) {
1072 error_log('MetaSync 404 Monitor: Rate limit exceeded - 50+ 404s per minute. Pausing logging.');
1073 }
1074 set_transient($global_rate_key, $global_hits + 1, 60);
1075 return;
1076 }
1077 set_transient($global_rate_key, $global_hits === false ? 1 : $global_hits + 1, 60);
1078
1079 // Get current URL
1080 $current_url = $this->get_current_url();
1081
1082 // PROTECTION 4: Per-URL caching - Prevent same URL from being logged repeatedly
1083 $url_cache_key = 'metasync_404_cached_' . md5($current_url);
1084 if (get_transient($url_cache_key)) {
1085 // This URL was already logged in last 5 minutes - skip DB write
1086 return;
1087 }
1088
1089 // PROTECTION 5: URL validation - Skip obviously malicious URLs
1090 if (strlen($current_url) > 500 || preg_match('/[<>{}\\\\|]/', $current_url)) {
1091 return; // Skip potentially malicious or malformed URLs
1092 }
1093
1094 // Initialize 404 monitor database
1095 require_once plugin_dir_path(dirname(__FILE__)) . '404-monitor/class-metasync-404-monitor-database.php';
1096 $db_404 = new Metasync_Error_Monitor_Database();
1097
1098 // Get user agent (sanitized)
1099 $user_agent = isset($_SERVER['HTTP_USER_AGENT']) ? sanitize_text_field($_SERVER['HTTP_USER_AGENT']) : '';
1100
1101 // Log the 404 error
1102 $result = $db_404->update([
1103 'uri' => $current_url,
1104 'user_agent' => $user_agent,
1105 'date_time' => current_time('mysql'),
1106 'hits_count' => 1
1107 ]);
1108
1109 // Cache this URL for 5 minutes to prevent repeated DB writes
1110 set_transient($url_cache_key, true, 300);
1111 }
1112
1113 /**
1114 * Get current URL
1115 */
1116 private function get_current_url()
1117 {
1118 $protocol = is_ssl() ? 'https://' : 'http://';
1119
1120 // Safely get HTTP_HOST with fallback
1121 $host = isset($_SERVER['HTTP_HOST']) ? $_SERVER['HTTP_HOST'] : '';
1122 if (empty($host) && isset($_SERVER['SERVER_NAME'])) {
1123 $host = $_SERVER['SERVER_NAME'];
1124 }
1125 if (empty($host)) {
1126 // Fallback to WordPress site URL if available
1127 $host = parse_url(home_url(), PHP_URL_HOST);
1128 }
1129
1130 // Safely get REQUEST_URI with fallback
1131 $uri = isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '/';
1132
1133 // Decode URL-encoded characters
1134 $uri = urldecode($uri);
1135
1136 // Ensure URI starts with /
1137 if (!str_starts_with($uri, '/')) {
1138 $uri = '/' . $uri;
1139 }
1140
1141 return $protocol . $host . $uri;
1142 }
1143
1144 /**
1145 * Run the loader to execute all of the hooks with WordPress.
1146 *
1147 * @since 1.0.0
1148 */
1149 public function run()
1150 {
1151 $this->loader->run();
1152 }
1153
1154 /**
1155 * The name of the plugin used to uniquely identify it within the context of
1156 * WordPress and to define internationalization functionality.
1157 *
1158 * @since 1.0.0
1159 * @return string The name of the plugin.
1160 */
1161 public function get_plugin_name()
1162 {
1163 return $this->plugin_name;
1164 }
1165
1166 /**
1167 * The reference to the class that orchestrates the hooks with the plugin.
1168 *
1169 * @since 1.0.0
1170 * @return Metasync_Loader Orchestrates the hooks of the plugin.
1171 */
1172 public function get_loader()
1173 {
1174 return $this->loader;
1175 }
1176
1177 /**
1178 * Retrieve the version number of the plugin.
1179 *
1180 * @since 1.0.0
1181 * @return string The version number of the plugin.
1182 */
1183 public function get_version()
1184 {
1185 return $this->version;
1186 }
1187 }
1188