rack 3.0.11 → 3.1.0
Sign up to get free protection for your applications and to get access to all the features.
Potentially problematic release.
This version of rack might be problematic. Click here for more details.
- checksums.yaml +4 -4
- data/CHANGELOG.md +59 -4
- data/CONTRIBUTING.md +11 -9
- data/README.md +34 -15
- data/SPEC.rdoc +38 -13
- data/lib/rack/auth/basic.rb +1 -2
- data/lib/rack/bad_request.rb +8 -0
- data/lib/rack/builder.rb +23 -10
- data/lib/rack/cascade.rb +0 -3
- data/lib/rack/constants.rb +3 -1
- data/lib/rack/content_length.rb +0 -1
- data/lib/rack/headers.rb +86 -2
- data/lib/rack/lint.rb +116 -34
- data/lib/rack/logger.rb +2 -1
- data/lib/rack/mime.rb +6 -5
- data/lib/rack/mock_request.rb +19 -14
- data/lib/rack/mock_response.rb +12 -14
- data/lib/rack/multipart/parser.rb +123 -62
- data/lib/rack/multipart.rb +34 -1
- data/lib/rack/query_parser.rb +15 -68
- data/lib/rack/request.rb +28 -21
- data/lib/rack/response.rb +21 -23
- data/lib/rack/show_exceptions.rb +6 -2
- data/lib/rack/utils.rb +57 -96
- data/lib/rack/version.rb +1 -14
- data/lib/rack.rb +10 -16
- metadata +4 -10
- data/lib/rack/auth/digest/md5.rb +0 -1
- data/lib/rack/auth/digest/nonce.rb +0 -1
- data/lib/rack/auth/digest/params.rb +0 -1
- data/lib/rack/auth/digest/request.rb +0 -1
- data/lib/rack/auth/digest.rb +0 -256
- data/lib/rack/chunked.rb +0 -120
- data/lib/rack/file.rb +0 -9
data/lib/rack/lint.rb
CHANGED
@@ -1,6 +1,7 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require 'forwardable'
|
4
|
+
require 'uri'
|
4
5
|
|
5
6
|
require_relative 'constants'
|
6
7
|
require_relative 'utils'
|
@@ -10,6 +11,11 @@ module Rack
|
|
10
11
|
# responses according to the Rack spec.
|
11
12
|
|
12
13
|
class Lint
|
14
|
+
REQUEST_PATH_ORIGIN_FORM = /\A\/[^#]*\z/
|
15
|
+
REQUEST_PATH_ABSOLUTE_FORM = /\A#{URI::DEFAULT_PARSER.make_regexp}\z/
|
16
|
+
REQUEST_PATH_AUTHORITY_FORM = /\A(.*?)(:\d*)\z/
|
17
|
+
REQUEST_PATH_ASTERISK_FORM = '*'
|
18
|
+
|
13
19
|
def initialize(app)
|
14
20
|
@app = app
|
15
21
|
end
|
@@ -56,9 +62,6 @@ module Rack
|
|
56
62
|
raise LintError, "No env given" unless @env
|
57
63
|
check_environment(@env)
|
58
64
|
|
59
|
-
@env[RACK_INPUT] = InputWrapper.new(@env[RACK_INPUT])
|
60
|
-
@env[RACK_ERRORS] = ErrorWrapper.new(@env[RACK_ERRORS])
|
61
|
-
|
62
65
|
## and returns a non-frozen Array of exactly three values:
|
63
66
|
@response = @app.call(@env)
|
64
67
|
raise LintError, "response is not an Array, but #{@response.class}" unless @response.kind_of? Array
|
@@ -78,8 +81,9 @@ module Rack
|
|
78
81
|
end
|
79
82
|
|
80
83
|
## and the *body*.
|
81
|
-
|
82
|
-
|
84
|
+
check_content_type_header(@status, @headers)
|
85
|
+
check_content_length_header(@status, @headers)
|
86
|
+
check_rack_protocol_header(@status, @headers)
|
83
87
|
@head_request = @env[REQUEST_METHOD] == HEAD
|
84
88
|
|
85
89
|
@lint = (@env['rack.lint'] ||= []) << self
|
@@ -179,6 +183,16 @@ module Rack
|
|
179
183
|
## to +call+ that is used to perform a full
|
180
184
|
## hijack.
|
181
185
|
|
186
|
+
## <tt>rack.protocol</tt>:: An optional +Array+ of +String+, containing
|
187
|
+
## the protocols advertised by the client in
|
188
|
+
## the +upgrade+ header (HTTP/1) or the
|
189
|
+
## +:protocol+ pseudo-header (HTTP/2).
|
190
|
+
if protocols = @env['rack.protocol']
|
191
|
+
unless protocols.is_a?(Array) && protocols.all?{|protocol| protocol.is_a?(String)}
|
192
|
+
raise LintError, "rack.protocol must be an Array of Strings"
|
193
|
+
end
|
194
|
+
end
|
195
|
+
|
182
196
|
## Additional environment specifications have approved to
|
183
197
|
## standardized middleware APIs. None of these are required to
|
184
198
|
## be implemented by the server.
|
@@ -265,11 +279,9 @@ module Rack
|
|
265
279
|
## is reserved for use with the Rack core distribution and other
|
266
280
|
## accepted specifications and must not be used otherwise.
|
267
281
|
##
|
268
|
-
|
269
|
-
%w[REQUEST_METHOD SERVER_NAME QUERY_STRING SERVER_PROTOCOL
|
270
|
-
rack.input rack.errors].each { |header|
|
282
|
+
%w[REQUEST_METHOD SERVER_NAME QUERY_STRING SERVER_PROTOCOL rack.errors].each do |header|
|
271
283
|
raise LintError, "env missing required key #{header}" unless env.include? header
|
272
|
-
|
284
|
+
end
|
273
285
|
|
274
286
|
## The <tt>SERVER_PORT</tt> must be an Integer if set.
|
275
287
|
server_port = env["SERVER_PORT"]
|
@@ -293,11 +305,6 @@ module Rack
|
|
293
305
|
raise LintError, "env[SERVER_PROTOCOL] does not match HTTP/\\d(\\.\\d)?"
|
294
306
|
end
|
295
307
|
|
296
|
-
## If the <tt>HTTP_VERSION</tt> is present, it must equal the <tt>SERVER_PROTOCOL</tt>.
|
297
|
-
if env['HTTP_VERSION'] && env['HTTP_VERSION'] != server_protocol
|
298
|
-
raise LintError, "env[HTTP_VERSION] does not equal env[SERVER_PROTOCOL]"
|
299
|
-
end
|
300
|
-
|
301
308
|
## The environment must not contain the keys
|
302
309
|
## <tt>HTTP_CONTENT_TYPE</tt> or <tt>HTTP_CONTENT_LENGTH</tt>
|
303
310
|
## (use the versions without <tt>HTTP_</tt>).
|
@@ -328,12 +335,21 @@ module Rack
|
|
328
335
|
raise LintError, "rack.url_scheme unknown: #{env[RACK_URL_SCHEME].inspect}"
|
329
336
|
end
|
330
337
|
|
331
|
-
## * There
|
332
|
-
|
338
|
+
## * There may be a valid input stream in <tt>rack.input</tt>.
|
339
|
+
if rack_input = env[RACK_INPUT]
|
340
|
+
check_input_stream(rack_input)
|
341
|
+
@env[RACK_INPUT] = InputWrapper.new(rack_input)
|
342
|
+
end
|
343
|
+
|
333
344
|
## * There must be a valid error stream in <tt>rack.errors</tt>.
|
334
|
-
|
345
|
+
rack_errors = env[RACK_ERRORS]
|
346
|
+
check_error_stream(rack_errors)
|
347
|
+
@env[RACK_ERRORS] = ErrorWrapper.new(rack_errors)
|
348
|
+
|
335
349
|
## * There may be a valid hijack callback in <tt>rack.hijack</tt>
|
336
350
|
check_hijack env
|
351
|
+
## * There may be a valid early hints callback in <tt>rack.early_hints</tt>
|
352
|
+
check_early_hints env
|
337
353
|
|
338
354
|
## * The <tt>REQUEST_METHOD</tt> must be a valid token.
|
339
355
|
unless env[REQUEST_METHOD] =~ /\A[0-9A-Za-z!\#$%&'*+.^_`|~-]+\z/
|
@@ -344,10 +360,32 @@ module Rack
|
|
344
360
|
if env.include?(SCRIPT_NAME) && env[SCRIPT_NAME] != "" && env[SCRIPT_NAME] !~ /\A\//
|
345
361
|
raise LintError, "SCRIPT_NAME must start with /"
|
346
362
|
end
|
347
|
-
|
348
|
-
|
349
|
-
|
363
|
+
|
364
|
+
## * The <tt>PATH_INFO</tt>, if provided, must be a valid request target.
|
365
|
+
if env.include?(PATH_INFO)
|
366
|
+
case env[PATH_INFO]
|
367
|
+
when REQUEST_PATH_ASTERISK_FORM
|
368
|
+
## * Only <tt>OPTIONS</tt> requests may have <tt>PATH_INFO</tt> set to <tt>*</tt> (asterisk-form).
|
369
|
+
unless env[REQUEST_METHOD] == OPTIONS
|
370
|
+
raise LintError, "Only OPTIONS requests may have PATH_INFO set to '*' (asterisk-form)"
|
371
|
+
end
|
372
|
+
when REQUEST_PATH_AUTHORITY_FORM
|
373
|
+
## * Only <tt>CONNECT</tt> requests may have <tt>PATH_INFO</tt> set to an authority (authority-form). Note that in HTTP/2+, the authority-form is not a valid request target.
|
374
|
+
unless env[REQUEST_METHOD] == CONNECT
|
375
|
+
raise LintError, "Only CONNECT requests may have PATH_INFO set to an authority (authority-form)"
|
376
|
+
end
|
377
|
+
when REQUEST_PATH_ABSOLUTE_FORM
|
378
|
+
## * <tt>CONNECT</tt> and <tt>OPTIONS</tt> requests must not have <tt>PATH_INFO</tt> set to a URI (absolute-form).
|
379
|
+
if env[REQUEST_METHOD] == CONNECT || env[REQUEST_METHOD] == OPTIONS
|
380
|
+
raise LintError, "CONNECT and OPTIONS requests must not have PATH_INFO set to a URI (absolute-form)"
|
381
|
+
end
|
382
|
+
when REQUEST_PATH_ORIGIN_FORM
|
383
|
+
## * Otherwise, <tt>PATH_INFO</tt> must start with a <tt>/</tt> and must not include a fragment part starting with '#' (origin-form).
|
384
|
+
else
|
385
|
+
raise LintError, "PATH_INFO must start with a '/' and must not include a fragment part starting with '#' (origin-form)"
|
386
|
+
end
|
350
387
|
end
|
388
|
+
|
351
389
|
## * The <tt>CONTENT_LENGTH</tt>, if given, must consist of digits only.
|
352
390
|
if env.include?("CONTENT_LENGTH") && env["CONTENT_LENGTH"] !~ /\A\d+\z/
|
353
391
|
raise LintError, "Invalid CONTENT_LENGTH: #{env["CONTENT_LENGTH"]}"
|
@@ -384,9 +422,9 @@ module Rack
|
|
384
422
|
##
|
385
423
|
## The input stream is an IO-like object which contains the raw HTTP
|
386
424
|
## POST data.
|
387
|
-
def
|
425
|
+
def check_input_stream(input)
|
388
426
|
## When applicable, its external encoding must be "ASCII-8BIT" and it
|
389
|
-
## must be opened in binary mode
|
427
|
+
## must be opened in binary mode.
|
390
428
|
if input.respond_to?(:external_encoding) && input.external_encoding != Encoding::ASCII_8BIT
|
391
429
|
raise LintError, "rack.input #{input} does not have ASCII-8BIT as its external encoding"
|
392
430
|
end
|
@@ -418,7 +456,7 @@ module Rack
|
|
418
456
|
v
|
419
457
|
end
|
420
458
|
|
421
|
-
## * +read+ behaves like IO#read
|
459
|
+
## * +read+ behaves like <tt>IO#read</tt>.
|
422
460
|
## Its signature is <tt>read([length, [buffer]])</tt>.
|
423
461
|
##
|
424
462
|
## If given, +length+ must be a non-negative Integer (>= 0) or +nil+,
|
@@ -478,8 +516,8 @@ module Rack
|
|
478
516
|
}
|
479
517
|
end
|
480
518
|
|
481
|
-
## * +close+ can be called on the input stream to indicate that
|
482
|
-
##
|
519
|
+
## * +close+ can be called on the input stream to indicate that
|
520
|
+
## any remaining input is not needed.
|
483
521
|
def close(*args)
|
484
522
|
@input.close(*args)
|
485
523
|
end
|
@@ -488,7 +526,7 @@ module Rack
|
|
488
526
|
##
|
489
527
|
## === The Error Stream
|
490
528
|
##
|
491
|
-
def
|
529
|
+
def check_error_stream(error)
|
492
530
|
## The error stream must respond to +puts+, +write+ and +flush+.
|
493
531
|
[:puts, :write, :flush].each { |method|
|
494
532
|
unless error.respond_to? method
|
@@ -609,6 +647,30 @@ module Rack
|
|
609
647
|
nil
|
610
648
|
end
|
611
649
|
|
650
|
+
##
|
651
|
+
## === Early Hints
|
652
|
+
##
|
653
|
+
## The application or any middleware may call the <tt>rack.early_hints</tt>
|
654
|
+
## with an object which would be valid as the headers of a Rack response.
|
655
|
+
def check_early_hints(env)
|
656
|
+
if env[RACK_EARLY_HINTS]
|
657
|
+
##
|
658
|
+
## If <tt>rack.early_hints</tt> is present, it must respond to #call.
|
659
|
+
unless env[RACK_EARLY_HINTS].respond_to?(:call)
|
660
|
+
raise LintError, "rack.early_hints must respond to call"
|
661
|
+
end
|
662
|
+
|
663
|
+
original_callback = env[RACK_EARLY_HINTS]
|
664
|
+
env[RACK_EARLY_HINTS] = lambda do |headers|
|
665
|
+
## If <tt>rack.early_hints</tt> is called, it must be called with
|
666
|
+
## valid Rack response headers.
|
667
|
+
check_headers(headers)
|
668
|
+
original_callback.call(headers)
|
669
|
+
end
|
670
|
+
end
|
671
|
+
end
|
672
|
+
|
673
|
+
##
|
612
674
|
## == The Response
|
613
675
|
##
|
614
676
|
## === The Status
|
@@ -672,9 +734,9 @@ module Rack
|
|
672
734
|
end
|
673
735
|
|
674
736
|
##
|
675
|
-
##
|
737
|
+
## ==== The +content-type+ Header
|
676
738
|
##
|
677
|
-
def
|
739
|
+
def check_content_type_header(status, headers)
|
678
740
|
headers.each { |key, value|
|
679
741
|
## There must not be a <tt>content-type</tt> header key when the +Status+ is 1xx,
|
680
742
|
## 204, or 304.
|
@@ -688,9 +750,9 @@ module Rack
|
|
688
750
|
end
|
689
751
|
|
690
752
|
##
|
691
|
-
##
|
753
|
+
## ==== The +content-length+ Header
|
692
754
|
##
|
693
|
-
def
|
755
|
+
def check_content_length_header(status, headers)
|
694
756
|
headers.each { |key, value|
|
695
757
|
if key == 'content-length'
|
696
758
|
## There must not be a <tt>content-length</tt> header key when the
|
@@ -715,6 +777,29 @@ module Rack
|
|
715
777
|
end
|
716
778
|
end
|
717
779
|
|
780
|
+
##
|
781
|
+
## ==== The +rack.protocol+ Header
|
782
|
+
##
|
783
|
+
def check_rack_protocol_header(status, headers)
|
784
|
+
## If the +rack.protocol+ header is present, it must be a +String+, and
|
785
|
+
## must be one of the values from the +rack.protocol+ array from the
|
786
|
+
## environment.
|
787
|
+
protocol = headers['rack.protocol']
|
788
|
+
|
789
|
+
if protocol
|
790
|
+
request_protocols = @env['rack.protocol']
|
791
|
+
|
792
|
+
if request_protocols.nil?
|
793
|
+
raise LintError, "rack.protocol header is #{protocol.inspect}, but rack.protocol was not set in request!"
|
794
|
+
elsif !request_protocols.include?(protocol)
|
795
|
+
raise LintError, "rack.protocol header is #{protocol.inspect}, but should be one of #{request_protocols.inspect} from the request!"
|
796
|
+
end
|
797
|
+
end
|
798
|
+
end
|
799
|
+
##
|
800
|
+
## Setting this value informs the server that it should perform a
|
801
|
+
## connection upgrade. In HTTP/1, this is done using the +upgrade+
|
802
|
+
## header. In HTTP/2, this is done by accepting the request.
|
718
803
|
##
|
719
804
|
## === The Body
|
720
805
|
##
|
@@ -782,7 +867,7 @@ module Rack
|
|
782
867
|
## It must only be called once.
|
783
868
|
raise LintError, "Response body must only be invoked once (#{@invoked})" unless @invoked.nil?
|
784
869
|
|
785
|
-
## It must not be called after being closed
|
870
|
+
## It must not be called after being closed,
|
786
871
|
raise LintError, "Response body is already closed" if @closed
|
787
872
|
|
788
873
|
@invoked = :each
|
@@ -793,9 +878,6 @@ module Rack
|
|
793
878
|
raise LintError, "Body yielded non-string value #{chunk.inspect}"
|
794
879
|
end
|
795
880
|
|
796
|
-
##
|
797
|
-
## The Body itself should not be an instance of String, as this will
|
798
|
-
## break in Ruby 1.9.
|
799
881
|
##
|
800
882
|
## Middleware must not call +each+ directly on the Body.
|
801
883
|
## Instead, middleware can return a new Body that calls +each+ on the
|
data/lib/rack/logger.rb
CHANGED
data/lib/rack/mime.rb
CHANGED
@@ -290,7 +290,7 @@ module Rack
|
|
290
290
|
".jpg" => "image/jpeg",
|
291
291
|
".jpgv" => "video/jpeg",
|
292
292
|
".jpm" => "video/jpm",
|
293
|
-
".js" => "
|
293
|
+
".js" => "text/javascript",
|
294
294
|
".json" => "application/json",
|
295
295
|
".karbon" => "application/vnd.kde.karbon",
|
296
296
|
".kfo" => "application/vnd.kde.kformula",
|
@@ -338,6 +338,7 @@ module Rack
|
|
338
338
|
".mif" => "application/vnd.mif",
|
339
339
|
".mime" => "message/rfc822",
|
340
340
|
".mj2" => "video/mj2",
|
341
|
+
".mjs" => "text/javascript",
|
341
342
|
".mlp" => "application/vnd.dolby.mlp",
|
342
343
|
".mmd" => "application/vnd.chipnuts.karaoke-mmd",
|
343
344
|
".mmf" => "application/vnd.smaf",
|
@@ -409,7 +410,7 @@ module Rack
|
|
409
410
|
".ogx" => "application/ogg",
|
410
411
|
".org" => "application/vnd.lotus-organizer",
|
411
412
|
".otc" => "application/vnd.oasis.opendocument.chart-template",
|
412
|
-
".otf" => "
|
413
|
+
".otf" => "font/otf",
|
413
414
|
".otg" => "application/vnd.oasis.opendocument.graphics-template",
|
414
415
|
".oth" => "application/vnd.oasis.opendocument.text-web",
|
415
416
|
".oti" => "application/vnd.oasis.opendocument.image-template",
|
@@ -590,7 +591,7 @@ module Rack
|
|
590
591
|
".trm" => "application/x-msterminal",
|
591
592
|
".ts" => "video/mp2t",
|
592
593
|
".tsv" => "text/tab-separated-values",
|
593
|
-
".ttf" => "
|
594
|
+
".ttf" => "font/ttf",
|
594
595
|
".twd" => "application/vnd.simtech-mindmapper",
|
595
596
|
".txd" => "application/vnd.genomatix.tuxedo",
|
596
597
|
".txf" => "application/vnd.mobius.txf",
|
@@ -636,8 +637,8 @@ module Rack
|
|
636
637
|
".wmv" => "video/x-ms-wmv",
|
637
638
|
".wmx" => "video/x-ms-wmx",
|
638
639
|
".wmz" => "application/x-ms-wmz",
|
639
|
-
".woff" => "
|
640
|
-
".woff2" => "
|
640
|
+
".woff" => "font/woff",
|
641
|
+
".woff2" => "font/woff2",
|
641
642
|
".wpd" => "application/vnd.wordperfect",
|
642
643
|
".wpl" => "application/vnd.ms-wpl",
|
643
644
|
".wps" => "application/vnd.ms-works",
|
data/lib/rack/mock_request.rb
CHANGED
@@ -41,11 +41,6 @@ module Rack
|
|
41
41
|
end
|
42
42
|
end
|
43
43
|
|
44
|
-
DEFAULT_ENV = {
|
45
|
-
RACK_INPUT => StringIO.new,
|
46
|
-
RACK_ERRORS => StringIO.new,
|
47
|
-
}.freeze
|
48
|
-
|
49
44
|
def initialize(app)
|
50
45
|
@app = app
|
51
46
|
end
|
@@ -104,7 +99,7 @@ module Rack
|
|
104
99
|
uri = parse_uri_rfc2396(uri)
|
105
100
|
uri.path = "/#{uri.path}" unless uri.path[0] == ?/
|
106
101
|
|
107
|
-
env =
|
102
|
+
env = {}
|
108
103
|
|
109
104
|
env[REQUEST_METHOD] = (opts[:method] ? opts[:method].to_s.upcase : GET).b
|
110
105
|
env[SERVER_NAME] = (uri.host || "example.org").b
|
@@ -144,20 +139,30 @@ module Rack
|
|
144
139
|
end
|
145
140
|
end
|
146
141
|
|
147
|
-
opts[:input]
|
148
|
-
if String ===
|
149
|
-
rack_input = StringIO.new(
|
142
|
+
input = opts[:input]
|
143
|
+
if String === input
|
144
|
+
rack_input = StringIO.new(input)
|
145
|
+
rack_input.set_encoding(Encoding::BINARY)
|
150
146
|
else
|
151
|
-
|
147
|
+
if input.respond_to?(:encoding) && input.encoding != Encoding::BINARY
|
148
|
+
warn "input encoding not binary", uplevel: 1
|
149
|
+
if input.respond_to?(:set_encoding)
|
150
|
+
input.set_encoding(Encoding::BINARY)
|
151
|
+
else
|
152
|
+
raise ArgumentError, "could not coerce input to binary encoding"
|
153
|
+
end
|
154
|
+
end
|
155
|
+
rack_input = input
|
152
156
|
end
|
153
157
|
|
154
|
-
rack_input
|
155
|
-
|
158
|
+
if rack_input
|
159
|
+
env[RACK_INPUT] = rack_input
|
156
160
|
|
157
|
-
|
161
|
+
env["CONTENT_LENGTH"] ||= env[RACK_INPUT].size.to_s if env[RACK_INPUT].respond_to?(:size)
|
162
|
+
end
|
158
163
|
|
159
164
|
opts.each { |field, value|
|
160
|
-
env[field] = value
|
165
|
+
env[field] = value if String === field
|
161
166
|
}
|
162
167
|
|
163
168
|
env
|
data/lib/rack/mock_response.rb
CHANGED
@@ -80,20 +80,18 @@ module Rack
|
|
80
80
|
cookies = Hash.new
|
81
81
|
if headers.has_key? 'set-cookie'
|
82
82
|
set_cookie_header = headers.fetch('set-cookie')
|
83
|
-
Array(set_cookie_header).each do |
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
cookies.store(cookie_name, parsed_cookie)
|
96
|
-
end
|
83
|
+
Array(set_cookie_header).each do |cookie|
|
84
|
+
cookie_name, cookie_filling = cookie.split('=', 2)
|
85
|
+
cookie_attributes = identify_cookie_attributes cookie_filling
|
86
|
+
parsed_cookie = CGI::Cookie.new(
|
87
|
+
'name' => cookie_name.strip,
|
88
|
+
'value' => cookie_attributes.fetch('value'),
|
89
|
+
'path' => cookie_attributes.fetch('path', nil),
|
90
|
+
'domain' => cookie_attributes.fetch('domain', nil),
|
91
|
+
'expires' => cookie_attributes.fetch('expires', nil),
|
92
|
+
'secure' => cookie_attributes.fetch('secure', false)
|
93
|
+
)
|
94
|
+
cookies.store(cookie_name, parsed_cookie)
|
97
95
|
end
|
98
96
|
end
|
99
97
|
cookies
|