PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.25
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.25
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-activator.php

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

607 lines 18.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Fired during plugin activation
5 *
6 * @link https://searchatlas.com
7 * @since 1.0.0
8 *
9 * @package Metasync
10 * @subpackage Metasync/includes
11 */
12
13 /**
14 * Fired during plugin activation.
15 *
16 * This class defines all code necessary to run during the plugin's activation.
17 *
18 * @since 1.0.0
19 * @package Metasync
20 * @subpackage Metasync/includes
21 * @author Engineering Team <[email protected]>
22 */
23 class Metasync_Activator
24 {
25
26 /**
27 * Canonical list of MetaSync custom WP-Cron hooks.
28 * Shared with Metasync_Deactivator so deactivation cleans up every scheduled hook.
29 *
30 * @since 2.5.x
31 * @var string[]
32 */
33 public static $cron_hooks = [
34 'metasync_sync_log_daily_cleanup',
35 'metasync_announce_cron',
36 'metasync_rate_limit_cleanup',
37 'metasync_heartbeat_cron_check',
38 'metasync_burst_heartbeat',
39 'metasync_check_debug_limits',
40 'metasync_cleanup_transients',
41 'metasync_hidden_post_check',
42 'metasync_otto_recheck_404_exclusions',
43 'metasync_db_cleanup',
44 'metasync_media_batch_optimize_cron',
45 'metasync_speed_cache_cleanup',
46 'metasync_process_seo_job',
47 'metasync_process_otto_crawl_url_job',
48 'metasync_process_otto_batch_cache_job',
49 'metasync_host_blocking_check',
50 'metasync_host_blocking_weekly_check',
51 ];
52
53 /**
54 * Short Description. (use period)
55 *
56 * Long Description.
57 *
58 * @since 1.0.0
59 */
60 public static function activate()
61 {
62 // WordPress core sitemap functionality is required
63 // if (wp_sitemaps_get_server()->sitemaps_enabled() == false) {
64 // add_filter('wp_sitemaps_enabled', '__return_true');
65 // }
66
67 // Generate Plugin Auth Token on first activation
68 self::ensure_plugin_auth_token();
69
70 // Import whitelabel settings only if the JSON file is new or changed
71 // (prevents overwriting admin UI changes on every deactivate/activate cycle)
72 self::check_whitelabel_settings_update(true);
73
74 // Pre-SSO announce: tell backend plugin is installed (PR4 - heartbeat reliability)
75 update_option('metasync_announce_attempt_count', 0);
76 self::send_announce_ping();
77 update_option('metasync_announce_attempt_count', 1);
78
79 // Schedule cron for announce pings 2-5 (every 10 minutes)
80 if (!wp_next_scheduled('metasync_announce_cron')) {
81 wp_schedule_event(time() + 10 * MINUTE_IN_SECONDS, 'metasync_every_10_minutes', 'metasync_announce_cron');
82 }
83
84 // Detect a host that blocks GET/POST between this site and Search Atlas.
85 // Deferred to cron (~10 minutes out) rather than run here — a blocking HTTP call
86 // inside the activation request is exactly what has taken slow hosts down before,
87 // and a firewall-blocked request is the slowest kind there is.
88 if (class_exists('Metasync_Host_Blocking_Check')) {
89 Metasync_Host_Blocking_Check::schedule_initial_check();
90 }
91
92 // Set first activation flag for setup wizard
93 if (!get_option('metasync_first_activation_time')) {
94 update_option('metasync_first_activation_time', current_time('mysql'));
95 update_option('metasync_show_wizard', true);
96 }
97
98 // Auto-disable WP core sitemap if a MetaSync sitemap already exists.
99 // Skip on fresh installs where no sitemap has been generated yet so the site
100 // doesn't end up with zero sitemaps.
101 if (get_option('metasync_sitemap_auto_update', false)
102 || !empty(get_option('metasync_sitemap_files', []))
103 || file_exists(ABSPATH . 'sitemap_index.xml')
104 ) {
105 update_option('metasync_disable_wp_sitemap', true);
106 }
107
108 // Soft flush only (no .htaccess rewrite). A hard flush calls
109 // save_mod_rewrite_rules(), which takes an exclusive flock() on the site's
110 // root .htaccess. On hosts whose cache/optimizer also holds that lock (e.g.
111 // SiteGround + SG Optimizer), the activation request blocks until
112 // max_execution_time and the host returns a 500. MetaSync's rewrite rules are
113 // registered via add_rewrite_rule (stored in the `rewrite_rules` option, NOT
114 // in .htaccess), so a soft flush is sufficient and never touches the file.
115 flush_rewrite_rules(false);
116 }
117
118 /**
119 * Send pre-SSO announce ping to backend (zero-trust; backend rate-limits).
120 * POST /api/wp-plugin-announce/ with url + plugin_version; optional X-Plugin-Token for deduplication.
121 * Callable from activation and from init (rate-limited) when no API key yet.
122 *
123 * @since 2.5.x
124 */
125 public static function send_announce_ping()
126 {
127 $base = 'https://ca.searchatlas.com';
128 if (class_exists('Metasync_Endpoint_Manager')) {
129 $base = Metasync_Endpoint_Manager::get_endpoint('CA_API_DOMAIN');
130 } elseif (class_exists('Metasync')) {
131 $base = Metasync::CA_API_DOMAIN;
132 }
133 $url = rtrim($base, '/') . '/api/wp-plugin-announce/';
134
135 $body = wp_json_encode([
136 'url' => get_home_url(),
137 'plugin_version' => defined('METASYNC_VERSION') ? METASYNC_VERSION : 'unknown',
138 ]);
139
140 $options = get_option('metasync_options', []);
141 $plugin_auth_token = $options['general']['apikey'] ?? '';
142 $headers = [
143 'Content-Type' => 'application/json',
144 ];
145 if (!empty($plugin_auth_token)) {
146 $headers['X-Plugin-Token'] = $plugin_auth_token;
147 }
148
149 wp_remote_post($url, [
150 'body' => $body,
151 'headers' => $headers,
152 'timeout' => 10,
153 'blocking' => false,
154 ]);
155 }
156
157 /**
158 * Ensure Plugin Auth Token exists
159 * Generates a unique Plugin Auth Token during plugin activation
160 */
161 private static function ensure_plugin_auth_token()
162 {
163 $options = get_option('metasync_options', []);
164
165 if (empty($options['general']['apikey'])) {
166 // Generate unique Plugin Auth Token (alphanumeric only)
167 $plugin_auth_token = wp_generate_password(32, false, false);
168
169 // Initialize options structure if needed
170 if (!isset($options['general'])) {
171 $options['general'] = [];
172 }
173
174 // Store Plugin Auth Token
175 $options['general']['apikey'] = $plugin_auth_token;
176 update_option('metasync_options', $options);
177 }
178 }
179
180 /**
181 * Get the path to the whitelabel settings JSON file
182 *
183 * @return string|false Path to the file if it exists, false otherwise
184 * @since 2.5.0
185 */
186 public static function get_whitelabel_settings_file()
187 {
188 $plugin_dir = plugin_dir_path(dirname(__FILE__));
189
190 // Check for whitelabel-settings.json in plugin root
191 $json_file = $plugin_dir . 'whitelabel-settings.json';
192
193 // Also check in a common extracted zip location (if zip was extracted)
194 if (!file_exists($json_file)) {
195 $json_file = $plugin_dir . 'metasync/whitelabel-settings.json';
196 }
197
198 if (!file_exists($json_file)) {
199 return false;
200 }
201
202 return $json_file;
203 }
204
205 /**
206 * Check if whitelabel settings file has been updated and import if needed
207 * This method should be called on init to detect plugin uploads/updates
208 *
209 * @param bool $trusted_update_context True only for core activation/upgrader hooks.
210 * @return bool Whether the file was already current or imported successfully.
211 * @since 2.5.0
212 */
213 public static function check_whitelabel_settings_update($trusted_update_context = false)
214 {
215 if (!$trusted_update_context && !current_user_can('manage_options')) {
216 return false;
217 }
218
219 $json_file = self::get_whitelabel_settings_file();
220
221 if ($json_file === false) {
222 return false;
223 }
224
225 // Get current file modification time and content hash
226 $file_mtime = filemtime($json_file);
227 $file_hash = md5_file($json_file);
228 if (!is_int($file_mtime) || !is_string($file_hash)) {
229 return false;
230 }
231
232 // Get stored file info
233 $stored_mtime = get_option('metasync_whitelabel_file_mtime', 0);
234 $stored_hash = get_option('metasync_whitelabel_file_hash', '');
235
236 // Check if file has changed (either modification time or content)
237 if ($file_mtime > $stored_mtime || $file_hash !== $stored_hash) {
238 // File has changed, import settings
239 if (!self::import_whitelabel_settings()) {
240 return false;
241 }
242
243 // Record the file only after the complete import succeeds.
244 update_option('metasync_whitelabel_file_mtime', $file_mtime);
245 update_option('metasync_whitelabel_file_hash', $file_hash);
246 }
247
248 return true;
249 }
250
251 /**
252 * Import whitelabel settings from JSON file if available
253 * Checks for whitelabel-settings.json in the plugin directory or extracted zip
254 *
255 * @return bool Whether the complete import succeeded.
256 * @since 2.5.0
257 */
258 private static function import_whitelabel_settings()
259 {
260 $json_file = self::get_whitelabel_settings_file();
261
262 if ($json_file === false) {
263 return false;
264 }
265
266 // Read JSON file
267 $json_content = file_get_contents($json_file);
268 if ($json_content === false) {
269 return false;
270 }
271
272 // Decode JSON
273 $import_data = json_decode($json_content, true);
274 if ($import_data === null || json_last_error() !== JSON_ERROR_NONE) {
275 return false;
276 }
277
278 // Validate import data structure
279 if (!isset($import_data['whitelabel_settings']) || !is_array($import_data['whitelabel_settings'])) {
280 return false;
281 }
282
283 $plugin_file = plugin_dir_path(dirname(__FILE__)) . 'metasync.php';
284 $original_plugin_content = file_get_contents($plugin_file);
285 if ($original_plugin_content === false) {
286 return false;
287 }
288
289 // Get current options
290 $options = get_option('metasync_options', array());
291
292 // Import whitelabel settings
293 if (isset($import_data['whitelabel_settings'])) {
294 $whitelabel_settings = $import_data['whitelabel_settings'];
295
296 // Initialize whitelabel array if needed
297 if (!isset($options['whitelabel'])) {
298 $options['whitelabel'] = array();
299 }
300
301 // Merge imported settings with existing (imported settings take precedence)
302 $options['whitelabel'] = array_merge($options['whitelabel'], $whitelabel_settings);
303
304 // The settings password travels as plaintext in the export file so it
305 // can be imported on a site with different salts — encrypt it at rest.
306 if (!empty($options['whitelabel']['settings_password']) && is_string($options['whitelabel']['settings_password'])) {
307 $options['whitelabel']['settings_password'] = Metasync::encrypt_secret($options['whitelabel']['settings_password']);
308 }
309
310 // Update timestamp
311 $options['whitelabel']['updated_at'] = time();
312 $options['whitelabel']['imported_at'] = current_time('mysql');
313 }
314
315 // Import general settings related to whitelabel
316 if (isset($import_data['general_settings']) && is_array($import_data['general_settings'])) {
317 if (!isset($options['general'])) {
318 $options['general'] = array();
319 }
320
321 // Merge general settings
322 foreach ($import_data['general_settings'] as $key => $value) {
323 $options['general'][$key] = $value;
324 }
325 }
326
327 // Validate and persist the public plugin headers before any other import side effect.
328 $header_data = array(
329 'general_settings' => $options['general'] ?? array(),
330 );
331 if (!self::update_plugin_file_headers($header_data, $plugin_file)) {
332 return false;
333 }
334
335 // Restore bundled icon: if the icon value is a __bundled_icon__{ext} marker,
336 // copy the bundled file from the plugin directory to uploads and update the URL.
337 $icon_value = $options['general']['white_label_plugin_menu_icon'] ?? '';
338 if (!empty($icon_value) && strpos($icon_value, '__bundled_icon__') === 0) {
339 $ext = substr($icon_value, strlen('__bundled_icon__'));
340 $ext = preg_replace('/[^a-z0-9]/', '', strtolower($ext)); // sanitize
341 $bundled_file = plugin_dir_path(dirname(__FILE__)) . 'whitelabel-icon.' . $ext;
342
343 if (file_exists($bundled_file) && in_array($ext, ['png', 'svg'], true)) {
344 $upload_dir = wp_upload_dir();
345 $dest_dir = $upload_dir['basedir'] . '/metasync';
346 if (!file_exists($dest_dir)) {
347 wp_mkdir_p($dest_dir);
348 }
349 $dest_file = $dest_dir . '/whitelabel-icon.' . $ext;
350 if (copy($bundled_file, $dest_file)) {
351 $options['general']['white_label_plugin_menu_icon'] = $upload_dir['baseurl'] . '/metasync/whitelabel-icon.' . $ext;
352 } else {
353 // Could not copy — clear the broken marker so default icon shows
354 $options['general']['white_label_plugin_menu_icon'] = '';
355 }
356 } else {
357 // Bundled file missing or unsupported extension — clear the marker
358 $options['general']['white_label_plugin_menu_icon'] = '';
359 }
360 }
361
362 // Save updated options
363 update_option('metasync_options', $options);
364 if (get_option('metasync_options', null) !== $options) {
365 if (self::atomically_replace_plugin_file($plugin_file, $original_plugin_content)) {
366 wp_cache_delete('plugins', 'plugins');
367 }
368 return false;
369 }
370
371 // Optionally delete the JSON file after successful import (uncomment if desired)
372 // unlink($json_file);
373
374 return true;
375 }
376
377 /**
378 * Sync plugin file headers from the current saved options in the database.
379 * Call this after saving whitelabel settings via the admin UI to ensure
380 * the plugin file headers reflect the latest whitelabel values.
381 *
382 * @since 2.5.0
383 */
384 public static function sync_plugin_file_headers()
385 {
386 if (!current_user_can('manage_options')) {
387 return false;
388 }
389
390 $options = get_option('metasync_options', array());
391 $general = $options['general'] ?? array();
392
393 // Build the import_data format expected by update_plugin_file_headers
394 $import_data = array(
395 'general_settings' => $general,
396 );
397
398 return self::update_plugin_file_headers($import_data);
399 }
400
401 /**
402 * Update the main plugin file headers with whitelabel values
403 * WordPress reads plugin metadata directly from the file header comments,
404 * so modifying these ensures whitelabel shows even when the plugin is deactivated.
405 *
406 * @param array $import_data The imported whitelabel data
407 * @since 2.5.0
408 */
409 private static function update_plugin_file_headers($import_data, $plugin_file = null)
410 {
411 $plugin_file = $plugin_file ?: plugin_dir_path(dirname(__FILE__)) . 'metasync.php';
412
413 if (!file_exists($plugin_file) || !is_writable($plugin_file)) {
414 return false;
415 }
416
417 $content = file_get_contents($plugin_file);
418 if ($content === false) {
419 return false;
420 }
421
422 $general = $import_data['general_settings'] ?? array();
423
424 // Map of whitelabel setting keys to plugin header field names
425 // with default values to restore when whitelabel is cleared
426 $header_map = array(
427 'white_label_plugin_name' => array(
428 'header' => 'Plugin Name',
429 'default' => 'Search Atlas: The Premier AI SEO Plugin for Instant Optimization',
430 ),
431 'white_label_plugin_description' => array(
432 'header' => 'Description',
433 'default' => 'Search Atlas SEO is an intuitive WordPress Plugin that transforms the most complicated, most labor-intensive SEO tasks into streamlined, straightforward processes. With a few clicks, the meta-bulk update feature automates the re-optimization of meta tags using AI to increase clicks. Stay up-to-date with the freshest Google Search data for your entire site or targeted URLs within the Meta Sync plug-in page.',
434 ),
435 'white_label_plugin_author' => array(
436 'header' => 'Author',
437 'default' => 'Search Atlas',
438 ),
439 'white_label_plugin_author_uri' => array(
440 'header' => 'Author URI',
441 'default' => 'https://searchatlas.com',
442 ),
443 'white_label_plugin_uri' => array(
444 'header' => 'Plugin URI',
445 'default' => 'https://searchatlas.com/',
446 ),
447 );
448
449 $modified = false;
450 $original_content = $content;
451 $url_fields = array('white_label_plugin_author_uri', 'white_label_plugin_uri');
452
453 foreach ($header_map as $setting_key => $field_config) {
454 $header_field = $field_config['header'];
455
456 // Use whitelabel value if set, otherwise restore default
457 $new_value = !empty($general[$setting_key])
458 ? $general[$setting_key]
459 : $field_config['default'];
460
461 if (!is_string($new_value) || !self::is_valid_plugin_header_value($new_value, in_array($setting_key, $url_fields, true))) {
462 return false;
463 }
464
465 // Match the header line: " * Field Name: any value"
466 // Handles varying whitespace between field name and value
467 $pattern = '/^(\s*\*\s*' . preg_quote($header_field, '/') . ':\s*)(.+)$/m';
468
469 if (preg_match_all($pattern, $content, $matches) !== 1) {
470 return false;
471 }
472
473 // Only replace if the value actually differs from what's in the file.
474 if (trim($matches[2][0]) !== trim($new_value)) {
475 $content = preg_replace_callback(
476 $pattern,
477 static function ($match) use ($new_value) {
478 return $match[1] . $new_value;
479 },
480 $content,
481 1,
482 $replacement_count
483 );
484
485 if (!is_string($content) || $replacement_count !== 1) {
486 return false;
487 }
488 $modified = true;
489 }
490 }
491
492 if (!self::is_valid_transformed_plugin_file($original_content, $content, $header_map)) {
493 return false;
494 }
495
496 if (!$modified) {
497 return true;
498 }
499
500 if (!self::atomically_replace_plugin_file($plugin_file, $content)) {
501 return false;
502 }
503
504 // Clear WordPress plugin cache so it reads the updated headers.
505 wp_cache_delete('plugins', 'plugins');
506
507 return true;
508 }
509
510 /**
511 * Validate a value before placing it inside the plugin's PHP docblock.
512 */
513 private static function is_valid_plugin_header_value($value, $is_url)
514 {
515 if (
516 $value === ''
517 || preg_match('/^[\p{L}\p{M}\p{N}\p{P}\p{S}\p{Zs}]+$/u', $value) !== 1
518 || preg_match('/\*\/|<\?php|\?>/i', $value) !== 0
519 ) {
520 return false;
521 }
522
523 if (!$is_url) {
524 return true;
525 }
526
527 if (filter_var($value, FILTER_VALIDATE_URL) === false) {
528 return false;
529 }
530
531 $scheme = strtolower((string) parse_url($value, PHP_URL_SCHEME));
532
533 return in_array($scheme, array('http', 'https'), true);
534 }
535
536 /**
537 * Validate the complete transformed PHP file and its header structure.
538 */
539 private static function is_valid_transformed_plugin_file($original, $transformed, $header_map)
540 {
541 if (!is_string($transformed) || strpos($transformed, '<?php') !== 0) {
542 return false;
543 }
544
545 if (
546 substr_count($original, '<?php') !== substr_count($transformed, '<?php')
547 || substr_count($original, '?>') !== substr_count($transformed, '?>')
548 ) {
549 return false;
550 }
551
552 foreach ($header_map as $field_config) {
553 $pattern = '/^\s*\*\s*' . preg_quote($field_config['header'], '/') . ':\s*.+$/m';
554 if (preg_match_all($pattern, $transformed) !== 1) {
555 return false;
556 }
557 }
558
559 try {
560 $tokens = token_get_all($transformed, TOKEN_PARSE);
561 } catch (ParseError $error) {
562 return false;
563 }
564
565 return true;
566 }
567
568 /**
569 * Replace a plugin file from a same-directory temporary file.
570 */
571 private static function atomically_replace_plugin_file($plugin_file, $content, $rename_file = null)
572 {
573 $directory = dirname($plugin_file);
574 $temp_file = tempnam($directory, '.metasync-header-');
575 if ($temp_file === false) {
576 return false;
577 }
578
579 $permissions = fileperms($plugin_file);
580 $written = file_put_contents($temp_file, $content, LOCK_EX);
581 if ($written !== strlen($content)) {
582 @unlink($temp_file);
583 return false;
584 }
585
586 if ($permissions !== false) {
587 @chmod($temp_file, $permissions & 0777);
588 }
589
590 if ($rename_file === null) {
591 $rename_file = static function ($source, $destination) {
592 return @rename($source, $destination);
593 };
594 }
595
596 // The temporary file is on the same filesystem, so a successful rename is
597 // atomic. On failure the original path has not been touched.
598 if ($rename_file($temp_file, $plugin_file)) {
599 return true;
600 }
601
602 @unlink($temp_file);
603
604 return false;
605 }
606 }
607