diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 1f0d97d8..250cf3f7 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -9,7 +9,10 @@ module.exports = { 'prettier', // Uses eslint-config-prettier to disable ESLint rules from @typescript-eslint/eslint-plugin that would conflict with prettier ], rules: { - // Place to specify ESLint rules. Can be used to overwrite rules specified from the extended configs - // e.g. "@typescript-eslint/explicit-function-return-type": "off", + // Place to specify ESLint rules. Can be used to overwrite rules specified + // from the extended configs e.g. + // "@typescript-eslint/explicit-function-return-type": "off", + '@typescript-eslint/no-explicit-any': 'error', + 'no-multi-assign': 'error', }, }; diff --git a/INSTRUCTIONS.md b/INSTRUCTIONS.md index 75fbd2c5..6fba8d17 100644 --- a/INSTRUCTIONS.md +++ b/INSTRUCTIONS.md @@ -35,6 +35,9 @@ - Never use non-null assertions (`!`); use a null check with an early return or conditional instead. - Use `zod` for runtime validation if external data is involved. - `noUnusedParameters` and `noImplicitReturns` are enforced — all parameters must be used and all code paths must return. + - Prefer explicitly returning `null` rather than `undefined` for absent or empty state values. + - Prefer `async`/`await` over verbose Promise chaining (`.then().catch()`). + - Prefer instantiating `Date` objects for timestamps rather than using raw numbers (e.g. `Date.now()`). - **Testing (Vitest):** diff --git a/docs/configuration/cameras/README.md b/docs/configuration/cameras/README.md index 3ed7c126..d9820f8e 100644 --- a/docs/configuration/cameras/README.md +++ b/docs/configuration/cameras/README.md @@ -334,12 +334,7 @@ Configures whether and how the content is proxied via [hass-web-proxy-integration](https://github.com/dermotduffy/hass-web-proxy-integration) (this must be installed separately). This allows fetching media or live streams **through** the Home Assistant process itself, allowing the card to access -resources it otherwise would not be able to directly access. There are [security -and performance -implications](https://github.com/dermotduffy/hass-web-proxy-integration?tab=readme-ov-file#considerations) -to consider before installing -[hass-web-proxy-integration](https://github.com/dermotduffy/hass-web-proxy-integration) -and using this functionality. +resources it otherwise would not be able to directly access. ```yaml cameras: @@ -350,6 +345,8 @@ cameras: ![Camera Proxying](../../images/proxy.png 'Camera Proxying :size=400') +[](../common/proxy-warning.md ':include') + For live streams, only the `go2rtc` [live provider](./live-provider.md) currently supports live stream proxying. For media, not all [engines](./engine.md) benefit from proxying: @@ -360,20 +357,15 @@ For media, not all [engines](./engine.md) benefit from proxying: | `reolink`, `motioneye` | May be used to fetch videos in cases where the browser may not be able to access the camera/NVR, or the camera/NVR may use a self-signed SSL certificate that your browser would otherwise reject due to [mixed content](https://developer.mozilla.org/en-US/docs/Web/Security/Defenses/Mixed_content). | | `generic` | `generic` cameras do not have media, so proxying currently would serve no purpose. | -Regardless of the parameters, the integration will never attempt to proxy -content if the -[hass-web-proxy-integration](https://github.com/dermotduffy/hass-web-proxy-integration) -is not detected. - Proxying parameters: -| Option | Default | Description | -| ------------------ | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `live` | `auto` | Whether or not to proxy live streams. `true` to proxy, `false` to not proxy, or `auto` to allow the camera engine to decide whether to proxy or not. Not all [live providers](./live-provider.md) support proxying live streams. | -| `media` | `auto` | Whether or not to proxy media items. `true` to proxy, `false` to not proxy, or `auto` to allow the camera engine to decide whether to proxy or not. | -| `dynamic` | `true` | Whether to dynamically (at the time) request proxying of the required media item, or rely on statically user-configured pre-existing proxying. See the [hass-web-proxy-integration documentation](https://github.com/dermotduffy/hass-web-proxy-integration). | -| `ssl_verification` | `auto` | Whether to verify the validity of SSL certificates. If `true` always verifies, if `false` never verifies and if `auto` the [engine](./engine.md) decides the best setting for that camera ecosystem. | -| `ssl_ciphers` | `auto` | Whether to use `default`, `intermediate`, `insecure` or `modern` SSL ciphers. See the [Home Assistant code](https://github.com/home-assistant/core/blob/dev/homeassistant/util/ssl.py) for the precise list of SSL ciphers each implies. If `auto` the [engine](./engine.md) decides the best setting for that camera ecosystem. | +| Option | Default | Description | +| ------------------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `live` | `auto` | Whether or not to proxy live streams. `true` to proxy (will show an error if the proxy integration is unavailable), `false` to not proxy, or `auto` to allow the camera engine to decide whether to proxy or not. If `auto` resolves to proxying, the card will still fall back to the original URL if the proxy integration is unavailable. Not all [live providers](./live-provider.md) support proxying live streams. | +| `media` | `auto` | Whether or not to proxy media items. `true` to proxy (will show an error if the proxy integration is unavailable), `false` to not proxy, or `auto` to allow the camera engine to decide whether to proxy or not. If `auto` resolves to proxying, the card will still fall back to the original URL if the proxy integration is unavailable. | +| `dynamic` | `true` | Whether to dynamically (at the time) request proxying of the required media item, or rely on statically user-configured pre-existing proxying. See the [hass-web-proxy-integration documentation](https://github.com/dermotduffy/hass-web-proxy-integration). | +| `ssl_verification` | `auto` | Whether to verify the validity of SSL certificates. If `true` always verifies, if `false` never verifies and if `auto` the [engine](./engine.md) decides the best setting for that camera ecosystem. | +| `ssl_ciphers` | `auto` | Whether to use `default`, `intermediate`, `insecure` or `modern` SSL ciphers. See the [Home Assistant code](https://github.com/home-assistant/core/blob/dev/homeassistant/util/ssl.py) for the precise list of SSL ciphers each implies. If `auto` the [engine](./engine.md) decides the best setting for that camera ecosystem. | ## `triggers` diff --git a/docs/configuration/cameras/engine.md b/docs/configuration/cameras/engine.md index daea3f9e..97eead63 100644 --- a/docs/configuration/cameras/engine.md +++ b/docs/configuration/cameras/engine.md @@ -4,13 +4,13 @@ A "Camera Engine" defines what "type" of camera is being configured (e.g. `frigate`), each engine offers different capabilities: -| Engine | Live | Supports clips | Supports Snapshots | Supports Recordings | Supports Timeline | Supports PTZ out of the box | Supports manually configured PTZ | Favorite events | Favorite recordings | Detect new events | Detect new snapshots | Detect new clips | May requiring [proxying](./README.md?id=proxy) | Thumbnails | -| ----------- | ------------------ | ------------------------ | ------------------------ | ------------------------ | ------------------------ | --------------------------- | -------------------------------- | ------------------------ | ------------------------ | ------------------------ | ------------------------ | ------------------------ | ---------------------------------------------- | ------------------------ | -| `frigate` | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :heavy_multiplication_x: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :heavy_multiplication_x: | :white_check_mark: | -| `generic` | :white_check_mark: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :white_check_mark: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | -| `motioneye` | :white_check_mark: | :white_check_mark: | :white_check_mark: | :heavy_multiplication_x: | :white_check_mark: | :heavy_multiplication_x: | :white_check_mark: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :white_check_mark: | :white_check_mark: | -| `reolink` | :white_check_mark: | :white_check_mark: | :heavy_multiplication_x: | :heavy_multiplication_x: | :white_check_mark: | :eight_spoked_asterisk: | :white_check_mark: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :white_check_mark: | :heavy_multiplication_x: | -| `tplink` | :white_check_mark: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :eight_spoked_asterisk: | :white_check_mark: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | +| Engine | Live | Supports clips | Supports Snapshots | Supports Recordings | Supports Timeline | Supports PTZ out of the box | Supports manually configured PTZ | Favorite events | Favorite recordings | Detect new events | Detect new snapshots | Detect new clips | May require [proxying](./README.md?id=proxy) | Thumbnails | +| ----------- | ------------------ | ------------------------ | ------------------------ | ------------------------ | ------------------------ | --------------------------- | -------------------------------- | ------------------------ | ------------------------ | ------------------------ | ------------------------ | ------------------------ | -------------------------------------------- | ------------------------ | +| `frigate` | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :heavy_multiplication_x: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :heavy_multiplication_x: | :white_check_mark: | +| `generic` | :white_check_mark: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :white_check_mark: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | +| `motioneye` | :white_check_mark: | :white_check_mark: | :white_check_mark: | :heavy_multiplication_x: | :white_check_mark: | :heavy_multiplication_x: | :white_check_mark: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :white_check_mark: | :white_check_mark: | +| `reolink` | :white_check_mark: | :white_check_mark: | :heavy_multiplication_x: | :heavy_multiplication_x: | :white_check_mark: | :eight_spoked_asterisk: | :white_check_mark: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :white_check_mark: | :heavy_multiplication_x: | +| `tplink` | :white_check_mark: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :eight_spoked_asterisk: | :white_check_mark: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | ### Live providers supported per Engine diff --git a/docs/configuration/cameras/live-provider.md b/docs/configuration/cameras/live-provider.md index e134bf84..9c8eeb29 100644 --- a/docs/configuration/cameras/live-provider.md +++ b/docs/configuration/cameras/live-provider.md @@ -61,7 +61,9 @@ cameras: | `entity_parameters` | | Optional URL parameters to add to the URL generated for entity-based modes (i.e. when `mode` is `camera` or `entity`). | | `mode` | `auto` | Value must be one of `url` (to fetch an arbitrary image URL), `camera` (to show a still of the currently selected camera entity using either `camera_entity` or `webrtc_card.entity` in that order of precedence), `entity` to show an image associated with a named entity (see the `entity` parameter below), or `screensaver` (to show an [embedded image](https://github.com/dermotduffy/advanced-camera-card/blob/main/src/images/iris-screensaver.jpg)). If `auto`, the mode is chosen automatically based on whether `url` or `entity` parameters have been specified. | | `refresh_seconds` | 1 | The image will be refreshed at least every `refresh_seconds` (it may refresh more frequently, e.g. whenever Home Assistant updates its camera security token). `0` implies no refreshing. | -| `url` | | A static image URL to be used when the `mode` is set to `url` or when a temporary image is required (e.g. may appear momentarily prior to load of a camera snapshot in the `camera` mode). Note that a `_t=[timestsamp]` query parameter will be automatically added to all URLs such that the image will not be cached by the browser. | +| `url` | | A static image URL to be used when the `mode` is set to `url` or when a temporary image is required (e.g. may appear momentarily prior to load of a camera snapshot in the `camera` mode). Note that a `_t=[timestamp]` cache-busting value will be added automatically. | + +[](../common/proxy-warning.md ':include') ## `jsmpeg` diff --git a/docs/configuration/common/proxy-warning.md b/docs/configuration/common/proxy-warning.md new file mode 100644 index 00000000..dc2ee556 --- /dev/null +++ b/docs/configuration/common/proxy-warning.md @@ -0,0 +1,23 @@ +There are [security and performance +implications](https://github.com/dermotduffy/hass-web-proxy-integration?tab=readme-ov-file#considerations) +to consider before installing +[hass-web-proxy-integration](https://github.com/dermotduffy/hass-web-proxy-integration) +and using this functionality. + +If the +[hass-web-proxy-integration](https://github.com/dermotduffy/hass-web-proxy-integration) +is not detected, the card will fall back to the original URL unless proxying was +explicitly enabled (i.e. set to `true`), in which case an error will be shown. + +> [!WARNING] +> When proxying, the card's ability to ensure caching doesn't interfere with +> refresh accuracy depends on what is being proxied. Specifically, `image` +> based proxying will have less reliable behavior because signed URLs cannot +> have additional query parameters added without invalidating the signature. The +> card uses a URL fragment (`#_t=...`) for cache-busting instead, which does not +> affect the signature but is less reliable: the fragment is not sent to the +> server, so the browser may serve a cached response. Reliable refreshing will +> depend on the source server providing appropriate `Cache-Control` headers. The +> Home Assistant companion app's WebView does not re-fetch images when only the +> fragment changes, so proxied image refreshing will not work in the companion +> app. diff --git a/docs/configuration/image.md b/docs/configuration/image.md index da41c015..da52edac 100644 --- a/docs/configuration/image.md +++ b/docs/configuration/image.md @@ -13,12 +13,37 @@ image: | `entity` | | The entity to use when `mode` is set to `entity`. This entity is expected to have an `entity_picture` attribute that specifies the image URL. | | `entity_parameters` | | Optional URL parameters to add to the URL generated for entity-based modes (i.e. when `mode` is `camera` or `entity`). | | `mode` | `auto` | Value must be one of `url` (to fetch an arbitrary image URL), `camera` (to show a still of the currently selected camera entity using either `camera_entity` or `webrtc_card.entity` in that order of precedence), `entity` to show an image associated with a named entity (see the `entity` parameter below), or `screensaver` (to show an [embedded image](https://github.com/dermotduffy/advanced-camera-card/blob/main/src/images/iris-screensaver.jpg)). If `auto`, the mode is chosen automatically based on whether `url` or `entity` parameters have been specified. | +| `proxy` | | Proxy configuration for `url` mode images. See [proxy](#proxy) below. | | `refresh_seconds` | 1 | The image will be refreshed at least every `refresh_seconds` (it may refresh more frequently, e.g. whenever Home Assistant updates its camera security token). `0` implies no refreshing. | -| `url` | | A static image URL to be used when the `mode` is set to `url` or when a temporary image is required (e.g. may appear momentarily prior to load of a camera snapshot in the `camera` mode). Note that a `_t=[timestsamp]` query parameter will be automatically added to all URLs such that the image will not be cached by the browser. | +| `url` | | A static image URL to be used when the `mode` is set to `url` or when a temporary image is required (e.g. may appear momentarily prior to load of a camera snapshot in the `camera` mode). Note that a `_t=[timestamp]` cache-busting value will be added automatically. | > [!NOTE] > When `mode` is set to `camera` this is effectively providing the same image as the `image` [live provider](cameras/live-provider.md) would show in the live camera carousel. +## `proxy` + +Configures whether and how the image URL is proxied via +[hass-web-proxy-integration](https://github.com/dermotduffy/hass-web-proxy-integration) +(this must be installed separately). This is useful when the image URL uses HTTP +but Home Assistant is served over HTTPS, which would otherwise be blocked by the +browser as [mixed +content](https://developer.mozilla.org/en-US/docs/Web/Security/Defenses/Mixed_content). + +```yaml +image: + proxy: + # [...] +``` + +[](common/proxy-warning.md ':include') + +| Option | Default | Description | +| ------------------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `enabled` | `false` | Whether or not to proxy image URLs when in `url` mode. `true` to proxy (will show an error if the proxy integration is unavailable), `false` to not proxy. | +| `dynamic` | `true` | Whether to dynamically (at the time) request proxying of the required URL, or rely on statically user-configured pre-existing proxying. See the [hass-web-proxy-integration documentation](https://github.com/dermotduffy/hass-web-proxy-integration). | +| `ssl_verification` | `auto` | Whether to verify the validity of SSL certificates. If `true` always verifies, if `false` never verifies and if `auto` defaults to `true`. | +| `ssl_ciphers` | `auto` | Whether to use `default`, `intermediate`, `insecure` or `modern` SSL ciphers. See the [Home Assistant code](https://github.com/home-assistant/core/blob/dev/homeassistant/util/ssl.py) for the precise list of SSL ciphers each implies. If `auto` defaults to `default`. | + ## Fully expanded reference [](common/expanded-warning.md ':include') @@ -30,6 +55,11 @@ image: url: 'https://path/to/image.png' entity: image.office_person entity_parameters: 'width=400&height=200' + proxy: + enabled: false + dynamic: true + ssl_verification: auto + ssl_ciphers: auto actions: entity: light.office_main_lights tap_action: diff --git a/docs/images/proxy.png b/docs/images/proxy.png index 6f16a626..5fa85ddb 100644 Binary files a/docs/images/proxy.png and b/docs/images/proxy.png differ diff --git a/package.json b/package.json index 5d71d935..240354b2 100644 --- a/package.json +++ b/package.json @@ -185,7 +185,7 @@ "docs-check-links": "docker run --init --rm -v \"$(pwd):/input\" -w /input lycheeverse/lychee --config lychee.toml \"./docs/\"", "docs-update-images": "./scripts/docs-update-images.sh", "docs-update-dependencies": "./scripts/docs-update-dependencies.sh", - "lint": "eslint 'src/**/*.ts'", + "lint": "eslint '{src,tests}/**/*.ts'", "format": "prettier --write .", "format-check": "prettier --check .", "rollup": "rollup -c", diff --git a/src/camera-manager/camera.ts b/src/camera-manager/camera.ts index ea9dc8e4..6cca62b7 100644 --- a/src/camera-manager/camera.ts +++ b/src/camera-manager/camera.ts @@ -2,6 +2,7 @@ import { ActionsExecutor } from '../card-controller/actions/types'; import { StateWatcherSubscriptionInterface } from '../card-controller/hass/state-watcher'; import { PTZAction, PTZActionPhase } from '../config/schema/actions/custom/ptz'; import { CameraConfig } from '../config/schema/cameras'; +import { EnabledProxyConfig, resolveProxyConfig } from '../config/schema/common/proxy'; import { isTriggeredState } from '../ha/is-triggered-state'; import { HassStateDifference, HomeAssistant } from '../ha/types'; import { localize } from '../localize/localize'; @@ -100,6 +101,8 @@ export class Camera { } protected async _has2WayAudioCapability(hass: HomeAssistant): Promise { + // Check disable/disableExcept/force early to short-circuit the expensive + // network call to fetch go2rtc metadata. if (this._config.capabilities?.disable?.includes('2-way-audio')) { return false; } @@ -115,7 +118,7 @@ export class Camera { this.getConfig(), this.getConfig().go2rtc.metadata_fetch_timeout_seconds, this._getGo2RTCMetadataEndpoint(), - this.getProxyConfig(), + this.getLiveProxyConfig(), ); } @@ -137,7 +140,7 @@ export class Camera { } public async destroy(): Promise { - this._destroyCallbacks.forEach((callback) => callback()); + await Promise.all(this._destroyCallbacks.map((callback) => callback())); } public getConfig(): CameraConfig { @@ -204,6 +207,7 @@ export class Camera { public getProxyConfig(): CameraProxyConfig { return { + ...resolveProxyConfig(this._config.proxy), live: this._config.proxy.live === 'auto' ? // Live is proxied if the live provider is go2rtc and if a go2rtc @@ -211,13 +215,32 @@ export class Camera { this._config.live_provider === 'go2rtc' && !!this._config.go2rtc?.url : this._config.proxy.live, media: this._config.proxy.media === 'auto' ? false : this._config.proxy.media, + }; + } - dynamic: this._config.proxy.dynamic, - ssl_verification: this._config.proxy.ssl_verification !== false, - ssl_ciphers: - this._config.proxy.ssl_ciphers === 'auto' - ? 'default' - : this._config.proxy.ssl_ciphers, + public getLiveProxyConfig(): EnabledProxyConfig { + const config = this.getProxyConfig(); + return { + ...config, + + // `enabled` uses the resolved engine decision (so `auto` may become + // true), whereas `enforce` uses the raw user setting so only an explicit + // `true` means "fail instead of falling back" if the proxy is unavailable. + enabled: config.live, + enforce: this._config.proxy.live === true, + }; + } + + public getMediaProxyConfig(): EnabledProxyConfig { + const config = this.getProxyConfig(); + return { + ...config, + + // `enabled` uses the resolved engine decision (so `auto` may become + // true), whereas `enforce` uses the raw user setting so only an explicit + // `true` means "fail instead of falling back" if the proxy is unavailable. + enabled: config.media, + enforce: this._config.proxy.media === true, }; } @@ -250,11 +273,10 @@ export class Camera { this._destroyCallbacks.push(callback); } - protected _subscribeBasedOnCapabilities( + private _subscribeBasedOnCapabilities( stateWatcher: StateWatcherSubscriptionInterface, ): void { if (this._capabilities?.has('trigger')) { - stateWatcher.unsubscribe(this._stateChangeHandler); stateWatcher.subscribe(this._stateChangeHandler, this._config.triggers.entities); } } diff --git a/src/camera-manager/types.ts b/src/camera-manager/types.ts index b67f5619..71c8c24f 100644 --- a/src/camera-manager/types.ts +++ b/src/camera-manager/types.ts @@ -1,7 +1,7 @@ import { ExpiringEqualityCache } from '../cache/expiring-cache'; -import { SSLCiphers } from '../config/schema/cameras'; import { AdvancedCameraCardView } from '../config/schema/common/const'; import { InternalIcon } from '../config/schema/common/icon'; +import { ResolvedProxyConfig } from '../config/schema/common/proxy'; import { BaseQuery, QueryFilters, QuerySource } from '../query-source'; import { CapabilityKey, Endpoint } from '../types'; import { ViewMedia } from '../view/item'; @@ -137,12 +137,9 @@ export interface CameraEndpoints { webrtcCard?: Endpoint; } -export interface CameraProxyConfig { - dynamic: boolean; +export interface CameraProxyConfig extends ResolvedProxyConfig { live: boolean; media: boolean; - ssl_verification: boolean; - ssl_ciphers: SSLCiphers; } export interface EngineOptions { diff --git a/src/camera-manager/utils/go2rtc/audio.ts b/src/camera-manager/utils/go2rtc/audio.ts index 2878ac4b..94e3c947 100644 --- a/src/camera-manager/utils/go2rtc/audio.ts +++ b/src/camera-manager/utils/go2rtc/audio.ts @@ -1,9 +1,9 @@ +import { EnabledProxyConfig } from '../../../config/schema/common/proxy'; import { homeAssistantSignAndFetch } from '../../../ha/fetch'; import { HomeAssistant } from '../../../ha/types'; import { createProxiedEndpointIfNecessary } from '../../../ha/web-proxy'; import { Endpoint } from '../../../types'; import { errorToConsole } from '../../../utils/basic'; -import { CameraProxyConfig } from '../../types'; import { Go2RTCStreamInfo, go2RTCStreamInfoSchema } from './types'; const getGo2RTCStreamMetadata = async ( @@ -44,14 +44,14 @@ const streamSupports2WayAudio = (streamInfo: Go2RTCStreamInfo | null): boolean = * * @param hass Home Assistant instance. * @param go2rtcMetadataEndpoint The go2rtc metadata endpoint. - * @param proxyConfig The camera's proxy configuration for live streams. + * @param proxyConfig The resolved proxy configuration for live streams. * @returns True if supports 2-way audio, false otherwise. */ export const supports2WayAudio = async ( hass: HomeAssistant, metadataFetchTimeoutSeconds: number, go2rtcMetadataEndpoint?: Endpoint | null, - proxyConfig?: CameraProxyConfig, + proxyConfig?: EnabledProxyConfig, ): Promise => { if (!go2rtcMetadataEndpoint) { return false; @@ -61,8 +61,11 @@ export const supports2WayAudio = async ( hass, go2rtcMetadataEndpoint, proxyConfig, - { context: 'live', openLimit: 1 }, + { openLimit: 1 }, ); + if (!endpoint) { + return false; + } const streamInfo = await getGo2RTCStreamMetadata( hass, diff --git a/src/card-controller/view/item-manager.ts b/src/card-controller/view/item-manager.ts index 275b0506..7dcb484b 100644 --- a/src/card-controller/view/item-manager.ts +++ b/src/card-controller/view/item-manager.ts @@ -1,9 +1,9 @@ import { format } from 'date-fns'; +import { homeAssistantGetSignedURLIfNecessary } from '../../ha/sign-path'; import { localize } from '../../localize/localize'; import { AdvancedCameraCardError } from '../../types'; import { errorToConsole } from '../../utils/basic'; import { downloadURL } from '../../utils/download'; -import { homeAssistantSignPath } from '../../ha/sign-path'; import { ViewItem } from '../../view/item'; import { ViewItemClassifier } from '../../view/item-classifier'; import { ViewItemCapabilities } from '../../view/types'; @@ -90,22 +90,19 @@ export class ViewItemManager { throw new AdvancedCameraCardError(localize('error.download_no_media')); } - let finalURL = endpoint.endpoint; - if (endpoint.sign) { - let response: string | null | undefined; - try { - response = await homeAssistantSignPath(hass, endpoint.endpoint); - } catch (e) { - errorToConsole(e as Error); - } - - if (!response) { - throw new AdvancedCameraCardError(localize('error.download_sign_failed')); - } - finalURL = response; + let url: string | null; + try { + url = await homeAssistantGetSignedURLIfNecessary(hass, endpoint); + } catch (e) { + errorToConsole(e as Error); + url = null; } - downloadURL(finalURL, this._generateDownloadFilename(item)); + if (!url) { + throw new AdvancedCameraCardError(localize('error.download_sign_failed')); + } + + downloadURL(url, this._generateDownloadFilename(item)); } private _generateDownloadFilename(item: ViewItem): string { diff --git a/src/components-lib/cached-value-controller.ts b/src/components-lib/cached-value-controller.ts index 056f24f1..f946ca3e 100644 --- a/src/components-lib/cached-value-controller.ts +++ b/src/components-lib/cached-value-controller.ts @@ -2,40 +2,42 @@ import { ReactiveController, ReactiveControllerHost } from 'lit'; import { Timer } from '../utils/timer'; export class CachedValueController 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 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 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 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(); } /** diff --git a/src/components-lib/media-player/updating-image.ts b/src/components-lib/media-player/updating-image.ts index 97b98e0d..dfc791af 100644 --- a/src/components-lib/media-player/updating-image.ts +++ b/src/components-lib/media-player/updating-image.ts @@ -55,7 +55,7 @@ export class UpdatingImageMediaPlayerController implements MediaPlayerController public async getScreenshotURL(): Promise { await this._host.updateComplete; - return this._getCachedValueController()?.value ?? null; + return this._getCachedValueController()?.getValue() ?? null; } public getFullscreenElement(): FullscreenElement | null { diff --git a/src/components-lib/signed-url-controller.ts b/src/components-lib/signed-url-controller.ts new file mode 100644 index 00000000..19c2fbc2 --- /dev/null +++ b/src/components-lib/signed-url-controller.ts @@ -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 { + 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 { + 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 { + 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(); + } +} diff --git a/src/components/image-updating-player.ts b/src/components/image-updating-player.ts index c4100c01..1edef9a7 100644 --- a/src/components/image-updating-player.ts +++ b/src/components/image-updating-player.ts @@ -11,12 +11,13 @@ import { customElement, property, state } from 'lit/decorators.js'; import { live } from 'lit/directives/live.js'; import { createRef, ref, Ref } from 'lit/directives/ref.js'; import { isEqual } from 'lodash-es'; -import { CameraManager } from '../camera-manager/manager.js'; import { getCameraEntityFromConfig } from '../camera-manager/utils/camera-entity-from-config.js'; import { CachedValueController } from '../components-lib/cached-value-controller.js'; import { UpdatingImageMediaPlayerController } from '../components-lib/media-player/updating-image.js'; +import { SignedURLController } from '../components-lib/signed-url-controller.js'; import { CameraConfig } from '../config/schema/cameras.js'; import { type ImageBaseConfig, ImageMode } from '../config/schema/common/image.js'; +import { EnabledProxyConfig } from '../config/schema/common/proxy.js'; import { isHassDifferent } from '../ha/is-hass-different.js'; import { HomeAssistant } from '../ha/types.js'; import defaultImage from '../images/iris-screensaver.jpg'; @@ -79,8 +80,8 @@ export class AdvancedCameraCardImageUpdatingPlayer @property({ attribute: false }) public cameraConfig?: CameraConfig; - @property({ attribute: false }) - public cameraManager?: CameraManager; + @property({ attribute: false, hasChanged: contentsChanged }) + public proxyConfig?: EnabledProxyConfig; // Using contentsChanged to ensure overridden configs (e.g. when the // 'show_image_during_load' option is true for live views, an overridden @@ -89,11 +90,36 @@ export class AdvancedCameraCardImageUpdatingPlayer public imageConfig?: ImageBaseConfig; @state() - private _message: Message | null = null; + private _imageLoadError = false; private _refImage: Ref = createRef(); - private _cachedValueController?: CachedValueController; + private _cachedValueController = new CachedValueController( + this, + () => this.imageConfig?.refresh_seconds ?? null, + () => this._getImageSource(), + () => dispatchMediaPlayEvent(this), + () => dispatchMediaPauseEvent(this), + // Clear image load errors on each timer tick so the next render retries the + // . Retries are bounded by refresh_seconds, not a tight loop. + () => { + this._imageLoadError = false; + }, + ); + + private _signedURLController = new SignedURLController( + this, + () => ({ + hass: this.hass, + endpoint: this.imageConfig?.url ? { endpoint: this.imageConfig.url } : undefined, + proxyConfig: this.proxyConfig, + }), + () => { + this._cachedValueController.clearValue(); + this._imageLoadError = false; + }, + ); + private _boundVisibilityHandler = this._visibilityHandler.bind(this); private _mediaLoadedInfo: MediaLoadedInfo | null = null; @@ -101,7 +127,7 @@ export class AdvancedCameraCardImageUpdatingPlayer private _mediaPlayerController = new UpdatingImageMediaPlayerController( this, () => this._refImage.value ?? null, - () => this._cachedValueController ?? null, + () => this._cachedValueController, ); public async getMediaPlayerController(): Promise { @@ -143,21 +169,6 @@ export class AdvancedCameraCardImageUpdatingPlayer * @param _changedProps The changed properties */ protected willUpdate(changedProps: PropertyValues): void { - if (changedProps.has('imageConfig')) { - if (this._cachedValueController) { - this._cachedValueController.removeController(); - } - if (this.imageConfig) { - this._cachedValueController = new CachedValueController( - this, - this.imageConfig.refresh_seconds, - this._getImageSource.bind(this), - () => dispatchMediaPlayEvent(this), - () => dispatchMediaPauseEvent(this), - ); - } - } - const relevantEntity = this._getRelevantEntityForMode( resolveImageMode({ imageConfig: this.imageConfig, @@ -170,20 +181,19 @@ export class AdvancedCameraCardImageUpdatingPlayer // the state is not acceptable, discard the old value (to allow a stock or // backup image to be displayed). if ( + changedProps.has('imageConfig') || changedProps.has('cameraConfig') || + changedProps.has('proxyConfig') || changedProps.has('view') || (relevantEntity && !this._getAcceptableState(relevantEntity)) ) { this._cachedValueController?.clearValue(); + this._imageLoadError = false; } - if (!this._cachedValueController?.value) { + if (!this._cachedValueController?.getValue()) { this._cachedValueController?.updateValue(); } - - if (['imageConfig', 'view'].some((prop) => changedProps.has(prop))) { - this._message = null; - } } /** @@ -220,7 +230,7 @@ export class AdvancedCameraCardImageUpdatingPlayer */ disconnectedCallback(): void { this._cachedValueController?.stopTimer(); - this._message = null; + this._imageLoadError = false; document.removeEventListener('visibilitychange', this._boundVisibilityHandler); super.disconnectedCallback(); } @@ -254,12 +264,23 @@ export class AdvancedCameraCardImageUpdatingPlayer } /** - * Build a working absolute image URL that the browser will not cache. - * @param url An input URL (may be relative to document origin) - * @returns A new URL as a string (absolute, will not be browser cached). + * Build an image URL that the browser will not cache. Supports two modes: + * - 'query-string': Appends a `_t` parameter. This is the most robust way to + * defeat caching (it bypasses HTTP caches) but it changes the path sent to + * the server and so can invalidate signed URLs. + * - 'fragment': Appends a `_t` fragment. This is less robust (the browser + * might still serve from its HTTP cache) but it does not change the URL + * sent to the server so it is safe for signed URLs. + * @param url The input URL. + * @param mode The cache-busting mode. + * @returns The cache-busted URL string. */ - private _buildImageURL(url: URL): string { - url.searchParams.append('_t', String(Date.now())); + private _buildCacheBustURL(url: URL, mode: 'query-string' | 'fragment'): string { + if (mode === 'query-string') { + url.searchParams.append('_t', String(Date.now())); + } else { + url.hash = `_t=${Date.now()}`; + } return url.toString(); } @@ -294,7 +315,7 @@ export class AdvancedCameraCardImageUpdatingPlayer if (state?.attributes.entity_picture) { const urlObj = new URL(state.attributes.entity_picture, document.baseURI); this._addQueryParametersToURL(urlObj, this.imageConfig?.entity_parameters); - return this._buildImageURL(urlObj); + return this._buildCacheBustURL(urlObj, 'query-string'); } } @@ -303,12 +324,21 @@ export class AdvancedCameraCardImageUpdatingPlayer if (state?.attributes.entity_picture) { const urlObj = new URL(state.attributes.entity_picture, document.baseURI); this._addQueryParametersToURL(urlObj, this.imageConfig?.entity_parameters); - return this._buildImageURL(urlObj); + return this._buildCacheBustURL(urlObj, 'query-string'); } } if (mode === 'url' && this.imageConfig?.url) { - return this._buildImageURL(new URL(this.imageConfig.url, document.baseURI)); + const url = this._signedURLController.getValue(); + if (url) { + const urlObj = new URL(url, document.baseURI); + if (this.proxyConfig?.enabled) { + // Use a fragment for cache-busting proxied URLs, as this does not + // change the path and thus preserves the validity of the signed URL. + return this._buildCacheBustURL(urlObj, 'fragment'); + } + return this._buildCacheBustURL(urlObj, 'query-string'); + } } return defaultImage; @@ -319,17 +349,43 @@ export class AdvancedCameraCardImageUpdatingPlayer */ private _forceSafeImage(stockOnly?: boolean): void { if (this._refImage.value) { - this._refImage.value.src = - !stockOnly && this.imageConfig?.url ? this.imageConfig.url : defaultImage; + // Avoid restoring the raw configured URL when proxying is enabled, since + // that would bypass the proxied/signed URL path on visibility changes. + const configuredURL = + !stockOnly && !this.proxyConfig?.enabled ? this.imageConfig?.url ?? null : null; + this._refImage.value.src = configuredURL ?? defaultImage; } } + private _getDisplayMessage(): Message | null { + const error = this._signedURLController.getError(); + if (error) { + return { + type: 'error', + message: localize( + error === 'proxy' ? 'error.failed_proxy' : 'error.failed_sign', + ), + context: this.proxyConfig, + }; + } + if (this._imageLoadError) { + return { + type: 'error', + message: localize('error.image_load_error'), + context: this.imageConfig, + }; + } + return null; + } + protected render(): TemplateResult | void { - if (this._message) { - return renderMessage(this._message); + const message = this._getDisplayMessage(); + if (message) { + return renderMessage(message); } - const src = this._cachedValueController?.value; + const src = this._cachedValueController?.getValue(); + // Note the use of live() below to ensure the update will restore the image // src if it's been changed via _forceSafeImage(). return src @@ -364,13 +420,7 @@ export class AdvancedCameraCardImageUpdatingPlayer // failed to load. this._forceSafeImage(true); } else if (mode === 'url') { - // In url mode, the user likely specified a URL that cannot be - // resolved. Show an error message. - this._message = { - type: 'error', - message: localize('error.image_load_error'), - context: this.imageConfig, - }; + this._imageLoadError = true; } }} /> diff --git a/src/components/image.ts b/src/components/image.ts index 669ecf21..e9ef1984 100644 --- a/src/components/image.ts +++ b/src/components/image.ts @@ -7,7 +7,11 @@ import { ViewManagerEpoch } from '../card-controller/view/types'; import { ZoomSettingsObserved } from '../components-lib/zoom/types'; import { handleZoomSettingsObservedEvent } from '../components-lib/zoom/zoom-view-context'; import { CameraConfig } from '../config/schema/cameras'; -import { ImageViewConfig } from '../config/schema/image'; +import { + type EnabledProxyConfig, + resolveProxyConfig, +} from '../config/schema/common/proxy'; +import { ImageViewConfig, type ImageViewProxyConfig } from '../config/schema/image'; import { IMAGE_VIEW_ZOOM_TARGET_SENTINEL } from '../const'; import { HomeAssistant } from '../ha/types'; import { localize } from '../localize/localize.js'; @@ -82,6 +86,16 @@ export class AdvancedCameraCardImage extends LitElement implements MediaPlayer { : intermediateTemplate}`; } + private _resolveProxyConfig(proxy?: ImageViewProxyConfig): EnabledProxyConfig | null { + return proxy + ? { + ...resolveProxyConfig(proxy), + enabled: proxy.enabled, + enforce: proxy.enabled, + } + : null; + } + protected render(): TemplateResult | void { if (!this.hass) { return; @@ -108,6 +122,7 @@ export class AdvancedCameraCardImage extends LitElement implements MediaPlayer { .view=${this.viewManagerEpoch?.manager.getView()} .imageConfig=${this.imageConfig} .cameraConfig=${this.cameraConfig} + .proxyConfig=${this._resolveProxyConfig(this.imageConfig?.proxy) ?? undefined} > `); diff --git a/src/components/live/provider.ts b/src/components/live/provider.ts index 41fd5583..7717ae6f 100644 --- a/src/components/live/provider.ts +++ b/src/components/live/provider.ts @@ -302,6 +302,7 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP ${ref(this._refProvider)} .hass=${this.hass} .cameraConfig=${cameraConfig} + .proxyConfig=${this.camera.getLiveProxyConfig()} class=${classMap({ ...classes, // The image provider is providing the temporary loading image, diff --git a/src/components/live/providers/go2rtc/index.ts b/src/components/live/providers/go2rtc/index.ts index 989224d9..03345c71 100644 --- a/src/components/live/providers/go2rtc/index.ts +++ b/src/components/live/providers/go2rtc/index.ts @@ -6,35 +6,26 @@ import { TemplateResult, unsafeCSS, } from 'lit'; -import { customElement, property, state } from 'lit/decorators.js'; +import { customElement, property } from 'lit/decorators.js'; import { Camera } from '../../../../camera-manager/camera.js'; import { CameraEndpoints } from '../../../../camera-manager/types.js'; import { MicrophoneState } from '../../../../card-controller/types.js'; import { dispatchLiveErrorEvent } from '../../../../components-lib/live/utils/dispatch-live-error.js'; import { VideoMediaPlayerController } from '../../../../components-lib/media-player/video.js'; +import { SignedURLController } from '../../../../components-lib/signed-url-controller.js'; import { MicrophoneConfig } from '../../../../config/schema/live.js'; -import { homeAssistantSignPath } from '../../../../ha/sign-path.js'; import { HomeAssistant } from '../../../../ha/types.js'; -import { createProxiedEndpointIfNecessary } from '../../../../ha/web-proxy.js'; import { localize } from '../../../../localize/localize.js'; import liveGo2RTCStyle from '../../../../scss/live-go2rtc.scss'; -import { MediaPlayer, MediaPlayerController, Message } from '../../../../types.js'; -import { errorToConsole } from '../../../../utils/basic.js'; +import { MediaPlayer, MediaPlayerController } from '../../../../types.js'; import { renderMessage } from '../../../message.js'; import { VideoRTC } from './video-rtc.js'; customElements.define('advanced-camera-card-live-go2rtc-player', VideoRTC); -// Note (2023-02-18): Depending on the behavior of the player / browser is -// possible this URL will need to be re-signed in order to avoid HA spamming -// logs after the expiry time, but this complexity is not added for now until -// there are verified cases of this being an issue (see equivalent in the JSMPEG -// provider). -const GO2RTC_URL_SIGN_EXPIRY_SECONDS = 24 * 60 * 60; - @customElement('advanced-camera-card-live-go2rtc') export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer { - // Not an reactive property to avoid resetting the video. + // Not a reactive property to avoid resetting the video. public hass?: HomeAssistant; @property({ attribute: false }) @@ -52,10 +43,8 @@ export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer @property({ attribute: true, type: Boolean }) public controls = false; - @state() - private _message: Message | null = null; - private _player?: VideoRTC; + private _hasLiveError = false; private _mediaPlayerController = new VideoMediaPlayerController( this, @@ -63,13 +52,29 @@ export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer () => this.controls, ); + private _signedURLController = new SignedURLController( + this, + () => { + const endpoint = this.cameraEndpoints?.go2rtc; + if (!this.hass || !endpoint) { + return {}; + } + return { + hass: this.hass, + endpoint, + proxyConfig: this.camera?.getLiveProxyConfig(), + proxyEndpointOptions: { websocket: true }, + }; + }, + () => this._createPlayer(), + ); + public async getMediaPlayerController(): Promise { return this._mediaPlayerController; } disconnectedCallback(): void { this._player = undefined; - this._message = null; super.disconnectedCallback(); } @@ -81,82 +86,8 @@ export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer this.requestUpdate(); } - private _handleError(message: Message, e?: Error): void { - if (e) { - errorToConsole(e as Error); - } - - this._message = { - type: 'error', - ...message, - }; - dispatchLiveErrorEvent(this); - return; - } - - private async _getPlayerSource(): Promise { - const cameraConfig = this.camera?.getConfig(); - const proxyConfig = this.camera?.getProxyConfig(); - if (!this.hass || !cameraConfig) { - return null; - } - - const streamEndpoint = this.cameraEndpoints?.go2rtc; - if (!streamEndpoint) { - this._handleError({ - message: localize('error.live_camera_no_endpoint'), - context: cameraConfig, - }); - return null; - } - - let result: string | null = null; - - try { - const endpoint = await createProxiedEndpointIfNecessary( - this.hass, - streamEndpoint, - proxyConfig, - { - context: 'live', - ttl: GO2RTC_URL_SIGN_EXPIRY_SECONDS, - websocket: true, - - // The link may need to be opened multiple times. - openLimit: 0, - }, - ); - - if (endpoint.sign) { - result = await homeAssistantSignPath( - this.hass, - endpoint.endpoint, - GO2RTC_URL_SIGN_EXPIRY_SECONDS, - ); - if (!result) { - this._handleError({ - message: localize('error.failed_sign'), - context: cameraConfig, - }); - } - } else { - result = endpoint.endpoint; - } - } catch (e) { - this._handleError( - { - message: localize('error.failed_proxy'), - context: cameraConfig, - }, - e as Error, - ); - } - - return result; - } - - private async _createPlayer(): Promise { - const src = await this._getPlayerSource(); + private _createPlayer(): void { + const src = this._signedURLController.getValue(); if (!src) { return; } @@ -178,12 +109,20 @@ export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer protected willUpdate(changedProps: PropertyValues): void { if (changedProps.has('cameraEndpoints')) { - this._message = null; + // Clear old player; the new one is created by the + // SignedURLController's valueChangeCallback once the URL resolves. + this._player = undefined; } - if (!this._message && (!this._player || changedProps.has('cameraEndpoints'))) { - this._createPlayer(); + // Only treat a missing go2rtc endpoint as an error after cameraEndpoints + // has been explicitly set (not undefined / still loading). + const hasError = + !!this._signedURLController.getError() || + (!!this.cameraEndpoints && !this.cameraEndpoints.go2rtc); + if (hasError && !this._hasLiveError) { + dispatchLiveErrorEvent(this); } + this._hasLiveError = hasError; if (changedProps.has('controls') && this._player) { this._player.setControls(this.controls); @@ -203,8 +142,22 @@ export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer } protected render(): TemplateResult | void { - if (this._message) { - return renderMessage(this._message); + const error = this._signedURLController.getError(); + if (error) { + return renderMessage({ + type: 'error', + message: localize( + error === 'proxy' ? 'error.failed_proxy' : 'error.failed_sign', + ), + context: this.camera?.getConfig(), + }); + } + if (!this.cameraEndpoints?.go2rtc) { + return renderMessage({ + type: 'error', + message: localize('error.live_camera_no_endpoint'), + context: this.camera?.getConfig(), + }); } return html`${this._player}`; } @@ -216,6 +169,7 @@ export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer declare global { interface HTMLElementTagNameMap { + 'advanced-camera-card-live-go2rtc-player': VideoRTC; 'advanced-camera-card-live-go2rtc': AdvancedCameraCardGo2RTC; } } diff --git a/src/components/live/providers/image.ts b/src/components/live/providers/image.ts index 5433fb8b..55dad3f8 100644 --- a/src/components/live/providers/image.ts +++ b/src/components/live/providers/image.ts @@ -2,6 +2,7 @@ import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit import { customElement, property } from 'lit/decorators.js'; import { createRef, ref, Ref } from 'lit/directives/ref.js'; import { CameraConfig } from '../../../config/schema/cameras'; +import { EnabledProxyConfig } from '../../../config/schema/common/proxy'; import { HomeAssistant } from '../../../ha/types'; import basicBlockStyle from '../../../scss/basic-block.scss'; import { @@ -19,6 +20,9 @@ export class AdvancedCameraCardLiveImage extends LitElement implements MediaPlay @property({ attribute: false }) public cameraConfig?: CameraConfig; + @property({ attribute: false }) + public proxyConfig?: EnabledProxyConfig; + private _refImage: Ref = createRef(); public async getMediaPlayerController(): Promise { @@ -37,6 +41,7 @@ export class AdvancedCameraCardLiveImage extends LitElement implements MediaPlay .hass=${this.hass} .imageConfig=${this.cameraConfig.image} .cameraConfig=${this.cameraConfig} + .proxyConfig=${this.proxyConfig} > `; diff --git a/src/components/live/providers/jsmpeg.ts b/src/components/live/providers/jsmpeg.ts index 350f964e..a1627b99 100644 --- a/src/components/live/providers/jsmpeg.ts +++ b/src/components/live/providers/jsmpeg.ts @@ -14,7 +14,7 @@ import { dispatchLiveErrorEvent } from '../../../components-lib/live/utils/dispa import { JSMPEGMediaPlayerController } from '../../../components-lib/media-player/jsmpeg.js'; import { CameraConfig } from '../../../config/schema/cameras.js'; import { CardWideConfig } from '../../../config/schema/types.js'; -import { homeAssistantSignPath } from '../../../ha/sign-path.js'; +import { homeAssistantGetSignedURLIfNecessary } from '../../../ha/sign-path.js'; import { HomeAssistant } from '../../../ha/types.js'; import { localize } from '../../../localize/localize.js'; import liveJSMPEGStyle from '../../../scss/live-jsmpeg.scss'; @@ -186,9 +186,9 @@ export class AdvancedCameraCardLiveJSMPEG extends LitElement implements MediaPla let response: string | null | undefined; try { - response = await homeAssistantSignPath( + response = await homeAssistantGetSignedURLIfNecessary( this.hass, - endpoint.endpoint, + endpoint, JSMPEG_URL_SIGN_EXPIRY_SECONDS, ); } catch (e) { diff --git a/src/components/viewer/provider.ts b/src/components/viewer/provider.ts index 52559c4c..cbfc8e85 100644 --- a/src/components/viewer/provider.ts +++ b/src/components/viewer/provider.ts @@ -6,13 +6,14 @@ import { TemplateResult, unsafeCSS, } from 'lit'; -import { customElement, property, state } from 'lit/decorators.js'; +import { customElement, property } from 'lit/decorators.js'; import { guard } from 'lit/directives/guard.js'; import { createRef, Ref, ref } from 'lit/directives/ref.js'; import { CameraManager } from '../../camera-manager/manager.js'; import { QueryType } from '../../camera-manager/types.js'; import { ViewManagerEpoch } from '../../card-controller/view/types.js'; import { LazyLoadController } from '../../components-lib/lazy-load-controller.js'; +import { SignedURLController } from '../../components-lib/signed-url-controller.js'; import { ZoomSettingsObserved } from '../../components-lib/zoom/types.js'; import { handleZoomSettingsObservedEvent } from '../../components-lib/zoom/zoom-view-context.js'; import { CameraConfig } from '../../config/schema/cameras.js'; @@ -21,17 +22,16 @@ import { ViewerConfig } from '../../config/schema/viewer.js'; import { canonicalizeHAURL } from '../../ha/canonical-url.js'; import { isHARelativeURL } from '../../ha/is-ha-relative-url.js'; import { ResolvedMediaCache, resolveMedia } from '../../ha/resolved-media.js'; -import { homeAssistantSignPath } from '../../ha/sign-path.js'; -import { HomeAssistant, ResolvedMedia } from '../../ha/types.js'; -import { createProxiedEndpointIfNecessary } from '../../ha/web-proxy.js'; +import { HomeAssistant } from '../../ha/types.js'; +import { localize } from '../../localize/localize.js'; import '../../patches/ha-hls-player.js'; import viewerProviderStyle from '../../scss/viewer-provider.scss'; import { MediaPlayer, MediaPlayerController, MediaPlayerElement } from '../../types.js'; -import { errorToConsole } from '../../utils/basic.js'; import { ViewItemClassifier } from '../../view/item-classifier.js'; import { VideoContentType, ViewMedia } from '../../view/item.js'; import { UnifiedQueryTransformer } from '../../view/unified-query-transformer.js'; import '../image-player.js'; +import { renderMessage } from '../message.js'; import { renderProgressIndicator } from '../progress-indicator.js'; import '../video-player.js'; import './../media-dimensions-container'; @@ -60,15 +60,32 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi public cardWideConfig?: CardWideConfig; private _refProvider: Ref = createRef(); - private _refContainer: Ref = createRef(); private _lazyLoadController: LazyLoadController = new LazyLoadController(this); - @state() - private _url: string | null = null; + private _resolvedMediaURL: string | null = null; + + private _signedURLController = new SignedURLController(this, () => { + if (!this.hass || !this._resolvedMediaURL) { + return {}; + } + // HA-relative URLs need no proxying or signing. + if (isHARelativeURL(this._resolvedMediaURL)) { + return { + endpoint: { endpoint: canonicalizeHAURL(this.hass, this._resolvedMediaURL) }, + }; + } + const cameraID = this.media?.getCameraID(); + const camera = cameraID ? this.cameraManager?.getStore().getCamera(cameraID) : null; + return { + hass: this.hass, + endpoint: { endpoint: this._resolvedMediaURL }, + proxyConfig: camera?.getMediaProxyConfig(), + }; + }); constructor() { super(); - this._lazyLoadController.addListener((loaded) => loaded && this._setURL()); + this._lazyLoadController.addListener((loaded) => loaded && this._resolveURL()); } public async getMediaPlayerController(): Promise { @@ -108,69 +125,23 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi }); } - private async _setURL(): Promise { - const mediaContentID = this.media?.getContentID(); - if ( - !this.media || - !mediaContentID || - !this.hass || - !this._lazyLoadController?.isLoaded() - ) { + private async _resolveURL(): Promise { + const contentID = this.media?.getContentID(); + if (!contentID || !this.hass || !this._lazyLoadController?.isLoaded()) { + this._resolvedMediaURL = null; return; } - let resolvedMedia: ResolvedMedia | null = - this.resolvedMediaCache?.get(mediaContentID) ?? null; - if (!resolvedMedia) { - resolvedMedia = await resolveMedia( - this.hass, - mediaContentID, - this.resolvedMediaCache, - ); - } + // Clear immediately so the SignedURLController doesn't see a stale URL + // from the previous media item during the async gap. + this._resolvedMediaURL = null; - if (!resolvedMedia) { - return; - } + const resolved = + this.resolvedMediaCache?.get(contentID) ?? + (await resolveMedia(this.hass, contentID, this.resolvedMediaCache)); - const unsignedURL = resolvedMedia.url; - if (isHARelativeURL(unsignedURL)) { - // No need to proxy or sign local resolved URLs. - this._url = canonicalizeHAURL(this.hass, unsignedURL); - return; - } - - const cameraID = this.media.getCameraID(); - const camera = cameraID ? this.cameraManager?.getStore().getCamera(cameraID) : null; - const proxyConfig = camera?.getProxyConfig(); - - if (!proxyConfig) { - this._url = unsignedURL; - return; - } - - try { - // Create endpoint from unsigned URL - it doesn't need signing initially - const unsignedEndpoint = { endpoint: unsignedURL, sign: false }; - const proxiedEndpoint = await createProxiedEndpointIfNecessary( - this.hass, - unsignedEndpoint, - proxyConfig, - { - context: 'media', - // The link may need to be opened multiple times. - openLimit: 0, - }, - ); - - if (proxiedEndpoint.sign) { - this._url = await homeAssistantSignPath(this.hass, proxiedEndpoint.endpoint); - } else { - this._url = proxiedEndpoint.endpoint; - } - } catch (e) { - errorToConsole(e as Error); - } + this._resolvedMediaURL = resolved?.url ?? null; + this.requestUpdate(); } protected willUpdate(changedProps: PropertyValues): void { @@ -187,7 +158,7 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi changedProps.has('resolvedMediaCache') || changedProps.has('hass') ) { - this._setURL(); + this._resolveURL(); } if (changedProps.has('viewerConfig') && this.viewerConfig?.zoomable) { @@ -260,7 +231,19 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi return; } - if (!this._url) { + const error = this._signedURLController.getError(); + if (error) { + return renderMessage({ + type: 'error', + message: localize( + error === 'proxy' ? 'error.failed_proxy' : 'error.failed_sign', + ), + context: this.media?.getContentID(), + }); + } + + const url = this._signedURLController.getValue(); + if (!url) { return renderProgressIndicator({ cardWideConfig: this.cardWideConfig, }); @@ -280,7 +263,7 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi muted playsinline title="${this.media.getTitle() ?? ''}" - url=${this._url} + url=${url} .hass=${this.hass} ?controls=${this.viewerConfig.controls.builtin} > @@ -288,7 +271,7 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi : html` { diff --git a/src/config/schema/cameras.ts b/src/config/schema/cameras.ts index 5aa21671..cd1e632a 100644 --- a/src/config/schema/cameras.ts +++ b/src/config/schema/cameras.ts @@ -5,6 +5,7 @@ import { ptzCameraConfigDefaults, ptzCameraConfigSchema } from './camera/ptz'; import { aspectRatioSchema } from './common/aspect-ratio'; import { eventsMediaTypeSchema } from './common/events-media'; import { imageBaseConfigDefault, imageBaseConfigSchema } from './common/image'; +import { proxyBaseConfigDefault, proxyBaseConfigSchema } from './common/proxy'; import { severitySchema } from './common/severity'; const CAMERA_TRIGGER_EVENT_TYPES = [ @@ -143,32 +144,18 @@ export const cameraConfigDefault = { }, }, proxy: { - dynamic: true, + ...proxyBaseConfigDefault, live: 'auto' as const, media: 'auto' as const, - ssl_ciphers: 'auto' as const, - ssl_verification: 'auto' as const, }, go2rtc: go2rtcConfigDefault, image: imageBaseConfigDefault, always_error_if_entity_unavailable: false, }; -const SSL_CIPHERS = ['default', 'insecure', 'intermediate', 'modern'] as const; -export type SSLCiphers = (typeof SSL_CIPHERS)[number]; - -const proxyConfigSchema = z.object({ +const proxyConfigSchema = proxyBaseConfigSchema.extend({ live: z.boolean().or(z.literal('auto')).default(cameraConfigDefault.proxy.live), media: z.boolean().or(z.literal('auto')).default(cameraConfigDefault.proxy.media), - dynamic: z.boolean().default(cameraConfigDefault.proxy.dynamic), - ssl_verification: z - .boolean() - .or(z.literal('auto')) - .default(cameraConfigDefault.proxy.ssl_verification), - ssl_ciphers: z - .enum(SSL_CIPHERS) - .or(z.literal('auto')) - .default(cameraConfigDefault.proxy.ssl_ciphers), }); const rotationSchema = z diff --git a/src/config/schema/common/image.ts b/src/config/schema/common/image.ts index d374c1e9..1f883402 100644 --- a/src/config/schema/common/image.ts +++ b/src/config/schema/common/image.ts @@ -5,11 +5,6 @@ export const imageBaseConfigDefault = { refresh_seconds: 1, }; -export const imageConfigDefault = { - ...imageBaseConfigDefault, - zoomable: true, -}; - const IMAGE_MODES = ['auto', 'camera', 'entity', 'screensaver', 'url'] as const; export type ImageMode = (typeof IMAGE_MODES)[number]; diff --git a/src/config/schema/common/proxy.ts b/src/config/schema/common/proxy.ts new file mode 100644 index 00000000..49188d1c --- /dev/null +++ b/src/config/schema/common/proxy.ts @@ -0,0 +1,45 @@ +import { z } from 'zod'; + +const SSL_CIPHERS = ['default', 'insecure', 'intermediate', 'modern'] as const; +type SSLCiphers = (typeof SSL_CIPHERS)[number]; + +export const proxyBaseConfigDefault = { + dynamic: true, + ssl_ciphers: 'auto' as const, + ssl_verification: 'auto' as const, +}; + +export const proxyBaseConfigSchema = z.object({ + dynamic: z.boolean().default(proxyBaseConfigDefault.dynamic), + ssl_verification: z + .boolean() + .or(z.literal('auto')) + .default(proxyBaseConfigDefault.ssl_verification), + ssl_ciphers: z + .enum(SSL_CIPHERS) + .or(z.literal('auto')) + .default(proxyBaseConfigDefault.ssl_ciphers), +}); +type UnresolvedProxyConfig = z.output; + +export interface ResolvedProxyConfig { + dynamic: boolean; + ssl_verification: boolean; + ssl_ciphers: SSLCiphers; +} + +export interface EnabledProxyConfig extends ResolvedProxyConfig { + enabled: boolean; + + // Whether proxying is a strict requirement. When false, callers may fall + // back to the original URL if the proxy integration is unavailable. + enforce?: boolean; +} + +export const resolveProxyConfig = ( + config: UnresolvedProxyConfig, +): ResolvedProxyConfig => ({ + dynamic: config.dynamic, + ssl_verification: config.ssl_verification === 'auto' ? true : config.ssl_verification, + ssl_ciphers: config.ssl_ciphers === 'auto' ? 'default' : config.ssl_ciphers, +}); diff --git a/src/config/schema/image.ts b/src/config/schema/image.ts index 5140a89e..2b2ade16 100644 --- a/src/config/schema/image.ts +++ b/src/config/schema/image.ts @@ -1,9 +1,25 @@ import { z } from 'zod'; import { actionsSchema } from './actions/types'; -import { imageBaseConfigSchema, imageConfigDefault } from './common/image'; +import { imageBaseConfigDefault, imageBaseConfigSchema } from './common/image'; +import { proxyBaseConfigDefault, proxyBaseConfigSchema } from './common/proxy'; + +export const imageConfigDefault = { + ...imageBaseConfigDefault, + proxy: { + ...proxyBaseConfigDefault, + enabled: false, + }, + zoomable: true, +}; + +const imageProxyConfigSchema = proxyBaseConfigSchema.extend({ + enabled: z.boolean().default(imageConfigDefault.proxy.enabled), +}); +export type ImageViewProxyConfig = z.infer; export const imageConfigSchema = imageBaseConfigSchema .extend({ + proxy: imageProxyConfigSchema.optional(), zoomable: z.boolean().default(imageConfigDefault.zoomable), }) .extend(actionsSchema.shape) diff --git a/src/config/schema/types.ts b/src/config/schema/types.ts index df7ed2f6..e282aa10 100644 --- a/src/config/schema/types.ts +++ b/src/config/schema/types.ts @@ -3,12 +3,11 @@ import { deepRemoveDefaults } from '../../utils/zod/deep-remove-defaults'; import { automationsSchema } from './automations'; import { cameraConfigDefault, cameraConfigSchema, camerasConfigSchema } from './cameras'; import { cardIDRegex } from './common/const'; -import { imageConfigDefault } from './common/image'; import { DebugConfig, debugConfigDefault, debugConfigSchema } from './debug'; import { dimensionsConfigSchema } from './dimensions'; import { pictureElementsSchema } from './elements/types'; import { foldersConfigSchema } from './folders'; -import { imageConfigSchema } from './image'; +import { imageConfigDefault, imageConfigSchema } from './image'; import { liveConfigDefault, liveConfigSchema } from './live'; import { mediaGalleryConfigDefault, mediaGalleryConfigSchema } from './media-gallery'; import { menuConfigDefault, menuConfigSchema } from './menu'; diff --git a/src/const.ts b/src/const.ts index 76af50cf..60241c1b 100644 --- a/src/const.ts +++ b/src/const.ts @@ -364,6 +364,11 @@ const CONF_IMAGE = 'image' as const; export const CONF_IMAGE_ENTITY = `${CONF_IMAGE}.entity` as const; export const CONF_IMAGE_ENTITY_PARAMETERS = `${CONF_IMAGE}.entity_parameters` as const; export const CONF_IMAGE_MODE = `${CONF_IMAGE}.mode` as const; +export const CONF_IMAGE_PROXY_DYNAMIC = `${CONF_IMAGE}.proxy.dynamic` as const; +export const CONF_IMAGE_PROXY_ENABLED = `${CONF_IMAGE}.proxy.enabled` as const; +export const CONF_IMAGE_PROXY_SSL_CIPHERS = `${CONF_IMAGE}.proxy.ssl_ciphers` as const; +export const CONF_IMAGE_PROXY_SSL_VERIFICATION = + `${CONF_IMAGE}.proxy.ssl_verification` as const; export const CONF_IMAGE_REFRESH_SECONDS = `${CONF_IMAGE}.refresh_seconds` as const; export const CONF_IMAGE_URL = `${CONF_IMAGE}.url` as const; diff --git a/src/editor.ts b/src/editor.ts index ee65f1de..9c74b30b 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -110,6 +110,10 @@ import { CONF_IMAGE_ENTITY, CONF_IMAGE_ENTITY_PARAMETERS, CONF_IMAGE_MODE, + CONF_IMAGE_PROXY_DYNAMIC, + CONF_IMAGE_PROXY_ENABLED, + CONF_IMAGE_PROXY_SSL_CIPHERS, + CONF_IMAGE_PROXY_SSL_VERIFICATION, CONF_IMAGE_REFRESH_SECONDS, CONF_IMAGE_URL, CONF_LIVE_AUTO_MUTE, @@ -315,6 +319,7 @@ const MENU_MEDIA_VIEWER_CONTROLS_TIMELINE = 'media_viewer.controls.timeline'; const MENU_MEDIA_VIEWER_CONTROLS_TIMELINE_FORMAT = 'media_viewer.controls.timeline.format'; const MENU_MEDIA_VIEWER_DISPLAY = 'media_viewer.display'; +const MENU_IMAGE_PROXY = 'image.proxy'; const MENU_MENU_BUTTONS = 'menu.buttons'; const MENU_OPTIONS = 'options'; const MENU_PERFORMANCE_FEATURES = 'performance.features'; @@ -388,6 +393,7 @@ const SUBMENU_DOC_LINKS: Record = { [MENU_MEDIA_VIEWER_CONTROLS_TIMELINE]: 'configuration/media-viewer?id=timeline', [MENU_MEDIA_VIEWER_CONTROLS_TIMELINE_FORMAT]: 'configuration/media-viewer?id=format', [MENU_MEDIA_VIEWER_DISPLAY]: 'configuration/media-viewer?id=display', + [MENU_IMAGE_PROXY]: 'configuration/image?id=proxy', [MENU_MENU_BUTTONS]: 'configuration/menu?id=buttons', [MENU_OPTIONS]: 'configuration/README', [MENU_PERFORMANCE_FEATURES]: 'configuration/performance?id=features', @@ -1053,15 +1059,15 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard { value: '', label: '' }, { value: 'auto', - label: localize('config.cameras.proxy.modes.auto'), + label: localize('config.common.proxy.modes.auto'), }, { value: true, - label: localize('config.cameras.proxy.modes.true'), + label: localize('config.common.proxy.modes.true'), }, { value: false, - label: localize('config.cameras.proxy.modes.false'), + label: localize('config.common.proxy.modes.false'), }, ]; @@ -1069,23 +1075,23 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard { value: '', label: '' }, { value: 'auto', - label: localize('config.cameras.proxy.ssl_ciphers.auto'), + label: localize('config.common.proxy.ssl_ciphers.auto'), }, { value: 'default', - label: localize('config.cameras.proxy.ssl_ciphers.default'), + label: localize('config.common.proxy.ssl_ciphers.default'), }, { value: 'insecure', - label: localize('config.cameras.proxy.ssl_ciphers.insecure'), + label: localize('config.common.proxy.ssl_ciphers.insecure'), }, { value: 'intermediate', - label: localize('config.cameras.proxy.ssl_ciphers.intermediate'), + label: localize('config.common.proxy.ssl_ciphers.intermediate'), }, { value: 'modern', - label: localize('config.cameras.proxy.ssl_ciphers.modern'), + label: localize('config.common.proxy.ssl_ciphers.modern'), }, ]; @@ -1093,15 +1099,15 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard { value: '', label: '' }, { value: 'auto', - label: localize('config.cameras.proxy.ssl_verification.auto'), + label: localize('config.common.proxy.ssl_verification.auto'), }, { value: true, - label: localize('config.cameras.proxy.ssl_verification.true'), + label: localize('config.common.proxy.ssl_verification.true'), }, { value: false, - label: localize('config.cameras.proxy.ssl_verification.false'), + label: localize('config.common.proxy.ssl_verification.false'), }, ]; @@ -2102,6 +2108,9 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard configPathEntity: string, configPathEntityParameters: string, configPathRefreshSeconds: string, + options?: { + proxyMenu?: TemplateResult; + }, ): TemplateResult { return html` ${this._renderOptionSelector(configPathMode, this._imageModes, { @@ -2123,9 +2132,64 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard ${this._renderNumberInput(configPathRefreshSeconds, { label: localize('config.common.image.refresh_seconds'), })} + ${options?.proxyMenu ?? html``} `; } + private _renderProxySubmenu( + domain: string, + key: unknown, + labelPath: string, + configPathDynamic: string, + dynamicDefault: boolean, + configPathSSLCiphers: string, + configPathSSLVerification: string, + options?: { + configPathEnabled?: string; + configPathLive?: string; + configPathMedia?: string; + enabledDefault?: boolean; + }, + ): TemplateResult { + return this._putInSubmenu( + domain, + key, + labelPath, + 'mdi:arrow-decision', + html` + ${options?.configPathEnabled !== undefined && + options.enabledDefault !== undefined + ? this._renderSwitch(options.configPathEnabled, options.enabledDefault, { + label: localize('config.common.proxy.modes.true'), + }) + : html``} + ${options?.configPathLive + ? this._renderOptionSelector(options.configPathLive, this._proxyModes, { + label: localize('config.cameras.proxy.live'), + }) + : html``} + ${options?.configPathMedia + ? this._renderOptionSelector(options.configPathMedia, this._proxyModes, { + label: localize('config.cameras.proxy.media'), + }) + : html``} + ${this._renderSwitch(configPathDynamic, dynamicDefault, { + label: localize('config.common.proxy.dynamic'), + })} + ${this._renderOptionSelector( + configPathSSLVerification, + this._proxySSLVerification, + { + label: localize('config.common.proxy.ssl_verification.editor_label'), + }, + )} + ${this._renderOptionSelector(configPathSSLCiphers, this._proxySSLCiphers, { + label: localize('config.common.proxy.ssl_ciphers.editor_label'), + })} + `, + ); + } + private _modifyConfig(func: (config: RawAdvancedCameraCardConfig) => boolean): void { if (this._config) { const newConfig = copyConfig(this._config); @@ -2835,53 +2899,27 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard )} `, )} - ${this._putInSubmenu( + ${this._renderProxySubmenu( MENU_CAMERAS_PROXY, cameraIndex, 'config.cameras.proxy.editor_label', - 'mdi:arrow-decision', - html` - ${this._renderOptionSelector( - getArrayConfigPath(CONF_CAMERAS_ARRAY_PROXY_LIVE, cameraIndex), - this._proxyModes, - { - label: localize('config.cameras.proxy.live'), - }, - )} - ${this._renderOptionSelector( - getArrayConfigPath(CONF_CAMERAS_ARRAY_PROXY_MEDIA, cameraIndex), - this._proxyModes, - { - label: localize('config.cameras.proxy.media'), - }, - )} - ${this._renderSwitch( - getArrayConfigPath(CONF_CAMERAS_ARRAY_PROXY_DYNAMIC, cameraIndex), - this._defaults.cameras.proxy.dynamic, - )} - ${this._renderOptionSelector( - getArrayConfigPath( - CONF_CAMERAS_ARRAY_PROXY_SSL_VERIFICATION, - cameraIndex, - ), - this._proxySSLVerification, - { - label: localize( - 'config.cameras.proxy.ssl_verification.editor_label', - ), - }, - )} - ${this._renderOptionSelector( - getArrayConfigPath( - CONF_CAMERAS_ARRAY_PROXY_SSL_CIPHERS, - cameraIndex, - ), - this._proxySSLCiphers, - { - label: localize('config.cameras.proxy.ssl_ciphers.editor_label'), - }, - )} - `, + getArrayConfigPath(CONF_CAMERAS_ARRAY_PROXY_DYNAMIC, cameraIndex), + this._defaults.cameras.proxy.dynamic, + getArrayConfigPath(CONF_CAMERAS_ARRAY_PROXY_SSL_CIPHERS, cameraIndex), + getArrayConfigPath( + CONF_CAMERAS_ARRAY_PROXY_SSL_VERIFICATION, + cameraIndex, + ), + { + configPathLive: getArrayConfigPath( + CONF_CAMERAS_ARRAY_PROXY_LIVE, + cameraIndex, + ), + configPathMedia: getArrayConfigPath( + CONF_CAMERAS_ARRAY_PROXY_MEDIA, + cameraIndex, + ), + }, )} ` : ``} @@ -3470,6 +3508,21 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard CONF_IMAGE_ENTITY, CONF_IMAGE_ENTITY_PARAMETERS, CONF_IMAGE_REFRESH_SECONDS, + { + proxyMenu: this._renderProxySubmenu( + MENU_IMAGE_PROXY, + true, + 'config.common.image.proxy.editor_label', + CONF_IMAGE_PROXY_DYNAMIC, + this._defaults.image.proxy.dynamic, + CONF_IMAGE_PROXY_SSL_CIPHERS, + CONF_IMAGE_PROXY_SSL_VERIFICATION, + { + configPathEnabled: CONF_IMAGE_PROXY_ENABLED, + enabledDefault: this._defaults.image.proxy.enabled, + }, + ), + }, )} ` : ''} diff --git a/src/ha/fetch.ts b/src/ha/fetch.ts index 3af7cfb8..da909c0c 100644 --- a/src/ha/fetch.ts +++ b/src/ha/fetch.ts @@ -1,7 +1,7 @@ import { ZodSchema } from 'zod'; import { localize } from '../localize/localize'; import { AdvancedCameraCardError, Endpoint } from '../types'; -import { homeAssistantSignPath } from './sign-path'; +import { homeAssistantGetSignedURLIfNecessary } from './sign-path'; import { HomeAssistant } from './types'; /** @@ -22,25 +22,20 @@ export const homeAssistantSignAndFetch = async ( timeoutSeconds?: number; }, ): Promise => { - let url: string | null = endpoint.endpoint; - const sign = endpoint.sign; + let url: string | null; + try { + url = await homeAssistantGetSignedURLIfNecessary(hass, endpoint); + } catch (error) { + throw new AdvancedCameraCardError(localize('error.failed_sign'), { + endpoint, + error, + }); + } - // Sign the path if needed - if (sign) { - try { - url = await homeAssistantSignPath(hass, url); - } catch (error) { - throw new AdvancedCameraCardError(localize('error.failed_sign'), { - endpoint, - error, - }); - } - - if (!url) { - throw new AdvancedCameraCardError(localize('error.failed_sign'), { - endpoint, - }); - } + if (!url) { + throw new AdvancedCameraCardError(localize('error.failed_sign'), { + endpoint, + }); } let response: Response; diff --git a/src/ha/sign-path.ts b/src/ha/sign-path.ts index 4e70b566..a052ba60 100644 --- a/src/ha/sign-path.ts +++ b/src/ha/sign-path.ts @@ -1,6 +1,6 @@ -import { SignedPath, signedPathSchema } from '../types'; -import { homeAssistantWSRequest } from './ws-request'; +import { type Endpoint, SignedPath, signedPathSchema } from '../types'; import { HomeAssistant } from './types'; +import { homeAssistantWSRequest } from './ws-request'; /** * Request that HA sign a path. May throw. @@ -30,3 +30,21 @@ export async function homeAssistantSignPath( } return hass.hassUrl(response.path); } + +/** + * Sign an endpoint's path if the endpoint requires signing. + * @param hass The HomeAssistant object used to request the signature. + * @param endpoint The endpoint to potentially sign. + * @param expires An optional number of seconds to sign the path for. + * @returns The signed or unsigned URL, or null if signing failed. + */ +export async function homeAssistantGetSignedURLIfNecessary( + hass: HomeAssistant, + endpoint: Endpoint, + expires?: number, +): Promise { + if (!endpoint.sign) { + return endpoint.endpoint; + } + return await homeAssistantSignPath(hass, endpoint.endpoint, expires); +} diff --git a/src/ha/web-proxy.ts b/src/ha/web-proxy.ts index 50865ee2..025cf544 100644 --- a/src/ha/web-proxy.ts +++ b/src/ha/web-proxy.ts @@ -1,4 +1,4 @@ -import { CameraProxyConfig } from '../camera-manager/types'; +import { EnabledProxyConfig, ResolvedProxyConfig } from '../config/schema/common/proxy'; import { Endpoint } from '../types'; import { HomeAssistant } from './types'; @@ -26,22 +26,12 @@ export const getWebProxiedURL = (url: string, options?: ProxiedURLOptions): stri ); }; -export const shouldUseWebProxy = ( - hass: HomeAssistant, - proxyConfig: CameraProxyConfig, - context: 'media' | 'live' = 'media', -): boolean => { - return hasWebProxyAvailable(hass) && !!proxyConfig[context]; -}; - export async function addDynamicProxyURL( hass: HomeAssistant, url_pattern: string, options?: { - proxyConfig?: CameraProxyConfig; + proxyConfig?: ResolvedProxyConfig; urlID?: string; - sslVerification?: boolean; - sslCiphers?: string; openLimit?: number; ttl?: number; allowUnauthenticated?: boolean; @@ -51,9 +41,8 @@ export async function addDynamicProxyURL( url_pattern: url_pattern, ...(options && { url_id: options.urlID, - ssl_verification: - options.sslVerification ?? options?.proxyConfig?.ssl_verification, - ssl_ciphers: options.sslCiphers ?? options?.proxyConfig?.ssl_ciphers, + ssl_verification: options.proxyConfig?.ssl_verification, + ssl_ciphers: options.proxyConfig?.ssl_ciphers, open_limit: options.openLimit, ttl: options.ttl, allow_unauthenticated: options.allowUnauthenticated, @@ -61,8 +50,7 @@ export async function addDynamicProxyURL( }); } -interface CreateProxiedEndpointOptions { - context?: 'live' | 'media'; +export interface CreateProxiedEndpointOptions { ttl?: number; websocket?: boolean; openLimit?: number; @@ -73,25 +61,31 @@ interface CreateProxiedEndpointOptions { * Handles dynamic proxy registration and returns a proxied Endpoint. * @param hass Home Assistant instance. * @param endpoint The endpoint to potentially proxy. - * @param proxyConfig The camera proxy configuration. If undefined, returns original endpoint. + * @param proxyConfig The proxy configuration. If undefined or not enabled, + * returns the original endpoint. * @param options Additional options for proxy registration. - * @returns Proxied Endpoint if proxying needed, original endpoint otherwise. + * @returns Proxied Endpoint if proxying needed, original endpoint if proxying + * is not enabled, or null if proxying is required but unavailable. */ export const createProxiedEndpointIfNecessary = async ( hass: HomeAssistant, endpoint: Endpoint, - proxyConfig?: CameraProxyConfig, + proxyConfig?: EnabledProxyConfig, options?: CreateProxiedEndpointOptions, -): Promise => { - const context = options?.context ?? 'media'; - if (!proxyConfig || !shouldUseWebProxy(hass, proxyConfig, context)) { +): Promise => { + if (!proxyConfig || !proxyConfig.enabled) { return endpoint; } + + if (!hasWebProxyAvailable(hass)) { + return proxyConfig.enforce === true ? null : endpoint; + } + if (proxyConfig.dynamic) { - // Strip hash fragment for registration - it's client-side only and - // not relevant for proxy pattern matching. - const registrationUrl = endpoint.endpoint.split(/#/)[0]; - await addDynamicProxyURL(hass, registrationUrl, { + // Strip hash fragment — it's client-side only and not relevant for + // proxy pattern matching. + const url = endpoint.endpoint.split(/#/)[0]; + await addDynamicProxyURL(hass, url, { proxyConfig, ttl: options?.ttl, openLimit: options?.openLimit ?? 0, diff --git a/src/localize/languages/de.json b/src/localize/languages/de.json index 7cd01ab8..f6251ac5 100644 --- a/src/localize/languages/de.json +++ b/src/localize/languages/de.json @@ -64,38 +64,18 @@ "rotation": "Rotation", "rotations": { "0": "Keine Rotation", - "90": "90 Grad im Uhrzeigersinn", "180": "180 Grad im Uhrzeigersinn", - "270": "270 Grad im Uhrzeigersinn" + "270": "270 Grad im Uhrzeigersinn", + "90": "90 Grad im Uhrzeigersinn" } }, "go2rtc": { "url": "go2rtc URL" }, "proxy": { - "dynamic": "Dynamischer Proxy", "editor_label": "Kamera Proxy", "live": "Live Proxy", - "media": "Medien Proxy", - "modes": { - "auto": "Proxy automatisch konfigurieren", - "false": "Proxy deaktiviert", - "true": "Proxy aktiviert" - }, - "ssl_ciphers": { - "auto": "SSL ciphers automatisch konfiguriert", - "default": "Standard SSL ciphers", - "editor_label": "SSL ciphers", - "insecure": "Unsichere SSL ciphers", - "intermediate": "Zwischen SSL ciphers", - "modern": "Moderne SSL ciphers" - }, - "ssl_verification": { - "auto": "SSL Überprüfung automatisch konfiguriert", - "editor_label": "SSL Überprüfung", - "false": "SSL Überprüfung deaktiviert", - "true": "SSL Überprüfung aktiviert" - } + "media": "Medien Proxy" }, "reolink": { "editor_label": "Reolink Optionen", @@ -116,10 +96,6 @@ } }, "common": { - "media_types": { - "events": "Ereignisse", - "recordings": "Aufzeichnungen" - }, "controls": { "builtin": "Eingebettete Video Bedienelemente", "thumbnails": { @@ -165,6 +141,32 @@ "microphone_mute": "Bei Mikrofon Stummschaltung", "microphone_unmute": "Bei Mikrofon Reaktivierung" }, + "media_types": { + "events": "Ereignisse", + "recordings": "Aufzeichnungen" + }, + "proxy": { + "dynamic": "Dynamischer Proxy", + "modes": { + "auto": "Proxy automatisch konfigurieren", + "false": "Proxy deaktiviert", + "true": "Proxy aktiviert" + }, + "ssl_ciphers": { + "auto": "SSL ciphers automatisch konfiguriert", + "default": "Standard SSL ciphers", + "editor_label": "SSL ciphers", + "insecure": "Unsichere SSL ciphers", + "intermediate": "Zwischen SSL ciphers", + "modern": "Moderne SSL ciphers" + }, + "ssl_verification": { + "auto": "SSL Überprüfung automatisch konfiguriert", + "editor_label": "SSL Überprüfung", + "false": "SSL Überprüfung deaktiviert", + "true": "SSL Überprüfung aktiviert" + } + }, "timeline": { "style": "Zeitleisten Stil", "styles": { diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 4771dc2e..34943a89 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -173,29 +173,9 @@ "url": "MotionEye UI URL" }, "proxy": { - "dynamic": "Dynamic proxying", "editor_label": "Camera proxying", "live": "Live proxying", - "media": "Media proxying", - "modes": { - "auto": "Proxying automatically configured", - "false": "Proxying disabled", - "true": "Proxying enabled" - }, - "ssl_ciphers": { - "auto": "SSL ciphers automatically configured", - "default": "Default SSL ciphers", - "editor_label": "SSL ciphers", - "insecure": "Insecure SSL ciphers", - "intermediate": "Intermediate SSL ciphers", - "modern": "Modern SSL ciphers" - }, - "ssl_verification": { - "auto": "SSL verification automatically configured", - "editor_label": "SSL verification", - "false": "SSL verification disabled", - "true": "SSL verification enabled" - } + "media": "Media proxying" }, "reolink": { "editor_label": "Reolink options", @@ -322,6 +302,9 @@ "screensaver": "Embedded screensaver image", "url": "Arbitrary image specified by URL" }, + "proxy": { + "editor_label": "Image proxying" + }, "refresh_seconds": "Number of seconds after which to refresh (0=never)", "url": "Static image URL" }, @@ -342,6 +325,28 @@ "recordings": "Recordings", "reviews": "Reviews" }, + "proxy": { + "dynamic": "Dynamic proxying", + "modes": { + "auto": "Proxying automatically configured", + "false": "Proxying disabled", + "true": "Proxying enabled" + }, + "ssl_ciphers": { + "auto": "SSL ciphers automatically configured", + "default": "Default SSL ciphers", + "editor_label": "SSL ciphers", + "insecure": "Insecure SSL ciphers", + "intermediate": "Intermediate SSL ciphers", + "modern": "Modern SSL ciphers" + }, + "ssl_verification": { + "auto": "SSL verification automatically configured", + "editor_label": "SSL verification", + "false": "SSL verification disabled", + "true": "SSL verification enabled" + } + }, "timeline": { "clustering_threshold": "The count of events at which they are clustered (0=no clustering)", "events_media_type": "The events media the timeline displays", diff --git a/src/localize/languages/fr.json b/src/localize/languages/fr.json index fd670592..25d653cd 100644 --- a/src/localize/languages/fr.json +++ b/src/localize/languages/fr.json @@ -127,27 +127,7 @@ "url": "URL de l’interface MotionEye" }, "proxy": { - "dynamic": "Proxy dynamique", - "editor_label": "Proxy de la caméra", - "modes": { - "auto": "Proxy automatique", - "false": "Proxy désactivé", - "true": "Proxy activé" - }, - "ssl_ciphers": { - "auto": "Chiffrement SSL automatique", - "default": "Chiffrement SSL par défaut", - "editor_label": "Chiffrement SSL", - "insecure": "Chiffrement SSL non sécurisé", - "intermediate": "Chiffrement SSL intermédiaire", - "modern": "Chiffrement SSL moderne" - }, - "ssl_verification": { - "auto": "Vérification SSL automatique", - "editor_label": "Vérification SSL", - "false": "Vérification SSL désactivée", - "true": "Vérification SSL activée" - } + "editor_label": "Proxy de la caméra" }, "reolink": { "editor_label": "Option Reolink", @@ -178,15 +158,6 @@ } }, "common": { - "media_types": { - "events": "Événements", - "recordings": "Enregistrements" - }, - "events_media_types": { - "all": "Tous types de médias", - "clips": "Extraits", - "snapshots": "Instantanés" - }, "controls": { "builtin": "Commandes vidéo intégrées", "filter": { @@ -257,6 +228,11 @@ "grid_selected_width_factor": "Augmenter la largeur du média sélectionnée par ce facteur", "mode": "Mode" }, + "events_media_types": { + "all": "Tous types de médias", + "clips": "Extraits", + "snapshots": "Instantanés" + }, "image": { "entity": "Entité à utiliser avec le mode entité", "entity_parameters": "Paramètres de requête ajoutés aux URL des images basées sur l'entité (par exemple, width=1920&height=1080)", @@ -279,6 +255,32 @@ "unselected": "Lors de la désélection", "visible": "Sur la visibilité du navigateur/onglet" }, + "media_types": { + "events": "Événements", + "recordings": "Enregistrements" + }, + "proxy": { + "dynamic": "Proxy dynamique", + "modes": { + "auto": "Proxy automatique", + "false": "Proxy désactivé", + "true": "Proxy activé" + }, + "ssl_ciphers": { + "auto": "Chiffrement SSL automatique", + "default": "Chiffrement SSL par défaut", + "editor_label": "Chiffrement SSL", + "insecure": "Chiffrement SSL non sécurisé", + "intermediate": "Chiffrement SSL intermédiaire", + "modern": "Chiffrement SSL moderne" + }, + "ssl_verification": { + "auto": "Vérification SSL automatique", + "editor_label": "Vérification SSL", + "false": "Vérification SSL désactivée", + "true": "Vérification SSL activée" + } + }, "timeline": { "clustering_threshold": "Nombre d'événements pour lesquels ils sont regroupés (0 = pas de clustering)", "events_media_type": "Médias affichés par la chronologie", diff --git a/src/localize/languages/pl.json b/src/localize/languages/pl.json index 2c3f219c..d71e94a0 100644 --- a/src/localize/languages/pl.json +++ b/src/localize/languages/pl.json @@ -83,9 +83,9 @@ "rotation": "Obrót", "rotations": { "0": "Brak obrotu", - "90": "90 stopni zgodnie z zegarem", "180": "180 stopni zgodnie z zegarem", - "270": "270 stopni zgodnie z zegarem" + "270": "270 stopni zgodnie z zegarem", + "90": "90 stopni zgodnie z zegarem" } }, "engines": { @@ -143,29 +143,9 @@ "url": "URL interfejsu MotionEye" }, "proxy": { - "dynamic": "Proxy dynamiczne", "editor_label": "Proxy kamery", "live": "Proxy na żywo", - "media": "Proxy mediów", - "modes": { - "auto": "Proxy skonfigurowane automatycznie", - "false": "Proxy wyłączone", - "true": "Proxy włączone" - }, - "ssl_ciphers": { - "auto": "Szyfry SSL skonfigurowane automatycznie", - "default": "Domyślne szyfry SSL", - "editor_label": "Szyfry SSL", - "insecure": "Niezabezpieczone szyfry SSL", - "intermediate": "Pośrednie szyfry SSL", - "modern": "Nowoczesne szyfry SSL" - }, - "ssl_verification": { - "auto": "Weryfikacja SSL skonfigurowana automatycznie", - "editor_label": "Weryfikacja SSL", - "false": "Weryfikacja SSL wyłączona", - "true": "Weryfikacja SSL włączona" - } + "media": "Proxy mediów" }, "reolink": { "editor_label": "Opcje Reolink", @@ -302,6 +282,28 @@ "unselected": "Po odznaczeniu", "visible": "Gdy przeglądarka/karta jest widoczna" }, + "proxy": { + "dynamic": "Proxy dynamiczne", + "modes": { + "auto": "Proxy skonfigurowane automatycznie", + "false": "Proxy wyłączone", + "true": "Proxy włączone" + }, + "ssl_ciphers": { + "auto": "Szyfry SSL skonfigurowane automatycznie", + "default": "Domyślne szyfry SSL", + "editor_label": "Szyfry SSL", + "insecure": "Niezabezpieczone szyfry SSL", + "intermediate": "Pośrednie szyfry SSL", + "modern": "Nowoczesne szyfry SSL" + }, + "ssl_verification": { + "auto": "Weryfikacja SSL skonfigurowana automatycznie", + "editor_label": "Weryfikacja SSL", + "false": "Weryfikacja SSL wyłączona", + "true": "Weryfikacja SSL włączona" + } + }, "timeline": { "clustering_threshold": "Liczba zdarzeń, przy której są grupowane (0=brak)", "events_media_type": "Typ mediów wyświetlany na osi czasu", diff --git a/src/utils/live-provider.ts b/src/utils/live-provider.ts index fe41d330..13ec43d8 100644 --- a/src/utils/live-provider.ts +++ b/src/utils/live-provider.ts @@ -1,7 +1,7 @@ -import { CameraProxyConfig } from '../camera-manager/types'; import { supports2WayAudio as gortcSupports2WayAudio } from '../camera-manager/utils/go2rtc/audio'; import { CameraConfig } from '../config/schema/cameras'; import { LiveProvider } from '../config/schema/cameras.js'; +import { EnabledProxyConfig } from '../config/schema/common/proxy'; import { HomeAssistant } from '../ha/types'; import { Endpoint } from '../types'; @@ -27,7 +27,7 @@ export const liveProviderSupports2WayAudio = async ( config: CameraConfig, metadataFetchTimeoutSeconds: number, go2rtcMetadataEndpoint?: Endpoint | null, - proxyConfig?: CameraProxyConfig, + proxyConfig?: EnabledProxyConfig, ): Promise => { if (getResolvedLiveProvider(config) !== 'go2rtc') { return false; diff --git a/tests/camera-manager/camera.test.ts b/tests/camera-manager/camera.test.ts index 25a6bb8b..5c47a353 100644 --- a/tests/camera-manager/camera.test.ts +++ b/tests/camera-manager/camera.test.ts @@ -133,7 +133,12 @@ describe('Camera', () => { 'http://go2rtc/api/streams?src=stream&video=all&audio=allµphone', sign: false, }, - expect.anything(), + expect.objectContaining({ + dynamic: true, + ssl_verification: true, + ssl_ciphers: 'default', + enabled: false, + }), ); expect(camera.getCapabilities()?.has('2-way-audio')).toBe(true); @@ -184,7 +189,99 @@ describe('Camera', () => { expect.anything(), 20, expect.anything(), + expect.objectContaining({ enabled: false }), + ); + }); + + it('should pass proxy config when web proxy is available', async () => { + const camera = new Camera( + createCameraConfig({ + go2rtc: { + url: 'http://go2rtc', + stream: 'stream', + }, + proxy: { + live: true, + }, + }), + new GenericCameraManagerEngine(mock()), + ); + + vi.mocked(liveProviderSupports2WayAudio).mockResolvedValue(true); + + const hass = createHASS(); + hass.config.components = ['hass_web_proxy']; + + await camera.initialize({ + hass, + stateWatcher: mock(), + }); + + expect(liveProviderSupports2WayAudio).toHaveBeenCalledWith( expect.anything(), + expect.anything(), + 2, + expect.anything(), + { + dynamic: true, + ssl_verification: true, + ssl_ciphers: 'default', + live: true, + media: false, + enabled: true, + enforce: true, + }, + ); + + expect(camera.getCapabilities()?.has('2-way-audio')).toBe(true); + }); + + it('should return live proxy config', () => { + const camera = new Camera( + createCameraConfig({ + proxy: { live: true }, + }), + new GenericCameraManagerEngine(mock()), + ); + expect(camera.getLiveProxyConfig()).toEqual( + expect.objectContaining({ enabled: true, enforce: true }), + ); + }); + + it('should return media proxy config', () => { + const camera = new Camera( + createCameraConfig({ + proxy: { media: true }, + }), + new GenericCameraManagerEngine(mock()), + ); + expect(camera.getMediaProxyConfig()).toEqual( + expect.objectContaining({ enabled: true, enforce: true }), + ); + }); + + it('should not enforce live proxy config when live proxying is auto', () => { + const camera = new Camera( + createCameraConfig({ + live_provider: 'go2rtc', + go2rtc: { url: 'http://go2rtc' }, + }), + new GenericCameraManagerEngine(mock()), + ); + expect(camera.getLiveProxyConfig()).toEqual( + expect.objectContaining({ enabled: true, enforce: false }), + ); + }); + + it('should not enforce media proxy config when media proxying is auto', () => { + const camera = new Camera( + createCameraConfig({ + proxy: { media: 'auto' }, + }), + new GenericCameraManagerEngine(mock()), + ); + expect(camera.getMediaProxyConfig()).toEqual( + expect.objectContaining({ enabled: false, enforce: false }), ); }); diff --git a/tests/camera-manager/utils/go2rtc-audio.test.ts b/tests/camera-manager/utils/go2rtc-audio.test.ts index 97dc9e62..5c8207f1 100644 --- a/tests/camera-manager/utils/go2rtc-audio.test.ts +++ b/tests/camera-manager/utils/go2rtc-audio.test.ts @@ -119,4 +119,10 @@ describe('supports2WayAudio', () => { expect(await supports2WayAudio(hass, 2, endpoint)).toBe(false); }); + + it('should return false if proxied endpoint is null', async () => { + vi.mocked(createProxiedEndpointIfNecessary).mockResolvedValue(null); + + expect(await supports2WayAudio(hass, 2, endpoint)).toBe(false); + }); }); diff --git a/tests/card-controller/actions/actions/set.test.ts b/tests/card-controller/actions/actions/set.test.ts index e05d6f6a..cd0ece7e 100644 --- a/tests/card-controller/actions/actions/set.test.ts +++ b/tests/card-controller/actions/actions/set.test.ts @@ -6,7 +6,7 @@ import { createCardAPI } from '../../../test-utils'; describe('ActionSet', () => { it('should execute single action', async () => { const api = createCardAPI(); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const set = new ActionSet({}, createLogAction('Hello, world!')); const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined); @@ -30,7 +30,7 @@ describe('ActionSet', () => { it('should stop execution', async () => { const api = createCardAPI(); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const set = new ActionSet({}, createLogAction('Hello, world!')); const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined); diff --git a/tests/card-controller/view/item-manager.test.ts b/tests/card-controller/view/item-manager.test.ts index c9cf1e3d..5c75a433 100644 --- a/tests/card-controller/view/item-manager.test.ts +++ b/tests/card-controller/view/item-manager.test.ts @@ -1,7 +1,7 @@ import { format } from 'date-fns'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ViewItemManager } from '../../../src/card-controller/view/item-manager'; -import { homeAssistantSignPath } from '../../../src/ha/sign-path.js'; +import { homeAssistantGetSignedURLIfNecessary } from '../../../src/ha/sign-path.js'; import { downloadURL } from '../../../src/utils/download'; import { ViewFolder, ViewMediaType } from '../../../src/view/item'; import { @@ -57,6 +57,12 @@ describe('ViewItemManager', () => { }); describe('download', () => { + beforeEach(() => { + vi.mocked(homeAssistantGetSignedURLIfNecessary).mockImplementation( + async (_hass, endpoint) => endpoint.endpoint, + ); + }); + afterEach(() => { vi.restoreAllMocks(); }); @@ -85,7 +91,7 @@ describe('ViewItemManager', () => { }); const signError = new Error('sign-error'); - vi.mocked(homeAssistantSignPath).mockRejectedValue(signError); + vi.mocked(homeAssistantGetSignedURLIfNecessary).mockRejectedValue(signError); expect(await manager.download(item)).toBe(false); expect(api.getMessageManager().setErrorIfHigherPriority).toHaveBeenCalledWith( @@ -108,7 +114,9 @@ describe('ViewItemManager', () => { endpoint: 'foo', }); - vi.mocked(homeAssistantSignPath).mockResolvedValue('http://foo/signed-url'); + vi.mocked(homeAssistantGetSignedURLIfNecessary).mockResolvedValue( + 'http://foo/signed-url', + ); expect(await manager.download(item)).toBe(true); expect(downloadURL).toBeCalledWith( @@ -129,7 +137,9 @@ describe('ViewItemManager', () => { endpoint: 'foo', }); - vi.mocked(homeAssistantSignPath).mockResolvedValue('http://foo/signed-url'); + vi.mocked(homeAssistantGetSignedURLIfNecessary).mockResolvedValue( + 'http://foo/signed-url', + ); expect(await manager.download(item)).toBe(true); expect(downloadURL).toBeCalledWith('http://foo/signed-url', 'media_id.mp4'); @@ -147,8 +157,6 @@ describe('ViewItemManager', () => { endpoint: 'foo', }); - expect(homeAssistantSignPath).not.toBeCalled(); - expect(await manager.download(item)).toBe(true); expect(downloadURL).toBeCalledWith('foo', 'camera-office_id.mp4'); }); diff --git a/tests/components-lib/cached-value-controller.test.ts b/tests/components-lib/cached-value-controller.test.ts index 3fbcff62..e2073445 100644 --- a/tests/components-lib/cached-value-controller.test.ts +++ b/tests/components-lib/cached-value-controller.test.ts @@ -9,25 +9,50 @@ describe('CachedValueController', () => { vi.useRealTimers(); }); + it('should not restart timer on hostUpdate if not connected', () => { + const host = mock(); + const callback = vi.fn().mockReturnValue(42); + let refreshSeconds: number | null = 10; + + const controller = new CachedValueController(host, () => refreshSeconds, callback); + + Object.defineProperty(host, 'isConnected', { get: () => false }); + + refreshSeconds = 20; + controller.hostUpdate(); + expect(controller.hasTimer()).toBeFalsy(); + }); + + it('should not restart timer if unchanged on hostUpdate', () => { + const host = mock(); + const callback = vi.fn().mockReturnValue(42); + const refreshSeconds: number | null = 10; + + const controller = new CachedValueController(host, () => refreshSeconds, callback); + + Object.defineProperty(host, 'isConnected', { get: () => true }); + + // This starts the timer as isConnected is true + controller.hostConnected(); + + const startTimerSpy = vi.spyOn(controller, 'startTimer'); + + controller.hostUpdate(); + + // Should not restart since refreshSeconds hasn't changed + expect(startTimerSpy).not.toBeCalled(); + }); + it('should construct', () => { - const host = mock(); + const host = mock(); const callback = vi.fn(); - const controller = new CachedValueController(host, 10, callback); + const controller = new CachedValueController(host, () => 10, callback); expect(controller).toBeTruthy(); }); - it('should remove host', () => { - const host = mock(); - const callback = vi.fn(); - const controller = new CachedValueController(host, 10, callback); - - controller.removeController(); - expect(host.removeController).toBeCalled(); - }); - it('should have timer', () => { - const host = mock(); + const host = mock(); const callback = vi.fn(); const startCallback = vi.fn(); const stopCallback = vi.fn(); @@ -36,7 +61,7 @@ describe('CachedValueController', () => { const controller = new CachedValueController( host, - 10, + () => 10, callback, startCallback, stopCallback, @@ -49,13 +74,13 @@ describe('CachedValueController', () => { vi.runOnlyPendingTimers(); expect(callback).toBeCalled(); expect(host.requestUpdate).toBeCalled(); - expect(controller.value).toBe(3); + expect(controller.getValue()).toBe(3); callback.mockReturnValue(4); vi.runOnlyPendingTimers(); expect(callback).toBeCalled(); expect(host.requestUpdate).toBeCalled(); - expect(controller.value).toBe(4); + expect(controller.getValue()).toBe(4); expect(controller.hasTimer()).toBeTruthy(); @@ -68,42 +93,129 @@ describe('CachedValueController', () => { }); it('should clear value', () => { - const host = mock(); + const host = mock(); const callback = vi.fn().mockReturnValue(42); vi.useFakeTimers(); - const controller = new CachedValueController(host, 10, callback); + const controller = new CachedValueController(host, () => 10, callback); controller.startTimer(); vi.runOnlyPendingTimers(); - expect(controller.value).equal(42); + expect(controller.getValue()).equal(42); controller.clearValue(); - expect(controller.value).toBeUndefined(); + expect(controller.getValue()).toBeNull(); }); it('should connect and disconnect host', () => { - const host = mock(); + const host = mock(); const callback = vi.fn().mockReturnValue(43); const startCallback = vi.fn(); const stopCallback = vi.fn(); const controller = new CachedValueController( host, - 10, + () => 10, callback, startCallback, stopCallback, ); controller.hostConnected(); - expect(controller.value).equal(43); + expect(controller.getValue()).equal(43); expect(startCallback).toBeCalled(); expect(host.requestUpdate).toBeCalled(); controller.hostDisconnected(); - expect(controller.value).toBeUndefined(); + expect(controller.getValue()).toBeNull(); expect(stopCallback).toBeCalled(); }); + + it('should call timer tick callback on each tick before updateValue', () => { + const host = mock(); + const callback = vi.fn().mockReturnValue('value'); + const tickCallback = vi.fn(); + + vi.useFakeTimers(); + + const controller = new CachedValueController( + host, + () => 5, + callback, + undefined, + undefined, + tickCallback, + ); + + controller.startTimer(); + + vi.runOnlyPendingTimers(); + expect(tickCallback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledTimes(1); + + vi.runOnlyPendingTimers(); + expect(tickCallback).toHaveBeenCalledTimes(2); + expect(callback).toHaveBeenCalledTimes(2); + }); + + it('should not call timerTickCallback on manual updateValue', () => { + const host = mock(); + const callback = vi.fn().mockReturnValue('value'); + const tickCallback = vi.fn(); + + const controller = new CachedValueController( + host, + () => 5, + callback, + undefined, + undefined, + tickCallback, + ); + + controller.updateValue(); + expect(callback).toHaveBeenCalledTimes(1); + expect(tickCallback).not.toHaveBeenCalled(); + }); + + it('should restart timer with new interval on hostUpdate', () => { + const host = mock(); + const callback = vi.fn().mockReturnValue(42); + let refreshSeconds: number | null = null; + + const controller = new CachedValueController(host, () => refreshSeconds, callback); + + vi.useFakeTimers(); + Object.defineProperty(host, 'isConnected', { get: () => true }); + controller.hostConnected(); + expect(controller.hasTimer()).toBeFalsy(); + + refreshSeconds = 20; + controller.hostUpdate(); + + // Timer should have been restarted. Fast forward 15 seconds. If it didn't + // restart, it would fire at 10 seconds. Since it restarted at 20 seconds, + // it shouldn't fire at 15 seconds. + callback.mockClear(); + vi.advanceTimersByTime(15 * 1000); + expect(callback).not.toBeCalled(); + + vi.advanceTimersByTime(5 * 1000); + expect(callback).toBeCalled(); + + // Now set it to null -> stops timer + refreshSeconds = null; + controller.hostUpdate(); + expect(controller.hasTimer()).toBeFalsy(); + + // Now set it to 0 -> stops timer + refreshSeconds = 0; + controller.hostUpdate(); + expect(controller.hasTimer()).toBeFalsy(); + + // Now set it to negative -> stops timer + refreshSeconds = -1; + controller.hostUpdate(); + expect(controller.hasTimer()).toBeFalsy(); + }); }); diff --git a/tests/components-lib/media-player/updating-image.test.ts b/tests/components-lib/media-player/updating-image.test.ts index 5398a4e3..daed79f2 100644 --- a/tests/components-lib/media-player/updating-image.test.ts +++ b/tests/components-lib/media-player/updating-image.test.ts @@ -144,7 +144,7 @@ describe('UpdatingImageMediaPlayerController', () => { it('should return screenshot URL with cached value controller', async () => { const url = 'data:image/png;base64,'; const cachedValueController = mock>(); - Object.defineProperty(cachedValueController, 'value', { value: url }); + cachedValueController.getValue.mockReturnValue(url); const controller = new UpdatingImageMediaPlayerController( createLitElement(), diff --git a/tests/components-lib/signed-url-controller.test.ts b/tests/components-lib/signed-url-controller.test.ts new file mode 100644 index 00000000..2376f864 --- /dev/null +++ b/tests/components-lib/signed-url-controller.test.ts @@ -0,0 +1,1004 @@ +import { ReactiveControllerHost } from 'lit'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import { SignedURLController } from '../../src/components-lib/signed-url-controller'; +import { homeAssistantGetSignedURLIfNecessary } from '../../src/ha/sign-path'; +import { createProxiedEndpointIfNecessary } from '../../src/ha/web-proxy'; +import { Endpoint } from '../../src/types'; +import { createHASS, flushPromises } from '../test-utils'; + +vi.mock('../../src/ha/sign-path'); +vi.mock('../../src/ha/web-proxy'); + +const createEndpoint = (url: string, sign?: boolean): Endpoint => ({ + endpoint: url, + ...(sign !== undefined && { sign }), +}); + +// @vitest-environment jsdom +describe('SignedURLController', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should initialize correctly', () => { + const host = mock(); + const controller = new SignedURLController(host, () => ({})); + + expect(host.addController).toBeCalledWith(controller); + expect(controller.getValue()).toBeNull(); + }); + + it('should fetch proxied url successfully', async () => { + const host = mock(); + const hass = createHASS(); + const proxyConfig = { + enabled: true, + dynamic: true, + ssl_verification: true, + ssl_ciphers: 'default' as const, + live: true, + media: true, + }; + + vi.mocked(createProxiedEndpointIfNecessary).mockResolvedValue({ + endpoint: 'http://proxied-url.com', + sign: true, + }); + + vi.mocked(homeAssistantGetSignedURLIfNecessary).mockResolvedValue( + 'http://signed-proxied-url.com', + ); + + const controller = new SignedURLController(host, () => ({ + hass, + endpoint: createEndpoint('http://test-url.com'), + proxyConfig, + })); + + controller.hostUpdate(); + + expect(controller.getValue()).toBeNull(); + + expect(createProxiedEndpointIfNecessary).toBeCalledWith( + hass, + { endpoint: 'http://test-url.com/', sign: false }, + proxyConfig, + { ttl: 86400, openLimit: 0 }, + ); + + await flushPromises(); + + expect(homeAssistantGetSignedURLIfNecessary).toBeCalled(); + expect(controller.getValue()).toBe('http://signed-proxied-url.com'); + expect(host.requestUpdate).toBeCalled(); + }); + + it('should not fetch if inputs are missing', async () => { + const host = mock(); + const controller = new SignedURLController(host, () => ({})); + + controller.hostUpdate(); + await flushPromises(); + + expect(createProxiedEndpointIfNecessary).not.toBeCalled(); + expect(controller.getValue()).toBeNull(); + }); + + it('should return unproxied url if proxy is disabled', async () => { + const host = mock(); + const hass = createHASS(); + + const controller = new SignedURLController(host, () => ({ + hass, + endpoint: createEndpoint('http://test-url.com'), + })); + + controller.hostUpdate(); + await flushPromises(); + + expect(createProxiedEndpointIfNecessary).not.toBeCalled(); + expect(controller.getValue()).toBe('http://test-url.com'); + }); + + it('should clear value if inputs turn invalid', async () => { + const host = mock(); + const hass = createHASS(); + let endpoint: Endpoint | undefined = createEndpoint('http://test-url.com'); + + vi.mocked(createProxiedEndpointIfNecessary).mockResolvedValue({ + endpoint: 'http://proxied-url.com', + sign: false, + }); + vi.mocked(homeAssistantGetSignedURLIfNecessary).mockResolvedValue( + 'http://signed-proxied-url.com', + ); + + const controller = new SignedURLController(host, () => ({ + hass, + endpoint, + proxyConfig: { + enabled: true, + dynamic: true, + ssl_verification: true, + ssl_ciphers: 'default' as const, + live: true, + media: true, + }, + })); + + controller.hostUpdate(); + await flushPromises(); + expect(controller.getValue()).toBe('http://signed-proxied-url.com'); + + // Make endpoint invalid + endpoint = undefined; + controller.hostUpdate(); + + expect(controller.getValue()).toBeNull(); + }); + + it('should call valueChangeCallback when a new URL is successfully resolved', async () => { + const host = mock(); + const hass = createHASS(); + const valueChangeCallback = vi.fn(); + + vi.mocked(createProxiedEndpointIfNecessary).mockResolvedValue({ + endpoint: 'http://proxied-url.com', + sign: false, + }); + vi.mocked(homeAssistantGetSignedURLIfNecessary).mockResolvedValue( + 'http://signed-proxied-url.com', + ); + + const controller = new SignedURLController( + host, + () => ({ + hass, + endpoint: createEndpoint('http://test-url.com'), + proxyConfig: { + enabled: true, + dynamic: true, + ssl_verification: true, + ssl_ciphers: 'default' as const, + live: true, + media: true, + }, + }), + valueChangeCallback, + ); + + controller.hostUpdate(); + await flushPromises(); + + expect(controller.getValue()).toBe('http://signed-proxied-url.com'); + expect(valueChangeCallback).toHaveBeenCalledTimes(1); + }); + + it('should not call valueChangeCallback on null proxy endpoint', async () => { + const host = mock(); + const hass = createHASS(); + const valueChangeCallback = vi.fn(); + + vi.mocked(createProxiedEndpointIfNecessary).mockResolvedValue(null); + + const controller = new SignedURLController( + host, + () => ({ + hass, + endpoint: createEndpoint('http://test-url.com'), + proxyConfig: { + enabled: true, + dynamic: true, + ssl_verification: true, + ssl_ciphers: 'default' as const, + live: true, + media: true, + }, + }), + valueChangeCallback, + ); + + controller.hostUpdate(); + await flushPromises(); + + expect(controller.getValue()).toBeNull(); + expect(valueChangeCallback).not.toBeCalled(); + }); + + it('should not call valueChangeCallback on null signed URL', async () => { + const host = mock(); + const hass = createHASS(); + const valueChangeCallback = vi.fn(); + + vi.mocked(createProxiedEndpointIfNecessary).mockResolvedValue({ + endpoint: 'http://proxied-url.com', + sign: true, + }); + vi.mocked(homeAssistantGetSignedURLIfNecessary).mockResolvedValue(null); + + const controller = new SignedURLController( + host, + () => ({ + hass, + endpoint: createEndpoint('http://test-url.com'), + proxyConfig: { + enabled: true, + dynamic: true, + ssl_verification: true, + ssl_ciphers: 'default' as const, + live: true, + media: true, + }, + }), + valueChangeCallback, + ); + + controller.hostUpdate(); + await flushPromises(); + + expect(controller.getValue()).toBeNull(); + expect(valueChangeCallback).not.toBeCalled(); + }); + + it('should ignore successful fetch if inputs become invalid', async () => { + const host = mock(); + const hass = createHASS(); + let endpoint: Endpoint | undefined = createEndpoint('http://test-url.com'); + + let resolveProxy: ((value: Endpoint) => void) | undefined; + vi.mocked(createProxiedEndpointIfNecessary).mockReturnValueOnce( + new Promise((resolve) => { + resolveProxy = resolve; + }), + ); + vi.mocked(homeAssistantGetSignedURLIfNecessary).mockResolvedValue('stale'); + + const controller = new SignedURLController(host, () => ({ + hass, + endpoint, + proxyConfig: { + enabled: true, + dynamic: true, + ssl_verification: true, + ssl_ciphers: 'default' as const, + live: true, + media: true, + }, + })); + + controller.hostUpdate(); + + endpoint = undefined; + controller.hostUpdate(); + + resolveProxy?.({ endpoint: 'old', sign: false }); + await flushPromises(); + + expect(controller.getValue()).toBeNull(); + expect(host.requestUpdate).not.toBeCalled(); + }); + + it('should invalidate cache if input changes', async () => { + const host = mock(); + const hass = createHASS(); + let url = 'http://test-url.com'; + + vi.mocked(createProxiedEndpointIfNecessary).mockResolvedValue({ + endpoint: 'http://proxied-url.com', + sign: false, + }); + vi.mocked(homeAssistantGetSignedURLIfNecessary).mockResolvedValue( + 'http://signed-proxied-url.com', + ); + + const proxyConfig = { + enabled: true, + dynamic: true, + ssl_verification: true, + ssl_ciphers: 'default' as const, + live: true, + media: true, + }; + + const controller = new SignedURLController(host, () => ({ + hass, + endpoint: createEndpoint(url), + proxyConfig, + })); + + controller.hostUpdate(); + await flushPromises(); + expect(controller.getValue()).toBe('http://signed-proxied-url.com'); + + // Change url + url = 'http://new-url.com'; + vi.mocked(homeAssistantGetSignedURLIfNecessary).mockResolvedValue( + 'http://new-signed-proxied-url.com', + ); + + controller.hostUpdate(); + + expect(controller.getValue()).toBeNull(); + + await flushPromises(); + + expect(controller.getValue()).toBe('http://new-signed-proxied-url.com'); + }); + + it('should invalidate cache if proxy config changes', async () => { + const host = mock(); + const hass = createHASS(); + + vi.mocked(createProxiedEndpointIfNecessary).mockResolvedValue({ + endpoint: 'http://proxied-url.com', + sign: false, + }); + vi.mocked(homeAssistantGetSignedURLIfNecessary).mockResolvedValue( + 'http://signed-proxied-url.com', + ); + + let proxyConfig = { + enabled: true, + dynamic: true, + ssl_verification: true, + ssl_ciphers: 'default' as const, + live: true, + media: true, + }; + + const controller = new SignedURLController(host, () => ({ + hass, + endpoint: createEndpoint('http://test-url.com'), + proxyConfig, + })); + + controller.hostUpdate(); + await flushPromises(); + expect(controller.getValue()).toBe('http://signed-proxied-url.com'); + + // Change a relevant proxy config field. + proxyConfig = { ...proxyConfig, dynamic: false }; + + vi.mocked(homeAssistantGetSignedURLIfNecessary).mockResolvedValue( + 'http://new-signed-proxied-url.com', + ); + + controller.hostUpdate(); + expect(controller.getValue()).toBeNull(); + + await flushPromises(); + expect(controller.getValue()).toBe('http://new-signed-proxied-url.com'); + }); + + it('should handle errors gracefully', async () => { + const host = mock(); + const hass = createHASS(); + const consoleSpy = vi.spyOn(console, 'warn').mockReturnValue(); + + vi.mocked(createProxiedEndpointIfNecessary).mockRejectedValue( + new Error('test-error'), + ); + + const controller = new SignedURLController(host, () => ({ + hass, + endpoint: createEndpoint('http://test-url.com'), + proxyConfig: { + enabled: true, + dynamic: true, + ssl_verification: true, + ssl_ciphers: 'default' as const, + live: true, + media: true, + }, + })); + + controller.hostUpdate(); + await flushPromises(); + + expect(controller.getValue()).toBeNull(); + expect(controller.getError()).toBe('proxy'); + expect(host.requestUpdate).toBeCalledTimes(1); + consoleSpy.mockRestore(); + }); + + it('should not retry after sign error with same inputs', async () => { + const host = mock(); + const hass = createHASS(); + + vi.mocked(createProxiedEndpointIfNecessary).mockResolvedValue({ + endpoint: 'http://proxied-url.com', + sign: true, + }); + vi.mocked(homeAssistantGetSignedURLIfNecessary).mockResolvedValue(null); + + const controller = new SignedURLController(host, () => ({ + hass, + endpoint: createEndpoint('http://test-url.com'), + proxyConfig: { + enabled: true, + dynamic: true, + ssl_verification: true, + ssl_ciphers: 'default' as const, + }, + })); + + await controller.hostUpdate(); + + expect(controller.getValue()).toBeNull(); + expect(controller.getError()).toBe('sign'); + expect(homeAssistantGetSignedURLIfNecessary).toHaveBeenCalledTimes(1); + + // Re-render with same inputs should NOT retry. + await controller.hostUpdate(); + expect(homeAssistantGetSignedURLIfNecessary).toHaveBeenCalledTimes(1); + }); + + it('should set sign error when signing throws', async () => { + const host = mock(); + const hass = createHASS(); + const consoleSpy = vi.spyOn(console, 'error').mockReturnValue(undefined); + + vi.mocked(createProxiedEndpointIfNecessary).mockResolvedValue({ + endpoint: 'http://proxied-url.com', + sign: true, + }); + vi.mocked(homeAssistantGetSignedURLIfNecessary).mockRejectedValue( + new Error('sign failure'), + ); + + const controller = new SignedURLController(host, () => ({ + hass, + endpoint: createEndpoint('http://test-url.com'), + proxyConfig: { + enabled: true, + dynamic: true, + ssl_verification: true, + ssl_ciphers: 'default' as const, + }, + })); + + await controller.hostUpdate(); + + expect(controller.getValue()).toBeNull(); + expect(controller.getError()).toBe('sign'); + consoleSpy.mockRestore(); + }); + + it('should not fetch again if within cache TTL', async () => { + const host = mock(); + const hass = createHASS(); + + vi.useFakeTimers(); + + vi.mocked(createProxiedEndpointIfNecessary).mockResolvedValue({ + endpoint: 'http://proxied-url.com', + sign: false, + }); + vi.mocked(homeAssistantGetSignedURLIfNecessary).mockResolvedValue( + 'http://signed-proxied-url.com', + ); + + const controller = new SignedURLController(host, () => ({ + hass, + endpoint: createEndpoint('http://test-url.com'), + proxyConfig: { + enabled: true, + dynamic: true, + ssl_verification: true, + ssl_ciphers: 'default', + live: true, + media: true, + }, + })); + + controller.hostUpdate(); + await flushPromises(); + expect(createProxiedEndpointIfNecessary).toHaveBeenCalledTimes(1); + + // Call again within TTL, inputs unchanged + controller.hostUpdate(); + await flushPromises(); + expect(createProxiedEndpointIfNecessary).toHaveBeenCalledTimes(1); + + vi.useRealTimers(); + }); + + it('should re-fetch after cache TTL expires', async () => { + const host = mock(); + const hass = createHASS(); + + vi.useFakeTimers(); + + vi.mocked(createProxiedEndpointIfNecessary).mockResolvedValue({ + endpoint: 'http://proxied-url.com', + sign: false, + }); + vi.mocked(homeAssistantGetSignedURLIfNecessary).mockResolvedValue( + 'http://signed-proxied-url.com', + ); + + const controller = new SignedURLController(host, () => ({ + hass, + endpoint: createEndpoint('http://test-url.com'), + proxyConfig: { + enabled: true, + dynamic: true, + ssl_verification: true, + ssl_ciphers: 'default' as const, + live: true, + media: true, + }, + })); + + controller.hostUpdate(); + await flushPromises(); + expect(createProxiedEndpointIfNecessary).toHaveBeenCalledTimes(1); + + // Advance time past the TTL (12 hours + 1 second). + vi.advanceTimersByTime(12 * 60 * 60 * 1000 + 1000); + + controller.hostUpdate(); + await flushPromises(); + expect(createProxiedEndpointIfNecessary).toHaveBeenCalledTimes(2); + + vi.useRealTimers(); + }); + + it('should not re-enter while re-signing after cache expiry', async () => { + const host = mock(); + const hass = createHASS(); + + vi.useFakeTimers(); + + vi.mocked(createProxiedEndpointIfNecessary).mockResolvedValue({ + endpoint: 'http://proxied-url.com', + sign: false, + }); + vi.mocked(homeAssistantGetSignedURLIfNecessary).mockResolvedValue( + 'http://signed-proxied-url.com', + ); + + const controller = new SignedURLController(host, () => ({ + hass, + endpoint: createEndpoint('http://test-url.com'), + proxyConfig: { + enabled: true, + dynamic: true, + ssl_verification: true, + ssl_ciphers: 'default' as const, + }, + })); + + controller.hostUpdate(); + await flushPromises(); + expect(createProxiedEndpointIfNecessary).toHaveBeenCalledTimes(1); + + // Advance past the default TTL. + vi.advanceTimersByTime(12 * 60 * 60 * 1000 + 1000); + + // First call after expiry starts a re-sign. + controller.hostUpdate(); + expect(createProxiedEndpointIfNecessary).toHaveBeenCalledTimes(2); + + // Subsequent calls while in-flight should NOT start another re-sign. + controller.hostUpdate(); + controller.hostUpdate(); + expect(createProxiedEndpointIfNecessary).toHaveBeenCalledTimes(2); + + await flushPromises(); + expect(controller.getValue()).toBe('http://signed-proxied-url.com'); + + vi.useRealTimers(); + }); + + it('should ignore successful fetch if request ID changed', async () => { + const host = mock(); + const hass = createHASS(); + let url = 'http://test-url.com'; + + let resolveProxy: ((value: Endpoint) => void) | undefined; + vi.mocked(createProxiedEndpointIfNecessary) + .mockReturnValueOnce( + new Promise((resolve) => { + resolveProxy = resolve; + }), + ) + .mockReturnValue( + // Request 2 pending forever. + new Promise(() => {}), + ); + vi.mocked(homeAssistantGetSignedURLIfNecessary).mockResolvedValue('ok'); + + const controller = new SignedURLController(host, () => ({ + hass, + endpoint: createEndpoint(url), + proxyConfig: { + enabled: true, + dynamic: true, + ssl_verification: true, + ssl_ciphers: 'default', + live: true, + media: true, + }, + })); + + // Request 1 starts. + controller.hostUpdate(); + + url = 'http://new-url.com'; + // Request 2 starts + controller.hostUpdate(); + + resolveProxy?.({ endpoint: 'old', sign: false }); + await flushPromises(); + + expect(controller.getValue()).toBeNull(); + }); + + it('should ignore failed fetch if request ID changed', async () => { + const host = mock(); + const hass = createHASS(); + let url = 'http://test-url.com'; + + let rejectProxy: ((error: Error) => void) | undefined; + vi.mocked(createProxiedEndpointIfNecessary) + .mockReturnValueOnce( + new Promise((_, reject) => { + rejectProxy = reject; + }), + ) + .mockReturnValue( + // Pending forever for request 2. + new Promise(() => {}), + ); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const controller = new SignedURLController(host, () => ({ + hass, + endpoint: createEndpoint(url), + proxyConfig: { + enabled: true, + dynamic: true, + ssl_verification: true, + ssl_ciphers: 'default', + live: true, + media: true, + }, + })); + + // Request 1 starts. + controller.hostUpdate(); + + url = 'http://new-url.com'; + + // Request 2 starts. + controller.hostUpdate(); + + rejectProxy?.(new Error('fail')); + await flushPromises(); + + expect(host.requestUpdate).not.toBeCalled(); + }); + + it('should clear value if proxy endpoint is null', async () => { + const host = mock(); + const hass = createHASS(); + + vi.mocked(createProxiedEndpointIfNecessary).mockResolvedValue(null); + + const controller = new SignedURLController(host, () => ({ + hass, + endpoint: createEndpoint('http://test-url.com'), + proxyConfig: { + enabled: true, + dynamic: true, + ssl_verification: true, + ssl_ciphers: 'default' as const, + live: true, + media: true, + }, + })); + + controller.hostUpdate(); + await flushPromises(); + + expect(controller.getValue()).toBeNull(); + expect(controller.getError()).toBe('proxy'); + expect(host.requestUpdate).toBeCalled(); + }); + + it('should not retry after proxy error with same inputs', async () => { + const host = mock(); + const hass = createHASS(); + + vi.mocked(createProxiedEndpointIfNecessary).mockResolvedValue(null); + + const controller = new SignedURLController(host, () => ({ + hass, + endpoint: createEndpoint('http://test-url.com'), + proxyConfig: { + enabled: true, + dynamic: true, + ssl_verification: true, + ssl_ciphers: 'default' as const, + }, + })); + + await controller.hostUpdate(); + expect(controller.getError()).toBe('proxy'); + expect(createProxiedEndpointIfNecessary).toHaveBeenCalledTimes(1); + + // Re-render with same inputs should NOT retry. + await controller.hostUpdate(); + expect(createProxiedEndpointIfNecessary).toHaveBeenCalledTimes(1); + }); + + it('should not restart in-flight work on re-render with same inputs', async () => { + const host = mock(); + const hass = createHASS(); + + let resolveProxy: ((value: Endpoint) => void) | undefined; + vi.mocked(createProxiedEndpointIfNecessary).mockReturnValueOnce( + new Promise((resolve) => { + resolveProxy = resolve; + }), + ); + vi.mocked(homeAssistantGetSignedURLIfNecessary).mockResolvedValue( + 'http://signed-proxied-url.com', + ); + + const proxyConfig = { + enabled: true, + dynamic: true, + ssl_verification: true, + ssl_ciphers: 'default' as const, + live: true, + media: true, + }; + + const controller = new SignedURLController(host, () => ({ + hass, + endpoint: createEndpoint('http://test-url.com'), + proxyConfig, + })); + + // First call starts async work. + controller.hostUpdate(); + expect(createProxiedEndpointIfNecessary).toHaveBeenCalledTimes(1); + + // Second call with identical inputs should be a no-op (in-flight guard). + controller.hostUpdate(); + expect(createProxiedEndpointIfNecessary).toHaveBeenCalledTimes(1); + + // Resolve the original request — should still succeed. + resolveProxy?.({ endpoint: 'http://proxied-url.com', sign: false }); + await flushPromises(); + + expect(controller.getValue()).toBe('http://signed-proxied-url.com'); + expect(host.requestUpdate).toHaveBeenCalledTimes(1); + }); + + it('should not invalidate cache when extraneous proxy config fields change', async () => { + const host = mock(); + const hass = createHASS(); + + vi.mocked(createProxiedEndpointIfNecessary).mockResolvedValue({ + endpoint: 'http://proxied.com', + sign: false, + }); + vi.mocked(homeAssistantGetSignedURLIfNecessary).mockResolvedValue( + 'http://signed.com', + ); + + // Start with extraneous `live: true`. + let extraneous = true; + const controller = new SignedURLController(host, () => ({ + hass, + endpoint: createEndpoint('http://test-url.com'), + proxyConfig: { + enabled: true, + dynamic: true, + ssl_verification: true, + ssl_ciphers: 'default' as const, + live: extraneous, + }, + })); + + await controller.hostUpdate(); + expect(createProxiedEndpointIfNecessary).toHaveBeenCalledTimes(1); + expect(controller.getValue()).toBe('http://signed.com'); + + // Change only the extraneous field — should hit the cache, not re-fetch. + extraneous = false; + await controller.hostUpdate(); + expect(createProxiedEndpointIfNecessary).toHaveBeenCalledTimes(1); + }); + + it('should reset all state on hostDisconnected', async () => { + const host = mock(); + const hass = createHASS(); + + vi.mocked(createProxiedEndpointIfNecessary).mockResolvedValue({ + endpoint: 'http://proxied-url.com', + sign: false, + }); + vi.mocked(homeAssistantGetSignedURLIfNecessary).mockResolvedValue( + 'http://signed-proxied-url.com', + ); + + const controller = new SignedURLController(host, () => ({ + hass, + endpoint: createEndpoint('http://test-url.com'), + proxyConfig: { + enabled: true, + dynamic: true, + ssl_verification: true, + ssl_ciphers: 'default' as const, + }, + })); + + controller.hostUpdate(); + await flushPromises(); + expect(controller.getValue()).toBe('http://signed-proxied-url.com'); + expect(controller.getError()).toBeNull(); + + controller.hostDisconnected(); + + expect(controller.getValue()).toBeNull(); + expect(controller.getError()).toBeNull(); + }); + + it('should discard in-flight result after hostDisconnected', async () => { + const host = mock(); + const hass = createHASS(); + + let resolveProxy: ((value: Endpoint) => void) | undefined; + vi.mocked(createProxiedEndpointIfNecessary).mockReturnValueOnce( + new Promise((resolve) => { + resolveProxy = resolve; + }), + ); + vi.mocked(homeAssistantGetSignedURLIfNecessary).mockResolvedValue( + 'http://signed.com', + ); + + const controller = new SignedURLController(host, () => ({ + hass, + endpoint: createEndpoint('http://test-url.com'), + proxyConfig: { + enabled: true, + dynamic: true, + ssl_verification: true, + ssl_ciphers: 'default' as const, + }, + })); + + controller.hostUpdate(); + + controller.hostDisconnected(); + + resolveProxy?.({ endpoint: 'http://proxied.com', sign: false }); + await flushPromises(); + + expect(controller.getValue()).toBeNull(); + expect(host.requestUpdate).not.toBeCalled(); + }); + + it('should ignore stale null signed URL after request ID changed', async () => { + const host = mock(); + const hass = createHASS(); + let url = 'http://test-url.com'; + + let resolveSign: ((value: string | null) => void) | undefined; + vi.mocked(createProxiedEndpointIfNecessary).mockResolvedValue({ + endpoint: 'http://proxied.com', + sign: true, + }); + vi.mocked(homeAssistantGetSignedURLIfNecessary) + .mockReturnValueOnce( + new Promise((resolve) => { + resolveSign = resolve; + }), + ) + .mockReturnValue(new Promise(() => {})); + + const controller = new SignedURLController(host, () => ({ + hass, + endpoint: createEndpoint(url), + proxyConfig: { + enabled: true, + dynamic: true, + ssl_verification: true, + ssl_ciphers: 'default' as const, + }, + })); + + controller.hostUpdate(); + await flushPromises(); + + // Change URL to start a new request. + url = 'http://new-url.com'; + controller.hostUpdate(); + + // Resolve the first signing request with null (stale). + resolveSign?.(null); + await flushPromises(); + + // Stale result should be discarded. + expect(host.requestUpdate).not.toBeCalled(); + }); + + it('should sign endpoint without proxying when sign is set', async () => { + const host = mock(); + const hass = createHASS(); + + vi.mocked(homeAssistantGetSignedURLIfNecessary).mockResolvedValue( + 'http://ha.local/api/some/endpoint?authSig=abc', + ); + + const controller = new SignedURLController(host, () => ({ + hass, + endpoint: createEndpoint('/api/some/endpoint', true), + })); + + expect(controller.getValue()).toBeNull(); + + controller.hostUpdate(); + await flushPromises(); + + expect(createProxiedEndpointIfNecessary).not.toBeCalled(); + expect(homeAssistantGetSignedURLIfNecessary).toBeCalled(); + expect(controller.getValue()).toBe('http://ha.local/api/some/endpoint?authSig=abc'); + expect(host.requestUpdate).toBeCalled(); + }); + + it('should return url directly when sign is false and proxy is disabled', () => { + const host = mock(); + + const controller = new SignedURLController(host, () => ({ + hass: createHASS(), + endpoint: createEndpoint('http://example.com/stream'), + })); + + expect(controller.getValue()).toBe('http://example.com/stream'); + }); + + it('should ignore null proxy endpoint if request ID changed', async () => { + const host = mock(); + const hass = createHASS(); + let url = 'http://test-url.com'; + + let resolveProxy: ((value: Endpoint | null) => void) | undefined; + vi.mocked(createProxiedEndpointIfNecessary) + .mockReturnValueOnce( + new Promise((resolve) => { + resolveProxy = resolve; + }), + ) + .mockReturnValue(new Promise(() => {})); + + const controller = new SignedURLController(host, () => ({ + hass, + endpoint: createEndpoint(url), + proxyConfig: { + enabled: true, + dynamic: true, + ssl_verification: true, + ssl_ciphers: 'default' as const, + live: true, + media: true, + }, + })); + + controller.hostUpdate(); + + url = 'http://new-url.com'; + controller.hostUpdate(); + + resolveProxy?.(null); + await flushPromises(); + + expect(host.requestUpdate).not.toBeCalled(); + }); +}); diff --git a/tests/config/types.test.ts b/tests/config/types.test.ts index 8673a05c..0aea2ef2 100644 --- a/tests/config/types.test.ts +++ b/tests/config/types.test.ts @@ -77,6 +77,12 @@ describe('config defaults', () => { elements: [], image: { mode: 'auto', + proxy: { + dynamic: true, + ssl_ciphers: 'auto', + ssl_verification: 'auto', + enabled: false, + }, refresh_seconds: 1, zoomable: true, }, diff --git a/tests/ha/fetch.test.ts b/tests/ha/fetch.test.ts index 3748b813..f7cbc381 100644 --- a/tests/ha/fetch.test.ts +++ b/tests/ha/fetch.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { z } from 'zod'; import { homeAssistantSignAndFetch } from '../../src/ha/fetch'; -import { homeAssistantSignPath } from '../../src/ha/sign-path'; +import { homeAssistantGetSignedURLIfNecessary } from '../../src/ha/sign-path'; import { AdvancedCameraCardError, Endpoint } from '../../src/types'; import { createHASS } from '../test-utils'; @@ -18,7 +18,6 @@ describe('homeAssistantSignAndFetch', () => { beforeEach(() => { vi.stubGlobal('fetch', fetchMock); - vi.mocked(homeAssistantSignPath).mockResolvedValue('http://signed'); }); afterEach(() => { @@ -27,26 +26,31 @@ describe('homeAssistantSignAndFetch', () => { }); it('should return parsed data on successful call with endpoint', async () => { + const endpoint: Endpoint = { endpoint: 'http://example.com' }; + vi.mocked(homeAssistantGetSignedURLIfNecessary).mockResolvedValue( + 'http://example.com', + ); fetchMock.mockResolvedValueOnce({ ok: true, json: async () => response, }); - const endpoint: Endpoint = { endpoint: 'http://example.com' }; - expect(await homeAssistantSignAndFetch(createHASS(), endpoint, schema)).toEqual( - response, - ); - expect(homeAssistantSignPath).not.toHaveBeenCalled(); + const hass = createHASS(); + expect(await homeAssistantSignAndFetch(hass, endpoint, schema)).toEqual(response); + expect(homeAssistantGetSignedURLIfNecessary).toHaveBeenCalledWith(hass, endpoint); expect(fetchMock).toHaveBeenCalledWith('http://example.com', {}); }); it('should pass timeout signal when timeoutSeconds is provided', async () => { + const endpoint: Endpoint = { endpoint: 'http://example.com' }; + vi.mocked(homeAssistantGetSignedURLIfNecessary).mockResolvedValue( + 'http://example.com', + ); fetchMock.mockResolvedValueOnce({ ok: true, json: async () => response, }); - const endpoint: Endpoint = { endpoint: 'http://example.com' }; expect( await homeAssistantSignAndFetch(createHASS(), endpoint, schema, { timeoutSeconds: 5, @@ -58,6 +62,7 @@ describe('homeAssistantSignAndFetch', () => { }); it('should sign path if requested', async () => { + vi.mocked(homeAssistantGetSignedURLIfNecessary).mockResolvedValue('http://signed'); fetchMock.mockResolvedValueOnce({ ok: true, json: async () => response, @@ -69,12 +74,14 @@ describe('homeAssistantSignAndFetch', () => { }; const hass = createHASS(); expect(await homeAssistantSignAndFetch(hass, endpoint, schema)).toEqual(response); - expect(homeAssistantSignPath).toHaveBeenCalledWith(hass, 'http://example.com'); + expect(homeAssistantGetSignedURLIfNecessary).toHaveBeenCalledWith(hass, endpoint); expect(fetchMock).toHaveBeenCalledWith('http://signed', {}); }); it('should throw on sign failure', async () => { - vi.mocked(homeAssistantSignPath).mockRejectedValueOnce(new Error('Sign failed')); + vi.mocked(homeAssistantGetSignedURLIfNecessary).mockRejectedValueOnce( + new Error('Sign failed'), + ); const endpoint: Endpoint = { endpoint: 'http://example.com', @@ -85,8 +92,8 @@ describe('homeAssistantSignAndFetch', () => { ).rejects.toThrow(/Could not sign Home Assistant URL/); }); - it('should throw if sign path returns null', async () => { - vi.mocked(homeAssistantSignPath).mockResolvedValue(null); + it('should throw if sign endpoint returns null', async () => { + vi.mocked(homeAssistantGetSignedURLIfNecessary).mockResolvedValue(null); const endpoint: Endpoint = { endpoint: 'http://example.com', @@ -98,9 +105,12 @@ describe('homeAssistantSignAndFetch', () => { }); it('should throw on fetch failure', async () => { + const endpoint: Endpoint = { endpoint: 'http://example.com' }; + vi.mocked(homeAssistantGetSignedURLIfNecessary).mockResolvedValue( + 'http://example.com', + ); fetchMock.mockRejectedValueOnce(new Error('Fetch failed')); - const endpoint: Endpoint = { endpoint: 'http://example.com' }; try { await homeAssistantSignAndFetch(createHASS(), endpoint, schema); expect.fail('Should have thrown'); @@ -117,6 +127,10 @@ describe('homeAssistantSignAndFetch', () => { }); it('should throw on non-ok response', async () => { + const endpoint: Endpoint = { endpoint: 'http://example.com' }; + vi.mocked(homeAssistantGetSignedURLIfNecessary).mockResolvedValue( + 'http://example.com', + ); const response = { ok: false, status: 404, @@ -124,7 +138,6 @@ describe('homeAssistantSignAndFetch', () => { } as Response; fetchMock.mockResolvedValueOnce(response); - const endpoint: Endpoint = { endpoint: 'http://example.com' }; try { await homeAssistantSignAndFetch(createHASS(), endpoint, schema); expect.fail('Should have thrown'); @@ -140,6 +153,10 @@ describe('homeAssistantSignAndFetch', () => { }); it('should throw on JSON parse failure', async () => { + const endpoint: Endpoint = { endpoint: 'http://example.com' }; + vi.mocked(homeAssistantGetSignedURLIfNecessary).mockResolvedValue( + 'http://example.com', + ); const response = { ok: true, json: async () => { @@ -148,7 +165,6 @@ describe('homeAssistantSignAndFetch', () => { } as unknown as Response; fetchMock.mockResolvedValueOnce(response); - const endpoint: Endpoint = { endpoint: 'http://example.com' }; try { await homeAssistantSignAndFetch(createHASS(), endpoint, schema); expect.fail('Should have thrown'); @@ -166,6 +182,10 @@ describe('homeAssistantSignAndFetch', () => { }); it('should throw on schema validation failure', async () => { + const endpoint: Endpoint = { endpoint: 'http://example.com' }; + vi.mocked(homeAssistantGetSignedURLIfNecessary).mockResolvedValue( + 'http://example.com', + ); const data = { val: 'string' }; const response = { ok: true, @@ -173,8 +193,6 @@ describe('homeAssistantSignAndFetch', () => { } as unknown as Response; fetchMock.mockResolvedValueOnce(response); - const endpoint: Endpoint = { endpoint: 'http://example.com' }; - try { await homeAssistantSignAndFetch(createHASS(), endpoint, schema); expect.fail('Should have thrown'); diff --git a/tests/ha/sign-path.test.ts b/tests/ha/sign-path.test.ts index da644914..53aa7806 100644 --- a/tests/ha/sign-path.test.ts +++ b/tests/ha/sign-path.test.ts @@ -1,5 +1,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { homeAssistantSignPath } from '../../src/ha/sign-path'; +import { + homeAssistantGetSignedURLIfNecessary, + homeAssistantSignPath, +} from '../../src/ha/sign-path'; import { homeAssistantWSRequest } from '../../src/ha/ws-request.js'; import { signedPathSchema } from '../../src/types'; import { createHASS } from '../test-utils'; @@ -36,3 +39,52 @@ describe('homeAssistantSignPath', () => { expect(await homeAssistantSignPath(createHASS(), 'unsigned/path', 42)).toBeNull(); }); }); + +describe('homeAssistantSignEndpoint', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should return endpoint URL without signing when sign is false', async () => { + const endpoint = { endpoint: 'http://example.com', sign: false }; + expect(await homeAssistantGetSignedURLIfNecessary(createHASS(), endpoint)).toBe( + 'http://example.com', + ); + expect(homeAssistantWSRequest).not.toHaveBeenCalled(); + }); + + it('should return endpoint URL without signing when sign is undefined', async () => { + const endpoint = { endpoint: 'http://example.com' }; + expect(await homeAssistantGetSignedURLIfNecessary(createHASS(), endpoint)).toBe( + 'http://example.com', + ); + expect(homeAssistantWSRequest).not.toHaveBeenCalled(); + }); + + it('should sign endpoint when sign is true', async () => { + const hass = createHASS(); + vi.mocked(homeAssistantWSRequest).mockResolvedValue({ + path: 'signed/path', + }); + vi.mocked(hass.hassUrl).mockImplementation((url) => 'hass:' + url); + + const endpoint = { endpoint: 'http://example.com', sign: true }; + expect(await homeAssistantGetSignedURLIfNecessary(hass, endpoint, 60)).toBe( + 'hass:signed/path', + ); + expect(homeAssistantWSRequest).toHaveBeenCalledWith(hass, signedPathSchema, { + type: 'auth/sign_path', + path: 'http://example.com', + expires: 60, + }); + }); + + it('should return null when signing fails', async () => { + vi.mocked(homeAssistantWSRequest).mockResolvedValue(null); + + const endpoint = { endpoint: 'http://example.com', sign: true }; + expect( + await homeAssistantGetSignedURLIfNecessary(createHASS(), endpoint), + ).toBeNull(); + }); +}); diff --git a/tests/ha/web-proxy.test.ts b/tests/ha/web-proxy.test.ts index 1e1c50cb..d5aa623d 100644 --- a/tests/ha/web-proxy.test.ts +++ b/tests/ha/web-proxy.test.ts @@ -1,10 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { CameraProxyConfig } from '../../src/camera-manager/types.js'; +import { EnabledProxyConfig } from '../../src/config/schema/common/proxy.js'; import { addDynamicProxyURL, createProxiedEndpointIfNecessary, getWebProxiedURL, - shouldUseWebProxy, } from '../../src/ha/web-proxy.js'; import { createHASS } from '../test-utils.js'; @@ -28,50 +27,19 @@ describe('getWebProxiedURL', () => { }); }); -describe('shouldUseWebProxy', () => { - const createProxyConfig = ( - config: Partial = {}, - ): CameraProxyConfig => ({ - media: true, - live: true, - ssl_verification: true, - ssl_ciphers: 'default', - dynamic: true, - ...config, - }); - - it('should return false without a the proxy installed', () => { - const hass = createHASS(); - hass.config.components = []; - - expect(shouldUseWebProxy(hass, createProxyConfig())).toBe(false); - }); - - it('should return when proxy config does not want proxying', () => { - const hass = createHASS(); - hass.config.components = ['hass_web_proxy']; - - const proxyConfig = createProxyConfig({ media: false }); - expect(shouldUseWebProxy(hass, proxyConfig, 'media')).toBe(false); - }); - - it('should return when proxy config does want proxying', () => { - const hass = createHASS(); - hass.config.components = ['hass_web_proxy']; - - const proxyConfig = createProxyConfig({ media: true }); - expect(shouldUseWebProxy(hass, proxyConfig, 'media')).toBe(true); - }); -}); - describe('addDynamicProxyURL', () => { - it('should add dynamic proxy URL', async () => { + it('should add dynamic proxy URL with proxy config', async () => { const hass = createHASS(); + const proxyConfig: EnabledProxyConfig = { + dynamic: true, + ssl_verification: true, + ssl_ciphers: 'modern', + enabled: true, + }; await addDynamicProxyURL(hass, 'http://example.com', { + proxyConfig, urlID: 'id', - sslVerification: true, - sslCiphers: 'modern', openLimit: 5, ttl: 60, allowUnauthenticated: false, @@ -92,40 +60,29 @@ describe('addDynamicProxyURL', () => { ); }); - it('should add dynamic proxy URL using config defaults', async () => { + it('should add dynamic proxy URL without options', async () => { const hass = createHASS(); - const proxyConfig: CameraProxyConfig = { - media: true, - live: true, - ssl_verification: false, - ssl_ciphers: 'insecure', - dynamic: true, - }; - await addDynamicProxyURL(hass, 'http://example.com', { - proxyConfig: proxyConfig, - }); + await addDynamicProxyURL(hass, 'http://example.com'); expect(hass.callService).toHaveBeenCalledWith( 'hass_web_proxy', 'create_proxied_url', - expect.objectContaining({ - ssl_verification: false, - ssl_ciphers: 'insecure', - }), + { + url_pattern: 'http://example.com', + }, ); }); }); describe('createProxiedEndpointIfNecessary', () => { - const createProxyConfig = ( - config: Partial = {}, - ): CameraProxyConfig => ({ - media: true, - live: true, + const createEnabledProxyConfig = ( + config: Partial = {}, + ): EnabledProxyConfig => ({ ssl_verification: true, ssl_ciphers: 'default', dynamic: true, + enabled: true, ...config, }); @@ -133,33 +90,41 @@ describe('createProxiedEndpointIfNecessary', () => { it('should return original endpoint when proxyConfig is undefined', async () => { const hass = createHASS(); - hass.config.components = ['hass_web_proxy']; const result = await createProxiedEndpointIfNecessary(hass, testEndpoint); expect(result).toBe(testEndpoint); }); - it('should return original endpoint when proxy is not available', async () => { - const hass = createHASS(); - hass.config.components = []; - - const result = await createProxiedEndpointIfNecessary( - hass, - testEndpoint, - createProxyConfig(), - ); - expect(result).toBe(testEndpoint); - }); - - it('should return original endpoint when context is not enabled', async () => { + it('should return original endpoint when enabled is false', async () => { const hass = createHASS(); hass.config.components = ['hass_web_proxy']; const result = await createProxiedEndpointIfNecessary( hass, testEndpoint, - createProxyConfig({ media: false }), - { context: 'media' }, + createEnabledProxyConfig({ enabled: false }), + ); + expect(result).toBe(testEndpoint); + }); + + it('should return null when web proxy is not available and enforced', async () => { + const hass = createHASS(); + + const result = await createProxiedEndpointIfNecessary( + hass, + testEndpoint, + createEnabledProxyConfig({ enforce: true }), + ); + expect(result).toBeNull(); + }); + + it('should return original endpoint when web proxy is not available but not enforced', async () => { + const hass = createHASS(); + + const result = await createProxiedEndpointIfNecessary( + hass, + testEndpoint, + createEnabledProxyConfig(), ); expect(result).toBe(testEndpoint); }); @@ -171,8 +136,8 @@ describe('createProxiedEndpointIfNecessary', () => { const result = await createProxiedEndpointIfNecessary( hass, testEndpoint, - createProxyConfig(), - { context: 'media', ttl: 300, openLimit: 5 }, + createEnabledProxyConfig(), + { ttl: 300, openLimit: 5 }, ); expect(hass.callService).toHaveBeenCalledWith( @@ -200,7 +165,11 @@ describe('createProxiedEndpointIfNecessary', () => { sign: false, }; - await createProxiedEndpointIfNecessary(hass, endpointWithHash, createProxyConfig()); + await createProxiedEndpointIfNecessary( + hass, + endpointWithHash, + createEnabledProxyConfig(), + ); expect(hass.callService).toHaveBeenCalledWith( 'hass_web_proxy', @@ -218,7 +187,7 @@ describe('createProxiedEndpointIfNecessary', () => { const result = await createProxiedEndpointIfNecessary( hass, testEndpoint, - createProxyConfig({ dynamic: false }), + createEnabledProxyConfig({ dynamic: false }), ); expect(hass.callService).not.toHaveBeenCalled(); @@ -235,7 +204,7 @@ describe('createProxiedEndpointIfNecessary', () => { const result = await createProxiedEndpointIfNecessary( hass, testEndpoint, - createProxyConfig({ dynamic: false }), + createEnabledProxyConfig({ dynamic: false }), { websocket: true }, ); @@ -245,25 +214,15 @@ describe('createProxiedEndpointIfNecessary', () => { }); }); - it('should use live context when specified', async () => { - const hass = createHASS(); - hass.config.components = ['hass_web_proxy']; - - const result = await createProxiedEndpointIfNecessary( - hass, - testEndpoint, - createProxyConfig({ media: false, live: true }), - { context: 'live' }, - ); - - expect(result.endpoint).toContain('/api/hass_web_proxy/'); - }); - it('should default openLimit to 0 when not specified', async () => { const hass = createHASS(); hass.config.components = ['hass_web_proxy']; - await createProxiedEndpointIfNecessary(hass, testEndpoint, createProxyConfig()); + await createProxiedEndpointIfNecessary( + hass, + testEndpoint, + createEnabledProxyConfig(), + ); expect(hass.callService).toHaveBeenCalledWith( 'hass_web_proxy', diff --git a/tests/test-utils.ts b/tests/test-utils.ts index e964f207..88ca8397 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -130,6 +130,7 @@ export const createHASS = (states?: HassEntities, user?: CurrentUser): HomeAssis if (user) { hass.user = user; } + hass.config.components = []; hass.connection.subscribeMessage = vi.fn(); // ha-nunjucks calls sendMessagePromise to fetch label registry; return empty array to prevent crash. diff --git a/tests/utils/zod/parse-errors.test.ts b/tests/utils/zod/parse-errors.test.ts index f334ec77..d53d56ca 100644 --- a/tests/utils/zod/parse-errors.test.ts +++ b/tests/utils/zod/parse-errors.test.ts @@ -7,7 +7,9 @@ describe('getParseErrorPaths', () => { const result = z .object({ a: z.string(), b: z.number() }) .safeParse({ a: 1, b: 'a' }); - if (result.success) return; + if (result.success) { + return; + } expect(getParseErrorPaths(result.error)).toEqual(new Set(['a', 'b'])); }); @@ -15,13 +17,17 @@ describe('getParseErrorPaths', () => { const result = z .object({ a: z.object({ b: z.string() }) }) .safeParse({ a: { b: 1 } }); - if (result.success) return; + if (result.success) { + return; + } expect(getParseErrorPaths(result.error)).toEqual(new Set(['a.b'])); }); it('should get array error paths', () => { const result = z.array(z.string()).safeParse([1, 'a', 2]); - if (result.success) return; + if (result.success) { + return; + } expect(getParseErrorPaths(result.error)).toEqual(new Set(['[0]', '[2]'])); }); @@ -29,7 +35,9 @@ describe('getParseErrorPaths', () => { const result = z .object({ a: z.array(z.object({ b: z.string() })) }) .safeParse({ a: [{ b: 1 }, { b: 'a' }, { b: 2 }] }); - if (result.success) return; + if (result.success) { + return; + } expect(getParseErrorPaths(result.error)).toEqual(new Set(['a[0].b', 'a[2].b'])); }); });