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
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: cdf5ad5923491f14b204be61539b2e45869d4003
|
4
|
+
data.tar.gz: 4aeebb9df5fa9f232c8c6e7bbc305b3bbc49ab1b
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 2f906ce580e67875972c699045df0bc1ed3826a078db16bcbc126a661b191091562644b07bcd7ec555fc05435b1d6f1570eca816850284656aae09fc4aa55206
|
7
|
+
data.tar.gz: ba77348924856dc11a5230cd8f430b06c93236337006c5c202eccf456a5dc8ed7e05a59b4d95c53a17a28317b267d45513f00e25748ff06ccfe31f211ad47d85
|
data/.gitignore
ADDED
data/.travis.yml
ADDED
data/DEVELOPMENT.md
ADDED
@@ -0,0 +1,111 @@
|
|
1
|
+
# NOTE: Until 1.0.0 ImageVise API is somewhat in flux
|
2
|
+
|
3
|
+
## Defining image operators
|
4
|
+
|
5
|
+
To add an image operator, define a class roughly like so, and add it to the list of operators.
|
6
|
+
|
7
|
+
class MyBlur
|
8
|
+
# The constructor must accept keyword arguments if your operator accepts them
|
9
|
+
def initialize(radius:, sigma:)
|
10
|
+
@radius = radius.to_f
|
11
|
+
@sigma = sigma.to_f
|
12
|
+
end
|
13
|
+
|
14
|
+
# Needed for the operator to be serialized to parameters
|
15
|
+
def to_h
|
16
|
+
{radius: @radius, sigma: @sigma}
|
17
|
+
end
|
18
|
+
|
19
|
+
def apply!(magick_image) # The argument is a Magick::Image
|
20
|
+
# Apply the method to the function argument
|
21
|
+
blurred_image = magick_image.blur_image(@radius, @sigma)
|
22
|
+
|
23
|
+
# Some methods (without !) return a new Magick::Image.
|
24
|
+
# That image has to be composited back into the original.
|
25
|
+
magick_image.composite!(blurred_image, Magick::CenterGravity, Magick::CopyCompositeOp)
|
26
|
+
ensure
|
27
|
+
# Make sure to explicitly destroy() all the intermediate images
|
28
|
+
ImageVise.destroy(blurred_image)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
# This will also make `ImageVise::Pipeline` respond to `blur(radius:.., sigma:..)`
|
33
|
+
ImageVise.add_operator 'my_blur', MyBlur
|
34
|
+
|
35
|
+
## Defining fetchers
|
36
|
+
|
37
|
+
Fetchers are based on the scheme of the image URL. Default fetchers are already defined for `http`, `https`
|
38
|
+
and `file` schemes. If you need to grab data from a database, for instance, you can define a fetcher and register
|
39
|
+
it:
|
40
|
+
|
41
|
+
module DatabaseFetcher
|
42
|
+
def self.fetch_uri_to_tempfile(uri_object)
|
43
|
+
tf = Tempfile.new 'data-fetch'
|
44
|
+
|
45
|
+
object_id = uri_object.path[/\d+/]
|
46
|
+
data_model = ProfilePictures.find(object_id)
|
47
|
+
tf << data_model.body
|
48
|
+
|
49
|
+
tf.rewind; tf
|
50
|
+
rescue Exception => e
|
51
|
+
ImageVise.close_and_unlink(tf) # do not litter Tempfiles
|
52
|
+
raise e
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
ImageVise.register_fetcher 'profilepictures', self
|
57
|
+
|
58
|
+
Once done, you can use URLs like `profilepictures:/5674`. A very simple Fetcher would just
|
59
|
+
override the standard filesystem one (be mindful that the filesystem fetcher still checks
|
60
|
+
path access using the glob whitelist)
|
61
|
+
|
62
|
+
class PicFetcher < ImageVise::FetcherFile
|
63
|
+
def self.fetch_uri_to_tempfile(uri_object)
|
64
|
+
# Convert an internal "pic://sites/uploads/abcdef.jpg" to a full path URL
|
65
|
+
partial_path = URI.decode(uri_object.path)
|
66
|
+
full_path = File.join(Mappe::ROOT, 'sites', partial_path)
|
67
|
+
full_path_uri = URI('file://' + URI.encode(full_path))
|
68
|
+
super(full_path_uri)
|
69
|
+
end
|
70
|
+
ImageVise.register_fetcher 'pic', self
|
71
|
+
end
|
72
|
+
|
73
|
+
## Overriding the render engine
|
74
|
+
|
75
|
+
By default, `ImageVise.call` delegates to `ImageVise::RenderEngine.new.call`. You can mount your own subclass
|
76
|
+
instead, and it will handle everything the same way:
|
77
|
+
|
78
|
+
class MyThumbnailer < ImageVise::RenderEngine
|
79
|
+
...
|
80
|
+
end
|
81
|
+
|
82
|
+
map '/thumbs' do
|
83
|
+
run MyThumbnailer.new
|
84
|
+
end
|
85
|
+
|
86
|
+
Note that since the API is in flux the methods you can override in `RenderEngine` can change.
|
87
|
+
So far none of them are private.
|
88
|
+
|
89
|
+
## Performance and memory
|
90
|
+
|
91
|
+
ImageVise uses ImageMagick and RMagick. It _does not_ shell out to `convert` or `mogrify`, because shelling out
|
92
|
+
is _expensive_ in terms of wall clock. It _does_ do it's best to deallocate (`#destroy!`) the image it works on,
|
93
|
+
but it is not 100% bullet proof.
|
94
|
+
|
95
|
+
Additionally, in contrast to `convert` and `mogrify` ImageVise supports _stackable_ operations, and these operations
|
96
|
+
might be repeated with different parameters. Unfortunately, `convert` is _not_ Shake, so we cannot pass a DAG of
|
97
|
+
image operators to it and just expect it to work. If we want to do processing of multiple steps that `convert` is
|
98
|
+
unable to execute in one call, we have to do
|
99
|
+
|
100
|
+
[fork+exec read + convert + write] -> [fork+exec read + convert + write] + ...
|
101
|
+
|
102
|
+
for each operator we want to apply in a consistent fashion. We cannot stuff all the operators into one `convert`
|
103
|
+
command because the order the operators get applied within `convert` is not clear, whereas we need a reproducible
|
104
|
+
deterministic order of operations (as set in the pipeline). A much better solution is - load the image into memory
|
105
|
+
**once**, do all the transformations, save. Additionally, if you use things like OpenCL with ImageMagick, the overhead
|
106
|
+
of loading the library and compiling the compute kernels will outweigh _any_ performance gains you might get when
|
107
|
+
actually using them. If you are using a library it is a one-time cost, with very fast processing afterwards.
|
108
|
+
|
109
|
+
Also note that image operators are not per definition Imagemagick-specific - it's completely possible to not only use
|
110
|
+
a different library for processing them, but even to use a different image processing server complying to the
|
111
|
+
same protocol (a signed JSON-encodded waybill of HTTP(S) source-URL + pipeline instructions).
|
data/Gemfile
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,29 @@
|
|
1
|
+
Copyright (c) 2016 WeTransfer
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
21
|
+
|
22
|
+
To anyone who acknowledges that the file "sRGB_v4_ICC_preference_displayclass" is
|
23
|
+
provided "AS IS" WITH NO EXPRESS OR IMPLIED WARRANTY, permission to use,
|
24
|
+
copy and distribute this file for any purpose is hereby granted without
|
25
|
+
fee, provided that the file is not changed including the ICC copyright
|
26
|
+
notice tag, and that the name of ICC shall not be used in advertising or
|
27
|
+
publicity pertaining to distribution of the software without specific,
|
28
|
+
written prior permission. ICC makes no representations about the
|
29
|
+
suitability of this software for any purpose.
|
data/README.md
ADDED
@@ -0,0 +1,213 @@
|
|
1
|
+
# A thumbnailing server
|
2
|
+
|
3
|
+
`ImageVise` is an image-from-url-as-a-service server for use either standalone or within a larger Rails/Rack
|
4
|
+
framework. The main uses are:
|
5
|
+
|
6
|
+
* Image resizing on request
|
7
|
+
* Applying image filters
|
8
|
+
|
9
|
+
It is implemented as a Rack application that responds to any URL and accepts the following two _last_ path
|
10
|
+
compnents, internally named `q` and `sig`:
|
11
|
+
|
12
|
+
* `q` - Base64 encoded JSON object with `src_url` and `pipeline` properties
|
13
|
+
(the source URL of the image and processing steps to apply)
|
14
|
+
* `sig` - the HMAC signature, computed over the JSON in `q` before it gets Base64-encoded
|
15
|
+
|
16
|
+
A request to `ImageVise` might look like this:
|
17
|
+
|
18
|
+
/acbhGyfhyYErghff/acfgheg123
|
19
|
+
|
20
|
+
The URL that gets generated is best composed with the included `ImageVise.image_params` method. This method will
|
21
|
+
take care of encoding the source URL and the commands in the right way, as well as signing.
|
22
|
+
|
23
|
+
## Using ImageVise within a Rails application
|
24
|
+
|
25
|
+
Mount ImageVise in your `routes.rb`:
|
26
|
+
|
27
|
+
```ruby
|
28
|
+
mount '/images' => ImageVise
|
29
|
+
```
|
30
|
+
|
31
|
+
and add an initializer (like `config/initializers/image_vise_config.rb`) to set up the permitted hosts
|
32
|
+
|
33
|
+
```ruby
|
34
|
+
ImageVise.add_allowed_host! your_application_hostname
|
35
|
+
ImageVise.add_secret_key! ENV.fetch('IMAGE_VISE_SECRET')
|
36
|
+
```
|
37
|
+
|
38
|
+
You might want to define a helper method for generating signed URLs as well, which will look something like this:
|
39
|
+
|
40
|
+
```ruby
|
41
|
+
def thumb_url(source_image_url)
|
42
|
+
path = ImageVise.image_path(src_url: source_image_url, secret: ENV.fetch('IMAGE_VISE_SECRET')) do |pipeline|
|
43
|
+
# For example, you can also yield `pipeline` to the caller
|
44
|
+
pipeline.fit_crop width: 128, height: 128, gravity: 'c'
|
45
|
+
end
|
46
|
+
'/images' + path
|
47
|
+
end
|
48
|
+
```
|
49
|
+
|
50
|
+
To preserve your sanity, make the route to the ImageVise engine terminal and do _not_ perform rewrites
|
51
|
+
on it in your webserver configuration - for instance, Base64 permits slashes.
|
52
|
+
|
53
|
+
## Using ImageVise within a Rack application
|
54
|
+
|
55
|
+
Mount ImageVise under a script name in your `config.ru`:
|
56
|
+
|
57
|
+
```ruby
|
58
|
+
map '/images' do
|
59
|
+
run ImageVise
|
60
|
+
end
|
61
|
+
```
|
62
|
+
|
63
|
+
and add the initialization code either to `config.ru` proper or to some file in your application:
|
64
|
+
|
65
|
+
ImageVise.add_allowed_host! your_application_hostname
|
66
|
+
ImageVise.add_secret_key! ENV.fetch('IMAGE_VISE_SECRET')
|
67
|
+
|
68
|
+
You might want to define a helper method for generating signed URLs as well, which will look something like this:
|
69
|
+
|
70
|
+
```ruby
|
71
|
+
def thumb_url(source_image_url)
|
72
|
+
path_param = ImageVise.image_path(src_url: source_image_url, secret: ENV.fetch('IMAGE_VISE_SECRET')) do |pipe|
|
73
|
+
pipe.fit_crop width: 256, height: 256, gravity: 'c'
|
74
|
+
pipe.sharpen sigma: 0.5, radius: 2
|
75
|
+
pipe.ellipse_stencil
|
76
|
+
end
|
77
|
+
# Output a URL to the app
|
78
|
+
'/images' + path
|
79
|
+
end
|
80
|
+
```
|
81
|
+
## Path decoding and SCRIPT_NAME
|
82
|
+
|
83
|
+
`ImageVise::RenderEngine` _must_ be mounted under a `SCRIPT_NAME` (using either `mount` in Rails
|
84
|
+
or using `map` in Rack). That is so since we may have more than 1 path component that we have to
|
85
|
+
decode (when the Base64 payload contains slashes).
|
86
|
+
|
87
|
+
## Processing files on the local filesystem instead of remote ones
|
88
|
+
|
89
|
+
If you want to grab a local file, compose a `file://` URL (mind the endcoding!)
|
90
|
+
|
91
|
+
src_url = 'file://' + URI.encode(File.expand_path(my_pic))
|
92
|
+
|
93
|
+
Note that you need to permit certain glob patterns as sources before this will work, see below.
|
94
|
+
|
95
|
+
## Operators and pipelining
|
96
|
+
|
97
|
+
ImageVise processes an image using _operators_. Each operator is just like an adjustment layer in Photoshop, except
|
98
|
+
that it can also resize the canvas. If you are familiar with node-based compositing systems like Shake, Nuke or Fusion
|
99
|
+
the pipeline is a node DAG with only one connection arrow going all the way. The operations are always applied in a
|
100
|
+
destructive way, so that the additional intermediate versions don't have to be deallocated manually after processing.
|
101
|
+
|
102
|
+
Each Operator is described in the pipeline using a tuple (Array) of roughly this structure:
|
103
|
+
|
104
|
+
[<operator_name>, {"<operator_param1>": <operator_param1_value>}]
|
105
|
+
|
106
|
+
You can have an unlimited number of such Operators per thumbnail, and they all get encoded in the URL (well,
|
107
|
+
technically, you _are_ limited - by the URL length supported by your web server).
|
108
|
+
|
109
|
+
For example, you can use the pipeline to apply a sharpening operator _after_ resising an image (for the lack
|
110
|
+
of decent image filtering choices in ImageMagick proper).
|
111
|
+
|
112
|
+
Here is an example pipeline, JSON-encoded (this is what is passed in the URL):
|
113
|
+
|
114
|
+
```json
|
115
|
+
[
|
116
|
+
["auto_orient", {}],
|
117
|
+
["geom", {"geometry_string": "512x512"}],
|
118
|
+
["fit_crop", {"width": 32, "height": 32, "gravity": "se"}],
|
119
|
+
["sharpen", {"radius": 0.75, "sigma": 0.5}],
|
120
|
+
["ellipse_stencil", {}]
|
121
|
+
]
|
122
|
+
```
|
123
|
+
|
124
|
+
The same pipeline can be created using the `Pipeline` DSL:
|
125
|
+
|
126
|
+
```ruby
|
127
|
+
pipe = Pipeline.new.
|
128
|
+
auto_orient.
|
129
|
+
geom(geometry_string: '512x512').
|
130
|
+
fit_crop(width: 32, height: 32, gravity: 'se').
|
131
|
+
sharpen(radius: 0.75, sigma: 0.5).
|
132
|
+
ellipse_stencil
|
133
|
+
```
|
134
|
+
and can then be applied to a `Magick::Image` object:
|
135
|
+
|
136
|
+
```ruby
|
137
|
+
image = Magick::Image.read(my_image_path)[0]
|
138
|
+
pipe.apply!(image)
|
139
|
+
```
|
140
|
+
|
141
|
+
|
142
|
+
## Caching
|
143
|
+
|
144
|
+
The app is _designed_ to be run behind a frontline HTTP cache. The easiest is to use `Rack::Cache`, but this might
|
145
|
+
be instance-local depending on the storage backend used. A much better idea is to run ImageVise behind a long-caching
|
146
|
+
CDN.
|
147
|
+
|
148
|
+
## Shared HMAC keys for signed URLs
|
149
|
+
|
150
|
+
To allow `ImageVise` to recognize the signature when the signature is going to be received, add it to the list
|
151
|
+
of the shared keys on the `ImageVise` server:
|
152
|
+
|
153
|
+
```ruby
|
154
|
+
ImageVise.add_secret_key!('ahoy! this is a secret!')
|
155
|
+
```
|
156
|
+
|
157
|
+
A single `ImageVise` server can maintain multiple signature keys, so that you will be able to generate thumbnails from
|
158
|
+
multiple applications all using different keys for their signatures. Every request will be validated against
|
159
|
+
each key and if at least one key generates the same signature for the same given parameters, it is going to be
|
160
|
+
accepted and the request will be allowed to go through.
|
161
|
+
|
162
|
+
## Hostname and filesystem validation
|
163
|
+
|
164
|
+
By default, `ImageVise` will refuse to process images from URLs on "unknown" hosts. To mark a host as "known"
|
165
|
+
tell `ImageVise` to
|
166
|
+
|
167
|
+
```ruby
|
168
|
+
ImageVise.add_allowed_host!('my-image-store.ourcompany.co.uk')
|
169
|
+
```
|
170
|
+
|
171
|
+
If you want to permit images from the local server filesystem to be accessed, add the glob pattern
|
172
|
+
to the set of allowed filesystem patterns:
|
173
|
+
|
174
|
+
```ruby
|
175
|
+
ImageVise.allow_filesystem_source!(Rails.root + '/public/*.jpg')
|
176
|
+
```
|
177
|
+
|
178
|
+
Note that these are _glob_ patterns. The image path will be checked against them using `File.fnmatch`.
|
179
|
+
|
180
|
+
## Handling errors within the rendering Rack app
|
181
|
+
|
182
|
+
By default, the Rack app within ImageVise swallows all exceptions and returns the error message
|
183
|
+
within a machine-readable JSON payload. If that doesn't work for you, or you want to add error
|
184
|
+
handling using some error tracking provider, either subclass `ImageVise::RenderEngine` or prepend
|
185
|
+
a module into it that will intercept the errors. See error handling in `examples/` for more.
|
186
|
+
|
187
|
+
## State
|
188
|
+
|
189
|
+
Except for the HTTP cache no state is stored (`ImageVise` does not care whether you store
|
190
|
+
your images using Dragonfly, CarrierWave or some custom handling code). All the app needs is the full URL.
|
191
|
+
|
192
|
+
## Running the tests, versioning, contributing
|
193
|
+
|
194
|
+
By default, `bundle exec rake` will run RSpec and will also open the generated images using the `$ open` command available
|
195
|
+
on your CLI. If you want to skip viewing those images, set the `SKIP_INTERACTIVE` environment variable to any value.
|
196
|
+
|
197
|
+
The gem version is specified in `image_vise.rb`. When contributing, please follow:
|
198
|
+
|
199
|
+
* Check out the latest master to make sure the feature hasn't been implemented or the bug hasn't been fixed yet.
|
200
|
+
* Check out the issue tracker to make sure someone already hasn't requested it and/or contributed it.
|
201
|
+
* Fork the project.
|
202
|
+
* Start a feature/bugfix branch.
|
203
|
+
* Commit and push until you are happy with your contribution.
|
204
|
+
* Make sure to add tests for it. This is important so I don't break it in a future version unintentionally.
|
205
|
+
* Please try not to mess with the Rakefile, version, or history. If you want to have your own version, or is otherwise necessary, that is fine, but please isolate to its own commit so I can cherry-pick around it.
|
206
|
+
|
207
|
+
### Copyright
|
208
|
+
|
209
|
+
Copyright (c) 2016 WeTransfer. See LICENSE.txt for further details.
|
210
|
+
The licensing terms also apply to the `waterside_magic_hour.jpg` test image.
|
211
|
+
|
212
|
+
The sRGB color profiles are [downloaded from the ICC](http://www.color.org/srgbprofiles.xalter) and it's
|
213
|
+
use is governed by the terms present in the LICENSE.txt
|
data/Rakefile
ADDED
data/SECURITY.md
ADDED
@@ -0,0 +1,57 @@
|
|
1
|
+
# Security implications for ImageVise
|
2
|
+
|
3
|
+
This lists out the implementation details of security-sensitive parts of ImageVise.
|
4
|
+
|
5
|
+
## Protection of the URLs
|
6
|
+
|
7
|
+
URLs are passed as Base64-encoded JSON. The HMAC signature is computed over the Base-64 encoded string,
|
8
|
+
so altering the string (with the intention to bust the cache) will invalidate the signature.
|
9
|
+
|
10
|
+
For checking HMAC values `Rack::Utils.secure_compare` constant-time comparison is used.
|
11
|
+
|
12
|
+
## Throttling still recommended
|
13
|
+
|
14
|
+
Throttling between the caching CDN/proxy is recommended.
|
15
|
+
|
16
|
+
## Cache bypass protection for fuzzed paths
|
17
|
+
|
18
|
+
ImageVise accepts exactly 2 path components, and will return early if there are more
|
19
|
+
|
20
|
+
## Cache bypass protection for randomized query string params
|
21
|
+
|
22
|
+
ImageVise defaults to using paths. If you have a way to forbid query strings on the fronting CDN
|
23
|
+
or proxy server we suggest you to do so, to prevent randomized URLs from filling up your cache
|
24
|
+
and extreme amounts of processing from happening.
|
25
|
+
|
26
|
+
* `/image/<pipeline>/<sig>?&random=123`
|
27
|
+
* `/image/<pipeline>/<sig>?&random=456`
|
28
|
+
|
29
|
+
These URLs would in fact resolve to the same source image and pipeline, but would not be stored in an upstream
|
30
|
+
CDN cache because the query string params contain extra data.
|
31
|
+
|
32
|
+
## Protection for remote URLs from HTTP(s) origins
|
33
|
+
|
34
|
+
Only URLs referring to permitted hosts are going to be permitted for fetching. If there are no hosts added,
|
35
|
+
any remote URL is going to cause an exception. No special verification for whether the upstream must be HTTP
|
36
|
+
or HTTPS is performed at this time.
|
37
|
+
|
38
|
+
## Protection for "file:/" URLs
|
39
|
+
|
40
|
+
The file URLs are going to be decoded, and the path component will be matched against permitted _glob patterns._
|
41
|
+
The matching takes links (hard and soft) into account, and uses Ruby's `File.fnmatch?` under the hood. The path
|
42
|
+
is always expanded first using `File.expand_path`. The data is not read into ImageMagick from the original location,
|
43
|
+
but gets copied into a tempfile first.
|
44
|
+
|
45
|
+
The path in to the file gets encoded in the image processing request and may be examined by the user, that will
|
46
|
+
disclose where the source image is stored on the server's filesystem. This might be an issue - if it is,
|
47
|
+
a customised version with a custom URL scheme should be used for the source URL.
|
48
|
+
|
49
|
+
## ImageMagick memory constraints
|
50
|
+
|
51
|
+
ImageVise does not set RMagick limits by itself. You should
|
52
|
+
[set them according to the RMagick documentation.](https://rmagick.github.io/magick.html#limit_resource)
|
53
|
+
|
54
|
+
## Processing time constraints
|
55
|
+
|
56
|
+
If you are using forking, there will be a timeout used for how long the forked child process may run,
|
57
|
+
which is the default timeout used in ExceptionalFork.
|
data/examples/config.ru
ADDED
@@ -0,0 +1,17 @@
|
|
1
|
+
require File.dirname(__FILE__) + '/lib/image_vise'
|
2
|
+
|
3
|
+
# Add all the secret keys specified in the environment separated by a comma
|
4
|
+
if ENV['VISE_SECRET_KEYS']
|
5
|
+
ENV['VISE_SECRET_KEYS'].split(',').map do | key |
|
6
|
+
ImageVise.add_secret_key!(key.strip)
|
7
|
+
end
|
8
|
+
end
|
9
|
+
|
10
|
+
# Cover things with caching, even redirects (since we do not want to ask S3 too often)
|
11
|
+
use Rack::Cache, :metastore => 'file:tmp/cache/rack/meta', :entitystore => 'file:tmp/cache/rack/entity',
|
12
|
+
:verbose => true, :allow_reload => false, :allow_revalidate => false
|
13
|
+
|
14
|
+
# Serve runtime thumbnails, under a specific URL
|
15
|
+
map '/images'do
|
16
|
+
run ImageVise
|
17
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
class MyBlur
|
2
|
+
# The constructor must accept keyword arguments if your operator accepts them
|
3
|
+
def initialize(radius:, sigma:)
|
4
|
+
@radius = radius.to_f
|
5
|
+
@sigma = sigma.to_f
|
6
|
+
end
|
7
|
+
|
8
|
+
# Needed for the operator to be serialized to parameters
|
9
|
+
def to_h
|
10
|
+
{radius: @radius, sigma: @sigma}
|
11
|
+
end
|
12
|
+
|
13
|
+
def apply!(magick_image) # The argument is a Magick::Image
|
14
|
+
# Apply the method to the function argument
|
15
|
+
blurred_image = magick_image.blur_image(@radius, @sigma)
|
16
|
+
|
17
|
+
# Some methods (without !) return a new Magick::Image.
|
18
|
+
# That image has to be composited back into the original.
|
19
|
+
magick_image.composite!(blurred_image, Magick::CenterGravity, Magick::CopyCompositeOp)
|
20
|
+
ensure
|
21
|
+
# Make sure to explicitly destroy() all the intermediate images
|
22
|
+
ImageVise.destroy(blurred_image)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
# This will also make `ImageVise::Pipeline` respond to `blur(radius:.., sigma:..)`
|
27
|
+
ImageVise.add_operator 'my_blur', MyBlur
|
@@ -0,0 +1,23 @@
|
|
1
|
+
# Anywhere in your app code
|
2
|
+
module ImageViseAppsignal
|
3
|
+
ImageVise::RenderEngine.prepend self
|
4
|
+
|
5
|
+
def setup_error_handling(rack_env)
|
6
|
+
txn = Appsignal::Transaction.current
|
7
|
+
txn.set_action('%s#%s' % [self.class, 'call'])
|
8
|
+
end
|
9
|
+
|
10
|
+
def handle_request_error(err)
|
11
|
+
Appsignal.add_exception(err)
|
12
|
+
end
|
13
|
+
|
14
|
+
def handle_generic_error(err)
|
15
|
+
Appsignal.add_exception(err)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
# In config.ru
|
20
|
+
map '/thumbnails' do
|
21
|
+
use Appsignal::Rack::GenericInstrumentation
|
22
|
+
run ImageVise
|
23
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
# Anywhere in your app code
|
2
|
+
module ImageViseSentrySupport
|
3
|
+
ImageVise::RenderEngine.prepend self
|
4
|
+
|
5
|
+
def setup_error_handling(rack_env)
|
6
|
+
@env = rack_env
|
7
|
+
end
|
8
|
+
|
9
|
+
def handle_request_error(err)
|
10
|
+
@env['rack.exception'] = err
|
11
|
+
end
|
12
|
+
|
13
|
+
def handle_generic_error(err)
|
14
|
+
@env['rack.exception'] = err
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
# In config.ru
|
19
|
+
Raven.configure do |config|
|
20
|
+
config.dsn = 'https://secretoken@app.getsentry.com/1234567'
|
21
|
+
end
|
22
|
+
use Raven::Rack
|
23
|
+
map '/thumbnails' do
|
24
|
+
run ImageVise
|
25
|
+
end
|
data/image_vise.gemspec
ADDED
@@ -0,0 +1,43 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'image_vise/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = "image_vise"
|
8
|
+
spec.version = ImageVise::VERSION
|
9
|
+
spec.authors = ["Julik Tarkhanov"]
|
10
|
+
spec.email = ["me@julik.nl"]
|
11
|
+
|
12
|
+
spec.summary = "Runtime thumbnailing proxy"
|
13
|
+
spec.description = "Image processing via URLs"
|
14
|
+
spec.homepage = "https://github.com/WeTransfer/image_vise"
|
15
|
+
spec.license = "MIT"
|
16
|
+
|
17
|
+
# Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host'
|
18
|
+
# to allow pushing to a single host or delete this section to allow pushing to any host.
|
19
|
+
if spec.respond_to?(:metadata)
|
20
|
+
spec.metadata['allowed_push_host'] = "https://rubygems.org"
|
21
|
+
else
|
22
|
+
raise "RubyGems 2.0 or newer is required to protect against public gem pushes."
|
23
|
+
end
|
24
|
+
|
25
|
+
spec.files = `git ls-files -z`.split("\x0")
|
26
|
+
spec.bindir = "exe"
|
27
|
+
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
28
|
+
spec.require_paths = ["lib"]
|
29
|
+
|
30
|
+
spec.add_dependency 'patron', '~> 0.6'
|
31
|
+
spec.add_dependency 'rmagick', '~> 2.15'
|
32
|
+
spec.add_dependency 'ks'
|
33
|
+
spec.add_dependency 'magic_bytes', '~> 1'
|
34
|
+
spec.add_dependency 'rack', '~> 1'
|
35
|
+
|
36
|
+
spec.add_development_dependency "bundler", "~> 1.7"
|
37
|
+
spec.add_development_dependency "rake", "~> 10.0"
|
38
|
+
spec.add_development_dependency "rack-test"
|
39
|
+
spec.add_development_dependency "rspec", "~> 3.0"
|
40
|
+
spec.add_development_dependency "addressable"
|
41
|
+
spec.add_development_dependency "strenv"
|
42
|
+
spec.add_development_dependency "simplecov"
|
43
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
class ImageVise::FetcherFile
|
2
|
+
class AccessError < StandardError
|
3
|
+
def http_status; 403; end
|
4
|
+
end
|
5
|
+
def self.fetch_uri_to_tempfile(uri)
|
6
|
+
tf = Tempfile.new 'imagevise-localfs-copy'
|
7
|
+
real_path_on_filesystem = File.expand_path(URI.decode(uri.path))
|
8
|
+
verify_filesystem_access! real_path_on_filesystem
|
9
|
+
# Do the checks
|
10
|
+
File.open(real_path_on_filesystem, 'rb') do |f|
|
11
|
+
IO.copy_stream(f, tf)
|
12
|
+
end
|
13
|
+
tf.rewind; tf
|
14
|
+
rescue Exception => e
|
15
|
+
ImageVise.close_and_unlink(tf)
|
16
|
+
raise e
|
17
|
+
end
|
18
|
+
|
19
|
+
def self.verify_filesystem_access!(path_on_filesystem)
|
20
|
+
patterns = ImageVise.allowed_filesystem_sources
|
21
|
+
matches = patterns.any? { |glob_pattern| File.fnmatch?(glob_pattern, path_on_filesystem) }
|
22
|
+
raise AccessError, "filesystem access is disabled" unless patterns.any?
|
23
|
+
raise AccessError, "#{path_on_filesystem} is not on the path whitelist" unless matches
|
24
|
+
end
|
25
|
+
|
26
|
+
ImageVise.register_fetcher 'file', self
|
27
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
class ImageVise::FetcherHTTP
|
2
|
+
EXTERNAL_IMAGE_FETCH_TIMEOUT_SECONDS = 5
|
3
|
+
|
4
|
+
class AccessError < StandardError; end
|
5
|
+
|
6
|
+
class UpstreamError < StandardError
|
7
|
+
attr_accessor :http_status
|
8
|
+
def initialize(http_status, message)
|
9
|
+
super(message)
|
10
|
+
@http_status = http_status
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
def self.fetch_uri_to_tempfile(uri)
|
15
|
+
tf = Tempfile.new 'imagevise-http-download'
|
16
|
+
verify_uri_access!(uri)
|
17
|
+
s = Patron::Session.new
|
18
|
+
s.automatic_content_encoding = true
|
19
|
+
s.timeout = EXTERNAL_IMAGE_FETCH_TIMEOUT_SECONDS
|
20
|
+
s.connect_timeout = EXTERNAL_IMAGE_FETCH_TIMEOUT_SECONDS
|
21
|
+
|
22
|
+
response = s.get_file(uri.to_s, tf.path)
|
23
|
+
|
24
|
+
if response.status != 200
|
25
|
+
raise UpstreamError.new(response.status, "Unfortunate upstream response #{response.status} on #{uri}")
|
26
|
+
end
|
27
|
+
|
28
|
+
tf
|
29
|
+
rescue Exception => e
|
30
|
+
ImageVise.close_and_unlink(tf)
|
31
|
+
raise e
|
32
|
+
end
|
33
|
+
|
34
|
+
def self.verify_uri_access!(uri)
|
35
|
+
host = uri.host
|
36
|
+
return if ImageVise.allowed_hosts.include?(uri.host)
|
37
|
+
raise AccessError, "#{uri} is not permitted as source"
|
38
|
+
end
|
39
|
+
|
40
|
+
ImageVise.register_fetcher 'http', self
|
41
|
+
ImageVise.register_fetcher 'https', self
|
42
|
+
end
|