Merge pull request #3828 from pallets/move-send-file
move send_file and send_from_directory to Werkzeug
This commit is contained in:
commit
bbb273bb76
8 changed files with 260 additions and 654 deletions
14
CHANGES.rst
14
CHANGES.rst
|
|
@ -45,6 +45,20 @@ Unreleased
|
||||||
- Include ``samesite`` and ``secure`` options when removing the
|
- Include ``samesite`` and ``secure`` options when removing the
|
||||||
session cookie. :pr:`3726`
|
session cookie. :pr:`3726`
|
||||||
- Support passing a ``pathlib.Path`` to ``static_folder``. :pr:`3579`
|
- Support passing a ``pathlib.Path`` to ``static_folder``. :pr:`3579`
|
||||||
|
- ``send_file`` and ``send_from_directory`` are wrappers around the
|
||||||
|
implementations in ``werkzeug.utils``. :pr:`3828`
|
||||||
|
- Some ``send_file`` parameters have been renamed, the old names are
|
||||||
|
deprecated. ``attachment_filename`` is renamed to ``download_name``.
|
||||||
|
``cache_timeout`` is renamed to ``max_age``. :pr:`3828`
|
||||||
|
- ``send_file`` passes ``download_name`` even if
|
||||||
|
``as_attachment=False`` by using ``Content-Disposition: inline``.
|
||||||
|
:pr:`3828`
|
||||||
|
- ``send_file`` sets ``conditional=True`` and ``max_age=None`` by
|
||||||
|
default. ``Cache-Control`` is set to ``no-cache`` if ``max_age`` is
|
||||||
|
not set, otherwise ``public``. This tells browsers to validate
|
||||||
|
conditional requests instead of using a timed cache. :pr:`3828`
|
||||||
|
- ``helpers.safe_join`` is deprecated. Use
|
||||||
|
``werkzeug.utils.safe_join`` instead. :pr:`3828`
|
||||||
|
|
||||||
|
|
||||||
Version 1.1.2
|
Version 1.1.2
|
||||||
|
|
|
||||||
|
|
@ -265,11 +265,16 @@ The following configuration values are used internally by Flask:
|
||||||
.. py:data:: SEND_FILE_MAX_AGE_DEFAULT
|
.. py:data:: SEND_FILE_MAX_AGE_DEFAULT
|
||||||
|
|
||||||
When serving files, set the cache control max age to this number of
|
When serving files, set the cache control max age to this number of
|
||||||
seconds. Can either be a :class:`datetime.timedelta` or an ``int``.
|
seconds. Can be a :class:`datetime.timedelta` or an ``int``.
|
||||||
Override this value on a per-file basis using
|
Override this value on a per-file basis using
|
||||||
:meth:`~flask.Flask.get_send_file_max_age` on the application or blueprint.
|
:meth:`~flask.Flask.get_send_file_max_age` on the application or
|
||||||
|
blueprint.
|
||||||
|
|
||||||
Default: ``timedelta(hours=12)`` (``43200`` seconds)
|
If ``None``, ``send_file`` tells the browser to use conditional
|
||||||
|
requests will be used instead of a timed cache, which is usually
|
||||||
|
preferable.
|
||||||
|
|
||||||
|
Default: ``None``
|
||||||
|
|
||||||
.. py:data:: SERVER_NAME
|
.. py:data:: SERVER_NAME
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -63,8 +63,7 @@ the file and redirects the user to the URL for the uploaded file::
|
||||||
if file and allowed_file(file.filename):
|
if file and allowed_file(file.filename):
|
||||||
filename = secure_filename(file.filename)
|
filename = secure_filename(file.filename)
|
||||||
file.save(os.path.join(app.config['UPLOAD_FOLDER'], filename))
|
file.save(os.path.join(app.config['UPLOAD_FOLDER'], filename))
|
||||||
return redirect(url_for('uploaded_file',
|
return redirect(url_for('download_file', name=filename))
|
||||||
filename=filename))
|
|
||||||
return '''
|
return '''
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<title>Upload new File</title>
|
<title>Upload new File</title>
|
||||||
|
|
@ -102,31 +101,28 @@ before storing it directly on the filesystem.
|
||||||
>>> secure_filename('../../../../home/username/.bashrc')
|
>>> secure_filename('../../../../home/username/.bashrc')
|
||||||
'home_username_.bashrc'
|
'home_username_.bashrc'
|
||||||
|
|
||||||
Now one last thing is missing: the serving of the uploaded files. In the
|
We want to be able to serve the uploaded files so they can be downloaded
|
||||||
:func:`upload_file()` we redirect the user to
|
by users. We'll define a ``download_file`` view to serve files in the
|
||||||
``url_for('uploaded_file', filename=filename)``, that is, ``/uploads/filename``.
|
upload folder by name. ``url_for("download_file", name=name)`` generates
|
||||||
So we write the :func:`uploaded_file` function to return the file of that name. As
|
download URLs.
|
||||||
of Flask 0.5 we can use a function that does that for us::
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
from flask import send_from_directory
|
from flask import send_from_directory
|
||||||
|
|
||||||
@app.route('/uploads/<filename>')
|
@app.route('/uploads/<name>')
|
||||||
def uploaded_file(filename):
|
def download_file(name):
|
||||||
return send_from_directory(app.config['UPLOAD_FOLDER'],
|
return send_from_directory(app.config["UPLOAD_FOLDER"], name)
|
||||||
filename)
|
|
||||||
|
|
||||||
Alternatively you can register `uploaded_file` as `build_only` rule and
|
If you're using middleware or the HTTP server to serve files, you can
|
||||||
use the :class:`~werkzeug.wsgi.SharedDataMiddleware`. This also works with
|
register the ``download_file`` endpoint as ``build_only`` so ``url_for``
|
||||||
older versions of Flask::
|
will work without a view function.
|
||||||
|
|
||||||
from werkzeug.middleware.shared_data import SharedDataMiddleware
|
.. code-block:: python
|
||||||
app.add_url_rule('/uploads/<filename>', 'uploaded_file',
|
|
||||||
build_only=True)
|
|
||||||
app.wsgi_app = SharedDataMiddleware(app.wsgi_app, {
|
|
||||||
'/uploads': app.config['UPLOAD_FOLDER']
|
|
||||||
})
|
|
||||||
|
|
||||||
If you now run the application everything should work as expected.
|
app.add_url_rule(
|
||||||
|
"/uploads/<name>", endpoint="download_file", build_only=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
Improving Uploads
|
Improving Uploads
|
||||||
|
|
|
||||||
|
|
@ -55,9 +55,10 @@ from .wrappers import Response
|
||||||
|
|
||||||
|
|
||||||
def _make_timedelta(value):
|
def _make_timedelta(value):
|
||||||
if not isinstance(value, timedelta):
|
if value is None or isinstance(value, timedelta):
|
||||||
return timedelta(seconds=value)
|
return value
|
||||||
return value
|
|
||||||
|
return timedelta(seconds=value)
|
||||||
|
|
||||||
|
|
||||||
class Flask(Scaffold):
|
class Flask(Scaffold):
|
||||||
|
|
@ -234,13 +235,16 @@ class Flask(Scaffold):
|
||||||
"PERMANENT_SESSION_LIFETIME", get_converter=_make_timedelta
|
"PERMANENT_SESSION_LIFETIME", get_converter=_make_timedelta
|
||||||
)
|
)
|
||||||
|
|
||||||
#: A :class:`~datetime.timedelta` which is used as default cache_timeout
|
#: A :class:`~datetime.timedelta` or number of seconds which is used
|
||||||
#: for the :func:`send_file` functions. The default is 12 hours.
|
#: 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
|
#: Configured with the :data:`SEND_FILE_MAX_AGE_DEFAULT`
|
||||||
#: ``SEND_FILE_MAX_AGE_DEFAULT`` configuration key. This configuration
|
#: configuration key.
|
||||||
#: variable can also be set with an integer value used as seconds.
|
#:
|
||||||
#: Defaults to ``timedelta(hours=12)``
|
#: .. versionchanged:: 2.0
|
||||||
|
#: Defaults to ``None`` instead of 12 hours.
|
||||||
send_file_max_age_default = ConfigAttribute(
|
send_file_max_age_default = ConfigAttribute(
|
||||||
"SEND_FILE_MAX_AGE_DEFAULT", get_converter=_make_timedelta
|
"SEND_FILE_MAX_AGE_DEFAULT", get_converter=_make_timedelta
|
||||||
)
|
)
|
||||||
|
|
@ -297,7 +301,7 @@ class Flask(Scaffold):
|
||||||
"SESSION_COOKIE_SAMESITE": None,
|
"SESSION_COOKIE_SAMESITE": None,
|
||||||
"SESSION_REFRESH_EACH_REQUEST": True,
|
"SESSION_REFRESH_EACH_REQUEST": True,
|
||||||
"MAX_CONTENT_LENGTH": None,
|
"MAX_CONTENT_LENGTH": None,
|
||||||
"SEND_FILE_MAX_AGE_DEFAULT": timedelta(hours=12),
|
"SEND_FILE_MAX_AGE_DEFAULT": None,
|
||||||
"TRAP_BAD_REQUEST_ERRORS": None,
|
"TRAP_BAD_REQUEST_ERRORS": None,
|
||||||
"TRAP_HTTP_EXCEPTIONS": False,
|
"TRAP_HTTP_EXCEPTIONS": False,
|
||||||
"EXPLAIN_TEMPLATE_LOADING": False,
|
"EXPLAIN_TEMPLATE_LOADING": False,
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,16 @@
|
||||||
import io
|
|
||||||
import mimetypes
|
|
||||||
import os
|
import os
|
||||||
import pkgutil
|
import pkgutil
|
||||||
import posixpath
|
|
||||||
import socket
|
import socket
|
||||||
import sys
|
import sys
|
||||||
import unicodedata
|
import warnings
|
||||||
from functools import update_wrapper
|
from functools import update_wrapper
|
||||||
from threading import RLock
|
from threading import RLock
|
||||||
from time import time
|
|
||||||
from zlib import adler32
|
|
||||||
|
|
||||||
|
import werkzeug.utils
|
||||||
from jinja2 import FileSystemLoader
|
from jinja2 import FileSystemLoader
|
||||||
from werkzeug.datastructures import Headers
|
|
||||||
from werkzeug.exceptions import BadRequest
|
|
||||||
from werkzeug.exceptions import NotFound
|
from werkzeug.exceptions import NotFound
|
||||||
from werkzeug.exceptions import RequestedRangeNotSatisfiable
|
|
||||||
from werkzeug.routing import BuildError
|
from werkzeug.routing import BuildError
|
||||||
from werkzeug.urls import url_quote
|
from werkzeug.urls import url_quote
|
||||||
from werkzeug.wsgi import wrap_file
|
|
||||||
|
|
||||||
from .globals import _app_ctx_stack
|
from .globals import _app_ctx_stack
|
||||||
from .globals import _request_ctx_stack
|
from .globals import _request_ctx_stack
|
||||||
|
|
@ -444,49 +436,116 @@ def get_flashed_messages(with_categories=False, category_filter=()):
|
||||||
return flashes
|
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(
|
def send_file(
|
||||||
filename_or_fp,
|
path_or_file,
|
||||||
mimetype=None,
|
mimetype=None,
|
||||||
as_attachment=False,
|
as_attachment=False,
|
||||||
|
download_name=None,
|
||||||
attachment_filename=None,
|
attachment_filename=None,
|
||||||
|
conditional=True,
|
||||||
add_etags=True,
|
add_etags=True,
|
||||||
cache_timeout=None,
|
|
||||||
conditional=False,
|
|
||||||
last_modified=None,
|
last_modified=None,
|
||||||
|
max_age=None,
|
||||||
|
cache_timeout=None,
|
||||||
):
|
):
|
||||||
"""Sends the contents of a file to the client. This will use the
|
"""Send the contents of a file to the client.
|
||||||
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
|
The first argument can be a file path or a file-like object. Paths
|
||||||
also explicitly provide one. For extra security you probably want
|
are preferred in most cases because Werkzeug can manage the file and
|
||||||
to send certain files as attachment (HTML for instance). The mimetype
|
get extra information from the path. Passing a file-like object
|
||||||
guessing requires a `filename` or an `attachment_filename` to be
|
requires that the file is opened in binary mode, and is mostly
|
||||||
provided.
|
useful when building a file in memory with :class:`io.BytesIO`.
|
||||||
|
|
||||||
When passing a file-like object instead of a filename, only binary
|
Never pass file paths provided by a user. The path is assumed to be
|
||||||
mode is supported (``open(filename, "rb")``, :class:`~io.BytesIO`,
|
trusted, so a user could craft a path to access a file you didn't
|
||||||
etc.). Text mode files and :class:`~io.StringIO` will raise a
|
intend. Use :func:`send_from_directory` to safely serve
|
||||||
:exc:`ValueError`.
|
user-requested paths from within a directory.
|
||||||
|
|
||||||
ETags will also be attached automatically if a `filename` is provided. You
|
If the WSGI server sets a ``file_wrapper`` in ``environ``, it is
|
||||||
can turn this off by setting `add_etags=False`.
|
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
|
:param path_or_file: The path to the file to send, relative to the
|
||||||
upgrade the response stream to support range requests. This will allow
|
current working directory if a relative path is given.
|
||||||
the request to be answered with partial content response.
|
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;
|
.. versionchanged:: 2.0
|
||||||
you should use :func:`send_from_directory` instead.
|
``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
|
.. versionchanged:: 2.0
|
||||||
Passing a file-like object that inherits from
|
Passing a file-like object that inherits from
|
||||||
:class:`~io.TextIOBase` will raise a :exc:`ValueError` rather
|
:class:`~io.TextIOBase` will raise a :exc:`ValueError` rather
|
||||||
than sending an empty file.
|
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
|
.. versionchanged:: 1.1
|
||||||
``filename`` may be a :class:`~os.PathLike` object.
|
``filename`` may be a :class:`~os.PathLike` object.
|
||||||
|
|
||||||
|
|
@ -498,260 +557,106 @@ def send_file(
|
||||||
compatibility with WSGI servers.
|
compatibility with WSGI servers.
|
||||||
|
|
||||||
.. versionchanged:: 1.0
|
.. versionchanged:: 1.0
|
||||||
UTF-8 filenames, as specified in `RFC 2231`_, are supported.
|
UTF-8 filenames as specified in :rfc:`2231` are supported.
|
||||||
|
|
||||||
.. _RFC 2231: https://tools.ietf.org/html/rfc2231#section-4
|
|
||||||
|
|
||||||
.. versionchanged:: 0.12
|
.. versionchanged:: 0.12
|
||||||
The filename is no longer automatically inferred from file
|
The filename is no longer automatically inferred from file
|
||||||
objects. If you want to use automatic MIME and etag support, pass
|
objects. If you want to use automatic MIME and etag support,
|
||||||
a filename via ``filename_or_fp`` or ``attachment_filename``.
|
pass a filename via ``filename_or_fp`` or
|
||||||
|
``attachment_filename``.
|
||||||
|
|
||||||
.. versionchanged:: 0.12
|
.. versionchanged:: 0.12
|
||||||
``attachment_filename`` is preferred over ``filename`` for MIME
|
``attachment_filename`` is preferred over ``filename`` for MIME
|
||||||
detection.
|
detection.
|
||||||
|
|
||||||
.. versionchanged:: 0.9
|
.. versionchanged:: 0.9
|
||||||
``cache_timeout`` defaults to
|
``cache_timeout`` defaults to
|
||||||
:meth:`Flask.get_send_file_max_age`.
|
:meth:`Flask.get_send_file_max_age`.
|
||||||
|
|
||||||
.. versionchanged:: 0.7
|
.. versionchanged:: 0.7
|
||||||
MIME guessing and etag support for file-like objects was
|
MIME guessing and etag support for file-like objects was
|
||||||
deprecated because it was unreliable. Pass a filename if you are
|
deprecated because it was unreliable. Pass a filename if you are
|
||||||
able to, otherwise attach an etag yourself. This functionality
|
able to, otherwise attach an etag yourself.
|
||||||
will be removed in Flask 1.0.
|
|
||||||
|
|
||||||
.. versionadded:: 0.5
|
.. versionchanged:: 0.5
|
||||||
The ``add_etags``, ``cache_timeout`` and ``conditional``
|
The ``add_etags``, ``cache_timeout`` and ``conditional``
|
||||||
parameters were added. The default behavior is to add etags.
|
parameters were added. The default behavior is to add etags.
|
||||||
|
|
||||||
.. versionadded:: 0.2
|
.. 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
|
return werkzeug.utils.send_file(
|
||||||
fsize = None
|
**_prepare_send_file_kwargs(
|
||||||
|
path_or_file=path_or_file,
|
||||||
if hasattr(filename_or_fp, "__fspath__"):
|
environ=request.environ,
|
||||||
filename_or_fp = os.fspath(filename_or_fp)
|
mimetype=mimetype,
|
||||||
|
as_attachment=as_attachment,
|
||||||
if isinstance(filename_or_fp, str):
|
download_name=download_name,
|
||||||
filename = filename_or_fp
|
attachment_filename=attachment_filename,
|
||||||
if not os.path.isabs(filename):
|
conditional=conditional,
|
||||||
filename = os.path.join(current_app.root_path, filename)
|
add_etags=add_etags,
|
||||||
file = None
|
last_modified=last_modified,
|
||||||
if attachment_filename is None:
|
max_age=max_age,
|
||||||
attachment_filename = os.path.basename(filename)
|
cache_timeout=cache_timeout,
|
||||||
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
|
|
||||||
)
|
)
|
||||||
|
|
||||||
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):
|
def safe_join(directory, *pathnames):
|
||||||
"""Safely join `directory` and zero or more untrusted `pathnames`
|
"""Safely join zero or more untrusted path components to a base
|
||||||
components.
|
directory to avoid escaping the base directory.
|
||||||
|
|
||||||
Example usage::
|
:param directory: The trusted base directory.
|
||||||
|
:param pathnames: The untrusted path components relative to the
|
||||||
@app.route('/wiki/<path:filename>')
|
base directory.
|
||||||
def wiki_page(filename):
|
:return: A safe path, otherwise ``None``.
|
||||||
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.
|
|
||||||
"""
|
"""
|
||||||
|
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:
|
return path
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
def send_from_directory(directory, filename, **options):
|
def send_from_directory(directory, path, **kwargs):
|
||||||
"""Send a file from a given directory with :func:`send_file`. This
|
"""Send a file from within a directory using :func:`send_file`.
|
||||||
is a secure way to quickly expose static files from an upload folder
|
|
||||||
or something similar.
|
|
||||||
|
|
||||||
Example usage::
|
.. code-block:: python
|
||||||
|
|
||||||
@app.route('/uploads/<path:filename>')
|
@app.route("/uploads/<path:name>")
|
||||||
def download_file(filename):
|
def download_file(name):
|
||||||
return send_from_directory(app.config['UPLOAD_FOLDER'],
|
return send_from_directory(
|
||||||
filename, as_attachment=True)
|
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
|
If the final path does not point to an existing regular file,
|
||||||
your webserver or (if no authentication happens) to tell the webserver
|
raises a 404 :exc:`~werkzeug.exceptions.NotFound` error.
|
||||||
to serve files for the given path on its own without calling into the
|
|
||||||
web application for improved performance.
|
: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
|
.. 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)
|
return werkzeug.utils.send_from_directory(
|
||||||
directory = os.fspath(directory)
|
directory, path, **_prepare_send_file_kwargs(**kwargs)
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
def get_root_path(import_name):
|
def get_root_path(import_name):
|
||||||
|
|
@ -1016,30 +921,25 @@ class _PackageBoundObject:
|
||||||
return FileSystemLoader(os.path.join(self.root_path, self.template_folder))
|
return FileSystemLoader(os.path.join(self.root_path, self.template_folder))
|
||||||
|
|
||||||
def get_send_file_max_age(self, filename):
|
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
|
By default, this returns :data:`SEND_FILE_MAX_AGE_DEFAULT` from
|
||||||
the configuration of :data:`~flask.current_app`.
|
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
|
.. versionchanged:: 2.0
|
||||||
function, and :func:`send_file` calls this function on
|
The default configuration is ``None`` instead of 12 hours.
|
||||||
: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)
|
|
||||||
|
|
||||||
.. versionadded:: 0.9
|
.. 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):
|
def send_static_file(self, filename):
|
||||||
"""Function used internally to send static files from the static
|
"""Function used internally to send static files from the static
|
||||||
|
|
@ -1049,12 +949,11 @@ class _PackageBoundObject:
|
||||||
"""
|
"""
|
||||||
if not self.has_static_folder:
|
if not self.has_static_folder:
|
||||||
raise RuntimeError("No static folder for this object")
|
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.
|
# send_file only knows to call get_send_file_max_age on the app,
|
||||||
cache_timeout = self.get_send_file_max_age(filename)
|
# call it here so it works for blueprints too.
|
||||||
return send_from_directory(
|
max_age = self.get_send_file_max_age(filename)
|
||||||
self.static_folder, filename, cache_timeout=cache_timeout
|
return send_from_directory(self.static_folder, filename, max_age=max_age)
|
||||||
)
|
|
||||||
|
|
||||||
def open_resource(self, resource, mode="rb"):
|
def open_resource(self, resource, mode="rb"):
|
||||||
"""Opens a resource from the application's resource folder. To see
|
"""Opens a resource from the application's resource folder. To see
|
||||||
|
|
|
||||||
|
|
@ -222,7 +222,7 @@ def test_templates_and_static(test_apps):
|
||||||
assert flask.render_template("nested/nested.txt") == "I'm nested"
|
assert flask.render_template("nested/nested.txt") == "I'm nested"
|
||||||
|
|
||||||
|
|
||||||
def test_default_static_cache_timeout(app):
|
def test_default_static_max_age(app):
|
||||||
class MyBlueprint(flask.Blueprint):
|
class MyBlueprint(flask.Blueprint):
|
||||||
def get_send_file_max_age(self, filename):
|
def get_send_file_max_age(self, filename):
|
||||||
return 100
|
return 100
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,7 @@
|
||||||
import datetime
|
|
||||||
import io
|
import io
|
||||||
import os
|
import os
|
||||||
import sys
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from werkzeug.datastructures import Range
|
|
||||||
from werkzeug.exceptions import BadRequest
|
|
||||||
from werkzeug.exceptions import NotFound
|
|
||||||
from werkzeug.http import http_date
|
|
||||||
from werkzeug.http import parse_cache_control_header
|
|
||||||
from werkzeug.http import parse_options_header
|
|
||||||
|
|
||||||
import flask
|
import flask
|
||||||
from flask.helpers import get_debug_flag
|
from flask.helpers import get_debug_flag
|
||||||
|
|
@ -39,278 +31,45 @@ class PyBytesIO:
|
||||||
|
|
||||||
|
|
||||||
class TestSendfile:
|
class TestSendfile:
|
||||||
def test_send_file_regular(self, app, req_ctx):
|
def test_send_file(self, app, req_ctx):
|
||||||
rv = flask.send_file("static/index.html")
|
rv = flask.send_file("static/index.html")
|
||||||
assert rv.direct_passthrough
|
assert rv.direct_passthrough
|
||||||
assert rv.mimetype == "text/html"
|
assert rv.mimetype == "text/html"
|
||||||
|
|
||||||
with app.open_resource("static/index.html") as f:
|
with app.open_resource("static/index.html") as f:
|
||||||
rv.direct_passthrough = False
|
rv.direct_passthrough = False
|
||||||
assert rv.data == f.read()
|
assert rv.data == f.read()
|
||||||
|
|
||||||
rv.close()
|
rv.close()
|
||||||
|
|
||||||
def test_send_file_xsendfile(self, app, req_ctx):
|
|
||||||
app.use_x_sendfile = True
|
|
||||||
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"
|
|
||||||
rv.close()
|
|
||||||
|
|
||||||
def test_send_file_last_modified(self, app, client):
|
|
||||||
last_modified = datetime.datetime(1999, 1, 1)
|
|
||||||
|
|
||||||
@app.route("/")
|
|
||||||
def index():
|
|
||||||
return flask.send_file(
|
|
||||||
io.BytesIO(b"party like it's"),
|
|
||||||
last_modified=last_modified,
|
|
||||||
mimetype="text/plain",
|
|
||||||
)
|
|
||||||
|
|
||||||
rv = client.get("/")
|
|
||||||
assert rv.last_modified == last_modified
|
|
||||||
|
|
||||||
def test_send_file_object_without_mimetype(self, app, req_ctx):
|
|
||||||
with pytest.raises(ValueError) as excinfo:
|
|
||||||
flask.send_file(io.BytesIO(b"LOL"))
|
|
||||||
assert "Unable to infer MIME-type" in str(excinfo.value)
|
|
||||||
assert "no filename is available" in str(excinfo.value)
|
|
||||||
|
|
||||||
flask.send_file(io.BytesIO(b"LOL"), attachment_filename="filename")
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"opener",
|
|
||||||
[
|
|
||||||
lambda app: open(os.path.join(app.static_folder, "index.html"), "rb"),
|
|
||||||
lambda app: io.BytesIO(b"Test"),
|
|
||||||
lambda app: PyBytesIO(b"Test"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
@pytest.mark.usefixtures("req_ctx")
|
|
||||||
def test_send_file_object(self, app, opener):
|
|
||||||
file = opener(app)
|
|
||||||
app.use_x_sendfile = True
|
|
||||||
rv = flask.send_file(file, mimetype="text/plain")
|
|
||||||
rv.direct_passthrough = False
|
|
||||||
assert rv.data
|
|
||||||
assert rv.mimetype == "text/plain"
|
|
||||||
assert "x-sendfile" not in rv.headers
|
|
||||||
rv.close()
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"opener",
|
|
||||||
[
|
|
||||||
lambda app: io.StringIO("Test"),
|
|
||||||
lambda app: open(os.path.join(app.static_folder, "index.html")),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
@pytest.mark.usefixtures("req_ctx")
|
|
||||||
def test_send_file_text_fails(self, app, opener):
|
|
||||||
file = opener(app)
|
|
||||||
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
flask.send_file(file, mimetype="text/plain")
|
|
||||||
|
|
||||||
file.close()
|
|
||||||
|
|
||||||
def test_send_file_pathlike(self, app, req_ctx):
|
|
||||||
rv = flask.send_file(FakePath("static/index.html"))
|
|
||||||
assert rv.direct_passthrough
|
|
||||||
assert rv.mimetype == "text/html"
|
|
||||||
with app.open_resource("static/index.html") as f:
|
|
||||||
rv.direct_passthrough = False
|
|
||||||
assert rv.data == f.read()
|
|
||||||
rv.close()
|
|
||||||
|
|
||||||
@pytest.mark.skipif(
|
|
||||||
not callable(getattr(Range, "to_content_range_header", None)),
|
|
||||||
reason="not implemented within werkzeug",
|
|
||||||
)
|
|
||||||
def test_send_file_range_request(self, app, client):
|
|
||||||
@app.route("/")
|
|
||||||
def index():
|
|
||||||
return flask.send_file("static/index.html", conditional=True)
|
|
||||||
|
|
||||||
rv = client.get("/", headers={"Range": "bytes=4-15"})
|
|
||||||
assert rv.status_code == 206
|
|
||||||
with app.open_resource("static/index.html") as f:
|
|
||||||
assert rv.data == f.read()[4:16]
|
|
||||||
rv.close()
|
|
||||||
|
|
||||||
rv = client.get("/", headers={"Range": "bytes=4-"})
|
|
||||||
assert rv.status_code == 206
|
|
||||||
with app.open_resource("static/index.html") as f:
|
|
||||||
assert rv.data == f.read()[4:]
|
|
||||||
rv.close()
|
|
||||||
|
|
||||||
rv = client.get("/", headers={"Range": "bytes=4-1000"})
|
|
||||||
assert rv.status_code == 206
|
|
||||||
with app.open_resource("static/index.html") as f:
|
|
||||||
assert rv.data == f.read()[4:]
|
|
||||||
rv.close()
|
|
||||||
|
|
||||||
rv = client.get("/", headers={"Range": "bytes=-10"})
|
|
||||||
assert rv.status_code == 206
|
|
||||||
with app.open_resource("static/index.html") as f:
|
|
||||||
assert rv.data == f.read()[-10:]
|
|
||||||
rv.close()
|
|
||||||
|
|
||||||
rv = client.get("/", headers={"Range": "bytes=1000-"})
|
|
||||||
assert rv.status_code == 416
|
|
||||||
rv.close()
|
|
||||||
|
|
||||||
rv = client.get("/", headers={"Range": "bytes=-"})
|
|
||||||
assert rv.status_code == 416
|
|
||||||
rv.close()
|
|
||||||
|
|
||||||
rv = client.get("/", headers={"Range": "somethingsomething"})
|
|
||||||
assert rv.status_code == 416
|
|
||||||
rv.close()
|
|
||||||
|
|
||||||
last_modified = datetime.datetime.utcfromtimestamp(
|
|
||||||
os.path.getmtime(os.path.join(app.root_path, "static/index.html"))
|
|
||||||
).replace(microsecond=0)
|
|
||||||
|
|
||||||
rv = client.get(
|
|
||||||
"/", headers={"Range": "bytes=4-15", "If-Range": http_date(last_modified)}
|
|
||||||
)
|
|
||||||
assert rv.status_code == 206
|
|
||||||
rv.close()
|
|
||||||
|
|
||||||
rv = client.get(
|
|
||||||
"/",
|
|
||||||
headers={
|
|
||||||
"Range": "bytes=4-15",
|
|
||||||
"If-Range": http_date(datetime.datetime(1999, 1, 1)),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert rv.status_code == 200
|
|
||||||
rv.close()
|
|
||||||
|
|
||||||
def test_send_file_range_request_bytesio(self, app, client):
|
|
||||||
@app.route("/")
|
|
||||||
def index():
|
|
||||||
file = io.BytesIO(b"somethingsomething")
|
|
||||||
return flask.send_file(
|
|
||||||
file, attachment_filename="filename", conditional=True
|
|
||||||
)
|
|
||||||
|
|
||||||
rv = client.get("/", headers={"Range": "bytes=4-15"})
|
|
||||||
assert rv.status_code == 206
|
|
||||||
assert rv.data == b"somethingsomething"[4:16]
|
|
||||||
rv.close()
|
|
||||||
|
|
||||||
def test_send_file_range_request_xsendfile_invalid(self, app, client):
|
|
||||||
# https://github.com/pallets/flask/issues/2526
|
|
||||||
app.use_x_sendfile = True
|
|
||||||
|
|
||||||
@app.route("/")
|
|
||||||
def index():
|
|
||||||
return flask.send_file("static/index.html", conditional=True)
|
|
||||||
|
|
||||||
rv = client.get("/", headers={"Range": "bytes=1000-"})
|
|
||||||
assert rv.status_code == 416
|
|
||||||
rv.close()
|
|
||||||
|
|
||||||
def test_attachment(self, app, req_ctx):
|
|
||||||
app = flask.Flask(__name__)
|
|
||||||
with app.test_request_context():
|
|
||||||
with open(os.path.join(app.root_path, "static/index.html"), "rb") as f:
|
|
||||||
rv = flask.send_file(
|
|
||||||
f, as_attachment=True, attachment_filename="index.html"
|
|
||||||
)
|
|
||||||
value, options = parse_options_header(rv.headers["Content-Disposition"])
|
|
||||||
assert value == "attachment"
|
|
||||||
rv.close()
|
|
||||||
|
|
||||||
with open(os.path.join(app.root_path, "static/index.html"), "rb") as f:
|
|
||||||
rv = flask.send_file(
|
|
||||||
f, as_attachment=True, attachment_filename="index.html"
|
|
||||||
)
|
|
||||||
value, options = parse_options_header(rv.headers["Content-Disposition"])
|
|
||||||
assert value == "attachment"
|
|
||||||
assert options["filename"] == "index.html"
|
|
||||||
assert "filename*" not in rv.headers["Content-Disposition"]
|
|
||||||
rv.close()
|
|
||||||
|
|
||||||
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"
|
|
||||||
rv.close()
|
|
||||||
|
|
||||||
rv = flask.send_file(
|
|
||||||
io.BytesIO(b"Test"),
|
|
||||||
as_attachment=True,
|
|
||||||
attachment_filename="index.txt",
|
|
||||||
add_etags=False,
|
|
||||||
)
|
|
||||||
assert rv.mimetype == "text/plain"
|
|
||||||
value, options = parse_options_header(rv.headers["Content-Disposition"])
|
|
||||||
assert value == "attachment"
|
|
||||||
assert options["filename"] == "index.txt"
|
|
||||||
rv.close()
|
|
||||||
|
|
||||||
@pytest.mark.usefixtures("req_ctx")
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("filename", "ascii", "utf8"),
|
|
||||||
(
|
|
||||||
("index.html", "index.html", False),
|
|
||||||
(
|
|
||||||
"Ñandú/pingüino.txt",
|
|
||||||
'"Nandu/pinguino.txt"',
|
|
||||||
"%C3%91and%C3%BA%EF%BC%8Fping%C3%BCino.txt",
|
|
||||||
),
|
|
||||||
("Vögel.txt", "Vogel.txt", "V%C3%B6gel.txt"),
|
|
||||||
# ":/" are not safe in filename* value
|
|
||||||
("те:/ст", '":/"', "%D1%82%D0%B5%3A%2F%D1%81%D1%82"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
def test_attachment_filename_encoding(self, filename, ascii, utf8):
|
|
||||||
rv = flask.send_file(
|
|
||||||
"static/index.html", as_attachment=True, attachment_filename=filename
|
|
||||||
)
|
|
||||||
rv.close()
|
|
||||||
content_disposition = rv.headers["Content-Disposition"]
|
|
||||||
assert f"filename={ascii}" in content_disposition
|
|
||||||
if utf8:
|
|
||||||
assert f"filename*=UTF-8''{utf8}" in content_disposition
|
|
||||||
else:
|
|
||||||
assert "filename*=UTF-8''" not in content_disposition
|
|
||||||
|
|
||||||
def test_static_file(self, app, req_ctx):
|
def test_static_file(self, app, req_ctx):
|
||||||
# default cache timeout is 12 hours
|
# Default max_age is None.
|
||||||
|
|
||||||
# Test with static file handler.
|
# Test with static file handler.
|
||||||
rv = app.send_static_file("index.html")
|
rv = app.send_static_file("index.html")
|
||||||
cc = parse_cache_control_header(rv.headers["Cache-Control"])
|
assert rv.cache_control.max_age is None
|
||||||
assert cc.max_age == 12 * 60 * 60
|
|
||||||
rv.close()
|
rv.close()
|
||||||
# Test again with direct use of send_file utility.
|
|
||||||
|
# Test with direct use of send_file.
|
||||||
rv = flask.send_file("static/index.html")
|
rv = flask.send_file("static/index.html")
|
||||||
cc = parse_cache_control_header(rv.headers["Cache-Control"])
|
assert rv.cache_control.max_age is None
|
||||||
assert cc.max_age == 12 * 60 * 60
|
|
||||||
rv.close()
|
rv.close()
|
||||||
|
|
||||||
app.config["SEND_FILE_MAX_AGE_DEFAULT"] = 3600
|
app.config["SEND_FILE_MAX_AGE_DEFAULT"] = 3600
|
||||||
|
|
||||||
# Test with static file handler.
|
# Test with static file handler.
|
||||||
rv = app.send_static_file("index.html")
|
rv = app.send_static_file("index.html")
|
||||||
cc = parse_cache_control_header(rv.headers["Cache-Control"])
|
assert rv.cache_control.max_age == 3600
|
||||||
assert cc.max_age == 3600
|
|
||||||
rv.close()
|
|
||||||
# Test again with direct use of send_file utility.
|
|
||||||
rv = flask.send_file("static/index.html")
|
|
||||||
cc = parse_cache_control_header(rv.headers["Cache-Control"])
|
|
||||||
assert cc.max_age == 3600
|
|
||||||
rv.close()
|
rv.close()
|
||||||
|
|
||||||
# Test with static file handler.
|
# Test with direct use of send_file.
|
||||||
|
rv = flask.send_file("static/index.html")
|
||||||
|
assert rv.cache_control.max_age == 3600
|
||||||
|
rv.close()
|
||||||
|
|
||||||
|
# Test with pathlib.Path.
|
||||||
rv = app.send_static_file(FakePath("index.html"))
|
rv = app.send_static_file(FakePath("index.html"))
|
||||||
cc = parse_cache_control_header(rv.headers["Cache-Control"])
|
assert rv.cache_control.max_age == 3600
|
||||||
assert cc.max_age == 3600
|
|
||||||
rv.close()
|
rv.close()
|
||||||
|
|
||||||
class StaticFileApp(flask.Flask):
|
class StaticFileApp(flask.Flask):
|
||||||
|
|
@ -318,16 +77,16 @@ class TestSendfile:
|
||||||
return 10
|
return 10
|
||||||
|
|
||||||
app = StaticFileApp(__name__)
|
app = StaticFileApp(__name__)
|
||||||
|
|
||||||
with app.test_request_context():
|
with app.test_request_context():
|
||||||
# Test with static file handler.
|
# Test with static file handler.
|
||||||
rv = app.send_static_file("index.html")
|
rv = app.send_static_file("index.html")
|
||||||
cc = parse_cache_control_header(rv.headers["Cache-Control"])
|
assert rv.cache_control.max_age == 10
|
||||||
assert cc.max_age == 10
|
|
||||||
rv.close()
|
rv.close()
|
||||||
# Test again with direct use of send_file utility.
|
|
||||||
|
# Test with direct use of send_file.
|
||||||
rv = flask.send_file("static/index.html")
|
rv = flask.send_file("static/index.html")
|
||||||
cc = parse_cache_control_header(rv.headers["Cache-Control"])
|
assert rv.cache_control.max_age == 10
|
||||||
assert cc.max_age == 10
|
|
||||||
rv.close()
|
rv.close()
|
||||||
|
|
||||||
def test_send_from_directory(self, app, req_ctx):
|
def test_send_from_directory(self, app, req_ctx):
|
||||||
|
|
@ -339,28 +98,6 @@ class TestSendfile:
|
||||||
assert rv.data.strip() == b"Hello Subdomain"
|
assert rv.data.strip() == b"Hello Subdomain"
|
||||||
rv.close()
|
rv.close()
|
||||||
|
|
||||||
def test_send_from_directory_pathlike(self, app, req_ctx):
|
|
||||||
app.root_path = os.path.join(
|
|
||||||
os.path.dirname(__file__), "test_apps", "subdomaintestmodule"
|
|
||||||
)
|
|
||||||
rv = flask.send_from_directory(FakePath("static"), FakePath("hello.txt"))
|
|
||||||
rv.direct_passthrough = False
|
|
||||||
assert rv.data.strip() == b"Hello Subdomain"
|
|
||||||
rv.close()
|
|
||||||
|
|
||||||
def test_send_from_directory_null_character(self, app, req_ctx):
|
|
||||||
app.root_path = os.path.join(
|
|
||||||
os.path.dirname(__file__), "test_apps", "subdomaintestmodule"
|
|
||||||
)
|
|
||||||
|
|
||||||
if sys.version_info >= (3, 8):
|
|
||||||
exception = NotFound
|
|
||||||
else:
|
|
||||||
exception = BadRequest
|
|
||||||
|
|
||||||
with pytest.raises(exception):
|
|
||||||
flask.send_from_directory("static", "bad\x00")
|
|
||||||
|
|
||||||
|
|
||||||
class TestUrlFor:
|
class TestUrlFor:
|
||||||
def test_url_for_with_anchor(self, app, req_ctx):
|
def test_url_for_with_anchor(self, app, req_ctx):
|
||||||
|
|
@ -514,47 +251,6 @@ class TestStreaming:
|
||||||
assert rv.data == b"flask"
|
assert rv.data == b"flask"
|
||||||
|
|
||||||
|
|
||||||
class TestSafeJoin:
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"args, expected",
|
|
||||||
(
|
|
||||||
(("a/b/c",), "a/b/c"),
|
|
||||||
(("/", "a/", "b/", "c/"), "/a/b/c"),
|
|
||||||
(("a", "b", "c"), "a/b/c"),
|
|
||||||
(("/a", "b/c"), "/a/b/c"),
|
|
||||||
(("a/b", "X/../c"), "a/b/c"),
|
|
||||||
(("/a/b", "c/X/.."), "/a/b/c"),
|
|
||||||
# If last path is '' add a slash
|
|
||||||
(("/a/b/c", ""), "/a/b/c/"),
|
|
||||||
# Preserve dot slash
|
|
||||||
(("/a/b/c", "./"), "/a/b/c/."),
|
|
||||||
(("a/b/c", "X/.."), "a/b/c/."),
|
|
||||||
# Base directory is always considered safe
|
|
||||||
(("../", "a/b/c"), "../a/b/c"),
|
|
||||||
(("/..",), "/.."),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
def test_safe_join(self, args, expected):
|
|
||||||
assert flask.safe_join(*args) == expected
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"args",
|
|
||||||
(
|
|
||||||
# path.isabs and ``..'' checks
|
|
||||||
("/a", "b", "/c"),
|
|
||||||
("/a", "../b/c"),
|
|
||||||
("/a", "..", "b/c"),
|
|
||||||
# Boundaries violations after path normalization
|
|
||||||
("/a", "b/../b/../../c"),
|
|
||||||
("/a", "b", "c/../.."),
|
|
||||||
("/a", "b/../../c"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
def test_safe_join_exceptions(self, args):
|
|
||||||
with pytest.raises(NotFound):
|
|
||||||
print(flask.safe_join(*args))
|
|
||||||
|
|
||||||
|
|
||||||
class TestHelpers:
|
class TestHelpers:
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"debug, expected_flag, expected_default_flag",
|
"debug, expected_flag, expected_default_flag",
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import platform
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from werkzeug.exceptions import NotFound
|
|
||||||
|
|
||||||
import flask
|
import flask
|
||||||
|
|
||||||
|
|
@ -56,13 +55,6 @@ def test_memory_consumption():
|
||||||
fire()
|
fire()
|
||||||
|
|
||||||
|
|
||||||
def test_safe_join_toplevel_pardir():
|
|
||||||
from flask.helpers import safe_join
|
|
||||||
|
|
||||||
with pytest.raises(NotFound):
|
|
||||||
safe_join("/foo", "..")
|
|
||||||
|
|
||||||
|
|
||||||
def test_aborting(app):
|
def test_aborting(app):
|
||||||
class Foo(Exception):
|
class Foo(Exception):
|
||||||
whatever = 42
|
whatever = 42
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue