refile 0.4.2 → 0.5.0

Sign up to get free protection for your applications and to get access to all the features.

Potentially problematic release.


This version of refile might be problematic. Click here for more details.

Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +8 -2
  3. data/.travis.yml +2 -0
  4. data/.yardopts +1 -0
  5. data/CONTRIBUTING.md +33 -0
  6. data/History.md +9 -0
  7. data/README.md +67 -16
  8. data/app/assets/javascripts/refile.js +19 -17
  9. data/lib/refile.rb +36 -6
  10. data/lib/refile/app.rb +15 -12
  11. data/lib/refile/attacher.rb +119 -49
  12. data/lib/refile/attachment.rb +29 -16
  13. data/lib/refile/attachment/active_record.rb +5 -2
  14. data/lib/refile/backend/file_system.rb +61 -1
  15. data/lib/refile/backend/s3.rb +66 -0
  16. data/lib/refile/custom_logger.rb +46 -0
  17. data/lib/refile/file.rb +32 -1
  18. data/lib/refile/image_processing.rb +72 -3
  19. data/lib/refile/rails.rb +2 -8
  20. data/lib/refile/rails/attachment_helper.rb +77 -19
  21. data/lib/refile/signature.rb +16 -1
  22. data/lib/refile/type.rb +28 -0
  23. data/lib/refile/version.rb +1 -1
  24. data/refile.gemspec +1 -1
  25. data/spec/refile/active_record_helper.rb +27 -0
  26. data/spec/refile/attachment/active_record_spec.rb +92 -0
  27. data/spec/refile/attachment_spec.rb +153 -28
  28. data/spec/refile/custom_logger_spec.rb +22 -0
  29. data/spec/refile/features/direct_upload_spec.rb +19 -2
  30. data/spec/refile/features/normal_upload_spec.rb +41 -11
  31. data/spec/refile/features/presigned_upload_spec.rb +1 -2
  32. data/spec/refile/rails/attachment_helper_spec.rb +1 -1
  33. data/spec/refile/test_app.rb +16 -14
  34. data/spec/refile/test_app/app/controllers/direct_posts_controller.rb +1 -1
  35. data/spec/refile/test_app/app/controllers/normal_posts_controller.rb +1 -1
  36. data/spec/refile/test_app/app/controllers/presigned_posts_controller.rb +1 -1
  37. data/spec/refile/test_app/app/views/direct_posts/new.html.erb +4 -0
  38. data/spec/refile/test_app/app/views/normal_posts/show.html.erb +5 -3
  39. metadata +27 -17
data/lib/refile/app.rb CHANGED
@@ -3,6 +3,17 @@ require "sinatra/base"
3
3
  require "tempfile"
4
4
 
5
5
  module Refile
6
+ # A Rack application which can be mounted or run on its own.
7
+ #
8
+ # @example mounted in Rails
9
+ # Rails.application.routes.draw do
10
+ # mount Refile::App.new, at: "attachments", as: :refile_app
11
+ # end
12
+ #
13
+ # @example as standalone app
14
+ # require "refile"
15
+ #
16
+ # run Refile::App.new
6
17
  class App < Sinatra::Base
7
18
  configure do
8
19
  set :show_exceptions, false
@@ -10,10 +21,10 @@ module Refile
10
21
  set :sessions, false
11
22
  set :logging, false
12
23
  set :dump_errors, false
24
+ use CustomLogger, "Refile::App", proc { Refile.logger }
13
25
  end
14
26
 
15
27
  before do
16
- content_type ::File.extname(request.path), default: "application/octet-stream"
17
28
  if Refile.allow_origin
18
29
  response["Access-Control-Allow-Origin"] = Refile.allow_origin
19
30
  response["Access-Control-Allow-Headers"] = request.env["HTTP_ACCESS_CONTROL_REQUEST_HEADERS"].to_s
@@ -22,27 +33,22 @@ module Refile
22
33
  end
23
34
 
24
35
  get "/:backend/:id/:filename" do
25
- set_expires_header
26
36
  stream_file file
27
37
  end
28
38
 
29
39
  get "/:backend/:processor/:id/:file_basename.:extension" do
30
- set_expires_header
31
40
  stream_file processor.call(file, format: params[:extension])
32
41
  end
33
42
 
34
43
  get "/:backend/:processor/:id/:filename" do
35
- set_expires_header
36
44
  stream_file processor.call(file)
37
45
  end
38
46
 
39
47
  get "/:backend/:processor/*/:id/:file_basename.:extension" do
40
- set_expires_header
41
48
  stream_file processor.call(file, *params[:splat].first.split("/"), format: params[:extension])
42
49
  end
43
50
 
44
51
  get "/:backend/:processor/*/:id/:filename" do
45
- set_expires_header
46
52
  stream_file processor.call(file, *params[:splat].first.split("/"))
47
53
  end
48
54
 
@@ -51,8 +57,7 @@ module Refile
51
57
  end
52
58
 
53
59
  post "/:backend" do
54
- backend = Refile.backends[params[:backend]]
55
- halt 404 unless backend && Refile.direct_upload.include?(params[:backend])
60
+ halt 404 unless Refile.direct_upload.include?(params[:backend])
56
61
  tempfile = request.params.fetch("file").fetch(:tempfile)
57
62
  file = backend.upload(tempfile)
58
63
  content_type :json
@@ -75,15 +80,13 @@ module Refile
75
80
 
76
81
  private
77
82
 
78
- def set_expires_header
79
- expires Refile.content_max_age, :public, :must_revalidate
80
- end
81
-
82
83
  def logger
83
84
  Refile.logger
84
85
  end
85
86
 
86
87
  def stream_file(file)
88
+ expires Refile.content_max_age, :public, :must_revalidate
89
+
87
90
  if file.respond_to?(:path)
88
91
  path = file.path
89
92
  else
@@ -1,9 +1,13 @@
1
+ require "open-uri"
2
+
1
3
  module Refile
2
4
  # @api private
3
5
  class Attacher
4
- attr_reader :record, :name, :cache, :store, :cache_id, :options, :errors, :type, :extensions, :content_types
6
+ attr_reader :record, :name, :cache, :store, :options, :errors, :type, :valid_extensions, :valid_content_types
5
7
  attr_accessor :remove
6
8
 
9
+ Presence = ->(val) { val if val != "" }
10
+
7
11
  def initialize(record, name, cache:, store:, raise_errors: true, type: nil, extension: nil, content_type: nil)
8
12
  @record = record
9
13
  @name = name
@@ -11,105 +15,171 @@ module Refile
11
15
  @cache = Refile.backends.fetch(cache.to_s)
12
16
  @store = Refile.backends.fetch(store.to_s)
13
17
  @type = type
14
- @extensions = [extension].flatten if extension
15
- @content_types = [content_type].flatten if content_type
16
- @content_types ||= %w[image/jpeg image/gif image/png] if type == :image
18
+ @valid_extensions = [extension].flatten if extension
19
+ @valid_content_types = [content_type].flatten if content_type
20
+ @valid_content_types ||= Refile.types.fetch(type).content_type if type
17
21
  @errors = []
22
+ @metadata = {}
18
23
  end
19
24
 
20
25
  def id
21
- record.send(:"#{name}_id")
26
+ Presence[read(:id)]
22
27
  end
23
28
 
24
- def id=(id)
25
- record.send(:"#{name}_id=", id) unless record.frozen?
29
+ def size
30
+ Presence[@metadata[:size] || read(:size)]
31
+ end
32
+
33
+ def filename
34
+ Presence[@metadata[:filename] || read(:filename)]
35
+ end
36
+
37
+ def content_type
38
+ Presence[@metadata[:content_type] || read(:content_type)]
39
+ end
40
+
41
+ def cache_id
42
+ Presence[@metadata[:id]]
43
+ end
44
+
45
+ def basename
46
+ if filename and extension
47
+ ::File.basename(filename, "." << extension)
48
+ else
49
+ filename
50
+ end
51
+ end
52
+
53
+ def extension
54
+ if filename
55
+ Presence[::File.extname(filename).sub(/^\./, "")]
56
+ elsif content_type
57
+ type = MIME::Types[content_type][0]
58
+ type.extensions[0] if type
59
+ end
26
60
  end
27
61
 
28
62
  def get
29
- if cached?
63
+ if cache_id
30
64
  cache.get(cache_id)
31
- elsif id and not id == ""
65
+ elsif id
32
66
  store.get(id)
33
67
  end
34
68
  end
35
69
 
36
- def valid?(uploadable)
37
- @errors = []
38
- @errors << :invalid_extension if @extensions and not valid_extension?(uploadable)
39
- @errors << :invalid_content_type if @content_types and not valid_content_type?(uploadable)
40
- @errors << :too_large if cache.max_size and uploadable.size >= cache.max_size
41
- @errors.empty?
70
+ def set(value)
71
+ if value.is_a?(String)
72
+ retrieve!(value)
73
+ else
74
+ cache!(value)
75
+ end
76
+ end
77
+
78
+ def retrieve!(value)
79
+ @metadata = JSON.parse(value, symbolize_names: true) || {}
80
+ write_metadata if cache_id
81
+ rescue JSON::ParserError
42
82
  end
43
83
 
44
84
  def cache!(uploadable)
45
- if valid?(uploadable)
46
- @cache_file = cache.upload(uploadable)
47
- @cache_id = @cache_file.id
85
+ @metadata = {
86
+ size: uploadable.size,
87
+ content_type: Refile.extract_content_type(uploadable),
88
+ filename: Refile.extract_filename(uploadable)
89
+ }
90
+ if valid?
91
+ @metadata[:id] = cache.upload(uploadable).id
92
+ write_metadata
48
93
  elsif @raise_errors
49
94
  raise Refile::Invalid, @errors.join(", ")
50
95
  end
51
96
  end
52
97
 
53
98
  def download(url)
54
- if url and not url == ""
55
- cache!(RestClient::Request.new(method: :get, url: url, raw_response: true).execute.file)
99
+ unless url.to_s.empty?
100
+ file = open(url)
101
+ @metadata = {
102
+ size: file.meta["content-length"].to_i,
103
+ filename: ::File.basename(file.base_uri.path),
104
+ content_type: file.meta["content-type"]
105
+ }
106
+ if valid?
107
+ @metadata[:id] = cache.upload(file).id
108
+ write_metadata
109
+ elsif @raise_errors
110
+ raise Refile::Invalid, @errors.join(", ")
111
+ end
56
112
  end
57
- rescue RestClient::Exception
113
+ rescue OpenURI::HTTPError, RuntimeError => error
114
+ raise if error.is_a?(RuntimeError) and error.message !~ /redirection loop/
58
115
  @errors = [:download_failed]
59
116
  raise if @raise_errors
60
117
  end
61
118
 
62
- def cache_id=(id)
63
- @cache_id = id unless @cache_file
64
- end
65
-
66
119
  def store!
67
120
  if remove?
68
121
  delete!
69
- elsif cached?
70
- file = store.upload(cache.get(cache_id))
122
+ write(:id, nil)
123
+ elsif cache_id
124
+ file = store.upload(get)
71
125
  delete!
72
- self.id = file.id
126
+ write(:id, file.id)
73
127
  end
128
+ write_metadata
129
+ @metadata = {}
74
130
  end
75
131
 
76
132
  def delete!
77
- if cached?
78
- cache.delete(cache_id)
79
- @cache_id = nil
80
- @cache_file = nil
81
- end
133
+ cache.delete(cache_id) if cache_id
82
134
  store.delete(id) if id
83
- self.id = nil
135
+ @metadata = {}
136
+ end
137
+
138
+ def accept
139
+ if valid_content_types
140
+ valid_content_types.join(",")
141
+ elsif valid_extensions
142
+ valid_extensions.map { |e| ".#{e}" }.join(",")
143
+ end
84
144
  end
85
145
 
86
146
  def remove?
87
147
  remove and remove != "" and remove !~ /\A0|false$\z/
88
148
  end
89
149
 
90
- def accept
91
- if content_types
92
- content_types.join(",")
93
- elsif extensions
94
- extensions.map { |e| ".#{e}" }.join(",")
95
- end
150
+ def present?
151
+ id or not @metadata.empty?
152
+ end
153
+
154
+ def valid?
155
+ @errors = []
156
+ @errors << :invalid_extension if valid_extensions and not valid_extensions.include?(extension)
157
+ @errors << :invalid_content_type if valid_content_types and not valid_content_types.include?(content_type)
158
+ @errors << :too_large if cache.max_size and size and size >= cache.max_size
159
+ @errors.empty?
160
+ end
161
+
162
+ def data
163
+ @metadata if valid?
96
164
  end
97
165
 
98
166
  private
99
167
 
100
- def valid_content_type?(uploadable)
101
- content_type = Refile.extract_content_type(uploadable) or return false
102
- @content_types.include?(content_type)
168
+ def read(column)
169
+ m = "#{name}_#{column}"
170
+ value ||= record.send(m) if record.respond_to?(m)
171
+ value
103
172
  end
104
173
 
105
- def valid_extension?(uploadable)
106
- filename = Refile.extract_filename(uploadable) or return false
107
- extension = ::File.extname(filename).sub(/^\./, "")
108
- @extensions.include?(extension)
174
+ def write(column, value)
175
+ m = "#{name}_#{column}="
176
+ record.send(m, value) if record.respond_to?(m) and not record.frozen?
109
177
  end
110
178
 
111
- def cached?
112
- cache_id and not cache_id == ""
179
+ def write_metadata
180
+ write(:size, size)
181
+ write(:content_type, content_type)
182
+ write(:filename, filename)
113
183
  end
114
184
  end
115
185
  end
@@ -4,20 +4,38 @@ module Refile
4
4
  # possible to upload and retrieve previously uploaded files through the
5
5
  # generated accessors.
6
6
  #
7
- # The +raise_errors+ option controls whether assigning an invalid file
7
+ # The `raise_errors` option controls whether assigning an invalid file
8
8
  # should immediately raise an error, or save the error and defer handling
9
9
  # it until later.
10
10
  #
11
+ # Given a record with an attachment named `image`, the following methods
12
+ # will be added:
13
+ #
14
+ # - `image`
15
+ # - `image=`
16
+ # - `remove_image`
17
+ # - `remove_image=`
18
+ # - `remote_image_url`
19
+ # - `remote_image_url=`
20
+ #
21
+ # @example
22
+ # class User
23
+ # extends Refile::Attachment
24
+ #
25
+ # attachment :image
26
+ # attr_accessor :image_id
27
+ # end
28
+ #
11
29
  # @param [String] name Name of the column which accessor are generated for
12
- # @param [#to_s] cache Name of a backend in +Refile.backends+ to use as transient cache
13
- # @param [#to_s] store Name of a backend in +Refile.backends+ to use as permanent store
30
+ # @param [#to_s] cache Name of a backend in {Refile.backends} to use as transient cache
31
+ # @param [#to_s] store Name of a backend in {Refile.backends} to use as permanent store
14
32
  # @param [true, false] raise_errors Whether to raise errors in case an invalid file is assigned
15
- # @param [:image, nil] type The type of file that can be uploaded, currently +:image+ is the
16
- # only valid value and restricts uploads to JPEG, PNG and GIF images
33
+ # @param [Symbol, nil] type The type of file that can be uploaded, see {Refile.types}
17
34
  # @param [String, Array<String>, nil] extension Limit the uploaded file to the given extension or list of extensions
18
35
  # @param [String, Array<String>, nil] content_type Limit the uploaded file to the given content type or list of content types
36
+ # @return [void]
19
37
  # @ignore
20
- # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
38
+ # rubocop:disable Metrics/MethodLength
21
39
  def attachment(name, cache: :cache, store: :store, raise_errors: true, type: nil, extension: nil, content_type: nil)
22
40
  mod = Module.new do
23
41
  attacher = :"#{name}_attacher"
@@ -36,22 +54,14 @@ module Refile
36
54
  end
37
55
  end
38
56
 
39
- define_method "#{name}=" do |uploadable|
40
- send(attacher).cache!(uploadable)
57
+ define_method "#{name}=" do |value|
58
+ send(attacher).set(value)
41
59
  end
42
60
 
43
61
  define_method name do
44
62
  send(attacher).get
45
63
  end
46
64
 
47
- define_method "#{name}_cache_id=" do |cache_id|
48
- send(attacher).cache_id = cache_id
49
- end
50
-
51
- define_method "#{name}_cache_id" do
52
- send(attacher).cache_id
53
- end
54
-
55
65
  define_method "remove_#{name}=" do |remove|
56
66
  send(attacher).remove = remove
57
67
  end
@@ -66,6 +76,9 @@ module Refile
66
76
 
67
77
  define_method "remote_#{name}_url" do
68
78
  end
79
+
80
+ define_singleton_method("to_s") { "Refile::Attachment(#{name})" }
81
+ define_singleton_method("inspect") { "Refile::Attachment(#{name})" }
69
82
  end
70
83
 
71
84
  include mod
@@ -12,8 +12,11 @@ module Refile
12
12
  attacher = "#{name}_attacher"
13
13
 
14
14
  validate do
15
- errors = send(attacher).errors
16
- self.errors.add(name, *errors) unless errors.empty?
15
+ if send(attacher).present?
16
+ send(attacher).valid?
17
+ errors = send(attacher).errors
18
+ self.errors.add(name, *errors) unless errors.empty?
19
+ end
17
20
  end
18
21
 
19
22
  before_save do
@@ -1,8 +1,23 @@
1
1
  module Refile
2
2
  module Backend
3
+ # A backend which stores uploaded files in the local filesystem
4
+ #
5
+ # @example
6
+ # backend = Refile::Backend::FileSystem.new("some/path")
7
+ # file = backend.upload(StringIO.new("hello"))
8
+ # backend.read(file.id) # => "hello"
3
9
  class FileSystem
4
- attr_reader :directory, :max_size
10
+ # @return [String] the directory where files are stored
11
+ attr_reader :directory
5
12
 
13
+ # @return [String] the maximum size of files stored in this backend
14
+ attr_reader :max_size
15
+
16
+ # Creates the given directory if it doesn't exist.
17
+ #
18
+ # @param [String] directory The path to a directory where files should be stored
19
+ # @param [Integer, nil] max_size The maximum size of an uploaded file
20
+ # @param [#hash] hasher A hasher which is used to generate ids from files
6
21
  def initialize(directory, max_size: nil, hasher: Refile::RandomHasher.new)
7
22
  @hasher = hasher
8
23
  @directory = directory
@@ -11,6 +26,10 @@ module Refile
11
26
  FileUtils.mkdir_p(@directory)
12
27
  end
13
28
 
29
+ # Upload a file into this backend
30
+ #
31
+ # @param [IO] uploadable An uploadable IO-like object.
32
+ # @return [Refile::File] The uploaded file
14
33
  def upload(uploadable)
15
34
  Refile.verify_uploadable(uploadable, @max_size)
16
35
 
@@ -20,36 +39,77 @@ module Refile
20
39
  Refile::File.new(self, id)
21
40
  end
22
41
 
42
+ # Get a file from this backend.
43
+ #
44
+ # Note that this method will always return a {Refile::File} object, even
45
+ # if a file with the given id does not exist in this backend. Use
46
+ # {FileSystem#exists?} to check if the file actually exists.
47
+ #
48
+ # @param [Sring] id The id of the file
49
+ # @return [Refile::File] The retrieved file
23
50
  def get(id)
24
51
  Refile::File.new(self, id)
25
52
  end
26
53
 
54
+ # Delete a file from this backend
55
+ #
56
+ # @param [Sring] id The id of the file
57
+ # @return [void]
27
58
  def delete(id)
28
59
  FileUtils.rm(path(id)) if exists?(id)
29
60
  end
30
61
 
62
+ # Return an IO object for the uploaded file which can be used to read its
63
+ # content.
64
+ #
65
+ # @param [Sring] id The id of the file
66
+ # @return [IO] An IO object containing the file contents
31
67
  def open(id)
32
68
  ::File.open(path(id), "rb")
33
69
  end
34
70
 
71
+ # Return the entire contents of the uploaded file as a String.
72
+ #
73
+ # @param [Sring] id The id of the file
74
+ # @return [String] The file's contents
35
75
  def read(id)
36
76
  ::File.read(path(id)) if exists?(id)
37
77
  end
38
78
 
79
+ # Return the size in bytes of the uploaded file.
80
+ #
81
+ # @param [Sring] id The id of the file
82
+ # @return [Integer] The file's size
39
83
  def size(id)
40
84
  ::File.size(path(id)) if exists?(id)
41
85
  end
42
86
 
87
+ # Return whether the file with the given id exists in this backend.
88
+ #
89
+ # @param [Sring] id The id of the file
90
+ # @return [Boolean]
43
91
  def exists?(id)
44
92
  ::File.exist?(path(id))
45
93
  end
46
94
 
95
+ # Remove all files in this backend. You must confirm the deletion by
96
+ # passing the symbol `:confirm` as an argument to this method.
97
+ #
98
+ # @example
99
+ # backend.clear!(:confirm)
100
+ # @raise [Refile::Confirm] Unless the `:confirm` symbol has been passed.
101
+ # @param [:confirm] confirm Pass the symbol `:confirm` to confirm deletion.
102
+ # @return [void]
47
103
  def clear!(confirm = nil)
48
104
  raise Refile::Confirm unless confirm == :confirm
49
105
  FileUtils.rm_rf(@directory)
50
106
  FileUtils.mkdir_p(@directory)
51
107
  end
52
108
 
109
+ # Return the full path of the uploaded file with the given id.
110
+ #
111
+ # @param [Sring] id The id of the file
112
+ # @return [String]
53
113
  def path(id)
54
114
  ::File.join(@directory, id)
55
115
  end