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