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

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