activestorage 0.1

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

Potentially problematic release.


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

Files changed (45) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +1 -0
  3. data/Gemfile +11 -0
  4. data/Gemfile.lock +235 -0
  5. data/MIT-LICENSE +20 -0
  6. data/README.md +76 -0
  7. data/Rakefile +11 -0
  8. data/activestorage.gemspec +21 -0
  9. data/lib/active_storage.rb +9 -0
  10. data/lib/active_storage/attached.rb +34 -0
  11. data/lib/active_storage/attached/macros.rb +23 -0
  12. data/lib/active_storage/attached/many.rb +31 -0
  13. data/lib/active_storage/attached/one.rb +29 -0
  14. data/lib/active_storage/attachment.rb +30 -0
  15. data/lib/active_storage/blob.rb +80 -0
  16. data/lib/active_storage/disk_controller.rb +28 -0
  17. data/lib/active_storage/download.rb +90 -0
  18. data/lib/active_storage/filename.rb +31 -0
  19. data/lib/active_storage/migration.rb +28 -0
  20. data/lib/active_storage/purge_job.rb +10 -0
  21. data/lib/active_storage/railtie.rb +56 -0
  22. data/lib/active_storage/service.rb +34 -0
  23. data/lib/active_storage/service/disk_service.rb +70 -0
  24. data/lib/active_storage/service/gcs_service.rb +41 -0
  25. data/lib/active_storage/service/mirror_service.rb +34 -0
  26. data/lib/active_storage/service/s3_service.rb +55 -0
  27. data/lib/active_storage/storage_services.yml +27 -0
  28. data/lib/active_storage/verified_key_with_expiration.rb +24 -0
  29. data/lib/tasks/activestorage.rake +19 -0
  30. data/test/attachments_test.rb +95 -0
  31. data/test/blob_test.rb +28 -0
  32. data/test/database/create_users_migration.rb +7 -0
  33. data/test/database/setup.rb +6 -0
  34. data/test/disk_controller_test.rb +34 -0
  35. data/test/filename_test.rb +36 -0
  36. data/test/service/.gitignore +1 -0
  37. data/test/service/configurations-example.yml +11 -0
  38. data/test/service/disk_service_test.rb +8 -0
  39. data/test/service/gcs_service_test.rb +20 -0
  40. data/test/service/mirror_service_test.rb +50 -0
  41. data/test/service/s3_service_test.rb +11 -0
  42. data/test/service/shared_service_tests.rb +68 -0
  43. data/test/test_helper.rb +28 -0
  44. data/test/verified_key_with_expiration_test.rb +19 -0
  45. metadata +171 -0
@@ -0,0 +1,31 @@
1
+ class ActiveStorage::Attached::Many < ActiveStorage::Attached
2
+ delegate_missing_to :attachments
3
+
4
+ def attachments
5
+ @attachments ||= ActiveStorage::Attachment.where(record_gid: record.to_gid.to_s, name: name)
6
+ end
7
+
8
+ def attach(*attachables)
9
+ @attachments = attachments | Array(attachables).flatten.collect do |attachable|
10
+ ActiveStorage::Attachment.create!(record_gid: record.to_gid.to_s, name: name, blob: create_blob_from(attachable))
11
+ end
12
+ end
13
+
14
+ def attached?
15
+ attachments.any?
16
+ end
17
+
18
+ def purge
19
+ if attached?
20
+ attachments.each(&:purge)
21
+ @attachments = nil
22
+ end
23
+ end
24
+
25
+ def purge_later
26
+ if attached?
27
+ attachments.each(&:purge_later)
28
+ @attachments = nil
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,29 @@
1
+ class ActiveStorage::Attached::One < ActiveStorage::Attached
2
+ delegate_missing_to :attachment
3
+
4
+ def attachment
5
+ @attachment ||= ActiveStorage::Attachment.find_by(record_gid: record.to_gid.to_s, name: name)
6
+ end
7
+
8
+ def attach(attachable)
9
+ @attachment = ActiveStorage::Attachment.create!(record_gid: record.to_gid.to_s, name: name, blob: create_blob_from(attachable))
10
+ end
11
+
12
+ def attached?
13
+ attachment.present?
14
+ end
15
+
16
+ def purge
17
+ if attached?
18
+ attachment.purge
19
+ @attachment = nil
20
+ end
21
+ end
22
+
23
+ def purge_later
24
+ if attached?
25
+ attachment.purge_later
26
+ @attachment = nil
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,30 @@
1
+ require "active_storage/blob"
2
+ require "global_id"
3
+ require "active_support/core_ext/module/delegation"
4
+
5
+ # Schema: id, record_gid, blob_id, created_at
6
+ class ActiveStorage::Attachment < ActiveRecord::Base
7
+ self.table_name = "active_storage_attachments"
8
+
9
+ belongs_to :blob, class_name: "ActiveStorage::Blob"
10
+
11
+ delegate_missing_to :blob
12
+
13
+ def record
14
+ @record ||= GlobalID::Locator.locate(record_gid)
15
+ end
16
+
17
+ def record=(record)
18
+ @record = record
19
+ self.record_gid = record&.to_gid
20
+ end
21
+
22
+ def purge
23
+ blob.purge
24
+ destroy
25
+ end
26
+
27
+ def purge_later
28
+ ActiveStorage::PurgeJob.perform_later(self)
29
+ end
30
+ end
@@ -0,0 +1,80 @@
1
+ require "active_storage/service"
2
+ require "active_storage/filename"
3
+ require "active_storage/purge_job"
4
+
5
+ # Schema: id, key, filename, content_type, metadata, byte_size, checksum, created_at
6
+ class ActiveStorage::Blob < ActiveRecord::Base
7
+ self.table_name = "active_storage_blobs"
8
+
9
+ has_secure_token :key
10
+ store :metadata, coder: JSON
11
+
12
+ class_attribute :service
13
+
14
+ class << self
15
+ def build_after_upload(io:, filename:, content_type: nil, metadata: nil)
16
+ new.tap do |blob|
17
+ blob.filename = filename
18
+ blob.content_type = content_type
19
+ blob.metadata = metadata
20
+
21
+ blob.upload io
22
+ end
23
+ end
24
+
25
+ def create_after_upload!(io:, filename:, content_type: nil, metadata: nil)
26
+ build_after_upload(io: io, filename: filename, content_type: content_type, metadata: metadata).tap(&:save!)
27
+ end
28
+ end
29
+
30
+ # We can't wait until the record is first saved to have a key for it
31
+ def key
32
+ self[:key] ||= self.class.generate_unique_secure_token
33
+ end
34
+
35
+ def filename
36
+ ActiveStorage::Filename.new(self[:filename])
37
+ end
38
+
39
+ def url(expires_in: 5.minutes, disposition: :inline)
40
+ service.url key, expires_in: expires_in, disposition: disposition, filename: filename
41
+ end
42
+
43
+
44
+ def upload(io)
45
+ self.checksum = compute_checksum_in_chunks(io)
46
+ self.byte_size = io.size
47
+
48
+ service.upload(key, io, checksum: checksum)
49
+ end
50
+
51
+ def download
52
+ service.download key
53
+ end
54
+
55
+
56
+ def delete
57
+ service.delete key
58
+ end
59
+
60
+ def purge
61
+ delete
62
+ destroy
63
+ end
64
+
65
+ def purge_later
66
+ ActiveStorage::PurgeJob.perform_later(self)
67
+ end
68
+
69
+
70
+ private
71
+ def compute_checksum_in_chunks(io)
72
+ Digest::MD5.new.tap do |checksum|
73
+ while chunk = io.read(5.megabytes)
74
+ checksum << chunk
75
+ end
76
+
77
+ io.rewind
78
+ end.base64digest
79
+ end
80
+ end
@@ -0,0 +1,28 @@
1
+ require "action_controller"
2
+ require "active_storage/blob"
3
+ require "active_storage/verified_key_with_expiration"
4
+
5
+ require "active_support/core_ext/object/inclusion"
6
+
7
+ class ActiveStorage::DiskController < ActionController::Base
8
+ def show
9
+ if key = decode_verified_key
10
+ blob = ActiveStorage::Blob.find_by!(key: key)
11
+
12
+ if stale?(etag: blob.checksum)
13
+ send_data blob.download, filename: blob.filename, type: blob.content_type, disposition: disposition_param
14
+ end
15
+ else
16
+ head :not_found
17
+ end
18
+ end
19
+
20
+ private
21
+ def decode_verified_key
22
+ ActiveStorage::VerifiedKeyWithExpiration.decode(params[:encoded_key])
23
+ end
24
+
25
+ def disposition_param
26
+ params[:disposition].presence_in(%w( inline attachment )) || 'inline'
27
+ end
28
+ end
@@ -0,0 +1,90 @@
1
+ class ActiveStorage::Download
2
+ # Sending .ai files as application/postscript to Safari opens them in a blank, grey screen.
3
+ # Downloading .ai as application/postscript files in Safari appends .ps to the extension.
4
+ # Sending HTML, SVG, XML and SWF files as binary closes XSS vulnerabilities.
5
+ # Sending JS files as binary avoids InvalidCrossOriginRequest without compromising security.
6
+ CONTENT_TYPES_TO_RENDER_AS_BINARY = %w(
7
+ text/html
8
+ text/javascript
9
+ image/svg+xml
10
+ application/postscript
11
+ application/x-shockwave-flash
12
+ text/xml
13
+ application/xml
14
+ application/xhtml+xml
15
+ )
16
+
17
+ BINARY_CONTENT_TYPE = 'application/octet-stream'
18
+
19
+ def initialize(stored_file)
20
+ @stored_file = stored_file
21
+ end
22
+
23
+ def headers(force_attachment: false)
24
+ {
25
+ x_accel_redirect: '/reproxy',
26
+ x_reproxy_url: reproxy_url,
27
+ content_type: content_type,
28
+ content_disposition: content_disposition(force_attachment),
29
+ x_frame_options: 'SAMEORIGIN'
30
+ }
31
+ end
32
+
33
+ private
34
+ def reproxy_url
35
+ @stored_file.depot_location.paths.first
36
+ end
37
+
38
+ def content_type
39
+ if @stored_file.content_type.in? CONTENT_TYPES_TO_RENDER_AS_BINARY
40
+ BINARY_CONTENT_TYPE
41
+ else
42
+ @stored_file.content_type
43
+ end
44
+ end
45
+
46
+ def content_disposition(force_attachment = false)
47
+ if force_attachment || content_type == BINARY_CONTENT_TYPE
48
+ "attachment; #{escaped_filename}"
49
+ else
50
+ "inline; #{escaped_filename}"
51
+ end
52
+ end
53
+
54
+ # RFC2231 encoding for UTF-8 filenames, with an ASCII fallback
55
+ # first for unsupported browsers (IE < 9, perhaps others?).
56
+ # http://greenbytes.de/tech/tc2231/#encoding-2231-fb
57
+ def escaped_filename
58
+ filename = @stored_file.filename.sanitized
59
+ ascii_filename = encode_ascii_filename(filename)
60
+ utf8_filename = encode_utf8_filename(filename)
61
+ "#{ascii_filename}; #{utf8_filename}"
62
+ end
63
+
64
+ TRADITIONAL_PARAMETER_ESCAPED_CHAR = /[^ A-Za-z0-9!#$+.^_`|~-]/
65
+
66
+ def encode_ascii_filename(filename)
67
+ # There is no reliable way to escape special or non-Latin characters
68
+ # in a traditionally quoted Content-Disposition filename parameter.
69
+ # Settle for transliterating to ASCII, then percent-escaping special
70
+ # characters, excluding spaces.
71
+ filename = I18n.transliterate(filename)
72
+ filename = percent_escape(filename, TRADITIONAL_PARAMETER_ESCAPED_CHAR)
73
+ %(filename="#{filename}")
74
+ end
75
+
76
+ RFC5987_PARAMETER_ESCAPED_CHAR = /[^A-Za-z0-9!#$&+.^_`|~-]/
77
+
78
+ def encode_utf8_filename(filename)
79
+ # RFC2231 filename parameters can simply be percent-escaped according
80
+ # to RFC5987.
81
+ filename = percent_escape(filename, RFC5987_PARAMETER_ESCAPED_CHAR)
82
+ %(filename*=UTF-8''#{filename})
83
+ end
84
+
85
+ def percent_escape(string, pattern)
86
+ string.gsub(pattern) do |char|
87
+ char.bytes.map { |byte| "%%%02X" % byte }.join("")
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,31 @@
1
+ class ActiveStorage::Filename
2
+ include Comparable
3
+
4
+ def initialize(filename)
5
+ @filename = filename
6
+ end
7
+
8
+ def extname
9
+ File.extname(@filename)
10
+ end
11
+
12
+ def extension
13
+ extname.from(1)
14
+ end
15
+
16
+ def base
17
+ File.basename(@filename, extname)
18
+ end
19
+
20
+ def sanitized
21
+ @filename.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "�").strip.tr("\u{202E}%$|:;/\t\r\n\\", "-")
22
+ end
23
+
24
+ def to_s
25
+ sanitized.to_s
26
+ end
27
+
28
+ def <=>(other)
29
+ to_s.downcase <=> other.to_s.downcase
30
+ end
31
+ end
@@ -0,0 +1,28 @@
1
+ class ActiveStorage::CreateTables < ActiveRecord::Migration[5.1]
2
+ def change
3
+ create_table :active_storage_blobs do |t|
4
+ t.string :key
5
+ t.string :filename
6
+ t.string :content_type
7
+ t.text :metadata
8
+ t.integer :byte_size
9
+ t.string :checksum
10
+ t.time :created_at
11
+
12
+ t.index [ :key ], unique: true
13
+ end
14
+
15
+ create_table :active_storage_attachments do |t|
16
+ t.string :name
17
+ t.string :record_gid
18
+ t.integer :blob_id
19
+
20
+ t.time :created_at
21
+
22
+ t.index :record_gid
23
+ t.index :blob_id
24
+ t.index [ :record_gid, :name ]
25
+ t.index [ :record_gid, :blob_id ], unique: true
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,10 @@
1
+ require "active_job"
2
+
3
+ class ActiveStorage::PurgeJob < ActiveJob::Base
4
+ # FIXME: Limit this to a custom ActiveStorage error
5
+ retry_on StandardError
6
+
7
+ def perform(attachment_or_blob)
8
+ attachment_or_blob.purge
9
+ end
10
+ end
@@ -0,0 +1,56 @@
1
+ require "rails/railtie"
2
+
3
+ module ActiveStorage
4
+ class Engine < Rails::Engine # :nodoc:
5
+ config.active_storage = ActiveSupport::OrderedOptions.new
6
+
7
+ config.eager_load_namespaces << ActiveStorage
8
+
9
+ initializer "active_storage.routes" do
10
+ require "active_storage/disk_controller"
11
+
12
+ config.after_initialize do |app|
13
+ app.routes.prepend do
14
+ get "/rails/blobs/:encoded_key" => "active_storage/disk#show", as: :rails_disk_blob
15
+ end
16
+ end
17
+ end
18
+
19
+ initializer "active_storage.attached" do
20
+ require "active_storage/attached"
21
+
22
+ ActiveSupport.on_load(:active_record) do
23
+ extend ActiveStorage::Attached::Macros
24
+ end
25
+ end
26
+
27
+ config.after_initialize do |app|
28
+ config_choice = app.config.active_storage.service
29
+ config_file = Pathname.new(Rails.root.join("config/storage_services.yml"))
30
+
31
+ if config_choice
32
+ raise("Couldn't find Active Storage configuration in #{config_file}") unless config_file.exist?
33
+
34
+ begin
35
+ require "yaml"
36
+ require "erb"
37
+ configs = YAML.load(ERB.new(config_file.read).result) || {}
38
+
39
+ if service_configuration = configs[config_choice.to_s].symbolize_keys
40
+ service_name = service_configuration.delete(:service)
41
+
42
+ ActiveStorage::Blob.service = ActiveStorage::Service.configure(service_name, service_configuration)
43
+ else
44
+ raise "Couldn't configure Active Storage as #{config_choice} was not found in #{config_file}"
45
+ end
46
+ rescue Psych::SyntaxError => e
47
+ raise "YAML syntax error occurred while parsing #{config_file}. " \
48
+ "Please note that YAML must be consistently indented using spaces. Tabs are not allowed. " \
49
+ "Error: #{e.message}"
50
+ rescue => e
51
+ raise e, "Cannot load `Rails.config.active_storage.service`:\n#{e.message}", e.backtrace
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,34 @@
1
+ # Abstract class serving as an interface for concrete services.
2
+ class ActiveStorage::Service
3
+ class ActiveStorage::IntegrityError < StandardError; end
4
+
5
+ def self.configure(service, **options)
6
+ begin
7
+ require "active_storage/service/#{service.to_s.downcase}_service"
8
+ ActiveStorage::Service.const_get(:"#{service}Service").new(**options)
9
+ rescue LoadError => e
10
+ puts "Couldn't configure service: #{service} (#{e.message})"
11
+ end
12
+ end
13
+
14
+
15
+ def upload(key, io, checksum: nil)
16
+ raise NotImplementedError
17
+ end
18
+
19
+ def download(key)
20
+ raise NotImplementedError
21
+ end
22
+
23
+ def delete(key)
24
+ raise NotImplementedError
25
+ end
26
+
27
+ def exist?(key)
28
+ raise NotImplementedError
29
+ end
30
+
31
+ def url(key, expires_in:, disposition:, filename:)
32
+ raise NotImplementedError
33
+ end
34
+ end