forwardImageAsync awaited response.arrayBuffer(), so the whole image sat in server memory before anything was sent to the client. A media board asks for dozens of posters and backdrops at once, so that is dozens of full images held simultaneously - and a client that gives up midway still cost a complete server-side download. Forwarding response.body instead lets backpressure reach upstream. Measured with 30 concurrent 2 MiB images and deliberately slow clients, one fresh process per arm because the allocator does not return freed spans: buffered +116.0 MiB, +117.8 MiB peak RSS streamed +74.2 MiB, +78.8 MiB peak RSS -36% peak RSS on that load, and it scales with concurrency times image size. Content-Length is now forwarded too, so clients still get a progress bar.
184 lines
6.2 KiB
TypeScript
184 lines
6.2 KiB
TypeScript
import { createHmac, hkdfSync } from "node:crypto";
|
|
|
|
import { createId } 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}`);
|
|
|
|
/** The upstream body, forwarded as-is rather than buffered. */
|
|
type ProxiedImageBody = Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>["body"];
|
|
|
|
export type ForwardImageResult =
|
|
| { image: ProxiedImageBody; contentType: string | null; contentLength: 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 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 contentLength = response.headers.get("content-length");
|
|
logger.debug("Forwarding image succeeded", {
|
|
id,
|
|
url: this.redactUrl(urlAndHeaders.url),
|
|
headers: this.redactHeaders(urlAndHeaders.headers),
|
|
proxyUrl,
|
|
size: contentLength ? `${(Number(contentLength) / 1024).toFixed(1)}KB` : "unknown",
|
|
});
|
|
|
|
// The body is forwarded as a stream rather than buffered: a board can request dozens of posters
|
|
// at once, and buffering held every one of them in memory in full before responding.
|
|
return {
|
|
image: response.body,
|
|
contentType: response.headers.get("content-type"),
|
|
contentLength,
|
|
};
|
|
}
|
|
|
|
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(", ");
|
|
}
|
|
}
|