imageproxy 0.1.0

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,4 @@
1
+ require 'cgi'
2
+ require 'mime/types'
3
+ Bundler.require :default
4
+
@@ -0,0 +1,22 @@
1
+ class Command
2
+ protected
3
+
4
+ def execute_command(command_line)
5
+ stdin, stdout, stderr = Open3.popen3(command_line)
6
+ [output_to_string(stdout), output_to_string(stderr)].join("")
7
+ end
8
+
9
+ def curl(url, options={})
10
+ user_agent = options[:user_agent] || "imageproxy"
11
+ %|curl -s -A "#{user_agent}" "#{url}"|
12
+ end
13
+
14
+ def to_path(obj)
15
+ obj.respond_to?(:path) ? obj.path : obj.to_s
16
+ end
17
+
18
+ def output_to_string(output)
19
+ output.readlines.join("").chomp
20
+ end
21
+
22
+ end
@@ -0,0 +1,12 @@
1
+ require File.join(File.expand_path(File.dirname(__FILE__)), "command")
2
+
3
+ class Compare < Command
4
+ def initialize(a, b)
5
+ @path_a = to_path(a)
6
+ @path_b = to_path(b)
7
+ end
8
+
9
+ def execute
10
+ execute_command %'compare -metric AE -fuzz 10% "#{@path_a}" "#{@path_b}" "#{Tempfile.new("compare").path}"'
11
+ end
12
+ end
@@ -0,0 +1,72 @@
1
+ require File.join(File.expand_path(File.dirname(__FILE__)), "command")
2
+
3
+ class Convert < Command
4
+ attr_reader :options
5
+
6
+ def initialize(options)
7
+ @options = options
8
+ if (!(options.resize || options.thumbnail || options.rotate || options.flip || options.format || options.quality))
9
+ raise "Missing action or illegal parameter value"
10
+ end
11
+ end
12
+
13
+ def execute(user_agent=nil)
14
+ execute_command %'#{curl options.source, :user_agent => user_agent} | convert - #{convert_options} #{new_format}#{file.path}'
15
+ file
16
+ end
17
+
18
+ def convert_options
19
+ convert_options = []
20
+ convert_options << "-resize #{resize_thumbnail_options(options.resize)}" if options.resize
21
+ convert_options << "-thumbnail #{resize_thumbnail_options(options.thumbnail)}" if options.thumbnail
22
+ convert_options << "-flop" if options.flip == "horizontal"
23
+ convert_options << "-flip" if options.flip == "vertical"
24
+ convert_options << rotate_options if options.rotate
25
+ convert_options << "-colors 256" if options.format == "png8"
26
+ convert_options << "-quality #{options.quality}" if options.quality
27
+ convert_options << interlace_options if options.progressive
28
+ convert_options.join " "
29
+ end
30
+
31
+ def resize_thumbnail_options(size)
32
+ case options.shape
33
+ when "cut"
34
+ "#{size}^ -gravity center -extent #{size}"
35
+ when "preserve"
36
+ size
37
+ when "pad"
38
+ background = options.background ? %|"#{options.background}"| : %|none -matte|
39
+ "#{size} -background #{background} -gravity center -extent #{size}"
40
+ else
41
+ size
42
+ end
43
+ end
44
+
45
+ def rotate_options
46
+ if options.rotate.to_f % 90 == 0
47
+ "-rotate #{options.rotate}"
48
+ else
49
+ background = options.background ? %|"#{options.background}"| : %|none|
50
+ "-background #{background} -matte -rotate #{options.rotate}"
51
+ end
52
+ end
53
+
54
+ def interlace_options
55
+ case options.progressive
56
+ when "true"
57
+ "-interlace JPEG"
58
+ when "false"
59
+ "-interlace none"
60
+ else
61
+ ""
62
+ end
63
+ end
64
+
65
+ def new_format
66
+ options.format ? "#{options.format}:" : ""
67
+ end
68
+
69
+ def file
70
+ @tempfile ||= Tempfile.new("imageproxy").tap(&:close)
71
+ end
72
+ end
@@ -0,0 +1,11 @@
1
+ require File.join(File.expand_path(File.dirname(__FILE__)), "command")
2
+
3
+ class Identify < Command
4
+ def initialize(options)
5
+ @options = options
6
+ end
7
+
8
+ def execute(user_agent=nil)
9
+ execute_command %'#{curl @options.source, :user_agent => user_agent} | identify -verbose -'
10
+ end
11
+ end
@@ -0,0 +1,73 @@
1
+ require 'uri'
2
+ require 'cgi'
3
+ require 'mime/types'
4
+
5
+ class Options
6
+ def initialize(path, query_params)
7
+ params_from_path = path.split('/').reject { |s| s.nil? || s.empty? }
8
+ command = params_from_path.shift
9
+
10
+ @hash = Hash[*params_from_path]
11
+ @hash['command'] = command
12
+ @hash.merge! query_params
13
+ merge_obfuscated
14
+ @hash["source"] = @hash.delete("src") if @hash.has_key?("src")
15
+
16
+ unescape_source
17
+ unescape_signature
18
+ check_parameters
19
+ end
20
+
21
+ def check_parameters
22
+ check_param('resize',/^[0-9]{1,5}(x[0-9]{1,5})?$/)
23
+ check_param('thumbnail',/^[0-9]{1,5}(x[0-9]{1,5})?$/)
24
+ check_param('rotate',/^(-)?[0-9]{1,3}(\.[0-9]+)?$/)
25
+ check_param('format',/^[0-9a-zA-Z]{2,6}$/)
26
+ check_param('progressive',/^true|false$/i)
27
+ check_param('background',/^#[0-9a-f]{3}([0-9a-f]{3})?|rgba\([0-9]{1,3},[0-9]{1,3},[0-9]{1,3},[0-1](.[0-9]+)?\)$/)
28
+ check_param('shape',/^preserve|pad|cut$/i)
29
+ @hash['quality'] = [[@hash['quality'].to_i, 100].min, 0].max.to_s if @hash.has_key?('quality')
30
+ end
31
+
32
+ def check_param(param, rega)
33
+ if @hash.has_key? param
34
+ if (! rega.match(@hash[param]))
35
+ @hash.delete(param)
36
+ end
37
+ end
38
+ end
39
+
40
+ def method_missing(symbol)
41
+ @hash[symbol.to_s] || @hash[symbol]
42
+ end
43
+
44
+ def content_type
45
+ MIME::Types.of(@hash['source']).first.content_type
46
+ end
47
+
48
+ private
49
+
50
+ def unescape_source
51
+ @hash['source'] &&= CGI.unescape(CGI.unescape(@hash['source']))
52
+ end
53
+
54
+ def unescape_signature
55
+ @hash['signature'] &&= URI.unescape(@hash['signature'])
56
+ end
57
+
58
+ def merge_obfuscated
59
+ if @hash["_"]
60
+ decoded = Base64.decode64(CGI.unescape(@hash["_"]))
61
+ decoded_hash = CGI.parse(decoded)
62
+ @hash.delete "_"
63
+ decoded_hash.map { |k, v| @hash[k] = (v.class == Array) ? v.first : v }
64
+ end
65
+
66
+ if @hash["-"]
67
+ decoded = Base64.decode64(CGI.unescape(@hash["-"]))
68
+ decoded_hash = Hash[*decoded.split('/').reject { |s| s.nil? || s.empty? }]
69
+ @hash.delete "-"
70
+ decoded_hash.map { |k, v| @hash[k] = (v.class == Array) ? v.first : v }
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,72 @@
1
+ class Selftest
2
+ def self.html(request, signature_required, signature_secret)
3
+ html = <<-HTML
4
+ <html>
5
+ <head>
6
+ <title>imageproxy selftest</title>
7
+ <style type="text/css">
8
+ body { background: url(/background.png); font-family: "Helvetica", sans-serif; font-size: smaller; }
9
+ h3 { margin: 2em 0 0 0; }
10
+ img { display: block; border: 1px solid black; margin: 1em 0; }
11
+ .footer { margin-top: 2em; border-top: 1px solid #999; padding-top: 0.5em; font-size: smallest; }
12
+ </style>
13
+ </head>
14
+ <body>
15
+ HTML
16
+
17
+ url_prefix = "#{request.scheme}://#{request.host_with_port}"
18
+ raw_source = "http://eahanson.s3.amazonaws.com/imageproxy/sample.png"
19
+ source = CGI.escape(URI.escape(URI.escape(raw_source)))
20
+
21
+ html += <<-HTML
22
+ <h3>Original Image</h3>
23
+ <a href="#{raw_source}">#{raw_source}</a>
24
+ <img src="#{raw_source}">
25
+ HTML
26
+
27
+ examples = [
28
+ ["Resize (regular query-string URL format)", "/convert?resize=100x100&source=#{source}"],
29
+ ["Resize (CloudFront-compatible URL format)", "/convert/resize/100x100/source/#{source}"],
30
+
31
+ ["Resize with padding", "/convert?resize=100x100&shape=pad&source=#{source}"],
32
+ ["Resize with padding & background color", "/convert?resize=100x100&shape=pad&background=%23ff00ff&source=#{source}"],
33
+
34
+ ["Resize with cutting", "/convert?resize=100x100&shape=cut&source=#{source}"],
35
+
36
+ ["Flipping horizontally", "/convert?flip=horizontal&source=#{source}"],
37
+ ["Flipping vertically", "/convert?flip=vertical&source=#{source}"],
38
+
39
+ ["Rotating to a 90-degree increment", "/convert?rotate=90&source=#{source}"],
40
+ ["Rotating to a non-90-degree increment", "/convert?rotate=120&source=#{source}"],
41
+ ["Rotating to a non-90-degree increment with a background color", "/convert?rotate=120&background=%23ff00ff&source=#{source}"],
42
+
43
+ ["Combo", "/convert?resize=100x100&shape=cut&rotate=45&background=%23ff00ff&source=#{source}"]
44
+ ]
45
+
46
+ examples.each do |example|
47
+ path = example[1]
48
+ if (signature_required)
49
+ signature = CGI.escape(Signature.create(path, signature_secret))
50
+ if path.include?("&")
51
+ path += "&signature=#{signature}"
52
+ else
53
+ path += "/signature/#{signature}"
54
+ end
55
+ end
56
+ example_url = url_prefix + path
57
+ html += <<-HTML
58
+ <h3>#{example[0]}</h3>
59
+ <a href="#{example_url}">#{example_url}</a>
60
+ <img src="#{example_url}">
61
+ HTML
62
+ end
63
+
64
+ html += <<-HTML
65
+ <div class="footer"><a href="https://github.com/eahanson/imageproxy">imageproxy</a> selftest</div>
66
+ </body>
67
+ </html>
68
+ HTML
69
+
70
+ html
71
+ end
72
+ end
@@ -0,0 +1,105 @@
1
+ require File.join(File.expand_path(File.dirname(__FILE__)), "options")
2
+ require File.join(File.expand_path(File.dirname(__FILE__)), "convert")
3
+ require File.join(File.expand_path(File.dirname(__FILE__)), "identify")
4
+ require File.join(File.expand_path(File.dirname(__FILE__)), "selftest")
5
+ require File.join(File.expand_path(File.dirname(__FILE__)), "signature")
6
+ require 'uri'
7
+
8
+ class Server
9
+ def initialize
10
+ @file_server = Rack::File.new(File.join(File.expand_path(File.dirname(__FILE__)), "..", "public"))
11
+ end
12
+
13
+ def call(env)
14
+ request = Rack::Request.new(env)
15
+ options = Options.new(request.path_info, request.params)
16
+ user_agent = request.env["HTTP_USER_AGENT"]
17
+ cachetime = config(:cache_time) ? config(:cache_time) : 86400
18
+
19
+ case options.command
20
+ when "convert", "process", nil
21
+ check_signature request, options
22
+ check_domain options
23
+ check_size options
24
+
25
+ file = Convert.new(options).execute(user_agent)
26
+ class << file
27
+ alias to_path path
28
+ end
29
+
30
+ file.open
31
+ [200, {"Content-Type" => options.content_type, "Cache-Control" => "max-age=#{cachetime}, must-revalidate"}, file]
32
+ when "identify"
33
+ check_signature request, options
34
+ check_domain options
35
+
36
+ [200, {"Content-Type" => "text/plain"}, [Identify.new(options).execute(user_agent)]]
37
+ when "selftest"
38
+ [200, {"Content-Type" => "text/html"}, [Selftest.html(request, config?(:signature_required), config(:signature_secret))]]
39
+ else
40
+ @file_server.call(env)
41
+ end
42
+ rescue
43
+ STDERR.puts $!
44
+ [500, {"Content-Type" => "text/plain"}, ["Error (#{$!})"]]
45
+ end
46
+
47
+ private
48
+
49
+ def config(symbol)
50
+ ENV["IMAGEPROXY_#{symbol.to_s.upcase}"]
51
+ end
52
+
53
+ def config?(symbol)
54
+ config(symbol) && config(symbol).casecmp("TRUE") == 0
55
+ end
56
+
57
+ def check_signature(request, options)
58
+ if config?(:signature_required)
59
+ raise "Missing siganture" if options.signature.nil?
60
+
61
+ valid_signature = Signature.correct?(options.signature, request.fullpath, config(:signature_secret))
62
+ raise "Invalid signature #{options.signature} for #{request.url}" unless valid_signature
63
+ end
64
+ end
65
+
66
+ def check_domain(options)
67
+ raise "Invalid domain" unless domain_allowed? options.source
68
+ end
69
+
70
+ def check_size(options)
71
+ raise "Image size too large" if exceeds_max_size(options.resize, options.thumbnail)
72
+ end
73
+
74
+ def domain_allowed?(url)
75
+ return true unless allowed_domains
76
+ allowed_domains.include?(url_to_domain url)
77
+ end
78
+
79
+ def url_to_domain(url)
80
+ URI::parse(url).host.split(".")[-2, 2].join(".")
81
+ rescue
82
+ ""
83
+ end
84
+
85
+ def allowed_domains
86
+ config(:allowed_domains) && config(:allowed_domains).split(",").map(&:strip)
87
+ end
88
+
89
+ def exceeds_max_size(*sizes)
90
+ max_size && sizes.any? { |size| size && requested_size(size) > max_size }
91
+ end
92
+
93
+ def max_size
94
+ config(:max_size) && config(:max_size).to_i
95
+ end
96
+
97
+ def requested_size(req_size)
98
+ sizes = req_size.scan(/\d*/)
99
+ if sizes[2] && (sizes[2].to_i > sizes[0].to_i)
100
+ sizes[2].to_i
101
+ else
102
+ sizes[0].to_i
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,24 @@
1
+ require 'base64'
2
+ require 'openssl'
3
+
4
+ class Signature
5
+ def self.create(path, secret)
6
+ Base64.encode64(OpenSSL::HMAC.digest(OpenSSL::Digest::Digest.new('sha1'), secret, remove_signature_from(path))).strip.tr('+/', '-_')
7
+ end
8
+
9
+ def self.remove_signature_from(path)
10
+ #TODO: do this in fewer passes
11
+ path.
12
+ sub(%r{&signature(=[^&]*)?(?=&|$)}, "").
13
+ sub(%r{\?signature(=[^&]*)?&}, "?").
14
+ sub(%r{\?signature(=[^&]*)?$}, "").
15
+ sub(%r{/signature/[^\?/]+/}, "/").
16
+ sub(%r{/signature/[^\?/]+\?}, "?").
17
+ sub(%r{/signature/[^\?/]+}, "")
18
+ end
19
+
20
+ def self.correct?(signature, path, secret)
21
+ created = create(path, secret)
22
+ signature != nil && path != nil && secret != nil && (created == signature || created == signature.tr('+/', '-_'))
23
+ end
24
+ end
Binary file
Binary file
Binary file
@@ -0,0 +1,19 @@
1
+ require 'spec_helper'
2
+
3
+ describe Command do
4
+ describe "#curl" do
5
+ context "when a user agent is supplied" do
6
+ it "should send that user agent" do
7
+ Command.new.send(:curl, "http://example.com/dog.jpg", :user_agent => "some user agent").should ==
8
+ %|curl -s -A "some user agent" "http://example.com/dog.jpg"|
9
+ end
10
+ end
11
+
12
+ context "when no user agent is supplied" do
13
+ it "should send a default user agent" do
14
+ Command.new.send(:curl, "http://example.com/dog.jpg").should ==
15
+ %|curl -s -A "imageproxy" "http://example.com/dog.jpg"|
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,151 @@
1
+ require 'spec_helper'
2
+
3
+ describe Convert do
4
+ before do
5
+ @mock_file = mock("file")
6
+ @mock_file.stub!(:path).and_return("/mock/file/path")
7
+ end
8
+
9
+ def command(options)
10
+ command = Convert.new(Options.new("", {:source => "http%3A%2F%2Fexample.com%2Fdog.jpg"}.merge(options)))
11
+ command.stub!(:file).and_return(@mock_file)
12
+ command.stub!(:system)
13
+ command
14
+ end
15
+
16
+ context "general" do
17
+ before do
18
+ @command = Convert.new(Options.new("/convert/format/png/resize/10x20/source/http%3A%2F%2Fexample.com%2Fdog.jpg", {}))
19
+ @command.stub!(:file).and_return(@mock_file)
20
+ @command.stub!(:system)
21
+ end
22
+
23
+ it "should generate the proper command-line" do
24
+ @command.should_receive(:execute_command).with(%'curl -s -A "imageproxy" "http://example.com/dog.jpg" | convert - -resize 10x20 png:/mock/file/path')
25
+ @command.execute
26
+ end
27
+
28
+ it "should return the output file" do
29
+ @command.stub!(:execute_command)
30
+ @command.execute.should == @mock_file
31
+ end
32
+ end
33
+
34
+ context "when resizing" do
35
+ it("with no extra args") do
36
+ command(:resize => "10x20").convert_options.should ==
37
+ '-resize 10x20'
38
+ end
39
+
40
+ it("with a different size") do
41
+ command(:resize => "50x50").convert_options.should ==
42
+ '-resize 50x50'
43
+ end
44
+
45
+ it("when preserving shape") do
46
+ command(:resize => "10x20", :shape => "preserve").convert_options.should ==
47
+ '-resize 10x20'
48
+ end
49
+
50
+ it("when padding") do
51
+ command(:resize => "10x20", :shape => "pad").convert_options.should ==
52
+ '-resize 10x20 -background none -matte -gravity center -extent 10x20'
53
+ end
54
+
55
+ it("when padding with a background color") do
56
+ command(:resize => "10x20", :shape => "pad", :background => "#ff00ff").convert_options.should ==
57
+ '-resize 10x20 -background "#ff00ff" -gravity center -extent 10x20'
58
+ end
59
+
60
+ it("when cutting") do
61
+ command(:resize => "10x20", :shape => "cut").convert_options.should ==
62
+ '-resize 10x20^ -gravity center -extent 10x20'
63
+ end
64
+ end
65
+
66
+ context "when thumbnailing" do
67
+ it("when preserving shape") do
68
+ command(:thumbnail => "10x20", :shape => "preserve").convert_options.should ==
69
+ '-thumbnail 10x20'
70
+ end
71
+
72
+ it("when padding") do
73
+ command(:thumbnail => "10x20", :shape => "pad", :background => "#ff00ff").convert_options.should ==
74
+ '-thumbnail 10x20 -background "#ff00ff" -gravity center -extent 10x20'
75
+ end
76
+
77
+ it("when cutting") do
78
+ command(:thumbnail => "10x20", :shape => "cut").convert_options.should ==
79
+ '-thumbnail 10x20^ -gravity center -extent 10x20'
80
+ end
81
+ end
82
+
83
+ context "when flipping" do
84
+ it("should flip horizontal") do
85
+ command(:flip => "horizontal").convert_options.should ==
86
+ "-flop"
87
+ end
88
+
89
+ it("should flip vertical") do
90
+ command(:flip => "vertical").convert_options.should ==
91
+ "-flip"
92
+ end
93
+ end
94
+
95
+ context "when rotating" do
96
+ it("should rotate to a right angle") do
97
+ command(:rotate => "90").convert_options.should ==
98
+ "-rotate 90"
99
+ end
100
+
101
+ it("should rotate to a non-right angle") do
102
+ command(:rotate => "92.1").convert_options.should ==
103
+ "-background none -matte -rotate 92.1"
104
+ end
105
+
106
+ it("should rotate to a non-right angle with a background") do
107
+ command(:rotate => "92.1", :background => "#ff00ff").convert_options.should ==
108
+ '-background "#ff00ff" -matte -rotate 92.1'
109
+ end
110
+ end
111
+
112
+ context "when changing format" do
113
+ it("should not change the format if not requested") do
114
+ command(:rotate => "90").new_format.should ==
115
+ ""
116
+ end
117
+
118
+ it("should not change the format if not requested") do
119
+ command(:rotate => "90", :format => "png").new_format.should ==
120
+ "png:"
121
+ end
122
+
123
+ it("should set the colors when converting to png8") do
124
+ command(:rotate => "90", :format => "png8").convert_options.should ==
125
+ "-rotate 90 -colors 256"
126
+ end
127
+ end
128
+
129
+ context "when changing quality" do
130
+ it("should set the quality") do
131
+ command(:quality => "85").convert_options.should ==
132
+ "-quality 85"
133
+ end
134
+ end
135
+
136
+ context "when converting to progressive" do
137
+ it("should be 'JPEG' if progressive is 'true'") do
138
+ command(:resize => "10x10", :progressive => "true").convert_options.should ==
139
+ "-resize 10x10 -interlace JPEG"
140
+ end
141
+
142
+ it("should be 'none' if progressive is 'false'") do
143
+ command(:resize => "10x10", :progressive => "false").convert_options.should ==
144
+ "-resize 10x10 -interlace none"
145
+ end
146
+
147
+ it("should not be set if progressive isn't supplied") do
148
+ command({:resize => "10x10"}).convert_options.should_not match /interlace/
149
+ end
150
+ end
151
+ end