dis 0.9.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,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 0d9ec3b8500386221e40c1ea04e0b38e0d416f6d
4
+ data.tar.gz: 1b7ae3618b80c6d95b27a5ba832292c3cd035a45
5
+ SHA512:
6
+ metadata.gz: 186c74e43812f0e71ddfc36856106decb829607854bd819d5a51467cfeb3e19811fabd9bd755975bfdc50ee11971e96f19a847612da6e0aaf456078a453916fe
7
+ data.tar.gz: 54bc48092d38eca8edb24d727b5295c56ea9a7d31aed80432ac8ea4a9316eb14162d2333797c76ae8b280a5d55e5fcb4ab27c7652e221d2de1b486e6385b21cf
@@ -0,0 +1,20 @@
1
+ Copyright 2014 Inge Jørgensen
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.
@@ -0,0 +1,165 @@
1
+ # Dis [![Build Status](https://travis-ci.org/elektronaut/dis.png)](https://travis-ci.org/elektronaut/dis) [![Code Climate](https://codeclimate.com/github/elektronaut/dis.png)](https://codeclimate.com/github/elektronaut/dis) [![Code Climate](https://codeclimate.com/github/elektronaut/dis/coverage.png)](https://codeclimate.com/github/elektronaut/dis)
2
+
3
+ Dis handles file uploads for your Rails app.
4
+ It's similar to [Paperclip](https://github.com/thoughtbot/paperclip)
5
+ and [Carrierwave](https://github.com/carrierwaveuploader/carrierwave),
6
+ but different in a few ways. Chiefly, it's much, much simpler.
7
+
8
+ Your files are stored in one or more layers, either on disk or in
9
+ a cloud somewhere. [Fog](http://fog.io) and
10
+ [Active Job](https://github.com/rails/activejob) does most of the
11
+ heavy lifting.
12
+
13
+ Files are indexed by the SHA1 hash of their contents. This means you get
14
+ deduplication for free. This also means you run the (very slight) risk of
15
+ hash collisions. There is no concept of updates in the data store,
16
+ a file with changed content is by definition a different file.
17
+
18
+ It does not do any processing. The idea is to provide a simple foundation
19
+ other gems can build on. If you are looking to handle uploaded images,
20
+ check out [DynamicImage](https://github.com/elektronaut/dynamic_image).
21
+
22
+ Requires Rails 4.1+ and Ruby 1.9.3+.
23
+
24
+ ## Documentation
25
+
26
+ [Documentation on RubyDoc.info](http://rdoc.info/github/elektronaut/dis)
27
+
28
+ ## Installation
29
+
30
+ Add the gem to your Gemfile and run `bundle install`:
31
+
32
+ ```ruby
33
+ gem "dis"
34
+ ```
35
+
36
+ Now, run the generator to install the initializer:
37
+
38
+ ```sh
39
+ bin/rails generate dis:install
40
+ ```
41
+
42
+ ## Usage
43
+
44
+ Run the generator to create your model.
45
+
46
+ ```sh
47
+ bin/rails generate dis:model Document
48
+ ```
49
+
50
+ This will create a model along with a migration.
51
+
52
+ Here's what your model might look like. Note that Dis does not
53
+ validate any data by default, you are expected to use the Rails validators.
54
+ A validator for validating presence of data is provided.
55
+
56
+ ```ruby
57
+ class Document < ActiveRecord::Base
58
+ include Dis::Model
59
+ validates_data_presence
60
+ validates :content_type, presence: true, format: /\Aapplication\/(x\-)?pdf\z/
61
+ validates :filename, presence: true, format: /\A[\w_\-\.]+\.pdf\z/i
62
+ validates :content_length, numericality: { less_than: 5.megabytes }
63
+ end
64
+ ```
65
+
66
+ To save your document, simply set the `file` attribute.
67
+
68
+ ```ruby
69
+ document_params = params.require(:document).permit(:file)
70
+ @document = Document.create(document_params)
71
+ ```
72
+
73
+ You can also assign the data directly.
74
+
75
+ ```ruby
76
+ Document.create(
77
+ data: File.open('document.pdf'),
78
+ content_type: 'application/pdf',
79
+ filename: 'document.pdf'
80
+ )
81
+ ```
82
+
83
+ ## Defining layers
84
+
85
+ The install generator will set you up with a local storage layer on disk,
86
+ but this is configurable in `config/initializers/dis.rb`.
87
+
88
+ You can have as many layers as you want, any storage provider
89
+ [supported by Fog](http://fog.io/storage/) should work in theory.
90
+ Having a local layer first is a good idea, this will provide you
91
+ with a cache on disk. Any misses will be filled from the next layer.
92
+
93
+ ```ruby
94
+ Dis::Storage.layers << Dis::Layer.new(
95
+ Fog::Storage.new({provider: 'Local', local_root: Rails.root.join('db', 'binaries')}),
96
+ path: Rails.env
97
+ )
98
+ ```
99
+
100
+ Delayed layers will be processed out of the request cycle using
101
+ whatever adapter you've configured
102
+ [Active Job](https://github.com/rails/activejob) to use.
103
+ Note: You must have at least one non-delayed layer.
104
+
105
+ ```ruby
106
+ if Rails.env.production?
107
+ Dis::Storage.layers << Dis::Layer.new(
108
+ Fog::Storage.new({
109
+ provider: 'AWS',
110
+ aws_access_key_id: YOUR_AWS_ACCESS_KEY_ID,
111
+ aws_secret_access_key: YOUR_AWS_SECRET_ACCESS_KEY
112
+ }),
113
+ path: "my_bucket",
114
+ delayed: true
115
+ )
116
+ end
117
+ ```
118
+
119
+ You can also set layers to be read only. This is handy if you want to
120
+ access production data from your development environment, or if you
121
+ are in the process of migration from one provider to another.
122
+
123
+ ```ruby
124
+ if Rails.env.development?
125
+ Dis::Storage.layers << Dis::Layer.new(
126
+ Fog::Storage.new(...),
127
+ readonly: true
128
+ )
129
+ end
130
+ ```
131
+
132
+ ## Interacting with the store
133
+
134
+ You can interact directly with the store if you want.
135
+
136
+ ```ruby
137
+ file = File.open("foo.txt")
138
+ hash = Dis::Storage.store("stuff", file) # => "8843d7f92416211de9ebb963ff4ce28125932878"
139
+ Dis::Storage.exists?("stuff", hash) # => true
140
+ Dis::Storage.get("stuff", hash).body # => "foobar"
141
+ Dis::Storage.delete("stuff", hash) # => true
142
+ ```
143
+
144
+ ## License
145
+
146
+ Copyright 2014 Inge Jørgensen
147
+
148
+ Permission is hereby granted, free of charge, to any person obtaining
149
+ a copy of this software and associated documentation files (the
150
+ "Software"), to deal in the Software without restriction, including
151
+ without limitation the rights to use, copy, modify, merge, publish,
152
+ distribute, sublicense, and/or sell copies of the Software, and to
153
+ permit persons to whom the Software is furnished to do so, subject to
154
+ the following conditions:
155
+
156
+ The above copyright notice and this permission notice shall be
157
+ included in all copies or substantial portions of the Software.
158
+
159
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
160
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
161
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
162
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
163
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
164
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
165
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,15 @@
1
+ # encoding: utf-8
2
+
3
+ require "digest/sha1"
4
+ require "fog"
5
+ require "active_job"
6
+ require "dis/errors"
7
+ require "dis/jobs"
8
+ require "dis/layer"
9
+ require "dis/layers"
10
+ require "dis/model"
11
+ require "dis/storage"
12
+ require "dis/validations"
13
+
14
+ module Dis
15
+ end
@@ -0,0 +1,11 @@
1
+ # encoding: utf-8
2
+
3
+ module Dis
4
+ module Errors
5
+ class Error < StandardError; end
6
+ class ReadOnlyError < Dis::Errors::Error; end
7
+ class NoLayersError < Dis::Errors::Error; end
8
+ class NotFoundError < Dis::Errors::Error; end
9
+ class NoDataError < Dis::Errors::Error; end
10
+ end
11
+ end
@@ -0,0 +1,4 @@
1
+ # encoding: utf-8
2
+
3
+ require "dis/jobs/delete"
4
+ require "dis/jobs/store"
@@ -0,0 +1,18 @@
1
+ # encoding: utf-8
2
+
3
+ module Dis
4
+ module Jobs
5
+ # = Dis Delete Job
6
+ #
7
+ # Handles delayed deletion of objects.
8
+ #
9
+ # Dis::Jobs::Delete.enqueue("documents", hash)
10
+ class Delete < ActiveJob::Base
11
+ queue_as :dis
12
+
13
+ def perform(type, hash)
14
+ Dis::Storage.delayed_delete(type, hash)
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,18 @@
1
+ # encoding: utf-8
2
+
3
+ module Dis
4
+ module Jobs
5
+ # = Dis Store Job
6
+ #
7
+ # Handles delayed storage of objects.
8
+ #
9
+ # Dis::Jobs::Store.enqueue("documents", hash)
10
+ class Store < ActiveJob::Base
11
+ queue_as :dis
12
+
13
+ def perform(type, hash)
14
+ Dis::Storage.delayed_store(type, hash)
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,174 @@
1
+ # encoding: utf-8
2
+
3
+ module Dis
4
+ # = Dis Layer
5
+ #
6
+ # Represents a layer of storage. It's a wrapper around
7
+ # <tt>Fog::Storage</tt>, any provider supported by Fog should be usable.
8
+ #
9
+ # ==== Options
10
+ #
11
+ # * <tt>:delayed</tt> - Delayed layers will be processed outside of
12
+ # the request cycle by ActiveJob.
13
+ # * <tt>:readonly</tt> - Readonly layers can only be read from,
14
+ # not written to.
15
+ # * <tt>:public</tt> - Objects stored in public layers will have the
16
+ # public readable flag set if supported by the storage provider.
17
+ # * <tt>:path</tt> - Directory name to use for the store. For Amazon S3,
18
+ # this will be the name of the bucket.
19
+ #
20
+ # ==== Examples
21
+ #
22
+ # This creates a local storage layer. It's a good idea to have a local layer
23
+ # first, this provides you with a cache on disk that will be faster than
24
+ # reading from the cloud.
25
+ #
26
+ # Dis::Layer.new(
27
+ # Fog::Storage.new({
28
+ # provider: 'Local',
29
+ # local_root: Rails.root.join('db', 'dis')
30
+ # }),
31
+ # path: Rails.env
32
+ # )
33
+ #
34
+ # This creates a delayed layer on Amazon S3. ActiveJob will kick in and
35
+ # and transfer content from one of the immediate layers later at it's
36
+ # leisure.
37
+ #
38
+ # Dis::Layer.new(
39
+ # Fog::Storage.new({
40
+ # provider: 'AWS',
41
+ # aws_access_key_id: YOUR_AWS_ACCESS_KEY_ID,
42
+ # aws_secret_access_key: YOUR_AWS_SECRET_ACCESS_KEY
43
+ # }),
44
+ # path: "my_bucket",
45
+ # delayed: true
46
+ # )
47
+ class Layer
48
+ attr_reader :connection
49
+
50
+ def initialize(connection, options={})
51
+ options = default_options.merge(options)
52
+ @connection = connection
53
+ @delayed = options[:delayed]
54
+ @readonly = options[:readonly]
55
+ @public = options[:public]
56
+ @path = options[:path]
57
+ end
58
+
59
+ # Returns true if the layer is a delayed layer.
60
+ def delayed?
61
+ @delayed
62
+ end
63
+
64
+ # Returns true if the layer isn't a delayed layer.
65
+ def immediate?
66
+ !delayed?
67
+ end
68
+
69
+ # Returns true if the layer isn't a delayed layer.
70
+ def public?
71
+ @public
72
+ end
73
+
74
+ # Returns true if the layer is read only.
75
+ def readonly?
76
+ @readonly
77
+ end
78
+
79
+ # Returns true if the layer is writeable.
80
+ def writeable?
81
+ !readonly?
82
+ end
83
+
84
+ # Stores a file.
85
+ #
86
+ # hash = Digest::SHA1.file(file.path).hexdigest
87
+ # layer.store("documents", hash, path)
88
+ #
89
+ # Hash must be a hex digest of the file content. If an object with the
90
+ # supplied hash already exists, no action will be performed. In other
91
+ # words, no data will be overwritten if a hash collision occurs.
92
+ #
93
+ # Returns an instance of Fog::Model, or raises an error if the layer
94
+ # is readonly.
95
+ def store(type, hash, file)
96
+ raise Dis::Errors::ReadOnlyError if readonly?
97
+ store!(type, hash, file)
98
+ end
99
+
100
+ # Returns true if a object with the given hash exists.
101
+ #
102
+ # layer.exists?("documents", hash)
103
+ def exists?(type, hash)
104
+ (directory(type, hash) &&
105
+ directory(type, hash).files.head(key_component(type, hash))) ? true : false
106
+ end
107
+
108
+ # Retrieves a file from the store.
109
+ #
110
+ # layer.get("documents", hash)
111
+ def get(type, hash)
112
+ if dir = directory(type, hash)
113
+ dir.files.get(key_component(type, hash))
114
+ end
115
+ end
116
+
117
+ # Deletes a file from the store.
118
+ #
119
+ # layer.delete("documents", hash)
120
+ #
121
+ # Returns true if the file was deleted, or false if it could not be found.
122
+ # Raises an error if the layer is readonly.
123
+ def delete(type, hash)
124
+ raise Dis::Errors::ReadOnlyError if readonly?
125
+ delete!(type, hash)
126
+ end
127
+
128
+ private
129
+
130
+ def default_options
131
+ { delayed: false, readonly: false, public: false, path: nil }
132
+ end
133
+
134
+ def directory_component(type, hash)
135
+ path || ""
136
+ end
137
+
138
+ def key_component(type, hash)
139
+ [type, hash[0...2], hash[2..hash.length]].compact.join('/')
140
+ end
141
+
142
+ def delete!(type, hash)
143
+ return false unless exists?(type, hash)
144
+ get(type, hash).destroy
145
+ end
146
+
147
+ def directory(type, hash)
148
+ connection.directories.get(directory_component(type, hash))
149
+ end
150
+
151
+ def directory!(type, hash)
152
+ dir = directory(type, hash)
153
+ dir ||= connection.directories.create(
154
+ key: directory_component(type, hash),
155
+ public: public?
156
+ )
157
+ dir
158
+ end
159
+
160
+ def store!(type, hash, file)
161
+ return get(type, hash) if exists?(type, hash)
162
+ file.rewind if file.respond_to?(:rewind)
163
+ directory!(type, hash).files.create(
164
+ key: key_component(type, hash),
165
+ body: (file.kind_of?(Fog::Model) ? file.body : file),
166
+ public: public?
167
+ )
168
+ end
169
+
170
+ def path
171
+ @path && !@path.empty? ? @path : nil
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,69 @@
1
+ # encoding: utf-8
2
+
3
+ module Dis
4
+ # = Dis Layers
5
+ #
6
+ # Represents a collection of layers.
7
+ class Layers
8
+ include Enumerable
9
+
10
+ def initialize(layers=[])
11
+ @layers = layers
12
+ end
13
+
14
+ # Adds a layer to the collection.
15
+ def <<(layer)
16
+ @layers << layer
17
+ end
18
+
19
+ # Clears all layers from the collection.
20
+ def clear!
21
+ @layers = []
22
+ end
23
+
24
+ # Iterates over the layers.
25
+ def each
26
+ @layers.each { |layer| yield layer }
27
+ end
28
+
29
+ # Returns a new instance containing only the delayed layers.
30
+ def delayed
31
+ self.class.new select { |layer| layer.delayed? }
32
+ end
33
+
34
+ # Returns true if one or more delayed layers exist.
35
+ def delayed?
36
+ delayed.any?
37
+ end
38
+
39
+ # Returns a new instance containing only the immediate layers.
40
+ def immediate
41
+ self.class.new select { |layer| layer.immediate? }
42
+ end
43
+
44
+ # Returns true if one or more immediate layers exist.
45
+ def immediate?
46
+ immediate.any?
47
+ end
48
+
49
+ # Returns a new instance containing only the readonly layers.
50
+ def readonly
51
+ self.class.new select { |layer| layer.readonly? }
52
+ end
53
+
54
+ # Returns true if one or more readonly layers exist.
55
+ def readonly?
56
+ readonly.any?
57
+ end
58
+
59
+ # Returns a new instance containing only the writeable layers.
60
+ def writeable
61
+ self.class.new select { |layer| layer.writeable? }
62
+ end
63
+
64
+ # Returns true if one or more writeable layers exist.
65
+ def writeable?
66
+ writeable.any?
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,159 @@
1
+ # encoding: utf-8
2
+
3
+ require 'dis/model/class_methods'
4
+ require 'dis/model/data'
5
+
6
+ module Dis
7
+ # = Dis Model
8
+ #
9
+ # ActiveModel extension for the model holding your data. To use it,
10
+ # simply include the module in your model:
11
+ #
12
+ # class Document < ActiveRecord::Base
13
+ # include Dis::Model
14
+ # end
15
+ #
16
+ # You'll need to define a few attributes in your database table.
17
+ # Here's a minimal migration:
18
+ #
19
+ # create_table :documents do |t|
20
+ # t.string :content_hash
21
+ # t.string :content_type
22
+ # t.integer :content_length
23
+ # t.string :filename
24
+ # end
25
+ #
26
+ # You can override the names of any of these by setting
27
+ # <tt>dis_attributes</tt>.
28
+ #
29
+ # class Document < ActiveRecord::Base
30
+ # include Dis::Model
31
+ # self.dis_attributes = {
32
+ # filename: :my_filename,
33
+ # content_length: :filesize
34
+ # }
35
+ # end
36
+ #
37
+ # == Usage
38
+ #
39
+ # To save a file, simply assign to the <tt>file</tt> attribute.
40
+ #
41
+ # document = Document.create(file: params.permit(:file))
42
+ #
43
+ # <tt>content_type</tt> and <tt>filename</tt> will automatically be set if
44
+ # the supplied object quacks like a file. <tt>content_length</tt> will always
45
+ # be set. <tt>content_hash</tt> won't be set until the record is being saved.
46
+ #
47
+ # If you don't care about filenames and content types and just want to store
48
+ # a binary blob, you can also just set the <tt>data</tt> attribute.
49
+ #
50
+ # my_data = File.read('document.pdf')
51
+ # document.update(data: my_data)
52
+ #
53
+ # The data won't be stored until the record is saved, and not unless
54
+ # the record is valid.
55
+ #
56
+ # To retrieve your data, simply read the <tt>data</tt> attribute. The file
57
+ # will be lazily loaded from the store on demand and cached in memory as long
58
+ # as the record stays in scope.
59
+ #
60
+ # my_data = document.data
61
+ #
62
+ # Destroying a record will delete the file from the store, unless another
63
+ # record also refers to the same hash. Similarly, stale files will be purged
64
+ # when content changes.
65
+ #
66
+ # == Validations
67
+ #
68
+ # No validation is performed by default. If you want to ensure that data is
69
+ # present, use the <tt>validates_data_presence</tt> method.
70
+ #
71
+ # class Document < ActiveRecord::Base
72
+ # include Dis::Model
73
+ # validates_data_presence
74
+ # end
75
+ #
76
+ # If you want to validate content types, size or similar, simply use standard
77
+ # Rails validations on the metadata attributes:
78
+ #
79
+ # validates :content_type, presence: true, format: /\Aapplication\/(x\-)?pdf\z/
80
+ # validates :filename, presence: true, format: /\A[\w_\-\.]+\.pdf\z/i
81
+ # validates :content_length, numericality: { less_than: 5.megabytes }
82
+ module Model
83
+ extend ActiveSupport::Concern
84
+
85
+ included do
86
+ before_save :store_data
87
+ after_save :cleanup_data
88
+ after_destroy :delete_data
89
+ end
90
+
91
+ # Returns the data as a binary string, or nil if no data has been set.
92
+ def data
93
+ dis_data.read
94
+ end
95
+
96
+ # Returns true if data is set.
97
+ def data?
98
+ dis_data.any?
99
+ end
100
+
101
+ # Assigns new data. This also sets <tt>content_length</tt>, and resets
102
+ # <tt>content_hash</tt> to nil.
103
+ def data=(new_data)
104
+ new_data = Dis::Model::Data.new(self, new_data)
105
+ attribute_will_change!('data') unless new_data == dis_data
106
+ @dis_data = new_data
107
+ dis_set :content_hash, nil
108
+ dis_set :content_length, dis_data.content_length
109
+ end
110
+
111
+ # Returns true if the data has been changed since the object was last saved.
112
+ def data_changed?
113
+ changes.include?('data')
114
+ end
115
+
116
+ # Assigns new data from an uploaded file. In addition to the actions
117
+ # performed by <tt>data=</tt>, this will set <tt>content_type</tt> and
118
+ # <tt>filename</tt>.
119
+ def file=(file)
120
+ self.data = file
121
+ dis_set :content_type, file.content_type
122
+ dis_set :filename, file.original_filename
123
+ end
124
+
125
+ private
126
+
127
+ def cleanup_data
128
+ if previous_hash = changes[dis_attribute(:content_hash)].try(&:first)
129
+ dis_data.expire(previous_hash)
130
+ end
131
+ end
132
+
133
+ def delete_data
134
+ dis_data.expire(dis_get(:content_hash))
135
+ end
136
+
137
+ def store_data
138
+ if dis_data.changed?
139
+ dis_set :content_hash, dis_data.store!
140
+ end
141
+ end
142
+
143
+ def dis_get(attribute_name)
144
+ self[dis_attribute(attribute_name)]
145
+ end
146
+
147
+ def dis_data
148
+ @dis_data ||= Dis::Model::Data.new(self)
149
+ end
150
+
151
+ def dis_set(attribute_name, value)
152
+ self[dis_attribute(attribute_name)] = value
153
+ end
154
+
155
+ def dis_attribute(attribute_name)
156
+ self.class.dis_attributes[attribute_name]
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,63 @@
1
+ # encoding: utf-8
2
+
3
+ module Dis
4
+ module Model
5
+ module ClassMethods
6
+ # Returns the mapping of attribute names.
7
+ def dis_attributes
8
+ default_dis_attributes.merge(@dis_attributes || {})
9
+ end
10
+
11
+ # Sets the current mapping of attribute names. Use this if you want to
12
+ # override the attributes and database columns that Dis will use.
13
+ #
14
+ # class Document < ActiveRecord::Base
15
+ # include Dis::Model
16
+ # self.dis_attributes = { filename: :my_custom_filename }
17
+ # end
18
+ def dis_attributes=(new_attributes)
19
+ @dis_attributes = new_attributes
20
+ end
21
+
22
+ # Returns the storage type name, which Dis will use for
23
+ # directory scoping. Defaults to the name of the database table.
24
+ #
25
+ # class Document < ActiveRecord::Base; end
26
+ # Document.dis_type # => "documents"
27
+ def dis_type
28
+ @dis_type || self.table_name
29
+ end
30
+
31
+ # Sets the storage type name.
32
+ #
33
+ # Take care not to set the same name for multiple models, this will
34
+ # cause data loss when a record is destroyed.
35
+ def dis_type=(new_type)
36
+ @dis_type = new_type
37
+ end
38
+
39
+ # Adds a presence validation on the +data+ attribute.
40
+ #
41
+ # This is better than using `validates :data, presence: true`, since
42
+ # that would cause it to load the data from storage on each save.
43
+ #
44
+ # class Document < ActiveRecord::Base
45
+ # include Dis::Model
46
+ # validates_data_presence
47
+ # end
48
+ def validates_data_presence
49
+ validates_with Dis::Validations::DataPresence
50
+ end
51
+
52
+ # Returns the default attribute names.
53
+ def default_dis_attributes
54
+ {
55
+ content_hash: :content_hash,
56
+ content_length: :content_length,
57
+ content_type: :content_type,
58
+ filename: :filename
59
+ }
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,118 @@
1
+ # encoding: utf-8
2
+
3
+ module Dis
4
+ module Model
5
+ # = Dis Model Data
6
+ #
7
+ # Facilitates communication between the model and the storage,
8
+ # and holds any newly assigned data before the record is saved.
9
+ class Data
10
+ def initialize(record, raw=nil)
11
+ @record = record
12
+ @raw = raw
13
+ end
14
+
15
+ # Returns true if two Data objects represent the same data.
16
+ def ==(comp)
17
+ # TODO: This can be made faster by
18
+ # comparing hashes for stored objects.
19
+ comp.read == read
20
+ end
21
+
22
+ # Returns true if data exists either in memory or in storage.
23
+ def any?
24
+ raw? || stored?
25
+ end
26
+
27
+ # Returns the data as a binary string.
28
+ def read
29
+ @cached ||= read_from(closest)
30
+ end
31
+
32
+ # Will be true if data has been explicitely set.
33
+ #
34
+ # Dis::Model::Data.new(record).changed? # => false
35
+ # Dis::Model::Data.new(record, new_file).changed? # => true
36
+ def changed?
37
+ raw?
38
+ end
39
+
40
+ # Returns the length of the data.
41
+ def content_length
42
+ if raw? && raw.respond_to?(:length)
43
+ raw.length
44
+ else
45
+ read.try(&:length).to_i
46
+ end
47
+ end
48
+
49
+ # Expires a data object from the storage if it's no longer being used
50
+ # by existing records. This is triggered from callbacks on the record
51
+ # whenever they are changed or destroyed.
52
+ def expire(hash)
53
+ unless @record.class.where(
54
+ @record.class.dis_attributes[:content_hash] => hash
55
+ ).any?
56
+ Dis::Storage.delete(storage_type, hash)
57
+ end
58
+ end
59
+
60
+ # Stores the data. Returns a hash of the content for reference.
61
+ def store!
62
+ raise Dis::Errors::NoDataError unless raw?
63
+ Dis::Storage.store(storage_type, raw)
64
+ end
65
+
66
+ private
67
+
68
+ def closest
69
+ if raw?
70
+ raw
71
+ elsif stored?
72
+ stored
73
+ end
74
+ end
75
+
76
+ def content_hash
77
+ @record[@record.class.dis_attributes[:content_hash]]
78
+ end
79
+
80
+ def raw?
81
+ raw ? true : false
82
+ end
83
+
84
+ def read_from(object)
85
+ return nil unless object
86
+ if object.respond_to?(:body)
87
+ object.body
88
+ elsif object.respond_to?(:read)
89
+ object.rewind
90
+ response = object.read
91
+ object.rewind
92
+ response
93
+ else
94
+ object
95
+ end
96
+ end
97
+
98
+ def storage_type
99
+ @record.class.dis_type
100
+ end
101
+
102
+ def stored?
103
+ !content_hash.blank?
104
+ end
105
+
106
+ def stored
107
+ Dis::Storage.get(
108
+ storage_type,
109
+ content_hash
110
+ )
111
+ end
112
+
113
+ def raw
114
+ @raw
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,153 @@
1
+ # encoding: utf-8
2
+
3
+ module Dis
4
+ # = Dis Storage
5
+ #
6
+ # This is the interface for interacting with the storage layers.
7
+ #
8
+ # All queries are scoped by object type, which will default to the table
9
+ # name of the model. Take care to use your own scope if you interact with
10
+ # the store directly, as models will purge expired content when they change.
11
+ #
12
+ # Files are stored with a SHA1 digest of the file contents as the key.
13
+ # This ensures data is deduplicated per scope. Hash collisions will be
14
+ # silently ignored.
15
+ #
16
+ # Layers should be added to <tt>Dis::Storage.layers</tt>. At least
17
+ # one writeable, non-delayed layer must exist.
18
+ class Storage
19
+ class << self
20
+ # Exposes the layer set, which is an instance of
21
+ # <tt>Dis::Layers</tt>.
22
+ def layers
23
+ @layers ||= Dis::Layers.new
24
+ end
25
+
26
+ # Stores a file and returns a digest. Kicks off a
27
+ # <tt>Dis::Jobs::Store</tt> job if any delayed layers are defined.
28
+ #
29
+ # hash = Dis::Storage.store("things", File.open('foo.bin'))
30
+ # # => "8843d7f92416211de9ebb963ff4ce28125932878"
31
+ def store(type, file)
32
+ require_writeable_layers!
33
+ hash = store_immediately!(type, file)
34
+ if layers.delayed.writeable.any?
35
+ Dis::Jobs::Store.enqueue(type, hash)
36
+ end
37
+ hash
38
+ end
39
+
40
+ # Transfers files from immediate layers to all delayed layers.
41
+ #
42
+ # Dis::Storage.delayed_store("things", hash)
43
+ def delayed_store(type, hash)
44
+ file = get(type, hash)
45
+ layers.delayed.writeable.each do |layer|
46
+ layer.store(type, hash, file)
47
+ end
48
+ end
49
+
50
+ # Returns true if the file exists in any layer.
51
+ #
52
+ # Dis::Storage.exists?("things", hash) # => true
53
+ def exists?(type, hash)
54
+ require_layers!
55
+ layers.each do |layer|
56
+ return true if layer.exists?(type, hash)
57
+ end
58
+ false
59
+ end
60
+
61
+ # Retrieves a file from the store.
62
+ #
63
+ # stuff = Dis::Storage.get("things", hash)
64
+ #
65
+ # If any misses are detected, it will try to fetch the file from the
66
+ # first available layer, then store it in all immediate layer.
67
+ #
68
+ # Returns an instance of Fog::Model.
69
+ def get(type, hash)
70
+ require_layers!
71
+ miss = false
72
+ layers.each do |layer|
73
+ if result = layer.get(type, hash)
74
+ store_immediately!(type, result) if miss
75
+ return result
76
+ else
77
+ miss = true
78
+ end
79
+ end
80
+ raise Dis::Errors::NotFoundError
81
+ end
82
+
83
+ # Deletes a file from all layers. Kicks off a
84
+ # <tt>Dis::Jobs::Delete</tt> job if any delayed layers are defined.
85
+ # Returns true if the file existed in any immediate layers,
86
+ # or false if not.
87
+ #
88
+ # Dis::Storage.delete("things", hash)
89
+ # # => true
90
+ # Dis::Storage.delete("things", hash)
91
+ # # => false
92
+ def delete(type, hash)
93
+ require_writeable_layers!
94
+ deleted = false
95
+ layers.immediate.writeable.each do |layer|
96
+ deleted = true if layer.delete(type, hash)
97
+ end
98
+ if layers.delayed.writeable.any?
99
+ Dis::Jobs::Delete.enqueue(type, hash)
100
+ end
101
+ deleted
102
+ end
103
+
104
+ # Deletes content from all delayed layers.
105
+ #
106
+ # Dis::Storage.delayed_delete("things", hash)
107
+ def delayed_delete(type, hash)
108
+ layers.delayed.writeable.each do |layer|
109
+ layer.delete(type, hash)
110
+ end
111
+ end
112
+
113
+ private
114
+
115
+ def store_immediately!(type, file)
116
+ hash_file(file) do |hash|
117
+ layers.immediate.writeable.each do |layer|
118
+ layer.store(type, hash, file)
119
+ end
120
+ end
121
+ end
122
+
123
+ def require_layers!
124
+ unless layers.any?
125
+ raise Dis::Errors::NoLayersError
126
+ end
127
+ end
128
+
129
+ def require_writeable_layers!
130
+ unless layers.immediate.writeable.any?
131
+ raise Dis::Errors::NoLayersError
132
+ end
133
+ end
134
+
135
+ def digest
136
+ Digest::SHA1
137
+ end
138
+
139
+ def hash_file(file, &block)
140
+ hash = case file
141
+ when Fog::Model
142
+ digest.hexdigest(file.body)
143
+ when String
144
+ digest.hexdigest(file)
145
+ else
146
+ digest.file(file.path).hexdigest
147
+ end
148
+ yield hash if block_given?
149
+ hash
150
+ end
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,3 @@
1
+ # encoding: utf-8
2
+
3
+ require 'dis/validations/data_presence'
@@ -0,0 +1,17 @@
1
+ # encoding: utf-8
2
+
3
+ module Dis
4
+ module Validations
5
+ # = Dis Data Presence Validation
6
+ #
7
+ class DataPresence < ActiveModel::Validator
8
+ # Validates that a record has data, either freshly assigned or
9
+ # persisted in the storage. Adds a `:blank` error on `:data`if not.
10
+ def validate(record)
11
+ unless record.data?
12
+ record.errors.add(:data, :blank)
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,5 @@
1
+ # encoding: utf-8
2
+
3
+ module Dis
4
+ VERSION = "0.9.0"
5
+ end
@@ -0,0 +1,17 @@
1
+ # encoding: utf-8
2
+
3
+ require 'rails/generators'
4
+ require 'rails/generators/rails/model/model_generator'
5
+
6
+ module Dis
7
+ module Generators
8
+ class InstallGenerator < Rails::Generators::Base
9
+ desc "Creates the Dis initializer"
10
+ source_root File.expand_path("../templates", __FILE__)
11
+
12
+ def create_initializer
13
+ template 'initializer.rb', File.join('config', 'initializers', 'dis.rb')
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,18 @@
1
+ # Be sure to restart your server when you modify this file.
2
+
3
+ # Creates a local storage layer in db/dis:
4
+ Dis::Storage.layers << Dis::Layer.new(
5
+ Fog::Storage.new({ provider: 'Local', local_root: Rails.root.join('db', 'dis') }),
6
+ path: Rails.env
7
+ )
8
+
9
+ # You can also add cloud storage.
10
+ # Dis::Storage.layers << Dis::Layer.new(
11
+ # Fog::Storage.new({
12
+ # provider: 'AWS',
13
+ # aws_access_key_id: AWS_ACCESS_KEY_ID,
14
+ # aws_secret_access_key: AWS_SECRET_ACCESS_KEY
15
+ # }),
16
+ # path: "my_bucket",
17
+ # delayed: true
18
+ # )
@@ -0,0 +1,41 @@
1
+ # encoding: utf-8
2
+
3
+ require 'rails/generators'
4
+ require 'rails/generators/rails/model/model_generator'
5
+
6
+ module Dis
7
+ module Generators
8
+ class ModelGenerator < Rails::Generators::ModelGenerator
9
+ desc "Creates a Dis model"
10
+
11
+ def initialize(args, *options)
12
+ super(inject_dis_attributes(args), *options)
13
+ end
14
+
15
+ def add_model_extension
16
+ inject_into_file File.join('app/models', class_path, "#{file_name}.rb"), after: "ActiveRecord::Base\n" do
17
+ " include Dis::Model\n"
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def inject_dis_attributes(args)
24
+ if args.any?
25
+ args = [args[0]] + dis_attributes + args[1..args.length]
26
+ else
27
+ args
28
+ end
29
+ end
30
+
31
+ def dis_attributes
32
+ %w{
33
+ content_hash:string
34
+ content_type:string
35
+ content_length:integer
36
+ filename:string
37
+ }
38
+ end
39
+ end
40
+ end
41
+ end
metadata ADDED
@@ -0,0 +1,134 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: dis
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.9.0
5
+ platform: ruby
6
+ authors:
7
+ - Inge Jørgensen
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2014-09-23 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rails
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: 4.1.0
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: 4.1.0
27
+ - !ruby/object:Gem::Dependency
28
+ name: fog
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: 1.22.1
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: 1.22.1
41
+ - !ruby/object:Gem::Dependency
42
+ name: activejob
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: sqlite3
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rspec-rails
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: 3.0.0
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: 3.0.0
83
+ description: Dis is a Rails plugin that stores your file uploads and other binary
84
+ blobs.
85
+ email:
86
+ - inge@elektronaut.no
87
+ executables: []
88
+ extensions: []
89
+ extra_rdoc_files: []
90
+ files:
91
+ - MIT-LICENSE
92
+ - README.md
93
+ - lib/dis.rb
94
+ - lib/dis/errors.rb
95
+ - lib/dis/jobs.rb
96
+ - lib/dis/jobs/delete.rb
97
+ - lib/dis/jobs/store.rb
98
+ - lib/dis/layer.rb
99
+ - lib/dis/layers.rb
100
+ - lib/dis/model.rb
101
+ - lib/dis/model/class_methods.rb
102
+ - lib/dis/model/data.rb
103
+ - lib/dis/storage.rb
104
+ - lib/dis/validations.rb
105
+ - lib/dis/validations/data_presence.rb
106
+ - lib/dis/version.rb
107
+ - lib/rails/generators/dis/install/install_generator.rb
108
+ - lib/rails/generators/dis/install/templates/initializer.rb
109
+ - lib/rails/generators/dis/model/model_generator.rb
110
+ homepage: https://github.com/elektronaut/dis
111
+ licenses:
112
+ - MIT
113
+ metadata: {}
114
+ post_install_message:
115
+ rdoc_options: []
116
+ require_paths:
117
+ - lib
118
+ required_ruby_version: !ruby/object:Gem::Requirement
119
+ requirements:
120
+ - - ">="
121
+ - !ruby/object:Gem::Version
122
+ version: 1.9.2
123
+ required_rubygems_version: !ruby/object:Gem::Requirement
124
+ requirements:
125
+ - - ">="
126
+ - !ruby/object:Gem::Version
127
+ version: '0'
128
+ requirements: []
129
+ rubyforge_project:
130
+ rubygems_version: 2.4.1
131
+ signing_key:
132
+ specification_version: 4
133
+ summary: A file store for your Rails app
134
+ test_files: []