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:
Matysh
2026-07-28 16:06:21 +03:00
parent 96d387ff1d
commit 260615a63f
27 changed files with 1130 additions and 228 deletions
+1 -1
View File
@@ -24,7 +24,7 @@ MAX_SIGN_PATHS = 200
PLAN_ORPHAN_TTL_S = 3600
FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.45.4"
VERSION = "1.46.0"
DEFAULT_CONFIG: dict = {
"spaces": [],
File diff suppressed because one or more lines are too long
+87 -29
View File
@@ -6,6 +6,8 @@ breaks the connection on a large PDF) but via a plain multipart POST — like me
from __future__ import annotations
import logging
import os
import tempfile
from pathlib import Path
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 .auth import may_write
from .plans import unique_filename
from .validation import (
FILE_EXTENSIONS,
MAX_FILE_BYTES,
@@ -73,17 +76,35 @@ class HouseplanContentView(HomeAssistantView):
if not str(path).startswith(str(base)):
return web.Response(status=404)
def _read() -> bytes | None:
return path.read_bytes() if path.is_file() else None
blob = await hass.async_add_executor_job(_read)
if blob is None:
if not await hass.async_add_executor_job(path.is_file):
return web.Response(status=404)
return web.Response(
body=blob,
content_type=_MIME.get(path.suffix.lower(), "application/octet-stream"),
headers={"Cache-Control": "private, max-age=3600"},
suffix = path.suffix.lower()
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):
@@ -100,8 +121,27 @@ class HouseplanUploadView(HomeAssistantView):
marker_id = "misc"
filename: str | None = None
blob: bytes | None = None
tmp_path: Path | None = None
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:
reader = await request.multipart()
async for part in reader:
@@ -109,44 +149,62 @@ class HouseplanUploadView(HomeAssistantView):
marker_id = sanitize_marker_id(await part.text())
elif part.name == "file":
filename = part.filename or "file"
# read in chunks, aborting at the limit, instead of loading the whole file into memory
chunks: list[bytes] = []
if file_ext(filename) not in FILE_EXTENSIONS:
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
while chunk := await part.read_chunk(_CHUNK):
size += len(chunk)
if size > MAX_FILE_BYTES:
too_large = True
break
chunks.append(chunk)
await hass.async_add_executor_job(_append, tmp_path, chunk)
if too_large:
break
blob = b"".join(chunks)
except Exception as err: # noqa: BLE001
_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)
if too_large:
return web.json_response(
{"error": "too_large", "max_mb": MAX_FILE_BYTES // 1024 // 1024}, status=413
)
if blob is None or not filename:
return web.json_response({"error": "no_file"}, status=400)
ext = file_ext(filename)
if ext not in FILE_EXTENSIONS:
if bad_ext:
if tmp_path is not None:
await hass.async_add_executor_job(_discard, tmp_path)
return web.json_response(
{"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)
target_dir = Path(hass.config.path(FILES_DIR)) / marker_id
path = target_dir / safe_name
target_dir = files_root / marker_id
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)
path.write_bytes(blob)
return int(path.stat().st_mtime)
name = unique_filename(target_dir, safe_name)
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(
{"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}
)
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"single_config_entry": true,
"version": "1.45.4"
"version": "1.46.0"
}
+97 -5
View File
@@ -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
may delete a plan file, and when, is a correctness question rather than a
housekeeping one. It lives here, apart from the WebSocket plumbing, precisely
because it is the part that has to be reasoned about and tested.
may write or delete a plan or an attachment, and when, is a correctness
question rather than housekeeping. It lives here, apart from the WebSocket and
HTTP plumbing, precisely because it is the part that has to be reasoned about
and tested.
"""
from __future__ import annotations
@@ -13,11 +14,102 @@ from pathlib import Path
from typing import Any
from .const import PLAN_ORPHAN_TTL_S
from .validation import PLAN_EXTENSIONS
from .validation import PLAN_EXTENSIONS, sanitize_filename
_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:
"""File name a stored plan_url points at ('' when there is none)."""
if not isinstance(url, str) or not url:
+14 -5
View File
@@ -51,8 +51,17 @@ async def async_check_plan_files(hass: HomeAssistant, entry: HouseplanConfigEntr
translation_key="broken_plan",
translation_placeholders={"space": space_id, "file": fname},
)
# clear stale issues for spaces that are fine again (or gone)
for sp in spaces:
sid = sp.get("id", "?")
if sid not in broken:
ir.async_delete_issue(hass, DOMAIN, f"broken_plan_{sid}")
# Clear stale issues. Iterating the CURRENT spaces could only ever clear
# issues for spaces that still exist, so deleting or renaming a space with a
# missing plan left its warning in Repairs forever, with nothing left to fix
# it (HP-1454-09). Enumerate what we actually published instead.
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)
+41 -17
View File
@@ -66,6 +66,26 @@ MAX_MARKERS = 2000
MAX_OPENINGS = 500
MAX_DECOR = 1000
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(
{vol.Required("x"): _finite, vol.Required("y"): _finite},
@@ -85,10 +105,10 @@ def _require_geometry(room: dict) -> dict:
ROOM_SCHEMA = vol.All(
vol.Schema(
{
vol.Required("id"): str,
vol.Required("name"): str,
vol.Optional("area"): vol.Any(str, None),
vol.Optional("open_to"): [str],
vol.Required("id"): _TEXT,
vol.Required("name"): _TEXT,
vol.Optional("area"): _TEXT_OR_NONE,
vol.Optional("open_to"): vol.All([_TEXT], vol.Length(max=MAX_OPEN_TO)),
vol.Optional("settings"): vol.Any(
None,
vol.Schema(
@@ -106,7 +126,7 @@ ROOM_SCHEMA = vol.All(
vol.Optional("y"): _finite,
vol.Optional("w"): _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,
),
@@ -185,7 +205,10 @@ SPACE_SCHEMA = vol.Schema(
# 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;
# 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,
)
@@ -197,13 +220,13 @@ MARKER_SCHEMA = vol.Schema(
vol.Optional("space"): vol.Any(str, None),
vol.Optional("area"): vol.Any(str, None),
vol.Optional("hidden"): bool,
vol.Optional("name"): vol.Any(str, None),
vol.Optional("icon"): vol.Any(str, None),
vol.Optional("model"): vol.Any(str, None),
vol.Optional("link"): vol.Any(str, None),
vol.Optional("description"): vol.Any(str, None),
vol.Optional("name"): _TEXT_OR_NONE,
vol.Optional("icon"): _TEXT_OR_NONE,
vol.Optional("model"): _TEXT_OR_NONE,
vol.Optional("link"): vol.Any(None, _URL),
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("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("is_light"): vol.Any(bool, 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("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("pdfs"): [
vol.Schema({vol.Required("name"): str, vol.Required("url"): str}, extra=vol.ALLOW_EXTRA)
],
vol.Optional("pdfs"): vol.All(
[vol.Schema({vol.Required("name"): _TEXT, vol.Required("url"): _URL}, extra=vol.ALLOW_EXTRA)],
vol.Length(max=MAX_PDFS),
),
},
extra=vol.ALLOW_EXTRA,
)
@@ -227,8 +251,8 @@ CONFIG_SCHEMA = 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("known_devices"): [str],
vol.Optional("new_device_ids"): [str],
vol.Optional("known_devices"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
vol.Optional("new_device_ids"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
vol.Optional("fill_colors"): vol.Schema(
{
str: vol.Schema(
+40 -14
View File
@@ -5,6 +5,7 @@ import logging
import base64
import binascii
import json
import secrets
from pathlib import Path
from typing import Any
@@ -16,13 +17,13 @@ from homeassistant.core import HomeAssistant, callback
from .const import (
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 .plans import collect_plans
from .plans import collect_attachments, collect_plans
from .store import HouseplanData, get_data, get_entry
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,
)
@@ -74,7 +75,9 @@ async def ws_layout_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
if rt is None:
return
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(
@@ -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})"
)
return
await rt.store.async_save({"layout": msg["layout"], "rev": current_rev + 1})
connection.send_result(msg["id"], {"ok": True, "rev": current_rev + 1})
new_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(
@@ -131,8 +136,13 @@ async def ws_layout_update(hass: HomeAssistant, connection, msg: dict[str, Any])
data = await rt.store.async_load() or {}
layout = data.get("layout", {})
layout[msg["device_id"]] = msg["pos"]
await rt.store.async_save({"layout": layout})
connection.send_result(msg["id"], {"ok": True})
# keep the revision: a point-wise write used to drop it, which made the
# 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(
@@ -297,13 +307,17 @@ async def ws_layout_delete(hass: HomeAssistant, connection, msg: dict[str, Any])
rt = _runtime(hass, connection, msg["id"])
if rt is None:
return
new_rev: int | None = None
async with rt.write_lock:
data = await rt.store.async_load() or {}
layout = data.get("layout", {})
if msg["device_id"] in layout:
del layout[msg["device_id"]]
await rt.store.async_save({"layout": layout})
connection.send_result(msg["id"], {"ok": True})
new_rev = int(data.get("rev", 0)) + 1
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 ----------------
@@ -343,6 +357,16 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
rt = _runtime(hass, connection, msg["id"])
if rt is None:
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:
data = await rt.config_store.async_load() or {}
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,
# or the client retries an edit the server has already accepted and
# 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:
await hass.async_add_executor_job(
collect_plans, Path(hass.config.path(PLANS_DIR)), data.get("config"), msg["config"]
)
await hass.async_add_executor_job(_collect)
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})
# refresh repair issues (broken plan references) without waiting for a restart
entry = get_entry(hass)
+67
View File
@@ -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);
+59
View File
@@ -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);
+78
View File
@@ -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
+16 -16
View File
File diff suppressed because one or more lines are too long
+27 -3
View File
@@ -172,14 +172,38 @@ double click → properties dialog. In markup mode the "Opening" tool handles cl
| Command | Parameters | Response |
|---|---|---|
| `houseplan/layout/get` | — | `{layout: {device_id: {x,y}}}` |
| `houseplan/layout/set` | `layout` | `{ok}` (admin_only optional) |
| `houseplan/layout/update` | `device_id`, `pos` | `{ok}` |
| `houseplan/layout/get` | — | `{layout: {device_id: {x,y}}, rev}` |
| `houseplan/layout/set` | `layout`, `expected_rev?` | `{ok, rev}` / err `conflict`; event `houseplan_layout_updated` |
| `houseplan/layout/update` | `device_id`, `pos` | `{ok, rev}`; event `houseplan_layout_updated` |
| `houseplan/config/get` | — | `{config, rev}` |
| `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/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**
(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
+74
View File
@@ -1,5 +1,79 @@
# 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)
- **A partly successful signing answer no longer skips the backoff (R5-1).**
The backend signs each path independently: one it cannot sign is logged,
+75
View File
@@ -6,6 +6,81 @@
> **Правило проекта:** оба файла пополняются в одном коммите с самим
> изменением — как и остальная документация (см. 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)
- **Частично успешный ответ на подпись больше не пропускает выдержку (R5-1).**
Бэкенд подписывает каждый путь независимо: тот, что подписать не удалось,
+2 -2
View File
@@ -15,12 +15,12 @@
| 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) |
| 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 |
| HACS | Custom repository works. **Inclusion PR: hacs/default#9004** — open, valid, labeled; ~864 older open PRs but merge rate ≈180/mo; realistic ETA 13 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 |
| 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 |
+33
View File
@@ -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
[auto: backend test_every_display_mode_the_editor_offers_is_accepted and
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,
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;
+7 -38
View File
@@ -1,12 +1,12 @@
{
"name": "houseplan-card",
"version": "1.41.2",
"version": "1.45.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "houseplan-card",
"version": "1.41.2",
"version": "1.45.4",
"license": "MIT",
"dependencies": {
"lit": "^3.1.3",
@@ -817,16 +817,6 @@
"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": {
"version": "1.22.12",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
@@ -894,35 +884,14 @@
"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": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz",
"integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==",
"version": "7.0.7",
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.7.tgz",
"integrity": "sha512-YAy8Od6KV+uuwUuU50np8fGB/Aues6Y0nAhA9y/hId74PlKUcme4pXcBD46NWKr1Q4osN/iseZ17YqO1XfmI8g==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"randombytes": "^2.1.0"
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/smob": {
+4 -1
View File
@@ -1,6 +1,6 @@
{
"name": "houseplan-card",
"version": "1.45.4",
"version": "1.46.0",
"description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT",
"type": "module",
@@ -25,5 +25,8 @@
"dependencies": {
"lit": "^3.1.3",
"polyclip-ts": "^0.16.8"
},
"overrides": {
"serialize-javascript": "^7.0.5"
}
}
+9 -3
View File
@@ -47,11 +47,17 @@ async function fetchFresh(hass: any): Promise<HpConfigSnapshot> {
};
if (!subscribed && hass.connection?.subscribeEvents) {
subscribed = true;
try {
await hass.connection.subscribeEvents(() => {
const invalidate = () => {
cache = null; // invalidate; listeners reload
listeners.forEach((l) => l());
}, 'houseplan_config_updated');
};
try {
await hass.connection.subscribeEvents(invalidate, 'houseplan_config_updated');
// Layout is separate state: dragging an icon on the full card writes only
// the layout, so a static card on the same dashboard kept showing the old
// 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 {
subscribed = false;
}
+86 -45
View File
@@ -35,7 +35,7 @@ import './space-card';
import { cardStyles } from './styles';
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_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
const LS_ZOOM = 'houseplan_card_zoom_v1';
@@ -234,6 +234,15 @@ class HouseplanCard extends LitElement {
private _infoCard: DevItem | null = null;
private _markerDialog: {
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 copysavecleanup order as a rebind.
*/
uploadId?: string;
name: string;
binding: string; // 'device:<id>' | 'entity:<eid>' | 'virtual' | '' (not chosen yet)
bindingMode: 'virtual' | 'ha';
@@ -556,9 +565,17 @@ class HouseplanCard extends LitElement {
for (const x of sp as any[]) {
s += (x.id || '') + ',' + (x.aspect || '') + ',' + (x.plan_url || '').length + ','
+ (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('+') + '.'
+ (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;
}
@@ -1530,8 +1547,44 @@ class HouseplanCard extends LitElement {
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. */
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
@@ -1546,17 +1599,9 @@ class HouseplanCard extends LitElement {
private _saveConfigDebounced = debounce(() => {
if (!this._serverCfg) return;
this._dropLegacySegments();
this._cfgWriting = true;
this.hass
.callWS({ type: 'houseplan/config/set', config: this._serverCfg, expected_rev: this._cfgRev })
.then((r: any) => {
this._cfgRev = r?.rev ?? this._cfgRev + 1;
this._cfgWriting = false;
})
.catch((e: any) => {
this._cfgWriting = false;
this._writeConfig().catch((e: any) => {
if (e?.code === 'conflict') {
// a real one now: another window wrote between our read and our write
this._showToast(this._t('toast.conflict'));
this._cancelPath();
this._reloadConfigOnly(true);
@@ -1961,21 +2006,24 @@ class HouseplanCard extends LitElement {
}
/** 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[][] }[] {
// 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
// epoch + current space.
// The key includes the open_to links themselves: _serverCfg is mutated in
// place in ~22 places (audit L7), so an epoch counter alone is not a
// trustworthy cache key — a missed bump would render a stale plan, which is
// far worse than recomputing a short string here.
// collinear-overlap math on every HA state push), so it is memoized.
//
// The key is the SPACE MODEL OBJECT ITSELF (HP-1454-04). It used to be a
// string of room ids and open_to links, which said nothing about geometry:
// change the space's aspect, or drag a vertex, and the shared segments were
// 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 key = this._space + '|' + sp.rooms.map((r) => r.id + ':' + ((r as any).open_to || []).join(',')).join(';');
if (this._openPairsCache && this._openPairsCache.key === key) return this._openPairsCache.pairs;
if (this._openPairsCache && this._openPairsCache.model === sp) return this._openPairsCache.pairs;
const pairs = this._computeOpenPairs();
this._openPairsCache = { key, pairs };
this._openPairsCache = { model: sp, pairs };
return pairs;
}
@@ -2508,6 +2556,7 @@ class HouseplanCard extends LitElement {
tapAction: '', defaultTap: 'info', controls: [], controlsFilter: '', isLight: false,
glowRadius: '', model: '',
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] : [];
input.value = '';
if (!files.length || !this._markerDialog) return;
const mid = this._markerDialog.devId || 'new';
const mid = this._markerDialog.uploadId || this._markerDialog.devId || 'new';
const uploaded: PdfRef[] = [];
for (const file of files) {
try {
@@ -2728,13 +2777,16 @@ class HouseplanCard extends LitElement {
// stored config still resolve — the files never left. A failed copy
// leaves the urls untouched and tells the user (review CR-3).
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 {
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 || {};
marker.pdfs = migratePdfUrls(marker.pdfs, oldId, id, mapping);
marker.pdfs = migratePdfUrls(marker.pdfs, fileSrc, id, mapping);
cleanupOldFiles = Object.keys(mapping).length > 0;
} catch (e: any) {
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);
}
// the config is committed — now it is safe to drop the old folder
if (cleanupOldFiles && oldId) {
if (cleanupOldFiles && fileSrc) {
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
}
this._markerDialog = null;
@@ -3048,25 +3100,14 @@ class HouseplanCard extends LitElement {
user's retry starts from the fresh config instead of hitting the same
conflict again. */
private async _saveConfigNow(): Promise<void> {
this._dropLegacySegments();
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 {
const r = await this.hass.callWS({
type: 'houseplan/config/set', config: this._serverCfg, expected_rev: this._cfgRev,
});
this._cfgRev = r?.rev ?? this._cfgRev + 1;
// same queue as the debounced writer: a dialog saving while a background
// write is still out must not race it into a self-inflicted conflict
await this._writeConfig();
} catch (e: any) {
if (e?.code === 'conflict') {
this._cfgWriting = false;
await this._reloadConfigOnly();
}
if (e?.code === 'conflict') await this._reloadConfigOnly();
throw e;
} finally {
this._cfgWriting = false;
}
}
+5
View File
@@ -20,6 +20,11 @@ export function spaceModels(cfg: ServerConfig | null): SpaceModel[] {
id: r.id,
name: r.name,
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,
y: r.y != null ? r.y * H : undefined,
w: r.w != null ? r.w * NORM_W : undefined,
+8 -6
View File
@@ -8,7 +8,7 @@
*/
import { html, svg, nothing, type TemplateResult } from 'lit';
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 { t, type Lang } from './i18n';
import type { ServerConfig } from './types';
@@ -73,16 +73,18 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
.map((r) => {
let cls = 'room ' + (space.bg ? 'overlay' : 'yard');
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';
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)
const fillC = r.area
? roomFillStyle(
disp.fill,
disp.fill === 'lqi' ? areaLqi(o.hass, devs, r.area) : null,
disp.fill === 'light' ? areaLights(o.hass, devs, r.area) : 'none',
disp.fill === 'temp' ? areaTemp(o.hass, devs, r.area) : null,
fill,
fill === 'lqi' ? areaLqi(o.hass, devs, r.area) : null,
fill === 'light' ? areaLights(o.hass, devs, r.area) : 'none',
fill === 'temp' ? areaTemp(o.hass, devs, r.area) : null,
disp.tempMin,
disp.tempMax,
fillColorsOf(o.cfg?.settings),
+148
View File
@@ -545,3 +545,151 @@ async def test_signing_one_path_may_fail_without_failing_the_request(
urls = resp["result"]["urls"]
assert good in urls and "authSig=" in urls[good]
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"
+109
View File
@@ -381,3 +381,112 @@ def test_every_room_fill_mode_the_editor_offers_is_accepted():
v.SPACE_SCHEMA(room(None)) # inherit from the space
with pytest.raises(vol.Invalid):
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