Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Resolve and caches paths for CachingStaticResource in the executor #74474

Merged
merged 10 commits into from
Jul 6, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 34 additions & 14 deletions homeassistant/components/http/static.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,28 +9,51 @@
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):
"""Static Resource handler that will add cache headers."""

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
Expand All @@ -39,13 +62,10 @@ async def _handle(self, request: Request) -> StreamResponse:
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)
2 changes: 1 addition & 1 deletion homeassistant/components/recorder/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 0 additions & 3 deletions requirements_all.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 0 additions & 3 deletions requirements_test_all.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
32 changes: 32 additions & 0 deletions tests/components/frontend/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -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