get_var( $wpdb->prepare( 'SELECT option_value FROM %i WHERE option_name=%s LIMIT 1', $wpdb->options, $name ) ); if ( '' !== $wpdb->last_error ) { return new \WP_Error( 'migration_storage', 'Site option storage is unavailable.' ); } return null === $raw ? null : maybe_unserialize( $raw ); } /** * Inspect persisted cron metadata for the selected owned hook. * * @param string $hook Owned scheduled hook whose persisted presence is being checked. * * @return bool Whether actual persisted cron data contains the hook. */ public static function scheduled( string $hook ): bool { $cron = self::persisted_option( 'cron' ); if ( ! is_array( $cron ) ) { return false; } foreach ( $cron as $timestamp => $events ) { if ( is_numeric( $timestamp ) && is_array( $events ) && ! empty( $events[ $hook ] ) ) { return true; } } return false; } /** * Initialize and verify requested grants, response defaults and owned schedules after schema setup. * * Per-site DML only; shared tables have already passed the migration phases. * * @param array $capabilities Fixed plugin capability suffixes authorized for this site initialization. * * @return bool|\WP_Error True after verified initialization, or a capability, storage or schedule error. */ public static function initialize_site( array $capabilities ): bool|\WP_Error { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb Native WordPress database connection, including supported worker subclasses. */ $role_key = $wpdb->get_blog_prefix( get_current_blog_id() ) . 'user_roles'; wp_cache_delete( $role_key, 'options' ); wp_cache_delete( 'alloptions', 'options' ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Refresh the current site's native role registry after cache invalidation before explicitly adding and verifying this plugin's setup capabilities. $GLOBALS['wp_roles'] = new \WP_Roles( get_current_blog_id() ); $role = get_role( 'administrator' ); if ( $capabilities && ! $role ) { return new \WP_Error( 'migration_capabilities', 'The administrator role is unavailable; capabilities were not initialized.' ); } foreach ( $capabilities as $cap ) { $role->add_cap( 'logged_ai_' . $cap ); } $roles = self::persisted_option( $role_key ); if ( is_wp_error( $roles ) ) { return $roles; } foreach ( $capabilities as $cap ) { if ( ! is_array( $roles ) || true !== ( $roles['administrator']['capabilities'][ 'logged_ai_' . $cap ] ?? null ) ) { return new \WP_Error( 'migration_capabilities', 'Required capabilities could not be saved.' ); } } $network = Scope::network(); $site = (int) get_current_blog_id(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Initialize the custom response scope with native prepared DML while preserving existing rows. $ok = $wpdb->query( $wpdb->prepare( 'INSERT IGNORE INTO %i (network_id,site_id,schema_version,last_change) VALUES (%d,%d,%d,%d)', Requests::table( 'response_state' ), $network, $site, Requests::VERSION, time() ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Update the custom response schema marker without overwriting a future version; actual DML is required. if ( false === $ok || false === $wpdb->query( $wpdb->prepare( 'UPDATE %i SET schema_version=%d WHERE network_id=%d AND site_id=%d AND schema_version<=%d', Requests::table( 'response_state' ), Requests::VERSION, $network, $site, Requests::VERSION ) ) ) { return new \WP_Error( 'migration_site', 'Response scope initialization failed.' ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Verify the custom response version actually persisted; cached state cannot prove initialization. $version = $wpdb->get_var( $wpdb->prepare( 'SELECT schema_version FROM %i WHERE network_id=%d AND site_id=%d', Requests::table( 'response_state' ), $network, $site ) ); if ( '' !== $wpdb->last_error || (string) Requests::VERSION !== (string) $version ) { return new \WP_Error( 'migration_site', 'Response scope initialization could not be verified.' ); } try { RequestsAutomaticSchema::setup_off(); } catch ( \Throwable $error ) { return new \WP_Error( 'migration_site', 'Automatic off-state initialization failed.' ); } $hooks = array( 'logged_ai_retention' => 300, 'logged_ai_request_retention' => 60, 'logged_ai_export_cleanup' => 300, 'logged_ai_alert_worker' => 60, 'logged_ai_automatic_cleanup' => 60, ); foreach ( $hooks as $hook => $delay ) { if ( ! wp_next_scheduled( $hook ) ) { $scheduled = wp_schedule_event( time() + $delay, 'hourly', $hook, array(), true ); if ( is_wp_error( $scheduled ) || ! $scheduled ) { return new \WP_Error( 'migration_schedule', 'Required background work could not be scheduled.' ); } } if ( ! self::scheduled( $hook ) ) { return new \WP_Error( 'migration_schedule', 'Required background work was not persisted.' ); } } try { if ( class_exists( NetworkPrivacy::class ) ) { NetworkPrivacy::configure(); } } catch ( \Throwable $error ) { return new \WP_Error( 'migration_site', 'Network scope initialization failed.' ); } if ( is_multisite() && class_exists( NetworkPrivacy::class ) ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Verify actual network privacy initialization; cached configuration is not persistence proof. $state = $wpdb->get_row( $wpdb->prepare( 'SELECT event_days,generation FROM %i WHERE network_id=%d', NetworkPrivacy::table(), $network ), ARRAY_A ); if ( '' !== $wpdb->last_error || ! is_array( $state ) || (int) $state['event_days'] < 1 || (int) $state['event_days'] > 3650 || (int) $state['generation'] < 1 ) { return new \WP_Error( 'migration_site', 'Network scope initialization was not persisted.' ); } if ( (int) get_main_site_id( $network ) === $site && ! self::scheduled( 'logged_ai_network_retention' ) ) { return new \WP_Error( 'migration_schedule', 'Network retention work was not persisted.' ); } } update_option( 'logged_ai_schema_version', 1, false ); $marker = self::persisted_option( 'logged_ai_schema_version' ); if ( is_wp_error( $marker ) || '1' !== (string) $marker ) { return new \WP_Error( 'migration_storage', 'Site schema marker could not be saved.' ); } return true; } /** * Delegate plugin activation to the bounded resumable migration engine. * * @param bool $network_wide Whether the operation covers the current WordPress network. * * @return void * * Unsupported databases keep only the paused diagnostics shell, without setup writes. */ public static function activate( bool $network_wide = false ): void { if ( ! Platform::supported() ) { Platform::pause(); return; } try { Migrations::activate( $network_wide ); } catch ( \Throwable $error ) { // Do not expose exception text, SQL, credentials or filesystem paths. wp_die( '
' . esc_html__( 'Logged.ai could not complete database setup. The plugin has not been activated. Return to Plugins and retry. If setup still fails, contact support.', 'logged-ai' ) . '
', esc_html__( 'Logged.ai setup needs attention', 'logged-ai' ), array( 'response' => 503, 'back_link' => true ) ); } } /** * Initialize a newly created site only when this plugin is network activated there. * * @param \WP_Site $site New native WordPress site whose current network activation is checked. * * @return void */ public static function created_site( \WP_Site $site ): void { $active = get_network_option( (int) $site->network_id, 'active_sitewide_plugins', array() ); if ( ! isset( $active[ plugin_basename( LOGGED_AI_FILE ) ] ) ) { return; } switch_to_blog( (int) $site->blog_id ); try { Migrations::activate(); } catch ( \Throwable $error ) { Migrations::defer_site(); } finally { restore_current_blog(); } } /** * Remove owned schedules for the selected site or current network while preserving data. * * @param bool $network_wide Whether the operation covers the current WordPress network. * * @return void * * @throws \RuntimeException When an owned advanced-alert schedule cannot be removed. */ public static function deactivate( bool $network_wide = false ): void { $clear = static function () { foreach ( array( 'logged_ai_request_retention', 'logged_ai_retention', 'logged_ai_privacy_batch', 'logged_ai_export_worker', 'logged_ai_export_cleanup', 'logged_ai_alert_worker', 'logged_ai_alert_batch', 'logged_ai_network_alert_batch', 'logged_ai_network_retention', 'logged_ai_network_privacy_batch', 'logged_ai_network_export_worker', 'logged_ai_automatic_cleanup', 'logged_ai_automatic_worker', Migrations::HOOK ) as $hook ) { wp_clear_scheduled_hook( $hook ); } foreach ( array( 'logged_ai_advanced_alert_sources', 'logged_ai_advanced_alert_deliveries' ) as $hook ) { $result = wp_unschedule_hook( $hook, true ); if ( is_wp_error( $result ) || false === $result ) { throw new \RuntimeException( 'Owned advanced alert schedules could not be removed; retry deactivation.' ); } } }; if ( is_multisite() && $network_wide ) { $offset = 0; do { $sites = get_sites( array( 'network_id' => Scope::network(), 'fields' => 'ids', 'number' => 100, 'offset' => $offset, 'orderby' => 'id', 'order' => 'ASC', ) ); foreach ( $sites as $site ) { switch_to_blog( (int) $site ); try { $clear(); } finally { restore_current_blog(); } } $offset += count( $sites ); // phpcs:ignore Squiz.PHP.DisallowSizeFunctionsInLoops.Found -- This bounded collection changes during each page/cleanup step. Its current native array count is an O(1) continuation check and must not be hoisted outside the loop. } while ( 100 === count( $sites ) ); } else { $clear(); } } }