diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..bf7b494f --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "docs/_themes"] + path = docs/_themes + url = git://github.com/mitsuhiko/flask-sphinx-themes.git diff --git a/CHANGES b/CHANGES index ae523e86..485902e7 100644 --- a/CHANGES +++ b/CHANGES @@ -15,6 +15,7 @@ Version 0.2 view function. - server listens on 127.0.0.1 by default now to fix issues with chrome. - added external URL support. +- added support for :func:`~flask.send_file` - module support and internal request handling refactoring to better support pluggable applications. diff --git a/docs/_themes b/docs/_themes new file mode 160000 index 00000000..11cb6b51 --- /dev/null +++ b/docs/_themes @@ -0,0 +1 @@ +Subproject commit 11cb6b51c9ea3bc8f94afa3d7411b617f9db2570 diff --git a/docs/_themes/flasky/static/flasky.css_t b/docs/_themes/flasky/static/flasky.css_t deleted file mode 100644 index 04c5c5f6..00000000 --- a/docs/_themes/flasky/static/flasky.css_t +++ /dev/null @@ -1,344 +0,0 @@ -/* - * flasky.css_t - * ~~~~~~~~~~~~ - * - * Sphinx stylesheet -- flasky theme based on nature theme. - * - * :copyright: Copyright 2007-2010 by the Sphinx team, see AUTHORS. - * :license: BSD, see LICENSE for details. - * - */ - -@import url("basic.css"); - -/* -- page layout ----------------------------------------------------------- */ - -body { - font-family: 'Georgia', serif; - font-size: 17px; - background-color: #ddd; - color: #000; - margin: 0; - padding: 0; -} - -div.document { - background: #fafafa; -} - -div.documentwrapper { - float: left; - width: 100%; -} - -div.bodywrapper { - margin: 0 0 0 230px; -} - -hr { - border: 1px solid #B1B4B6; -} - -div.body { - background-color: #ffffff; - color: #3E4349; - padding: 0 30px 30px 30px; - min-height: 34em; -} - -img.floatingflask { - padding: 0 0 10px 10px; - float: right; -} - -div.footer { - position: absolute; - right: 0; - margin-top: -70px; - text-align: right; - color: #888; - padding: 10px; - font-size: 14px; -} - -div.footer a { - color: #888; - text-decoration: underline; -} - -div.related { - line-height: 32px; - color: #888; -} - -div.related ul { - padding: 0 0 0 10px; -} - -div.related a { - color: #444; -} - -div.sphinxsidebar { - font-size: 14px; - line-height: 1.5; -} - -div.sphinxsidebarwrapper { - padding: 0 20px; -} - -div.sphinxsidebarwrapper p.logo { - padding: 20px 0 10px 0; - margin: 0; - text-align: center; -} - -div.sphinxsidebar h3, -div.sphinxsidebar h4 { - font-family: 'Garamond', 'Georgia', serif; - color: #222; - font-size: 24px; - font-weight: normal; - margin: 20px 0 5px 0; - padding: 0; -} - -div.sphinxsidebar h4 { - font-size: 20px; -} - -div.sphinxsidebar h3 a { - color: #444; -} - -div.sphinxsidebar p { - color: #555; - margin: 10px 0; -} - -div.sphinxsidebar ul { - margin: 10px 0; - padding: 0; - color: #000; -} - -div.sphinxsidebar a { - color: #444; - text-decoration: none; -} - -div.sphinxsidebar a:hover { - text-decoration: underline; -} - -div.sphinxsidebar input { - border: 1px solid #ccc; - font-family: 'Georgia', serif; - font-size: 1em; -} - -/* -- body styles ----------------------------------------------------------- */ - -a { - color: #004B6B; - text-decoration: underline; -} - -a:hover { - color: #6D4100; - text-decoration: underline; -} - -div.body { - padding-bottom: 40px; /* saved for footer */ -} - -div.body h1, -div.body h2, -div.body h3, -div.body h4, -div.body h5, -div.body h6 { - font-family: 'Garamond', 'Georgia', serif; - font-weight: normal; - margin: 30px 0px 10px 0px; - padding: 0; -} - -div.body h1 { margin-top: 0; padding-top: 20px; font-size: 240%; } -div.body h2 { font-size: 180%; } -div.body h3 { font-size: 150%; } -div.body h4 { font-size: 130%; } -div.body h5 { font-size: 100%; } -div.body h6 { font-size: 100%; } - -a.headerlink { - color: white; - padding: 0 4px; - text-decoration: none; -} - -a.headerlink:hover { - color: #444; - background: #eaeaea; -} - -div.body p, div.body dd, div.body li { - line-height: 1.4em; -} - -div.admonition { - background: #fafafa; - margin: 20px -30px; - padding: 10px 30px; - border-top: 1px solid #ccc; - border-bottom: 1px solid #ccc; -} - -div.admonition p.admonition-title { - font-family: 'Garamond', 'Georgia', serif; - font-weight: normal; - font-size: 24px; - margin: 0 0 10px 0; - padding: 0; - line-height: 1; -} - -div.admonition p.last { - margin-bottom: 0; -} - -div.highlight{ - background-color: white; -} - -dt:target, .highlight { - background: #FAF3E8; -} - -div.note { - background-color: #eee; - border: 1px solid #ccc; -} - -div.seealso { - background-color: #ffc; - border: 1px solid #ff6; -} - -div.topic { - background-color: #eee; -} - -div.warning { - background-color: #ffe4e4; - border: 1px solid #f66; -} - -p.admonition-title { - display: inline; -} - -p.admonition-title:after { - content: ":"; -} - -pre, tt { - font-family: 'Consolas', 'Menlo', 'Deja Vu Sans Mono', 'Bitstream Vera Sans Mono', monospace; - font-size: 0.9em; -} - -img.screenshot { -} - -tt.descname, tt.descclassname { - font-size: 0.95em; -} - -tt.descname { - padding-right: 0.08em; -} - -img.screenshot { - -moz-box-shadow: 2px 2px 4px #eee; - -webkit-box-shadow: 2px 2px 4px #eee; - box-shadow: 2px 2px 4px #eee; -} - -table.docutils { - border: 1px solid #888; - -moz-box-shadow: 2px 2px 4px #eee; - -webkit-box-shadow: 2px 2px 4px #eee; - box-shadow: 2px 2px 4px #eee; -} - -table.docutils td, table.docutils th { - border: 1px solid #888; - padding: 0.25em 0.7em; -} - -table.field-list, table.footnote { - border: none; - -moz-box-shadow: none; - -webkit-box-shadow: none; - box-shadow: none; -} - -table.footnote { - margin: 15px 0; - width: 100%; - border: 1px solid #eee; -} - -table.field-list th { - padding: 0 0.8em 0 0; -} - -table.field-list td { - padding: 0; -} - -table.footnote td { - padding: 0.5em; -} - -dl { - margin: 0; - padding: 0; -} - -dl dd { - margin-left: 30px; -} - -pre { - background: #eee; - padding: 7px 30px; - margin: 15px -30px; - line-height: 1.3em; -} - -dl pre { - margin-left: -60px; - padding-left: 60px; -} - -dl dl pre { - margin-left: -90px; - padding-left: 90px; -} - -tt { - background-color: #ecf0f3; - color: #222; - /* padding: 1px 2px; */ -} - -tt.xref, a tt { - background-color: #FBFBFB; -} - -a:hover tt { - background: #EEE; -} diff --git a/docs/_themes/flasky/theme.conf b/docs/_themes/flasky/theme.conf deleted file mode 100644 index cb9eb465..00000000 --- a/docs/_themes/flasky/theme.conf +++ /dev/null @@ -1,3 +0,0 @@ -[theme] -inherit = basic -stylesheet = flasky.css diff --git a/docs/api.rst b/docs/api.rst index f322f965..e13e9524 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -222,6 +222,8 @@ Useful Functions and Classes .. autofunction:: redirect +.. autofunction:: send_file + .. autofunction:: escape .. autoclass:: Markup diff --git a/docs/conf.py b/docs/conf.py index f4756ae0..2048ab6e 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -16,7 +16,7 @@ import sys, os # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. -sys.path.append(os.path.abspath('.')) +sys.path.append(os.path.abspath('_themes')) # -- General configuration ----------------------------------------------------- @@ -79,9 +79,6 @@ exclude_patterns = ['_build'] # output. They are ignored by default. #show_authors = False -# The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'flaskext.FlaskyStyle' - # A list of ignored prefixes for module index sorting. #modindex_common_prefix = [] @@ -90,7 +87,7 @@ pygments_style = 'flaskext.FlaskyStyle' # The theme to use for HTML and HTML Help pages. Major themes that come with # Sphinx are currently 'default' and 'sphinxdoc'. -html_theme = 'flasky' +html_theme = 'flask' # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the @@ -237,3 +234,5 @@ intersphinx_mapping = { 'http://www.sqlalchemy.org/docs/': None, 'http://wtforms.simplecodes.com/docs/0.5/': None } + +pygments_style = 'flask_theme_support.FlaskyStyle' diff --git a/flask.py b/flask.py index c5516c4f..5af32ea8 100644 --- a/flask.py +++ b/flask.py @@ -12,13 +12,14 @@ from __future__ import with_statement import os import sys +import mimetypes from datetime import datetime, timedelta from itertools import chain from jinja2 import Environment, PackageLoader, FileSystemLoader from werkzeug import Request as RequestBase, Response as ResponseBase, \ LocalStack, LocalProxy, create_environ, SharedDataMiddleware, \ - ImmutableDict, cached_property + ImmutableDict, cached_property, wrap_file, Headers from werkzeug.routing import Map, Rule from werkzeug.exceptions import HTTPException from werkzeug.contrib.securecookie import SecureCookie @@ -268,6 +269,78 @@ def jsonify(*args, **kwargs): indent=None if request.is_xhr else 2), mimetype='application/json') +def send_file(filename_or_fp, mimetype=None, as_attachment=False, + attachment_filename=None): + """Sends the contents of a file to the client. This will use the + most efficient method available and configured. By default it will + try to use the WSGI server's file_wrapper support. Alternatively + you can set the application's :attr:`~Flask.use_x_sendfile` attribute + to ``True`` to directly emit an `X-Sendfile` header. This however + requires support of the underlying webserver for `X-Sendfile`. + + By default it will try to guess the mimetype for you, but you can + also explicitly provide one. For extra security you probably want + to sent certain files as attachment (HTML for instance). + + Please never pass filenames to this function from user sources without + checking them first. Something like this is usually sufficient to + avoid security problems:: + + if '..' in filename or filename.startswith('/'): + abort(404) + + .. versionadded:: 0.2 + + :param filename_or_fp: the filename of the file to send. This is + relative to the :attr:`~Flask.root_path` if a + relative path is specified. + Alternatively a file object might be provided + in which case `X-Sendfile` might not work and + fall back to the traditional method. + :param mimetype: the mimetype of the file if provided, otherwise + auto detection happens. + :param as_attachment: set to `True` if you want to send this file with + a ``Content-Disposition: attachment`` header. + :param attachment_filename: the filename for the attachment if it + differs from the file's filename. + """ + if isinstance(filename_or_fp, basestring): + filename = filename_or_fp + file = None + else: + file = filename_or_fp + filename = getattr(file, 'name', None) + if filename is not None: + filename = os.path.join(current_app.root_path, filename) + if mimetype is None and (filename or attachment_filename): + mimetype = mimetypes.guess_type(filename or attachment_filename)[0] + if mimetype is None: + mimetype = 'application/octet-stream' + + headers = Headers() + if as_attachment: + if attachment_filename is None: + if filename is None: + raise TypeError('filename unavailable, required for ' + 'sending as attachment') + attachment_filename = os.path.basename(filename) + headers.add('Content-Disposition', 'attachment', + filename=attachment_filename) + + if current_app.use_x_sendfile and filename: + if file is not None: + file.close() + headers['X-Sendfile'] = filename + data = None + else: + if file is None: + file = open(filename, 'rb') + data = wrap_file(request.environ, file) + + return Response(data, mimetype=mimetype, headers=headers, + direct_passthrough=True) + + def render_template(template_name, **context): """Renders a template from the template folder with the given context. @@ -554,6 +627,13 @@ class Flask(_PackageBoundObject): #: permanent session survive for roughly one month. permanent_session_lifetime = timedelta(days=31) + #: Enable this if you want to use the X-Sendfile feature. Keep in + #: mind that the server has to support this. This only affects files + #: sent with the :func:`send_file` method. + #: + #: .. versionadded:: 0.2 + use_x_sendfile = False + #: options that are passed directly to the Jinja2 environment jinja_options = ImmutableDict( autoescape=True, diff --git a/tests/flask_tests.py b/tests/flask_tests.py index 1240dadd..c7cebb9c 100644 --- a/tests/flask_tests.py +++ b/tests/flask_tests.py @@ -17,7 +17,8 @@ import flask import unittest import tempfile from datetime import datetime -from werkzeug import parse_date +from werkzeug import parse_date, parse_options_header +from cStringIO import StringIO example_path = os.path.join(os.path.dirname(__file__), '..', 'examples') @@ -474,6 +475,87 @@ class ModuleTestCase(unittest.TestCase): assert app.test_client().get('/admin/').data == '42' +class SendfileTestCase(unittest.TestCase): + + def test_send_file_regular(self): + app = flask.Flask(__name__) + with app.test_request_context(): + rv = flask.send_file('static/index.html') + assert rv.direct_passthrough + assert rv.mimetype == 'text/html' + with app.open_resource('static/index.html') as f: + assert rv.data == f.read() + + def test_send_file_xsendfile(self): + app = flask.Flask(__name__) + app.use_x_sendfile = True + with app.test_request_context(): + rv = flask.send_file('static/index.html') + assert rv.direct_passthrough + assert 'x-sendfile' in rv.headers + assert rv.headers['x-sendfile'] == \ + os.path.join(app.root_path, 'static/index.html') + assert rv.mimetype == 'text/html' + + def test_send_file_object(self): + app = flask.Flask(__name__) + with app.test_request_context(): + f = open(os.path.join(app.root_path, 'static/index.html')) + rv = flask.send_file(f) + with app.open_resource('static/index.html') as f: + assert rv.data == f.read() + assert rv.mimetype == 'text/html' + + app.use_x_sendfile = True + with app.test_request_context(): + f = open(os.path.join(app.root_path, 'static/index.html')) + rv = flask.send_file(f) + assert rv.mimetype == 'text/html' + assert 'x-sendfile' in rv.headers + assert rv.headers['x-sendfile'] == \ + os.path.join(app.root_path, 'static/index.html') + + app.use_x_sendfile = False + with app.test_request_context(): + f = StringIO('Test') + rv = flask.send_file(f) + assert rv.data == 'Test' + assert rv.mimetype == 'application/octet-stream' + f = StringIO('Test') + rv = flask.send_file(f, mimetype='text/plain') + assert rv.data == 'Test' + assert rv.mimetype == 'text/plain' + + app.use_x_sendfile = True + with app.test_request_context(): + f = StringIO('Test') + rv = flask.send_file(f) + assert 'x-sendfile' not in rv.headers + + def test_attachment(self): + app = flask.Flask(__name__) + with app.test_request_context(): + f = open(os.path.join(app.root_path, 'static/index.html')) + rv = flask.send_file(f, as_attachment=True) + value, options = parse_options_header(rv.headers['Content-Disposition']) + assert value == 'attachment' + + with app.test_request_context(): + assert options['filename'] == 'index.html' + rv = flask.send_file('static/index.html', as_attachment=True) + value, options = parse_options_header(rv.headers['Content-Disposition']) + assert value == 'attachment' + assert options['filename'] == 'index.html' + + with app.test_request_context(): + rv = flask.send_file(StringIO('Test'), as_attachment=True, + attachment_filename='index.txt') + assert rv.mimetype == 'text/plain' + value, options = parse_options_header(rv.headers['Content-Disposition']) + assert value == 'attachment' + assert options['filename'] == 'index.txt' + + def suite(): from minitwit_tests import MiniTwitTestCase from flaskr_tests import FlaskrTestCase @@ -481,11 +563,12 @@ def suite(): suite.addTest(unittest.makeSuite(ContextTestCase)) suite.addTest(unittest.makeSuite(BasicFunctionalityTestCase)) suite.addTest(unittest.makeSuite(TemplatingTestCase)) + suite.addTest(unittest.makeSuite(SendfileTestCase)) + suite.addTest(unittest.makeSuite(ModuleTestCase)) if flask.json_available: suite.addTest(unittest.makeSuite(JSONTestCase)) suite.addTest(unittest.makeSuite(MiniTwitTestCase)) suite.addTest(unittest.makeSuite(FlaskrTestCase)) - suite.addTest(unittest.makeSuite(ModuleTestCase)) return suite