laserblob 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/laserblob/attachment.rb +41 -0
- data/app/models/laserblob/blob/image.rb +38 -0
- data/app/models/laserblob/blob/pdf.rb +31 -0
- data/app/models/laserblob/blob/video.rb +44 -0
- data/app/models/laserblob/blob.rb +122 -0
- data/lib/generators/activeblob/install/templates/create_attachments.rb +20 -0
- data/lib/generators/laserblob/install/install_generator.rb +30 -0
- data/lib/generators/laserblob/install/templates/README +41 -0
- data/lib/generators/laserblob/install/templates/create_blobs.rb +16 -0
- data/lib/generators/laserblob/install/templates/initializer.rb +19 -0
- data/lib/laserblob/blob_helpers.rb +166 -0
- data/lib/laserblob/engine.rb +20 -0
- data/lib/laserblob/model_extensions.rb +114 -0
- data/lib/laserblob/storage/filesystem.rb +60 -0
- data/lib/laserblob/storage/s3.rb +68 -0
- data/lib/laserblob/version.rb +3 -0
- data/lib/laserblob.rb +46 -0
- metadata +137 -0
@@ -0,0 +1,122 @@
|
|
1
|
+
module LaserBlob
|
2
|
+
class Blob < ActiveRecord::Base
|
3
|
+
self.table_name = 'blobs'
|
4
|
+
|
5
|
+
include LaserBlob::BlobHelpers
|
6
|
+
|
7
|
+
attr_reader :data
|
8
|
+
|
9
|
+
has_many :attachments, class_name: 'LaserBlob::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,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
|
@@ -0,0 +1,30 @@
|
|
1
|
+
require 'rails/generators'
|
2
|
+
require 'rails/generators/migration'
|
3
|
+
|
4
|
+
module Laserblob
|
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/laserblob.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
|
+
LaserBlob 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/laserblob.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 = LaserBlob::Blob.new(url: "https://example.com/image.jpg")
|
31
|
+
|
32
|
+
# Create with base64
|
33
|
+
blob = LaserBlob::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/laserkats/laserblob
|
40
|
+
|
41
|
+
===============================================================================
|
@@ -0,0 +1,16 @@
|
|
1
|
+
class CreateBlobs < ActiveRecord::Migration[6.1]
|
2
|
+
def change
|
3
|
+
create_table :blobs, id: :uuid do |t|
|
4
|
+
t.string :type, default: 'LaserBlob::Blob'
|
5
|
+
t.bigint :size, null: false
|
6
|
+
t.string :content_type, null: false
|
7
|
+
t.jsonb :metadata, default: {}
|
8
|
+
t.binary :sha1, limit: 20, null: false
|
9
|
+
|
10
|
+
t.timestamps
|
11
|
+
end
|
12
|
+
|
13
|
+
add_index :blobs, :sha1
|
14
|
+
add_index :blobs, [:type, :sha1]
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
# LaserBlob Configuration
|
2
|
+
LaserBlob.configure do |config|
|
3
|
+
# Storage configuration
|
4
|
+
# For filesystem storage (default):
|
5
|
+
config.storage_config = {
|
6
|
+
storage: 'filesystem',
|
7
|
+
path: Rails.root.join('storage', 'blobs')
|
8
|
+
}
|
9
|
+
|
10
|
+
# For S3 storage:
|
11
|
+
# config.storage_config = {
|
12
|
+
# storage: 's3',
|
13
|
+
# bucket: ENV['S3_BUCKET'],
|
14
|
+
# access_key_id: ENV['S3_ACCESS_KEY_ID'],
|
15
|
+
# secret_access_key: ENV['S3_SECRET_ACCESS_KEY'],
|
16
|
+
# region: ENV['S3_REGION'] || 'us-east-1',
|
17
|
+
# endpoint: ENV['S3_ENDPOINT'] # Optional, for S3-compatible services like DigitalOcean Spaces
|
18
|
+
# }
|
19
|
+
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 LaserBlob
|
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
|
+
LaserBlob.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 LaserBlob
|
4
|
+
class Engine < ::Rails::Engine
|
5
|
+
isolate_namespace LaserBlob
|
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 "laserblob.active_record", before: :load_config_initializers do
|
14
|
+
ActiveSupport.on_load(:active_record) do
|
15
|
+
require "laserblob/model_extensions"
|
16
|
+
ActiveRecord::Base.include(LaserBlob::ModelExtensions)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,114 @@
|
|
1
|
+
module LaserBlob
|
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: "::LaserBlob::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 = LaserBlob::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?(LaserBlob::Attachment)
|
33
|
+
file = LaserBlob::Attachment.new({
|
34
|
+
blob: file,
|
35
|
+
filename: LaserBlob::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: '::LaserBlob::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
|
+
LaserBlob::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?(LaserBlob::Attachment)
|
88
|
+
file.type = '#{singular}'
|
89
|
+
file.order = i
|
90
|
+
file
|
91
|
+
elsif file.is_a?(LaserBlob::Blob)
|
92
|
+
LaserBlob::Attachment.new({
|
93
|
+
order: i,
|
94
|
+
type: '#{singular}',
|
95
|
+
blob: file,
|
96
|
+
filename: LaserBlob::BlobHelpers.filename_from_file(file)
|
97
|
+
})
|
98
|
+
else
|
99
|
+
LaserBlob::Attachment.new({
|
100
|
+
order: i,
|
101
|
+
type: '#{singular}',
|
102
|
+
blob: LaserBlob::Blob.new(file: file),
|
103
|
+
filename: LaserBlob::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 LaserBlob
|
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 LaserBlob
|
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
|