mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 08:28:31 +00:00
fix v1.44.3: signed content paths — plans and PDFs load again (B1 regression)
The v1.43.0 auth fix closed the hole but left the DISPLAY path unauthenticated: HA authenticates by a Bearer header or an authSig signed path, and an <image href> / <a href> sends neither, so plan backgrounds and manual links returned 401. Reproduced live before the fix (fetch 401, Image onerror). - new WS houseplan/content/sign mints async_sign_path urls (24 h, bound to the connection's refresh token, only for our own endpoint) - the card resolves display urls through _display(): signed when known, requests a batched signature otherwise, re-renders when it lands, and drops all signatures every 12 h so long-lived wall tablets stay valid - houseplan-space-card signs its background too - backend test asserts the unsigned url is refused and the signed one returns the bytes WITHOUT an Authorization header
This commit is contained in:
+52
-3
@@ -348,6 +348,12 @@ class HouseplanCard extends LitElement {
|
||||
public connectedCallback(): void {
|
||||
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._signed = {};
|
||||
this.requestUpdate();
|
||||
}, 12 * 3600 * 1000);
|
||||
if (this._config?.kiosk && Number(this._config?.cycle) > 0) {
|
||||
clearInterval(this._cycleTimer);
|
||||
this._cycleTimer = window.setInterval(() => this._cycleTick(), Number(this._config.cycle) * 1000);
|
||||
@@ -361,6 +367,9 @@ class HouseplanCard extends LitElement {
|
||||
clearTimeout(this._kioskDotsTimer);
|
||||
clearTimeout(this._kioskHoldTimer);
|
||||
clearTimeout(this._reloadRetry);
|
||||
clearTimeout(this._signTimer);
|
||||
clearInterval(this._resignTimer);
|
||||
clearTimeout(this._toastTimer);
|
||||
this._saveConfigDebounced.flush(); // never leave an edit unsent on teardown
|
||||
window.removeEventListener('hashchange', this._onHashChange);
|
||||
clearTimeout(this._holdTimer);
|
||||
@@ -571,7 +580,7 @@ class HouseplanCard extends LitElement {
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
vb: [s.view_box[0] * NORM_W, s.view_box[1] * H, s.view_box[2] * NORM_W, s.view_box[3] * H],
|
||||
bg: s.plan_url ? { href: contentUrl(s.plan_url), x: 0, y: 0, w: NORM_W, h: H } : null,
|
||||
bg: s.plan_url ? { href: this._display(s.plan_url), x: 0, y: 0, w: NORM_W, h: H } : null,
|
||||
rooms: s.rooms.map(scale),
|
||||
};
|
||||
});
|
||||
@@ -756,6 +765,46 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
private _reloadRetry?: number;
|
||||
/**
|
||||
* Signed urls for the content endpoint (audit follow-up B1 regression).
|
||||
* A browser cannot authenticate an <image href> or an <a href>: HA takes a
|
||||
* 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, string> = {};
|
||||
private _signPending = new Set<string>();
|
||||
private _signTimer?: number;
|
||||
|
||||
/** Display url: the signed variant when we have one, else the plain path. */
|
||||
private _display(url: string | null | undefined): string {
|
||||
const u = contentUrl(url);
|
||||
if (!u.startsWith('/api/houseplan/content/')) return u;
|
||||
if (this._signed[u]) return this._signed[u];
|
||||
this._requestSignature(u);
|
||||
return u; // first paint may 401; the signature lands and re-renders
|
||||
}
|
||||
|
||||
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.hass
|
||||
.callWS({ type: 'houseplan/content/sign', paths })
|
||||
.then((r: any) => {
|
||||
if (!r?.urls) return;
|
||||
this._signed = { ...this._signed, ...r.urls };
|
||||
this.requestUpdate();
|
||||
})
|
||||
.catch(() => undefined); // unsigned urls simply keep failing; no loop
|
||||
}, 30);
|
||||
}
|
||||
|
||||
/** Re-sign everything periodically: a wall tablet outlives a signature. */
|
||||
private _resignTimer?: number;
|
||||
private _dirtyPos = new Set<string>();
|
||||
|
||||
private _persistLayout = debounce(() => {
|
||||
@@ -4602,7 +4651,7 @@ class HouseplanCard extends LitElement {
|
||||
${d.pdfs && d.pdfs.length
|
||||
? html`<div class="inforow"><span class="k">${this._t('info.manuals')}</span><span class="pdflist">
|
||||
${d.pdfs.map(
|
||||
(p) => html`<a class="pdf" href="${safeUrl(contentUrl(p.url)) || '#'}" target="_blank" rel="noreferrer noopener">
|
||||
(p) => html`<a class="pdf" href="${safeUrl(this._display(p.url)) || '#'}" target="_blank" rel="noreferrer noopener">
|
||||
<ha-icon icon="mdi:file-pdf-box"></ha-icon>${p.name}</a>`,
|
||||
)}</span></div>`
|
||||
: nothing}
|
||||
@@ -4839,7 +4888,7 @@ class HouseplanCard extends LitElement {
|
||||
<div class="pdfedit">
|
||||
${d.pdfs.map(
|
||||
(p) => html`<span class="pdftag"><ha-icon icon="mdi:file-pdf-box"></ha-icon>
|
||||
<a href="${safeUrl(contentUrl(p.url)) || '#'}" target="_blank" rel="noreferrer noopener">${p.name}</a>
|
||||
<a href="${safeUrl(this._display(p.url)) || '#'}" target="_blank" rel="noreferrer noopener">${p.name}</a>
|
||||
<ha-icon class="x" icon="mdi:close" @click=${() => this._removeMarkerPdf(p.url)}></ha-icon></span>`,
|
||||
)}
|
||||
<label class="btn filebtn">
|
||||
|
||||
@@ -109,6 +109,8 @@ 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
|
||||
@@ -121,6 +123,27 @@ 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>();
|
||||
|
||||
/** 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);
|
||||
}
|
||||
|
||||
protected render(): TemplateResult | typeof nothing {
|
||||
if (!this._config) return nothing;
|
||||
const cfg = this._snap?.config;
|
||||
|
||||
Reference in New Issue
Block a user