|
|
|
@ -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<ProxyRoute[]>( |
|
|
|
PROPERTY_PROXY_ROUTES |
|
|
|
)) ?? []; |
|
|
|
|
|
|
|
this.webFetchRoutes = |
|
|
|
(await this.propertyService.getByKey<WebFetchRoute[]>( |
|
|
|
PROPERTY_WEB_FETCH_ROUTES |
|
|
|
@ -59,8 +67,10 @@ export class FetchService implements OnModuleInit { |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
const proxiedInput = this.applyProxyRoute(input); |
|
|
|
|
|
|
|
try { |
|
|
|
return await globalThis.fetch(input, init); |
|
|
|
return await globalThis.fetch(proxiedInput, init); |
|
|
|
} catch (error) { |
|
|
|
if (error instanceof Error) { |
|
|
|
this.logger.error( |
|
|
|
@ -171,18 +181,73 @@ 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 { |
|
|
|
let requestUrl: URL; |
|
|
|
|
|
|
|
try { |
|
|
|
requestUrl = new URL( |
|
|
|
input instanceof Request ? input.url : input.toString() |
|
|
|
); |
|
|
|
} catch { |
|
|
|
return input; |
|
|
|
} |
|
|
|
|
|
|
|
const route = this.proxyRoutes.find(({ domain }) => { |
|
|
|
return this.hostnameMatchesDomain({ |
|
|
|
domain, |
|
|
|
hostname: requestUrl.hostname |
|
|
|
}); |
|
|
|
}); |
|
|
|
|
|
|
|
if (!route) { |
|
|
|
return input; |
|
|
|
} |
|
|
|
|
|
|
|
try { |
|
|
|
const proxyUrl = new URL(route.url); |
|
|
|
|
|
|
|
requestUrl.host = proxyUrl.host; |
|
|
|
requestUrl.protocol = proxyUrl.protocol; |
|
|
|
} catch { |
|
|
|
this.logger.warn( |
|
|
|
`Skipping proxy route for "${route.domain}": invalid url "${route.url}"` |
|
|
|
); |
|
|
|
|
|
|
|
return input; |
|
|
|
} |
|
|
|
|
|
|
|
return input instanceof Request |
|
|
|
? new Request(requestUrl.toString(), input) |
|
|
|
: requestUrl.toString(); |
|
|
|
} |
|
|
|
|
|
|
|
private getMatchingWebFetchRoute(url: string) { |
|
|
|
try { |
|
|
|
const { hostname } = new URL(url); |
|
|
|
|
|
|
|
return this.webFetchRoutes.find(({ domain }) => { |
|
|
|
return hostname === domain || hostname.endsWith(`.${domain}`); |
|
|
|
return this.hostnameMatchesDomain({ domain, hostname }); |
|
|
|
}); |
|
|
|
} catch { |
|
|
|
return undefined; |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
private hostnameMatchesDomain({ |
|
|
|
domain, |
|
|
|
hostname |
|
|
|
}: { |
|
|
|
domain: string; |
|
|
|
hostname: string; |
|
|
|
}): boolean { |
|
|
|
return hostname === domain || hostname.endsWith(`.${domain}`); |
|
|
|
} |
|
|
|
|
|
|
|
private redactUrl(rawUrl: string): string { |
|
|
|
try { |
|
|
|
const url = new URL(rawUrl); |
|
|
|
|