Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with
or
.
Download ZIP
Browse files

A little reorg, plus added S3 support.

git-svn-id: https://svn.thoughtbot.com/plugins/paperclip/trunk@222 7bbfaf0e-4d1d-0410-9690-a8bb5f8ef2aa
  • Loading branch information...
commit 66b47339c6bd51ea307ff740b21548fe4838fe0a 1 parent 16b60a5
jyurek authored
View
495 lib/paperclip.rb
@@ -10,495 +10,8 @@
#
# See the +has_attached_file+ documentation for more details.
-module Thoughtbot #:nodoc:
- # Paperclip defines an attachment as any file, though it makes special considerations
- # for image files. You can declare that a model has an attached file with the
- # +has_attached_file+ method:
- #
- # class User < ActiveRecord::Base
- # has_attached_file :avatar, :thumbnails => { :thumb => "100x100" }
- # end
- #
- # See the +has_attached_file+ documentation for more details.
- module Paperclip
-
- PAPERCLIP_OPTIONS = {
- :whiny_deletes => false,
- :whiny_thumbnails => true,
- :image_magick_path => nil
- }
-
- def self.options
- PAPERCLIP_OPTIONS
- end
-
- DEFAULT_ATTACHMENT_OPTIONS = {
- :path_prefix => ":rails_root/public",
- :url_prefix => "",
- :path => ":attachment/:id/:style_:name",
- :attachment_type => :image,
- :thumbnails => {},
- :delete_on_destroy => true,
- :default_style => :original,
- :missing_url => "",
- :missing_path => ""
- }
-
- class PaperclipError < StandardError #:nodoc:
- attr_accessor :attachment
- def initialize attachment
- @attachment = attachment
- end
- end
+require 'paperclip/paperclip'
+require 'paperclip/storage'
+require 'paperclip/storage/filesystem'
+require 'paperclip/storage/s3'
- module ClassMethods
- # == Methods
- # +has_attached_file+ attaches a file (or files) with a given name to a model. It creates seven instance
- # methods using the attachment name (where "attachment" in the following is the name
- # passed in to +has_attached_file+):
- # * attachment: Returns the name of the file that was attached, with no path information.
- # * attachment?: Alias for _attachment_ for clarity in determining if the attachment exists.
- # * attachment=(file): Sets the attachment to the file and creates the thumbnails (if necessary).
- # +file+ can be anything normally accepted as an upload (+StringIO+ or +Tempfile+) or a +File+
- # if it has had the +Upfile+ module included. +file+ can also be a URL object pointing to a valid
- # resource. This resource will be downloaded using +open-uri+[http://www.ruby-doc.org/stdlib/libdoc/open-uri/rdoc/]
- # and processed as a regular file object would. Finally, you can set this property to +nil+ to clear
- # the attachment, which is the same thing as calling +destroy_attachment+.
- # Note this does not save the attachments.
- # user.avatar = File.new("~/pictures/me.png")
- # user.avatar = params[:user][:avatar] # When :avatar is a file_field
- # user.avatar = URI.parse("http://www.avatars-r-us.com/spiffy.png")
- # * attachment_file_name(style): The name of the file, including path information. Pass in the
- # name of a thumbnail to get the path to that thumbnail.
- # user.avatar_file_name(:thumb) # => "public/users/44/thumb/me.png"
- # user.avatar_file_name # => "public/users/44/original/me.png"
- # * attachment_url(style): The public URL of the attachment, suitable for passing to +image_tag+
- # or +link_to+. Pass in the name of a thumbnail to get the url to that thumbnail.
- # user.avatar_url(:thumb) # => "http://assethost.com/users/44/thumb/me.png"
- # user.avatar_url # => "http://assethost.com/users/44/original/me.png"
- # * attachment_valid?: If unsaved, returns true if all thumbnails have data (that is,
- # they were successfully made). If saved, returns true if all expected files exist and are
- # of nonzero size.
- # * destroy_attachment(complain = false): Flags the attachment and all thumbnails for deletion. Sets
- # the +attachment_file_name+ column and +attachment_content_type+ column to +nil+. Set +complain+
- # to true to override the +whiny_deletes+ option. NOTE: this does not actually delete the attachment.
- # You must still call +save+ on the model to actually delete the file and commit the change to the
- # database.
- #
- # == Options
- # There are a number of options you can set to change the behavior of Paperclip.
- # * +path_prefix+: The location of the repository of attachments on disk. See Interpolation below
- # for more control over where the files are located.
- # :path_prefix => ":rails_root/public"
- # :path_prefix => "/var/app/repository"
- # * +url_prefix+: The root URL of where the attachment is publically accessible. See Interpolation below
- # for more control over where the files are located.
- # :url_prefix => "/"
- # :url_prefix => "/user_files"
- # :url_prefix => "http://some.other.host/stuff"
- # * +path+: Where the files are stored underneath the +path_prefix+ directory and underneath the +url_prefix+ URL.
- # See Interpolation below for more control over where the files are located.
- # :path => ":class/:style/:id/:name" # => "users/original/13/picture.gif"
- # * +attachment_type+: If this is set to :image (which it is, by default), Paperclip will attempt to make thumbnails.
- # * +thumbnails+: A hash of thumbnail styles and their geometries. You can find more about geometry strings
- # at the ImageMagick website (http://www.imagemagick.org/script/command-line-options.php#resize). Paperclip
- # also adds the "#" option, which will resize the image to fit maximally inside the dimensions and then crop
- # the rest off (weighted at the center).
- # * +delete_on_destroy+: When records are deleted, the attachment that goes with it is also deleted. Set
- # this to +false+ to prevent the file from being deleted.
- # * +default_style+: The thumbnail style that will be used by default for +attachment_file_name+ and +attachment_url+
- # Defaults to +original+.
- # has_attached_file :avatar, :thumbnails => { :normal => "100x100#" },
- # :default_style => :normal
- # user.avatar_url # => "/avatars/23/normal_me.png"
- # * +missing_url+: The URL that will be returned if there is no attachment assigned. It should be an absolute
- # URL, not relative to the +url_prefix+. This field is interpolated.
- # has_attached_file :avatar, :missing_url => "/images/default_:style_avatar.png"
- # User.new.avatar_url(:small) # => "/images/default_small_avatar.png"
- #
- # == Interpolation
- # The +path_prefix+, +url_prefix+, and +path+ options can have dynamic interpolation done so that the
- # locations of the files can vary depending on a variety of factors. Each variable looks like a Ruby symbol
- # and is searched for with +gsub+, so a variety of effects can be achieved. The list of possible variables
- # follows:
- # * +rails_root+: The value of the +RAILS_ROOT+ constant for your app. Typically used when putting your
- # attachments into the public directory. Probably not useful in the +path+ definition.
- # * +class+: The underscored, pluralized version of the class in which the attachment is defined.
- # * +attachment+: The pluralized name of the attachment as given to +has_attached_file+
- # * +style+: The name of the thumbnail style for the current thumbnail. If no style is given, "original" is used.
- # * +id+: The record's id.
- # * +name+: The file's name, as stored in the attachment_file_name column.
- # * +base+: The base of the file's name, e.g. "myself" from "myself.jpg", or "my.picture" from "my.picture.png".
- # It is defined as everything except the final period and what follows it. If there is no extension, :base works
- # the same as :name.
- # * +ext+: The extension of the file, e.g. "jpg" from "myself.jpg". It is defined as everything following the final
- # period
- #
- # When interpolating, you are not confined to making any one of these into its own directory. This is
- # perfectly valid:
- # :path => ":attachment/:style/:id-:name" # => "avatars/thumb/44-me.png"
- #
- # == Model Requirements
- # For any given attachment _foo_, the model the attachment is in needs to have both a +foo_file_name+
- # and +foo_content_type+ column, as a type of +string+. The +foo_file_name+ column contains only the name
- # of the file and none of the path information. However, the +foo_file_name+ column accessor is overwritten
- # by the one (defined above) which returns the full path to whichever style thumbnail is passed in.
- # In a pinch, you can either use +read_attribute+ or the plain +foo+ accessor, which returns the database's
- # +foo_file_name+ column.
- #
- # Note that if these columns are not found in the model (according to +ActiveRecord::Base#column_names+) then
- # Paperclip will throw a +PaperclipError+ informing you of the fact.
- #
- # == Event Triggers
- # When an attachment is set by using he setter (+model.attachment=+), the thumbnails are created and held in
- # memory. They are not saved until the +after_save+ trigger fires, at which point the attachment and all
- # thumbnails are written to disk.
- #
- # Attached files are destroyed when the associated record is destroyed in a +before_destroy+ trigger. Set
- # the +delete_on_destroy+ option to +false+ to prevent this behavior. Also note that using the ActiveRecord's
- # +delete+ method instead of the +destroy+ method will prevent the +before_destroy+ trigger from firing.
- #
- # == Validation
- # If there is a problem in the thumbnail-making process, Paperclip will add errors to your model on save. These
- # errors appear if there is an error with +convert+ (e.g. +convert+ doesn't exist, the file wasn't an image, etc).
- def has_attached_file *attachment_names
- options = attachment_names.last.is_a?(Hash) ? attachment_names.pop : {}
- options = DEFAULT_ATTACHMENT_OPTIONS.merge(options)
-
- include InstanceMethods
- attachments = (@attachments ||= {})
-
- attachment_names.each do |attr|
- attachments[attr] = (attachments[attr] || {:name => attr}).merge(options)
- whine_about_columns_for attachments[attr]
-
- define_method "#{attr}=" do |uploaded_file|
- uploaded_file = fetch_uri(uploaded_file) if uploaded_file.is_a? URI
- return send("destroy_#{attr}") if uploaded_file.nil?
- return unless is_a_file? uploaded_file
-
- attachments[attr].merge!({
- :dirty => true,
- :files => {:original => uploaded_file},
- :content_type => uploaded_file.content_type,
- :filename => sanitize_filename(uploaded_file.original_filename),
- :errors => [],
- :delete_on_save => false
- })
- write_attribute(:"#{attr}_file_name", attachments[attr][:filename])
- write_attribute(:"#{attr}_content_type", attachments[attr][:content_type])
-
- if attachments[attr][:attachment_type] == :image
- send("process_#{attr}_thumbnails")
- end
-
- uploaded_file
- end
-
- define_method attr do
- read_attribute("#{attr}_file_name")
- end
- alias_method "#{attr}?", attr
-
- define_method "#{attr}_attachment" do
- attachments[attr]
- end
- private :"#{attr}_attachment"
-
- define_method "#{attr}_file_name" do |*args|
- style = args.shift || attachments[attr][:default_style] # This prevents arity warnings
- path_for(attachments[attr], style) || interpolate(attachments[attr], attachments[attr][:missing_path], style)
- end
-
- define_method "#{attr}_url" do |*args|
- style = args.shift || attachments[attr][:default_style] # This prevents arity warnings
- url_for(attachments[attr], style) || interpolate(attachments[attr], attachments[attr][:missing_url], style)
- end
-
- define_method "#{attr}_valid?" do
- attachments[attr][:thumbnails].merge(:original => nil).all? do |style, geometry|
- if read_attribute("#{attr}_file_name")
- if attachments[attr][:dirty]
- !attachments[attr][:files][style].blank? && attachments[attr][:errors].empty?
- else
- File.file?( path_for(attachments[attr], style) )
- end
- else
- false
- end
- end
- end
-
- define_method "process_#{attr}_thumbnails" do
- make_thumbnails attachments[attr]
- end
-
- define_method "destroy_#{attr}" do |*args|
- complain = args.first || false
- if attachments[attr].keys.any?
- attachments[attr][:files] = nil
- attachments[attr][:delete_on_save] = true
- attachments[attr][:complain_on_delete] = complain
- write_attribute("#{attr}_file_name", nil)
- write_attribute("#{attr}_content_type", nil)
- end
- true
- end
-
- validates_each attr do |r, a, v|
- attachments[attr][:errors].each{|e| r.errors.add(attr, e) } if attachments[attr][:errors]
- end
-
- define_method "#{attr}_before_save" do
- if attachments[attr].keys.any?
- write_attachment attachments[attr] if attachments[attr][:files]
- delete_attachment attachments[attr], attachments[attr][:complain_on_delete] if attachments[attr][:delete_on_save]
- attachments[attr][:delete_on_save] = false
- attachments[attr][:dirty] = false
- attachments[attr][:files] = nil
- end
- end
- private :"#{attr}_before_save"
- after_save :"#{attr}_before_save"
-
- define_method "#{attr}_before_destroy" do
- if attachments[attr].keys.any?
- delete_attachment attachments[attr] if attachments[attr][:delete_on_destroy]
- end
- end
- private :"#{attr}_before_destroy"
- before_destroy :"#{attr}_before_destroy"
- end
- end
-
- def attachment_names
- @attachments.keys
- end
-
- def attachment name
- @attachments[name]
- end
-
- # Adds errors if the attachments you specify are either missing or had errors on them.
- # Essentially, acts like validates_presence_of for attachments.
- def validates_attached_file *attachment_names
- validates_each *attachment_names do |r, a, v|
- r.errors.add(a, "requires a valid attachment.") unless r.send("#{a}_valid?")
- end
- end
-
- def whine_about_columns_for attachment #:nodoc:
- name = attachment[:name]
- unless column_names.include?("#{name}_file_name") && column_names.include?("#{name}_content_type")
- error = "Class #{self.name} does not have the necessary columns to have an attachment named #{name}. " +
- "(#{name}_file_name and #{name}_content_type)"
- raise PaperclipError.new(attachment), error
- end
- end
- end
-
- module InstanceMethods #:nodoc:
-
- private
-
- def interpolate attachment, source, style
- file_name = read_attribute("#{attachment[:name]}_file_name")
- returning source.dup do |s|
- s.gsub!(/:rails_root/, RAILS_ROOT)
- s.gsub!(/:id/, self.id.to_s) if self.id
- s.gsub!(/:class/, self.class.to_s.underscore.pluralize)
- s.gsub!(/:style/, style.to_s)
- s.gsub!(/:attachment/, attachment[:name].to_s.pluralize)
- if file_name
- file_bits = file_name.split(".")
- s.gsub!(/:name/, file_name)
- s.gsub!(/:base/, [file_bits[0], *file_bits[1..-2]].join("."))
- s.gsub!(/:ext/, file_bits.last )
- end
- end
- end
-
- def path_for attachment, style = nil
- style ||= attachment[:default_style]
- file = read_attribute("#{attachment[:name]}_file_name")
- return nil unless file && self.id
-
- prefix = interpolate attachment, "#{attachment[:path_prefix]}/#{attachment[:path]}", style
- File.join( prefix.split("/") )
- end
-
- def url_for attachment, style = nil
- style ||= attachment[:default_style]
- file = read_attribute("#{attachment[:name]}_file_name")
- return nil unless file && self.id
-
- interpolate attachment, "#{attachment[:url_prefix]}/#{attachment[:path]}", style
- end
-
- def ensure_directories_for attachment
- attachment[:files].each do |style, file|
- dirname = File.dirname(path_for(attachment, style))
- FileUtils.mkdir_p dirname
- end
- end
-
- def write_attachment attachment
- return if attachment[:files].blank?
- ensure_directories_for attachment
- attachment[:files].each do |style, atch|
- atch.rewind
- data = atch.read
- File.open( path_for(attachment, style), "w" ) do |file|
- file.rewind
- file.write(data)
- end
- end
- attachment[:files] = nil
- attachment[:dirty] = false
- end
-
- def delete_attachment attachment, complain = false
- (attachment[:thumbnails].keys + [:original]).each do |style|
- file_path = path_for(attachment, style)
- begin
- FileUtils.rm file_path if file_path
- rescue SystemCallError => e
- raise PaperclipError.new(attachment), "Could not delete thumbnail." if ::Thoughtbot::Paperclip.options[:whiny_deletes] || complain
- end
- end
- end
-
- def make_thumbnails attachment
- attachment[:files] ||= {}
- attachment[:files][:original] ||= File.new( path_for(attachment, :original) )
- attachment[:thumbnails].each do |style, geometry|
- begin
- attachment[:files][style] = make_thumbnail(attachment, attachment[:files][:original], geometry)
- rescue PaperclipError => e
- attachment[:errors] << "thumbnail '#{style}' could not be created."
- end
- end
- end
-
- def make_thumbnail attachment, orig_io, geometry
- operator = geometry[-1,1]
- begin
- geometry, crop_geometry = geometry_for_crop(geometry, orig_io) if operator == '#'
- command = "#{path_for_command "convert"} - -scale '#{geometry}' #{operator == '#' ? "-crop '#{crop_geometry}'" : ""} - 2>/dev/null"
- thumb = IO.popen(command, "w+") do |io|
- orig_io.rewind
- io.write(orig_io.read)
- io.close_write
- StringIO.new(io.read)
- end
- rescue Errno::EPIPE => e
- raise PaperclipError.new(attachment), "Could not create thumbnail. Is ImageMagick or GraphicsMagick installed and available?"
- rescue SystemCallError => e
- raise PaperclipError.new(attachment), "Could not create thumbnail."
- end
- if ::Thoughtbot::Paperclip.options[:whiny_thumbnails] && !$?.success?
- raise PaperclipError.new(attachment), "Convert returned with result code #{$?.exitstatus}: #{thumb.read}"
- end
- thumb
- end
-
- def geometry_for_crop geometry, orig_io
- IO.popen("#{path_for_command "identify"} - 2>/dev/null", "w+") do |io|
- orig_io.rewind
- io.write(orig_io.read)
- io.close_write
- if match = io.read.split[2].match(/(\d+)x(\d+)/)
- src = match[1,2].map(&:to_f)
- srch = src[0] > src[1]
- dst = geometry.match(/(\d+)x(\d+)/)[1,2].map(&:to_f)
- dsth = dst[0] > dst[1]
- ar = src[0] / src[1]
-
- scale_geometry, scale = if dst[0] == dst[1]
- if srch
- [ "x#{dst[1]}", src[1] / dst[1] ]
- else
- [ "#{dst[0]}x", src[0] / dst[0] ]
- end
- elsif dsth
- [ "#{dst[0]}x", src[0] / dst[0] ]
- else
- [ "x#{dst[1]}", src[1] / dst[1] ]
- end
-
- crop_geometry = if dsth
- "%dx%d+%d+%d" % [ dst[0], dst[1], 0, (src[1] / scale - dst[1]) / 2 ]
- else
- "%dx%d+%d+%d" % [ dst[0], dst[1], (src[0] / scale - dst[0]) / 2, 0 ]
- end
-
- [ scale_geometry, crop_geometry ]
- end
- end
- end
-
- def fetch_uri uri
- image = if uri.scheme == 'file'
- path = url.gsub(%r{^file://}, '/')
- open(path)
- else
- require 'open-uri'
- uri
- end
- begin
- data = StringIO.new(image.read)
- uri.extend(Upfile)
- class << data
- attr_accessor :original_filename, :content_type
- end
- data.original_filename = uri.original_filename
- data.content_type = uri.content_type
- data
- rescue OpenURI::HTTPError => e
- self.errors.add_to_base("The file at #{uri.to_s} could not be found.")
- $stderr.puts "#{e.message}: #{uri.to_s}"
- return nil
- end
- end
-
- def is_a_file? data
- [:content_type, :original_filename, :read].map do |meth|
- data.respond_to? meth
- end.all?
- end
-
- def sanitize_filename filename
- File.basename(filename).gsub(/[^\w\.\_]/,'_')
- end
-
- def path_for_command command
- File.join([::Thoughtbot::Paperclip.options[:image_magick_path], command].compact)
- end
- end
-
- # The Upfile module is a convenience module for adding uploaded-file-type methods
- # to the +File+ class. Useful for testing.
- # user.avatar = File.new("test/test_avatar.jpg")
- module Upfile
- # Infer the MIME-type of the file from the extension.
- def content_type
- type = self.path.match(/\.(\w+)$/)[1] || "data"
- case type
- when "jpg", "png", "gif" then "image/#{type}"
- when "txt", "csv", "xml", "html", "htm" then "text/#{type}"
- else "x-application/#{type}"
- end
- end
-
- # Returns the file's normal name.
- def original_filename
- self.path
- end
-
- # Returns the size of the file.
- def size
- File.size(self)
- end
- end
- end
-end
View
359 lib/paperclip/paperclip.rb
@@ -0,0 +1,359 @@
+module Thoughtbot #:nodoc:
+ # Paperclip defines an attachment as any file, though it makes special considerations
+ # for image files. You can declare that a model has an attached file with the
+ # +has_attached_file+ method:
+ #
+ # class User < ActiveRecord::Base
+ # has_attached_file :avatar, :thumbnails => { :thumb => "100x100" }
+ # end
+ #
+ # See the +has_attached_file+ documentation for more details.
+ module Paperclip
+
+ PAPERCLIP_OPTIONS = {
+ :whiny_deletes => false,
+ :whiny_thumbnails => true,
+ :image_magick_path => nil
+ }
+
+ def self.options
+ PAPERCLIP_OPTIONS
+ end
+
+ DEFAULT_ATTACHMENT_OPTIONS = {
+ :path_prefix => ":rails_root/public",
+ :url_prefix => "",
+ :path => ":attachment/:id/:style_:name",
+ :attachment_type => :image,
+ :thumbnails => {},
+ :delete_on_destroy => true,
+ :default_style => :original,
+ :missing_url => "",
+ :missing_path => ""
+ }
+
+ class PaperclipError < StandardError #:nodoc:
+ attr_accessor :attachment
+ def initialize attachment
+ @attachment = attachment
+ end
+ end
+
+ module ClassMethods
+ # == Methods
+ # +has_attached_file+ attaches a file (or files) with a given name to a model. It creates seven instance
+ # methods using the attachment name (where "attachment" in the following is the name
+ # passed in to +has_attached_file+):
+ # * attachment: Returns the name of the file that was attached, with no path information.
+ # * attachment?: Alias for _attachment_ for clarity in determining if the attachment exists.
+ # * attachment=(file): Sets the attachment to the file and creates the thumbnails (if necessary).
+ # +file+ can be anything normally accepted as an upload (+StringIO+ or +Tempfile+) or a +File+
+ # if it has had the +Upfile+ module included. +file+ can also be a URL object pointing to a valid
+ # resource. This resource will be downloaded using +open-uri+[http://www.ruby-doc.org/stdlib/libdoc/open-uri/rdoc/]
+ # and processed as a regular file object would. Finally, you can set this property to +nil+ to clear
+ # the attachment, which is the same thing as calling +destroy_attachment+.
+ # Note this does not save the attachments.
+ # user.avatar = File.new("~/pictures/me.png")
+ # user.avatar = params[:user][:avatar] # When :avatar is a file_field
+ # user.avatar = URI.parse("http://www.avatars-r-us.com/spiffy.png")
+ # * attachment_file_name(style): The name of the file, including path information. Pass in the
+ # name of a thumbnail to get the path to that thumbnail.
+ # user.avatar_file_name(:thumb) # => "public/users/44/thumb/me.png"
+ # user.avatar_file_name # => "public/users/44/original/me.png"
+ # * attachment_url(style): The public URL of the attachment, suitable for passing to +image_tag+
+ # or +link_to+. Pass in the name of a thumbnail to get the url to that thumbnail.
+ # user.avatar_url(:thumb) # => "http://assethost.com/users/44/thumb/me.png"
+ # user.avatar_url # => "http://assethost.com/users/44/original/me.png"
+ # * attachment_valid?: If unsaved, returns true if all thumbnails have data (that is,
+ # they were successfully made). If saved, returns true if all expected files exist and are
+ # of nonzero size.
+ # * destroy_attachment(complain = false): Flags the attachment and all thumbnails for deletion. Sets
+ # the +attachment_file_name+ column and +attachment_content_type+ column to +nil+. Set +complain+
+ # to true to override the +whiny_deletes+ option. NOTE: this does not actually delete the attachment.
+ # You must still call +save+ on the model to actually delete the file and commit the change to the
+ # database.
+ #
+ # == Options
+ # There are a number of options you can set to change the behavior of Paperclip.
+ # * +path_prefix+: The location of the repository of attachments on disk. See Interpolation below
+ # for more control over where the files are located.
+ # :path_prefix => ":rails_root/public"
+ # :path_prefix => "/var/app/repository"
+ # * +url_prefix+: The root URL of where the attachment is publically accessible. See Interpolation below
+ # for more control over where the files are located.
+ # :url_prefix => "/"
+ # :url_prefix => "/user_files"
+ # :url_prefix => "http://some.other.host/stuff"
+ # * +path+: Where the files are stored underneath the +path_prefix+ directory and underneath the +url_prefix+ URL.
+ # See Interpolation below for more control over where the files are located.
+ # :path => ":class/:style/:id/:name" # => "users/original/13/picture.gif"
+ # * +attachment_type+: If this is set to :image (which it is, by default), Paperclip will attempt to make thumbnails.
+ # * +thumbnails+: A hash of thumbnail styles and their geometries. You can find more about geometry strings
+ # at the ImageMagick website (http://www.imagemagick.org/script/command-line-options.php#resize). Paperclip
+ # also adds the "#" option, which will resize the image to fit maximally inside the dimensions and then crop
+ # the rest off (weighted at the center).
+ # * +delete_on_destroy+: When records are deleted, the attachment that goes with it is also deleted. Set
+ # this to +false+ to prevent the file from being deleted.
+ # * +default_style+: The thumbnail style that will be used by default for +attachment_file_name+ and +attachment_url+
+ # Defaults to +original+.
+ # has_attached_file :avatar, :thumbnails => { :normal => "100x100#" },
+ # :default_style => :normal
+ # user.avatar_url # => "/avatars/23/normal_me.png"
+ # * +missing_url+: The URL that will be returned if there is no attachment assigned. It should be an absolute
+ # URL, not relative to the +url_prefix+. This field is interpolated.
+ # has_attached_file :avatar, :missing_url => "/images/default_:style_avatar.png"
+ # User.new.avatar_url(:small) # => "/images/default_small_avatar.png"
+ #
+ # == Interpolation
+ # The +path_prefix+, +url_prefix+, and +path+ options can have dynamic interpolation done so that the
+ # locations of the files can vary depending on a variety of factors. Each variable looks like a Ruby symbol
+ # and is searched for with +gsub+, so a variety of effects can be achieved. The list of possible variables
+ # follows:
+ # * +rails_root+: The value of the +RAILS_ROOT+ constant for your app. Typically used when putting your
+ # attachments into the public directory. Probably not useful in the +path+ definition.
+ # * +class+: The underscored, pluralized version of the class in which the attachment is defined.
+ # * +attachment+: The pluralized name of the attachment as given to +has_attached_file+
+ # * +style+: The name of the thumbnail style for the current thumbnail. If no style is given, "original" is used.
+ # * +id+: The record's id.
+ # * +name+: The file's name, as stored in the attachment_file_name column.
+ # * +base+: The base of the file's name, e.g. "myself" from "myself.jpg", or "my.picture" from "my.picture.png".
+ # It is defined as everything except the final period and what follows it. If there is no extension, :base works
+ # the same as :name.
+ # * +ext+: The extension of the file, e.g. "jpg" from "myself.jpg". It is defined as everything following the final
+ # period
+ #
+ # When interpolating, you are not confined to making any one of these into its own directory. This is
+ # perfectly valid:
+ # :path => ":attachment/:style/:id-:name" # => "avatars/thumb/44-me.png"
+ #
+ # == Model Requirements
+ # For any given attachment _foo_, the model the attachment is in needs to have both a +foo_file_name+
+ # and +foo_content_type+ column, as a type of +string+. The +foo_file_name+ column contains only the name
+ # of the file and none of the path information. However, the +foo_file_name+ column accessor is overwritten
+ # by the one (defined above) which returns the full path to whichever style thumbnail is passed in.
+ # In a pinch, you can either use +read_attribute+ or the plain +foo+ accessor, which returns the database's
+ # +foo_file_name+ column.
+ #
+ # Note that if these columns are not found in the model (according to +ActiveRecord::Base#column_names+) then
+ # Paperclip will throw a +PaperclipError+ informing you of the fact.
+ #
+ # == Event Triggers
+ # When an attachment is set by using he setter (+model.attachment=+), the thumbnails are created and held in
+ # memory. They are not saved until the +after_save+ trigger fires, at which point the attachment and all
+ # thumbnails are written to disk.
+ #
+ # Attached files are destroyed when the associated record is destroyed in a +before_destroy+ trigger. Set
+ # the +delete_on_destroy+ option to +false+ to prevent this behavior. Also note that using the ActiveRecord's
+ # +delete+ method instead of the +destroy+ method will prevent the +before_destroy+ trigger from firing.
+ #
+ # == Validation
+ # If there is a problem in the thumbnail-making process, Paperclip will add errors to your model on save. These
+ # errors appear if there is an error with +convert+ (e.g. +convert+ doesn't exist, the file wasn't an image, etc).
+ def has_attached_file *attachment_names
+ options = attachment_names.last.is_a?(Hash) ? attachment_names.pop : {}
+ options = DEFAULT_ATTACHMENT_OPTIONS.merge(options)
+
+ include InstanceMethods
+ attachments = (@attachments ||= {})
+
+ define_method :after_initialize do
+ attachments.each do |name, options|
+ options[:instance] = self
+ end
+ end
+
+ attachment_names.each do |attr|
+ attachments[attr] = (attachments[attr] || {:name => attr}).merge(options)
+ whine_about_columns_for attachments[attr]
+
+ if attachments[attr][:storage]
+ attachments[attr][:storage] = Thoughtbot::Paperclip::Storage.const_get(attachments[attr][:storage].to_s.camelize).new
+ else
+ attachments[attr][:storage] = Thoughtbot::Paperclip::Storage::Filesystem.new
+ end
+
+ define_method "#{attr}=" do |uploaded_file|
+ uploaded_file = fetch_uri(uploaded_file) if uploaded_file.is_a? URI
+ return send("destroy_#{attr}") if uploaded_file.nil?
+ return unless is_a_file? uploaded_file
+
+ attachments[attr].merge!({
+ :dirty => true,
+ :files => {:original => uploaded_file},
+ :content_type => uploaded_file.content_type,
+ :file_name => sanitize_filename(uploaded_file.original_filename),
+ :errors => [],
+ :delete_on_save => false
+ })
+ write_attribute(:"#{attr}_file_name", attachments[attr][:file_name])
+ write_attribute(:"#{attr}_content_type", attachments[attr][:content_type])
+
+ if attachments[attr][:attachment_type] == :image
+ send("process_#{attr}_thumbnails")
+ end
+
+ uploaded_file
+ end
+
+ define_method attr do
+ read_attribute("#{attr}_file_name")
+ end
+ alias_method "#{attr}?", attr
+
+ define_method "#{attr}_attachment" do
+ attachments[attr]
+ end
+ private :"#{attr}_attachment"
+
+ define_method "#{attr}_file_name" do |*args|
+ style = args.shift || attachments[attr][:default_style] # This prevents arity warnings
+ attachments[attr][:storage].path_for(attachments[attr], style) ||
+ attachments[attr][:storage].interpolate(attachments[attr], attachments[attr][:missing_path], style)
+ end
+
+ define_method "#{attr}_url" do |*args|
+ style = args.shift || attachments[attr][:default_style] # This prevents arity warnings
+ attachments[attr][:storage].url_for(attachments[attr], style) ||
+ attachments[attr][:storage].interpolate(attachments[attr], attachments[attr][:missing_url], style)
+ end
+
+ define_method "#{attr}_valid?" do
+ attachments[attr][:storage].attachment_valid? attachments[attr]
+ end
+
+ define_method "process_#{attr}_thumbnails" do
+ attachments[attr][:storage].make_thumbnails attachments[attr]
+ end
+
+ define_method "destroy_#{attr}" do |*args|
+ complain = args.first || false
+ if attachments[attr].keys.any?
+ attachments[attr][:files] = nil
+ attachments[attr][:delete_on_save] = true
+ attachments[attr][:complain_on_delete] = complain
+ write_attribute("#{attr}_file_name", nil)
+ write_attribute("#{attr}_content_type", nil)
+ end
+ true
+ end
+
+ validates_each attr do |r, a, v|
+ attachments[attr][:errors].each{|e| r.errors.add(attr, e) } if attachments[attr][:errors]
+ end
+
+ define_method "#{attr}_before_save" do
+ if attachments[attr].keys.any?
+ if attachments[attr][:files]
+ attachments[attr][:storage].write_attachment attachments[attr]
+ end
+ if attachments[attr][:delete_on_save]
+ attachments[attr][:storage].delete_attachment attachments[attr], attachments[attr][:complain_on_delete]
+ end
+ attachments[attr][:delete_on_save] = false
+ attachments[attr][:dirty] = false
+ attachments[attr][:files] = nil
+ end
+ end
+ private :"#{attr}_before_save"
+ after_save :"#{attr}_before_save"
+
+ define_method "#{attr}_before_destroy" do
+ if attachments[attr].keys.any?
+ attachments[attr][:storage].delete_attachment attachments[attr] if attachments[attr][:delete_on_destroy]
+ end
+ end
+ private :"#{attr}_before_destroy"
+ before_destroy :"#{attr}_before_destroy"
+ end
+
+ [attachments, options]
+ end
+
+ def attachment_names
+ @attachments.keys
+ end
+
+ def attachment name
+ @attachments[name]
+ end
+
+ # Adds errors if the attachments you specify are either missing or had errors on them.
+ # Essentially, acts like validates_presence_of for attachments.
+ def validates_attached_file *attachment_names
+ validates_each *attachment_names do |r, a, v|
+ r.errors.add(a, "requires a valid attachment.") unless r.send("#{a}_valid?")
+ end
+ end
+
+ def whine_about_columns_for attachment #:nodoc:
+ name = attachment[:name]
+ unless column_names.include?("#{name}_file_name") && column_names.include?("#{name}_content_type")
+ error = "Class #{self.name} does not have the necessary columns to have an attachment named #{name}. " +
+ "(#{name}_file_name and #{name}_content_type)"
+ raise PaperclipError.new(attachment), error
+ end
+ end
+ end
+
+ module InstanceMethods #:nodoc:
+ def is_a_file? data
+ [:content_type, :original_filename, :read].map do |meth|
+ data.respond_to? meth
+ end.all?
+ end
+
+ def sanitize_filename filename
+ File.basename(filename).gsub(/[^\w\.\_]/,'_')
+ end
+
+ def fetch_uri uri
+ image = if uri.scheme == 'file'
+ path = url.gsub(%r{^file://}, '/')
+ open(path)
+ else
+ require 'open-uri'
+ uri
+ end
+ begin
+ data = StringIO.new(image.read)
+ uri.extend(Upfile)
+ class << data
+ attr_accessor :original_filename, :content_type
+ end
+ data.original_filename = uri.original_filename
+ data.content_type = uri.content_type
+ data
+ rescue OpenURI::HTTPError => e
+ self.errors.add_to_base("The file at #{uri.to_s} could not be found.")
+ $stderr.puts "#{e.message}: #{uri.to_s}"
+ return nil
+ end
+ end
+ end
+
+ # The Upfile module is a convenience module for adding uploaded-file-type methods
+ # to the +File+ class. Useful for testing.
+ # user.avatar = File.new("test/test_avatar.jpg")
+ module Upfile
+ # Infer the MIME-type of the file from the extension.
+ def content_type
+ type = self.path.match(/\.(\w+)$/)[1] || "data"
+ case type
+ when "jpg", "png", "gif" then "image/#{type}"
+ when "txt", "csv", "xml", "html", "htm" then "text/#{type}"
+ else "x-application/#{type}"
+ end
+ end
+
+ # Returns the file's normal name.
+ def original_filename
+ self.path
+ end
+
+ # Returns the size of the file.
+ def size
+ File.size(self)
+ end
+ end
+ end
+end
View
100 lib/paperclip/storage.rb
@@ -0,0 +1,100 @@
+module Thoughtbot
+ module Paperclip
+ class Storage
+ def interpolate attachment, source, style
+ style ||= attachment[:default_style]
+ file_name = attachment[:instance]["#{attachment[:name]}_file_name"]
+ returning source.dup do |s|
+ s.gsub!(/:rails_root/, RAILS_ROOT)
+ s.gsub!(/:id/, attachment[:instance].id.to_s) if attachment[:instance].id
+ s.gsub!(/:class/, attachment[:instance].class.to_s.underscore.pluralize)
+ s.gsub!(/:style/, style.to_s )
+ s.gsub!(/:attachment/, attachment[:name].to_s.pluralize)
+ if file_name
+ file_bits = file_name.split(".")
+ s.gsub!(/:name/, file_name)
+ s.gsub!(/:base/, [file_bits[0], *file_bits[1..-2]].join("."))
+ s.gsub!(/:ext/, file_bits.last )
+ end
+ end
+ end
+
+ def make_thumbnails attachment
+ attachment[:files] ||= {}
+ attachment[:files][:original] ||= File.new( path_for(attachment, :original) )
+ attachment[:thumbnails].each do |style, geometry|
+ begin
+ attachment[:files][style] = make_thumbnail(attachment, attachment[:files][:original], geometry)
+ rescue PaperclipError => e
+ attachment[:errors] << "thumbnail '#{style}' could not be created."
+ end
+ end
+ end
+
+ def make_thumbnail attachment, orig_io, geometry
+ operator = geometry[-1,1]
+ begin
+ geometry, crop_geometry = geometry_for_crop(geometry, orig_io) if operator == '#'
+ command = "#{path_for_command "convert"} - -scale '#{geometry}' #{operator == '#' ? "-crop '#{crop_geometry}'" : ""} - 2>/dev/null"
+ thumb = IO.popen(command, "w+") do |io|
+ orig_io.rewind
+ io.write(orig_io.read)
+ io.close_write
+ StringIO.new(io.read)
+ end
+ rescue Errno::EPIPE => e
+ raise PaperclipError.new(attachment), "Could not create thumbnail. Is ImageMagick or GraphicsMagick installed and available?"
+ rescue SystemCallError => e
+ raise PaperclipError.new(attachment), "Could not create thumbnail."
+ end
+ if ::Thoughtbot::Paperclip.options[:whiny_thumbnails] && !$?.success?
+ raise PaperclipError.new(attachment), "Convert returned with result code #{$?.exitstatus}: #{thumb.read}"
+ end
+ thumb
+ end
+
+ def geometry_for_crop geometry, orig_io
+ IO.popen("#{path_for_command "identify"} - 2>/dev/null", "w+") do |io|
+ orig_io.rewind
+ io.write(orig_io.read)
+ io.close_write
+ if match = io.read.split[2].match(/(\d+)x(\d+)/)
+ src = match[1,2].map(&:to_f)
+ srch = src[0] > src[1]
+ dst = geometry.match(/(\d+)x(\d+)/)[1,2].map(&:to_f)
+ dsth = dst[0] > dst[1]
+ ar = src[0] / src[1]
+
+ scale_geometry, scale = if dst[0] == dst[1]
+ if srch
+ [ "x#{dst[1]}", src[1] / dst[1] ]
+ else
+ [ "#{dst[0]}x", src[0] / dst[0] ]
+ end
+ elsif dsth
+ [ "#{dst[0]}x", src[0] / dst[0] ]
+ else
+ [ "x#{dst[1]}", src[1] / dst[1] ]
+ end
+
+ crop_geometry = if dsth
+ "%dx%d+%d+%d" % [ dst[0], dst[1], 0, (src[1] / scale - dst[1]) / 2 ]
+ else
+ "%dx%d+%d+%d" % [ dst[0], dst[1], (src[0] / scale - dst[0]) / 2, 0 ]
+ end
+
+ [ scale_geometry, crop_geometry ]
+ end
+ end
+ end
+
+ def path_for_command command
+ File.join([::Thoughtbot::Paperclip.options[:image_magick_path], command].compact)
+ end
+
+ def to_s
+ self.class.name
+ end
+ end
+ end
+end
View
80 lib/paperclip/storage/filesystem.rb
@@ -0,0 +1,80 @@
+module Thoughtbot
+ module Paperclip
+ module ClassMethods
+
+ def has_attached_file_with_fs *attachment_names
+ has_attached_file_without_fs *attachment_names
+ end
+ alias_method_chain :has_attached_file, :fs
+
+ end
+
+ class Storage
+ class Filesystem < Storage
+ def path_for attachment, style = nil
+ style ||= attachment[:default_style]
+ file = attachment[:instance]["#{attachment[:name]}_file_name"]
+ return nil unless file && attachment[:instance].id
+
+ prefix = interpolate attachment, "#{attachment[:path_prefix]}/#{attachment[:path]}", style
+ File.join( prefix.split("/") )
+ end
+
+ def url_for attachment, style = nil
+ style ||= attachment[:default_style]
+ file = attachment[:instance]["#{attachment[:name]}_file_name"]
+ return nil unless file && attachment[:instance].id
+
+ interpolate attachment, "#{attachment[:url_prefix]}/#{attachment[:path]}", style
+ end
+
+ def ensure_directories_for attachment
+ attachment[:files].each do |style, file|
+ dirname = File.dirname(path_for(attachment, style))
+ FileUtils.mkdir_p dirname
+ end
+ end
+
+ def write_attachment attachment
+ return if attachment[:files].blank?
+ ensure_directories_for attachment
+ attachment[:files].each do |style, atch|
+ atch.rewind
+ data = atch.read
+ File.open( path_for(attachment, style), "w" ) do |file|
+ file.rewind
+ file.write(data)
+ end
+ end
+ attachment[:files] = nil
+ attachment[:dirty] = false
+ end
+
+ def delete_attachment attachment, complain = false
+ (attachment[:thumbnails].keys + [:original]).each do |style|
+ file_path = path_for(attachment, style)
+ begin
+ FileUtils.rm file_path if file_path
+ rescue SystemCallError => e
+ raise PaperclipError.new(attachment), "Could not delete thumbnail." if ::Thoughtbot::Paperclip.options[:whiny_deletes] || complain
+ end
+ end
+ end
+
+ def attachment_valid? attachment
+ attachment[:thumbnails].merge(:original => nil).all? do |style, geometry|
+ if attachment[:instance]["#{attachment[:name]}_file_name"]
+ if attachment[:dirty]
+ !attachment[:files][style].blank? && attachment[:errors].empty?
+ else
+ File.file?( path_for(attachment, style) )
+ end
+ else
+ false
+ end
+ end
+ end
+ end
+ end
+ end
+end
View
100 lib/paperclip/storage/s3.rb
@@ -0,0 +1,100 @@
+module Thoughtbot
+ module Paperclip
+ module ClassMethods
+ def has_attached_file_with_s3 *attachment_names
+ attachments, options = has_attached_file_without_s3 *attachment_names
+
+ access_key = secret_key = ""
+ if file_name = s3_credentials_file
+ creds = YAML.load_file(file_name)
+ creds = creds[RAILS_ENV] || creds if Object.const_defined?("RAILS_ENV")
+ access_key = creds['access_key_id']
+ secret_key = creds['secret_access_key']
+ else
+ access_key = Thoughtbot::Paperclip.options[:s3_access_key_id]
+ secret_key = Thoughtbot::Paperclip.options[:s3_secret_access_key]
+ end
+
+ if options[:storage].to_s.downcase == "s3"
+ require 'aws/s3'
+ AWS::S3::Base.establish_connection!(
+ :access_key_id => access_key,
+ :secret_access_key => secret_key,
+ :persistent => Thoughtbot::Paperclip.options[:s3_persistent] || true
+ )
+ end
+ end
+ alias_method_chain :has_attached_file, :s3
+
+ private
+ def s3_credentials_file
+ [ Thoughtbot::Paperclip.options[:s3_credentials_file], File.join(RAILS_ROOT, "config", "s3.yml") ].compact.each do |f|
+ return f if File.exists?(f)
+ end
+ nil
+ end
+ end
+
+ class Storage
+ class S3 < Storage
+ def path_for attachment, style = nil
+ style ||= attachment[:default_style]
+ file = attachment[:instance]["#{attachment[:name]}_file_name"]
+ return nil unless file && attachment[:instance].id
+
+ interpolate attachment, attachment[:path], style
+ end
+
+ def url_for attachment, style = nil
+ "http://s3.amazonaws.com/#{bucket_for(attachment)}/#{path_for(attachment, style)}"
+ end
+
+ def bucket_for attachment
+ bucket_name = interpolate attachment, attachment[:url_prefix], nil
+ end
+
+ def ensure_bucket_for attachment, style = nil
+ begin
+ bucket_name = bucket_for attachment
+ AWS::S3::Bucket.create(bucket_name)
+ bucket_name
+ rescue AWS::S3::S3Exception => e
+ raise Thoughtbot::Paperclip::PaperclipError.new(attachment), "You are not allowed access to the bucket '#{bucket_name}'."
+ end
+ end
+
+ def write_attachment attachment
+ return if attachment[:files].blank?
+ bucket = ensure_bucket_for attachment
+ attachment[:files].each do |style, atch|
+ atch.rewind
+ AWS::S3::S3Object.store( path_for(attachment, style), atch, bucket, :access => attachment[:access] || :public_read )
+ end
+ attachment[:files] = nil
+ attachment[:dirty] = false
+ end
+
+ def delete_attachment attachment, complain = false
+ (attachment[:thumbnails].keys + [:original]).each do |style|
+ file_path = path_for(attachment, style)
+ AWS::S3::S3Object.delete( file_path, bucket_for(attachment) )
+ end
+ end
+
+ def attachment_valid? attachment
+ attachment[:thumbnails].merge(:original => nil).all? do |style, geometry|
+ if attachment[:instance]["#{attachment[:name]}_file_name"]
+ if attachment[:dirty]
+ !attachment[:files][style].blank? && attachment[:errors].empty?
+ else
+ AWS::S3::S3Object.exists?( path_for(attachment, style), bucket_for(attachment) )
+ end
+ else
+ false
+ end
+ end
+ end
+ end
+ end
+ end
+end
View
24 test/models.rb
@@ -13,6 +13,12 @@
table.column :avatar_file_name, :string
table.column :avatar_content_type, :string
end
+ ActiveRecord::Base.connection.create_table :ess_threes, :force => true do |table|
+ table.column :resume_file_name, :string
+ table.column :resume_content_type, :string
+ table.column :avatar_file_name, :string
+ table.column :avatar_content_type, :string
+ end
ActiveRecord::Base.connection.create_table :negatives, :force => true do |table|
table.column :this_is_the_wrong_name_file_name, :string
end
@@ -47,5 +53,23 @@ class NonStandard < ActiveRecord::Base
:missing_url => "/:class/:style/:attachment/404.png"
end
+# class EssThree < ActiveRecord::Base
+# has_attached_file :resume, :attachment_type => :document,
+# :path_prefix => "paperclip/test",
+# :path => ":attachment_:id_:name",
+# :missing_url => "/:class/:style/:attachment/404.txt",
+# :storage => :S3
+# has_attached_file :avatar, :attachment_type => :image,
+# :thumbnails => { :cropped => "200x10#",
+# :bigger => "1000x1000",
+# :smaller => "200x200>",
+# :square => "150x150#" },
+# :path_prefix => "paperclip/test/images",
+# :path => ":class/:attachment/:id/:style_:name",
+# :default_style => :square,
+# :missing_url => "/:class/:style/:attachment/404.png",
+# :storage => :S3
+# end
+
class Negative < ActiveRecord::Base
end
View
14 test/paperclip_s3_test.rb
@@ -0,0 +1,14 @@
+require 'test/unit'
+require File.dirname(__FILE__) + "/test_helper.rb"
+require File.dirname(__FILE__) + "/../init.rb"
+require File.join(File.dirname(__FILE__), "models.rb")
+
+class PaperclipS3Test < Test::Unit::TestCase
+ def setup
+ end
+
+ def test_truth
+ assert true
+ end
+
+end
Please sign in to comment.
Something went wrong with that request. Please try again.