mirror of
https://github.com/rails/rails.git
synced 2022-11-09 12:12:34 -05:00
5a2cea265d
Using File.stat where a file does not exist raises an exception. Since ActionDispatch::Static does this: def call(env) @file_handler.attempt(env) || @app.call(env) end ... this means that around 8 exceptions are raised and rescued on each request which is passed to the application. This change improves latency by ~.14 milliseconds on all applications. Partial revert of #37265
187 lines
6.3 KiB
Ruby
187 lines
6.3 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require "rack/utils"
|
|
require "active_support/core_ext/uri"
|
|
|
|
module ActionDispatch
|
|
# This middleware serves static files from disk, if available.
|
|
# If no file is found, it hands off to the main app.
|
|
#
|
|
# In Rails apps, this middleware is configured to serve assets from
|
|
# the +public/+ directory.
|
|
#
|
|
# Only GET and HEAD requests are served. POST and other HTTP methods
|
|
# are handed off to the main app.
|
|
#
|
|
# Only files in the root directory are served; path traversal is denied.
|
|
class Static
|
|
def initialize(app, path, index: "index", headers: {})
|
|
@app = app
|
|
@file_handler = FileHandler.new(path, index: index, headers: headers)
|
|
end
|
|
|
|
def call(env)
|
|
@file_handler.attempt(env) || @app.call(env)
|
|
end
|
|
end
|
|
|
|
# This endpoint serves static files from disk using Rack::File.
|
|
#
|
|
# URL paths are matched with static files according to expected
|
|
# conventions: +path+, +path+.html, +path+/index.html.
|
|
#
|
|
# Precompressed versions of these files are checked first. Brotli (.br)
|
|
# and gzip (.gz) files are supported. If +path+.br exists, this
|
|
# endpoint returns that file with a <tt>Content-Encoding: br</tt> header.
|
|
#
|
|
# If no matching file is found, this endpoint responds 404 Not Found.
|
|
#
|
|
# Pass the +root+ directory to search for matching files, an optional
|
|
# <tt>index: "index"</tt> to change the default +path+/index.html, and optional
|
|
# additional response headers.
|
|
class FileHandler
|
|
# Accept-Encoding value -> file extension
|
|
PRECOMPRESSED = {
|
|
"br" => ".br",
|
|
"gzip" => ".gz",
|
|
"identity" => nil
|
|
}
|
|
|
|
def initialize(root, index: "index", headers: {}, precompressed: %i[ br gzip ], compressible_content_types: /\A(?:text\/|application\/javascript)/)
|
|
@root = root.chomp("/").b
|
|
@index = index
|
|
|
|
@precompressed = Array(precompressed).map(&:to_s) | %w[ identity ]
|
|
@compressible_content_types = compressible_content_types
|
|
|
|
@file_server = ::Rack::File.new(@root, headers)
|
|
end
|
|
|
|
def call(env)
|
|
attempt(env) || @file_server.call(env)
|
|
end
|
|
|
|
def attempt(env)
|
|
request = Rack::Request.new env
|
|
|
|
if request.get? || request.head?
|
|
if found = find_file(request.path_info, accept_encoding: request.accept_encoding)
|
|
serve request, *found
|
|
end
|
|
end
|
|
end
|
|
|
|
private
|
|
def serve(request, filepath, content_headers)
|
|
original, request.path_info =
|
|
request.path_info, ::Rack::Utils.escape_path(filepath).b
|
|
|
|
@file_server.call(request.env).tap do |status, headers, body|
|
|
# Omit Content-Encoding/Type/etc headers for 304 Not Modified
|
|
if status != 304
|
|
headers.update(content_headers)
|
|
end
|
|
end
|
|
ensure
|
|
request.path_info = original
|
|
end
|
|
|
|
# Match a URI path to a static file to be served.
|
|
#
|
|
# Used by the +Static+ class to negotiate a servable file in the
|
|
# +public/+ directory (see Static#call).
|
|
#
|
|
# Checks for +path+, +path+.html, and +path+/index.html files,
|
|
# in that order, including .br and .gzip compressed extensions.
|
|
#
|
|
# If a matching file is found, the path and necessary response headers
|
|
# (Content-Type, Content-Encoding) are returned.
|
|
def find_file(path_info, accept_encoding:)
|
|
each_candidate_filepath(path_info) do |filepath, content_type|
|
|
if response = try_files(filepath, content_type, accept_encoding: accept_encoding)
|
|
return response
|
|
end
|
|
end
|
|
end
|
|
|
|
def try_files(filepath, content_type, accept_encoding:)
|
|
headers = { "Content-Type" => content_type }
|
|
|
|
if compressible? content_type
|
|
try_precompressed_files filepath, headers, accept_encoding: accept_encoding
|
|
elsif file_readable? filepath
|
|
[ filepath, headers ]
|
|
end
|
|
end
|
|
|
|
def try_precompressed_files(filepath, headers, accept_encoding:)
|
|
each_precompressed_filepath(filepath) do |content_encoding, precompressed_filepath|
|
|
if file_readable? precompressed_filepath
|
|
# Identity encoding is default, so we skip Accept-Encoding
|
|
# negotiation and needn't set Content-Encoding.
|
|
#
|
|
# Vary header is expected when we've found other available
|
|
# encodings that Accept-Encoding ruled out.
|
|
if content_encoding == "identity"
|
|
return precompressed_filepath, headers
|
|
else
|
|
headers["Vary"] = "Accept-Encoding"
|
|
|
|
if accept_encoding.any? { |enc, _| /\b#{content_encoding}\b/i.match?(enc) }
|
|
headers["Content-Encoding"] = content_encoding
|
|
return precompressed_filepath, headers
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
def file_readable?(path)
|
|
file_path = File.join(@root, path.b)
|
|
File.file?(file_path) && File.readable?(file_path)
|
|
end
|
|
|
|
def compressible?(content_type)
|
|
@compressible_content_types.match?(content_type)
|
|
end
|
|
|
|
def each_precompressed_filepath(filepath)
|
|
@precompressed.each do |content_encoding|
|
|
precompressed_ext = PRECOMPRESSED.fetch(content_encoding)
|
|
yield content_encoding, "#{filepath}#{precompressed_ext}"
|
|
end
|
|
|
|
nil
|
|
end
|
|
|
|
def each_candidate_filepath(path_info)
|
|
return unless path = clean_path(path_info)
|
|
|
|
ext = ::File.extname(path)
|
|
content_type = ::Rack::Mime.mime_type(ext, nil)
|
|
yield path, content_type || "text/plain"
|
|
|
|
# Tack on .html and /index.html only for paths that don't have
|
|
# an explicit, resolvable file extension. No need to check
|
|
# for foo.js.html and foo.js/index.html.
|
|
unless content_type
|
|
default_ext = ::ActionController::Base.default_static_extension
|
|
if ext != default_ext
|
|
default_content_type = ::Rack::Mime.mime_type(default_ext, "text/plain")
|
|
|
|
yield "#{path}#{default_ext}", default_content_type
|
|
yield "#{path}/#{@index}#{default_ext}", default_content_type
|
|
end
|
|
end
|
|
|
|
nil
|
|
end
|
|
|
|
def clean_path(path_info)
|
|
path = ::Rack::Utils.unescape_path path_info.chomp("/")
|
|
if ::Rack::Utils.valid_path? path
|
|
::Rack::Utils.clean_path_info path
|
|
end
|
|
end
|
|
end
|
|
end
|