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.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +8 -0
  3. data/.travis.yml +13 -0
  4. data/DEVELOPMENT.md +111 -0
  5. data/Gemfile +4 -0
  6. data/LICENSE.txt +29 -0
  7. data/README.md +213 -0
  8. data/Rakefile +6 -0
  9. data/SECURITY.md +57 -0
  10. data/examples/config.ru +17 -0
  11. data/examples/custom_image_operator.rb +27 -0
  12. data/examples/error_handline_appsignal.rb +23 -0
  13. data/examples/error_handling_sentry.rb +25 -0
  14. data/image_vise.gemspec +43 -0
  15. data/lib/image_vise/fetchers/fetcher_file.rb +27 -0
  16. data/lib/image_vise/fetchers/fetcher_http.rb +42 -0
  17. data/lib/image_vise/file_response.rb +22 -0
  18. data/lib/image_vise/image_request.rb +70 -0
  19. data/lib/image_vise/operators/auto_orient.rb +10 -0
  20. data/lib/image_vise/operators/background_fill.rb +18 -0
  21. data/lib/image_vise/operators/crop.rb +32 -0
  22. data/lib/image_vise/operators/ellipse_stencil.rb +70 -0
  23. data/lib/image_vise/operators/fit_crop.rb +33 -0
  24. data/lib/image_vise/operators/force_jpg_out.rb +17 -0
  25. data/lib/image_vise/operators/geom.rb +16 -0
  26. data/lib/image_vise/operators/sRGB_v4_ICC_preference_displayclass.icc +0 -0
  27. data/lib/image_vise/operators/sharpen.rb +21 -0
  28. data/lib/image_vise/operators/srgb.rb +30 -0
  29. data/lib/image_vise/operators/strip_metadata.rb +10 -0
  30. data/lib/image_vise/pipeline.rb +64 -0
  31. data/lib/image_vise/render_engine.rb +298 -0
  32. data/lib/image_vise/version.rb +3 -0
  33. data/lib/image_vise/writers/auto_writer.rb +23 -0
  34. data/lib/image_vise/writers/jpeg_writer.rb +9 -0
  35. data/lib/image_vise.rb +177 -0
  36. data/spec/image_vise/auto_orient_spec.rb +10 -0
  37. data/spec/image_vise/background_fill_spec.rb +39 -0
  38. data/spec/image_vise/crop_spec.rb +20 -0
  39. data/spec/image_vise/ellipse_stencil_spec.rb +18 -0
  40. data/spec/image_vise/fetcher_file_spec.rb +48 -0
  41. data/spec/image_vise/fetcher_http_spec.rb +44 -0
  42. data/spec/image_vise/file_response_spec.rb +45 -0
  43. data/spec/image_vise/fit_crop_spec.rb +20 -0
  44. data/spec/image_vise/force_jpg_out_spec.rb +36 -0
  45. data/spec/image_vise/geom_spec.rb +33 -0
  46. data/spec/image_vise/image_request_spec.rb +62 -0
  47. data/spec/image_vise/pipeline_spec.rb +72 -0
  48. data/spec/image_vise/render_engine_spec.rb +336 -0
  49. data/spec/image_vise/sharpen_spec.rb +17 -0
  50. data/spec/image_vise/srgb_spec.rb +23 -0
  51. data/spec/image_vise/strip_metadata_spec.rb +14 -0
  52. data/spec/image_vise/writers/auto_writer_spec.rb +25 -0
  53. data/spec/image_vise/writers/jpeg_writer_spec.rb +32 -0
  54. data/spec/image_vise_spec.rb +110 -0
  55. data/spec/layers-with-blending.psd +0 -0
  56. data/spec/spec_helper.rb +112 -0
  57. data/spec/test_server.rb +61 -0
  58. data/spec/waterside_magic_hour.jpg +0 -0
  59. data/spec/waterside_magic_hour.psd +0 -0
  60. data/spec/waterside_magic_hour_adobergb.jpg +0 -0
  61. data/spec/waterside_magic_hour_gray.tif +0 -0
  62. data/spec/waterside_magic_hour_transp.png +0 -0
  63. 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