From 332cf3cd2d674bcf27c7d381f575d69f440355c8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 6 Jul 2022 13:49:48 -0500 Subject: [PATCH] Resolve and caches paths for CachingStaticResource in the executor (#74474) --- homeassistant/components/http/static.py | 48 +++++++++++++------ .../components/recorder/manifest.json | 2 +- pyproject.toml | 1 + requirements.txt | 1 + requirements_all.txt | 3 -- requirements_test_all.txt | 3 -- tests/components/frontend/test_init.py | 32 +++++++++++++ 7 files changed, 69 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/http/static.py b/homeassistant/components/http/static.py index 112549553eb..e5e84ca141d 100644 --- a/homeassistant/components/http/static.py +++ b/homeassistant/components/http/static.py @@ -9,11 +9,31 @@ from aiohttp import hdrs from aiohttp.web import FileResponse, Request, StreamResponse from aiohttp.web_exceptions import HTTPForbidden, HTTPNotFound from aiohttp.web_urldispatcher import StaticResource +from lru import LRU # pylint: disable=no-name-in-module + +from homeassistant.core import HomeAssistant + +from .const import KEY_HASS CACHE_TIME: Final = 31 * 86400 # = 1 month CACHE_HEADERS: Final[Mapping[str, str]] = { hdrs.CACHE_CONTROL: f"public, max-age={CACHE_TIME}" } +PATH_CACHE = LRU(512) + + +def _get_file_path( + filename: str, directory: Path, follow_symlinks: bool +) -> Path | None: + filepath = directory.joinpath(filename).resolve() + if not follow_symlinks: + filepath.relative_to(directory) + # on opening a dir, load its contents if allowed + if filepath.is_dir(): + return None + if filepath.is_file(): + return filepath + raise HTTPNotFound class CachingStaticResource(StaticResource): @@ -21,16 +41,19 @@ class CachingStaticResource(StaticResource): async def _handle(self, request: Request) -> StreamResponse: rel_url = request.match_info["filename"] + hass: HomeAssistant = request.app[KEY_HASS] + filename = Path(rel_url) + if filename.anchor: + # rel_url is an absolute name like + # /static/\\machine_name\c$ or /static/D:\path + # where the static dir is totally different + raise HTTPForbidden() try: - filename = Path(rel_url) - if filename.anchor: - # rel_url is an absolute name like - # /static/\\machine_name\c$ or /static/D:\path - # where the static dir is totally different - raise HTTPForbidden() - filepath = self._directory.joinpath(filename).resolve() - if not self._follow_symlinks: - filepath.relative_to(self._directory) + key = (filename, self._directory, self._follow_symlinks) + if (filepath := PATH_CACHE.get(key)) is None: + filepath = PATH_CACHE[key] = await hass.async_add_executor_job( + _get_file_path, filename, self._directory, self._follow_symlinks + ) except (ValueError, FileNotFoundError) as error: # relatively safe raise HTTPNotFound() from error @@ -39,13 +62,10 @@ class CachingStaticResource(StaticResource): request.app.logger.exception(error) raise HTTPNotFound() from error - # on opening a dir, load its contents if allowed - if filepath.is_dir(): - return await super()._handle(request) - if filepath.is_file(): + if filepath: return FileResponse( filepath, chunk_size=self._chunk_size, headers=CACHE_HEADERS, ) - raise HTTPNotFound + return await super()._handle(request) diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json index 3d22781906a..f0c1f81689a 100644 --- a/homeassistant/components/recorder/manifest.json +++ b/homeassistant/components/recorder/manifest.json @@ -2,7 +2,7 @@ "domain": "recorder", "name": "Recorder", "documentation": "https://www.home-assistant.io/integrations/recorder", - "requirements": ["sqlalchemy==1.4.38", "fnvhash==0.1.0", "lru-dict==1.1.7"], + "requirements": ["sqlalchemy==1.4.38", "fnvhash==0.1.0"], "codeowners": ["@home-assistant/core"], "quality_scale": "internal", "iot_class": "local_push" diff --git a/pyproject.toml b/pyproject.toml index 51f1d115e7f..59ad04c4b11 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,7 @@ dependencies = [ "httpx==0.23.0", "ifaddr==0.1.7", "jinja2==3.1.2", + "lru-dict==1.1.7", "PyJWT==2.4.0", # PyJWT has loose dependency. We want the latest one. "cryptography==36.0.2", diff --git a/requirements.txt b/requirements.txt index 98b148fa923..a8ec77333e0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,6 +13,7 @@ ciso8601==2.2.0 httpx==0.23.0 ifaddr==0.1.7 jinja2==3.1.2 +lru-dict==1.1.7 PyJWT==2.4.0 cryptography==36.0.2 orjson==3.7.5 diff --git a/requirements_all.txt b/requirements_all.txt index b63bb91e20f..f4895707f06 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -974,9 +974,6 @@ logi_circle==0.2.3 # homeassistant.components.london_underground london-tube-status==0.5 -# homeassistant.components.recorder -lru-dict==1.1.7 - # homeassistant.components.luftdaten luftdaten==0.7.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 876944367d9..d5c616edf2e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -681,9 +681,6 @@ life360==4.1.1 # homeassistant.components.logi_circle logi_circle==0.2.3 -# homeassistant.components.recorder -lru-dict==1.1.7 - # homeassistant.components.luftdaten luftdaten==0.7.2 diff --git a/tests/components/frontend/test_init.py b/tests/components/frontend/test_init.py index 84ca04df3ba..661b3ace38a 100644 --- a/tests/components/frontend/test_init.py +++ b/tests/components/frontend/test_init.py @@ -578,3 +578,35 @@ async def test_manifest_json(hass, frontend_themes, mock_http_client): json = await resp.json() assert json["theme_color"] != DEFAULT_THEME_COLOR + + +async def test_static_path_cache(hass, mock_http_client): + """Test static paths cache.""" + resp = await mock_http_client.get("/lovelace/default_view", allow_redirects=False) + assert resp.status == 404 + + resp = await mock_http_client.get("/frontend_latest/", allow_redirects=False) + assert resp.status == 403 + + resp = await mock_http_client.get( + "/static/icons/favicon.ico", allow_redirects=False + ) + assert resp.status == 200 + + # and again to make sure the cache works + resp = await mock_http_client.get( + "/static/icons/favicon.ico", allow_redirects=False + ) + assert resp.status == 200 + + resp = await mock_http_client.get( + "/static/fonts/roboto/Roboto-Bold.woff2", allow_redirects=False + ) + assert resp.status == 200 + + resp = await mock_http_client.get("/static/does-not-exist", allow_redirects=False) + assert resp.status == 404 + + # and again to make sure the cache works + resp = await mock_http_client.get("/static/does-not-exist", allow_redirects=False) + assert resp.status == 404