activestorage 0.1 → 5.2.0.beta1

Sign up to get free protection for your applications and to get access to all the features.

Potentially problematic release.


This version of activestorage might be problematic. Click here for more details.

Files changed (85) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +5 -0
  3. data/README.md +94 -25
  4. data/app/assets/javascripts/activestorage.js +1 -0
  5. data/app/controllers/active_storage/blobs_controller.rb +16 -0
  6. data/app/controllers/active_storage/direct_uploads_controller.rb +23 -0
  7. data/app/controllers/active_storage/disk_controller.rb +51 -0
  8. data/app/controllers/active_storage/previews_controller.rb +12 -0
  9. data/app/controllers/active_storage/variants_controller.rb +16 -0
  10. data/app/javascript/activestorage/blob_record.js +54 -0
  11. data/app/javascript/activestorage/blob_upload.js +35 -0
  12. data/app/javascript/activestorage/direct_upload.js +42 -0
  13. data/app/javascript/activestorage/direct_upload_controller.js +67 -0
  14. data/app/javascript/activestorage/direct_uploads_controller.js +50 -0
  15. data/app/javascript/activestorage/file_checksum.js +53 -0
  16. data/app/javascript/activestorage/helpers.js +42 -0
  17. data/app/javascript/activestorage/index.js +11 -0
  18. data/app/javascript/activestorage/ujs.js +75 -0
  19. data/app/jobs/active_storage/analyze_job.rb +8 -0
  20. data/app/jobs/active_storage/base_job.rb +5 -0
  21. data/app/jobs/active_storage/purge_job.rb +11 -0
  22. data/app/models/active_storage/attachment.rb +35 -0
  23. data/app/models/active_storage/blob.rb +313 -0
  24. data/app/models/active_storage/filename.rb +73 -0
  25. data/app/models/active_storage/filename/parameters.rb +36 -0
  26. data/app/models/active_storage/preview.rb +90 -0
  27. data/app/models/active_storage/variant.rb +86 -0
  28. data/app/models/active_storage/variation.rb +67 -0
  29. data/config/routes.rb +43 -0
  30. data/lib/active_storage.rb +37 -2
  31. data/lib/active_storage/analyzer.rb +33 -0
  32. data/lib/active_storage/analyzer/image_analyzer.rb +36 -0
  33. data/lib/active_storage/analyzer/null_analyzer.rb +13 -0
  34. data/lib/active_storage/analyzer/video_analyzer.rb +79 -0
  35. data/lib/active_storage/attached.rb +28 -22
  36. data/lib/active_storage/attached/macros.rb +89 -16
  37. data/lib/active_storage/attached/many.rb +53 -21
  38. data/lib/active_storage/attached/one.rb +74 -20
  39. data/lib/active_storage/downloading.rb +26 -0
  40. data/lib/active_storage/engine.rb +72 -0
  41. data/lib/active_storage/gem_version.rb +17 -0
  42. data/lib/active_storage/log_subscriber.rb +52 -0
  43. data/lib/active_storage/previewer.rb +58 -0
  44. data/lib/active_storage/previewer/pdf_previewer.rb +17 -0
  45. data/lib/active_storage/previewer/video_previewer.rb +23 -0
  46. data/lib/active_storage/service.rb +112 -24
  47. data/lib/active_storage/service/azure_storage_service.rb +124 -0
  48. data/lib/active_storage/service/configurator.rb +32 -0
  49. data/lib/active_storage/service/disk_service.rb +103 -44
  50. data/lib/active_storage/service/gcs_service.rb +87 -29
  51. data/lib/active_storage/service/mirror_service.rb +38 -22
  52. data/lib/active_storage/service/s3_service.rb +83 -38
  53. data/lib/active_storage/version.rb +10 -0
  54. data/lib/tasks/activestorage.rake +4 -15
  55. metadata +64 -108
  56. data/.gitignore +0 -1
  57. data/Gemfile +0 -11
  58. data/Gemfile.lock +0 -235
  59. data/Rakefile +0 -11
  60. data/activestorage.gemspec +0 -21
  61. data/lib/active_storage/attachment.rb +0 -30
  62. data/lib/active_storage/blob.rb +0 -80
  63. data/lib/active_storage/disk_controller.rb +0 -28
  64. data/lib/active_storage/download.rb +0 -90
  65. data/lib/active_storage/filename.rb +0 -31
  66. data/lib/active_storage/migration.rb +0 -28
  67. data/lib/active_storage/purge_job.rb +0 -10
  68. data/lib/active_storage/railtie.rb +0 -56
  69. data/lib/active_storage/storage_services.yml +0 -27
  70. data/lib/active_storage/verified_key_with_expiration.rb +0 -24
  71. data/test/attachments_test.rb +0 -95
  72. data/test/blob_test.rb +0 -28
  73. data/test/database/create_users_migration.rb +0 -7
  74. data/test/database/setup.rb +0 -6
  75. data/test/disk_controller_test.rb +0 -34
  76. data/test/filename_test.rb +0 -36
  77. data/test/service/.gitignore +0 -1
  78. data/test/service/configurations-example.yml +0 -11
  79. data/test/service/disk_service_test.rb +0 -8
  80. data/test/service/gcs_service_test.rb +0 -20
  81. data/test/service/mirror_service_test.rb +0 -50
  82. data/test/service/s3_service_test.rb +0 -11
  83. data/test/service/shared_service_tests.rb +0 -68
  84. data/test/test_helper.rb +0 -28
  85. data/test/verified_key_with_expiration_test.rb +0 -19
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveStorage
4
+ module Downloading
5
+ private
6
+ # Opens a new tempfile in #tempdir and copies blob data into it. Yields the tempfile.
7
+ def download_blob_to_tempfile # :doc:
8
+ Tempfile.open("ActiveStorage", tempdir) do |file|
9
+ download_blob_to file
10
+ yield file
11
+ end
12
+ end
13
+
14
+ # Efficiently downloads blob data into the given file.
15
+ def download_blob_to(file) # :doc:
16
+ file.binmode
17
+ blob.download { |chunk| file.write(chunk) }
18
+ file.rewind
19
+ end
20
+
21
+ # Returns the directory in which tempfiles should be opened. Defaults to +Dir.tmpdir+.
22
+ def tempdir # :doc:
23
+ Dir.tmpdir
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails"
4
+ require "active_storage"
5
+
6
+ require "active_storage/previewer/pdf_previewer"
7
+ require "active_storage/previewer/video_previewer"
8
+
9
+ require "active_storage/analyzer/image_analyzer"
10
+ require "active_storage/analyzer/video_analyzer"
11
+
12
+ module ActiveStorage
13
+ class Engine < Rails::Engine # :nodoc:
14
+ isolate_namespace ActiveStorage
15
+
16
+ config.active_storage = ActiveSupport::OrderedOptions.new
17
+ config.active_storage.previewers = [ ActiveStorage::Previewer::PDFPreviewer, ActiveStorage::Previewer::VideoPreviewer ]
18
+ config.active_storage.analyzers = [ ActiveStorage::Analyzer::ImageAnalyzer, ActiveStorage::Analyzer::VideoAnalyzer ]
19
+
20
+ config.eager_load_namespaces << ActiveStorage
21
+
22
+ initializer "active_storage.configs" do
23
+ config.after_initialize do |app|
24
+ ActiveStorage.logger = app.config.active_storage.logger || Rails.logger
25
+ ActiveStorage.queue = app.config.active_storage.queue
26
+ ActiveStorage.previewers = app.config.active_storage.previewers || []
27
+ ActiveStorage.analyzers = app.config.active_storage.analyzers || []
28
+ end
29
+ end
30
+
31
+ initializer "active_storage.attached" do
32
+ require "active_storage/attached"
33
+
34
+ ActiveSupport.on_load(:active_record) do
35
+ extend ActiveStorage::Attached::Macros
36
+ end
37
+ end
38
+
39
+ initializer "active_storage.verifier" do
40
+ config.after_initialize do |app|
41
+ ActiveStorage.verifier = app.message_verifier("ActiveStorage")
42
+ end
43
+ end
44
+
45
+ initializer "active_storage.services" do
46
+ config.to_prepare do
47
+ if config_choice = Rails.configuration.active_storage.service
48
+ configs = Rails.configuration.active_storage.service_configurations ||= begin
49
+ config_file = Pathname.new(Rails.root.join("config/storage.yml"))
50
+ raise("Couldn't find Active Storage configuration in #{config_file}") unless config_file.exist?
51
+
52
+ require "yaml"
53
+ require "erb"
54
+
55
+ YAML.load(ERB.new(config_file.read).result) || {}
56
+ rescue Psych::SyntaxError => e
57
+ raise "YAML syntax error occurred while parsing #{config_file}. " \
58
+ "Please note that YAML must be consistently indented using spaces. Tabs are not allowed. " \
59
+ "Error: #{e.message}"
60
+ end
61
+
62
+ ActiveStorage::Blob.service =
63
+ begin
64
+ ActiveStorage::Service.configure config_choice, configs
65
+ rescue => e
66
+ raise e, "Cannot load `Rails.config.active_storage.service`:\n#{e.message}", e.backtrace
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveStorage
4
+ # Returns the version of the currently loaded Active Storage as a <tt>Gem::Version</tt>.
5
+ def self.gem_version
6
+ Gem::Version.new VERSION::STRING
7
+ end
8
+
9
+ module VERSION
10
+ MAJOR = 5
11
+ MINOR = 2
12
+ TINY = 0
13
+ PRE = "beta1"
14
+
15
+ STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".")
16
+ end
17
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/log_subscriber"
4
+
5
+ module ActiveStorage
6
+ class LogSubscriber < ActiveSupport::LogSubscriber
7
+ def service_upload(event)
8
+ message = "Uploaded file to key: #{key_in(event)}"
9
+ message += " (checksum: #{event.payload[:checksum]})" if event.payload[:checksum]
10
+ info event, color(message, GREEN)
11
+ end
12
+
13
+ def service_download(event)
14
+ info event, color("Downloaded file from key: #{key_in(event)}", BLUE)
15
+ end
16
+
17
+ def service_delete(event)
18
+ info event, color("Deleted file from key: #{key_in(event)}", RED)
19
+ end
20
+
21
+ def service_exist(event)
22
+ debug event, color("Checked if file exists at key: #{key_in(event)} (#{event.payload[:exist] ? "yes" : "no"})", BLUE)
23
+ end
24
+
25
+ def service_url(event)
26
+ debug event, color("Generated URL for file at key: #{key_in(event)} (#{event.payload[:url]})", BLUE)
27
+ end
28
+
29
+ def logger
30
+ ActiveStorage.logger
31
+ end
32
+
33
+ private
34
+ def info(event, colored_message)
35
+ super log_prefix_for_service(event) + colored_message
36
+ end
37
+
38
+ def debug(event, colored_message)
39
+ super log_prefix_for_service(event) + colored_message
40
+ end
41
+
42
+ def log_prefix_for_service(event)
43
+ color " #{event.payload[:service]} Storage (#{event.duration.round(1)}ms) ", CYAN
44
+ end
45
+
46
+ def key_in(event)
47
+ event.payload[:key]
48
+ end
49
+ end
50
+ end
51
+
52
+ ActiveStorage::LogSubscriber.attach_to :active_storage
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_storage/downloading"
4
+
5
+ module ActiveStorage
6
+ # This is an abstract base class for previewers, which generate images from blobs. See
7
+ # ActiveStorage::Previewer::PDFPreviewer and ActiveStorage::Previewer::VideoPreviewer for examples of
8
+ # concrete subclasses.
9
+ class Previewer
10
+ include Downloading
11
+
12
+ attr_reader :blob
13
+
14
+ # Implement this method in a concrete subclass. Have it return true when given a blob from which
15
+ # the previewer can generate an image.
16
+ def self.accept?(blob)
17
+ false
18
+ end
19
+
20
+ def initialize(blob)
21
+ @blob = blob
22
+ end
23
+
24
+ # Override this method in a concrete subclass. Have it yield an attachable preview image (i.e.
25
+ # anything accepted by ActiveStorage::Attached::One#attach).
26
+ def preview
27
+ raise NotImplementedError
28
+ end
29
+
30
+ private
31
+ # Executes a system command, capturing its binary output in a tempfile. Yields the tempfile.
32
+ #
33
+ # Use this method to shell out to a system library (e.g. mupdf or ffmpeg) for preview image
34
+ # generation. The resulting tempfile can be used as the +:io+ value in an attachable Hash:
35
+ #
36
+ # def preview
37
+ # download_blob_to_tempfile do |input|
38
+ # draw "my-drawing-command", input.path, "--format", "png", "-" do |output|
39
+ # yield io: output, filename: "#{blob.filename.base}.png", content_type: "image/png"
40
+ # end
41
+ # end
42
+ # end
43
+ #
44
+ # The output tempfile is opened in the directory returned by ActiveStorage::Downloading#tempdir.
45
+ def draw(*argv) # :doc:
46
+ Tempfile.open("ActiveStorage", tempdir) do |file|
47
+ capture(*argv, to: file)
48
+ yield file
49
+ end
50
+ end
51
+
52
+ def capture(*argv, to:)
53
+ to.binmode
54
+ IO.popen(argv) { |out| IO.copy_stream(out, to) }
55
+ to.rewind
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveStorage
4
+ class Previewer::PDFPreviewer < Previewer
5
+ def self.accept?(blob)
6
+ blob.content_type == "application/pdf"
7
+ end
8
+
9
+ def preview
10
+ download_blob_to_tempfile do |input|
11
+ draw "mutool", "draw", "-F", "png", "-o", "-", input.path, "1" do |output|
12
+ yield io: output, filename: "#{blob.filename.base}.png", content_type: "image/png"
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveStorage
4
+ class Previewer::VideoPreviewer < Previewer
5
+ def self.accept?(blob)
6
+ blob.video?
7
+ end
8
+
9
+ def preview
10
+ download_blob_to_tempfile do |input|
11
+ draw_relevant_frame_from input do |output|
12
+ yield io: output, filename: "#{blob.filename.base}.png", content_type: "image/png"
13
+ end
14
+ end
15
+ end
16
+
17
+ private
18
+ def draw_relevant_frame_from(file, &block)
19
+ draw "ffmpeg", "-i", file.path, "-y", "-vcodec", "png",
20
+ "-vf", "thumbnail", "-vframes", "1", "-f", "image2", "-", &block
21
+ end
22
+ end
23
+ end
@@ -1,34 +1,122 @@
1
- # Abstract class serving as an interface for concrete services.
2
- class ActiveStorage::Service
3
- class ActiveStorage::IntegrityError < StandardError; end
1
+ # frozen_string_literal: true
4
2
 
5
- def self.configure(service, **options)
6
- begin
7
- require "active_storage/service/#{service.to_s.downcase}_service"
8
- ActiveStorage::Service.const_get(:"#{service}Service").new(**options)
9
- rescue LoadError => e
10
- puts "Couldn't configure service: #{service} (#{e.message})"
3
+ require "active_storage/log_subscriber"
4
+
5
+ module ActiveStorage
6
+ class IntegrityError < StandardError; end
7
+
8
+ # Abstract class serving as an interface for concrete services.
9
+ #
10
+ # The available services are:
11
+ #
12
+ # * +Disk+, to manage attachments saved directly on the hard drive.
13
+ # * +GCS+, to manage attachments through Google Cloud Storage.
14
+ # * +S3+, to manage attachments through Amazon S3.
15
+ # * +AzureStorage+, to manage attachments through Microsoft Azure Storage.
16
+ # * +Mirror+, to be able to use several services to manage attachments.
17
+ #
18
+ # Inside a Rails application, you can set-up your services through the
19
+ # generated <tt>config/storage.yml</tt> file and reference one
20
+ # of the aforementioned constant under the +service+ key. For example:
21
+ #
22
+ # local:
23
+ # service: Disk
24
+ # root: <%= Rails.root.join("storage") %>
25
+ #
26
+ # You can checkout the service's constructor to know which keys are required.
27
+ #
28
+ # Then, in your application's configuration, you can specify the service to
29
+ # use like this:
30
+ #
31
+ # config.active_storage.service = :local
32
+ #
33
+ # If you are using Active Storage outside of a Ruby on Rails application, you
34
+ # can configure the service to use like this:
35
+ #
36
+ # ActiveStorage::Blob.service = ActiveStorage::Service.configure(
37
+ # :Disk,
38
+ # root: Pathname("/foo/bar/storage")
39
+ # )
40
+ class Service
41
+ extend ActiveSupport::Autoload
42
+ autoload :Configurator
43
+
44
+ class_attribute :url_expires_in, default: 5.minutes
45
+
46
+ class << self
47
+ # Configure an Active Storage service by name from a set of configurations,
48
+ # typically loaded from a YAML file. The Active Storage engine uses this
49
+ # to set the global Active Storage service when the app boots.
50
+ def configure(service_name, configurations)
51
+ Configurator.build(service_name, configurations)
52
+ end
53
+
54
+ # Override in subclasses that stitch together multiple services and hence
55
+ # need to build additional services using the configurator.
56
+ #
57
+ # Passes the configurator and all of the service's config as keyword args.
58
+ #
59
+ # See MirrorService for an example.
60
+ def build(configurator:, service: nil, **service_config) #:nodoc:
61
+ new(**service_config)
62
+ end
11
63
  end
12
- end
13
64
 
65
+ # Upload the +io+ to the +key+ specified. If a +checksum+ is provided, the service will
66
+ # ensure a match when the upload has completed or raise an ActiveStorage::IntegrityError.
67
+ def upload(key, io, checksum: nil)
68
+ raise NotImplementedError
69
+ end
14
70
 
15
- def upload(key, io, checksum: nil)
16
- raise NotImplementedError
17
- end
71
+ # Return the content of the file at the +key+.
72
+ def download(key)
73
+ raise NotImplementedError
74
+ end
18
75
 
19
- def download(key)
20
- raise NotImplementedError
21
- end
76
+ # Delete the file at the +key+.
77
+ def delete(key)
78
+ raise NotImplementedError
79
+ end
22
80
 
23
- def delete(key)
24
- raise NotImplementedError
25
- end
81
+ # Return +true+ if a file exists at the +key+.
82
+ def exist?(key)
83
+ raise NotImplementedError
84
+ end
26
85
 
27
- def exist?(key)
28
- raise NotImplementedError
29
- end
86
+ # Returns a signed, temporary URL for the file at the +key+. The URL will be valid for the amount
87
+ # of seconds specified in +expires_in+. You most also provide the +disposition+ (+:inline+ or +:attachment+),
88
+ # +filename+, and +content_type+ that you wish the file to be served with on request.
89
+ def url(key, expires_in:, disposition:, filename:, content_type:)
90
+ raise NotImplementedError
91
+ end
92
+
93
+ # Returns a signed, temporary URL that a direct upload file can be PUT to on the +key+.
94
+ # The URL will be valid for the amount of seconds specified in +expires_in+.
95
+ # You most also provide the +content_type+, +content_length+, and +checksum+ of the file
96
+ # that will be uploaded. All these attributes will be validated by the service upon upload.
97
+ def url_for_direct_upload(key, expires_in:, content_type:, content_length:, checksum:)
98
+ raise NotImplementedError
99
+ end
100
+
101
+ # Returns a Hash of headers for +url_for_direct_upload+ requests.
102
+ def headers_for_direct_upload(key, filename:, content_type:, content_length:, checksum:)
103
+ {}
104
+ end
105
+
106
+ private
107
+ def instrument(operation, key, payload = {}, &block)
108
+ ActiveSupport::Notifications.instrument(
109
+ "service_#{operation}.active_storage",
110
+ payload.merge(key: key, service: service_name), &block)
111
+ end
112
+
113
+ def service_name
114
+ # ActiveStorage::Service::DiskService => Disk
115
+ self.class.name.split("::").third.remove("Service")
116
+ end
30
117
 
31
- def url(key, expires_in:, disposition:, filename:)
32
- raise NotImplementedError
118
+ def content_disposition_with(type: "inline", filename:)
119
+ (type.to_s.presence_in(%w( attachment inline )) || "inline") + "; #{filename.parameters}"
120
+ end
33
121
  end
34
122
  end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/numeric/bytes"
4
+ require "azure/storage"
5
+ require "azure/storage/core/auth/shared_access_signature"
6
+
7
+ module ActiveStorage
8
+ # Wraps the Microsoft Azure Storage Blob Service as an Active Storage service.
9
+ # See ActiveStorage::Service for the generic API documentation that applies to all services.
10
+ class Service::AzureStorageService < Service
11
+ attr_reader :client, :path, :blobs, :container, :signer
12
+
13
+ def initialize(path:, storage_account_name:, storage_access_key:, container:)
14
+ @client = Azure::Storage::Client.create(storage_account_name: storage_account_name, storage_access_key: storage_access_key)
15
+ @signer = Azure::Storage::Core::Auth::SharedAccessSignature.new(storage_account_name, storage_access_key)
16
+ @blobs = client.blob_client
17
+ @container = container
18
+ @path = path
19
+ end
20
+
21
+ def upload(key, io, checksum: nil)
22
+ instrument :upload, key, checksum: checksum do
23
+ begin
24
+ blobs.create_block_blob(container, key, io, content_md5: checksum)
25
+ rescue Azure::Core::Http::HTTPError
26
+ raise ActiveStorage::IntegrityError
27
+ end
28
+ end
29
+ end
30
+
31
+ def download(key, &block)
32
+ if block_given?
33
+ instrument :streaming_download, key do
34
+ stream(key, &block)
35
+ end
36
+ else
37
+ instrument :download, key do
38
+ _, io = blobs.get_blob(container, key)
39
+ io.force_encoding(Encoding::BINARY)
40
+ end
41
+ end
42
+ end
43
+
44
+ def delete(key)
45
+ instrument :delete, key do
46
+ begin
47
+ blobs.delete_blob(container, key)
48
+ rescue Azure::Core::Http::HTTPError
49
+ false
50
+ end
51
+ end
52
+ end
53
+
54
+ def exist?(key)
55
+ instrument :exist, key do |payload|
56
+ answer = blob_for(key).present?
57
+ payload[:exist] = answer
58
+ answer
59
+ end
60
+ end
61
+
62
+ def url(key, expires_in:, filename:, disposition:, content_type:)
63
+ instrument :url, key do |payload|
64
+ base_url = url_for(key)
65
+ generated_url = signer.signed_uri(
66
+ URI(base_url), false,
67
+ permissions: "r",
68
+ expiry: format_expiry(expires_in),
69
+ content_disposition: content_disposition_with(type: disposition, filename: filename),
70
+ content_type: content_type
71
+ ).to_s
72
+
73
+ payload[:url] = generated_url
74
+
75
+ generated_url
76
+ end
77
+ end
78
+
79
+ def url_for_direct_upload(key, expires_in:, content_type:, content_length:, checksum:)
80
+ instrument :url, key do |payload|
81
+ base_url = url_for(key)
82
+ generated_url = signer.signed_uri(URI(base_url), false, permissions: "rw",
83
+ expiry: format_expiry(expires_in)).to_s
84
+
85
+ payload[:url] = generated_url
86
+
87
+ generated_url
88
+ end
89
+ end
90
+
91
+ def headers_for_direct_upload(key, content_type:, checksum:, **)
92
+ { "Content-Type" => content_type, "Content-MD5" => checksum, "x-ms-blob-type" => "BlockBlob" }
93
+ end
94
+
95
+ private
96
+ def url_for(key)
97
+ "#{path}/#{container}/#{key}"
98
+ end
99
+
100
+ def blob_for(key)
101
+ blobs.get_blob_properties(container, key)
102
+ rescue Azure::Core::Http::HTTPError
103
+ false
104
+ end
105
+
106
+ def format_expiry(expires_in)
107
+ expires_in ? Time.now.utc.advance(seconds: expires_in).iso8601 : nil
108
+ end
109
+
110
+ # Reads the object for the given key in chunks, yielding each to the block.
111
+ def stream(key)
112
+ blob = blob_for(key)
113
+
114
+ chunk_size = 5.megabytes
115
+ offset = 0
116
+
117
+ while offset < blob.properties[:content_length]
118
+ _, chunk = blobs.get_blob(container, key, start_range: offset, end_range: offset + chunk_size - 1)
119
+ yield chunk.force_encoding(Encoding::BINARY)
120
+ offset += chunk_size
121
+ end
122
+ end
123
+ end
124
+ end