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,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,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
|