diff --git a/src/components/entity/state-badge.ts b/src/components/entity/state-badge.ts index f15f36178f..71b6a7d74e 100644 --- a/src/components/entity/state-badge.ts +++ b/src/components/entity/state-badge.ts @@ -15,6 +15,7 @@ import { iconColorCSS } from "../../common/style/icon_color_css"; import { cameraUrlWithWidthHeight } from "../../data/camera"; import { CLIMATE_HVAC_ACTION_TO_MODE } from "../../data/climate"; import type { HomeAssistant } from "../../types"; +import { addBrandsAuth } from "../../util/brands-url"; import "../ha-state-icon"; @customElement("state-badge") @@ -137,6 +138,7 @@ export class StateBadge extends LitElement { let imageUrl = stateObj.attributes.entity_picture_local || stateObj.attributes.entity_picture; + imageUrl = addBrandsAuth(imageUrl); if (this.hass) { imageUrl = this.hass.hassUrl(imageUrl); } diff --git a/src/components/ha-selector/ha-selector-media.ts b/src/components/ha-selector/ha-selector-media.ts index 7ff43cdc5e..9755fd3a75 100644 --- a/src/components/ha-selector/ha-selector-media.ts +++ b/src/components/ha-selector/ha-selector-media.ts @@ -13,7 +13,11 @@ import { } from "../../data/media-player"; import type { MediaSelector, MediaSelectorValue } from "../../data/selector"; import type { HomeAssistant } from "../../types"; -import { brandsUrl, extractDomainFromBrandUrl } from "../../util/brands-url"; +import { + brandsUrl, + extractDomainFromBrandUrl, + isBrandUrl, +} from "../../util/brands-url"; import "../ha-alert"; import "../ha-form/ha-form"; import type { SchemaUnion } from "../ha-form/types"; @@ -72,16 +76,7 @@ export class HaMediaSelector extends LitElement { if (thumbnail === oldThumbnail) { return; } - if (thumbnail && thumbnail.startsWith("/")) { - this._thumbnailUrl = undefined; - // Thumbnails served by local API require authentication - getSignedPath(this.hass, thumbnail).then((signedPath) => { - this._thumbnailUrl = signedPath.path; - }); - } else if ( - thumbnail && - thumbnail.startsWith("https://brands.home-assistant.io") - ) { + if (thumbnail && isBrandUrl(thumbnail)) { // The backend is not aware of the theme used by the users, // so we rewrite the URL to show a proper icon this._thumbnailUrl = brandsUrl({ @@ -89,6 +84,12 @@ export class HaMediaSelector extends LitElement { type: "icon", darkOptimized: this.hass.themes?.darkMode, }); + } else if (thumbnail && thumbnail.startsWith("/")) { + this._thumbnailUrl = undefined; + // Thumbnails served by local API require authentication + getSignedPath(this.hass, thumbnail).then((signedPath) => { + this._thumbnailUrl = signedPath.path; + }); } else { this._thumbnailUrl = thumbnail; } diff --git a/src/components/media-player/ha-media-player-browse.ts b/src/components/media-player/ha-media-player-browse.ts index d55a6f3a07..d60ec2cabf 100644 --- a/src/components/media-player/ha-media-player-browse.ts +++ b/src/components/media-player/ha-media-player-browse.ts @@ -765,6 +765,16 @@ export class HaMediaPlayerBrowse extends LitElement { return ""; } + if (isBrandUrl(thumbnailUrl)) { + // The backend is not aware of the theme used by the users, + // so we rewrite the URL to show a proper icon + return brandsUrl({ + domain: extractDomainFromBrandUrl(thumbnailUrl), + type: "icon", + darkOptimized: this.hass.themes?.darkMode, + }); + } + if (thumbnailUrl.startsWith("/")) { // Thumbnails served by local API require authentication return new Promise((resolve, reject) => { @@ -787,16 +797,6 @@ export class HaMediaPlayerBrowse extends LitElement { }); } - if (isBrandUrl(thumbnailUrl)) { - // The backend is not aware of the theme used by the users, - // so we rewrite the URL to show a proper icon - thumbnailUrl = brandsUrl({ - domain: extractDomainFromBrandUrl(thumbnailUrl), - type: "icon", - darkOptimized: this.hass.themes?.darkMode, - }); - } - return thumbnailUrl; } diff --git a/src/entrypoints/service-worker.ts b/src/entrypoints/service-worker.ts index 9df16df372..f352f1d912 100644 --- a/src/entrypoints/service-worker.ts +++ b/src/entrypoints/service-worker.ts @@ -32,21 +32,28 @@ const initRouting = () => { new CacheFirst({ matchOptions: { ignoreSearch: true } }) ); - // Cache any brand images used for 30 days - // Use revalidation so cache is always available during an extended outage + // Cache any brand images used for 1 day + // Brands are proxied via the local API with backend caching. + // Strip the rotating access token from cache keys so token rotation + // doesn't bust the cache, while preserving other params like "placeholder". registerRoute( ({ url, request }) => - url.origin === "https://brands.home-assistant.io" && + url.pathname.startsWith("/api/brands/") && request.destination === "image", new StaleWhileRevalidate({ cacheName: "brands", - // CORS must be forced to work for CSS images - fetchOptions: { mode: "cors", credentials: "omit" }, plugins: [ + { + cacheKeyWillBeUsed: async ({ request }) => { + const url = new URL(request.url); + url.searchParams.delete("token"); + return url.href; + }, + }, // Add 404 so we quickly respond to domains with missing images new CacheableResponsePlugin({ statuses: [0, 200, 404] }), new ExpirationPlugin({ - maxAgeSeconds: 60 * 60 * 24 * 30, + maxAgeSeconds: 60 * 60 * 24, purgeOnQuotaError: true, }), ], diff --git a/src/panels/config/integrations/dialog-add-integration.ts b/src/panels/config/integrations/dialog-add-integration.ts index ff6dc35049..7ab9f53e9e 100644 --- a/src/panels/config/integrations/dialog-add-integration.ts +++ b/src/panels/config/integrations/dialog-add-integration.ts @@ -578,7 +578,6 @@ class AddIntegrationDialog extends LitElement { } return html` >( this._updateHass({ systemData: {} }); }); clearInterval(this.__backendPingInterval); + + // Fetch the brands access token on initial connect and schedule refresh + fetchAndScheduleBrandsAccessToken(this.hass!); + this.__backendPingInterval = setInterval(() => { if (this.hass?.connected) { // If the backend is busy, or the connection is latent, @@ -343,6 +351,9 @@ export const connectionMixin = >( this._updateHass({ connected: true }); broadcastConnectionStatus("connected"); + // Refresh the brands access token on reconnect and restart refresh schedule + fetchAndScheduleBrandsAccessToken(this.hass!); + // on reconnect always fetch config as we might miss an update while we were disconnected // @ts-ignore this.hass!.callWS({ type: "get_config" }).then((config: HassConfig) => { @@ -360,5 +371,6 @@ export const connectionMixin = >( this._updateHass({ connected: false }); broadcastConnectionStatus("disconnected"); clearInterval(this.__backendPingInterval); + clearBrandsTokenRefresh(); } }; diff --git a/src/util/brands-url.ts b/src/util/brands-url.ts index 4ea8b7ab32..3564127fea 100644 --- a/src/util/brands-url.ts +++ b/src/util/brands-url.ts @@ -1,8 +1,9 @@ +import type { HomeAssistant } from "../types"; + export interface BrandsOptions { domain: string; type: "icon" | "logo" | "icon@2x" | "logo@2x"; darkOptimized?: boolean; - brand?: boolean; } export interface HardwareBrandsOptions { @@ -12,17 +13,94 @@ export interface HardwareBrandsOptions { darkOptimized?: boolean; } -export const brandsUrl = (options: BrandsOptions): string => - `https://brands.home-assistant.io/${options.brand ? "brands/" : ""}_/${options.domain}/${ +let _brandsAccessToken: string | undefined; +let _brandsRefreshInterval: ReturnType | undefined; + +// Token refreshes every 30 minutes and is valid for 1 hour. +// Re-fetch every 30 minutes to always have a valid token. +const TOKEN_REFRESH_MS = 30 * 60 * 1000; + +export const fetchAndScheduleBrandsAccessToken = ( + hass: HomeAssistant +): Promise => + fetchBrandsAccessToken(hass).then( + () => scheduleBrandsTokenRefresh(hass), + () => { + // Ignore failures; older backends may not support this command + } + ); + +export const fetchBrandsAccessToken = async ( + hass: HomeAssistant +): Promise => { + const result = await hass.callWS<{ token: string }>({ + type: "brands/access_token", + }); + _brandsAccessToken = result.token; +}; + +export const scheduleBrandsTokenRefresh = (hass: HomeAssistant): void => { + clearBrandsTokenRefresh(); + _brandsRefreshInterval = setInterval(() => { + fetchBrandsAccessToken(hass).catch(() => { + // Ignore failures; older backends may not support this command + }); + }, TOKEN_REFRESH_MS); +}; + +export const clearBrandsTokenRefresh = (): void => { + if (_brandsRefreshInterval) { + clearInterval(_brandsRefreshInterval); + _brandsRefreshInterval = undefined; + } +}; + +export const brandsUrl = (options: BrandsOptions): string => { + const base = `/api/brands/integration/${options.domain}/${ options.darkOptimized ? "dark_" : "" }${options.type}.png`; + if (_brandsAccessToken) { + return `${base}?token=${_brandsAccessToken}`; + } + return base; +}; -export const hardwareBrandsUrl = (options: HardwareBrandsOptions): string => - `https://brands.home-assistant.io/hardware/${options.category}/${ +export const hardwareBrandsUrl = (options: HardwareBrandsOptions): string => { + const base = `/api/brands/hardware/${options.category}/${ options.darkOptimized ? "dark_" : "" }${options.manufacturer}${options.model ? `_${options.model}` : ""}.png`; + if (_brandsAccessToken) { + return `${base}?token=${_brandsAccessToken}`; + } + return base; +}; -export const extractDomainFromBrandUrl = (url: string) => url.split("/")[4]; +export const addBrandsAuth = (url: string): string => { + if (!_brandsAccessToken || !url.startsWith("/api/brands/")) { + return url; + } + const fullUrl = new URL(url, location.origin); + fullUrl.searchParams.set("token", _brandsAccessToken); + return `${fullUrl.pathname}${fullUrl.search}`; +}; + +export const extractDomainFromBrandUrl = (url: string): string => { + // Handle both new local API paths (/api/brands/integration/{domain}/...) + // and legacy CDN URLs (https://brands.home-assistant.io/_/{domain}/...) + if (url.startsWith("/api/brands/")) { + // /api/brands/integration/{domain}/... -> ["" ,"api", "brands", "integration", "{domain}", ...] + return url.split("/")[4]; + } + // https://brands.home-assistant.io/_/{domain}/... -> ["", "_", "{domain}", ...] + const parsed = new URL(url); + const segments = parsed.pathname.split("/").filter((s) => s.length > 0); + const underscoreIdx = segments.indexOf("_"); + if (underscoreIdx !== -1 && underscoreIdx + 1 < segments.length) { + return segments[underscoreIdx + 1]; + } + return segments[1] ?? ""; +}; export const isBrandUrl = (thumbnail: string | ""): boolean => + thumbnail.startsWith("/api/brands/") || thumbnail.startsWith("https://brands.home-assistant.io/"); diff --git a/test/util/generate-brands-url.test.ts b/test/util/generate-brands-url.test.ts index bd169771e3..527bded700 100644 --- a/test/util/generate-brands-url.test.ts +++ b/test/util/generate-brands-url.test.ts @@ -1,27 +1,144 @@ -import { assert, describe, it } from "vitest"; -import { brandsUrl } from "../../src/util/brands-url"; +import { assert, describe, it, vi, afterEach } from "vitest"; +import type { HomeAssistant } from "../../src/types"; +import { + addBrandsAuth, + brandsUrl, + clearBrandsTokenRefresh, + fetchBrandsAccessToken, + scheduleBrandsTokenRefresh, +} from "../../src/util/brands-url"; describe("Generate brands Url", () => { it("Generate logo brands url for cloud component", () => { assert.strictEqual( - // @ts-ignore brandsUrl({ domain: "cloud", type: "logo" }), - "https://brands.home-assistant.io/_/cloud/logo.png" + "/api/brands/integration/cloud/logo.png" ); }); it("Generate icon brands url for cloud component", () => { assert.strictEqual( - // @ts-ignore brandsUrl({ domain: "cloud", type: "icon" }), - "https://brands.home-assistant.io/_/cloud/icon.png" + "/api/brands/integration/cloud/icon.png" ); }); it("Generate dark theme optimized logo brands url for cloud component", () => { assert.strictEqual( - // @ts-ignore brandsUrl({ domain: "cloud", type: "logo", darkOptimized: true }), - "https://brands.home-assistant.io/_/cloud/dark_logo.png" + "/api/brands/integration/cloud/dark_logo.png" ); }); }); + +describe("addBrandsAuth", () => { + it("Returns non-brands URLs unchanged", () => { + assert.strictEqual( + addBrandsAuth("/api/camera_proxy/camera.foo?token=abc"), + "/api/camera_proxy/camera.foo?token=abc" + ); + }); + + it("Returns brands URL unchanged when no token is available", () => { + assert.strictEqual( + addBrandsAuth("/api/brands/integration/demo/icon.png"), + "/api/brands/integration/demo/icon.png" + ); + }); + + it("Appends token to brands URL when token is available", async () => { + const mockHass = { + callWS: async () => ({ token: "test-token-123" }), + } as unknown as HomeAssistant; + await fetchBrandsAccessToken(mockHass); + + assert.strictEqual( + addBrandsAuth("/api/brands/integration/demo/icon.png"), + "/api/brands/integration/demo/icon.png?token=test-token-123" + ); + }); + + it("Replaces existing token param instead of duplicating", async () => { + const mockHass = { + callWS: async () => ({ token: "new-token" }), + } as unknown as HomeAssistant; + await fetchBrandsAccessToken(mockHass); + + assert.strictEqual( + addBrandsAuth("/api/brands/integration/demo/icon.png?token=old-token"), + "/api/brands/integration/demo/icon.png?token=new-token" + ); + }); +}); + +describe("scheduleBrandsTokenRefresh", () => { + afterEach(() => { + clearBrandsTokenRefresh(); + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + it("Refreshes the token after 30 minutes", async () => { + vi.useFakeTimers(); + let callCount = 0; + const mockHass = { + callWS: async () => { + callCount++; + return { token: `token-${callCount}` }; + }, + } as unknown as HomeAssistant; + + await fetchBrandsAccessToken(mockHass); + assert.strictEqual(callCount, 1); + assert.strictEqual( + brandsUrl({ domain: "test", type: "icon" }), + "/api/brands/integration/test/icon.png?token=token-1" + ); + + scheduleBrandsTokenRefresh(mockHass); + + // Advance 30 minutes + await vi.advanceTimersByTimeAsync(30 * 60 * 1000); + assert.strictEqual(callCount, 2); + assert.strictEqual( + brandsUrl({ domain: "test", type: "icon" }), + "/api/brands/integration/test/icon.png?token=token-2" + ); + }); + + it("Does not refresh before 30 minutes", async () => { + vi.useFakeTimers(); + let callCount = 0; + const mockHass = { + callWS: async () => { + callCount++; + return { token: `token-${callCount}` }; + }, + } as unknown as HomeAssistant; + + await fetchBrandsAccessToken(mockHass); + scheduleBrandsTokenRefresh(mockHass); + + // Advance 29 minutes — should not have refreshed + await vi.advanceTimersByTimeAsync(29 * 60 * 1000); + assert.strictEqual(callCount, 1); + }); + + it("clearBrandsTokenRefresh stops the interval", async () => { + vi.useFakeTimers(); + let callCount = 0; + const mockHass = { + callWS: async () => { + callCount++; + return { token: `token-${callCount}` }; + }, + } as unknown as HomeAssistant; + + await fetchBrandsAccessToken(mockHass); + scheduleBrandsTokenRefresh(mockHass); + clearBrandsTokenRefresh(); + + // Advance 30 minutes — should not have refreshed because we cleared + await vi.advanceTimersByTimeAsync(30 * 60 * 1000); + assert.strictEqual(callCount, 1); + }); +});