Home Assistant Unofficial Reference 2024.12.1
http.py
Go to the documentation of this file.
1 """HTTP Support for Hass.io."""
2 
3 from __future__ import annotations
4 
5 from http import HTTPStatus
6 import logging
7 import os
8 import re
9 from typing import TYPE_CHECKING
10 from urllib.parse import quote, unquote
11 
12 import aiohttp
13 from aiohttp import web
14 from aiohttp.client import ClientTimeout
15 from aiohttp.hdrs import (
16  AUTHORIZATION,
17  CACHE_CONTROL,
18  CONTENT_ENCODING,
19  CONTENT_LENGTH,
20  CONTENT_TYPE,
21  RANGE,
22  TRANSFER_ENCODING,
23 )
24 from aiohttp.web_exceptions import HTTPBadGateway
25 
27  KEY_AUTHENTICATED,
28  KEY_HASS,
29  KEY_HASS_USER,
30  HomeAssistantView,
31 )
32 from homeassistant.components.onboarding import async_is_onboarded
33 
34 from .const import X_HASS_SOURCE
35 
36 _LOGGER = logging.getLogger(__name__)
37 
38 MAX_UPLOAD_SIZE = 1024 * 1024 * 1024
39 
40 NO_TIMEOUT = re.compile(
41  r"^(?:"
42  r"|backups/.+/full"
43  r"|backups/.+/partial"
44  r"|backups/[^/]+/(?:upload|download)"
45  r"|audio/logs/(follow|boots/-?\d+(/follow)?)"
46  r"|cli/logs/(follow|boots/-?\d+(/follow)?)"
47  r"|core/logs/(follow|boots/-?\d+(/follow)?)"
48  r"|dns/logs/(follow|boots/-?\d+(/follow)?)"
49  r"|host/logs/(follow|boots/-?\d+(/follow)?)"
50  r"|multicast/logs/(follow|boots/-?\d+(/follow)?)"
51  r"|observer/logs/(follow|boots/-?\d+(/follow)?)"
52  r"|supervisor/logs/(follow|boots/-?\d+(/follow)?)"
53  r"|addons/[^/]+/logs/(follow|boots/-?\d+(/follow)?)"
54  r")$"
55 )
56 
57 # fmt: off
58 # Onboarding can upload backups and restore it
59 PATHS_NOT_ONBOARDED = re.compile(
60  r"^(?:"
61  r"|backups/[a-f0-9]{8}(/info|/new/upload|/download|/restore/full|/restore/partial)?"
62  r"|backups/new/upload"
63  r")$"
64 )
65 
66 # Authenticated users manage backups + download logs, changelog and documentation
67 PATHS_ADMIN = re.compile(
68  r"^(?:"
69  r"|backups/[a-f0-9]{8}(/info|/download|/restore/full|/restore/partial)?"
70  r"|backups/new/upload"
71  r"|audio/logs(/follow|/boots/-?\d+(/follow)?)?"
72  r"|cli/logs(/follow|/boots/-?\d+(/follow)?)?"
73  r"|core/logs(/follow|/boots/-?\d+(/follow)?)?"
74  r"|dns/logs(/follow|/boots/-?\d+(/follow)?)?"
75  r"|host/logs(/follow|/boots(/-?\d+(/follow)?)?)?"
76  r"|multicast/logs(/follow|/boots/-?\d+(/follow)?)?"
77  r"|observer/logs(/follow|/boots/-?\d+(/follow)?)?"
78  r"|supervisor/logs(/follow|/boots/-?\d+(/follow)?)?"
79  r"|addons/[^/]+/(changelog|documentation)"
80  r"|addons/[^/]+/logs(/follow|/boots/-?\d+(/follow)?)?"
81  r")$"
82 )
83 
84 # Unauthenticated requests come in for Supervisor panel + add-on images
85 PATHS_NO_AUTH = re.compile(
86  r"^(?:"
87  r"|app/.*"
88  r"|(store/)?addons/[^/]+/(logo|icon)"
89  r")$"
90 )
91 
92 NO_STORE = re.compile(
93  r"^(?:"
94  r"|app/entrypoint.js"
95  r")$"
96 )
97 
98 # Follow logs should not be compressed, to be able to get streamed by frontend
99 NO_COMPRESS = re.compile(
100  r"^(?:"
101  r"|audio/logs/(follow|boots/-?\d+(/follow)?)"
102  r"|cli/logs/(follow|boots/-?\d+(/follow)?)"
103  r"|core/logs/(follow|boots/-?\d+(/follow)?)"
104  r"|dns/logs/(follow|boots/-?\d+(/follow)?)"
105  r"|host/logs/(follow|boots/-?\d+(/follow)?)"
106  r"|multicast/logs/(follow|boots/-?\d+(/follow)?)"
107  r"|observer/logs/(follow|boots/-?\d+(/follow)?)"
108  r"|supervisor/logs/(follow|boots/-?\d+(/follow)?)"
109  r"|addons/[^/]+/logs/(follow|boots/-?\d+(/follow)?)"
110  r")$"
111 )
112 
113 PATHS_LOGS = re.compile(
114  r"^(?:"
115  r"|audio/logs(/follow|/boots/-?\d+(/follow)?)?"
116  r"|cli/logs(/follow|/boots/-?\d+(/follow)?)?"
117  r"|core/logs(/follow|/boots/-?\d+(/follow)?)?"
118  r"|dns/logs(/follow|/boots/-?\d+(/follow)?)?"
119  r"|host/logs(/follow|/boots/-?\d+(/follow)?)?"
120  r"|multicast/logs(/follow|/boots/-?\d+(/follow)?)?"
121  r"|observer/logs(/follow|/boots/-?\d+(/follow)?)?"
122  r"|supervisor/logs(/follow|/boots/-?\d+(/follow)?)?"
123  r"|addons/[^/]+/logs(/follow|/boots/-?\d+(/follow)?)?"
124  r")$"
125 )
126 # fmt: on
127 
128 
129 RESPONSE_HEADERS_FILTER = {
130  TRANSFER_ENCODING,
131  CONTENT_LENGTH,
132  CONTENT_TYPE,
133  CONTENT_ENCODING,
134 }
135 
136 
137 class HassIOView(HomeAssistantView):
138  """Hass.io view to handle base part."""
139 
140  name = "api:hassio"
141  url = "/api/hassio/{path:.+}"
142  requires_auth = False
143 
144  def __init__(self, host: str, websession: aiohttp.ClientSession) -> None:
145  """Initialize a Hass.io base view."""
146  self._host_host = host
147  self._websession_websession = websession
148 
149  async def _handle(self, request: web.Request, path: str) -> web.StreamResponse:
150  """Return a client request with proxy origin for Hass.io supervisor.
151 
152  Use cases:
153  - Onboarding allows restoring backups
154  - Load Supervisor panel and add-on logo unauthenticated
155  - User upload/restore backups
156  """
157  # No bullshit
158  if path != unquote(path):
159  return web.Response(status=HTTPStatus.BAD_REQUEST)
160 
161  hass = request.app[KEY_HASS]
162  is_admin = request[KEY_AUTHENTICATED] and request[KEY_HASS_USER].is_admin
163  authorized = is_admin
164 
165  if is_admin:
166  allowed_paths = PATHS_ADMIN
167 
168  elif not async_is_onboarded(hass):
169  allowed_paths = PATHS_NOT_ONBOARDED
170 
171  # During onboarding we need the user to manage backups
172  authorized = True
173 
174  else:
175  # Either unauthenticated or not an admin
176  allowed_paths = PATHS_NO_AUTH
177 
178  no_auth_path = PATHS_NO_AUTH.match(path)
179  headers = {
180  X_HASS_SOURCE: "core.http",
181  }
182 
183  if no_auth_path:
184  if request.method != "GET":
185  return web.Response(status=HTTPStatus.METHOD_NOT_ALLOWED)
186 
187  else:
188  if not allowed_paths.match(path):
189  return web.Response(status=HTTPStatus.UNAUTHORIZED)
190 
191  if authorized:
192  headers[AUTHORIZATION] = (
193  f"Bearer {os.environ.get('SUPERVISOR_TOKEN', '')}"
194  )
195 
196  if request.method == "POST":
197  headers[CONTENT_TYPE] = request.content_type
198  # _stored_content_type is only computed once `content_type` is accessed
199  if path == "backups/new/upload":
200  # We need to reuse the full content type that includes the boundary
201  if TYPE_CHECKING:
202  assert isinstance(request._stored_content_type, str) # noqa: SLF001
203  headers[CONTENT_TYPE] = request._stored_content_type # noqa: SLF001
204 
205  # forward range headers for logs
206  if PATHS_LOGS.match(path) and request.headers.get(RANGE):
207  headers[RANGE] = request.headers[RANGE]
208 
209  try:
210  client = await self._websession_websession.request(
211  method=request.method,
212  url=f"http://{self._host}/{quote(path)}",
213  params=request.query,
214  data=request.content if request.method != "GET" else None,
215  headers=headers,
216  timeout=_get_timeout(path),
217  )
218 
219  # Stream response
220  response = web.StreamResponse(
221  status=client.status, headers=_response_header(client, path)
222  )
223  response.content_type = client.content_type
224 
225  if should_compress(response.content_type, path):
226  response.enable_compression()
227  await response.prepare(request)
228  # In testing iter_chunked, iter_any, and iter_chunks:
229  # iter_chunks was the best performing option since
230  # it does not have to do as much re-assembly
231  async for data, _ in client.content.iter_chunks():
232  await response.write(data)
233 
234  except aiohttp.ClientError as err:
235  _LOGGER.error("Client error on api %s request %s", path, err)
236  raise HTTPBadGateway from err
237  except TimeoutError as err:
238  _LOGGER.error("Client timeout error on API request %s", path)
239  raise HTTPBadGateway from err
240  return response
241 
242  get = _handle
243  post = _handle
244 
245 
246 def _response_header(response: aiohttp.ClientResponse, path: str) -> dict[str, str]:
247  """Create response header."""
248  headers = {
249  name: value
250  for name, value in response.headers.items()
251  if name not in RESPONSE_HEADERS_FILTER
252  }
253  if NO_STORE.match(path):
254  headers[CACHE_CONTROL] = "no-store, max-age=0"
255  return headers
256 
257 
258 def _get_timeout(path: str) -> ClientTimeout:
259  """Return timeout for a URL path."""
260  if NO_TIMEOUT.match(path):
261  return ClientTimeout(connect=10, total=None)
262  return ClientTimeout(connect=10, total=300)
263 
264 
265 def should_compress(content_type: str, path: str | None = None) -> bool:
266  """Return if we should compress a response."""
267  if path is not None and NO_COMPRESS.match(path):
268  return False
269  if content_type.startswith("image/"):
270  return "svg" in content_type
271  if content_type.startswith("application/"):
272  return (
273  "json" in content_type
274  or "xml" in content_type
275  or "javascript" in content_type
276  )
277  return not content_type.startswith(("video/", "audio/", "font/"))
None __init__(self, str host, aiohttp.ClientSession websession)
Definition: http.py:144
web.StreamResponse _handle(self, web.Request request, str path)
Definition: http.py:149
dict[str, str] _response_header(aiohttp.ClientResponse response, str path)
Definition: http.py:246
bool should_compress(str content_type, str|None path=None)
Definition: http.py:265
ClientTimeout _get_timeout(str path)
Definition: http.py:258
bool async_is_onboarded(HomeAssistant hass)
Definition: __init__.py:68