diff options
author | Jens Alfke <jens@mooseyard.com> | 2010-10-03 17:08:49 -0700 |
---|---|---|
committer | raggi <jftucker@gmail.com> | 2010-10-03 21:36:37 -0300 |
commit | 8859e5c35cac9631ed55bfab904bf7028db3260c (patch) | |
tree | b79ce26dfdf9b4bde742b37b586a04a1d2819a8d | |
parent | a2e420e1d2730487c95e1d9e5471a28e5c6cf32e (diff) | |
download | rack-8859e5c35cac9631ed55bfab904bf7028db3260c.tar.gz |
Byte-range support for File class.
Allows Rack to support byte-range requests (via the HTTP 1.1 "Range:" header) for static files, even when sendfile is not being used. Conforms to RFC 2616 sec. 14.35 _except_ that multiple byte-ranges are not supported yet. (They're parsed correctly, but the response body would need to be a MIME multipart.) Tested in Ruby 1.8.7 on Mac OS X 10.6.4. Signed-off-by: raggi <jftucker@gmail.com>
-rw-r--r-- | lib/rack/file.rb | 120 | ||||
-rw-r--r-- | test/spec_file.rb | 62 |
2 files changed, 146 insertions, 36 deletions
diff --git a/lib/rack/file.rb b/lib/rack/file.rb index 347955f5..6524e199 100644 --- a/lib/rack/file.rb +++ b/lib/rack/file.rb @@ -29,64 +29,112 @@ module Rack def _call(env) @path_info = Utils.unescape(env["PATH_INFO"]) - return forbidden if @path_info.include? ".." + return fail(403, "Forbidden") if @path_info.include? ".." @path = F.join(@root, @path_info) begin if F.file?(@path) && F.readable?(@path) - serving + serving(env) else raise Errno::EPERM end rescue SystemCallError - not_found + fail(404, "File not found: #{@path_info}") end end - def forbidden - body = "Forbidden\n" - [403, {"Content-Type" => "text/plain", - "Content-Length" => body.size.to_s, - "X-Cascade" => "pass"}, - [body]] - end + def serving(env) + # NOTE: + # We check via File::size? whether this file provides size info + # via stat (e.g. /proc files often don't), otherwise we have to + # figure it out by reading the whole file into memory. + size = F.size?(@path) || Utils.bytesize(F.read(@path)) - # NOTE: - # We check via File::size? whether this file provides size info - # via stat (e.g. /proc files often don't), otherwise we have to - # figure it out by reading the whole file into memory. And while - # we're at it we also use this as body then. + response = [200, { + "Last-Modified" => F.mtime(@path).httpdate, + "Content-Type" => Mime.mime_type(F.extname(@path), 'text/plain') + }, self] - def serving - if size = F.size?(@path) - body = self + ranges = File.byte_ranges(env, size) + if ranges.nil? || ranges.length > 1 + # No ranges, or multiple ranges (which we don't support): + # TODO: Support multiple byte-ranges + response[0] = 200 + @range = (0..size-1) + elsif ranges.empty? + # Unsatisfiable. Return error, and file size: + response = fail(416, "Byte range unsatisfiable") + response[1]["Content-Range"] = "bytes */#{size}" + return response else - body = [F.read(@path)] - size = Utils.bytesize(body.first) + # Partial content: + @range = ranges[0] + response[0] = 206 + response[1]["Content-Range"] = "bytes #{@range.begin}-#{@range.end}/#{size}" + size = @range.end - @range.begin + 1 end - [200, { - "Last-Modified" => F.mtime(@path).httpdate, - "Content-Type" => Mime.mime_type(F.extname(@path), 'text/plain'), - "Content-Length" => size.to_s - }, body] - end - - def not_found - body = "File not found: #{@path_info}\n" - [404, {"Content-Type" => "text/plain", - "Content-Length" => body.size.to_s, - "X-Cascade" => "pass"}, - [body]] + response[1]["Content-Length"] = size.to_s + return response end def each - F.open(@path, "rb") { |file| - while part = file.read(8192) + F.open(@path, "rb") do |file| + file.seek(@range.begin) + remaining_len = @range.end-@range.begin+1 + while remaining_len > 0 + part = file.read([8192, remaining_len].min) + break unless part + remaining_len -= part.length + yield part end - } + end + end + + # Parses the "Range:" header, if present, into an array of Range objects. + # Returns nil if the header is missing or syntactically invalid. + # Returns an empty array if none of the ranges are satisfiable. + def File.byte_ranges(env, size) + # See <http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.35> + http_range = env['HTTP_RANGE'] + return nil unless http_range + ranges = [] + for range_spec in http_range.split(/,\s*/) do + matches = range_spec.match(/bytes=(\d*)-(\d*)/) + return nil unless matches + r0,r1 = matches[1], matches[2] + if r0.empty? + return nil if r1.empty? + # suffix-byte-range-spec, represents trailing suffix of file + r0 = [size - r1.to_i, 0].max + r1 = size - 1 + else + r0 = r0.to_i + if r1.empty? then + r1 = size-1 + else + r1 = r1.to_i + return nil if r1 < r0 # backwards range is syntactically invalid + r1 = size-1 if r1 >= size + end + end + ranges << (r0..r1) if r0 <= r1 + end + return ranges end + + private + + def fail(status, body) + body += "\n" + [status, + {"Content-Type" => "text/plain", + "Content-Length" => body.size.to_s, + "X-Cascade" => "pass"}, + [body]] + end + end end diff --git a/test/spec_file.rb b/test/spec_file.rb index f191aa03..433a2d6f 100644 --- a/test/spec_file.rb +++ b/test/spec_file.rb @@ -68,4 +68,66 @@ describe Rack::File do body.should.respond_to :to_path body.to_path.should.equal path end + + should "ignore missing or syntactically invalid byte ranges" do + Rack::File.byte_ranges({},500).should.equal nil + Rack::File.byte_ranges({"HTTP_RANGE" => "foobar"},500).should.equal nil + Rack::File.byte_ranges({"HTTP_RANGE" => "furlongs=123-456"},500).should.equal nil + Rack::File.byte_ranges({"HTTP_RANGE" => "bytes="},500).should.equal nil + Rack::File.byte_ranges({"HTTP_RANGE" => "bytes=-"},500).should.equal nil + Rack::File.byte_ranges({"HTTP_RANGE" => "bytes=123,456"},500).should.equal nil + # A range of non-positive length is syntactically invalid and ignored: + Rack::File.byte_ranges({"HTTP_RANGE" => "bytes=456-123"},500).should.equal nil + Rack::File.byte_ranges({"HTTP_RANGE" => "bytes=456-455"},500).should.equal nil + end + + should "parse simple byte ranges" do + Rack::File.byte_ranges({"HTTP_RANGE" => "bytes=123-456"},500).should.equal [(123..456)] + Rack::File.byte_ranges({"HTTP_RANGE" => "bytes=123-"},500).should.equal [(123..499)] + Rack::File.byte_ranges({"HTTP_RANGE" => "bytes=-100"},500).should.equal [(400..499)] + Rack::File.byte_ranges({"HTTP_RANGE" => "bytes=0-0"},500).should.equal [(0..0)] + Rack::File.byte_ranges({"HTTP_RANGE" => "bytes=499-499"},500).should.equal [(499..499)] + end + + should "truncate byte ranges" do + Rack::File.byte_ranges({"HTTP_RANGE" => "bytes=123-999"},500).should.equal [(123..499)] + Rack::File.byte_ranges({"HTTP_RANGE" => "bytes=600-999"},500).should.equal [] + Rack::File.byte_ranges({"HTTP_RANGE" => "bytes=-999"},500).should.equal [(0..499)] + end + + should "ignore unsatisfiable byte ranges" do + Rack::File.byte_ranges({"HTTP_RANGE" => "bytes=500-501"},500).should.equal [] + Rack::File.byte_ranges({"HTTP_RANGE" => "bytes=500-"},500).should.equal [] + Rack::File.byte_ranges({"HTTP_RANGE" => "bytes=999-"},500).should.equal [] + Rack::File.byte_ranges({"HTTP_RANGE" => "bytes=-0"},500).should.equal [] + end + + should "handle byte ranges of empty files" do + Rack::File.byte_ranges({"HTTP_RANGE" => "bytes=123-456"},0).should.equal [] + Rack::File.byte_ranges({"HTTP_RANGE" => "bytes=0-"},0).should.equal [] + Rack::File.byte_ranges({"HTTP_RANGE" => "bytes=-100"},0).should.equal [] + Rack::File.byte_ranges({"HTTP_RANGE" => "bytes=0-0"},0).should.equal [] + Rack::File.byte_ranges({"HTTP_RANGE" => "bytes=-0"},0).should.equal [] + end + + should "return correct byte range in body" do + env = Rack::MockRequest.env_for("/cgi/test") + env["HTTP_RANGE"] = "bytes=22-33" + res = Rack::MockResponse.new(*Rack::File.new(DOCROOT).call(env)) + + res.status.should.equal 206 + res["Content-Length"].should.equal "12" + res["Content-Range"].should.equal "bytes 22-33/193" + res.body.should.equal "-*- ruby -*-" + end + + should "return error for unsatisfiable byte range" do + env = Rack::MockRequest.env_for("/cgi/test") + env["HTTP_RANGE"] = "bytes=1234-5678" + res = Rack::MockResponse.new(*Rack::File.new(DOCROOT).call(env)) + + res.status.should.equal 416 + res["Content-Range"].should.equal "bytes */193" + end + end |