Resolve and caches paths for CachingStaticResource in the executor (#74474)

This commit is contained in:
J. Nick Koston 2022-07-06 13:49:48 -05:00 committed by GitHub
parent 113ccfe6af
commit 332cf3cd2d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 69 additions and 21 deletions

View file

@ -9,11 +9,31 @@ from aiohttp import hdrs
from aiohttp.web import FileResponse, Request, StreamResponse from aiohttp.web import FileResponse, Request, StreamResponse
from aiohttp.web_exceptions import HTTPForbidden, HTTPNotFound from aiohttp.web_exceptions import HTTPForbidden, HTTPNotFound
from aiohttp.web_urldispatcher import StaticResource 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_TIME: Final = 31 * 86400 # = 1 month
CACHE_HEADERS: Final[Mapping[str, str]] = { CACHE_HEADERS: Final[Mapping[str, str]] = {
hdrs.CACHE_CONTROL: f"public, max-age={CACHE_TIME}" 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): class CachingStaticResource(StaticResource):
@ -21,16 +41,19 @@ class CachingStaticResource(StaticResource):
async def _handle(self, request: Request) -> StreamResponse: async def _handle(self, request: Request) -> StreamResponse:
rel_url = request.match_info["filename"] 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: try:
filename = Path(rel_url) key = (filename, self._directory, self._follow_symlinks)
if filename.anchor: if (filepath := PATH_CACHE.get(key)) is None:
# rel_url is an absolute name like filepath = PATH_CACHE[key] = await hass.async_add_executor_job(
# /static/\\machine_name\c$ or /static/D:\path _get_file_path, filename, self._directory, self._follow_symlinks
# 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)
except (ValueError, FileNotFoundError) as error: except (ValueError, FileNotFoundError) as error:
# relatively safe # relatively safe
raise HTTPNotFound() from error raise HTTPNotFound() from error
@ -39,13 +62,10 @@ class CachingStaticResource(StaticResource):
request.app.logger.exception(error) request.app.logger.exception(error)
raise HTTPNotFound() from error raise HTTPNotFound() from error
# on opening a dir, load its contents if allowed if filepath:
if filepath.is_dir():
return await super()._handle(request)
if filepath.is_file():
return FileResponse( return FileResponse(
filepath, filepath,
chunk_size=self._chunk_size, chunk_size=self._chunk_size,
headers=CACHE_HEADERS, headers=CACHE_HEADERS,
) )
raise HTTPNotFound return await super()._handle(request)

View file

@ -2,7 +2,7 @@
"domain": "recorder", "domain": "recorder",
"name": "Recorder", "name": "Recorder",
"documentation": "https://www.home-assistant.io/integrations/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"], "codeowners": ["@home-assistant/core"],
"quality_scale": "internal", "quality_scale": "internal",
"iot_class": "local_push" "iot_class": "local_push"

View file

@ -38,6 +38,7 @@ dependencies = [
"httpx==0.23.0", "httpx==0.23.0",
"ifaddr==0.1.7", "ifaddr==0.1.7",
"jinja2==3.1.2", "jinja2==3.1.2",
"lru-dict==1.1.7",
"PyJWT==2.4.0", "PyJWT==2.4.0",
# PyJWT has loose dependency. We want the latest one. # PyJWT has loose dependency. We want the latest one.
"cryptography==36.0.2", "cryptography==36.0.2",

View file

@ -13,6 +13,7 @@ ciso8601==2.2.0
httpx==0.23.0 httpx==0.23.0
ifaddr==0.1.7 ifaddr==0.1.7
jinja2==3.1.2 jinja2==3.1.2
lru-dict==1.1.7
PyJWT==2.4.0 PyJWT==2.4.0
cryptography==36.0.2 cryptography==36.0.2
orjson==3.7.5 orjson==3.7.5

View file

@ -974,9 +974,6 @@ logi_circle==0.2.3
# homeassistant.components.london_underground # homeassistant.components.london_underground
london-tube-status==0.5 london-tube-status==0.5
# homeassistant.components.recorder
lru-dict==1.1.7
# homeassistant.components.luftdaten # homeassistant.components.luftdaten
luftdaten==0.7.2 luftdaten==0.7.2

View file

@ -681,9 +681,6 @@ life360==4.1.1
# homeassistant.components.logi_circle # homeassistant.components.logi_circle
logi_circle==0.2.3 logi_circle==0.2.3
# homeassistant.components.recorder
lru-dict==1.1.7
# homeassistant.components.luftdaten # homeassistant.components.luftdaten
luftdaten==0.7.2 luftdaten==0.7.2

View file

@ -578,3 +578,35 @@ async def test_manifest_json(hass, frontend_themes, mock_http_client):
json = await resp.json() json = await resp.json()
assert json["theme_color"] != DEFAULT_THEME_COLOR 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