| @@ -11,12 +11,12 @@ | ||
| 11 | 11 | * @link https://searchatlas.com/ |
| 12 | 12 | * @since 1.0.0 |
| 13 | 13 | * |
| 14 | 14 | * @wordpress-plugin |
| 15 | - * Plugin Name: Search Atlas: The Premier AI SEO Plugin for Instant Optimization | |
| 15 | + * Plugin Name: Search Atlas SEO - OTTO AI SEO Automation for WordPress | |
| 16 | 16 | * Plugin URI: https://searchatlas.com/ |
| 17 | 17 | * Description: 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. |
| 18 | - * Version: 2.6.24 | |
| 18 | + * Version: 2.7.2 | |
| 19 | 19 | * Requires PHP: 8.1 |
| 20 | 20 | * Author: Search Atlas |
| 21 | 21 | * Author URI: https://searchatlas.com |
| 22 | 22 | * License: GPL v3 |
| @@ -35,14 +35,31 @@ | ||
| 35 | 35 | // Canonical sanitizer — required explicitly (not via the committed classmap) |
| 36 | 36 | // so it is guaranteed loadable everywhere canonicals are read or written. |
| 37 | 37 | require_once __DIR__ . '/includes/class-metasync-canonical-sanitizer.php'; |
| 38 | 38 | |
| 39 | +// Editor-settings feature flags — required explicitly for the same reason: they | |
| 40 | +// gate output in the OTTO buffer and in third-party filter callbacks, both of | |
| 41 | +// which can run before the classmap has resolved anything else. | |
| 42 | +require_once __DIR__ . '/includes/class-metasync-feature-flags.php'; | |
| 43 | +Metasync_Feature_Flags::register_invalidation(); | |
| 44 | + | |
| 45 | +// Consent gate and write-once backup for third-party SEO storage — required | |
| 46 | +// explicitly for the same reason: every writer into Yoast / Rank Math / AIOSEO | |
| 47 | +// consults it, and a class that fails to load would read as "no consent" and | |
| 48 | +// silently stop those writes. | |
| 49 | +require_once __DIR__ . '/includes/class-metasync-seo-backup.php'; | |
| 50 | + | |
| 51 | +// Loaded beside the backup class because it is the only reader of what that | |
| 52 | +// class writes. Restore is inert until something calls it, so loading it early | |
| 53 | +// costs nothing and keeps the pair together. | |
| 54 | +require_once __DIR__ . '/includes/class-metasync-seo-restore.php'; | |
| 55 | + | |
| 39 | 56 | /** |
| 40 | 57 | * Currently plugin version. |
| 41 | 58 | * Start at version 1.0.0 and use SemVer - https://semver.org |
| 42 | 59 | * Rename this for your plugin and update it as you release new versions. |
| 43 | 60 | */ |
| 44 | -$metasync_version = '2.6.24'; | |
| 61 | +$metasync_version = '2.7.2'; | |
| 45 | 62 | define('METASYNC_VERSION', preg_match('/^\d+\.\d+/', $metasync_version) ? $metasync_version : '9.9.9'); |
| 46 | 63 | /** |
| 47 | 64 | * Define the current required php version |
| 48 | 65 | * This will be used to validate whether the user can install the plugin or not |
| @@ -78,8 +95,18 @@ | ||
| 78 | 95 | define('METASYNC_GA4_MEASUREMENT_ID', 'G-SBLWW1EMTJ'); |
| 79 | 96 | define('METASYNC_GA4_API_SECRET', 'nMGs22mxQ3qVUy-aInqfZA'); |
| 80 | 97 | |
| 81 | 98 | /** |
| 99 | + * PostHog product analytics configuration. | |
| 100 | + * The write key is a public project key embedded by design (same precedent | |
| 101 | + * as the GA4 secret above); set METASYNC_POSTHOG_API_KEY in the environment | |
| 102 | + * to override it - including to an empty string, which disables PostHog. | |
| 103 | + */ | |
| 104 | +define('METASYNC_POSTHOG_HOST', 'https://posthog.internal.searchatlas.com'); | |
| 105 | +$metasync_posthog_key = getenv('METASYNC_POSTHOG_API_KEY'); | |
| 106 | +define('METASYNC_POSTHOG_API_KEY', false !== $metasync_posthog_key ? $metasync_posthog_key : 'phc_ntSayCJE7Ad9eBHRG6ygBaY4BJMa8rFjXcXESun9ftkh'); // gitleaks:allow -- public write-only PostHog project key | |
| 107 | + | |
| 108 | +/** | |
| 82 | 109 | * Define whether to show the plugin status in WordPress admin top navigation bar |
| 83 | 110 | * Set to false to hide the status indicator |
| 84 | 111 | */ |
| 85 | 112 | define('METASYNC_SHOW_ADMIN_BAR_STATUS', true); |
| @@ -128,10 +155,10 @@ | ||
| 128 | 155 | } |
| 129 | 156 | if (!defined('DOING_AJAX') || !DOING_AJAX) { |
| 130 | 157 | return ($result = false); |
| 131 | 158 | } |
| 132 | - $action = isset($_POST['action']) ? sanitize_text_field(wp_unslash($_POST['action'])) | |
| 133 | - : (isset($_GET['action']) ? sanitize_text_field(wp_unslash($_GET['action'])) : ''); | |
| 159 | + $action = isset($_POST['action']) ? sanitize_text_field(wp_unslash($_POST['action'])) // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only action-name prefix check during boot | |
| 160 | + : (isset($_GET['action']) ? sanitize_text_field(wp_unslash($_GET['action'])) : ''); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only action-name prefix check during boot | |
| 134 | 161 | if ($action === '') { |
| 135 | 162 | return ($result = true); |
| 136 | 163 | } |
| 137 | 164 | if (strpos($action, 'metasync_') === 0 || strpos($action, 'meta_sync_') === 0 || $action === 'sample-permalink') { |
| @@ -171,8 +198,9 @@ | ||
| 171 | 198 | |
| 172 | 199 | // Phase 2 — these files have file-level side effects (add_action outside class body) |
| 173 | 200 | // and must remain as explicit require_once until refactored. |
| 174 | 201 | require_once plugin_dir_path( __FILE__ ) . 'includes/class-metasync-api-backoff-rest.php'; |
| 202 | +require_once plugin_dir_path( __FILE__ ) . 'includes/class-metasync-data-store.php'; | |
| 175 | 203 | require_once plugin_dir_path( __FILE__ ) . 'includes/class-metasync-error-logger.php'; |
| 176 | 204 | |
| 177 | 205 | // Shared helpers (custom/LPS page detection + query exclusion) — loaded in all |
| 178 | 206 | // request contexts (admin, REST, MCP, AJAX) so every SEO surface uses one rule. |
| @@ -235,9 +263,9 @@ | ||
| 235 | 263 | #craft the message |
| 236 | 264 | sprintf( |
| 237 | 265 | '%s requires WordPress version %s or later. You are currently using version %s. Please update WordPress to activate this plugin.', |
| 238 | 266 | esc_html($plugin_name), |
| 239 | - METASYNC_MIN_WP, | |
| 267 | + esc_html(METASYNC_MIN_WP), | |
| 240 | 268 | esc_html($wp_version) |
| 241 | 269 | ), |
| 242 | 270 | |
| 243 | 271 | #the plugin title as page title |
| @@ -257,9 +285,9 @@ | ||
| 257 | 285 | #craft the message |
| 258 | 286 | sprintf( |
| 259 | 287 | '%s requires PHP version %s or later. You are currently using version %s. Please update PHP to activate this plugin.', |
| 260 | 288 | esc_html($plugin_name), |
| 261 | - METASYNC_MIN_PHP, | |
| 289 | + esc_html(METASYNC_MIN_PHP), | |
| 262 | 290 | esc_html($php_version) |
| 263 | 291 | ), |
| 264 | 292 | |
| 265 | 293 | #the plugin title as page title |
| @@ -285,8 +313,12 @@ | ||
| 285 | 313 | Metasync_Cache_Purge::purge_all('plugin_activation'); |
| 286 | 314 | |
| 287 | 315 | // Migrate physical sitemap files on activation. |
| 288 | 316 | metasync_migrate_physical_sitemaps(); |
| 317 | + | |
| 318 | + // Consent-gated analytics: the activation event only fires if the user | |
| 319 | + // previously opted in (re-activation) — a brand-new install is OFF. | |
| 320 | + Metasync_PostHog::get_instance()->track('plugin_activated', array()); | |
| 289 | 321 | } |
| 290 | 322 | |
| 291 | 323 | // Log-sync removed - error monitoring now handled by Sentry |
| 292 | 324 | // require_once plugin_dir_path(__FILE__) . 'log-sync/log-sync.php'; |
| @@ -351,19 +383,90 @@ | ||
| 351 | 383 | if (empty($candidates)) { |
| 352 | 384 | return; |
| 353 | 385 | } |
| 354 | 386 | |
| 387 | + // Ownership gate: other plugins (Yoast and friends) write files with the | |
| 388 | + // exact same names (sitemap_index.xml, sitemap1.xml, ...), so a name match | |
| 389 | + // alone must never authorize deleting a physical file. A file is only | |
| 390 | + // migrated when this plugin left evidence it wrote it. | |
| 391 | + $tracked_names = []; | |
| 392 | + $tracked_files = get_option('metasync_sitemap_files', []); | |
| 393 | + if (is_array($tracked_files)) { | |
| 394 | + foreach ($tracked_files as $entry) { | |
| 395 | + $name = is_array($entry) && isset($entry['filename']) ? $entry['filename'] : $entry; | |
| 396 | + if (is_string($name) && '' !== $name) { | |
| 397 | + $tracked_names[] = $name; | |
| 398 | + } | |
| 399 | + } | |
| 400 | + } | |
| 355 | 401 | $virtual_index = get_option('metasync_sitemap_virtual_index', []); |
| 402 | + if (!is_array($virtual_index)) { | |
| 403 | + $virtual_index = []; | |
| 404 | + } | |
| 405 | + $parked_names = array_keys($virtual_index); | |
| 406 | + $generator_ran = !empty($tracked_names) && false !== get_option('metasync_sitemap_last_generated'); | |
| 407 | + | |
| 408 | + $ours = []; | |
| 409 | + $metasync_files_found = false; | |
| 410 | + foreach ($candidates as $file) { | |
| 411 | + $bn = basename($file); | |
| 412 | + | |
| 413 | + // A parked name proves MetaSync owned the file at an earlier point, but | |
| 414 | + // an expired transient no longer proves that a file currently on disk | |
| 415 | + // is ours. A different plugin may have recreated the same basename. | |
| 416 | + if (in_array($bn, $parked_names, true)) { | |
| 417 | + if (false !== get_transient('metasync_vsm_' . md5($bn))) { | |
| 418 | + continue; | |
| 419 | + } | |
| 420 | + continue; | |
| 421 | + } | |
| 422 | + | |
| 423 | + // Chunk, index, news, and video files are migratable only when their | |
| 424 | + // exact basename is recorded by MetaSync's generator/configuration. | |
| 425 | + if (in_array($bn, $tracked_names, true) && $generator_ran) { | |
| 426 | + $ours[] = $file; | |
| 427 | + $metasync_files_found = true; | |
| 428 | + continue; | |
| 429 | + } | |
| 430 | + | |
| 431 | + if ($bn === 'news-sitemap.xml' && null !== get_option('metasync_news_sitemap_settings', null)) { | |
| 432 | + $ours[] = $file; | |
| 433 | + $metasync_files_found = true; | |
| 434 | + continue; | |
| 435 | + } | |
| 436 | + if ($bn === 'video-sitemap.xml' && null !== get_option('metasync_video_sitemap_settings', null)) { | |
| 437 | + $ours[] = $file; | |
| 438 | + $metasync_files_found = true; | |
| 439 | + } | |
| 440 | + } | |
| 441 | + | |
| 442 | + // Do not infer ownership of sitemap_index.xml from another sitemap: Yoast, | |
| 443 | + // Rank Math, and other SEO plugins commonly use the same root filename. | |
| 444 | + // It must be explicitly recorded in MetaSync's tracked file list. | |
| 445 | + $ours = array_values(array_unique($ours)); | |
| 446 | + | |
| 447 | + if (empty($ours)) { | |
| 448 | + return; | |
| 449 | + } | |
| 450 | + | |
| 356 | 451 | $migrated_files = []; |
| 357 | 452 | |
| 358 | - foreach ($candidates as $file) { | |
| 453 | + foreach ($ours as $file) { | |
| 359 | 454 | $bn = basename($file); |
| 360 | 455 | $content = @file_get_contents($file); |
| 361 | 456 | if (false !== $content) { |
| 362 | 457 | $tkey = 'metasync_vsm_' . md5($bn); |
| 363 | - set_transient($tkey, $content, 30 * DAY_IN_SECONDS); | |
| 458 | + // The news sitemap's entries are only valid inside Google News' | |
| 459 | + // 48-hour window, so it must not be migrated in under a 30-day | |
| 460 | + // TTL — that would re-introduce the staleness the generator's | |
| 461 | + // own bounded TTL exists to prevent. Regenerate-on-miss rebuilds | |
| 462 | + // it with a fresh date_query when this expires. | |
| 463 | + $ttl = ('news-sitemap.xml' === $bn) ? (int) (DAY_IN_SECONDS / 4) : 30 * DAY_IN_SECONDS; | |
| 464 | + set_transient($tkey, $content, $ttl); | |
| 364 | 465 | if (false !== get_transient($tkey)) { |
| 365 | - @unlink($file); | |
| 466 | + // The parked transient doubles as a 30-day backup of the | |
| 467 | + // content before the file is removed from disk. | |
| 468 | + @wp_delete_file($file); | |
| 366 | 469 | $virtual_index[$bn] = $tkey; |
| 367 | 470 | if ($bn !== 'sitemap_index.xml' && $bn !== 'news-sitemap.xml' && $bn !== 'video-sitemap.xml') { |
| 368 | 471 | $migrated_files[] = [ |
| 369 | 472 | 'filename' => $bn, |
| @@ -394,10 +497,17 @@ | ||
| 394 | 497 | |
| 395 | 498 | $current_version = get_option('metasync_version', '0.0.0'); |
| 396 | 499 | $plugin_version = METASYNC_VERSION; |
| 397 | 500 | |
| 398 | - // If versions don't match, run migration | |
| 399 | - if (version_compare($current_version, $plugin_version, '<')) { | |
| 501 | + // If versions don't match, run migration. Dev builds store the 9.9.9 | |
| 502 | + // placeholder version, which compares greater than every real release — | |
| 503 | + // once stored, a plain "<" comparison never fires again and migrations | |
| 504 | + // are silently skipped forever. Also enter when the stored version is the | |
| 505 | + // placeholder but the running version is a real release, so the migration | |
| 506 | + // runner's dev-build escape hatch becomes reachable and the stored version | |
| 507 | + // is corrected to the real release. | |
| 508 | + if (version_compare($current_version, $plugin_version, '<') | |
| 509 | + || ('9.9.9' === $current_version && '9.9.9' !== $plugin_version)) { | |
| 400 | 510 | // Import whitelabel settings only if the JSON file is new or changed |
| 401 | 511 | // (prevents overwriting admin UI changes on every version check) |
| 402 | 512 | Metasync_Activator::check_whitelabel_settings_update(); |
| 403 | 513 | |
| @@ -403,8 +513,13 @@ | ||
| 403 | 513 | |
| 404 | 514 | // Run version-specific migrations first |
| 405 | 515 | MetaSync_DBMigration::run_version_migrations($current_version, $plugin_version); |
| 406 | 516 | |
| 517 | + // The developer endpoint-switching panel has been removed. Reset its | |
| 518 | + // persisted staging mode so upgrades cannot keep routing API requests | |
| 519 | + // to staging without a visible warning or a supported recovery path. | |
| 520 | + delete_option('metasync_dev_endpoint_mode'); | |
| 521 | + | |
| 407 | 522 | // Migration for v2.7.0+: Remove AI Agent, switch to plugin auth token, make MCP always-on |
| 408 | 523 | if (version_compare($current_version, '2.7.0', '<')) { |
| 409 | 524 | // Remove old MCP API key option |
| 410 | 525 | delete_option('metasync_mcp_api_key'); |
| @@ -450,8 +565,23 @@ | ||
| 450 | 565 | MetaSync_DBMigration::cleanup_corrupted_canonicals(); |
| 451 | 566 | update_option('metasync_canonical_cleanup_done', 'done', false); |
| 452 | 567 | } |
| 453 | 568 | |
| 569 | + // One-time repair of Local Business logos corrupted to "http://<id>". | |
| 570 | + // The sanitizer fix stopped new corruption; this restores the | |
| 571 | + // attachment ID on sites that saved a logo before it. The corrupted | |
| 572 | + // value encodes the original ID exactly, so the rewrite is lossless. | |
| 573 | + // Claimed via add_option() — it fails when the row already exists, so | |
| 574 | + // concurrent requests can't run the repair twice, and the claim lands | |
| 575 | + // BEFORE the work: the repair itself is idempotent, and the read-side | |
| 576 | + // normalisation in the schema output and admin preview already | |
| 577 | + // protects output if a run is interrupted. | |
| 578 | + if (false === get_option('metasync_local_seo_logo_repair_done') | |
| 579 | + && add_option('metasync_local_seo_logo_repair_done', 'running', '', false)) { | |
| 580 | + MetaSync_DBMigration::repair_corrupted_local_seo_logo(); | |
| 581 | + update_option('metasync_local_seo_logo_repair_done', 'done', false); | |
| 582 | + } | |
| 583 | + | |
| 454 | 584 | // Migrate physical sitemap files on version update. |
| 455 | 585 | metasync_migrate_physical_sitemaps(); |
| 456 | 586 | |
| 457 | 587 | // Run full migration to ensure all tables are up to date |
| @@ -574,8 +704,13 @@ | ||
| 574 | 704 | } |
| 575 | 705 | $checked = true; |
| 576 | 706 | |
| 577 | 707 | require_once plugin_dir_path(__FILE__) . 'includes/class-metasync-activator.php'; |
| 708 | + | |
| 709 | + // Surface a persistently failing White Label import instead of | |
| 710 | + // retrying it silently on every admin request. | |
| 711 | + add_action('admin_notices', array('Metasync_Activator', 'render_whitelabel_import_failure_notice')); | |
| 712 | + | |
| 578 | 713 | Metasync_Activator::check_whitelabel_settings_update(); |
| 579 | 714 | } |
| 580 | 715 | |
| 581 | 716 | // Retry package imports on an authorized admin request when an update completed |
| @@ -631,9 +766,17 @@ | ||
| 631 | 766 | echo '<script>window.__SA_DYO_INITIALIZED__=true;</script>' . "\n"; |
| 632 | 767 | } |
| 633 | 768 | add_action('wp_head', 'metasync_output_dyo_init_flag', 1); |
| 634 | 769 | |
| 635 | -// Runtime feature initialisers (GA4, API backoff, review notice, JWT accessor, debug mode) extracted to keep this entry file lean. | |
| 770 | +// Shared analytics helper is loaded in all request contexts so server-side feature | |
| 771 | +// events can use the same consent gate as admin analytics. | |
| 772 | +require_once plugin_dir_path( __FILE__ ) . 'includes/class-metasync-posthog.php'; | |
| 773 | +require_once plugin_dir_path( __FILE__ ) . 'includes/class-metasync-analytics-consent-notice.php'; | |
| 774 | + | |
| 775 | +/** | |
| 776 | + * Runtime feature initialisers (GA4, API backoff, review notice, JWT accessor, | |
| 777 | + * debug mode) extracted to keep this entry file lean. | |
| 778 | + */ | |
| 636 | 779 | require_once plugin_dir_path( __FILE__ ) . 'includes/metasync-runtime-init.php'; |
| 637 | 780 | |
| 638 | 781 | /** |
| 639 | 782 | * Append a "Website Studio" post state to LPS-synced / MetaSync custom pages in |