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

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