refile 0.5.5 → 0.6.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (72) hide show
  1. checksums.yaml +4 -4
  2. data/lib/refile.rb +252 -27
  3. data/lib/refile/app.rb +55 -14
  4. data/lib/refile/attacher.rb +39 -40
  5. data/lib/refile/attachment.rb +28 -13
  6. data/lib/refile/attachment/active_record.rb +90 -1
  7. data/lib/refile/attachment_definition.rb +47 -0
  8. data/lib/refile/backend/s3.rb +1 -147
  9. data/lib/refile/backend_macros.rb +13 -5
  10. data/lib/refile/custom_logger.rb +3 -1
  11. data/lib/refile/file.rb +9 -0
  12. data/lib/refile/image_processing.rb +1 -143
  13. data/lib/refile/rails.rb +30 -0
  14. data/lib/refile/rails/attachment_helper.rb +27 -16
  15. data/lib/refile/signature.rb +5 -0
  16. data/lib/refile/simple_form.rb +17 -0
  17. data/lib/refile/version.rb +1 -1
  18. data/spec/refile/active_record_helper.rb +11 -0
  19. data/spec/refile/app_spec.rb +197 -20
  20. data/spec/refile/attachment/active_record_spec.rb +298 -1
  21. data/spec/refile/attachment_helper_spec.rb +39 -0
  22. data/spec/refile/attachment_spec.rb +53 -5
  23. data/spec/refile/backend_examples.rb +13 -2
  24. data/spec/refile/backend_macros_spec.rb +27 -6
  25. data/spec/refile/custom_logger_spec.rb +2 -3
  26. data/spec/refile/features/direct_upload_spec.rb +18 -0
  27. data/spec/refile/features/multiple_upload_spec.rb +122 -0
  28. data/spec/refile/features/normal_upload_spec.rb +5 -3
  29. data/spec/refile/features/presigned_upload_spec.rb +4 -0
  30. data/spec/refile/features/simple_form_spec.rb +8 -0
  31. data/spec/refile/fixtures/monkey.txt +1 -0
  32. data/spec/refile/fixtures/world.txt +1 -0
  33. data/spec/refile/spec_helper.rb +21 -11
  34. data/spec/refile_spec.rb +253 -24
  35. metadata +12 -303
  36. data/.gitignore +0 -27
  37. data/.rspec +0 -2
  38. data/.rubocop.yml +0 -68
  39. data/.travis.yml +0 -21
  40. data/.yardopts +0 -1
  41. data/CONTRIBUTING.md +0 -33
  42. data/Gemfile +0 -3
  43. data/History.md +0 -96
  44. data/LICENSE.txt +0 -22
  45. data/README.md +0 -651
  46. data/Rakefile +0 -19
  47. data/app/assets/javascripts/refile.js +0 -63
  48. data/config.ru +0 -8
  49. data/config/locales/en.yml +0 -8
  50. data/config/routes.rb +0 -5
  51. data/refile.gemspec +0 -42
  52. data/spec/refile/backend/s3_spec.rb +0 -11
  53. data/spec/refile/test_app.rb +0 -65
  54. data/spec/refile/test_app/app/assets/javascripts/application.js +0 -42
  55. data/spec/refile/test_app/app/controllers/application_controller.rb +0 -2
  56. data/spec/refile/test_app/app/controllers/direct_posts_controller.rb +0 -15
  57. data/spec/refile/test_app/app/controllers/home_controller.rb +0 -4
  58. data/spec/refile/test_app/app/controllers/normal_posts_controller.rb +0 -48
  59. data/spec/refile/test_app/app/controllers/presigned_posts_controller.rb +0 -31
  60. data/spec/refile/test_app/app/models/post.rb +0 -5
  61. data/spec/refile/test_app/app/views/direct_posts/new.html.erb +0 -20
  62. data/spec/refile/test_app/app/views/home/index.html.erb +0 -1
  63. data/spec/refile/test_app/app/views/layouts/application.html.erb +0 -14
  64. data/spec/refile/test_app/app/views/normal_posts/_form.html.erb +0 -28
  65. data/spec/refile/test_app/app/views/normal_posts/edit.html.erb +0 -1
  66. data/spec/refile/test_app/app/views/normal_posts/index.html +0 -5
  67. data/spec/refile/test_app/app/views/normal_posts/new.html.erb +0 -1
  68. data/spec/refile/test_app/app/views/normal_posts/show.html.erb +0 -19
  69. data/spec/refile/test_app/app/views/presigned_posts/new.html.erb +0 -16
  70. data/spec/refile/test_app/config/database.yml +0 -7
  71. data/spec/refile/test_app/config/routes.rb +0 -17
  72. data/spec/refile/test_app/public/favicon.ico +0 -0
@@ -1,27 +1,32 @@
1
1
  module Refile
2
2
  # @api private
3
3
  class Attacher
4
- attr_reader :record, :name, :cache, :store, :options, :errors, :type, :valid_extensions, :valid_content_types
4
+ attr_reader :definition, :record, :errors
5
5
  attr_accessor :remove
6
6
 
7
7
  Presence = ->(val) { val if val != "" }
8
8
 
9
- def initialize(record, name, cache:, store:, raise_errors: true, type: nil, extension: nil, content_type: nil)
9
+ def initialize(definition, record)
10
+ @definition = definition
10
11
  @record = record
11
- @name = name
12
- @raise_errors = raise_errors
13
- @cache = Refile.backends.fetch(cache.to_s)
14
- @store = Refile.backends.fetch(store.to_s)
15
- @type = type
16
- @valid_extensions = [extension].flatten if extension
17
- @valid_content_types = [content_type].flatten if content_type
18
- @valid_content_types ||= Refile.types.fetch(type).content_type if type
19
12
  @errors = []
20
13
  @metadata = {}
21
14
  end
22
15
 
16
+ def name
17
+ @definition.name
18
+ end
19
+
20
+ def cache
21
+ @definition.cache
22
+ end
23
+
24
+ def store
25
+ @definition.store
26
+ end
27
+
23
28
  def id
24
- Presence[read(:id)]
29
+ Presence[read(:id, true)]
25
30
  end
26
31
 
27
32
  def size
@@ -66,7 +71,7 @@ module Refile
66
71
  end
67
72
 
68
73
  def set(value)
69
- if value.is_a?(String)
74
+ if value.is_a?(String) or value.is_a?(Hash)
70
75
  retrieve!(value)
71
76
  else
72
77
  cache!(value)
@@ -74,9 +79,12 @@ module Refile
74
79
  end
75
80
 
76
81
  def retrieve!(value)
77
- @metadata = JSON.parse(value, symbolize_names: true) || {}
82
+ if value.is_a?(String)
83
+ @metadata = Refile.parse_json(value, symbolize_names: true) || {}
84
+ elsif value.is_a?(Hash)
85
+ @metadata = value
86
+ end
78
87
  write_metadata if cache_id
79
- rescue JSON::ParserError
80
88
  end
81
89
 
82
90
  def cache!(uploadable)
@@ -88,7 +96,7 @@ module Refile
88
96
  if valid?
89
97
  @metadata[:id] = cache.upload(uploadable).id
90
98
  write_metadata
91
- elsif @raise_errors
99
+ elsif @definition.raise_errors?
92
100
  raise Refile::Invalid, @errors.join(", ")
93
101
  end
94
102
  end
@@ -98,29 +106,30 @@ module Refile
98
106
  response = RestClient::Request.new(method: :get, url: url, raw_response: true).execute
99
107
  @metadata = {
100
108
  size: response.file.size,
101
- filename: ::File.basename(url),
109
+ filename: URI.parse(url).path.split("/").last,
102
110
  content_type: response.headers[:content_type]
103
111
  }
104
112
  if valid?
113
+ response.file.open if response.file.closed? # https://github.com/refile/refile/pull/210
105
114
  @metadata[:id] = cache.upload(response.file).id
106
115
  write_metadata
107
- elsif @raise_errors
116
+ elsif @definition.raise_errors?
108
117
  raise Refile::Invalid, @errors.join(", ")
109
118
  end
110
119
  end
111
120
  rescue RestClient::Exception
112
121
  @errors = [:download_failed]
113
- raise if @raise_errors
122
+ raise if @definition.raise_errors?
114
123
  end
115
124
 
116
125
  def store!
117
126
  if remove?
118
127
  delete!
119
- write(:id, nil)
128
+ write(:id, nil, true)
120
129
  elsif cache_id
121
130
  file = store.upload(get)
122
131
  delete!
123
- write(:id, file.id)
132
+ write(:id, file.id, true)
124
133
  end
125
134
  write_metadata
126
135
  @metadata = {}
@@ -132,14 +141,6 @@ module Refile
132
141
  @metadata = {}
133
142
  end
134
143
 
135
- def accept
136
- if valid_content_types
137
- valid_content_types.join(",")
138
- elsif valid_extensions
139
- valid_extensions.map { |e| ".#{e}" }.join(",")
140
- end
141
- end
142
-
143
144
  def remove?
144
145
  remove and remove != "" and remove !~ /\A0|false$\z/
145
146
  end
@@ -148,29 +149,27 @@ module Refile
148
149
  not @metadata.empty?
149
150
  end
150
151
 
151
- def valid?
152
- @errors = []
153
- @errors << :invalid_extension if valid_extensions and not valid_extensions.include?(extension)
154
- @errors << :invalid_content_type if valid_content_types and not valid_content_types.include?(content_type)
155
- @errors << :too_large if cache.max_size and size and size >= cache.max_size
156
- @errors.empty?
157
- end
158
-
159
152
  def data
160
153
  @metadata if valid?
161
154
  end
162
155
 
156
+ def valid?
157
+ @errors = @definition.validate(self)
158
+ @errors.empty?
159
+ end
160
+
163
161
  private
164
162
 
165
- def read(column)
163
+ def read(column, strict = false)
166
164
  m = "#{name}_#{column}"
167
- value ||= record.send(m) if record.respond_to?(m)
165
+ value ||= record.send(m) if strict or record.respond_to?(m)
168
166
  value
169
167
  end
170
168
 
171
- def write(column, value)
169
+ def write(column, value, strict = false)
170
+ return if record.frozen?
172
171
  m = "#{name}_#{column}="
173
- record.send(m, value) if record.respond_to?(m) and not record.frozen?
172
+ record.send(m, value) if strict or record.respond_to?(m)
174
173
  end
175
174
 
176
175
  def write_metadata
@@ -17,10 +17,11 @@ module Refile
17
17
  # - `remove_image=`
18
18
  # - `remote_image_url`
19
19
  # - `remote_image_url=`
20
+ # - `image_url`
20
21
  #
21
22
  # @example
22
23
  # class User
23
- # extends Refile::Attachment
24
+ # extend Refile::Attachment
24
25
  #
25
26
  # attachment :image
26
27
  # attr_accessor :image_id
@@ -34,24 +35,30 @@ module Refile
34
35
  # @param [String, Array<String>, nil] extension Limit the uploaded file to the given extension or list of extensions
35
36
  # @param [String, Array<String>, nil] content_type Limit the uploaded file to the given content type or list of content types
36
37
  # @return [void]
37
- # @ignore
38
- # rubocop:disable Metrics/MethodLength
39
38
  def attachment(name, cache: :cache, store: :store, raise_errors: true, type: nil, extension: nil, content_type: nil)
39
+ definition = AttachmentDefinition.new(name,
40
+ cache: cache,
41
+ store: store,
42
+ raise_errors: raise_errors,
43
+ type: type,
44
+ extension: extension,
45
+ content_type: content_type
46
+ )
47
+
48
+ define_singleton_method :"#{name}_attachment_definition" do
49
+ definition
50
+ end
51
+
40
52
  mod = Module.new do
41
53
  attacher = :"#{name}_attacher"
42
54
 
55
+ define_method :"#{name}_attachment_definition" do
56
+ definition
57
+ end
58
+
43
59
  define_method attacher do
44
60
  ivar = :"@#{attacher}"
45
- instance_variable_get(ivar) or begin
46
- instance_variable_set(ivar, Attacher.new(self, name,
47
- cache: cache,
48
- store: store,
49
- raise_errors: raise_errors,
50
- type: type,
51
- extension: extension,
52
- content_type: content_type
53
- ))
54
- end
61
+ instance_variable_get(ivar) or instance_variable_set(ivar, Attacher.new(definition, self))
55
62
  end
56
63
 
57
64
  define_method "#{name}=" do |value|
@@ -77,6 +84,14 @@ module Refile
77
84
  define_method "remote_#{name}_url" do
78
85
  end
79
86
 
87
+ define_method "#{name}_url" do |*args|
88
+ Refile.attachment_url(self, name, *args)
89
+ end
90
+
91
+ define_method "#{name}_data" do
92
+ send(attacher).data
93
+ end
94
+
80
95
  define_singleton_method("to_s") { "Refile::Attachment(#{name})" }
81
96
  define_singleton_method("inspect") { "Refile::Attachment(#{name})" }
82
97
  end
@@ -5,6 +5,7 @@ module Refile
5
5
 
6
6
  # Attachment method which hooks into ActiveRecord models
7
7
  #
8
+ # @return [void]
8
9
  # @see Refile::Attachment#attachment
9
10
  def attachment(name, raise_errors: false, **options)
10
11
  super
@@ -15,10 +16,27 @@ module Refile
15
16
  if send(attacher).present?
16
17
  send(attacher).valid?
17
18
  errors = send(attacher).errors
18
- self.errors.add(name, *errors) unless errors.empty?
19
+ errors.each do |error|
20
+ self.errors.add(name, error)
21
+ end
19
22
  end
20
23
  end
21
24
 
25
+ define_method "#{name}=" do |value|
26
+ send("#{name}_id_will_change!")
27
+ super(value)
28
+ end
29
+
30
+ define_method "remove_#{name}=" do |value|
31
+ send("#{name}_id_will_change!")
32
+ super(value)
33
+ end
34
+
35
+ define_method "remote_#{name}_url=" do |value|
36
+ send("#{name}_id_will_change!")
37
+ super(value)
38
+ end
39
+
22
40
  before_save do
23
41
  send(attacher).store!
24
42
  end
@@ -27,6 +45,77 @@ module Refile
27
45
  send(attacher).delete!
28
46
  end
29
47
  end
48
+
49
+ # Macro which generates accessors for assigning multiple attachments at
50
+ # once. This is primarily useful together with multiple file uploads.
51
+ #
52
+ # The name of the generated accessors will be the name of the association
53
+ # and the name of the attachment in the associated model. So if a `Post`
54
+ # accepts attachments for `images`, and the attachment in the `Image`
55
+ # model is named `file`, then the accessors will be named `images_files`.
56
+ #
57
+ # @example in model
58
+ # class Post
59
+ # has_many :images, dependent: :destroy
60
+ # accepts_attachments_for :images
61
+ # end
62
+ #
63
+ # @example in associated model
64
+ # class Image
65
+ # attachment :image
66
+ # end
67
+ #
68
+ # @example in form
69
+ # <%= form_for @post do |form| %>
70
+ # <%= form.attachment_field :images_files, multiple: true %>
71
+ # <% end %>
72
+ #
73
+ # @param [Symbol] association_name Name of the association
74
+ # @param [Symbol] attachment Name of the attachment in the associated model
75
+ # @param [Symbol] append If true, new files are appended instead of replacing the entire list of associated models.
76
+ # @return [void]
77
+ def accepts_attachments_for(association_name, attachment: :file, append: false)
78
+ association = reflect_on_association(association_name)
79
+ name = "#{association_name}_#{attachment.to_s.pluralize}"
80
+
81
+ mod = Module.new do
82
+ define_method :"#{name}_attachment_definition" do
83
+ association.klass.send("#{attachment}_attachment_definition")
84
+ end
85
+
86
+ define_method :"#{name}_data" do
87
+ if send(association_name).all? { |record| record.send("#{attachment}_attacher").valid? }
88
+ send(association_name).map(&:"#{attachment}_data").select(&:present?)
89
+ end
90
+ end
91
+
92
+ define_method :"#{name}" do
93
+ send(association_name).map(&attachment)
94
+ end
95
+
96
+ define_method :"#{name}=" do |files|
97
+ cache, files = files.partition { |file| file.is_a?(String) }
98
+
99
+ cache = Refile.parse_json(cache.first)
100
+
101
+ if not append and (files.present? or cache.present?)
102
+ send("#{association_name}=", [])
103
+ end
104
+
105
+ if files.empty? and cache.present?
106
+ cache.select(&:present?).each do |file|
107
+ send(association_name).build(attachment => file.to_json)
108
+ end
109
+ else
110
+ files.select(&:present?).each do |file|
111
+ send(association_name).build(attachment => file)
112
+ end
113
+ end
114
+ end
115
+ end
116
+
117
+ include mod
118
+ end
30
119
  end
31
120
  end
32
121
  end
@@ -0,0 +1,47 @@
1
+ module Refile
2
+ # @api private
3
+ class AttachmentDefinition
4
+ attr_reader :record, :name, :cache, :store, :options, :type, :valid_extensions, :valid_content_types
5
+ attr_accessor :remove
6
+
7
+ def initialize(name, cache:, store:, raise_errors: true, type: nil, extension: nil, content_type: nil)
8
+ @name = name
9
+ @raise_errors = raise_errors
10
+ @cache_name = cache
11
+ @store_name = store
12
+ @type = type
13
+ @valid_extensions = [extension].flatten if extension
14
+ @valid_content_types = [content_type].flatten if content_type
15
+ @valid_content_types ||= Refile.types.fetch(type).content_type if type
16
+ end
17
+
18
+ def cache
19
+ Refile.backends.fetch(@cache_name.to_s)
20
+ end
21
+
22
+ def store
23
+ Refile.backends.fetch(@store_name.to_s)
24
+ end
25
+
26
+ def accept
27
+ if valid_content_types
28
+ valid_content_types.join(",")
29
+ elsif valid_extensions
30
+ valid_extensions.map { |e| ".#{e}" }.join(",")
31
+ end
32
+ end
33
+
34
+ def raise_errors?
35
+ @raise_errors
36
+ end
37
+
38
+ def validate(attacher)
39
+ errors = []
40
+ extension_included = valid_extensions && valid_extensions.map(&:downcase).include?(attacher.extension.to_s.downcase)
41
+ errors << :invalid_extension if valid_extensions and not extension_included
42
+ errors << :invalid_content_type if valid_content_types and not valid_content_types.include?(attacher.content_type)
43
+ errors << :too_large if cache.max_size and attacher.size and attacher.size >= cache.max_size
44
+ errors
45
+ end
46
+ end
47
+ end
@@ -1,147 +1 @@
1
- require "aws-sdk"
2
- require "open-uri"
3
-
4
- module Refile
5
- module Backend
6
- # A refile backend which stores files in Amazon S3
7
- #
8
- # @example
9
- # backend = Refile::Backend::S3.new(
10
- # access_key_id: "xyz",
11
- # secret_access_key: "abcd1234",
12
- # bucket: "my-bucket",
13
- # prefix: "files"
14
- # )
15
- # file = backend.upload(StringIO.new("hello"))
16
- # backend.read(file.id) # => "hello"
17
- class S3
18
- extend Refile::BackendMacros
19
-
20
- attr_reader :access_key_id, :max_size
21
-
22
- # Sets up an S3 backend with the given credentials.
23
- #
24
- # @param [String] access_key_id
25
- # @param [String] secret_access_key
26
- # @param [String] bucket The name of the bucket where files will be stored
27
- # @param [String] prefix A prefix to add to all files. Prefixes on S3 are kind of like folders.
28
- # @param [Integer, nil] max_size The maximum size of an uploaded file
29
- # @param [#hash] hasher A hasher which is used to generate ids from files
30
- # @param [Hash] s3_options Additional options to initialize S3 with
31
- # @see http://docs.aws.amazon.com/AWSRubySDK/latest/AWS/Core/Configuration.html
32
- # @see http://docs.aws.amazon.com/AWSRubySDK/latest/AWS/S3.html
33
- def initialize(access_key_id:, secret_access_key:, bucket:, max_size: nil, prefix: nil, hasher: Refile::RandomHasher.new, **s3_options)
34
- @access_key_id = access_key_id
35
- @secret_access_key = secret_access_key
36
- @s3_options = { access_key_id: access_key_id, secret_access_key: secret_access_key }.merge s3_options
37
- @s3 = AWS::S3.new @s3_options
38
- @bucket_name = bucket
39
- @bucket = @s3.buckets[@bucket_name]
40
- @hasher = hasher
41
- @prefix = prefix
42
- @max_size = max_size
43
- end
44
-
45
- # Upload a file into this backend
46
- #
47
- # @param [IO] uploadable An uploadable IO-like object.
48
- # @return [Refile::File] The uploaded file
49
- verify_uploadable def upload(uploadable)
50
- id = @hasher.hash(uploadable)
51
-
52
- if uploadable.is_a?(Refile::File) and uploadable.backend.is_a?(S3) and uploadable.backend.access_key_id == access_key_id
53
- uploadable.backend.object(uploadable.id).copy_to(object(id))
54
- else
55
- object(id).write(uploadable, content_length: uploadable.size)
56
- end
57
-
58
- Refile::File.new(self, id)
59
- end
60
-
61
- # Get a file from this backend.
62
- #
63
- # Note that this method will always return a {Refile::File} object, even
64
- # if a file with the given id does not exist in this backend. Use
65
- # {FileSystem#exists?} to check if the file actually exists.
66
- #
67
- # @param [Sring] id The id of the file
68
- # @return [Refile::File] The retrieved file
69
- verify_id def get(id)
70
- Refile::File.new(self, id)
71
- end
72
-
73
- # Delete a file from this backend
74
- #
75
- # @param [Sring] id The id of the file
76
- # @return [void]
77
- verify_id def delete(id)
78
- object(id).delete
79
- end
80
-
81
- # Return an IO object for the uploaded file which can be used to read its
82
- # content.
83
- #
84
- # @param [Sring] id The id of the file
85
- # @return [IO] An IO object containing the file contents
86
- verify_id def open(id)
87
- Kernel.open(object(id).url_for(:read))
88
- end
89
-
90
- # Return the entire contents of the uploaded file as a String.
91
- #
92
- # @param [String] id The id of the file
93
- # @return [String] The file's contents
94
- verify_id def read(id)
95
- object(id).read
96
- rescue AWS::S3::Errors::NoSuchKey
97
- nil
98
- end
99
-
100
- # Return the size in bytes of the uploaded file.
101
- #
102
- # @param [Sring] id The id of the file
103
- # @return [Integer] The file's size
104
- verify_id def size(id)
105
- object(id).content_length
106
- rescue AWS::S3::Errors::NoSuchKey
107
- nil
108
- end
109
-
110
- # Return whether the file with the given id exists in this backend.
111
- #
112
- # @param [Sring] id The id of the file
113
- # @return [Boolean]
114
- verify_id def exists?(id)
115
- object(id).exists?
116
- end
117
-
118
- # Remove all files in this backend. You must confirm the deletion by
119
- # passing the symbol `:confirm` as an argument to this method.
120
- #
121
- # @example
122
- # backend.clear!(:confirm)
123
- # @raise [Refile::Confirm] Unless the `:confirm` symbol has been passed.
124
- # @param [:confirm] confirm Pass the symbol `:confirm` to confirm deletion.
125
- # @return [void]
126
- def clear!(confirm = nil)
127
- raise Refile::Confirm unless confirm == :confirm
128
- @bucket.objects.with_prefix(@prefix).delete_all
129
- end
130
-
131
- # Return a presign signature which can be used to upload a file into this
132
- # backend directly.
133
- #
134
- # @return [Refile::Signature]
135
- def presign
136
- id = RandomHasher.new.hash
137
- signature = @bucket.presigned_post(key: [*@prefix, id].join("/"))
138
- signature.where(content_length: @max_size) if @max_size
139
- Signature.new(as: "file", id: id, url: signature.url.to_s, fields: signature.fields)
140
- end
141
-
142
- verify_id def object(id)
143
- @bucket.objects[[*@prefix, id].join("/")]
144
- end
145
- end
146
- end
147
- end
1
+ raise "[Refile] the S3 backend has been extracted into a separate gem, see https://github.com/refile/refile-s3"