PluginProbe
404 Solution / 4.1.19
404 Solution v4.1.19
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / Uninstaller.php

Uninstaller.php in 404 Solution 4.1.19, at includes/Uninstaller.php

307 lines 10.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3
4 if (!defined('ABSPATH')) {
5 exit;
6 }
7
8 /**
9 * Handles plugin uninstallation
10 * Completely separate from existing plugin code
11 *
12 * @since 2.36.11
13 */
14 class ABJ_404_Solution_Uninstaller {
15
16 /**
17 * Main uninstall method
18 * Processes deletion based on user preferences
19 *
20 * @param array<string, mixed> $preferences User's uninstall preferences from modal
21 * @return void
22 */
23 public static function uninstall(array $preferences): void {
24 global $wpdb;
25
26 /** @var array<string, mixed> $preferences */
27
28 // 1. Delete database tables based on user preferences
29 self::deleteTables($wpdb, $preferences);
30
31 // 2. Delete system page if user chose to delete data
32 $deleteAnyData = ($preferences['delete_redirects'] ?? false)
33 || ($preferences['delete_logs'] ?? false)
34 || ($preferences['delete_cache'] ?? false);
35 if ($deleteAnyData) {
36 self::deleteSystemPage();
37 }
38
39 // 3. Delete all WordPress options
40 self::deleteAllOptions();
41
42 // 4. Clean up scheduled cron jobs
43 self::cleanupCronJobs();
44
45 // Feedback is sent at deactivate-time by UninstallModal (the modal AJAX
46 // handler dispatches before WordPress deletes the plugin). WordPress
47 // enforces deactivate-before-delete, so any feedback the user opted into
48 // has already been queued/sent by the time uninstall.php runs. Sending
49 // again here from the persisted preferences would be a double-send.
50 }
51
52 /**
53 * Delete the auto-created system page (if it exists).
54 * Finds it via post meta _abj404_system_page = 1.
55 *
56 * @return void
57 */
58 private static function deleteSystemPage(): void {
59 // Guard: get_posts may not exist during standalone uninstall (no autoloader).
60 if (!function_exists('get_posts')) {
61 return;
62 }
63
64 $pages = get_posts(array(
65 'post_type' => 'page',
66 'post_status' => 'any',
67 'meta_key' => '_abj404_system_page',
68 'meta_value' => '1',
69 'posts_per_page' => 5,
70 'fields' => 'ids',
71 'no_found_rows' => true,
72 ));
73
74 if (is_array($pages)) {
75 foreach ($pages as $pageId) {
76 wp_delete_post((int) $pageId, true);
77 }
78 }
79 }
80
81 /**
82 * Handle multisite uninstallation
83 * Runs uninstall process for each site in the network
84 * IMPORTANT: This should only be called when the plugin is network-activated
85 *
86 * @param array<string, mixed> $preferences User's uninstall preferences
87 * @return void
88 */
89 public static function multisite_uninstall(array $preferences): void {
90 global $wpdb;
91
92 // Safety check: Verify this is actually a network-wide uninstall
93 // This prevents accidental data loss if called incorrectly
94 if (!function_exists('is_plugin_active_for_network')) {
95 require_once ABSPATH . 'wp-admin/includes/plugin.php';
96 }
97
98 $plugin_file = '404-solution/404-solution.php';
99 if (!is_plugin_active_for_network($plugin_file)) {
100 // Log error and bail out - this method should not have been called
101 error_log('404 Solution: multisite_uninstall() called but plugin is not network-activated. Aborting to prevent data loss.');
102 return;
103 }
104
105 // Get all blog IDs in the network
106 // DAO-bypass-approved: Multisite blog enumeration during uninstall — no DAO autoloader
107 $blog_ids = $wpdb->get_col("SELECT blog_id FROM $wpdb->blogs");
108
109 foreach ($blog_ids as $blog_id) {
110 switch_to_blog($blog_id);
111 self::uninstall($preferences);
112 restore_current_blog();
113 }
114 }
115
116 /**
117 * Delete database tables based on user preferences
118 *
119 * @param object $wpdb WordPress database object (wpdb or compatible)
120 * @param array<string, mixed> $preferences User preferences
121 * @return void
122 */
123 private static function deleteTables(object $wpdb, array $preferences): void {
124 // Use wpdb prefix directly - Uninstaller must be standalone (no autoloader)
125 /** @var \wpdb $wpdb */
126 $prefix = strtolower($wpdb->prefix);
127
128 $deleteRedirects = $preferences['delete_redirects'] ?? false;
129 $deleteLogs = $preferences['delete_logs'] ?? false;
130 $deleteCache = $preferences['delete_cache'] ?? false;
131
132 // When ALL data-deletion options are selected, use SHOW TABLES for dynamic
133 // discovery — the DB is the source of truth for which plugin tables exist.
134 // This ensures future tables are cleaned up even if this list isn't updated.
135 if ($deleteRedirects && $deleteLogs && $deleteCache) {
136 // DAO-bypass-approved: Plugin-table discovery during uninstall — no DAO autoloader
137 $allTables = $wpdb->get_results(
138 $wpdb->prepare("SHOW TABLES LIKE %s", $wpdb->esc_like($prefix . 'abj404_') . '%'),
139 ARRAY_N
140 );
141 if (is_array($allTables)) {
142 foreach ($allTables as $row) {
143 if (is_string($row[0])) {
144 self::deleteTable($row[0]);
145 }
146 }
147 }
148 return;
149 }
150
151 // Partial deletion: respect each category preference separately.
152 if ($deleteRedirects) {
153 self::deleteTable($prefix . 'abj404_redirects');
154 }
155
156 if ($deleteLogs) {
157 self::deleteTable($prefix . 'abj404_logsv2');
158 self::deleteTable($prefix . 'abj404_lookup');
159 }
160
161 if ($deleteCache) {
162 self::deleteTable($prefix . 'abj404_permalink_cache');
163 self::deleteTable($prefix . 'abj404_ngram_cache');
164 self::deleteTable($prefix . 'abj404_spelling_cache');
165 self::deleteTable($prefix . 'abj404_view_cache');
166 }
167
168 // Always delete temporary tables (they hold no user data worth preserving).
169 self::deleteTable($prefix . 'abj404_logs_hits_temp');
170 }
171
172 /**
173 * Safely delete a database table
174 *
175 * @param string $table_name Full table name with prefix
176 * @return void
177 */
178 private static function deleteTable(string $table_name): void {
179 global $wpdb;
180
181 // CRON GUARD: Uninstaller.php is loaded only by uninstall.php which
182 // WordPress invokes during operator-driven plugin removal, never via
183 // cron. Refuse cron context as a structural backstop so
184 // CronReachableDestructiveSqlLintTest can prove the DROP TABLE below
185 // is unreachable from any cron tick.
186 if (function_exists('wp_doing_cron') && wp_doing_cron()) {
187 return;
188 }
189
190 // @utf8-audit: opt-out — $table_name is built from $wpdb->prefix +
191 // 'abj404_*' constants in the deleteTables() loop, or comes from a
192 // SHOW TABLES query result; never user input.
193 // Security: Use wpdb methods and prepare statement
194 $table_name = esc_sql($table_name);
195 // DAO-bypass-approved: DDL drop during uninstall — no DAO autoloader
196 $wpdb->query("DROP TABLE IF EXISTS `$table_name`");
197 }
198
199 /**
200 * Delete all plugin options from wp_options table
201 * @return void
202 */
203 public static function deleteAllOptions(): void {
204 global $wpdb;
205
206 $optionsTable = $wpdb->options ?? (($wpdb->prefix ?? 'wp_') . 'options');
207 $sitemetaTable = $wpdb->sitemeta ?? (($wpdb->prefix ?? 'wp_') . 'sitemeta');
208
209 // List of all plugin options
210 $options = array(
211 'abj404_settings',
212 'abj404_db_version',
213 'abj404_migrated_to_relative_paths',
214 'abj404_migration_results',
215 'abj404_ngram_cache_initialized',
216 'abj404_ngram_rebuild_offset',
217 'abj404_uninstall_preferences' // Clean up the preferences option
218 );
219
220 // Delete each option
221 foreach ($options as $option) {
222 delete_option($option);
223 delete_site_option($option); // For multisite (network options)
224 }
225
226 // Delete dynamic sync options (using LIKE pattern)
227 // DAO-bypass-approved: wp_options cleanup during uninstall — no DAO autoloader
228 $wpdb->query(
229 $wpdb->prepare(
230 "DELETE FROM {$optionsTable} WHERE option_name LIKE %s",
231 $wpdb->esc_like('abj404_sync_') . '%'
232 )
233 );
234
235 // For multisite, delete from site options too
236 if (is_multisite()) {
237 // DAO-bypass-approved: Multisite sitemeta cleanup during uninstall
238 $wpdb->query(
239 $wpdb->prepare(
240 "DELETE FROM {$sitemetaTable} WHERE meta_key LIKE %s",
241 $wpdb->esc_like('abj404_sync_') . '%'
242 )
243 );
244 }
245 }
246
247 /**
248 * Clean up all scheduled cron jobs
249 * @return void
250 */
251 public static function cleanupCronJobs(): void {
252 // Full per-site cron tear-down: everything the plugin currently
253 // schedules plus historical hook names that older versions may have
254 // left behind. Must stay aligned with the canonical list pinned by
255 // DeactivationCronCleanupTest. When production starts scheduling a
256 // new hook, add it here AND to doUnregisterCrons() in
257 // PluginLogicTrait_Lifecycle.php.
258 $cron_hooks = array(
259 'abj404_cleanupCronAction',
260 'abj404_updateLogsHitsTableAction',
261 'abj404_updatePermalinkCacheAction',
262 'abj404_rebuild_ngram_cache_hook',
263 'abj404_rebuildViewDone',
264 'abj404_gsc_fetch_cron',
265 'abj404_gsc_background_refresh',
266 'abj404_send_digest',
267 'abj404_logsv2_canonical_backfill',
268 'abj404_send_queued_report',
269 );
270
271 foreach ($cron_hooks as $hook) {
272 wp_clear_scheduled_hook($hook);
273 }
274
275 // Also clean up any old/legacy cron hooks
276 $legacy_hooks = array(
277 'abj404_duplicateCronAction',
278 'abj404_updatePermalinkCache',
279 'abj404_cleanupCron',
280 'removeDuplicatesCron',
281 'deleteOldRedirectsCron',
282 );
283
284 foreach ($legacy_hooks as $hook) {
285 wp_clear_scheduled_hook($hook);
286 }
287 }
288
289 /**
290 * Get list of all tables created by this plugin
291 *
292 * @return array<int, string> Array of table names (without prefix)
293 */
294 public static function getTableNames(): array {
295 return array(
296 'abj404_redirects',
297 'abj404_logsv2',
298 'abj404_lookup',
299 'abj404_logs_hits_temp',
300 'abj404_permalink_cache',
301 'abj404_ngram_cache',
302 'abj404_spelling_cache',
303 'abj404_view_cache'
304 );
305 }
306 }
307