active_storage_db 1.5.0 → 1.6.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0167f8b1c0fa434977a9fc46cc743c11e67602355eb483cb67c5b47366fc6aa5
4
- data.tar.gz: fc760095ce36ad3a77a21bc803461589de2407c759bebb1439bfcb395fd0f51e
3
+ metadata.gz: ef4de196ae1110b81dfa6adfdd641e82bd009d3d1ed7fd0fbdd497d66cee089f
4
+ data.tar.gz: f02ae2f4d33d4f054814b0c1fa178af09ec2f6084beb02448af43cf16b45db40
5
5
  SHA512:
6
- metadata.gz: 2e990c95440f59b89c93bacc4aa994258b468db54c1a73ce6cc4bbf7ae92c497938cf9874bca6dbe7a8ec6b2280b32f466b1b8a7970bef62e2dd6fb88e53d49c
7
- data.tar.gz: f8ba1f9b8931a1c1fba1f9a3b59e9747850a201cde129f68af80644d2cefe4e8cea15ee3f6d96fe9eabe361e607872557fe3c24f6162c90d076aa4d3bc9ca5c3
6
+ metadata.gz: 331e0ccf899fba5d8ed88eacfd95612b074fb403696092b0744a81641dcea1d83f7c723dd430281e3a91224a01657f132ce5cdf9eb06fc04b4e9b6ac98ba6c5d
7
+ data.tar.gz: 4e657d03432899f4c18bac98201c639c0473150fedd0f83bbfe1ae674000e3765aca3111d823aaf46178a9e4195c44c9ca28297256b1e573bcbe3747cda16f72
data/README.md CHANGED
@@ -5,8 +5,8 @@
5
5
  [![maintainability](https://api.codeclimate.com/v1/badges/92e1e703c308744a0f66/maintainability)](https://codeclimate.com/github/blocknotes/active_storage_db/maintainability)
6
6
 
7
7
  [![linters](https://github.com/blocknotes/active_storage_db/actions/workflows/linters.yml/badge.svg)](https://github.com/blocknotes/active_storage_db/actions/workflows/linters.yml)
8
- [![Specs Postgres Rails 8.0](https://github.com/blocknotes/active_storage_db/actions/workflows/specs_postgres_rails80.yml/badge.svg)](https://github.com/blocknotes/active_storage_db/actions/workflows/specs_postgres_rails80.yml)
9
- [![Specs MySQL Rails 8.0](https://github.com/blocknotes/active_storage_db/actions/workflows/specs_mysql_rails80.yml/badge.svg)](https://github.com/blocknotes/active_storage_db/actions/workflows/specs_mysql_rails80.yml)
8
+ [![Specs Postgres Rails 8.1](https://github.com/blocknotes/active_storage_db/actions/workflows/specs_postgres_rails81.yml/badge.svg)](https://github.com/blocknotes/active_storage_db/actions/workflows/specs_postgres_rails81.yml)
9
+ [![Specs MySQL Rails 8.1](https://github.com/blocknotes/active_storage_db/actions/workflows/specs_mysql_rails81.yml/badge.svg)](https://github.com/blocknotes/active_storage_db/actions/workflows/specs_mysql_rails81.yml)
10
10
 
11
11
  An Active Storage service upload/download plugin that stores files in a PostgreSQL or MySQL database.
12
12
  Experimental support also for MSSQL and SQLite.
@@ -21,8 +21,8 @@ Useful also with platforms like Heroku (due to their ephemeral file system).
21
21
  - Add to your Gemfile `gem 'active_storage_db'` (and execute: `bundle`)
22
22
  - Install the gem migrations: `bin/rails active_storage_db:install:migrations` (and execute: `bin/rails db:migrate`)
23
23
  - Add to your `config/routes.rb`: `mount ActiveStorageDB::Engine => '/active_storage_db'`
24
- - Change Active Storage service in *config/environments/development.rb* to: `config.active_storage.service = :db`
25
- - Add to *config/storage.yml*:
24
+ - Change Active Storage service in _config/environments/development.rb_ to: `config.active_storage.service = :db`
25
+ - Add to _config/storage.yml_:
26
26
 
27
27
  ```yml
28
28
  db:
@@ -16,19 +16,20 @@ module ActiveStorageDB
16
16
 
17
17
  def update
18
18
  if (token = decode_verified_token)
19
- file_uploaded = upload_file(token, body: request.body)
20
- head(file_uploaded ? :no_content : unprocessable)
19
+ file_uploaded = upload_file(token)
20
+ head(file_uploaded ? :no_content : ActiveStorageDB::UNPROCESSABLE_STATUS)
21
21
  else
22
22
  head(:not_found)
23
23
  end
24
24
  rescue ActiveStorage::IntegrityError
25
- head(unprocessable)
25
+ head(ActiveStorageDB::UNPROCESSABLE_STATUS)
26
26
  end
27
27
 
28
28
  private
29
29
 
30
30
  def acceptable_content?(token)
31
- token[:content_type] == request.content_mime_type && token[:content_length] == request.content_length
31
+ token[:content_type] == request.media_type &&
32
+ token[:content_length] == request.content_length
32
33
  end
33
34
 
34
35
  def db_service
@@ -53,15 +54,11 @@ module ActiveStorageDB
53
54
  send_data(db_service.download(key), options)
54
55
  end
55
56
 
56
- def upload_file(token, body:)
57
+ def upload_file(token) # rubocop:disable Naming/PredicateMethod
57
58
  return false unless acceptable_content?(token)
58
59
 
59
60
  db_service.upload(token[:key], request.body, checksum: token[:checksum])
60
61
  true
61
62
  end
62
-
63
- def unprocessable
64
- Gem::Version.new(Rails.version) >= Gem::Version.new("7.1") ? :unprocessable_content : :unprocessable_entity
65
- end
66
63
  end
67
64
  end
@@ -4,7 +4,7 @@ module ActiveStorageDB
4
4
  class File < ApplicationRecord
5
5
  validates :ref,
6
6
  presence: true,
7
- allow_blank: false,
8
7
  uniqueness: { case_sensitive: false }
8
+ validates :data, presence: true
9
9
  end
10
10
  end
@@ -20,7 +20,6 @@ class CreateActiveStorageDBFiles < ActiveRecord::Migration[6.0]
20
20
 
21
21
  def primary_key_type
22
22
  config = Rails.configuration.generators
23
- primary_key_type = config.options[config.orm][:primary_key_type]
24
- primary_key_type || :primary_key
23
+ config.options.dig(config.orm, :primary_key_type) || :primary_key
25
24
  end
26
25
  end
@@ -18,16 +18,26 @@ module ActiveStorage
18
18
  end
19
19
  # :nocov:
20
20
 
21
+ MINIMUM_CHUNK_SIZE = 1
22
+
21
23
  def initialize(public: false, **)
22
- @chunk_size = ENV.fetch("ASDB_CHUNK_SIZE") { 1.megabytes }
24
+ @chunk_size = [ENV.fetch("ASDB_CHUNK_SIZE") { 1.megabytes }.to_i, MINIMUM_CHUNK_SIZE].max
25
+ @max_size = ENV.fetch("ASDB_MAX_FILE_SIZE", nil)&.to_i
23
26
  @public = public
24
27
  end
25
28
 
26
29
  def upload(key, io, checksum: nil, **)
27
30
  instrument :upload, key: key, checksum: checksum do
28
- file = ::ActiveStorageDB::File.create!(ref: key, data: io.read)
29
- ensure_integrity_of(key, checksum) if checksum
30
- file
31
+ data = io.read
32
+ if @max_size && data.bytesize > @max_size
33
+ raise ArgumentError, "File size exceeds the maximum allowed size of #{@max_size} bytes"
34
+ end
35
+
36
+ if checksum
37
+ digest = Digest::MD5.base64digest(data)
38
+ raise ActiveStorage::IntegrityError unless digest == checksum
39
+ end
40
+ ::ActiveStorageDB::File.create!(ref: key, data: data)
31
41
  end
32
42
  end
33
43
 
@@ -45,11 +55,13 @@ module ActiveStorage
45
55
 
46
56
  def download_chunk(key, range)
47
57
  instrument :download_chunk, key: key, range: range do
58
+ # NOTE: from/size are derived from Range#begin and Range#size (always integers),
59
+ # so string interpolation into SQL is safe here.
48
60
  from = range.begin + 1
49
61
  size = range.size
50
62
  args = adapter_sqlserver? || adapter_sqlite? ? "data, #{from}, #{size}" : "data FROM #{from} FOR #{size}"
51
63
  record = object_for(key, fields: "SUBSTRING(#{args}) AS chunk")
52
- raise(ActiveStorage::FileNotFoundError) unless record
64
+ raise ActiveStorage::FileNotFoundError unless record
53
65
 
54
66
  record.chunk
55
67
  end
@@ -68,7 +80,7 @@ module ActiveStorage
68
80
  def delete_prefixed(prefix)
69
81
  instrument :delete_prefixed, prefix: prefix do
70
82
  comment = "DBService#delete_prefixed"
71
- sanitized_prefix = "#{ApplicationRecord.sanitize_sql_like(prefix)}%"
83
+ sanitized_prefix = "#{ActiveRecord::Base.sanitize_sql_like(prefix)}%"
72
84
  ::ActiveStorageDB::File.annotate(comment).where("ref LIKE ?", sanitized_prefix).destroy_all
73
85
  end
74
86
  end
@@ -76,7 +88,7 @@ module ActiveStorage
76
88
  def exist?(key)
77
89
  instrument :exist, key: key do |payload|
78
90
  comment = "DBService#exist?"
79
- result = ::ActiveStorageDB::File.annotate(comment).where(ref: key).exists?
91
+ result = ::ActiveStorageDB::File.annotate(comment).exists?(ref: key)
80
92
  payload[:exist] = result
81
93
  result
82
94
  end
@@ -90,7 +102,7 @@ module ActiveStorage
90
102
  content_type: content_type,
91
103
  content_length: content_length,
92
104
  checksum: checksum,
93
- service_name: respond_to?(:name) ? name : "db"
105
+ service_name: service_name_for_token
94
106
  },
95
107
  expires_in: expires_in,
96
108
  purpose: :blob_token
@@ -108,12 +120,20 @@ module ActiveStorage
108
120
 
109
121
  private
110
122
 
123
+ def service_name_for_token
124
+ name.presence || "db"
125
+ end
126
+
111
127
  def adapter_sqlite?
112
- @adapter_sqlite ||= active_storage_db_adapter_name == "SQLite"
128
+ return @adapter_sqlite if defined?(@adapter_sqlite)
129
+
130
+ @adapter_sqlite = active_storage_db_adapter_name == "SQLite"
113
131
  end
114
132
 
115
133
  def adapter_sqlserver?
116
- @adapter_sqlserver ||= active_storage_db_adapter_name == "SQLServer"
134
+ return @adapter_sqlserver if defined?(@adapter_sqlserver)
135
+
136
+ @adapter_sqlserver = active_storage_db_adapter_name == "SQLServer"
117
137
  end
118
138
 
119
139
  def active_storage_db_adapter_name
@@ -131,7 +151,7 @@ module ActiveStorage
131
151
  key: key,
132
152
  disposition: content_disposition,
133
153
  content_type: content_type,
134
- service_name: respond_to?(:name) ? name : "db"
154
+ service_name: service_name_for_token
135
155
  },
136
156
  expires_in: expires_in,
137
157
  purpose: :blob_key
@@ -149,35 +169,38 @@ module ActiveStorage
149
169
  )
150
170
  end
151
171
 
152
- def ensure_integrity_of(key, checksum)
153
- return if Digest::MD5.base64digest(object_for(key).data) == checksum
154
-
155
- delete(key)
156
- raise ActiveStorage::IntegrityError
157
- end
158
-
159
172
  def retrieve_file(key)
160
173
  file = object_for(key)
161
- raise(ActiveStorage::FileNotFoundError) unless file
174
+ raise ActiveStorage::FileNotFoundError unless file
162
175
 
163
176
  file.data
164
177
  end
165
178
 
166
179
  def object_for(key, fields: nil)
167
180
  comment = "DBService#object_for"
168
- as_file = fields ? ::ActiveStorageDB::File.annotate(comment).select(*fields) : ::ActiveStorageDB::File
169
- as_file.find_by(ref: key)
181
+ scope = ::ActiveStorageDB::File.annotate(comment)
182
+ scope = scope.select(fields) if fields
183
+ scope.find_by(ref: key)
170
184
  end
171
185
 
172
186
  def stream(key)
173
- data_size = adapter_sqlserver? ? "DATALENGTH(data)" : "OCTET_LENGTH(data)"
174
- size = object_for(key, fields: "#{data_size} AS size")&.size || raise(ActiveStorage::FileNotFoundError)
187
+ size = object_for(key, fields: data_size)&.size || raise(ActiveStorage::FileNotFoundError)
175
188
  (size / @chunk_size.to_f).ceil.times.each do |i|
176
189
  range = (i * @chunk_size)..(((i + 1) * @chunk_size) - 1)
177
190
  yield download_chunk(key, range)
178
191
  end
179
192
  end
180
193
 
194
+ def data_size
195
+ if adapter_sqlserver?
196
+ "DATALENGTH(data) AS size"
197
+ elsif adapter_sqlite?
198
+ "LENGTH(data) AS size"
199
+ else
200
+ "OCTET_LENGTH(data) AS size"
201
+ end
202
+ end
203
+
181
204
  def url_helpers
182
205
  @url_helpers ||= ::ActiveStorageDB::Engine.routes.url_helpers
183
206
  end
@@ -6,11 +6,13 @@ module ActiveStorage
6
6
  buffer = nil
7
7
  comment = "DBService#compose"
8
8
  source_keys.each do |source_key|
9
- data = ::ActiveStorageDB::File.annotate(comment).find_by!(ref: source_key).data
9
+ record = ::ActiveStorageDB::File.annotate(comment).select(:data).find_by(ref: source_key)
10
+ raise ActiveStorage::FileNotFoundError unless record
11
+
10
12
  if buffer
11
- buffer << data
13
+ buffer << record.data
12
14
  else
13
- buffer = +data
15
+ buffer = +record.data
14
16
  end
15
17
  end
16
18
  ::ActiveStorageDB::File.create!(ref: destination_key, data: buffer) if buffer
@@ -1,6 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveStorageDB
4
+ # :nocov:
5
+ UNPROCESSABLE_STATUS = if Rails::VERSION::MAJOR > 7 || (Rails::VERSION::MAJOR == 7 && Rails::VERSION::MINOR >= 1)
6
+ :unprocessable_content
7
+ else
8
+ :unprocessable_entity
9
+ end
10
+ # :nocov:
11
+
4
12
  class Engine < ::Rails::Engine
5
13
  isolate_namespace ActiveStorageDB
6
14
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveStorageDB
4
- VERSION = '1.5.0'
4
+ VERSION = '1.6.0'
5
5
  end
@@ -5,22 +5,38 @@ module ActiveStorage
5
5
  module_function
6
6
 
7
7
  def print_blob_header(digits: 0)
8
- puts ['Size'.rjust(8), 'Date'.rjust(18), 'Id'.rjust(digits + 2), ' Filename'].join
8
+ puts ["Size".rjust(8), "Date".rjust(18), "Id".rjust(digits + 2), " Filename"].join
9
9
  end
10
10
 
11
11
  def print_blob(blob, digits: 0)
12
- size = (blob.byte_size / 1024).to_s.rjust(7)
13
- date = blob.created_at.strftime('%Y-%m-%d %H:%M')
14
- puts "#{size}K #{date} #{blob.id.to_s.rjust(digits)} #{blob.filename}"
12
+ size = format_size(blob.byte_size)
13
+ date = blob.created_at.strftime("%Y-%m-%d %H:%M")
14
+ puts "#{size} #{date} #{blob.id.to_s.rjust(digits)} #{blob.filename}"
15
+ end
16
+
17
+ def format_size(bytes)
18
+ if bytes >= 1.gigabyte
19
+ "#{(bytes / 1.gigabyte.to_f).round(1)}G".rjust(8)
20
+ elsif bytes >= 1.megabyte
21
+ "#{(bytes / 1.megabyte.to_f).round(1)}M".rjust(8)
22
+ elsif bytes >= 1.kilobyte
23
+ "#{bytes / 1024}K".rjust(8)
24
+ else
25
+ "#{bytes}B".rjust(8)
26
+ end
15
27
  end
16
28
  end
17
29
  end
18
30
 
19
31
  namespace :asdb do
20
- desc 'ActiveStorageDB: list attachments ordered by blob id desc'
21
- task list: [:environment] do |_t, _args|
22
- query = ActiveStorage::Blob.order(id: :desc).limit(100)
23
- digits = query.ids.inject(0) { |ret, id| size = id.to_s.size; [size, ret].max }
32
+ desc "ActiveStorageDB: list attachments ordered by blob id desc"
33
+ task :list, [:count] => [:environment] do |_t, args|
34
+ count = (args[:count] || 100).to_i
35
+ query = ActiveStorage::Blob.order(id: :desc).limit(count)
36
+ digits = query.ids.inject(0) { |ret, id|
37
+ size = id.to_s.size
38
+ [size, ret].max
39
+ }
24
40
 
25
41
  ActiveStorage::Tasks.print_blob_header(digits: digits)
26
42
  query.each do |blob|
@@ -28,14 +44,14 @@ namespace :asdb do
28
44
  end
29
45
  end
30
46
 
31
- desc 'ActiveStorageDB: download attachment by blob id'
47
+ desc "ActiveStorageDB: download attachment by blob id"
32
48
  task :download, [:blob_id, :destination] => [:environment] do |_t, args|
33
49
  blob_id = args[:blob_id]&.strip
34
50
  destination = args[:destination]&.strip || Dir.pwd
35
- abort('Required arguments: source blob id, destination path') if blob_id.blank? || destination.blank?
51
+ abort("Required arguments: source blob id, destination path") if blob_id.blank? || destination.blank?
36
52
 
37
53
  blob = ActiveStorage::Blob.find_by(id: blob_id)
38
- abort('Source file not found') unless blob
54
+ abort("Source file not found") unless blob
39
55
 
40
56
  destination = "#{destination}/#{blob.filename}" if Dir.exist?(destination)
41
57
  dir = File.dirname(destination)
@@ -45,20 +61,23 @@ namespace :asdb do
45
61
  puts "#{ret} bytes written - #{destination}"
46
62
  end
47
63
 
48
- desc 'ActiveStorageDB: search attachment by filename (or part of it)'
64
+ desc "ActiveStorageDB: search attachment by filename (or part of it)"
49
65
  task :search, [:filename] => [:environment] do |_t, args|
50
66
  filename = args[:filename]&.strip
51
- abort('Required arguments: filename') if filename.blank?
67
+ abort("Required arguments: filename") if filename.blank?
52
68
 
53
- blobs = ActiveStorage::Blob.where('filename LIKE ?', "%#{filename}%").order(id: :desc)
69
+ blobs = ActiveStorage::Blob.where("filename LIKE ?", "%#{ActiveRecord::Base.sanitize_sql_like(filename)}%").order(id: :desc)
54
70
  if blobs.any?
55
- digits = blobs.ids.inject(0) { |ret, id| size = id.to_s.size; [size, ret].max }
71
+ digits = blobs.ids.inject(0) { |ret, id|
72
+ size = id.to_s.size
73
+ [size, ret].max
74
+ }
56
75
  ActiveStorage::Tasks.print_blob_header(digits: digits)
57
76
  blobs.each do |blob|
58
77
  ActiveStorage::Tasks.print_blob(blob, digits: digits)
59
78
  end
60
79
  else
61
- puts 'No results'
80
+ puts "No results"
62
81
  end
63
82
  end
64
83
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: active_storage_db
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.5.0
4
+ version: 1.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mattia Roccoberton