roda 3.18.0 → 3.19.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/CHANGELOG +24 -0
- data/README.rdoc +7 -9
- data/doc/conventions.rdoc +10 -10
- data/doc/release_notes/3.19.0.txt +229 -0
- data/lib/roda.rb +88 -45
- data/lib/roda/plugins/assets.rb +11 -4
- data/lib/roda/plugins/delay_build.rb +3 -30
- data/lib/roda/plugins/empty_root.rb +1 -1
- data/lib/roda/plugins/hash_routes.rb +455 -0
- data/lib/roda/plugins/match_hook.rb +69 -0
- data/lib/roda/plugins/multi_route.rb +4 -0
- data/lib/roda/plugins/multi_view.rb +4 -0
- data/lib/roda/plugins/optimized_string_matchers.rb +1 -1
- data/lib/roda/plugins/sessions.rb +63 -16
- data/lib/roda/plugins/static_routing.rb +7 -40
- data/lib/roda/version.rb +1 -1
- data/spec/define_roda_method_spec.rb +3 -0
- data/spec/freeze_spec.rb +10 -1
- data/spec/integration_spec.rb +1 -1
- data/spec/plugin/assets_spec.rb +16 -0
- data/spec/plugin/delay_build_spec.rb +2 -3
- data/spec/plugin/hash_routes_spec.rb +535 -0
- data/spec/plugin/match_hook_spec.rb +79 -0
- data/spec/plugin/middleware_spec.rb +1 -0
- data/spec/plugin/sessions_spec.rb +363 -320
- metadata +8 -2
@@ -0,0 +1,69 @@
|
|
1
|
+
# frozen-string-literal: true
|
2
|
+
|
3
|
+
#
|
4
|
+
class Roda
|
5
|
+
module RodaPlugins
|
6
|
+
# The match_hook plugin adds hooks that are called upon a successful match
|
7
|
+
# by any of the matchers.
|
8
|
+
#
|
9
|
+
# plugin :match_hook
|
10
|
+
#
|
11
|
+
# match_hook do
|
12
|
+
# logger.debug("#{request.matched_path} matched. #{request.remaining_path} remaining.")
|
13
|
+
# end
|
14
|
+
module MatchHook
|
15
|
+
def self.configure(app)
|
16
|
+
app.opts[:match_hooks] ||= []
|
17
|
+
end
|
18
|
+
|
19
|
+
module ClassMethods
|
20
|
+
# Freeze the array of hook methods when freezing the app
|
21
|
+
def freeze
|
22
|
+
opts[:match_hooks].freeze
|
23
|
+
super
|
24
|
+
end
|
25
|
+
|
26
|
+
# Add a match hook.
|
27
|
+
def match_hook(&block)
|
28
|
+
opts[:match_hooks] << define_roda_method("match_hook", 0, &block)
|
29
|
+
|
30
|
+
if opts[:match_hooks].length == 1
|
31
|
+
class_eval("alias _match_hook #{opts[:match_hooks].first}", __FILE__, __LINE__)
|
32
|
+
else
|
33
|
+
class_eval("def _match_hook; #{opts[:match_hooks].join(';')} end", __FILE__, __LINE__)
|
34
|
+
end
|
35
|
+
|
36
|
+
public :_match_hook
|
37
|
+
|
38
|
+
nil
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
module InstanceMethods
|
43
|
+
# Default empty method if no match hooks are defined.
|
44
|
+
def _match_hook
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
module RequestMethods
|
49
|
+
private
|
50
|
+
|
51
|
+
# Call the match hook if yielding to the block before yielding to the block.
|
52
|
+
def if_match(_)
|
53
|
+
super do |*a|
|
54
|
+
scope._match_hook
|
55
|
+
yield(*a)
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
# Call the match hook before yielding to the block
|
60
|
+
def always
|
61
|
+
scope._match_hook
|
62
|
+
super
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
register_plugin :match_hook, MatchHook
|
68
|
+
end
|
69
|
+
end
|
@@ -13,6 +13,10 @@ class Roda
|
|
13
13
|
# if the first segment in the path matches a named route, and dispatch
|
14
14
|
# to that named route.
|
15
15
|
#
|
16
|
+
# The hash_routes plugin offers a +r.hash_routes+ method that is similar to
|
17
|
+
# and performs better than the +r.multi_route+ method, and it is recommended
|
18
|
+
# to consider using that instead of this plugin.
|
19
|
+
#
|
16
20
|
# Example:
|
17
21
|
#
|
18
22
|
# plugin :multi_route
|
@@ -16,6 +16,10 @@ class Roda
|
|
16
16
|
# +multi_view_compile+ class method that will take an array of view template
|
17
17
|
# names and construct a regexp that can be passed to +r.multi_view+.
|
18
18
|
#
|
19
|
+
# The hash_routes plugin offers a views method that is similar to and performs
|
20
|
+
# better than the +r.multi_view+ method, and it is recommended to consider
|
21
|
+
# using that instead of this plugin.
|
22
|
+
#
|
19
23
|
# Example:
|
20
24
|
#
|
21
25
|
# plugin :multi_view
|
@@ -20,8 +20,9 @@ class Roda
|
|
20
20
|
# The sessions plugin adds support for sessions using cookies. It is the recommended
|
21
21
|
# way to support sessions in Roda applications.
|
22
22
|
#
|
23
|
-
# The session cookies are encrypted with AES-256-CTR
|
24
|
-
# By default, session data is padded to reduce information
|
23
|
+
# The session cookies are encrypted with AES-256-CTR using a separate encryption key per cookie,
|
24
|
+
# and then signed with HMAC-SHA-256. By default, session data is padded to reduce information
|
25
|
+
# leaked based on the session size.
|
25
26
|
#
|
26
27
|
# Sessions are serialized via JSON, so session information should only store data that
|
27
28
|
# allows roundtrips via JSON (String, Integer, Float, Array, Hash, true, false, and nil).
|
@@ -77,6 +78,12 @@ class Roda
|
|
77
78
|
# bytes if given.
|
78
79
|
# :pad_size :: Pad session data (after possible compression, before encryption), to a multiple of this
|
79
80
|
# many bytes (default: 32). This can be between 2-4096 bytes, or +nil+ to disable padding.
|
81
|
+
# :per_cookie_cipher_secret :: Uses a separate cipher key for every cookie, with the key used generated using
|
82
|
+
# HMAC-SHA-256 of 32 bytes of random data with the default cipher secret. This
|
83
|
+
# offers additional protection in case the random initialization vector used when
|
84
|
+
# encrypting the session data has been reused. Odds of that are 1 in 2**64 if
|
85
|
+
# initialization vector is truly random, but weaknesses in the random number
|
86
|
+
# generator could make the odds much higher. Default is +true+.
|
80
87
|
# :parser :: The parser for the serialized session data (default: <tt>JSON.method(:parse)</tt>).
|
81
88
|
# :serializer :: The serializer for the session data (default +:to_json.to_proc+).
|
82
89
|
# :skip_within :: If the last update time for the session cookie is less than this number of seconds from the
|
@@ -105,13 +112,18 @@ class Roda
|
|
105
112
|
#
|
106
113
|
# = Session Cookie Cryptography/Format
|
107
114
|
#
|
108
|
-
# Session cookies created by this plugin use the following format:
|
115
|
+
# Session cookies created by this plugin by default use the following format:
|
109
116
|
#
|
110
|
-
# urlsafe_base64(
|
117
|
+
# urlsafe_base64("\1" + random_data + IV + encrypted session data + HMAC)
|
118
|
+
#
|
119
|
+
# If +:per_cookie_cipher_secret+ option is set to +false+, an older format is used:
|
120
|
+
#
|
121
|
+
# urlsafe_base64("\0" + IV + encrypted session data + HMAC)
|
111
122
|
#
|
112
123
|
# where:
|
113
124
|
#
|
114
|
-
# version :: 1 byte, currently must be 0, other values reserved for future expansion.
|
125
|
+
# version :: 1 byte, currently must be 1 or 0, other values reserved for future expansion.
|
126
|
+
# random_data :: 32 bytes, used for generating the per-cookie secret
|
115
127
|
# IV :: 16 bytes, initialization vector for AES-256-CTR cipher.
|
116
128
|
# encrypted session data :: >=12 bytes of data encrypted with AES-256-CTR cipher, see below.
|
117
129
|
# HMAC :: 32 bytes, HMAC-SHA-256 of all preceding data plus cookie key (so that a cookie value
|
@@ -141,6 +153,7 @@ class Roda
|
|
141
153
|
SESSION_CREATED_AT = 'roda.session.created_at'.freeze
|
142
154
|
SESSION_UPDATED_AT = 'roda.session.updated_at'.freeze
|
143
155
|
SESSION_SERIALIZED = 'roda.session.serialized'.freeze
|
156
|
+
SESSION_VERSION_NUM = 'roda.session.version'.freeze
|
144
157
|
SESSION_DELETE_RACK_COOKIE = 'roda.session.delete_rack_session_cookie'.freeze
|
145
158
|
|
146
159
|
# Exception class used when creating a session cookie that would exceed the
|
@@ -166,6 +179,9 @@ class Roda
|
|
166
179
|
opts[:parser] ||= app.opts[:json_parser] || JSON.method(:parse)
|
167
180
|
opts[:serializer] ||= app.opts[:json_serializer] || :to_json.to_proc
|
168
181
|
|
182
|
+
opts[:per_cookie_cipher_secret] = true unless opts.has_key?(:per_cookie_cipher_secret)
|
183
|
+
opts[:session_version_num] = opts[:per_cookie_cipher_secret] ? 1 : 0
|
184
|
+
|
169
185
|
if opts[:upgrade_from_rack_session_cookie_secret]
|
170
186
|
opts[:upgrade_from_rack_session_cookie_key] ||= 'rack.session'
|
171
187
|
rsco = opts[:upgrade_from_rack_session_cookie_options] = Hash[opts[:upgrade_from_rack_session_cookie_options] || OPTS]
|
@@ -317,10 +333,13 @@ class Roda
|
|
317
333
|
rescue ArgumentError
|
318
334
|
return _session_serialization_error("Unable to decode session: invalid base64")
|
319
335
|
end
|
320
|
-
|
321
|
-
|
322
|
-
|
336
|
+
|
337
|
+
case version = data.getbyte(0)
|
338
|
+
when 1
|
339
|
+
per_cookie_secret = true
|
340
|
+
# minimum length (1+32+16+12+32) (version+random_data+cipher_iv+minimum session+hmac)
|
323
341
|
# 1 : version
|
342
|
+
# 32 : random_data (if per_cookie_cipher_secret)
|
324
343
|
# 16 : cipher_iv
|
325
344
|
# 12 : minimum_session
|
326
345
|
# 2 : bitmap for gzip + padding info
|
@@ -328,12 +347,20 @@ class Roda
|
|
328
347
|
# 4 : update time
|
329
348
|
# 2 : data
|
330
349
|
# 32 : HMAC-SHA-256
|
331
|
-
|
350
|
+
min_data_length = 93
|
351
|
+
when 0
|
352
|
+
per_cookie_secret = false
|
353
|
+
# minimum length (1+16+12+32) (version+cipher_iv+minimum session+hmac)
|
354
|
+
min_data_length = 61
|
355
|
+
when nil
|
356
|
+
return _session_serialization_error("Unable to decode session: no data")
|
357
|
+
else
|
358
|
+
return _session_serialization_error("Unable to decode session: version marker unsupported")
|
332
359
|
end
|
333
360
|
|
334
|
-
|
335
|
-
|
336
|
-
return _session_serialization_error("Unable to decode session:
|
361
|
+
length = data.bytesize
|
362
|
+
if data.length < min_data_length
|
363
|
+
return _session_serialization_error("Unable to decode session: data too short")
|
337
364
|
end
|
338
365
|
|
339
366
|
encrypted_data = data.slice!(0, length-32)
|
@@ -345,7 +372,15 @@ class Roda
|
|
345
372
|
end
|
346
373
|
end
|
347
374
|
|
375
|
+
# Remove version
|
348
376
|
encrypted_data.slice!(0)
|
377
|
+
|
378
|
+
cipher_secret = opts[use_old_cipher_secret ? :old_cipher_secret : :cipher_secret]
|
379
|
+
if per_cookie_secret
|
380
|
+
cipher_secret = OpenSSL::HMAC.digest(OpenSSL::Digest::SHA256.new, cipher_secret, encrypted_data.slice!(0, 32))
|
381
|
+
end
|
382
|
+
cipher_iv = encrypted_data.slice!(0, 16)
|
383
|
+
|
349
384
|
cipher = OpenSSL::Cipher.new("aes-256-ctr")
|
350
385
|
|
351
386
|
# Not rescuing cipher errors. If there is an error in the decryption, that's
|
@@ -353,8 +388,8 @@ class Roda
|
|
353
388
|
# able to forge a valid HMAC, in which case the error should be raised to
|
354
389
|
# alert the application owner about the problem.
|
355
390
|
cipher.decrypt
|
356
|
-
cipher.key =
|
357
|
-
|
391
|
+
cipher.key = cipher_secret
|
392
|
+
cipher.iv = cipher_iv
|
358
393
|
data = cipher.update(encrypted_data) << cipher.final
|
359
394
|
|
360
395
|
bitmap, created_at, updated_at = data.unpack('vVV')
|
@@ -376,6 +411,7 @@ class Roda
|
|
376
411
|
env[SESSION_CREATED_AT] = created_at
|
377
412
|
env[SESSION_UPDATED_AT] = updated_at
|
378
413
|
env[SESSION_SERIALIZED] = data
|
414
|
+
env[SESSION_VERSION_NUM] = version
|
379
415
|
|
380
416
|
opts[:parser].call(data)
|
381
417
|
end
|
@@ -387,6 +423,7 @@ class Roda
|
|
387
423
|
json_data = opts[:serializer].call(session).force_encoding('BINARY')
|
388
424
|
|
389
425
|
if (serialized_session = env[SESSION_SERIALIZED]) &&
|
426
|
+
(opts[:session_version_num] == env[SESSION_VERSION_NUM]) &&
|
390
427
|
(updated_at = env[SESSION_UPDATED_AT]) &&
|
391
428
|
(now - updated_at < opts[:skip_within]) &&
|
392
429
|
(serialized_session == json_data)
|
@@ -418,14 +455,24 @@ class Roda
|
|
418
455
|
serialized_data << padding_data if padding_data
|
419
456
|
serialized_data << json_data
|
420
457
|
|
458
|
+
cipher_secret = opts[:cipher_secret]
|
459
|
+
if per_cookie_secret = opts[:per_cookie_cipher_secret]
|
460
|
+
version = "\1"
|
461
|
+
per_cookie_secret_base = SecureRandom.random_bytes(32)
|
462
|
+
cipher_secret = OpenSSL::HMAC.digest(OpenSSL::Digest::SHA256.new, cipher_secret, per_cookie_secret_base)
|
463
|
+
else
|
464
|
+
version = "\0"
|
465
|
+
end
|
466
|
+
|
421
467
|
cipher = OpenSSL::Cipher.new("aes-256-ctr")
|
422
468
|
cipher.encrypt
|
423
|
-
cipher.key =
|
469
|
+
cipher.key = cipher_secret
|
424
470
|
cipher_iv = cipher.random_iv
|
425
471
|
encrypted_data = cipher.update(serialized_data) << cipher.final
|
426
472
|
|
427
473
|
data = String.new
|
428
|
-
data <<
|
474
|
+
data << version
|
475
|
+
data << per_cookie_secret_base if per_cookie_secret_base
|
429
476
|
data << cipher_iv
|
430
477
|
data << encrypted_data
|
431
478
|
data << OpenSSL::HMAC.digest(OpenSSL::Digest::SHA256.new, opts[:hmac_secret], data+opts[:key])
|
@@ -36,7 +36,7 @@ class Roda
|
|
36
36
|
# leading slash in the path argument.
|
37
37
|
#
|
38
38
|
# Second, the static_* routing methods only take a single string argument for
|
39
|
-
# the path, they do not
|
39
|
+
# the path, they do not accept other options, and do not handle placeholders
|
40
40
|
# in strings. For any routes needing placeholders, you should use Roda's
|
41
41
|
# routing tree.
|
42
42
|
#
|
@@ -49,56 +49,26 @@ class Roda
|
|
49
49
|
# static_route block to have shared behavior for different request methods,
|
50
50
|
# while still handling the request methods differently.
|
51
51
|
module StaticRouting
|
52
|
-
def self.
|
53
|
-
app.
|
52
|
+
def self.load_dependencies(app)
|
53
|
+
app.plugin :hash_routes
|
54
54
|
end
|
55
55
|
|
56
56
|
module ClassMethods
|
57
|
-
# Freeze the static route metadata when freezing the app.
|
58
|
-
def freeze
|
59
|
-
opts[:static_routes].freeze.each_value(&:freeze)
|
60
|
-
super
|
61
|
-
end
|
62
|
-
|
63
|
-
# Duplicate static route metadata in subclass.
|
64
|
-
def inherited(subclass)
|
65
|
-
super
|
66
|
-
static_routes = subclass.opts[:static_routes]
|
67
|
-
opts[:static_routes].each do |k, v|
|
68
|
-
static_routes[k] = v.dup
|
69
|
-
end
|
70
|
-
end
|
71
|
-
|
72
57
|
# Add a static route for any request method. These are
|
73
58
|
# tried after the request method specific static routes (e.g.
|
74
59
|
# static_get), but allow you to use Roda's routing tree
|
75
60
|
# methods inside the route for handling shared behavior while
|
76
61
|
# still allowing request method specific handling.
|
77
62
|
def static_route(path, &block)
|
78
|
-
|
63
|
+
hash_path(:static_routing, path, &block)
|
79
64
|
end
|
80
65
|
|
81
|
-
# Return the static route for the given request method and path.
|
82
|
-
def static_route_for(method, path)
|
83
|
-
if h = opts[:static_routes][path]
|
84
|
-
h[method] || h[nil]
|
85
|
-
end
|
86
|
-
end
|
87
|
-
|
88
66
|
[:get, :post, :delete, :head, :options, :link, :patch, :put, :trace, :unlink].each do |meth|
|
89
67
|
request_method = meth.to_s.upcase
|
90
68
|
define_method("static_#{meth}") do |path, &block|
|
91
|
-
|
69
|
+
hash_path(request_method, path, &block)
|
92
70
|
end
|
93
71
|
end
|
94
|
-
|
95
|
-
private
|
96
|
-
|
97
|
-
# Add a static route for the given method.
|
98
|
-
def add_static_route(method, path, &block)
|
99
|
-
routes = opts[:static_routes][path] ||= {}
|
100
|
-
routes[method] = define_roda_method(routes[method] || "static_route_#{method}_#{path}", 1, &convert_route_block(block))
|
101
|
-
end
|
102
72
|
end
|
103
73
|
|
104
74
|
module InstanceMethods
|
@@ -108,11 +78,8 @@ class Roda
|
|
108
78
|
# instead having the routing tree handle the request.
|
109
79
|
def _roda_before_30__static_routing
|
110
80
|
r = @_request
|
111
|
-
|
112
|
-
|
113
|
-
r.send(:block_result, send(meth, r))
|
114
|
-
throw :halt, @_response.finish
|
115
|
-
end
|
81
|
+
r.hash_paths(r.request_method)
|
82
|
+
r.hash_paths(:static_routing)
|
116
83
|
end
|
117
84
|
end
|
118
85
|
end
|
data/lib/roda/version.rb
CHANGED
@@ -55,6 +55,9 @@ describe "Roda.define_roda_method" do
|
|
55
55
|
|
56
56
|
m1 = app.define_roda_method("x", 1){2}
|
57
57
|
@scope.send(m1, 3).must_equal 2
|
58
|
+
|
59
|
+
m1 = app.define_roda_method("x", 1){|x, y| [x, y]}
|
60
|
+
@scope.send(m1, 4).must_equal [4, nil]
|
58
61
|
end
|
59
62
|
|
60
63
|
it "should raise for unexpected expected_arity" do
|
data/spec/freeze_spec.rb
CHANGED
@@ -2,7 +2,16 @@ require_relative "spec_helper"
|
|
2
2
|
|
3
3
|
describe "Roda.freeze" do
|
4
4
|
before do
|
5
|
-
app{}.freeze
|
5
|
+
app{'a'}.freeze
|
6
|
+
end
|
7
|
+
|
8
|
+
it "should result in a working application" do
|
9
|
+
body.must_equal 'a'
|
10
|
+
end
|
11
|
+
|
12
|
+
it "should not break if called more than once" do
|
13
|
+
app.freeze
|
14
|
+
body.must_equal 'a'
|
6
15
|
end
|
7
16
|
|
8
17
|
it "should make opts not be modifiable after calling finalize!" do
|
data/spec/integration_spec.rb
CHANGED
@@ -195,7 +195,7 @@ describe "integration" do
|
|
195
195
|
end
|
196
196
|
|
197
197
|
it "should have app return the rack application to call" do
|
198
|
-
app(:bare){}.app.
|
198
|
+
app(:bare){}.app.must_be_kind_of(Proc)
|
199
199
|
app.route{|r|}
|
200
200
|
app.app.must_be_kind_of(Proc)
|
201
201
|
c = Class.new{def initialize(app) @app = app end; def call(env) @app.call(env) end}
|
data/spec/plugin/assets_spec.rb
CHANGED
@@ -196,6 +196,22 @@ if run_tests
|
|
196
196
|
js.must_include('console.log')
|
197
197
|
end
|
198
198
|
|
199
|
+
it 'should handle rendering assets, linking to them, and accepting requests for them when :timestamp_paths plugin option is used with string value' do
|
200
|
+
app.plugin :assets, :timestamp_paths=>'-'
|
201
|
+
html = body('/test')
|
202
|
+
html.scan(/<link/).length.must_equal 2
|
203
|
+
html =~ %r{href="(/assets/css/\d+-app\.scss)"}
|
204
|
+
css = body($1)
|
205
|
+
html =~ %r{href="(/assets/css/\d+-raw\.css)"}
|
206
|
+
css2 = body($1)
|
207
|
+
html.scan(/<script/).length.must_equal 1
|
208
|
+
html =~ %r{src="(/assets/js/\d+-head/app\.js)"}
|
209
|
+
js = body($1)
|
210
|
+
css.must_match(/color: red;/)
|
211
|
+
css2.must_match(/color: blue;/)
|
212
|
+
js.must_include('console.log')
|
213
|
+
end
|
214
|
+
|
199
215
|
it 'should handle early hints if the :early_hints option is used' do
|
200
216
|
app.plugin :assets, :early_hints=>true
|
201
217
|
eh = []
|
@@ -1,6 +1,6 @@
|
|
1
1
|
require_relative "../spec_helper"
|
2
2
|
|
3
|
-
describe "delay_build plugin" do
|
3
|
+
describe "delay_build plugin" do
|
4
4
|
it "does not build rack app until app is called" do
|
5
5
|
app(:delay_build){"a"}
|
6
6
|
app.instance_variable_get(:@app).must_be_nil
|
@@ -9,7 +9,7 @@ describe "delay_build plugin" do
|
|
9
9
|
refute_equal app.instance_variable_get(:@app), nil
|
10
10
|
end
|
11
11
|
|
12
|
-
it "
|
12
|
+
it "supports the build! method for backwards compatibility" do
|
13
13
|
app(:delay_build){"a"}
|
14
14
|
body.must_equal "a"
|
15
15
|
c = Class.new do
|
@@ -17,7 +17,6 @@ describe "delay_build plugin" do
|
|
17
17
|
def call(_) [200, {}, ["b"]] end
|
18
18
|
end
|
19
19
|
app.use c
|
20
|
-
body.must_equal "a"
|
21
20
|
app.build!
|
22
21
|
body.must_equal "b"
|
23
22
|
end
|