diff --git a/apps/api/src/services/fetch/fetch.service.ts b/apps/api/src/services/fetch/fetch.service.ts index 2425e476e..9dc464d39 100644 --- a/apps/api/src/services/fetch/fetch.service.ts +++ b/apps/api/src/services/fetch/fetch.service.ts @@ -4,6 +4,7 @@ import { PROPERTY_API_KEY_OPENROUTER, PROPERTY_OPENROUTER_MODEL, PROPERTY_OPENROUTER_MODEL_WEB_FETCH, + PROPERTY_PROXY_ROUTES, PROPERTY_WEB_FETCH_ROUTES } from '@ghostfolio/common/config'; @@ -12,6 +13,7 @@ import { createOpenRouter } from '@openrouter/ai-sdk-provider'; import { generateText, jsonSchema, tool } from 'ai'; import ms from 'ms'; +import { ProxyRoute } from './interfaces/proxy-route.interface'; import { WebFetchRoute } from './interfaces/web-fetch-route.interface'; @Injectable() @@ -21,11 +23,17 @@ export class FetchService implements OnModuleInit { private static readonly REDACTED_QUERY_PARAM_NAMES = ['apikey', 'api_token']; private static readonly WEB_FETCH_TIMEOUT = ms('30 seconds'); + private proxyRoutes: ProxyRoute[] = []; private webFetchRoutes: WebFetchRoute[] = []; public constructor(private readonly propertyService: PropertyService) {} public async onModuleInit() { + this.proxyRoutes = + (await this.propertyService.getByKey( + PROPERTY_PROXY_ROUTES + )) ?? []; + this.webFetchRoutes = (await this.propertyService.getByKey( PROPERTY_WEB_FETCH_ROUTES @@ -33,6 +41,10 @@ export class FetchService implements OnModuleInit { } public async fetch(input: RequestInfo | URL, init?: RequestInit) { + // Apply a generic per-domain origin override (e.g. route a provider through + // a local proxy) before doing anything else with the request + input = this.applyProxyRoute(input); + const method = ( init?.method ?? (input instanceof Request ? input.method : undefined) ?? @@ -171,18 +183,53 @@ export class FetchService implements OnModuleInit { } } + /** + * Rewrites the origin (protocol, host and port) of a request when its domain + * matches a configured {@link ProxyRoute}, preserving path and query. Returns + * the input unchanged when no route matches or parsing fails. + */ + private applyProxyRoute(input: RequestInfo | URL): RequestInfo | URL { + try { + const url = new URL( + input instanceof Request ? input.url : input.toString() + ); + + const route = this.proxyRoutes.find(({ domain }) => { + return this.matchesDomain(url.hostname, domain); + }); + + if (!route) { + return input; + } + + const target = new URL(route.url); + url.protocol = target.protocol; + url.host = target.host; + + return input instanceof Request + ? new Request(url.toString(), input) + : url.toString(); + } catch { + return input; + } + } + private getMatchingWebFetchRoute(url: string) { try { const { hostname } = new URL(url); return this.webFetchRoutes.find(({ domain }) => { - return hostname === domain || hostname.endsWith(`.${domain}`); + return this.matchesDomain(hostname, domain); }); } catch { return undefined; } } + private matchesDomain(hostname: string, domain: string): boolean { + return hostname === domain || hostname.endsWith(`.${domain}`); + } + private redactUrl(rawUrl: string): string { try { const url = new URL(rawUrl); diff --git a/apps/api/src/services/fetch/interfaces/proxy-route.interface.ts b/apps/api/src/services/fetch/interfaces/proxy-route.interface.ts new file mode 100644 index 000000000..49c374916 --- /dev/null +++ b/apps/api/src/services/fetch/interfaces/proxy-route.interface.ts @@ -0,0 +1,22 @@ +/** + * Overrides the origin (protocol, host and port) of outgoing requests for a + * given domain. This lets a self-hosted instance transparently route a + * provider's traffic through a local proxy (e.g. one that solves a bot + * challenge) without changing any call sites — the request path and query are + * preserved. + * + * Configured via the `PROXY_ROUTES` property as a JSON array, e.g. + * + * [ + * { + * "domain": "www.trackinsight.com", + * "url": "http://trackinsight-proxy:8191" + * } + * ] + * + * Matches the domain itself and its subdomains (e.g. `api.example.com`). + */ +export interface ProxyRoute { + domain: string; + url: string; +} diff --git a/libs/common/src/lib/config.ts b/libs/common/src/lib/config.ts index 03600088f..a8982d42d 100644 --- a/libs/common/src/lib/config.ts +++ b/libs/common/src/lib/config.ts @@ -259,6 +259,7 @@ export const PROPERTY_IS_READ_ONLY_MODE = 'IS_READ_ONLY_MODE'; export const PROPERTY_IS_USER_SIGNUP_ENABLED = 'IS_USER_SIGNUP_ENABLED'; export const PROPERTY_OPENROUTER_MODEL = 'OPENROUTER_MODEL'; export const PROPERTY_OPENROUTER_MODEL_WEB_FETCH = 'OPENROUTER_MODEL_WEB_FETCH'; +export const PROPERTY_PROXY_ROUTES = 'PROXY_ROUTES'; export const PROPERTY_REFERRAL_PARTNERS = 'REFERRAL_PARTNERS'; export const PROPERTY_SLACK_COMMUNITY_USERS = 'SLACK_COMMUNITY_USERS'; export const PROPERTY_STRIPE_CONFIG = 'STRIPE_CONFIG';