Permalink
executable file 364 lines (303 sloc) 13.4 KB
#!/usr/bin/env python
#
# Copyright 2013 Adam Gschwender
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
from __future__ import absolute_import, division, with_statement
import logging
import socket
import tornado.escape
import tornado.gen
import tornado.httpclient
import tornado.httpserver
import tornado.ioloop
import tornado.options
import tornado.web
from tornado.options import define, options, parse_config_file
from pilbox import errors
from pilbox.image import Image
from pilbox.signature import verify_signature
try:
from urlparse import urlparse, urljoin
except ImportError:
from urllib.parse import urlparse, urljoin
try:
import pycurl
except ImportError:
pycurl = None
# general settings
define("config", help="path to configuration file",
callback=lambda path: parse_config_file(path, final=False))
define("debug", help="run in debug mode", type=bool, default=False)
define("port", help="run on the given port", type=int, default=8888)
# security related settings
define("client_name", help="client name")
define("client_key", help="client key")
define("allowed_hosts", help="valid hosts", default=[], multiple=True)
define("allowed_operations", help="valid ops", default=[], multiple=True)
define("max_operations", help="maximum operations to perform", default=10)
define("max_resize_height", help="maximum resize height", default=15000)
define("max_resize_width", help="maximum resize width", default=15000)
# request related settings
define("max_requests", help="max concurrent requests", type=int, default=40)
define("timeout", help="request timeout in seconds", type=float, default=10)
define("implicit_base_url", help="prepend protocol/host to url paths")
define("ca_certs",
help="override filename of CA certificates in PEM format",
default=None)
define("validate_cert", help="validate certificates", type=bool, default=True)
define("proxy_host", help="proxy hostname")
define("proxy_port", help="proxy port", type=int)
# header related settings
define("content_type_from_image",
help="override content type using image mime type",
type=bool)
# default image option settings
define("background", help="default hexadecimal bg color (RGB or ARGB)")
define("expand", help="default to expand when rotating", type=int)
define("filter", help="default filter to use when resizing")
define("format", help="default format to use when outputting")
define("mode", help="default mode to use when resizing")
define("operation", help="default operation to perform")
define("optimize", help="default to optimize when saving", type=int)
define("position", help="default cropping position")
define("progressive", help="default to progressive when saving", type=int)
define("quality", help="default jpeg quality, 1-99 or keep")
define("retain", help="default adaptive retain percent, 1-99", type=int)
define("preserve_exif", help="default behavior for exif data", type=int)
logger = logging.getLogger("tornado.application")
class PilboxApplication(tornado.web.Application):
def __init__(self, **kwargs):
settings = dict(
debug=options.debug,
client_name=options.client_name,
client_key=options.client_key,
allowed_hosts=options.allowed_hosts,
allowed_operations=set(
options.allowed_operations or ImageHandler.OPERATIONS),
max_operations=options.max_operations,
max_resize_height=options.max_resize_height,
max_resize_width=options.max_resize_width,
background=options.background,
expand=options.expand,
filter=options.filter,
format=options.format,
mode=options.mode,
operation=options.operation,
optimize=options.optimize,
position=options.position,
progressive=options.progressive,
quality=options.quality,
max_requests=options.max_requests,
timeout=options.timeout,
implicit_base_url=options.implicit_base_url,
ca_certs=options.ca_certs,
validate_cert=options.validate_cert,
content_type_from_image=options.content_type_from_image,
proxy_host=options.proxy_host,
proxy_port=options.proxy_port,
preserve_exif=options.preserve_exif)
settings.update(kwargs)
if settings.get("proxy_host") and pycurl is None: # pragma: no cover
raise Exception("PycURL is required for proxy requests")
if pycurl is not None: # pragma: no cover
tornado.httpclient.AsyncHTTPClient.configure(
"tornado.curl_httpclient.CurlAsyncHTTPClient")
tornado.web.Application.__init__(self, self.get_handlers(), **settings)
def get_handlers(self):
return [(r"/", ImageHandler)]
class ImageHandler(tornado.web.RequestHandler):
FORWARD_HEADERS = ["Cache-Control", "Expires", "Last-Modified"]
OPERATIONS = ["region", "resize", "rotate", "noop"]
_FORMAT_TO_MIME = {
"gif": "image/gif",
"jpeg": "image/jpeg",
"jpg": "image/jpeg",
"png": "image/png",
"webp": "image/webp",
"tiff": "image/tiff",
}
@tornado.gen.coroutine
def get(self):
self.validate_request()
resp = yield self.fetch_image()
self.render_image(resp)
def get_argument(self, name, default=None, strip=True):
return super(ImageHandler, self).get_argument(name, default, strip)
def validate_request(self):
self._validate_operation()
self._validate_url()
self._validate_signature()
self._validate_client()
self._validate_host()
opts = self._get_save_options()
ops = self._get_operations()
if "resize" in ops:
w, h = self.get_argument("w"), self.get_argument("h")
Image.validate_dimensions(w, h)
if w and int(w) > self.settings.get("max_resize_width"):
raise errors.DimensionsError("Exceeds maximum allowed width")
elif h and int(h) > self.settings.get("max_resize_height"):
raise errors.DimensionsError("Exceeds maximum allowed height")
opts.update(self._get_resize_options())
if "rotate" in ops:
Image.validate_degree(self.get_argument("deg"))
opts.update(self._get_rotate_options())
if "region" in ops:
Image.validate_rectangle(self.get_argument("rect"))
Image.validate_options(opts)
@tornado.gen.coroutine
def fetch_image(self):
url = self.get_argument("url")
if self.settings.get("implicit_base_url") \
and urlparse(url).hostname is None:
url = urljoin(self.settings.get("implicit_base_url"), url)
client = tornado.httpclient.AsyncHTTPClient(
max_clients=self.settings.get("max_requests"))
try:
resp = yield client.fetch(
url,
request_timeout=self.settings.get("timeout"),
ca_certs=self.settings.get("ca_certs"),
validate_cert=self.settings.get("validate_cert"),
proxy_host=self.settings.get("proxy_host"),
proxy_port=self.settings.get("proxy_port"))
raise tornado.gen.Return(resp)
except (socket.gaierror, tornado.httpclient.HTTPError) as e:
logger.warn("Fetch error for %s: %s",
self.get_argument("url"),
str(e))
raise errors.FetchError()
def render_image(self, resp):
outfile, outfile_format = self._process_response(resp)
self._set_headers(resp.headers, outfile_format)
for block in iter(lambda: outfile.read(65536), b""):
self.write(block)
outfile.close()
def write_error(self, status_code, **kwargs):
err = kwargs["exc_info"][1] if "exc_info" in kwargs else None
if isinstance(err, errors.PilboxError):
self.set_header("Content-Type", "application/json")
resp = dict(status_code=status_code,
error_code=err.get_code(),
error=err.log_message)
self.finish(tornado.escape.json_encode(resp))
else:
super(ImageHandler, self).write_error(status_code, **kwargs)
def _process_response(self, resp):
ops = self._get_operations()
if "noop" in ops:
return (resp.buffer, None)
image = Image(resp.buffer)
for operation in ops:
if operation == "resize":
self._image_resize(image)
elif operation == "rotate":
self._image_rotate(image)
elif operation == "region":
self._image_region(image)
return (self._image_save(image), image.img.format)
def _image_region(self, image):
image.region(self.get_argument("rect").split(","))
def _image_resize(self, image):
opts = self._get_resize_options()
image.resize(self.get_argument("w"), self.get_argument("h"), **opts)
def _image_rotate(self, image):
opts = self._get_rotate_options()
image.rotate(self.get_argument("deg"), **opts)
def _image_save(self, image):
opts = self._get_save_options()
return image.save(**opts)
def _set_headers(self, headers, file_format):
if file_format and any((self.get_argument("fmt"),
self.settings.get("format"),
self.settings.get("content_type_from_image"))):
self.set_header(
"Content-Type", self._FORMAT_TO_MIME.get(file_format.lower()))
elif "Content-Type" in headers:
self.set_header("Content-Type", headers["Content-Type"])
for k in ImageHandler.FORWARD_HEADERS:
if k in headers and headers[k]:
self.set_header(k, headers[k])
def _get_operations(self):
return self.get_argument(
"op", self.settings.get("operation") or "resize").split(",")
def _get_resize_options(self):
return self._get_options(
dict(mode=self.get_argument("mode"),
filter=self.get_argument("filter"),
position=self.get_argument("pos"),
background=self.get_argument("bg"),
retain=self.get_argument("retain")))
def _get_rotate_options(self):
return self._get_options(
dict(expand=self.get_argument("expand")))
def _get_save_options(self):
return self._get_options(
dict(format=self.get_argument("fmt"),
optimize=self.get_argument("opt"),
quality=self.get_argument("q"),
progressive=self.get_argument("prog"),
background=self.get_argument("bg"),
preserve_exif=self.get_argument("exif")))
def _get_options(self, opts):
for k, v in opts.items():
if v is None:
opts[k] = self.settings.get(k, None)
return opts
def _validate_operation(self):
operations = set(self._get_operations())
if not operations.issubset(self.settings.get("allowed_operations")):
raise errors.OperationError("Unsupported operation")
elif len(operations) > self.settings.get("max_operations"):
raise errors.OperationError("Too many operations")
def _validate_url(self):
url = self.get_argument("url")
if not url:
raise errors.UrlError("Missing url")
elif url.startswith("http://") or url.startswith("https://"):
return
elif self.settings.get("implicit_base_url") and url.startswith("/"):
return
raise errors.UrlError("Unsupported protocol")
def _validate_client(self):
client = self.settings.get("client_name")
if client and self.get_argument("client") != client:
raise errors.ClientError("Invalid client")
def _validate_signature(self):
key = self.settings.get("client_key")
if key and not verify_signature(key, urlparse(self.request.uri).query):
raise errors.SignatureError("Invalid signature")
def _validate_host(self):
hosts = self.settings.get("allowed_hosts", [])
if hosts and urlparse(self.get_argument("url")).hostname not in hosts:
raise errors.HostError("Invalid host")
def parse_command_line(): # pragma: no cover
tornado.options.parse_command_line()
def start_server(app=None): # pragma: no cover
if options.debug:
logger.setLevel(logging.DEBUG)
server = tornado.httpserver.HTTPServer(
app if app else PilboxApplication())
logger.info("Starting server...")
try:
server.bind(options.port)
server.start(1 if options.debug else 0)
tornado.ioloop.IOLoop.instance().start()
except KeyboardInterrupt:
tornado.ioloop.IOLoop.instance().stop()
def main(app=None):
parse_command_line()
start_server(app)
if __name__ == "__main__":
main()