activestorage_database 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: f213a1d92a9dc03906153e31d90168d00182923bc09656def364ed09b9458af5
4
+ data.tar.gz: 2eea5d3eac778060def89c0f4ddac88317a617027f430db6e78186dcc8fd9589
5
+ SHA512:
6
+ metadata.gz: aa951a310ce883147b9c2ff7e237b8d04d9208034d9e44917e19a38e9d3b4a8d07ad9cf03a42a99b0ce8ec46f5fde18cf532b44c13ca1b702f9ccec050038730
7
+ data.tar.gz: b072946792d6e3860524714dd73e6d3e3784fbee0d46e7247785d88bf5a7eb7d1480ddaec6c49e55e88fe7c2a65b9556df2339aeb7ef7136763e277ba8b8dbf9
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright 2021 Dino Maric
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,64 @@
1
+ # Active Storage Database
2
+
3
+ This is `ActiveStorage` engine that allows storing files inside the database.
4
+
5
+ While storing files on the external services such as `S3, GCS, Azure` is usually better approach, but better approach also
6
+ depends on the context.
7
+
8
+ There is a group of applications that do store the files, but not a lot of files or they are installed on-premises without
9
+ access to these public services.
10
+
11
+ This project is aimed more for those type of apps, if you store TBs of files then you should probabbly look into other solutions.
12
+
13
+
14
+ # How it works
15
+
16
+ Every file is stored inside the `activestorage_database_files` table as a `binary/bytea`.
17
+ Since PostgreSQL can't store values that span multiple pages(8kb), binaries will probabbly be stored inside the TOAST table,
18
+ which allows for quick table scan and leaves the main table(`activestorage_database_files`) slim.
19
+
20
+ **NOTE**: Max size of TOST tuple in PG is 1GB
21
+
22
+
23
+ Engine will use your `ActiveRecord` database connection.
24
+
25
+
26
+ ## Installation
27
+ Add this line to your application's Gemfile:
28
+
29
+ ```ruby
30
+ gem 'activestorage_database'
31
+ ```
32
+
33
+ Install and run the migrations:
34
+ ```
35
+ bin/rails activestorage_database:install:migrations
36
+ bin/rails db:migrate
37
+ ```
38
+
39
+ Configure database service:
40
+ ```yml
41
+ # config/storage.yml
42
+
43
+ database:
44
+ service: Database
45
+ ```
46
+
47
+ ```ruby
48
+ config.active_storage.service = :database
49
+ ```
50
+
51
+ ```ruby
52
+ # config/routes.rb
53
+ Rails.application.routes.draw do
54
+ mount ActivestorageDB::Engine => '/activestorage_database'
55
+ ...
56
+ end
57
+ ```
58
+
59
+
60
+ After the setup you can work with ActiveStorge API as if it was any other `ActiveStorage` service.
61
+
62
+
63
+ ## License
64
+ 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,18 @@
1
+ require "bundler/setup"
2
+
3
+ APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
4
+ load "rails/tasks/engine.rake"
5
+
6
+ load "rails/tasks/statistics.rake"
7
+
8
+ require "bundler/gem_tasks"
9
+
10
+ require "rake/testtask"
11
+
12
+ Rake::TestTask.new(:test) do |t|
13
+ t.libs << 'test'
14
+ t.pattern = 'test/**/*_test.rb'
15
+ t.verbose = false
16
+ end
17
+
18
+ task default: :test
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ActivestorageDatabase::FilesController < ActiveStorage::BaseController
4
+ skip_forgery_protection
5
+
6
+ def show
7
+ if key = decode_verified_key
8
+ serve_file(key[:key], content_type: key[:content_type], disposition: key[:disposition])
9
+ else
10
+ head :not_found
11
+ end
12
+ rescue ActiveStorage::FileNotFoundError
13
+ head :not_found
14
+ end
15
+
16
+ def update
17
+ if (token = decode_verified_token)
18
+ if acceptable_content?(token)
19
+ database_service.upload(token[:key], request.body, checksum: token[:checksum])
20
+ else
21
+ head :unprocessable_entity
22
+ end
23
+ else
24
+ head :not_found
25
+ end
26
+ rescue ActiveStorage::IntegrityError
27
+ head :unprocessable_entity
28
+ end
29
+
30
+ private
31
+ def database_service
32
+ ActiveStorage::Blob.service
33
+ end
34
+
35
+ def decode_verified_key
36
+ ActiveStorage.verifier.verified(params[:encoded_key], purpose: :blob_key)
37
+ end
38
+
39
+ def decode_verified_token
40
+ ActiveStorage.verifier.verified(params[:encoded_token], purpose: :blob_token)
41
+ end
42
+
43
+ def serve_file(key, content_type:, disposition:)
44
+ options = {
45
+ type: content_type || DEFAULT_SEND_FILE_TYPE,
46
+ disposition: disposition || DEFAULT_SEND_FILE_DISPOSITION
47
+ }
48
+
49
+ send_data database_service.download(key), options
50
+ end
51
+
52
+ def acceptable_content?(token)
53
+ token[:content_type] == request.content_mime_type && token[:content_length] == request.content_length
54
+ end
55
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ActivestorageDatabase::File < ActiveRecord::Base
4
+ validates :key, presence: true, allow_blank: false, uniqueness: { case_sensitive: false }
5
+ end
data/config/routes.rb ADDED
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ ActivestorageDatabase::Engine.routes.draw do
4
+ get '/files/:encoded_key/*filename', to: 'files#show', as: :service
5
+ put '/files/:encoded_token', to: 'files#update', as: :update_service
6
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateActivestorageDatabaseFiles < ActiveRecord::Migration[6.1]
4
+ def change
5
+ create_table :activestorage_database_files do |t|
6
+ t.string :key, null: false, index: { unique: true }
7
+ t.binary :data, null: false
8
+ t.datetime :created_at, null: false
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveStorage
4
+ class Service::DatabaseService < Service
5
+ CHUNK_SIZE = 1.megabytes.freeze
6
+
7
+ def upload(key, io, checksum: nil, **options)
8
+ instrument :upload, key: key, checksum: checksum do
9
+ file = ActivestorageDatabase::File.create!(key: key, data: io.read)
10
+ ensure_integrity_of(key, checksum) if checksum
11
+
12
+ file
13
+ end
14
+ end
15
+
16
+ def download(key, &block)
17
+ if block_given?
18
+ instrument :streaming_download, key: key do
19
+ stream(key, &block)
20
+ end
21
+ else
22
+ instrument :download, key: key do
23
+ record = ActivestorageDatabase::File.find_by(key: key)
24
+ record&.data || raise(ActiveStorage::FileNotFoundError)
25
+ end
26
+ end
27
+ end
28
+
29
+ def download_chunk(key, range)
30
+ instrument :download_chunk, key: key, range: range do
31
+ chunk_select = "SUBSTRING(data FROM #{range.begin + 1} FOR #{range.size}) AS chunk"
32
+ ActivestorageDatabase::File.select(chunk_select).find_by(key: key)&.chunk || raise(ActiveStorage::FileNotFoundError)
33
+ end
34
+ end
35
+
36
+ def delete(key)
37
+ instrument :delete, key: key do
38
+ ActivestorageDatabase::File.find_by(key: key)&.destroy
39
+ end
40
+ end
41
+
42
+ def delete_prefixed(prefix)
43
+ instrument :delete_prefixed, prefix: prefix do
44
+ ActivestorageDatabase::File.where("key LIKE ?", "#{prefix}%").destroy_all
45
+ end
46
+ end
47
+
48
+ def exist?(key)
49
+ instrument :exist, key: key do |payload|
50
+ answer = ActivestorageDatabase::File.where(key: key).exists?
51
+ payload[:exist] = answer
52
+ answer
53
+ end
54
+ end
55
+
56
+ def url(key, expires_in:, filename:, disposition:, content_type:)
57
+ instrument :url, key: key do |payload|
58
+ content_disposition = content_disposition_with(type: disposition, filename: filename)
59
+ verified_key_with_expiration = ActiveStorage.verifier.generate(
60
+ {
61
+ key: key,
62
+ disposition: content_disposition,
63
+ content_type: content_type
64
+ },
65
+ expires_in: expires_in,
66
+ purpose: :blob_key
67
+ )
68
+ current_uri = URI.parse(current_host)
69
+ generated_url = url_helpers.service_url(
70
+ verified_key_with_expiration,
71
+ protocol: current_uri.scheme,
72
+ host: current_uri.host,
73
+ port: current_uri.port,
74
+ disposition: content_disposition,
75
+ content_type: content_type,
76
+ filename: filename
77
+ )
78
+ payload[:url] = generated_url
79
+
80
+ generated_url
81
+ end
82
+ end
83
+
84
+ def url_for_direct_upload(key, expires_in:, content_type:, content_length:, checksum:)
85
+ instrument :url, key: key do |payload|
86
+ verified_token_with_expiration = ActiveStorage.verifier.generate(
87
+ {
88
+ key: key,
89
+ content_type: content_type,
90
+ content_length: content_length,
91
+ checksum: checksum
92
+ },
93
+ expires_in: expires_in,
94
+ purpose: :blob_token
95
+ )
96
+ generated_url = url_helpers.update_service_url(
97
+ verified_token_with_expiration,
98
+ host: current_host
99
+ )
100
+ payload[:url] = generated_url
101
+
102
+ generated_url
103
+ end
104
+ end
105
+
106
+ def headers_for_direct_upload(_key, content_type:, **)
107
+ { 'Content-Type' => content_type }
108
+ end
109
+
110
+ private
111
+ def current_host
112
+ ActiveStorage::Current.host
113
+ end
114
+
115
+
116
+ def ensure_integrity_of(key, checksum)
117
+ file = ::ActivestorageDatabase::File.find_by(key: key)
118
+ return if Digest::MD5.base64digest(file.data) == checksum
119
+
120
+ delete(key)
121
+ raise ActiveStorage::IntegrityError
122
+ end
123
+
124
+ def stream(key)
125
+ size = ActivestorageDatabase::File.select('OCTET_LENGTH(data) AS size').find_by(key: key)&.size || raise(ActiveStorage::FileNotFoundError)
126
+
127
+ (size / CHUNK_SIZE.to_f).ceil.times.each do |i|
128
+ range = (i * CHUNK_SIZE..(i + 1) * CHUNK_SIZE - 1)
129
+ yield download_chunk(key, range)
130
+ end
131
+ end
132
+
133
+ def url_helpers
134
+ @url_helpers ||= ActivestorageDatabase::Engine.routes.url_helpers
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActivestorageDatabase
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace ActivestorageDatabase
6
+ end
7
+ end
@@ -0,0 +1,3 @@
1
+ module ActivestorageDatabase
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "activestorage_database/version"
4
+ require "activestorage_database/engine"
5
+
6
+ module ActivestorageDatabase
7
+ # Your code goes here...
8
+ end
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :activestorage_database do
3
+ # # Task goes here
4
+ # end
metadata ADDED
@@ -0,0 +1,73 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: activestorage_database
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Dino Maric
8
+ - Sinan Mujan
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2021-09-28 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: rails
16
+ requirement: !ruby/object:Gem::Requirement
17
+ requirements:
18
+ - - ">="
19
+ - !ruby/object:Gem::Version
20
+ version: 6.0.0
21
+ type: :runtime
22
+ prerelease: false
23
+ version_requirements: !ruby/object:Gem::Requirement
24
+ requirements:
25
+ - - ">="
26
+ - !ruby/object:Gem::Version
27
+ version: 6.0.0
28
+ description: Store ActiveStorage attachments inside the database.
29
+ email:
30
+ - dinom@hey.com
31
+ executables: []
32
+ extensions: []
33
+ extra_rdoc_files: []
34
+ files:
35
+ - MIT-LICENSE
36
+ - README.md
37
+ - Rakefile
38
+ - app/controllers/activestorage_database/files_controller.rb
39
+ - app/models/activestorage_database/file.rb
40
+ - config/routes.rb
41
+ - db/migrate/20210815130342_create_activestorage_database_files.rb
42
+ - lib/active_storage/service/database_service.rb
43
+ - lib/activestorage_database.rb
44
+ - lib/activestorage_database/engine.rb
45
+ - lib/activestorage_database/version.rb
46
+ - lib/tasks/activestorage_database_tasks.rake
47
+ homepage: https://github.com/WizardComputer/activestorage_database
48
+ licenses:
49
+ - MIT
50
+ metadata:
51
+ homepage_uri: https://github.com/WizardComputer/activestorage_database
52
+ source_code_uri: https://github.com/WizardComputer/activestorage_database
53
+ changelog_uri: https://github.com/WizardComputer/activestorage_database
54
+ post_install_message:
55
+ rdoc_options: []
56
+ require_paths:
57
+ - lib
58
+ required_ruby_version: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - ">="
61
+ - !ruby/object:Gem::Version
62
+ version: '0'
63
+ required_rubygems_version: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
68
+ requirements: []
69
+ rubygems_version: 3.1.6
70
+ signing_key:
71
+ specification_version: 4
72
+ summary: Store ActiveStorage attachments inside the database.
73
+ test_files: []