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.
@@ -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
@@ -0,0 +1,3 @@
1
+ module ActiveBlob
2
+ VERSION = "0.1.0"
3
+ 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