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:
@@ -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
@@ -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
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user