module AttachmentSaver::DataStores::FileSystem

Constants

RETRIES
RND_CHARS

Public Class Methods

included(base) click to toggle source
# File lib/datastores/file_system.rb, line 12
def self.included(base)
  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
  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.
  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
  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
end

Public Instance Methods

in_storage?() click to toggle source
# File lib/datastores/file_system.rb, line 85
def in_storage?
  File.exist?(storage_filename)
end
public_path() click to toggle source
# File lib/datastores/file_system.rb, line 89
def public_path
  "/#{storage_key.tr('\\', '/')}" # the tr is just for windows' benefit
end
reprocess!() click to toggle source
# File lib/datastores/file_system.rb, line 93
def reprocess!
  raise "this attachment already has a file open to process" unless uploaded_file.nil?
  process_attachment_with_wrapping(storage_filename) if process_attachment?
  save!
end
save_attachment() click to toggle source
# File lib/datastores/file_system.rb, line 19
def save_attachment
  return unless @save_upload # this method is called every time the model is saved, not just when a new file has been uploaded
  
  old_storage_key = storage_key
  @old_filenames ||= []
  @old_filenames << storage_filename unless storage_key.blank?
  self.storage_key = nil
  define_finalizer
  
  # choose a storage key (ie. path/filename) and try it; note that we assign a new
  # storage key for every new upload, not just every new AR model, so that the URL
  # changes each time, which allows long/infinite cache TTLs & CDN support.
  begin
    if derive_storage_key?
      begin
        # for thumbnail/other derived images, we base the filename on the original
        # (parent) image + the derived format name
        self.storage_key = derive_storage_key_from(original)
        save_attachment_to(storage_filename)
      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
        retries = 0
        begin
          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
          save_attachment_to(storage_filename)
        rescue Errno::EEXIST
          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)
          retry # pick a new random name and try again
        end
      end
    else
      retries = 0
      begin
        if self.class.attachment_options[:filter_filenames] && respond_to?(:original_filename) && !original_filename.blank?
          # 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
          basename = AttachmentSaver::split_filename(original_filename).first.gsub(self.class.attachment_options[:filter_filenames], '_')
          self.storage_key = File.join(self.class.attachment_options[:storage_path_base], random_segment(3), random_segment(3), "#{basename}.#{file_extension}")
        else
          # 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
          # this is the default setting, and IMHO the most best choice for most apps; the original filenames are typically pretty meaningless
          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
        end
        save_attachment_to(storage_filename)
      rescue Errno::EEXIST
        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)
        retry # pick a new random name and try again
      end
    end

    # successfully written to file; process the attachment
    process_attachment_with_wrapping(storage_filename) if process_attachment?
    # if there's exceptions later (ie. during save itself) that prevent the record from being saved, the finalizer will clean up the file

    @save_upload = nil
  rescue Exception => ex
    FileUtils.rm_f(storage_filename) unless storage_key.blank? || ex.is_a?(Errno::EEXIST)
    self.storage_key = old_storage_key
    @old_filenames.pop unless old_storage_key.blank?
    raise if ex.is_a?(AttachmentSaverError)
    raise FileSystemAttachmentDataStoreError, "#{ex.class}: #{ex.message}", ex.backtrace
  end
end
storage_filename() click to toggle source
# File lib/datastores/file_system.rb, line 81
def storage_filename
  File.join(self.class.attachment_options[:storage_directory], storage_key)
end

Protected Instance Methods

define_finalizer() click to toggle source
# File lib/datastores/file_system.rb, line 137
def define_finalizer
  ObjectSpace.undefine_finalizer(self)
  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)
    FileUtils.rm_f(storage_filename)   if new_record? && !storage_key.blank?
    FileUtils.rm_f(@old_filenames) unless @old_filenames.blank? || self.class.attachment_options[:keep_old_files]
  })
end
delete_attachment() click to toggle source
# File lib/datastores/file_system.rb, line 129
def delete_attachment # called after_destroy
  FileUtils.rm_f(storage_filename) unless storage_key.blank?
  FileUtils.rm_f(@old_filenames)   unless @old_filenames.blank? || self.class.attachment_options[:keep_old_files]
  ObjectSpace.undefine_finalizer(self)
rescue Exception => ex
  raise FileSystemAttachmentDataStoreError, "#{ex.class}: #{ex.message}", ex.backtrace
end
derive_storage_key?() click to toggle source
# File lib/datastores/file_system.rb, line 111
def derive_storage_key?
  respond_to?(:format_name) && !format_name.blank? && respond_to?(:original) && !original.nil? &&
    original.class.included_modules.include?(FileSystem) &&
    original.respond_to?(:storage_key) && !original.storage_key.blank?
end
derive_storage_key_from(original, suffix = nil) click to toggle source
# File lib/datastores/file_system.rb, line 117
def derive_storage_key_from(original, suffix = nil)
  basename, extension = AttachmentSaver::split_filename(original.storage_key)
  "#{basename}_#{format_name}#{suffix}.#{file_extension}"
end
random_segment(chars) click to toggle source
# File lib/datastores/file_system.rb, line 107
def random_segment(chars)
  Array.new(chars) .collect { RND_CHARS[rand(RND_CHARS.length)] } .join
end
save_attachment_to(filename) click to toggle source

attempts to write the uploaded data/file to the given filename, setting the file open flags so that Errno::EEXIST will be thrown if the file already exists. creates any missing parent directories.

# File lib/datastores/file_system.rb, line 148
def save_attachment_to(filename)
  binary_mode = defined?(File::BINARY) ? File::BINARY : 0
  open_mode = File::CREAT | File::RDWR | File::EXCL | binary_mode
  
  FileUtils.mkdir_p(File.dirname(filename))
  
  if @uploaded_data
    File.open(filename, open_mode, self.class.attachment_options[:file_permissions]) do |fout| 
      fout.write(@uploaded_data)
    end
  else
    # typically, the temp file we get given when a user uploads a file is on the same
    # volume as the directory we're storing to, and since the temporary uploaded files
    # aren't changed ever - they're unlinked when we finish processing the request - we
    # can just efficiently hardlink it instead of wasting time & IO making an independent
    # copy of it.  of course, we still need to make a copied file if it isn't on the same
    # volume, if the destination file already exists, if we're on an OS that doesn't
    # support hardlinks, or if the 'uploaded' file isn't a temporary uploaded file at all
    # (presumably someone running an import job) - we don't want any nasty semantics
    # surprises with non-uploaded files!
    uploaded_tempfile = @uploaded_file.respond_to?(:tempfile) ? @uploaded_file.tempfile : @uploaded_file
    if uploaded_tempfile.is_a?(Tempfile)
      uploaded_tempfile.flush
      begin
        FileUtils.ln(uploaded_tempfile.path, filename)
        (File.chmod(self.class.attachment_options[:file_permissions], uploaded_tempfile.path) rescue nil) unless self.class.attachment_options[:file_permissions].nil? 
        return # successfully linked, we're done
      rescue
        # ignore and fall through do, it the long way
      end
    end
    File.open(filename, open_mode, self.class.attachment_options[:file_permissions]) do |fout|
      @uploaded_file.rewind
      while data = @uploaded_file.read(4096) 
        fout.write(data)
      end
    end
  end
end
tempfile_directory() click to toggle source
# File lib/datastores/file_system.rb, line 102
def tempfile_directory
  # tempfiles go under the same directory as the actual files will, so they'll be on the same filesystem and thus hardlinkable
  File.join(self.class.attachment_options[:storage_directory], self.class.attachment_options[:storage_path_base])
end
tidy_attachment() click to toggle source
# File lib/datastores/file_system.rb, line 122
def tidy_attachment # called after_save
  FileUtils.rm_f(@old_filenames)   unless @old_filenames.blank? || self.class.attachment_options[:keep_old_files]
  ObjectSpace.undefine_finalizer(self)
rescue Exception => ex
  raise FileSystemAttachmentDataStoreError, "#{ex.class}: #{ex.message}", ex.backtrace
end