leifcr-refile 0.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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