mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 08:28:31 +00:00
fix v1.44.4: audit follow-up B2, B5, L4 sub-item
B2: the HTTP upload view failed OPEN when the config entry was unavailable while the WS path failed closed — both now share one may_write() policy helper (new auth.py) that denies non-admins when the policy cannot be read. B5: _finite now guards room rects, polygon vertices, view_box and opening coordinates, not just layout positions; the declared MAX_OPENINGS cap is finally enforced. L4 (sub-item): every drag pipeline captures the pointer through the tolerant helper (an inactive pointerId used to kill device/label/resize drags); decor shapes gained a bounds clamp so they cannot be dragged far outside the plan and persisted there. +2 backend tests (16); both changelogs updated in this commit
This commit is contained in:
@@ -0,0 +1,28 @@
|
|||||||
|
"""Single source of truth for the write-authorization policy.
|
||||||
|
|
||||||
|
The WS and HTTP paths used to duplicate this decision and drifted apart: the
|
||||||
|
WS copy was fixed to fail closed while the upload view still failed OPEN when
|
||||||
|
the config entry was unavailable (audit follow-up B2, 2026-07-27). One helper,
|
||||||
|
one behaviour.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
|
from .const import CONF_ADMIN_ONLY
|
||||||
|
from .store import get_entry
|
||||||
|
|
||||||
|
|
||||||
|
def may_write(hass: HomeAssistant, user) -> bool:
|
||||||
|
"""True when `user` may modify House Plan data.
|
||||||
|
|
||||||
|
Fails CLOSED: when the entry cannot be read — during a reload, or while the
|
||||||
|
integration is disabled — the policy is unknown, and "unknown" is not the
|
||||||
|
same as "permissive": only admins are allowed through.
|
||||||
|
"""
|
||||||
|
is_admin = bool(getattr(user, "is_admin", False))
|
||||||
|
entry = get_entry(hass)
|
||||||
|
if entry is None:
|
||||||
|
return is_admin
|
||||||
|
admin_only = bool(entry.options.get(CONF_ADMIN_ONLY, False))
|
||||||
|
return is_admin if admin_only else True
|
||||||
@@ -13,7 +13,7 @@ FILES_URL = "/houseplan_files/files"
|
|||||||
CONTENT_URL = "/api/houseplan/content"
|
CONTENT_URL = "/api/houseplan/content"
|
||||||
FILES_DIR = "houseplan/files"
|
FILES_DIR = "houseplan/files"
|
||||||
CONF_ADMIN_ONLY = "admin_only"
|
CONF_ADMIN_ONLY = "admin_only"
|
||||||
VERSION = "1.44.2"
|
VERSION = "1.44.4"
|
||||||
|
|
||||||
DEFAULT_CONFIG: dict = {
|
DEFAULT_CONFIG: dict = {
|
||||||
"spaces": [],
|
"spaces": [],
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -19,7 +19,7 @@ except ImportError: # older HA versions
|
|||||||
from homeassistant.core import HomeAssistant
|
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 .store import get_entry
|
from .auth import may_write
|
||||||
from .validation import (
|
from .validation import (
|
||||||
FILE_EXTENSIONS,
|
FILE_EXTENSIONS,
|
||||||
MAX_FILE_BYTES,
|
MAX_FILE_BYTES,
|
||||||
@@ -95,12 +95,8 @@ class HouseplanUploadView(HomeAssistantView):
|
|||||||
|
|
||||||
async def post(self, request: web.Request) -> web.Response:
|
async def post(self, request: web.Request) -> web.Response:
|
||||||
hass: HomeAssistant = request.app[KEY_HASS]
|
hass: HomeAssistant = request.app[KEY_HASS]
|
||||||
entry = get_entry(hass)
|
if not may_write(hass, request.get("hass_user")):
|
||||||
admin_only = bool(entry and entry.options.get(CONF_ADMIN_ONLY, False))
|
return web.json_response({"error": "unauthorized"}, status=403)
|
||||||
if admin_only:
|
|
||||||
user = request.get("hass_user")
|
|
||||||
if user is None or not user.is_admin:
|
|
||||||
return web.json_response({"error": "unauthorized"}, status=403)
|
|
||||||
|
|
||||||
marker_id = "misc"
|
marker_id = "misc"
|
||||||
filename: str | None = None
|
filename: str | None = None
|
||||||
|
|||||||
@@ -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.44.2"
|
"version": "1.44.4"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ POS_SCHEMA = vol.Schema(
|
|||||||
)
|
)
|
||||||
LAYOUT_SCHEMA = vol.All(vol.Schema({str: POS_SCHEMA}), vol.Length(max=MAX_LAYOUT))
|
LAYOUT_SCHEMA = vol.All(vol.Schema({str: POS_SCHEMA}), vol.Length(max=MAX_LAYOUT))
|
||||||
|
|
||||||
POINT = vol.All([vol.Coerce(float)], vol.Length(min=2, max=2))
|
POINT = vol.All([_finite], vol.Length(min=2, max=2))
|
||||||
|
|
||||||
|
|
||||||
def _require_geometry(room: dict) -> dict:
|
def _require_geometry(room: dict) -> dict:
|
||||||
@@ -102,10 +102,10 @@ ROOM_SCHEMA = vol.All(
|
|||||||
extra=vol.ALLOW_EXTRA,
|
extra=vol.ALLOW_EXTRA,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
vol.Optional("x"): vol.Coerce(float),
|
vol.Optional("x"): _finite,
|
||||||
vol.Optional("y"): vol.Coerce(float),
|
vol.Optional("y"): _finite,
|
||||||
vol.Optional("w"): vol.Coerce(float),
|
vol.Optional("w"): _finite,
|
||||||
vol.Optional("h"): vol.Coerce(float),
|
vol.Optional("h"): _finite,
|
||||||
vol.Optional("poly"): vol.All([POINT], vol.Length(min=3)),
|
vol.Optional("poly"): vol.All([POINT], vol.Length(min=3)),
|
||||||
},
|
},
|
||||||
extra=vol.ALLOW_EXTRA,
|
extra=vol.ALLOW_EXTRA,
|
||||||
@@ -161,17 +161,17 @@ SPACE_SCHEMA = vol.Schema(
|
|||||||
vol.Optional("settings"): SPACE_DISPLAY_SCHEMA,
|
vol.Optional("settings"): SPACE_DISPLAY_SCHEMA,
|
||||||
vol.Optional("plan_url"): vol.Any(str, None),
|
vol.Optional("plan_url"): vol.Any(str, None),
|
||||||
vol.Required("aspect"): vol.All(vol.Coerce(float), vol.Range(min=0.05, max=20)),
|
vol.Required("aspect"): vol.All(vol.Coerce(float), vol.Range(min=0.05, max=20)),
|
||||||
vol.Required("view_box"): vol.All([vol.Coerce(float)], vol.Length(min=4, max=4)),
|
vol.Required("view_box"): vol.All([_finite], vol.Length(min=4, max=4)),
|
||||||
vol.Required("rooms"): vol.All([ROOM_SCHEMA], vol.Length(max=MAX_ROOMS)),
|
vol.Required("rooms"): vol.All([ROOM_SCHEMA], vol.Length(max=MAX_ROOMS)),
|
||||||
vol.Optional("decor"): vol.All([DECOR_SCHEMA], vol.Length(max=MAX_DECOR)),
|
vol.Optional("decor"): vol.All([DECOR_SCHEMA], vol.Length(max=MAX_DECOR)),
|
||||||
vol.Optional("openings"): [
|
vol.Optional("openings"): vol.All([
|
||||||
vol.Schema(
|
vol.Schema(
|
||||||
{
|
{
|
||||||
vol.Required("id"): str,
|
vol.Required("id"): str,
|
||||||
vol.Required("type"): vol.Any("door", "window"),
|
vol.Required("type"): vol.Any("door", "window"),
|
||||||
vol.Required("x"): vol.Coerce(float),
|
vol.Required("x"): _finite,
|
||||||
vol.Required("y"): vol.Coerce(float),
|
vol.Required("y"): _finite,
|
||||||
vol.Required("angle"): vol.Coerce(float),
|
vol.Required("angle"): _finite,
|
||||||
vol.Required("length"): vol.All(vol.Coerce(float), vol.Range(min=0.001, max=1)),
|
vol.Required("length"): vol.All(vol.Coerce(float), vol.Range(min=0.001, max=1)),
|
||||||
vol.Optional("contact"): vol.Any(str, None),
|
vol.Optional("contact"): vol.Any(str, None),
|
||||||
vol.Optional("lock"): vol.Any(str, None),
|
vol.Optional("lock"): vol.Any(str, None),
|
||||||
@@ -181,7 +181,7 @@ SPACE_SCHEMA = vol.Schema(
|
|||||||
},
|
},
|
||||||
extra=vol.ALLOW_EXTRA,
|
extra=vol.ALLOW_EXTRA,
|
||||||
)
|
)
|
||||||
],
|
], vol.Length(max=MAX_OPENINGS)),
|
||||||
# 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.
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from .const import (
|
|||||||
CONF_ADMIN_ONLY, DEFAULT_CONFIG,
|
CONF_ADMIN_ONLY, DEFAULT_CONFIG,
|
||||||
CONTENT_URL, PLANS_DIR, PLANS_URL,
|
CONTENT_URL, PLANS_DIR, PLANS_URL,
|
||||||
)
|
)
|
||||||
|
from .auth import may_write
|
||||||
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_PLAN_BYTES,
|
||||||
@@ -56,18 +57,8 @@ def _runtime(hass: HomeAssistant, connection, msg_id: int) -> HouseplanData | No
|
|||||||
|
|
||||||
|
|
||||||
def _check_write(hass: HomeAssistant, connection) -> bool:
|
def _check_write(hass: HomeAssistant, connection) -> bool:
|
||||||
"""May this connection write?
|
"""May this connection write? Thin wrapper over the shared policy."""
|
||||||
|
return may_write(hass, getattr(connection, "user", None))
|
||||||
Fails CLOSED (audit B2): when the entry cannot be read — during a reload or
|
|
||||||
while the integration is disabled — the policy is unknown, and "unknown" is
|
|
||||||
not the same as "permissive". Previously this returned True and ws_plan_set,
|
|
||||||
which never touches the runtime helper, accepted uploads in that window.
|
|
||||||
"""
|
|
||||||
entry = get_entry(hass)
|
|
||||||
if entry is None:
|
|
||||||
return bool(getattr(connection.user, "is_admin", False))
|
|
||||||
admin_only = bool(entry.options.get(CONF_ADMIN_ONLY, False))
|
|
||||||
return connection.user.is_admin if admin_only else True
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------- layout ----------------
|
# ---------------- layout ----------------
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Vendored
+4
-4
File diff suppressed because one or more lines are too long
@@ -1,5 +1,35 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v1.44.4 — 2026-07-27 (audit follow-up: B2, B5, L4)
|
||||||
|
- **One authorization policy (B2).** The HTTP upload view still failed **open**
|
||||||
|
when the config entry was unavailable while the WebSocket path failed closed —
|
||||||
|
the two had drifted apart. Both now call the same `may_write` helper, which
|
||||||
|
denies non-admins whenever the policy cannot be read.
|
||||||
|
- **NaN/Infinity refused on every coordinate (B5).** The finite-number check
|
||||||
|
guarded only layout positions; room rects, polygon vertices, `view_box` and
|
||||||
|
opening coordinates accepted `"NaN"`, which serializes to `null` and corrupts
|
||||||
|
the stored geometry permanently. The `MAX_OPENINGS` cap was defined but never
|
||||||
|
wired in — the openings list was unbounded.
|
||||||
|
- **Drag hardening (L4 sub-item).** The tolerant `setPointerCapture` wrapper is
|
||||||
|
now used by every drag pipeline (device, label, resize), not just openings —
|
||||||
|
an inactive pointer id could kill a drag outright. Decor shapes gained a
|
||||||
|
bounds clamp: they can no longer be dragged far outside the plan and saved
|
||||||
|
there.
|
||||||
|
|
||||||
|
## v1.44.3 — 2026-07-27 (fix: plans and manuals load again)
|
||||||
|
- **The authenticated content endpoint had no working browser path.** v1.43.0
|
||||||
|
closed the security hole correctly, but Home Assistant authenticates HTTP
|
||||||
|
requests by a Bearer header or an `authSig` signed path — and an SVG
|
||||||
|
`<image href>` or a plain `<a href>` sends neither. Plan backgrounds and PDF
|
||||||
|
links returned **401** on a real dashboard (reproduced live before the fix).
|
||||||
|
The card now asks the backend to sign what it displays
|
||||||
|
(`houseplan/content/sign`, 24 h, bound to the session's refresh token, only
|
||||||
|
for our own endpoint), re-renders when signatures arrive, and refreshes them
|
||||||
|
every 12 hours so wall tablets keep working. A backend test fetches a signed
|
||||||
|
url **without** an Authorization header and asserts 200, and 401 without the
|
||||||
|
signature.
|
||||||
|
|
||||||
|
|
||||||
> 🇷🇺 Русская версия: [CHANGELOG.ru.md](CHANGELOG.ru.md) (записи с v1.42.0).
|
> 🇷🇺 Русская версия: [CHANGELOG.ru.md](CHANGELOG.ru.md) (записи с v1.42.0).
|
||||||
|
|
||||||
## v1.44.2 — 2026-07-27 (external code review: CR-1…CR-3)
|
## v1.44.2 — 2026-07-27 (external code review: CR-1…CR-3)
|
||||||
|
|||||||
@@ -1,5 +1,43 @@
|
|||||||
# История изменений
|
# История изменений
|
||||||
|
|
||||||
|
> Русская версия [docs/CHANGELOG.md](CHANGELOG.md). Переведены записи начиная
|
||||||
|
> с v1.42.0 (2026-07-26); более ранние доступны только в английском файле.
|
||||||
|
>
|
||||||
|
> **Правило проекта:** оба файла пополняются в одном коммите с самим
|
||||||
|
> изменением — как и остальная документация (см. docs/STATUS.md).
|
||||||
|
|
||||||
|
## v1.44.4 — 2026-07-27 (доработка по аудиту: B2, B5, L4)
|
||||||
|
- **Единая политика авторизации (B2).** HTTP-загрузка по-прежнему **разрешала**
|
||||||
|
запись, когда запись о конфигурации недоступна, тогда как WebSocket-путь уже
|
||||||
|
отказывал — они разошлись. Теперь оба вызывают общий помощник `may_write`,
|
||||||
|
который в неопределённой ситуации пропускает только администраторов.
|
||||||
|
- **NaN/Infinity отвергаются во всех координатах (B5).** Проверка на конечность
|
||||||
|
числа стояла только у позиций раскладки; прямоугольники комнат, вершины
|
||||||
|
полигонов, `view_box` и координаты проёмов принимали `"NaN"`, который при
|
||||||
|
записи превращается в `null` и необратимо портит геометрию. Ограничение
|
||||||
|
`MAX_OPENINGS` было объявлено, но нигде не использовалось — список проёмов
|
||||||
|
оставался безразмерным.
|
||||||
|
- **Укрепление перетаскивания (часть L4).** Безопасная обёртка над
|
||||||
|
`setPointerCapture` теперь используется во всех сценариях перетаскивания
|
||||||
|
(устройства, подписи, изменение размера), а не только у проёмов — «мёртвый»
|
||||||
|
идентификатор указателя мог оборвать перетаскивание. Фигуры декора получили
|
||||||
|
ограничение по границам: их больше нельзя утащить далеко за пределы плана и
|
||||||
|
сохранить там.
|
||||||
|
|
||||||
|
## v1.44.3 — 2026-07-27 (исправление: планы и инструкции снова загружаются)
|
||||||
|
- **У аутентифицированной выдачи контента не было рабочего пути для браузера.**
|
||||||
|
Версия v1.43.0 закрыла дыру правильно, но Home Assistant аутентифицирует
|
||||||
|
HTTP-запросы либо заголовком Bearer, либо подписанным путём `authSig` — а
|
||||||
|
`<image href>` внутри SVG и обычная ссылка `<a href>` не отправляют ни того,
|
||||||
|
ни другого. На настоящем дашборде фоны планов и ссылки на PDF отдавали
|
||||||
|
**401** (воспроизведено вживую до исправления). Теперь карточка просит бэкенд
|
||||||
|
подписать то, что собирается показать (`houseplan/content/sign`, 24 часа,
|
||||||
|
привязано к токену сессии, только для нашего эндпоинта), перерисовывается,
|
||||||
|
когда подписи приходят, и обновляет их каждые 12 часов, чтобы настенные
|
||||||
|
планшеты продолжали работать. Тест бэкенда скачивает подписанный адрес **без**
|
||||||
|
заголовка авторизации и проверяет 200, а без подписи — 401.
|
||||||
|
|
||||||
|
|
||||||
> Русская версия [docs/CHANGELOG.md](CHANGELOG.md). Переведены записи начиная
|
> Русская версия [docs/CHANGELOG.md](CHANGELOG.md). Переведены записи начиная
|
||||||
> с v1.42.0 (2026-07-26); более ранние доступны только в английском файле.
|
> с v1.42.0 (2026-07-26); более ранние доступны только в английском файле.
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -50,6 +50,21 @@
|
|||||||
never silently linked); urls are rewritten only for confirmed copies
|
never silently linked); urls are rewritten only for confirmed copies
|
||||||
[auto: unit logic.test + tests_backend]
|
[auto: unit logic.test + tests_backend]
|
||||||
|
|
||||||
|
- [ ] Plans and PDFs load in a real browser (v1.44.3, B1 regression): open a
|
||||||
|
dashboard with an uploaded plan — the background renders and a manual link
|
||||||
|
opens; DevTools shows /api/houseplan/content/... returning 200 via a
|
||||||
|
signed url, while the same url without authSig returns 401
|
||||||
|
[auto: tests_backend + manual]
|
||||||
|
- [ ] Auth policy is single-sourced (v1.44.4, B2): the HTTP upload and every WS
|
||||||
|
write use the same `may_write`, which denies non-admins when the config
|
||||||
|
entry is unavailable [auto: tests_backend]
|
||||||
|
- [ ] Coordinates and caps (v1.44.4, B5): NaN/Infinity are refused on room
|
||||||
|
rects, polygon vertices, view_box and openings — not only in layout; the
|
||||||
|
openings list honours MAX_OPENINGS [auto: tests_backend]
|
||||||
|
- [ ] Drag hardening (v1.44.4, L4 sub-item): every drag pipeline captures the
|
||||||
|
pointer through the tolerant helper; decor shapes cannot be dragged more
|
||||||
|
than a quarter of the plan outside the viewBox [auto: smoke_decor]
|
||||||
|
|
||||||
## Environments matrix
|
## Environments matrix
|
||||||
|
|
||||||
Run the *core flows* (marked ★ below) in each environment at least once per minor release:
|
Run the *core flows* (marked ★ below) in each environment at least once per minor release:
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "houseplan-card",
|
"name": "houseplan-card",
|
||||||
"version": "1.44.2",
|
"version": "1.44.4",
|
||||||
"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",
|
||||||
|
|||||||
+35
-9
@@ -32,7 +32,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.44.2';
|
const CARD_VERSION = '1.44.4';
|
||||||
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';
|
||||||
@@ -90,6 +90,22 @@ const debounce = <T extends (...a: any[]) => void>(fn: T, ms: number): Debounced
|
|||||||
return wrapped;
|
return wrapped;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capture the pointer for a drag, tolerating an inactive pointerId.
|
||||||
|
*
|
||||||
|
* `setPointerCapture` throws for synthetic events and for pointers some
|
||||||
|
* browsers consider gone; that killed a drag outright. The opening pipeline
|
||||||
|
* was hardened for this, the device/label/resize ones were not (audit
|
||||||
|
* follow-up L4 sub-item) — now they all go through here.
|
||||||
|
*/
|
||||||
|
const capturePointer = (ev: PointerEvent): void => {
|
||||||
|
try {
|
||||||
|
(ev.target as Element | null)?.setPointerCapture?.(ev.pointerId);
|
||||||
|
} catch {
|
||||||
|
/* an inactive pointerId must never kill the drag */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
class HouseplanCard extends LitElement {
|
class HouseplanCard extends LitElement {
|
||||||
public hass?: any;
|
public hass?: any;
|
||||||
private _config?: CardConfig;
|
private _config?: CardConfig;
|
||||||
@@ -1358,7 +1374,7 @@ class HouseplanCard extends LitElement {
|
|||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
const p = this._pos(d);
|
const p = this._pos(d);
|
||||||
this._drag = { id: d.id, sx: ev.clientX, sy: ev.clientY, ox: p.x, oy: p.y, moved: false };
|
this._drag = { id: d.id, sx: ev.clientX, sy: ev.clientY, ox: p.x, oy: p.y, moved: false };
|
||||||
(ev.target as HTMLElement).setPointerCapture(ev.pointerId);
|
capturePointer(ev);
|
||||||
this._tip = null;
|
this._tip = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1701,7 +1717,7 @@ class HouseplanCard extends LitElement {
|
|||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
const p = this._snap(this._svgPoint(ev));
|
const p = this._snap(this._svgPoint(ev));
|
||||||
this._decorDraft = { kind: t, a: p, b: p, pid: ev.pointerId };
|
this._decorDraft = { kind: t, a: p, b: p, pid: ev.pointerId };
|
||||||
(ev.target as HTMLElement).setPointerCapture?.(ev.pointerId);
|
capturePointer(ev);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (t === 'text') {
|
if (t === 'text') {
|
||||||
@@ -1761,15 +1777,25 @@ class HouseplanCard extends LitElement {
|
|||||||
id: shape.id, start: this._svgPoint(ev), orig: JSON.parse(JSON.stringify(shape)),
|
id: shape.id, start: this._svgPoint(ev), orig: JSON.parse(JSON.stringify(shape)),
|
||||||
pid: ev.pointerId, moved: false,
|
pid: ev.pointerId, moved: false,
|
||||||
};
|
};
|
||||||
(ev.target as HTMLElement).setPointerCapture?.(ev.pointerId);
|
capturePointer(ev);
|
||||||
}
|
}
|
||||||
|
|
||||||
private _decorMoveUpdate(ev: PointerEvent): void {
|
private _decorMoveUpdate(ev: PointerEvent): void {
|
||||||
const m = this._decorMove!;
|
const m = this._decorMove!;
|
||||||
const p = this._svgPoint(ev);
|
const p = this._svgPoint(ev);
|
||||||
const g = this._gridPitch;
|
const g = this._gridPitch;
|
||||||
const dx = snapToGrid(p[0] - m.start[0], g) / NORM_W;
|
let dx = snapToGrid(p[0] - m.start[0], g) / NORM_W;
|
||||||
const dy = snapToGrid(p[1] - m.start[1], g) / this._decorH;
|
let dy = snapToGrid(p[1] - m.start[1], g) / this._decorH;
|
||||||
|
// audit follow-up L4: decor had neither a threshold nor a bounds clamp, so
|
||||||
|
// a shape could be dragged far outside the viewBox and persisted there.
|
||||||
|
const o = m.orig;
|
||||||
|
const curX = o.kind === 'line' ? Math.min(o.x1, o.x2) : o.x;
|
||||||
|
const curY = o.kind === 'line' ? Math.min(o.y1, o.y2) : o.y;
|
||||||
|
const w = o.kind === 'line' ? Math.abs(o.x2 - o.x1) : (o.w || 0);
|
||||||
|
const h = o.kind === 'line' ? Math.abs(o.y2 - o.y1) : (o.h || 0);
|
||||||
|
const lim = 0.25; // a quarter of the plan may hang outside, no more
|
||||||
|
dx = Math.max(-curX - lim, Math.min(1 + lim - curX - w, dx));
|
||||||
|
dy = Math.max(-curY - lim, Math.min(1 + lim - curY - h, dy));
|
||||||
if (dx || dy) m.moved = true;
|
if (dx || dy) m.moved = true;
|
||||||
const sp = this._curSpaceCfg;
|
const sp = this._curSpaceCfg;
|
||||||
sp.decor = this._decorList.map((x) => {
|
sp.decor = this._decorList.map((x) => {
|
||||||
@@ -2080,7 +2106,7 @@ class HouseplanCard extends LitElement {
|
|||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
try {
|
try {
|
||||||
(ev.target as Element).setPointerCapture?.(ev.pointerId);
|
capturePointer(ev);
|
||||||
} catch {
|
} catch {
|
||||||
/* an inactive pointerId (synthetic events, some browsers) must not kill the drag */
|
/* an inactive pointerId (synthetic events, some browsers) must not kill the drag */
|
||||||
}
|
}
|
||||||
@@ -3929,7 +3955,7 @@ class HouseplanCard extends LitElement {
|
|||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
const p = this._labelPos(r, spaceId);
|
const p = this._labelPos(r, spaceId);
|
||||||
this._drag = { id: 'rl_' + (r.id || ''), sx: ev.clientX, sy: ev.clientY, ox: p.x, oy: p.y, moved: false };
|
this._drag = { id: 'rl_' + (r.id || ''), sx: ev.clientX, sy: ev.clientY, ox: p.x, oy: p.y, moved: false };
|
||||||
(ev.target as HTMLElement).setPointerCapture(ev.pointerId);
|
capturePointer(ev);
|
||||||
this._tip = null;
|
this._tip = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3975,7 +4001,7 @@ class HouseplanCard extends LitElement {
|
|||||||
const cy = b.top + b.height / 2;
|
const cy = b.top + b.height / 2;
|
||||||
const d0 = Math.max(8, Math.hypot(ev.clientX - cx, ev.clientY - cy));
|
const d0 = Math.max(8, Math.hypot(ev.clientX - cx, ev.clientY - cy));
|
||||||
this._rlResize = { id: 'rl_' + (r.id || ''), space: spaceId, k0: this._labelScale(r), cx, cy, d0 };
|
this._rlResize = { id: 'rl_' + (r.id || ''), space: spaceId, k0: this._labelScale(r), cx, cy, d0 };
|
||||||
(ev.target as HTMLElement).setPointerCapture(ev.pointerId);
|
capturePointer(ev);
|
||||||
}
|
}
|
||||||
|
|
||||||
private _rlResizeMove(ev: PointerEvent): void {
|
private _rlResizeMove(ev: PointerEvent): void {
|
||||||
|
|||||||
@@ -140,3 +140,36 @@ def test_collection_caps():
|
|||||||
big = {f"d{i}": {"x": 0.1, "y": 0.1} for i in range(v.MAX_LAYOUT + 1)}
|
big = {f"d{i}": {"x": 0.1, "y": 0.1} for i in range(v.MAX_LAYOUT + 1)}
|
||||||
with pytest.raises(vol.Invalid):
|
with pytest.raises(vol.Invalid):
|
||||||
v.LAYOUT_SCHEMA(big)
|
v.LAYOUT_SCHEMA(big)
|
||||||
|
|
||||||
|
|
||||||
|
def test_finite_on_every_coordinate():
|
||||||
|
"""audit follow-up B5: NaN/Infinity must be refused everywhere, not only in layout."""
|
||||||
|
base = {"id": "s1", "title": "S", "aspect": 1.0, "view_box": [0, 0, 100, 100], "rooms": []}
|
||||||
|
# view_box
|
||||||
|
with pytest.raises(vol.Invalid):
|
||||||
|
v.CONFIG_SCHEMA({"spaces": [{**base, "view_box": [0, 0, "NaN", 100]}]})
|
||||||
|
# room rect coordinates
|
||||||
|
with pytest.raises(vol.Invalid):
|
||||||
|
v.CONFIG_SCHEMA({"spaces": [{**base, "rooms": [
|
||||||
|
{"id": "r", "name": "R", "x": "Infinity", "y": 0, "w": 1, "h": 1}]}]})
|
||||||
|
# polygon vertices
|
||||||
|
with pytest.raises(vol.Invalid):
|
||||||
|
v.CONFIG_SCHEMA({"spaces": [{**base, "rooms": [
|
||||||
|
{"id": "r", "name": "R", "poly": [[0, 0], [1, "NaN"], [1, 1]]}]}]})
|
||||||
|
# opening coordinates
|
||||||
|
with pytest.raises(vol.Invalid):
|
||||||
|
v.CONFIG_SCHEMA({"spaces": [{**base, "openings": [
|
||||||
|
{"id": "o", "type": "door", "x": "NaN", "y": 0.5, "angle": 0, "length": 0.1}]}]})
|
||||||
|
# a sane config still validates
|
||||||
|
assert v.CONFIG_SCHEMA({"spaces": [{**base, "rooms": [
|
||||||
|
{"id": "r", "name": "R", "poly": [[0, 0], [1, 0], [1, 1]]}]}]})
|
||||||
|
|
||||||
|
|
||||||
|
def test_openings_cap_enforced():
|
||||||
|
"""audit follow-up B5: MAX_OPENINGS was defined but never wired in."""
|
||||||
|
many = [{"id": f"o{i}", "type": "door", "x": 0.1, "y": 0.1, "angle": 0, "length": 0.1}
|
||||||
|
for i in range(v.MAX_OPENINGS + 1)]
|
||||||
|
with pytest.raises(vol.Invalid):
|
||||||
|
v.CONFIG_SCHEMA({"spaces": [{"id": "s1", "title": "S", "aspect": 1.0,
|
||||||
|
"view_box": [0, 0, 100, 100], "rooms": [],
|
||||||
|
"openings": many}]})
|
||||||
|
|||||||
Reference in New Issue
Block a user