citrusbyte-milton 0.3.0 → 0.3.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,22 @@
1
+ begin
2
+ require 'mimetype_fu'
3
+ rescue MissingSourceFile
4
+ end
5
+
6
+ module Milton
7
+ class File < ::File
8
+ class << self
9
+ def extension(filename)
10
+ extension = extname(filename)
11
+ extension.slice(1, extension.length-1)
12
+ end
13
+
14
+ # File respond_to?(:mime_type) is true if mimetype_fu is installed, so
15
+ # this way we always have File.mime_type? available but it favors
16
+ # mimetype_fu's implementation.
17
+ def mime_type?(file)
18
+ ::File.respond_to?(:mime_type?) ? super(file.filename) : file.content_type
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,38 @@
1
+ require 'tempfile'
2
+
3
+ module Milton
4
+ # For lack of a better name, a MiltonTempfile adds some helpful
5
+ # functionality to Ruby's Tempfile
6
+ class Tempfile < ::Tempfile
7
+ class << self
8
+ def create(data_or_path, tempfile_path)
9
+ FileUtils.mkdir_p(tempfile_path) unless File.exists?(tempfile_path)
10
+
11
+ tempfile = new(basename, tempfile_path)
12
+
13
+ if data_or_path.is_a?(StringIO)
14
+ tempfile.binmode
15
+ tempfile.write data_or_path.read
16
+ tempfile.close
17
+ else
18
+ tempfile.close
19
+ FileUtils.cp((data_or_path.respond_to?(:path) ? data_or_path.path : data_or_path), tempfile.path)
20
+ end
21
+
22
+ tempfile
23
+ end
24
+
25
+ def basename
26
+ "#{rand(Time.now.to_i)}"
27
+ end
28
+
29
+ def filename(extension)
30
+ "#{basename}.#{extension}"
31
+ end
32
+
33
+ def path(tempfile_path, extension)
34
+ File.join(tempfile_path, filename(extension))
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,95 @@
1
+ module Milton
2
+ # Represents a file created on the file system that is a derivative of the
3
+ # one referenced by the model, i.e. a thumbnail of an image, or a transcode
4
+ # of a video.
5
+ #
6
+ # Provides a container for options and a uniform API to dealing with
7
+ # passing options for the creation of derivatives.
8
+ #
9
+ # Files created as derivatives have their creation options appended into
10
+ # their filenames so it can be checked later if a file w/ the given
11
+ # options already exists (so as not to create it again).
12
+ #
13
+ class Derivative
14
+ class << self
15
+ # Given a string of attachment options, splits them out into a hash,
16
+ # useful for things that take options on the query string or from
17
+ # filenames
18
+ def options_from(string)
19
+ Hash[*(string.split('_').collect { |option|
20
+ key, value = option.split('=')
21
+ [ key.to_sym, value || true ] # nothing on RHS of = means it's a boolean true
22
+ }).flatten]
23
+ end
24
+
25
+ # Given a filename (presumably with options embedded in it) parses out
26
+ # the options and returns them as a hash.
27
+ def extract_options_from(filename, options)
28
+ File.basename(filename, File.extname(filename))[(filename.rindex(options[:separator]) + 1)..-1]
29
+ end
30
+
31
+ # Creates a new Derivative from the given filename by extracting the
32
+ # options.
33
+ def from_filename(filename)
34
+ Derivative.new(filename, options_from(extract_options_from(filename)))
35
+ end
36
+
37
+ def process(source, options={}, settings={})
38
+ returning(derivative = new(source, options, settings)) do
39
+ derivative.process unless derivative.exists?
40
+ end
41
+ end
42
+
43
+ def factory(type, source, options={}, settings={})
44
+ "Milton::#{type.to_s.classify}".constantize.new(source, options, settings)
45
+ end
46
+ end
47
+
48
+ attr_reader :options, :settings
49
+
50
+ # Instantiate a new Derivative:
51
+ # * +source+: a reference to the Storage::StoredFile this will be a Derivative of
52
+ # * +options+: options to generate the Derivative using
53
+ # * +settings+: settings about how to create Derivatives
54
+ def initialize(source, options={}, settings={})
55
+ @source = source
56
+ @options = options.is_a?(String) ? self.class.options_from(options) : options
57
+ @settings = settings
58
+ end
59
+
60
+ # The resulting filename of this Derivative with embedded options.
61
+ def filename
62
+ # ignore false booleans and don't output =true for true booleans,
63
+ # otherwise just k=v
64
+ append = options.reject{ |k, v| v.is_a?(FalseClass) }.collect { |k, v| v === true ? k.to_s : "#{k}=#{v}" }.sort.join('_')
65
+ extension = File.extname(@source.path)
66
+ File.basename(@source.path, extension) + (append.blank? ? '' : "#{settings[:separator]}#{append}") + extension
67
+ end
68
+
69
+ # The full path and filename to this Derivative.
70
+ def path
71
+ file.path
72
+ end
73
+
74
+ # Returns true if this Derivative has already been generated and stored.
75
+ def exists?
76
+ file.exists?
77
+ end
78
+
79
+ # Overwrite this to provide your derivatives processing.
80
+ def process;end;
81
+
82
+ # Convenience method, only runs process if the given condition is true.
83
+ # Returns the derivative so it's chainable.
84
+ def process_if(condition)
85
+ process if condition && !exists?
86
+ return self
87
+ end
88
+
89
+ # Returns the StoredFile which represents the Derivative (which is a copy
90
+ # of the source w/ a different filename).
91
+ def file
92
+ @file ||= @source.copy(filename)
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,64 @@
1
+ module Milton
2
+ class CropCalculator
3
+ attr_reader :original, :target
4
+
5
+ # Initializes a new CropCalculator with the two given Images.
6
+ #
7
+ # A CropCalculator is used to calculate the proper zoom/crop dimensions
8
+ # to be passed to ImageMagick's convert method in order to transform
9
+ # the original Image's dimensions into the target Image's dimensions
10
+ # with sensible zoom/cropping.
11
+ def initialize(original, target)
12
+ @original = original
13
+ @target = target
14
+ end
15
+
16
+ # Returns the geometry string to send to ImageMagick's convert -resize
17
+ # argument -- that is, the dimensions that the original Image would
18
+ # need to be resized to in order to result in the given target Image's
19
+ # dimensions with cropping.
20
+ def resizing_geometry
21
+ case
22
+ when original.wider? then "#{resized_width}x#{target.height}"
23
+ when original.square? && target.wider? then "#{target.width}x#{resized_height}"
24
+ when original.square? && !target.wider? then "#{resized_width}x#{target.height}"
25
+ else "#{target.width}x#{resized_height}"
26
+ end
27
+ end
28
+
29
+ # The geometry string to send to ImageMagick's convert -crop argument.
30
+ def cropping_geometry
31
+ "#{target.width}x#{target.height}+0+0"
32
+ end
33
+
34
+ # The gravity to use for cropping.
35
+ def gravity
36
+ original.wider? ? "center" : "north"
37
+ end
38
+
39
+ private
40
+
41
+ def resized_width
42
+ (target.height * original.width / original.height).to_i
43
+ end
44
+
45
+ def resized_height
46
+ (target.width * original.height / original.width).to_i
47
+ end
48
+
49
+ # TODO: this is the old-school cropping w/ coords, need to implement
50
+ # cropping w/ coords using the new system calls
51
+ # def crop_with_coordinates(img, x, y, size, options={})
52
+ # gravity = options[:gravity] || Magick::NorthGravity
53
+ # cropped_img = nil
54
+ # img = Magick::Image.read(img).first unless img.is_a?(Magick::Image)
55
+ # szx, szy = img.columns, img.rows
56
+ # sz = self.class.get_size_from_parameter(size)
57
+ # # logger.info "crop_with_coordinates: img.crop!(#{x}, #{y}, #{sz[0]}, #{sz[1]}, true)"
58
+ # # cropped_img = img.resize!(sz[0], sz[1]) # EEEEEK
59
+ # cropped_img = img.crop!(x, y, szx, szy, true)
60
+ # cropped_img.crop_resized!(sz[0], sz[1], gravity) # EEEEEK
61
+ # self.temp_path = write_to_temp_file(cropped_img.to_blob)
62
+ # end
63
+ end
64
+ end
@@ -0,0 +1,49 @@
1
+ module Milton
2
+ # Generic view of an "Image", or rather, something with a width and a
3
+ # height we care about =).
4
+ class Image
5
+ attr_accessor :width
6
+ attr_accessor :height
7
+
8
+ class << self
9
+ # Instantiates a new image from the given path. Uses ImageMagick's
10
+ # identify method to determine the width and height of the image with
11
+ # the given path and returns a new Image with those dimensions.
12
+ #
13
+ # Raises a MissingFileError if the given path could not be identify'd
14
+ # by ImageMagick (resulting in a height and width).
15
+ def from_path(path)
16
+ raise Milton::MissingFileError.new("Could not identify #{path} as an image, does the file exist?") unless Milton.syscall("identify #{path}") =~ /.*? (\d+)x(\d+)\+\d+\+\d+/
17
+ new($1, $2)
18
+ end
19
+
20
+ # Instantiates a new image from the given geometry string. A geometry
21
+ # string is just something like 50x40. The first number is the width
22
+ # and the second is the height.
23
+ def from_geometry(geometry)
24
+ new(*(geometry.split("x").collect(&:to_i)))
25
+ end
26
+ end
27
+
28
+ # Instantiates a new Image with the given width and height
29
+ def initialize(width=nil, height=nil)
30
+ @width = width.to_i
31
+ @height = height.to_i
32
+ end
33
+
34
+ # Returns the larger dimension of the Image
35
+ def larger_dimension
36
+ width > height ? width : height
37
+ end
38
+
39
+ # Returns true if the Image is wider than it is tall
40
+ def wider?
41
+ width > height
42
+ end
43
+
44
+ # Returns true if the Image is square
45
+ def square?
46
+ width == height
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,46 @@
1
+ require 'milton/derivatives/thumbnail/image'
2
+ require 'milton/derivatives/thumbnail/crop_calculator'
3
+
4
+ module Milton
5
+ class Thumbnail < Derivative
6
+ def process
7
+ raise "target size must be specified for resizing" unless options.has_key?(:size)
8
+
9
+ destination = Milton::Tempfile.path(settings[:tempfile_path], Milton::File.extension(@source.filename))
10
+
11
+ # TODO: determine if this is neccessary or was just a problem w/ the
12
+ # way we were calling convert
13
+ # convert can be destructive to the original image in certain failure
14
+ # cases, so copy it to a tempfile first before processing
15
+ # source = Milton::Tempfile.create(self.source, @source.options[:tempfile_path]).path
16
+
17
+ if options[:crop]
18
+ crop = CropCalculator.new(image, Image.from_geometry(options[:size]))
19
+ size = crop.resizing_geometry
20
+ conversion_options = %Q(-gravity #{crop.gravity} -crop #{crop.cropping_geometry})
21
+ end
22
+
23
+ # TODO: raise if the syscall fails
24
+ Milton.syscall(%Q{convert #{source} -geometry #{size || options[:size]} #{conversion_options || ''} +repage "#{destination}"})
25
+
26
+ # TODO: raise if the store fails
27
+ file.store(destination)
28
+ end
29
+
30
+ protected
31
+
32
+ # For speed, any derivatives less than 640-wide are made from a
33
+ # 640-wide version of the image (so you're not generating tiny
34
+ # thumbnails from an 8-megapixel upload)
35
+ def source
36
+ image.width > 640 && Image.from_geometry(options[:size]).width < 640 ?
37
+ Thumbnail.process(@source, { :size => '640x' }, settings).path : @source.path
38
+ end
39
+
40
+ # Returns and memoizes an Image initialized from the file we're making a
41
+ # thumbnail of
42
+ def image
43
+ @image ||= Image.from_path(@source.path)
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,78 @@
1
+ require File.join(File.dirname(__FILE__), 'stored_file')
2
+
3
+ module Milton
4
+ module Storage
5
+ # How Milton deals with a file stored on disk...
6
+ class DiskFile < StoredFile
7
+ class << self
8
+ # Creates the given directory and sets it to the mode given in
9
+ # options[:chmod]
10
+ def recreate_directory(directory, options)
11
+ return true if File.exists?(directory)
12
+ FileUtils.mkdir_p(directory)
13
+ File.chmod(options[:storage_options][:chmod], directory)
14
+ end
15
+ end
16
+
17
+ # Returns the full path and filename to the file with the given options.
18
+ # If no options are given then returns the path and filename to the
19
+ # original file.
20
+ def path
21
+ File.join(dirname, filename)
22
+ end
23
+
24
+ # Returns the full directory path up to the file, w/o the filename.
25
+ def dirname
26
+ File.join(root_path, partitioned_path)
27
+ end
28
+
29
+ # Returns true if the file exists on the underlying file system.
30
+ def exists?
31
+ File.exist?(path)
32
+ end
33
+
34
+ # Writes the given source file to this file's path.
35
+ def store(source)
36
+ Milton.log "storing #{source} to disk at #{path}"
37
+ self.class.recreate_directory(dirname, options)
38
+ File.cp(source, path)
39
+ File.chmod(options[:storage_options][:chmod], path)
40
+ end
41
+
42
+ # Removes the file from the underlying file system and any derivatives of
43
+ # the file.
44
+ def destroy
45
+ Milton.log "destroying path #{dirname}"
46
+ FileUtils.rm_rf dirname if File.exists?(dirname)
47
+ end
48
+
49
+ # Returns the mime type of the file.
50
+ def mime_type
51
+ Milton.syscall("file -Ib #{path}").gsub(/\n/,"")
52
+ end
53
+
54
+ protected
55
+
56
+ # Partitioner that takes an id, pads it up to 12 digits then splits
57
+ # that into 4 folders deep, each 3 digits long.
58
+ #
59
+ # i.e.
60
+ # 000/000/012/139
61
+ #
62
+ # Scheme allows for 1000 billion files while never storing more than
63
+ # 1000 files in a single folder.
64
+ #
65
+ # Can overwrite this method to provide your own partitioning scheme.
66
+ def partitioned_path
67
+ # TODO: there's probably some fancy 1-line way to do this...
68
+ padded = ("0"*(12-id.to_s.size)+id.to_s).split('')
69
+ File.join(*[0, 3, 6, 9].collect{ |i| padded.slice(i, 3).join })
70
+ end
71
+
72
+ # The full path to the root of where files will be stored on disk.
73
+ def root_path
74
+ options[:storage_options][:root]
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,89 @@
1
+ require 'milton/storage/stored_file'
2
+ require 'right_aws'
3
+ # these are required to generate HMAC:SHA1 signature for retrieving private
4
+ # files from S3
5
+ require 'base64'
6
+ require 'openssl'
7
+ require 'digest/sha1'
8
+
9
+ # TODO: Raise helpful errors on missing required options instead of letting
10
+ # right_aws fail cryptically
11
+
12
+ module Milton
13
+ module Storage
14
+ class S3File < StoredFile
15
+ def path
16
+ "http://#{options[:storage_options][:bucket]}.s3.amazonaws.com/#{key}"
17
+ end
18
+
19
+ def dirname
20
+ id
21
+ end
22
+
23
+ def exists?
24
+ bucket.key(key).exists?
25
+ end
26
+
27
+ def store(source)
28
+ Milton.log "storing #{source} to #{path} (#{options[:storage_options][:permissions]})"
29
+ bucket.put(key, File.open(source), {}, options[:storage_options][:permissions])
30
+ end
31
+
32
+ def destroy
33
+ Milton.log "destroying #{path}"
34
+ bucket.key(key).try(:delete)
35
+ end
36
+
37
+ def mime_type
38
+ # TODO: implement
39
+ end
40
+
41
+ # Generates a signed url to this resource on S3.
42
+ #
43
+ # See doc for +signature+.
44
+ def signed_url(expires_at=nil)
45
+ "#{path}?AWSAccessKeyId=#{options[:storage_options][:access_key_id]}" +
46
+ (expires_at ? "&Expires=#{expires_at.to_i}" : '') +
47
+ "&Signature=#{signature(expires_at)}"
48
+ end
49
+
50
+ # Generates a signature for passing authorization for this file on to
51
+ # another user without having to proxy the file.
52
+ #
53
+ # See http://docs.amazonwebservices.com/AmazonS3/latest/index.html?RESTAuthentication.html
54
+ #
55
+ # Optionally pass +expires_at+ to make the signature valid only until
56
+ # given expiration date/time -- useful for temporary secure access to
57
+ # files.
58
+ def signature(expires_at=nil)
59
+ CGI.escape(Base64.encode64(OpenSSL::HMAC.digest(
60
+ OpenSSL::Digest::Digest.new('sha1'),
61
+ options[:storage_options][:secret_access_key],
62
+ "GET\n\n\n#{expires_at ? expires_at.to_i : ''}\n/#{options[:storage_options][:bucket]}/#{key}"
63
+ )).chomp.gsub(/\n/, ''))
64
+ end
65
+
66
+ protected
67
+
68
+ def key
69
+ "#{dirname}/#{filename}"
70
+ end
71
+
72
+ def s3
73
+ @s3 ||= RightAws::S3.new(
74
+ options[:storage_options][:access_key_id],
75
+ options[:storage_options][:secret_access_key],
76
+ { :protocol => http? ? 'http' : 'https', :port => http? ? 80 : 443, :logger => Rails.logger }
77
+ )
78
+ end
79
+
80
+ def http?
81
+ options[:storage_options].has_key?(:protocol) && options[:storage_options][:protocol] == 'http'
82
+ end
83
+
84
+ def bucket
85
+ @bucket ||= s3.bucket(options[:storage_options][:bucket], true, options[:storage_options][:permissions])
86
+ end
87
+ end
88
+ end
89
+ end