thoughtbot-paperclip 2.2.2 → 2.2.3
Sign up to get free protection for your applications and to get access to all the features.
- data/README.rdoc +2 -2
- data/Rakefile +0 -13
- data/lib/paperclip.rb +18 -9
- data/lib/paperclip/attachment.rb +29 -10
- data/lib/paperclip/geometry.rb +1 -1
- data/lib/paperclip/storage.rb +8 -5
- data/shoulda_macros/matchers.rb +4 -0
- data/shoulda_macros/matchers/have_attached_file_matcher.rb +49 -0
- data/shoulda_macros/matchers/validate_attachment_content_type_matcher.rb +66 -0
- data/shoulda_macros/matchers/validate_attachment_presence_matcher.rb +48 -0
- data/shoulda_macros/matchers/validate_attachment_size_matcher.rb +83 -0
- data/shoulda_macros/paperclip.rb +20 -105
- data/tasks/paperclip_tasks.rake +1 -1
- data/test/attachment_test.rb +41 -8
- data/test/fixtures/twopage.pdf +0 -0
- data/test/helper.rb +21 -1
- data/test/integration_test.rb +4 -0
- data/test/matchers/have_attached_file_matcher_test.rb +21 -0
- data/test/matchers/validate_attachment_content_type_matcher_test.rb +30 -0
- data/test/matchers/validate_attachment_presence_matcher_test.rb +21 -0
- data/test/matchers/validate_attachment_size_matcher_test.rb +50 -0
- data/test/thumbnail_test.rb +33 -0
- metadata +14 -2
data/README.rdoc
CHANGED
@@ -109,7 +109,7 @@ a set of styles for an attachment, by default it is expected that those
|
|
109
109
|
"styles" are actually "thumbnails". However, you can do more than just
|
110
110
|
thumbnail images. By defining a subclass of Paperclip::Processor, you can
|
111
111
|
perform any processing you want on the files that are attached. Any file in
|
112
|
-
your Rails app's lib/
|
112
|
+
your Rails app's lib/paperclip_processors directory is automatically loaded by
|
113
113
|
paperclip, allowing you to easily define custom processors. You can specify a
|
114
114
|
processor with the :processors option to has_attached_file:
|
115
115
|
|
@@ -152,7 +152,7 @@ are called before and after the processing of each attachment), and the
|
|
152
152
|
attachment-specific "before_<attachment>_post_process" and
|
153
153
|
"after_<attachment>_post_process". The callbacks are intended to be as close to
|
154
154
|
normal ActiveRecord callbacks as possible, so if you return false (specifically
|
155
|
-
|
155
|
+
- returning nil is not the same) in a before_ filter, the post processing step
|
156
156
|
will halt. Returning false in an after_ filter will not halt anything, but you
|
157
157
|
can access the model and the attachment if necessary.
|
158
158
|
|
data/Rakefile
CHANGED
@@ -1,7 +1,6 @@
|
|
1
1
|
require 'rake'
|
2
2
|
require 'rake/testtask'
|
3
3
|
require 'rake/rdoctask'
|
4
|
-
require 'rake/gempackagetask'
|
5
4
|
|
6
5
|
$LOAD_PATH << File.join(File.dirname(__FILE__), 'lib')
|
7
6
|
require 'paperclip'
|
@@ -70,18 +69,6 @@ spec = Gem::Specification.new do |s|
|
|
70
69
|
s.add_development_dependency 'mocha'
|
71
70
|
end
|
72
71
|
|
73
|
-
desc "Release new version"
|
74
|
-
task :release => [:test, :sync_docs, :gem] do
|
75
|
-
require 'rubygems'
|
76
|
-
require 'rubyforge'
|
77
|
-
r = RubyForge.new
|
78
|
-
r.login
|
79
|
-
r.add_release spec.rubyforge_project,
|
80
|
-
spec.name,
|
81
|
-
spec.version,
|
82
|
-
File.join("pkg", "#{spec.name}-#{spec.version}.gem")
|
83
|
-
end
|
84
|
-
|
85
72
|
desc "Generate a gemspec file for GitHub"
|
86
73
|
task :gemspec do
|
87
74
|
File.open("#{spec.name}.gemspec", 'w') do |f|
|
data/lib/paperclip.rb
CHANGED
@@ -43,7 +43,7 @@ end
|
|
43
43
|
# documentation for Paperclip::ClassMethods for more useful information.
|
44
44
|
module Paperclip
|
45
45
|
|
46
|
-
VERSION = "2.2.
|
46
|
+
VERSION = "2.2.3"
|
47
47
|
|
48
48
|
class << self
|
49
49
|
# Provides configurability to Paperclip. There are a number of options available, such as:
|
@@ -60,20 +60,23 @@ module Paperclip
|
|
60
60
|
:whiny_thumbnails => true,
|
61
61
|
:image_magick_path => nil,
|
62
62
|
:command_path => nil,
|
63
|
-
:log => true
|
63
|
+
:log => true,
|
64
|
+
:swallow_stderr => true
|
64
65
|
}
|
65
66
|
end
|
66
67
|
|
67
68
|
def path_for_command command #:nodoc:
|
68
69
|
if options[:image_magick_path]
|
69
|
-
|
70
|
-
"will be removed. Use :command_path "+
|
71
|
-
"instead")
|
70
|
+
warn("[DEPRECATION] :image_magick_path is deprecated and will be removed. Use :command_path instead")
|
72
71
|
end
|
73
|
-
path = [options[:
|
72
|
+
path = [options[:command_path] || options[:image_magick_path], command].compact
|
74
73
|
File.join(*path)
|
75
74
|
end
|
76
75
|
|
76
|
+
def interpolates key, &block
|
77
|
+
Paperclip::Attachment.interpolations[key] = block
|
78
|
+
end
|
79
|
+
|
77
80
|
# The run method takes a command to execute and a string of parameters
|
78
81
|
# that get passed to it. The command is prefixed with the :command_path
|
79
82
|
# option from Paperclip.options. If you have many commands to run and
|
@@ -84,7 +87,9 @@ module Paperclip
|
|
84
87
|
# expected_outcodes, a PaperclipCommandLineError will be raised. Generally
|
85
88
|
# a code of 0 is expected, but a list of codes may be passed if necessary.
|
86
89
|
def run cmd, params = "", expected_outcodes = 0
|
87
|
-
|
90
|
+
command = %Q<#{%Q[#{path_for_command(cmd)} #{params}].gsub(/\s+/, " ")}>
|
91
|
+
command = "#{command} 2>#{bit_bucket}" if Paperclip.options[:swallow_stderr]
|
92
|
+
output = `#{command}`
|
88
93
|
unless [expected_outcodes].flatten.include?($?.exitstatus)
|
89
94
|
raise PaperclipCommandLineError, "Error while running #{cmd}"
|
90
95
|
end
|
@@ -204,7 +209,8 @@ module Paperclip
|
|
204
209
|
end
|
205
210
|
|
206
211
|
validates_each(name) do |record, attr, value|
|
207
|
-
|
212
|
+
attachment = record.attachment_for(name)
|
213
|
+
attachment.send(:flush_errors) unless attachment.valid?
|
208
214
|
end
|
209
215
|
end
|
210
216
|
|
@@ -250,6 +256,9 @@ module Paperclip
|
|
250
256
|
# match. Allows all by default.
|
251
257
|
# * +message+: The message to display when the uploaded file has an invalid
|
252
258
|
# content type.
|
259
|
+
# NOTE: If you do not specify an [attachment]_content_type field on your
|
260
|
+
# model, content_type validation will work _ONLY upon assignment_ and
|
261
|
+
# re-validation after the instance has been reloaded will always succeed.
|
253
262
|
def validates_attachment_content_type name, options = {}
|
254
263
|
attachment_definitions[name][:validations][:content_type] = lambda do |attachment, instance|
|
255
264
|
valid_types = [options[:content_type]].flatten
|
@@ -257,7 +266,7 @@ module Paperclip
|
|
257
266
|
unless attachment.original_filename.blank?
|
258
267
|
unless valid_types.blank?
|
259
268
|
content_type = attachment.instance_read(:content_type)
|
260
|
-
unless valid_types.any?{|t| t === content_type }
|
269
|
+
unless valid_types.any?{|t| content_type.nil? || t === content_type }
|
261
270
|
options[:message] || "is not one of the allowed file types."
|
262
271
|
end
|
263
272
|
end
|
data/lib/paperclip/attachment.rb
CHANGED
@@ -89,6 +89,7 @@ module Paperclip
|
|
89
89
|
|
90
90
|
@dirty = true
|
91
91
|
|
92
|
+
solidify_style_definitions
|
92
93
|
post_process if valid?
|
93
94
|
|
94
95
|
# Reset the file size if the original file was reprocessed.
|
@@ -241,6 +242,7 @@ module Paperclip
|
|
241
242
|
def instance_write(attr, value)
|
242
243
|
setter = :"#{name}_#{attr}="
|
243
244
|
responds = instance.respond_to?(setter)
|
245
|
+
self.instance_variable_set("@_#{setter.to_s.chop}", value)
|
244
246
|
instance.send(setter, value) if responds || attr.to_s == "file_name"
|
245
247
|
end
|
246
248
|
|
@@ -249,20 +251,22 @@ module Paperclip
|
|
249
251
|
def instance_read(attr)
|
250
252
|
getter = :"#{name}_#{attr}"
|
251
253
|
responds = instance.respond_to?(getter)
|
254
|
+
cached = self.instance_variable_get("@_#{getter}")
|
255
|
+
return cached if cached
|
252
256
|
instance.send(getter) if responds || attr.to_s == "file_name"
|
253
257
|
end
|
254
258
|
|
255
259
|
private
|
256
260
|
|
257
|
-
def logger
|
261
|
+
def logger #:nodoc:
|
258
262
|
instance.logger
|
259
263
|
end
|
260
264
|
|
261
|
-
def log message
|
265
|
+
def log message #:nodoc:
|
262
266
|
logger.info("[paperclip] #{message}") if logging?
|
263
267
|
end
|
264
268
|
|
265
|
-
def logging?
|
269
|
+
def logging? #:nodoc:
|
266
270
|
Paperclip.options[:log]
|
267
271
|
end
|
268
272
|
|
@@ -283,7 +287,7 @@ module Paperclip
|
|
283
287
|
@validation_errors
|
284
288
|
end
|
285
289
|
|
286
|
-
def normalize_style_definition
|
290
|
+
def normalize_style_definition #:nodoc:
|
287
291
|
@styles.each do |name, args|
|
288
292
|
unless args.is_a? Hash
|
289
293
|
dimensions, format = [args, nil].flatten[0..1]
|
@@ -305,7 +309,15 @@ module Paperclip
|
|
305
309
|
end
|
306
310
|
end
|
307
311
|
|
308
|
-
def
|
312
|
+
def solidify_style_definitions #:nodoc:
|
313
|
+
@styles.each do |name, args|
|
314
|
+
if @styles[name][:geometry].respond_to?(:call)
|
315
|
+
@styles[name][:geometry] = @styles[name][:geometry].call(instance)
|
316
|
+
end
|
317
|
+
end
|
318
|
+
end
|
319
|
+
|
320
|
+
def initialize_storage #:nodoc:
|
309
321
|
@storage_module = Paperclip::Storage.const_get(@storage.to_s.capitalize)
|
310
322
|
self.extend(@storage_module)
|
311
323
|
end
|
@@ -321,8 +333,17 @@ module Paperclip
|
|
321
333
|
|
322
334
|
def post_process #:nodoc:
|
323
335
|
return if @queued_for_write[:original].nil?
|
324
|
-
return if
|
325
|
-
|
336
|
+
return if fire_events(:before)
|
337
|
+
post_process_styles
|
338
|
+
return if fire_events(:after)
|
339
|
+
end
|
340
|
+
|
341
|
+
def fire_events(which)
|
342
|
+
return true if callback(:"#{which}_post_process") == false
|
343
|
+
return true if callback(:"#{which}_#{name}_post_process") == false
|
344
|
+
end
|
345
|
+
|
346
|
+
def post_process_styles
|
326
347
|
log("Post-processing #{name}")
|
327
348
|
@styles.each do |name, args|
|
328
349
|
begin
|
@@ -336,11 +357,9 @@ module Paperclip
|
|
336
357
|
(@errors[:processing] ||= []) << e.message if @whiny
|
337
358
|
end
|
338
359
|
end
|
339
|
-
callback(:"after_#{name}_post_process")
|
340
|
-
callback(:after_post_process)
|
341
360
|
end
|
342
361
|
|
343
|
-
def callback which
|
362
|
+
def callback which #:nodoc:
|
344
363
|
instance.run_callbacks(which, @queued_for_write){|result, obj| result == false }
|
345
364
|
end
|
346
365
|
|
data/lib/paperclip/geometry.rb
CHANGED
@@ -16,7 +16,7 @@ module Paperclip
|
|
16
16
|
def self.from_file file
|
17
17
|
file = file.path if file.respond_to? "path"
|
18
18
|
geometry = begin
|
19
|
-
Paperclip.run("identify", %Q[-format "%wx%h" "#{file}"])
|
19
|
+
Paperclip.run("identify", %Q[-format "%wx%h" "#{file}"[0]])
|
20
20
|
rescue PaperclipCommandLineError
|
21
21
|
""
|
22
22
|
end
|
data/lib/paperclip/storage.rb
CHANGED
@@ -61,8 +61,11 @@ module Paperclip
|
|
61
61
|
path = File.dirname(path)
|
62
62
|
FileUtils.rmdir(path)
|
63
63
|
end
|
64
|
-
rescue Errno::ENOTEMPTY, Errno::ENOENT, Errno::EINVAL
|
64
|
+
rescue Errno::EEXIST, Errno::ENOTEMPTY, Errno::ENOENT, Errno::EINVAL, Errno::ENOTDIR
|
65
65
|
# Stop trying to remove parent directories
|
66
|
+
rescue SystemCallError => e
|
67
|
+
logger.info("[paperclip] There was an unexpected error while deleting directories: #{e.class}")
|
68
|
+
# Ignore it
|
66
69
|
end
|
67
70
|
end
|
68
71
|
@queued_for_delete = []
|
@@ -118,11 +121,11 @@ module Paperclip
|
|
118
121
|
require 'right_aws'
|
119
122
|
base.instance_eval do
|
120
123
|
@s3_credentials = parse_credentials(@options[:s3_credentials])
|
121
|
-
@bucket = @options[:bucket]
|
122
|
-
@s3_options = @options[:s3_options]
|
124
|
+
@bucket = @options[:bucket] || @s3_credentials[:bucket]
|
125
|
+
@s3_options = @options[:s3_options] || {}
|
123
126
|
@s3_permissions = @options[:s3_permissions] || 'public-read'
|
124
|
-
@s3_protocol = @options[:s3_protocol]
|
125
|
-
@s3_headers = @options[:s3_headers]
|
127
|
+
@s3_protocol = @options[:s3_protocol] || (@s3_permissions == 'public-read' ? 'http' : 'https')
|
128
|
+
@s3_headers = @options[:s3_headers] || {}
|
126
129
|
@url = ":s3_path_url" unless @url.to_s.match(/^:s3.*url$/)
|
127
130
|
end
|
128
131
|
base.class.interpolations[:s3_path_url] = lambda do |attachment, style|
|
@@ -0,0 +1,4 @@
|
|
1
|
+
require 'shoulda_macros/matchers/have_attached_file_matcher'
|
2
|
+
require 'shoulda_macros/matchers/validate_attachment_presence_matcher'
|
3
|
+
require 'shoulda_macros/matchers/validate_attachment_content_type_matcher'
|
4
|
+
require 'shoulda_macros/matchers/validate_attachment_size_matcher'
|
@@ -0,0 +1,49 @@
|
|
1
|
+
module Paperclip
|
2
|
+
module Shoulda
|
3
|
+
module Matchers
|
4
|
+
def have_attached_file name
|
5
|
+
HaveAttachedFileMatcher.new(name)
|
6
|
+
end
|
7
|
+
|
8
|
+
class HaveAttachedFileMatcher
|
9
|
+
def initialize attachment_name
|
10
|
+
@attachment_name = attachment_name
|
11
|
+
end
|
12
|
+
|
13
|
+
def matches? subject
|
14
|
+
@subject = subject
|
15
|
+
responds? && has_column? && included?
|
16
|
+
end
|
17
|
+
|
18
|
+
def failure_message
|
19
|
+
"Should have an attachment named #{@attachment_name}"
|
20
|
+
end
|
21
|
+
|
22
|
+
def negative_failure_message
|
23
|
+
"Should not have an attachment named #{@attachment_name}"
|
24
|
+
end
|
25
|
+
|
26
|
+
def description
|
27
|
+
"have an attachment named #{@attachment_name}"
|
28
|
+
end
|
29
|
+
|
30
|
+
protected
|
31
|
+
|
32
|
+
def responds?
|
33
|
+
methods = @subject.instance_methods
|
34
|
+
methods.include?("#{@attachment_name}") &&
|
35
|
+
methods.include?("#{@attachment_name}=") &&
|
36
|
+
methods.include?("#{@attachment_name}?")
|
37
|
+
end
|
38
|
+
|
39
|
+
def has_column?
|
40
|
+
@subject.column_names.include?("#{@attachment_name}_file_name")
|
41
|
+
end
|
42
|
+
|
43
|
+
def included?
|
44
|
+
@subject.ancestors.include?(Paperclip::InstanceMethods)
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
@@ -0,0 +1,66 @@
|
|
1
|
+
module Paperclip
|
2
|
+
module Shoulda
|
3
|
+
module Matchers
|
4
|
+
def validate_attachment_content_type name
|
5
|
+
ValidateAttachmentContentTypeMatcher.new(name)
|
6
|
+
end
|
7
|
+
|
8
|
+
class ValidateAttachmentContentTypeMatcher
|
9
|
+
def initialize attachment_name
|
10
|
+
@attachment_name = attachment_name
|
11
|
+
end
|
12
|
+
|
13
|
+
def allowing *types
|
14
|
+
@allowed_types = types.flatten
|
15
|
+
self
|
16
|
+
end
|
17
|
+
|
18
|
+
def rejecting *types
|
19
|
+
@rejected_types = types.flatten
|
20
|
+
self
|
21
|
+
end
|
22
|
+
|
23
|
+
def matches? subject
|
24
|
+
@subject = subject
|
25
|
+
@allowed_types && @rejected_types &&
|
26
|
+
allowed_types_allowed? && rejected_types_rejected?
|
27
|
+
end
|
28
|
+
|
29
|
+
def failure_message
|
30
|
+
"Content types #{@allowed_types.join(", ")} should be accepted" +
|
31
|
+
" and #{@rejected_types.join(", ")} rejected by #{@attachment_name}"
|
32
|
+
end
|
33
|
+
|
34
|
+
def negative_failure_message
|
35
|
+
"Content types #{@allowed_types.join(", ")} should be rejected" +
|
36
|
+
" and #{@rejected_types.join(", ")} accepted by #{@attachment_name}"
|
37
|
+
end
|
38
|
+
|
39
|
+
def description
|
40
|
+
"validate the content types allowed on attachment #{@attachment_name}"
|
41
|
+
end
|
42
|
+
|
43
|
+
protected
|
44
|
+
|
45
|
+
def allow_types?(types)
|
46
|
+
types.all? do |type|
|
47
|
+
file = StringIO.new(".")
|
48
|
+
file.content_type = type
|
49
|
+
attachment = @subject.new.attachment_for(@attachment_name)
|
50
|
+
attachment.assign(file)
|
51
|
+
attachment.errors[:content_type].nil?
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
def allowed_types_allowed?
|
56
|
+
allow_types?(@allowed_types)
|
57
|
+
end
|
58
|
+
|
59
|
+
def rejected_types_rejected?
|
60
|
+
not allow_types?(@rejected_types)
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
@@ -0,0 +1,48 @@
|
|
1
|
+
module Paperclip
|
2
|
+
module Shoulda
|
3
|
+
module Matchers
|
4
|
+
def validate_attachment_presence name
|
5
|
+
ValidateAttachmentPresenceMatcher.new(name)
|
6
|
+
end
|
7
|
+
|
8
|
+
class ValidateAttachmentPresenceMatcher
|
9
|
+
def initialize attachment_name
|
10
|
+
@attachment_name = attachment_name
|
11
|
+
end
|
12
|
+
|
13
|
+
def matches? subject
|
14
|
+
@subject = subject
|
15
|
+
error_when_not_valid? && no_error_when_valid?
|
16
|
+
end
|
17
|
+
|
18
|
+
def failure_message
|
19
|
+
"Attachment #{@attachment_name} should be required"
|
20
|
+
end
|
21
|
+
|
22
|
+
def negative_failure_message
|
23
|
+
"Attachment #{@attachment_name} should not be required"
|
24
|
+
end
|
25
|
+
|
26
|
+
def description
|
27
|
+
"require presence of attachment #{@attachment_name}"
|
28
|
+
end
|
29
|
+
|
30
|
+
protected
|
31
|
+
|
32
|
+
def error_when_not_valid?
|
33
|
+
@attachment = @subject.new.send(@attachment_name)
|
34
|
+
@attachment.assign(nil)
|
35
|
+
not @attachment.errors[:presence].nil?
|
36
|
+
end
|
37
|
+
|
38
|
+
def no_error_when_valid?
|
39
|
+
@file = StringIO.new(".")
|
40
|
+
@attachment = @subject.new.send(@attachment_name)
|
41
|
+
@attachment.assign(@file)
|
42
|
+
@attachment.errors[:presence].nil?
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
@@ -0,0 +1,83 @@
|
|
1
|
+
module Paperclip
|
2
|
+
module Shoulda
|
3
|
+
module Matchers
|
4
|
+
def validate_attachment_size name
|
5
|
+
ValidateAttachmentSizeMatcher.new(name)
|
6
|
+
end
|
7
|
+
|
8
|
+
class ValidateAttachmentSizeMatcher
|
9
|
+
def initialize attachment_name
|
10
|
+
@attachment_name = attachment_name
|
11
|
+
@low, @high = 0, (1.0/0)
|
12
|
+
end
|
13
|
+
|
14
|
+
def less_than size
|
15
|
+
@high = size
|
16
|
+
self
|
17
|
+
end
|
18
|
+
|
19
|
+
def greater_than size
|
20
|
+
@low = size
|
21
|
+
self
|
22
|
+
end
|
23
|
+
|
24
|
+
def in range
|
25
|
+
@low, @high = range.first, range.last
|
26
|
+
self
|
27
|
+
end
|
28
|
+
|
29
|
+
def matches? subject
|
30
|
+
@subject = subject
|
31
|
+
lower_than_low? && higher_than_low? && lower_than_high? && higher_than_high?
|
32
|
+
end
|
33
|
+
|
34
|
+
def failure_message
|
35
|
+
"Attachment #{@attachment_name} must be between #{@low} and #{@high} bytes"
|
36
|
+
end
|
37
|
+
|
38
|
+
def negative_failure_message
|
39
|
+
"Attachment #{@attachment_name} cannot be between #{@low} and #{@high} bytes"
|
40
|
+
end
|
41
|
+
|
42
|
+
def description
|
43
|
+
"validate the size of attachment #{@attachment_name}"
|
44
|
+
end
|
45
|
+
|
46
|
+
protected
|
47
|
+
|
48
|
+
def override_method object, method, &replacement
|
49
|
+
(class << object; self; end).class_eval do
|
50
|
+
define_method(method, &replacement)
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
def passes_validation_with_size(new_size)
|
55
|
+
file = StringIO.new(".")
|
56
|
+
override_method(file, :size){ new_size }
|
57
|
+
attachment = @subject.new.attachment_for(@attachment_name)
|
58
|
+
attachment.assign(file)
|
59
|
+
attachment.errors[:size].nil?
|
60
|
+
end
|
61
|
+
|
62
|
+
def lower_than_low?
|
63
|
+
not passes_validation_with_size(@low - 1)
|
64
|
+
end
|
65
|
+
|
66
|
+
def higher_than_low?
|
67
|
+
passes_validation_with_size(@low + 1)
|
68
|
+
end
|
69
|
+
|
70
|
+
def lower_than_high?
|
71
|
+
return true if @high == (1.0/0)
|
72
|
+
passes_validation_with_size(@high - 1)
|
73
|
+
end
|
74
|
+
|
75
|
+
def higher_than_high?
|
76
|
+
return true if @high == (1.0/0)
|
77
|
+
not passes_validation_with_size(@high + 1)
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
data/shoulda_macros/paperclip.rb
CHANGED
@@ -1,3 +1,5 @@
|
|
1
|
+
require 'shoulda_macros/matchers'
|
2
|
+
|
1
3
|
module Paperclip
|
2
4
|
# =Paperclip Shoulda Macros
|
3
5
|
#
|
@@ -10,39 +12,20 @@ module Paperclip
|
|
10
12
|
# This will test whether you have defined your attachment correctly by
|
11
13
|
# checking for all the required fields exist after the definition of the
|
12
14
|
# attachment.
|
13
|
-
def should_have_attached_file name
|
14
|
-
klass
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
assert klass.instance_methods.include?(meth), "#{klass.name} does not respond to #{name}."
|
19
|
-
end
|
20
|
-
end
|
15
|
+
def should_have_attached_file name
|
16
|
+
klass = self.name.gsub(/Test$/, '').constantize
|
17
|
+
matcher = have_attached_file name
|
18
|
+
should matcher.description do
|
19
|
+
assert_accepts(matcher, klass)
|
21
20
|
end
|
22
21
|
end
|
23
22
|
|
24
23
|
# Tests for validations on the presence of the attachment.
|
25
24
|
def should_validate_attachment_presence name
|
26
25
|
klass = self.name.gsub(/Test$/, '').constantize
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
@attachment = klass.new.send(name)
|
31
|
-
@attachment.assign(nil)
|
32
|
-
end
|
33
|
-
should "have a :presence validation error" do
|
34
|
-
assert @assignment.errors[:presence]
|
35
|
-
end
|
36
|
-
end
|
37
|
-
context "when the assignment is valid" do
|
38
|
-
setup do
|
39
|
-
@attachment = klass.new.send(name)
|
40
|
-
@attachment.assign(nil)
|
41
|
-
end
|
42
|
-
should "have a :presence validation error" do
|
43
|
-
assert ! @assignment.errors[:presence]
|
44
|
-
end
|
45
|
-
end
|
26
|
+
matcher = validate_attachment_presence name
|
27
|
+
should matcher.description do
|
28
|
+
assert_accepts(matcher, klass)
|
46
29
|
end
|
47
30
|
end
|
48
31
|
|
@@ -54,35 +37,9 @@ module Paperclip
|
|
54
37
|
klass = self.name.gsub(/Test$/, '').constantize
|
55
38
|
valid = [options[:valid]].flatten
|
56
39
|
invalid = [options[:invalid]].flatten
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
setup do
|
61
|
-
@file = StringIO.new(".")
|
62
|
-
class << @file; attr_accessor :content_type; end
|
63
|
-
@file.content_type = type
|
64
|
-
@attachment = klass.new.send(name)
|
65
|
-
@attachment.assign(@file)
|
66
|
-
end
|
67
|
-
should "not have a :content_type validation error" do
|
68
|
-
assert ! @assignment.errors[:content_type]
|
69
|
-
end
|
70
|
-
end
|
71
|
-
end
|
72
|
-
invalid.each do |type|
|
73
|
-
context "being assigned a file with a content_type of #{type}" do
|
74
|
-
setup do
|
75
|
-
@file = StringIO.new(".")
|
76
|
-
class << @file; attr_accessor :content_type; end
|
77
|
-
@file.content_type = type
|
78
|
-
@attachment = klass.new.send(name)
|
79
|
-
@attachment.assign(@file)
|
80
|
-
end
|
81
|
-
should "have a :content_type validation error" do
|
82
|
-
assert @assignment.errors[:content_type]
|
83
|
-
end
|
84
|
-
end
|
85
|
-
end
|
40
|
+
matcher = validate_attachment_presence(name).allows(valid).rejects(invalid)
|
41
|
+
should matcher.description do
|
42
|
+
assert_accepts(matcher, klass)
|
86
43
|
end
|
87
44
|
end
|
88
45
|
|
@@ -97,57 +54,15 @@ module Paperclip
|
|
97
54
|
min = options[:greater_than] || (options[:in] && options[:in].first) || 0
|
98
55
|
max = options[:less_than] || (options[:in] && options[:in].last) || (1.0/0)
|
99
56
|
range = (min..max)
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
@file = StringIO.new("." * (max+1))
|
104
|
-
@attachment = klass.new.send(name)
|
105
|
-
@attachment.assign(@file)
|
106
|
-
end
|
107
|
-
|
108
|
-
should "have a :size validation error" do
|
109
|
-
assert @attachment.errors[:size]
|
110
|
-
end
|
111
|
-
end
|
112
|
-
context "with an attachment that us #{max-1} bytes" do
|
113
|
-
setup do
|
114
|
-
@file = StringIO.new("." * (max-1))
|
115
|
-
@attachment = klass.new.send(name)
|
116
|
-
@attachment.assign(@file)
|
117
|
-
end
|
118
|
-
|
119
|
-
should "not have a :size validation error" do
|
120
|
-
assert ! @attachment.errors[:size]
|
121
|
-
end
|
122
|
-
end
|
123
|
-
|
124
|
-
if min > 0
|
125
|
-
context "with an attachment that is #{min-1} bytes" do
|
126
|
-
setup do
|
127
|
-
@file = StringIO.new("." * (min-1))
|
128
|
-
@attachment = klass.new.send(name)
|
129
|
-
@attachment.assign(@file)
|
130
|
-
end
|
131
|
-
|
132
|
-
should "have a :size validation error" do
|
133
|
-
assert @attachment.errors[:size]
|
134
|
-
end
|
135
|
-
end
|
136
|
-
context "with an attachment that us #{min+1} bytes" do
|
137
|
-
setup do
|
138
|
-
@file = StringIO.new("." * (min+1))
|
139
|
-
@attachment = klass.new.send(name)
|
140
|
-
@attachment.assign(@file)
|
141
|
-
end
|
142
|
-
|
143
|
-
should "not have a :size validation error" do
|
144
|
-
assert ! @attachment.errors[:size]
|
145
|
-
end
|
146
|
-
end
|
147
|
-
end
|
57
|
+
matcher = validate_attachment_size(name).in(range)
|
58
|
+
should matcher.description do
|
59
|
+
assert_accepts(matcher, klass)
|
148
60
|
end
|
149
61
|
end
|
150
62
|
end
|
151
63
|
end
|
152
64
|
|
153
|
-
Test::Unit::TestCase
|
65
|
+
class Test::Unit::TestCase #:nodoc:
|
66
|
+
extend Paperclip::Shoulda
|
67
|
+
include Paperclip::Shoulda::Matchers
|
68
|
+
end
|
data/tasks/paperclip_tasks.rake
CHANGED
@@ -17,7 +17,7 @@ end
|
|
17
17
|
def for_all_attachments
|
18
18
|
klass = obtain_class
|
19
19
|
names = obtain_attachments
|
20
|
-
ids = klass.connection.select_values(
|
20
|
+
ids = klass.connection.select_values(klass.send(:construct_finder_sql, :select => 'id'))
|
21
21
|
|
22
22
|
ids.each do |id|
|
23
23
|
instance = klass.find(id)
|
data/test/attachment_test.rb
CHANGED
@@ -95,14 +95,10 @@ class AttachmentTest < Test::Unit::TestCase
|
|
95
95
|
rebuild_model :path => ":rails_env/:id.png"
|
96
96
|
@dummy = Dummy.new
|
97
97
|
@dummy.stubs(:id).returns(@id)
|
98
|
-
@file =
|
99
|
-
"fixtures",
|
100
|
-
"5k.png"), 'rb')
|
98
|
+
@file = StringIO.new(".")
|
101
99
|
@dummy.avatar = @file
|
102
100
|
end
|
103
101
|
|
104
|
-
teardown { @file.close }
|
105
|
-
|
106
102
|
should "return the proper path" do
|
107
103
|
temporary_rails_env(@rails_env) {
|
108
104
|
assert_equal "#{@rails_env}/#{@id}.png", @dummy.avatar.path
|
@@ -170,6 +166,35 @@ class AttachmentTest < Test::Unit::TestCase
|
|
170
166
|
end
|
171
167
|
end
|
172
168
|
|
169
|
+
geometry_specs = [
|
170
|
+
[ lambda{|z| "50x50#" }, :png ],
|
171
|
+
lambda{|z| "50x50#" },
|
172
|
+
{ :geometry => lambda{|z| "50x50#" } }
|
173
|
+
]
|
174
|
+
geometry_specs.each do |geometry_spec|
|
175
|
+
context "An attachment geometry like #{geometry_spec}" do
|
176
|
+
setup do
|
177
|
+
rebuild_model :styles => { :normal => geometry_spec }
|
178
|
+
@attachment = Dummy.new.avatar
|
179
|
+
end
|
180
|
+
|
181
|
+
should "not run the procs immediately" do
|
182
|
+
assert_kind_of Proc, @attachment.styles[:normal][:geometry]
|
183
|
+
end
|
184
|
+
|
185
|
+
context "when assigned" do
|
186
|
+
setup do
|
187
|
+
@file = StringIO.new(".")
|
188
|
+
@attachment.assign(@file)
|
189
|
+
end
|
190
|
+
|
191
|
+
should "have the correct geometry" do
|
192
|
+
assert_equal "50x50#", @attachment.styles[:normal][:geometry]
|
193
|
+
end
|
194
|
+
end
|
195
|
+
end
|
196
|
+
end
|
197
|
+
|
173
198
|
context "An attachment with both 'normal' and hash-style styles" do
|
174
199
|
setup do
|
175
200
|
rebuild_model :styles => {
|
@@ -234,7 +259,7 @@ class AttachmentTest < Test::Unit::TestCase
|
|
234
259
|
def do_before_all; end
|
235
260
|
def do_after_all; end
|
236
261
|
end
|
237
|
-
@file =
|
262
|
+
@file = StringIO.new(".")
|
238
263
|
@file.stubs(:to_tempfile).returns(@file)
|
239
264
|
@dummy = Dummy.new
|
240
265
|
Paperclip::Thumbnail.stubs(:make).returns(@file)
|
@@ -281,7 +306,7 @@ class AttachmentTest < Test::Unit::TestCase
|
|
281
306
|
context "Assigning an attachment" do
|
282
307
|
setup do
|
283
308
|
rebuild_model :styles => { :something => "100x100#" }
|
284
|
-
@file =
|
309
|
+
@file = StringIO.new(".")
|
285
310
|
@file.expects(:original_filename).returns("5k.png\n\n")
|
286
311
|
@file.expects(:content_type).returns("image/png\n\n")
|
287
312
|
@file.stubs(:to_tempfile).returns(@file)
|
@@ -521,8 +546,16 @@ class AttachmentTest < Test::Unit::TestCase
|
|
521
546
|
assert_nothing_raised { @dummy.avatar = @file }
|
522
547
|
end
|
523
548
|
|
524
|
-
should "return
|
549
|
+
should "return the time when sent #avatar_updated_at" do
|
550
|
+
now = Time.now
|
551
|
+
Time.stubs(:now).returns(now)
|
525
552
|
@dummy.avatar = @file
|
553
|
+
assert now, @dummy.avatar.updated_at
|
554
|
+
end
|
555
|
+
|
556
|
+
should "return nil when reloaded and sent #avatar_updated_at" do
|
557
|
+
@dummy.save
|
558
|
+
@dummy.reload
|
526
559
|
assert_nil @dummy.avatar.updated_at
|
527
560
|
end
|
528
561
|
|
Binary file
|
data/test/helper.rb
CHANGED
@@ -1,5 +1,6 @@
|
|
1
1
|
require 'rubygems'
|
2
2
|
require 'test/unit'
|
3
|
+
gem 'thoughtbot-shoulda', ">= 2.9.0"
|
3
4
|
require 'shoulda'
|
4
5
|
require 'mocha'
|
5
6
|
require 'tempfile'
|
@@ -22,12 +23,31 @@ $LOAD_PATH << File.join(ROOT, 'lib', 'paperclip')
|
|
22
23
|
|
23
24
|
require File.join(ROOT, 'lib', 'paperclip.rb')
|
24
25
|
|
26
|
+
require 'shoulda_macros/paperclip'
|
27
|
+
|
25
28
|
ENV['RAILS_ENV'] ||= 'test'
|
26
29
|
|
27
30
|
FIXTURES_DIR = File.join(File.dirname(__FILE__), "fixtures")
|
28
31
|
config = YAML::load(IO.read(File.dirname(__FILE__) + '/database.yml'))
|
29
32
|
ActiveRecord::Base.logger = Logger.new(File.dirname(__FILE__) + "/debug.log")
|
30
|
-
ActiveRecord::Base.establish_connection(config[
|
33
|
+
ActiveRecord::Base.establish_connection(config['test'])
|
34
|
+
|
35
|
+
def reset_class class_name
|
36
|
+
ActiveRecord::Base.send(:include, Paperclip)
|
37
|
+
Object.send(:remove_const, class_name) rescue nil
|
38
|
+
klass = Object.const_set(class_name, Class.new(ActiveRecord::Base))
|
39
|
+
klass.class_eval{ include Paperclip }
|
40
|
+
klass
|
41
|
+
end
|
42
|
+
|
43
|
+
def reset_table table_name, &block
|
44
|
+
block ||= lambda{ true }
|
45
|
+
ActiveRecord::Base.connection.create_table :dummies, {:force => true}, &block
|
46
|
+
end
|
47
|
+
|
48
|
+
def modify_table table_name, &block
|
49
|
+
ActiveRecord::Base.connection.change_table :dummies, &block
|
50
|
+
end
|
31
51
|
|
32
52
|
def rebuild_model options = {}
|
33
53
|
ActiveRecord::Base.connection.create_table :dummies, :force => true do |table|
|
data/test/integration_test.rb
CHANGED
@@ -106,6 +106,10 @@ class IntegrationTest < Test::Unit::TestCase
|
|
106
106
|
assert ! File.exists?(File.dirname(@saved_path))
|
107
107
|
assert ! File.exists?(File.dirname(File.dirname(@saved_path)))
|
108
108
|
end
|
109
|
+
|
110
|
+
before_should "not die if an unexpected SystemCallError happens" do
|
111
|
+
FileUtils.stubs(:rmdir).raises(Errno::EPIPE)
|
112
|
+
end
|
109
113
|
end
|
110
114
|
end
|
111
115
|
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
require 'test/helper'
|
2
|
+
|
3
|
+
class HaveAttachedFileMatcherTest < Test::Unit::TestCase
|
4
|
+
context "have_attached_file" do
|
5
|
+
setup do
|
6
|
+
@dummy_class = reset_class "Dummy"
|
7
|
+
reset_table "dummies"
|
8
|
+
@matcher = have_attached_file(:avatar)
|
9
|
+
end
|
10
|
+
|
11
|
+
should "reject a class with no attachment" do
|
12
|
+
assert_rejects @matcher, @dummy_class
|
13
|
+
end
|
14
|
+
|
15
|
+
should "accept a class with an attachment" do
|
16
|
+
modify_table("dummies"){|d| d.string :avatar_file_name }
|
17
|
+
@dummy_class.has_attached_file :avatar
|
18
|
+
assert_accepts @matcher, @dummy_class
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
require 'test/helper'
|
2
|
+
|
3
|
+
class ValidateAttachmentContentTypeMatcherTest < Test::Unit::TestCase
|
4
|
+
context "validate_attachment_content_type" do
|
5
|
+
setup do
|
6
|
+
reset_table("dummies") do |d|
|
7
|
+
d.string :avatar_file_name
|
8
|
+
end
|
9
|
+
@dummy_class = reset_class "Dummy"
|
10
|
+
@dummy_class.has_attached_file :avatar
|
11
|
+
@matcher = validate_attachment_content_type(:avatar).
|
12
|
+
allowing(%w(image/png image/jpeg)).
|
13
|
+
rejecting(%w(audio/mp3 application/octet-stream))
|
14
|
+
end
|
15
|
+
|
16
|
+
should "reject a class with no validation" do
|
17
|
+
assert_rejects @matcher, @dummy_class
|
18
|
+
end
|
19
|
+
|
20
|
+
should "reject a class with a validation that doesn't match" do
|
21
|
+
@dummy_class.validates_attachment_content_type :avatar, :content_type => %r{audio/.*}
|
22
|
+
assert_rejects @matcher, @dummy_class
|
23
|
+
end
|
24
|
+
|
25
|
+
should "accept a class with a validation" do
|
26
|
+
@dummy_class.validates_attachment_content_type :avatar, :content_type => %r{image/.*}
|
27
|
+
assert_accepts @matcher, @dummy_class
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
require 'test/helper'
|
2
|
+
|
3
|
+
class ValidateAttachmentPresenceMatcherTest < Test::Unit::TestCase
|
4
|
+
context "validate_attachment_presence" do
|
5
|
+
setup do
|
6
|
+
reset_table("dummies"){|d| d.string :avatar_file_name }
|
7
|
+
@dummy_class = reset_class "Dummy"
|
8
|
+
@dummy_class.has_attached_file :avatar
|
9
|
+
@matcher = validate_attachment_presence(:avatar)
|
10
|
+
end
|
11
|
+
|
12
|
+
should "reject a class with no validation" do
|
13
|
+
assert_rejects @matcher, @dummy_class
|
14
|
+
end
|
15
|
+
|
16
|
+
should "accept a class with a validation" do
|
17
|
+
@dummy_class.validates_attachment_presence :avatar
|
18
|
+
assert_accepts @matcher, @dummy_class
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
require 'test/helper'
|
2
|
+
|
3
|
+
class ValidateAttachmentSizeMatcherTest < Test::Unit::TestCase
|
4
|
+
context "validate_attachment_size" do
|
5
|
+
setup do
|
6
|
+
reset_table("dummies") do |d|
|
7
|
+
d.string :avatar_file_name
|
8
|
+
end
|
9
|
+
@dummy_class = reset_class "Dummy"
|
10
|
+
@dummy_class.has_attached_file :avatar
|
11
|
+
end
|
12
|
+
|
13
|
+
context "of limited size" do
|
14
|
+
setup{ @matcher = validate_attachment_size(:avatar).in(256..1024) }
|
15
|
+
|
16
|
+
should "reject a class with no validation" do
|
17
|
+
assert_rejects @matcher, @dummy_class
|
18
|
+
end
|
19
|
+
|
20
|
+
should "reject a class with a validation that's too high" do
|
21
|
+
@dummy_class.validates_attachment_size :avatar, :in => 256..2048
|
22
|
+
assert_rejects @matcher, @dummy_class
|
23
|
+
end
|
24
|
+
|
25
|
+
should "reject a class with a validation that's too low" do
|
26
|
+
@dummy_class.validates_attachment_size :avatar, :in => 0..1024
|
27
|
+
assert_rejects @matcher, @dummy_class
|
28
|
+
end
|
29
|
+
|
30
|
+
should "accept a class with a validation that matches" do
|
31
|
+
@dummy_class.validates_attachment_size :avatar, :in => 256..1024
|
32
|
+
assert_accepts @matcher, @dummy_class
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
context "validates_attachment_size with infinite range" do
|
37
|
+
setup{ @matcher = validate_attachment_size(:avatar) }
|
38
|
+
|
39
|
+
should "accept a class with an upper limit" do
|
40
|
+
@dummy_class.validates_attachment_size :avatar, :less_than => 1
|
41
|
+
assert_accepts @matcher, @dummy_class
|
42
|
+
end
|
43
|
+
|
44
|
+
should "accept a class with no upper limit" do
|
45
|
+
@dummy_class.validates_attachment_size :avatar, :greater_than => 1
|
46
|
+
assert_accepts @matcher, @dummy_class
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
data/test/thumbnail_test.rb
CHANGED
@@ -141,4 +141,37 @@ class ThumbnailTest < Test::Unit::TestCase
|
|
141
141
|
end
|
142
142
|
end
|
143
143
|
end
|
144
|
+
|
145
|
+
context "A multipage PDF" do
|
146
|
+
setup do
|
147
|
+
@file = File.new(File.join(File.dirname(__FILE__), "fixtures", "twopage.pdf"), 'rb')
|
148
|
+
end
|
149
|
+
|
150
|
+
teardown { @file.close }
|
151
|
+
|
152
|
+
should "start with two pages with dimensions 612x792" do
|
153
|
+
cmd = %Q[identify -format "%wx%h" "#{@file.path}"]
|
154
|
+
assert_equal "612x792"*2, `#{cmd}`.chomp
|
155
|
+
end
|
156
|
+
|
157
|
+
context "being thumbnailed at 100x100 with cropping" do
|
158
|
+
setup do
|
159
|
+
@thumb = Paperclip::Thumbnail.new(@file, :geometry => "100x100#", :format => :png)
|
160
|
+
end
|
161
|
+
|
162
|
+
should "report its correct current and target geometries" do
|
163
|
+
assert_equal "100x100#", @thumb.target_geometry.to_s
|
164
|
+
assert_equal "612x792", @thumb.current_geometry.to_s
|
165
|
+
end
|
166
|
+
|
167
|
+
should "report its correct format" do
|
168
|
+
assert_equal :png, @thumb.format
|
169
|
+
end
|
170
|
+
|
171
|
+
should "create the thumbnail when sent #make" do
|
172
|
+
dst = @thumb.make
|
173
|
+
assert_match /100x100/, `identify "#{dst.path}"`
|
174
|
+
end
|
175
|
+
end
|
176
|
+
end
|
144
177
|
end
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: thoughtbot-paperclip
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 2.2.
|
4
|
+
version: 2.2.3
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Jon Yurek
|
@@ -9,7 +9,7 @@ autorequire:
|
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
11
|
|
12
|
-
date:
|
12
|
+
date: 2009-02-07 00:00:00 -08:00
|
13
13
|
default_executable:
|
14
14
|
dependencies:
|
15
15
|
- !ruby/object:Gem::Dependency
|
@@ -77,15 +77,27 @@ files:
|
|
77
77
|
- test/fixtures/5k.png
|
78
78
|
- test/fixtures/bad.png
|
79
79
|
- test/fixtures/text.txt
|
80
|
+
- test/fixtures/twopage.pdf
|
80
81
|
- test/geometry_test.rb
|
81
82
|
- test/helper.rb
|
82
83
|
- test/integration_test.rb
|
83
84
|
- test/iostream_test.rb
|
85
|
+
- test/matchers
|
86
|
+
- test/matchers/have_attached_file_matcher_test.rb
|
87
|
+
- test/matchers/validate_attachment_content_type_matcher_test.rb
|
88
|
+
- test/matchers/validate_attachment_presence_matcher_test.rb
|
89
|
+
- test/matchers/validate_attachment_size_matcher_test.rb
|
84
90
|
- test/paperclip_test.rb
|
85
91
|
- test/processor_test.rb
|
86
92
|
- test/s3.yml
|
87
93
|
- test/storage_test.rb
|
88
94
|
- test/thumbnail_test.rb
|
95
|
+
- shoulda_macros/matchers
|
96
|
+
- shoulda_macros/matchers/have_attached_file_matcher.rb
|
97
|
+
- shoulda_macros/matchers/validate_attachment_content_type_matcher.rb
|
98
|
+
- shoulda_macros/matchers/validate_attachment_presence_matcher.rb
|
99
|
+
- shoulda_macros/matchers/validate_attachment_size_matcher.rb
|
100
|
+
- shoulda_macros/matchers.rb
|
89
101
|
- shoulda_macros/paperclip.rb
|
90
102
|
has_rdoc: true
|
91
103
|
homepage: http://www.thoughtbot.com/projects/paperclip
|