From dd77eb75ac15dd556a513262617216b9eeccaa1e Mon Sep 17 00:00:00 2001 From: dborisov Date: Tue, 4 Jul 2017 14:52:10 +0300 Subject: [PATCH] extract paperclip from insales to separate repository --- LICENSE | 26 + README.rdoc | 174 ++++ Rakefile | 99 +++ generators/paperclip/USAGE | 5 + generators/paperclip/paperclip_generator.rb | 27 + .../templates/paperclip_migration.rb.erb | 19 + init.rb | 1 + lib/paperclip.rb | 369 +++++++++ lib/paperclip/attachment.rb | 533 ++++++++++++ lib/paperclip/callback_compatability.rb | 62 ++ lib/paperclip/geometry.rb | 115 +++ lib/paperclip/interpolations.rb | 105 +++ lib/paperclip/iostream.rb | 45 + lib/paperclip/matchers.rb | 4 + .../matchers/have_attached_file_matcher.rb | 49 ++ ...alidate_attachment_content_type_matcher.rb | 66 ++ .../validate_attachment_presence_matcher.rb | 48 ++ .../validate_attachment_size_matcher.rb | 83 ++ lib/paperclip/optimizer.rb | 44 + lib/paperclip/processor.rb | 49 ++ lib/paperclip/recursive_thumbnail.rb | 12 + lib/paperclip/storage.rb | 346 ++++++++ lib/paperclip/thumbnail.rb | 91 +++ lib/paperclip/upfile.rb | 64 ++ lib/tasks/paperclip_tasks.rake | 79 ++ paperclip.gemspec | 36 + shoulda_macros/paperclip.rb | 68 ++ test/.gitignore | 1 + test/attachment_test.rb | 768 ++++++++++++++++++ test/database.yml | 4 + test/fixtures/12k.png | Bin 0 -> 12093 bytes test/fixtures/50x50.png | Bin 0 -> 1615 bytes test/fixtures/5k.png | Bin 0 -> 4456 bytes test/fixtures/bad.png | 1 + test/fixtures/s3.yml | 4 + test/fixtures/text.txt | 0 test/fixtures/twopage.pdf | Bin 0 -> 8775 bytes test/geometry_test.rb | 177 ++++ test/helper.rb | 100 +++ test/integration_test.rb | 481 +++++++++++ test/interpolations_test.rb | 120 +++ test/iostream_test.rb | 71 ++ .../have_attached_file_matcher_test.rb | 21 + ...te_attachment_content_type_matcher_test.rb | 30 + ...lidate_attachment_presence_matcher_test.rb | 21 + .../validate_attachment_size_matcher_test.rb | 50 ++ test/paperclip_test.rb | 291 +++++++ test/processor_test.rb | 10 + test/storage_test.rb | 282 +++++++ test/thumbnail_test.rb | 177 ++++ 50 files changed, 5228 insertions(+) create mode 100644 LICENSE create mode 100644 README.rdoc create mode 100644 Rakefile create mode 100644 generators/paperclip/USAGE create mode 100644 generators/paperclip/paperclip_generator.rb create mode 100644 generators/paperclip/templates/paperclip_migration.rb.erb create mode 100644 init.rb create mode 100644 lib/paperclip.rb create mode 100644 lib/paperclip/attachment.rb create mode 100644 lib/paperclip/callback_compatability.rb create mode 100644 lib/paperclip/geometry.rb create mode 100644 lib/paperclip/interpolations.rb create mode 100644 lib/paperclip/iostream.rb create mode 100644 lib/paperclip/matchers.rb create mode 100644 lib/paperclip/matchers/have_attached_file_matcher.rb create mode 100644 lib/paperclip/matchers/validate_attachment_content_type_matcher.rb create mode 100644 lib/paperclip/matchers/validate_attachment_presence_matcher.rb create mode 100644 lib/paperclip/matchers/validate_attachment_size_matcher.rb create mode 100644 lib/paperclip/optimizer.rb create mode 100644 lib/paperclip/processor.rb create mode 100644 lib/paperclip/recursive_thumbnail.rb create mode 100644 lib/paperclip/storage.rb create mode 100644 lib/paperclip/thumbnail.rb create mode 100644 lib/paperclip/upfile.rb create mode 100644 lib/tasks/paperclip_tasks.rake create mode 100644 paperclip.gemspec create mode 100644 shoulda_macros/paperclip.rb create mode 100644 test/.gitignore create mode 100644 test/attachment_test.rb create mode 100644 test/database.yml create mode 100644 test/fixtures/12k.png create mode 100644 test/fixtures/50x50.png create mode 100644 test/fixtures/5k.png create mode 100644 test/fixtures/bad.png create mode 100644 test/fixtures/s3.yml create mode 100644 test/fixtures/text.txt create mode 100644 test/fixtures/twopage.pdf create mode 100644 test/geometry_test.rb create mode 100644 test/helper.rb create mode 100644 test/integration_test.rb create mode 100644 test/interpolations_test.rb create mode 100644 test/iostream_test.rb create mode 100644 test/matchers/have_attached_file_matcher_test.rb create mode 100644 test/matchers/validate_attachment_content_type_matcher_test.rb create mode 100644 test/matchers/validate_attachment_presence_matcher_test.rb create mode 100644 test/matchers/validate_attachment_size_matcher_test.rb create mode 100644 test/paperclip_test.rb create mode 100644 test/processor_test.rb create mode 100644 test/storage_test.rb create mode 100644 test/thumbnail_test.rb diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..299b9ed --- /dev/null +++ b/LICENSE @@ -0,0 +1,26 @@ + +LICENSE + +The MIT License + +Copyright (c) 2008 Jon Yurek and thoughtbot, inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + + diff --git a/README.rdoc b/README.rdoc new file mode 100644 index 0000000..883b082 --- /dev/null +++ b/README.rdoc @@ -0,0 +1,174 @@ +=Paperclip + +Paperclip is intended as an easy file attachment library for ActiveRecord. The +intent behind it was to keep setup as easy as possible and to treat files as +much like other attributes as possible. This means they aren't saved to their +final locations on disk, nor are they deleted if set to nil, until +ActiveRecord::Base#save is called. It manages validations based on size and +presence, if required. It can transform its assigned image into thumbnails if +needed, and the prerequisites are as simple as installing ImageMagick (which, +for most modern Unix-based systems, is as easy as installing the right +packages). Attached files are saved to the filesystem and referenced in the +browser by an easily understandable specification, which has sensible and +useful defaults. + +See the documentation for +has_attached_file+ in Paperclip::ClassMethods for +more detailed options. + +==Quick Start + +In your model: + + class User < ActiveRecord::Base + has_attached_file :avatar, :styles => { :medium => "300x300>", :thumb => "100x100>" } + end + +In your migrations: + + class AddAvatarColumnsToUser < ActiveRecord::Migration + def self.up + add_column :users, :avatar_file_name, :string + add_column :users, :avatar_content_type, :string + add_column :users, :avatar_file_size, :integer + add_column :users, :avatar_updated_at, :datetime + end + + def self.down + remove_column :users, :avatar_file_name + remove_column :users, :avatar_content_type + remove_column :users, :avatar_file_size + remove_column :users, :avatar_updated_at + end + end + +In your edit and new views: + + <% form_for :user, @user, :url => user_path, :html => { :multipart => true } do |form| %> + <%= form.file_field :avatar %> + <% end %> + +In your controller: + + def create + @user = User.create( params[:user] ) + end + +In your show view: + + <%= image_tag @user.avatar.url %> + <%= image_tag @user.avatar.url(:medium) %> + <%= image_tag @user.avatar.url(:thumb) %> + +==Usage + +The basics of paperclip are quite simple: Declare that your model has an +attachment with the has_attached_file method, and give it a name. Paperclip +will wrap up up to four attributes (all prefixed with that attachment's name, +so you can have multiple attachments per model if you wish) and give the a +friendly front end. The attributes are _file_name, +_file_size, _content_type, and _updated_at. +Only _file_name is required for paperclip to operate. More +information about the options to has_attached_file is available in the +documentation of Paperclip::ClassMethods. + +Attachments can be validated with Paperclip's validation methods, +validates_attachment_presence, validates_attachment_content_type, and +validates_attachment_size. + +==Storage + +The files that are assigned as attachments are, by default, placed in the +directory specified by the :path option to has_attached_file. By default, this +location is ":rails_root/public/system/:attachment/:id/:style/:filename". This +location was chosen because on standard Capistrano deployments, the +public/system directory is symlinked to the app's shared directory, meaning it +will survive between deployments. For example, using that :path, you may have a +file at + + /data/myapp/releases/20081229172410/public/system/avatars/13/small/my_pic.png + +NOTE: This is a change from previous versions of Paperclip, but is overall a +safer choice for the default file store. + +You may also choose to store your files using Amazon's S3 service. You can find +more information about S3 storage at the description for +Paperclip::Storage::S3. + +Files on the local filesystem (and in the Rails app's public directory) will be +available to the internet at large. If you require access control, it's +possible to place your files in a different location. You will need to change +both the :path and :url options in order to make sure the files are unavailable +to the public. Both :path and :url allow the same set of interpolated +variables. + +==Post Processing + +Paperclip supports an extensible selection of post-processors. When you define +a set of styles for an attachment, by default it is expected that those +"styles" are actually "thumbnails". However, you can do much more than just +thumbnail images. By defining a subclass of Paperclip::Processor, you can +perform any processing you want on the files that are attached. Any file in +your Rails app's lib/paperclip_processors directory is automatically loaded by +paperclip, allowing you to easily define custom processors. You can specify a +processor with the :processors option to has_attached_file: + + has_attached_file :scan, :styles => { :text => { :quality => :better } }, + :processors => [:ocr] + +This would load the hypothetical class Paperclip::Ocr, which would have the +hash "{ :quality => :better }" passed to it along with the uploaded file. For +more information about defining processors, see Paperclip::Processor. + +The default processor is Paperclip::Thumbnail. For backwards compatability +reasons, you can pass a single geometry string or an array containing a +geometry and a format, which the file will be converted to, like so: + + has_attached_file :avatar, :styles => { :thumb => ["32x32#", :png] } + +This will convert the "thumb" style to a 32x32 square in png format, regardless +of what was uploaded. If the format is not specified, it is kept the same (i.e. +jpgs will remain jpgs). + +Multiple processors can be specified, and they will be invoked in the order +they are defined in the :processors array. Each successive processor will +be given the result of the previous processor's execution. All processors will +receive the same parameters, which are what you define in the :styles hash. +For example, assuming we had this definition: + + has_attached_file :scan, :styles => { :text => { :quality => :better } }, + :processors => [:rotator, :ocr] + +then both the :rotator processor and the :ocr processor would receive the +options "{ :quality => :better }". This parameter may not mean anything to one +or more or the processors, and they are expected to ignore it. + +NOTE: Because processors operate by turning the original attachment into the +styles, no processors will be run if there are no styles defined. + +==Events + +Before and after the Post Processing step, Paperclip calls back to the model +with a few callbacks, allowing the model to change or cancel the processing +step. The callbacks are "before_post_process" and "after_post_process" (which +are called before and after the processing of each attachment), and the +attachment-specific "before__post_process" and +"after__post_process". The callbacks are intended to be as close to +normal ActiveRecord callbacks as possible, so if you return false (specifically +- returning nil is not the same) in a before_ filter, the post processing step +will halt. Returning false in an after_ filter will not halt anything, but you +can access the model and the attachment if necessary. + +NOTE: Post processing will not even *start* if the attachment is not valid +according to the validations. Your callbacks and processors will *only* be +called with valid attachments. + +==Contributing + +If you'd like to contribute a feature or bugfix: Thanks! To make sure your +fix/feature has a high chance of being included, please read the following +guidelines: + +1. Ask on the mailing list, or post a new GitHub Issue. +2. Make sure there are tests! We will not accept any patch that is not tested. + It's a rare time when explicit tests aren't needed. If you have questions + about writing tests for paperclip, please ask the mailing list. diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..f364879 --- /dev/null +++ b/Rakefile @@ -0,0 +1,99 @@ +require 'rake' +require 'rake/testtask' +require 'rake/rdoctask' + +$LOAD_PATH << File.join(File.dirname(__FILE__), 'lib') +require 'paperclip' + +desc 'Default: run unit tests.' +task :default => [:clean, :test] + +desc 'Test the paperclip plugin.' +Rake::TestTask.new(:test) do |t| + t.libs << 'lib' << 'profile' + t.pattern = 'test/**/*_test.rb' + t.verbose = true +end + +desc 'Start an IRB session with all necessary files required.' +task :shell do |t| + chdir File.dirname(__FILE__) + exec 'irb -I lib/ -I lib/paperclip -r rubygems -r active_record -r tempfile -r init' +end + +desc 'Generate documentation for the paperclip plugin.' +Rake::RDocTask.new(:rdoc) do |rdoc| + rdoc.rdoc_dir = 'doc' + rdoc.title = 'Paperclip' + rdoc.options << '--line-numbers' << '--inline-source' + rdoc.rdoc_files.include('README*') + rdoc.rdoc_files.include('lib/**/*.rb') +end + +desc 'Update documentation on website' +task :sync_docs => 'rdoc' do + `rsync -ave ssh doc/ dev@dev.thoughtbot.com:/home/dev/www/dev.thoughtbot.com/paperclip` +end + +desc 'Clean up files.' +task :clean do |t| + FileUtils.rm_rf "doc" + FileUtils.rm_rf "tmp" + FileUtils.rm_rf "pkg" + FileUtils.rm "test/debug.log" rescue nil + FileUtils.rm "test/paperclip.db" rescue nil + Dir.glob("paperclip-*.gem").each{|f| FileUtils.rm f } +end + +include_file_globs = ["README*", + "LICENSE", + "Rakefile", + "init.rb", + "{generators,lib,tasks,test,shoulda_macros}/**/*"] +exclude_file_globs = ["test/s3.yml", + "test/debug.log", + "test/paperclip.db", + "test/doc", + "test/doc/*", + "test/pkg", + "test/pkg/*", + "test/tmp", + "test/tmp/*"] +spec = Gem::Specification.new do |s| + s.name = "paperclip" + s.version = Paperclip::VERSION + s.author = "Jon Yurek" + s.email = "jyurek@thoughtbot.com" + s.homepage = "http://www.thoughtbot.com/projects/paperclip" + s.platform = Gem::Platform::RUBY + s.summary = "File attachments as attributes for ActiveRecord" + s.files = FileList[include_file_globs].to_a - FileList[exclude_file_globs].to_a + s.require_path = "lib" + s.test_files = FileList["test/**/test_*.rb"].to_a + s.rubyforge_project = "paperclip" + s.has_rdoc = true + s.extra_rdoc_files = FileList["README*"].to_a + s.rdoc_options << '--line-numbers' << '--inline-source' + s.requirements << "ImageMagick" + s.add_development_dependency 'thoughtbot-shoulda' + s.add_development_dependency 'mocha' +end + +desc "Print a list of the files to be put into the gem" +task :manifest => :clean do + spec.files.each do |file| + puts file + end +end + +desc "Generate a gemspec file for GitHub" +task :gemspec => :clean do + File.open("#{spec.name}.gemspec", 'w') do |f| + f.write spec.to_ruby + end +end + +desc "Build the gem into the current directory" +task :gem => :gemspec do + `gem build #{spec.name}.gemspec` +end diff --git a/generators/paperclip/USAGE b/generators/paperclip/USAGE new file mode 100644 index 0000000..2d611d7 --- /dev/null +++ b/generators/paperclip/USAGE @@ -0,0 +1,5 @@ +Usage: + + script/generate paperclip Class attachment1 (attachment2 ...) + +This will create a migration that will add the proper columns to your class's table. \ No newline at end of file diff --git a/generators/paperclip/paperclip_generator.rb b/generators/paperclip/paperclip_generator.rb new file mode 100644 index 0000000..b5d3c22 --- /dev/null +++ b/generators/paperclip/paperclip_generator.rb @@ -0,0 +1,27 @@ +class PaperclipGenerator < Rails::Generator::NamedBase + attr_accessor :attachments, :migration_name + + def initialize(args, options = {}) + super + @class_name, @attachments = args[0], args[1..-1] + end + + def manifest + file_name = generate_file_name + @migration_name = file_name.camelize + record do |m| + m.migration_template "paperclip_migration.rb.erb", + File.join('db', 'migrate'), + :migration_file_name => file_name + end + end + + private + + def generate_file_name + names = attachments.map{|a| a.underscore } + names = names[0..-2] + ["and", names[-1]] if names.length > 1 + "add_attachments_#{names.join("_")}_to_#{@class_name.underscore}" + end + +end diff --git a/generators/paperclip/templates/paperclip_migration.rb.erb b/generators/paperclip/templates/paperclip_migration.rb.erb new file mode 100644 index 0000000..eebb0e5 --- /dev/null +++ b/generators/paperclip/templates/paperclip_migration.rb.erb @@ -0,0 +1,19 @@ +class <%= migration_name %> < ActiveRecord::Migration + def self.up +<% attachments.each do |attachment| -%> + add_column :<%= class_name.underscore.camelize.tableize %>, :<%= attachment %>_file_name, :string + add_column :<%= class_name.underscore.camelize.tableize %>, :<%= attachment %>_content_type, :string + add_column :<%= class_name.underscore.camelize.tableize %>, :<%= attachment %>_file_size, :integer + add_column :<%= class_name.underscore.camelize.tableize %>, :<%= attachment %>_updated_at, :datetime +<% end -%> + end + + def self.down +<% attachments.each do |attachment| -%> + remove_column :<%= class_name.underscore.camelize.tableize %>, :<%= attachment %>_file_name + remove_column :<%= class_name.underscore.camelize.tableize %>, :<%= attachment %>_content_type + remove_column :<%= class_name.underscore.camelize.tableize %>, :<%= attachment %>_file_size + remove_column :<%= class_name.underscore.camelize.tableize %>, :<%= attachment %>_updated_at +<% end -%> + end +end diff --git a/init.rb b/init.rb new file mode 100644 index 0000000..5a07dda --- /dev/null +++ b/init.rb @@ -0,0 +1 @@ +require File.join(File.dirname(__FILE__), "lib", "paperclip") diff --git a/lib/paperclip.rb b/lib/paperclip.rb new file mode 100644 index 0000000..c4877a2 --- /dev/null +++ b/lib/paperclip.rb @@ -0,0 +1,369 @@ +# Paperclip allows file attachments that are stored in the filesystem. All graphical +# transformations are done using the Graphics/ImageMagick command line utilities and +# are stored in Tempfiles until the record is saved. Paperclip does not require a +# separate model for storing the attachment's information, instead adding a few simple +# columns to your table. +# +# Author:: Jon Yurek +# Copyright:: Copyright (c) 2008-2009 thoughtbot, inc. +# License:: MIT License (http://www.opensource.org/licenses/mit-license.php) +# +# 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, :styles => { :thumb => "100x100" } +# end +# +# user = User.new +# user.avatar = params[:user][:avatar] +# user.avatar.url +# # => "/users/avatars/4/original_me.jpg" +# user.avatar.url(:thumb) +# # => "/users/avatars/4/thumb_me.jpg" +# +# See the +has_attached_file+ documentation for more details. + +require 'tempfile' +require 'fastimage' +require 'paperclip/upfile' +require 'paperclip/iostream' +require 'paperclip/geometry' +require 'paperclip/processor' +require 'paperclip/thumbnail' +require 'paperclip/storage' +require 'paperclip/interpolations' +require 'paperclip/attachment' +if defined? Rails.root + Dir.glob(File.join(File.expand_path(Rails.root), "lib", "paperclip_processors", "*.rb")).each do |processor| + require processor + end +end + +# The base module that gets included in ActiveRecord::Base. See the +# documentation for Paperclip::ClassMethods for more useful information. +module Paperclip + + VERSION = "2.2.9.2" + + class << self + # Provides configurability to Paperclip. There are a number of options available, such as: + # * whiny: Will raise an error if Paperclip cannot process thumbnails of + # an uploaded image. Defaults to true. + # * log: Logs progress to the Rails log. Uses ActiveRecord's logger, so honors + # log levels, etc. Defaults to true. + # * command_path: Defines the path at which to find the command line + # programs if they are not visible to Rails the system's search path. Defaults to + # nil, which uses the first executable found in the user's search path. + # * image_magick_path: Deprecated alias of command_path. + def options + @options ||= { + :whiny => true, + :image_magick_path => nil, + :command_path => nil, + :log => true, + :log_command => false, + :swallow_stderr => true + } + end + + def path_for_command command #:nodoc: + if options[:image_magick_path] + warn("[DEPRECATION] :image_magick_path is deprecated and will be removed. Use :command_path instead") + end + path = [options[:command_path] || options[:image_magick_path], command].compact + File.join(*path) + end + + def interpolates key, &block + Paperclip::Interpolations[key] = block + end + + # The run method takes a command to execute and a string of parameters + # that get passed to it. The command is prefixed with the :command_path + # option from Paperclip.options. If you have many commands to run and + # they are in different paths, the suggested course of action is to + # symlink them so they are all in the same directory. + # + # If the command returns with a result code that is not one of the + # expected_outcodes, a PaperclipCommandLineError will be raised. Generally + # a code of 0 is expected, but a list of codes may be passed if necessary. + # + # This method can log the command being run when + # Paperclip.options[:log_command] is set to true (defaults to false). This + # will only log if logging in general is set to true as well. + def run cmd, params = "", expected_outcodes = 0 + command = %Q<#{%Q[#{path_for_command(cmd)} #{params}].gsub(/\s+/, " ")}> + command = "#{command} 2>#{bit_bucket}" if Paperclip.options[:swallow_stderr] + Paperclip.log(command) if Paperclip.options[:log_command] + output = `#{command}` + unless [expected_outcodes].flatten.include?($?.exitstatus) + raise PaperclipCommandLineError, "Error while running #{cmd}" + end + output + end + + def bit_bucket #:nodoc: + File.exists?("/dev/null") ? "/dev/null" : "NUL" + end + + def included base #:nodoc: + base.extend ClassMethods + base.class_attribute :attachment_definitions + + if base.respond_to?(:set_callback) + base.send :include, Paperclip::CallbackCompatability::Rails3 + else + base.send :include, Paperclip::CallbackCompatability::Rails21 + end + end + + def processor name #:nodoc: + name = name.to_s.camelize + processor = Paperclip.const_get(name) + unless processor.ancestors.include?(Paperclip::Processor) + raise PaperclipError.new("Processor #{name} was not found") + end + processor + end + + # Log a paperclip-specific line. Uses ActiveRecord::Base.logger + # by default. Set Paperclip.options[:log] to false to turn off. + def log message + logger.info("[paperclip] #{message}") if logging? + end + + def logger #:nodoc: + ActiveRecord::Base.logger + end + + def logging? #:nodoc: + options[:log] + end + end + + class PaperclipError < StandardError #:nodoc: + end + + class PaperclipCommandLineError < StandardError #:nodoc: + end + + class NotIdentifiedByImageMagickError < PaperclipError #:nodoc: + end + + class InfiniteInterpolationError < PaperclipError #:nodoc: + end + + module ClassMethods + # +has_attached_file+ gives the class it is called on an attribute that maps to a file. This + # is typically a file stored somewhere on the filesystem and has been uploaded by a user. + # The attribute returns a Paperclip::Attachment object which handles the management of + # that file. The intent is to make the attachment as much like a normal attribute. The + # thumbnails will be created when the new file is assigned, but they will *not* be saved + # until +save+ is called on the record. Likewise, if the attribute is set to +nil+ is + # called on it, the attachment will *not* be deleted until +save+ is called. See the + # Paperclip::Attachment documentation for more specifics. There are a number of options + # you can set to change the behavior of a Paperclip attachment: + # * +url+: The full URL of where the attachment is publically accessible. This can just + # as easily point to a directory served directly through Apache as it can to an action + # that can control permissions. You can specify the full domain and path, but usually + # just an absolute path is sufficient. The leading slash *must* be included manually for + # absolute paths. The default value is + # "/system/:attachment/:id/:style/:filename". See + # Paperclip::Attachment#interpolate for more information on variable interpolaton. + # :url => "/:class/:attachment/:id/:style_:filename" + # :url => "http://some.other.host/stuff/:class/:id_:extension" + # * +default_url+: The URL that will be returned if there is no attachment assigned. + # This field is interpolated just as the url is. The default value is + # "/:attachment/:style/missing.png" + # has_attached_file :avatar, :default_url => "/images/default_:style_avatar.png" + # User.new.avatar_url(:small) # => "/images/default_small_avatar.png" + # * +styles+: 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 (e.g. "50x50#"), which will resize the image to fit maximally + # inside the dimensions and then crop the rest off (weighted at the center). The + # default value is to generate no thumbnails. + # * +default_style+: The thumbnail style that will be used by default URLs. + # Defaults to +original+. + # has_attached_file :avatar, :styles => { :normal => "100x100#" }, + # :default_style => :normal + # user.avatar.url # => "/avatars/23/normal_me.png" + # * +whiny+: Will raise an error if Paperclip cannot post_process an uploaded file due + # to a command line error. This will override the global setting for this attachment. + # Defaults to true. This option used to be called :whiny_thumbanils, but this is + # deprecated. + # * +convert_options+: When creating thumbnails, use this free-form options + # field to pass in various convert command options. Typical options are "-strip" to + # remove all Exif data from the image (save space for thumbnails and avatars) or + # "-depth 8" to specify the bit depth of the resulting conversion. See ImageMagick + # convert documentation for more options: (http://www.imagemagick.org/script/convert.php) + # Note that this option takes a hash of options, each of which correspond to the style + # of thumbnail being generated. You can also specify :all as a key, which will apply + # to all of the thumbnails being generated. If you specify options for the :original, + # it would be best if you did not specify destructive options, as the intent of keeping + # the original around is to regenerate all the thumbnails when requirements change. + # has_attached_file :avatar, :styles => { :large => "300x300", :negative => "100x100" } + # :convert_options => { + # :all => "-strip", + # :negative => "-negate" + # } + # NOTE: While not deprecated yet, it is not recommended to specify options this way. + # It is recommended that :convert_options option be included in the hash passed to each + # :styles for compatability with future versions. + # * +storage+: Chooses the storage backend where the files will be stored. The current + # choices are :filesystem and :s3. The default is :filesystem. Make sure you read the + # documentation for Paperclip::Storage::Filesystem and Paperclip::Storage::S3 + # for backend-specific options. + def has_attached_file name, options = {} + include InstanceMethods + + if attachment_definitions.nil? + self.attachment_definitions = {} + else + self.attachment_definitions = self.attachment_definitions.dup + end + attachment_definitions[name] = {:validations => []}.merge(options) + + after_save :save_attached_files + after_commit :destroy_attached_files, on: :destroy + after_commit :flush_attachment_jobs + + define_paperclip_callbacks :post_process, :"#{name}_post_process" + + define_method name do |*args| + a = attachment_for(name) + (args.length > 0) ? a.to_s(args.first) : a + end + + define_method "#{name}=" do |file| + attachment_for(name).assign(file) + end + + define_method "#{name}?" do + attachment_for(name).file? + end + + validates_each(name) do |record, attr, value| + attachment = record.attachment_for(name) + attachment.send(:flush_errors) unless attachment.valid? + end + end + + # Places ActiveRecord-style validations on the size of the file assigned. The + # possible options are: + # * +in+: a Range of bytes (i.e. +1..1.megabyte+), + # * +less_than+: equivalent to :in => 0..options[:less_than] + # * +greater_than+: equivalent to :in => options[:greater_than]..Infinity + # * +message+: error message to display, use :min and :max as replacements + # * +if+: A lambda or name of a method on the instance. Validation will only + # be run is this lambda or method returns true. + # * +unless+: Same as +if+ but validates if lambda or method returns false. + def validates_attachment_size name, options = {} + min = options[:greater_than] || (options[:in] && options[:in].first) || 0 + max = options[:less_than] || (options[:in] && options[:in].last) || (1.0/0) + range = (min..max) + message = options[:message] || "file size must be between :min and :max bytes." + + attachment_definitions[name][:validations] << [:size, {:range => range, + :message => message, + :if => options[:if], + :unless => options[:unless]}] + end + + # Adds errors if thumbnail creation fails. The same as specifying :whiny_thumbnails => true. + def validates_attachment_thumbnails name, options = {} + warn('[DEPRECATION] validates_attachment_thumbnail is deprecated. ' + + 'This validation is on by default and will be removed from future versions. ' + + 'If you wish to turn it off, supply :whiny => false in your definition.') + attachment_definitions[name][:whiny_thumbnails] = true + end + + # Places ActiveRecord-style validations on the presence of a file. + # Options: + # * +if+: A lambda or name of a method on the instance. Validation will only + # be run is this lambda or method returns true. + # * +unless+: Same as +if+ but validates if lambda or method returns false. + def validates_attachment_presence name, options = {} + message = options[:message] || :blank + + attachment_definitions[name][:validations] << [:presence, {:message => message, + :if => options[:if], + :unless => options[:unless]}] + end + + # Places ActiveRecord-style validations on the content type of the file + # assigned. The possible options are: + # * +content_type+: Allowed content types. Can be a single content type + # or an array. Each type can be a String or a Regexp. It should be + # noted that Internet Explorer upload files with content_types that you + # may not expect. For example, JPEG images are given image/pjpeg and + # PNGs are image/x-png, so keep that in mind when determining how you + # match. Allows all by default. + # * +message+: The message to display when the uploaded file has an invalid + # content type. + # * +if+: A lambda or name of a method on the instance. Validation will only + # be run is this lambda or method returns true. + # * +unless+: Same as +if+ but validates if lambda or method returns false. + # NOTE: If you do not specify an [attachment]_content_type field on your + # model, content_type validation will work _ONLY upon assignment_ and + # re-validation after the instance has been reloaded will always succeed. + def validates_attachment_content_type name, options = {} + attachment_definitions[name][:validations] << [:content_type, {:content_type => options[:content_type], + :message => options[:message], + :if => options[:if], + :unless => options[:unless]}] + end + + def attachment_definitions + self.attachment_definitions + end + end + + module InstanceMethods #:nodoc: + def attachment_for name + @_paperclip_attachments ||= {} + @_paperclip_attachments[name] ||= if self.class.attachment_definitions[name][:storage] == :delayeds3 + DelayedS3Attachment.new(name, self, self.class.attachment_definitions[name]) + else + Attachment.new(name, self, self.class.attachment_definitions[name]) + end + end + + def each_attachment + self.class.attachment_definitions.each do |name, definition| + yield(name, attachment_for(name)) + end + end + + def save_attached_files + logger.info("[paperclip] Saving attachments.") + each_attachment do |name, attachment| + attachment.send(:save) + end + end + + def destroy_attached_files + logger.info("[paperclip] Deleting attachments.") + each_attachment do |name, attachment| + attachment.send(:queue_existing_for_delete) + attachment.send(:flush_deletes) + end + logger.info("[paperclip] Finish deleting attachments.") + end + + def flush_attachment_jobs + logger.info("[paperclip] flushing jobs.") + each_attachment do |name, attachment| + attachment.send(:flush_jobs) + end + end + end +end + +# Set it all up. +if Object.const_defined?("ActiveRecord") + ActiveRecord::Base.send(:include, Paperclip) + File.send(:include, Paperclip::Upfile) +end diff --git a/lib/paperclip/attachment.rb b/lib/paperclip/attachment.rb new file mode 100644 index 0000000..cc8f4ab --- /dev/null +++ b/lib/paperclip/attachment.rb @@ -0,0 +1,533 @@ +module Paperclip + # The Attachment class manages the files for a given attachment. It saves + # when the model saves, deletes when the model is destroyed, and processes + # the file upon assignment. + class Attachment + include IOStream + + MAX_FILE_NAME_LENGTH = 100 + MAX_IMAGE_RESOLUTION = 8192 + + def self.default_options + @default_options ||= { + :url => "/system/:attachment/:id/:style/:filename", + :path => ":rails_root/public:url", + :style_order => [], + :styles => {}, + :default_url => "/:attachment/:style/missing.png", + :default_style => :original, + :validations => [], + :storage => :filesystem, +# :whiny => Paperclip.options[:whiny] || Paperclip.options[:whiny_thumbnails], + :whiny => true, + :restricted_characters => /[^\w\p{Word}\d\.\-]|(^\.{0,2}$)+/, + :filename_sanitizer => nil + } + end + + attr_reader :name, :instance, :style_order, :styles, :default_style, + :convert_options, :queued_for_write, :options + + attr_accessor :post_processing + + # Creates an Attachment object. +name+ is the name of the attachment, + # +instance+ is the ActiveRecord object instance it's attached to, and + # +options+ is the same as the hash passed to +has_attached_file+. + def initialize name, instance, options = {} + @name = name + @instance = instance + + options = self.class.default_options.merge(options) + + @url = options[:url] + @s3_url = options[:s3_url] + @s3_path = options[:s3_path] + @filesystem_url = options[:filesystem_url] + @filesystem_path = options[:filesystem_path] + @url = @url.call(self) if @url.is_a?(Proc) + @path = options[:path] + @path = @path.call(self) if @path.is_a?(Proc) + @style_order = options[:style_order] + @style_order = @style_order.call(self) if @style_order.is_a?(Proc) + @styles = options[:styles] + @styles = @styles.call(self) if @styles.is_a?(Proc) + @default_url = options[:default_url] + @filesystem_url = options[:filesystem_url] + @validations = options[:validations] + @default_style = options[:default_style] + @storage = options[:storage] + @whiny = options[:whiny_thumbnails] || options[:whiny] + @convert_options = options[:convert_options] || {} + @processors = options[:processors] || [:thumbnail] + @options = options + @queued_for_delete = [] + @queued_for_write = {} + @queued_jobs = [] + @errors = {} + @validation_errors = nil + @dirty = false + + @post_processing = true + @processing_url = options[:processing_url] || @default_url + + normalize_style_definition + initialize_storage + end + + # What gets called when you call instance.attachment = File. It clears + # errors, assigns attributes, processes the file, and runs validations. It + # also queues up the previous file for deletion, to be flushed away on + # #save of its host. In addition to form uploads, you can also assign + # another Paperclip attachment: + # new_user.avatar = old_user.avatar + # If the file that is assigned is not valid, the processing (i.e. + # thumbnailing, etc) will NOT be run. + def assign uploaded_file + ensure_required_accessors! + + # загрузка через nginx + if uploaded_file.is_a?(Hash) && uploaded_file.has_key?('original_name') + u = uploaded_file + uploaded_file = FastUploadFile.new(uploaded_file) + log "fast upload: #{u.inspect}" + end + + if uploaded_file.is_a?(Paperclip::Attachment) + uploaded_file = uploaded_file.to_file(:original) + close_uploaded_file = uploaded_file.respond_to?(:close) + end + + if image_content_type?(uploaded_file) && !valid_image_resolution?(uploaded_file) + @errors[:base] = :too_large + return + end + return unless valid_assignment?(uploaded_file) + + uploaded_file.binmode if uploaded_file.respond_to? :binmode + self.clear + + return if uploaded_file.nil? + + @queued_for_write[:original] = to_tempfile(uploaded_file) + + file_name = if @options[:filename_sanitizer] + @options[:filename_sanitizer].call uploaded_file.original_filename, self + else + sanitize_filename uploaded_file.original_filename + end + + instance_write(:file_name, file_name) + instance_write(:content_type, uploaded_file.content_type.to_s.strip) + instance_write(:file_size, uploaded_file.size.to_i) + instance_write(:updated_at, Time.now) + + @dirty = true + + post_process if post_processing && valid? + + updater = :"#{name}_file_name_will_change!" + instance.send updater if instance.respond_to? updater + # Reset the file size if the original file was reprocessed. + instance_write(:file_size, @queued_for_write[:original].size.to_i) + ensure + uploaded_file.close if close_uploaded_file + validate + end + + def image_content_type?(file) + file.respond_to?(:content_type) && file.content_type.try(:include?, 'image') + end + + def valid_image_resolution? file + sizes = FastImage.size(file) + !sizes || (sizes[0] <= MAX_IMAGE_RESOLUTION && sizes[1] <= MAX_IMAGE_RESOLUTION) + end + + def most_appropriate_url + # stub for delayed_paperclip + end + + # Returns the public URL of the attachment, with a given style. Note that + # this does not necessarily need to point to a file that your web server + # can access and can point to an action in your app, if you need fine + # grained security. This is not recommended if you don't need the + # security, however, for performance reasons. set + # include_updated_timestamp to false if you want to stop the attachment + # update time appended to the url + def url style = default_style, include_updated_timestamp = true + u = @url + if @storage == :delayeds3 + u = instance_read(:synced_to_s3) ? options[:s3_url] : options[:filesystem_url] + end + # for delayed_paperclip + if @instance.respond_to?("#{name}_processing?") && @instance.send("#{name}_processing?") + return interpolate @processing_url, style + end + url = original_filename.nil? ? interpolate(@default_url, style) : interpolate(u, style) + include_updated_timestamp && updated_at ? [url, updated_at].compact.join(url.include?("?") ? "&" : "?") : url + end + + # Метод необходим в ассетах + def filesystem_url style = default_style, include_updated_timestamp = true + u = @url + u = options[:filesystem_url] if @storage == :delayeds3 + url = original_filename.nil? ? interpolate(@default_url, style) : interpolate(u, style) + include_updated_timestamp && updated_at ? [url, updated_at].compact.join(url.include?("?") ? "&" : "?") : url + end + + # Returns the path of the attachment as defined by the :path option. If the + # file is stored in the filesystem the path refers to the path of the file + # on disk. If the file is stored in S3, the path is the "key" part of the + # URL, and the :bucket option refers to the S3 bucket. + def path style = default_style + return if original_filename.nil? + p = @path + if @storage == :delayeds3 + p = instance_read(:synced_to_s3) ? options[:s3_path] : options[:filesystem_path] + end + interpolate(p, style) + end + + def filesystem_path style = default_style + return if original_filename.nil? + p = @path + p = options[:filesystem_path] if @storage == :delayeds3 + interpolate(p, style) + end + + # Alias to +url+ + def to_s style = nil + url(style) + end + + # Returns true if there are no errors on this attachment. + def valid? + validate + errors.empty? + end + + # Returns an array containing the errors on this attachment. + def errors + @errors + end + + # Returns true if there are changes that need to be saved. + def dirty? + @dirty + end + + # Saves the file, if there are no errors. If there are, it flushes them to + # the instance's errors and returns false, cancelling the save. + def save + if valid? + flush_deletes + flush_writes + @dirty = false + true + else + flush_errors + false + end + end + + # Clears out the attachment. Has the same effect as previously assigning + # nil to the attachment. Does NOT save. If you wish to clear AND save, + # use #destroy. + def clear + queue_existing_for_delete + @errors = {} + @validation_errors = nil + end + + # Destroys the attachment. Has the same effect as previously assigning + # nil to the attachment *and saving*. This is permanent. If you wish to + # wipe out the existing attachment but not save, use #clear. + def destroy + clear + save + end + + # Returns the name of the file as originally assigned, and lives in the + # _file_name attribute of the model. + def original_filename + instance_read(:file_name) + end + + # Returns the size of the file as originally assigned, and lives in the + # _file_size attribute of the model. + def size + instance_read(:file_size) || (@queued_for_write[:original] && @queued_for_write[:original].size) + end + + # Returns the content_type of the file as originally assigned, and lives + # in the _content_type attribute of the model. + def content_type + instance_read(:content_type) + end + + # Returns the last modified time of the file as originally assigned, and + # lives in the _updated_at attribute of the model. + def updated_at + time = instance_read(:updated_at) + time && time.to_i + end + + def cdn_domain + instance_read(:cdn_domain).try(:domain) + end + + def cdn_protocol + domain = instance_read(:cdn_domain) + if domain + if domain.ssl_configured? + 'https' + else + 'http' + end + else + 'https' + end + end + + + # Paths and URLs can have a number of variables interpolated into them + # to vary the storage location based on name, id, style, class, etc. + # This method is a deprecated access into supplying and retrieving these + # interpolations. Future access should use either Paperclip.interpolates + # or extend the Paperclip::Interpolations module directly. + def self.interpolations + warn('[DEPRECATION] Paperclip::Attachment.interpolations is deprecated ' + + 'and will be removed from future versions. ' + + 'Use Paperclip.interpolates instead') + Paperclip::Interpolations + end + + def sanitize_filename(file_name) + file_name = file_name.strip + file_name.gsub!(@options[:restricted_characters], '_') if @options[:restricted_characters] + + # Укорачиваем слишком длинные имена файлов. + if file_name.length > MAX_FILE_NAME_LENGTH + # 1 символ имени и точка и того - 2 + ext = file_name.match(/\.(\w{0,#{MAX_FILE_NAME_LENGTH - 2}})$/) ? $1 : "" + # 1 - из-за того что отсчет идет от 0, и еще 1 из-за точки, и того 2 + file_name = file_name[0..(MAX_FILE_NAME_LENGTH - 2 - ext.length)] + file_name << ".#{ext}" if !ext.blank? + end + file_name + end + + # This method really shouldn't be called that often. It's expected use is + # in the paperclip:refresh rake task and that's it. It will regenerate all + # thumbnails forcefully, by reobtaining the original file and going through + # the post-process again. + def reprocess! + new_original = Tempfile.new("paperclip-reprocess-#{instance.id}-") + new_original.binmode + old_original = to_file(:original) + new_original.write( old_original.read ) + new_original.rewind + @queued_for_write = { :original => new_original } + post_process + old_original.close if old_original.respond_to?(:close) + save + flush_jobs + end + + # Returns true if a file has been assigned. + def file? + !original_filename.blank? + end + + # Writes the attachment-specific attribute on the instance. For example, + # instance_write(:file_name, "me.jpg") will write "me.jpg" to the instance's + # "avatar_file_name" field (assuming the attachment is called avatar). + def instance_write(attr, value) + setter = :"#{name}_#{attr}=" + responds = instance.respond_to?(setter) + self.instance_variable_set("@_#{setter.to_s.chop}", value) + instance.send(setter, value) if responds || attr.to_s == "file_name" + end + + def instance_update(attr, value) + setter = :"#{name}_#{attr}=" + responds = instance.respond_to?(setter) + self.instance_variable_set("@_#{setter.to_s.chop}", value) + instance.update_attribute(:"#{name}_#{attr}", value) if responds + end + + + # Reads the attachment-specific attribute on the instance. See instance_write + # for more details. + def instance_read(attr) + getter = :"#{name}_#{attr}" + responds = instance.respond_to?(getter) + cached = self.instance_variable_get("@_#{getter}") + return cached if cached + instance.send(getter) if responds || attr.to_s == "file_name" + end + + private + + def ensure_required_accessors! #:nodoc: + %w(file_name).each do |field| + unless @instance.respond_to?("#{name}_#{field}") && @instance.respond_to?("#{name}_#{field}=") + raise PaperclipError.new("#{@instance.class} model missing required attr_accessor for '#{name}_#{field}'") + end + end + end + + def log message #:nodoc: + Paperclip.log(message) + end + + def valid_assignment? file #:nodoc: + file.nil? || (file.respond_to?(:original_filename) && file.respond_to?(:content_type)) + end + + def validate #:nodoc: + unless @validation_errors + @validation_errors = @validations.inject({}) do |errors, validation| + name, options = validation + errors[name] = send(:"validate_#{name}", options) if allow_validation?(options) + errors + end + @validation_errors.reject!{|k,v| v == nil } + @errors.merge!(@validation_errors) + end + @validation_errors + end + + def allow_validation? options #:nodoc: + (options[:if].nil? || check_guard(options[:if])) && (options[:unless].nil? || !check_guard(options[:unless])) + end + + def check_guard guard #:nodoc: + if guard.respond_to? :call + guard.call(instance) + elsif ! guard.blank? + instance.send(guard.to_s) + end + end + + def validate_size options #:nodoc: + if file? && !options[:range].include?(size.to_i) + options[:message]#.gsub(/:min/, options[:min].to_s).gsub(/:max/, options[:max].to_s) + end + end + + def validate_presence options #:nodoc: + options[:message] unless file? + end + + def validate_content_type options #:nodoc: + valid_types = [options[:content_type]].flatten + unless original_filename.blank? + unless valid_types.blank? + content_type = instance_read(:content_type) + unless valid_types.any?{|t| content_type.nil? || t === content_type } + options[:message] || "is not one of the allowed file types." + end + end + end + end + + def normalize_style_definition #:nodoc: + styles.each do |name, args| + unless args.is_a? Hash + dimensions, format = [args, nil].flatten[0..1] + format = nil if format.blank? + @styles[name] = { + :processors => @processors, + :geometry => dimensions, + :format => format, + :whiny => @whiny, + :convert_options => extra_options_for(name) + } + else + @styles[name] = { + :processors => @processors, + :whiny => @whiny, + :convert_options => extra_options_for(name) + }.merge(@styles[name]) + end + end + end + + def solidify_style_definitions #:nodoc: + @styles.each do |name, args| + @styles[name][:geometry] = @styles[name][:geometry].call(instance) if @styles[name][:geometry].respond_to?(:call) + @styles[name][:processors] = @styles[name][:processors].call(instance) if @styles[name][:processors].respond_to?(:call) + end + end + + def initialize_storage #:nodoc: + @storage_module = Paperclip::Storage.const_get(@storage.to_s.capitalize) + self.extend(@storage_module) + end + + def extra_options_for(style) #:nodoc: + all_options = convert_options[:all] + all_options = all_options.call(instance) if all_options.respond_to?(:call) + style_options = convert_options[style] + style_options = style_options.call(instance) if style_options.respond_to?(:call) + + [ style_options, all_options ].compact.join(" ") + end + + def post_process #:nodoc: + return unless content_type.match(/image/) + return if @queued_for_write[:original].nil? + solidify_style_definitions + + instance.run_paperclip_callbacks(:post_process) do + instance.run_paperclip_callbacks(:"#{name}_post_process") do + post_process_styles + end + end + end + + def post_process_styles #:nodoc: + styles_in_order = @style_order.empty? ? @styles : @styles.sort_by{|s| @style_order.index(s.first)} + styles_in_order.each do |name, args| + begin + raise RuntimeError.new("Style #{name} has no processors defined.") if args[:processors].blank? + @queued_for_write[name] = args[:processors].inject(@queued_for_write[:original]) do |file, processor| + Paperclip.processor(processor).make(file, args, self) + end + rescue PaperclipError => e + log("An error was received while processing: #{e.inspect}") + (@errors[:processing] ||= []) << e.message if @whiny + end + end + end + + def interpolate pattern, style = default_style #:nodoc: + Paperclip::Interpolations.interpolate(pattern, self, style) + end + + def queue_existing_for_delete #:nodoc: + return unless file? + @queued_for_delete += [:original, *@styles.keys].uniq.map do |style| + @storage == :delayeds3 ? filesystem_path(style) : path(style) + end.compact + end + + def flush_errors #:nodoc: + @errors.each do |error, message| + [message].flatten.each {|m| instance.errors.add(name, m) } + end + end + + def flush_jobs + @queued_jobs.try(:each, &:call) + @queued_jobs = [] + end + end + + + class DelayedS3Attachment < Attachment + include Paperclip::Storage::Delayeds3 + extend Paperclip::Storage::Delayeds3::ClassMethods + end +end diff --git a/lib/paperclip/callback_compatability.rb b/lib/paperclip/callback_compatability.rb new file mode 100644 index 0000000..a0d1b66 --- /dev/null +++ b/lib/paperclip/callback_compatability.rb @@ -0,0 +1,62 @@ +module Paperclip + module CallbackCompatability + module Rails21 + def self.included(base) + base.extend(Defining) + base.send(:include, Running) + end + + module Defining + def define_paperclip_callbacks(*args) + args.each do |callback| + define_callbacks("before_#{callback}") + define_callbacks("after_#{callback}") + end + end + end + + module Running + def run_paperclip_callbacks(callback, opts = nil, &blk) + # The overall structure of this isn't ideal since after callbacks run even if + # befores return false. But this is how rails 3's callbacks work, unfortunately. + if run_callbacks(:"before_#{callback}"){ |result, object| result == false } != false + blk.call + end + run_callbacks(:"after_#{callback}"){ |result, object| result == false } + end + end + end + + module Rails3 + def self.included(base) + base.extend(Defining) + base.send(:include, Running) + end + + module Defining + def define_paperclip_callbacks(*callbacks) + define_callbacks *[callbacks, {:terminator => "result == false"}].flatten + callbacks.each do |callback| + eval <<-end_callbacks + def before_#{callback}(*args, &blk) + set_callback(:#{callback}, :before, *args, &blk) + end + def after_#{callback}(*args, &blk) + set_callback(:#{callback}, :after, *args, &blk) + end + end_callbacks + end + end + end + + module Running + def run_paperclip_callbacks(callback, opts = nil, &block) + run_callbacks(callback, opts, &block) + end + end + + end + + end +end + diff --git a/lib/paperclip/geometry.rb b/lib/paperclip/geometry.rb new file mode 100644 index 0000000..96ddd1b --- /dev/null +++ b/lib/paperclip/geometry.rb @@ -0,0 +1,115 @@ +module Paperclip + + # Defines the geometry of an image. + class Geometry + attr_accessor :height, :width, :modifier + + # Gives a Geometry representing the given height and width + def initialize width = nil, height = nil, modifier = nil + @height = height.to_f + @width = width.to_f + @modifier = modifier + end + + # Uses ImageMagick to determing the dimensions of a file, passed in as either a + # File or path. + def self.from_file file + file = file.path if file.respond_to? "path" + geometry = begin + Paperclip.run("identify", %Q[-format "%wx%h" "#{file}"[0]]) + rescue PaperclipCommandLineError => e + "" + end + parse(geometry) || + raise(NotIdentifiedByImageMagickError.new("Формат файла не соответствует его расширению.")) + end + + # Parses a "WxH" formatted string, where W is the width and H is the height. + def self.parse string + if match = (string && string.match(/\b(\d*)x?(\d*)\b([\>\<\#\@\%^!])?/i)) + Geometry.new(*match[1,3]) + end + end + + # True if the dimensions represent a square + def square? + height == width + end + + # True if the dimensions represent a horizontal rectangle + def horizontal? + height < width + end + + # True if the dimensions represent a vertical rectangle + def vertical? + height > width + end + + # The aspect ratio of the dimensions. + def aspect + width / height + end + + # Returns the larger of the two dimensions + def larger + [height, width].max + end + + # Returns the smaller of the two dimensions + def smaller + [height, width].min + end + + # Returns the width and height in a format suitable to be passed to Geometry.parse + def to_s + s = "" + s << width.to_i.to_s if width > 0 + s << "x#{height.to_i}" if height > 0 + s << modifier.to_s + s + end + + # Same as to_s + def inspect + to_s + end + + # Returns the scaling and cropping geometries (in string-based ImageMagick format) + # neccessary to transform this Geometry into the Geometry given. If crop is true, + # then it is assumed the destination Geometry will be the exact final resolution. + # In this case, the source Geometry is scaled so that an image containing the + # destination Geometry would be completely filled by the source image, and any + # overhanging image would be cropped. Useful for square thumbnail images. The cropping + # is weighted at the center of the Geometry. + def transformation_to dst, crop = false + if crop + ratio = Geometry.new( dst.width / self.width, dst.height / self.height ) + scale_geometry, scale = scaling(dst, ratio) + crop_geometry = cropping(dst, ratio, scale) + else + scale_geometry = dst.to_s + end + + [ scale_geometry, crop_geometry ] + end + + private + + def scaling dst, ratio + if ratio.horizontal? || ratio.square? + [ "%dx" % dst.width, ratio.width ] + else + [ "x%d" % dst.height, ratio.height ] + end + end + + def cropping dst, ratio, scale + if ratio.horizontal? || ratio.square? + "%dx%d+%d+%d" % [ dst.width, dst.height, 0, (self.height * scale - dst.height) / 2 ] + else + "%dx%d+%d+%d" % [ dst.width, dst.height, (self.width * scale - dst.width) / 2, 0 ] + end + end + end +end diff --git a/lib/paperclip/interpolations.rb b/lib/paperclip/interpolations.rb new file mode 100644 index 0000000..d1cd3de --- /dev/null +++ b/lib/paperclip/interpolations.rb @@ -0,0 +1,105 @@ +module Paperclip + # This module contains all the methods that are available for interpolation + # in paths and urls. To add your own (or override an existing one), you + # can either open this module and define it, or call the + # Paperclip.interpolates method. + module Interpolations + extend self + + # Hash assignment of interpolations. Included only for compatability, + # and is not intended for normal use. + def self.[]= name, block + define_method(name, &block) + end + + # Hash access of interpolations. Included only for compatability, + # and is not intended for normal use. + def self.[] name + method(name) + end + + # Returns a sorted list of all interpolations. + def self.all + self.instance_methods(false).sort + end + + # Perform the actual interpolation. Takes the pattern to interpolate + # and the arguments to pass, which are the attachment and style name. + def self.interpolate pattern, *args + all.reverse.inject( pattern.dup ) do |result, tag| + result.gsub(/:#{tag}/) do |match| + send( tag, *args ) + end + end + end + + # Returns the filename, the same way as ":basename.:extension" would. + def filename attachment, style + "#{basename(attachment, style)}.#{extension(attachment, style)}" + end + + # Returns the interpolated URL. Will raise an error if the url itself + # contains ":url" to prevent infinite recursion. This interpolation + # is used in the default :path to ease default specifications. + def url attachment, style + raise InfiniteInterpolationError if attachment.options[:url].include?(":url") + attachment.url(style, false) + end + + # Returns the timestamp as defined by the _updated_at field + def timestamp attachment, style + attachment.instance_read(:updated_at).to_s + end + + # Returns the Rails.root constant. + def rails_root attachment, style + Rails.root + end + + # Returns the Rails.env constant. + def rails_env attachment, style + Rails.env + end + + # Returns the underscored, pluralized version of the class name. + # e.g. "users" for the User class. + def class attachment, style + attachment.instance.class.to_s.underscore.pluralize + end + + # Returns the basename of the file. e.g. "file" for "file.jpg" + def basename attachment, style + attachment.original_filename.gsub(/#{File.extname(attachment.original_filename)}$/, "") + end + + # Returns the extension of the file. e.g. "jpg" for "file.jpg" + # If the style has a format defined, it will return the format instead + # of the actual extension. + def extension attachment, style + ((style = attachment.styles[style]) && style[:format]) || + File.extname(attachment.original_filename).gsub(/^\.+/, "") + end + + # Returns the id of the instance. + def id attachment, style + attachment.instance.id + end + + # Returns the id of the instance in a split path form. e.g. returns + # 000/001/234 for an id of 1234. + def id_partition attachment, style + ("%09d" % attachment.instance.id).scan(/\d{3}/).join("/") + end + + # Returns the pluralized form of the attachment name. e.g. + # "avatars" for an attachment of :avatar + def attachment attachment, style + attachment.name.to_s.downcase.pluralize + end + + # Returns the style, or the default style if nil is supplied. + def style attachment, style + style || attachment.default_style + end + end +end diff --git a/lib/paperclip/iostream.rb b/lib/paperclip/iostream.rb new file mode 100644 index 0000000..806d122 --- /dev/null +++ b/lib/paperclip/iostream.rb @@ -0,0 +1,45 @@ +# Provides method that can be included on File-type objects (IO, StringIO, Tempfile, etc) to allow stream copying +# and Tempfile conversion. +module IOStream + # Returns a Tempfile containing the contents of the readable object. + def to_tempfile(object) + return object.to_tempfile if object.respond_to?(:to_tempfile) + name = object.respond_to?(:original_filename) ? object.original_filename : (object.respond_to?(:path) ? object.path : "stream") + tempfile = Tempfile.new(["stream", File.extname(name)]) + tempfile.binmode + stream_to(object, tempfile) + end + + # Copies one read-able object from one place to another in blocks, obviating the need to load + # the whole thing into memory. Defaults to 8k blocks. Returns a File if a String is passed + # in as the destination and returns the IO or Tempfile as passed in if one is sent as the destination. + def stream_to object, path_or_file, in_blocks_of = 8192 + dstio = case path_or_file + when String then File.new(path_or_file, "wb+") + when IO then path_or_file + when Tempfile then path_or_file + end + buffer = "" + object.rewind + while object.read(in_blocks_of, buffer) do + dstio.write(buffer) + end + dstio.rewind + dstio + end +end + +# Corrects a bug in Windows when asking for Tempfile size. +if defined?(Tempfile) && RUBY_PLATFORM !~ /java/ + class Tempfile + def size + if @tmpfile + @tmpfile.fsync + @tmpfile.flush + @tmpfile.stat.size + else + 0 + end + end + end +end diff --git a/lib/paperclip/matchers.rb b/lib/paperclip/matchers.rb new file mode 100644 index 0000000..ca24b5e --- /dev/null +++ b/lib/paperclip/matchers.rb @@ -0,0 +1,4 @@ +require 'paperclip/matchers/have_attached_file_matcher' +require 'paperclip/matchers/validate_attachment_presence_matcher' +require 'paperclip/matchers/validate_attachment_content_type_matcher' +require 'paperclip/matchers/validate_attachment_size_matcher' diff --git a/lib/paperclip/matchers/have_attached_file_matcher.rb b/lib/paperclip/matchers/have_attached_file_matcher.rb new file mode 100644 index 0000000..da0dd8b --- /dev/null +++ b/lib/paperclip/matchers/have_attached_file_matcher.rb @@ -0,0 +1,49 @@ +module Paperclip + module Shoulda + module Matchers + def have_attached_file name + HaveAttachedFileMatcher.new(name) + end + + class HaveAttachedFileMatcher + def initialize attachment_name + @attachment_name = attachment_name + end + + def matches? subject + @subject = subject + responds? && has_column? && included? + end + + def failure_message + "Should have an attachment named #{@attachment_name}" + end + + def negative_failure_message + "Should not have an attachment named #{@attachment_name}" + end + + def description + "have an attachment named #{@attachment_name}" + end + + protected + + def responds? + methods = @subject.instance_methods + methods.include?("#{@attachment_name}") && + methods.include?("#{@attachment_name}=") && + methods.include?("#{@attachment_name}?") + end + + def has_column? + @subject.column_names.include?("#{@attachment_name}_file_name") + end + + def included? + @subject.ancestors.include?(Paperclip::InstanceMethods) + end + end + end + end +end diff --git a/lib/paperclip/matchers/validate_attachment_content_type_matcher.rb b/lib/paperclip/matchers/validate_attachment_content_type_matcher.rb new file mode 100644 index 0000000..b379eff --- /dev/null +++ b/lib/paperclip/matchers/validate_attachment_content_type_matcher.rb @@ -0,0 +1,66 @@ +module Paperclip + module Shoulda + module Matchers + def validate_attachment_content_type name + ValidateAttachmentContentTypeMatcher.new(name) + end + + class ValidateAttachmentContentTypeMatcher + def initialize attachment_name + @attachment_name = attachment_name + end + + def allowing *types + @allowed_types = types.flatten + self + end + + def rejecting *types + @rejected_types = types.flatten + self + end + + def matches? subject + @subject = subject + @allowed_types && @rejected_types && + allowed_types_allowed? && rejected_types_rejected? + end + + def failure_message + "Content types #{@allowed_types.join(", ")} should be accepted" + + " and #{@rejected_types.join(", ")} rejected by #{@attachment_name}" + end + + def negative_failure_message + "Content types #{@allowed_types.join(", ")} should be rejected" + + " and #{@rejected_types.join(", ")} accepted by #{@attachment_name}" + end + + def description + "validate the content types allowed on attachment #{@attachment_name}" + end + + protected + + def allow_types?(types) + types.all? do |type| + file = StringIO.new(".") + file.content_type = type + attachment = @subject.new.attachment_for(@attachment_name) + attachment.assign(file) + attachment.errors[:content_type].nil? + end + end + + def allowed_types_allowed? + allow_types?(@allowed_types) + end + + def rejected_types_rejected? + not allow_types?(@rejected_types) + end + end + end + end +end + diff --git a/lib/paperclip/matchers/validate_attachment_presence_matcher.rb b/lib/paperclip/matchers/validate_attachment_presence_matcher.rb new file mode 100644 index 0000000..61dc0ea --- /dev/null +++ b/lib/paperclip/matchers/validate_attachment_presence_matcher.rb @@ -0,0 +1,48 @@ +module Paperclip + module Shoulda + module Matchers + def validate_attachment_presence name + ValidateAttachmentPresenceMatcher.new(name) + end + + class ValidateAttachmentPresenceMatcher + def initialize attachment_name + @attachment_name = attachment_name + end + + def matches? subject + @subject = subject + error_when_not_valid? && no_error_when_valid? + end + + def failure_message + "Attachment #{@attachment_name} should be required" + end + + def negative_failure_message + "Attachment #{@attachment_name} should not be required" + end + + def description + "require presence of attachment #{@attachment_name}" + end + + protected + + def error_when_not_valid? + @attachment = @subject.new.send(@attachment_name) + @attachment.assign(nil) + not @attachment.errors[:presence].nil? + end + + def no_error_when_valid? + @file = StringIO.new(".") + @attachment = @subject.new.send(@attachment_name) + @attachment.assign(@file) + @attachment.errors[:presence].nil? + end + end + end + end +end + diff --git a/lib/paperclip/matchers/validate_attachment_size_matcher.rb b/lib/paperclip/matchers/validate_attachment_size_matcher.rb new file mode 100644 index 0000000..f84c479 --- /dev/null +++ b/lib/paperclip/matchers/validate_attachment_size_matcher.rb @@ -0,0 +1,83 @@ +module Paperclip + module Shoulda + module Matchers + def validate_attachment_size name + ValidateAttachmentSizeMatcher.new(name) + end + + class ValidateAttachmentSizeMatcher + def initialize attachment_name + @attachment_name = attachment_name + @low, @high = 0, (1.0/0) + end + + def less_than size + @high = size + self + end + + def greater_than size + @low = size + self + end + + def in range + @low, @high = range.first, range.last + self + end + + def matches? subject + @subject = subject + lower_than_low? && higher_than_low? && lower_than_high? && higher_than_high? + end + + def failure_message + "Attachment #{@attachment_name} must be between #{@low} and #{@high} bytes" + end + + def negative_failure_message + "Attachment #{@attachment_name} cannot be between #{@low} and #{@high} bytes" + end + + def description + "validate the size of attachment #{@attachment_name}" + end + + protected + + def override_method object, method, &replacement + (class << object; self; end).class_eval do + define_method(method, &replacement) + end + end + + def passes_validation_with_size(new_size) + file = StringIO.new(".") + override_method(file, :size){ new_size } + attachment = @subject.new.attachment_for(@attachment_name) + attachment.assign(file) + attachment.errors[:size].nil? + end + + def lower_than_low? + not passes_validation_with_size(@low - 1) + end + + def higher_than_low? + passes_validation_with_size(@low + 1) + end + + def lower_than_high? + return true if @high == (1.0/0) + passes_validation_with_size(@high - 1) + end + + def higher_than_high? + return true if @high == (1.0/0) + not passes_validation_with_size(@high + 1) + end + end + end + end +end + diff --git a/lib/paperclip/optimizer.rb b/lib/paperclip/optimizer.rb new file mode 100644 index 0000000..82e996b --- /dev/null +++ b/lib/paperclip/optimizer.rb @@ -0,0 +1,44 @@ +require 'open3' + +module Paperclip + class Optimizer < Processor + def make + optimized_file_path = optimize(@file) + if optimized_file_path && File.exists?(optimized_file_path) + return File.open(optimized_file_path) + else + return @file + end + end + + def real_content_type + out = Paperclip.run "file", "--mime-type #{@file.path.shellescape}" + out.split(/:\s+/)[1].gsub("\n", "") + end + + def optimize(file) + src = @file.path + dst = "#{src}-#{SecureRandom.hex}" + src_shell = src.shellescape + dst_shell = dst.shellescape + cmd = case real_content_type + when 'image/jpeg', 'image/jpg', 'image/pjpeg' + "cp #{src_shell} #{dst_shell} && jpegoptim --all-progressive -q --strip-com --strip-exif --strip-iptc -- #{dst_shell}" + when 'image/png', 'image/x-png' + "pngcrush -rem alla -q #{src_shell} #{dst_shell}" + when 'image/gif' + "gifsicle -o #{dst_shell} -O3 --no-comments --no-names --same-delay --same-loopcount --no-warnings -- #{src_shell}" + else + return + end + run_and_verify!(cmd) + dst + end + + private + def run_and_verify!(cmd) + # Checking stdout and stderr because pngcrush always has exit code of zero + Open3.capture3(cmd) + end + end +end diff --git a/lib/paperclip/processor.rb b/lib/paperclip/processor.rb new file mode 100644 index 0000000..da3e213 --- /dev/null +++ b/lib/paperclip/processor.rb @@ -0,0 +1,49 @@ +module Paperclip + # Paperclip processors allow you to modify attached files when they are + # attached in any way you are able. Paperclip itself uses command-line + # programs for its included Thumbnail processor, but custom processors + # are not required to follow suit. + # + # Processors are required to be defined inside the Paperclip module and + # are also required to be a subclass of Paperclip::Processor. There is + # only one method you *must* implement to properly be a subclass: + # #make, but #initialize may also be of use. Both methods accept 3 + # arguments: the file that will be operated on (which is an instance of + # File), a hash of options that were defined in has_attached_file's + # style hash, and the Paperclip::Attachment itself. + # + # All #make needs to return is an instance of File (Tempfile is + # acceptable) which contains the results of the processing. + # + # See Paperclip.run for more information about using command-line + # utilities from within Processors. + class Processor + attr_accessor :file, :options, :attachment + + def initialize file, options = {}, attachment = nil + @file = file + @options = options + @attachment = attachment + end + + def make + end + + def self.make file, options = {}, attachment = nil + new(file, options, attachment).make + end + end + + # Due to how ImageMagick handles its image format conversion and how Tempfile + # handles its naming scheme, it is necessary to override how Tempfile makes + # its names so as to allow for file extensions. Idea taken from the comments + # on this blog post: + # http://marsorange.com/archives/of-mogrify-ruby-tempfile-dynamic-class-definitions + class Tempfile < ::Tempfile + # Replaces Tempfile's +make_tmpname+ with one that honors file extensions. + def make_tmpname(basename, n) + extension = File.extname(basename) + sprintf("%s,%d,%d%s", File.basename(basename, extension), $$, n.to_i, extension) + end + end +end diff --git a/lib/paperclip/recursive_thumbnail.rb b/lib/paperclip/recursive_thumbnail.rb new file mode 100644 index 0000000..517553f --- /dev/null +++ b/lib/paperclip/recursive_thumbnail.rb @@ -0,0 +1,12 @@ +module Paperclip + class RecursiveThumbnail < Thumbnail + def initialize file, options = {}, attachment = nil + + # если по каким-то причинам не сформировался файл + # для прыдущего размера не кидаем ексепшен и + # генерим файл из оригинального + f = attachment.to_file(options[:thumbnail] || :original) rescue file + super f, options, attachment + end + end +end diff --git a/lib/paperclip/storage.rb b/lib/paperclip/storage.rb new file mode 100644 index 0000000..2b8f5d0 --- /dev/null +++ b/lib/paperclip/storage.rb @@ -0,0 +1,346 @@ +module Paperclip + module Storage + + # The default place to store attachments is in the filesystem. Files on the local + # filesystem can be very easily served by Apache without requiring a hit to your app. + # They also can be processed more easily after they've been saved, as they're just + # normal files. There is one Filesystem-specific option for has_attached_file. + # * +path+: The location of the repository of attachments on disk. This can (and, in + # almost all cases, should) be coordinated with the value of the +url+ option to + # allow files to be saved into a place where Apache can serve them without + # hitting your app. Defaults to + # ":rails_root/public/:attachment/:id/:style/:basename.:extension" + # By default this places the files in the app's public directory which can be served + # directly. If you are using capistrano for deployment, a good idea would be to + # make a symlink to the capistrano-created system directory from inside your app's + # public directory. + # See Paperclip::Attachment#interpolate for more information on variable interpolaton. + # :path => "/var/app/attachments/:class/:id/:style/:basename.:extension" + module Filesystem + def self.extended base + end + + def exists?(style = default_style) + if original_filename + File.exist?(path(style)) + else + false + end + end + + # Returns representation of the data of the file assigned to the given + # style, in the format most representative of the current storage. + def to_file style = default_style + @queued_for_write[style] || (File.new(path(style), 'rb') if exists?(style)) + end + alias_method :to_io, :to_file + + def flush_writes #:nodoc: + @queued_for_write.each do |style, file| + file.close + FileUtils.mkdir_p(File.dirname(path(style))) + log("saving #{path(style)}") + FileUtils.mv(file.path, path(style)) + FileUtils.chmod(0644, path(style)) + end + @queued_for_write = {} + end + + def flush_deletes #:nodoc: + @queued_for_delete.each do |path| + begin + log("deleting #{path}") + FileUtils.rm(path) + rescue Errno::ENOENT => e + # ignore file-not-found, let everything else pass + end + begin + while(true) + path = File.dirname(path) + if Dir.entries(path).empty? + FileUtils.rmdir(path) + else + break + end + end + rescue Errno::EEXIST, Errno::ENOTEMPTY, Errno::ENOENT, Errno::EINVAL, Errno::ENOTDIR + # Stop trying to remove parent directories + rescue SystemCallError => e + log("There was an unexpected error while deleting directories: #{e.class}") + # Ignore it + end + end + @queued_for_delete = [] + end + end + + # Need to create boolean field synced_to_s3 + module Delayeds3 + # require 'aws-sdk' + module ClassMethods + def parse_credentials creds + return @parsed_credentials if @parsed_credentials + creds = find_credentials(creds).stringify_keys + @parsed_credentials ||= (creds[Rails.env] || creds).symbolize_keys + end + + def find_credentials creds + case creds + when File + YAML.load_file(creds.path) + when String + YAML.load_file(creds) + when Hash + creds + else + raise ArgumentError, "Credentials are not a path, file, or hash." + end + end + end + + class WriteToS3Job < Struct.new(:class_name, :name, :id) + def perform + WriteToS3Worker.new.perform(class_name, name, id) + end + end + + class UploadWorker + include ::Sidekiq::Worker + sidekiq_options queue: ::IMAGE_UPLOAD_QUEUE + + def perform(class_name, name, id) + file = class_name.constantize.find_by_id(id) + return unless file + attachment = file.send(name) + write(attachment) + attachment.delete_local_files! + rescue Errno::ESTALE + raise if attachment && file.class.exists?(file) + rescue Errno::ENOENT => e + raise if attachment && file.class.exists?(file) + Rollbar.warn(e, file_name: extract_file_name_from_error(e)) + end + + def extract_file_name_from_error(err) + err.message.split(' - ')[-1] + end + end + + class WriteToS3Worker < UploadWorker + def write(attachment) + attachment.write_to_s3 + end + end + + class WriteToFogWorker < UploadWorker + def write(attachment) + attachment.write_to_fog + rescue Excon::Errors::SocketError => e + raise e.socket_error if e.socket_error.is_a?(Errno::ENOENT) || e.socket_error.is_a?(Errno::ESTALE) + raise + end + end + + def initialize_storage + @s3_credentials = self.class.parse_credentials(@options[:s3_credentials]) + @bucket = @options[:bucket] || @s3_credentials[:bucket] + @bucket = @bucket.call(self) if @bucket.is_a?(Proc) + @s3_options = @options[:s3_options] || {} + @s3_permissions = @options[:s3_permissions] || 'public-read' + @s3_protocol = @options[:s3_protocol] || (@s3_permissions == 'public-read' ? 'http' : 'https') + @s3_headers = @options[:s3_headers] || {} + @s3_host_alias = @options[:s3_host_alias] + @job_priority = @options[:job_priority] + + @fog_provider = @options[:fog_provider] + @fog_directory = @options[:fog_directory] + @fog_credentials = @options[:fog_credentials] + + @s3_url = ":s3_path_url" unless @s3_url.to_s.match(/^:s3.*url$/) + Paperclip.interpolates(:s3_alias_url) do |attachment, style| + ":cdn_protocol://:cdn_domain/#{attachment.path(style).gsub(%r{^/}, "")}" + end + end + + def aws_bucket + return @aws_bucket if @aws_bucket + + s3_client = Aws::S3::Client.new( + region: @s3_credentials[:region] || 'us-east-1', + access_key_id: @s3_credentials[:access_key_id], + secret_access_key: @s3_credentials[:secret_access_key] + ) + + s3_resource = Aws::S3::Resource.new(client: s3_client) + @aws_bucket = s3_resource.bucket(bucket_name) + end + + def bucket_name + @bucket + end + + def s3_host_alias + @s3_host_alias + end + + def to_file style = default_style + @queued_for_write[style] || (File.new(filesystem_path(style), 'rb') if exists?(style)) || download_file(style) + end + + def download_file(style = default_style) + return unless instance_read(:synced_to_s3) + temp_file = Tempfile.new(['product_image', File.extname(filesystem_path(style))], encoding: 'ascii-8bit') + uri = URI(URI.encode(url(style))) + Net::HTTP.start(uri.host, uri.port) do |http| + req = Net::HTTP::Get.new uri + http.request(req) do |response| + if response.is_a?(Net::HTTPOK) + response.read_body{|chunk| temp_file.write(chunk)} + else + return nil + end + end + end + temp_file.flush + temp_file.rewind + temp_file + end + + alias_method :to_io, :to_file + + def parse_credentials creds + creds = find_credentials(creds).stringify_keys + (creds[Rails.env] || creds).symbolize_keys + end + + def s3_protocol + @s3_protocol + end + + def exists?(style = default_style) + File.exist?(filesystem_path(style)) + end + + def s3_path style + interpolate(options[:s3_path], style) + end + + def filesystem_paths + h = {} + [:original, *@styles.keys].uniq.map do |style| + h[style] = filesystem_path(style) if File.exists?(filesystem_path(style)) + end + h + end + + def write_to_s3 + return true if instance_read(:synced_to_s3) + filesystem_paths.each do |style, file| + log("saving to s3 #{file}") + s3_object = aws_bucket.object(s3_path(style).gsub(/^\/+/,'')) + s3_object.upload_file(file, + cache_control: "max-age=#{10.year.to_i}", + content_type: instance_read(:content_type), + expires: 10.year.from_now.httpdate, + acl: 'public-read') + end + instance.send("#{name}_synced_to_s3=", true) + instance.save + end + + def fog_storage + @fog_storage ||= Fog::Storage.new(@fog_credentials.merge(provider: @fog_provider).symbolize_keys) + end + + def write_to_fog + return unless instance.respond_to? "#{name}_synced_to_fog" + return true if instance_read(:synced_to_fog) + paths = filesystem_paths + raise RuntimeError.new("Local files not found for Image:#{instance_read(:id)}") if paths.length < styles.length # To make monitoring easier + paths.each do |style, file| + path = s3_path(style) + path = path[1..-1] if path.start_with?('/') + log "Saving to Fog with key #{path}" + options = { + "Content-Type" => instance_read(:content_type), + "Cache-Control" => "max-age=#{10.year.to_i}", + "x-goog-acl" => "public-read" + } + + File.open(file, 'r') do |f| + fog_storage.put_object @fog_directory, path, f, options + end + end + # не вызываем колбеки и спокойно себя ведем если объект удален + instance.send("#{name}_synced_to_fog=", true) + instance.save + end + + + def flush_writes #:nodoc: + @queued_for_write.each do |style, file| + file.close + FileUtils.mkdir_p(File.dirname(filesystem_path(style))) + log("saving to filesystem #{filesystem_path(style)}") + FileUtils.mv(file.path, filesystem_path(style)) + FileUtils.chmod(0644, filesystem_path(style)) + end + + unless @queued_for_write.empty? || (delay_processing? && @was_dirty) + instance.update_column("#{name}_synced_to_s3", false) if instance_read(:synced_to_s3) + @queued_jobs.push -> { + WriteToS3Worker.perform_async(instance.class.to_s, @name, instance.id) + WriteToFogWorker.perform_async(instance.class.to_s, @name, instance.id) + } + end + @queued_for_write = {} + end + + # Deletes a file and all parent directories if they are empty + def delete_recursive(path) + initial_path = path + begin + FileUtils.rm(path) if File.exist?(path) + rescue Errno::ENOENT, Errno::ESTALE => e + end + begin + while(true) + path = File.dirname(path) + FileUtils.rmdir(path) + break if File.exists?(path) # Ruby 1.9.2 does not raise if the removal failed. + end + rescue Errno::EEXIST, Errno::ENOTEMPTY, Errno::ENOENT, Errno::EINVAL, Errno::ENOTDIR, Errno::ESTALE => e + rescue SystemCallError => e + Rollbar.error(e, {path: path, initial_path: initial_path}) + end + end + + def flush_deletes #:nodoc: + # если мы картинку заливали в облака, значит мы скорее всего ее уже удалили + # и можно не нагружать хранилище проверками + if !instance.is_a?(AccountFile) && instance_read(:synced_to_fog) && + instance_read(:synced_to_s3) + @queued_for_delete = [] + return + end + + @queued_for_delete.each do |path| + log("Deleting local file #{path}") + delete_recursive(path) + end + @queued_for_delete = [] + end + + def delete_local_files! + return if instance.is_a?(AccountFile) + instance.reload + if instance_read(:synced_to_fog) && instance_read(:synced_to_s3) + filesystem_paths.values.each do |filename| + log("Deleting local file #{filename}") + delete_recursive(filename) + end + end + end + end + end +end diff --git a/lib/paperclip/thumbnail.rb b/lib/paperclip/thumbnail.rb new file mode 100644 index 0000000..a601b1a --- /dev/null +++ b/lib/paperclip/thumbnail.rb @@ -0,0 +1,91 @@ +module Paperclip + # Handles thumbnailing images that are uploaded. + class Thumbnail < Processor + + attr_accessor :current_geometry, :target_geometry, :format, :whiny, :convert_options + + # Creates a Thumbnail object set to work on the +file+ given. It + # will attempt to transform the image into one defined by +target_geometry+ + # which is a "WxH"-style string. +format+ will be inferred from the +file+ + # unless specified. Thumbnail creation will raise no errors unless + # +whiny+ is true (which it is, by default. If +convert_options+ is + # set, the options will be appended to the convert command upon image conversion + def initialize file, options = {}, attachment = nil + super + geometry = options[:geometry] + @file = file + @crop = geometry[-1,1] == '#' + @target_geometry = Geometry.parse geometry + @current_geometry = Geometry.from_file @file + @convert_options = options[:convert_options] + @whiny = options[:whiny].nil? ? true : options[:whiny] + @format = options[:format] + + @current_format = File.extname(@file.path) + @basename = File.basename(@file.path, @current_format) + end + + # Returns true if the +target_geometry+ is meant to crop. + def crop? + @crop + end + + # Returns true if the image is meant to make use of additional convert options. + def convert_options? + not @convert_options.blank? + end + + # Performs the conversion of the +file+ into a thumbnail. Returns the Tempfile + # that contains the new image. + def make + src = @file + dst = Tempfile.new([@basename, @format].compact.join(".")) + dst.binmode + + command = <<-end_command + "#{ File.expand_path(src.path) }[0]" + #{ transformation_command } + #{ gamma_correction_if_needed } + "#{ File.expand_path(dst.path) }" + end_command + + begin + success = Paperclip.run("convert", command.gsub(/\s+/, " ")) + rescue PaperclipCommandLineError + raise PaperclipError, "There was an error processing the thumbnail for #{@basename}" if @whiny + end + + dst + end + + # Returns the command ImageMagick's +convert+ needs to transform the image + # into the thumbnail. + def transformation_command + scale, crop = @current_geometry.transformation_to(@target_geometry, crop?) + trans = "-resize \"#{scale}\"" + trans << " -crop \"#{crop}\" +repage" if crop + trans << " #{convert_options}" if convert_options? + trans + end + + def gamma_correction_if_needed + command = <<-end_command + -format "%[magick]\\n%[type]" + "#{ File.expand_path(@file.path) }[0]" + end_command + + begin + result = Paperclip.run("identify", command.gsub(/\s+/, ' ')) + rescue PaperclipCommandLineError + raise PaperclipError, "There was an error processing the thumbnail for #{@basename}" if @whiny + end + + magick, type = result.split("\n") + if magick == 'PNG' + '-define png:big-depth=16 -define png:color-type=6' + else + '' + end + end + end +end diff --git a/lib/paperclip/upfile.rb b/lib/paperclip/upfile.rb new file mode 100644 index 0000000..7fa7967 --- /dev/null +++ b/lib/paperclip/upfile.rb @@ -0,0 +1,64 @@ +module Paperclip + # 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 + Paperclip::Upfile.content_type self.path + end + + def self.content_type path + type = (path.match(/\.(\w+)$/)[1] rescue "octet-stream").downcase + case type + when %r"jpe?g" then "image/jpeg" + when %r"tiff?" then "image/tiff" + when %r"png", "gif", "bmp" then "image/#{type}" + when "txt" then "text/plain" + when %r"html?" then "text/html" + when "csv", "xml", "css", "js" then "text/#{type}" + when "liquid" then "text/x-liquid" + else "application/x-#{type}" + end + end + + # Returns the file's normal name. + def original_filename + File.basename(self.path) + end + + # Returns the size of the file. + def size + File.size(self) + end + end +end + +if defined? StringIO + class StringIO + attr_accessor :original_filename, :content_type + + def original_filename + @original_filename ||= "stringio.txt" + end + + def content_type + @content_type ||= Paperclip::Upfile.content_type original_filename + end + end +end + +class FastUploadFile < File + attr_accessor :original_filename, :content_type + + def initialize(uploaded_file) + @original_filename = uploaded_file['original_name'] + @content_type = uploaded_file['content_type'] + super uploaded_file['filepath'] + end +end + +class File #:nodoc: + include Paperclip::Upfile +end + diff --git a/lib/tasks/paperclip_tasks.rake b/lib/tasks/paperclip_tasks.rake new file mode 100644 index 0000000..8efc571 --- /dev/null +++ b/lib/tasks/paperclip_tasks.rake @@ -0,0 +1,79 @@ +def obtain_class + class_name = ENV['CLASS'] || ENV['class'] + raise "Must specify CLASS" unless class_name + @klass = Object.const_get(class_name) +end + +def obtain_attachments + name = ENV['ATTACHMENT'] || ENV['attachment'] + raise "Class #{@klass.name} has no attachments specified" unless @klass.respond_to?(:attachment_definitions) + if !name.blank? && @klass.attachment_definitions.keys.include?(name) + [ name ] + else + @klass.attachment_definitions.keys + end +end + +def for_all_attachments + klass = obtain_class + names = obtain_attachments + ids = klass.connection.select_values(klass.send(:construct_finder_sql, :select => 'id')) + + ids.each do |id| + instance = klass.find(id) + names.each do |name| + result = if instance.send("#{ name }?") + yield(instance, name) + else + true + end + print result ? "." : "x"; $stdout.flush + end + end + puts " Done." +end + +namespace :paperclip do + desc "Refreshes both metadata and thumbnails." + task :refresh => ["paperclip:refresh:metadata", "paperclip:refresh:thumbnails"] + + namespace :refresh do + desc "Regenerates thumbnails for a given CLASS (and optional ATTACHMENT)." + task :thumbnails => :environment do + errors = [] + for_all_attachments do |instance, name| + result = instance.send(name).reprocess! + errors << [instance.id, instance.errors] unless instance.errors.blank? + result + end + errors.each{|e| puts "#{e.first}: #{e.last.full_messages.inspect}" } + end + + desc "Regenerates content_type/size metadata for a given CLASS (and optional ATTACHMENT)." + task :metadata => :environment do + for_all_attachments do |instance, name| + if file = instance.send(name).to_file + instance.send("#{name}_file_name=", instance.send("#{name}_file_name").strip) + instance.send("#{name}_content_type=", file.content_type.strip) + instance.send("#{name}_file_size=", file.size) if instance.respond_to?("#{name}_file_size") + instance.save(:validate => false) + else + true + end + end + end + end + + desc "Cleans out invalid attachments. Useful after you've added new validations." + task :clean => :environment do + for_all_attachments do |instance, name| + instance.send(name).send(:validate) + if instance.send(name).valid? + true + else + instance.send("#{name}=", nil) + instance.save + end + end + end +end diff --git a/paperclip.gemspec b/paperclip.gemspec new file mode 100644 index 0000000..398a5c6 --- /dev/null +++ b/paperclip.gemspec @@ -0,0 +1,36 @@ + +Gem::Specification.new do |s| + s.name = %q{paperclip} + s.version = "2.2.9.2" + + s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version= + s.authors = ["Jon Yurek"] + s.date = %q{2009-06-18} + s.email = %q{jyurek@thoughtbot.com} + s.extra_rdoc_files = ["README.rdoc"] + s.files = ["README.rdoc", "LICENSE", "Rakefile", "init.rb", "generators/paperclip", "generators/paperclip/paperclip_generator.rb", "generators/paperclip/templates", "generators/paperclip/templates/paperclip_migration.rb.erb", "generators/paperclip/USAGE", "lib/paperclip", "lib/paperclip/attachment.rb", "lib/paperclip/callback_compatability.rb", "lib/paperclip/geometry.rb", "lib/paperclip/interpolations.rb", "lib/paperclip/iostream.rb", "lib/paperclip/matchers", "lib/paperclip/matchers/have_attached_file_matcher.rb", "lib/paperclip/matchers/validate_attachment_content_type_matcher.rb", "lib/paperclip/matchers/validate_attachment_presence_matcher.rb", "lib/paperclip/matchers/validate_attachment_size_matcher.rb", "lib/paperclip/matchers.rb", "lib/paperclip/processor.rb", "lib/paperclip/storage.rb", "lib/paperclip/thumbnail.rb", "lib/paperclip/upfile.rb", "lib/paperclip.rb", "tasks/paperclip_tasks.rake", "test/attachment_test.rb", "test/database.yml", "test/fixtures", "test/fixtures/12k.png", "test/fixtures/50x50.png", "test/fixtures/5k.png", "test/fixtures/bad.png", "test/fixtures/s3.yml", "test/fixtures/text.txt", "test/fixtures/twopage.pdf", "test/geometry_test.rb", "test/helper.rb", "test/integration_test.rb", "test/interpolations_test.rb", "test/iostream_test.rb", "test/matchers", "test/matchers/have_attached_file_matcher_test.rb", "test/matchers/validate_attachment_content_type_matcher_test.rb", "test/matchers/validate_attachment_presence_matcher_test.rb", "test/matchers/validate_attachment_size_matcher_test.rb", "test/paperclip_test.rb", "test/processor_test.rb", "test/storage_test.rb", "test/thumbnail_test.rb", "shoulda_macros/paperclip.rb"] + s.has_rdoc = true + s.homepage = %q{http://www.thoughtbot.com/projects/paperclip} + s.rdoc_options = ["--line-numbers", "--inline-source"] + s.require_paths = ["lib"] + s.requirements = ["ImageMagick"] + s.rubyforge_project = %q{paperclip} + s.rubygems_version = %q{1.3.1} + s.summary = %q{File attachments as attributes for ActiveRecord} + + if s.respond_to? :specification_version then + current_version = Gem::Specification::CURRENT_SPECIFICATION_VERSION + s.specification_version = 2 + + if Gem::Version.new(Gem::RubyGemsVersion) >= Gem::Version.new('1.2.0') then + s.add_development_dependency(%q, [">= 0"]) + s.add_development_dependency(%q, [">= 0"]) + else + s.add_dependency(%q, [">= 0"]) + s.add_dependency(%q, [">= 0"]) + end + else + s.add_dependency(%q, [">= 0"]) + s.add_dependency(%q, [">= 0"]) + end +end diff --git a/shoulda_macros/paperclip.rb b/shoulda_macros/paperclip.rb new file mode 100644 index 0000000..e433727 --- /dev/null +++ b/shoulda_macros/paperclip.rb @@ -0,0 +1,68 @@ +require 'paperclip/matchers' + +module Paperclip + # =Paperclip Shoulda Macros + # + # These macros are intended for use with shoulda, and will be included into + # your tests automatically. All of the macros use the standard shoulda + # assumption that the name of the test is based on the name of the model + # you're testing (that is, UserTest is the test for the User model), and + # will load that class for testing purposes. + module Shoulda + include Matchers + # This will test whether you have defined your attachment correctly by + # checking for all the required fields exist after the definition of the + # attachment. + def should_have_attached_file name + klass = self.name.gsub(/Test$/, '').constantize + matcher = have_attached_file name + should matcher.description do + assert_accepts(matcher, klass) + end + end + + # Tests for validations on the presence of the attachment. + def should_validate_attachment_presence name + klass = self.name.gsub(/Test$/, '').constantize + matcher = validate_attachment_presence name + should matcher.description do + assert_accepts(matcher, klass) + end + end + + # Tests that you have content_type validations specified. There are two + # options, :valid and :invalid. Both accept an array of strings. The + # strings should be a list of content types which will pass and fail + # validation, respectively. + def should_validate_attachment_content_type name, options = {} + klass = self.name.gsub(/Test$/, '').constantize + valid = [options[:valid]].flatten + invalid = [options[:invalid]].flatten + matcher = validate_attachment_content_type(name).allowing(valid).rejecting(invalid) + should matcher.description do + assert_accepts(matcher, klass) + end + end + + # Tests to ensure that you have file size validations turned on. You + # can pass the same options to this that you can to + # validate_attachment_file_size - :less_than, :greater_than, and :in. + # :less_than checks that a file is less than a certain size, :greater_than + # checks that a file is more than a certain size, and :in takes a Range or + # Array which specifies the lower and upper limits of the file size. + def should_validate_attachment_size name, options = {} + klass = self.name.gsub(/Test$/, '').constantize + min = options[:greater_than] || (options[:in] && options[:in].first) || 0 + max = options[:less_than] || (options[:in] && options[:in].last) || (1.0/0) + range = (min..max) + matcher = validate_attachment_size(name).in(range) + should matcher.description do + assert_accepts(matcher, klass) + end + end + end +end + +class Test::Unit::TestCase #:nodoc: + extend Paperclip::Shoulda +end diff --git a/test/.gitignore b/test/.gitignore new file mode 100644 index 0000000..b14c548 --- /dev/null +++ b/test/.gitignore @@ -0,0 +1 @@ +debug.log diff --git a/test/attachment_test.rb b/test/attachment_test.rb new file mode 100644 index 0000000..1c13f6f --- /dev/null +++ b/test/attachment_test.rb @@ -0,0 +1,768 @@ +require 'test/helper' + +class Dummy + # This is a dummy class +end + +class AttachmentTest < Test::Unit::TestCase + should "return the path based on the url by default" do + @attachment = attachment :url => "/:class/:id/:basename" + @model = @attachment.instance + @model.id = 1234 + @model.avatar_file_name = "fake.jpg" + assert_equal "#{Rails.root}/public/fake_models/1234/fake", @attachment.path + end + + context "Attachment default_options" do + setup do + rebuild_model + @old_default_options = Paperclip::Attachment.default_options.dup + @new_default_options = @old_default_options.merge({ + :path => "argle/bargle", + :url => "fooferon", + :default_url => "not here.png" + }) + end + + teardown do + Paperclip::Attachment.default_options.merge! @old_default_options + end + + should "be overrideable" do + Paperclip::Attachment.default_options.merge!(@new_default_options) + @new_default_options.keys.each do |key| + assert_equal @new_default_options[key], + Paperclip::Attachment.default_options[key] + end + end + + context "without an Attachment" do + setup do + @dummy = Dummy.new + end + + should "return false when asked exists?" do + assert !@dummy.avatar.exists? + end + end + + context "on an Attachment" do + setup do + @dummy = Dummy.new + @attachment = @dummy.avatar + end + + Paperclip::Attachment.default_options.keys.each do |key| + should "be the default_options for #{key}" do + assert_equal @old_default_options[key], + @attachment.instance_variable_get("@#{key}"), + key + end + end + + context "when redefined" do + setup do + Paperclip::Attachment.default_options.merge!(@new_default_options) + @dummy = Dummy.new + @attachment = @dummy.avatar + end + + Paperclip::Attachment.default_options.keys.each do |key| + should "be the new default_options for #{key}" do + assert_equal @new_default_options[key], + @attachment.instance_variable_get("@#{key}"), + key + end + end + end + end + end + + context "An attachment with similarly named interpolations" do + setup do + rebuild_model :path => ":id.omg/:id-bbq/:idwhat/:id_partition.wtf" + @dummy = Dummy.new + @dummy.stubs(:id).returns(1024) + @file = File.new(File.join(File.dirname(__FILE__), + "fixtures", + "5k.png"), 'rb') + @dummy.avatar = @file + end + + teardown { @file.close } + + should "make sure that they are interpolated correctly" do + assert_equal "1024.omg/1024-bbq/1024what/000/001/024.wtf", @dummy.avatar.path + end + end + + context "An attachment with a :rails_env interpolation" do + setup do + @rails_env = "blah" + @id = 1024 + rebuild_model :path => ":rails_env/:id.png" + @dummy = Dummy.new + @dummy.stubs(:id).returns(@id) + @file = StringIO.new(".") + @dummy.avatar = @file + end + + should "return the proper path" do + temporary_rails_env(@rails_env) { + assert_equal "#{@rails_env}/#{@id}.png", @dummy.avatar.path + } + end + end + + context "An attachment with a default style and an extension interpolation" do + setup do + @attachment = attachment :path => ":basename.:extension", + :styles => { :default => ["100x100", :png] }, + :default_style => :default + @file = StringIO.new("...") + @file.expects(:original_filename).returns("file.jpg") + end + should "return the right extension for the path" do + @attachment.assign(@file) + assert_equal "file.png", @attachment.path + end + end + + context "An attachment with :convert_options" do + setup do + rebuild_model :styles => { + :thumb => "100x100", + :large => "400x400" + }, + :convert_options => { + :all => "-do_stuff", + :thumb => "-thumbnailize" + } + @dummy = Dummy.new + @dummy.avatar + end + + should "report the correct options when sent #extra_options_for(:thumb)" do + assert_equal "-thumbnailize -do_stuff", @dummy.avatar.send(:extra_options_for, :thumb), @dummy.avatar.convert_options.inspect + end + + should "report the correct options when sent #extra_options_for(:large)" do + assert_equal "-do_stuff", @dummy.avatar.send(:extra_options_for, :large) + end + + before_should "call extra_options_for(:thumb/:large)" do + Paperclip::Attachment.any_instance.expects(:extra_options_for).with(:thumb) + Paperclip::Attachment.any_instance.expects(:extra_options_for).with(:large) + end + end + + context "An attachment with :convert_options that is a proc" do + setup do + rebuild_model :styles => { + :thumb => "100x100", + :large => "400x400" + }, + :convert_options => { + :all => lambda{|i| i.all }, + :thumb => lambda{|i| i.thumb } + } + Dummy.class_eval do + def all; "-all"; end + def thumb; "-thumb"; end + end + @dummy = Dummy.new + @dummy.avatar + end + + should "report the correct options when sent #extra_options_for(:thumb)" do + assert_equal "-thumb -all", @dummy.avatar.send(:extra_options_for, :thumb), @dummy.avatar.convert_options.inspect + end + + should "report the correct options when sent #extra_options_for(:large)" do + assert_equal "-all", @dummy.avatar.send(:extra_options_for, :large) + end + + before_should "call extra_options_for(:thumb/:large)" do + Paperclip::Attachment.any_instance.expects(:extra_options_for).with(:thumb) + Paperclip::Attachment.any_instance.expects(:extra_options_for).with(:large) + end + end + + context "An attachment with :path that is a proc" do + setup do + rebuild_model :path => lambda{ |attachment| "path/#{attachment.instance.other}.:extension" } + + @file = File.new(File.join(File.dirname(__FILE__), + "fixtures", + "5k.png"), 'rb') + @dummyA = Dummy.new(:other => 'a') + @dummyA.avatar = @file + @dummyB = Dummy.new(:other => 'b') + @dummyB.avatar = @file + end + + teardown { @file.close } + + should "return correct path" do + assert_equal "path/a.png", @dummyA.avatar.path + assert_equal "path/b.png", @dummyB.avatar.path + end + end + + context "An attachment with :styles that is a proc" do + setup do + rebuild_model :styles => lambda{ |attachment| {:thumb => "50x50#", :large => "400x400"} } + + @attachment = Dummy.new.avatar + end + + should "have the correct geometry" do + assert_equal "50x50#", @attachment.styles[:thumb][:geometry] + end + end + + context "An attachment with :url that is a proc" do + setup do + rebuild_model :url => lambda{ |attachment| "path/#{attachment.instance.other}.:extension" } + + @file = File.new(File.join(File.dirname(__FILE__), + "fixtures", + "5k.png"), 'rb') + @dummyA = Dummy.new(:other => 'a') + @dummyA.avatar = @file + @dummyB = Dummy.new(:other => 'b') + @dummyB.avatar = @file + end + + teardown { @file.close } + + should "return correct url" do + assert_equal "path/a.png", @dummyA.avatar.url(:original, false) + assert_equal "path/b.png", @dummyB.avatar.url(:original, false) + end + end + + geometry_specs = [ + [ lambda{|z| "50x50#" }, :png ], + lambda{|z| "50x50#" }, + { :geometry => lambda{|z| "50x50#" } } + ] + geometry_specs.each do |geometry_spec| + context "An attachment geometry like #{geometry_spec}" do + setup do + rebuild_model :styles => { :normal => geometry_spec } + @attachment = Dummy.new.avatar + end + + should "not run the procs immediately" do + assert_kind_of Proc, @attachment.styles[:normal][:geometry] + end + + context "when assigned" do + setup do + @file = StringIO.new(".") + @attachment.assign(@file) + end + + should "have the correct geometry" do + assert_equal "50x50#", @attachment.styles[:normal][:geometry] + end + end + end + end + + context "An attachment with both 'normal' and hash-style styles" do + setup do + rebuild_model :styles => { + :normal => ["50x50#", :png], + :hash => { :geometry => "50x50#", :format => :png } + } + @dummy = Dummy.new + @attachment = @dummy.avatar + end + + [:processors, :whiny, :convert_options, :geometry, :format].each do |field| + should "have the same #{field} field" do + assert_equal @attachment.styles[:normal][field], @attachment.styles[:hash][field] + end + end + end + + context "An attachment with :processors that is a proc" do + setup do + rebuild_model :styles => { :normal => '' }, :processors => lambda { |a| [ :test ] } + @attachment = Dummy.new.avatar + end + + should "not run the proc immediately" do + assert_kind_of Proc, @attachment.styles[:normal][:processors] + end + + context "when assigned" do + setup do + @attachment.assign(StringIO.new(".")) + end + + should "have the correct processors" do + assert_equal [ :test ], @attachment.styles[:normal][:processors] + end + end + end + + context "An attachment with erroring processor" do + setup do + rebuild_model :processor => [:thumbnail], :styles => { :small => '' }, :whiny_thumbnails => true + @dummy = Dummy.new + Paperclip::Thumbnail.expects(:make).raises(Paperclip::PaperclipError, "cannot be processed.") + @file = StringIO.new("...") + @file.stubs(:to_tempfile).returns(@file) + @dummy.avatar = @file + end + + should "correctly forward processing error message to the instance" do + @dummy.valid? + assert_contains @dummy.errors.full_messages, "Avatar cannot be processed." + end + end + + context "An attachment with multiple processors" do + setup do + class Paperclip::Test < Paperclip::Processor; end + @style_params = { :once => {:one => 1, :two => 2} } + rebuild_model :processors => [:thumbnail, :test], :styles => @style_params + @dummy = Dummy.new + @file = StringIO.new("...") + @file.stubs(:to_tempfile).returns(@file) + Paperclip::Test.stubs(:make).returns(@file) + Paperclip::Thumbnail.stubs(:make).returns(@file) + end + + context "when assigned" do + setup { @dummy.avatar = @file } + + before_should "call #make on all specified processors" do + expected_params = @style_params[:once].merge({:processors => [:thumbnail, :test], :whiny => true, :convert_options => ""}) + Paperclip::Thumbnail.expects(:make).with(@file, expected_params, @dummy.avatar).returns(@file) + Paperclip::Test.expects(:make).with(@file, expected_params, @dummy.avatar).returns(@file) + end + + before_should "call #make with attachment passed as third argument" do + expected_params = @style_params[:once].merge({:processors => [:thumbnail, :test], :whiny => true, :convert_options => ""}) + Paperclip::Test.expects(:make).with(@file, expected_params, @dummy.avatar).returns(@file) + end + end + end + + context "An attachment with no processors defined" do + setup do + rebuild_model :processors => [], :styles => {:something => 1} + @dummy = Dummy.new + @file = StringIO.new("...") + end + should "raise when assigned to" do + assert_raises(RuntimeError){ @dummy.avatar = @file } + end + end + + context "Assigning an attachment with post_process hooks" do + setup do + rebuild_model :styles => { :something => "100x100#" } + Dummy.class_eval do + before_avatar_post_process :do_before_avatar + after_avatar_post_process :do_after_avatar + before_post_process :do_before_all + after_post_process :do_after_all + def do_before_avatar; end + def do_after_avatar; end + def do_before_all; end + def do_after_all; end + end + @file = StringIO.new(".") + @file.stubs(:to_tempfile).returns(@file) + @dummy = Dummy.new + Paperclip::Thumbnail.stubs(:make).returns(@file) + @attachment = @dummy.avatar + end + + should "call the defined callbacks when assigned" do + @dummy.expects(:do_before_avatar).with() + @dummy.expects(:do_after_avatar).with() + @dummy.expects(:do_before_all).with() + @dummy.expects(:do_after_all).with() + Paperclip::Thumbnail.expects(:make).returns(@file) + @dummy.avatar = @file + end + + should "not cancel the processing if a before_post_process returns nil" do + @dummy.expects(:do_before_avatar).with().returns(nil) + @dummy.expects(:do_after_avatar).with() + @dummy.expects(:do_before_all).with().returns(nil) + @dummy.expects(:do_after_all).with() + Paperclip::Thumbnail.expects(:make).returns(@file) + @dummy.avatar = @file + end + + should "cancel the processing if a before_post_process returns false" do + @dummy.expects(:do_before_avatar).never + @dummy.expects(:do_after_avatar).never + @dummy.expects(:do_before_all).with().returns(false) + @dummy.expects(:do_after_all).never + Paperclip::Thumbnail.expects(:make).never + @dummy.avatar = @file + end + + should "cancel the processing if a before_avatar_post_process returns false" do + @dummy.expects(:do_before_avatar).with().returns(false) + @dummy.expects(:do_after_avatar).never + @dummy.expects(:do_before_all).with().returns(true) + @dummy.expects(:do_after_all).never + Paperclip::Thumbnail.expects(:make).never + @dummy.avatar = @file + end + end + + context "Assigning an attachment" do + setup do + rebuild_model :styles => { :something => "100x100#" } + @file = StringIO.new(".") + @file.expects(:original_filename).returns("5k.png\n\n") + @file.expects(:content_type).returns("image/png\n\n") + @file.stubs(:to_tempfile).returns(@file) + @dummy = Dummy.new + Paperclip::Thumbnail.expects(:make).returns(@file) + @dummy.expects(:run_callbacks).with(:before_avatar_post_process, {:original => @file}) + @dummy.expects(:run_callbacks).with(:before_post_process, {:original => @file}) + @dummy.expects(:run_callbacks).with(:after_avatar_post_process, {:original => @file, :something => @file}) + @dummy.expects(:run_callbacks).with(:after_post_process, {:original => @file, :something => @file}) + @attachment = @dummy.avatar + @dummy.avatar = @file + end + + should "strip whitespace from original_filename field" do + assert_equal "5k.png", @dummy.avatar.original_filename + end + + should "strip whitespace from content_type field" do + assert_equal "image/png", @dummy.avatar.instance.avatar_content_type + end + end + + context "Attachment with strange letters" do + setup do + rebuild_model + + @not_file = mock + @tempfile = mock + @not_file.stubs(:nil?).returns(false) + @not_file.expects(:size).returns(10) + @tempfile.expects(:size).returns(10) + @not_file.expects(:to_tempfile).returns(@tempfile) + @not_file.expects(:original_filename).returns("sheep_say_bæ.png\r\n") + @not_file.expects(:content_type).returns("image/png\r\n") + + @dummy = Dummy.new + @attachment = @dummy.avatar + @attachment.expects(:valid_assignment?).with(@not_file).returns(true) + @attachment.expects(:queue_existing_for_delete) + @attachment.expects(:post_process) + @attachment.expects(:valid?).returns(true) + @attachment.expects(:validate) + @dummy.avatar = @not_file + end + + should "remove strange letters and replace with underscore (_)" do + assert_equal "sheep_say_b_.png", @dummy.avatar.original_filename + end + + end + + context "An attachment" do + setup do + @old_defaults = Paperclip::Attachment.default_options.dup + Paperclip::Attachment.default_options.merge!({ + :path => ":rails_root/tmp/:attachment/:class/:style/:id/:basename.:extension" + }) + FileUtils.rm_rf("tmp") + rebuild_model + @instance = Dummy.new + @attachment = Paperclip::Attachment.new(:avatar, @instance) + @file = File.new(File.join(File.dirname(__FILE__), + "fixtures", + "5k.png"), 'rb') + end + + teardown do + @file.close + Paperclip::Attachment.default_options.merge!(@old_defaults) + end + + should "raise if there are not the correct columns when you try to assign" do + @other_attachment = Paperclip::Attachment.new(:not_here, @instance) + assert_raises(Paperclip::PaperclipError) do + @other_attachment.assign(@file) + end + end + + should "return its default_url when no file assigned" do + assert @attachment.to_file.nil? + assert_equal "/avatars/original/missing.png", @attachment.url + assert_equal "/avatars/blah/missing.png", @attachment.url(:blah) + end + + should "return nil as path when no file assigned" do + assert @attachment.to_file.nil? + assert_equal nil, @attachment.path + assert_equal nil, @attachment.path(:blah) + end + + context "with a file assigned in the database" do + setup do + @attachment.stubs(:instance_read).with(:file_name).returns("5k.png") + @attachment.stubs(:instance_read).with(:content_type).returns("image/png") + @attachment.stubs(:instance_read).with(:file_size).returns(12345) + now = Time.now + Time.stubs(:now).returns(now) + @attachment.stubs(:instance_read).with(:updated_at).returns(Time.now) + end + + should "return a correct url even if the file does not exist" do + assert_nil @attachment.to_file + assert_match %r{^/system/avatars/#{@instance.id}/blah/5k\.png}, @attachment.url(:blah) + end + + should "make sure the updated_at mtime is in the url if it is defined" do + assert_match %r{#{Time.now.to_i}$}, @attachment.url(:blah) + end + + should "make sure the updated_at mtime is NOT in the url if false is passed to the url method" do + assert_no_match %r{#{Time.now.to_i}$}, @attachment.url(:blah, false) + end + + context "with the updated_at field removed" do + setup do + @attachment.stubs(:instance_read).with(:updated_at).returns(nil) + end + + should "only return the url without the updated_at when sent #url" do + assert_match "/avatars/#{@instance.id}/blah/5k.png", @attachment.url(:blah) + end + end + + should "return the proper path when filename has a single .'s" do + assert_equal "./test/../tmp/avatars/dummies/original/#{@instance.id}/5k.png", @attachment.path + end + + should "return the proper path when filename has multiple .'s" do + @attachment.stubs(:instance_read).with(:file_name).returns("5k.old.png") + assert_equal "./test/../tmp/avatars/dummies/original/#{@instance.id}/5k.old.png", @attachment.path + end + + context "when expecting three styles" do + setup do + styles = {:styles => { :large => ["400x400", :png], + :medium => ["100x100", :gif], + :small => ["32x32#", :jpg]}} + @attachment = Paperclip::Attachment.new(:avatar, + @instance, + styles) + end + + context "and assigned a file" do + setup do + now = Time.now + Time.stubs(:now).returns(now) + @attachment.assign(@file) + end + + should "be dirty" do + assert @attachment.dirty? + end + + context "and saved" do + setup do + @attachment.save + end + + should "return the real url" do + file = @attachment.to_file + assert file + assert_match %r{^/system/avatars/#{@instance.id}/original/5k\.png}, @attachment.url + assert_match %r{^/system/avatars/#{@instance.id}/small/5k\.jpg}, @attachment.url(:small) + file.close + end + + should "commit the files to disk" do + [:large, :medium, :small].each do |style| + io = @attachment.to_io(style) + assert File.exists?(io) + assert ! io.is_a?(::Tempfile) + io.close + end + end + + should "save the files as the right formats and sizes" do + [[:large, 400, 61, "PNG"], + [:medium, 100, 15, "GIF"], + [:small, 32, 32, "JPEG"]].each do |style| + cmd = %Q[identify -format "%w %h %b %m" "#{@attachment.path(style.first)}"] + out = `#{cmd}` + width, height, size, format = out.split(" ") + assert_equal style[1].to_s, width.to_s + assert_equal style[2].to_s, height.to_s + assert_equal style[3].to_s, format.to_s + end + end + + should "still have its #file attribute not be nil" do + assert ! (file = @attachment.to_file).nil? + file.close + end + + context "and trying to delete" do + setup do + @existing_names = @attachment.styles.keys.collect do |style| + @attachment.path(style) + end + end + + should "delete the files after assigning nil" do + @attachment.expects(:instance_write).with(:file_name, nil) + @attachment.expects(:instance_write).with(:content_type, nil) + @attachment.expects(:instance_write).with(:file_size, nil) + @attachment.expects(:instance_write).with(:updated_at, nil) + @attachment.assign nil + @attachment.save + @existing_names.each{|f| assert ! File.exists?(f) } + end + + should "delete the files when you call #clear and #save" do + @attachment.expects(:instance_write).with(:file_name, nil) + @attachment.expects(:instance_write).with(:content_type, nil) + @attachment.expects(:instance_write).with(:file_size, nil) + @attachment.expects(:instance_write).with(:updated_at, nil) + @attachment.clear + @attachment.save + @existing_names.each{|f| assert ! File.exists?(f) } + end + + should "delete the files when you call #delete" do + @attachment.expects(:instance_write).with(:file_name, nil) + @attachment.expects(:instance_write).with(:content_type, nil) + @attachment.expects(:instance_write).with(:file_size, nil) + @attachment.expects(:instance_write).with(:updated_at, nil) + @attachment.destroy + @existing_names.each{|f| assert ! File.exists?(f) } + end + end + end + end + end + + end + + context "when trying a nonexistant storage type" do + setup do + rebuild_model :storage => :not_here + end + + should "not be able to find the module" do + assert_raise(NameError){ Dummy.new.avatar } + end + end + end + + context "An attachment with only a avatar_file_name column" do + setup do + ActiveRecord::Base.connection.create_table :dummies, :force => true do |table| + table.column :avatar_file_name, :string + end + rebuild_class + @dummy = Dummy.new + @file = File.new(File.join(File.dirname(__FILE__), "fixtures", "5k.png"), 'rb') + end + + teardown { @file.close } + + should "not error when assigned an attachment" do + assert_nothing_raised { @dummy.avatar = @file } + end + + should "return the time when sent #avatar_updated_at" do + now = Time.now + Time.stubs(:now).returns(now) + @dummy.avatar = @file + assert now, @dummy.avatar.updated_at + end + + should "return nil when reloaded and sent #avatar_updated_at" do + @dummy.save + @dummy.reload + assert_nil @dummy.avatar.updated_at + end + + should "return the right value when sent #avatar_file_size" do + @dummy.avatar = @file + assert_equal @file.size, @dummy.avatar.size + end + + context "and avatar_updated_at column" do + setup do + ActiveRecord::Base.connection.add_column :dummies, :avatar_updated_at, :timestamp + rebuild_class + @dummy = Dummy.new + end + + should "not error when assigned an attachment" do + assert_nothing_raised { @dummy.avatar = @file } + end + + should "return the right value when sent #avatar_updated_at" do + now = Time.now + Time.stubs(:now).returns(now) + @dummy.avatar = @file + assert_equal now.to_i, @dummy.avatar.updated_at + end + end + + context "and avatar_content_type column" do + setup do + ActiveRecord::Base.connection.add_column :dummies, :avatar_content_type, :string + rebuild_class + @dummy = Dummy.new + end + + should "not error when assigned an attachment" do + assert_nothing_raised { @dummy.avatar = @file } + end + + should "return the right value when sent #avatar_content_type" do + @dummy.avatar = @file + assert_equal "image/png", @dummy.avatar.content_type + end + end + + context "and avatar_file_size column" do + setup do + ActiveRecord::Base.connection.add_column :dummies, :avatar_file_size, :integer + rebuild_class + @dummy = Dummy.new + end + + should "not error when assigned an attachment" do + assert_nothing_raised { @dummy.avatar = @file } + end + + should "return the right value when sent #avatar_file_size" do + @dummy.avatar = @file + assert_equal @file.size, @dummy.avatar.size + end + + should "return the right value when saved, reloaded, and sent #avatar_file_size" do + @dummy.avatar = @file + @dummy.save + @dummy = Dummy.find(@dummy.id) + assert_equal @file.size, @dummy.avatar.size + end + end + end +end diff --git a/test/database.yml b/test/database.yml new file mode 100644 index 0000000..c12ad28 --- /dev/null +++ b/test/database.yml @@ -0,0 +1,4 @@ +test: + adapter: sqlite3 + database: ":memory:" + diff --git a/test/fixtures/12k.png b/test/fixtures/12k.png new file mode 100644 index 0000000000000000000000000000000000000000..f819d45195c3155fce332105f54d089f77b6f05c GIT binary patch literal 12093 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 t!6W285(nkRKP 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 BW5WOd literal 0 HcmV?d00001 diff --git a/test/fixtures/bad.png b/test/fixtures/bad.png new file mode 100644 index 0000000..7ba4f07 --- /dev/null +++ b/test/fixtures/bad.png @@ -0,0 +1 @@ +This is not an image. diff --git a/test/fixtures/s3.yml b/test/fixtures/s3.yml new file mode 100644 index 0000000..43d3996 --- /dev/null +++ b/test/fixtures/s3.yml @@ -0,0 +1,4 @@ +development: + key: 54321 +production: + key: 12345 diff --git a/test/fixtures/text.txt b/test/fixtures/text.txt new file mode 100644 index 0000000..e69de29 diff --git a/test/fixtures/twopage.pdf b/test/fixtures/twopage.pdf new file mode 100644 index 0000000000000000000000000000000000000000..0c34a5193f4404443922279f43c7f4f2309c0247 GIT binary patch literal 8775 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 H8SwuA2Tvy* literal 0 HcmV?d00001 diff --git a/test/geometry_test.rb b/test/geometry_test.rb new file mode 100644 index 0000000..4b31a10 --- /dev/null +++ b/test/geometry_test.rb @@ -0,0 +1,177 @@ +require 'test/helper' + +class GeometryTest < Test::Unit::TestCase + context "Paperclip::Geometry" do + should "correctly report its given dimensions" do + assert @geo = Paperclip::Geometry.new(1024, 768) + assert_equal 1024, @geo.width + assert_equal 768, @geo.height + end + + should "set height to 0 if height dimension is missing" do + assert @geo = Paperclip::Geometry.new(1024) + assert_equal 1024, @geo.width + assert_equal 0, @geo.height + end + + should "set width to 0 if width dimension is missing" do + assert @geo = Paperclip::Geometry.new(nil, 768) + assert_equal 0, @geo.width + assert_equal 768, @geo.height + end + + should "be generated from a WxH-formatted string" do + assert @geo = Paperclip::Geometry.parse("800x600") + assert_equal 800, @geo.width + assert_equal 600, @geo.height + end + + should "be generated from a xH-formatted string" do + assert @geo = Paperclip::Geometry.parse("x600") + assert_equal 0, @geo.width + assert_equal 600, @geo.height + end + + should "be generated from a Wx-formatted string" do + assert @geo = Paperclip::Geometry.parse("800x") + assert_equal 800, @geo.width + assert_equal 0, @geo.height + end + + should "be generated from a W-formatted string" do + assert @geo = Paperclip::Geometry.parse("800") + assert_equal 800, @geo.width + assert_equal 0, @geo.height + end + + should "ensure the modifier is nil if not present" do + assert @geo = Paperclip::Geometry.parse("123x456") + assert_nil @geo.modifier + end + + should "treat x and X the same in geometries" do + @lower = Paperclip::Geometry.parse("123x456") + @upper = Paperclip::Geometry.parse("123X456") + assert_equal 123, @lower.width + assert_equal 123, @upper.width + assert_equal 456, @lower.height + assert_equal 456, @upper.height + end + + ['>', '<', '#', '@', '%', '^', '!', nil].each do |mod| + should "ensure the modifier #{mod.inspect} is preserved" do + assert @geo = Paperclip::Geometry.parse("123x456#{mod}") + assert_equal mod, @geo.modifier + assert_equal "123x456#{mod}", @geo.to_s + end + end + + ['>', '<', '#', '@', '%', '^', '!', nil].each do |mod| + should "ensure the modifier #{mod.inspect} is preserved with no height" do + assert @geo = Paperclip::Geometry.parse("123x#{mod}") + assert_equal mod, @geo.modifier + assert_equal "123#{mod}", @geo.to_s + end + end + + should "make sure the modifier gets passed during transformation_to" do + assert @src = Paperclip::Geometry.parse("123x456") + assert @dst = Paperclip::Geometry.parse("123x456>") + assert_equal "123x456>", @src.transformation_to(@dst).to_s + end + + should "generate correct ImageMagick formatting string for W-formatted string" do + assert @geo = Paperclip::Geometry.parse("800") + assert_equal "800", @geo.to_s + end + + should "generate correct ImageMagick formatting string for Wx-formatted string" do + assert @geo = Paperclip::Geometry.parse("800x") + assert_equal "800", @geo.to_s + end + + should "generate correct ImageMagick formatting string for xH-formatted string" do + assert @geo = Paperclip::Geometry.parse("x600") + assert_equal "x600", @geo.to_s + end + + should "generate correct ImageMagick formatting string for WxH-formatted string" do + assert @geo = Paperclip::Geometry.parse("800x600") + assert_equal "800x600", @geo.to_s + end + + should "be generated from a file" do + file = File.join(File.dirname(__FILE__), "fixtures", "5k.png") + file = File.new(file, 'rb') + assert_nothing_raised{ @geo = Paperclip::Geometry.from_file(file) } + assert @geo.height > 0 + assert @geo.width > 0 + end + + should "be generated from a file path" do + file = File.join(File.dirname(__FILE__), "fixtures", "5k.png") + assert_nothing_raised{ @geo = Paperclip::Geometry.from_file(file) } + assert @geo.height > 0 + assert @geo.width > 0 + end + + should "not generate from a bad file" do + file = "/home/This File Does Not Exist.omg" + assert_raise(Paperclip::NotIdentifiedByImageMagickError){ @geo = Paperclip::Geometry.from_file(file) } + end + + [['vertical', 900, 1440, true, false, false, 1440, 900, 0.625], + ['horizontal', 1024, 768, false, true, false, 1024, 768, 1.3333], + ['square', 100, 100, false, false, true, 100, 100, 1]].each do |args| + context "performing calculations on a #{args[0]} viewport" do + setup do + @geo = Paperclip::Geometry.new(args[1], args[2]) + end + + should "#{args[3] ? "" : "not"} be vertical" do + assert_equal args[3], @geo.vertical? + end + + should "#{args[4] ? "" : "not"} be horizontal" do + assert_equal args[4], @geo.horizontal? + end + + should "#{args[5] ? "" : "not"} be square" do + assert_equal args[5], @geo.square? + end + + should "report that #{args[6]} is the larger dimension" do + assert_equal args[6], @geo.larger + end + + should "report that #{args[7]} is the smaller dimension" do + assert_equal args[7], @geo.smaller + end + + should "have an aspect ratio of #{args[8]}" do + assert_in_delta args[8], @geo.aspect, 0.0001 + end + end + end + + [[ [1000, 100], [64, 64], "x64", "64x64+288+0" ], + [ [100, 1000], [50, 950], "x950", "50x950+22+0" ], + [ [100, 1000], [50, 25], "50x", "50x25+0+237" ]]. each do |args| + context "of #{args[0].inspect} and given a Geometry #{args[1].inspect} and sent transform_to" do + setup do + @geo = Paperclip::Geometry.new(*args[0]) + @dst = Paperclip::Geometry.new(*args[1]) + @scale, @crop = @geo.transformation_to @dst, true + end + + should "be able to return the correct scaling transformation geometry #{args[2]}" do + assert_equal args[2], @scale + end + + should "be able to return the correct crop transformation geometry #{args[3]}" do + assert_equal args[3], @crop + end + end + end + end +end diff --git a/test/helper.rb b/test/helper.rb new file mode 100644 index 0000000..8ae6201 --- /dev/null +++ b/test/helper.rb @@ -0,0 +1,100 @@ +require 'rubygems' +require 'test/unit' +gem 'thoughtbot-shoulda', ">= 2.9.0" +require 'shoulda' +require 'mocha' +require 'tempfile' + +gem 'sqlite3-ruby' + +require 'active_record' +require 'active_support' +begin + require 'ruby-debug' +rescue LoadError + puts "ruby-debug not loaded" +end + +ROOT = File.join(File.dirname(__FILE__), '..') +Rails.root = ROOT +Rails.env = "test" + +$LOAD_PATH << File.join(ROOT, 'lib') +$LOAD_PATH << File.join(ROOT, 'lib', 'paperclip') + +require File.join(ROOT, 'lib', 'paperclip.rb') + +require 'shoulda_macros/paperclip' + +FIXTURES_DIR = File.join(File.dirname(__FILE__), "fixtures") +config = YAML::load(IO.read(File.dirname(__FILE__) + '/database.yml')) +ActiveRecord::Base.logger = Logger.new(File.dirname(__FILE__) + "/debug.log") +ActiveRecord::Base.establish_connection(config['test']) + +def reset_class class_name + ActiveRecord::Base.send(:include, Paperclip) + Object.send(:remove_const, class_name) rescue nil + klass = Object.const_set(class_name, Class.new(ActiveRecord::Base)) + klass.class_eval{ include Paperclip } + klass +end + +def reset_table table_name, &block + block ||= lambda{ true } + ActiveRecord::Base.connection.create_table :dummies, {:force => true}, &block +end + +def modify_table table_name, &block + ActiveRecord::Base.connection.change_table :dummies, &block +end + +def rebuild_model options = {} + ActiveRecord::Base.connection.create_table :dummies, :force => true do |table| + table.column :other, :string + table.column :avatar_file_name, :string + table.column :avatar_content_type, :string + table.column :avatar_file_size, :integer + table.column :avatar_updated_at, :datetime + end + rebuild_class options +end + +def rebuild_class options = {} + ActiveRecord::Base.send(:include, Paperclip) + Object.send(:remove_const, "Dummy") rescue nil + Object.const_set("Dummy", Class.new(ActiveRecord::Base)) + Dummy.class_eval do + include Paperclip + has_attached_file :avatar, options + end +end + +def temporary_rails_env(new_env) + old_env = Object.const_defined?("Rails.env") ? Rails.env : nil + silence_warnings do + Object.const_set("Rails.env", new_env) + end + yield + silence_warnings do + Object.const_set("Rails.env", old_env) + end +end + +class FakeModel + attr_accessor :avatar_file_name, + :avatar_file_size, + :avatar_last_updated, + :avatar_content_type, + :id + + def errors + @errors ||= [] + end + + def run_callbacks name, *args + end +end + +def attachment options + Paperclip::Attachment.new(:avatar, FakeModel.new, options) +end diff --git a/test/integration_test.rb b/test/integration_test.rb new file mode 100644 index 0000000..2ab4493 --- /dev/null +++ b/test/integration_test.rb @@ -0,0 +1,481 @@ +require 'test/helper' + +class IntegrationTest < Test::Unit::TestCase + context "Many models at once" do + setup do + rebuild_model + @file = File.new(File.join(FIXTURES_DIR, "5k.png"), 'rb') + 300.times do |i| + Dummy.create! :avatar => @file + end + end + + should "not exceed the open file limit" do + assert_nothing_raised do + dummies = Dummy.find(:all) + dummies.each { |dummy| dummy.avatar } + end + end + end + + context "An attachment" do + setup do + rebuild_model :styles => { :thumb => "50x50#" } + @dummy = Dummy.new + @file = File.new(File.join(File.dirname(__FILE__), + "fixtures", + "5k.png"), 'rb') + @dummy.avatar = @file + assert @dummy.save + end + + teardown { @file.close } + + should "create its thumbnails properly" do + assert_match /\b50x50\b/, `identify "#{@dummy.avatar.path(:thumb)}"` + end + + context "redefining its attachment styles" do + setup do + Dummy.class_eval do + has_attached_file :avatar, :styles => { :thumb => "150x25#" } + has_attached_file :avatar, :styles => { :thumb => "150x25#", :dynamic => lambda { |a| '50x50#' } } + end + @d2 = Dummy.find(@dummy.id) + @d2.avatar.reprocess! + @d2.save + end + + should "create its thumbnails properly" do + assert_match /\b150x25\b/, `identify "#{@dummy.avatar.path(:thumb)}"` + assert_match /\b50x50\b/, `identify "#{@dummy.avatar.path(:dynamic)}"` + end + end + end + + context "A model that modifies its original" do + setup do + rebuild_model :styles => { :original => "2x2#" } + @dummy = Dummy.new + @file = File.new(File.join(File.dirname(__FILE__), + "fixtures", + "5k.png"), 'rb') + @dummy.avatar = @file + end + + should "report the file size of the processed file and not the original" do + assert_not_equal @file.size, @dummy.avatar.size + end + + teardown { @file.close } + end + + context "A model with attachments scoped under an id" do + setup do + rebuild_model :styles => { :large => "100x100", + :medium => "50x50" }, + :path => ":rails_root/tmp/:id/:attachments/:style.:extension" + @dummy = Dummy.new + @file = File.new(File.join(File.dirname(__FILE__), + "fixtures", + "5k.png"), 'rb') + @dummy.avatar = @file + end + + teardown { @file.close } + + context "when saved" do + setup do + @dummy.save + @saved_path = @dummy.avatar.path(:large) + end + + should "have a large file in the right place" do + assert File.exists?(@dummy.avatar.path(:large)) + end + + context "and deleted" do + setup do + @dummy.avatar.clear + @dummy.save + end + + should "not have a large file in the right place anymore" do + assert ! File.exists?(@saved_path) + end + + should "not have its next two parent directories" do + assert ! File.exists?(File.dirname(@saved_path)) + assert ! File.exists?(File.dirname(File.dirname(@saved_path))) + end + + before_should "not die if an unexpected SystemCallError happens" do + FileUtils.stubs(:rmdir).raises(Errno::EPIPE) + end + end + end + end + + context "A model with no attachment validation" do + setup do + rebuild_model :styles => { :large => "300x300>", + :medium => "100x100", + :thumb => ["32x32#", :gif] }, + :default_style => :medium, + :url => "/:attachment/:class/:style/:id/:basename.:extension", + :path => ":rails_root/tmp/:attachment/:class/:style/:id/:basename.:extension" + @dummy = Dummy.new + end + + should "have its definition return false when asked about whiny_thumbnails" do + assert ! Dummy.attachment_definitions[:avatar][:whiny_thumbnails] + end + + context "when validates_attachment_thumbnails is called" do + setup do + Dummy.validates_attachment_thumbnails :avatar + end + + should "have its definition return true when asked about whiny_thumbnails" do + assert_equal true, Dummy.attachment_definitions[:avatar][:whiny_thumbnails] + end + end + + context "redefined to have attachment validations" do + setup do + rebuild_model :styles => { :large => "300x300>", + :medium => "100x100", + :thumb => ["32x32#", :gif] }, + :whiny_thumbnails => true, + :default_style => :medium, + :url => "/:attachment/:class/:style/:id/:basename.:extension", + :path => ":rails_root/tmp/:attachment/:class/:style/:id/:basename.:extension" + end + + should "have its definition return true when asked about whiny_thumbnails" do + assert_equal true, Dummy.attachment_definitions[:avatar][:whiny_thumbnails] + end + end + end + + context "A model with no convert_options setting" do + setup do + rebuild_model :styles => { :large => "300x300>", + :medium => "100x100", + :thumb => ["32x32#", :gif] }, + :default_style => :medium, + :url => "/:attachment/:class/:style/:id/:basename.:extension", + :path => ":rails_root/tmp/:attachment/:class/:style/:id/:basename.:extension" + @dummy = Dummy.new + end + + should "have its definition return nil when asked about convert_options" do + assert ! Dummy.attachment_definitions[:avatar][:convert_options] + end + + context "redefined to have convert_options setting" do + setup do + rebuild_model :styles => { :large => "300x300>", + :medium => "100x100", + :thumb => ["32x32#", :gif] }, + :convert_options => "-strip -depth 8", + :default_style => :medium, + :url => "/:attachment/:class/:style/:id/:basename.:extension", + :path => ":rails_root/tmp/:attachment/:class/:style/:id/:basename.:extension" + end + + should "have its definition return convert_options value when asked about convert_options" do + assert_equal "-strip -depth 8", Dummy.attachment_definitions[:avatar][:convert_options] + end + end + end + + context "A model with a filesystem attachment" do + setup do + rebuild_model :styles => { :large => "300x300>", + :medium => "100x100", + :thumb => ["32x32#", :gif] }, + :whiny_thumbnails => true, + :default_style => :medium, + :url => "/:attachment/:class/:style/:id/:basename.:extension", + :path => ":rails_root/tmp/:attachment/:class/:style/:id/:basename.:extension" + @dummy = Dummy.new + @file = File.new(File.join(FIXTURES_DIR, "5k.png"), 'rb') + @bad_file = File.new(File.join(FIXTURES_DIR, "bad.png"), 'rb') + + assert @dummy.avatar = @file + assert @dummy.valid? + assert @dummy.save + end + + should "write and delete its files" do + [["434x66", :original], + ["300x46", :large], + ["100x15", :medium], + ["32x32", :thumb]].each do |geo, style| + cmd = %Q[identify -format "%wx%h" "#{@dummy.avatar.path(style)}"] + assert_equal geo, `#{cmd}`.chomp, cmd + end + + saved_paths = [:thumb, :medium, :large, :original].collect{|s| @dummy.avatar.path(s) } + + @d2 = Dummy.find(@dummy.id) + assert_equal "100x15", `identify -format "%wx%h" "#{@d2.avatar.path}"`.chomp + assert_equal "434x66", `identify -format "%wx%h" "#{@d2.avatar.path(:original)}"`.chomp + assert_equal "300x46", `identify -format "%wx%h" "#{@d2.avatar.path(:large)}"`.chomp + assert_equal "100x15", `identify -format "%wx%h" "#{@d2.avatar.path(:medium)}"`.chomp + assert_equal "32x32", `identify -format "%wx%h" "#{@d2.avatar.path(:thumb)}"`.chomp + + @dummy.avatar = "not a valid file but not nil" + assert_equal File.basename(@file.path), @dummy.avatar_file_name + assert @dummy.valid? + assert @dummy.save + + saved_paths.each do |p| + assert File.exists?(p) + end + + @dummy.avatar.clear + assert_nil @dummy.avatar_file_name + assert @dummy.valid? + assert @dummy.save + + saved_paths.each do |p| + assert ! File.exists?(p) + end + + @d2 = Dummy.find(@dummy.id) + assert_nil @d2.avatar_file_name + end + + should "work exactly the same when new as when reloaded" do + @d2 = Dummy.find(@dummy.id) + + assert_equal @dummy.avatar_file_name, @d2.avatar_file_name + [:thumb, :medium, :large, :original].each do |style| + assert_equal @dummy.avatar.path(style), @d2.avatar.path(style) + end + + saved_paths = [:thumb, :medium, :large, :original].collect{|s| @dummy.avatar.path(s) } + + @d2.avatar.clear + assert @d2.save + + saved_paths.each do |p| + assert ! File.exists?(p) + end + end + + should "know the difference between good files, bad files, and not files" do + expected = @dummy.avatar.to_file + @dummy.avatar = "not a file" + assert @dummy.valid? + assert_equal expected.path, @dummy.avatar.path + expected.close + + @dummy.avatar = @bad_file + assert ! @dummy.valid? + end + + should "know the difference between good files, bad files, and not files when validating" do + Dummy.validates_attachment_presence :avatar + @d2 = Dummy.find(@dummy.id) + @d2.avatar = @file + assert @d2.valid?, @d2.errors.full_messages.inspect + @d2.avatar = @bad_file + assert ! @d2.valid? + end + + should "be able to reload without saving and not have the file disappear" do + @dummy.avatar = @file + assert @dummy.save + @dummy.avatar.clear + assert_nil @dummy.avatar_file_name + @dummy.reload + assert_equal "5k.png", @dummy.avatar_file_name + end + + context "that is assigned its file from another Paperclip attachment" do + setup do + @dummy2 = Dummy.new + @file2 = File.new(File.join(FIXTURES_DIR, "12k.png"), 'rb') + assert @dummy2.avatar = @file2 + @dummy2.save + end + + should "work when assigned a file" do + assert_not_equal `identify -format "%wx%h" "#{@dummy.avatar.path(:original)}"`, + `identify -format "%wx%h" "#{@dummy2.avatar.path(:original)}"` + + assert @dummy.avatar = @dummy2.avatar + @dummy.save + assert_equal `identify -format "%wx%h" "#{@dummy.avatar.path(:original)}"`, + `identify -format "%wx%h" "#{@dummy2.avatar.path(:original)}"` + end + end + + end + + context "A model with an attachments association and a Paperclip attachment" do + setup do + Dummy.class_eval do + has_many :attachments, :class_name => 'Dummy' + end + + @dummy = Dummy.new + @dummy.avatar = File.new(File.join(File.dirname(__FILE__), + "fixtures", + "5k.png"), 'rb') + end + + should "should not error when saving" do + assert_nothing_raised do + @dummy.save! + end + end + end + + if ENV['S3_TEST_BUCKET'] + def s3_files_for attachment + [:thumb, :medium, :large, :original].inject({}) do |files, style| + data = `curl "#{attachment.url(style)}" 2>/dev/null`.chomp + t = Tempfile.new("paperclip-test") + t.binmode + t.write(data) + t.rewind + files[style] = t + files + end + end + + def s3_headers_for attachment, style + `curl --head "#{attachment.url(style)}" 2>/dev/null`.split("\n").inject({}) do |h,head| + split_head = head.chomp.split(/\s*:\s*/, 2) + h[split_head.first.downcase] = split_head.last unless split_head.empty? + h + end + end + + context "A model with an S3 attachment" do + setup do + rebuild_model :styles => { :large => "300x300>", + :medium => "100x100", + :thumb => ["32x32#", :gif] }, + :storage => :s3, + :whiny_thumbnails => true, + # :s3_options => {:logger => Logger.new(StringIO.new)}, + :s3_credentials => File.new(File.join(File.dirname(__FILE__), "s3.yml")), + :default_style => :medium, + :bucket => ENV['S3_TEST_BUCKET'], + :path => ":class/:attachment/:id/:style/:basename.:extension" + @dummy = Dummy.new + @file = File.new(File.join(FIXTURES_DIR, "5k.png"), 'rb') + @bad_file = File.new(File.join(FIXTURES_DIR, "bad.png"), 'rb') + + assert @dummy.avatar = @file + assert @dummy.valid? + assert @dummy.save + + @files_on_s3 = s3_files_for @dummy.avatar + end + + should "write and delete its files" do + [["434x66", :original], + ["300x46", :large], + ["100x15", :medium], + ["32x32", :thumb]].each do |geo, style| + cmd = %Q[identify -format "%wx%h" "#{@files_on_s3[style].path}"] + assert_equal geo, `#{cmd}`.chomp, cmd + end + + @d2 = Dummy.find(@dummy.id) + @d2_files = s3_files_for @d2.avatar + [["434x66", :original], + ["300x46", :large], + ["100x15", :medium], + ["32x32", :thumb]].each do |geo, style| + cmd = %Q[identify -format "%wx%h" "#{@d2_files[style].path}"] + assert_equal geo, `#{cmd}`.chomp, cmd + end + + @dummy.avatar = "not a valid file but not nil" + assert_equal File.basename(@file.path), @dummy.avatar_file_name + assert @dummy.valid? + assert @dummy.save + + saved_keys = [:thumb, :medium, :large, :original].collect{|s| @dummy.avatar.to_file(s) } + + saved_keys.each do |key| + assert key.exists? + end + + @dummy.avatar.clear + assert_nil @dummy.avatar_file_name + assert @dummy.valid? + assert @dummy.save + + saved_keys.each do |key| + assert ! key.exists? + end + + @d2 = Dummy.find(@dummy.id) + assert_nil @d2.avatar_file_name + end + + should "work exactly the same when new as when reloaded" do + @d2 = Dummy.find(@dummy.id) + + assert_equal @dummy.avatar_file_name, @d2.avatar_file_name + [:thumb, :medium, :large, :original].each do |style| + assert_equal @dummy.avatar.to_file(style).to_s, @d2.avatar.to_file(style).to_s + end + + saved_keys = [:thumb, :medium, :large, :original].collect{|s| @dummy.avatar.to_file(s) } + + @d2.avatar.clear + assert @d2.save + + saved_keys.each do |key| + assert ! key.exists? + end + end + + should "know the difference between good files, bad files, not files, and nil" do + expected = @dummy.avatar.to_file + @dummy.avatar = "not a file" + assert @dummy.valid? + assert_equal expected.full_name, @dummy.avatar.to_file.full_name + + @dummy.avatar = @bad_file + assert ! @dummy.valid? + @dummy.avatar = nil + assert @dummy.valid? + + Dummy.validates_attachment_presence :avatar + @d2 = Dummy.find(@dummy.id) + @d2.avatar = @file + assert @d2.valid? + @d2.avatar = @bad_file + assert ! @d2.valid? + @d2.avatar = nil + assert ! @d2.valid? + end + + should "be able to reload without saving and not have the file disappear" do + @dummy.avatar = @file + assert @dummy.save + @dummy.avatar = nil + assert_nil @dummy.avatar_file_name + @dummy.reload + assert_equal "5k.png", @dummy.avatar_file_name + end + + should "have the right content type" do + headers = s3_headers_for(@dummy.avatar, :original) + p headers + assert_equal 'image/png', headers['content-type'] + end + end + end +end + diff --git a/test/interpolations_test.rb b/test/interpolations_test.rb new file mode 100644 index 0000000..8ff4451 --- /dev/null +++ b/test/interpolations_test.rb @@ -0,0 +1,120 @@ +require 'test/helper' + +class InterpolationsTest < Test::Unit::TestCase + should "return all methods but the infrastructure when sent #all" do + methods = Paperclip::Interpolations.all + assert ! methods.include?(:[]) + assert ! methods.include?(:[]=) + assert ! methods.include?(:all) + methods.each do |m| + assert Paperclip::Interpolations.respond_to? m + end + end + + should "return the Rails.root" do + assert_equal Rails.root, Paperclip::Interpolations.rails_root(:attachment, :style) + end + + should "return the Rails.env" do + assert_equal Rails.env, Paperclip::Interpolations.rails_env(:attachment, :style) + end + + should "return the class of the instance" do + attachment = mock + attachment.expects(:instance).returns(attachment) + attachment.expects(:class).returns("Thing") + assert_equal "things", Paperclip::Interpolations.class(attachment, :style) + end + + should "return the basename of the file" do + attachment = mock + attachment.expects(:original_filename).returns("one.jpg").times(2) + assert_equal "one", Paperclip::Interpolations.basename(attachment, :style) + end + + should "return the extension of the file" do + attachment = mock + attachment.expects(:original_filename).returns("one.jpg") + attachment.expects(:styles).returns({}) + assert_equal "jpg", Paperclip::Interpolations.extension(attachment, :style) + end + + should "return the extension of the file as the format if defined in the style" do + attachment = mock + attachment.expects(:original_filename).never + attachment.expects(:styles).returns({:style => {:format => "png"}}) + assert_equal "png", Paperclip::Interpolations.extension(attachment, :style) + end + + should "return the id of the attachment" do + attachment = mock + attachment.expects(:id).returns(23) + attachment.expects(:instance).returns(attachment) + assert_equal 23, Paperclip::Interpolations.id(attachment, :style) + end + + should "return the partitioned id of the attachment" do + attachment = mock + attachment.expects(:id).returns(23) + attachment.expects(:instance).returns(attachment) + assert_equal "000/000/023", Paperclip::Interpolations.id_partition(attachment, :style) + end + + should "return the name of the attachment" do + attachment = mock + attachment.expects(:name).returns("file") + assert_equal "files", Paperclip::Interpolations.attachment(attachment, :style) + end + + should "return the style" do + assert_equal :style, Paperclip::Interpolations.style(:attachment, :style) + end + + should "return the default style" do + attachment = mock + attachment.expects(:default_style).returns(:default_style) + assert_equal :default_style, Paperclip::Interpolations.style(attachment, nil) + end + + should "reinterpolate :url" do + attachment = mock + attachment.expects(:options).returns({:url => ":id"}) + attachment.expects(:url).with(:style, false).returns("1234") + assert_equal "1234", Paperclip::Interpolations.url(attachment, :style) + end + + should "raise if infinite loop detcted reinterpolating :url" do + attachment = mock + attachment.expects(:options).returns({:url => ":url"}) + assert_raises(Paperclip::InfiniteInterpolationError){ Paperclip::Interpolations.url(attachment, :style) } + end + + should "return the filename as basename.extension" do + attachment = mock + attachment.expects(:styles).returns({}) + attachment.expects(:original_filename).returns("one.jpg").times(3) + assert_equal "one.jpg", Paperclip::Interpolations.filename(attachment, :style) + end + + should "return the filename as basename.extension when format supplied" do + attachment = mock + attachment.expects(:styles).returns({:style => {:format => :png}}) + attachment.expects(:original_filename).returns("one.jpg").times(2) + assert_equal "one.png", Paperclip::Interpolations.filename(attachment, :style) + end + + should "return the timestamp" do + now = Time.now + attachment = mock + attachment.expects(:instance_read).with(:updated_at).returns(now) + assert_equal now.to_s, Paperclip::Interpolations.timestamp(attachment, :style) + end + + should "call all expected interpolations with the given arguments" do + Paperclip::Interpolations.expects(:id).with(:attachment, :style).returns(1234) + Paperclip::Interpolations.expects(:attachment).with(:attachment, :style).returns("attachments") + Paperclip::Interpolations.expects(:notreal).never + value = Paperclip::Interpolations.interpolate(":notreal/:id/:attachment", :attachment, :style) + assert_equal ":notreal/1234/attachments", value + end +end diff --git a/test/iostream_test.rb b/test/iostream_test.rb new file mode 100644 index 0000000..97030b5 --- /dev/null +++ b/test/iostream_test.rb @@ -0,0 +1,71 @@ +require 'test/helper' + +class IOStreamTest < Test::Unit::TestCase + context "IOStream" do + should "be included in IO, File, Tempfile, and StringIO" do + [IO, File, Tempfile, StringIO].each do |klass| + assert klass.included_modules.include?(IOStream), "Not in #{klass}" + end + end + end + + context "A file" do + setup do + @file = File.new(File.join(File.dirname(__FILE__), "fixtures", "5k.png"), 'rb') + end + + teardown { @file.close } + + context "that is sent #stream_to" do + + context "and given a String" do + setup do + FileUtils.mkdir_p(File.join(ROOT, 'tmp')) + assert @result = @file.stream_to(File.join(ROOT, 'tmp', 'iostream.string.test')) + end + + should "return a File" do + assert @result.is_a?(File) + end + + should "contain the same data as the original file" do + @file.rewind; @result.rewind + assert_equal @file.read, @result.read + end + end + + context "and given a Tempfile" do + setup do + tempfile = Tempfile.new('iostream.test') + tempfile.binmode + assert @result = @file.stream_to(tempfile) + end + + should "return a Tempfile" do + assert @result.is_a?(Tempfile) + end + + should "contain the same data as the original file" do + @file.rewind; @result.rewind + assert_equal @file.read, @result.read + end + end + + end + + context "that is sent #to_tempfile" do + setup do + assert @tempfile = @file.to_tempfile + end + + should "convert it to a Tempfile" do + assert @tempfile.is_a?(Tempfile) + end + + should "have the Tempfile contain the same data as the file" do + @file.rewind; @tempfile.rewind + assert_equal @file.read, @tempfile.read + end + end + end +end diff --git a/test/matchers/have_attached_file_matcher_test.rb b/test/matchers/have_attached_file_matcher_test.rb new file mode 100644 index 0000000..b29ec37 --- /dev/null +++ b/test/matchers/have_attached_file_matcher_test.rb @@ -0,0 +1,21 @@ +require 'test/helper' + +class HaveAttachedFileMatcherTest < Test::Unit::TestCase + context "have_attached_file" do + setup do + @dummy_class = reset_class "Dummy" + reset_table "dummies" + @matcher = self.class.have_attached_file(:avatar) + end + + should "reject a class with no attachment" do + assert_rejects @matcher, @dummy_class + end + + should "accept a class with an attachment" do + modify_table("dummies"){|d| d.string :avatar_file_name } + @dummy_class.has_attached_file :avatar + assert_accepts @matcher, @dummy_class + end + end +end diff --git a/test/matchers/validate_attachment_content_type_matcher_test.rb b/test/matchers/validate_attachment_content_type_matcher_test.rb new file mode 100644 index 0000000..241eb5f --- /dev/null +++ b/test/matchers/validate_attachment_content_type_matcher_test.rb @@ -0,0 +1,30 @@ +require 'test/helper' + +class ValidateAttachmentContentTypeMatcherTest < Test::Unit::TestCase + context "validate_attachment_content_type" do + setup do + reset_table("dummies") do |d| + d.string :avatar_file_name + end + @dummy_class = reset_class "Dummy" + @dummy_class.has_attached_file :avatar + @matcher = self.class.validate_attachment_content_type(:avatar). + allowing(%w(image/png image/jpeg)). + rejecting(%w(audio/mp3 application/octet-stream)) + end + + should "reject a class with no validation" do + assert_rejects @matcher, @dummy_class + end + + should "reject a class with a validation that doesn't match" do + @dummy_class.validates_attachment_content_type :avatar, :content_type => %r{audio/.*} + assert_rejects @matcher, @dummy_class + end + + should "accept a class with a validation" do + @dummy_class.validates_attachment_content_type :avatar, :content_type => %r{image/.*} + assert_accepts @matcher, @dummy_class + end + end +end diff --git a/test/matchers/validate_attachment_presence_matcher_test.rb b/test/matchers/validate_attachment_presence_matcher_test.rb new file mode 100644 index 0000000..860a760 --- /dev/null +++ b/test/matchers/validate_attachment_presence_matcher_test.rb @@ -0,0 +1,21 @@ +require 'test/helper' + +class ValidateAttachmentPresenceMatcherTest < Test::Unit::TestCase + context "validate_attachment_presence" do + setup do + reset_table("dummies"){|d| d.string :avatar_file_name } + @dummy_class = reset_class "Dummy" + @dummy_class.has_attached_file :avatar + @matcher = self.class.validate_attachment_presence(:avatar) + end + + should "reject a class with no validation" do + assert_rejects @matcher, @dummy_class + end + + should "accept a class with a validation" do + @dummy_class.validates_attachment_presence :avatar + assert_accepts @matcher, @dummy_class + end + end +end diff --git a/test/matchers/validate_attachment_size_matcher_test.rb b/test/matchers/validate_attachment_size_matcher_test.rb new file mode 100644 index 0000000..7e4c9b4 --- /dev/null +++ b/test/matchers/validate_attachment_size_matcher_test.rb @@ -0,0 +1,50 @@ +require 'test/helper' + +class ValidateAttachmentSizeMatcherTest < Test::Unit::TestCase + context "validate_attachment_size" do + setup do + reset_table("dummies") do |d| + d.string :avatar_file_name + end + @dummy_class = reset_class "Dummy" + @dummy_class.has_attached_file :avatar + end + + context "of limited size" do + setup{ @matcher = self.class.validate_attachment_size(:avatar).in(256..1024) } + + should "reject a class with no validation" do + assert_rejects @matcher, @dummy_class + end + + should "reject a class with a validation that's too high" do + @dummy_class.validates_attachment_size :avatar, :in => 256..2048 + assert_rejects @matcher, @dummy_class + end + + should "reject a class with a validation that's too low" do + @dummy_class.validates_attachment_size :avatar, :in => 0..1024 + assert_rejects @matcher, @dummy_class + end + + should "accept a class with a validation that matches" do + @dummy_class.validates_attachment_size :avatar, :in => 256..1024 + assert_accepts @matcher, @dummy_class + end + end + + context "validates_attachment_size with infinite range" do + setup{ @matcher = self.class.validate_attachment_size(:avatar) } + + should "accept a class with an upper limit" do + @dummy_class.validates_attachment_size :avatar, :less_than => 1 + assert_accepts @matcher, @dummy_class + end + + should "accept a class with no upper limit" do + @dummy_class.validates_attachment_size :avatar, :greater_than => 1 + assert_accepts @matcher, @dummy_class + end + end + end +end diff --git a/test/paperclip_test.rb b/test/paperclip_test.rb new file mode 100644 index 0000000..4b43037 --- /dev/null +++ b/test/paperclip_test.rb @@ -0,0 +1,291 @@ +require 'test/helper' + +class PaperclipTest < Test::Unit::TestCase + [:image_magick_path, :convert_path].each do |path| + context "Calling Paperclip.run with an #{path} specified" do + setup do + Paperclip.options[:image_magick_path] = nil + Paperclip.options[:convert_path] = nil + Paperclip.options[path] = "/usr/bin" + end + + should "execute the right command" do + Paperclip.expects(:path_for_command).with("convert").returns("/usr/bin/convert") + Paperclip.expects(:bit_bucket).returns("/dev/null") + Paperclip.expects(:"`").with("/usr/bin/convert one.jpg two.jpg 2>/dev/null") + Paperclip.run("convert", "one.jpg two.jpg") + end + end + end + + context "Calling Paperclip.run with no path specified" do + setup do + Paperclip.options[:image_magick_path] = nil + Paperclip.options[:convert_path] = nil + end + + should "execute the right command" do + Paperclip.expects(:path_for_command).with("convert").returns("convert") + Paperclip.expects(:bit_bucket).returns("/dev/null") + Paperclip.expects(:"`").with("convert one.jpg two.jpg 2>/dev/null") + Paperclip.run("convert", "one.jpg two.jpg") + end + + should "log the command when :log_command is set" do + Paperclip.options[:log_command] = true + Paperclip.expects(:bit_bucket).returns("/dev/null") + Paperclip.expects(:log).with("this is the command 2>/dev/null") + Paperclip.expects(:"`").with("this is the command 2>/dev/null") + Paperclip.run("this","is the command") + end + end + + should "raise when sent #processor and the name of a class that exists but isn't a subclass of Processor" do + assert_raises(Paperclip::PaperclipError){ Paperclip.processor(:attachment) } + end + + should "raise when sent #processor and the name of a class that doesn't exist" do + assert_raises(NameError){ Paperclip.processor(:boogey_man) } + end + + should "return a class when sent #processor and the name of a class under Paperclip" do + assert_equal ::Paperclip::Thumbnail, Paperclip.processor(:thumbnail) + end + + should "call a proc sent to check_guard" do + @dummy = Dummy.new + @dummy.expects(:one).returns(:one) + assert_equal :one, @dummy.avatar.send(:check_guard, lambda{|x| x.one }) + end + + should "call a method name sent to check_guard" do + @dummy = Dummy.new + @dummy.expects(:one).returns(:one) + assert_equal :one, @dummy.avatar.send(:check_guard, :one) + end + + context "Paperclip.bit_bucket" do + context "on systems without /dev/null" do + setup do + File.expects(:exists?).with("/dev/null").returns(false) + end + + should "return 'NUL'" do + assert_equal "NUL", Paperclip.bit_bucket + end + end + + context "on systems with /dev/null" do + setup do + File.expects(:exists?).with("/dev/null").returns(true) + end + + should "return '/dev/null'" do + assert_equal "/dev/null", Paperclip.bit_bucket + end + end + end + + context "An ActiveRecord model with an 'avatar' attachment" do + setup do + rebuild_model :path => "tmp/:class/omg/:style.:extension" + @file = File.new(File.join(FIXTURES_DIR, "5k.png"), 'rb') + end + + teardown { @file.close } + + should "not error when trying to also create a 'blah' attachment" do + assert_nothing_raised do + Dummy.class_eval do + has_attached_file :blah + end + end + end + + context "that is attr_protected" do + setup do + Dummy.class_eval do + attr_protected :avatar + end + @dummy = Dummy.new + end + + should "not assign the avatar on mass-set" do + @dummy.attributes = { :other => "I'm set!", + :avatar => @file } + + assert_equal "I'm set!", @dummy.other + assert ! @dummy.avatar? + end + + should "still allow assigment on normal set" do + @dummy.other = "I'm set!" + @dummy.avatar = @file + + assert_equal "I'm set!", @dummy.other + assert @dummy.avatar? + end + end + + context "with a subclass" do + setup do + class ::SubDummy < Dummy; end + end + + should "be able to use the attachment from the subclass" do + assert_nothing_raised do + @subdummy = SubDummy.create(:avatar => @file) + end + end + + should "be able to see the attachment definition from the subclass's class" do + assert_equal "tmp/:class/omg/:style.:extension", SubDummy.attachment_definitions[:avatar][:path] + end + + teardown do + Object.send(:remove_const, "SubDummy") rescue nil + end + end + + should "have an #avatar method" do + assert Dummy.new.respond_to?(:avatar) + end + + should "have an #avatar= method" do + assert Dummy.new.respond_to?(:avatar=) + end + + context "that is valid" do + setup do + @dummy = Dummy.new + @dummy.avatar = @file + end + + should "be valid" do + assert @dummy.valid? + end + + context "then has a validation added that makes it invalid" do + setup do + assert @dummy.save + Dummy.class_eval do + validates_attachment_content_type :avatar, :content_type => ["text/plain"] + end + @dummy2 = Dummy.find(@dummy.id) + end + + should "be invalid when reloaded" do + assert ! @dummy2.valid?, @dummy2.errors.inspect + end + + should "be able to call #valid? twice without having duplicate errors" do + @dummy2.avatar.valid? + first_errors = @dummy2.avatar.errors + @dummy2.avatar.valid? + assert_equal first_errors, @dummy2.avatar.errors + end + end + end + + context "a validation with an if guard clause" do + setup do + Dummy.send(:"validates_attachment_presence", :avatar, :if => lambda{|i| i.foo }) + @dummy = Dummy.new + end + + should "attempt validation if the guard returns true" do + @dummy.expects(:foo).returns(true) + @dummy.avatar.expects(:validate_presence).returns(nil) + @dummy.valid? + end + + should "not attempt validation if the guard returns false" do + @dummy.expects(:foo).returns(false) + @dummy.avatar.expects(:validate_presence).never + @dummy.valid? + end + end + + context "a validation with an unless guard clause" do + setup do + Dummy.send(:"validates_attachment_presence", :avatar, :unless => lambda{|i| i.foo }) + @dummy = Dummy.new + end + + should "attempt validation if the guard returns true" do + @dummy.expects(:foo).returns(false) + @dummy.avatar.expects(:validate_presence).returns(nil) + @dummy.valid? + end + + should "not attempt validation if the guard returns false" do + @dummy.expects(:foo).returns(true) + @dummy.avatar.expects(:validate_presence).never + @dummy.valid? + end + end + + def self.should_validate validation, options, valid_file, invalid_file + context "with #{validation} validation and #{options.inspect} options" do + setup do + Dummy.send(:"validates_attachment_#{validation}", :avatar, options) + @dummy = Dummy.new + end + context "and assigning nil" do + setup do + @dummy.avatar = nil + @dummy.valid? + end + if validation == :presence + should "have an error on the attachment" do + assert @dummy.errors.on(:avatar) + end + else + should "not have an error on the attachment" do + assert_nil @dummy.errors.on(:avatar) + end + end + end + context "and assigned a valid file" do + setup do + @dummy.avatar = valid_file + @dummy.valid? + end + should "not have an error when assigned a valid file" do + assert ! @dummy.avatar.errors.key?(validation) + end + should "not have an error on the attachment" do + assert_nil @dummy.errors.on(:avatar) + end + end + context "and assigned an invalid file" do + setup do + @dummy.avatar = invalid_file + @dummy.valid? + end + should "have an error when assigned a valid file" do + assert_not_nil @dummy.avatar.errors[validation] + end + should "have an error on the attachment" do + assert @dummy.errors.on(:avatar) + end + end + end + end + + [[:presence, {}, "5k.png", nil], + [:size, {:in => 1..10240}, nil, "12k.png"], + [:size, {:less_than => 10240}, "5k.png", "12k.png"], + [:size, {:greater_than => 8096}, "12k.png", "5k.png"], + [:content_type, {:content_type => "image/png"}, "5k.png", "text.txt"], + [:content_type, {:content_type => "text/plain"}, "text.txt", "5k.png"], + [:content_type, {:content_type => %r{image/.*}}, "5k.png", "text.txt"]].each do |args| + validation, options, valid_file, invalid_file = args + valid_file &&= File.open(File.join(FIXTURES_DIR, valid_file), "rb") + invalid_file &&= File.open(File.join(FIXTURES_DIR, invalid_file), "rb") + + should_validate validation, options, valid_file, invalid_file + end + + end +end diff --git a/test/processor_test.rb b/test/processor_test.rb new file mode 100644 index 0000000..a05f0a9 --- /dev/null +++ b/test/processor_test.rb @@ -0,0 +1,10 @@ +require 'test/helper' + +class ProcessorTest < Test::Unit::TestCase + should "instantiate and call #make when sent #make to the class" do + processor = mock + processor.expects(:make).with() + Paperclip::Processor.expects(:new).with(:one, :two, :three).returns(processor) + Paperclip::Processor.make(:one, :two, :three) + end +end diff --git a/test/storage_test.rb b/test/storage_test.rb new file mode 100644 index 0000000..5cf666d --- /dev/null +++ b/test/storage_test.rb @@ -0,0 +1,282 @@ +require 'test/helper' + +class StorageTest < Test::Unit::TestCase + context "Parsing S3 credentials" do + setup do + rebuild_model :storage => :s3, + :bucket => "testing", + :s3_credentials => {:not => :important} + + @dummy = Dummy.new + @avatar = @dummy.avatar + + @current_env = Rails.env + end + + teardown do + Object.const_set("Rails.env", @current_env) + end + + should "get the correct credentials when Rails.env is production" do + Object.const_set('Rails.env', "production") + assert_equal({:key => "12345"}, + @avatar.parse_credentials('production' => {:key => '12345'}, + :development => {:key => "54321"})) + end + + should "get the correct credentials when Rails.env is development" do + Object.const_set('Rails.env', "development") + assert_equal({:key => "54321"}, + @avatar.parse_credentials('production' => {:key => '12345'}, + :development => {:key => "54321"})) + end + + should "return the argument if the key does not exist" do + Object.const_set('Rails.env', "not really an env") + assert_equal({:test => "12345"}, @avatar.parse_credentials(:test => "12345")) + end + end + + context "" do + setup do + rebuild_model :storage => :s3, + :s3_credentials => {}, + :bucket => "bucket", + :path => ":attachment/:basename.:extension", + :url => ":s3_path_url" + @dummy = Dummy.new + @dummy.avatar = StringIO.new(".") + end + + should "return a url based on an S3 path" do + assert_match %r{^http://s3.amazonaws.com/bucket/avatars/stringio.txt}, @dummy.avatar.url + end + end + context "" do + setup do + rebuild_model :storage => :s3, + :s3_credentials => {}, + :bucket => "bucket", + :path => ":attachment/:basename.:extension", + :url => ":s3_domain_url" + @dummy = Dummy.new + @dummy.avatar = StringIO.new(".") + end + + should "return a url based on an S3 subdomain" do + assert_match %r{^http://bucket.s3.amazonaws.com/avatars/stringio.txt}, @dummy.avatar.url + end + end + context "" do + setup do + rebuild_model :storage => :s3, + :s3_credentials => { + :production => { :bucket => "prod_bucket" }, + :development => { :bucket => "dev_bucket" } + }, + :s3_host_alias => "something.something.com", + :path => ":attachment/:basename.:extension", + :url => ":s3_alias_url" + @dummy = Dummy.new + @dummy.avatar = StringIO.new(".") + end + + should "return a url based on the host_alias" do + assert_match %r{^http://something.something.com/avatars/stringio.txt}, @dummy.avatar.url + end + end + + context "Parsing S3 credentials with a bucket in them" do + setup do + rebuild_model :storage => :s3, + :s3_credentials => { + :production => { :bucket => "prod_bucket" }, + :development => { :bucket => "dev_bucket" } + } + @dummy = Dummy.new + @old_env = Rails.env + end + + teardown{ Object.const_set("Rails.env", @old_env) } + + should "get the right bucket in production" do + Object.const_set("Rails.env", "production") + assert_equal "prod_bucket", @dummy.avatar.bucket_name + end + + should "get the right bucket in development" do + Object.const_set("Rails.env", "development") + assert_equal "dev_bucket", @dummy.avatar.bucket_name + end + end + + context "An attachment with S3 storage" do + setup do + rebuild_model :storage => :s3, + :bucket => "testing", + :path => ":attachment/:style/:basename.:extension", + :s3_credentials => { + 'access_key_id' => "12345", + 'secret_access_key' => "54321" + } + end + + should "be extended by the S3 module" do + assert Dummy.new.avatar.is_a?(Paperclip::Storage::S3) + end + + should "not be extended by the Filesystem module" do + assert ! Dummy.new.avatar.is_a?(Paperclip::Storage::Filesystem) + end + + context "when assigned" do + setup do + @file = File.new(File.join(File.dirname(__FILE__), 'fixtures', '5k.png'), 'rb') + @dummy = Dummy.new + @dummy.avatar = @file + end + + teardown { @file.close } + + should "not get a bucket to get a URL" do + @dummy.avatar.expects(:s3).never + @dummy.avatar.expects(:s3_bucket).never + assert_match %r{^http://s3\.amazonaws\.com/testing/avatars/original/5k\.png}, @dummy.avatar.url + end + + context "and saved" do + setup do + @s3_mock = stub + @bucket_mock = stub + RightAws::S3.expects(:new).with("12345", "54321", {}).returns(@s3_mock) + @s3_mock.expects(:bucket).with("testing", true, "public-read").returns(@bucket_mock) + @key_mock = stub + @bucket_mock.expects(:key).returns(@key_mock) + @key_mock.expects(:data=) + @key_mock.expects(:put).with(nil, 'public-read', 'Content-type' => 'image/png') + @dummy.save + end + + should "succeed" do + assert true + end + end + + context "and remove" do + setup do + @s3_mock = stub + @bucket_mock = stub + RightAws::S3.expects(:new).with("12345", "54321", {}).returns(@s3_mock) + @s3_mock.expects(:bucket).with("testing", true, "public-read").returns(@bucket_mock) + @key_mock = stub + @bucket_mock.expects(:key).at_least(2).returns(@key_mock) + @key_mock.expects(:delete) + @dummy.destroy_attached_files + end + + should "succeed" do + assert true + end + end + end + end + + context "An attachment with S3 storage and bucket defined as a Proc" do + setup do + rebuild_model :storage => :s3, + :bucket => lambda { |attachment| "bucket_#{attachment.instance.other}" }, + :s3_credentials => {:not => :important} + end + + should "get the right bucket name" do + assert "bucket_a", Dummy.new(:other => 'a').avatar.bucket_name + assert "bucket_b", Dummy.new(:other => 'b').avatar.bucket_name + end + end + + context "An attachment with S3 storage and specific s3 headers set" do + setup do + rebuild_model :storage => :s3, + :bucket => "testing", + :path => ":attachment/:style/:basename.:extension", + :s3_credentials => { + 'access_key_id' => "12345", + 'secret_access_key' => "54321" + }, + :s3_headers => {'Cache-Control' => 'max-age=31557600'} + end + + context "when assigned" do + setup do + @file = File.new(File.join(File.dirname(__FILE__), 'fixtures', '5k.png'), 'rb') + @dummy = Dummy.new + @dummy.avatar = @file + end + + teardown { @file.close } + + context "and saved" do + setup do + @s3_mock = stub + @bucket_mock = stub + RightAws::S3.expects(:new).with("12345", "54321", {}).returns(@s3_mock) + @s3_mock.expects(:bucket).with("testing", true, "public-read").returns(@bucket_mock) + @key_mock = stub + @bucket_mock.expects(:key).returns(@key_mock) + @key_mock.expects(:data=) + @key_mock.expects(:put).with(nil, + 'public-read', + 'Content-type' => 'image/png', + 'Cache-Control' => 'max-age=31557600') + @dummy.save + end + + should "succeed" do + assert true + end + end + end + end + + unless ENV["S3_TEST_BUCKET"].blank? + context "Using S3 for real, an attachment with S3 storage" do + setup do + rebuild_model :styles => { :thumb => "100x100", :square => "32x32#" }, + :storage => :s3, + :bucket => ENV["S3_TEST_BUCKET"], + :path => ":class/:attachment/:id/:style.:extension", + :s3_credentials => File.new(File.join(File.dirname(__FILE__), "s3.yml")) + + Dummy.delete_all + @dummy = Dummy.new + end + + should "be extended by the S3 module" do + assert Dummy.new.avatar.is_a?(Paperclip::Storage::S3) + end + + context "when assigned" do + setup do + @file = File.new(File.join(File.dirname(__FILE__), 'fixtures', '5k.png'), 'rb') + @dummy.avatar = @file + end + + teardown { @file.close } + + should "still return a Tempfile when sent #to_io" do + assert_equal Tempfile, @dummy.avatar.to_io.class + end + + context "and saved" do + setup do + @dummy.save + end + + should "be on S3" do + assert true + end + end + end + end + end +end diff --git a/test/thumbnail_test.rb b/test/thumbnail_test.rb new file mode 100644 index 0000000..11d8083 --- /dev/null +++ b/test/thumbnail_test.rb @@ -0,0 +1,177 @@ +require 'test/helper' + +class ThumbnailTest < Test::Unit::TestCase + + context "A Paperclip Tempfile" do + setup do + @tempfile = Paperclip::Tempfile.new("file.jpg") + end + + should "have its path contain a real extension" do + assert_equal ".jpg", File.extname(@tempfile.path) + end + + should "be a real Tempfile" do + assert @tempfile.is_a?(::Tempfile) + end + end + + context "Another Paperclip Tempfile" do + setup do + @tempfile = Paperclip::Tempfile.new("file") + end + + should "not have an extension if not given one" do + assert_equal "", File.extname(@tempfile.path) + end + + should "still be a real Tempfile" do + assert @tempfile.is_a?(::Tempfile) + end + end + + context "An image" do + setup do + @file = File.new(File.join(File.dirname(__FILE__), "fixtures", "5k.png"), 'rb') + end + + teardown { @file.close } + + [["600x600>", "434x66"], + ["400x400>", "400x61"], + ["32x32<", "434x66"] + ].each do |args| + context "being thumbnailed with a geometry of #{args[0]}" do + setup do + @thumb = Paperclip::Thumbnail.new(@file, :geometry => args[0]) + end + + should "start with dimensions of 434x66" do + cmd = %Q[identify -format "%wx%h" "#{@file.path}"] + assert_equal "434x66", `#{cmd}`.chomp + end + + should "report the correct target geometry" do + assert_equal args[0], @thumb.target_geometry.to_s + end + + context "when made" do + setup do + @thumb_result = @thumb.make + end + + should "be the size we expect it to be" do + cmd = %Q[identify -format "%wx%h" "#{@thumb_result.path}"] + assert_equal args[1], `#{cmd}`.chomp + end + end + end + end + + context "being thumbnailed at 100x50 with cropping" do + setup do + @thumb = Paperclip::Thumbnail.new(@file, :geometry => "100x50#") + end + + should "report its correct current and target geometries" do + assert_equal "100x50#", @thumb.target_geometry.to_s + assert_equal "434x66", @thumb.current_geometry.to_s + end + + should "report its correct format" do + assert_nil @thumb.format + end + + should "have whiny turned on by default" do + assert @thumb.whiny + end + + should "have convert_options set to nil by default" do + assert_equal nil, @thumb.convert_options + end + + should "send the right command to convert when sent #make" do + Paperclip.expects(:"`").with do |arg| + arg.match %r{convert\s+"#{File.expand_path(@thumb.file.path)}\[0\]"\s+-resize\s+\"x50\"\s+-crop\s+\"100x50\+114\+0\"\s+\+repage\s+".*?"} + end + @thumb.make + end + + should "create the thumbnail when sent #make" do + dst = @thumb.make + assert_match /100x50/, `identify "#{dst.path}"` + end + end + + context "being thumbnailed with convert options set" do + setup do + @thumb = Paperclip::Thumbnail.new(@file, + :geometry => "100x50#", + :convert_options => "-strip -depth 8") + end + + should "have convert_options value set" do + assert_equal "-strip -depth 8", @thumb.convert_options + end + + should "send the right command to convert when sent #make" do + Paperclip.expects(:"`").with do |arg| + arg.match %r{convert\s+"#{File.expand_path(@thumb.file.path)}\[0\]"\s+-resize\s+"x50"\s+-crop\s+"100x50\+114\+0"\s+\+repage\s+-strip\s+-depth\s+8\s+".*?"} + end + @thumb.make + end + + should "create the thumbnail when sent #make" do + dst = @thumb.make + assert_match /100x50/, `identify "#{dst.path}"` + end + + context "redefined to have bad convert_options setting" do + setup do + @thumb = Paperclip::Thumbnail.new(@file, + :geometry => "100x50#", + :convert_options => "-this-aint-no-option") + end + + should "error when trying to create the thumbnail" do + assert_raises(Paperclip::PaperclipError) do + @thumb.make + end + end + end + end + end + + context "A multipage PDF" do + setup do + @file = File.new(File.join(File.dirname(__FILE__), "fixtures", "twopage.pdf"), 'rb') + end + + teardown { @file.close } + + should "start with two pages with dimensions 612x792" do + cmd = %Q[identify -format "%wx%h" "#{@file.path}"] + assert_equal "612x792"*2, `#{cmd}`.chomp + end + + context "being thumbnailed at 100x100 with cropping" do + setup do + @thumb = Paperclip::Thumbnail.new(@file, :geometry => "100x100#", :format => :png) + end + + should "report its correct current and target geometries" do + assert_equal "100x100#", @thumb.target_geometry.to_s + assert_equal "612x792", @thumb.current_geometry.to_s + end + + should "report its correct format" do + assert_equal :png, @thumb.format + end + + should "create the thumbnail when sent #make" do + dst = @thumb.make + assert_match /100x100/, `identify "#{dst.path}"` + end + end + end +end