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.
data/lib/otto.rb CHANGED
@@ -1,164 +1,231 @@
1
+ require 'json'
1
2
  require 'logger'
3
+ require 'securerandom'
4
+ require 'uri'
2
5
 
3
6
  require 'rack/request'
4
7
  require 'rack/response'
5
8
  require 'rack/utils'
6
- require 'addressable/uri'
7
9
 
8
10
  require_relative 'otto/route'
9
11
  require_relative 'otto/static'
10
12
  require_relative 'otto/helpers/request'
11
13
  require_relative 'otto/helpers/response'
12
14
  require_relative 'otto/version'
15
+ require_relative 'otto/security/config'
16
+ require_relative 'otto/security/csrf'
17
+ require_relative 'otto/security/validator'
13
18
 
14
19
  # Otto is a simple Rack router that allows you to define routes in a file
20
+ # with built-in security features including CSRF protection, input validation,
21
+ # and trusted proxy support.
15
22
  #
23
+ # Basic usage:
24
+ # otto = Otto.new('routes.txt')
25
+ #
26
+ # With security features:
27
+ # otto = Otto.new('routes.txt', {
28
+ # csrf_protection: true,
29
+ # request_validation: true,
30
+ # trusted_proxies: ['10.0.0.0/8']
31
+ # })
32
+ #
33
+ # Security headers are applied conservatively by default (only basic headers
34
+ # like X-Content-Type-Options). Restrictive headers like HSTS, CSP, and
35
+ # X-Frame-Options must be enabled explicitly:
36
+ # otto.enable_hsts!
37
+ # otto.enable_csp!
38
+ # otto.enable_frame_protection!
16
39
  #
17
40
  class Otto
18
41
  LIB_HOME = __dir__ unless defined?(Otto::LIB_HOME)
19
42
 
20
- @debug = ENV['OTTO_DEBUG'] == 'true'
43
+ @debug = ENV['OTTO_DEBUG'] == 'true'
21
44
  @logger = Logger.new($stdout, Logger::INFO)
22
45
 
23
- attr_reader :routes, :routes_literal, :routes_static, :route_definitions
24
- attr_reader :option, :static_route
25
- attr_accessor :not_found, :server_error
46
+ attr_reader :routes, :routes_literal, :routes_static, :route_definitions, :option, :static_route, :security_config
47
+ attr_accessor :not_found, :server_error, :middleware_stack
26
48
 
27
- def initialize path=nil, opts={}
28
- @routes_static = { GET: {} }
29
- @routes = { GET: [] }
30
- @routes_literal = { GET: {} }
49
+ def initialize(path = nil, opts = {})
50
+ @routes_static = { GET: {} }
51
+ @routes = { GET: [] }
52
+ @routes_literal = { GET: {} }
31
53
  @route_definitions = {}
32
- @option = opts.merge({
54
+ @option = {
33
55
  public: nil,
34
- locale: 'en'
35
- })
56
+ locale: 'en',
57
+ }.merge(opts)
58
+ @security_config = Otto::Security::Config.new
59
+ @middleware_stack = []
60
+
61
+ # Configure security based on options
62
+ configure_security(opts)
63
+
36
64
  Otto.logger.debug "new Otto: #{opts}" if Otto.debug
37
65
  load(path) unless path.nil?
38
66
  super()
39
67
  end
40
- alias_method :options, :option
68
+ alias options option
41
69
 
42
- def load path
70
+ def load(path)
43
71
  path = File.expand_path(path)
44
72
  raise ArgumentError, "Bad path: #{path}" unless File.exist?(path)
45
- raw = File.readlines(path).select { |line| line =~ /^\w/ }.collect { |line| line.strip.split(/\s+/) }
46
- raw.each { |entry|
47
- begin
48
- verb, path, definition = *entry
49
- route = Otto::Route.new verb, path, definition
50
- route.otto = self
51
- path_clean = path.gsub /\/$/, ''
52
- @route_definitions[route.definition] = route
53
- Otto.logger.debug "route: #{route.pattern}" if Otto.debug
54
- @routes[route.verb] ||= []
55
- @routes[route.verb] << route
56
- @routes_literal[route.verb] ||= {}
57
- @routes_literal[route.verb][path_clean] = route
58
73
 
59
- rescue StandardError => ex
60
- Otto.logger.error "Bad route in #{path}: #{entry}"
61
- end
62
- }
74
+ raw = File.readlines(path).select { |line| line =~ /^\w/ }.collect { |line| line.strip.split(/\s+/) }
75
+ raw.each do |entry|
76
+ verb, path, definition = *entry
77
+ route = Otto::Route.new verb, path, definition
78
+ route.otto = self
79
+ path_clean = path.gsub(%r{/$}, '')
80
+ @route_definitions[route.definition] = route
81
+ Otto.logger.debug "route: #{route.pattern}" if Otto.debug
82
+ @routes[route.verb] ||= []
83
+ @routes[route.verb] << route
84
+ @routes_literal[route.verb] ||= {}
85
+ @routes_literal[route.verb][path_clean] = route
86
+ rescue StandardError
87
+ Otto.logger.error "Bad route in #{path}: #{entry}"
88
+ end
63
89
  self
64
90
  end
65
91
 
66
- def safe_file? path
67
- globstr = File.join(option[:public], '*')
68
- pathstr = File.join(option[:public], path)
69
- File.fnmatch?(globstr, pathstr) && (File.owned?(pathstr) || File.grpowned?(pathstr)) && File.readable?(pathstr) && !File.directory?(pathstr)
92
+ def safe_file?(path)
93
+ return false if option[:public].nil? || option[:public].empty?
94
+ return false if path.nil? || path.empty?
95
+
96
+ # Normalize and resolve the public directory path
97
+ public_dir = File.expand_path(option[:public])
98
+ return false unless File.directory?(public_dir)
99
+
100
+ # Clean the requested path - remove null bytes and normalize
101
+ clean_path = path.delete("\0").strip
102
+ return false if clean_path.empty?
103
+
104
+ # Join and expand to get the full resolved path
105
+ requested_path = File.expand_path(File.join(public_dir, clean_path))
106
+
107
+ # Ensure the resolved path is within the public directory (prevents path traversal)
108
+ return false unless requested_path.start_with?(public_dir + File::SEPARATOR)
109
+
110
+ # Check file exists, is readable, and is not a directory
111
+ File.exist?(requested_path) &&
112
+ File.readable?(requested_path) &&
113
+ !File.directory?(requested_path) &&
114
+ (File.owned?(requested_path) || File.grpowned?(requested_path))
70
115
  end
71
116
 
72
- def safe_dir? path
73
- (File.owned?(path) || File.grpowned?(path)) && File.directory?(path)
117
+ def safe_dir?(path)
118
+ return false if path.nil? || path.empty?
119
+
120
+ # Clean and expand the path
121
+ clean_path = path.delete("\0").strip
122
+ return false if clean_path.empty?
123
+
124
+ expanded_path = File.expand_path(clean_path)
125
+
126
+ # Check directory exists, is readable, and has proper ownership
127
+ File.directory?(expanded_path) &&
128
+ File.readable?(expanded_path) &&
129
+ (File.owned?(expanded_path) || File.grpowned?(expanded_path))
74
130
  end
75
131
 
76
- def add_static_path path
77
- if safe_file?(path)
78
- base_path = File.split(path).first
79
- # Files in the root directory can refer to themselves
80
- base_path = path if base_path == '/'
81
- static_path = File.join(option[:public], base_path)
82
- Otto.logger.debug "new static route: #{base_path} (#{path})" if Otto.debug
83
- routes_static[:GET][base_path] = base_path
132
+ def add_static_path(path)
133
+ return unless safe_file?(path)
134
+
135
+ base_path = File.split(path).first
136
+ # Files in the root directory can refer to themselves
137
+ base_path = path if base_path == '/'
138
+ File.join(option[:public], base_path)
139
+ Otto.logger.debug "new static route: #{base_path} (#{path})" if Otto.debug
140
+ routes_static[:GET][base_path] = base_path
141
+ end
142
+
143
+ def call(env)
144
+ # Apply middleware stack
145
+ app = ->(e) { handle_request(e) }
146
+ @middleware_stack.reverse_each do |middleware|
147
+ app = middleware.new(app, @security_config)
148
+ end
149
+
150
+ begin
151
+ app.call(env)
152
+ rescue StandardError => ex
153
+ handle_error(ex, env)
84
154
  end
85
155
  end
86
156
 
87
- def call env
88
- locale = determine_locale env
157
+ def handle_request(env)
158
+ locale = determine_locale env
89
159
  env['rack.locale'] = locale
90
- if option[:public] && safe_dir?(option[:public])
91
- @static_route ||= Rack::File.new(option[:public])
92
- end
93
- path_info = Rack::Utils.unescape(env['PATH_INFO'])
94
- path_info = '/' if path_info.to_s.empty?
160
+ @static_route ||= Rack::Files.new(option[:public]) if option[:public] && safe_dir?(option[:public])
161
+ path_info = Rack::Utils.unescape(env['PATH_INFO'])
162
+ path_info = '/' if path_info.to_s.empty?
95
163
 
96
164
  begin
97
165
  path_info_clean = path_info
98
166
  .encode(
99
- 'UTF-8', # Target encoding
167
+ 'UTF-8', # Target encoding
100
168
  invalid: :replace, # Replace invalid byte sequences
101
169
  undef: :replace, # Replace characters undefined in UTF-8
102
- replace: '' # Use empty string for replacement
170
+ replace: '', # Use empty string for replacement
103
171
  )
104
- .gsub(/\/$/, '') # Remove trailing slash, if present
172
+ .gsub(%r{/$}, '') # Remove trailing slash, if present
105
173
  rescue ArgumentError => ex
106
- # Log the error
107
- Otto.logger.error "[Otto.call] Error cleaning `#{path_info}`: #{ex.message}"
174
+ # Log the error but don't expose details
175
+ Otto.logger.error '[Otto.handle_request] Path encoding error'
176
+ Otto.logger.debug "[Otto.handle_request] Error details: #{ex.message}" if Otto.debug
108
177
  # Set a default value or use the original path_info
109
178
  path_info_clean = path_info
110
179
  end
111
180
 
112
- base_path = File.split(path_info).first
181
+ base_path = File.split(path_info).first
113
182
  # Files in the root directory can refer to themselves
114
- base_path = path_info if base_path == '/'
115
- http_verb = env['REQUEST_METHOD'].upcase.to_sym
183
+ base_path = path_info if base_path == '/'
184
+ http_verb = env['REQUEST_METHOD'].upcase.to_sym
116
185
  literal_routes = routes_literal[http_verb] || {}
117
186
  literal_routes.merge! routes_literal[:GET] if http_verb == :HEAD
118
187
  if static_route && http_verb == :GET && routes_static[:GET].member?(base_path)
119
- #Otto.logger.debug " request: #{path_info} (static)"
188
+ # Otto.logger.debug " request: #{path_info} (static)"
120
189
  static_route.call(env)
121
190
  elsif literal_routes.has_key?(path_info_clean)
122
191
  route = literal_routes[path_info_clean]
123
- #Otto.logger.debug " request: #{http_verb} #{path_info} (literal route: #{route.verb} #{route.path})"
192
+ # Otto.logger.debug " request: #{http_verb} #{path_info} (literal route: #{route.verb} #{route.path})"
124
193
  route.call(env)
125
194
  elsif static_route && http_verb == :GET && safe_file?(path_info)
126
- static_path = File.join(option[:public], base_path)
127
- Otto.logger.debug " new static route: #{base_path} (#{path_info})"
195
+ Otto.logger.debug " new static route: #{base_path} (#{path_info})" if Otto.debug
128
196
  routes_static[:GET][base_path] = base_path
129
197
  static_route.call(env)
130
198
  else
131
- extra_params = {}
132
- found_route = nil
133
- valid_routes = routes[http_verb] || []
134
- valid_routes.push *routes[:GET] if http_verb == :HEAD
135
- valid_routes.each { |route|
136
- #Otto.logger.debug " request: #{http_verb} #{path_info} (trying route: #{route.verb} #{route.pattern})"
137
- if (match = route.pattern.match(path_info))
138
- values = match.captures.to_a
139
- # The first capture returned is the entire matched string b/c
140
- # we wrapped the entire regex in parens. We don't need it to
141
- # the full match.
142
- full_match = values.shift
143
- extra_params =
144
- if route.keys.any?
145
- route.keys.zip(values).inject({}) do |hash,(k,v)|
146
- if k == 'splat'
147
- (hash[k] ||= []) << v
148
- else
149
- hash[k] = v
150
- end
151
- hash
199
+ extra_params = {}
200
+ found_route = nil
201
+ valid_routes = routes[http_verb] || []
202
+ valid_routes.push(*routes[:GET]) if http_verb == :HEAD
203
+ valid_routes.each do |route|
204
+ # Otto.logger.debug " request: #{http_verb} #{path_info} (trying route: #{route.verb} #{route.pattern})"
205
+ next unless (match = route.pattern.match(path_info))
206
+
207
+ values = match.captures.to_a
208
+ # The first capture returned is the entire matched string b/c
209
+ # we wrapped the entire regex in parens. We don't need it to
210
+ # the full match.
211
+ values.shift
212
+ extra_params =
213
+ if route.keys.any?
214
+ route.keys.zip(values).each_with_object({}) do |(k, v), hash|
215
+ if k == 'splat'
216
+ (hash[k] ||= []) << v
217
+ else
218
+ hash[k] = v
152
219
  end
153
- elsif values.any?
154
- {'captures' => values}
155
- else
156
- {}
157
220
  end
158
- found_route = route
159
- break
160
- end
161
- }
221
+ elsif values.any?
222
+ { 'captures' => values }
223
+ else
224
+ {}
225
+ end
226
+ found_route = route
227
+ break
228
+ end
162
229
  found_route ||= literal_routes['/404']
163
230
  if found_route
164
231
  found_route.call env, extra_params
@@ -166,14 +233,6 @@ class Otto
166
233
  @not_found || Otto::Static.not_found
167
234
  end
168
235
  end
169
- rescue => ex
170
- Otto.logger.error "#{ex.class}: #{ex.message} #{ex.backtrace.join("\n")}"
171
-
172
- if found_route = literal_routes['/500']
173
- found_route.call env
174
- else
175
- @server_error || Otto::Static.server_error
176
- end
177
236
  end
178
237
 
179
238
  # Return the URI path for the given +route_definition+
@@ -181,44 +240,232 @@ class Otto
181
240
  #
182
241
  # Otto.default.path 'YourClass.somemethod' #=> /some/path
183
242
  #
184
- def uri route_definition, params={}
185
- #raise RuntimeError, "Not working"
243
+ def uri(route_definition, params = {})
244
+ # raise RuntimeError, "Not working"
186
245
  route = @route_definitions[route_definition]
187
- unless route.nil?
188
- local_params = params.clone
189
- local_path = route.path.clone
190
- if objid = local_params.delete(:id) || local_params.delete('id')
191
- local_path.gsub! /\*/, objid
192
- end
193
- local_params.each_pair { |k,v|
194
- next unless local_path.match(":#{k}")
195
- local_path.gsub!(":#{k}", local_params.delete(k))
196
- }
197
- uri = Addressable::URI.new
198
- uri.path = local_path
199
- uri.query_values = local_params
200
- uri.to_s
246
+ return if route.nil?
247
+
248
+ local_params = params.clone
249
+ local_path = route.path.clone
250
+
251
+ local_params.each_pair do |k, v|
252
+ next unless local_path.match(":#{k}")
253
+
254
+ local_path.gsub!(":#{k}", v.to_s)
255
+ local_params.delete(k)
256
+ end
257
+
258
+ uri = URI::HTTP.new(nil, nil, nil, nil, nil, local_path, nil, nil, nil)
259
+ unless local_params.empty?
260
+ query_string = local_params.map { |k, v| "#{URI.encode_www_form_component(k)}=#{URI.encode_www_form_component(v)}" }.join('&')
261
+ uri.query = query_string
201
262
  end
263
+ uri.to_s
202
264
  end
203
265
 
204
- def determine_locale env
266
+ def determine_locale(env)
205
267
  accept_langs = env['HTTP_ACCEPT_LANGUAGE']
206
- accept_langs = self.option[:locale] if accept_langs.to_s.empty?
207
- locales = []
268
+ accept_langs = option[:locale] if accept_langs.to_s.empty?
269
+ locales = []
208
270
  unless accept_langs.empty?
209
- locales = accept_langs.split(',').map { |l|
210
- l += ';q=1.0' unless l =~ /;q=\d+(?:\.\d+)?$/
271
+ locales = accept_langs.split(',').map do |l|
272
+ l += ';q=1.0' unless /;q=\d+(?:\.\d+)?$/.match?(l)
211
273
  l.split(';q=')
212
- }.sort_by { |locale, qvalue|
274
+ end.sort_by do |_locale, qvalue|
213
275
  qvalue.to_f
214
- }.collect { |locale, qvalue|
276
+ end.collect do |locale, _qvalue|
215
277
  locale
216
- }.reverse
278
+ end.reverse
217
279
  end
218
280
  Otto.logger.debug "locale: #{locales} (#{accept_langs})" if Otto.debug
219
281
  locales.empty? ? nil : locales
220
282
  end
221
283
 
284
+ # Add middleware to the stack
285
+ #
286
+ # @param middleware [Class] The middleware class to add
287
+ # @param args [Array] Additional arguments for the middleware
288
+ # @param block [Proc] Optional block for middleware configuration
289
+ def use(middleware, *, &)
290
+ @middleware_stack << middleware
291
+ end
292
+
293
+ # Enable CSRF protection for POST, PUT, DELETE, and PATCH requests.
294
+ # This will automatically add CSRF tokens to HTML forms and validate
295
+ # them on unsafe HTTP methods.
296
+ #
297
+ # @example
298
+ # otto.enable_csrf_protection!
299
+ def enable_csrf_protection!
300
+ return if middleware_enabled?(Otto::Security::CSRFMiddleware)
301
+
302
+ @security_config.enable_csrf_protection!
303
+ use Otto::Security::CSRFMiddleware
304
+ end
305
+
306
+ # Enable request validation including input sanitization, size limits,
307
+ # and protection against XSS and SQL injection attacks.
308
+ #
309
+ # @example
310
+ # otto.enable_request_validation!
311
+ def enable_request_validation!
312
+ return if middleware_enabled?(Otto::Security::ValidationMiddleware)
313
+
314
+ @security_config.input_validation = true
315
+ use Otto::Security::ValidationMiddleware
316
+ end
317
+
318
+ # Add a trusted proxy server for accurate client IP detection.
319
+ # Only requests from trusted proxies will have their forwarded headers honored.
320
+ #
321
+ # @param proxy [String, Regexp] IP address, CIDR range, or regex pattern
322
+ # @example
323
+ # otto.add_trusted_proxy('10.0.0.0/8')
324
+ # otto.add_trusted_proxy(/^172\.16\./)
325
+ def add_trusted_proxy(proxy)
326
+ @security_config.add_trusted_proxy(proxy)
327
+ end
328
+
329
+ # Set custom security headers that will be added to all responses.
330
+ # These merge with the default security headers.
331
+ #
332
+ # @param headers [Hash] Hash of header name => value pairs
333
+ # @example
334
+ # otto.set_security_headers({
335
+ # 'content-security-policy' => "default-src 'self'",
336
+ # 'strict-transport-security' => 'max-age=31536000'
337
+ # })
338
+ def set_security_headers(headers)
339
+ @security_config.security_headers.merge!(headers)
340
+ end
341
+
342
+ # Enable HTTP Strict Transport Security (HSTS) header.
343
+ # WARNING: This can make your domain inaccessible if HTTPS is not properly
344
+ # configured. Only enable this when you're certain HTTPS is working correctly.
345
+ #
346
+ # @param max_age [Integer] Maximum age in seconds (default: 1 year)
347
+ # @param include_subdomains [Boolean] Apply to all subdomains (default: true)
348
+ # @example
349
+ # otto.enable_hsts!(max_age: 86400, include_subdomains: false)
350
+ def enable_hsts!(max_age: 31_536_000, include_subdomains: true)
351
+ @security_config.enable_hsts!(max_age: max_age, include_subdomains: include_subdomains)
352
+ end
353
+
354
+ # Enable Content Security Policy (CSP) header to prevent XSS attacks.
355
+ # The default policy only allows resources from the same origin.
356
+ #
357
+ # @param policy [String] CSP policy string (default: "default-src 'self'")
358
+ # @example
359
+ # otto.enable_csp!("default-src 'self'; script-src 'self' 'unsafe-inline'")
360
+ def enable_csp!(policy = "default-src 'self'")
361
+ @security_config.enable_csp!(policy)
362
+ end
363
+
364
+ # Enable X-Frame-Options header to prevent clickjacking attacks.
365
+ #
366
+ # @param option [String] Frame options: 'DENY', 'SAMEORIGIN', or 'ALLOW-FROM uri'
367
+ # @example
368
+ # otto.enable_frame_protection!('DENY')
369
+ def enable_frame_protection!(option = 'SAMEORIGIN')
370
+ @security_config.enable_frame_protection!(option)
371
+ end
372
+
373
+ private
374
+
375
+ def configure_security(opts)
376
+ # Enable CSRF protection if requested
377
+ enable_csrf_protection! if opts[:csrf_protection]
378
+
379
+ # Enable request validation if requested
380
+ enable_request_validation! if opts[:request_validation]
381
+
382
+ # Add trusted proxies if provided
383
+ if opts[:trusted_proxies]
384
+ Array(opts[:trusted_proxies]).each { |proxy| add_trusted_proxy(proxy) }
385
+ end
386
+
387
+ # Set custom security headers
388
+ if opts[:security_headers]
389
+ set_security_headers(opts[:security_headers])
390
+ end
391
+ end
392
+
393
+ def middleware_enabled?(middleware_class)
394
+ @middleware_stack.any? { |m| m == middleware_class }
395
+ end
396
+
397
+ def handle_error(error, env)
398
+ # Log error details internally but don't expose them
399
+ error_id = SecureRandom.hex(8)
400
+ Otto.logger.error "[#{error_id}] #{error.class}: #{error.message}"
401
+ Otto.logger.debug "[#{error_id}] Backtrace: #{error.backtrace.join("\n")}" if Otto.debug
402
+
403
+ # Parse request for content negotiation
404
+ begin
405
+ Rack::Request.new(env)
406
+ rescue StandardError
407
+ nil
408
+ end
409
+ literal_routes = @routes_literal[:GET] || {}
410
+
411
+ # Try custom 500 route first
412
+ if found_route = literal_routes['/500']
413
+ begin
414
+ env['otto.error_id'] = error_id
415
+ return found_route.call(env)
416
+ rescue StandardError => ex
417
+ Otto.logger.error "[#{error_id}] Error in custom error handler: #{ex.message}"
418
+ end
419
+ end
420
+
421
+ # Content negotiation for built-in error response
422
+ accept_header = env['HTTP_ACCEPT'].to_s
423
+ if accept_header.include?('application/json')
424
+ return json_error_response(error_id)
425
+ end
426
+
427
+ # Fallback to built-in error response
428
+ @server_error || secure_error_response(error_id)
429
+ end
430
+
431
+ def secure_error_response(error_id)
432
+ body = if Otto.env?(:dev, :development)
433
+ "Server error (ID: #{error_id}). Check logs for details."
434
+ else
435
+ 'An error occurred. Please try again later.'
436
+ end
437
+
438
+ headers = {
439
+ 'content-type' => 'text/plain',
440
+ 'content-length' => body.bytesize.to_s,
441
+ }.merge(@security_config.security_headers)
442
+
443
+ [500, headers, [body]]
444
+ end
445
+
446
+ def json_error_response(error_id)
447
+ error_data = if Otto.env?(:dev, :development)
448
+ {
449
+ error: 'Internal Server Error',
450
+ message: 'Server error occurred. Check logs for details.',
451
+ error_id: error_id,
452
+ }
453
+ else
454
+ {
455
+ error: 'Internal Server Error',
456
+ message: 'An error occurred. Please try again later.',
457
+ }
458
+ end
459
+
460
+ body = JSON.generate(error_data)
461
+ headers = {
462
+ 'content-type' => 'application/json',
463
+ 'content-length' => body.bytesize.to_s,
464
+ }.merge(@security_config.security_headers)
465
+
466
+ [500, headers, [body]]
467
+ end
468
+
222
469
  class << self
223
470
  attr_accessor :debug, :logger
224
471
  end
@@ -228,15 +475,19 @@ class Otto
228
475
  @default ||= Otto.new
229
476
  @default
230
477
  end
231
- def load path
478
+
479
+ def load(path)
232
480
  default.load path
233
481
  end
234
- def path definition, params={}
482
+
483
+ def path(definition, params = {})
235
484
  default.path definition, params
236
485
  end
486
+
237
487
  def routes
238
488
  default.routes
239
489
  end
490
+
240
491
  def env? *guesses
241
492
  !guesses.flatten.select { |n| ENV['RACK_ENV'].to_s == n.to_s }.empty?
242
493
  end
data/otto.gemspec CHANGED
@@ -1,27 +1,27 @@
1
- # -*- encoding: utf-8 -*-
1
+ # otto.gemspec
2
2
 
3
3
  require_relative 'lib/otto/version'
4
4
 
5
5
  Gem::Specification.new do |spec|
6
- spec.name = "otto"
7
- spec.version = Otto::VERSION.to_s
8
- spec.summary = "Auto-define your rack-apps in plaintext."
9
- spec.description = "Otto: #{spec.summary}"
10
- spec.email = "gems@solutious.com"
11
- spec.authors = ["Delano Mandelbaum"]
12
- spec.license = "MIT"
13
- spec.files = Dir.chdir(File.expand_path(__dir__)) do
6
+ spec.name = 'otto'
7
+ spec.version = Otto::VERSION.to_s
8
+ spec.summary = 'Auto-define your rack-apps in plaintext.'
9
+ spec.description = "Otto: #{spec.summary}"
10
+ spec.email = 'gems@solutious.com'
11
+ spec.authors = ['Delano Mandelbaum']
12
+ spec.license = 'MIT'
13
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
14
14
  `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
15
15
  end
16
- spec.homepage = "https://github.com/delano/otto"
17
- spec.require_paths = ["lib"]
18
- spec.rubygems_version = "3.5.15" # Update to the latest version
16
+ spec.homepage = 'https://github.com/delano/otto'
17
+ spec.require_paths = ['lib']
19
18
 
20
- spec.required_ruby_version = ['>= 2.6.8', '< 4.0']
19
+ spec.required_ruby_version = ['>= 3.4', '< 4.0']
21
20
 
22
21
  # https://github.com/delano/otto/security/dependabot/5
23
22
  spec.add_dependency 'rexml', '>= 3.3.6'
24
23
 
25
- spec.add_runtime_dependency 'addressable', '~> 2.2', '< 3'
26
- spec.add_runtime_dependency 'rack', '~> 2.2', '< 3.0'
24
+ spec.add_dependency 'rack', '~> 3.1', '< 4.0'
25
+ spec.add_dependency 'rack-parser', '~> 0.7'
26
+ spec.metadata['rubygems_mfa_required'] = 'true'
27
27
  end