activeblob 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +412 -0
- data/Rakefile +3 -0
- data/app/models/activeblob/attachment.rb +41 -0
- data/app/models/activeblob/blob/image.rb +38 -0
- data/app/models/activeblob/blob/pdf.rb +31 -0
- data/app/models/activeblob/blob/video.rb +44 -0
- data/app/models/activeblob/blob.rb +122 -0
- data/lib/activeblob/blob_helpers.rb +166 -0
- data/lib/activeblob/engine.rb +20 -0
- data/lib/activeblob/model_extensions.rb +114 -0
- data/lib/activeblob/storage/filesystem.rb +60 -0
- data/lib/activeblob/storage/s3.rb +68 -0
- data/lib/activeblob/version.rb +3 -0
- data/lib/activeblob.rb +46 -0
- data/lib/generators/activeblob/install/install_generator.rb +30 -0
- data/lib/generators/activeblob/install/templates/README +41 -0
- data/lib/generators/activeblob/install/templates/create_attachments.rb +20 -0
- data/lib/generators/activeblob/install/templates/create_blobs.rb +16 -0
- data/lib/generators/activeblob/install/templates/initializer.rb +19 -0
- metadata +135 -0
@@ -0,0 +1,122 @@
|
|
1
|
+
module ActiveBlob
|
2
|
+
class Blob < ActiveRecord::Base
|
3
|
+
self.table_name = 'blobs'
|
4
|
+
|
5
|
+
include ActiveBlob::BlobHelpers
|
6
|
+
|
7
|
+
attr_reader :data
|
8
|
+
|
9
|
+
has_many :attachments, class_name: 'ActiveBlob::Attachment', dependent: :destroy
|
10
|
+
validates :file, presence: true, on: :create
|
11
|
+
validates :url, format: { with: URI::regexp(%w(http https)) }, on: :create, if: ->(b) { b.url }
|
12
|
+
validates :content_type, presence: true
|
13
|
+
validates :sha1, presence: true, length: { is: 20 }
|
14
|
+
validates :size, numericality: { only_integer: true, greater_than: 0 }
|
15
|
+
|
16
|
+
after_save :store_file
|
17
|
+
after_destroy :delete_file
|
18
|
+
|
19
|
+
def self.new(attrs={})
|
20
|
+
if attrs[:url] && attrs[:url] =~ URI::regexp(%w(http https))
|
21
|
+
attrs[:file] = download_url(attrs.delete(:url))
|
22
|
+
end
|
23
|
+
|
24
|
+
if attrs[:base64]
|
25
|
+
attrs[:file] = base64_file(attrs.delete(:base64), attrs)
|
26
|
+
end
|
27
|
+
|
28
|
+
if attrs[:data]
|
29
|
+
attrs[:file] = data_file(attrs.delete(:data), attrs)
|
30
|
+
end
|
31
|
+
|
32
|
+
if attrs[:file]
|
33
|
+
klass = attrs[:content_type] ? content_type_class(attrs[:content_type]) : file_class(attrs[:file])
|
34
|
+
sha1 = file_sha1(attrs[:file])
|
35
|
+
klass.find_by_sha1(sha1) || (klass == self ? super(attrs) : klass.new(attrs))
|
36
|
+
else
|
37
|
+
super(attrs)
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def url=(value)
|
42
|
+
@url = value if !persisted?
|
43
|
+
end
|
44
|
+
|
45
|
+
def url(**options)
|
46
|
+
options[:disposition] = options[:disposition] == 'inline' ? 'inline' : 'attachment'
|
47
|
+
if options[:filename]
|
48
|
+
options[:disposition] += "; filename=\"#{URI.encode_www_form_component(options[:filename].force_encoding(Encoding::UTF_8)).gsub("%2F", "/")}\""
|
49
|
+
end
|
50
|
+
|
51
|
+
if persisted?
|
52
|
+
if self.class.storage.local?
|
53
|
+
self.class.storage.url(id)
|
54
|
+
else
|
55
|
+
self.class.storage.url(id, **options.slice(:disposition, :expires_in))
|
56
|
+
end
|
57
|
+
else
|
58
|
+
@url
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
def file
|
63
|
+
@queued
|
64
|
+
end
|
65
|
+
|
66
|
+
def data=(value)
|
67
|
+
raise 'Cannot set file on persisted blob or blob with file already set' if persisted? || @file
|
68
|
+
self.size = value.bytesize
|
69
|
+
self.sha1 = Digest::SHA1.digest(value)
|
70
|
+
|
71
|
+
@queued = Tempfile.new(binmode: true)
|
72
|
+
@queued.write(value)
|
73
|
+
@queued.flush
|
74
|
+
@queued.rewind
|
75
|
+
end
|
76
|
+
|
77
|
+
def extension
|
78
|
+
"." + MiniMime.lookup_by_content_type(content_type).extension
|
79
|
+
end
|
80
|
+
|
81
|
+
def file=(file)
|
82
|
+
raise 'Cannot set file on persisted blob or blob with file already set' if persisted? || @file
|
83
|
+
return if file.nil?
|
84
|
+
|
85
|
+
if self.content_type.nil? || self.content_type == 'application/octet-stream'
|
86
|
+
self.content_type = file_content_type(file)
|
87
|
+
end
|
88
|
+
self.size = file.size
|
89
|
+
self.sha1 = file_sha1(file)
|
90
|
+
|
91
|
+
@queued = Tempfile.new(binmode: true)
|
92
|
+
FileUtils.cp(file.path, @queued.path)
|
93
|
+
end
|
94
|
+
|
95
|
+
def open(basename: nil, &block)
|
96
|
+
basename ||= ['', extension]
|
97
|
+
self.class.storage.copy_to_tempfile(id, basename: basename, &block)
|
98
|
+
end
|
99
|
+
|
100
|
+
private
|
101
|
+
|
102
|
+
def store_file
|
103
|
+
if @queued
|
104
|
+
self.class.storage.write(id, @queued, { content_type: content_type })
|
105
|
+
end
|
106
|
+
ensure
|
107
|
+
if @queued
|
108
|
+
@queued.close
|
109
|
+
if @queued.is_a? ActionDispatch::Http::UploadedFile
|
110
|
+
@queued.tempfile.unlink
|
111
|
+
else
|
112
|
+
@queued.unlink
|
113
|
+
end
|
114
|
+
@queued = nil
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
def delete_file
|
119
|
+
self.class.storage.delete(id)
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
@@ -0,0 +1,166 @@
|
|
1
|
+
require 'active_support/concern'
|
2
|
+
require 'mini_mime'
|
3
|
+
require 'digest/sha1'
|
4
|
+
require 'base64'
|
5
|
+
require 'tempfile'
|
6
|
+
require 'net/http'
|
7
|
+
require 'uri'
|
8
|
+
|
9
|
+
module ActiveBlob
|
10
|
+
module BlobHelpers
|
11
|
+
extend ActiveSupport::Concern
|
12
|
+
|
13
|
+
def file_content_type(file)
|
14
|
+
self.class.file_content_type(file)
|
15
|
+
end
|
16
|
+
|
17
|
+
def file_sha1(file)
|
18
|
+
self.class.file_sha1(file)
|
19
|
+
end
|
20
|
+
|
21
|
+
def self.filename_from_file(file)
|
22
|
+
fn = if file.respond_to?(:original_filename)
|
23
|
+
file.original_filename || File.basename(file.path)
|
24
|
+
else
|
25
|
+
File.basename(file.path)
|
26
|
+
end
|
27
|
+
"#{File.basename(fn, File.extname(fn))}#{File.extname(fn)&.downcase}"
|
28
|
+
end
|
29
|
+
|
30
|
+
class_methods do
|
31
|
+
def storage
|
32
|
+
ActiveBlob.storage
|
33
|
+
end
|
34
|
+
|
35
|
+
def file_sha1(file)
|
36
|
+
if file.is_a?(ActionDispatch::Http::UploadedFile)
|
37
|
+
if file.instance_variable_get(:@sha1)
|
38
|
+
file.instance_variable_get(:@sha1)
|
39
|
+
else
|
40
|
+
digest = Digest::SHA1.file(file.path).digest
|
41
|
+
file.instance_variable_set(:@sha1, digest)
|
42
|
+
digest
|
43
|
+
end
|
44
|
+
else
|
45
|
+
Digest::SHA1.file(file.path).digest
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
def download_url(url, headers: {})
|
50
|
+
return unless url =~ URI::regexp(%w(http https))
|
51
|
+
uri = URI.parse(url)
|
52
|
+
|
53
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
54
|
+
http.use_ssl = (uri.scheme == "https")
|
55
|
+
|
56
|
+
http.request_get(uri.request_uri, headers) do |resp|
|
57
|
+
if resp.is_a?(Net::HTTPRedirection)
|
58
|
+
return download_url(resp['location'])
|
59
|
+
elsif resp.is_a?(Net::HTTPSuccess)
|
60
|
+
tmpfile = Tempfile.new(binmode: true)
|
61
|
+
digest = Digest::SHA1.new
|
62
|
+
|
63
|
+
resp.read_body do |part|
|
64
|
+
digest << part
|
65
|
+
tmpfile.write(part)
|
66
|
+
end
|
67
|
+
tmpfile.flush
|
68
|
+
tmpfile.rewind
|
69
|
+
|
70
|
+
filename = if resp["content-disposition"] && resp["content_disposition"] =~ /filename=/
|
71
|
+
File.basename(resp["content-disposition"].match(/filename=\"(.*)\"/)[1])
|
72
|
+
else
|
73
|
+
fn = File.basename(uri.path)
|
74
|
+
fn = 'index' if fn == '/'
|
75
|
+
mime_type = MiniMime.lookup_by_content_type(resp["content-type"])
|
76
|
+
extension = (mime_type ? ".#{mime_type.extension}" : File.extname(fn))
|
77
|
+
File.basename(uri.path, ".*") + extension
|
78
|
+
end
|
79
|
+
|
80
|
+
content_type = resp["content-type"]
|
81
|
+
if !content_type.present?
|
82
|
+
# Fallback to MiniMime if content-type header is missing
|
83
|
+
content_type = MiniMime.lookup_by_filename(filename)&.content_type || 'application/octet-stream'
|
84
|
+
end
|
85
|
+
|
86
|
+
file = ActionDispatch::Http::UploadedFile.new({
|
87
|
+
filename: filename,
|
88
|
+
type: content_type,
|
89
|
+
tempfile: tmpfile
|
90
|
+
})
|
91
|
+
file.instance_variable_set(:@sha1, digest.digest)
|
92
|
+
return file
|
93
|
+
else
|
94
|
+
raise Net::HTTPError.new("HTTP #{resp.code} loading #{url}", resp)
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
def data_file(value, attrs = {})
|
100
|
+
sha1 = Digest::SHA1.digest(value)
|
101
|
+
tmpfile = Tempfile.new(binmode: true)
|
102
|
+
tmpfile.write(value)
|
103
|
+
tmpfile.flush
|
104
|
+
tmpfile.rewind
|
105
|
+
|
106
|
+
file = ActionDispatch::Http::UploadedFile.new({
|
107
|
+
filename: attrs[:filename],
|
108
|
+
type: attrs[:content_type],
|
109
|
+
tempfile: tmpfile
|
110
|
+
})
|
111
|
+
file.instance_variable_set(:@sha1, sha1)
|
112
|
+
file
|
113
|
+
end
|
114
|
+
|
115
|
+
def base64_file(value, attrs = {})
|
116
|
+
value = Base64.decode64(value)
|
117
|
+
sha1 = Digest::SHA1.digest(value)
|
118
|
+
tmpfile = Tempfile.new(binmode: true)
|
119
|
+
tmpfile.write(value)
|
120
|
+
tmpfile.flush
|
121
|
+
tmpfile.rewind
|
122
|
+
|
123
|
+
file = ActionDispatch::Http::UploadedFile.new({
|
124
|
+
filename: attrs[:filename],
|
125
|
+
type: attrs[:content_type],
|
126
|
+
tempfile: tmpfile
|
127
|
+
})
|
128
|
+
file.instance_variable_set(:@sha1, sha1)
|
129
|
+
file
|
130
|
+
end
|
131
|
+
|
132
|
+
def file_content_type(file)
|
133
|
+
content_type = if file.respond_to?(:content_type)
|
134
|
+
file.content_type&.strip
|
135
|
+
else
|
136
|
+
'application/octet-stream'
|
137
|
+
end
|
138
|
+
|
139
|
+
if content_type == 'application/octet-stream'
|
140
|
+
content_type = MiniMime.lookup_by_filename(file.path)&.content_type
|
141
|
+
end
|
142
|
+
|
143
|
+
content_type
|
144
|
+
end
|
145
|
+
|
146
|
+
def file_class(file)
|
147
|
+
content_type_class(file_content_type(file))
|
148
|
+
end
|
149
|
+
|
150
|
+
def content_type_class(content_type)
|
151
|
+
descendant = self.descendants.find do |descendant|
|
152
|
+
descendant.validators_on(:content_type).find do |validator|
|
153
|
+
case validator
|
154
|
+
when ActiveModel::Validations::FormatValidator
|
155
|
+
content_type =~ validator.options[:with]
|
156
|
+
when ActiveModel::Validations::InclusionValidator
|
157
|
+
validator.send(:delimiter).include?(content_type)
|
158
|
+
end
|
159
|
+
end
|
160
|
+
end
|
161
|
+
|
162
|
+
descendant || self
|
163
|
+
end
|
164
|
+
end
|
165
|
+
end
|
166
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
require "rails"
|
2
|
+
|
3
|
+
module ActiveBlob
|
4
|
+
class Engine < ::Rails::Engine
|
5
|
+
isolate_namespace ActiveBlob
|
6
|
+
|
7
|
+
config.autoload_paths << File.expand_path('../../app/models', __dir__)
|
8
|
+
|
9
|
+
config.generators do |g|
|
10
|
+
g.test_framework :test_unit, fixture: false
|
11
|
+
end
|
12
|
+
|
13
|
+
initializer "activeblob.active_record", before: :load_config_initializers do
|
14
|
+
ActiveSupport.on_load(:active_record) do
|
15
|
+
require "activeblob/model_extensions"
|
16
|
+
ActiveRecord::Base.include(ActiveBlob::ModelExtensions)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,114 @@
|
|
1
|
+
module ActiveBlob
|
2
|
+
module ModelExtensions
|
3
|
+
extend ActiveSupport::Concern
|
4
|
+
|
5
|
+
class_methods do
|
6
|
+
def has_one_blob(name, dependent: :destroy)
|
7
|
+
has_one :"#{name}", -> { where(type: name) }, class_name: "::ActiveBlob::Attachment", as: :record, inverse_of: :record, dependent: dependent
|
8
|
+
|
9
|
+
class_eval <<-RUBY, __FILE__, __LINE__ + 1
|
10
|
+
def #{name}_attributes=(attrs)
|
11
|
+
attachment = if self.#{name} && self.#{name}.id == (attrs[:id] || attrs['id'])
|
12
|
+
self.#{name}
|
13
|
+
end
|
14
|
+
|
15
|
+
if attachment
|
16
|
+
attachment.order = 0
|
17
|
+
attachment.filename = (attrs[:filename] || attrs['filename']) if (attrs[:filename] || attrs['filename'])
|
18
|
+
attachment.blob_id = (attrs[:blob_id] || attrs['blob_id']) if (attrs[:blob_id] || attrs['blob_id'])
|
19
|
+
else
|
20
|
+
attachment = ActiveBlob::Attachment.new({
|
21
|
+
order: 0,
|
22
|
+
filename: (attrs[:filename] || attrs['filename']),
|
23
|
+
blob_id: (attrs[:blob_id] || attrs['blob_id']),
|
24
|
+
type: '#{name}'
|
25
|
+
})
|
26
|
+
end
|
27
|
+
|
28
|
+
self.#{name} = attachment
|
29
|
+
end
|
30
|
+
|
31
|
+
def #{name}=(file)
|
32
|
+
if file && !file.is_a?(ActiveBlob::Attachment)
|
33
|
+
file = ActiveBlob::Attachment.new({
|
34
|
+
blob: file,
|
35
|
+
filename: ActiveBlob::BlobHelpers.filename_from_file(file),
|
36
|
+
type: '#{name}'
|
37
|
+
})
|
38
|
+
elsif file
|
39
|
+
file.type = '#{name}'
|
40
|
+
end
|
41
|
+
association(:#{name}).writer(file)
|
42
|
+
end
|
43
|
+
RUBY
|
44
|
+
end
|
45
|
+
|
46
|
+
def has_many_blobs(name, **options)
|
47
|
+
options[:dependent] ||= :destroy
|
48
|
+
options = {
|
49
|
+
dependent: :destroy,
|
50
|
+
autosave: true,
|
51
|
+
inverse_of: :record,
|
52
|
+
as: :record,
|
53
|
+
class_name: '::ActiveBlob::Attachment'
|
54
|
+
}.merge(options)
|
55
|
+
singular = name.to_s.singularize
|
56
|
+
has_many :"#{name}", -> { where(type: singular).order(order: :asc) }, **options
|
57
|
+
|
58
|
+
class_eval <<-RUBY, __FILE__, __LINE__ + 1
|
59
|
+
def #{name}_attributes=(attributes)
|
60
|
+
if !attributes.is_a?(Array)
|
61
|
+
raise ArgumentError, "Array expected for attributes `#{name}`, got \#{attributes.class.name} (\#{attributes.inspect})"
|
62
|
+
end
|
63
|
+
|
64
|
+
attachments = attributes.map.with_index do |attrs, i|
|
65
|
+
attachment = self.#{name}.find { |a| a.id == (attrs[:id] || attrs['id']) }
|
66
|
+
|
67
|
+
if attachment
|
68
|
+
attachment.order = i
|
69
|
+
attachment.filename = (attrs[:filename] || attrs['filename']) if (attrs[:filename] || attrs['filename'])
|
70
|
+
attachment.blob_id = (attrs[:blob_id] || attrs['blob_id']) if (attrs[:blob_id] || attrs['blob_id'])
|
71
|
+
attachment
|
72
|
+
else
|
73
|
+
ActiveBlob::Attachment.new({
|
74
|
+
order: i,
|
75
|
+
filename: (attrs[:filename] || attrs['filename']),
|
76
|
+
blob_id: (attrs[:blob_id] || attrs['blob_id']),
|
77
|
+
type: '#{singular}'
|
78
|
+
})
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
self.#{name} = attachments
|
83
|
+
end
|
84
|
+
|
85
|
+
def #{name}=(files)
|
86
|
+
files = files.map.with_index do |file, i|
|
87
|
+
if file.is_a?(ActiveBlob::Attachment)
|
88
|
+
file.type = '#{singular}'
|
89
|
+
file.order = i
|
90
|
+
file
|
91
|
+
elsif file.is_a?(ActiveBlob::Blob)
|
92
|
+
ActiveBlob::Attachment.new({
|
93
|
+
order: i,
|
94
|
+
type: '#{singular}',
|
95
|
+
blob: file,
|
96
|
+
filename: ActiveBlob::BlobHelpers.filename_from_file(file)
|
97
|
+
})
|
98
|
+
else
|
99
|
+
ActiveBlob::Attachment.new({
|
100
|
+
order: i,
|
101
|
+
type: '#{singular}',
|
102
|
+
blob: ActiveBlob::Blob.new(file: file),
|
103
|
+
filename: ActiveBlob::BlobHelpers.filename_from_file(file)
|
104
|
+
})
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
association(:#{name}).writer(files)
|
109
|
+
end
|
110
|
+
RUBY
|
111
|
+
end
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
@@ -0,0 +1,60 @@
|
|
1
|
+
require 'fileutils'
|
2
|
+
|
3
|
+
module ActiveBlob
|
4
|
+
module Storage
|
5
|
+
class Filesystem
|
6
|
+
attr_reader :path
|
7
|
+
|
8
|
+
def initialize(config = {})
|
9
|
+
@path = config[:path] || Rails.root.join('storage', 'blobs')
|
10
|
+
FileUtils.mkdir_p(@path)
|
11
|
+
end
|
12
|
+
|
13
|
+
def local?
|
14
|
+
true
|
15
|
+
end
|
16
|
+
|
17
|
+
def write(id, file, options = {})
|
18
|
+
file_path = path_for(id)
|
19
|
+
FileUtils.mkdir_p(File.dirname(file_path))
|
20
|
+
FileUtils.cp(file.path, file_path)
|
21
|
+
end
|
22
|
+
|
23
|
+
def read(id)
|
24
|
+
File.read(path_for(id))
|
25
|
+
end
|
26
|
+
|
27
|
+
def delete(id)
|
28
|
+
FileUtils.rm_f(path_for(id))
|
29
|
+
end
|
30
|
+
|
31
|
+
def exists?(id)
|
32
|
+
File.exist?(path_for(id))
|
33
|
+
end
|
34
|
+
|
35
|
+
def url(id, **options)
|
36
|
+
"/blobs/#{id}/download"
|
37
|
+
end
|
38
|
+
|
39
|
+
def copy_to_tempfile(id, basename: nil, &block)
|
40
|
+
basename ||= ['blob', '']
|
41
|
+
source_path = path_for(id)
|
42
|
+
|
43
|
+
Tempfile.create(basename, binmode: true) do |tmpfile|
|
44
|
+
FileUtils.cp(source_path, tmpfile.path)
|
45
|
+
tmpfile.rewind
|
46
|
+
block.call(tmpfile)
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
private
|
51
|
+
|
52
|
+
def path_for(id)
|
53
|
+
# Split ID into subdirectories for better filesystem performance
|
54
|
+
# e.g., "abc123" -> "ab/c1/abc123"
|
55
|
+
id_str = id.to_s
|
56
|
+
File.join(@path, id_str[0..1], id_str[2..3], id_str)
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
@@ -0,0 +1,68 @@
|
|
1
|
+
require 'aws-sdk-s3'
|
2
|
+
|
3
|
+
module ActiveBlob
|
4
|
+
module Storage
|
5
|
+
class S3
|
6
|
+
attr_reader :bucket, :client
|
7
|
+
|
8
|
+
def initialize(config = {})
|
9
|
+
@bucket = config[:bucket]
|
10
|
+
@client = Aws::S3::Client.new(
|
11
|
+
access_key_id: config[:access_key_id],
|
12
|
+
secret_access_key: config[:secret_access_key],
|
13
|
+
region: config[:region] || 'us-east-1',
|
14
|
+
endpoint: config[:endpoint]
|
15
|
+
)
|
16
|
+
@resource = Aws::S3::Resource.new(client: @client)
|
17
|
+
@bucket_obj = @resource.bucket(@bucket)
|
18
|
+
end
|
19
|
+
|
20
|
+
def local?
|
21
|
+
false
|
22
|
+
end
|
23
|
+
|
24
|
+
def write(id, file, options = {})
|
25
|
+
@bucket_obj.object(key_for(id)).upload_file(
|
26
|
+
file.path,
|
27
|
+
content_type: options[:content_type]
|
28
|
+
)
|
29
|
+
end
|
30
|
+
|
31
|
+
def read(id)
|
32
|
+
@bucket_obj.object(key_for(id)).get.body.read
|
33
|
+
end
|
34
|
+
|
35
|
+
def delete(id)
|
36
|
+
@bucket_obj.object(key_for(id)).delete
|
37
|
+
end
|
38
|
+
|
39
|
+
def exists?(id)
|
40
|
+
@bucket_obj.object(key_for(id)).exists?
|
41
|
+
end
|
42
|
+
|
43
|
+
def url(id, disposition: 'attachment', expires_in: 300)
|
44
|
+
@bucket_obj.object(key_for(id)).presigned_url(
|
45
|
+
:get,
|
46
|
+
expires_in: expires_in,
|
47
|
+
response_content_disposition: disposition
|
48
|
+
)
|
49
|
+
end
|
50
|
+
|
51
|
+
def copy_to_tempfile(id, basename: nil, &block)
|
52
|
+
basename ||= ['blob', '']
|
53
|
+
|
54
|
+
Tempfile.create(basename, binmode: true) do |tmpfile|
|
55
|
+
@bucket_obj.object(key_for(id)).get(response_target: tmpfile.path)
|
56
|
+
tmpfile.rewind
|
57
|
+
block.call(tmpfile)
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
private
|
62
|
+
|
63
|
+
def key_for(id)
|
64
|
+
"blobs/#{id}"
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
data/lib/activeblob.rb
ADDED
@@ -0,0 +1,46 @@
|
|
1
|
+
require "activeblob/version"
|
2
|
+
require "activeblob/engine"
|
3
|
+
require "activeblob/blob_helpers"
|
4
|
+
require "activeblob/storage/filesystem"
|
5
|
+
require "activeblob/storage/s3"
|
6
|
+
require "activeblob/model_extensions"
|
7
|
+
|
8
|
+
# Require models explicitly since they're in a gem
|
9
|
+
require_relative "../app/models/activeblob/blob"
|
10
|
+
require_relative "../app/models/activeblob/attachment"
|
11
|
+
require_relative "../app/models/activeblob/blob/image"
|
12
|
+
require_relative "../app/models/activeblob/blob/video"
|
13
|
+
require_relative "../app/models/activeblob/blob/pdf"
|
14
|
+
|
15
|
+
module ActiveBlob
|
16
|
+
mattr_accessor :storage_config
|
17
|
+
|
18
|
+
class << self
|
19
|
+
def configure
|
20
|
+
yield self if block_given?
|
21
|
+
end
|
22
|
+
|
23
|
+
def storage
|
24
|
+
@storage ||= begin
|
25
|
+
config = storage_config || default_storage_config
|
26
|
+
case config[:storage]
|
27
|
+
when 'filesystem', nil
|
28
|
+
ActiveBlob::Storage::Filesystem.new(config)
|
29
|
+
when 's3'
|
30
|
+
ActiveBlob::Storage::S3.new(config)
|
31
|
+
else
|
32
|
+
raise "Unknown storage type: #{config[:storage]}"
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
private
|
38
|
+
|
39
|
+
def default_storage_config
|
40
|
+
{
|
41
|
+
storage: 'filesystem',
|
42
|
+
path: Rails.root.join('storage', 'blobs')
|
43
|
+
}
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
require 'rails/generators'
|
2
|
+
require 'rails/generators/migration'
|
3
|
+
|
4
|
+
module Activeblob
|
5
|
+
module Generators
|
6
|
+
class InstallGenerator < Rails::Generators::Base
|
7
|
+
include Rails::Generators::Migration
|
8
|
+
|
9
|
+
source_root File.expand_path('templates', __dir__)
|
10
|
+
|
11
|
+
def self.next_migration_number(path)
|
12
|
+
Time.now.utc.strftime("%Y%m%d%H%M%S")
|
13
|
+
end
|
14
|
+
|
15
|
+
def copy_migrations
|
16
|
+
migration_template "create_blobs.rb", "db/migrate/create_blobs.rb"
|
17
|
+
sleep 1 # Ensure unique timestamp
|
18
|
+
migration_template "create_attachments.rb", "db/migrate/create_attachments.rb"
|
19
|
+
end
|
20
|
+
|
21
|
+
def create_initializer
|
22
|
+
template "initializer.rb", "config/initializers/activeblob.rb"
|
23
|
+
end
|
24
|
+
|
25
|
+
def show_readme
|
26
|
+
readme "README" if behavior == :invoke
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
===============================================================================
|
2
|
+
|
3
|
+
ActiveBlob has been installed!
|
4
|
+
|
5
|
+
Next steps:
|
6
|
+
|
7
|
+
1. Run the migrations:
|
8
|
+
rails db:migrate
|
9
|
+
|
10
|
+
2. Configure your storage backend in config/initializers/activeblob.rb
|
11
|
+
|
12
|
+
3. Add blob attachments to your models:
|
13
|
+
|
14
|
+
# Single attachment
|
15
|
+
class User < ApplicationRecord
|
16
|
+
has_one_blob :avatar
|
17
|
+
end
|
18
|
+
|
19
|
+
# Multiple attachments
|
20
|
+
class Post < ApplicationRecord
|
21
|
+
has_many_blobs :images
|
22
|
+
end
|
23
|
+
|
24
|
+
4. Use in your application:
|
25
|
+
|
26
|
+
# Create with file upload
|
27
|
+
user.avatar = params[:avatar]
|
28
|
+
|
29
|
+
# Create with URL
|
30
|
+
blob = ActiveBlob::Blob.new(url: "https://example.com/image.jpg")
|
31
|
+
|
32
|
+
# Create with base64
|
33
|
+
blob = ActiveBlob::Blob.new(base64: base64_string, content_type: "image/png")
|
34
|
+
|
35
|
+
# Get URL
|
36
|
+
user.avatar.url
|
37
|
+
blob.url(disposition: 'inline', filename: 'custom.jpg')
|
38
|
+
|
39
|
+
For more information, visit: https://github.com/bemky/activeblob
|
40
|
+
|
41
|
+
===============================================================================
|
@@ -0,0 +1,20 @@
|
|
1
|
+
class CreateAttachments < ActiveRecord::Migration[6.1]
|
2
|
+
def change
|
3
|
+
create_table :attachments, id: :uuid do |t|
|
4
|
+
t.string :type
|
5
|
+
t.integer :order, null: false, default: 0
|
6
|
+
t.string :filename
|
7
|
+
t.string :record_type
|
8
|
+
t.uuid :record_id
|
9
|
+
t.uuid :blob_id, null: false
|
10
|
+
|
11
|
+
t.timestamps
|
12
|
+
end
|
13
|
+
|
14
|
+
add_index :attachments, [:record_type, :record_id]
|
15
|
+
add_index :attachments, :blob_id
|
16
|
+
add_index :attachments, [:blob_id, :record_id, :record_type, :type, :filename],
|
17
|
+
unique: true,
|
18
|
+
name: 'index_attachments_on_blob_record_type_filename'
|
19
|
+
end
|
20
|
+
end
|