$db->prefix, 'base_prefix' => $db->base_prefix, 'blogid' => self::integer( $db->blogid, 1 ) ?? $db->blogid, 'siteid' => $db->siteid, ); foreach ( $db->tables( 'all', false ) as $name ) { $tables[ $name ] = $db->{$name} ?? null; } return $tables; } /** * Capture native identity while normalizing only supported integer blog identifiers. * * Preserve native switch/restore equivalence without coalescing malformed site IDs. * * @phpstan-impure * * @param \wpdb $db Native database connection selected for this operation. * * @return array Native context fingerprint retaining malformed values without coalescing them. */ private static function native_context( \wpdb $db ): array { $context = Derived::native_context( $db ); $context[1] = self::integer( $context[1], 1 ) ?? $context[1]; $context['blogid'] = self::integer( $context['blogid'], 1 ) ?? $context['blogid']; return $context; } /** * Resolve the current automatic-response origin while permitting initial principal resolution. * * @phpstan-impure Reads current native authority or mutable context. * * @return array|\WP_Error Captured connection, principal, scope and table maps, or a context-drift error. */ private static function origin(): array|\WP_Error { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ $db = $wpdb; $native = self::native_context( $db ); $tables = self::table_context( $db ); $initialized = ( $GLOBALS['current_user'] ?? null ) instanceof \WP_User; $network = Scope::network(); $resolved = self::native_context( $db ); $expected = $native; // Slot 3 is Derived's raw native principal. Only an initially unresolved user may // initialize; every captured caller map, blog and global pointer stays fixed. if ( ! $initialized ) { $expected[3] = $resolved[3]; } if ( $expected !== $resolved || self::table_context( $db ) !== $tables ) { return self::error( 'Automatic response origin changed during network resolution.' ); } $principal = get_current_user_id(); $resolved = self::native_context( $db ); if ( ! $initialized ) { $expected[3] = $resolved[3]; } if ( $expected !== $resolved || self::table_context( $db ) !== $tables || ! ( $GLOBALS['current_user'] ?? null ) instanceof \WP_User || $principal !== (int) $resolved[3] ) { return self::error( 'Automatic response origin changed during principal resolution.' ); } return array( 'db' => $db, 'network' => $network, 'site' => get_current_blog_id(), 'principal' => $principal, 'prefix' => $GLOBALS['table_prefix'] ?? null, 'tables' => $tables, 'native' => $resolved, ); } /** * Compare the current native principal, connection and table map with a captured origin. * * Pure native-context comparison. Do not repair or hide a callback's real principal/blog/table-map change. * * @phpstan-impure * * @param array $origin Captured native identity and table-map fingerprint to compare. * * @return bool Whether every captured origin component still matches. */ private static function same_origin( array $origin ): bool { $principal = $GLOBALS['current_user'] ?? null; return ( $GLOBALS['wpdb'] ?? null ) === $origin['db'] && get_current_blog_id() === $origin['site'] && ( $principal instanceof \WP_User ? (int) $principal->ID : 0 ) === $origin['principal'] && ( $GLOBALS['table_prefix'] ?? null ) === $origin['prefix'] && self::table_context( $origin['db'] ) === $origin['tables'] && self::native_context( $origin['db'] ) === $origin['native']; } /** * Recheck the captured caller origin and independent writer table map. * * @phpstan-impure Reads current native authority or mutable context. * * @param \wpdb $db Native database connection selected for this operation. * * @return bool Whether this registered connection retains its original native context. */ public static function unchanged( \wpdb $db ): bool { return null !== self::$origins && isset( self::$origins[ $db ] ) && self::same_origin( self::$origins[ $db ] ) && self::table_context( $db ) === self::$origins[ $db ]['writer_tables']; } /** * Create an automatic-response error with a fixed caller-selected code and status. * * @param string $message Fixed operation-specific message supplied by installed code. * @param string $code Finite operation-specific error code selected by the caller. * @param int $status HTTP status associated with this structured error. * * @return \WP_Error Structured error containing the supplied message and response status. */ public static function error( string $message, string $code = 'automatic_unavailable', int $status = 503 ): \WP_Error { return new \WP_Error( $code, $message, array( 'status' => $status ) ); } /** * Create and verify an independent committed connection while observing setup callbacks. * * @return \wpdb|\WP_Error Registered private database connection, or an error after refusing unavailable setup. */ public static function connection(): \wpdb|\WP_Error { global $wpdb; if ( ! $wpdb instanceof \wpdb || ! in_array( get_class( $wpdb ), array( 'wpdb', WorkerConnection::class ), true ) ) { return self::error( 'Automatic response requires a verified independent database adapter.' ); } $db = null; $handle = null; $observe = null; $accepted = false; /** * WordPress query callbacks can change this captured flag. * * @var bool $changed */ $changed = false; $expected = null; $worker_tables = null; $thread = null; try { $caller = $wpdb; $initial = array( 'native' => self::native_context( $caller ), 'tables' => self::table_context( $caller ), 'resolved' => ( $GLOBALS['current_user'] ?? null ) instanceof \WP_User, ); $origin = null; $caller_handle = $caller->__get( 'dbh' ); if ( ! $caller_handle instanceof \mysqli ) { return self::error( 'Automatic response storage or independent connection settings are unavailable.' ); } $caller_thread = $caller_handle->thread_id; $current = static function () use ( $caller, $caller_handle, $caller_thread, &$origin, &$initial ): bool { /** * Captured reference is read at invocation after setup/callback changes. * * @var array|\WP_Error|null $origin */ try { return ( null !== $origin ? self::same_origin( $origin ) : self::factory_origin_current( $caller, $initial ) ) && $caller_handle === $caller->__get( 'dbh' ) && $caller_thread === $caller_handle->thread_id; } catch ( \Throwable $error ) { return false; } }; $stable = static function () use ( $current, &$db, &$handle, &$thread, &$worker_tables ): bool { /** * Captured reference is read at invocation after setup/callback changes. * * @var \wpdb|null $db */ /** * Captured reference is read at invocation after setup/callback changes. * * @var \mysqli|null $handle */ /** * Captured reference is read at invocation after setup/callback changes. * * @var int|null $thread */ /** * Captured reference is read at invocation after setup/callback changes. * * @var array|null $worker_tables */ try { return $current() && $db instanceof \wpdb && $handle instanceof \mysqli && $handle === $db->__get( 'dbh' ) && $thread === $handle->thread_id && self::table_context( $db ) === $worker_tables; } catch ( \Throwable $error ) { return false; } }; // Install before dependency/origin callbacks; setup has the same caller protection as reads. $observe = static function ( string $sql ) use ( $caller_handle, $current, $stable, &$db, &$expected, &$changed ): string { /** * Captured reference is read at invocation after setup/callback changes. * * @var string|null $expected */ /** * Captured reference is read at invocation after setup/callback changes. * * @var \wpdb|null $db */ // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_debug_backtrace -- Inspect at most 24 live frames to bind connection setup SQL to the actual owned database object and caller; 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 ) { continue; } if ( ! $current() ) { $changed = true; return ''; } $original = $frame['args'][0] ?? null; $next = $trace[ $index + 1 ] ?? array(); $reader = $frame['object']; if ( $reader === $db ) { if ( ! $stable() || null === $expected || $expected !== $sql || self::class !== ( $next['class'] ?? '' ) || 'connection' !== ( $next['function'] ) || ! is_string( $original ) || $reader->remove_placeholder_escape( $original ) !== $sql ) { $changed = true; return ''; } } elseif ( ! preg_match( '/\A(?:SELECT|SHOW|DESCRIBE|EXPLAIN)\s/i', trim( $sql ) ) ) { // Native grants may initialize only their own independent authority reader. $permission_setup = $reader instanceof \LoggedAI\WorkerConnection && $reader->__get( 'dbh' ) !== $caller_handle && in_array( $next['class'] ?? '', array( 'LoggedAI\Scope', 'LoggedAI\BackgroundAuthority' ), true ) && in_array( $next['function'], array( 'committed_capabilities', 'allowed' ), true ) && in_array( $sql, array( 'SET SESSION autocommit=1', 'SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED' ), true ) && is_string( $original ) && $original === $sql; if ( ! $permission_setup ) { $changed = true; return ''; } } break; } return $sql; }; add_filter( 'query', $observe, PHP_INT_MAX ); $origin = self::origin(); if ( is_wp_error( $origin ) ) { return $origin; } if ( ! $current() || $changed ) { return self::error( 'Automatic response storage or independent connection settings are unavailable.' ); } $db = new \LoggedAI\WorkerConnection( DB_USER, DB_PASSWORD, DB_NAME, DB_HOST ); $handle = $db->__get( 'dbh' ); // Reject a borrowed handle before setup, and never claim it for cleanup. if ( ! $handle instanceof \mysqli || $handle === $caller_handle ) { $handle = null; return self::error( 'Automatic response storage or independent connection settings are unavailable.' ); } /** * Native callbacks since the preceding guard can change this flag. * * @var bool $changed */ if ( ! $db->ready || ! $current() || $changed ) { return self::error( 'Automatic response storage or independent connection settings are unavailable.' ); } $thread = $handle->thread_id; $db->set_prefix( $origin['tables']['base_prefix'] ); $db->set_blog_id( $origin['site'], $origin['network'] ); $db->suppress_errors( true ); $worker_tables = self::table_context( $db ); /** * Native callbacks since the preceding guard can change this flag. * * @var bool $changed */ if ( $db->prefix !== $caller->prefix || $db->users !== $caller->users || $db->usermeta !== $caller->usermeta || ! $stable() || $changed ) { return self::error( 'Automatic response storage or independent connection settings are unavailable.' ); } foreach ( array( 'SET SESSION autocommit=1', 'SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED', 'SET SESSION innodb_lock_wait_timeout=2' ) as $statement ) { $expected = $statement; /** * Native callbacks since the preceding guard can change this flag. * * @var bool $changed */ if ( 0 !== $db->query( $statement ) || '' !== $db->last_error || $statement !== $db->last_query || ! $stable() || $changed ) { return self::error( 'Automatic response storage or independent connection settings are unavailable.' ); } } $expected = 'SELECT @@SESSION.autocommit AS autocommit,@@SESSION.transaction_isolation AS isolation_level,@@SESSION.innodb_lock_wait_timeout AS lock_wait'; $selected = $db->query( $expected ); /** * Native callbacks since the preceding guard can change this flag. * * @var bool $changed */ if ( ! $stable() || $changed || $expected !== $db->last_query ) { return self::error( 'Automatic response storage or independent connection settings are unavailable.' ); } if ( false === $selected && '' !== $db->last_error ) { // Native older MariaDB variable absence permits one legacy read; rewrites do not. $expected = 'SELECT @@SESSION.autocommit AS autocommit,@@SESSION.tx_isolation AS isolation_level,@@SESSION.innodb_lock_wait_timeout AS lock_wait'; $selected = $db->query( $expected ); } /** * Native callbacks since the preceding guard can change this flag. * * @var bool $changed */ if ( 1 !== $selected || '' !== $db->last_error || $expected !== $db->last_query || ! $stable() || $changed || 1 !== $db->num_rows || 1 !== count( $db->last_result ) || ! is_object( $db->last_result[0] ) || array_keys( get_object_vars( $db->last_result[0] ) ) !== array( 'autocommit', 'isolation_level', 'lock_wait' ) ) { // @phpstan-ignore function.alreadyNarrowedType (wpdb::last_result is public and untyped; retain this cached-row object check without changing short-circuit reads.) return self::error( 'Automatic response storage or independent connection settings are unavailable.' ); } $settings = array( $db->get_var( null, 0, 0 ), $db->get_var( null, 1, 0 ), $db->get_var( null, 2, 0 ) ); /** * Native callbacks since the preceding guard can change this flag. * * @var bool $changed */ if ( array( '1', 'READ-COMMITTED', '2' ) !== $settings || ! $stable() || $changed ) { return self::error( 'Automatic response storage or independent connection settings are unavailable.' ); } $origin['writer_tables'] = $worker_tables; self::$origins ??= new \WeakMap(); self::$origins->offsetSet( $db, $origin ); // PHP 8.0/8.1 require a method argument to dereference a captured object key. $accepted = true; return $db; } catch ( \Throwable $error ) { return self::error( 'Automatic response storage or independent connection settings are unavailable.' ); } finally { if ( null !== $observe ) { remove_filter( 'query', $observe, PHP_INT_MAX ); } if ( ! $accepted && $db instanceof WorkerConnection && $db !== $caller ) { remove_filter( 'query', array( $db, 'remove_placeholder_escape' ), 0 ); } if ( ! $accepted && $db instanceof \wpdb && $handle instanceof \mysqli ) { // The original private native handle is owned here; a substituted pointer is not. try { $handle === $db->__get( 'dbh' ) ? $db->close() : $handle->close(); // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- Connection refusal is already selected; best-effort cleanup cannot authorize work and must never close a borrowed or replacement handle. } catch ( \Throwable $error ) { /* Refusal already selected. */ } } } } /** * Permit one unresolved principal to initialize without hiding other caller changes. * * Preserve the existing one-time native principal resolution before origin() returns. * * @param \wpdb $caller Original native database connection retained by its caller. * @param array $initial Captured factory context, updated only for permitted initial principal resolution. * * @return bool Whether the original context remains valid after allowed principal resolution. */ private static function factory_origin_current( \wpdb $caller, array &$initial ): bool { $native = self::native_context( $caller ); $expected = $initial['native']; $resolved = ( $GLOBALS['current_user'] ?? null ) instanceof \WP_User; if ( ! $initial['resolved'] && $resolved ) { $expected[3] = $native[3]; } if ( $expected !== $native || self::table_context( $caller ) !== $initial['tables'] ) { return false; } if ( $resolved ) { $initial['resolved'] = true; $initial['native'] = $native; } return true; } /** * Check the selected current site and network against the registered writer origin. * * @param \wpdb $db Native database connection selected for this operation. * @param int $network Selected network identifier. * @param int $site Selected site identifier. * * @return bool Whether the private writer remains bound to this exact native site context. */ public static function identity( \wpdb $db, int $network, int $site ): bool { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ return Scope::network() === $network && get_current_blog_id() === $site && $site > 0 && self::unchanged( $db ) && self::$origins[ $db ]['network'] === $network && $db->base_prefix === $wpdb->base_prefix && $db->prefix === $wpdb->prefix && $db->get_blog_prefix( $site ) === $wpdb->prefix; } /** * Parse a native integer or canonical nonnegative decimal within explicit integer bounds. * * @param mixed $value Supplied value to validate or format under the method contract. * @param int $minimum Inclusive minimum accepted native integer. * @param int $maximum Inclusive maximum accepted native integer. * * @return int|null Validated native integer, or null for unsupported representation or range. */ public static function integer( mixed $value, int $minimum = 0, int $maximum = PHP_INT_MAX ): ?int { if ( ! is_int( $value ) && ( ! is_string( $value ) || ! preg_match( '/\A(?:0|[1-9][0-9]{0,18})\z/D', $value ) || strlen( $value ) > strlen( (string) PHP_INT_MAX ) || ( strlen( $value ) === strlen( (string) PHP_INT_MAX ) && strcmp( $value, (string) PHP_INT_MAX ) > 0 ) ) ) { return null; } $number = (int) $value; return $number >= $minimum && $number <= $maximum ? $number : null; } /** * Check the exact lowercase hexadecimal automatic-generation representation. * * @param mixed $value Supplied value to validate or format under the method contract. * * @return bool Whether the value is a 32-character lowercase hexadecimal string. */ public static function generation( mixed $value ): bool { return is_string( $value ) && 1 === preg_match( '/\A[a-f0-9]{32}\z/D', $value ); } /** * Check that the already loaded Pro worker has registered its scheduled callback. * * WordPress can skip missing plugin files while their saved activation entry remains. No autoload or Pro call. * * @return bool Whether the required loaded worker and exact callback are present. */ public static function addon_loaded(): bool { return defined( 'LOGGED_AI_PRO_EDITION_API' ) && 2 === LOGGED_AI_PRO_EDITION_API && class_exists( 'LoggedAI\Pro\AutomaticWorker', false ) && false !== has_action( 'logged_ai_automatic_worker', array( 'LoggedAI\Pro\AutomaticWorker', 'scheduled' ) ); } /** * Read and validate the exact automatic-state schema in the current registered scope. * * @param \wpdb $db Native database connection selected for this operation. * @param bool $for_update Whether the state read includes a locking FOR UPDATE clause. * * @return array|\WP_Error Decoded bounded state, or a schema, identity or storage error. */ public static function read( \wpdb $db, bool $for_update = false ): array|\WP_Error { $network = Scope::network(); $site = get_current_blog_id(); if ( ! self::identity( $db, $network, $site ) ) { return self::error( 'Automatic state scope changed.' ); } $table = RequestsAutomaticSchema::table( 'automatic_state', $db ); $row = $for_update ? $db->get_row( $db->prepare( 'SELECT * FROM %i WHERE network_id=%d AND site_id=%d FOR UPDATE', $table, $network, $site ), ARRAY_A ) : $db->get_row( $db->prepare( 'SELECT * FROM %i WHERE network_id=%d AND site_id=%d', $table, $network, $site ), ARRAY_A ); if ( '' !== $db->last_error || ! is_array( $row ) ) { return self::error( 'Automatic response state is missing or unavailable.' ); } $defaults = RequestsAutomaticSchema::off( $network, $site ); if ( array_diff( array_keys( $defaults ), array_keys( $row ) ) || array_diff( array_keys( $row ), array_keys( $defaults ) ) ) { return self::error( 'Automatic response state is incompatible.' ); } foreach ( $defaults as $key => $default ) { if ( is_int( $default ) ) { $value = self::integer( $row[ $key ] ); if ( null === $value ) { return self::error( 'An automatic state bound is invalid.' ); } $row[ $key ] = $value; } } if ( $row['network_id'] !== $network || $row['site_id'] !== $site || $row['revision'] < 1 || $row['revision'] >= PHP_INT_MAX || ! in_array( $row['mode'], array( 'off', 'shadow', 'active' ), true ) || ! self::generation( $row['generation'] ) || ! self::generation( $row['source_generation'] ) || ! in_array( $row['ttl'], self::TTLS, true ) || ! in_array( $row['scan_complete'], array( 0, 1 ), true ) || RequestsAutomaticSchema::PREDICATE !== $row['predicate_version'] || RequestsAutomaticSchema::ROUTES !== $row['route_version'] || ! is_string( $row['suppression'] ) || ! preg_match( '/\A[a-z_]{0,40}\z/D', $row['suppression'] ) ) { return self::error( 'Automatic state could not be validated.' ); } if ( 'off' !== $row['mode'] && ( $row['operator_id'] < 1 || $row['settings_revision'] < 1 || str_repeat( '0', 32 ) === $row['generation'] || $row['shadow_started'] < 1 || $row['shadow_started'] > time() || ( 'active' === $row['mode'] && ( $row['active_started'] < $row['shadow_started'] + self::SHADOW_SECONDS || $row['active_started'] > time() ) ) ) ) { return self::error( 'Automatic opt-in or shadow evidence is invalid.' ); } return self::identity( $db, $network, $site ) ? $row : self::error( 'Automatic state scope changed during its read.' ); } /** * Read committed privacy, source-trust, activation and response-switch dependencies. * * Persisted options are read from this committed connection, never an object-cache or caller snapshot. * * @phpstan-impure * * @param \wpdb $db Native database connection selected for this operation. * * @return array|\WP_Error Validated dependency context and content binding, or an explicit availability error. */ public static function context( \wpdb $db ): array|\WP_Error { $network = Scope::network(); $site = get_current_blog_id(); if ( ! self::identity( $db, $network, $site ) ) { return self::error( 'Automatic context scope is unavailable.' ); } $options = $db->get_blog_prefix( $site ) . 'options'; $rows = $db->get_results( $db->prepare( "SELECT option_name,CASE WHEN OCTET_LENGTH(option_value)<=16384 THEN option_value ELSE NULL END AS value FROM %i WHERE option_name IN ('logged_ai_settings','logged_ai_request_source','active_plugins')", $options ), ARRAY_A ); if ( '' !== $db->last_error || count( $rows ) > 3 ) { return self::error( 'Automatic dependency configuration could not be read.' ); } $values = array(); foreach ( $rows as $row ) { if ( ! is_string( $row['value'] ?? null ) || isset( $values[ $row['option_name'] ] ) ) { return self::error( 'Automatic dependency configuration is invalid.' ); } $values[ $row['option_name'] ] = $row['value']; } $settings = isset( $values['logged_ai_settings'] ) ? self::unserialize( $values['logged_ai_settings'] ) : Settings::defaults(); if ( ! is_array( $settings ) || array_diff( array_keys( $settings ), array_keys( Settings::defaults() ) ) || array_diff( array_keys( Settings::defaults() ), array_keys( $settings ) ) ) { return self::error( 'Privacy settings are unavailable for automatic targeting.' ); } foreach ( Settings::defaults() as $key => $default ) { if ( 'ip_mode' === $key ) { if ( ! in_array( $settings[ $key ], array( 'full', 'masked', 'disabled' ), true ) ) { return self::error( 'Client context mode is invalid.' ); } } elseif ( ! is_int( $settings[ $key ] ) || null === self::integer( $settings[ $key ], 1, 'revision' === $key ? 2147483647 : 3650 ) ) { return self::error( 'Privacy settings are invalid.' ); } } $source = isset( $values['logged_ai_request_source'] ) && strlen( $values['logged_ai_request_source'] ) <= 8192 ? json_decode( $values['logged_ai_request_source'], true, 5 ) : ( isset( $values['logged_ai_request_source'] ) ? null : RequestsSource::defaults() ); if ( ! is_array( $source ) || array_keys( $source ) !== array_keys( RequestsSource::defaults() ) || ! is_int( $source['revision'] ) || null === self::integer( $source['revision'], 0, 2147483647 ) || ! is_int( $source['changed_at'] ) || $source['changed_at'] < 0 || ! self::generation( $source['generation'] ) || ! in_array( $source['header'], array( 'direct_peer', 'x_forwarded_for' ), true ) || ! is_array( $source['trusted'] ) || ! array_is_list( $source['trusted'] ) || count( $source['trusted'] ) > RequestsSource::MAX_PROXIES ) { return self::error( 'Source trust is invalid or unavailable.' ); } if ( isset( $values['logged_ai_request_source'] ) && $source['revision'] < 1 ) { return self::error( 'The stored source-trust revision is invalid.' ); } foreach ( $source['trusted'] as $entry ) { if ( ! is_string( $entry ) || RequestsSource::cidr( $entry ) !== $entry ) { return self::error( 'A source trust boundary is invalid.' ); } } if ( 'direct_peer' === $source['header'] ? (bool) $source['trusted'] : ! $source['trusted'] || $source['revision'] < 1 || str_repeat( '0', 32 ) === $source['generation'] ) { return self::error( 'Source trust is incomplete.' ); } $site_plugins = isset( $values['active_plugins'] ) ? self::unserialize( $values['active_plugins'] ) : array(); $network_plugins = array(); // Native deactivate_plugins() preserves index gaps after removing a plugin. if ( ! is_array( $site_plugins ) || count( $site_plugins ) > 1000 ) { return self::error( 'Current plugin activation could not be verified.' ); } foreach ( $site_plugins as $plugin ) { if ( ! is_string( $plugin ) ) { return self::error( 'Current plugin activation is invalid.' ); } } if ( is_multisite() ) { $row = $db->get_row( $db->prepare( "SELECT CASE WHEN OCTET_LENGTH(meta_value)<=65536 THEN meta_value ELSE NULL END AS value FROM %i WHERE site_id=%d AND meta_key='active_sitewide_plugins' ORDER BY meta_id LIMIT 1", $db->sitemeta, $network ), ARRAY_A ); if ( '' !== $db->last_error || ( null !== $row && ! is_string( $row['value'] ?? null ) ) ) { return self::error( 'Network plugin activation is unavailable.' ); } $network_plugins = null === $row ? array() : self::unserialize( $row['value'] ); if ( ! is_array( $network_plugins ) || count( $network_plugins ) > 1000 ) { return self::error( 'Network plugin activation is invalid.' ); } foreach ( $network_plugins as $plugin => $stamp ) { if ( ! is_string( $plugin ) || ! is_int( $stamp ) || $stamp < 0 ) { return self::error( 'Network plugin activation is invalid.' ); } } } $active = self::addon_loaded(); foreach ( array( 'logged-ai/logged-ai.php', 'logged-ai-pro/logged-ai-pro.php' ) as $plugin ) { $active = $active && ( in_array( $plugin, $site_plugins, true ) || isset( $network_plugins[ $plugin ] ) ); } $response = $db->get_row( $db->prepare( 'SELECT schema_version,enabled,revision FROM %i WHERE network_id=%d AND site_id=%d', $db->base_prefix . 'logged_ai_response_state', $network, $site ), ARRAY_A ); if ( '' !== $db->last_error || ! is_array( $response ) || Requests::VERSION !== self::integer( $response['schema_version'] ?? null ) || null === self::integer( $response['enabled'] ?? null, 0, 1 ) || null === self::integer( $response['revision'] ?? null, 1 ) ) { return self::error( 'The shared response switch is unavailable.' ); } $result = array( 'network_id' => $network, 'site_id' => $site, 'settings' => $settings, 'source' => $source, 'active' => $active, 'response_enabled' => '1' === (string) $response['enabled'], 'response_revision' => (int) $response['revision'], ); $result['binding'] = hash( 'sha256', wp_json_encode( array( $network, $site, $settings, $source, $active, $result['response_enabled'], $result['response_revision'] ) ) ); return self::identity( $db, $network, $site ) ? $result : self::error( 'Automatic context changed during its read.' ); } /** * Decode recognized serialized option data with classes disabled and bounded depth. * * @param string $raw Persisted serialized option contents; caller applies the storage-specific byte bound. * * @return mixed Decoded native value, or null when the input is not serialized data. */ private static function unserialize( string $raw ): mixed { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize -- Decode bounded native option state with classes disabled and finite depth; callers validate the typed result before using it. Do not expose raw option bytes in parser warnings. return is_serialized( $raw ) ? @unserialize( $raw, array( 'allowed_classes' => false, 'max_depth' => 8, ) ) : null; } /** * Compare automatic opt-in against active dependencies and current source/privacy versions. * * @param array $state Validated stored automatic-response state. * @param array $context Validated current automatic-response dependency context. * * @return bool Whether the recorded opt-in matches the supplied compatible context. */ public static function bound( array $state, array $context ): bool { return $context['active'] && 'full' === $context['settings']['ip_mode'] && $state['settings_revision'] === $context['settings']['revision'] && $state['source_generation'] === $context['source']['generation'] && RequestsAutomaticSchema::PREDICATE === $state['predicate_version'] && RequestsAutomaticSchema::ROUTES === $state['route_version']; } /** * Project only aggregate suppression-notification configuration from current storage. * * Aggregate delivery setup only. No recipient identity or address is exposed to response operators. * * @param \wpdb $db Native database connection selected for this operation. * * @return array Configuration state, revision and recipient count without recipient identities. */ public static function notification_status( \wpdb $db ): array { $unknown = array( 'state' => 'unavailable', 'revision' => 0, 'recipients' => 0, ); if ( ! self::unchanged( $db ) ) { return $unknown; } $row = $db->get_row( $db->prepare( "SELECT CASE WHEN OCTET_LENGTH(option_value)<=16384 THEN option_value ELSE NULL END AS value FROM %i WHERE option_name='logged_ai_alerts' LIMIT 1", $db->options ), ARRAY_A ); if ( '' !== $db->last_error || ( null !== $row && ! is_string( $row['value'] ?? null ) ) ) { return $unknown; } $config = null === $row ? Alerts::defaults() : self::unserialize( $row['value'] ); if ( ! is_array( $config ) || ! is_int( $config['revision'] ?? null ) || $config['revision'] < 1 || ! is_array( $config['rules'] ?? null ) || ! array_is_list( $config['rules'] ) || count( $config['rules'] ) > 8 || ! is_array( $config['recipients'] ?? null ) || ! array_is_list( $config['recipients'] ) || count( $config['recipients'] ) > 5 || ! self::unchanged( $db ) ) { return $unknown; } foreach ( $config['recipients'] as $recipient ) { if ( ! is_int( $recipient ) || $recipient < 1 ) { return $unknown; } } return array( 'state' => in_array( 'automatic_suppressed', $config['rules'], true ) && $config['recipients'] ? 'configured_delivery_unverified' : 'not_configured', 'revision' => $config['revision'], 'recipients' => count( $config['recipients'] ), ); } /** * Check current site response authority and the additional grants needed to opt in. * * @phpstan-impure Reads current native authority or mutable context. * * @param Scope $scope Current operation scope whose capabilities are rechecked. * @param string $action Supported automatic-mode transition requested by the operator. * * @return bool Whether the supplied action has its required current capabilities. */ public static function authorized( Scope $scope, string $action = 'active' ): bool { return $scope->site_id > 0 && ( 'off' === $action ? $scope->valid_for( 'logged_ai_manage_response' ) : $scope->valid_for_all( array( 'logged_ai_manage_response', 'logged_ai_view_sensitive_context', 'logged_ai_manage_investigations' ) ) ); } /** * Observe a fresh scoped grant decision while sealing an optional dependency check. * * Seal an already-read dependency binding with a fresh native grant decision which cannot write unnoticed. * * @phpstan-impure * * @param Scope $scope Current operation scope whose capabilities are rechecked. * @param string $action Supported automatic-mode transition requested by the operator. * @param \wpdb $db Native database connection selected for this operation. * @param callable|null $context_check Optional callback that rechecks the exact retained dependency context. * * @return bool Whether authorization completes without an observed write or origin change. */ public static function read_only_authorized( Scope $scope, string $action, \wpdb $db, ?callable $context_check = null ): bool { return self::read_only_decision( $db, static fn(): bool => self::authorized( $scope, $action ), $context_check ); } /** * Observe the stored operator grant decision with an optional dependency recheck. * * @param array $state Validated stored automatic-response state. * @param \wpdb $db Native database connection selected for this operation. * @param callable|null $context_check Optional callback that rechecks the exact retained dependency context. * * @return bool Whether the background operator remains authorized within the observed boundary. */ public static function read_only_operator( array $state, \wpdb $db, ?callable $context_check = null ): bool { return self::read_only_decision( $db, static fn(): bool => BackgroundAuthority::allowed( $state['operator_id'], $state['network_id'], $state['site_id'], 'automatic_operator' ), $context_check ); } /** * Observe native queries around a decision and reject unsupported writes or drift. * * @param \wpdb $db Native database connection selected for this operation. * @param callable $decision Native authorization callback evaluated within the observed query boundary. * @param callable|null $context_check Optional callback that rechecks the exact retained dependency context. * * @return bool Whether the decision and optional context check remain valid without observed mutation. */ private static function read_only_decision( \wpdb $db, callable $decision, ?callable $context_check ): bool { if ( ! self::unchanged( $db ) ) { return false; } /** * WordPress query callbacks can change this captured flag. * * @var bool $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 permit only separate Scope or BackgroundAuthority connection setup during the read-only decision. $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'] ) || ( BackgroundAuthority::class === ( $next['class'] ?? '' ) && 'allowed' === $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 { $allowed = ( null === $context_check || true === $context_check() ) && $decision(); } catch ( \Throwable $error ) { $allowed = false; } finally { remove_filter( 'query', $observe, PHP_INT_MAX ); } return $allowed && ! $changed && self::unchanged( $db ); } /** * Run automatic-state work under the ordered privacy and request mutexes. * * @param \wpdb $db Native database connection selected for this operation. * @param callable $work Operation to execute after acquiring the ordered native mutexes. * * @return mixed Work result, or an origin, mutex-acquisition or release-verification error. */ public static function locked( \wpdb $db, callable $work ): mixed { $network = Scope::network(); $site = get_current_blog_id(); $principal = get_current_user_id(); $held = array(); $cleanup = true; if ( ! self::identity( $db, $network, $site ) ) { return self::error( 'Automatic mutation scope is unavailable.' ); } try { foreach ( array( Derived::lock_name(), 'logged_ai_req_' . substr( hash( 'sha256', DB_NAME . ':' . $db->base_prefix ), 0, 36 ) ) as $lock ) { if ( '1' !== (string) $db->get_var( $db->prepare( 'SELECT GET_LOCK(%s,0)', $lock ) ) || '' !== $db->last_error ) { return self::error( 'Automatic response maintenance is busy. Retry shortly.', 'automatic_busy', 409 ); } $held[] = $lock; } if ( ! self::identity( $db, $network, $site ) || get_current_user_id() !== $principal ) { return self::error( 'Automatic mutation origin changed.' ); } $result = $work(); } finally { foreach ( array_reverse( $held ) as $lock ) { try { if ( '1' !== (string) $db->get_var( $db->prepare( 'SELECT RELEASE_LOCK(%s)', $lock ) ) || '' !== $db->last_error ) { $cleanup = false; } } catch ( \Throwable $error ) { $cleanup = false; } } } if ( ! $cleanup ) { return self::error( 'Automatic lock cleanup could not be verified; inspect the saved state before retrying.' ); } return self::unchanged( $db ) ? $result : self::error( 'Automatic mutation origin changed before completion.' ); } /** * Append an allowlisted automatic-response history fact on the supplied connection. * * @param \wpdb $db Native database connection selected for this operation. * @param array $state Validated stored automatic-response state. * @param string $action Allowlisted automatic-response history action to record. * @param string|null $policy Optional policy identifier associated with the history fact. * * @return bool Whether exactly one supported history row was inserted. */ public static function audit( \wpdb $db, array $state, string $action, ?string $policy = null ): bool { if ( ! in_array( $action, array( 'automatic_shadow', 'automatic_active', 'automatic_off', 'automatic_created', 'automatic_suppressed', 'automatic_deactivated', 'automatic_erased' ), true ) ) { return false; } return 1 === $db->insert( RequestsAutomaticSchema::table( 'response_history', $db ), array( 'network_id' => $state['network_id'], 'site_id' => $state['site_id'], 'policy_id' => $policy, 'actor_id' => $state['operator_id'], 'action' => $action, 'happened_at' => time(), 'revision' => $state['revision'], ) ); } /** * Select the fixed operator-visible automatic-state fields. * * @param array $state Validated stored automatic-response state. * * @return array State projection excluding operator identity and dependency secrets. */ public static function projection( array $state ): array { return array_intersect_key( $state, array_flip( array( 'mode', 'revision', 'ttl', 'shadow_started', 'active_started', 'observed_count', 'eligible_count', 'candidate_count', 'protected_count', 'unavailable_count', 'scan_complete', 'last_scan', 'suppression', 'suppressed_at' ) ) ); } /** * Turn the expected automatic generation off and disable its automatic policies. * * An invalid dependency can only turn automation off, never opt in. Called with a server-read expected state. * * @param \wpdb $db Native database connection selected for this operation. * @param array $expected Server-read state revision and generation expected by this mutation. * @param string $reason Allowlisted dependency or privacy invalidation reason. * * @return array|\WP_Error Saved state projection, or a stale-state, locking, storage or receipt error. */ public static function invalidate( \wpdb $db, array $expected, string $reason ): array|\WP_Error { if ( ! in_array( $reason, array( 'dependencies_changed', 'addon_deactivated', 'operator_erased', 'privacy_reduced' ), true ) ) { return self::error( 'Automatic invalidation reason is unavailable.' ); } return self::locked( $db, static function () use ( $db, $expected, $reason ): array|\WP_Error { $transaction = false; try { if ( false === $db->query( 'START TRANSACTION' ) ) { return self::error( 'Automatic invalidation could not start.' ); } $transaction = true; $state = self::read( $db, true ); if ( is_wp_error( $state ) ) { return $state; } if ( $state['revision'] !== $expected['revision'] || $state['generation'] !== $expected['generation'] ) { return self::error( 'Automatic state changed before invalidation.', 'automatic_stale', 409 ); } $next = $state; $next['mode'] = 'off'; $next['revision']++; $next['generation'] = bin2hex( random_bytes( 16 ) ); $next['changed_at'] = time(); $next['suppression'] = $reason; $next['suppressed_at'] = time(); $next['scan_complete'] = 0; if ( 1 !== $db->update( RequestsAutomaticSchema::table( 'automatic_state', $db ), $next, array( 'network_id' => $state['network_id'], 'site_id' => $state['site_id'], 'revision' => $state['revision'], 'generation' => $state['generation'], ) ) ) { return self::error( 'Automatic invalidation state could not be saved.' ); } if ( false === $db->query( $db->prepare( "UPDATE %i SET state='disabled',changed_at=%d WHERE network_id=%d AND site_id=%d AND origin='automatic' AND state IN ('active','draft','observe')", RequestsAutomaticSchema::table( 'response_policies', $db ), time(), $state['network_id'], $state['site_id'] ) ) ) { return self::error( 'Automatic policies could not be invalidated.' ); } $receipt = $next; $receipt['operator_id'] = 0; if ( ! self::audit( $db, $receipt, 'automatic_deactivated' ) || ! self::identity( $db, $state['network_id'], $state['site_id'] ) || false === $db->query( 'COMMIT' ) ) { return self::error( 'Automatic invalidation receipt or commit failed.' ); } $transaction = false; return self::projection( $next ); } finally { if ( $transaction ) { $db->query( 'ROLLBACK' ); } } } ); } /** Refuse an obsolete add-on configuration call during a paired replacement. * @param mixed ...$unused Legacy arguments are intentionally not inspected. * @return \WP_Error Matched-pair update instruction; no mode is changed. */ public static function preview( mixed ...$unused ): \WP_Error { return self::error( 'Update Logged.ai Pro to the matching edition build before configuring automatic response.', 'automatic_pair_required', 409 ); } /** Refuse an obsolete add-on confirmation call during a paired replacement. * @param mixed ...$unused Legacy arguments are intentionally not inspected. * @return \WP_Error Matched-pair update instruction; no mode is changed. */ public static function confirm( mixed ...$unused ): \WP_Error { return self::error( 'Update Logged.ai Pro to the matching edition build before configuring automatic response.', 'automatic_pair_required', 409 ); } }