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
+10 -4
View File
@@ -47,11 +47,17 @@ async function fetchFresh(hass: any): Promise<HpConfigSnapshot> {
};
if (!subscribed && hass.connection?.subscribeEvents) {
subscribed = true;
const invalidate = () => {
cache = null; // invalidate; listeners reload
listeners.forEach((l) => l());
};
try {
await hass.connection.subscribeEvents(() => {
cache = null; // invalidate; listeners reload
listeners.forEach((l) => l());
}, 'houseplan_config_updated');
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;
}
+94 -53
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 copy→save→cleanup 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,24 +1599,16 @@ 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;
if (e?.code === 'conflict') {
this._showToast(this._t('toast.conflict'));
this._cancelPath();
this._reloadConfigOnly(true);
} else {
this._showToast(this._t('toast.cfg_save_failed', { err: this._errText(e) }));
}
});
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);
} else {
this._showToast(this._t('toast.cfg_save_failed', { err: this._errText(e) }));
}
});
}, 500);
/**
@@ -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),