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

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