]> projects.mako.cc - selectricity/blob - vendor/plugins/attachment_fu/lib/technoweenie/attachment_fu.rb
fixed a large number of bugs in the software (see wiki) over a days work
[selectricity] / vendor / plugins / attachment_fu / lib / technoweenie / attachment_fu.rb
1 module Technoweenie # :nodoc:
2   module AttachmentFu # :nodoc:
3     @@default_processors = %w(ImageScience Rmagick MiniMagick Gd2 CoreImage)
4     @@tempfile_path      = File.join(RAILS_ROOT, 'tmp', 'attachment_fu')
5     @@content_types      = [
6       'image/jpeg',
7       'image/pjpeg',
8       'image/jpg',
9       'image/gif',
10       'image/png',
11       'image/x-png',
12       'image/jpg',
13       'image/x-ms-bmp',
14       'image/bmp',
15       'image/x-bmp',
16       'image/x-bitmap',
17       'image/x-xbitmap',
18       'image/x-win-bitmap',
19       'image/x-windows-bmp',
20       'image/ms-bmp',
21       'application/bmp',
22       'application/x-bmp',
23       'application/x-win-bitmap',
24       'application/preview',
25       'image/jp_',
26       'application/jpg',
27       'application/x-jpg',
28       'image/pipeg',
29       'image/vnd.swiftview-jpeg',
30       'image/x-xbitmap',
31       'application/png',
32       'application/x-png',
33       'image/gi_',
34       'image/x-citrix-pjpeg'
35     ]
36     mattr_reader :content_types, :tempfile_path, :default_processors
37     mattr_writer :tempfile_path
38
39     class ThumbnailError < StandardError;  end
40     class AttachmentError < StandardError; end
41
42     module ActMethods
43       # Options:
44       # *  <tt>:content_type</tt> - Allowed content types.  Allows all by default.  Use :image to allow all standard image types.
45       # *  <tt>:min_size</tt> - Minimum size allowed.  1 byte is the default.
46       # *  <tt>:max_size</tt> - Maximum size allowed.  1.megabyte is the default.
47       # *  <tt>:size</tt> - Range of sizes allowed.  (1..1.megabyte) is the default.  This overrides the :min_size and :max_size options.
48       # *  <tt>:resize_to</tt> - Used by RMagick to resize images.  Pass either an array of width/height, or a geometry string.
49       # *  <tt>:thumbnails</tt> - Specifies a set of thumbnails to generate.  This accepts a hash of filename suffixes and RMagick resizing options.
50       # *  <tt>:thumbnail_class</tt> - Set what class to use for thumbnails.  This attachment class is used by default.
51       # *  <tt>:path_prefix</tt> - path to store the uploaded files.  Uses public/#{table_name} by default for the filesystem, and just #{table_name}
52       #      for the S3 backend.  Setting this sets the :storage to :file_system.
53
54       # *  <tt>:storage</tt> - Use :file_system to specify the attachment data is stored with the file system.  Defaults to :db_system.
55       # *  <tt>:cloundfront</tt> - Set to true if you are using S3 storage and want to serve the files through CloudFront.  You will need to
56       #      set a distribution domain in the amazon_s3.yml config file. Defaults to false
57       # *  <tt>:bucket_key</tt> - Use this to specify a different bucket key other than :bucket_name in the amazon_s3.yml file.  This allows you to use
58       #      different buckets for different models. An example setting would be :image_bucket and the you would need to define the name of the corresponding
59       #      bucket in the amazon_s3.yml file.
60
61       # *  <tt>:keep_profile</tt> By default image EXIF data will be stripped to minimize image size. For small thumbnails this proivides important savings. Picture quality is not affected. Set to false if you want to keep the image profile as is. ImageScience will allways keep EXIF data.
62       #
63       # Examples:
64       #   has_attachment :max_size => 1.kilobyte
65       #   has_attachment :size => 1.megabyte..2.megabytes
66       #   has_attachment :content_type => 'application/pdf'
67       #   has_attachment :content_type => ['application/pdf', 'application/msword', 'text/plain']
68       #   has_attachment :content_type => :image, :resize_to => [50,50]
69       #   has_attachment :content_type => ['application/pdf', :image], :resize_to => 'x50'
70       #   has_attachment :thumbnails => { :thumb => [50, 50], :geometry => 'x50' }
71       #   has_attachment :storage => :file_system, :path_prefix => 'public/files'
72       #   has_attachment :storage => :file_system, :path_prefix => 'public/files',
73       #     :content_type => :image, :resize_to => [50,50]
74       #   has_attachment :storage => :file_system, :path_prefix => 'public/files',
75       #     :thumbnails => { :thumb => [50, 50], :geometry => 'x50' }
76       #   has_attachment :storage => :s3
77       def has_attachment(options = {})
78         # this allows you to redefine the acts' options for each subclass, however
79         options[:min_size]         ||= 1
80         options[:max_size]         ||= 1.megabyte
81         options[:size]             ||= (options[:min_size]..options[:max_size])
82         options[:thumbnails]       ||= {}
83         options[:thumbnail_class]  ||= self
84         options[:s3_access]        ||= :public_read
85         options[:cloudfront]       ||= false
86         options[:content_type] = [options[:content_type]].flatten.collect! { |t| t == :image ? Technoweenie::AttachmentFu.content_types : t }.flatten unless options[:content_type].nil?
87
88         unless options[:thumbnails].is_a?(Hash)
89           raise ArgumentError, ":thumbnails option should be a hash: e.g. :thumbnails => { :foo => '50x50' }"
90         end
91
92         extend ClassMethods unless (class << self; included_modules; end).include?(ClassMethods)
93         include InstanceMethods unless included_modules.include?(InstanceMethods)
94
95         parent_options = attachment_options || {}
96         # doing these shenanigans so that #attachment_options is available to processors and backends
97         self.attachment_options = options
98
99         attr_accessor :thumbnail_resize_options
100
101         attachment_options[:storage]     ||= (attachment_options[:file_system_path] || attachment_options[:path_prefix]) ? :file_system : :db_file
102         attachment_options[:storage]     ||= parent_options[:storage]
103         attachment_options[:path_prefix] ||= attachment_options[:file_system_path]
104         if attachment_options[:path_prefix].nil?
105           attachment_options[:path_prefix] = case attachment_options[:storage]
106             when :s3 then table_name
107             when :cloud_files then table_name
108             else File.join("public", table_name)
109           end
110         end
111         attachment_options[:path_prefix]   = attachment_options[:path_prefix][1..-1] if options[:path_prefix].first == '/'
112
113         association_options = { :foreign_key => 'parent_id' }
114         if attachment_options[:association_options]
115           association_options.merge!(attachment_options[:association_options])
116         end
117         with_options(association_options) do |m|
118           m.has_many   :thumbnails, :class_name => "::#{attachment_options[:thumbnail_class]}"
119           m.belongs_to :parent, :class_name => "::#{base_class}" unless options[:thumbnails].empty?
120         end
121
122         storage_mod = Technoweenie::AttachmentFu::Backends.const_get("#{options[:storage].to_s.classify}Backend")
123         include storage_mod unless included_modules.include?(storage_mod)
124
125         case attachment_options[:processor]
126         when :none, nil
127           processors = Technoweenie::AttachmentFu.default_processors.dup
128           begin
129             if processors.any?
130               attachment_options[:processor] = processors.first
131               processor_mod = Technoweenie::AttachmentFu::Processors.const_get("#{attachment_options[:processor].to_s.classify}Processor")
132               include processor_mod unless included_modules.include?(processor_mod)
133             end
134           rescue Object, Exception
135             raise unless load_related_exception?($!)
136
137             processors.shift
138             retry
139           end
140         else
141           begin
142             processor_mod = Technoweenie::AttachmentFu::Processors.const_get("#{attachment_options[:processor].to_s.classify}Processor")
143             include processor_mod unless included_modules.include?(processor_mod)
144           rescue Object, Exception
145             raise unless load_related_exception?($!)
146
147             puts "Problems loading #{options[:processor]}Processor: #{$!}"
148           end
149         end unless parent_options[:processor] # Don't let child override processor
150       end
151
152       def load_related_exception?(e) #:nodoc: implementation specific
153         case
154         when e.kind_of?(LoadError), e.kind_of?(MissingSourceFile), $!.class.name == "CompilationError"
155           # We can't rescue CompilationError directly, as it is part of the RubyInline library.
156           # We must instead rescue RuntimeError, and check the class' name.
157           true
158         else
159           false
160         end
161       end
162       private :load_related_exception?
163     end
164
165     module ClassMethods
166       delegate :content_types, :to => Technoweenie::AttachmentFu
167
168       # Performs common validations for attachment models.
169       def validates_as_attachment
170         validates_presence_of :size, :content_type, :filename
171         validate              :attachment_attributes_valid?
172       end
173
174       # Returns true or false if the given content type is recognized as an image.
175       def image?(content_type)
176         content_types.include?(content_type)
177       end
178
179       def self.extended(base)
180         base.class_inheritable_accessor :attachment_options
181         base.before_destroy :destroy_thumbnails
182         base.before_validation :set_size_from_temp_path
183         base.after_save :after_process_attachment
184         base.after_destroy :destroy_file
185         base.after_validation :process_attachment
186         if defined?(::ActiveSupport::Callbacks)
187           base.define_callbacks :after_resize, :after_attachment_saved, :before_thumbnail_saved
188         end
189       end
190
191       unless defined?(::ActiveSupport::Callbacks)
192         # Callback after an image has been resized.
193         #
194         #   class Foo < ActiveRecord::Base
195         #     acts_as_attachment
196         #     after_resize do |record, img|
197         #       record.aspect_ratio = img.columns.to_f / img.rows.to_f
198         #     end
199         #   end
200         def after_resize(&block)
201           write_inheritable_array(:after_resize, [block])
202         end
203
204         # Callback after an attachment has been saved either to the file system or the DB.
205         # Only called if the file has been changed, not necessarily if the record is updated.
206         #
207         #   class Foo < ActiveRecord::Base
208         #     acts_as_attachment
209         #     after_attachment_saved do |record|
210         #       ...
211         #     end
212         #   end
213         def after_attachment_saved(&block)
214           write_inheritable_array(:after_attachment_saved, [block])
215         end
216
217         # Callback before a thumbnail is saved.  Use this to pass any necessary extra attributes that may be required.
218         #
219         #   class Foo < ActiveRecord::Base
220         #     acts_as_attachment
221         #     before_thumbnail_saved do |thumbnail|
222         #       record = thumbnail.parent
223         #       ...
224         #     end
225         #   end
226         def before_thumbnail_saved(&block)
227           write_inheritable_array(:before_thumbnail_saved, [block])
228         end
229       end
230
231       # Get the thumbnail class, which is the current attachment class by default.
232       # Configure this with the :thumbnail_class option.
233       def thumbnail_class
234         attachment_options[:thumbnail_class] = attachment_options[:thumbnail_class].constantize unless attachment_options[:thumbnail_class].is_a?(Class)
235         attachment_options[:thumbnail_class]
236       end
237
238       # Copies the given file path to a new tempfile, returning the closed tempfile.
239       def copy_to_temp_file(file, temp_base_name)
240         returning Tempfile.new(temp_base_name, Technoweenie::AttachmentFu.tempfile_path) do |tmp|
241           tmp.close
242           FileUtils.cp file, tmp.path
243         end
244       end
245
246       # Writes the given data to a new tempfile, returning the closed tempfile.
247       def write_to_temp_file(data, temp_base_name)
248         returning Tempfile.new(temp_base_name, Technoweenie::AttachmentFu.tempfile_path) do |tmp|
249           tmp.binmode
250           tmp.write data
251           tmp.close
252         end
253       end
254     end
255
256     module InstanceMethods
257       def self.included(base)
258         base.define_callbacks *[:after_resize, :after_attachment_saved, :before_thumbnail_saved] if base.respond_to?(:define_callbacks)
259       end
260
261       # Checks whether the attachment's content type is an image content type
262       def image?
263         self.class.image?(content_type)
264       end
265
266       # Returns true/false if an attachment is thumbnailable.  A thumbnailable attachment has an image content type and the parent_id attribute.
267       def thumbnailable?
268         image? && respond_to?(:parent_id) && parent_id.nil?
269       end
270
271       # Returns the class used to create new thumbnails for this attachment.
272       def thumbnail_class
273         self.class.thumbnail_class
274       end
275
276       # Gets the thumbnail name for a filename.  'foo.jpg' becomes 'foo_thumbnail.jpg'
277       def thumbnail_name_for(thumbnail = nil)
278         return filename if thumbnail.blank?
279         ext = nil
280         basename = filename.gsub /\.\w+$/ do |s|
281           ext = s; ''
282         end
283         # ImageScience doesn't create gif thumbnails, only pngs
284         ext.sub!(/gif$/, 'png') if attachment_options[:processor] == "ImageScience"
285         "#{basename}_#{thumbnail}#{ext}"
286       end
287
288       # Creates or updates the thumbnail for the current attachment.
289       def create_or_update_thumbnail(temp_file, file_name_suffix, *size)
290         thumbnailable? || raise(ThumbnailError.new("Can't create a thumbnail if the content type is not an image or there is no parent_id column"))
291         returning find_or_initialize_thumbnail(file_name_suffix) do |thumb|
292           thumb.temp_paths.unshift temp_file
293           thumb.send(:'attributes=', {
294             :content_type             => content_type,
295             :filename                 => thumbnail_name_for(file_name_suffix),
296             :thumbnail_resize_options => size
297           }, false)
298           callback_with_args :before_thumbnail_saved, thumb
299           thumb.save!
300         end
301       end
302
303       # Sets the content type.
304       def content_type=(new_type)
305         write_attribute :content_type, new_type.to_s.strip
306       end
307
308       # Sanitizes a filename.
309       def filename=(new_name)
310         write_attribute :filename, sanitize_filename(new_name)
311       end
312
313       # Returns the width/height in a suitable format for the image_tag helper: (100x100)
314       def image_size
315         [width.to_s, height.to_s] * 'x'
316       end
317
318       # Returns true if the attachment data will be written to the storage system on the next save
319       def save_attachment?
320         File.file?(temp_path.to_s)
321       end
322
323       # nil placeholder in case this field is used in a form.
324       def uploaded_data() nil; end
325
326       # This method handles the uploaded file object.  If you set the field name to uploaded_data, you don't need
327       # any special code in your controller.
328       #
329       #   <% form_for :attachment, :html => { :multipart => true } do |f| -%>
330       #     <p><%= f.file_field :uploaded_data %></p>
331       #     <p><%= submit_tag :Save %>
332       #   <% end -%>
333       #
334       #   @attachment = Attachment.create! params[:attachment]
335       #
336       # TODO: Allow it to work with Merb tempfiles too.
337       def uploaded_data=(file_data)
338         if file_data.respond_to?(:content_type)
339           return nil if file_data.size == 0
340           self.content_type = file_data.content_type
341           self.filename     = file_data.original_filename if respond_to?(:filename)
342         else
343           return nil if file_data.blank? || file_data['size'] == 0
344           self.content_type = file_data['content_type']
345           self.filename =  file_data['filename']
346           file_data = file_data['tempfile']
347         end
348         if file_data.is_a?(StringIO)
349           file_data.rewind
350           set_temp_data file_data.read
351         else
352           self.temp_paths.unshift file_data
353         end
354       end
355
356       # Gets the latest temp path from the collection of temp paths.  While working with an attachment,
357       # multiple Tempfile objects may be created for various processing purposes (resizing, for example).
358       # An array of all the tempfile objects is stored so that the Tempfile instance is held on to until
359       # it's not needed anymore.  The collection is cleared after saving the attachment.
360       def temp_path
361         p = temp_paths.first
362         p.respond_to?(:path) ? p.path : p.to_s
363       end
364
365       # Gets an array of the currently used temp paths.  Defaults to a copy of #full_filename.
366       def temp_paths
367         @temp_paths ||= (new_record? || !respond_to?(:full_filename) || !File.exist?(full_filename) ?
368           [] : [copy_to_temp_file(full_filename)])
369       end
370
371       # Gets the data from the latest temp file.  This will read the file into memory.
372       def temp_data
373         save_attachment? ? File.read(temp_path) : nil
374       end
375
376       # Writes the given data to a Tempfile and adds it to the collection of temp files.
377       def set_temp_data(data)
378         temp_paths.unshift write_to_temp_file data unless data.nil?
379       end
380
381       # Copies the given file to a randomly named Tempfile.
382       def copy_to_temp_file(file)
383         self.class.copy_to_temp_file file, random_tempfile_filename
384       end
385
386       # Writes the given file to a randomly named Tempfile.
387       def write_to_temp_file(data)
388         self.class.write_to_temp_file data, random_tempfile_filename
389       end
390
391       # Stub for creating a temp file from the attachment data.  This should be defined in the backend module.
392       def create_temp_file() end
393
394       # Allows you to work with a processed representation (RMagick, ImageScience, etc) of the attachment in a block.
395       #
396       #   @attachment.with_image do |img|
397       #     self.data = img.thumbnail(100, 100).to_blob
398       #   end
399       #
400       def with_image(&block)
401         self.class.with_image(temp_path, &block)
402       end
403
404       protected
405         # Generates a unique filename for a Tempfile.
406         def random_tempfile_filename
407           "#{rand Time.now.to_i}#{filename || 'attachment'}"
408         end
409
410         def sanitize_filename(filename)
411           return unless filename
412           returning filename.strip do |name|
413             # NOTE: File.basename doesn't work right with Windows paths on Unix
414             # get only the filename, not the whole path
415             name.gsub! /^.*(\\|\/)/, ''
416
417             # Finally, replace all non alphanumeric, underscore or periods with underscore
418             name.gsub! /[^A-Za-z0-9\.\-]/, '_'
419           end
420         end
421
422         # before_validation callback.
423         def set_size_from_temp_path
424           self.size = File.size(temp_path) if save_attachment?
425         end
426
427         # validates the size and content_type attributes according to the current model's options
428         def attachment_attributes_valid?
429           [:size, :content_type].each do |attr_name|
430             enum = attachment_options[attr_name]
431             if Object.const_defined?(:I18n) # Rails >= 2.2
432               errors.add attr_name, I18n.translate("activerecord.errors.messages.inclusion", attr_name => enum) unless enum.nil? || enum.include?(send(attr_name))
433             else
434               errors.add attr_name, ActiveRecord::Errors.default_error_messages[:inclusion] unless enum.nil? || enum.include?(send(attr_name))
435             end
436           end
437         end
438
439         # Initializes a new thumbnail with the given suffix.
440         def find_or_initialize_thumbnail(file_name_suffix)
441           respond_to?(:parent_id) ?
442             thumbnail_class.find_or_initialize_by_thumbnail_and_parent_id(file_name_suffix.to_s, id) :
443             thumbnail_class.find_or_initialize_by_thumbnail(file_name_suffix.to_s)
444         end
445
446         # Stub for a #process_attachment method in a processor
447         def process_attachment
448           @saved_attachment = save_attachment?
449         end
450
451         # Cleans up after processing.  Thumbnails are created, the attachment is stored to the backend, and the temp_paths are cleared.
452         def after_process_attachment
453           if @saved_attachment
454             if respond_to?(:process_attachment_with_processing) && thumbnailable? && !attachment_options[:thumbnails].blank? && parent_id.nil?
455               temp_file = temp_path || create_temp_file
456               attachment_options[:thumbnails].each { |suffix, size| create_or_update_thumbnail(temp_file, suffix, *size) }
457             end
458             save_to_storage
459             @temp_paths.clear
460             @saved_attachment = nil
461             callback :after_attachment_saved
462           end
463         end
464
465         # Resizes the given processed img object with either the attachment resize options or the thumbnail resize options.
466         def resize_image_or_thumbnail!(img)
467           if (!respond_to?(:parent_id) || parent_id.nil?) && attachment_options[:resize_to] # parent image
468             resize_image(img, attachment_options[:resize_to])
469           elsif thumbnail_resize_options # thumbnail
470             resize_image(img, thumbnail_resize_options)
471           end
472         end
473
474         # Yanked from ActiveRecord::Callbacks, modified so I can pass args to the callbacks besides self.
475         # Only accept blocks, however
476         if ActiveSupport.const_defined?(:Callbacks)
477           # Rails 2.1 and beyond!
478           def callback_with_args(method, arg = self)
479             notify(method)
480
481             result = run_callbacks(method, { :object => arg }) { |result, object| result == false }
482
483             if result != false && respond_to_without_attributes?(method)
484               result = send(method)
485             end
486
487             result
488           end
489
490           def run_callbacks(kind, options = {}, &block)
491             options.reverse_merge!( :object => self )
492             self.class.send("#{kind}_callback_chain").run(options[:object], options, &block)
493           end
494         else
495           # Rails 2.0
496           def callback_with_args(method, arg = self)
497             notify(method)
498
499             result = nil
500             callbacks_for(method).each do |callback|
501               result = callback.call(self, arg)
502               return false if result == false
503             end
504             result
505           end
506         end
507
508         # Removes the thumbnails for the attachment, if it has any
509         def destroy_thumbnails
510           self.thumbnails.each { |thumbnail| thumbnail.destroy } if thumbnailable?
511         end
512     end
513   end
514 end

Benjamin Mako Hill || Want to submit a patch?