Repository: falconry/falcon

Issue:
Add default file name in the StaticRoute 
Consider adding the ability to specify a default filename for a static route. 

I'm not sure if it would be best to send the file or raise an `HTTPMovedPermanently` (or a `HTTPPermanentRedirect`). 
Probably for performance reasons the redirect would be best (but in this case maybe `default_filename` should become `default_location`)



Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/falconry/falcon/pull/1226?src=pr&el=h1) Report
> Merging [#1226](https://codecov.io/gh/falconry/falcon/pull/1226?src=pr&el=desc) into [master](https://codecov.io/gh/falconry/falcon/commit/800396f0a8d9d13889386522c4bee3e4661d547f?src=pr&el=desc) will **not change** coverage.
> The diff coverage is `100%`.

[![Impacted file tree graph](https://codecov.io/gh/falconry/falcon/pull/1226/graphs/tree.svg?height=150&src=pr&token=zOtkfNQ3h6&width=650)](https://codecov.io/gh/falconry/falcon/pull/1226?src=pr&el=tree)

```diff
@@          Coverage Diff           @@
##           master   #1226   +/-   ##
======================================
  Coverage     100%    100%           
======================================
  Files          38      38           
  Lines        2484    2491    +7     
  Branches      364     367    +3     
======================================
+ Hits         2484    2491    +7
```


| [Impacted Files](https://codecov.io/gh/falconry/falcon/pull/1226?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [falcon/routing/static.py](https://codecov.io/gh/falconry/falcon/pull/1226/diff?src=pr&el=tree#diff-ZmFsY29uL3JvdXRpbmcvc3RhdGljLnB5) | `100% <100%> (ø)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/falconry/falcon/pull/1226?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/falconry/falcon/pull/1226?src=pr&el=footer). Last update [800396f...d77e0ec](https://codecov.io/gh/falconry/falcon/pull/1226?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

CaselIT: I should've addressed the comments.

Patch:
diff --git a/falcon/api.py b/falcon/api.py
index c1ef8fa..65e0d76 100644
--- a/falcon/api.py
+++ b/falcon/api.py
@@ -371,7 +371,7 @@ class API(object):
         self._router.add_route(uri_template, method_map, resource, *args,
                                **kwargs)
 
-    def add_static_route(self, prefix, directory, downloadable=False):
+    def add_static_route(self, prefix, directory, downloadable=False, fallback_filename=None):
         """Add a route to a directory of static files.
 
         Static routes provide a way to serve files directly. This
@@ -383,6 +383,8 @@ class API(object):
             Serving files directly from the web server,
             rather than through the Python app, will always be more efficient,
             and therefore should be preferred in production deployments.
+            For security reasons, the directory and the fallback_filename (if provided)
+            should be read only for the account running the application.
 
         Static routes are matched in LIFO order. Therefore, if the same
         prefix is used for two routes, the second one will override the
@@ -410,12 +412,16 @@ class API(object):
             downloadable (bool): Set to ``True`` to include a
                 Content-Disposition header in the response. The "filename"
                 directive is simply set to the name of the requested file.
+            fallback_filename (str): Fallback filename used when the requested file
+                is not found. Can be a relative path inside the prefix folder or any valid
+                absolute path.
 
         """
 
         self._static_routes.insert(
             0,
-            routing.StaticRoute(prefix, directory, downloadable=downloadable)
+            routing.StaticRoute(prefix, directory, downloadable=downloadable,
+                                fallback_filename=fallback_filename)
         )
 
     def add_sink(self, sink, prefix=r'/'):
diff --git a/falcon/routing/static.py b/falcon/routing/static.py
index ba3fab2..d659f99 100644
--- a/falcon/routing/static.py
+++ b/falcon/routing/static.py
@@ -24,6 +24,9 @@ class StaticRoute(object):
         downloadable (bool): Set to ``True`` to include a
             Content-Disposition header in the response. The "filename"
             directive is simply set to the name of the requested file.
+        fallback_filename (str): Fallback filename used when the requested file
+                is not found. Can be a relative path inside the prefix folder or any valid
+                absolute path.
     """
 
     # NOTE(kgriffs): Don't allow control characters and reserved chars
@@ -33,13 +36,20 @@ class StaticRoute(object):
     # minimizes how much can be included in the payload.
     _MAX_NON_PREFIXED_LEN = 512
 
-    def __init__(self, prefix, directory, downloadable=False):
+    def __init__(self, prefix, directory, downloadable=False, fallback_filename=None):
         if not prefix.startswith('/'):
             raise ValueError("prefix must start with '/'")
 
         if not os.path.isabs(directory):
             raise ValueError('directory must be an absolute path')
 
+        if fallback_filename is None:
+            self._fallback_filename = None
+        else:
+            self._fallback_filename = os.path.join(directory, fallback_filename)
+            if not os.path.isfile(self._fallback_filename):
+                raise ValueError('fallback_filename is not a file')
+
         # NOTE(kgriffs): Ensure it ends with a path separator to ensure
         # we only match on the complete segment. Don't raise an error
         # because most people won't expect to have to append a slash.
@@ -52,7 +62,9 @@ class StaticRoute(object):
 
     def match(self, path):
         """Check whether the given path matches this route."""
-        return path.startswith(self._prefix)
+        if self._fallback_filename is None:
+            return path.startswith(self._prefix)
+        return path.startswith(self._prefix) or path == self._prefix[:-1]
 
     def __call__(self, req, resp):
         """Resource responder for this route."""
@@ -61,7 +73,8 @@ class StaticRoute(object):
 
         # NOTE(kgriffs): Check surrounding whitespace and strip trailing
         # periods, which are illegal on windows
-        if (not without_prefix or
+        # NOTE(CaselIT): An empty filename is allowed when fallback_filename is provided
+        if (not (without_prefix or self._fallback_filename is not None) or
                 without_prefix.strip().rstrip('.') != without_prefix or
                 self._DISALLOWED_CHARS_PATTERN.search(without_prefix) or
                 '\\' in without_prefix or
@@ -86,7 +99,12 @@ class StaticRoute(object):
         try:
             resp.stream = io.open(file_path, 'rb')
         except IOError:
-            raise falcon.HTTPNotFound()
+            if self._fallback_filename is None:
+                raise falcon.HTTPNotFound()
+            try:
+                resp.stream = io.open(self._fallback_filename, 'rb')
+            except IOError:
+                raise falcon.HTTPNotFound()
 
         suffix = os.path.splitext(file_path)[1]
         resp.content_type = resp.options.static_media_types.get(


Test patch:
diff --git a/tests/test_static.py b/tests/test_static.py
index 2fbb55c..b92d978 100644
--- a/tests/test_static.py
+++ b/tests/test_static.py
@@ -1,6 +1,7 @@
 # -*- coding: utf-8 -*-
 
 import io
+import os
 
 import pytest
 
@@ -106,6 +107,21 @@ def test_invalid_args(prefix, directory, client):
         client.app.add_static_route(prefix, directory)
 
 
+@pytest.mark.parametrize('default', [
+    'not-existing-file',
+    # directories
+    '.',
+    '/tmp',
+])
+def test_invalid_args_fallback_filename(client, default):
+    prefix, directory = '/static', '/var/www/statics'
+    with pytest.raises(ValueError, match='fallback_filename'):
+        StaticRoute(prefix, directory, fallback_filename=default)
+
+    with pytest.raises(ValueError, match='fallback_filename'):
+        client.app.add_static_route(prefix, directory, fallback_filename=default)
+
+
 @pytest.mark.parametrize('uri_prefix, uri_path, expected_path, mtype', [
     ('/static/', '/css/test.css', '/css/test.css', 'text/css'),
     ('/static', '/css/test.css', '/css/test.css', 'text/css'),
@@ -199,3 +215,88 @@ def test_downloadable_not_found(client):
 
     response = client.simulate_request(path='/downloads/thing.zip')
     assert response.status == falcon.HTTP_404
+
+
+@pytest.mark.parametrize('uri, default, expected', [
+    ('', 'default', 'default'),
+    ('other', 'default', 'default'),
+    ('index2', 'index', 'index2'),
+    ('absolute', '/foo/bar/index', '/foo/bar/index'),
+])
+def test_fallback_filename(uri, default, expected, monkeypatch):
+
+    def mockOpen(path, mode):
+        if default in path:
+            return path
+        raise IOError()
+
+    monkeypatch.setattr(io, 'open', mockOpen)
+    monkeypatch.setattr('os.path.isfile', lambda file: default in file)
+
+    sr = StaticRoute('/static', '/var/www/statics', fallback_filename=default)
+
+    req_path = '/static/' + uri
+
+    req = Request(testing.create_environ(
+        host='test.com',
+        path=req_path,
+        app='statics'
+    ))
+    resp = Response()
+    sr(req, resp)
+
+    assert sr.match(req.path)
+    assert resp.stream == os.path.join('/var/www/statics', expected)
+
+
+@pytest.mark.parametrize('strip_slash', [True, False])
+@pytest.mark.parametrize('path, fallback, static_exp, assert_axp', [
+    ('/index', 'index.html', 'index', 'index'),
+    ('', 'index.html', 'index.html', None),
+    ('/', 'index.html', 'index.html', None),
+    ('/other', 'index.html', 'index.html', None),
+    ('/other', 'index.raise', None, None)
+])
+def test_e2e_fallback_filename(client, monkeypatch, strip_slash, path, fallback,
+                               static_exp, assert_axp):
+
+    def mockOpen(path, mode):
+        if 'index' in path and 'raise' not in path:
+            return [path.encode('utf-8')]
+        raise IOError()
+
+    monkeypatch.setattr(io, 'open', mockOpen)
+    monkeypatch.setattr('os.path.isfile', lambda file: 'index' in file)
+
+    client.app.req_options.strip_url_path_trailing_slash = strip_slash
+    client.app.add_static_route('/static', '/opt/somesite/static',
+                                fallback_filename=fallback)
+    client.app.add_static_route('/assets/', '/opt/somesite/assets')
+
+    def test(prefix, directory, expected):
+        response = client.simulate_request(path=prefix + path)
+        if expected is None:
+            assert response.status == falcon.HTTP_404
+        else:
+            assert response.status == falcon.HTTP_200
+            assert response.text == directory + expected
+
+    test('/static', '/opt/somesite/static/', static_exp)
+    test('/assets', '/opt/somesite/assets/', assert_axp)
+
+
+@pytest.mark.parametrize('default, path, expected', [
+    (None, '/static', False),
+    (None, '/static/', True),
+    (None, '/staticfoo', False),
+    (None, '/static/foo', True),
+    ('index2', '/static', True),
+    ('index2', '/static/', True),
+    ('index2', '/staticfoo', False),
+    ('index2', '/static/foo', True),
+])
+def test_match(default, path, expected, monkeypatch):
+    monkeypatch.setattr('os.path.isfile', lambda file: True)
+    sr = StaticRoute('/static', '/var/www/statics', fallback_filename=default)
+
+    assert sr.match(path) == expected