Files
homarr/packages/image-proxy/src/index.ts
T
+12 fcd7c979df feat!: release v2 (#6545)
Co-authored-by: Artur <145776561+arturict@users.noreply.github.com>
Co-authored-by: arturict <arturict@users.noreply.github.com>
Co-authored-by: Thomas <thomas@ajnart.fr>
Co-authored-by: Afonso Jorge Ramos <afonsojorgeramos@gmail.com>
Co-authored-by: Danura30082 <danura30082+github@gmail.com>
Co-authored-by: Kushie <122986913+kushiemoon-dev@users.noreply.github.com>
Co-authored-by: ajnart <thomas@ajnart.fr>
Co-authored-by: ajnart <thomas@ajnart.dev>
Co-authored-by: chenjun <chenjun@foxmail.com>
Co-authored-by: Wikibear <wikibear@users.noreply.github.com>
Co-authored-by: Bergasha <bergasha@gmail.com>
Co-authored-by: leafyelin <43306196+leafyelin@users.noreply.github.com>
Co-authored-by: Lucas Araujo <lucas.lra@gmail.com>
Co-authored-by: GreenMonito <86085570+GreenMonito@users.noreply.github.com>
Co-authored-by: Nerdy-Technician <roffo@nerdy-technician.social>
Co-authored-by: Jun <34545896+jcstrong@users.noreply.github.com>
Co-authored-by: Edmond Abraham <54119952+gitedmond@users.noreply.github.com>
Co-authored-by: thrawnx <tight.bulb9384@fastmail.com>
Co-authored-by: Chris <58918656+nordada@users.noreply.github.com>
Co-authored-by: yi111 <153097222+Yi-111-a@users.noreply.github.com>
Co-authored-by: Yanis <naamaneyanis@gmail.com>
Co-authored-by: Wikibear <me@sej.de>
Co-authored-by: alessiottavo <93441886+alessiottavo@users.noreply.github.com>
2026-10-02 21:00:14 +02:00

205 lines
7.2 KiB
TypeScript

import { createHmac, hkdfSync, randomUUID } from "node:crypto";
import { setImmediate } from "node:timers/promises";
import { createId, formatBytes } from "@homarr/common";
import { env } from "@homarr/common/env";
import { decryptSecret, encryptSecret } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import { createGetSetChannel } from "@homarr/redis";
const logger = createLogger({ module: "imageProxy" });
export const IMAGE_PROXY_REDIS_TTL_SECONDS = 86400;
const createHmacChannel = (hmac: `${string}.${string}`) => createGetSetChannel<string>(`image-proxy:hmac:${hmac}`);
const createUrlByIdChannel = (id: string) =>
createGetSetChannel<{
url: `${string}.${string}`;
headers: `${string}.${string}`;
}>(`image-proxy:url:${id}`);
export type ForwardImageResult =
| { image: ArrayBuffer; contentType: string | null }
| { error: "not-found" }
| { error: "upstream-error"; statusCode: number }
| { error: "fetch-error" };
export class ImageProxy {
private static hmacKey: Buffer<ArrayBuffer> | null = null;
/**
* Generate secure key for image-proxy from encryption key
*/
private static getHmacKey(): Buffer<ArrayBuffer> {
this.hmacKey ??= Buffer.from(hkdfSync("sha256", env.SECRET_ENCRYPTION_KEY, "", "image-proxy", 32));
return this.hmacKey;
}
public async createImageAsync(url: string, headers?: Record<string, string>): Promise<string> {
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 createImagesAsync(urls: string[], headers?: Record<string, string>): Promise<string[]> {
// Album assets share credentials. Hash/encrypt that immutable value once,
// and bound each registration batch so it cannot monopolize rendering.
const headerValue = JSON.stringify(headers ?? null);
const headerHash = this.hashSecret(headerValue);
const encryptedHeaders = encryptSecret(headerValue);
const links: string[] = [];
for (let index = 0; index < urls.length; index += 128) {
const batch = await Promise.all(
urls.slice(index, index + 128).map(async (url) => {
const urlHash = this.hashSecret(url);
const hashChannel = createHmacChannel(`${urlHash}.${headerHash}`);
const existingId = await hashChannel.getAsync();
if (existingId) return this.createImageUrl(existingId);
const id = randomUUID();
await createUrlByIdChannel(id).setAsync(
{ url: encryptSecret(url), headers: encryptedHeaders },
{ ttlSeconds: IMAGE_PROXY_REDIS_TTL_SECONDS },
);
await hashChannel.setAsync(id, { ttlSeconds: IMAGE_PROXY_REDIS_TTL_SECONDS });
return this.createImageUrl(id);
}),
);
links.push(...batch);
if (index + 128 < urls.length) await setImmediate();
}
return links;
}
public async forwardImageAsync(id: string): Promise<ForwardImageResult> {
const urlAndHeaders = await this.getImageUrlAndHeadersAsync(id);
if (!urlAndHeaders) {
return { error: "not-found" };
}
const proxyUrl = this.createImageUrl(id);
let response: Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>;
try {
response = await fetchWithTrustedCertificatesAsync(urlAndHeaders.url, {
headers: urlAndHeaders.headers ?? {},
});
} catch (error) {
logger.error(
new ErrorWithMetadata(
"Failed to connect to image source",
{
id,
url: this.redactUrl(urlAndHeaders.url),
headers: this.redactHeaders(urlAndHeaders.headers),
proxyUrl,
},
{ cause: error },
),
);
return { error: "fetch-error" };
}
if (!response.ok) {
logger.error(
new ErrorWithMetadata("Failed to fetch image", {
id,
url: this.redactUrl(urlAndHeaders.url),
headers: this.redactHeaders(urlAndHeaders.headers),
proxyUrl,
statusCode: response.status,
}),
);
return { error: "upstream-error", statusCode: response.status };
}
const arrayBuffer = await response.arrayBuffer();
logger.debug("Forwarding image succeeded", {
id,
url: this.redactUrl(urlAndHeaders.url),
headers: this.redactHeaders(urlAndHeaders.headers),
proxyUrl,
size: formatBytes(arrayBuffer.byteLength),
});
return { image: arrayBuffer, contentType: response.headers.get("content-type") };
}
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 });
return null;
}
return {
url: decryptSecret(urlHeader.url),
headers: JSON.parse(decryptSecret(urlHeader.headers)) as Record<string, string> | null,
};
}
private async getExistingIdAsync(url: string, headers: Record<string, string> | undefined): Promise<string | null> {
const urlHash = this.hashSecret(url);
const headerHash = this.hashSecret(JSON.stringify(headers ?? null));
const channel = createHmacChannel(`${urlHash}.${headerHash}`);
return await channel.getAsync();
}
private async storeImageAsync(id: string, url: string, headers: Record<string, string> | undefined): Promise<void> {
const urlHash = this.hashSecret(url);
const headerHash = this.hashSecret(JSON.stringify(headers ?? null));
const hashChannel = createHmacChannel(`${urlHash}.${headerHash}`);
const urlHeaderChannel = createUrlByIdChannel(id);
await urlHeaderChannel.setAsync(
{
url: encryptSecret(url),
headers: encryptSecret(JSON.stringify(headers ?? null)),
},
{ ttlSeconds: IMAGE_PROXY_REDIS_TTL_SECONDS },
);
await hashChannel.setAsync(id, { ttlSeconds: IMAGE_PROXY_REDIS_TTL_SECONDS });
logger.debug("Stored image in the proxy", {
id,
url: this.redactUrl(url),
headers: this.redactHeaders(headers ?? null),
});
}
private hashSecret(value: string): string {
return createHmac("sha256", ImageProxy.getHmacKey()).update(value).digest("hex");
}
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<string, string> | null): string | null {
if (!headers) return null;
return Object.keys(headers).join(", ");
}
}