reputable 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "redis"
4
+ require "connection_pool"
5
+ require "timeout"
6
+
7
+ module Reputable
8
+ # Redis connection pool manager with robust error handling
9
+ class Connection
10
+ # Track if we're in a degraded state (failed recently)
11
+ @circuit_open = false
12
+ @circuit_opened_at = nil
13
+ @failure_count = 0
14
+
15
+ # Circuit breaker settings
16
+ CIRCUIT_THRESHOLD = 5 # Failures before opening circuit
17
+ CIRCUIT_TIMEOUT = 30 # Seconds before trying again
18
+
19
+ class << self
20
+ attr_accessor :circuit_open, :circuit_opened_at, :failure_count
21
+
22
+ def pool
23
+ @pool ||= ConnectionPool.new(
24
+ size: Reputable.configuration.pool_size,
25
+ timeout: Reputable.configuration.pool_timeout
26
+ ) do
27
+ build_redis_client
28
+ end
29
+ end
30
+
31
+ # Safe wrapper that handles all errors gracefully
32
+ # @param default [Object] Value to return on failure
33
+ # @param context [String] Description for error logging
34
+ # @yield Block to execute with Redis connection
35
+ # @return [Object] Result of block or default value
36
+ def safe_with(default: nil, context: "redis_operation")
37
+ return default unless Reputable.enabled?
38
+ return default if circuit_open?
39
+
40
+ result = Timeout.timeout(operation_timeout) do
41
+ pool.with do |redis|
42
+ yield redis
43
+ end
44
+ end
45
+
46
+ record_success
47
+ result
48
+ rescue Timeout::Error, ConnectionPool::TimeoutError => e
49
+ record_failure(e, context)
50
+ default
51
+ rescue Redis::BaseError, Errno::ECONNREFUSED, Errno::ETIMEDOUT,
52
+ Errno::EHOSTUNREACH, Errno::ENETUNREACH, Errno::ECONNRESET,
53
+ Errno::EPIPE, SocketError, IOError => e
54
+ record_failure(e, context)
55
+ default
56
+ rescue OpenSSL::SSL::SSLError, OpenSSL::OpenSSLError => e
57
+ # Catch all SSL/TLS errors - certificate issues, handshake failures, etc.
58
+ record_failure(e, context)
59
+ default
60
+ rescue StandardError => e
61
+ # Catch-all for any unexpected errors
62
+ record_failure(e, context)
63
+ default
64
+ end
65
+
66
+ # Legacy method for backward compatibility
67
+ def with(&block)
68
+ safe_with(default: nil, context: "legacy_with", &block)
69
+ end
70
+
71
+ def reset!
72
+ @pool&.shutdown(&:close)
73
+ @pool = nil
74
+ @circuit_open = false
75
+ @circuit_opened_at = nil
76
+ @failure_count = 0
77
+ end
78
+
79
+ # Check if circuit breaker is open (we should skip operations)
80
+ def circuit_open?
81
+ return false unless @circuit_open
82
+
83
+ # Check if enough time has passed to try again
84
+ if @circuit_opened_at && (Time.now - @circuit_opened_at) > CIRCUIT_TIMEOUT
85
+ @circuit_open = false
86
+ @failure_count = 0
87
+ Reputable.logger&.info("Reputable circuit breaker: attempting reconnection")
88
+ return false
89
+ end
90
+
91
+ true
92
+ end
93
+
94
+ private
95
+
96
+ def operation_timeout
97
+ # Total timeout for an operation (connect + read + write + pool checkout)
98
+ config = Reputable.configuration
99
+ config.connect_timeout + config.read_timeout + config.pool_timeout + 0.5
100
+ end
101
+
102
+ def record_success
103
+ @failure_count = 0
104
+ if @circuit_open
105
+ @circuit_open = false
106
+ Reputable.logger&.info("Reputable circuit breaker: closed (connection restored)")
107
+ end
108
+ end
109
+
110
+ def record_failure(error, context)
111
+ @failure_count += 1
112
+
113
+ Reputable.logger&.warn(
114
+ "Reputable #{context} error (#{@failure_count}/#{CIRCUIT_THRESHOLD}): #{error.class} - #{error.message}"
115
+ )
116
+
117
+ # Notify via callback if configured
118
+ Reputable.configuration.on_error&.call(error, context)
119
+
120
+ # Open circuit breaker if too many failures
121
+ if @failure_count >= CIRCUIT_THRESHOLD && !@circuit_open
122
+ @circuit_open = true
123
+ @circuit_opened_at = Time.now
124
+ Reputable.logger&.warn(
125
+ "Reputable circuit breaker: OPEN (#{CIRCUIT_THRESHOLD} failures, will retry in #{CIRCUIT_TIMEOUT}s)"
126
+ )
127
+ end
128
+ end
129
+
130
+ def build_redis_client
131
+ config = Reputable.configuration
132
+ url = config.redis_url
133
+ options = config.redis_options.dup
134
+
135
+ # Set timeouts
136
+ options[:connect_timeout] = config.connect_timeout
137
+ options[:read_timeout] = config.read_timeout
138
+ options[:write_timeout] = config.write_timeout
139
+
140
+ # Handle TLS URLs (rediss://)
141
+ if config.tls?
142
+ options[:ssl] = true
143
+ options[:ssl_params] = config.effective_ssl_params
144
+ end
145
+
146
+ Redis.new(url: url, **options)
147
+ end
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,266 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reputable
4
+ # Rack middleware for automatic request tracking
5
+ #
6
+ # RESILIENCE: This middleware is designed to NEVER break your application.
7
+ # - Disable entirely via ENV: REPUTABLE_ENABLED=false
8
+ # - All errors are caught and logged silently
9
+ # - Circuit breaker prevents repeated failures from impacting performance
10
+ # - Async mode (default) runs tracking in a separate thread
11
+ #
12
+ # @example Basic usage in config.ru
13
+ # require 'reputable'
14
+ # use Reputable::Middleware
15
+ #
16
+ # @example With options
17
+ # use Reputable::Middleware,
18
+ # skip_paths: ['/health', '/assets'],
19
+ # skip_if: ->(env) { env['HTTP_X_INTERNAL'] == 'true' },
20
+ # tag_builder: ->(env) { ["custom:tag"] }
21
+ #
22
+ class Middleware
23
+ DEFAULT_SKIP_PATHS = %w[
24
+ /health
25
+ /healthz
26
+ /ready
27
+ /readyz
28
+ /live
29
+ /livez
30
+ /metrics
31
+ /favicon.ico
32
+ ].freeze
33
+
34
+ DEFAULT_SKIP_EXTENSIONS = %w[
35
+ .js .css .png .jpg .jpeg .gif .svg .ico .woff .woff2 .ttf .eot .map
36
+ ].freeze
37
+
38
+ def initialize(app, options = {})
39
+ @app = app
40
+ @skip_paths = options.fetch(:skip_paths, DEFAULT_SKIP_PATHS)
41
+ @skip_extensions = options.fetch(:skip_extensions, DEFAULT_SKIP_EXTENSIONS)
42
+ @skip_if = options[:skip_if]
43
+ @tag_builder = options[:tag_builder]
44
+ @async = options.fetch(:async, true)
45
+ end
46
+
47
+ def call(env)
48
+ # ALWAYS process the request first - tracking must never block
49
+ status, headers, response = @app.call(env)
50
+
51
+ # Only attempt tracking if enabled and not skipped
52
+ # All tracking is wrapped in rescue to ensure it never fails
53
+ safe_track_request(env)
54
+
55
+ [status, headers, response]
56
+ end
57
+
58
+ private
59
+
60
+ def safe_track_request(env)
61
+ # Skip if disabled globally
62
+ return unless Reputable.enabled?
63
+
64
+ # Skip if this request should be skipped
65
+ return if skip_request?(env)
66
+
67
+ track_request(env)
68
+ rescue StandardError => e
69
+ # NEVER let tracking errors propagate - just log and continue
70
+ Reputable.logger&.debug("Reputable middleware: #{e.class} - #{e.message}")
71
+ end
72
+
73
+ def skip_request?(env)
74
+ return true if @skip_if&.call(env)
75
+
76
+ path = env["PATH_INFO"] || "/"
77
+
78
+ # Skip exact path matches
79
+ return true if @skip_paths.include?(path)
80
+
81
+ # Skip by extension
82
+ ext = File.extname(path).downcase
83
+ return true if @skip_extensions.include?(ext)
84
+
85
+ false
86
+ rescue StandardError
87
+ # If skip logic fails, default to skipping (safer)
88
+ true
89
+ end
90
+
91
+ def track_request(env)
92
+ params = build_params(env)
93
+ return unless params
94
+
95
+ if @async
96
+ Tracker.track_request_async(**params)
97
+ else
98
+ Tracker.track_request(**params)
99
+ end
100
+ rescue StandardError => e
101
+ Reputable.logger&.debug("Reputable track_request: #{e.class} - #{e.message}")
102
+ end
103
+
104
+ def build_params(env)
105
+ request = Rack::Request.new(env)
106
+
107
+ {
108
+ ip: extract_ip(env),
109
+ path: request.path,
110
+ query: request.query_string.empty? ? nil : request.query_string,
111
+ method: request.request_method,
112
+ session_id: extract_session_id(env),
113
+ session_present: session_present?(env),
114
+ user_agent: env["HTTP_USER_AGENT"],
115
+ referer: env["HTTP_REFERER"],
116
+ tags: build_tags(env)
117
+ }.compact
118
+ rescue StandardError => e
119
+ Reputable.logger&.debug("Reputable build_params: #{e.class} - #{e.message}")
120
+ nil
121
+ end
122
+
123
+ def extract_ip(env)
124
+ config = Reputable.configuration
125
+
126
+ # Try each header in priority order
127
+ config.ip_header_priority.each do |header|
128
+ value = env[header]
129
+ next if value.nil? || value.empty?
130
+
131
+ # X-Forwarded-For and Forwarded can contain multiple IPs
132
+ if header == "HTTP_X_FORWARDED_FOR"
133
+ ip = extract_from_xff(value)
134
+ return ip if ip
135
+ elsif header == "HTTP_FORWARDED"
136
+ ip = extract_from_forwarded(value)
137
+ return ip if ip
138
+ else
139
+ # Single IP headers
140
+ ip = value.to_s.strip
141
+ return ip unless ip.empty?
142
+ end
143
+ end
144
+
145
+ # Ultimate fallback
146
+ "0.0.0.0"
147
+ rescue StandardError
148
+ "0.0.0.0"
149
+ end
150
+
151
+ # Extract real client IP from X-Forwarded-For header
152
+ # X-Forwarded-For: client, proxy1, proxy2
153
+ # We want the leftmost non-private IP
154
+ def extract_from_xff(xff)
155
+ return nil if xff.nil? || xff.empty?
156
+
157
+ config = Reputable.configuration
158
+ ips = xff.split(",").map(&:strip)
159
+
160
+ # Find the first (leftmost) public IP
161
+ ips.each do |ip|
162
+ next if ip.empty?
163
+ next if config.private_ip?(ip)
164
+ return ip
165
+ end
166
+
167
+ # If all IPs are private, return the first one
168
+ ips.first&.strip
169
+ rescue StandardError
170
+ nil
171
+ end
172
+
173
+ # Extract client IP from RFC 7239 Forwarded header
174
+ # Forwarded: for=192.0.2.60;proto=http;by=203.0.113.43
175
+ def extract_from_forwarded(forwarded)
176
+ return nil if forwarded.nil? || forwarded.empty?
177
+
178
+ # Parse the "for" directive
179
+ forwarded.split(";").each do |directive|
180
+ key, value = directive.split("=", 2).map(&:strip)
181
+ if key&.downcase == "for" && value
182
+ # Remove quotes and brackets (IPv6)
183
+ ip = value.gsub(/["'\[\]]/, "").strip
184
+ return ip unless ip.empty?
185
+ end
186
+ end
187
+
188
+ nil
189
+ rescue StandardError
190
+ nil
191
+ end
192
+
193
+ def extract_session_id(env)
194
+ # Try rack.session.id first
195
+ return env["rack.session.id"] if env["rack.session.id"]
196
+
197
+ # Try to get from rack.session
198
+ session = env["rack.session"]
199
+ return session.id.to_s if session.respond_to?(:id)
200
+
201
+ nil
202
+ rescue StandardError
203
+ nil
204
+ end
205
+
206
+ def session_present?(env)
207
+ return true if env["rack.session.id"]
208
+ return true if env["rack.session"]&.any?
209
+ return true if env["HTTP_COOKIE"]&.include?("_session")
210
+
211
+ false
212
+ rescue StandardError
213
+ false
214
+ end
215
+
216
+ def build_tags(env)
217
+ tags = []
218
+
219
+ # Add custom tags from tag_builder
220
+ if @tag_builder
221
+ begin
222
+ custom_tags = @tag_builder.call(env)
223
+ tags.concat(Array(custom_tags))
224
+ rescue StandardError
225
+ # Ignore tag builder errors
226
+ end
227
+ end
228
+
229
+ # Add source tag based on referer
230
+ if (referer = env["HTTP_REFERER"])
231
+ tag = categorize_referer(referer)
232
+ tags << tag if tag
233
+ end
234
+
235
+ tags.compact
236
+ rescue StandardError
237
+ []
238
+ end
239
+
240
+ def categorize_referer(referer)
241
+ uri = URI.parse(referer) rescue nil
242
+ return nil unless uri&.host
243
+
244
+ host = uri.host.downcase
245
+
246
+ case host
247
+ when /google\./
248
+ "trust:channel:search"
249
+ when /bing\./
250
+ "trust:channel:search"
251
+ when /facebook\./, /fb\./
252
+ "trust:channel:social"
253
+ when /twitter\./, /t\.co/
254
+ "trust:channel:social"
255
+ when /instagram\./
256
+ "trust:channel:social"
257
+ when /linkedin\./
258
+ "trust:channel:social"
259
+ else
260
+ nil
261
+ end
262
+ rescue StandardError
263
+ nil
264
+ end
265
+ end
266
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reputable
4
+ # Rails-specific integration
5
+ module Rails
6
+ # Railtie for automatic Rails integration
7
+ class Railtie < ::Rails::Railtie
8
+ initializer "reputable.configure_rails_initialization" do
9
+ # Auto-configure from Rails credentials or ENV
10
+ Reputable.configure do |config|
11
+ # Try Rails credentials first
12
+ if defined?(::Rails.application.credentials)
13
+ creds = ::Rails.application.credentials.reputable rescue nil
14
+ if creds
15
+ config.redis_url = creds[:redis_url] if creds[:redis_url]
16
+ config.tenant_id = creds[:tenant_id] if creds[:tenant_id]
17
+ end
18
+ end
19
+ end
20
+ end
21
+
22
+ initializer "reputable.insert_middleware" do |app|
23
+ # Only insert middleware if explicitly enabled
24
+ if ENV["REPUTABLE_AUTO_MIDDLEWARE"] == "true"
25
+ app.middleware.use Reputable::Middleware
26
+ end
27
+ end
28
+ end
29
+
30
+ # Helper methods for controllers
31
+ module ControllerHelpers
32
+ extend ActiveSupport::Concern
33
+
34
+ # Track the current request with optional extra tags
35
+ def track_reputable_request(tags: [], **options)
36
+ Reputable::Tracker.track_request(
37
+ ip: request.remote_ip,
38
+ path: request.path,
39
+ query: request.query_string,
40
+ method: request.method,
41
+ session_id: session.id.to_s,
42
+ session_present: session.any?,
43
+ user_agent: request.user_agent,
44
+ referer: request.referer,
45
+ tags: tags,
46
+ **options
47
+ )
48
+ end
49
+
50
+ # Trust the current user's IP (e.g., after payment)
51
+ def trust_current_ip(reason:, **metadata)
52
+ Reputable::Reputation.trust_ip(
53
+ request.remote_ip,
54
+ reason: reason,
55
+ **metadata
56
+ )
57
+ end
58
+
59
+ # Trust the current user
60
+ def trust_current_user(reason:, **metadata)
61
+ return false unless respond_to?(:current_user) && current_user
62
+
63
+ Reputable::Reputation.trust_user(
64
+ current_user.id,
65
+ reason: reason,
66
+ **metadata
67
+ )
68
+ end
69
+
70
+ # Trust the current session
71
+ def trust_current_session(reason:, **metadata)
72
+ Reputable::Reputation.trust_session(
73
+ session.id.to_s,
74
+ reason: reason,
75
+ **metadata
76
+ )
77
+ end
78
+
79
+ # Challenge the current IP
80
+ def challenge_current_ip(reason:, **metadata)
81
+ Reputable::Reputation.challenge_ip(
82
+ request.remote_ip,
83
+ reason: reason,
84
+ **metadata
85
+ )
86
+ end
87
+
88
+ # Block the current IP
89
+ def block_current_ip(reason:, **metadata)
90
+ Reputable::Reputation.block_ip(
91
+ request.remote_ip,
92
+ reason: reason,
93
+ **metadata
94
+ )
95
+ end
96
+
97
+ # ========================================
98
+ # Lookup methods (O(1) Redis lookups)
99
+ # ========================================
100
+
101
+ # Check if current IP is trusted
102
+ # @return [Boolean]
103
+ def current_ip_trusted?
104
+ Reputable::Reputation.trusted_ip?(request.remote_ip)
105
+ end
106
+
107
+ # Check if current IP is blocked
108
+ # @return [Boolean]
109
+ def current_ip_blocked?
110
+ Reputable::Reputation.blocked_ip?(request.remote_ip)
111
+ end
112
+
113
+ # Check if current IP should be challenged
114
+ # @return [Boolean]
115
+ def current_ip_challenged?
116
+ Reputable::Reputation.challenged_ip?(request.remote_ip)
117
+ end
118
+
119
+ # Get full reputation data for current IP
120
+ # @return [Hash, nil]
121
+ def current_ip_reputation
122
+ Reputable::Reputation.lookup(:ip, request.remote_ip)
123
+ end
124
+
125
+ # Get reputation status for current IP
126
+ # @return [String, nil]
127
+ def current_ip_status
128
+ Reputable::Reputation.lookup_ip(request.remote_ip)
129
+ end
130
+
131
+ # Check if current user is trusted
132
+ # @return [Boolean]
133
+ def current_user_trusted?
134
+ return false unless respond_to?(:current_user) && current_user
135
+
136
+ Reputable::Reputation.trusted_user?(current_user.id)
137
+ end
138
+
139
+ # Check if current session is trusted
140
+ # @return [Boolean]
141
+ def current_session_trusted?
142
+ Reputable::Reputation.trusted_session?(session.id.to_s)
143
+ end
144
+ end
145
+ end
146
+ end
147
+
148
+ # Auto-load Railtie if Rails is present
149
+ if defined?(::Rails::Railtie)
150
+ require_relative "rails/railtie"
151
+ end