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.
- checksums.yaml +4 -4
- data/.rubocop.yml +8 -2
- data/.travis.yml +2 -0
- data/.yardopts +1 -0
- data/CONTRIBUTING.md +33 -0
- data/History.md +9 -0
- data/README.md +67 -16
- data/app/assets/javascripts/refile.js +19 -17
- data/lib/refile.rb +36 -6
- data/lib/refile/app.rb +15 -12
- data/lib/refile/attacher.rb +119 -49
- data/lib/refile/attachment.rb +29 -16
- data/lib/refile/attachment/active_record.rb +5 -2
- data/lib/refile/backend/file_system.rb +61 -1
- data/lib/refile/backend/s3.rb +66 -0
- data/lib/refile/custom_logger.rb +46 -0
- data/lib/refile/file.rb +32 -1
- data/lib/refile/image_processing.rb +72 -3
- data/lib/refile/rails.rb +2 -8
- data/lib/refile/rails/attachment_helper.rb +77 -19
- data/lib/refile/signature.rb +16 -1
- data/lib/refile/type.rb +28 -0
- data/lib/refile/version.rb +1 -1
- data/refile.gemspec +1 -1
- data/spec/refile/active_record_helper.rb +27 -0
- data/spec/refile/attachment/active_record_spec.rb +92 -0
- data/spec/refile/attachment_spec.rb +153 -28
- data/spec/refile/custom_logger_spec.rb +22 -0
- data/spec/refile/features/direct_upload_spec.rb +19 -2
- data/spec/refile/features/normal_upload_spec.rb +41 -11
- data/spec/refile/features/presigned_upload_spec.rb +1 -2
- data/spec/refile/rails/attachment_helper_spec.rb +1 -1
- data/spec/refile/test_app.rb +16 -14
- data/spec/refile/test_app/app/controllers/direct_posts_controller.rb +1 -1
- data/spec/refile/test_app/app/controllers/normal_posts_controller.rb +1 -1
- data/spec/refile/test_app/app/controllers/presigned_posts_controller.rb +1 -1
- data/spec/refile/test_app/app/views/direct_posts/new.html.erb +4 -0
- data/spec/refile/test_app/app/views/normal_posts/show.html.erb +5 -3
- 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
|
-
|
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
|
data/lib/refile/attacher.rb
CHANGED
@@ -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, :
|
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
|
-
@
|
15
|
-
@
|
16
|
-
@
|
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
|
-
|
26
|
+
Presence[read(:id)]
|
22
27
|
end
|
23
28
|
|
24
|
-
def
|
25
|
-
|
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
|
63
|
+
if cache_id
|
30
64
|
cache.get(cache_id)
|
31
|
-
elsif id
|
65
|
+
elsif id
|
32
66
|
store.get(id)
|
33
67
|
end
|
34
68
|
end
|
35
69
|
|
36
|
-
def
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
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
|
-
|
46
|
-
|
47
|
-
|
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
|
-
|
55
|
-
|
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
|
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
|
-
|
70
|
-
|
122
|
+
write(:id, nil)
|
123
|
+
elsif cache_id
|
124
|
+
file = store.upload(get)
|
71
125
|
delete!
|
72
|
-
|
126
|
+
write(:id, file.id)
|
73
127
|
end
|
128
|
+
write_metadata
|
129
|
+
@metadata = {}
|
74
130
|
end
|
75
131
|
|
76
132
|
def delete!
|
77
|
-
if
|
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
|
-
|
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
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
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
|
101
|
-
|
102
|
-
|
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
|
106
|
-
|
107
|
-
|
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
|
112
|
-
|
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
|
data/lib/refile/attachment.rb
CHANGED
@@ -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
|
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
|
13
|
-
# @param [#to_s] store Name of a backend in
|
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 [
|
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
|
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 |
|
40
|
-
send(attacher).
|
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
|
-
|
16
|
-
|
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
|
-
|
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
|