Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support multipart range requests #1420

Merged
merged 1 commit into from
Jan 27, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we encode this as a string rather than a heredoc.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried to stick here with existing code styles, like

def content_for_tempfile(io, file, name)
<<-EOF
--#{MULTIPART_BOUNDARY}\r
Content-Disposition: form-data; name="#{name}"; filename="#{Utils.escape(file.original_filename)}"\r
Content-Type: #{file.content_type}\r
Content-Length: #{::File.stat(file.path).size}\r
\r
#{io.read}\r
EOF
end
def content_for_other(file, name)
<<-EOF
--#{MULTIPART_BOUNDARY}\r
Content-Disposition: form-data; name="#{name}"\r
\r
#{file}\r
EOF
end

and like examples in many test cases.
So should I change this to string?

\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