dis 0.9.0

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