Skip to content

Commit e3de2ad

Browse files
authored
Implement multiple partial ranges (#708)
1 parent 19d3913 commit e3de2ad

File tree

3 files changed

+172
-27
lines changed

3 files changed

+172
-27
lines changed

spec/helpers_spec.cr

+110
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,116 @@ describe "Macros" do
145145
response.status_code.should eq(200)
146146
response.headers["Content-Disposition"].should eq("attachment; filename=\"image.jpg\"")
147147
end
148+
149+
it "handles multiple range requests" do
150+
get "/" do |env|
151+
send_file env, "#{__DIR__}/asset/hello.ecr"
152+
end
153+
154+
headers = HTTP::Headers{"Range" => "bytes=0-4,7-11"}
155+
request = HTTP::Request.new("GET", "/", headers)
156+
response = call_request_on_app(request)
157+
158+
response.status_code.should eq(206)
159+
response.headers["Content-Type"].should match(/^multipart\/byteranges; boundary=kemal-/)
160+
response.headers["Accept-Ranges"].should eq("bytes")
161+
162+
# Verify multipart response structure
163+
body = response.body
164+
boundary = response.headers["Content-Type"].split("boundary=")[1]
165+
parts = body.split("--#{boundary}")
166+
# Parts structure:
167+
# 1. Empty part before first boundary
168+
# 2. First content part (0-4)
169+
# 3. Second content part (7-11)
170+
# 4. Trailing part after last boundary
171+
parts.size.should eq(4)
172+
173+
# First part (0-4)
174+
first_part = parts[1]
175+
first_part.should contain("Content-Type: multipart/byteranges")
176+
first_part.should contain("Content-Range: bytes 0-4/18")
177+
first_part.split("\r\n\r\n")[1].strip.should eq("Hello")
178+
179+
# Second part (7-11)
180+
second_part = parts[2]
181+
second_part.should contain("Content-Type: multipart/byteranges")
182+
second_part.should contain("Content-Range: bytes 7-11/18")
183+
second_part.split("\r\n\r\n")[1].strip.should eq("%= na")
184+
end
185+
186+
it "handles invalid range requests" do
187+
get "/" do |env|
188+
send_file env, "#{__DIR__}/asset/hello.ecr"
189+
end
190+
191+
# Invalid range format
192+
headers = HTTP::Headers{"Range" => "invalid"}
193+
request = HTTP::Request.new("GET", "/", headers)
194+
response = call_request_on_app(request)
195+
response.status_code.should eq(200)
196+
response.body.should eq(File.read("#{__DIR__}/asset/hello.ecr"))
197+
198+
# Range out of bounds
199+
headers = HTTP::Headers{"Range" => "bytes=100-200"}
200+
request = HTTP::Request.new("GET", "/", headers)
201+
response = call_request_on_app(request)
202+
response.status_code.should eq(200)
203+
response.body.should eq(File.read("#{__DIR__}/asset/hello.ecr"))
204+
205+
# Invalid range values
206+
headers = HTTP::Headers{"Range" => "bytes=5-3"}
207+
request = HTTP::Request.new("GET", "/", headers)
208+
response = call_request_on_app(request)
209+
response.status_code.should eq(200)
210+
response.body.should eq(File.read("#{__DIR__}/asset/hello.ecr"))
211+
end
212+
213+
it "handles empty range requests" do
214+
get "/" do |env|
215+
send_file env, "#{__DIR__}/asset/hello.ecr"
216+
end
217+
218+
headers = HTTP::Headers{"Range" => "bytes="}
219+
request = HTTP::Request.new("GET", "/", headers)
220+
response = call_request_on_app(request)
221+
response.status_code.should eq(200)
222+
response.body.should eq(File.read("#{__DIR__}/asset/hello.ecr"))
223+
end
224+
225+
it "handles overlapping ranges" do
226+
get "/" do |env|
227+
send_file env, "#{__DIR__}/asset/hello.ecr"
228+
end
229+
230+
headers = HTTP::Headers{"Range" => "bytes=0-5,3-8"}
231+
request = HTTP::Request.new("GET", "/", headers)
232+
response = call_request_on_app(request)
233+
234+
response.status_code.should eq(206)
235+
response.headers["Content-Type"].should match(/^multipart\/byteranges; boundary=kemal-/)
236+
237+
# Verify both ranges are included
238+
body = response.body
239+
boundary = response.headers["Content-Type"].split("boundary=")[1]
240+
parts = body.split("--#{boundary}")
241+
# Parts structure:
242+
# 1. Empty part before first boundary
243+
# 2. First content part (0-5)
244+
# 3. Second content part (3-8)
245+
# 4. Trailing part after last boundary
246+
parts.size.should eq(4)
247+
248+
# First part (0-5)
249+
first_part = parts[1]
250+
first_part.should contain("Content-Range: bytes 0-5/18")
251+
first_part.split("\r\n\r\n")[1].strip.should eq("Hello")
252+
253+
# Second part (3-8)
254+
second_part = parts[2]
255+
second_part.should contain("Content-Range: bytes 3-8/18")
256+
second_part.split("\r\n\r\n")[1].strip.should eq("lo <%=")
257+
end
148258
end
149259

150260
describe "#gzip" do

spec/static_file_handler_spec.cr

+14-16
Original file line numberDiff line numberDiff line change
@@ -113,29 +113,27 @@ describe Kemal::StaticFileHandler do
113113

114114
it "should send part of files when requested (RFC7233)" do
115115
%w(POST PUT DELETE HEAD).each do |method|
116-
headers = HTTP::Headers{"Range" => "0-100"}
116+
headers = HTTP::Headers{"Range" => "bytes=0-4"}
117117
response = handle HTTP::Request.new(method, "/dir/test.txt", headers)
118118
response.status_code.should_not eq(206)
119119
response.headers.has_key?("Content-Range").should eq(false)
120120
end
121121

122122
%w(GET).each do |method|
123-
headers = HTTP::Headers{"Range" => "0-100"}
123+
headers = HTTP::Headers{"Range" => "bytes=0-4"}
124124
response = handle HTTP::Request.new(method, "/dir/test.txt", headers)
125-
response.status_code.should eq(206 || 200)
126-
if response.status_code == 206
127-
response.headers.has_key?("Content-Range").should eq true
128-
match = response.headers["Content-Range"].match(/bytes (\d+)-(\d+)\/(\d+)/)
129-
match.should_not be_nil
130-
if match
131-
start_range = match[1].to_i { 0 }
132-
end_range = match[2].to_i { 0 }
133-
range_size = match[3].to_i { 0 }
134-
135-
range_size.should eq file_size
136-
(end_range < file_size).should eq true
137-
(start_range < end_range).should eq true
138-
end
125+
response.status_code.should eq(206)
126+
response.headers.has_key?("Content-Range").should eq true
127+
match = response.headers["Content-Range"].match(/bytes (\d+)-(\d+)\/(\d+)/)
128+
match.should_not be_nil
129+
if match
130+
start_range = match[1].to_i { 0 }
131+
end_range = match[2].to_i { 0 }
132+
range_size = match[3].to_i { 0 }
133+
134+
range_size.should eq file_size
135+
(end_range < file_size).should eq true
136+
(start_range < end_range).should eq true
139137
end
140138
end
141139
end

src/kemal/helpers/helpers.cr

+48-11
Original file line numberDiff line numberDiff line change
@@ -212,31 +212,68 @@ end
212212
private def multipart(file, env : HTTP::Server::Context)
213213
# See http://httpwg.org/specs/rfc7233.html
214214
fileb = file.size
215-
startb = endb = 0_i64
215+
ranges = parse_ranges(env.request.headers["Range"]?, fileb)
216216

217-
if match = env.request.headers["Range"].match /bytes=(\d{1,})-(\d{0,})/
218-
startb = match[1].to_i64 { 0_i64 } if match.size >= 2
219-
endb = match[2].to_i64 { 0_i64 } if match.size >= 3
217+
if ranges.empty?
218+
env.response.content_length = fileb
219+
env.response.status_code = 200 # Range not satisfiable
220+
IO.copy(file, env.response)
221+
return
220222
end
221223

222-
endb = fileb - 1 if endb == 0
223-
224-
if startb < endb < fileb
224+
if ranges.size == 1
225+
# Single range - send as regular partial content
226+
startb, endb = ranges[0]
225227
content_length = 1_i64 + endb - startb
226228
env.response.status_code = 206
227229
env.response.content_length = content_length
228230
env.response.headers["Accept-Ranges"] = "bytes"
229-
env.response.headers["Content-Range"] = "bytes #{startb}-#{endb}/#{fileb}" # MUST
231+
env.response.headers["Content-Range"] = "bytes #{startb}-#{endb}/#{fileb}"
230232

231233
file.seek(startb)
232234
IO.copy(file, env.response, content_length)
233235
else
234-
env.response.content_length = fileb
235-
env.response.status_code = 200 # Range not satisfable, see 4.4 Note
236-
IO.copy(file, env.response)
236+
# Multiple ranges - send as multipart/byteranges
237+
boundary = "kemal-#{Random::Secure.hex(16)}"
238+
env.response.content_type = "multipart/byteranges; boundary=#{boundary}"
239+
env.response.status_code = 206
240+
env.response.headers["Accept-Ranges"] = "bytes"
241+
242+
ranges.each do |start_byte, end_byte|
243+
env.response.print "--#{boundary}\r\n"
244+
env.response.print "Content-Type: #{env.response.headers["Content-Type"]}\r\n"
245+
env.response.print "Content-Range: bytes #{start_byte}-#{end_byte}/#{fileb}\r\n"
246+
env.response.print "\r\n"
247+
248+
file.seek(start_byte)
249+
IO.copy(file, env.response, 1_i64 + end_byte - start_byte)
250+
env.response.print "\r\n"
251+
end
252+
env.response.print "--#{boundary}--\r\n"
237253
end
238254
end
239255

256+
private def parse_ranges(range_header : String?, file_size : Int64) : Array({Int64, Int64})
257+
return [] of {Int64, Int64} unless range_header
258+
259+
ranges = [] of {Int64, Int64}
260+
return ranges unless range_header.starts_with?("bytes=")
261+
262+
range_header[6..].split(",").each do |range|
263+
if match = range.match /(\d{1,})-(\d{0,})/
264+
startb = match[1].to_i64 { 0_i64 }
265+
endb = match[2].to_i64 { 0_i64 }
266+
endb = file_size - 1 if endb == 0
267+
268+
if startb < endb && endb < file_size
269+
ranges << {startb, endb}
270+
end
271+
end
272+
end
273+
274+
ranges
275+
end
276+
240277
# Set the Content-Disposition to "attachment" with the specified filename,
241278
# instructing the user agents to prompt to save.
242279
private def attachment(env : HTTP::Server::Context, filename : String? = nil, disposition : String? = nil)

0 commit comments

Comments
 (0)