Permalink
Browse files

initial code with basic option/validation tests

git-svn-id: http://svn.techno-weenie.net/projects/plugins/attachment_fu@2550 567b1171-46fb-0310-a4c9-b4bef9110e78
  • Loading branch information...
1 parent 9412332 commit 7a14bcb9cd58a765ab5ef1d641e0a3f567981366 technoweenie committed Dec 14, 2006
View
@@ -1 +1 @@
-ActiveRecord::Base.send(:include, Technoweenie::ActsAsAttachment)
+ActiveRecord::Base.send(:extend, Technoweenie::AttachmentFu::ActMethods)
@@ -0,0 +1,193 @@
+require File.join(File.dirname(__FILE__), 'attachment_fu', 'backends')
+
+module Technoweenie # :nodoc:
+ module AttachmentFu # :nodoc:
+ @@content_types = ['image/jpeg', 'image/pjpeg', 'image/gif', 'image/png', 'image/x-png']
+ mattr_reader :content_types
+
+ class ThumbnailError < StandardError; end
+ class AttachmentError < StandardError; end
+
+ module ActMethods
+ # Options:
+ # <tt>:content_type</tt> - Allowed content types. Allows all by default. Use :image to allow all standard image types.
+ # <tt>:min_size</tt> - Minimum size allowed. 1 byte is the default.
+ # <tt>:max_size</tt> - Maximum size allowed. 1.megabyte is the default.
+ # <tt>:size</tt> - Range of sizes allowed. (1..1.megabyte) is the default. This overrides the :min_size and :max_size options.
+ # <tt>:resize_to</tt> - Used by RMagick to resize images. Pass either an array of width/height, or a geometry string.
+ # <tt>:thumbnails</tt> - Specifies a set of thumbnails to generate. This accepts a hash of filename suffixes and RMagick resizing options.
+ # <tt>:thumbnail_class</tt> - Set what class to use for thumbnails. This attachment class is used by default.
+ # <tt>:file_system_path</tt> - path to store the uploaded files. Uses public/#{table_name} by default.
+ # Setting this sets the :storage to :file_system.
+ # <tt>:storage</tt> - Use :file_system to specify the attachment data is stored with the file system. Defaults to :db_system.
+ #
+ # Examples:
+ # has_attachment :max_size => 1.kilobyte
+ # has_attachment :size => 1.megabyte..2.megabytes
+ # has_attachment :content_type => 'application/pdf'
+ # has_attachment :content_type => ['application/pdf', 'application/msword', 'text/plain']
+ # has_attachment :content_type => :image, :resize_to => [50,50]
+ # has_attachment :content_type => ['application/pdf', :image], :resize_to => 'x50'
+ # has_attachment :thumbnails => { :thumb => [50, 50], :geometry => 'x50' }
+ # has_attachment :storage => :file_system, :file_system_path => 'public/files'
+ # has_attachment :storage => :file_system, :file_system_path => 'public/files',
+ # :content_type => :image, :resize_to => [50,50]
+ # has_attachment :storage => :file_system, :file_system_path => 'public/files',
+ # :thumbnails => { :thumb => [50, 50], :geometry => 'x50' }
+ def has_attachment(options = {})
+ # this allows you to redefine the acts' options for each subclass, however
+ options[:min_size] ||= 1
+ options[:max_size] ||= 1.megabyte
+ options[:size] ||= (options[:min_size]..options[:max_size])
+ options[:thumbnails] ||= {}
+ options[:thumbnail_class] ||= self
+
+ # only need to define these once on a class
+ unless included_modules.include? InstanceMethods
+ class_inheritable_accessor :attachment_options
+
+ options[:storage] ||= options[:file_system_path] ? :file_system : :db_file
+ options[:file_system_path] ||= File.join("public", table_name)
+ options[:file_system_path] = options[:file_system_path][1..-1] if options[:file_system_path].first == '/'
+
+ ##with_options :foreign_key => 'parent_id' do |m|
+ ## m.has_many :thumbnails, :dependent => :destroy, :class_name => options[:thumbnail_class].to_s
+ ## m.belongs_to :parent, :class_name => self.base_class.to_s
+ ##end
+
+ ##after_save :create_attachment_thumbnails # allows thumbnails with parent_id to be created
+
+ after_destroy :destroy_file
+ extend ClassMethods
+ include InstanceMethods, Technoweenie::AttachmentFu::const_get("#{options[:storage].to_s.classify}Backend")
+ end
+
+ options[:content_type] = [options[:content_type]].flatten.collect { |t| t == :image ? Technoweenie::ActsAsAttachment.content_types : t }.flatten unless options[:content_type].nil?
+ self.attachment_options = options
+ end
+ end
+
+ module ClassMethods
+ delegate :content_types, :to => Technoweenie::ActsAsAttachment
+
+ # Performs common validations for attachment models.
+ def validates_as_attachment
+ validates_presence_of :size, :content_type, :filename
+ validate :attachment_attributes_valid?
+ end
+
+ # Callback after an attachment has been saved either to the file system or the DB.
+ # Only called if the file has been changed, not necessarily if the record is updated.
+ #
+ # class Foo < ActiveRecord::Base
+ # acts_as_attachment
+ # after_attachment_saved do |record|
+ # ...
+ # end
+ # end
+ def after_attachment_saved(&block)
+ write_inheritable_array(:after_attachment_saved, [block])
+ end
+ end
+
+ module InstanceMethods
+ # Gets the thumbnail name for a filename. 'foo.jpg' becomes 'foo_thumbnail.jpg'
+ def thumbnail_name_for(thumbnail = nil)
+ return filename if thumbnail.blank?
+ ext = nil
+ basename = filename.gsub /\.\w+$/ do |s|
+ ext = s
+ ''
+ end
+ "#{basename}_#{thumbnail}#{ext}"
+ end
+
+ # nil placeholder in case this field is used in a form.
+ def uploaded_data() nil; end
+
+ # This method handles the uploaded file object. If you set the field name to uploaded_data, you don't need
+ # any special code in your controller.
+ #
+ # <% form_for :attachment, :html => { :multipart => true } do |f| -%>
+ # <p><%= f.file_field :uploaded_data %></p>
+ # <p><%= submit_tag :Save %>
+ # <% end -%>
+ #
+ # @attachment = Attachment.create! params[:attachment]
+ #
+ # TODO: Allow it to work with Merb tempfiles too.
+ def uploaded_data=(file_data)
+ return nil if file_data.nil? || file_data.size == 0
+ self.content_type = file_data.content_type
+ self.filename = file_data.original_filename if respond_to?(:filename)
+ self.attachment_data = file_data.read
+ end
+
+ # returns true if the attachment data will be written to the storage system on the next save
+ def save_attachment?
+ @save_attachment == true
+ end
+
+ # Sets the actual binary data. This is typically called by uploaded_data=, but you can call this
+ # manually if you're creating from the console. This is also where the resizing occurs.
+ def attachment_data=(data)
+ @attachment_data = nil
+ @save_attachment = false
+ self.size = 0
+ return nil if data.nil?
+
+ if data
+ self.size = data.length
+ @save_attachment = true
+ @attachment_data = data
+ end
+ end
+
+ # Sets the content type.
+ def content_type=(new_type)
+ write_attribute :content_type, new_type.to_s.strip
+ end
+
+ # sanitizes a filename.
+ def filename=(new_name)
+ write_attribute :filename, sanitize_filename(new_name)
+ end
+
+ protected
+ @@filename_basename_regex = /^.*(\\|\/)/
+ @@filename_character_regex = /[^\w\.\-]/
+ def sanitize_filename(filename)
+ returning filename.strip do |name|
+ # NOTE: File.basename doesn't work right with Windows paths on Unix
+ # get only the filename, not the whole path
+ name.gsub! @@filename_basename_regex, ''
+
+ # Finally, replace all non alphanumeric, underscore or periods with underscore
+ name.gsub! @@filename_character_regex, '_'
+ end
+ end
+
+ # validates the size and content_type attributes according to the current model's options
+ def attachment_attributes_valid?
+ [:size, :content_type].each do |attr_name|
+ enum = attachment_options[attr_name]
+ errors.add attr_name, ActiveRecord::Errors.default_error_messages[:inclusion] unless enum.nil? || enum.include?(send(attr_name))
+ end
+ end
+
+ # Yanked from ActiveRecord::Callbacks, modified so I can pass args to the callbacks besides self.
+ # Only accept blocks, however
+ def callback_with_args(method, arg = self)
+ notify(method)
+
+ result = nil
+ callbacks_for(method).each do |callback|
+ result = callback.call(self, arg)
+ return false if result == false
+ end
+
+ return result
+ end
+ end
+ end
+end
@@ -0,0 +1,117 @@
+module Technoweenie # :nodoc:
+ module AttachmentFu # :nodoc:
+ # Methods for file system backed attachments
+ module FileSystemBackend
+ def self.included(base) #:nodoc:
+ base.before_update :rename_file
+ base.after_save :save_to_storage # so the id can be part of the url
+ end
+
+ # Gets the attachment data
+ def attachment_data
+ return @attachment_data if @attachment_data
+
+ filename = full_filename
+ File.open(filename, 'rb') do |file|
+ @attachment_data = file.read
+ end if File.file?(filename)
+ @attachment_data
+ end
+
+ # Gets the full path to the filename in this format:
+ #
+ # # This assumes a model name like MyModel
+ # # public/#{table_name} is the default filesystem path
+ # RAILS_ROOT/public/my_models/5/blah.jpg
+ #
+ # Overwrite this method in your model to customize the filename.
+ # The optional thumbnail argument will output the thumbnail's filename.
+ def full_filename(thumbnail = nil)
+ file_system_path = (thumbnail ? thumbnail_class : self).attachment_options[:file_system_path].to_s
+ File.join(RAILS_ROOT, file_system_path, attachment_path_id, thumbnail_name_for(thumbnail))
+ end
+
+ # Used as the base path that #public_filename strips off full_filename to create the public path
+ def base_path
+ @base_path ||= File.join(RAILS_ROOT, 'public')
+ end
+
+ # The attachment ID used in the full path of a file
+ def attachment_path_id
+ ((respond_to?(:parent_id) && parent_id) || id).to_s
+ end
+
+ # Gets the public path to the file
+ # The optional thumbnail argument will output the thumbnail's filename.
+ def public_filename(thumbnail = nil)
+ full_filename(thumbnail).gsub %r(^#{Regexp.escape(base_path)}), ''
+ end
+
+ def filename=(value)
+ @old_filename = full_filename unless filename.nil? || @old_filename
+ write_attribute :filename, sanitize_filename(value)
+ end
+
+ # Destroys the file. Called in the after_destroy callback
+ def destroy_file
+ FileUtils.rm full_filename rescue nil
+ end
+
+ def rename_file
+ return unless @old_filename && @old_filename != full_filename
+ if @save_attachment && File.exists?(@old_filename)
+ FileUtils.rm @old_filename
+ elsif File.exists?(@old_filename)
+ FileUtils.mv @old_filename, full_filename
+ end
+ @old_filename = nil
+ true
+ end
+
+ # Saves the file to the file system
+ def save_to_storage
+ if @save_attachment
+ # TODO: This overwrites the file if it exists, maybe have an allow_overwrite option?
+ FileUtils.mkdir_p(File.dirname(full_filename))
+
+ # TODO Convert to streaming storage to prevent excessive memory usage
+ # FileUtils.copy_stream is very efficient in regards to copies
+ # OR - get the tmp filename for large files and do FileUtils.cp ? *agile*
+ File.open(full_filename, "wb") do |file|
+ file.write(attachment_data)
+ end
+ end
+ @old_filename = nil
+ true
+ end
+ end
+
+ # Methods for DB backed attachments
+ module DbFileBackend
+ def self.included(base) #:nodoc:
+ base.belongs_to :db_file, :class_name => '::DbFile', :foreign_key => 'db_file_id'
+ base.before_save :save_to_storage # so the db_file_id can be set
+ end
+
+ # Gets the attachment data
+ def attachment_data
+ @attachment_data ||= db_file.data
+ end
+
+ # Destroys the file. Called in the after_destroy callback
+ def destroy_file
+ db_file.destroy if db_file
+ end
+
+ # Saves the data to the DbFile model
+ def save_to_storage
+ if @save_attachment
+ (db_file || build_db_file).data = attachment_data
+ db_file.save!
+ self.db_file_id = db_file.id # needed for my own sanity, k thx
+ end
+ true
+ end
+ end
+ end
+end
View
@@ -0,0 +1,71 @@
+require File.join(File.dirname(__FILE__), 'test_helper')
+
+class BasicTest < Test::Unit::TestCase
+ def test_should_set_default_min_size
+ assert_equal 1, Attachment.attachment_options[:min_size]
+ end
+
+ def test_should_set_default_max_size
+ assert_equal 1.megabyte, Attachment.attachment_options[:max_size]
+ end
+
+ def test_should_set_default_size
+ assert_equal (1..1.megabyte), Attachment.attachment_options[:size]
+ end
+
+ def test_should_set_default_thumbnails_option
+ assert_equal Hash.new, Attachment.attachment_options[:thumbnails]
+ end
+
+ def test_should_set_default_thumbnail_class
+ assert_equal Attachment, Attachment.attachment_options[:thumbnail_class]
+ end
+
+ def test_should_normalize_content_types_to_array
+ assert_equal %w(pdf), PdfAttachment.attachment_options[:content_type]
+ assert_equal %w(pdf doc txt), DocAttachment.attachment_options[:content_type]
+ assert_equal ['image/jpeg', 'image/pjpeg', 'image/gif', 'image/png', 'image/x-png'], ImageAttachment.attachment_options[:content_type]
+ assert_equal ['pdf', 'image/jpeg', 'image/pjpeg', 'image/gif', 'image/png', 'image/x-png'], ImageOrPdfAttachment.attachment_options[:content_type]
+ end
+
+ def test_should_sanitize_content_type
+ @attachment = Attachment.new :content_type => ' foo '
+ assert_equal 'foo', @attachment.content_type
+ end
+
+ def test_should_sanitize_filenames
+ @attachment = Attachment.new :filename => 'blah/foo.bar'
+ assert_equal 'foo.bar', @attachment.filename
+
+ @attachment.filename = 'blah\\foo.bar'
+ assert_equal 'foo.bar', @attachment.filename
+
+ @attachment.filename = 'f o!O-.bar'
+ assert_equal 'f_o_O-.bar', @attachment.filename
+ end
+
+ def test_should_convert_thumbnail_name
+ @attachment = FileAttachment.new :filename => 'foo.bar'
+ assert_equal 'foo.bar', @attachment.thumbnail_name_for(nil)
+ assert_equal 'foo.bar', @attachment.thumbnail_name_for('')
+ assert_equal 'foo_blah.bar', @attachment.thumbnail_name_for(:blah)
+ assert_equal 'foo_blah.blah.bar', @attachment.thumbnail_name_for('blah.blah')
+
+ @attachment.filename = 'foo.bar.baz'
+ assert_equal 'foo.bar_blah.baz', @attachment.thumbnail_name_for(:blah)
+ end
+
+ def test_should_set_attachment_data
+ @attachment = FileAttachment.new :filename => 'foo.bar'
+ assert_nil @attachment.attachment_data
+ assert !@attachment.save_attachment?
+
+ @attachment.attachment_data = 'blah'
+ assert @attachment.save_attachment?
+ assert_equal 4, @attachment.size
+
+ @attachment.attachment_data = nil
+ assert !@attachment.save_attachment?
+ assert_equal 0, @attachment.size
+ end
+end
Oops, something went wrong.

0 comments on commit 7a14bcb

Please sign in to comment.