lib/nozzle/adapter/base.rb
require 'fileutils'
require 'tmpdir'
require 'tempfile'
require 'nozzle/adapter/outlet'
module Nozzle
module Adapter
class Base
include Nozzle::Adapter::Outlet
# minimum filename length for Nozzle to try and fit it to the filesystem.
MIN_PATH_MAX = 255
# Initializes internal structure of new adapter.
# outlet_class.new( instance, :avatar, 'image.jpg', :fake => true )
def initialize( record, column, filename, options = {} )
@record = record
@model = record.class
@column = column.to_sym
@filename = filename
settings.merge! options
end
# Sets or gets settings provided by options.
# if instance.avatar.settings[:fake]
# instance.avatar.settings[:fake] = false
# end
def settings
@settings ||= {}
end
# Constructs an URL which relatively points to the file.
# instance.avatar.url # => '/uploads/Model/avatar/image.jpg'
# How it's constructed:
# "#{public_path}/#{filename}"
# "/#{adapter_folder}/#{relative_folder}/#{filename}"
# "/uploads/#{@model}/#{@column}/#{filename}"
# "/uploads/Model/avatar/image.jpg"
# Note: if filename is not yet stored, +default_url+ is called.
def url
if filename
File.join '', public_path, filename
else
default_url
end
end
# Gets url and adds a cache busting timestamp to it.
# instance.avatar.url? # => '/uploads/Model/avatar/image.jpg?1373369401'
def url?
result = url
result && "#{result}?#{File.mtime(path).to_i}"
rescue Errno::ENOENT
result
end
# Constructs a filesustem path which absolutely points to stored file.
# instance.avatar.path # => 'public/uploads/Model/avatar/image.jpg'
# How it's constructed:
# "/#{system_path}/#{filename}"
# "/#{adapter_path}/#{relative_folder}/#{filename}"
# "#{root}/#{adapter_folder}/#{@model}/#{@column}/#{filename}"
# "public/uploads/#{@model}/#{@column}/#{filename}"
# "public/uploads/Model/avatar/image.jpg"
# Note: if filename is not yet stored, nil is returned.
def path
if filename
File.join system_path, filename
end
end
# Returns intermediate path to the tempfile if the record is not yet
# saved and file is not yet stored at path.
def access_path
@tempfile_path || path
end
# Returns file content_type stored in avatar_content_type column
# of the record.
def content_type
@record.send( :"#{@column}_content_type" ) rescue ''
end
# Returns file size stored in avatar_size column of the record.
def size
@record.send( :"#{@column}_size" ) rescue -1
end
# Returns stored filename.
# instance.avatar.filename # => 'image.jpg'
def filename
@filename
end
# Returns nil.
# This SHOULD be overridden by subclasses of Nozzle::Adapter::Base.
# instance.avatar.default_url # => nil
def default_url
nil
end
# Returns root path of application's static assets.
# instance.avatar.root # => 'public'
# This MAY be overridden to return an application root different
# from the current folder.
def root
'public'
end
# Returns folder name of the adapter relative to application public.
# instance.avatar.adapter_folder # => 'uploads'
# This MAY be overridden to specify where all the files should be stored.
def adapter_folder
'uploads'
end
# Returns filesystem folder path of the adapter relative to adapter root.
# instance.avatar.adapter_path # => 'public/uploads'
# It is constructed from #root, 'public' and #adapter_folder.
def adapter_path
File.join root, adapter_folder
end
# Returns file's folder relative to #adapter_path.
# instance.avatar.relative_folder # => 'Model/avatar'
# It is constructed from object's class name and column name.
# This MAY be overridden to place files somwhere other than 'Model/avatar'.
def relative_folder
File.join @model.to_s, @column.to_s
end
# Returns filesystem folder path relative to adapter root.
# instance.avatar.system_path # => 'public/uploads/Model/avatar'
# It is constructed from #adapter_path and #relative_folder
def system_path
File.join adapter_path, relative_folder
end
# Returns folder path relative to public folder.
# instance.avatar.public_path # => 'uploads/Model/avatar'
# It is constructed from #adapter_folder and #relative_folder
def public_path
File.join adapter_folder, relative_folder
end
# Inspects class name and url of the object.
# instance.avatar.to_s # => 'Model#url: /uploads/Model/avatar/image.jpg'
def to_s
"#{self.class}#url: #{url}"
end
# Sets adapter to delete stored file on #adapŠµr_after_save.
# instance.avatar.delete # => nil
def delete
@record.send(:"#{@column}=", nil)
end
# Returns adapter instance.
# It's used in Nozzle::Adapter#avatar after retrieving filename from the object.
def load( value )
@filename = value
self
end
# Fills internal structure of the adapter with new file's path.
# It's used in Nozzle::Adapter#avatar= before sending filename to the object.
def dump( value )
reset
@original_path = path
return nil unless value
new_path, filename_candidate = expand_argument(value)
raise Errno::ENOENT, "'#{new_path}'" unless File.exists?(new_path)
@tempfile_path = File.expand_path(new_path)
detect_properties
@filename = fit_to_filesystem(prepare_filename(filename_candidate))
end
# Stores temporary filename by the constructed path. Deletes old file.
# Note: the file is moved if it's path contains /tmp/ or /temp/, copied
# otherwise.
def store!
unlink! @original_path
return nil unless @tempfile_path
new_path = path
FileUtils.mkdir_p File.dirname(new_path)
result = if @tempfile_path =~ /\/te?mp\//
FileUtils.move @tempfile_path, new_path
else
FileUtils.copy @tempfile_path, new_path
end
File.chmod 0644, new_path
reset
result
end
# Deletes file by path. Do not use, it will break adapter's integrity.
# It's called in #avatar_after_destroy after the object is destroyed.
# unlink! # deletes path
# unlink! @original_path # deletes @original_path
# unlink! nil # deletes nothing
def unlink!( target = path )
delete_file_and_folder! target if target
end
def as_json(*args)
{ :url => url }
end
private
# Returns a filename prepared for saving. Should be overridden.
# instance.avatar.new_filename('image.jpg') # => '0006-avatary-image.jpg'
def prepare_filename(candidate=filename)
candidate
end
# Returns a filename that will be acceptable for filesystem
# chopping it if nessesary.
def fit_to_filesystem(candidate)
return candidate if candidate.bytesize <= MIN_PATH_MAX
ext = File.extname(candidate)
base = File.basename(candidate, ext)
tmp = Dir.tmpdir
loop do
begin
candidate = base + ext
try_file = File.join(tmp, candidate)
FileUtils.touch try_file
FileUtils.rm try_file
return candidate
rescue Errno::ENAMETOOLONG
if base.length > 0
base.chop!
retry
else
raise
end
end
end
end
# Tries to detect content_type and size of the file.
# Note: this method calls `file` system command to detect file content type.
def detect_properties
@record.send( :"#{@column}_content_type=", `file -bp --mime-type '#{access_path}'`.to_s.strip )
@record.send( :"#{@column}_size=", File.size(access_path) )
rescue NoMethodError
nil
end
# Resets internal paths.
def reset
@original_path = nil
@tempfile_path = nil
end
# Analyzes the value assigned to adapter and fills @filename. Returns
# system path where temporary file is located.
# The +value+ MUST be File, String, Hash or nil. See Nozzle::Adapter#avatar=.
def expand_argument( value )
tempfile_path = case value
when String
value
when File, Tempfile
value.path
when Hash
expand_argument( value[:tempfile] || value['tempfile'] ).first
else
raise ArgumentError, "#{@model}##{@column}= argument must be kind of String, File, Tempfile or Hash[:tempfile => 'path']"
end
[tempfile_path, value.kind_of?(Hash) && ( value[:filename] || value['filename'] ) || File.basename(tempfile_path)]
end
# Deletes the specified file and all empty folders recursively stopping at
# #adapter_folder.
def delete_file_and_folder!( file_path )
FileUtils.rm_f file_path
boundary = adapter_path + '/'
loop do
file_path = File.dirname file_path
break unless file_path.index boundary
FileUtils.rmdir file_path
end
end
end
end
end