From faa55de538210554aa1311ea343c618a3fdfa449 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 18 Jun 2024 01:18:31 -0500 Subject: [PATCH] Fix blocking I/O in the event loop when registering static paths (#119629) --- homeassistant/components/dynalite/__init__.py | 5 +- homeassistant/components/dynalite/panel.py | 30 +++--- homeassistant/components/frontend/__init__.py | 17 ++-- homeassistant/components/hassio/__init__.py | 11 ++- homeassistant/components/http/__init__.py | 98 +++++++++++++++---- .../components/insteon/api/__init__.py | 5 +- homeassistant/components/knx/manifest.json | 2 +- homeassistant/components/knx/websocket.py | 13 ++- tests/components/frontend/test_init.py | 21 ++++ tests/components/http/test_static.py | 22 +++++ 10 files changed, 171 insertions(+), 53 deletions(-) diff --git a/homeassistant/components/dynalite/__init__.py b/homeassistant/components/dynalite/__init__.py index 46fcfb267d0..59b8e464bb0 100644 --- a/homeassistant/components/dynalite/__init__.py +++ b/homeassistant/components/dynalite/__init__.py @@ -106,6 +106,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ), ) + await async_register_dynalite_frontend(hass) + return True @@ -131,9 +133,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryNotReady await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - - await async_register_dynalite_frontend(hass) - return True diff --git a/homeassistant/components/dynalite/panel.py b/homeassistant/components/dynalite/panel.py index b7020367f74..b62944f63fe 100644 --- a/homeassistant/components/dynalite/panel.py +++ b/homeassistant/components/dynalite/panel.py @@ -5,6 +5,7 @@ import voluptuous as vol from homeassistant.components import panel_custom, websocket_api from homeassistant.components.cover import DEVICE_CLASSES +from homeassistant.components.http import StaticPathConfig from homeassistant.const import CONF_DEFAULT, CONF_HOST, CONF_NAME, CONF_PORT from homeassistant.core import HomeAssistant, callback @@ -98,19 +99,18 @@ async def async_register_dynalite_frontend(hass: HomeAssistant): """Register the Dynalite frontend configuration panel.""" websocket_api.async_register_command(hass, get_dynalite_config) websocket_api.async_register_command(hass, save_dynalite_config) - if DOMAIN not in hass.data.get("frontend_panels", {}): - path = locate_dir() - build_id = get_build_id() - hass.http.register_static_path( - URL_BASE, path, cache_headers=(build_id != "dev") - ) + path = locate_dir() + build_id = get_build_id() + await hass.http.async_register_static_paths( + [StaticPathConfig(URL_BASE, path, cache_headers=(build_id != "dev"))] + ) - await panel_custom.async_register_panel( - hass=hass, - frontend_url_path=DOMAIN, - config_panel_domain=DOMAIN, - webcomponent_name="dynalite-panel", - module_url=f"{URL_BASE}/entrypoint-{build_id}.js", - embed_iframe=True, - require_admin=True, - ) + await panel_custom.async_register_panel( + hass=hass, + frontend_url_path=DOMAIN, + config_panel_domain=DOMAIN, + webcomponent_name="dynalite-panel", + module_url=f"{URL_BASE}/entrypoint-{build_id}.js", + embed_iframe=True, + require_admin=True, + ) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 7ff7f76c61c..2f038e34102 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -15,7 +15,7 @@ import voluptuous as vol from yarl import URL from homeassistant.components import onboarding, websocket_api -from homeassistant.components.http import KEY_HASS, HomeAssistantView +from homeassistant.components.http import KEY_HASS, HomeAssistantView, StaticPathConfig from homeassistant.components.websocket_api.connection import ActiveConnection from homeassistant.config import async_hass_config_yaml from homeassistant.const import ( @@ -378,6 +378,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: is_dev = repo_path is not None root_path = _frontend_root(repo_path) + static_paths_configs: list[StaticPathConfig] = [] + for path, should_cache in ( ("service_worker.js", False), ("robots.txt", False), @@ -386,10 +388,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ("frontend_latest", not is_dev), ("frontend_es5", not is_dev), ): - hass.http.register_static_path(f"/{path}", str(root_path / path), should_cache) + static_paths_configs.append( + StaticPathConfig(f"/{path}", str(root_path / path), should_cache) + ) - hass.http.register_static_path( - "/auth/authorize", str(root_path / "authorize.html"), False + static_paths_configs.append( + StaticPathConfig("/auth/authorize", str(root_path / "authorize.html"), False) ) # https://wicg.github.io/change-password-url/ hass.http.register_redirect( @@ -397,9 +401,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) local = hass.config.path("www") - if os.path.isdir(local): - hass.http.register_static_path("/local", local, not is_dev) + if await hass.async_add_executor_job(os.path.isdir, local): + static_paths_configs.append(StaticPathConfig("/local", local, not is_dev)) + await hass.http.async_register_static_paths(static_paths_configs) # Shopping list panel was replaced by todo panel in 2023.11 hass.http.register_redirect("/shopping-list", "/todo") diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 34d15501c48..647c2248d56 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -15,6 +15,7 @@ import voluptuous as vol from homeassistant.auth.const import GROUP_ID_ADMIN from homeassistant.components import panel_custom from homeassistant.components.homeassistant import async_set_stop_handler +from homeassistant.components.http import StaticPathConfig from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry from homeassistant.const import ( ATTR_NAME, @@ -350,8 +351,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: # This overrides the normal API call that would be forwarded development_repo = config.get(DOMAIN, {}).get(CONF_FRONTEND_REPO) if development_repo is not None: - hass.http.register_static_path( - "/api/hassio/app", os.path.join(development_repo, "hassio/build"), False + await hass.http.async_register_static_paths( + [ + StaticPathConfig( + "/api/hassio/app", + os.path.join(development_repo, "hassio/build"), + False, + ) + ] ) hass.http.register_view(HassIOView(host, websession)) diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index b48e9f9615c..4e62df3a024 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -3,7 +3,10 @@ from __future__ import annotations import asyncio +from collections.abc import Collection +from dataclasses import dataclass import datetime +from functools import partial from ipaddress import IPv4Network, IPv6Network, ip_network import logging import os @@ -29,7 +32,7 @@ from yarl import URL from homeassistant.components.network import async_get_source_ip from homeassistant.const import EVENT_HOMEASSISTANT_STOP, SERVER_PORT -from homeassistant.core import Event, HomeAssistant +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import storage import homeassistant.helpers.config_validation as cv @@ -134,6 +137,21 @@ HTTP_SCHEMA: Final = vol.All( CONFIG_SCHEMA: Final = vol.Schema({DOMAIN: HTTP_SCHEMA}, extra=vol.ALLOW_EXTRA) +@dataclass(slots=True) +class StaticPathConfig: + """Configuration for a static path.""" + + url_path: str + path: str + cache_headers: bool = True + + +_STATIC_CLASSES = { + True: CachingStaticResource, + False: web.StaticResource, +} + + class ConfData(TypedDict, total=False): """Typed dict for config data.""" @@ -284,6 +302,16 @@ class HomeAssistantApplication(web.Application): ) +async def _serve_file_with_cache_headers( + path: str, request: web.Request +) -> web.FileResponse: + return web.FileResponse(path, headers=CACHE_HEADERS) + + +async def _serve_file(path: str, request: web.Request) -> web.FileResponse: + return web.FileResponse(path) + + class HomeAssistantHTTP: """HTTP server for Home Assistant.""" @@ -403,30 +431,58 @@ class HomeAssistantHTTP: self.app.router.add_route("GET", url, redirect) ) + def _make_static_resources( + self, configs: Collection[StaticPathConfig] + ) -> dict[str, CachingStaticResource | web.StaticResource | None]: + """Create a list of static resources.""" + return { + config.url_path: _STATIC_CLASSES[config.cache_headers]( + config.url_path, config.path + ) + if os.path.isdir(config.path) + else None + for config in configs + } + + async def async_register_static_paths( + self, configs: Collection[StaticPathConfig] + ) -> None: + """Register a folder or file to serve as a static path.""" + resources = await self.hass.async_add_executor_job( + self._make_static_resources, configs + ) + self._async_register_static_paths(configs, resources) + + @callback + def _async_register_static_paths( + self, + configs: Collection[StaticPathConfig], + resources: dict[str, CachingStaticResource | web.StaticResource | None], + ) -> None: + """Register a folders or files to serve as a static path.""" + app = self.app + allow_cors = app[KEY_ALLOW_CONFIGRED_CORS] + for config in configs: + if resource := resources[config.url_path]: + app.router.register_resource(resource) + allow_cors(resource) + + target = ( + _serve_file_with_cache_headers if config.cache_headers else _serve_file + ) + allow_cors( + self.app.router.add_route( + "GET", config.url_path, partial(target, config.path) + ) + ) + def register_static_path( self, url_path: str, path: str, cache_headers: bool = True ) -> None: """Register a folder or file to serve as a static path.""" - if os.path.isdir(path): - if cache_headers: - resource: CachingStaticResource | web.StaticResource = ( - CachingStaticResource(url_path, path) - ) - else: - resource = web.StaticResource(url_path, path) - self.app.router.register_resource(resource) - self.app[KEY_ALLOW_CONFIGRED_CORS](resource) - return - - async def serve_file(request: web.Request) -> web.FileResponse: - """Serve file from disk.""" - if cache_headers: - return web.FileResponse(path, headers=CACHE_HEADERS) - return web.FileResponse(path) - - self.app[KEY_ALLOW_CONFIGRED_CORS]( - self.app.router.add_route("GET", url_path, serve_file) - ) + configs = [StaticPathConfig(url_path, path, cache_headers)] + resources = self._make_static_resources(configs) + self._async_register_static_paths(configs, resources) def _create_ssl_context(self) -> ssl.SSLContext | None: context: ssl.SSLContext | None = None diff --git a/homeassistant/components/insteon/api/__init__.py b/homeassistant/components/insteon/api/__init__.py index 1f671aa1343..b19b1912340 100644 --- a/homeassistant/components/insteon/api/__init__.py +++ b/homeassistant/components/insteon/api/__init__.py @@ -3,6 +3,7 @@ from insteon_frontend import get_build_id, locate_dir from homeassistant.components import panel_custom, websocket_api +from homeassistant.components.http import StaticPathConfig from homeassistant.core import HomeAssistant, callback from ..const import CONF_DEV_PATH, DOMAIN @@ -91,7 +92,9 @@ async def async_register_insteon_frontend(hass: HomeAssistant): is_dev = dev_path is not None path = dev_path if dev_path else locate_dir() build_id = get_build_id(is_dev) - hass.http.register_static_path(URL_BASE, path, cache_headers=not is_dev) + await hass.http.async_register_static_paths( + [StaticPathConfig(URL_BASE, path, cache_headers=not is_dev)] + ) await panel_custom.async_register_panel( hass=hass, diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index af0c6b8d01c..3e8986641e7 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -4,7 +4,7 @@ "after_dependencies": ["panel_custom"], "codeowners": ["@Julius2342", "@farmio", "@marvin-w"], "config_flow": true, - "dependencies": ["file_upload", "websocket_api"], + "dependencies": ["file_upload", "http", "websocket_api"], "documentation": "https://www.home-assistant.io/integrations/knx", "integration_type": "hub", "iot_class": "local_push", diff --git a/homeassistant/components/knx/websocket.py b/homeassistant/components/knx/websocket.py index dc5b5e483be..0ac5a21d333 100644 --- a/homeassistant/components/knx/websocket.py +++ b/homeassistant/components/knx/websocket.py @@ -9,6 +9,7 @@ import voluptuous as vol from xknxproject.exceptions import XknxProjectException from homeassistant.components import panel_custom, websocket_api +from homeassistant.components.http import StaticPathConfig from homeassistant.core import HomeAssistant, callback from .const import DOMAIN @@ -31,10 +32,14 @@ async def register_panel(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, ws_get_knx_project) if DOMAIN not in hass.data.get("frontend_panels", {}): - hass.http.register_static_path( - URL_BASE, - path=knx_panel.locate_dir(), - cache_headers=knx_panel.is_prod_build, + await hass.http.async_register_static_paths( + [ + StaticPathConfig( + URL_BASE, + path=knx_panel.locate_dir(), + cache_headers=knx_panel.is_prod_build, + ) + ] ) await panel_custom.async_register_panel( hass=hass, diff --git a/tests/components/frontend/test_init.py b/tests/components/frontend/test_init.py index 81bec28598d..b8642aa997d 100644 --- a/tests/components/frontend/test_init.py +++ b/tests/components/frontend/test_init.py @@ -2,6 +2,7 @@ from asyncio import AbstractEventLoop from http import HTTPStatus +from pathlib import Path import re from typing import Any from unittest.mock import patch @@ -787,3 +788,23 @@ async def test_get_icons_for_single_integration( assert msg["type"] == TYPE_RESULT assert msg["success"] assert msg["result"] == {"resources": {"http": {}}} + + +async def test_www_local_dir( + hass: HomeAssistant, tmp_path: Path, hass_client: ClientSessionGenerator +) -> None: + """Test local www folder.""" + hass.config.config_dir = str(tmp_path) + tmp_path_www = tmp_path / "www" + x_txt_file = tmp_path_www / "x.txt" + + def _create_www_and_x_txt(): + tmp_path_www.mkdir() + x_txt_file.write_text("any") + + await hass.async_add_executor_job(_create_www_and_x_txt) + + assert await async_setup_component(hass, "frontend", {}) + client = await hass_client() + resp = await client.get("/local/x.txt") + assert resp.status == HTTPStatus.OK diff --git a/tests/components/http/test_static.py b/tests/components/http/test_static.py index e3cf2f50c15..92e92cdb4a7 100644 --- a/tests/components/http/test_static.py +++ b/tests/components/http/test_static.py @@ -1,11 +1,13 @@ """The tests for http static files.""" +from http import HTTPStatus from pathlib import Path from aiohttp.test_utils import TestClient from aiohttp.web_exceptions import HTTPForbidden import pytest +from homeassistant.components.http import StaticPathConfig from homeassistant.components.http.static import CachingStaticResource, _get_file_path from homeassistant.core import EVENT_HOMEASSISTANT_START, HomeAssistant from homeassistant.helpers.http import KEY_ALLOW_CONFIGRED_CORS @@ -59,3 +61,23 @@ async def test_static_path_blocks_anchors( # changes we still block it. with pytest.raises(HTTPForbidden): _get_file_path(canonical_url, tmp_path) + + +async def test_async_register_static_paths( + hass: HomeAssistant, hass_client: ClientSessionGenerator +) -> None: + """Test registering multiple static paths.""" + assert await async_setup_component(hass, "frontend", {}) + path = str(Path(__file__).parent) + await hass.http.async_register_static_paths( + [ + StaticPathConfig("/something", path), + StaticPathConfig("/something_else", path), + ] + ) + + client = await hass_client() + resp = await client.get("/something/__init__.py") + assert resp.status == HTTPStatus.OK + resp = await client.get("/something_else/__init__.py") + assert resp.status == HTTPStatus.OK