1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192
|
# frozen_string_literal: true
# :markup: markdown
require "rack/utils"
module ActionDispatch
# # Action Dispatch Static
#
# 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
# # Action Dispatch FileHandler
#
# This endpoint serves static files from disk using `Rack::Files`.
#
# 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 `content-encoding: br` header.
#
# If no matching file is found, this endpoint responds `404 Not Found`.
#
# Pass the `root` directory to search for matching files, an optional `index:
# "index"` 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|image\/svg\+xml)/)
@root = root.chomp("/").b
@index = index
@precompressed = Array(precompressed).map(&:to_s) | %w[ identity ]
@compressible_content_types = compressible_content_types
@file_server = ::Rack::Files.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 = { Rack::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[ActionDispatch::Constants::VARY] = "accept-encoding"
if accept_encoding.any? { |enc, _| /\b#{content_encoding}\b/i.match?(enc) }
headers[ActionDispatch::Constants::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
|