feat: Add proxying support for images (#2427)

- Closes #2418
This commit is contained in:
Dermot Duffy
2026-06-30 17:45:12 -07:00
committed by dermotduffy
parent 9384785d37
commit 1cd5520154
51 changed files with 2404 additions and 687 deletions
+34 -18
View File
@@ -2,40 +2,42 @@ import { ReactiveController, ReactiveControllerHost } from 'lit';
import { Timer } from '../utils/timer';
export class CachedValueController<T> implements ReactiveController {
private _value?: T;
private _host: ReactiveControllerHost;
private _timerSeconds: number;
private _host: ReactiveControllerHost & HTMLElement;
private _value: T | null = null;
private _timerSeconds: number | null = null;
private _callback: () => T;
private _getTimerSecondsCallback: () => number | null;
private _timerStartCallback?: () => void;
private _timerStopCallback?: () => void;
private _timerTickCallback?: () => void;
private _timer = new Timer();
constructor(
host: ReactiveControllerHost,
timerSeconds: number,
host: ReactiveControllerHost & HTMLElement,
getTimerSecondsCallback: () => number | null,
callback: () => T,
timerStartCallback?: () => void,
timerStopCallback?: () => void,
timerTickCallback?: () => void,
) {
this._timerSeconds = timerSeconds;
this._getTimerSecondsCallback = getTimerSecondsCallback;
this._timerSeconds = getTimerSecondsCallback();
this._callback = callback;
this._timerStartCallback = timerStartCallback;
this._timerStopCallback = timerStopCallback;
this._timerTickCallback = timerTickCallback;
(this._host = host).addController(this);
}
/**
* Remove the controller for the host.
*/
public removeController(): void {
this.stopTimer();
this._host.removeController(this);
}
/**
* Get the value.
*/
get value(): T | undefined {
public getValue(): T | null {
return this._value;
}
@@ -44,13 +46,14 @@ export class CachedValueController<T> implements ReactiveController {
*/
public updateValue(): void {
this._value = this._callback();
this._host.requestUpdate();
}
/**
* Clear the cached value.
*/
public clearValue(): void {
this._value = undefined;
this._value = null;
}
/**
@@ -69,10 +72,14 @@ export class CachedValueController<T> implements ReactiveController {
public startTimer(): void {
this.stopTimer();
if (!this._timerSeconds || this._timerSeconds <= 0) {
return;
}
this._timerStartCallback?.();
this._timer.startRepeated(this._timerSeconds, () => {
this._timerTickCallback?.();
this.updateValue();
this._host.requestUpdate();
});
}
@@ -80,13 +87,22 @@ export class CachedValueController<T> implements ReactiveController {
return this._timer.isRunning();
}
public hostUpdate(): void {
const newTimerSeconds = this._getTimerSecondsCallback();
if (newTimerSeconds !== this._timerSeconds) {
this._timerSeconds = newTimerSeconds;
if (this._host.isConnected) {
this.startTimer();
}
}
}
/**
* Host has connected to the cache.
*/
hostConnected(): void {
this.updateValue();
this.startTimer();
this._host.requestUpdate();
}
/**
@@ -55,7 +55,7 @@ export class UpdatingImageMediaPlayerController implements MediaPlayerController
public async getScreenshotURL(): Promise<string | null> {
await this._host.updateComplete;
return this._getCachedValueController()?.value ?? null;
return this._getCachedValueController()?.getValue() ?? null;
}
public getFullscreenElement(): FullscreenElement | null {
+232
View File
@@ -0,0 +1,232 @@
import { ReactiveController, ReactiveControllerHost } from 'lit';
import { isEqual } from 'lodash-es';
import { EnabledProxyConfig } from '../config/schema/common/proxy.js';
import { homeAssistantGetSignedURLIfNecessary } from '../ha/sign-path.js';
import { HomeAssistant } from '../ha/types.js';
import {
CreateProxiedEndpointOptions,
createProxiedEndpointIfNecessary,
} from '../ha/web-proxy.js';
import { Endpoint } from '../types.js';
import { errorToConsole } from '../utils/basic.js';
const PROXY_URL_SIGN_EXPIRY_SECONDS = 24 * 60 * 60;
// Re-register and re-sign well before the signed URL expires.
const PROXY_CACHE_TTL_SECONDS = PROXY_URL_SIGN_EXPIRY_SECONDS / 2;
interface SignedURLControllerOptions {
// The endpoint to resolve. The `sign` flag on the endpoint controls whether
// the URL requires HA authentication even when proxying is disabled (e.g.
// HA-relative API paths like go2rtc streams served through Frigate).
endpoint?: Endpoint;
hass?: HomeAssistant;
proxyConfig?: EnabledProxyConfig | null;
proxyEndpointOptions?: CreateProxiedEndpointOptions;
}
type SignedURLErrorType = 'sign' | 'proxy';
export class SignedURLController implements ReactiveController {
private _host: ReactiveControllerHost;
private _getOptionsCallback: () => SignedURLControllerOptions;
private _valueChangeCallback: (() => void) | undefined;
private _value: string | null = null;
private _error: SignedURLErrorType | null = null;
private _cachedAt: Date | null = null;
// Caching and race-condition state.
// The targetURL and proxy config are tracked to detect when inputs change and
// invalidate the cache. The requestID tracks the most recent valid fetch, to
// ensure that older, slower in-flight requests do not overwrite newer ones.
private _targetURL: string | null = null;
private _targetProxyConfig: EnabledProxyConfig | null = null;
private _requestID = 0;
constructor(
host: ReactiveControllerHost,
getOptionsCallback: () => SignedURLControllerOptions,
valueChangeCallback?: () => void,
) {
(this._host = host).addController(this);
this._getOptionsCallback = getOptionsCallback;
this._valueChangeCallback = valueChangeCallback;
}
public getError(): SignedURLErrorType | null {
return this._error;
}
public getValue(): string | null {
const options = this._getOptionsCallback();
// When the endpoint requires signing or proxying, the URL must go through
// the async resolution path. For proxied URLs, under no circumstances
// should we fall back to returning the unproxied URL — doing so risks
// leaking traffic or causing mixed-content errors.
if (options.proxyConfig?.enabled || options.endpoint?.sign) {
return this._value;
}
return options.endpoint?.endpoint ?? null;
}
public hostDisconnected(): void {
++this._requestID;
this._value = null;
this._error = null;
this._cachedAt = null;
this._targetURL = null;
this._targetProxyConfig = null;
}
public async hostUpdate(): Promise<void> {
const { hass, endpoint, proxyConfig, proxyEndpointOptions } =
this._getOptionsCallback();
if (!hass || !endpoint || (!proxyConfig?.enabled && !endpoint.sign)) {
// Invalidate any in-flight async work so a stale proxy/sign result cannot
// repopulate the controller after inputs have been cleared or disabled.
++this._requestID;
this._value = null;
this._error = null;
this._targetURL = null;
this._targetProxyConfig = null;
this._cachedAt = null;
return;
}
const targetURL = new URL(endpoint.endpoint, document.baseURI).toString();
// Pick only the EnabledProxyConfig fields so that extraneous properties
// (e.g. `live`/`media` from CameraProxyConfig spreads) don't cause
// spurious cache invalidations. When only signing (no proxy), the config
// is null.
const comparableConfig: EnabledProxyConfig | null = proxyConfig?.enabled
? {
dynamic: proxyConfig.dynamic,
ssl_verification: proxyConfig.ssl_verification,
ssl_ciphers: proxyConfig.ssl_ciphers,
enabled: proxyConfig.enabled,
enforce: proxyConfig.enforce,
}
: null;
if (
targetURL !== this._targetURL ||
!isEqual(comparableConfig, this._targetProxyConfig)
) {
this._targetURL = targetURL;
this._targetProxyConfig = comparableConfig;
this._cachedAt = null;
this._error = null;
this._value = null;
} else if (
this._cachedAt &&
new Date().getTime() - this._cachedAt.getTime() < PROXY_CACHE_TTL_SECONDS * 1000
) {
return;
} else if (!this._cachedAt) {
// Either async work for these exact inputs is already in flight, or
// we already failed for these exact inputs. Either way, don't
// restart: inputs must change before we retry.
return;
}
// Mark as in-flight so the `!this._cachedAt` guard above prevents
// subsequent hostUpdate() calls from restarting the async work.
this._cachedAt = null;
const requestID = ++this._requestID;
const resolvedEndpoint = await this._proxy(
hass,
targetURL,
endpoint,
proxyConfig,
proxyEndpointOptions,
);
if (this._isStale(requestID)) {
return;
}
if (!resolvedEndpoint) {
this._applyError('proxy');
return;
}
const signedURL = await this._sign(hass, resolvedEndpoint);
if (this._isStale(requestID)) {
return;
}
if (!signedURL) {
this._applyError('sign');
return;
}
this._applySuccess(signedURL);
}
/**
* Proxy the endpoint if proxying is enabled, otherwise return it as-is.
*/
private async _proxy(
hass: HomeAssistant,
targetURL: string,
endpoint: Endpoint,
proxyConfig: EnabledProxyConfig | null | undefined,
proxyEndpointOptions: CreateProxiedEndpointOptions | undefined,
): Promise<Endpoint | null> {
if (!proxyConfig?.enabled) {
return { endpoint: targetURL, sign: endpoint.sign };
}
try {
return await createProxiedEndpointIfNecessary(
hass,
{ endpoint: targetURL, sign: false },
proxyConfig,
{
ttl: PROXY_URL_SIGN_EXPIRY_SECONDS,
openLimit: 0,
...proxyEndpointOptions,
},
);
} catch (e: unknown) {
errorToConsole(e as Error);
return null;
}
}
/**
* Sign the endpoint if it requires signing, otherwise return the URL as-is.
*/
private async _sign(hass: HomeAssistant, endpoint: Endpoint): Promise<string | null> {
try {
return await homeAssistantGetSignedURLIfNecessary(
hass,
endpoint,
PROXY_URL_SIGN_EXPIRY_SECONDS,
);
} catch (e: unknown) {
errorToConsole(e as Error);
return null;
}
}
private _isStale(requestID: number): boolean {
return this._requestID !== requestID;
}
private _applySuccess(url: string): void {
this._value = url;
this._error = null;
this._valueChangeCallback?.();
this._cachedAt = new Date();
this._host.requestUpdate();
}
private _applyError(error: SignedURLErrorType): void {
this._value = null;
this._error = error;
this._host.requestUpdate();
}
}