dm-paperclip 2.1.2

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.
@@ -0,0 +1,253 @@
1
+ module Paperclip
2
+ # The Attachment class manages the files for a given attachment. It saves when the model saves,
3
+ # deletes when the model is destroyed, and processes the file upon assignment.
4
+ class Attachment
5
+
6
+ def self.default_options
7
+ @default_options ||= {
8
+ :url => "/:attachment/:id/:style/:basename.:extension",
9
+ :path => ":merb_root/public/:attachment/:id/:style/:basename.:extension",
10
+ :styles => {},
11
+ :default_url => "/:attachment/:style/missing.png",
12
+ :default_style => :original,
13
+ :validations => [],
14
+ :storage => :filesystem
15
+ }
16
+ end
17
+
18
+ attr_reader :name, :instance, :styles, :default_style
19
+
20
+ # Creates an Attachment object. +name+ is the name of the attachment, +instance+ is the
21
+ # ActiveRecord object instance it's attached to, and +options+ is the same as the hash
22
+ # passed to +has_attached_file+.
23
+ def initialize name, instance, options = {}
24
+ @name = name
25
+ @instance = instance
26
+
27
+ options = self.class.default_options.merge(options)
28
+
29
+ @url = options[:url]
30
+ @path = options[:path]
31
+ @styles = options[:styles]
32
+ @default_url = options[:default_url]
33
+ @validations = options[:validations]
34
+ @default_style = options[:default_style]
35
+ @storage = options[:storage]
36
+ @whiny_thumbnails = options[:whiny_thumbnails]
37
+ @options = options
38
+ @queued_for_delete = []
39
+ @queued_for_write = {}
40
+ @errors = []
41
+ @validation_errors = nil
42
+ @dirty = false
43
+
44
+ normalize_style_definition
45
+ initialize_storage
46
+ end
47
+
48
+ # What gets called when you call instance.attachment = File. It clears errors,
49
+ # assigns attributes, processes the file, and runs validations. It also queues up
50
+ # the previous file for deletion, to be flushed away on #save of its host.
51
+ def assign uploaded_file
52
+ return nil unless valid_assignment?(uploaded_file)
53
+
54
+ queue_existing_for_delete
55
+ @errors = []
56
+ @validation_errors = nil
57
+
58
+ return nil if uploaded_file.nil?
59
+
60
+ @queued_for_write[:original] = uploaded_file.to_tempfile
61
+ newvals = { :"#{@name}_file_name" => uploaded_file.original_filename,
62
+ :"#{@name}_content_type" => uploaded_file.content_type,
63
+ :"#{@name}_file_size" => uploaded_file.size }
64
+ @instance.update_attributes(newvals)
65
+
66
+ @dirty = true
67
+
68
+ post_process
69
+ ensure
70
+ validate
71
+ end
72
+
73
+ # Returns the public URL of the attachment, with a given style. Note that this
74
+ # does not necessarily need to point to a file that your web server can access
75
+ # and can point to an action in your app, if you need fine grained security.
76
+ # This is not recommended if you don't need the security, however, for
77
+ # performance reasons.
78
+ def url style = default_style
79
+ original_filename.nil? ? interpolate(@default_url, style) : interpolate(@url, style)
80
+ end
81
+
82
+ # Returns the path of the attachment as defined by the :path optionn. If the
83
+ # file is stored in the filesystem the path refers to the path of the file on
84
+ # disk. If the file is stored in S3, the path is the "key" part of th URL,
85
+ # and the :bucket option refers to the S3 bucket.
86
+ def path style = nil #:nodoc:
87
+ interpolate(@path, style)
88
+ end
89
+
90
+ # Alias to +url+
91
+ def to_s style = nil
92
+ url(style)
93
+ end
94
+
95
+ # Returns true if there are any errors on this attachment.
96
+ def valid?
97
+ @errors.length == 0
98
+ end
99
+
100
+ # Returns an array containing the errors on this attachment.
101
+ def errors
102
+ @errors.compact.uniq
103
+ end
104
+
105
+ # Returns true if there are changes that need to be saved.
106
+ def dirty?
107
+ @dirty
108
+ end
109
+
110
+ # Saves the file, if there are no errors. If there are, it flushes them to
111
+ # the instance's errors and returns false, cancelling the save.
112
+ def save
113
+ if valid?
114
+ flush_deletes
115
+ flush_writes
116
+ @dirty = false
117
+ true
118
+ else
119
+ flush_errors
120
+ false
121
+ end
122
+ end
123
+
124
+ # Returns the name of the file as originally assigned, and as lives in the
125
+ # <attachment>_file_name attribute of the model.
126
+ def original_filename
127
+ @instance.attribute_get(:"#{name}_file_name")
128
+ end
129
+
130
+ # A hash of procs that are run during the interpolation of a path or url.
131
+ # A variable of the format :name will be replaced with the return value of
132
+ # the proc named ":name". Each lambda takes the attachment and the current
133
+ # style as arguments. This hash can be added to with your own proc if
134
+ # necessary.
135
+ def self.interpolations
136
+ @interpolations ||= {
137
+ :merb_root => lambda{|attachment,style| Merb.root },
138
+ :class => lambda do |attachment,style|
139
+ underscore(attachment.instance.class.name.pluralize)
140
+ end,
141
+ :basename => lambda do |attachment,style|
142
+ attachment.original_filename.gsub(File.extname(attachment.original_filename), "")
143
+ end,
144
+ :extension => lambda do |attachment,style|
145
+ ((style = attachment.styles[style]) && style.last) ||
146
+ File.extname(attachment.original_filename).gsub(/^\.+/, "")
147
+ end,
148
+ :id => lambda{|attachment,style| attachment.instance.id },
149
+ :id_partition => lambda do |attachment, style|
150
+ ("%09d" % attachment.instance.id).scan(/\d{3}/).join("/")
151
+ end,
152
+ :attachment => lambda{|attachment,style| attachment.name.to_s.downcase.pluralize },
153
+ :style => lambda{|attachment,style| style || attachment.default_style },
154
+ }
155
+ end
156
+
157
+ # This method really shouldn't be called that often. It's expected use is in the
158
+ # paperclip:refresh rake task and that's it. It will regenerate all thumbnails
159
+ # forcefully, by reobtaining the original file and going through the post-process
160
+ # again.
161
+ def reprocess!
162
+ new_original = Tempfile.new("paperclip-reprocess")
163
+ old_original = to_file(:original)
164
+ new_original.write( old_original.read )
165
+ new_original.rewind
166
+
167
+ @queued_for_write = { :original => new_original }
168
+ post_process
169
+
170
+ old_original.close if old_original.respond_to?(:close)
171
+ end
172
+
173
+ private
174
+
175
+ def self.underscore(camel_cased_word)
176
+ camel_cased_word.to_s.gsub(/::/, '/').
177
+ gsub(/([A-Z]+)([A-Z][a-z])/,'\1_\2').
178
+ gsub(/([a-z\d])([A-Z])/,'\1_\2').
179
+ tr("-", "_").
180
+ downcase
181
+ end
182
+
183
+ def valid_assignment? file #:nodoc:
184
+ file.nil? || (file.respond_to?(:original_filename) && file.respond_to?(:content_type))
185
+ end
186
+
187
+ def validate #:nodoc:
188
+ unless @validation_errors
189
+ @validation_errors = @validations.collect do |v|
190
+ v.call(self, @instance)
191
+ end.flatten.compact.uniq
192
+ @errors += @validation_errors
193
+ end
194
+ end
195
+
196
+ def normalize_style_definition
197
+ @styles.each do |name, args|
198
+ dimensions, format = [args, nil].flatten[0..1]
199
+ format = nil if format == ""
200
+ @styles[name] = [dimensions, format]
201
+ end
202
+ end
203
+
204
+ def initialize_storage
205
+ @storage_module = Paperclip::Storage.const_get(@storage.to_s.capitalize)
206
+ self.extend(@storage_module)
207
+ end
208
+
209
+ def post_process #:nodoc:
210
+ return if @queued_for_write[:original].nil?
211
+ @styles.each do |name, args|
212
+ begin
213
+ dimensions, format = args
214
+ @queued_for_write[name] = Thumbnail.make(@queued_for_write[:original],
215
+ dimensions,
216
+ format,
217
+ @whiny_thumnails)
218
+ rescue PaperclipError => e
219
+ @errors << e.message if @whiny_thumbnails
220
+ end
221
+ end
222
+ end
223
+
224
+ def interpolate pattern, style = default_style #:nodoc:
225
+ interpolations = self.class.interpolations.sort{|a,b| a.first.to_s <=> b.first.to_s }
226
+ interpolations.reverse.inject( pattern.dup ) do |result, interpolation|
227
+ tag, blk = interpolation
228
+ result.gsub(/:#{tag}/) do |match|
229
+ blk.call( self, style )
230
+ end
231
+ end
232
+ end
233
+
234
+ def queue_existing_for_delete #:nodoc:
235
+ return if original_filename.blank?
236
+ @queued_for_delete += [:original, *@styles.keys].uniq.map do |style|
237
+ path(style) if exists?(style)
238
+ end.compact
239
+ newvals = { :"#{@name}_file_name" => nil,
240
+ :"#{@name}_content_type" => nil,
241
+ :"#{@name}_file_size" => nil }
242
+ @instance.update_attributes(newvals)
243
+ end
244
+
245
+ def flush_errors #:nodoc:
246
+ @errors.each do |error|
247
+ @instance.errors.add(name, error)
248
+ end
249
+ end
250
+
251
+ end
252
+ end
253
+
@@ -0,0 +1,109 @@
1
+ module Paperclip
2
+
3
+ # Defines the geometry of an image.
4
+ class Geometry
5
+ attr_accessor :height, :width, :modifier
6
+
7
+ # Gives a Geometry representing the given height and width
8
+ def initialize width = nil, height = nil, modifier = nil
9
+ height = nil if height == ""
10
+ width = nil if width == ""
11
+ @height = (height || width).to_f
12
+ @width = (width || height).to_f
13
+ @modifier = modifier
14
+ end
15
+
16
+ # Uses ImageMagick to determing the dimensions of a file, passed in as either a
17
+ # File or path.
18
+ def self.from_file file
19
+ file = file.path if file.respond_to? "path"
20
+ parse(`#{Paperclip.path_for_command('identify')} "#{file}"`) ||
21
+ raise(NotIdentifiedByImageMagickError.new("#{file} is not recognized by the 'identify' command."))
22
+ end
23
+
24
+ # Parses a "WxH" formatted string, where W is the width and H is the height.
25
+ def self.parse string
26
+ if match = (string && string.match(/\b(\d*)x(\d*)\b([\>\<\#\@\%^!])?/))
27
+ Geometry.new(*match[1,3])
28
+ end
29
+ end
30
+
31
+ # True if the dimensions represent a square
32
+ def square?
33
+ height == width
34
+ end
35
+
36
+ # True if the dimensions represent a horizontal rectangle
37
+ def horizontal?
38
+ height < width
39
+ end
40
+
41
+ # True if the dimensions represent a vertical rectangle
42
+ def vertical?
43
+ height > width
44
+ end
45
+
46
+ # The aspect ratio of the dimensions.
47
+ def aspect
48
+ width / height
49
+ end
50
+
51
+ # Returns the larger of the two dimensions
52
+ def larger
53
+ [height, width].max
54
+ end
55
+
56
+ # Returns the smaller of the two dimensions
57
+ def smaller
58
+ [height, width].min
59
+ end
60
+
61
+ # Returns the width and height in a format suitable to be passed to Geometry.parse
62
+ def to_s
63
+ "%dx%d%s" % [width, height, modifier]
64
+ end
65
+
66
+ # Same as to_s
67
+ def inspect
68
+ to_s
69
+ end
70
+
71
+ # Returns the scaling and cropping geometries (in string-based ImageMagick format)
72
+ # neccessary to transform this Geometry into the Geometry given. If crop is true,
73
+ # then it is assumed the destination Geometry will be the exact final resolution.
74
+ # In this case, the source Geometry is scaled so that an image containing the
75
+ # destination Geometry would be completely filled by the source image, and any
76
+ # overhanging image would be cropped. Useful for square thumbnail images. The cropping
77
+ # is weighted at the center of the Geometry.
78
+ def transformation_to dst, crop = false
79
+ ratio = Geometry.new( dst.width / self.width, dst.height / self.height )
80
+
81
+ if crop
82
+ scale_geometry, scale = scaling(dst, ratio)
83
+ crop_geometry = cropping(dst, ratio, scale)
84
+ else
85
+ scale_geometry = dst.to_s
86
+ end
87
+
88
+ [ scale_geometry, crop_geometry ]
89
+ end
90
+
91
+ private
92
+
93
+ def scaling dst, ratio
94
+ if ratio.horizontal? || ratio.square?
95
+ [ "%dx" % dst.width, ratio.width ]
96
+ else
97
+ [ "x%d" % dst.height, ratio.height ]
98
+ end
99
+ end
100
+
101
+ def cropping dst, ratio, scale
102
+ if ratio.horizontal? || ratio.square?
103
+ "%dx%d+%d+%d" % [ dst.width, dst.height, 0, (self.height * scale - dst.height) / 2 ]
104
+ else
105
+ "%dx%d+%d+%d" % [ dst.width, dst.height, (self.width * scale - dst.width) / 2, 0 ]
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,43 @@
1
+ # Provides method that can be included on File-type objects (IO, StringIO, Tempfile, etc) to allow stream copying
2
+ # and Tempfile conversion.
3
+ module IOStream
4
+
5
+ # Returns a Tempfile containing the contents of the readable object.
6
+ def to_tempfile
7
+ tempfile = Tempfile.new("stream")
8
+ tempfile.binmode
9
+ self.stream_to(tempfile)
10
+ end
11
+
12
+ # Copies one read-able object from one place to another in blocks, obviating the need to load
13
+ # the whole thing into memory. Defaults to 8k blocks. If this module is included in both
14
+ # both StringIO and Tempfile, then either can have its data copied anywhere else without typing
15
+ # worries or memory overhead worries. Returns a File if a String is passed in as the destination
16
+ # and returns the IO or Tempfile as passed in if one is sent as the destination.
17
+ def stream_to path_or_file, in_blocks_of = 8192
18
+ dstio = case path_or_file
19
+ when String then File.new(path_or_file, "wb+")
20
+ when IO then path_or_file
21
+ when Tempfile then path_or_file
22
+ end
23
+ buffer = ""
24
+ self.rewind
25
+ while self.read(in_blocks_of, buffer) do
26
+ dstio.write(buffer)
27
+ end
28
+ dstio.rewind
29
+ dstio
30
+ end
31
+ end
32
+
33
+ class IO
34
+ include IOStream
35
+ end
36
+
37
+ %w( Tempfile StringIO ).each do |klass|
38
+ if Object.const_defined? klass
39
+ Object.const_get(klass).class_eval do
40
+ include IOStream
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,148 @@
1
+ module Paperclip
2
+ module Storage
3
+
4
+ module Filesystem
5
+ def self.extended base
6
+ end
7
+
8
+ def exists?(style = default_style)
9
+ if original_filename
10
+ File.exist?(path(style))
11
+ else
12
+ false
13
+ end
14
+ end
15
+
16
+ # Returns representation of the data of the file assigned to the given
17
+ # style, in the format most representative of the current storage.
18
+ def to_file style = default_style
19
+ @queued_for_write[style] || (File.new(path(style)) if exists?(style))
20
+ end
21
+ alias_method :to_io, :to_file
22
+
23
+ def flush_writes #:nodoc:
24
+ @queued_for_write.each do |style, file|
25
+ FileUtils.mkdir_p(File.dirname(path(style)))
26
+ result = file.stream_to(path(style))
27
+ file.close
28
+ result.close
29
+ end
30
+ @queued_for_write = {}
31
+ end
32
+
33
+ def flush_deletes #:nodoc:
34
+ @queued_for_delete.each do |path|
35
+ begin
36
+ FileUtils.rm(path) if File.exist?(path)
37
+ rescue Errno::ENOENT => e
38
+ # ignore file-not-found, let everything else pass
39
+ end
40
+ end
41
+ @queued_for_delete = []
42
+ end
43
+ end
44
+
45
+ module S3
46
+ def self.extended base
47
+ require 'right_aws'
48
+ base.instance_eval do
49
+ @bucket = @options[:bucket]
50
+ @s3_credentials = parse_credentials(@options[:s3_credentials])
51
+ @s3_options = @options[:s3_options] || {}
52
+ @s3_permissions = @options[:s3_permissions] || 'public-read'
53
+ @url = ":s3_url"
54
+ end
55
+ base.class.interpolations[:s3_url] = lambda do |attachment, style|
56
+ "https://s3.amazonaws.com/#{attachment.bucket_name}/#{attachment.path(style).gsub(%r{^/}, "")}"
57
+ end
58
+ end
59
+
60
+ def s3
61
+ @s3 ||= RightAws::S3.new(@s3_credentials[:access_key_id],
62
+ @s3_credentials[:secret_access_key],
63
+ @s3_options)
64
+ end
65
+
66
+ def s3_bucket
67
+ @s3_bucket ||= s3.bucket(@bucket, true, @s3_permissions)
68
+ end
69
+
70
+ def bucket_name
71
+ @bucket
72
+ end
73
+
74
+ def parse_credentials creds
75
+ creds = stringify_keys(find_credentials(creds))
76
+ symbolize_keys((creds[Merb.env] || creds))
77
+ end
78
+
79
+ def exists?(style = default_style)
80
+ s3_bucket.key(path(style)) ? true : false
81
+ end
82
+
83
+ # Returns representation of the data of the file assigned to the given
84
+ # style, in the format most representative of the current storage.
85
+ def to_file style = default_style
86
+ @queued_for_write[style] || s3_bucket.key(path(style))
87
+ end
88
+ alias_method :to_io, :to_file
89
+
90
+ def flush_writes #:nodoc:
91
+ @queued_for_write.each do |style, file|
92
+ begin
93
+ key = s3_bucket.key(path(style))
94
+ key.data = file
95
+ key.put(nil, @s3_permissions)
96
+ rescue RightAws::AwsError => e
97
+ raise
98
+ end
99
+ end
100
+ @queued_for_write = {}
101
+ end
102
+
103
+ def flush_deletes #:nodoc:
104
+ @queued_for_delete.each do |path|
105
+ begin
106
+ if file = s3_bucket.key(path)
107
+ file.delete
108
+ end
109
+ rescue RightAws::AwsError
110
+ # Ignore this.
111
+ end
112
+ end
113
+ @queued_for_delete = []
114
+ end
115
+
116
+ def find_credentials creds
117
+ case creds
118
+ when File:
119
+ YAML.load_file(creds.path)
120
+ when String:
121
+ YAML.load_file(creds)
122
+ when Hash:
123
+ creds
124
+ else
125
+ raise ArgumentError, "Credentials are not a path, file, or hash."
126
+ end
127
+ end
128
+ private :find_credentials
129
+
130
+ private
131
+
132
+ def stringify_keys(hash)
133
+ hash.inject({}) do |options, (key, value)|
134
+ options[key.to_s] = value
135
+ options
136
+ end
137
+ end
138
+
139
+ def symbolize_keys(hash)
140
+ hash.inject({}) do |options, (key, value)|
141
+ options[key.to_sym || key] = value
142
+ options
143
+ end
144
+ end
145
+
146
+ end
147
+ end
148
+ end