time(), 'credential_id' => $credential_id, 'user_id' => $user_id, 'capability' => $capability, 'outcome' => $success ? 'success' : 'failure', 'message' => $message ? self::sanitize_message( $message ) : null, ]; update_option( self::OPTION, self::trim( $entries ), 'no' ); } catch ( \Throwable $e ) { // Swallowed deliberately — see class docblock (FR-039h). return; } } /** * @return array */ public static function all(): array { $entries = get_option( self::OPTION, [] ); return is_array( $entries ) ? $entries : []; } /** * Most recent first, for display. * * @param int $limit * @return array */ public static function recent( int $limit = 50 ): array { $entries = self::trim( self::all() ); return array_slice( array_reverse( $entries ), 0, max( 1, $limit ) ); } public static function clear(): void { delete_option( self::OPTION ); } /** * Trim by age first, then by count. Both bounds exist so a busy site does * not retain unboundedly and a quiet site does not retain forever. * * @param array $entries * @return array */ private static function trim( array $entries ): array { $cutoff = time() - self::MAX_AGE; $entries = array_values( array_filter( $entries, static function ( $entry ) use ( $cutoff ) { return isset( $entry['at'] ) && (int) $entry['at'] >= $cutoff; } ) ); if ( count( $entries ) > self::MAX_ENTRIES ) { $entries = array_slice( $entries, -self::MAX_ENTRIES ); } return $entries; } /** * Keep messages short and free of internal detail (FR-039f, FR-044). * Anything resembling an absolute path is dropped rather than truncated. * * @param string $message * @return string */ private static function sanitize_message( string $message ): string { $message = (string) preg_replace( '#(/[^\s:]+)+\.php#', '', $message ); $message = trim( wp_strip_all_tags( $message ) ); if ( function_exists( 'mb_substr' ) ) { return mb_substr( $message, 0, 200 ); } return substr( $message, 0, 200 ); } }