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.
- checksums.yaml +7 -0
- data/.gitignore +1 -0
- data/Gemfile +11 -0
- data/Gemfile.lock +235 -0
- data/MIT-LICENSE +20 -0
- data/README.md +76 -0
- data/Rakefile +11 -0
- data/activestorage.gemspec +21 -0
- data/lib/active_storage.rb +9 -0
- data/lib/active_storage/attached.rb +34 -0
- data/lib/active_storage/attached/macros.rb +23 -0
- data/lib/active_storage/attached/many.rb +31 -0
- data/lib/active_storage/attached/one.rb +29 -0
- data/lib/active_storage/attachment.rb +30 -0
- data/lib/active_storage/blob.rb +80 -0
- data/lib/active_storage/disk_controller.rb +28 -0
- data/lib/active_storage/download.rb +90 -0
- data/lib/active_storage/filename.rb +31 -0
- data/lib/active_storage/migration.rb +28 -0
- data/lib/active_storage/purge_job.rb +10 -0
- data/lib/active_storage/railtie.rb +56 -0
- data/lib/active_storage/service.rb +34 -0
- data/lib/active_storage/service/disk_service.rb +70 -0
- data/lib/active_storage/service/gcs_service.rb +41 -0
- data/lib/active_storage/service/mirror_service.rb +34 -0
- data/lib/active_storage/service/s3_service.rb +55 -0
- data/lib/active_storage/storage_services.yml +27 -0
- data/lib/active_storage/verified_key_with_expiration.rb +24 -0
- data/lib/tasks/activestorage.rake +19 -0
- data/test/attachments_test.rb +95 -0
- data/test/blob_test.rb +28 -0
- data/test/database/create_users_migration.rb +7 -0
- data/test/database/setup.rb +6 -0
- data/test/disk_controller_test.rb +34 -0
- data/test/filename_test.rb +36 -0
- data/test/service/.gitignore +1 -0
- data/test/service/configurations-example.yml +11 -0
- data/test/service/disk_service_test.rb +8 -0
- data/test/service/gcs_service_test.rb +20 -0
- data/test/service/mirror_service_test.rb +50 -0
- data/test/service/s3_service_test.rb +11 -0
- data/test/service/shared_service_tests.rb +68 -0
- data/test/test_helper.rb +28 -0
- data/test/verified_key_with_expiration_test.rb +19 -0
- 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
|
data/test/blob_test.rb
ADDED
@@ -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,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
|