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
← All changes | includes/class-metasync-activator.php +370 -14 2.6.25 → 2.7.2 View file →
@@ -23,8 +23,55 @@
23 23 class Metasync_Activator
24 24 {
25 25
26 26 /**
27 + * Option recording the last failed White Label JSON import.
28 + * Stores array{file_hash: string, failed_at: int, error: string}.
29 + *
30 + * @var string
31 + */
32 + const IMPORT_FAILURE_OPTION = 'metasync_whitelabel_import_failure';
33 +
34 + /**
35 + * Retry cadence (seconds) for a failing White Label import so a
36 + * broken file cannot produce a silent retry storm on every admin request.
37 + *
38 + * @var int
39 + */
40 + const IMPORT_RETRY_BACKOFF_SECONDS = 3600;
41 +
42 + /**
43 + * The white-label settings whose values are mirrored into the
44 + * metasync.php plugin header.
45 + *
46 + * @var string[]
47 + */
48 + const BRANDING_HEADER_FIELDS = [
49 + 'white_label_plugin_name',
50 + 'white_label_plugin_description',
51 + 'white_label_plugin_author',
52 + 'white_label_plugin_author_uri',
53 + 'white_label_plugin_uri',
54 + ];
55 +
56 + /**
57 + * Machine-readable reason for the most recent import failure, consumed
58 + * by render_whitelabel_import_failure_notice().
59 + *
60 + * @var string
61 + */
62 + private static $last_import_error = '';
63 +
64 + /**
65 + * Field-specific validation errors for the most recent import failure,
66 + * keyed by white-label setting name. Populated only when the import was
67 + * rejected because a branding value fails the plugin-header contract.
68 + *
69 + * @var array<string, string>
70 + */
71 + private static $last_import_invalid_fields = array();
72 +
73 + /**
27 74 * Canonical list of MetaSync custom WP-Cron hooks.
28 75 * Shared with Metasync_Deactivator so deactivation cleans up every scheduled hook.
29 76 *
30 77 * @since 2.5.x
@@ -211,8 +258,18 @@
211 258 * @since 2.5.0
212 259 */
213 260 public static function check_whitelabel_settings_update($trusted_update_context = false)
214 261 {
262 +// Never run the import from AJAX handlers. admin-ajax requests
263 + // (notably the Forgot Password recovery call) run admin_init first, so an
264 + // import attempted there already holds the shared recovery lock when the
265 + // handler itself runs, and every recovery request returns HTTP 429.
266 + // Trusted activation/upgrader contexts run once per update, not per
267 + // request, and stay exempt.
268 + if (!$trusted_update_context && wp_doing_ajax()) {
269 + return false;
270 + }
271 +
215 272 if (!$trusted_update_context && !current_user_can('manage_options')) {
216 273 return false;
217 274 }
218 275
@@ -234,12 +291,33 @@
234 291 $stored_hash = get_option('metasync_whitelabel_file_hash', '');
235 292
236 293 // Check if file has changed (either modification time or content)
237 294 if ($file_mtime > $stored_mtime || $file_hash !== $stored_hash) {
295 + // A failing import must not retry on every admin request.
296 + // Back off per failing file hash; a successful import or a changed
297 + // file clears the state and retries immediately.
298 + $failure = get_option(self::IMPORT_FAILURE_OPTION, array());
299 + if (is_array($failure)
300 + && isset($failure['file_hash'], $failure['failed_at'])
301 + && (string) $failure['file_hash'] === $file_hash
302 + && (time() - (int) $failure['failed_at']) < self::IMPORT_RETRY_BACKOFF_SECONDS) {
303 + return false;
304 + }
305 +
238 306 // File has changed, import settings
239 307 if (!self::import_whitelabel_settings()) {
308 + $failure = array(
309 + 'file_hash' => $file_hash,
310 + 'failed_at' => time(),
311 + 'error' => self::$last_import_error,
312 + );
313 + if (self::$last_import_error === 'branding_field_invalid' && !empty(self::$last_import_invalid_fields)) {
314 + $failure['invalid_fields'] = self::$last_import_invalid_fields;
315 + }
316 + update_option(self::IMPORT_FAILURE_OPTION, $failure);
240 317 return false;
241 318 }
319 + delete_option(self::IMPORT_FAILURE_OPTION);
242 320
243 321 // Record the file only after the complete import succeeds.
244 322 update_option('metasync_whitelabel_file_mtime', $file_mtime);
245 323 update_option('metasync_whitelabel_file_hash', $file_hash);
@@ -248,8 +326,72 @@
248 326 return true;
249 327 }
250 328
251 329 /**
330 + * Machine-readable reason of the most recent failed White Label import.
331 + *
332 + * @return string
333 + */
334 + public static function get_last_import_error()
335 + {
336 + return self::$last_import_error;
337 + }
338 +
339 + /**
340 + * Field-specific branding errors of the most recent failed White Label import.
341 + *
342 + * @return array<string, string>
343 + */
344 + public static function get_last_import_invalid_fields()
345 + {
346 + return self::$last_import_invalid_fields;
347 + }
348 +
349 + /**
350 + * Admin notice for a White Label JSON import that keeps failing.
351 + * Registered from metasync_check_whitelabel_on_admin(); the state is set
352 + * by check_whitelabel_settings_update() when an import fails.
353 + *
354 + * @return void
355 + */
356 + public static function render_whitelabel_import_failure_notice()
357 + {
358 + if (!current_user_can('manage_options')) {
359 + return;
360 + }
361 + $failure = get_option(self::IMPORT_FAILURE_OPTION, array());
362 + if (!is_array($failure) || empty($failure['file_hash']) || empty($failure['failed_at'])) {
363 + return;
364 + }
365 + $reasons = array(
366 + 'json_read_failed' => 'the file could not be read',
367 + 'json_invalid' => 'the file is not valid JSON',
368 + 'json_structure_invalid' => 'the file structure is invalid',
369 + 'plugin_file_unreadable' => 'metasync.php could not be read',
370 + 'branding_field_invalid' => 'the white-label branding values are not valid',
371 + 'plugin_header_write_failed' => 'the plugin file headers could not be updated (is metasync.php writable?)',
372 + 'recovery_lock_busy' => 'another password operation was in progress',
373 + 'options_write_failed' => 'the settings could not be saved',
374 + );
375 + $reason = isset($failure['error'], $reasons[$failure['error']]) ? $reasons[$failure['error']] : 'an unexpected error occurred';
376 +
377 + // Field-specific detail for a content rejection: name the exact
378 + // branding field so the package can be fixed rather than retried blind.
379 + echo '<div class="notice notice-error"><p><strong>White Label settings import failed.</strong> '
380 + . 'MetaSync could not import <code>whitelabel-settings.json</code>: ' . esc_html($reason)
381 + . '. The import will be retried automatically; fix the file and reload to retry immediately.</p>';
382 + if (isset($failure['invalid_fields']) && is_array($failure['invalid_fields']) && !empty($failure['invalid_fields'])) {
383 + echo '<ul style="margin: 8px 0 0 0; list-style: disc; padding-left: 20px;">';
384 + foreach ($failure['invalid_fields'] as $message) {
385 + echo '<li>' . esc_html((string) $message) . '</li>';
386 + }
387 + echo '</ul>';
388 + echo '<p style="margin: 8px 0 0 0;">Allowed: single-line text (including valid Unicode) and http(s) URLs. Line breaks, control/invisible characters, comment-ending text, PHP tags, and non-http(s) URLs are rejected.</p>';
389 + }
390 + echo '</div>';
391 + }
392 +
393 + /**
252 394 * Import whitelabel settings from JSON file if available
253 395 * Checks for whitelabel-settings.json in the plugin directory or extracted zip
254 396 *
255 397 * @return bool Whether the complete import succeeded.
@@ -262,11 +404,15 @@
262 404 if ($json_file === false) {
263 405 return false;
264 406 }
265 407
408 + self::$last_import_error = 'unknown';
409 + self::$last_import_invalid_fields = array();
410 +
266 411 // Read JSON file
267 412 $json_content = file_get_contents($json_file);
268 413 if ($json_content === false) {
414 + self::$last_import_error = 'json_read_failed';
269 415 return false;
270 416 }
271 417
272 418 // Decode JSON
@@ -271,13 +417,15 @@
271 417
272 418 // Decode JSON
273 419 $import_data = json_decode($json_content, true);
274 420 if ($import_data === null || json_last_error() !== JSON_ERROR_NONE) {
421 + self::$last_import_error = 'json_invalid';
275 422 return false;
276 423 }
277 424
278 425 // Validate import data structure
279 426 if (!isset($import_data['whitelabel_settings']) || !is_array($import_data['whitelabel_settings'])) {
427 + self::$last_import_error = 'json_structure_invalid';
280 428 return false;
281 429 }
282 430
283 431 $plugin_file = plugin_dir_path(dirname(__FILE__)) . 'metasync.php';
@@ -282,8 +430,9 @@
282 430
283 431 $plugin_file = plugin_dir_path(dirname(__FILE__)) . 'metasync.php';
284 432 $original_plugin_content = file_get_contents($plugin_file);
285 433 if ($original_plugin_content === false) {
434 + self::$last_import_error = 'plugin_file_unreadable';
286 435 return false;
287 436 }
288 437
289 438 // Get current options
@@ -328,8 +477,17 @@
328 477 $header_data = array(
329 478 'general_settings' => $options['general'] ?? array(),
330 479 );
331 480 if (!self::update_plugin_file_headers($header_data, $plugin_file)) {
481 + self::$last_import_error = 'plugin_header_write_failed';
482 + // Distinguish a content rejection (invalid branding values) from an
483 + // environmental failure (unwritable file, mangled header block) so the
484 + // admin notice can point at the offending field.
485 + $invalid_fields = self::validate_plugin_header_settings($options['general'] ?? array());
486 + if (!empty($invalid_fields)) {
487 + self::$last_import_error = 'branding_field_invalid';
488 + self::$last_import_invalid_fields = $invalid_fields;
489 + }
332 490 return false;
333 491 }
334 492
335 493 // Restore bundled icon: if the icon value is a __bundled_icon__{ext} marker,
@@ -358,14 +516,52 @@
358 516 $options['general']['white_label_plugin_menu_icon'] = '';
359 517 }
360 518 }
361 519
362 - // Save updated options
363 - update_option('metasync_options', $options);
520 + // Save updated options.
521 +// when the package carries a settings password this is a
522 + // legitimate import write, but the recovery protection filter treats any
523 + // unauthenticated password write as a conflict and swaps it back to the
524 + // stored password — the verification below could then never pass once a
525 + // password is stored, and the retry loop would hold the shared recovery
526 + // lock on every admin request, blocking the Forgot Password flow.
527 + // Mirror the recovery flow's own persist path: take the lock and
528 + // authorize the write for the duration of the save.
529 + $carries_password = !empty($options['whitelabel']['settings_password']);
530 + $import_lock_owner = '';
531 + if ($carries_password) {
532 + require_once __DIR__ . '/class-metasync-admin-ajax.php';
533 + require_once __DIR__ . '/class-metasync-settings-registration.php';
534 + if (!self::class_supports_methods('Metasync_Admin_Ajax', array('acquire_recovery_lock', 'release_recovery_lock'))
535 + || !self::class_supports_methods('Metasync_Settings_Registration', array('authorize_recovery_password_write'))) {
536 + // The updater can replace plugin files after the old classes were
537 + // loaded in this request. Defer the import to the next request.
538 + self::$last_import_error = 'recovery_api_unavailable';
539 + return false;
540 + }
541 + if (!Metasync_Admin_Ajax::instance()->acquire_recovery_lock($import_lock_owner)) {
542 + // A recovery or settings save is mid-write; retry via the normal
543 + // backoff instead of fighting for the lock.
544 + self::$last_import_error = 'recovery_lock_busy';
545 + return false;
546 + }
547 + }
548 + try {
549 + if ($carries_password) {
550 + Metasync_Settings_Registration::authorize_recovery_password_write(true);
551 + }
552 + update_option('metasync_options', $options);
553 + } finally {
554 + if ($carries_password) {
555 + Metasync_Settings_Registration::authorize_recovery_password_write(false);
556 + Metasync_Admin_Ajax::instance()->release_recovery_lock($import_lock_owner);
557 + }
558 + }
364 559 if (get_option('metasync_options', null) !== $options) {
365 560 if (self::atomically_replace_plugin_file($plugin_file, $original_plugin_content)) {
366 561 wp_cache_delete('plugins', 'plugins');
367 562 }
563 + self::$last_import_error = 'options_write_failed';
368 564 return false;
369 565 }
370 566
371 567 // Optionally delete the JSON file after successful import (uncomment if desired)
@@ -374,8 +570,26 @@
374 570 return true;
375 571 }
376 572
377 573 /**
574 + * Check runtime class compatibility without letting static analysis fold it.
575 + *
576 + * @param string $class_name Class name loaded in the current request.
577 + * @param string[] $methods Required method names.
578 + * @return bool
579 + */
580 + private static function class_supports_methods($class_name, $methods)
581 + {
582 + foreach ($methods as $method) {
583 + if (!method_exists($class_name, $method)) {
584 + return false;
585 + }
586 + }
587 +
588 + return true;
589 + }
590 +
591 + /**
378 592 * Sync plugin file headers from the current saved options in the database.
379 593 * Call this after saving whitelabel settings via the admin UI to ensure
380 594 * the plugin file headers reflect the latest whitelabel values.
381 595 *
@@ -398,8 +612,134 @@
398 612 return self::update_plugin_file_headers($import_data);
399 613 }
400 614
401 615 /**
616 + * Absolute path of the plugin file whose header carries the branding.
617 + *
618 + * @return string
619 + */
620 + public static function plugin_header_file_path()
621 + {
622 + return plugin_dir_path(dirname(__FILE__)) . 'metasync.php';
623 + }
624 +
625 + /**
626 + * Apply a proposed save's branding to the physical plugin header BEFORE
627 + * the options write starts, so an unwritable or malformed target stops
628 + * the save while nothing has been persisted yet.
629 + *
630 + * Returns null when none of the branding values changed (the file is left
631 + * untouched — unrelated saves must not rewrite metasync.php), true when
632 + * the header now matches the proposed values, and false when the update
633 + * was rejected or failed (file unchanged, save must be aborted).
634 + *
635 + * @param mixed $proposed_general General section proposed for saving.
636 + * @param mixed $stored_general General section currently stored.
637 + * @return bool|null
638 + */
639 + public static function apply_plugin_file_headers_for_save($proposed_general, $stored_general)
640 + {
641 + $proposed_general = is_array($proposed_general) ? $proposed_general : array();
642 + $stored_general = is_array($stored_general) ? $stored_general : array();
643 +
644 + $changed = false;
645 + foreach (self::BRANDING_HEADER_FIELDS as $key) {
646 + if ((string) ($proposed_general[$key] ?? '') !== (string) ($stored_general[$key] ?? '')) {
647 + $changed = true;
648 + break;
649 + }
650 + }
651 + if (!$changed) {
652 + return null;
653 + }
654 +
655 + return self::update_plugin_file_headers(array('general_settings' => $proposed_general));
656 + }
657 +
658 + /**
659 + * Restore a previous metasync.php content after a save that wrote new
660 + * headers but then failed to persist the matching options.
661 + *
662 + * @param string $plugin_file Absolute path to metasync.php.
663 + * @param string|false $original_content Content captured before the header write (file_get_contents() result).
664 + * @return bool Whether the restore succeeded.
665 + */
666 + public static function restore_plugin_file_content($plugin_file, $original_content)
667 + {
668 + if (!is_string($original_content) || $original_content === '') {
669 + return false;
670 + }
671 + if (!self::atomically_replace_plugin_file($plugin_file, $original_content)) {
672 + return false;
673 + }
674 + wp_cache_delete('plugins', 'plugins');
675 + return true;
676 + }
677 +
678 + /**
679 + * Validate the five values that are written into metasync.php's plugin header.
680 + *
681 + * This is deliberately shared by administrator saves and package imports so
682 + * both paths enforce the same contract before options are persisted.
683 + *
684 + * @param mixed $general General settings submitted or imported.
685 + * @return array<string, string> Field-specific errors, keyed by setting name.
686 + */
687 + public static function validate_plugin_header_settings($general)
688 + {
689 + $general = is_array($general) ? $general : array();
690 + $header_map = array(
691 + 'white_label_plugin_name' => array(
692 + 'label' => 'Plugin Name',
693 + 'default' => 'Search Atlas SEO - OTTO AI SEO Automation for WordPress',
694 + 'url' => false,
695 + ),
696 + 'white_label_plugin_description' => array(
697 + 'label' => 'Plugin Description',
698 + '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.',
699 + 'url' => false,
700 + ),
701 + 'white_label_plugin_author' => array(
702 + 'label' => 'Author',
703 + 'default' => 'Search Atlas',
704 + 'url' => false,
705 + ),
706 + 'white_label_plugin_author_uri' => array(
707 + 'label' => 'Author URI',
708 + 'default' => 'https://searchatlas.com',
709 + 'url' => true,
710 + ),
711 + 'white_label_plugin_uri' => array(
712 + 'label' => 'Plugin URI',
713 + 'default' => 'https://searchatlas.com/',
714 + 'url' => true,
715 + ),
716 + );
717 + $errors = array();
718 +
719 + foreach ($header_map as $key => $config) {
720 + // A present-but-empty string resets to the default; a scalar like
721 + // "0" is a real value and must not be conflated with unset (the
722 + // classic PHP !empty() trap). Malformed non-strings fall through
723 + // to the is_string rejection below.
724 + $value = $config['default'];
725 + if (array_key_exists($key, $general)) {
726 + $raw = $general[$key];
727 + if (is_string($raw)) {
728 + $value = $raw !== '' ? $raw : $config['default'];
729 + } elseif ($raw !== null) {
730 + $value = $raw;
731 + }
732 + }
733 + if (!is_string($value) || !self::is_valid_plugin_header_value($value, $config['url'])) {
734 + $errors[$key] = sprintf('%s contains invalid characters or format.', $config['label']);
735 + }
736 + }
737 +
738 + return $errors;
739 + }
740 +
741 + /**
402 742 * Update the main plugin file headers with whitelabel values
403 743 * WordPress reads plugin metadata directly from the file header comments,
404 744 * so modifying these ensures whitelabel shows even when the plugin is deactivated.
405 745 *
@@ -409,9 +749,9 @@
409 749 private static function update_plugin_file_headers($import_data, $plugin_file = null)
410 750 {
411 751 $plugin_file = $plugin_file ?: plugin_dir_path(dirname(__FILE__)) . 'metasync.php';
412 752
413 - if (!file_exists($plugin_file) || !is_writable($plugin_file)) {
753 + if (!file_exists($plugin_file) || !wp_is_writable($plugin_file)) {
414 754 return false;
415 755 }
416 756
417 757 $content = file_get_contents($plugin_file);
@@ -425,9 +765,9 @@
425 765 // with default values to restore when whitelabel is cleared
426 766 $header_map = array(
427 767 'white_label_plugin_name' => array(
428 768 'header' => 'Plugin Name',
429 - 'default' => 'Search Atlas: The Premier AI SEO Plugin for Instant Optimization',
769 + 'default' => 'Search Atlas SEO - OTTO AI SEO Automation for WordPress',
430 770 ),
431 771 'white_label_plugin_description' => array(
432 772 'header' => 'Description',
433 773 '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.',
@@ -452,12 +792,21 @@
452 792
453 793 foreach ($header_map as $setting_key => $field_config) {
454 794 $header_field = $field_config['header'];
455 795
456 - // Use whitelabel value if set, otherwise restore default
457 - $new_value = !empty($general[$setting_key])
458 - ? $general[$setting_key]
459 - : $field_config['default'];
796 + // Use whitelabel value if set, otherwise restore default. A scalar
797 + // like "0" is a real value, not an unset field; an empty string or
798 + // null restores the default; malformed non-strings are rejected by
799 + // the is_string check below.
800 + $new_value = $field_config['default'];
801 + if (array_key_exists($setting_key, $general)) {
802 + $raw_value = $general[$setting_key];
803 + if (is_string($raw_value)) {
804 + $new_value = $raw_value !== '' ? $raw_value : $field_config['default'];
805 + } elseif ($raw_value !== null) {
806 + $new_value = $raw_value;
807 + }
808 + }
460 809
461 810 if (!is_string($new_value) || !self::is_valid_plugin_header_value($new_value, in_array($setting_key, $url_fields, true))) {
462 811 return false;
463 812 }
@@ -508,10 +857,17 @@
508 857 }
509 858
510 859 /**
511 860 * Validate a value before placing it inside the plugin's PHP docblock.
861 + *
862 + * Public so form/import handlers can pre-validate a value with the exact
863 + * contract used when metasync.php is physically rewritten.
864 + *
865 + * @param string $value Candidate header value.
866 + * @param bool $is_url Whether the value must be an http(s) URL.
867 + * @return bool
512 868 */
513 - private static function is_valid_plugin_header_value($value, $is_url)
869 + public static function is_valid_plugin_header_value($value, $is_url)
514 870 {
515 871 if (
516 872 $value === ''
517 873 || preg_match('/^[\p{L}\p{M}\p{N}\p{P}\p{S}\p{Zs}]+$/u', $value) !== 1
@@ -527,9 +883,9 @@
527 883 if (filter_var($value, FILTER_VALIDATE_URL) === false) {
528 884 return false;
529 885 }
530 886
531 - $scheme = strtolower((string) parse_url($value, PHP_URL_SCHEME));
887 + $scheme = strtolower((string) wp_parse_url($value, PHP_URL_SCHEME));
532 888
533 889 return in_array($scheme, array('http', 'https'), true);
534 890 }
535 891
@@ -578,19 +934,19 @@
578 934
579 935 $permissions = fileperms($plugin_file);
580 936 $written = file_put_contents($temp_file, $content, LOCK_EX);
581 937 if ($written !== strlen($content)) {
582 - @unlink($temp_file);
938 + @wp_delete_file($temp_file);
583 939 return false;
584 940 }
585 941
586 942 if ($permissions !== false) {
587 - @chmod($temp_file, $permissions & 0777);
943 + @chmod($temp_file, $permissions & 0777); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- direct POSIX permission change; WP_Filesystem would prompt for FTP credentials on non-direct hosts
588 944 }
589 945
590 946 if ($rename_file === null) {
591 947 $rename_file = static function ($source, $destination) {
592 - return @rename($source, $destination);
948 + return @rename($source, $destination); // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename -- atomic same-filesystem replace; WP_Filesystem::move() needs credential bootstrapping on non-direct hosts
593 949 };
594 950 }
595 951
596 952 // The temporary file is on the same filesystem, so a successful rename is
@@ -598,9 +954,9 @@
598 954 if ($rename_file($temp_file, $plugin_file)) {
599 955 return true;
600 956 }
601 957
602 - @unlink($temp_file);
958 + @wp_delete_file($temp_file);
603 959
604 960 return false;
605 961 }
606 962 }