PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.3
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.3
2.7.0 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 All 139 releases
metasync / includes / class-metasync.php

class-metasync.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.6.3, at includes/class-metasync.php

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