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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 89d322a2edaa8e9fe38cd46de0bbb3c95413780079b428c6024a4e1d85362708
4
+ data.tar.gz: 7b0d9320c5eb5311b01a60d0a1db582c01b23b635da5fbefefbb49b5fe60c78e
5
+ SHA512:
6
+ metadata.gz: 6aa7e5b2bed692d80735397eba86aef9466f436964d74b9d63b661a017b19610f3fe04ce6ca110c4778693886ea4bab31d5c6f3abe7978d58e5fd2aba9707003
7
+ data.tar.gz: c09f2e166040761d02fca0846bae64959dacf5257545a6b15fcf66f40b319c74c3fc31f6d17cf07fa8dfa09ecc79e0e2e7ad835b9f8b846deed6edd6556aeb7e
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2025 Ben Ehmke
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,412 @@
1
+ # ActiveBlob
2
+
3
+ ActiveBlob is a content-addressable blob storage system for Rails applications. It provides SHA1-based deduplication, polymorphic attachments, and support for multiple storage backends (filesystem and S3).
4
+
5
+ ## Features
6
+
7
+ - **Content-addressable storage**: Blobs are identified by SHA1 hash, preventing duplicate storage
8
+ - **Polymorphic attachments**: Attach blobs to any model with `has_one_blob` or `has_many_blobs`
9
+ - **Multiple storage backends**: Filesystem or S3-compatible storage
10
+ - **Type-specific models**: Built-in support for images, videos, and PDFs with automatic metadata extraction
11
+ - **Flexible input**: Create blobs from files, URLs, base64 data, or raw data
12
+ - **URL generation**: Generate signed URLs for cloud storage or local paths
13
+
14
+ ## Installation
15
+
16
+ Add this line to your application's Gemfile:
17
+
18
+ ```ruby
19
+ gem 'activeblob'
20
+ ```
21
+
22
+ And then execute:
23
+
24
+ ```bash
25
+ bundle install
26
+ ```
27
+
28
+ Run the installer to generate migrations and configuration:
29
+
30
+ ```bash
31
+ rails generate activeblob:install
32
+ rails db:migrate
33
+ ```
34
+
35
+ ## Configuration
36
+
37
+ Configure your storage backend in `config/initializers/activeblob.rb`:
38
+
39
+ ### Filesystem Storage (Default)
40
+
41
+ ```ruby
42
+ ActiveBlob.configure do |config|
43
+ config.storage_config = {
44
+ storage: 'filesystem',
45
+ path: Rails.root.join('storage', 'blobs')
46
+ }
47
+ end
48
+ ```
49
+
50
+ ### S3 Storage
51
+
52
+ ```ruby
53
+ ActiveBlob.configure do |config|
54
+ config.storage_config = {
55
+ storage: 's3',
56
+ bucket: ENV['S3_BUCKET'],
57
+ access_key_id: ENV['S3_ACCESS_KEY_ID'],
58
+ secret_access_key: ENV['S3_SECRET_ACCESS_KEY'],
59
+ region: ENV['S3_REGION'] || 'us-east-1',
60
+ endpoint: ENV['S3_ENDPOINT'] # Optional, for S3-compatible services
61
+ }
62
+ end
63
+ ```
64
+
65
+ ## Usage
66
+
67
+ ### Adding Attachments to Models
68
+
69
+ #### Single Attachment
70
+
71
+ ```ruby
72
+ class User < ApplicationRecord
73
+ has_one_blob :avatar
74
+ end
75
+
76
+ # Usage
77
+ user = User.new
78
+ user.avatar = params[:avatar] # File upload
79
+ user.save
80
+ ```
81
+
82
+ #### Multiple Attachments
83
+
84
+ ```ruby
85
+ class Post < ApplicationRecord
86
+ has_many_blobs :images
87
+ end
88
+
89
+ # Usage
90
+ post = Post.new
91
+ post.images = [file1, file2, file3]
92
+ post.save
93
+ ```
94
+
95
+ ### Creating Blobs
96
+
97
+ #### From File Upload
98
+
99
+ ```ruby
100
+ blob = ActiveBlob::Blob.new(file: params[:file])
101
+ blob.save
102
+ ```
103
+
104
+ #### From URL
105
+
106
+ ```ruby
107
+ blob = ActiveBlob::Blob.new(url: "https://example.com/image.jpg")
108
+ blob.save
109
+ ```
110
+
111
+ #### From Base64
112
+
113
+ ```ruby
114
+ blob = ActiveBlob::Blob.new(
115
+ base64: base64_string,
116
+ content_type: "image/png",
117
+ filename: "image.png"
118
+ )
119
+ blob.save
120
+ ```
121
+
122
+ #### From Raw Data
123
+
124
+ ```ruby
125
+ blob = ActiveBlob::Blob.new(data: binary_data)
126
+ blob.content_type = "image/jpeg"
127
+ blob.save
128
+ ```
129
+
130
+ ### Accessing Blobs
131
+
132
+ ```ruby
133
+ # Get blob URL
134
+ user.avatar.url
135
+
136
+ # Get blob URL with options
137
+ blob.url(disposition: 'inline', filename: 'custom.jpg', expires_in: 3600)
138
+
139
+ # Get blob metadata
140
+ blob.size # File size in bytes
141
+ blob.content_type # MIME type
142
+ blob.sha1 # Binary SHA1 hash
143
+ blob.extension # File extension based on content type
144
+
145
+ # Open blob for processing
146
+ blob.open do |file|
147
+ # Process the file
148
+ puts file.read
149
+ end
150
+ ```
151
+
152
+ ### Blob Types
153
+
154
+ ActiveBlob automatically selects the appropriate blob type based on content type:
155
+
156
+ #### Images (ActiveBlob::Blob::Image)
157
+
158
+ Requires `ruby-vips` gem:
159
+
160
+ ```ruby
161
+ gem 'ruby-vips'
162
+ ```
163
+
164
+ Automatically extracts:
165
+ - Width and height
166
+ - Dominant color
167
+ - Background color
168
+ - Aspect ratio
169
+
170
+ ```ruby
171
+ image = ActiveBlob::Blob.new(file: image_file)
172
+ image.save
173
+
174
+ image.width # => 1920
175
+ image.height # => 1080
176
+ image.aspect_ratio # => 1.777...
177
+ image.dominant_color # => "255,128,0"
178
+ ```
179
+
180
+ #### Videos (ActiveBlob::Blob::Video)
181
+
182
+ Requires `streamio-ffmpeg` gem:
183
+
184
+ ```ruby
185
+ gem 'streamio-ffmpeg'
186
+ ```
187
+
188
+ Automatically extracts:
189
+ - Duration
190
+ - Bitrate
191
+ - Codec
192
+ - Width and height
193
+ - Frame rate
194
+
195
+ ```ruby
196
+ video = ActiveBlob::Blob.new(file: video_file)
197
+ video.save
198
+
199
+ video.duration # => 120.5 (seconds)
200
+ video.width # => 1920
201
+ video.height # => 1080
202
+ video.codec # => "h264"
203
+ video.frame_rate # => 30.0
204
+ ```
205
+
206
+ #### PDFs (ActiveBlob::Blob::PDF)
207
+
208
+ Requires `pdf-reader` gem:
209
+
210
+ ```ruby
211
+ gem 'pdf-reader'
212
+ ```
213
+
214
+ Automatically extracts:
215
+ - Page count
216
+ - Width and height per page
217
+ - Aspect ratio
218
+
219
+ ```ruby
220
+ pdf = ActiveBlob::Blob.new(file: pdf_file)
221
+ pdf.save
222
+
223
+ pdf.page_count # => 10
224
+ pdf.aspect_ratio # => 0.707 (based on first page)
225
+ ```
226
+
227
+ ### Deduplication
228
+
229
+ ActiveBlob automatically deduplicates blobs based on SHA1 hash:
230
+
231
+ ```ruby
232
+ # Upload the same file twice
233
+ blob1 = ActiveBlob::Blob.new(file: file)
234
+ blob1.save
235
+
236
+ blob2 = ActiveBlob::Blob.new(file: file)
237
+ blob2.save
238
+
239
+ # blob1 and blob2 reference the same record
240
+ blob1.id == blob2.id # => true
241
+ ```
242
+
243
+ ### Attachments
244
+
245
+ Attachments link blobs to your records:
246
+
247
+ ```ruby
248
+ # Create attachment explicitly
249
+ attachment = ActiveBlob::Attachment.create(
250
+ record: user,
251
+ blob: blob,
252
+ filename: "avatar.jpg",
253
+ type: "avatar"
254
+ )
255
+
256
+ # Access attachment properties
257
+ attachment.filename # => "avatar.jpg"
258
+ attachment.content_type # => "image/jpeg"
259
+ attachment.size # => 102400
260
+ attachment.url # Delegates to blob.url
261
+ ```
262
+
263
+ ### Nested Attributes
264
+
265
+ You can use nested attributes with attachments:
266
+
267
+ ```ruby
268
+ class Post < ApplicationRecord
269
+ has_many_blobs :images
270
+ accepts_nested_attributes_for :images
271
+ end
272
+
273
+ # In your controller
274
+ post.update(
275
+ images_attributes: [
276
+ { blob_id: blob.id, filename: "image1.jpg" },
277
+ { blob_id: blob2.id, filename: "image2.jpg" }
278
+ ]
279
+ )
280
+ ```
281
+
282
+ ## Architecture
283
+
284
+ ### Models
285
+
286
+ - **ActiveBlob::Blob**: Base model for all blobs
287
+ - `ActiveBlob::Blob::Image`: Image blobs with metadata extraction
288
+ - `ActiveBlob::Blob::Video`: Video blobs with metadata extraction
289
+ - `ActiveBlob::Blob::PDF`: PDF blobs with metadata extraction
290
+
291
+ - **ActiveBlob::Attachment**: Polymorphic join model linking blobs to records
292
+
293
+ ### Storage Backends
294
+
295
+ - **ActiveBlob::Storage::Filesystem**: Store blobs on local filesystem
296
+ - **ActiveBlob::Storage::S3**: Store blobs on S3 or S3-compatible services
297
+
298
+ ### Database Schema
299
+
300
+ #### Blobs Table
301
+
302
+ ```ruby
303
+ create_table :blobs, id: :uuid do |t|
304
+ t.string :type # STI type
305
+ t.bigint :size, null: false # File size in bytes
306
+ t.string :content_type, null: false # MIME type
307
+ t.jsonb :metadata, default: {} # Type-specific metadata
308
+ t.binary :sha1, limit: 20, null: false # SHA1 hash for deduplication
309
+ t.timestamps
310
+ end
311
+
312
+ add_index :blobs, :sha1
313
+ add_index :blobs, [:type, :sha1]
314
+ ```
315
+
316
+ #### Attachments Table
317
+
318
+ ```ruby
319
+ create_table :attachments, id: :uuid do |t|
320
+ t.string :type # Attachment type (e.g., "avatar", "photo")
321
+ t.integer :order, default: 0 # Order for has_many_blobs
322
+ t.string :filename # Original filename
323
+ t.string :record_type # Polymorphic record type
324
+ t.uuid :record_id # Polymorphic record ID
325
+ t.uuid :blob_id, null: false # Reference to blob
326
+ t.timestamps
327
+ end
328
+
329
+ add_index :attachments, [:record_type, :record_id]
330
+ add_index :attachments, :blob_id
331
+ add_index :attachments, [:blob_id, :record_id, :record_type, :type, :filename],
332
+ unique: true
333
+ ```
334
+
335
+ ## Advanced Usage
336
+
337
+ ### Custom Blob Types
338
+
339
+ Create custom blob types by subclassing `ActiveBlob::Blob`:
340
+
341
+ ```ruby
342
+ class ActiveBlob::Blob::Audio < ActiveBlob::Blob
343
+ validates :content_type, format: /\Aaudio\/.*\Z/
344
+
345
+ def duration
346
+ metadata['duration']
347
+ end
348
+
349
+ def self.process(record, path)
350
+ # Extract audio metadata
351
+ # record.metadata = { 'duration' => ..., 'bitrate' => ... }
352
+ end
353
+ end
354
+ ```
355
+
356
+ ### Storage Adapter Interface
357
+
358
+ Create custom storage adapters by implementing:
359
+
360
+ ```ruby
361
+ class MyStorage
362
+ def local?
363
+ # Return true if storage is local, false otherwise
364
+ end
365
+
366
+ def write(id, file, options = {})
367
+ # Write file to storage
368
+ end
369
+
370
+ def read(id)
371
+ # Read file from storage
372
+ end
373
+
374
+ def delete(id)
375
+ # Delete file from storage
376
+ end
377
+
378
+ def exists?(id)
379
+ # Check if file exists
380
+ end
381
+
382
+ def url(id, **options)
383
+ # Generate URL for file
384
+ end
385
+
386
+ def copy_to_tempfile(id, basename: nil, &block)
387
+ # Copy file to temporary location and yield to block
388
+ end
389
+ end
390
+ ```
391
+
392
+ ## Development
393
+
394
+ After checking out the repo, run:
395
+
396
+ ```bash
397
+ bundle install
398
+ ```
399
+
400
+ To run tests:
401
+
402
+ ```bash
403
+ rails test
404
+ ```
405
+
406
+ ## Contributing
407
+
408
+ Bug reports and pull requests are welcome on GitHub at https://github.com/bemky/activeblob.
409
+
410
+ ## License
411
+
412
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,3 @@
1
+ require "bundler/gem_tasks"
2
+
3
+ task default: :test
@@ -0,0 +1,41 @@
1
+ module ActiveBlob
2
+ class Attachment < ActiveRecord::Base
3
+ self.table_name = 'attachments'
4
+ self.inheritance_column = nil
5
+
6
+ belongs_to :blob, class_name: 'ActiveBlob::Blob'
7
+ belongs_to :record, polymorphic: true
8
+
9
+ before_validation { self.order ||= 0 }
10
+ before_save :default_filename
11
+
12
+ validates_uniqueness_of :blob_id, scope: [:record_id, :record_type, :type, :filename], message: " already attached", if: :record_id
13
+
14
+ delegate :size, :content_type, :sha1, :extension, to: :blob
15
+
16
+ def url(**options)
17
+ options[:filename] ||= filename
18
+ blob.url(**options)
19
+ end
20
+
21
+ def file=(file)
22
+ self.filename ||= ActiveBlob::BlobHelpers.filename_from_file(file)
23
+ self.blob = ActiveBlob::Blob.new(file: file)
24
+ end
25
+
26
+ def url=(url)
27
+ file = ActiveBlob::Blob.download_url(url)
28
+ self.filename = file.original_filename
29
+ self.blob = ActiveBlob::Blob.create!(file: file)
30
+ end
31
+
32
+ def default_filename
33
+ self.filename ||= [self.type, SecureRandom.hex(10)].join("-")
34
+ end
35
+
36
+ def open(basename: nil, &block)
37
+ basename ||= [File.basename(filename), File.extname(filename)]
38
+ blob.open(basename: basename, &block)
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,38 @@
1
+ module ActiveBlob
2
+ class Blob::Image < Blob
3
+ validates :content_type, presence: true, format: /\Aimage\/\w+\Z/
4
+
5
+ def width
6
+ metadata['width']
7
+ end
8
+
9
+ def height
10
+ metadata['height']
11
+ end
12
+
13
+ def dominant_color
14
+ metadata['dominant_color']
15
+ end
16
+
17
+ def background_color
18
+ metadata['background_color']
19
+ end
20
+
21
+ def aspect_ratio
22
+ return nil unless self.width && self.height
23
+ self.width.to_f / self.height.to_f
24
+ end
25
+
26
+ def self.process(record, path)
27
+ require 'vips' unless defined?(Vips)
28
+
29
+ vips = Vips::Image.new_from_file(path)
30
+ record.metadata = {
31
+ 'width' => vips.width,
32
+ 'height' => vips.height,
33
+ 'dominant_color' => vips.thumbnail_image(1).getpoint(0, 0).join(","),
34
+ 'background_color' => vips.getpoint(0,0).join(",")
35
+ }
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,31 @@
1
+ module ActiveBlob
2
+ class Blob::PDF < Blob
3
+ validates :content_type, presence: true, format: /\Aapplication\/pdf\Z/
4
+
5
+ def page_count
6
+ metadata['pages']&.size || 0
7
+ end
8
+
9
+ def aspect_ratio
10
+ first_page = metadata['pages']&.first
11
+ return nil unless first_page
12
+
13
+ width = first_page['width'].to_f
14
+ height = first_page['height'].to_f
15
+
16
+ return nil if height.zero?
17
+
18
+ width / height
19
+ end
20
+
21
+ def self.process(record, path)
22
+ require 'pdf-reader' unless defined?(PDF::Reader)
23
+
24
+ record.metadata = {
25
+ 'pages' => PDF::Reader.new(path).pages.map { |page|
26
+ { 'width' => page.width, 'height' => page.height }
27
+ }
28
+ }
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,44 @@
1
+ module ActiveBlob
2
+ class Blob::Video < Blob
3
+ validates :content_type, presence: true, format: /\Avideo\/.*\Z/
4
+
5
+ def width
6
+ metadata['width']
7
+ end
8
+
9
+ def height
10
+ metadata['height']
11
+ end
12
+
13
+ def duration
14
+ metadata['duration']
15
+ end
16
+
17
+ def bitrate
18
+ metadata['bitrate']
19
+ end
20
+
21
+ def codec
22
+ metadata['codec']
23
+ end
24
+
25
+ def frame_rate
26
+ metadata['frame_rate']
27
+ end
28
+
29
+ def self.process(record, path)
30
+ require 'streamio-ffmpeg' unless defined?(FFMPEG)
31
+
32
+ video = FFMPEG::Movie.new(path)
33
+
34
+ record.metadata = {
35
+ 'duration' => video.duration,
36
+ 'bitrate' => video.bitrate,
37
+ 'codec' => video.video_codec,
38
+ 'width' => video.width,
39
+ 'height' => video.height,
40
+ 'frame_rate' => video.frame_rate
41
+ }
42
+ end
43
+ end
44
+ end