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
@ -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

View file

@ -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,
)

View file

@ -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")

View file

@ -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))

View file

@ -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

View file

@ -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,

View file

@ -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",

View file

@ -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,

View file

@ -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

View file

@ -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