roda 3.109.0 → 3.110.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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/lib/roda/cache.rb +0 -2
  3. data/lib/roda/opts.rb +1 -1
  4. data/lib/roda/plugins/_symbol_class_matchers.rb +2 -4
  5. data/lib/roda/plugins/additional_view_directories.rb +2 -2
  6. data/lib/roda/plugins/assets.rb +173 -142
  7. data/lib/roda/plugins/assets_preloading.rb +26 -10
  8. data/lib/roda/plugins/backtracking_array.rb +2 -2
  9. data/lib/roda/plugins/branch_locals.rb +4 -4
  10. data/lib/roda/plugins/break.rb +2 -2
  11. data/lib/roda/plugins/caching.rb +27 -17
  12. data/lib/roda/plugins/capture_erb.rb +15 -12
  13. data/lib/roda/plugins/chunked.rb +32 -30
  14. data/lib/roda/plugins/class_level_routing.rb +5 -2
  15. data/lib/roda/plugins/class_matchers.rb +11 -6
  16. data/lib/roda/plugins/common_logger.rb +21 -3
  17. data/lib/roda/plugins/conditional_sessions.rb +3 -3
  18. data/lib/roda/plugins/content_for.rb +7 -6
  19. data/lib/roda/plugins/content_security_policy.rb +11 -12
  20. data/lib/roda/plugins/cookie_flags.rb +12 -6
  21. data/lib/roda/plugins/cookies.rb +7 -5
  22. data/lib/roda/plugins/csrf.rb +2 -1
  23. data/lib/roda/plugins/custom_block_results.rb +6 -7
  24. data/lib/roda/plugins/default_headers.rb +2 -2
  25. data/lib/roda/plugins/delay_build.rb +1 -1
  26. data/lib/roda/plugins/delegate.rb +7 -6
  27. data/lib/roda/plugins/direct_call.rb +5 -1
  28. data/lib/roda/plugins/disallow_file_uploads.rb +2 -3
  29. data/lib/roda/plugins/drop_body.rb +3 -6
  30. data/lib/roda/plugins/each_part.rb +4 -6
  31. data/lib/roda/plugins/early_hints.rb +4 -4
  32. data/lib/roda/plugins/empty_root.rb +5 -5
  33. data/lib/roda/plugins/environments.rb +8 -8
  34. data/lib/roda/plugins/erb_h.rb +4 -4
  35. data/lib/roda/plugins/error_email.rb +16 -17
  36. data/lib/roda/plugins/error_handler.rb +24 -32
  37. data/lib/roda/plugins/error_mail.rb +26 -15
  38. data/lib/roda/plugins/exception_page.rb +41 -35
  39. data/lib/roda/plugins/filter_common_logger.rb +6 -4
  40. data/lib/roda/plugins/flash.rb +16 -13
  41. data/lib/roda/plugins/freeze_request_response.rb +24 -0
  42. data/lib/roda/plugins/hash_routes.rb +1 -1
  43. data/lib/roda/plugins/permissions_policy.rb +14 -16
  44. data/lib/roda/plugins/public.rb +11 -0
  45. data/lib/roda/plugins/recheck_precompiled_assets.rb +30 -8
  46. data/lib/roda/plugins/render.rb +3 -0
  47. data/lib/roda/plugins/render_locals.rb +1 -1
  48. data/lib/roda/plugins/sessions.rb +158 -137
  49. data/lib/roda/plugins/symbol_matchers.rb +8 -5
  50. data/lib/roda/plugins/unescape_path.rb +22 -4
  51. data/lib/roda/plugins.rb +24 -0
  52. data/lib/roda/request.rb +8 -7
  53. data/lib/roda/response.rb +9 -7
  54. data/lib/roda/version.rb +1 -1
  55. data/lib/roda.rb +12 -9
  56. metadata +2 -1
@@ -29,8 +29,8 @@ class Roda
29
29
  # used in sessions when this plugin is used. This plugin sets the
30
30
  # +:sessions_convert_symbols+ application option to +true+ if it hasn't been set yet,
31
31
  # for better integration with plugins that can use either symbol or string session or
32
- # flash keys. Unlike Rack::Session::Cookie, the session is stored as a plain ruby hash,
33
- # and does not convert all keys to strings.
32
+ # flash keys. Unlike some other session implementations, the session is stored as a plain ruby hash,
33
+ # and does not convert keys to strings.
34
34
  #
35
35
  # All sessions are timestamped and session expiration is enabled by default, with sessions
36
36
  # being valid for 30 days maximum and 7 days since last use by default. Session creation time is
@@ -39,67 +39,43 @@ class Roda
39
39
  #
40
40
  # Session secrets can be rotated. See options below.
41
41
  #
42
- # The sessions plugin can transparently upgrade sessions from versions of Rack::Session::Cookie
43
- # shipped with Rack before Rack 3,
44
- # if the default Rack::Session::Cookie coder and HMAC are used, see options below.
45
- # It is recommended to only enable transparent upgrades for a brief transition period,
46
- # and remove support for them once old sessions have converted or timed out.
47
- #
48
42
  # If the final cookie is too large (>=4096 bytes), a Roda::RodaPlugins::Sessions::CookieTooLarge
49
43
  # exception will be raised.
50
44
  #
51
- # = Required Options
45
+ # = Required Keyword Arguments
52
46
  #
53
47
  # The session cookies this plugin uses are both encrypted and signed, so two separate
54
48
  # secrets are used internally. However, for ease of use, these secrets are combined into
55
- # a single +:secret+ option. The +:secret+ option must be a string of at least 64 bytes
49
+ # a single +secret+ argument. The +secret+ argument must be a string of at least 64 bytes
56
50
  # and should be randomly generated. The first 32 bytes are used as the secret for the
57
51
  # cipher, any remaining bytes are used for the secret for the HMAC.
58
52
  #
59
- # = Other Options
53
+ # = Optional Keyword Arguments
60
54
  #
61
- # :cookie_options :: Any cookie options to set on the session cookie. By default, uses
62
- # <tt>httponly: true, path: '/', same_site: :lax</tt> so that the cookie is not accessible
63
- # to javascript, allowed for all paths, and will not be used for cross-site non-GET requests
64
- # that. If the +:secure+ option is not present in the hash, then
65
- # <tt>secure: true</tt> is also set if the request is made over HTTPS. If this option is
66
- # given, it will be merged into the default cookie options.
67
- # :env_key :: The key in `env` where the session should be located. Defaults to <tt>"rack.session"</tt>, the
68
- # default key for sessions in Rack.
69
- # :gzip_over (deprecated) :: For session data over this many bytes, compress it with the deflate algorithm (default: nil,
70
- # so never compress). Note that compression should not be enabled if you are storing data in
71
- # the session derived from user input and also storing sensitive data in the session.
72
- # :key :: The cookie name to use (default: <tt>'roda.session'</tt>)
73
- # :max_seconds :: The maximum number of seconds to allow for total session lifetime, starting with when
74
- # the session was originally created. Default is <tt>86400*30</tt> (30 days). Can be set to
75
- # +nil+ to disable session lifetime checks.
76
- # :max_idle_seconds :: The maximum number of seconds to allow since the session was last updated.
77
- # Default is <tt>86400*7</tt> (7 days). Can be set to nil to disable session idleness
78
- # checks.
79
- # :old_secret :: The previous secret to use, allowing for secret rotation. Must be a string of at least 64
80
- # bytes if given.
81
- # :pad_size :: Pad session data (after possible compression, before encryption), to a multiple of this
82
- # many bytes (default: 32). This can be between 2-4096 bytes, or +nil+ to disable padding.
83
- # :per_cookie_cipher_secret :: Uses a separate cipher key for every cookie, with the key used generated using
84
- # HMAC-SHA-256 of 32 bytes of random data with the default cipher secret. This
85
- # offers additional protection in case the random initialization vector used when
86
- # encrypting the session data has been reused. Odds of that are 1 in 2**64 if
87
- # initialization vector is truly random, but weaknesses in the random number
88
- # generator could make the odds much higher. Default is +true+.
89
- # :parser :: The parser for the serialized session data (default: <tt>JSON.method(:parse)</tt>).
55
+ # +cookie_options+ :: Any cookie options to set on the session cookie. By default, uses
56
+ # <tt>httponly: true, path: '/', same_site: :lax</tt> so that the cookie is not accessible
57
+ # to javascript, allowed for all paths, and will not be used for cross-site non-GET requests.
58
+ # If the +:secure+ option is not present in the hash, then
59
+ # <tt>secure: true</tt> is also set if the request is made over HTTPS. If this option is
60
+ # given, it will be merged into the default cookie options.
61
+ # +env_key+ :: The key in `env` where the session should be located. Defaults to <tt>"rack.session"</tt>, the
62
+ # default key for sessions in Rack.
63
+ # +key+ :: The cookie name to use (default: <tt>'roda.session'</tt>)
64
+ # +max_idle_seconds+ :: The maximum number of seconds to allow since the session was last updated.
65
+ # Default is <tt>86400*7</tt> (7 days). Can be set to nil to disable session idleness
66
+ # checks.
67
+ # +max_seconds+ :: The maximum number of seconds to allow for total session lifetime, starting with when
68
+ # the session was originally created. Default is <tt>86400*30</tt> (30 days). Can be set to
69
+ # +nil+ to disable session lifetime checks.
70
+ # +old_secret+ :: The previous secret to use, allowing for secret rotation. Must be a string of at least 64
71
+ # bytes if given.
72
+ # +pad_size+ :: Pad session data to a multiple of this many bytes (default: 32). This can be between
73
+ # 2-4096 bytes, or +nil+ to disable padding.
74
+ # +parser+ :: The parser for the serialized session data (default: <tt>JSON.method(:parse)</tt>).
90
75
  # :serializer :: The serializer for the session data (default +:to_json.to_proc+).
91
76
  # :skip_within :: If the last update time for the session cookie is less than this number of seconds from the
92
77
  # current time, and the session has not been modified, do not set a new session cookie
93
78
  # (default: 3600).
94
- # :upgrade_from_rack_session_cookie_key :: The cookie name to use for transparently upgrading from
95
- # Rack::Session:Cookie (defaults to <tt>'rack.session'</tt>).
96
- # :upgrade_from_rack_session_cookie_secret (deprecated) :: The secret for the HMAC-SHA1 signature when allowing
97
- # transparent upgrades from Rack::Session::Cookie. Using this
98
- # option is only recommended during a short transition period,
99
- # and is not enabled by default as it lowers security.
100
- # :upgrade_from_rack_session_cookie_options :: Options to pass when deleting the cookie used by
101
- # Rack::Session::Cookie after converting it to use the session
102
- # cookies used by this plugin.
103
79
  #
104
80
  # = Not a Rack Middleware
105
81
  #
@@ -118,13 +94,9 @@ class Roda
118
94
  #
119
95
  # urlsafe_base64("\1" + random_data + IV + encrypted session data + HMAC)
120
96
  #
121
- # If +:per_cookie_cipher_secret+ option is set to +false+, an older format is used:
122
- #
123
- # urlsafe_base64("\0" + IV + encrypted session data + HMAC)
124
- #
125
97
  # where:
126
98
  #
127
- # version :: 1 byte, currently must be 1 or 0, other values reserved for future expansion.
99
+ # version :: 1 byte, currently must be 1, other values reserved for future expansion.
128
100
  # random_data :: 32 bytes, used for generating the per-cookie secret
129
101
  # IV :: 16 bytes, initialization vector for AES-256-CTR cipher.
130
102
  # encrypted session data :: >=12 bytes of data encrypted with AES-256-CTR cipher, see below.
@@ -138,15 +110,13 @@ class Roda
138
110
  # where:
139
111
  #
140
112
  # bitmap :: 2 bytes in little endian format, lower 12 bits storing number of padding
141
- # bytes, 13th bit storing whether serialized data is compressed with deflate.
142
- # Bits 14-16 reserved for future expansion.
113
+ # bytes. Bits 13-16 reserved for future expansion.
143
114
  # creation time :: 4 byte integer in unsigned little endian format, storing unix timestamp
144
115
  # since session initially created.
145
116
  # update time :: 4 byte integer in unsigned little endian format, storing unix timestamp
146
117
  # since session last updated.
147
118
  # padding :: >=0 padding bytes specified in bitmap, filled with random data, can be ignored.
148
- # serialized data :: >=2 bytes of serialized data in JSON format. If the bitmap indicates
149
- # deflate compression, this contains the deflate compressed data.
119
+ # serialized data :: >=2 bytes of serialized data in JSON format.
150
120
  module Sessions
151
121
  DEFAULT_COOKIE_OPTIONS = {:httponly=>true, :path=>'/'.freeze, :same_site=>:lax}.freeze
152
122
  DEFAULT_OPTIONS = {:key => 'roda.session'.freeze, :max_seconds=>86400*30, :max_idle_seconds=>86400*7, :pad_size=>32, :gzip_over=>nil, :skip_within=>3600, :env_key=>'rack.session'}.freeze
@@ -158,6 +128,56 @@ class Roda
158
128
  SESSION_VERSION_NUM = 'roda.session.version'.freeze
159
129
  SESSION_DELETE_RACK_COOKIE = 'roda.session.delete_rack_session_cookie'.freeze
160
130
 
131
+ %i[
132
+ DEFAULT_COOKIE_OPTIONS
133
+ DEFAULT_OPTIONS
134
+ DEFLATE_BIT
135
+ PADDING_MASK
136
+ SESSION_CREATED_AT
137
+ SESSION_UPDATED_AT
138
+ SESSION_SERIALIZED
139
+ SESSION_VERSION_NUM
140
+ SESSION_DELETE_RACK_COOKIE].each do |sym|
141
+ RodaPlugins.deprecate_constant self, sym
142
+ end
143
+
144
+ PLUGIN_ARGUMENTS = %i[
145
+ cookie_options
146
+ env_key
147
+ gzip_over
148
+ key
149
+ max_idle_seconds
150
+ max_seconds
151
+ old_secret
152
+ pad_size
153
+ parser
154
+ per_cookie_cipher_secret
155
+ serializer
156
+ secret
157
+ skip_within
158
+ upgrade_from_rack_session_cookie_key
159
+ upgrade_from_rack_session_cookie_secret
160
+ upgrade_from_rack_session_cookie_options
161
+ ].freeze
162
+ private_constant :PLUGIN_ARGUMENTS
163
+
164
+ deprecated_plugin_arguments = %i[
165
+ gzip_over
166
+ per_cookie_cipher_secret
167
+ upgrade_from_rack_session_cookie_key
168
+ upgrade_from_rack_session_cookie_secret
169
+ upgrade_from_rack_session_cookie_options
170
+ ]
171
+
172
+ Config = Struct.new(*(PLUGIN_ARGUMENTS - deprecated_plugin_arguments - %i[secret old_secret]),
173
+ :remove_cookie_options,
174
+ :cipher_secret,
175
+ :hmac_secret,
176
+ :old_cipher_secret,
177
+ :old_hmac_secret
178
+ )
179
+ private_constant :Config
180
+
161
181
  # Exception class used when creating a session cookie that would exceed the
162
182
  # allowable cookie size limit.
163
183
  class CookieTooLarge < RodaError
@@ -167,57 +187,71 @@ class Roda
167
187
  def self.split_secret(name, secret)
168
188
  raise RodaError, "sessions plugin :#{name} option must be a String" unless secret.is_a?(String)
169
189
  raise RodaError, "invalid sessions plugin :#{name} option length: #{secret.bytesize}, must be >=64" unless secret.bytesize >= 64
170
- hmac_secret = secret = secret.dup.force_encoding('BINARY')
190
+ hmac_secret = secret = secret.b
171
191
  cipher_secret = secret.slice!(0, 32)
172
192
  [cipher_secret.freeze, hmac_secret.freeze]
173
193
  end
174
194
 
175
- def self.load_dependencies(app, opts=OPTS)
195
+ def self.load_dependencies(app, opts=OPTS, **)
176
196
  app.plugin :_base64
177
197
  app.plugin :_json
178
198
  end
179
199
 
180
200
  # Configure the plugin, see Sessions for details on options.
181
201
  def self.configure(app, opts=OPTS)
182
- opts = (app.sessions_opts || DEFAULT_OPTIONS).merge(opts)
183
- co = opts[:cookie_options] = DEFAULT_COOKIE_OPTIONS.merge(opts[:cookie_options] || OPTS).freeze
184
- opts[:remove_cookie_options] = co.merge(:max_age=>'0', :expires=>Time.at(0))
185
- opts[:parser] ||= app.json_parser
186
- opts[:serializer] ||= app.json_serializer
202
+ RodaPlugins.check_supported_opts("the sessions plugin", opts, PLUGIN_ARGUMENTS)
203
+
204
+ if opts.has_key?(:per_cookie_cipher_secret)
205
+ RodaPlugins.warn("Roda 4 will default to per cookie cipher secrets, remove the per_cookie_cipher_secret argument", uplevel: 1)
206
+ end
207
+
208
+ if opts.has_key?(:upgrade_from_rack_session_cookie_secret) || opts.has_key?(:upgrade_from_rack_session_cookie_key) || opts.has_key?(:upgrade_from_rack_session_cookie_options)
209
+ RodaPlugins.warn("Support for upgrading rack session cookie sessions will be removed in Roda 4, remove the upgrade_from_rack_session_cookie_* arguments.", uplevel: 1)
210
+ end
211
+
212
+ if opts.has_key?(:gzip_over)
213
+ RodaPlugins.warn("Support for compressing session data will be removed in Roda 4, remove the gzip_over argument.", uplevel: 1)
214
+ end
215
+
216
+ opts = (app.sessions_opts || {:key => 'roda.session'.freeze, :max_seconds=>86400*30, :max_idle_seconds=>86400*7, :pad_size=>32, :skip_within=>3600, :env_key=>'rack.session'}).merge(opts)
217
+ config = Config.new(*opts.values_at(*Config.members))
218
+ co = config.cookie_options = {:httponly=>true, :path=>'/'.freeze, :same_site=>:lax}.merge(config.cookie_options || OPTS).freeze
219
+ config.remove_cookie_options = co.merge(:max_age=>'0', :expires=>Time.at(0))
220
+ config.parser ||= app.json_parser
221
+ config.serializer ||= app.json_serializer
187
222
 
188
223
  opts[:per_cookie_cipher_secret] = true unless opts.has_key?(:per_cookie_cipher_secret)
189
- opts[:session_version_num] = opts[:per_cookie_cipher_secret] ? 1 : 0
190
224
 
191
225
  if opts[:upgrade_from_rack_session_cookie_secret]
192
- RodaPlugins.warn("Support for upgrading rack session cookie sessions will be removed in Roda 4.")
193
226
  opts[:upgrade_from_rack_session_cookie_key] ||= 'rack.session'
194
227
  rsco = opts[:upgrade_from_rack_session_cookie_options] = Hash[opts[:upgrade_from_rack_session_cookie_options] || OPTS]
195
228
  rsco[:path] ||= co[:path]
196
229
  rsco[:domain] ||= co[:domain]
197
230
  end
198
231
 
199
- if opts[:gzip_over]
200
- RodaPlugins.warn("Support for compressing session data will be removed in Roda 4.")
232
+ secret = opts[:secret]
233
+ config.cipher_secret, config.hmac_secret = split_secret(:secret, secret)
234
+ if old_secret = opts[:old_secret]
235
+ config.old_cipher_secret, config.old_hmac_secret = split_secret(:old_secret, old_secret)
201
236
  end
202
237
 
203
- opts[:cipher_secret], opts[:hmac_secret] = split_secret(:secret, opts[:secret])
204
- opts[:old_cipher_secret], opts[:old_hmac_secret] = (split_secret(:old_secret, opts[:old_secret]) if opts[:old_secret])
205
-
206
- case opts[:pad_size]
238
+ case config.pad_size
207
239
  when nil
208
240
  # no changes
209
241
  when Integer
210
- raise RodaError, "invalid :pad_size: #{opts[:pad_size]}, must be >=2, < 4096" unless opts[:pad_size] >= 2 && opts[:pad_size] < 4096
242
+ raise RodaError, "invalid :pad_size: #{config.pad_size}, must be >=2, < 4096" unless config.pad_size >= 2 && config.pad_size < 4096
211
243
  else
212
- raise RodaError, "invalid :pad_size option: #{opts[:pad_size].inspect}, must be Integer or nil"
244
+ raise RodaError, "invalid pad_size argument: #{config.pad_size.inspect}, must be Integer or nil"
213
245
  end
214
246
 
215
- app.opts[:sessions] = opts.freeze
247
+ app.opts[:sessions] = config.to_h.merge(opts).freeze
248
+ app.opts[:sessions_config] = config.freeze
216
249
  app.opts[:sessions_convert_symbols] = true unless app.opts.has_key?(:sessions_convert_symbols)
217
250
  end
218
251
 
219
252
  module ClassMethods
220
253
  RodaPlugins.opt_attr_reader(self, :sessions, name: :sessions_opts)
254
+ RodaPlugins.opt_attr_reader(self, :sessions_config)
221
255
  end
222
256
 
223
257
  module InstanceMethods
@@ -226,8 +260,8 @@ class Roda
226
260
  # instead of the previous creation timestamp.
227
261
  def clear_session
228
262
  session.clear
229
- env.delete(SESSION_CREATED_AT)
230
- env.delete(SESSION_UPDATED_AT)
263
+ env.delete('roda.session.created_at')
264
+ env.delete('roda.session.updated_at')
231
265
  nil
232
266
  end
233
267
 
@@ -237,7 +271,7 @@ class Roda
237
271
  # update the rack response headers to set the session cookie in
238
272
  # the response.
239
273
  def _roda_after_50__sessions(res)
240
- if res && (session = env[self.class.sessions_opts[:env_key]])
274
+ if res && (session = env[self.class.sessions_config.env_key])
241
275
  @_request.persist_session(res[1], session)
242
276
  end
243
277
  end
@@ -251,37 +285,35 @@ class Roda
251
285
  # this method stores the session in 'rack.session' in the request environment,
252
286
  # but that does not happen until this method is called.
253
287
  def session
254
- @env[roda_class.sessions_opts[:env_key]] ||= _load_session
288
+ @env[roda_class.sessions_config.env_key] ||= _load_session
255
289
  end
256
290
 
257
291
  # The time the session was originally created. nil if there is no active session.
258
292
  def session_created_at
259
293
  session
260
- Time.at(@env[SESSION_CREATED_AT]) if @env[SESSION_SERIALIZED]
294
+ Time.at(@env['roda.session.created_at']) if @env['roda.session.serialized']
261
295
  end
262
296
 
263
297
  # The time the session was last updated. nil if there is no active session.
264
298
  def session_updated_at
265
299
  session
266
- Time.at(@env[SESSION_UPDATED_AT]) if @env[SESSION_SERIALIZED]
300
+ Time.at(@env['roda.session.updated_at']) if @env['roda.session.serialized']
267
301
  end
268
302
 
269
- # Persist the session data as a cookie. If transparently upgrading from
270
- # Rack::Session::Cookie, mark the related cookie for expiration so it isn't
271
- # sent in the future.
303
+ # Persist the session data as a cookie.
272
304
  def persist_session(headers, session)
273
- opts = roda_class.sessions_opts
305
+ config = roda_class.sessions_config
274
306
 
275
307
  if session.empty?
276
- if env[SESSION_SERIALIZED]
308
+ if env['roda.session.serialized']
277
309
  # If session was submitted and is now empty, remove the cookie
278
- Rack::Utils.delete_cookie_header!(headers, opts[:key], opts[:remove_cookie_options])
310
+ Rack::Utils.delete_cookie_header!(headers, config.key, config.remove_cookie_options)
279
311
  # else
280
312
  # If no session was submitted, and the session is empty
281
313
  # then there is no need to do anything
282
314
  end
283
315
  elsif cookie_value = _serialize_session(session)
284
- cookie = Hash[opts[:cookie_options]]
316
+ cookie = Hash[config.cookie_options]
285
317
  cookie[:value] = cookie_value
286
318
  cookie[:secure] = true if !cookie.has_key?(:secure) && ssl?
287
319
 
@@ -291,7 +323,7 @@ class Roda
291
323
  0
292
324
  end
293
325
 
294
- Rack::Utils.set_cookie_header!(headers, opts[:key], cookie)
326
+ Rack::Utils.set_cookie_header!(headers, config.key, cookie)
295
327
 
296
328
  cookie_size = case set_cookie_after = headers[RodaResponseHeaders::SET_COOKIE]
297
329
  when String
@@ -309,7 +341,8 @@ class Roda
309
341
  end
310
342
  end
311
343
 
312
- if env[SESSION_DELETE_RACK_COOKIE]
344
+ if env['roda.session.delete_rack_session_cookie']
345
+ opts = roda_class.sessions_opts
313
346
  Rack::Utils.delete_cookie_header!(headers, opts[:upgrade_from_rack_session_cookie_key], opts[:upgrade_from_rack_session_cookie_options])
314
347
  end
315
348
 
@@ -321,12 +354,11 @@ class Roda
321
354
  # Load the session by looking for the appropriate cookie, or falling
322
355
  # back to the rack session cookie if configured.
323
356
  def _load_session
324
- opts = roda_class.sessions_opts
325
357
  cs = cookies
326
358
 
327
- if data = cs[opts[:key]]
359
+ if data = cs[roda_class.sessions_config.key]
328
360
  _deserialize_session(data)
329
- elsif (key = opts[:upgrade_from_rack_session_cookie_key]) && (data = cs[key])
361
+ elsif (key = roda_class.sessions_opts[:upgrade_from_rack_session_cookie_key]) && (data = cs[key])
330
362
  _deserialize_rack_session(data)
331
363
  end || {}
332
364
  end
@@ -340,9 +372,6 @@ class Roda
340
372
  nil
341
373
  end
342
374
 
343
- # Interpret given cookie data as a Rack::Session::Cookie
344
- # serialized session using the default Rack::Session::Cookie
345
- # hmac and coder.
346
375
  def _deserialize_rack_session(data)
347
376
  opts = roda_class.sessions_opts
348
377
  data, digest = data.split("--", 2)
@@ -359,26 +388,18 @@ class Roda
359
388
  return _session_serialization_error("Error decoding Rack::Session::Cookie session: not base64 encoded marshal dump")
360
389
  end
361
390
 
362
- # Mark rack session cookie for deletion on success
363
- env[SESSION_DELETE_RACK_COOKIE] = true
391
+ env['roda.session.delete_rack_session_cookie'] = true
364
392
 
365
- # Delete the session id before serializing it. Starting in rack 2.0.8,
366
- # this is an object and not just a string, and calling to_s on it raises
367
- # a RuntimeError.
368
393
  session.delete("session_id")
369
394
 
370
- # Convert the rack session by roundtripping it through
371
- # the parser and serializer, so that you would get the
372
- # same result as you would if the session was handled
373
- # by this plugin.
374
- env[SESSION_SERIALIZED] = data = opts[:serializer].call(session)
375
- env[SESSION_CREATED_AT] = Time.now.to_i
395
+ env['roda.session.serialized'] = data = opts[:serializer].call(session)
396
+ env['roda.session.created_at'] = Time.now.to_i
376
397
  opts[:parser].call(data)
377
398
  end
378
399
 
379
- # Interpret given cookie data as a Rack::Session::Cookie
400
+ # Interpret given cookie data as a session
380
401
  def _deserialize_session(data)
381
- opts = roda_class.sessions_opts
402
+ config = roda_class.sessions_config
382
403
 
383
404
  begin
384
405
  data = Base64_.urlsafe_decode64(data)
@@ -402,7 +423,6 @@ class Roda
402
423
  min_data_length = 93
403
424
  when 0
404
425
  per_cookie_secret = false
405
- # minimum length (1+16+12+32) (version+cipher_iv+minimum session+hmac)
406
426
  min_data_length = 61
407
427
  when nil
408
428
  return _session_serialization_error("Unable to decode session: no data")
@@ -416,9 +436,11 @@ class Roda
416
436
  end
417
437
 
418
438
  encrypted_data = data.slice!(0, length-32)
419
- unless Rack::Utils.secure_compare(data, OpenSSL::HMAC.digest(OpenSSL::Digest::SHA256.new, opts[:hmac_secret], encrypted_data+opts[:key]))
420
- if opts[:old_hmac_secret] && Rack::Utils.secure_compare(data, OpenSSL::HMAC.digest(OpenSSL::Digest::SHA256.new, opts[:old_hmac_secret], encrypted_data+opts[:key]))
421
- use_old_cipher_secret = true
439
+ hmac_data = encrypted_data+config.key
440
+ cipher_secret = config.cipher_secret
441
+ unless Rack::Utils.secure_compare(data, OpenSSL::HMAC.digest(OpenSSL::Digest::SHA256.new, config.hmac_secret, hmac_data))
442
+ if config.old_hmac_secret && Rack::Utils.secure_compare(data, OpenSSL::HMAC.digest(OpenSSL::Digest::SHA256.new, config.old_hmac_secret, hmac_data))
443
+ cipher_secret = config.old_cipher_secret
422
444
  else
423
445
  return _session_serialization_error("Not decoding session: HMAC invalid")
424
446
  end
@@ -427,7 +449,6 @@ class Roda
427
449
  # Remove version
428
450
  encrypted_data.slice!(0)
429
451
 
430
- cipher_secret = opts[use_old_cipher_secret ? :old_cipher_secret : :cipher_secret]
431
452
  if per_cookie_secret
432
453
  cipher_secret = OpenSSL::HMAC.digest(OpenSSL::Digest::SHA256.new, cipher_secret, encrypted_data.slice!(0, 32))
433
454
  end
@@ -445,71 +466,71 @@ class Roda
445
466
  data = cipher.update(encrypted_data) << cipher.final
446
467
 
447
468
  bitmap, created_at, updated_at = data.unpack('vVV')
448
- padding_bytes = bitmap & PADDING_MASK
469
+ padding_bytes = bitmap & 0x0fff
449
470
  now = Time.now.to_i
450
- if (max = opts[:max_seconds]) && now > created_at + max
471
+ if (max = config.max_seconds) && now > created_at + max
451
472
  return _session_serialization_error("Not returning session: maximum session time expired")
452
473
  end
453
- if (max = opts[:max_idle_seconds]) && now > updated_at + max
474
+ if (max = config.max_idle_seconds) && now > updated_at + max
454
475
  return _session_serialization_error("Not returning session: maximum session idle time expired")
455
476
  end
456
477
 
457
478
  data = data.slice(10+padding_bytes, data.bytesize)
458
479
 
459
- if bitmap & DEFLATE_BIT > 0
480
+ if bitmap & 0x1000 > 0
460
481
  data = Zlib::Inflate.inflate(data)
461
482
  end
462
483
 
463
484
  env = @env
464
- env[SESSION_CREATED_AT] = created_at
465
- env[SESSION_UPDATED_AT] = updated_at
466
- env[SESSION_SERIALIZED] = data
467
- env[SESSION_VERSION_NUM] = version
485
+ env['roda.session.created_at'] = created_at
486
+ env['roda.session.updated_at'] = updated_at
487
+ env['roda.session.serialized'] = data
488
+ env['roda.session.version'] = version
468
489
 
469
- opts[:parser].call(data)
490
+ config.parser.call(data)
470
491
  end
471
492
 
472
493
  def _serialize_session(session)
473
- opts = roda_class.sessions_opts
494
+ config = roda_class.sessions_config
474
495
  env = @env
475
496
  now = Time.now.to_i
476
- json_data = opts[:serializer].call(session).force_encoding('BINARY')
497
+ json_data = config.serializer.call(session).force_encoding(Encoding::ASCII_8BIT)
477
498
 
478
- if (serialized_session = env[SESSION_SERIALIZED]) &&
479
- (opts[:session_version_num] == env[SESSION_VERSION_NUM]) &&
480
- (updated_at = env[SESSION_UPDATED_AT]) &&
481
- (now - updated_at < opts[:skip_within]) &&
499
+ if (serialized_session = env['roda.session.serialized']) &&
500
+ ((roda_class.sessions_opts[:per_cookie_cipher_secret] ? 1 : 0) == env['roda.session.version']) &&
501
+ (updated_at = env['roda.session.updated_at']) &&
502
+ (now - updated_at < config.skip_within) &&
482
503
  (serialized_session == json_data)
483
504
  return
484
505
  end
485
506
 
486
507
  bitmap = 0
487
508
  json_length = json_data.bytesize
488
- gzip_over = opts[:gzip_over]
509
+ gzip_over = roda_class.sessions_opts[:gzip_over]
489
510
 
490
511
  if gzip_over && json_length > gzip_over
491
512
  json_data = Zlib.deflate(json_data)
492
513
  json_length = json_data.bytesize
493
- bitmap |= DEFLATE_BIT
514
+ bitmap |= 0x1000
494
515
  end
495
516
 
496
517
  # When calculating padding bytes to use, include 10 bytes for bitmap and
497
518
  # session create/update times, so total size of encrypted data is a
498
519
  # multiple of pad_size.
499
- if (pad_size = opts[:pad_size]) && (padding_bytes = (json_length+10) % pad_size) != 0
520
+ if (pad_size = config.pad_size) && (padding_bytes = (json_length+10) % pad_size) != 0
500
521
  padding_bytes = pad_size - padding_bytes
501
522
  bitmap |= padding_bytes
502
523
  padding_data = SecureRandom.random_bytes(padding_bytes)
503
524
  end
504
525
 
505
- session_create_time = env[SESSION_CREATED_AT]
526
+ session_create_time = env['roda.session.created_at']
506
527
  serialized_data = [bitmap, session_create_time||now, now].pack('vVV')
507
528
 
508
529
  serialized_data << padding_data if padding_data
509
530
  serialized_data << json_data
510
531
 
511
- cipher_secret = opts[:cipher_secret]
512
- if opts[:per_cookie_cipher_secret]
532
+ cipher_secret = config.cipher_secret
533
+ if roda_class.sessions_opts[:per_cookie_cipher_secret]
513
534
  version = "\1"
514
535
  per_cookie_secret_base = SecureRandom.random_bytes(32)
515
536
  cipher_secret = OpenSSL::HMAC.digest(OpenSSL::Digest::SHA256.new, cipher_secret, per_cookie_secret_base)
@@ -528,7 +549,7 @@ class Roda
528
549
  data << per_cookie_secret_base if per_cookie_secret_base
529
550
  data << cipher_iv
530
551
  data << encrypted_data
531
- data << OpenSSL::HMAC.digest(OpenSSL::Digest::SHA256.new, opts[:hmac_secret], data+opts[:key])
552
+ data << OpenSSL::HMAC.digest(OpenSSL::Digest::SHA256.new, config.hmac_secret, data+config.key)
532
553
 
533
554
  data = Base64_.urlsafe_encode64(data)
534
555
 
@@ -19,7 +19,7 @@ class Roda
19
19
  # Then the route will only if the path is +/foobar123+, but not if it is
20
20
  # +/foo+, +/FooBar123+, or +/foobar_123+.
21
21
  #
22
- # You can provide a <tt>segment: true</tt> option to symbol_matcher to speed up
22
+ # You can provide a <tt>segment: true</tt> argument to symbol_matcher to speed up
23
23
  # the matching on Ruby 2.4+:
24
24
  #
25
25
  # symbol_matcher :username, /([a-z0-9]{6,20})/, segment: true
@@ -131,10 +131,13 @@ class Roda
131
131
  # If providing a registered symbol or class, the block will be called with the
132
132
  # captures returned by the block for the registered symbol or class, or the regexp
133
133
  # captures if no block was registered with the symbol or class. In either case,
134
- # if a block is given, it should return an array with the captures to yield to
135
- # the match block.
136
- def symbol_matcher(s, matcher, opts=OPTS, &block)
137
- _symbol_class_matcher(Symbol, s, matcher, block, opts) do |meth, array|
134
+ # if a block is given, it should return an object to yield to the match block,
135
+ # or an array to yield multiple arguments to the match block. If the block returns
136
+ # nil or false, it will be treated as not matching.
137
+ def symbol_matcher(s, matcher, opts=(no_positional_opts = true; OPTS), segment: opts[:segment], **kw, &block)
138
+ RodaPlugins.check_keywords(__callee__, no_positional_opts, opts, kw)
139
+
140
+ _symbol_class_matcher(Symbol, s, matcher, block, segment: segment) do |meth, array|
138
141
  define_method(meth){array}
139
142
  end
140
143
 
@@ -1,7 +1,5 @@
1
1
  # frozen-string-literal: true
2
2
 
3
- require 'rack/utils'
4
-
5
3
  #
6
4
  class Roda
7
5
  module RodaPlugins
@@ -24,14 +22,34 @@ class Roda
24
22
  # of the remaining path.
25
23
  def matched_path
26
24
  e = @env
27
- Rack::Utils.unescape(e["SCRIPT_NAME"] + e["PATH_INFO"]).chomp(@remaining_path)
25
+ _unescape_path(e["SCRIPT_NAME"] + e["PATH_INFO"]).chomp(@remaining_path)
28
26
  end
29
27
 
30
28
  private
31
29
 
32
30
  # Unescape the path.
33
31
  def _remaining_path(env)
34
- Rack::Utils.unescape(super)
32
+ _unescape_path(super)
33
+ end
34
+
35
+ if RUBY_VERSION >= "4.0"
36
+ require "cgi/escape"
37
+
38
+ # Use CGI.unescapeURIComponent, which unescapes percent encoding
39
+ # but not +.
40
+ def _unescape_path(path)
41
+ CGI.unescapeURIComponent(path)
42
+ end
43
+ # simplecov:disable
44
+ else
45
+ require 'rack/utils'
46
+
47
+ # Rack::Utils.unescape decodes + as a space, which is only
48
+ # correct for form data, so escape + first to keep it.
49
+ def _unescape_path(path)
50
+ Rack::Utils.unescape(path.gsub('+', '%2B'))
51
+ end
52
+ # simplecov:enable
35
53
  end
36
54
  end
37
55
  end
data/lib/roda/plugins.rb CHANGED
@@ -42,6 +42,30 @@ class Roda
42
42
  nil
43
43
  end
44
44
 
45
+ # Warn if the opts given contain unsupported keys.
46
+ def self.check_supported_opts(desc, opts, supported_keys)
47
+ extra_keys = opts.keys - supported_keys
48
+
49
+ unless extra_keys.empty?
50
+ warn("Unsupported options given to #{desc}: #{extra_keys.map(&:inspect).join(", ")}", uplevel: 2)
51
+ end
52
+
53
+ nil
54
+ end
55
+
56
+ # Handle positional argument to keyword argument conversion warnings in plugins.
57
+ def self.check_keywords(method, no_positional_opts, opts, kw)
58
+ unless no_positional_opts
59
+ warn("A positional options hash is now deprecated for #{method}. Support for this will be removed in Roda 4. Switch to passing keywords arguments. Keys given in positional options hash: #{opts.keys.map(&:inspect).join(", ")}", uplevel: 2)
60
+ end
61
+
62
+ unless kw.empty?
63
+ warn("Unrecognized keywords given to #{method}: #{kw.keys.join(", ")}. Backwards compatibility for this will be removed in Roda 4.", uplevel: 2)
64
+ end
65
+
66
+ nil
67
+ end
68
+
45
69
  # Register the given plugin with Roda, so that it can be loaded using #plugin
46
70
  # with a symbol. Should be used by plugin files. Example:
47
71
  #