Fix blocking I/O in the event loop when registering static paths (#119629)
This commit is contained in:
parent
eb89ce47ea
commit
faa55de538
10 changed files with 171 additions and 53 deletions
|
@ -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
|
||||
|
||||
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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")
|
||||
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Reference in a new issue