From db4b6bf4b512cecdb23ce65aa6ce7258911cd0a5 Mon Sep 17 00:00:00 2001 From: ajnart Date: Sat, 12 Sep 2026 23:57:33 +0200 Subject: [PATCH] refactor: let integrations own HTTP authentication --- .../adguard-home/adguard-home-integration.ts | 5 + .../src/anchor/anchor-integration.ts | 10 +- .../archive-team-warrior-integration.ts | 5 + .../audiobookshelf-integration.ts | 5 + packages/integrations/src/base/creator.ts | 393 +++++------------- packages/integrations/src/base/integration.ts | 3 + .../src/bazarr/bazarr-integration.ts | 5 + .../src/coolify/coolify-integration.ts | 5 + .../src/dashdot/dashdot-integration.ts | 5 + .../aria2/aria2-integration.ts | 22 + .../nzbget/nzbget-integration.ts | 6 + .../sabnzbd/sabnzbd-integration.ts | 6 + .../slskd/slskd-integration.ts | 14 +- .../integrations/src/emby/emby-integration.ts | 6 + .../src/glances/glances-integration.ts | 5 + .../src/gluetun/gluetun-integration.ts | 5 + .../src/gotify/gotify-integration.ts | 5 + .../homeassistant-integration.ts | 5 + packages/integrations/src/http-auth.ts | 111 +---- .../integrations/src/ical/ical-integration.ts | 15 + .../src/immich/immich-integration.ts | 6 + .../src/jellyseerr/jellyseerr-integration.ts | 6 + .../src/llama-cpp/llamacpp-integration.ts | 5 + .../lidarr/lidarr-integration.ts | 12 +- .../radarr/radarr-integration.ts | 16 +- .../readarr/readarr-integration.ts | 12 +- .../sonarr/sonarr-integration.ts | 16 +- .../media-transcoding/tdarr-integration.ts | 17 +- .../integrations/src/mock/mock-integration.ts | 5 + .../src/nextcloud/nextcloud.integration.ts | 13 +- .../integrations/src/ntfy/ntfy-integration.ts | 7 +- .../src/opnsense/opnsense-integration.ts | 5 + .../src/overseerr/overseerr-integration.ts | 46 +- .../paperless-ngx-integration.ts | 5 + .../src/patchmon/patchmon-integration.ts | 5 + .../src/peanut/peanut-integration.ts | 5 + .../integrations/src/plex/plex-integration.ts | 6 + .../src/prowlarr/prowlarr-integration.ts | 6 + .../src/proxmox/proxmox-integration.ts | 9 + .../speedtest-tracker-integration.ts | 5 + .../src/tracearr/tracearr-integration.ts | 5 + .../src/truenas/truenas-integration.ts | 5 + .../src/unraid/unraid-integration.ts | 6 + .../uptime-kuma/uptime-kuma-integration.ts | 5 + .../integrations/src/wud/wud-integration.ts | 5 + packages/integrations/test/base.spec.ts | 3 + 46 files changed, 425 insertions(+), 447 deletions(-) diff --git a/packages/integrations/src/adguard-home/adguard-home-integration.ts b/packages/integrations/src/adguard-home/adguard-home-integration.ts index cd7a54f71..f33ca42c7 100644 --- a/packages/integrations/src/adguard-home/adguard-home-integration.ts +++ b/packages/integrations/src/adguard-home/adguard-home-integration.ts @@ -1,6 +1,7 @@ import { ParseError } from "@homarr/common/server"; import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; +import type { IntegrationHttpAuthentication } from "../http-auth"; import type { IntegrationTestingInput } from "../base/integration"; import { Integration } from "../base/integration"; import { TestConnectionError } from "../base/test-connection/test-connection-error"; @@ -10,6 +11,10 @@ import type { DnsHoleSummary } from "../interfaces/dns-hole-summary/dns-hole-sum import { filteringStatusSchema, statsResponseSchema, statusResponseSchema } from "./adguard-home-types"; export class AdGuardHomeIntegration extends Integration implements DnsHoleSummaryIntegration { + public async getHttpAuthenticationAsync(): Promise { + return { headers: { Authorization: `Basic ${this.getAuthorizationHeaderValue()}` } }; + } + public async getSummaryAsync(): Promise { const headers = { Authorization: `Basic ${this.getAuthorizationHeaderValue()}`, diff --git a/packages/integrations/src/anchor/anchor-integration.ts b/packages/integrations/src/anchor/anchor-integration.ts index 4c4931894..85b3ac2e3 100644 --- a/packages/integrations/src/anchor/anchor-integration.ts +++ b/packages/integrations/src/anchor/anchor-integration.ts @@ -4,6 +4,8 @@ import { ResponseError } from "@homarr/common/server"; import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; import { createLogger } from "@homarr/core/infrastructure/logs"; +import type { IntegrationHttpAuthentication } from "../http-auth"; +import { bearerAuth } from "../http-auth"; import type { IntegrationTestingInput } from "../base/integration"; import { Integration } from "../base/integration"; import { TestConnectionError } from "../base/test-connection/test-connection-error"; @@ -14,11 +16,13 @@ import type { AnchorNote, AnchorNotesListInput, AnchorNoteSummary, AnchorNoteUpd const logger = createLogger({ module: "anchorIntegration" }); export class AnchorIntegration extends Integration { + public async getHttpAuthenticationAsync(): Promise { + return bearerAuth(this.integration); + } + protected async testingAsync(input: IntegrationTestingInput): Promise { const response = await input.fetchAsync(this.url("/api/notes", { limit: 1 }), { - headers: { - Authorization: `Bearer ${this.getSecretValue("apiKey")}`, - }, + headers: (await this.getHttpAuthenticationAsync()).headers, }); if (!response.ok) return TestConnectionError.StatusResult(response); diff --git a/packages/integrations/src/archive-team-warrior/archive-team-warrior-integration.ts b/packages/integrations/src/archive-team-warrior/archive-team-warrior-integration.ts index 5f95b6d34..5619208ad 100644 --- a/packages/integrations/src/archive-team-warrior/archive-team-warrior-integration.ts +++ b/packages/integrations/src/archive-team-warrior/archive-team-warrior-integration.ts @@ -6,6 +6,7 @@ import { z } from "zod/v4"; import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; +import type { IntegrationHttpAuthentication } from "../http-auth"; import type { IntegrationTestingInput } from "../base/integration"; import { Integration } from "../base/integration"; import { TestConnectionError } from "../base/test-connection/test-connection-error"; @@ -36,6 +37,10 @@ interface WarriorSnapshotState { } export class ArchiveTeamWarriorIntegration extends Integration { + public async getHttpAuthenticationAsync(): Promise { + return { headers: this.createAuthHeaders() }; + } + protected async testingAsync(input: IntegrationTestingInput): Promise { const response = await input.fetchAsync(this.url("/index.html"), { headers: this.createAuthHeaders(), diff --git a/packages/integrations/src/audiobookshelf/audiobookshelf-integration.ts b/packages/integrations/src/audiobookshelf/audiobookshelf-integration.ts index 2dee921d0..84bf80e00 100644 --- a/packages/integrations/src/audiobookshelf/audiobookshelf-integration.ts +++ b/packages/integrations/src/audiobookshelf/audiobookshelf-integration.ts @@ -1,6 +1,7 @@ import { ResponseError } from "@homarr/common/server"; import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; +import type { IntegrationHttpAuthentication } from "../http-auth"; import type { IntegrationTestingInput } from "../base/integration"; import { Integration } from "../base/integration"; import { TestConnectionError } from "../base/test-connection/test-connection-error"; @@ -26,6 +27,10 @@ const mediaTypeCountField = { >; export class AudiobookshelfIntegration extends Integration { + public async getHttpAuthenticationAsync(): Promise { + return { headers: this.getAuthHeaders(), redactValues: [] }; + } + protected async testingAsync(input: IntegrationTestingInput): Promise { const healthResponse = await input.fetchAsync(this.url("/healthcheck")); diff --git a/packages/integrations/src/base/creator.ts b/packages/integrations/src/base/creator.ts index 95b6310d9..46ac23382 100644 --- a/packages/integrations/src/base/creator.ts +++ b/packages/integrations/src/base/creator.ts @@ -1,296 +1,120 @@ +import { isHttpIntegrationKind } from "@homarr/definitions"; import type { IntegrationKind } from "@homarr/definitions"; import { createLogger } from "@homarr/core/infrastructure/logs"; import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; -import { - withHttpAuthentication, - apiKeyAuth, - bearerAuth, - basicAuth, - headerAuth, - queryAuth, - noAuth, - optionalAuth, - alternativeAuth, - rpcAuth, - feedAuth, - secret, - clientAuthentication, -} from "../http-auth"; -import type { IntegrationHttpAuthentication } from "../http-auth"; +import { clientAuthentication } from "../http-auth"; import type { Integration, IntegrationInput } from "./integration"; const logger = createLogger({ module: "integrationFactory" }); // Keep each import path explicit so Node and Turbopack can discover every lazy integration chunk. const integrationCreators = { - anchor: withHttpAuthentication( - async (input: IntegrationInput) => new (await import("../anchor/anchor-integration")).AnchorIntegration(input), - bearerAuth, - ), - piHole: withHttpAuthentication( - async (input: IntegrationInput) => - (await import("../pi-hole/pi-hole-integration-factory")).createPiHoleIntegrationAsync(input), - clientAuthentication, - ), - adGuardHome: withHttpAuthentication( - async (input: IntegrationInput) => - new (await import("../adguard-home/adguard-home-integration")).AdGuardHomeIntegration(input), - basicAuth(), - ), - technitiumDns: withHttpAuthentication( - async (input: IntegrationInput) => - (await import("../technitium/technitium-integration-factory")).createTechnitiumDnsIntegrationAsync(input), - clientAuthentication, - ), - homeAssistant: withHttpAuthentication( - async (input: IntegrationInput) => - new (await import("../homeassistant/homeassistant-integration")).HomeAssistantIntegration(input), - bearerAuth, - ), - jellyfin: withHttpAuthentication( - async (input: IntegrationInput) => - new (await import("../jellyfin/jellyfin-integration")).JellyfinIntegration(input), - clientAuthentication, - ), - plex: withHttpAuthentication( - async (input: IntegrationInput) => new (await import("../plex/plex-integration")).PlexIntegration(input), - headerAuth("X-Plex-Token"), - ), - sonarr: withHttpAuthentication( - async (input: IntegrationInput) => - new (await import("../media-organizer/sonarr/sonarr-integration")).SonarrIntegration(input), - apiKeyAuth, - ), - radarr: withHttpAuthentication( - async (input: IntegrationInput) => - new (await import("../media-organizer/radarr/radarr-integration")).RadarrIntegration(input), - apiKeyAuth, - ), - sabNzbd: withHttpAuthentication( - async (input: IntegrationInput) => - new (await import("../download-client/sabnzbd/sabnzbd-integration")).SabnzbdIntegration(input), - queryAuth("apikey"), - ), - nzbGet: withHttpAuthentication( - async (input: IntegrationInput) => - new (await import("../download-client/nzbget/nzbget-integration")).NzbGetIntegration(input), - basicAuth(), - ), - qBittorrent: withHttpAuthentication( - async (input: IntegrationInput) => - new (await import("../download-client/qbittorrent/qbittorrent-integration")).QBitTorrentIntegration(input), - clientAuthentication, - ), - deluge: withHttpAuthentication( - async (input: IntegrationInput) => - new (await import("../download-client/deluge/deluge-integration")).DelugeIntegration(input), - clientAuthentication, - ), - transmission: withHttpAuthentication( - async (input: IntegrationInput) => - new (await import("../download-client/transmission/transmission-integration")).TransmissionIntegration(input), - clientAuthentication, - ), - slskd: withHttpAuthentication( - async (input: IntegrationInput) => - new (await import("../download-client/slskd/slskd-integration")).SlskdIntegration(input), - apiKeyAuth, - ), - aria2: withHttpAuthentication( - async (input: IntegrationInput) => - new (await import("../download-client/aria2/aria2-integration")).Aria2Integration(input), - optionalAuth(rpcAuth), - ), - jellyseerr: withHttpAuthentication( - async (input: IntegrationInput) => - new (await import("../jellyseerr/jellyseerr-integration")).JellyseerrIntegration(input), - apiKeyAuth, - ), - seerr: withHttpAuthentication( - async (input: IntegrationInput) => new (await import("../seerr/seerr-integration")).SeerrIntegration(input), - apiKeyAuth, - ), - overseerr: withHttpAuthentication( - async (input: IntegrationInput) => - new (await import("../overseerr/overseerr-integration")).OverseerrIntegration(input), - apiKeyAuth, - ), - prowlarr: withHttpAuthentication( - async (input: IntegrationInput) => - new (await import("../prowlarr/prowlarr-integration")).ProwlarrIntegration(input), - apiKeyAuth, - ), - openmediavault: withHttpAuthentication( - async (input: IntegrationInput) => - new (await import("../openmediavault/openmediavault-integration")).OpenMediaVaultIntegration(input), - clientAuthentication, - ), - lidarr: withHttpAuthentication( - async (input: IntegrationInput) => - new (await import("../media-organizer/lidarr/lidarr-integration")).LidarrIntegration(input), - apiKeyAuth, - ), - readarr: withHttpAuthentication( - async (input: IntegrationInput) => - new (await import("../media-organizer/readarr/readarr-integration")).ReadarrIntegration(input), - apiKeyAuth, - ), - dashDot: withHttpAuthentication( - async (input: IntegrationInput) => new (await import("../dashdot/dashdot-integration")).DashDotIntegration(input), - noAuth, - ), - tdarr: withHttpAuthentication( - async (input: IntegrationInput) => - new (await import("../media-transcoding/tdarr-integration")).TdarrIntegration(input), - optionalAuth(apiKeyAuth), - ), - proxmox: withHttpAuthentication( - async (input: IntegrationInput) => new (await import("../proxmox/proxmox-integration")).ProxmoxIntegration(input), - (input) => ({ - headers: { - Authorization: `PVEAPIToken=${secret(input, "username")}@${secret(input, "realm")}!${secret(input, "tokenId")}=${secret(input, "apiKey")}`, - }, - }), - ), - emby: withHttpAuthentication( - async (input: IntegrationInput) => new (await import("../emby/emby-integration")).EmbyIntegration(input), - headerAuth("X-Emby-Token"), - ), - nextcloud: withHttpAuthentication( - async (input: IntegrationInput) => - new (await import("../nextcloud/nextcloud.integration")).NextcloudIntegration(input), - basicAuth("username", "password", { "OCS-APIRequest": "true" }), - ), - unifiController: withHttpAuthentication( - async (input: IntegrationInput) => - new (await import("../unifi-controller/unifi-controller-integration")).UnifiControllerIntegration(input), - clientAuthentication, - ), - opnsense: withHttpAuthentication( - async (input: IntegrationInput) => - new (await import("../opnsense/opnsense-integration")).OPNsenseIntegration(input), - basicAuth("opnsenseApiKey", "opnsenseApiSecret"), - ), - ical: withHttpAuthentication( - async (input: IntegrationInput) => new (await import("../ical/ical-integration")).ICalIntegration(input), - feedAuth, - ), - ntfy: withHttpAuthentication( - async (input: IntegrationInput) => new (await import("../ntfy/ntfy-integration")).NTFYIntegration(input), - optionalAuth(bearerAuth), - ), - gotify: withHttpAuthentication( - async (input: IntegrationInput) => new (await import("../gotify/gotify-integration")).GotifyIntegration(input), - basicAuth(), - ), + anchor: async (input: IntegrationInput) => + new (await import("../anchor/anchor-integration")).AnchorIntegration(input), + piHole: async (input: IntegrationInput) => + (await import("../pi-hole/pi-hole-integration-factory")).createPiHoleIntegrationAsync(input), + adGuardHome: async (input: IntegrationInput) => + new (await import("../adguard-home/adguard-home-integration")).AdGuardHomeIntegration(input), + technitiumDns: async (input: IntegrationInput) => + (await import("../technitium/technitium-integration-factory")).createTechnitiumDnsIntegrationAsync(input), + homeAssistant: async (input: IntegrationInput) => + new (await import("../homeassistant/homeassistant-integration")).HomeAssistantIntegration(input), + jellyfin: async (input: IntegrationInput) => + new (await import("../jellyfin/jellyfin-integration")).JellyfinIntegration(input), + plex: async (input: IntegrationInput) => new (await import("../plex/plex-integration")).PlexIntegration(input), + sonarr: async (input: IntegrationInput) => + new (await import("../media-organizer/sonarr/sonarr-integration")).SonarrIntegration(input), + radarr: async (input: IntegrationInput) => + new (await import("../media-organizer/radarr/radarr-integration")).RadarrIntegration(input), + sabNzbd: async (input: IntegrationInput) => + new (await import("../download-client/sabnzbd/sabnzbd-integration")).SabnzbdIntegration(input), + nzbGet: async (input: IntegrationInput) => + new (await import("../download-client/nzbget/nzbget-integration")).NzbGetIntegration(input), + qBittorrent: async (input: IntegrationInput) => + new (await import("../download-client/qbittorrent/qbittorrent-integration")).QBitTorrentIntegration(input), + deluge: async (input: IntegrationInput) => + new (await import("../download-client/deluge/deluge-integration")).DelugeIntegration(input), + transmission: async (input: IntegrationInput) => + new (await import("../download-client/transmission/transmission-integration")).TransmissionIntegration(input), + slskd: async (input: IntegrationInput) => + new (await import("../download-client/slskd/slskd-integration")).SlskdIntegration(input), + aria2: async (input: IntegrationInput) => + new (await import("../download-client/aria2/aria2-integration")).Aria2Integration(input), + jellyseerr: async (input: IntegrationInput) => + new (await import("../jellyseerr/jellyseerr-integration")).JellyseerrIntegration(input), + seerr: async (input: IntegrationInput) => new (await import("../seerr/seerr-integration")).SeerrIntegration(input), + overseerr: async (input: IntegrationInput) => + new (await import("../overseerr/overseerr-integration")).OverseerrIntegration(input), + prowlarr: async (input: IntegrationInput) => + new (await import("../prowlarr/prowlarr-integration")).ProwlarrIntegration(input), + openmediavault: async (input: IntegrationInput) => + new (await import("../openmediavault/openmediavault-integration")).OpenMediaVaultIntegration(input), + lidarr: async (input: IntegrationInput) => + new (await import("../media-organizer/lidarr/lidarr-integration")).LidarrIntegration(input), + readarr: async (input: IntegrationInput) => + new (await import("../media-organizer/readarr/readarr-integration")).ReadarrIntegration(input), + dashDot: async (input: IntegrationInput) => + new (await import("../dashdot/dashdot-integration")).DashDotIntegration(input), + tdarr: async (input: IntegrationInput) => + new (await import("../media-transcoding/tdarr-integration")).TdarrIntegration(input), + proxmox: async (input: IntegrationInput) => + new (await import("../proxmox/proxmox-integration")).ProxmoxIntegration(input), + emby: async (input: IntegrationInput) => new (await import("../emby/emby-integration")).EmbyIntegration(input), + nextcloud: async (input: IntegrationInput) => + new (await import("../nextcloud/nextcloud.integration")).NextcloudIntegration(input), + unifiController: async (input: IntegrationInput) => + new (await import("../unifi-controller/unifi-controller-integration")).UnifiControllerIntegration(input), + opnsense: async (input: IntegrationInput) => + new (await import("../opnsense/opnsense-integration")).OPNsenseIntegration(input), + ical: async (input: IntegrationInput) => new (await import("../ical/ical-integration")).ICalIntegration(input), + ntfy: async (input: IntegrationInput) => new (await import("../ntfy/ntfy-integration")).NTFYIntegration(input), + gotify: async (input: IntegrationInput) => + new (await import("../gotify/gotify-integration")).GotifyIntegration(input), mock: async (input: IntegrationInput) => new (await import("../mock/mock-integration")).MockIntegration(input), truenas: async (input: IntegrationInput) => new (await import("../truenas/truenas-integration")).TrueNasIntegration(input), - synology: withHttpAuthentication( - async (input: IntegrationInput) => - new (await import("../synology/synology-integration")).SynologyIntegration(input), - clientAuthentication, - ), - unraid: withHttpAuthentication( - async (input: IntegrationInput) => new (await import("../unraid/unraid-integration")).UnraidIntegration(input), - apiKeyAuth, - ), - coolify: withHttpAuthentication( - async (input: IntegrationInput) => new (await import("../coolify/coolify-integration")).CoolifyIntegration(input), - bearerAuth, - ), - tracearr: withHttpAuthentication( - async (input: IntegrationInput) => - new (await import("../tracearr/tracearr-integration")).TracearrIntegration(input), - bearerAuth, - ), - glances: withHttpAuthentication( - async (input: IntegrationInput) => new (await import("../glances/glances-integration")).GlancesIntegration(input), - noAuth, - ), - immich: withHttpAuthentication( - async (input: IntegrationInput) => new (await import("../immich/immich-integration")).ImmichIntegration(input), - apiKeyAuth, - ), - paperlessNgx: withHttpAuthentication( - async (input: IntegrationInput) => - new (await import("../paperless-ngx/paperless-ngx-integration")).PaperlessNgxIntegration(input), - headerAuth("Authorization", "Token "), - ), - patchmon: withHttpAuthentication( - async (input: IntegrationInput) => - new (await import("../patchmon/patchmon-integration")).PatchMonIntegration(input), - basicAuth("patchmonApiKey", "patchmonApiSecret"), - ), - speedtestTracker: withHttpAuthentication( - async (input: IntegrationInput) => - new (await import("../speedtest-tracker/speedtest-tracker-integration")).SpeedtestTrackerIntegration(input), - bearerAuth, - ), - audiobookshelf: withHttpAuthentication( - async (input: IntegrationInput) => - new (await import("../audiobookshelf/audiobookshelf-integration")).AudiobookshelfIntegration(input), - bearerAuth, - ), - navidrome: withHttpAuthentication( - async (input: IntegrationInput) => - new (await import("../navidrome/navidrome-integration")).NavidromeIntegration(input), - clientAuthentication, - ), - umami: withHttpAuthentication( - async (input: IntegrationInput) => new (await import("../umami/umami-integration")).UmamiIntegration(input), - clientAuthentication, - ), - gluetun: withHttpAuthentication( - async (input: IntegrationInput) => new (await import("../gluetun/gluetun-integration")).GluetunIntegration(input), - alternativeAuth(apiKeyAuth), - ), - archiveTeamWarrior: withHttpAuthentication( - async (input: IntegrationInput) => - new (await import("../archive-team-warrior/archive-team-warrior-integration")).ArchiveTeamWarriorIntegration( - input, - ), - optionalAuth(basicAuth(), ["username", "password"]), - ), - uptimeKuma: withHttpAuthentication( - async (input: IntegrationInput) => - new (await import("../uptime-kuma/uptime-kuma-integration")).UptimeKumaIntegration(input), - noAuth, - ), - peaNut: withHttpAuthentication( - async (input: IntegrationInput) => new (await import("../peanut/peanut-integration")).PeaNutIntegration(input), - optionalAuth(basicAuth(), ["username", "password"]), - ), - beszel: withHttpAuthentication( - async (input: IntegrationInput) => new (await import("../beszel/beszel-integration")).BeszelIntegration(input), - clientAuthentication, - ), - bazarr: withHttpAuthentication( - async (input: IntegrationInput) => new (await import("../bazarr/bazarr-integration")).BazarrIntegration(input), - apiKeyAuth, - ), - traefik: withHttpAuthentication( - async (input: IntegrationInput) => new (await import("../traefik/traefik-integration")).TraefikIntegration(input), - clientAuthentication, - ), - wud: withHttpAuthentication( - async (input: IntegrationInput) => new (await import("../wud/wud-integration")).WudIntegration(input), - optionalAuth(basicAuth(), ["username", "password"]), - ), - llamacpp: withHttpAuthentication( - async (input: IntegrationInput) => - new (await import("../llama-cpp/llamacpp-integration")).LlamacppIntegration(input), - noAuth, - ), -} satisfies { - [Kind in IntegrationKind]: ((input: IntegrationInput) => Promise) & - (Kind extends "truenas" | "mock" - ? unknown - : { - authenticate(input: IntegrationInput): IntegrationHttpAuthentication | Promise; - }); -}; + synology: async (input: IntegrationInput) => + new (await import("../synology/synology-integration")).SynologyIntegration(input), + unraid: async (input: IntegrationInput) => + new (await import("../unraid/unraid-integration")).UnraidIntegration(input), + coolify: async (input: IntegrationInput) => + new (await import("../coolify/coolify-integration")).CoolifyIntegration(input), + tracearr: async (input: IntegrationInput) => + new (await import("../tracearr/tracearr-integration")).TracearrIntegration(input), + glances: async (input: IntegrationInput) => + new (await import("../glances/glances-integration")).GlancesIntegration(input), + immich: async (input: IntegrationInput) => + new (await import("../immich/immich-integration")).ImmichIntegration(input), + paperlessNgx: async (input: IntegrationInput) => + new (await import("../paperless-ngx/paperless-ngx-integration")).PaperlessNgxIntegration(input), + patchmon: async (input: IntegrationInput) => + new (await import("../patchmon/patchmon-integration")).PatchMonIntegration(input), + speedtestTracker: async (input: IntegrationInput) => + new (await import("../speedtest-tracker/speedtest-tracker-integration")).SpeedtestTrackerIntegration(input), + audiobookshelf: async (input: IntegrationInput) => + new (await import("../audiobookshelf/audiobookshelf-integration")).AudiobookshelfIntegration(input), + navidrome: async (input: IntegrationInput) => + new (await import("../navidrome/navidrome-integration")).NavidromeIntegration(input), + umami: async (input: IntegrationInput) => new (await import("../umami/umami-integration")).UmamiIntegration(input), + gluetun: async (input: IntegrationInput) => + new (await import("../gluetun/gluetun-integration")).GluetunIntegration(input), + archiveTeamWarrior: async (input: IntegrationInput) => + new (await import("../archive-team-warrior/archive-team-warrior-integration")).ArchiveTeamWarriorIntegration(input), + uptimeKuma: async (input: IntegrationInput) => + new (await import("../uptime-kuma/uptime-kuma-integration")).UptimeKumaIntegration(input), + peaNut: async (input: IntegrationInput) => + new (await import("../peanut/peanut-integration")).PeaNutIntegration(input), + beszel: async (input: IntegrationInput) => + new (await import("../beszel/beszel-integration")).BeszelIntegration(input), + bazarr: async (input: IntegrationInput) => + new (await import("../bazarr/bazarr-integration")).BazarrIntegration(input), + traefik: async (input: IntegrationInput) => + new (await import("../traefik/traefik-integration")).TraefikIntegration(input), + wud: async (input: IntegrationInput) => new (await import("../wud/wud-integration")).WudIntegration(input), + llamacpp: async (input: IntegrationInput) => + new (await import("../llama-cpp/llamacpp-integration")).LlamacppIntegration(input), +} satisfies Record Promise>; type IntegrationCreators = typeof integrationCreators; type IntegrationInstanceOfKind = Awaited< @@ -341,10 +165,7 @@ export async function createIntegrationAsync( } } -export async function getIntegrationHttpAuthenticationAsync( - integration: IntegrationInput & { kind: IntegrationKind }, -): Promise { - const creator = integrationCreators[integration.kind]; - if (!("authenticate" in creator)) throw new Error("This integration does not use HTTP"); - return creator.authenticate(integration); +export async function getIntegrationHttpAuthenticationAsync(integration: IntegrationInput & { kind: IntegrationKind }) { + if (!isHttpIntegrationKind(integration.kind)) throw new Error("This integration does not use HTTP"); + return clientAuthentication(() => createIntegrationAsync(integration)); } diff --git a/packages/integrations/src/base/integration.ts b/packages/integrations/src/base/integration.ts index 6563609f2..dd0d419e5 100644 --- a/packages/integrations/src/base/integration.ts +++ b/packages/integrations/src/base/integration.ts @@ -7,6 +7,7 @@ import { removeTrailingSlash } from "@homarr/common"; import { createAxiosCertificateInstanceAsync, createCertificateAgentAsync } from "@homarr/core/infrastructure/http"; import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions"; +import type { IntegrationHttpAuthentication } from "../http-auth"; import { HandleIntegrationErrors } from "./errors/decorator"; import { TestConnectionError } from "./test-connection/test-connection-error"; import type { TestingResult } from "./test-connection/test-connection-service"; @@ -36,6 +37,8 @@ export interface IntegrationTestingInput { export abstract class Integration { constructor(protected integration: IntegrationInput) {} + public abstract getHttpAuthenticationAsync(refresh?: boolean): Promise; + public get publicIntegration() { return { id: this.integration.id, diff --git a/packages/integrations/src/bazarr/bazarr-integration.ts b/packages/integrations/src/bazarr/bazarr-integration.ts index 8f224d6ea..4d6eba5b2 100644 --- a/packages/integrations/src/bazarr/bazarr-integration.ts +++ b/packages/integrations/src/bazarr/bazarr-integration.ts @@ -1,5 +1,6 @@ import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; +import type { IntegrationHttpAuthentication } from "../http-auth"; import type { IntegrationTestingInput } from "../base/integration"; import { Integration } from "../base/integration"; import { TestConnectionError } from "../base/test-connection/test-connection-error"; @@ -8,6 +9,10 @@ import type { BazarrBadges } from "./bazarr-types"; import { bazarrBadgesSchema, bazarrSystemStatusSchema } from "./bazarr-types"; export class BazarrIntegration extends Integration { + public async getHttpAuthenticationAsync(): Promise { + return { headers: this.getAuthHeaders(), redactValues: [] }; + } + protected async testingAsync(input: IntegrationTestingInput): Promise { const response = await input.fetchAsync(this.url("/api/system/status"), { headers: this.getAuthHeaders(), diff --git a/packages/integrations/src/coolify/coolify-integration.ts b/packages/integrations/src/coolify/coolify-integration.ts index fa7e50667..1b3378cb4 100644 --- a/packages/integrations/src/coolify/coolify-integration.ts +++ b/packages/integrations/src/coolify/coolify-integration.ts @@ -1,6 +1,7 @@ import { ResponseError } from "@homarr/common/server"; import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; +import type { IntegrationHttpAuthentication } from "../http-auth"; import type { IntegrationTestingInput } from "../base/integration"; import { Integration } from "../base/integration"; import type { TestingResult } from "../base/test-connection/test-connection-service"; @@ -32,6 +33,10 @@ import { } from "./coolify-types"; export class CoolifyIntegration extends Integration { + public async getHttpAuthenticationAsync(): Promise { + return { headers: this.getAuthHeaders() }; + } + protected async testingAsync(input: IntegrationTestingInput): Promise { const healthUrl = this.url("/api/health"); const healthResponse = await input.fetchAsync(healthUrl, {}); diff --git a/packages/integrations/src/dashdot/dashdot-integration.ts b/packages/integrations/src/dashdot/dashdot-integration.ts index 4ad19681e..fc086b653 100644 --- a/packages/integrations/src/dashdot/dashdot-integration.ts +++ b/packages/integrations/src/dashdot/dashdot-integration.ts @@ -5,6 +5,7 @@ import { z } from "zod/v4"; import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; +import type { IntegrationHttpAuthentication } from "../http-auth"; import { createChannelEventHistoryOld } from "../../../redis/src/lib/channel"; import type { IntegrationTestingInput } from "../base/integration"; import { Integration } from "../base/integration"; @@ -14,6 +15,10 @@ import type { ISystemHealthMonitoringIntegration } from "../interfaces/health-mo import type { SystemHealthMonitoring } from "../interfaces/health-monitoring/health-monitoring-types"; export class DashDotIntegration extends Integration implements ISystemHealthMonitoringIntegration { + public async getHttpAuthenticationAsync(): Promise { + return {}; + } + protected async testingAsync(input: IntegrationTestingInput): Promise { const response = await input.fetchAsync(this.url("/info")); if (!response.ok) return TestConnectionError.StatusResult(response); diff --git a/packages/integrations/src/download-client/aria2/aria2-integration.ts b/packages/integrations/src/download-client/aria2/aria2-integration.ts index 447e33742..a09f9323e 100644 --- a/packages/integrations/src/download-client/aria2/aria2-integration.ts +++ b/packages/integrations/src/download-client/aria2/aria2-integration.ts @@ -4,6 +4,7 @@ import type { fetch as undiciFetch } from "undici"; import { ResponseError } from "@homarr/common/server"; import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; +import type { IntegrationHttpAuthentication } from "../../http-auth"; import { Integration } from "../../base/integration"; import type { IntegrationTestingInput } from "../../base/integration"; import type { TestingResult } from "../../base/test-connection/test-connection-service"; @@ -13,6 +14,27 @@ import type { DownloadClientItem } from "../../interfaces/downloads/download-cli import type { Aria2Download, Aria2GetClient } from "./aria2-types"; export class Aria2Integration extends Integration implements IDownloadClientIntegration { + public async getHttpAuthenticationAsync(): Promise { + if (!this.hasSecretValue("apiKey") || !this.getSecretValue("apiKey")) return {}; + return { + transformBody: (body) => { + const authenticate = (value: unknown): unknown => { + if (Array.isArray(value)) return value.map(authenticate); + if (!value || typeof value !== "object") throw new Error("Expected a JSON-RPC request body"); + const request = value as { method?: string; methodName?: string; params?: unknown[] }; + const method = request.method ?? request.methodName; + const params = request.params ?? []; + if (!Array.isArray(params)) throw new Error("JSON-RPC params must be an array"); + if (method === "system.multicall") return { ...request, params: [authenticate(params[0])] }; + if (method === "system.listMethods" || method === "system.listNotifications") return request; + if (typeof params[0] === "string" && params[0].startsWith("token:")) params.shift(); + return { ...request, params: [`token:${this.getSecretValue("apiKey")}`, ...params] }; + }; + return JSON.stringify(authenticate(JSON.parse(body ?? "null"))); + }, + }; + } + public async getClientJobsAndStatusAsync(input: { limit: number }): Promise { const client = this.getClient(); const keys: (keyof Aria2Download)[] = [ diff --git a/packages/integrations/src/download-client/nzbget/nzbget-integration.ts b/packages/integrations/src/download-client/nzbget/nzbget-integration.ts index 889a29754..d61c5b641 100644 --- a/packages/integrations/src/download-client/nzbget/nzbget-integration.ts +++ b/packages/integrations/src/download-client/nzbget/nzbget-integration.ts @@ -4,6 +4,8 @@ import type { fetch as undiciFetch } from "undici"; import { ResponseError } from "@homarr/common/server"; import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; +import type { IntegrationHttpAuthentication } from "../../http-auth"; +import { basicAuth } from "../../http-auth"; import { Integration } from "../../base/integration"; import type { IntegrationTestingInput } from "../../base/integration"; import type { TestingResult } from "../../base/test-connection/test-connection-service"; @@ -14,6 +16,10 @@ import type { DownloadClientStatus } from "../../interfaces/downloads/download-c import type { NzbGetClient } from "./nzbget-types"; export class NzbGetIntegration extends Integration implements IDownloadClientIntegration { + public async getHttpAuthenticationAsync(): Promise { + return basicAuth()(this.integration); + } + protected async testingAsync(input: IntegrationTestingInput): Promise { await this.nzbGetApiCallWithCustomFetchAsync(input.fetchAsync, "version"); return { diff --git a/packages/integrations/src/download-client/sabnzbd/sabnzbd-integration.ts b/packages/integrations/src/download-client/sabnzbd/sabnzbd-integration.ts index e8f4d58a5..42b6fe3f0 100644 --- a/packages/integrations/src/download-client/sabnzbd/sabnzbd-integration.ts +++ b/packages/integrations/src/download-client/sabnzbd/sabnzbd-integration.ts @@ -5,6 +5,8 @@ import type { fetch as undiciFetch } from "undici"; import { ResponseError } from "@homarr/common/server"; import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; +import type { IntegrationHttpAuthentication } from "../../http-auth"; +import { queryAuth } from "../../http-auth"; import { Integration } from "../../base/integration"; import type { IntegrationTestingInput } from "../../base/integration"; import type { TestingResult } from "../../base/test-connection/test-connection-service"; @@ -17,6 +19,10 @@ import { historySchema, queueSchema } from "./sabnzbd-schema"; dayjs.extend(duration); export class SabnzbdIntegration extends Integration implements IDownloadClientIntegration { + public async getHttpAuthenticationAsync(): Promise { + return queryAuth("apikey")(this.integration); + } + protected async testingAsync(input: IntegrationTestingInput): Promise { //This is the one call that uses the least amount of data while requiring the api key await this.sabNzbApiCallWithCustomFetchAsync(input.fetchAsync, "translate", { value: "ping" }); diff --git a/packages/integrations/src/download-client/slskd/slskd-integration.ts b/packages/integrations/src/download-client/slskd/slskd-integration.ts index 91c7b55ff..3873e56ed 100644 --- a/packages/integrations/src/download-client/slskd/slskd-integration.ts +++ b/packages/integrations/src/download-client/slskd/slskd-integration.ts @@ -6,6 +6,8 @@ import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/h import { createLogger } from "@homarr/core/infrastructure/logs"; import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; +import type { IntegrationHttpAuthentication } from "../../http-auth"; +import { apiKeyAuth } from "../../http-auth"; import type { IntegrationTestingInput } from "../../base/integration"; import { Integration } from "../../base/integration"; import { TestConnectionError } from "../../base/test-connection/test-connection-error"; @@ -49,6 +51,10 @@ const logger = createLogger({ module: "slskd-integration" }); * Slskd integration cannot pause or stop downloads, it can only list downloads. */ export class SlskdIntegration extends Integration implements IDownloadClientIntegration { + public async getHttpAuthenticationAsync(): Promise { + return apiKeyAuth(this.integration); + } + // eslint-disable-next-line no-restricted-syntax public pauseQueueAsync(): Promise { return Promise.resolve(); @@ -76,9 +82,7 @@ export class SlskdIntegration extends Integration implements IDownloadClientInte protected async testingAsync(input: IntegrationTestingInput): Promise { const response = await input.fetchAsync(this.url("/api/v0/server"), { - headers: { - "X-API-Key": this.getSecretValue("apiKey"), - }, + headers: (await this.getHttpAuthenticationAsync()).headers, }); if (!response.ok) { @@ -148,9 +152,7 @@ export class SlskdIntegration extends Integration implements IDownloadClientInte ): Promise { const response = await fetchWithTrustedCertificatesAsync(this.url(path), { ...options, - headers: { - "X-API-Key": this.getSecretValue("apiKey"), - }, + headers: (await this.getHttpAuthenticationAsync()).headers, }); if (!response.ok) { diff --git a/packages/integrations/src/emby/emby-integration.ts b/packages/integrations/src/emby/emby-integration.ts index f9ccde099..e05ac088b 100644 --- a/packages/integrations/src/emby/emby-integration.ts +++ b/packages/integrations/src/emby/emby-integration.ts @@ -4,6 +4,8 @@ import { z } from "zod/v4"; import { ResponseError } from "@homarr/common/server"; import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; +import type { IntegrationHttpAuthentication } from "../http-auth"; +import { headerAuth } from "../http-auth"; import type { IntegrationTestingInput } from "../base/integration"; import { Integration } from "../base/integration"; import { TestConnectionError } from "../base/test-connection/test-connection-error"; @@ -90,6 +92,10 @@ const userSchema = z.object({ }); export class EmbyIntegration extends Integration implements IMediaServerIntegration, IMediaReleasesIntegration { + public async getHttpAuthenticationAsync(): Promise { + return headerAuth("X-Emby-Token")(this.integration); + } + private static readonly apiKeyHeader = "X-Emby-Token"; private static readonly deviceId = "homarr-emby-integration"; private static readonly authorizationHeaderValue = `Emby Client="Dashboard", Device="Homarr", DeviceId="${EmbyIntegration.deviceId}", Version="0.0.1"`; diff --git a/packages/integrations/src/glances/glances-integration.ts b/packages/integrations/src/glances/glances-integration.ts index 5915b44b2..0ede7d4e1 100644 --- a/packages/integrations/src/glances/glances-integration.ts +++ b/packages/integrations/src/glances/glances-integration.ts @@ -4,6 +4,7 @@ import { z } from "zod/v4"; import { ResponseError } from "@homarr/common/server"; import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; +import type { IntegrationHttpAuthentication } from "../http-auth"; import type { IntegrationInput, IntegrationTestingInput } from "../base/integration"; import { Integration } from "../base/integration"; import type { SessionStore } from "../base/session-store"; @@ -14,6 +15,10 @@ import type { ISystemHealthMonitoringIntegration } from "../interfaces/health-mo import type { SystemHealthMonitoring } from "../types"; export class GlancesIntegration extends Integration implements ISystemHealthMonitoringIntegration { + public async getHttpAuthenticationAsync(): Promise { + return {}; + } + private readonly sessionStore: SessionStore<{ version: string }>; constructor(integration: IntegrationInput) { diff --git a/packages/integrations/src/gluetun/gluetun-integration.ts b/packages/integrations/src/gluetun/gluetun-integration.ts index a150f0f18..ee072b1a8 100644 --- a/packages/integrations/src/gluetun/gluetun-integration.ts +++ b/packages/integrations/src/gluetun/gluetun-integration.ts @@ -1,6 +1,7 @@ import { ResponseError } from "@homarr/common/server"; import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; +import type { IntegrationHttpAuthentication } from "../http-auth"; import type { IntegrationTestingInput } from "../base/integration"; import { Integration } from "../base/integration"; import { TestConnectionError } from "../base/test-connection/test-connection-error"; @@ -16,6 +17,10 @@ import { import type { GluetunDnsStatus, GluetunPublicIp, GluetunVpnSettings, GluetunVpnStatus } from "./gluetun-types"; export class GluetunIntegration extends Integration implements VpnSummaryIntegration { + public async getHttpAuthenticationAsync(): Promise { + return { headers: this.getAuthHeaders() }; + } + protected async testingAsync(input: IntegrationTestingInput): Promise { const authHeaders = this.getAuthHeaders(); const url = this.url("/v1/vpn/status"); diff --git a/packages/integrations/src/gotify/gotify-integration.ts b/packages/integrations/src/gotify/gotify-integration.ts index 829e7abcd..9ffd49cd5 100644 --- a/packages/integrations/src/gotify/gotify-integration.ts +++ b/packages/integrations/src/gotify/gotify-integration.ts @@ -2,6 +2,7 @@ import { ResponseError } from "@homarr/common/server"; import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; import { ImageProxy } from "@homarr/image-proxy"; +import type { IntegrationHttpAuthentication } from "../http-auth"; import { Integration } from "../base/integration"; import type { IntegrationTestingInput } from "../base/integration"; import type { TestingResult } from "../base/test-connection/test-connection-service"; @@ -10,6 +11,10 @@ import type { INotificationsIntegration } from "../interfaces/notifications/noti import { gotifyApplicationsResponseSchema, gotifyMessagesResponseSchema } from "./gotify-schema"; export class GotifyIntegration extends Integration implements INotificationsIntegration { + public async getHttpAuthenticationAsync(): Promise { + return { headers: this.getHeaders() }; + } + public async testingAsync(input: IntegrationTestingInput): Promise { await input.fetchAsync(this.url("/health"), { headers: this.getHeaders() }); return { success: true }; diff --git a/packages/integrations/src/homeassistant/homeassistant-integration.ts b/packages/integrations/src/homeassistant/homeassistant-integration.ts index 15c4ece7f..fc35e8f52 100644 --- a/packages/integrations/src/homeassistant/homeassistant-integration.ts +++ b/packages/integrations/src/homeassistant/homeassistant-integration.ts @@ -5,6 +5,7 @@ import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/h import { createLogger } from "@homarr/core/infrastructure/logs"; import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; +import type { IntegrationHttpAuthentication } from "../http-auth"; import type { IntegrationTestingInput } from "../base/integration"; import { Integration } from "../base/integration"; import { TestConnectionError } from "../base/test-connection/test-connection-error"; @@ -17,6 +18,10 @@ import { calendarEventSchema, calendarsSchema, entityStateSchema } from "./homea const logger = createLogger({ module: "homeAssistantIntegration" }); export class HomeAssistantIntegration extends Integration implements ISmartHomeIntegration, ICalendarIntegration { + public async getHttpAuthenticationAsync(): Promise { + return { headers: this.getAuthHeaders() }; + } + public async getEntityStateAsync(entityId: string) { try { const response = await this.getAsync(`/api/states/${entityId}`); diff --git a/packages/integrations/src/http-auth.ts b/packages/integrations/src/http-auth.ts index d7d8a6ae4..e9ae62665 100644 --- a/packages/integrations/src/http-auth.ts +++ b/packages/integrations/src/http-auth.ts @@ -15,7 +15,7 @@ export interface IntegrationHttpAuthentication { refreshAsync?(): Promise; } -export function secret(input: IntegrationInput, kind: IntegrationSecretKind) { +function secret(input: IntegrationInput, kind: IntegrationSecretKind) { const value = input.decryptedSecrets.find((entry) => entry.kind === kind)?.value; if (!value) throw new Error(`Integration credential ${kind} is missing`); return value; @@ -41,83 +41,28 @@ export const basicAuth = export const queryAuth = (name: string, kind: IntegrationSecretKind = "apiKey") => (input: IntegrationInput): IntegrationHttpAuthentication => ({ query: { [name]: secret(input, kind) } }); -export const noAuth = (): IntegrationHttpAuthentication => ({}); -export const optionalAuth = - ( - resolve: (input: IntegrationInput) => IntegrationHttpAuthentication, - required: IntegrationSecretKind[] = ["apiKey"], - ) => - (input: IntegrationInput) => { - if ( - !required.every((requiredKind) => - input.decryptedSecrets.some(({ kind, value }) => kind === requiredKind && value), - ) - ) - return noAuth(); - return resolve(input); - }; -export const alternativeAuth = - ( - apiKey: (input: IntegrationInput) => IntegrationHttpAuthentication, - fallback = optionalAuth(basicAuth(), ["username", "password"]), - ) => - (input: IntegrationInput) => { - if (input.decryptedSecrets.some(({ kind, value }) => kind === "apiKey" && value)) return apiKey(input); - return fallback(input); - }; -export const rpcAuth = (input: IntegrationInput): IntegrationHttpAuthentication => ({ - transformBody(body) { - const authenticate = (value: unknown): unknown => { - if (Array.isArray(value)) return value.map(authenticate); - if (!value || typeof value !== "object") throw new Error("Expected a JSON-RPC request body"); - const request = value as { method?: string; methodName?: string; params?: unknown[] }; - const method = request.method ?? request.methodName; - const params = request.params ?? []; - if (!Array.isArray(params)) throw new Error("JSON-RPC params must be an array"); - if (method === "system.multicall") return { ...request, params: [authenticate(params[0])] }; - if (method === "system.listMethods" || method === "system.listNotifications") return request; - if (typeof params[0] === "string" && params[0].startsWith("token:")) params.shift(); - return { ...request, params: [`token:${secret(input, "apiKey")}`, ...params] }; - }; - return JSON.stringify(authenticate(JSON.parse(body ?? "null"))); - }, -}); - -export function withHttpAuthentication( - create: (input: IntegrationInput) => Promise, - authenticate: ( - input: IntegrationInput, - create: () => Promise, - ) => IntegrationHttpAuthentication | Promise, -) { - return Object.assign(create, { - authenticate: (input: IntegrationInput) => withAuthTimeout(() => authenticate(input, () => create(input))), - }); -} -export const clientAuthentication = async < - T extends { getHttpAuthenticationAsync(refresh?: boolean): Promise }, ->( - _input: IntegrationInput, - create: () => Promise, -) => { - const client = await create(); - const resolve = async (refresh = false): Promise => { - const auth = await client.getHttpAuthenticationAsync(refresh); - const values = [...Object.values(auth.headers ?? {}), ...Object.values(auth.query ?? {})]; - for (const [name, value] of Object.entries(auth.headers ?? {})) { - if (name.toLowerCase() === "cookie") { - for (const cookie of value.split(";")) values.push(cookie.slice(cookie.indexOf("=") + 1).trim()); +export const clientAuthentication = async ( + create: () => Promise<{ getHttpAuthenticationAsync(refresh?: boolean): Promise }>, +) => + withAuthTimeout(async () => { + const client = await create(); + const resolve = async (refresh = false): Promise => { + const auth = await client.getHttpAuthenticationAsync(refresh); + const values = [...Object.values(auth.headers ?? {}), ...Object.values(auth.query ?? {})]; + for (const [name, value] of Object.entries(auth.headers ?? {})) { + if (name.toLowerCase() === "cookie") { + for (const cookie of value.split(";")) values.push(cookie.slice(cookie.indexOf("=") + 1).trim()); + } + if (name.toLowerCase() === "authorization") values.push(value.slice(value.indexOf(" ") + 1)); } - if (name.toLowerCase() === "authorization") values.push(value.slice(value.indexOf(" ") + 1)); - } - return { - ...auth, - redactValues: auth.redactValues ?? values, - refreshAsync: () => withAuthTimeout(() => resolve(true)), + return { + ...auth, + redactValues: auth.redactValues ?? values, + refreshAsync: () => withAuthTimeout(() => resolve(true)), + }; }; - }; - return resolve(); -}; + return resolve(); + }); async function withAuthTimeout(resolve: () => T | Promise): Promise { let timer: ReturnType | undefined; @@ -132,17 +77,3 @@ async function withAuthTimeout(resolve: () => T | Promise): Promise { clearTimeout(timer); } } - -export const feedAuth = (input: IntegrationInput): IntegrationHttpAuthentication => { - const baseUrl = secret(input, "url"); - const base = new URL(baseUrl); - return { - baseUrl, - redactValues: [...base.searchParams.values()], - transformUrl(url) { - // The stored feed URL is a complete endpoint, so the root request uses it exactly. - if (url.pathname === `${base.pathname}/`) url.pathname = base.pathname; - for (const [name, value] of base.searchParams) url.searchParams.set(name, value); - }, - }; -}; diff --git a/packages/integrations/src/ical/ical-integration.ts b/packages/integrations/src/ical/ical-integration.ts index 0e725bccb..63fd3c33a 100644 --- a/packages/integrations/src/ical/ical-integration.ts +++ b/packages/integrations/src/ical/ical-integration.ts @@ -2,6 +2,7 @@ import ICAL from "ical.js"; import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; +import type { IntegrationHttpAuthentication } from "../http-auth"; import type { IntegrationTestingInput } from "../base/integration"; import { Integration } from "../base/integration"; import { TestConnectionError } from "../base/test-connection/test-connection-error"; @@ -10,6 +11,20 @@ import type { ICalendarIntegration } from "../interfaces/calendar/calendar-integ import type { CalendarEvent } from "../interfaces/calendar/calendar-types"; export class ICalIntegration extends Integration implements ICalendarIntegration { + public async getHttpAuthenticationAsync(): Promise { + const baseUrl = this.getSecretValue("url"); + const base = new URL(baseUrl); + return { + baseUrl, + redactValues: [...base.searchParams.values()], + transformUrl(url) { + // The stored feed URL is a complete endpoint, so the root request uses it exactly. + if (url.pathname === `${base.pathname}/`) url.pathname = base.pathname; + for (const [name, value] of base.searchParams) url.searchParams.set(name, value); + }, + }; + } + async getCalendarEventsAsync(start: Date, end: Date): Promise { const response = await fetchWithTrustedCertificatesAsync(super.getSecretValue("url")); const result = await response.text(); diff --git a/packages/integrations/src/immich/immich-integration.ts b/packages/integrations/src/immich/immich-integration.ts index 8503b1eac..21ee6d640 100644 --- a/packages/integrations/src/immich/immich-integration.ts +++ b/packages/integrations/src/immich/immich-integration.ts @@ -17,6 +17,8 @@ import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/h import { createLogger } from "@homarr/core/infrastructure/logs"; import { ImageProxy } from "@homarr/image-proxy"; +import type { IntegrationHttpAuthentication } from "../http-auth"; +import { apiKeyAuth } from "../http-auth"; import type { IntegrationInput, IntegrationTestingInput } from "../base/integration"; import { Integration } from "../base/integration"; import type { TestingResult } from "../base/test-connection/test-connection-service"; @@ -48,6 +50,10 @@ const logger = createLogger({ module: "immich-integration" }); const carouselPhotoCount = 50; export class ImmichIntegration extends Integration { + public async getHttpAuthenticationAsync(): Promise { + return apiKeyAuth(this.integration); + } + constructor(integration: IntegrationInput) { super(integration); } diff --git a/packages/integrations/src/jellyseerr/jellyseerr-integration.ts b/packages/integrations/src/jellyseerr/jellyseerr-integration.ts index eadb2b95f..b36e62c00 100644 --- a/packages/integrations/src/jellyseerr/jellyseerr-integration.ts +++ b/packages/integrations/src/jellyseerr/jellyseerr-integration.ts @@ -1,8 +1,14 @@ +import type { IntegrationHttpAuthentication } from "../http-auth"; +import { apiKeyAuth } from "../http-auth"; import type { MediaAvailability } from "../interfaces/media-requests/media-request-types"; import { UpstreamMediaAvailability } from "../interfaces/media-requests/media-request-types"; import { OverseerrIntegration } from "../overseerr/overseerr-integration"; export class JellyseerrIntegration extends OverseerrIntegration { + public async getHttpAuthenticationAsync(): Promise { + return apiKeyAuth(this.integration); + } + protected override mapAvailability(availability: UpstreamMediaAvailability, inProgress: boolean): MediaAvailability { // Availability statuses are not exactly the same between Jellyseerr and Overseerr (Jellyseerr has "blacklisted" additionally (deleted is the same value in overseerr)) if (availability === UpstreamMediaAvailability.JellyseerrBlacklistedOrOverseerrDeleted) return "blacklisted"; diff --git a/packages/integrations/src/llama-cpp/llamacpp-integration.ts b/packages/integrations/src/llama-cpp/llamacpp-integration.ts index 9640c78ed..bc3a243e6 100644 --- a/packages/integrations/src/llama-cpp/llamacpp-integration.ts +++ b/packages/integrations/src/llama-cpp/llamacpp-integration.ts @@ -1,6 +1,7 @@ import { ParseError, ResponseError } from "@homarr/common/server"; import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; +import type { IntegrationHttpAuthentication } from "../http-auth"; import type { IntegrationTestingInput } from "../base/integration"; import { Integration } from "../base/integration"; import { TestConnectionError } from "../base/test-connection/test-connection-error"; @@ -22,6 +23,10 @@ import { const LLMACPP_REQUEST_TIMEOUT_MS = 10_000; export class LlamacppIntegration extends Integration { + public async getHttpAuthenticationAsync(): Promise { + return {}; + } + protected async testingAsync(input: IntegrationTestingInput): Promise { const response = await input.fetchAsync(this.url("/health"), { signal: AbortSignal.timeout(LLMACPP_REQUEST_TIMEOUT_MS), diff --git a/packages/integrations/src/media-organizer/lidarr/lidarr-integration.ts b/packages/integrations/src/media-organizer/lidarr/lidarr-integration.ts index 5b16b3ea8..f70fcd7f8 100644 --- a/packages/integrations/src/media-organizer/lidarr/lidarr-integration.ts +++ b/packages/integrations/src/media-organizer/lidarr/lidarr-integration.ts @@ -3,6 +3,8 @@ import { z } from "zod/v4"; import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; import { createLogger } from "@homarr/core/infrastructure/logs"; +import type { IntegrationHttpAuthentication } from "../../http-auth"; +import { apiKeyAuth } from "../../http-auth"; import { Integration } from "../../base/integration"; import type { IntegrationTestingInput } from "../../base/integration"; import { TestConnectionError } from "../../base/test-connection/test-connection-error"; @@ -14,9 +16,13 @@ import { mediaOrganizerPriorities } from "../media-organizer"; const logger = createLogger({ module: "lidarrIntegration" }); export class LidarrIntegration extends Integration implements ICalendarIntegration { + public async getHttpAuthenticationAsync(): Promise { + return apiKeyAuth(this.integration); + } + protected async testingAsync(input: IntegrationTestingInput): Promise { const response = await input.fetchAsync(this.url("/api"), { - headers: { "X-Api-Key": super.getSecretValue("apiKey") }, + headers: (await this.getHttpAuthenticationAsync()).headers, }); if (!response.ok) return TestConnectionError.StatusResult(response); @@ -39,9 +45,7 @@ export class LidarrIntegration extends Integration implements ICalendarIntegrati }); const response = await fetchWithTrustedCertificatesAsync(url, { - headers: { - "X-Api-Key": super.getSecretValue("apiKey"), - }, + headers: (await this.getHttpAuthenticationAsync()).headers, }); const lidarrCalendarEvents = await z.array(lidarrCalendarEventSchema).parseAsync(await response.json()); diff --git a/packages/integrations/src/media-organizer/radarr/radarr-integration.ts b/packages/integrations/src/media-organizer/radarr/radarr-integration.ts index aa116d1e5..5453cba06 100644 --- a/packages/integrations/src/media-organizer/radarr/radarr-integration.ts +++ b/packages/integrations/src/media-organizer/radarr/radarr-integration.ts @@ -3,6 +3,8 @@ import { z } from "zod/v4"; import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; import { createLogger } from "@homarr/core/infrastructure/logs"; +import type { IntegrationHttpAuthentication } from "../../http-auth"; +import { apiKeyAuth } from "../../http-auth"; import type { IntegrationTestingInput } from "../../base/integration"; import { Integration } from "../../base/integration"; import { TestConnectionError } from "../../base/test-connection/test-connection-error"; @@ -17,6 +19,10 @@ import { mediaOrganizerPriorities } from "../media-organizer"; const logger = createLogger({ module: "radarrIntegration" }); export class RadarrIntegration extends Integration implements ICalendarIntegration, IMediaOrganizerIntegration { + public async getHttpAuthenticationAsync(): Promise { + return apiKeyAuth(this.integration); + } + /** * Gets the events in the Radarr calendar between two dates. * @param start The start date @@ -31,9 +37,7 @@ export class RadarrIntegration extends Integration implements ICalendarIntegrati }); const response = await fetchWithTrustedCertificatesAsync(url, { - headers: { - "X-Api-Key": super.getSecretValue("apiKey"), - }, + headers: (await this.getHttpAuthenticationAsync()).headers, }); const radarrCalendarEvents = await z.array(radarrCalendarEventSchema).parseAsync(await response.json()); @@ -122,7 +126,7 @@ export class RadarrIntegration extends Integration implements ICalendarIntegrati }); const response = await fetchWithTrustedCertificatesAsync(url, { - headers: { "X-Api-Key": super.getSecretValue("apiKey") }, + headers: (await this.getHttpAuthenticationAsync()).headers, }); const data = await z .object({ @@ -154,7 +158,7 @@ export class RadarrIntegration extends Integration implements ICalendarIntegrati }); const response = await fetchWithTrustedCertificatesAsync(url, { - headers: { "X-Api-Key": super.getSecretValue("apiKey") }, + headers: (await this.getHttpAuthenticationAsync()).headers, }); const data = await z .object({ @@ -188,7 +192,7 @@ export class RadarrIntegration extends Integration implements ICalendarIntegrati protected async testingAsync(input: IntegrationTestingInput): Promise { const response = await input.fetchAsync(this.url("/api"), { - headers: { "X-Api-Key": super.getSecretValue("apiKey") }, + headers: (await this.getHttpAuthenticationAsync()).headers, }); if (!response.ok) return TestConnectionError.StatusResult(response); diff --git a/packages/integrations/src/media-organizer/readarr/readarr-integration.ts b/packages/integrations/src/media-organizer/readarr/readarr-integration.ts index aa036626c..508e77792 100644 --- a/packages/integrations/src/media-organizer/readarr/readarr-integration.ts +++ b/packages/integrations/src/media-organizer/readarr/readarr-integration.ts @@ -3,6 +3,8 @@ import { z } from "zod/v4"; import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; import { createLogger } from "@homarr/core/infrastructure/logs"; +import type { IntegrationHttpAuthentication } from "../../http-auth"; +import { apiKeyAuth } from "../../http-auth"; import { Integration } from "../../base/integration"; import type { IntegrationTestingInput } from "../../base/integration"; import { TestConnectionError } from "../../base/test-connection/test-connection-error"; @@ -14,9 +16,13 @@ import { mediaOrganizerPriorities } from "../media-organizer"; const logger = createLogger({ module: "readarrIntegration" }); export class ReadarrIntegration extends Integration implements ICalendarIntegration { + public async getHttpAuthenticationAsync(): Promise { + return apiKeyAuth(this.integration); + } + protected async testingAsync(input: IntegrationTestingInput): Promise { const response = await input.fetchAsync(this.url("/api"), { - headers: { "X-Api-Key": super.getSecretValue("apiKey") }, + headers: (await this.getHttpAuthenticationAsync()).headers, }); if (!response.ok) return TestConnectionError.StatusResult(response); @@ -45,9 +51,7 @@ export class ReadarrIntegration extends Integration implements ICalendarIntegrat }); const response = await fetchWithTrustedCertificatesAsync(url, { - headers: { - "X-Api-Key": super.getSecretValue("apiKey"), - }, + headers: (await this.getHttpAuthenticationAsync()).headers, }); const readarrCalendarEvents = await z.array(readarrCalendarEventSchema).parseAsync(await response.json()); diff --git a/packages/integrations/src/media-organizer/sonarr/sonarr-integration.ts b/packages/integrations/src/media-organizer/sonarr/sonarr-integration.ts index 43810ae19..a429c60bd 100644 --- a/packages/integrations/src/media-organizer/sonarr/sonarr-integration.ts +++ b/packages/integrations/src/media-organizer/sonarr/sonarr-integration.ts @@ -3,6 +3,8 @@ import { z } from "zod/v4"; import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; import { createLogger } from "@homarr/core/infrastructure/logs"; +import type { IntegrationHttpAuthentication } from "../../http-auth"; +import { apiKeyAuth } from "../../http-auth"; import { Integration } from "../../base/integration"; import type { IntegrationTestingInput } from "../../base/integration"; import { TestConnectionError } from "../../base/test-connection/test-connection-error"; @@ -16,6 +18,10 @@ import { mediaOrganizerPriorities } from "../media-organizer"; const logger = createLogger({ module: "sonarrIntegration" }); export class SonarrIntegration extends Integration implements ICalendarIntegration, IMediaOrganizerIntegration { + public async getHttpAuthenticationAsync(): Promise { + return apiKeyAuth(this.integration); + } + /** * Gets the events in the Sonarr calendar between two dates. * @param start The start date @@ -33,9 +39,7 @@ export class SonarrIntegration extends Integration implements ICalendarIntegrati }); const response = await fetchWithTrustedCertificatesAsync(url, { - headers: { - "X-Api-Key": super.getSecretValue("apiKey"), - }, + headers: (await this.getHttpAuthenticationAsync()).headers, }); const sonarrCalendarEvents = await z.array(sonarrCalendarEventSchema).parseAsync(await response.json()); @@ -119,7 +123,7 @@ export class SonarrIntegration extends Integration implements ICalendarIntegrati }); const response = await fetchWithTrustedCertificatesAsync(url, { - headers: { "X-Api-Key": super.getSecretValue("apiKey") }, + headers: (await this.getHttpAuthenticationAsync()).headers, }); const data = await z .object({ @@ -157,7 +161,7 @@ export class SonarrIntegration extends Integration implements ICalendarIntegrati }); const response = await fetchWithTrustedCertificatesAsync(url, { - headers: { "X-Api-Key": super.getSecretValue("apiKey") }, + headers: (await this.getHttpAuthenticationAsync()).headers, }); const data = await z .object({ @@ -193,7 +197,7 @@ export class SonarrIntegration extends Integration implements ICalendarIntegrati protected async testingAsync(input: IntegrationTestingInput): Promise { const response = await input.fetchAsync(this.url("/api"), { - headers: { "X-Api-Key": super.getSecretValue("apiKey") }, + headers: (await this.getHttpAuthenticationAsync()).headers, }); if (!response.ok) return TestConnectionError.StatusResult(response); diff --git a/packages/integrations/src/media-transcoding/tdarr-integration.ts b/packages/integrations/src/media-transcoding/tdarr-integration.ts index 5a16d9ff3..e27dc2a63 100644 --- a/packages/integrations/src/media-transcoding/tdarr-integration.ts +++ b/packages/integrations/src/media-transcoding/tdarr-integration.ts @@ -1,5 +1,7 @@ import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; +import type { IntegrationHttpAuthentication } from "../http-auth"; +import { apiKeyAuth } from "../http-auth"; import type { IntegrationTestingInput } from "../base/integration"; import { Integration } from "../base/integration"; import { TestConnectionError } from "../base/test-connection/test-connection-error"; @@ -9,12 +11,17 @@ import type { TdarrQueue, TdarrStatistics, TdarrWorker } from "../interfaces/med import { getNodesResponseSchema, getStatisticsSchema, getStatusTableSchema } from "./tdarr-validation-schemas"; export class TdarrIntegration extends Integration implements IMediaTranscodingIntegration { + public async getHttpAuthenticationAsync(): Promise { + if (!this.hasSecretValue("apiKey") || !this.getSecretValue("apiKey")) return {}; + return apiKeyAuth(this.integration); + } + protected async testingAsync(input: IntegrationTestingInput): Promise { const response = await input.fetchAsync(this.url("/api/v2/is-server-alive"), { method: "POST", headers: { accept: "application/json", - "X-Api-Key": super.hasSecretValue("apiKey") ? super.getSecretValue("apiKey") : "", + ...(await this.getHttpAuthenticationAsync()).headers, }, }); @@ -30,7 +37,7 @@ export class TdarrIntegration extends Integration implements IMediaTranscodingIn const headerParams = { accept: "application/json", "Content-Type": "application/json", - ...(super.hasSecretValue("apiKey") ? { "X-Api-Key": super.getSecretValue("apiKey") } : {}), + ...(await this.getHttpAuthenticationAsync()).headers, }; const response = await fetchWithTrustedCertificatesAsync(url, { @@ -76,7 +83,7 @@ export class TdarrIntegration extends Integration implements IMediaTranscodingIn const url = this.url("/api/v2/get-nodes"); const headerParams = { "Content-Type": "application/json", - ...(super.hasSecretValue("apiKey") ? { "X-Api-Key": super.getSecretValue("apiKey") } : {}), + ...(await this.getHttpAuthenticationAsync()).headers, }; const response = await fetchWithTrustedCertificatesAsync(url, { method: "GET", @@ -120,7 +127,7 @@ export class TdarrIntegration extends Integration implements IMediaTranscodingIn const url = this.url("/api/v2/client/status-tables"); const headerParams = { "Content-Type": "application/json", - ...(super.hasSecretValue("apiKey") ? { "X-Api-Key": super.getSecretValue("apiKey") } : {}), + ...(await this.getHttpAuthenticationAsync()).headers, }; const response = await fetchWithTrustedCertificatesAsync(url, { method: "POST", @@ -159,7 +166,7 @@ export class TdarrIntegration extends Integration implements IMediaTranscodingIn const url = this.url("/api/v2/client/status-tables"); const headerParams = { "Content-Type": "application/json", - ...(super.hasSecretValue("apiKey") ? { "X-Api-Key": super.getSecretValue("apiKey") } : {}), + ...(await this.getHttpAuthenticationAsync()).headers, }; const response = await fetchWithTrustedCertificatesAsync(url, { method: "POST", diff --git a/packages/integrations/src/mock/mock-integration.ts b/packages/integrations/src/mock/mock-integration.ts index 90c3905da..2cdcbd31c 100644 --- a/packages/integrations/src/mock/mock-integration.ts +++ b/packages/integrations/src/mock/mock-integration.ts @@ -1,3 +1,4 @@ +import type { IntegrationHttpAuthentication } from "../http-auth"; import type { IntegrationTestingInput } from "../base/integration"; import { Integration } from "../base/integration"; import type { TestingResult } from "../base/test-connection/test-connection-service"; @@ -52,6 +53,10 @@ export class MockIntegration ISmartHomeIntegration, IUpsSummaryIntegration { + public async getHttpAuthenticationAsync(): Promise { + throw new Error("This integration does not use HTTP"); + } + private static readonly dnsHole = new DnsHoleMockService(); private static readonly calendar = new CalendarMockService(); private static readonly downloadClient = new DownloadClientMockService(); diff --git a/packages/integrations/src/nextcloud/nextcloud.integration.ts b/packages/integrations/src/nextcloud/nextcloud.integration.ts index 4b3909610..899851c83 100644 --- a/packages/integrations/src/nextcloud/nextcloud.integration.ts +++ b/packages/integrations/src/nextcloud/nextcloud.integration.ts @@ -12,6 +12,8 @@ import { ResponseError } from "@homarr/common/server"; import { createHttpsAgentAsync, fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; import { createLogger } from "@homarr/core/infrastructure/logs"; +import type { IntegrationHttpAuthentication } from "../http-auth"; +import { basicAuth } from "../http-auth"; import { HandleIntegrationErrors } from "../base/errors/decorator"; import { integrationTsdavHttpErrorHandler } from "../base/errors/http"; import type { IntegrationTestingInput } from "../base/integration"; @@ -57,6 +59,10 @@ dayjs.extend(timezone); @HandleIntegrationErrors([integrationTsdavHttpErrorHandler]) export class NextcloudIntegration extends Integration implements ICalendarIntegration, INotificationsIntegration { + public async getHttpAuthenticationAsync(): Promise { + return basicAuth("username", "password", { "OCS-APIRequest": "true" })(this.integration); + } + protected async testingAsync(input: IntegrationTestingInput): Promise { const client = await this.createCalendarClientAsync(await createHttpsAgentAsync(input.options)); await client.login(); @@ -168,12 +174,7 @@ export class NextcloudIntegration extends Integration implements ICalendarIntegr public async getNotificationsAsync(): Promise { const url = this.url("/ocs/v2.php/apps/notifications/api/v2/notifications", { format: "json" }); const response = await fetchWithTrustedCertificatesAsync(url, { - headers: { - Authorization: `Basic ${Buffer.from( - `${this.getSecretValue("username")}:${this.getSecretValue("password")}`, - ).toString("base64")}`, - "OCS-APIRequest": "true", - }, + headers: (await this.getHttpAuthenticationAsync()).headers, }); if (!response.ok) throw new ResponseError(response); diff --git a/packages/integrations/src/ntfy/ntfy-integration.ts b/packages/integrations/src/ntfy/ntfy-integration.ts index 0fa437fec..402541f85 100644 --- a/packages/integrations/src/ntfy/ntfy-integration.ts +++ b/packages/integrations/src/ntfy/ntfy-integration.ts @@ -1,6 +1,7 @@ import { ResponseError } from "@homarr/common/server"; import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; +import type { IntegrationHttpAuthentication } from "../http-auth"; import { Integration } from "../base/integration"; import type { IntegrationTestingInput } from "../base/integration"; import type { TestingResult } from "../base/test-connection/test-connection-service"; @@ -9,6 +10,10 @@ import type { INotificationsIntegration } from "../interfaces/notifications/noti import { ntfyNotificationSchema } from "./ntfy-schema"; export class NTFYIntegration extends Integration implements INotificationsIntegration { + public async getHttpAuthenticationAsync(): Promise { + return { headers: this.getHeaders() }; + } + public async testingAsync(input: IntegrationTestingInput): Promise { await input.fetchAsync(this.url("/v1/account"), { headers: this.getHeaders() }); return { success: true }; @@ -17,7 +22,7 @@ export class NTFYIntegration extends Integration implements INotificationsIntegr private getTopicURL() { return this.url(`/${encodeURIComponent(super.getSecretValue("topic"))}/json`, { poll: 1 }); } - private getHeaders() { + private getHeaders(): Record { return this.hasSecretValue("apiKey") ? { Authorization: `Bearer ${super.getSecretValue("apiKey")}` } : {}; } diff --git a/packages/integrations/src/opnsense/opnsense-integration.ts b/packages/integrations/src/opnsense/opnsense-integration.ts index 8ab0e968d..cced68440 100644 --- a/packages/integrations/src/opnsense/opnsense-integration.ts +++ b/packages/integrations/src/opnsense/opnsense-integration.ts @@ -1,6 +1,7 @@ import { ParseError, ResponseError } from "@homarr/common/server"; import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; +import type { IntegrationHttpAuthentication } from "../http-auth"; import { createChannelEventHistoryOld } from "../../../redis/src/lib/channel"; import type { IntegrationTestingInput } from "../base/integration"; import { Integration } from "../base/integration"; @@ -22,6 +23,10 @@ import { } from "./opnsense-types"; export class OPNsenseIntegration extends Integration implements FirewallSummaryIntegration { + public async getHttpAuthenticationAsync(): Promise { + return { headers: { Authorization: this.getAuthHeaders() } }; + } + protected async testingAsync(input: IntegrationTestingInput): Promise { const response = await input.fetchAsync(this.url("/api/diagnostics/system/system_information"), { headers: { diff --git a/packages/integrations/src/overseerr/overseerr-integration.ts b/packages/integrations/src/overseerr/overseerr-integration.ts index f36e46745..5e76d2e06 100644 --- a/packages/integrations/src/overseerr/overseerr-integration.ts +++ b/packages/integrations/src/overseerr/overseerr-integration.ts @@ -4,6 +4,8 @@ import { ResponseError } from "@homarr/common/server"; import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; import { createLogger } from "@homarr/core/infrastructure/logs"; +import type { IntegrationHttpAuthentication } from "../http-auth"; +import { apiKeyAuth } from "../http-auth"; import type { IntegrationTestingInput } from "../base/integration"; import { Integration } from "../base/integration"; import type { ISearchableIntegration } from "../base/searchable-integration"; @@ -40,11 +42,13 @@ export class OverseerrIntegration extends Integration implements IMediaRequestIntegration, ISearchableIntegration { + public async getHttpAuthenticationAsync(): Promise { + return apiKeyAuth(this.integration); + } + public async searchAsync(query: string) { const response = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/search", { query }), { - headers: { - "X-Api-Key": this.getSecretValue("apiKey"), - }, + headers: (await this.getHttpAuthenticationAsync()).headers, }); const schemaData = await searchSchema.parseAsync(await response.json()); @@ -69,9 +73,7 @@ export class OverseerrIntegration public async getSeriesInformationAsync(mediaType: "movie" | "tv", id: number) { const url = mediaType === "tv" ? this.url(`/api/v1/tv/${id}`) : this.url(`/api/v1/movie/${id}`); const response = await fetchWithTrustedCertificatesAsync(url, { - headers: { - "X-Api-Key": this.getSecretValue("apiKey"), - }, + headers: (await this.getHttpAuthenticationAsync()).headers, }); const data = await mediaInformationSchema.parseAsync(await response.json()); const requestedSeasons = [ @@ -110,9 +112,7 @@ export class OverseerrIntegration protected async testingAsync(input: IntegrationTestingInput): Promise { const response = await input.fetchAsync(this.url("/api/v1/auth/me"), { - headers: { - "X-Api-Key": this.getSecretValue("apiKey"), - }, + headers: (await this.getHttpAuthenticationAsync()).headers, }); if (!response.ok) return TestConnectionError.StatusResult(response); @@ -126,18 +126,14 @@ export class OverseerrIntegration const pendingRequests = await fetchWithTrustedCertificatesAsync( this.url("/api/v1/request", { take: 20, filter: "pending" }), { - headers: { - "X-Api-Key": this.getSecretValue("apiKey"), - }, + headers: (await this.getHttpAuthenticationAsync()).headers, }, ); const allRequests = await fetchWithTrustedCertificatesAsync( this.url("/api/v1/request", { take: 20, sort: "modified" }), { - headers: { - "X-Api-Key": this.getSecretValue("apiKey"), - }, + headers: (await this.getHttpAuthenticationAsync()).headers, }, ); @@ -234,18 +230,14 @@ export class OverseerrIntegration public async getStatsAsync(): Promise { const response = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/request/count"), { - headers: { - "X-Api-Key": this.getSecretValue("apiKey"), - }, + headers: (await this.getHttpAuthenticationAsync()).headers, }); return await getStatsSchema.parseAsync(await response.json()); } public async getUsersAsync(): Promise { const response = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/user", { take: 10, sort: "requests" }), { - headers: { - "X-Api-Key": this.getSecretValue("apiKey"), - }, + headers: (await this.getHttpAuthenticationAsync()).headers, }); const users = (await getUsersSchema.parseAsync(await response.json())).results; return users.map((user): RequestUser => { @@ -261,9 +253,7 @@ export class OverseerrIntegration logger.info("Approving media request", { requestId, integration: this.integration.name }); const response = await fetchWithTrustedCertificatesAsync(this.url(`/api/v1/request/${requestId}/approve`), { method: "POST", - headers: { - "X-Api-Key": this.getSecretValue("apiKey"), - }, + headers: (await this.getHttpAuthenticationAsync()).headers, }); if (!response.ok) throw new ResponseError(response); logger.info("Successfully approved media request", { requestId, integration: this.integration.name }); @@ -274,9 +264,7 @@ export class OverseerrIntegration const response = await fetchWithTrustedCertificatesAsync(this.url(`/api/v1/request/${requestId}/decline`), { method: "POST", - headers: { - "X-Api-Key": this.getSecretValue("apiKey"), - }, + headers: (await this.getHttpAuthenticationAsync()).headers, }); if (!response.ok) throw new ResponseError(response); logger.info("Successfully declined media request", { requestId, integration: this.integration.name }); @@ -284,9 +272,7 @@ export class OverseerrIntegration private async getItemInformationAsync(id: number, type: MediaRequest["type"]): Promise { const response = await fetchWithTrustedCertificatesAsync(this.url(`/api/v1/${type}/${id}`), { - headers: { - "X-Api-Key": this.getSecretValue("apiKey"), - }, + headers: (await this.getHttpAuthenticationAsync()).headers, }); if (!response.ok) { diff --git a/packages/integrations/src/paperless-ngx/paperless-ngx-integration.ts b/packages/integrations/src/paperless-ngx/paperless-ngx-integration.ts index a6059c3e8..c06314046 100644 --- a/packages/integrations/src/paperless-ngx/paperless-ngx-integration.ts +++ b/packages/integrations/src/paperless-ngx/paperless-ngx-integration.ts @@ -1,6 +1,7 @@ import { ResponseError } from "@homarr/common/server"; import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; +import type { IntegrationHttpAuthentication } from "../http-auth"; import type { IntegrationTestingInput } from "../base/integration"; import { Integration } from "../base/integration"; import { TestConnectionError } from "../base/test-connection/test-connection-error"; @@ -9,6 +10,10 @@ import type { PaperlessNgxStats } from "./paperless-ngx-types"; import { paperlessNgxPaginatedCountSchema, paperlessNgxStatisticsSchema } from "./paperless-ngx-types"; export class PaperlessNgxIntegration extends Integration { + public async getHttpAuthenticationAsync(): Promise { + return { headers: this.getAuthHeaders() }; + } + protected async testingAsync(input: IntegrationTestingInput): Promise { const statisticsUrl = this.url("/api/statistics/"); const statisticsResponse = await input.fetchAsync(statisticsUrl, { diff --git a/packages/integrations/src/patchmon/patchmon-integration.ts b/packages/integrations/src/patchmon/patchmon-integration.ts index 2a9c4dc5a..29f894e6d 100644 --- a/packages/integrations/src/patchmon/patchmon-integration.ts +++ b/packages/integrations/src/patchmon/patchmon-integration.ts @@ -1,6 +1,7 @@ import { ParseError, ResponseError } from "@homarr/common/server"; import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; +import type { IntegrationHttpAuthentication } from "../http-auth"; import type { IntegrationTestingInput } from "../base/integration"; import { Integration } from "../base/integration"; import { TestConnectionError } from "../base/test-connection/test-connection-error"; @@ -11,6 +12,10 @@ import { mapPatchMonStats, parsePatchMonStatsResponseAsync } from "./patchmon-ty const STATS_REQUEST_TIMEOUT_MS = 10_000; export class PatchMonIntegration extends Integration { + public async getHttpAuthenticationAsync(): Promise { + return { headers: this.getAuthHeaders() }; + } + protected async testingAsync(input: IntegrationTestingInput): Promise { const response = await input.fetchAsync(this.url("/api/v1/gethomepage/stats"), { headers: this.getAuthHeaders(), diff --git a/packages/integrations/src/peanut/peanut-integration.ts b/packages/integrations/src/peanut/peanut-integration.ts index ad3fe6296..1c81bd78c 100644 --- a/packages/integrations/src/peanut/peanut-integration.ts +++ b/packages/integrations/src/peanut/peanut-integration.ts @@ -1,6 +1,7 @@ import { ResponseError } from "@homarr/common/server"; import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; +import type { IntegrationHttpAuthentication } from "../http-auth"; import type { IntegrationTestingInput } from "../base/integration"; import { Integration } from "../base/integration"; import { TestConnectionError } from "../base/test-connection/test-connection-error"; @@ -11,6 +12,10 @@ import type { PeaNutDevice } from "./peanut-types"; import { peaNutDevicesSchema } from "./peanut-types"; export class PeaNutIntegration extends Integration implements IUpsSummaryIntegration { + public async getHttpAuthenticationAsync(): Promise { + return { headers: this.getAuthHeaders() }; + } + protected async testingAsync(input: IntegrationTestingInput): Promise { const response = await input.fetchAsync(this.url("/api/v1/devices"), { headers: this.getAuthHeaders(), diff --git a/packages/integrations/src/plex/plex-integration.ts b/packages/integrations/src/plex/plex-integration.ts index 01f971abf..cbdda9ceb 100644 --- a/packages/integrations/src/plex/plex-integration.ts +++ b/packages/integrations/src/plex/plex-integration.ts @@ -6,6 +6,8 @@ import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/h import { createLogger } from "@homarr/core/infrastructure/logs"; import { ImageProxy } from "@homarr/image-proxy"; +import type { IntegrationHttpAuthentication } from "../http-auth"; +import { headerAuth } from "../http-auth"; import type { IntegrationTestingInput } from "../base/integration"; import { Integration } from "../base/integration"; import { TestConnectionError } from "../base/test-connection/test-connection-error"; @@ -50,6 +52,10 @@ function isStreamDirect(decision: string | undefined): boolean { } export class PlexIntegration extends Integration implements IMediaServerIntegration, IMediaReleasesIntegration { + public async getHttpAuthenticationAsync(): Promise { + return headerAuth("X-Plex-Token")(this.integration); + } + public async getCurrentSessionsAsync(_options: CurrentSessionsInput): Promise { const token = super.getSecretValue("apiKey"); diff --git a/packages/integrations/src/prowlarr/prowlarr-integration.ts b/packages/integrations/src/prowlarr/prowlarr-integration.ts index db6e35f06..284507e38 100644 --- a/packages/integrations/src/prowlarr/prowlarr-integration.ts +++ b/packages/integrations/src/prowlarr/prowlarr-integration.ts @@ -2,6 +2,8 @@ import { z } from "zod/v4"; import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; +import type { IntegrationHttpAuthentication } from "../http-auth"; +import { apiKeyAuth } from "../http-auth"; import type { IntegrationTestingInput } from "../base/integration"; import { Integration } from "../base/integration"; import { TestConnectionError } from "../base/test-connection/test-connection-error"; @@ -11,6 +13,10 @@ import type { Indexer } from "../interfaces/indexer-manager/indexer-manager-type import { indexerResponseSchema, statusResponseSchema } from "./prowlarr-types"; export class ProwlarrIntegration extends Integration implements IIndexerManagerIntegration { + public async getHttpAuthenticationAsync(): Promise { + return apiKeyAuth(this.integration); + } + public async getIndexersAsync(): Promise { const apiKey = super.getSecretValue("apiKey"); diff --git a/packages/integrations/src/proxmox/proxmox-integration.ts b/packages/integrations/src/proxmox/proxmox-integration.ts index 5f70c6b2f..9dabb1564 100644 --- a/packages/integrations/src/proxmox/proxmox-integration.ts +++ b/packages/integrations/src/proxmox/proxmox-integration.ts @@ -4,6 +4,7 @@ import proxmoxApi from "proxmox-api"; import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; import { createLogger } from "@homarr/core/infrastructure/logs"; +import type { IntegrationHttpAuthentication } from "../http-auth"; import { HandleIntegrationErrors } from "../base/errors/decorator"; import type { IntegrationTestingInput } from "../base/integration"; import { Integration } from "../base/integration"; @@ -23,6 +24,14 @@ const logger = createLogger({ module: "proxmoxIntegration" }); @HandleIntegrationErrors([new ProxmoxApiErrorHandler()]) export class ProxmoxIntegration extends Integration implements IClusterHealthMonitoringIntegration { + public async getHttpAuthenticationAsync(): Promise { + return { + headers: { + Authorization: `PVEAPIToken=${this.getSecretValue("username")}@${this.getSecretValue("realm")}!${this.getSecretValue("tokenId")}=${this.getSecretValue("apiKey")}`, + }, + }; + } + protected async testingAsync(input: IntegrationTestingInput): Promise { const proxmox = this.getPromoxApi(input.fetchAsync); await proxmox.nodes.$get(); diff --git a/packages/integrations/src/speedtest-tracker/speedtest-tracker-integration.ts b/packages/integrations/src/speedtest-tracker/speedtest-tracker-integration.ts index 80517c69e..a1cc0f491 100644 --- a/packages/integrations/src/speedtest-tracker/speedtest-tracker-integration.ts +++ b/packages/integrations/src/speedtest-tracker/speedtest-tracker-integration.ts @@ -1,6 +1,7 @@ import { ResponseError } from "@homarr/common/server"; import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; +import type { IntegrationHttpAuthentication } from "../http-auth"; import type { IntegrationTestingInput } from "../base/integration"; import { Integration } from "../base/integration"; import { TestConnectionError } from "../base/test-connection/test-connection-error"; @@ -18,6 +19,10 @@ import type { } from "./speedtest-tracker-types"; export class SpeedtestTrackerIntegration extends Integration { + public async getHttpAuthenticationAsync(): Promise { + return { headers: this.getAuthHeaders(), redactValues: [] }; + } + protected async testingAsync(input: IntegrationTestingInput): Promise { const url = this.url("/api/v1/results/latest"); const response = await input.fetchAsync(url, { diff --git a/packages/integrations/src/tracearr/tracearr-integration.ts b/packages/integrations/src/tracearr/tracearr-integration.ts index 40f3b2618..3f8fbb692 100644 --- a/packages/integrations/src/tracearr/tracearr-integration.ts +++ b/packages/integrations/src/tracearr/tracearr-integration.ts @@ -3,6 +3,7 @@ import { ResponseError } from "@homarr/common/server"; import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; import { ImageProxy } from "@homarr/image-proxy"; +import type { IntegrationHttpAuthentication } from "../http-auth"; import type { IntegrationTestingInput } from "../base/integration"; import { Integration } from "../base/integration"; import type { TestingResult } from "../base/test-connection/test-connection-service"; @@ -17,6 +18,10 @@ import type { } from "./tracearr-types"; export class TracearrIntegration extends Integration { + public async getHttpAuthenticationAsync(): Promise { + return { headers: this.getAuthHeaders() }; + } + protected async testingAsync(input: IntegrationTestingInput): Promise { const healthUrl = this.url("/api/v1/public/health"); const healthResponse = await input.fetchAsync(healthUrl, { diff --git a/packages/integrations/src/truenas/truenas-integration.ts b/packages/integrations/src/truenas/truenas-integration.ts index 7fb966aa0..10d3bb207 100644 --- a/packages/integrations/src/truenas/truenas-integration.ts +++ b/packages/integrations/src/truenas/truenas-integration.ts @@ -3,6 +3,7 @@ import z from "zod"; import { createLogger } from "@homarr/core/infrastructure/logs"; +import type { IntegrationHttpAuthentication } from "../http-auth"; import type { IntegrationTestingInput } from "../base/integration"; import { Integration } from "../base/integration"; import type { TestingResult } from "../base/test-connection/test-connection-service"; @@ -32,6 +33,10 @@ const describeRequestError = (error: unknown) => { }; export class TrueNasIntegration extends Integration implements ISystemHealthMonitoringIntegration { + public async getHttpAuthenticationAsync(): Promise { + throw new Error("This integration does not use HTTP"); + } + private client?: TrueNasClient; protected async testingAsync(input: IntegrationTestingInput): Promise { diff --git a/packages/integrations/src/unraid/unraid-integration.ts b/packages/integrations/src/unraid/unraid-integration.ts index 0c26b8134..398adc50f 100644 --- a/packages/integrations/src/unraid/unraid-integration.ts +++ b/packages/integrations/src/unraid/unraid-integration.ts @@ -5,6 +5,8 @@ import { ResponseError } from "@homarr/common/server"; import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; import { createLogger } from "@homarr/core/infrastructure/logs"; +import type { IntegrationHttpAuthentication } from "../http-auth"; +import { apiKeyAuth } from "../http-auth"; import { HandleIntegrationErrors } from "../base/errors/decorator"; import type { IntegrationTestingInput } from "../base/integration"; import { Integration } from "../base/integration"; @@ -18,6 +20,10 @@ const logger = createLogger({ module: "UnraidIntegration" }); @HandleIntegrationErrors([]) export class UnraidIntegration extends Integration implements ISystemHealthMonitoringIntegration { + public async getHttpAuthenticationAsync(): Promise { + return apiKeyAuth(this.integration); + } + protected async testingAsync(input: IntegrationTestingInput): Promise { await this.queryGraphQLAsync<{ info: UnraidSystemInfo }>( ` diff --git a/packages/integrations/src/uptime-kuma/uptime-kuma-integration.ts b/packages/integrations/src/uptime-kuma/uptime-kuma-integration.ts index ce494e599..77eedb037 100644 --- a/packages/integrations/src/uptime-kuma/uptime-kuma-integration.ts +++ b/packages/integrations/src/uptime-kuma/uptime-kuma-integration.ts @@ -1,6 +1,7 @@ import { ParseError, ResponseError } from "@homarr/common/server"; import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; +import type { IntegrationHttpAuthentication } from "../http-auth"; import type { IntegrationTestingInput } from "../base/integration"; import { Integration } from "../base/integration"; import { TestConnectionError } from "../base/test-connection/test-connection-error"; @@ -13,6 +14,10 @@ import { import type { UptimeKumaDashboardData, UptimeKumaMonitor, UptimeKumaMonitorCategory } from "./uptime-kuma-types"; export class UptimeKumaIntegration extends Integration { + public async getHttpAuthenticationAsync(): Promise { + return {}; + } + protected async testingAsync(input: IntegrationTestingInput): Promise { const statusPageResponse = await input.fetchAsync(this.statusPageUrl()); diff --git a/packages/integrations/src/wud/wud-integration.ts b/packages/integrations/src/wud/wud-integration.ts index 05c6cc17a..cc2e58cec 100644 --- a/packages/integrations/src/wud/wud-integration.ts +++ b/packages/integrations/src/wud/wud-integration.ts @@ -3,6 +3,7 @@ import { Buffer } from "node:buffer"; import { ParseError, ResponseError } from "@homarr/common/server"; import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; +import type { IntegrationHttpAuthentication } from "../http-auth"; import type { IntegrationTestingInput } from "../base/integration"; import { Integration } from "../base/integration"; import { TestConnectionError } from "../base/test-connection/test-connection-error"; @@ -13,6 +14,10 @@ import { mapWudStats, parseWudContainersResponseAsync } from "./wud-types"; const CONTAINERS_REQUEST_TIMEOUT_MS = 10_000; export class WudIntegration extends Integration { + public async getHttpAuthenticationAsync(): Promise { + return { headers: this.getAuthHeaders() }; + } + protected async testingAsync(input: IntegrationTestingInput): Promise { const response = await input.fetchAsync(this.url("/api/containers"), { headers: this.getAuthHeaders(), diff --git a/packages/integrations/test/base.spec.ts b/packages/integrations/test/base.spec.ts index 4fcce3458..6b1e2c7c3 100644 --- a/packages/integrations/test/base.spec.ts +++ b/packages/integrations/test/base.spec.ts @@ -45,6 +45,9 @@ describe("Base integration", () => { }); class FakeIntegration extends Integration { + public async getHttpAuthenticationAsync() { + return {}; + } constructor( private testingResult?: TestingResult, private error?: Error,