base_prefix . 'logged_ai_network_state'; } /** * Declare the fixed network-state column and index contract. * * @return array Expected schema fields and primary key for migration verification. */ public static function migration_manifest(): array { return array( 'network_state' => array( 'columns' => array( 'network_id' => 'bigint unsigned', 'event_days' => 'smallint unsigned', 'generation' => 'bigint unsigned', ), 'indexes' => array( 'PRIMARY' => array( 'unique' => true, 'columns' => array( 'network_id' ), ), ), ), ); } /** * Declare minimized network privacy and retention event schemas. * * @return array Fixed network-origin definitions and their permitted summary fields. */ public static function definitions(): array { return array( 'privacy.network_retention_pruned' => array( 'event_scope' => 'network', 'category' => 'privacy', 'action' => 'retention_pruned', 'severity' => 'notice', 'object_type' => 'network', 'fields' => array( 'removed_count' => 'count', 'retention_days' => 'count', ), 'diffs' => array(), ), 'privacy.network_identity_erased' => array( 'event_scope' => 'network', 'category' => 'privacy', 'action' => 'identity_erased', 'severity' => 'notice', 'object_type' => 'network', 'fields' => array( 'removed_count' => 'count' ), 'diffs' => array(), ), 'configuration.network_privacy_changed' => array( 'event_scope' => 'network', 'category' => 'configuration', 'action' => 'privacy_changed', 'severity' => 'notice', 'object_type' => 'network', 'fields' => array( 'retention_days' => 'count' ), 'diffs' => array(), ), ); } /** * Create a network-privacy error with the supported response status. * * @param string $code Finite operation-specific error code selected by the caller. * @param string $message Fixed operation-specific message supplied by installed code. * * @return \WP_Error Structured network error with a forbidden or invalid-operation status. */ private static function error( string $code, string $message ): \WP_Error { return new \WP_Error( 'logged_ai_network_' . $code, $message, array( 'status' => 'forbidden' === $code ? 403 : 400 ) ); } /** * Require the current network settings and purge grants plus ready storage. * * @phpstan-impure Reads current native authority or mutable context. * * @param Scope $scope Current operation scope whose capabilities are rechecked. * * @return bool Whether this zero-site network scope may purge evidence. */ private static function purge_allowed( Scope $scope ): bool { return 0 === $scope->site_id && $scope->valid_for( self::CAP ) && $scope->valid_for( self::PURGE_CAP ) && self::ready(); } /** * Check multisite support and the current network-schema migration gate. * * @return bool Whether the network privacy module can use its storage. */ private static function ready(): bool { return is_multisite() && ( ! class_exists( Migrations::class ) || Migrations::ready( 'network' ) ); } /** * Install the network privacy table and verify its required storage engine. * * @return void * * @throws \RuntimeException When the network privacy table does not use the required storage engine. */ 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'; $table = self::table(); dbDelta( "CREATE TABLE $table ( network_id bigint unsigned NOT NULL, event_days smallint unsigned NOT NULL DEFAULT 90, generation bigint unsigned NOT NULL DEFAULT 1, PRIMARY KEY (network_id) ) ENGINE=InnoDB " . $wpdb->get_charset_collate() . ';' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Verify the actual network privacy storage engine after dbDelta; cached metadata is insufficient. if ( 'InnoDB' !== $wpdb->get_var( $wpdb->prepare( 'SELECT ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=%s', $table ) ) ) { throw new \RuntimeException( 'Network privacy schema is unavailable.' ); } } /** * Initialize current-network defaults and schedule retention on its main site. * * Idempotent per-network DML, invoked by verified site initialization. * * @return void * * @throws \RuntimeException When the current network defaults cannot be initialized. */ public static function configure(): void { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ if ( ! is_multisite() ) { return; } $network = Scope::network(); // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Fixed native-prefix network-state table; current network is a prepared integer. Persist current-network privacy defaults without overwriting an existing policy. if ( $network < 1 || false === $wpdb->query( $wpdb->prepare( 'INSERT IGNORE INTO %i (network_id,event_days,generation) VALUES (%d,90,1)', self::table(), $network ) ) ) { throw new \RuntimeException( 'Network privacy defaults could not be initialized.' ); } if ( get_current_blog_id() === (int) get_main_site_id( $network ) && ! wp_next_scheduled( 'logged_ai_network_retention' ) ) { wp_schedule_event( time() + 300, 'hourly', 'logged_ai_network_retention' ); } } /** * Register network retention, protected privacy references, erasure and operator handlers. * * @return void */ public static function boot(): void { add_action( 'logged_ai_network_retention', array( self::class, 'cron' ) ); add_action( 'logged_ai_network_privacy_batch', array( self::class, 'cron' ) ); add_action( 'logged_ai_network_export_worker', static function () { if ( self::ready() && get_current_blog_id() === (int) get_main_site_id( Scope::network() ) ) { Exports::cron( true ); } } ); add_filter( 'wp_privacy_personal_data_exporters', static function ( $exporters ) { if ( self::ready() && Scope::can( 'manage_privacy_options' ) && ! is_wp_error( Scope::current_network( self::CAP ) ) && current_user_can( 'logged_ai_export_network_events' ) ) { $exporters['logged-ai-network'] = array( 'exporter_friendly_name' => __( 'Logged.ai authenticated network evidence reference (current network)', 'logged-ai' ), 'callback' => array( self::class, 'native_exporter' ), ); } return $exporters; } ); add_filter( 'wp_privacy_personal_data_erasers', static function ( $erasers ) { if ( self::ready() && Scope::can( 'manage_privacy_options' ) && ! is_wp_error( Scope::current_network( self::CAP ) ) && ! is_wp_error( Scope::current_network( self::PURGE_CAP ) ) ) { $erasers['logged-ai-network'] = array( 'eraser_friendly_name' => __( 'Logged.ai network activity evidence (current network)', 'logged-ai' ), 'callback' => array( self::class, 'native_eraser' ), ); } return $erasers; } ); add_action( 'admin_post_logged_ai_network_privacy', array( self::class, 'handle_save' ) ); if ( defined( 'WP_CLI' ) && WP_CLI ) { \WP_CLI::add_command( 'logged-ai network', array( self::class, 'cli' ) ); } } /** * Schedule one network cleanup or export invocation on the network main site. * * @param bool $export Whether to schedule the network export worker instead of privacy cleanup. * * @return void */ public static function schedule( bool $export = false ): void { if ( ! self::ready() ) { return; } $main = (int) get_main_site_id( Scope::network() ); if ( $main < 1 ) { return; } $switched = get_current_blog_id() !== $main; if ( $switched ) { switch_to_blog( $main ); } try { $hook = $export ? 'logged_ai_network_export_worker' : 'logged_ai_network_privacy_batch'; if ( ! wp_next_scheduled( $hook ) ) { wp_schedule_single_event( time() + 10, $hook ); } } finally { if ( $switched ) { restore_current_blog(); } } } /** * Read and validate the current network retention policy and export generation. * * @return array|\WP_Error Supported retention and generation values, or a schema/storage error. */ private static function state(): array|\WP_Error { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ if ( ! self::ready() ) { return self::error( 'schema', 'Network privacy is unavailable until its schema is verified.' ); } // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Fixed native-prefix network-state table and bound current-network value. Read current network retention and export generation; cached policy could retain revoked bytes. $row = $wpdb->get_row( $wpdb->prepare( 'SELECT event_days,generation FROM %i WHERE network_id=%d', self::table(), Scope::network() ), ARRAY_A ); if ( ! is_array( $row ) || '' !== $wpdb->last_error || ! ctype_digit( (string) $row['event_days'] ) || (int) $row['event_days'] < 1 || (int) $row['event_days'] > 3650 || ! ctype_digit( (string) $row['generation'] ) || (int) $row['generation'] < 1 ) { return self::error( 'storage', 'Network privacy state is unavailable.' ); } return array( 'event_days' => (int) $row['event_days'], 'generation' => (int) $row['generation'], ); } /** * Read the validated current network export-privacy generation. * * @return int|\WP_Error Current generation, or an error when network state is unavailable. */ public static function generation(): int|\WP_Error { $state = self::state(); return is_wp_error( $state ) ? $state : $state['generation']; } /** * Invalidate network export generations under current network settings authority. * * @param Scope $scope Current operation scope whose capabilities are rechecked. * * @return bool|\WP_Error True after advancing exactly one generation, or an authority/storage error. */ public static function advance_generation( Scope $scope ): bool|\WP_Error { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ if ( 0 !== $scope->site_id || ! $scope->valid_for( self::CAP ) || ! self::ready() ) { return self::error( 'forbidden', 'Network privacy invalidation denied.' ); } // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Fixed native-prefix network-state table; authorized network value is prepared and revision cap is literal. Atomically advance the persisted network privacy generation before erasing source data. $changed = $wpdb->query( $wpdb->prepare( 'UPDATE %i SET generation=generation+1 WHERE network_id=%d AND generation<9223372036854775807', self::table(), $scope->network_id ) ); return 1 === $changed ? true : self::error( 'storage', 'Network export privacy could not be invalidated; erasure must not proceed.' ); } /** * Return network retention and fixed export-cleanup bounds to an authorized scope. * * @param Scope $scope Current operation scope whose capabilities are rechecked. * * @return array|\WP_Error Current network policy and operational limits, or an access/storage error. */ public static function settings( Scope $scope ): array|\WP_Error { if ( 0 !== $scope->site_id || ! $scope->valid_for( self::CAP ) ) { return self::error( 'forbidden', 'Network privacy settings denied.' ); } $state = self::state(); if ( is_wp_error( $state ) ) { return $state; } return array( 'network_id' => $scope->network_id, 'revision' => $state['generation'], 'event_days' => $state['event_days'], 'export_hours' => 24, 'export_history_days' => 30, 'batch_limit' => 500, ); } /** * Save a confirmed network retention revision after invalidating dependent copies. * * @param Scope $scope Current operation scope whose capabilities are rechecked. * @param int $days Requested current-network event retention in days. * @param bool $confirmed Whether the network retention policy was explicitly confirmed. * @param int $expected_revision Stored generation shown when the network policy was reviewed. * * @return array|\WP_Error Current saved settings and export-cleanup status, or an explicit partial/validation error. */ public static function save( Scope $scope, int $days, bool $confirmed, int $expected_revision ): array|\WP_Error { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ $before = self::settings( $scope ); if ( is_wp_error( $before ) ) { return $before; } if ( $expected_revision !== $before['revision'] ) { return self::error( 'stale', 'Network privacy changed since this form was opened. Reload and review the current policy.' ); } if ( $days < 1 || $days > 3650 || ! $confirmed ) { return self::error( 'confirmation', 'Confirm this network retention policy using 1–3650 days.' ); } if ( $before['event_days'] === $days ) { return $before; } $shorter = $days < $before['event_days']; if ( $shorter && ! self::purge_allowed( $scope ) ) { return self::error( 'forbidden', 'Network evidence purge permission is required to shorten retention.' ); } $actor = Store::actor_context(); $alerts = Alerts::invalidate_network( $scope ); if ( is_wp_error( $alerts ) ) { return $alerts; } $invalidated = Exports::invalidate_network( $scope ); if ( is_wp_error( $invalidated ) ) { return $invalidated; } if ( ! $scope->valid_for( self::CAP ) || ( $shorter && ! self::purge_allowed( $scope ) ) ) { return self::error( 'forbidden', 'Network policy permission changed.' ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Commit retention only against the exact prior policy and newly invalidated generation. if ( 1 !== $wpdb->update( self::table(), array( 'event_days' => $days ), array( 'network_id' => $scope->network_id, 'event_days' => $before['event_days'], 'generation' => $expected_revision + 1, ) ) ) { return self::error( 'storage', 'Network policy could not be saved; reload before retrying. Older exports were invalidated.' ); } if ( ! Store::capture( array( 'code' => 'configuration.network_privacy_changed', 'outcome' => 'stored_change', 'actor_context' => $actor, 'object_id' => $scope->network_id, 'payload' => array( 'observed_via' => 'logged_ai_network_privacy', 'facts' => array( 'retention_days' => $days ), ), ) ) ) { return self::error( 'audit', 'Network policy changed, but its audit summary could not be recorded.' ); } self::schedule(); $result = self::settings( $scope ); if ( is_array( $result ) ) { $result['export_cleanup_pending'] = ! $invalidated; } return $result; } /** * Count current-network events and eligibility under the stored retention policy. * * @return array|\WP_Error Event totals, cutoff and batch limit, or an unavailable-state/count error. */ private static function counts(): array|\WP_Error { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ $state = self::state(); if ( is_wp_error( $state ) ) { return $state; } $cutoff = gmdate( 'Y-m-d H:i:s', time() - $state['event_days'] * DAY_IN_SECONDS ); // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Fixed event table; cutoff/network are prepared and site_id=0 is a literal network-origin boundary. Read current network-origin retention counts; cached totals could misreport remaining privacy work. $row = $wpdb->get_row( $wpdb->prepare( 'SELECT COUNT(*) AS total,COALESCE(SUM(occurred_at<%s),0) AS eligible FROM %i WHERE network_id=%d AND site_id=0', $cutoff, Store::table(), Scope::network() ), ARRAY_A ); if ( ! $row || '' !== $wpdb->last_error ) { return self::error( 'storage', 'Network retention counts are unavailable.' ); } return array( 'network_id' => Scope::network(), 'site_id' => 0, 'event_count' => (int) $row['total'], 'eligible_events' => (int) $row['eligible'], 'cutoff_utc' => $cutoff, 'retention_days' => $state['event_days'], 'batch_limit' => 500, ); } /** * Require current network settings access before reading retention counts. * * @param Scope $scope Current operation scope whose capabilities are rechecked. * * @return array|\WP_Error Network retention statistics, or an authority/storage error. */ public static function stats( Scope $scope ): array|\WP_Error { if ( 0 !== $scope->site_id || ! $scope->valid_for( self::CAP ) ) { return self::error( 'forbidden', 'Network retention access denied.' ); } return self::counts(); } /** * Authorize one bounded current-network retention batch. * * @param Scope $scope Current operation scope whose capabilities are rechecked. * @param int $limit Requested batch limit clamped to the operation's supported finite range. * * @return array|\WP_Error Cleanup progress, or a current purge-authority or downstream error. */ public static function prune( Scope $scope, int $limit = 500 ): array|\WP_Error { if ( ! self::purge_allowed( $scope ) ) { return self::error( 'forbidden', 'Network evidence purge permission is required.' ); } return self::prune_current( $limit, Store::actor_context(), $scope ); } /** * Run one network retention batch under the shared derived-data mutation. * * @param int $limit Requested batch limit clamped to the operation's supported finite range. * @param array $actor Captured actor context used only for the minimized retention summary. * @param Scope|null $scope Current operation scope whose capabilities are rechecked. Null selects the internal server adapter. * * @return array|\WP_Error Locked cleanup result, or a mutation-authority/availability error. */ private static function prune_current( int $limit, array $actor, ?Scope $scope = null ): array|\WP_Error { return Derived::with_mutation( static fn() => self::prune_locked( $limit, $actor, $scope ), $scope ? static fn() => $scope->valid_for_all( array( self::CAP, self::PURGE_CAP ) ) : null ); } /** * Reduce linked network context before deleting the selected expired source rows. * * @param int $limit Requested batch limit clamped to the operation's supported finite range. * @param array $actor Captured actor context used only for the minimized retention summary. * @param Scope|null $scope Current operation scope whose capabilities are rechecked. Null selects the internal server adapter. * * @return array|\WP_Error Removed count and bounded completion evidence, or an explicit cleanup/audit error. */ private static function prune_locked( int $limit, array $actor, ?Scope $scope ): array|\WP_Error { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ $before = self::counts(); if ( is_wp_error( $before ) ) { return $before; } // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Fixed event table; current network, retention cutoff and bounded limit are prepared under the held mutation. Lock the current bounded network-origin source batch before reducing its dependent copies. $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT id,uid,request_id FROM %i WHERE network_id=%d AND site_id=0 AND occurred_at<%s ORDER BY occurred_at,id LIMIT %d FOR UPDATE', Store::table(), $before['network_id'], $before['cutoff_utc'], max( 1, min( 500, $limit ) ) ), ARRAY_A ); if ( ! is_array( $rows ) || '' !== $wpdb->last_error ) { return self::error( 'storage', 'Network retention could not load its batch.' ); } if ( $scope && ! self::purge_allowed( $scope ) ) { return self::error( 'forbidden', 'Network evidence purge permission changed.' ); } $context = self::erase_context( $rows, $scope ); if ( is_wp_error( $context ) ) { return $context; } if ( ! $context['done'] ) { self::schedule(); return array( 'removed' => 0, 'remaining' => null, 'done' => false, 'context_cleanup_remaining' => $context['remaining'], ); } $derived = Derived::erase_events( $before['network_id'], 0, $rows, array( 'kind' => 'retention' ) ); if ( is_wp_error( $derived ) ) { return $derived; } if ( ! $derived['done'] ) { self::schedule(); return array( 'removed' => 0, 'derived_removed' => $derived['removed'], 'remaining' => null, 'done' => false, 'derived_cleanup_pending' => true, ); } if ( $scope && ! self::purge_allowed( $scope ) ) { return self::error( 'forbidden', 'Network evidence purge permission changed.' ); } $count = self::delete_rows( $rows, $before['network_id'] ); if ( false === $count ) { return self::error( 'storage', 'Network retention failed; retry is safe.' ); } if ( $count && ! Store::capture( array( 'code' => 'privacy.network_retention_pruned', 'outcome' => 'completed', 'actor_context' => $actor, 'object_id' => $before['network_id'], 'payload' => array( 'observed_via' => 'logged_ai_network_retention', 'facts' => array( 'removed_count' => $count, 'retention_days' => $before['retention_days'], ), ), ) ) ) { return self::error( 'audit', 'Expired network events were removed, but their summary failed.' ); } $after = self::current_remaining( $before['network_id'], $before['cutoff_utc'] ); if ( is_wp_error( $after ) ) { return $after; } if ( ! $after['done'] ) { self::schedule(); } return array( 'removed' => $count, 'cutoff_utc' => $before['cutoff_utc'], 'network_id' => $before['network_id'], 'site_id' => 0, ) + $after; } /** * Read a bounded current locking selection to establish remaining network work. * * Bound completion reads to current locked IDs; never infer completion from caller MVCC history. * * @param int $network Captured network identifier. * @param string|null $cutoff Retention cutoff, or null for identity erasure. * @param int|null $user User whose remaining identities must be checked. * * @return array|\WP_Error Completion and a count below 501, or a lower-bound sentinel or read error. */ private static function current_remaining( int $network, ?string $cutoff = null, ?int $user = null ): array|\WP_Error { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ if ( ! Derived::mutation_allowed() ) { return self::error( 'forbidden', 'Network privacy completion authority or scope changed.' ); } $ids = null !== $cutoff // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Lock authoritative remaining IDs; cached absence cannot prove privacy completion. The complete query binds every table and scope/retention or identity value. ? $wpdb->get_col( $wpdb->prepare( 'SELECT id FROM %i WHERE network_id=%d AND site_id=0 AND occurred_at<%s ORDER BY id LIMIT 501 FOR UPDATE', Store::table(), $network, $cutoff ) ) // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Lock authoritative remaining IDs; cached absence cannot prove privacy completion. The complete query binds every table and scope/retention or identity value. : $wpdb->get_col( $wpdb->prepare( "SELECT id FROM %i WHERE network_id=%d AND site_id=0 AND (actor_id=%d OR subject_id=%d OR (object_type='user' AND object_id=%d)) ORDER BY id LIMIT 501 FOR UPDATE", Store::table(), $network, $user, $user, $user ) ); /** * Validate current event IDs before reporting network privacy completion from the untyped wpdb adapter. * * @var mixed $ids */ if ( '' !== $wpdb->last_error || ! is_array( $ids ) ) { return self::error( 'storage', 'Current network privacy completion could not be verified. Retry without assuming completion.' ); } if ( ! Derived::mutation_allowed() ) { return self::error( 'forbidden', 'Network privacy completion authority or scope changed.' ); } $count = count( $ids ); return array( 'remaining' => $count < 501 ? $count : null, 'remaining_at_least' => $count, 'done' => 0 === $count, ); } /** * Remove network alert facts before reducing linked request context. * * @param array $rows Selected source event identities whose dependent context has been or must be reduced. * @param Scope|null $scope Current operation scope whose capabilities are rechecked. Null selects the internal server adapter. * * @return array|\WP_Error Dependent cleanup progress, an empty no-op, or an explicit adapter error. */ private static function erase_context( array $rows, ?Scope $scope ): array|\WP_Error { if ( ! $rows ) { return array( 'done' => true, 'remaining' => 0, ); } $alerts = Alerts::erase_network_facts( array_map( 'intval', array_column( $rows, 'id' ) ), $scope ); if ( is_wp_error( $alerts ) || ! $alerts['done'] ) { return $alerts; } $uids = array_values( array_unique( array_column( $rows, 'request_id' ) ) ); return $scope ? Requests::erase_network_event_context( $scope, $uids ) : Requests::erase_network_event_context_for_retention( $uids ); } /** * Delete only the selected source IDs within their network-origin boundary. * * @param array $rows Selected source event identities whose dependent context has been or must be reduced. * @param int $network Selected network identifier. * * @return int|false Database-reported deleted row count, or false when deletion fails. */ private static function delete_rows( array $rows, int $network ): int|false { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ if ( ! $rows ) { return 0; } $ids = array_map( 'intval', array_column( $rows, 'id' ) ); $marks = implode( ',', array_fill( 0, count( $ids ), '%d' ) ); // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- Fixed event table; exact current-network selected IDs use a generated integer placeholder list. The Store table is fixed; marks is generated %d slots for bounded selected IDs cast to integers. Network and each ID are separate prepared arguments; site_id=0 is literal. Delete actual selected network-origin sources only after dependent link cleanup. One network %d and N generated ID %d placeholders receive the network followed by exactly N selected IDs cast to integers. return $wpdb->query( $wpdb->prepare( "DELETE FROM %i WHERE network_id=%d AND site_id=0 AND id IN ($marks)", Store::table(), $network, ...$ids ) ); } /** * Run bounded network retention and export work only on the current main site. * * @return void */ public static function cron(): void { if ( ! self::ready() || get_current_blog_id() !== (int) get_main_site_id( Scope::network() ) ) { return; } $result = self::prune_current( 500, array( 'id' => 0, 'channel' => 'cron', ) ); if ( class_exists( Health::class ) ) { Health::record( 'network_retention', $result ); } Exports::cron( true ); } /** * Require privacy-tool access and resolve the current network settings scope. * * @return Scope|\WP_Error Authorized current-network scope, or an unavailable/access error. */ private static function privacy_scope(): Scope|\WP_Error { if ( ! self::ready() || ! Scope::can( 'manage_privacy_options' ) ) { return self::error( 'forbidden', 'Network privacy access denied.' ); } return Scope::current_network( self::CAP ); } /** * Return a protected administrator reference through the WordPress network exporter. * * @param string $email Account email used transiently to resolve the current registered user. * @param int $page WordPress privacy page argument; the protected reference result completes in one response. * * @return array|\WP_Error One reference without evidence or identity payloads, an empty result, or an access error. */ public static function native_exporter( string $email, int $page = 1 ): array|\WP_Error { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- Preserve the registered native hook/callback positional signature; this adapter intentionally does not consume or retain the unused supplied field. $scope = self::privacy_scope(); if ( is_wp_error( $scope ) ) { return $scope; } $user = get_user_by( 'email', $email ); if ( ! $user ) { return array( 'data' => array(), 'done' => true, ); } $reference = Exports::personal_reference( $scope, (int) $user->ID ); if ( is_wp_error( $reference ) ) { return $reference; } // Core's plaintext ZIP receives no evidence, names, IDs, or grant tokens. return array( 'data' => array( array( 'group_id' => 'logged-ai-network-reference', 'group_label' => 'Logged.ai private network evidence', 'item_id' => 'logged-ai-network-reference', 'data' => array( array( 'name' => 'Private evidence access', 'value' => $reference['message'], ), array( 'name' => 'Administrator review', 'value' => esc_url_raw( $reference['url'] ), ), ), ), ), 'done' => true, ); } /** * Adapt current-network account erasure to the WordPress privacy callback format. * * @param string $email Account email used transiently to resolve the current registered user. * @param int $page Native privacy callback page argument; this adapter performs one bounded step. * * @return array|\WP_Error Native progress flags and scope guidance, or an authority/cleanup error. */ public static function native_eraser( string $email, int $page = 1 ): array|\WP_Error { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- Preserve the registered native hook/callback positional signature; this adapter intentionally does not consume or retain the unused supplied field. $scope = self::privacy_scope(); if ( is_wp_error( $scope ) ) { return $scope; } if ( ! self::purge_allowed( $scope ) ) { return self::error( 'forbidden', 'Network evidence purge permission is required.' ); } $user = get_user_by( 'email', $email ); if ( ! $user ) { return array( 'items_removed' => false, 'items_retained' => false, 'messages' => array(), 'done' => true, ); } $result = self::erase_user( $scope, (int) $user->ID ); if ( is_wp_error( $result ) ) { return $result; } return array( 'items_removed' => $result['removed'] > 0 || ( $result['derived_removed'] ?? 0 ) > 0, 'items_retained' => false, 'messages' => array( 'Matching network-origin evidence and linked assessments in the current network are removed in bounded steps. Sites and other networks require separately authorized handling. Previously downloaded bytes cannot be recalled.' ), 'done' => $result['done'], ); } /** * Run current-network identity erasure under the shared authorized mutation. * * @param Scope $scope Current operation scope whose capabilities are rechecked. * @param int $user Positive current or former user identifier selected for privacy handling. * @param int $limit Requested batch limit clamped to the operation's supported finite range. * * @return array|\WP_Error Bounded cleanup progress, or a current-authority/adapter error. */ public static function erase_user( Scope $scope, int $user, int $limit = 100 ): array|\WP_Error { return Derived::with_mutation( static fn() => self::erase_user_locked( $scope, $user, $limit ), static fn() => $scope->valid_for_all( array( self::CAP, self::PURGE_CAP, 'manage_privacy_options' ) ) ); } /** * Invalidate network copies and dependent context before deleting matching identity rows. * * @param Scope $scope Current operation scope whose capabilities are rechecked. * @param int $user Positive current or former user identifier selected for privacy handling. * @param int $limit Requested batch limit clamped to the operation's supported finite range. * * @return array|\WP_Error Source and derived cleanup progress, or an explicit incomplete/error result. */ private static function erase_user_locked( Scope $scope, int $user, int $limit ): array|\WP_Error { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ if ( ! self::purge_allowed( $scope ) || ! Scope::can( 'manage_privacy_options' ) || $user < 1 ) { return self::error( 'forbidden', 'Network privacy erasure requires privacy and network purge permissions.' ); } $alerts = Alerts::invalidate_network( $scope, $user ); if ( is_wp_error( $alerts ) ) { return $alerts; } if ( ! $alerts ) { return array( 'removed' => 0, 'remaining' => null, 'done' => false, 'alert_cleanup_pending' => true, ); } $clean = Exports::invalidate_network( $scope, $user ); if ( is_wp_error( $clean ) ) { return $clean; } if ( ! $clean ) { return array( 'removed' => 0, 'remaining' => null, 'done' => false, 'export_cleanup_pending' => true, ); } // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Fixed event table and prepared network/user predicate; the typed limit is clamped to1..500 and the read remains locking. The Store table is fixed; where is fully prepared current-network user-identity SQL. The typed integer limit is clamped to 1..500 before concatenation; all other syntax is literal and the read remains locking. Lock the current personal network-origin batch before deleting its dependent identities. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT id,uid,request_id FROM %i WHERE network_id=%d AND site_id=0 AND (actor_id=%d OR subject_id=%d OR (object_type='user' AND object_id=%d)) ORDER BY id LIMIT %d FOR UPDATE", Store::table(), $scope->network_id, $user, $user, $user, max( 1, min( 500, $limit ) ) ), ARRAY_A ); /** * Validate network event references before running their erasure batch from the untyped wpdb adapter. * * @var mixed $rows */ if ( ! is_array( $rows ) || '' !== $wpdb->last_error ) { return self::error( 'storage', 'Network erasure could not load its batch.' ); } if ( ! self::purge_allowed( $scope ) || ! Scope::can( 'manage_privacy_options' ) ) { return self::error( 'forbidden', 'Network privacy erasure permission changed.' ); } $context = self::erase_context( $rows, $scope ); if ( is_wp_error( $context ) ) { return $context; } if ( ! $context['done'] ) { return array( 'removed' => 0, 'remaining' => null, 'done' => false, 'context_cleanup_remaining' => $context['remaining'], ); } $derived = Derived::erase_events( $scope->network_id, 0, $rows, array( 'kind' => 'user', 'subject' => $user, ) ); if ( is_wp_error( $derived ) ) { return $derived; } if ( ! $derived['done'] ) { return array( 'removed' => 0, 'derived_removed' => $derived['removed'], 'remaining' => null, 'done' => false, 'derived_cleanup_pending' => true, ); } if ( ! self::purge_allowed( $scope ) || ! Scope::can( 'manage_privacy_options' ) ) { return self::error( 'forbidden', 'Network privacy erasure permission changed.' ); } $removed = self::delete_rows( $rows, $scope->network_id ); if ( false === $removed ) { return self::error( 'storage', 'Network erasure failed; retry is safe.' ); } // Never recreate the erased identity, including when the caller erases self. if ( $removed && ! Store::capture( array( 'code' => 'privacy.network_identity_erased', 'outcome' => 'completed', 'actor_context' => array( 'id' => 0, 'channel' => Store::channel(), ), 'object_id' => $scope->network_id, 'payload' => array( 'observed_via' => 'logged_ai_network_privacy', 'facts' => array( 'removed_count' => $removed ), ), ) ) ) { return self::error( 'audit', 'Network identity records were removed, but their summary failed.' ); } $remaining = self::current_remaining( $scope->network_id, null, $user ); if ( is_wp_error( $remaining ) ) { return $remaining; } return array( 'removed' => $removed, 'derived_removed' => $derived['removed'], 'network_id' => $scope->network_id, 'site_id' => 0, ) + $remaining; } /** * Handle the nonce-bound POST for a confirmed current-network retention revision. * * @return void */ public static function handle_save(): void { $request_form = \LoggedAI\AdminInput::form(); $scope = Scope::current_network( self::CAP ); if ( is_wp_error( $scope ) || 'POST' !== \LoggedAI\AdminInput::method() ) { wp_die( esc_html__( 'Network privacy access denied.', 'logged-ai' ), '', array( 'response' => 403 ) ); } check_admin_referer( 'logged_ai_network_privacy' ); $days = isset( $request_form['event_days'] ) && is_string( $request_form['event_days'] ) && ctype_digit( $request_form['event_days'] ) ? (int) $request_form['event_days'] : 0; $revision = isset( $request_form['revision'] ) && is_string( $request_form['revision'] ) && ctype_digit( $request_form['revision'] ) ? (int) $request_form['revision'] : 0; $result = self::save( $scope, $days, isset( $request_form['confirm'] ) && 'yes' === $request_form['confirm'], $revision ); if ( is_wp_error( $result ) ) { wp_die( esc_html( PresentationTranslations::text( $result->get_error_message() ) ), '', array( 'response' => (int) ( $result->get_error_data()['status'] ?? 400 ) ) ); } wp_safe_redirect( Scope::network_admin_url( 'admin.php?page=logged-ai-network-privacy' ) ); exit; } /** * Render authorized network privacy settings, bounded-operation guidance and confirmation. * * @return void */ public static function page(): void { $scope = Scope::current_network( self::CAP ); $settings = is_wp_error( $scope ) ? $scope : self::settings( $scope ); if ( is_wp_error( $settings ) ) { wp_die( esc_html( PresentationTranslations::text( $settings->get_error_message() ) ), '', array( 'response' => 403 ) ); } $stats = self::stats( $scope ); echo ( '
' . esc_html__( 'This policy covers network-origin events in this network only. Each site retains its separate policy. Network facts contain minimized IDs and explicit redacted changes; IP context is unavailable.', 'logged-ai' ) . '
' ); if ( is_wp_error( $stats ) ) { echo ( '' . ( esc_html( PresentationTranslations::text( $stats->get_error_message() ) ) ) . '
' ); } else { echo ( '' . ( esc_html( sprintf( /* translators: 1: Value shown in this message. 2: Value shown in this message. */ __( '%1$s events; %2$s eligible for removal under the current policy.', 'logged-ai' ), $stats['event_count'], $stats['eligible_events'] ) ) ) . '
' ); } if ( ! self::purge_allowed( $scope ) ) { echo ( '' . esc_html__( 'Your network settings permission allows longer retention. Shortening retention and manual erasure require separate network evidence purge permission.', 'logged-ai' ) . '
' ); } echo '' . '' . esc_html__( 'Private exports expire after 24 hours; purged operational export history expires after 30 days. WordPress privacy tools offer a protected administrator reference and current-network erasure to authorized network administrators. They do not combine site evidence or other networks. These defaults are product settings, not a legal compliance determination.', 'logged-ai' ) . '
' . '