require "../../../spec_helper" require "http/server/handler" require "http/client/response" require "../../../../support/tempfile" private def handle(request, *, fallthrough = true, directory_listing = true, ignore_body = false, decompress = true, directory = datapath("static_file_handler")) io = IO::Memory.new response = HTTP::Server::Response.new(io) context = HTTP::Server::Context.new(request, response) handler = HTTP::StaticFileHandler.new directory, fallthrough, directory_listing handler.call context response.close io.rewind HTTP::Client::Response.from_io(io, ignore_body, decompress) end describe HTTP::StaticFileHandler do file_text = File.read datapath("static_file_handler", "test.txt") it "serves a file" do response = handle HTTP::Request.new("GET", "/test.txt"), ignore_body: false response.status_code.should eq(200) response.body.should eq(File.read(datapath("static_file_handler", "test.txt"))) end it "handles forbidden characters in windows paths" do response = handle HTTP::Request.new("GET", "/foo\\bar.txt"), ignore_body: false response.status_code.should eq 404 # This file can't be checkout out from git on Windows, thus we need to create it here. File.touch(Path[datapath("static_file_handler"), Path.posix("back\\slash.txt")]) response = handle HTTP::Request.new("GET", "/back\\slash.txt"), ignore_body: false response.status_code.should eq 200 ensure File.delete(Path[datapath("static_file_handler"), Path.posix("back\\slash.txt")]) end it "adds Etag header" do response = handle HTTP::Request.new("GET", "/test.txt") response.headers["Etag"].should match(/W\/"\d+"$/) end it "adds Last-Modified header" do response = handle HTTP::Request.new("GET", "/test.txt") modification_time = File.info(datapath("static_file_handler", "test.txt")).modification_time HTTP.parse_time(response.headers["Last-Modified"]).should eq(modification_time.at_beginning_of_second) end context "with If-Modified-Since header" do it "returns 304 Not Modified for equal to Last-Modified" do initial_response = handle HTTP::Request.new("GET", "/test.txt") headers = HTTP::Headers.new headers["If-Modified-Since"] = initial_response.headers["Last-Modified"] response = handle HTTP::Request.new("GET", "/test.txt", headers), ignore_body: true response.status_code.should eq(304) response.headers["Last-Modified"].should eq initial_response.headers["Last-Modified"] response.headers["Content-Type"]?.should be_nil response.body.should eq "" end it "returns 304 Not Modified for younger than Last-Modified" do initial_response = handle HTTP::Request.new("GET", "/test.txt") last_modified = HTTP.parse_time(initial_response.headers["Last-Modified"]).not_nil! headers = HTTP::Headers.new headers["If-Modified-Since"] = HTTP.format_time(last_modified + 1.hour) response = handle HTTP::Request.new("GET", "/test.txt", headers), ignore_body: true response.headers["Last-Modified"].should eq initial_response.headers["Last-Modified"] response.status_code.should eq(304) response.body.should eq "" end it "serves content for older than Last-Modified" do initial_response = handle HTTP::Request.new("GET", "/test.txt") last_modified = HTTP.parse_time(initial_response.headers["Last-Modified"]).not_nil! headers = HTTP::Headers.new headers["If-Modified-Since"] = HTTP.format_time(last_modified - 1.hour) response = handle HTTP::Request.new("GET", "/test.txt", headers), ignore_body: false response.headers["Last-Modified"].should eq initial_response.headers["Last-Modified"] response.status_code.should eq(200) response.body.should eq(File.read(datapath("static_file_handler", "test.txt"))) end end context "with If-None-Match header" do it "returns 304 Not Modified if header matches etag" do initial_response = handle HTTP::Request.new("GET", "/test.txt") headers = HTTP::Headers.new headers["If-None-Match"] = initial_response.headers["Etag"] response = handle HTTP::Request.new("GET", "/test.txt", headers), ignore_body: true response.status_code.should eq(304) end it "serves file if header does not match etag" do headers = HTTP::Headers.new headers["If-None-Match"] = "some random etag" response = handle HTTP::Request.new("GET", "/test.txt", headers), ignore_body: false response.status_code.should eq(200) response.body.should eq(File.read(datapath("static_file_handler", "test.txt"))) end it "returns 304 Not Modified if header is *" do headers = HTTP::Headers.new headers["If-None-Match"] = "*" response = handle HTTP::Request.new("GET", "/test.txt", headers), ignore_body: true response.status_code.should eq(304) end it "serves file if header is empty" do headers = HTTP::Headers.new headers["If-None-Match"] = "" response = handle HTTP::Request.new("GET", "/test.txt", headers), ignore_body: false response.status_code.should eq(200) response.body.should eq(File.read(datapath("static_file_handler", "test.txt"))) end it "serves file if header does not contain valid etag" do headers = HTTP::Headers.new headers["If-None-Match"] = ", foo" response = handle HTTP::Request.new("GET", "/test.txt", headers), ignore_body: false response.status_code.should eq(200) response.body.should eq(File.read(datapath("static_file_handler", "test.txt"))) end end context "with multiple If-None-Match header" do it "returns 304 Not Modified if at least one header matches etag" do initial_response = handle HTTP::Request.new("GET", "/test.txt") headers = HTTP::Headers.new headers["If-None-Match"] = %(,, ,W/"1234567" , , #{initial_response.headers["Etag"]},"12345678",%) response = handle HTTP::Request.new("GET", "/test.txt", headers), ignore_body: true response.status_code.should eq(304) end it "serves file if no header matches etag" do headers = HTTP::Headers.new headers["If-None-Match"] = "some random etag, 1234567" response = handle HTTP::Request.new("GET", "/test.txt", headers), ignore_body: false response.status_code.should eq(200) response.body.should eq(File.read(datapath("static_file_handler", "test.txt"))) end end context "with both If-None-Match and If-Modified-Since headers" do it "ignores If-Modified-Since as specified in RFC 7232" do initial_response = handle HTTP::Request.new("GET", "/test.txt") headers = HTTP::Headers.new headers["If-Modified-Since"] = HTTP.format_time(File.info(datapath("static_file_handler", "test.txt")).modification_time - 1.hour) headers["If-None-Match"] = initial_response.headers["Etag"] response = handle HTTP::Request.new("GET", "/test.txt", headers), ignore_body: true response.status_code.should eq(304) end it "serves a file if header does not match etag even If-Modified-Since is fresh" do initial_response = handle HTTP::Request.new("GET", "/test.txt") headers = HTTP::Headers.new headers["If-Modified-Since"] = initial_response.headers["Last-Modified"] headers["If-None-Match"] = "some random etag" response = handle HTTP::Request.new("GET", "/test.txt", headers), ignore_body: false response.status_code.should eq(200) response.body.should eq(File.read(datapath("static_file_handler", "test.txt"))) end end context "when a Range header is provided" do context "int range" do it "serves a byte range" do headers = HTTP::Headers{"Range" => "bytes=0-2"} response = handle HTTP::Request.new("GET", "/range.txt", headers) response.status_code.should eq(206) response.headers["Content-Range"]?.should eq "bytes 0-2/12" response.body.should eq "Hel" end it "serves a single byte" do headers = HTTP::Headers{"Range" => "bytes=0-0"} response = handle HTTP::Request.new("GET", "/range.txt", headers) response.status_code.should eq(206) response.headers["Content-Range"]?.should eq "bytes 0-0/12" response.body.should eq "H" end it "serves zero bytes" do headers = HTTP::Headers{"Range" => "bytes=0-0"} response = handle HTTP::Request.new("GET", "/empty.txt", headers) response.status_code.should eq(416) response.headers["Content-Range"]?.should eq "bytes */0" response.body.should eq "" end it "serves an open-ended byte range" do headers = HTTP::Headers{"Range" => "bytes=6-"} response = handle HTTP::Request.new("GET", "/range.txt", headers) response.status_code.should eq(206) response.headers["Content-Range"]?.should eq "bytes 6-11/12" response.body.should eq "world\n" end it "serves multiple byte ranges (separator without whitespace)" do headers = HTTP::Headers{"Range" => "bytes=0-1,6-7"} response = handle HTTP::Request.new("GET", "/range.txt", headers) response.status_code.should eq(206) response.headers["Content-Range"]?.should be_nil count = 0 MIME::Multipart.parse(response) do |headers, part| chunk = part.gets_to_end case range = headers["Content-Range"] when "bytes 0-1/12" chunk.should eq "He" when "bytes 6-7/12" chunk.should eq "wo" else fail "Unknown range: #{range.inspect}" end count += 1 end count.should eq 2 end it "serves multiple byte ranges (separator with whitespace)" do headers = HTTP::Headers{"Range" => "bytes=0-1, 6-7"} response = handle HTTP::Request.new("GET", "/range.txt", headers) response.status_code.should eq(206) response.headers["Content-Range"]?.should be_nil count = 0 MIME::Multipart.parse(response) do |headers, part| chunk = part.gets_to_end case range = headers["Content-Range"] when "bytes 0-1/12" chunk.should eq "He" when "bytes 6-7/12" chunk.should eq "wo" else fail "Unknown range: #{range.inspect}" end count += 1 end count.should eq 2 end it "end of the range is larger than the file size" do headers = HTTP::Headers{"Range" => "bytes=6-14"} response = handle HTTP::Request.new("GET", "/range.txt", headers) response.status_code.should eq 206 response.headers["Content-Range"]?.should eq "bytes 6-11/12" response.body.should eq "world\n" end it "start of the range is larger than the file size" do headers = HTTP::Headers{"Range" => "bytes=14-15"} response = handle HTTP::Request.new("GET", "/range.txt", headers) response.status_code.should eq 416 response.headers["Content-Range"]?.should eq "bytes */12" end it "start >= file_size" do headers = HTTP::Headers{"Range" => "bytes=12-"} response = handle HTTP::Request.new("GET", "/range.txt", headers) response.status_code.should eq(416) response.headers["Content-Range"]?.should eq "bytes */12" end end describe "suffix range" do it "partial" do headers = HTTP::Headers{"Range" => "bytes=-6"} response = handle HTTP::Request.new("GET", "/range.txt", headers) response.status_code.should eq(206) response.headers["Content-Range"]?.should eq "bytes 6-11/12" response.body.should eq "world\n" end it "more bytes than content" do headers = HTTP::Headers{"Range" => "bytes=-15"} response = handle HTTP::Request.new("GET", "/range.txt", headers) response.status_code.should eq(206) response.headers["Content-Range"]?.should eq "bytes 0-11/12" response.body.should eq "Hello world\n" end it "zero" do headers = HTTP::Headers{"Range" => "bytes=-0"} response = handle HTTP::Request.new("GET", "/range.txt", headers) response.status_code.should eq(400) response.headers["Content-Range"]?.should be_nil end it "zero" do headers = HTTP::Headers{"Range" => "bytes=-0"} response = handle HTTP::Request.new("GET", "/empty.txt", headers) response.status_code.should eq(400) response.headers["Content-Range"]?.should be_nil end it "empty file" do headers = HTTP::Headers{"Range" => "bytes=-1"} response = handle HTTP::Request.new("GET", "/empty.txt", headers) response.status_code.should eq(200) response.headers["Content-Range"]?.should be_nil end it "negative size" do headers = HTTP::Headers{"Range" => "bytes=--2"} response = handle HTTP::Request.new("GET", "/range.txt", headers) response.status_code.should eq(400) response.headers["Content-Range"]?.should be_nil end end describe "invalid Range syntax" do it "byte number without dash" do headers = HTTP::Headers{"Range" => "bytes=1"} response = handle HTTP::Request.new("GET", "/range.txt", headers) response.status_code.should eq(400) end it "start > end" do headers = HTTP::Headers{"Range" => "bytes=2-1"} response = handle HTTP::Request.new("GET", "/range.txt", headers) response.status_code.should eq(400) end it "negative end" do headers = HTTP::Headers{"Range" => "bytes=1--2"} response = handle HTTP::Request.new("GET", "/range.txt", headers) response.status_code.should eq(400) end it "open range with negative end" do headers = HTTP::Headers{"Range" => "bytes=--2"} response = handle HTTP::Request.new("GET", "/range.txt", headers) response.status_code.should eq(400) end it "open range with negative end" do headers = HTTP::Headers{"Range" => "bytes=--2"} response = handle HTTP::Request.new("GET", "/empty.txt", headers) response.status_code.should eq(400) end it "unsupported unit" do headers = HTTP::Headers{"Range" => "chars=1-2"} response = handle HTTP::Request.new("GET", "/range.txt", headers) response.status_code.should eq(416) response.headers["Content-Range"]?.should eq "bytes */12" end it "multiple dashes" do headers = HTTP::Headers{"Range" => "bytes=1-2-3"} response = handle HTTP::Request.new("GET", "/range.txt", headers) response.status_code.should eq(400) end it "not a number" do headers = HTTP::Headers{"Range" => "bytes=a-b"} response = handle HTTP::Request.new("GET", "/range.txt", headers) response.status_code.should eq(400) end it "not a range" do headers = HTTP::Headers{"Range" => "bytes=-"} response = handle HTTP::Request.new("GET", "/range.txt", headers) response.status_code.should eq(400) end end end it "lists directory's entries" do response = handle HTTP::Request.new("GET", "/") response.status_code.should eq(200) response.body.should match(/test.txt/) end it "does not list directory's entries when directory_listing is set to false" do response = handle HTTP::Request.new("GET", "/"), directory_listing: false response.status_code.should eq(404) end it "does not redirect directory when directory_listing=false" do response = handle HTTP::Request.new("GET", "/foo"), directory_listing: false response.status_code.should eq(404) end it "redirect directory when directory_listing=true" do response = handle HTTP::Request.new("GET", "/foo"), directory_listing: true response.status_code.should eq(302) response.headers["Location"].should eq "/foo/" end it "preserves uri components during redirect" do response = handle HTTP::Request.new("GET", "/foo?ami=kept#somefragment"), directory_listing: true response.status_code.should eq(302) response.headers["Location"].should eq "/foo/?ami=kept#somefragment" end it "does not double encode query parameters when redirecting" do response = handle HTTP::Request.new("GET", "/foo?k=%26k%3Dv"), directory_listing: true response.status_code.should eq(302) response.headers["Location"].should eq "/foo/?k=%26k%3Dv" end it "does 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 "does not serve a not found directory" do response = handle HTTP::Request.new("GET", "/not_found_dir/") response.status_code.should eq(404) end it "does not serve a file as directory" do response = handle HTTP::Request.new("GET", "/test.txt/") response.status_code.should eq(404) end it "handles only GET and HEAD method" do %w(GET HEAD).each do |method| response = handle HTTP::Request.new(method, "/test.txt") response.status_code.should eq(200) end %w(POST PUT DELETE).each do |method| response = handle HTTP::Request.new(method, "/test.txt") response.status_code.should eq(404) response = handle HTTP::Request.new(method, "/test.txt"), fallthrough: false response.status_code.should eq(405) response.headers["Allow"].should eq("GET, HEAD") end end it "expands a request path" do %w(../test.txt ../../test.txt test.txt/../test.txt a/./b/../c/../../test.txt).each do |path| response = handle HTTP::Request.new("GET", "/#{path}") response.status_code.should eq(302) response.headers["Location"].should eq("/test.txt") end # directory %w(.. ../ ../.. a/.. a/.././b/../).each do |path| response = handle HTTP::Request.new("GET", "/#{path}") response.status_code.should eq(302) response.headers["Location"].should eq("/") end end it "unescapes a request path" do %w(test%2Etxt %74%65%73%74%2E%74%78%74).each do |path| response = handle HTTP::Request.new("GET", "/#{path}") response.status_code.should eq(200) response.body.should eq(file_text) end %w(%2E%2E/test.txt found%2F%2E%2E%2Ftest%2Etxt).each do |path| response = handle HTTP::Request.new("GET", "/#{path}") response.status_code.should eq(302) response.headers["Location"].should eq("/test.txt") end end it "returns 400" do %w(%00 test.txt%00).each do |path| response = handle HTTP::Request.new("GET", "/#{path}") response.status_code.should eq(400) end end it "handles invalid redirect path" do response = handle HTTP::Request.new("GET", "test.txt%0A") response.status_code.should eq(302) response.headers["Location"].should eq "/test.txt%0A" response = handle HTTP::Request.new("GET", "/test.txt%0A") response.status_code.should eq(404) end it "serve compressed content" do modification_time = File.info(datapath("static_file_handler", "test.txt")).modification_time File.touch datapath("static_file_handler", "test.txt.gz"), modification_time + 1.second headers = HTTP::Headers{"Accept-Encoding" => "gzip"} response = handle HTTP::Request.new("GET", "/test.txt", headers), decompress: false response.headers["Content-Encoding"].should eq("gzip") end it "still serve compressed content when modification time is very close" do modification_time = File.info(datapath("static_file_handler", "test.txt")).modification_time File.touch datapath("static_file_handler", "test.txt.gz"), modification_time - 1.millisecond headers = HTTP::Headers{"Accept-Encoding" => "gzip"} response = handle HTTP::Request.new("GET", "/test.txt", headers), decompress: false response.headers["Content-Encoding"].should eq("gzip") end it "doesn't serve compressed content if older than raw file" do modification_time = File.info(datapath("static_file_handler", "test.txt")).modification_time File.touch datapath("static_file_handler", "test.txt.gz"), modification_time - 1.second headers = HTTP::Headers{"Accept-Encoding" => "gzip"} response = handle HTTP::Request.new("GET", "/test.txt", headers) response.headers["Content-Encoding"]?.should be_nil end it "returns 404 for file error" do with_tempdir do File.symlink("nonexistent.txt", "broken-symlink.txt") response = handle HTTP::Request.new("GET", "/broken-symlink.txt") response.status_code.should eq(404) end end it "returns 404 for unreadable file" do with_tempdir do File.write("forbidden.txt", "not for your eyes") File.chmod("forbidden.txt", File::Permissions::None) # FIXME: Setting permissions does not work on all systems. Even the # permissions recheck is not sufficient (see https://github.com/crystal-lang/crystal/pull/16025#issuecomment-3112225515). pending! if File.info("forbidden.txt").permissions.owner_read? || (File.read("forbidden.txt") rescue nil) response = handle HTTP::Request.new("GET", "/forbidden.txt"), directory: "." response.status_code.should eq(404) end end end