From 322d04fd98c9789a4fa9ffecd840440b181bc979 Mon Sep 17 00:00:00 2001 From: cjcolvar Date: Thu, 29 Mar 2018 09:55:58 -0400 Subject: [PATCH] [WIP] Handle byte range requests for local files (#2816) Handle byte range requests for local files --- .rubocop_fixme.yml | 1 + ...ocal_file_downloads_controller_behavior.rb | 86 +++++++++++++++++++ app/controllers/hyrax/downloads_controller.rb | 7 +- .../hyrax/downloads_controller_spec.rb | 42 +++++++++ 4 files changed, 132 insertions(+), 4 deletions(-) create mode 100644 app/controllers/concerns/hyrax/local_file_downloads_controller_behavior.rb diff --git a/.rubocop_fixme.yml b/.rubocop_fixme.yml index 428ccde77a..d8b93c6638 100644 --- a/.rubocop_fixme.yml +++ b/.rubocop_fixme.yml @@ -6,6 +6,7 @@ Metrics/ClassLength: Exclude: - 'app/controllers/hyrax/dashboard/collections_controller.rb' - 'app/controllers/hyrax/admin/admin_sets_controller.rb' + - 'app/controllers/hyrax/downloads_controller.rb' - 'app/controllers/hyrax/file_sets_controller.rb' - 'app/forms/hyrax/forms/permission_template_form.rb' - 'app/presenters/hyrax/work_show_presenter.rb' diff --git a/app/controllers/concerns/hyrax/local_file_downloads_controller_behavior.rb b/app/controllers/concerns/hyrax/local_file_downloads_controller_behavior.rb new file mode 100644 index 0000000000..ecc373ea2f --- /dev/null +++ b/app/controllers/concerns/hyrax/local_file_downloads_controller_behavior.rb @@ -0,0 +1,86 @@ +module Hyrax + module LocalFileDownloadsControllerBehavior + protected + + # Handle the HTTP show request + def send_local_content + response.headers['Accept-Ranges'] = 'bytes' + if request.head? + local_content_head + elsif request.headers['Range'] + send_range_for_local_file + else + send_local_file_contents + end + end + + # render an HTTP Range response + def send_range_for_local_file + _, range = request.headers['Range'].split('bytes=') + from, to = range.split('-').map(&:to_i) + to = local_file_size - 1 unless to + length = to - from + 1 + response.headers['Content-Range'] = "bytes #{from}-#{to}/#{local_file_size}" + response.headers['Content-Length'] = length.to_s + self.status = 206 + prepare_local_file_headers + # For derivatives stored on the local file system + send_data IO.binread(file, length, from), local_derivative_download_options.merge(status: status) + end + + def send_local_file_contents + self.status = 200 + prepare_local_file_headers + # For derivatives stored on the local file system + send_file file, local_derivative_download_options + end + + def local_file_size + File.size(file) + end + + def local_file_mime_type + mime_type_for(file) + end + + # @return [String] the filename + def local_file_name + params[:filename] || File.basename(file) || (asset.respond_to?(:label) && asset.label) + end + + def local_file_last_modified + File.mtime(file) if file.is_a? String + end + + # Override + # render an HTTP HEAD response + def local_content_head + response.headers['Content-Length'] = local_file_size.to_s + head :ok, content_type: local_file_mime_type + end + + # Override + def prepare_local_file_headers + send_file_headers! local_content_options + response.headers['Content-Type'] = local_file_mime_type + response.headers['Content-Length'] ||= local_file_size.to_s + # Prevent Rack::ETag from calculating a digest over body + response.headers['Last-Modified'] = local_file_last_modified.utc.strftime("%a, %d %b %Y %T GMT") + self.content_type = local_file_mime_type + end + + private + + # Override the Hydra::Controller::DownloadBehavior#content_options so that + # we have an attachement rather than 'inline' + def local_content_options + { type: local_file_mime_type, filename: local_file_name, disposition: 'attachment' } + end + + # Override this method if you want to change the options sent when downloading + # a derivative file + def local_derivative_download_options + { type: local_file_mime_type, filename: local_file_name, disposition: 'inline' } + end + end +end diff --git a/app/controllers/hyrax/downloads_controller.rb b/app/controllers/hyrax/downloads_controller.rb index 5057c28919..df64966144 100644 --- a/app/controllers/hyrax/downloads_controller.rb +++ b/app/controllers/hyrax/downloads_controller.rb @@ -1,6 +1,7 @@ module Hyrax class DownloadsController < ApplicationController include Hydra::Controller::DownloadBehavior + include Hyrax::LocalFileDownloadsControllerBehavior def self.default_content_path :original_file @@ -15,9 +16,7 @@ def show super when String # For derivatives stored on the local file system - response.headers['Accept-Ranges'] = 'bytes' - response.headers['Content-Length'] = File.size(file).to_s - send_file file, derivative_download_options + send_local_content else raise ActiveFedora::ObjectNotFoundError end @@ -55,7 +54,7 @@ def default_image # Loads the file specified by the HTTP parameter `:file`. # If this object does not have a file by that name, return the default file # as returned by {#default_file} - # @return [ActiveFedora::File, String, NilClass] Returns the file from the repository or a path to a file on the local file system, if it exists. + # @return [ActiveFedora::File, File, NilClass] Returns the file from the repository or a path to a file on the local file system, if it exists. def load_file file_reference = params[:file] return default_file unless file_reference diff --git a/spec/controllers/hyrax/downloads_controller_spec.rb b/spec/controllers/hyrax/downloads_controller_spec.rb index a61be6d980..790ecfc715 100644 --- a/spec/controllers/hyrax/downloads_controller_spec.rb +++ b/spec/controllers/hyrax/downloads_controller_spec.rb @@ -66,6 +66,48 @@ expect(ActiveFedora::Base).not_to receive(:find).with(file_set.id) get :show, params: { id: file_set, file: 'thumbnail' } end + + context "stream" do + it "head request" do + request.env["HTTP_RANGE"] = 'bytes=0-15' + head :show, params: { id: file_set, file: 'thumbnail' } + expect(response.headers['Content-Length']).to eq '4218' + expect(response.headers['Accept-Ranges']).to eq 'bytes' + expect(response.headers['Content-Type']).to start_with 'image/png' + end + + it "sends the whole thing" do + request.env["HTTP_RANGE"] = 'bytes=0-4217' + get :show, params: { id: file_set, file: 'thumbnail' } + expect(response.headers["Content-Range"]).to eq 'bytes 0-4217/4218' + expect(response.headers["Content-Length"]).to eq '4218' + expect(response.headers['Accept-Ranges']).to eq 'bytes' + expect(response.headers['Content-Type']).to start_with "image/png" + expect(response.headers["Content-Disposition"]).to eq "inline; filename=\"world.png\"" + expect(response.body).to eq content + expect(response.status).to eq 206 + end + + it "sends the whole thing when the range is open ended" do + request.env["HTTP_RANGE"] = 'bytes=0-' + get :show, params: { id: file_set, file: 'thumbnail' } + expect(response.body).to eq content + end + + it "gets a range not starting at the beginning" do + request.env["HTTP_RANGE"] = 'bytes=4200-4217' + get :show, params: { id: file_set, file: 'thumbnail' } + expect(response.headers["Content-Range"]).to eq 'bytes 4200-4217/4218' + expect(response.headers["Content-Length"]).to eq '18' + end + + it "gets a range not ending at the end" do + request.env["HTTP_RANGE"] = 'bytes=4-11' + get :show, params: { id: file_set, file: 'thumbnail' } + expect(response.headers["Content-Range"]).to eq 'bytes 4-11/4218' + expect(response.headers["Content-Length"]).to eq '8' + end + end end context "that isn't persisted" do