'ids', 'number' => 0)); // Store list of pending site IDs in network option update_site_option('abj404_pending_network_activation', $sites); update_site_option('abj404_network_activation_total', count($sites)); // Schedule first batch immediately wp_schedule_single_event(time(), 'abj404_network_activation_hook'); // Show admin notice that activation is happening in background add_action('network_admin_notices', function() { $pendingRaw = get_site_option('abj404_pending_network_activation', array()); $pending = is_array($pendingRaw) ? $pendingRaw : array(); $totalRaw = get_site_option('abj404_network_activation_total', 0); $total = is_scalar($totalRaw) ? (int)$totalRaw : 0; $completed = $total - count($pending); if (!empty($pending)) { echo '

404 Solution: Network activation in progress... ' . esc_html((string)$completed) . ' of ' . esc_html((string)$total) . ' sites activated. ' . 'This will complete in the background.

'; } }); } else { // Single site activation (or individual subsite activation) self::activateSingleSite(); } } /** * Activate plugin for a single site. * This contains the actual activation logic that was previously in runOnPluginActivation. * * @global type $abj404logic * @global type $abj404dao * @global type $abj404logging * @return void */ private static function activateSingleSite(): void { $abj404logic = abj_service('plugin_logic'); add_option('abj404_settings', '', '', false); $upgradesEtc = abj_service('database_upgrades'); $upgradesEtc->createDatabaseTables(); // Route through the canonical self-heal prologue so activation // reaches every recovery primitive in the same order as the daily // cron. createDatabaseTables() above is the full schema-creation // path for a fresh install; the prologue is a cheap idempotent // drift-correction pass that the SelfHealingPrologueReachabilityTest // can statically observe. $upgradesEtc->runSelfHealPrologue(); ABJ_404_Solution_PluginLogic::doRegisterCrons(); $abj404logic->doUpdateDBVersionOption(); } /** * Background cron handler for network activation. * Processes one site at a time to prevent timeouts. * Reschedules itself if more sites remain. * @return void */ static function networkActivationCronHandler(): void { // Get list of pending sites $pendingRaw = get_site_option('abj404_pending_network_activation', array()); $pending = is_array($pendingRaw) ? $pendingRaw : array(); if (empty($pending)) { // All done! Clean up network options delete_site_option('abj404_pending_network_activation'); delete_site_option('abj404_network_activation_total'); return; } // Process one site $blog_id = array_shift($pending); $blog_id_int = is_scalar($blog_id) ? (int)$blog_id : 0; try { switch_to_blog($blog_id_int); self::activateSingleSite(); restore_current_blog(); } catch (Exception $e) { // Log to BOTH the PHP error log (so a host opened ticket can find // it without a debug bundle) and the plugin debug log (so it lands // in the support-bundle excerpt). Continue with other sites: a // single-site failure must not block network activation overall. $remaining = max(0, count($pending)); $errorLine = '404 Solution: Network activation failed for site ' . $blog_id_int . ': ' . $e->getMessage() . '. Remaining sites=' . $remaining . '. Action: skipping this site, continuing with next.'; error_log($errorLine); $logger = abj_service('logging'); if ($logger !== null) { $logger->errorMessage($errorLine, $e); } restore_current_blog(); } // Update pending list update_site_option('abj404_pending_network_activation', $pending); // Schedule next site (10 seconds delay to spread load) if (!empty($pending)) { wp_schedule_single_event(time() + 10, 'abj404_network_activation_hook'); } else { // All done! Clean up network options delete_site_option('abj404_pending_network_activation'); delete_site_option('abj404_network_activation_total'); } } /** * Handle new blog creation in multisite (WordPress < 5.1). * This is triggered by the wpmu_new_blog action. * * @param int $blog_id Blog ID of the new blog * @param int $user_id User ID of the user creating the blog * @param string $domain Domain of the new blog * @param string $path Path of the new blog * @param int $site_id Site ID (network ID) * @param array $meta Additional meta information * @return void */ static function activateNewSite($blog_id, $user_id, $domain, $path, $site_id, $meta): void { // Only activate if the plugin is network-activated. // is_plugin_active_for_network() lives in wp-admin/includes/plugin.php; guard // adjacent in case this hook fires before wp-admin includes are loaded. if (!function_exists('is_plugin_active_for_network')) { return; } if (is_plugin_active_for_network(plugin_basename(ABJ404_FILE))) { switch_to_blog($blog_id); try { self::activateSingleSite(); } catch (\Throwable $e) { // Per-subsite infrastructure failure (disk-full, read-only-replica // during CREATE TABLE on the brand-new subsite). Log at WARN so // dev email reports are not triggered, but always restore the // blog context so the request that created the subsite is not // left in a corrupted switch_to_blog state. The plugin remains // functional on every other site in the network. $logger = abj_service('logging'); if ($logger !== null && method_exists($logger, 'warn')) { $logger->warn(sprintf( '404 Solution: subsite activation failed for blog_id=%d: %s', (int)$blog_id, $e->getMessage() )); } } finally { restore_current_blog(); } } } /** * Handle new blog creation in multisite (WordPress >= 5.1). * This is triggered by the wp_initialize_site action. * * @param mixed $site The WP_Site object for the new site. Normalized via * ABJ_404_Solution_SiteRef so a malformed payload (third-party filter * mutating the action argument, or a missing blog_id) early-returns * instead of calling switch_to_blog(0). * @param array $args Additional arguments passed to the hook * @return void */ static function activateNewSiteModern($site, $args): void { // Only activate if the plugin is network-activated. // is_plugin_active_for_network() lives in wp-admin/includes/plugin.php; guard // adjacent in case this hook fires before wp-admin includes are loaded. if (!function_exists('is_plugin_active_for_network')) { return; } if (is_plugin_active_for_network(plugin_basename(ABJ404_FILE))) { $siteRef = ABJ_404_Solution_SiteRef::fromWpSite($site); if ($siteRef === null) { return; } $blogId = $siteRef->getBlogId(); switch_to_blog($blogId); try { self::activateSingleSite(); } catch (\Throwable $e) { // Same per-subsite degradation contract as activateNewSite() // above (legacy hook). See that method's comment for the // rationale; this path is the WordPress 5.1+ replacement // (wp_initialize_site). $logger = abj_service('logging'); if ($logger !== null && method_exists($logger, 'warn')) { $logger->warn(sprintf( '404 Solution: subsite activation failed for blog_id=%d: %s', $blogId, $e->getMessage() )); } } finally { restore_current_blog(); } } } /** * Handle plugin deactivation for both single-site and multisite. * * @param bool $network_wide Whether this is a network-wide deactivation * @return void */ static function runOnPluginDeactivation(bool $network_wide = false): void { if (is_multisite() && $network_wide) { // Network deactivation: deactivate for all sites $sites = get_sites(array('fields' => 'ids', 'number' => 0)); foreach ($sites as $blog_id) { switch_to_blog($blog_id); self::deactivateSingleSite(); restore_current_blog(); } } else { // Single site deactivation self::deactivateSingleSite(); } } /** * Deactivate plugin for a single site. * Unregisters cron jobs. * @return void */ private static function deactivateSingleSite(): void { self::doUnregisterCrons(); } /** * Clean up when a blog is deleted in multisite. * This is triggered by the delete_blog action. * * @global wpdb $wpdb WordPress database object * @param int $blog_id Blog ID being deleted * @param bool $drop Whether to drop the tables (true) or just deactivate (false) * @return void */ static function deleteBlogData($blog_id, $drop = false): void { // CRON GUARD: hooked only via add_action('delete_blog') from // registerLifecycleHooks(), which itself runs only under is_admin(). // Refuse cron context as a structural backstop so // CronReachableDestructiveSqlLintTest can prove the DROP TABLE below // is never reachable from a daily cron tick. if (function_exists('wp_doing_cron') && wp_doing_cron()) { return; } if ($drop) { switch_to_blog($blog_id); global $wpdb; $dao = abj_service('data_access'); $prefix = $dao->getLowercasePrefix(); // Remove ALL custom database tables via dynamic discovery. // SHOW TABLES is the source of truth; new tables are automatically included. // DAO-bypass-approved: deleteBlogData() runs during multisite blog teardown after switch_to_blog() $tables = $wpdb->get_results( // DAO-bypass-approved: prepare() argument to the get_results above $wpdb->prepare("SHOW TABLES LIKE %s", $wpdb->esc_like($prefix . 'abj404_') . '%'), ARRAY_N ); foreach ($tables as $tableRow) { $tblName = is_array($tableRow) && isset($tableRow[0]) ? $tableRow[0] : ''; if (preg_match('/^[a-zA-Z0-9_]+$/', $tblName) && strpos($tblName, 'abj404') !== false) { // DAO-bypass-approved: deleteBlogData() — DDL drop during blog teardown $wpdb->query("DROP TABLE IF EXISTS `{$tblName}`"); } } // Remove ALL plugin options $plugin_options = array( 'abj404_settings', 'abj404_db_version', 'abj404_migrated_to_relative_paths', 'abj404_migration_results', 'abj404_ngram_cache_initialized', 'abj404_ngram_rebuild_offset', 'abj404_ngram_usage_stats', 'abj404_installed_time', 'abj404_user_feedback', 'abj404_uninstall_preferences' ); foreach ($plugin_options as $option) { delete_option($option); } // Delete dynamic sync options (using LIKE pattern) // DAO-bypass-approved: deleteBlogData() runs wp_options cleanup during blog teardown $wpdb->query( // DAO-bypass-approved: prepare() argument to the query above $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s", $wpdb->esc_like('abj404_sync_') . '%' ) ); // Clear ALL scheduled cron jobs for this blog. Must stay aligned // with the canonical list pinned by DeactivationCronCleanupTest // and with Uninstaller::cleanupCronJobs(). When production starts // scheduling a new hook, add it to all three sites. $cron_hooks = array( 'abj404_cleanupCronAction', 'abj404_updateLogsHitsTableAction', 'abj404_updatePermalinkCacheAction', 'abj404_rebuild_ngram_cache_hook', 'abj404_rebuildViewDone', 'abj404_gsc_fetch_cron', 'abj404_gsc_background_refresh', 'abj404_send_digest', 'abj404_logsv2_canonical_backfill', 'abj404_send_queued_report', ); foreach ($cron_hooks as $hook) { wp_clear_scheduled_hook($hook); } // Also clear legacy cron hooks $legacy_hooks = array( 'abj404_duplicateCronAction', 'abj404_updatePermalinkCache', 'abj404_cleanupCron', 'removeDuplicatesCron', 'deleteOldRedirectsCron', ); foreach ($legacy_hooks as $hook) { wp_clear_scheduled_hook($hook); } restore_current_blog(); } } /** @return void */ static function doRegisterCrons(): void { if (!wp_next_scheduled('abj404_cleanupCronAction')) { // we randomize this so that when the geo2ip file is downloaded, there aren't a whole // lot of users that request the file at the same time. $timeForEvent = '0' . random_int(0, 5) . ':' . random_int(10, 59) . ':' . random_int(10, 59); $eventTimestamp = strtotime($timeForEvent); if ($eventTimestamp !== false) { wp_schedule_event($eventTimestamp, 'daily', 'abj404_cleanupCronAction'); } } if (!wp_next_scheduled('abj404_gsc_fetch_cron')) { $timeForGsc = '0' . random_int(1, 4) . ':' . random_int(10, 59) . ':' . random_int(10, 59); $gscTimestamp = strtotime($timeForGsc); if ($gscTimestamp !== false) { wp_schedule_event($gscTimestamp, 'daily', 'abj404_gsc_fetch_cron'); } } self::scheduleViewDoneWarmup(); } /** @return void */ private static function scheduleViewDoneWarmup(): void { if (!function_exists('wp_next_scheduled') || !function_exists('wp_schedule_single_event')) { return; } if (wp_next_scheduled('abj404_rebuildViewDone') !== false) { return; } wp_schedule_single_event(time() + 5, 'abj404_rebuildViewDone'); } }