otto 2.0.0.pre2 → 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 -3
- data/.github/workflows/claude-code-review.yml +29 -13
- 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 +116 -45
- data/Gemfile +5 -2
- data/Gemfile.lock +70 -24
- 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/docs/.gitignore +1 -0
- data/docs/ipaddr-encoding-quirk.md +34 -0
- data/docs/migrating/v2.0.0-pre2.md +11 -18
- data/examples/advanced_routes/README.md +137 -20
- data/examples/authentication_strategies/README.md +212 -19
- data/examples/authentication_strategies/config.ru +0 -1
- 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 +90 -45
- data/lib/otto/core/error_handler.rb +138 -8
- data/lib/otto/core/file_safety.rb +2 -2
- data/lib/otto/core/freezable.rb +93 -0
- data/lib/otto/core/middleware_stack.rb +25 -18
- data/lib/otto/core/router.rb +62 -9
- data/lib/otto/core/uri_generator.rb +2 -2
- data/lib/otto/core.rb +10 -0
- data/lib/otto/design_system.rb +2 -2
- data/lib/otto/env_keys.rb +65 -12
- data/lib/otto/helpers/base.rb +2 -2
- data/lib/otto/helpers/request.rb +85 -2
- data/lib/otto/helpers/response.rb +5 -5
- data/lib/otto/helpers/validation.rb +2 -2
- data/lib/otto/helpers.rb +6 -0
- data/lib/otto/locale/config.rb +56 -0
- 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 +5 -0
- data/lib/otto/privacy/config.rb +201 -0
- data/lib/otto/privacy/geo_resolver.rb +285 -0
- data/lib/otto/privacy/ip_privacy.rb +177 -0
- data/lib/otto/privacy/redacted_fingerprint.rb +146 -0
- data/lib/otto/privacy.rb +31 -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 -1
- data/lib/otto/route_handlers/class_method.rb +18 -25
- data/lib/otto/route_handlers/factory.rb +18 -16
- 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 +25 -8
- data/lib/otto/route_handlers.rb +2 -2
- data/lib/otto/security/authentication/{failure_result.rb → auth_failure.rb} +5 -5
- data/lib/otto/security/authentication/auth_strategy.rb +13 -6
- data/lib/otto/security/authentication/route_auth_wrapper.rb +304 -41
- data/lib/otto/security/authentication/strategies/api_key_strategy.rb +2 -0
- data/lib/otto/security/authentication/strategies/noauth_strategy.rb +7 -1
- 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 +5 -6
- data/lib/otto/security/authorization_error.rb +73 -0
- data/lib/otto/security/config.rb +53 -9
- data/lib/otto/security/configurator.rb +17 -15
- 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 +231 -0
- 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 +12 -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 +344 -89
- data/otto.gemspec +9 -2
- metadata +72 -8
- data/lib/otto/security/authentication/authentication_middleware.rb +0 -140
data/lib/otto/core/router.rb
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
1
|
# lib/otto/core/router.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
4
|
|
|
5
5
|
require_relative '../mcp/route_parser'
|
|
6
6
|
|
|
@@ -36,13 +36,28 @@ class Otto
|
|
|
36
36
|
route.otto = self
|
|
37
37
|
path_clean = path.gsub(%r{/$}, '')
|
|
38
38
|
@route_definitions[route.definition] = route
|
|
39
|
-
Otto.
|
|
39
|
+
Otto.structured_log(:debug, "Route loaded",
|
|
40
|
+
{
|
|
41
|
+
pattern: route.pattern.source,
|
|
42
|
+
verb: route.verb,
|
|
43
|
+
definition: route.definition,
|
|
44
|
+
type: 'pattern'
|
|
45
|
+
}
|
|
46
|
+
) if Otto.debug
|
|
40
47
|
@routes[route.verb] ||= []
|
|
41
48
|
@routes[route.verb] << route
|
|
42
49
|
@routes_literal[route.verb] ||= {}
|
|
43
50
|
@routes_literal[route.verb][path_clean] = route
|
|
44
51
|
rescue StandardError => e
|
|
45
|
-
Otto.
|
|
52
|
+
Otto.structured_log(:error, "Route load failed",
|
|
53
|
+
{
|
|
54
|
+
path: path,
|
|
55
|
+
verb: verb,
|
|
56
|
+
definition: definition,
|
|
57
|
+
error: e.message,
|
|
58
|
+
error_class: e.class.name
|
|
59
|
+
}
|
|
60
|
+
)
|
|
46
61
|
Otto.logger.debug e.backtrace.join("\n") if Otto.debug
|
|
47
62
|
end
|
|
48
63
|
self
|
|
@@ -51,7 +66,7 @@ class Otto
|
|
|
51
66
|
def handle_request(env)
|
|
52
67
|
locale = determine_locale env
|
|
53
68
|
env['rack.locale'] = locale
|
|
54
|
-
env['otto.locale_config'] = @locale_config if @locale_config
|
|
69
|
+
env['otto.locale_config'] = @locale_config.to_h if @locale_config
|
|
55
70
|
@static_route ||= Rack::Files.new(option[:public]) if option[:public] && safe_dir?(option[:public])
|
|
56
71
|
path_info = Rack::Utils.unescape(env['PATH_INFO'])
|
|
57
72
|
path_info = '/' if path_info.to_s.empty?
|
|
@@ -81,14 +96,30 @@ class Otto
|
|
|
81
96
|
literal_routes.merge! routes_literal[:GET] if http_verb == :HEAD
|
|
82
97
|
|
|
83
98
|
if static_route && http_verb == :GET && routes_static[:GET].member?(base_path)
|
|
84
|
-
|
|
99
|
+
Otto.structured_log(:debug, "Route matched",
|
|
100
|
+
Otto::LoggingHelpers.request_context(env).merge(
|
|
101
|
+
type: 'static_cached',
|
|
102
|
+
base_path: base_path
|
|
103
|
+
)
|
|
104
|
+
)
|
|
85
105
|
static_route.call(env)
|
|
86
106
|
elsif literal_routes.has_key?(path_info_clean)
|
|
87
107
|
route = literal_routes[path_info_clean]
|
|
88
|
-
|
|
108
|
+
Otto.structured_log(:debug, "Route matched",
|
|
109
|
+
Otto::LoggingHelpers.request_context(env).merge(
|
|
110
|
+
type: 'literal',
|
|
111
|
+
handler: route.route_definition.definition,
|
|
112
|
+
auth_strategy: route.route_definition.auth_requirement || 'none'
|
|
113
|
+
)
|
|
114
|
+
)
|
|
89
115
|
route.call(env)
|
|
90
116
|
elsif static_route && http_verb == :GET && safe_file?(path_info)
|
|
91
|
-
Otto.
|
|
117
|
+
Otto.structured_log(:debug, "Route matched",
|
|
118
|
+
Otto::LoggingHelpers.request_context(env).merge(
|
|
119
|
+
type: 'static_new',
|
|
120
|
+
base_path: base_path
|
|
121
|
+
)
|
|
122
|
+
)
|
|
92
123
|
routes_static[:GET][base_path] = base_path
|
|
93
124
|
static_route.call(env)
|
|
94
125
|
else
|
|
@@ -123,7 +154,6 @@ class Otto
|
|
|
123
154
|
valid_routes.push(*routes[:GET]) if http_verb == :HEAD
|
|
124
155
|
|
|
125
156
|
valid_routes.each do |route|
|
|
126
|
-
# Otto.logger.debug " request: #{http_verb} #{path_info} (trying route: #{route.verb} #{route.pattern})"
|
|
127
157
|
next unless (match = route.pattern.match(path_info))
|
|
128
158
|
|
|
129
159
|
values = match.captures.to_a
|
|
@@ -133,13 +163,36 @@ class Otto
|
|
|
133
163
|
values.shift
|
|
134
164
|
extra_params = build_route_params(route, values)
|
|
135
165
|
found_route = route
|
|
166
|
+
|
|
167
|
+
# Log successful route match
|
|
168
|
+
Otto.structured_log(:debug, "Route matched",
|
|
169
|
+
Otto::LoggingHelpers.request_context(env).merge(
|
|
170
|
+
pattern: route.pattern.source,
|
|
171
|
+
handler: route.route_definition.definition,
|
|
172
|
+
auth_strategy: route.route_definition.auth_requirement || 'none',
|
|
173
|
+
route_params: extra_params
|
|
174
|
+
)
|
|
175
|
+
)
|
|
136
176
|
break
|
|
137
177
|
end
|
|
138
178
|
|
|
139
179
|
found_route ||= literal_routes['/404']
|
|
140
180
|
if found_route
|
|
181
|
+
# Log 404 route usage if we fell back to it
|
|
182
|
+
if found_route == literal_routes['/404']
|
|
183
|
+
Otto.structured_log(:info, "Route not found",
|
|
184
|
+
Otto::LoggingHelpers.request_context(env).merge(
|
|
185
|
+
fallback_to: '404_route'
|
|
186
|
+
)
|
|
187
|
+
)
|
|
188
|
+
end
|
|
141
189
|
found_route.call env, extra_params
|
|
142
190
|
else
|
|
191
|
+
Otto.structured_log(:info, "Route not found",
|
|
192
|
+
Otto::LoggingHelpers.request_context(env).merge(
|
|
193
|
+
fallback_to: 'default_not_found'
|
|
194
|
+
)
|
|
195
|
+
)
|
|
143
196
|
@not_found || Otto::Static.not_found
|
|
144
197
|
end
|
|
145
198
|
end
|
data/lib/otto/core.rb
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# lib/otto/core.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
require_relative 'core/router'
|
|
6
|
+
require_relative 'core/file_safety'
|
|
7
|
+
require_relative 'core/configuration'
|
|
8
|
+
require_relative 'core/error_handler'
|
|
9
|
+
require_relative 'core/uri_generator'
|
|
10
|
+
require_relative 'core/middleware_stack'
|
data/lib/otto/design_system.rb
CHANGED
data/lib/otto/env_keys.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# lib/otto/env_keys.rb
|
|
2
2
|
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
#
|
|
3
5
|
# Central registry of all env['otto.*'] keys used throughout Otto framework.
|
|
4
6
|
# This documentation helps prevent key conflicts and aids multi-app integration.
|
|
5
7
|
#
|
|
@@ -36,22 +38,18 @@ class Otto
|
|
|
36
38
|
|
|
37
39
|
# Authentication strategy result containing session/user state
|
|
38
40
|
# Type: Otto::Security::Authentication::StrategyResult
|
|
39
|
-
# Set by:
|
|
41
|
+
# Set by: RouteAuthWrapper (wraps all route handlers)
|
|
40
42
|
# Used by: RouteHandlers, LogicClasses, Controllers
|
|
41
|
-
#
|
|
43
|
+
# Guarantee: ALWAYS present - either authenticated or anonymous
|
|
44
|
+
# - Routes WITH auth requirement: Authenticated StrategyResult or 401/302
|
|
45
|
+
# - Routes WITHOUT auth requirement: Anonymous StrategyResult
|
|
42
46
|
STRATEGY_RESULT = 'otto.strategy_result'
|
|
43
47
|
|
|
44
|
-
#
|
|
45
|
-
#
|
|
46
|
-
# Set by: AuthenticationMiddleware (from strategy_result.user)
|
|
47
|
-
# Used by: Controllers, RouteHandlers
|
|
48
|
-
USER = 'otto.user'
|
|
48
|
+
# REMOVED: Use strategy_result.user instead
|
|
49
|
+
# USER = 'otto.user'
|
|
49
50
|
|
|
50
|
-
#
|
|
51
|
-
#
|
|
52
|
-
# Set by: AuthenticationMiddleware (from strategy_result.user_context)
|
|
53
|
-
# Used by: Controllers, Analytics
|
|
54
|
-
USER_CONTEXT = 'otto.user_context'
|
|
51
|
+
# REMOVED: Use strategy_result.metadata instead
|
|
52
|
+
# USER_CONTEXT = 'otto.user_context'
|
|
55
53
|
|
|
56
54
|
# =========================================================================
|
|
57
55
|
# SECURITY & CONFIGURATION
|
|
@@ -101,6 +99,61 @@ class Otto
|
|
|
101
99
|
# Used by: Error responses, logging, support
|
|
102
100
|
ERROR_ID = 'otto.error_id'
|
|
103
101
|
|
|
102
|
+
# =========================================================================
|
|
103
|
+
# PRIVACY (IP MASKING)
|
|
104
|
+
# =========================================================================
|
|
105
|
+
|
|
106
|
+
# Privacy-safe masked IP address
|
|
107
|
+
# Type: String (e.g., '192.168.1.0')
|
|
108
|
+
# Set by: IPPrivacyMiddleware
|
|
109
|
+
# Used by: Rate limiting, analytics, logging
|
|
110
|
+
module Privacy
|
|
111
|
+
MASKED_IP = 'otto.privacy.masked_ip'
|
|
112
|
+
|
|
113
|
+
# Geo-location country code
|
|
114
|
+
# Type: String (ISO 3166-1 alpha-2)
|
|
115
|
+
# Set by: IPPrivacyMiddleware
|
|
116
|
+
# Used by: Analytics, localization
|
|
117
|
+
GEO_COUNTRY = 'otto.privacy.geo_country'
|
|
118
|
+
|
|
119
|
+
# Daily-rotating IP hash for session correlation
|
|
120
|
+
# Type: String (hexadecimal)
|
|
121
|
+
# Set by: IPPrivacyMiddleware
|
|
122
|
+
# Used by: Session correlation without storing IPs
|
|
123
|
+
HASHED_IP = 'otto.privacy.hashed_ip'
|
|
124
|
+
|
|
125
|
+
# Privacy fingerprint object
|
|
126
|
+
# Type: Otto::Privacy::RedactedFingerprint
|
|
127
|
+
# Set by: IPPrivacyMiddleware
|
|
128
|
+
# Used by: Full privacy context access
|
|
129
|
+
FINGERPRINT = 'otto.privacy.fingerprint'
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# =========================================================================
|
|
133
|
+
# ORIGINAL VALUES (Privacy Disabled)
|
|
134
|
+
# =========================================================================
|
|
135
|
+
|
|
136
|
+
# Original client IP address (only when privacy disabled)
|
|
137
|
+
# Type: String
|
|
138
|
+
# Set by: IPPrivacyMiddleware (when privacy disabled)
|
|
139
|
+
# Used by: Debugging, legitimate use cases requiring real IP
|
|
140
|
+
# NOTE: Not available when privacy is enabled (intentional)
|
|
141
|
+
ORIGINAL_IP = 'otto.original_ip'
|
|
142
|
+
|
|
143
|
+
# Original User-Agent string (only when privacy disabled)
|
|
144
|
+
# Type: String
|
|
145
|
+
# Set by: IPPrivacyMiddleware (when privacy disabled)
|
|
146
|
+
# Used by: Bot detection, browser feature detection
|
|
147
|
+
# NOTE: Not available when privacy is enabled (intentional)
|
|
148
|
+
ORIGINAL_USER_AGENT = 'otto.original_user_agent'
|
|
149
|
+
|
|
150
|
+
# Original Referer URL (only when privacy disabled)
|
|
151
|
+
# Type: String
|
|
152
|
+
# Set by: IPPrivacyMiddleware (when privacy disabled)
|
|
153
|
+
# Used by: Analytics, debugging
|
|
154
|
+
# NOTE: Not available when privacy is enabled (intentional)
|
|
155
|
+
ORIGINAL_REFERER = 'otto.original_referer'
|
|
156
|
+
|
|
104
157
|
# =========================================================================
|
|
105
158
|
# MCP (MODEL CONTEXT PROTOCOL)
|
|
106
159
|
# =========================================================================
|
data/lib/otto/helpers/base.rb
CHANGED
data/lib/otto/helpers/request.rb
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
1
|
# lib/otto/helpers/request.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
4
|
|
|
5
5
|
require_relative 'base'
|
|
6
6
|
|
|
@@ -13,6 +13,89 @@ class Otto
|
|
|
13
13
|
env['HTTP_USER_AGENT']
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
+
# NOTE: We do NOT override Rack::Request#ip
|
|
17
|
+
#
|
|
18
|
+
# IPPrivacyMiddleware masks both REMOTE_ADDR and X-Forwarded-For headers,
|
|
19
|
+
# so Rack's native ip resolution logic works correctly with masked values.
|
|
20
|
+
# This allows Rack to handle proxy scenarios (trusted proxies, header parsing)
|
|
21
|
+
# while still returning privacy-safe masked IPs.
|
|
22
|
+
#
|
|
23
|
+
# If you need the masked IP explicitly, use:
|
|
24
|
+
# req.masked_ip # => '192.168.1.0' or nil if privacy disabled
|
|
25
|
+
#
|
|
26
|
+
# If you need the geo country:
|
|
27
|
+
# req.geo_country # => 'US' or nil
|
|
28
|
+
#
|
|
29
|
+
# If you need the full privacy fingerprint:
|
|
30
|
+
# req.redacted_fingerprint # => RedactedFingerprint object or nil
|
|
31
|
+
|
|
32
|
+
# Get the privacy-safe fingerprint for this request
|
|
33
|
+
#
|
|
34
|
+
# Returns nil if IP privacy is disabled. The fingerprint contains
|
|
35
|
+
# anonymized request information suitable for logging and analytics.
|
|
36
|
+
#
|
|
37
|
+
# @return [Otto::Privacy::RedactedFingerprint, nil] Privacy-safe fingerprint
|
|
38
|
+
# @example
|
|
39
|
+
# fingerprint = req.redacted_fingerprint
|
|
40
|
+
# fingerprint.masked_ip # => '192.168.1.0'
|
|
41
|
+
# fingerprint.country # => 'US'
|
|
42
|
+
def redacted_fingerprint
|
|
43
|
+
env['otto.redacted_fingerprint']
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Get the geo-location country code for the request
|
|
47
|
+
#
|
|
48
|
+
# Returns ISO 3166-1 alpha-2 country code or 'XX' for unknown.
|
|
49
|
+
# Only available when IP privacy is enabled (default).
|
|
50
|
+
#
|
|
51
|
+
# @return [String, nil] Country code or nil if privacy disabled
|
|
52
|
+
# @example
|
|
53
|
+
# req.geo_country # => 'US'
|
|
54
|
+
def geo_country
|
|
55
|
+
redacted_fingerprint&.country || env['otto.geo_country']
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Get anonymized user agent string
|
|
59
|
+
#
|
|
60
|
+
# Returns user agent with version numbers stripped for privacy.
|
|
61
|
+
# When privacy is enabled (default), env['HTTP_USER_AGENT'] is already
|
|
62
|
+
# anonymized by IPPrivacyMiddleware, so this just returns that value.
|
|
63
|
+
# When privacy is disabled, returns the raw user agent.
|
|
64
|
+
#
|
|
65
|
+
# @return [String, nil] Anonymized (or raw if privacy disabled) user agent
|
|
66
|
+
# @example
|
|
67
|
+
# req.anonymized_user_agent
|
|
68
|
+
# # => 'Mozilla/X.X (Windows NT X.X; Win64; x64) AppleWebKit/X.X'
|
|
69
|
+
# @deprecated Use env['HTTP_USER_AGENT'] directly (already anonymized when privacy enabled)
|
|
70
|
+
def anonymized_user_agent
|
|
71
|
+
user_agent
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Get masked IP address
|
|
75
|
+
#
|
|
76
|
+
# Returns privacy-safe masked IP. When privacy is enabled (default),
|
|
77
|
+
# this returns the masked version. When disabled, returns original IP.
|
|
78
|
+
#
|
|
79
|
+
# @return [String, nil] Masked or original IP address
|
|
80
|
+
# @example
|
|
81
|
+
# req.masked_ip # => '192.168.1.0'
|
|
82
|
+
def masked_ip
|
|
83
|
+
env['otto.masked_ip'] || env['REMOTE_ADDR']
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Get hashed IP for session correlation
|
|
87
|
+
#
|
|
88
|
+
# Returns daily-rotating hash of the IP address, allowing session
|
|
89
|
+
# tracking without storing the original IP. Only available when
|
|
90
|
+
# IP privacy is enabled (default).
|
|
91
|
+
#
|
|
92
|
+
# @return [String, nil] Hexadecimal hash string or nil
|
|
93
|
+
# @example
|
|
94
|
+
# req.hashed_ip # => 'a3f8b2c4d5e6f7...'
|
|
95
|
+
def hashed_ip
|
|
96
|
+
redacted_fingerprint&.hashed_ip || env['otto.hashed_ip']
|
|
97
|
+
end
|
|
98
|
+
|
|
16
99
|
def client_ipaddress
|
|
17
100
|
remote_addr = env['REMOTE_ADDR']
|
|
18
101
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
1
|
# lib/otto/helpers/response.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
4
|
|
|
5
5
|
require_relative 'base'
|
|
6
6
|
|
|
@@ -14,10 +14,10 @@ class Otto
|
|
|
14
14
|
def send_secure_cookie(name, value, ttl, opts = {})
|
|
15
15
|
# Default security options
|
|
16
16
|
defaults = {
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
secure: true,
|
|
18
|
+
httponly: true,
|
|
19
19
|
same_site: :strict,
|
|
20
|
-
|
|
20
|
+
path: '/',
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
# Merge with provided options
|
data/lib/otto/helpers.rb
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# lib/otto/locale/config.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
require_relative '../core/freezable'
|
|
6
|
+
|
|
7
|
+
class Otto
|
|
8
|
+
module Locale
|
|
9
|
+
# Locale configuration for Otto applications
|
|
10
|
+
#
|
|
11
|
+
# This class manages locale-related settings including available locales
|
|
12
|
+
# and default locale selection.
|
|
13
|
+
#
|
|
14
|
+
# @example Basic usage
|
|
15
|
+
# config = Otto::Locale::Config.new
|
|
16
|
+
# config.available_locales = { 'en' => 'English', 'es' => 'Spanish' }
|
|
17
|
+
# config.default_locale = 'en'
|
|
18
|
+
#
|
|
19
|
+
# @example With initialization
|
|
20
|
+
# config = Otto::Locale::Config.new(
|
|
21
|
+
# available_locales: { 'en' => 'English', 'fr' => 'French' },
|
|
22
|
+
# default_locale: 'en'
|
|
23
|
+
# )
|
|
24
|
+
class Config
|
|
25
|
+
include Otto::Core::Freezable
|
|
26
|
+
|
|
27
|
+
attr_accessor :available_locales, :default_locale
|
|
28
|
+
|
|
29
|
+
# Initialize locale configuration
|
|
30
|
+
#
|
|
31
|
+
# @param available_locales [Hash, nil] Hash of locale codes to names
|
|
32
|
+
# @param default_locale [String, nil] Default locale code
|
|
33
|
+
def initialize(available_locales: nil, default_locale: nil)
|
|
34
|
+
@available_locales = available_locales
|
|
35
|
+
@default_locale = default_locale
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Convert to hash for compatibility with existing code
|
|
39
|
+
#
|
|
40
|
+
# @return [Hash] Hash representation of configuration
|
|
41
|
+
def to_h
|
|
42
|
+
{
|
|
43
|
+
available_locales: @available_locales,
|
|
44
|
+
default_locale: @default_locale,
|
|
45
|
+
}.compact
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Check if locale configuration is present
|
|
49
|
+
#
|
|
50
|
+
# @return [Boolean] true if either available_locales or default_locale is set
|
|
51
|
+
def configured?
|
|
52
|
+
!@available_locales.nil? || !@default_locale.nil?
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# lib/otto/locale/middleware.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
class Otto
|
|
6
|
+
module Locale
|
|
7
|
+
# Locale detection and resolution middleware
|
|
8
|
+
#
|
|
9
|
+
# Sets env['otto.locale'] based on:
|
|
10
|
+
# 1. URL parameter (?locale=es)
|
|
11
|
+
# 2. Session preference (session['locale'])
|
|
12
|
+
# 3. HTTP Accept-Language header
|
|
13
|
+
# 4. Default locale
|
|
14
|
+
#
|
|
15
|
+
# Configuration:
|
|
16
|
+
# use Otto::Locale::Middleware,
|
|
17
|
+
# available_locales: { 'en' => 'English', 'es' => 'Spanish' },
|
|
18
|
+
# default_locale: 'en',
|
|
19
|
+
# debug: false
|
|
20
|
+
#
|
|
21
|
+
# @example Basic usage
|
|
22
|
+
# use Otto::Locale::Middleware,
|
|
23
|
+
# available_locales: { 'en' => 'English', 'es' => 'Español', 'fr' => 'Français' },
|
|
24
|
+
# default_locale: 'en'
|
|
25
|
+
#
|
|
26
|
+
# @example With session persistence
|
|
27
|
+
# use Rack::Session::Cookie, secret: 'secret'
|
|
28
|
+
# use Otto::Locale::Middleware,
|
|
29
|
+
# available_locales: { 'en' => 'English', 'es' => 'Español' },
|
|
30
|
+
# default_locale: 'en'
|
|
31
|
+
#
|
|
32
|
+
class Middleware
|
|
33
|
+
attr_reader :available_locales, :default_locale
|
|
34
|
+
|
|
35
|
+
# Initialize locale middleware
|
|
36
|
+
#
|
|
37
|
+
# @param app [#call] Rack application
|
|
38
|
+
# @param available_locales [Hash<String, String>] Hash of locale codes to language names
|
|
39
|
+
# @param default_locale [String] Default locale code
|
|
40
|
+
# @param debug [Boolean] Enable debug logging
|
|
41
|
+
def initialize(app, available_locales:, default_locale:, debug: false)
|
|
42
|
+
@app = app
|
|
43
|
+
@available_locales = available_locales
|
|
44
|
+
@default_locale = default_locale
|
|
45
|
+
@debug = debug
|
|
46
|
+
|
|
47
|
+
validate_config!
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Process request and set locale
|
|
51
|
+
#
|
|
52
|
+
# @param env [Hash] Rack environment
|
|
53
|
+
# @return [Array] Rack response tuple [status, headers, body]
|
|
54
|
+
def call(env)
|
|
55
|
+
locale = detect_locale(env)
|
|
56
|
+
env['otto.locale'] = locale
|
|
57
|
+
|
|
58
|
+
debug_log(env, locale) if @debug
|
|
59
|
+
|
|
60
|
+
@app.call(env)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
# Detect locale using priority chain
|
|
66
|
+
#
|
|
67
|
+
# @param env [Hash] Rack environment
|
|
68
|
+
# @return [String] Resolved locale code
|
|
69
|
+
def detect_locale(env)
|
|
70
|
+
# 1. Check URL parameter
|
|
71
|
+
req = Rack::Request.new(env)
|
|
72
|
+
locale = req.params['locale']
|
|
73
|
+
return locale if valid_locale?(locale)
|
|
74
|
+
|
|
75
|
+
# 2. Check session
|
|
76
|
+
session = env['rack.session']
|
|
77
|
+
locale = session['locale'] if session
|
|
78
|
+
return locale if valid_locale?(locale)
|
|
79
|
+
|
|
80
|
+
# 3. Parse Accept-Language header
|
|
81
|
+
locale = parse_accept_language(env['HTTP_ACCEPT_LANGUAGE'])
|
|
82
|
+
return locale if valid_locale?(locale)
|
|
83
|
+
|
|
84
|
+
# 4. Default
|
|
85
|
+
@default_locale
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Parse Accept-Language header with RFC 2616 quality value support
|
|
89
|
+
#
|
|
90
|
+
# Handles formats like:
|
|
91
|
+
# - "en-US,en;q=0.9,fr;q=0.8" → finds first available from [en, en, fr]
|
|
92
|
+
# - "es,en;q=0.9" → returns "en" if "es" unavailable but "en" is
|
|
93
|
+
# - "fr-CA" → "fr"
|
|
94
|
+
#
|
|
95
|
+
# Respects q-values (quality factors) and returns the highest-priority
|
|
96
|
+
# available locale instead of just the first language tag.
|
|
97
|
+
#
|
|
98
|
+
# @param header [String, nil] Accept-Language header value
|
|
99
|
+
# @return [String, nil] Best matching available locale code or nil
|
|
100
|
+
def parse_accept_language(header)
|
|
101
|
+
return nil unless header
|
|
102
|
+
|
|
103
|
+
# Parse all language tags with their q-values
|
|
104
|
+
# Format: "en-US,en;q=0.9,fr;q=0.8" → [[en-US, 1.0], [en, 0.9], [fr, 0.8]]
|
|
105
|
+
languages = header.split(',').map do |tag|
|
|
106
|
+
# Split on semicolon and extract q-value
|
|
107
|
+
parts = tag.strip.split(/\s*;\s*q\s*=\s*/)
|
|
108
|
+
locale_str = parts[0]
|
|
109
|
+
q_value = parts[1] ? parts[1].to_f : 1.0
|
|
110
|
+
[locale_str, q_value]
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Sort by q-value descending (highest preference first)
|
|
114
|
+
# and find the first locale that matches available_locales
|
|
115
|
+
languages.sort_by { |_, q| -q }.each do |lang_tag, _|
|
|
116
|
+
# Extract primary language code: "en-US" → "en", "fr" → "fr"
|
|
117
|
+
locale_code = lang_tag.split('-').first.downcase
|
|
118
|
+
return locale_code if valid_locale?(locale_code)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
nil # No matching locale found
|
|
122
|
+
rescue StandardError => ex
|
|
123
|
+
Otto.logger&.warn "[Otto::Locale] Failed to parse Accept-Language: #{ex.message}"
|
|
124
|
+
nil
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Check if locale is valid
|
|
128
|
+
#
|
|
129
|
+
# @param locale [String, nil] Locale code to validate
|
|
130
|
+
# @return [Boolean] true if locale is in available_locales
|
|
131
|
+
def valid_locale?(locale)
|
|
132
|
+
return false unless locale
|
|
133
|
+
@available_locales.key?(locale.to_s)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Validate middleware configuration
|
|
137
|
+
#
|
|
138
|
+
# @raise [ArgumentError] if configuration is invalid
|
|
139
|
+
def validate_config!
|
|
140
|
+
raise ArgumentError, 'available_locales must be a Hash' unless @available_locales.is_a?(Hash)
|
|
141
|
+
raise ArgumentError, 'available_locales cannot be empty' if @available_locales.empty?
|
|
142
|
+
raise ArgumentError, 'default_locale must be in available_locales' unless @available_locales.key?(@default_locale)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Log debug information about locale detection
|
|
146
|
+
#
|
|
147
|
+
# @param env [Hash] Rack environment
|
|
148
|
+
# @param locale [String] Resolved locale
|
|
149
|
+
def debug_log(env, locale)
|
|
150
|
+
Otto.logger&.debug format(
|
|
151
|
+
'[Otto::Locale] Selected locale=%s (param=%s session=%s header=%s)',
|
|
152
|
+
locale,
|
|
153
|
+
Rack::Request.new(env).params['locale'] || 'nil',
|
|
154
|
+
env['rack.session']&.dig('locale') || 'nil',
|
|
155
|
+
env['HTTP_ACCEPT_LANGUAGE']&.split(',')&.first || 'nil'
|
|
156
|
+
)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|