echo_uploads 0.0.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
+ SHA1:
3
+ metadata.gz: e1c16aedf066542375c4917d100a2b80e76f552f
4
+ data.tar.gz: 8245c8fb20b7b6f4f5a5d706fd9f88577c8fe494
5
+ SHA512:
6
+ metadata.gz: b4b033b96e3d39697160a5edbee7aaf95265bf3209fd949975db05aa5a7f003936efb6e38280ce11159609830cd1e9d6dded6a7509324277eb52d9a2be8a8267
7
+ data.tar.gz: ecc573409a3bfb5ac9532ce8b8cd91d24e1e74f3a1c6051bbbe4079efde93c37ba52e4fc15bb869af8709564d0f459cefbee39d25a938086c4ddd6be89fe7dc9
@@ -0,0 +1,10 @@
1
+ module EchoUploads; end
2
+
3
+ require 'echo_uploads/railtie'
4
+ require 'echo_uploads/validation'
5
+ require 'echo_uploads/perm_file_saving'
6
+ require 'echo_uploads/temp_file_saving'
7
+ require 'echo_uploads/model'
8
+ require 'echo_uploads/file'
9
+ require 'echo_uploads/abstract_store'
10
+ require 'echo_uploads/filesystem_store'
@@ -0,0 +1,7 @@
1
+ module EchoUploads
2
+ class AbstractStore
3
+ def path(key)
4
+ raise "This type of filestore doesn't support the #path method."
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,76 @@
1
+ require 'digest/sha2'
2
+ require 'mime/types'
3
+
4
+ module EchoUploads
5
+ class File < ActiveRecord::Base
6
+ self.table_name = 'echo_uploads_files'
7
+
8
+ belongs_to :owner, polymorphic: true
9
+
10
+ before_destroy :delete_file_conditionally
11
+
12
+ def compute_mime!
13
+ type = MIME::Types.type_for(original_filename).first
14
+ self.mime_type = type ? type.content_type : 'application/octet-stream'
15
+ end
16
+
17
+ # Returns a proc that takes as its only argument an ActionDispatch::UploadedFile
18
+ # and returns a key string.
19
+ def self.default_key_proc
20
+ ->(file) do
21
+ digest = Digest::SHA512.new
22
+ file.rewind
23
+ until file.eof?
24
+ digest.update file.read(1000)
25
+ end
26
+ digest.hexdigest
27
+ end
28
+ end
29
+
30
+ # Deletes the file on disk if and only if no other instances of EchoUpload::File
31
+ # reference it.
32
+ def delete_file_conditionally
33
+ unless self.class.where(key: key).where(['id != ?', id]).exists?
34
+ storage.delete key
35
+ end
36
+ end
37
+
38
+ def original_filename
39
+ original_basename + original_extension
40
+ end
41
+
42
+ # Pass in an attribute name, an ActionDispatch::UploadedFile, and an options hash.
43
+ def persist!(attr, file, options)
44
+ # Configure and save the metadata object.
45
+ self.key = options[:key].call file
46
+ self.owner_attr = attr
47
+ self.original_extension = ::File.extname(file.original_filename)
48
+ self.original_basename = ::File.basename(file.original_filename, original_extension)
49
+ compute_mime!
50
+ self.storage_type = options[:storage].name
51
+ save!
52
+
53
+ # Write the file to the filestore.
54
+ storage.write key, file
55
+
56
+ # Prune any expired temporary files. (Unless automatic pruning was turned off in
57
+ # the app config.)
58
+ unless (
59
+ Rails.configuration.echo_uploads.respond_to?(:prune_tmp_files_on_upload) and
60
+ !Rails.configuration.echo_uploads.prune_tmp_files_on_upload
61
+ )
62
+ self.class.prune_temporary!
63
+ end
64
+ end
65
+
66
+ def self.prune_temporary!
67
+ where(temporary: true).where(['expires_at < ?', Time.now]).each do |file_meta|
68
+ file_meta.destroy
69
+ end
70
+ end
71
+
72
+ def storage
73
+ Object.const_get(storage_type).new
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,46 @@
1
+ module EchoUploads
2
+ class FilesystemStore < ::EchoUploads::AbstractStore
3
+ def write(key, file)
4
+ _path = path key
5
+ unless ::File.exists?(_path)
6
+ unless ::File.exists?(folder)
7
+ begin
8
+ FileUtils.mkdir_p folder
9
+ rescue Errno::EACCES
10
+ raise "Permission denied trying to create #{folder}"
11
+ end
12
+ end
13
+ FileUtils.cp file.path, _path
14
+ end
15
+ end
16
+
17
+ def read(key)
18
+ File.read path(key)
19
+ end
20
+
21
+ def delete(key)
22
+ _path = path(key)
23
+ ::File.delete(_path) if ::File.exists?(_path)
24
+ end
25
+
26
+ def open(key)
27
+ ::File.open(path(key), 'rb', &block)
28
+ end
29
+
30
+ def path(key)
31
+ ::File.join folder, key
32
+ end
33
+
34
+ private
35
+
36
+ # Can be customized in your per-environment config like this:
37
+ # config.echo_uploads.folder = File.join(Rails.root, 'my_uploads_folder', 'development')
38
+ def folder
39
+ if Rails.configuration.respond_to?(:echo_uploads) and Rails.configuration.echo_uploads.folder
40
+ Rails.configuration.echo_uploads.folder
41
+ else
42
+ ::File.join Rails.root, 'echo_uploads', Rails.env
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,139 @@
1
+ require 'base64'
2
+ require 'json'
3
+ require 'fileutils'
4
+
5
+ module EchoUploads
6
+ module Model
7
+ def self.included(base)
8
+ base.class_eval do
9
+ class_attribute :echo_uploads_config
10
+
11
+ include ::EchoUploads::Validation
12
+ include ::EchoUploads::PermFileSaving
13
+ include ::EchoUploads::TempFileSaving
14
+
15
+ extend ClassMethods
16
+ end
17
+ end
18
+
19
+ def echo_uploads_data
20
+ Base64.encode64(JSON.dump(self.class.echo_uploads_config.inject({}) do |hash, (attr, cfg)|
21
+ meta = send("#{attr}_tmp_metadata")
22
+ if meta
23
+ hash[attr] = {'id' => meta.id, 'key' => meta.key}
24
+ end
25
+ hash
26
+ end)).strip
27
+ end
28
+
29
+ # Pass in a hash that's been encoded as JSON and then Base64.
30
+ def echo_uploads_data=(data)
31
+ parsed = JSON.parse Base64.decode64(data)
32
+ parsed.each do |attr, attr_data|
33
+ # Must verify that the metadata record is temporary. If not, an attacker could
34
+ # pass the ID of a permanent record and change its owner.
35
+ meta = ::EchoUploads::File.where(key: attr_data['key'], temporary: true).find(attr_data['id'])
36
+ send("#{attr}_tmp_metadata=", meta)
37
+ end
38
+ end
39
+
40
+ # Helper method used internally Echo Uploads.
41
+ def map_metadata(attr)
42
+ meta = send("#{attr}_metadata")
43
+ meta ? yield(meta) : nil
44
+ end
45
+
46
+ module ClassMethods
47
+ # Options:
48
+ # - +key+: A Proc that takes an ActionDispatch::UploadedFile and returns a key
49
+ # uniquely identifying the file. If this option is not specified, the key is
50
+ # computed as the SHA-512 hash of the file contents. A digest of the file's
51
+ # contents should always be at least a part of the key.
52
+ # - +expires+: Length of time temporary files will be persisted. Defaults to
53
+ # +1.day+.
54
+ # - +storage+: A class that persists uploaded files to disk, to the cloud, or to
55
+ # wherever else you want. Defaults to +EchoUploads::FilesystemStore+.
56
+ def echo_upload(attr, options = {})
57
+ options = {
58
+ expires: 1.day,
59
+ storage: ::EchoUploads::FilesystemStore,
60
+ key: ::EchoUploads::File.default_key_proc
61
+ }.merge(options)
62
+
63
+ # Init the config object. We can't use [] syntax to set the hash key because
64
+ # class_attribute expects you to call the setter method every time the
65
+ # attribute value changes. (Merely calling [] would just mutate the referenced
66
+ # object, and wouldn't invoke the setter.)
67
+ self.echo_uploads_config ||= {}
68
+ self.echo_uploads_config = echo_uploads_config.merge attr => {}
69
+
70
+ # Define reader and writer methods for the file attribute.
71
+ attr_accessor attr
72
+
73
+ # Define the path method. This method will raise if the given storage
74
+ # class doesn't support the #path method.
75
+ define_method("#{attr}_path") do
76
+ map_metadata(attr) do |meta|
77
+ meta.storage.path meta.key
78
+ end
79
+ end
80
+
81
+ # Define the MIME type method.
82
+ define_method("#{attr}_mime") do
83
+ map_metadata(attr, &:mime_type)
84
+ end
85
+ alias_method "#{attr}_mime_type", "#{attr}_mime"
86
+
87
+ # Define the original filename method.
88
+ define_method("#{attr}_original_filename") do
89
+ map_metadata(attr, &:original_filename)
90
+ end
91
+
92
+ # Define the key method
93
+ define_method("#{attr}_key") do
94
+ map_metadata(attr, &:key)
95
+ end
96
+
97
+ # Define the has_x? method. Returns true if a permanent or temporary file has been
98
+ # persisted, or if a file (which may not be valid) has been uploaded this request
99
+ # cycle.
100
+ define_method("has_#{attr}?") do
101
+ # Does this record have a permanent file?
102
+ send("has_prm_#{attr}?") or
103
+
104
+ # Did the submitted form "remember" a previously saved metadata record?
105
+ send("has_tmp_#{attr}?") or
106
+
107
+ # Has a new file been uploaded in this request cycle?
108
+ send(attr).present?
109
+ end
110
+
111
+ # Define the has_prm_x? method. Returns true if the permanent metadata record
112
+ # exists and has its owner set to this object.
113
+ define_method("has_prm_#{attr}?") do
114
+ send("#{attr}_metadata").present? and send("#{attr}_metadata").persisted?
115
+ end
116
+
117
+ # Define the has_tmp_x? method. Returns true if the record "remembers"
118
+ # a a temporary metadata record. (Typically because validation errors caused
119
+ # the form to be redisplayed.)
120
+ define_method("has_tmp_#{attr}?") do
121
+ send("#{attr}_tmp_metadata").present?
122
+ end
123
+
124
+ # Define the association with the metadata model.
125
+ has_one("#{attr}_metadata".to_sym,
126
+ ->() { where(owner_attr: attr) },
127
+ as: :owner, dependent: :destroy, class_name: '::EchoUploads::File'
128
+ )
129
+
130
+ # Define the temp attribute for the metadata model.
131
+ attr_accessor "#{attr}_tmp_metadata"
132
+
133
+ configure_temp_file_saving attr, options
134
+
135
+ configure_perm_file_saving attr, options
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,46 @@
1
+ module EchoUploads
2
+ module PermFileSaving
3
+ def self.included(base)
4
+ base.class_eval { extend ClassMethods }
5
+ end
6
+
7
+ module ClassMethods
8
+ def configure_perm_file_saving(attr, options)
9
+ # Save the file and the metadata after this model saves.
10
+ after_save do |model|
11
+ if (file = send(attr)).present?
12
+ # A file is being uploaded during this request cycle.
13
+ if meta = send("#{attr}_metadata")
14
+ # A previous permanent file exists. This is a new version being uploaded.
15
+ # Delete the old version from the disk if no other metadata record
16
+ # references it.
17
+ meta.delete_file_conditionally
18
+ else
19
+ # No previous permanent file exists.
20
+ meta = ::EchoUploads::File.new(owner: model, temporary: false)
21
+ send("#{attr}_metadata=", meta)
22
+ end
23
+ meta.persist! attr, file, options
24
+ elsif meta = send("#{attr}_tmp_metadata") and meta.temporary
25
+ # A file has not been uploaded during this request cycle. However, the
26
+ # submitted form "remembered" a temporary metadata record that was previously
27
+ # saved. We mark it as permanent and set its owner.
28
+ #
29
+ # But first, we must delete any existing metadata record. (It's possible we
30
+ # were trying to replace an old version of the file, and there were validation
31
+ # errors on the first attempt.)
32
+ if old = model.send("#{attr}_metadata")
33
+ old.destroy
34
+ end
35
+
36
+ meta.owner = model
37
+ send("#{attr}_metadata=", meta)
38
+ meta.temporary = false
39
+ meta.expires_at = nil
40
+ meta.save!
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,7 @@
1
+ require 'ostruct'
2
+
3
+ module EchoUploads
4
+ class Railtie < Rails::Railtie
5
+ config.echo_uploads = OpenStruct.new
6
+ end
7
+ end
@@ -0,0 +1,79 @@
1
+ module EchoUploads
2
+ module TempFileSaving
3
+ def self.included(base)
4
+ base.class_eval { extend ClassMethods }
5
+ end
6
+
7
+ # On a failed attempt to save (typically due to validation errors), save the file
8
+ # and metadata. Metadata record will be given the temporary flag.
9
+ #
10
+ # To deal with the various persistence methods (#save, #create,
11
+ # #update_attributes), and the fact that ActiveRecord rolls back the transaction
12
+ # on validation failure, we can't just use a convenient after_validation callback.
13
+ # Instead, we have to do some trickery with .alias_method_chain.
14
+ def maybe_save_temp_file(attr, options)
15
+ success = yield
16
+
17
+ # Because of the tangled way ActiveRecord's persistence methods delegate to each
18
+ # other, maybe_save_temp_file sometimes gets called twice. That's unavoidable. To
19
+ # workaround that issue, we check whether we're calling #save from within #update.
20
+ @echo_uploads_saving ||= {}
21
+ @echo_uploads_updating ||= {}
22
+ unless @echo_uploads_saving[attr] and @echo_uploads_updating[attr]
23
+ if (file = send(attr)).present? and !success and errors[attr].empty?
24
+ # A file has been uploaded. Validation failed, but the file itself was valid.
25
+ # Thus, we must persist a temporary file.
26
+ #
27
+ # It's possible at this point that the record already has a permanent file.
28
+ # That's fine. We'll now have a permanent and a temporary one. The temporary
29
+ # one will replace the permanent one if and when the user resubmits with
30
+ # valid data.
31
+ meta = ::EchoUploads::File.new(
32
+ owner: nil, temporary: true, expires_at: options[:expires].from_now
33
+ )
34
+ meta.persist! attr, file, options
35
+ send("#{attr}_tmp_metadata=", meta)
36
+ end
37
+ end
38
+
39
+ success
40
+ end
41
+
42
+ module ClassMethods
43
+ # Wraps ActiveRecord's persistence methods. We can't use a callback for this. See
44
+ # the comment above for an explanation of why.
45
+ def configure_temp_file_saving(attr, options)
46
+ # Wrap the #save method. This also suffices for #create.
47
+ define_method("save_with_#{attr}_temp_file") do |*args|
48
+ @echo_uploads_saving ||= {}
49
+ @echo_uploads_saving[attr] = true
50
+ begin
51
+ success = maybe_save_temp_file(attr, options) do
52
+ send "save_without_#{attr}_temp_file", *args
53
+ end
54
+ success
55
+ ensure
56
+ @echo_uploads_saving.delete attr
57
+ end
58
+ end
59
+ alias_method_chain :save, "#{attr}_temp_file".to_sym
60
+
61
+ # Wrap the #update and #update_attributes methods.
62
+ define_method("update_with_#{attr}_temp_file") do |*args|
63
+ @echo_uploads_updating ||= {}
64
+ @echo_uploads_updating[attr] = true
65
+ begin
66
+ success = maybe_save_temp_file(attr, options) do
67
+ send "update_without_#{attr}_temp_file", *args
68
+ end
69
+ success
70
+ ensure
71
+ @echo_uploads_updating.delete attr
72
+ end
73
+ end
74
+ alias_method_chain :update, "#{attr}_temp_file".to_sym
75
+ alias_method :update_attributes, "update_with_#{attr}_temp_file".to_sym
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,89 @@
1
+ # If you want to validate the presence of a file, be sure to use:
2
+ # validates :attr, uploads: {presence: true}
3
+ # instead of:
4
+ # validates :attr, presence: true
5
+ # The former takes into account files that have already been persisted, whereas the
6
+ # latter does not and will cause false validation errors.
7
+
8
+ module EchoUploads
9
+ module Validation
10
+ class UploadValidator < ActiveModel::EachValidator
11
+ def validate_each(record, attr, val)
12
+ # Presence validation
13
+ if options[:presence]
14
+ unless record.send("has_#{attr}?")
15
+ record.errors[attr] << (
16
+ options[:message] ||
17
+ 'must be uploaded'
18
+ )
19
+ end
20
+ end
21
+
22
+ # File size validation
23
+ if options[:max_size]
24
+ unless options[:max_size].is_a? Numeric
25
+ raise(ArgumentError,
26
+ "validates :#{attr}, :upload called with invalid :max_size option. " +
27
+ ":max_size must be a number, e.g. 1.megabyte"
28
+ )
29
+ end
30
+
31
+ if val.present?
32
+ unless val.respond_to?(:size)
33
+ raise ArgumentError, "Expected ##{attr} to respond to #size"
34
+ end
35
+
36
+ if val.size > options[:max_size]
37
+ record.errors[attr] << (
38
+ options[:message] ||
39
+ "must be smaller than #{options[:max_size].to_i} bytes"
40
+ )
41
+ end
42
+ end
43
+ end
44
+
45
+ # Extension validation
46
+ if options[:extension]
47
+ unless options[:extension].is_a? Array
48
+ raise(ArgumentError,
49
+ "validates :#{attr}, :upload called with invalid :extension option. " +
50
+ ":extension must be an array of extensions like ['.jpg', '.png']"
51
+ )
52
+ end
53
+
54
+ if val.present?
55
+ unless val.respond_to?(:original_filename)
56
+ raise ArgumentError, "Expected ##{attr} to respond to #original_filename"
57
+ end
58
+
59
+ ext = ::File.extname val.original_filename
60
+ unless options[:extension].include?(ext)
61
+ record.errors[attr] << (
62
+ options[:message] ||
63
+ "must have one of the following extensions: #{options[:extension].join(',')}"
64
+ )
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
72
+
73
+ # If you pass in the presence: true option, this validator will assume you're using the
74
+ # CachedUploads module. It will look at the has_cached_upload config for the given
75
+ # attribute and check if a) the upload is present, b) the temporary MD5 hash is present,
76
+ # or c) the record has already been saved.
77
+ class UploadValidator < ActiveModel::EachValidator
78
+ def validate_each(record, attribute, value)
79
+ if options[:presence]
80
+ config = record.class.cached_uploads[attribute.to_sym]
81
+ if record.new_record? and value.blank? and record.send(config[:md5_attr]).blank?
82
+ record.errors[attribute] << (options[:message] || "can't be blank")
83
+ end
84
+ end
85
+ if value.present? and value.size > options[:max_size]
86
+ record.errors[attribute] << (options[:message] || "is too large (max is #{options[:max_size]} bytes)")
87
+ end
88
+ end
89
+ end
metadata ADDED
@@ -0,0 +1,80 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: echo_uploads
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Jarrett Colby
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2014-06-09 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: mime-types
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: turn
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ description: ''
42
+ email: jarrett@madebyhq.com
43
+ executables: []
44
+ extensions: []
45
+ extra_rdoc_files: []
46
+ files:
47
+ - lib/echo_uploads.rb
48
+ - lib/echo_uploads/abstract_store.rb
49
+ - lib/echo_uploads/file.rb
50
+ - lib/echo_uploads/filesystem_store.rb
51
+ - lib/echo_uploads/model.rb
52
+ - lib/echo_uploads/perm_file_saving.rb
53
+ - lib/echo_uploads/railtie.rb
54
+ - lib/echo_uploads/temp_file_saving.rb
55
+ - lib/echo_uploads/validation.rb
56
+ homepage: https://github.com/jarrett/echo_uploads
57
+ licenses:
58
+ - MIT
59
+ metadata: {}
60
+ post_install_message:
61
+ rdoc_options: []
62
+ require_paths:
63
+ - lib
64
+ required_ruby_version: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ required_rubygems_version: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - ">="
72
+ - !ruby/object:Gem::Version
73
+ version: '0'
74
+ requirements: []
75
+ rubyforge_project:
76
+ rubygems_version: 2.2.2
77
+ signing_key:
78
+ specification_version: 4
79
+ summary: Uploaded files for Rails
80
+ test_files: []