'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');
}
}