import bcrypt from "bcrypt"; import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server"; import { createId } from "@homarr/common"; import { decryptSecret, encryptSecret } from "@homarr/common/server"; import { logger } from "@homarr/log"; import { createGetSetChannel } from "@homarr/redis"; const createHashChannel = (hash: `${string}.${string}`) => createGetSetChannel(`image-proxy:hash:${hash}`); const createUrlByIdChannel = (id: string) => createGetSetChannel<{ url: `${string}.${string}`; headers: `${string}.${string}`; }>(`image-proxy:url:${id}`); const saltChannel = createGetSetChannel("image-proxy:salt"); export class ImageProxy { private static salt: string | null = null; private async getOrCreateSaltAsync(): Promise { if (ImageProxy.salt) return ImageProxy.salt; const existingSalt = await saltChannel.getAsync(); if (existingSalt) { ImageProxy.salt = existingSalt; return existingSalt; } const salt = await bcrypt.genSalt(10); logger.debug(`Generated new salt for image proxy salt="${salt}"`); ImageProxy.salt = salt; await saltChannel.setAsync(salt); return salt; } 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 id = createId(); await this.storeImageAsync(id, url, headers); return this.createImageUrl(id); } public async forwardImageAsync(id: string): Promise { const urlAndHeaders = await this.getImageUrlAndHeadersAsync(id); if (!urlAndHeaders) { return null; } const response = await fetchWithTrustedCertificatesAsync(urlAndHeaders.url, { headers: urlAndHeaders.headers ?? {}, }); const proxyUrl = this.createImageUrl(id); if (!response.ok) { logger.error( `Failed to fetch image id="${id}" url="${this.redactUrl(urlAndHeaders.url)}" headers="${this.redactHeaders(urlAndHeaders.headers)}" proxyUrl="${proxyUrl}" statusCode="${response.status}"`, ); return null; } const blob = (await response.blob()) as Blob; logger.debug( `Forwarding image succeeded id="${id}" url="${this.redactUrl(urlAndHeaders.url)}" headers="${this.redactHeaders(urlAndHeaders.headers)}" proxyUrl="${proxyUrl} size="${(blob.size / 1024).toFixed(1)}KB"`, ); return blob; } private createImageUrl(id: string): string { return `/api/image-proxy/${id}`; } private async getImageUrlAndHeadersAsync(id: string) { const urlHeaderChannel = createUrlByIdChannel(id); const urlHeader = await urlHeaderChannel.getAsync(); if (!urlHeader) { logger.warn(`Image not found in the proxy id="${id}"`); return null; } return { url: decryptSecret(urlHeader.url), headers: JSON.parse(decryptSecret(urlHeader.headers)) as Record | null, }; } private async getExistingIdAsync(url: string, headers: Record | undefined): Promise { const salt = await this.getOrCreateSaltAsync(); const urlHash = await bcrypt.hash(url, salt); const headerHash = await bcrypt.hash(JSON.stringify(headers ?? null), salt); const channel = createHashChannel(`${urlHash}.${headerHash}`); return await channel.getAsync(); } private async storeImageAsync(id: string, url: string, headers: Record | undefined): Promise { const salt = await this.getOrCreateSaltAsync(); const urlHash = await bcrypt.hash(url, salt); const headerHash = await bcrypt.hash(JSON.stringify(headers ?? null), salt); const hashChannel = createHashChannel(`${urlHash}.${headerHash}`); const urlHeaderChannel = createUrlByIdChannel(id); await urlHeaderChannel.setAsync({ url: encryptSecret(url), headers: encryptSecret(JSON.stringify(headers ?? null)), }); await hashChannel.setAsync(id); logger.debug( `Stored image in the proxy id="${id}" url="${this.redactUrl(url)}" headers="${this.redactHeaders(headers ?? null)}"`, ); } private redactUrl(url: string): string { const urlObject = new URL(url); const redactedSearch = [...urlObject.searchParams.keys()].map((key) => `${key}=REDACTED`).join("&"); return `${urlObject.origin}${urlObject.pathname}${redactedSearch ? `?${redactedSearch}` : ""}`; } private redactHeaders(headers: Record | null): string | null { if (!headers) return null; return Object.keys(headers).join(", "); } }