Merge pull request #434 from dermotduffy/image-auth-again

Prevent old HA state from using an old image access token
This commit is contained in:
Dermot Duffy
2022-03-05 19:11:22 -08:00
committed by GitHub
4 changed files with 128 additions and 49 deletions
+24 -16
View File
@@ -1,8 +1,7 @@
import { ReactiveController, ReactiveControllerHost } from 'lit'; import { ReactiveController, ReactiveControllerHost } from 'lit';
export class CachedValueController<T> implements ReactiveController { export class CachedValueController<T> implements ReactiveController {
public value?: T; protected _value?: T;
protected _host: ReactiveControllerHost; protected _host: ReactiveControllerHost;
protected _timerSeconds: number; protected _timerSeconds: number;
protected _callback: () => T; protected _callback: () => T;
@@ -21,38 +20,46 @@ export class CachedValueController<T> implements ReactiveController {
this._host.removeController(this); this._host.removeController(this);
} }
/**
* Get the value.
*/
get value(): T | undefined {
return this._value;
}
/** /**
* Update the cached value (and reset the timer). * Update the cached value (and reset the timer).
*/ */
public updateValue(): void { public updateValue(): void {
this.value = this._callback(); this._value = this._callback();
this._setTimer(); this._startTimer();
} }
/** /**
* Update the value and render it. * Clear the cached value.
*/ */
protected _updateValueAndRender(): void { public clearValue(): void {
this.updateValue(); this._value = undefined;
this._host.requestUpdate(); this._stopTimer();
} }
/** /**
* Remove the timer. * Disable the timer.
*/ */
protected _removeTimer(): void { protected _stopTimer(): void {
clearInterval(this._timerID); clearInterval(this._timerID);
this._timerID = undefined; this._timerID = undefined;
} }
/** /**
* Set the timer. * Enable the timer. Repeated calls will have no effect.
*/ */
protected _setTimer(): void { protected _startTimer(): void {
clearInterval(this._timerID); this._stopTimer();
if (this._timerSeconds > 0) { if (this._timerSeconds > 0) {
this._timerID = window.setInterval(() => { this._timerID = window.setInterval(() => {
this._updateValueAndRender(); this.updateValue();
this._host.requestUpdate();
}, this._timerSeconds * 1000); }, this._timerSeconds * 1000);
} }
} }
@@ -61,13 +68,14 @@ export class CachedValueController<T> implements ReactiveController {
* Host has connected to the cache. * Host has connected to the cache.
*/ */
hostConnected(): void { hostConnected(): void {
this._updateValueAndRender(); this.updateValue();
this._host.requestUpdate();
} }
/** /**
* Host has disconnected from the cache. * Host has disconnected from the cache.
*/ */
hostDisconnected(): void { hostDisconnected(): void {
this._removeTimer(); this.clearValue();
} }
} }
+7 -10
View File
@@ -233,18 +233,15 @@ export function shouldUpdateBasedOnHass(
if (!newHass || !entities || !entities.length) { if (!newHass || !entities || !entities.length) {
return false; return false;
} }
if (!oldHass) {
if (oldHass) {
for (let i = 0; i < entities.length; i++) {
const entity = entities[i];
if (!entity) {
continue;
}
if (oldHass.states[entity] !== newHass.states[entity]) {
return true; return true;
} }
for (let i = 0; i < entities.length; i++) {
const entity = entities[i];
if (entity && oldHass.states[entity] !== newHass.states[entity]) {
return true;
} }
return false;
} }
return false; return false;
} }
@@ -591,4 +588,4 @@ export const frigateCardHasAction = (
*/ */
export const stopEventFromActivatingCardWideActions = (ev: Event): void => { export const stopEventFromActivatingCardWideActions = (ev: Event): void => {
ev.stopPropagation(); ev.stopPropagation();
} };
+91 -10
View File
@@ -7,7 +7,7 @@ import {
unsafeCSS, unsafeCSS,
} from 'lit'; } from 'lit';
import { HomeAssistant } from 'custom-card-helpers'; import { HomeAssistant } from 'custom-card-helpers';
import { customElement, property, state } from 'lit/decorators.js'; import { customElement, property, query, state } from 'lit/decorators.js';
import { CachedValueController } from '../cached-value-controller.js'; import { CachedValueController } from '../cached-value-controller.js';
import { CameraConfig, ImageViewConfig } from '../types.js'; import { CameraConfig, ImageViewConfig } from '../types.js';
@@ -17,6 +17,9 @@ import defaultImage from '../images/frigate-bird-in-sky.jpg';
import imageStyle from '../scss/image.scss'; import imageStyle from '../scss/image.scss';
// See: https://github.com/home-assistant/core/blob/dev/homeassistant/components/camera/__init__.py#L101
const HASS_REJECTION_CUTOFF_MS = 5 * 60 * 1000;
@customElement('frigate-card-image') @customElement('frigate-card-image')
export class FrigateCardImage extends LitElement { export class FrigateCardImage extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
@@ -31,8 +34,11 @@ export class FrigateCardImage extends LitElement {
@state() @state()
protected _imageConfig?: ImageViewConfig; protected _imageConfig?: ImageViewConfig;
protected _cachedValueController?: CachedValueController<string>; @query('img')
protected _image?: HTMLImageElement;
protected _cachedValueController?: CachedValueController<string>;
protected _boundVisibilityHandler = this._visibilityHandler.bind(this);
/** /**
* Set the image configuration. * Set the image configuration.
*/ */
@@ -62,21 +68,96 @@ export class FrigateCardImage extends LitElement {
* @returns `true` if the element should be updated. * @returns `true` if the element should be updated.
*/ */
protected shouldUpdate(changedProps: PropertyValues): boolean { protected shouldUpdate(changedProps: PropertyValues): boolean {
const oldHass = changedProps.get('hass') as HomeAssistant | undefined; if (!this.hass || document.visibilityState !== 'visible') {
let shouldUpdate = !oldHass || changedProps.size != 1; return false;
}
// Image needs to update if the image view is in camera mode and the camera // If camera mode is enabled, reject all updates if hass is older than
// entity changes, as this could be a security token change. // HASS_REJECTION_CUTOFF_MS or if HASS is not currently connected. By using
if (oldHass && this._imageConfig?.mode === 'camera') { // an older hass (even if it is not the property being updated), we run the
// risk that the JS has an old access token for the camera, and that results
// in a notification on the HA UI about a failed login. See
// https://github.com/dermotduffy/frigate-hass-card/issues/398 .
const cameraEntity = this._getCameraEntity(); const cameraEntity = this._getCameraEntity();
const state = cameraEntity ? this.hass.states[cameraEntity] : undefined;
if ( if (
shouldUpdateBasedOnHass(this.hass, oldHass, cameraEntity ? [cameraEntity] : []) this._imageConfig?.mode === 'camera' &&
(!this.hass.connected ||
!state ||
Date.now() - Date.parse(state.last_updated) >= HASS_REJECTION_CUTOFF_MS)
) { ) {
shouldUpdate ||= true; return false;
}
if (
changedProps.has('hass') &&
changedProps.size == 1 &&
this._imageConfig?.mode === 'camera' &&
cameraEntity
) {
if (shouldUpdateBasedOnHass(this.hass, changedProps.get('hass'), [cameraEntity])) {
// If the state of the camera entity has changed, remove the cached
// value (will be re-calculated in willUpdate). This is important to
// ensure a changed access token is immediately used.
this._cachedValueController?.clearValue();
return true;
}
return false;
}
return true;
}
/**
* Ensure there is a cached value before an update.
* @param _changedProps The changed properties
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected willUpdate(_changedProps: PropertyValues): void {
if (!this._cachedValueController?.value) {
this._cachedValueController?.updateValue(); this._cachedValueController?.updateValue();
} }
} }
return shouldUpdate;
/**
* Component connected callback.
*/
connectedCallback(): void {
super.connectedCallback();
document.addEventListener('visibilitychange', this._boundVisibilityHandler);
}
/**
* Component disconnected callback.
*/
disconnectedCallback(): void {
document.removeEventListener('visibilitychange', this._boundVisibilityHandler);
super.disconnectedCallback();
}
/**
* Handle document visibility changes.
*/
protected _visibilityHandler(): void {
if (!this._image) {
return;
}
if (document.visibilityState === 'hidden') {
// Set the image to default when the document is hidden. This is to avoid
// some browsers (e.g. Firefox) eagerly re-loading the old image when the
// document regains visibility -- for some images (e.g. camera mode) the
// image may be using an old-expired token and re-use prior to
// re-generation of a new URL would generate an unauthorized request
// (401), see:
// https://github.com/dermotduffy/frigate-hass-card/issues/398
this._cachedValueController?.clearValue();
this._image.src = defaultImage;
} else {
// If the document is freshly re-visible, immediately re-render it to
// restore the image src. If the HASS object is old (i.e. browser tab was
// inactive for some time) this update request may be (correctly)
// rejected.
this.requestUpdate();
}
} }
/** /**
-7
View File
@@ -1,7 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit'; import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit';
import { customElement, property, state } from 'lit/decorators.js'; import { customElement, property, state } from 'lit/decorators.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { HomeAssistant, LovelaceCardEditor, fireEvent } from 'custom-card-helpers'; import { HomeAssistant, LovelaceCardEditor, fireEvent } from 'custom-card-helpers';
import { localize } from './localize/localize.js'; import { localize } from './localize/localize.js';
@@ -95,12 +94,6 @@ interface EditorOptions {
[setName: string]: EditorOptionsSet; [setName: string]: EditorOptionsSet;
} }
interface ConfigValueTarget {
configValue: string;
checked?: boolean;
value?: string;
}
interface EditorCameraTarget { interface EditorCameraTarget {
cameraIndex: number; cameraIndex: number;
} }