mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 08:28:31 +00:00
v1.45.1: follow-up review of v1.45.0 — R3-1, R3-2
R3-1 (high): v1.45.0 made the upload safe but left deletion to the client — after a successful save the card asked the backend to remove everything but the file it had just committed. Two open editors cannot be ordered: a delayed request from one deleted the plan the other had just saved, leaving the accepted configuration pointing at nothing, the exact damage copy-on-write was introduced to prevent. houseplan/plan/cleanup is removed. config/set collects inside its own write lock from the two configurations that bracket the commit (plans.collect_plans): a file the old revision referenced and the new one does not is superseded and goes; any other unreferenced upload waits out PLAN_ORPHAN_TTL_S, because a fresh one may belong to a transaction that has not committed yet. The collector lives in a pure module so it can be reasoned about and unit-tested without the HA harness. R3-2: houseplan-space-card signed its plan url and threw the result away — getCardSize() mutated a throwaway model while render() rebuilt its own from the config, so the <image> requested the protected path and got 401 on every render. Both cards now share ContentSigner (src/signing.ts), which also gives the static card batching, expiry handling and periodic re-signing. is released in finally: one failed request no longer wedges a url for the life of the page. Tests: five backend interleaving cases from the report, six unit tests for the pure collector, smoke_space_card_bg (verified to fail against a v1.45.0 build: the raw url reaches the DOM and no retry happens). 57 smokes, 124 unit, 22 backend-pure. Docs: CHANGELOG.md + CHANGELOG.ru.md + ARCHITECTURE.md + TESTING.md + STATUS.md.
This commit is contained in:
+14
-95
@@ -21,8 +21,9 @@ import {
|
||||
spaceDisplayOf, roomFillStyle, fillColorsOf, DEFAULT_FILL_COLORS, type FillColors,
|
||||
isActiveState, DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY,
|
||||
DEFAULT_TEMP_MIN, DEFAULT_TEMP_MAX, type SpaceDisplay,
|
||||
MAX_SIGN_PATHS, SIGN_TTL_MS, SIGN_REFRESH_MS, chunk, referencedContentUrls,
|
||||
referencedContentUrls,
|
||||
} from './logic';
|
||||
import { ContentSigner } from './signing';
|
||||
import { buildDevices, lqiFor, tempFor, humFor, isHumEntity, areaLights, areaTemp, areaHum, areaLightStats, sourceValue, areaClimateMap, type AreaClimate } from './devices';
|
||||
import type {
|
||||
OpeningCfg,
|
||||
@@ -33,7 +34,7 @@ import './space-card';
|
||||
import { cardStyles } from './styles';
|
||||
import { langOf, t, type I18nKey } from './i18n';
|
||||
|
||||
const CARD_VERSION = '1.45.0';
|
||||
const CARD_VERSION = '1.45.1';
|
||||
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';
|
||||
@@ -366,8 +367,7 @@ class HouseplanCard extends LitElement {
|
||||
super.connectedCallback();
|
||||
window.addEventListener('keydown', this._keyHandler);
|
||||
// signatures expire (24 h); refresh well before that on long-lived screens
|
||||
clearInterval(this._resignTimer);
|
||||
this._resignTimer = window.setInterval(() => this._resign(), 12 * 3600 * 1000);
|
||||
this._signer.start(() => this.hass, () => referencedContentUrls(this._serverCfg));
|
||||
if (this._config?.kiosk && Number(this._config?.cycle) > 0) {
|
||||
clearInterval(this._cycleTimer);
|
||||
this._cycleTimer = window.setInterval(() => this._cycleTick(), Number(this._config.cycle) * 1000);
|
||||
@@ -381,8 +381,7 @@ class HouseplanCard extends LitElement {
|
||||
clearTimeout(this._kioskDotsTimer);
|
||||
clearTimeout(this._kioskHoldTimer);
|
||||
clearTimeout(this._reloadRetry);
|
||||
clearTimeout(this._signTimer);
|
||||
clearInterval(this._resignTimer);
|
||||
this._signer.dispose();
|
||||
clearTimeout(this._toastTimer);
|
||||
this._saveConfigDebounced.flush(); // never leave an edit unsent on teardown
|
||||
window.removeEventListener('hashchange', this._onHashChange);
|
||||
@@ -789,85 +788,18 @@ class HouseplanCard extends LitElement {
|
||||
* Bearer header or an `authSig` signed path, and an element sends neither.
|
||||
* So the card asks the backend to sign what it is about to display.
|
||||
*/
|
||||
private _signed: Record<string, { url: string; at: number }> = {};
|
||||
private _signPending = new Set<string>();
|
||||
private _signTimer?: number;
|
||||
private _signer = new ContentSigner(() => this.requestUpdate());
|
||||
|
||||
/** Display url: a signature we hold and still trust, else nothing. */
|
||||
private _display(url: string | null | undefined): string {
|
||||
const u = contentUrl(url);
|
||||
if (!u.startsWith('/api/houseplan/content/')) return u;
|
||||
const hit = this._signed[u];
|
||||
const age = hit ? Date.now() - hit.at : Infinity;
|
||||
// Past its lifetime the signature is worthless: serving it would 401 and
|
||||
// raise a failed-login warning, exactly what an unsigned path does.
|
||||
if (age >= SIGN_TTL_MS) delete this._signed[u];
|
||||
else if (age < SIGN_REFRESH_MS) return hit.url;
|
||||
else {
|
||||
// aging but still valid: keep showing it while a fresh one is fetched
|
||||
this._requestSignature(u);
|
||||
return hit.url;
|
||||
}
|
||||
this._requestSignature(u);
|
||||
// Empty, NOT the plain path: an unsigned request to a `requires_auth` view
|
||||
// returns 401 and Home Assistant raises a "failed login attempt" for the
|
||||
// viewer's own IP. Callers skip rendering until the signature lands.
|
||||
return '';
|
||||
return this._signer.display(this.hass, url);
|
||||
}
|
||||
|
||||
private _requestSignature(url: string): void {
|
||||
if (this._signPending.has(url) || !this.hass?.callWS) return;
|
||||
this._signPending.add(url);
|
||||
clearTimeout(this._signTimer);
|
||||
// batch: a plan switch asks for several urls in the same tick
|
||||
this._signTimer = window.setTimeout(() => {
|
||||
const paths = [...this._signPending];
|
||||
this._signPending.clear();
|
||||
this._signBatches(paths);
|
||||
}, 30);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign in batches the backend will actually answer in full. It caps a request
|
||||
* at MAX_SIGN_PATHS and drops the rest without saying so, so one oversized
|
||||
* call leaves entries that never get refreshed and silently expire (R2-2).
|
||||
*/
|
||||
private _signBatches(paths: string[]): void {
|
||||
if (!paths.length || !this.hass?.callWS) return;
|
||||
for (const batch of chunk(paths, MAX_SIGN_PATHS)) {
|
||||
this.hass
|
||||
.callWS({ type: 'houseplan/content/sign', paths: batch })
|
||||
.then((r: any) => {
|
||||
if (!r?.urls) return;
|
||||
const now = Date.now();
|
||||
const next = { ...this._signed };
|
||||
for (const [k, v] of Object.entries<string>(r.urls)) next[k] = { url: v, at: now };
|
||||
this._signed = next;
|
||||
this.requestUpdate();
|
||||
})
|
||||
.catch(() => undefined); // unsigned urls simply keep failing; no loop
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-sign periodically: a wall tablet outlives a signature.
|
||||
* The old urls are kept until the new ones arrive — dropping them first would
|
||||
* blank the plan for a round trip (and, if the socket is down, until it heals).
|
||||
*/
|
||||
private _resignTimer?: number;
|
||||
|
||||
/** Re-sign what the live config still references (wall tablets outlive one). */
|
||||
private _resign(): void {
|
||||
// prune first: an entry for a plan that was replaced months ago must not
|
||||
// consume a slot in the (capped) signing request
|
||||
const live = referencedContentUrls(this._serverCfg);
|
||||
const now = Date.now();
|
||||
const kept: Record<string, { url: string; at: number }> = {};
|
||||
for (const [k, v] of Object.entries(this._signed)) {
|
||||
if (live.has(k) && now - v.at < SIGN_TTL_MS) kept[k] = v;
|
||||
}
|
||||
this._signed = kept;
|
||||
this._signBatches(Object.keys(kept));
|
||||
this._signer.resign(this.hass, referencedContentUrls(this._serverCfg));
|
||||
}
|
||||
|
||||
private _dirtyPos = new Set<string>();
|
||||
|
||||
private _persistLayout = debounce(() => {
|
||||
@@ -3057,11 +2989,11 @@ class HouseplanCard extends LitElement {
|
||||
label_light: d.labelLight,
|
||||
};
|
||||
sp.cell_cm = Number.isFinite(d.cellCm) && d.cellCm > 0 ? d.cellCm : 5;
|
||||
// Nothing to clean up from here: the backend collects the superseded
|
||||
// file inside the same locked transaction that accepted this config
|
||||
// (review R3-1). A cleanup driven from the client could not be ordered
|
||||
// against another client's commit and deleted its freshly saved plan.
|
||||
await this._saveConfigNow();
|
||||
// Only now is the new file authoritative: the config write was accepted,
|
||||
// so the previous plan can go. Before this point nothing on disk was
|
||||
// touched, which is what makes a rejected save harmless (review R2-1).
|
||||
if (uploaded) this._cleanupPlanFiles(spaceId, uploaded.url);
|
||||
this._spaceDialog = null;
|
||||
if (d.mode === 'create') this._space = sp.id;
|
||||
this._regSignature = '';
|
||||
@@ -3138,19 +3070,6 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Remove plan files superseded by `keepUrl`. Fire-and-forget on purpose: the
|
||||
* user's edit is already saved, and a failed cleanup only leaves a stray file
|
||||
* that the next successful upload collects (review R2-1).
|
||||
*/
|
||||
private _cleanupPlanFiles(spaceId: string, keepUrl: string): void {
|
||||
const keep = keepUrl.split('?')[0].split('/').pop();
|
||||
if (!keep || !this.hass?.callWS) return;
|
||||
this.hass
|
||||
.callWS({ type: 'houseplan/plan/cleanup', space_id: spaceId, keep })
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
// ================= FLOORS IMPORT WIZARD =================
|
||||
|
||||
private _startImport(): void {
|
||||
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
import { contentUrl, chunk, MAX_SIGN_PATHS, SIGN_TTL_MS, SIGN_REFRESH_MS } from './logic';
|
||||
|
||||
/**
|
||||
* Signed urls for the authenticated content endpoint, shared by both cards.
|
||||
*
|
||||
* A browser cannot authenticate an `<image href>` or an `<a href>`: Home
|
||||
* Assistant takes a Bearer header or an `authSig` signed path, and an element
|
||||
* sends neither. So whatever is about to be displayed has to be signed first.
|
||||
*
|
||||
* This used to be implemented twice — and the second copy (houseplan-space-card)
|
||||
* signed correctly but never handed the result to its renderer, so the plan
|
||||
* background asked for the raw protected url and got a 401 on every render
|
||||
* (review R3-2). One implementation, one set of rules:
|
||||
*
|
||||
* - requests are chunked to MAX_SIGN_PATHS, the cap the backend silently
|
||||
* applies (an oversized call comes back partial with no way to tell what was
|
||||
* dropped, and those entries then expire for good);
|
||||
* - every entry carries the time it was issued: past SIGN_REFRESH_MS a
|
||||
* replacement is fetched while the old url keeps rendering, past SIGN_TTL_MS
|
||||
* the entry is dropped rather than served (it would 401 and raise a
|
||||
* failed-login warning for the viewer's own IP);
|
||||
* - `pending` is always released, so one failed request does not wedge a url
|
||||
* forever.
|
||||
*/
|
||||
export class ContentSigner {
|
||||
private signed: Record<string, { url: string; at: number }> = {};
|
||||
private pending = new Set<string>();
|
||||
private batchTimer?: ReturnType<typeof setTimeout>;
|
||||
private resignTimer?: ReturnType<typeof setInterval>;
|
||||
|
||||
/**
|
||||
* @param onUpdate schedule a re-render (a signature arriving changes the DOM)
|
||||
* @param now injectable clock — the tests need to age a signature
|
||||
*/
|
||||
constructor(private onUpdate: () => void, private now: () => number = () => Date.now()) {}
|
||||
|
||||
/** Start the periodic re-sign. `referenced` prunes the cache on each tick. */
|
||||
start(hass: () => any, referenced: () => Set<string>): void {
|
||||
this.stopTimer();
|
||||
this.resignTimer = setInterval(() => this.resign(hass(), referenced()), SIGN_REFRESH_MS / 2);
|
||||
}
|
||||
|
||||
/** Release every timer; the cache survives a reconnect, the timers must not. */
|
||||
dispose(): void {
|
||||
this.stopTimer();
|
||||
clearTimeout(this.batchTimer);
|
||||
this.pending.clear();
|
||||
}
|
||||
|
||||
private stopTimer(): void {
|
||||
if (this.resignTimer !== undefined) clearInterval(this.resignTimer);
|
||||
this.resignTimer = undefined;
|
||||
}
|
||||
|
||||
/** The url to put in the DOM: a signature we hold and still trust, else ''. */
|
||||
display(hass: any, url: string | null | undefined): string {
|
||||
const u = contentUrl(url);
|
||||
if (!u.startsWith('/api/houseplan/content/')) return u;
|
||||
const hit = this.signed[u];
|
||||
const age = hit ? this.now() - hit.at : Infinity;
|
||||
if (age < SIGN_REFRESH_MS) return hit.url;
|
||||
if (age < SIGN_TTL_MS) {
|
||||
// aging but still valid: keep showing it while a fresh one is fetched
|
||||
this.request(hass, u);
|
||||
return hit.url;
|
||||
}
|
||||
if (hit) delete this.signed[u];
|
||||
this.request(hass, u);
|
||||
// Empty, NOT the plain path: an unsigned request to a `requires_auth` view
|
||||
// returns 401 and Home Assistant raises a "failed login attempt".
|
||||
return '';
|
||||
}
|
||||
|
||||
private request(hass: any, url: string): void {
|
||||
if (this.pending.has(url) || !hass?.callWS) return;
|
||||
this.pending.add(url);
|
||||
clearTimeout(this.batchTimer);
|
||||
// batch: switching space asks for several urls in the same tick
|
||||
this.batchTimer = setTimeout(() => {
|
||||
const paths = [...this.pending];
|
||||
this.pending.clear();
|
||||
this.sign(hass, paths);
|
||||
}, 30);
|
||||
}
|
||||
|
||||
private sign(hass: any, paths: string[]): void {
|
||||
if (!paths.length || !hass?.callWS) return;
|
||||
for (const batch of chunk(paths, MAX_SIGN_PATHS)) {
|
||||
hass
|
||||
.callWS({ type: 'houseplan/content/sign', paths: batch })
|
||||
.then((r: any) => {
|
||||
if (!r?.urls) return;
|
||||
const at = this.now();
|
||||
const next = { ...this.signed };
|
||||
for (const [k, v] of Object.entries<string>(r.urls)) next[k] = { url: v, at };
|
||||
this.signed = next;
|
||||
this.onUpdate();
|
||||
})
|
||||
.catch(() => undefined) // a retry happens on the next render
|
||||
.finally(() => {
|
||||
// never leave a url wedged in `pending`: the first failure would
|
||||
// otherwise make it unrequestable for the life of the page (R3-2)
|
||||
for (const p of batch) this.pending.delete(p);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-sign what is still in use. A wall tablet outlives a signature, and an
|
||||
* entry for a plan replaced months ago must not consume a slot in the capped
|
||||
* request — so prune to the urls the live config still references.
|
||||
*/
|
||||
resign(hass: any, referenced: Set<string>): void {
|
||||
const now = this.now();
|
||||
const kept: Record<string, { url: string; at: number }> = {};
|
||||
for (const [k, v] of Object.entries(this.signed)) {
|
||||
if (referenced.has(k) && now - v.at < SIGN_TTL_MS) kept[k] = v;
|
||||
}
|
||||
this.signed = kept;
|
||||
this.sign(hass, Object.keys(kept));
|
||||
}
|
||||
|
||||
/** Test/debug view of the cache. */
|
||||
get entries(): Record<string, { url: string; at: number }> {
|
||||
return this.signed;
|
||||
}
|
||||
}
|
||||
+16
-20
@@ -10,6 +10,8 @@ import { cardStyles } from './styles';
|
||||
import { renderSpaceStatic, spaceModels } from './space-render';
|
||||
import { getConfig, onConfigChange, cachedSnapshot, type HpConfigSnapshot } from './config-store';
|
||||
import { t, langOf, type Lang } from './i18n';
|
||||
import { ContentSigner } from './signing';
|
||||
import { referencedContentUrls } from './logic';
|
||||
import './space-editor';
|
||||
|
||||
const fireEvent = (node: EventTarget, type: string, detail?: unknown) => {
|
||||
@@ -73,11 +75,14 @@ class HouseplanSpaceCard extends LitElement {
|
||||
this._snap = null;
|
||||
this.requestUpdate();
|
||||
});
|
||||
// a dashboard on a wall tablet outlives a 24 h signature
|
||||
this._signer.start(() => this.hass, () => this._referenced());
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
this._unsub?.();
|
||||
this._unsub = undefined;
|
||||
this._signer.dispose();
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
@@ -109,8 +114,6 @@ class HouseplanSpaceCard extends LitElement {
|
||||
|
||||
public getCardSize(): number {
|
||||
const models = spaceModels(this._snap?.config || null);
|
||||
// B1 follow-up: the plan <image> needs a signed url in a browser session
|
||||
for (const m of models) if (m.bg?.href) this._signBg(m.bg);
|
||||
const sp = models.find((s) => s.id === this._config?.space);
|
||||
if (sp) {
|
||||
const ratio = sp.vb[3] / sp.vb[2]; // h/w
|
||||
@@ -123,25 +126,16 @@ class HouseplanSpaceCard extends LitElement {
|
||||
return html`<ha-card><div class="hp-static-error">${msg}</div></ha-card>`;
|
||||
}
|
||||
|
||||
private _signedBg: Record<string, string> = {};
|
||||
private _signBgPending = new Set<string>();
|
||||
/**
|
||||
* Same signer as the main card (review R3-2). The previous copy here signed
|
||||
* the url and then threw it away: `getCardSize()` mutated a throwaway model
|
||||
* while `render()` rebuilt its own from the config, so the <image> kept
|
||||
* asking for the raw protected path and got a 401 on every render.
|
||||
*/
|
||||
private _signer = new ContentSigner(() => this.requestUpdate());
|
||||
|
||||
/** Ask the backend for a signed content url and swap it in when it arrives. */
|
||||
private _signBg(bg: { href: string }): void {
|
||||
const raw = bg.href.split('?authSig=')[0];
|
||||
if (!raw.startsWith('/api/houseplan/content/')) return;
|
||||
if (this._signedBg[raw]) { bg.href = this._signedBg[raw]; return; }
|
||||
if (this._signBgPending.has(raw) || !this.hass?.callWS) return;
|
||||
this._signBgPending.add(raw);
|
||||
this.hass
|
||||
.callWS({ type: 'houseplan/content/sign', paths: [raw] })
|
||||
.then((r: any) => {
|
||||
const url = r?.urls?.[raw];
|
||||
if (!url) return;
|
||||
this._signedBg = { ...this._signedBg, [raw]: url };
|
||||
this.requestUpdate();
|
||||
})
|
||||
.catch(() => undefined);
|
||||
private _referenced(): Set<string> {
|
||||
return referencedContentUrls(this._snap?.config);
|
||||
}
|
||||
|
||||
protected render(): TemplateResult | typeof nothing {
|
||||
@@ -159,6 +153,8 @@ class HouseplanSpaceCard extends LitElement {
|
||||
spaceId,
|
||||
iconSize: this._config.icon_size,
|
||||
lang: this._lang,
|
||||
// resolved at render time: a url baked in earlier would be the unsigned one
|
||||
displayUrl: (raw) => this._signer.display(this.hass, raw),
|
||||
});
|
||||
if (!stage) {
|
||||
return this._errorCard(t(this._lang, 'space_card.not_found', { id: spaceId }));
|
||||
|
||||
+11
-2
@@ -25,6 +25,13 @@ export interface StaticRenderOpts {
|
||||
spaceId: string;
|
||||
iconSize?: number;
|
||||
lang: Lang;
|
||||
/**
|
||||
* Resolve a stored content url to what the DOM may actually request — the
|
||||
* plan lives behind `requires_auth`, so it needs an `authSig` signature.
|
||||
* Returning '' means "not signed yet": the caller must render no <image>
|
||||
* rather than an unsigned one, which would 401 (review R3-2).
|
||||
*/
|
||||
displayUrl?: (raw: string) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -118,11 +125,13 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
|
||||
})
|
||||
: [];
|
||||
|
||||
const bgHref = space.bg ? (o.displayUrl ? o.displayUrl(space.bg.href) : space.bg.href) : '';
|
||||
|
||||
return html`
|
||||
<div class="hp-static-stage" style="aspect-ratio:${vb[2]}/${vb[3]}">
|
||||
<svg viewBox="${vb[0]} ${vb[1]} ${vb[2]} ${vb[3]}" preserveAspectRatio="xMidYMid meet">
|
||||
${space.bg
|
||||
? svg`<image href="${space.bg.href}" x="${space.bg.x}" y="${space.bg.y}" width="${space.bg.w}" height="${space.bg.h}" preserveAspectRatio="none" />`
|
||||
${bgHref
|
||||
? svg`<image href="${bgHref}" x="${space.bg!.x}" y="${space.bg!.y}" width="${space.bg!.w}" height="${space.bg!.h}" preserveAspectRatio="none" />`
|
||||
: nothing}
|
||||
${roomShapes}
|
||||
</svg>
|
||||
|
||||
Reference in New Issue
Block a user