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,70 @@
1
+ require "fileutils"
2
+ require "pathname"
3
+ require "active_support/core_ext/numeric/bytes"
4
+
5
+ class ActiveStorage::Service::DiskService < ActiveStorage::Service
6
+ attr_reader :root
7
+
8
+ def initialize(root:)
9
+ @root = root
10
+ end
11
+
12
+ def upload(key, io, checksum: nil)
13
+ File.open(make_path_for(key), "wb") do |file|
14
+ while chunk = io.read(64.kilobytes)
15
+ file.write(chunk)
16
+ end
17
+ end
18
+
19
+ ensure_integrity_of(key, checksum) if checksum
20
+ end
21
+
22
+ def download(key)
23
+ if block_given?
24
+ File.open(path_for(key)) do |file|
25
+ while data = file.read(64.kilobytes)
26
+ yield data
27
+ end
28
+ end
29
+ else
30
+ File.open path_for(key), &:read
31
+ end
32
+ end
33
+
34
+ def delete(key)
35
+ File.delete path_for(key) rescue Errno::ENOENT # Ignore files already deleted
36
+ end
37
+
38
+ def exist?(key)
39
+ File.exist? path_for(key)
40
+ end
41
+
42
+ def url(key, expires_in:, disposition:, filename:)
43
+ verified_key_with_expiration = ActiveStorage::VerifiedKeyWithExpiration.encode(key, expires_in: expires_in)
44
+
45
+ if defined?(Rails) && defined?(Rails.application)
46
+ Rails.application.routes.url_helpers.rails_disk_blob_path(verified_key_with_expiration, disposition: disposition)
47
+ else
48
+ "/rails/blobs/#{verified_key_with_expiration}?disposition=#{disposition}"
49
+ end
50
+ end
51
+
52
+ private
53
+ def path_for(key)
54
+ File.join root, folder_for(key), key
55
+ end
56
+
57
+ def folder_for(key)
58
+ [ key[0..1], key[2..3] ].join("/")
59
+ end
60
+
61
+ def make_path_for(key)
62
+ path_for(key).tap { |path| FileUtils.mkdir_p File.dirname(path) }
63
+ end
64
+
65
+ def ensure_integrity_of(key, checksum)
66
+ unless Digest::MD5.file(path_for(key)).base64digest == checksum
67
+ raise ActiveStorage::IntegrityError
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,41 @@
1
+ require "google/cloud/storage"
2
+ require "active_support/core_ext/object/to_query"
3
+
4
+ class ActiveStorage::Service::GCSService < ActiveStorage::Service
5
+ attr_reader :client, :bucket
6
+
7
+ def initialize(project:, keyfile:, bucket:)
8
+ @client = Google::Cloud::Storage.new(project: project, keyfile: keyfile)
9
+ @bucket = @client.bucket(bucket)
10
+ end
11
+
12
+ def upload(key, io, checksum: nil)
13
+ # FIXME: Ensure integrity by sending the checksum for service side verification
14
+ bucket.create_file(io, key)
15
+ end
16
+
17
+ # FIXME: Add streaming when given a block
18
+ def download(key)
19
+ io = file_for(key).download
20
+ io.rewind
21
+ io.read
22
+ end
23
+
24
+ def delete(key)
25
+ file_for(key).try(:delete)
26
+ end
27
+
28
+ def exist?(key)
29
+ file_for(key).present?
30
+ end
31
+
32
+ def url(key, expires_in:, disposition:, filename:)
33
+ file_for(key).signed_url(expires: expires_in) + "&" +
34
+ { "response-content-disposition" => "#{disposition}; filename=\"#{filename}\"" }.to_query
35
+ end
36
+
37
+ private
38
+ def file_for(key)
39
+ bucket.file(key)
40
+ end
41
+ end
@@ -0,0 +1,34 @@
1
+ require "active_support/core_ext/module/delegation"
2
+
3
+ class ActiveStorage::Service::MirrorService < ActiveStorage::Service
4
+ attr_reader :services
5
+
6
+ delegate :download, :exist?, :url, :byte_size, :checksum, to: :primary_service
7
+
8
+ def initialize(services:)
9
+ @services = services
10
+ end
11
+
12
+ def upload(key, io, checksum: nil)
13
+ services.collect do |service|
14
+ service.upload key, io, checksum: checksum
15
+ io.rewind
16
+ end
17
+ end
18
+
19
+ def delete(key)
20
+ perform_across_services :delete, key
21
+ end
22
+
23
+ private
24
+ def primary_service
25
+ services.first
26
+ end
27
+
28
+ def perform_across_services(method, *args)
29
+ # FIXME: Convert to be threaded
30
+ services.collect do |service|
31
+ service.public_send method, *args
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,55 @@
1
+ require "aws-sdk"
2
+ require "active_support/core_ext/numeric/bytes"
3
+
4
+ class ActiveStorage::Service::S3Service < ActiveStorage::Service
5
+ attr_reader :client, :bucket
6
+
7
+ def initialize(access_key_id:, secret_access_key:, region:, bucket:)
8
+ @client = Aws::S3::Resource.new(access_key_id: access_key_id, secret_access_key: secret_access_key, region: region)
9
+ @bucket = @client.bucket(bucket)
10
+ end
11
+
12
+ def upload(key, io, checksum: nil)
13
+ # FIXME: Ensure integrity by sending the checksum for service side verification
14
+ object_for(key).put(body: io)
15
+ end
16
+
17
+ def download(key)
18
+ if block_given?
19
+ stream(key, &block)
20
+ else
21
+ object_for(key).get.body.read
22
+ end
23
+ end
24
+
25
+ def delete(key)
26
+ object_for(key).delete
27
+ end
28
+
29
+ def exist?(key)
30
+ object_for(key).exists?
31
+ end
32
+
33
+ def url(key, expires_in:, disposition:, filename:)
34
+ object_for(key).presigned_url :get, expires_in: expires_in,
35
+ response_content_disposition: "#{disposition}; filename=\"#{filename}\""
36
+ end
37
+
38
+ private
39
+ def object_for(key)
40
+ bucket.object(key)
41
+ end
42
+
43
+ # Reads the object for the given key in chunks, yielding each to the block.
44
+ def stream(key, options = {}, &block)
45
+ object = object_for(key)
46
+
47
+ chunk_size = 5.megabytes
48
+ offset = 0
49
+
50
+ while offset < object.content_length
51
+ yield object.read(options.merge(:range => "bytes=#{offset}-#{offset + chunk_size - 1}"))
52
+ offset += chunk_size
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,27 @@
1
+ test:
2
+ service: Disk
3
+ root: <%= Rails.root.join("tmp/storage") %>
4
+
5
+ local:
6
+ service: Disk
7
+ root: <%= Rails.root.join("storage") %>
8
+
9
+ # Use rails secrets:edit to set the AWS secrets (as shared:aws:access_key_id|secret_access_key)
10
+ amazon:
11
+ service: S3
12
+ access_key_id: <%= Rails.application.secrets.aws[:access_key_id] %>
13
+ secret_access_key: <%= Rails.application.secrets.aws[:secret_access_key] %>
14
+ region: us-east-1
15
+ bucket: your_own_bucket
16
+
17
+ # Remember not to checkin your GCS keyfile to a repository
18
+ google:
19
+ service: GCS
20
+ project: your_project
21
+ keyfile: <%= Rails.root.join("path/to/gcs.keyfile") %>
22
+ bucket: your_own_bucket
23
+
24
+ mirror:
25
+ service: Mirror
26
+ primary: local
27
+ secondaries: [ amazon, google ]
@@ -0,0 +1,24 @@
1
+ class ActiveStorage::VerifiedKeyWithExpiration
2
+ class_attribute :verifier, default: defined?(Rails) ? Rails.application.message_verifier('ActiveStorage') : nil
3
+
4
+ class << self
5
+ def encode(key, expires_in: nil)
6
+ verifier.generate([ key, expires_at(expires_in) ])
7
+ end
8
+
9
+ def decode(encoded_key)
10
+ key, expires_at = verifier.verified(encoded_key)
11
+
12
+ key if key && fresh?(expires_at)
13
+ end
14
+
15
+ private
16
+ def expires_at(expires_in)
17
+ expires_in ? Time.now.utc.advance(seconds: expires_in) : nil
18
+ end
19
+
20
+ def fresh?(expires_at)
21
+ expires_at.nil? || Time.now.utc < expires_at
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,19 @@
1
+ require "fileutils"
2
+
3
+ namespace :activestorage do
4
+ desc "Copy over the migration needed to the application"
5
+ task :install do
6
+ FileUtils.mkdir_p Rails.root.join("storage")
7
+ FileUtils.mkdir_p Rails.root.join("tmp/storage")
8
+ puts "Made storage and tmp/storage directories for development and testing"
9
+
10
+ FileUtils.cp File.expand_path("../../active_storage/storage_services.yml", __FILE__), Rails.root.join("config")
11
+ puts "Copied default configuration to config/storage_services.yml"
12
+
13
+ migration_file_path = "db/migrate/#{Time.now.utc.strftime("%Y%m%d%H%M%S")}_active_storage_create_tables.rb"
14
+ FileUtils.cp File.expand_path("../../active_storage/migration.rb", __FILE__), Rails.root.join(migration_file_path)
15
+ puts "Copied migration to #{migration_file_path}"
16
+
17
+ puts "Now run rails db:migrate to create the tables for Active Storage"
18
+ end
19
+ end
@@ -0,0 +1,95 @@
1
+ require "test_helper"
2
+ require "database/setup"
3
+ require "active_storage/blob"
4
+
5
+ require "active_job"
6
+ ActiveJob::Base.queue_adapter = :test
7
+ ActiveJob::Base.logger = nil
8
+
9
+ # ActiveRecord::Base.logger = Logger.new(STDOUT)
10
+
11
+ class User < ActiveRecord::Base
12
+ has_one_attached :avatar
13
+ has_many_attached :highlights
14
+ end
15
+
16
+ class ActiveStorage::AttachmentsTest < ActiveSupport::TestCase
17
+ include ActiveJob::TestHelper
18
+
19
+ setup { @user = User.create!(name: "DHH") }
20
+
21
+ teardown { ActiveStorage::Blob.all.each(&:purge) }
22
+
23
+ test "attach existing blob" do
24
+ @user.avatar.attach create_blob(filename: "funky.jpg")
25
+ assert_equal "funky.jpg", @user.avatar.filename.to_s
26
+ end
27
+
28
+ test "attach new blob" do
29
+ @user.avatar.attach io: StringIO.new("STUFF"), filename: "town.jpg", content_type: "image/jpg"
30
+ assert_equal "town.jpg", @user.avatar.filename.to_s
31
+ end
32
+
33
+ test "purge attached blob" do
34
+ @user.avatar.attach create_blob(filename: "funky.jpg")
35
+ avatar_key = @user.avatar.key
36
+
37
+ @user.avatar.purge
38
+ assert_not @user.avatar.attached?
39
+ assert_not ActiveStorage::Blob.service.exist?(avatar_key)
40
+ end
41
+
42
+ test "purge attached blob later when the record is destroyed" do
43
+ @user.avatar.attach create_blob(filename: "funky.jpg")
44
+ avatar_key = @user.avatar.key
45
+
46
+ perform_enqueued_jobs do
47
+ @user.destroy
48
+
49
+ assert_nil ActiveStorage::Blob.find_by(key: avatar_key)
50
+ assert_not ActiveStorage::Blob.service.exist?(avatar_key)
51
+ end
52
+ end
53
+
54
+
55
+ test "attach existing blobs" do
56
+ @user.highlights.attach create_blob(filename: "funky.jpg"), create_blob(filename: "wonky.jpg")
57
+
58
+ assert_equal "funky.jpg", @user.highlights.first.filename.to_s
59
+ assert_equal "wonky.jpg", @user.highlights.second.filename.to_s
60
+ end
61
+
62
+ test "attach new blobs" do
63
+ @user.highlights.attach(
64
+ { io: StringIO.new("STUFF"), filename: "town.jpg", content_type: "image/jpg" },
65
+ { io: StringIO.new("IT"), filename: "country.jpg", content_type: "image/jpg" })
66
+
67
+ assert_equal "town.jpg", @user.highlights.first.filename.to_s
68
+ assert_equal "country.jpg", @user.highlights.second.filename.to_s
69
+ end
70
+
71
+ test "purge attached blobs" do
72
+ @user.highlights.attach create_blob(filename: "funky.jpg"), create_blob(filename: "wonky.jpg")
73
+ highlight_keys = @user.highlights.collect(&:key)
74
+
75
+ @user.highlights.purge
76
+ assert_not @user.highlights.attached?
77
+ assert_not ActiveStorage::Blob.service.exist?(highlight_keys.first)
78
+ assert_not ActiveStorage::Blob.service.exist?(highlight_keys.second)
79
+ end
80
+
81
+ test "purge attached blobs later when the record is destroyed" do
82
+ @user.highlights.attach create_blob(filename: "funky.jpg"), create_blob(filename: "wonky.jpg")
83
+ highlight_keys = @user.highlights.collect(&:key)
84
+
85
+ perform_enqueued_jobs do
86
+ @user.destroy
87
+
88
+ assert_nil ActiveStorage::Blob.find_by(key: highlight_keys.first)
89
+ assert_not ActiveStorage::Blob.service.exist?(highlight_keys.first)
90
+
91
+ assert_nil ActiveStorage::Blob.find_by(key: highlight_keys.second)
92
+ assert_not ActiveStorage::Blob.service.exist?(highlight_keys.second)
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,28 @@
1
+ require "test_helper"
2
+ require "database/setup"
3
+ require "active_storage/blob"
4
+
5
+ class ActiveStorage::BlobTest < ActiveSupport::TestCase
6
+ test "create after upload sets byte size and checksum" do
7
+ data = "Hello world!"
8
+ blob = create_blob data: data
9
+
10
+ assert_equal data, blob.download
11
+ assert_equal data.length, blob.byte_size
12
+ assert_equal Digest::MD5.base64digest(data), blob.checksum
13
+ end
14
+
15
+ test "urls expiring in 5 minutes" do
16
+ blob = create_blob
17
+
18
+ travel_to Time.now do
19
+ assert_equal expected_url_for(blob), blob.url
20
+ assert_equal expected_url_for(blob, disposition: :attachment), blob.url(disposition: :attachment)
21
+ end
22
+ end
23
+
24
+ private
25
+ def expected_url_for(blob, disposition: :inline)
26
+ "/rails/blobs/#{ActiveStorage::VerifiedKeyWithExpiration.encode(blob.key, expires_in: 5.minutes)}?disposition=#{disposition}"
27
+ end
28
+ end
@@ -0,0 +1,7 @@
1
+ class ActiveStorage::CreateUsers < ActiveRecord::Migration[5.1]
2
+ def change
3
+ create_table :users do |t|
4
+ t.string :name
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,6 @@
1
+ require "active_storage/migration"
2
+ require_relative "create_users_migration"
3
+
4
+ ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:')
5
+ ActiveStorage::CreateTables.migrate(:up)
6
+ ActiveStorage::CreateUsers.migrate(:up)
@@ -0,0 +1,34 @@
1
+ require "test_helper"
2
+ require "database/setup"
3
+
4
+ require "action_controller"
5
+ require "action_controller/test_case"
6
+
7
+ require "active_storage/disk_controller"
8
+ require "active_storage/verified_key_with_expiration"
9
+
10
+ class ActiveStorage::DiskControllerTest < ActionController::TestCase
11
+ Routes = ActionDispatch::Routing::RouteSet.new.tap do |routes|
12
+ routes.draw do
13
+ get "/rails/blobs/:encoded_key" => "active_storage/disk#show", as: :rails_disk_blob
14
+ end
15
+ end
16
+
17
+ setup do
18
+ @blob = create_blob
19
+ @routes = Routes
20
+ @controller = ActiveStorage::DiskController.new
21
+ end
22
+
23
+ test "showing blob inline" do
24
+ get :show, params: { encoded_key: ActiveStorage::VerifiedKeyWithExpiration.encode(@blob.key, expires_in: 5.minutes) }
25
+ assert_equal "inline; filename=\"#{@blob.filename}\"", @response.headers["Content-Disposition"]
26
+ assert_equal "text/plain", @response.headers["Content-Type"]
27
+ end
28
+
29
+ test "sending blob as attachment" do
30
+ get :show, params: { encoded_key: ActiveStorage::VerifiedKeyWithExpiration.encode(@blob.key, expires_in: 5.minutes), disposition: :attachment }
31
+ assert_equal "attachment; filename=\"#{@blob.filename}\"", @response.headers["Content-Disposition"]
32
+ assert_equal "text/plain", @response.headers["Content-Type"]
33
+ end
34
+ end