self::config( $fresh ) ); } /** * Save alert configuration through the network-mode authority checks. * * @param Scope $scope Operation scope whose current permissions are checked. * @param array $input Complete proposed alert settings to validate. * * @return array|\WP_Error Verified saved configuration, or a validation, authority or storage error. */ public static function network_save( Scope $scope, array $input ): array|\WP_Error { return self::in_network( static fn() => self::save( $scope, $input ) ); } /** * List bounded delivery work through the network-mode read checks. * * @param Scope $scope Operation scope whose current permissions are checked. * * @return array|\WP_Error Projected recent deliveries, or an authorization or storage error. */ public static function network_listing( Scope $scope ): array|\WP_Error { return self::in_network( static fn() => self::listing( $scope ) ); } /** * Run network alert work only in its unchanged native main-site context. * * Network cron runs on its native main site; normal worker health is already recorded. * * @return void */ public static function network_cron(): void { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ $db = $wpdb; $origin = Derived::native_context( $db ); if ( ! is_multisite() ) { return; } $network = Scope::network(); $main = (int) get_main_site_id( $network ); if ( Derived::native_context( $db ) !== $origin || get_current_blog_id() !== $main ) { return; } try { $result = self::network_worker(); } catch ( \Throwable $error ) { $result = new \WP_Error( 'worker', 'Network alert work was interrupted.' ); } // Do not repeat the normal heartbeat or add another worker schedule. A matching // already-recorded failure is coalesced by Health::record's native readback. $failed = is_wp_error( $result ); if ( Derived::native_context( $db ) === $origin && $failed && class_exists( Health::class, false ) ) { Health::record( 'network_alerts', $result ); } } /** * Run the network alert worker on its main site and restore any site switch. * * @return array|\WP_Error Worker counts and status, or a scope or worker error. */ public static function network_worker(): array|\WP_Error { if ( ! is_multisite() ) { return new \WP_Error( 'scope', 'A multisite network is required.' ); } $network = Scope::network(); $main = (int) get_main_site_id( $network ); $switched = get_current_blog_id() !== $main; if ( $switched ) { switch_to_blog( $main ); } try { if ( get_current_blog_id() !== $main || Scope::network() !== $network ) { return new \WP_Error( 'scope', 'Network alert context could not be established.' ); } return self::in_network( static fn() => self::worker() ); } finally { if ( $switched ) { restore_current_blog(); } } } /** * Invalidate derived alert context through the network privacy adapter. * * @param Scope $scope Operation scope whose current permissions are checked. * @param int|null $user Optional user identifier to remove from network alert recipients. * * @return bool|\WP_Error Whether cleanup completed, or a privacy or storage error. */ public static function invalidate_network( Scope $scope, ?int $user = null ): bool|\WP_Error { return self::in_network( static fn() => self::invalidate_site( $scope, $user ) ); } /** * Schedule the batch matching the admitted event scope and restore alert mode. * * @param array $event Already minimized event evidence with its scope and identity. * * @return void */ public static function schedule_for_event( array $event ): void { $before = self::$network_mode; self::$network_mode = is_multisite() && 0 === (int) $event['site_id']; try { self::schedule(); } finally { self::$network_mode = $before; } } private const RULES = array( 'administrator' => 'Administrator creation or promotion', 'registration' => 'Registration enabled or privileged default role selected', 'security_component' => 'Selected security component deactivated', 'admin_email' => 'Administrative email change confirmed', 'authentication_burst' => 'Bounded failed authentication burst', 'request_finding' => 'Admitted WordPress request finding', 'automatic_suppressed' => 'Automatic response limit or coverage suppression', ); /** Rules supplied by locally installed add-ons; critical notices remain in Free. */ public static function rules(): array { return self::RULES + (array) apply_filters( 'logged_ai_activity_notification_rules', array(), self::$network_mode ); } /** * Resolve an allowlisted alert table under the configured database prefix. * * @param string $kind Allowlisted alert storage kind. * * @return string Server-owned table name for deliveries or cooldowns. * * @throws \InvalidArgumentException When the requested alert table kind is not allowlisted. */ public static function table( string $kind = 'deliveries' ): string { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ if ( ! in_array( $kind, array( 'deliveries', 'alert_cooldowns' ), true ) ) { throw new \InvalidArgumentException( 'Unknown alert table.' ); } return $wpdb->base_prefix . 'logged_ai_' . $kind; } /** * Provide disabled alert rules and recipients with a finite default cooldown. * * @return array Default alert configuration and initial revision. */ public static function defaults(): array { return array( 'revision' => 1, 'rules' => array(), 'recipients' => array(), 'cooldown' => 3600, 'security_components' => array(), ); } /** * Read validated alert settings and fall back to disabled defaults on failure. * * @param bool $fresh Whether to bypass the option cache for a strict storage read. * * @return array Validated current configuration or disabled defaults. */ public static function config( bool $fresh = false ): array { $config = $fresh ? self::strict_config() : self::option( 'logged_ai_alerts', self::defaults() ); if ( ! is_array( $config ) || true !== self::validate( $config ) ) { Store::degraded( 'alert_failed' ); return self::defaults(); } return $config; } /** * Read and validate the plugin option directly from current scoped storage. * * @return array|\WP_Error Validated configuration, defaults when absent, or an explicit storage/configuration error. */ private static function strict_config(): array|\WP_Error { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ if ( self::$network_mode ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Read actual alert configuration; cached options could retain revoked rules or recipients. $row = $wpdb->get_row( $wpdb->prepare( 'SELECT meta_value AS option_value FROM %i WHERE site_id=%d AND meta_key=%s ORDER BY meta_id LIMIT 1', $wpdb->sitemeta, Scope::network(), 'logged_ai_network_alerts' ), ARRAY_A ); } else { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Read actual alert configuration; cached options could retain revoked rules or recipients. $row = $wpdb->get_row( $wpdb->prepare( 'SELECT option_value FROM %i WHERE option_name=%s LIMIT 1', $wpdb->options, 'logged_ai_alerts' ), ARRAY_A ); } if ( '' !== $wpdb->last_error ) { return new \WP_Error( 'storage', 'Alert configuration is unavailable.' ); } if ( null === $row ) { return self::defaults(); } // This is the plugin's own WordPress-serialized option, never request input. $config = maybe_unserialize( $row['option_value'] ); return is_array( $config ) && true === self::validate( $config ) ? $config : new \WP_Error( 'config', 'Saved alert configuration is invalid; delivery is paused.' ); } /** * Validate the complete finite alert-configuration schema. * * @param array $input Complete proposed alert settings to validate. * * @return bool|\WP_Error True for valid settings, or an error describing an unsupported selection. */ private static function validate( array $input ): bool|\WP_Error { if ( array_diff( array_keys( self::defaults() ), array_keys( $input ) ) || array_diff( array_keys( $input ), array_keys( self::defaults() ) ) || ! is_int( $input['revision'] ) || $input['revision'] < 1 || $input['revision'] >= 2147483647 || ! is_int( $input['cooldown'] ) || $input['cooldown'] < 60 || $input['cooldown'] > 86400 ) { return new \WP_Error( 'config', 'Invalid alert configuration.' ); } foreach ( array( 'rules' => 8, 'recipients' => 5, 'security_components' => 32, ) as $key => $limit ) { if ( ! is_array( $input[ $key ] ) || ! array_is_list( $input[ $key ] ) || count( $input[ $key ] ) > $limit || count( array_unique( $input[ $key ], SORT_REGULAR ) ) !== count( $input[ $key ] ) ) { return new \WP_Error( 'config', 'Alert selections must be bounded unique lists.' ); } } foreach ( $input['rules'] as $rule ) { if ( ! is_string( $rule ) || ( ! isset( self::rules()[ $rule ] ) && 'plugin_install' !== $rule ) || ( self::$network_mode && in_array( $rule, array( 'authentication_burst', 'request_finding', 'automatic_suppressed' ), true ) ) ) { return new \WP_Error( 'rule', 'Choose a fixed supported rule.' ); } } foreach ( $input['recipients'] as $id ) { if ( ! is_int( $id ) || $id < 1 ) { return new \WP_Error( 'recipient', 'Choose an authorized local recipient.' ); } } foreach ( $input['security_components'] as $component ) { if ( ! is_string( $component ) || ! preg_match( '~\A[a-zA-Z0-9_.-]+(?:/[a-zA-Z0-9_.-]+)*\.php\z~D', $component ) || strlen( $component ) > 160 || 0 !== validate_file( $component ) ) { return new \WP_Error( 'component', 'Use an installed plugin file identifier.' ); } } return true; } /** * Declare minimized site, network and automatic-suppression event fields. * * @return array Registered event definitions for alert configuration and suppression facts. */ public static function definitions(): array { $definitions = array( 'configuration.alerts_changed' => array( 'category' => 'configuration', 'action' => 'alerts_changed', 'severity' => 'notice', 'object_type' => 'site', 'fields' => array( 'revision' => 'count', 'enabled_rules' => 'count', 'recipient_count' => 'count', ), 'diffs' => array(), ), ); $definitions['configuration.network_alerts_changed'] = $definitions['configuration.alerts_changed']; $definitions['configuration.network_alerts_changed']['event_scope'] = 'network'; $definitions['configuration.network_alerts_changed']['object_type'] = 'network'; $definitions['configuration.automatic_suppressed'] = array( 'category' => 'configuration', 'action' => 'automatic_suppressed', 'severity' => 'warning', 'object_type' => 'site', 'fields' => array( 'reason' => array( 'site_active_limit', 'installation_active_limit', 'site_hour_limit', 'installation_hour_limit', 'response_disabled', 'incomplete_source_window' ), 'configuration_revision' => 'count', 'cooldown_seconds' => 'count', ), 'diffs' => array(), ); return $definitions; } /** * Select an enabled fixed rule using only the supplied minimized evidence. * * Pure match over already minimized evidence; no username/IP speculation. * * @param array $event Already minimized event evidence with its scope and identity. * @param array $config Validated fixed-rule configuration used for this decision. * * @return string|null Matching rule key, or null when no enabled rule applies. */ public static function match( array $event, array $config ): ?string { if ( 'request' === ( $event['fact_kind'] ?? '' ) ) { return ! self::$network_mode && 'request.finding' === ( $event['code'] ?? '' ) && in_array( RequestsAlerts::RULE, $config['rules'], true ) ? RequestsAlerts::RULE : null; } $payload = json_decode( $event['payload'] ?? '', true ); if ( ! is_array( $payload ) ) { return null; } $facts = $payload['facts'] ?? array(); $code = $event['code'] ?? ''; $rule = null; if ( 'identity.authentication_failed_burst' === $code && 'failed' === ( $event['outcome'] ?? '' ) ) { $rule = 'authentication_burst'; } elseif ( 'stored_change' !== ( $event['outcome'] ?? '' ) ) { return null; } elseif ( 'configuration.automatic_suppressed' === $code && ! self::$network_mode && in_array( $facts['reason'] ?? '', array( 'site_active_limit', 'installation_active_limit', 'site_hour_limit', 'installation_hour_limit', 'response_disabled', 'incomplete_source_window' ), true ) ) { $rule = 'automatic_suppressed'; } elseif ( 'identity.user_created' === $code && in_array( 'administrator', $facts['roles'] ?? array(), true ) ) { $rule = 'administrator'; } elseif ( 'identity.super_admin_granted' === $code ) { $rule = 'administrator'; } elseif ( 'identity.roles_changed' === $code ) { foreach ( $payload['diffs'] ?? array() as $diff ) { if ( 'roles' === ( $diff['field'] ?? '' ) && 'captured' === ( $diff['availability'] ?? '' ) && in_array( 'administrator', $diff['after'] ?? array(), true ) && ! in_array( 'administrator', $diff['before'] ?? array(), true ) ) { $rule = 'administrator'; } } } elseif ( in_array( $code, array( 'configuration.option_changed', 'network.option_changed' ), true ) ) { if ( 'admin_email' === ( $facts['option'] ?? '' ) && 'confirmed' === ( $facts['confirmation'] ?? '' ) ) { $rule = 'admin_email'; } elseif ( true === ( $facts['registration_enabled'] ?? null ) || true === ( $facts['default_role_privileged'] ?? null ) ) { $rule = 'registration'; } } elseif ( in_array( $code, array( 'configuration.plugin_deactivated', 'configuration.network_plugin_deactivated' ), true ) && in_array( $facts['component'] ?? null, $config['security_components'], true ) ) { $rule = 'security_component'; } return $rule && in_array( $rule, $config['rules'], true ) ? $rule : apply_filters( 'logged_ai_activity_notification_match', null, $event, $config, self::$network_mode ); } /** * Bind an admitted event to the current matching rule and recipient revision. * * @param array $event Already minimized event evidence with its scope and identity. * * @return int Configuration revision eligible for alert work, or zero when none applies. */ public static function capture_revision( array $event ): int { $before = self::$network_mode; self::$network_mode = is_multisite() && 0 === (int) $event['site_id']; try { $config = self::config(); return $config['recipients'] && self::match( $event, $config ) ? $config['revision'] : 0; } finally { self::$network_mode = $before; } } /** * Bind an eligible request finding to the current site alert revision. * * @param array $record Minimized request finding being admitted. * * @return int Configuration revision eligible for request alerts, or zero when unavailable. */ public static function request_capture_revision( array $record ): int { if ( self::$network_mode || ( class_exists( Migrations::class ) && ! Migrations::ready( 'alerts' ) ) || ! RequestsAlerts::qualifies( $record ) ) { return 0; } $config = self::config(); return $config['recipients'] && in_array( RequestsAlerts::RULE, $config['rules'], true ) ? $config['revision'] : 0; } /** * Update alert processing state for the exact scoped fact identity. * * @param array $fact Selected request or event fact with its exact identity. * @param string $state Finite processing state selected by the caller. * @param int $network Selected network identifier. * @param int $site Selected site identifier, with zero representing network-origin evidence. * * @return bool Whether the scoped state update avoided a storage error. */ private static function mark_fact( array $fact, string $state, int $network, int $site ): bool { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ $request = 'request' === ( $fact['fact_kind'] ?? 'event' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Persist processing state for the exact custom fact identity; cache writes are insufficient. return false !== $wpdb->update( $request ? Requests::table( 'requests' ) : Store::table(), array( 'alert_state' => $state ), array( 'id' => $fact['id'], $request ? 'request_uid' : 'uid' => $fact['uid'], 'network_id' => $network, 'site_id' => $site, ) ); } /** * Reload the exact scoped request or event supporting a delivery job. * * @param array $job Stored export or delivery job fields used by this operation. * @param int $network Selected network identifier. * @param int $site Selected site identifier, with zero representing network-origin evidence. * * @return array|\WP_Error|null Current supporting fact, null when absent or unsupported, or a query error. */ private static function load_fact( array $job, int $network, int $site ): array|\WP_Error|null { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ if ( 'request' === ( $job['fact_kind'] ?? 'event' ) ) { return self::$network_mode ? null : RequestsAlerts::load( $network, $site, (int) $job['event_id'], (string) $job['event_uid'] ); } if ( 'event' !== ( $job['fact_kind'] ?? 'event' ) ) { return null; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Fixed native-prefix event table; exact scoped event UID/ID are bound placeholders. Reload current supporting evidence; cached rows could survive privacy erasure. $event = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE network_id=%d AND site_id=%d AND uid=%s AND id=%d', Store::table(), $network, $site, $job['event_uid'], $job['event_id'] ), ARRAY_A ); return '' !== $wpdb->last_error ? new \WP_Error( 'storage', 'Alert evidence could not be read.' ) : $event; } /** * Schedule a single next alert batch in the selected site or network main site. * * @return void */ public static function schedule(): void { $main = self::$network_mode ? (int) get_main_site_id( Scope::network() ) : get_current_blog_id(); $switched = get_current_blog_id() !== $main; if ( $switched ) { switch_to_blog( $main ); } try { if ( ! wp_next_scheduled( self::hook() ) ) { wp_schedule_single_event( time() + 60, self::hook() ); } } finally { if ( $switched ) { restore_current_blog(); } } } /** * Refresh native roles and account data before checking recipient evidence access. * * @param int $id Local account identifier whose current evidence access is checked. * * @return \WP_User|null Current authorized account with a valid email, or null when ineligible. */ private static function recipient( int $id ): ?\WP_User { // Role capability changes must not depend on this worker's earlier object cache. $role_key = $GLOBALS['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 this site's native roles after clearing role caches so a prior worker snapshot cannot authorize a revoked recipient. $GLOBALS['wp_roles'] = new \WP_Roles( get_current_blog_id() ); clean_user_cache( $id ); $user = new \WP_User( $id, '', get_current_blog_id() ); if ( ! $user->exists() || ! is_email( $user->user_email ) ) { return null; } $allowed = self::$network_mode ? Scope::network_authorized( $id ) && ! in_array( 'do_not_allow', map_meta_cap( 'logged_ai_view_network_events', $id ), true ) : $user->has_cap( 'logged_ai_view_events' ) && ( ! is_multisite() || Scope::network_authorized( $id ) || ! empty( $user->allcaps['logged_ai_view_events'] ) ); return $allowed ? $user : null; } /** * Validate and persist an alert revision with guarded native writes and readback. * * @param Scope $scope Operation scope whose current permissions are checked. * @param array $input Complete proposed alert settings to validate. * * @return array|\WP_Error Verified saved configuration, or a validation, authority or storage error. */ public static function save( Scope $scope, array $input ): array|\WP_Error { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ $db = $wpdb; $origin = self::native_context( $db ); $current = static fn(): bool => self::native_context( $db ) === $origin; if ( ! $scope->valid_for_all( array( self::cap() ) ) || ! $current() ) { return new \WP_Error( 'forbidden', 'Alert management is unavailable.' ); } $actor = Store::actor_context(); if ( ! $current() ) { return new \WP_Error( 'scope', 'Alert actor context changed.' ); } $valid = self::validate( $input ); if ( is_wp_error( $valid ) ) { return $valid; } foreach ( $input['recipients'] as $id ) { if ( ! $current() || ! self::recipient( $id ) || ! $current() ) { return new \WP_Error( 'recipient', 'Each recipient must currently have this site’s evidence access and a valid account email.' ); } } if ( ! $current() || ! $scope->valid_for_all( array( self::cap() ) ) || ! $current() ) { return new \WP_Error( 'forbidden', 'Alert authority changed.' ); } $result = self::locked( static function () use ( $input, $scope, $actor, $db, $origin, $current ) { if ( ! $current() ) { return new \WP_Error( 'scope', 'Alert settings context changed.' ); } $wpdb = $db; $old = self::strict_config(); if ( is_wp_error( $old ) ) { return $old; } foreach ( array_diff( $input['rules'], $old['rules'] ) as $rule ) { if ( ! isset( self::rules()[ $rule ] ) ) { return new \WP_Error( 'edition', 'Routine activity email notifications require the activated Pro add-on. Critical security and failure notices remain Free.' ); } } if ( $old['revision'] !== $input['revision'] ) { return new \WP_Error( 'stale', 'Alert settings changed. Reload them.' ); } $authorized = self::protected_read( $scope, $db, $origin, static fn() => self::strict_config() === $old ? true : new \WP_Error( 'stale', 'Alert settings changed before saving.' ) ); if ( is_wp_error( $authorized ) ) { return $authorized; } if ( $input['revision'] >= 2147483646 ) { return new \WP_Error( 'config', 'Alert revision capacity was reached; delivery remains paused until operator repair.' ); } $input['revision']++; $saved = self::guarded_write( $db, $origin, static fn() => self::put_option( 'logged_ai_alerts', $input ), $scope, $old, self::config_write_sql( $db, $scope->network_id, $input ) ); if ( is_wp_error( $saved ) ) { return $saved; } if ( ! $saved ) { return new \WP_Error( 'storage', 'Alert settings could not be saved.' ); } if ( ! $current() || Scope::network() !== $scope->network_id || self::site() !== $scope->site_id || ! $current() || self::strict_config() !== $input || ! $current() ) { return new \WP_Error( 'storage', 'Saved alert settings could not be verified in their original scope.' ); } // No historical replay: old queued work cannot adopt new recipients or rules. $cancelled = self::guarded_write( $db, $origin, static fn() => $db->query( $db->prepare( "UPDATE %i SET state='cancelled' WHERE network_id=%d AND site_id=%d AND state IN ('queued','retry_scheduled')", self::table(), $scope->network_id, $scope->site_id ) ) ); if ( is_wp_error( $cancelled ) ) { return $cancelled; } if ( false === $cancelled ) { Store::degraded( 'alert_failed' ); } if ( ! $current() ) { return new \WP_Error( 'scope', 'Alert settings were saved, but their audit context changed.' ); } $audited = self::guarded_write( $db, $origin, static fn() => Store::capture( array( 'code' => self::$network_mode ? 'configuration.network_alerts_changed' : 'configuration.alerts_changed', 'object_id' => self::$network_mode ? $scope->network_id : $scope->site_id, 'actor_context' => $actor, 'outcome' => 'stored_change', 'payload' => array( 'observed_via' => 'logged_ai_alerts', 'facts' => array( 'revision' => $input['revision'], 'enabled_rules' => count( $input['rules'] ), 'recipient_count' => count( $input['recipients'] ), ), ), ) ) ); if ( is_wp_error( $audited ) ) { return $audited; } if ( ! $audited ) { return new \WP_Error( 'audit_failed', 'Alert settings were saved, but their audit record failed. Check System status.' ); } return self::protected_read( $scope, $db, $origin, static fn() => self::strict_config() === $input ? $input : new \WP_Error( 'stale', 'Saved alert settings changed during auditing.' ) ); } ); if ( is_wp_error( $result ) ) { return $result; } // RELEASE_LOCK is a native query boundary too; do not disclose stale success. return self::protected_read( $scope, $db, $origin, static fn() => self::strict_config() === $result ? $result : new \WP_Error( 'stale', 'Saved alert settings changed before confirmation.' ) ); } /** * Fingerprint current alert mode together with the native database context. * * @phpstan-impure Pure native fingerprint; never refills caches or restores drift. * * @param \wpdb $db Original database connection bound to the operation. * * @return array Exact current mode and native context for comparison. */ private static function native_context( \wpdb $db ): array { return array( self::$network_mode, Derived::native_context( $db ) ); } /** * Recheck the result and final grant while observing intervening native writes. * * Protect a result and its final native grant decision from callback writes. * * @param Scope $scope Operation scope whose current permissions are checked. * @param \wpdb $db Original database connection bound to the operation. * @param array $origin Native context captured before callback or query boundaries. * @param callable $read Callback returning the exact value whose freshness must be verified. * * @return mixed Fresh authorized result, or an error when its context or value changes. */ private static function protected_read( Scope $scope, \wpdb $db, array $origin, callable $read ): mixed { $current = static fn(): bool => self::native_context( $db ) === $origin; if ( ! $current() ) { return new \WP_Error( 'scope', 'Alert read context changed.' ); } $changed = false; $observe = static function ( string $sql ) use ( &$changed, $db ): string { $statement = trim( $sql ); $setup = false; if ( in_array( $statement, array( 'SET SESSION autocommit=1', 'SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED' ), true ) ) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_debug_backtrace -- Inspect at most 24 argument-free frames to recognize only separate Scope permission-connection setup during the protected alert read. $trace = debug_backtrace( DEBUG_BACKTRACE_PROVIDE_OBJECT | DEBUG_BACKTRACE_IGNORE_ARGS, 24 ); foreach ( $trace as $index => $frame ) { if ( 'query' !== $frame['function'] || ! ( ( $frame['object'] ?? null ) instanceof \wpdb ) ) { continue; } $next = $trace[ $index + 1 ] ?? array(); $setup = $frame['object'] instanceof WorkerConnection && $frame['object'] !== $db && Scope::class === ( $next['class'] ?? '' ) && 'committed_capabilities' === $next['function']; break; } } if ( ! $setup && ! preg_match( '/\A(?:SELECT|SHOW|DESCRIBE|EXPLAIN)\s/i', $statement ) ) { $changed = true; } return $sql; }; add_filter( 'query', $observe, PHP_INT_MAX ); try { $result = $read(); $allowed = ! is_wp_error( $result ) && $current() && $scope->valid_for_all( array( self::cap() ) ) && $current(); // A capability callback may commit through an independent native worker. // Recheck the exact result within this observer after that callback. if ( $allowed ) { $fresh = $read(); $allowed = ! is_wp_error( $fresh ) && $fresh === $result && $current(); } } catch ( \Throwable $error ) { $result = new \WP_Error( 'storage', 'Alert verification was interrupted.' ); $allowed = false; } finally { remove_filter( 'query', $observe, PHP_INT_MAX ); } if ( $changed || ! $current() ) { return new \WP_Error( 'forbidden', 'Alert authority, configuration or context changed.' ); } return is_wp_error( $result ) || $allowed ? $result : new \WP_Error( 'forbidden', 'Alert authority changed.' ); } /** * Generate exact prepared native statements for the proposed alert option value. * * Exact native statements for the reviewed candidate, including first creation. * * @param \wpdb $db Original database connection bound to the operation. * @param int $network Selected network identifier. * @param array $value Validated alert option value to save. * * @return array Permitted SQL statements after native placeholder restoration. */ private static function config_write_sql( \wpdb $db, int $network, array $value ): array { $serialized = maybe_serialize( $value ); $statements = array(); if ( self::$network_mode && is_multisite() ) { $key = 'logged_ai_network_alerts'; $statements[] = $db->prepare( 'UPDATE %i SET `meta_value` = %s WHERE `site_id` = %d AND `meta_key` = %s', $db->sitemeta, $serialized, $network, $key ); $statements[] = $db->prepare( 'INSERT INTO %i (`site_id`, `meta_key`, `meta_value`) VALUES (%d, %s, %s)', $db->sitemeta, $network, $key, $serialized ); } else { $key = self::option_name( 'logged_ai_alerts' ); foreach ( array( 'off', 'no' ) as $autoload ) { $statements[] = $db->prepare( 'UPDATE %i SET `option_value` = %s, `autoload` = %s WHERE `option_name` = %s', $db->options, $serialized, $autoload, $key ); $statements[] = $db->prepare( 'INSERT INTO %i (`option_name`, `option_value`, `autoload`) VALUES (%s, %s, %s) ON DUPLICATE KEY UPDATE `option_name` = VALUES(`option_name`), `option_value` = VALUES(`option_value`), `autoload` = VALUES(`autoload`)', $db->options, $key, $serialized, $autoload ); } } return array_map( array( $db, 'remove_placeholder_escape' ), $statements ); } /** * Reject rewritten or unauthorized native writes within the captured context. * * Guard native save hooks before their SQL mutation, without taking transaction ownership. * * @param \wpdb $db Original database connection bound to the operation. * @param array $origin Native context captured before callback or query boundaries. * @param callable $write Callback performing the selected guarded native write. * @param Scope|null $scope Optional scope enabling fresh option-write authorization checks. * @param array|null $expected Expected current option configuration, or null when no option check applies. * @param array|null $expected_sql Exact permitted option-write statements, or null without that restriction. * * @return mixed Write result, or an error when SQL, authority or context changes. */ private static function guarded_write( \wpdb $db, array $origin, callable $write, ?Scope $scope = null, ?array $expected = null, ?array $expected_sql = null ): mixed { $current = static fn(): bool => self::native_context( $db ) === $origin; if ( ! $current() ) { return new \WP_Error( 'scope', 'Alert write context changed.' ); } $changed = false; $checking = false; $tables = array_filter( array( $db->options, $db->sitemeta ?? null ), 'is_string' ); $observe = static function ( string $sql ) use ( $db, $origin, $current, $scope, $expected, $expected_sql, $tables, &$changed, &$checking ): string { /** * Captured reference may be true during recursive native binding queries. * * @var bool $checking */ $statement = trim( $sql ); $read = 1 === preg_match( '/\A(?:SELECT|SHOW|DESCRIBE|EXPLAIN)\s/i', $statement ); $connection = null; $original_sql = null; $next = array(); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_debug_backtrace -- Inspect at most 24 live frames to compare original SQL and the actual database object before authorizing this alert write; no trace is emitted or persisted. $trace = debug_backtrace( DEBUG_BACKTRACE_PROVIDE_OBJECT, 24 ); foreach ( $trace as $index => $frame ) { if ( 'query' === $frame['function'] && ( $frame['object'] ?? null ) instanceof \wpdb ) { $connection = $frame['object']; $original_sql = $frame['args'][0] ?? null; $next = $trace[ $index + 1 ] ?? array(); break; } } // Core's priority-zero placeholder restoration is part of prepare(), not a SQL rewrite. if ( $connection instanceof \wpdb && is_string( $original_sql ) ) { $original_sql = $connection->remove_placeholder_escape( $original_sql ); } $setup = $connection instanceof WorkerConnection && $connection !== $db && Scope::class === ( $next['class'] ?? '' ) && 'committed_capabilities' === $next['function'] && in_array( $statement, array( 'SET SESSION autocommit=1', 'SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED' ), true ); if ( $original_sql !== $sql || ( ! $read && ! $setup && ( $changed || $checking || ! $current() ) ) ) { $changed = true; return ''; // Native wpdb rejects an empty filtered query before execution. } if ( ! $read && ! $setup && null !== $scope && $connection === $db ) { foreach ( $tables as $table ) { if ( preg_match( '/\A(?:UPDATE|INSERT(?:\s+IGNORE)?\s+INTO|REPLACE\s+INTO|DELETE\s+FROM)\s+`?' . preg_quote( $table, '/' ) . '`?\s/i', $statement ) ) { if ( null !== $expected_sql && ! in_array( $sql, $expected_sql, true ) ) { $changed = true; return ''; } $checking = true; try { $valid = self::protected_read( $scope, $db, $origin, static fn() => self::strict_config() === $expected ? true : new \WP_Error( 'stale', 'Alert settings changed in a native save hook.' ) ); } finally { $checking = false; } if ( is_wp_error( $valid ) || $changed || ! $current() ) { $changed = true; return ''; // Native wpdb rejects an empty filtered query before execution. } break; } } } return $sql; }; add_filter( 'query', $observe, PHP_INT_MAX ); try { $result = $write(); } catch ( \Throwable $error ) { $result = new \WP_Error( 'storage', 'Alert write was interrupted.' ); } finally { remove_filter( 'query', $observe, PHP_INT_MAX ); } return $changed || ! $current() ? new \WP_Error( 'forbidden', 'Alert save authority, SQL or context changed; inspect current settings before retrying.' ) : $result; } /** * Observe one alert-mutex statement and bind it to its original connection key. * * Bind named-lock SQL to its captured connection and key across native filters. * * @param \wpdb $db Original database connection bound to the operation. * @param array $origin Native context captured before callback or query boundaries. * @param string $key Original installation mutex key. * @param bool $release Whether to release this invocation's lease instead of acquiring it. * * @return array Observed scalar result and a statement/context-change flag. */ private static function lock_statement( \wpdb $db, array $origin, string $key, bool $release ): array { $expected = $db->remove_placeholder_escape( $release ? $db->prepare( 'SELECT RELEASE_LOCK(%s)', $key ) : $db->prepare( 'SELECT GET_LOCK(%s,1)', $key ) ); $changed = false; $observe = static function ( string $sql ) use ( $db, $origin, $expected, $release, &$changed ): string { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_debug_backtrace -- Inspect at most 12 live frames to bind the original mutex statement to its owning connection and reject callback rewrites; no trace is emitted or persisted. $trace = debug_backtrace( DEBUG_BACKTRACE_PROVIDE_OBJECT, 12 ); foreach ( $trace as $frame ) { if ( 'query' !== $frame['function'] || ! ( ( $frame['object'] ?? null ) instanceof \wpdb ) ) { continue; } $original = $frame['args'][0] ?? null; if ( $frame['object'] === $db && is_string( $original ) && $db->remove_placeholder_escape( $original ) === $expected ) { if ( $sql !== $expected || ( ! $release && self::native_context( $db ) !== $origin ) ) { $changed = true; // Cleanup releases only the original key even after context drift, // but a rewritten statement can never verify public success. return $release ? $expected : ''; } } break; } return $sql; }; add_filter( 'query', $observe, PHP_INT_MAX ); try { if ( $release ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Release the original connection's mutex; a cached result cannot release a lease. $queried = $db->query( $db->prepare( 'SELECT RELEASE_LOCK(%s)', $key ) ); } else { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Acquire the original connection's mutex; a cached result cannot establish a lease. $queried = $db->query( $db->prepare( 'SELECT GET_LOCK(%s,1)', $key ) ); } // An empty query reads only the preceding result; wpdb::get_var() executes SQL only for a nonempty query. $value = 1 === $queried ? $db->get_var( '' ) : null; } catch ( \Throwable $error ) { $value = null; $changed = true; } finally { remove_filter( 'query', $observe, PHP_INT_MAX ); } return array( 'value' => $value, 'changed' => $changed, ); } /** * Run alert work under one installation mutex with verified lease release. * * @param callable $work Operation to run within the selected mode or lock boundary. * * @return mixed Operation result, or a lock, release or context error. */ private static function locked( callable $work ): mixed { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ $db = $wpdb; $origin = self::native_context( $db ); // One installation critical section; never changes caller transaction ownership. $key = 'lai_alerts_' . substr( hash( 'sha256', DB_NAME . ':' . $db->base_prefix ), 0, 32 ); $acquired = self::lock_statement( $db, $origin, $key, false ); if ( '1' !== (string) $acquired['value'] || $acquired['changed'] ) { if ( '1' === (string) $acquired['value'] ) { self::lock_statement( $db, $origin, $key, true ); } return new \WP_Error( 'busy', 'Another alert operation is running.' ); } try { $result = self::native_context( $db ) === $origin ? $work() : new \WP_Error( 'scope', 'Alert lock context changed.' ); } finally { // A callback may replace global wpdb; only the acquiring connection owns this lock. $release = self::lock_statement( $db, $origin, $key, true ); $released = '1' === (string) $release['value'] && ! $release['changed']; } if ( ! $released ) { return new \WP_Error( 'storage', 'Alert lock release could not be verified.' ); } return self::native_context( $db ) === $origin ? $result : new \WP_Error( 'scope', 'Alert operation context changed.' ); } /** * Install and verify alert storage before scheduling the recurring worker. * * @return void * * @throws \RuntimeException When the alert mutex or required storage cannot be verified. */ public static function activate(): void { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ require_once ABSPATH . 'wp-admin/includes/upgrade.php'; $result = self::locked( static function () use ( $wpdb ) { $deliveries = self::table(); $cooldowns = self::table( 'alert_cooldowns' ); dbDelta( "CREATE TABLE $deliveries ( id char(32) NOT NULL, logical_key char(64) NOT NULL, network_id bigint unsigned NOT NULL, site_id bigint unsigned NOT NULL, event_uid char(36) NULL, event_id bigint unsigned NOT NULL DEFAULT 0, fact_kind varchar(12) NOT NULL DEFAULT 'event', rule_key varchar(32) NOT NULL, config_revision bigint unsigned NOT NULL, recipient_id bigint unsigned NOT NULL, state varchar(24) NOT NULL, synthetic tinyint unsigned NOT NULL DEFAULT 0, attempts smallint unsigned NOT NULL DEFAULT 0, next_attempt bigint unsigned NOT NULL, created_at bigint unsigned NOT NULL, expires_at bigint unsigned NOT NULL, attempted_at bigint unsigned NOT NULL DEFAULT 0, PRIMARY KEY (id), UNIQUE KEY logical_key (logical_key), KEY scope_state (network_id,site_id,state,next_attempt), KEY state_queue (state,next_attempt), KEY scope_event (network_id,site_id,event_uid), KEY kind_state (fact_kind,state), KEY scope_kind_state (network_id,site_id,fact_kind,state,next_attempt) ) ENGINE=InnoDB " . $wpdb->get_charset_collate() . ';' ); dbDelta( "CREATE TABLE $cooldowns ( id char(64) NOT NULL, network_id bigint unsigned NOT NULL, site_id bigint unsigned NOT NULL, last_job char(32) NOT NULL, eligible_at bigint unsigned NOT NULL, PRIMARY KEY (id), KEY scope_expiry (network_id,site_id,eligible_at) ) ENGINE=InnoDB " . $wpdb->get_charset_collate() . ';' ); foreach ( array( $deliveries => array( 'id', 'logical_key', 'network_id', 'site_id', 'event_uid', 'event_id', 'fact_kind', 'rule_key', 'config_revision', 'recipient_id', 'state', 'synthetic', 'attempts', 'next_attempt', 'created_at', 'expires_at', 'attempted_at' ), $cooldowns => array( 'id', 'network_id', 'site_id', 'last_job', 'eligible_at' ), ) as $table => $expected ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Verify actual alert-table engine after dbDelta; cached metadata cannot prove readiness. $engine = $wpdb->get_var( $wpdb->prepare( 'SELECT ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=%s', $table ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Verify actual alert columns after dbDelta; cached metadata cannot prove readiness. SHOW identifier comes only from the two fixed native-prefix delivery/cooldown names in the local loop. $columns = $wpdb->get_col( $wpdb->prepare( 'SHOW COLUMNS FROM %i', $table ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Verify actual alert uniqueness indexes; cached declarations cannot prove deduplication storage. SHOW INDEX uses the same two fixed native-prefix identifiers; returned primary/logical-key ordered columns are checked. $indexes = $wpdb->get_results( $wpdb->prepare( 'SHOW INDEX FROM %i', $table ), ARRAY_A ); $unique = array(); foreach ( $indexes as $index ) { if ( 0 === (int) $index['Non_unique'] ) { $unique[ $index['Key_name'] ][ (int) $index['Seq_in_index'] ] = $index['Column_name']; } } if ( 'InnoDB' !== $engine || array_diff( $expected, $columns ) || ( $unique['PRIMARY'] ?? array() ) !== array( 1 => 'id' ) || ( $table === $deliveries && ( $unique['logical_key'] ?? array() ) !== array( 1 => 'logical_key' ) ) ) { return new \WP_Error( 'schema', 'Alert schema could not be verified.' ); } } return true; } ); if ( is_wp_error( $result ) ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- locked() supplies fixed plugin error text here; this exception is not an HTML render. throw new \RuntimeException( $result->get_error_message() ); } if ( ! wp_next_scheduled( 'logged_ai_alert_worker' ) ) { wp_schedule_event( time() + 60, 'hourly', 'logged_ai_alert_worker' ); } } /** * Admit bounded recipient jobs for pending facts within the worker deadline. * * Repeated selection of pending facts includes late commits; no high-water ID cursor. * * @param float $deadline Absolute microtime deadline for admitting new alert jobs. * * @return int|\WP_Error Number of new jobs, or an error when evidence, capacity or context is unavailable. */ private static function enqueue( float $deadline ): int|\WP_Error { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ $bound_blog = get_current_blog_id(); $bound_network = Scope::network(); $bound_mode = self::$network_mode; $context = static fn() => self::same_context( $bound_blog, $bound_network, $bound_mode ); $changed = static fn() => new \WP_Error( 'scope', 'Alert admission context changed.' ); $config = self::strict_config(); if ( is_wp_error( $config ) ) { return $config; } if ( ! $context() ) { return $changed(); } $network = $bound_network; $site = $bound_mode ? 0 : $bound_blog; $events = Store::table(); $jobs = self::table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Read current pending facts under the alert mutex; cached selection could miss or replay work. Fixed event table; network/site %d; literal pending state, ORDER BY id and LIMIT 100. $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i WHERE network_id=%d AND site_id=%d AND alert_state=\'pending\' ORDER BY id LIMIT 100', $events, $network, $site ), ARRAY_A ); if ( '' !== $wpdb->last_error ) { return new \WP_Error( 'storage', 'Pending alert facts could not be read.' ); } if ( ! $bound_mode ) { $request_rows = RequestsAlerts::pending( $network, $site ); if ( is_wp_error( $request_rows ) ) { return $request_rows; } $rows = array_merge( $rows, $request_rows ); } $created = 0; $counts = array(); foreach ( array( 'pending', 'local_pending', 'total', 'local_total' ) as $key ) { $count = match ( $key ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Read live queue capacity under the alert mutex; cached totals cannot enforce admission limits. 'pending' => $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE state IN ('queued','retry_scheduled','attempting')", $jobs ) ), // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Read live scoped queue capacity before admitting another job. 'local_pending' => $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE network_id=%d AND site_id=%d AND state IN ('queued','retry_scheduled','attempting')", $jobs, $network, $site ) ), // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Read actual retained history to enforce the shared storage bound. 'total' => $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i', $jobs ) ), // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Read actual scoped history to enforce its storage bound. 'local_total' => $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE network_id=%d AND site_id=%d', $jobs, $network, $site ) ), }; if ( null === $count || '' !== $wpdb->last_error ) { return new \WP_Error( 'storage', 'Alert queue capacity is unavailable.' ); } $counts[ $key ] = (int) $count; } if ( ! $bound_mode ) { foreach ( array( 'request_pending', 'request_local_pending', 'request_total', 'request_local_total' ) as $key ) { $count = match ( $key ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Read live request queue capacity under the mutex before admitting another job. 'request_pending' => $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE fact_kind='request' AND state IN ('queued','retry_scheduled','attempting')", $jobs ) ), // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Read live scoped request capacity; cached counts cannot enforce admission limits. 'request_local_pending' => $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE network_id=%d AND site_id=%d AND fact_kind='request' AND state IN ('queued','retry_scheduled','attempting')", $jobs, $network, $site ) ), // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Read actual retained request history to enforce its shared storage bound. 'request_total' => $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE fact_kind='request'", $jobs ) ), // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Read actual scoped request history to enforce its storage bound. 'request_local_total' => $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE network_id=%d AND site_id=%d AND fact_kind='request'", $jobs, $network, $site ) ), }; if ( null === $count || '' !== $wpdb->last_error ) { return new \WP_Error( 'storage', 'Request alert capacity is unavailable.' ); } $counts[ $key ] = (int) $count; } // Independent request-history bounds preserve room for administrative work. if ( $counts['request_local_total'] >= 900 || $counts['request_total'] >= 4900 ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Delete bounded terminal request history from custom storage before admitting more jobs. Fixed delivery table; network/site %d; literal request kind and terminal-state exclusion; deterministic oldest-first LIMIT 500. Affected-row count adjusts current capacity. $removed = $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE network_id=%d AND site_id=%d AND fact_kind=\'request\' AND state NOT IN (\'queued\',\'retry_scheduled\',\'attempting\') ORDER BY created_at,id LIMIT 500', $jobs, $network, $site ) ); if ( false === $removed ) { return new \WP_Error( 'storage', 'Request delivery history cleanup failed.' ); } foreach ( array( 'request_total', 'request_local_total', 'total', 'local_total' ) as $key ) { $counts[ $key ] -= $removed; } } } if ( ! $context() ) { return $changed(); } if ( $counts['local_total'] >= 9500 || $counts['total'] >= 49500 ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Delete bounded terminal delivery history from custom storage before admitting more jobs. Fixed delivery table; network/site %d; literal active-state exclusion and oldest-first LIMIT 500. Native query result supplies actual removed count. $removed = $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE network_id=%d AND site_id=%d AND state NOT IN (\'queued\',\'retry_scheduled\',\'attempting\') ORDER BY created_at,id LIMIT 500', $jobs, $network, $site ) ); if ( false === $removed ) { return new \WP_Error( 'storage', 'Bounded delivery-history cleanup failed.' ); } $counts['total'] -= $removed; $counts['local_total'] -= $removed; } foreach ( $rows as $event ) { if ( ! $context() ) { return $changed(); } if ( microtime( true ) >= $deadline ) { break; } $rule = (int) $event['alert_revision'] === $config['revision'] ? self::match( $event, $config ) : null; if ( ! $rule || strtotime( $event['ingested_at'] . ' UTC' ) < time() - DAY_IN_SECONDS ) { if ( ! self::mark_fact( $event, 'skipped', $network, $site ) ) { return new \WP_Error( 'storage', 'Alert processing could not advance.' ); } continue; } // Defer new admission while still allowing the worker to drain ready jobs. $needed = count( $config['recipients'] ); $request_fact = 'request' === ( $event['fact_kind'] ?? 'event' ); if ( $request_fact && ( $counts['request_pending'] + $needed > 500 || $counts['request_local_pending'] + $needed > 100 || $counts['request_total'] + $needed > 5000 || $counts['request_local_total'] + $needed > 1000 ) ) { return $created; } if ( $counts['pending'] + $needed > 10000 || $counts['local_pending'] + $needed > 1000 || $counts['total'] + $needed > 50000 || $counts['local_total'] + $needed > 10000 ) { return $created; } $payload = json_decode( $event['payload'], true ); foreach ( $config['recipients'] as $recipient ) { if ( ! $context() ) { return $changed(); } $key = hash( 'sha256', implode( ':', array( $network, $site, $event['uid'], $rule, $config['revision'], 'email', $recipient ) ) ); $row = array( 'id' => bin2hex( random_bytes( 16 ) ), 'logical_key' => $key, 'network_id' => $network, 'site_id' => $site, 'event_uid' => $event['uid'], 'event_id' => (int) $event['id'], 'fact_kind' => $request_fact ? 'request' : 'event', 'rule_key' => $rule, 'config_revision' => $config['revision'], 'recipient_id' => $recipient, 'state' => 'queued', 'synthetic' => 'synthetic_test' === ( $payload['source'] ?? '' ) ? 1 : 0, 'next_attempt' => time(), 'created_at' => time(), 'expires_at' => time() + DAY_IN_SECONDS, ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Check current durable logical-job identity under the mutex; cached absence cannot deduplicate. Fixed delivery table and logical_key=%s. Key is a SHA-256 digest of network/site/event UID/rule/config revision/channel/recipient; unique database index remains decisive. $exists = $wpdb->get_var( $wpdb->prepare( 'SELECT id FROM %i WHERE logical_key=%s', $jobs, $key ) ); if ( ! $context() ) { return $changed(); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Persist the deduplicated recipient job in custom storage before advancing its source fact. if ( '' !== $wpdb->last_error || ( null === $exists && 1 !== $wpdb->insert( $jobs, $row ) ) ) { return new \WP_Error( 'storage', 'Alert job could not be saved.' ); } if ( null === $exists ) { ++$created; foreach ( array( 'pending', 'local_pending', 'total', 'local_total' ) as $key ) { ++$counts[ $key ]; } if ( $request_fact ) { foreach ( array( 'request_pending', 'request_local_pending', 'request_total', 'request_local_total' ) as $key ) { ++$counts[ $key ]; } } } } if ( ! self::mark_fact( $event, 'processed', $network, $site ) ) { return new \WP_Error( 'storage', 'Alert processing could not advance.' ); } } return $created; } /** * Process alert work on an independent connection and restore the caller context. * * @return array|\WP_Error Worker counts and status, or an explicit connection, context or processing error. */ public static function worker(): array|\WP_Error { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ $original = $wpdb; $original_context = Derived::native_context( $original ); $original_blog = get_current_blog_id(); $original_network = Scope::network(); $mode = self::$network_mode; $original_stack = $GLOBALS['_wp_switched_stack'] ?? array(); $original_switched = $GLOBALS['switched'] ?? false; $original_roles = $GLOBALS['wp_roles'] ?? null; // A fresh core wpdb connection gives both MySQL and MariaDB a separate // autocommit session. No COMMIT/ROLLBACK ever touches a caller's session. if ( 'wpdb' !== get_class( $original ) ) { return new \WP_Error( 'database_adapter', 'This database drop-in needs a verified independent worker connection adapter.' ); } $connection = null; try { $connection = new WorkerConnection( DB_USER, DB_PASSWORD, DB_NAME, DB_HOST ); if ( ! $connection->ready ) { return new \WP_Error( 'storage', 'An independent alert worker connection is unavailable.' ); } $connection->set_prefix( $original->base_prefix ); $connection->set_blog_id( get_current_blog_id(), Scope::network() ); $connection->suppress_errors( true ); if ( false === $connection->query( 'SET SESSION autocommit=1' ) || false === $connection->query( 'SET SESSION innodb_lock_wait_timeout=2' ) ) { return new \WP_Error( 'storage', 'Independent worker transaction settings could not be established.' ); } if ( Derived::native_context( $original ) !== $original_context ) { return new \WP_Error( 'scope', 'Alert connection initialization changed its original context.' ); } // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Temporarily route alert work through its independently configured connection; worker finally restores the original connection and context. $wpdb = $connection; if ( ! self::same_context( $original_blog, $original_network, $mode ) ) { return new \WP_Error( 'scope', 'Alert connection initialization changed its scope.' ); } $result = self::worker_connection(); if ( ! self::same_context( $original_blog, $original_network, $mode ) ) { return new \WP_Error( 'scope', 'Alert worker context changed; no other scope may authorize this delivery.' ); } $scheduled = self::reschedule(); if ( ! is_wp_error( $result ) && is_wp_error( $scheduled ) ) { $result = $scheduled; } if ( self::same_context( $original_blog, $original_network, $mode ) && class_exists( Health::class ) ) { Health::record( self::$network_mode ? 'network_alerts' : 'alerts', $result ); } return $result; } catch ( \Throwable $error ) { if ( self::same_context( $original_blog, $original_network, $mode ) ) { Store::degraded( 'alert_failed' ); } return new \WP_Error( 'worker', 'Alert processing was interrupted. Inspect delivery state before retrying; unknown sending attempts are not automatically repeated.' ); } finally { // Initialization callbacks can switch the caller before the private // connection becomes global. Restore native tables on that caller. // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restore the captured caller connection before native site restoration callbacks can run in worker finally. $wpdb = $original; $restore_failed = false; try { if ( get_current_blog_id() !== $original_blog ) { switch_to_blog( $original_blog ); } } catch ( \Throwable $error ) { $restore_failed = true; } finally { // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Reassert the captured caller connection even when a site-restoration callback throws or replaces it. $wpdb = $original; // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound -- Core-owned WordPress globals must retain their native names when restoring the captured caller context. $GLOBALS['_wp_switched_stack'] = $original_stack; // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound -- Core-owned WordPress globals must retain their native names when restoring the captured caller context. $GLOBALS['switched'] = $original_switched; if ( $original_roles ) { // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restore the captured native roles after returning from the alert worker's temporary connection and site context. $GLOBALS['wp_roles'] = $original_roles; } // Native switching may represent the same positive ID as an int. // Preserve its original type only after every other field matches. $restored = Derived::native_context( $original ); foreach ( array( 1, 'blogid' ) as $field ) { $canonical = array( $original_blog, (string) $original_blog ); if ( $original_blog < 1 || ! in_array( $original_context[ $field ], $canonical, true ) || ! in_array( $restored[ $field ], $canonical, true ) ) { $restored = array(); break; } $restored[ $field ] = $original_context[ $field ]; } if ( $restored === $original_context ) { // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restore the original blog identifier representation only after every other captured context field matches; restoration failure is reported. $GLOBALS['blog_id'] = $original_context[1]; $original->blogid = $original_context['blogid']; } if ( $connection ) { $connection->close(); } } if ( $restore_failed ) { return new \WP_Error( 'worker_restore', 'Alert context restoration was interrupted. Inspect saved delivery state before retrying.' ); } } } /** * Schedule remaining fact, delivery or privacy work when another batch is needed. * * @return bool|\WP_Error True when scheduling is satisfied, or a storage or scheduling error. */ private static function reschedule(): bool|\WP_Error { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Fixed native-prefix event table; current network/site use bound placeholders. Check actual pending event work before rescheduling; a cached negative could strand it. $pending = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM %i WHERE network_id=%d AND site_id=%d AND alert_state='pending' LIMIT 1", Store::table(), Scope::network(), self::site() ) ); if ( '' !== $wpdb->last_error ) { return new \WP_Error( 'storage', 'Remaining alert facts could not be checked.' ); } if ( ! self::$network_mode && RequestsAlerts::ready() ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Fixed allowlisted request table; current network/site use bound placeholders. Check actual pending request work before rescheduling; a cached negative could strand it. $requests_pending = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM %i WHERE network_id=%d AND site_id=%d AND alert_state='pending' LIMIT 1", Requests::table( 'requests' ), Scope::network(), self::site() ) ); if ( '' !== $wpdb->last_error ) { return new \WP_Error( 'storage', 'Remaining request alerts could not be checked.' ); } $pending = $pending || $requests_pending; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Fixed allowlisted delivery table; current network/site use bound placeholders. Read the actual next delivery time; cached queue state could delay required work. $next = $wpdb->get_var( $wpdb->prepare( "SELECT MIN(next_attempt) FROM %i WHERE network_id=%d AND site_id=%d AND state IN ('queued','retry_scheduled')", self::table(), Scope::network(), self::site() ) ); if ( '' !== $wpdb->last_error ) { return new \WP_Error( 'storage', 'Remaining alert deliveries could not be checked.' ); } if ( ( $pending || null !== $next || self::option( 'logged_ai_alert_cleanup_pending', false ) ) && ! wp_next_scheduled( self::hook() ) ) { $scheduled = wp_schedule_single_event( $pending ? time() + 60 : max( time() + 60, (int) $next ), self::hook(), array(), true ); if ( is_wp_error( $scheduled ) || ! $scheduled ) { return new \WP_Error( 'storage', 'Alert work returned, but the next batch could not be scheduled.' ); } } return true; } /** * Compare the current site, network and alert mode with the captured values. * * @phpstan-impure Reads current native authority or mutable context. * * @param int $blog Captured native site identifier. * @param int $network Selected network identifier. * @param bool $mode Captured network-alert mode. * * @return bool Whether all selected context values still match. */ private static function same_context( int $blog, int $network, bool $mode ): bool { return get_current_blog_id() === $blog && Scope::network() === $network && get_current_blog_id() === $blog && self::$network_mode === $mode; } /** * Count enclosing native mail calls without collecting message arguments. * * Internal shared guard: nested mail from other extensions must not inherit * the recipient, message or failure state of this plugin's owning attempt. * * @return int Current global WordPress mail-call nesting depth. */ public static function mail_depth(): int { $depth = 0; // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_debug_backtrace -- Count live mail-call frames with arguments excluded to scope temporary authorization guards; no trace or message data is emitted or persisted. foreach ( debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS ) as $frame ) { if ( 'wp_mail' === strtolower( $frame['function'] ?? '' ) && empty( $frame['class'] ) ) { ++$depth; } } return $depth; } /** * Keep a reserved alert bound to its current recipient, evidence and native context through mail callbacks. * * @param \wpdb $db Dedicated native worker connection holding the alert mutex. * @param array $job Exact queued job whose durable attempt was just reserved. * @param array $event Current supporting fact used to compose the minimal notice. * @param string $email Authorized account email captured before the mail call. * @param string $subject Exact minimal subject. * @param string $body Exact minimal plain-text notice. * * @return bool Whether the unmodified authorized notice was accepted by transport. * @throws \RuntimeException When authority or content changes; the durable attempt remains non-retryable. */ private static function guarded_mail( \wpdb $db, array $job, array $event, string $email, string $subject, string $body ): bool { $origin = self::native_context( $db ); $network = Scope::network(); $site = self::site(); $table = self::table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Capture the actual committed attempt under the worker mutex, using its exact bound scope and identity. $attempt = $db->get_row( $db->prepare( 'SELECT * FROM %i WHERE network_id=%d AND site_id=%d AND id=%s', $table, $network, $site, $job['id'] ), ARRAY_A ); if ( '' !== $db->last_error || ! $attempt || 'attempting' !== $attempt['state'] || (int) $attempt['attempts'] !== (int) $job['attempts'] + 1 || self::native_context( $db ) !== $origin ) { throw new \RuntimeException( 'Alert attempt changed before mail preparation.' ); } $verify = static function () use ( $db, $origin, $network, $site, $table, $attempt, $event, $email ): bool { if ( self::native_context( $db ) !== $origin ) { return false; } $changed = false; $observe = static function ( string $sql ) use ( &$changed ): string { if ( ! preg_match( '/\A(?:SELECT|SHOW|DESCRIBE|EXPLAIN)\s/i', trim( $sql ) ) ) { $changed = true; } return $sql; }; add_filter( 'query', $observe, PHP_INT_MAX ); try { $recipient = self::recipient( (int) $attempt['recipient_id'] ); $config = self::strict_config(); $fact = self::load_fact( $attempt, $network, $site ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Re-read the exact durable attempt after current recipient and rule callbacks, preventing cancelled or erased work from sending. $fresh = $db->get_row( $db->prepare( 'SELECT * FROM %i WHERE network_id=%d AND site_id=%d AND id=%s', $table, $network, $site, $attempt['id'] ), ARRAY_A ); $valid = '' === $db->last_error && $fresh === $attempt && (int) $attempt['expires_at'] > time() && $recipient && $recipient->user_email === $email && ! is_wp_error( $config ) && $config['revision'] === (int) $attempt['config_revision'] && in_array( (int) $attempt['recipient_id'], $config['recipients'], true ) && ! is_wp_error( $fact ) && $fact === $event && self::match( $fact, $config ) === $attempt['rule_key']; } finally { remove_filter( 'query', $observe, PHP_INT_MAX ); } return $valid && ! $changed && self::native_context( $db ) === $origin; }; $denied = false; $depth = self::mail_depth() + 1; $sending = false; $gate = static function ( mixed $pre, array $attributes ) use ( $email, $subject, $body, $verify, &$denied, $depth, &$sending ): mixed { if ( ! $sending || self::mail_depth() !== $depth ) { return $pre; } if ( ( $attributes['to'] ?? null ) !== $email || ( $attributes['subject'] ?? null ) !== $subject || ( $attributes['message'] ?? null ) !== $body || ( $attributes['headers'] ?? null ) !== '' || ! empty( $attributes['attachments'] ) || ! empty( $attributes['embeds'] ) || ! $verify() ) { $denied = true; return false; } return $pre; }; $last_gate = static function ( mixed $mailer ) use ( $email, $subject, $body, $verify, &$denied, $depth, &$sending ): void { if ( ! $sending || self::mail_depth() !== $depth ) { return; } // Authorization callbacks can send nested mail and change the shared native mailer. // Check its identity and message only after those callbacks return. $valid = $verify() && is_object( $mailer ) && $mailer === ( $GLOBALS['phpmailer'] ?? null ); foreach ( array( 'getToAddresses', 'getCcAddresses', 'getBccAddresses', 'getAttachments', 'getCustomHeaders' ) as $method ) { $valid = $valid && is_callable( array( $mailer, $method ) ); } // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Inspect exact native PHPMailer fields at the final message callback, while allowing SMTP transport configuration. if ( ! $valid || $mailer->getToAddresses() !== array( array( $email, '' ) ) || $mailer->getCcAddresses() || $mailer->getBccAddresses() || $mailer->getAttachments() || $mailer->getCustomHeaders() || $mailer->Subject !== $subject || $mailer->Body !== $body || 'text/plain' !== $mailer->ContentType ) { $denied = true; throw new \RuntimeException( 'Alert mail authority or minimal message changed.' ); } }; add_filter( 'pre_wp_mail', $gate, PHP_INT_MAX, 2 ); add_action( 'phpmailer_init', $last_gate, PHP_INT_MAX, 1 ); try { if ( ! $verify() ) { throw new \RuntimeException( 'Alert authority changed before mail execution.' ); } $sending = true; $accepted = true === wp_mail( $email, $subject, $body ); if ( $denied ) { throw new \RuntimeException( 'Alert mail authority or minimal message changed.' ); } return $accepted; } finally { $sending = false; remove_filter( 'pre_wp_mail', $gate, PHP_INT_MAX ); remove_action( 'phpmailer_init', $last_gate, PHP_INT_MAX ); } } /** * Recover delivery states and attempt bounded mail work after fresh eligibility checks. * * @return array|\WP_Error Enqueued/attempted counts or cleanup status; mail receipt remains unestablished. */ private static function worker_connection(): array|\WP_Error { return self::locked( static function () { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ $started = microtime( true ); $bound_blog = get_current_blog_id(); $bound_network = Scope::network(); $bound_mode = self::$network_mode; $context = static fn() => self::same_context( $bound_blog, $bound_network, $bound_mode ); $context_error = static fn() => new \WP_Error( 'scope', 'Alert worker context changed; delivery was stopped.' ); $network = Scope::network(); $site = self::site(); $jobs = self::table(); $cooldowns = self::table( 'alert_cooldowns' ); $config = self::strict_config(); if ( is_wp_error( $config ) ) { return $config; } if ( ! $context() ) { return $context_error(); } $config_revision = $config['revision']; // Capture every cutoff before the first recovery query, preserving one recovery batch's bounds. $history_cutoff = time() - 30 * DAY_IN_SECONDS; $cooldown_cutoff = time(); $expiry_cutoff = time(); foreach ( array( 'history', 'cooldowns', 'attempts', 'expired' ) as $recovery_step ) { $recovered = match ( $recovery_step ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Persist bounded history recovery on the worker connection; cached state cannot remove stale jobs. 'history' => $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE network_id=%d AND site_id=%d AND (config_revision<>%d OR created_at<%d) LIMIT 500', $jobs, $network, $site, $config_revision, $history_cutoff ) ), // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Remove at most 500 expired scoped cooldown rows from current storage. 'cooldowns' => $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE network_id=%d AND site_id=%d AND eligible_at<%d LIMIT 500', $cooldowns, $network, $site, $cooldown_cutoff ) ), // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Persist the unknown outcome of interrupted attempts before selecting due work. 'attempts' => $wpdb->query( $wpdb->prepare( "UPDATE %i SET state='delivery_unknown' WHERE network_id=%d AND site_id=%d AND state='attempting'", $jobs, $network, $site ) ), // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Persist expiration before selecting current due jobs. 'expired' => $wpdb->query( $wpdb->prepare( "UPDATE %i SET state='expired' WHERE network_id=%d AND site_id=%d AND state IN ('queued','retry_scheduled') AND expires_at<=%d", $jobs, $network, $site, $expiry_cutoff ) ), }; if ( false === $recovered ) { return new \WP_Error( 'storage', 'Alert recovery could not complete.' ); } } if ( self::option( 'logged_ai_alert_cleanup_pending', false ) ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Erase actual scoped cooldown rows before declaring privacy cleanup complete. Fixed cooldown table; network/site %d and literal LIMIT 500; false query result stops recovery. if ( false === $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE network_id=%d AND site_id=%d LIMIT 500', $cooldowns, $network, $site ) ) ) { return new \WP_Error( 'storage', 'Alert privacy cleanup is pending.' ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Check actual old-revision jobs; cached totals cannot prove privacy cleanup completion. Fixed delivery table; network/site/config revision all %d; counts jobs whose revision differs from strict current config. $old_jobs = $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE network_id=%d AND site_id=%d AND config_revision<>%d', $jobs, $network, $site, $config_revision ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Check actual scoped cooldowns; cached totals cannot prove privacy cleanup completion. Fixed cooldown table and prepared network/site %d; combined with the actual old-job count before clearing pending cleanup. $old_cooldowns = $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE network_id=%d AND site_id=%d', $cooldowns, $network, $site ) ); if ( null === $old_jobs || null === $old_cooldowns || '' !== $wpdb->last_error ) { return new \WP_Error( 'storage', 'Alert privacy cleanup status is unavailable.' ); } if ( (int) $old_jobs + (int) $old_cooldowns > 0 ) { self::schedule(); return array( 'enqueued' => 0, 'attempted' => 0, 'privacy_cleanup_pending' => true, ); } self::remove_option( 'logged_ai_alert_cleanup_pending' ); } // Reserve most of the quantum for draining existing deliveries. if ( ! $context() ) { return $context_error(); } $enqueued = self::enqueue( $started + 3 ); if ( is_wp_error( $enqueued ) ) { return $enqueued; } if ( ! $context() ) { return $context_error(); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Select current due delivery jobs under the mutex; cached queue pages could replay attempts. Fixed delivery table; network/site and due time %d; literal queued/retry states, finite sort and LIMIT 10. $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i WHERE network_id=%d AND site_id=%d AND state IN (\'queued\',\'retry_scheduled\') AND next_attempt<=%d ORDER BY fact_kind,next_attempt,id LIMIT 10', $jobs, $network, $site, time() ), ARRAY_A ); if ( '' !== $wpdb->last_error ) { return new \WP_Error( 'storage', 'Alert queue could not be read.' ); } $attempted = 0; foreach ( $rows as $job ) { if ( microtime( true ) - $started > 10 ) { break; } if ( ! $context() ) { return $context_error(); } $config = self::strict_config(); if ( is_wp_error( $config ) ) { return $config; } if ( ! $context() ) { return $context_error(); } $recipient = self::recipient( (int) $job['recipient_id'] ); if ( ! $context() ) { return $context_error(); } $event = self::load_fact( $job, $network, $site ); if ( is_wp_error( $event ) ) { return $event; } if ( ! $context() ) { return $context_error(); } if ( ! $recipient || (int) $job['config_revision'] !== $config['revision'] || ! in_array( (int) $job['recipient_id'], $config['recipients'], true ) || ! $event || self::match( $event, $config ) !== $job['rule_key'] ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Persist cancellation after current recipient, rule, revision or evidence checks fail. if ( false === $wpdb->update( $jobs, array( 'state' => 'cancelled' ), array( 'id' => $job['id'] ) ) ) { return new \WP_Error( 'storage', 'Alert cancellation could not be saved.' ); } continue; } $cooldown_key = hash( 'sha256', implode( ':', array( $network, $site, $job['rule_key'], $job['config_revision'], $job['recipient_id'], $job['synthetic'] ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Read current cooldown ownership under the mutex; stale state could allow duplicate alerts. Fixed cooldown table; id=%s is SHA-256 of scope/rule/revision/recipient/synthetic flag. Current eligible_at and last_job determine suppression. $prior = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE id=%s', $cooldowns, $cooldown_key ), ARRAY_A ); if ( '' !== $wpdb->last_error ) { return new \WP_Error( 'storage', 'Alert cooldown is unavailable.' ); } if ( $prior && (int) $prior['eligible_at'] > time() && $prior['last_job'] !== $job['id'] ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Persist the current cooldown-suppressed result in the custom delivery row. if ( false === $wpdb->update( $jobs, array( 'state' => 'cooldown_suppressed' ), array( 'id' => $job['id'] ) ) ) { return new \WP_Error( 'storage', 'Alert cooldown result could not be saved.' ); } continue; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Durably reserve the scoped recipient cooldown before attempting transport. if ( false === $wpdb->replace( $cooldowns, array( 'id' => $cooldown_key, 'network_id' => $network, 'site_id' => $site, 'last_job' => $job['id'], 'eligible_at' => time() + $config['cooldown'], ) ) ) { return new \WP_Error( 'storage', 'Alert cooldown could not be reserved.' ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Durably mark the exact queued job attempting before calling mail transport. if ( 1 !== $wpdb->update( $jobs, array( 'state' => 'attempting', 'attempts' => (int) $job['attempts'] + 1, 'attempted_at' => time(), ), array( 'id' => $job['id'], 'state' => $job['state'], ) ) ) { return new \WP_Error( 'storage', 'Alert attempt could not be durably recorded.' ); } $title = self::rules()[ $job['rule_key'] ]; $subject = ( $job['synthetic'] ? '[Synthetic test] ' : '' ) . 'Logged.ai: ' . $title; $body = ( $job['synthetic'] ? "This is a labeled synthetic test.\n\n" : '' ) . $title . ".\nObserved event #" . (int) $job['event_id'] . ". This fact alone does not establish an attack.\nReview the authorized site evidence: " . ( self::$network_mode ? Scope::network_admin_url( 'admin.php?page=logged-ai-network&event=' . (int) $job['event_id'] ) : admin_url( 'admin.php?page=logged-ai&event=' . (int) $job['event_id'] ) ) . "\n"; if ( 'request' === $job['fact_kind'] ) { $body = ( $job['synthetic'] ? "This is a labeled synthetic test.\n\n" : '' ) . RequestsAlerts::message( $event ); } if ( ! $context() ) { return $context_error(); } $config = self::strict_config(); if ( ! $context() ) { return $context_error(); } if ( is_wp_error( $config ) ) { return $config; } $recipient = self::recipient( (int) $job['recipient_id'] ); if ( ! $context() ) { return $context_error(); } if ( ! $recipient || $config['revision'] !== (int) $job['config_revision'] ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Persist cancellation when fresh recipient or configuration checks revoke delivery. if ( false === $wpdb->update( $jobs, array( 'state' => 'cancelled' ), array( 'id' => $job['id'] ) ) ) { return new \WP_Error( 'storage', 'Alert cancellation could not be saved.' ); } continue; } $fresh_event = self::load_fact( $job, $network, $site ); if ( is_wp_error( $fresh_event ) ) { return $fresh_event; } if ( ! $context() ) { return $context_error(); } if ( ! $fresh_event || self::match( $fresh_event, $config ) !== $job['rule_key'] ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Persist cancellation when the final supporting-fact read no longer qualifies. if ( false === $wpdb->update( $jobs, array( 'state' => 'cancelled' ), array( 'id' => $job['id'] ) ) ) { return new \WP_Error( 'storage', 'Alert cancellation could not be saved.' ); } continue; } if ( 'request' === $job['fact_kind'] ) { $body = ( $job['synthetic'] ? "This is a labeled synthetic test.\n\n" : '' ) . RequestsAlerts::message( $fresh_event ); } if ( 'automatic_suppressed' === $job['rule_key'] ) { $facts = json_decode( $fresh_event['payload'], true )['facts']; $body = ( $job['synthetic'] ? "This is a labeled synthetic test.\n\n" : '' ) . "Logged.ai did not create a new automatic response policy.\nReason: " . str_replace( '_', ' ', $facts['reason'] ) . ".\nExisting policy expiry and manual controls are unchanged. No application effect or successful attack is established by this notice.\nReview automatic response coverage and recovery: " . admin_url( 'admin.php?page=logged-ai-automatic' ) . "\nReview the saved site fact: " . admin_url( 'admin.php?page=logged-ai&event=' . (int) $job['event_id'] ) . "\n"; } // The installation lock also serializes configuration changes. Fresh // recipient, event and version checks occur immediately before the call. if ( ! $context() ) { return $context_error(); } try { $accepted = self::guarded_mail( $wpdb, $job, $fresh_event, $recipient->user_email, $subject, $body ); } catch ( \Throwable $error ) { Store::degraded( 'alert_failed' ); return new \WP_Error( 'delivery_unknown', 'Mail execution was interrupted. The attempt will not automatically repeat.' ); } if ( ! $context() ) { return $context_error(); } $attempted++; $attempts = (int) $job['attempts'] + 1; $state = $accepted ? 'accepted_by_transport' : ( $attempts < 3 ? 'retry_scheduled' : 'permanently_failed' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Persist transport outcome against the durable attempting state; cache updates are insufficient. if ( false === $wpdb->update( $jobs, array( 'state' => $state, 'next_attempt' => time() + ( 1 === $attempts ? 60 : 300 ), ), array( 'id' => $job['id'], 'state' => 'attempting', ) ) ) { return new \WP_Error( 'storage', 'Mail result could not be saved; delivery is unknown and will not automatically repeat.' ); } } return array( 'enqueued' => $enqueued, 'attempted' => $attempted, 'receipt' => 'not_established', ); } ); } /** * Return recent delivery status through protected current-authority readback. * * @param Scope $scope Operation scope whose current permissions are checked. * * @return array|\WP_Error Bounded delivery rows with stale revision identities hidden, or an error. */ public static function listing( Scope $scope ): array|\WP_Error { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ $db = $wpdb; $origin = self::native_context( $db ); return self::protected_read( $scope, $db, $origin, static function () use ( $scope, $db, $origin ) { if ( ! $scope->valid_for_all( array( self::cap() ) ) || self::native_context( $db ) !== $origin ) { return new \WP_Error( 'forbidden', 'Alert management is unavailable.' ); } $rows = $db->get_results( $db->prepare( 'SELECT id,event_id,fact_kind,rule_key,config_revision,recipient_id,state,synthetic,attempts,created_at,attempted_at FROM %i WHERE network_id=%d AND site_id=%d ORDER BY created_at DESC,id DESC LIMIT 50', self::table(), $scope->network_id, $scope->site_id ), ARRAY_A ); if ( '' !== $db->last_error || self::native_context( $db ) !== $origin ) { return new \WP_Error( 'storage', 'Delivery status is unavailable.' ); } $config = self::strict_config(); if ( is_wp_error( $config ) ) { return $config; } $revision = $config['revision']; foreach ( $rows as &$row ) { if ( (int) $row['config_revision'] !== $revision ) { $row['event_id'] = 0; $row['recipient_id'] = 0; if ( in_array( $row['state'], array( 'queued', 'retry_scheduled' ), true ) ) { $row['state'] = 'cancelled'; } } } unset( $row ); return $rows; } ); } /** * Advance alert privacy revision and remove bounded derived delivery context. * * Explicit privacy reset: no old recipient/event IDs survive in derived copies. * * @param Scope $scope Operation scope whose current permissions are checked. * @param int|null $erase_user Optional user identifier to remove from alert recipients during invalidation. * * @return bool|\WP_Error True when derived delivery cleanup is complete, false when pending, or an error. */ public static function invalidate_site( Scope $scope, ?int $erase_user = null ): bool|\WP_Error { $privacy_cap = self::privacy_cap(); $purge_cap = 0 === $scope->site_id ? 'logged_ai_purge_network_data' : 'logged_ai_purge_data'; $allowed = static fn() => $scope->valid_for( $privacy_cap ) && ( null === $erase_user || $scope->valid_for( $purge_cap ) ) && Scope::network() === $scope->network_id && self::site() === $scope->site_id; if ( ! $allowed() ) { return new \WP_Error( 'forbidden', 'Privacy and applicable purge authority are required.' ); } return self::locked( static function () use ( $scope, $erase_user, $allowed ) { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ $config = self::strict_config(); if ( is_wp_error( $config ) ) { return $config; } if ( ! $allowed() ) { return new \WP_Error( 'forbidden', 'Privacy authority changed.' ); } if ( $config['revision'] >= 2147483646 ) { return new \WP_Error( 'config', 'Alert revision capacity was reached; privacy cleanup needs operator repair.' ); } $config['revision']++; if ( null !== $erase_user ) { $config['recipients'] = array_values( array_filter( $config['recipients'], static fn( $id ) => $id !== $erase_user ) ); } if ( ! self::put_option( 'logged_ai_alerts', $config ) ) { return new \WP_Error( 'storage', 'Alert privacy revision could not be saved.' ); } if ( Scope::network() !== $scope->network_id || self::site() !== $scope->site_id || self::strict_config() !== $config || ! $allowed() ) { return new \WP_Error( 'storage', 'Alert privacy revision could not be verified in its original scope.' ); } self::put_option( 'logged_ai_alert_cleanup_pending', true ); $remaining = 0; foreach ( array( self::table(), self::table( 'alert_cooldowns' ) ) as $table ) { if ( ! $allowed() ) { return new \WP_Error( 'forbidden', 'Privacy authority changed.' ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Erase bounded scoped delivery or cooldown rows; cache invalidation is not privacy erasure. Table comes only from the literal two-helper array; network/site %d and LIMIT 500. if ( false === $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE network_id=%d AND site_id=%d LIMIT 500', $table, $scope->network_id, $scope->site_id ) ) ) { return new \WP_Error( 'storage', 'Derived alert data could not be erased. Retry before removing identity links.' ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Count actual scoped rows after erasure; cached totals cannot prove completion. Same two fixed tables and prepared network/site as the immediately preceding bounded deletion. $count = $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE network_id=%d AND site_id=%d', $table, $scope->network_id, $scope->site_id ) ); if ( null === $count || '' !== $wpdb->last_error ) { return new \WP_Error( 'storage', 'Alert privacy cleanup status is unavailable.' ); } $remaining += (int) $count; } if ( ! $allowed() ) { return new \WP_Error( 'forbidden', 'Privacy authority changed.' ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Fixed native-prefix event table; exact privacy network/site use bound placeholders. Persist cancellation of pending event alerts after privacy revision changes. if ( false === $wpdb->query( $wpdb->prepare( "UPDATE %i SET alert_state='skipped' WHERE network_id=%d AND site_id=%d AND alert_state='pending'", Store::table(), $scope->network_id, $scope->site_id ) ) ) { return new \WP_Error( 'storage', 'Pending alert processing could not be cancelled.' ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Fixed allowlisted request table; exact privacy network/site use bound placeholders. Persist bounded cancellation of pending request alerts after privacy revision changes. if ( ! self::$network_mode && RequestsAlerts::ready() && false === $wpdb->query( $wpdb->prepare( "UPDATE %i SET alert_state='skipped' WHERE network_id=%d AND site_id=%d AND alert_state='pending' LIMIT 500", Requests::table( 'requests' ), $scope->network_id, $scope->site_id ) ) ) { return new \WP_Error( 'storage', 'Pending request alerts could not be cancelled.' ); } if ( $remaining ) { self::schedule(); } else { self::remove_option( 'logged_ai_alert_cleanup_pending' ); } return 0 === $remaining; } ); } /** * Select bounded request-linked delivery cleanup by native request identifiers. * * Internal request lifecycle adapters: erase links before their identifying facts. * * @param int $network Selected network identifier. * @param int $site Selected site identifier, with zero representing network-origin evidence. * @param array $ids Bounded positive source-fact identifiers selected by the caller. * @param Scope|null $authority Explicit purge scope, or null for internally constrained retention. * * @return array|\WP_Error Cleanup completion and remaining-work facts, or a scope, authority or storage error. */ public static function erase_request_facts( int $network, int $site, array $ids, ?Scope $authority = null ): array|\WP_Error { if ( Scope::network() !== $network || get_current_blog_id() !== $site || count( $ids ) > 500 ) { return new \WP_Error( 'scope', 'Request alert cleanup scope is invalid.' ); } if ( ! self::cleanup_authorized( $authority, $network, $site ) ) { return new \WP_Error( 'forbidden', 'Request alert purge authority is required.' ); } foreach ( $ids as $id ) { if ( ! is_int( $id ) || $id < 1 ) { return new \WP_Error( 'scope', 'Request alert reference is invalid.' ); } } if ( ! $ids ) { return array( 'done' => true, 'remaining' => 0, ); } return self::erase_request_links( $network, $site, $ids, false, $authority ); } /** * Select bounded request-linked delivery cleanup by request correlation identifiers. * * @param int $network Selected network identifier. * @param int|null $site Selected site identifier, or null for the internally selected current-network scope. * @param array $uids Bounded request correlation identifiers selected from retained facts. * @param Scope|null $authority Explicit purge scope, or null for internally constrained retention. * * @return array|\WP_Error Cleanup completion and remaining-work facts, or a scope, authority or storage error. */ public static function erase_request_references( int $network, ?int $site, array $uids, ?Scope $authority = null ): array|\WP_Error { if ( Scope::network() !== $network || ( null !== $site && get_current_blog_id() !== $site ) || count( $uids ) > 500 ) { return new \WP_Error( 'scope', 'Request alert reference scope is invalid.' ); } if ( ! self::cleanup_authorized( $authority, $network, $site ) ) { return new \WP_Error( 'forbidden', 'Request alert purge authority is required.' ); } foreach ( $uids as $uid ) { if ( ! is_string( $uid ) || ! preg_match( '/\A[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}\z/D', $uid ) ) { return new \WP_Error( 'scope', 'Request alert reference is invalid.' ); } } if ( ! $uids ) { return array( 'done' => true, 'remaining' => 0, ); } return self::erase_request_links( $network, $site, $uids, true, $authority ); } /** * Check explicit purge authority or accept the internally constrained retention adapter. * * @phpstan-impure Reads current native authority or mutable context. * * @param Scope|null $authority Explicit purge scope, or null for internally constrained retention. * @param int $network Selected network identifier. * @param int|null $site Selected site identifier, or null for the internally selected current-network scope. * * @return bool Whether the supplied scope authorizes cleanup in the selected namespace. */ private static function cleanup_authorized( ?Scope $authority, int $network, ?int $site ): bool { if ( null === $authority ) { return true; } // Internal retention has already derived and constrained scope. return $authority->network_id === $network && ( $site ?? 0 ) === $authority->site_id && $authority->valid_for( null === $site || 0 === $site ? 'logged_ai_manage_network_settings' : 'logged_ai_manage_settings' ) && $authority->valid_for( null === $site || 0 === $site ? 'logged_ai_purge_network_data' : 'logged_ai_purge_data' ); } /** * Cancel selected request alerts and erase bounded delivery and cooldown links. * * @param int $network Selected network identifier. * @param int|null $site Selected site identifier, or null for the internally selected current-network scope. * @param array $references Bounded native IDs or canonical UUIDs validated by the public adapters. * @param bool $by_uid Select UUID references instead of positive native IDs. * @param Scope|null $authority Explicit purge scope, or null for internally constrained retention. * * @return array|\WP_Error Completion and lower-bound remaining-work facts, or a privacy or storage error. */ private static function erase_request_links( int $network, ?int $site, array $references, bool $by_uid, ?Scope $authority ): array|\WP_Error { $blog = get_current_blog_id(); $principal = get_current_user_id(); $allowed = static fn() => Scope::network() === $network && get_current_blog_id() === $blog && get_current_user_id() === $principal && self::cleanup_authorized( $authority, $network, $site ); return self::locked( static function () use ( $network, $site, $references, $by_uid, $allowed ) { if ( ! $allowed() ) { return new \WP_Error( 'forbidden', 'Request alert purge authority or scope changed.' ); } global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ $jobs = self::table(); $cooldowns = self::table( 'alert_cooldowns' ); $requests = Requests::table( 'requests' ); $reference_marks = implode( ',', array_fill( 0, count( $references ), $by_uid ? '%s' : '%d' ) ); $request_column = $by_uid ? 'request_uid' : 'id'; $job_column = $by_uid ? 'event_uid' : 'event_id'; $request_args = array_merge( null === $site ? array( $requests, $network, $request_column ) : array( $requests, $network, $site, $request_column ), $references ); $job_args = array_merge( null === $site ? array( $jobs, $network, $job_column ) : array( $jobs, $network, $site, $job_column ), $references ); // Only the generated placeholder list is interpolated; all identifiers and values are bound here. $cancel = null === $site // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- The IN list contains only one generated placeholder per bounded validated reference; the argument array binds scope/identifiers followed by every reference. Read or update current linked work; caching cannot establish erasure. ? $wpdb->query( $wpdb->prepare( "UPDATE %i SET alert_state='skipped' WHERE network_id=%d AND %i IN ($reference_marks) AND alert_state='pending' LIMIT 500", $request_args ) ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- The IN list contains only one generated placeholder per bounded validated reference; the argument array binds scope/identifiers followed by every reference. Read or update current linked work; caching cannot establish erasure. : $wpdb->query( $wpdb->prepare( "UPDATE %i SET alert_state='skipped' WHERE network_id=%d AND site_id=%d AND %i IN ($reference_marks) AND alert_state='pending' LIMIT 500", $request_args ) ); if ( false === $cancel ) { return new \WP_Error( 'storage', 'Linked request alerts could not be cancelled.' ); } $ids = null === $site // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- The IN list contains only one generated placeholder per bounded validated reference; the argument array binds scope/identifiers followed by every reference. Read or update current linked work; caching cannot establish erasure. ? $wpdb->get_col( $wpdb->prepare( "SELECT id FROM %i WHERE fact_kind='request' AND network_id=%d AND %i IN ($reference_marks) ORDER BY id LIMIT 500", $job_args ) ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- The IN list contains only one generated placeholder per bounded validated reference; the argument array binds scope/identifiers followed by every reference. Read or update current linked work; caching cannot establish erasure. : $wpdb->get_col( $wpdb->prepare( "SELECT id FROM %i WHERE fact_kind='request' AND network_id=%d AND site_id=%d AND %i IN ($reference_marks) ORDER BY id LIMIT 500", $job_args ) ); if ( '' !== $wpdb->last_error ) { return new \WP_Error( 'storage', 'Linked request deliveries could not be selected.' ); } if ( $ids ) { $marks = implode( ',', array_fill( 0, count( $ids ), '%s' ) ); if ( ! $allowed() ) { return new \WP_Error( 'forbidden', 'Request alert purge authority or scope changed.' ); } $cooldown_args = array_merge( null === $site ? array( $cooldowns, $network ) : array( $cooldowns, $network, $site ), $ids ); $remove_cooldowns = null === $site // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- The IN list contains only one generated %s per selected job ID (at most 500); the argument array binds table/scope followed by every ID. Erase actual linked cooldowns; cached state cannot establish deletion. ? $wpdb->query( $wpdb->prepare( "DELETE FROM %i WHERE network_id=%d AND last_job IN ($marks)", $cooldown_args ) ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- The IN list contains only one generated %s per selected job ID (at most 500); the argument array binds table/scope followed by every ID. Erase actual linked cooldowns; cached state cannot establish deletion. : $wpdb->query( $wpdb->prepare( "DELETE FROM %i WHERE network_id=%d AND site_id=%d AND last_job IN ($marks)", $cooldown_args ) ); if ( false === $remove_cooldowns ) { return new \WP_Error( 'storage', 'Linked request cooldowns could not be erased.' ); } if ( ! $allowed() ) { return new \WP_Error( 'forbidden', 'Request alert purge authority or scope changed.' ); } $remove_args = array_merge( $job_args, $ids ); $remove_jobs = null === $site // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Both IN lists contain only generated placeholders; the argument array binds scope/identifiers, validated references, then the at-most-500 selected job IDs. Persist current erasure; cached rows cannot establish deletion. ? $wpdb->query( $wpdb->prepare( "DELETE FROM %i WHERE fact_kind='request' AND network_id=%d AND %i IN ($reference_marks) AND id IN ($marks)", $remove_args ) ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Both IN lists contain only generated placeholders; the argument array binds scope/identifiers, validated references, then the at-most-500 selected job IDs. Persist current erasure; cached rows cannot establish deletion. : $wpdb->query( $wpdb->prepare( "DELETE FROM %i WHERE fact_kind='request' AND network_id=%d AND site_id=%d AND %i IN ($reference_marks) AND id IN ($marks)", $remove_args ) ); if ( false === $remove_jobs ) { return new \WP_Error( 'storage', 'Linked request deliveries could not be erased.' ); } } $left = null === $site // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- The IN list contains only one generated placeholder per bounded validated reference; the argument array binds scope/identifiers followed by every reference. Read or update current linked work; caching cannot establish erasure. ? $wpdb->get_var( $wpdb->prepare( "SELECT id FROM %i WHERE fact_kind='request' AND network_id=%d AND %i IN ($reference_marks) LIMIT 1", $job_args ) ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- The IN list contains only one generated placeholder per bounded validated reference; the argument array binds scope/identifiers followed by every reference. Read or update current linked work; caching cannot establish erasure. : $wpdb->get_var( $wpdb->prepare( "SELECT id FROM %i WHERE fact_kind='request' AND network_id=%d AND site_id=%d AND %i IN ($reference_marks) LIMIT 1", $job_args ) ); if ( '' !== $wpdb->last_error ) { return new \WP_Error( 'storage', 'Request delivery erasure could not be verified.' ); } if ( ! $allowed() ) { return new \WP_Error( 'forbidden', 'Request alert purge authority or scope changed.' ); } return array( 'done' => null === $left, 'remaining' => null === $left ? 0 : 1, 'remaining_is_lower_bound' => null !== $left, ); } ); } /** * Erase bounded network-event delivery links before their source identities vanish. * * Retention removes exact derived links before discarding their identifying facts. * * @param array $ids Bounded positive source-fact identifiers selected by the caller. * @param Scope|null $authority Explicit purge scope, or null for internally constrained retention. * * @return array|\WP_Error Completion and lower-bound remaining-work facts, or a scope or storage error. */ public static function erase_network_facts( array $ids, ?Scope $authority = null ): array|\WP_Error { if ( ! is_multisite() || count( $ids ) > 500 ) { return new \WP_Error( 'scope', 'Invalid network retention batch.' ); } foreach ( $ids as $id ) { if ( ! is_int( $id ) || $id < 1 ) { return new \WP_Error( 'scope', 'Invalid network retention batch.' ); } } if ( ! $ids ) { return array( 'done' => true, 'remaining' => 0, ); } $network = Scope::network(); $blog = get_current_blog_id(); $principal = get_current_user_id(); $allowed = static fn() => Scope::network() === $network && get_current_blog_id() === $blog && get_current_user_id() === $principal && self::cleanup_authorized( $authority, $network, 0 ); if ( ! $allowed() ) { return new \WP_Error( 'forbidden', 'Network alert purge authority is required.' ); } return self::locked( static function () use ( $ids, $network, $allowed ) { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ if ( ! $allowed() ) { return new \WP_Error( 'forbidden', 'Network alert purge authority or scope changed.' ); } $marks = implode( ',', array_fill( 0, count( $ids ), '%d' ) ); $table = self::table(); $args = array_merge( array( $table, $network ), $ids ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- The IN list contains only one generated %d per validated positive ID (at most 500); the argument array binds table/network followed by every ID. Verify or persist actual scoped erasure without caching. if ( false === $wpdb->query( $wpdb->prepare( "DELETE FROM %i WHERE network_id=%d AND site_id=0 AND event_id IN ($marks) LIMIT 500", $args ) ) ) { return new \WP_Error( 'storage', 'Network alert links could not be erased; original facts were preserved.' ); } // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- The IN list contains only one generated %d per validated positive ID (at most 500); the argument array binds table/network followed by every ID. Verify or persist actual scoped erasure without caching. $left = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM %i WHERE network_id=%d AND site_id=0 AND event_id IN ($marks) LIMIT 1", $args ) ); if ( '' !== $wpdb->last_error ) { return new \WP_Error( 'storage', 'Network alert cleanup could not be verified.' ); } if ( ! $allowed() ) { return new \WP_Error( 'forbidden', 'Network alert purge authority or scope changed.' ); } return array( 'done' => null === $left, 'remaining' => null === $left ? 0 : 1, 'remaining_is_lower_bound' => null !== $left, ); } ); } /** * Register site/network alert pages, forms, worker actions and CLI commands. * * @return void */ public static function boot(): void { add_action( 'admin_menu', static function () { add_submenu_page( 'logged-ai', __( 'Email alerts', 'logged-ai' ), __( 'Alerts', 'logged-ai' ), self::CAP, 'logged-ai-alerts', array( self::class, 'page' ) ); }, 20 ); add_action( 'admin_post_logged_ai_alerts', array( self::class, 'submit' ) ); add_action( 'network_admin_menu', static function () { add_submenu_page( 'logged-ai-network', __( 'Network fixed alerts', 'logged-ai' ), __( 'Alerts', 'logged-ai' ), 'logged_ai_manage_network_settings', 'logged-ai-network-alerts', static function () { self::in_network( static function () { self::page(); } ); } ); }, 20 ); add_action( 'admin_post_logged_ai_network_alerts', static function () { self::in_network( static function () { self::submit(); } ); } ); add_action( 'logged_ai_network_alert_batch', array( self::class, 'network_cron' ) ); add_action( 'logged_ai_alert_worker', static function () { $result = self::worker(); if ( is_wp_error( $result ) ) { Store::degraded( 'alert_failed' ); } if ( is_multisite() && get_current_blog_id() === (int) get_main_site_id( Scope::network() ) ) { self::network_worker(); } } ); add_action( 'logged_ai_alert_batch', static function () { do_action( 'logged_ai_alert_worker' ); } ); if ( defined( 'WP_CLI' ) && WP_CLI ) { \WP_CLI::add_command( 'logged-ai network-alerts', static function ( $args, $assoc ) { $scope = Scope::current_network( 'logged_ai_manage_network_settings' ); if ( is_wp_error( $scope ) || $assoc || ! in_array( $args[0] ?? '', array( 'status', 'run' ), true ) ) { \WP_CLI::error( __( 'Use logged-ai network-alerts status|run --user= --url=.', 'logged-ai' ) ); } $result = 'run' === $args[0] ? self::network_worker() : self::network_listing( $scope ); if ( is_wp_error( $result ) ) { \WP_CLI::error( PresentationTranslations::text( $result->get_error_message() ) ); } \WP_CLI::line( wp_json_encode( $result, JSON_PRETTY_PRINT ) ); } ); \WP_CLI::add_command( 'logged-ai alerts', static function ( $args, $assoc ) { $scope = self::scope(); if ( is_wp_error( $scope ) || $assoc || ! in_array( $args[0] ?? '', array( 'status', 'run' ), true ) ) { \WP_CLI::error( __( 'Use logged-ai alerts status|run --user= --url=.', 'logged-ai' ) ); } $result = 'run' === $args[0] ? self::worker() : self::listing( $scope ); if ( is_wp_error( $result ) ) { \WP_CLI::error( PresentationTranslations::text( $result->get_error_message() ) ); } \WP_CLI::line( wp_json_encode( $result, JSON_PRETTY_PRINT ) ); } ); } } /** * Validate an authorized nonce-bound alert form and save its selected configuration. * * @return void */ public static function submit(): void { $request_form = \LoggedAI\AdminInput::form(); $scope = self::scope(); if ( is_wp_error( $scope ) || 'POST' !== \LoggedAI\AdminInput::method() ) { wp_die( esc_html__( 'Alert management denied.', 'logged-ai' ), '', array( 'response' => 403 ) ); } check_admin_referer( self::action() ); $input = $request_form; foreach ( array( 'revision', 'cooldown' ) as $key ) { if ( ! isset( $input[ $key ] ) || ! is_string( $input[ $key ] ) || ! preg_match( '/\A[0-9]{1,10}\z/D', $input[ $key ] ) ) { wp_die( esc_html__( 'Invalid alert form.', 'logged-ai' ), '', array( 'response' => 400 ) ); } } $recipients = $input['recipients'] ?? array(); if ( ! is_array( $recipients ) || count( $recipients ) > 5 ) { wp_die( esc_html__( 'Invalid recipient selection.', 'logged-ai' ), '', array( 'response' => 400 ) ); } foreach ( $recipients as &$id ) { if ( ! is_string( $id ) || ! preg_match( '/\A[1-9][0-9]{0,18}\z/D', $id ) ) { wp_die( esc_html__( 'Invalid recipient.', 'logged-ai' ), '', array( 'response' => 400 ) ); } $id = (int) $id; } unset( $id ); $result = self::save( $scope, array( 'revision' => (int) $input['revision'], 'cooldown' => (int) $input['cooldown'], 'rules' => $input['rules'] ?? array(), 'recipients' => $recipients, 'security_components' => $input['security_components'] ?? array(), ) ); if ( is_wp_error( $result ) ) { wp_die( esc_html( PresentationTranslations::text( $result->get_error_message() ) ), '', array( 'response' => 400, 'back_link' => true, ) ); } wp_safe_redirect( add_query_arg( 'saved', '1', self::page_url() ) ); exit; } /** * Render scoped fixed-alert configuration and recent delivery status. * * @return void */ public static function page(): void { // Resolve every display translation before scoped configuration or delivery reads. $alert_text = array( 0 => __( 'Unavailable rule', 'logged-ai' ), /* translators: %1$s: recorded request identifier. */ 1 => __( 'Synthetic Request #%1$s', 'logged-ai' ), /* translators: 1: Value shown in this message. */ 2 => __( 'Synthetic Event #%1$s', 'logged-ai' ), /* translators: %1$s: recorded request identifier. */ 3 => __( 'Request #%1$s', 'logged-ai' ), /* translators: 1: Value shown in this message. */ 4 => __( 'Event #%1$s', 'logged-ai' ), 5 => __( 'Alert management denied.', 'logged-ai' ), 6 => __( 'Email alerts', 'logged-ai' ), 7 => __( 'Choose network-origin changes and recipients with current network evidence access. Alerts contain a short description and an authenticated link. Mail acceptance is not proof of receipt.', 'logged-ai' ), 8 => __( 'Choose important changes and recipients who already have access to this site’s evidence. Alerts contain a short description and an authenticated link. Mail acceptance is not proof of receipt.', 'logged-ai' ), 9 => __( '1. Choose what matters', 'logged-ai' ), 10 => __( 'Request alerts use admitted bundled detector findings, including explicitly labeled partial analysis. They exclude manual-response-only decisions and observer self-tests. A response exception does not suppress these alerts. No address, raw URL or query is included; an alert does not establish an attack or its effect.', 'logged-ai' ), 11 => __( '2. Choose who receives it', 'logged-ai' ), 12 => __( 'These alerts use network-origin events only. Site events and their alert settings remain separate. Only administrators of this network with network evidence access may receive these alerts.', 'logged-ai' ), /* translators: 1: Value shown in this message. 2: Value shown in this message. */ 13 => __( 'User #%1$s — %2$s', 'logged-ai' ), 14 => __( 'Choose up to five. This list shows up to 100 authorized accounts; no address is copied into delivery metadata.', 'logged-ai' ), 15 => __( 'Cooldown in seconds (60–86400)', 'logged-ai' ), 16 => __( 'Security components you selected', 'logged-ai' ), 17 => __( 'Selecting a component gives its ordinary deactivation event additional alert significance. Names alone do not establish that a plugin provides security.', 'logged-ai' ), 18 => __( 'Save alert settings', 'logged-ai' ), 19 => __( 'Recent delivery work', 'logged-ai' ), 20 => __( 'If the mail transport reports failure, Logged.ai tries up to three times in total, with retries after one and five minutes. A duplicate email is possible if the mail server accepted a message before reporting an error. A crashed sending attempt becomes delivery unknown and is not automatically repeated. Rule changes cancel older queued work. Cooldowns suppress repeated matches; synthetic deliveries are labeled separately.', 'logged-ai' ), 21 => __( 'Evidence', 'logged-ai' ), 22 => __( 'Rule', 'logged-ai' ), 23 => __( 'Recipient', 'logged-ai' ), 24 => __( 'State', 'logged-ai' ), 25 => __( 'Attempts', 'logged-ai' ), /* translators: 1: Value shown in this message. */ 26 => __( 'User #%1$s', 'logged-ai' ), 27 => __( 'No retained alert delivery work to show. New queued alerts appear here; older work may have expired.', 'logged-ai' ), ); // Resolve new display translations before the scoped delivery listing. $pro_invitation = __( 'Choose your own rules and notify your team through email, Slack or webhooks.', 'logged-ai' ); $display_rules = PresentationTranslations::snapshot( self::rules() ); $unavailable_rule = $alert_text[ 0 ]; $evidence_formats = array( /* translators: %1$s: recorded request identifier. */ 'synthetic_request' => $alert_text[ 1 ], /* translators: 1: Value shown in this message. */ 'synthetic_event' => $alert_text[ 2 ], /* translators: %1$s: recorded request identifier. */ 'request' => $alert_text[ 3 ], /* translators: 1: Value shown in this message. */ 'event' => $alert_text[ 4 ], ); $scope = self::scope(); if ( is_wp_error( $scope ) ) { wp_die( esc_html( $alert_text[ 5 ] ), '', array( 'response' => 403 ) ); } nocache_headers(); $config = self::config(); $jobs = self::listing( $scope ); echo '

' . esc_html( $alert_text[ 6 ] ) . '

' . esc_html( self::$network_mode ? $alert_text[ 7 ] : $alert_text[ 8 ] ) . '

'; if ( ! self::$network_mode ) { DesignPro::invitation( 'alert', 'alerts', $pro_invitation ); } echo '
'; wp_nonce_field( self::action() ); echo '

' . esc_html__( 'Critical security and failure notices are Free. Routine activity notification emails are supplied by the activated Pro add-on.', 'logged-ai' ) . '

'; foreach ( array_diff( $config['rules'], array_keys( self::rules() ) ) as $retained_rule ) { echo ''; } echo '

' . esc_html( $alert_text[ 9 ] ) . '

'; foreach ( self::rules() as $key => $label ) { if ( self::$network_mode && in_array( $key, array( 'authentication_burst', 'request_finding', 'automatic_suppressed' ), true ) ) { continue; } echo '

'; } if ( ! self::$network_mode ) { echo '

' . esc_html( $alert_text[ 10 ] ) . '

'; } echo '

' . esc_html( $alert_text[ 11 ] ) . '

'; if ( self::$network_mode ) { echo '

' . esc_html( $alert_text[ 12 ] ) . '

'; } if ( self::$network_mode ) { $names = isset( $GLOBALS['super_admins'] ) ? $GLOBALS['super_admins'] : get_network_option( $scope->network_id, 'site_admins', array( 'admin' ) ); $users = array(); foreach ( is_array( $names ) ? array_slice( $names, 0, 100 ) : array() as $name ) { if ( is_string( $name ) && strlen( $name ) <= 60 ) { $user = get_user_by( 'login', $name ); if ( $user ) { $users[] = $user; } } } } else { $users = get_users( array( 'blog_id' => $scope->site_id, 'capability' => 'logged_ai_view_events', 'number' => 100, 'orderby' => 'ID', ) ); } foreach ( $users as $user ) { if ( ! self::recipient( (int) $user->ID ) ) { continue; } echo ( '

' . '' . '

' ); } echo '

' . esc_html( $alert_text[ 14 ] ) . '

' . esc_html( $alert_text[ 16 ] ) . '

' . esc_html( $alert_text[ 17 ] ) . '

'; require_once ABSPATH . 'wp-admin/includes/plugin.php'; foreach ( array_slice( get_plugins(), 0, 100, true ) as $component => $plugin ) { $name = is_string( $plugin['Name'] ?? null ) ? trim( wp_strip_all_tags( $plugin['Name'] ) ) : ''; $name = '' !== $name ? $name : $component; echo '

'; } echo '

' . esc_html( $alert_text[ 19 ] ) . '

' . esc_html( $alert_text[ 20 ] ) . '

'; if ( is_wp_error( $jobs ) ) { echo ( '

' . ( esc_html( PresentationTranslations::text( $jobs->get_error_message() ) ) ) . '

' ); } elseif ( ! $jobs ) { echo '

' . esc_html( $alert_text[ 27 ] ) . '

'; } else { echo ( '
' . '' . '' . '' . '' . '' . '' . '' . '' . '' . '' . '' ); foreach ( $jobs as $job ) { echo ( '' . '' . '' . '' . '' . '' . '' ); } echo '
' . esc_html( $alert_text[ 21 ] ) . '' . esc_html( $alert_text[ 22 ] ) . '' . esc_html( $alert_text[ 23 ] ) . '' . esc_html( $alert_text[ 24 ] ) . '' . esc_html( $alert_text[ 25 ] ) . '
' . ( esc_html( sprintf( $evidence_formats[ ( $job['synthetic'] ? 'synthetic_' : '' ) . ( 'request' === $job['fact_kind'] ? 'request' : 'event' ) ], $job['event_id'] ) ) ) . '' . ( esc_html( isset( self::rules()[ $job['rule_key'] ] ) ? PresentationTranslations::lookup( $display_rules, self::rules()[ $job['rule_key'] ] ) : $unavailable_rule ) ) . '' . ( esc_html( sprintf( /* translators: 1: Value shown in this message. */ $alert_text[ 26 ], $job['recipient_id'] ) ) ) . '' . ( esc_html( str_replace( '_', ' ', $job['state'] ) ) ) . '' . ( esc_html( $job['attempts'] ) ) . '
'; } echo '
'; } }