attachment_saver 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (41) hide show
  1. data/.gitignore +3 -0
  2. data/MIT-LICENSE +19 -0
  3. data/README +137 -0
  4. data/Rakefile +16 -0
  5. data/attachment_saver.gemspec +41 -0
  6. data/init.rb +1 -0
  7. data/lib/attachment_saver.rb +171 -0
  8. data/lib/attachment_saver/version.rb +3 -0
  9. data/lib/attachment_saver_errors.rb +3 -0
  10. data/lib/datastores/file_system.rb +189 -0
  11. data/lib/datastores/in_column.rb +49 -0
  12. data/lib/misc/extended_tempfile.rb +12 -0
  13. data/lib/misc/file_size.rb +5 -0
  14. data/lib/misc/image_science_extensions.rb +102 -0
  15. data/lib/misc/mini_magick_extensions.rb +89 -0
  16. data/lib/processors/image.rb +187 -0
  17. data/lib/processors/image_science.rb +94 -0
  18. data/lib/processors/mini_magick.rb +103 -0
  19. data/lib/processors/r_magick.rb +120 -0
  20. data/test/attachment_saver_test.rb +162 -0
  21. data/test/database.yml +3 -0
  22. data/test/file_system_datastore_test.rb +468 -0
  23. data/test/fixtures/broken.jpg +1 -0
  24. data/test/fixtures/emptyextension. +0 -0
  25. data/test/fixtures/noextension +0 -0
  26. data/test/fixtures/test.jpg +0 -0
  27. data/test/fixtures/test.js +1 -0
  28. data/test/fixtures/wrongextension.png +0 -0
  29. data/test/image_fixtures.rb +69 -0
  30. data/test/image_operations.rb +114 -0
  31. data/test/image_processor_test.rb +67 -0
  32. data/test/image_processor_test_common.rb +81 -0
  33. data/test/image_science_processor_test.rb +20 -0
  34. data/test/in_column_datastore_test.rb +115 -0
  35. data/test/mini_magick_processor_test.rb +20 -0
  36. data/test/model_test.rb +205 -0
  37. data/test/public/.empty +0 -0
  38. data/test/rmagick_processor_test.rb +20 -0
  39. data/test/schema.rb +41 -0
  40. data/test/test_helper.rb +49 -0
  41. metadata +223 -0
@@ -0,0 +1,3 @@
1
+ test/log/*
2
+ test/tmp/*
3
+ test/attachment_saver_test_sqlite.db
@@ -0,0 +1,19 @@
1
+ Copyright (c) 2007 Will Bryant, Sekuda Ltd
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in
11
+ all copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19
+ THE SOFTWARE.
data/README ADDED
@@ -0,0 +1,137 @@
1
+ AttachmentSaver
2
+ ===============
3
+
4
+ This plugin implements attachment storage and processing, integrated with
5
+ ActiveRecord models and Ruby CGI/Rails-style uploads. Image processing
6
+ operations including a number of different resizing & thumbnailing modes are
7
+ provided, and the architecture simplifies clean implementation of other types
8
+ of processing. Errors are carefully handled to minimize the possibility of
9
+ broken uploads leaving incomplete or corrupt data.
10
+
11
+ RMagick, MiniMagick, and ImageScience image processors are supported.
12
+
13
+
14
+ Compatibility
15
+ =============
16
+
17
+ Currently tested against Rails 3.2.13 and 3.1.8, on Ruby 1.8.7 and 2.0.0p0.
18
+ Was also tested compatible with 2.3.14 and 3.0.17.
19
+
20
+
21
+ Examples
22
+ ========
23
+
24
+ A 'dumb' attachment store that saves minimal info
25
+ -------------------------------------------------
26
+
27
+ # in your model:
28
+ class SomeModel
29
+ saves_attachment
30
+ end
31
+
32
+ # in your database schema:
33
+ create_table :some_model do |t|
34
+ t.string :storage_key, :null => false
35
+ end
36
+
37
+ # in your new/update forms:
38
+ file_field :some_model, :uploaded_data
39
+
40
+ # no special controller handling is required.
41
+
42
+
43
+ A 'dumb' attachment store that saves full file info automatically
44
+ -----------------------------------------------------------------
45
+
46
+ # as for above, but in the schema:
47
+ create_table :some_model do |t|
48
+ t.string :storage_key, :null => false
49
+ t.string :original_filename, :null => false # as sent by the user's browser, with IE path removed
50
+ t.string :content_type, :null => false # as sent by the user's browser
51
+ t.integer :size, :null => false # file size in bytes
52
+ t.timestamps
53
+ end
54
+
55
+
56
+ An image store that automatically saves width and height and corrects mime types & file extensions
57
+ --------------------------------------------------------------------------------------------------
58
+
59
+ # in your models:
60
+ class Image
61
+ saves_attachment :processor => 'rmagick'
62
+ end
63
+
64
+ # in your database schema:
65
+ create_table :photos do |t|
66
+ t.string :storage_key, :null => false
67
+ t.string :original_filename, :null => false # as sent by the user's browser, with IE path removed
68
+ t.string :content_type, :null => false # corrected if the user's browser sent a mime type that didn't match the image
69
+ t.integer :size, :null => false # file size in bytes
70
+ t.integer :width, :null => false # set by the image processors
71
+ t.integer :height, :null => false # ditto
72
+ t.timestamps
73
+ end
74
+
75
+
76
+ An image store that resizes images to produce thumbnails etc.
77
+ -------------------------------------------------------------
78
+
79
+ # in your models:
80
+ class Photo
81
+ saves_attachment :processor => 'RMagick', :derived_class => 'Thumbnail',
82
+ :formats => {:page_width => '520x', # ImageMagick-style format string
83
+ :small => [:shrink_to_fit, 250, 250], # or more explicit [operation, width, height] format
84
+ :nav => [:cover_and_crop, 50, 50]} # lots of useful resize and/or crop modes available
85
+ end
86
+
87
+ class Thumbnail
88
+ saves_attachment
89
+ end
90
+
91
+ # in your database schema:
92
+ create_table :photos do |t|
93
+ t.string :storage_key, :null => false
94
+ t.string :original_filename, :null => false # as sent by the user's browser, with IE path removed
95
+ t.string :content_type, :null => false # corrected if the user's browser sent a mime type that didn't match the image
96
+ t.integer :size, :null => false # file size in bytes
97
+ t.integer :width, :null => false # set by the image processors
98
+ t.integer :height, :null => false # ditto
99
+ t.timestamps
100
+ end
101
+
102
+ create_table :thumbnails do |t|
103
+ t.string :original_type, :null => false # multiple models can save their derived images as thumbnails
104
+ t.integer :original_id, :null => false
105
+ t.string :format_name, :null => false # from your :formats - eg. 'small', 'nav'
106
+ t.string :storage_key, :null => false # still required (but will be based on the original's, for convenience)
107
+ t.string :content_type, :null => false # these fields are optional (as they are for Photo)
108
+ t.integer :size, :null => false
109
+ t.integer :width, :null => false # but width and height are generally needed for layout
110
+ t.integer :height, :null => false
111
+ t.timestamps
112
+ end
113
+
114
+
115
+ A custom image-processing format using your image-processor's features
116
+ ----------------------------------------------------------------------
117
+
118
+ # in a file in your lib/ directory that's required in somewhere:
119
+ module AttachmentSaver::Processors::RMagick::Operations # or MiniMagick::Operations or ImageScience::Operations - see lib/processors
120
+ # this module is mixed in to the actual image objects built by the processor, so you can call its' methods directly
121
+ def wavy_black_and_white(wave_height, wave_length, &block)
122
+ # RMagick returns the new object; MiniMagick acts on the same object (so you must dup); ImageScience yields; so, look at the existing lib/processors to see the appropriate pattern
123
+ image = quantize(256, Magick::GRAYColorspace).wave(wave_height, wave_length)
124
+
125
+ # mix the operations in to the new image, for reuse
126
+ image.extend Operations
127
+
128
+ # yield up the new image
129
+ block.call(image)
130
+ end
131
+ end
132
+
133
+ # in your models:
134
+ class Image
135
+ saves_attachment :processor => 'RMagick', :derived_class => 'SpecialImage',
136
+ :formats => {:flashback => [:wavy_and_black_and_white, 10, 200]}
137
+ end
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env rake
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rake'
5
+ require 'rake/testtask'
6
+
7
+ desc 'Default: run unit tests.'
8
+ task :default => :test
9
+
10
+ desc 'Test the columns_on_demand plugin.'
11
+ Rake::TestTask.new(:test) do |t|
12
+ t.libs << 'lib'
13
+ t.libs << 'test'
14
+ t.pattern = 'test/*_test.rb'
15
+ t.verbose = true
16
+ end
@@ -0,0 +1,41 @@
1
+ # -*- encoding: utf-8 -*-
2
+ require File.expand_path('../lib/attachment_saver/version', __FILE__)
3
+
4
+ spec = Gem::Specification.new do |gem|
5
+ gem.name = 'attachment_saver'
6
+ gem.version = AttachmentSaver::VERSION
7
+ gem.summary = "Saves attachments in files, models, or columns."
8
+ gem.description = <<-EOF
9
+ This plugin implements attachment storage and processing, integrated with
10
+ ActiveRecord models and Ruby CGI/Rails-style uploads. Image processing
11
+ operations including a number of different resizing & thumbnailing modes are
12
+ provided, and the architecture simplifies clean implementation of other types
13
+ of processing. Errors are carefully handled to minimize the possibility of
14
+ broken uploads leaving incomplete or corrupt data.
15
+
16
+ RMagick, MiniMagick, and ImageScience image processors are supported.
17
+
18
+
19
+ Compatibility
20
+ =============
21
+
22
+ Currently tested against Rails 3.2.13 and 3.1.8, on Ruby 1.8.7 and 2.0.0p0.
23
+ Was also tested compatible with 2.3.14 and 3.0.17.
24
+ EOF
25
+ gem.has_rdoc = false
26
+ gem.author = "Will Bryant"
27
+ gem.email = "will.bryant@gmail.com"
28
+ gem.homepage = "http://github.com/willbryant/attachment_saver"
29
+
30
+ gem.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
31
+ gem.files = `git ls-files`.split("\n")
32
+ gem.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
33
+ gem.require_path = "lib"
34
+
35
+ gem.add_dependency "activerecord"
36
+ gem.add_development_dependency "rake"
37
+ gem.add_development_dependency "image_science"
38
+ gem.add_development_dependency "rmagick"
39
+ gem.add_development_dependency "mini_magick"
40
+ gem.add_development_dependency "sqlite3"
41
+ end
data/init.rb ADDED
@@ -0,0 +1 @@
1
+ require 'attachment_saver'
@@ -0,0 +1,171 @@
1
+ require 'attachment_saver_errors'
2
+ require 'misc/file_size'
3
+ require 'tmpdir'
4
+
5
+ module AttachmentSaver
6
+ module BaseMethods
7
+ def saves_attachment(options = {})
8
+ extend ClassMethods
9
+ include InstanceMethods
10
+
11
+ class_attribute :attachment_options
12
+ self.attachment_options = options
13
+
14
+ attachment_options[:datastore] ||= 'file_system'
15
+ require "datastores/#{attachment_options[:datastore].to_s.underscore}"
16
+ include DataStores.const_get(attachment_options[:datastore].to_s.classify)
17
+ before_validation :before_validate_attachment # this callback does things like override the content-type based on the actual file data
18
+ before_save :save_attachment # this callback is where most of the goodness happens; note that it runs before save, so that it prevents the record being saved if processing raises; this is why our filenames can't be based on the instance ID
19
+ after_save :tidy_attachment
20
+ after_save :close_open_file
21
+ after_destroy :delete_attachment
22
+
23
+ if attachment_options[:formats] && reflect_on_association(:formats).nil? # this allows you to override our definition of the sizes association by simply defining it before calling has_attachment
24
+ attachment_options[:processor] ||= 'image_science'
25
+ attachment_options[:derived_class] ||= DerivedImage
26
+ has_many :formats, :as => :original, :class_name => attachment_options[:derived_class].to_s, :dependent => :destroy
27
+ after_save :save_updated_derived_children
28
+ end
29
+
30
+ if attachment_options[:processor]
31
+ unless Object.const_defined?(:Processors) && Processors.const_defined?(attachment_options[:processor].to_s.classify)
32
+ require "processors/#{attachment_options[:processor].to_s.underscore}"
33
+ end
34
+ include Processors.const_get(attachment_options[:processor].to_s.classify)
35
+ end
36
+ end
37
+ end
38
+
39
+ module ClassMethods
40
+ # currently present only for the benefit of extensions
41
+ end
42
+
43
+ module InstanceMethods
44
+ def uploaded_data=(uploaded)
45
+ # we don't go ahead and process the upload just yet - in particular, we need to wait
46
+ # until we have all the attributes, and then until validation passes - so we just retain
47
+ # the data or file reference for now.
48
+ if uploaded.is_a?(String) # we allow people to upload into the file field using a normal input element (eg. a textarea)
49
+ return if uploaded.blank? # this handles the case when a form has a file field but no file is selected - most browsers submit an empty string then (annoyingly)
50
+ @uploaded_data = uploaded
51
+ @uploaded_file = nil
52
+ elsif uploaded.is_a?(StringIO)
53
+ uploaded.rewind
54
+ @uploaded_data = uploaded.read
55
+ @uploaded_file = nil
56
+ elsif uploaded
57
+ @uploaded_data = nil
58
+ @uploaded_file = uploaded
59
+ else
60
+ @uploaded_data = @uploaded_file = @save_upload = nil
61
+ return
62
+ end
63
+ @save_upload = true
64
+
65
+ self.size = uploaded.size if respond_to?(:size=)
66
+ self.content_type = uploaded.content_type.strip.downcase if respond_to?(:content_type=) && uploaded.respond_to?(:content_type)
67
+ self.original_filename = trim_original_filename(uploaded.original_filename) if respond_to?(:original_filename=) && uploaded.respond_to?(:original_filename)
68
+ end
69
+
70
+ def uploaded_data
71
+ if @uploaded_data.nil?
72
+ if @uploaded_file.nil?
73
+ nil
74
+ else
75
+ @uploaded_file.rewind
76
+ @uploaded_file.read
77
+ end
78
+ else
79
+ @uploaded_data
80
+ end
81
+ end
82
+
83
+ def uploaded_file
84
+ unless @uploaded_data.nil?
85
+ # if we have a processor, we need to get the uploaded data into a file at some point
86
+ # so it can be processed. we take advantage of the fact that our file backend knows
87
+ # how to hardlink temporary files into their final location (rather than copying) to
88
+ # simplify things without introducing an extra file copy (so long as we put the temp
89
+ # file in the right place); of course, for non-file backends, this file will be only
90
+ # temporary in any case - so doing this here represents no extra overhead (remember,
91
+ # uploaded files over the magic size built into the CGI module are saved to files in
92
+ # the first place, so we know that the overhead here is minimal anyway).
93
+ FileUtils.mkdir_p(tempfile_directory)
94
+ temp = Tempfile.new("asutemp", tempfile_directory)
95
+ temp.binmode
96
+ temp.write(@uploaded_data)
97
+ temp.flush
98
+ @uploaded_file = temp
99
+ @uploaded_data = nil
100
+ end
101
+ @uploaded_file
102
+ end
103
+
104
+ def uploaded_file_path
105
+ uploaded_file.respond_to?(:tempfile) ?
106
+ uploaded_file.tempfile.path : # rails 3
107
+ uploaded_file.path # rails 2
108
+ end
109
+
110
+ def close_open_file
111
+ @uploaded_file.close if @uploaded_file && @uploaded_file.respond_to?(:close)
112
+ @uploaded_file.tempfile.close if @uploaded_file.respond_to?(:tempfile) && @uploaded_file.tempfile.respond_to?(:close)
113
+ end
114
+
115
+ def before_validate_attachment # overridden by the processors (and/or by the class we're mixed into)
116
+ # when you write code in here that needs to access the file, use the uploaded_file method to get it
117
+ end
118
+
119
+ def process_attachment? # called by the datastores, overridden by the processors (and/or by the class we're mixed into)
120
+ false
121
+ end
122
+
123
+ def process_attachment_with_wrapping(filename)
124
+ process_attachment(filename)
125
+ rescue AttachmentProcessorError
126
+ raise # pass any exceptions of the correct type (which anything eminating from our processors should be) straight
127
+ rescue Exception => ex
128
+ raise AttachmentProcessorError, "#{ex.class}: #{ex.message}", ex.backtrace # wrap anything else
129
+ end
130
+
131
+ def tempfile_directory # called by uploaded_file, overridden by the file datastore, which sets it to the base dir that it saves into itself, so that the files are put on the same partition & so can be directly hardlinked rather than copied
132
+ Dir.tmpdir
133
+ end
134
+
135
+ def file_extension=(extension) # used by processors to override the original extension
136
+ @file_extension = extension
137
+ end
138
+
139
+ def file_extension
140
+ extension = @file_extension
141
+ extension = AttachmentSaver::split_filename(original_filename).last if extension.blank? && respond_to?(:original_filename) && !original_filename.blank?
142
+ extension = 'bin' if extension.blank?
143
+ extension
144
+ end
145
+
146
+ def trim_original_filename(filename)
147
+ return filename.strip if attachment_options[:keep_original_filename_path]
148
+ filename.gsub(/^.*(\\|\/)/, '').strip
149
+ end
150
+
151
+ def image_size
152
+ width.nil? || height.nil? ? nil : "#{width}x#{height}"
153
+ end
154
+
155
+ def save_updated_derived_children # rails automatically saves children on create, but not on update; when uploading a new image, we don't want to save them until we've finished processing in case that raises & causes a rollback, so we have to save them ourselves later
156
+ @updated_derived_children.each(&:save!) unless @updated_derived_children.blank?
157
+ @updated_derived_children = nil
158
+ end
159
+ end
160
+
161
+ def self.split_filename(filename)
162
+ pos = filename.rindex('.')
163
+ if pos.nil?
164
+ return [filename, nil]
165
+ else
166
+ return [filename[0..pos - 1], filename[pos + 1..-1]]
167
+ end
168
+ end
169
+ end
170
+
171
+ ActiveRecord::Base.send(:extend, AttachmentSaver::BaseMethods)
@@ -0,0 +1,3 @@
1
+ module AttachmentSaver
2
+ VERSION = '1.0.0'
3
+ end
@@ -0,0 +1,3 @@
1
+ class AttachmentSaverError < StandardError; end
2
+ class AttachmentDataStoreError < AttachmentSaverError; end
3
+ class AttachmentProcessorError < AttachmentSaverError; end
@@ -0,0 +1,189 @@
1
+ require 'fileutils'
2
+ require 'tempfile'
3
+ require 'attachment_saver_errors'
4
+
5
+ class FileSystemAttachmentDataStoreError < AttachmentDataStoreError; end
6
+
7
+ module AttachmentSaver
8
+ module DataStores
9
+ module FileSystem
10
+ RETRIES = 100 # max attempts at finding a unique storage key. very rare to have to retry at all, so if it fails after 100 attempts, something's seriously wrong.
11
+
12
+ def self.included(base)
13
+ base.attachment_options[:storage_directory] ||= File.join(Rails.root, 'public') # this is the part of the full filename that _doesn't_ form part of the HTTP path to the files
14
+ base.attachment_options[:storage_path_base] ||= Rails.env == 'production' ? base.table_name : File.join(Rails.env, base.table_name) # and this is the part that does.
15
+ base.attachment_options[:filter_filenames] = Regexp.new(base.attachment_options[:filter_filenames]) if base.attachment_options[:filter_filenames].is_a?(String) # may be nil, in which case the normal randomised-filename scheme is used instead of the filtered-original-filename scheme
16
+ base.attachment_options[:file_permissions] = 0664 unless base.attachment_options.has_key?(:file_permissions) # we don't use || as nil is a meaningful value for this option - it means to not explicitly set the file permissions
17
+ end
18
+
19
+ def save_attachment
20
+ return unless @save_upload # this method is called every time the model is saved, not just when a new file has been uploaded
21
+
22
+ old_storage_key = storage_key
23
+ @old_filenames ||= []
24
+ @old_filenames << storage_filename unless storage_key.blank?
25
+ self.storage_key = nil
26
+ define_finalizer
27
+
28
+ # choose a storage key (ie. path/filename) and try it; note that we assign a new
29
+ # storage key for every new upload, not just every new AR model, so that the URL
30
+ # changes each time, which allows long/infinite cache TTLs & CDN support.
31
+ begin
32
+ if derive_storage_key?
33
+ begin
34
+ # for thumbnail/other derived images, we base the filename on the original
35
+ # (parent) image + the derived format name
36
+ self.storage_key = derive_storage_key_from(original)
37
+ save_attachment_to(storage_filename)
38
+ rescue Errno::EEXIST # if clobbering pre-existing files (only possible if using filtered_filenames, and even then only if creating new derived images explicitly at some time other than during processing the parent), we still don't want to write into them, we want to use a new file & an atomic rename
39
+ retries = 0
40
+ begin
41
+ self.storage_key = derive_storage_key_from(original, retries + 2) # +2 is arbitrary, I just think it's more human-friendly to go from xyz_thumb.jpg to xyz_thumb2.jpg rather than xyz_thumb0.jpg
42
+ save_attachment_to(storage_filename)
43
+ rescue Errno::EEXIST
44
+ raise if (retries += 1) >= RETRIES # in fact it would be very unusual to ever need to retry at all, let alone multiple times; if you hit this, your operating system is actually broken (or someone's messed with storage_filename)
45
+ retry # pick a new random name and try again
46
+ end
47
+ end
48
+ else
49
+ retries = 0
50
+ begin
51
+ if self.class.attachment_options[:filter_filenames] && respond_to?(:original_filename) && !original_filename.blank?
52
+ # replace all the original_filename characters not included in the keep_filenames character list with underscores, leave the rest; store in randomized directories to avoid naming clashes
53
+ basename = AttachmentSaver::split_filename(original_filename).first.gsub(self.class.attachment_options[:filter_filenames], '_')
54
+ self.storage_key = File.join(self.class.attachment_options[:storage_path_base], random_segment(3), random_segment(3), "#{basename}.#{file_extension}")
55
+ else
56
+ # for new files under this option, we pick a random name (split into 3 parts - 2 directories and a file - to help keep the directories at manageable sizes), and never overwrite
57
+ # this is the default setting, and IMHO the most best choice for most apps; the original filenames are typically pretty meaningless
58
+ self.storage_key = File.join(self.class.attachment_options[:storage_path_base], random_segment(2), random_segment(2), "#{random_segment(6)}.#{file_extension}") # in fact just two random characters in the last part would be ample, since 36^(2+2+2) = billions, but we sacrifice 4 more characters of URL shortness for the benefit of ppl saving the assets to disk without renaming them
59
+ end
60
+ save_attachment_to(storage_filename)
61
+ rescue Errno::EEXIST
62
+ raise if (retries += 1) >= RETRIES # in fact it would be very unusual to ever need to retry at all, let alone multiple times; if you hit this, your operating system is actually broken (or someone's messed with storage_filename)
63
+ retry # pick a new random name and try again
64
+ end
65
+ end
66
+
67
+ # successfully written to file; process the attachment
68
+ process_attachment_with_wrapping(storage_filename) if process_attachment?
69
+ # if there's exceptions later (ie. during save itself) that prevent the record from being saved, the finalizer will clean up the file
70
+
71
+ @save_upload = nil
72
+ rescue Exception => ex
73
+ FileUtils.rm_f(storage_filename) unless storage_key.blank? || ex.is_a?(Errno::EEXIST)
74
+ self.storage_key = old_storage_key
75
+ @old_filenames.pop unless old_storage_key.blank?
76
+ raise if ex.is_a?(AttachmentSaverError)
77
+ raise FileSystemAttachmentDataStoreError, "#{ex.class}: #{ex.message}", ex.backtrace
78
+ end
79
+ end
80
+
81
+ def storage_filename
82
+ File.join(self.class.attachment_options[:storage_directory], storage_key)
83
+ end
84
+
85
+ def in_storage?
86
+ File.exists?(storage_filename)
87
+ end
88
+
89
+ def public_path
90
+ "/#{storage_key.tr('\\', '/')}" # the tr is just for windows' benefit
91
+ end
92
+
93
+ def reprocess!
94
+ raise "this attachment already has a file open to process" unless uploaded_file.nil?
95
+ process_attachment_with_wrapping(storage_filename) if process_attachment?
96
+ save!
97
+ end
98
+
99
+ protected
100
+ RND_CHARS = ('a'..'z').to_a + ('0'..'9').to_a # we generously support case-insensitive filesystems. aren't we nice?
101
+
102
+ def tempfile_directory
103
+ # tempfiles go under the same directory as the actual files will, so they'll be on the same filesystem and thus hardlinkable
104
+ File.join(self.class.attachment_options[:storage_directory], self.class.attachment_options[:storage_path_base])
105
+ end
106
+
107
+ def random_segment(chars)
108
+ Array.new(chars) .collect { RND_CHARS[rand(RND_CHARS.length)] } .join
109
+ end
110
+
111
+ def derive_storage_key?
112
+ respond_to?(:format_name) && !format_name.blank? && respond_to?(:original) && !original.nil? &&
113
+ original.class.included_modules.include?(FileSystem) &&
114
+ original.respond_to?(:storage_key) && !original.storage_key.blank?
115
+ end
116
+
117
+ def derive_storage_key_from(original, suffix = nil)
118
+ basename, extension = AttachmentSaver::split_filename(original.storage_key)
119
+ "#{basename}_#{format_name}#{suffix}.#{file_extension}"
120
+ end
121
+
122
+ def tidy_attachment # called after_save
123
+ FileUtils.rm_f(@old_filenames) unless @old_filenames.blank? || self.class.attachment_options[:keep_old_files]
124
+ ObjectSpace.undefine_finalizer(self)
125
+ rescue Exception => ex
126
+ raise FileSystemAttachmentDataStoreError, "#{ex.class}: #{ex.message}", ex.backtrace
127
+ end
128
+
129
+ def delete_attachment # called after_destroy
130
+ FileUtils.rm_f(storage_filename) unless storage_key.blank?
131
+ FileUtils.rm_f(@old_filenames) unless @old_filenames.blank? || self.class.attachment_options[:keep_old_files]
132
+ ObjectSpace.undefine_finalizer(self)
133
+ rescue Exception => ex
134
+ raise FileSystemAttachmentDataStoreError, "#{ex.class}: #{ex.message}", ex.backtrace
135
+ end
136
+
137
+ def define_finalizer
138
+ ObjectSpace.undefine_finalizer(self)
139
+ ObjectSpace.define_finalizer(self, lambda { # called on GC finalization if a save was attempted at some point but wasn't completed (presumably because an exception was raised)
140
+ FileUtils.rm_f(storage_filename) if new_record? && !storage_key.blank?
141
+ FileUtils.rm_f(@old_filenames) unless @old_filenames.blank? || self.class.attachment_options[:keep_old_files]
142
+ })
143
+ end
144
+
145
+ # attempts to write the uploaded data/file to the given filename, setting the file
146
+ # open flags so that Errno::EEXIST will be thrown if the file already exists.
147
+ # creates any missing parent directories.
148
+ def save_attachment_to(filename)
149
+ binary_mode = defined?(File::BINARY) ? File::BINARY : 0
150
+ open_mode = File::CREAT | File::RDWR | File::EXCL | binary_mode
151
+
152
+ FileUtils.mkdir_p(File.dirname(filename))
153
+
154
+ if @uploaded_data
155
+ File.open(filename, open_mode, self.class.attachment_options[:file_permissions]) do |fout|
156
+ fout.write(@uploaded_data)
157
+ end
158
+ else
159
+ # typically, the temp file we get given when a user uploads a file is on the same
160
+ # volume as the directory we're storing to, and since the temporary uploaded files
161
+ # aren't changed ever - they're unlinked when we finish processing the request - we
162
+ # can just efficiently hardlink it instead of wasting time & IO making an independent
163
+ # copy of it. of course, we still need to make a copied file if it isn't on the same
164
+ # volume, if the destination file already exists, if we're on an OS that doesn't
165
+ # support hardlinks, or if the 'uploaded' file isn't a temporary uploaded file at all
166
+ # (presumably someone running an import job) - we don't want any nasty semantics
167
+ # surprises with non-uploaded files!
168
+ uploaded_tempfile = @uploaded_file.respond_to?(:tempfile) ? @uploaded_file.tempfile : @uploaded_file
169
+ if uploaded_tempfile.is_a?(Tempfile)
170
+ uploaded_tempfile.flush
171
+ begin
172
+ FileUtils.ln(uploaded_tempfile.path, filename)
173
+ (File.chmod(self.class.attachment_options[:file_permissions], uploaded_tempfile.path) rescue nil) unless self.class.attachment_options[:file_permissions].nil?
174
+ return # successfully linked, we're done
175
+ rescue
176
+ # ignore and fall through do, it the long way
177
+ end
178
+ end
179
+ File.open(filename, open_mode, self.class.attachment_options[:file_permissions]) do |fout|
180
+ @uploaded_file.rewind
181
+ while data = @uploaded_file.read(4096)
182
+ fout.write(data)
183
+ end
184
+ end
185
+ end
186
+ end
187
+ end
188
+ end
189
+ end