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
@@ -4,9 +4,30 @@ require "open-uri"
4
4
  module Refile
5
5
  module Backend
6
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"
7
17
  class S3
8
18
  attr_reader :access_key_id, :max_size
9
19
 
20
+ # Sets up an S3 backend with the given credentials.
21
+ #
22
+ # @param [String] access_key_id
23
+ # @param [String] secret_access_key
24
+ # @param [String] bucket The name of the bucket where files will be stored
25
+ # @param [String] prefix A prefix to add to all files. Prefixes on S3 are kind of like folders.
26
+ # @param [Integer, nil] max_size The maximum size of an uploaded file
27
+ # @param [#hash] hasher A hasher which is used to generate ids from files
28
+ # @param [Hash] s3_options Additional options to initialize S3 with
29
+ # @see http://docs.aws.amazon.com/AWSRubySDK/latest/AWS/Core/Configuration.html
30
+ # @see http://docs.aws.amazon.com/AWSRubySDK/latest/AWS/S3.html
10
31
  def initialize(access_key_id:, secret_access_key:, bucket:, max_size: nil, prefix: nil, hasher: Refile::RandomHasher.new, **s3_options)
11
32
  @access_key_id = access_key_id
12
33
  @secret_access_key = secret_access_key
@@ -19,6 +40,10 @@ module Refile
19
40
  @max_size = max_size
20
41
  end
21
42
 
43
+ # Upload a file into this backend
44
+ #
45
+ # @param [IO] uploadable An uploadable IO-like object.
46
+ # @return [Refile::File] The uploaded file
22
47
  def upload(uploadable)
23
48
  Refile.verify_uploadable(uploadable, @max_size)
24
49
 
@@ -33,39 +58,80 @@ module Refile
33
58
  Refile::File.new(self, id)
34
59
  end
35
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
36
69
  def get(id)
37
70
  Refile::File.new(self, id)
38
71
  end
39
72
 
73
+ # Delete a file from this backend
74
+ #
75
+ # @param [Sring] id The id of the file
76
+ # @return [void]
40
77
  def delete(id)
41
78
  object(id).delete
42
79
  end
43
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
44
86
  def open(id)
45
87
  Kernel.open(object(id).url_for(:read))
46
88
  end
47
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
48
94
  def read(id)
49
95
  object(id).read
50
96
  rescue AWS::S3::Errors::NoSuchKey
51
97
  nil
52
98
  end
53
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
54
104
  def size(id)
55
105
  object(id).content_length
56
106
  rescue AWS::S3::Errors::NoSuchKey
57
107
  nil
58
108
  end
59
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]
60
114
  def exists?(id)
61
115
  object(id).exists?
62
116
  end
63
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]
64
126
  def clear!(confirm = nil)
65
127
  raise Refile::Confirm unless confirm == :confirm
66
128
  @bucket.objects.with_prefix(@prefix).delete_all
67
129
  end
68
130
 
131
+ # Return a presign signature which can be used to upload a file into this
132
+ # backend directly.
133
+ #
134
+ # @return [Refile::Signature]
69
135
  def presign
70
136
  id = RandomHasher.new.hash
71
137
  signature = @bucket.presigned_post(key: [*@prefix, id].join("/"))
@@ -0,0 +1,46 @@
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, @prefix, @logger_proc = app, prefix, logger_proc
10
+ end
11
+
12
+ def call(env)
13
+ began_at = Time.now
14
+ status, header, body = @app.call(env)
15
+ body = Rack::BodyProxy.new(body) { log(env, status, began_at) }
16
+ [status, header, body]
17
+ end
18
+
19
+ private
20
+
21
+ def log(env, status, began_at)
22
+ now = Time.now
23
+ logger.info do
24
+ format(
25
+ LOG_FORMAT,
26
+ @prefix,
27
+ now.strftime("%F %T %z"),
28
+ env["REQUEST_METHOD"],
29
+ env["PATH_INFO"],
30
+ env["QUERY_STRING"].empty? ? "" : "?" + env["QUERY_STRING"],
31
+ status.to_s[0..3],
32
+ (now - began_at) * 1000
33
+ )
34
+ end
35
+ end
36
+
37
+ def logger
38
+ @logger ||= @logger_proc.call
39
+ @logger || fallback_logger
40
+ end
41
+
42
+ def fallback_logger
43
+ @fallback_logger ||= Logger.new(nil)
44
+ end
45
+ end
46
+ end
data/lib/refile/file.rb CHANGED
@@ -1,40 +1,71 @@
1
1
  module Refile
2
2
  class File
3
- attr_reader :backend, :id
3
+ # @return [Backend] the backend the file is stored in
4
+ attr_reader :backend
4
5
 
6
+ # @return [String] the id of the file
7
+ attr_reader :id
8
+
9
+ # @api private
5
10
  def initialize(backend, id)
6
11
  @backend = backend
7
12
  @id = id
8
13
  end
9
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
10
20
  def read(*args)
11
21
  io.read(*args)
12
22
  end
13
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]
14
28
  def eof?
15
29
  io.eof?
16
30
  end
17
31
 
32
+ # Close the file object and release its file descriptor.
33
+ #
34
+ # @return [void]
18
35
  def close
19
36
  io.close
20
37
  end
21
38
 
39
+ # @return [Integer] the size of the file in bytes
22
40
  def size
23
41
  backend.size(id)
24
42
  end
25
43
 
44
+ # Remove the file from the backend.
45
+ #
46
+ # @return [void]
26
47
  def delete
27
48
  backend.delete(id)
28
49
  end
29
50
 
51
+ # @return [Boolean] whether the file exists in the backend
30
52
  def exists?
31
53
  backend.exists?(id)
32
54
  end
33
55
 
56
+ # @return [IO] an IO object which contains the contents of the file
34
57
  def to_io
35
58
  io
36
59
  end
37
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
38
69
  def download
39
70
  return io if io.is_a?(Tempfile)
40
71
 
@@ -2,27 +2,67 @@ require "refile"
2
2
  require "mini_magick"
3
3
 
4
4
  module Refile
5
+ # Processes images via MiniMagick, resizing cropping and padding them.
5
6
  class ImageProcessor
7
+ # @param [Symbol] method The method to invoke on {#call}
6
8
  def initialize(method)
7
9
  @method = method
8
10
  end
9
11
 
12
+ # Changes the image encoding format to the given format
13
+ #
14
+ # @see http://www.imagemagick.org/script/command-line-options.php#format
15
+ # @param [MiniMagick::Image] img the image to convert
16
+ # @param [String] format the format to convert to
17
+ # @return [void]
10
18
  def convert(img, format)
11
19
  img.format(format.to_s.downcase)
12
20
  end
13
21
 
22
+ # Resize the image to fit within the specified dimensions while retaining
23
+ # the original aspect ratio. Will only resize the image if it is larger
24
+ # than the specified dimensions. The resulting image may be shorter or
25
+ # narrower than specified in either dimension but will not be larger than
26
+ # the specified values.
27
+ #
28
+ # @param [MiniMagick::Image] img the image to convert
29
+ # @param [#to_s] width the maximum width
30
+ # @param [#to_s] height the maximum height
31
+ # @return [void]
14
32
  def limit(img, width, height)
15
33
  img.resize "#{width}x#{height}>"
16
34
  end
17
35
 
36
+ # Resize the image to fit within the specified dimensions while retaining
37
+ # the original aspect ratio. The image may be shorter or narrower than
38
+ # specified in the smaller dimension but will not be larger than the
39
+ # specified values.
40
+ #
41
+ # @param [MiniMagick::Image] img the image to convert
42
+ # @param [#to_s] width the width to fit into
43
+ # @param [#to_s] height the height to fit into
44
+ # @return [void]
18
45
  def fit(img, width, height)
19
46
  img.resize "#{width}x#{height}"
20
47
  end
21
48
 
22
- # @ignore
23
- # rubocop:disable Metrics/AbcSize
24
- # FIXME: test and rewrite to simpler implementation!
49
+ # Resize the image so that it is at least as large in both dimensions as
50
+ # specified, then crops any excess outside the specified dimensions.
51
+ #
52
+ # The resulting image will always be exactly as large as the specified
53
+ # dimensions.
54
+ #
55
+ # By default, the center part of the image is kept, and the remainder
56
+ # cropped off, but this can be changed via the `gravity` option.
57
+ #
58
+ # @param [MiniMagick::Image] img the image to convert
59
+ # @param [#to_s] width the width to fill out
60
+ # @param [#to_s] height the height to fill out
61
+ # @param [String] gravity which part of the image to focus on
62
+ # @return [void]
63
+ # @see http://www.imagemagick.org/script/command-line-options.php#gravity
25
64
  def fill(img, width, height, gravity = "Center")
65
+ # FIXME: test and rewrite to simpler implementation!
26
66
  width = width.to_i
27
67
  height = height.to_i
28
68
  cols, rows = img[:dimensions]
@@ -46,6 +86,26 @@ module Refile
46
86
  end
47
87
  end
48
88
 
89
+ # resize the image to fit within the specified dimensions while retaining
90
+ # the original aspect ratio in the same way as {#fill}. unlike {#fill} it
91
+ # will, if necessary, pad the remaining area with the given color, which
92
+ # defaults to transparent where supported by the image format and white
93
+ # otherwise.
94
+ #
95
+ # the resulting image will always be exactly as large as the specified
96
+ # dimensions.
97
+ #
98
+ # by default, the image will be placed in the center but this can be
99
+ # changed via the `gravity` option.
100
+ #
101
+ # @param [minimagick::image] img the image to convert
102
+ # @param [#to_s] width the width to fill out
103
+ # @param [#to_s] height the height to fill out
104
+ # @param [string] background the color to use as a background
105
+ # @param [string] gravity which part of the image to focus on
106
+ # @return [void]
107
+ # @see http://www.imagemagick.org/script/color.php
108
+ # @see http://www.imagemagick.org/script/command-line-options.php#gravity
49
109
  def pad(img, width, height, background = "transparent", gravity = "Center")
50
110
  img.combine_options do |cmd|
51
111
  cmd.thumbnail "#{width}x#{height}>"
@@ -59,6 +119,15 @@ module Refile
59
119
  end
60
120
  end
61
121
 
122
+ # Process the given file. The file will be processed via one of the
123
+ # instance methods of this class, depending on the `method` argument passed
124
+ # to the constructor on initialization.
125
+ #
126
+ # If the format is given it will convert the image to the given file format.
127
+ #
128
+ # @param [Tempfile] file the file to manipulate
129
+ # @param [String] format the file format to convert to
130
+ # @return [File] the processed file
62
131
  def call(file, *args, format: nil)
63
132
  img = ::MiniMagick::Image.new(file.path)
64
133
  img.format(format.to_s.downcase) if format
data/lib/refile/rails.rb CHANGED
@@ -2,13 +2,7 @@ require "refile"
2
2
  require "refile/rails/attachment_helper"
3
3
 
4
4
  module Refile
5
- module AttachmentFieldHelper
6
- def attachment_field(method, options = {})
7
- self.multipart = true
8
- @template.attachment_field(@object_name, method, objectify_options(options))
9
- end
10
- end
11
-
5
+ # @api private
12
6
  class Engine < Rails::Engine
13
7
  initializer "refile.setup", before: :load_environment_config do
14
8
  Refile.store ||= Refile::Backend::FileSystem.new(Rails.root.join("tmp/uploads/store").to_s)
@@ -19,7 +13,7 @@ module Refile
19
13
  end
20
14
 
21
15
  ActionView::Base.send(:include, Refile::AttachmentHelper)
22
- ActionView::Helpers::FormBuilder.send(:include, AttachmentFieldHelper)
16
+ ActionView::Helpers::FormBuilder.send(:include, AttachmentHelper::FormBuilder)
23
17
  end
24
18
 
25
19
  initializer "refile.app" do
@@ -1,10 +1,49 @@
1
1
  module Refile
2
+ # Rails view helpers which aid in using Refile from views.
2
3
  module AttachmentHelper
4
+ # Form builder extension
5
+ module FormBuilder
6
+ # @see AttachmentHelper#attachment_field
7
+ def attachment_field(method, options = {})
8
+ self.multipart = true
9
+ @template.attachment_field(@object_name, method, objectify_options(options))
10
+ end
11
+ end
12
+
13
+ # View helper which generates a url for an attachment. This generates a URL
14
+ # to the {Refile::App} which is assumed to be mounted in the Rails
15
+ # application.
16
+ #
17
+ # Optionally the name of a processor and a arguments to it can be appended.
18
+ #
19
+ # If the filename option is not given, the filename falls back to the
20
+ # `name`.
21
+ #
22
+ # The host defaults to {Refile.host}, which is useful for serving all
23
+ # attachments from a CDN. You can also override the host via the `host`
24
+ # option.
25
+ #
26
+ # Returns `nil` if there is no file attached.
27
+ #
28
+ # @example
29
+ # attachment_url(@post, :document)
30
+ #
31
+ # @example With processor
32
+ # attachment_url(@post, :image, :fill, 300, 300, format: "jpg")
33
+ #
34
+ # @param [Refile::Attachment] record Instance of a class which has an attached file
35
+ # @param [Symbol] name The name of the attachment column
36
+ # @param [String, nil] filename The filename to be appended to the URL
37
+ # @param [String, nil] format A file extension to be appended to the URL
38
+ # @param [String, nil] host Override the host
39
+ # @return [String, nil] The generated URL
3
40
  def attachment_url(record, name, *args, filename: nil, format: nil, host: nil)
4
- file = record.send(name)
41
+ attacher = record.send(:"#{name}_attacher")
42
+ file = attacher.get
5
43
  return unless file
6
44
 
7
- filename ||= name.to_s
45
+ filename ||= attacher.basename || name.to_s
46
+ format ||= attacher.extension
8
47
 
9
48
  backend_name = Refile.backends.key(file.backend)
10
49
  host = host || Refile.host || request.base_url
@@ -15,6 +54,16 @@ module Refile
15
54
  ::File.join(host, main_app.refile_app_path, backend_name, *args.map(&:to_s), file.id.to_s, filename)
16
55
  end
17
56
 
57
+ # Generates an image tag for the given attachment, adding appropriate
58
+ # classes and optionally falling back to the given fallback image if there
59
+ # is no file attached.
60
+ #
61
+ # Returns `nil` if there is no file attached and no fallback specified.
62
+ #
63
+ # @param [String] fallback The path to an image asset to be used as a fallback
64
+ # @param [Hash] options Additional options for the image tag
65
+ # @see #attachment_url
66
+ # @return [ActiveSupport::SafeBuffer, nil] The generated image tag
18
67
  def attachment_image_tag(record, name, *args, fallback: nil, format: nil, host: nil, **options)
19
68
  file = record.send(name)
20
69
  classes = ["attachment", record.class.model_name.singular, name, *options[:class]]
@@ -27,29 +76,38 @@ module Refile
27
76
  end
28
77
  end
29
78
 
30
- # @ignore
31
- # rubocop:disable Metrics/AbcSize
32
- def attachment_field(object_name, method, options = {})
79
+ # Generates a form field which can be used with records which have
80
+ # attachments. This will generate both a file field as well as a hidden
81
+ # field which tracks the id of the file in the cache before it is
82
+ # permanently stored.
83
+ #
84
+ # @param object_name The name of the object to generate a field for
85
+ # @param method The name of the field
86
+ # @param [Hash] options
87
+ # @option options [Object] object Set by the form builder, currently required for direct/presigned uploads to work.
88
+ # @option options [Boolean] direct If set to true, adds the appropriate data attributes for direct uploads with refile.js.
89
+ # @option options [Boolean] presign If set to true, adds the appropriate data attributes for presigned uploads with refile.js.
90
+ # @return [ActiveSupport::SafeBuffer] The generated form field
91
+ def attachment_field(object_name, method, object:, **options)
33
92
  options[:data] ||= {}
34
93
 
35
- if options[:object]
36
- attacher = options[:object].send(:"#{method}_attacher")
37
- options[:accept] = attacher.accept
94
+ attacher = object.send(:"#{method}_attacher")
95
+ options[:accept] = attacher.accept
38
96
 
39
- if options[:direct]
40
- host = options[:host] || Refile.host || request.base_url
41
- backend_name = Refile.backends.key(attacher.cache)
97
+ if options[:direct]
98
+ host = options[:host] || Refile.host || request.base_url
99
+ backend_name = Refile.backends.key(attacher.cache)
42
100
 
43
- url = ::File.join(host, main_app.refile_app_path, backend_name)
44
- options[:data].merge!(direct: true, as: "file", url: url)
45
- end
101
+ url = ::File.join(host, main_app.refile_app_path, backend_name)
102
+ options[:data].merge!(direct: true, as: "file", url: url)
103
+ end
46
104
 
47
- if options[:presigned] and attacher.cache.respond_to?(:presign)
48
- options[:data].merge!(direct: true).merge!(attacher.cache.presign.as_json)
49
- end
105
+ if options[:presigned] and attacher.cache.respond_to?(:presign)
106
+ options[:data].merge!(direct: true).merge!(attacher.cache.presign.as_json)
50
107
  end
51
- hidden_field(object_name, :"#{method}_cache_id", options.slice(:object)) +
52
- file_field(object_name, method, options)
108
+
109
+ html = hidden_field(object_name, method, value: attacher.data.to_json, object: object)
110
+ html + file_field(object_name, method, options)
53
111
  end
54
112
  end
55
113
  end