PluginProbe
404 Solution / trunk
404 Solution vtrunk
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 / uninstall / Uninstaller.php

Uninstaller.php in 404 Solution trunk, at includes/uninstall/Uninstaller.php

342 lines 12.5 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 self::logWarning('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 // DAO-bypass-approved: Plugin-table discovery during uninstall — no DAO autoloader
139 $wpdb->prepare("SHOW TABLES LIKE %s", $wpdb->esc_like($prefix . 'abj404_') . '%'),
140 ARRAY_N
141 );
142 if (is_array($allTables)) {
143 foreach ($allTables as $row) {
144 if (is_string($row[0])) {
145 self::deleteTable($row[0]);
146 }
147 }
148 }
149 return;
150 }
151
152 // Partial deletion: respect each category preference separately.
153 if ($deleteRedirects) {
154 self::deleteTable($prefix . 'abj404_redirects');
155 }
156
157 if ($deleteLogs) {
158 self::deleteTable($prefix . 'abj404_logsv2');
159 self::deleteTable($prefix . 'abj404_lookup');
160 }
161
162 if ($deleteCache) {
163 self::deleteTable($prefix . 'abj404_permalink_cache');
164 self::deleteTable($prefix . 'abj404_ngram_cache');
165 self::deleteTable($prefix . 'abj404_spelling_cache');
166 self::deleteTable($prefix . 'abj404_view_cache');
167 }
168
169 // Always delete temporary tables (they hold no user data worth preserving).
170 self::deleteTable($prefix . 'abj404_logs_hits_temp');
171 }
172
173 /**
174 * Safely delete a database table.
175 *
176 * Defense-in-depth: refuses any name that is not shaped like a plugin
177 * table ({prefix}abj404_{suffix}). Today the only callers are the
178 * partial-deletion paths in deleteTables() using {prefix} + 'abj404_*'
179 * constants and a 'SHOW TABLES LIKE ...abj404_%' discovery loop, so an
180 * unsafe value cannot reach here. The shape check inside the sink keeps
181 * that property robust against future maintenance routing a user-derived
182 * value into the same private method, without relying on esc_sql() (which
183 * does not escape backticks and so cannot protect an identifier context
184 * on its own).
185 *
186 * @param string $table_name Full table name with prefix
187 * @return void
188 */
189 private static function deleteTable(string $table_name): void {
190 global $wpdb;
191
192 // CRON GUARD: Uninstaller.php is loaded only by uninstall.php which
193 // WordPress invokes during operator-driven plugin removal, never via
194 // cron. Refuse cron context as a structural backstop so
195 // CronReachableDestructiveSqlLintTest can prove the DROP TABLE below
196 // is unreachable from any cron tick.
197 if (function_exists('wp_doing_cron') && wp_doing_cron()) {
198 return;
199 }
200
201 // q992: refuse anything outside the {prefix}abj404_{suffix} shape.
202 if (!preg_match('/^[a-z0-9_]+abj404_[a-z0-9_]+$/i', $table_name)) {
203 return;
204 }
205
206 // DAO-bypass-approved: DDL drop during uninstall. No DAO autoloader available.
207 $wpdb->query("DROP TABLE IF EXISTS `$table_name`");
208 }
209
210 /**
211 * Delete all plugin options from wp_options table
212 * @return void
213 */
214 public static function deleteAllOptions(): void {
215 global $wpdb;
216
217 $optionsTable = $wpdb->options ?? (($wpdb->prefix ?? 'wp_') . 'options');
218 $sitemetaTable = $wpdb->sitemeta ?? (($wpdb->prefix ?? 'wp_') . 'sitemeta');
219
220 // List of all plugin options
221 $options = array(
222 'abj404_settings',
223 'abj404_db_version',
224 'abj404_migrated_to_relative_paths',
225 'abj404_migration_results',
226 'abj404_ngram_cache_initialized',
227 'abj404_ngram_rebuild_offset',
228 // The multisite rebuild walk's progress record. Written as network
229 // options on a network-activated install, so an uninstall that
230 // dropped only the two above left a half-finished walk behind for
231 // the next install to resume into a cache that no longer exists.
232 'abj404_ngram_last_site_id',
233 'abj404_ngram_sites_completed',
234 'abj404_ngram_current_site_offset',
235 'abj404_ngram_total_sites',
236 'abj404_ngram_pending_sites',
237 'abj404_uninstall_preferences' // Clean up the preferences option
238 );
239
240 // Delete each option
241 foreach ($options as $option) {
242 delete_option($option);
243 delete_site_option($option); // For multisite (network options)
244 }
245
246 // Delete dynamic sync options (using LIKE pattern)
247 // DAO-bypass-approved: wp_options cleanup during uninstall — no DAO autoloader
248 $wpdb->query(
249 // DAO-bypass-approved: wp_options cleanup during uninstall — no DAO autoloader
250 $wpdb->prepare(
251 "DELETE FROM {$optionsTable} WHERE option_name LIKE %s",
252 $wpdb->esc_like('abj404_sync_') . '%'
253 )
254 );
255
256 // For multisite, delete from site options too
257 if (is_multisite()) {
258 // DAO-bypass-approved: Multisite sitemeta cleanup during uninstall
259 $wpdb->query(
260 // DAO-bypass-approved: Multisite sitemeta cleanup during uninstall
261 $wpdb->prepare(
262 "DELETE FROM {$sitemetaTable} WHERE meta_key LIKE %s",
263 $wpdb->esc_like('abj404_sync_') . '%'
264 )
265 );
266 }
267 }
268
269 /**
270 * Clean up all scheduled cron jobs
271 * @return void
272 */
273 public static function cleanupCronJobs(): void {
274 // Full per-site cron tear-down: everything the plugin currently
275 // schedules plus historical hook names that older versions may have
276 // left behind. Must stay aligned with the canonical list pinned by
277 // DeactivationCronCleanupTest. When production starts scheduling a
278 // new hook, add it here AND to doUnregisterCrons() in
279 // PluginLogicTrait_Lifecycle.php.
280 $cron_hooks = array(
281 'abj404_cleanupCronAction',
282 'abj404_updateLogsHitsTableAction',
283 'abj404_updatePermalinkCacheAction',
284 'abj404_rebuild_ngram_cache_hook',
285 'abj404_rebuildViewDone',
286 'abj404_gsc_fetch_cron',
287 'abj404_gsc_background_refresh',
288 'abj404_send_digest',
289 'abj404_logsv2_canonical_backfill',
290 'abj404_redirects_denorm_backfill',
291 'abj404_redirects_sort_key_backfill',
292 'abj404_send_queued_report',
293 'abj404_repair_collations',
294 );
295
296 foreach ($cron_hooks as $hook) {
297 wp_clear_scheduled_hook($hook);
298 }
299
300 // Also clean up any old/legacy cron hooks
301 $legacy_hooks = array(
302 'abj404_duplicateCronAction',
303 'abj404_updatePermalinkCache',
304 'abj404_cleanupCron',
305 'removeDuplicatesCron',
306 'deleteOldRedirectsCron',
307 );
308
309 foreach ($legacy_hooks as $hook) {
310 wp_clear_scheduled_hook($hook);
311 }
312 }
313
314 /**
315 * Get list of all tables created by this plugin
316 *
317 * @return array<int, string> Array of table names (without prefix)
318 */
319 public static function getTableNames(): array {
320 return array(
321 'abj404_redirects',
322 'abj404_logsv2',
323 'abj404_lookup',
324 'abj404_logs_hits_temp',
325 'abj404_permalink_cache',
326 'abj404_ngram_cache',
327 'abj404_spelling_cache',
328 'abj404_view_cache'
329 );
330 }
331
332 private static function logWarning(string $message): void {
333 $logger = function_exists('abj_service_optional') ? abj_service_optional('logging') : null;
334 if (is_object($logger) && method_exists($logger, 'warn')) {
335 $logger->warn($message);
336 return;
337 }
338
339 abj404_logPhpFallback('service-resolution-fallback', $message);
340 }
341 }
342