image_vise 0.0.16
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.
- checksums.yaml +7 -0
- data/Gemfile +21 -0
- data/LICENSE.txt +21 -0
- data/README.md +203 -0
- data/Rakefile +29 -0
- data/examples/config.ru +17 -0
- data/image_vise.gemspec +116 -0
- data/lib/image_vise/auto_orient.rb +10 -0
- data/lib/image_vise/crop.rb +32 -0
- data/lib/image_vise/ellipse_stencil.rb +43 -0
- data/lib/image_vise/file_response.rb +23 -0
- data/lib/image_vise/fit_crop.rb +33 -0
- data/lib/image_vise/geom.rb +16 -0
- data/lib/image_vise/image_request.rb +68 -0
- data/lib/image_vise/pipeline.rb +54 -0
- data/lib/image_vise/render_engine.rb +210 -0
- data/lib/image_vise/sharpen.rb +21 -0
- data/lib/image_vise.rb +118 -0
- data/spec/image_vise/auto_orient_spec.rb +10 -0
- data/spec/image_vise/crop_spec.rb +20 -0
- data/spec/image_vise/ellipse_stencil_spec.rb +10 -0
- data/spec/image_vise/file_response_spec.rb +45 -0
- data/spec/image_vise/fit_crop_spec.rb +20 -0
- data/spec/image_vise/geom_spec.rb +17 -0
- data/spec/image_vise/image_request_spec.rb +53 -0
- data/spec/image_vise/pipeline_spec.rb +70 -0
- data/spec/image_vise/render_engine_spec.rb +167 -0
- data/spec/image_vise/sharpen_spec.rb +17 -0
- data/spec/image_vise_spec.rb +89 -0
- data/spec/spec_helper.rb +74 -0
- data/spec/test_server.rb +61 -0
- data/spec/waterside_magic_hour.jpg +0 -0
- metadata +306 -0
@@ -0,0 +1,167 @@
|
|
1
|
+
require_relative '../spec_helper'
|
2
|
+
require 'rack/test'
|
3
|
+
|
4
|
+
describe ImageVise::RenderEngine do
|
5
|
+
include Rack::Test::Methods
|
6
|
+
|
7
|
+
let(:app) { ImageVise::RenderEngine.new }
|
8
|
+
|
9
|
+
context 'when the subclass is configured to raise exceptions' do
|
10
|
+
after :each do
|
11
|
+
ImageVise.reset_allowed_hosts!
|
12
|
+
ImageVise.reset_secret_keys!
|
13
|
+
end
|
14
|
+
|
15
|
+
it 'raises an exception instead of returning an error response' do
|
16
|
+
class << app
|
17
|
+
def raise_exceptions?
|
18
|
+
true
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
p = ImageVise::Pipeline.new.crop(width: 10, height: 10, gravity: 'c')
|
23
|
+
image_request = ImageVise::ImageRequest.new(src_url: 'http://unknown.com/image.jpg', pipeline: p)
|
24
|
+
params = image_request.to_query_string_params('l33tness')
|
25
|
+
expect(app).to receive(:handle_generic_error).and_call_original
|
26
|
+
expect {
|
27
|
+
get '/', params
|
28
|
+
}.to raise_error(/No keys set/)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
context 'when requesting an image' do
|
33
|
+
after :each do
|
34
|
+
ImageVise.reset_allowed_hosts!
|
35
|
+
ImageVise.reset_secret_keys!
|
36
|
+
end
|
37
|
+
|
38
|
+
context 'halts with 422' do
|
39
|
+
before :each do
|
40
|
+
parsed_url = Addressable::URI.parse(public_url)
|
41
|
+
ImageVise.add_allowed_host!(parsed_url.host)
|
42
|
+
end
|
43
|
+
|
44
|
+
it 'when the requested image cannot be opened by ImageMagick' do
|
45
|
+
uri = Addressable::URI.parse(public_url)
|
46
|
+
ImageVise.add_allowed_host!(uri.host)
|
47
|
+
ImageVise.add_secret_key!('l33tness')
|
48
|
+
uri.path = '/___nonexistent_image.jpg'
|
49
|
+
|
50
|
+
p = ImageVise::Pipeline.new.crop(width: 10, height: 10, gravity: 'c')
|
51
|
+
image_request = ImageVise::ImageRequest.new(src_url: uri.to_s, pipeline: p)
|
52
|
+
params = image_request.to_query_string_params('l33tness')
|
53
|
+
|
54
|
+
expect_any_instance_of(Patron::Session).to receive(:get_file) {|_self, url, path|
|
55
|
+
File.open(path, 'wb') {|f| f << 'totally not an image' }
|
56
|
+
double(status: 200)
|
57
|
+
}
|
58
|
+
expect(app).to receive(:handle_request_error).and_call_original
|
59
|
+
|
60
|
+
get '/', params
|
61
|
+
expect(last_response.status).to eq(422)
|
62
|
+
expect(last_response['Cache-Control']).to eq("private, max-age=0, no-cache")
|
63
|
+
expect(last_response.body).to include('Unsupported/unknown')
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
it 'responds with 403 when upstream returns it' do
|
68
|
+
uri = Addressable::URI.parse(public_url)
|
69
|
+
ImageVise.add_allowed_host!(uri.host)
|
70
|
+
ImageVise.add_secret_key!('l33tness')
|
71
|
+
uri.path = '/forbidden'
|
72
|
+
|
73
|
+
p = ImageVise::Pipeline.new.crop(width: 10, height: 10, gravity: 'c')
|
74
|
+
image_request = ImageVise::ImageRequest.new(src_url: uri.to_s, pipeline: p)
|
75
|
+
params = image_request.to_query_string_params('l33tness')
|
76
|
+
|
77
|
+
get '/', params
|
78
|
+
expect(last_response.status).to eq(403)
|
79
|
+
expect(last_response.headers['Content-Type']).to eq('application/json')
|
80
|
+
parsed = JSON.load(last_response.body)
|
81
|
+
expect(parsed['errors']).to include("Unfortunate upstream response: 403")
|
82
|
+
end
|
83
|
+
|
84
|
+
it 'replays upstream error response codes that are selected to be replayed to the requester' do
|
85
|
+
uri = Addressable::URI.parse(public_url)
|
86
|
+
ImageVise.add_allowed_host!(uri.host)
|
87
|
+
ImageVise.add_secret_key!('l33tness')
|
88
|
+
|
89
|
+
[404, 403, 503, 504, 500].each do | error_code |
|
90
|
+
allow_any_instance_of(Patron::Session).to receive(:get_file).and_return(double(status: error_code))
|
91
|
+
|
92
|
+
p = ImageVise::Pipeline.new.crop(width: 10, height: 10, gravity: 'c')
|
93
|
+
image_request = ImageVise::ImageRequest.new(src_url: uri.to_s, pipeline: p)
|
94
|
+
params = image_request.to_query_string_params('l33tness')
|
95
|
+
|
96
|
+
get '/', params
|
97
|
+
expect(last_response.status).to eq(error_code)
|
98
|
+
expect(last_response.headers).to have_key('Cache-Control')
|
99
|
+
expect(last_response.headers['Cache-Control']).to eq("private, max-age=0, no-cache")
|
100
|
+
|
101
|
+
expect(last_response.headers['Content-Type']).to eq('application/json')
|
102
|
+
parsed = JSON.load(last_response.body)
|
103
|
+
expect(parsed['errors']).to include("Unfortunate upstream response: #{error_code}")
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
it 'sets very far caching headers and an ETag, and returns a 304 if any ETag is set' do
|
108
|
+
uri = Addressable::URI.parse(public_url)
|
109
|
+
ImageVise.add_allowed_host!(uri.host)
|
110
|
+
ImageVise.add_secret_key!('l33tness')
|
111
|
+
|
112
|
+
p = ImageVise::Pipeline.new.fit_crop(width: 10, height: 35, gravity: 'c')
|
113
|
+
image_request = ImageVise::ImageRequest.new(src_url: uri.to_s, pipeline: p)
|
114
|
+
params = image_request.to_query_string_params('l33tness')
|
115
|
+
|
116
|
+
get '/', params
|
117
|
+
|
118
|
+
expect(last_response).to be_ok
|
119
|
+
expect(last_response['ETag']).not_to be_nil
|
120
|
+
expect(last_response['Cache-Control']).to eq('public')
|
121
|
+
|
122
|
+
get '/', params, {'HTTP_IF_NONE_MATCH' => last_response['ETag']}
|
123
|
+
expect(last_response.status).to eq(304)
|
124
|
+
|
125
|
+
# Should consider _any_ ETag a request to rerender something
|
126
|
+
# that already exists in an upstream cache
|
127
|
+
get '/', params, {'HTTP_IF_NONE_MATCH' => SecureRandom.hex(4)}
|
128
|
+
expect(last_response.status).to eq(304)
|
129
|
+
end
|
130
|
+
|
131
|
+
it 'when all goes well responds with an image that passes through all the processing steps' do
|
132
|
+
uri = Addressable::URI.parse(public_url)
|
133
|
+
ImageVise.add_allowed_host!(uri.host)
|
134
|
+
ImageVise.add_secret_key!('l33tness')
|
135
|
+
|
136
|
+
p = ImageVise::Pipeline.new.geom(geometry_string: '512x335').fit_crop(width: 10, height: 10, gravity: 'c')
|
137
|
+
image_request = ImageVise::ImageRequest.new(src_url: uri.to_s, pipeline: p)
|
138
|
+
params = image_request.to_query_string_params('l33tness')
|
139
|
+
|
140
|
+
get '/', params
|
141
|
+
expect(last_response.status).to eq(200)
|
142
|
+
|
143
|
+
expect(last_response.headers['Content-Type']).to eq('image/jpeg')
|
144
|
+
expect(last_response.headers).to have_key('Content-Length')
|
145
|
+
parsed_image = Magick::Image.from_blob(last_response.body)[0]
|
146
|
+
expect(parsed_image.columns).to eq(10)
|
147
|
+
end
|
148
|
+
|
149
|
+
it 'returns the processed JPEG image as a PNG if it had to get an alpha channel during processing' do
|
150
|
+
uri = Addressable::URI.parse(public_url)
|
151
|
+
ImageVise.add_allowed_host!(uri.host)
|
152
|
+
ImageVise.add_secret_key!('l33tness')
|
153
|
+
|
154
|
+
p = ImageVise::Pipeline.new.geom(geometry_string: '220x220').ellipse_stencil
|
155
|
+
image_request = ImageVise::ImageRequest.new(src_url: uri.to_s, pipeline: p)
|
156
|
+
params = image_request.to_query_string_params('l33tness')
|
157
|
+
|
158
|
+
get '/', params
|
159
|
+
expect(last_response.status).to eq(200)
|
160
|
+
|
161
|
+
expect(last_response.headers['Content-Type']).to eq('image/png')
|
162
|
+
expect(last_response.headers).to have_key('Content-Length')
|
163
|
+
|
164
|
+
examine_image_from_string(last_response.body)
|
165
|
+
end
|
166
|
+
end
|
167
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe ImageVise::Sharpen do
|
4
|
+
it 'refuses invalid parameters' do
|
5
|
+
expect { described_class.new(sigma: 0, radius: -1) }.to raise_error(ArgumentError)
|
6
|
+
end
|
7
|
+
|
8
|
+
it 'applies the crop with different gravities' do
|
9
|
+
[[1, 1], [4, 2], [0.75, 0.5]].each do |(r, s)|
|
10
|
+
image = Magick::Image.read(test_image_path)[0]
|
11
|
+
expect(ImageVise).to receive(:destroy).with(instance_of(Magick::Image)).and_call_original
|
12
|
+
sharpen = described_class.new(radius: r, sigma: s)
|
13
|
+
sharpen.apply!(image)
|
14
|
+
examine_image(image, "sharpen-rad_%02f-sigma_%02f" % [r, s])
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,89 @@
|
|
1
|
+
require_relative 'spec_helper'
|
2
|
+
require 'rack/test'
|
3
|
+
|
4
|
+
describe ImageVise do
|
5
|
+
include Rack::Test::Methods
|
6
|
+
|
7
|
+
def app
|
8
|
+
described_class.new
|
9
|
+
end
|
10
|
+
|
11
|
+
context 'ImageVise.allowed_hosts' do
|
12
|
+
xit 'returns the allowed hosts' do
|
13
|
+
expect(described_class.allowed_hosts).not_to be_empty
|
14
|
+
expect(described_class.allowed_hosts).to include('wetransfer-unittests.s3.amazonaws.com')
|
15
|
+
end
|
16
|
+
|
17
|
+
it 'allows add_allowed_host! and reset_allowed_hosts!' do
|
18
|
+
described_class.add_allowed_host!('www.imageboard.im')
|
19
|
+
expect(described_class.allowed_hosts).to include('www.imageboard.im')
|
20
|
+
described_class.reset_allowed_hosts!
|
21
|
+
expect(described_class.allowed_hosts).not_to include('www.imageboard.im')
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
context 'ImageVise.secret_keys' do
|
26
|
+
it 'raises when asked for a key and no keys has been set' do
|
27
|
+
expect {
|
28
|
+
described_class.secret_keys
|
29
|
+
}.to raise_error("No keys set, add a key using `ImageVise.add_secret_key!(key)'")
|
30
|
+
end
|
31
|
+
|
32
|
+
it 'allows add_secret_key!(key) and reset_secret_keys!' do
|
33
|
+
described_class.add_secret_key!('l33t')
|
34
|
+
expect(described_class.secret_keys).to include('l33t')
|
35
|
+
described_class.reset_secret_keys!
|
36
|
+
expect {
|
37
|
+
expect(described_class.secret_keys)
|
38
|
+
}.to raise_error
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
describe 'ImageVise.new.call' do
|
43
|
+
it 'instantiates a new app and performs call() on it' do
|
44
|
+
expect_any_instance_of(ImageVise::RenderEngine).to receive(:call).with(:mock_env) { :yes }
|
45
|
+
ImageVise.new.call(:mock_env)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
describe 'ImageVise.call' do
|
50
|
+
it 'instantiates a new app and performs call() on it' do
|
51
|
+
expect_any_instance_of(ImageVise::RenderEngine).to receive(:call).with(:mock_env) { :yes }
|
52
|
+
ImageVise.call(:mock_env)
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
describe '.image_params' do
|
57
|
+
it 'generates a Hash with paremeters for processing the resized image' do
|
58
|
+
params = ImageVise.image_params(src_url: 'http://host.com/image.jpg', secret: 'l33t') do |pipe|
|
59
|
+
pipe.fit_crop width: 128, height: 256, gravity: 'c'
|
60
|
+
end
|
61
|
+
expect(params).to be_kind_of(Hash)
|
62
|
+
expect(params[:q]).not_to be_empty
|
63
|
+
expect(params[:sig]).not_to be_empty
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
describe 'methods dealing with the operator list' do
|
68
|
+
it 'have the basic operators already set up' do
|
69
|
+
oplist = ImageVise.defined_operator_names
|
70
|
+
expect(oplist).to include('sharpen')
|
71
|
+
expect(oplist).to include('crop')
|
72
|
+
end
|
73
|
+
|
74
|
+
it 'allows an operator to be added and retrieved' do
|
75
|
+
class CustomOp; end
|
76
|
+
ImageVise.add_operator 'custom_op', CustomOp
|
77
|
+
expect(ImageVise.operator_from(:custom_op)).to eq(CustomOp)
|
78
|
+
expect(ImageVise.operator_name_for(CustomOp.new)).to eq('custom_op')
|
79
|
+
expect(ImageVise.defined_operator_names).to include('custom_op')
|
80
|
+
end
|
81
|
+
|
82
|
+
it 'raises an exception when an operator key is requested that does not exist' do
|
83
|
+
class UnknownOp; end
|
84
|
+
expect {
|
85
|
+
ImageVise.operator_name_for(UnknownOp.new)
|
86
|
+
}.to raise_error(/not registered using ImageVise/)
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
data/spec/spec_helper.rb
ADDED
@@ -0,0 +1,74 @@
|
|
1
|
+
require 'bundler'
|
2
|
+
Bundler.require
|
3
|
+
|
4
|
+
require 'addressable/uri'
|
5
|
+
require 'strenv'
|
6
|
+
require 'tmpdir'
|
7
|
+
require_relative 'test_server'
|
8
|
+
|
9
|
+
|
10
|
+
TEST_RENDERS_DIR = Dir.mktmpdir
|
11
|
+
|
12
|
+
module Examine
|
13
|
+
def examine_image(magick_image, name_tag = 'test-img')
|
14
|
+
# When doing TDD, waiting for stuff to open is a drag - allow
|
15
|
+
# it to be squelched using 2 envvars. Also viewing images
|
16
|
+
# makes no sense on CI unless we bother with artifacts.
|
17
|
+
# The first one is what Gitlab-CI sets for us.
|
18
|
+
return if ENV.key?("CI_BUILD_ID")
|
19
|
+
return if ENV.key?("SKIP_INTERACTIVE")
|
20
|
+
|
21
|
+
Dir.mkdir(TEST_RENDERS_DIR) unless File.exist?(TEST_RENDERS_DIR)
|
22
|
+
path = File.join(TEST_RENDERS_DIR, name_tag + '.png')
|
23
|
+
magick_image.format = 'png'
|
24
|
+
magick_image.write(path)
|
25
|
+
`open #{path}`
|
26
|
+
end
|
27
|
+
|
28
|
+
def examine_image_from_string(string)
|
29
|
+
# When doing TDD, waiting for stuff to open is a drag - allow
|
30
|
+
# it to be squelched using 2 envvars. Also viewing images
|
31
|
+
# makes no sense on CI unless we bother with artifacts.
|
32
|
+
# The first one is what Gitlab-CI sets for us.
|
33
|
+
return if ENV.key?("CI_BUILD_ID")
|
34
|
+
return if ENV.key?("SKIP_INTERACTIVE")
|
35
|
+
|
36
|
+
Dir.mkdir(TEST_RENDERS_DIR) unless File.exist?(TEST_RENDERS_DIR)
|
37
|
+
random_name = 'test-image-%s' % SecureRandom.hex(3)
|
38
|
+
path = File.join(TEST_RENDERS_DIR, random_name)
|
39
|
+
File.open(path, 'wb'){|f| f << string }
|
40
|
+
`open #{path}`
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
require 'simplecov'
|
45
|
+
SimpleCov.start do
|
46
|
+
add_filter "/spec/"
|
47
|
+
end
|
48
|
+
|
49
|
+
require_relative '../lib/image_vise'
|
50
|
+
|
51
|
+
RSpec.configure do | config |
|
52
|
+
config.order = 'random'
|
53
|
+
config.include Examine
|
54
|
+
config.before :suite do
|
55
|
+
TestServer.start(nil, ssl=false, port=9001)
|
56
|
+
end
|
57
|
+
|
58
|
+
config.after :suite do
|
59
|
+
sleep 2
|
60
|
+
FileUtils.rm_rf(TEST_RENDERS_DIR)
|
61
|
+
end
|
62
|
+
|
63
|
+
def test_image_path
|
64
|
+
File.expand_path(__dir__ + '/waterside_magic_hour.jpg')
|
65
|
+
end
|
66
|
+
|
67
|
+
def public_url
|
68
|
+
'http://localhost:9001/waterside_magic_hour.jpg'
|
69
|
+
end
|
70
|
+
|
71
|
+
config.around :each do |e|
|
72
|
+
STRICT_ENV.with_protected_env { e.run }
|
73
|
+
end
|
74
|
+
end
|
data/spec/test_server.rb
ADDED
@@ -0,0 +1,61 @@
|
|
1
|
+
require 'webrick'
|
2
|
+
include WEBrick
|
3
|
+
|
4
|
+
class ForbiddenServlet < HTTPServlet::AbstractServlet
|
5
|
+
def do_GET(req,res)
|
6
|
+
res['Content-Type'] = "text/plain"
|
7
|
+
res.status = 403
|
8
|
+
end
|
9
|
+
end
|
10
|
+
|
11
|
+
class TestServer
|
12
|
+
def self.start( log_file = nil, ssl = false, port = 9001 )
|
13
|
+
new(log_file, ssl, port).start
|
14
|
+
end
|
15
|
+
|
16
|
+
def initialize( log_file = nil, ssl = false, port = 9001 )
|
17
|
+
log_file ||= StringIO.new
|
18
|
+
log = WEBrick::Log.new(log_file)
|
19
|
+
|
20
|
+
options = {
|
21
|
+
:Port => port,
|
22
|
+
:Logger => log,
|
23
|
+
:AccessLog => [
|
24
|
+
[ log, WEBrick::AccessLog::COMMON_LOG_FORMAT ],
|
25
|
+
[ log, WEBrick::AccessLog::REFERER_LOG_FORMAT ]
|
26
|
+
],
|
27
|
+
:DocumentRoot => File.expand_path(__dir__),
|
28
|
+
}
|
29
|
+
|
30
|
+
if ssl
|
31
|
+
options[:SSLEnable] = true
|
32
|
+
options[:SSLCertificate] = OpenSSL::X509::Certificate.new(File.open("spec/certs/cacert.pem").read)
|
33
|
+
options[:SSLPrivateKey] = OpenSSL::PKey::RSA.new(File.open("spec/certs/privkey.pem").read)
|
34
|
+
options[:SSLCertName] = [ ["CN", WEBrick::Utils::getservername ] ]
|
35
|
+
end
|
36
|
+
|
37
|
+
@server = WEBrick::HTTPServer.new(options)
|
38
|
+
@server.mount("/forbidden", ForbiddenServlet)
|
39
|
+
end
|
40
|
+
|
41
|
+
def start
|
42
|
+
trap('INT') {
|
43
|
+
begin
|
44
|
+
@server.shutdown unless @server.nil?
|
45
|
+
rescue Object => e
|
46
|
+
$stderr.puts "Error #{__FILE__}:#{__LINE__}\n#{e.message}"
|
47
|
+
end
|
48
|
+
}
|
49
|
+
|
50
|
+
@thread = Thread.new { @server.start }
|
51
|
+
Thread.pass
|
52
|
+
self
|
53
|
+
end
|
54
|
+
|
55
|
+
def join
|
56
|
+
if defined? @thread and @thread
|
57
|
+
@thread.join
|
58
|
+
end
|
59
|
+
self
|
60
|
+
end
|
61
|
+
end
|
Binary file
|