mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
102 lines
3.7 KiB
Python
102 lines
3.7 KiB
Python
"""Bounded outbound submit to the compile-time House Plan support relay (#43)."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import Any
|
|
from urllib.parse import urlsplit
|
|
|
|
import aiohttp
|
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
|
|
|
from .const import SUPPORT_RELAY_URL
|
|
|
|
_REPORT_ID_PREFIX = "hpr-"
|
|
|
|
|
|
class SupportTransportError(RuntimeError):
|
|
"""Stable frontend-facing error; never contains the remote response."""
|
|
|
|
def __init__(self, code: str) -> None:
|
|
super().__init__(code)
|
|
self.code = code
|
|
|
|
|
|
async def async_submit_report(
|
|
hass,
|
|
*,
|
|
message: str,
|
|
contact: str,
|
|
versions: dict[str, Any],
|
|
idempotency_key: str,
|
|
attachment: bytes | None,
|
|
attachment_sha256: str | None,
|
|
) -> str:
|
|
"""Send once, without redirects, proxies, arbitrary hosts or reflected text."""
|
|
target = urlsplit(SUPPORT_RELAY_URL)
|
|
if target.scheme != "https" or target.hostname != "support.houseplan.tech":
|
|
raise SupportTransportError("support_unavailable")
|
|
|
|
request: dict[str, Any] = {
|
|
"schema_version": 1,
|
|
"message": message,
|
|
"idempotency_key": idempotency_key,
|
|
"versions": {key: str(value) for key, value in versions.items()},
|
|
}
|
|
if contact:
|
|
request["contact"] = contact
|
|
if attachment is not None:
|
|
request["attachment"] = {
|
|
"size": len(attachment),
|
|
"sha256": str(attachment_sha256 or ""),
|
|
}
|
|
form = aiohttp.FormData()
|
|
form.add_field(
|
|
"request", json.dumps(request, ensure_ascii=False, separators=(",", ":")),
|
|
content_type="application/json",
|
|
)
|
|
if attachment is not None:
|
|
filename_sha256 = str(attachment_sha256 or "")
|
|
form.add_field(
|
|
"attachment", attachment,
|
|
filename=f"houseplan-support-{filename_sha256[:12]}.json",
|
|
content_type="application/json",
|
|
)
|
|
|
|
session = async_get_clientsession(hass)
|
|
timeout = aiohttp.ClientTimeout(total=20, sock_connect=5)
|
|
try:
|
|
async with session.post(
|
|
SUPPORT_RELAY_URL, data=form, timeout=timeout, allow_redirects=False,
|
|
) as response:
|
|
if response.status == 429:
|
|
raise SupportTransportError("support_rate_limited")
|
|
if response.status == 413:
|
|
raise SupportTransportError("support_package_too_large")
|
|
if response.status in {400, 401, 403, 404, 405, 409, 415, 422}:
|
|
raise SupportTransportError("support_rejected")
|
|
if response.status != 200:
|
|
raise SupportTransportError("support_unavailable")
|
|
# A compromised/misconfigured relay cannot make HA buffer an
|
|
# unbounded response or reflect its details to the browser.
|
|
body = await response.content.read(4097)
|
|
if len(body) > 4096:
|
|
raise SupportTransportError("support_unavailable")
|
|
except SupportTransportError:
|
|
raise
|
|
except (aiohttp.ClientError, TimeoutError, OSError):
|
|
raise SupportTransportError("support_unavailable") from None
|
|
|
|
try:
|
|
payload = json.loads(body.decode("utf-8"))
|
|
report_id = payload.get("report_id") if isinstance(payload, dict) else None
|
|
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
report_id = None
|
|
if not isinstance(report_id, str) or not report_id.startswith(_REPORT_ID_PREFIX):
|
|
raise SupportTransportError("support_unavailable")
|
|
if not 8 <= len(report_id) <= 64 or any(
|
|
char not in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_."
|
|
for char in report_id
|
|
):
|
|
raise SupportTransportError("support_unavailable")
|
|
return report_id
|