otto 1.1.0.pre.alpha4 → 1.3.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.
- checksums.yaml +4 -4
- data/.gitignore +3 -0
- data/.rspec +4 -0
- data/.rubocop.yml +371 -25
- data/Gemfile +16 -5
- data/Gemfile.lock +115 -39
- data/LICENSE.txt +1 -1
- data/README.md +61 -112
- data/examples/basic/app.rb +78 -0
- data/examples/basic/config.ru +30 -0
- data/examples/basic/routes +20 -0
- data/examples/dynamic_pages/app.rb +115 -0
- data/examples/dynamic_pages/config.ru +30 -0
- data/{example → examples/dynamic_pages}/routes +5 -3
- data/examples/security_features/app.rb +276 -0
- data/examples/security_features/config.ru +83 -0
- data/examples/security_features/routes +11 -0
- data/lib/otto/design_system.rb +463 -0
- data/lib/otto/helpers/request.rb +124 -15
- data/lib/otto/helpers/response.rb +72 -14
- data/lib/otto/route.rb +111 -19
- data/lib/otto/security/config.rb +312 -0
- data/lib/otto/security/csrf.rb +177 -0
- data/lib/otto/security/validator.rb +299 -0
- data/lib/otto/static.rb +18 -5
- data/lib/otto/version.rb +2 -24
- data/lib/otto.rb +378 -127
- data/otto.gemspec +15 -15
- metadata +30 -29
- data/CHANGES.txt +0 -35
- data/VERSION.yml +0 -4
- data/example/app.rb +0 -58
- data/example/config.ru +0 -35
- /data/{example/public → public}/favicon.ico +0 -0
- /data/{example/public → public}/img/otto.jpg +0 -0
@@ -0,0 +1,177 @@
|
|
1
|
+
# lib/otto/security/csrf.rb
|
2
|
+
|
3
|
+
require 'securerandom'
|
4
|
+
|
5
|
+
class Otto
|
6
|
+
# CSRF protection middleware for Otto framework
|
7
|
+
module Security
|
8
|
+
# Middleware that provides Cross-Site Request Forgery (CSRF) protection
|
9
|
+
class CSRFMiddleware
|
10
|
+
SAFE_METHODS = %w[GET HEAD OPTIONS TRACE].freeze
|
11
|
+
|
12
|
+
def initialize(app, config = nil)
|
13
|
+
@app = app
|
14
|
+
@config = config || Otto::Security::Config.new
|
15
|
+
end
|
16
|
+
|
17
|
+
def call(env)
|
18
|
+
return @app.call(env) unless @config.csrf_enabled?
|
19
|
+
|
20
|
+
request = Rack::Request.new(env)
|
21
|
+
|
22
|
+
# Skip CSRF protection for safe methods
|
23
|
+
if safe_method?(request.request_method)
|
24
|
+
response = @app.call(env)
|
25
|
+
response = inject_csrf_token(request, response) if html_response?(response)
|
26
|
+
return response
|
27
|
+
end
|
28
|
+
|
29
|
+
# Validate CSRF token for unsafe methods
|
30
|
+
return csrf_error_response unless valid_csrf_token?(request)
|
31
|
+
|
32
|
+
@app.call(env)
|
33
|
+
end
|
34
|
+
|
35
|
+
private
|
36
|
+
|
37
|
+
def safe_method?(method)
|
38
|
+
SAFE_METHODS.include?(method.upcase)
|
39
|
+
end
|
40
|
+
|
41
|
+
def valid_csrf_token?(request)
|
42
|
+
token = extract_csrf_token(request)
|
43
|
+
return false if token.nil? || token.empty?
|
44
|
+
|
45
|
+
session_id = @config.get_or_create_session_id(request)
|
46
|
+
@config.verify_csrf_token(token, session_id)
|
47
|
+
end
|
48
|
+
|
49
|
+
def extract_csrf_token(request)
|
50
|
+
# Try form parameter first
|
51
|
+
token = request.params[@config.csrf_token_key]
|
52
|
+
|
53
|
+
# Try header if not in params
|
54
|
+
token ||= request.env[@config.csrf_header_key]
|
55
|
+
|
56
|
+
# Try alternative header format
|
57
|
+
token ||= request.env['HTTP_X_CSRF_TOKEN'] if request.env['HTTP_X_REQUESTED_WITH'] == 'XMLHttpRequest'
|
58
|
+
|
59
|
+
token
|
60
|
+
end
|
61
|
+
|
62
|
+
def extract_session_id(request)
|
63
|
+
@config.get_or_create_session_id(request)
|
64
|
+
end
|
65
|
+
|
66
|
+
def inject_csrf_token(request, response)
|
67
|
+
return response unless response.is_a?(Array) && response.length >= 3
|
68
|
+
|
69
|
+
status, headers, body = response
|
70
|
+
content_type = headers.find { |k, _v| k.downcase == 'content-type' }&.last
|
71
|
+
|
72
|
+
return response unless content_type&.include?('text/html')
|
73
|
+
|
74
|
+
# Get or create session ID
|
75
|
+
session_id = @config.get_or_create_session_id(request)
|
76
|
+
|
77
|
+
# Ensure session ID is saved to cookie if it was newly created
|
78
|
+
ensure_session_cookie(request, headers, session_id)
|
79
|
+
|
80
|
+
# Generate new CSRF token
|
81
|
+
csrf_token = @config.generate_csrf_token(session_id)
|
82
|
+
|
83
|
+
# Inject meta tag into HTML head
|
84
|
+
body_content = body.respond_to?(:join) ? body.join : body.to_s
|
85
|
+
|
86
|
+
if body_content.match?(/<head>/i)
|
87
|
+
meta_tag = %(<meta name="csrf-token" content="#{csrf_token}">)
|
88
|
+
body_content = body_content.sub(/<head>/i, "<head>\n#{meta_tag}")
|
89
|
+
|
90
|
+
# Update content length if present
|
91
|
+
content_length_key = headers.keys.find { |k| k.downcase == 'content-length' }
|
92
|
+
headers[content_length_key] = body_content.bytesize.to_s if content_length_key
|
93
|
+
|
94
|
+
[status, headers, [body_content]]
|
95
|
+
else
|
96
|
+
response
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
def ensure_session_cookie(request, headers, session_id)
|
101
|
+
# Check if session ID already exists in cookies
|
102
|
+
existing_cookie = request.cookies['_otto_session']
|
103
|
+
return if existing_cookie == session_id
|
104
|
+
|
105
|
+
# Set the session cookie
|
106
|
+
cookie_value = "#{session_id}; Path=/; HttpOnly; SameSite=Lax"
|
107
|
+
cookie_value += '; Secure' if request.scheme == 'https'
|
108
|
+
|
109
|
+
# Handle existing Set-Cookie headers
|
110
|
+
existing_cookies = headers['set-cookie'] || headers['Set-Cookie']
|
111
|
+
if existing_cookies
|
112
|
+
# Append to existing cookies (handle both string and array formats)
|
113
|
+
if existing_cookies.is_a?(Array)
|
114
|
+
existing_cookies << "_otto_session=#{cookie_value}"
|
115
|
+
else
|
116
|
+
headers['set-cookie'] = [existing_cookies, "_otto_session=#{cookie_value}"]
|
117
|
+
end
|
118
|
+
else
|
119
|
+
headers['set-cookie'] = "_otto_session=#{cookie_value}"
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
def html_response?(response)
|
124
|
+
return false unless response.is_a?(Array) && response.length >= 2
|
125
|
+
|
126
|
+
headers = response[1]
|
127
|
+
content_type = headers.find { |k, _v| k.downcase == 'content-type' }&.last
|
128
|
+
content_type&.include?('text/html')
|
129
|
+
end
|
130
|
+
|
131
|
+
def csrf_error_response
|
132
|
+
[
|
133
|
+
403,
|
134
|
+
{
|
135
|
+
'content-type' => 'application/json',
|
136
|
+
'content-length' => csrf_error_body.bytesize.to_s,
|
137
|
+
},
|
138
|
+
[csrf_error_body],
|
139
|
+
]
|
140
|
+
end
|
141
|
+
|
142
|
+
def csrf_error_body
|
143
|
+
{
|
144
|
+
error: 'CSRF token validation failed',
|
145
|
+
message: 'The request could not be authenticated. Please refresh the page and try again.',
|
146
|
+
}.to_json
|
147
|
+
end
|
148
|
+
end
|
149
|
+
|
150
|
+
# Helper methods for CSRF token handling in views and controllers
|
151
|
+
module CSRFHelpers
|
152
|
+
def csrf_token
|
153
|
+
if @csrf_token.nil? && otto.respond_to?(:security_config)
|
154
|
+
session_id = otto.security_config.get_or_create_session_id(req)
|
155
|
+
@csrf_token = otto.security_config.generate_csrf_token(session_id)
|
156
|
+
end
|
157
|
+
@csrf_token
|
158
|
+
end
|
159
|
+
|
160
|
+
def csrf_meta_tag
|
161
|
+
%(<meta name="csrf-token" content="#{csrf_token}">)
|
162
|
+
end
|
163
|
+
|
164
|
+
def csrf_form_tag
|
165
|
+
%(<input type="hidden" name="#{csrf_token_key}" value="#{csrf_token}">)
|
166
|
+
end
|
167
|
+
|
168
|
+
def csrf_token_key
|
169
|
+
if otto.respond_to?(:security_config)
|
170
|
+
otto.security_config.csrf_token_key
|
171
|
+
else
|
172
|
+
'_csrf_token'
|
173
|
+
end
|
174
|
+
end
|
175
|
+
end
|
176
|
+
end
|
177
|
+
end
|
@@ -0,0 +1,299 @@
|
|
1
|
+
# lib/otto/security/validator.rb
|
2
|
+
|
3
|
+
require 'json'
|
4
|
+
require 'cgi'
|
5
|
+
|
6
|
+
class Otto
|
7
|
+
module Security
|
8
|
+
# ValidationMiddleware provides input validation and sanitization for web requests
|
9
|
+
class ValidationMiddleware
|
10
|
+
# Character validation patterns
|
11
|
+
INVALID_CHARACTERS = /[\x00-\x1f\x7f-\xff]/n
|
12
|
+
NULL_BYTE = /\0/
|
13
|
+
|
14
|
+
DANGEROUS_PATTERNS = [
|
15
|
+
/<script[^>]*>/i, # Script tags
|
16
|
+
/javascript:/i, # JavaScript protocol
|
17
|
+
/data:.*base64/i, # Data URLs with base64
|
18
|
+
/on\w+\s*=/i, # Event handlers
|
19
|
+
/expression\s*\(/i, # CSS expressions
|
20
|
+
/url\s*\(/i, # CSS url() functions
|
21
|
+
NULL_BYTE, # Null bytes
|
22
|
+
INVALID_CHARACTERS, # Control characters and extended ASCII
|
23
|
+
].freeze
|
24
|
+
|
25
|
+
SQL_INJECTION_PATTERNS = [
|
26
|
+
/('|(\\')|(;)|(\\)|(--)|(%27)|(%3B)|(%3D))/i,
|
27
|
+
/(union|select|insert|update|delete|drop|create|alter|exec|execute)/i,
|
28
|
+
/(or|and)\s+\w+\s*=\s*\w+/i,
|
29
|
+
/\d+\s*(=|>|<|>=|<=|<>|!=)\s*\d+/i,
|
30
|
+
].freeze
|
31
|
+
|
32
|
+
def initialize(app, config = nil)
|
33
|
+
@app = app
|
34
|
+
@config = config || Otto::Security::Config.new
|
35
|
+
end
|
36
|
+
|
37
|
+
def call(env)
|
38
|
+
return @app.call(env) unless @config.input_validation
|
39
|
+
|
40
|
+
request = Rack::Request.new(env)
|
41
|
+
|
42
|
+
begin
|
43
|
+
# Validate request size
|
44
|
+
validate_request_size(request)
|
45
|
+
|
46
|
+
# Validate content type
|
47
|
+
validate_content_type(request)
|
48
|
+
|
49
|
+
# Validate and sanitize parameters
|
50
|
+
begin
|
51
|
+
validate_parameters(request) if request.params
|
52
|
+
rescue Rack::QueryParser::QueryLimitError => ex
|
53
|
+
# Handle Rack's built-in query parsing limits
|
54
|
+
raise Otto::Security::ValidationError, "Parameter structure too complex: #{ex.message}"
|
55
|
+
end
|
56
|
+
|
57
|
+
# Validate headers
|
58
|
+
validate_headers(request)
|
59
|
+
|
60
|
+
@app.call(env)
|
61
|
+
rescue Otto::Security::ValidationError => ex
|
62
|
+
validation_error_response(ex.message)
|
63
|
+
rescue Otto::Security::RequestTooLargeError => ex
|
64
|
+
request_too_large_response(ex.message)
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
private
|
69
|
+
|
70
|
+
def validate_request_size(request)
|
71
|
+
content_length = request.env['CONTENT_LENGTH']
|
72
|
+
@config.validate_request_size(content_length)
|
73
|
+
end
|
74
|
+
|
75
|
+
def validate_content_type(request)
|
76
|
+
content_type = request.env['CONTENT_TYPE']
|
77
|
+
return unless content_type
|
78
|
+
|
79
|
+
# Block dangerous content types
|
80
|
+
dangerous_types = [
|
81
|
+
'application/x-shockwave-flash',
|
82
|
+
'application/x-silverlight-app',
|
83
|
+
'text/vbscript',
|
84
|
+
'application/vbscript',
|
85
|
+
]
|
86
|
+
|
87
|
+
if dangerous_types.any? { |type| content_type.downcase.include?(type) }
|
88
|
+
raise Otto::Security::ValidationError, "Dangerous content type: #{content_type}"
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
def validate_parameters(request)
|
93
|
+
validate_param_structure(request.params, 0)
|
94
|
+
sanitize_params(request.params)
|
95
|
+
end
|
96
|
+
|
97
|
+
def validate_param_structure(params, depth = 0)
|
98
|
+
if depth > @config.max_param_depth
|
99
|
+
raise Otto::Security::ValidationError, "Parameter depth exceeds maximum (#{@config.max_param_depth})"
|
100
|
+
end
|
101
|
+
|
102
|
+
case params
|
103
|
+
when Hash
|
104
|
+
if params.keys.length > @config.max_param_keys
|
105
|
+
raise Otto::Security::ValidationError, "Too many parameters (#{params.keys.length} > #{@config.max_param_keys})"
|
106
|
+
end
|
107
|
+
|
108
|
+
params.each do |key, value|
|
109
|
+
validate_param_key(key)
|
110
|
+
validate_param_structure(value, depth + 1) if value.is_a?(Hash) || value.is_a?(Array)
|
111
|
+
end
|
112
|
+
when Array
|
113
|
+
if params.length > @config.max_param_keys
|
114
|
+
raise Otto::Security::ValidationError, "Too many array elements (#{params.length} > #{@config.max_param_keys})"
|
115
|
+
end
|
116
|
+
|
117
|
+
params.each do |value|
|
118
|
+
validate_param_structure(value, depth + 1) if value.is_a?(Hash) || value.is_a?(Array)
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
def validate_param_key(key)
|
124
|
+
key_str = key.to_s
|
125
|
+
|
126
|
+
# Check for dangerous characters in parameter names using shared patterns
|
127
|
+
if key_str.match?(NULL_BYTE) || key_str.match?(INVALID_CHARACTERS)
|
128
|
+
raise Otto::Security::ValidationError, "Invalid characters in parameter name: #{key_str}"
|
129
|
+
end
|
130
|
+
|
131
|
+
# Check for suspiciously long parameter names
|
132
|
+
if key_str.length > 256
|
133
|
+
raise Otto::Security::ValidationError, "Parameter name too long: #{key_str[0..50]}..."
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
137
|
+
def sanitize_params(params)
|
138
|
+
case params
|
139
|
+
when Hash
|
140
|
+
params.each do |key, value|
|
141
|
+
params[key] = sanitize_value(value)
|
142
|
+
end
|
143
|
+
when Array
|
144
|
+
params.map! { |value| sanitize_value(value) }
|
145
|
+
else
|
146
|
+
sanitize_value(params)
|
147
|
+
end
|
148
|
+
end
|
149
|
+
|
150
|
+
def sanitize_value(value)
|
151
|
+
return value unless value.is_a?(String)
|
152
|
+
|
153
|
+
# Check for dangerous patterns
|
154
|
+
DANGEROUS_PATTERNS.each do |pattern|
|
155
|
+
if value.match?(pattern)
|
156
|
+
raise Otto::Security::ValidationError, 'Dangerous content detected in parameter'
|
157
|
+
end
|
158
|
+
end
|
159
|
+
|
160
|
+
# Check for SQL injection patterns
|
161
|
+
SQL_INJECTION_PATTERNS.each do |pattern|
|
162
|
+
if value.match?(pattern)
|
163
|
+
raise Otto::Security::ValidationError, 'Potential SQL injection detected'
|
164
|
+
end
|
165
|
+
end
|
166
|
+
|
167
|
+
# Check for extremely long values
|
168
|
+
if value.length > 10_000
|
169
|
+
raise Otto::Security::ValidationError, "Parameter value too long (#{value.length} characters)"
|
170
|
+
end
|
171
|
+
|
172
|
+
# Basic sanitization - remove null bytes and control characters
|
173
|
+
sanitized = value.gsub(/\0/, '').gsub(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/, '')
|
174
|
+
|
175
|
+
# Additional sanitization for common attack vectors
|
176
|
+
sanitized = sanitized.gsub(/<!--.*?-->/m, '') # Remove HTML comments
|
177
|
+
sanitized.gsub(/<!\[CDATA\[.*?\]\]>/m, '') # Remove CDATA sections
|
178
|
+
end
|
179
|
+
|
180
|
+
def validate_headers(request)
|
181
|
+
# Check for dangerous headers
|
182
|
+
dangerous_headers = %w[
|
183
|
+
HTTP_X_FORWARDED_HOST
|
184
|
+
HTTP_X_ORIGINAL_URL
|
185
|
+
HTTP_X_REWRITE_URL
|
186
|
+
HTTP_DESTINATION
|
187
|
+
HTTP_UPGRADE_INSECURE_REQUESTS
|
188
|
+
]
|
189
|
+
|
190
|
+
dangerous_headers.each do |header|
|
191
|
+
value = request.env[header]
|
192
|
+
next unless value
|
193
|
+
|
194
|
+
# Basic validation - no null bytes or control characters
|
195
|
+
if value.match?(NULL_BYTE) || value.match?(INVALID_CHARACTERS)
|
196
|
+
raise Otto::Security::ValidationError, "Invalid characters in header: #{header}"
|
197
|
+
end
|
198
|
+
end
|
199
|
+
|
200
|
+
# Validate User-Agent length
|
201
|
+
user_agent = request.env['HTTP_USER_AGENT']
|
202
|
+
if user_agent && user_agent.length > 1000
|
203
|
+
raise Otto::Security::ValidationError, 'User-Agent header too long'
|
204
|
+
end
|
205
|
+
|
206
|
+
# Validate Referer header
|
207
|
+
referer = request.env['HTTP_REFERER']
|
208
|
+
if referer && referer.length > 2000
|
209
|
+
raise Otto::Security::ValidationError, 'Referer header too long'
|
210
|
+
end
|
211
|
+
end
|
212
|
+
|
213
|
+
def validation_error_response(message)
|
214
|
+
[
|
215
|
+
400,
|
216
|
+
{
|
217
|
+
'content-type' => 'application/json',
|
218
|
+
'content-length' => validation_error_body(message).bytesize.to_s,
|
219
|
+
},
|
220
|
+
[validation_error_body(message)],
|
221
|
+
]
|
222
|
+
end
|
223
|
+
|
224
|
+
def request_too_large_response(message)
|
225
|
+
[
|
226
|
+
413,
|
227
|
+
{
|
228
|
+
'content-type' => 'application/json',
|
229
|
+
'content-length' => request_too_large_body(message).bytesize.to_s,
|
230
|
+
},
|
231
|
+
[request_too_large_body(message)],
|
232
|
+
]
|
233
|
+
end
|
234
|
+
|
235
|
+
def validation_error_body(message)
|
236
|
+
require 'json'
|
237
|
+
{
|
238
|
+
error: 'Validation failed',
|
239
|
+
message: message,
|
240
|
+
}.to_json
|
241
|
+
end
|
242
|
+
|
243
|
+
def request_too_large_body(message)
|
244
|
+
require 'json'
|
245
|
+
{
|
246
|
+
error: 'Request too large',
|
247
|
+
message: message,
|
248
|
+
}.to_json
|
249
|
+
end
|
250
|
+
end
|
251
|
+
|
252
|
+
module ValidationHelpers
|
253
|
+
def validate_input(input, max_length: 1000, allow_html: false)
|
254
|
+
return input if input.nil? || input.empty?
|
255
|
+
|
256
|
+
input_str = input.to_s
|
257
|
+
|
258
|
+
# Check length
|
259
|
+
if input_str.length > max_length
|
260
|
+
raise Otto::Security::ValidationError, "Input too long (#{input_str.length} > #{max_length})"
|
261
|
+
end
|
262
|
+
|
263
|
+
# Check for dangerous patterns unless HTML is allowed
|
264
|
+
unless allow_html
|
265
|
+
ValidationMiddleware::DANGEROUS_PATTERNS.each do |pattern|
|
266
|
+
if input_str.match?(pattern)
|
267
|
+
raise Otto::Security::ValidationError, 'Dangerous content detected'
|
268
|
+
end
|
269
|
+
end
|
270
|
+
end
|
271
|
+
|
272
|
+
# Always check for SQL injection
|
273
|
+
ValidationMiddleware::SQL_INJECTION_PATTERNS.each do |pattern|
|
274
|
+
if input_str.match?(pattern)
|
275
|
+
raise Otto::Security::ValidationError, 'Potential SQL injection detected'
|
276
|
+
end
|
277
|
+
end
|
278
|
+
|
279
|
+
input_str
|
280
|
+
end
|
281
|
+
|
282
|
+
def sanitize_filename(filename)
|
283
|
+
return nil if filename.nil? || filename.empty?
|
284
|
+
|
285
|
+
# Remove path components and dangerous characters
|
286
|
+
clean_name = File.basename(filename.to_s)
|
287
|
+
clean_name = clean_name.gsub(/[^\w\-_\.]/, '_')
|
288
|
+
clean_name = clean_name.gsub(/_{2,}/, '_')
|
289
|
+
clean_name = clean_name.gsub(/^_+|_+$/, '')
|
290
|
+
|
291
|
+
# Ensure it's not empty and has reasonable length
|
292
|
+
clean_name = 'file' if clean_name.empty?
|
293
|
+
clean_name = clean_name[0..100] if clean_name.length > 100
|
294
|
+
|
295
|
+
clean_name
|
296
|
+
end
|
297
|
+
end
|
298
|
+
end
|
299
|
+
end
|
data/lib/otto/static.rb
CHANGED
@@ -1,20 +1,33 @@
|
|
1
|
+
# lib/otto/static.rb
|
1
2
|
|
2
3
|
class Otto
|
3
|
-
|
4
4
|
module Static
|
5
5
|
extend self
|
6
|
+
|
6
7
|
def server_error
|
7
|
-
[500, {'
|
8
|
+
[500, security_headers.merge({ 'content-type' => 'text/plain' }), ['Server error']]
|
8
9
|
end
|
10
|
+
|
9
11
|
def not_found
|
10
|
-
[404, {'
|
12
|
+
[404, security_headers.merge({ 'content-type' => 'text/plain' }), ['Not Found']]
|
13
|
+
end
|
14
|
+
|
15
|
+
def security_headers
|
16
|
+
{
|
17
|
+
'x-frame-options' => 'DENY',
|
18
|
+
'x-content-type-options' => 'nosniff',
|
19
|
+
'x-xss-protection' => '1; mode=block',
|
20
|
+
'referrer-policy' => 'strict-origin-when-cross-origin',
|
21
|
+
}
|
11
22
|
end
|
23
|
+
|
12
24
|
# Enable string or symbol key access to the nested params hash.
|
13
25
|
def indifferent_params(params)
|
14
26
|
if params.is_a?(Hash)
|
15
27
|
params = indifferent_hash.merge(params)
|
16
28
|
params.each do |key, value|
|
17
29
|
next unless value.is_a?(Hash) || value.is_a?(Array)
|
30
|
+
|
18
31
|
params[key] = indifferent_params(value)
|
19
32
|
end
|
20
33
|
elsif params.is_a?(Array)
|
@@ -27,10 +40,10 @@ class Otto
|
|
27
40
|
end
|
28
41
|
end
|
29
42
|
end
|
43
|
+
|
30
44
|
# Creates a Hash with indifferent access.
|
31
45
|
def indifferent_hash
|
32
|
-
Hash.new {|hash,key| hash[key.to_s] if Symbol
|
46
|
+
Hash.new { |hash, key| hash[key.to_s] if key.is_a?(Symbol) }
|
33
47
|
end
|
34
48
|
end
|
35
|
-
|
36
49
|
end
|
data/lib/otto/version.rb
CHANGED
@@ -1,27 +1,5 @@
|
|
1
|
-
#
|
1
|
+
# lib/otto/version.rb
|
2
2
|
|
3
3
|
class Otto
|
4
|
-
|
5
|
-
#
|
6
|
-
module VERSION
|
7
|
-
def self.to_a
|
8
|
-
load_config
|
9
|
-
[@version[:MAJOR], @version[:MINOR], @version[:PATCH]]
|
10
|
-
end
|
11
|
-
|
12
|
-
def self.to_s
|
13
|
-
version = to_a.join('.')
|
14
|
-
"#{version}-#{@version[:PRE]}" if @version[:PRE]
|
15
|
-
end
|
16
|
-
|
17
|
-
def self.inspect
|
18
|
-
to_s
|
19
|
-
end
|
20
|
-
|
21
|
-
def self.load_config
|
22
|
-
return if @version
|
23
|
-
require 'yaml'
|
24
|
-
@version = YAML.load_file(File.join(__dir__, '..', '..', 'VERSION.yml'))
|
25
|
-
end
|
26
|
-
end
|
4
|
+
VERSION = '1.3.0'.freeze
|
27
5
|
end
|