image_vise 0.2.1 → 0.2.2
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 +4 -4
- data/.gitignore +8 -0
- data/.travis.yml +13 -0
- data/DEVELOPMENT.md +111 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +29 -0
- data/README.md +213 -0
- data/Rakefile +6 -0
- data/SECURITY.md +57 -0
- data/examples/config.ru +17 -0
- data/examples/custom_image_operator.rb +27 -0
- data/examples/error_handline_appsignal.rb +23 -0
- data/examples/error_handling_sentry.rb +25 -0
- data/image_vise.gemspec +43 -0
- data/lib/image_vise/fetchers/fetcher_file.rb +27 -0
- data/lib/image_vise/fetchers/fetcher_http.rb +42 -0
- data/lib/image_vise/file_response.rb +22 -0
- data/lib/image_vise/image_request.rb +70 -0
- data/lib/image_vise/operators/auto_orient.rb +10 -0
- data/lib/image_vise/operators/background_fill.rb +18 -0
- data/lib/image_vise/operators/crop.rb +32 -0
- data/lib/image_vise/operators/ellipse_stencil.rb +70 -0
- data/lib/image_vise/operators/fit_crop.rb +33 -0
- data/lib/image_vise/operators/force_jpg_out.rb +17 -0
- data/lib/image_vise/operators/geom.rb +16 -0
- data/lib/image_vise/operators/sRGB_v4_ICC_preference_displayclass.icc +0 -0
- data/lib/image_vise/operators/sharpen.rb +21 -0
- data/lib/image_vise/operators/srgb.rb +30 -0
- data/lib/image_vise/operators/strip_metadata.rb +10 -0
- data/lib/image_vise/pipeline.rb +64 -0
- data/lib/image_vise/render_engine.rb +298 -0
- data/lib/image_vise/version.rb +3 -0
- data/lib/image_vise/writers/auto_writer.rb +23 -0
- data/lib/image_vise/writers/jpeg_writer.rb +9 -0
- data/lib/image_vise.rb +177 -0
- data/spec/image_vise/auto_orient_spec.rb +10 -0
- data/spec/image_vise/background_fill_spec.rb +39 -0
- data/spec/image_vise/crop_spec.rb +20 -0
- data/spec/image_vise/ellipse_stencil_spec.rb +18 -0
- data/spec/image_vise/fetcher_file_spec.rb +48 -0
- data/spec/image_vise/fetcher_http_spec.rb +44 -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/force_jpg_out_spec.rb +36 -0
- data/spec/image_vise/geom_spec.rb +33 -0
- data/spec/image_vise/image_request_spec.rb +62 -0
- data/spec/image_vise/pipeline_spec.rb +72 -0
- data/spec/image_vise/render_engine_spec.rb +336 -0
- data/spec/image_vise/sharpen_spec.rb +17 -0
- data/spec/image_vise/srgb_spec.rb +23 -0
- data/spec/image_vise/strip_metadata_spec.rb +14 -0
- data/spec/image_vise/writers/auto_writer_spec.rb +25 -0
- data/spec/image_vise/writers/jpeg_writer_spec.rb +32 -0
- data/spec/image_vise_spec.rb +110 -0
- data/spec/layers-with-blending.psd +0 -0
- data/spec/spec_helper.rb +112 -0
- data/spec/test_server.rb +61 -0
- data/spec/waterside_magic_hour.jpg +0 -0
- data/spec/waterside_magic_hour.psd +0 -0
- data/spec/waterside_magic_hour_adobergb.jpg +0 -0
- data/spec/waterside_magic_hour_gray.tif +0 -0
- data/spec/waterside_magic_hour_transp.png +0 -0
- metadata +63 -2
@@ -0,0 +1,44 @@
|
|
1
|
+
require_relative '../spec_helper'
|
2
|
+
|
3
|
+
describe ImageVise::FetcherHTTP do
|
4
|
+
it 'is a class (can be inherited from)' do
|
5
|
+
expect(ImageVise::FetcherHTTP).to be_kind_of(Class)
|
6
|
+
end
|
7
|
+
|
8
|
+
it 'is registered as a fetcher for http:// and https://' do
|
9
|
+
expect(ImageVise.fetcher_for('http')).to eq(ImageVise::FetcherHTTP)
|
10
|
+
expect(ImageVise.fetcher_for('https')).to eq(ImageVise::FetcherHTTP)
|
11
|
+
end
|
12
|
+
|
13
|
+
it 'raises an AccessError if the host of the URL is not on the whitelist' do
|
14
|
+
uri = URI('https://wrong-origin.com/image.psd')
|
15
|
+
expect {
|
16
|
+
ImageVise::FetcherHTTP.fetch_uri_to_tempfile(uri)
|
17
|
+
}.to raise_error(ImageVise::FetcherHTTP::AccessError, /is not permitted as source/)
|
18
|
+
end
|
19
|
+
|
20
|
+
it 'raises an UpstreamError if the upstream fetch returns an error-ish status code' do
|
21
|
+
uri = URI('http://localhost:9001/forbidden')
|
22
|
+
ImageVise.add_allowed_host! 'localhost'
|
23
|
+
|
24
|
+
expect {
|
25
|
+
ImageVise::FetcherHTTP.fetch_uri_to_tempfile(uri)
|
26
|
+
}.to raise_error {|e|
|
27
|
+
expect(e).to be_kind_of(ImageVise::FetcherHTTP::UpstreamError)
|
28
|
+
expect(e.message).to include(uri.to_s)
|
29
|
+
expect(e.message).to include('403')
|
30
|
+
expect(e.http_status).to eq(403)
|
31
|
+
}
|
32
|
+
end
|
33
|
+
|
34
|
+
it 'fetches the image into a Tempfile' do
|
35
|
+
uri = URI(public_url_psd)
|
36
|
+
ImageVise.add_allowed_host! 'localhost'
|
37
|
+
|
38
|
+
result = ImageVise::FetcherHTTP.fetch_uri_to_tempfile(uri)
|
39
|
+
|
40
|
+
expect(result).to be_kind_of(Tempfile)
|
41
|
+
expect(result.size).to be_nonzero
|
42
|
+
expect(result.pos).to be_zero
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
require_relative '../spec_helper'
|
2
|
+
|
3
|
+
describe ImageVise::FileResponse do
|
4
|
+
it 'reads the file in binary mode, closes and unlinks the tempfile when close() is called' do
|
5
|
+
random_data = Random.new.bytes(1024 * 2048)
|
6
|
+
f = Tempfile.new("experiment")
|
7
|
+
f.binmode
|
8
|
+
f << random_data
|
9
|
+
|
10
|
+
response = described_class.new(f)
|
11
|
+
readback = ''.encode(Encoding::BINARY)
|
12
|
+
response.each do | chunk |
|
13
|
+
expect(chunk.encoding).to eq(Encoding::BINARY)
|
14
|
+
readback << chunk
|
15
|
+
end
|
16
|
+
|
17
|
+
response.close
|
18
|
+
|
19
|
+
expect(readback).to eq(random_data)
|
20
|
+
expect(f).to be_closed
|
21
|
+
expect(f.path).to be_nil
|
22
|
+
end
|
23
|
+
|
24
|
+
it 'only asks for the path of the tempfile and uses a separate file descriptor' do
|
25
|
+
f = Tempfile.new("experiment")
|
26
|
+
f.binmode
|
27
|
+
f << Random.new.bytes(2048)
|
28
|
+
f.flush
|
29
|
+
|
30
|
+
# Use a double so that all the methods except the ones we mock raise an assertion
|
31
|
+
double = double(path: f.path)
|
32
|
+
expect(double).to receive(:flush)
|
33
|
+
|
34
|
+
read_from_response = ''.encode(Encoding::BINARY)
|
35
|
+
response = described_class.new(double)
|
36
|
+
response.each{|b| read_from_response << b }
|
37
|
+
|
38
|
+
f.rewind
|
39
|
+
|
40
|
+
expect(f.read).to eq(read_from_response)
|
41
|
+
|
42
|
+
f.close
|
43
|
+
f.unlink
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe ImageVise::FitCrop do
|
4
|
+
it 'refuses invalid arguments' do
|
5
|
+
expect { described_class.new(width: 0, height: -1, gravity: '') }.to raise_error(ArgumentError)
|
6
|
+
end
|
7
|
+
|
8
|
+
it 'applies the crop with different gravities' do
|
9
|
+
%w( s sw se n ne nw c).each do |gravity|
|
10
|
+
image = Magick::Image.read(test_image_path)[0]
|
11
|
+
crop = described_class.new(width: 120, height: 220, gravity: gravity)
|
12
|
+
|
13
|
+
crop.apply!(image)
|
14
|
+
|
15
|
+
expect(image.columns).to eq(120)
|
16
|
+
expect(image.rows).to eq(220)
|
17
|
+
examine_image(image, "gravity-%s-" % gravity)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
require_relative '../spec_helper'
|
2
|
+
|
3
|
+
describe ImageVise::ForceJPGOut do
|
4
|
+
it "raises on invalid arguments" do
|
5
|
+
expect {
|
6
|
+
described_class.new({})
|
7
|
+
}.to raise_error(ArgumentError)
|
8
|
+
|
9
|
+
expect {
|
10
|
+
described_class.new(quality: '1')
|
11
|
+
}.to raise_error(ArgumentError)
|
12
|
+
|
13
|
+
expect {
|
14
|
+
described_class.new(quality: -1)
|
15
|
+
}.to raise_error(ArgumentError)
|
16
|
+
|
17
|
+
expect {
|
18
|
+
described_class.new(quality: 'very very low')
|
19
|
+
}.to raise_error(ArgumentError)
|
20
|
+
|
21
|
+
described_class.new(quality: 25)
|
22
|
+
end
|
23
|
+
|
24
|
+
it "sets the :writer metadata key to a JPGWriter" do
|
25
|
+
subject = described_class.new(quality: 25)
|
26
|
+
|
27
|
+
fake_magick_image = double('Magick::Image')
|
28
|
+
metadata = {}
|
29
|
+
|
30
|
+
subject.apply!(fake_magick_image, metadata)
|
31
|
+
|
32
|
+
w = metadata.fetch(:writer)
|
33
|
+
expect(w).to be_kind_of(ImageVise::JPGWriter)
|
34
|
+
expect(w.quality).to eq(25)
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe ImageVise::Geom do
|
4
|
+
it 'refuses invalid parameters' do
|
5
|
+
expect { described_class.new(geometry_string: nil) }.to raise_error(ArgumentError)
|
6
|
+
end
|
7
|
+
|
8
|
+
it 'fits to height' do
|
9
|
+
image = Magick::Image.read(test_image_path)[0]
|
10
|
+
crop = described_class.new(geometry_string: 'x200')
|
11
|
+
crop.apply!(image)
|
12
|
+
expect(image.rows).to eq(200)
|
13
|
+
examine_image(image, 'fit-height-200')
|
14
|
+
end
|
15
|
+
|
16
|
+
it 'fits to width' do
|
17
|
+
image = Magick::Image.read(test_image_path)[0]
|
18
|
+
crop = described_class.new(geometry_string: '100x')
|
19
|
+
crop.apply!(image)
|
20
|
+
expect(image.columns).to eq(100)
|
21
|
+
examine_image(image, 'fit-width-100')
|
22
|
+
end
|
23
|
+
|
24
|
+
it 'applies various geometry strings' do
|
25
|
+
%w( ^220x110 !20x20 !10x100 ).each do |geom_string|
|
26
|
+
image = Magick::Image.read(test_image_path)[0]
|
27
|
+
crop = described_class.new(geometry_string: geom_string)
|
28
|
+
|
29
|
+
crop.apply!(image)
|
30
|
+
examine_image(image, 'geom-%s' % geom_string)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,62 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe ImageVise::ImageRequest do
|
4
|
+
it 'accepts a set of params and secrets, and returns a Pipeline' do
|
5
|
+
img_params = {src_url: 'http://bucket.s3.aws.com/image.jpg', pipeline: [[:crop, {width: 10, height: 10, gravity: 's'}]]}
|
6
|
+
img_params_json = JSON.dump(img_params)
|
7
|
+
|
8
|
+
q = Base64.encode64(img_params_json)
|
9
|
+
signature = OpenSSL::HMAC.hexdigest(OpenSSL::Digest::SHA256.new, 'this is a secret', q)
|
10
|
+
params = {q: q, sig: signature}
|
11
|
+
|
12
|
+
image_request = described_class.from_params(qs_params: params, secrets: ['this is a secret'])
|
13
|
+
request_qs_params = image_request.to_query_string_params('this is a secret')
|
14
|
+
expect(request_qs_params).to be_kind_of(Hash)
|
15
|
+
|
16
|
+
image_request_roundtrip = described_class.from_params(qs_params: request_qs_params, secrets: ['this is a secret'])
|
17
|
+
end
|
18
|
+
|
19
|
+
it 'converts a file:// URL into a URI objectlist' do
|
20
|
+
img_params = {src_url: 'file:///etc/passwd', pipeline: [[:auto_orient, {}]]}
|
21
|
+
img_params_json = JSON.dump(img_params)
|
22
|
+
q = Base64.encode64(img_params_json)
|
23
|
+
signature = OpenSSL::HMAC.hexdigest(OpenSSL::Digest::SHA256.new, 'this is a secret', q)
|
24
|
+
params = {q: q, sig: signature}
|
25
|
+
image_request = described_class.from_params(qs_params: params, secrets: ['this is a secret'])
|
26
|
+
expect(image_request.src_url).to be_kind_of(URI)
|
27
|
+
end
|
28
|
+
|
29
|
+
it 'composes path parameters' do
|
30
|
+
parametrized = double(to_params: {foo: 'bar'})
|
31
|
+
uri = URI('http://example.com/image.psd')
|
32
|
+
image_request = described_class.new(src_url: uri, pipeline: parametrized)
|
33
|
+
path = image_request.to_path_params('password')
|
34
|
+
expect(path).to start_with('/eyJwaXB')
|
35
|
+
expect(path).to end_with('f207b')
|
36
|
+
end
|
37
|
+
|
38
|
+
it 'never apppends "="-padding to the Base64-encoded "q"' do
|
39
|
+
parametrized = double(to_params: {foo: 'bar'})
|
40
|
+
(1..12).each do |num_chars_in_url|
|
41
|
+
uri = URI('http://ex.com/%s' % ('i' * num_chars_in_url))
|
42
|
+
image_request = described_class.new(src_url: uri, pipeline: parametrized)
|
43
|
+
q = image_request.to_query_string_params('password').fetch(:q)
|
44
|
+
expect(q).not_to include('=')
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
describe 'fails with an invalid signature' do
|
49
|
+
it 'when the sig is invalid' do
|
50
|
+
img_params = {src_url: 'http://bucket.s3.aws.com/image.jpg',
|
51
|
+
pipeline: [[:crop, {width: 10, height: 10, gravity: 's'}]]}
|
52
|
+
img_params_json = JSON.dump(img_params)
|
53
|
+
enc = Base64.encode64(img_params_json)
|
54
|
+
signature = OpenSSL::HMAC.hexdigest(OpenSSL::Digest::SHA256.new, 'a', enc)
|
55
|
+
params = {q: enc, sig: signature}
|
56
|
+
|
57
|
+
expect {
|
58
|
+
described_class.from_params(qs_params: params, secrets: ['b'])
|
59
|
+
}.to raise_error(/Invalid or missing signature/)
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
@@ -0,0 +1,72 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe ImageVise::Pipeline do
|
4
|
+
it 'is empty by default' do
|
5
|
+
expect(subject).to be_empty
|
6
|
+
end
|
7
|
+
|
8
|
+
it 'reinstates the pipeline from the operator list parameters' do
|
9
|
+
params = [
|
10
|
+
["geom", {:geometry_string=>"10x10"}],
|
11
|
+
["crop", {:width=>5, :height=>5, :gravity=>"se"}],
|
12
|
+
["auto_orient", {}],
|
13
|
+
["fit_crop", {:width=>10, :height=>32, :gravity=>"c"}]
|
14
|
+
]
|
15
|
+
pipeline = described_class.from_param(params)
|
16
|
+
expect(pipeline).not_to be_empty
|
17
|
+
end
|
18
|
+
|
19
|
+
it 'produces a usable operator parameter list that can be roundtripped' do
|
20
|
+
operator_list = subject.geom(geometry_string: '10x10').
|
21
|
+
crop(width: 5, height: 5, gravity: 'se').
|
22
|
+
auto_orient.
|
23
|
+
fit_crop(width: 10, height: 32, gravity: 'c').to_params
|
24
|
+
|
25
|
+
expect(operator_list).to eq([
|
26
|
+
["geom", {:geometry_string=>"10x10"}],
|
27
|
+
["crop", {:width=>5, :height=>5, :gravity=>"se"}],
|
28
|
+
["auto_orient", {}],
|
29
|
+
["fit_crop", {:width=>10, :height=>32, :gravity=>"c"}]
|
30
|
+
])
|
31
|
+
|
32
|
+
pipeline = described_class.from_param(operator_list)
|
33
|
+
expect(pipeline).not_to be_empty
|
34
|
+
end
|
35
|
+
|
36
|
+
it 'applies itself to the image' do
|
37
|
+
pipeline = subject.
|
38
|
+
auto_orient.
|
39
|
+
fit_crop(width: 48, height: 48, gravity: 'c').
|
40
|
+
srgb.
|
41
|
+
sharpen(radius: 2, sigma: 0.5).
|
42
|
+
ellipse_stencil.
|
43
|
+
strip_metadata
|
44
|
+
|
45
|
+
image = Magick::Image.read(test_image_path)[0]
|
46
|
+
pipeline.apply! image, {}
|
47
|
+
examine_image(image, "stenciled")
|
48
|
+
end
|
49
|
+
|
50
|
+
it 'raises an exception when an attempt is made to serialize an unknown operator' do
|
51
|
+
unknown_op_class = Class.new
|
52
|
+
subject << unknown_op_class.new
|
53
|
+
expect {
|
54
|
+
subject.to_params
|
55
|
+
}.to raise_error(/not registered/)
|
56
|
+
end
|
57
|
+
|
58
|
+
it 'composes parameters even if one of the operators does not support to_h' do
|
59
|
+
class AnonOp
|
60
|
+
end
|
61
|
+
class ParametricOp
|
62
|
+
def to_h; {a: 133}; end
|
63
|
+
end
|
64
|
+
|
65
|
+
ImageVise.add_operator('t_anon', AnonOp)
|
66
|
+
ImageVise.add_operator('t_parametric', ParametricOp)
|
67
|
+
|
68
|
+
subject << AnonOp.new
|
69
|
+
subject << ParametricOp.new
|
70
|
+
expect(subject.to_params).to eq([["t_anon", {}], ["t_parametric", {:a=>133}]])
|
71
|
+
end
|
72
|
+
end
|
@@ -0,0 +1,336 @@
|
|
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
|
+
expect(app).to receive(:handle_generic_error).and_call_original
|
25
|
+
expect {
|
26
|
+
get image_request.to_path_params('l33tness')
|
27
|
+
}.to raise_error(/No keys set/)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
context 'when requesting an image' do
|
32
|
+
before :each do
|
33
|
+
parsed_url = Addressable::URI.parse(public_url)
|
34
|
+
ImageVise.add_allowed_host!(parsed_url.host)
|
35
|
+
end
|
36
|
+
|
37
|
+
after :each do
|
38
|
+
ImageVise.reset_allowed_hosts!
|
39
|
+
ImageVise.reset_secret_keys!
|
40
|
+
end
|
41
|
+
|
42
|
+
it 'halts with 400 when the requested image cannot be opened by ImageMagick' do
|
43
|
+
uri = Addressable::URI.parse(public_url)
|
44
|
+
ImageVise.add_allowed_host!(uri.host)
|
45
|
+
ImageVise.add_secret_key!('l33tness')
|
46
|
+
uri.path = '/___nonexistent_image.jpg'
|
47
|
+
|
48
|
+
p = ImageVise::Pipeline.new.crop(width: 10, height: 10, gravity: 'c')
|
49
|
+
image_request = ImageVise::ImageRequest.new(src_url: uri.to_s, pipeline: p)
|
50
|
+
|
51
|
+
expect_any_instance_of(Patron::Session).to receive(:get_file) {|_self, url, path|
|
52
|
+
File.open(path, 'wb') {|f| f << 'totally not an image' }
|
53
|
+
double(status: 200)
|
54
|
+
}
|
55
|
+
expect(app).to receive(:handle_request_error).and_call_original
|
56
|
+
|
57
|
+
get image_request.to_path_params('l33tness')
|
58
|
+
expect(last_response.status).to eq(400)
|
59
|
+
expect(last_response['Cache-Control']).to match(/public/)
|
60
|
+
expect(last_response.body).to include('Unsupported/unknown')
|
61
|
+
end
|
62
|
+
|
63
|
+
it 'halts with 400 when a file:// URL is given and filesystem access is not enabled' do
|
64
|
+
uri = 'file://' + test_image_path
|
65
|
+
ImageVise.deny_filesystem_sources!
|
66
|
+
ImageVise.add_secret_key!('l33tness')
|
67
|
+
|
68
|
+
p = ImageVise::Pipeline.new.fit_crop(width: 10, height: 10, gravity: 'c')
|
69
|
+
image_request = ImageVise::ImageRequest.new(src_url: uri.to_s, pipeline: p)
|
70
|
+
|
71
|
+
get image_request.to_path_params('l33tness')
|
72
|
+
expect(last_response.status).to eq(403)
|
73
|
+
expect(last_response.body).to include('filesystem access is disabled')
|
74
|
+
end
|
75
|
+
|
76
|
+
it 'responds with 403 when upstream returns it, and includes the URL in the error message' do
|
77
|
+
uri = Addressable::URI.parse(public_url)
|
78
|
+
ImageVise.add_allowed_host!(uri.host)
|
79
|
+
ImageVise.add_secret_key!('l33tness')
|
80
|
+
uri.path = '/forbidden'
|
81
|
+
|
82
|
+
p = ImageVise::Pipeline.new.crop(width: 10, height: 10, gravity: 'c')
|
83
|
+
image_request = ImageVise::ImageRequest.new(src_url: uri.to_s, pipeline: p)
|
84
|
+
|
85
|
+
get image_request.to_path_params('l33tness')
|
86
|
+
expect(last_response.status).to eq(403)
|
87
|
+
expect(last_response.headers['Content-Type']).to eq('application/json')
|
88
|
+
parsed = JSON.load(last_response.body)
|
89
|
+
expect(parsed['errors'].to_s).to include("Unfortunate upstream response")
|
90
|
+
expect(parsed['errors'].to_s).to include(uri.to_s)
|
91
|
+
end
|
92
|
+
|
93
|
+
it 'replays upstream error response codes that are selected to be replayed to the requester' do
|
94
|
+
uri = Addressable::URI.parse(public_url)
|
95
|
+
ImageVise.add_allowed_host!(uri.host)
|
96
|
+
ImageVise.add_secret_key!('l33tness')
|
97
|
+
|
98
|
+
[404, 403, 503, 504, 500].each do | error_code |
|
99
|
+
allow_any_instance_of(Patron::Session).to receive(:get_file).and_return(double(status: error_code))
|
100
|
+
|
101
|
+
p = ImageVise::Pipeline.new.crop(width: 10, height: 10, gravity: 'c')
|
102
|
+
image_request = ImageVise::ImageRequest.new(src_url: uri.to_s, pipeline: p)
|
103
|
+
|
104
|
+
get image_request.to_path_params('l33tness')
|
105
|
+
|
106
|
+
expect(last_response.status).to eq(error_code)
|
107
|
+
expect(last_response.headers).to have_key('Cache-Control')
|
108
|
+
expect(last_response.headers['Cache-Control']).to match(/public/)
|
109
|
+
|
110
|
+
expect(last_response.headers['Content-Type']).to eq('application/json')
|
111
|
+
parsed = JSON.load(last_response.body)
|
112
|
+
expect(parsed['errors'].to_s).to include("Unfortunate upstream response")
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
it 'sets very far caching headers and an ETag, and returns a 304 if any ETag is set' do
|
117
|
+
uri = Addressable::URI.parse(public_url)
|
118
|
+
ImageVise.add_allowed_host!(uri.host)
|
119
|
+
ImageVise.add_secret_key!('l33tness')
|
120
|
+
|
121
|
+
p = ImageVise::Pipeline.new.fit_crop(width: 10, height: 35, gravity: 'c')
|
122
|
+
image_request = ImageVise::ImageRequest.new(src_url: uri.to_s, pipeline: p)
|
123
|
+
|
124
|
+
req_path = image_request.to_path_params('l33tness')
|
125
|
+
|
126
|
+
get req_path, {}
|
127
|
+
expect(last_response).to be_ok
|
128
|
+
expect(last_response['ETag']).not_to be_nil
|
129
|
+
expect(last_response['Cache-Control']).to match(/public/)
|
130
|
+
|
131
|
+
get req_path, {}, {'HTTP_IF_NONE_MATCH' => last_response['ETag']}
|
132
|
+
expect(last_response.status).to eq(304)
|
133
|
+
|
134
|
+
# Should consider _any_ ETag a request to rerender something
|
135
|
+
# that already exists in an upstream cache
|
136
|
+
get req_path, {}, {'HTTP_IF_NONE_MATCH' => SecureRandom.hex(4)}
|
137
|
+
expect(last_response.status).to eq(304)
|
138
|
+
end
|
139
|
+
|
140
|
+
it 'responds with an image that passes through all the processing steps' do
|
141
|
+
uri = Addressable::URI.parse(public_url)
|
142
|
+
ImageVise.add_allowed_host!(uri.host)
|
143
|
+
ImageVise.add_secret_key!('l33tness')
|
144
|
+
|
145
|
+
p = ImageVise::Pipeline.new.geom(geometry_string: '512x335').fit_crop(width: 10, height: 10, gravity: 'c')
|
146
|
+
image_request = ImageVise::ImageRequest.new(src_url: uri.to_s, pipeline: p)
|
147
|
+
|
148
|
+
get image_request.to_path_params('l33tness')
|
149
|
+
expect(last_response.status).to eq(200)
|
150
|
+
|
151
|
+
expect(last_response.headers['Content-Type']).to eq('image/jpeg')
|
152
|
+
expect(last_response.headers).to have_key('Content-Length')
|
153
|
+
parsed_image = Magick::Image.from_blob(last_response.body)[0]
|
154
|
+
expect(parsed_image.columns).to eq(10)
|
155
|
+
end
|
156
|
+
|
157
|
+
it 'properly decodes the image request if its Base64 representation contains masked slashes and plus characters' do
|
158
|
+
ImageVise.add_secret_key!("this is fab")
|
159
|
+
sig = '64759d9ea610d75d9138bfa3ea01595d343ca8994261ae06fca8e6490222f140'
|
160
|
+
q = 'eyJwaXBlbGluZSI6W1sic2hhcnBlbiIseyJyYWRpdXMiO' +
|
161
|
+
'jAuNSwic2lnbWEiOjAuNX1dXSwic3JjX3VybCI6InNoYWRl' +
|
162
|
+
'cmljb246L0NQR1BfRmlyZWJhbGw-Yz1kOWM4ZTMzO'+
|
163
|
+
'TZmNjMwYzM1MjM0MTYwMmM2YzJhYmQyZjAzNTcxMTF'+
|
164
|
+
'jIn0'
|
165
|
+
params = {q: q, sig: sig}
|
166
|
+
req = ImageVise::ImageRequest.from_params(qs_params: params, secrets: ['this is fab'])
|
167
|
+
|
168
|
+
# We do a check based on the raised exception - the request will fail
|
169
|
+
# at the fetcher lookup stage. That stage however takes place _after_ the
|
170
|
+
# signature has been validated, which means that the slash within the
|
171
|
+
# Base64 payload has been taken into account
|
172
|
+
expect(app).to receive(:raise_exceptions?).and_return(true)
|
173
|
+
expect {
|
174
|
+
get req.to_path_params('this is fab')
|
175
|
+
}.to raise_error(/No fetcher registered for shadericon/)
|
176
|
+
end
|
177
|
+
|
178
|
+
it 'calls all of the internal methods during execution' do
|
179
|
+
uri = Addressable::URI.parse(public_url)
|
180
|
+
ImageVise.add_allowed_host!(uri.host)
|
181
|
+
ImageVise.add_secret_key!('l33tness')
|
182
|
+
|
183
|
+
p = ImageVise::Pipeline.new.geom(geometry_string: '512x335').fit_crop(width: 10, height: 10, gravity: 'c')
|
184
|
+
image_request = ImageVise::ImageRequest.new(src_url: uri.to_s, pipeline: p)
|
185
|
+
params = image_request.to_query_string_params('l33tness')
|
186
|
+
|
187
|
+
expect(app).to receive(:parse_env_into_request).and_call_original
|
188
|
+
expect(app).to receive(:process_image_request).and_call_original
|
189
|
+
expect(app).to receive(:extract_params_from_request).and_call_original
|
190
|
+
expect(app).to receive(:image_rack_response).and_call_original
|
191
|
+
expect(app).to receive(:source_file_type_permitted?).and_call_original
|
192
|
+
|
193
|
+
get image_request.to_path_params('l33tness')
|
194
|
+
expect(last_response.status).to eq(200)
|
195
|
+
end
|
196
|
+
|
197
|
+
it 'picks the image from the filesystem if that is permitted' do
|
198
|
+
uri = 'file://' + test_image_path
|
199
|
+
ImageVise.allow_filesystem_source!(File.dirname(test_image_path) + '/*.*')
|
200
|
+
ImageVise.add_secret_key!('l33tness')
|
201
|
+
|
202
|
+
p = ImageVise::Pipeline.new.fit_crop(width: 10, height: 10, gravity: 'c')
|
203
|
+
image_request = ImageVise::ImageRequest.new(src_url: uri.to_s, pipeline: p)
|
204
|
+
|
205
|
+
get image_request.to_path_params('l33tness')
|
206
|
+
expect(last_response.status).to eq(200)
|
207
|
+
expect(last_response.headers['Content-Type']).to eq('image/jpeg')
|
208
|
+
end
|
209
|
+
|
210
|
+
it 'URI-decodes the path in a file:// URL for a file with a Unicode path' do
|
211
|
+
utf8_file_path = File.dirname(test_image_path) + '/картинка.jpg'
|
212
|
+
FileUtils.cp_r(test_image_path, utf8_file_path)
|
213
|
+
uri = 'file://' + URI.encode(utf8_file_path)
|
214
|
+
|
215
|
+
ImageVise.allow_filesystem_source!(File.dirname(test_image_path) + '/*.*')
|
216
|
+
ImageVise.add_secret_key!('l33tness')
|
217
|
+
|
218
|
+
p = ImageVise::Pipeline.new.fit_crop(width: 10, height: 10, gravity: 'c')
|
219
|
+
image_request = ImageVise::ImageRequest.new(src_url: uri.to_s, pipeline: p)
|
220
|
+
|
221
|
+
get image_request.to_path_params('l33tness')
|
222
|
+
File.unlink(utf8_file_path)
|
223
|
+
expect(last_response.status).to eq(200)
|
224
|
+
expect(last_response.headers['Content-Type']).to eq('image/jpeg')
|
225
|
+
end
|
226
|
+
|
227
|
+
it 'forbids a request with an extra GET param' do
|
228
|
+
uri = 'file://' + URI.encode(test_image_path)
|
229
|
+
|
230
|
+
p = ImageVise::Pipeline.new.fit_crop(width: 10, height: 10, gravity: 'c')
|
231
|
+
image_request = ImageVise::ImageRequest.new(src_url: uri.to_s, pipeline: p)
|
232
|
+
|
233
|
+
get image_request.to_path_params('l33tness'), {'extra' => '123'}
|
234
|
+
|
235
|
+
expect(last_response.status).to eq(400)
|
236
|
+
end
|
237
|
+
|
238
|
+
it 'returns the processed JPEG image as a PNG if it had to get an alpha channel during processing' do
|
239
|
+
uri = Addressable::URI.parse(public_url)
|
240
|
+
ImageVise.add_allowed_host!(uri.host)
|
241
|
+
ImageVise.add_secret_key!('l33tness')
|
242
|
+
|
243
|
+
p = ImageVise::Pipeline.new.geom(geometry_string: '220x220').ellipse_stencil
|
244
|
+
image_request = ImageVise::ImageRequest.new(src_url: uri.to_s, pipeline: p)
|
245
|
+
|
246
|
+
get image_request.to_path_params('l33tness')
|
247
|
+
expect(last_response.status).to eq(200)
|
248
|
+
|
249
|
+
expect(last_response.headers['Content-Type']).to eq('image/png')
|
250
|
+
expect(last_response.headers).to have_key('Content-Length')
|
251
|
+
|
252
|
+
examine_image_from_string(last_response.body)
|
253
|
+
end
|
254
|
+
|
255
|
+
it 'permits a PSD file by default' do
|
256
|
+
uri = Addressable::URI.parse(public_url_psd)
|
257
|
+
ImageVise.add_allowed_host!(uri.host)
|
258
|
+
ImageVise.add_secret_key!('l33tness')
|
259
|
+
|
260
|
+
p = ImageVise::Pipeline.new.geom(geometry_string: '220x220').ellipse_stencil
|
261
|
+
image_request = ImageVise::ImageRequest.new(src_url: uri.to_s, pipeline: p)
|
262
|
+
|
263
|
+
get image_request.to_path_params('l33tness')
|
264
|
+
expect(last_response.status).to eq(200)
|
265
|
+
end
|
266
|
+
|
267
|
+
it 'destroys all the loaded PSD layers' do
|
268
|
+
uri = Addressable::URI.parse(public_url_psd_multilayer)
|
269
|
+
ImageVise.add_allowed_host!(uri.host)
|
270
|
+
ImageVise.add_secret_key!('l33tness')
|
271
|
+
|
272
|
+
p = ImageVise::Pipeline.new.geom(geometry_string: '220x220')
|
273
|
+
image_request = ImageVise::ImageRequest.new(src_url: uri.to_s, pipeline: p)
|
274
|
+
|
275
|
+
class << app
|
276
|
+
def raise_exceptions?; true; end
|
277
|
+
end
|
278
|
+
|
279
|
+
# For each layer loaded into the ImageList
|
280
|
+
expect(ImageVise).to receive(:destroy).and_call_original.exactly(5).times
|
281
|
+
|
282
|
+
get image_request.to_path_params('l33tness')
|
283
|
+
|
284
|
+
expect(last_response.status).to eq(200)
|
285
|
+
end
|
286
|
+
|
287
|
+
it 'outputs a converted TIFF file as a PNG' do
|
288
|
+
uri = Addressable::URI.parse(public_url_tif)
|
289
|
+
ImageVise.add_allowed_host!(uri.host)
|
290
|
+
ImageVise.add_secret_key!('l33tness')
|
291
|
+
|
292
|
+
p = ImageVise::Pipeline.new.geom(geometry_string: '220x220')
|
293
|
+
image_request = ImageVise::ImageRequest.new(src_url: uri.to_s, pipeline: p)
|
294
|
+
|
295
|
+
class << app
|
296
|
+
def source_file_type_permitted?(type); true; end
|
297
|
+
end
|
298
|
+
|
299
|
+
get image_request.to_path_params('l33tness')
|
300
|
+
expect(last_response.status).to eq(200)
|
301
|
+
expect(last_response.headers['Content-Type']).to eq('image/jpeg')
|
302
|
+
end
|
303
|
+
|
304
|
+
it 'processes a 1.5mb PSD with a forced conversion to JPEG' do
|
305
|
+
uri = Addressable::URI.parse(public_url_psd)
|
306
|
+
ImageVise.add_allowed_host!(uri.host)
|
307
|
+
ImageVise.add_secret_key!('1337ness')
|
308
|
+
|
309
|
+
p = ImageVise::Pipeline.new.geom(geometry_string: 'x220').force_jpg_out(quality: 85)
|
310
|
+
image_request = ImageVise::ImageRequest.new(src_url: uri.to_s, pipeline: p)
|
311
|
+
|
312
|
+
get image_request.to_path_params('1337ness')
|
313
|
+
|
314
|
+
expect(last_response.headers['Content-Type']).to eq('image/jpeg')
|
315
|
+
expect(last_response.status).to eq(200)
|
316
|
+
|
317
|
+
examine_image_from_string(last_response.body)
|
318
|
+
end
|
319
|
+
|
320
|
+
it 'converts a PNG into a JPG applying a background fill' do
|
321
|
+
uri = Addressable::URI.parse(public_url_png_transparency)
|
322
|
+
ImageVise.add_allowed_host!(uri.host)
|
323
|
+
ImageVise.add_secret_key!('h00ray')
|
324
|
+
|
325
|
+
p = ImageVise::Pipeline.new.background_fill(color: 'white').geom(geometry_string: 'x220').force_jpg_out(quality: 5)
|
326
|
+
image_request = ImageVise::ImageRequest.new(src_url: uri.to_s, pipeline: p)
|
327
|
+
|
328
|
+
get image_request.to_path_params('h00ray')
|
329
|
+
|
330
|
+
expect(last_response.status).to eq(200)
|
331
|
+
expect(last_response.headers['Content-Type']).to eq('image/jpeg')
|
332
|
+
|
333
|
+
examine_image_from_string(last_response.body)
|
334
|
+
end
|
335
|
+
end
|
336
|
+
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
|