Class: Rack::Thumb

Inherits:
Object
  • Object
show all
Defined in:
lib/rack/thumb.rb

Overview

The Rack::Thumb middleware intercepts requests for images that have urls of

the form <code>/path/to/image_{metadata}.ext</code> and returns rendered
thumbnails. Rendering options include +width+, +height+ and +gravity+. If
both +width+ and +height+ are supplied, images are cropped and resized
to fit the aspect ratio.

Rack::Thumb is file-server agnostic to provide maximum deployment
flexibility. Simply set it up in front of any downstream application that
can serve the source images. Example:

  # rackup.ru
  require 'rack/thumb'

  use Rack::Thumb
  use Rack::Static, :urls => ["/media"]

  run MyApp.new

See the example directory for more <tt>Rack</tt> configurations. Because
thumbnailing is an expensive operation, you should run Rack::Thumb
behind a cache, such as <tt>Rack::Cache</tt>.

Link to thumbnails from your templates as follows:

  /media/foobar_50x50.jpg     # => Crop and resize to 50x50
  /media/foobar_50x50-nw.jpg  # => Crop and resize with northwest gravity
  /media/foobar_50x.jpg       # => Resize to a width of 50, preserving AR
  /media/foobar_x50.jpg       # => Resize to a height of 50, preserving AR

To prevent pesky end-users and bots from flooding your application with
render requests you can set up Rack::Thumb to check for a <tt>SHA-1</tt> signature
that is unique to every url. Using this option, only thumbnails requested
by your templates will be valid. Example:

  use Rack::Thumb(
    :secret => "My secret",
    :key_length => "16"       # => Only use 16 digits of the SHA-1 key
  )

You can then use your +secret+ to generate secure links in your templates:

  /media/foobar_50x100-sw-a267c193a7eff046.jpg  # => Successful
  /media/foobar_120x250-a267c193a7eff046.jpg    # => Returns a bad request error

Constant Summary collapse

RE_TH_BASE =
/_([0-9]+x|x[0-9]+|[0-9]+x[0-9]+)(-(?:nw|n|ne|w|c|e|sw|s|se))?/
RE_TH_EXT =
/(\.(?:jpg|jpeg|png|gif))/i
TH_GRAV =
{
  '-nw' => :northwest,
  '-n' => :north,
  '-ne' => :northeast,
  '-w' => :west,
  '-c' => :center,
  '-e' => :east,
  '-sw' => :southwest,
  '-s' => :south,
  '-se' => :southeast
}

Instance Method Summary collapse

Constructor Details

#initialize(app, options = {}) ⇒ Thumb

Returns a new instance of Thumb.



76
77
78
79
80
81
# File 'lib/rack/thumb.rb', line 76

def initialize(app, options={})
  @app = app
  @keylen = options[:keylength]
  @secret = options[:secret]
  @routes = generate_routes(options[:urls] || ["/"], options[:prefix])
end

Instance Method Details

#_call(env) ⇒ Object



100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
# File 'lib/rack/thumb.rb', line 100

def _call(env)
  response = catch(:halt) do
    throw :halt unless %w{GET HEAD}.include? env["REQUEST_METHOD"]
    @env = env
    @path = env["PATH_INFO"]
    @routes.each do |regex|
      if match = @path.match(regex)
        @source, dim, grav = extract_meta(match)
        @image = get_source_image
        @thumb = render_thumbnail(dim, grav) unless head?
        serve
      end
    end
    nil
  end

  response || @app.call(env)
end

#bad_requestObject



244
245
246
247
248
249
# File 'lib/rack/thumb.rb', line 244

def bad_request
  body = "Bad thumbnail parameters in #{@path}\n"
  [400, {"Content-Type" => "text/plain",
     "Content-Length" => body.size.to_s},
   [body]]
end

#call(env) ⇒ Object



96
97
98
# File 'lib/rack/thumb.rb', line 96

def call(env)
  dup._call(env)
end

#create_tempfileObject

Creates a new tempfile



240
241
242
# File 'lib/rack/thumb.rb', line 240

def create_tempfile
  Tempfile.new(::File.basename(@path)).tap { |f| f.close }
end

#eachObject



262
263
264
265
266
267
268
# File 'lib/rack/thumb.rb', line 262

def each
  ::File.open(@thumb.path, "rb") { |file|
    while part = file.read(8192)
      yield part
    end
  }
end

#extract_meta(match) ⇒ Object

Extracts filename and options from the path.



120
121
122
123
124
125
126
127
128
129
# File 'lib/rack/thumb.rb', line 120

def extract_meta(match)
  result = if @keylen
    extract_signed_meta(match)
  else
    extract_unsigned_meta(match)
  end

  throw :halt unless result
  result
end

#extract_signed_meta(match) ⇒ Object

Extracts filename and options from a signed path.



132
133
134
135
136
137
# File 'lib/rack/thumb.rb', line 132

def extract_signed_meta(match)
  base, dim, grav, sig, ext = match.captures
  digest = Digest::SHA1.hexdigest("#{base}_#{dim}#{grav}#{ext}#{@secret}")[0..@keylen-1]
  throw(:halt, forbidden) unless sig && (sig == digest)
  [base + ext, dim, grav]
end

#extract_unsigned_meta(match) ⇒ Object

Extracts filename and options from an unsigned path.



140
141
142
143
# File 'lib/rack/thumb.rb', line 140

def extract_unsigned_meta(match)
  base, dim, grav, ext = match.captures
  [base + ext, dim, grav]
end

#forbiddenObject



251
252
253
254
255
256
# File 'lib/rack/thumb.rb', line 251

def forbidden
  body = "Bad thumbnail signature in #{@path}\n"
  [403, {"Content-Type" => "text/plain",
     "Content-Length" => body.size.to_s},
   [body]]
end

#generate_routes(urls, prefix = nil) ⇒ Object

Generates routes given a list of prefixes.



84
85
86
87
88
89
90
91
92
93
94
# File 'lib/rack/thumb.rb', line 84

def generate_routes(urls, prefix = nil)
  urls.map do |url|
    prefix = prefix ? Regexp.escape(prefix) : ''
    url = url == "/" ? '' : Regexp.escape(url)
    if @keylen
      /^#{prefix}(#{url}\/.+)#{RE_TH_BASE}-([0-9a-f]{#{@keylen}})#{RE_TH_EXT}$/
    else
      /^#{prefix}(#{url}\/.+)#{RE_TH_BASE}#{RE_TH_EXT}$/
    end
  end
end

#get_source_imageObject

Fetch the source image from the downstream app, returning the downstream app’s response if it is not a success.



147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
# File 'lib/rack/thumb.rb', line 147

def get_source_image
  status, headers, body = @app.call(@env.merge(
    "PATH_INFO" => @source
  ))

  unless (status >= 200 && status < 300) &&
      (headers["Content-Type"].split("/").first == "image")
    throw :halt, [status, headers, body]
  end

  @source_headers = headers

  if !head?
    if body.respond_to?(:path)
      ::File.open(body.path, 'rb')
    elsif body.respond_to?(:each)
      data = ''
      body.each { |part| data << part.to_s }
      Tempfile.new(::File.basename(@path)).tap do |f|
        f.binmode
        f.write(data)
        f.close
      end
    end
  else
    nil
  end
end

#head?Boolean

Returns:

  • (Boolean)


258
259
260
# File 'lib/rack/thumb.rb', line 258

def head?
  @env["REQUEST_METHOD"] == "HEAD"
end

#parse_dimensions(meta) ⇒ Object

Parses the rendering options; returns false if rendering options are invalid



217
218
219
220
221
222
223
224
225
226
227
228
# File 'lib/rack/thumb.rb', line 217

def parse_dimensions(meta)
  dimensions = meta.split('x').map do |dim|
    if dim.empty?
      nil
    elsif dim[0].to_i == 0
      throw :halt, bad_request
    else
      dim.to_i
    end
  end
  dimensions.any? ? dimensions : throw(:halt, bad_request)
end

#render_thumbnail(dim, grav) ⇒ Object

Renders a thumbnail from the source image. Returns a Tempfile.



177
178
179
180
181
182
183
184
185
186
187
188
189
190
# File 'lib/rack/thumb.rb', line 177

def render_thumbnail(dim, grav)
  gravity = grav ? TH_GRAV[grav] : :center
  dimensions = parse_dimensions(dim)
  output = create_tempfile
  cmd = Mapel(@image.path).gravity(gravity)
  width, height = dimensions
  if width && height
    cmd.resize!(width, height)
  else
    cmd.resize(width, height, 0, 0, :>)
  end
  cmd.to(output.path).run
  output
end

#resolve(uri) ⇒ Object



230
231
232
233
234
235
236
237
# File 'lib/rack/thumb.rb', line 230

def resolve(uri)
  uri = URI.parse(uri) unless uri.respond_to?(:scheme)
  if uri.scheme == "file"
    ::File.expand_path(uri.opaque || uri.path)
  else
    uri.to_s
  end
end

#serveObject

Serves the thumbnail. If this is a HEAD request we strip the body as well as the content length because the render was never run.



194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
# File 'lib/rack/thumb.rb', line 194

def serve
  lastmod = Time.now.httpdate
  # Use origin content type?
  ctype = Mime.mime_type(::File.extname(@path), 'text/plain')

  response = if head?
    @source_headers.delete("Content-Length")
    [200, @source_headers.merge(
        "Last-Modified"  => lastmod,
        "Content-Type"   => ctype
      ), []]
  else
    [200, @source_headers.merge(
        "Last-Modified"  => lastmod,
        "Content-Type"   => ctype,
        "Content-Length" => ::File.size(@thumb.path).to_s
      ), self]
  end

  throw :halt, response
end