Skip to content

Commit fe4ec2a

Browse files
authored
Merge pull request rails#41437 from tomprats/active-storage-byte-range
Added Active Storage support for byte ranges
2 parents 0124428 + fcc4622 commit fe4ec2a

File tree

5 files changed

+105
-2
lines changed

5 files changed

+105
-2
lines changed

activestorage/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
* Add support for byte range requests
2+
3+
*Tom Prats*
4+
15
* Attachments can be deleted after their association is no longer defined.
26

37
Fixes #42514

activestorage/app/controllers/active_storage/blobs/proxy_controller.rb

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,15 @@ class ActiveStorage::Blobs::ProxyController < ActiveStorage::BaseController
1010
include ActiveStorage::SetBlob
1111

1212
def show
13-
http_cache_forever public: true do
14-
send_blob_stream @blob
13+
if request.headers["Range"].present?
14+
send_blob_byte_range_data @blob, request.headers["Range"]
15+
else
16+
http_cache_forever public: true do
17+
response.headers["Accept-Ranges"] = "bytes"
18+
response.headers["Content-Length"] = @blob.byte_size.to_s
19+
20+
send_blob_stream @blob
21+
end
1522
end
1623
end
1724
end

activestorage/app/controllers/concerns/active_storage/streaming.rb

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,55 @@
11
# frozen_string_literal: true
22

3+
require "securerandom"
4+
35
module ActiveStorage::Streaming
46
DEFAULT_BLOB_STREAMING_DISPOSITION = "inline"
57

8+
include ActionController::DataStreaming
69
include ActionController::Live
710

811
private
12+
# Stream the blob in byte ranges specified through the header
13+
def send_blob_byte_range_data(blob, range_header, disposition: nil) #:doc:
14+
ranges = Rack::Utils.get_byte_ranges(range_header, blob.byte_size)
15+
16+
return head(:range_not_satisfiable) if ranges.blank? || ranges.all?(&:blank?)
17+
18+
if ranges.length == 1
19+
range = ranges.first
20+
content_type = blob.content_type_for_serving
21+
data = blob.download_chunk(range)
22+
23+
response.headers["Content-Range"] = "bytes #{range.begin}-#{range.end}/#{blob.byte_size}"
24+
else
25+
boundary = SecureRandom.hex
26+
content_type = "multipart/byteranges; boundary=#{boundary}"
27+
data = +""
28+
29+
ranges.compact.each do |range|
30+
chunk = blob.download_chunk(range)
31+
32+
data << "\r\n--#{boundary}\r\n"
33+
data << "Content-Type: #{blob.content_type_for_serving}\r\n"
34+
data << "Content-Range: bytes #{range.begin}-#{range.end}/#{blob.byte_size}\r\n\r\n"
35+
data << chunk
36+
end
37+
38+
data << "\r\n--#{boundary}--\r\n"
39+
end
40+
41+
response.headers["Accept-Ranges"] = "bytes"
42+
response.headers["Content-Length"] = data.length.to_s
43+
44+
send_data(
45+
data,
46+
disposition: blob.forced_disposition_for_serving || disposition || DEFAULT_BLOB_STREAMING_DISPOSITION,
47+
filename: blob.filename.sanitized,
48+
status: :partial_content,
49+
type: content_type
50+
)
51+
end
52+
953
# Stream the blob from storage directly to the response. The disposition can be controlled by setting +disposition+.
1054
# The content type and filename is set directly from the +blob+.
1155
def send_blob_stream(blob, disposition: nil) # :doc:

activestorage/app/models/active_storage/blob.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,11 @@ def download(&block)
253253
service.download key, &block
254254
end
255255

256+
# Downloads a part of the file associated with this blob.
257+
def download_chunk(range)
258+
service.download_chunk key, range
259+
end
260+
256261
# Downloads the blob to a tempfile on disk. Yields the tempfile.
257262
#
258263
# The tempfile's name is prefixed with +ActiveStorage-+ and the blob's ID. Its extension matches that of the blob.

activestorage/test/controllers/blobs/proxy_controller_test.rb

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
require "test_helper"
44
require "database/setup"
5+
require "minitest/mock"
56

67
class ActiveStorage::Blobs::ProxyControllerTest < ActionDispatch::IntegrationTest
78
test "invalid signed ID" do
@@ -36,6 +37,48 @@ class ActiveStorage::Blobs::ProxyControllerTest < ActionDispatch::IntegrationTes
3637
get url
3738
assert_response :not_found
3839
end
40+
41+
test "single Byte Range" do
42+
get rails_storage_proxy_url(create_file_blob(filename: "racecar.jpg")), headers: { "Range" => "bytes=5-9" }
43+
assert_response :partial_content
44+
assert_equal "5", response.headers["Content-Length"]
45+
assert_equal "bytes 5-9/1124062", response.headers["Content-Range"]
46+
assert_equal "image/jpeg", response.headers["Content-Type"]
47+
assert_equal " Exif", response.body
48+
end
49+
50+
test "invalid Byte Range" do
51+
get rails_storage_proxy_url(create_file_blob(filename: "racecar.jpg")), headers: { "Range" => "bytes=*/1234" }
52+
assert_response :range_not_satisfiable
53+
end
54+
55+
test "multiple Byte Ranges" do
56+
boundary = SecureRandom.hex
57+
SecureRandom.stub :hex, boundary do
58+
get rails_storage_proxy_url(create_file_blob(filename: "racecar.jpg")), headers: { "Range" => "bytes=5-9,13-17" }
59+
assert_response :partial_content
60+
assert_equal "252", response.headers["Content-Length"]
61+
assert_equal "multipart/byteranges; boundary=#{boundary}", response.headers["Content-Type"]
62+
assert_equal(
63+
[
64+
"",
65+
"--#{boundary}",
66+
"Content-Type: image/jpeg",
67+
"Content-Range: bytes 5-9/1124062",
68+
"",
69+
" Exif",
70+
"--#{boundary}",
71+
"Content-Type: image/jpeg",
72+
"Content-Range: bytes 13-17/1124062",
73+
"",
74+
"I*\u0000\b\u0000",
75+
"--#{boundary}--",
76+
""
77+
].join("\r\n"),
78+
response.body
79+
)
80+
end
81+
end
3982
end
4083

4184
class ActiveStorage::Blobs::ExpiringProxyControllerTest < ActionDispatch::IntegrationTest

0 commit comments

Comments
 (0)