refactor+fix v1.10.0: аудит — гонки записи (asyncio.Lock, атомарный rev), точечный layout/update вместо layout/set (анти last-writer-wins), layout/delete, safeUrl против XSS в link/pdfs, fetchWithAuth, KEY_HASS, стриминговый лимит upload, модульность (styles/types/devices), динамические пространства в GUI-редакторе, чистка мёртвого кода (file/set, GROUP_TITLES)

This commit is contained in:
Matysh
2026-07-05 20:58:33 +03:00
parent 7cd9a56f5a
commit e7cf0416be
16 changed files with 1991 additions and 1874 deletions
+28 -9
View File
@@ -11,6 +11,11 @@ from pathlib import Path
from aiohttp import web
from homeassistant.components.http import HomeAssistantView
try: # KEY_HASS — современный доступ к hass из aiohttp-приложения
from homeassistant.components.http import KEY_HASS
except ImportError: # старые версии HA
KEY_HASS = "hass" # type: ignore[assignment]
from homeassistant.core import HomeAssistant
from .const import CONF_ADMIN_ONLY, DOMAIN, FILES_DIR, FILES_URL
@@ -24,6 +29,8 @@ from .validation import (
_LOGGER = logging.getLogger(__name__)
_CHUNK = 64 * 1024
class HouseplanUploadView(HomeAssistantView):
"""POST /api/houseplan/upload — сохранить файл маркера, вернуть URL."""
@@ -33,7 +40,7 @@ class HouseplanUploadView(HomeAssistantView):
requires_auth = True
async def post(self, request: web.Request) -> web.Response:
hass: HomeAssistant = request.app["hass"]
hass: HomeAssistant = request.app[KEY_HASS]
entry = hass.data.get(DOMAIN, {}).get("entry")
admin_only = bool(entry and entry.options.get(CONF_ADMIN_ONLY, False))
if admin_only:
@@ -44,6 +51,7 @@ class HouseplanUploadView(HomeAssistantView):
marker_id = "misc"
filename: str | None = None
blob: bytes | None = None
too_large = False
try:
reader = await request.multipart()
async for part in reader:
@@ -51,11 +59,26 @@ class HouseplanUploadView(HomeAssistantView):
marker_id = sanitize_marker_id(await part.text())
elif part.name == "file":
filename = part.filename or "file"
blob = await part.read(decode=False)
# читаем чанками с обрывом по лимиту, а не весь файл в память
chunks: list[bytes] = []
size = 0
while chunk := await part.read_chunk(_CHUNK):
size += len(chunk)
if size > MAX_FILE_BYTES:
too_large = True
break
chunks.append(chunk)
if too_large:
break
blob = b"".join(chunks)
except Exception as err: # noqa: BLE001
_LOGGER.warning("House Plan upload: ошибка чтения multipart: %s", err)
return web.json_response({"error": "bad_request"}, status=400)
if too_large:
return web.json_response(
{"error": "too_large", "max_mb": MAX_FILE_BYTES // 1024 // 1024}, status=413
)
if blob is None or not filename:
return web.json_response({"error": "no_file"}, status=400)
ext = file_ext(filename)
@@ -63,21 +86,17 @@ class HouseplanUploadView(HomeAssistantView):
return web.json_response(
{"error": "bad_ext", "allowed": sorted(FILE_EXTENSIONS)}, status=400
)
if len(blob) > MAX_FILE_BYTES:
return web.json_response(
{"error": "too_large", "max_mb": MAX_FILE_BYTES // 1024 // 1024}, status=413
)
safe_name = sanitize_filename(filename)
target_dir = Path(hass.config.path(FILES_DIR)) / marker_id
path = target_dir / safe_name
def _write() -> None:
def _write() -> int:
target_dir.mkdir(parents=True, exist_ok=True)
path.write_bytes(blob)
return int(path.stat().st_mtime)
await hass.async_add_executor_job(_write)
mtime = int(path.stat().st_mtime)
mtime = await hass.async_add_executor_job(_write)
return web.json_response(
{"ok": True, "url": f"{FILES_URL}/{marker_id}/{safe_name}?v={mtime}", "name": filename}
)