prepare(). * * Direct (uncached) queries are intentional — uninstall runs once and must hit * the database, not a cache. Only the non-injection DirectDatabaseQuery sniffs * are suppressed file-wide: * * phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange -- One-time uninstall teardown of this plugin's own custom tables/options; must hit the DB uncached. Non-injection sniffs only; identifiers bound with %i, values with %s via $wpdb->prepare(); the PreparedSQL sniffs are never suppressed. */ declare(strict_types=1); namespace AetosSeo\Core; if (!defined('ABSPATH')) { exit; } use AetosSeo\Storage\Schema; /** * Uninstaller — fully wipes plugin footprint when WordPress uninstalls the plugin. * * v2.8.0: thorough cleanup covering: * 1. Custom DB tables (DROP) * 2. wp_options entries — known keys + ALL `aetos_seo_*` (every per-build * hash-suffixed option: license cache, install fp, audit locks, * last-deactivate-reason, integrations, OAuth state, etc). The Pro build * additionally sweeps this product's own pre-rebrand legacy option * patterns; a separate sibling plugin's keys are intentionally never swept. * 3. wp_options transients (transient + transient_timeout) for all prefixes * 4. wp_usermeta entries (user-scoped dismissals, onboarding state) * 5. Site transients (multisite-aware) * 6. Scheduled cron events (wp_cron_jobs persisted in cron option) * 7. Capabilities/roles registered by the plugin (none currently — defensive) * 8. Upload directory tree (recursively) */ class Uninstaller { public static function uninstall_hook(): void { self::uninstall(); } public static function uninstall(): void { if (defined('WP_UNINSTALL_PLUGIN') && !WP_UNINSTALL_PLUGIN) { return; } // Edition coexistence data-safety guard. The free (WordPress.org) and // Pro editions share the same option keys + custom tables. If the OTHER // edition is still active, the shared data belongs to it — deleting this // edition must NOT wipe it. Only the LAST Aetos edition removed performs // the full cleanup below. Without this, deleting a dormant free edition // would destroy a paying Pro customer's settings + audit history. if (self::sibling_edition_active()) { return; } global $wpdb; // 1. Drop custom tables. $schema = new Schema($wpdb); foreach ($schema->get_table_names() as $table) { // $table is built from $wpdb->prefix; bound as an identifier via %i, // with a belt-and-suspenders format assertion. if (preg_match('/^[a-z0-9_]+$/i', (string) $table)) { $wpdb->query($wpdb->prepare("DROP TABLE IF EXISTS %i", $table)); } } // 2. Delete known option keys explicitly first. $known_options = [ Settings::OPTION_KEY, Settings::CRAWL_STATE_OPTION, Settings::REPORTS_INDEX_OPTION, 'aetos_seo_db_version', 'aetos_seo_active_plugin_paths', 'aetos_seo_integrations', 'aetos_seo_last_deactivate_reason', ]; foreach ($known_options as $opt) { delete_option($opt); delete_site_option($opt); } // 3. Delete pattern-based options (per-build hash-suffixed: aetos_seo__*). // Scoped to this plugin's own prefix only — deliberately NOT a broad // match that could also wipe a separate, similarly-named third-party // plugin's options. $patterns = [ 'aetos_seo\_%', ]; foreach ($patterns as $pat) { $like_safe = $pat; // already escaped for LIKE $wpdb->query($wpdb->prepare( "DELETE FROM %i WHERE option_name LIKE %s", $wpdb->options, $like_safe )); } // 4. Delete transients (current + legacy bases, both transient + // transient_timeout halves, single- and multisite). $transient_patterns = []; $transient_bases = ['aetos_seo']; foreach ($transient_bases as $t_base) { $transient_patterns[] = '_transient_' . $t_base . '\_%'; $transient_patterns[] = '_transient_timeout_' . $t_base . '\_%'; $transient_patterns[] = '_site_transient_' . $t_base . '\_%'; $transient_patterns[] = '_site_transient_timeout_' . $t_base . '\_%'; } foreach ($transient_patterns as $pat) { $wpdb->query($wpdb->prepare( "DELETE FROM %i WHERE option_name LIKE %s", $wpdb->options, $pat )); } // 5. Delete user meta (dismissed notices, onboarding state) — current + // this plugin's own legacy prefixes. $usermeta_patterns = [ 'aetos_seo\_%', ]; foreach ($usermeta_patterns as $pat) { $wpdb->query($wpdb->prepare( "DELETE FROM %i WHERE meta_key LIKE %s", $wpdb->usermeta, $pat )); } // 6. Clear scheduled cron events. $cron = _get_cron_array(); if (is_array($cron)) { foreach ($cron as $ts => $hooks) { if (!is_array($hooks)) { continue; } foreach ($hooks as $hook => $events) { $h = (string) $hook; // This plugin's own cron hooks only (aetos_seo_*). Matched on // the exact prefix — never a broad match that could unschedule // a separate, similarly-named third-party plugin's events. $ours = (strpos($h, 'aetos_seo_') === 0); if (!$ours) { continue; } if (!is_array($events)) { continue; } foreach ($events as $event) { $args = isset($event['args']) && is_array($event['args']) ? $event['args'] : []; wp_unschedule_event((int) $ts, (string) $hook, $args); } } } } delete_transient('doing_cron'); // 7. Multisite — sweep each site's options/usermeta if running on a network. if (is_multisite() && function_exists('get_sites')) { $sites = get_sites(['number' => 0]); $current = get_current_blog_id(); foreach ($sites as $site) { $blog_id = (int) $site->blog_id; if ($blog_id === $current) { continue; // already cleaned above } switch_to_blog($blog_id); foreach ($known_options as $opt) { delete_option($opt); } foreach ($patterns as $pat) { $wpdb->query($wpdb->prepare( "DELETE FROM %i WHERE option_name LIKE %s", $wpdb->options, $pat )); } foreach ($transient_patterns as $pat) { $wpdb->query($wpdb->prepare( "DELETE FROM %i WHERE option_name LIKE %s", $wpdb->options, $pat )); } restore_current_blog(); } } // 8. Upload directory tree (reports, imports, cache, logs, bundles). Settings::delete_upload_dirs(); // 9. Object-cache invalidation — targeted, not site-wide. // // Pre-3.1.0 we called wp_cache_flush() which empties the entire WP // object cache (memcached/Redis), evicting every other plugin's data // and causing a site-wide latency spike after uninstall. We now only // invalidate the keys this plugin owns. Cache-misses on already-deleted // options are cheap; lingering stale values get refreshed on next read. if (function_exists('wp_cache_delete')) { foreach ($known_options as $opt) { wp_cache_delete($opt, 'options'); } } if (function_exists('wp_cache_flush_group')) { // Added in WP 6.1+ on supporting object caches; silently no-ops elsewhere. @wp_cache_flush_group('aetos_seo_locks'); } } /** * Is the OTHER Aetos edition (the sibling of this Lite/Pro build) still * active? Slug-agnostic and rewrite-proof: we scan active plugins (single- * and network-site) for any plugin in a DIFFERENT folder whose main-file * header carries the Aetos namespace / brand marker. Runs once, at * uninstall, so the small file reads are cheap. Present in BOTH editions. */ private static function sibling_edition_active(): bool { if (!function_exists('get_option')) { return false; } // This plugin's own folder name, via the WordPress plugin-path API // (plugin_basename() returns "/includes/Core/Uninstaller.php"; // its first path segment is the plugin folder) instead of a // fixed-level parent-directory walk. $self_rel = function_exists('plugin_basename') ? str_replace('\\', '/', (string) plugin_basename(__FILE__)) : ''; $self_dir = $self_rel !== '' ? (string) strtok($self_rel, '/') : ''; $active = (array) get_option('active_plugins', []); if (function_exists('is_multisite') && is_multisite() && function_exists('get_site_option')) { $active = array_merge($active, array_keys((array) get_site_option('active_sitewide_plugins', []))); } if (function_exists('aetos_seo_plugins_root')) { $plugin_root = rtrim(aetos_seo_plugins_root(), '/\\'); } else { // Without the plugin's own path helper we cannot resolve the // plugins directory; skip the optional sibling check rather than // assume a fixed directory layout. return false; } foreach ($active as $basename) { if (!is_string($basename) || $basename === '') { continue; } if (dirname($basename) === $self_dir) { continue; // this is me } $main = $plugin_root . '/' . $basename; if (!is_readable($main)) { continue; } $head = (string) file_get_contents($main, false, null, 0, 8192); if ($head === '') { continue; } // 'AetosSeo' (namespace) and 'Aetos SEO' (brand) are preserved // verbatim in BOTH builds — the slug rewrite only touches the // hyphenated 'aetos-seo' literal, never these. if (strpos($head, 'AetosSeo') !== false || strpos($head, 'Aetos SEO') !== false) { return true; } } return false; } }