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.
- data/MIT-LICENSE +20 -0
- data/README.markdown +354 -0
- data/Rakefile +9 -0
- data/init.rb +1 -0
- data/lib/milton/attachment.rb +203 -0
- data/lib/milton/core/file.rb +22 -0
- data/lib/milton/core/tempfile.rb +38 -0
- data/lib/milton/derivatives/derivative.rb +95 -0
- data/lib/milton/derivatives/thumbnail/crop_calculator.rb +64 -0
- data/lib/milton/derivatives/thumbnail/image.rb +49 -0
- data/lib/milton/derivatives/thumbnail.rb +46 -0
- data/lib/milton/storage/disk_file.rb +78 -0
- data/lib/milton/storage/s3_file.rb +89 -0
- data/lib/milton/storage/stored_file.rb +47 -0
- data/lib/milton/uploading.rb +82 -0
- data/lib/milton.rb +95 -0
- data/test/fixtures/big-milton.jpg +0 -0
- data/test/fixtures/milton.jpg +0 -0
- data/test/fixtures/mini-milton.jpg +0 -0
- data/test/fixtures/unsanitary .milton.jpg +0 -0
- data/test/milton/attachment_test.rb +329 -0
- data/test/milton/milton_test.rb +13 -0
- data/test/milton/resizing_test.rb +70 -0
- data/test/schema.rb +13 -0
- data/test/test_helper.rb +62 -0
- metadata +31 -7
@@ -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
|