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