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

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