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,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
|