1
0
mirror of https://github.com/home-assistant/frontend.git synced 2026-04-02 00:27:49 +01:00

Adjust brands assets to proxy brand images through local API (#29799)

Co-authored-by: Bram Kragten <mail@bramkragten.nl>
This commit is contained in:
Franck Nijhof
2026-02-25 17:10:38 +01:00
committed by GitHub
parent ce5991582c
commit 6070c1907a
12 changed files with 262 additions and 48 deletions

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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,
}),
],

View File

@@ -578,7 +578,6 @@ class AddIntegrationDialog extends LitElement {
}
return html`
<ha-integration-list-item
brand
.hass=${this.hass}
.integration=${integration}
tabindex="0"

View File

@@ -30,8 +30,6 @@ export class HaIntegrationListItem extends ListItemBase {
// eslint-disable-next-line lit/attribute-names
@property({ type: Boolean }) hasMeta = true;
@property({ type: Boolean }) brand = false;
// @ts-expect-error
protected override renderSingleLine() {
if (!this.integration) {
@@ -68,7 +66,6 @@ export class HaIntegrationListItem extends ListItemBase {
domain: this.integration.domain,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
brand: this.brand,
})}
crossorigin="anonymous"
referrerpolicy="no-referrer"

View File

@@ -224,7 +224,6 @@ export class ThreadConfigPanel extends SubscribeMixin(LitElement) {
slot="graphic"
.src=${brandsUrl({
domain: router.brand,
brand: true,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
})}

View File

@@ -18,6 +18,7 @@ import "../../../components/ha-svg-icon";
import { cameraUrlWithWidthHeight } from "../../../data/camera";
import type { ActionHandlerEvent } from "../../../data/lovelace/action_handler";
import type { HomeAssistant } from "../../../types";
import { addBrandsAuth } from "../../../util/brands-url";
import { actionHandler } from "../common/directives/action-handler-directive";
import { computeLovelaceEntityName } from "../common/entity/compute-lovelace-entity-name";
import { findEntities } from "../common/find-entities";
@@ -143,7 +144,7 @@ export class HuiEntityBadge extends LitElement implements LovelaceBadge {
if (!entityPicture) return undefined;
let imageUrl = this.hass!.hassUrl(entityPicture);
let imageUrl = this.hass!.hassUrl(addBrandsAuth(entityPicture));
if (computeStateDomain(stateObj) === "camera") {
imageUrl = cameraUrlWithWidthHeight(imageUrl, 32, 32);
}

View File

@@ -21,6 +21,7 @@ import { cameraUrlWithWidthHeight } from "../../../data/camera";
import type { ActionHandlerEvent } from "../../../data/lovelace/action_handler";
import "../../../state-display/state-display";
import type { HomeAssistant } from "../../../types";
import { addBrandsAuth } from "../../../util/brands-url";
import "../card-features/hui-card-features";
import type { LovelaceCardFeatureContext } from "../card-features/types";
import { computeLovelaceEntityName } from "../common/entity/compute-lovelace-entity-name";
@@ -158,7 +159,7 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
if (!entityPicture) return undefined;
let imageUrl = this.hass!.hassUrl(entityPicture);
let imageUrl = this.hass!.hassUrl(addBrandsAuth(entityPicture));
if (computeDomain(entity.entity_id) === "camera") {
imageUrl = cameraUrlWithWidthHeight(imageUrl, 80, 80);
}

View File

@@ -30,6 +30,10 @@ import { subscribeEntityRegistryDisplay } from "../data/ws-entity_registry_displ
import { subscribeFloorRegistry } from "../data/ws-floor_registry";
import { subscribePanels } from "../data/ws-panels";
import { translationMetadata } from "../resources/translations-metadata";
import {
clearBrandsTokenRefresh,
fetchAndScheduleBrandsAccessToken,
} from "../util/brands-url";
import type { Constructor, HomeAssistant, ServiceCallResponse } from "../types";
import { getLocalLanguage } from "../util/common-translation";
import { fetchWithAuth } from "../util/fetch-with-auth";
@@ -319,6 +323,10 @@ export const connectionMixin = <T extends Constructor<HassBaseEl>>(
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 = <T extends Constructor<HassBaseEl>>(
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 = <T extends Constructor<HassBaseEl>>(
this._updateHass({ connected: false });
broadcastConnectionStatus("disconnected");
clearInterval(this.__backendPingInterval);
clearBrandsTokenRefresh();
}
};

View File

@@ -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<typeof setInterval> | 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<void> =>
fetchBrandsAccessToken(hass).then(
() => scheduleBrandsTokenRefresh(hass),
() => {
// Ignore failures; older backends may not support this command
}
);
export const fetchBrandsAccessToken = async (
hass: HomeAssistant
): Promise<void> => {
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/");

View File

@@ -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);
});
});