© 2010 Jason Frame [ jason@onehackoranother.com / @jaz303 ]
Released under the MIT License.
Asset Warp is a Rack middleware for performing JIT image manipulation. It proxies incoming requests to arbitrary URLs (either internal or external) and applies named sets of transformations called "profiles".
Asset Warp was originally implemented as a Rails plugin and over the last three years has seen solid production use across many apps. This Rack extraction, however, should be considered alpha quality software until any kinks are ironed out.
- A working ImageMagick installation
- MiniMagick gem
- GhostScript required for PDF manipulation
The Asset Warp middleware (AssetWarp
) is instantiated with a context object (AssetWarp::Context
). A context is a configuration object and contains three things:
- the URL prefix for assets to be handled by Asset Warp
- a set of asset sources mapping incoming URLs to the URL of the original, unaltered, asset (original assets might reside on the same server, but it's not necessary)
- a set of named profiles implementing the actual image transformations
The first thing to do is create a context, passing an optional block for customisation:
context = AssetWarp::Context.new do |c|
...
end
The URL prefix can be set either via the constructor, or with an explicit setter. The default
prefix is simply 'a'
.
# Following two lines are equivalent and tell Asset Warp to intercept requests
# whose path is prefixed by /assets/. Start/end slashes are optional, and prefix
# may contain intermediate slashes.
AssetWarp::Context.new('assets')
context.prefix = '/assets/'
Next up are mappings. Asset Warp handles URLs with the format:
$PREFIX/:source/:id[/:profile]
So an incoming request for $PREFIX/avatars/2139/small
would map to the asset source avatars
, with asset ID 2139
and profile small
. If the original versions of our avatars were found at /avatars/:id
, we'd set up the following mapping:
c.map('avatars', '/avatars/:id')
Because of the leading slash, Asset Warp knows to prepend the request host/port. If the avatars were on another server, that's supported too:
c.map('avatars', 'http://cdn.example.com/avatars/:id')
By default, Asset Warp requires that asset IDs be numeric and uses the original
profile if the request path contains none. These defaults can be overridden:
# Set a very permissive ID restriction and default to 'small' profile
c.map('avatars', '/avatars/:id', :id => /^[^\/]+$/,
:default_profile => 'small')
(setting :default_profile
to false
will disable default profiles for this mapping, thereby requiring explicit profiles for incoming requests)
For situations where simple string substitutions don't cut it, a block form is supported too. The block receives the asset ID and Rack environment as parameters and should return the URI of the original asset. Supported URI schemes are file
, http
and https
:
c.map('avatars') do |asset_id, env|
"http://cdn.example.com/#{asset_id.upcase}"
end
Profiles are the workhorses of Asset Warp, and are basically blocks that manipulate blobs. You define profiles with either the profile
or image_profile
methods; image_profile()
will create a profile that operates only on images and returns a 404 for all other file types, whereas profiles created by profile()
operates on all files:
c.image_profile('thumb') do |blob|
blob.crop_resize(100, 100)
end
# This profile is defined by default, no need to create it yourself
c.profile('original') do |blob|
# do nothing
end
You can restrict the profiles available to a particular asset mapping by passing the :only
and :except
options to map()
:
# only allow 'thumb' profile
c.map('foo', '/foo/:id', :only => 'thumb')
# allow all profiles except 'column-1'
c.map('bar', '/bar/:id', :except => 'column-1')
Finally, tell Rack to use the middleware:
use AssetWarp, context
Profile blocks receive an instance of AssetWarp::Blob
as their parameter. The following methods are available:
Predicates:
web_safe_image?
- returns true if blob is a web-safe image (i.e. JPEG, GIF or PNG)pdf?
- returns true if blob is a PDFcontent_type
- returns MIME type of image
Transformations:
format(new_format)
- change format of image, works with PDFsreduce(width, height)
- reduce image, maintaining aspect-ratio; no-op if image is already smaller than target dimensionsreduce!(width, height)
- as above, but does not maintain aspect-ratioresize(width, height)
- resize image, maintaining aspect-ratioresize!(width, height)
- resize image, not maintaining aspect-ratiocrop(width, height, gravity = 'Center')
- crops image to a given size with configurable gravitycrop_resize(width, height, gravity = 'Center')
- resizes image proportionally, then crops to match target dimensionsrounded_corners(radius, options = {})
- add rounded corners to image; only valid option is:color
grayscale
- convert image to grayscale.negate
- negate image colors
The repository contains a working test application in the rack-test
directory. Just clone, cd
and rackup
. Once you're up and running, try these URLs:
# Original files
http://localhost:3000/a/img/berk.gif
http://localhost:3000/a/img/clouds.jpg
http://localhost:3000/a/img/test.txt
http://localhost:3000/a/img/rmagick.pdf
# Original files (explicit profile)
http://localhost:3000/a/img/berk.gif/original
http://localhost:3000/a/img/clouds.jpg/original
http://localhost:3000/a/img/test.txt/original
http://localhost:3000/a/img/rmagick.pdf/original
# Thumbnails
http://localhost:3000/a/img/berk.gif/rounded-thumb
http://localhost:3000/a/img/clouds.jpg/rounded-thumb
http://localhost:3000/a/img/test.txt/rounded-thumb
http://localhost:3000/a/img/rmagick.pdf/rounded-thumb
# Main images
http://localhost:3000/a/img/berk.gif/main-image
http://localhost:3000/a/img/clouds.jpg/main-image
http://localhost:3000/a/img/test.txt/main-image
Note the difference between requesting test.txt
with the rounded-thumb
and main-image
profiles: rounded-thumb
is declared as an image profile so it returns a 404 because test.txt
is not an image. main-image
is a standard profile so the text file is allowed to pass through unaltered.
Here's the config.ru
that made this possible:
#\ -p 3000
require File.dirname(__FILE__) + '/../lib/asset_warp'
# Create a context object describing our configuration
# Context objects allow multiple Asset Wrap instances to live inside a single process
context = AssetWarp::Context.new do |c|
# Set Asset Warp to intercept all requests beginning with /a/
# (can also be set via constructor - defaults to 'a')
c.prefix = 'a'
# Maps /a/img/foo.gif/profile-name to /images/foo.gif
# The profile name is optional and in this case defaults to 'original'
c.map 'img', '/images/:id', :id => /^[^\/]+$/, :default_profile => 'original'
# Maps /a/files/foo.gif/profile-name to filesystem path
# Note: you need to return a file:// URI
# This example is insecure! For example only!
c.map 'file', :id => /^[^\/]+$/,
:default_profile => false,
:only => %w(rounded-thumb main-image) do |asset_id, env|
"file://" + File.expand_path(File.dirname(__FILE__)) + '/files/' + asset_id
end
# Define a standard profile
# Standard profiles operate on everything and, to be honest, aren't much use.
# A single standard profile, 'original', is used internally as a no-op for viewing original asset
c.profile 'rounded-thumb' do |b|
b.format 'png' if b.pdf?
b.crop_resize(100, 100)
b.rounded_corners(15)
end
# Define an image profile
# Image profiles operate only on web-safe images
# If they encounter any other content types they will return a 404
c.image_profile 'main-image' do |b|
b.resize!(400, 300)
end
end
use AssetWarp, context
# Serve our source images statically - Asset Warp proxies these
use Rack::Static, :urls => ["/images"], :root => "public"
# Dummy app
class MyApp
def call(env)
[ 200, {'Content-Type' => 'text/plain'}, env['REQUEST_PATH'] ]
end
end
run MyApp.new
Yes. Put a cache in front of it.
Yes. An attacker could request every possible asset/profile combination, regardless of whether your site actually referenced it, thus filling your cache with crap.
Is this really a problem? Your application might not have many assets/profiles, or it might use every asset/profile combination. In these cases, there's no reason to worry.
If it is a problem, there are a couple of possible solutions:
- Restrict profile availability based on asset source. For example, avatars might only be needed in 2 sizes, so deny all other profiles. This is possible using the
:only
and:except
options tocontext.map()
. - Consult a whitelist to decide whether a profile can be applied. This is slightly more involved, but the basic idea is to insert a database row for each valid asset/profile combination - you'd probably write a helper to do this transparently in your view. Before proxying a request, Asset Warp would check for the corresponding row in the database, bailing out if it wasn't found (possible implementation: guard blocks on sources which return a boolean).
- rounded corners with background color don't work. My ImageMagick-fu ran out.
- Gem release
- Support loading context from file
- Write some tests?
- Audio/video manipulation?
- Render file-type icons based on content type? (or possibly redirect)