Fix blocking I/O in the event loop when registering static paths (#119629)

This commit is contained in:
J. Nick Koston 2024-06-18 01:18:31 -05:00 committed by GitHub
parent eb89ce47ea
commit faa55de538
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 171 additions and 53 deletions

View file

@ -106,6 +106,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
), ),
) )
await async_register_dynalite_frontend(hass)
return True return True
@ -131,9 +133,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
raise ConfigEntryNotReady raise ConfigEntryNotReady
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
await async_register_dynalite_frontend(hass)
return True return True

View file

@ -5,6 +5,7 @@ import voluptuous as vol
from homeassistant.components import panel_custom, websocket_api from homeassistant.components import panel_custom, websocket_api
from homeassistant.components.cover import DEVICE_CLASSES 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.const import CONF_DEFAULT, CONF_HOST, CONF_NAME, CONF_PORT
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
@ -98,11 +99,10 @@ async def async_register_dynalite_frontend(hass: HomeAssistant):
"""Register the Dynalite frontend configuration panel.""" """Register the Dynalite frontend configuration panel."""
websocket_api.async_register_command(hass, get_dynalite_config) websocket_api.async_register_command(hass, get_dynalite_config)
websocket_api.async_register_command(hass, save_dynalite_config) websocket_api.async_register_command(hass, save_dynalite_config)
if DOMAIN not in hass.data.get("frontend_panels", {}):
path = locate_dir() path = locate_dir()
build_id = get_build_id() build_id = get_build_id()
hass.http.register_static_path( await hass.http.async_register_static_paths(
URL_BASE, path, cache_headers=(build_id != "dev") [StaticPathConfig(URL_BASE, path, cache_headers=(build_id != "dev"))]
) )
await panel_custom.async_register_panel( await panel_custom.async_register_panel(

View file

@ -15,7 +15,7 @@ import voluptuous as vol
from yarl import URL from yarl import URL
from homeassistant.components import onboarding, websocket_api 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.components.websocket_api.connection import ActiveConnection
from homeassistant.config import async_hass_config_yaml from homeassistant.config import async_hass_config_yaml
from homeassistant.const import ( from homeassistant.const import (
@ -378,6 +378,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
is_dev = repo_path is not None is_dev = repo_path is not None
root_path = _frontend_root(repo_path) root_path = _frontend_root(repo_path)
static_paths_configs: list[StaticPathConfig] = []
for path, should_cache in ( for path, should_cache in (
("service_worker.js", False), ("service_worker.js", False),
("robots.txt", False), ("robots.txt", False),
@ -386,10 +388,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
("frontend_latest", not is_dev), ("frontend_latest", not is_dev),
("frontend_es5", 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( static_paths_configs.append(
"/auth/authorize", str(root_path / "authorize.html"), False StaticPathConfig("/auth/authorize", str(root_path / "authorize.html"), False)
) )
# https://wicg.github.io/change-password-url/ # https://wicg.github.io/change-password-url/
hass.http.register_redirect( hass.http.register_redirect(
@ -397,9 +401,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
) )
local = hass.config.path("www") local = hass.config.path("www")
if os.path.isdir(local): if await hass.async_add_executor_job(os.path.isdir, local):
hass.http.register_static_path("/local", local, not is_dev) 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 # Shopping list panel was replaced by todo panel in 2023.11
hass.http.register_redirect("/shopping-list", "/todo") hass.http.register_redirect("/shopping-list", "/todo")

View file

@ -15,6 +15,7 @@ import voluptuous as vol
from homeassistant.auth.const import GROUP_ID_ADMIN from homeassistant.auth.const import GROUP_ID_ADMIN
from homeassistant.components import panel_custom from homeassistant.components import panel_custom
from homeassistant.components.homeassistant import async_set_stop_handler 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.config_entries import SOURCE_SYSTEM, ConfigEntry
from homeassistant.const import ( from homeassistant.const import (
ATTR_NAME, 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 # This overrides the normal API call that would be forwarded
development_repo = config.get(DOMAIN, {}).get(CONF_FRONTEND_REPO) development_repo = config.get(DOMAIN, {}).get(CONF_FRONTEND_REPO)
if development_repo is not None: if development_repo is not None:
hass.http.register_static_path( await hass.http.async_register_static_paths(
"/api/hassio/app", os.path.join(development_repo, "hassio/build"), False [
StaticPathConfig(
"/api/hassio/app",
os.path.join(development_repo, "hassio/build"),
False,
)
]
) )
hass.http.register_view(HassIOView(host, websession)) hass.http.register_view(HassIOView(host, websession))

View file

@ -3,7 +3,10 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from collections.abc import Collection
from dataclasses import dataclass
import datetime import datetime
from functools import partial
from ipaddress import IPv4Network, IPv6Network, ip_network from ipaddress import IPv4Network, IPv6Network, ip_network
import logging import logging
import os import os
@ -29,7 +32,7 @@ from yarl import URL
from homeassistant.components.network import async_get_source_ip from homeassistant.components.network import async_get_source_ip
from homeassistant.const import EVENT_HOMEASSISTANT_STOP, SERVER_PORT 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.exceptions import HomeAssistantError
from homeassistant.helpers import storage from homeassistant.helpers import storage
import homeassistant.helpers.config_validation as cv 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) 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): class ConfData(TypedDict, total=False):
"""Typed dict for config data.""" """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: class HomeAssistantHTTP:
"""HTTP server for Home Assistant.""" """HTTP server for Home Assistant."""
@ -403,30 +431,58 @@ class HomeAssistantHTTP:
self.app.router.add_route("GET", url, redirect) 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( def register_static_path(
self, url_path: str, path: str, cache_headers: bool = True self, url_path: str, path: str, cache_headers: bool = True
) -> None: ) -> None:
"""Register a folder or file to serve as a static path.""" """Register a folder or file to serve as a static path."""
if os.path.isdir(path): configs = [StaticPathConfig(url_path, path, cache_headers)]
if cache_headers: resources = self._make_static_resources(configs)
resource: CachingStaticResource | web.StaticResource = ( self._async_register_static_paths(configs, resources)
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)
)
def _create_ssl_context(self) -> ssl.SSLContext | None: def _create_ssl_context(self) -> ssl.SSLContext | None:
context: ssl.SSLContext | None = None context: ssl.SSLContext | None = None

View file

@ -3,6 +3,7 @@
from insteon_frontend import get_build_id, locate_dir from insteon_frontend import get_build_id, locate_dir
from homeassistant.components import panel_custom, websocket_api from homeassistant.components import panel_custom, websocket_api
from homeassistant.components.http import StaticPathConfig
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from ..const import CONF_DEV_PATH, DOMAIN 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 is_dev = dev_path is not None
path = dev_path if dev_path else locate_dir() path = dev_path if dev_path else locate_dir()
build_id = get_build_id(is_dev) 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( await panel_custom.async_register_panel(
hass=hass, hass=hass,

View file

@ -4,7 +4,7 @@
"after_dependencies": ["panel_custom"], "after_dependencies": ["panel_custom"],
"codeowners": ["@Julius2342", "@farmio", "@marvin-w"], "codeowners": ["@Julius2342", "@farmio", "@marvin-w"],
"config_flow": true, "config_flow": true,
"dependencies": ["file_upload", "websocket_api"], "dependencies": ["file_upload", "http", "websocket_api"],
"documentation": "https://www.home-assistant.io/integrations/knx", "documentation": "https://www.home-assistant.io/integrations/knx",
"integration_type": "hub", "integration_type": "hub",
"iot_class": "local_push", "iot_class": "local_push",

View file

@ -9,6 +9,7 @@ import voluptuous as vol
from xknxproject.exceptions import XknxProjectException from xknxproject.exceptions import XknxProjectException
from homeassistant.components import panel_custom, websocket_api from homeassistant.components import panel_custom, websocket_api
from homeassistant.components.http import StaticPathConfig
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from .const import DOMAIN from .const import DOMAIN
@ -31,11 +32,15 @@ async def register_panel(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, ws_get_knx_project) websocket_api.async_register_command(hass, ws_get_knx_project)
if DOMAIN not in hass.data.get("frontend_panels", {}): if DOMAIN not in hass.data.get("frontend_panels", {}):
hass.http.register_static_path( await hass.http.async_register_static_paths(
[
StaticPathConfig(
URL_BASE, URL_BASE,
path=knx_panel.locate_dir(), path=knx_panel.locate_dir(),
cache_headers=knx_panel.is_prod_build, cache_headers=knx_panel.is_prod_build,
) )
]
)
await panel_custom.async_register_panel( await panel_custom.async_register_panel(
hass=hass, hass=hass,
frontend_url_path=DOMAIN, frontend_url_path=DOMAIN,

View file

@ -2,6 +2,7 @@
from asyncio import AbstractEventLoop from asyncio import AbstractEventLoop
from http import HTTPStatus from http import HTTPStatus
from pathlib import Path
import re import re
from typing import Any from typing import Any
from unittest.mock import patch from unittest.mock import patch
@ -787,3 +788,23 @@ async def test_get_icons_for_single_integration(
assert msg["type"] == TYPE_RESULT assert msg["type"] == TYPE_RESULT
assert msg["success"] assert msg["success"]
assert msg["result"] == {"resources": {"http": {}}} 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

View file

@ -1,11 +1,13 @@
"""The tests for http static files.""" """The tests for http static files."""
from http import HTTPStatus
from pathlib import Path from pathlib import Path
from aiohttp.test_utils import TestClient from aiohttp.test_utils import TestClient
from aiohttp.web_exceptions import HTTPForbidden from aiohttp.web_exceptions import HTTPForbidden
import pytest import pytest
from homeassistant.components.http import StaticPathConfig
from homeassistant.components.http.static import CachingStaticResource, _get_file_path from homeassistant.components.http.static import CachingStaticResource, _get_file_path
from homeassistant.core import EVENT_HOMEASSISTANT_START, HomeAssistant from homeassistant.core import EVENT_HOMEASSISTANT_START, HomeAssistant
from homeassistant.helpers.http import KEY_ALLOW_CONFIGRED_CORS 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. # changes we still block it.
with pytest.raises(HTTPForbidden): with pytest.raises(HTTPForbidden):
_get_file_path(canonical_url, tmp_path) _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