leifcr-refile 0.6.3

Sign up to get free protection for your applications and to get access to all the features.
Files changed (46) hide show
  1. checksums.yaml +7 -0
  2. data/app/assets/javascripts/refile.js +125 -0
  3. data/config/locales/en.yml +10 -0
  4. data/config/routes.rb +5 -0
  5. data/lib/refile.rb +510 -0
  6. data/lib/refile/app.rb +186 -0
  7. data/lib/refile/attacher.rb +190 -0
  8. data/lib/refile/attachment.rb +108 -0
  9. data/lib/refile/attachment/active_record.rb +133 -0
  10. data/lib/refile/attachment_definition.rb +83 -0
  11. data/lib/refile/backend/file_system.rb +120 -0
  12. data/lib/refile/backend/s3.rb +1 -0
  13. data/lib/refile/backend_macros.rb +45 -0
  14. data/lib/refile/custom_logger.rb +48 -0
  15. data/lib/refile/file.rb +102 -0
  16. data/lib/refile/file_double.rb +13 -0
  17. data/lib/refile/image_processing.rb +1 -0
  18. data/lib/refile/rails.rb +54 -0
  19. data/lib/refile/rails/attachment_helper.rb +121 -0
  20. data/lib/refile/random_hasher.rb +11 -0
  21. data/lib/refile/signature.rb +36 -0
  22. data/lib/refile/simple_form.rb +17 -0
  23. data/lib/refile/type.rb +28 -0
  24. data/lib/refile/version.rb +3 -0
  25. data/spec/refile/active_record_helper.rb +35 -0
  26. data/spec/refile/app_spec.rb +424 -0
  27. data/spec/refile/attachment/active_record_spec.rb +568 -0
  28. data/spec/refile/attachment_helper_spec.rb +78 -0
  29. data/spec/refile/attachment_spec.rb +589 -0
  30. data/spec/refile/backend/file_system_spec.rb +5 -0
  31. data/spec/refile/backend_examples.rb +228 -0
  32. data/spec/refile/backend_macros_spec.rb +83 -0
  33. data/spec/refile/custom_logger_spec.rb +21 -0
  34. data/spec/refile/features/direct_upload_spec.rb +63 -0
  35. data/spec/refile/features/multiple_upload_spec.rb +122 -0
  36. data/spec/refile/features/normal_upload_spec.rb +144 -0
  37. data/spec/refile/features/presigned_upload_spec.rb +31 -0
  38. data/spec/refile/features/simple_form_spec.rb +8 -0
  39. data/spec/refile/fixtures/hello.txt +1 -0
  40. data/spec/refile/fixtures/image.jpg +0 -0
  41. data/spec/refile/fixtures/large.txt +44 -0
  42. data/spec/refile/fixtures/monkey.txt +1 -0
  43. data/spec/refile/fixtures/world.txt +1 -0
  44. data/spec/refile/spec_helper.rb +72 -0
  45. data/spec/refile_spec.rb +355 -0
  46. metadata +143 -0
@@ -0,0 +1,133 @@
1
+ module Refile
2
+ module ActiveRecord
3
+ module Attachment
4
+ include Refile::Attachment
5
+
6
+ # Attachment method which hooks into ActiveRecord models
7
+ #
8
+ # @param [true, false] destroy Whether to remove the stored file if its model is destroyed
9
+ # @return [void]
10
+ # @see Refile::Attachment#attachment
11
+ def attachment(name, raise_errors: false, destroy: true, **options)
12
+ super(name, raise_errors: raise_errors, **options)
13
+
14
+ attacher = "#{name}_attacher"
15
+
16
+ validate do
17
+ if send(attacher).present?
18
+ send(attacher).valid?
19
+ errors = send(attacher).errors
20
+ errors.each do |error|
21
+ self.errors.add(name, *error)
22
+ end
23
+ end
24
+ end
25
+
26
+ define_method "#{name}=" do |value|
27
+ send("#{name}_id_will_change!") if respond_to?("#{name}_id_will_change!")
28
+ super(value)
29
+ end
30
+
31
+ define_method "remove_#{name}=" do |value|
32
+ send("#{name}_id_will_change!")
33
+ super(value)
34
+ end
35
+
36
+ define_method "remote_#{name}_url=" do |value|
37
+ send("#{name}_id_will_change!")
38
+ super(value)
39
+ end
40
+
41
+ before_save do
42
+ send(attacher).store!
43
+ end
44
+
45
+ after_destroy do
46
+ send(attacher).delete! if destroy
47
+ end
48
+ end
49
+
50
+ # Macro which generates accessors for assigning multiple attachments at
51
+ # once. This is primarily useful together with multiple file uploads.
52
+ #
53
+ # The name of the generated accessors will be the name of the association
54
+ # and the name of the attachment in the associated model. So if a `Post`
55
+ # accepts attachments for `images`, and the attachment in the `Image`
56
+ # model is named `file`, then the accessors will be named `images_files`.
57
+ #
58
+ # @example in model
59
+ # class Post
60
+ # has_many :images, dependent: :destroy
61
+ # accepts_attachments_for :images
62
+ # end
63
+ #
64
+ # @example in associated model
65
+ # class Image
66
+ # attachment :image
67
+ # end
68
+ #
69
+ # @example in form
70
+ # <%= form_for @post do |form| %>
71
+ # <%= form.attachment_field :images_files, multiple: true %>
72
+ # <% end %>
73
+ #
74
+ # @param [Symbol] association_name Name of the association
75
+ # @param [Symbol] attachment Name of the attachment in the associated model
76
+ # @param [Symbol] append If true, new files are appended instead of replacing the entire list of associated models.
77
+ # @return [void]
78
+ def accepts_attachments_for(association_name, attachment: :file, append: false)
79
+ association = reflect_on_association(association_name)
80
+ attachment_pluralized = attachment.to_s.pluralize
81
+ name = "#{association_name}_#{attachment_pluralized}"
82
+
83
+ mod = Module.new do
84
+ define_method :"#{name}_attachment_definition" do
85
+ association.klass.send("#{attachment}_attachment_definition")
86
+ end
87
+
88
+ define_method(:method_missing) do |method|
89
+ if method == attachment_pluralized.to_sym
90
+ raise NoMethodError, "wrong association name #{method}, use like this #{name}"
91
+ else
92
+ super(method)
93
+ end
94
+ end
95
+
96
+ define_method :"#{name}_data" do
97
+ if send(association_name).all? { |record| record.send("#{attachment}_attacher").valid? }
98
+ send(association_name).map(&:"#{attachment}_data").select(&:present?)
99
+ end
100
+ end
101
+
102
+ define_method :"#{name}" do
103
+ send(association_name).map(&attachment)
104
+ end
105
+
106
+ define_method :"#{name}=" do |files|
107
+ cache, files = files.partition { |file| file.is_a?(String) }
108
+
109
+ cache = Refile.parse_json(cache.first)
110
+
111
+ if not append and (files.present? or cache.present?)
112
+ send("#{association_name}=", [])
113
+ end
114
+
115
+ if files.empty? and cache.present?
116
+ cache.select(&:present?).each do |file|
117
+ send(association_name).build(attachment => file.to_json)
118
+ end
119
+ else
120
+ files.select(&:present?).each do |file|
121
+ send(association_name).build(attachment => file)
122
+ end
123
+ end
124
+ end
125
+ end
126
+
127
+ include mod
128
+ end
129
+ end
130
+ end
131
+ end
132
+
133
+ ::ActiveRecord::Base.extend(Refile::ActiveRecord::Attachment)
@@ -0,0 +1,83 @@
1
+ module Refile
2
+ # @api private
3
+ class AttachmentDefinition
4
+ attr_reader :record, :name, :cache, :store, :options, :type, :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
+ @extension = 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 valid_extensions
39
+ return unless @extension
40
+ if @extension.is_a?(Proc)
41
+ Array(@extension.call)
42
+ else
43
+ Array(@extension)
44
+ end
45
+ end
46
+
47
+ def validate(attacher)
48
+ extension = attacher.extension.to_s.downcase
49
+ content_type = attacher.content_type.to_s.downcase
50
+ content_type = content_type.split(";").first unless content_type.empty?
51
+
52
+ errors = []
53
+ errors << extension_error_params(extension) if invalid_extension?(extension)
54
+ errors << content_type_error_params(content_type) if invalid_content_type?(content_type)
55
+ errors << :too_large if cache.max_size and attacher.size and attacher.size >= cache.max_size
56
+ errors << :zero_byte_detected if attacher.size.to_i.zero?
57
+ errors
58
+ end
59
+
60
+ private
61
+
62
+ def extension_error_params(extension)
63
+ [:invalid_extension, extension: format_param(extension), permitted: valid_extensions.to_sentence]
64
+ end
65
+
66
+ def content_type_error_params(content_type)
67
+ [:invalid_content_type, content: format_param(content_type), permitted: valid_content_types.to_sentence]
68
+ end
69
+
70
+ def invalid_extension?(extension)
71
+ extension_included = valid_extensions && valid_extensions.map(&:downcase).include?(extension)
72
+ valid_extensions and not extension_included
73
+ end
74
+
75
+ def invalid_content_type?(content_type)
76
+ valid_content_types and not valid_content_types.include?(content_type)
77
+ end
78
+
79
+ def format_param(param)
80
+ param.empty? ? I18n.t("refile.empty_param") : param
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,120 @@
1
+ module Refile
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"
9
+ class FileSystem
10
+ extend Refile::BackendMacros
11
+
12
+ # @return [String] the directory where files are stored
13
+ attr_reader :directory
14
+
15
+ # @return [String] the maximum size of files stored in this backend
16
+ attr_reader :max_size
17
+
18
+ # Creates the given directory if it doesn't exist.
19
+ #
20
+ # @param [String] directory The path to a directory where files should be stored
21
+ # @param [Integer, nil] max_size The maximum size of an uploaded file
22
+ # @param [#hash] hasher A hasher which is used to generate ids from files
23
+ def initialize(directory, max_size: nil, hasher: Refile::RandomHasher.new)
24
+ @hasher = hasher
25
+ @directory = directory
26
+ @max_size = max_size
27
+
28
+ FileUtils.mkdir_p(@directory)
29
+ end
30
+
31
+ # Upload a file into this backend
32
+ #
33
+ # @param [IO] uploadable An uploadable IO-like object.
34
+ # @return [Refile::File] The uploaded file
35
+ verify_uploadable def upload(uploadable)
36
+ id = @hasher.hash(uploadable)
37
+ IO.copy_stream(uploadable, path(id))
38
+
39
+ Refile::File.new(self, id)
40
+ ensure
41
+ uploadable.close
42
+ end
43
+
44
+ # Get a file from this backend.
45
+ #
46
+ # Note that this method will always return a {Refile::File} object, even
47
+ # if a file with the given id does not exist in this backend. Use
48
+ # {FileSystem#exists?} to check if the file actually exists.
49
+ #
50
+ # @param [String] id The id of the file
51
+ # @return [Refile::File] The retrieved file
52
+ verify_id def get(id)
53
+ Refile::File.new(self, id)
54
+ end
55
+
56
+ # Delete a file from this backend
57
+ #
58
+ # @param [String] id The id of the file
59
+ # @return [void]
60
+ verify_id def delete(id)
61
+ FileUtils.rm(path(id)) if exists?(id)
62
+ end
63
+
64
+ # Return an IO object for the uploaded file which can be used to read its
65
+ # content.
66
+ #
67
+ # @param [String] id The id of the file
68
+ # @return [IO] An IO object containing the file contents
69
+ verify_id def open(id)
70
+ ::File.open(path(id), "rb")
71
+ end
72
+
73
+ # Return the entire contents of the uploaded file as a String.
74
+ #
75
+ # @param [String] id The id of the file
76
+ # @return [String] The file's contents
77
+ verify_id def read(id)
78
+ ::File.read(path(id)) if exists?(id)
79
+ end
80
+
81
+ # Return the size in bytes of the uploaded file.
82
+ #
83
+ # @param [String] id The id of the file
84
+ # @return [Integer] The file's size
85
+ verify_id def size(id)
86
+ ::File.size(path(id)) if exists?(id)
87
+ end
88
+
89
+ # Return whether the file with the given id exists in this backend.
90
+ #
91
+ # @param [String] id The id of the file
92
+ # @return [Boolean]
93
+ verify_id def exists?(id)
94
+ ::File.exist?(path(id))
95
+ end
96
+
97
+ # Remove all files in this backend. You must confirm the deletion by
98
+ # passing the symbol `:confirm` as an argument to this method.
99
+ #
100
+ # @example
101
+ # backend.clear!(:confirm)
102
+ # @raise [Refile::Confirm] Unless the `:confirm` symbol has been passed.
103
+ # @param [:confirm] confirm Pass the symbol `:confirm` to confirm deletion.
104
+ # @return [void]
105
+ def clear!(confirm = nil)
106
+ raise Refile::Confirm unless confirm == :confirm
107
+ FileUtils.rm_rf(@directory)
108
+ FileUtils.mkdir_p(@directory)
109
+ end
110
+
111
+ # Return the full path of the uploaded file with the given id.
112
+ #
113
+ # @param [String] id The id of the file
114
+ # @return [String]
115
+ verify_id def path(id)
116
+ ::File.join(@directory, id)
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1 @@
1
+ raise "[Refile] the S3 backend has been extracted into a separate gem, see https://github.com/refile/refile-s3"
@@ -0,0 +1,45 @@
1
+ module Refile
2
+ # Macros which make it easier to write secure backends.
3
+ #
4
+ # @api private
5
+ module BackendMacros
6
+ def verify_id(method)
7
+ mod = Module.new do
8
+ define_method(method) do |id|
9
+ id = self.class.decode_id(id)
10
+ if self.class.valid_id?(id)
11
+ super(id)
12
+ else
13
+ raise Refile::InvalidID
14
+ end
15
+ end
16
+ end
17
+ prepend mod
18
+ end
19
+
20
+ def verify_uploadable(method)
21
+ mod = Module.new do
22
+ define_method(method) do |uploadable|
23
+ [:size, :read, :eof?, :rewind, :close].each do |m|
24
+ unless uploadable.respond_to?(m)
25
+ raise Refile::InvalidFile, "does not respond to `#{m}`."
26
+ end
27
+ end
28
+ if max_size and uploadable.size > max_size
29
+ raise Refile::InvalidMaxSize, "#{uploadable.inspect} is too large"
30
+ end
31
+ super(uploadable)
32
+ end
33
+ end
34
+ prepend mod
35
+ end
36
+
37
+ def valid_id?(id)
38
+ id =~ /\A[a-z0-9]+\z/i
39
+ end
40
+
41
+ def decode_id(id)
42
+ id.to_s
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,48 @@
1
+ require "rack/body_proxy"
2
+
3
+ module Refile
4
+ # @api private
5
+ class CustomLogger
6
+ LOG_FORMAT = %(%s: [%s] %s "%s%s" %d %0.1fms\n)
7
+
8
+ def initialize(app, prefix, logger_proc)
9
+ @app = app
10
+ @prefix = prefix
11
+ @logger_proc = logger_proc
12
+ end
13
+
14
+ def call(env)
15
+ began_at = Time.now
16
+ status, header, body = @app.call(env)
17
+ body = Rack::BodyProxy.new(body) { log(env, status, began_at) }
18
+ [status, header, body]
19
+ end
20
+
21
+ private
22
+
23
+ def log(env, status, began_at)
24
+ now = Time.now
25
+ logger.info do
26
+ format(
27
+ LOG_FORMAT,
28
+ @prefix,
29
+ now.strftime("%F %T %z"),
30
+ env["REQUEST_METHOD"],
31
+ env["PATH_INFO"],
32
+ env["QUERY_STRING"].empty? ? "" : "?" + env["QUERY_STRING"],
33
+ status.to_s[0..3],
34
+ (now - began_at) * 1000
35
+ )
36
+ end
37
+ end
38
+
39
+ def logger
40
+ @logger ||= @logger_proc.call
41
+ @logger || fallback_logger
42
+ end
43
+
44
+ def fallback_logger
45
+ @fallback_logger ||= Logger.new(nil)
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,102 @@
1
+ module Refile
2
+ class File
3
+ # @return [Backend] the backend the file is stored in
4
+ attr_reader :backend
5
+
6
+ # @return [String] the id of the file
7
+ attr_reader :id
8
+
9
+ # @api private
10
+ def initialize(backend, id)
11
+ @backend = backend
12
+ @id = id
13
+ end
14
+
15
+ # Reads from the file.
16
+ #
17
+ # @see http://www.ruby-doc.org/core-2.2.0/IO.html#method-i-read
18
+ #
19
+ # @return [String] The contents of the read chunk
20
+ def read(*args)
21
+ io.read(*args)
22
+ end
23
+
24
+ # Returns whether there is more data to read. Returns true if the end of
25
+ # the data has been reached.
26
+ #
27
+ # @return [Boolean]
28
+ def eof?
29
+ io.eof?
30
+ end
31
+
32
+ # Close the file object and release its file descriptor.
33
+ #
34
+ # @return [void]
35
+ def close
36
+ io.close
37
+ end
38
+
39
+ # @return [Integer] the size of the file in bytes
40
+ def size
41
+ backend.size(id)
42
+ end
43
+
44
+ # Remove the file from the backend.
45
+ #
46
+ # @return [void]
47
+ def delete
48
+ backend.delete(id)
49
+ end
50
+
51
+ # @return [Boolean] whether the file exists in the backend
52
+ def exists?
53
+ backend.exists?(id)
54
+ end
55
+
56
+ # @return [IO] an IO object which contains the contents of the file
57
+ def to_io
58
+ io
59
+ end
60
+
61
+ # Downloads the file to a Tempfile on disk and returns this tempfile.
62
+ #
63
+ # @example
64
+ # file = backend.upload(StringIO.new("hello"))
65
+ # tempfile = file.download
66
+ # File.read(tempfile.path) # => "hello"
67
+ #
68
+ # @return [Tempfile] a tempfile with the file's content
69
+ def download
70
+ return io if io.is_a?(Tempfile)
71
+
72
+ Tempfile.new(id, binmode: true).tap do |tempfile|
73
+ IO.copy_stream(io, tempfile)
74
+ tempfile.rewind
75
+ tempfile.fsync
76
+ end
77
+ end
78
+
79
+ # Rewind to beginning of file.
80
+ #
81
+ # @return [nil]
82
+ def rewind
83
+ @io = nil
84
+ end
85
+
86
+ # Prevent from exposing secure information unexpectedly
87
+ #
88
+ # @return [Hash]
89
+ def as_json
90
+ {
91
+ id: id,
92
+ backend: backend.to_s
93
+ }
94
+ end
95
+
96
+ private
97
+
98
+ def io
99
+ @io ||= backend.open(id)
100
+ end
101
+ end
102
+ end