otto 2.0.0.pre3 → 2.0.0.pre7
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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +1 -1
- data/.github/workflows/claude-code-review.yml +1 -1
- data/.github/workflows/code-smells.yml +146 -0
- data/.gitignore +4 -0
- data/.pre-commit-config.yaml +2 -2
- data/.reek.yml +99 -0
- data/CHANGELOG.rst +90 -0
- data/CLAUDE.md +74 -540
- data/Gemfile +4 -2
- data/Gemfile.lock +58 -19
- data/README.md +49 -1
- data/changelog.d/20251103_235431_delano_86_improve_error_logging.rst +15 -0
- data/changelog.d/20251109_025012_claude_fix_backtrace_sanitization.rst +37 -0
- data/examples/advanced_routes/README.md +137 -20
- data/examples/authentication_strategies/README.md +212 -19
- data/examples/backtrace_sanitization_demo.rb +86 -0
- data/examples/basic/README.md +61 -10
- data/examples/error_handler_registration.rb +136 -0
- data/examples/logging_improvements.rb +76 -0
- data/examples/mcp_demo/README.md +187 -27
- data/examples/security_features/README.md +249 -30
- data/examples/simple_geo_resolver.rb +107 -0
- data/lib/otto/core/configuration.rb +15 -20
- data/lib/otto/core/error_handler.rb +138 -8
- data/lib/otto/core/file_safety.rb +2 -2
- data/lib/otto/core/freezable.rb +2 -2
- data/lib/otto/core/middleware_stack.rb +2 -2
- data/lib/otto/core/router.rb +61 -8
- data/lib/otto/core/uri_generator.rb +2 -2
- data/lib/otto/core.rb +2 -0
- data/lib/otto/design_system.rb +2 -2
- data/lib/otto/env_keys.rb +61 -12
- data/lib/otto/helpers/base.rb +2 -2
- data/lib/otto/helpers/request.rb +8 -3
- data/lib/otto/helpers/response.rb +2 -2
- data/lib/otto/helpers/validation.rb +2 -2
- data/lib/otto/helpers.rb +2 -0
- data/lib/otto/locale/config.rb +2 -2
- data/lib/otto/locale/middleware.rb +160 -0
- data/lib/otto/locale.rb +10 -0
- data/lib/otto/logging_helpers.rb +273 -0
- data/lib/otto/mcp/auth/token.rb +2 -2
- data/lib/otto/mcp/protocol.rb +2 -2
- data/lib/otto/mcp/rate_limiting.rb +2 -2
- data/lib/otto/mcp/registry.rb +2 -2
- data/lib/otto/mcp/route_parser.rb +2 -2
- data/lib/otto/mcp/schema_validation.rb +2 -2
- data/lib/otto/mcp/server.rb +2 -2
- data/lib/otto/mcp.rb +2 -0
- data/lib/otto/privacy/config.rb +2 -0
- data/lib/otto/privacy/geo_resolver.rb +199 -29
- data/lib/otto/privacy/ip_privacy.rb +2 -0
- data/lib/otto/privacy/redacted_fingerprint.rb +18 -8
- data/lib/otto/privacy.rb +2 -0
- data/lib/otto/response_handlers/auto.rb +2 -0
- data/lib/otto/response_handlers/base.rb +2 -0
- data/lib/otto/response_handlers/default.rb +2 -0
- data/lib/otto/response_handlers/factory.rb +2 -0
- data/lib/otto/response_handlers/json.rb +2 -0
- data/lib/otto/response_handlers/redirect.rb +2 -0
- data/lib/otto/response_handlers/view.rb +2 -0
- data/lib/otto/response_handlers.rb +2 -2
- data/lib/otto/route.rb +4 -4
- data/lib/otto/route_definition.rb +42 -15
- data/lib/otto/route_handlers/base.rb +2 -0
- data/lib/otto/route_handlers/class_method.rb +18 -25
- data/lib/otto/route_handlers/factory.rb +2 -2
- data/lib/otto/route_handlers/instance_method.rb +8 -5
- data/lib/otto/route_handlers/lambda.rb +8 -20
- data/lib/otto/route_handlers/logic_class.rb +23 -6
- data/lib/otto/route_handlers.rb +2 -2
- data/lib/otto/security/authentication/auth_failure.rb +2 -2
- data/lib/otto/security/authentication/auth_strategy.rb +11 -4
- data/lib/otto/security/authentication/route_auth_wrapper.rb +230 -78
- data/lib/otto/security/authentication/strategies/api_key_strategy.rb +2 -0
- data/lib/otto/security/authentication/strategies/noauth_strategy.rb +2 -0
- data/lib/otto/security/authentication/strategies/permission_strategy.rb +2 -0
- data/lib/otto/security/authentication/strategies/role_strategy.rb +2 -0
- data/lib/otto/security/authentication/strategies/session_strategy.rb +2 -0
- data/lib/otto/security/authentication/strategy_result.rb +6 -5
- data/lib/otto/security/authentication.rb +2 -2
- data/lib/otto/security/authorization_error.rb +73 -0
- data/lib/otto/security/config.rb +2 -2
- data/lib/otto/security/configurator.rb +17 -2
- data/lib/otto/security/csrf.rb +2 -2
- data/lib/otto/security/middleware/csrf_middleware.rb +11 -1
- data/lib/otto/security/middleware/ip_privacy_middleware.rb +31 -11
- data/lib/otto/security/middleware/rate_limit_middleware.rb +2 -0
- data/lib/otto/security/middleware/validation_middleware.rb +15 -0
- data/lib/otto/security/rate_limiter.rb +2 -2
- data/lib/otto/security/rate_limiting.rb +2 -2
- data/lib/otto/security/validator.rb +2 -2
- data/lib/otto/security.rb +3 -0
- data/lib/otto/static.rb +2 -2
- data/lib/otto/utils.rb +27 -2
- data/lib/otto/version.rb +3 -3
- data/lib/otto.rb +174 -14
- data/otto.gemspec +7 -3
- metadata +24 -15
- data/benchmark_middleware_wrap.rb +0 -163
- data/changelog.d/20251014_144317_delano_54_thats_a_wrapper.rst +0 -36
- data/changelog.d/20251014_161526_delano_54_thats_a_wrapper.rst +0 -5
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
# lib/otto/privacy/geo_resolver.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
2
4
|
|
|
3
5
|
require 'ipaddr'
|
|
4
6
|
|
|
@@ -7,53 +9,221 @@ class Otto
|
|
|
7
9
|
# Lightweight geo-location resolution for IP addresses
|
|
8
10
|
#
|
|
9
11
|
# Provides country-level geo-location without requiring external
|
|
10
|
-
# databases or API calls.
|
|
11
|
-
#
|
|
12
|
+
# databases or API calls. Supports headers from major CDN/infrastructure
|
|
13
|
+
# providers (Cloudflare, AWS CloudFront, Fastly, Akamai, Azure) with
|
|
14
|
+
# fallback to basic IP range detection.
|
|
12
15
|
#
|
|
13
|
-
#
|
|
16
|
+
# Supported CDN/Infrastructure Headers:
|
|
17
|
+
# - Cloudflare: CF-IPCountry
|
|
18
|
+
# - AWS CloudFront: CloudFront-Viewer-Country
|
|
19
|
+
# - Fastly: Fastly-Client-IP-Country
|
|
20
|
+
# - Akamai: X-Akamai-Edgescape (country_code=XX format)
|
|
21
|
+
# - Azure Front Door: X-Azure-ClientIP-Country
|
|
22
|
+
# - Semi-standard: X-Geo-Country, X-Country-Code, Country-Code
|
|
23
|
+
#
|
|
24
|
+
# @example Resolve country from Cloudflare header
|
|
14
25
|
# env = { 'HTTP_CF_IPCOUNTRY' => 'US' }
|
|
15
26
|
# GeoResolver.resolve('1.2.3.4', env)
|
|
16
27
|
# # => 'US'
|
|
17
28
|
#
|
|
18
|
-
# @example Resolve
|
|
19
|
-
#
|
|
20
|
-
#
|
|
29
|
+
# @example Resolve from AWS CloudFront
|
|
30
|
+
# env = { 'HTTP_CLOUDFRONT_VIEWER_COUNTRY' => 'GB' }
|
|
31
|
+
# GeoResolver.resolve('1.2.3.4', env)
|
|
32
|
+
# # => 'GB'
|
|
33
|
+
#
|
|
34
|
+
# @example Resolve without CDN headers
|
|
35
|
+
# GeoResolver.resolve('8.8.8.8', {})
|
|
36
|
+
# # => 'US' (Google DNS via range detection)
|
|
37
|
+
#
|
|
38
|
+
# @example Using a custom resolver (MaxMind)
|
|
39
|
+
# GeoResolver.custom_resolver = ->(ip, env) {
|
|
40
|
+
# reader = MaxMind::DB.new('GeoLite2-Country.mmdb')
|
|
41
|
+
# result = reader.get(ip)
|
|
42
|
+
# result&.dig('country', 'iso_code')
|
|
43
|
+
# }
|
|
44
|
+
# GeoResolver.resolve('1.2.3.4', {}) # Uses custom resolver
|
|
45
|
+
#
|
|
46
|
+
# @example Extending via subclass
|
|
47
|
+
# class MyGeoResolver < Otto::Privacy::GeoResolver
|
|
48
|
+
# def self.detect_by_range(ip)
|
|
49
|
+
# # Custom logic here
|
|
50
|
+
# super # Fall back to parent
|
|
51
|
+
# end
|
|
52
|
+
# end
|
|
53
|
+
#
|
|
54
|
+
#
|
|
55
|
+
# Resolution flow
|
|
56
|
+
#
|
|
57
|
+
# Request → Has familiar HTTP Header?
|
|
58
|
+
# ├─ Yes → Return country (Cloudflare, AWS, etc.)
|
|
59
|
+
# └─ No → Custom Resolver?
|
|
60
|
+
# ├─ Configured → Call & validate
|
|
61
|
+
# │ ├─ Valid → Return country
|
|
62
|
+
# │ └─ Invalid/Error → Continue
|
|
63
|
+
# └─ Not configured → Built-in range detection
|
|
64
|
+
# └─ Unknown ('**')
|
|
21
65
|
#
|
|
22
66
|
class GeoResolver
|
|
23
|
-
# Unknown country code (ISO 3166-1 alpha-2)
|
|
24
|
-
UNKNOWN = '
|
|
67
|
+
# Unknown country code (not ISO 3166-1 alpha-2, intentionally distinct)
|
|
68
|
+
UNKNOWN = '**'
|
|
69
|
+
|
|
70
|
+
# Custom resolver for extending geo-location capabilities
|
|
71
|
+
# Can be set to a proc/lambda or a class responding to .call(ip, env)
|
|
72
|
+
#
|
|
73
|
+
# @example Using a proc
|
|
74
|
+
# GeoResolver.custom_resolver = ->(ip, env) {
|
|
75
|
+
# return nil # Return nil to continue with built-in resolution
|
|
76
|
+
# # or return 'US' to provide custom country code
|
|
77
|
+
# }
|
|
78
|
+
#
|
|
79
|
+
# @example Using MaxMind
|
|
80
|
+
# GeoResolver.custom_resolver = ->(ip, env) {
|
|
81
|
+
# reader = MaxMind::DB.new('path/to/GeoLite2-Country.mmdb')
|
|
82
|
+
# result = reader.get(ip)
|
|
83
|
+
# result&.dig('country', 'iso_code')
|
|
84
|
+
# }
|
|
85
|
+
#
|
|
86
|
+
# Thread Safety Model:
|
|
87
|
+
# This follows Ruby's standard configuration pattern:
|
|
88
|
+
# - Set ONCE at boot time (single-threaded initialization)
|
|
89
|
+
# - Read many times during requests (multi-threaded reads)
|
|
90
|
+
# - No synchronization needed for this access pattern
|
|
91
|
+
#
|
|
92
|
+
# Runtime resolver switching is NOT supported. Changing the resolver
|
|
93
|
+
# while processing requests creates race conditions (write vs read).
|
|
94
|
+
# This is intentional - resolver configuration is boot-time only.
|
|
95
|
+
@custom_resolver = nil
|
|
96
|
+
|
|
97
|
+
class << self
|
|
98
|
+
attr_accessor :custom_resolver
|
|
99
|
+
|
|
100
|
+
# Set a custom resolver for geo-location
|
|
101
|
+
#
|
|
102
|
+
# MUST be called during single-threaded initialization (before
|
|
103
|
+
# accepting requests). Runtime changes while serving requests
|
|
104
|
+
# will cause race conditions.
|
|
105
|
+
#
|
|
106
|
+
# @param resolver [Proc, #call] A proc or callable object that takes (ip, env)
|
|
107
|
+
# and returns a country code string or nil
|
|
108
|
+
# @raise [ArgumentError] if resolver doesn't respond to :call
|
|
109
|
+
def custom_resolver=(resolver)
|
|
110
|
+
unless resolver.nil? || resolver.respond_to?(:call)
|
|
111
|
+
raise ArgumentError, 'Custom resolver must respond to :call'
|
|
112
|
+
end
|
|
113
|
+
@custom_resolver = resolver
|
|
114
|
+
end
|
|
115
|
+
end
|
|
25
116
|
|
|
26
117
|
# Resolve country code for an IP address
|
|
27
118
|
#
|
|
28
119
|
# Resolution priority:
|
|
29
|
-
# 1.
|
|
120
|
+
# 1. CDN/infrastructure provider headers (Cloudflare, AWS, Fastly, etc.)
|
|
30
121
|
# 2. Basic IP range detection for major countries/providers
|
|
31
|
-
# 3. Return '
|
|
122
|
+
# 3. Return '**' for unknown
|
|
32
123
|
#
|
|
33
124
|
# @param ip [String] IP address to resolve
|
|
34
|
-
# @param env [Hash] Rack environment (may contain
|
|
35
|
-
# @return [String] ISO 3166-1 alpha-2 country code or '
|
|
125
|
+
# @param env [Hash] Rack environment (may contain geo headers)
|
|
126
|
+
# @return [String] ISO 3166-1 alpha-2 country code or '**'
|
|
36
127
|
def self.resolve(ip, env = {})
|
|
37
128
|
return UNKNOWN if ip.nil? || ip.empty?
|
|
38
129
|
|
|
39
|
-
#
|
|
40
|
-
|
|
41
|
-
|
|
130
|
+
# Check CDN/infrastructure headers in priority order
|
|
131
|
+
# Priority based on reliability and deployment frequency
|
|
132
|
+
country = check_geo_headers(env)
|
|
133
|
+
return country if country
|
|
134
|
+
|
|
135
|
+
# Try custom resolver if configured
|
|
136
|
+
if @custom_resolver
|
|
137
|
+
begin
|
|
138
|
+
country = @custom_resolver.call(ip, env)
|
|
139
|
+
return country if country && valid_country_code?(country)
|
|
140
|
+
rescue StandardError => e
|
|
141
|
+
# Log error but don't crash - fall through to built-in detection
|
|
142
|
+
warn "GeoResolver custom resolver error: #{e.message}" if $DEBUG
|
|
143
|
+
end
|
|
144
|
+
end
|
|
42
145
|
|
|
43
|
-
#
|
|
146
|
+
# Fallback: Basic range detection
|
|
44
147
|
detect_by_range(ip)
|
|
45
148
|
rescue IPAddr::InvalidAddressError
|
|
46
149
|
UNKNOWN
|
|
47
150
|
end
|
|
48
151
|
|
|
152
|
+
# Check CDN/infrastructure provider geo headers
|
|
153
|
+
#
|
|
154
|
+
# Headers are checked in order of reliability and deployment frequency:
|
|
155
|
+
# 1. Cloudflare (CF-IPCountry) - Most widely deployed
|
|
156
|
+
# 2. AWS CloudFront (CloudFront-Viewer-Country)
|
|
157
|
+
# 3. Fastly (Fastly-Client-IP-Country)
|
|
158
|
+
# 4. Akamai (X-Akamai-Edgescape) - Complex format, extract country
|
|
159
|
+
# 5. Azure Front Door (X-Azure-ClientIP-Country)
|
|
160
|
+
# 6. Semi-standard headers (X-Geo-Country, X-Country-Code, Country-Code)
|
|
161
|
+
#
|
|
162
|
+
# @param env [Hash] Rack environment
|
|
163
|
+
# @return [String, nil] ISO 3166-1 alpha-2 country code or nil
|
|
164
|
+
# @api private
|
|
165
|
+
def self.check_geo_headers(env)
|
|
166
|
+
# Cloudflare (most common)
|
|
167
|
+
country = env['HTTP_CF_IPCOUNTRY']
|
|
168
|
+
return country if valid_country_code?(country)
|
|
169
|
+
|
|
170
|
+
# AWS CloudFront
|
|
171
|
+
country = env['HTTP_CLOUDFRONT_VIEWER_COUNTRY']
|
|
172
|
+
return country if valid_country_code?(country)
|
|
173
|
+
|
|
174
|
+
# Fastly
|
|
175
|
+
country = env['HTTP_FASTLY_CLIENT_IP_COUNTRY']
|
|
176
|
+
return country if valid_country_code?(country)
|
|
177
|
+
|
|
178
|
+
# Akamai Edgescape (format: country_code=US,region_code=CA,...)
|
|
179
|
+
if (edgescape = env['HTTP_X_AKAMAI_EDGESCAPE'])
|
|
180
|
+
country = extract_akamai_country(edgescape)
|
|
181
|
+
return country if valid_country_code?(country)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Azure Front Door
|
|
185
|
+
country = env['HTTP_X_AZURE_CLIENTIP_COUNTRY']
|
|
186
|
+
return country if valid_country_code?(country)
|
|
187
|
+
|
|
188
|
+
# Semi-standard headers (least reliable, check last)
|
|
189
|
+
country = env['HTTP_X_GEO_COUNTRY']
|
|
190
|
+
return country if valid_country_code?(country)
|
|
191
|
+
|
|
192
|
+
country = env['HTTP_X_COUNTRY_CODE']
|
|
193
|
+
return country if valid_country_code?(country)
|
|
194
|
+
|
|
195
|
+
country = env['HTTP_COUNTRY_CODE']
|
|
196
|
+
return country if valid_country_code?(country)
|
|
197
|
+
|
|
198
|
+
nil
|
|
199
|
+
end
|
|
200
|
+
private_class_method :check_geo_headers
|
|
201
|
+
|
|
202
|
+
# Extract country code from Akamai Edgescape header
|
|
203
|
+
#
|
|
204
|
+
# Edgescape format: "country_code=US,region_code=CA,city=LOSANGELES,..."
|
|
205
|
+
#
|
|
206
|
+
# @param edgescape [String] Akamai Edgescape header value
|
|
207
|
+
# @return [String, nil] Country code or nil
|
|
208
|
+
# @api private
|
|
209
|
+
def self.extract_akamai_country(edgescape)
|
|
210
|
+
return nil unless edgescape.is_a?(String)
|
|
211
|
+
|
|
212
|
+
# Extract country_code=XX (must be exactly 2 uppercase letters, bounded)
|
|
213
|
+
# Use word boundary or comma to ensure we don't match partial strings like "INVALID"
|
|
214
|
+
match = edgescape.match(/country_code=([A-Z]{2})(?:,|\z)/)
|
|
215
|
+
match ? match[1] : nil
|
|
216
|
+
end
|
|
217
|
+
private_class_method :extract_akamai_country
|
|
218
|
+
|
|
49
219
|
# Detect country by IP range (basic implementation)
|
|
50
220
|
#
|
|
51
221
|
# Detects major cloud providers and well-known IP ranges.
|
|
52
222
|
# This is intentionally limited - for comprehensive geo-location,
|
|
53
|
-
# use
|
|
223
|
+
# use CDN headers or configure a custom resolver.
|
|
54
224
|
#
|
|
55
225
|
# @param ip [String] IP address
|
|
56
|
-
# @return [String] Country code or '
|
|
226
|
+
# @return [String] Country code or '**'
|
|
57
227
|
# @api private
|
|
58
228
|
def self.detect_by_range(ip)
|
|
59
229
|
addr = IPAddr.new(ip)
|
|
@@ -70,18 +240,8 @@ class Otto
|
|
|
70
240
|
end
|
|
71
241
|
private_class_method :detect_by_range
|
|
72
242
|
|
|
73
|
-
# Validate country code format
|
|
74
|
-
#
|
|
75
|
-
# @param code [String] Country code to validate
|
|
76
|
-
# @return [Boolean] true if valid ISO 3166-1 alpha-2 code
|
|
77
|
-
# @api private
|
|
78
|
-
def self.valid_country_code?(code)
|
|
79
|
-
code.is_a?(String) && code.length == 2 && code.match?(/^[A-Z]{2}$/)
|
|
80
|
-
end
|
|
81
|
-
private_class_method :valid_country_code?
|
|
82
|
-
|
|
83
243
|
# Known IP ranges for major providers (limited set for basic detection)
|
|
84
|
-
# For comprehensive geo-location, use
|
|
244
|
+
# For comprehensive geo-location, use CDN headers or custom resolver
|
|
85
245
|
KNOWN_RANGES = {
|
|
86
246
|
# Google Public DNS
|
|
87
247
|
IPAddr.new('8.8.8.0/24') => 'US',
|
|
@@ -110,6 +270,16 @@ class Otto
|
|
|
110
270
|
IPAddr.new('208.67.222.0/24') => 'US',
|
|
111
271
|
IPAddr.new('208.67.220.0/24') => 'US',
|
|
112
272
|
}.freeze
|
|
273
|
+
|
|
274
|
+
# Validate country code format
|
|
275
|
+
#
|
|
276
|
+
# @param code [String] Country code to validate
|
|
277
|
+
# @return [Boolean] true if valid ISO 3166-1 alpha-2 code
|
|
278
|
+
# @api private
|
|
279
|
+
def self.valid_country_code?(code)
|
|
280
|
+
code.is_a?(String) && code.length == 2 && code.match?(/^[A-Z]{2}$/)
|
|
281
|
+
end
|
|
282
|
+
private_class_method :valid_country_code?
|
|
113
283
|
end
|
|
114
284
|
end
|
|
115
285
|
end
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
# lib/otto/privacy/redacted_fingerprint.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
2
4
|
|
|
3
5
|
require 'securerandom'
|
|
4
6
|
require 'time'
|
|
@@ -88,21 +90,29 @@ class Otto
|
|
|
88
90
|
|
|
89
91
|
private
|
|
90
92
|
|
|
91
|
-
# Anonymize user agent string by removing version numbers
|
|
93
|
+
# Anonymize user agent string by removing version numbers and build identifiers
|
|
92
94
|
#
|
|
93
|
-
# Removes specific version numbers (
|
|
94
|
-
# fingerprinting granularity while maintaining
|
|
95
|
+
# Removes specific version numbers (*.*.* pattern) and build identifiers
|
|
96
|
+
# (e.g., Build/MRA58N) to reduce fingerprinting granularity while maintaining
|
|
97
|
+
# browser/OS info.
|
|
95
98
|
#
|
|
96
99
|
# @param ua [String, nil] User agent string
|
|
97
100
|
# @return [String, nil] Anonymized user agent or nil
|
|
98
101
|
def anonymize_user_agent(ua)
|
|
99
102
|
return nil if ua.nil? || ua.empty?
|
|
100
103
|
|
|
101
|
-
# Remove
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
104
|
+
# Remove build identifiers (e.g., Build/MRA58N, Build/MPJ24.139-64)
|
|
105
|
+
# This must run BEFORE version stripping to avoid partial matches.
|
|
106
|
+
# If we strip versions first, Build/MPJ24.139-64 becomes Build/MPJ*.*-64,
|
|
107
|
+
# and the regex won't match properly (asterisks not in [\w.-] class).
|
|
108
|
+
anonymized = ua.gsub(/Build\/[\w.-]+/, 'Build/*')
|
|
109
|
+
|
|
110
|
+
# Remove version patterns (*.*.*.*, *.*.*, *.*)
|
|
111
|
+
# Support both dot and underscore separators (e.g., 10.15.7 and 10_15_7)
|
|
112
|
+
anonymized = anonymized
|
|
113
|
+
.gsub(/\d+[._]\d+[._]\d+[._]\d+/, '*.*.*.*')
|
|
114
|
+
.gsub(/\d+[._]\d+[._]\d+/, '*.*.*')
|
|
115
|
+
.gsub(/\d+[._]\d+/, '*.*')
|
|
106
116
|
|
|
107
117
|
# Truncate if too long (prevent DoS via huge UA strings)
|
|
108
118
|
anonymized.length > 500 ? anonymized[0..499] : anonymized
|
data/lib/otto/privacy.rb
CHANGED
data/lib/otto/route.rb
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
1
|
# lib/otto/route.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
4
|
|
|
5
5
|
class Otto
|
|
6
6
|
# Otto::Route
|
|
@@ -158,8 +158,8 @@ class Otto
|
|
|
158
158
|
if response_type != 'default'
|
|
159
159
|
context = {
|
|
160
160
|
logic_instance: (kind == :instance ? inst : nil),
|
|
161
|
-
|
|
162
|
-
|
|
161
|
+
status_code: nil,
|
|
162
|
+
redirect_path: nil,
|
|
163
163
|
}
|
|
164
164
|
|
|
165
165
|
Otto::ResponseHandlers::HandlerFactory.handle_response(result, res, response_type, context)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
1
|
# lib/otto/route_definition.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
4
|
|
|
5
5
|
class Otto
|
|
6
6
|
# Immutable data class representing a complete route definition
|
|
@@ -73,10 +73,37 @@ class Otto
|
|
|
73
73
|
@options.fetch(key.to_sym, default)
|
|
74
74
|
end
|
|
75
75
|
|
|
76
|
-
# Get authentication requirement
|
|
76
|
+
# Get authentication requirement (backward compatibility - returns first requirement)
|
|
77
77
|
# @return [String, nil] The auth requirement or nil
|
|
78
78
|
def auth_requirement
|
|
79
|
-
|
|
79
|
+
auth_requirements.first
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Get all authentication requirements as an array
|
|
83
|
+
# Supports multiple strategies: auth=session,apikey,oauth
|
|
84
|
+
# @return [Array<String>] Array of auth requirement strings
|
|
85
|
+
def auth_requirements
|
|
86
|
+
auth = option(:auth)
|
|
87
|
+
return [] unless auth
|
|
88
|
+
|
|
89
|
+
auth.split(',').map(&:strip).reject(&:empty?)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Get role requirement for route-level authorization
|
|
93
|
+
# Supports single role or comma-separated roles (OR logic): role=admin,editor
|
|
94
|
+
# @return [String, nil] The role requirement or nil
|
|
95
|
+
def role_requirement
|
|
96
|
+
option(:role)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Get all role requirements as an array
|
|
100
|
+
# Supports multiple roles with OR logic: role=admin,editor
|
|
101
|
+
# @return [Array<String>] Array of role requirement strings
|
|
102
|
+
def role_requirements
|
|
103
|
+
role = option(:role)
|
|
104
|
+
return [] unless role
|
|
105
|
+
|
|
106
|
+
role.split(',').map(&:strip).reject(&:empty?)
|
|
80
107
|
end
|
|
81
108
|
|
|
82
109
|
# Get response type
|
|
@@ -111,16 +138,16 @@ class Otto
|
|
|
111
138
|
# @return [Hash]
|
|
112
139
|
def to_h
|
|
113
140
|
{
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
141
|
+
verb: @verb,
|
|
142
|
+
path: @path,
|
|
143
|
+
definition: @definition,
|
|
144
|
+
target: @target,
|
|
145
|
+
klass_name: @klass_name,
|
|
119
146
|
method_name: @method_name,
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
147
|
+
kind: @kind,
|
|
148
|
+
options: @options,
|
|
149
|
+
pattern: @pattern,
|
|
150
|
+
keys: @keys,
|
|
124
151
|
}
|
|
125
152
|
end
|
|
126
153
|
|
|
@@ -166,11 +193,11 @@ class Otto
|
|
|
166
193
|
case target
|
|
167
194
|
when /^(.+)\.(.+)$/
|
|
168
195
|
# Class.method - call class method directly
|
|
169
|
-
{ klass_name:
|
|
196
|
+
{ klass_name: ::Regexp.last_match(1), method_name: ::Regexp.last_match(2), kind: :class }
|
|
170
197
|
|
|
171
198
|
when /^(.+)#(.+)$/
|
|
172
199
|
# Class#method - instantiate then call instance method
|
|
173
|
-
{ klass_name:
|
|
200
|
+
{ klass_name: ::Regexp.last_match(1), method_name: ::Regexp.last_match(2), kind: :instance }
|
|
174
201
|
|
|
175
202
|
when /^[A-Z][A-Za-z0-9_]*(?:::[A-Z][A-Za-z0-9_]*)*$/
|
|
176
203
|
# Bare class name - instantiate the class
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
1
|
# lib/otto/route_handlers/class_method.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
4
|
|
|
5
5
|
require 'json'
|
|
6
6
|
require 'securerandom'
|
|
@@ -13,6 +13,7 @@ class Otto
|
|
|
13
13
|
# Maintains backward compatibility for Controller.action patterns
|
|
14
14
|
class ClassMethodHandler < BaseHandler
|
|
15
15
|
def call(env, extra_params = {})
|
|
16
|
+
start_time = Otto::Utils.now_in_μs
|
|
16
17
|
req = Rack::Request.new(env)
|
|
17
18
|
res = Rack::Response.new
|
|
18
19
|
|
|
@@ -25,19 +26,22 @@ class Otto
|
|
|
25
26
|
|
|
26
27
|
# Only handle response if response_type is not default
|
|
27
28
|
if route_definition.response_type != 'default'
|
|
28
|
-
handle_response(result, res,
|
|
29
|
-
|
|
29
|
+
handle_response(result, res,
|
|
30
|
+
{
|
|
31
|
+
class: target_class,
|
|
30
32
|
request: req,
|
|
31
|
-
|
|
33
|
+
})
|
|
32
34
|
end
|
|
33
35
|
rescue StandardError => e
|
|
34
36
|
# Check if we're being called through Otto's integrated context (vs direct handler testing)
|
|
35
37
|
# In integrated context, let Otto's centralized error handler manage the response
|
|
36
38
|
# In direct testing context, handle errors locally for unit testing
|
|
37
39
|
if otto_instance
|
|
38
|
-
#
|
|
39
|
-
|
|
40
|
-
|
|
40
|
+
# Store handler context in env for centralized error handler
|
|
41
|
+
handler_name = "#{target_class.name}##{route_definition.method_name}"
|
|
42
|
+
env['otto.handler'] = handler_name
|
|
43
|
+
env['otto.handler_duration'] = Otto::Utils.now_in_μs - start_time
|
|
44
|
+
|
|
41
45
|
raise e # Re-raise to let Otto's centralized error handler manage the response
|
|
42
46
|
else
|
|
43
47
|
# Direct handler testing context - handle errors locally with security improvements
|
|
@@ -51,26 +55,15 @@ class Otto
|
|
|
51
55
|
accept_header = env['HTTP_ACCEPT'].to_s
|
|
52
56
|
if accept_header.include?('application/json')
|
|
53
57
|
res.headers['content-type'] = 'application/json'
|
|
54
|
-
error_data =
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
}
|
|
60
|
-
else
|
|
61
|
-
{
|
|
62
|
-
error: 'Internal Server Error',
|
|
63
|
-
message: 'An error occurred. Please try again later.',
|
|
64
|
-
}
|
|
65
|
-
end
|
|
58
|
+
error_data = {
|
|
59
|
+
error: 'Internal Server Error',
|
|
60
|
+
message: 'Server error occurred. Check logs for details.',
|
|
61
|
+
error_id: error_id,
|
|
62
|
+
}
|
|
66
63
|
res.write JSON.generate(error_data)
|
|
67
64
|
else
|
|
68
65
|
res.headers['content-type'] = 'text/plain'
|
|
69
|
-
|
|
70
|
-
res.write "Server error (ID: #{error_id}). Check logs for details."
|
|
71
|
-
else
|
|
72
|
-
res.write 'An error occurred. Please try again later.'
|
|
73
|
-
end
|
|
66
|
+
res.write "Server error (ID: #{error_id}). Check logs for details."
|
|
74
67
|
end
|
|
75
68
|
|
|
76
69
|
# Add security headers if available
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
1
|
# lib/otto/route_handlers/instance_method.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
4
|
require 'securerandom'
|
|
5
5
|
|
|
6
6
|
require_relative 'base'
|
|
@@ -11,6 +11,7 @@ class Otto
|
|
|
11
11
|
# Maintains backward compatibility for Controller#action patterns
|
|
12
12
|
class InstanceMethodHandler < BaseHandler
|
|
13
13
|
def call(env, extra_params = {})
|
|
14
|
+
start_time = Otto::Utils.now_in_μs
|
|
14
15
|
req = Rack::Request.new(env)
|
|
15
16
|
res = Rack::Response.new
|
|
16
17
|
|
|
@@ -34,9 +35,11 @@ class Otto
|
|
|
34
35
|
# In integrated context, let Otto's centralized error handler manage the response
|
|
35
36
|
# In direct testing context, handle errors locally for unit testing
|
|
36
37
|
if otto_instance
|
|
37
|
-
#
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
# Store handler context in env for centralized error handler
|
|
39
|
+
handler_name = "#{target_class}##{route_definition.method_name}"
|
|
40
|
+
env['otto.handler'] = handler_name
|
|
41
|
+
env['otto.handler_duration'] = Otto::Utils.now_in_μs - start_time
|
|
42
|
+
|
|
40
43
|
raise e # Re-raise to let Otto's centralized error handler manage the response
|
|
41
44
|
else
|
|
42
45
|
# Direct handler testing context - handle errors locally with security improvements
|