Skip to content

Commit

Permalink
Support multipart range requests
Browse files Browse the repository at this point in the history
  • Loading branch information
fatkodima authored and jeremyevans committed Jan 27, 2020
1 parent 0155690 commit 756708f
Show file tree
Hide file tree
Showing 2 changed files with 102 additions and 36 deletions.
112 changes: 76 additions & 36 deletions lib/rack/files.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ module Rack
class Files
ALLOWED_VERBS = %w[GET HEAD OPTIONS]
ALLOW_HEADER = ALLOWED_VERBS.join(', ')
MULTIPART_BOUNDARY = 'AaB03x'

attr_reader :root

Expand Down Expand Up @@ -70,69 +71,108 @@ def serving(request, path)
headers[CONTENT_TYPE] = mime_type if mime_type

# Set custom headers
@headers.each { |field, content| headers[field] = content } if @headers

response = [ 200, headers ]
headers.merge!(@headers) if @headers

status = 200
size = filesize path

range = nil
ranges = Rack::Utils.get_byte_ranges(request.get_header('HTTP_RANGE'), 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
if ranges.nil?
# No ranges:
ranges = [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
# Partial content:
elsif ranges.size >= 1
# Partial content
partial_content = true
range = ranges[0]
response[0] = 206
response[1]["Content-Range"] = "bytes #{range.begin}-#{range.end}/#{size}"
size = range.end - range.begin + 1

if ranges.size == 1
range = ranges[0]
headers["Content-Range"] = "bytes #{range.begin}-#{range.end}/#{size}"
else
headers[CONTENT_TYPE] = "multipart/byteranges; boundary=#{MULTIPART_BOUNDARY}"
end

status = 206
body = BaseIterator.new(path, ranges, mime_type: mime_type, size: size)
size = body.bytesize
end

response[2] = [response_body] unless response_body.nil?
headers[CONTENT_LENGTH] = size.to_s

response[1][CONTENT_LENGTH] = size.to_s
response[2] = if request.head?
[]
elsif partial_content
BaseIterator.new path, range
else
Iterator.new path, range
if request.head?
body = []
elsif !partial_content
body = Iterator.new(path, ranges, mime_type: mime_type, size: size)
end
response

[status, headers, body]
end

class BaseIterator
attr_reader :path, :range
attr_reader :path, :ranges, :options

def initialize path, range
@path = path
@range = range
def initialize(path, ranges, options)
@path = path
@ranges = ranges
@options = options
end

def each
::File.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
ranges.each do |range|
yield multipart_heading(range) if multipart?

each_range_part(file, range) do |part|
yield part
end
end

yield "\r\n--#{MULTIPART_BOUNDARY}--\r\n" if multipart?
end
end

def bytesize
size = ranges.inject(0) do |sum, range|
sum += multipart_heading(range).bytesize if multipart?
sum += range.size
end
size += "\r\n--#{MULTIPART_BOUNDARY}--\r\n".bytesize if multipart?
size
end

def close; end

private

def multipart?
ranges.size > 1
end

def multipart_heading(range)
<<-EOF
\r
--#{MULTIPART_BOUNDARY}\r
Content-Type: #{options[:mime_type]}\r
Content-Range: bytes #{range.begin}-#{range.end}/#{options[:size]}\r
\r
EOF
end

def each_range_part(file, range)
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

class Iterator < BaseIterator
Expand Down
26 changes: 26 additions & 0 deletions test/spec_files.rb
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,32 @@ def files(*args)
res.body.must_equal "frozen_strin"
end

it "return correct multiple byte ranges in body" do
env = Rack::MockRequest.env_for("/cgi/test")
env["HTTP_RANGE"] = "bytes=22-33, 60-80"
res = Rack::MockResponse.new(*files(DOCROOT).call(env))

res.status.must_equal 206
res["Content-Length"].must_equal "191"
res["Content-Type"].must_equal "multipart/byteranges; boundary=AaB03x"
expected_body = <<-EOF
\r
--AaB03x\r
Content-Type: text/plain\r
Content-Range: bytes 22-33/208\r
\r
frozen_strin\r
--AaB03x\r
Content-Type: text/plain\r
Content-Range: bytes 60-80/208\r
\r
e.join(File.dirname(_\r
--AaB03x--\r
EOF

res.body.must_equal expected_body
end

it "return error for unsatisfiable byte range" do
env = Rack::MockRequest.env_for("/cgi/test")
env["HTTP_RANGE"] = "bytes=1234-5678"
Expand Down

0 comments on commit 756708f

Please sign in to comment.