From 7a1272146c597c1f9df89d74b8389f70e7cdd61a Mon Sep 17 00:00:00 2001 From: StrandedTurtle <132835255+StrandedTurtle@users.noreply.github.com> Date: Mon, 18 May 2026 20:39:01 +0100 Subject: [PATCH] fix(tracearr): support absolute image urls (#5654) * Update base URL handling for tracearr integration * fix: remove unnecessary prefix from bcrypt hashing * refactor: add is-absolute-url check * test: add unit tests --------- Co-authored-by: Meier Lukas --- packages/common/src/test/url.spec.ts | 20 +++++++++- packages/common/src/url.ts | 6 +++ .../src/tracearr/tracearr-integration.ts | 38 +++++-------------- 3 files changed, 34 insertions(+), 30 deletions(-) diff --git a/packages/common/src/test/url.spec.ts b/packages/common/src/test/url.spec.ts index eafee07dd..8b6600d93 100644 --- a/packages/common/src/test/url.spec.ts +++ b/packages/common/src/test/url.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "vitest"; -import { getPortFromUrl } from "../url"; +import { getPortFromUrl, isAbsoluteUrl } from "../url.js"; describe("getPortFromUrl", () => { test.each([ @@ -38,3 +38,21 @@ describe("getPortFromUrl", () => { expect(act).toThrowError("Unsupported protocol: ftp:"); }); }); + +describe("isAbsoluteUrl", () => { + test.each([ + ["http://example.com", true], + ["https://example.com", true], + ["ftp://example.com", true], + ["file:///path/to/file", true], + ["mailto:johndoe@example.com", true], + ["/relative/path", false], + ["relative/path", false], + ])("should return %s for URL: %s", (urlString, expected) => { + // Act + const result = isAbsoluteUrl(urlString); + + // Assert + expect(result).toBe(expected); + }); +}); diff --git a/packages/common/src/url.ts b/packages/common/src/url.ts index 0bdf322fe..7550def44 100644 --- a/packages/common/src/url.ts +++ b/packages/common/src/url.ts @@ -38,3 +38,9 @@ export const getPortFromUrl = (url: URL): number => { throw new Error(`Unsupported protocol: ${url.protocol}`); }; + +const absoluteUrlRegex = /^[a-z]+:(\/\/)?/; + +export const isAbsoluteUrl = (urlOrPath: string): boolean => { + return absoluteUrlRegex.test(urlOrPath.toLowerCase()); +}; diff --git a/packages/integrations/src/tracearr/tracearr-integration.ts b/packages/integrations/src/tracearr/tracearr-integration.ts index 0ad7001a2..40f3b2618 100644 --- a/packages/integrations/src/tracearr/tracearr-integration.ts +++ b/packages/integrations/src/tracearr/tracearr-integration.ts @@ -1,3 +1,4 @@ +import { isAbsoluteUrl } from "@homarr/common"; import { ResponseError } from "@homarr/common/server"; import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; import { ImageProxy } from "@homarr/image-proxy"; @@ -85,10 +86,10 @@ export class TracearrIntegration extends Integration { data: await Promise.all( json.data.map(async (stream) => { if (stream.userAvatarUrl) { - stream.userAvatarUrl = await this.proxyImageAsync(imageProxy, stream.userAvatarUrl, stream.id, "avatar"); + stream.userAvatarUrl = await this.proxyImageAsync(imageProxy, stream.userAvatarUrl); } if (stream.posterUrl) { - stream.posterUrl = await this.proxyImageAsync(imageProxy, stream.posterUrl, stream.id, "poster"); + stream.posterUrl = await this.proxyImageAsync(imageProxy, stream.posterUrl); } return stream; }), @@ -120,12 +121,7 @@ export class TracearrIntegration extends Integration { ...json, data: await Promise.all( json.data.map(async (violation) => { - violation.user.avatarUrl = await this.proxyImageAsync( - imageProxy, - violation.user.avatarUrl, - violation.user.id, - "avatar", - ); + violation.user.avatarUrl = await this.proxyImageAsync(imageProxy, violation.user.avatarUrl); return violation; }), ), @@ -156,12 +152,7 @@ export class TracearrIntegration extends Integration { ...json, data: await Promise.all( json.data.map(async (session) => { - session.user.avatarUrl = await this.proxyImageAsync( - imageProxy, - session.user.avatarUrl, - session.user.id, - "avatar", - ); + session.user.avatarUrl = await this.proxyImageAsync(imageProxy, session.user.avatarUrl); return session; }), ), @@ -194,23 +185,12 @@ export class TracearrIntegration extends Integration { }; } - private async proxyImageAsync( - imageProxy: ImageProxy, - url: string | null | undefined, - uniqueId: string, - discriminator: string, - ): Promise { - if (!url) return null; + private async proxyImageAsync(imageProxy: ImageProxy, urlOrPath: string | null | undefined): Promise { + if (!urlOrPath) return null; try { // ImageProxy doesn't support fallback urls so we need to remove them - const cleanUrl = url.replace(/&fallback=[^&]+/, "").replace(/\?fallback=[^&]+&?/, "?"); - // Build the full URL, then inject _uid as the FIRST query param. - // This is critical because ImageProxy uses bcrypt which truncates at 72 bytes. - // Without this, all long avatar/poster URLs from the same session hash identically - // since they share the same first 72+ bytes. - const baseUrl = this.url(cleanUrl as `/${string}`).toString(); - const prefix = `_uid=${discriminator}_${uniqueId}&`; - const fullUrl = baseUrl.includes("?") ? baseUrl.replace("?", `?${prefix}`) : `${baseUrl}?${prefix}`; + const cleanUrl = urlOrPath.replace(/&fallback=[^&]+/, "").replace(/\?fallback=[^&]+&?/, "?"); + const fullUrl = isAbsoluteUrl(urlOrPath) ? cleanUrl : this.url(cleanUrl as `/${string}`).toString(); return await imageProxy.createImageAsync(fullUrl, this.getAuthHeaders()); } catch {