"""HTTP Support for Hass.io.""" from __future__ import annotations from http import HTTPStatus import logging import os import re from typing import TYPE_CHECKING from urllib.parse import quote, unquote import aiohttp from aiohttp import web from aiohttp.client import ClientTimeout from aiohttp.hdrs import ( AUTHORIZATION, CACHE_CONTROL, CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_TYPE, TRANSFER_ENCODING, ) from aiohttp.web_exceptions import HTTPBadGateway from homeassistant.components.http import ( KEY_AUTHENTICATED, KEY_HASS, KEY_HASS_USER, HomeAssistantView, ) from homeassistant.components.onboarding import async_is_onboarded from .const import X_HASS_SOURCE _LOGGER = logging.getLogger(__name__) MAX_UPLOAD_SIZE = 1024 * 1024 * 1024 NO_TIMEOUT = re.compile( r"^(?:" r"|backups/.+/full" r"|backups/.+/partial" r"|backups/[^/]+/(?:upload|download)" r")$" ) # fmt: off # Onboarding can upload backups and restore it PATHS_NOT_ONBOARDED = re.compile( r"^(?:" r"|backups/[a-f0-9]{8}(/info|/new/upload|/download|/restore/full|/restore/partial)?" r"|backups/new/upload" r")$" ) # Authenticated users manage backups + download logs, changelog and documentation PATHS_ADMIN = re.compile( r"^(?:" r"|backups/[a-f0-9]{8}(/info|/download|/restore/full|/restore/partial)?" r"|backups/new/upload" r"|audio/logs" r"|cli/logs" r"|core/logs" r"|dns/logs" r"|host/logs" r"|multicast/logs" r"|observer/logs" r"|supervisor/logs" r"|addons/[^/]+/(changelog|documentation|logs)" r")$" ) # Unauthenticated requests come in for Supervisor panel + add-on images PATHS_NO_AUTH = re.compile( r"^(?:" r"|app/.*" r"|(store/)?addons/[^/]+/(logo|icon)" r")$" ) NO_STORE = re.compile( r"^(?:" r"|app/entrypoint.js" r")$" ) # fmt: on RESPONSE_HEADERS_FILTER = { TRANSFER_ENCODING, CONTENT_LENGTH, CONTENT_TYPE, CONTENT_ENCODING, } class HassIOView(HomeAssistantView): """Hass.io view to handle base part.""" name = "api:hassio" url = "/api/hassio/{path:.+}" requires_auth = False def __init__(self, host: str, websession: aiohttp.ClientSession) -> None: """Initialize a Hass.io base view.""" self._host = host self._websession = websession async def _handle(self, request: web.Request, path: str) -> web.StreamResponse: """Return a client request with proxy origin for Hass.io supervisor. Use cases: - Onboarding allows restoring backups - Load Supervisor panel and add-on logo unauthenticated - User upload/restore backups """ # No bullshit if path != unquote(path): return web.Response(status=HTTPStatus.BAD_REQUEST) hass = request.app[KEY_HASS] is_admin = request[KEY_AUTHENTICATED] and request[KEY_HASS_USER].is_admin authorized = is_admin if is_admin: allowed_paths = PATHS_ADMIN elif not async_is_onboarded(hass): allowed_paths = PATHS_NOT_ONBOARDED # During onboarding we need the user to manage backups authorized = True else: # Either unauthenticated or not an admin allowed_paths = PATHS_NO_AUTH no_auth_path = PATHS_NO_AUTH.match(path) headers = { X_HASS_SOURCE: "core.http", } if no_auth_path: if request.method != "GET": return web.Response(status=HTTPStatus.METHOD_NOT_ALLOWED) else: if not allowed_paths.match(path): return web.Response(status=HTTPStatus.UNAUTHORIZED) if authorized: headers[AUTHORIZATION] = ( f"Bearer {os.environ.get('SUPERVISOR_TOKEN', '')}" ) if request.method == "POST": headers[CONTENT_TYPE] = request.content_type # _stored_content_type is only computed once `content_type` is accessed if path == "backups/new/upload": # We need to reuse the full content type that includes the boundary if TYPE_CHECKING: assert isinstance(request._stored_content_type, str) # noqa: SLF001 headers[CONTENT_TYPE] = request._stored_content_type # noqa: SLF001 try: client = await self._websession.request( method=request.method, url=f"http://{self._host}/{quote(path)}", params=request.query, data=request.content if request.method != "GET" else None, headers=headers, timeout=_get_timeout(path), ) # Stream response response = web.StreamResponse( status=client.status, headers=_response_header(client, path) ) response.content_type = client.content_type if should_compress(response.content_type): response.enable_compression() await response.prepare(request) # In testing iter_chunked, iter_any, and iter_chunks: # iter_chunks was the best performing option since # it does not have to do as much re-assembly async for data, _ in client.content.iter_chunks(): await response.write(data) except aiohttp.ClientError as err: _LOGGER.error("Client error on api %s request %s", path, err) raise HTTPBadGateway from err except TimeoutError as err: _LOGGER.error("Client timeout error on API request %s", path) raise HTTPBadGateway from err return response get = _handle post = _handle def _response_header(response: aiohttp.ClientResponse, path: str) -> dict[str, str]: """Create response header.""" headers = { name: value for name, value in response.headers.items() if name not in RESPONSE_HEADERS_FILTER } if NO_STORE.match(path): headers[CACHE_CONTROL] = "no-store, max-age=0" return headers def _get_timeout(path: str) -> ClientTimeout: """Return timeout for a URL path.""" if NO_TIMEOUT.match(path): return ClientTimeout(connect=10, total=None) return ClientTimeout(connect=10, total=300) def should_compress(content_type: str) -> bool: """Return if we should compress a response.""" if content_type.startswith("image/"): return "svg" in content_type if content_type.startswith("application/"): return ( "json" in content_type or "xml" in content_type or "javascript" in content_type ) return not content_type.startswith(("video/", "audio/", "font/"))