attachmerb_fu 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (36) hide show
  1. data/LICENSE +22 -0
  2. data/README +166 -0
  3. data/Rakefile +35 -0
  4. data/TODO +5 -0
  5. data/lib/amazon_s3.yml.tpl +14 -0
  6. data/lib/attachment_fu.rb +431 -0
  7. data/lib/attachmerb_fu.rb +446 -0
  8. data/lib/attachmerb_fu/backends/db_file_backend.rb +37 -0
  9. data/lib/attachmerb_fu/backends/file_system_backend.rb +95 -0
  10. data/lib/attachmerb_fu/backends/s3_backend.rb +307 -0
  11. data/lib/attachmerb_fu/merbtasks.rb +6 -0
  12. data/lib/attachmerb_fu/processors/image_science_processor.rb +60 -0
  13. data/lib/attachmerb_fu/processors/mini_magick_processor.rb +54 -0
  14. data/lib/attachmerb_fu/processors/rmagick_processor.rb +51 -0
  15. data/lib/geometry.rb +93 -0
  16. data/lib/tempfile_ext.rb +9 -0
  17. data/lib/test/amazon_s3.yml +6 -0
  18. data/lib/test/backends/db_file_test.rb +16 -0
  19. data/lib/test/backends/file_system_test.rb +80 -0
  20. data/lib/test/backends/remote/s3_test.rb +103 -0
  21. data/lib/test/base_attachment_tests.rb +57 -0
  22. data/lib/test/basic_test.rb +64 -0
  23. data/lib/test/database.yml +18 -0
  24. data/lib/test/extra_attachment_test.rb +57 -0
  25. data/lib/test/fixtures/attachment.rb +127 -0
  26. data/lib/test/fixtures/files/fake/rails.png +0 -0
  27. data/lib/test/fixtures/files/foo.txt +1 -0
  28. data/lib/test/fixtures/files/rails.png +0 -0
  29. data/lib/test/geometry_test.rb +101 -0
  30. data/lib/test/processors/image_science_test.rb +31 -0
  31. data/lib/test/processors/mini_magick_test.rb +31 -0
  32. data/lib/test/processors/rmagick_test.rb +241 -0
  33. data/lib/test/schema.rb +86 -0
  34. data/lib/test/test_helper.rb +142 -0
  35. data/lib/test/validation_test.rb +55 -0
  36. metadata +107 -0
@@ -0,0 +1,446 @@
1
+ require "geometry"
2
+ require "tempfile_ext"
3
+
4
+ module AttachmerbFu # :nodoc:
5
+ @@default_processors = %w(ImageScience Rmagick MiniMagick)
6
+ @@tempfile_path = File.join(Merb.root, 'tmp', 'attachment_fu')
7
+ @@content_types = ['image/jpeg', 'image/pjpeg', 'image/gif', 'image/png', 'image/x-png', 'image/jpg']
8
+ attr_reader :content_types, :tempfile_path, :default_processors
9
+ attr_writer :tempfile_path
10
+
11
+ [:content_types, :tempfile_path, :default_processors].each do |m|
12
+ class_eval "def self.#{m}() @@#{m} end"
13
+ end
14
+
15
+ class ThumbnailError < StandardError; end
16
+ class AttachmentError < StandardError; end
17
+
18
+ module ActMethods
19
+ # Options:
20
+ # * <tt>:content_type</tt> - Allowed content types. Allows all by default. Use :image to allow all standard image types.
21
+ # * <tt>:min_size</tt> - Minimum size allowed. 1 byte is the default.
22
+ # * <tt>:max_size</tt> - Maximum size allowed. 1.megabyte is the default.
23
+ # * <tt>:size</tt> - Range of sizes allowed. (1..1.megabyte) is the default. This overrides the :min_size and :max_size options.
24
+ # * <tt>:resize_to</tt> - Used by RMagick to resize images. Pass either an array of width/height, or a geometry string.
25
+ # * <tt>:thumbnails</tt> - Specifies a set of thumbnails to generate. This accepts a hash of filename suffixes and RMagick resizing options.
26
+ # * <tt>:thumbnail_class</tt> - Set what class to use for thumbnails. This attachment class is used by default.
27
+ # * <tt>:path_prefix</tt> - path to store the uploaded files. Uses public/#{table_name} by default for the filesystem, and just #{table_name}
28
+ # for the S3 backend. Setting this sets the :storage to :file_system.
29
+ # * <tt>:storage</tt> - Use :file_system to specify the attachment data is stored with the file system. Defaults to :db_system.
30
+ #
31
+ # Examples:
32
+ # has_attachment :max_size => 1.kilobyte
33
+ # has_attachment :size => 1.megabyte..2.megabytes
34
+ # has_attachment :content_type => 'application/pdf'
35
+ # has_attachment :content_type => ['application/pdf', 'application/msword', 'text/plain']
36
+ # has_attachment :content_type => :image, :resize_to => [50,50]
37
+ # has_attachment :content_type => ['application/pdf', :image], :resize_to => 'x50'
38
+ # has_attachment :thumbnails => { :thumb => [50, 50], :geometry => 'x50' }
39
+ # has_attachment :storage => :file_system, :path_prefix => 'public/files'
40
+ # has_attachment :storage => :file_system, :path_prefix => 'public/files',
41
+ # :content_type => :image, :resize_to => [50,50]
42
+ # has_attachment :storage => :file_system, :path_prefix => 'public/files',
43
+ # :thumbnails => { :thumb => [50, 50], :geometry => 'x50' }
44
+ # has_attachment :storage => :s3
45
+ def has_attachment(options = {})
46
+ # this allows you to redefine the acts' options for each subclass, however
47
+ options[:min_size] ||= 1
48
+ options[:max_size] ||= 1024*1024*1024
49
+ options[:size] ||= (options[:min_size]..options[:max_size])
50
+ options[:thumbnails] ||= {}
51
+ options[:thumbnail_class] ||= self
52
+ options[:s3_access] ||= :public_read
53
+ options[:content_type] = [options[:content_type]].flatten.collect! { |t| t == :image ? AttachmerbFu.content_types : t }.flatten unless options[:content_type].nil?
54
+
55
+ unless options[:thumbnails].is_a?(Hash)
56
+ raise ArgumentError, ":thumbnails option should be a hash: e.g. :thumbnails => { :foo => '50x50' }"
57
+ end
58
+
59
+ # doing these shenanigans so that #attachment_options is available to processors and backends
60
+ class_inheritable_accessor :attachment_options
61
+ self.attachment_options = options
62
+
63
+ # only need to define these once on a class
64
+ unless included_modules.include?(InstanceMethods)
65
+ attr_accessor :thumbnail_resize_options
66
+
67
+ attachment_options[:storage] ||= :file_system
68
+ attachment_options[:path_prefix] ||= attachment_options[:file_system_path]
69
+ if attachment_options[:path_prefix].nil?
70
+ attachment_options[:path_prefix] = File.join("public", table.name)
71
+ end
72
+ attachment_options[:path_prefix] = attachment_options[:path_prefix][1..-1] if options[:path_prefix][0] == '/'
73
+
74
+ has_many :thumbnails, :class_name => attachment_options[:thumbnail_class].to_s, :foreign_key => 'parent_id'
75
+ belongs_to :parent, :class_name => table.klass.to_s, :foreign_key => 'parent_id'
76
+
77
+ before_update :rename_file
78
+ before_destroy :destroy_thumbnails
79
+
80
+ before_validation :set_size_from_temp_path
81
+ after_save :after_process_attachment
82
+ after_destroy :destroy_file
83
+ extend ClassMethods
84
+ include InstanceMethods
85
+
86
+ backend = "#{options[:storage].to_s.classify}Backend"
87
+ require "attachmerb_fu/backends/#{backend.snake_case}"
88
+ include AttachmerbFu::Backends.const_get(backend)
89
+
90
+ case attachment_options[:processor]
91
+ when :none
92
+ when nil
93
+ processors = AttachmerbFu.default_processors.dup
94
+ begin
95
+ if processors.any?
96
+ attachment_options[:processor] = "#{processors.first}Processor"
97
+ require "attachmerb_fu/processors/#{attachment_options[:processor].snake_case}"
98
+
99
+ include AttachmerbFu::Processors.const_get(attachment_options[:processor])
100
+ end
101
+ rescue LoadError
102
+ processors.shift
103
+ retry
104
+ end
105
+ else
106
+ begin
107
+ processor = "#{options[:processor].to_s.classify}Processor"
108
+ require "attachmerb_fu/processors/#{processor.snake_case}"
109
+ include AttachmerbFu::Processors.const_get(processor)
110
+ rescue LoadError
111
+ puts "Problems loading #{processor}: #{$!}"
112
+ end
113
+ end
114
+
115
+ after_validation :process_attachment
116
+ end
117
+ end
118
+ end
119
+
120
+ module ClassMethods
121
+ def content_types
122
+ AttachmerbFu.content_types
123
+ end
124
+
125
+ # Performs common validations for attachment models.
126
+ def validates_as_attachment
127
+ validates_presence_of :size, :content_type, :filename
128
+ validate :attachment_attributes_valid?
129
+ end
130
+
131
+ # Returns true or false if the given content type is recognized as an image.
132
+ def image?(content_type)
133
+ content_types.include?(content_type)
134
+ end
135
+
136
+ # Callback after an image has been resized.
137
+ #
138
+ # class Foo < ActiveRecord::Base
139
+ # acts_as_attachment
140
+ # after_resize do |record, img|
141
+ # record.aspect_ratio = img.columns.to_f / img.rows.to_f
142
+ # end
143
+ # end
144
+ def after_resize(&block)
145
+ write_inheritable_array(:after_resize, [block])
146
+ end
147
+
148
+ # Callback after an attachment has been saved either to the file system or the DB.
149
+ # Only called if the file has been changed, not necessarily if the record is updated.
150
+ #
151
+ # class Foo < ActiveRecord::Base
152
+ # acts_as_attachment
153
+ # after_attachment_saved do |record|
154
+ # ...
155
+ # end
156
+ # end
157
+ def after_attachment_saved(&block)
158
+ write_inheritable_array(:after_attachment_saved, [block])
159
+ end
160
+
161
+ # Callback before a thumbnail is saved. Use this to pass any necessary extra attributes that may be required.
162
+ #
163
+ # class Foo < ActiveRecord::Base
164
+ # acts_as_attachment
165
+ # before_thumbnail_saved do |record, thumbnail|
166
+ # ...
167
+ # end
168
+ # end
169
+ def before_thumbnail_saved(&block)
170
+ write_inheritable_array(:before_thumbnail_saved, [block])
171
+ end
172
+
173
+ # Get the thumbnail class, which is the current attachment class by default.
174
+ # Configure this with the :thumbnail_class option.
175
+ def thumbnail_class
176
+ attachment_options[:thumbnail_class] = attachment_options[:thumbnail_class].constantize unless attachment_options[:thumbnail_class].is_a?(Class)
177
+ attachment_options[:thumbnail_class]
178
+ end
179
+
180
+ # Copies the given file path to a new tempfile, returning the closed tempfile.
181
+ def copy_to_temp_file(file, temp_base_name)
182
+ returning Tempfile.new(temp_base_name, AttachmerbFu.tempfile_path) do |tmp|
183
+ tmp.close
184
+ FileUtils.cp file, tmp.path
185
+ end
186
+ end
187
+
188
+ # Writes the given data to a new tempfile, returning the closed tempfile.
189
+ def write_to_temp_file(data, temp_base_name)
190
+ returning Tempfile.new(temp_base_name, AttachmerbFu.tempfile_path) do |tmp|
191
+ tmp.binmode
192
+ tmp.write data
193
+ tmp.close
194
+ end
195
+ end
196
+ end
197
+
198
+ module InstanceMethods
199
+ # Checks whether the attachment's content type is an image content type
200
+ def image?
201
+ self.class.image?(content_type)
202
+ end
203
+
204
+ # Returns true/false if an attachment is thumbnailable. A thumbnailable attachment has an image content type and the parent_id attribute.
205
+ def thumbnailable?
206
+ image? && respond_to?(:parent_id) && parent_id.nil?
207
+ end
208
+
209
+ # Returns the class used to create new thumbnails for this attachment.
210
+ def thumbnail_class
211
+ self.class.thumbnail_class
212
+ end
213
+
214
+ # Gets the thumbnail name for a filename. 'foo.jpg' becomes 'foo_thumbnail.jpg'
215
+ def thumbnail_name_for(thumbnail = nil)
216
+ return filename if thumbnail.blank?
217
+ ext = nil
218
+ basename = filename.gsub /\.\w+$/ do |s|
219
+ ext = s; ''
220
+ end
221
+ # ImageScience doesn't create gif thumbnails, only pngs
222
+ ext.sub!(/gif$/, 'png') if attachment_options[:processor] == "ImageScienceProcessor"
223
+ "#{basename}_#{thumbnail}#{ext}"
224
+ end
225
+
226
+ # Creates or updates the thumbnail for the current attachment.
227
+ def create_or_update_thumbnail(temp_file, file_name_suffix, *size)
228
+ thumbnailable? || raise(ThumbnailError.new("Can't create a thumbnail if the content type is not an image or there is no parent_id column"))
229
+ returning find_or_initialize_thumbnail(file_name_suffix) do |thumb|
230
+ thumb.attributes = {
231
+ :content_type => content_type,
232
+ :filename => thumbnail_name_for(file_name_suffix),
233
+ :temp_path => temp_file,
234
+ :thumbnail_resize_options => size
235
+ }
236
+ callback_with_args :before_thumbnail_saved, thumb
237
+ thumb.save!
238
+ end
239
+ end
240
+
241
+ # Sets the content type.
242
+ def content_type=(new_type)
243
+ write_attribute :content_type, new_type.to_s.strip
244
+ end
245
+
246
+ # Sanitizes a filename.
247
+ def filename=(new_name)
248
+ write_attribute :filename, sanitize_filename(new_name)
249
+ end
250
+
251
+ # Returns the width/height in a suitable format for the image_tag helper: (100x100)
252
+ def image_size
253
+ [width.to_s, height.to_s] * 'x'
254
+ end
255
+
256
+ # Returns true if the attachment data will be written to the storage system on the next save
257
+ def save_attachment?
258
+ File.file?(temp_path.to_s)
259
+ end
260
+
261
+ # nil placeholder in case this field is used in a form.
262
+ def uploaded_data() nil; end
263
+
264
+ # This method handles the uploaded file object. If you set the field name to uploaded_data, you don't need
265
+ # any special code in your controller.
266
+ #
267
+ # <% form_for :attachment, :html => { :multipart => true } do |f| -%>
268
+ # <p><%= f.file_field :uploaded_data %></p>
269
+ # <p><%= submit_tag :Save %>
270
+ # <% end -%>
271
+ #
272
+ # @attachment = Attachment.create! params[:attachment]
273
+ #
274
+ # TODO: Allow it to work with Merb tempfiles too.
275
+ def uploaded_data=(file_data)
276
+
277
+ return nil if file_data.nil? || file_data["size"] == 0
278
+ self.content_type = file_data["content_type"]
279
+ self.filename = file_data["filename"] if respond_to?(:filename)
280
+ data = file_data["tempfile"]
281
+ if data.is_a?(StringIO)
282
+ data.rewind
283
+ self.temp_data = data.read
284
+ else
285
+ self.temp_path = data.path
286
+ end
287
+ end
288
+
289
+ # Gets the latest temp path from the collection of temp paths. While working with an attachment,
290
+ # multiple Tempfile objects may be created for various processing purposes (resizing, for example).
291
+ # An array of all the tempfile objects is stored so that the Tempfile instance is held on to until
292
+ # it's not needed anymore. The collection is cleared after saving the attachment.
293
+ def temp_path
294
+ p = temp_paths.first
295
+ p.respond_to?(:path) ? p.path : p.to_s
296
+ end
297
+
298
+ # Gets an array of the currently used temp paths. Defaults to a copy of #full_filename.
299
+ def temp_paths
300
+ @temp_paths ||= (new_record? || !File.exist?(full_filename)) ? [] : [copy_to_temp_file(full_filename)]
301
+ end
302
+
303
+ # Adds a new temp_path to the array. This should take a string or a Tempfile. This class makes no
304
+ # attempt to remove the files, so Tempfiles should be used. Tempfiles remove themselves when they go out of scope.
305
+ # You can also use string paths for temporary files, such as those used for uploaded files in a web server.
306
+ def temp_path=(value)
307
+ temp_paths.unshift value
308
+ temp_path
309
+ end
310
+
311
+ # Gets the data from the latest temp file. This will read the file into memory.
312
+ def temp_data
313
+ save_attachment? ? File.read(temp_path) : nil
314
+ end
315
+
316
+ # Writes the given data to a Tempfile and adds it to the collection of temp files.
317
+ def temp_data=(data)
318
+ self.temp_path = write_to_temp_file data unless data.nil?
319
+ end
320
+
321
+ # Copies the given file to a randomly named Tempfile.
322
+ def copy_to_temp_file(file)
323
+ self.class.copy_to_temp_file file, random_tempfile_filename
324
+ end
325
+
326
+ # Writes the given file to a randomly named Tempfile.
327
+ def write_to_temp_file(data)
328
+ self.class.write_to_temp_file data, random_tempfile_filename
329
+ end
330
+
331
+ # Stub for creating a temp file from the attachment data. This should be defined in the backend module.
332
+ def create_temp_file() end
333
+
334
+ # Allows you to work with a processed representation (RMagick, ImageScience, etc) of the attachment in a block.
335
+ #
336
+ # @attachment.with_image do |img|
337
+ # self.data = img.thumbnail(100, 100).to_blob
338
+ # end
339
+ #
340
+ def with_image(&block)
341
+ self.class.with_image(temp_path, &block)
342
+ end
343
+
344
+ protected
345
+ # Generates a unique filename for a Tempfile.
346
+ def random_tempfile_filename
347
+ "#{rand Time.now.to_i}#{filename || 'attachment'}"
348
+ end
349
+
350
+ def sanitize_filename(filename)
351
+ returning filename.strip do |name|
352
+ # NOTE: File.basename doesn't work right with Windows paths on Unix
353
+ # get only the filename, not the whole path
354
+ name.gsub! /^.*(\\|\/)/, ''
355
+
356
+ # Finally, replace all non alphanumeric, underscore or periods with underscore
357
+ name.gsub! /[^\w\.\-]/, '_'
358
+ end
359
+ end
360
+
361
+ # before_validation callback.
362
+ def set_size_from_temp_path
363
+ self.size = File.size(temp_path) if save_attachment?
364
+ end
365
+
366
+ # validates the size and content_type attributes according to the current model's options
367
+ def attachment_attributes_valid?
368
+ [:size, :content_type].each do |attr_name|
369
+ enum = attachment_options[attr_name]
370
+ errors.add attr_name, ActiveRecord::Errors.default_error_messages[:inclusion] unless enum.nil? || enum.include?(send(attr_name))
371
+ end
372
+ end
373
+
374
+ # Initializes a new thumbnail with the given suffix.
375
+ def find_or_initialize_thumbnail(file_name_suffix)
376
+ if respond_to?(:parent_id)
377
+ args = { :thumbnail => file_name_suffix.to_s, :parent_id => id}
378
+ else
379
+ args = { :thumbnail => file_name_suffix.to_s}
380
+ end
381
+
382
+ thumbnail_class.first(args) || thumbnail_class.new(args)
383
+ end
384
+
385
+ # Stub for a #process_attachment method in a processor
386
+ def process_attachment
387
+ @saved_attachment = save_attachment?
388
+ end
389
+
390
+ # Cleans up after processing. Thumbnails are created, the attachment is stored to the backend, and the temp_paths are cleared.
391
+ def after_process_attachment
392
+ if @saved_attachment
393
+ if respond_to?(:process_attachment_with_processing) && thumbnailable? && !attachment_options[:thumbnails].blank? && parent_id.nil?
394
+ temp_file = temp_path || create_temp_file
395
+ attachment_options[:thumbnails].each { |suffix, size| create_or_update_thumbnail(temp_file, suffix, *size) }
396
+ end
397
+ save_to_storage
398
+ @temp_paths.clear
399
+ @saved_attachment = nil
400
+ # callback :after_attachment_saved
401
+ end
402
+ end
403
+
404
+ # Resizes the given processed img object with either the attachment resize options or the thumbnail resize options.
405
+ def resize_image_or_thumbnail!(img)
406
+ if (!respond_to?(:parent_id) || parent_id.nil?) && attachment_options[:resize_to] # parent image
407
+ resize_image(img, attachment_options[:resize_to])
408
+ elsif thumbnail_resize_options # thumbnail
409
+ resize_image(img, thumbnail_resize_options)
410
+ end
411
+ end
412
+
413
+ # Yanked from ActiveRecord::Callbacks, modified so I can pass args to the callbacks besides self.
414
+ # Only accept blocks, however
415
+ def callback_with_args(method, arg = self)
416
+ # notify(method)
417
+ #
418
+ # result = nil
419
+ # callbacks_for(method).each do |callback|
420
+ # result = callback.call(self, arg)
421
+ # return false if result == false
422
+ # end
423
+ #
424
+ # return result
425
+ end
426
+
427
+ # Removes the thumbnails for the attachment, if it has any
428
+ def destroy_thumbnails
429
+ self.thumbnails.each { |thumbnail| thumbnail.destroy } if thumbnailable?
430
+ end
431
+ end
432
+ end
433
+
434
+
435
+ # make sure we're running inside Merb
436
+ if defined?(Merb::Plugins)
437
+
438
+ # Merb gives you a Merb::Plugins.config hash...feel free to put your stuff in your piece of it
439
+ Merb::Plugins.config[:attachmerb_fu] = {
440
+ }
441
+
442
+ #Merb::Plugins.add_rakefiles "attachmerb_fu/merbtasks"
443
+
444
+ DataMapper::Base.send(:extend, AttachmerbFu::ActMethods)
445
+
446
+ end
@@ -0,0 +1,37 @@
1
+ module AttachmerbFu # :nodoc:
2
+ module Backends
3
+ # Methods for DB backed attachments
4
+ module DbFileBackend
5
+ def self.included(base) #:nodoc:
6
+ Object.const_set(:DbFile, Class.new(ActiveRecord::Base)) unless Object.const_defined?(:DbFile)
7
+ base.belongs_to :db_file, :class_name => '::DbFile', :foreign_key => 'db_file_id'
8
+ end
9
+
10
+ # Creates a temp file with the current db data.
11
+ def create_temp_file
12
+ write_to_temp_file current_data
13
+ end
14
+
15
+ # Gets the current data from the database
16
+ def current_data
17
+ db_file.data
18
+ end
19
+
20
+ protected
21
+ # Destroys the file. Called in the after_destroy callback
22
+ def destroy_file
23
+ db_file.destroy if db_file
24
+ end
25
+
26
+ # Saves the data to the DbFile model
27
+ def save_to_storage
28
+ if save_attachment?
29
+ (db_file || build_db_file).data = temp_data
30
+ db_file.save!
31
+ self.class.update_all ['db_file_id = ?', self.db_file_id = db_file.id], ['id = ?', id]
32
+ end
33
+ true
34
+ end
35
+ end
36
+ end
37
+ end