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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -578,7 +578,6 @@ class AddIntegrationDialog extends LitElement {
|
||||
}
|
||||
return html`
|
||||
<ha-integration-list-item
|
||||
brand
|
||||
.hass=${this.hass}
|
||||
.integration=${integration}
|
||||
tabindex="0"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
})}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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/");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user