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 +241 -15 2.6.26 → 2.7.2 View file →
@@ -39,8 +39,22 @@
39 39 */
40 40 const IMPORT_RETRY_BACKOFF_SECONDS = 3600;
41 41
42 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 + /**
43 57 * Machine-readable reason for the most recent import failure, consumed
44 58 * by render_whitelabel_import_failure_notice().
45 59 *
46 60 * @var string
@@ -47,8 +61,17 @@
47 61 */
48 62 private static $last_import_error = '';
49 63
50 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 + /**
51 74 * Canonical list of MetaSync custom WP-Cron hooks.
52 75 * Shared with Metasync_Deactivator so deactivation cleans up every scheduled hook.
53 76 *
54 77 * @since 2.5.x
@@ -281,13 +304,17 @@
281 304 }
282 305
283 306 // File has changed, import settings
284 307 if (!self::import_whitelabel_settings()) {
285 - update_option(self::IMPORT_FAILURE_OPTION, array(
308 + $failure = array(
286 309 'file_hash' => $file_hash,
287 310 'failed_at' => time(),
288 311 'error' => self::$last_import_error,
289 - ));
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);
290 317 return false;
291 318 }
292 319 delete_option(self::IMPORT_FAILURE_OPTION);
293 320
@@ -309,8 +336,18 @@
309 336 return self::$last_import_error;
310 337 }
311 338
312 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 + /**
313 350 * Admin notice for a White Label JSON import that keeps failing.
314 351 * Registered from metasync_check_whitelabel_on_admin(); the state is set
315 352 * by check_whitelabel_settings_update() when an import fails.
316 353 *
@@ -329,16 +366,29 @@
329 366 'json_read_failed' => 'the file could not be read',
330 367 'json_invalid' => 'the file is not valid JSON',
331 368 'json_structure_invalid' => 'the file structure is invalid',
332 369 'plugin_file_unreadable' => 'metasync.php could not be read',
370 + 'branding_field_invalid' => 'the white-label branding values are not valid',
333 371 'plugin_header_write_failed' => 'the plugin file headers could not be updated (is metasync.php writable?)',
334 372 'recovery_lock_busy' => 'another password operation was in progress',
335 373 'options_write_failed' => 'the settings could not be saved',
336 374 );
337 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.
338 379 echo '<div class="notice notice-error"><p><strong>White Label settings import failed.</strong> '
339 380 . 'MetaSync could not import <code>whitelabel-settings.json</code>: ' . esc_html($reason)
340 - . '. The import will be retried automatically; fix the file and reload to retry immediately.</p></div>';
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>';
341 391 }
342 392
343 393 /**
344 394 * Import whitelabel settings from JSON file if available
@@ -355,8 +405,9 @@
355 405 return false;
356 406 }
357 407
358 408 self::$last_import_error = 'unknown';
409 + self::$last_import_invalid_fields = array();
359 410
360 411 // Read JSON file
361 412 $json_content = file_get_contents($json_file);
362 413 if ($json_content === false) {
@@ -427,8 +478,16 @@
427 478 'general_settings' => $options['general'] ?? array(),
428 479 );
429 480 if (!self::update_plugin_file_headers($header_data, $plugin_file)) {
430 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 + }
431 490 return false;
432 491 }
433 492
434 493 // Restore bundled icon: if the icon value is a __bundled_icon__{ext} marker,
@@ -471,8 +530,15 @@
471 530 $import_lock_owner = '';
472 531 if ($carries_password) {
473 532 require_once __DIR__ . '/class-metasync-admin-ajax.php';
474 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 + }
475 541 if (!Metasync_Admin_Ajax::instance()->acquire_recovery_lock($import_lock_owner)) {
476 542 // A recovery or settings save is mid-write; retry via the normal
477 543 // backoff instead of fighting for the lock.
478 544 self::$last_import_error = 'recovery_lock_busy';
@@ -504,8 +570,26 @@
504 570 return true;
505 571 }
506 572
507 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 + /**
508 592 * Sync plugin file headers from the current saved options in the database.
509 593 * Call this after saving whitelabel settings via the admin UI to ensure
510 594 * the plugin file headers reflect the latest whitelabel values.
511 595 *
@@ -528,8 +612,134 @@
528 612 return self::update_plugin_file_headers($import_data);
529 613 }
530 614
531 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 + /**
532 742 * Update the main plugin file headers with whitelabel values
533 743 * WordPress reads plugin metadata directly from the file header comments,
534 744 * so modifying these ensures whitelabel shows even when the plugin is deactivated.
535 745 *
@@ -539,9 +749,9 @@
539 749 private static function update_plugin_file_headers($import_data, $plugin_file = null)
540 750 {
541 751 $plugin_file = $plugin_file ?: plugin_dir_path(dirname(__FILE__)) . 'metasync.php';
542 752
543 - if (!file_exists($plugin_file) || !is_writable($plugin_file)) {
753 + if (!file_exists($plugin_file) || !wp_is_writable($plugin_file)) {
544 754 return false;
545 755 }
546 756
547 757 $content = file_get_contents($plugin_file);
@@ -555,9 +765,9 @@
555 765 // with default values to restore when whitelabel is cleared
556 766 $header_map = array(
557 767 'white_label_plugin_name' => array(
558 768 'header' => 'Plugin Name',
559 - 'default' => 'Search Atlas: The Premier AI SEO Plugin for Instant Optimization',
769 + 'default' => 'Search Atlas SEO - OTTO AI SEO Automation for WordPress',
560 770 ),
561 771 'white_label_plugin_description' => array(
562 772 'header' => 'Description',
563 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.',
@@ -582,12 +792,21 @@
582 792
583 793 foreach ($header_map as $setting_key => $field_config) {
584 794 $header_field = $field_config['header'];
585 795
586 - // Use whitelabel value if set, otherwise restore default
587 - $new_value = !empty($general[$setting_key])
588 - ? $general[$setting_key]
589 - : $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 + }
590 809
591 810 if (!is_string($new_value) || !self::is_valid_plugin_header_value($new_value, in_array($setting_key, $url_fields, true))) {
592 811 return false;
593 812 }
@@ -638,10 +857,17 @@
638 857 }
639 858
640 859 /**
641 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
642 868 */
643 - private static function is_valid_plugin_header_value($value, $is_url)
869 + public static function is_valid_plugin_header_value($value, $is_url)
644 870 {
645 871 if (
646 872 $value === ''
647 873 || preg_match('/^[\p{L}\p{M}\p{N}\p{P}\p{S}\p{Zs}]+$/u', $value) !== 1
@@ -657,9 +883,9 @@
657 883 if (filter_var($value, FILTER_VALIDATE_URL) === false) {
658 884 return false;
659 885 }
660 886
661 - $scheme = strtolower((string) parse_url($value, PHP_URL_SCHEME));
887 + $scheme = strtolower((string) wp_parse_url($value, PHP_URL_SCHEME));
662 888
663 889 return in_array($scheme, array('http', 'https'), true);
664 890 }
665 891
@@ -708,19 +934,19 @@
708 934
709 935 $permissions = fileperms($plugin_file);
710 936 $written = file_put_contents($temp_file, $content, LOCK_EX);
711 937 if ($written !== strlen($content)) {
712 - @unlink($temp_file);
938 + @wp_delete_file($temp_file);
713 939 return false;
714 940 }
715 941
716 942 if ($permissions !== false) {
717 - @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
718 944 }
719 945
720 946 if ($rename_file === null) {
721 947 $rename_file = static function ($source, $destination) {
722 - 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
723 949 };
724 950 }
725 951
726 952 // The temporary file is on the same filesystem, so a successful rename is
@@ -728,9 +954,9 @@
728 954 if ($rename_file($temp_file, $plugin_file)) {
729 955 return true;
730 956 }
731 957
732 - @unlink($temp_file);
958 + @wp_delete_file($temp_file);
733 959
734 960 return false;
735 961 }
736 962 }