image_vise 0.2.1 → 0.2.2
Sign up to get free protection for your applications and to get access to all the features.
- 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,298 @@
|
|
1
|
+
class ImageVise::RenderEngine
|
2
|
+
class UnsupportedInputFormat < StandardError; end
|
3
|
+
class EmptyRender < StandardError; end
|
4
|
+
|
5
|
+
DEFAULT_HEADERS = {
|
6
|
+
'Allow' => "GET"
|
7
|
+
}.freeze
|
8
|
+
|
9
|
+
# Headers for error responses that denote an invalid or
|
10
|
+
# an unsatisfiable request
|
11
|
+
JSON_ERROR_HEADERS_REQUEST = DEFAULT_HEADERS.merge({
|
12
|
+
'Content-Type' => 'application/json',
|
13
|
+
'Cache-Control' => 'public, max-age=600'
|
14
|
+
}).freeze
|
15
|
+
|
16
|
+
# Headers for error responses that denote
|
17
|
+
# an intermittent error (that permit retries)
|
18
|
+
JSON_ERROR_HEADERS_INTERMITTENT = DEFAULT_HEADERS.merge({
|
19
|
+
'Content-Type' => 'application/json',
|
20
|
+
'Cache-Control' => 'public, max-age=5'
|
21
|
+
}).freeze
|
22
|
+
|
23
|
+
# "public" of course. Add max-age so that there is _some_
|
24
|
+
# revalidation after a time (otherwise some proxies treat it
|
25
|
+
# as "must-revalidate" always), and "no-transform" so that
|
26
|
+
# various deflate schemes are not applied to it (does happen
|
27
|
+
# with Rack::Cache and leads Chrome to throw up on content
|
28
|
+
# decoding for example).
|
29
|
+
IMAGE_CACHE_CONTROL = 'public, no-transform, max-age=2592000'
|
30
|
+
|
31
|
+
# How long is a render (the ImageMagick/write part) is allowed to
|
32
|
+
# take before we kill it
|
33
|
+
RENDER_TIMEOUT_SECONDS = 10
|
34
|
+
|
35
|
+
# Which input files we permit (based on extensions stored in MagicBytes)
|
36
|
+
PERMITTED_SOURCE_FILE_EXTENSIONS = %w( gif png jpg psd tif)
|
37
|
+
|
38
|
+
# How long should we wait when fetching the image from the external host
|
39
|
+
EXTERNAL_IMAGE_FETCH_TIMEOUT_SECONDS = 4
|
40
|
+
|
41
|
+
def bail(status, *errors_array)
|
42
|
+
headers = if (300...500).cover?(status)
|
43
|
+
JSON_ERROR_HEADERS_REQUEST.dup
|
44
|
+
else
|
45
|
+
JSON_ERROR_HEADERS_INTERMITTENT.dup
|
46
|
+
end
|
47
|
+
response = [status.to_i, headers, [JSON.pretty_generate({errors: errors_array})]]
|
48
|
+
throw :__bail, response
|
49
|
+
end
|
50
|
+
|
51
|
+
# The main entry point for the Rack app. Wraps a call to {#handle_request} in a `catch{}` block
|
52
|
+
# so that any method can abort the request by calling {#bail}
|
53
|
+
#
|
54
|
+
# @param env[Hash] the Rack env
|
55
|
+
# @return [Array] the Rack response
|
56
|
+
def call(env)
|
57
|
+
catch(:__bail) { handle_request(env) }
|
58
|
+
end
|
59
|
+
|
60
|
+
# Hadles the Rack request. If one of the steps calls {#bail} the `:__bail` symbol will be
|
61
|
+
# thrown and the execution will abort. Any errors will cause either an error response in
|
62
|
+
# JSON format or an Exception will be raised (depending on the return value of `raise_exceptions?`)
|
63
|
+
#
|
64
|
+
# @param env[Hash] the Rack env
|
65
|
+
# @return [Array] the Rack response
|
66
|
+
def handle_request(env)
|
67
|
+
setup_error_handling(env)
|
68
|
+
|
69
|
+
# Assume that if _any_ ETag is given the image is being requested anew as a refetch,
|
70
|
+
# and the client already has it. Just respond with a 304.
|
71
|
+
return [304, DEFAULT_HEADERS.dup, []] if env['HTTP_IF_NONE_MATCH']
|
72
|
+
|
73
|
+
req = parse_env_into_request(env)
|
74
|
+
bail(405, 'Only GET supported') unless req.get?
|
75
|
+
params = extract_params_from_request(req)
|
76
|
+
|
77
|
+
image_request = ImageVise::ImageRequest.from_params(qs_params: params, secrets: ImageVise.secret_keys)
|
78
|
+
render_destination_file, render_file_type, etag = process_image_request(image_request)
|
79
|
+
image_rack_response(render_destination_file, render_file_type, etag)
|
80
|
+
rescue *permanent_failures => e
|
81
|
+
handle_request_error(e)
|
82
|
+
http_status_code = e.respond_to?(:http_status) ? e.http_status : 400
|
83
|
+
raise_exception_or_error_response(e, http_status_code)
|
84
|
+
rescue Exception => e
|
85
|
+
if http_status_code = (e.respond_to?(:http_status) && e.http_status)
|
86
|
+
handle_request_error(e)
|
87
|
+
raise_exception_or_error_response(e, http_status_code)
|
88
|
+
else
|
89
|
+
handle_generic_error(e)
|
90
|
+
raise_exception_or_error_response(e, 500)
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
# Parses the Rack environment into a Rack::Reqest. The following methods
|
95
|
+
# are going to be called on it: `#get?` and `#params`. You can use this
|
96
|
+
# method to override path-to-parameter translation for example.
|
97
|
+
#
|
98
|
+
# @param rack_env[Hash] the Rack environment
|
99
|
+
# @return [#get?, #params] the Rack request or a compatible object
|
100
|
+
def parse_env_into_request(rack_env)
|
101
|
+
Rack::Request.new(rack_env)
|
102
|
+
end
|
103
|
+
|
104
|
+
# Extracts the image params from the Rack::Request
|
105
|
+
#
|
106
|
+
# @param rack_request[#path_info] an object that has a path info
|
107
|
+
# @return [Hash] the params hash with `:q` and `:sig` keys
|
108
|
+
def extract_params_from_request(rack_request)
|
109
|
+
# Prevent cache bypass DOS attacks by only permitting :sig and :q
|
110
|
+
bail(400, 'Query strings are not supported') if rack_request.params.any?
|
111
|
+
|
112
|
+
# Extract the tail (signature) and the front (the Base64-encoded request).
|
113
|
+
# Slashes within :q are masked by ImageRequest already, so we don't have
|
114
|
+
# to worry about them.
|
115
|
+
*, q_from_path, sig_from_path = rack_request.path_info.split('/')
|
116
|
+
|
117
|
+
# Raise if any of them are empty or blank
|
118
|
+
nothing_recovered = [q_from_path, sig_from_path].all?{|v| v.nil? || v.empty? }
|
119
|
+
bail(400, 'Need 2 usable path components') if nothing_recovered
|
120
|
+
|
121
|
+
{q: q_from_path, sig: sig_from_path}
|
122
|
+
end
|
123
|
+
|
124
|
+
# Processes the ImageRequest object created from the request parameters,
|
125
|
+
# and returns a triplet of the File object containing the rendered image,
|
126
|
+
# the MagicBytes::FileType object of the render, and the cache ETag value
|
127
|
+
# representing the processing pipeline
|
128
|
+
#
|
129
|
+
# @param image_request[ImageVise::ImageRequest] the request for the image
|
130
|
+
# @return [Array<File, MagicBytes::FileType, String]
|
131
|
+
def process_image_request(image_request)
|
132
|
+
# Recover the source image URL and the pipeline instructions (all the image ops)
|
133
|
+
source_image_uri, pipeline = image_request.src_url, image_request.pipeline
|
134
|
+
raise 'Image pipeline has no operators' if pipeline.empty?
|
135
|
+
|
136
|
+
# Compute an ETag which describes this image transform + image source location.
|
137
|
+
# Assume the image URL contents does _never_ change.
|
138
|
+
etag = image_request.cache_etag
|
139
|
+
|
140
|
+
# Download/copy the original into a Tempfile
|
141
|
+
fetcher = ImageVise.fetcher_for(source_image_uri.scheme)
|
142
|
+
source_file = fetcher.fetch_uri_to_tempfile(source_image_uri)
|
143
|
+
|
144
|
+
# Make sure we do not try to process something...questionable
|
145
|
+
source_file_type = detect_file_type(source_file)
|
146
|
+
unless source_file_type_permitted?(source_file_type)
|
147
|
+
raise UnsupportedInputFormat.new("Unsupported/unknown input file format .%s" % source_file_type.ext)
|
148
|
+
end
|
149
|
+
|
150
|
+
render_destination_file = Tempfile.new('imagevise-render').tap{|f| f.binmode }
|
151
|
+
|
152
|
+
# Do the actual imaging stuff
|
153
|
+
apply_pipeline(source_file.path, pipeline, source_file_type, render_destination_file.path)
|
154
|
+
|
155
|
+
# Catch this one early
|
156
|
+
render_destination_file.rewind
|
157
|
+
raise EmptyRender, "The rendered image was empty" if render_destination_file.size.zero?
|
158
|
+
|
159
|
+
render_file_type = detect_file_type(render_destination_file)
|
160
|
+
[render_destination_file, render_file_type, etag]
|
161
|
+
ensure
|
162
|
+
ImageVise.close_and_unlink(source_file)
|
163
|
+
end
|
164
|
+
|
165
|
+
# Returns a Rack response triplet. Accepts the return value of
|
166
|
+
# `process_image_request` unsplatted, and returns a triplet that
|
167
|
+
# can be returned as a Rack response. The Rack response will contain
|
168
|
+
# an iterable body object that is designed to automatically delete
|
169
|
+
# the Tempfile it wraps on close.
|
170
|
+
#
|
171
|
+
# @param render_destination_file[File] the File handle to the rendered image
|
172
|
+
# @param render_file_type[MagicBytes::FileType] the rendered file type
|
173
|
+
# @param etag[String] the ETag for the response
|
174
|
+
def image_rack_response(render_destination_file, render_file_type, etag)
|
175
|
+
response_headers = DEFAULT_HEADERS.merge({
|
176
|
+
'Content-Type' => render_file_type.mime,
|
177
|
+
'Content-Length' => '%d' % render_destination_file.size,
|
178
|
+
'Cache-Control' => IMAGE_CACHE_CONTROL,
|
179
|
+
'ETag' => etag
|
180
|
+
})
|
181
|
+
|
182
|
+
# Wrap the body Tempfile with a self-closing response.
|
183
|
+
# Once the response is read in full, the tempfile is going to be closed and unlinked.
|
184
|
+
[200, response_headers, ImageVise::FileResponse.new(render_destination_file)]
|
185
|
+
end
|
186
|
+
|
187
|
+
# Depending on `raise_exceptions?` will either raise the passed Exception,
|
188
|
+
# or force the application to return the error in the Rack response.
|
189
|
+
#
|
190
|
+
# @param exception[Exception] the error that has to be captured
|
191
|
+
# @param status_code[Fixnum] the HTTP status code
|
192
|
+
def raise_exception_or_error_response(exception, status_code)
|
193
|
+
if raise_exceptions?
|
194
|
+
raise exception
|
195
|
+
else
|
196
|
+
bail status_code, exception.message
|
197
|
+
end
|
198
|
+
end
|
199
|
+
|
200
|
+
# Detects the file type of the given File and returns
|
201
|
+
# a MagicBytes::FileType object that contains the extension and
|
202
|
+
# the MIME type.
|
203
|
+
#
|
204
|
+
# @param tempfile[File] the file to perform detection on
|
205
|
+
# @return [MagicBytes::FileType] the detected file type
|
206
|
+
def detect_file_type(tempfile)
|
207
|
+
tempfile.rewind
|
208
|
+
MagicBytes.read_and_detect(tempfile).tap { tempfile.rewind }
|
209
|
+
end
|
210
|
+
|
211
|
+
# Tells whether the given file type may be loaded into the image processor.
|
212
|
+
#
|
213
|
+
# @param magic_bytes_file_info[MagicBytes::FileType] the filetype
|
214
|
+
# @return [Boolean]
|
215
|
+
def source_file_type_permitted?(magic_bytes_file_info)
|
216
|
+
PERMITTED_SOURCE_FILE_EXTENSIONS.include?(magic_bytes_file_info.ext)
|
217
|
+
end
|
218
|
+
|
219
|
+
# Lists exceptions that should lead to the request being flagged
|
220
|
+
# as invalid (4xx as opposed to 5xx for a generic server error).
|
221
|
+
# Decent clients should _not_ retry those requests.
|
222
|
+
def permanent_failures
|
223
|
+
[
|
224
|
+
Magick::ImageMagickError,
|
225
|
+
UnsupportedInputFormat,
|
226
|
+
ImageVise::ImageRequest::InvalidRequest
|
227
|
+
]
|
228
|
+
end
|
229
|
+
|
230
|
+
# Is meant to be overridden by subclasses,
|
231
|
+
# will be called at the start of each request to set up the error handling
|
232
|
+
# library (Appsignal, Honeybadger, Sentry...)
|
233
|
+
#
|
234
|
+
# @param rack_env[Hash] the Rack env
|
235
|
+
# @return [void]
|
236
|
+
def setup_error_handling(rack_env)
|
237
|
+
end
|
238
|
+
|
239
|
+
# Is meant to be overridden by subclasses,
|
240
|
+
# will be called when a request fails due to a malformed query string,
|
241
|
+
# unrecognized signature or other client-induced problems. The method
|
242
|
+
# should _not_ re-raise the exception.
|
243
|
+
#
|
244
|
+
# @param exception[Exception] the exception to be handled
|
245
|
+
# @return [void]
|
246
|
+
def handle_request_error(exception)
|
247
|
+
end
|
248
|
+
|
249
|
+
# Is meant to be overridden by subclasses,
|
250
|
+
# will be called when a request fails due to an error on the server
|
251
|
+
# (like an unexpected error in an image operator). The method
|
252
|
+
# should _not_ re-raise the exception.
|
253
|
+
#
|
254
|
+
# @param exception[Exception] the exception to be handled
|
255
|
+
# @return [void]
|
256
|
+
def handle_generic_error(exception)
|
257
|
+
end
|
258
|
+
|
259
|
+
# Tells whether the engine must raise the exceptions further up the Rack stack,
|
260
|
+
# or they should be suppressed and a JSON response must be returned.
|
261
|
+
#
|
262
|
+
# @return [Boolean]
|
263
|
+
def raise_exceptions?
|
264
|
+
false
|
265
|
+
end
|
266
|
+
|
267
|
+
# Applies the given {ImageVise::Pipeline} to the image, and writes the render to
|
268
|
+
# the given path.
|
269
|
+
#
|
270
|
+
# @param source_file_path[String] the path to the file containing the source image
|
271
|
+
# @param pipeline[#apply!(Magick::Image)] the processing pipeline
|
272
|
+
# @param render_to_path[String] the path to write the rendered image to
|
273
|
+
# @return [void]
|
274
|
+
def apply_pipeline(source_file_path, pipeline, source_file_type, render_to_path)
|
275
|
+
render_file_type = source_file_type
|
276
|
+
|
277
|
+
# Load the first frame of the animated GIF _or_ the blended compatibility layer from Photoshop
|
278
|
+
image_list = Magick::Image.read(source_file_path)
|
279
|
+
magick_image = image_list.first # Picks up the "precomp" PSD layer in compatibility mode, or the first frame of a GIF
|
280
|
+
|
281
|
+
# If any operators want to stash some data for downstream use we use this Hash
|
282
|
+
metadata = {}
|
283
|
+
|
284
|
+
# Apply the pipeline (all the image operators)
|
285
|
+
pipeline.apply!(magick_image, metadata)
|
286
|
+
|
287
|
+
# Write out the file honoring the possible injected metadata. One of the metadata
|
288
|
+
# elements (that an operator might want to alter) is the :writer, we forcibly #fetch
|
289
|
+
# it so that we get a KeyError if some operator has deleted it without providing a replacement.
|
290
|
+
# If no operators touched the writer we are going to use the automatic format selection
|
291
|
+
writer = metadata.fetch(:writer, ImageVise::AutoWriter.new)
|
292
|
+
writer.write_image!(magick_image, metadata, render_to_path)
|
293
|
+
ensure
|
294
|
+
# destroy all the loaded images explicitly
|
295
|
+
(image_list || []).map {|img| ImageVise.destroy(img) }
|
296
|
+
end
|
297
|
+
|
298
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
# Picks the most reasonable "default" output format for web resources. In practice, if the
|
2
|
+
# image contains transparency (an alpha channel) PNG will be chosen, and if not - JPEG will
|
3
|
+
# be chosen. Since ImageVise URLs do not contain a file extension we are free to pick
|
4
|
+
# the suitable format at render time
|
5
|
+
class ImageVise::AutoWriter
|
6
|
+
# The default file type for images with alpha
|
7
|
+
PNG_FILE_TYPE = MagicBytes::FileType.new('png','image/png').freeze
|
8
|
+
|
9
|
+
# Renders the file as a jpg if the custom output filetype operator is used
|
10
|
+
JPG_FILE_TYPE = MagicBytes::FileType.new('jpg','image/jpeg').freeze
|
11
|
+
|
12
|
+
def write_image!(magick_image, _, render_to_path)
|
13
|
+
# If processing the image has created an alpha channel, use PNG always.
|
14
|
+
# Otherwise, keep the original format for as far as the supported formats list goes.
|
15
|
+
render_file_type = if magick_image.alpha?
|
16
|
+
PNG_FILE_TYPE
|
17
|
+
else
|
18
|
+
JPG_FILE_TYPE
|
19
|
+
end
|
20
|
+
magick_image.format = render_file_type.ext
|
21
|
+
magick_image.write(render_to_path)
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,9 @@
|
|
1
|
+
class ImageVise::JPGWriter < Ks.strict(:quality)
|
2
|
+
JPG_FILE_TYPE = MagicBytes::FileType.new('jpg','image/jpeg').freeze
|
3
|
+
|
4
|
+
def write_image!(magick_image, _, render_to_path)
|
5
|
+
q = self.quality # to avoid the changing "self" context
|
6
|
+
magick_image.format = JPG_FILE_TYPE.ext
|
7
|
+
magick_image.write(render_to_path) { self.quality = q }
|
8
|
+
end
|
9
|
+
end
|
data/lib/image_vise.rb
ADDED
@@ -0,0 +1,177 @@
|
|
1
|
+
require 'ks'
|
2
|
+
require 'json'
|
3
|
+
require 'patron'
|
4
|
+
require 'rmagick'
|
5
|
+
require 'magic_bytes'
|
6
|
+
require 'thread'
|
7
|
+
require 'base64'
|
8
|
+
require 'rack'
|
9
|
+
|
10
|
+
class ImageVise
|
11
|
+
require_relative 'image_vise/version'
|
12
|
+
S_MUTEX = Mutex.new
|
13
|
+
private_constant :S_MUTEX
|
14
|
+
|
15
|
+
@allowed_hosts = Set.new
|
16
|
+
@keys = Set.new
|
17
|
+
@operators = {}
|
18
|
+
@allowed_glob_patterns = Set.new
|
19
|
+
@fetchers = {}
|
20
|
+
|
21
|
+
class << self
|
22
|
+
# Resets all allowed hosts
|
23
|
+
def reset_allowed_hosts!
|
24
|
+
S_MUTEX.synchronize { @allowed_hosts.clear }
|
25
|
+
end
|
26
|
+
|
27
|
+
# Add an allowed host
|
28
|
+
def add_allowed_host!(hostname)
|
29
|
+
S_MUTEX.synchronize { @allowed_hosts << hostname }
|
30
|
+
end
|
31
|
+
|
32
|
+
# Returns both the allowed hosts added at runtime and the ones set in the constant
|
33
|
+
def allowed_hosts
|
34
|
+
S_MUTEX.synchronize { @allowed_hosts.to_a }
|
35
|
+
end
|
36
|
+
|
37
|
+
# Removes all set keys
|
38
|
+
def reset_secret_keys!
|
39
|
+
S_MUTEX.synchronize { @keys.clear }
|
40
|
+
end
|
41
|
+
|
42
|
+
def allow_filesystem_source!(glob_pattern)
|
43
|
+
S_MUTEX.synchronize { @allowed_glob_patterns << glob_pattern }
|
44
|
+
end
|
45
|
+
|
46
|
+
def allowed_filesystem_sources
|
47
|
+
S_MUTEX.synchronize { @allowed_glob_patterns.to_a }
|
48
|
+
end
|
49
|
+
|
50
|
+
def deny_filesystem_sources!
|
51
|
+
S_MUTEX.synchronize { @allowed_glob_patterns.clear }
|
52
|
+
end
|
53
|
+
|
54
|
+
# Adds a key against which the parameters are going to be verified.
|
55
|
+
# Multiple applications may have their own different keys,
|
56
|
+
# so we need to have multiple keys.
|
57
|
+
def add_secret_key!(key)
|
58
|
+
S_MUTEX.synchronize { @keys << key }
|
59
|
+
self
|
60
|
+
end
|
61
|
+
|
62
|
+
# Returns the array of defined keys or raises an exception if no keys have been set yet
|
63
|
+
def secret_keys
|
64
|
+
keys = S_MUTEX.synchronize { @keys.any? && @keys.to_a }
|
65
|
+
keys or raise "No keys set, add a key using `ImageVise.add_secret_key!(key)'"
|
66
|
+
end
|
67
|
+
|
68
|
+
# Generate a set of querystring params for a resized image. Yields a Pipeline object that
|
69
|
+
# will receive method calls for adding image operations to a stack.
|
70
|
+
#
|
71
|
+
# ImageVise.image_params(src_url: image_url_on_s3, secret: '...') do |p|
|
72
|
+
# p.center_fit width: 128, height: 128
|
73
|
+
# p.elliptic_stencil
|
74
|
+
# end #=> {q: '...', sig: '...'}
|
75
|
+
#
|
76
|
+
# The query string elements can be then passed on to RenderEngine for validation and execution.
|
77
|
+
#
|
78
|
+
# @yield {ImageVise::Pipeline}
|
79
|
+
# @return [Hash]
|
80
|
+
def image_params(src_url:, secret:)
|
81
|
+
p = Pipeline.new
|
82
|
+
yield(p)
|
83
|
+
raise ArgumentError, "Image pipeline has no steps defined" if p.empty?
|
84
|
+
ImageRequest.new(src_url: URI(src_url), pipeline: p).to_query_string_params(secret)
|
85
|
+
end
|
86
|
+
|
87
|
+
# Generate a path for a resized image. Yields a Pipeline object that
|
88
|
+
# will receive method calls for adding image operations to a stack.
|
89
|
+
#
|
90
|
+
# ImageVise.image_path(src_url: image_url_on_s3, secret: '...') do |p|
|
91
|
+
# p.center_fit width: 128, height: 128
|
92
|
+
# p.elliptic_stencil
|
93
|
+
# end #=> "/abcdef/xyz123"
|
94
|
+
#
|
95
|
+
# The query string elements can be then passed on to RenderEngine for validation and execution.
|
96
|
+
#
|
97
|
+
# @yield {ImageVise::Pipeline}
|
98
|
+
# @return [String]
|
99
|
+
def image_path(src_url:, secret:)
|
100
|
+
p = Pipeline.new
|
101
|
+
yield(p)
|
102
|
+
raise ArgumentError, "Image pipeline has no steps defined" if p.empty?
|
103
|
+
ImageRequest.new(src_url: URI(src_url), pipeline: p).to_path_params(secret)
|
104
|
+
end
|
105
|
+
|
106
|
+
# Adds an operator
|
107
|
+
def add_operator(operator_name, object_responding_to_new)
|
108
|
+
@operators[operator_name.to_s] = object_responding_to_new
|
109
|
+
end
|
110
|
+
|
111
|
+
# Gets an operator by name
|
112
|
+
def operator_from(operator_name)
|
113
|
+
@operators.fetch(operator_name.to_s)
|
114
|
+
end
|
115
|
+
|
116
|
+
def defined_operator_names
|
117
|
+
@operators.keys
|
118
|
+
end
|
119
|
+
|
120
|
+
def register_fetcher(scheme, fetcher)
|
121
|
+
S_MUTEX.synchronize { @fetchers[scheme.to_s] = fetcher }
|
122
|
+
end
|
123
|
+
|
124
|
+
def fetcher_for(scheme)
|
125
|
+
S_MUTEX.synchronize { @fetchers[scheme.to_s] or raise "No fetcher registered for #{scheme}" }
|
126
|
+
end
|
127
|
+
|
128
|
+
def operator_name_for(operator)
|
129
|
+
S_MUTEX.synchronize do
|
130
|
+
@operators.key(operator.class) or raise "Operator #{operator.inspect} not registered using ImageVise.add_operator"
|
131
|
+
end
|
132
|
+
end
|
133
|
+
end
|
134
|
+
|
135
|
+
# Made available since the object that is used with `mount()` in Rails
|
136
|
+
# has to, by itself, to respond to `call`.
|
137
|
+
#
|
138
|
+
# Thanks to this method you can do this:
|
139
|
+
#
|
140
|
+
# mount ImageVise => '/thumbnails'
|
141
|
+
#
|
142
|
+
# instead of having to do
|
143
|
+
#
|
144
|
+
# mount ImageVise.new => '/thumbnails'
|
145
|
+
#
|
146
|
+
def self.call(rack_env)
|
147
|
+
ImageVise::RenderEngine.new.call(rack_env)
|
148
|
+
end
|
149
|
+
|
150
|
+
def call(rack_env)
|
151
|
+
ImageVise::RenderEngine.new.call(rack_env)
|
152
|
+
end
|
153
|
+
|
154
|
+
# Used as a shorthand to force-destroy Magick images in ensure() blocks. Since
|
155
|
+
# ensure blocks sometimes deal with variables in inconsistent states (variable
|
156
|
+
# in scope but not yet set to an image) we take the possibility of nils into account.
|
157
|
+
# We also deal with Magick::Image objects that already have been destroyed in a clean manner.
|
158
|
+
def self.destroy(maybe_image)
|
159
|
+
return unless maybe_image
|
160
|
+
return unless maybe_image.respond_to?(:destroy!)
|
161
|
+
return if maybe_image.destroyed?
|
162
|
+
maybe_image.destroy!
|
163
|
+
end
|
164
|
+
|
165
|
+
# Used as a shorthand to force-dealloc Tempfiles in an ensure() blocks. Since
|
166
|
+
# ensure blocks sometimes deal with variables in inconsistent states (variable
|
167
|
+
# in scope but not yet set to an image) we take the possibility of nils into account.
|
168
|
+
def self.close_and_unlink(maybe_tempfile)
|
169
|
+
return unless maybe_tempfile
|
170
|
+
maybe_tempfile.close unless maybe_tempfile.closed?
|
171
|
+
maybe_tempfile.unlink
|
172
|
+
end
|
173
|
+
end
|
174
|
+
|
175
|
+
Dir.glob(__dir__ + '/**/*.rb').sort.each do |f|
|
176
|
+
require f unless f == File.expand_path(__FILE__)
|
177
|
+
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,39 @@
|
|
1
|
+
require_relative '../spec_helper'
|
2
|
+
|
3
|
+
describe ImageVise::BackgroundFill do
|
4
|
+
|
5
|
+
it 'refuses empty parameters' do
|
6
|
+
expect { described_class.new(color:"") }.to raise_error(ArgumentError)
|
7
|
+
end
|
8
|
+
|
9
|
+
it 'successfully exports a png with a fill' do
|
10
|
+
image = Magick::Image.read(test_image_png_transparency)[0]
|
11
|
+
expect(image).to be_alpha
|
12
|
+
background_fill = described_class.new(color: 'white')
|
13
|
+
background_fill.apply!(image)
|
14
|
+
|
15
|
+
expect(image).to_not be_alpha
|
16
|
+
examine_image(image, "set-color-to-white")
|
17
|
+
end
|
18
|
+
|
19
|
+
it 'can be passed CSS word colors and process them consistently' do
|
20
|
+
image = Magick::Image.read(test_image_png_transparency)[0]
|
21
|
+
|
22
|
+
background_fill = described_class.new(color: 'green')
|
23
|
+
background_fill.apply!(image)
|
24
|
+
hex_color = image.pixel_color(720,248).to_color(Magick::AllCompliance,matte=false, 8, hex=true)
|
25
|
+
|
26
|
+
expect(hex_color).to eq("#008000")
|
27
|
+
examine_image(image, "set-color-to-green")
|
28
|
+
end
|
29
|
+
|
30
|
+
it 'can be passed hex colors and process them consistently' do
|
31
|
+
image = Magick::Image.read(test_image_png_transparency)[0]
|
32
|
+
background_fill = described_class.new(color: '#ffebcd')
|
33
|
+
background_fill.apply!(image)
|
34
|
+
hex_color = image.pixel_color(720,248).to_color(Magick::AllCompliance,matte=false, 8, hex=true)
|
35
|
+
|
36
|
+
expect(hex_color).to eq("#FFEBCD")
|
37
|
+
examine_image(image, "set-color-to-blanched-almond")
|
38
|
+
end
|
39
|
+
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,18 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe ImageVise::EllipseStencil do
|
4
|
+
it 'applies the circle stencil' do
|
5
|
+
image = Magick::Image.read(test_image_path)[0]
|
6
|
+
stencil = described_class.new
|
7
|
+
stencil.apply!(image)
|
8
|
+
examine_image(image, "circle-stencil")
|
9
|
+
end
|
10
|
+
|
11
|
+
it 'applies the circle stencil to a png with transparency' do
|
12
|
+
png_transparent_path = File.expand_path(__dir__ + '/../waterside_magic_hour_transp.png')
|
13
|
+
image = Magick::Image.read(png_transparent_path)[0]
|
14
|
+
stencil = described_class.new
|
15
|
+
stencil.apply!(image)
|
16
|
+
examine_image(image, "circle-stencil-transparent-bg")
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
require_relative '../spec_helper'
|
2
|
+
|
3
|
+
describe ImageVise::FetcherFile do
|
4
|
+
it 'is a class (can be inherited from)' do
|
5
|
+
expect(ImageVise::FetcherFile).to be_kind_of(Class)
|
6
|
+
end
|
7
|
+
|
8
|
+
it 'is registered as a fetcher for file://' do
|
9
|
+
expect(ImageVise.fetcher_for('file')).to eq(ImageVise::FetcherFile)
|
10
|
+
end
|
11
|
+
|
12
|
+
it 'returns a Tempfile containing this test suite' do
|
13
|
+
path = File.expand_path(__FILE__)
|
14
|
+
ruby_files_in_this_directory = __dir__ + '/*.rb'
|
15
|
+
ImageVise.allow_filesystem_source! ruby_files_in_this_directory
|
16
|
+
|
17
|
+
uri = URI('file://' + URI.encode(path))
|
18
|
+
fetched = ImageVise::FetcherFile.fetch_uri_to_tempfile(uri)
|
19
|
+
|
20
|
+
expect(fetched).to be_kind_of(Tempfile)
|
21
|
+
expect(fetched.size).to eq(File.size(__FILE__))
|
22
|
+
expect(fetched.pos).to be_zero
|
23
|
+
end
|
24
|
+
|
25
|
+
it 'raises a meaningful exception if no file sources are permitted' do
|
26
|
+
path = File.expand_path(__FILE__)
|
27
|
+
|
28
|
+
ImageVise.deny_filesystem_sources!
|
29
|
+
|
30
|
+
uri = URI('file://' + URI.encode(path))
|
31
|
+
expect {
|
32
|
+
ImageVise::FetcherFile.fetch_uri_to_tempfile(uri)
|
33
|
+
}.to raise_error(ImageVise::FetcherFile::AccessError)
|
34
|
+
end
|
35
|
+
|
36
|
+
it 'raises a meaningful exception if this file is not permitted as source' do
|
37
|
+
path = File.expand_path(__FILE__)
|
38
|
+
|
39
|
+
text_files_in_this_directory = __dir__ + '/*.txt'
|
40
|
+
ImageVise.deny_filesystem_sources!
|
41
|
+
ImageVise.allow_filesystem_source! text_files_in_this_directory
|
42
|
+
|
43
|
+
uri = URI('file://' + URI.encode(path))
|
44
|
+
expect {
|
45
|
+
ImageVise::FetcherFile.fetch_uri_to_tempfile(uri)
|
46
|
+
}.to raise_error(ImageVise::FetcherFile::AccessError)
|
47
|
+
end
|
48
|
+
end
|