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.
- 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
|