PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.2
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.2
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
metasync / includes / class-metasync.php

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

1,867 lines 69.8 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 <[email protected]>
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 = "[email protected]";
94 public const DOCUMENTATION_DOMAIN = "https://help.searchatlas.com";
95
96 /**
97 * Storage prefix marking a Search Atlas API key value as encrypted at rest.
98 */
99 private const API_KEY_ENC_PREFIX = 'enc_v1:';
100
101 /**
102 * Request-scoped memo for the decrypted Search Atlas API key.
103 *
104 * null = not yet loaded this request
105 * false = load attempted but decryption failed (salts changed / corrupt)
106 * string = decrypted plaintext (may be '')
107 *
108 * Never written to a transient or the DB — exists only for the lifetime
109 * of the current request.
110 *
111 * @var string|false|null
112 */
113 private static $memo_api_key = null;
114
115 /**
116 * Define the core functionality of the plugin.
117 *
118 * Set the plugin name and the plugin version that can be used throughout the plugin.
119 * Load the dependencies, define the locale, and set the hooks for the admin area and
120 * the public-facing side of the site.
121 *
122 * @since 1.0.0
123 */
124 public function __construct()
125 {
126 if (defined('METASYNC_VERSION')) {
127 $this->version = METASYNC_VERSION;
128 } else {
129 $this->version = '1.0.0';
130 }
131 $this->plugin_name = 'metasync';
132
133 $this->load_dependencies();
134 // $this->set_locale(); // Language support removed - using default only
135 $this->init_api_key_monitor();
136 $this->define_admin_hooks();
137 $this->define_public_hooks();
138 }
139
140 /**
141 * Load the required dependencies for this plugin.
142 *
143 * Include the following files that make up the plugin:
144 *
145 * - Metasync_Loader. Orchestrates the hooks of the plugin.
146 * - Metasync_i18n. Defines internationalization functionality.
147 * - Metasync_Admin. Defines all hooks for the admin area.
148 * - Metasync_Public. Defines all hooks for the public side of the site.
149 *
150 * Create an instance of the loader which will be used to register the hooks
151 * with WordPress.
152 *
153 * @since 1.0.0
154 * @access private
155 */
156 private function load_dependencies()
157 {
158 // WordPress core — cannot be autoloaded.
159 require_once ABSPATH . 'wp-admin/includes/taxonomy.php';
160
161 // Procedural init file — not a class, must stay explicit.
162 if (file_exists(plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php')) {
163 require_once plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php';
164 } else {
165 error_log('MetaSync Google Index: google-index-init.php not found at ' . plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
166 }
167
168 // The SEO precedence resolver is referenced statically from the render
169 // filters, the sync layer and four admin screens. Require it explicitly
170 // for the same reason as the admin navigation below: a partial update can
171 // leave newer PHP files beside an older committed autoload classmap, and
172 // a missing class on wp_head or the posts list is a fatal, not a
173 // degradation.
174 if (!class_exists('Metasync_Seo_Precedence')) {
175 require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-seo-precedence.php';
176 }
177
178 // Admin navigation is referenced statically from frontend-reachable
179 // includes (heartbeat/connect managers). Require it explicitly here so
180 // the static call never fatals when wp_head fires before autoload.
181 if (!class_exists('Metasync_Admin_Navigation')) {
182 require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-admin-navigation.php';
183 }
184
185 // Hooked on init at priority 0, before any theme or plugin callback
186 // runs, so it cannot wait for a lazy autoload. See define_public_hooks.
187 if (!class_exists('Metasync_BookingPress_Compat')) {
188 require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-bookingpress-compat.php';
189 }
190
191 $this->loader = new Metasync_Loader();
192 // Install aggregate-option password protection in every runtime context.
193 Metasync_Settings_Registration::instance();
194 $this->db_heartbeat_errors = new Metasync_HeartBeat_Error_Monitor_Database();
195 $this->db_redirection = new Metasync_Redirection_Database();
196 }
197
198 /**
199 * Define the locale for this plugin for internationalization.
200 *
201 * Uses the Metasync_i18n class in order to set the domain and to register the hook
202 * with WordPress.
203 *
204 * @since 1.0.0
205 * @access private
206 */
207 // Language support removed - using default only
208 /*
209 private function set_locale()
210 {
211 $plugin_i18n = new Metasync_i18n();
212
213 $this->loader->add_action('plugins_loaded', $plugin_i18n, 'load_plugin_textdomain');
214 }
215 */
216
217 /**
218 * Initialize the API Key Monitor for comprehensive API key change detection
219 *
220 * @since 1.0.0
221 * @access private
222 */
223 private function init_api_key_monitor()
224 {
225 // Initialize the singleton instance of the API Key Monitor
226 // This will automatically set up hooks to monitor all API key changes
227 Metasync_API_Key_Monitor::get_instance();
228
229 // Log successful initialization
230 #commented out to stop appending this to error.php
231 # error_log('MetaSync: API Key Monitor initialized successfully');
232 }
233
234 /**
235 * Register all of the hooks related to the admin area functionality
236 * of the plugin.
237 *
238 * @since 1.0.0
239 * @access private
240 */
241 private function define_admin_hooks()
242 {
243
244 $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
245
246 // Initialize HTML Visual Editor
247 $html_visual_editor = new Metasync_HTML_Visual_Editor($this->get_plugin_name(), $this->get_version());
248 $html_visual_editor->init();
249
250 // Initialize OTTO Debug class for developers
251 if (class_exists('Metasync_Otto_Debug')) {
252 $otto_debug = new Metasync_Otto_Debug($this->get_plugin_name(), $this->get_version());
253 }
254
255 // Initialize SEO Sidebar for Gutenberg Block Editor
256 if (class_exists('Metasync_SEO_Sidebar')) {
257 new Metasync_SEO_Sidebar($this->get_version());
258 }
259
260 // Initialize Internal Link Suggestions for Gutenberg Block Editor
261 if (class_exists('Metasync_Link_Suggestions')) {
262 new Metasync_Link_Suggestions();
263 }
264
265 $this->loader->add_action('admin_enqueue_scripts', $plugin_admin, 'enqueue_styles');
266 $this->loader->add_action('admin_enqueue_scripts', $plugin_admin, 'enqueue_scripts');
267
268 # Redirection import AJAX handler
269 $redirection_handler = new Metasync_Redirection($this->db_redirection);
270 $this->loader->add_action('wp_ajax_metasync_import_redirections', $redirection_handler, 'handle_import_ajax');
271 $this->loader->add_action('wp_ajax_metasync_check_redirects_health', $redirection_handler, 'handle_health_check_ajax');
272
273 // HeartBeat API Receive Respond and Settings.
274 $this->loader->add_action('heartbeat_settings', $plugin_admin, 'metasync_heartbeat_settings');
275 $this->loader->add_action('heartbeat_received', $plugin_admin, 'metasync_received_data', 10, 2);
276 $this->loader->add_action('wp_ajax_metasync_send_customer_params', $plugin_admin, 'lgSendCustomerParams');
277
278 // Search Atlas Connect endpoints - authenticates with Search Atlas platform to retrieve SA API key and Otto UUID
279 $this->loader->add_action('wp_ajax_metasync_generate_connect_url', $plugin_admin, 'generate_searchatlas_connect_url');
280 $this->loader->add_action('wp_ajax_metasync_check_connect_status', $plugin_admin, 'check_searchatlas_connect_status');
281 $this->loader->add_action('wp_ajax_metasync_reset_authentication', $plugin_admin, 'reset_searchatlas_authentication');
282
283 // Search Atlas Connect development/testing endpoints
284 $this->loader->add_action('wp_ajax_metasync_test_enhanced_tokens', $plugin_admin, 'test_enhanced_searchatlas_tokens');
285 $this->loader->add_action('wp_ajax_metasync_test_whitelabel_domain', $plugin_admin, 'test_whitelabel_domain');
286 $this->loader->add_action('wp_ajax_metasync_test_ajax_endpoint', $plugin_admin, 'test_searchatlas_ajax_endpoint');
287
288
289 $post_meta_setting = new Metasync_Post_Meta_Settings();
290 $this->loader->add_action('admin_init', $post_meta_setting, 'add_post_meta_data', 2);
291 $this->loader->add_action('admin_init', $post_meta_setting, 'show_top_admin_bar', 9);
292
293 // Unified "SEO Suite" meta box — consolidates the separate Classic-editor
294 // meta boxes above into one tabbed box (presentation-only; save handlers
295 // unchanged). Classic editor only; the block editor keeps its SEO sidebar.
296 // Self-registers its hooks in the constructor. Opt out via the
297 // `metasync_enable_seo_suite` filter.
298 require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-seo-suite.php';
299 new Metasync_Seo_Suite();
300
301 // Classic-editor save path for the three fields that exist only as
302 // Gutenberg sidebar (REST) meta otherwise: breadcrumb title, language
303 // alternates, primary category. The Suite renders the first two and
304 // emits the nonce; this handler persists them from the classic form.
305 // It guards itself against block-editor and managed-page context, so
306 // it is safe to register unconditionally.
307 require_once plugin_dir_path(dirname(__FILE__)) . 'admin/class-metasync-classic-seo-fields.php';
308 new Metasync_Classic_Seo_Fields();
309
310 // SEO Health CSV export: must run on admin_init (before output).
311 // Cheap $_GET check avoids loading the class on every admin page.
312 if (
313 isset($_GET['page'], $_GET['export'], $_GET['_wpnonce']) && // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- routing check, the handler verifies nonce and capability
314 $_GET['export'] === 'csv' && // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- routing check, the handler verifies nonce and capability
315 strpos($_GET['page'], '-seo-health') !== false // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- routing check, the handler verifies nonce and capability
316 ) {
317 $this->loader->add_action('admin_init', Metasync_SEO_Health::get_instance(), 'handle_csv_export', 1);
318 }
319 $this->loader->add_action('wp', $post_meta_setting, 'show_top_admin_bar', 9);
320
321 // SEO meta columns on the posts/pages list tables (WP-624).
322 // Registered for both the `posts` and `pages` variants of each hook so the
323 // columns reach every supported post type; the callbacks bail on unsupported
324 // ones. Hidden by default — users opt in from Screen Options.
325 $seo_columns = Metasync_SEO_Columns::get_instance();
326 $this->loader->add_filter('manage_posts_columns', $seo_columns, 'add_columns', 10, 2);
327 $this->loader->add_filter('manage_pages_columns', $seo_columns, 'add_columns', 10, 1);
328 $this->loader->add_action('manage_posts_custom_column', $seo_columns, 'render_column', 10, 2);
329 $this->loader->add_action('manage_pages_custom_column', $seo_columns, 'render_column', 10, 2);
330 $this->loader->add_filter('default_hidden_columns', $seo_columns, 'default_hidden_columns', 10, 2);
331 $this->loader->add_filter('hidden_columns', $seo_columns, 'hidden_columns', 10, 3);
332 $this->loader->add_action('admin_head', $seo_columns, 'print_styles');
333 $this->loader->add_action('admin_notices', $seo_columns, 'companion_plugin_notice');
334
335 // Initialize XML Sitemap auto-update hooks if enabled
336 // Note: Must not be gated by is_admin() because Gutenberg saves posts
337 // via the REST API where is_admin() returns false, and REST_REQUEST
338 // is not yet defined at plugin load time
339 if (get_option('metasync_sitemap_auto_update', false)) {
340 $sitemap_generator = new Metasync_Sitemap_Generator();
341 $sitemap_generator->setup_auto_update_hooks();
342 }
343 // Initialize Schema Markup functionality
344 $schema_markup = new Metasync_Schema_Markup($this->get_plugin_name(), $this->get_version());
345 $this->loader->add_action('wp_ajax_metasync_get_schema_fields', $schema_markup, 'ajax_get_schema_fields');
346 $this->loader->add_action('wp_ajax_metasync_preview_schema', $schema_markup, 'ajax_preview_schema');
347
348 // Initialize Breadcrumbs functionality
349 if (class_exists('Metasync_Breadcrumbs')) {
350 new Metasync_Breadcrumbs($this->get_plugin_name(), $this->get_version());
351 }
352 if (class_exists('Metasync_Breadcrumbs_Schema')) {
353 new Metasync_Breadcrumbs_Schema($this->get_plugin_name(), $this->get_version());
354 }
355
356 // Initialize Site Health integration
357 if (class_exists('Metasync_Site_Health')) {
358 $site_health = new Metasync_Site_Health();
359 $site_health->register_tests();
360 }
361
362 // Initialize endpoint URL filtering for staging mode
363 $this->init_endpoint_filtering();
364
365 }
366
367 /**
368 * Register all of the hooks related to the public-facing functionality
369 * of the plugin.
370 *
371 * @since 1.0.0
372 * @access private
373 */
374 private function define_public_hooks()
375 {
376 // Header and Footer code snippets
377 $code_snippets = new Metasync_Code_Snippets();
378
379 $this->loader->add_action('wp_head', $code_snippets, 'get_header_snippet');
380 $this->loader->add_action('wp_footer', $code_snippets, 'get_footer_snippet');
381
382 $plugin_public = new Metasync_Public($this->get_plugin_name(), $this->get_version());
383 $rest_api = $plugin_public->get_rest_api();
384 $seo_output = $plugin_public->get_seo_output();
385 // Derive the basename from the actual file location: the plugin
386 // folder is not guaranteed to be named after the plugin (renamed
387 // installs), and a hardcoded name makes the settings links vanish.
388 $get_plugin_basename = plugin_basename(dirname(__DIR__) . '/metasync.php');
389
390 // Asset enqueue hooks (Metasync_Public)
391 $this->loader->add_action('wp_enqueue_scripts', $plugin_public, 'enqueue_styles');
392 $this->loader->add_action('wp_enqueue_scripts', $plugin_public, 'enqueue_scripts');
393 $this->loader->add_action('wp_enqueue_scripts', $plugin_public, 'enqueue_page_custom_css', 999);
394
395 // Registered here rather than from inside enqueue_scripts(), which would
396 // re-register the same filter on every front-end request.
397 $this->loader->add_filter('script_loader_tag', $plugin_public, 'add_defer_attribute', 10, 2);
398
399 // Elementor editor CSS injection
400 if (class_exists('\Elementor\Plugin')) {
401 $this->loader->add_action('elementor/preview/enqueue_styles', $plugin_public, 'enqueue_elementor_editor_css', 999);
402 }
403
404 // Divi builder CSS injection
405 if (function_exists('et_setup_theme')) {
406 $this->loader->add_action('wp_enqueue_scripts', $plugin_public, 'enqueue_divi_builder_css', 999);
407 }
408
409 // Initialize centralized SEO conflict handler (singleton — suppresses
410 // third-party SEO plugin descriptions when MetaSync provides its own).
411 Metasync_SEO_Conflict_Handler::get_instance();
412
413 // Headless delivery: register the GraphQL SEO field. This only adds a
414 // callback on `graphql_register_types`, which never fires unless WPGraphQL
415 // is active, and the callback returns immediately unless Headless Mode is
416 // on — so with the mode off nothing about this request changes.
417 Metasync_Headless_Graphql::init();
418
419 // Headless stale-while-revalidate OTTO refresh: background safety net for
420 // missed/delayed OTTO deployment webhooks. Every branch is gated on
421 // Metasync_Headless_Config::is_active(), so with headless mode off this
422 // schedules no cron event and registers no route beyond an inert REST
423 // callback that itself checks the flag before doing anything.
424 Metasync_Headless_Refresh_Job::init();
425
426 // Term-level SEO plugin sync: propagate MetaSync term meta (category/tag
427 // archives) into Yoast/Rank Math/AIOSEO term storage on every write.
428 $this->loader->add_action('updated_term_meta', $this, 'on_term_meta_updated', 10, 4);
429 $this->loader->add_action('added_term_meta', $this, 'on_term_meta_updated', 10, 4);
430
431 // Post-level plugin sync: propagate MetaSync post meta into
432 // Yoast/Rank Math/AIOSEO post storage on every write.
433 $this->loader->add_action('updated_post_meta', $this, 'on_post_meta_updated', 10, 4);
434 $this->loader->add_action('added_post_meta', $this, 'on_post_meta_updated', 10, 4);
435 // Deletes matter too: clearing the last checkbox in a meta box removes
436 // the meta row, which fires neither hook above.
437 $this->loader->add_action('deleted_post_meta', $this, 'on_post_meta_deleted', 10, 3);
438
439 // SEO Output hooks (Metasync_Seo_Output)
440 $this->loader->add_action('wp_head', $seo_output, 'hook_metasync_metatags', 1, 1);
441 // LocalBusiness / Organization / Person JSON-LD from the Local Business page.
442 $this->loader->add_action('wp_head', $seo_output, 'output_local_business_schema', 2, 1);
443 $this->loader->add_action('template_redirect', $seo_output, 'inject_archive_seo_controls');
444
445 // Hreflang / language alternates output (wp_head @ priority 2). The
446 // emitter shares the SEO output instance for the noindex check.
447 $plugin_hreflang = new Metasync_Hreflang_Output($seo_output);
448 $this->loader->add_action('wp_head', $plugin_hreflang, 'output_hreflang_tags', 2);
449 // WPML prints its own hreflang set at wp_head priority 1, ahead of
450 // MetaSync's output at priority 2 — the suppression filter has to be
451 // in place before wp_head starts, hence template_redirect.
452 $this->loader->add_action('template_redirect', $plugin_hreflang, 'register_wpml_suppression', 1);
453
454 // Edge Cache: detect Cloudways Varnish and persist for settings UI
455 $this->loader->add_action('init', 'Metasync_Edge_Cache_Purge', 'detect_cloudways');
456
457 // BookingPress starts a PHP session on every front-end request (init
458 // at priority 1), which makes hosts skip page caching site-wide. Its
459 // booking flows manage their own sessions inside their AJAX handlers,
460 // so unhook the global start. Priority 0 keeps this ahead of theirs.
461 // Registered with add_action directly (like Metasync_Oxygen_Compat)
462 // because the loader's $component parameter is typed to objects.
463 add_action('init', ['Metasync_BookingPress_Compat', 'neutralize_bookingpress_session'], 0);
464
465 // Sitemap exclusions for disabled archive types
466 $this->loader->add_filter('wp_sitemaps_taxonomies', $seo_output, 'filter_sitemap_taxonomies');
467 $this->loader->add_filter('wp_sitemaps_users_entry', $seo_output, 'filter_sitemap_users', 10, 2);
468 $this->loader->add_filter('wp_sitemaps_add_provider', $seo_output, 'filter_sitemap_providers', 10, 2);
469 $this->loader->add_filter('wp_sitemaps_index_entry', $seo_output, 'filter_sitemap_index_entries', 10, 4);
470
471 // AMP cleanup functionality - remove metasync_optimized attribute from head on AMP pages
472 $this->loader->add_action('template_redirect', $seo_output, 'cleanup_amp_head_attribute', 1);
473 $this->loader->add_action('wp_footer', $seo_output, 'end_amp_head_cleanup', 999);
474
475 // Redirection functionality
476 $redirection = new Metasync_Redirection($this->db_redirection);
477 $this->loader->add_action('template_redirect', $redirection, 'handle_template_redirect', 5);
478
479 # Prevent WordPress from redirecting to draft posts via redirect_canonical
480 $this->loader->add_filter('redirect_canonical', $redirection, 'prevent_draft_post_redirects', 10, 2);
481
482 # Prevent WordPress old slug redirects to unpublished posts only
483 $this->loader->add_filter('old_slug_redirect_post_id', $redirection, 'prevent_old_slug_redirect_to_drafts', 10, 1);
484
485 // Auto-redirect on slug change - creates 301 redirect when post/page slug is changed
486 $auto_redirect = new Metasync_Auto_Redirect($this->db_redirection);
487 $auto_redirect->init();
488
489 # Custom HTML Pages functionality
490 # No additional loader hooks needed - class registers its own hooks
491 $custom_pages = new Metasync_Custom_Pages();
492
493 // 404 Error monitoring
494 $this->loader->add_action('template_redirect', $this, 'handle_404_monitoring', 10);
495 $this->loader->add_action('plugin_action_links_' . $get_plugin_basename, $plugin_public, 'metasync_plugin_links');
496
497 // REST API hooks (Metasync_Rest_Api)
498 $this->loader->add_action('rest_api_init', $rest_api, 'metasync_register_rest_routes');
499 // Coexist with third-party JWT auth plugins: clear their prior auth error
500 // for metasync/v1 requests when our own API key validates. The Tmeister
501 // "JWT Authentication for WP-API" plugin surfaces its jwt_auth_invalid_token
502 // 403 via rest_pre_dispatch (priority 10), so we hook the same filter at a
503 // later priority (11) to clear it for our namespace only.
504 $this->loader->add_filter('rest_pre_dispatch', $rest_api, 'allow_metasync_rest_auth', 11, 3);
505 // Coexist with site-wide "authenticated users only" REST restrictions.
506 // Those hook rest_authentication_errors, which WP applies before dispatch,
507 // so rest_pre_dispatch and permission_callback never run. Hook it late
508 // (99) to clear the error for metasync/v1 requests that present a valid
509 // plugin API key; every other route keeps the site's restriction.
510 $this->loader->add_filter('rest_authentication_errors', $rest_api, 'allow_metasync_rest_authentication', 99, 1);
511 $this->loader->add_action('init', $plugin_public, 'metasync_plugin_init', 5);
512 $this->loader->add_action('wp_ajax_metasync_lglogin', $rest_api, 'linkgraph_login');
513
514 // Robots meta filter (Metasync_Seo_Output)
515 $this->loader->add_filter('wp_robots', $seo_output, 'metasync_wp_robots_meta');
516
517
518
519 $metasyncTemplateClass = new Metasync_Template();
520 $this->loader->add_filter('theme_page_templates', $metasyncTemplateClass, 'metasync_template_landing_page', 10, 3);
521 $this->loader->add_filter('template_include', $metasyncTemplateClass, 'metasync_template_landing_page_load', 99 );
522 $templateCrawler = new MetaSyncHiddenPostManager(); # initialize the crawler class
523
524 $this->loader->add_action('wp_trash_post', $templateCrawler , 'prevent_post_deletion'); # Prevent post deletion when moved to trash
525 $this->loader->add_action('before_delete_post', $templateCrawler , 'prevent_post_deletion'); # Prevent permanent deletion
526 # $this->loader->add_filter('metasync_hidden_post_manager', $templateCrawler , 'init'); # run the crawler
527 # Hidden post manager now runs via cron instead of filter (to avoid interfering with post create/update)
528 $this->loader->add_action('metasync_hidden_post_check', $templateCrawler , 'init'); # run the crawler via cron
529
530 // Open Graph and Social Media Tags
531 $opengraph = new Metasync_OpenGraph($this->get_plugin_name(), $this->get_version());
532 $opengraph->init();
533
534 # Save current theme info to database (safe context - admin/init hooks)
535 $this->loader->add_action('after_switch_theme', $this, 'save_current_theme_info');
536 $this->loader->add_action('admin_init', $this, 'ensure_theme_info_saved');
537
538 // OTTO Frontend Toolbar
539 $otto_toolbar = new Metasync_Otto_Frontend_Toolbar($this->get_plugin_name(), $this->get_version());
540 $this->loader->add_action('wp_enqueue_scripts', $otto_toolbar, 'enqueue_styles');
541 $this->loader->add_action('wp_enqueue_scripts', $otto_toolbar, 'enqueue_scripts');
542 $this->loader->add_action('admin_bar_menu', $otto_toolbar, 'add_admin_bar_menu', 100);
543 $this->loader->add_action('wp_footer', $otto_toolbar, 'render_debug_bar', 999);
544
545 // Initialize Sitemap Generator on frontend (for virtual sitemap serving)
546 $sitemap_generator = new Metasync_Sitemap_Generator();
547
548 // Initialize LLMs.txt Generator (for virtual /llms.txt and /llms-full.txt serving)
549 require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-html-to-markdown.php';
550 require_once plugin_dir_path(dirname(__FILE__)) . 'llms-txt/class-metasync-llms-txt-generator.php';
551 $llms_txt_generator = new Metasync_Llms_Txt_Generator();
552
553 // Serve the IndexNow key file virtually at /{key}.txt so it works
554 // on read-only web roots and nginx hosts that 403 direct static .txt access.
555 require_once plugin_dir_path(dirname(__FILE__)) . 'bing-index/class-metasync-bing-instant-index.php';
556 add_action('template_redirect', array('Metasync_Bing_Instant_Index', 'serve_virtual_key_file'), 0);
557
558 // Scheduled IndexNow submissions, deferred from save_post so a slow
559 // IndexNow round-trip cannot stall the editor or REST write. Registered
560 // statically because the class is loaded but never instantiated on
561 // cron loads. The hook name matches the uninstall cron sweep exactly.
562 add_action('metasync_bing_indexnow_submit_event', array('Metasync_Bing_Instant_Index', 'run_scheduled_submit'), 10, 1);
563
564 // Construct the robots.txt manager on the public load path so its
565 // `robots_txt` filter is registered before a plain GET /robots.txt is
566 // served. The filter is attached in the singleton's constructor, and
567 // previously only admin screens built the instance — so the virtual
568 // robots.txt rules and sitemap lines never rendered on the frontend.
569 require_once plugin_dir_path(dirname(__FILE__)) . 'robots-txt/class-metasync-robots-txt.php';
570 Metasync_Robots_Txt::get_instance();
571
572 // One-time upgrade: regenerate sitemap to remove any Beaver Builder template entries
573 if ( ! get_option( 'metasync_sitemap_bb_exclusion_applied' ) ) {
574 $this->loader->add_action('init', $this, 'maybe_regenerate_sitemap_after_upgrade');
575 }
576
577 // One-time upgrade: delete the legacy Otto crawl-data option. Nothing
578 // reads or writes it anymore (the webhook and SEO sync job stopped
579 // persisting crawl data), and on sites where OTTO crawled many URLs
580 // the row could grow to megabytes while being autoloaded on every
581 // request before WordPress 6.6. The guard below runs on every request,
582 // so the flag must be autoloaded (like the sitemap flag above): a
583 // boolean true costs ~2 bytes inside the alloptions query WordPress
584 // already always runs, keeping the steady-state cost a cache hit.
585 if ( ! get_option( 'metasync_otto_crawldata_removed' ) ) {
586 $this->loader->add_action('init', $this, 'maybe_delete_legacy_otto_crawl_data');
587 }
588 }
589
590 /**
591 * One-time upgrade routine: regenerate the XML sitemap so that Beaver Builder
592 * template post types (fl-builder-template, fl-theme-layout) that were already
593 * present in previously-generated sitemaps are purged.
594 *
595 * Runs once on 'init' and sets a flag so it never runs again.
596 *
597 * @since 1.0.0
598 */
599 public function maybe_regenerate_sitemap_after_upgrade() {
600 $done_key = 'metasync_sitemap_bb_exclusion_applied';
601 if ( get_option( $done_key ) ) {
602 return;
603 }
604
605 // Only regenerate if the custom sitemap feature is actually in use.
606 if ( get_option( 'metasync_sitemap_auto_update', false ) || file_exists( ABSPATH . 'sitemap_index.xml' ) ) {
607 if ( ! class_exists( 'Metasync_Sitemap_Generator' ) ) {
608 require_once plugin_dir_path( dirname( __FILE__ ) ) . 'sitemap/class-metasync-sitemap-generator.php';
609 }
610 $sitemap = new Metasync_Sitemap_Generator();
611 $sitemap->generate_sitemap();
612 update_option( $done_key, true );
613 }
614 // If sitemap is not in use, don't set the flag — retry on next load
615 // so that enabling sitemaps later will still clean up BB templates.
616 }
617
618 /**
619 * One-time upgrade routine: delete the legacy Otto crawl-data option.
620 *
621 * The option stored every URL Otto ever sent and grew without bound;
622 * nothing writes it anymore and nothing in production reads it (per-URL
623 * state lives in Metasync_Otto_Job_Status, delivery receipts in
624 * Metasync_Otto_Webhook_Audit, both bounded). This drops the leftover
625 * row — which on long-running sites can be megabytes of autoloaded data.
626 *
627 * Runs once on 'init' and sets a flag so it never runs again. The flag
628 * is autoloaded deliberately: the registration guard reads it on every
629 * request, and an autoloaded boolean is a free cache hit inside the
630 * alloptions query WordPress always performs — a non-autoloaded flag
631 * would cost a dedicated SELECT per request forever, the exact waste
632 * this routine exists to remove. delete_option() is called directly
633 * without reading the option first: loading a multi-megabyte value into
634 * memory just to delete it would be the one way this cleanup could hurt
635 * a site, and the SQL DELETE never needs the value.
636 */
637 public function maybe_delete_legacy_otto_crawl_data() {
638 $done_key = 'metasync_otto_crawldata_removed';
639 if ( get_option( $done_key ) ) {
640 return;
641 }
642
643 // No "feature in use" conditional here, unlike the sitemap routine:
644 // the row is dead weight whether or not Otto is configured, and
645 // delete_option() on an absent row is a cheap no-op.
646 delete_option( 'metasync_otto_crawldata' );
647 update_option( $done_key, true );
648 }
649
650 /**
651 * Initialize endpoint URL filtering for staging mode
652 * Intercepts HTTP requests and replaces production URLs with staging URLs
653 */
654 private function init_endpoint_filtering() {
655 // Only add filter if Endpoint Manager is available and staging mode is active
656 if (!class_exists('Metasync_Endpoint_Manager') || !Metasync_Endpoint_Manager::is_staging_mode()) {
657 return;
658 }
659
660 // Add filter to intercept HTTP requests before they're sent
661 add_filter('pre_http_request', array($this, 'filter_http_request_urls'), 10, 3);
662 }
663
664 /**
665 * Filter HTTP request URLs to replace production endpoints with staging
666 *
667 * @param false|array|WP_Error $preempt Whether to preempt an HTTP request's return value.
668 * @param array $args HTTP request arguments.
669 * @param string $url The request URL.
670 * @return false|array|WP_Error
671 */
672 public function filter_http_request_urls($preempt, $args, $url) {
673 // Only process if we're not preempting the request
674 if ($preempt !== false) {
675 return $preempt;
676 }
677
678 // Only process if staging mode is active
679 if (!class_exists('Metasync_Endpoint_Manager') || !Metasync_Endpoint_Manager::is_staging_mode()) {
680 return $preempt;
681 }
682
683 // Define URL replacements (production => staging)
684 $url_replacements = array(
685 'https://dashboard.searchatlas.com' => 'https://dashboard.staging.searchatlas.com',
686 'https://api.searchatlas.com' => 'https://api.staging.searchatlas.com',
687 'https://ca.searchatlas.com' => 'https://ca.staging.searchatlas.com',
688 'https://sa.searchatlas.com' => 'https://sa.staging.searchatlas.com',
689 );
690
691 // Check if URL needs to be replaced
692 $original_url = $url;
693 foreach ($url_replacements as $production => $staging) {
694 if (strpos($url, $production) === 0) {
695 $url = str_replace($production, $staging, $url);
696 error_log("MetaSync Endpoint Filter: Replaced {$production} with {$staging} in URL: {$original_url}"); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-gated, no secrets
697 break;
698 }
699 }
700
701 // If URL was changed, modify the args and make the request ourselves
702 if ($url !== $original_url) {
703 // Make the request with the modified URL
704 return wp_remote_request($url, $args);
705 }
706
707 return $preempt;
708 }
709
710 /**
711 * Term meta update hook: mirror MetaSync term meta (`_metasync_*`)
712 * into the active third-party SEO plugins' term storage.
713 *
714 * Registered on both `updated_term_meta` and `added_term_meta` so new
715 * fields are synced the first time they are written as well as on
716 * subsequent updates.
717 *
718 * @param int $meta_id Meta row ID (unused).
719 * @param int $object_id Term ID.
720 * @param string $meta_key Meta key being written.
721 * @param mixed $meta_value Meta value being written.
722 */
723 public function on_term_meta_updated($meta_id, $object_id, $meta_key, $meta_value) {
724 if (strncmp($meta_key, '_metasync_', 10) !== 0) {
725 return;
726 }
727
728 if (!class_exists('Metasync_Term_Plugin_Sync')) {
729 return;
730 }
731
732 $term = get_term((int) $object_id);
733 if (!$term || is_wp_error($term)) {
734 return;
735 }
736
737 $canonical_map = [
738 '_metasync_metatitle' => 'title',
739 '_metasync_metadesc' => 'desc',
740 '_metasync_robots_index' => 'noindex',
741 '_metasync_canonical_url' => 'canonical',
742 '_metasync_og_title' => 'og_title',
743 '_metasync_og_description' => 'og_desc',
744 '_metasync_og_image' => 'og_image',
745 '_metasync_twitter_title' => 'twitter_title',
746 '_metasync_twitter_description' => 'twitter_desc',
747 ];
748
749 if (!isset($canonical_map[$meta_key])) {
750 return;
751 }
752
753 $canonical_key = $canonical_map[$meta_key];
754
755 Metasync_Term_Plugin_Sync::get_instance()->sync_term(
756 (int) $object_id,
757 (string) $term->taxonomy,
758 [$canonical_key => $meta_value]
759 );
760 }
761
762 /**
763 * Post meta update hook: mirror MetaSync post meta (`_metasync_*`)
764 * into the active third-party SEO plugins' post storage.
765 *
766 * Registered on both `updated_post_meta` and `added_post_meta` so new
767 * fields are synced the first time they are written as well as on
768 * subsequent updates.
769 *
770 * @param int $meta_id Meta row ID (unused).
771 * @param int $post_id Post ID.
772 * @param string $meta_key Meta key being written.
773 * @param mixed $meta_value Meta value being written.
774 */
775 public function on_post_meta_updated($meta_id, $post_id, $meta_key, $meta_value) {
776 if (!self::is_metasync_meta_key($meta_key)) {
777 return;
778 }
779
780 if (!self::sync_layer_handles('on_meta_updated')) {
781 return;
782 }
783
784 Metasync_Plugin_Sync::get_instance()->on_meta_updated($meta_id, $post_id, $meta_key, $meta_value);
785 }
786
787 /**
788 * Bridge deleted_post_meta to the sync layer.
789 *
790 * Clearing the last checkbox in a meta box deletes its meta row rather than
791 * updating it, so the update hooks above never fire and mirrored values can
792 * go stale. Only the legacy robots keys are acted on; see
793 * Metasync_Plugin_Sync::on_meta_deleted().
794 *
795 * @param array $meta_ids Meta row IDs (unused).
796 * @param int $post_id Post ID.
797 * @param string $meta_key Meta key being deleted.
798 */
799 public function on_post_meta_deleted($meta_ids, $post_id, $meta_key) {
800 if (!self::is_metasync_meta_key($meta_key)) {
801 return;
802 }
803
804 if (!self::sync_layer_handles('on_meta_deleted')) {
805 return;
806 }
807
808 Metasync_Plugin_Sync::get_instance()->on_meta_deleted($meta_ids, $post_id, $meta_key);
809 }
810
811 /**
812 * Is this meta key one the post sync layer could possibly care about?
813 *
814 * Cheap string gate, no autoload, no singleton. Every watched post key is
815 * either `_metasync_*` (sidebar, OTTO and the mirrored robots JSON) or
816 * `metasync_*` (the legacy meta box keys), so this keeps third-party meta
817 * writes out of the sync layer entirely — `deleted_post_meta` and
818 * `updated_post_meta` fire for every key on the site, including on front-end
819 * requests, and crossing into the sync layer for keys it will only discard
820 * costs an autoload plus a singleton on the hot path.
821 *
822 * Deliberately broader than the sync layer's own key maps: those stay the
823 * single source of exact truth, so a new MetaSync key needs no change here.
824 *
825 * @param string $meta_key Meta key being written or deleted.
826 * @return bool
827 */
828 private static function is_metasync_meta_key($meta_key) {
829 return strncmp($meta_key, '_metasync_', 10) === 0
830 || strncmp($meta_key, 'metasync_', 9) === 0;
831 }
832
833 /**
834 * Can the post sync layer actually handle this hook right now?
835 *
836 * A partially updated install can leave a newer class-metasync.php beside an
837 * older class-metasync-plugin-sync.php — stale opcache bytecode for one file
838 * is enough. Calling a method the loaded class does not define is a fatal,
839 * and because these bridges run on `wp_head` via third-party meta writes it
840 * takes the front end down rather than degrading. Check before dispatching.
841 *
842 * Checked on the class, not an instance, so a mismatch skips the singleton.
843 *
844 * @param string $method Sync-layer method about to be called.
845 * @return bool
846 */
847 private static function sync_layer_handles($method) {
848 return class_exists('Metasync_Plugin_Sync')
849 && method_exists('Metasync_Plugin_Sync', 'get_instance')
850 && method_exists('Metasync_Plugin_Sync', $method);
851 }
852
853 /**
854 * Save current theme information to MetaSync options
855 * This runs in WordPress admin context, not during REST API requests
856 * Safe to use wp_get_theme() here
857 */
858 public function save_current_theme_info() {
859 $theme = wp_get_theme();
860 $metasync_data = self::get_option();
861
862 if (!isset($metasync_data['general'])) {
863 $metasync_data['general'] = array();
864 }
865
866 $metasync_data['general']['current_theme_name'] = $theme->get('Name');
867 $metasync_data['general']['current_theme_template'] = $theme->get_template();
868 $metasync_data['general']['theme_info_updated'] = time();
869
870 self::set_option($metasync_data);
871 }
872
873 /**
874 * Ensure theme info is saved on admin_init if not already saved
875 * This ensures theme info is available even if theme wasn't switched
876 */
877 public function ensure_theme_info_saved() {
878 $metasync_data = self::get_option('general');
879
880 # Only run once per day to avoid overhead
881 if (empty($metasync_data['theme_info_updated']) ||
882 (time() - $metasync_data['theme_info_updated']) > 86400) {
883 $this->save_current_theme_info();
884 }
885 }
886
887 public static function get_option($key = null, $default = null)
888 {
889 $options = get_option(Metasync::option_name);
890 if (empty($options)) $options = [];
891 if ($key === null) return $options;
892 return $options[$key] ?? ($default !== null ? $default : null);
893 }
894
895 public static function set_option($data)
896 {
897 #return update_option(Metasync::option_name, $data);
898 $result = update_option(Metasync::option_name, $data);
899
900 // NEW: Structured error logging for database errors (only log if it's a real DB error)
901 global $wpdb;
902 if ($result === false && class_exists('Metasync_Error_Logger') && !empty($wpdb->last_error)) {
903 // Check if it's actually a database error (not just same value)
904 $saved_data = get_option(Metasync::option_name);
905 if ($saved_data !== $data) {
906 // Value is different but save failed - this is a real database error
907 Metasync_Error_Logger::log(
908 Metasync_Error_Logger::CATEGORY_DATABASE_ERROR,
909 Metasync_Error_Logger::SEVERITY_ERROR,
910 'Failed to save plugin main options to database',
911 [
912 'option_name' => Metasync::option_name,
913 'wpdb_error' => $wpdb->last_error,
914 'wpdb_last_query' => $wpdb->last_query,
915 'operation' => 'set_option',
916 'has_api_key' => !empty($data['general']['searchatlas_api_key'] ?? null),
917 'has_auth_token' => !empty($data['general']['apikey'] ?? null)
918 ]
919 );
920 }
921 }
922
923 return $result;
924 }
925
926 /**
927 * Derive the 32-byte AES key from existing WordPress salts.
928 *
929 * No new secret is stored anywhere — the key material is the concatenation
930 * of three WordPress salts, hashed to a fixed 32 bytes. If the salts change
931 * (e.g. wp-config regenerated) the derived key changes and previously
932 * encrypted values can no longer be decrypted, which is handled gracefully
933 * by the callers (re-authenticate state) rather than fataling.
934 *
935 * @return string 32 raw bytes.
936 */
937 private static function api_key_crypto_key()
938 {
939 $material = wp_salt('secure_auth') . wp_salt('logged_in') . wp_salt('nonce');
940 return hash('sha256', $material, true);
941 }
942
943 /**
944 * Determine whether a stored value is in the encrypted-at-rest format.
945 *
946 * @param mixed $value
947 * @return bool
948 */
949 public static function is_encrypted_api_key($value)
950 {
951 return is_string($value) && strncmp($value, self::API_KEY_ENC_PREFIX, strlen(self::API_KEY_ENC_PREFIX)) === 0;
952 }
953
954 /**
955 * Whether a value is a strictly valid UUID (versions 1-8).
956 *
957 * The single shared validator for every write path that persists
958 * `general.otto_pixel_uuid` (SSO push callback, pull-based connect,
959 * heartbeat writes, settings sanitizer). The OTTO Pixel UUID must never
960 * hold arbitrary text — usernames such as `raza.imran` landing in it
961 * break OTTO SSR authentication — so every write path funnels through
962 * this check instead of a scattered one-off regex.
963 *
964 * Deliberately stricter than wp_is_uuid(), which only checks the
965 * 8-4-4-4-12 hex shape and would accept placeholder values such as
966 * `11111111-2222-3333-4444-555555555555`. The version nibble (first hex
967 * of the third group) must be 1-8 — RFC 4122 defined 1-5 and RFC 9562
968 * added 6/7/8, which modern generators increasingly emit — and the
969 * variant nibble (first hex of the fourth group) must be 8/9/a/b. That
970 * keeps behaviour identical with and without the WP helper loaded (unit
971 * tests run without it).
972 *
973 * An empty string is NOT valid: clearing the field is a separate concern
974 * owned by the disconnect flow (unset + write_options_without_stored_merge),
975 * so '' must not masquerade as a usable UUID here.
976 *
977 * @param mixed $value
978 * @return bool True only for a canonical 8-4-4-4-12 hex UUID of version 1-8
979 * with an RFC-4122 variant (8/9/a/b) in the fourth group.
980 */
981 public static function is_valid_uuid($value)
982 {
983 if (!is_string($value)) {
984 return false;
985 }
986
987 $value = trim($value);
988 if ($value === '') {
989 return false;
990 }
991
992 return (bool) preg_match(
993 '/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i',
994 $value
995 );
996 }
997
998 /**
999 * Encrypt a plaintext Search Atlas API key for storage at rest.
1000 *
1001 * Uses AES-256-GCM (authenticated) with a random 12-byte IV. The IV, the
1002 * 16-byte GCM tag and the ciphertext are concatenated and base64-encoded
1003 * behind an `enc_v1:` prefix. An empty string is stored as-is (no key set).
1004 *
1005 * When OpenSSL is unavailable or encryption fails the plaintext is stored
1006 * unchanged (availability over hard-fail) and the degradation is logged so
1007 * it cannot go unnoticed.
1008 *
1009 * @param string $plaintext
1010 * @return string Encrypted blob, or '' when $plaintext is empty.
1011 */
1012 public static function encrypt_api_key($plaintext)
1013 {
1014 $plaintext = (string) $plaintext;
1015 if ($plaintext === '') {
1016 return '';
1017 }
1018
1019 // Already encrypted — do not double-encrypt.
1020 if (self::is_encrypted_api_key($plaintext)) {
1021 return $plaintext;
1022 }
1023
1024 if (!function_exists('openssl_encrypt')) {
1025 // OpenSSL unavailable — store plaintext rather than lose the key.
1026 error_log('MetaSync: OpenSSL is unavailable — the Search Atlas API key was stored WITHOUT encryption at rest.'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
1027 return $plaintext;
1028 }
1029
1030 $key = self::api_key_crypto_key();
1031 $iv = random_bytes(12);
1032 $tag = '';
1033 $ciphertext = openssl_encrypt($plaintext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag, '', 16);
1034
1035 if ($ciphertext === false) {
1036 // Encryption failed — fall back to plaintext storage, but say so.
1037 error_log('MetaSync: API key encryption failed — the Search Atlas API key was stored WITHOUT encryption at rest.'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
1038 return $plaintext;
1039 }
1040
1041 return self::API_KEY_ENC_PREFIX . base64_encode($iv . $tag . $ciphertext);
1042 }
1043
1044 /**
1045 * Decrypt a stored Search Atlas API key value.
1046 *
1047 * Accepts either the encrypted `enc_v1:` format or a legacy plaintext value
1048 * (returned unchanged, supporting installs that pre-date encryption). On any
1049 * decryption failure (salt change / corruption) returns false so callers can
1050 * surface a re-authenticate state instead of using a bad key.
1051 *
1052 * @param mixed $value
1053 * @return string|false Plaintext, or false when an encrypted value cannot be decrypted.
1054 */
1055 public static function decrypt_api_key($value)
1056 {
1057 if (!is_string($value) || $value === '') {
1058 return '';
1059 }
1060
1061 if (!self::is_encrypted_api_key($value)) {
1062 // Legacy plaintext key.
1063 return $value;
1064 }
1065
1066 if (!function_exists('openssl_decrypt')) {
1067 return false;
1068 }
1069
1070 $raw = base64_decode(substr($value, strlen(self::API_KEY_ENC_PREFIX)), true);
1071 if ($raw === false || strlen($raw) < 12 + 16 + 1) {
1072 return false;
1073 }
1074
1075 $iv = substr($raw, 0, 12);
1076 $tag = substr($raw, 12, 16);
1077 $ciphertext = substr($raw, 28);
1078
1079 $key = self::api_key_crypto_key();
1080 $plaintext = openssl_decrypt($ciphertext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag);
1081
1082 if ($plaintext === false) {
1083 return false;
1084 }
1085
1086 return $plaintext;
1087 }
1088
1089 /**
1090 * Get the decrypted Search Atlas API key, memoized for the request.
1091 *
1092 * Decrypts at most once per request and never persists the decrypted value
1093 * anywhere. When a legacy plaintext key is found it is migrated to the
1094 * encrypted format in place (one-time migration on load). Returns:
1095 * - the plaintext key (string, possibly '')
1096 * - false when an encrypted value exists but cannot be decrypted
1097 * (salts changed / corrupt) — callers should treat this as a
1098 * "please re-authenticate" state.
1099 *
1100 * @return string|false
1101 */
1102 public static function get_searchatlas_api_key()
1103 {
1104 if (self::$memo_api_key !== null) {
1105 return self::$memo_api_key;
1106 }
1107
1108 $general = self::get_option('general');
1109 $stored = is_array($general) ? ($general['searchatlas_api_key'] ?? '') : '';
1110
1111 // One-time migration: a non-empty legacy plaintext value is encrypted
1112 // in place the first time it is read after this feature ships.
1113 if ($stored !== '' && is_string($stored) && !self::is_encrypted_api_key($stored)) {
1114 $encrypted = self::encrypt_api_key($stored);
1115 if (self::is_encrypted_api_key($encrypted)) {
1116 $options = self::get_option();
1117 if (!is_array($options)) {
1118 $options = [];
1119 }
1120 $options['general']['searchatlas_api_key'] = $encrypted;
1121 self::set_option($options);
1122 }
1123 }
1124
1125 self::$memo_api_key = self::decrypt_api_key($stored);
1126 return self::$memo_api_key;
1127 }
1128
1129 /**
1130 * Clear the request-scoped decrypted-key memo.
1131 *
1132 * Call after any write that changes the stored searchatlas_api_key so a
1133 * subsequent read in the same request reflects the new value.
1134 */
1135 public static function invalidate_api_key_cache()
1136 {
1137 self::$memo_api_key = null;
1138 }
1139
1140 /**
1141 * Read the heartbeat throttle state from its dedicated option.
1142 *
1143 * Backfills from the legacy location (`metasync_options['general']`) the
1144 * first time the dedicated option is empty, so existing installs keep
1145 * their throttle history across the migration.
1146 */
1147 public static function get_heartbeat_throttle(): array
1148 {
1149 $value = get_option(self::heartbeat_throttle_option, []);
1150 if (is_array($value) && !empty($value)) {
1151 return $value;
1152 }
1153
1154 $general = self::get_option('general');
1155 if (is_array($general) && (array_key_exists('last_heart_beat', $general) || array_key_exists('last_heartbeat_at', $general))) {
1156 $throttle = [
1157 'last_heart_beat' => $general['last_heart_beat'] ?? 0,
1158 'last_heartbeat_at' => $general['last_heartbeat_at'] ?? null,
1159 ];
1160 update_option(self::heartbeat_throttle_option, $throttle);
1161 return $throttle;
1162 }
1163
1164 return [];
1165 }
1166
1167 /**
1168 * Merge fields into the dedicated heartbeat throttle option.
1169 *
1170 * Writes via update_option directly so the main metasync_options blob is
1171 * never read or rewritten — avoiding the read-modify-write race with
1172 * concurrent settings saves.
1173 */
1174 public static function set_heartbeat_throttle(array $fields): void
1175 {
1176 $existing = get_option(self::heartbeat_throttle_option, []);
1177 if (!is_array($existing)) {
1178 $existing = [];
1179 }
1180 $merged = array_merge($existing, $fields);
1181 update_option(self::heartbeat_throttle_option, $merged);
1182 }
1183
1184 /**
1185 * Storage prefix marking a secret value as encrypted at rest.
1186 */
1187 private const SECRET_ENC_PREFIX = 'enc_v1:';
1188
1189 /**
1190 * Derive the 32-byte AES key from existing WordPress salts.
1191 *
1192 * No new secret is stored anywhere — the key material is the concatenation
1193 * of three WordPress salts, hashed to a fixed 32 bytes. If the salts change
1194 * (e.g. wp-config regenerated) the derived key changes and previously
1195 * encrypted values can no longer be decrypted, which callers handle
1196 * gracefully rather than fataling.
1197 *
1198 * @return string 32 raw bytes.
1199 */
1200 private static function secret_crypto_key()
1201 {
1202 $material = wp_salt('secure_auth') . wp_salt('logged_in') . wp_salt('nonce');
1203 return hash('sha256', $material, true);
1204 }
1205
1206 /**
1207 * Determine whether a stored value is in the encrypted-at-rest format.
1208 *
1209 * @param mixed $value
1210 * @return bool
1211 */
1212 public static function is_encrypted_secret($value)
1213 {
1214 return is_string($value) && strncmp($value, self::SECRET_ENC_PREFIX, strlen(self::SECRET_ENC_PREFIX)) === 0;
1215 }
1216
1217 /**
1218 * Encrypt a plaintext secret (e.g. the whitelabel settings password) for
1219 * storage at rest.
1220 *
1221 * Uses AES-256-GCM (authenticated) with a random 12-byte IV. The IV, the
1222 * 16-byte GCM tag and the ciphertext are concatenated and base64-encoded
1223 * behind an `enc_v1:` prefix. An empty string is stored as-is (no secret).
1224 *
1225 * When OpenSSL is unavailable or encryption fails the plaintext is stored
1226 * unchanged (availability over hard-fail) and the degradation is logged so
1227 * it cannot go unnoticed.
1228 *
1229 * @param string $plaintext
1230 * @return string Encrypted blob, or '' when $plaintext is empty.
1231 */
1232 public static function encrypt_secret($plaintext)
1233 {
1234 $plaintext = (string) $plaintext;
1235 if ($plaintext === '') {
1236 return '';
1237 }
1238
1239 // Already encrypted — do not double-encrypt.
1240 if (self::is_encrypted_secret($plaintext)) {
1241 return $plaintext;
1242 }
1243
1244 if (!function_exists('openssl_encrypt')) {
1245 // OpenSSL unavailable — store plaintext rather than lose the secret.
1246 error_log('MetaSync: OpenSSL is unavailable — a secret was stored WITHOUT encryption at rest.'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
1247 return $plaintext;
1248 }
1249
1250 $key = self::secret_crypto_key();
1251 $iv = random_bytes(12);
1252 $tag = '';
1253 $ciphertext = openssl_encrypt($plaintext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag, '', 16);
1254
1255 if ($ciphertext === false) {
1256 // Encryption failed — fall back to plaintext storage, but say so.
1257 error_log('MetaSync: Secret encryption failed — a secret was stored WITHOUT encryption at rest.'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
1258 return $plaintext;
1259 }
1260
1261 return self::SECRET_ENC_PREFIX . base64_encode($iv . $tag . $ciphertext);
1262 }
1263
1264 /**
1265 * Decrypt a stored secret value.
1266 *
1267 * Accepts either the encrypted `enc_v1:` format or a legacy plaintext value
1268 * (returned unchanged, supporting installs that pre-date encryption). On any
1269 * decryption failure (salt change / corruption) returns false so callers can
1270 * degrade gracefully instead of using a bad value.
1271 *
1272 * @param mixed $value
1273 * @return string|false Plaintext, or false when an encrypted value cannot be decrypted.
1274 */
1275 public static function decrypt_secret($value)
1276 {
1277 if (!is_string($value) || $value === '') {
1278 return '';
1279 }
1280
1281 if (!self::is_encrypted_secret($value)) {
1282 // Legacy plaintext value.
1283 return $value;
1284 }
1285
1286 if (!function_exists('openssl_decrypt')) {
1287 return false;
1288 }
1289
1290 $raw = base64_decode(substr($value, strlen(self::SECRET_ENC_PREFIX)), true);
1291 if ($raw === false || strlen($raw) < 12 + 16 + 1) {
1292 return false;
1293 }
1294
1295 $iv = substr($raw, 0, 12);
1296 $tag = substr($raw, 12, 16);
1297 $ciphertext = substr($raw, 28);
1298
1299 $key = self::secret_crypto_key();
1300 $plaintext = openssl_decrypt($ciphertext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag);
1301
1302 if ($plaintext === false) {
1303 return false;
1304 }
1305
1306 return $plaintext;
1307 }
1308
1309 /**
1310 * Get the decrypted whitelabel settings password.
1311 *
1312 * Reads the stored (encrypted) value and returns the plaintext for
1313 * verification or authorized display. A legacy plaintext value found in
1314 * storage is migrated to the encrypted format in place (one-time migration
1315 * on read). Returns '' when no password is set or when an encrypted value
1316 * can no longer be decrypted (salts changed / corrupt) — in that case the
1317 * stored value still counts as "password set" for protection checks, but
1318 * the user password cannot authenticate until it is reset.
1319 *
1320 * @return string
1321 */
1322 public static function get_whitelabel_password()
1323 {
1324 $whitelabel = self::get_whitelabel_settings();
1325 $stored = $whitelabel['settings_password'] ?? '';
1326
1327 if (!is_string($stored) || $stored === '') {
1328 return '';
1329 }
1330
1331 // One-time migration: encrypt a legacy plaintext value in place.
1332 // This is a whole-blob read-modify-write of metasync_options during a
1333 // read request; a concurrent settings save could theoretically clobber
1334 // it, but it fires at most once per legacy install so the window is
1335 // accepted rather than adding a dedicated option.
1336 if (!self::is_encrypted_secret($stored)) {
1337 $encrypted = self::encrypt_secret($stored);
1338 if (self::is_encrypted_secret($encrypted)) {
1339 $options = self::get_option();
1340 if (!is_array($options)) {
1341 $options = [];
1342 }
1343 $options['whitelabel']['settings_password'] = $encrypted;
1344 self::set_option($options);
1345 }
1346 return $stored;
1347 }
1348
1349 $plaintext = self::decrypt_secret($stored);
1350 return $plaintext === false ? '' : $plaintext;
1351 }
1352
1353 /**
1354 * Get whitelabel settings
1355 * Helper method to retrieve whitelabel configuration
1356 */
1357 public static function get_whitelabel_settings()
1358 {
1359 $whitelabel = self::get_option('whitelabel');
1360 return is_array($whitelabel) ? $whitelabel : array(
1361 'is_whitelabel' => false,
1362 'domain' => '',
1363 'logo' => '',
1364 'logo_light' => '',
1365 'logo_dark' => '',
1366 'company_name' => '',
1367 'color_palette' => array(),
1368 'updated_at' => 0
1369 );
1370 }
1371
1372 /**
1373 * Check if whitelabel mode is enabled
1374 */
1375 public static function is_whitelabel_enabled()
1376 {
1377 $whitelabel = self::get_whitelabel_settings();
1378 return isset($whitelabel['is_whitelabel']) && $whitelabel['is_whitelabel'] === true;
1379 }
1380
1381 /**
1382 * Get effective dashboard domain for the plugin
1383 * Returns whitelabel domain if set (regardless of is_whitelabel flag), otherwise respects staging/production mode
1384 */
1385 public static function get_dashboard_domain()
1386 {
1387 $whitelabel = self::get_whitelabel_settings();
1388
1389 // Priority 1: Use whitelabel domain if it's not empty (regardless of is_whitelabel flag)
1390 if (!empty($whitelabel['domain'])) {
1391 return $whitelabel['domain'];
1392 }
1393
1394 // Priority 2: Use endpoint manager to respect staging/production mode
1395 if (class_exists('Metasync_Endpoint_Manager')) {
1396 return Metasync_Endpoint_Manager::get_endpoint('DASHBOARD_DOMAIN');
1397 }
1398
1399 // Priority 3: Fallback to production default domain
1400 return self::DASHBOARD_DOMAIN;
1401 }
1402
1403 /**
1404 * Get whitelabel logo URL
1405 * Returns the whitelabel logo URL if logo is set
1406 */
1407 public static function get_whitelabel_logo()
1408 {
1409 $whitelabel = self::get_whitelabel_settings();
1410
1411 // Return logo if it's set and is a valid URL
1412 // Users should be able to set a custom logo without requiring a custom domain
1413 if (!empty($whitelabel['logo'])) {
1414 return $whitelabel['logo'];
1415 }
1416
1417 return null;
1418 }
1419
1420 /**
1421 * Get whitelabel logo URL for light theme
1422 * Falls back to legacy 'logo' field if logo_light is not set
1423 */
1424 public static function get_whitelabel_logo_light()
1425 {
1426 $whitelabel = self::get_whitelabel_settings();
1427
1428 if (!empty($whitelabel['logo_light'])) {
1429 return $whitelabel['logo_light'];
1430 }
1431
1432 if (!empty($whitelabel['logo'])) {
1433 return $whitelabel['logo'];
1434 }
1435
1436 return null;
1437 }
1438
1439 /**
1440 * Get whitelabel logo URL for dark theme
1441 * Falls back to legacy 'logo' field if logo_dark is not set
1442 */
1443 public static function get_whitelabel_logo_dark()
1444 {
1445 $whitelabel = self::get_whitelabel_settings();
1446
1447 if (!empty($whitelabel['logo_dark'])) {
1448 return $whitelabel['logo_dark'];
1449 }
1450
1451 if (!empty($whitelabel['logo'])) {
1452 return $whitelabel['logo'];
1453 }
1454
1455 return null;
1456 }
1457
1458 /**
1459 * Get whitelabel company name
1460 * Returns the whitelabel company name if whitelabel is active and company name is set
1461 */
1462 public static function get_whitelabel_company_name()
1463 {
1464 $whitelabel = self::get_whitelabel_settings();
1465
1466 // Return company name only if whitelabel is active and company name is set
1467 if (isset($whitelabel['is_whitelabel']) && $whitelabel['is_whitelabel'] === true && !empty($whitelabel['company_name'])) {
1468 return $whitelabel['company_name'];
1469 }
1470
1471 return null;
1472 }
1473
1474 /**
1475 * Get whitelabel OTTO name
1476 * Returns the custom OTTO name if set, otherwise returns 'OTTO'
1477 */
1478 public static function get_whitelabel_otto_name()
1479 {
1480 $general_settings = self::get_option('general');
1481
1482 // Return custom OTTO name if set, otherwise fallback to 'OTTO'
1483 if (!empty($general_settings['whitelabel_otto_name'])) {
1484 return $general_settings['whitelabel_otto_name'];
1485 }
1486
1487 return 'OTTO';
1488 }
1489
1490 /**
1491 * Check if the current user has access to the plugin based on role settings
1492 *
1493 * @return bool True if user has access, false otherwise
1494 */
1495 public static function current_user_has_plugin_access()
1496 {
1497 $user = wp_get_current_user();
1498 if (!$user || !$user->exists()) {
1499 return false;
1500 }
1501
1502 // Administrators always have access
1503 if (in_array('administrator', (array) $user->roles)) {
1504 return true;
1505 }
1506
1507 // Get the plugin access roles setting
1508 $general_options = self::get_option('general');
1509
1510 // If setting not configured, default to admin-only access
1511 if (!isset($general_options['plugin_access_roles'])) {
1512 return false;
1513 }
1514
1515 $allowed_roles = $general_options['plugin_access_roles'];
1516
1517 // If it's a string (single role), convert to array
1518 if (!is_array($allowed_roles)) {
1519 $allowed_roles = array($allowed_roles);
1520 }
1521
1522 // If "all" is selected, allow access
1523 if (in_array('all', $allowed_roles)) {
1524 return true;
1525 }
1526
1527 // If array is empty, deny access (only admins allowed)
1528 if (empty($allowed_roles)) {
1529 return false;
1530 }
1531
1532 // Check if user has any of the allowed roles
1533 $user_roles = (array) $user->roles;
1534 return !empty(array_intersect($user_roles, $allowed_roles));
1535 }
1536
1537 /**
1538 * Get active JWT token for Search Atlas API authentication
1539 * Convenience method accessible from anywhere in the plugin
1540 *
1541 * @param bool $force_refresh Force generation of new token even if cached one exists
1542 * @return string|false JWT token on success, false on failure
1543 */
1544 public static function get_jwt_token($force_refresh = false)
1545 {
1546 // Delegate to admin class method
1547 return Metasync_Admin::get_active_jwt_token($force_refresh);
1548 }
1549
1550 /**
1551 * Get effective plugin name
1552 * Returns plugin name respecting white label settings
1553 * Priority: 1) white_label_plugin_name 2) company branding + base_name 3) base_name
1554 */
1555 public static function get_effective_plugin_name($base_name = 'Search Atlas')
1556 {
1557 $general_settings = self::get_option('general');
1558
1559 // Priority 1: Use white_label_plugin_name if set and not empty
1560 if (!empty($general_settings['white_label_plugin_name'])) {
1561 return $general_settings['white_label_plugin_name'];
1562 }
1563
1564 $whitelabel = self::get_whitelabel_settings();
1565
1566 // Priority 2: If whitelabel is enabled and company name is provided, enhance the plugin name
1567 if (isset($whitelabel['is_whitelabel']) && $whitelabel['is_whitelabel'] === true && !empty($whitelabel['company_name'])) {
1568 return $whitelabel['company_name'] . ' ' . $base_name;
1569 }
1570
1571 // Priority 3: Return base_name as fallback
1572 return $base_name;
1573 }
1574
1575 /**
1576 * Render a standalone info-icon tooltip (the same visual/JS pattern used by
1577 * get_field_tooltips() + render_accordion_sections() in Metasync_Settings_Fields).
1578 * Use this on any admin page whose fields are NOT rendered through that
1579 * accordion field-loop (custom render_callback pages, standalone view files) —
1580 * the trigger/hover/positioning JS in admin/js/metasync-admin.js binds to
1581 * `.metasync-tooltip-trigger` globally, so no extra wiring is needed as long
1582 * as this markup is present on a page where metasync-admin.js is enqueued
1583 * (i.e. any admin page under this plugin's menu).
1584 *
1585 * @param string $tooltip_id Unique id for this tooltip (unique per page).
1586 * @param string $text Plain-English help text (escaped internally).
1587 */
1588 public static function render_tooltip_icon($tooltip_id, $text)
1589 {
1590 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- get_tooltip_icon_html() escapes every interpolated value internally (esc_attr / esc_html)
1591 echo self::get_tooltip_icon_html($tooltip_id, $text);
1592 }
1593
1594 /**
1595 * Same tooltip markup as render_tooltip_icon(), but RETURNS the HTML string
1596 * instead of echoing it. Use this when the tooltip needs to be concatenated
1597 * into another string — e.g. appended to the $title argument of
1598 * add_settings_field(), which WordPress core echoes raw next to the label.
1599 *
1600 * The whole trigger+popup pair is wrapped in its own small
1601 * `position: relative` anchor span. The popup CSS (.metasync-tooltip) is
1602 * `position: absolute; left: 100%` and positions itself relative to the
1603 * nearest positioned ancestor — on the main settings accordion that's the
1604 * `.metasync-field-label-wrapper` div, but standalone pages (custom
1605 * render_callback templates, add_settings_field titles on plain
1606 * do_settings_sections() pages, etc.) usually have no such ancestor, so
1607 * the popup would escape to whatever distant positioned element exists
1608 * on the page (rendering in the wrong corner of the screen). Wrapping
1609 * here makes every tooltip self-contained regardless of where it's placed.
1610 *
1611 * @param string $tooltip_id Unique id for this tooltip (unique per page).
1612 * @param string $text Plain-English help text (escaped internally).
1613 * @return string HTML markup for the info-icon trigger + tooltip content.
1614 */
1615 public static function get_tooltip_icon_html($tooltip_id, $text)
1616 {
1617 $html = '<span class="metasync-tooltip-anchor" style="position:relative;display:inline-block;vertical-align:middle;margin-left:8px;">';
1618 $html .= '<button type="button" class="metasync-tooltip-trigger" data-tooltip-id="' . esc_attr($tooltip_id) . '" aria-label="More information">';
1619 $html .= '<svg class="metasync-info-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">';
1620 $html .= '<circle cx="12" cy="12" r="10"></circle>';
1621 $html .= '<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"></path>';
1622 $html .= '<line x1="12" y1="17" x2="12.01" y2="17"></line>';
1623 $html .= '</svg>';
1624 $html .= '</button>';
1625
1626 $html .= '<div class="metasync-tooltip" id="tooltip-' . esc_attr($tooltip_id) . '" role="tooltip">';
1627 $html .= '<div class="metasync-tooltip-arrow"></div>';
1628 $html .= '<div class="metasync-tooltip-content">' . esc_html($text) . '</div>';
1629 $html .= '</div>';
1630 $html .= '</span>';
1631
1632 return $html;
1633 }
1634
1635 /**
1636 * Centralized API Key Event Logging
1637 * Provides structured logging for all API key related events with consistent formatting
1638 *
1639 * @since 1.0.0
1640 * @param string $event_type Type of event (change, refresh, reset, etc.)
1641 * @param string $api_key_type Type of API key (plugin_auth_token, searchatlas_api_key)
1642 * @param array $details Additional details about the event
1643 * @param string $level Log level (info, warning, error)
1644 */
1645 public static function log_api_key_event($event_type, $api_key_type, $details = array(), $level = 'info')
1646 {
1647 try {
1648 // Build structured log entry
1649 $log_data = array(
1650 'timestamp' => current_time('mysql'),
1651 'event_type' => $event_type,
1652 'api_key_type' => $api_key_type,
1653 'level' => $level
1654 );
1655
1656 // Add details if provided
1657 if (!empty($details)) {
1658 $log_data['details'] = $details;
1659 }
1660
1661 // Format log message with consistent structure
1662 $log_prefix = strtoupper($level) . ' - MetaSync API Key Event';
1663 $log_message = sprintf('[%s] %s: %s (%s)',
1664 $log_data['timestamp'],
1665 $log_prefix,
1666 $event_type,
1667 $api_key_type
1668 );
1669
1670 // Add details to log message if present
1671 if (!empty($details)) {
1672 $formatted_details = array();
1673 foreach ($details as $key => $value) {
1674 $formatted_details[] = $key . ': ' . (is_string($value) ? $value : json_encode($value));
1675 }
1676 $log_message .= ' - ' . implode(', ', $formatted_details);
1677 }
1678
1679
1680 // Optionally store in database for admin dashboard (future enhancement)
1681 // This could be extended to store in a dedicated log table
1682
1683 } catch (Exception $e) {
1684 // Fallback logging if structured logging fails
1685 error_log('MetaSync API Key Event Logging Error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
1686 }
1687 }
1688
1689 /**
1690 * Handle 404 error monitoring
1691 */
1692 public function handle_404_monitoring()
1693 {
1694 // Only process on frontend
1695 if (is_admin()) {
1696 return;
1697 }
1698
1699 // Check if this is a 404 error
1700 if (!is_404()) {
1701 return;
1702 }
1703
1704 // PROTECTION 0: Skip WordPress system paths — these are not "broken links"
1705 $request_uri = $_SERVER['REQUEST_URI'] ?? '';
1706 $skip_prefixes = [
1707 '/wp-json/',
1708 '/wp-admin/',
1709 '/feed/',
1710 '/xmlrpc.php',
1711 '/wp-login.php',
1712 '/wp-cron.php',
1713 ];
1714 foreach ($skip_prefixes as $prefix) {
1715 if (stripos($request_uri, $prefix) === 0) {
1716 return;
1717 }
1718 }
1719
1720 // PROTECTION 1: Exclude static assets to reduce noise
1721 $static_extensions = ['.css', '.js', '.jpg', '.jpeg', '.png', '.gif', '.ico', '.svg', '.woff', '.woff2', '.ttf', '.eot', '.map','.webp'];
1722 foreach ($static_extensions as $ext) {
1723 if (stripos($request_uri, $ext) !== false) {
1724 return; // Skip logging static asset 404s
1725 }
1726 }
1727
1728 // PROTECTION 2: Bot detection - Block known bot patterns
1729 $user_agent = $_SERVER['HTTP_USER_AGENT'] ?? '';
1730 $bot_patterns = ['bot', 'crawler', 'spider', 'scraper', 'curl', 'wget', 'python', 'java'];
1731 foreach ($bot_patterns as $pattern) {
1732 if (stripos($user_agent, $pattern) !== false) {
1733 // Rate limit bot 404s more aggressively
1734 $bot_rate_key = 'metasync_404_bot_rate';
1735 $bot_hits = get_transient($bot_rate_key);
1736 if ($bot_hits !== false && $bot_hits >= 10) {
1737 // Bot has hit 10+ 404s in last minute - stop logging
1738 return;
1739 }
1740 set_transient($bot_rate_key, $bot_hits === false ? 1 : $bot_hits + 1, 60);
1741 break;
1742 }
1743 }
1744
1745 // PROTECTION 3: Global rate limiting - Prevent 404 logging storms
1746 $global_rate_key = 'metasync_404_global_rate';
1747 $global_hits = get_transient($global_rate_key);
1748 if ($global_hits !== false && $global_hits >= 50) {
1749 // More than 50 404s per minute - stop logging to protect database
1750 if ($global_hits === 50) {
1751 error_log('MetaSync 404 Monitor: Rate limit exceeded - 50+ 404s per minute. Pausing logging.'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
1752 }
1753 set_transient($global_rate_key, $global_hits + 1, 60);
1754 return;
1755 }
1756 set_transient($global_rate_key, $global_hits === false ? 1 : $global_hits + 1, 60);
1757
1758 // Get current URL
1759 $current_url = $this->get_current_url();
1760
1761 // PROTECTION 4: Per-URL caching - Prevent same URL from being logged repeatedly
1762 $url_cache_key = 'metasync_404_cached_' . md5($current_url);
1763 if (get_transient($url_cache_key)) {
1764 // This URL was already logged in last 5 minutes - skip DB write
1765 return;
1766 }
1767
1768 // PROTECTION 5: URL validation - Skip obviously malicious URLs
1769 if (strlen($current_url) > 500 || preg_match('/[<>{}\\\\|]/', $current_url)) {
1770 return; // Skip potentially malicious or malformed URLs
1771 }
1772
1773 // Initialize 404 monitor database
1774 require_once plugin_dir_path(dirname(__FILE__)) . '404-monitor/class-metasync-404-monitor-database.php';
1775 $db_404 = new Metasync_Error_Monitor_Database();
1776
1777 // Get user agent (sanitized)
1778 $user_agent = isset($_SERVER['HTTP_USER_AGENT']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_USER_AGENT'])) : '';
1779
1780 // Log the 404 error
1781 $result = $db_404->update([
1782 'uri' => $current_url,
1783 'user_agent' => $user_agent,
1784 'date_time' => current_time('mysql'),
1785 'hits_count' => 1
1786 ]);
1787
1788 // Cache this URL for 5 minutes to prevent repeated DB writes
1789 set_transient($url_cache_key, true, 300);
1790 }
1791
1792 /**
1793 * Get current URL
1794 */
1795 private function get_current_url()
1796 {
1797 $protocol = is_ssl() ? 'https://' : 'http://';
1798
1799 // Safely get HTTP_HOST with fallback
1800 $host = isset($_SERVER['HTTP_HOST']) ? $_SERVER['HTTP_HOST'] : '';
1801 if (empty($host) && isset($_SERVER['SERVER_NAME'])) {
1802 $host = $_SERVER['SERVER_NAME'];
1803 }
1804 if (empty($host)) {
1805 // Fallback to WordPress site URL if available
1806 $host = wp_parse_url(home_url(), PHP_URL_HOST);
1807 }
1808
1809 // Safely get REQUEST_URI with fallback
1810 $uri = isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '/';
1811
1812 // Decode URL-encoded characters
1813 $uri = urldecode($uri);
1814
1815 // Ensure URI starts with /
1816 if (!str_starts_with($uri, '/')) {
1817 $uri = '/' . $uri;
1818 }
1819
1820 return $protocol . $host . $uri;
1821 }
1822
1823 /**
1824 * Run the loader to execute all of the hooks with WordPress.
1825 *
1826 * @since 1.0.0
1827 */
1828 public function run()
1829 {
1830 $this->loader->run();
1831 }
1832
1833 /**
1834 * The name of the plugin used to uniquely identify it within the context of
1835 * WordPress and to define internationalization functionality.
1836 *
1837 * @since 1.0.0
1838 * @return string The name of the plugin.
1839 */
1840 public function get_plugin_name()
1841 {
1842 return $this->plugin_name;
1843 }
1844
1845 /**
1846 * The reference to the class that orchestrates the hooks with the plugin.
1847 *
1848 * @since 1.0.0
1849 * @return Metasync_Loader Orchestrates the hooks of the plugin.
1850 */
1851 public function get_loader()
1852 {
1853 return $this->loader;
1854 }
1855
1856 /**
1857 * Retrieve the version number of the plugin.
1858 *
1859 * @since 1.0.0
1860 * @return string The version number of the plugin.
1861 */
1862 public function get_version()
1863 {
1864 return $this->version;
1865 }
1866 }
1867