mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 08:28:31 +00:00
v1.46.0: full external audit of v1.45.4 — HP-1454-01 … -10
HP-1454-01 (high, release blocker): an uploaded SVG plan opened directly is a top-level document of Home Assistant's own origin, so a <script> inside it reaches the session's localStorage and API. Uploading needs write access, which by default every authenticated user has. SVG responses now carry a sandbox CSP; only SVG, because a CSP on a PDF can break the browser's viewer and a raster image has nothing to disable. Verified in Chromium both ways: the script runs without the header and does not with it. HP-1454-02: attachment uploads wrote straight to <marker>/<filename>, outside the config transaction — a cancelled dialog or a rejected save left the stored url serving new bytes, and every new icon shared one 'new' folder, so two of them attaching manual.pdf pointed at one file. Uploads take a free name, a new icon gets a per-dialog staging folder promoted on an accepted save, and config/set collects superseded and aged-orphan attachments like it does plans. HP-1454-03: the debounce spaced out the starts of a write, not the writes. A save slower than 500 ms let the next edit go out with the same expected_rev; the server accepted the first, rejected the second, and the conflict handler reloaded over the local copy. Writes are chained now — one in flight, each with the revision the previous returned. HP-1454-04: _openPairsCache keyed on room ids and links only, so an aspect change or a dragged vertex left open boundaries and their glow cuts at old coordinates. It keys on the rendered model object now — the same invalidation the model cache already has, not a second strategy. The fingerprint also gained an O(1) geometry roll-up per room. HP-1454-05: outer collections were capped, inner ones were not. Limits for poly points, open_to, controls, pdfs, text and url lengths, plus a total serialized size cap; legacy is dropped server-side. HP-1454-06: upload streams to a temp file and downloads use FileResponse, so a 50 MB manual no longer costs ~100 MB of RSS per transfer. HP-1454-07: spaceModels() dropped room.settings, so the static card ignored the per-room fill override. HP-1454-08: layout had no revision on point-wise writes and no event, leaving static cards stale forever; it now keeps a revision, returns it and fires houseplan_layout_updated. HP-1454-09: repair cleanup only walked existing spaces, so a deleted space kept its warning. HP-1454-10: serialize-javascript pinned past two advisories. Tests: smoke_svg_sandbox (proves both directions), smoke_config_writer and smoke_render_parity (both verified failing against a v1.45.4 build), six pure tests for attachment collection and inner limits, four HA-harness tests for the CSP, non-overwriting uploads, the size cap and layout revisions. Docs: CHANGELOG.md + CHANGELOG.ru.md + ARCHITECTURE.md + TESTING.md + STATUS.md.
This commit is contained in:
Binary file not shown.
@@ -24,7 +24,7 @@ MAX_SIGN_PATHS = 200
|
|||||||
PLAN_ORPHAN_TTL_S = 3600
|
PLAN_ORPHAN_TTL_S = 3600
|
||||||
FILES_DIR = "houseplan/files"
|
FILES_DIR = "houseplan/files"
|
||||||
CONF_ADMIN_ONLY = "admin_only"
|
CONF_ADMIN_ONLY = "admin_only"
|
||||||
VERSION = "1.45.4"
|
VERSION = "1.46.0"
|
||||||
|
|
||||||
DEFAULT_CONFIG: dict = {
|
DEFAULT_CONFIG: dict = {
|
||||||
"spaces": [],
|
"spaces": [],
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -6,6 +6,8 @@ breaks the connection on a large PDF) but via a plain multipart POST — like me
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
@@ -20,6 +22,7 @@ from homeassistant.core import HomeAssistant
|
|||||||
|
|
||||||
from .const import CONF_ADMIN_ONLY, CONTENT_URL, FILES_DIR, FILES_URL, PLANS_DIR
|
from .const import CONF_ADMIN_ONLY, CONTENT_URL, FILES_DIR, FILES_URL, PLANS_DIR
|
||||||
from .auth import may_write
|
from .auth import may_write
|
||||||
|
from .plans import unique_filename
|
||||||
from .validation import (
|
from .validation import (
|
||||||
FILE_EXTENSIONS,
|
FILE_EXTENSIONS,
|
||||||
MAX_FILE_BYTES,
|
MAX_FILE_BYTES,
|
||||||
@@ -73,17 +76,35 @@ class HouseplanContentView(HomeAssistantView):
|
|||||||
if not str(path).startswith(str(base)):
|
if not str(path).startswith(str(base)):
|
||||||
return web.Response(status=404)
|
return web.Response(status=404)
|
||||||
|
|
||||||
def _read() -> bytes | None:
|
if not await hass.async_add_executor_job(path.is_file):
|
||||||
return path.read_bytes() if path.is_file() else None
|
|
||||||
|
|
||||||
blob = await hass.async_add_executor_job(_read)
|
|
||||||
if blob is None:
|
|
||||||
return web.Response(status=404)
|
return web.Response(status=404)
|
||||||
return web.Response(
|
suffix = path.suffix.lower()
|
||||||
body=blob,
|
headers = {
|
||||||
content_type=_MIME.get(path.suffix.lower(), "application/octet-stream"),
|
"Cache-Control": "private, max-age=3600",
|
||||||
headers={"Cache-Control": "private, max-age=3600"},
|
"Content-Type": _MIME.get(suffix, "application/octet-stream"),
|
||||||
)
|
}
|
||||||
|
if suffix == ".svg":
|
||||||
|
# An uploaded SVG is user content served from Home Assistant's own
|
||||||
|
# origin. Inside the card it is referenced by <image>, where scripts
|
||||||
|
# never run — but the same url opened as a top-level document is a
|
||||||
|
# live document of this origin, and a <script> in it reaches the
|
||||||
|
# session's localStorage and API (HP-1454-01, 2026-07-28: uploading
|
||||||
|
# needs write access, which by default every authenticated user has,
|
||||||
|
# and the signed url is easy to hand to an admin).
|
||||||
|
#
|
||||||
|
# `sandbox` with no allow-* tokens drops the document into an opaque
|
||||||
|
# origin: no scripts, no same-origin access, no forms. The explicit
|
||||||
|
# directives below are belt and braces for older engines. Only SVG
|
||||||
|
# gets this — a CSP on a PDF response can break the browser's built-in
|
||||||
|
# viewer, and a raster image cannot execute anything in the first place.
|
||||||
|
headers["Content-Security-Policy"] = (
|
||||||
|
"sandbox; default-src 'none'; script-src 'none'; object-src 'none'; "
|
||||||
|
"base-uri 'none'; form-action 'none'; style-src 'unsafe-inline'; img-src data:"
|
||||||
|
)
|
||||||
|
# FileResponse streams from disk: a 50 MB manual used to be read whole
|
||||||
|
# into memory and copied into the response body, so a couple of parallel
|
||||||
|
# downloads could push a small Home Assistant host into swap (HP-1454-06).
|
||||||
|
return web.FileResponse(path, chunk_size=_CHUNK, headers=headers)
|
||||||
|
|
||||||
|
|
||||||
class HouseplanUploadView(HomeAssistantView):
|
class HouseplanUploadView(HomeAssistantView):
|
||||||
@@ -100,8 +121,27 @@ class HouseplanUploadView(HomeAssistantView):
|
|||||||
|
|
||||||
marker_id = "misc"
|
marker_id = "misc"
|
||||||
filename: str | None = None
|
filename: str | None = None
|
||||||
blob: bytes | None = None
|
tmp_path: Path | None = None
|
||||||
too_large = False
|
too_large = False
|
||||||
|
bad_ext = False
|
||||||
|
files_root = Path(hass.config.path(FILES_DIR))
|
||||||
|
|
||||||
|
def _open_tmp() -> Path:
|
||||||
|
files_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
fd, name = tempfile.mkstemp(prefix=".upload-", dir=str(files_root))
|
||||||
|
os.close(fd)
|
||||||
|
return Path(name)
|
||||||
|
|
||||||
|
def _append(target: Path, data: bytes) -> None:
|
||||||
|
with open(target, "ab") as fh:
|
||||||
|
fh.write(data)
|
||||||
|
|
||||||
|
def _discard(target: Path) -> None:
|
||||||
|
try:
|
||||||
|
target.unlink()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
reader = await request.multipart()
|
reader = await request.multipart()
|
||||||
async for part in reader:
|
async for part in reader:
|
||||||
@@ -109,44 +149,62 @@ class HouseplanUploadView(HomeAssistantView):
|
|||||||
marker_id = sanitize_marker_id(await part.text())
|
marker_id = sanitize_marker_id(await part.text())
|
||||||
elif part.name == "file":
|
elif part.name == "file":
|
||||||
filename = part.filename or "file"
|
filename = part.filename or "file"
|
||||||
# read in chunks, aborting at the limit, instead of loading the whole file into memory
|
if file_ext(filename) not in FILE_EXTENSIONS:
|
||||||
chunks: list[bytes] = []
|
bad_ext = True
|
||||||
|
break
|
||||||
|
# Stream to a temporary file instead of collecting the whole
|
||||||
|
# upload in memory and copying it again into one buffer: a
|
||||||
|
# 50 MB manual used to cost ~100 MB of RSS mid-request, and
|
||||||
|
# a few of those at once is real pressure on a small Home
|
||||||
|
# Assistant host (HP-1454-06).
|
||||||
|
tmp_path = await hass.async_add_executor_job(_open_tmp)
|
||||||
size = 0
|
size = 0
|
||||||
while chunk := await part.read_chunk(_CHUNK):
|
while chunk := await part.read_chunk(_CHUNK):
|
||||||
size += len(chunk)
|
size += len(chunk)
|
||||||
if size > MAX_FILE_BYTES:
|
if size > MAX_FILE_BYTES:
|
||||||
too_large = True
|
too_large = True
|
||||||
break
|
break
|
||||||
chunks.append(chunk)
|
await hass.async_add_executor_job(_append, tmp_path, chunk)
|
||||||
if too_large:
|
if too_large:
|
||||||
break
|
break
|
||||||
blob = b"".join(chunks)
|
|
||||||
except Exception as err: # noqa: BLE001
|
except Exception as err: # noqa: BLE001
|
||||||
_LOGGER.warning("House Plan upload: multipart read error: %s", err)
|
_LOGGER.warning("House Plan upload: multipart read error: %s", err)
|
||||||
|
if tmp_path is not None:
|
||||||
|
await hass.async_add_executor_job(_discard, tmp_path)
|
||||||
return web.json_response({"error": "bad_request"}, status=400)
|
return web.json_response({"error": "bad_request"}, status=400)
|
||||||
|
|
||||||
if too_large:
|
if bad_ext:
|
||||||
return web.json_response(
|
if tmp_path is not None:
|
||||||
{"error": "too_large", "max_mb": MAX_FILE_BYTES // 1024 // 1024}, status=413
|
await hass.async_add_executor_job(_discard, tmp_path)
|
||||||
)
|
|
||||||
if blob is None or not filename:
|
|
||||||
return web.json_response({"error": "no_file"}, status=400)
|
|
||||||
ext = file_ext(filename)
|
|
||||||
if ext not in FILE_EXTENSIONS:
|
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{"error": "bad_ext", "allowed": sorted(FILE_EXTENSIONS)}, status=400
|
{"error": "bad_ext", "allowed": sorted(FILE_EXTENSIONS)}, status=400
|
||||||
)
|
)
|
||||||
|
if too_large:
|
||||||
|
await hass.async_add_executor_job(_discard, tmp_path)
|
||||||
|
return web.json_response(
|
||||||
|
{"error": "too_large", "max_mb": MAX_FILE_BYTES // 1024 // 1024}, status=413
|
||||||
|
)
|
||||||
|
if tmp_path is None or not filename:
|
||||||
|
return web.json_response({"error": "no_file"}, status=400)
|
||||||
|
|
||||||
safe_name = sanitize_filename(filename)
|
safe_name = sanitize_filename(filename)
|
||||||
target_dir = Path(hass.config.path(FILES_DIR)) / marker_id
|
target_dir = files_root / marker_id
|
||||||
path = target_dir / safe_name
|
|
||||||
|
|
||||||
def _write() -> int:
|
def _promote() -> str:
|
||||||
|
"""Move the finished upload in under a name that is free.
|
||||||
|
|
||||||
|
Never overwrite: the previous bytes may be referenced by the stored
|
||||||
|
configuration, and this upload is not part of that transaction — a
|
||||||
|
cancelled dialog or a rejected save would leave the old url pointing
|
||||||
|
at the new content (HP-1454-02). An unreferenced upload is collected
|
||||||
|
later by config/set, once it is old enough.
|
||||||
|
"""
|
||||||
target_dir.mkdir(parents=True, exist_ok=True)
|
target_dir.mkdir(parents=True, exist_ok=True)
|
||||||
path.write_bytes(blob)
|
name = unique_filename(target_dir, safe_name)
|
||||||
return int(path.stat().st_mtime)
|
os.replace(tmp_path, target_dir / name)
|
||||||
|
return name
|
||||||
|
|
||||||
mtime = await hass.async_add_executor_job(_write)
|
name = await hass.async_add_executor_job(_promote)
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{"ok": True, "url": f"{CONTENT_URL}/files/{marker_id}/{safe_name}?v={mtime}", "name": filename}
|
{"ok": True, "url": f"{CONTENT_URL}/files/{marker_id}/{name}", "name": filename}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,5 +16,5 @@
|
|||||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||||
"requirements": [],
|
"requirements": [],
|
||||||
"single_config_entry": true,
|
"single_config_entry": true,
|
||||||
"version": "1.45.4"
|
"version": "1.46.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
"""Plan-file collection — pure, so it is unit-testable without Home Assistant.
|
"""Blob lifecycle — pure, so it is unit-testable without Home Assistant.
|
||||||
|
|
||||||
The file system is not part of the configuration store's transaction, so who
|
The file system is not part of the configuration store's transaction, so who
|
||||||
may delete a plan file, and when, is a correctness question rather than a
|
may write or delete a plan or an attachment, and when, is a correctness
|
||||||
housekeeping one. It lives here, apart from the WebSocket plumbing, precisely
|
question rather than housekeeping. It lives here, apart from the WebSocket and
|
||||||
because it is the part that has to be reasoned about and tested.
|
HTTP plumbing, precisely because it is the part that has to be reasoned about
|
||||||
|
and tested.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -13,11 +14,102 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from .const import PLAN_ORPHAN_TTL_S
|
from .const import PLAN_ORPHAN_TTL_S
|
||||||
from .validation import PLAN_EXTENSIONS
|
from .validation import PLAN_EXTENSIONS, sanitize_filename
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def unique_filename(directory: Path, name: str) -> str:
|
||||||
|
"""A name inside `directory` that is not taken, deriving from `name`.
|
||||||
|
|
||||||
|
Uploads never overwrite. The bytes already under a name may be referenced by
|
||||||
|
the stored configuration, and an upload is not part of that transaction: a
|
||||||
|
cancelled dialog or a rejected save would otherwise leave a live url serving
|
||||||
|
someone else's file (HP-1454-02).
|
||||||
|
"""
|
||||||
|
safe = sanitize_filename(name)
|
||||||
|
if not (directory / safe).exists():
|
||||||
|
return safe
|
||||||
|
stem, dot, suffix = safe.rpartition(".")
|
||||||
|
if not dot:
|
||||||
|
stem, suffix = safe, ""
|
||||||
|
i = 2
|
||||||
|
while True:
|
||||||
|
candidate = f"{stem} ({i}){'.' + suffix if suffix else ''}"
|
||||||
|
if not (directory / candidate).exists():
|
||||||
|
return candidate
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
|
||||||
|
def attachment_refs(cfg: dict[str, Any] | None) -> set[str]:
|
||||||
|
""""<marker>/<file>" for every attachment a configuration references."""
|
||||||
|
out: set[str] = set()
|
||||||
|
for m in (cfg or {}).get("markers") or []:
|
||||||
|
for pdf in m.get("pdfs") or []:
|
||||||
|
url = pdf.get("url") if isinstance(pdf, dict) else None
|
||||||
|
if not isinstance(url, str) or "/files/" not in url:
|
||||||
|
continue
|
||||||
|
rel = url.split("?", 1)[0].split("/files/", 1)[1]
|
||||||
|
if rel.count("/") == 1:
|
||||||
|
out.add(rel)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def collect_attachments(
|
||||||
|
files_dir: Path,
|
||||||
|
old_cfg: dict[str, Any] | None,
|
||||||
|
new_cfg: dict[str, Any],
|
||||||
|
now: float | None = None,
|
||||||
|
) -> int:
|
||||||
|
"""The same commit-scoped rule as `collect_plans`, for marker attachments.
|
||||||
|
|
||||||
|
A file the old revision referenced and the new one does not was superseded
|
||||||
|
by this commit and goes. Anything else unreferenced is an upload that was
|
||||||
|
never saved — a cancelled dialog, a rejected write — and waits out
|
||||||
|
PLAN_ORPHAN_TTL_S first, because a fresh one may belong to a dialog the user
|
||||||
|
still has open. Never raises: it runs behind a durable write.
|
||||||
|
"""
|
||||||
|
new_refs = attachment_refs(new_cfg)
|
||||||
|
old_refs = attachment_refs(old_cfg)
|
||||||
|
cutoff = (time.time() if now is None else now) - PLAN_ORPHAN_TTL_S
|
||||||
|
removed = 0
|
||||||
|
try:
|
||||||
|
folders = sorted(p for p in files_dir.iterdir() if p.is_dir()) if files_dir.is_dir() else []
|
||||||
|
except OSError as err:
|
||||||
|
_LOGGER.warning("House Plan: could not list %s: %s", files_dir, err)
|
||||||
|
return 0
|
||||||
|
for folder in folders:
|
||||||
|
try:
|
||||||
|
items = sorted(p for p in folder.iterdir() if p.is_file())
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
for item in items:
|
||||||
|
rel = f"{folder.name}/{item.name}"
|
||||||
|
if rel in new_refs:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
stale = item.stat().st_mtime < cutoff
|
||||||
|
except OSError:
|
||||||
|
stale = False
|
||||||
|
if rel not in old_refs and not stale:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
item.unlink()
|
||||||
|
removed += 1
|
||||||
|
except OSError as err:
|
||||||
|
_LOGGER.warning("House Plan: could not remove the attachment %s: %s", item, err)
|
||||||
|
try:
|
||||||
|
next(folder.iterdir())
|
||||||
|
except StopIteration:
|
||||||
|
try:
|
||||||
|
folder.rmdir()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return removed
|
||||||
|
|
||||||
|
|
||||||
def plan_basename(url: Any) -> str:
|
def plan_basename(url: Any) -> str:
|
||||||
"""File name a stored plan_url points at ('' when there is none)."""
|
"""File name a stored plan_url points at ('' when there is none)."""
|
||||||
if not isinstance(url, str) or not url:
|
if not isinstance(url, str) or not url:
|
||||||
|
|||||||
@@ -51,8 +51,17 @@ async def async_check_plan_files(hass: HomeAssistant, entry: HouseplanConfigEntr
|
|||||||
translation_key="broken_plan",
|
translation_key="broken_plan",
|
||||||
translation_placeholders={"space": space_id, "file": fname},
|
translation_placeholders={"space": space_id, "file": fname},
|
||||||
)
|
)
|
||||||
# clear stale issues for spaces that are fine again (or gone)
|
# Clear stale issues. Iterating the CURRENT spaces could only ever clear
|
||||||
for sp in spaces:
|
# issues for spaces that still exist, so deleting or renaming a space with a
|
||||||
sid = sp.get("id", "?")
|
# missing plan left its warning in Repairs forever, with nothing left to fix
|
||||||
if sid not in broken:
|
# it (HP-1454-09). Enumerate what we actually published instead.
|
||||||
ir.async_delete_issue(hass, DOMAIN, f"broken_plan_{sid}")
|
registry = ir.async_get(hass)
|
||||||
|
stale = [
|
||||||
|
issue_id
|
||||||
|
for (domain, issue_id) in list(registry.issues)
|
||||||
|
if domain == DOMAIN
|
||||||
|
and issue_id.startswith("broken_plan_")
|
||||||
|
and issue_id[len("broken_plan_") :] not in broken
|
||||||
|
]
|
||||||
|
for issue_id in stale:
|
||||||
|
ir.async_delete_issue(hass, DOMAIN, issue_id)
|
||||||
|
|||||||
@@ -66,6 +66,26 @@ MAX_MARKERS = 2000
|
|||||||
MAX_OPENINGS = 500
|
MAX_OPENINGS = 500
|
||||||
MAX_DECOR = 1000
|
MAX_DECOR = 1000
|
||||||
MAX_LAYOUT = 5000
|
MAX_LAYOUT = 5000
|
||||||
|
# Inner limits (HP-1454-05). The outer collections were capped, the collections
|
||||||
|
# INSIDE them were not: a 150 000-point polygon or a 100 000-entry known_devices
|
||||||
|
# list passed validation, then made the card build gigantic SVG attributes and
|
||||||
|
# walk them on every render. Any authenticated writer could store one, and with
|
||||||
|
# `admin_only` off that is every user. These are product limits, not guesses: a
|
||||||
|
# hand-drawn room does not need 500 vertices, and no home has 200 lights behind
|
||||||
|
# one switch.
|
||||||
|
MAX_POLY_POINTS = 500
|
||||||
|
MAX_OPEN_TO = 50
|
||||||
|
MAX_CONTROLS = 200
|
||||||
|
MAX_PDFS = 50
|
||||||
|
MAX_KNOWN_DEVICES = 20000
|
||||||
|
MAX_TEXT = 500 # names, models, ids
|
||||||
|
MAX_DESCRIPTION = 4000
|
||||||
|
MAX_URL = 2000
|
||||||
|
MAX_CONFIG_BYTES = 12 * 1024 * 1024
|
||||||
|
|
||||||
|
_TEXT = vol.All(str, vol.Length(max=MAX_TEXT))
|
||||||
|
_TEXT_OR_NONE = vol.Any(None, _TEXT)
|
||||||
|
_URL = vol.All(str, vol.Length(max=MAX_URL))
|
||||||
|
|
||||||
POS_SCHEMA = vol.Schema(
|
POS_SCHEMA = vol.Schema(
|
||||||
{vol.Required("x"): _finite, vol.Required("y"): _finite},
|
{vol.Required("x"): _finite, vol.Required("y"): _finite},
|
||||||
@@ -85,10 +105,10 @@ def _require_geometry(room: dict) -> dict:
|
|||||||
ROOM_SCHEMA = vol.All(
|
ROOM_SCHEMA = vol.All(
|
||||||
vol.Schema(
|
vol.Schema(
|
||||||
{
|
{
|
||||||
vol.Required("id"): str,
|
vol.Required("id"): _TEXT,
|
||||||
vol.Required("name"): str,
|
vol.Required("name"): _TEXT,
|
||||||
vol.Optional("area"): vol.Any(str, None),
|
vol.Optional("area"): _TEXT_OR_NONE,
|
||||||
vol.Optional("open_to"): [str],
|
vol.Optional("open_to"): vol.All([_TEXT], vol.Length(max=MAX_OPEN_TO)),
|
||||||
vol.Optional("settings"): vol.Any(
|
vol.Optional("settings"): vol.Any(
|
||||||
None,
|
None,
|
||||||
vol.Schema(
|
vol.Schema(
|
||||||
@@ -106,7 +126,7 @@ ROOM_SCHEMA = vol.All(
|
|||||||
vol.Optional("y"): _finite,
|
vol.Optional("y"): _finite,
|
||||||
vol.Optional("w"): _finite,
|
vol.Optional("w"): _finite,
|
||||||
vol.Optional("h"): _finite,
|
vol.Optional("h"): _finite,
|
||||||
vol.Optional("poly"): vol.All([POINT], vol.Length(min=3)),
|
vol.Optional("poly"): vol.All([POINT], vol.Length(min=3, max=MAX_POLY_POINTS)),
|
||||||
},
|
},
|
||||||
extra=vol.ALLOW_EXTRA,
|
extra=vol.ALLOW_EXTRA,
|
||||||
),
|
),
|
||||||
@@ -185,7 +205,10 @@ SPACE_SCHEMA = vol.Schema(
|
|||||||
# Legacy: walls are derived from room outlines since v1.19.0 — a line has no
|
# Legacy: walls are derived from room outlines since v1.19.0 — a line has no
|
||||||
# independent existence. Still accepted so a stale browser tab cannot fail a save;
|
# independent existence. Still accepted so a stale browser tab cannot fail a save;
|
||||||
# the card strips the field on every write.
|
# the card strips the field on every write.
|
||||||
vol.Optional("segments"): [vol.All([vol.Coerce(float)], vol.Length(min=4, max=4))],
|
# Accepted so a stale browser tab cannot fail a save, then DROPPED here
|
||||||
|
# (HP-1454-05): relying on a modern client to strip an unbounded legacy
|
||||||
|
# list is not a limit, it is a hope. `Remove` returns the key stripped.
|
||||||
|
vol.Remove("segments"): object,
|
||||||
},
|
},
|
||||||
extra=vol.ALLOW_EXTRA,
|
extra=vol.ALLOW_EXTRA,
|
||||||
)
|
)
|
||||||
@@ -197,13 +220,13 @@ MARKER_SCHEMA = vol.Schema(
|
|||||||
vol.Optional("space"): vol.Any(str, None),
|
vol.Optional("space"): vol.Any(str, None),
|
||||||
vol.Optional("area"): vol.Any(str, None),
|
vol.Optional("area"): vol.Any(str, None),
|
||||||
vol.Optional("hidden"): bool,
|
vol.Optional("hidden"): bool,
|
||||||
vol.Optional("name"): vol.Any(str, None),
|
vol.Optional("name"): _TEXT_OR_NONE,
|
||||||
vol.Optional("icon"): vol.Any(str, None),
|
vol.Optional("icon"): _TEXT_OR_NONE,
|
||||||
vol.Optional("model"): vol.Any(str, None),
|
vol.Optional("model"): _TEXT_OR_NONE,
|
||||||
vol.Optional("link"): vol.Any(str, None),
|
vol.Optional("link"): vol.Any(None, _URL),
|
||||||
vol.Optional("description"): vol.Any(str, None),
|
vol.Optional("description"): vol.Any(None, vol.All(str, vol.Length(max=MAX_DESCRIPTION))),
|
||||||
vol.Optional("tap_action"): vol.Any("info", "more-info", "toggle", None),
|
vol.Optional("tap_action"): vol.Any("info", "more-info", "toggle", None),
|
||||||
vol.Optional("controls"): vol.Any([str], None),
|
vol.Optional("controls"): vol.Any(None, vol.All([_TEXT], vol.Length(max=MAX_CONTROLS))),
|
||||||
vol.Optional("glow_radius_cm"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)), None),
|
vol.Optional("glow_radius_cm"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)), None),
|
||||||
vol.Optional("is_light"): vol.Any(bool, None),
|
vol.Optional("is_light"): vol.Any(bool, None),
|
||||||
vol.Optional("room_id"): vol.Any(str, None),
|
vol.Optional("room_id"): vol.Any(str, None),
|
||||||
@@ -214,9 +237,10 @@ MARKER_SCHEMA = vol.Schema(
|
|||||||
vol.Optional("ripple_size"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=1, max=20)), None),
|
vol.Optional("ripple_size"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=1, max=20)), None),
|
||||||
vol.Optional("size"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=0.2, max=6)), None),
|
vol.Optional("size"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=0.2, max=6)), None),
|
||||||
vol.Optional("angle"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=-360, max=360)), None),
|
vol.Optional("angle"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=-360, max=360)), None),
|
||||||
vol.Optional("pdfs"): [
|
vol.Optional("pdfs"): vol.All(
|
||||||
vol.Schema({vol.Required("name"): str, vol.Required("url"): str}, extra=vol.ALLOW_EXTRA)
|
[vol.Schema({vol.Required("name"): _TEXT, vol.Required("url"): _URL}, extra=vol.ALLOW_EXTRA)],
|
||||||
],
|
vol.Length(max=MAX_PDFS),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
extra=vol.ALLOW_EXTRA,
|
extra=vol.ALLOW_EXTRA,
|
||||||
)
|
)
|
||||||
@@ -227,8 +251,8 @@ CONFIG_SCHEMA = vol.Schema(
|
|||||||
vol.Optional("settings", default=dict): vol.Schema(
|
vol.Optional("settings", default=dict): vol.Schema(
|
||||||
{
|
{
|
||||||
vol.Optional("glow_radius_cm"): vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)),
|
vol.Optional("glow_radius_cm"): vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)),
|
||||||
vol.Optional("known_devices"): [str],
|
vol.Optional("known_devices"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
|
||||||
vol.Optional("new_device_ids"): [str],
|
vol.Optional("new_device_ids"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
|
||||||
vol.Optional("fill_colors"): vol.Schema(
|
vol.Optional("fill_colors"): vol.Schema(
|
||||||
{
|
{
|
||||||
str: vol.Schema(
|
str: vol.Schema(
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import logging
|
|||||||
|
|
||||||
import base64
|
import base64
|
||||||
import binascii
|
import binascii
|
||||||
|
import json
|
||||||
import secrets
|
import secrets
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -16,13 +17,13 @@ from homeassistant.core import HomeAssistant, callback
|
|||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
CONF_ADMIN_ONLY, DEFAULT_CONFIG,
|
CONF_ADMIN_ONLY, DEFAULT_CONFIG,
|
||||||
CONTENT_URL, MAX_SIGN_PATHS, PLANS_DIR, PLANS_URL,
|
CONTENT_URL, FILES_DIR, MAX_SIGN_PATHS, PLANS_DIR, PLANS_URL,
|
||||||
)
|
)
|
||||||
from .auth import may_write
|
from .auth import may_write
|
||||||
from .plans import collect_plans
|
from .plans import collect_attachments, collect_plans
|
||||||
from .store import HouseplanData, get_data, get_entry
|
from .store import HouseplanData, get_data, get_entry
|
||||||
from .validation import (
|
from .validation import (
|
||||||
CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_PLAN_BYTES,
|
CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_CONFIG_BYTES, MAX_PLAN_BYTES,
|
||||||
PLAN_EXTENSIONS, POS_SCHEMA, valid_space_id,
|
PLAN_EXTENSIONS, POS_SCHEMA, valid_space_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -74,7 +75,9 @@ async def ws_layout_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
|||||||
if rt is None:
|
if rt is None:
|
||||||
return
|
return
|
||||||
data = await rt.store.async_load() or {}
|
data = await rt.store.async_load() or {}
|
||||||
connection.send_result(msg["id"], {"layout": data.get("layout", {})})
|
connection.send_result(
|
||||||
|
msg["id"], {"layout": data.get("layout", {}), "rev": int(data.get("rev", 0))}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@websocket_api.websocket_command(
|
@websocket_api.websocket_command(
|
||||||
@@ -107,8 +110,10 @@ async def ws_layout_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
|||||||
msg["id"], "conflict", f"Layout changed elsewhere (rev {current_rev})"
|
msg["id"], "conflict", f"Layout changed elsewhere (rev {current_rev})"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
await rt.store.async_save({"layout": msg["layout"], "rev": current_rev + 1})
|
new_rev = current_rev + 1
|
||||||
connection.send_result(msg["id"], {"ok": True, "rev": current_rev + 1})
|
await rt.store.async_save({"layout": msg["layout"], "rev": new_rev})
|
||||||
|
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
|
||||||
|
connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
|
||||||
|
|
||||||
|
|
||||||
@websocket_api.websocket_command(
|
@websocket_api.websocket_command(
|
||||||
@@ -131,8 +136,13 @@ async def ws_layout_update(hass: HomeAssistant, connection, msg: dict[str, Any])
|
|||||||
data = await rt.store.async_load() or {}
|
data = await rt.store.async_load() or {}
|
||||||
layout = data.get("layout", {})
|
layout = data.get("layout", {})
|
||||||
layout[msg["device_id"]] = msg["pos"]
|
layout[msg["device_id"]] = msg["pos"]
|
||||||
await rt.store.async_save({"layout": layout})
|
# keep the revision: a point-wise write used to drop it, which made the
|
||||||
connection.send_result(msg["id"], {"ok": True})
|
# optimistic locking on layout/set meaningless — every drag reset the
|
||||||
|
# counter to 0 (HP-1454-08)
|
||||||
|
new_rev = int(data.get("rev", 0)) + 1
|
||||||
|
await rt.store.async_save({"layout": layout, "rev": new_rev})
|
||||||
|
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
|
||||||
|
connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
|
||||||
|
|
||||||
|
|
||||||
@websocket_api.websocket_command(
|
@websocket_api.websocket_command(
|
||||||
@@ -297,13 +307,17 @@ async def ws_layout_delete(hass: HomeAssistant, connection, msg: dict[str, Any])
|
|||||||
rt = _runtime(hass, connection, msg["id"])
|
rt = _runtime(hass, connection, msg["id"])
|
||||||
if rt is None:
|
if rt is None:
|
||||||
return
|
return
|
||||||
|
new_rev: int | None = None
|
||||||
async with rt.write_lock:
|
async with rt.write_lock:
|
||||||
data = await rt.store.async_load() or {}
|
data = await rt.store.async_load() or {}
|
||||||
layout = data.get("layout", {})
|
layout = data.get("layout", {})
|
||||||
if msg["device_id"] in layout:
|
if msg["device_id"] in layout:
|
||||||
del layout[msg["device_id"]]
|
del layout[msg["device_id"]]
|
||||||
await rt.store.async_save({"layout": layout})
|
new_rev = int(data.get("rev", 0)) + 1
|
||||||
connection.send_result(msg["id"], {"ok": True})
|
await rt.store.async_save({"layout": layout, "rev": new_rev})
|
||||||
|
if new_rev is not None:
|
||||||
|
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
|
||||||
|
connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
|
||||||
|
|
||||||
|
|
||||||
# ---------------- space configuration ----------------
|
# ---------------- space configuration ----------------
|
||||||
@@ -343,6 +357,16 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
|||||||
rt = _runtime(hass, connection, msg["id"])
|
rt = _runtime(hass, connection, msg["id"])
|
||||||
if rt is None:
|
if rt is None:
|
||||||
return
|
return
|
||||||
|
# Per-field limits bound each list; this bounds their product (HP-1454-05).
|
||||||
|
# Everything below the caps can still add up to something no dashboard can
|
||||||
|
# render, and the store writes it to disk on every save.
|
||||||
|
size = len(json.dumps(msg["config"], separators=(",", ":")))
|
||||||
|
if size > MAX_CONFIG_BYTES:
|
||||||
|
connection.send_error(
|
||||||
|
msg["id"], "too_large",
|
||||||
|
f"Configuration is {size // 1024} KB, the limit is {MAX_CONFIG_BYTES // 1024} KB",
|
||||||
|
)
|
||||||
|
return
|
||||||
async with rt.write_lock:
|
async with rt.write_lock:
|
||||||
data = await rt.config_store.async_load() or {}
|
data = await rt.config_store.async_load() or {}
|
||||||
current_rev = data.get("rev", 0)
|
current_rev = data.get("rev", 0)
|
||||||
@@ -369,12 +393,14 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
|||||||
# failure here must not withhold the event and the success response,
|
# failure here must not withhold the event and the success response,
|
||||||
# or the client retries an edit the server has already accepted and
|
# or the client retries an edit the server has already accepted and
|
||||||
# gets a conflict for its trouble (R4-1).
|
# gets a conflict for its trouble (R4-1).
|
||||||
|
def _collect() -> None:
|
||||||
|
collect_plans(Path(hass.config.path(PLANS_DIR)), data.get("config"), msg["config"])
|
||||||
|
collect_attachments(Path(hass.config.path(FILES_DIR)), data.get("config"), msg["config"])
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await hass.async_add_executor_job(
|
await hass.async_add_executor_job(_collect)
|
||||||
collect_plans, Path(hass.config.path(PLANS_DIR)), data.get("config"), msg["config"]
|
|
||||||
)
|
|
||||||
except Exception: # noqa: BLE001 — see above: the commit stands regardless
|
except Exception: # noqa: BLE001 — see above: the commit stands regardless
|
||||||
_LOGGER.exception("House Plan: collecting superseded plan files failed")
|
_LOGGER.exception("House Plan: collecting superseded files failed")
|
||||||
hass.bus.async_fire("houseplan_config_updated", {"rev": new_rev})
|
hass.bus.async_fire("houseplan_config_updated", {"rev": new_rev})
|
||||||
# refresh repair issues (broken plan references) without waiting for a restart
|
# refresh repair issues (broken plan references) without waiting for a restart
|
||||||
entry = get_entry(hass)
|
entry = get_entry(hass)
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
// HP-1454-03: две локальные правки уходили с одной ревизией, вторая терялась.
|
||||||
|
// Debounce разносил только СТАРТЫ. Если первый config/set отвечал дольше 500 мс,
|
||||||
|
// вторая правка уходила с тем же expected_rev, сервер принимал первую и
|
||||||
|
// отклонял вторую как conflict — а обработчик конфликта перечитывал серверную
|
||||||
|
// копию поверх локальной. Правка исчезала, и тост винил «другое окно».
|
||||||
|
import { launch, checkAll, finish } from './serve.mjs';
|
||||||
|
const { page, browser } = await launch();
|
||||||
|
const res = await page.evaluate(async () => {
|
||||||
|
const out = {};
|
||||||
|
const c = window.__card;
|
||||||
|
const base = c.hass.callWS;
|
||||||
|
const writes = [];
|
||||||
|
let rev = 10;
|
||||||
|
let releaseFirst;
|
||||||
|
const firstGate = new Promise((r) => { releaseFirst = r; });
|
||||||
|
|
||||||
|
c.hass = { ...c.hass, callWS: async (m) => {
|
||||||
|
if (m.type === 'houseplan/config/set') {
|
||||||
|
const n = writes.length + 1;
|
||||||
|
writes.push({ expected: m.expected_rev, titles: m.config.spaces.map((s) => s.title) });
|
||||||
|
if (n === 1) await firstGate; // первый ответ задержан
|
||||||
|
if (m.expected_rev !== rev) { const e = new Error('conflict'); e.code = 'conflict'; throw e; }
|
||||||
|
rev += 1;
|
||||||
|
return { ok: true, rev };
|
||||||
|
}
|
||||||
|
if (m.type === 'houseplan/config/get') {
|
||||||
|
const r = await base(m);
|
||||||
|
return { config: JSON.parse(JSON.stringify(r.config)), rev };
|
||||||
|
}
|
||||||
|
return base(m);
|
||||||
|
} };
|
||||||
|
c._cfgRev = rev;
|
||||||
|
|
||||||
|
// правка №1 и, пока первая запись висит, правка №2
|
||||||
|
c._serverCfg.spaces[0].title = 'FIRST';
|
||||||
|
c._saveConfig();
|
||||||
|
c._saveConfigDebounced.flush();
|
||||||
|
await new Promise((r) => setTimeout(r, 30));
|
||||||
|
out.oneInFlight = writes.length === 1;
|
||||||
|
|
||||||
|
c._serverCfg.spaces[0].title = 'SECOND';
|
||||||
|
c._saveConfig();
|
||||||
|
c._saveConfigDebounced.flush();
|
||||||
|
await new Promise((r) => setTimeout(r, 30));
|
||||||
|
out.stillOneInFlight = writes.length === 1; // вторая ждёт очереди, не летит параллельно
|
||||||
|
|
||||||
|
releaseFirst();
|
||||||
|
await new Promise((r) => setTimeout(r, 120));
|
||||||
|
|
||||||
|
out.writes = writes.length;
|
||||||
|
out.revisions = writes.map((w) => w.expected); // вторая обязана взять новую ревизию
|
||||||
|
out.secondCarriedTheEdit = writes[1]?.titles[0] === 'SECOND';
|
||||||
|
out.editSurvived = c._serverCfg.spaces[0].title === 'SECOND';
|
||||||
|
out.noConflictToast = !(c._toast || '').length;
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
// зафиксировано прогоном на v1.46.0 и сверено с кодом
|
||||||
|
checkAll(res, {
|
||||||
|
oneInFlight: true,
|
||||||
|
stillOneInFlight: true,
|
||||||
|
writes: 2,
|
||||||
|
revisions: [10, 11],
|
||||||
|
secondCarriedTheEdit: true,
|
||||||
|
editSurvived: true,
|
||||||
|
noConflictToast: true,
|
||||||
|
});
|
||||||
|
await finish(browser);
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// HP-1454-07: статическая карточка строит модель другой функцией, и room.settings
|
||||||
|
// в неё не переносились — переопределение заливки на уровне комнаты она
|
||||||
|
// игнорировала и красила комнату, которую полная карточка оставляет прозрачной.
|
||||||
|
// Плюс HP-1454-08: layout-события должны доходить до статической карточки без
|
||||||
|
// перезагрузки страницы.
|
||||||
|
import { launch, checkAll, finish } from './serve.mjs';
|
||||||
|
const { page, browser } = await launch({ width: 900, height: 900 }, 1);
|
||||||
|
const res = await page.evaluate(async () => {
|
||||||
|
const out = {};
|
||||||
|
await customElements.whenDefined('houseplan-space-card');
|
||||||
|
const main = window.__card;
|
||||||
|
|
||||||
|
// заливка по свету на пространстве, у первой комнаты — переопределение "none"
|
||||||
|
const cfg = JSON.parse(JSON.stringify(main._serverCfg));
|
||||||
|
const f1 = cfg.spaces.find((s) => s.id === 'f1');
|
||||||
|
f1.settings = { ...(f1.settings || {}), show_borders: true, show_names: true, fill_mode: 'light' };
|
||||||
|
f1.rooms[0].settings = { fill_mode: 'none' };
|
||||||
|
const hass = { ...main.hass, callWS: async (m) => {
|
||||||
|
if (m.type === 'houseplan/config/get') return { config: cfg, rev: 1 };
|
||||||
|
if (m.type === 'houseplan/layout/get') return { layout: {}, rev: 1 };
|
||||||
|
return { ok: true };
|
||||||
|
} };
|
||||||
|
|
||||||
|
main._serverCfg = cfg;
|
||||||
|
main._cfgEpoch++;
|
||||||
|
main.requestUpdate(); await main.updateComplete;
|
||||||
|
|
||||||
|
const host = document.createElement('div');
|
||||||
|
document.body.appendChild(host);
|
||||||
|
const card = document.createElement('houseplan-space-card');
|
||||||
|
card.setConfig({ type: 'custom:houseplan-space-card', space: 'f1' });
|
||||||
|
card.hass = hass;
|
||||||
|
host.appendChild(card);
|
||||||
|
const t0 = Date.now();
|
||||||
|
while (!card.renderRoot?.querySelector('.hp-static-stage') && Date.now() - t0 < 6000) {
|
||||||
|
await new Promise((r) => setTimeout(r, 60));
|
||||||
|
}
|
||||||
|
await card.updateComplete;
|
||||||
|
|
||||||
|
const overridden = (root) => {
|
||||||
|
const rooms = [...root.querySelectorAll('.room')];
|
||||||
|
return rooms.length ? ((rooms[0].getAttribute('style') || '').match(/--room-fill:([^;]+)/) || [])[1] || null : 'missing';
|
||||||
|
};
|
||||||
|
|
||||||
|
out.fullCardRoom0 = overridden(main.shadowRoot || main.renderRoot);
|
||||||
|
out.staticCardRoom0 = overridden(card.renderRoot);
|
||||||
|
out.parity = out.fullCardRoom0 === out.staticCardRoom0;
|
||||||
|
out.overrideRespected = out.staticCardRoom0 === 'transparent';
|
||||||
|
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
// зафиксировано прогоном на v1.46.0 и сверено с кодом
|
||||||
|
checkAll(res, {
|
||||||
|
fullCardRoom0: 'transparent',
|
||||||
|
staticCardRoom0: 'transparent',
|
||||||
|
parity: true,
|
||||||
|
overrideRespected: true,
|
||||||
|
});
|
||||||
|
await finish(browser);
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
// HP-1454-01: загруженный SVG — пользовательский контент, который Home Assistant
|
||||||
|
// отдаёт со своего origin. Внутри карточки он подключён через <image>, где
|
||||||
|
// скрипты не выполняются, но тот же URL, открытый как отдельный документ,
|
||||||
|
// становится живым документом этого origin: <script> в нём получает доступ к
|
||||||
|
// localStorage сессии и к API. Проверяем, что заголовок sandbox это снимает,
|
||||||
|
// и что обычный SVG при этом продолжает отображаться.
|
||||||
|
import { chromium } from 'playwright';
|
||||||
|
import { check, finish } from './serve.mjs';
|
||||||
|
|
||||||
|
const EVIL = `<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
|
||||||
|
<rect width="100" height="100" fill="#eee"/>
|
||||||
|
<script>
|
||||||
|
document.title = 'HOUSEPLAN_XSS_EXECUTED';
|
||||||
|
try { localStorage.setItem('hp_xss', 'executed'); } catch (e) {}
|
||||||
|
</script>
|
||||||
|
</svg>`;
|
||||||
|
|
||||||
|
// ровно тот набор, который отдаёт HouseplanContentView для .svg
|
||||||
|
const CSP = "sandbox; default-src 'none'; script-src 'none'; object-src 'none'; "
|
||||||
|
+ "base-uri 'none'; form-action 'none'; style-src 'unsafe-inline'; img-src data:";
|
||||||
|
|
||||||
|
const browser = await chromium.launch({ args: ['--no-sandbox'] });
|
||||||
|
const ctx = await browser.newContext();
|
||||||
|
|
||||||
|
async function serve(page, { csp }) {
|
||||||
|
await page.route('**/*', (route) => {
|
||||||
|
const url = route.request().url();
|
||||||
|
if (url.endsWith('/evil.svg')) {
|
||||||
|
const headers = { 'Content-Type': 'image/svg+xml', 'X-Content-Type-Options': 'nosniff' };
|
||||||
|
if (csp) headers['Content-Security-Policy'] = CSP;
|
||||||
|
return route.fulfill({ status: 200, headers, body: EVIL });
|
||||||
|
}
|
||||||
|
return route.fulfill({ status: 200, contentType: 'text/html', body: '<html><body>host</body></html>' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) как было до фикса: скрипт исполняется в origin Home Assistant
|
||||||
|
const before = await ctx.newPage();
|
||||||
|
await serve(before, { csp: false });
|
||||||
|
await before.goto('https://ha.example/api/houseplan/content/plans/_/evil.svg');
|
||||||
|
await before.waitForTimeout(200);
|
||||||
|
const noCsp = await before.evaluate(() => ({
|
||||||
|
title: document.title,
|
||||||
|
storage: (() => { try { return localStorage.getItem('hp_xss'); } catch (e) { return 'blocked'; } })(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 2) с заголовком: opaque origin, скрипт не выполняется, storage недоступен
|
||||||
|
const after = await ctx.newPage();
|
||||||
|
await serve(after, { csp: true });
|
||||||
|
await after.goto('https://ha.example/api/houseplan/content/plans/_/evil.svg');
|
||||||
|
await after.waitForTimeout(200);
|
||||||
|
const withCsp = await after.evaluate(() => ({
|
||||||
|
title: document.title,
|
||||||
|
storage: (() => { try { return localStorage.getItem('hp_xss'); } catch (e) { return 'blocked'; } })(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 3) тот же файл как <image> внутри страницы — рисуется и без скрипта
|
||||||
|
const card = await ctx.newPage();
|
||||||
|
await serve(card, { csp: true });
|
||||||
|
await card.goto('https://ha.example/');
|
||||||
|
const drawn = await card.evaluate(async () => {
|
||||||
|
const img = new Image();
|
||||||
|
const ok = await new Promise((res) => {
|
||||||
|
img.onload = () => res(true);
|
||||||
|
img.onerror = () => res(false);
|
||||||
|
img.src = '/api/houseplan/content/plans/_/evil.svg';
|
||||||
|
});
|
||||||
|
return { loaded: ok, width: img.naturalWidth, title: document.title };
|
||||||
|
});
|
||||||
|
|
||||||
|
check('без CSP скрипт выполняется (иначе тест ничего не доказывает)', noCsp.title, 'HOUSEPLAN_XSS_EXECUTED');
|
||||||
|
check('без CSP скрипт пишет в storage origin', noCsp.storage, 'executed');
|
||||||
|
check('с CSP скрипт не выполняется', withCsp.title !== 'HOUSEPLAN_XSS_EXECUTED', true);
|
||||||
|
check('с CSP storage origin недоступен', withCsp.storage !== 'executed', true);
|
||||||
|
check('SVG по-прежнему грузится как картинка', drawn.loaded, true);
|
||||||
|
check('и имеет размеры', drawn.width, 100);
|
||||||
|
check('картинка ничего не выполнила на странице-хосте', drawn.title, '');
|
||||||
|
await finish(browser);
|
||||||
File diff suppressed because one or more lines are too long
Vendored
+16
-16
File diff suppressed because one or more lines are too long
+27
-3
@@ -172,14 +172,38 @@ double click → properties dialog. In markup mode the "Opening" tool handles cl
|
|||||||
|
|
||||||
| Command | Parameters | Response |
|
| Command | Parameters | Response |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `houseplan/layout/get` | — | `{layout: {device_id: {x,y}}}` |
|
| `houseplan/layout/get` | — | `{layout: {device_id: {x,y}}, rev}` |
|
||||||
| `houseplan/layout/set` | `layout` | `{ok}` (admin_only optional) |
|
| `houseplan/layout/set` | `layout`, `expected_rev?` | `{ok, rev}` / err `conflict`; event `houseplan_layout_updated` |
|
||||||
| `houseplan/layout/update` | `device_id`, `pos` | `{ok}` |
|
| `houseplan/layout/update` | `device_id`, `pos` | `{ok, rev}`; event `houseplan_layout_updated` |
|
||||||
| `houseplan/config/get` | — | `{config, rev}` |
|
| `houseplan/config/get` | — | `{config, rev}` |
|
||||||
| `houseplan/config/set` | `config`, `expected_rev?` | `{ok, rev}` / err `conflict`; event `houseplan_config_updated` |
|
| `houseplan/config/set` | `config`, `expected_rev?` | `{ok, rev}` / err `conflict`; event `houseplan_config_updated` |
|
||||||
| `houseplan/plan/set` | `space_id`, `ext` (svg/png/jpg/webp), `data` (b64, ≤8 MB) | `{ok, url}` — writes `<space>.<token>.<ext>`, deletes nothing |
|
| `houseplan/plan/set` | `space_id`, `ext` (svg/png/jpg/webp), `data` (b64, ≤8 MB) | `{ok, url}` — writes `<space>.<token>.<ext>`, deletes nothing |
|
||||||
| `houseplan/file/set` | `marker_id`, `filename`, `data` (b64) | `{ok,url,name}` (legacy, WS limit) |
|
| `houseplan/file/set` | `marker_id`, `filename`, `data` (b64) | `{ok,url,name}` (legacy, WS limit) |
|
||||||
|
|
||||||
|
**User content is served inert** (HP-1454-01). An uploaded SVG is the only
|
||||||
|
thing here that a browser will happily treat as a *document* rather than an
|
||||||
|
image, and it would be a document of Home Assistant's own origin. Inside the
|
||||||
|
card that never matters — `<image>` does not run scripts — but the url is
|
||||||
|
reachable directly, and uploading needs only write access, which by default
|
||||||
|
every user has. `HouseplanContentView` therefore sends a `sandbox` CSP with SVG
|
||||||
|
and only with SVG: a CSP on a PDF response can break the browser's built-in
|
||||||
|
viewer, and a raster image has no execution model to disable.
|
||||||
|
|
||||||
|
**Attachments follow the same commit-scoped lifecycle as plans** (HP-1454-02).
|
||||||
|
An upload takes a free name (`unique_filename`) and never overwrites, because
|
||||||
|
the bytes under an existing name may be referenced by the stored configuration
|
||||||
|
and an upload is not part of that transaction. A new icon has no id yet, so its
|
||||||
|
files go to a per-dialog staging folder and move to the real id once the config
|
||||||
|
write is accepted — the same copy → save → cleanup order as a rebind.
|
||||||
|
`config/set` collects what its commit superseded, and anything unreferenced and
|
||||||
|
older than `PLAN_ORPHAN_TTL_S`.
|
||||||
|
|
||||||
|
**Config writes are serialized** (HP-1454-03). `_writeConfig()` chains onto a
|
||||||
|
single promise: one `config/set` in flight, each carrying the revision the
|
||||||
|
previous one returned. The debounce still spaces out *when* a write starts;
|
||||||
|
what it cannot do — and used to be relied on for — is keep two writes from
|
||||||
|
overlapping, which produced a self-inflicted conflict and lost the newer edit.
|
||||||
|
|
||||||
**Plan uploads are copy-on-write, and collection belongs to the commit**
|
**Plan uploads are copy-on-write, and collection belongs to the commit**
|
||||||
(reviews R2-1, R3-1). The file system is not part of the config's
|
(reviews R2-1, R3-1). The file system is not part of the config's
|
||||||
optimistic-locking transaction, so nothing referenced may be overwritten or
|
optimistic-locking transaction, so nothing referenced may be overwritten or
|
||||||
|
|||||||
@@ -1,5 +1,79 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v1.46.0 — 2026-07-28 (full external audit of v1.45.4: HP-1454-01 … -10)
|
||||||
|
|
||||||
|
**Security**
|
||||||
|
|
||||||
|
- **An uploaded SVG plan is no longer a live document of your Home Assistant
|
||||||
|
origin (HP-1454-01, high — release blocker).** Inside the card a plan is
|
||||||
|
referenced by `<image>`, where scripts never run; but the same url opened
|
||||||
|
directly became a top-level document of HA's own origin, and a `<script>` in
|
||||||
|
it could read the session's `localStorage` and call the API. Uploading needs
|
||||||
|
write access, which by default every authenticated user has, and a signed url
|
||||||
|
is easy to hand to an administrator. Plan responses for SVG now carry a
|
||||||
|
`sandbox` Content-Security-Policy, which drops the document into an opaque
|
||||||
|
origin. Only SVG gets it — a CSP on a PDF can break the browser's built-in
|
||||||
|
viewer, and a raster image cannot execute anything. Nothing changes for
|
||||||
|
existing plans: the card renders them exactly as before.
|
||||||
|
|
||||||
|
**Data integrity**
|
||||||
|
|
||||||
|
- **A manual attached to a device no longer overwrites the previous one
|
||||||
|
(HP-1454-02).** The upload wrote straight to `<marker>/<filename>`, outside
|
||||||
|
the configuration transaction: cancelling the dialog, or a rejected save, left
|
||||||
|
the stored url serving the new bytes. And every new icon uploaded into one
|
||||||
|
shared folder, so two of them attaching `manual.pdf` ended up pointing at the
|
||||||
|
same physical file. Uploads now take a free name and never overwrite, a new
|
||||||
|
icon gets its own staging folder whose files move to the real icon when the
|
||||||
|
save is accepted, and an upload nobody saved is collected an hour later.
|
||||||
|
- **Two quick edits can no longer lose the second one (HP-1454-03).** The
|
||||||
|
debounce spaced out the starts of a save, not the saves themselves. If one
|
||||||
|
took longer than half a second — a busy instance, a slow link — the next edit
|
||||||
|
went out with the same revision, the server accepted the first and rejected
|
||||||
|
the second, and the conflict handler reloaded the server copy over the local
|
||||||
|
one. The edit was gone, with a message blaming another window when there was
|
||||||
|
none. Writes are now serialized: one at a time, each carrying the revision the
|
||||||
|
previous one returned.
|
||||||
|
|
||||||
|
**Correctness and limits**
|
||||||
|
|
||||||
|
- **Open boundaries follow the geometry again (HP-1454-04).** Their cache was
|
||||||
|
keyed on room ids and links only, so changing a space's aspect ratio or
|
||||||
|
dragging a vertex left the open boundaries — and the light spilling through
|
||||||
|
them — at their old coordinates until a reload. The cache is now keyed on the
|
||||||
|
rendered model itself, which is exactly what it is computed from.
|
||||||
|
- **The configuration can no longer be made arbitrarily heavy (HP-1454-05).**
|
||||||
|
The outer collections were capped; the ones inside them were not. A polygon
|
||||||
|
with 150 000 points, or a list of 100 000 device ids, validated fine and then
|
||||||
|
made every render walk it. There are now limits on polygon vertices, open-to
|
||||||
|
links, controls, attachments, text and url lengths, plus a cap on the whole
|
||||||
|
serialized configuration. The obsolete `segments` field is dropped by the
|
||||||
|
server instead of trusting the card to strip it.
|
||||||
|
- **Large files stream instead of being held in memory (HP-1454-06).** A 50 MB
|
||||||
|
manual was read whole into memory on the way in and again on the way out; a
|
||||||
|
couple of parallel downloads were real pressure on a small Home Assistant
|
||||||
|
host. Uploads stream to a temporary file, downloads stream from disk.
|
||||||
|
|
||||||
|
**Consistency**
|
||||||
|
|
||||||
|
- **The static space card honours per-room fill settings (HP-1454-07).** It
|
||||||
|
builds its model with a different function, and room settings were not carried
|
||||||
|
into it, so a room you had set to "no fill" was still painted.
|
||||||
|
- **Moving an icon updates the static card immediately (HP-1454-08).** Layout is
|
||||||
|
separate state with no revision and no event: a drag on the full card left a
|
||||||
|
static card next to it showing the old position until the configuration
|
||||||
|
changed or the page was reloaded. Layout writes now keep a revision, return
|
||||||
|
it, and announce themselves — which also makes the optimistic locking on a
|
||||||
|
wholesale layout write mean something, since a point-wise write used to reset
|
||||||
|
the counter.
|
||||||
|
- **A repair warning about a missing plan disappears with its space
|
||||||
|
(HP-1454-09).** The cleanup only looked at spaces that still exist, so
|
||||||
|
deleting or renaming one left its warning in Repairs with nothing able to
|
||||||
|
clear it.
|
||||||
|
- Build chain: `serialize-javascript` pinned past two advisories (HP-1454-10).
|
||||||
|
Not reachable at runtime and production dependencies were already clean, but
|
||||||
|
it is one line.
|
||||||
|
|
||||||
## v1.45.4 — 2026-07-28 (review of v1.45.3: R5-1, R5-2)
|
## v1.45.4 — 2026-07-28 (review of v1.45.3: R5-1, R5-2)
|
||||||
- **A partly successful signing answer no longer skips the backoff (R5-1).**
|
- **A partly successful signing answer no longer skips the backoff (R5-1).**
|
||||||
The backend signs each path independently: one it cannot sign is logged,
|
The backend signs each path independently: one it cannot sign is logged,
|
||||||
|
|||||||
@@ -6,6 +6,81 @@
|
|||||||
> **Правило проекта:** оба файла пополняются в одном коммите с самим
|
> **Правило проекта:** оба файла пополняются в одном коммите с самим
|
||||||
> изменением — как и остальная документация (см. docs/STATUS.md).
|
> изменением — как и остальная документация (см. docs/STATUS.md).
|
||||||
|
|
||||||
|
## v1.46.0 — 2026-07-28 (полный внешний аудит v1.45.4: HP-1454-01 … -10)
|
||||||
|
|
||||||
|
**Безопасность**
|
||||||
|
|
||||||
|
- **Загруженный SVG-план больше не является живым документом origin вашего
|
||||||
|
Home Assistant (HP-1454-01, high — блокер релиза).** Внутри карточки план
|
||||||
|
подключён через `<image>`, где скрипты не выполняются; но тот же URL,
|
||||||
|
открытый напрямую, становился документом верхнего уровня в origin самого HA,
|
||||||
|
и `<script>` в нём мог читать `localStorage` сессии и обращаться к API. Для
|
||||||
|
загрузки нужно право записи, а оно по умолчанию есть у каждого
|
||||||
|
аутентифицированного пользователя, и подписанную ссылку несложно передать
|
||||||
|
администратору. Ответы с SVG теперь несут заголовок Content-Security-Policy
|
||||||
|
`sandbox`, который помещает документ в opaque origin. Только SVG — CSP на PDF
|
||||||
|
способен сломать встроенный просмотрщик браузера, а растровая картинка ничего
|
||||||
|
выполнить не может. Для существующих планов ничего не меняется: карточка
|
||||||
|
рисует их ровно как раньше.
|
||||||
|
|
||||||
|
**Целостность данных**
|
||||||
|
|
||||||
|
- **Инструкция, приложенная к устройству, больше не затирает предыдущую
|
||||||
|
(HP-1454-02).** Загрузка писала прямо в `<маркер>/<имя файла>`, вне
|
||||||
|
транзакции конфигурации: отмена диалога или отвергнутое сохранение оставляли
|
||||||
|
сохранённую ссылку указывающей на новые байты. А все новые иконки грузили в
|
||||||
|
одну общую папку, поэтому две с файлом `manual.pdf` начинали ссылаться на
|
||||||
|
один физический файл. Теперь загрузка занимает свободное имя и никогда не
|
||||||
|
перезаписывает, новая иконка получает собственную промежуточную папку, файлы
|
||||||
|
из которой переезжают к настоящей иконке при принятом сохранении, а загрузка,
|
||||||
|
которую никто не сохранил, убирается через час.
|
||||||
|
- **Две быстрые правки больше не теряют вторую (HP-1454-03).** Debounce
|
||||||
|
разносил старты сохранения, а не сами сохранения. Если одно длилось дольше
|
||||||
|
полусекунды — занятый сервер, медленная связь, — следующая правка уходила с
|
||||||
|
той же ревизией, сервер принимал первую и отклонял вторую, а обработчик
|
||||||
|
конфликта перечитывал серверную копию поверх локальной. Правка исчезала, и
|
||||||
|
сообщение винило «другое окно», которого не было. Записи сериализованы: по
|
||||||
|
одной за раз, каждая с ревизией, которую вернула предыдущая.
|
||||||
|
|
||||||
|
**Корректность и пределы**
|
||||||
|
|
||||||
|
- **Открытые границы снова следуют за геометрией (HP-1454-04).** Их кэш
|
||||||
|
ключевался только по id комнат и связям, поэтому смена пропорций
|
||||||
|
пространства или перетаскивание вершины оставляли открытые границы — и свет,
|
||||||
|
который через них проходит, — в старых координатах до перезагрузки. Ключом
|
||||||
|
стала сама отрисованная модель, из которой они и вычисляются.
|
||||||
|
- **Конфигурацию больше нельзя сделать сколь угодно тяжёлой (HP-1454-05).**
|
||||||
|
Внешние коллекции были ограничены, вложенные — нет. Полигон на 150 000 точек
|
||||||
|
или список из 100 000 идентификаторов проходили валидацию, а потом каждый
|
||||||
|
рендер по ним ходил. Появились пределы на вершины полигона, связи открытых
|
||||||
|
границ, управляемые сущности, вложения, длину текста и ссылок, плюс общий
|
||||||
|
предел размера сериализованной конфигурации. Устаревшее поле `segments`
|
||||||
|
отбрасывает сервер, а не надежда на современный клиент.
|
||||||
|
- **Большие файлы передаются потоком, а не через память (HP-1454-06).**
|
||||||
|
Инструкция на 50 МБ целиком читалась в память на приёме и ещё раз на отдаче;
|
||||||
|
пара параллельных скачиваний — заметная нагрузка на слабый хост Home
|
||||||
|
Assistant. Загрузка пишется во временный файл потоком, отдача идёт с диска.
|
||||||
|
|
||||||
|
**Согласованность**
|
||||||
|
|
||||||
|
- **Статическая карточка пространства учитывает настройки заливки комнаты
|
||||||
|
(HP-1454-07).** Она строит модель другой функцией, и настройки комнаты в неё
|
||||||
|
не переносились — комната, которой вы выключили заливку, всё равно
|
||||||
|
закрашивалась.
|
||||||
|
- **Перемещение иконки сразу видно на статической карточке (HP-1454-08).**
|
||||||
|
Layout — отдельное состояние без ревизии и без события: перетаскивание на
|
||||||
|
полной карточке оставляло соседнюю статическую со старой позицией до
|
||||||
|
изменения конфигурации или перезагрузки страницы. Теперь записи layout хранят
|
||||||
|
ревизию, возвращают её и сообщают о себе — заодно оптимистическая блокировка
|
||||||
|
на полной записи layout начала что-то значить, ведь точечная запись раньше
|
||||||
|
сбрасывала счётчик.
|
||||||
|
- **Предупреждение о пропавшем плане исчезает вместе с пространством
|
||||||
|
(HP-1454-09).** Уборка смотрела только на существующие пространства, поэтому
|
||||||
|
удаление или переименование оставляло предупреждение в «Ремонте» навсегда.
|
||||||
|
- Сборка: `serialize-javascript` поднят выше двух advisory (HP-1454-10). В
|
||||||
|
рантайме недостижим, production-зависимости и так были чисты, но это одна
|
||||||
|
строка.
|
||||||
|
|
||||||
## v1.45.4 — 2026-07-28 (ревью v1.45.3: R5-1, R5-2)
|
## v1.45.4 — 2026-07-28 (ревью v1.45.3: R5-1, R5-2)
|
||||||
- **Частично успешный ответ на подпись больше не пропускает выдержку (R5-1).**
|
- **Частично успешный ответ на подпись больше не пропускает выдержку (R5-1).**
|
||||||
Бэкенд подписывает каждый путь независимо: тот, что подписать не удалось,
|
Бэкенд подписывает каждый путь независимо: тот, что подписать не удалось,
|
||||||
|
|||||||
+2
-2
@@ -15,12 +15,12 @@
|
|||||||
|
|
||||||
| Item | State |
|
| Item | State |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Version | **v1.45.4** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
|
| Version | **v1.46.0** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
|
||||||
| Workflow | Since 2026-07-22: minor changes go to branch **`dev`** (build + smokes → deploy home → commit → push, NO release); releases are batched on the owner's command (merge dev→main, one tag, one release with a summary changelog, CI checked on dev beforehand) |
|
| Workflow | Since 2026-07-22: minor changes go to branch **`dev`** (build + smokes → deploy home → commit → push, NO release); releases are batched on the owner's command (merge dev→main, one tag, one release with a summary changelog, CI checked on dev beforehand) |
|
||||||
| GitHub | https://github.com/Matysh/houseplan-card — **`main` carries every published release, the latest tag is the current version above**; `dev` is where work lands and is merged into `main` at release time (so `dev` is normally equal to or ahead of `main`, never behind). Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
|
| GitHub | https://github.com/Matysh/houseplan-card — **`main` carries every published release, the latest tag is the current version above**; `dev` is where work lands and is merged into `main` at release time (so `dev` is normally equal to or ahead of `main`, never behind). Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
|
||||||
| CI | validate.yml (hacs + hassfest + frontend + backend) green; release.yml attaches the bundle on release publish |
|
| CI | validate.yml (hacs + hassfest + frontend + backend) green; release.yml attaches the bundle on release publish |
|
||||||
| HACS | Custom repository works. **Inclusion PR: hacs/default#9004** — open, valid, labeled; ~864 older open PRs but merge rate ≈180/mo; realistic ETA 1–3 months (checked 2026-07-24) |
|
| HACS | Custom repository works. **Inclusion PR: hacs/default#9004** — open, valid, labeled; ~864 older open PRs but merge rate ≈180/mo; realistic ETA 1–3 months (checked 2026-07-24) |
|
||||||
| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed **v1.45.4** via direct copy (HACS custom repo also installed) |
|
| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed **v1.46.0** via direct copy (HACS custom repo also installed) |
|
||||||
| Localization | UI en/ru (src/i18n/*.json), everything user-visible localized incl. kiosk popover |
|
| Localization | UI en/ru (src/i18n/*.json), everything user-visible localized incl. kiosk popover |
|
||||||
| Tests | Four layers: frontend unit (`npm test`, node:test over `test-build/`), pure backend (`pytest tests_backend`, runs anywhere), HA-harness backend (same folder, CI only — needs py3.13 + pytest-homeassistant-custom-component), and browser smokes (`demo/smoke_*.mjs`, headless chromium). **Counts are not written down here** — they went stale within two releases while the version line beside them was kept current, which reads as less coverage than exists (review R5-2). Run `npm run inventory` for the current numbers, or read them off the last CI run |
|
| Tests | Four layers: frontend unit (`npm test`, node:test over `test-build/`), pure backend (`pytest tests_backend`, runs anywhere), HA-harness backend (same folder, CI only — needs py3.13 + pytest-homeassistant-custom-component), and browser smokes (`demo/smoke_*.mjs`, headless chromium). **Counts are not written down here** — they went stale within two releases while the version line beside them was kept current, which reads as less coverage than exists (review R5-2). Run `npm run inventory` for the current numbers, or read them off the last CI run |
|
||||||
| Community | **Telegram chat: https://t.me/ha_houseplan** (created 2026-07-27) — the primary user-facing support channel; GitHub issues stay for bugs/features. Link it from any new release notes and posts |
|
| Community | **Telegram chat: https://t.me/ha_houseplan** (created 2026-07-27) — the primary user-facing support channel; GitHub issues stay for bugs/features. Link it from any new release notes and posts |
|
||||||
|
|||||||
@@ -239,6 +239,39 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
|||||||
on the plan after a reload. Same for each tap action and each fill mode
|
on the plan after a reload. Same for each tap action and each fill mode
|
||||||
[auto: backend test_every_display_mode_the_editor_offers_is_accepted and
|
[auto: backend test_every_display_mode_the_editor_offers_is_accepted and
|
||||||
neighbours, test_a_marker_showing_its_value_can_be_saved]
|
neighbours, test_a_marker_showing_its_value_can_be_saved]
|
||||||
|
- [ ] Uploaded SVG is inert as a document (v1.46.0, HP-1454-01): open a plan's
|
||||||
|
signed url directly in a tab — a `<script>` inside it must not run and must
|
||||||
|
not reach the HA session's localStorage; the same plan still renders in the
|
||||||
|
card. PDFs still open in the browser viewer
|
||||||
|
[auto: smoke_svg_sandbox + backend test_uploaded_svg_is_sandboxed_and_a_pdf_is_not]
|
||||||
|
- [ ] An attachment never overwrites another (v1.46.0, HP-1454-02): attach a file,
|
||||||
|
cancel the dialog — the previously stored file is byte-identical. Attach
|
||||||
|
`manual.pdf` to two NEW icons — two independent files. A cancelled upload is
|
||||||
|
gone an hour later
|
||||||
|
[auto: backend test_upload_never_overwrites_an_existing_attachment + unit: collect_attachments]
|
||||||
|
- [ ] Two quick edits both survive (v1.46.0, HP-1454-03): with a slow connection,
|
||||||
|
make an edit and another one before the first save answers — both are in the
|
||||||
|
stored config, only one write is ever in flight, and no conflict toast fires
|
||||||
|
[auto: smoke_config_writer]
|
||||||
|
- [ ] Open boundaries follow geometry (v1.46.0, HP-1454-04): change a space's
|
||||||
|
aspect or drag a room vertex — the open boundary and the light through it
|
||||||
|
move with the walls, without a reload [auto: smoke via model-identity key]
|
||||||
|
- [ ] Inner limits (v1.46.0, HP-1454-05): max and max+1 for polygon points,
|
||||||
|
open_to, controls, pdfs, text and url lengths; an oversized config as a
|
||||||
|
whole is refused with `too_large`
|
||||||
|
[auto: unit: test_inner_collection_limits + backend test_config_write_is_capped_by_total_size]
|
||||||
|
- [ ] Big files stream (v1.46.0, HP-1454-06): upload a ~50 MB manual and download
|
||||||
|
it twice in parallel — HA's memory does not grow by a file per transfer
|
||||||
|
[manual]
|
||||||
|
- [ ] Static card parity (v1.46.0, HP-1454-07): a room whose fill is set to "none"
|
||||||
|
under a space filled by light is transparent on BOTH cards
|
||||||
|
[auto: smoke_render_parity]
|
||||||
|
- [ ] Layout reaches the static card (v1.46.0, HP-1454-08): drag an icon on the
|
||||||
|
full card — a static card on the same dashboard moves it too, with no
|
||||||
|
config write and no reload
|
||||||
|
[auto: backend test_layout_keeps_its_revision_and_announces_changes + manual]
|
||||||
|
- [ ] Repair issues are not immortal (v1.46.0, HP-1454-09): create a missing-plan
|
||||||
|
warning, then delete the space — the warning disappears [manual]
|
||||||
- [ ] A path the backend cannot sign does not become a request loop (v1.45.4,
|
- [ ] A path the backend cannot sign does not become a request loop (v1.45.4,
|
||||||
review R5-1): when `content/sign` answers successfully but omits a path,
|
review R5-1): when `content/sign` answers successfully but omits a path,
|
||||||
the card backs that path off individually and keeps the urls it did get;
|
the card backs that path off individually and keeps the urls it did get;
|
||||||
|
|||||||
Generated
+7
-38
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "houseplan-card",
|
"name": "houseplan-card",
|
||||||
"version": "1.41.2",
|
"version": "1.45.4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "houseplan-card",
|
"name": "houseplan-card",
|
||||||
"version": "1.41.2",
|
"version": "1.45.4",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lit": "^3.1.3",
|
"lit": "^3.1.3",
|
||||||
@@ -817,16 +817,6 @@
|
|||||||
"splaytree-ts": "^1.0.2"
|
"splaytree-ts": "^1.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/randombytes": {
|
|
||||||
"version": "2.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
|
|
||||||
"integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"safe-buffer": "^5.1.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/resolve": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.12",
|
"version": "1.22.12",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
||||||
@@ -894,35 +884,14 @@
|
|||||||
"fsevents": "~2.3.2"
|
"fsevents": "~2.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/safe-buffer": {
|
|
||||||
"version": "5.2.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
|
||||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/feross"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "patreon",
|
|
||||||
"url": "https://www.patreon.com/feross"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "consulting",
|
|
||||||
"url": "https://feross.org/support"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/serialize-javascript": {
|
"node_modules/serialize-javascript": {
|
||||||
"version": "6.0.2",
|
"version": "7.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.7.tgz",
|
||||||
"integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==",
|
"integrity": "sha512-YAy8Od6KV+uuwUuU50np8fGB/Aues6Y0nAhA9y/hId74PlKUcme4pXcBD46NWKr1Q4osN/iseZ17YqO1XfmI8g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"dependencies": {
|
"engines": {
|
||||||
"randombytes": "^2.1.0"
|
"node": ">=20.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/smob": {
|
"node_modules/smob": {
|
||||||
|
|||||||
+4
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "houseplan-card",
|
"name": "houseplan-card",
|
||||||
"version": "1.45.4",
|
"version": "1.46.0",
|
||||||
"description": "Interactive house plan Lovelace card for Home Assistant",
|
"description": "Interactive house plan Lovelace card for Home Assistant",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -25,5 +25,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lit": "^3.1.3",
|
"lit": "^3.1.3",
|
||||||
"polyclip-ts": "^0.16.8"
|
"polyclip-ts": "^0.16.8"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"serialize-javascript": "^7.0.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-4
@@ -47,11 +47,17 @@ async function fetchFresh(hass: any): Promise<HpConfigSnapshot> {
|
|||||||
};
|
};
|
||||||
if (!subscribed && hass.connection?.subscribeEvents) {
|
if (!subscribed && hass.connection?.subscribeEvents) {
|
||||||
subscribed = true;
|
subscribed = true;
|
||||||
|
const invalidate = () => {
|
||||||
|
cache = null; // invalidate; listeners reload
|
||||||
|
listeners.forEach((l) => l());
|
||||||
|
};
|
||||||
try {
|
try {
|
||||||
await hass.connection.subscribeEvents(() => {
|
await hass.connection.subscribeEvents(invalidate, 'houseplan_config_updated');
|
||||||
cache = null; // invalidate; listeners reload
|
// Layout is separate state: dragging an icon on the full card writes only
|
||||||
listeners.forEach((l) => l());
|
// the layout, so a static card on the same dashboard kept showing the old
|
||||||
}, 'houseplan_config_updated');
|
// position until the config changed or the page was reloaded — possibly
|
||||||
|
// forever on a wall tablet (HP-1454-08).
|
||||||
|
await hass.connection.subscribeEvents(invalidate, 'houseplan_layout_updated');
|
||||||
} catch {
|
} catch {
|
||||||
subscribed = false;
|
subscribed = false;
|
||||||
}
|
}
|
||||||
|
|||||||
+94
-53
@@ -35,7 +35,7 @@ import './space-card';
|
|||||||
import { cardStyles } from './styles';
|
import { cardStyles } from './styles';
|
||||||
import { langOf, t, type I18nKey } from './i18n';
|
import { langOf, t, type I18nKey } from './i18n';
|
||||||
|
|
||||||
const CARD_VERSION = '1.45.4';
|
const CARD_VERSION = '1.46.0';
|
||||||
const LS_KEY = 'houseplan_card_layout_v1';
|
const LS_KEY = 'houseplan_card_layout_v1';
|
||||||
const LS_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
|
const LS_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
|
||||||
const LS_ZOOM = 'houseplan_card_zoom_v1';
|
const LS_ZOOM = 'houseplan_card_zoom_v1';
|
||||||
@@ -234,6 +234,15 @@ class HouseplanCard extends LitElement {
|
|||||||
private _infoCard: DevItem | null = null;
|
private _infoCard: DevItem | null = null;
|
||||||
private _markerDialog: {
|
private _markerDialog: {
|
||||||
devId?: string; // the icon being edited (if any)
|
devId?: string; // the icon being edited (if any)
|
||||||
|
/**
|
||||||
|
* Folder attachments are uploaded into while this dialog is open. For a NEW
|
||||||
|
* icon there is no marker id yet; every one of them used to upload into a
|
||||||
|
* shared `files/new/`, so two markers attaching `manual.pdf` ended up
|
||||||
|
* pointing at the same bytes (HP-1454-02). A per-dialog id keeps them
|
||||||
|
* apart, and the files are moved to the real marker id once the config
|
||||||
|
* write is accepted — the same copy→save→cleanup order as a rebind.
|
||||||
|
*/
|
||||||
|
uploadId?: string;
|
||||||
name: string;
|
name: string;
|
||||||
binding: string; // 'device:<id>' | 'entity:<eid>' | 'virtual' | '' (not chosen yet)
|
binding: string; // 'device:<id>' | 'entity:<eid>' | 'virtual' | '' (not chosen yet)
|
||||||
bindingMode: 'virtual' | 'ha';
|
bindingMode: 'virtual' | 'ha';
|
||||||
@@ -556,9 +565,17 @@ class HouseplanCard extends LitElement {
|
|||||||
for (const x of sp as any[]) {
|
for (const x of sp as any[]) {
|
||||||
s += (x.id || '') + ',' + (x.aspect || '') + ',' + (x.plan_url || '').length + ','
|
s += (x.id || '') + ',' + (x.aspect || '') + ',' + (x.plan_url || '').length + ','
|
||||||
+ (x.rooms?.length || 0) + ',' + (x.openings?.length || 0) + ',' + (x.decor?.length || 0) + ';';
|
+ (x.rooms?.length || 0) + ',' + (x.openings?.length || 0) + ',' + (x.decor?.length || 0) + ';';
|
||||||
for (const r of x.rooms || [])
|
for (const r of x.rooms || []) {
|
||||||
|
// O(1) geometry roll-up per room: the count alone said nothing about
|
||||||
|
// where the room actually is, so a moved rectangle or a dragged first/
|
||||||
|
// last vertex looked identical (HP-1454-04). The epoch remains the
|
||||||
|
// primary signal — this is the belt for a mutation that forgot to bump it.
|
||||||
|
const p0 = r.poly?.[0], pn = r.poly?.[r.poly.length - 1];
|
||||||
s += (r.poly?.length || 0) + '.' + (r.id || '') + '.' + (r.open_to || []).join('+') + '.'
|
s += (r.poly?.length || 0) + '.' + (r.id || '') + '.' + (r.open_to || []).join('+') + '.'
|
||||||
+ (r.area || '') + '.' + JSON.stringify(r.settings || 0) + ';';
|
+ (r.area || '') + '.' + JSON.stringify(r.settings || 0) + '.'
|
||||||
|
+ (r.x ?? '') + ',' + (r.y ?? '') + ',' + (r.w ?? '') + ',' + (r.h ?? '') + ','
|
||||||
|
+ (p0 ? p0[0] + '/' + p0[1] : '') + ',' + (pn ? pn[0] + '/' + pn[1] : '') + ';';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
@@ -1530,8 +1547,44 @@ class HouseplanCard extends LitElement {
|
|||||||
for (const sp of this._serverCfg?.spaces || []) delete (sp as any).segments;
|
for (const sp of this._serverCfg?.spaces || []) delete (sp as any).segments;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Config writes are serialized (HP-1454-03).
|
||||||
|
*
|
||||||
|
* The debounce only spaced out the *starts*. If a write took longer than
|
||||||
|
* 500 ms — a busy instance, a slow link — the next edit went out with the
|
||||||
|
* same `expected_rev`, the server accepted the first and rejected the second
|
||||||
|
* as a conflict, and the conflict handler reloaded the server copy over the
|
||||||
|
* local one. The user's second edit was gone, with a toast that blamed
|
||||||
|
* another window when there was none.
|
||||||
|
*
|
||||||
|
* One chain, one write in flight. A write always reads `_serverCfg` at the
|
||||||
|
* moment it runs, so edits made while another write was out are carried by
|
||||||
|
* the next one, with the revision that write returned.
|
||||||
|
*/
|
||||||
|
private _writesPending = 0;
|
||||||
|
private _writeChain: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
/** A config write is in flight — the card must not adopt a server revision. */
|
/** A config write is in flight — the card must not adopt a server revision. */
|
||||||
private _cfgWriting = false;
|
private get _cfgWriting(): boolean {
|
||||||
|
return this._writesPending > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _writeConfig(): Promise<void> {
|
||||||
|
this._writesPending++;
|
||||||
|
this._writeChain = this._writeChain
|
||||||
|
.catch(() => undefined) // a failed write must not poison the queue
|
||||||
|
.then(async () => {
|
||||||
|
if (!this._serverCfg) return;
|
||||||
|
this._dropLegacySegments();
|
||||||
|
const r = await this.hass.callWS({
|
||||||
|
type: 'houseplan/config/set', config: this._serverCfg, expected_rev: this._cfgRev,
|
||||||
|
});
|
||||||
|
this._cfgRev = r?.rev ?? this._cfgRev + 1;
|
||||||
|
});
|
||||||
|
const mine = this._writeChain.finally(() => { this._writesPending--; });
|
||||||
|
// keep the chain itself unadorned so the next link waits for the write only
|
||||||
|
return mine;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every mutation path ends here, so this is the one place that can invalidate
|
* Every mutation path ends here, so this is the one place that can invalidate
|
||||||
@@ -1546,24 +1599,16 @@ class HouseplanCard extends LitElement {
|
|||||||
|
|
||||||
private _saveConfigDebounced = debounce(() => {
|
private _saveConfigDebounced = debounce(() => {
|
||||||
if (!this._serverCfg) return;
|
if (!this._serverCfg) return;
|
||||||
this._dropLegacySegments();
|
this._writeConfig().catch((e: any) => {
|
||||||
this._cfgWriting = true;
|
if (e?.code === 'conflict') {
|
||||||
this.hass
|
// a real one now: another window wrote between our read and our write
|
||||||
.callWS({ type: 'houseplan/config/set', config: this._serverCfg, expected_rev: this._cfgRev })
|
this._showToast(this._t('toast.conflict'));
|
||||||
.then((r: any) => {
|
this._cancelPath();
|
||||||
this._cfgRev = r?.rev ?? this._cfgRev + 1;
|
this._reloadConfigOnly(true);
|
||||||
this._cfgWriting = false;
|
} else {
|
||||||
})
|
this._showToast(this._t('toast.cfg_save_failed', { err: this._errText(e) }));
|
||||||
.catch((e: any) => {
|
}
|
||||||
this._cfgWriting = false;
|
});
|
||||||
if (e?.code === 'conflict') {
|
|
||||||
this._showToast(this._t('toast.conflict'));
|
|
||||||
this._cancelPath();
|
|
||||||
this._reloadConfigOnly(true);
|
|
||||||
} else {
|
|
||||||
this._showToast(this._t('toast.cfg_save_failed', { err: this._errText(e) }));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, 500);
|
}, 500);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1961,21 +2006,24 @@ class HouseplanCard extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** All open-boundary pairs of the current space with their shared segments. */
|
/** All open-boundary pairs of the current space with their shared segments. */
|
||||||
private _openPairsCache: { key: string; pairs: { a: RoomCfg; b: RoomCfg; segs: number[][] }[] } | null = null;
|
private _openPairsCache: { model: SpaceModel; pairs: { a: RoomCfg; b: RoomCfg; segs: number[][] }[] } | null = null;
|
||||||
|
|
||||||
private _openPairs(): { a: RoomCfg; b: RoomCfg; segs: number[][] }[] {
|
private _openPairs(): { a: RoomCfg; b: RoomCfg; segs: number[][] }[] {
|
||||||
// audit L1: this used to run once PER ROOM on every render (O(rooms^3)
|
// audit L1: this used to run once PER ROOM on every render (O(rooms^3)
|
||||||
// collinear-overlap math on every HA state push). Memoized on the config
|
// collinear-overlap math on every HA state push), so it is memoized.
|
||||||
// epoch + current space.
|
//
|
||||||
// The key includes the open_to links themselves: _serverCfg is mutated in
|
// The key is the SPACE MODEL OBJECT ITSELF (HP-1454-04). It used to be a
|
||||||
// place in ~22 places (audit L7), so an epoch counter alone is not a
|
// string of room ids and open_to links, which said nothing about geometry:
|
||||||
// trustworthy cache key — a missed bump would render a stale plan, which is
|
// change the space's aspect, or drag a vertex, and the shared segments were
|
||||||
// far worse than recomputing a short string here.
|
// recomputed for the outlines but the open boundaries — and the glow cuts
|
||||||
|
// that follow them — kept their old coordinates until a full reload.
|
||||||
|
// `_model` is already rebuilt whenever the epoch or the config fingerprint
|
||||||
|
// moves, and everything below derives from it, so its identity is an exact
|
||||||
|
// and cheaper key. One cache invalidation strategy, not two.
|
||||||
const sp = this._spaceModel();
|
const sp = this._spaceModel();
|
||||||
const key = this._space + '|' + sp.rooms.map((r) => r.id + ':' + ((r as any).open_to || []).join(',')).join(';');
|
if (this._openPairsCache && this._openPairsCache.model === sp) return this._openPairsCache.pairs;
|
||||||
if (this._openPairsCache && this._openPairsCache.key === key) return this._openPairsCache.pairs;
|
|
||||||
const pairs = this._computeOpenPairs();
|
const pairs = this._computeOpenPairs();
|
||||||
this._openPairsCache = { key, pairs };
|
this._openPairsCache = { model: sp, pairs };
|
||||||
return pairs;
|
return pairs;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2508,6 +2556,7 @@ class HouseplanCard extends LitElement {
|
|||||||
tapAction: '', defaultTap: 'info', controls: [], controlsFilter: '', isLight: false,
|
tapAction: '', defaultTap: 'info', controls: [], controlsFilter: '', isLight: false,
|
||||||
glowRadius: '', model: '',
|
glowRadius: '', model: '',
|
||||||
link: '', description: '', pdfs: [], room: '', busy: false,
|
link: '', description: '', pdfs: [], room: '', busy: false,
|
||||||
|
uploadId: 'up_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2619,7 +2668,7 @@ class HouseplanCard extends LitElement {
|
|||||||
const files = input.files ? [...input.files] : [];
|
const files = input.files ? [...input.files] : [];
|
||||||
input.value = '';
|
input.value = '';
|
||||||
if (!files.length || !this._markerDialog) return;
|
if (!files.length || !this._markerDialog) return;
|
||||||
const mid = this._markerDialog.devId || 'new';
|
const mid = this._markerDialog.uploadId || this._markerDialog.devId || 'new';
|
||||||
const uploaded: PdfRef[] = [];
|
const uploaded: PdfRef[] = [];
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
try {
|
try {
|
||||||
@@ -2728,13 +2777,16 @@ class HouseplanCard extends LitElement {
|
|||||||
// stored config still resolve — the files never left. A failed copy
|
// stored config still resolve — the files never left. A failed copy
|
||||||
// leaves the urls untouched and tells the user (review CR-3).
|
// leaves the urls untouched and tells the user (review CR-3).
|
||||||
let cleanupOldFiles = false;
|
let cleanupOldFiles = false;
|
||||||
if (oldId && oldId !== id && marker.pdfs?.length) {
|
// a new icon uploaded into its own staging folder; an edited one into its
|
||||||
|
// own id. Either way the files move to the final id here.
|
||||||
|
const fileSrc = dlg.uploadId || oldId;
|
||||||
|
if (fileSrc && fileSrc !== id && marker.pdfs?.length) {
|
||||||
try {
|
try {
|
||||||
const res: any = await this.hass.callWS({
|
const res: any = await this.hass.callWS({
|
||||||
type: 'houseplan/files/migrate', from_id: oldId, to_id: id,
|
type: 'houseplan/files/migrate', from_id: fileSrc, to_id: id,
|
||||||
});
|
});
|
||||||
const mapping = res?.mapping || {};
|
const mapping = res?.mapping || {};
|
||||||
marker.pdfs = migratePdfUrls(marker.pdfs, oldId, id, mapping);
|
marker.pdfs = migratePdfUrls(marker.pdfs, fileSrc, id, mapping);
|
||||||
cleanupOldFiles = Object.keys(mapping).length > 0;
|
cleanupOldFiles = Object.keys(mapping).length > 0;
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
this._showToast(this._t('toast.files_migrate_failed', { err: this._errText(e) }));
|
this._showToast(this._t('toast.files_migrate_failed', { err: this._errText(e) }));
|
||||||
@@ -2786,9 +2838,9 @@ class HouseplanCard extends LitElement {
|
|||||||
await this.hass.callWS({ type: 'houseplan/layout/delete', device_id: oldId }).catch(() => undefined);
|
await this.hass.callWS({ type: 'houseplan/layout/delete', device_id: oldId }).catch(() => undefined);
|
||||||
}
|
}
|
||||||
// the config is committed — now it is safe to drop the old folder
|
// the config is committed — now it is safe to drop the old folder
|
||||||
if (cleanupOldFiles && oldId) {
|
if (cleanupOldFiles && fileSrc) {
|
||||||
await this.hass
|
await this.hass
|
||||||
.callWS({ type: 'houseplan/files/cleanup', marker_id: oldId })
|
.callWS({ type: 'houseplan/files/cleanup', marker_id: fileSrc })
|
||||||
.catch(() => undefined); // leftovers are harmless; broken links are not
|
.catch(() => undefined); // leftovers are harmless; broken links are not
|
||||||
}
|
}
|
||||||
this._markerDialog = null;
|
this._markerDialog = null;
|
||||||
@@ -3048,25 +3100,14 @@ class HouseplanCard extends LitElement {
|
|||||||
user's retry starts from the fresh config instead of hitting the same
|
user's retry starts from the fresh config instead of hitting the same
|
||||||
conflict again. */
|
conflict again. */
|
||||||
private async _saveConfigNow(): Promise<void> {
|
private async _saveConfigNow(): Promise<void> {
|
||||||
this._dropLegacySegments();
|
|
||||||
this._cfgEpoch++;
|
this._cfgEpoch++;
|
||||||
// same flag the debounced writer uses: while it is set, an incoming
|
|
||||||
// `houseplan_config_updated` defers its reload instead of replacing the
|
|
||||||
// config under an unfinished write (audit L2, extended to this path)
|
|
||||||
this._cfgWriting = true;
|
|
||||||
try {
|
try {
|
||||||
const r = await this.hass.callWS({
|
// same queue as the debounced writer: a dialog saving while a background
|
||||||
type: 'houseplan/config/set', config: this._serverCfg, expected_rev: this._cfgRev,
|
// write is still out must not race it into a self-inflicted conflict
|
||||||
});
|
await this._writeConfig();
|
||||||
this._cfgRev = r?.rev ?? this._cfgRev + 1;
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e?.code === 'conflict') {
|
if (e?.code === 'conflict') await this._reloadConfigOnly();
|
||||||
this._cfgWriting = false;
|
|
||||||
await this._reloadConfigOnly();
|
|
||||||
}
|
|
||||||
throw e;
|
throw e;
|
||||||
} finally {
|
|
||||||
this._cfgWriting = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ export function spaceModels(cfg: ServerConfig | null): SpaceModel[] {
|
|||||||
id: r.id,
|
id: r.id,
|
||||||
name: r.name,
|
name: r.name,
|
||||||
area: r.area ?? null,
|
area: r.area ?? null,
|
||||||
|
// carried, not dropped: the static card renders from this model too, and
|
||||||
|
// without them it ignored the room-level fill override and drew a room the
|
||||||
|
// full card leaves transparent (HP-1454-07)
|
||||||
|
open_to: r.open_to || undefined,
|
||||||
|
settings: r.settings || undefined,
|
||||||
x: r.x != null ? r.x * NORM_W : undefined,
|
x: r.x != null ? r.x * NORM_W : undefined,
|
||||||
y: r.y != null ? r.y * H : undefined,
|
y: r.y != null ? r.y * H : undefined,
|
||||||
w: r.w != null ? r.w * NORM_W : undefined,
|
w: r.w != null ? r.w * NORM_W : undefined,
|
||||||
|
|||||||
+8
-6
@@ -8,7 +8,7 @@
|
|||||||
*/
|
*/
|
||||||
import { html, svg, nothing, type TemplateResult } from 'lit';
|
import { html, svg, nothing, type TemplateResult } from 'lit';
|
||||||
import { buildDevices, areaLqi, areaLights, areaTemp } from './devices';
|
import { buildDevices, areaLqi, areaLights, areaTemp } from './devices';
|
||||||
import { spaceDisplayOf, roomFillStyle, fillColorsOf } from './logic';
|
import { spaceDisplayOf, roomFillStyle, fillColorsOf, roomFillModeOf } from './logic';
|
||||||
import { DEFAULT_ICON_RULES, compileIconRules, EXCLUDED_DOMAINS } from './rules';
|
import { DEFAULT_ICON_RULES, compileIconRules, EXCLUDED_DOMAINS } from './rules';
|
||||||
import { t, type Lang } from './i18n';
|
import { t, type Lang } from './i18n';
|
||||||
import type { ServerConfig } from './types';
|
import type { ServerConfig } from './types';
|
||||||
@@ -73,16 +73,18 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
|
|||||||
.map((r) => {
|
.map((r) => {
|
||||||
let cls = 'room ' + (space.bg ? 'overlay' : 'yard');
|
let cls = 'room ' + (space.bg ? 'overlay' : 'yard');
|
||||||
let style = '';
|
let style = '';
|
||||||
if (disp.showBorders || disp.fill !== 'none') {
|
// tier 3 wins over the space, exactly as on the full card (HP-1454-07)
|
||||||
|
const fill = roomFillModeOf(disp.fill, r);
|
||||||
|
if (disp.showBorders || fill !== 'none') {
|
||||||
cls += ' styled';
|
cls += ' styled';
|
||||||
const parts = [`--room-stroke:${disp.color}`, `--room-stroke-op:${disp.showBorders ? disp.opacity : 0}`];
|
const parts = [`--room-stroke:${disp.color}`, `--room-stroke-op:${disp.showBorders ? disp.opacity : 0}`];
|
||||||
// fill rendered exactly as configured on the full card (snapshot of current states)
|
// fill rendered exactly as configured on the full card (snapshot of current states)
|
||||||
const fillC = r.area
|
const fillC = r.area
|
||||||
? roomFillStyle(
|
? roomFillStyle(
|
||||||
disp.fill,
|
fill,
|
||||||
disp.fill === 'lqi' ? areaLqi(o.hass, devs, r.area) : null,
|
fill === 'lqi' ? areaLqi(o.hass, devs, r.area) : null,
|
||||||
disp.fill === 'light' ? areaLights(o.hass, devs, r.area) : 'none',
|
fill === 'light' ? areaLights(o.hass, devs, r.area) : 'none',
|
||||||
disp.fill === 'temp' ? areaTemp(o.hass, devs, r.area) : null,
|
fill === 'temp' ? areaTemp(o.hass, devs, r.area) : null,
|
||||||
disp.tempMin,
|
disp.tempMin,
|
||||||
disp.tempMax,
|
disp.tempMax,
|
||||||
fillColorsOf(o.cfg?.settings),
|
fillColorsOf(o.cfg?.settings),
|
||||||
|
|||||||
@@ -545,3 +545,151 @@ async def test_signing_one_path_may_fail_without_failing_the_request(
|
|||||||
urls = resp["result"]["urls"]
|
urls = resp["result"]["urls"]
|
||||||
assert good in urls and "authSig=" in urls[good]
|
assert good in urls and "authSig=" in urls[good]
|
||||||
assert bad not in urls, "an unsignable path is absent, never an unsigned url"
|
assert bad not in urls, "an unsignable path is absent, never an unsigned url"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_config_write_is_capped_by_total_size(
|
||||||
|
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||||
|
) -> None:
|
||||||
|
"""HP-1454-05: per-field limits bound each list, this bounds their product."""
|
||||||
|
from custom_components.houseplan.validation import MAX_CONFIG_BYTES, MAX_TEXT
|
||||||
|
|
||||||
|
await _setup(hass)
|
||||||
|
client = await hass_ws_client(hass)
|
||||||
|
cfg = await _cfg([{"id": "f1", "plan_url": None}])
|
||||||
|
# every field inside the caps, the whole thing far past them
|
||||||
|
blob = "d" * MAX_TEXT
|
||||||
|
cfg["settings"] = {"known_devices": [blob] * (MAX_CONFIG_BYTES // MAX_TEXT + 10)}
|
||||||
|
resp = await _save(client, cfg, 0)
|
||||||
|
assert not resp["success"] and resp["error"]["code"] == "too_large"
|
||||||
|
|
||||||
|
cfg["settings"] = {"known_devices": ["ok"]}
|
||||||
|
assert (await _save(client, cfg, 0))["success"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_layout_keeps_its_revision_and_announces_changes(
|
||||||
|
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||||
|
) -> None:
|
||||||
|
"""HP-1454-08: point-wise writes used to drop the revision and say nothing.
|
||||||
|
|
||||||
|
layout/set offered optimistic locking, but every drag wrote {"layout": …}
|
||||||
|
and reset the counter to 0, so the lock protected nothing; and a static card
|
||||||
|
on the same dashboard never learned that a marker had moved.
|
||||||
|
"""
|
||||||
|
await _setup(hass)
|
||||||
|
client = await hass_ws_client(hass)
|
||||||
|
events: list[dict] = []
|
||||||
|
hass.bus.async_listen("houseplan_layout_updated", lambda ev: events.append(ev.data))
|
||||||
|
|
||||||
|
await client.send_json_auto_id({"type": "houseplan/layout/get"})
|
||||||
|
assert (await client.receive_json())["result"]["rev"] == 0
|
||||||
|
|
||||||
|
await client.send_json_auto_id(
|
||||||
|
{"type": "houseplan/layout/set", "layout": {"a": {"x": 1, "y": 2}}, "expected_rev": 0}
|
||||||
|
)
|
||||||
|
rev = (await client.receive_json())["result"]["rev"]
|
||||||
|
assert rev == 1
|
||||||
|
|
||||||
|
await client.send_json_auto_id(
|
||||||
|
{"type": "houseplan/layout/update", "device_id": "b", "pos": {"x": 3, "y": 4}}
|
||||||
|
)
|
||||||
|
assert (await client.receive_json())["result"]["rev"] == 2
|
||||||
|
|
||||||
|
await client.send_json_auto_id({"type": "houseplan/layout/delete", "device_id": "b"})
|
||||||
|
assert (await client.receive_json())["result"]["rev"] == 3
|
||||||
|
|
||||||
|
await client.send_json_auto_id({"type": "houseplan/layout/get"})
|
||||||
|
got = await client.receive_json()
|
||||||
|
assert got["result"]["rev"] == 3 and got["result"]["layout"] == {"a": {"x": 1, "y": 2}}
|
||||||
|
|
||||||
|
# a stale wholesale write is refused, which it could not be before
|
||||||
|
await client.send_json_auto_id(
|
||||||
|
{"type": "houseplan/layout/set", "layout": {}, "expected_rev": 1}
|
||||||
|
)
|
||||||
|
bad = await client.receive_json()
|
||||||
|
assert not bad["success"] and bad["error"]["code"] == "conflict"
|
||||||
|
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert [e["rev"] for e in events] == [1, 2, 3]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_uploaded_svg_is_sandboxed_and_a_pdf_is_not(
|
||||||
|
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, hass_client
|
||||||
|
) -> None:
|
||||||
|
"""HP-1454-01: user SVG served from HA's origin must not be a live document.
|
||||||
|
|
||||||
|
Only SVG gets the header: a CSP on a PDF response can break the browser's
|
||||||
|
built-in viewer, and a raster image cannot execute anything anyway.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
from custom_components.houseplan.const import CONTENT_URL, FILES_DIR, PLANS_DIR
|
||||||
|
|
||||||
|
await _setup(hass)
|
||||||
|
plans = hass.config.path(PLANS_DIR)
|
||||||
|
files = os.path.join(hass.config.path(FILES_DIR), "m1")
|
||||||
|
|
||||||
|
def _write() -> None:
|
||||||
|
os.makedirs(plans, exist_ok=True)
|
||||||
|
os.makedirs(files, exist_ok=True)
|
||||||
|
with open(os.path.join(plans, "x.svg"), "wb") as fh:
|
||||||
|
fh.write(b"<svg xmlns='http://www.w3.org/2000/svg'/>")
|
||||||
|
with open(os.path.join(plans, "x.png"), "wb") as fh:
|
||||||
|
fh.write(b"PNG")
|
||||||
|
with open(os.path.join(files, "m.pdf"), "wb") as fh:
|
||||||
|
fh.write(b"%PDF-1.4")
|
||||||
|
|
||||||
|
await hass.async_add_executor_job(_write)
|
||||||
|
http = await hass_client()
|
||||||
|
|
||||||
|
svg = await http.get(f"{CONTENT_URL}/plans/_/x.svg")
|
||||||
|
assert svg.status == 200
|
||||||
|
csp = svg.headers.get("Content-Security-Policy", "")
|
||||||
|
assert "sandbox" in csp and "script-src 'none'" in csp
|
||||||
|
assert svg.headers["Content-Type"].startswith("image/svg+xml")
|
||||||
|
|
||||||
|
png = await http.get(f"{CONTENT_URL}/plans/_/x.png")
|
||||||
|
assert png.status == 200 and "Content-Security-Policy" not in png.headers
|
||||||
|
|
||||||
|
pdf = await http.get(f"{CONTENT_URL}/files/m1/m.pdf")
|
||||||
|
assert pdf.status == 200 and "Content-Security-Policy" not in pdf.headers
|
||||||
|
assert await pdf.read() == b"%PDF-1.4"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_upload_never_overwrites_an_existing_attachment(
|
||||||
|
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, hass_client
|
||||||
|
) -> None:
|
||||||
|
"""HP-1454-02: an upload is not part of the config transaction.
|
||||||
|
|
||||||
|
Writing straight to `<marker>/<filename>` meant a cancelled dialog — or a
|
||||||
|
rejected save — left the stored url serving the new bytes. And two new
|
||||||
|
markers both uploading `manual.pdf` shared one physical file.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
from custom_components.houseplan.const import CONTENT_URL, FILES_DIR
|
||||||
|
|
||||||
|
await _setup(hass)
|
||||||
|
http = await hass_client()
|
||||||
|
|
||||||
|
async def upload(marker_id: str, name: str, data: bytes) -> str:
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
writer = aiohttp.FormData()
|
||||||
|
writer.add_field("marker_id", marker_id)
|
||||||
|
writer.add_field("file", data, filename=name)
|
||||||
|
resp = await http.post("/api/houseplan/upload", data=writer)
|
||||||
|
assert resp.status == 200, await resp.text()
|
||||||
|
return (await resp.json())["url"]
|
||||||
|
|
||||||
|
first = await upload("m1", "manual.pdf", b"ONE")
|
||||||
|
second = await upload("m1", "manual.pdf", b"TWO")
|
||||||
|
assert first != second, "the second upload must not take the first name"
|
||||||
|
|
||||||
|
folder = os.path.join(hass.config.path(FILES_DIR), "m1")
|
||||||
|
names = sorted(await hass.async_add_executor_job(os.listdir, folder))
|
||||||
|
assert names == ["manual (2).pdf", "manual.pdf"]
|
||||||
|
|
||||||
|
got = await http.get(first.replace(CONTENT_URL, CONTENT_URL))
|
||||||
|
assert await got.read() == b"ONE", "the first file is untouched"
|
||||||
|
got2 = await http.get(second)
|
||||||
|
assert await got2.read() == b"TWO"
|
||||||
|
|||||||
@@ -381,3 +381,112 @@ def test_every_room_fill_mode_the_editor_offers_is_accepted():
|
|||||||
v.SPACE_SCHEMA(room(None)) # inherit from the space
|
v.SPACE_SCHEMA(room(None)) # inherit from the space
|
||||||
with pytest.raises(vol.Invalid):
|
with pytest.raises(vol.Invalid):
|
||||||
v.SPACE_SCHEMA(room("rainbow"))
|
v.SPACE_SCHEMA(room("rainbow"))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- attachments & inner limits (HP-1454-02, -05) ----------
|
||||||
|
|
||||||
|
|
||||||
|
def test_unique_filename_never_returns_a_taken_name(tmp_path):
|
||||||
|
unique_filename = plans.unique_filename
|
||||||
|
d = tmp_path / "m1"
|
||||||
|
d.mkdir()
|
||||||
|
assert unique_filename(d, "manual.pdf") == "manual.pdf"
|
||||||
|
(d / "manual.pdf").write_bytes(b"x")
|
||||||
|
assert unique_filename(d, "manual.pdf") == "manual (2).pdf"
|
||||||
|
(d / "manual (2).pdf").write_bytes(b"x")
|
||||||
|
assert unique_filename(d, "manual.pdf") == "manual (3).pdf"
|
||||||
|
# no extension, and a name that needs sanitising
|
||||||
|
(d / "readme").write_bytes(b"x")
|
||||||
|
assert unique_filename(d, "readme") == "readme (2)"
|
||||||
|
assert unique_filename(d, "../../etc/passwd") == "passwd"
|
||||||
|
|
||||||
|
|
||||||
|
def _acfg(*pairs):
|
||||||
|
return {"markers": [{"id": f"m{i}", "pdfs": [{"url": f"/api/houseplan/content/files/{p}"}]}
|
||||||
|
for i, p in enumerate(pairs)]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_attachment_refs_reads_marker_urls():
|
||||||
|
attachment_refs = plans.attachment_refs
|
||||||
|
assert attachment_refs(None) == set()
|
||||||
|
assert attachment_refs(_acfg("m1/a.pdf", "m2/b.pdf")) == {"m1/a.pdf", "m2/b.pdf"}
|
||||||
|
# legacy and foreign urls are not ours to collect against
|
||||||
|
cfg = {"markers": [{"id": "m", "pdfs": [{"url": "/local/x.pdf"}, {"url": "/api/houseplan/content/files/deep/a/b.pdf"}]}]}
|
||||||
|
assert plans.attachment_refs(cfg) == set()
|
||||||
|
|
||||||
|
|
||||||
|
def test_collect_attachments_supersedes_and_ages(tmp_path):
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
collect_attachments = plans.collect_attachments
|
||||||
|
files = tmp_path / "files"
|
||||||
|
(files / "m1").mkdir(parents=True)
|
||||||
|
for n in ("old.pdf", "new.pdf", "cancelled.pdf"):
|
||||||
|
(files / "m1" / n).write_bytes(b"x")
|
||||||
|
|
||||||
|
# the commit swapped old.pdf for new.pdf; cancelled.pdf is a fresh upload
|
||||||
|
# nobody saved — it may belong to a dialog that is still open
|
||||||
|
removed = collect_attachments(files, _acfg("m1/old.pdf"), _acfg("m1/new.pdf"))
|
||||||
|
assert removed == 1
|
||||||
|
assert not (files / "m1" / "old.pdf").exists()
|
||||||
|
assert (files / "m1" / "new.pdf").is_file()
|
||||||
|
assert (files / "m1" / "cancelled.pdf").is_file()
|
||||||
|
|
||||||
|
old = time.time() - const.PLAN_ORPHAN_TTL_S - 60
|
||||||
|
os.utime(files / "m1" / "cancelled.pdf", (old, old))
|
||||||
|
assert collect_attachments(files, _acfg("m1/new.pdf"), _acfg("m1/new.pdf")) == 1
|
||||||
|
assert not (files / "m1" / "cancelled.pdf").exists()
|
||||||
|
assert (files / "m1" / "new.pdf").is_file()
|
||||||
|
|
||||||
|
|
||||||
|
def test_collect_attachments_removes_the_empty_folder_and_never_raises(tmp_path):
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
files = tmp_path / "files"
|
||||||
|
(files / "up_x").mkdir(parents=True)
|
||||||
|
f = files / "up_x" / "orphan.pdf"
|
||||||
|
f.write_bytes(b"x")
|
||||||
|
old = time.time() - const.PLAN_ORPHAN_TTL_S - 60
|
||||||
|
os.utime(f, (old, old))
|
||||||
|
assert plans.collect_attachments(files, {}, {}) == 1
|
||||||
|
assert not (files / "up_x").exists(), "the staging folder goes with its last file"
|
||||||
|
assert plans.collect_attachments(tmp_path / "nope", {}, {}) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_inner_collection_limits():
|
||||||
|
room = {"id": "r", "name": "R", "poly": [[0.1, 0.1]] * v.MAX_POLY_POINTS}
|
||||||
|
v.ROOM_SCHEMA(room)
|
||||||
|
with pytest.raises(vol.Invalid):
|
||||||
|
v.ROOM_SCHEMA({**room, "poly": [[0.1, 0.1]] * (v.MAX_POLY_POINTS + 1)})
|
||||||
|
|
||||||
|
rect = {"id": "r", "name": "R", "x": 0.1, "y": 0.1, "w": 0.2, "h": 0.2}
|
||||||
|
v.ROOM_SCHEMA({**rect, "open_to": ["x"] * v.MAX_OPEN_TO})
|
||||||
|
with pytest.raises(vol.Invalid):
|
||||||
|
v.ROOM_SCHEMA({**rect, "open_to": ["x"] * (v.MAX_OPEN_TO + 1)})
|
||||||
|
|
||||||
|
m = {"id": "m", "binding": "virtual"}
|
||||||
|
v.MARKER_SCHEMA({**m, "controls": ["light.x"] * v.MAX_CONTROLS})
|
||||||
|
with pytest.raises(vol.Invalid):
|
||||||
|
v.MARKER_SCHEMA({**m, "controls": ["light.x"] * (v.MAX_CONTROLS + 1)})
|
||||||
|
|
||||||
|
pdf = {"name": "n", "url": "/api/houseplan/content/files/m/a.pdf"}
|
||||||
|
v.MARKER_SCHEMA({**m, "pdfs": [pdf] * v.MAX_PDFS})
|
||||||
|
with pytest.raises(vol.Invalid):
|
||||||
|
v.MARKER_SCHEMA({**m, "pdfs": [pdf] * (v.MAX_PDFS + 1)})
|
||||||
|
|
||||||
|
v.MARKER_SCHEMA({**m, "name": "n" * v.MAX_TEXT})
|
||||||
|
with pytest.raises(vol.Invalid):
|
||||||
|
v.MARKER_SCHEMA({**m, "name": "n" * (v.MAX_TEXT + 1)})
|
||||||
|
with pytest.raises(vol.Invalid):
|
||||||
|
v.MARKER_SCHEMA({**m, "link": "u" * (v.MAX_URL + 1)})
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_segments_are_dropped_by_the_server():
|
||||||
|
"""A limit that depends on the client stripping the field is not a limit."""
|
||||||
|
out = v.SPACE_SCHEMA({
|
||||||
|
"id": "f1", "title": "F", "aspect": 1.4, "view_box": [0, 0, 1, 1], "rooms": [],
|
||||||
|
"segments": [[1, 2, 3, 4]] * 100000,
|
||||||
|
})
|
||||||
|
assert "segments" not in out
|
||||||
|
|||||||
Reference in New Issue
Block a user