Repository: aio-libs/aiohttp

Issue:
router.add_static is greedy
🐞 **Describe the bug**

The first argument for `add_static` matches on any path that starts with that string. For instance, if you specify "h", then `add_static` will match on "h" or "help" or "hoover".

💡 **To Reproduce**

Run this code:

```
from aiohttp import web

async def handle(request):
    name = request.match_info.get('name', "Anonymous")
    text = "Hello, " + name
    return web.Response(text=text)

app = web.Application()

router = app.router

router.add_static("/h", "./")

router.add_routes([web.get('/', handle),
                web.get('/{name}', handle)])

if __name__ == '__main__':
    web.run_app(app)
```

Add a text file in the current working directory named `test.txt` and put some text in there.

Then navigate to the following URLs to demonstrate the issue:

* http://0.0.0.0:8080/
* http://0.0.0.0:8080/groot
* http://0.0.0.0:8080/h/test.txt
* http://0.0.0.0:8080/hoover

The last link unexpectedly returns a 404.

💡 **Expected behavior**

I would have expected http://0.0.0.0:8080/hoover to have similar behavior to http://0.0.0.0:8080/groot, and render "Hello, hoover".


📋 **Your version of the Python**

3.8

📋 **Your version of the aiohttp/yarl/multidict distributions**

aiohttp 3.7.2



Discussion:
The order of routes is important, the search is stopped on the first match.
Is the static route more ready than the plain or dynamic one?
@asvetlov I'm not sure what you mean by "more ready".
`router.add_static("/h/", "./")`
@alex-eri That has no effect due to this logic where any trailing slash is removed: https://github.com/aio-libs/aiohttp/blob/master/aiohttp/web_urldispatcher.py#L1097
I also encountered this. It's rather surprising that `router.add_static("/tutorials/", ...)` would match `/tutorials-landing.html`.

It seems like the fix should be in `StaticResource.resolve`:
```python3
    async def resolve(self, request: Request) -> _Resolve:
        path = request.rel_url.raw_path
        method = request.method
        allowed_methods = set(self._routes)
        if not path.startswith(self._prefix + "/"):
            return None, set()
        # ....
```

That seems safe and correct considering that the resolved filename implicitly assumes that the character following the prefix is irrelevant:
```
        match_dict = {"filename": _unquote_path(path[len(self._prefix) + 1 :])}
```


Resolving patch:
diff --git a/aiohttp/web_urldispatcher.py b/aiohttp/web_urldispatcher.py
--- a/aiohttp/web_urldispatcher.py
+++ b/aiohttp/web_urldispatcher.py
@@ -513,6 +513,7 @@ def __init__(self, prefix: str, *, name: Optional[str] = None) -> None:
         assert prefix in ("", "/") or not prefix.endswith("/"), prefix
         super().__init__(name=name)
         self._prefix = _requote_path(prefix)
+        self._prefix2 = self._prefix + "/"
 
     @property
     def canonical(self) -> str:
@@ -523,6 +524,7 @@ def add_prefix(self, prefix: str) -> None:
         assert not prefix.endswith("/")
         assert len(prefix) > 1
         self._prefix = prefix + self._prefix
+        self._prefix2 = self._prefix + "/"
 
     def raw_match(self, prefix: str) -> bool:
         return False
@@ -634,7 +636,7 @@ async def resolve(self, request: Request) -> _Resolve:
         path = request.rel_url.raw_path
         method = request.method
         allowed_methods = set(self._routes)
-        if not path.startswith(self._prefix):
+        if not path.startswith(self._prefix2) and path != self._prefix:
             return None, set()
 
         if method not in allowed_methods:
@@ -750,7 +752,7 @@ def get_info(self) -> _InfoDict:
 
     async def resolve(self, request: Request) -> _Resolve:
         if (
-            not request.url.raw_path.startswith(self._prefix + "/")
+            not request.url.raw_path.startswith(self._prefix2)
             and request.url.raw_path != self._prefix
         ):
             return None, set()


Test patch:
diff --git a/tests/test_web_urldispatcher.py b/tests/test_web_urldispatcher.py
--- a/tests/test_web_urldispatcher.py
+++ b/tests/test_web_urldispatcher.py
@@ -5,6 +5,7 @@
 import shutil
 import sys
 import tempfile
+from typing import Any
 from unittest import mock
 from unittest.mock import MagicMock
 
@@ -519,6 +520,21 @@ async def test_static_absolute_url(aiohttp_client, tmpdir) -> None:
     assert resp.status == 403
 
 
+async def test_for_issue_5250(aiohttp_client: Any, tmp_path: Any) -> None:
+    app = web.Application()
+    app.router.add_static("/foo", tmp_path)
+
+    async def get_foobar(request):
+        return web.Response(body="success!")
+
+    app.router.add_get("/foobar", get_foobar)
+
+    client = await aiohttp_client(app)
+    async with await client.get("/foobar") as resp:
+        assert resp.status == 200
+        assert (await resp.text()) == "success!"
+
+
 @pytest.mark.xfail(
     raises=AssertionError,
     reason="Regression in v3.7: https://github.com/aio-libs/aiohttp/issues/5621",