echo_uploads 0.0.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
+ 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: []