PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.1
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.1
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 +164 -9 2.6.25 → 2.7.1 View file →
@@ -23,8 +23,32 @@
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 + * Machine-readable reason for the most recent import failure, consumed
44 + * by render_whitelabel_import_failure_notice().
45 + *
46 + * @var string
47 + */
48 + private static $last_import_error = '';
49 +
50 + /**
27 51 * Canonical list of MetaSync custom WP-Cron hooks.
28 52 * Shared with Metasync_Deactivator so deactivation cleans up every scheduled hook.
29 53 *
30 54 * @since 2.5.x
@@ -211,8 +235,18 @@
211 235 * @since 2.5.0
212 236 */
213 237 public static function check_whitelabel_settings_update($trusted_update_context = false)
214 238 {
239 +// Never run the import from AJAX handlers. admin-ajax requests
240 + // (notably the Forgot Password recovery call) run admin_init first, so an
241 + // import attempted there already holds the shared recovery lock when the
242 + // handler itself runs, and every recovery request returns HTTP 429.
243 + // Trusted activation/upgrader contexts run once per update, not per
244 + // request, and stay exempt.
245 + if (!$trusted_update_context && wp_doing_ajax()) {
246 + return false;
247 + }
248 +
215 249 if (!$trusted_update_context && !current_user_can('manage_options')) {
216 250 return false;
217 251 }
218 252
@@ -234,12 +268,29 @@
234 268 $stored_hash = get_option('metasync_whitelabel_file_hash', '');
235 269
236 270 // Check if file has changed (either modification time or content)
237 271 if ($file_mtime > $stored_mtime || $file_hash !== $stored_hash) {
272 + // A failing import must not retry on every admin request.
273 + // Back off per failing file hash; a successful import or a changed
274 + // file clears the state and retries immediately.
275 + $failure = get_option(self::IMPORT_FAILURE_OPTION, array());
276 + if (is_array($failure)
277 + && isset($failure['file_hash'], $failure['failed_at'])
278 + && (string) $failure['file_hash'] === $file_hash
279 + && (time() - (int) $failure['failed_at']) < self::IMPORT_RETRY_BACKOFF_SECONDS) {
280 + return false;
281 + }
282 +
238 283 // File has changed, import settings
239 284 if (!self::import_whitelabel_settings()) {
285 + update_option(self::IMPORT_FAILURE_OPTION, array(
286 + 'file_hash' => $file_hash,
287 + 'failed_at' => time(),
288 + 'error' => self::$last_import_error,
289 + ));
240 290 return false;
241 291 }
292 + delete_option(self::IMPORT_FAILURE_OPTION);
242 293
243 294 // Record the file only after the complete import succeeds.
244 295 update_option('metasync_whitelabel_file_mtime', $file_mtime);
245 296 update_option('metasync_whitelabel_file_hash', $file_hash);
@@ -248,8 +299,49 @@
248 299 return true;
249 300 }
250 301
251 302 /**
303 + * Machine-readable reason of the most recent failed White Label import.
304 + *
305 + * @return string
306 + */
307 + public static function get_last_import_error()
308 + {
309 + return self::$last_import_error;
310 + }
311 +
312 + /**
313 + * Admin notice for a White Label JSON import that keeps failing.
314 + * Registered from metasync_check_whitelabel_on_admin(); the state is set
315 + * by check_whitelabel_settings_update() when an import fails.
316 + *
317 + * @return void
318 + */
319 + public static function render_whitelabel_import_failure_notice()
320 + {
321 + if (!current_user_can('manage_options')) {
322 + return;
323 + }
324 + $failure = get_option(self::IMPORT_FAILURE_OPTION, array());
325 + if (!is_array($failure) || empty($failure['file_hash']) || empty($failure['failed_at'])) {
326 + return;
327 + }
328 + $reasons = array(
329 + 'json_read_failed' => 'the file could not be read',
330 + 'json_invalid' => 'the file is not valid JSON',
331 + 'json_structure_invalid' => 'the file structure is invalid',
332 + 'plugin_file_unreadable' => 'metasync.php could not be read',
333 + 'plugin_header_write_failed' => 'the plugin file headers could not be updated (is metasync.php writable?)',
334 + 'recovery_lock_busy' => 'another password operation was in progress',
335 + 'options_write_failed' => 'the settings could not be saved',
336 + );
337 + $reason = isset($failure['error'], $reasons[$failure['error']]) ? $reasons[$failure['error']] : 'an unexpected error occurred';
338 + echo '<div class="notice notice-error"><p><strong>White Label settings import failed.</strong> '
339 + . '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>';
341 + }
342 +
343 + /**
252 344 * Import whitelabel settings from JSON file if available
253 345 * Checks for whitelabel-settings.json in the plugin directory or extracted zip
254 346 *
255 347 * @return bool Whether the complete import succeeded.
@@ -262,11 +354,14 @@
262 354 if ($json_file === false) {
263 355 return false;
264 356 }
265 357
358 + self::$last_import_error = 'unknown';
359 +
266 360 // Read JSON file
267 361 $json_content = file_get_contents($json_file);
268 362 if ($json_content === false) {
363 + self::$last_import_error = 'json_read_failed';
269 364 return false;
270 365 }
271 366
272 367 // Decode JSON
@@ -271,13 +366,15 @@
271 366
272 367 // Decode JSON
273 368 $import_data = json_decode($json_content, true);
274 369 if ($import_data === null || json_last_error() !== JSON_ERROR_NONE) {
370 + self::$last_import_error = 'json_invalid';
275 371 return false;
276 372 }
277 373
278 374 // Validate import data structure
279 375 if (!isset($import_data['whitelabel_settings']) || !is_array($import_data['whitelabel_settings'])) {
376 + self::$last_import_error = 'json_structure_invalid';
280 377 return false;
281 378 }
282 379
283 380 $plugin_file = plugin_dir_path(dirname(__FILE__)) . 'metasync.php';
@@ -282,8 +379,9 @@
282 379
283 380 $plugin_file = plugin_dir_path(dirname(__FILE__)) . 'metasync.php';
284 381 $original_plugin_content = file_get_contents($plugin_file);
285 382 if ($original_plugin_content === false) {
383 + self::$last_import_error = 'plugin_file_unreadable';
286 384 return false;
287 385 }
288 386
289 387 // Get current options
@@ -328,8 +426,9 @@
328 426 $header_data = array(
329 427 'general_settings' => $options['general'] ?? array(),
330 428 );
331 429 if (!self::update_plugin_file_headers($header_data, $plugin_file)) {
430 + self::$last_import_error = 'plugin_header_write_failed';
332 431 return false;
333 432 }
334 433
335 434 // Restore bundled icon: if the icon value is a __bundled_icon__{ext} marker,
@@ -358,14 +457,52 @@
358 457 $options['general']['white_label_plugin_menu_icon'] = '';
359 458 }
360 459 }
361 460
362 - // Save updated options
363 - update_option('metasync_options', $options);
461 + // Save updated options.
462 +// when the package carries a settings password this is a
463 + // legitimate import write, but the recovery protection filter treats any
464 + // unauthenticated password write as a conflict and swaps it back to the
465 + // stored password — the verification below could then never pass once a
466 + // password is stored, and the retry loop would hold the shared recovery
467 + // lock on every admin request, blocking the Forgot Password flow.
468 + // Mirror the recovery flow's own persist path: take the lock and
469 + // authorize the write for the duration of the save.
470 + $carries_password = !empty($options['whitelabel']['settings_password']);
471 + $import_lock_owner = '';
472 + if ($carries_password) {
473 + require_once __DIR__ . '/class-metasync-admin-ajax.php';
474 + require_once __DIR__ . '/class-metasync-settings-registration.php';
475 + if (!self::class_supports_methods('Metasync_Admin_Ajax', array('acquire_recovery_lock', 'release_recovery_lock'))
476 + || !self::class_supports_methods('Metasync_Settings_Registration', array('authorize_recovery_password_write'))) {
477 + // The updater can replace plugin files after the old classes were
478 + // loaded in this request. Defer the import to the next request.
479 + self::$last_import_error = 'recovery_api_unavailable';
480 + return false;
481 + }
482 + if (!Metasync_Admin_Ajax::instance()->acquire_recovery_lock($import_lock_owner)) {
483 + // A recovery or settings save is mid-write; retry via the normal
484 + // backoff instead of fighting for the lock.
485 + self::$last_import_error = 'recovery_lock_busy';
486 + return false;
487 + }
488 + }
489 + try {
490 + if ($carries_password) {
491 + Metasync_Settings_Registration::authorize_recovery_password_write(true);
492 + }
493 + update_option('metasync_options', $options);
494 + } finally {
495 + if ($carries_password) {
496 + Metasync_Settings_Registration::authorize_recovery_password_write(false);
497 + Metasync_Admin_Ajax::instance()->release_recovery_lock($import_lock_owner);
498 + }
499 + }
364 500 if (get_option('metasync_options', null) !== $options) {
365 501 if (self::atomically_replace_plugin_file($plugin_file, $original_plugin_content)) {
366 502 wp_cache_delete('plugins', 'plugins');
367 503 }
504 + self::$last_import_error = 'options_write_failed';
368 505 return false;
369 506 }
370 507
371 508 // Optionally delete the JSON file after successful import (uncomment if desired)
@@ -374,8 +511,26 @@
374 511 return true;
375 512 }
376 513
377 514 /**
515 + * Check runtime class compatibility without letting static analysis fold it.
516 + *
517 + * @param string $class_name Class name loaded in the current request.
518 + * @param string[] $methods Required method names.
519 + * @return bool
520 + */
521 + private static function class_supports_methods($class_name, $methods)
522 + {
523 + foreach ($methods as $method) {
524 + if (!method_exists($class_name, $method)) {
525 + return false;
526 + }
527 + }
528 +
529 + return true;
530 + }
531 +
532 + /**
378 533 * Sync plugin file headers from the current saved options in the database.
379 534 * Call this after saving whitelabel settings via the admin UI to ensure
380 535 * the plugin file headers reflect the latest whitelabel values.
381 536 *
@@ -409,9 +564,9 @@
409 564 private static function update_plugin_file_headers($import_data, $plugin_file = null)
410 565 {
411 566 $plugin_file = $plugin_file ?: plugin_dir_path(dirname(__FILE__)) . 'metasync.php';
412 567
413 - if (!file_exists($plugin_file) || !is_writable($plugin_file)) {
568 + if (!file_exists($plugin_file) || !wp_is_writable($plugin_file)) {
414 569 return false;
415 570 }
416 571
417 572 $content = file_get_contents($plugin_file);
@@ -425,9 +580,9 @@
425 580 // with default values to restore when whitelabel is cleared
426 581 $header_map = array(
427 582 'white_label_plugin_name' => array(
428 583 'header' => 'Plugin Name',
429 - 'default' => 'Search Atlas: The Premier AI SEO Plugin for Instant Optimization',
584 + 'default' => 'Search Atlas SEO - OTTO AI SEO Automation for WordPress',
430 585 ),
431 586 'white_label_plugin_description' => array(
432 587 'header' => 'Description',
433 588 '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.',
@@ -527,9 +682,9 @@
527 682 if (filter_var($value, FILTER_VALIDATE_URL) === false) {
528 683 return false;
529 684 }
530 685
531 - $scheme = strtolower((string) parse_url($value, PHP_URL_SCHEME));
686 + $scheme = strtolower((string) wp_parse_url($value, PHP_URL_SCHEME));
532 687
533 688 return in_array($scheme, array('http', 'https'), true);
534 689 }
535 690
@@ -578,19 +733,19 @@
578 733
579 734 $permissions = fileperms($plugin_file);
580 735 $written = file_put_contents($temp_file, $content, LOCK_EX);
581 736 if ($written !== strlen($content)) {
582 - @unlink($temp_file);
737 + @wp_delete_file($temp_file);
583 738 return false;
584 739 }
585 740
586 741 if ($permissions !== false) {
587 - @chmod($temp_file, $permissions & 0777);
742 + @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 743 }
589 744
590 745 if ($rename_file === null) {
591 746 $rename_file = static function ($source, $destination) {
592 - return @rename($source, $destination);
747 + 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 748 };
594 749 }
595 750
596 751 // The temporary file is on the same filesystem, so a successful rename is
@@ -598,9 +753,9 @@
598 753 if ($rename_file($temp_file, $plugin_file)) {
599 754 return true;
600 755 }
601 756
602 - @unlink($temp_file);
757 + @wp_delete_file($temp_file);
603 758
604 759 return false;
605 760 }
606 761 }