├── .gitignore ├── .gitmodules ├── .travis.yml ├── AUTHORS.txt ├── CHANGELOG.txt ├── LICENSE.txt ├── MANIFEST.in ├── Procfile ├── README.md ├── TODO.txt ├── demo ├── __init__.py ├── main.py └── templates │ └── main.tpl ├── docs ├── Makefile ├── conf.py ├── index.rst └── upload ├── flask_images ├── __init__.py ├── core.py ├── modes.py ├── size.py └── transform.py ├── requirements.txt ├── setup.py └── tests ├── __init__.py ├── assets └── cc.png ├── test_build_url.py ├── test_remote.py ├── test_security.py ├── test_size.py ├── test_template_use.py └── test_url_escaping.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | .DS_Store 3 | 4 | *.egg-info 5 | /dist 6 | MANIFEST 7 | 8 | /venv* 9 | /docs/_build 10 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "docs/_themes"] 2 | path = docs/_themes 3 | url = git@github.com:mitsuhiko/flask-sphinx-themes.git 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | git: 4 | # Testing doesn't need Flask themes. 5 | submodules: false 6 | 7 | matrix: 8 | 9 | include: 10 | 11 | # 3 12 | 13 | - python: "3.7" 14 | env: FLASK_VERSION="" 15 | os: linux 16 | 17 | - python: "3.7" 18 | env: FLASK_VERSION="==1.0" 19 | os: linux 20 | 21 | - python: "3.7" 22 | env: FLASK_VERSION="==0.10" 23 | os: linux 24 | 25 | # 2 26 | 27 | - python: "2.7" 28 | env: FLASK_VERSION="" 29 | os: linux 30 | 31 | - python: "2.7" 32 | env: FLASK_VERSION="==1.0" 33 | os: linux 34 | 35 | - python: "2.7" 36 | env: FLASK_VERSION="==0.10" 37 | os: linux 38 | 39 | - python: "2.7" 40 | env: FLASK_VERSION="==0.9" 41 | os: linux 42 | 43 | # PyPy. 44 | 45 | - python: "pypy" 46 | env: FLASK_VERSION="" 47 | os: linux 48 | 49 | - python: "pypy" 50 | env: FLASK_VERSION="==0.10" 51 | os: linux 52 | 53 | # Older 3s. 54 | 55 | - python: "3.6" 56 | env: FLASK_VERSION="" 57 | os: linux 58 | 59 | - python: "3.5" 60 | env: FLASK_VERSION="" 61 | os: linux 62 | 63 | - python: "3.4" 64 | env: FLASK_VERSION="" 65 | os: linux 66 | 67 | 68 | before_install: 69 | - sudo apt-get install libjpeg8 libjpeg8-dev libfreetype6 libfreetype6-dev zlib1g-dev tree 70 | - sudo ln -s /usr/lib/`uname -i`-linux-gnu/libjpeg.so.8 ~/virtualenv/python2.7/lib/ 71 | - sudo ln -s /usr/lib/`uname -i`-linux-gnu/libfreetype.so ~/virtualenv/python2.7/lib/ 72 | - sudo ln -s /usr/lib/`uname -i`-linux-gnu/libz.so ~/virtualenv/python2.7/lib/ 73 | - pip install nose 74 | 75 | 76 | install: 77 | - pip install Flask$FLASK_VERSION 78 | - pip install -e . 79 | 80 | 81 | script: nosetests 82 | -------------------------------------------------------------------------------- /AUTHORS.txt: -------------------------------------------------------------------------------- 1 | Mike Boers, @mikeboers on GitHub, 2 | Iuri de Silvio, @iurisilvio on GitHub, : Initial `url_for` implementation. 3 | -------------------------------------------------------------------------------- /CHANGELOG.txt: -------------------------------------------------------------------------------- 1 | 2 | 3.0.2 3 | ----- 4 | - Fix a couple Python3 dict API changes. 5 | - More forgiving USM syntax. 6 | - `class_` passed through as image attribute. 7 | - A bit more precision in path escaping. 8 | 9 | 3.0.1 10 | ----- 11 | - Fix Python2 regression in HTTP error handling. 12 | 13 | 3.0.0 14 | ----- 15 | - Python3 support. 16 | - ValueError is raised (instead of whatever different versions of Flask/werkzeuf raise) when `mode` kwarg is specified along with `images.{mode}` route. 17 | - Fix for changing location of functions in dependencies. (#50) 18 | - Fix If-Modified-Since which erroneously had microseconds. (#37) 19 | 20 | 2.1.2 21 | ----- 22 | - Fix for local cache not taking "enlarge" parameter into account. 23 | 24 | 2.1.1 25 | ----- 26 | - Fix for Cache-Control header to be a string (as all headers should be). 27 | 28 | 2.1.0 29 | ----- 30 | - Retina/HiDPI support (still alpha; the API is subject to change). 31 | - Transformation support (still alpha; the API is subject to change). 32 | - `url_for(..., _external=True)` works. 33 | - `enlarge=False` will not enlarge images. 34 | - `resized_img_attrs(...)` to calculate final width and height. 35 | 36 | 2.0.0 37 | ----- 38 | - INCOMPATIBILITY: Many things moved to flask.ext.images.core; `Images` and 39 | `resized_img_src` remain. 40 | - Compatible with Flask 0.9 (from 0.10). 41 | 42 | 1.1.5 43 | ----- 44 | - Background colours are parsed by PIL; hex and html colours are now allowed. 45 | - Fix for extension parsing bugs with URLs. 46 | 47 | 1.1.4 48 | ----- 49 | - Fix regression introduced by 1.1.3 (such that remote images did not work). 50 | 51 | 1.1.3 52 | ----- 53 | - Fix 3 security bugs, each of which would allow for reading any image on disk. 54 | 55 | 1.1.2 56 | ----- 57 | - Depend on Pillow OR PIL. 58 | 59 | 1.1.1 60 | ----- 61 | - Fix bug stopping remote images from working. 62 | 63 | 1.1.0 64 | ----- 65 | - `url_for('images', **kw)` to ease transition from static files. 66 | - Compatible with Pillow. 67 | 68 | 1.0.0 69 | ----- 70 | - First official release! 71 | - Removed lots of backwards compatibility with my own sites: 72 | - restricted default path to ['static']; 73 | - removed synonyms for `resized_img_src`. 74 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright retained by original committers. All rights reserved. 2 | 3 | Redistribution and use in source and binary forms, with or without 4 | modification, are permitted provided that the following conditions are met: 5 | * Redistributions of source code must retain the above copyright 6 | notice, this list of conditions and the following disclaimer. 7 | * Redistributions in binary form must reproduce the above copyright 8 | notice, this list of conditions and the following disclaimer in the 9 | documentation and/or other materials provided with the distribution. 10 | * Neither the name of the project nor the names of its contributors may be 11 | used to endorse or promote products derived from this software without 12 | specific prior written permission. 13 | 14 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 15 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 16 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 17 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS BE LIABLE FOR ANY DIRECT, 18 | INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 19 | BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 20 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY 21 | OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 22 | NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, 23 | EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include AUTHORS.txt 2 | include CHANGELOG.txt 3 | include LICENSE.txt 4 | include README.md 5 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | # For demo app. 2 | web: gunicorn demo.main:app 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Flask-Images 2 | ============ 3 | 4 | [![Travis Build Status][travis-badge]][travis] 5 | 6 | Flask-Images is a Flask extension that provides dynamic image resizing for your application. 7 | 8 | [Read the docs][docs], [try the demo][demo], have fun, and good luck! 9 | 10 | 11 | [travis-badge]: https://img.shields.io/travis/mikeboers/Flask-Images/develop.svg?logo=travis&label=travis 12 | [travis]: https://travis-ci.org/mikeboers/Flask-Images 13 | 14 | [docs]: https://mikeboers.github.io/Flask-Images/ 15 | [demo]: https://flask-images.herokuapp.com/ 16 | -------------------------------------------------------------------------------- /TODO.txt: -------------------------------------------------------------------------------- 1 | - Integrate pngcrush. 2 | - Investigate using a Blueprint. 3 | -------------------------------------------------------------------------------- /demo/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikeboers/Flask-Images/8e6c08e3915a1f1927fb5506b3b202109938a6b8/demo/__init__.py -------------------------------------------------------------------------------- /demo/main.py: -------------------------------------------------------------------------------- 1 | from flask import Flask, request, redirect, render_template 2 | from flask.ext.images import Images 3 | 4 | 5 | app = Flask(__name__) 6 | app.secret_key = 'monkey' 7 | app.debug = True 8 | images = Images(app) 9 | 10 | 11 | @app.route('/') 12 | @app.route('/demo') 13 | def index(): 14 | 15 | url = request.args.get('url') 16 | width = max(0, min(1000, int(request.args.get('width', 200)))) 17 | height = max(0, min(1000, int(request.args.get('height', 200)))) 18 | background = request.args.get('background', '#000000') 19 | transform = request.args.get('transform', '') 20 | enlarge = bool(request.args.get('enlarge')) 21 | return render_template('main.tpl', 22 | url=url, 23 | width=width, 24 | height=height, 25 | background=background, 26 | transform=transform, 27 | enlarge=enlarge, 28 | ) 29 | 30 | 31 | @app.route('/direct/') 32 | def direct(url): 33 | kwargs = {} 34 | for key in ('width', 'height', 'mode', 'quality', 'transform'): 35 | value = request.args.get(key) or request.args.get(key[0]) 36 | if value is not None: 37 | value = int(value) if value.isdigit() else value 38 | kwargs[key] = value 39 | return redirect(images.build_url(url, **kwargs)) 40 | 41 | 42 | if __name__ == '__main__': 43 | app.run(debug=True, use_reloader=True) 44 | -------------------------------------------------------------------------------- /demo/templates/main.tpl: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 13 | 14 | 15 | 16 |
17 | 18 |

Flask-Images Demo

19 | 20 |

Flask-Images is a Flask extension that provides dynamic image resizing for your application. 21 | 22 |

23 | 24 |
25 | 26 | 27 |
28 | 29 |
30 | 31 | 32 |
33 | 34 |
35 |
36 |
37 |
38 | 39 |
40 | 41 | {% if url and (transform or (width and height)) %} 42 |

Results for {{ url }}: 43 | 44 | 45 | 53 | 62 | 71 | 80 |
width={{ width }} 46 | 52 |
width={{ width }}
height={{ height }} 54 |
61 |
width={{ width }}
height={{ height }}
mode='crop' 63 |
70 |
width={{ width }}
height={{ height }}
mode='fit' 72 |
79 |
width={{ width }}
height={{ height }}
mode='pad' 81 |
89 | {% endif %} 90 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | SPHINXOPTS = 2 | SPHINXBUILD = sphinx-build 3 | BUILDDIR = _build 4 | ALLSPHINXOPTS = $(SPHINXOPTS) . 5 | 6 | .PHONY: clean html 7 | 8 | html: 9 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 10 | @echo 11 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 12 | 13 | clean: 14 | rm -rf $(BUILDDIR)/* 15 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | 4 | extensions = [ 5 | 'sphinx.ext.autodoc', 6 | 'sphinx.ext.viewcode', 7 | ] 8 | 9 | templates_path = ['_templates'] 10 | source_suffix = '.rst' 11 | master_doc = 'index' 12 | 13 | 14 | project = u'Flask-Images' 15 | copyright = u'2014, Mike Boers' 16 | 17 | version = '2.1' 18 | release = '2.1.1' 19 | 20 | 21 | exclude_patterns = ['_build'] 22 | 23 | pygments_style = 'sphinx' 24 | 25 | 26 | sys.path.append(os.path.abspath('_themes')) 27 | html_theme_path = ['_themes'] 28 | html_theme = 'flask_small' 29 | html_theme_options = { 30 | 'index_logo': '', 31 | 'github_fork': 'mikeboers/Flask-Images', 32 | } 33 | 34 | html_static_path = ['_static'] 35 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Flask-Images 2 | ============ 3 | 4 | Flask-Images is a Flask extension that provides dynamic image resizing for your application. 5 | 6 | This extension adds a :func:`resized_img_src` function (and :ref:`others `) to the template context, which creates a URL to dynamically resize an image. This function takes either a path to a local image (either absolute, or relative to the :data:`IMAGES_PATH`) or an URL to a remote image, and returns a URL that will serve a resized version on demand. 7 | 8 | Alternatively, this responds to ``url_for('images', filename='...', **kw)`` to ease transition from Flask's static files. 9 | 10 | Try `the demo app`_ (`demo source`_), and see `with an example image`_. 11 | 12 | .. _the demo app: https://flask-images.herokuapp.com 13 | .. _demo source: https://github.com/mikeboers/Flask-Images/blob/master/demo 14 | .. _with an example image: https://flask-images.herokuapp.com/demo?url=https%3A%2F%2Ffarm4.staticflickr.com%2F3540%2F5753968652_a28184e5fb.jpg 15 | 16 | 17 | Usage 18 | ===== 19 | 20 | For example, within a Jinja template: 21 | 22 | :: 23 | 24 | 25 | 26 | OR 27 | 28 | 29 | 30 | 31 | Behaviour is specified with keyword arguments: 32 | 33 | - ``mode``: one of ``'fit'``, ``'crop'``, ``'pad'``, or ``None``: 34 | 35 | - ``'fit'``: as large as possible while fitting within the given dimensions; 36 | 37 | - ``'crop'``: as large as possible while fitting into the given aspect ratio; 38 | 39 | - ``'pad'``: as large as possible while fitting within the given dimensions, 40 | and padding to thegiven dimensions with a background colour; 41 | 42 | - ``None``: resize to the specific dimensions without preserving aspect ratio. 43 | 44 | - ``width`` and ``height``: pixel dimensions; at least one is required, but 45 | both are required for most modes. 46 | 47 | - ``format``: The file extension to use (`as accepted by PIL `_ (or `Pillow `_)); defaults to the 48 | input image's extension. 49 | 50 | - ``quality``: JPEG quality (no effect on non-JPEG images); defaults to `75`. 51 | 52 | - ``background``: Background colour for ``pad`` mode. Expressed via via 53 | CSS names (e.g. ``"black"``), hexadecimal (e.g. ``"#ff8800"``), or 54 | `anything else accepted by PIL `_. 55 | Defaults to ``"black"``. 56 | 57 | - ``enlarge``: Should the image be enlarged to satisfy requested dimensions? E.g. 58 | If you specify ``mode="crop", width=400, height=400, enlarge=True``, but the 59 | image is smaller than 400x400, it will be enlarged to fill that requested size. 60 | Defaults to ``False``. 61 | 62 | - ``transform``: A space-or-comma separated list of a transform method and its values, 63 | `as understood by PIL `_: 64 | E.g. ``"name,width,height,v0,...,vn"``. Width and height of zero will use the image's 65 | native dimensions. Percent values are interpreted as relative to the real size of the appropriate axis. 66 | E.g.: ``transform="EXTENT,50%,50%,25%,25%,75%,75%"`` will crop the center out of the image. 67 | 68 | - ``sharpen``: Space-or-comma separated parameters for an `unsharp mask `_, 69 | `as understood by Pillow `_. 70 | E.g. ``sharpen="0.3,250,2"``. 71 | 72 | - ``hidpi`` and ``hidpi_quality``: A resolution scale for HiDPI or "retina" displays. 73 | Requested dimensions will be multiplied by ``hidpi`` if the image can support it 74 | without enlargement. Any other kwargs matching ``hi_dpi_*`` will also take effect, 75 | e.g. for reducing JPEG quality of HiDPI images, e.g.: ``hi_dpi=2, quality=90, hidpi_quality=60``. 76 | This is only useful with :func:`resized_img_attrs` or :func:`resized_img_tag`. 77 | 78 | 79 | 80 | Installation 81 | ------------ 82 | 83 | From PyPI:: 84 | 85 | pip install Flask-Images 86 | 87 | From GitHub:: 88 | 89 | git clone git@github.com:mikeboers/Flask-Images 90 | pip install -e Flask-Images 91 | 92 | 93 | Usage 94 | ----- 95 | 96 | All you must do is make sure your app has a secret key, then create the 97 | :class:`Images` object:: 98 | 99 | app = Flask(__name__) 100 | app.secret_key = 'monkey' 101 | images = Images(app) 102 | 103 | 104 | Now, use either the :func:`resized_img_src` function in your templates, or the 105 | ``images.`` routes in ``url_for``. 106 | 107 | Can be used within Python after import:: 108 | 109 | from flask.ext.images import resized_img_src 110 | 111 | 112 | 113 | Configuration 114 | ------------- 115 | 116 | Configure Flask-Images via the following keys in the Flask config: 117 | 118 | - .. data:: IMAGES_URL 119 | 120 | The url to mount Flask-Images to; defaults to ``'/imgsizer'``. 121 | 122 | - .. data:: IMAGES_NAME 123 | 124 | The name of the registered endpoint used in url_for. 125 | 126 | - .. data:: IMAGES_PATH 127 | 128 | A list of paths to search for images (relative to ``app.root_path``); e.g. ``['static/uploads']`` 129 | 130 | - .. data:: IMAGES_CACHE 131 | 132 | Where to store resized images; defaults to ``'/tmp/flask-images'``. 133 | 134 | - .. data:: IMAGES_MAX_AGE 135 | 136 | How long to tell the browser to cache missing results; defaults to ``3600``. Usually, we will set a max age of one year, and cache bust via the modification time of the source image. 137 | 138 | 139 | .. _api: 140 | 141 | Template Functions 142 | ------------------ 143 | 144 | .. function:: resized_img_src(filename, **kw) 145 | 146 | Get the URL that will render into a resized image. 147 | 148 | .. function:: resized_img_size(filename, **kw) 149 | 150 | Get a :class:`flask.ext.images.size.Size` object for the given parameters. 151 | 152 | .. function:: resized_img_attrs(filename, **kw) 153 | 154 | Get a ``dict`` of attributes for an HTML ```` tag. 155 | 156 | .. function:: resized_img_tag(filename, **kw) 157 | 158 | Get a ``str`` HTML ```` tag. 159 | 160 | .. 161 | Contents: 162 | .. toctree:: 163 | :maxdepth: 2 164 | Indices and tables 165 | ================== 166 | * :ref:`genindex` 167 | * :ref:`modindex` 168 | * :ref:`search` 169 | 170 | -------------------------------------------------------------------------------- /docs/upload: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | docs="$(cd "$(dirname "${BASH_SOURCE[0]}")"; pwd)" 4 | cd "$docs/_build/html" 5 | 6 | if [[ ! -d .git ]]; then 7 | git init . 8 | fi 9 | 10 | touch .nojekyll 11 | 12 | git add . 13 | git commit -m "$(date)" 14 | 15 | git push -f git@github.com:mikeboers/Flask-Images.git HEAD:gh-pages 16 | 17 | -------------------------------------------------------------------------------- /flask_images/__init__.py: -------------------------------------------------------------------------------- 1 | from .core import Images, resized_img_src, resized_img_size, resized_img_attrs, resized_img_tag 2 | from .size import ImageSize 3 | -------------------------------------------------------------------------------- /flask_images/core.py: -------------------------------------------------------------------------------- 1 | from __future__ import division 2 | 3 | from io import BytesIO as StringIO 4 | from subprocess import call 5 | import base64 6 | import datetime 7 | import errno 8 | import hashlib 9 | import hmac 10 | import html 11 | import logging 12 | import os 13 | import re 14 | import struct 15 | 16 | from six import iteritems, PY3, string_types, text_type 17 | if PY3: 18 | from urllib.parse import urlparse, urlencode, quote as urlquote 19 | from urllib.request import urlopen 20 | from urllib.error import HTTPError 21 | else: 22 | from urlparse import urlparse 23 | from urllib import urlencode, quote as urlquote 24 | from urllib2 import urlopen, HTTPError 25 | 26 | from PIL import Image, ImageFilter 27 | from flask import request, current_app, send_file, abort 28 | 29 | 30 | from . import modes 31 | from .size import ImageSize 32 | from .transform import Transform 33 | 34 | 35 | log = logging.getLogger(__name__) 36 | 37 | 38 | 39 | def encode_str(value): 40 | if isinstance(value, text_type): 41 | return value.encode('utf-8') 42 | return value 43 | 44 | def encode_int(value): 45 | return base64.urlsafe_b64encode(struct.pack('>I', int(value))).decode('utf-8').rstrip('=').lstrip('A') 46 | 47 | 48 | def makedirs(path): 49 | try: 50 | os.makedirs(path) 51 | except OSError as e: 52 | if e.errno != errno.EEXIST: 53 | raise 54 | 55 | 56 | # We must whitelist schemes which are permitted, otherwise craziness (such as 57 | # allowing access to the filesystem) may ensue. 58 | ALLOWED_SCHEMES = set(('http', 'https', 'ftp')) 59 | 60 | # The options which we immediately recognize and shorten. 61 | LONG_TO_SHORT = dict( 62 | background='b', 63 | cache='c', 64 | enlarge='e', 65 | format='f', 66 | height='h', 67 | mode='m', 68 | quality='q', 69 | transform='x', 70 | url='u', 71 | version='v', 72 | width='w', 73 | sharpen='usm', 74 | # signature -> 's', but should not be here. 75 | ) 76 | SHORT_TO_LONG = dict((v, k) for k, v in iteritems(LONG_TO_SHORT)) 77 | 78 | 79 | def as_bytes(x): 80 | if isinstance(x, bytes): 81 | return x 82 | return x.encode('utf8') 83 | 84 | 85 | class Images(object): 86 | 87 | 88 | def __init__(self, app=None): 89 | if app is not None: 90 | self.init_app(app) 91 | 92 | def init_app(self, app): 93 | if not hasattr(app, 'extensions'): 94 | app.extensions = {} 95 | app.extensions['images'] = self 96 | 97 | app.config.setdefault('IMAGES_URL', '/imgsizer') # This is historical. 98 | app.config.setdefault('IMAGES_NAME', 'images') 99 | app.config.setdefault('IMAGES_PATH', ['static']) 100 | app.config.setdefault('IMAGES_CACHE', '/tmp/flask-images') 101 | app.config.setdefault('IMAGES_MAX_AGE', 3600) 102 | 103 | app.add_url_rule(app.config['IMAGES_URL'] + '/', app.config['IMAGES_NAME'], self.handle_request) 104 | app.url_build_error_handlers.append(self.build_error_handler) 105 | 106 | if hasattr(app, 'add_template_global'): # Flask >= 0.10 107 | app.add_template_global(resized_img_src) 108 | app.add_template_global(resized_img_size) 109 | app.add_template_global(resized_img_attrs) 110 | app.add_template_global(resized_img_tag) 111 | else: 112 | ctx = { 113 | 'resized_img_src': resized_img_src, 114 | 'resized_img_size': resized_img_size, 115 | 'resized_img_attrs': resized_img_attrs, 116 | 'resized_img_tag': resized_img_tag, 117 | } 118 | app.context_processor(lambda: ctx) 119 | 120 | 121 | def build_error_handler(self, error, endpoint, values): 122 | 123 | # See if we were asked for "images" or "images.". 124 | m = re.match(r'^%s(?:\.(%s))?$' % ( 125 | re.escape(current_app.config['IMAGES_NAME']), 126 | '|'.join(re.escape(mode) for mode in modes.ALL) 127 | ), endpoint) 128 | if m: 129 | 130 | filename = values.pop('filename') 131 | 132 | mode = m.group(1) 133 | if mode: 134 | # There used to be a TypeError here that werkzeug would generate, 135 | # if there was already a "mode" but it seems that has changed in 136 | # newer versions, so lets just take care of it ourselves. 137 | if 'mode' in values: 138 | raise ValueError("`mode` is specified in endpoint and kwargs.") 139 | values['mode'] = mode 140 | 141 | return self.build_url(filename, **values) 142 | 143 | return None 144 | 145 | def build_url(self, local_path, **kwargs): 146 | 147 | # Make the path relative. 148 | local_path = local_path.strip('/') 149 | 150 | # We complain when we see non-normalized paths, as it is a good 151 | # indicator that unsanitized data may be getting through. 152 | # Mutating the scheme syntax to match is a little gross, but it works 153 | # for today. 154 | norm_path = os.path.normpath(local_path) 155 | if local_path.replace('://', ':/') != norm_path or norm_path.startswith('../'): 156 | raise ValueError('path is not normalized') 157 | 158 | external = kwargs.pop('external', None) or kwargs.pop('_external', None) 159 | scheme = kwargs.pop('scheme', None) 160 | if scheme and not external: 161 | raise ValueError('cannot specify scheme without external=True') 162 | if kwargs.get('_anchor'): 163 | raise ValueError('images have no _anchor') 164 | if kwargs.get('_method'): 165 | raise ValueError('images have no _method') 166 | 167 | # Remote URLs are encoded into the query. 168 | parsed = urlparse(local_path) 169 | if parsed.scheme or parsed.netloc: 170 | if parsed.scheme not in ALLOWED_SCHEMES: 171 | raise ValueError('scheme %r is not allowed' % parsed.scheme) 172 | kwargs['url'] = local_path 173 | local_path = '_' # Must be something. 174 | 175 | # Local ones are not. 176 | else: 177 | abs_path = self.find_img(local_path) 178 | if abs_path: 179 | kwargs['version'] = encode_int(int(os.path.getmtime(abs_path))) 180 | 181 | # Prep the cache flag, which defaults to True. 182 | cache = kwargs.pop('cache', True) 183 | if not cache: 184 | kwargs['cache'] = '' 185 | 186 | # Prep the enlarge flag, which defaults to False. 187 | enlarge = kwargs.pop('enlarge', False) 188 | if enlarge: 189 | kwargs['enlarge'] = '1' 190 | 191 | # Prep the transform, which is a set of delimited strings. 192 | transform = kwargs.get('transform') 193 | if transform: 194 | if isinstance(transform, string_types): 195 | transform = re.split(r'[,;:_ ]', transform) 196 | # We replace delimiters with underscores, and percent with p, since 197 | # these won't need escaping. 198 | kwargs['transform'] = '_'.join(str(x).replace('%', 'p') for x in transform) 199 | 200 | # Sign the query. 201 | # Collapse to a dict first so that if we accidentally have two of the 202 | # same kwarg (e.g. used `hidpi_sharpen` and `usm` which both turn into `usm`). 203 | public_kwargs = { 204 | LONG_TO_SHORT.get(k, k): v 205 | for k, v in iteritems(kwargs) 206 | if v is not None and not k.startswith('_') 207 | } 208 | query = urlencode(sorted(iteritems(public_kwargs)), True) 209 | sig_auth = hmac.new(as_bytes(current_app.secret_key), 210 | f'{local_path}?{query}'.encode('utf-8'), 211 | digestmod='sha256' 212 | ) 213 | 214 | # I am using hexdigest to increase readability in warnings. 215 | url = '%s/%s?%s&s=%s' % ( 216 | current_app.config['IMAGES_URL'], 217 | urlquote(local_path, "/$-_.+!*'(),"), 218 | query, 219 | sig_auth.hexdigest(), 220 | ) 221 | 222 | if external: 223 | url = '%s://%s%s/%s' % ( 224 | scheme or request.scheme, 225 | request.host, 226 | request.script_root, 227 | url.lstrip('/') 228 | ) 229 | 230 | return url 231 | 232 | def find_img(self, local_path): 233 | local_path = os.path.normpath(local_path.lstrip('/')) 234 | for path_base in current_app.config['IMAGES_PATH']: 235 | path = os.path.join(current_app.root_path, path_base, local_path) 236 | if os.path.exists(path): 237 | return path 238 | 239 | def calculate_size(self, path, **kw): 240 | path = self.find_img(path) 241 | if not path: 242 | abort(404) 243 | return ImageSize(path=path, **kw) 244 | 245 | def resize(self, image, background=None, **kw): 246 | 247 | size = ImageSize(image=image, **kw) 248 | 249 | # Get into the right colour space. 250 | if not image.mode.upper().startswith('RGB'): 251 | image = image.convert('RGBA') 252 | 253 | # Apply any requested transform. 254 | if size.transform: 255 | image = Transform(size.transform, image.size).apply(image) 256 | 257 | # Handle the easy cases. 258 | if size.mode in (modes.RESHAPE, None) or size.req_width is None or size.req_height is None: 259 | return image.resize((size.width, size.height), Image.LANCZOS) 260 | 261 | if size.mode not in (modes.FIT, modes.PAD, modes.CROP): 262 | raise ValueError('unknown mode %r' % size.mode) 263 | 264 | if image.size != (size.op_width, size.op_height): 265 | image = image.resize((size.op_width, size.op_height), Image.LANCZOS) 266 | 267 | if size.mode == modes.FIT: 268 | return image 269 | 270 | elif size.mode == modes.PAD: 271 | pad_color = str(background or 'black') 272 | padded = Image.new('RGBA', (size.width, size.height), pad_color) 273 | padded.paste(image, ( 274 | (size.width - size.op_width ) // 2, 275 | (size.height - size.op_height) // 2 276 | )) 277 | return padded 278 | 279 | elif size.mode == modes.CROP: 280 | 281 | dx = (size.op_width - size.width ) // 2 282 | dy = (size.op_height - size.height) // 2 283 | return image.crop( 284 | (dx, dy, dx + size.width, dy + size.height) 285 | ) 286 | 287 | else: 288 | raise RuntimeError('unhandled mode %r' % size.mode) 289 | 290 | def post_process(self, image, sharpen=None): 291 | 292 | if sharpen: 293 | assert len(sharpen) == 3, 'unsharp-mask has 3 parameters' 294 | image = image.filter(ImageFilter.UnsharpMask( 295 | float(sharpen[0]), 296 | int(sharpen[1]), 297 | int(sharpen[2]), 298 | )) 299 | 300 | return image 301 | 302 | def handle_request(self, path): 303 | 304 | # Verify the signature. 305 | query = dict(iteritems(request.args)) 306 | old_sig = bytes(query.pop('s', None), 'utf-8') 307 | if not old_sig: 308 | abort(404) 309 | query_info = urlencode(sorted(iteritems(query)), True) 310 | msg_auth = hmac.new(as_bytes(current_app.secret_key), 311 | f'{path}?{query_info}'.encode('utf-8'), 312 | digestmod='sha256', 313 | ) 314 | new_sig = bytes(msg_auth.hexdigest(), 'utf-8') 315 | if not hmac.compare_digest(old_sig, new_sig): 316 | log.warning(f"Signature mismatch: url's {old_sig} != expected {new_sig}") 317 | abort(404) 318 | 319 | # Expand kwargs. 320 | 321 | query = dict((SHORT_TO_LONG.get(k, k), v) for k, v in iteritems(query)) 322 | remote_url = query.get('url') 323 | if remote_url: 324 | 325 | # This is redundant for newly built URLs, but not for those which 326 | # have already been generated and cached. 327 | parsed = urlparse(remote_url) 328 | if parsed.scheme not in ALLOWED_SCHEMES: 329 | abort(404) 330 | 331 | # Download the remote file. 332 | makedirs(current_app.config['IMAGES_CACHE']) 333 | path = os.path.join( 334 | current_app.config['IMAGES_CACHE'], 335 | hashlib.md5(encode_str(remote_url)).hexdigest() + os.path.splitext(parsed.path)[1] 336 | ) 337 | 338 | if not os.path.exists(path): 339 | log.info('downloading %s' % remote_url) 340 | tmp_path = path + '.tmp-' + str(os.getpid()) 341 | try: 342 | remote_file = urlopen(remote_url).read() 343 | except HTTPError as e: 344 | # abort with remote error code (403 or 404 most times) 345 | # log.debug('HTTP Error: %r' % e) 346 | abort(e.code) 347 | else: 348 | fh = open(tmp_path, 'wb') 349 | fh.write(remote_file) 350 | fh.close() 351 | call(['mv', tmp_path, path]) 352 | else: 353 | path = self.find_img(path) 354 | if not path: 355 | abort(404) # Not found. 356 | 357 | raw_mtime = os.path.getmtime(path) 358 | mtime = datetime.datetime.utcfromtimestamp(raw_mtime).replace(microsecond=0) 359 | # log.debug('last_modified: %r' % mtime) 360 | # log.debug('if_modified_since: %r' % request.if_modified_since) 361 | if request.if_modified_since and request.if_modified_since >= mtime: 362 | return '', 304 363 | 364 | mode = query.get('mode') 365 | 366 | transform = query.get('transform') 367 | transform = re.split(r'[;,_/ ]', transform) if transform else None 368 | 369 | background = query.get('background') 370 | width = query.get('width') 371 | width = int(width) if width else None 372 | height = query.get('height') 373 | height = int(height) if height else None 374 | quality = query.get('quality') 375 | quality = int(quality) if quality else 75 376 | format = (query.get('format', '') or os.path.splitext(path)[1][1:] or 'jpeg').lower() 377 | format = {'jpg' : 'jpeg'}.get(format, format) 378 | has_version = 'version' in query 379 | use_cache = query.get('cache', True) 380 | enlarge = query.get('enlarge', False) 381 | 382 | sharpen = query.get('sharpen') 383 | sharpen = re.split(r'[+:;,_/ ]', sharpen) if sharpen else None 384 | 385 | if use_cache: 386 | 387 | # The parts in this initial list were parameters cached in version 1. 388 | # In order to avoid regenerating all images when a new feature is 389 | # added, we append (feature_name, value) tuples to the end. 390 | cache_key_parts = [path, mode, width, height, quality, format, background] 391 | if transform: 392 | cache_key_parts.append(('transform', transform)) 393 | if sharpen: 394 | cache_key_parts.append(('sharpen', sharpen)) 395 | if enlarge: 396 | cache_key_parts.append(('enlarge', enlarge)) 397 | 398 | 399 | cache_key = hashlib.md5(repr(tuple(cache_key_parts)).encode('utf-8')).hexdigest() 400 | cache_dir = os.path.join(current_app.config['IMAGES_CACHE'], cache_key[:2]) 401 | cache_path = os.path.join(cache_dir, cache_key + '.' + format) 402 | cache_mtime = os.path.getmtime(cache_path) if os.path.exists(cache_path) else None 403 | 404 | mimetype = 'image/%s' % format 405 | max_age = 31536000 if has_version else current_app.config['IMAGES_MAX_AGE'] 406 | 407 | if not use_cache or not cache_mtime or cache_mtime < raw_mtime: 408 | 409 | log.info('resizing %r for %s' % (path, query)) 410 | image = Image.open(path) 411 | image = self.resize(image, 412 | background=background, 413 | enlarge=enlarge, 414 | height=height, 415 | mode=mode, 416 | transform=transform, 417 | width=width, 418 | ) 419 | image = self.post_process(image, 420 | sharpen=sharpen, 421 | ) 422 | 423 | if not use_cache: 424 | fh = StringIO() 425 | image.save(fh, format, quality=quality) 426 | return fh.getvalue(), 200, [ 427 | ('Content-Type', mimetype), 428 | ('Cache-Control', str(max_age)), 429 | ] 430 | 431 | makedirs(cache_dir) 432 | cache_file = open(cache_path, 'wb') 433 | image.save(cache_file, format, quality=quality) 434 | cache_file.close() 435 | 436 | return send_file(cache_path, mimetype=mimetype, max_age=max_age) 437 | 438 | 439 | 440 | def resized_img_size(path, **kw): 441 | self = current_app.extensions['images'] 442 | return self.calculate_size(path, **kw) 443 | 444 | def resized_img_attrs(path, hidpi=None, width=None, height=None, enlarge=False, **kw): 445 | 446 | self = current_app.extensions['images'] 447 | 448 | page = image = self.calculate_size( 449 | path, 450 | width=width, 451 | height=height, 452 | enlarge=enlarge, 453 | _shortcut=True, 454 | **kw 455 | ) 456 | 457 | if hidpi: 458 | 459 | hidpi_size = self.calculate_size( 460 | path, 461 | width=hidpi * width if width else None, 462 | height=hidpi * height if height else None, 463 | enlarge=enlarge, 464 | _shortcut=True, 465 | **kw 466 | ) 467 | 468 | # If the larger size works. 469 | if enlarge or not hidpi_size.needs_enlarge: 470 | image = hidpi_size 471 | 472 | for k, v in list(kw.items()): 473 | if k.startswith('hidpi_'): 474 | kw.pop(k) 475 | kw[k[6:]] = v 476 | 477 | kw.setdefault('quality', 60) 478 | 479 | else: 480 | hidpi = False 481 | 482 | return { 483 | 484 | 'data-hidpi-scale': hidpi, 485 | 'data-original-width': image.image_width, 486 | 'data-original-height': image.image_height, 487 | 488 | 'width': page.width, 489 | 'height': page.height, 490 | 'src': self.build_url( 491 | path, 492 | width=int(image.req_width) if image.req_width else image.req_width, 493 | height=int(image.req_height) if image.req_height else image.req_height, 494 | enlarge=enlarge, 495 | **kw 496 | ), 497 | 498 | } 499 | 500 | 501 | def resized_img_tag(path, **kw): 502 | attrs = {} 503 | for attr, key in (('class', 'class_'), ): 504 | try: 505 | attrs[attr] = kw.pop(key) 506 | except KeyError: 507 | pass 508 | attrs.update(resized_img_attrs(path, **kw)) 509 | return '' % ' '.join('%s="%s"' % (k, html.escape(str(v))) for k, v in sorted(iteritems(attrs))) 510 | 511 | 512 | def resized_img_src(path, **kw): 513 | self = current_app.extensions['images'] 514 | return self.build_url(path, **kw) 515 | 516 | 517 | -------------------------------------------------------------------------------- /flask_images/modes.py: -------------------------------------------------------------------------------- 1 | 2 | FIT = 'fit' 3 | CROP = 'crop' 4 | PAD = 'pad' 5 | RESHAPE = 'reshape' 6 | ALL = (FIT, CROP, PAD, RESHAPE) 7 | -------------------------------------------------------------------------------- /flask_images/size.py: -------------------------------------------------------------------------------- 1 | from __future__ import division 2 | 3 | from PIL import Image 4 | 5 | from . import modes 6 | from .transform import Transform 7 | 8 | 9 | class ImageSize(object): 10 | 11 | @property 12 | def image(self): 13 | if not self._image and self.path: 14 | self._image = Image.open(self.path) 15 | return self._image 16 | 17 | def __init__(self, path=None, image=None, width=None, height=None, 18 | enlarge=True, mode=None, transform=None, sharpen=None, _shortcut=False, **kw 19 | ): 20 | 21 | # Inputs. 22 | self.__dict__.update(kw) 23 | self.path = path 24 | self._image = image 25 | self.req_width = width 26 | self.req_height = height 27 | self.enlarge = bool(enlarge) 28 | self.mode = mode 29 | self.transform = transform 30 | self.sharpen = sharpen 31 | 32 | self.image_width = self.image_height = None 33 | 34 | # Results to be updated as appropriate. 35 | self.needs_enlarge = None 36 | self.width = width 37 | self.height = height 38 | self.op_width = None 39 | self.op_height = None 40 | 41 | if _shortcut and width and height and enlarge and mode in (modes.RESHAPE, modes.CROP, None): 42 | return 43 | 44 | # Source the original image dimensions. 45 | if self.transform: 46 | self.image_width, self.image_height = Transform(self.transform, 47 | self.image.size if self.image else (width, height) 48 | ).size 49 | else: 50 | self.image_width, self.image_height = self.image.size 51 | 52 | # It is possible that no dimensions are even given, so pass it through. 53 | if not (self.width or self.height): 54 | self.width = self.image_width 55 | self.height = self.image_height 56 | return 57 | 58 | # Maintain aspect ratio and scale width. 59 | if not self.height: 60 | self.needs_enlarge = self.width > self.image_width 61 | if not self.enlarge: 62 | self.width = min(self.width, self.image_width) 63 | self.height = self.image_height * self.width // self.image_width 64 | return 65 | 66 | # Maintain aspect ratio and scale height. 67 | if not self.width: 68 | self.needs_enlarge = self.height > self.image_height 69 | if not self.enlarge: 70 | self.height = min(self.height, self.image_height) 71 | self.width = self.image_width * self.height // self.image_height 72 | return 73 | 74 | # Don't maintain aspect ratio; enlarging is sloppy here. 75 | if self.mode in (modes.RESHAPE, None): 76 | self.needs_enlarge = self.width > self.image_width or self.height > self.image_height 77 | if not self.enlarge: 78 | self.width = min(self.width, self.image_width) 79 | self.height = min(self.height, self.image_height) 80 | return 81 | 82 | if self.mode not in (modes.FIT, modes.CROP, modes.PAD): 83 | raise ValueError('unknown mode %r' % self.mode) 84 | 85 | # This effectively gives us the dimensions of scaling to fit within or 86 | # around the requested size. These are always scaled to fit. 87 | fit, pre_crop = sorted([ 88 | (self.req_width, self.image_height * self.req_width // self.image_width), 89 | (self.image_width * self.req_height // self.image_height, self.req_height) 90 | ]) 91 | 92 | self.op_width, self.op_height = fit if self.mode in (modes.FIT, modes.PAD) else pre_crop 93 | self.needs_enlarge = self.op_width > self.image_width or self.op_height > self.image_height 94 | 95 | if self.needs_enlarge and not self.enlarge: 96 | self.op_width = min(self.op_width, self.image_width) 97 | self.op_height = min(self.op_height, self.image_height) 98 | if self.mode != modes.PAD: 99 | self.width = min(self.width, self.image_width) 100 | self.height = min(self.height, self.image_height) 101 | return 102 | 103 | if self.mode != modes.PAD: 104 | self.width = min(self.op_width, self.width) 105 | self.height = min(self.op_height, self.height) 106 | 107 | 108 | 109 | -------------------------------------------------------------------------------- /flask_images/transform.py: -------------------------------------------------------------------------------- 1 | from PIL import Image 2 | from six import moves, string_types 3 | 4 | 5 | TRANSFORM_AXIS = { 6 | Image.EXTENT: (0, 1, 0, 1), 7 | Image.AFFINE: (None, None, 0, None, None, 1), 8 | Image.QUAD: (0, 1, 0, 1, 0, 1, 0, 1), 9 | Image.PERSPECTIVE: (None, None, None, None, None, None, None, None), 10 | # Image.MESH: ??? 11 | } 12 | 13 | 14 | class Transform(list): 15 | 16 | def __init__(self, spec, image_size=None): 17 | 18 | super(Transform, self).__init__(spec) 19 | 20 | self.flag = getattr(Image, self[0].upper()) 21 | try: 22 | axis = (None, 0, 1) + TRANSFORM_AXIS[self.flag] 23 | except KeyError: 24 | raise ValueError('unknown transform %r' % self[0]) 25 | 26 | if len(self) != len(axis): 27 | raise ValueError('expected %d transform values; got %d' % (len(axis), len(self))) 28 | 29 | for i in moves.range(1, len(self)): 30 | v = self[i] 31 | if isinstance(v, string_types): 32 | if v[-1:] in ('%', 'p'): # Percentages. 33 | if axis[i] is None: 34 | raise ValueError('unknown dimension for %s value %d' % (self[0], i)) 35 | if image_size is None: 36 | raise ValueError('no image size with relative transform') 37 | self[i] = image_size[axis[i]] * float(v[:-1]) / 100 38 | else: 39 | self[i] = float(v) 40 | 41 | # Finalize the size. 42 | if not self[1] or not self[2]: 43 | if not image_size: 44 | ValueError('no image size or transform size') 45 | self[1] = int(self[1] or image_size[0]) 46 | self[2] = int(self[2] or image_size[1]) 47 | 48 | @property 49 | def size(self): 50 | return self[1], self[2] 51 | 52 | def apply(self, image): 53 | return image.transform( 54 | (int(self[1] or image.size[0]), int(self[2] or image.size[1])), 55 | self.flag, 56 | self[3:], 57 | Image.BILINEAR, 58 | ) 59 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # Demo requirements. 2 | gunicorn 3 | -e . 4 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup( 4 | 5 | name='Flask-Images', 6 | version='3.0.2', 7 | description='Dynamic image resizing for Flask.', 8 | url='http://github.com/mikeboers/Flask-Images', 9 | 10 | author='Mike Boers', 11 | author_email='flask_images@mikeboers.com', 12 | license='BSD-3', 13 | 14 | packages=['flask_images'], 15 | 16 | install_requires=[ 17 | 18 | 'Flask>=0.9', 19 | 'itsdangerous', # For Flask v0.9 20 | 21 | # We need either PIL, or the newer Pillow. Since this may induce some 22 | # dependency madness, I have created a module that should flatten that 23 | # out. See: https://github.com/mikeboers/Flask-Images/pull/10 for more. 24 | 'PillowCase', 25 | 26 | 'six', 27 | 28 | ], 29 | 30 | classifiers=[ 31 | 'Development Status :: 5 - Production/Stable', 32 | 'Intended Audience :: Developers', 33 | 'License :: OSI Approved :: BSD License', 34 | 'Natural Language :: English', 35 | 'Operating System :: OS Independent', 36 | 'Programming Language :: Python :: 2', 37 | 'Programming Language :: Python :: 3', 38 | 'Topic :: Software Development :: Libraries :: Python Modules', 39 | ], 40 | 41 | tests_require=[ 42 | 'nose>=1.0', 43 | ], 44 | test_suite='nose.collector', 45 | 46 | zip_safe=False, 47 | ) 48 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import sys 3 | from six import PY3 4 | if PY3: 5 | from urllib.parse import urlsplit, parse_qsl 6 | else: 7 | from urlparse import urlsplit, parse_qsl 8 | 9 | import werkzeug as wz 10 | 11 | from flask import Flask, url_for, render_template_string 12 | import flask 13 | 14 | from flask_images import Images, ImageSize, resized_img_src 15 | 16 | 17 | flask_version = tuple(map(int, flask.__version__.split('.'))) 18 | 19 | 20 | class TestCase(unittest.TestCase): 21 | 22 | def setUp(self): 23 | self.app = self.create_app() 24 | self.app_ctx = self.app.app_context() 25 | self.app_ctx.push() 26 | self.req_ctx = self.app.test_request_context('http://localhost:8000/') 27 | self.req_ctx.push() 28 | self.client = self.app.test_client() 29 | 30 | def create_app(self): 31 | app = Flask(__name__) 32 | app.config['TESTING'] = True 33 | app.config['SERVER_NAME'] = 'localhost' 34 | app.config['SECRET_KEY'] = 'secret secret' 35 | app.config['IMAGES_PATH'] = ['assets'] 36 | self.images = Images(app) 37 | return app 38 | 39 | def assert200(self, res): 40 | self.assertEqual(res.status_code, 200) 41 | 42 | -------------------------------------------------------------------------------- /tests/assets/cc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikeboers/Flask-Images/8e6c08e3915a1f1927fb5506b3b202109938a6b8/tests/assets/cc.png -------------------------------------------------------------------------------- /tests/test_build_url.py: -------------------------------------------------------------------------------- 1 | from . import * 2 | 3 | 4 | class TestUrlBuild(TestCase): 5 | 6 | def test_default_mode(self): 7 | 8 | url = url_for('images', filename='cc.png', width=5, mode='crop') 9 | 10 | parsed_url = urlsplit(url) 11 | query_args = dict(parse_qsl(parsed_url.query)) 12 | 13 | self.assertEqual('/imgsizer/cc.png', parsed_url.path) 14 | self.assertEqual('crop', query_args['m']) 15 | self.assertEqual('5', query_args['w']) 16 | self.assertIn('s', query_args) 17 | 18 | response = self.client.get(url) 19 | self.assert200(response) 20 | 21 | def test_explicit_modes(self): 22 | 23 | for mode in 'crop', 'fit', 'pad', 'reshape': 24 | 25 | url = url_for('images.%s' % mode, filename='cc.png', width=5) 26 | response = self.client.get(url) 27 | self.assert200(response) 28 | 29 | parsed_url = urlsplit(url) 30 | query_args = dict(parse_qsl(parsed_url.query)) 31 | 32 | self.assertEqual('/imgsizer/cc.png', parsed_url.path) 33 | self.assertEqual(mode, query_args['m']) 34 | self.assertEqual('5', query_args['w']) 35 | self.assertIn('s', query_args) 36 | 37 | def test_too_many_modes(self): 38 | self.assertRaises(ValueError, url_for, 'images.crop', filename='cc.png', mode='reshape') 39 | 40 | def test_external(self): 41 | 42 | netloc = 'localhost:8000' if flask_version >= (0, 10) else 'localhost' 43 | 44 | url = url_for('images', filename='cc.png', width=5, mode='crop', _external=True) 45 | parsed_url = urlsplit(url) 46 | self.assertEqual(parsed_url.scheme, 'http') 47 | self.assertEqual(parsed_url.netloc, netloc) 48 | 49 | url = url_for('images', filename='cc.png', width=5, mode='crop', _external=True, scheme='https') 50 | parsed_url = urlsplit(url) 51 | self.assertEqual(parsed_url.scheme, 'https') 52 | self.assertEqual(parsed_url.netloc, netloc) 53 | 54 | url = resized_img_src('cc.png', width=5, mode='crop', external=True) 55 | parsed_url = urlsplit(url) 56 | self.assertEqual(parsed_url.scheme, 'http') 57 | self.assertEqual(parsed_url.netloc, netloc) 58 | 59 | url = resized_img_src('cc.png', width=5, mode='crop', external=True, scheme='https') 60 | parsed_url = urlsplit(url) 61 | self.assertEqual(parsed_url.scheme, 'https') 62 | self.assertEqual(parsed_url.netloc, netloc) 63 | 64 | def test_no_cache(self): 65 | url = url_for('images.crop', filename='cc.png', width=5, cache=False) 66 | response = self.client.get(url) 67 | self.assert200(response) 68 | -------------------------------------------------------------------------------- /tests/test_remote.py: -------------------------------------------------------------------------------- 1 | from . import * 2 | 3 | 4 | class TestRemote(TestCase): 5 | 6 | 7 | def test_basics(self): 8 | 9 | url = url_for('images', filename='https://httpbin.org/image/jpeg', mode='crop', height=100, width=100) 10 | 11 | parsed_url = urlsplit(url) 12 | query_args = dict(parse_qsl(parsed_url.query)) 13 | 14 | self.assertEqual(parsed_url.path, '/imgsizer/_') 15 | self.assertEqual(query_args.get('u'), 'https://httpbin.org/image/jpeg') 16 | 17 | response = self.client.get(url) 18 | self.assert200(response) 19 | 20 | def test_failure(self): 21 | 22 | url = url_for('images', filename='https://httpbin.org/status/418', mode='crop', height=100, width=100) 23 | response = self.client.get(url) 24 | 25 | self.assertEqual(response.status_code, 418) 26 | 27 | -------------------------------------------------------------------------------- /tests/test_security.py: -------------------------------------------------------------------------------- 1 | from . import * 2 | 3 | 4 | class TestSecurity(TestCase): 5 | 6 | def test_path_normalization(self): 7 | self.assertRaises(ValueError, url_for, 'images', filename='/etc//passwd') 8 | self.assertRaises(ValueError, url_for, 'images', filename='/./etc/passwd') 9 | self.assertRaises(ValueError, url_for, 'images', filename='/etc/./passwd') 10 | self.assertRaises(ValueError, url_for, 'images', filename='/something/../etc/passwd') 11 | self.assertRaises(ValueError, url_for, 'images', filename='../etc/passwd') 12 | self.assertRaises(ValueError, url_for, 'images', filename='http://example.com/../photo.jpg') 13 | 14 | def test_invalid_scheme(self): 15 | self.assertRaises(ValueError, url_for, 'images', filename='file:///etc/passwd') 16 | 17 | def test_invalid_scheme_with_netloc(self): 18 | self.assertRaises(ValueError, url_for, 'images', filename='file://../etc/passwd') 19 | 20 | def test_valid_paths(self): 21 | url_for('images', filename='http://example.com/photo.jpg') 22 | url_for('images', filename='relative/path.jpg') 23 | url_for('images', filename='/absolute/path.jpg') 24 | 25 | -------------------------------------------------------------------------------- /tests/test_size.py: -------------------------------------------------------------------------------- 1 | from . import * 2 | 3 | 4 | def make_transform(width=100, height=100): 5 | return ['EXTENT', width, height, 0, 0, 100, 100] 6 | 7 | class TestImageSize(TestCase): 8 | 9 | def test_reshape(self): 10 | 11 | s = ImageSize(transform=make_transform(), width=50) 12 | self.assertFalse(s.needs_enlarge) 13 | self.assertEqual(s.width, 50) 14 | self.assertEqual(s.height, 50) 15 | 16 | s = ImageSize(transform=make_transform(), width=200, enlarge=True) 17 | self.assertTrue(s.needs_enlarge) 18 | self.assertEqual(s.width, 200) 19 | self.assertEqual(s.height, 200) 20 | 21 | s = ImageSize(transform=make_transform(), width=200, enlarge=False) 22 | self.assertTrue(s.needs_enlarge) 23 | self.assertEqual(s.width, 100) 24 | self.assertEqual(s.height, 100) 25 | 26 | s = ImageSize(transform=make_transform(), height=50) 27 | self.assertFalse(s.needs_enlarge) 28 | self.assertEqual(s.width, 50) 29 | self.assertEqual(s.height, 50) 30 | 31 | s = ImageSize(transform=make_transform(), height=200, enlarge=True) 32 | self.assertTrue(s.needs_enlarge) 33 | self.assertEqual(s.width, 200) 34 | self.assertEqual(s.height, 200) 35 | 36 | s = ImageSize(transform=make_transform(), height=200, enlarge=False) 37 | self.assertTrue(s.needs_enlarge) 38 | self.assertEqual(s.width, 100) 39 | self.assertEqual(s.height, 100) 40 | 41 | 42 | def test_crop_enlarge(self): 43 | 44 | # Both need enlarging. 45 | s = ImageSize(transform=make_transform(), width=150, height=200, mode='crop', enlarge=True) 46 | self.assertTrue(s.needs_enlarge) 47 | self.assertEqual(s.width, 150) 48 | self.assertEqual(s.height, 200) 49 | self.assertEqual(s.op_width, 200) 50 | self.assertEqual(s.op_height, 200) 51 | 52 | # One needs enlarging. 53 | s = ImageSize(transform=make_transform(200, 100), width=150, height=200, mode='crop', enlarge=True) 54 | self.assertTrue(s.needs_enlarge) 55 | self.assertEqual(s.width, 150) 56 | self.assertEqual(s.height, 200) 57 | self.assertEqual(s.op_width, 400) 58 | self.assertEqual(s.op_height, 200) 59 | 60 | # Neither need enlarging. 61 | s = ImageSize(transform=make_transform(400, 400), width=150, height=200, mode='crop', enlarge=True) 62 | self.assertFalse(s.needs_enlarge) 63 | self.assertEqual(s.width, 150) 64 | self.assertEqual(s.height, 200) 65 | self.assertEqual(s.op_width, 200) 66 | self.assertEqual(s.op_height, 200) 67 | 68 | def test_crop_no_enlarge(self): 69 | 70 | # Both need enlarging. 71 | s = ImageSize(transform=make_transform(), width=150, height=200, mode='crop', enlarge=False) 72 | self.assertTrue(s.needs_enlarge) 73 | self.assertEqual(s.width, 100) 74 | self.assertEqual(s.height, 100) 75 | self.assertEqual(s.op_width, 100) 76 | self.assertEqual(s.op_height, 100) 77 | 78 | # One needs enlarging. 79 | s = ImageSize(transform=make_transform(200, 100), width=150, height=200, mode='crop', enlarge=False) 80 | self.assertTrue(s.needs_enlarge) 81 | self.assertEqual(s.width, 150) 82 | self.assertEqual(s.height, 100) # <-- 83 | self.assertEqual(s.op_width, 200) 84 | self.assertEqual(s.op_height, 100) 85 | 86 | # Neither need enlarging. 87 | s = ImageSize(transform=make_transform(400, 400), width=150, height=200, mode='crop', enlarge=False) 88 | self.assertFalse(s.needs_enlarge) 89 | self.assertEqual(s.width, 150) 90 | self.assertEqual(s.height, 200) 91 | self.assertEqual(s.op_width, 200) 92 | self.assertEqual(s.op_height, 200) 93 | 94 | def test_fit_enlarge(self): 95 | 96 | # Both need enlarging. 97 | s = ImageSize(transform=make_transform(), width=150, height=200, mode='fit', enlarge=True) 98 | self.assertTrue(s.needs_enlarge) 99 | self.assertEqual(s.width, 150) 100 | self.assertEqual(s.height, 150) 101 | self.assertEqual(s.op_width, 150) 102 | self.assertEqual(s.op_height, 150) 103 | 104 | # One is big enough. 105 | s = ImageSize(transform=make_transform(200, 100), width=150, height=200, mode='fit', enlarge=True) 106 | self.assertFalse(s.needs_enlarge) 107 | self.assertEqual(s.width, 150) 108 | self.assertEqual(s.height, 75) 109 | self.assertEqual(s.op_width, 150) 110 | self.assertEqual(s.op_height, 75) 111 | 112 | # Neither need enlarging. 113 | s = ImageSize(transform=make_transform(400, 400), width=150, height=200, mode='fit', enlarge=True) 114 | self.assertFalse(s.needs_enlarge) 115 | self.assertEqual(s.width, 150) 116 | self.assertEqual(s.height, 150) 117 | self.assertEqual(s.op_width, 150) 118 | self.assertEqual(s.op_height, 150) 119 | 120 | def test_fit_no_enlarge(self): 121 | 122 | # Both need enlarging. 123 | s = ImageSize(transform=make_transform(), width=150, height=200, mode='fit', enlarge=False) 124 | self.assertTrue(s.needs_enlarge) 125 | self.assertEqual(s.width, 100) 126 | self.assertEqual(s.height, 100) 127 | self.assertEqual(s.op_width, 100) 128 | self.assertEqual(s.op_height, 100) 129 | 130 | # One is big enough. 131 | s = ImageSize(transform=make_transform(200, 100), width=150, height=200, mode='fit', enlarge=False) 132 | self.assertFalse(s.needs_enlarge) 133 | self.assertEqual(s.width, 150) 134 | self.assertEqual(s.height, 75) # <-- 135 | self.assertEqual(s.op_width, 150) 136 | self.assertEqual(s.op_height, 75) 137 | 138 | # Neither need enlarging. 139 | s = ImageSize(transform=make_transform(400, 400), width=150, height=200, mode='fit', enlarge=False) 140 | self.assertFalse(s.needs_enlarge) 141 | self.assertEqual(s.width, 150) 142 | self.assertEqual(s.height, 150) 143 | self.assertEqual(s.op_width, 150) 144 | self.assertEqual(s.op_height, 150) 145 | 146 | 147 | def test_pad_enlarge(self): 148 | 149 | # Both need enlarging. 150 | s = ImageSize(transform=make_transform(), width=150, height=200, mode='pad', enlarge=True) 151 | self.assertTrue(s.needs_enlarge) 152 | self.assertEqual(s.width, 150) 153 | self.assertEqual(s.height, 200) 154 | self.assertEqual(s.op_width, 150) 155 | self.assertEqual(s.op_height, 150) 156 | 157 | # One is big enough. 158 | s = ImageSize(transform=make_transform(200, 100), width=150, height=200, mode='pad', enlarge=True) 159 | self.assertFalse(s.needs_enlarge) 160 | self.assertEqual(s.width, 150) 161 | self.assertEqual(s.height, 200) 162 | self.assertEqual(s.op_width, 150) 163 | self.assertEqual(s.op_height, 75) 164 | 165 | # Neither need enlarging. 166 | s = ImageSize(transform=make_transform(400, 400), width=150, height=200, mode='pad', enlarge=True) 167 | self.assertFalse(s.needs_enlarge) 168 | self.assertEqual(s.width, 150) 169 | self.assertEqual(s.height, 200) 170 | self.assertEqual(s.op_width, 150) 171 | self.assertEqual(s.op_height, 150) 172 | 173 | def test_pad_no_enlarge(self): 174 | 175 | # Both need enlarging. 176 | s = ImageSize(transform=make_transform(), width=150, height=200, mode='pad', enlarge=False) 177 | self.assertTrue(s.needs_enlarge) 178 | self.assertEqual(s.width, 150) 179 | self.assertEqual(s.height, 200) 180 | self.assertEqual(s.op_width, 100) 181 | self.assertEqual(s.op_height, 100) 182 | 183 | # One is big enough. 184 | s = ImageSize(transform=make_transform(200, 100), width=150, height=200, mode='pad', enlarge=False) 185 | self.assertFalse(s.needs_enlarge) 186 | self.assertEqual(s.width, 150) 187 | self.assertEqual(s.height, 200) # <-- 188 | self.assertEqual(s.op_width, 150) 189 | self.assertEqual(s.op_height, 75) 190 | 191 | # Neither need enlarging. 192 | s = ImageSize(transform=make_transform(400, 400), width=150, height=200, mode='pad', enlarge=False) 193 | self.assertFalse(s.needs_enlarge) 194 | self.assertEqual(s.width, 150) 195 | self.assertEqual(s.height, 200) 196 | self.assertEqual(s.op_width, 150) 197 | self.assertEqual(s.op_height, 150) 198 | 199 | 200 | 201 | -------------------------------------------------------------------------------- /tests/test_template_use.py: -------------------------------------------------------------------------------- 1 | from . import * 2 | 3 | 4 | class TestTemplateUse(TestCase): 5 | 6 | def test_resized_img_src(self): 7 | 8 | @self.app.route('/resized_img_src') 9 | def use(): 10 | return render_template_string(''' 11 | 12 | '''.strip()) 13 | 14 | res = self.client.get('/resized_img_src') 15 | self.assert200(res) 16 | if PY3: 17 | self.assertIn('src="/imgsizer/cc.png?', res.data.decode('utf-8')) 18 | else: 19 | self.assertIn('src="/imgsizer/cc.png?', res.data) 20 | 21 | def test_url_for(self): 22 | 23 | @self.app.route('/url_for') 24 | def use(): 25 | return render_template_string(''' 26 | 27 | '''.strip()) 28 | 29 | res = self.client.get('/url_for') 30 | self.assert200(res) 31 | if PY3: 32 | self.assertIn('src="/imgsizer/cc.png?', res.data.decode('utf-8')) 33 | else: 34 | self.assertIn('src="/imgsizer/cc.png?', res.data) 35 | -------------------------------------------------------------------------------- /tests/test_url_escaping.py: -------------------------------------------------------------------------------- 1 | from . import * 2 | 3 | 4 | class TestURLEscaping(TestCase): 5 | 6 | def test_url_with_query(self): 7 | url = url_for('images', filename='http://example.com/?a=1&b=2') 8 | self.assertTrue( 9 | url.startswith('/imgsizer/_?u=http%3A%2F%2Fexample.com%2F%3Fa%3D1%26b%3D2&'), 10 | url 11 | ) 12 | --------------------------------------------------------------------------------