citrusbyte-milton 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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