mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 16:38:31 +00:00
v1.45.0: external review of v1.44.8 — R2-1, R2-2, R2-3
R2-1 (high): plan replacement committed filesystem state before the config CAS.
The upload wrote the final name and unlinked the other extension, so a rejected
config write left the live plan already replaced — or the stored config
pointing at a deleted file. Uploads now go to <space>.<token>.<ext> and delete
nothing; houseplan/plan/cleanup runs only after the config write is accepted.
The '.' separator is load-bearing: a space id cannot contain one, so cleaning
'f1' can never reach the files of 'f1-attic'.
R2-2: the backend signs at most MAX_SIGN_PATHS (200) per request and ignores
the rest silently, while the card sent its whole cache in one call and trusted
any cached entry forever — past 200 attachments the later ones stopped being
refreshed and expired for good. Requests are chunked to the shared constant,
entries carry their issue time (aging urls keep rendering while a replacement
is fetched, expired ones are dropped), and the cache is pruned to urls the live
config still references.
R2-3: areaClimate() rescanned the whole registry per room and per measurement.
areaClimateMap() classifies once and returns Map<area,{temp,hum}>, memoized on
hass identity so fresh states are always observed. Smoke measurement: 133
registry scans per update with 44 rooms before, 2 after, flat in room count.
Also: smoke_ux_fixes wrote its screenshot to a hard-coded /tmp path and could
not run on Windows.
Tests: smoke_plan_upload_reject, smoke_sign_cap, smoke_climate_once (all fail
on v1.44.8), three backend tests for versioned plan names and cleanup scoping,
unit tests for chunk/referencedContentUrls and areaClimateMap.
Docs: CHANGELOG.md + CHANGELOG.ru.md + ARCHITECTURE.md + TESTING.md + STATUS.md.
This commit is contained in:
@@ -11,9 +11,14 @@ PLANS_DIR = "houseplan/plans" # relative to the HA configuration directory
|
||||
FILES_URL = "/houseplan_files/files"
|
||||
# authenticated read path (audit B1): /api/houseplan/content/<plans|files>/<sub>/<name>
|
||||
CONTENT_URL = "/api/houseplan/content"
|
||||
|
||||
# How many paths one houseplan/content/sign call may carry. The card batches to
|
||||
# the same number; a client that sends more used to get a partial answer with no
|
||||
# way to tell which paths were dropped (review R2-2).
|
||||
MAX_SIGN_PATHS = 200
|
||||
FILES_DIR = "houseplan/files"
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
VERSION = "1.44.8"
|
||||
VERSION = "1.45.0"
|
||||
|
||||
DEFAULT_CONFIG: dict = {
|
||||
"spaces": [],
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -16,5 +16,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.44.8"
|
||||
"version": "1.45.0"
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import logging
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -15,13 +16,13 @@ from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
from .const import (
|
||||
CONF_ADMIN_ONLY, DEFAULT_CONFIG,
|
||||
CONTENT_URL, PLANS_DIR, PLANS_URL,
|
||||
CONTENT_URL, MAX_SIGN_PATHS, PLANS_DIR, PLANS_URL,
|
||||
)
|
||||
from .auth import may_write
|
||||
from .store import HouseplanData, get_data, get_entry
|
||||
from .validation import (
|
||||
CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_PLAN_BYTES,
|
||||
PLAN_EXTENSIONS, POS_SCHEMA, valid_space_id,
|
||||
PLAN_EXTENSIONS, POS_SCHEMA, sanitize_filename, valid_space_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -38,6 +39,7 @@ def async_register(hass: HomeAssistant) -> None:
|
||||
websocket_api.async_register_command(hass, ws_config_get)
|
||||
websocket_api.async_register_command(hass, ws_config_set)
|
||||
websocket_api.async_register_command(hass, ws_plan_set)
|
||||
websocket_api.async_register_command(hass, ws_plan_cleanup)
|
||||
websocket_api.async_register_command(hass, ws_files_migrate)
|
||||
websocket_api.async_register_command(hass, ws_files_cleanup)
|
||||
websocket_api.async_register_command(hass, ws_content_sign)
|
||||
@@ -226,7 +228,7 @@ async def ws_content_sign(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
|
||||
out: dict[str, str] = {}
|
||||
token_id = getattr(connection, "refresh_token_id", None)
|
||||
for path in msg["paths"][:200]:
|
||||
for path in msg["paths"][:MAX_SIGN_PATHS]:
|
||||
if not isinstance(path, str) or not path.startswith(CONTENT_URL + "/"):
|
||||
continue # only ever sign our own content endpoint
|
||||
clean = path.split("?", 1)[0]
|
||||
@@ -400,20 +402,81 @@ async def ws_plan_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> N
|
||||
connection.send_error(msg["id"], "too_large", f"Plan is larger than {MAX_PLAN_BYTES // 1024 // 1024} MB")
|
||||
return
|
||||
|
||||
# Copy-on-write: a plan is written under a NEW unique name and nothing is
|
||||
# deleted here (review R2-1). The old name stays readable, so a config write
|
||||
# that is later rejected — revision conflict, validation, lost connection —
|
||||
# leaves the stored plan exactly as it was. The card calls
|
||||
# `houseplan/plan/cleanup` only after its config CAS succeeds; a crash in
|
||||
# between leaves an orphan file that the next successful save removes.
|
||||
#
|
||||
# `.` separates the id from the token because a space id cannot contain one
|
||||
# (SPACE_ID_RE), so "<space>.<token>.<ext>" can never be confused with the
|
||||
# files of a differently named space.
|
||||
plans_dir = Path(hass.config.path(PLANS_DIR))
|
||||
path = plans_dir / f"{space_id}.{msg['ext']}"
|
||||
name = f"{space_id}.{secrets.token_hex(4)}.{msg['ext']}"
|
||||
path = plans_dir / name
|
||||
|
||||
def _write() -> int:
|
||||
def _write() -> None:
|
||||
plans_dir.mkdir(parents=True, exist_ok=True)
|
||||
# remove old variants with a different extension
|
||||
for old_ext in PLAN_EXTENSIONS:
|
||||
old = plans_dir / f"{space_id}.{old_ext}"
|
||||
if old_ext != msg["ext"] and old.exists():
|
||||
old.unlink()
|
||||
path.write_bytes(raw)
|
||||
return int(path.stat().st_mtime)
|
||||
|
||||
mtime = await hass.async_add_executor_job(_write)
|
||||
connection.send_result(
|
||||
msg["id"], {"ok": True, "url": f"{CONTENT_URL}/plans/_/{space_id}.{msg['ext']}?v={mtime}"}
|
||||
)
|
||||
await hass.async_add_executor_job(_write)
|
||||
connection.send_result(msg["id"], {"ok": True, "url": f"{CONTENT_URL}/plans/_/{name}"})
|
||||
|
||||
|
||||
def _plan_files(plans_dir: Path, space_id: str) -> list[Path]:
|
||||
"""Every plan file belonging to a space: the legacy flat name and versioned ones."""
|
||||
out: list[Path] = []
|
||||
if not plans_dir.is_dir():
|
||||
return out
|
||||
for item in plans_dir.iterdir():
|
||||
if not item.is_file():
|
||||
continue
|
||||
parts = item.name.split(".")
|
||||
# "<space>.<ext>" (legacy) or "<space>.<token>.<ext>"
|
||||
if len(parts) in (2, 3) and parts[0] == space_id and parts[-1].lower() in PLAN_EXTENSIONS:
|
||||
out.append(item)
|
||||
return out
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/plan/cleanup",
|
||||
vol.Required("space_id"): str,
|
||||
vol.Required("keep"): str,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_plan_cleanup(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
"""Drop superseded plan files for a space (review R2-1).
|
||||
|
||||
Called by the card ONLY after the config write that references `keep` has
|
||||
been accepted. Until then every previous file is still on disk, which is
|
||||
what makes a rejected save harmless. Deleting nothing is always a safe
|
||||
outcome here — the orphans are bounded by one per rejected upload and are
|
||||
collected by the next successful one.
|
||||
"""
|
||||
if not _check_write(hass, connection):
|
||||
connection.send_error(msg["id"], "unauthorized", "Only administrators may manage plans")
|
||||
return
|
||||
space_id = msg["space_id"]
|
||||
if not valid_space_id(space_id):
|
||||
connection.send_error(msg["id"], "invalid_space_id", "space_id: only [a-z0-9_-], up to 64 characters")
|
||||
return
|
||||
keep = sanitize_filename(msg["keep"])
|
||||
plans_dir = Path(hass.config.path(PLANS_DIR))
|
||||
|
||||
def _clean() -> int:
|
||||
removed = 0
|
||||
for item in _plan_files(plans_dir, space_id):
|
||||
if item.name == keep:
|
||||
continue
|
||||
try:
|
||||
item.unlink()
|
||||
removed += 1
|
||||
except OSError as err: # noqa: PERF203 — a stuck file must not fail the save
|
||||
_LOGGER.warning("House Plan: could not remove the old plan %s: %s", item, err)
|
||||
return removed
|
||||
|
||||
removed = await hass.async_add_executor_job(_clean)
|
||||
connection.send_result(msg["id"], {"ok": True, "removed": removed})
|
||||
|
||||
Reference in New Issue
Block a user