actionpack 6.0.3 → 6.1.0.rc1
Sign up to get free protection for your applications and to get access to all the features.
Potentially problematic release.
This version of actionpack might be problematic. Click here for more details.
- checksums.yaml +4 -4
- data/CHANGELOG.md +246 -217
- data/MIT-LICENSE +1 -1
- data/lib/abstract_controller.rb +1 -0
- data/lib/abstract_controller/base.rb +35 -2
- data/lib/abstract_controller/callbacks.rb +2 -2
- data/lib/abstract_controller/helpers.rb +105 -90
- data/lib/abstract_controller/rendering.rb +9 -9
- data/lib/abstract_controller/translation.rb +8 -2
- data/lib/action_controller.rb +2 -3
- data/lib/action_controller/api.rb +2 -2
- data/lib/action_controller/base.rb +4 -2
- data/lib/action_controller/caching.rb +0 -1
- data/lib/action_controller/log_subscriber.rb +3 -3
- data/lib/action_controller/metal.rb +2 -2
- data/lib/action_controller/metal/conditional_get.rb +10 -2
- data/lib/action_controller/metal/content_security_policy.rb +1 -1
- data/lib/action_controller/metal/data_streaming.rb +1 -1
- data/lib/action_controller/metal/etag_with_template_digest.rb +2 -4
- data/lib/action_controller/metal/exceptions.rb +33 -0
- data/lib/action_controller/metal/feature_policy.rb +46 -0
- data/lib/action_controller/metal/head.rb +7 -4
- data/lib/action_controller/metal/helpers.rb +11 -1
- data/lib/action_controller/metal/http_authentication.rb +4 -2
- data/lib/action_controller/metal/implicit_render.rb +1 -1
- data/lib/action_controller/metal/instrumentation.rb +11 -9
- data/lib/action_controller/metal/live.rb +1 -1
- data/lib/action_controller/metal/logging.rb +20 -0
- data/lib/action_controller/metal/mime_responds.rb +6 -2
- data/lib/action_controller/metal/parameter_encoding.rb +35 -4
- data/lib/action_controller/metal/params_wrapper.rb +14 -8
- data/lib/action_controller/metal/redirecting.rb +1 -1
- data/lib/action_controller/metal/rendering.rb +6 -0
- data/lib/action_controller/metal/request_forgery_protection.rb +74 -30
- data/lib/action_controller/metal/rescue.rb +1 -1
- data/lib/action_controller/metal/strong_parameters.rb +107 -15
- data/lib/action_controller/renderer.rb +24 -13
- data/lib/action_controller/test_case.rb +62 -56
- data/lib/action_dispatch.rb +3 -2
- data/lib/action_dispatch/http/cache.rb +12 -10
- data/lib/action_dispatch/http/content_disposition.rb +2 -2
- data/lib/action_dispatch/http/content_security_policy.rb +5 -1
- data/lib/action_dispatch/http/feature_policy.rb +168 -0
- data/lib/action_dispatch/http/filter_parameters.rb +1 -1
- data/lib/action_dispatch/http/filter_redirect.rb +1 -1
- data/lib/action_dispatch/http/headers.rb +3 -2
- data/lib/action_dispatch/http/mime_negotiation.rb +20 -8
- data/lib/action_dispatch/http/mime_type.rb +28 -15
- data/lib/action_dispatch/http/parameters.rb +1 -19
- data/lib/action_dispatch/http/request.rb +26 -8
- data/lib/action_dispatch/http/response.rb +17 -16
- data/lib/action_dispatch/http/url.rb +3 -2
- data/lib/action_dispatch/journey.rb +0 -2
- data/lib/action_dispatch/journey/formatter.rb +53 -28
- data/lib/action_dispatch/journey/gtg/builder.rb +22 -36
- data/lib/action_dispatch/journey/gtg/simulator.rb +8 -7
- data/lib/action_dispatch/journey/gtg/transition_table.rb +6 -4
- data/lib/action_dispatch/journey/nfa/dot.rb +0 -11
- data/lib/action_dispatch/journey/nodes/node.rb +4 -3
- data/lib/action_dispatch/journey/parser.rb +13 -13
- data/lib/action_dispatch/journey/parser.y +1 -1
- data/lib/action_dispatch/journey/path/pattern.rb +13 -18
- data/lib/action_dispatch/journey/route.rb +7 -18
- data/lib/action_dispatch/journey/router.rb +26 -30
- data/lib/action_dispatch/journey/router/utils.rb +6 -4
- data/lib/action_dispatch/middleware/actionable_exceptions.rb +9 -2
- data/lib/action_dispatch/middleware/cookies.rb +74 -33
- data/lib/action_dispatch/middleware/debug_exceptions.rb +10 -17
- data/lib/action_dispatch/middleware/debug_view.rb +1 -1
- data/lib/action_dispatch/middleware/exception_wrapper.rb +29 -17
- data/lib/action_dispatch/middleware/host_authorization.rb +23 -3
- data/lib/action_dispatch/middleware/public_exceptions.rb +1 -1
- data/lib/action_dispatch/middleware/remote_ip.rb +5 -4
- data/lib/action_dispatch/middleware/request_id.rb +4 -5
- data/lib/action_dispatch/middleware/session/abstract_store.rb +2 -2
- data/lib/action_dispatch/middleware/session/cookie_store.rb +2 -2
- data/lib/action_dispatch/middleware/ssl.rb +9 -6
- data/lib/action_dispatch/middleware/stack.rb +18 -0
- data/lib/action_dispatch/middleware/static.rb +154 -93
- data/lib/action_dispatch/middleware/templates/rescues/_message_and_suggestions.html.erb +18 -0
- data/lib/action_dispatch/middleware/templates/rescues/diagnostics.html.erb +2 -5
- data/lib/action_dispatch/middleware/templates/rescues/invalid_statement.html.erb +2 -2
- data/lib/action_dispatch/middleware/templates/rescues/invalid_statement.text.erb +2 -2
- data/lib/action_dispatch/middleware/templates/rescues/layout.erb +88 -8
- data/lib/action_dispatch/middleware/templates/rescues/unknown_action.html.erb +1 -1
- data/lib/action_dispatch/middleware/templates/routes/_table.html.erb +12 -1
- data/lib/action_dispatch/railtie.rb +3 -2
- data/lib/action_dispatch/request/session.rb +2 -8
- data/lib/action_dispatch/request/utils.rb +26 -2
- data/lib/action_dispatch/routing/inspector.rb +8 -7
- data/lib/action_dispatch/routing/mapper.rb +102 -71
- data/lib/action_dispatch/routing/polymorphic_routes.rb +12 -11
- data/lib/action_dispatch/routing/redirection.rb +3 -3
- data/lib/action_dispatch/routing/route_set.rb +49 -41
- data/lib/action_dispatch/routing/url_for.rb +1 -0
- data/lib/action_dispatch/system_test_case.rb +29 -24
- data/lib/action_dispatch/system_testing/browser.rb +33 -27
- data/lib/action_dispatch/system_testing/driver.rb +6 -7
- data/lib/action_dispatch/system_testing/test_helpers/screenshot_helper.rb +47 -6
- data/lib/action_dispatch/system_testing/test_helpers/setup_and_teardown.rb +4 -7
- data/lib/action_dispatch/testing/assertions.rb +1 -1
- data/lib/action_dispatch/testing/assertions/response.rb +2 -4
- data/lib/action_dispatch/testing/assertions/routing.rb +5 -5
- data/lib/action_dispatch/testing/integration.rb +38 -27
- data/lib/action_dispatch/testing/test_process.rb +29 -4
- data/lib/action_dispatch/testing/test_request.rb +3 -3
- data/lib/action_pack.rb +1 -1
- data/lib/action_pack/gem_version.rb +3 -3
- metadata +20 -21
- data/lib/action_controller/metal/force_ssl.rb +0 -58
- data/lib/action_dispatch/http/parameter_filter.rb +0 -12
- data/lib/action_dispatch/journey/nfa/builder.rb +0 -78
- data/lib/action_dispatch/journey/nfa/simulator.rb +0 -47
- data/lib/action_dispatch/journey/nfa/transition_table.rb +0 -119
@@ -6,21 +6,21 @@ require "rack/utils"
|
|
6
6
|
module ActionDispatch
|
7
7
|
class ExceptionWrapper
|
8
8
|
cattr_accessor :rescue_responses, default: Hash.new(:internal_server_error).merge!(
|
9
|
-
"ActionController::RoutingError"
|
10
|
-
"AbstractController::ActionNotFound"
|
11
|
-
"ActionController::MethodNotAllowed"
|
12
|
-
"ActionController::UnknownHttpMethod"
|
13
|
-
"ActionController::NotImplemented"
|
14
|
-
"ActionController::UnknownFormat"
|
15
|
-
"
|
16
|
-
"ActionController::MissingExactTemplate"
|
17
|
-
"ActionController::InvalidAuthenticityToken"
|
18
|
-
"ActionController::InvalidCrossOriginRequest"
|
19
|
-
"ActionDispatch::Http::Parameters::ParseError"
|
20
|
-
"ActionController::BadRequest"
|
21
|
-
"ActionController::ParameterMissing"
|
22
|
-
"Rack::QueryParser::ParameterTypeError"
|
23
|
-
"Rack::QueryParser::InvalidParameterError"
|
9
|
+
"ActionController::RoutingError" => :not_found,
|
10
|
+
"AbstractController::ActionNotFound" => :not_found,
|
11
|
+
"ActionController::MethodNotAllowed" => :method_not_allowed,
|
12
|
+
"ActionController::UnknownHttpMethod" => :method_not_allowed,
|
13
|
+
"ActionController::NotImplemented" => :not_implemented,
|
14
|
+
"ActionController::UnknownFormat" => :not_acceptable,
|
15
|
+
"ActionDispatch::Http::MimeNegotiation::InvalidType" => :not_acceptable,
|
16
|
+
"ActionController::MissingExactTemplate" => :not_acceptable,
|
17
|
+
"ActionController::InvalidAuthenticityToken" => :unprocessable_entity,
|
18
|
+
"ActionController::InvalidCrossOriginRequest" => :unprocessable_entity,
|
19
|
+
"ActionDispatch::Http::Parameters::ParseError" => :bad_request,
|
20
|
+
"ActionController::BadRequest" => :bad_request,
|
21
|
+
"ActionController::ParameterMissing" => :bad_request,
|
22
|
+
"Rack::QueryParser::ParameterTypeError" => :bad_request,
|
23
|
+
"Rack::QueryParser::InvalidParameterError" => :bad_request
|
24
24
|
)
|
25
25
|
|
26
26
|
cattr_accessor :rescue_templates, default: Hash.new("diagnostics").merge!(
|
@@ -36,18 +36,24 @@ module ActionDispatch
|
|
36
36
|
"ActionView::Template::Error"
|
37
37
|
]
|
38
38
|
|
39
|
+
cattr_accessor :silent_exceptions, default: [
|
40
|
+
"ActionController::RoutingError",
|
41
|
+
"ActionDispatch::Http::MimeNegotiation::InvalidType"
|
42
|
+
]
|
43
|
+
|
39
44
|
attr_reader :backtrace_cleaner, :exception, :wrapped_causes, :line_number, :file
|
40
45
|
|
41
46
|
def initialize(backtrace_cleaner, exception)
|
42
47
|
@backtrace_cleaner = backtrace_cleaner
|
43
48
|
@exception = exception
|
49
|
+
@exception_class_name = @exception.class.name
|
44
50
|
@wrapped_causes = wrapped_causes_for(exception, backtrace_cleaner)
|
45
51
|
|
46
52
|
expand_backtrace if exception.is_a?(SyntaxError) || exception.cause.is_a?(SyntaxError)
|
47
53
|
end
|
48
54
|
|
49
55
|
def unwrapped_exception
|
50
|
-
if wrapper_exceptions.include?(
|
56
|
+
if wrapper_exceptions.include?(@exception_class_name)
|
51
57
|
exception.cause
|
52
58
|
else
|
53
59
|
exception
|
@@ -55,13 +61,19 @@ module ActionDispatch
|
|
55
61
|
end
|
56
62
|
|
57
63
|
def rescue_template
|
58
|
-
@@rescue_templates[@
|
64
|
+
@@rescue_templates[@exception_class_name]
|
59
65
|
end
|
60
66
|
|
61
67
|
def status_code
|
62
68
|
self.class.status_code_for_exception(unwrapped_exception.class.name)
|
63
69
|
end
|
64
70
|
|
71
|
+
def exception_trace
|
72
|
+
trace = application_trace
|
73
|
+
trace = framework_trace if trace.empty? && !silent_exceptions.include?(@exception_class_name)
|
74
|
+
trace
|
75
|
+
end
|
76
|
+
|
65
77
|
def application_trace
|
66
78
|
clean_backtrace(:silent)
|
67
79
|
end
|
@@ -4,7 +4,12 @@ require "action_dispatch/http/request"
|
|
4
4
|
|
5
5
|
module ActionDispatch
|
6
6
|
# This middleware guards from DNS rebinding attacks by explicitly permitting
|
7
|
-
# the hosts a request can be sent to
|
7
|
+
# the hosts a request can be sent to, and is passed the options set in
|
8
|
+
# +config.host_authorization+.
|
9
|
+
#
|
10
|
+
# Requests can opt-out of Host Authorization with +exclude+:
|
11
|
+
#
|
12
|
+
# config.host_authorization = { exclude: ->(request) { request.path =~ /healthcheck/ } }
|
8
13
|
#
|
9
14
|
# When a request comes to an unauthorized host, the +response_app+
|
10
15
|
# application will be executed and rendered. If no +response_app+ is given, a
|
@@ -66,9 +71,20 @@ module ActionDispatch
|
|
66
71
|
}, [body]]
|
67
72
|
end
|
68
73
|
|
69
|
-
def initialize(app, hosts,
|
74
|
+
def initialize(app, hosts, deprecated_response_app = nil, exclude: nil, response_app: nil)
|
70
75
|
@app = app
|
71
76
|
@permissions = Permissions.new(hosts)
|
77
|
+
@exclude = exclude
|
78
|
+
|
79
|
+
unless deprecated_response_app.nil?
|
80
|
+
ActiveSupport::Deprecation.warn(<<-MSG.squish)
|
81
|
+
`action_dispatch.hosts_response_app` is deprecated and will be ignored in Rails 6.2.
|
82
|
+
Use the Host Authorization `response_app` setting instead.
|
83
|
+
MSG
|
84
|
+
|
85
|
+
response_app ||= deprecated_response_app
|
86
|
+
end
|
87
|
+
|
72
88
|
@response_app = response_app || DEFAULT_RESPONSE_APP
|
73
89
|
end
|
74
90
|
|
@@ -77,7 +93,7 @@ module ActionDispatch
|
|
77
93
|
|
78
94
|
request = Request.new(env)
|
79
95
|
|
80
|
-
if authorized?(request)
|
96
|
+
if authorized?(request) || excluded?(request)
|
81
97
|
mark_as_authorized(request)
|
82
98
|
@app.call(env)
|
83
99
|
else
|
@@ -94,6 +110,10 @@ module ActionDispatch
|
|
94
110
|
(forwarded_host.blank? || @permissions.allows?(forwarded_host))
|
95
111
|
end
|
96
112
|
|
113
|
+
def excluded?(request)
|
114
|
+
@exclude && @exclude.call(request)
|
115
|
+
end
|
116
|
+
|
97
117
|
def mark_as_authorized(request)
|
98
118
|
request.set_header("action_dispatch.authorized_host", request.host)
|
99
119
|
end
|
@@ -23,7 +23,7 @@ module ActionDispatch
|
|
23
23
|
status = request.path_info[1..-1].to_i
|
24
24
|
begin
|
25
25
|
content_type = request.formats.first
|
26
|
-
rescue
|
26
|
+
rescue ActionDispatch::Http::MimeNegotiation::InvalidType
|
27
27
|
content_type = Mime[:text]
|
28
28
|
end
|
29
29
|
body = { status: status, error: Rack::Utils::HTTP_STATUS_CODES.fetch(status, Rack::Utils::HTTP_STATUS_CODES[500]) }
|
@@ -33,7 +33,7 @@ module ActionDispatch
|
|
33
33
|
# not be the ultimate client IP in production, and so are discarded. See
|
34
34
|
# https://en.wikipedia.org/wiki/Private_network for details.
|
35
35
|
TRUSTED_PROXIES = [
|
36
|
-
"127.0.0.
|
36
|
+
"127.0.0.0/8", # localhost IPv4 range, per RFC-3330
|
37
37
|
"::1", # localhost IPv6
|
38
38
|
"fc00::/7", # private IPv6 range fc00::/7
|
39
39
|
"10.0.0.0/8", # private IPv4 range 10.x.x.x
|
@@ -143,10 +143,11 @@ module ActionDispatch
|
|
143
143
|
# - X-Forwarded-For will be a list of IPs, one per proxy, or blank
|
144
144
|
# - Client-Ip is propagated from the outermost proxy, or is blank
|
145
145
|
# - REMOTE_ADDR will be the IP that made the request to Rack
|
146
|
-
ips = [forwarded_ips, client_ips
|
146
|
+
ips = [forwarded_ips, client_ips].flatten.compact
|
147
147
|
|
148
|
-
# If every single IP option is in the trusted list,
|
149
|
-
|
148
|
+
# If every single IP option is in the trusted list, return the IP
|
149
|
+
# that's furthest away
|
150
|
+
filter_proxies(ips + [remote_addr]).first || ips.last || remote_addr
|
150
151
|
end
|
151
152
|
|
152
153
|
# Memoizes the value returned by #calculate_ip and returns it for
|
@@ -15,16 +15,15 @@ module ActionDispatch
|
|
15
15
|
# The unique request id can be used to trace a request end-to-end and would typically end up being part of log files
|
16
16
|
# from multiple pieces of the stack.
|
17
17
|
class RequestId
|
18
|
-
|
19
|
-
|
20
|
-
def initialize(app)
|
18
|
+
def initialize(app, header:)
|
21
19
|
@app = app
|
20
|
+
@header = header
|
22
21
|
end
|
23
22
|
|
24
23
|
def call(env)
|
25
24
|
req = ActionDispatch::Request.new env
|
26
|
-
req.request_id = make_request_id(req.
|
27
|
-
@app.call(env).tap { |_status, headers, _body| headers[
|
25
|
+
req.request_id = make_request_id(req.headers[@header])
|
26
|
+
@app.call(env).tap { |_status, headers, _body| headers[@header] = req.request_id }
|
28
27
|
end
|
29
28
|
|
30
29
|
private
|
@@ -82,7 +82,7 @@ module ActionDispatch
|
|
82
82
|
include SessionObject
|
83
83
|
|
84
84
|
private
|
85
|
-
def set_cookie(request,
|
85
|
+
def set_cookie(request, response, cookie)
|
86
86
|
request.cookie_jar[key] = cookie
|
87
87
|
end
|
88
88
|
end
|
@@ -97,7 +97,7 @@ module ActionDispatch
|
|
97
97
|
end
|
98
98
|
|
99
99
|
private
|
100
|
-
def set_cookie(request,
|
100
|
+
def set_cookie(request, response, cookie)
|
101
101
|
request.cookie_jar[key] = cookie
|
102
102
|
end
|
103
103
|
end
|
@@ -10,8 +10,8 @@ module ActionDispatch
|
|
10
10
|
# dramatically faster than the alternatives.
|
11
11
|
#
|
12
12
|
# Sessions typically contain at most a user_id and flash message; both fit
|
13
|
-
# within the
|
14
|
-
# you attempt to store more than
|
13
|
+
# within the 4096 bytes cookie size limit. A CookieOverflow exception is raised if
|
14
|
+
# you attempt to store more than 4096 bytes of data.
|
15
15
|
#
|
16
16
|
# The cookie jar used for storage is automatically configured to be the
|
17
17
|
# best possible option given your application's configuration.
|
@@ -13,7 +13,7 @@ module ActionDispatch
|
|
13
13
|
#
|
14
14
|
# Requests can opt-out of redirection with +exclude+:
|
15
15
|
#
|
16
|
-
# config.ssl_options = { redirect: { exclude: -> request { request.path
|
16
|
+
# config.ssl_options = { redirect: { exclude: -> request { /healthcheck/.match?(request.path) } } }
|
17
17
|
#
|
18
18
|
# Cookies will not be flagged as secure for excluded requests.
|
19
19
|
#
|
@@ -29,7 +29,7 @@ module ActionDispatch
|
|
29
29
|
#
|
30
30
|
# * +expires+: How long, in seconds, these settings will stick. The minimum
|
31
31
|
# required to qualify for browser preload lists is 1 year. Defaults to
|
32
|
-
#
|
32
|
+
# 2 years (recommended).
|
33
33
|
#
|
34
34
|
# * +subdomains+: Set to +true+ to tell the browser to apply these settings
|
35
35
|
# to all subdomains. This protects your cookies from interception by a
|
@@ -49,14 +49,14 @@ module ActionDispatch
|
|
49
49
|
class SSL
|
50
50
|
# :stopdoc:
|
51
51
|
|
52
|
-
# Default to
|
53
|
-
HSTS_EXPIRES_IN =
|
52
|
+
# Default to 2 years as recommended on hstspreload.org.
|
53
|
+
HSTS_EXPIRES_IN = 63072000
|
54
54
|
|
55
55
|
def self.default_hsts_options
|
56
56
|
{ expires: HSTS_EXPIRES_IN, subdomains: true, preload: false }
|
57
57
|
end
|
58
58
|
|
59
|
-
def initialize(app, redirect: {}, hsts: {}, secure_cookies: true)
|
59
|
+
def initialize(app, redirect: {}, hsts: {}, secure_cookies: true, ssl_default_redirect_status: nil)
|
60
60
|
@app = app
|
61
61
|
|
62
62
|
@redirect = redirect
|
@@ -65,6 +65,7 @@ module ActionDispatch
|
|
65
65
|
@secure_cookies = secure_cookies
|
66
66
|
|
67
67
|
@hsts_header = build_hsts_header(normalize_hsts_options(hsts))
|
68
|
+
@ssl_default_redirect_status = ssl_default_redirect_status
|
68
69
|
end
|
69
70
|
|
70
71
|
def call(env)
|
@@ -126,12 +127,14 @@ module ActionDispatch
|
|
126
127
|
[ @redirect.fetch(:status, redirection_status(request)),
|
127
128
|
{ "Content-Type" => "text/html",
|
128
129
|
"Location" => https_location_for(request) },
|
129
|
-
@redirect
|
130
|
+
(@redirect[:body] || []) ]
|
130
131
|
end
|
131
132
|
|
132
133
|
def redirection_status(request)
|
133
134
|
if request.get? || request.head?
|
134
135
|
301 # Issue a permanent redirect via a GET request.
|
136
|
+
elsif @ssl_default_redirect_status
|
137
|
+
@ssl_default_redirect_status
|
135
138
|
else
|
136
139
|
307 # Issue a fresh request redirect to preserve the HTTP method.
|
137
140
|
end
|
@@ -122,6 +122,24 @@ module ActionDispatch
|
|
122
122
|
middlewares.delete_if { |m| m.klass == target }
|
123
123
|
end
|
124
124
|
|
125
|
+
def move(target, source)
|
126
|
+
source_index = assert_index(source, :before)
|
127
|
+
source_middleware = middlewares.delete_at(source_index)
|
128
|
+
|
129
|
+
target_index = assert_index(target, :before)
|
130
|
+
middlewares.insert(target_index, source_middleware)
|
131
|
+
end
|
132
|
+
|
133
|
+
alias_method :move_before, :move
|
134
|
+
|
135
|
+
def move_after(target, source)
|
136
|
+
source_index = assert_index(source, :after)
|
137
|
+
source_middleware = middlewares.delete_at(source_index)
|
138
|
+
|
139
|
+
target_index = assert_index(target, :after)
|
140
|
+
middlewares.insert(target_index + 1, source_middleware)
|
141
|
+
end
|
142
|
+
|
125
143
|
def use(klass, *args, &block)
|
126
144
|
middlewares.push(build_middleware(klass, args, block))
|
127
145
|
end
|
@@ -4,126 +4,187 @@ require "rack/utils"
|
|
4
4
|
require "active_support/core_ext/uri"
|
5
5
|
|
6
6
|
module ActionDispatch
|
7
|
-
# This middleware
|
8
|
-
#
|
9
|
-
# when a response containing a file's contents is delivered.
|
7
|
+
# This middleware serves static files from disk, if available.
|
8
|
+
# If no file is found, it hands off to the main app.
|
10
9
|
#
|
11
|
-
#
|
12
|
-
#
|
13
|
-
#
|
14
|
-
#
|
15
|
-
#
|
16
|
-
#
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
@
|
21
|
-
@
|
10
|
+
# In Rails apps, this middleware is configured to serve assets from
|
11
|
+
# the +public/+ directory.
|
12
|
+
#
|
13
|
+
# Only GET and HEAD requests are served. POST and other HTTP methods
|
14
|
+
# are handed off to the main app.
|
15
|
+
#
|
16
|
+
# Only files in the root directory are served; path traversal is denied.
|
17
|
+
class Static
|
18
|
+
def initialize(app, path, index: "index", headers: {})
|
19
|
+
@app = app
|
20
|
+
@file_handler = FileHandler.new(path, index: index, headers: headers)
|
22
21
|
end
|
23
22
|
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
23
|
+
def call(env)
|
24
|
+
@file_handler.attempt(env) || @app.call(env)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
# This endpoint serves static files from disk using Rack::File.
|
29
|
+
#
|
30
|
+
# URL paths are matched with static files according to expected
|
31
|
+
# conventions: +path+, +path+.html, +path+/index.html.
|
32
|
+
#
|
33
|
+
# Precompressed versions of these files are checked first. Brotli (.br)
|
34
|
+
# and gzip (.gz) files are supported. If +path+.br exists, this
|
35
|
+
# endpoint returns that file with a +Content-Encoding: br+ header.
|
36
|
+
#
|
37
|
+
# If no matching file is found, this endpoint responds 404 Not Found.
|
38
|
+
#
|
39
|
+
# Pass the +root+ directory to search for matching files, an optional
|
40
|
+
# +index: "index"+ to change the default +path+/index.html, and optional
|
41
|
+
# additional response headers.
|
42
|
+
class FileHandler
|
43
|
+
# Accept-Encoding value -> file extension
|
44
|
+
PRECOMPRESSED = {
|
45
|
+
"br" => ".br",
|
46
|
+
"gzip" => ".gz",
|
47
|
+
"identity" => nil
|
48
|
+
}
|
49
|
+
|
50
|
+
def initialize(root, index: "index", headers: {}, precompressed: %i[ br gzip ], compressible_content_types: /\A(?:text\/|application\/javascript)/)
|
51
|
+
@root = root.chomp("/").b
|
52
|
+
@index = index
|
53
|
+
|
54
|
+
@precompressed = Array(precompressed).map(&:to_s) | %w[ identity ]
|
55
|
+
@compressible_content_types = compressible_content_types
|
56
|
+
|
57
|
+
@file_server = ::Rack::File.new(@root, headers)
|
47
58
|
end
|
48
59
|
|
49
60
|
def call(env)
|
50
|
-
|
61
|
+
attempt(env) || @file_server.call(env)
|
51
62
|
end
|
52
63
|
|
53
|
-
def
|
54
|
-
|
55
|
-
gzip_path = gzip_file_path(path)
|
64
|
+
def attempt(env)
|
65
|
+
request = Rack::Request.new env
|
56
66
|
|
57
|
-
if
|
58
|
-
request.path_info
|
59
|
-
|
60
|
-
if status == 304
|
61
|
-
return [status, headers, body]
|
67
|
+
if request.get? || request.head?
|
68
|
+
if found = find_file(request.path_info, accept_encoding: request.accept_encoding)
|
69
|
+
serve request, *found
|
62
70
|
end
|
63
|
-
headers["Content-Encoding"] = "gzip"
|
64
|
-
headers["Content-Type"] = content_type(path)
|
65
|
-
else
|
66
|
-
status, headers, body = @file_server.call(request.env)
|
67
71
|
end
|
68
|
-
|
69
|
-
headers["Vary"] = "Accept-Encoding" if gzip_path
|
70
|
-
|
71
|
-
[status, headers, body]
|
72
|
-
ensure
|
73
|
-
request.path_info = path
|
74
72
|
end
|
75
73
|
|
76
74
|
private
|
77
|
-
def
|
78
|
-
|
75
|
+
def serve(request, filepath, content_headers)
|
76
|
+
original, request.path_info =
|
77
|
+
request.path_info, ::Rack::Utils.escape_path(filepath).b
|
78
|
+
|
79
|
+
@file_server.call(request.env).tap do |status, headers, body|
|
80
|
+
# Omit Content-Encoding/Type/etc headers for 304 Not Modified
|
81
|
+
if status != 304
|
82
|
+
headers.update(content_headers)
|
83
|
+
end
|
84
|
+
end
|
85
|
+
ensure
|
86
|
+
request.path_info = original
|
79
87
|
end
|
80
88
|
|
81
|
-
|
82
|
-
|
89
|
+
# Match a URI path to a static file to be served.
|
90
|
+
#
|
91
|
+
# Used by the +Static+ class to negotiate a servable file in the
|
92
|
+
# +public/+ directory (see Static#call).
|
93
|
+
#
|
94
|
+
# Checks for +path+, +path+.html, and +path+/index.html files,
|
95
|
+
# in that order, including .br and .gzip compressed extensions.
|
96
|
+
#
|
97
|
+
# If a matching file is found, the path and necessary response headers
|
98
|
+
# (Content-Type, Content-Encoding) are returned.
|
99
|
+
def find_file(path_info, accept_encoding:)
|
100
|
+
each_candidate_filepath(path_info) do |filepath, content_type|
|
101
|
+
if response = try_files(filepath, content_type, accept_encoding: accept_encoding)
|
102
|
+
return response
|
103
|
+
end
|
104
|
+
end
|
83
105
|
end
|
84
106
|
|
85
|
-
def
|
86
|
-
|
107
|
+
def try_files(filepath, content_type, accept_encoding:)
|
108
|
+
headers = { "Content-Type" => content_type }
|
109
|
+
|
110
|
+
if compressible? content_type
|
111
|
+
try_precompressed_files filepath, headers, accept_encoding: accept_encoding
|
112
|
+
elsif file_readable? filepath
|
113
|
+
[ filepath, headers ]
|
114
|
+
end
|
87
115
|
end
|
88
116
|
|
89
|
-
def
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
117
|
+
def try_precompressed_files(filepath, headers, accept_encoding:)
|
118
|
+
each_precompressed_filepath(filepath) do |content_encoding, precompressed_filepath|
|
119
|
+
if file_readable? precompressed_filepath
|
120
|
+
# Identity encoding is default, so we skip Accept-Encoding
|
121
|
+
# negotiation and needn't set Content-Encoding.
|
122
|
+
#
|
123
|
+
# Vary header is expected when we've found other available
|
124
|
+
# encodings that Accept-Encoding ruled out.
|
125
|
+
if content_encoding == "identity"
|
126
|
+
return precompressed_filepath, headers
|
127
|
+
else
|
128
|
+
headers["Vary"] = "Accept-Encoding"
|
129
|
+
|
130
|
+
if accept_encoding.any? { |enc, _| /\b#{content_encoding}\b/i.match?(enc) }
|
131
|
+
headers["Content-Encoding"] = content_encoding
|
132
|
+
return precompressed_filepath, headers
|
133
|
+
end
|
134
|
+
end
|
135
|
+
end
|
96
136
|
end
|
97
137
|
end
|
98
|
-
end
|
99
138
|
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
# produce a directory traversal using this middleware. Only 'GET' and 'HEAD'
|
108
|
-
# requests will result in a file being returned.
|
109
|
-
class Static
|
110
|
-
def initialize(app, path, index: "index", headers: {})
|
111
|
-
@app = app
|
112
|
-
@file_handler = FileHandler.new(path, index: index, headers: headers)
|
113
|
-
end
|
139
|
+
def file_readable?(path)
|
140
|
+
file_stat = File.stat(File.join(@root, path.b))
|
141
|
+
rescue SystemCallError
|
142
|
+
false
|
143
|
+
else
|
144
|
+
file_stat.file? && file_stat.readable?
|
145
|
+
end
|
114
146
|
|
115
|
-
|
116
|
-
|
147
|
+
def compressible?(content_type)
|
148
|
+
@compressible_content_types.match?(content_type)
|
149
|
+
end
|
117
150
|
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
return @file_handler.serve(req)
|
151
|
+
def each_precompressed_filepath(filepath)
|
152
|
+
@precompressed.each do |content_encoding|
|
153
|
+
precompressed_ext = PRECOMPRESSED.fetch(content_encoding)
|
154
|
+
yield content_encoding, "#{filepath}#{precompressed_ext}"
|
123
155
|
end
|
156
|
+
|
157
|
+
nil
|
124
158
|
end
|
125
159
|
|
126
|
-
|
127
|
-
|
160
|
+
def each_candidate_filepath(path_info)
|
161
|
+
return unless path = clean_path(path_info)
|
162
|
+
|
163
|
+
ext = ::File.extname(path)
|
164
|
+
content_type = ::Rack::Mime.mime_type(ext, nil)
|
165
|
+
yield path, content_type || "text/plain"
|
166
|
+
|
167
|
+
# Tack on .html and /index.html only for paths that don't have
|
168
|
+
# an explicit, resolvable file extension. No need to check
|
169
|
+
# for foo.js.html and foo.js/index.html.
|
170
|
+
unless content_type
|
171
|
+
default_ext = ::ActionController::Base.default_static_extension
|
172
|
+
if ext != default_ext
|
173
|
+
default_content_type = ::Rack::Mime.mime_type(default_ext, "text/plain")
|
174
|
+
|
175
|
+
yield "#{path}#{default_ext}", default_content_type
|
176
|
+
yield "#{path}/#{@index}#{default_ext}", default_content_type
|
177
|
+
end
|
178
|
+
end
|
179
|
+
|
180
|
+
nil
|
181
|
+
end
|
182
|
+
|
183
|
+
def clean_path(path_info)
|
184
|
+
path = ::Rack::Utils.unescape_path path_info.chomp("/")
|
185
|
+
if ::Rack::Utils.valid_path? path
|
186
|
+
::Rack::Utils.clean_path_info path
|
187
|
+
end
|
188
|
+
end
|
128
189
|
end
|
129
190
|
end
|