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 +614 -39 2.6.5 → 2.7.2 View file →
@@ -23,8 +23,82 @@
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 + /**
74 + * Canonical list of MetaSync custom WP-Cron hooks.
75 + * Shared with Metasync_Deactivator so deactivation cleans up every scheduled hook.
76 + *
77 + * @since 2.5.x
78 + * @var string[]
79 + */
80 + public static $cron_hooks = [
81 + 'metasync_sync_log_daily_cleanup',
82 + 'metasync_announce_cron',
83 + 'metasync_rate_limit_cleanup',
84 + 'metasync_heartbeat_cron_check',
85 + 'metasync_burst_heartbeat',
86 + 'metasync_check_debug_limits',
87 + 'metasync_cleanup_transients',
88 + 'metasync_hidden_post_check',
89 + 'metasync_otto_recheck_404_exclusions',
90 + 'metasync_db_cleanup',
91 + 'metasync_media_batch_optimize_cron',
92 + 'metasync_speed_cache_cleanup',
93 + 'metasync_process_seo_job',
94 + 'metasync_process_otto_crawl_url_job',
95 + 'metasync_process_otto_batch_cache_job',
96 + 'metasync_host_blocking_check',
97 + 'metasync_host_blocking_weekly_check',
98 + ];
99 +
100 + /**
27 101 * Short Description. (use period)
28 102 *
29 103 * Long Description.
30 104 *
@@ -41,9 +115,9 @@
41 115 self::ensure_plugin_auth_token();
42 116
43 117 // Import whitelabel settings only if the JSON file is new or changed
44 118 // (prevents overwriting admin UI changes on every deactivate/activate cycle)
45 - self::check_whitelabel_settings_update();
119 + self::check_whitelabel_settings_update(true);
46 120
47 121 // Pre-SSO announce: tell backend plugin is installed (PR4 - heartbeat reliability)
48 122 update_option('metasync_announce_attempt_count', 0);
49 123 self::send_announce_ping();
@@ -53,8 +127,16 @@
53 127 if (!wp_next_scheduled('metasync_announce_cron')) {
54 128 wp_schedule_event(time() + 10 * MINUTE_IN_SECONDS, 'metasync_every_10_minutes', 'metasync_announce_cron');
55 129 }
56 130
131 + // Detect a host that blocks GET/POST between this site and Search Atlas.
132 + // Deferred to cron (~10 minutes out) rather than run here — a blocking HTTP call
133 + // inside the activation request is exactly what has taken slow hosts down before,
134 + // and a firewall-blocked request is the slowest kind there is.
135 + if (class_exists('Metasync_Host_Blocking_Check')) {
136 + Metasync_Host_Blocking_Check::schedule_initial_check();
137 + }
138 +
57 139 // Set first activation flag for setup wizard
58 140 if (!get_option('metasync_first_activation_time')) {
59 141 update_option('metasync_first_activation_time', current_time('mysql'));
60 142 update_option('metasync_show_wizard', true);
@@ -59,9 +141,26 @@
59 141 update_option('metasync_first_activation_time', current_time('mysql'));
60 142 update_option('metasync_show_wizard', true);
61 143 }
62 144
63 - flush_rewrite_rules();
145 + // Auto-disable WP core sitemap if a MetaSync sitemap already exists.
146 + // Skip on fresh installs where no sitemap has been generated yet so the site
147 + // doesn't end up with zero sitemaps.
148 + if (get_option('metasync_sitemap_auto_update', false)
149 + || !empty(get_option('metasync_sitemap_files', []))
150 + || file_exists(ABSPATH . 'sitemap_index.xml')
151 + ) {
152 + update_option('metasync_disable_wp_sitemap', true);
153 + }
154 +
155 + // Soft flush only (no .htaccess rewrite). A hard flush calls
156 + // save_mod_rewrite_rules(), which takes an exclusive flock() on the site's
157 + // root .htaccess. On hosts whose cache/optimizer also holds that lock (e.g.
158 + // SiteGround + SG Optimizer), the activation request blocks until
159 + // max_execution_time and the host returns a 500. MetaSync's rewrite rules are
160 + // registered via add_rewrite_rule (stored in the `rewrite_rules` option, NOT
161 + // in .htaccess), so a soft flush is sufficient and never touches the file.
162 + flush_rewrite_rules(false);
64 163 }
65 164
66 165 /**
67 166 * Send pre-SSO announce ping to backend (zero-trust; backend rate-limits).
@@ -153,21 +252,40 @@
153 252 /**
154 253 * Check if whitelabel settings file has been updated and import if needed
155 254 * This method should be called on init to detect plugin uploads/updates
156 255 *
256 + * @param bool $trusted_update_context True only for core activation/upgrader hooks.
257 + * @return bool Whether the file was already current or imported successfully.
157 258 * @since 2.5.0
158 259 */
159 - public static function check_whitelabel_settings_update()
260 + public static function check_whitelabel_settings_update($trusted_update_context = false)
160 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 +
272 + if (!$trusted_update_context && !current_user_can('manage_options')) {
273 + return false;
274 + }
275 +
161 276 $json_file = self::get_whitelabel_settings_file();
162 277
163 278 if ($json_file === false) {
164 - return;
279 + return false;
165 280 }
166 281
167 282 // Get current file modification time and content hash
168 283 $file_mtime = filemtime($json_file);
169 284 $file_hash = md5_file($json_file);
285 + if (!is_int($file_mtime) || !is_string($file_hash)) {
286 + return false;
287 + }
170 288
171 289 // Get stored file info
172 290 $stored_mtime = get_option('metasync_whitelabel_file_mtime', 0);
173 291 $stored_hash = get_option('metasync_whitelabel_file_hash', '');
@@ -173,49 +291,151 @@
173 291 $stored_hash = get_option('metasync_whitelabel_file_hash', '');
174 292
175 293 // Check if file has changed (either modification time or content)
176 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 +
177 306 // File has changed, import settings
178 - self::import_whitelabel_settings();
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);
317 + return false;
318 + }
319 + delete_option(self::IMPORT_FAILURE_OPTION);
179 320
180 - // Store new file info
321 + // Record the file only after the complete import succeeds.
181 322 update_option('metasync_whitelabel_file_mtime', $file_mtime);
182 323 update_option('metasync_whitelabel_file_hash', $file_hash);
183 324 }
325 +
326 + return true;
184 327 }
185 328
186 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 + /**
187 394 * Import whitelabel settings from JSON file if available
188 395 * Checks for whitelabel-settings.json in the plugin directory or extracted zip
189 396 *
190 - * This method is public to allow calling during both activation and plugin updates.
397 + * @return bool Whether the complete import succeeded.
191 398 * @since 2.5.0
192 399 */
193 - public static function import_whitelabel_settings()
400 + private static function import_whitelabel_settings()
194 401 {
195 402 $json_file = self::get_whitelabel_settings_file();
196 403
197 404 if ($json_file === false) {
198 - return;
405 + return false;
199 406 }
200 407
408 + self::$last_import_error = 'unknown';
409 + self::$last_import_invalid_fields = array();
410 +
201 411 // Read JSON file
202 412 $json_content = file_get_contents($json_file);
203 413 if ($json_content === false) {
204 - return;
414 + self::$last_import_error = 'json_read_failed';
415 + return false;
205 416 }
206 417
207 418 // Decode JSON
208 419 $import_data = json_decode($json_content, true);
209 420 if ($import_data === null || json_last_error() !== JSON_ERROR_NONE) {
210 - return;
421 + self::$last_import_error = 'json_invalid';
422 + return false;
211 423 }
212 424
213 425 // Validate import data structure
214 426 if (!isset($import_data['whitelabel_settings']) || !is_array($import_data['whitelabel_settings'])) {
215 - return;
427 + self::$last_import_error = 'json_structure_invalid';
428 + return false;
216 429 }
217 430
431 + $plugin_file = plugin_dir_path(dirname(__FILE__)) . 'metasync.php';
432 + $original_plugin_content = file_get_contents($plugin_file);
433 + if ($original_plugin_content === false) {
434 + self::$last_import_error = 'plugin_file_unreadable';
435 + return false;
436 + }
437 +
218 438 // Get current options
219 439 $options = get_option('metasync_options', array());
220 440
221 441 // Import whitelabel settings
@@ -229,8 +449,14 @@
229 449
230 450 // Merge imported settings with existing (imported settings take precedence)
231 451 $options['whitelabel'] = array_merge($options['whitelabel'], $whitelabel_settings);
232 452
453 + // The settings password travels as plaintext in the export file so it
454 + // can be imported on a site with different salts — encrypt it at rest.
455 + if (!empty($options['whitelabel']['settings_password']) && is_string($options['whitelabel']['settings_password'])) {
456 + $options['whitelabel']['settings_password'] = Metasync::encrypt_secret($options['whitelabel']['settings_password']);
457 + }
458 +
233 459 // Update timestamp
234 460 $options['whitelabel']['updated_at'] = time();
235 461 $options['whitelabel']['imported_at'] = current_time('mysql');
236 462 }
@@ -246,8 +472,25 @@
246 472 $options['general'][$key] = $value;
247 473 }
248 474 }
249 475
476 + // Validate and persist the public plugin headers before any other import side effect.
477 + $header_data = array(
478 + 'general_settings' => $options['general'] ?? array(),
479 + );
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 + }
490 + return false;
491 + }
492 +
250 493 // Restore bundled icon: if the icon value is a __bundled_icon__{ext} marker,
251 494 // copy the bundled file from the plugin directory to uploads and update the URL.
252 495 $icon_value = $options['general']['white_label_plugin_menu_icon'] ?? '';
253 496 if (!empty($icon_value) && strpos($icon_value, '__bundled_icon__') === 0) {
@@ -273,19 +516,80 @@
273 516 $options['general']['white_label_plugin_menu_icon'] = '';
274 517 }
275 518 }
276 519
277 - // Save updated options
278 - 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 + }
559 + if (get_option('metasync_options', null) !== $options) {
560 + if (self::atomically_replace_plugin_file($plugin_file, $original_plugin_content)) {
561 + wp_cache_delete('plugins', 'plugins');
562 + }
563 + self::$last_import_error = 'options_write_failed';
564 + return false;
565 + }
279 566
280 - // Update the plugin file headers so whitelabel shows even when deactivated
281 - self::update_plugin_file_headers($import_data);
282 -
283 567 // Optionally delete the JSON file after successful import (uncomment if desired)
284 568 // unlink($json_file);
569 +
570 + return true;
285 571 }
286 572
287 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 + /**
288 592 * Sync plugin file headers from the current saved options in the database.
289 593 * Call this after saving whitelabel settings via the admin UI to ensure
290 594 * the plugin file headers reflect the latest whitelabel values.
291 595 *
@@ -292,8 +596,12 @@
292 596 * @since 2.5.0
293 597 */
294 598 public static function sync_plugin_file_headers()
295 599 {
600 + if (!current_user_can('manage_options')) {
601 + return false;
602 + }
603 +
296 604 $options = get_option('metasync_options', array());
297 605 $general = $options['general'] ?? array();
298 606
299 607 // Build the import_data format expected by update_plugin_file_headers
@@ -300,12 +608,138 @@
300 608 $import_data = array(
301 609 'general_settings' => $general,
302 610 );
303 611
304 - self::update_plugin_file_headers($import_data);
612 + return self::update_plugin_file_headers($import_data);
305 613 }
306 614
307 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 + /**
308 742 * Update the main plugin file headers with whitelabel values
309 743 * WordPress reads plugin metadata directly from the file header comments,
310 744 * so modifying these ensures whitelabel shows even when the plugin is deactivated.
311 745 *
@@ -311,19 +745,19 @@
311 745 *
312 746 * @param array $import_data The imported whitelabel data
313 747 * @since 2.5.0
314 748 */
315 - private static function update_plugin_file_headers($import_data)
749 + private static function update_plugin_file_headers($import_data, $plugin_file = null)
316 750 {
317 - $plugin_file = plugin_dir_path(dirname(__FILE__)) . 'metasync.php';
751 + $plugin_file = $plugin_file ?: plugin_dir_path(dirname(__FILE__)) . 'metasync.php';
318 752
319 - if (!file_exists($plugin_file) || !is_writable($plugin_file)) {
320 - return;
753 + if (!file_exists($plugin_file) || !wp_is_writable($plugin_file)) {
754 + return false;
321 755 }
322 756
323 757 $content = file_get_contents($plugin_file);
324 758 if ($content === false) {
325 - return;
759 + return false;
326 760 }
327 761
328 762 $general = $import_data['general_settings'] ?? array();
329 763
@@ -331,9 +765,9 @@
331 765 // with default values to restore when whitelabel is cleared
332 766 $header_map = array(
333 767 'white_label_plugin_name' => array(
334 768 'header' => 'Plugin Name',
335 - 'default' => 'Search Atlas: The Premier AI SEO Plugin for Instant Optimization',
769 + 'default' => 'Search Atlas SEO - OTTO AI SEO Automation for WordPress',
336 770 ),
337 771 'white_label_plugin_description' => array(
338 772 'header' => 'Description',
339 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.',
@@ -352,36 +786,177 @@
352 786 ),
353 787 );
354 788
355 789 $modified = false;
790 + $original_content = $content;
791 + $url_fields = array('white_label_plugin_author_uri', 'white_label_plugin_uri');
356 792
357 793 foreach ($header_map as $setting_key => $field_config) {
358 794 $header_field = $field_config['header'];
359 795
360 - // Use whitelabel value if set, otherwise restore default
361 - $new_value = !empty($general[$setting_key])
362 - ? $general[$setting_key]
363 - : $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 + }
364 809
810 + if (!is_string($new_value) || !self::is_valid_plugin_header_value($new_value, in_array($setting_key, $url_fields, true))) {
811 + return false;
812 + }
813 +
365 814 // Match the header line: " * Field Name: any value"
366 815 // Handles varying whitespace between field name and value
367 816 $pattern = '/^(\s*\*\s*' . preg_quote($header_field, '/') . ':\s*)(.+)$/m';
368 817
369 - if (preg_match($pattern, $content, $matches)) {
370 - // Only replace if the value actually differs from what's in the file
371 - if (trim($matches[2]) !== trim($new_value)) {
372 - // Escape both backslashes and $ signs for preg_replace replacement string
373 - $escaped_value = str_replace(array('\\', '$'), array('\\\\', '\\$'), $new_value);
374 - $content = preg_replace($pattern, '${1}' . $escaped_value, $content, 1);
375 - $modified = true;
818 + if (preg_match_all($pattern, $content, $matches) !== 1) {
819 + return false;
820 + }
821 +
822 + // Only replace if the value actually differs from what's in the file.
823 + if (trim($matches[2][0]) !== trim($new_value)) {
824 + $content = preg_replace_callback(
825 + $pattern,
826 + static function ($match) use ($new_value) {
827 + return $match[1] . $new_value;
828 + },
829 + $content,
830 + 1,
831 + $replacement_count
832 + );
833 +
834 + if (!is_string($content) || $replacement_count !== 1) {
835 + return false;
376 836 }
837 + $modified = true;
377 838 }
378 839 }
379 840
380 - if ($modified) {
381 - file_put_contents($plugin_file, $content);
841 + if (!self::is_valid_transformed_plugin_file($original_content, $content, $header_map)) {
842 + return false;
843 + }
382 844
383 - // Clear WordPress plugin cache so it reads the updated headers
384 - wp_cache_delete('plugins', 'plugins');
845 + if (!$modified) {
846 + return true;
385 847 }
848 +
849 + if (!self::atomically_replace_plugin_file($plugin_file, $content)) {
850 + return false;
851 + }
852 +
853 + // Clear WordPress plugin cache so it reads the updated headers.
854 + wp_cache_delete('plugins', 'plugins');
855 +
856 + return true;
857 + }
858 +
859 + /**
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
868 + */
869 + public static function is_valid_plugin_header_value($value, $is_url)
870 + {
871 + if (
872 + $value === ''
873 + || preg_match('/^[\p{L}\p{M}\p{N}\p{P}\p{S}\p{Zs}]+$/u', $value) !== 1
874 + || preg_match('/\*\/|<\?php|\?>/i', $value) !== 0
875 + ) {
876 + return false;
877 + }
878 +
879 + if (!$is_url) {
880 + return true;
881 + }
882 +
883 + if (filter_var($value, FILTER_VALIDATE_URL) === false) {
884 + return false;
885 + }
886 +
887 + $scheme = strtolower((string) wp_parse_url($value, PHP_URL_SCHEME));
888 +
889 + return in_array($scheme, array('http', 'https'), true);
890 + }
891 +
892 + /**
893 + * Validate the complete transformed PHP file and its header structure.
894 + */
895 + private static function is_valid_transformed_plugin_file($original, $transformed, $header_map)
896 + {
897 + if (!is_string($transformed) || strpos($transformed, '<?php') !== 0) {
898 + return false;
899 + }
900 +
901 + if (
902 + substr_count($original, '<?php') !== substr_count($transformed, '<?php')
903 + || substr_count($original, '?>') !== substr_count($transformed, '?>')
904 + ) {
905 + return false;
906 + }
907 +
908 + foreach ($header_map as $field_config) {
909 + $pattern = '/^\s*\*\s*' . preg_quote($field_config['header'], '/') . ':\s*.+$/m';
910 + if (preg_match_all($pattern, $transformed) !== 1) {
911 + return false;
912 + }
913 + }
914 +
915 + try {
916 + $tokens = token_get_all($transformed, TOKEN_PARSE);
917 + } catch (ParseError $error) {
918 + return false;
919 + }
920 +
921 + return true;
922 + }
923 +
924 + /**
925 + * Replace a plugin file from a same-directory temporary file.
926 + */
927 + private static function atomically_replace_plugin_file($plugin_file, $content, $rename_file = null)
928 + {
929 + $directory = dirname($plugin_file);
930 + $temp_file = tempnam($directory, '.metasync-header-');
931 + if ($temp_file === false) {
932 + return false;
933 + }
934 +
935 + $permissions = fileperms($plugin_file);
936 + $written = file_put_contents($temp_file, $content, LOCK_EX);
937 + if ($written !== strlen($content)) {
938 + @wp_delete_file($temp_file);
939 + return false;
940 + }
941 +
942 + if ($permissions !== false) {
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
944 + }
945 +
946 + if ($rename_file === null) {
947 + $rename_file = static function ($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
949 + };
950 + }
951 +
952 + // The temporary file is on the same filesystem, so a successful rename is
953 + // atomic. On failure the original path has not been touched.
954 + if ($rename_file($temp_file, $plugin_file)) {
955 + return true;
956 + }
957 +
958 + @wp_delete_file($temp_file);
959 +
960 + return false;
386 961 }
387 962 }