require "ecr/macros"
require "html"
require "uri"
require "mime"
# A handler that lists directories and serves files under a given public directory.
#
# This handler can send precompressed content, if the client accepts it, and a file
# with the same name and `.gz` extension appended is found in the same directory.
# Precompressed files are only served if they are newer than the original file.
#
# NOTE: To use `StaticFileHandler`, you must explicitly import it with `require "http"`
class HTTP::StaticFileHandler
include HTTP::Handler
# In some file systems, using `gz --keep` to compress the file will keep the
# modification time of the original file but truncating some decimals. We
# serve the gzipped file nonetheless if the .gz file is modified by a duration
# of `TIME_DRIFT` before the original file. This value should match the
# granularity of the underlying file system's modification times
private TIME_DRIFT = 10.milliseconds
@public_dir : Path
# Creates a handler that will serve files in the given *public_dir*, after
# expanding it (using `File#expand_path`).
#
# If *fallthrough* is `false`, this handler does not call next handler when
# request method is neither GET or HEAD, then serves `405 Method Not Allowed`.
# Otherwise, it calls next handler.
#
# If *directory_listing* is `false`, directory listing is disabled. This means that
# paths matching directories are ignored and next handler is called.
def initialize(public_dir : String, @fallthrough : Bool = true, @directory_listing : Bool = true)
@public_dir = Path.new(public_dir).expand
end
# :ditto:
@[Deprecated]
def self.new(public_dir : String, fallthrough = true, directory_listing = true)
new(public_dir, fallthrough: !!fallthrough, listing: !!listing)
end
def call(context) : Nil
check_request_method!(context) || return
request_path = request_path(context)
check_request_path!(context, request_path) || return
request_path = Path.posix(request_path)
expanded_path = request_path.expand("/")
file_info, file_path = file_info(expanded_path)
if normalized_path = normalize_request_path(context, request_path, expanded_path, file_info)
return redirect_to context, normalized_path
end
return call_next(context) unless file_info
if file_info.directory?
directory_index(context, request_path, file_path)
elsif file_info.file?
serve_file_with_cache(context, file_info, file_path)
else # Not a normal file (FIFO/device/socket)
call_next(context)
end
end
private def check_request_method!(context : Server::Context) : Bool
return true if context.request.method.in?("GET", "HEAD")
if @fallthrough
call_next(context)
else
context.response.status = :method_not_allowed
context.response.headers.add("Allow", "GET, HEAD")
end
false
end
private def check_request_path!(context : Server::Context, request_path : String) : Bool
# File path cannot contain '\0' (NUL) because all filesystem I know
# don't accept '\0' character as file name.
if request_path.includes? '\0'
context.response.respond_with_status(:bad_request)
return false
end
true
end
private def normalize_request_path(context : Server::Context, request_path : Path, expanded_path : Path, file_info) : Path?
if @directory_listing && file_info.try(&.directory?) && !request_path.ends_with_separator?
# Append / to path if missing
expanded_path.join("")
elsif request_path != expanded_path
expanded_path
end
end
private def file_info(expanded_path : Path)
file_path = @public_dir.join(expanded_path.to_kind(Path::Kind.native))
{File.info?(file_path), file_path}
end
private def serve_file_with_cache(context : Server::Context, file_info, file_path : Path)
last_modified = file_info.modification_time
add_cache_headers(context.response.headers, last_modified)
if cache_request?(context, last_modified)
context.response.status = :not_modified
return
end
serve_file_compressed(context, file_info, file_path, last_modified)
end
private def serve_file_compressed(context : Server::Context, file_info, file_path : Path, last_modified : Time)
original_file_path = file_path
# Checks if pre-gzipped file can be served
if context.request.headers.includes_word?("Accept-Encoding", "gzip")
gz_file_path = Path["#{file_path}.gz"]
if (gz_file_info = File.info?(gz_file_path)) &&
last_modified - gz_file_info.modification_time < TIME_DRIFT
file_path = gz_file_path
file_info = gz_file_info
context.response.headers["Content-Encoding"] = "gzip"
end
end
serve_file(context, file_info, file_path, original_file_path, last_modified)
end
private def serve_file(context : Server::Context, file_info, file_path : Path, original_file_path : Path, last_modified : Time)
context.response.content_type = MIME.from_filename(original_file_path.to_s, "application/octet-stream")
begin
File.open(file_path) do |file|
if range_header = context.request.headers["Range"]?
serve_file_range(context, file, range_header, file_info)
else
context.response.headers["Accept-Ranges"] = "bytes"
serve_file_full(context, file, file_info)
end
end
rescue File::Error
# If there's any file error, we report the file as not existing.
# Even if it exists but is not readable, we don't want to disclose its
# existence.
context.response.respond_with_status(:not_found)
return
end
end
# *file* should be seekable, that's implement #seek method
private def serve_file_range(context : Server::Context, file : IO, range_header : String, file_info)
range_header = range_header.lchop?("bytes=")
unless range_header
context.response.headers["Content-Range"] = "bytes */#{file_info.size}"
context.response.status = :range_not_satisfiable
context.response.close
return
end
ranges = parse_ranges(range_header, file_info.size)
unless ranges
context.response.respond_with_status :bad_request
return
end
if file_info.size.zero? && ranges.size == 1 && ranges[0].begin.zero?
context.response.status = :ok
return
end
# If any of the ranges start beyond the end of the file, we return an
# HTTP 416 Range Not Satisfiable.
# See https://www.rfc-editor.org/rfc/rfc9110.html#section-14.1.2-11.1
if ranges.any? { |range| range.begin >= file_info.size }
context.response.headers["Content-Range"] = "bytes */#{file_info.size}"
context.response.status = :range_not_satisfiable
context.response.close
return
end
ranges.map! { |range| range.begin..(Math.min(range.end, file_info.size - 1)) }
context.response.status = :partial_content
if ranges.size == 1
range = ranges.first
file.seek range.begin
context.response.headers["Content-Range"] = "bytes #{range.begin}-#{range.end}/#{file_info.size}"
IO.copy file, context.response, range.size
else
MIME::Multipart.build(context.response) do |builder|
content_type = context.response.headers["Content-Type"]?
context.response.headers["Content-Type"] = builder.content_type("byterange")
ranges.each do |range|
file.seek range.begin
headers = HTTP::Headers{
"Content-Range" => "bytes #{range.begin}-#{range.end}/#{file_info.size}",
"Content-Length" => range.size.to_s,
}
headers["Content-Type"] = content_type if content_type
chunk_io = IO::Sized.new(file, range.size)
builder.body_part headers, chunk_io
end
end
end
end
private def serve_file_full(context : Server::Context, file : IO, file_info)
context.response.status = :ok
context.response.content_length = file_info.size
IO.copy(file, context.response)
end
# TODO: Optimize without lots of intermediary strings
private def parse_ranges(header, file_size)
ranges = [] of Range(Int64, Int64)
header.split(",") do |range|
start_string, dash, finish_string = range.lchop(' ').partition("-")
return if dash.empty?
start = start_string.to_i64?
return if start.nil? && !start_string.empty?
if finish_string.empty?
return if start_string.empty?
finish = file_size
else
finish = finish_string.to_i64? || return
end
if file_size.zero?
# > When a selected representation has zero length, the only satisfiable
# > form of range-spec in a GET request is a suffix-range with a non-zero suffix-length.
if start
# This return value signals an unsatisfiable range.
return [1_i64..0_i64]
elsif finish <= 0
return
else
start = finish = 0_i64
end
elsif !start
# suffix-range
start = {file_size - finish, 0_i64}.max
finish = file_size - 1
end
range = (start..finish)
return unless 0 <= range.begin <= range.end
ranges << range
end
ranges unless ranges.empty?
end
private def request_path(context : Server::Context) : String
original_path = context.request.path.not_nil!
request_path(URI.decode(original_path))
end
# given a full path of the request, returns the path
# of the file that should be expanded at the public_dir
protected def request_path(path : String) : String
path
end
private def redirect_to(context : Server::Context, path)
uri = context.request.uri.dup
uri.path = URI.encode_path(path.to_s)
context.response.redirect uri
end
private def add_cache_headers(response_headers : HTTP::Headers, last_modified : Time) : Nil
response_headers["Etag"] = etag(last_modified)
response_headers["Last-Modified"] = HTTP.format_time(last_modified)
end
private def cache_request?(context : HTTP::Server::Context, last_modified : Time) : Bool
# According to RFC 7232:
# A recipient must ignore If-Modified-Since if the request contains an If-None-Match header field
if if_none_match = context.request.if_none_match
match = {"*", context.response.headers["Etag"]}
if_none_match.any? { |etag| match.includes?(etag) }
elsif if_modified_since = context.request.headers["If-Modified-Since"]?
header_time = HTTP.parse_time(if_modified_since)
# File mtime probably has a higher resolution than the header value.
# An exact comparison might be slightly off, so we add 1s padding.
# Static files should generally not be modified in subsecond intervals, so this is perfectly safe.
# This might be replaced by a more sophisticated time comparison when it becomes available.
!!(header_time && last_modified <= header_time + 1.second)
else
false
end
end
private def etag(modification_time)
%{W/"#{modification_time.to_unix}"}
end
record DirectoryListing, request_path : String, path : String do
def each_entry(&)
Dir.each_child(path) do |entry|
yield entry
end
end
ECR.def_to_s "#{__DIR__}/static_file_handler.html"
end
private def directory_index(context : Server::Context, request_path : Path, path : Path)
unless @directory_listing
return call_next(context)
end
context.response.content_type = "text/html; charset=utf-8"
directory_listing(context.response, request_path, path)
end
private def directory_listing(io : IO, request_path : Path, path : Path)
DirectoryListing.new(request_path.to_s, path.to_s).to_s(io)
end
end