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 +216 -15 2.7.0 → 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,
@@ -553,8 +612,134 @@
553 612 return self::update_plugin_file_headers($import_data);
554 613 }
555 614
556 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 + /**
557 742 * Update the main plugin file headers with whitelabel values
558 743 * WordPress reads plugin metadata directly from the file header comments,
559 744 * so modifying these ensures whitelabel shows even when the plugin is deactivated.
560 745 *
@@ -564,9 +749,9 @@
564 749 private static function update_plugin_file_headers($import_data, $plugin_file = null)
565 750 {
566 751 $plugin_file = $plugin_file ?: plugin_dir_path(dirname(__FILE__)) . 'metasync.php';
567 752
568 - if (!file_exists($plugin_file) || !is_writable($plugin_file)) {
753 + if (!file_exists($plugin_file) || !wp_is_writable($plugin_file)) {
569 754 return false;
570 755 }
571 756
572 757 $content = file_get_contents($plugin_file);
@@ -580,9 +765,9 @@
580 765 // with default values to restore when whitelabel is cleared
581 766 $header_map = array(
582 767 'white_label_plugin_name' => array(
583 768 'header' => 'Plugin Name',
584 - 'default' => 'Search Atlas: The Premier AI SEO Plugin for Instant Optimization',
769 + 'default' => 'Search Atlas SEO - OTTO AI SEO Automation for WordPress',
585 770 ),
586 771 'white_label_plugin_description' => array(
587 772 'header' => 'Description',
588 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.',
@@ -607,12 +792,21 @@
607 792
608 793 foreach ($header_map as $setting_key => $field_config) {
609 794 $header_field = $field_config['header'];
610 795
611 - // Use whitelabel value if set, otherwise restore default
612 - $new_value = !empty($general[$setting_key])
613 - ? $general[$setting_key]
614 - : $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 + }
615 809
616 810 if (!is_string($new_value) || !self::is_valid_plugin_header_value($new_value, in_array($setting_key, $url_fields, true))) {
617 811 return false;
618 812 }
@@ -663,10 +857,17 @@
663 857 }
664 858
665 859 /**
666 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
667 868 */
668 - private static function is_valid_plugin_header_value($value, $is_url)
869 + public static function is_valid_plugin_header_value($value, $is_url)
669 870 {
670 871 if (
671 872 $value === ''
672 873 || preg_match('/^[\p{L}\p{M}\p{N}\p{P}\p{S}\p{Zs}]+$/u', $value) !== 1
@@ -682,9 +883,9 @@
682 883 if (filter_var($value, FILTER_VALIDATE_URL) === false) {
683 884 return false;
684 885 }
685 886
686 - $scheme = strtolower((string) parse_url($value, PHP_URL_SCHEME));
887 + $scheme = strtolower((string) wp_parse_url($value, PHP_URL_SCHEME));
687 888
688 889 return in_array($scheme, array('http', 'https'), true);
689 890 }
690 891
@@ -733,19 +934,19 @@
733 934
734 935 $permissions = fileperms($plugin_file);
735 936 $written = file_put_contents($temp_file, $content, LOCK_EX);
736 937 if ($written !== strlen($content)) {
737 - @unlink($temp_file);
938 + @wp_delete_file($temp_file);
738 939 return false;
739 940 }
740 941
741 942 if ($permissions !== false) {
742 - @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
743 944 }
744 945
745 946 if ($rename_file === null) {
746 947 $rename_file = static function ($source, $destination) {
747 - 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
748 949 };
749 950 }
750 951
751 952 // The temporary file is on the same filesystem, so a successful rename is
@@ -753,9 +954,9 @@
753 954 if ($rename_file($temp_file, $plugin_file)) {
754 955 return true;
755 956 }
756 957
757 - @unlink($temp_file);
958 + @wp_delete_file($temp_file);
758 959
759 960 return false;
760 961 }
761 962 }