roda 3.107.0 → 3.109.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.
Files changed (105) hide show
  1. checksums.yaml +4 -4
  2. data/lib/roda/opts.rb +22 -0
  3. data/lib/roda/plugins/Integer_matcher_max.rb +1 -1
  4. data/lib/roda/plugins/_add_script_name.rb +14 -0
  5. data/lib/roda/plugins/_base64.rb +2 -2
  6. data/lib/roda/plugins/_json.rb +22 -0
  7. data/lib/roda/plugins/_optimized_matching.rb +1 -0
  8. data/lib/roda/plugins/_symbol_class_matchers.rb +6 -6
  9. data/lib/roda/plugins/additional_render_engines.rb +5 -1
  10. data/lib/roda/plugins/additional_view_directories.rb +5 -1
  11. data/lib/roda/plugins/all_verbs.rb +2 -2
  12. data/lib/roda/plugins/assets.rb +11 -14
  13. data/lib/roda/plugins/autoload_hash_branches.rb +9 -3
  14. data/lib/roda/plugins/autoload_named_routes.rb +9 -3
  15. data/lib/roda/plugins/bearer_token.rb +2 -2
  16. data/lib/roda/plugins/break.rb +9 -1
  17. data/lib/roda/plugins/caching.rb +12 -12
  18. data/lib/roda/plugins/capture_erb.rb +5 -1
  19. data/lib/roda/plugins/chunked.rb +11 -5
  20. data/lib/roda/plugins/class_level_routing.rb +5 -3
  21. data/lib/roda/plugins/class_matchers.rb +3 -1
  22. data/lib/roda/plugins/common_logger.rb +11 -7
  23. data/lib/roda/plugins/conditional_sessions.rb +1 -1
  24. data/lib/roda/plugins/content_for.rb +5 -1
  25. data/lib/roda/plugins/content_security_policy.rb +6 -2
  26. data/lib/roda/plugins/cookie_flags.rb +8 -4
  27. data/lib/roda/plugins/cookies.rb +7 -3
  28. data/lib/roda/plugins/custom_block_results.rb +5 -3
  29. data/lib/roda/plugins/custom_matchers.rb +4 -2
  30. data/lib/roda/plugins/default_headers.rb +2 -5
  31. data/lib/roda/plugins/default_status.rb +1 -0
  32. data/lib/roda/plugins/each_part.rb +23 -27
  33. data/lib/roda/plugins/environments.rb +3 -7
  34. data/lib/roda/plugins/error_email.rb +11 -7
  35. data/lib/roda/plugins/error_handler.rb +5 -3
  36. data/lib/roda/plugins/error_mail.rb +6 -2
  37. data/lib/roda/plugins/exception_page.rb +2 -2
  38. data/lib/roda/plugins/h.rb +4 -4
  39. data/lib/roda/plugins/hash_branch_view_subdir.rb +6 -4
  40. data/lib/roda/plugins/hash_branches.rb +15 -9
  41. data/lib/roda/plugins/hash_paths.rb +11 -7
  42. data/lib/roda/plugins/hash_public.rb +13 -9
  43. data/lib/roda/plugins/hash_public_cache.rb +9 -7
  44. data/lib/roda/plugins/hash_routes.rb +5 -3
  45. data/lib/roda/plugins/heartbeat.rb +6 -2
  46. data/lib/roda/plugins/hmac_paths.rb +36 -32
  47. data/lib/roda/plugins/hooks.rb +13 -10
  48. data/lib/roda/plugins/host_authorization.rb +7 -2
  49. data/lib/roda/plugins/host_routing.rb +36 -6
  50. data/lib/roda/plugins/invalid_request_body.rb +22 -4
  51. data/lib/roda/plugins/ip_from_header.rb +5 -1
  52. data/lib/roda/plugins/json.rb +10 -8
  53. data/lib/roda/plugins/json_parser.rb +43 -11
  54. data/lib/roda/plugins/mail_processor.rb +11 -8
  55. data/lib/roda/plugins/mailer.rb +9 -7
  56. data/lib/roda/plugins/match_affix.rb +6 -1
  57. data/lib/roda/plugins/match_hook_args.rb +7 -5
  58. data/lib/roda/plugins/middleware.rb +13 -7
  59. data/lib/roda/plugins/module_include.rb +1 -1
  60. data/lib/roda/plugins/multi_public.rb +6 -2
  61. data/lib/roda/plugins/multi_route.rb +1 -1
  62. data/lib/roda/plugins/multi_run.rb +13 -17
  63. data/lib/roda/plugins/named_routes.rb +8 -6
  64. data/lib/roda/plugins/named_templates.rb +5 -3
  65. data/lib/roda/plugins/optimized_segment_matchers.rb +13 -6
  66. data/lib/roda/plugins/optimized_string_matchers.rb +6 -3
  67. data/lib/roda/plugins/params_capturing.rb +16 -7
  68. data/lib/roda/plugins/params_capturing_restore.rb +72 -0
  69. data/lib/roda/plugins/part.rb +4 -4
  70. data/lib/roda/plugins/pass.rb +4 -1
  71. data/lib/roda/plugins/path.rb +22 -20
  72. data/lib/roda/plugins/path_matchers.rb +5 -1
  73. data/lib/roda/plugins/path_rewriter.rb +8 -5
  74. data/lib/roda/plugins/permissions_policy.rb +7 -3
  75. data/lib/roda/plugins/public.rb +15 -14
  76. data/lib/roda/plugins/recheck_precompiled_assets.rb +1 -1
  77. data/lib/roda/plugins/redirect_http_to_https.rb +6 -3
  78. data/lib/roda/plugins/render.rb +58 -61
  79. data/lib/roda/plugins/render_coverage.rb +12 -8
  80. data/lib/roda/plugins/render_each.rb +2 -2
  81. data/lib/roda/plugins/render_locals.rb +12 -6
  82. data/lib/roda/plugins/request_aref.rb +1 -1
  83. data/lib/roda/plugins/response_content_type.rb +7 -3
  84. data/lib/roda/plugins/route_csrf.rb +42 -5
  85. data/lib/roda/plugins/run_append_slash.rb +12 -4
  86. data/lib/roda/plugins/sec_fetch_site_csrf.rb +11 -4
  87. data/lib/roda/plugins/sessions.rb +17 -13
  88. data/lib/roda/plugins/shape_friendly.rb +7 -5
  89. data/lib/roda/plugins/sinatra_helpers.rb +7 -3
  90. data/lib/roda/plugins/static.rb +3 -0
  91. data/lib/roda/plugins/status_handler.rb +5 -3
  92. data/lib/roda/plugins/strip_path_prefix.rb +4 -2
  93. data/lib/roda/plugins/symbol_matchers.rb +3 -1
  94. data/lib/roda/plugins/symbol_views.rb +1 -1
  95. data/lib/roda/plugins/timestamp_public.rb +8 -4
  96. data/lib/roda/plugins/type_routing.rb +10 -6
  97. data/lib/roda/plugins/typecast_params.rb +2 -2
  98. data/lib/roda/plugins/typecast_params_sized_integers.rb +1 -1
  99. data/lib/roda/plugins/url_escape.rb +2 -14
  100. data/lib/roda/plugins/view_options.rb +3 -5
  101. data/lib/roda/plugins.rb +14 -4
  102. data/lib/roda/request.rb +25 -9
  103. data/lib/roda/version.rb +1 -1
  104. data/lib/roda.rb +32 -21
  105. metadata +7 -3
@@ -1,6 +1,5 @@
1
1
  # frozen-string-literal: true
2
2
 
3
- require 'json'
4
3
  require 'find'
5
4
 
6
5
  class Roda
@@ -29,6 +28,7 @@ class Roda
29
28
  # load_hash_public_cache_file
30
29
  module HashPublicCache
31
30
  def self.load_dependencies(app, _cache_file, opts = OPTS)
31
+ app.plugin :_json
32
32
  app.plugin :hash_public, opts
33
33
  end
34
34
 
@@ -40,13 +40,15 @@ class Roda
40
40
  end
41
41
 
42
42
  module ClassMethods
43
+ RodaPlugins.opt_attr_reader(self, :hash_public_cache_file)
44
+
43
45
  # Load the hash public cache file, if it exists. This replaces the hash
44
46
  # public cache with the values from the file.
45
47
  def load_hash_public_cache_file
46
- file = opts[:hash_public_cache_file]
48
+ file = hash_public_cache_file
47
49
  return unless File.file?(file)
48
50
 
49
- cache = opts[:hash_public_cache] = (opts[:json_parser] || ::JSON.method(:parse)).call(::File.read(file))
51
+ cache = opts[:hash_public_cache] = json_parser.call(::File.read(file))
50
52
  cache.each_value(&:freeze)
51
53
  nil
52
54
  end
@@ -56,13 +58,13 @@ class Roda
56
58
  # If a block is given, it will only calculate the digest for the file
57
59
  # if the block returns truthy.
58
60
  def scan_hash_public_cache_dir
59
- cache = opts[:hash_public_cache]
61
+ cache = hash_public_cache
60
62
 
61
63
  # Public root doesn't have trailing slash even if given, as
62
64
  # File.expand_path removes it.
63
- root = opts[:public_root] + File::SEPARATOR
65
+ root = public_root + File::SEPARATOR
64
66
 
65
- Find.find(opts[:public_root]) do |file|
67
+ Find.find(public_root) do |file|
66
68
  if File.file?(file)
67
69
  file = file.sub(root, '')
68
70
  next if cache[file]
@@ -80,7 +82,7 @@ class Roda
80
82
 
81
83
  # Write the current hash public cache to the cache file.
82
84
  def dump_hash_public_cache_file
83
- File.write(opts[:hash_public_cache_file], (opts[:json_serializer] || :to_json.to_proc).call(opts[:hash_public_cache]))
85
+ File.write(hash_public_cache_file, json_serializer.call(hash_public_cache))
84
86
  nil
85
87
  end
86
88
  end
@@ -191,7 +191,7 @@ class Roda
191
191
  def dispatch_from(namespace='', branch, &block)
192
192
  ns = @namespace
193
193
  if block
194
- meth_hash = @roda.opts[:hash_routes_methods]
194
+ meth_hash = @roda.hash_routes_methods
195
195
  key = [:dispatch_from, namespace, branch].freeze
196
196
  meth = meth_hash[key] = @roda.define_roda_method(meth_hash[key] || "hash_routes_dispatch_from_#{namespace}_#{branch}", 1, &block)
197
197
  @roda.hash_branch(namespace, branch) do |r|
@@ -253,7 +253,7 @@ class Roda
253
253
  # Returns 404 for requests for the path with a different request method.
254
254
  def verb(verb, path, &block)
255
255
  path = path == true ? "" : "/#{path}"
256
- meth_hash = @roda.opts[:hash_routes_methods]
256
+ meth_hash = @roda.hash_routes_methods
257
257
  key = [@namespace, path].freeze
258
258
  meth = meth_hash[key] = @roda.define_roda_method(meth_hash[key] || "hash_routes_#{@namespace}_#{path}", 0, &block)
259
259
  @roda.hash_path(@namespace, path) do |r|
@@ -265,9 +265,11 @@ class Roda
265
265
  end
266
266
 
267
267
  module ClassMethods
268
+ RodaPlugins.opt_attr_reader(self, :hash_routes_methods)
269
+
268
270
  # Freeze the hash_routes metadata when freezing the app.
269
271
  def freeze
270
- opts[:hash_routes_methods].freeze
272
+ hash_routes_methods.freeze
271
273
  super
272
274
  end
273
275
 
@@ -16,7 +16,11 @@ class Roda
16
16
  module Heartbeat
17
17
  # Set the heartbeat path to the given path.
18
18
  def self.configure(app, opts=OPTS)
19
- app.opts[:heartbeat_path] = (opts[:path] || app.opts[:heartbeat_path] || "/heartbeat").dup.freeze
19
+ app.opts[:heartbeat_path] = (opts[:path] || app.heartbeat_path || "/heartbeat").dup.freeze
20
+ end
21
+
22
+ module ClassMethods
23
+ RodaPlugins.opt_attr_reader(self, :heartbeat_path)
20
24
  end
21
25
 
22
26
  module InstanceMethods
@@ -24,7 +28,7 @@ class Roda
24
28
 
25
29
  # If the request is for a heartbeat path, return the heartbeat response.
26
30
  def _roda_before_20__heartbeat
27
- if env['PATH_INFO'] == opts[:heartbeat_path]
31
+ if env['PATH_INFO'] == self.class.heartbeat_path
28
32
  response = @_response
29
33
  response.status = 200
30
34
  response[RodaResponseHeaders::CONTENT_TYPE] = 'text/plain'
@@ -231,6 +231,12 @@ class Roda
231
231
  end
232
232
  end
233
233
 
234
+ module ClassMethods
235
+ RodaPlugins.opt_attr_reader(self, :hmac_paths_secret)
236
+ RodaPlugins.opt_attr_reader(self, :hmac_paths_old_secret)
237
+ RodaPlugins.opt_attr_reader(self, :hmac_paths_namespace_session_key)
238
+ end
239
+
234
240
  module InstanceMethods
235
241
  # Return a path with an HMAC. Designed to be used with r.hmac_path, to make sure
236
242
  # users can only request paths that they have been provided by the application
@@ -315,7 +321,7 @@ class Roda
315
321
  # using the secret given in the plugin, for the given root and options.
316
322
  # This always returns a hexidecimal string.
317
323
  def hmac_path_hmac_secret(root, opts=OPTS)
318
- secret = opts[:secret] || self.opts[:hmac_paths_secret]
324
+ secret = opts[:secret] || self.class.hmac_paths_secret
319
325
 
320
326
  if namespace = hmac_path_namespace(opts)
321
327
  secret = OpenSSL::HMAC.digest(OpenSSL::Digest::SHA256.new, secret, namespace)
@@ -326,7 +332,7 @@ class Roda
326
332
 
327
333
  # The default namespace to use for hmac_path, if a :namespace option is not provided.
328
334
  def hmac_path_default_namespace
329
- if (key = opts[:hmac_paths_namespace_session_key]) && (value = session[key])
335
+ if (key = self.class.hmac_paths_namespace_session_key) && (value = session[key])
330
336
  value.to_s
331
337
  end
332
338
  end
@@ -346,43 +352,41 @@ class Roda
346
352
 
347
353
  if submitted_hmac.bytesize == 64
348
354
  on String do |flags|
349
- if flags.bytesize >= 1
350
- if flags.include?('n') ^ !scope.hmac_path_namespace(opts).nil?
351
- # Namespace required and not provided, or provided and not required.
352
- # Bail early to avoid unnecessary HMAC calculation.
353
- @remaining_path = orig_path
354
- return
355
- end
355
+ if flags.include?('n') ^ !scope.hmac_path_namespace(opts).nil?
356
+ # Namespace required and not provided, or provided and not required.
357
+ # Bail early to avoid unnecessary HMAC calculation.
358
+ @remaining_path = orig_path
359
+ return
360
+ end
356
361
 
357
- if flags.include?('m')
358
- rpath = "#{env['REQUEST_METHOD'].to_s.upcase}:#{rpath}"
359
- end
362
+ if flags.include?('m')
363
+ rpath = "#{env['REQUEST_METHOD'].to_s.upcase}:#{rpath}"
364
+ end
360
365
 
361
- if flags.include?('p')
362
- rpath = "#{rpath}?#{env["QUERY_STRING"]}"
363
- end
366
+ if flags.include?('p')
367
+ rpath = "#{rpath}?#{env["QUERY_STRING"]}"
368
+ end
364
369
 
365
- if hmac_path_valid?(mpath, rpath, submitted_hmac, opts)
366
- if flags.include?('t')
367
- on Integer do |int|
368
- if int >= Time.now.to_i
369
- always(&block)
370
- else
371
- # Return from method without matching
372
- @remaining_path = orig_path
373
- return
374
- end
370
+ if hmac_path_valid?(mpath, rpath, submitted_hmac, opts)
371
+ if flags.include?('t')
372
+ on Integer do |int|
373
+ if int >= Time.now.to_i
374
+ always(&block)
375
+ else
376
+ # Return from method without matching
377
+ @remaining_path = orig_path
378
+ return
375
379
  end
376
- else
377
- always(&block)
378
380
  end
381
+ else
382
+ always(&block)
379
383
  end
380
384
  end
381
-
382
- # Return from method without matching
383
- @remaining_path = orig_path
384
- return
385
385
  end
386
+
387
+ # Return from method without matching
388
+ @remaining_path = orig_path
389
+ return
386
390
  end
387
391
 
388
392
  # Return from method without matching
@@ -397,7 +401,7 @@ class Roda
397
401
  def hmac_path_valid?(root, path, hmac, opts=OPTS)
398
402
  if Rack::Utils.secure_compare(scope.hmac_path_hmac(root, path, opts), hmac)
399
403
  true
400
- elsif old_secret = roda_class.opts[:hmac_paths_old_secret]
404
+ elsif old_secret = roda_class.hmac_paths_old_secret
401
405
  opts = opts.dup
402
406
  opts[:secret] = old_secret
403
407
  Rack::Utils.secure_compare(scope.hmac_path_hmac(root, path, opts), hmac)
@@ -39,21 +39,24 @@ class Roda
39
39
  end
40
40
 
41
41
  module ClassMethods
42
+ RodaPlugins.opt_attr_reader(self, :after_hooks)
43
+ RodaPlugins.opt_attr_reader(self, :before_hooks)
44
+
42
45
  # Freeze the array of hook methods when freezing the app.
43
46
  def freeze
44
- opts[:after_hooks].freeze
45
- opts[:before_hooks].freeze
47
+ after_hooks.freeze
48
+ before_hooks.freeze
46
49
 
47
50
  super
48
51
  end
49
52
 
50
53
  # Add an after hook.
51
54
  def after(&block)
52
- opts[:after_hooks] << define_roda_method("after_hook", 1, &block)
53
- if opts[:after_hooks].length == 1
54
- class_eval("alias _roda_after_80__hooks #{opts[:after_hooks].first}", __FILE__, __LINE__)
55
+ after_hooks << define_roda_method("after_hook", 1, &block)
56
+ if after_hooks.length == 1
57
+ class_eval("alias _roda_after_80__hooks #{after_hooks.first}", __FILE__, __LINE__)
55
58
  else
56
- class_eval("def _roda_after_80__hooks(res) #{opts[:after_hooks].map{|m| "#{m}(res)"}.join(';')} end", __FILE__, __LINE__)
59
+ class_eval("def _roda_after_80__hooks(res) #{after_hooks.map{|m| "#{m}(res)"}.join(';')} end", __FILE__, __LINE__)
57
60
  end
58
61
  private :_roda_after_80__hooks
59
62
  def_roda_after
@@ -62,11 +65,11 @@ class Roda
62
65
 
63
66
  # Add a before hook.
64
67
  def before(&block)
65
- opts[:before_hooks].unshift(define_roda_method("before_hook", 0, &block))
66
- if opts[:before_hooks].length == 1
67
- class_eval("alias _roda_before_10__hooks #{opts[:before_hooks].first}", __FILE__, __LINE__)
68
+ before_hooks.unshift(define_roda_method("before_hook", 0, &block))
69
+ if before_hooks.length == 1
70
+ class_eval("alias _roda_before_10__hooks #{before_hooks.first}", __FILE__, __LINE__)
68
71
  else
69
- class_eval("def _roda_before_10__hooks; #{opts[:before_hooks].join(';')} end", __FILE__, __LINE__)
72
+ class_eval("def _roda_before_10__hooks; #{before_hooks.join(';')} end", __FILE__, __LINE__)
70
73
  end
71
74
  private :_roda_before_10__hooks
72
75
  def_roda_before
@@ -101,6 +101,11 @@ class Roda
101
101
  end
102
102
  end
103
103
 
104
+ module ClassMethods
105
+ RodaPlugins.opt_attr_reader(self, :host_authorization_host)
106
+ RodaPlugins.opt_attr_reader(self, :host_authorization_check_forwarded, name: :host_authorization_check_forwarded?)
107
+ end
108
+
104
109
  module InstanceMethods
105
110
  # Check whether the host is authorized. If not authorized, return a response
106
111
  # immediately based on the plugin block.
@@ -108,7 +113,7 @@ class Roda
108
113
  r = @_request
109
114
  return if host_authorized?(_convert_host_for_authorization(r.env["HTTP_HOST"].to_s.dup))
110
115
 
111
- if opts[:host_authorization_check_forwarded] && (host = r.env["HTTP_X_FORWARDED_HOST"])
116
+ if self.class.host_authorization_check_forwarded? && (host = r.env["HTTP_X_FORWARDED_HOST"])
112
117
  if i = host.rindex(',')
113
118
  host = host[i+1, 10000000].to_s
114
119
  end
@@ -133,7 +138,7 @@ class Roda
133
138
  end
134
139
 
135
140
  # Whether the host given is one of the authorized hosts for this application.
136
- def host_authorized?(host, authorized_host = opts[:host_authorization_host])
141
+ def host_authorized?(host, authorized_host = self.class.host_authorization_host)
137
142
  case authorized_host
138
143
  when Array
139
144
  authorized_host.any?{|auth_host| host_authorized?(host, auth_host)}
@@ -68,8 +68,29 @@ class Roda
68
68
  # end
69
69
  # end
70
70
  #
71
- # This plugin uses the host method on the request to get the hostname (this method
72
- # is defined by Rack).
71
+ # This plugin uses the +host+ method on the request to get the hostname (this method
72
+ # is defined by Rack). Note that as of Rack 3.2, Rack's default approach to determine
73
+ # the hostname is insecure in many cases, since:
74
+ #
75
+ # * It will use a forwarded header instead of the +host+ header by default if the
76
+ # request was from a trusted proxy.
77
+ # * Rack trust proxies from any private IP address range by default
78
+ # (this can be disabled using <tt>Rack::Request.ip_filter=</tt>).
79
+ # * Rack supports both +forwarded+ and <tt>x-forwarded-host</tt> headers, will check
80
+ # both by default, and the order of checking depends on Rack's configuration
81
+ # (this can be configured using <tt>Rack::Request.forwarded_priority</tt>).
82
+ # * If the application can be reached via an arbitrary +host+ header, the attacker
83
+ # has full control over the <tt>Rack::Request#host</tt> value even when forwarded
84
+ # host usage is disabled.
85
+ #
86
+ # You should configure Rack appropriately for your deployment, not allowing the use
87
+ # of forwarded information unless your deployment is using a reverse proxy, and only
88
+ # allowing the specific forwarded header set by the expected reverse proxy if a
89
+ # reverse proxy is used. Rack 3.3 should include more control in this area,
90
+ # and Rack 4 should ship with more secure defaults in regard to forwarding headers.
91
+ #
92
+ # It is recommended to use the host_authorization plugin to check that the requested
93
+ # host is allowed when using this plugin.
73
94
  module HostRouting
74
95
  REQUEST_INSTANCE_VARIABLES = [:@_host_routing_host].freeze
75
96
 
@@ -140,7 +161,7 @@ class Roda
140
161
  # Treat all given hostnames as routing to the give host.
141
162
  def to(host, *hostnames)
142
163
  hostnames.each do |hostname|
143
- @host_hash[hostname] = host
164
+ @host_hash[hostname.downcase.freeze] = host
144
165
  end
145
166
  end
146
167
 
@@ -155,6 +176,11 @@ class Roda
155
176
  end
156
177
  private_constant :DSL
157
178
 
179
+ module ClassMethods
180
+ RodaPlugins.opt_attr_reader(self, :host_routing_hash)
181
+ RodaPlugins.opt_attr_reader(self, :host_routing_default_host)
182
+ end
183
+
158
184
  module InstanceMethods
159
185
  # Handle case where plugin is used without providing a block to
160
186
  # +hosts.default+. This returns nil, ensuring that the hostname
@@ -180,11 +206,15 @@ class Roda
180
206
  # * The return value of the +hosts.default+ block, if given
181
207
  # * The default value provided in the +hosts.default+ call
182
208
  def _get_host_routing_host
183
- host = self.host || ""
209
+ if host = self.host
210
+ host = host.downcase
211
+ else
212
+ host = ""
213
+ end
184
214
 
185
- roda_class.opts[:host_routing_hash][host] ||
215
+ roda_class.host_routing_hash[host] ||
186
216
  scope._host_routing_default(host) ||
187
- roda_class.opts[:host_routing_default_host]
217
+ roda_class.host_routing_default_host
188
218
  end
189
219
  end
190
220
  end
@@ -37,13 +37,25 @@ class Roda
37
37
  # # To treat the exception raised as a submitted parameter
38
38
  # {body_error: exception}
39
39
  # end
40
+ #
41
+ # You can use the +rescue_classes+ argument when loading the plugin to only rescue
42
+ # specific exception classes:
43
+ #
44
+ # plugin :invalid_request_body, :empty_hash, rescue_classes: Rack::BadRequest
45
+ #
46
+ # However, this can be risky, because there have been many cases in the past where
47
+ # Rack can rescue classes such as +ArgumentError+ or +NoMethodError+ when parsing
48
+ # invalid request bodies. The default for +rescue_classes+ is +StandardError+.
40
49
  module InvalidRequestBody
41
50
  # Exception class raised for invalid request bodies.
42
51
  Error = Class.new(RodaError)
43
52
 
44
53
  # Set the action to use (:empty_400, :empty_hash, :raise) for invalid request bodies,
45
- # or use a block for custom behavior.
46
- def self.configure(app, action=nil, &block)
54
+ # or use a block for custom behavior. The rescue_classes argument is an exception
55
+ # class or array of exception classes to rescue.
56
+ def self.configure(app, action=nil, rescue_classes: StandardError, &block)
57
+ app.opts[:invalid_response_body_rescue_classes] = Array(rescue_classes).dup.freeze
58
+
47
59
  if action
48
60
  if block
49
61
  raise RodaError, "cannot provide both block and action when loading invalid_request_body plugin"
@@ -56,7 +68,7 @@ class Roda
56
68
 
57
69
  app::RodaRequest.send(:alias_method, :handle_invalid_request_body, method)
58
70
  elsif block
59
- app::RodaRequest.class_eval do
71
+ app::RodaRequest.class_exec do
60
72
  define_method(:handle_invalid_request_body, &block)
61
73
  alias handle_invalid_request_body handle_invalid_request_body
62
74
  end
@@ -67,12 +79,18 @@ class Roda
67
79
  app::RodaRequest.send(:private, :handle_invalid_request_body)
68
80
  end
69
81
 
82
+ module ClassMethods
83
+ RodaPlugins.opt_attr_reader(self, :invalid_response_body_rescue_classes)
84
+ end
85
+
70
86
  module RequestMethods
71
87
  # Handle invalid request bodies as configured if the default behavior
72
88
  # raises an exception.
73
89
  def POST
74
90
  super
75
- rescue => e
91
+ rescue RodaError
92
+ raise
93
+ rescue *roda_class.invalid_response_body_rescue_classes => e
76
94
  handle_invalid_request_body(e)
77
95
  end
78
96
 
@@ -20,11 +20,15 @@ class Roda
20
20
  app.opts[:ip_from_header_env_key] = "HTTP_#{header.upcase.tr('-', '_')}".freeze
21
21
  end
22
22
 
23
+ module ClassMethods
24
+ RodaPlugins.opt_attr_reader(self, :ip_from_header_env_key)
25
+ end
26
+
23
27
  module RequestMethods
24
28
  # Return the IP address continained in the configured header, if present.
25
29
  # Fallback to the default behavior if not present.
26
30
  def ip
27
- @env[roda_class.opts[:ip_from_header_env_key]] || super
31
+ @env[roda_class.ip_from_header_env_key] || super
28
32
  end
29
33
  end
30
34
  end
@@ -1,7 +1,5 @@
1
1
  # frozen-string-literal: true
2
2
 
3
- require 'json'
4
-
5
3
  class Roda
6
4
  module RodaPlugins
7
5
  # The json plugin allows match blocks to return
@@ -57,6 +55,7 @@ class Roda
57
55
  # not support treating String, FalseClass, or NilClass values as JSON.
58
56
  module Json
59
57
  def self.load_dependencies(app, opts=OPTS)
58
+ app.plugin :_json
60
59
  app.plugin :custom_block_results
61
60
  end
62
61
 
@@ -69,10 +68,10 @@ class Roda
69
68
  classes.uniq!
70
69
  classes.freeze
71
70
  classes.each do |klass|
72
- app.opts[:custom_block_results][klass] = :handle_json_block_result
71
+ app.custom_block_results[klass] = :handle_json_block_result
73
72
  end
74
73
 
75
- app.opts[:json_result_serializer] = opts[:serializer] || app.opts[:json_result_serializer] || app.opts[:json_serializer] || :to_json.to_proc
74
+ app.opts[:json_result_serializer] = opts[:serializer] || app.json_result_serializer || app.json_serializer
76
75
 
77
76
  app.opts[:json_result_include_request] = opts[:include_request] if opts.has_key?(:include_request)
78
77
 
@@ -80,6 +79,10 @@ class Roda
80
79
  end
81
80
 
82
81
  module ClassMethods
82
+ RodaPlugins.opt_attr_reader(self, :json_result_serializer)
83
+ RodaPlugins.opt_attr_reader(self, :json_result_include_request, name: :json_result_include_request?)
84
+ RodaPlugins.opt_attr_reader(self, :json_result_content_type)
85
+
83
86
  # The classes that should be automatically converted to json
84
87
  def json_result_classes
85
88
  # RODA4: remove, only used by previous implementation.
@@ -91,7 +94,7 @@ class Roda
91
94
  # Handle a result for one of the registered JSON result classes
92
95
  # by converting the result to JSON.
93
96
  def handle_json_block_result(result)
94
- @_response[RodaResponseHeaders::CONTENT_TYPE] ||= opts[:json_result_content_type]
97
+ @_response[RodaResponseHeaders::CONTENT_TYPE] ||= self.class.json_result_content_type
95
98
  @_request.send(:convert_to_json, result)
96
99
  end
97
100
  end
@@ -102,10 +105,9 @@ class Roda
102
105
  # Convert the given object to JSON. Uses to_json by default,
103
106
  # but can use a custom serializer passed to the plugin.
104
107
  def convert_to_json(result)
105
- opts = roda_class.opts
106
- serializer = opts[:json_result_serializer]
108
+ serializer = roda_class.json_result_serializer
107
109
 
108
- if opts[:json_result_include_request]
110
+ if roda_class.json_result_include_request?
109
111
  serializer.call(result, self)
110
112
  else
111
113
  serializer.call(result)
@@ -1,7 +1,5 @@
1
1
  # frozen-string-literal: true
2
2
 
3
- require 'json'
4
-
5
3
  class Roda
6
4
  module RodaPlugins
7
5
  # The json_parser plugin parses request bodies in JSON format
@@ -17,7 +15,23 @@ class Roda
17
15
  module JsonParser
18
16
  DEFAULT_ERROR_HANDLER = proc{|r| r.halt [400, {}, []]}
19
17
 
18
+ # simplecov:disable
19
+ MATCH_METHOD = RUBY_VERSION >= '2.4' ? :match? : :match
20
+ # simplecov:enable
21
+ private_constant :MATCH_METHOD
22
+
23
+ def self.load_dependencies(app, opts=OPTS)
24
+ app.plugin :_json
25
+ end
26
+
20
27
  # Handle options for the json_parser plugin:
28
+ # :content_type_regexp :: A regexp used to determine if the request's
29
+ # content type is JSON. For backwards compatibility,
30
+ # uses the insecure /json/ if not provided. Warns
31
+ # if this option is not provided. The recommended
32
+ # value to use is /\Aapplication\/json\b/i or
33
+ # /\Aapplication\/(?:vnd\.api\+)?json\b/i. Roda 4
34
+ # will default to the latter.
21
35
  # :error_handler :: A proc to call if an exception is raised when
22
36
  # parsing a JSON request body. The proc is called
23
37
  # with the request object, and should probably call
@@ -34,9 +48,16 @@ class Roda
34
48
  # :always will wrap all values, and a value of :unless_hash will
35
49
  # only wrap values that are not already hashes.
36
50
  def self.configure(app, opts=OPTS)
37
- app.opts[:json_parser_error_handler] = opts[:error_handler] || app.opts[:json_parser_error_handler] || DEFAULT_ERROR_HANDLER
38
- app.opts[:json_parser_parser] = opts[:parser] || app.opts[:json_parser_parser] || app.opts[:json_parser] || JSON.method(:parse)
51
+ app.opts[:json_parser_error_handler] = opts[:error_handler] || app.json_parser_error_handler || DEFAULT_ERROR_HANDLER
52
+ app.opts[:json_parser_parser] = opts[:parser] || app.json_parser_parser || app.json_parser
39
53
  app.opts[:json_parser_include_request] = opts[:include_request] if opts.has_key?(:include_request)
54
+ app.opts[:json_parser_content_type_regexp] = opts[:content_type_regexp] || app.json_parser_content_type_regexp
55
+
56
+ unless app.json_parser_content_type_regexp
57
+ # RODA4: Switch to /\Aapplication\/(?:vnd\.api\+)?json\b/i by default
58
+ RodaPlugins.warn(':content_type_regexp option not provided to json_parser plugin, using /json/ for backwards compatibility, which may be insecure, using /\Aapplication\/json\b/i or /\Aapplication\/(?:vnd\.api\+)?json\b/i is recommended')
59
+ app.opts[:json_parser_content_type_regexp] = /json/
60
+ end
40
61
 
41
62
  case opts[:wrap]
42
63
  when :unless_hash, :always
@@ -48,6 +69,14 @@ class Roda
48
69
  end
49
70
  end
50
71
 
72
+ module ClassMethods
73
+ RodaPlugins.opt_attr_reader(self, :json_parser_error_handler)
74
+ RodaPlugins.opt_attr_reader(self, :json_parser_parser)
75
+ RodaPlugins.opt_attr_reader(self, :json_parser_include_request, name: :json_parser_include_request?)
76
+ RodaPlugins.opt_attr_reader(self, :json_parser_content_type_regexp)
77
+ RodaPlugins.opt_attr_reader(self, :json_parser_wrap)
78
+ end
79
+
51
80
  module RequestMethods
52
81
  # If the Content-Type header in the request includes "json",
53
82
  # parse the request body as JSON. Ignore an empty request body.
@@ -57,7 +86,7 @@ class Roda
57
86
  return post_params
58
87
  end
59
88
 
60
- unless (input = env["rack.input"]) && (content_type = self.content_type) && content_type.include?('json')
89
+ unless (input = env["rack.input"]) && (content_type = self.content_type) && roda_class.json_parser_content_type_regexp.send(MATCH_METHOD, content_type)
61
90
  return super
62
91
  end
63
92
 
@@ -66,10 +95,10 @@ class Roda
66
95
  begin
67
96
  json_params = parse_json(str)
68
97
  rescue
69
- roda_class.opts[:json_parser_error_handler].call(self)
98
+ roda_class.json_parser_error_handler.call(self)
70
99
  end
71
100
 
72
- wrap = roda_class.opts[:json_parser_wrap]
101
+ wrap = roda_class.json_parser_wrap
73
102
  if wrap == :always || (wrap == :unless_hash && !json_params.is_a?(Hash))
74
103
  json_params = {"_json"=>json_params}
75
104
  end
@@ -80,11 +109,14 @@ class Roda
80
109
  private
81
110
 
82
111
  def parse_json(str)
83
- args = [str]
84
- args << self if roda_class.opts[:json_parser_include_request]
85
- roda_class.opts[:json_parser_parser].call(*args)
86
- end
112
+ parser = roda_class.json_parser_parser
87
113
 
114
+ if roda_class.json_parser_include_request?
115
+ parser.call(str, self)
116
+ else
117
+ parser.call(str)
118
+ end
119
+ end
88
120
 
89
121
  # Rack 3 dropped requirement that input be rewindable
90
122
  if Rack.release >= '3'