loading
Generated 2020-08-24T21:57:56-04:00

All Files ( 74.52% covered at 76.22 hits/line )

67 files in total.
1786 relevant lines, 1331 lines covered and 455 lines missed. ( 74.52% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line
app/controllers/active_storage/base_controller.rb 100.00 % 19 9 9 0 4.67
app/controllers/active_storage/blobs/proxy_controller.rb 100.00 % 14 7 7 0 2.00
app/controllers/active_storage/blobs/redirect_controller.rb 100.00 % 14 5 5 0 3.00
app/controllers/active_storage/direct_uploads_controller.rb 100.00 % 23 9 9 0 4.33
app/controllers/active_storage/disk_controller.rb 92.00 % 54 25 23 2 9.12
app/controllers/active_storage/representations/proxy_controller.rb 100.00 % 19 10 10 0 4.80
app/controllers/active_storage/representations/redirect_controller.rb 100.00 % 14 5 5 0 4.20
app/controllers/concerns/active_storage/file_server.rb 100.00 % 18 10 10 0 20.40
app/controllers/concerns/active_storage/set_blob.rb 100.00 % 16 8 8 0 9.63
app/controllers/concerns/active_storage/set_current.rb 100.00 % 15 5 5 0 19.40
app/controllers/concerns/active_storage/set_headers.rb 100.00 % 12 6 6 0 4.67
app/jobs/active_storage/analyze_job.rb 100.00 % 13 6 6 0 30.33
app/jobs/active_storage/base_job.rb 100.00 % 4 1 1 0 3.00
app/jobs/active_storage/mirror_job.rb 100.00 % 15 7 7 0 4.57
app/jobs/active_storage/purge_job.rb 100.00 % 13 6 6 0 14.33
app/models/active_storage/attachment.rb 100.00 % 58 29 29 0 35.45
app/models/active_storage/blob.rb 100.00 % 337 121 121 0 205.61
app/models/active_storage/blob/analyzable.rb 100.00 % 61 17 17 0 217.12
app/models/active_storage/blob/identifiable.rb 82.35 % 32 17 14 3 82.47
app/models/active_storage/blob/representable.rb 96.43 % 101 28 27 1 26.96
app/models/active_storage/current.rb 100.00 % 5 2 2 0 3.00
app/models/active_storage/filename.rb 95.83 % 77 24 23 1 187.96
app/models/active_storage/preview.rb 86.49 % 114 37 32 5 11.97
app/models/active_storage/variant.rb 100.00 % 130 34 34 0 35.24
app/models/active_storage/variant_record.rb 100.00 % 8 4 4 0 3.00
app/models/active_storage/variant_with_record.rb 86.67 % 59 30 26 4 6.20
app/models/active_storage/variation.rb 90.32 % 84 31 28 3 45.68
config/routes.rb 75.68 % 82 37 28 9 4.81
db/migrate/20170806125915_create_active_storage_tables.rb 100.00 % 35 24 24 0 3.00
lib/active_storage.rb 100.00 % 77 36 36 0 3.00
lib/active_storage/analyzer.rb 88.89 % 44 18 16 2 28.78
lib/active_storage/analyzer/image_analyzer.rb 83.33 % 55 24 20 4 37.46
lib/active_storage/analyzer/null_analyzer.rb 87.50 % 17 8 7 1 57.88
lib/active_storage/analyzer/video_analyzer.rb 96.08 % 118 51 49 2 21.71
lib/active_storage/attached.rb 100.00 % 25 13 13 0 163.38
lib/active_storage/attached/changes.rb 100.00 % 16 9 9 0 3.00
lib/active_storage/attached/changes/create_many.rb 100.00 % 47 24 24 0 93.96
lib/active_storage/attached/changes/create_one.rb 100.00 % 82 36 36 0 163.64
lib/active_storage/attached/changes/create_one_of_many.rb 100.00 % 10 5 5 0 60.20
lib/active_storage/attached/changes/delete_many.rb 92.31 % 27 13 12 1 2.77
lib/active_storage/attached/changes/delete_one.rb 100.00 % 19 8 8 0 3.50
lib/active_storage/attached/many.rb 100.00 % 66 16 16 0 69.63
lib/active_storage/attached/model.rb 100.00 % 198 46 46 0 390.61
lib/active_storage/attached/one.rb 100.00 % 80 29 29 0 42.72
lib/active_storage/downloader.rb 100.00 % 43 24 24 0 100.50
lib/active_storage/downloading.rb 0.00 % 47 36 0 36 0.00
lib/active_storage/engine.rb 92.75 % 154 69 64 5 2.78
lib/active_storage/errors.rb 100.00 % 26 7 7 0 3.00
lib/active_storage/gem_version.rb 88.89 % 17 9 8 1 2.67
lib/active_storage/log_subscriber.rb 100.00 % 64 34 34 0 732.62
lib/active_storage/previewer.rb 90.63 % 85 32 29 3 18.75
lib/active_storage/previewer/mupdf_previewer.rb 83.33 % 36 18 15 3 4.00
lib/active_storage/previewer/poppler_pdf_previewer.rb 100.00 % 35 17 17 0 14.94
lib/active_storage/previewer/video_previewer.rb 100.00 % 34 17 17 0 7.76
lib/active_storage/reflection.rb 100.00 % 64 26 26 0 10.38
lib/active_storage/service.rb 82.14 % 170 56 46 10 124.63
lib/active_storage/service/azure_storage_service.rb 0.00 % 170 135 0 135 0.00
lib/active_storage/service/configurator.rb 94.44 % 36 18 17 1 17.50
lib/active_storage/service/disk_service.rb 100.00 % 171 76 76 0 254.08
lib/active_storage/service/gcs_service.rb 0.00 % 143 108 0 108 0.00
lib/active_storage/service/mirror_service.rb 100.00 % 79 32 32 0 58.69
lib/active_storage/service/registry.rb 93.75 % 32 16 15 1 174.81
lib/active_storage/service/s3_service.rb 0.00 % 147 112 0 112 0.00
lib/active_storage/transformers/image_processing_transformer.rb 100.00 % 39 16 16 0 27.19
lib/active_storage/transformers/mini_magick_transformer.rb 100.00 % 38 20 20 0 7.35
lib/active_storage/transformers/transformer.rb 92.86 % 42 14 13 1 28.21
lib/active_storage/version.rb 75.00 % 10 4 3 1 2.25

app/controllers/active_storage/base_controller.rb

100.0% lines covered

9 relevant lines. 9 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. # The base class for all Active Storage controllers.
  3. 3 class ActiveStorage::BaseController < ActionController::Base
  4. 3 include ActiveStorage::SetCurrent
  5. 3 protect_from_forgery with: :exception
  6. 3 self.etag_with_template_digest = false
  7. 3 private
  8. 3 def stream(blob)
  9. 8 blob.download do |chunk|
  10. 8 response.stream.write chunk
  11. end
  12. ensure
  13. 8 response.stream.close
  14. end
  15. end

app/controllers/active_storage/blobs/proxy_controller.rb

100.0% lines covered

7 relevant lines. 7 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. # Proxy files through application. This avoids having a redirect and makes files easier to cache.
  3. 2 class ActiveStorage::Blobs::ProxyController < ActiveStorage::BaseController
  4. 2 include ActiveStorage::SetBlob
  5. 2 include ActiveStorage::SetHeaders
  6. 2 def show
  7. 2 http_cache_forever public: true do
  8. 2 set_content_headers_from @blob
  9. 2 stream @blob
  10. end
  11. end
  12. end

app/controllers/active_storage/blobs/redirect_controller.rb

100.0% lines covered

5 relevant lines. 5 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. # Take a signed permanent reference for a blob and turn it into an expiring service URL for download.
  3. # Note: These URLs are publicly accessible. If you need to enforce access protection beyond the
  4. # security-through-obscurity factor of the signed blob references, you'll need to implement your own
  5. # authenticated redirection controller.
  6. 3 class ActiveStorage::Blobs::RedirectController < ActiveStorage::BaseController
  7. 3 include ActiveStorage::SetBlob
  8. 3 def show
  9. 3 expires_in ActiveStorage.service_urls_expire_in
  10. 3 redirect_to @blob.url(disposition: params[:disposition])
  11. end
  12. end

app/controllers/active_storage/direct_uploads_controller.rb

100.0% lines covered

9 relevant lines. 9 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. # Creates a new blob on the server side in anticipation of a direct-to-service upload from the client side.
  3. # When the client-side upload is completed, the signed_blob_id can be submitted as part of the form to reference
  4. # the blob that was created up front.
  5. 3 class ActiveStorage::DirectUploadsController < ActiveStorage::BaseController
  6. 3 def create
  7. 6 blob = ActiveStorage::Blob.create_before_direct_upload!(**blob_args)
  8. 6 render json: direct_upload_json(blob)
  9. end
  10. 3 private
  11. 3 def blob_args
  12. 6 params.require(:blob).permit(:filename, :byte_size, :checksum, :content_type, :metadata).to_h.symbolize_keys
  13. end
  14. 3 def direct_upload_json(blob)
  15. 6 blob.as_json(root: false, methods: :signed_id).merge(direct_upload: {
  16. url: blob.service_url_for_direct_upload,
  17. headers: blob.service_headers_for_direct_upload
  18. })
  19. end
  20. end

app/controllers/active_storage/disk_controller.rb

92.0% lines covered

25 relevant lines. 23 lines covered and 2 lines missed.
    
  1. # frozen_string_literal: true
  2. # Serves files stored with the disk service in the same way that the cloud services do.
  3. # This means using expiring, signed URLs that are meant for immediate access, not permanent linking.
  4. # Always go through the BlobsController, or your own authenticated controller, rather than directly
  5. # to the service URL.
  6. 3 class ActiveStorage::DiskController < ActiveStorage::BaseController
  7. 3 include ActiveStorage::FileServer
  8. 3 skip_forgery_protection
  9. 3 def show
  10. 27 if key = decode_verified_key
  11. 24 serve_file named_disk_service(key[:service_name]).path_for(key[:key]), content_type: key[:content_type], disposition: key[:disposition]
  12. else
  13. 3 head :not_found
  14. end
  15. rescue Errno::ENOENT
  16. 3 head :not_found
  17. end
  18. 3 def update
  19. 18 if token = decode_verified_token
  20. 15 if acceptable_content?(token)
  21. 6 named_disk_service(token[:service_name]).upload token[:key], request.body, checksum: token[:checksum]
  22. else
  23. 9 head :unprocessable_entity
  24. end
  25. else
  26. 3 head :not_found
  27. end
  28. rescue ActiveStorage::IntegrityError
  29. head :unprocessable_entity
  30. end
  31. 3 private
  32. 3 def named_disk_service(name)
  33. 30 ActiveStorage::Blob.services.fetch(name) do
  34. ActiveStorage::Blob.service
  35. end
  36. end
  37. 3 def decode_verified_key
  38. 27 ActiveStorage.verifier.verified(params[:encoded_key], purpose: :blob_key)
  39. end
  40. 3 def decode_verified_token
  41. 18 ActiveStorage.verifier.verified(params[:encoded_token], purpose: :blob_token)
  42. end
  43. 3 def acceptable_content?(token)
  44. 15 token[:content_type] == request.content_mime_type && token[:content_length] == request.content_length
  45. end
  46. end

app/controllers/active_storage/representations/proxy_controller.rb

100.0% lines covered

10 relevant lines. 10 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. # Proxy files through application. This avoids having a redirect and makes files easier to cache.
  3. 3 class ActiveStorage::Representations::ProxyController < ActiveStorage::BaseController
  4. 3 include ActiveStorage::SetBlob
  5. 3 include ActiveStorage::SetHeaders
  6. 3 def show
  7. 6 http_cache_forever public: true do
  8. 6 set_content_headers_from representation.image
  9. 6 stream representation
  10. end
  11. end
  12. 3 private
  13. 3 def representation
  14. 12 @representation ||= @blob.representation(params[:variation_key]).processed
  15. end
  16. end

app/controllers/active_storage/representations/redirect_controller.rb

100.0% lines covered

5 relevant lines. 5 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. # Take a signed permanent reference for a blob representation and turn it into an expiring service URL for download.
  3. # Note: These URLs are publicly accessible. If you need to enforce access protection beyond the
  4. # security-through-obscurity factor of the signed blob and variation reference, you'll need to implement your own
  5. # authenticated redirection controller.
  6. 3 class ActiveStorage::Representations::RedirectController < ActiveStorage::BaseController
  7. 3 include ActiveStorage::SetBlob
  8. 3 def show
  9. 6 expires_in ActiveStorage.service_urls_expire_in
  10. 6 redirect_to @blob.representation(params[:variation_key]).processed.url(disposition: params[:disposition])
  11. end
  12. end

app/controllers/concerns/active_storage/file_server.rb

100.0% lines covered

10 relevant lines. 10 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 3 module ActiveStorage::FileServer # :nodoc:
  3. 3 private
  4. 3 def serve_file(path, content_type:, disposition:)
  5. 24 Rack::File.new(nil).serving(request, path).tap do |(status, headers, body)|
  6. 21 self.status = status
  7. 21 self.response_body = body
  8. 21 headers.each do |name, value|
  9. 66 response.headers[name] = value
  10. end
  11. 21 response.headers["Content-Type"] = content_type || DEFAULT_SEND_FILE_TYPE
  12. 21 response.headers["Content-Disposition"] = disposition || DEFAULT_SEND_FILE_DISPOSITION
  13. end
  14. end
  15. end

app/controllers/concerns/active_storage/set_blob.rb

100.0% lines covered

8 relevant lines. 8 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 3 module ActiveStorage::SetBlob #:nodoc:
  3. 3 extend ActiveSupport::Concern
  4. 3 included do
  5. 11 before_action :set_blob
  6. end
  7. 3 private
  8. 3 def set_blob
  9. 34 @blob = ActiveStorage::Blob.find_signed!(params[:signed_blob_id] || params[:signed_id])
  10. rescue ActiveSupport::MessageVerifier::InvalidSignature
  11. 17 head :not_found
  12. end
  13. end

app/controllers/concerns/active_storage/set_current.rb

100.0% lines covered

5 relevant lines. 5 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. # Sets the <tt>ActiveStorage::Current.host</tt> attribute, which the disk service uses to generate URLs.
  3. # Include this concern in custom controllers that call ActiveStorage::Blob#url,
  4. # ActiveStorage::Variant#url, or ActiveStorage::Preview#url so the disk service can
  5. # generate URLs using the same host, protocol, and base path as the current request.
  6. 3 module ActiveStorage::SetCurrent
  7. 3 extend ActiveSupport::Concern
  8. 3 included do
  9. 3 before_action do
  10. 85 ActiveStorage::Current.host = request.base_url
  11. end
  12. end
  13. end

app/controllers/concerns/active_storage/set_headers.rb

100.0% lines covered

6 relevant lines. 6 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 3 module ActiveStorage::SetHeaders #:nodoc:
  3. 3 extend ActiveSupport::Concern
  4. 3 private
  5. 3 def set_content_headers_from(blob)
  6. 8 response.headers["Content-Type"] = blob.content_type
  7. 8 response.headers["Content-Disposition"] = ActionDispatch::Http::ContentDisposition.format \
  8. disposition: params[:disposition] || "inline", filename: blob.filename.sanitized
  9. end
  10. end

app/jobs/active_storage/analyze_job.rb

100.0% lines covered

6 relevant lines. 6 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. # Provides asynchronous analysis of ActiveStorage::Blob records via ActiveStorage::Blob#analyze_later.
  3. 3 class ActiveStorage::AnalyzeJob < ActiveStorage::BaseJob
  4. 150 queue_as { ActiveStorage.queues[:analysis] }
  5. 3 discard_on ActiveRecord::RecordNotFound
  6. 3 retry_on ActiveStorage::IntegrityError, attempts: 10, wait: :exponentially_longer
  7. 3 def perform(blob)
  8. 20 blob.analyze
  9. end
  10. end

app/jobs/active_storage/base_job.rb

100.0% lines covered

1 relevant lines. 1 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 3 class ActiveStorage::BaseJob < ActiveJob::Base
  3. end

app/jobs/active_storage/mirror_job.rb

100.0% lines covered

7 relevant lines. 7 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 3 require "active_support/core_ext/object/try"
  3. # Provides asynchronous mirroring of directly-uploaded blobs.
  4. 3 class ActiveStorage::MirrorJob < ActiveStorage::BaseJob
  5. 14 queue_as { ActiveStorage.queues[:mirror] }
  6. 3 discard_on ActiveStorage::FileNotFoundError
  7. 3 retry_on ActiveStorage::IntegrityError, attempts: 10, wait: :exponentially_longer
  8. 3 def perform(key, checksum:)
  9. 3 ActiveStorage::Blob.service.try(:mirror, key, checksum: checksum)
  10. end
  11. end

app/jobs/active_storage/purge_job.rb

100.0% lines covered

6 relevant lines. 6 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. # Provides asynchronous purging of ActiveStorage::Blob records via ActiveStorage::Blob#purge_later.
  3. 2 class ActiveStorage::PurgeJob < ActiveStorage::BaseJob
  4. 44 queue_as { ActiveStorage.queues[:purge] }
  5. 2 discard_on ActiveRecord::RecordNotFound
  6. 2 retry_on ActiveRecord::Deadlocked, attempts: 10, wait: :exponentially_longer
  7. 2 def perform(blob)
  8. 34 blob.purge
  9. end
  10. end

app/models/active_storage/attachment.rb

100.0% lines covered

29 relevant lines. 29 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 3 require "active_support/core_ext/module/delegation"
  3. # Attachments associate records with blobs. Usually that's a one record-many blobs relationship,
  4. # but it is possible to associate many different records with the same blob. A foreign-key constraint
  5. # on the attachments table prevents blobs from being purged if they’re still attached to any records.
  6. #
  7. # Attachments also have access to all methods from {ActiveStorage::Blob}[rdoc-ref:ActiveStorage::Blob].
  8. 3 class ActiveStorage::Attachment < ActiveRecord::Base
  9. 3 self.table_name = "active_storage_attachments"
  10. 3 belongs_to :record, polymorphic: true, touch: true
  11. 3 belongs_to :blob, class_name: "ActiveStorage::Blob", autosave: true
  12. 3 delegate_missing_to :blob
  13. 3 delegate :signed_id, to: :blob
  14. 3 after_create_commit :mirror_blob_later, :analyze_blob_later
  15. 3 after_destroy_commit :purge_dependent_blob_later
  16. # Synchronously deletes the attachment and {purges the blob}[rdoc-ref:ActiveStorage::Blob#purge].
  17. 3 def purge
  18. 14 transaction do
  19. 14 delete
  20. 14 record&.touch
  21. end
  22. 14 blob&.purge
  23. end
  24. # Deletes the attachment and {enqueues a background job}[rdoc-ref:ActiveStorage::Blob#purge_later] to purge the blob.
  25. 3 def purge_later
  26. 14 transaction do
  27. 14 delete
  28. 14 record&.touch
  29. end
  30. 14 blob&.purge_later
  31. end
  32. 3 private
  33. 3 def analyze_blob_later
  34. 381 blob.analyze_later unless blob.analyzed?
  35. end
  36. 3 def mirror_blob_later
  37. 380 blob.mirror_later
  38. end
  39. 3 def purge_dependent_blob_later
  40. 52 blob&.purge_later if dependent == :purge_later
  41. end
  42. 3 def dependent
  43. 52 record.attachment_reflections[name]&.options[:dependent]
  44. end
  45. end
  46. 3 ActiveSupport.run_load_hooks :active_storage_attachment, ActiveStorage::Attachment

app/models/active_storage/blob.rb

100.0% lines covered

121 relevant lines. 121 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. # A blob is a record that contains the metadata about a file and a key for where that file resides on the service.
  3. # Blobs can be created in two ways:
  4. #
  5. # 1. Ahead of the file being uploaded server-side to the service, via <tt>create_and_upload!</tt>. A rewindable
  6. # <tt>io</tt> with the file contents must be available at the server for this operation.
  7. # 2. Ahead of the file being directly uploaded client-side to the service, via <tt>create_before_direct_upload!</tt>.
  8. #
  9. # The first option doesn't require any client-side JavaScript integration, and can be used by any other back-end
  10. # service that deals with files. The second option is faster, since you're not using your own server as a staging
  11. # point for uploads, and can work with deployments like Heroku that do not provide large amounts of disk space.
  12. #
  13. # Blobs are intended to be immutable in as-so-far as their reference to a specific file goes. You're allowed to
  14. # update a blob's metadata on a subsequent pass, but you should not update the key or change the uploaded file.
  15. # If you need to create a derivative or otherwise change the blob, simply create a new blob and purge the old one.
  16. 3 class ActiveStorage::Blob < ActiveRecord::Base
  17. # We use constant paths in the following include calls to avoid a gotcha of
  18. # classic mode: If the parent application defines a top-level Analyzable, for
  19. # example, and ActiveStorage::Blob::Analyzable is not yet loaded, a bare
  20. #
  21. # include Analyzable
  22. #
  23. # would resolve to the top-level one, const_missing would not be triggered,
  24. # and therefore ActiveStorage::Blob::Analyzable would not be autoloaded.
  25. #
  26. # By using qualified names, we ensure const_missing is invoked if needed.
  27. # Please, note that Ruby 2.5 or newer is required, so Object is not checked
  28. # when looking up the ancestors of ActiveStorage::Blob.
  29. #
  30. # Zeitwerk mode does not have this gotcha. If we ever drop classic mode, this
  31. # can be simplified, bare constant names would just work.
  32. 3 include ActiveStorage::Blob::Analyzable
  33. 3 include ActiveStorage::Blob::Identifiable
  34. 3 include ActiveStorage::Blob::Representable
  35. 3 self.table_name = "active_storage_blobs"
  36. 3 self.signed_id_verifier = ActiveStorage.verifier
  37. 3 MINIMUM_TOKEN_LENGTH = 28
  38. 3 has_secure_token :key, length: MINIMUM_TOKEN_LENGTH
  39. 3 store :metadata, accessors: [ :analyzed, :identified ], coder: ActiveRecord::Coders::JSON
  40. 3 class_attribute :services, default: {}
  41. 3 class_attribute :service, instance_accessor: false
  42. 3 has_many :attachments
  43. 9 scope :unattached, -> { where.missing(:attachments) }
  44. 3 after_initialize do
  45. 1229 self.service_name ||= self.class.service.name
  46. end
  47. 3 after_update_commit :update_service_metadata, if: :content_type_previously_changed?
  48. 3 before_destroy(prepend: true) do
  49. 58 raise ActiveRecord::InvalidForeignKey if attachments.exists?
  50. end
  51. 3 validates :service_name, presence: true
  52. 3 validate do
  53. 967 if service_name_changed? && service_name.present?
  54. 658 services.fetch(service_name) do
  55. 4 errors.add(:service_name, :invalid)
  56. end
  57. end
  58. end
  59. 3 class << self
  60. # You can use the signed ID of a blob to refer to it on the client side without fear of tampering.
  61. # This is particularly helpful for direct uploads where the client-side needs to refer to the blob
  62. # that was created ahead of the upload itself on form submission.
  63. #
  64. # The signed ID is also used to create stable URLs for the blob through the BlobsController.
  65. 3 def find_signed!(id, record: nil)
  66. 71 super(id, purpose: :blob_id)
  67. end
  68. 3 def build_after_upload(io:, filename:, content_type: nil, metadata: nil, service_name: nil, identify: true, record: nil) #:nodoc:
  69. 2 new(filename: filename, content_type: content_type, metadata: metadata, service_name: service_name).tap do |blob|
  70. 2 blob.upload(io, identify: identify)
  71. end
  72. end
  73. 3 deprecate :build_after_upload
  74. 3 def build_after_unfurling(key: nil, io:, filename:, content_type: nil, metadata: nil, service_name: nil, identify: true, record: nil) #:nodoc:
  75. 622 new(key: key, filename: filename, content_type: content_type, metadata: metadata, service_name: service_name).tap do |blob|
  76. 622 blob.unfurl(io, identify: identify)
  77. end
  78. end
  79. 3 def create_after_unfurling!(key: nil, io:, filename:, content_type: nil, metadata: nil, service_name: nil, identify: true, record: nil) #:nodoc:
  80. 488 build_after_unfurling(key: key, io: io, filename: filename, content_type: content_type, metadata: metadata, service_name: service_name, identify: identify).tap(&:save!)
  81. end
  82. # Creates a new blob instance and then uploads the contents of
  83. # the given <tt>io</tt> to the service. The blob instance is going to
  84. # be saved before the upload begins to prevent the upload clobbering another due to key collisions.
  85. # When providing a content type, pass <tt>identify: false</tt> to bypass
  86. # automatic content type inference.
  87. 3 def create_and_upload!(key: nil, io:, filename:, content_type: nil, metadata: nil, service_name: nil, identify: true, record: nil)
  88. 488 create_after_unfurling!(key: key, io: io, filename: filename, content_type: content_type, metadata: metadata, service_name: service_name, identify: identify).tap do |blob|
  89. 486 blob.upload_without_unfurling(io)
  90. end
  91. end
  92. 3 alias_method :create_after_upload!, :create_and_upload!
  93. 3 deprecate create_after_upload!: :create_and_upload!
  94. # Returns a saved blob _without_ uploading a file to the service. This blob will point to a key where there is
  95. # no file yet. It's intended to be used together with a client-side upload, which will first create the blob
  96. # in order to produce the signed URL for uploading. This signed URL points to the key generated by the blob.
  97. # Once the form using the direct upload is submitted, the blob can be associated with the right record using
  98. # the signed ID.
  99. 3 def create_before_direct_upload!(key: nil, filename:, byte_size:, checksum:, content_type: nil, metadata: nil, service_name: nil, record: nil)
  100. 48 create! key: key, filename: filename, byte_size: byte_size, checksum: checksum, content_type: content_type, metadata: metadata, service_name: service_name
  101. end
  102. # To prevent problems with case-insensitive filesystems, especially in combination
  103. # with databases which treat indices as case-sensitive, all blob keys generated are going
  104. # to only contain the base-36 character alphabet and will therefore be lowercase. To maintain
  105. # the same or higher amount of entropy as in the base-58 encoding used by `has_secure_token`
  106. # the number of bytes used is increased to 28 from the standard 24
  107. 3 def generate_unique_secure_token(length: MINIMUM_TOKEN_LENGTH)
  108. 666 SecureRandom.base36(length)
  109. end
  110. # Customize signed ID purposes for backwards compatibility.
  111. 3 def combine_signed_id_purposes(purpose)
  112. 153 purpose.to_s
  113. end
  114. end
  115. # Returns a signed ID for this blob that's suitable for reference on the client-side without fear of tampering.
  116. 3 def signed_id
  117. 82 super(purpose: :blob_id)
  118. end
  119. # Returns the key pointing to the file on the service that's associated with this blob. The key is the
  120. # secure-token format from Rails in lower case. So it'll look like: xtapjjcjiudrlk3tmwyjgpuobabd.
  121. # This key is not intended to be revealed directly to the user.
  122. # Always refer to blobs using the signed_id or a verified form of the key.
  123. 3 def key
  124. # We can't wait until the record is first saved to have a key for it
  125. 1832 self[:key] ||= self.class.generate_unique_secure_token(length: MINIMUM_TOKEN_LENGTH)
  126. end
  127. # Returns an ActiveStorage::Filename instance of the filename that can be
  128. # queried for basename, extension, and a sanitized version of the filename
  129. # that's safe to use in URLs.
  130. 3 def filename
  131. 1606 ActiveStorage::Filename.new(self[:filename])
  132. end
  133. # Returns true if the content_type of this blob is in the image range, like image/png.
  134. 3 def image?
  135. 984 content_type.start_with?("image")
  136. end
  137. # Returns true if the content_type of this blob is in the audio range, like audio/mpeg.
  138. 3 def audio?
  139. 6 content_type.start_with?("audio")
  140. end
  141. # Returns true if the content_type of this blob is in the video range, like video/mp4.
  142. 3 def video?
  143. 505 content_type.start_with?("video")
  144. end
  145. # Returns true if the content_type of this blob is in the text range, like text/plain.
  146. 3 def text?
  147. 2 content_type.start_with?("text")
  148. end
  149. # Returns the URL of the blob on the service. This returns a permanent URL for public files, and returns a
  150. # short-lived URL for private files. Private files are signed, and not for public use. Instead,
  151. # the URL should only be exposed as a redirect from a stable, possibly authenticated URL. Hiding the
  152. # URL behind a redirect also allows you to change services without updating all URLs.
  153. 3 def url(expires_in: ActiveStorage.service_urls_expire_in, disposition: :inline, filename: nil, **options)
  154. 46 filename = ActiveStorage::Filename.wrap(filename || self.filename)
  155. 46 service.url key, expires_in: expires_in, filename: filename, content_type: content_type_for_service_url,
  156. disposition: forced_disposition_for_service_url || disposition, **options
  157. end
  158. 3 alias_method :service_url, :url
  159. 3 deprecate service_url: :url
  160. # Returns a URL that can be used to directly upload a file for this blob on the service. This URL is intended to be
  161. # short-lived for security and only generated on-demand by the client-side JavaScript responsible for doing the uploading.
  162. 3 def service_url_for_direct_upload(expires_in: ActiveStorage.service_urls_expire_in)
  163. 21 service.url_for_direct_upload key, expires_in: expires_in, content_type: content_type, content_length: byte_size, checksum: checksum
  164. end
  165. # Returns a Hash of headers for +service_url_for_direct_upload+ requests.
  166. 3 def service_headers_for_direct_upload
  167. 6 service.headers_for_direct_upload key, filename: filename, content_type: content_type, content_length: byte_size, checksum: checksum
  168. end
  169. # Uploads the +io+ to the service on the +key+ for this blob. Blobs are intended to be immutable, so you shouldn't be
  170. # using this method after a file has already been uploaded to fit with a blob. If you want to create a derivative blob,
  171. # you should instead simply create a new blob based on the old one.
  172. #
  173. # Prior to uploading, we compute the checksum, which is sent to the service for transit integrity validation. If the
  174. # checksum does not match what the service receives, an exception will be raised. We also measure the size of the +io+
  175. # and store that in +byte_size+ on the blob record. The content type is automatically extracted from the +io+ unless
  176. # you specify a +content_type+ and pass +identify+ as false.
  177. #
  178. # Normally, you do not have to call this method directly at all. Use the +create_and_upload!+ class method instead.
  179. # If you do use this method directly, make sure you are using it on a persisted Blob as otherwise another blob's
  180. # data might get overwritten on the service.
  181. 3 def upload(io, identify: true)
  182. 2 unfurl io, identify: identify
  183. 2 upload_without_unfurling io
  184. end
  185. 3 def unfurl(io, identify: true) #:nodoc:
  186. 624 self.checksum = compute_checksum_in_chunks(io)
  187. 624 self.content_type = extract_content_type(io) if content_type.nil? || identify
  188. 624 self.byte_size = io.size
  189. 624 self.identified = true
  190. end
  191. 3 def upload_without_unfurling(io) #:nodoc:
  192. 606 service.upload key, io, checksum: checksum, **service_metadata
  193. end
  194. # Downloads the file associated with this blob. If no block is given, the entire file is read into memory and returned.
  195. # That'll use a lot of RAM for very large files. If a block is given, then the download is streamed and yielded in chunks.
  196. 3 def download(&block)
  197. 20 service.download key, &block
  198. end
  199. # Downloads the blob to a tempfile on disk. Yields the tempfile.
  200. #
  201. # The tempfile's name is prefixed with +ActiveStorage-+ and the blob's ID. Its extension matches that of the blob.
  202. #
  203. # By default, the tempfile is created in <tt>Dir.tmpdir</tt>. Pass +tmpdir:+ to create it in a different directory:
  204. #
  205. # blob.open(tmpdir: "/path/to/tmp") do |file|
  206. # # ...
  207. # end
  208. #
  209. # The tempfile is automatically closed and unlinked after the given block is executed.
  210. #
  211. # Raises ActiveStorage::IntegrityError if the downloaded data does not match the blob's checksum.
  212. 3 def open(tmpdir: nil, &block)
  213. 153 service.open key, checksum: checksum,
  214. name: [ "ActiveStorage-#{id}-", filename.extension_with_delimiter ], tmpdir: tmpdir, &block
  215. end
  216. 3 def mirror_later #:nodoc:
  217. 380 ActiveStorage::MirrorJob.perform_later(key, checksum: checksum) if service.respond_to?(:mirror)
  218. end
  219. # Deletes the files on the service associated with the blob. This should only be done if the blob is going to be
  220. # deleted as well or you will essentially have a dead reference. It's recommended to use #purge and #purge_later
  221. # methods in most circumstances.
  222. 3 def delete
  223. 351 service.delete(key)
  224. 351 service.delete_prefixed("variants/#{key}/") if image?
  225. end
  226. # Destroys the blob record and then deletes the file on the service. This is the recommended way to dispose of unwanted
  227. # blobs. Note, though, that deleting the file off the service will initiate an HTTP connection to the service, which may
  228. # be slow or prevented, so you should not use this method inside a transaction or in callbacks. Use #purge_later instead.
  229. 3 def purge
  230. 58 destroy
  231. 44 delete
  232. rescue ActiveRecord::InvalidForeignKey
  233. end
  234. # Enqueues an ActiveStorage::PurgeJob to call #purge. This is the recommended way to purge blobs from a transaction,
  235. # an Active Record callback, or in any other real-time scenario.
  236. 3 def purge_later
  237. 40 ActiveStorage::PurgeJob.perform_later(self)
  238. end
  239. # Returns an instance of service, which can be configured globally or per attachment
  240. 3 def service
  241. 1999 services.fetch(service_name)
  242. end
  243. 3 private
  244. 3 def compute_checksum_in_chunks(io)
  245. Digest::MD5.new.tap do |checksum|
  246. 624 while chunk = io.read(5.megabytes)
  247. 624 checksum << chunk
  248. end
  249. 624 io.rewind
  250. 624 end.base64digest
  251. end
  252. 3 def extract_content_type(io)
  253. 622 Marcel::MimeType.for io, name: filename.to_s, declared_type: content_type
  254. end
  255. 3 def forcibly_serve_as_binary?
  256. 714 ActiveStorage.content_types_to_serve_as_binary.include?(content_type)
  257. end
  258. 3 def allowed_inline?
  259. 660 ActiveStorage.content_types_allowed_inline.include?(content_type)
  260. end
  261. 3 def content_type_for_service_url
  262. 46 forcibly_serve_as_binary? ? ActiveStorage.binary_content_type : content_type
  263. end
  264. 3 def forced_disposition_for_service_url
  265. 46 if forcibly_serve_as_binary? || !allowed_inline?
  266. 31 :attachment
  267. end
  268. end
  269. 3 def service_metadata
  270. 622 if forcibly_serve_as_binary?
  271. 4 { content_type: ActiveStorage.binary_content_type, disposition: :attachment, filename: filename }
  272. 618 elsif !allowed_inline?
  273. 333 { content_type: content_type, disposition: :attachment, filename: filename }
  274. else
  275. 285 { content_type: content_type }
  276. end
  277. end
  278. 3 def update_service_metadata
  279. 8 service.update_metadata key, **service_metadata if service_metadata.any?
  280. end
  281. end
  282. 3 ActiveSupport.run_load_hooks :active_storage_blob, ActiveStorage::Blob

app/models/active_storage/blob/analyzable.rb

100.0% lines covered

17 relevant lines. 17 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 3 require "active_storage/analyzer/null_analyzer"
  3. 3 module ActiveStorage::Blob::Analyzable
  4. # Extracts and stores metadata from the file associated with this blob using a relevant analyzer. Active Storage comes
  5. # with built-in analyzers for images and videos. See ActiveStorage::Analyzer::ImageAnalyzer and
  6. # ActiveStorage::Analyzer::VideoAnalyzer for information about the specific attributes they extract and the third-party
  7. # libraries they require.
  8. #
  9. # To choose the analyzer for a blob, Active Storage calls +accept?+ on each registered analyzer in order. It uses the
  10. # first analyzer for which +accept?+ returns true when given the blob. If no registered analyzer accepts the blob, no
  11. # metadata is extracted from it.
  12. #
  13. # In a Rails application, add or remove analyzers by manipulating +Rails.application.config.active_storage.analyzers+
  14. # in an initializer:
  15. #
  16. # # Add a custom analyzer for Microsoft Office documents:
  17. # Rails.application.config.active_storage.analyzers.append DOCXAnalyzer
  18. #
  19. # # Remove the built-in video analyzer:
  20. # Rails.application.config.active_storage.analyzers.delete ActiveStorage::Analyzer::VideoAnalyzer
  21. #
  22. # Outside of a Rails application, manipulate +ActiveStorage.analyzers+ instead.
  23. #
  24. # You won't ordinarily need to call this method from a Rails application. New blobs are automatically and asynchronously
  25. # analyzed via #analyze_later when they're attached for the first time.
  26. 3 def analyze
  27. 262 update! metadata: metadata.merge(extract_metadata_via_analyzer)
  28. end
  29. # Enqueues an ActiveStorage::AnalyzeJob which calls #analyze, or calls #analyze inline based on analyzer class configuration.
  30. #
  31. # This method is automatically called for a blob when it's attached for the first time. You can call it to analyze a blob
  32. # again (e.g. if you add a new analyzer or modify an existing one).
  33. 3 def analyze_later
  34. 369 if analyzer_class.analyze_later?
  35. 145 ActiveStorage::AnalyzeJob.perform_later(self)
  36. else
  37. 224 analyze
  38. end
  39. end
  40. # Returns true if the blob has been analyzed.
  41. 3 def analyzed?
  42. 406 analyzed
  43. end
  44. 3 private
  45. 3 def extract_metadata_via_analyzer
  46. 262 analyzer.metadata.merge(analyzed: true)
  47. end
  48. 3 def analyzer
  49. 262 analyzer_class.new(self)
  50. end
  51. 3 def analyzer_class
  52. 1734 ActiveStorage.analyzers.detect { |klass| klass.accept?(self) } || ActiveStorage::Analyzer::NullAnalyzer
  53. end
  54. end

app/models/active_storage/blob/identifiable.rb

82.35% lines covered

17 relevant lines. 14 lines covered and 3 lines missed.
    
  1. # frozen_string_literal: true
  2. 3 module ActiveStorage::Blob::Identifiable
  3. 3 def identify
  4. identify_without_saving
  5. save!
  6. end
  7. 3 def identify_without_saving
  8. 643 unless identified?
  9. 19 self.content_type = identify_content_type
  10. 19 self.identified = true
  11. end
  12. end
  13. 3 def identified?
  14. 643 identified
  15. end
  16. 3 private
  17. 3 def identify_content_type
  18. 19 Marcel::MimeType.for download_identifiable_chunk, name: filename.to_s, declared_type: content_type
  19. end
  20. 3 def download_identifiable_chunk
  21. 19 if byte_size.positive?
  22. 19 service.download_chunk key, 0...4.kilobytes
  23. else
  24. ""
  25. end
  26. end
  27. end

app/models/active_storage/blob/representable.rb

96.43% lines covered

28 relevant lines. 27 lines covered and 1 lines missed.
    
  1. # frozen_string_literal: true
  2. 3 module ActiveStorage::Blob::Representable
  3. 3 extend ActiveSupport::Concern
  4. 3 included do
  5. 3 has_many :variant_records, class_name: "ActiveStorage::VariantRecord", dependent: false
  6. 47 before_destroy { variant_records.destroy_all if ActiveStorage.track_variants }
  7. 3 has_one_attached :preview_image
  8. end
  9. # Returns an ActiveStorage::Variant instance with the set of +transformations+ provided. This is only relevant for image
  10. # files, and it allows any image to be transformed for size, colors, and the like. Example:
  11. #
  12. # avatar.variant(resize_to_limit: [100, 100]).processed.url
  13. #
  14. # This will create and process a variant of the avatar blob that's constrained to a height and width of 100px.
  15. # Then it'll upload said variant to the service according to a derivative key of the blob and the transformations.
  16. #
  17. # Frequently, though, you don't actually want to transform the variant right away. But rather simply refer to a
  18. # specific variant that can be created by a controller on-demand. Like so:
  19. #
  20. # <%= image_tag Current.user.avatar.variant(resize_to_limit: [100, 100]) %>
  21. #
  22. # This will create a URL for that specific blob with that specific variant, which the ActiveStorage::RepresentationsController
  23. # can then produce on-demand.
  24. #
  25. # Raises ActiveStorage::InvariableError if ImageMagick cannot transform the blob. To determine whether a blob is
  26. # variable, call ActiveStorage::Blob#variable?.
  27. 3 def variant(transformations)
  28. 103 if variable?
  29. 100 variant_class.new(self, transformations)
  30. else
  31. 3 raise ActiveStorage::InvariableError
  32. end
  33. end
  34. # Returns true if ImageMagick can transform the blob (its content type is in +ActiveStorage.variable_content_types+).
  35. 3 def variable?
  36. 113 ActiveStorage.variable_content_types.include?(content_type)
  37. end
  38. # Returns an ActiveStorage::Preview instance with the set of +transformations+ provided. A preview is an image generated
  39. # from a non-image blob. Active Storage comes with built-in previewers for videos and PDF documents. The video previewer
  40. # extracts the first frame from a video and the PDF previewer extracts the first page from a PDF document.
  41. #
  42. # blob.preview(resize_to_limit: [100, 100]).processed.url
  43. #
  44. # Avoid processing previews synchronously in views. Instead, link to a controller action that processes them on demand.
  45. # Active Storage provides one, but you may want to create your own (for example, if you need authentication). Here’s
  46. # how to use the built-in version:
  47. #
  48. # <%= image_tag video.preview(resize_to_limit: [100, 100]) %>
  49. #
  50. # This method raises ActiveStorage::UnpreviewableError if no previewer accepts the receiving blob. To determine
  51. # whether a blob is accepted by any previewer, call ActiveStorage::Blob#previewable?.
  52. 3 def preview(transformations)
  53. 31 if previewable?
  54. 28 ActiveStorage::Preview.new(self, transformations)
  55. else
  56. 3 raise ActiveStorage::UnpreviewableError
  57. end
  58. end
  59. # Returns true if any registered previewer accepts the blob. By default, this will return true for videos and PDF documents.
  60. 3 def previewable?
  61. 148 ActiveStorage.previewers.any? { |klass| klass.accept?(self) }
  62. end
  63. # Returns an ActiveStorage::Preview for a previewable blob or an ActiveStorage::Variant for a variable image blob.
  64. #
  65. # blob.representation(resize_to_limit: [100, 100]).processed.url
  66. #
  67. # Raises ActiveStorage::UnrepresentableError if the receiving blob is neither variable nor previewable. Call
  68. # ActiveStorage::Blob#representable? to determine whether a blob is representable.
  69. #
  70. # See ActiveStorage::Blob#preview and ActiveStorage::Blob#variant for more information.
  71. 3 def representation(transformations)
  72. case
  73. when previewable?
  74. 10 preview transformations
  75. when variable?
  76. 8 variant transformations
  77. else
  78. 2 raise ActiveStorage::UnrepresentableError
  79. 20 end
  80. end
  81. # Returns true if the blob is variable or previewable.
  82. 3 def representable?
  83. variable? || previewable?
  84. end
  85. 3 private
  86. 3 def variant_class
  87. 100 ActiveStorage.track_variants ? ActiveStorage::VariantWithRecord : ActiveStorage::Variant
  88. end
  89. end

app/models/active_storage/current.rb

100.0% lines covered

2 relevant lines. 2 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 3 class ActiveStorage::Current < ActiveSupport::CurrentAttributes #:nodoc:
  3. 3 attribute :host
  4. end

app/models/active_storage/filename.rb

95.83% lines covered

24 relevant lines. 23 lines covered and 1 lines missed.
    
  1. # frozen_string_literal: true
  2. # Encapsulates a string representing a filename to provide convenient access to parts of it and sanitization.
  3. # A Filename instance is returned by ActiveStorage::Blob#filename, and is comparable so it can be used for sorting.
  4. 3 class ActiveStorage::Filename
  5. 3 include Comparable
  6. 3 class << self
  7. # Returns a Filename instance based on the given filename. If the filename is a Filename, it is
  8. # returned unmodified. If it is a String, it is passed to ActiveStorage::Filename.new.
  9. 3 def wrap(filename)
  10. 46 filename.kind_of?(self) ? filename : new(filename)
  11. end
  12. end
  13. 3 def initialize(filename)
  14. 1716 @filename = filename
  15. end
  16. # Returns the part of the filename preceding any extension.
  17. #
  18. # ActiveStorage::Filename.new("racecar.jpg").base # => "racecar"
  19. # ActiveStorage::Filename.new("racecar").base # => "racecar"
  20. # ActiveStorage::Filename.new(".gitignore").base # => ".gitignore"
  21. 3 def base
  22. 53 File.basename @filename, extension_with_delimiter
  23. end
  24. # Returns the extension of the filename (i.e. the substring following the last dot, excluding a dot at the
  25. # beginning) with the dot that precedes it. If the filename has no extension, an empty string is returned.
  26. #
  27. # ActiveStorage::Filename.new("racecar.jpg").extension_with_delimiter # => ".jpg"
  28. # ActiveStorage::Filename.new("racecar").extension_with_delimiter # => ""
  29. # ActiveStorage::Filename.new(".gitignore").extension_with_delimiter # => ""
  30. 3 def extension_with_delimiter
  31. 224 File.extname @filename
  32. end
  33. # Returns the extension of the filename (i.e. the substring following the last dot, excluding a dot at
  34. # the beginning). If the filename has no extension, an empty string is returned.
  35. #
  36. # ActiveStorage::Filename.new("racecar.jpg").extension_without_delimiter # => "jpg"
  37. # ActiveStorage::Filename.new("racecar").extension_without_delimiter # => ""
  38. # ActiveStorage::Filename.new(".gitignore").extension_without_delimiter # => ""
  39. 3 def extension_without_delimiter
  40. 9 extension_with_delimiter.from(1).to_s
  41. end
  42. 3 alias_method :extension, :extension_without_delimiter
  43. # Returns the sanitized filename.
  44. #
  45. # ActiveStorage::Filename.new("foo:bar.jpg").sanitized # => "foo-bar.jpg"
  46. # ActiveStorage::Filename.new("foo/bar.jpg").sanitized # => "foo-bar.jpg"
  47. #
  48. # Characters considered unsafe for storage (e.g. \, $, and the RTL override character) are replaced with a dash.
  49. 3 def sanitized
  50. 1290 @filename.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "�").strip.tr("\u{202E}%$|:;/\t\r\n\\", "-")
  51. end
  52. # Returns the sanitized version of the filename.
  53. 3 def to_s
  54. 1115 sanitized.to_s
  55. end
  56. 3 def as_json(*)
  57. 6 to_s
  58. end
  59. 3 def to_json
  60. to_s
  61. end
  62. 3 def <=>(other)
  63. 10 to_s.downcase <=> other.to_s.downcase
  64. end
  65. end

app/models/active_storage/preview.rb

86.49% lines covered

37 relevant lines. 32 lines covered and 5 lines missed.
    
  1. # frozen_string_literal: true
  2. # Some non-image blobs can be previewed: that is, they can be presented as images. A video blob can be previewed by
  3. # extracting its first frame, and a PDF blob can be previewed by extracting its first page.
  4. #
  5. # A previewer extracts a preview image from a blob. Active Storage provides previewers for videos and PDFs.
  6. # ActiveStorage::Previewer::VideoPreviewer is used for videos whereas ActiveStorage::Previewer::PopplerPDFPreviewer
  7. # and ActiveStorage::Previewer::MuPDFPreviewer are used for PDFs. Build custom previewers by
  8. # subclassing ActiveStorage::Previewer and implementing the requisite methods. Consult the ActiveStorage::Previewer
  9. # documentation for more details on what's required of previewers.
  10. #
  11. # To choose the previewer for a blob, Active Storage calls +accept?+ on each registered previewer in order. It uses the
  12. # first previewer for which +accept?+ returns true when given the blob. In a Rails application, add or remove previewers
  13. # by manipulating +Rails.application.config.active_storage.previewers+ in an initializer:
  14. #
  15. # Rails.application.config.active_storage.previewers
  16. # # => [ ActiveStorage::Previewer::PopplerPDFPreviewer, ActiveStorage::Previewer::MuPDFPreviewer, ActiveStorage::Previewer::VideoPreviewer ]
  17. #
  18. # # Add a custom previewer for Microsoft Office documents:
  19. # Rails.application.config.active_storage.previewers << DOCXPreviewer
  20. # # => [ ActiveStorage::Previewer::PopplerPDFPreviewer, ActiveStorage::Previewer::MuPDFPreviewer, ActiveStorage::Previewer::VideoPreviewer, DOCXPreviewer ]
  21. #
  22. # Outside of a Rails application, modify +ActiveStorage.previewers+ instead.
  23. #
  24. # The built-in previewers rely on third-party system libraries. Specifically, the built-in video previewer requires
  25. # {FFmpeg}[https://www.ffmpeg.org]. Two PDF previewers are provided: one requires {Poppler}[https://poppler.freedesktop.org],
  26. # and the other requires {muPDF}[https://mupdf.com] (version 1.8 or newer). To preview PDFs, install either Poppler or muPDF.
  27. #
  28. # These libraries are not provided by Rails. You must install them yourself to use the built-in previewers. Before you
  29. # install and use third-party software, make sure you understand the licensing implications of doing so.
  30. 3 class ActiveStorage::Preview
  31. 3 class UnprocessedError < StandardError; end
  32. 3 attr_reader :blob, :variation
  33. 3 def initialize(blob, variation_or_variation_key)
  34. 28 @blob, @variation = blob, ActiveStorage::Variation.wrap(variation_or_variation_key)
  35. end
  36. # Processes the preview if it has not been processed yet. Returns the receiving Preview instance for convenience:
  37. #
  38. # blob.preview(resize_to_limit: [100, 100]).processed.url
  39. #
  40. # Processing a preview generates an image from its blob and attaches the preview image to the blob. Because the preview
  41. # image is stored with the blob, it is only generated once.
  42. 3 def processed
  43. 25 process unless processed?
  44. 25 self
  45. end
  46. # Returns the blob's attached preview image.
  47. 3 def image
  48. 99 blob.preview_image
  49. end
  50. # Returns the URL of the preview's variant on the service. Raises ActiveStorage::Preview::UnprocessedError if the
  51. # preview has not been processed yet.
  52. #
  53. # This method synchronously processes a variant of the preview image, so do not call it in views. Instead, generate
  54. # a stable URL that redirects to the URL returned by this method.
  55. 3 def url(**options)
  56. 3 if processed?
  57. 3 variant.url(**options)
  58. else
  59. raise UnprocessedError
  60. end
  61. end
  62. 3 alias_method :service_url, :url
  63. 3 deprecate service_url: :url
  64. # Returns a combination key of the blob and the variation that together identifies a specific variant.
  65. 3 def key
  66. if processed?
  67. variant.key
  68. else
  69. raise UnprocessedError
  70. end
  71. end
  72. 3 def download(&block)
  73. 3 if processed?
  74. 3 variant.download(&block)
  75. else
  76. raise UnprocessedError
  77. end
  78. end
  79. 3 private
  80. 3 def processed?
  81. 31 image.attached?
  82. end
  83. 3 def process
  84. 25 previewer.preview(service_name: blob.service_name) do |attachable|
  85. 25 ActiveRecord::Base.connected_to(role: ActiveRecord::Base.writing_role) do
  86. 25 image.attach(attachable)
  87. end
  88. end
  89. end
  90. 3 def variant
  91. 6 image.variant(variation).processed
  92. end
  93. 3 def previewer
  94. 25 previewer_class.new(blob)
  95. end
  96. 3 def previewer_class
  97. 66 ActiveStorage.previewers.detect { |klass| klass.accept?(blob) }
  98. end
  99. end

app/models/active_storage/variant.rb

100.0% lines covered

34 relevant lines. 34 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 3 require "ostruct"
  3. # Image blobs can have variants that are the result of a set of transformations applied to the original.
  4. # These variants are used to create thumbnails, fixed-size avatars, or any other derivative image from the
  5. # original.
  6. #
  7. # Variants rely on {ImageProcessing}[https://github.com/janko-m/image_processing] gem for the actual transformations
  8. # of the file, so you must add <tt>gem "image_processing"</tt> to your Gemfile if you wish to use variants. By
  9. # default, images will be processed with {ImageMagick}[http://imagemagick.org] using the
  10. # {MiniMagick}[https://github.com/minimagick/minimagick] gem, but you can also switch to the
  11. # {libvips}[http://libvips.github.io/libvips/] processor operated by the {ruby-vips}[https://github.com/libvips/ruby-vips]
  12. # gem).
  13. #
  14. # Rails.application.config.active_storage.variant_processor
  15. # # => :mini_magick
  16. #
  17. # Rails.application.config.active_storage.variant_processor = :vips
  18. # # => :vips
  19. #
  20. # Note that to create a variant it's necessary to download the entire blob file from the service. Because of this process,
  21. # you also want to be considerate about when the variant is actually processed. You shouldn't be processing variants inline
  22. # in a template, for example. Delay the processing to an on-demand controller, like the one provided in
  23. # ActiveStorage::RepresentationsController.
  24. #
  25. # To refer to such a delayed on-demand variant, simply link to the variant through the resolved route provided
  26. # by Active Storage like so:
  27. #
  28. # <%= image_tag Current.user.avatar.variant(resize_to_limit: [100, 100]) %>
  29. #
  30. # This will create a URL for that specific blob with that specific variant, which the ActiveStorage::RepresentationsController
  31. # can then produce on-demand.
  32. #
  33. # When you do want to actually produce the variant needed, call +processed+. This will check that the variant
  34. # has already been processed and uploaded to the service, and, if so, just return that. Otherwise it will perform
  35. # the transformations, upload the variant to the service, and return itself again. Example:
  36. #
  37. # avatar.variant(resize_to_limit: [100, 100]).processed.url
  38. #
  39. # This will create and process a variant of the avatar blob that's constrained to a height and width of 100.
  40. # Then it'll upload said variant to the service according to a derivative key of the blob and the transformations.
  41. #
  42. # You can combine any number of ImageMagick/libvips operations into a variant, as well as any macros provided by the
  43. # ImageProcessing gem (such as +resize_to_limit+):
  44. #
  45. # avatar.variant(resize_to_limit: [800, 800], monochrome: true, rotate: "-90")
  46. #
  47. # Visit the following links for a list of available ImageProcessing commands and ImageMagick/libvips operations:
  48. #
  49. # * {ImageProcessing::MiniMagick}[https://github.com/janko-m/image_processing/blob/master/doc/minimagick.md#methods]
  50. # * {ImageMagick reference}[https://www.imagemagick.org/script/mogrify.php]
  51. # * {ImageProcessing::Vips}[https://github.com/janko-m/image_processing/blob/master/doc/vips.md#methods]
  52. # * {ruby-vips reference}[http://www.rubydoc.info/gems/ruby-vips/Vips/Image]
  53. 3 class ActiveStorage::Variant
  54. 3 attr_reader :blob, :variation
  55. 3 delegate :service, to: :blob
  56. 3 delegate :filename, :content_type, to: :specification
  57. 3 def initialize(blob, variation_or_variation_key)
  58. 88 @blob, @variation = blob, ActiveStorage::Variation.wrap(variation_or_variation_key)
  59. end
  60. # Returns the variant instance itself after it's been processed or an existing processing has been found on the service.
  61. 3 def processed
  62. 67 process unless processed?
  63. 64 self
  64. end
  65. # Returns a combination key of the blob and the variation that together identifies a specific variant.
  66. 3 def key
  67. 240 "variants/#{blob.key}/#{Digest::SHA256.hexdigest(variation.key)}"
  68. end
  69. # Returns the URL of the blob variant on the service. See {ActiveStorage::Blob#url} for details.
  70. #
  71. # Use <tt>url_for(variant)</tt> (or the implied form, like +link_to variant+ or +redirect_to variant+) to get the stable URL
  72. # for a variant that points to the ActiveStorage::RepresentationsController, which in turn will use this +service_call+ method
  73. # for its redirection.
  74. 3 def url(expires_in: ActiveStorage.service_urls_expire_in, disposition: :inline)
  75. 45 service.url key, expires_in: expires_in, disposition: disposition, filename: filename, content_type: content_type
  76. end
  77. 3 alias_method :service_url, :url
  78. 3 deprecate service_url: :url
  79. # Downloads the file associated with this variant. If no block is given, the entire file is read into memory and returned.
  80. # That'll use a lot of RAM for very large files. If a block is given, then the download is streamed and yielded in chunks.
  81. 3 def download(&block)
  82. 6 service.download key, &block
  83. end
  84. # Returns the receiving variant. Allows ActiveStorage::Variant and ActiveStorage::Preview instances to be used interchangeably.
  85. 3 def image
  86. 5 self
  87. end
  88. 3 private
  89. 3 def processed?
  90. 67 service.exist?(key)
  91. end
  92. 3 def process
  93. 67 blob.open do |input|
  94. 67 variation.transform(input, format: format) do |output|
  95. 64 service.upload(key, output, content_type: content_type)
  96. end
  97. end
  98. end
  99. 3 def specification
  100. 227 @specification ||=
  101. 67 if ActiveStorage.web_image_content_types.include?(blob.content_type)
  102. 55 Specification.new \
  103. filename: blob.filename,
  104. content_type: blob.content_type,
  105. format: nil
  106. else
  107. 12 Specification.new \
  108. filename: ActiveStorage::Filename.new("#{blob.filename.base}.png"),
  109. content_type: "image/png",
  110. format: "png"
  111. end
  112. end
  113. 3 delegate :format, to: :specification
  114. 3 class Specification < OpenStruct; end
  115. end

app/models/active_storage/variant_record.rb

100.0% lines covered

4 relevant lines. 4 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 3 class ActiveStorage::VariantRecord < ActiveRecord::Base
  3. 3 self.table_name = "active_storage_variant_records"
  4. 3 belongs_to :blob
  5. 3 has_one_attached :image
  6. end

app/models/active_storage/variant_with_record.rb

86.67% lines covered

30 relevant lines. 26 lines covered and 4 lines missed.
    
  1. # frozen_string_literal: true
  2. 3 class ActiveStorage::VariantWithRecord
  3. 3 attr_reader :blob, :variation
  4. 3 def initialize(blob, variation)
  5. 12 @blob, @variation = blob, ActiveStorage::Variation.wrap(variation)
  6. end
  7. 3 def processed
  8. process
  9. self
  10. end
  11. 3 def process
  12. 21 transform_blob { |image| create_or_find_record(image: image) } unless processed?
  13. end
  14. 3 def processed?
  15. 12 record.present?
  16. end
  17. 3 def image
  18. 12 record&.image
  19. end
  20. 3 delegate :key, :url, :download, to: :image, allow_nil: true
  21. 3 alias_method :service_url, :url
  22. 3 deprecate service_url: :url
  23. 3 private
  24. 3 def transform_blob
  25. 9 blob.open do |input|
  26. 9 if blob.content_type.in?(ActiveStorage.web_image_content_types)
  27. 9 variation.transform(input) do |output|
  28. 9 yield io: output, filename: blob.filename, content_type: blob.content_type, service_name: blob.service.name
  29. end
  30. else
  31. variation.transform(input, format: "png") do |output|
  32. yield io: output, filename: "#{blob.filename.base}.png", content_type: "image/png", service_name: blob.service.name
  33. end
  34. end
  35. end
  36. end
  37. 3 def create_or_find_record(image:)
  38. 9 @record =
  39. ActiveRecord::Base.connected_to(role: ActiveRecord::Base.writing_role) do
  40. 9 blob.variant_records.create_or_find_by!(variation_digest: variation.digest) do |record|
  41. 9 record.image.attach(image)
  42. end
  43. end
  44. end
  45. 3 def record
  46. 24 @record ||= blob.variant_records.find_by(variation_digest: variation.digest)
  47. end
  48. end

app/models/active_storage/variation.rb

90.32% lines covered

31 relevant lines. 28 lines covered and 3 lines missed.
    
  1. # frozen_string_literal: true
  2. # A set of transformations that can be applied to a blob to create a variant. This class is exposed via
  3. # the ActiveStorage::Blob#variant method and should rarely be used directly.
  4. #
  5. # In case you do need to use this directly, it's instantiated using a hash of transformations where
  6. # the key is the command and the value is the arguments. Example:
  7. #
  8. # ActiveStorage::Variation.new(resize_to_limit: [100, 100], monochrome: true, trim: true, rotate: "-90")
  9. #
  10. # The options map directly to {ImageProcessing}[https://github.com/janko-m/image_processing] commands.
  11. 3 class ActiveStorage::Variation
  12. 3 attr_reader :transformations
  13. 3 class << self
  14. # Returns a Variation instance based on the given variator. If the variator is a Variation, it is
  15. # returned unmodified. If it is a String, it is passed to ActiveStorage::Variation.decode. Otherwise,
  16. # it is assumed to be a transformations Hash and is passed directly to the constructor.
  17. 3 def wrap(variator)
  18. 128 case variator
  19. when self
  20. 6 variator
  21. when String
  22. 12 decode variator
  23. else
  24. 110 new variator
  25. end
  26. end
  27. # Returns a Variation instance with the transformations that were encoded by +encode+.
  28. 3 def decode(key)
  29. 12 new ActiveStorage.verifier.verify(key, purpose: :variation)
  30. end
  31. # Returns a signed key for the +transformations+, which can be used to refer to a specific
  32. # variation in a URL or combined key (like <tt>ActiveStorage::Variant#key</tt>).
  33. 3 def encode(transformations)
  34. 276 ActiveStorage.verifier.generate(transformations, purpose: :variation)
  35. end
  36. end
  37. 3 def initialize(transformations)
  38. 122 @transformations = transformations.deep_symbolize_keys
  39. end
  40. # Accepts a File object, performs the +transformations+ against it, and
  41. # saves the transformed image into a temporary file. If +format+ is specified
  42. # it will be the format of the result image, otherwise the result image
  43. # retains the source format.
  44. 3 def transform(file, format: nil, &block)
  45. 76 ActiveSupport::Notifications.instrument("transform.active_storage") do
  46. 76 transformer.transform(file, format: format, &block)
  47. end
  48. end
  49. # Returns a signed key for all the +transformations+ that this variation was instantiated with.
  50. 3 def key
  51. 252 self.class.encode(transformations)
  52. end
  53. 3 def digest
  54. 24 Digest::SHA1.base64digest Marshal.dump(transformations)
  55. end
  56. 3 private
  57. 3 def transformer
  58. 76 if ActiveStorage.variant_processor
  59. 67 begin
  60. 67 require "image_processing"
  61. rescue LoadError
  62. ActiveSupport::Deprecation.warn <<~WARNING.squish
  63. Generating image variants will require the image_processing gem in Rails 6.1.
  64. Please add `gem 'image_processing', '~> 1.2'` to your Gemfile.
  65. WARNING
  66. ActiveStorage::Transformers::MiniMagickTransformer.new(transformations)
  67. else
  68. 67 ActiveStorage::Transformers::ImageProcessingTransformer.new(transformations)
  69. end
  70. else
  71. 9 ActiveStorage::Transformers::MiniMagickTransformer.new(transformations)
  72. end
  73. end
  74. end

config/routes.rb

75.68% lines covered

37 relevant lines. 28 lines covered and 9 lines missed.
    
  1. # frozen_string_literal: true
  2. Rails.application.routes.draw do
  3. 3 scope ActiveStorage.routes_prefix do
  4. 3 get "/blobs/redirect/:signed_id/*filename" => "active_storage/blobs/redirect#show", as: :rails_service_blob
  5. 3 get "/blobs/proxy/:signed_id/*filename" => "active_storage/blobs/proxy#show", as: :rails_service_blob_proxy
  6. 3 get "/blobs/:signed_id/*filename" => "active_storage/blobs/redirect#show"
  7. 3 get "/representations/redirect/:signed_blob_id/:variation_key/*filename" => "active_storage/representations/redirect#show", as: :rails_blob_representation
  8. 3 get "/representations/proxy/:signed_blob_id/:variation_key/*filename" => "active_storage/representations/proxy#show", as: :rails_blob_representation_proxy
  9. 3 get "/representations/:signed_blob_id/:variation_key/*filename" => "active_storage/representations/redirect#show"
  10. 3 get "/disk/:encoded_key/*filename" => "active_storage/disk#show", as: :rails_disk_service
  11. 3 put "/disk/:encoded_token" => "active_storage/disk#update", as: :update_rails_disk_service
  12. 3 post "/direct_uploads" => "active_storage/direct_uploads#create", as: :rails_direct_uploads
  13. end
  14. 3 direct :rails_representation do |representation, options|
  15. signed_blob_id = representation.blob.signed_id
  16. variation_key = representation.variation.key
  17. filename = representation.blob.filename
  18. route_for(:rails_blob_representation, signed_blob_id, variation_key, filename, options)
  19. end
  20. 9 resolve("ActiveStorage::Variant") { |variant, options| route_for(ActiveStorage.resolve_model_to_route, variant, options) }
  21. 3 resolve("ActiveStorage::VariantWithRecord") { |variant, options| route_for(ActiveStorage.resolve_model_to_route, variant, options) }
  22. 9 resolve("ActiveStorage::Preview") { |preview, options| route_for(ActiveStorage.resolve_model_to_route, preview, options) }
  23. 3 direct :rails_blob do |blob, options|
  24. route_for(:rails_service_blob, blob.signed_id, blob.filename, options)
  25. end
  26. 9 resolve("ActiveStorage::Blob") { |blob, options| route_for(ActiveStorage.resolve_model_to_route, blob, options) }
  27. 9 resolve("ActiveStorage::Attachment") { |attachment, options| route_for(ActiveStorage.resolve_model_to_route, attachment.blob, options) }
  28. 3 direct :rails_storage_proxy do |model, options|
  29. 2 if model.respond_to?(:signed_id)
  30. 2 route_for(
  31. :rails_service_blob_proxy,
  32. model.signed_id,
  33. model.filename,
  34. options
  35. )
  36. else
  37. signed_blob_id = model.blob.signed_id
  38. variation_key = model.variation.key
  39. filename = model.blob.filename
  40. route_for(
  41. :rails_blob_representation_proxy,
  42. signed_blob_id,
  43. variation_key,
  44. filename,
  45. options
  46. )
  47. end
  48. end
  49. 3 direct :rails_storage_redirect do |model, options|
  50. 27 if model.respond_to?(:signed_id)
  51. 15 route_for(
  52. :rails_service_blob,
  53. model.signed_id,
  54. model.filename,
  55. options
  56. )
  57. else
  58. 12 signed_blob_id = model.blob.signed_id
  59. 12 variation_key = model.variation.key
  60. 12 filename = model.blob.filename
  61. 12 route_for(
  62. :rails_blob_representation,
  63. signed_blob_id,
  64. variation_key,
  65. filename,
  66. options
  67. )
  68. end
  69. end
  70. 3 end if ActiveStorage.draw_routes

db/migrate/20170806125915_create_active_storage_tables.rb

100.0% lines covered

24 relevant lines. 24 lines covered and 0 lines missed.
    
  1. 3 class CreateActiveStorageTables < ActiveRecord::Migration[5.2]
  2. 3 def change
  3. 3 create_table :active_storage_blobs do |t|
  4. 3 t.string :key, null: false
  5. 3 t.string :filename, null: false
  6. 3 t.string :content_type
  7. 3 t.text :metadata
  8. 3 t.string :service_name, null: false
  9. 3 t.bigint :byte_size, null: false
  10. 3 t.string :checksum, null: false
  11. 3 t.datetime :created_at, null: false
  12. 3 t.index [ :key ], unique: true
  13. end
  14. 3 create_table :active_storage_attachments do |t|
  15. 3 t.string :name, null: false
  16. 3 t.references :record, null: false, polymorphic: true, index: false
  17. 3 t.references :blob, null: false
  18. 3 t.datetime :created_at, null: false
  19. 3 t.index [ :record_type, :record_id, :name, :blob_id ], name: "index_active_storage_attachments_uniqueness", unique: true
  20. 3 t.foreign_key :active_storage_blobs, column: :blob_id
  21. end
  22. 3 create_table :active_storage_variant_records do |t|
  23. 3 t.belongs_to :blob, null: false, index: false
  24. 3 t.string :variation_digest, null: false
  25. 3 t.index %i[ blob_id variation_digest ], name: "index_active_storage_variant_records_uniqueness", unique: true
  26. 3 t.foreign_key :active_storage_blobs, column: :blob_id
  27. end
  28. end
  29. end

lib/active_storage.rb

100.0% lines covered

36 relevant lines. 36 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. #--
  3. # Copyright (c) 2017-2020 David Heinemeier Hansson, Basecamp
  4. #
  5. # Permission is hereby granted, free of charge, to any person obtaining
  6. # a copy of this software and associated documentation files (the
  7. # "Software"), to deal in the Software without restriction, including
  8. # without limitation the rights to use, copy, modify, merge, publish,
  9. # distribute, sublicense, and/or sell copies of the Software, and to
  10. # permit persons to whom the Software is furnished to do so, subject to
  11. # the following conditions:
  12. #
  13. # The above copyright notice and this permission notice shall be
  14. # included in all copies or substantial portions of the Software.
  15. #
  16. # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
  17. # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
  18. # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
  19. # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
  20. # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
  21. # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
  22. # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
  23. #++
  24. 3 require "active_record"
  25. 3 require "active_support"
  26. 3 require "active_support/rails"
  27. 3 require "active_support/core_ext/numeric/time"
  28. 3 require "active_storage/version"
  29. 3 require "active_storage/errors"
  30. 3 require "marcel"
  31. 3 module ActiveStorage
  32. 3 extend ActiveSupport::Autoload
  33. 3 autoload :Attached
  34. 3 autoload :Service
  35. 3 autoload :Previewer
  36. 3 autoload :Analyzer
  37. 3 mattr_accessor :logger
  38. 3 mattr_accessor :verifier
  39. 3 mattr_accessor :variant_processor, default: :mini_magick
  40. 3 mattr_accessor :queues, default: {}
  41. 3 mattr_accessor :previewers, default: []
  42. 3 mattr_accessor :analyzers, default: []
  43. 3 mattr_accessor :paths, default: {}
  44. 3 mattr_accessor :variable_content_types, default: []
  45. 3 mattr_accessor :web_image_content_types, default: []
  46. 3 mattr_accessor :binary_content_type, default: "application/octet-stream"
  47. 3 mattr_accessor :content_types_to_serve_as_binary, default: []
  48. 3 mattr_accessor :content_types_allowed_inline, default: []
  49. 3 mattr_accessor :service_urls_expire_in, default: 5.minutes
  50. 3 mattr_accessor :routes_prefix, default: "/rails/active_storage"
  51. 3 mattr_accessor :draw_routes, default: true
  52. 3 mattr_accessor :resolve_model_to_route, default: :rails_storage_redirect
  53. 3 mattr_accessor :replace_on_assign_to_many, default: false
  54. 3 mattr_accessor :track_variants, default: false
  55. 3 module Transformers
  56. 3 extend ActiveSupport::Autoload
  57. 3 autoload :Transformer
  58. 3 autoload :ImageProcessingTransformer
  59. 3 autoload :MiniMagickTransformer
  60. end
  61. end

lib/active_storage/analyzer.rb

88.89% lines covered

18 relevant lines. 16 lines covered and 2 lines missed.
    
  1. # frozen_string_literal: true
  2. 3 module ActiveStorage
  3. # This is an abstract base class for analyzers, which extract metadata from blobs. See
  4. # ActiveStorage::Analyzer::ImageAnalyzer for an example of a concrete subclass.
  5. 3 class Analyzer
  6. 3 attr_reader :blob
  7. # Implement this method in a concrete subclass. Have it return true when given a blob from which
  8. # the analyzer can extract metadata.
  9. 3 def self.accept?(blob)
  10. false
  11. end
  12. # Implement this method in concrete subclasses. It will determine if blob analysis
  13. # should be done in a job or performed inline. By default, analysis is enqueued in a job.
  14. 3 def self.analyze_later?
  15. 145 true
  16. end
  17. 3 def initialize(blob)
  18. 262 @blob = blob
  19. end
  20. # Override this method in a concrete subclass. Have it return a Hash of metadata.
  21. 3 def metadata
  22. raise NotImplementedError
  23. end
  24. 3 private
  25. # Downloads the blob to a tempfile on disk. Yields the tempfile.
  26. 3 def download_blob_to_tempfile(&block) #:doc:
  27. 38 blob.open tmpdir: tmpdir, &block
  28. end
  29. 3 def logger #:doc:
  30. 2 ActiveStorage.logger
  31. end
  32. 3 def tmpdir #:doc:
  33. 38 Dir.tmpdir
  34. end
  35. end
  36. end

lib/active_storage/analyzer/image_analyzer.rb

83.33% lines covered

24 relevant lines. 20 lines covered and 4 lines missed.
    
  1. # frozen_string_literal: true
  2. 3 module ActiveStorage
  3. # Extracts width and height in pixels from an image blob.
  4. #
  5. # If the image contains EXIF data indicating its angle is 90 or 270 degrees, its width and height are swapped for convenience.
  6. #
  7. # Example:
  8. #
  9. # ActiveStorage::Analyzer::ImageAnalyzer.new(blob).metadata
  10. # # => { width: 4104, height: 2736 }
  11. #
  12. # This analyzer relies on the third-party {MiniMagick}[https://github.com/minimagick/minimagick] gem. MiniMagick requires
  13. # the {ImageMagick}[http://www.imagemagick.org] system library.
  14. 3 class Analyzer::ImageAnalyzer < Analyzer
  15. 3 def self.accept?(blob)
  16. 631 blob.image?
  17. end
  18. 3 def metadata
  19. 28 read_image do |image|
  20. 26 if rotated_image?(image)
  21. 2 { width: image.height, height: image.width }
  22. else
  23. 23 { width: image.width, height: image.height }
  24. end
  25. end
  26. end
  27. 3 private
  28. 3 def read_image
  29. 28 download_blob_to_tempfile do |file|
  30. 28 require "mini_magick"
  31. 28 image = MiniMagick::Image.new(file.path)
  32. 28 if image.valid?
  33. 26 yield image
  34. else
  35. 2 logger.info "Skipping image analysis because ImageMagick doesn't support the file"
  36. 2 {}
  37. end
  38. end
  39. rescue LoadError
  40. logger.info "Skipping image analysis because the mini_magick gem isn't installed"
  41. {}
  42. rescue MiniMagick::Error => error
  43. logger.error "Skipping image analysis due to an ImageMagick error: #{error.message}"
  44. {}
  45. end
  46. 3 def rotated_image?(image)
  47. 26 %w[ RightTop LeftBottom ].include?(image["%[orientation]"])
  48. end
  49. end
  50. end

lib/active_storage/analyzer/null_analyzer.rb

87.5% lines covered

8 relevant lines. 7 lines covered and 1 lines missed.
    
  1. # frozen_string_literal: true
  2. 3 module ActiveStorage
  3. 3 class Analyzer::NullAnalyzer < Analyzer # :nodoc:
  4. 3 def self.accept?(blob)
  5. true
  6. end
  7. 3 def self.analyze_later?
  8. 224 false
  9. end
  10. 3 def metadata
  11. 224 {}
  12. end
  13. end
  14. end

lib/active_storage/analyzer/video_analyzer.rb

96.08% lines covered

51 relevant lines. 49 lines covered and 2 lines missed.
    
  1. # frozen_string_literal: true
  2. 3 module ActiveStorage
  3. # Extracts the following from a video blob:
  4. #
  5. # * Width (pixels)
  6. # * Height (pixels)
  7. # * Duration (seconds)
  8. # * Angle (degrees)
  9. # * Display aspect ratio
  10. #
  11. # Example:
  12. #
  13. # ActiveStorage::Analyzer::VideoAnalyzer.new(blob).metadata
  14. # # => { width: 640.0, height: 480.0, duration: 5.0, angle: 0, display_aspect_ratio: [4, 3] }
  15. #
  16. # When a video's angle is 90 or 270 degrees, its width and height are automatically swapped for convenience.
  17. #
  18. # This analyzer requires the {FFmpeg}[https://www.ffmpeg.org] system library, which is not provided by Rails.
  19. 3 class Analyzer::VideoAnalyzer < Analyzer
  20. 3 def self.accept?(blob)
  21. 472 blob.video?
  22. end
  23. 3 def metadata
  24. 10 { width: width, height: height, duration: duration, angle: angle, display_aspect_ratio: display_aspect_ratio }.compact
  25. end
  26. 3 private
  27. 3 def width
  28. 10 if rotated?
  29. 2 computed_height || encoded_height
  30. else
  31. 8 encoded_width
  32. end
  33. end
  34. 3 def height
  35. 10 if rotated?
  36. 2 encoded_width
  37. else
  38. 8 computed_height || encoded_height
  39. end
  40. end
  41. 3 def duration
  42. 10 Float(video_stream["duration"]) if video_stream["duration"]
  43. end
  44. 3 def angle
  45. 46 Integer(tags["rotate"]) if tags["rotate"]
  46. end
  47. 3 def display_aspect_ratio
  48. 36 if descriptor = video_stream["display_aspect_ratio"]
  49. 30 if terms = descriptor.split(":", 2)
  50. 30 numerator = Integer(terms[0])
  51. 30 denominator = Integer(terms[1])
  52. 30 [numerator, denominator] unless numerator == 0
  53. end
  54. end
  55. end
  56. 3 def rotated?
  57. 20 angle == 90 || angle == 270
  58. end
  59. 3 def computed_height
  60. 10 if encoded_width && display_height_scale
  61. 6 encoded_width * display_height_scale
  62. end
  63. end
  64. 3 def encoded_width
  65. 26 @encoded_width ||= Float(video_stream["width"]) if video_stream["width"]
  66. end
  67. 3 def encoded_height
  68. 4 @encoded_height ||= Float(video_stream["height"]) if video_stream["height"]
  69. end
  70. 3 def display_height_scale
  71. 14 @display_height_scale ||= Float(display_aspect_ratio.last) / display_aspect_ratio.first if display_aspect_ratio
  72. end
  73. 3 def tags
  74. 52 @tags ||= video_stream["tags"] || {}
  75. end
  76. 3 def video_stream
  77. 118 @video_stream ||= streams.detect { |stream| stream["codec_type"] == "video" } || {}
  78. end
  79. 3 def streams
  80. 10 probe["streams"] || []
  81. end
  82. 3 def probe
  83. 20 download_blob_to_tempfile { |file| probe_from(file) }
  84. end
  85. 3 def probe_from(file)
  86. 10 IO.popen([ ffprobe_path, "-print_format", "json", "-show_streams", "-v", "error", file.path ]) do |output|
  87. 10 JSON.parse(output.read)
  88. end
  89. rescue Errno::ENOENT
  90. logger.info "Skipping video analysis because FFmpeg isn't installed"
  91. {}
  92. end
  93. 3 def ffprobe_path
  94. 10 ActiveStorage.paths[:ffprobe] || "ffprobe"
  95. end
  96. end
  97. end

lib/active_storage/attached.rb

100.0% lines covered

13 relevant lines. 13 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 3 require "active_support/core_ext/module/delegation"
  3. 3 module ActiveStorage
  4. # Abstract base class for the concrete ActiveStorage::Attached::One and ActiveStorage::Attached::Many
  5. # classes that both provide proxy access to the blob association for a record.
  6. 3 class Attached
  7. 3 attr_reader :name, :record
  8. 3 def initialize(name, record)
  9. 298 @name, @record = name, record
  10. end
  11. 3 private
  12. 3 def change
  13. 1793 record.attachment_changes[name]
  14. end
  15. end
  16. end
  17. 3 require "active_storage/attached/model"
  18. 3 require "active_storage/attached/one"
  19. 3 require "active_storage/attached/many"
  20. 3 require "active_storage/attached/changes"

lib/active_storage/attached/changes.rb

100.0% lines covered

9 relevant lines. 9 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 3 module ActiveStorage
  3. 3 module Attached::Changes #:nodoc:
  4. 3 extend ActiveSupport::Autoload
  5. 3 eager_autoload do
  6. 3 autoload :CreateOne
  7. 3 autoload :CreateMany
  8. 3 autoload :CreateOneOfMany
  9. 3 autoload :DeleteOne
  10. 3 autoload :DeleteMany
  11. end
  12. end
  13. end

lib/active_storage/attached/changes/create_many.rb

100.0% lines covered

24 relevant lines. 24 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 3 module ActiveStorage
  3. 3 class Attached::Changes::CreateMany #:nodoc:
  4. 3 attr_reader :name, :record, :attachables
  5. 3 def initialize(name, record, attachables)
  6. 133 @name, @record, @attachables = name, record, Array(attachables)
  7. 133 blobs.each(&:identify_without_saving)
  8. end
  9. 3 def attachments
  10. 377 @attachments ||= subchanges.collect(&:attachment)
  11. end
  12. 3 def blobs
  13. 133 @blobs ||= subchanges.collect(&:blob)
  14. end
  15. 3 def upload
  16. 119 subchanges.each(&:upload)
  17. end
  18. 3 def save
  19. 119 assign_associated_attachments
  20. 119 reset_associated_blobs
  21. end
  22. 3 private
  23. 3 def subchanges
  24. 612 @subchanges ||= attachables.collect { |attachable| build_subchange_from(attachable) }
  25. end
  26. 3 def build_subchange_from(attachable)
  27. 233 ActiveStorage::Attached::Changes::CreateOneOfMany.new(name, record, attachable)
  28. end
  29. 3 def assign_associated_attachments
  30. 119 record.public_send("#{name}_attachments=", attachments)
  31. end
  32. 3 def reset_associated_blobs
  33. 119 record.public_send("#{name}_blobs").reset
  34. end
  35. end
  36. end

lib/active_storage/attached/changes/create_one.rb

100.0% lines covered

36 relevant lines. 36 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 3 require "action_dispatch"
  3. 3 require "action_dispatch/http/upload"
  4. 3 module ActiveStorage
  5. 3 class Attached::Changes::CreateOne #:nodoc:
  6. 3 attr_reader :name, :record, :attachable
  7. 3 def initialize(name, record, attachable)
  8. 425 @name, @record, @attachable = name, record, attachable
  9. 425 blob.identify_without_saving
  10. end
  11. 3 def attachment
  12. 523 @attachment ||= find_or_build_attachment
  13. end
  14. 3 def blob
  15. 1607 @blob ||= find_or_build_blob
  16. end
  17. 3 def upload
  18. 393 case attachable
  19. when ActionDispatch::Http::UploadedFile, Rack::Test::UploadedFile
  20. 53 blob.upload_without_unfurling(attachable.open)
  21. when Hash
  22. 65 blob.upload_without_unfurling(attachable.fetch(:io))
  23. end
  24. end
  25. 3 def save
  26. 180 record.public_send("#{name}_attachment=", attachment)
  27. 180 record.public_send("#{name}_blob=", blob)
  28. end
  29. 3 private
  30. 3 def find_or_build_attachment
  31. 415 find_attachment || build_attachment
  32. end
  33. 3 def find_attachment
  34. 188 if record.public_send("#{name}_blob") == blob
  35. 4 record.public_send("#{name}_attachment")
  36. end
  37. end
  38. 3 def build_attachment
  39. 403 ActiveStorage::Attachment.new(record: record, name: name, blob: blob)
  40. end
  41. 3 def find_or_build_blob
  42. 425 case attachable
  43. when ActiveStorage::Blob
  44. 259 attachable
  45. when ActionDispatch::Http::UploadedFile, Rack::Test::UploadedFile
  46. 67 ActiveStorage::Blob.build_after_unfurling(
  47. io: attachable.open,
  48. filename: attachable.original_filename,
  49. content_type: attachable.content_type,
  50. record: record,
  51. service_name: attachment_service_name
  52. )
  53. when Hash
  54. 65 ActiveStorage::Blob.build_after_unfurling(
  55. **attachable.reverse_merge(
  56. record: record,
  57. service_name: attachment_service_name
  58. ).symbolize_keys
  59. )
  60. when String
  61. 30 ActiveStorage::Blob.find_signed!(attachable, record: record)
  62. else
  63. 4 raise ArgumentError, "Could not find or build blob: expected attachable, got #{attachable.inspect}"
  64. end
  65. end
  66. 3 def attachment_service_name
  67. 132 record.attachment_reflections[name].options[:service_name]
  68. end
  69. end
  70. end

lib/active_storage/attached/changes/create_one_of_many.rb

100.0% lines covered

5 relevant lines. 5 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 3 module ActiveStorage
  3. 3 class Attached::Changes::CreateOneOfMany < Attached::Changes::CreateOne #:nodoc:
  4. 3 private
  5. 3 def find_attachment
  6. 289 record.public_send("#{name}_attachments").detect { |attachment| attachment.blob_id == blob.id }
  7. end
  8. end
  9. end

lib/active_storage/attached/changes/delete_many.rb

92.31% lines covered

13 relevant lines. 12 lines covered and 1 lines missed.
    
  1. # frozen_string_literal: true
  2. 2 module ActiveStorage
  3. 2 class Attached::Changes::DeleteMany #:nodoc:
  4. 2 attr_reader :name, :record
  5. 2 def initialize(name, record)
  6. 8 @name, @record = name, record
  7. end
  8. 2 def attachables
  9. []
  10. end
  11. 2 def attachments
  12. 4 ActiveStorage::Attachment.none
  13. end
  14. 2 def blobs
  15. 4 ActiveStorage::Blob.none
  16. end
  17. 2 def save
  18. 4 record.public_send("#{name}_attachments=", [])
  19. end
  20. end
  21. end

lib/active_storage/attached/changes/delete_one.rb

100.0% lines covered

8 relevant lines. 8 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 2 module ActiveStorage
  3. 2 class Attached::Changes::DeleteOne #:nodoc:
  4. 2 attr_reader :name, :record
  5. 2 def initialize(name, record)
  6. 8 @name, @record = name, record
  7. end
  8. 2 def attachment
  9. nil
  10. end
  11. 2 def save
  12. 8 record.public_send("#{name}_attachment=", nil)
  13. end
  14. end
  15. end

lib/active_storage/attached/many.rb

100.0% lines covered

16 relevant lines. 16 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 3 module ActiveStorage
  3. # Decorated proxy object representing of multiple attachments to a model.
  4. 3 class Attached::Many < Attached
  5. 3 delegate_missing_to :attachments
  6. # Returns all the associated attachment records.
  7. #
  8. # All methods called on this proxy object that aren't listed here will automatically be delegated to +attachments+.
  9. 3 def attachments
  10. 652 change.present? ? change.attachments : record.public_send("#{name}_attachments")
  11. end
  12. # Returns all attached blobs.
  13. 3 def blobs
  14. 107 change.present? ? change.blobs : record.public_send("#{name}_blobs")
  15. end
  16. # Attaches one or more +attachables+ to the record.
  17. #
  18. # If the record is persisted and unchanged, the attachments are saved to
  19. # the database immediately. Otherwise, they'll be saved to the DB when the
  20. # record is next saved.
  21. #
  22. # document.images.attach(params[:images]) # Array of ActionDispatch::Http::UploadedFile objects
  23. # document.images.attach(params[:signed_blob_id]) # Signed reference to blob from direct upload
  24. # document.images.attach(io: File.open("/path/to/racecar.jpg"), filename: "racecar.jpg", content_type: "image/jpg")
  25. # document.images.attach([ first_blob, second_blob ])
  26. 3 def attach(*attachables)
  27. 105 if record.persisted? && !record.changed?
  28. 81 record.public_send("#{name}=", blobs + attachables.flatten)
  29. 81 record.save
  30. else
  31. 24 record.public_send("#{name}=", (change&.attachables || blobs) + attachables.flatten)
  32. end
  33. end
  34. # Returns true if any attachments have been made.
  35. #
  36. # class Gallery < ApplicationRecord
  37. # has_many_attached :photos
  38. # end
  39. #
  40. # Gallery.new.photos.attached? # => false
  41. 3 def attached?
  42. 38 attachments.any?
  43. end
  44. # Deletes associated attachments without purging them, leaving their respective blobs in place.
  45. 3 def detach
  46. 2 attachments.delete_all if attached?
  47. end
  48. ##
  49. # :method: purge
  50. #
  51. # Directly purges each associated attachment (i.e. destroys the blobs and
  52. # attachments and deletes the files on the service).
  53. ##
  54. # :method: purge_later
  55. #
  56. # Purges each associated attachment through the queuing system.
  57. end
  58. end

lib/active_storage/attached/model.rb

100.0% lines covered

46 relevant lines. 46 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 3 require "active_support/core_ext/object/try"
  3. 3 module ActiveStorage
  4. # Provides the class-level DSL for declaring an Active Record model's attachments.
  5. 3 module Attached::Model
  6. 3 extend ActiveSupport::Concern
  7. 3 class_methods do
  8. # Specifies the relation between a single attachment and the model.
  9. #
  10. # class User < ApplicationRecord
  11. # has_one_attached :avatar
  12. # end
  13. #
  14. # There is no column defined on the model side, Active Storage takes
  15. # care of the mapping between your records and the attachment.
  16. #
  17. # To avoid N+1 queries, you can include the attached blobs in your query like so:
  18. #
  19. # User.with_attached_avatar
  20. #
  21. # Under the covers, this relationship is implemented as a +has_one+ association to a
  22. # ActiveStorage::Attachment record and a +has_one-through+ association to a
  23. # ActiveStorage::Blob record. These associations are available as +avatar_attachment+
  24. # and +avatar_blob+. But you shouldn't need to work with these associations directly in
  25. # most circumstances.
  26. #
  27. # The system has been designed to having you go through the ActiveStorage::Attached::One
  28. # proxy that provides the dynamic proxy to the associations and factory methods, like +attach+.
  29. #
  30. # If the +:dependent+ option isn't set, the attachment will be purged
  31. # (i.e. destroyed) whenever the record is destroyed.
  32. #
  33. # If you need the attachment to use a service which differs from the globally configured one,
  34. # pass the +:service+ option. For instance:
  35. #
  36. # class User < ActiveRecord::Base
  37. # has_one_attached :avatar, service: :s3
  38. # end
  39. #
  40. 3 def has_one_attached(name, dependent: :purge_later, service: nil)
  41. 17 validate_service_configuration(name, service)
  42. 15 generated_association_methods.class_eval <<-CODE, __FILE__, __LINE__ + 1
  43. # frozen_string_literal: true
  44. def #{name}
  45. @active_storage_attached ||= {}
  46. @active_storage_attached[:#{name}] ||= ActiveStorage::Attached::One.new("#{name}", self)
  47. end
  48. def #{name}=(attachable)
  49. attachment_changes["#{name}"] =
  50. if attachable.nil?
  51. ActiveStorage::Attached::Changes::DeleteOne.new("#{name}", self)
  52. else
  53. ActiveStorage::Attached::Changes::CreateOne.new("#{name}", self, attachable)
  54. end
  55. end
  56. CODE
  57. 457 has_one :"#{name}_attachment", -> { where(name: name) }, class_name: "ActiveStorage::Attachment", as: :record, inverse_of: :record, dependent: :destroy
  58. 15 has_one :"#{name}_blob", through: :"#{name}_attachment", class_name: "ActiveStorage::Blob", source: :blob
  59. 15 scope :"with_attached_#{name}", -> { includes("#{name}_attachment": :blob) }
  60. 2047 after_save { attachment_changes[name.to_s]&.save }
  61. 2102 after_commit(on: %i[ create update ]) { attachment_changes.delete(name.to_s).try(:upload) }
  62. 15 reflection = ActiveRecord::Reflection.create(
  63. :has_one_attached,
  64. name,
  65. nil,
  66. { dependent: dependent, service_name: service },
  67. self
  68. )
  69. 15 ActiveRecord::Reflection.add_attachment_reflection(self, name, reflection)
  70. end
  71. # Specifies the relation between multiple attachments and the model.
  72. #
  73. # class Gallery < ApplicationRecord
  74. # has_many_attached :photos
  75. # end
  76. #
  77. # There are no columns defined on the model side, Active Storage takes
  78. # care of the mapping between your records and the attachments.
  79. #
  80. # To avoid N+1 queries, you can include the attached blobs in your query like so:
  81. #
  82. # Gallery.where(user: Current.user).with_attached_photos
  83. #
  84. # Under the covers, this relationship is implemented as a +has_many+ association to a
  85. # ActiveStorage::Attachment record and a +has_many-through+ association to a
  86. # ActiveStorage::Blob record. These associations are available as +photos_attachments+
  87. # and +photos_blobs+. But you shouldn't need to work with these associations directly in
  88. # most circumstances.
  89. #
  90. # The system has been designed to having you go through the ActiveStorage::Attached::Many
  91. # proxy that provides the dynamic proxy to the associations and factory methods, like +#attach+.
  92. #
  93. # If the +:dependent+ option isn't set, all the attachments will be purged
  94. # (i.e. destroyed) whenever the record is destroyed.
  95. #
  96. # If you need the attachment to use a service which differs from the globally configured one,
  97. # pass the +:service+ option. For instance:
  98. #
  99. # class Gallery < ActiveRecord::Base
  100. # has_many_attached :photos, service: :s3
  101. # end
  102. #
  103. 3 def has_many_attached(name, dependent: :purge_later, service: nil)
  104. 8 validate_service_configuration(name, service)
  105. 6 generated_association_methods.class_eval <<-CODE, __FILE__, __LINE__ + 1
  106. # frozen_string_literal: true
  107. def #{name}
  108. @active_storage_attached ||= {}
  109. @active_storage_attached[:#{name}] ||= ActiveStorage::Attached::Many.new("#{name}", self)
  110. end
  111. def #{name}=(attachables)
  112. if ActiveStorage.replace_on_assign_to_many
  113. attachment_changes["#{name}"] =
  114. if Array(attachables).none?
  115. ActiveStorage::Attached::Changes::DeleteMany.new("#{name}", self)
  116. else
  117. ActiveStorage::Attached::Changes::CreateMany.new("#{name}", self, attachables)
  118. end
  119. else
  120. if Array(attachables).any?
  121. attachment_changes["#{name}"] =
  122. ActiveStorage::Attached::Changes::CreateMany.new("#{name}", self, #{name}.blobs + attachables)
  123. end
  124. end
  125. end
  126. CODE
  127. 439 has_many :"#{name}_attachments", -> { where(name: name) }, as: :record, class_name: "ActiveStorage::Attachment", inverse_of: :record, dependent: :destroy do
  128. 6 def purge
  129. 4 each(&:purge)
  130. 4 reset
  131. end
  132. 6 def purge_later
  133. 4 each(&:purge_later)
  134. 4 reset
  135. end
  136. end
  137. 6 has_many :"#{name}_blobs", through: :"#{name}_attachments", class_name: "ActiveStorage::Blob", source: :blob
  138. 6 scope :"with_attached_#{name}", -> { includes("#{name}_attachments": :blob) }
  139. 1060 after_save { attachment_changes[name.to_s]&.save }
  140. 1116 after_commit(on: %i[ create update ]) { attachment_changes.delete(name.to_s).try(:upload) }
  141. 6 reflection = ActiveRecord::Reflection.create(
  142. :has_many_attached,
  143. name,
  144. nil,
  145. { dependent: dependent, service_name: service },
  146. self
  147. )
  148. 6 ActiveRecord::Reflection.add_attachment_reflection(self, name, reflection)
  149. end
  150. 3 private
  151. 3 def validate_service_configuration(association_name, service)
  152. 25 if service.present?
  153. 10 ActiveStorage::Blob.services.fetch(service) do
  154. 4 raise ArgumentError, "Cannot configure service :#{service} for #{name}##{association_name}"
  155. end
  156. end
  157. end
  158. end
  159. 3 def attachment_changes #:nodoc:
  160. 9281 @attachment_changes ||= {}
  161. end
  162. 3 def changed_for_autosave? #:nodoc:
  163. 1020 super || attachment_changes.any?
  164. end
  165. 3 def initialize_dup(*) #:nodoc:
  166. 8 super
  167. 8 @active_storage_attached = nil
  168. 8 @attachment_changes = nil
  169. end
  170. 3 def reload(*) #:nodoc:
  171. 186 super.tap { @attachment_changes = nil }
  172. end
  173. end
  174. end

lib/active_storage/attached/one.rb

100.0% lines covered

29 relevant lines. 29 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 3 module ActiveStorage
  3. # Representation of a single attachment to a model.
  4. 3 class Attached::One < Attached
  5. 3 delegate_missing_to :attachment, allow_nil: true
  6. # Returns the associated attachment record.
  7. #
  8. # You don't have to call this method to access the attachment's methods as
  9. # they are all available at the model level.
  10. 3 def attachment
  11. 628 change.present? ? change.attachment : record.public_send("#{name}_attachment")
  12. end
  13. 3 def blank?
  14. 8 !attached?
  15. end
  16. # Attaches an +attachable+ to the record.
  17. #
  18. # If the record is persisted and unchanged, the attachment is saved to
  19. # the database immediately. Otherwise, it'll be saved to the DB when the
  20. # record is next saved.
  21. #
  22. # person.avatar.attach(params[:avatar]) # ActionDispatch::Http::UploadedFile object
  23. # person.avatar.attach(params[:signed_blob_id]) # Signed reference to blob from direct upload
  24. # person.avatar.attach(io: File.open("/path/to/face.jpg"), filename: "face.jpg", content_type: "image/jpg")
  25. # person.avatar.attach(avatar_blob) # ActiveStorage::Blob object
  26. 3 def attach(attachable)
  27. 150 if record.persisted? && !record.changed?
  28. 120 record.public_send("#{name}=", attachable)
  29. 120 record.save
  30. else
  31. 30 record.public_send("#{name}=", attachable)
  32. end
  33. end
  34. # Returns +true+ if an attachment has been made.
  35. #
  36. # class User < ApplicationRecord
  37. # has_one_attached :avatar
  38. # end
  39. #
  40. # User.new.avatar.attached? # => false
  41. 3 def attached?
  42. 107 attachment.present?
  43. end
  44. # Deletes the attachment without purging it, leaving its blob in place.
  45. 3 def detach
  46. 2 if attached?
  47. 2 attachment.delete
  48. 2 write_attachment nil
  49. end
  50. end
  51. # Directly purges the attachment (i.e. destroys the blob and
  52. # attachment and deletes the file on the service).
  53. 3 def purge
  54. 4 if attached?
  55. 4 attachment.purge
  56. 4 write_attachment nil
  57. end
  58. end
  59. # Purges the attachment through the queuing system.
  60. 3 def purge_later
  61. 4 if attached?
  62. 4 attachment.purge_later
  63. 4 write_attachment nil
  64. end
  65. end
  66. 3 private
  67. 3 def write_attachment(attachment)
  68. 10 record.public_send("#{name}_attachment=", attachment)
  69. end
  70. end
  71. end

lib/active_storage/downloader.rb

100.0% lines covered

24 relevant lines. 24 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 3 module ActiveStorage
  3. 3 class Downloader #:nodoc:
  4. 3 attr_reader :service
  5. 3 def initialize(service)
  6. 159 @service = service
  7. end
  8. 3 def open(key, checksum:, name: "ActiveStorage-", tmpdir: nil)
  9. 159 open_tempfile(name, tmpdir) do |file|
  10. 159 download key, file
  11. 159 verify_integrity_of file, checksum: checksum
  12. 157 yield file
  13. end
  14. end
  15. 3 private
  16. 3 def open_tempfile(name, tmpdir = nil)
  17. 159 file = Tempfile.open(name, tmpdir)
  18. 159 begin
  19. 159 yield file
  20. ensure
  21. 159 file.close!
  22. end
  23. end
  24. 3 def download(key, file)
  25. 159 file.binmode
  26. 318 service.download(key) { |chunk| file.write(chunk) }
  27. 159 file.flush
  28. 159 file.rewind
  29. end
  30. 3 def verify_integrity_of(file, checksum:)
  31. 159 unless Digest::MD5.file(file).base64digest == checksum
  32. 2 raise ActiveStorage::IntegrityError
  33. end
  34. end
  35. end
  36. end

lib/active_storage/downloading.rb

0.0% lines covered

36 relevant lines. 0 lines covered and 36 lines missed.
    
  1. # frozen_string_literal: true
  2. require "tmpdir"
  3. require "active_support/core_ext/string/filters"
  4. module ActiveStorage
  5. module Downloading
  6. def self.included(klass)
  7. ActiveSupport::Deprecation.warn <<~MESSAGE.squish, caller_locations(2)
  8. ActiveStorage::Downloading is deprecated and will be removed in Active Storage 6.1.
  9. Use ActiveStorage::Blob#open instead.
  10. MESSAGE
  11. end
  12. private
  13. # Opens a new tempfile in #tempdir and copies blob data into it. Yields the tempfile.
  14. def download_blob_to_tempfile #:doc:
  15. open_tempfile_for_blob do |file|
  16. download_blob_to file
  17. yield file
  18. end
  19. end
  20. def open_tempfile_for_blob
  21. tempfile = Tempfile.open([ "ActiveStorage", blob.filename.extension_with_delimiter ], tempdir)
  22. begin
  23. yield tempfile
  24. ensure
  25. tempfile.close!
  26. end
  27. end
  28. # Efficiently downloads blob data into the given file.
  29. def download_blob_to(file) #:doc:
  30. file.binmode
  31. blob.download { |chunk| file.write(chunk) }
  32. file.flush
  33. file.rewind
  34. end
  35. # Returns the directory in which tempfiles should be opened. Defaults to +Dir.tmpdir+.
  36. def tempdir #:doc:
  37. Dir.tmpdir
  38. end
  39. end
  40. end

lib/active_storage/engine.rb

92.75% lines covered

69 relevant lines. 64 lines covered and 5 lines missed.
    
  1. # frozen_string_literal: true
  2. 3 require "rails"
  3. 3 require "action_controller/railtie"
  4. 3 require "active_job/railtie"
  5. 3 require "active_record/railtie"
  6. 3 require "active_storage"
  7. 3 require "active_storage/previewer/poppler_pdf_previewer"
  8. 3 require "active_storage/previewer/mupdf_previewer"
  9. 3 require "active_storage/previewer/video_previewer"
  10. 3 require "active_storage/analyzer/image_analyzer"
  11. 3 require "active_storage/analyzer/video_analyzer"
  12. 3 require "active_storage/service/registry"
  13. 3 require "active_storage/reflection"
  14. 3 module ActiveStorage
  15. 3 class Engine < Rails::Engine # :nodoc:
  16. 3 isolate_namespace ActiveStorage
  17. 3 config.active_storage = ActiveSupport::OrderedOptions.new
  18. 3 config.active_storage.previewers = [ ActiveStorage::Previewer::PopplerPDFPreviewer, ActiveStorage::Previewer::MuPDFPreviewer, ActiveStorage::Previewer::VideoPreviewer ]
  19. 3 config.active_storage.analyzers = [ ActiveStorage::Analyzer::ImageAnalyzer, ActiveStorage::Analyzer::VideoAnalyzer ]
  20. 3 config.active_storage.paths = ActiveSupport::OrderedOptions.new
  21. 3 config.active_storage.queues = ActiveSupport::InheritableOptions.new(mirror: :active_storage_mirror)
  22. 3 config.active_storage.variable_content_types = %w(
  23. image/png
  24. image/gif
  25. image/jpg
  26. image/jpeg
  27. image/pjpeg
  28. image/tiff
  29. image/bmp
  30. image/vnd.adobe.photoshop
  31. image/vnd.microsoft.icon
  32. image/webp
  33. )
  34. 3 config.active_storage.web_image_content_types = %w(
  35. image/png
  36. image/jpeg
  37. image/jpg
  38. image/gif
  39. )
  40. 3 config.active_storage.content_types_to_serve_as_binary = %w(
  41. text/html
  42. text/javascript
  43. image/svg+xml
  44. application/postscript
  45. application/x-shockwave-flash
  46. text/xml
  47. application/xml
  48. application/xhtml+xml
  49. application/mathml+xml
  50. text/cache-manifest
  51. )
  52. 3 config.active_storage.content_types_allowed_inline = %w(
  53. image/png
  54. image/gif
  55. image/jpg
  56. image/jpeg
  57. image/tiff
  58. image/bmp
  59. image/vnd.adobe.photoshop
  60. image/vnd.microsoft.icon
  61. application/pdf
  62. )
  63. 3 config.eager_load_namespaces << ActiveStorage
  64. 3 initializer "active_storage.configs" do
  65. 3 config.after_initialize do |app|
  66. 3 ActiveStorage.logger = app.config.active_storage.logger || Rails.logger
  67. 3 ActiveStorage.variant_processor = app.config.active_storage.variant_processor || :mini_magick
  68. 3 ActiveStorage.previewers = app.config.active_storage.previewers || []
  69. 3 ActiveStorage.analyzers = app.config.active_storage.analyzers || []
  70. 3 ActiveStorage.paths = app.config.active_storage.paths || {}
  71. 3 ActiveStorage.routes_prefix = app.config.active_storage.routes_prefix || "/rails/active_storage"
  72. 3 ActiveStorage.draw_routes = app.config.active_storage.draw_routes != false
  73. 3 ActiveStorage.resolve_model_to_route = app.config.active_storage.resolve_model_to_route || :rails_storage_redirect
  74. 3 ActiveStorage.variable_content_types = app.config.active_storage.variable_content_types || []
  75. 3 ActiveStorage.web_image_content_types = app.config.active_storage.web_image_content_types || []
  76. 3 ActiveStorage.content_types_to_serve_as_binary = app.config.active_storage.content_types_to_serve_as_binary || []
  77. 3 ActiveStorage.service_urls_expire_in = app.config.active_storage.service_urls_expire_in || 5.minutes
  78. 3 ActiveStorage.content_types_allowed_inline = app.config.active_storage.content_types_allowed_inline || []
  79. 3 ActiveStorage.binary_content_type = app.config.active_storage.binary_content_type || "application/octet-stream"
  80. 3 ActiveStorage.replace_on_assign_to_many = app.config.active_storage.replace_on_assign_to_many || false
  81. 3 ActiveStorage.track_variants = app.config.active_storage.track_variants || false
  82. end
  83. end
  84. 3 initializer "active_storage.attached" do
  85. 3 require "active_storage/attached"
  86. 3 ActiveSupport.on_load(:active_record) do
  87. 3 include ActiveStorage::Attached::Model
  88. end
  89. end
  90. 3 initializer "active_storage.verifier" do
  91. 3 config.after_initialize do |app|
  92. 3 ActiveStorage.verifier = app.message_verifier("ActiveStorage")
  93. end
  94. end
  95. 3 initializer "active_storage.services" do
  96. 3 ActiveSupport.on_load(:active_storage_blob) do
  97. 3 configs = Rails.configuration.active_storage.service_configurations ||=
  98. begin
  99. config_file = Rails.root.join("config/storage.yml")
  100. raise("Couldn't find Active Storage configuration in #{config_file}") unless config_file.exist?
  101. ActiveSupport::ConfigurationFile.parse(config_file)
  102. end
  103. 3 ActiveStorage::Blob.services = ActiveStorage::Service::Registry.new(configs)
  104. 3 if config_choice = Rails.configuration.active_storage.service
  105. 3 ActiveStorage::Blob.service = ActiveStorage::Blob.services.fetch(config_choice)
  106. end
  107. end
  108. end
  109. 3 initializer "active_storage.queues" do
  110. 3 config.after_initialize do |app|
  111. 3 if queue = app.config.active_storage.queue
  112. ActiveSupport::Deprecation.warn \
  113. "config.active_storage.queue is deprecated and will be removed in Rails 6.1. " \
  114. "Set config.active_storage.queues.purge and config.active_storage.queues.analysis instead."
  115. ActiveStorage.queues = { purge: queue, analysis: queue, mirror: queue }
  116. else
  117. 3 ActiveStorage.queues = app.config.active_storage.queues || {}
  118. end
  119. end
  120. end
  121. 3 initializer "active_storage.reflection" do
  122. 3 ActiveSupport.on_load(:active_record) do
  123. 3 include Reflection::ActiveRecordExtensions
  124. 3 ActiveRecord::Reflection.singleton_class.prepend(Reflection::ReflectionExtension)
  125. end
  126. end
  127. end
  128. end

lib/active_storage/errors.rb

100.0% lines covered

7 relevant lines. 7 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 3 module ActiveStorage
  3. # Generic base class for all Active Storage exceptions.
  4. 3 class Error < StandardError; end
  5. # Raised when ActiveStorage::Blob#variant is called on a blob that isn't variable.
  6. # Use ActiveStorage::Blob#variable? to determine whether a blob is variable.
  7. 3 class InvariableError < Error; end
  8. # Raised when ActiveStorage::Blob#preview is called on a blob that isn't previewable.
  9. # Use ActiveStorage::Blob#previewable? to determine whether a blob is previewable.
  10. 3 class UnpreviewableError < Error; end
  11. # Raised when ActiveStorage::Blob#representation is called on a blob that isn't representable.
  12. # Use ActiveStorage::Blob#representable? to determine whether a blob is representable.
  13. 3 class UnrepresentableError < Error; end
  14. # Raised when uploaded or downloaded data does not match a precomputed checksum.
  15. # Indicates that a network error or a software bug caused data corruption.
  16. 3 class IntegrityError < Error; end
  17. # Raised when ActiveStorage::Blob#download is called on a blob where the
  18. # backing file is no longer present in its service.
  19. 3 class FileNotFoundError < Error; end
  20. end

lib/active_storage/gem_version.rb

88.89% lines covered

9 relevant lines. 8 lines covered and 1 lines missed.
    
  1. # frozen_string_literal: true
  2. 3 module ActiveStorage
  3. # Returns the version of the currently loaded Active Storage as a <tt>Gem::Version</tt>.
  4. 3 def self.gem_version
  5. Gem::Version.new VERSION::STRING
  6. end
  7. 3 module VERSION
  8. 3 MAJOR = 6
  9. 3 MINOR = 1
  10. 3 TINY = 0
  11. 3 PRE = "alpha"
  12. 3 STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".")
  13. end
  14. end

lib/active_storage/log_subscriber.rb

100.0% lines covered

34 relevant lines. 34 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 3 require "active_support/log_subscriber"
  3. 3 module ActiveStorage
  4. 3 class LogSubscriber < ActiveSupport::LogSubscriber
  5. 3 def service_upload(event)
  6. 1163 message = "Uploaded file to key: #{key_in(event)}"
  7. 1163 message += " (checksum: #{event.payload[:checksum]})" if event.payload[:checksum]
  8. 1163 info event, color(message, GREEN)
  9. end
  10. 3 def service_download(event)
  11. 251 info event, color("Downloaded file from key: #{key_in(event)}", BLUE)
  12. end
  13. 3 alias_method :service_streaming_download, :service_download
  14. 3 def service_delete(event)
  15. 859 info event, color("Deleted file from key: #{key_in(event)}", RED)
  16. end
  17. 3 def service_delete_prefixed(event)
  18. 147 info event, color("Deleted files by key prefix: #{event.payload[:prefix]}", RED)
  19. end
  20. 3 def service_exist(event)
  21. 287 debug event, color("Checked if file exists at key: #{key_in(event)} (#{event.payload[:exist] ? "yes" : "no"})", BLUE)
  22. end
  23. 3 def service_url(event)
  24. 120 debug event, color("Generated URL for file at key: #{key_in(event)} (#{event.payload[:url]})", BLUE)
  25. end
  26. 3 def service_mirror(event)
  27. 6 message = "Mirrored file at key: #{key_in(event)}"
  28. 6 message += " (checksum: #{event.payload[:checksum]})" if event.payload[:checksum]
  29. 6 debug event, color(message, GREEN)
  30. end
  31. 3 def logger
  32. 11332 ActiveStorage.logger
  33. end
  34. 3 private
  35. 3 def info(event, colored_message)
  36. 2420 super log_prefix_for_service(event) + colored_message
  37. end
  38. 3 def debug(event, colored_message)
  39. 413 super log_prefix_for_service(event) + colored_message
  40. end
  41. 3 def log_prefix_for_service(event)
  42. 2833 color " #{event.payload[:service]} Storage (#{event.duration.round(1)}ms) ", CYAN
  43. end
  44. 3 def key_in(event)
  45. 2686 event.payload[:key]
  46. end
  47. end
  48. end
  49. 3 ActiveStorage::LogSubscriber.attach_to :active_storage

lib/active_storage/previewer.rb

90.63% lines covered

32 relevant lines. 29 lines covered and 3 lines missed.
    
  1. # frozen_string_literal: true
  2. 3 module ActiveStorage
  3. # This is an abstract base class for previewers, which generate images from blobs. See
  4. # ActiveStorage::Previewer::MuPDFPreviewer and ActiveStorage::Previewer::VideoPreviewer for
  5. # examples of concrete subclasses.
  6. 3 class Previewer
  7. 3 attr_reader :blob
  8. # Implement this method in a concrete subclass. Have it return true when given a blob from which
  9. # the previewer can generate an image.
  10. 3 def self.accept?(blob)
  11. false
  12. end
  13. 3 def initialize(blob)
  14. 33 @blob = blob
  15. end
  16. # Override this method in a concrete subclass. Have it yield an attachable preview image (i.e.
  17. # anything accepted by ActiveStorage::Attached::One#attach). Pass the additional options to
  18. # the underlying blob that is created.
  19. 3 def preview(**options)
  20. raise NotImplementedError
  21. end
  22. 3 private
  23. # Downloads the blob to a tempfile on disk. Yields the tempfile.
  24. 3 def download_blob_to_tempfile(&block) #:doc:
  25. 33 blob.open tmpdir: tmpdir, &block
  26. end
  27. # Executes a system command, capturing its binary output in a tempfile. Yields the tempfile.
  28. #
  29. # Use this method to shell out to a system library (e.g. muPDF or FFmpeg) for preview image
  30. # generation. The resulting tempfile can be used as the +:io+ value in an attachable Hash:
  31. #
  32. # def preview
  33. # download_blob_to_tempfile do |input|
  34. # draw "my-drawing-command", input.path, "--format", "png", "-" do |output|
  35. # yield io: output, filename: "#{blob.filename.base}.png", content_type: "image/png"
  36. # end
  37. # end
  38. # end
  39. #
  40. # The output tempfile is opened in the directory returned by #tmpdir.
  41. 3 def draw(*argv) #:doc:
  42. 33 open_tempfile do |file|
  43. 33 instrument :preview, key: blob.key do
  44. 33 capture(*argv, to: file)
  45. end
  46. 32 yield file
  47. end
  48. end
  49. 3 def open_tempfile
  50. 33 tempfile = Tempfile.open("ActiveStorage-", tmpdir)
  51. 33 begin
  52. 33 yield tempfile
  53. ensure
  54. 33 tempfile.close!
  55. end
  56. end
  57. 3 def instrument(operation, payload = {}, &block)
  58. 33 ActiveSupport::Notifications.instrument "#{operation}.active_storage", payload, &block
  59. end
  60. 3 def capture(*argv, to:)
  61. 33 to.binmode
  62. 65 IO.popen(argv, err: File::NULL) { |out| IO.copy_stream(out, to) }
  63. 32 to.rewind
  64. end
  65. 3 def logger #:doc:
  66. ActiveStorage.logger
  67. end
  68. 3 def tmpdir #:doc:
  69. 66 Dir.tmpdir
  70. end
  71. end
  72. end

lib/active_storage/previewer/mupdf_previewer.rb

83.33% lines covered

18 relevant lines. 15 lines covered and 3 lines missed.
    
  1. # frozen_string_literal: true
  2. 3 module ActiveStorage
  3. 3 class Previewer::MuPDFPreviewer < Previewer
  4. 3 class << self
  5. 3 def accept?(blob)
  6. 31 blob.content_type == "application/pdf" && mutool_exists?
  7. end
  8. 3 def mutool_path
  9. 3 ActiveStorage.paths[:mutool] || "mutool"
  10. end
  11. 3 def mutool_exists?
  12. return @mutool_exists unless @mutool_exists.nil?
  13. system mutool_path, out: File::NULL, err: File::NULL
  14. @mutool_exists = $?.exitstatus == 1
  15. end
  16. end
  17. 3 def preview(**options)
  18. 3 download_blob_to_tempfile do |input|
  19. 3 draw_first_page_from input do |output|
  20. 2 yield io: output, filename: "#{blob.filename.base}.png", content_type: "image/png", **options
  21. end
  22. end
  23. end
  24. 3 private
  25. 3 def draw_first_page_from(file, &block)
  26. 3 draw self.class.mutool_path, "draw", "-F", "png", "-o", "-", file.path, "1", &block
  27. end
  28. end
  29. end

lib/active_storage/previewer/poppler_pdf_previewer.rb

100.0% lines covered

17 relevant lines. 17 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 3 module ActiveStorage
  3. 3 class Previewer::PopplerPDFPreviewer < Previewer
  4. 3 class << self
  5. 3 def accept?(blob)
  6. 76 blob.content_type == "application/pdf" && pdftoppm_exists?
  7. end
  8. 3 def pdftoppm_path
  9. 23 ActiveStorage.paths[:pdftoppm] || "pdftoppm"
  10. end
  11. 3 def pdftoppm_exists?
  12. 45 return @pdftoppm_exists if defined?(@pdftoppm_exists)
  13. 3 @pdftoppm_exists = system(pdftoppm_path, "-v", out: File::NULL, err: File::NULL)
  14. end
  15. end
  16. 3 def preview(**options)
  17. 20 download_blob_to_tempfile do |input|
  18. 20 draw_first_page_from input do |output|
  19. 20 yield io: output, filename: "#{blob.filename.base}.png", content_type: "image/png", **options
  20. end
  21. end
  22. end
  23. 3 private
  24. 3 def draw_first_page_from(file, &block)
  25. # use 72 dpi to match thumbnail dimensions of the PDF
  26. 20 draw self.class.pdftoppm_path, "-singlefile", "-r", "72", "-png", file.path, &block
  27. end
  28. end
  29. end

lib/active_storage/previewer/video_previewer.rb

100.0% lines covered

17 relevant lines. 17 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 3 module ActiveStorage
  3. 3 class Previewer::VideoPreviewer < Previewer
  4. 3 class << self
  5. 3 def accept?(blob)
  6. 31 blob.video? && ffmpeg_exists?
  7. end
  8. 3 def ffmpeg_exists?
  9. 18 return @ffmpeg_exists if defined?(@ffmpeg_exists)
  10. 3 @ffmpeg_exists = system(ffmpeg_path, "-version", out: File::NULL, err: File::NULL)
  11. end
  12. 3 def ffmpeg_path
  13. 13 ActiveStorage.paths[:ffmpeg] || "ffmpeg"
  14. end
  15. end
  16. 3 def preview(**options)
  17. 10 download_blob_to_tempfile do |input|
  18. 10 draw_relevant_frame_from input do |output|
  19. 10 yield io: output, filename: "#{blob.filename.base}.jpg", content_type: "image/jpeg", **options
  20. end
  21. end
  22. end
  23. 3 private
  24. 3 def draw_relevant_frame_from(file, &block)
  25. 10 draw self.class.ffmpeg_path, "-i", file.path, "-y", "-vframes", "1", "-f", "image2", "-", &block
  26. end
  27. end
  28. end

lib/active_storage/reflection.rb

100.0% lines covered

26 relevant lines. 26 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 3 module ActiveStorage
  3. 3 module Reflection
  4. # Holds all the metadata about a has_one_attached attachment as it was
  5. # specified in the Active Record class.
  6. 3 class HasOneAttachedReflection < ActiveRecord::Reflection::MacroReflection #:nodoc:
  7. 3 def macro
  8. 9 :has_one_attached
  9. end
  10. end
  11. # Holds all the metadata about a has_many_attached attachment as it was
  12. # specified in the Active Record class.
  13. 3 class HasManyAttachedReflection < ActiveRecord::Reflection::MacroReflection #:nodoc:
  14. 3 def macro
  15. 9 :has_many_attached
  16. end
  17. end
  18. 3 module ReflectionExtension # :nodoc:
  19. 3 def add_attachment_reflection(model, name, reflection)
  20. 21 model.attachment_reflections = model.attachment_reflections.merge(name.to_s => reflection)
  21. end
  22. 3 private
  23. 3 def reflection_class_for(macro)
  24. 81 case macro
  25. when :has_one_attached
  26. 15 HasOneAttachedReflection
  27. when :has_many_attached
  28. 6 HasManyAttachedReflection
  29. else
  30. 60 super
  31. end
  32. end
  33. end
  34. 3 module ActiveRecordExtensions
  35. 3 extend ActiveSupport::Concern
  36. 3 included do
  37. 3 class_attribute :attachment_reflections, instance_writer: false, default: {}
  38. end
  39. 3 module ClassMethods
  40. # Returns an array of reflection objects for all the attachments in the
  41. # class.
  42. 3 def reflect_on_all_attachments
  43. 3 attachment_reflections.values
  44. end
  45. # Returns the reflection object for the named +attachment+.
  46. #
  47. # User.reflect_on_attachment(:avatar)
  48. # # => the avatar reflection
  49. #
  50. 3 def reflect_on_attachment(attachment)
  51. 15 attachment_reflections[attachment.to_s]
  52. end
  53. end
  54. end
  55. end
  56. end

lib/active_storage/service.rb

82.14% lines covered

56 relevant lines. 46 lines covered and 10 lines missed.
    
  1. # frozen_string_literal: true
  2. 3 require "active_storage/log_subscriber"
  3. 3 require "active_storage/downloader"
  4. 3 require "action_dispatch"
  5. 3 require "action_dispatch/http/content_disposition"
  6. 3 module ActiveStorage
  7. # Abstract class serving as an interface for concrete services.
  8. #
  9. # The available services are:
  10. #
  11. # * +Disk+, to manage attachments saved directly on the hard drive.
  12. # * +GCS+, to manage attachments through Google Cloud Storage.
  13. # * +S3+, to manage attachments through Amazon S3.
  14. # * +AzureStorage+, to manage attachments through Microsoft Azure Storage.
  15. # * +Mirror+, to be able to use several services to manage attachments.
  16. #
  17. # Inside a Rails application, you can set-up your services through the
  18. # generated <tt>config/storage.yml</tt> file and reference one
  19. # of the aforementioned constant under the +service+ key. For example:
  20. #
  21. # local:
  22. # service: Disk
  23. # root: <%= Rails.root.join("storage") %>
  24. #
  25. # You can checkout the service's constructor to know which keys are required.
  26. #
  27. # Then, in your application's configuration, you can specify the service to
  28. # use like this:
  29. #
  30. # config.active_storage.service = :local
  31. #
  32. # If you are using Active Storage outside of a Ruby on Rails application, you
  33. # can configure the service to use like this:
  34. #
  35. # ActiveStorage::Blob.service = ActiveStorage::Service.configure(
  36. # :Disk,
  37. # root: Pathname("/foo/bar/storage")
  38. # )
  39. 3 class Service
  40. 3 extend ActiveSupport::Autoload
  41. 3 autoload :Configurator
  42. 3 attr_accessor :name
  43. 3 class << self
  44. # Configure an Active Storage service by name from a set of configurations,
  45. # typically loaded from a YAML file. The Active Storage engine uses this
  46. # to set the global Active Storage service when the app boots.
  47. 3 def configure(service_name, configurations)
  48. 9 Configurator.build(service_name, configurations)
  49. end
  50. # Override in subclasses that stitch together multiple services and hence
  51. # need to build additional services using the configurator.
  52. #
  53. # Passes the configurator and all of the service's config as keyword args.
  54. #
  55. # See MirrorService for an example.
  56. 3 def build(configurator:, name:, service: nil, **service_config) #:nodoc:
  57. 42 new(**service_config).tap do |service_instance|
  58. 42 service_instance.name = name
  59. end
  60. end
  61. end
  62. # Upload the +io+ to the +key+ specified. If a +checksum+ is provided, the service will
  63. # ensure a match when the upload has completed or raise an ActiveStorage::IntegrityError.
  64. 3 def upload(key, io, checksum: nil, **options)
  65. raise NotImplementedError
  66. end
  67. # Update metadata for the file identified by +key+ in the service.
  68. # Override in subclasses only if the service needs to store specific
  69. # metadata that has to be updated upon identification.
  70. 3 def update_metadata(key, **metadata)
  71. end
  72. # Return the content of the file at the +key+.
  73. 3 def download(key)
  74. raise NotImplementedError
  75. end
  76. # Return the partial content in the byte +range+ of the file at the +key+.
  77. 3 def download_chunk(key, range)
  78. raise NotImplementedError
  79. end
  80. 3 def open(*args, **options, &block)
  81. 159 ActiveStorage::Downloader.new(self).open(*args, **options, &block)
  82. end
  83. # Delete the file at the +key+.
  84. 3 def delete(key)
  85. raise NotImplementedError
  86. end
  87. # Delete files at keys starting with the +prefix+.
  88. 3 def delete_prefixed(prefix)
  89. raise NotImplementedError
  90. end
  91. # Return +true+ if a file exists at the +key+.
  92. 3 def exist?(key)
  93. raise NotImplementedError
  94. end
  95. # Returns the URL for the file at the +key+. This returns a permanent URL for public files, and returns a
  96. # short-lived URL for private files. You must provide the +disposition+ (+:inline+ or +:attachment+),
  97. # +filename+, and +content_type+ that you wish the file to be served with on request. In addition, for
  98. # private files, you must also provide the amount of seconds the URL will be valid for, specified in +expires_in+.
  99. 3 def url(key, **options)
  100. 99 instrument :url, key: key do |payload|
  101. 99 generated_url =
  102. 99 if public?
  103. 8 public_url(key, **options)
  104. else
  105. 91 private_url(key, **options)
  106. end
  107. 99 payload[:url] = generated_url
  108. 99 generated_url
  109. end
  110. end
  111. # Returns a signed, temporary URL that a direct upload file can be PUT to on the +key+.
  112. # The URL will be valid for the amount of seconds specified in +expires_in+.
  113. # You must also provide the +content_type+, +content_length+, and +checksum+ of the file
  114. # that will be uploaded. All these attributes will be validated by the service upon upload.
  115. 3 def url_for_direct_upload(key, expires_in:, content_type:, content_length:, checksum:)
  116. raise NotImplementedError
  117. end
  118. # Returns a Hash of headers for +url_for_direct_upload+ requests.
  119. 3 def headers_for_direct_upload(key, filename:, content_type:, content_length:, checksum:)
  120. {}
  121. end
  122. 3 def public?
  123. 99 @public
  124. end
  125. 3 private
  126. 3 def private_url(key, expires_in:, filename:, disposition:, content_type:, **)
  127. raise NotImplementedError
  128. end
  129. 3 def public_url(key, **)
  130. raise NotImplementedError
  131. end
  132. 3 def instrument(operation, payload = {}, &block)
  133. 2873 ActiveSupport::Notifications.instrument(
  134. "service_#{operation}.active_storage",
  135. payload.merge(service: service_name), &block)
  136. end
  137. 3 def service_name
  138. # ActiveStorage::Service::DiskService => Disk
  139. 2873 self.class.name.split("::").third.remove("Service")
  140. end
  141. 3 def content_disposition_with(type: "inline", filename:)
  142. 99 disposition = (type.to_s.presence_in(%w( attachment inline )) || "inline")
  143. 99 ActionDispatch::Http::ContentDisposition.format(disposition: disposition, filename: filename.sanitized)
  144. end
  145. end
  146. end

lib/active_storage/service/azure_storage_service.rb

0.0% lines covered

135 relevant lines. 0 lines covered and 135 lines missed.
    
  1. # frozen_string_literal: true
  2. gem "azure-storage-blob", ">= 1.1"
  3. require "active_support/core_ext/numeric/bytes"
  4. require "azure/storage/blob"
  5. require "azure/storage/common/core/auth/shared_access_signature"
  6. module ActiveStorage
  7. # Wraps the Microsoft Azure Storage Blob Service as an Active Storage service.
  8. # See ActiveStorage::Service for the generic API documentation that applies to all services.
  9. class Service::AzureStorageService < Service
  10. attr_reader :client, :container, :signer
  11. def initialize(storage_account_name:, storage_access_key:, container:, public: false, **options)
  12. @client = Azure::Storage::Blob::BlobService.create(storage_account_name: storage_account_name, storage_access_key: storage_access_key, **options)
  13. @signer = Azure::Storage::Common::Core::Auth::SharedAccessSignature.new(storage_account_name, storage_access_key)
  14. @container = container
  15. @public = public
  16. end
  17. def upload(key, io, checksum: nil, filename: nil, content_type: nil, disposition: nil, **)
  18. instrument :upload, key: key, checksum: checksum do
  19. handle_errors do
  20. content_disposition = content_disposition_with(filename: filename, type: disposition) if disposition && filename
  21. client.create_block_blob(container, key, IO.try_convert(io) || io, content_md5: checksum, content_type: content_type, content_disposition: content_disposition)
  22. end
  23. end
  24. end
  25. def download(key, &block)
  26. if block_given?
  27. instrument :streaming_download, key: key do
  28. stream(key, &block)
  29. end
  30. else
  31. instrument :download, key: key do
  32. handle_errors do
  33. _, io = client.get_blob(container, key)
  34. io.force_encoding(Encoding::BINARY)
  35. end
  36. end
  37. end
  38. end
  39. def download_chunk(key, range)
  40. instrument :download_chunk, key: key, range: range do
  41. handle_errors do
  42. _, io = client.get_blob(container, key, start_range: range.begin, end_range: range.exclude_end? ? range.end - 1 : range.end)
  43. io.force_encoding(Encoding::BINARY)
  44. end
  45. end
  46. end
  47. def delete(key)
  48. instrument :delete, key: key do
  49. client.delete_blob(container, key)
  50. rescue Azure::Core::Http::HTTPError => e
  51. raise unless e.type == "BlobNotFound"
  52. # Ignore files already deleted
  53. end
  54. end
  55. def delete_prefixed(prefix)
  56. instrument :delete_prefixed, prefix: prefix do
  57. marker = nil
  58. loop do
  59. results = client.list_blobs(container, prefix: prefix, marker: marker)
  60. results.each do |blob|
  61. client.delete_blob(container, blob.name)
  62. end
  63. break unless marker = results.continuation_token.presence
  64. end
  65. end
  66. end
  67. def exist?(key)
  68. instrument :exist, key: key do |payload|
  69. answer = blob_for(key).present?
  70. payload[:exist] = answer
  71. answer
  72. end
  73. end
  74. def url_for_direct_upload(key, expires_in:, content_type:, content_length:, checksum:)
  75. instrument :url, key: key do |payload|
  76. generated_url = signer.signed_uri(
  77. uri_for(key), false,
  78. service: "b",
  79. permissions: "rw",
  80. expiry: format_expiry(expires_in)
  81. ).to_s
  82. payload[:url] = generated_url
  83. generated_url
  84. end
  85. end
  86. def headers_for_direct_upload(key, content_type:, checksum:, filename: nil, disposition: nil, **)
  87. content_disposition = content_disposition_with(type: disposition, filename: filename) if filename
  88. { "Content-Type" => content_type, "Content-MD5" => checksum, "x-ms-blob-content-disposition" => content_disposition, "x-ms-blob-type" => "BlockBlob" }
  89. end
  90. private
  91. def private_url(key, expires_in:, filename:, disposition:, content_type:, **)
  92. signer.signed_uri(
  93. uri_for(key), false,
  94. service: "b",
  95. permissions: "r",
  96. expiry: format_expiry(expires_in),
  97. content_disposition: content_disposition_with(type: disposition, filename: filename),
  98. content_type: content_type
  99. ).to_s
  100. end
  101. def public_url(key, **)
  102. uri_for(key).to_s
  103. end
  104. def uri_for(key)
  105. client.generate_uri("#{container}/#{key}")
  106. end
  107. def blob_for(key)
  108. client.get_blob_properties(container, key)
  109. rescue Azure::Core::Http::HTTPError
  110. false
  111. end
  112. def format_expiry(expires_in)
  113. expires_in ? Time.now.utc.advance(seconds: expires_in).iso8601 : nil
  114. end
  115. # Reads the object for the given key in chunks, yielding each to the block.
  116. def stream(key)
  117. blob = blob_for(key)
  118. chunk_size = 5.megabytes
  119. offset = 0
  120. raise ActiveStorage::FileNotFoundError unless blob.present?
  121. while offset < blob.properties[:content_length]
  122. _, chunk = client.get_blob(container, key, start_range: offset, end_range: offset + chunk_size - 1)
  123. yield chunk.force_encoding(Encoding::BINARY)
  124. offset += chunk_size
  125. end
  126. end
  127. def handle_errors
  128. yield
  129. rescue Azure::Core::Http::HTTPError => e
  130. case e.type
  131. when "BlobNotFound"
  132. raise ActiveStorage::FileNotFoundError
  133. when "Md5Mismatch"
  134. raise ActiveStorage::IntegrityError
  135. else
  136. raise
  137. end
  138. end
  139. end
  140. end

lib/active_storage/service/configurator.rb

94.44% lines covered

18 relevant lines. 17 lines covered and 1 lines missed.
    
  1. # frozen_string_literal: true
  2. 3 module ActiveStorage
  3. 3 class Service::Configurator #:nodoc:
  4. 3 attr_reader :configurations
  5. 3 def self.build(service_name, configurations)
  6. 18 new(configurations).build(service_name)
  7. end
  8. 3 def initialize(configurations)
  9. 21 @configurations = configurations.deep_symbolize_keys
  10. end
  11. 3 def build(service_name)
  12. 51 config = config_for(service_name.to_sym)
  13. 48 resolve(config.fetch(:service)).build(
  14. **config, configurator: self, name: service_name
  15. )
  16. end
  17. 3 private
  18. 3 def config_for(name)
  19. 51 configurations.fetch name do
  20. 3 raise "Missing configuration for the #{name.inspect} Active Storage service. Configurations available for #{configurations.keys.inspect}"
  21. end
  22. end
  23. 3 def resolve(class_name)
  24. 48 require "active_storage/service/#{class_name.to_s.underscore}_service"
  25. 48 ActiveStorage::Service.const_get(:"#{class_name.camelize}Service")
  26. rescue LoadError
  27. raise "Missing service adapter for #{class_name.inspect}"
  28. end
  29. end
  30. end

lib/active_storage/service/disk_service.rb

100.0% lines covered

76 relevant lines. 76 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 3 require "fileutils"
  3. 3 require "pathname"
  4. 3 require "digest/md5"
  5. 3 require "active_support/core_ext/numeric/bytes"
  6. 3 module ActiveStorage
  7. # Wraps a local disk path as an Active Storage service. See ActiveStorage::Service for the generic API
  8. # documentation that applies to all services.
  9. 3 class Service::DiskService < Service
  10. 3 attr_reader :root
  11. 3 def initialize(root:, public: false, **options)
  12. 42 @root = root
  13. 42 @public = public
  14. end
  15. 3 def upload(key, io, checksum: nil, **)
  16. 1163 instrument :upload, key: key, checksum: checksum do
  17. 1163 IO.copy_stream(io, make_path_for(key))
  18. 1163 ensure_integrity_of(key, checksum) if checksum
  19. end
  20. end
  21. 3 def download(key, &block)
  22. 251 if block_given?
  23. 183 instrument :streaming_download, key: key do
  24. 183 stream key, &block
  25. end
  26. else
  27. 68 instrument :download, key: key do
  28. 68 File.binread path_for(key)
  29. rescue Errno::ENOENT
  30. 7 raise ActiveStorage::FileNotFoundError
  31. end
  32. end
  33. end
  34. 3 def download_chunk(key, range)
  35. 40 instrument :download_chunk, key: key, range: range do
  36. 40 File.open(path_for(key), "rb") do |file|
  37. 33 file.seek range.begin
  38. 33 file.read range.size
  39. end
  40. rescue Errno::ENOENT
  41. 7 raise ActiveStorage::FileNotFoundError
  42. end
  43. end
  44. 3 def delete(key)
  45. 859 instrument :delete, key: key do
  46. 859 File.delete path_for(key)
  47. rescue Errno::ENOENT
  48. # Ignore files already deleted
  49. end
  50. end
  51. 3 def delete_prefixed(prefix)
  52. 147 instrument :delete_prefixed, prefix: prefix do
  53. 147 Dir.glob(path_for("#{prefix}*")).each do |path|
  54. 34 FileUtils.rm_rf(path)
  55. end
  56. end
  57. end
  58. 3 def exist?(key)
  59. 287 instrument :exist, key: key do |payload|
  60. 287 answer = File.exist? path_for(key)
  61. 287 payload[:exist] = answer
  62. 287 answer
  63. end
  64. end
  65. 3 def url_for_direct_upload(key, expires_in:, content_type:, content_length:, checksum:)
  66. 21 instrument :url, key: key do |payload|
  67. 21 verified_token_with_expiration = ActiveStorage.verifier.generate(
  68. {
  69. key: key,
  70. content_type: content_type,
  71. content_length: content_length,
  72. checksum: checksum,
  73. service_name: name
  74. },
  75. expires_in: expires_in,
  76. purpose: :blob_token
  77. )
  78. 21 generated_url = url_helpers.update_rails_disk_service_url(verified_token_with_expiration, host: current_host)
  79. 21 payload[:url] = generated_url
  80. 21 generated_url
  81. end
  82. end
  83. 3 def headers_for_direct_upload(key, content_type:, **)
  84. 8 { "Content-Type" => content_type }
  85. end
  86. 3 def path_for(key) #:nodoc:
  87. 3551 File.join root, folder_for(key), key
  88. end
  89. 3 private
  90. 3 def private_url(key, expires_in:, filename:, content_type:, disposition:, **)
  91. 91 generate_url(key, expires_in: expires_in, filename: filename, content_type: content_type, disposition: disposition)
  92. end
  93. 3 def public_url(key, filename:, content_type: nil, disposition: :attachment, **)
  94. 8 generate_url(key, expires_in: nil, filename: filename, content_type: content_type, disposition: disposition)
  95. end
  96. 3 def generate_url(key, expires_in:, filename:, content_type:, disposition:)
  97. 99 content_disposition = content_disposition_with(type: disposition, filename: filename)
  98. 99 verified_key_with_expiration = ActiveStorage.verifier.generate(
  99. {
  100. key: key,
  101. disposition: content_disposition,
  102. content_type: content_type,
  103. service_name: name
  104. },
  105. expires_in: expires_in,
  106. purpose: :blob_key
  107. )
  108. 99 current_uri = URI.parse(current_host)
  109. 99 url_helpers.rails_disk_service_url(verified_key_with_expiration,
  110. protocol: current_uri.scheme,
  111. host: current_uri.host,
  112. port: current_uri.port,
  113. filename: filename
  114. )
  115. end
  116. 3 def stream(key)
  117. 183 File.open(path_for(key), "rb") do |file|
  118. 176 while data = file.read(5.megabytes)
  119. 185 yield data
  120. end
  121. end
  122. rescue Errno::ENOENT
  123. 7 raise ActiveStorage::FileNotFoundError
  124. end
  125. 3 def folder_for(key)
  126. 3551 [ key[0..1], key[2..3] ].join("/")
  127. end
  128. 3 def make_path_for(key)
  129. 2326 path_for(key).tap { |path| FileUtils.mkdir_p File.dirname(path) }
  130. end
  131. 3 def ensure_integrity_of(key, checksum)
  132. 708 unless Digest::MD5.file(path_for(key)).base64digest == checksum
  133. 7 delete key
  134. 7 raise ActiveStorage::IntegrityError
  135. end
  136. end
  137. 3 def url_helpers
  138. 120 @url_helpers ||= Rails.application.routes.url_helpers
  139. end
  140. 3 def current_host
  141. 120 ActiveStorage::Current.host
  142. end
  143. end
  144. end

lib/active_storage/service/gcs_service.rb

0.0% lines covered

108 relevant lines. 0 lines covered and 108 lines missed.
    
  1. # frozen_string_literal: true
  2. gem "google-cloud-storage", "~> 1.11"
  3. require "google/cloud/storage"
  4. module ActiveStorage
  5. # Wraps the Google Cloud Storage as an Active Storage service. See ActiveStorage::Service for the generic API
  6. # documentation that applies to all services.
  7. class Service::GCSService < Service
  8. def initialize(public: false, **config)
  9. @config = config
  10. @public = public
  11. end
  12. def upload(key, io, checksum: nil, content_type: nil, disposition: nil, filename: nil)
  13. instrument :upload, key: key, checksum: checksum do
  14. # GCS's signed URLs don't include params such as response-content-type response-content_disposition
  15. # in the signature, which means an attacker can modify them and bypass our effort to force these to
  16. # binary and attachment when the file's content type requires it. The only way to force them is to
  17. # store them as object's metadata.
  18. content_disposition = content_disposition_with(type: disposition, filename: filename) if disposition && filename
  19. bucket.create_file(io, key, md5: checksum, content_type: content_type, content_disposition: content_disposition)
  20. rescue Google::Cloud::InvalidArgumentError
  21. raise ActiveStorage::IntegrityError
  22. end
  23. end
  24. def download(key, &block)
  25. if block_given?
  26. instrument :streaming_download, key: key do
  27. stream(key, &block)
  28. end
  29. else
  30. instrument :download, key: key do
  31. file_for(key).download.string
  32. rescue Google::Cloud::NotFoundError
  33. raise ActiveStorage::FileNotFoundError
  34. end
  35. end
  36. end
  37. def update_metadata(key, content_type:, disposition: nil, filename: nil)
  38. instrument :update_metadata, key: key, content_type: content_type, disposition: disposition do
  39. file_for(key).update do |file|
  40. file.content_type = content_type
  41. file.content_disposition = content_disposition_with(type: disposition, filename: filename) if disposition && filename
  42. end
  43. end
  44. end
  45. def download_chunk(key, range)
  46. instrument :download_chunk, key: key, range: range do
  47. file_for(key).download(range: range).string
  48. rescue Google::Cloud::NotFoundError
  49. raise ActiveStorage::FileNotFoundError
  50. end
  51. end
  52. def delete(key)
  53. instrument :delete, key: key do
  54. file_for(key).delete
  55. rescue Google::Cloud::NotFoundError
  56. # Ignore files already deleted
  57. end
  58. end
  59. def delete_prefixed(prefix)
  60. instrument :delete_prefixed, prefix: prefix do
  61. bucket.files(prefix: prefix).all do |file|
  62. file.delete
  63. rescue Google::Cloud::NotFoundError
  64. # Ignore concurrently-deleted files
  65. end
  66. end
  67. end
  68. def exist?(key)
  69. instrument :exist, key: key do |payload|
  70. answer = file_for(key).exists?
  71. payload[:exist] = answer
  72. answer
  73. end
  74. end
  75. def url_for_direct_upload(key, expires_in:, checksum:, **)
  76. instrument :url, key: key do |payload|
  77. generated_url = bucket.signed_url key, method: "PUT", expires: expires_in, content_md5: checksum
  78. payload[:url] = generated_url
  79. generated_url
  80. end
  81. end
  82. def headers_for_direct_upload(key, checksum:, filename: nil, disposition: nil, **)
  83. content_disposition = content_disposition_with(type: disposition, filename: filename) if filename
  84. { "Content-MD5" => checksum, "Content-Disposition" => content_disposition }
  85. end
  86. private
  87. def private_url(key, expires_in:, filename:, content_type:, disposition:, **)
  88. file_for(key).signed_url expires: expires_in, query: {
  89. "response-content-disposition" => content_disposition_with(type: disposition, filename: filename),
  90. "response-content-type" => content_type
  91. }
  92. end
  93. def public_url(key, **)
  94. file_for(key).public_url
  95. end
  96. attr_reader :config
  97. def file_for(key, skip_lookup: true)
  98. bucket.file(key, skip_lookup: skip_lookup)
  99. end
  100. # Reads the file for the given key in chunks, yielding each to the block.
  101. def stream(key)
  102. file = file_for(key, skip_lookup: false)
  103. chunk_size = 5.megabytes
  104. offset = 0
  105. raise ActiveStorage::FileNotFoundError unless file.present?
  106. while offset < file.size
  107. yield file.download(range: offset..(offset + chunk_size - 1)).string
  108. offset += chunk_size
  109. end
  110. end
  111. def bucket
  112. @bucket ||= client.bucket(config.fetch(:bucket), skip_lookup: true)
  113. end
  114. def client
  115. @client ||= Google::Cloud::Storage.new(**config.except(:bucket))
  116. end
  117. end
  118. end

lib/active_storage/service/mirror_service.rb

100.0% lines covered

32 relevant lines. 32 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 3 require "active_support/core_ext/module/delegation"
  3. 3 module ActiveStorage
  4. # Wraps a set of mirror services and provides a single ActiveStorage::Service object that will all
  5. # have the files uploaded to them. A +primary+ service is designated to answer calls to:
  6. # * +download+
  7. # * +exists?+
  8. # * +url+
  9. # * +url_for_direct_upload+
  10. # * +headers_for_direct_upload+
  11. 3 class Service::MirrorService < Service
  12. 3 attr_reader :primary, :mirrors
  13. 3 delegate :download, :download_chunk, :exist?, :url,
  14. :url_for_direct_upload, :headers_for_direct_upload, :path_for, to: :primary
  15. # Stitch together from named services.
  16. 3 def self.build(primary:, mirrors:, name:, configurator:, **options) #:nodoc:
  17. new(
  18. primary: configurator.build(primary),
  19. 18 mirrors: mirrors.collect { |mirror_name| configurator.build mirror_name }
  20. 6 ).tap do |service_instance|
  21. 6 service_instance.name = name
  22. end
  23. end
  24. 3 def initialize(primary:, mirrors:)
  25. 6 @primary, @mirrors = primary, mirrors
  26. end
  27. # Upload the +io+ to the +key+ specified to all services. If a +checksum+ is provided, all services will
  28. # ensure a match when the upload has completed or raise an ActiveStorage::IntegrityError.
  29. 3 def upload(key, io, checksum: nil, **options)
  30. 92 each_service.collect do |service|
  31. 359 io.rewind
  32. 359 service.upload key, io, checksum: checksum, **options
  33. end
  34. end
  35. # Delete the file at the +key+ on all services.
  36. 3 def delete(key)
  37. 104 perform_across_services :delete, key
  38. end
  39. # Delete files at keys starting with the +prefix+ on all services.
  40. 3 def delete_prefixed(prefix)
  41. 14 perform_across_services :delete_prefixed, prefix
  42. end
  43. # Copy the file at the +key+ from the primary service to each of the mirrors where it doesn't already exist.
  44. 3 def mirror(key, checksum:)
  45. 6 instrument :mirror, key: key, checksum: checksum do
  46. 24 if (mirrors_in_need_of_mirroring = mirrors.select { |service| !service.exist?(key) }).any?
  47. 6 primary.open(key, checksum: checksum) do |io|
  48. 6 mirrors_in_need_of_mirroring.each do |service|
  49. 15 io.rewind
  50. 15 service.upload key, io, checksum: checksum
  51. end
  52. end
  53. end
  54. end
  55. end
  56. 3 private
  57. 3 def each_service(&block)
  58. 210 [ primary, *mirrors ].each(&block)
  59. end
  60. 3 def perform_across_services(method, *args)
  61. # FIXME: Convert to be threaded
  62. 118 each_service.collect do |service|
  63. 472 service.public_send method, *args
  64. end
  65. end
  66. end
  67. end

lib/active_storage/service/registry.rb

93.75% lines covered

16 relevant lines. 15 lines covered and 1 lines missed.
    
  1. # frozen_string_literal: true
  2. 3 module ActiveStorage
  3. 3 class Service::Registry #:nodoc:
  4. 3 def initialize(configurations)
  5. 3 @configurations = configurations.deep_symbolize_keys
  6. 3 @services = {}
  7. end
  8. 3 def fetch(name)
  9. 2719 services.fetch(name.to_sym) do |key|
  10. 17 if configurations.include?(key)
  11. 9 services[key] = configurator.build(key)
  12. else
  13. 8 if block_given?
  14. 8 yield key
  15. else
  16. raise KeyError, "Missing configuration for the #{key} Active Storage service. " \
  17. "Configurations available for the #{configurations.keys.to_sentence} services."
  18. end
  19. end
  20. end
  21. end
  22. 3 private
  23. 3 attr_reader :configurations, :services
  24. 3 def configurator
  25. 9 @configurator ||= ActiveStorage::Service::Configurator.new(configurations)
  26. end
  27. end
  28. end

lib/active_storage/service/s3_service.rb

0.0% lines covered

112 relevant lines. 0 lines covered and 112 lines missed.
    
  1. # frozen_string_literal: true
  2. gem "aws-sdk-s3", "~> 1.48"
  3. require "aws-sdk-s3"
  4. require "active_support/core_ext/numeric/bytes"
  5. module ActiveStorage
  6. # Wraps the Amazon Simple Storage Service (S3) as an Active Storage service.
  7. # See ActiveStorage::Service for the generic API documentation that applies to all services.
  8. class Service::S3Service < Service
  9. attr_reader :client, :bucket
  10. attr_reader :multipart_upload_threshold, :upload_options
  11. def initialize(bucket:, upload: {}, public: false, **options)
  12. @client = Aws::S3::Resource.new(**options)
  13. @bucket = @client.bucket(bucket)
  14. @multipart_upload_threshold = upload.fetch(:multipart_threshold, 100.megabytes)
  15. @public = public
  16. @upload_options = upload
  17. @upload_options[:acl] = "public-read" if public?
  18. end
  19. def upload(key, io, checksum: nil, filename: nil, content_type: nil, disposition: nil, **)
  20. instrument :upload, key: key, checksum: checksum do
  21. content_disposition = content_disposition_with(filename: filename, type: disposition) if disposition && filename
  22. if io.size < multipart_upload_threshold
  23. upload_with_single_part key, io, checksum: checksum, content_type: content_type, content_disposition: content_disposition
  24. else
  25. upload_with_multipart key, io, content_type: content_type, content_disposition: content_disposition
  26. end
  27. end
  28. end
  29. def download(key, &block)
  30. if block_given?
  31. instrument :streaming_download, key: key do
  32. stream(key, &block)
  33. end
  34. else
  35. instrument :download, key: key do
  36. object_for(key).get.body.string.force_encoding(Encoding::BINARY)
  37. rescue Aws::S3::Errors::NoSuchKey
  38. raise ActiveStorage::FileNotFoundError
  39. end
  40. end
  41. end
  42. def download_chunk(key, range)
  43. instrument :download_chunk, key: key, range: range do
  44. object_for(key).get(range: "bytes=#{range.begin}-#{range.exclude_end? ? range.end - 1 : range.end}").body.string.force_encoding(Encoding::BINARY)
  45. rescue Aws::S3::Errors::NoSuchKey
  46. raise ActiveStorage::FileNotFoundError
  47. end
  48. end
  49. def delete(key)
  50. instrument :delete, key: key do
  51. object_for(key).delete
  52. end
  53. end
  54. def delete_prefixed(prefix)
  55. instrument :delete_prefixed, prefix: prefix do
  56. bucket.objects(prefix: prefix).batch_delete!
  57. end
  58. end
  59. def exist?(key)
  60. instrument :exist, key: key do |payload|
  61. answer = object_for(key).exists?
  62. payload[:exist] = answer
  63. answer
  64. end
  65. end
  66. def url_for_direct_upload(key, expires_in:, content_type:, content_length:, checksum:)
  67. instrument :url, key: key do |payload|
  68. generated_url = object_for(key).presigned_url :put, expires_in: expires_in.to_i,
  69. content_type: content_type, content_length: content_length, content_md5: checksum,
  70. whitelist_headers: ["content-length"], **upload_options
  71. payload[:url] = generated_url
  72. generated_url
  73. end
  74. end
  75. def headers_for_direct_upload(key, content_type:, checksum:, filename: nil, disposition: nil, **)
  76. content_disposition = content_disposition_with(type: disposition, filename: filename) if filename
  77. { "Content-Type" => content_type, "Content-MD5" => checksum, "Content-Disposition" => content_disposition }
  78. end
  79. private
  80. def private_url(key, expires_in:, filename:, disposition:, content_type:, **)
  81. object_for(key).presigned_url :get, expires_in: expires_in.to_i,
  82. response_content_disposition: content_disposition_with(type: disposition, filename: filename),
  83. response_content_type: content_type
  84. end
  85. def public_url(key, **)
  86. object_for(key).public_url
  87. end
  88. MAXIMUM_UPLOAD_PARTS_COUNT = 10000
  89. MINIMUM_UPLOAD_PART_SIZE = 5.megabytes
  90. def upload_with_single_part(key, io, checksum: nil, content_type: nil, content_disposition: nil)
  91. object_for(key).put(body: io, content_md5: checksum, content_type: content_type, content_disposition: content_disposition, **upload_options)
  92. rescue Aws::S3::Errors::BadDigest
  93. raise ActiveStorage::IntegrityError
  94. end
  95. def upload_with_multipart(key, io, content_type: nil, content_disposition: nil)
  96. part_size = [ io.size.fdiv(MAXIMUM_UPLOAD_PARTS_COUNT).ceil, MINIMUM_UPLOAD_PART_SIZE ].max
  97. object_for(key).upload_stream(content_type: content_type, content_disposition: content_disposition, part_size: part_size, **upload_options) do |out|
  98. IO.copy_stream(io, out)
  99. end
  100. end
  101. def object_for(key)
  102. bucket.object(key)
  103. end
  104. # Reads the object for the given key in chunks, yielding each to the block.
  105. def stream(key)
  106. object = object_for(key)
  107. chunk_size = 5.megabytes
  108. offset = 0
  109. raise ActiveStorage::FileNotFoundError unless object.exists?
  110. while offset < object.content_length
  111. yield object.get(range: "bytes=#{offset}-#{offset + chunk_size - 1}").body.string.force_encoding(Encoding::BINARY)
  112. offset += chunk_size
  113. end
  114. end
  115. end
  116. end

lib/active_storage/transformers/image_processing_transformer.rb

100.0% lines covered

16 relevant lines. 16 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 3 require "image_processing"
  3. 3 module ActiveStorage
  4. 3 module Transformers
  5. 3 class ImageProcessingTransformer < Transformer
  6. 3 private
  7. 3 def process(file, format:)
  8. processor.
  9. source(file).
  10. loader(page: 0).
  11. convert(format).
  12. apply(operations).
  13. 67 call
  14. end
  15. 3 def processor
  16. 67 ImageProcessing.const_get(ActiveStorage.variant_processor.to_s.camelize)
  17. end
  18. 3 def operations
  19. 64 transformations.each_with_object([]) do |(name, argument), list|
  20. 70 if name.to_s == "combine_options"
  21. 3 ActiveSupport::Deprecation.warn <<~WARNING.squish
  22. Active Storage's ImageProcessing transformer doesn't support :combine_options,
  23. as it always generates a single ImageMagick command. Passing :combine_options will
  24. not be supported in Rails 6.1.
  25. WARNING
  26. 9 list.concat argument.keep_if { |key, value| value.present? }.to_a
  27. 67 elsif argument.present?
  28. 64 list << [ name, argument ]
  29. end
  30. end
  31. end
  32. end
  33. end
  34. end

lib/active_storage/transformers/mini_magick_transformer.rb

100.0% lines covered

20 relevant lines. 20 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 3 require "mini_magick"
  3. 3 module ActiveStorage
  4. 3 module Transformers
  5. 3 class MiniMagickTransformer < Transformer
  6. 3 private
  7. 3 def process(file, format:)
  8. 9 image = MiniMagick::Image.new(file.path, file)
  9. 9 transformations.each do |name, argument_or_subtransformations|
  10. 9 image.mogrify do |command|
  11. 9 if name.to_s == "combine_options"
  12. 6 argument_or_subtransformations.each do |subtransformation_name, subtransformation_argument|
  13. 15 pass_transform_argument(command, subtransformation_name, subtransformation_argument)
  14. end
  15. else
  16. 3 pass_transform_argument(command, name, argument_or_subtransformations)
  17. end
  18. end
  19. end
  20. 9 image.format(format) if format
  21. 9 image.tempfile.tap(&:open)
  22. end
  23. 3 def pass_transform_argument(command, method, argument)
  24. 18 if argument == true
  25. 3 command.public_send(method)
  26. 15 elsif argument.present?
  27. 12 command.public_send(method, argument)
  28. end
  29. end
  30. end
  31. end
  32. end

lib/active_storage/transformers/transformer.rb

92.86% lines covered

14 relevant lines. 13 lines covered and 1 lines missed.
    
  1. # frozen_string_literal: true
  2. 3 module ActiveStorage
  3. 3 module Transformers
  4. # A Transformer applies a set of transformations to an image.
  5. #
  6. # The following concrete subclasses are included in Active Storage:
  7. #
  8. # * ActiveStorage::Transformers::ImageProcessingTransformer:
  9. # backed by ImageProcessing, a common interface for MiniMagick and ruby-vips
  10. #
  11. # * ActiveStorage::Transformers::MiniMagickTransformer:
  12. # backed by MiniMagick, a wrapper around the ImageMagick CLI
  13. 3 class Transformer
  14. 3 attr_reader :transformations
  15. 3 def initialize(transformations)
  16. 76 @transformations = transformations
  17. end
  18. # Applies the transformations to the source image in +file+, producing a target image in the
  19. # specified +format+. Yields an open Tempfile containing the target image. Closes and unlinks
  20. # the output tempfile after yielding to the given block. Returns the result of the block.
  21. 3 def transform(file, format:)
  22. 76 output = process(file, format: format)
  23. 73 begin
  24. 73 yield output
  25. ensure
  26. 73 output.close!
  27. end
  28. end
  29. 3 private
  30. # Returns an open Tempfile containing a transformed image in the given +format+.
  31. # All subclasses implement this method.
  32. 3 def process(file, format:) #:doc:
  33. raise NotImplementedError
  34. end
  35. end
  36. end
  37. end

lib/active_storage/version.rb

75.0% lines covered

4 relevant lines. 3 lines covered and 1 lines missed.
    
  1. # frozen_string_literal: true
  2. 3 require_relative "gem_version"
  3. 3 module ActiveStorage
  4. # Returns the version of the currently loaded ActiveStorage as a <tt>Gem::Version</tt>
  5. 3 def self.version
  6. gem_version
  7. end
  8. end