activestorage_database 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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: []