1, 'event_days' => 90, 'request_days' => 7, 'request_group_days' => 30, 'policy_history_days' => 30, 'exact_ip_days' => 14, 'diagnostic_days' => 7, 'ip_mode' => 'masked', ); } /** * Load merged site settings and disable address context when validation fails. * * @return array Validated merged settings or the documented conservative fallback. */ public static function get(): array { $settings = self::current(); if ( is_wp_error( $settings ) ) { $settings = self::defaults(); $settings['ip_mode'] = 'disabled'; } return $settings; } /** * Read current committed policy without the request-local option cache. * * @return array|\WP_Error Current valid policy, or an error that pauses retention. */ public static function current(): array|\WP_Error { $settings = self::retention_preview(); if ( is_wp_error( $settings ) ) { Store::degraded( 'settings_invalid' ); } return $settings; } /** * Read bounded persisted retention metadata without repairing settings or recording health. * * Bounded current metadata for a CLI retention preview; never records health or repairs settings. * * @return array|\WP_Error Current valid settings/defaults, or an error requiring explicit repair. */ public static function retention_preview(): array|\WP_Error { global $wpdb; $db = $wpdb; $origin = Derived::native_context( $db ); $handle = $db->__get( 'dbh' ); $statement = null; $rows = array(); try { if ( ! $handle instanceof \mysqli ) { return new \WP_Error( 'settings_preview', 'Current retention settings could not be read without repair.' ); } $thread = $handle->thread_id; // Native prepared current read: no option cache, repeatable-read snapshot, query callbacks or caller transaction ownership changes. $sql = $db->remove_placeholder_escape( $db->prepare( 'SELECT CASE WHEN OCTET_LENGTH(option_value)<=16384 THEN option_value ELSE NULL END AS value FROM %i WHERE option_name=%s LIMIT 2 FOR UPDATE', $db->options, 'logged_ai_settings' ) ); if ( Derived::native_context( $db ) !== $origin || $db->__get( 'dbh' ) !== $handle || $handle->thread_id !== $thread ) { return new \WP_Error( 'settings_preview', 'Current retention settings could not be read without repair.' ); } $statement = $handle->prepare( $sql ); if ( ! $statement instanceof \mysqli_stmt || ! $statement->execute() ) { return new \WP_Error( 'settings_preview', 'Current retention settings could not be read without repair.' ); } $value = null; if ( ! $statement->bind_result( $value ) ) { return new \WP_Error( 'settings_preview', 'Current retention settings could not be read without repair.' ); } while ( true === ( $fetched = $statement->fetch() ) ) { $rows[] = array( 'value' => $value ); } if ( false === $fetched || count( $rows ) > 1 ) { return new \WP_Error( 'settings_preview', 'Current retention settings could not be read without repair.' ); } } catch ( \Throwable $error ) { return new \WP_Error( 'settings_preview', 'Current retention settings could not be read without repair.' ); } finally { if ( $statement instanceof \mysqli_stmt ) { $statement->close(); } } if ( array() === $rows ) { return self::defaults(); } $raw = $rows[0]['value'] ?? null; if ( ! is_string( $raw ) || ! is_serialized( $raw ) ) { return new \WP_Error( 'settings_preview', 'Repair invalid retention settings before requesting a preview.' ); } // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize -- Decode the bounded native serialized privacy option with classes disabled; the following settings shape checks reject unreadable values. Suppress parser warnings so stored bytes cannot leak into output. $stored = @unserialize( $raw, array( 'allowed_classes' => false, 'max_depth' => 8, ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize -- Reject trailing or noncanonical serialized bytes in the bounded native privacy option. if ( ! is_array( $stored ) || serialize( $stored ) !== $raw ) { return new \WP_Error( 'settings_preview', 'Repair invalid retention settings before requesting a preview.' ); } $settings = array_merge( self::defaults(), $stored ); return is_wp_error( self::validate( $settings ) ) ? new \WP_Error( 'settings_preview', 'Repair invalid retention settings before requesting a preview.' ) : $settings; } /** * Validate the exact settings key set, address mode and integer bounds. * * @param array $settings Complete site privacy and retention settings map. * * @return bool|\WP_Error True for supported settings, or a specific key/value error. */ private static function validate( array $settings ): bool|\WP_Error { if ( array_diff( array_keys( $settings ), array_keys( self::defaults() ) ) || array_diff( array_keys( self::defaults() ), array_keys( $settings ) ) ) { return new \WP_Error( 'settings', 'Unknown or missing setting.' ); } foreach ( self::defaults() as $key => $default ) { if ( 'ip_mode' === $key ) { if ( ! in_array( $settings[ $key ], array( 'masked', 'full', 'disabled' ), true ) ) { return new \WP_Error( 'settings', 'Choose a supported IP mode.' ); } } elseif ( ! is_int( $settings[ $key ] ) || $settings[ $key ] < 1 || $settings[ $key ] > ( 'revision' === $key ? 2147483647 : 3650 ) ) { return new \WP_Error( 'settings', 'Retention must be a whole number from 1 to 3650 days.' ); } } return true; } /** * Resolve guarded current settings with an exact raw binding for supported repair. * * Public get() defaults remain available, with exact raw binding for repair. * * @param OptionMutation $guard Current option mutation guard owning the saved-value binding and authority. * @param bool $metadata Whether to include raw binding and repair status with the resolved configuration. * * @return array|\WP_Error Current configuration or configuration/binding/repair metadata, or a guard error. */ private static function mutation_value( OptionMutation $guard, bool $metadata = false ): array|\WP_Error { $value = $guard->value(); $repair = false; if ( is_wp_error( $value ) && 'config_bounds' !== $value->get_error_code() ) { return $value; } if ( null === $value ) { $config = self::defaults(); } elseif ( is_wp_error( $value ) ) { $config = self::defaults(); $config['ip_mode'] = 'disabled'; $repair = true; } elseif ( ! is_array( $value ) ) { $config = self::defaults(); $config['ip_mode'] = 'disabled'; $repair = true; } else { $config = array_merge( self::defaults(), $value ); if ( is_wp_error( self::validate( $config ) ) ) { $config = self::defaults(); $config['ip_mode'] = 'disabled'; $repair = true; } } return $metadata ? array( 'config' => $config, 'binding' => $guard->fingerprint(), 'repair' => $repair, ) : $config; } /** * Save one reviewed site privacy revision after required confirmation and copy invalidation. * * @param Scope $scope Current operation scope whose native grants are checked. * @param array $input Complete validated settings map carrying the exact previously reviewed revision. * @param bool $confirm_shorter Whether deletion of older records under shorter retention was explicitly confirmed. * * @return array|\WP_Error Verified saved settings, or a conflict, authority, storage or partial-audit error. */ public static function save( Scope $scope, array $input, bool $confirm_shorter = false ): array|\WP_Error { return Derived::with_mutation( static fn() => self::save_locked( $scope, $input, $confirm_shorter ), static fn(): bool => $scope->valid_for( 'logged_ai_manage_settings' ) ); } /** * Save policy while retention and request downloads hold the same privacy mutex. * * @param Scope $scope Current settings authority. * @param array $input Reviewed policy and revision. * @param bool $confirm_shorter Explicit confirmation of shorter retention. * @return array|\WP_Error Verified saved policy or a retryable error. */ private static function save_locked( Scope $scope, array $input, bool $confirm_shorter ): array|\WP_Error { require_once __DIR__ . '/class-optionmutation.php'; return OptionMutation::run( $scope, 'settings', static function ( OptionMutation $guard ) use ( $scope, $input, $confirm_shorter ) { $valid = self::validate( $input ); if ( is_wp_error( $valid ) ) { return $valid; } $actor = $guard->read( static fn() => Store::actor_context() ); if ( is_wp_error( $actor ) ) { return $actor; } $snapshot = $guard->read( static fn() => self::mutation_value( $guard, true ) ); if ( is_wp_error( $snapshot ) ) { return $snapshot; } $previous = $snapshot['config']; if ( $input['revision'] !== $previous['revision'] ) { return new \WP_Error( 'conflict', 'Settings changed since this form was opened. Reload and review them.', array( 'status' => 409 ) ); } $changed = array(); $shorter = false; foreach ( $previous as $key => $value ) { if ( 'revision' === $key || $value === $input[ $key ] ) { continue; } if ( 'ip_mode' !== $key && $input[ $key ] < $value ) { $shorter = true; if ( ! $confirm_shorter ) { return new \WP_Error( 'confirmation', 'Confirm removal of older records before shortening retention.' ); } } $changed[] = $key; } if ( $shorter && ! $guard->require_purge() ) { return new \WP_Error( 'forbidden', 'Evidence purge permission is required to shorten retention.', array( 'status' => 403 ) ); } if ( ! $changed && ! $snapshot['repair'] ) { return $previous; } if ( ! $changed ) { $changed = array_values( array_diff( array_keys( self::defaults() ), array( 'revision' ) ) ); } if ( $input['revision'] >= 2147483647 ) { return new \WP_Error( 'config', 'Privacy settings revision capacity was reached.' ); } $binding = static function () use ( $guard, $previous, $snapshot ): bool { return self::mutation_value( $guard ) === $previous && $guard->fingerprint() === $snapshot['binding']; }; $alerts = $guard->write( static fn() => Alerts::invalidate_site( $scope ), $binding ); if ( is_wp_error( $alerts ) ) { return $alerts; } $invalidated = $guard->write( static fn() => Exports::invalidate_site( $scope ), $binding ); if ( is_wp_error( $invalidated ) ) { return $invalidated; } $input['revision']++; $saved = $guard->update( $input, $binding ); if ( is_wp_error( $saved ) ) { return $saved; } if ( ! $saved ) { return new \WP_Error( 'storage', 'Settings could not be saved.' ); } $verified = $guard->read( static fn() => self::mutation_value( $guard ) === $input ? true : new \WP_Error( 'storage', 'Saved privacy settings could not be verified. Reload their current state.' ), false ); if ( is_wp_error( $verified ) ) { return $verified; } $scheduled = $guard->write( static fn() => Privacy::schedule() ); if ( is_wp_error( $scheduled ) ) { return $scheduled; } $audited = $guard->write( static fn() => Store::capture( array( 'code' => 'configuration.privacy_changed', 'actor_context' => $actor, 'outcome' => 'stored_change', 'object_id' => $scope->site_id, 'payload' => array( 'observed_via' => 'logged_ai_settings', 'facts' => array( 'changed_fields' => $changed ), ), ) ) ); if ( is_wp_error( $audited ) ) { return $audited; } if ( ! $audited ) { return new \WP_Error( 'audit_failed', 'Settings saved, but the audit record failed. Check System status.' ); } return $guard->read( static fn() => self::mutation_value( $guard ) === $input ? $input : new \WP_Error( 'stale', 'Saved privacy settings changed during auditing.' ) ); }, static fn( OptionMutation $guard, array $result ): bool => self::mutation_value( $guard ) === $result ); } /** * Register site privacy navigation, mutation handlers and read-only configuration CLI access. * * @return void */ public static function boot(): void { add_action( 'admin_menu', static function () { add_submenu_page( 'logged-ai', __( 'Your data. Your rules.', 'logged-ai' ), __( 'Privacy & retention', 'logged-ai' ), 'logged_ai_manage_settings', 'logged-ai-settings', array( self::class, 'page' ) ); }, 20 ); add_action( 'admin_post_logged_ai_settings', array( self::class, 'submit' ) ); add_action( 'admin_post_logged_ai_prune', array( self::class, 'submit' ) ); add_action( 'admin_post_logged_ai_uninstall_choice', array( self::class, 'submit' ) ); if ( defined( 'WP_CLI' ) && WP_CLI ) { \WP_CLI::add_command( 'logged-ai config', array( self::class, 'cli' ) ); } } /** * Check the scope's current installation-wide data-purge authority. * * An installation-wide deletion choice belongs to the installation's main network. * * @phpstan-impure * * @param Scope $scope Current operation scope whose native grants are checked. * * @return bool Whether this principal and scope may change the installation deletion preference. */ public static function can_change_uninstall( Scope $scope ): bool { return $scope->valid_for_installation_purge(); } /** * Read the guarded deletion preference and its exact raw persistence binding. * * Current raw choice binding, including its exact stored representation. * * @param OptionMutation $guard Current option mutation guard owning the saved-value binding and authority. * * @return array|\WP_Error Boolean preference with binding metadata, or a guarded-read error. */ private static function mutation_choice( OptionMutation $guard ): array|\WP_Error { $value = $guard->value(); return is_wp_error( $value ) ? $value : array( 'enabled' => (bool) $value, 'binding' => $guard->fingerprint(), ); } /** * Save and verify the explicitly confirmed installation-wide deletion preference. * * @param Scope $scope Current operation scope whose native grants are checked. * @param bool $enabled Requested installation-wide deletion preference. * @param bool $confirmed Whether installation-wide deletion on uninstall was explicitly acknowledged. * * @return bool|\WP_Error True after a verified unchanged or saved choice, or an authority/storage/audit error. */ public static function save_uninstall_choice( Scope $scope, bool $enabled, bool $confirmed = false ): bool|\WP_Error { require_once __DIR__ . '/class-optionmutation.php'; return OptionMutation::run( $scope, 'uninstall_choice', static function ( OptionMutation $guard ) use ( $scope, $enabled, $confirmed ) { $previous = $guard->read( static fn() => self::mutation_choice( $guard ) ); if ( is_wp_error( $previous ) ) { return $previous; } if ( $previous['enabled'] === $enabled ) { return true; } if ( $enabled && ! $confirmed ) { return new \WP_Error( 'confirmation', 'Explicitly confirm deletion of all plugin data on uninstall.' ); } $actor = $guard->read( static fn() => Store::actor_context() ); if ( is_wp_error( $actor ) ) { return $actor; } $saved = $guard->update( $enabled, static fn() => self::mutation_choice( $guard ) === $previous ); if ( is_wp_error( $saved ) ) { return $saved; } if ( ! $saved ) { return new \WP_Error( 'storage', 'Uninstall choice could not be saved. Reload its current state.' ); } $verify = static function () use ( $guard, $enabled ) { $actual = self::mutation_choice( $guard ); return is_array( $actual ) && $actual['enabled'] === $enabled ? true : new \WP_Error( 'stale', 'Uninstall choice may have been saved, but its current value could not be verified. Reload before retrying.' ); }; $verified = $guard->read( $verify, false ); if ( is_wp_error( $verified ) ) { return $verified; } $audited = $guard->write( static fn() => Store::capture( array( 'code' => 'configuration.uninstall_choice_changed', 'actor_context' => $actor, 'outcome' => 'stored_change', 'object_id' => $scope->site_id, 'payload' => array( 'observed_via' => 'logged_ai_uninstall_choice', 'facts' => array( 'enabled' => $enabled ), ), ) ) ); if ( is_wp_error( $audited ) ) { return $audited; } if ( ! $audited ) { return new \WP_Error( 'audit_failed', 'Uninstall choice saved, but its audit summary failed.' ); } return $guard->read( $verify ); }, static function ( OptionMutation $guard, bool $result ) use ( $enabled ): bool { $actual = self::mutation_choice( $guard ); return $result && is_array( $actual ) && $actual['enabled'] === $enabled; } ); } /** * Print scoped privacy configuration and the installation deletion preference. * * @param array $args Positional WP-CLI command arguments. * @param array $assoc Named WP-CLI command flags or filters. * * @return void */ public static function cli( array $args, array $assoc ): void { $scope = Scope::current( 'logged_ai_manage_settings' ); if ( is_wp_error( $scope ) || array() !== $assoc || 'get' !== ( $args[0] ?? 'get' ) ) { \WP_CLI::error( __( 'Use logged-ai config get --user= --url=. Settings changes are available in Privacy & retention.', 'logged-ai' ) ); } \WP_CLI::line( wp_json_encode( array( 'network_id' => $scope->network_id, 'site_id' => $scope->site_id, 'settings' => self::get(), 'delete_all_on_uninstall' => self::delete_all(), ), JSON_PRETTY_PRINT ) ); } /** * Dispatch nonce-bound settings, prune and uninstall-choice forms after scoped authorization. * * @return void */ public static function submit(): void { $request_form = \LoggedAI\AdminInput::form(); $scope = Scope::current( 'logged_ai_manage_settings' ); if ( is_wp_error( $scope ) || 'POST' !== \LoggedAI\AdminInput::method() ) { wp_die( esc_html__( 'Settings access denied.', 'logged-ai' ), '', array( 'response' => 403 ) ); } $action = isset( $request_form['action'] ) && is_string( $request_form['action'] ) ? $request_form['action'] : ''; check_admin_referer( $action ); if ( 'logged_ai_prune' === $action ) { $result = isset( $request_form['confirm'] ) && '1' === $request_form['confirm'] ? Privacy::prune( $scope ) : new \WP_Error( 'confirmation', 'Confirm this deletion first.' ); } elseif ( 'logged_ai_uninstall_choice' === $action ) { $enabled = isset( $request_form['delete_all'] ) && '1' === $request_form['delete_all']; // The separate, explicitly labelled checkbox is the browser opt-in. $result = self::save_uninstall_choice( $scope, $enabled, $enabled ); } else { $input = array(); foreach ( self::defaults() as $key => $value ) { $raw = $request_form[ $key ] ?? null; if ( ! is_string( $raw ) || ( 'ip_mode' !== $key && ! preg_match( '/\A[0-9]{1,10}\z/D', $raw ) ) ) { wp_die( esc_html__( 'Invalid settings form.', 'logged-ai' ), '', array( 'response' => 400 ) ); } $input[ $key ] = 'ip_mode' === $key ? $raw : (int) $raw; } $result = self::save( $scope, $input, isset( $request_form['confirm_shorter'] ) && '1' === $request_form['confirm_shorter'] ); } if ( is_wp_error( $result ) ) { wp_die( esc_html( PresentationTranslations::text( $result->get_error_message() ) ), '', array( 'response' => (int) ( $result->get_error_data()['status'] ?? 400 ), 'back_link' => true, ) ); } wp_safe_redirect( admin_url( 'admin.php?page=logged-ai-settings&saved=1' ) ); exit; } /** * Render current privacy settings, bounded cleanup controls and explicit uninstall consent. * * @return void */ public static function page(): void { $request_query = \LoggedAI\AdminInput::query(); $scope = Scope::current( 'logged_ai_manage_settings' ); if ( is_wp_error( $scope ) ) { wp_die( esc_html__( 'Settings access denied.', 'logged-ai' ), '', array( 'response' => 403 ) ); } nocache_headers(); $settings = self::get(); $can_purge = $scope->valid_for( 'logged_ai_purge_data' ); $status = Privacy::stats( $scope ); echo '

' . esc_html__( 'Your data. Your rules.', 'logged-ai' ) . '

' . esc_html__( 'Choose how much evidence this site keeps. These are product defaults; choose a policy appropriate for your organization.', 'logged-ai' ) . '

'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- This read-only presence flag selects fixed notice text for an already authorized settings page; it grants no authority and performs no mutation. if ( isset( $request_query['saved'] ) ) { echo '

' . esc_html__( 'Operation completed. Current settings and remaining work are shown below.', 'logged-ai' ) . '

'; } echo '
'; wp_nonce_field( 'logged_ai_settings' ); echo '

' . esc_html__( 'Address privacy', 'logged-ai' ) . '

' . esc_html__( 'The direct connection address is the default. Request source settings can explicitly trust forwarding headers from configured proxies. Masked addresses are shared network context, not exact identities. Disabled mode removes address context and makes source targeting unavailable. Full mode records the address derived under those source settings until its separate expiry. No IP pseudonym is retained. More private modes immediately restrict displays and exports; bounded cleanup removes older stored copies.', 'logged-ai' ) . '

' . esc_html__( 'How long to keep evidence', 'logged-ai' ) . '

' . esc_html__( 'Each retention period is in days (1–3,650). Shorter periods make older evidence eligible for permanent removal.', 'logged-ai' ) . '

'; foreach ( array( 'event_days' => 'Administrative events', 'request_days' => 'Request observations', 'request_group_days' => 'Request groups', 'policy_history_days' => 'Inactive response history', 'exact_ip_days' => 'Exact client addresses', 'diagnostic_days' => 'Diagnostics', ) as $key => $label ) { echo ''; } echo '
'; if ( $can_purge ) { echo '

'; } else { echo '

' . esc_html__( 'Your settings permission allows longer retention and address privacy changes. Shortening retention, manual erasure and pruning require separate evidence purge permission.', 'logged-ai' ) . '

'; } echo '

' . esc_html__( 'Retention status', 'logged-ai' ) . '

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

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

' ); } else { echo ( '

' . ( esc_html( sprintf( /* translators: Values identify the item described by this message. */ __( 'This site has %1$d administrative events; %2$d are older than its retention policy. Each run removes at most 500 events. Cron delays may postpone removal; repeat runs resume safely.', 'logged-ai' ), $status['event_count'], $status['eligible_events'] ) ) ) . '

' ); } if ( method_exists( Requests::class, 'peer_cleanup_status' ) ) { // @phpstan-ignore function.alreadyNarrowedType (Requests is an optionally loaded module; retain the capability probe before rendering its cleanup status.) $cleanup = Requests::peer_cleanup_status( $scope ); if ( is_wp_error( $cleanup ) ) { echo ( '

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

' ); } else { echo ( '

' . ( esc_html( sprintf( /* translators: Values identify the item described by this message. */ __( 'Address cleanup: %1$d observation copies and %2$d personal policy targets still require physical removal. Current privacy restrictions already apply to viewing, exporting and enforcement.', 'logged-ai' ), $cleanup['observations'], $cleanup['policy_targets'] ) ) ) . '

' ); } } if ( $can_purge ) { echo '
'; wp_nonce_field( 'logged_ai_prune' ); echo '

'; } echo '

' . esc_html__( 'Personal data requests', 'logged-ai' ) . '

' . esc_html__( 'WordPress privacy tools match the requested email to an account on this site. Export Personal Data provides a reference for an authorized administrator to review and download its associated audit records; Logged.ai adds no activity records to the WordPress download. Erasure requires both WordPress privacy permission and separate evidence purge permission. It removes matching event rows and linked request address context; it does not delete the WordPress account. Once an account is deleted, email cannot recover its former ID here: use the documented administrator CLI with the known user ID. Other sites require separate authorized requests. Previously downloaded files cannot be recalled.', 'logged-ai' ) . '

' . esc_html__( 'Deactivation and uninstall', 'logged-ai' ) . '

' . esc_html__( 'Deactivation preserves data. Uninstall also preserves it unless an administrator with installation purge permission explicitly selects deletion. This includes every Logged.ai site and network in this WordPress installation.', 'logged-ai' ) . '

'; if ( self::can_change_uninstall( $scope ) ) { echo '
'; wp_nonce_field( 'logged_ai_uninstall_choice' ); echo '

'; } DesignPro::settings_overview(); echo '
'; } }