diff --git a/spec/helpers_spec.cr b/spec/helpers_spec.cr index e3b7e6d..94dfdc0 100644 --- a/spec/helpers_spec.cr +++ b/spec/helpers_spec.cr @@ -133,4 +133,21 @@ describe "Macros" do Kemal.config.handlers.last.is_a?(HTTP::DeflateHandler).should eq false end end + + describe "#serve_static" do + it "should disble static file hosting" do + serve_static false + Kemal.config.serve_static.should eq false + end + + it "should disble enable gzip and dir_listing" do + serve_static({"gzip" => true, "dir_listing" => true}) + conf = Kemal.config.serve_static + conf.is_a?(Hash).should eq true + if conf.is_a?(Hash) + conf["gzip"].should eq true + conf["dir_listing"].should eq true + end + end + end end diff --git a/spec/static/dir/bigger.txt b/spec/static/dir/bigger.txt new file mode 100644 index 0000000..36281ae --- /dev/null +++ b/spec/static/dir/bigger.txt @@ -0,0 +1,9 @@ +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse posuere cursus consectetur. Donec mauris lorem, sodales a eros a, ultricies convallis ante. Quisque elementum lacus purus, sagittis mollis justo dignissim ac. Suspendisse potenti. Cras non mauris accumsan mi porttitor congue. Quisque posuere aliquam tellus sit amet ultrices. Sed at tortor sed libero fringilla luctus vitae quis magna. In maximus congue felis, et porta tortor egestas sed. Phasellus orci eros, finibus sed ipsum eget, euismod bibendum nisl. Etiam ultrices facilisis diam in gravida. Praesent lobortis leo vitae aliquet volutpat. Praesent vel blandit risus. In suscipit eget nunc at ultrices. Proin dapibus feugiat diam ut tincidunt. Donec lectus diam, ornare ut consequat nec, gravida sit amet metus. + +Nunc a viverra urna, quis ullamcorper augue. Morbi posuere auctor nibh, tempor luctus massa mollis laoreet. Pellentesque sagittis leo eu felis interdum finibus. Pellentesque porttitor lobortis arcu, eu mollis dui iaculis nec. Vestibulum sit amet sodales erat. Nullam quis mi massa. Suspendisse sit amet elit auctor, feugiat ipsum a, placerat metus. Vestibulum quis felis a lectus blandit aliquam. Nam consectetur iaculis nulla. Mauris sit amet condimentum erat, in vestibulum dui. Nullam nec mattis tortor, non viverra nunc. Proin eget congue augue. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Sed ut hendrerit nulla. Etiam cursus sagittis metus, et feugiat ligula molestie sit amet. Aliquam laoreet auctor sagittis. + +Aliquam tempor urna non consectetur tincidunt. Maecenas porttitor augue diam, ac lobortis nulla suscipit eget. Ut quis lacus facilisis, euismod lacus non, ullamcorper urna. Cras pretium fringilla pharetra. Praesent sed nunc at elit vulputate elementum. Suspendisse ac molestie nunc, sit amet consectetur nunc. Cras placerat ligula tortor, non bibendum massa tempus ut. Etiam eros erat, gravida id felis eget, congue suscipit ipsum. Sed condimentum erat at facilisis dictum. Cras venenatis vitae turpis vitae sagittis. Proin id posuere est, non ornare sem. Donec vitae sollicitudin dolor, a pulvinar ex. Integer porta velit lectus, et imperdiet enim commodo a. + +Donec sit amet ipsum tempus, tincidunt neque eget, luctus massa. Praesent vel nulla pretium, bibendum enim a, pulvinar enim. Vestibulum non libero eu est dignissim cursus. Nullam commodo tellus imperdiet feugiat placerat. Sed sed dolor ut nibh blandit maximus ac eget neque. Ut sit amet augue maximus, lacinia eros non, faucibus eros. Suspendisse ac bibendum libero, eu lobortis nulla. Mauris arcu nulla, tempus eu varius eu, bibendum at nibh. Donec id libero consequat, volutpat ex vitae, molestie velit. Aliquam aliquam sem ac arcu pellentesque, placerat bibendum enim dapibus. Duis consectetur ligula non placerat euismod. + +Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia Curae; Proin commodo ullamcorper venenatis. Cras ac lorem sit amet augue varius convallis. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Mauris dolor nisi, efficitur id aliquet ut, ultricies sed elit. Proin ultricies turpis dolor, in auctor velit aliquet nec. Praesent vehicula aliquam viverra. Suspendisse potenti. Donec aliquet iaculis ultricies. Proin dignissim vitae nisl at rutrum. \ No newline at end of file diff --git a/spec/static/dir/test.txt b/spec/static/dir/test.txt new file mode 100644 index 0000000..9db7df0 --- /dev/null +++ b/spec/static/dir/test.txt @@ -0,0 +1,2 @@ +hello +world \ No newline at end of file diff --git a/spec/static_file_handler.cr b/spec/static_file_handler.cr new file mode 100644 index 0000000..31ce94f --- /dev/null +++ b/spec/static_file_handler.cr @@ -0,0 +1,102 @@ +require "./spec_helper" + +private def handle(request, fallthrough = true) + io = MemoryIO.new + response = HTTP::Server::Response.new(io) + context = HTTP::Server::Context.new(request, response) + handler = Kemal::StaticFileHandler.new "#{__DIR__}/static", fallthrough + handler.call context + response.close + io.rewind + HTTP::Client::Response.from_io(io) +end + +describe Kemal::StaticFileHandler do + file_text = File.read "#{__DIR__}/static/dir/test.txt" + + it "should serve a file with content type and etag" do + response = handle HTTP::Request.new("GET", "/dir/test.txt") + response.status_code.should eq(200) + response.headers["Content-Type"].should eq "text/plain" + response.headers["Etag"].should contain "W/\"" + response.body.should eq(File.read("#{__DIR__}/static/dir/test.txt")) + end + + it "should respond with 304 if file has not changed" do + response = handle HTTP::Request.new("GET", "/dir/test.txt") + response.status_code.should eq(200) + etag = response.headers["Etag"] + + headers = HTTP::Headers{"If-None-Match" => etag} + response = handle HTTP::Request.new("GET", "/dir/test.txt", headers) + response.status_code.should eq(304) + response.body.should eq "" + end + + it "should not list directory's entries" do + serve_static({"gzip" => true, "dir_listing" => false}) + response = handle HTTP::Request.new("GET", "/dir/") + response.status_code.should eq(404) + end + + it "should list directory's entries when config is set" do + serve_static({"gzip" => true, "dir_listing" => true}) + response = handle HTTP::Request.new("GET", "/dir/") + response.status_code.should eq(200) + response.body.should match(/test.txt/) + end + + it "should gzip a file if config is true, headers accept gzip and file is > 880 bytes" do + serve_static({"gzip" => true, "dir_listing" => true}) + headers = HTTP::Headers{"Accept-Encoding" => "gzip, deflate, sdch, br"} + response = handle HTTP::Request.new("GET", "/dir/bigger.txt", headers) + response.status_code.should eq(200) + response.headers["Content-Encoding"].should eq "gzip" + end + + it "should not gzip a file if config is true, headers accept gzip and file is < 880 bytes" do + serve_static({"gzip" => true, "dir_listing" => true}) + headers = HTTP::Headers{"Accept-Encoding" => "gzip, deflate, sdch, br"} + response = handle HTTP::Request.new("GET", "/dir/test.txt", headers) + response.status_code.should eq(200) + response.headers["Content-Encoding"]?.should eq nil + end + + it "should not gzip a file if config is false, headers accept gzip and file is > 880 bytes" do + serve_static({"gzip" => false, "dir_listing" => true}) + headers = HTTP::Headers{"Accept-Encoding" => "gzip, deflate, sdch, br"} + response = handle HTTP::Request.new("GET", "/dir/bigger.txt", headers) + response.status_code.should eq(200) + response.headers["Content-Encoding"]?.should eq nil + end + + it "should not serve a not found file" do + response = handle HTTP::Request.new("GET", "/not_found_file.txt") + response.status_code.should eq(404) + end + + it "should not serve a not found directory" do + response = handle HTTP::Request.new("GET", "/not_found_dir/") + response.status_code.should eq(404) + end + + it "should not serve a file as directory" do + response = handle HTTP::Request.new("GET", "/dir/test.txt/") + response.status_code.should eq(404) + end + + it "should handle only GET and HEAD method" do + %w(GET HEAD).each do |method| + response = handle HTTP::Request.new(method, "/dir/test.txt") + response.status_code.should eq(200) + end + + %w(POST PUT DELETE).each do |method| + response = handle HTTP::Request.new(method, "/dir/test.txt") + response.status_code.should eq(404) + response = handle HTTP::Request.new(method, "/dir/test.txt"), false + response.status_code.should eq(405) + response.headers["Allow"].should eq("GET, HEAD") + end + end +end diff --git a/src/kemal/config.cr b/src/kemal/config.cr index 93d6165..d991a10 100644 --- a/src/kemal/config.cr +++ b/src/kemal/config.cr @@ -11,13 +11,13 @@ module Kemal @ssl : OpenSSL::SSL::Context::Server? property host_binding, ssl, port, env, public_folder, logging, - always_rescue, serve_static, server, extra_options + always_rescue, serve_static : (Bool | Hash(String, Bool)), server, extra_options def initialize @host_binding = "0.0.0.0" @port = 3000 @env = "development" - @serve_static = true + @serve_static = {"dir_listing" => false, "gzip" => true} @public_folder = "./public" @logging = true @logger = nil @@ -89,7 +89,7 @@ module Kemal end private def setup_static_file_handler - HANDLERS.insert(3, Kemal::StaticFileHandler.new(@public_folder)) if @serve_static + HANDLERS.insert(3, Kemal::StaticFileHandler.new(@public_folder)) if @serve_static.is_a?(Hash) end end diff --git a/src/kemal/helpers/helpers.cr b/src/kemal/helpers/helpers.cr index efe99f9..fcfe260 100644 --- a/src/kemal/helpers/helpers.cr +++ b/src/kemal/helpers/helpers.cr @@ -32,7 +32,7 @@ def logger(logger) end # Enables / Disables static file serving. -def serve_static(status) +def serve_static(status : (Bool | Hash)) Kemal.config.serve_static = status end diff --git a/src/kemal/static_file_handler.cr b/src/kemal/static_file_handler.cr index f1d6f44..f3b4d67 100644 --- a/src/kemal/static_file_handler.cr +++ b/src/kemal/static_file_handler.cr @@ -1,13 +1,92 @@ +{% if !flag?(:without_zlib) %} + require "zlib" +{% end %} + module Kemal - # Kemal::StaticFileHandler is used to serve static files(.js/.css/.png e.g). - # This handler is on by default and you can disable it like. - # - # serve_static false - # class StaticFileHandler < HTTP::StaticFileHandler def call(context) return call_next(context) if context.request.path.not_nil! == "/" - super + + unless context.request.method == "GET" || context.request.method == "HEAD" + if @fallthrough + call_next(context) + else + context.response.status_code = 405 + context.response.headers.add("Allow", "GET, HEAD") + end + return + end + + config = Kemal.config.serve_static + original_path = context.request.path.not_nil! + is_dir_path = original_path.ends_with? "/" + request_path = URI.unescape(original_path) + + # File path cannot contains '\0' (NUL) because all filesystem I know + # don't accept '\0' character as file name. + if request_path.includes? '\0' + context.response.status_code = 400 + return + end + + expanded_path = File.expand_path(request_path, "/") + if is_dir_path && !expanded_path.ends_with? "/" + expanded_path = "#{expanded_path}/" + end + is_dir_path = expanded_path.ends_with? "/" + + file_path = File.join(@public_dir, expanded_path) + is_dir = Dir.exists? file_path + + if request_path != expanded_path || is_dir && !is_dir_path + redirect_to context, "#{expanded_path}#{is_dir && !is_dir_path ? "/" : ""}" + end + + if Dir.exists?(file_path) + if config.is_a?(Hash) && config["dir_listing"] == true + context.response.content_type = "text/html" + directory_listing(context.response, request_path, file_path) + else + return call_next(context) + end + elsif File.exists?(file_path) + return if self.etag(context, file_path) + minsize = 860 # http://webmasters.stackexchange.com/questions/31750/what-is-recommended-minimum-object-size-for-gzip-performance-benefits ?? + context.response.content_type = mime_type(file_path) + request_headers = context.request.headers + filesize = File.size(file_path) + File.open(file_path) do |file| + if request_headers.includes_word?("Accept-Encoding", "gzip") && config.is_a?(Hash) && config["gzip"] == true && filesize > minsize && self.zip_types(file_path) + context.response.headers["Content-Encoding"] = "gzip" + Zlib::Deflate.gzip(context.response) do |deflate| + IO.copy(file, deflate) + end + elsif request_headers.includes_word?("Accept-Encoding", "deflate") && config.is_a?(Hash) && config["gzip"]? == true && filesize > minsize && self.zip_types(file_path) + context.response.headers["Content-Encoding"] = "deflate" + Zlib::Deflate.new(context.response) do |deflate| + IO.copy(file, deflate) + end + else + context.response.content_length = filesize + IO.copy(file, context.response) + end + end + else + call_next(context) + end + end + + def etag(context, file_path) + etag = %{W/"#{File.lstat(file_path).mtime.epoch.to_s}"} + context.response.headers["ETag"] = etag + return false if !context.request.headers["If-None-Match"]? || context.request.headers["If-None-Match"] != etag + context.response.content_length = 0 + context.response.status_code = 304 # not modified + return true + end + + def zip_types(path) # https://github.com/h5bp/server-configs-nginx/blob/master/nginx.conf + [".htm", ".html", ".txt", ".css", ".js", ".svg", ".json", ".xml", ".otf", ".ttf", ".woff", ".woff2"].includes? File.extname(path) end def mime_type(path) @@ -20,6 +99,11 @@ module Kemal when ".jpg", ".jpeg" then "image/jpeg" when ".gif" then "image/gif" when ".svg" then "image/svg+xml" + when ".xml" then "application/xml" + when ".json" then "application/json" + when ".otf", ".ttf" then "application/font-sfnt" + when ".woff" then "application/font-woff" + when ".woff2" then "font/woff2" else "application/octet-stream" end end