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,54 @@
|
|
1
|
+
class ImageVise::Pipeline
|
2
|
+
def self.operator_by_name(name)
|
3
|
+
operator = ImageVise.operator_from(name) or raise "Unknown operator #{name}"
|
4
|
+
end
|
5
|
+
|
6
|
+
def self.from_param(array_of_operator_names_to_operator_params)
|
7
|
+
operators = array_of_operator_names_to_operator_params.map do |(operator_name, operator_params)|
|
8
|
+
operator_class = operator_by_name(operator_name)
|
9
|
+
if operator_params.any? && operator_class.method(:new).arity.nonzero?
|
10
|
+
operator_class.new(**operator_params)
|
11
|
+
else
|
12
|
+
operator_class.new
|
13
|
+
end
|
14
|
+
end
|
15
|
+
new(operators)
|
16
|
+
end
|
17
|
+
|
18
|
+
def initialize(operators = [])
|
19
|
+
@ops = operators.to_a
|
20
|
+
end
|
21
|
+
|
22
|
+
def <<(image_operator)
|
23
|
+
@ops << image_operator; self
|
24
|
+
end
|
25
|
+
|
26
|
+
def empty?
|
27
|
+
@ops.empty?
|
28
|
+
end
|
29
|
+
|
30
|
+
def method_missing(method_name, *a, &blk)
|
31
|
+
operator_builder = ImageVise.operator_from(method_name)
|
32
|
+
self << operator_builder.new(*a)
|
33
|
+
end
|
34
|
+
|
35
|
+
def respond_to_missing?(method_name, *a)
|
36
|
+
ImageVise.defined_operators.include?(method_name.to_s)
|
37
|
+
end
|
38
|
+
|
39
|
+
def to_params
|
40
|
+
@ops.map do |operator|
|
41
|
+
operator_name = ImageVise.operator_name_for(operator)
|
42
|
+
operator_params = operator.respond_to?(:to_h) ? operator.to_h : {}
|
43
|
+
[operator_name, operator_params]
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def apply!(magick_image)
|
48
|
+
@ops.each{|e| e.apply!(magick_image) }
|
49
|
+
end
|
50
|
+
|
51
|
+
def each(&b)
|
52
|
+
@ops.each(&b)
|
53
|
+
end
|
54
|
+
end
|
@@ -0,0 +1,210 @@
|
|
1
|
+
class ImageVise::RenderEngine
|
2
|
+
require_relative 'image_request'
|
3
|
+
require_relative 'file_response'
|
4
|
+
class UnsupportedInputFormat < StandardError; end
|
5
|
+
class EmptyRender < StandardError; end
|
6
|
+
|
7
|
+
# Codes that have to be sent through to the requester
|
8
|
+
PASSTHROUGH_STATUS_CODES = [404, 403, 503, 504, 500]
|
9
|
+
|
10
|
+
DEFAULT_HEADERS = {
|
11
|
+
'Allow' => "GET"
|
12
|
+
}.freeze
|
13
|
+
|
14
|
+
# To prevent some string allocations
|
15
|
+
JSON_ERROR_HEADERS = DEFAULT_HEADERS.merge({
|
16
|
+
'Content-Type' => 'application/json',
|
17
|
+
'Cache-Control' => 'private, max-age=0, no-cache'
|
18
|
+
}).freeze
|
19
|
+
|
20
|
+
# How long is a render (the ImageMagick/write part) is allowed to
|
21
|
+
# take before we kill it
|
22
|
+
RENDER_TIMEOUT_SECONDS = 10
|
23
|
+
|
24
|
+
# Which input files we permit (based on extensions stored in MagicBytes)
|
25
|
+
PERMITTED_EXTENSIONS = %w( gif png jpg )
|
26
|
+
|
27
|
+
# How long should we wait when fetching the image from the external host
|
28
|
+
EXTERNAL_IMAGE_FETCH_TIMEOUT_SECONDS = 4
|
29
|
+
|
30
|
+
# The default file type for images with alpha
|
31
|
+
PNG_FILE_TYPE = Class.new do
|
32
|
+
def self.mime; 'image/png'; end
|
33
|
+
def self.ext; 'png'; end
|
34
|
+
end
|
35
|
+
|
36
|
+
# Fetch the given URL into a Tempfile and return the File object
|
37
|
+
def fetch_url_into_tempfile(source_image_uri)
|
38
|
+
tf = Tempfile.new('source-imagevise-image')
|
39
|
+
s = Patron::Session.new
|
40
|
+
s.automatic_content_encoding = true
|
41
|
+
s.timeout = EXTERNAL_IMAGE_FETCH_TIMEOUT_SECONDS
|
42
|
+
s.connect_timeout = EXTERNAL_IMAGE_FETCH_TIMEOUT_SECONDS
|
43
|
+
response = s.get_file(source_image_uri, tf.path)
|
44
|
+
if PASSTHROUGH_STATUS_CODES.include?(response.status)
|
45
|
+
tf.close; tf.unlink;
|
46
|
+
bail response.status, "Unfortunate upstream response: #{response.status}"
|
47
|
+
end
|
48
|
+
tf
|
49
|
+
rescue Exception => e
|
50
|
+
tf.close; tf.unlink;
|
51
|
+
raise e
|
52
|
+
end
|
53
|
+
|
54
|
+
def bail(status, *errors_array)
|
55
|
+
h = JSON_ERROR_HEADERS.dup # Needed because some upstream middleware migh be modifying headers
|
56
|
+
response = [status.to_i, h, [JSON.pretty_generate({errors: errors_array})]]
|
57
|
+
throw :__bail, response
|
58
|
+
end
|
59
|
+
|
60
|
+
# The main entry point URL, at the index so that the Sinatra app can be used
|
61
|
+
# in-place of a Rails controller (as opposed to having to mount it at the root
|
62
|
+
# of the Rails app or having all the URLs refer to a subpath)
|
63
|
+
def call(env)
|
64
|
+
catch(:__bail) { handle_request(env) }
|
65
|
+
end
|
66
|
+
|
67
|
+
def handle_request(env)
|
68
|
+
setup_error_handling(env)
|
69
|
+
render_destination_file = binary_tempfile
|
70
|
+
|
71
|
+
# Assume that if _any_ ETag is given the image is being requested anew as a refetch,
|
72
|
+
# and the client already has it. Just respond with a 304.
|
73
|
+
return [304, DEFAULT_HEADERS.dup, []] if env['HTTP_IF_NONE_MATCH']
|
74
|
+
|
75
|
+
req = Rack::Request.new(env)
|
76
|
+
bail(405, 'Only GET supported') unless req.get?
|
77
|
+
|
78
|
+
# Validate the inputs
|
79
|
+
image_request = ImageVise::ImageRequest.to_request(qs_params: req.params,
|
80
|
+
secrets: ImageVise.secret_keys,
|
81
|
+
permitted_source_hosts: ImageVise.allowed_hosts)
|
82
|
+
|
83
|
+
# Recover the source image URL and the pipeline instructions (all the image ops)
|
84
|
+
source_image_uri, pipeline = image_request.src_url, image_request.pipeline
|
85
|
+
raise 'Image pipeline has no operators' if pipeline.empty?
|
86
|
+
|
87
|
+
# Compute an ETag which describes this image transform + image source location.
|
88
|
+
# Assume the image URL contents does _never_ change.
|
89
|
+
etag = image_request.cache_etag
|
90
|
+
|
91
|
+
# Download the original into a Tempfile
|
92
|
+
source_file = fetch_url_into_tempfile(source_image_uri)
|
93
|
+
|
94
|
+
# Make sure we do not try to process something...questionable
|
95
|
+
source_file_type = detect_file_type(source_file)
|
96
|
+
|
97
|
+
# Perform the processing
|
98
|
+
if enable_forking?
|
99
|
+
require 'exceptional_fork'
|
100
|
+
ExceptionalFork.fork_and_wait { apply_pipeline(source_file.path, pipeline, source_file_type, render_destination_file.path) }
|
101
|
+
else
|
102
|
+
apply_pipeline(source_file.path, pipeline, source_file_type, render_destination_file.path)
|
103
|
+
end
|
104
|
+
|
105
|
+
# Catch this one early
|
106
|
+
raise EmptyRender, "The rendered image was empty" if render_destination_file.size.zero?
|
107
|
+
|
108
|
+
render_destination_file.rewind
|
109
|
+
render_file_type = detect_file_type(render_destination_file)
|
110
|
+
|
111
|
+
response_headers = DEFAULT_HEADERS.merge({
|
112
|
+
'Content-Type' => render_file_type.mime,
|
113
|
+
'Content-Length' => '%d' % render_destination_file.size,
|
114
|
+
'Cache-Control' => 'public',
|
115
|
+
'ETag' => etag
|
116
|
+
})
|
117
|
+
|
118
|
+
# Wrap the body Tempfile with a self-closing response.
|
119
|
+
# Once the response is read in full, the tempfile is going to be closed and unlinked.
|
120
|
+
[200, response_headers, ImageVise::FileResponse.new(render_destination_file)]
|
121
|
+
rescue *permanent_failures => e
|
122
|
+
handle_request_error(e)
|
123
|
+
raise_exception_or_error_response(e, 422)
|
124
|
+
rescue Exception => e
|
125
|
+
handle_generic_error(e)
|
126
|
+
raise_exception_or_error_response(e, 500)
|
127
|
+
ensure
|
128
|
+
close_and_unlink(source_file)
|
129
|
+
end
|
130
|
+
|
131
|
+
def raise_exception_or_error_response(exception, status_code)
|
132
|
+
if raise_exceptions?
|
133
|
+
raise exception
|
134
|
+
else
|
135
|
+
bail status_code, exception.message
|
136
|
+
end
|
137
|
+
end
|
138
|
+
|
139
|
+
def close_and_unlink(f)
|
140
|
+
return unless f
|
141
|
+
f.close unless f.closed?
|
142
|
+
f.unlink
|
143
|
+
end
|
144
|
+
|
145
|
+
def binary_tempfile
|
146
|
+
Tempfile.new('imagevise-tmp').tap{|f| f.binmode }
|
147
|
+
end
|
148
|
+
|
149
|
+
def detect_file_type(tempfile)
|
150
|
+
tempfile.rewind
|
151
|
+
|
152
|
+
file_info = MagicBytes.read_and_detect(tempfile)
|
153
|
+
return file_info if PERMITTED_EXTENSIONS.include?(file_info.ext)
|
154
|
+
raise UnsupportedInputFormat.new("Unsupported/unknown input file format .%s" %
|
155
|
+
file_info.ext)
|
156
|
+
end
|
157
|
+
|
158
|
+
# Lists exceptions that should lead to the request being flagged
|
159
|
+
# as invalid (and not 5xx). Decent clients should _not_ retry those requests.
|
160
|
+
def permanent_failures
|
161
|
+
[
|
162
|
+
Magick::ImageMagickError,
|
163
|
+
UnsupportedInputFormat,
|
164
|
+
ImageVise::ImageRequest::InvalidRequest
|
165
|
+
]
|
166
|
+
end
|
167
|
+
|
168
|
+
# Is meant to be overridden by subclasses,
|
169
|
+
# will be called at the start of each reauest
|
170
|
+
def setup_error_handling(rack_env)
|
171
|
+
end
|
172
|
+
|
173
|
+
# Is meant to be overridden by subclasses,
|
174
|
+
# will be called when a request fails due to a malformed query string,
|
175
|
+
# unrecognized signature or other client-induced problems
|
176
|
+
def handle_request_error(err)
|
177
|
+
end
|
178
|
+
|
179
|
+
# Is meant to be overridden by subclasses,
|
180
|
+
# will be called when a request fails due to an error on the server
|
181
|
+
# (like an unexpected error in an image operator)
|
182
|
+
def handle_generic_error(err)
|
183
|
+
end
|
184
|
+
|
185
|
+
# Tells whether the engine must raise the exceptions further up the Rack stack,
|
186
|
+
# or they should be suppressed and a JSON response must be returned.
|
187
|
+
def raise_exceptions?
|
188
|
+
false
|
189
|
+
end
|
190
|
+
|
191
|
+
def enable_forking?
|
192
|
+
ENV.key? 'IMAGE_VISE_ENABLE_FORK'
|
193
|
+
end
|
194
|
+
|
195
|
+
def apply_pipeline(source_file_path, pipeline, source_file_type, render_to_path)
|
196
|
+
render_file_type = source_file_type
|
197
|
+
magick_image = Magick::Image.read(source_file_path)[0]
|
198
|
+
pipeline.apply!(magick_image)
|
199
|
+
|
200
|
+
# If processing the image has created an alpha channel, use PNG always.
|
201
|
+
# Otherwise, keep the original format for as far as the supported formats list goes.
|
202
|
+
render_file_type = PNG_FILE_TYPE if magick_image.alpha?
|
203
|
+
|
204
|
+
magick_image.format = render_file_type.ext
|
205
|
+
magick_image.write(render_to_path)
|
206
|
+
ensure
|
207
|
+
ImageVise.destroy(magick_image)
|
208
|
+
end
|
209
|
+
|
210
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# Applies a sharpening filter to the image.
|
2
|
+
#
|
3
|
+
# The corresponding Pipeline method is `sharpen`.
|
4
|
+
class ImageVise::Sharpen < Ks.strict(:radius, :sigma)
|
5
|
+
def initialize(*)
|
6
|
+
super
|
7
|
+
self.radius = radius.to_f
|
8
|
+
self.sigma = sigma.to_f
|
9
|
+
raise ArgumentError, ":radius must positive" unless sigma > 0
|
10
|
+
raise ArgumentError, ":sigma must positive" unless sigma > 0
|
11
|
+
end
|
12
|
+
|
13
|
+
def apply!(magick_image)
|
14
|
+
sharpened_image = magick_image.sharpen(radius, sigma)
|
15
|
+
magick_image.composite!(sharpened_image, Magick::CenterGravity, Magick::CopyCompositeOp)
|
16
|
+
ensure
|
17
|
+
ImageVise.destroy(sharpened_image)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
ImageVise.add_operator 'sharpen', ImageVise::Sharpen
|
data/lib/image_vise.rb
ADDED
@@ -0,0 +1,118 @@
|
|
1
|
+
require 'ks'
|
2
|
+
require 'json'
|
3
|
+
require 'patron'
|
4
|
+
require 'rmagick'
|
5
|
+
require 'magic_bytes'
|
6
|
+
|
7
|
+
class ImageVise
|
8
|
+
VERSION = '0.0.16'
|
9
|
+
|
10
|
+
@allowed_hosts = Set.new
|
11
|
+
@keys = Set.new
|
12
|
+
@operators = {}
|
13
|
+
|
14
|
+
class << self
|
15
|
+
# Resets all allowed hosts
|
16
|
+
def reset_allowed_hosts!
|
17
|
+
@allowed_hosts.clear
|
18
|
+
end
|
19
|
+
|
20
|
+
# Add an allowed host
|
21
|
+
def add_allowed_host!(hostname)
|
22
|
+
@allowed_hosts << hostname
|
23
|
+
end
|
24
|
+
|
25
|
+
# Returns both the allowed hosts added at runtime and the ones set in the constant
|
26
|
+
def allowed_hosts
|
27
|
+
@allowed_hosts.to_a
|
28
|
+
end
|
29
|
+
|
30
|
+
# Removes all set keys
|
31
|
+
def reset_secret_keys!
|
32
|
+
@keys.clear
|
33
|
+
end
|
34
|
+
|
35
|
+
# Adds a key against which the parameters are going to be verified.
|
36
|
+
# Multiple applications may have their own different keys,
|
37
|
+
# so we need to have multiple keys.
|
38
|
+
def add_secret_key!(key)
|
39
|
+
@keys << key; self
|
40
|
+
end
|
41
|
+
|
42
|
+
# Returns the array of defined keys or raises an exception if no keys have been set yet
|
43
|
+
def secret_keys
|
44
|
+
(@keys.any? && @keys.to_a) or raise "No keys set, add a key using `ImageVise.add_secret_key!(key)'"
|
45
|
+
end
|
46
|
+
|
47
|
+
# Generate a set of querystring params for a resized image. Yields a Pipeline object that
|
48
|
+
# will receive method calls for adding image operations to a stack.
|
49
|
+
#
|
50
|
+
# ImageVise.image_params(src_url: image_url_on_s3, secret: '...') do |p|
|
51
|
+
# p.center_fit width: 128, height: 128
|
52
|
+
# p.elliptic_stencil
|
53
|
+
# end #=> {q: '...', sig: '...'}
|
54
|
+
#
|
55
|
+
# The query string elements can be then passed on to RenderEngine for validation and execution.
|
56
|
+
#
|
57
|
+
# @yields {ImageVise::Pipeline}
|
58
|
+
# @return [Hash]
|
59
|
+
def image_params(src_url:, secret:)
|
60
|
+
p = Pipeline.new
|
61
|
+
yield(p)
|
62
|
+
raise ArgumentError, "Image pipeline has no steps defined" if p.empty?
|
63
|
+
ImageRequest.new(src_url: src_url, pipeline: p).to_query_string_params(secret)
|
64
|
+
end
|
65
|
+
|
66
|
+
# Adds an operator
|
67
|
+
def add_operator(operator_name, object_responding_to_new)
|
68
|
+
@operators[operator_name.to_s] = object_responding_to_new
|
69
|
+
end
|
70
|
+
|
71
|
+
# Gets an operator by name
|
72
|
+
def operator_from(operator_name)
|
73
|
+
@operators.fetch(operator_name.to_s)
|
74
|
+
end
|
75
|
+
|
76
|
+
def defined_operator_names
|
77
|
+
@operators.keys
|
78
|
+
end
|
79
|
+
|
80
|
+
def operator_name_for(operator)
|
81
|
+
@operators.key(operator.class) or raise "Operator #{operator.inspect} not registered using ImageVise.add_operator"
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
# Made available since the object that is used with `mount()` in Rails
|
86
|
+
# has to, by itself, to respond to `call`.
|
87
|
+
#
|
88
|
+
# Thanks to this method you can do this:
|
89
|
+
#
|
90
|
+
# mount ImageVise => '/thumbnails'
|
91
|
+
#
|
92
|
+
# instead of having to do
|
93
|
+
#
|
94
|
+
# mount ImageVise.new => '/thumbnails'
|
95
|
+
#
|
96
|
+
def self.call(rack_env)
|
97
|
+
ImageVise::RenderEngine.new.call(rack_env)
|
98
|
+
end
|
99
|
+
|
100
|
+
def call(rack_env)
|
101
|
+
ImageVise::RenderEngine.new.call(rack_env)
|
102
|
+
end
|
103
|
+
|
104
|
+
# Used as a shorthand to force-destroy Magick images in ensure() blocks. Since
|
105
|
+
# ensure blocks sometimes deal with variables in inconsistent states (variable
|
106
|
+
# in scope but not yet set to an image) we take the possibility of nils into account.
|
107
|
+
# We also deal with Magick::Image objects that already have been destroyed in a clean manner.
|
108
|
+
def self.destroy(maybe_image)
|
109
|
+
return unless maybe_image
|
110
|
+
return unless maybe_image.respond_to?(:destroy!)
|
111
|
+
return if maybe_image.destroyed?
|
112
|
+
maybe_image.destroy!
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
Dir.glob(__dir__ + '/**/*.rb').sort.each do |f|
|
117
|
+
require f unless f == File.expand_path(__FILE__)
|
118
|
+
end
|
@@ -0,0 +1,10 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe ImageVise::AutoOrient do
|
4
|
+
it 'applies auto orient to the image' do
|
5
|
+
image = Magick::Image.read(test_image_path)[0]
|
6
|
+
orient = described_class.new
|
7
|
+
expect(image).to receive(:auto_orient!).and_call_original
|
8
|
+
orient.apply!(image)
|
9
|
+
end
|
10
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe ImageVise::Crop do
|
4
|
+
it 'refuses invalid parameters' 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,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 = SecureRandom.random_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 << SecureRandom.random_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,17 @@
|
|
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 'applies various geometry strings' do
|
9
|
+
%w( ^220x110 !20x20 !10x100 ).each do |geom_string|
|
10
|
+
image = Magick::Image.read(test_image_path)[0]
|
11
|
+
crop = described_class.new(geometry_string: geom_string)
|
12
|
+
|
13
|
+
crop.apply!(image)
|
14
|
+
examine_image(image, 'geom-%s' % geom_string)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,53 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe ImageVise::ImageRequest do
|
4
|
+
it 'accepts a set of params, secrets and permitted hosts 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
|
+
signature = OpenSSL::HMAC.hexdigest(OpenSSL::Digest::SHA256.new, 'this is a secret', img_params_json)
|
8
|
+
params = {
|
9
|
+
q: Base64.encode64(img_params_json),
|
10
|
+
sig: signature
|
11
|
+
}
|
12
|
+
|
13
|
+
image_request = described_class.to_request(qs_params: params, secrets: ['this is a secret'], permitted_source_hosts: ['bucket.s3.aws.com'])
|
14
|
+
request_qs_params = image_request.to_query_string_params('this is a secret')
|
15
|
+
expect(request_qs_params).to be_kind_of(Hash)
|
16
|
+
|
17
|
+
image_request_roundtrip = described_class.to_request(qs_params: request_qs_params, secrets: ['this is a secret'], permitted_source_hosts: ['bucket.s3.aws.com'])
|
18
|
+
end
|
19
|
+
|
20
|
+
describe 'fails with an invalid pipeline' do
|
21
|
+
it 'when the pipe param is missing'
|
22
|
+
it 'when the pipe param is empty'
|
23
|
+
it 'when the pipe param cannot be parsed into a Pipeline'
|
24
|
+
it 'when the pipe param parses into a Pipeline with zero operators'
|
25
|
+
end
|
26
|
+
|
27
|
+
describe 'fails with an invalid URL' do
|
28
|
+
it 'when the URL param is missing'
|
29
|
+
it 'when the URL param is empty'
|
30
|
+
it 'when the URL is referencing a non-permitted host'
|
31
|
+
it 'when the URL refers to a non-HTTP(S) scheme'
|
32
|
+
end
|
33
|
+
|
34
|
+
describe 'fails with an invalid signature' do
|
35
|
+
it 'when the sig param is missing'
|
36
|
+
it 'when the sig param is empty'
|
37
|
+
it 'when the sig is invalid' do
|
38
|
+
img_params = {src_url: 'http://bucket.s3.aws.com/image.jpg',
|
39
|
+
pipeline: [[:crop, {width: 10, height: 10, gravity: 's'}]]}
|
40
|
+
img_params_json = JSON.dump(img_params)
|
41
|
+
signature = OpenSSL::HMAC.hexdigest(OpenSSL::Digest::SHA256.new, 'a', img_params_json)
|
42
|
+
params = {
|
43
|
+
q: Base64.encode64(img_params_json),
|
44
|
+
sig: signature
|
45
|
+
}
|
46
|
+
|
47
|
+
expect {
|
48
|
+
described_class.to_request(qs_params: params,
|
49
|
+
secrets: ['b'], permitted_source_hosts: ['bucket.s3.aws.com'])
|
50
|
+
}.to raise_error(/Invalid or missing signature/)
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
@@ -0,0 +1,70 @@
|
|
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
|
+
sharpen(radius: 2, sigma: 0.5).
|
41
|
+
ellipse_stencil
|
42
|
+
|
43
|
+
image = Magick::Image.read(test_image_path)[0]
|
44
|
+
pipeline.apply! image
|
45
|
+
examine_image(image, "stenciled")
|
46
|
+
end
|
47
|
+
|
48
|
+
it 'raises an exception when an attempt is made to serialize an unknown operator' do
|
49
|
+
unknown_op_class = Class.new
|
50
|
+
subject << unknown_op_class.new
|
51
|
+
expect {
|
52
|
+
subject.to_params
|
53
|
+
}.to raise_error(/not registered/)
|
54
|
+
end
|
55
|
+
|
56
|
+
it 'composes parameters even if one of the operators does not support to_h' do
|
57
|
+
class AnonOp
|
58
|
+
end
|
59
|
+
class ParametricOp
|
60
|
+
def to_h; {a: 133}; end
|
61
|
+
end
|
62
|
+
|
63
|
+
ImageVise.add_operator('t_anon', AnonOp)
|
64
|
+
ImageVise.add_operator('t_parametric', ParametricOp)
|
65
|
+
|
66
|
+
subject << AnonOp.new
|
67
|
+
subject << ParametricOp.new
|
68
|
+
expect(subject.to_params).to eq([["t_anon", {}], ["t_parametric", {:a=>133}]])
|
69
|
+
end
|
70
|
+
end
|