diff --git a/activestorage/CHANGELOG.md b/activestorage/CHANGELOG.md index 096d1bb8e5..11d952ec96 100644 --- a/activestorage/CHANGELOG.md +++ b/activestorage/CHANGELOG.md @@ -1,3 +1,7 @@ +* Add support for byte range requests + + *Tom Prats* + * Attachments can be deleted after their association is no longer defined. Fixes #42514 diff --git a/activestorage/app/controllers/active_storage/blobs/proxy_controller.rb b/activestorage/app/controllers/active_storage/blobs/proxy_controller.rb index 50cdbf1f50..0073517db3 100644 --- a/activestorage/app/controllers/active_storage/blobs/proxy_controller.rb +++ b/activestorage/app/controllers/active_storage/blobs/proxy_controller.rb @@ -10,8 +10,15 @@ class ActiveStorage::Blobs::ProxyController < ActiveStorage::BaseController include ActiveStorage::SetBlob def show - http_cache_forever public: true do - send_blob_stream @blob + if request.headers["Range"].present? + send_blob_byte_range_data @blob, request.headers["Range"] + else + http_cache_forever public: true do + response.headers["Accept-Ranges"] = "bytes" + response.headers["Content-Length"] = @blob.byte_size.to_s + + send_blob_stream @blob + end end end end diff --git a/activestorage/app/controllers/concerns/active_storage/streaming.rb b/activestorage/app/controllers/concerns/active_storage/streaming.rb index dc1707eb56..f67d85b2ef 100644 --- a/activestorage/app/controllers/concerns/active_storage/streaming.rb +++ b/activestorage/app/controllers/concerns/active_storage/streaming.rb @@ -1,11 +1,55 @@ # frozen_string_literal: true +require "securerandom" + module ActiveStorage::Streaming DEFAULT_BLOB_STREAMING_DISPOSITION = "inline" + include ActionController::DataStreaming include ActionController::Live private + # Stream the blob in byte ranges specified through the header + def send_blob_byte_range_data(blob, range_header, disposition: nil) #:doc: + ranges = Rack::Utils.get_byte_ranges(range_header, blob.byte_size) + + return head(:range_not_satisfiable) if ranges.blank? || ranges.all?(&:blank?) + + if ranges.length == 1 + range = ranges.first + content_type = blob.content_type_for_serving + data = blob.download_chunk(range) + + response.headers["Content-Range"] = "bytes #{range.begin}-#{range.end}/#{blob.byte_size}" + else + boundary = SecureRandom.hex + content_type = "multipart/byteranges; boundary=#{boundary}" + data = +"" + + ranges.compact.each do |range| + chunk = blob.download_chunk(range) + + data << "\r\n--#{boundary}\r\n" + data << "Content-Type: #{blob.content_type_for_serving}\r\n" + data << "Content-Range: bytes #{range.begin}-#{range.end}/#{blob.byte_size}\r\n\r\n" + data << chunk + end + + data << "\r\n--#{boundary}--\r\n" + end + + response.headers["Accept-Ranges"] = "bytes" + response.headers["Content-Length"] = data.length.to_s + + send_data( + data, + disposition: blob.forced_disposition_for_serving || disposition || DEFAULT_BLOB_STREAMING_DISPOSITION, + filename: blob.filename.sanitized, + status: :partial_content, + type: content_type + ) + end + # Stream the blob from storage directly to the response. The disposition can be controlled by setting +disposition+. # The content type and filename is set directly from the +blob+. def send_blob_stream(blob, disposition: nil) #:doc: diff --git a/activestorage/app/models/active_storage/blob.rb b/activestorage/app/models/active_storage/blob.rb index 4df68b8dce..89a2436550 100644 --- a/activestorage/app/models/active_storage/blob.rb +++ b/activestorage/app/models/active_storage/blob.rb @@ -253,6 +253,11 @@ class ActiveStorage::Blob < ActiveStorage::Record service.download key, &block end + # Downloads a part of the file associated with this blob. + def download_chunk(range) + service.download_chunk key, range + end + # Downloads the blob to a tempfile on disk. Yields the tempfile. # # The tempfile's name is prefixed with +ActiveStorage-+ and the blob's ID. Its extension matches that of the blob. diff --git a/activestorage/test/controllers/blobs/proxy_controller_test.rb b/activestorage/test/controllers/blobs/proxy_controller_test.rb index a2ff505d73..83bcf3f0f8 100644 --- a/activestorage/test/controllers/blobs/proxy_controller_test.rb +++ b/activestorage/test/controllers/blobs/proxy_controller_test.rb @@ -2,6 +2,7 @@ require "test_helper" require "database/setup" +require "minitest/mock" class ActiveStorage::Blobs::ProxyControllerTest < ActionDispatch::IntegrationTest test "invalid signed ID" do @@ -36,6 +37,48 @@ class ActiveStorage::Blobs::ProxyControllerTest < ActionDispatch::IntegrationTes get url assert_response :not_found end + + test "single Byte Range" do + get rails_storage_proxy_url(create_file_blob(filename: "racecar.jpg")), headers: { "Range" => "bytes=5-9" } + assert_response :partial_content + assert_equal "5", response.headers["Content-Length"] + assert_equal "bytes 5-9/1124062", response.headers["Content-Range"] + assert_equal "image/jpeg", response.headers["Content-Type"] + assert_equal " Exif", response.body + end + + test "invalid Byte Range" do + get rails_storage_proxy_url(create_file_blob(filename: "racecar.jpg")), headers: { "Range" => "bytes=*/1234" } + assert_response :range_not_satisfiable + end + + test "multiple Byte Ranges" do + boundary = SecureRandom.hex + SecureRandom.stub :hex, boundary do + get rails_storage_proxy_url(create_file_blob(filename: "racecar.jpg")), headers: { "Range" => "bytes=5-9,13-17" } + assert_response :partial_content + assert_equal "252", response.headers["Content-Length"] + assert_equal "multipart/byteranges; boundary=#{boundary}", response.headers["Content-Type"] + assert_equal( + [ + "", + "--#{boundary}", + "Content-Type: image/jpeg", + "Content-Range: bytes 5-9/1124062", + "", + " Exif", + "--#{boundary}", + "Content-Type: image/jpeg", + "Content-Range: bytes 13-17/1124062", + "", + "I*\u0000\b\u0000", + "--#{boundary}--", + "" + ].join("\r\n"), + response.body + ) + end + end end class ActiveStorage::Blobs::ExpiringProxyControllerTest < ActionDispatch::IntegrationTest