move send_file and send_from_directory to Werkzeug

The implementations were moved to Werkzeug, Flask's functions become
wrappers around Werkzeug to pass some Flask-specific values.

cache_timeout is renamed to max_age. SEND_FILE_MAX_AGE_DEFAULT,
app.send_file_max_age_default, and app.get_send_file_max_age defaults
to None. This tells the browser to use conditional requests rather than
a 12 hour cache.

attachment_filename is renamed to download_name, and is always sent if
a name is known.

Deprecate helpers.safe_join in favor of werkzeug.utils.safe_join.

Removed most of the send_file tests, they're tested in Werkzeug.

In the file upload example, renamed the uploaded_file view to
download_file to avoid a common source of confusion.
This commit is contained in:
David Lord 2020-11-05 09:00:57 -08:00
parent 15a49e7297
commit dc11cdb4a4
No known key found for this signature in database
GPG key ID: 7A1C87E3F5BC42A8
8 changed files with 260 additions and 654 deletions

View file

@ -55,9 +55,10 @@ from .wrappers import Response
def _make_timedelta(value):
if not isinstance(value, timedelta):
return timedelta(seconds=value)
return value
if value is None or isinstance(value, timedelta):
return value
return timedelta(seconds=value)
class Flask(Scaffold):
@ -234,13 +235,16 @@ class Flask(Scaffold):
"PERMANENT_SESSION_LIFETIME", get_converter=_make_timedelta
)
#: A :class:`~datetime.timedelta` which is used as default cache_timeout
#: for the :func:`send_file` functions. The default is 12 hours.
#: A :class:`~datetime.timedelta` or number of seconds which is used
#: as the default ``max_age`` for :func:`send_file`. The default is
#: ``None``, which tells the browser to use conditional requests
#: instead of a timed cache.
#:
#: This attribute can also be configured from the config with the
#: ``SEND_FILE_MAX_AGE_DEFAULT`` configuration key. This configuration
#: variable can also be set with an integer value used as seconds.
#: Defaults to ``timedelta(hours=12)``
#: Configured with the :data:`SEND_FILE_MAX_AGE_DEFAULT`
#: configuration key.
#:
#: .. versionchanged:: 2.0
#: Defaults to ``None`` instead of 12 hours.
send_file_max_age_default = ConfigAttribute(
"SEND_FILE_MAX_AGE_DEFAULT", get_converter=_make_timedelta
)
@ -297,7 +301,7 @@ class Flask(Scaffold):
"SESSION_COOKIE_SAMESITE": None,
"SESSION_REFRESH_EACH_REQUEST": True,
"MAX_CONTENT_LENGTH": None,
"SEND_FILE_MAX_AGE_DEFAULT": timedelta(hours=12),
"SEND_FILE_MAX_AGE_DEFAULT": None,
"TRAP_BAD_REQUEST_ERRORS": None,
"TRAP_HTTP_EXCEPTIONS": False,
"EXPLAIN_TEMPLATE_LOADING": False,

View file

@ -1,24 +1,16 @@
import io
import mimetypes
import os
import pkgutil
import posixpath
import socket
import sys
import unicodedata
import warnings
from functools import update_wrapper
from threading import RLock
from time import time
from zlib import adler32
import werkzeug.utils
from jinja2 import FileSystemLoader
from werkzeug.datastructures import Headers
from werkzeug.exceptions import BadRequest
from werkzeug.exceptions import NotFound
from werkzeug.exceptions import RequestedRangeNotSatisfiable
from werkzeug.routing import BuildError
from werkzeug.urls import url_quote
from werkzeug.wsgi import wrap_file
from .globals import _app_ctx_stack
from .globals import _request_ctx_stack
@ -444,49 +436,116 @@ def get_flashed_messages(with_categories=False, category_filter=()):
return flashes
def _prepare_send_file_kwargs(
download_name=None,
attachment_filename=None,
max_age=None,
cache_timeout=None,
**kwargs,
):
if attachment_filename is not None:
warnings.warn(
"The 'attachment_filename' parameter has been renamed to 'download_name'."
" The old name will be removed in Flask 2.1.",
DeprecationWarning,
stacklevel=3,
)
download_name = attachment_filename
if cache_timeout is not None:
warnings.warn(
"The 'cache_timeout' parameter has been renamed to 'max_age'. The old name"
" will be removed in Flask 2.1.",
DeprecationWarning,
stacklevel=3,
)
max_age = cache_timeout
if max_age is None:
max_age = current_app.get_send_file_max_age
kwargs.update(
environ=request.environ,
download_name=download_name,
max_age=max_age,
use_x_sendfile=current_app.use_x_sendfile,
response_class=current_app.response_class,
_root_path=current_app.root_path,
)
return kwargs
def send_file(
filename_or_fp,
path_or_file,
mimetype=None,
as_attachment=False,
download_name=None,
attachment_filename=None,
conditional=True,
add_etags=True,
cache_timeout=None,
conditional=False,
last_modified=None,
max_age=None,
cache_timeout=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``.
"""Send the contents of a file to the client.
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 send certain files as attachment (HTML for instance). The mimetype
guessing requires a `filename` or an `attachment_filename` to be
provided.
The first argument can be a file path or a file-like object. Paths
are preferred in most cases because Werkzeug can manage the file and
get extra information from the path. Passing a file-like object
requires that the file is opened in binary mode, and is mostly
useful when building a file in memory with :class:`io.BytesIO`.
When passing a file-like object instead of a filename, only binary
mode is supported (``open(filename, "rb")``, :class:`~io.BytesIO`,
etc.). Text mode files and :class:`~io.StringIO` will raise a
:exc:`ValueError`.
Never pass file paths provided by a user. The path is assumed to be
trusted, so a user could craft a path to access a file you didn't
intend. Use :func:`send_from_directory` to safely serve
user-requested paths from within a directory.
ETags will also be attached automatically if a `filename` is provided. You
can turn this off by setting `add_etags=False`.
If the WSGI server sets a ``file_wrapper`` in ``environ``, it is
used, otherwise Werkzeug's built-in wrapper is used. Alternatively,
if the HTTP server supports ``X-Sendfile``, configuring Flask with
``USE_X_SENDFILE = True`` will tell the server to send the given
path, which is much more efficient than reading it in Python.
If `conditional=True` and `filename` is provided, this method will try to
upgrade the response stream to support range requests. This will allow
the request to be answered with partial content response.
:param path_or_file: The path to the file to send, relative to the
current working directory if a relative path is given.
Alternatively, a file-like object opened in binary mode. Make
sure the file pointer is seeked to the start of the data.
:param mimetype: The MIME type to send for the file. If not
provided, it will try to detect it from the file name.
:param as_attachment: Indicate to a browser that it should offer to
save the file instead of displaying it.
:param download_name: The default name browsers will use when saving
the file. Defaults to the passed file name.
:param conditional: Enable conditional and range responses based on
request headers. Requires passing a file path and ``environ``.
:param add_etags: Calculate an ETag for the file. Requires passing a
file path.
:param last_modified: The last modified time to send for the file,
in seconds. If not provided, it will try to detect it from the
file path.
:param max_age: How long the client should cache the file, in
seconds. If set, ``Cache-Control`` will be ``public``, otherwise
it will be ``no-cache`` to prefer conditional caching.
Please never pass filenames to this function from user sources;
you should use :func:`send_from_directory` instead.
.. versionchanged:: 2.0
``download_name`` replaces the ``attachment_filename``
parameter. If ``as_attachment=False``, it is passed with
``Content-Disposition: inline`` instead.
.. versionchanged:: 2.0
``max_age`` replaces the ``cache_timeout`` parameter.
``conditional`` is enabled and ``max_age`` is not set by
default.
.. versionchanged:: 2.0
Passing a file-like object that inherits from
:class:`~io.TextIOBase` will raise a :exc:`ValueError` rather
than sending an empty file.
.. versionadded:: 2.0
Moved the implementation to Werkzeug. This is now a wrapper to
pass some Flask-specific arguments.
.. versionchanged:: 1.1
``filename`` may be a :class:`~os.PathLike` object.
@ -498,260 +557,106 @@ def send_file(
compatibility with WSGI servers.
.. versionchanged:: 1.0
UTF-8 filenames, as specified in `RFC 2231`_, are supported.
.. _RFC 2231: https://tools.ietf.org/html/rfc2231#section-4
UTF-8 filenames as specified in :rfc:`2231` are supported.
.. versionchanged:: 0.12
The filename is no longer automatically inferred from file
objects. If you want to use automatic MIME and etag support, pass
a filename via ``filename_or_fp`` or ``attachment_filename``.
The filename is no longer automatically inferred from file
objects. If you want to use automatic MIME and etag support,
pass a filename via ``filename_or_fp`` or
``attachment_filename``.
.. versionchanged:: 0.12
``attachment_filename`` is preferred over ``filename`` for MIME
detection.
``attachment_filename`` is preferred over ``filename`` for MIME
detection.
.. versionchanged:: 0.9
``cache_timeout`` defaults to
:meth:`Flask.get_send_file_max_age`.
``cache_timeout`` defaults to
:meth:`Flask.get_send_file_max_age`.
.. versionchanged:: 0.7
MIME guessing and etag support for file-like objects was
deprecated because it was unreliable. Pass a filename if you are
able to, otherwise attach an etag yourself. This functionality
will be removed in Flask 1.0.
MIME guessing and etag support for file-like objects was
deprecated because it was unreliable. Pass a filename if you are
able to, otherwise attach an etag yourself.
.. versionadded:: 0.5
The ``add_etags``, ``cache_timeout`` and ``conditional``
parameters were added. The default behavior is to add etags.
.. versionchanged:: 0.5
The ``add_etags``, ``cache_timeout`` and ``conditional``
parameters were added. The default behavior is to add etags.
.. versionadded:: 0.2
:param filename_or_fp: The filename of the file to send, relative to
:attr:`~Flask.root_path` if a relative path is specified.
Alternatively, a file-like object opened in binary mode. Make
sure the file pointer is seeked to the start of the data.
``X-Sendfile`` will only be used with filenames.
:param mimetype: the mimetype of the file if provided. If a file path is
given, auto detection happens as fallback, otherwise an
error will be raised.
: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.
:param add_etags: set to ``False`` to disable attaching of etags.
:param conditional: set to ``True`` to enable conditional responses.
:param cache_timeout: the timeout in seconds for the headers. When ``None``
(default), this value is set by
:meth:`~Flask.get_send_file_max_age` of
:data:`~flask.current_app`.
:param last_modified: set the ``Last-Modified`` header to this value,
a :class:`~datetime.datetime` or timestamp.
If a file was passed, this overrides its mtime.
"""
mtime = None
fsize = None
if hasattr(filename_or_fp, "__fspath__"):
filename_or_fp = os.fspath(filename_or_fp)
if isinstance(filename_or_fp, str):
filename = filename_or_fp
if not os.path.isabs(filename):
filename = os.path.join(current_app.root_path, filename)
file = None
if attachment_filename is None:
attachment_filename = os.path.basename(filename)
else:
file = filename_or_fp
filename = None
if mimetype is None:
if attachment_filename is not None:
mimetype = (
mimetypes.guess_type(attachment_filename)[0]
or "application/octet-stream"
)
if mimetype is None:
raise ValueError(
"Unable to infer MIME-type because no filename is available. "
"Please set either `attachment_filename`, pass a filepath to "
"`filename_or_fp` or set your own MIME-type via `mimetype`."
)
headers = Headers()
if as_attachment:
if attachment_filename is None:
raise TypeError("filename unavailable, required for sending as attachment")
if not isinstance(attachment_filename, str):
attachment_filename = attachment_filename.decode("utf-8")
try:
attachment_filename = attachment_filename.encode("ascii")
except UnicodeEncodeError:
quoted = url_quote(attachment_filename, safe="")
filenames = {
"filename": unicodedata.normalize("NFKD", attachment_filename).encode(
"ascii", "ignore"
),
"filename*": f"UTF-8''{quoted}",
}
else:
filenames = {"filename": attachment_filename}
headers.add("Content-Disposition", "attachment", **filenames)
if current_app.use_x_sendfile and filename:
if file is not None:
file.close()
headers["X-Sendfile"] = filename
fsize = os.path.getsize(filename)
data = None
else:
if file is None:
file = open(filename, "rb")
mtime = os.path.getmtime(filename)
fsize = os.path.getsize(filename)
elif isinstance(file, io.BytesIO):
fsize = file.getbuffer().nbytes
elif isinstance(file, io.TextIOBase):
raise ValueError("Files must be opened in binary mode or use BytesIO.")
data = wrap_file(request.environ, file)
if fsize is not None:
headers["Content-Length"] = fsize
rv = current_app.response_class(
data, mimetype=mimetype, headers=headers, direct_passthrough=True
return werkzeug.utils.send_file(
**_prepare_send_file_kwargs(
path_or_file=path_or_file,
environ=request.environ,
mimetype=mimetype,
as_attachment=as_attachment,
download_name=download_name,
attachment_filename=attachment_filename,
conditional=conditional,
add_etags=add_etags,
last_modified=last_modified,
max_age=max_age,
cache_timeout=cache_timeout,
)
)
if last_modified is not None:
rv.last_modified = last_modified
elif mtime is not None:
rv.last_modified = mtime
rv.cache_control.public = True
if cache_timeout is None:
cache_timeout = current_app.get_send_file_max_age(filename)
if cache_timeout is not None:
rv.cache_control.max_age = cache_timeout
rv.expires = int(time() + cache_timeout)
if add_etags and filename is not None:
from warnings import warn
try:
check = (
adler32(
filename.encode("utf-8") if isinstance(filename, str) else filename
)
& 0xFFFFFFFF
)
rv.set_etag(
f"{os.path.getmtime(filename)}-{os.path.getsize(filename)}-{check}"
)
except OSError:
warn(
f"Access {filename} failed, maybe it does not exist, so"
" ignore etags in headers",
stacklevel=2,
)
if conditional:
try:
rv = rv.make_conditional(request, accept_ranges=True, complete_length=fsize)
except RequestedRangeNotSatisfiable:
if file is not None:
file.close()
raise
# make sure we don't send x-sendfile for servers that
# ignore the 304 status code for x-sendfile.
if rv.status_code == 304:
rv.headers.pop("x-sendfile", None)
return rv
def safe_join(directory, *pathnames):
"""Safely join `directory` and zero or more untrusted `pathnames`
components.
"""Safely join zero or more untrusted path components to a base
directory to avoid escaping the base directory.
Example usage::
@app.route('/wiki/<path:filename>')
def wiki_page(filename):
filename = safe_join(app.config['WIKI_FOLDER'], filename)
with open(filename, 'rb') as fd:
content = fd.read() # Read and process the file content...
:param directory: the trusted base directory.
:param pathnames: the untrusted pathnames relative to that directory.
:raises: :class:`~werkzeug.exceptions.NotFound` if one or more passed
paths fall out of its boundaries.
:param directory: The trusted base directory.
:param pathnames: The untrusted path components relative to the
base directory.
:return: A safe path, otherwise ``None``.
"""
warnings.warn(
"'flask.helpers.safe_join' is deprecated and will be removed in"
" 2.1. Use 'werkzeug.utils.safe_join' instead.",
DeprecationWarning,
stacklevel=2,
)
path = werkzeug.utils.safe_join(directory, *pathnames)
parts = [directory]
if path is None:
raise NotFound()
for filename in pathnames:
if filename != "":
filename = posixpath.normpath(filename)
if (
any(sep in filename for sep in _os_alt_seps)
or os.path.isabs(filename)
or filename == ".."
or filename.startswith("../")
):
raise NotFound()
parts.append(filename)
return posixpath.join(*parts)
return path
def send_from_directory(directory, filename, **options):
"""Send a file from a given directory with :func:`send_file`. This
is a secure way to quickly expose static files from an upload folder
or something similar.
def send_from_directory(directory, path, **kwargs):
"""Send a file from within a directory using :func:`send_file`.
Example usage::
.. code-block:: python
@app.route('/uploads/<path:filename>')
def download_file(filename):
return send_from_directory(app.config['UPLOAD_FOLDER'],
filename, as_attachment=True)
@app.route("/uploads/<path:name>")
def download_file(name):
return send_from_directory(
app.config['UPLOAD_FOLDER'], name, as_attachment=True
)
.. admonition:: Sending files and Performance
This is a secure way to serve files from a folder, such as static
files or uploads. Uses :func:`~werkzeug.security.safe_join` to
ensure the path coming from the client is not maliciously crafted to
point outside the specified directory.
It is strongly recommended to activate either ``X-Sendfile`` support in
your webserver or (if no authentication happens) to tell the webserver
to serve files for the given path on its own without calling into the
web application for improved performance.
If the final path does not point to an existing regular file,
raises a 404 :exc:`~werkzeug.exceptions.NotFound` error.
:param directory: The directory that ``path`` must be located under.
:param path: The path to the file to send, relative to
``directory``.
:param kwargs: Arguments to pass to :func:`send_file`.
.. versionadded:: 2.0
Moved the implementation to Werkzeug. This is now a wrapper to
pass some Flask-specific arguments.
.. versionadded:: 0.5
:param directory: the directory where all the files are stored.
:param filename: the filename relative to that directory to
download.
:param options: optional keyword arguments that are directly
forwarded to :func:`send_file`.
"""
filename = os.fspath(filename)
directory = os.fspath(directory)
filename = safe_join(directory, filename)
if not os.path.isabs(filename):
filename = os.path.join(current_app.root_path, filename)
try:
if not os.path.isfile(filename):
raise NotFound()
except (TypeError, ValueError):
raise BadRequest()
options.setdefault("conditional", True)
return send_file(filename, **options)
return werkzeug.utils.send_from_directory(
directory, path, **_prepare_send_file_kwargs(**kwargs)
)
def get_root_path(import_name):
@ -1016,30 +921,25 @@ class _PackageBoundObject:
return FileSystemLoader(os.path.join(self.root_path, self.template_folder))
def get_send_file_max_age(self, filename):
"""Provides default cache_timeout for the :func:`send_file` functions.
"""Used by :func:`send_file` to determine the ``max_age`` cache
value for a given file path if it wasn't passed.
By default, this function returns ``SEND_FILE_MAX_AGE_DEFAULT`` from
the configuration of :data:`~flask.current_app`.
By default, this returns :data:`SEND_FILE_MAX_AGE_DEFAULT` from
the configuration of :data:`~flask.current_app`. This defaults
to ``None``, which tells the browser to use conditional requests
instead of a timed cache, which is usually preferable.
Static file functions such as :func:`send_from_directory` use this
function, and :func:`send_file` calls this function on
:data:`~flask.current_app` when the given cache_timeout is ``None``. If a
cache_timeout is given in :func:`send_file`, that timeout is used;
otherwise, this method is called.
This allows subclasses to change the behavior when sending files based
on the filename. For example, to set the cache timeout for .js files
to 60 seconds::
class MyFlask(flask.Flask):
def get_send_file_max_age(self, name):
if name.lower().endswith('.js'):
return 60
return flask.Flask.get_send_file_max_age(self, name)
.. versionchanged:: 2.0
The default configuration is ``None`` instead of 12 hours.
.. versionadded:: 0.9
"""
return total_seconds(current_app.send_file_max_age_default)
value = current_app.send_file_max_age_default
if value is None:
return None
return total_seconds(value)
def send_static_file(self, filename):
"""Function used internally to send static files from the static
@ -1049,12 +949,11 @@ class _PackageBoundObject:
"""
if not self.has_static_folder:
raise RuntimeError("No static folder for this object")
# Ensure get_send_file_max_age is called in all cases.
# Here, we ensure get_send_file_max_age is called for Blueprints.
cache_timeout = self.get_send_file_max_age(filename)
return send_from_directory(
self.static_folder, filename, cache_timeout=cache_timeout
)
# send_file only knows to call get_send_file_max_age on the app,
# call it here so it works for blueprints too.
max_age = self.get_send_file_max_age(filename)
return send_from_directory(self.static_folder, filename, max_age=max_age)
def open_resource(self, resource, mode="rb"):
"""Opens a resource from the application's resource folder. To see