actionpack 7.2.3 → 8.1.3

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 (102) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +394 -119
  3. data/lib/abstract_controller/asset_paths.rb +4 -2
  4. data/lib/abstract_controller/base.rb +11 -5
  5. data/lib/abstract_controller/caching.rb +6 -3
  6. data/lib/abstract_controller/callbacks.rb +6 -0
  7. data/lib/abstract_controller/logger.rb +2 -1
  8. data/lib/abstract_controller/rendering.rb +0 -1
  9. data/lib/action_controller/api.rb +1 -0
  10. data/lib/action_controller/base.rb +3 -2
  11. data/lib/action_controller/caching.rb +1 -2
  12. data/lib/action_controller/form_builder.rb +4 -4
  13. data/lib/action_controller/log_subscriber.rb +22 -3
  14. data/lib/action_controller/metal/allow_browser.rb +12 -2
  15. data/lib/action_controller/metal/conditional_get.rb +30 -1
  16. data/lib/action_controller/metal/data_streaming.rb +5 -5
  17. data/lib/action_controller/metal/exceptions.rb +5 -0
  18. data/lib/action_controller/metal/flash.rb +1 -4
  19. data/lib/action_controller/metal/head.rb +3 -1
  20. data/lib/action_controller/metal/instrumentation.rb +1 -2
  21. data/lib/action_controller/metal/live.rb +65 -25
  22. data/lib/action_controller/metal/permissions_policy.rb +9 -0
  23. data/lib/action_controller/metal/rate_limiting.rb +39 -9
  24. data/lib/action_controller/metal/redirecting.rb +105 -13
  25. data/lib/action_controller/metal/renderers.rb +29 -9
  26. data/lib/action_controller/metal/rendering.rb +7 -1
  27. data/lib/action_controller/metal/request_forgery_protection.rb +18 -10
  28. data/lib/action_controller/metal/rescue.rb +9 -0
  29. data/lib/action_controller/metal/streaming.rb +5 -84
  30. data/lib/action_controller/metal/strong_parameters.rb +277 -89
  31. data/lib/action_controller/railtie.rb +33 -15
  32. data/lib/action_controller/structured_event_subscriber.rb +116 -0
  33. data/lib/action_controller/test_case.rb +12 -2
  34. data/lib/action_dispatch/http/cache.rb +138 -11
  35. data/lib/action_dispatch/http/content_security_policy.rb +14 -1
  36. data/lib/action_dispatch/http/filter_parameters.rb +5 -3
  37. data/lib/action_dispatch/http/mime_negotiation.rb +55 -1
  38. data/lib/action_dispatch/http/mime_types.rb +1 -0
  39. data/lib/action_dispatch/http/param_builder.rb +187 -0
  40. data/lib/action_dispatch/http/param_error.rb +26 -0
  41. data/lib/action_dispatch/http/parameters.rb +3 -3
  42. data/lib/action_dispatch/http/permissions_policy.rb +6 -0
  43. data/lib/action_dispatch/http/query_parser.rb +55 -0
  44. data/lib/action_dispatch/http/request.rb +70 -21
  45. data/lib/action_dispatch/http/response.rb +50 -16
  46. data/lib/action_dispatch/http/url.rb +110 -14
  47. data/lib/action_dispatch/journey/gtg/simulator.rb +33 -12
  48. data/lib/action_dispatch/journey/gtg/transition_table.rb +33 -41
  49. data/lib/action_dispatch/journey/nodes/node.rb +2 -1
  50. data/lib/action_dispatch/journey/parser.rb +99 -196
  51. data/lib/action_dispatch/journey/route.rb +45 -31
  52. data/lib/action_dispatch/journey/router/utils.rb +8 -14
  53. data/lib/action_dispatch/journey/router.rb +59 -81
  54. data/lib/action_dispatch/journey/routes.rb +7 -0
  55. data/lib/action_dispatch/journey/scanner.rb +44 -42
  56. data/lib/action_dispatch/journey/visitors.rb +55 -23
  57. data/lib/action_dispatch/journey/visualizer/fsm.js +4 -6
  58. data/lib/action_dispatch/log_subscriber.rb +7 -3
  59. data/lib/action_dispatch/middleware/cookies.rb +8 -4
  60. data/lib/action_dispatch/middleware/debug_exceptions.rb +24 -5
  61. data/lib/action_dispatch/middleware/debug_view.rb +11 -5
  62. data/lib/action_dispatch/middleware/exception_wrapper.rb +11 -11
  63. data/lib/action_dispatch/middleware/executor.rb +12 -2
  64. data/lib/action_dispatch/middleware/public_exceptions.rb +1 -5
  65. data/lib/action_dispatch/middleware/remote_ip.rb +11 -5
  66. data/lib/action_dispatch/middleware/request_id.rb +2 -1
  67. data/lib/action_dispatch/middleware/session/cache_store.rb +17 -0
  68. data/lib/action_dispatch/middleware/ssl.rb +13 -3
  69. data/lib/action_dispatch/middleware/templates/rescues/_copy_button.html.erb +1 -0
  70. data/lib/action_dispatch/middleware/templates/rescues/_source.html.erb +3 -5
  71. data/lib/action_dispatch/middleware/templates/rescues/_trace.html.erb +9 -5
  72. data/lib/action_dispatch/middleware/templates/rescues/blocked_host.html.erb +1 -0
  73. data/lib/action_dispatch/middleware/templates/rescues/diagnostics.html.erb +1 -0
  74. data/lib/action_dispatch/middleware/templates/rescues/invalid_statement.html.erb +4 -0
  75. data/lib/action_dispatch/middleware/templates/rescues/invalid_statement.text.erb +3 -0
  76. data/lib/action_dispatch/middleware/templates/rescues/layout.erb +50 -0
  77. data/lib/action_dispatch/middleware/templates/rescues/missing_exact_template.html.erb +1 -0
  78. data/lib/action_dispatch/middleware/templates/rescues/missing_template.html.erb +1 -0
  79. data/lib/action_dispatch/middleware/templates/rescues/routing_error.html.erb +1 -0
  80. data/lib/action_dispatch/middleware/templates/rescues/template_error.html.erb +1 -0
  81. data/lib/action_dispatch/middleware/templates/rescues/unknown_action.html.erb +1 -0
  82. data/lib/action_dispatch/railtie.rb +21 -0
  83. data/lib/action_dispatch/request/session.rb +1 -0
  84. data/lib/action_dispatch/request/utils.rb +9 -3
  85. data/lib/action_dispatch/routing/inspector.rb +80 -57
  86. data/lib/action_dispatch/routing/mapper.rb +404 -223
  87. data/lib/action_dispatch/routing/polymorphic_routes.rb +2 -2
  88. data/lib/action_dispatch/routing/redirection.rb +10 -7
  89. data/lib/action_dispatch/routing/route_set.rb +21 -12
  90. data/lib/action_dispatch/routing/routes_proxy.rb +1 -0
  91. data/lib/action_dispatch/structured_event_subscriber.rb +20 -0
  92. data/lib/action_dispatch/system_test_case.rb +3 -3
  93. data/lib/action_dispatch/system_testing/browser.rb +12 -21
  94. data/lib/action_dispatch/system_testing/test_helpers/screenshot_helper.rb +2 -2
  95. data/lib/action_dispatch/testing/assertions/response.rb +26 -2
  96. data/lib/action_dispatch/testing/assertions/routing.rb +27 -15
  97. data/lib/action_dispatch/testing/integration.rb +18 -7
  98. data/lib/action_dispatch.rb +14 -4
  99. data/lib/action_pack/gem_version.rb +2 -2
  100. metadata +18 -48
  101. data/lib/action_dispatch/journey/parser.y +0 -50
  102. data/lib/action_dispatch/journey/parser_extras.rb +0 -33
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionController
4
+ class StructuredEventSubscriber < ActiveSupport::StructuredEventSubscriber # :nodoc:
5
+ INTERNAL_PARAMS = %w(controller action format _method only_path)
6
+
7
+ def start_processing(event)
8
+ payload = event.payload
9
+ params = {}
10
+ payload[:params].each_pair do |k, v|
11
+ params[k] = v unless INTERNAL_PARAMS.include?(k)
12
+ end
13
+ format = payload[:format]
14
+ format = format.to_s.upcase if format.is_a?(Symbol)
15
+ format = "*/*" if format.nil?
16
+
17
+ emit_event("action_controller.request_started",
18
+ controller: payload[:controller],
19
+ action: payload[:action],
20
+ format:,
21
+ params:,
22
+ )
23
+ end
24
+
25
+ def process_action(event)
26
+ payload = event.payload
27
+ status = payload[:status]
28
+
29
+ if status.nil? && (exception_class_name = payload[:exception]&.first)
30
+ status = ActionDispatch::ExceptionWrapper.status_code_for_exception(exception_class_name)
31
+ end
32
+
33
+ emit_event("action_controller.request_completed", {
34
+ controller: payload[:controller],
35
+ action: payload[:action],
36
+ status: status,
37
+ **additions_for(payload),
38
+ duration_ms: event.duration.round(2),
39
+ gc_time_ms: event.gc_time.round(1),
40
+ }.compact)
41
+ end
42
+
43
+ def halted_callback(event)
44
+ emit_event("action_controller.callback_halted", filter: event.payload[:filter])
45
+ end
46
+
47
+ def rescue_from_callback(event)
48
+ exception = event.payload[:exception]
49
+
50
+ exception_backtrace = exception.backtrace&.first
51
+ exception_backtrace = exception_backtrace&.delete_prefix("#{Rails.root}/") if defined?(Rails.root) && Rails.root
52
+
53
+ emit_event("action_controller.rescue_from_handled",
54
+ exception_class: exception.class.name,
55
+ exception_message: exception.message,
56
+ exception_backtrace:
57
+ )
58
+ end
59
+
60
+ def send_file(event)
61
+ emit_event("action_controller.file_sent", path: event.payload[:path], duration_ms: event.duration.round(1))
62
+ end
63
+
64
+ def redirect_to(event)
65
+ emit_event("action_controller.redirected", location: event.payload[:location])
66
+ end
67
+
68
+ def send_data(event)
69
+ emit_event("action_controller.data_sent", filename: event.payload[:filename], duration_ms: event.duration.round(1))
70
+ end
71
+
72
+ def unpermitted_parameters(event)
73
+ unpermitted_keys = event.payload[:keys]
74
+ context = event.payload[:context]
75
+
76
+ emit_debug_event("action_controller.unpermitted_parameters",
77
+ unpermitted_keys:,
78
+ context: context.except(:request)
79
+ )
80
+ end
81
+ debug_only :unpermitted_parameters
82
+
83
+ def write_fragment(event)
84
+ fragment_cache(__method__, event)
85
+ end
86
+
87
+ def read_fragment(event)
88
+ fragment_cache(__method__, event)
89
+ end
90
+
91
+ def exist_fragment?(event)
92
+ fragment_cache(__method__, event)
93
+ end
94
+
95
+ def expire_fragment(event)
96
+ fragment_cache(__method__, event)
97
+ end
98
+
99
+ private
100
+ def fragment_cache(method_name, event)
101
+ key = ActiveSupport::Cache.expand_cache_key(event.payload[:key] || event.payload[:path])
102
+
103
+ emit_event("action_controller.fragment_cache",
104
+ method: "#{method_name}",
105
+ key: key,
106
+ duration_ms: event.duration.round(1)
107
+ )
108
+ end
109
+
110
+ def additions_for(payload)
111
+ payload.slice(:view_runtime, :db_runtime, :queries_count, :cached_queries_count)
112
+ end
113
+ end
114
+ end
115
+
116
+ ActionController::StructuredEventSubscriber.attach_to :action_controller
@@ -22,11 +22,21 @@ module ActionController
22
22
  # database on the main thread, so they could open a txn, then the controller
23
23
  # thread will open a new connection and try to access data that's only visible
24
24
  # to the main thread's txn. This is the problem in #23483.
25
+ alias_method :original_new_controller_thread, :new_controller_thread
26
+
25
27
  silence_redefinition_of_method :new_controller_thread
26
28
  def new_controller_thread # :nodoc:
27
29
  yield
28
30
  end
29
31
 
32
+ # Because of the above, we need to prevent the clearing of thread locals, since
33
+ # no new thread is actually spawned in the test environment.
34
+ alias_method :original_clean_up_thread_locals, :clean_up_thread_locals
35
+
36
+ silence_redefinition_of_method :clean_up_thread_locals
37
+ def clean_up_thread_locals(*args) # :nodoc:
38
+ end
39
+
30
40
  # Avoid a deadlock from the queue filling up
31
41
  Buffer.queue_size = nil
32
42
  end
@@ -106,7 +116,7 @@ module ActionController
106
116
  set_header k, "application/x-www-form-urlencoded"
107
117
  end
108
118
 
109
- case content_mime_type.to_sym
119
+ case content_mime_type&.to_sym
110
120
  when nil
111
121
  raise "Unknown Content-Type: #{content_type}"
112
122
  when :json
@@ -121,7 +131,7 @@ module ActionController
121
131
  end
122
132
  end
123
133
 
124
- data_stream = StringIO.new(data)
134
+ data_stream = StringIO.new(data.b)
125
135
  set_header "CONTENT_LENGTH", data_stream.length.to_s
126
136
  set_header "rack.input", data_stream
127
137
  end
@@ -9,6 +9,8 @@ module ActionDispatch
9
9
  HTTP_IF_MODIFIED_SINCE = "HTTP_IF_MODIFIED_SINCE"
10
10
  HTTP_IF_NONE_MATCH = "HTTP_IF_NONE_MATCH"
11
11
 
12
+ mattr_accessor :strict_freshness, default: false
13
+
12
14
  def if_modified_since
13
15
  if since = get_header(HTTP_IF_MODIFIED_SINCE)
14
16
  Time.rfc2822(since) rescue nil
@@ -34,19 +36,140 @@ module ActionDispatch
34
36
  end
35
37
  end
36
38
 
37
- # Check response freshness (`Last-Modified` and ETag) against request
38
- # `If-Modified-Since` and `If-None-Match` conditions. If both headers are
39
- # supplied, both must match, or the request is not considered fresh.
39
+ # Check response freshness (`Last-Modified` and `ETag`) against request
40
+ # `If-Modified-Since` and `If-None-Match` conditions.
41
+ # If both headers are supplied, based on configuration, either `ETag` is preferred over `Last-Modified`
42
+ # or both are considered equally. You can adjust the preference with
43
+ # `config.action_dispatch.strict_freshness`.
44
+ # Reference: http://tools.ietf.org/html/rfc7232#section-6
40
45
  def fresh?(response)
41
- last_modified = if_modified_since
42
- etag = if_none_match
46
+ if Request.strict_freshness
47
+ if if_none_match
48
+ etag_matches?(response.etag)
49
+ elsif if_modified_since
50
+ not_modified?(response.last_modified)
51
+ else
52
+ false
53
+ end
54
+ else
55
+ last_modified = if_modified_since
56
+ etag = if_none_match
57
+
58
+ return false unless last_modified || etag
59
+
60
+ success = true
61
+ success &&= not_modified?(response.last_modified) if last_modified
62
+ success &&= etag_matches?(response.etag) if etag
63
+ success
64
+ end
65
+ end
66
+
67
+ def cache_control_directives
68
+ @cache_control_directives ||= CacheControlDirectives.new(get_header("HTTP_CACHE_CONTROL"))
69
+ end
70
+
71
+ # Represents the HTTP Cache-Control header for requests,
72
+ # providing methods to access various cache control directives
73
+ # Reference: https://www.rfc-editor.org/rfc/rfc9111.html#name-request-directives
74
+ class CacheControlDirectives
75
+ def initialize(cache_control_header)
76
+ @only_if_cached = false
77
+ @no_cache = false
78
+ @no_store = false
79
+ @no_transform = false
80
+ @max_age = nil
81
+ @max_stale = nil
82
+ @min_fresh = nil
83
+ @stale_if_error = false
84
+ parse_directives(cache_control_header)
85
+ end
86
+
87
+ # Returns true if the only-if-cached directive is present.
88
+ # This directive indicates that the client only wishes to obtain a
89
+ # stored response. If a valid stored response is not available,
90
+ # the server should respond with a 504 (Gateway Timeout) status.
91
+ def only_if_cached?
92
+ @only_if_cached
93
+ end
94
+
95
+ # Returns true if the no-cache directive is present.
96
+ # This directive indicates that a cache must not use the response
97
+ # to satisfy subsequent requests without successful validation on the origin server.
98
+ def no_cache?
99
+ @no_cache
100
+ end
101
+
102
+ # Returns true if the no-store directive is present.
103
+ # This directive indicates that a cache must not store any part of the
104
+ # request or response.
105
+ def no_store?
106
+ @no_store
107
+ end
108
+
109
+ # Returns true if the no-transform directive is present.
110
+ # This directive indicates that a cache or proxy must not transform the payload.
111
+ def no_transform?
112
+ @no_transform
113
+ end
43
114
 
44
- return false unless last_modified || etag
115
+ # Returns the value of the max-age directive.
116
+ # This directive indicates that the client is willing to accept a response
117
+ # whose age is no greater than the specified number of seconds.
118
+ attr_reader :max_age
45
119
 
46
- success = true
47
- success &&= not_modified?(response.last_modified) if last_modified
48
- success &&= etag_matches?(response.etag) if etag
49
- success
120
+ # Returns the value of the max-stale directive.
121
+ # When max-stale is present with a value, returns that integer value.
122
+ # When max-stale is present without a value, returns true (unlimited staleness).
123
+ # When max-stale is not present, returns nil.
124
+ attr_reader :max_stale
125
+
126
+ # Returns true if max-stale directive is present (with or without a value)
127
+ def max_stale?
128
+ !@max_stale.nil?
129
+ end
130
+
131
+ # Returns true if max-stale directive is present without a value (unlimited staleness)
132
+ def max_stale_unlimited?
133
+ @max_stale == true
134
+ end
135
+
136
+ # Returns the value of the min-fresh directive.
137
+ # This directive indicates that the client is willing to accept a response
138
+ # whose freshness lifetime is no less than its current age plus the specified time in seconds.
139
+ attr_reader :min_fresh
140
+
141
+ # Returns the value of the stale-if-error directive.
142
+ # This directive indicates that the client is willing to accept a stale response
143
+ # if the check for a fresh one fails with an error for the specified number of seconds.
144
+ attr_reader :stale_if_error
145
+
146
+ private
147
+ def parse_directives(header_value)
148
+ return unless header_value
149
+
150
+ header_value.delete(" ").downcase.split(",").each do |directive|
151
+ name, value = directive.split("=", 2)
152
+
153
+ case name
154
+ when "max-age"
155
+ @max_age = value.to_i
156
+ when "min-fresh"
157
+ @min_fresh = value.to_i
158
+ when "stale-if-error"
159
+ @stale_if_error = value.to_i
160
+ when "no-cache"
161
+ @no_cache = true
162
+ when "no-store"
163
+ @no_store = true
164
+ when "no-transform"
165
+ @no_transform = true
166
+ when "only-if-cached"
167
+ @only_if_cached = true
168
+ when "max-stale"
169
+ @max_stale = value ? value.to_i : true
170
+ end
171
+ end
172
+ end
50
173
  end
51
174
  end
52
175
 
@@ -127,7 +250,7 @@ module ActionDispatch
127
250
  private
128
251
  DATE = "Date"
129
252
  LAST_MODIFIED = "Last-Modified"
130
- SPECIAL_KEYS = Set.new(%w[extras no-store no-cache max-age public private must-revalidate])
253
+ SPECIAL_KEYS = Set.new(%w[extras no-store no-cache max-age public private must-revalidate must-understand])
131
254
 
132
255
  def generate_weak_etag(validators)
133
256
  "W/#{generate_strong_etag(validators)}"
@@ -171,6 +294,8 @@ module ActionDispatch
171
294
  PUBLIC = "public"
172
295
  PRIVATE = "private"
173
296
  MUST_REVALIDATE = "must-revalidate"
297
+ IMMUTABLE = "immutable"
298
+ MUST_UNDERSTAND = "must-understand"
174
299
 
175
300
  def handle_conditional_get!
176
301
  # Normally default cache control setting is handled by ETag middleware. But, if
@@ -205,6 +330,7 @@ module ActionDispatch
205
330
 
206
331
  if control[:no_store]
207
332
  options << PRIVATE if control[:private]
333
+ options << MUST_UNDERSTAND if control[:must_understand]
208
334
  options << NO_STORE
209
335
  elsif control[:no_cache]
210
336
  options << PUBLIC if control[:public]
@@ -221,6 +347,7 @@ module ActionDispatch
221
347
  options << MUST_REVALIDATE if control[:must_revalidate]
222
348
  options << "stale-while-revalidate=#{stale_while_revalidate.to_i}" if stale_while_revalidate
223
349
  options << "stale-if-error=#{stale_if_error.to_i}" if stale_if_error
350
+ options << IMMUTABLE if control[:immutable]
224
351
  options.concat(extras) if extras
225
352
  end
226
353
 
@@ -128,6 +128,7 @@ module ActionDispatch # :nodoc:
128
128
  MAPPINGS = {
129
129
  self: "'self'",
130
130
  unsafe_eval: "'unsafe-eval'",
131
+ wasm_unsafe_eval: "'wasm-unsafe-eval'",
131
132
  unsafe_hashes: "'unsafe-hashes'",
132
133
  unsafe_inline: "'unsafe-inline'",
133
134
  none: "'none'",
@@ -170,6 +171,8 @@ module ActionDispatch # :nodoc:
170
171
  worker_src: "worker-src"
171
172
  }.freeze
172
173
 
174
+ HASH_SOURCE_ALGORITHM_PREFIXES = ["sha256-", "sha384-", "sha512-"].freeze
175
+
173
176
  DEFAULT_NONCE_DIRECTIVES = %w[script-src style-src].freeze
174
177
 
175
178
  private_constant :MAPPINGS, :DIRECTIVES, :DEFAULT_NONCE_DIRECTIVES
@@ -304,7 +307,13 @@ module ActionDispatch # :nodoc:
304
307
  case source
305
308
  when Symbol
306
309
  apply_mapping(source)
307
- when String, Proc
310
+ when String
311
+ if hash_source?(source)
312
+ "'#{source}'"
313
+ else
314
+ source
315
+ end
316
+ when Proc
308
317
  source
309
318
  else
310
319
  raise ArgumentError, "Invalid content security policy source: #{source.inspect}"
@@ -373,5 +382,9 @@ module ActionDispatch # :nodoc:
373
382
  def nonce_directive?(directive, nonce_directives)
374
383
  nonce_directives.include?(directive)
375
384
  end
385
+
386
+ def hash_source?(source)
387
+ source.start_with?(*HASH_SOURCE_ALGORITHM_PREFIXES)
388
+ end
376
389
  end
377
390
  end
@@ -17,9 +17,11 @@ module ActionDispatch
17
17
  # For more information about filter behavior, see
18
18
  # ActiveSupport::ParameterFilter.
19
19
  module FilterParameters
20
- ENV_MATCH = [/RAW_POST_DATA/, "rack.request.form_vars"] # :nodoc:
21
- NULL_PARAM_FILTER = ActiveSupport::ParameterFilter.new # :nodoc:
22
- NULL_ENV_FILTER = ActiveSupport::ParameterFilter.new ENV_MATCH # :nodoc:
20
+ # :stopdoc:
21
+ ENV_MATCH = [/RAW_POST_DATA/, "rack.request.form_vars"]
22
+ NULL_PARAM_FILTER = ActiveSupport::ParameterFilter.new
23
+ NULL_ENV_FILTER = ActiveSupport::ParameterFilter.new ENV_MATCH
24
+ # :startdoc:
23
25
 
24
26
  def initialize
25
27
  super
@@ -91,7 +91,49 @@ module ActionDispatch
91
91
  end
92
92
  end
93
93
 
94
- # Sets the variant for template.
94
+ # Sets the \variant for the response template.
95
+ #
96
+ # When determining which template to render, Action View will incorporate
97
+ # all variants from the request. For example, if an
98
+ # `ArticlesController#index` action needs to respond to
99
+ # `request.variant = [:ios, :turbo_native]`, it will render the
100
+ # first template file it can find in the following list:
101
+ #
102
+ # - `app/views/articles/index.html+ios.erb`
103
+ # - `app/views/articles/index.html+turbo_native.erb`
104
+ # - `app/views/articles/index.html.erb`
105
+ #
106
+ # Variants add context to the requests that views render appropriately.
107
+ # Variant names are arbitrary, and can communicate anything from the
108
+ # request's platform (`:android`, `:ios`, `:linux`, `:macos`, `:windows`)
109
+ # to its browser (`:chrome`, `:edge`, `:firefox`, `:safari`), to the type
110
+ # of user (`:admin`, `:guest`, `:user`).
111
+ #
112
+ # Note: Adding many new variant templates with similarities to existing
113
+ # template files can make maintaining your view code more difficult.
114
+ #
115
+ # #### Parameters
116
+ #
117
+ # * `variant` - a symbol name or an array of symbol names for variants
118
+ # used to render the response template
119
+ #
120
+ # #### Examples
121
+ #
122
+ # class ApplicationController < ActionController::Base
123
+ # before_action :determine_variants
124
+ #
125
+ # private
126
+ # def determine_variants
127
+ # variants = []
128
+ #
129
+ # # some code to determine the variant(s) to use
130
+ #
131
+ # variants << :ios if request.user_agent.include?("iOS")
132
+ # variants << :turbo_native if request.user_agent.include?("Turbo Native")
133
+ #
134
+ # request.variant = variants
135
+ # end
136
+ # end
95
137
  def variant=(variant)
96
138
  variant = Array(variant)
97
139
 
@@ -102,6 +144,18 @@ module ActionDispatch
102
144
  end
103
145
  end
104
146
 
147
+ # Returns the \variant for the response template as an instance of
148
+ # ActiveSupport::ArrayInquirer.
149
+ #
150
+ # request.variant = :phone
151
+ # request.variant.phone? # => true
152
+ # request.variant.tablet? # => false
153
+ #
154
+ # request.variant = [:phone, :tablet]
155
+ # request.variant.phone? # => true
156
+ # request.variant.desktop? # => false
157
+ # request.variant.any?(:phone, :desktop) # => true
158
+ # request.variant.any?(:desktop, :watch) # => false
105
159
  def variant
106
160
  @variant ||= ActiveSupport::ArrayInquirer.new
107
161
  end
@@ -13,6 +13,7 @@ Mime::Type.register "text/calendar", :ics
13
13
  Mime::Type.register "text/csv", :csv
14
14
  Mime::Type.register "text/vcard", :vcf
15
15
  Mime::Type.register "text/vtt", :vtt, %w(vtt)
16
+ Mime::Type.register "text/markdown", :md, [], %w(md markdown)
16
17
 
17
18
  Mime::Type.register "image/png", :png, [], %w(png)
18
19
  Mime::Type.register "image/jpeg", :jpeg, [], %w(jpg jpeg jpe pjpeg)
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionDispatch
4
+ class ParamBuilder
5
+ # --
6
+ # This implementation is based on Rack::QueryParser,
7
+ # Copyright (C) 2007-2021 Leah Neukirchen <http://leahneukirchen.org/infopage.html>
8
+
9
+ def self.make_default(param_depth_limit)
10
+ new param_depth_limit
11
+ end
12
+
13
+ attr_reader :param_depth_limit
14
+
15
+ def initialize(param_depth_limit)
16
+ @param_depth_limit = param_depth_limit
17
+ end
18
+
19
+ cattr_accessor :default
20
+ self.default = make_default(100)
21
+
22
+ class << self
23
+ delegate :from_query_string, :from_pairs, :from_hash, to: :default
24
+
25
+ def ignore_leading_brackets
26
+ ActionDispatch.deprecator.warn <<~MSG
27
+ ActionDispatch::ParamBuilder.ignore_leading_brackets is deprecated and have no effect and will be removed in Rails 8.2.
28
+ MSG
29
+
30
+ @ignore_leading_brackets
31
+ end
32
+
33
+ def ignore_leading_brackets=(value)
34
+ ActionDispatch.deprecator.warn <<~MSG
35
+ ActionDispatch::ParamBuilder.ignore_leading_brackets is deprecated and have no effect and will be removed in Rails 8.2.
36
+ MSG
37
+
38
+ @ignore_leading_brackets = value
39
+ end
40
+ end
41
+
42
+ def from_query_string(qs, separator: nil, encoding_template: nil)
43
+ from_pairs QueryParser.each_pair(qs, separator), encoding_template: encoding_template
44
+ end
45
+
46
+ def from_pairs(pairs, encoding_template: nil)
47
+ params = make_params
48
+
49
+ pairs.each do |k, v|
50
+ if Hash === v
51
+ v = ActionDispatch::Http::UploadedFile.new(v)
52
+ end
53
+
54
+ store_nested_param(params, k, v, 0, encoding_template)
55
+ end
56
+
57
+ params
58
+ rescue ArgumentError => e
59
+ raise InvalidParameterError, e.message, e.backtrace
60
+ end
61
+
62
+ def from_hash(hash, encoding_template: nil)
63
+ # Force encodings from encoding template
64
+ hash = Request::Utils::CustomParamEncoder.encode_for_template(hash, encoding_template)
65
+
66
+ # Assert valid encoding
67
+ Request::Utils.check_param_encoding(hash)
68
+
69
+ # Convert hashes to HWIA (or UploadedFile), and deep-munge nils
70
+ # out of arrays
71
+ hash = Request::Utils.normalize_encode_params(hash)
72
+
73
+ hash
74
+ end
75
+
76
+ private
77
+ def store_nested_param(params, name, v, depth, encoding_template = nil)
78
+ raise ParamsTooDeepError if depth >= param_depth_limit
79
+
80
+ if !name
81
+ # nil name, treat same as empty string (required by tests)
82
+ k = after = ""
83
+ elsif depth == 0
84
+ # Start of parsing, don't treat [] or [ at start of string specially
85
+ if start = name.index("[", 1)
86
+ # Start of parameter nesting, use part before brackets as key
87
+ k = name[0, start]
88
+ after = name[start, name.length]
89
+ else
90
+ # Plain parameter with no nesting
91
+ k = name
92
+ after = ""
93
+ end
94
+ elsif name.start_with?("[]")
95
+ # Array nesting
96
+ k = "[]"
97
+ after = name[2, name.length]
98
+ elsif name.start_with?("[") && (start = name.index("]", 1))
99
+ # Hash nesting, use the part inside brackets as the key
100
+ k = name[1, start - 1]
101
+ after = name[start + 1, name.length]
102
+ else
103
+ # Probably malformed input, nested but not starting with [
104
+ # treat full name as key for backwards compatibility.
105
+ k = name
106
+ after = ""
107
+ end
108
+
109
+ return if k.empty?
110
+
111
+ unless k.valid_encoding?
112
+ raise InvalidParameterError, "Invalid encoding for parameter: #{k}"
113
+ end
114
+
115
+ if depth == 0 && String === v
116
+ # We have to wait until we've found the top part of the name,
117
+ # because that's what the encoding template is configured with
118
+ if encoding_template && (designated_encoding = encoding_template[k]) && !v.frozen?
119
+ v.force_encoding(designated_encoding)
120
+ end
121
+
122
+ # ... and we can't validate the encoding until after we've
123
+ # applied any template override
124
+ unless v.valid_encoding?
125
+ raise InvalidParameterError, "Invalid encoding for parameter: #{v.scrub}"
126
+ end
127
+ end
128
+
129
+ if after == ""
130
+ if k == "[]" && depth != 0
131
+ return (v || !ActionDispatch::Request::Utils.perform_deep_munge) ? [v] : []
132
+ else
133
+ params[k] = v
134
+ end
135
+ elsif after == "["
136
+ params[name] = v
137
+ elsif after == "[]"
138
+ params[k] ||= []
139
+ raise ParameterTypeError, "expected Array (got #{params[k].class.name}) for param `#{k}'" unless params[k].is_a?(Array)
140
+ params[k] << v if v || !ActionDispatch::Request::Utils.perform_deep_munge
141
+ elsif after.start_with?("[]")
142
+ # Recognize x[][y] (hash inside array) parameters
143
+ unless after[2] == "[" && after.end_with?("]") && (child_key = after[3, after.length - 4]) && !child_key.empty? && !child_key.index("[") && !child_key.index("]")
144
+ # Handle other nested array parameters
145
+ child_key = after[2, after.length]
146
+ end
147
+ params[k] ||= []
148
+ raise ParameterTypeError, "expected Array (got #{params[k].class.name}) for param `#{k}'" unless params[k].is_a?(Array)
149
+ if params_hash_type?(params[k].last) && !params_hash_has_key?(params[k].last, child_key)
150
+ store_nested_param(params[k].last, child_key, v, depth + 1)
151
+ else
152
+ params[k] << store_nested_param(make_params, child_key, v, depth + 1)
153
+ end
154
+ else
155
+ params[k] ||= make_params
156
+ raise ParameterTypeError, "expected Hash (got #{params[k].class.name}) for param `#{k}'" unless params_hash_type?(params[k])
157
+ params[k] = store_nested_param(params[k], after, v, depth + 1)
158
+ end
159
+
160
+ params
161
+ end
162
+
163
+ def make_params
164
+ ActiveSupport::HashWithIndifferentAccess.new
165
+ end
166
+
167
+ def new_depth_limit(param_depth_limit)
168
+ self.class.new @params_class, param_depth_limit
169
+ end
170
+
171
+ def params_hash_type?(obj)
172
+ Hash === obj
173
+ end
174
+
175
+ def params_hash_has_key?(hash, key)
176
+ return false if key.include?("[]")
177
+
178
+ key.split(/[\[\]]+/).inject(hash) do |h, part|
179
+ next h if part == ""
180
+ return false unless params_hash_type?(h) && h.key?(part)
181
+ h[part]
182
+ end
183
+
184
+ true
185
+ end
186
+ end
187
+ end