├── VERSION ├── .ruby-version ├── .document ├── public ├── sample.png ├── background.png └── sample_10x20.png ├── lib ├── imageproxy.rb └── imageproxy │ ├── identify.rb │ ├── identify_format.rb │ ├── compare.rb │ ├── signature.rb │ ├── command.rb │ ├── options.rb │ ├── selftest.rb │ ├── convert.rb │ └── server.rb ├── config.ru ├── .gitignore ├── Gemfile ├── spec ├── identify_format_spec.rb ├── spec_helper.rb ├── command_spec.rb ├── signature_spec.rb ├── options_spec.rb ├── server_spec.rb └── convert_spec.rb ├── Gemfile.lock ├── LICENSE.txt ├── Rakefile ├── release-notes.mdown ├── imageproxy.gemspec └── README.mdown /VERSION: -------------------------------------------------------------------------------- 1 | 1.0.1 -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 2.2.2 2 | -------------------------------------------------------------------------------- /.document: -------------------------------------------------------------------------------- 1 | lib/**/*.rb 2 | bin/* 3 | - 4 | features/**/*.feature 5 | LICENSE.txt 6 | -------------------------------------------------------------------------------- /public/sample.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eahanson/imageproxy/HEAD/public/sample.png -------------------------------------------------------------------------------- /public/background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eahanson/imageproxy/HEAD/public/background.png -------------------------------------------------------------------------------- /public/sample_10x20.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eahanson/imageproxy/HEAD/public/sample_10x20.png -------------------------------------------------------------------------------- /lib/imageproxy.rb: -------------------------------------------------------------------------------- 1 | require 'cgi' 2 | require 'mime/types' 3 | Bundler.require :default 4 | 5 | require File.join(File.expand_path(File.dirname(__FILE__)), "imageproxy", "server") 6 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | require 'bundler' 2 | require 'rack/sendfile' 3 | 4 | require File.join(File.expand_path(File.dirname(__FILE__)), "lib", "imageproxy") 5 | 6 | run Rack::Sendfile.new(Imageproxy::Server.new) -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/* 2 | .rvmrc 3 | .bundle 4 | 5 | # rcov generated 6 | coverage 7 | 8 | # rdoc generated 9 | rdoc 10 | 11 | # yard generated 12 | doc 13 | .yardoc 14 | 15 | # bundler 16 | .bundle 17 | 18 | # jeweler generated 19 | pkg 20 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem "rack" 4 | gem "rake" 5 | gem "mime-types" 6 | 7 | group :development, :test do 8 | gem "heroku" 9 | gem "shotgun" 10 | gem "rspec" 11 | gem "rack-test", :require => "rack/test" 12 | gem "awesome_print" 13 | gem "jeweler" 14 | end 15 | -------------------------------------------------------------------------------- /lib/imageproxy/identify.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.expand_path(File.dirname(__FILE__)), "command") 2 | 3 | module Imageproxy 4 | class Identify < Imageproxy::Command 5 | def initialize(options) 6 | @options = options 7 | end 8 | 9 | def execute(user_agent=nil) 10 | execute_command %'#{curl @options.source, :user_agent => user_agent} | identify -verbose -' 11 | end 12 | end 13 | end -------------------------------------------------------------------------------- /lib/imageproxy/identify_format.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.expand_path(File.dirname(__FILE__)), "command") 2 | 3 | module Imageproxy 4 | class IdentifyFormat < Imageproxy::Command 5 | def initialize(file) 6 | @file = file 7 | end 8 | 9 | def execute 10 | result = execute_command %'identify -format "%m" #{@file.path}' 11 | result.start_with?("identify:") ? nil : result 12 | end 13 | end 14 | end -------------------------------------------------------------------------------- /lib/imageproxy/compare.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.expand_path(File.dirname(__FILE__)), "command") 2 | 3 | module Imageproxy 4 | class Compare < Imageproxy::Command 5 | def initialize(a, b) 6 | @path_a = to_path(a) 7 | @path_b = to_path(b) 8 | end 9 | 10 | def execute 11 | execute_command %'compare -metric AE -fuzz 10% "#{@path_a}" "#{@path_b}" "#{Tempfile.new("compare").path}"' 12 | end 13 | end 14 | end -------------------------------------------------------------------------------- /spec/identify_format_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Imageproxy::IdentifyFormat do 4 | before do 5 | @mock_file = mock("file") 6 | @mock_file.stub!(:path).and_return("/tmp/foo") 7 | end 8 | 9 | context "when the format looks valid" do 10 | it "should return the format" do 11 | command = Imageproxy::IdentifyFormat.new(@mock_file) 12 | command.stub!(:execute_command).and_return("JPEG") 13 | command.execute.should == "JPEG" 14 | end 15 | end 16 | 17 | context "when the format looks like an error message" do 18 | it "should return nil" do 19 | command = Imageproxy::IdentifyFormat.new(@mock_file) 20 | command.stub!(:execute_command).and_return("identify: some error message") 21 | command.execute.should == nil 22 | end 23 | end 24 | end -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | Bundler.require :test 2 | 3 | Dir.glob(File.join(File.dirname(__FILE__), "..", "lib", "**", "*.rb")).each {|f| require f } 4 | 5 | def response_body_as_file 6 | result_file = Tempfile.new("request_spec") 7 | result_file.write(last_response.body) 8 | result_file.close 9 | result_file 10 | end 11 | 12 | def test_image_path(size=nil) 13 | size_suffix = size.nil? ? "" : "_#{size}" 14 | File.expand_path(File.dirname(__FILE__) + "/../public/sample#{size_suffix}.png") 15 | end 16 | 17 | def test_image_url 18 | "file://#{test_image_path}" 19 | end 20 | 21 | def escaped_test_image_url 22 | CGI.escape test_image_url 23 | end 24 | 25 | def test_broken_image_path 26 | File.expand_path(File.dirname(__FILE__) + "/../public/does-not-exist.png") 27 | end 28 | 29 | def test_broken_image_url 30 | "file://#{test_broken_image_path}" 31 | end 32 | 33 | def escaped_test_broken_image_url 34 | CGI.escape test_broken_image_url 35 | end 36 | -------------------------------------------------------------------------------- /lib/imageproxy/signature.rb: -------------------------------------------------------------------------------- 1 | require 'base64' 2 | require 'openssl' 3 | 4 | module Imageproxy 5 | class Signature 6 | def self.create(path, secret) 7 | Base64.encode64(OpenSSL::HMAC.digest(OpenSSL::Digest.new('sha1'), secret, remove_signature_from(path))).strip.tr('+/', '-_') 8 | end 9 | 10 | def self.remove_signature_from(path) 11 | #TODO: do this in fewer passes 12 | path. 13 | sub(%r{&signature(=[^&]*)?(?=&|$)}, ""). 14 | sub(%r{\?signature(=[^&]*)?&}, "?"). 15 | sub(%r{\?signature(=[^&]*)?$}, ""). 16 | sub(%r{/signature/[^\?/]+/}, "/"). 17 | sub(%r{/signature/[^\?/]+\?}, "?"). 18 | sub(%r{/signature/[^\?/]+}, "") 19 | end 20 | 21 | def self.correct?(signature, path, secret) 22 | created = create(path, secret) 23 | signature != nil && path != nil && secret != nil && (created == signature || created == signature.tr('+/', '-_')) 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | awesome_print (0.3.2) 5 | configuration (1.2.0) 6 | diff-lcs (1.1.2) 7 | git (1.2.5) 8 | heroku (1.18.3) 9 | launchy (~> 0.3.2) 10 | rest-client (>= 1.4.0, < 1.7.0) 11 | jeweler (1.6.2) 12 | bundler (~> 1.0) 13 | git (>= 1.2.5) 14 | rake 15 | launchy (0.3.7) 16 | configuration (>= 0.0.5) 17 | rake (>= 0.8.1) 18 | mime-types (1.16) 19 | rack (1.2.2) 20 | rack-test (0.5.7) 21 | rack (>= 1.0) 22 | rake (10.4.2) 23 | rest-client (1.6.1) 24 | mime-types (>= 1.16) 25 | rspec (2.5.0) 26 | rspec-core (~> 2.5.0) 27 | rspec-expectations (~> 2.5.0) 28 | rspec-mocks (~> 2.5.0) 29 | rspec-core (2.5.1) 30 | rspec-expectations (2.5.0) 31 | diff-lcs (~> 1.1.2) 32 | rspec-mocks (2.5.0) 33 | shotgun (0.9) 34 | rack (>= 1.0) 35 | 36 | PLATFORMS 37 | ruby 38 | 39 | DEPENDENCIES 40 | awesome_print 41 | heroku 42 | jeweler 43 | mime-types 44 | rack 45 | rack-test 46 | rake 47 | rspec 48 | shotgun 49 | 50 | BUNDLED WITH 51 | 1.10.5 52 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2011 Erik Hanson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/imageproxy/command.rb: -------------------------------------------------------------------------------- 1 | require 'open3' 2 | 3 | module Imageproxy 4 | class Command 5 | protected 6 | 7 | def execute_command(command_line) 8 | stdin, stdout, stderr, wait_thr = Open3.popen3(command_line) 9 | unless wait_thr.nil? || wait_thr.value.success? 10 | $stderr.puts output_to_string(stderr) 11 | raise "Child process exited with non-zero exit code" 12 | end 13 | [output_to_string(stdout), output_to_string(stderr)].join("") 14 | end 15 | 16 | def curl(url, options={}) 17 | user_agent = options[:user_agent] || "imageproxy" 18 | timeout = options[:timeout] ? "-m #{options[:timeout]} " : "" 19 | authInfo = options[:authInfo] 20 | language = options[:language] 21 | output = options[:output] 22 | %|curl #{timeout}-L -f -s -S #{language ? "--header \"accept-language: #{language}\" " : ""} #{authInfo ? "-u #{authInfo} " : ""} -A "#{user_agent}" #{output ? "-o #{output} ": ""}"#{url}"| 23 | end 24 | 25 | def to_path(obj) 26 | obj.respond_to?(:path) ? obj.path : obj.to_s 27 | end 28 | 29 | def output_to_string(output) 30 | output.readlines.join("").chomp 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler' 2 | Bundler.require :test 3 | 4 | desc "Run all specs" 5 | task :spec do 6 | system 'rspec --color spec' 7 | end 8 | 9 | task :default => :spec 10 | 11 | desc "Run the server locally (for development)" 12 | task :run do 13 | require 'uri' 14 | puts < "some user agent").should == 8 | %|curl -L -f -s -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 | Imageproxy::Command.new.send(:curl, "http://example.com/dog.jpg").should == 15 | %|curl -L -f -s -S -A "imageproxy" "http://example.com/dog.jpg"| 16 | end 17 | end 18 | 19 | context "when a timeout is supplied" do 20 | it "should set the timeout" do 21 | Imageproxy::Command.new.send(:curl, "http://example.com/dog.jpg", :timeout => "2").should == 22 | %|curl -m 2 -L -f -s -S -A "imageproxy" "http://example.com/dog.jpg"| 23 | end 24 | end 25 | 26 | it "should have curl report errors on stderr" do 27 | curl_command = Imageproxy::Command.new.send(:curl, "http://example.com/dog.jpg") 28 | curl_command.should match(/curl .*-f -s -S .*/) 29 | end 30 | 31 | context "when the command exits with a non-zero status" do 32 | it "should raise an exception" do 33 | ruby_19 = RUBY_VERSION.split(".").map(&:to_i)[1] == 9 34 | 35 | if ruby_19 36 | lambda { 37 | Imageproxy::Command.new.send(:execute_command, "ls /asdkljasldkjaskl") 38 | }.should raise_exception 39 | end 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /release-notes.mdown: -------------------------------------------------------------------------------- 1 | Imageproxy Release Notes 2 | ======================== 3 | 4 | See the [Imageproxy readme](https://github.com/eahanson/imageproxy/blob/master/README.mdown) for more info. 5 | 6 | ### 1.0.1 - February 24, 2014 7 | 8 | * Fix warning: "The source :rubygems is deprecated because HTTP requests are insecure" (thanks @mahemoff) 9 | * Replace deprecated Digest::Digest with Digest (thanks @mahemoff) 10 | 11 | ### 1.0 - February 22, 2013 12 | 13 | * Bumped version to 1.0 -- imageproxy has been in production for over 1.5 years at this point and deserves a real version number 14 | * Added `world_readable_tempfile` option to make the permissions of the generated tempfiles to be `-rw-r--r--` instead of the default `-rw-------` 15 | 16 | ### 0.4.3 - November 26, 2012 17 | 18 | * Fix error on Heroku Cedar (thanks Daniel Szmulewicz!) 19 | * Retry `curl` command after 10 second timeout 20 | 21 | ### 0.4.2 - August 8, 2012 22 | 23 | * Have `curl` report errors to standard error 24 | 25 | ### 0.4.1 - July 24, 2012 26 | 27 | * Improve logging on errors 28 | 29 | ### 0.4.0 - May 16, 2012 30 | 31 | * Add ability to composite two images 32 | 33 | ### 0.3.0 - May 15, 2012 34 | 35 | * Allow '.' characters in base 64-encoded path, which gets replaced with '=' characters before decoding 36 | 37 | ### 0.2.0 - August 28, 2011 38 | 39 | * When a command fails, only raise an exception in Ruby 1.9, not 1.8 40 | 41 | ### 0.1.4 - August 24, 2011 42 | 43 | * Add timeout option 44 | 45 | ### 0.1.3 - June 9, 2011 46 | 47 | * Use ImageMagick's `identify` command to figure out the content type of the image 48 | * Follow redirects when requesting images 49 | 50 | ### 0.1.0 - June 8, 2011 51 | 52 | * Initial version 53 | -------------------------------------------------------------------------------- /lib/imageproxy/options.rb: -------------------------------------------------------------------------------- 1 | require 'uri' 2 | require 'cgi' 3 | require 'mime/types' 4 | 5 | module Imageproxy 6 | class Options 7 | def initialize(path, query_params) 8 | params_from_path = path.split('/').reject { |s| s.nil? || s.empty? } 9 | command = params_from_path.shift 10 | 11 | @hash = Hash[*params_from_path] 12 | @hash['command'] = command 13 | @hash.merge! query_params 14 | merge_obfuscated 15 | @hash["source"] = @hash.delete("src") if @hash.has_key?("src") 16 | 17 | unescape_source 18 | unescape_overlay 19 | unescape_signature 20 | check_parameters 21 | end 22 | 23 | def check_parameters 24 | check_param('resize', /^[0-9]{1,5}(x[0-9]{1,5})?$/) 25 | check_param('thumbnail', /^[0-9]{1,5}(x[0-9]{1,5})?$/) 26 | check_param('rotate', /^(-)?[0-9]{1,3}(\.[0-9]+)?$/) 27 | check_param('format', /^[0-9a-zA-Z]{2,6}$/) 28 | check_param('progressive', /^true|false$/i) 29 | 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]+)?\)$/) 30 | check_param('shape', /^preserve|pad|cut$/i) 31 | @hash['quality'] = [[@hash['quality'].to_i, 100].min, 0].max.to_s if @hash.has_key?('quality') 32 | end 33 | 34 | def check_param(param, rega) 35 | if @hash.has_key? param 36 | if (!rega.match(@hash[param])) 37 | @hash.delete(param) 38 | end 39 | end 40 | end 41 | 42 | def method_missing(symbol) 43 | @hash[symbol.to_s] || @hash[symbol] 44 | end 45 | 46 | def to_s 47 | @hash.map do |key, value| 48 | if key && value 49 | "#{CGI::escape(key)}=#{CGI::escape(value)}" 50 | else 51 | nil 52 | end 53 | end.compact.join(', ') 54 | end 55 | 56 | private 57 | 58 | def unescape_source 59 | @hash['source'] &&= CGI.unescape(CGI.unescape(@hash['source'])) 60 | end 61 | 62 | def unescape_overlay 63 | @hash['overlay'] &&= CGI.unescape(CGI.unescape(@hash['overlay'])) 64 | end 65 | 66 | def unescape_signature 67 | @hash['signature'] &&= URI.unescape(@hash['signature']) 68 | end 69 | 70 | def merge_obfuscated 71 | if @hash["_"] 72 | decoded = decode64(CGI.unescape(@hash["_"])) 73 | decoded_hash = CGI.parse(decoded) 74 | @hash.delete "_" 75 | decoded_hash.map { |k, v| @hash[k] = (v.class == Array) ? v.first : v } 76 | end 77 | 78 | if @hash["-"] 79 | decoded = decode64(CGI.unescape(@hash["-"])) 80 | decoded_hash = Hash[*decoded.split('/').reject { |s| s.nil? || s.empty? }] 81 | @hash.delete "-" 82 | decoded_hash.map { |k, v| @hash[k] = (v.class == Array) ? v.first : v } 83 | end 84 | end 85 | 86 | def decode64(encoded) 87 | Base64.decode64(encoded.gsub(".", "=")) 88 | end 89 | end 90 | end -------------------------------------------------------------------------------- /lib/imageproxy/selftest.rb: -------------------------------------------------------------------------------- 1 | module Imageproxy 2 | class Selftest 3 | def self.html(request, signature_required, signature_secret) 4 | html = <<-HTML 5 | 6 | 7 | imageproxy selftest 8 | 14 | 15 | 16 | HTML 17 | 18 | url_prefix = "#{request.scheme}://#{request.host_with_port}" 19 | raw_source = "http://eahanson.s3.amazonaws.com/imageproxy/sample.png" 20 | source = CGI.escape(URI.escape(URI.escape(raw_source))) 21 | 22 | raw_overlay = "http://www.imagemagick.org/image/smile.gif" 23 | overlay = CGI.escape(URI.escape(URI.escape(raw_overlay))) 24 | 25 | html += <<-HTML 26 |

Original Image

27 | #{raw_source} 28 | 29 | HTML 30 | 31 | examples = [ 32 | ["Resize (regular query-string URL format)", "/convert?resize=100x100&source=#{source}"], 33 | ["Resize (CloudFront-compatible URL format)", "/convert/resize/100x100/source/#{source}"], 34 | 35 | ["Resize with padding", "/convert?resize=100x100&shape=pad&source=#{source}"], 36 | ["Resize with padding & background color", "/convert?resize=100x100&shape=pad&background=%23ff00ff&source=#{source}"], 37 | 38 | ["Resize with cutting", "/convert?resize=100x100&shape=cut&source=#{source}"], 39 | 40 | ["Flipping horizontally", "/convert?flip=horizontal&source=#{source}"], 41 | ["Flipping vertically", "/convert?flip=vertical&source=#{source}"], 42 | 43 | ["Rotating to a 90-degree increment", "/convert?rotate=90&source=#{source}"], 44 | ["Rotating to a non-90-degree increment", "/convert?rotate=120&source=#{source}"], 45 | ["Rotating to a non-90-degree increment with a background color", "/convert?rotate=120&background=%23ff00ff&source=#{source}"], 46 | 47 | ["Combo", "/convert?resize=100x100&shape=cut&rotate=45&background=%23ff00ff&source=#{source}"], 48 | 49 | ["Compositing", "/convert?source=#{source}&overlay=#{overlay}"], 50 | ["Composite and then do something else", "/convert?source=#{source}&overlay=#{overlay}&rotate=50"] 51 | ] 52 | 53 | examples.each do |example| 54 | path = example[1] 55 | if (signature_required) 56 | signature = CGI.escape(Signature.create(path, signature_secret)) 57 | if path.include?("&") 58 | path += "&signature=#{signature}" 59 | else 60 | path += "/signature/#{signature}" 61 | end 62 | end 63 | example_url = url_prefix + path 64 | html += <<-HTML 65 |

#{example[0]}

66 | #{example_url} 67 | 68 | HTML 69 | end 70 | 71 | html += <<-HTML 72 | 73 | 74 | 75 | HTML 76 | 77 | html 78 | end 79 | end 80 | end -------------------------------------------------------------------------------- /imageproxy.gemspec: -------------------------------------------------------------------------------- 1 | # Generated by jeweler 2 | # DO NOT EDIT THIS FILE DIRECTLY 3 | # Instead, edit Jeweler::Tasks in Rakefile, and run 'rake gemspec' 4 | # -*- encoding: utf-8 -*- 5 | 6 | Gem::Specification.new do |s| 7 | s.name = "imageproxy" 8 | s.version = "1.0.1" 9 | 10 | s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version= 11 | s.authors = ["Erik Hanson"] 12 | s.date = "2014-02-24" 13 | s.description = "A image processing proxy server, written in Ruby as a Rack application. Requires ImageMagick." 14 | s.email = "erik@eahanson.com" 15 | s.extra_rdoc_files = [ 16 | "LICENSE.txt", 17 | "README.mdown" 18 | ] 19 | s.files = [ 20 | ".document", 21 | "Gemfile", 22 | "Gemfile.lock", 23 | "LICENSE.txt", 24 | "README.mdown", 25 | "Rakefile", 26 | "VERSION", 27 | "config.ru", 28 | "imageproxy.gemspec", 29 | "lib/imageproxy.rb", 30 | "lib/imageproxy/command.rb", 31 | "lib/imageproxy/compare.rb", 32 | "lib/imageproxy/convert.rb", 33 | "lib/imageproxy/identify.rb", 34 | "lib/imageproxy/identify_format.rb", 35 | "lib/imageproxy/options.rb", 36 | "lib/imageproxy/selftest.rb", 37 | "lib/imageproxy/server.rb", 38 | "lib/imageproxy/signature.rb", 39 | "public/background.png", 40 | "public/sample.png", 41 | "public/sample_10x20.png", 42 | "release-notes.mdown", 43 | "spec/command_spec.rb", 44 | "spec/convert_spec.rb", 45 | "spec/identify_format_spec.rb", 46 | "spec/options_spec.rb", 47 | "spec/server_spec.rb", 48 | "spec/signature_spec.rb", 49 | "spec/spec_helper.rb" 50 | ] 51 | s.homepage = "http://github.com/eahanson/imageproxy" 52 | s.licenses = ["MIT"] 53 | s.require_paths = ["lib"] 54 | s.rubygems_version = "1.8.24" 55 | s.summary = "A image processing proxy server, written in Ruby as a Rack application." 56 | 57 | if s.respond_to? :specification_version then 58 | s.specification_version = 3 59 | 60 | if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then 61 | s.add_runtime_dependency(%q, [">= 0"]) 62 | s.add_runtime_dependency(%q, [">= 0"]) 63 | s.add_runtime_dependency(%q, [">= 0"]) 64 | s.add_development_dependency(%q, [">= 0"]) 65 | s.add_development_dependency(%q, [">= 0"]) 66 | s.add_development_dependency(%q, [">= 0"]) 67 | s.add_development_dependency(%q, [">= 0"]) 68 | s.add_development_dependency(%q, [">= 0"]) 69 | s.add_development_dependency(%q, [">= 0"]) 70 | else 71 | s.add_dependency(%q, [">= 0"]) 72 | s.add_dependency(%q, [">= 0"]) 73 | s.add_dependency(%q, [">= 0"]) 74 | s.add_dependency(%q, [">= 0"]) 75 | s.add_dependency(%q, [">= 0"]) 76 | s.add_dependency(%q, [">= 0"]) 77 | s.add_dependency(%q, [">= 0"]) 78 | s.add_dependency(%q, [">= 0"]) 79 | s.add_dependency(%q, [">= 0"]) 80 | end 81 | else 82 | s.add_dependency(%q, [">= 0"]) 83 | s.add_dependency(%q, [">= 0"]) 84 | s.add_dependency(%q, [">= 0"]) 85 | s.add_dependency(%q, [">= 0"]) 86 | s.add_dependency(%q, [">= 0"]) 87 | s.add_dependency(%q, [">= 0"]) 88 | s.add_dependency(%q, [">= 0"]) 89 | s.add_dependency(%q, [">= 0"]) 90 | s.add_dependency(%q, [">= 0"]) 91 | end 92 | end 93 | 94 | -------------------------------------------------------------------------------- /spec/signature_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Imageproxy::Signature do 4 | describe "#create" do 5 | it "should create a signature from a query string" do 6 | Imageproxy::Signature.create("/convert?src=http://www.example.com/dog.jpg&resize=400x400&signature=AAA&key=BBB", "SECRET").should_not be_nil 7 | end 8 | 9 | it "should ignore the signature param" do 10 | Imageproxy::Signature.create("/convert?src=SRC&resize=10x10&signature=SIG&key=KEY", "SECRET").should == Imageproxy::Signature.create("/convert?src=SRC&resize=10x10&key=KEY", "SECRET") 11 | end 12 | end 13 | 14 | describe "#correct?" do 15 | it "should validate a signature" do 16 | Imageproxy::Signature.correct?(Imageproxy::Signature.create("/convert?src=SRC&resize=10x10&key=KEY", "SECRET"), "/convert?src=SRC&resize=10x10&key=KEY", "SECRET").should be_true 17 | end 18 | 19 | it "should return false if signature is nil" do 20 | Imageproxy::Signature.correct?(nil, "/convert?src=SRC&resize=10x10&key=KEY", "SECRET").should be_false 21 | end 22 | 23 | it "should handle URL-safe signatures" do 24 | Imageproxy::Signature.correct?("_v70E0zfdcRR4cJehS2mhvqJ-8s=", "YLANEBHFSJGCAWKDNCKWEKJRXKPMYU", "SECRET").should be_true 25 | end 26 | 27 | it "should handle non-URL-safe signatures" do 28 | Imageproxy::Signature.correct?("k7DMQ/G8YAsbSovX+mDFjlHHMjo=", "YPMMYCRRECCCIPSXPDDFIJFSINOIRC", "SECRET").should be_true 29 | end 30 | end 31 | 32 | describe "#remove_signature_from" do 33 | it "should remove the signature when it's the only query param" do 34 | Imageproxy::Signature.remove_signature_from("/convert/a/apple/b/banana?signature=SIG").should == 35 | "/convert/a/apple/b/banana" 36 | end 37 | 38 | it "should remove the signature from the beginning of the query string" do 39 | Imageproxy::Signature.remove_signature_from("/convert/a/apple/b/banana?signature=SIG&c=cherry&d=donut").should == 40 | "/convert/a/apple/b/banana?c=cherry&d=donut" 41 | end 42 | 43 | it "should remove the signature from the middle of the query string" do 44 | Imageproxy::Signature.remove_signature_from("/convert/a/apple/b/banana?c=cherry&signature=SIG&d=donut").should == 45 | "/convert/a/apple/b/banana?c=cherry&d=donut" 46 | end 47 | 48 | it "should remove the signature from the end of the query string" do 49 | Imageproxy::Signature.remove_signature_from("/convert/a/apple/b/banana?c=cherry&d=donut&signature=SIG").should == 50 | "/convert/a/apple/b/banana?c=cherry&d=donut" 51 | end 52 | 53 | it "should remove the signature from the beginning of the path" do 54 | Imageproxy::Signature.remove_signature_from("/convert/signature/SIG/a/apple/b/banana?c=cherry&d=donut").should == 55 | "/convert/a/apple/b/banana?c=cherry&d=donut" 56 | end 57 | 58 | it "should remove the signature from the middle of the path" do 59 | Imageproxy::Signature.remove_signature_from("/convert/a/apple/signature/SIG/b/banana?c=cherry&d=donut").should == 60 | "/convert/a/apple/b/banana?c=cherry&d=donut" 61 | end 62 | 63 | it "should remove the signature from the end of the path" do 64 | Imageproxy::Signature.remove_signature_from("/convert/a/apple/b/banana/signature/SIG?c=cherry&d=donut").should == 65 | "/convert/a/apple/b/banana?c=cherry&d=donut" 66 | end 67 | 68 | it "should remove the signature from the end of the path when there's no query string" do 69 | Imageproxy::Signature.remove_signature_from("/convert/a/apple/b/banana/signature/SIG").should == 70 | "/convert/a/apple/b/banana" 71 | end 72 | end 73 | end 74 | 75 | -------------------------------------------------------------------------------- /lib/imageproxy/convert.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.expand_path(File.dirname(__FILE__)), "command") 2 | require "timeout" 3 | 4 | module Imageproxy 5 | class Convert < Imageproxy::Command 6 | attr_reader :options 7 | 8 | def initialize(options, settings={}) 9 | @options = options 10 | @settings = settings 11 | 12 | if (!(options.resize || options.thumbnail || options.rotate || options.flip || options.format || 13 | options.quality || options.overlay)) 14 | raise "Missing action or illegal parameter value" 15 | end 16 | end 17 | 18 | def execute(user_agent=nil, timeout=nil) 19 | if options.overlay 20 | @overlay_file ||= Tempfile.new("imageproxy").tap(&:close) 21 | try_command_with_timeout(curl options.overlay, :user_agent => user_agent, :timeout => timeout, :authInfo => options.authInfo, :language => options.language, :output => @overlay_file.path) 22 | try_command_with_timeout curl(options.source, :user_agent => user_agent, :timeout => timeout, :authInfo => options.authInfo, :language => options.language) + 23 | "| composite #{@overlay_file.path} - - | convert - #{convert_options} #{new_format}#{file.path}" 24 | file 25 | else 26 | try_command_with_timeout %'#{curl options.source, :user_agent => user_agent, :timeout => timeout, :authInfo => options.authInfo, :language => options.language} | convert - #{convert_options} #{new_format}#{file.path}' 27 | file 28 | end 29 | end 30 | 31 | def try_command_with_timeout cmd 32 | Timeout::timeout(10) { execute_command cmd } 33 | rescue Timeout::Error => e 34 | puts "Command timed out after 10 seconds, retrying >#{cmd}<" 35 | execute_command cmd 36 | puts "SUCCESS " * 20 37 | rescue Exception => e 38 | puts "Error while retrieving #{options.source}" 39 | execute_command %'convert #{Dir.pwd}/public/noImage.png #{convert_options} #{new_format}#{file.path}' 40 | end 41 | 42 | def convert_options 43 | convert_options = [] 44 | convert_options << "-resize #{resize_thumbnail_options(options.resize)}" if options.resize 45 | convert_options << "-thumbnail #{resize_thumbnail_options(options.thumbnail)}" if options.thumbnail 46 | convert_options << "-flop" if options.flip == "horizontal" 47 | convert_options << "-flip" if options.flip == "vertical" 48 | convert_options << rotate_options if options.rotate 49 | convert_options << "-colors 256" if options.format == "png8" 50 | convert_options << "-quality #{options.quality}" if options.quality 51 | convert_options << interlace_options if options.progressive 52 | convert_options.join " " 53 | end 54 | 55 | def resize_thumbnail_options(size) 56 | case options.shape 57 | when "cut" 58 | background = options.background ? %|"#{options.background}"| : %|none -matte| 59 | "#{size}^ -background #{background} -gravity center -extent #{size}" 60 | when "preserve" 61 | size 62 | when "preserve-not-enlarge" 63 | "#{size}\\\> " 64 | when "pad" 65 | background = options.background ? %|"#{options.background}"| : %|none -matte| 66 | "#{size} -background #{background} -gravity center -extent #{size}" 67 | else 68 | size 69 | end 70 | end 71 | 72 | def rotate_options 73 | if options.rotate.to_f % 90 == 0 74 | "-rotate #{options.rotate}" 75 | else 76 | background = options.background ? %|"#{options.background}"| : %|none| 77 | "-background #{background} -matte -rotate #{options.rotate}" 78 | end 79 | end 80 | 81 | def interlace_options 82 | case options.progressive 83 | when "true" 84 | "-interlace JPEG" 85 | when "false" 86 | "-interlace none" 87 | else 88 | "" 89 | end 90 | end 91 | 92 | def new_format 93 | options.format ? "#{options.format}:" : "" 94 | end 95 | 96 | def file 97 | @tempfile ||= begin 98 | file = Tempfile.new("imageproxy") 99 | file.chmod 0644 if @settings[:world_readable_tempfile] 100 | file.close 101 | file 102 | end 103 | end 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /lib/imageproxy/server.rb: -------------------------------------------------------------------------------- 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__)), "identify_format") 5 | require File.join(File.expand_path(File.dirname(__FILE__)), "selftest") 6 | require File.join(File.expand_path(File.dirname(__FILE__)), "signature") 7 | require 'uri' 8 | 9 | module Imageproxy 10 | class Server 11 | def initialize 12 | @file_server = Rack::File.new(File.join(File.expand_path(File.dirname(__FILE__)), "..", "public")) 13 | end 14 | 15 | def call(env) 16 | request = Rack::Request.new(env) 17 | options = Options.new(request.path_info, request.params) 18 | user_agent = request.env["HTTP_USER_AGENT"] 19 | cachetime = config(:cache_time) ? config(:cache_time) : 86400 20 | 21 | case options.command 22 | when "convert", "process", nil 23 | check_signature request, options 24 | check_domain options 25 | check_size options 26 | 27 | file = convert_file(options, user_agent) 28 | class << file 29 | alias to_path path 30 | end 31 | 32 | file.open 33 | [200, {"Cache-Control" => "max-age=#{cachetime}, must-revalidate"}.merge(content_type(file, options)), file] 34 | when "identify" 35 | check_signature request, options 36 | check_domain options 37 | 38 | [200, {"Content-Type" => "text/plain"}, [Identify.new(options).execute(user_agent)]] 39 | when "selftest" 40 | [200, {"Content-Type" => "text/html"}, [Selftest.html(request, config?(:signature_required), config(:signature_secret))]] 41 | else 42 | @file_server.call(env) 43 | end 44 | rescue 45 | STDERR.puts "Request failed: #{options}" 46 | STDERR.puts $! 47 | STDERR.puts $!.backtrace.join("\n") if config?(:verbose) 48 | [500, {"Content-Type" => "text/plain"}, ["Error (#{$!})"]] 49 | end 50 | 51 | private 52 | 53 | def convert_file(options, user_agent) 54 | Convert. 55 | new(options, :world_readable_tempfile => config?(:world_readable_tempfile)). 56 | execute(user_agent, config(:timeout)) 57 | end 58 | 59 | def config(symbol) 60 | ENV["IMAGEPROXY_#{symbol.to_s.upcase}"] 61 | end 62 | 63 | def config?(symbol) 64 | config(symbol) && config(symbol).casecmp("TRUE") == 0 65 | end 66 | 67 | def check_signature(request, options) 68 | if config?(:signature_required) 69 | raise "Missing siganture" if options.signature.nil? 70 | 71 | valid_signature = Signature.correct?(options.signature, request.fullpath, config(:signature_secret)) 72 | raise "Invalid signature #{options.signature} for #{request.url}" unless valid_signature 73 | end 74 | end 75 | 76 | def check_domain(options) 77 | raise "Invalid domain" unless domain_allowed? options.source 78 | end 79 | 80 | def check_size(options) 81 | raise "Image size too large" if exceeds_max_size(options.resize, options.thumbnail) 82 | end 83 | 84 | def domain_allowed?(url) 85 | return true unless allowed_domains 86 | allowed_domains.include?(url_to_domain url) 87 | end 88 | 89 | def url_to_domain(url) 90 | URI::parse(url).host.split(".")[-2, 2].join(".") 91 | rescue 92 | "" 93 | end 94 | 95 | def allowed_domains 96 | config(:allowed_domains) && config(:allowed_domains).split(",").map(&:strip) 97 | end 98 | 99 | def exceeds_max_size(*sizes) 100 | max_size && sizes.any? { |size| size && requested_size(size) > max_size } 101 | end 102 | 103 | def max_size 104 | config(:max_size) && config(:max_size).to_i 105 | end 106 | 107 | def requested_size(req_size) 108 | sizes = req_size.scan(/\d*/) 109 | if sizes[2] && (sizes[2].to_i > sizes[0].to_i) 110 | sizes[2].to_i 111 | else 112 | sizes[0].to_i 113 | end 114 | end 115 | 116 | def content_type(file, options) 117 | format = options.format 118 | format = identify_format(file) unless format 119 | format = options.source unless format 120 | format ? { "Content-Type" => MIME::Types.of(format).first.content_type } : {} 121 | end 122 | 123 | def identify_format(file) 124 | Imageproxy::IdentifyFormat.new(file).execute 125 | end 126 | end 127 | end 128 | -------------------------------------------------------------------------------- /spec/options_spec.rb: -------------------------------------------------------------------------------- 1 | require 'base64' 2 | require "#{File.dirname(__FILE__)}/../lib/imageproxy" 3 | 4 | describe Imageproxy::Options do 5 | describe "parsing path" do 6 | context "a simple URL" do 7 | subject { Imageproxy::Options.new "/process/color/blue/size/medium", {} } 8 | its(:command) { should == "process" } 9 | its(:color) { should == "blue" } 10 | its(:size) { should == "medium" } 11 | end 12 | 13 | context "source" do 14 | context "when double-escaped" do 15 | subject { Imageproxy::Options.new "/process/source/http%253A%252F%252Fexample.com%252Fdog.jpg", {} } 16 | it("should unescape") { subject.source.should == "http://example.com/dog.jpg" } 17 | end 18 | 19 | context "when escaped" do 20 | subject { Imageproxy::Options.new "/process/source/http%3A%2F%2Fexample.com%2Fdog.jpg", {} } 21 | it("should unescape") { subject.source.should == "http://example.com/dog.jpg" } 22 | end 23 | 24 | context "when not escaped" do 25 | subject { Imageproxy::Options.new "/process/source/foo", {} } 26 | it("should not unescape") { subject.source.should == "foo" } 27 | end 28 | 29 | context "when parameter is named 'src'" do 30 | subject { Imageproxy::Options.new "/process/src/foo", {} } 31 | it("should rename to 'source'") { subject.source.should == "foo" } 32 | end 33 | end 34 | 35 | context "overlay" do 36 | context "when double-escaped" do 37 | subject { Imageproxy::Options.new "/convert/overlay/http%253A%252F%252Fexample.com%252Fframe.jpg", {} } 38 | it("should unescape") { subject.overlay.should == "http://example.com/frame.jpg" } 39 | end 40 | 41 | context "when escaped" do 42 | subject { Imageproxy::Options.new "/convert/overlay/http%3A%2F%2Fexample.com%2Fframe.jpg", {} } 43 | it("should unescape") { subject.overlay.should == "http://example.com/frame.jpg" } 44 | end 45 | 46 | context "when not escaped" do 47 | subject { Imageproxy::Options.new "/convert/overlay/foo", {} } 48 | it("should not unescape") { subject.overlay.should == "foo" } 49 | end 50 | end 51 | 52 | context "signature" do 53 | context "when escaped with + signs" do 54 | subject { Imageproxy::Options.new "/process/source/foo/signature/foo+bar", {} } 55 | it("should keep the + sign") { subject.signature.should == "foo+bar" } 56 | end 57 | end 58 | end 59 | 60 | describe "adding query params" do 61 | subject { Imageproxy::Options.new "/convert/source/foo", { "resize" => "20x20" } } 62 | it("should add query params") { subject.resize.should == "20x20" } 63 | it("should keep params from path") { subject.source.should == "foo" } 64 | end 65 | 66 | describe "obfuscation" do 67 | it "should allow the query string to be encoded in base64" do 68 | encoded = CGI.escape(Base64.encode64("resize=20x20&source=http://example.com/dog.jpg")) 69 | options = Imageproxy::Options.new "/convert", "_" => encoded 70 | options.resize.should == "20x20" 71 | options.source.should == "http://example.com/dog.jpg" 72 | end 73 | 74 | it "should allow the path to be encoded in base64" do 75 | encoded = CGI.escape(Base64.encode64("resize/20x20/source/http%3A%2F%2Fexample.com%2Fdog.jpg")) 76 | options = Imageproxy::Options.new "/convert/-/#{encoded}", {} 77 | options.resize.should == "20x20" 78 | options.source.should == "http://example.com/dog.jpg" 79 | end 80 | 81 | it "should allow padding with dots instead of equals signs" do 82 | encoded = Base64.encode64("resize/20x20/source/http%3A%2F%2Fexample.com%2Fdo.jpg") 83 | encoded.should include "=" 84 | encoded.gsub! "=", "." 85 | escaped = CGI.escape(encoded) 86 | options = Imageproxy::Options.new "/convert/-/#{escaped}", {} 87 | options.resize.should == "20x20" 88 | options.source.should == "http://example.com/do.jpg" 89 | end 90 | end 91 | 92 | describe "#to_s" do 93 | it "should show all the options" do 94 | options = Imageproxy::Options.new "/convert", { "resize" => "20x20" } 95 | options.to_s.should == "command=convert, resize=20x20" 96 | end 97 | 98 | it "should show all the options even if there is obfuscation" do 99 | encoded = CGI.escape(Base64.encode64("resize=20x20&source=http://example.com/dog.jpg")) 100 | options = Imageproxy::Options.new "/convert", "_" => encoded 101 | options.to_s.should == "command=convert, resize=20x20, source=http%3A%2F%2Fexample.com%2Fdog.jpg" 102 | end 103 | 104 | it "should be OK with a nil key" do 105 | options = Imageproxy::Options.new "/convert", { nil => "20x20" } 106 | options.to_s.should == "command=convert" 107 | end 108 | 109 | it "should be OK with a nil value" do 110 | options = Imageproxy::Options.new "", {} 111 | options.to_s.should == "" 112 | end 113 | end 114 | 115 | describe "quality" do 116 | it "should be set to 0 if it's less than 0" do 117 | Imageproxy::Options.new("/convert", "quality" => "-39").quality.should == "0" 118 | end 119 | 120 | it "should be set to 100 if it's > 100" do 121 | Imageproxy::Options.new("/convert", "quality" => "293").quality.should == "100" 122 | end 123 | 124 | it "should not change if it's >= 0 <= 100" do 125 | Imageproxy::Options.new("/convert", "quality" => "59").quality.should == "59" 126 | end 127 | end 128 | end 129 | -------------------------------------------------------------------------------- /spec/server_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe "Server" do 4 | include Rack::Test::Methods 5 | 6 | RSpec::Matchers.define :succeed do 7 | match do |actual| 8 | actual.status == 200 9 | end 10 | end 11 | 12 | RSpec::Matchers.define :fail do 13 | match do |actual| 14 | actual.status == 500 15 | end 16 | end 17 | 18 | def app 19 | @app ||= Imageproxy::Server.new 20 | end 21 | 22 | context "when converting" do 23 | it "should send back the right result" do 24 | app.stub!(:config) { |sym| nil } 25 | get("/convert/resize/10x20/source/#{escaped_test_image_url}").should succeed 26 | Imageproxy::Compare.new(response_body_as_file, test_image_path("10x20")).execute.should == "0" 27 | end 28 | end 29 | 30 | context "when identifying" do 31 | it "should send back information about the image" do 32 | app.stub!(:config) { |sym| nil } 33 | get "/identify/source/#{escaped_test_image_url}" 34 | last_response.body.should =~ /Format: PNG.*Geometry: 200x116\+0\+0/m 35 | end 36 | end 37 | 38 | context "when signature is required" do 39 | before do 40 | @secret = "SEEKRET" 41 | app.stub!(:config) { |sym| {:signature_required => "true", :signature_secret => @secret}[sym] } 42 | end 43 | 44 | it "should fail if the signature is missing" do 45 | get("/convert/resize/10x20/source/#{escaped_test_image_url}").should fail 46 | end 47 | 48 | it "should fail if the signature is incorrect" do 49 | url = "/convert/resize/10x20/source/#{escaped_test_image_url}" 50 | signature = "BAD" 51 | get("#{url}?signature=#{signature}").should fail 52 | end 53 | 54 | it "should work if the signature is correct" do 55 | url = "/convert/resize/10x20/source/#{escaped_test_image_url}" 56 | signature = Imageproxy::Signature.create(url, @secret) 57 | get("#{url}?signature=#{CGI.escape(signature)}").should succeed 58 | end 59 | 60 | it "should work if the signature is part of the path" do 61 | url = "/convert/resize/10x20/source/#{escaped_test_image_url}" 62 | signature = Imageproxy::Signature.create(url, @secret) 63 | get("#{url}/signature/#{URI.escape(signature)}").should succeed 64 | end 65 | end 66 | 67 | context "when limiting to certain domains" do 68 | before do 69 | app.stub!(:config) { |sym| {:allowed_domains => " example.com ,example.org"}[sym] } 70 | app.stub!(:convert_file).and_return(Tempfile.new("fooo")) 71 | app.stub!(:content_type).and_return({ "Content-Type" => "image/jpeg"}) 72 | end 73 | 74 | it "should parse the allowed domains" do 75 | app.send(:allowed_domains).should =~ ["example.com", "example.org"] 76 | end 77 | 78 | it "should only examine the second-level domain" do 79 | app.send(:url_to_domain, "http://foo.bar.example.com/something").should == "example.com" 80 | end 81 | 82 | it "should fail if the source domain is not in the allowed domains" do 83 | get("/convert/resize/10x20/source/#{CGI.escape('http://example.net/dog.jpg')}").should fail 84 | end 85 | 86 | it "should pass if the source domain is in the allowed domains" do 87 | get("/convert/resize/10x20/source/#{CGI.escape('http://example.org/dog.jpg')}").should succeed 88 | end 89 | end 90 | 91 | context "when limiting to a maximum size" do 92 | before do 93 | app.stub!(:config) { |sym| { :max_size => "50" }[sym] } 94 | end 95 | 96 | it "should parse out the larger dimension" do 97 | app.send(:requested_size, "10x50").should == 50 98 | app.send(:requested_size, "50x50").should == 50 99 | app.send(:requested_size, "50").should == 50 100 | end 101 | 102 | it "should pass when converting to a smaller size" do 103 | get("/convert/resize/20x20/source/#{escaped_test_image_url}").should succeed 104 | end 105 | 106 | it "should pass when converting to the max size" do 107 | get("/convert/resize/50x50/source/#{escaped_test_image_url}").should succeed 108 | end 109 | 110 | it "should fail when converting to a larger size" do 111 | get("/convert/resize/50x51/source/#{escaped_test_image_url}").should fail 112 | end 113 | 114 | it "should pass when thumbnailing to a smaller size" do 115 | get("/convert/thumbnail/20x20/source/#{escaped_test_image_url}").should succeed 116 | end 117 | 118 | it "should fail when thumbnailing to a larger size" do 119 | get("/convert/thumbnail/50x51/source/#{escaped_test_image_url}").should fail 120 | end 121 | 122 | end 123 | 124 | context "error handling on errors from curl" do 125 | it "should fail if curl can't load from that URL" do 126 | get("/convert/flip/vertical/source/#{escaped_test_broken_image_url}").should fail 127 | end 128 | 129 | it "should succeed if curl can load from that URL" do 130 | get("/convert/flip/vertical/source/#{escaped_test_image_url}").should succeed 131 | end 132 | end 133 | 134 | describe "#content_type" do 135 | before do 136 | @options = Imageproxy::Options.new("/", {}) 137 | 138 | @mock_file = mock("file") 139 | @mock_file.stub!(:path).and_return("/tmp/foo") 140 | 141 | @mock_identify_format = Imageproxy::IdentifyFormat.new(@mock_file) 142 | @mock_identify_format.stub!(:execute_command).and_return("identify: some error message\n") 143 | Imageproxy::IdentifyFormat.stub!(:new).and_return(@mock_identify_format) 144 | end 145 | 146 | context "when the output format is specified" do 147 | it "should return that format's mime type" do 148 | @options = Imageproxy::Options.new("/", :format => "jpg") 149 | app.send(:content_type, @mock_file, @options).should == { "Content-Type" => "image/jpeg" } 150 | end 151 | end 152 | 153 | context "when 'identify' knows the format" do 154 | it "should return that format's mime type" do 155 | @mock_identify_format.stub!(:execute_command).and_return("JPEG\n") 156 | app.send(:content_type, @mock_file, @options).should == { "Content-Type" => "image/jpeg" } 157 | end 158 | end 159 | 160 | context "when the input source has a file extension" do 161 | it "should return that format's mime type" do 162 | @options = Imageproxy::Options.new("/", :source => "foo.jpg") 163 | app.send(:content_type, @mock_file, @options).should == { "Content-Type" => "image/jpeg" } 164 | end 165 | end 166 | 167 | context "when nothing is known about the format" do 168 | it "should not return a mime type" do 169 | app.send(:content_type, @mock_file, @options).should == {} 170 | end 171 | end 172 | 173 | end 174 | end 175 | 176 | -------------------------------------------------------------------------------- /spec/convert_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Imageproxy::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 = Imageproxy::Convert.new(Imageproxy::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 = Imageproxy::Convert.new(Imageproxy::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( 25 | %'curl -L -f -s -S -A "imageproxy" "http://example.com/dog.jpg" | convert - -resize 10x20 png:/mock/file/path') 26 | @command.execute 27 | end 28 | 29 | it "should return the output file" do 30 | @command.stub!(:execute_command) 31 | @command.execute.should == @mock_file 32 | end 33 | end 34 | 35 | describe "#file" do 36 | it "should create a world-readable tempfile if requested" do 37 | convert = Imageproxy::Convert.new( 38 | Imageproxy::Options.new("", {:resize=> "20x20", :source => "http%3A%2F%2Fexample.com%2Fdog.jpg"}), 39 | world_readable_tempfile: true) 40 | mock_file = mock("Tempfile", close: nil, path: "") 41 | mock_file.should_receive(:chmod).with(0644) 42 | Tempfile.should_receive(:new).and_return(mock_file) 43 | convert.file 44 | end 45 | end 46 | 47 | context "when resizing" do 48 | it("with no extra args") do 49 | command(:resize => "10x20").convert_options.should == 50 | '-resize 10x20' 51 | end 52 | 53 | it("with a different size") do 54 | command(:resize => "50x50").convert_options.should == 55 | '-resize 50x50' 56 | end 57 | 58 | it("when preserving shape") do 59 | command(:resize => "10x20", :shape => "preserve").convert_options.should == 60 | '-resize 10x20' 61 | end 62 | 63 | it("when padding") do 64 | command(:resize => "10x20", :shape => "pad").convert_options.should == 65 | '-resize 10x20 -background none -matte -gravity center -extent 10x20' 66 | end 67 | 68 | it("when padding with a background color") do 69 | command(:resize => "10x20", :shape => "pad", :background => "#ff00ff").convert_options.should == 70 | '-resize 10x20 -background "#ff00ff" -gravity center -extent 10x20' 71 | end 72 | 73 | it("when cutting") do 74 | command(:resize => "10x20", :shape => "cut").convert_options.should == 75 | '-resize 10x20^ -background none -matte -gravity center -extent 10x20' 76 | end 77 | end 78 | 79 | context "when thumbnailing" do 80 | it("when preserving shape") do 81 | command(:thumbnail => "10x20", :shape => "preserve").convert_options.should == 82 | '-thumbnail 10x20' 83 | end 84 | 85 | it("when padding") do 86 | command(:thumbnail => "10x20", :shape => "pad", :background => "#ff00ff").convert_options.should == 87 | '-thumbnail 10x20 -background "#ff00ff" -gravity center -extent 10x20' 88 | end 89 | 90 | it("when cutting") do 91 | command(:thumbnail => "10x20", :shape => "cut").convert_options.should == 92 | '-thumbnail 10x20^ -background none -matte -gravity center -extent 10x20' 93 | end 94 | end 95 | 96 | context "when flipping" do 97 | it("should flip horizontal") do 98 | command(:flip => "horizontal").convert_options.should == 99 | "-flop" 100 | end 101 | 102 | it("should flip vertical") do 103 | command(:flip => "vertical").convert_options.should == 104 | "-flip" 105 | end 106 | end 107 | 108 | context "when rotating" do 109 | it("should rotate to a right angle") do 110 | command(:rotate => "90").convert_options.should == 111 | "-rotate 90" 112 | end 113 | 114 | it("should rotate to a non-right angle") do 115 | command(:rotate => "92.1").convert_options.should == 116 | "-background none -matte -rotate 92.1" 117 | end 118 | 119 | it("should rotate to a non-right angle with a background") do 120 | command(:rotate => "92.1", :background => "#ff00ff").convert_options.should == 121 | '-background "#ff00ff" -matte -rotate 92.1' 122 | end 123 | end 124 | 125 | context "when changing format" do 126 | it("should not change the format if not requested") do 127 | command(:rotate => "90").new_format.should == 128 | "" 129 | end 130 | 131 | it("should not change the format if not requested") do 132 | command(:rotate => "90", :format => "png").new_format.should == 133 | "png:" 134 | end 135 | 136 | it("should set the colors when converting to png8") do 137 | command(:rotate => "90", :format => "png8").convert_options.should == 138 | "-rotate 90 -colors 256" 139 | end 140 | end 141 | 142 | context "when changing quality" do 143 | it("should set the quality") do 144 | command(:quality => "85").convert_options.should == 145 | "-quality 85" 146 | end 147 | end 148 | 149 | context "when converting to progressive" do 150 | it("should be 'JPEG' if progressive is 'true'") do 151 | command(:resize => "10x10", :progressive => "true").convert_options.should == 152 | "-resize 10x10 -interlace JPEG" 153 | end 154 | 155 | it("should be 'none' if progressive is 'false'") do 156 | command(:resize => "10x10", :progressive => "false").convert_options.should == 157 | "-resize 10x10 -interlace none" 158 | end 159 | 160 | it("should not be set if progressive isn't supplied") do 161 | command({:resize => "10x10"}).convert_options.should_not match /interlace/ 162 | end 163 | end 164 | 165 | context "when compositing" do 166 | before do 167 | @command = command("overlay" => "http%3A%2F%2Fexample.com%2Fframe.jpg") 168 | @command.stub!(:file).and_return(@mock_file) 169 | @command.stub!(:system) 170 | end 171 | 172 | it "should fetch both the overlay and the source, and call the composite command to composit the overlay on top of the source" do 173 | @command.should_receive(:execute_command).with(%r|curl -L -f -s -S -A "imageproxy" -o [^ ]+ "http://example.com/frame.jpg"|) 174 | @command.should_receive(:execute_command).with( 175 | %r{curl -L -f -s -S -A "imageproxy" "http://example.com/dog.jpg" | composite [^ ]+ - - | convert - png:/mock/file/path}) 176 | @command.execute 177 | end 178 | 179 | it "should return the output file" do 180 | @command.stub!(:execute_command) 181 | @command.execute.should == @mock_file 182 | end 183 | end 184 | end 185 | -------------------------------------------------------------------------------- /README.mdown: -------------------------------------------------------------------------------- 1 | imageproxy 2 | ========== 3 | 4 | **[home page](https://github.com/eahanson/imageproxy)** • **[release notes](https://github.com/eahanson/imageproxy/blob/master/release-notes.mdown)** • **[RubyGems](http://rubygems.org/gems/imageproxy)** 5 | 6 | A image processing proxy server, written in Ruby as a Rack application. 7 | 8 | There are many possible uses for this, but one major use is to resize images on-the-fly directly from HTML code, rather than processing the image when it is first uploaded or created. For example, if a user uploads a file to your site and it gets stored in `http://example.com/uploads/39c0c11af.png`, you could show a 50x50 px version like this: 9 | 10 | 11 | 12 | If you ever decided to change the size, you wouldn't have to re-encode anything, just change the HTML: 13 | 14 | 15 | 16 | See [http://imageproxy.heroku.com/selftest](http://imageproxy.heroku.com/selftest) for some examples (it's running on Heroku's free plan, so it will be a bit slow). 17 | 18 | Status 19 | ------ 20 | 21 | imageproxy is being used in production on at least one commercial website, keeping two big EC2 servers busy. There are definitely some feature and performance improvements that can be/need to be made. Suggestions and pull requests are welcome. 22 | 23 | ### Current Features 24 | 25 | * display information about an image 26 | * resize, flip, rotate, change format and change quality of images 27 | * standard query-parameter based URLs as well as Amazon CloudFront-compatibile URLs 28 | * tested on Heroku and Amazon EC2 29 | * use the requester's user agent string 30 | * signed requests (to stop unauthorized use) 31 | * obfuscated params 32 | 33 | ### Future Features 34 | 35 | Feel free to help out with some of these :) 36 | 37 | * specify crop size and offset 38 | * create rounded corners 39 | * Rails helper for generating image tags that use imageproxy 40 | * X-Sendfile / X-Accel-Redirect header 41 | * better documentation 42 | * signature generation testing tool 43 | * nice error messages for improper API use 44 | * performance 45 | 46 | Performance 47 | ----------- 48 | 49 | imageproxy doesn't do any sort of caching. That kind of thing is better left up to CDNs (like Amazon CloudFront or VoxCast CDN) or to caching proxies such as Varnish. 50 | 51 | Also, imageproxy itself isn't nearly as fast as it could be. It's written in an interpreted language, and it shells out to curl and ImageMagick to do its work. Presumably, it would be way faster written in C as an Apache module or something. 52 | 53 | 54 | Requirements 55 | ------------ 56 | 57 | * [Ruby](http://www.ruby-lang.org/) 58 | * [Rack](http://rack.github.com) 59 | * [Imagemagick](http://www.imagemagick.org/) 60 | * [Curl](http://curl.haxx.se) 61 | 62 | 63 | Installing 64 | ---------- 65 | 66 | `gem install imageproxy` 67 | 68 | 69 | API 70 | --- 71 | 72 | There are two major functions: `identify` and `convert`, plus a helpful `selftest` function. 73 | 74 | ### Identify 75 | 76 | `identify` spits out a lot of information about the provided image. 77 | 78 | #### Parameters 79 | 80 | `source` *(Required)* The URL of the image to identify. 81 | 82 | `signature` To stop unauthorized use. See the "Signing Requests" section of this document. 83 | 84 | ### Convert 85 | 86 | `convert` converts an image. 87 | 88 | #### Parameters 89 | 90 | `source` *(Required)* The URL of the image to convert. (Also aliased to `src`.) 91 | 92 | `resize` The new size of the image, in "WxH" format (e.g., `20x30`). 93 | 94 | `thumbnail` The new size of the image, in "WxH" format (e.g., `20x30`). Thumbnailing assumes the resulting image will be pretty small and makes some optimizations. 95 | 96 | `shape` The shape of the image, when `resize`ing or `thumbnail`ing to a different aspect ratio. The value can be `preserve` which will preserve the original aspect ratio, `pad` which will add padding to keep the proper aspect ratio (you can supply a `background` parameter to choose the background color to pad with, or leave blank to pad with transparent color if the image format allows it), and `cut` which will cut the image to fit the new size. Another option is "preserve-not-enlarge", it does the same as "preserve" but does nothing if the supplied image format is smaller than the desider one. The default is `preserve`. 97 | 98 | `flip` Flip the image. The value can be `horizontal` or `vertical`. 99 | 100 | `rotate` Rotate the image. The value can be any number. When rotating to a non-right-angle, you can specify the `background` parameter to choose the color for the background. 101 | 102 | `format` Change the format. Possible formats include `gif`, `jpg`, `png`, `png8`, etc. 103 | 104 | `quality` Choose the compression quality for formats that support lossy compression. The value can be any number from 0 to 100. The default is to use [Imagemagick's default quality settings](http://www.imagemagick.org/script/command-line-options.php#quality). 105 | 106 | `progressive` Choose whether a JPEG image should be a progressive JPEG or not. Possible values are `true` and `false`. The default is `false`. 107 | 108 | `background` Some operations allow for a background color to be provided. The format is hex (e.g., `#ff00ff`) or rgba (e.g., `rgba(20,30,19,0.4)`) 109 | 110 | `signature` To stop unauthorized use. See the "Signing Requests" section of this document. 111 | 112 | ### Request Format 113 | 114 | The request must start with `identify` or `convert` (for backwards-compatibility, `process` is a synonym for `convert`). 115 | 116 | The parameters can be query string parameters, like this: 117 | 118 | http://example.com/convert?resize=100x100&shape=cut 119 | 120 | Or, the parameters can be Amazon CloudFront-compatible URLs, like this: 121 | 122 | http://example.com/convert/resize/100x100/shape/cut 123 | 124 | (Note that CloudFront now optionally support query parameters.) 125 | 126 | You can also mix the parameters if you like. This doesn't make much sense except for the case of the `signature` parameter which must be a query param: 127 | 128 | http://example.com/convert/resize/100x100?signature=szFGj470w%2ByhJYJfTRryFLF9msA%3D 129 | 130 | **Important:** Make sure to URL escape all query parameters. When using the CloudFront-compatible URL format, make sure to **double-escape** the source URL: 131 | 132 | http://example.com/convert?resize=100x100&source=http://www.google.com/images/logos/ps_logo2.png # WRONG - not escaped 133 | http://example.com/convert?resize=100x100&source=http%3A%2F%2Fwww.google.com%2Fimages%2Flogos%2Fps_logo2.png # RIGHT - escaped 134 | 135 | http://exampe.com/convert/resize/100x100/source/http://www.google.com/images/logos/ps_logo2.png # WRONG - not escaped 136 | http://exampe.com/convert/resize/100x100/source/http%3A%2F%2Fwww.google.com%2Fimages%2Flogos%2Fps_logo2.png # WRONG - only escaped once 137 | http://exampe.com/convert/resize/100x100/source/http%253A%252F%252Fwww.google.com%252Fimages%252Flogos%252Fps_logo2.png # RIGHT - escaped and then escaped again 138 | 139 | ### Signing Requests 140 | 141 | To require that requests are signed, set the following two environment variables: 142 | 143 | IMAGEPROXY_SIGNATURE_REQUIRED=true 144 | IMAGEPROXY_SIGNATURE_SECRET=some secret key 145 | 146 | Then add a `signature` parameter to your query string or path. 147 | 148 | The signature is calculated with the following formula, which is the same formula that Amazon Web Services uses: 149 | 150 | URLSafeBase64( HMAC-SHA1( UTF-8-Encoding-Of( YourSecretKey, StringToSign ) ) ); 151 | 152 | Where YourSecretKey is a secret key that you make up, and StringToSign is the full path of the request, excluding host name and the "signature" parameter, in the same order as in the query. For `http://example.com/convert/resize/100x100?shape=cut&signature=szFGj470w%2ByhJYJfTRryFLF9msA%3D` the StringToSign would be `/convert/resize/100x100?shape=cut`. 153 | 154 | URL safe base64 is the normal encoding but with replacing the + with - and / with _. 155 | 156 | Example Ruby code to generate the signature: 157 | 158 | digest = OpenSSL::Digest.new("sha1") 159 | Base64.encode64(OpenSSL::HMAC.digest(digest, your_secret_key, your_query_string)).strip.tr('+/', '-_') 160 | 161 | ### Other Server Configuration 162 | 163 | `IMAGEPROXY_TIMEOUT` maximum duration (in whole seconds) for downloading a source image (not recommended if you're using Ruby 1.8) 164 | 165 | `IMAGEPROXY_CACHE_TIME` value of the max-age header for the converted image 166 | 167 | `IMAGEPROXY_VERBOSE` enables full Ruby stacktraces in your error log 168 | 169 | `IMAGEPROXY_ALLOWED_DOMAINS` A comma-separated list of second-level domains (e.g., "example.com, example.org") that are valid domains for the `source` parameter. If not specified, then the `source` parameter can reference any domain. 170 | 171 | `IMAGEPROXY_MAX_SIZE` The maximum dimension allowed for a `resize` or `thumbnail` operation. Specifying `20` would cause a resize of `10x30` to fail because the maximum dimension of `20` is less than the largest requested dimension of `30`. 172 | 173 | `IMAGEPROXY_WORLD_READABLE_TEMPFILE` set to `true` if you want the generated tempfiles to be `-rw-r--r--` instead of the default `-rw-------` 174 | 175 | 176 | ### Obfuscating Requests 177 | 178 | You may obfuscate your requests by Base64 encoding and then URL encoding your query string or path. The parameter name for this encoded value is `_` if you're using a query string or `-` if you're using a path. Example: 179 | 180 | http://example.com/convert?src=http://example.com/dog.jpg&resize=10x10 181 | http://example.com/convert?_=c3JjPWh0dHA6Ly9leGFtcGxlLmNvbS9kb2cuanBnJnJlc2l6ZT0xMHgxMA%3D%3D 182 | http://example.com/convert/-/c3JjPWh0dHA6Ly9leGFtcGxlLmNvbS9kb2cuanBnJnJlc2l6ZT0xMHgxMA%3D%3D 183 | 184 | You can also replace the "=" characters in the Base64-encoded strings with "." characters which may make it possible to 185 | avoid having to percent-escape. 186 | 187 | ### Example requests 188 | 189 | CloudFront-compatible URLs: 190 | 191 | http://example.com/convert/resize/100x100/source/http%253A%252F%252Fwww.google.com%252Fimages%252Flogos%252Fps_logo2.png 192 | http://example.com/identify/source/http%253A%252F%252Fwww.google.com%252Fimages%252Flogos%252Fps_logo2.png 193 | 194 | Regular query string URLs: 195 | 196 | http://example.com/convert?resize=100x100&source=http%253A%252F%252Fwww.google.com%252Fimages%252Flogos%252Fps_logo2.png 197 | http://example.com/identify?source=http%253A%252F%252Fwww.google.com%252Fimages%252Flogos%252Fps_logo2.png 198 | 199 | Resize: 200 | 201 | http://example.com/convert?resize=100x100&source=http%253A%252F%252Fwww.google.com%252Fimages%252Flogos%252Fps_logo2.png 202 | 203 | Resize with padding: 204 | 205 | http://example.com/convert?resize=100x100&shape=pad&source=http%253A%252F%252Fwww.google.com%252Fimages%252Flogos%252Fps_logo2.png 206 | http://example.com/convert?resize=100x100&shape=pad&background=%23ff00ff&source=http%253A%252F%252Fwww.google.com%252Fimages%252Flogos%252Fps_logo2.png 207 | 208 | Resize with cutting: 209 | 210 | http://example.com/convert?resize=100x100&shape=cut&source=http%253A%252F%252Fwww.google.com%252Fimages%252Flogos%252Fps_logo2.png 211 | 212 | Flipping: 213 | 214 | http://example.com/convert?flip=horizontal&source=http%253A%252F%252Fwww.google.com%252Fimages%252Flogos%252Fps_logo2.png 215 | http://example.com/convert?flip=vertical&source=http%253A%252F%252Fwww.google.com%252Fimages%252Flogos%252Fps_logo2.png 216 | 217 | Rotating: 218 | 219 | http://example.com/convert?rotate=90&source=http%253A%252F%252Fwww.google.com%252Fimages%252Flogos%252Fps_logo2.png 220 | http://example.com/convert?rotate=120&source=http%253A%252F%252Fwww.google.com%252Fimages%252Flogos%252Fps_logo2.png 221 | http://example.com/convert?rotate=120&background=%23ff00ff&source=http%253A%252F%252Fwww.google.com%252Fimages%252Flogos%252Fps_logo2.png 222 | 223 | Combo: 224 | 225 | http://example.com/convert?resize=100x100&shape=cut&rotate=45&background=%23ff00ff&source=http%253A%252F%252Fwww.google.com%252Fimages%252Flogos%252Fps_logo2.png 226 | 227 | With signature (signed with secret key "SEEKRET"): 228 | 229 | http://example.com/convert/resize/100x100/source/http%253A%252F%252Fwww.google.com%252Fimages%252Flogos%252Fps_logo2.png?signature=iNoljMh0kALsoxRLzJfr7Wcq%2BnY%3D 230 | http://example.com/convert?resize=100x100&source=http%253A%252F%252Fwww.google.com%252Fimages%252Flogos%252Fps_logo2.png&signature=KLga1QNdCY8Xu4thsKdbTUjnYAk%3D 231 | 232 | 233 | ### Selftest 234 | 235 | You can go to the `/selftest` URL to see everything in action. For example: [http://imageproxy.heroku.com/selftest](http://imageproxy.heroku.com/selftest). 236 | 237 | 238 | Sample EC2 Installation Recipe 239 | ------------------------------ 240 | 241 | Create and boot an instance of an AWS Linux AMI using [Amazon's EC2 console](https://console.aws.amazon.com/ec2/). In this example, I used a "micro" instance. 242 | 243 | ssh into the instance as user `ec2-user` 244 | 245 | Make a directory for the proxy: 246 | 247 | sudo mkdir /opt/imageproxy 248 | sudo chown ec2-user:ec2-user /opt/imageproxy 249 | 250 | Install the Ruby HTTP stack + ImageMagick: 251 | 252 | sudo yum -y install make gcc gcc-c++ http rubygems ruby-devel openssl-devel zlib-devel httpd-devel git curl-devel openssl ImageMagick ImageMagick-devel 253 | 254 | Install passenger: 255 | 256 | sudo gem install passenger 257 | sudo passenger-install-apache2-module 258 | 259 | Update the Apache config as suggested by the passenger installer 260 | 261 | LoadModule passenger_module /usr/lib/ruby/gems/1.8/gems/passenger-3.0.5/ext/apache2/mod_passenger.so 262 | PassengerRoot /usr/lib/ruby/gems/1.8/gems/passenger-3.0.5 263 | PassengerRuby /usr/bin/ruby 264 | 265 | Set up a VirtualHost as suggested by the passenger installer 266 | 267 | 268 | ServerName ec2-184-72-213-98.compute-1.amazonaws.com 269 | DocumentRoot /opt/imageproxy/public 270 | 271 | Allow from all 272 | Options -MultiViews 273 | 274 | 275 | 276 | Clone the imageproxy code: 277 | 278 | git clone git://github.com/eahanson/imageproxy.git 279 | 280 | Install the gems: 281 | 282 | bundle install 283 | 284 | Start Apache: 285 | 286 | sudo /etc/init.d/httpd start 287 | 288 | 289 | Sample Heroku Installation Recipe 290 | ------------------------------ 291 | 292 | Clone the imageproxy code: 293 | 294 | git clone git://github.com/eahanson/imageproxy.git 295 | 296 | Set up Heroku: 297 | 298 | As of Aug 2012 cedar does not work out of the box but bamboo does (specify bamboo as 299 | an option to the heroku create command). 300 | 301 | http://devcenter.heroku.com/articles/quickstart 302 | 303 | Deploy: 304 | 305 | git push heroku master 306 | 307 | 308 | If You Want To Modify The Code 309 | ------------------------------ 310 | 311 | Tun run the server locally: 312 | 313 | rackup 314 | 315 | Make sure everthing is working: 316 | 317 | http://localhost:9292/selftest 318 | 319 | To run the specs 320 | 321 | rake spec 322 | 323 | Related 324 | ------- 325 | 326 | * [Thumbor](https://github.com/globocom/thumbor) is similar but has "smart crop" using various detection algorithms and some other features. 327 | * [Imgix](http://www.imgix.com) is a service that handles all of this for you, plus has a bunch of cool features 328 | 329 | 330 | Thanks 331 | ------ 332 | 333 | Thanks to the following for code contributions: 334 | 335 | * [David Hall](https://github.com/moonhouse) 336 | * [Andy Blyler](https://github.com/ablyler) 337 | * [NeedFeed](https://github.com/needfeed) 338 | * [Daniel Szmulewicz](https://github.com/danielsz) 339 | * [Gordon Chan](https://github.com/gordonc) 340 | * [Michael Mahemoff](https://github.com/mahemoff) 341 | * [Francesco Magnoni](https://github.com/francesco-magnoni) 342 | 343 | License 344 | ------- 345 | 346 | Licensed under the MIT license. See LICENSE.txt. 347 | --------------------------------------------------------------------------------