attachment_saver 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +3 -0
- data/MIT-LICENSE +19 -0
- data/README +137 -0
- data/Rakefile +16 -0
- data/attachment_saver.gemspec +41 -0
- data/init.rb +1 -0
- data/lib/attachment_saver.rb +171 -0
- data/lib/attachment_saver/version.rb +3 -0
- data/lib/attachment_saver_errors.rb +3 -0
- data/lib/datastores/file_system.rb +189 -0
- data/lib/datastores/in_column.rb +49 -0
- data/lib/misc/extended_tempfile.rb +12 -0
- data/lib/misc/file_size.rb +5 -0
- data/lib/misc/image_science_extensions.rb +102 -0
- data/lib/misc/mini_magick_extensions.rb +89 -0
- data/lib/processors/image.rb +187 -0
- data/lib/processors/image_science.rb +94 -0
- data/lib/processors/mini_magick.rb +103 -0
- data/lib/processors/r_magick.rb +120 -0
- data/test/attachment_saver_test.rb +162 -0
- data/test/database.yml +3 -0
- data/test/file_system_datastore_test.rb +468 -0
- data/test/fixtures/broken.jpg +1 -0
- data/test/fixtures/emptyextension. +0 -0
- data/test/fixtures/noextension +0 -0
- data/test/fixtures/test.jpg +0 -0
- data/test/fixtures/test.js +1 -0
- data/test/fixtures/wrongextension.png +0 -0
- data/test/image_fixtures.rb +69 -0
- data/test/image_operations.rb +114 -0
- data/test/image_processor_test.rb +67 -0
- data/test/image_processor_test_common.rb +81 -0
- data/test/image_science_processor_test.rb +20 -0
- data/test/in_column_datastore_test.rb +115 -0
- data/test/mini_magick_processor_test.rb +20 -0
- data/test/model_test.rb +205 -0
- data/test/public/.empty +0 -0
- data/test/rmagick_processor_test.rb +20 -0
- data/test/schema.rb +41 -0
- data/test/test_helper.rb +49 -0
- metadata +223 -0
data/.gitignore
ADDED
data/MIT-LICENSE
ADDED
@@ -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
|
data/Rakefile
ADDED
@@ -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,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
|