From e1ff84830f4941cc57b5ce37f4055c2971af34a1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 3 Jun 2026 06:01:34 +0000 Subject: [PATCH] feat: add image-proxy key ttl and shared http dispatcher --- .../core/src/infrastructure/http/request.ts | 41 ++++++++++++++++++- packages/image-proxy/src/index.ts | 29 +++++++++---- packages/redis/src/lib/channel.ts | 10 ++++- 3 files changed, 69 insertions(+), 11 deletions(-) diff --git a/packages/core/src/infrastructure/http/request.ts b/packages/core/src/infrastructure/http/request.ts index 9bfc376fa..86c53bc58 100644 --- a/packages/core/src/infrastructure/http/request.ts +++ b/packages/core/src/infrastructure/http/request.ts @@ -9,11 +9,18 @@ import { getAllTrustedCertificatesAsync, getTrustedCertificateHostnamesAsync, } from "@homarr/core/infrastructure/certificates"; -import { UndiciHttpAgent } from "@homarr/core/infrastructure/http"; +import { UndiciHttpAgent } from "@homarr/core/infrastructure/http/http-agent"; import type { TrustedCertificateHostname } from "../certificates/hostnames"; import { withTimeoutAsync } from "./timeout"; +const CERTIFICATE_DISPATCHER_TTL_MS = 5 * 60 * 1000; +let trustedCertificateDispatcher: { + dispatcher: UndiciHttpAgent; + expiresAt: number; +} | null = null; +let creatingTrustedCertificateDispatcherPromise: Promise | null = null; + export const createCustomCheckServerIdentity = ( trustedHostnames: TrustedCertificateHostname[], ): typeof checkServerIdentity => { @@ -41,6 +48,36 @@ export const createCertificateAgentAsync = async (override?: { }); }; +const getSharedCertificateAgentAsync = async () => { + const now = Date.now(); + if (trustedCertificateDispatcher && trustedCertificateDispatcher.expiresAt > now) { + return trustedCertificateDispatcher.dispatcher; + } + if (creatingTrustedCertificateDispatcherPromise) { + return await creatingTrustedCertificateDispatcherPromise; + } + + creatingTrustedCertificateDispatcherPromise = createCertificateAgentAsync(undefined) + .then((dispatcher) => { + const previousDispatcher = trustedCertificateDispatcher?.dispatcher; + trustedCertificateDispatcher = { + dispatcher, + expiresAt: Date.now() + CERTIFICATE_DISPATCHER_TTL_MS, + }; + if (previousDispatcher) { + void previousDispatcher.close().catch(() => { + previousDispatcher.destroy(); + }); + } + return dispatcher; + }) + .finally(() => { + creatingTrustedCertificateDispatcherPromise = null; + }); + + return await creatingTrustedCertificateDispatcherPromise; +}; + export const createHttpsAgentAsync = async (override?: Pick) => { return new HttpsAgent({ ca: await getAllTrustedCertificatesAsync(), @@ -63,7 +100,7 @@ export const fetchWithTrustedCertificatesAsync = async ( url: RequestInfo, options?: RequestInit & { timeout?: number }, ): Promise => { - const agent = await createCertificateAgentAsync(undefined); + const agent = await getSharedCertificateAgentAsync(); if (options?.timeout) { return await withTimeoutAsync( async (signal) => diff --git a/packages/image-proxy/src/index.ts b/packages/image-proxy/src/index.ts index 3660064c3..b1f809e91 100644 --- a/packages/image-proxy/src/index.ts +++ b/packages/image-proxy/src/index.ts @@ -9,6 +9,7 @@ import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; import { createGetSetChannel } from "@homarr/redis"; const logger = createLogger({ module: "imageProxy" }); +const IMAGE_PROXY_ENTRY_TTL_SECONDS = 60 * 60 * 24 * 7; const createHmacChannel = (hmac: `${string}.${string}`) => createGetSetChannel(`image-proxy:hmac:${hmac}`); const createUrlByIdChannel = (id: string) => @@ -31,12 +32,17 @@ export class ImageProxy { public async createImageAsync(url: string, headers?: Record): Promise { const existingId = await this.getExistingIdAsync(url, headers); if (existingId) { - logger.debug("Image already exists in the proxy", { - id: existingId, - url: this.redactUrl(url), - headers: this.redactHeaders(headers ?? null), - }); - return this.createImageUrl(existingId); + const existingImageData = await createUrlByIdChannel(existingId).getAsync(); + if (!existingImageData) { + await this.removeHashForImageAsync(url, headers); + } else { + logger.debug("Image already exists in the proxy", { + id: existingId, + url: this.redactUrl(url), + headers: this.redactHeaders(headers ?? null), + }); + return this.createImageUrl(existingId); + } } const id = createId(); @@ -116,8 +122,10 @@ export class ImageProxy { await urlHeaderChannel.setAsync({ url: encryptSecret(url), headers: encryptSecret(JSON.stringify(headers ?? null)), + }, { + ttlSeconds: IMAGE_PROXY_ENTRY_TTL_SECONDS, }); - await hashChannel.setAsync(id); + await hashChannel.setAsync(id, { ttlSeconds: IMAGE_PROXY_ENTRY_TTL_SECONDS }); logger.debug("Stored image in the proxy", { id, @@ -126,6 +134,13 @@ export class ImageProxy { }); } + private async removeHashForImageAsync(url: string, headers: Record | undefined): Promise { + const urlHash = this.hashSecret(url); + const headerHash = this.hashSecret(JSON.stringify(headers ?? null)); + + await createHmacChannel(`${urlHash}.${headerHash}`).removeAsync(); + } + private hashSecret(value: string): string { return createHmac("sha256", ImageProxy.getHmacKey()).update(value).digest("hex"); } diff --git a/packages/redis/src/lib/channel.ts b/packages/redis/src/lib/channel.ts index 193f551e3..25a257219 100644 --- a/packages/redis/src/lib/channel.ts +++ b/packages/redis/src/lib/channel.ts @@ -113,8 +113,14 @@ export const createGetSetChannel = (name: string) => { * Set data in the channel * @param data data to be stored in the channel */ - setAsync: async (data: TData) => { - await getSetClient.set(name, superjson.stringify(data)); + setAsync: async (data: TData, options?: { ttlSeconds?: number }) => { + const ttlSeconds = options?.ttlSeconds; + const serializedData = superjson.stringify(data); + if (ttlSeconds && ttlSeconds > 0) { + await getSetClient.set(name, serializedData, "EX", Math.ceil(ttlSeconds)); + return; + } + await getSetClient.set(name, serializedData); }, /** * Remove data from the channel