Repository: pallets/werkzeug

Issue:
add send_from_directory from Flask
#1850 added `send_file` from Flask, but `send_from_directory` should also be added.


Discussion:

Resolving patch:
diff --git a/src/werkzeug/utils.py b/src/werkzeug/utils.py
--- a/src/werkzeug/utils.py
+++ b/src/werkzeug/utils.py
@@ -16,7 +16,9 @@
 from ._internal import _missing
 from ._internal import _parse_signature
 from .datastructures import Headers
+from .exceptions import NotFound
 from .exceptions import RequestedRangeNotSatisfiable
+from .security import safe_join
 from .urls import url_quote
 from .wsgi import wrap_file
 
@@ -728,6 +730,41 @@ def send_file(
     return rv
 
 
+def send_from_directory(directory, path, environ, **kwargs):
+    """Send a file from within a directory using :func:`send_file`.
+
+    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.
+
+    If the final path does not point to an existing regular file,
+    returns 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 environ: The WSGI environ for the current request.
+    :param kwargs: Arguments to pass to :func:`send_file`.
+
+    .. versionadded:: 2.0.0
+        Adapted from Flask's implementation.
+    """
+    path = safe_join(os.fspath(directory), os.fspath(path))
+
+    if path is None:
+        raise NotFound()
+
+    try:
+        if not os.path.isfile(path):
+            raise NotFound()
+    except ValueError:
+        # path contains null byte on Python < 3.8
+        raise NotFound()
+
+    return send_file(path, environ, **kwargs)
+
+
 def import_string(import_name, silent=False):
     """Imports an object based on a string.  This is useful if you want to
     use import paths as endpoints or something similar.  An import path can


Test patch:
diff --git a/tests/test_send_file.py b/tests/test_send_file.py
--- a/tests/test_send_file.py
+++ b/tests/test_send_file.py
@@ -4,9 +4,11 @@
 
 import pytest
 
+from werkzeug.exceptions import NotFound
 from werkzeug.http import http_date
 from werkzeug.test import EnvironBuilder
 from werkzeug.utils import send_file
+from werkzeug.utils import send_from_directory
 
 res_path = pathlib.Path(__file__).parent / "res"
 html_path = res_path / "index.html"
@@ -144,3 +146,20 @@ def test_max_age(value, public):
     assert rv.cache_control.max_age == value
     assert rv.expires
     assert rv.status_code == 200
+
+
+@pytest.mark.parametrize(
+    ("directory", "path"),
+    [(str(res_path), "test.txt"), (res_path, pathlib.Path("test.txt"))],
+)
+def test_from_directory(directory, path):
+    rv = send_from_directory(directory, path, environ)
+    rv.direct_passthrough = False
+    assert rv.data.strip() == b"FOUND"
+    rv.close()
+
+
+@pytest.mark.parametrize("path", ["../unsafe.txt", "nothing.txt", "null\x00.txt"])
+def test_from_directory_not_found(path):
+    with pytest.raises(NotFound):
+        send_from_directory(res_path, "../bad.txt", environ)