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 / PluginLogicTrait_Lifecycle.php

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

446 lines 18.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 if (!defined('ABSPATH')) {
4 exit;
5 }
6
7 /**
8 * Plugin activation, deactivation, multisite lifecycle, and cron registration.
9 *
10 * Extracted from PluginLogic.php to keep the main class under the size limit.
11 */
12 trait ABJ_404_Solution_PluginLogicTrait_Lifecycle {
13
14 /** Remove cron jobs. @return void */
15 static function doUnregisterCrons(): void {
16 $crons = array(
17 // Currently scheduled per-site recurring/one-shot hooks.
18 'abj404_cleanupCronAction',
19 'abj404_gsc_fetch_cron',
20 'abj404_gsc_background_refresh',
21 'abj404_rebuildViewDone',
22 'abj404_updatePermalinkCacheAction',
23 'abj404_updateLogsHitsTableAction',
24 'abj404_send_digest',
25 'abj404_rebuild_ngram_cache_hook',
26 'abj404_logsv2_canonical_backfill',
27 'abj404_send_queued_report',
28 // Legacy hook names retained so an upgrade-then-deactivate cycle
29 // on a site that still carries stale entries from an older plugin
30 // version cleans them out. Production code no longer schedules
31 // these; they cost a wp_next_scheduled() probe per deactivation
32 // and that is cheap insurance against stranded legacy events.
33 'abj404_duplicateCronAction',
34 'removeDuplicatesCron',
35 'deleteOldRedirectsCron',
36 );
37 for ($i = 0; $i < count($crons); $i++) {
38 $cron_name = $crons[$i];
39 $timestamp1 = wp_next_scheduled($cron_name);
40 while ($timestamp1 != false) {
41 wp_unschedule_event($timestamp1, $cron_name);
42 $timestamp1 = wp_next_scheduled($cron_name);
43 }
44
45 $timestamp2 = wp_next_scheduled($cron_name, array(''));
46 while ($timestamp2 != false) {
47 wp_unschedule_event($timestamp2, $cron_name, array(''));
48 $timestamp2 = wp_next_scheduled($cron_name, array(''));
49 }
50
51 wp_clear_scheduled_hook($cron_name);
52 }
53 }
54
55 /** Create database tables. Register crons. etc.
56 * Handles both single-site and multisite activations.
57 *
58 * For network activations, sites are activated asynchronously in the background
59 * to prevent timeouts on large networks.
60 *
61 * @param bool $network_wide Whether this is a network-wide activation
62 * @global type $abj404logic
63 * @global type $abj404dao
64 * @return void
65 */
66 static function runOnPluginActivation(bool $network_wide = false): void {
67 if (is_multisite() && $network_wide) {
68 // Network activation: Schedule background activation to prevent timeouts
69 $sites = get_sites(array('fields' => 'ids', 'number' => 0));
70
71 // Store list of pending site IDs in network option
72 update_site_option('abj404_pending_network_activation', $sites);
73 update_site_option('abj404_network_activation_total', count($sites));
74
75 // Schedule first batch immediately
76 wp_schedule_single_event(time(), 'abj404_network_activation_hook');
77
78 // Show admin notice that activation is happening in background
79 add_action('network_admin_notices', function() {
80 $pendingRaw = get_site_option('abj404_pending_network_activation', array());
81 $pending = is_array($pendingRaw) ? $pendingRaw : array();
82 $totalRaw = get_site_option('abj404_network_activation_total', 0);
83 $total = is_scalar($totalRaw) ? (int)$totalRaw : 0;
84 $completed = $total - count($pending);
85
86 if (!empty($pending)) {
87 echo '<div class="notice notice-info"><p><strong>404 Solution:</strong> Network activation in progress... ' .
88 esc_html((string)$completed) . ' of ' . esc_html((string)$total) . ' sites activated. ' .
89 'This will complete in the background.</p></div>';
90 }
91 });
92 } else {
93 // Single site activation (or individual subsite activation)
94 self::activateSingleSite();
95 }
96 }
97
98 /**
99 * Activate plugin for a single site.
100 * This contains the actual activation logic that was previously in runOnPluginActivation.
101 *
102 * @global type $abj404logic
103 * @global type $abj404dao
104 * @global type $abj404logging
105 * @return void
106 */
107 private static function activateSingleSite(): void {
108 $abj404logic = abj_service('plugin_logic');
109 add_option('abj404_settings', '', '', false);
110
111 $upgradesEtc = abj_service('database_upgrades');
112 $upgradesEtc->createDatabaseTables();
113
114 // Route through the canonical self-heal prologue so activation
115 // reaches every recovery primitive in the same order as the daily
116 // cron. createDatabaseTables() above is the full schema-creation
117 // path for a fresh install; the prologue is a cheap idempotent
118 // drift-correction pass that the SelfHealingPrologueReachabilityTest
119 // can statically observe.
120 $upgradesEtc->runSelfHealPrologue();
121
122 ABJ_404_Solution_PluginLogic::doRegisterCrons();
123
124 $abj404logic->doUpdateDBVersionOption();
125 }
126
127 /**
128 * Background cron handler for network activation.
129 * Processes one site at a time to prevent timeouts.
130 * Reschedules itself if more sites remain.
131 * @return void
132 */
133 static function networkActivationCronHandler(): void {
134 // Get list of pending sites
135 $pendingRaw = get_site_option('abj404_pending_network_activation', array());
136 $pending = is_array($pendingRaw) ? $pendingRaw : array();
137
138 if (empty($pending)) {
139 // All done! Clean up network options
140 delete_site_option('abj404_pending_network_activation');
141 delete_site_option('abj404_network_activation_total');
142 return;
143 }
144
145 // Process one site
146 $blog_id = array_shift($pending);
147 $blog_id_int = is_scalar($blog_id) ? (int)$blog_id : 0;
148
149 try {
150 switch_to_blog($blog_id_int);
151 self::activateSingleSite();
152 restore_current_blog();
153 } catch (Exception $e) {
154 // Log to BOTH the PHP error log (so a host opened ticket can find
155 // it without a debug bundle) and the plugin debug log (so it lands
156 // in the support-bundle excerpt). Continue with other sites: a
157 // single-site failure must not block network activation overall.
158 $remaining = max(0, count($pending));
159 $errorLine = '404 Solution: Network activation failed for site ' . $blog_id_int .
160 ': ' . $e->getMessage() . '. Remaining sites=' . $remaining .
161 '. Action: skipping this site, continuing with next.';
162 error_log($errorLine);
163 $logger = abj_service('logging');
164 if ($logger !== null) {
165 $logger->errorMessage($errorLine, $e);
166 }
167 restore_current_blog();
168 }
169
170 // Update pending list
171 update_site_option('abj404_pending_network_activation', $pending);
172
173 // Schedule next site (10 seconds delay to spread load)
174 if (!empty($pending)) {
175 wp_schedule_single_event(time() + 10, 'abj404_network_activation_hook');
176 } else {
177 // All done! Clean up network options
178 delete_site_option('abj404_pending_network_activation');
179 delete_site_option('abj404_network_activation_total');
180 }
181 }
182
183 /**
184 * Handle new blog creation in multisite (WordPress < 5.1).
185 * This is triggered by the wpmu_new_blog action.
186 *
187 * @param int $blog_id Blog ID of the new blog
188 * @param int $user_id User ID of the user creating the blog
189 * @param string $domain Domain of the new blog
190 * @param string $path Path of the new blog
191 * @param int $site_id Site ID (network ID)
192 * @param array<string, mixed> $meta Additional meta information
193 * @return void
194 */
195 static function activateNewSite($blog_id, $user_id, $domain, $path, $site_id, $meta): void {
196 // Only activate if the plugin is network-activated.
197 // is_plugin_active_for_network() lives in wp-admin/includes/plugin.php; guard
198 // adjacent in case this hook fires before wp-admin includes are loaded.
199 if (!function_exists('is_plugin_active_for_network')) {
200 return;
201 }
202 if (is_plugin_active_for_network(plugin_basename(ABJ404_FILE))) {
203 switch_to_blog($blog_id);
204 try {
205 self::activateSingleSite();
206 } catch (\Throwable $e) {
207 // Per-subsite infrastructure failure (disk-full, read-only-replica
208 // during CREATE TABLE on the brand-new subsite). Log at WARN so
209 // dev email reports are not triggered, but always restore the
210 // blog context so the request that created the subsite is not
211 // left in a corrupted switch_to_blog state. The plugin remains
212 // functional on every other site in the network.
213 $logger = abj_service('logging');
214 if ($logger !== null && method_exists($logger, 'warn')) {
215 $logger->warn(sprintf(
216 '404 Solution: subsite activation failed for blog_id=%d: %s',
217 (int)$blog_id,
218 $e->getMessage()
219 ));
220 }
221 } finally {
222 restore_current_blog();
223 }
224 }
225 }
226
227 /**
228 * Handle new blog creation in multisite (WordPress >= 5.1).
229 * This is triggered by the wp_initialize_site action.
230 *
231 * @param mixed $site The WP_Site object for the new site. Normalized via
232 * ABJ_404_Solution_SiteRef so a malformed payload (third-party filter
233 * mutating the action argument, or a missing blog_id) early-returns
234 * instead of calling switch_to_blog(0).
235 * @param array<string, mixed> $args Additional arguments passed to the hook
236 * @return void
237 */
238 static function activateNewSiteModern($site, $args): void {
239 // Only activate if the plugin is network-activated.
240 // is_plugin_active_for_network() lives in wp-admin/includes/plugin.php; guard
241 // adjacent in case this hook fires before wp-admin includes are loaded.
242 if (!function_exists('is_plugin_active_for_network')) {
243 return;
244 }
245 if (is_plugin_active_for_network(plugin_basename(ABJ404_FILE))) {
246 $siteRef = ABJ_404_Solution_SiteRef::fromWpSite($site);
247 if ($siteRef === null) {
248 return;
249 }
250 $blogId = $siteRef->getBlogId();
251 switch_to_blog($blogId);
252 try {
253 self::activateSingleSite();
254 } catch (\Throwable $e) {
255 // Same per-subsite degradation contract as activateNewSite()
256 // above (legacy hook). See that method's comment for the
257 // rationale; this path is the WordPress 5.1+ replacement
258 // (wp_initialize_site).
259 $logger = abj_service('logging');
260 if ($logger !== null && method_exists($logger, 'warn')) {
261 $logger->warn(sprintf(
262 '404 Solution: subsite activation failed for blog_id=%d: %s',
263 $blogId,
264 $e->getMessage()
265 ));
266 }
267 } finally {
268 restore_current_blog();
269 }
270 }
271 }
272
273 /**
274 * Handle plugin deactivation for both single-site and multisite.
275 *
276 * @param bool $network_wide Whether this is a network-wide deactivation
277 * @return void
278 */
279 static function runOnPluginDeactivation(bool $network_wide = false): void {
280 if (is_multisite() && $network_wide) {
281 // Network deactivation: deactivate for all sites
282 $sites = get_sites(array('fields' => 'ids', 'number' => 0));
283
284 foreach ($sites as $blog_id) {
285 switch_to_blog($blog_id);
286 self::deactivateSingleSite();
287 restore_current_blog();
288 }
289 } else {
290 // Single site deactivation
291 self::deactivateSingleSite();
292 }
293 }
294
295 /**
296 * Deactivate plugin for a single site.
297 * Unregisters cron jobs.
298 * @return void
299 */
300 private static function deactivateSingleSite(): void {
301 self::doUnregisterCrons();
302 }
303
304 /**
305 * Clean up when a blog is deleted in multisite.
306 * This is triggered by the delete_blog action.
307 *
308 * @global wpdb $wpdb WordPress database object
309 * @param int $blog_id Blog ID being deleted
310 * @param bool $drop Whether to drop the tables (true) or just deactivate (false)
311 * @return void
312 */
313 static function deleteBlogData($blog_id, $drop = false): void {
314 // CRON GUARD: hooked only via add_action('delete_blog') from
315 // registerLifecycleHooks(), which itself runs only under is_admin().
316 // Refuse cron context as a structural backstop so
317 // CronReachableDestructiveSqlLintTest can prove the DROP TABLE below
318 // is never reachable from a daily cron tick.
319 if (function_exists('wp_doing_cron') && wp_doing_cron()) {
320 return;
321 }
322
323 if ($drop) {
324 switch_to_blog($blog_id);
325
326 global $wpdb;
327 $dao = abj_service('data_access');
328 $prefix = $dao->getLowercasePrefix();
329
330 // Remove ALL custom database tables via dynamic discovery.
331 // SHOW TABLES is the source of truth; new tables are automatically included.
332 // DAO-bypass-approved: deleteBlogData() runs during multisite blog teardown after switch_to_blog()
333 $tables = $wpdb->get_results(
334 // DAO-bypass-approved: prepare() argument to the get_results above
335 $wpdb->prepare("SHOW TABLES LIKE %s", $wpdb->esc_like($prefix . 'abj404_') . '%'),
336 ARRAY_N
337 );
338 foreach ($tables as $tableRow) {
339 $tblName = is_array($tableRow) && isset($tableRow[0]) ? $tableRow[0] : '';
340 if (preg_match('/^[a-zA-Z0-9_]+$/', $tblName) && strpos($tblName, 'abj404') !== false) {
341 // DAO-bypass-approved: deleteBlogData() — DDL drop during blog teardown
342 $wpdb->query("DROP TABLE IF EXISTS `{$tblName}`");
343 }
344 }
345
346 // Remove ALL plugin options
347 $plugin_options = array(
348 'abj404_settings',
349 'abj404_db_version',
350 'abj404_migrated_to_relative_paths',
351 'abj404_migration_results',
352 'abj404_ngram_cache_initialized',
353 'abj404_ngram_rebuild_offset',
354 'abj404_ngram_usage_stats',
355 'abj404_installed_time',
356 'abj404_user_feedback',
357 'abj404_uninstall_preferences'
358 );
359
360 foreach ($plugin_options as $option) {
361 delete_option($option);
362 }
363
364 // Delete dynamic sync options (using LIKE pattern)
365 // DAO-bypass-approved: deleteBlogData() runs wp_options cleanup during blog teardown
366 $wpdb->query(
367 // DAO-bypass-approved: prepare() argument to the query above
368 $wpdb->prepare(
369 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
370 $wpdb->esc_like('abj404_sync_') . '%'
371 )
372 );
373
374 // Clear ALL scheduled cron jobs for this blog. Must stay aligned
375 // with the canonical list pinned by DeactivationCronCleanupTest
376 // and with Uninstaller::cleanupCronJobs(). When production starts
377 // scheduling a new hook, add it to all three sites.
378 $cron_hooks = array(
379 'abj404_cleanupCronAction',
380 'abj404_updateLogsHitsTableAction',
381 'abj404_updatePermalinkCacheAction',
382 'abj404_rebuild_ngram_cache_hook',
383 'abj404_rebuildViewDone',
384 'abj404_gsc_fetch_cron',
385 'abj404_gsc_background_refresh',
386 'abj404_send_digest',
387 'abj404_logsv2_canonical_backfill',
388 'abj404_send_queued_report',
389 );
390
391 foreach ($cron_hooks as $hook) {
392 wp_clear_scheduled_hook($hook);
393 }
394
395 // Also clear legacy cron hooks
396 $legacy_hooks = array(
397 'abj404_duplicateCronAction',
398 'abj404_updatePermalinkCache',
399 'abj404_cleanupCron',
400 'removeDuplicatesCron',
401 'deleteOldRedirectsCron',
402 );
403
404 foreach ($legacy_hooks as $hook) {
405 wp_clear_scheduled_hook($hook);
406 }
407
408 restore_current_blog();
409 }
410 }
411
412 /** @return void */
413 static function doRegisterCrons(): void {
414 if (!wp_next_scheduled('abj404_cleanupCronAction')) {
415 // we randomize this so that when the geo2ip file is downloaded, there aren't a whole
416 // lot of users that request the file at the same time.
417 $timeForEvent = '0' . random_int(0, 5) . ':' . random_int(10, 59) . ':' . random_int(10, 59);
418 $eventTimestamp = strtotime($timeForEvent);
419 if ($eventTimestamp !== false) {
420 wp_schedule_event($eventTimestamp, 'daily', 'abj404_cleanupCronAction');
421 }
422 }
423
424 if (!wp_next_scheduled('abj404_gsc_fetch_cron')) {
425 $timeForGsc = '0' . random_int(1, 4) . ':' . random_int(10, 59) . ':' . random_int(10, 59);
426 $gscTimestamp = strtotime($timeForGsc);
427 if ($gscTimestamp !== false) {
428 wp_schedule_event($gscTimestamp, 'daily', 'abj404_gsc_fetch_cron');
429 }
430 }
431
432 self::scheduleViewDoneWarmup();
433 }
434
435 /** @return void */
436 private static function scheduleViewDoneWarmup(): void {
437 if (!function_exists('wp_next_scheduled') || !function_exists('wp_schedule_single_event')) {
438 return;
439 }
440 if (wp_next_scheduled('abj404_rebuildViewDone') !== false) {
441 return;
442 }
443 wp_schedule_single_event(time() + 5, 'abj404_rebuildViewDone');
444 }
445 }
446