diff --git a/packages/definitions/src/integration.ts b/packages/definitions/src/integration.ts index 7a1112aba..bbf0a66ba 100644 --- a/packages/definitions/src/integration.ts +++ b/packages/definitions/src/integration.ts @@ -207,6 +207,27 @@ export const integrationDefs = { category: ["releasesProvider"], defaultUrl: "https://codeberg.org", }, + linuxServerIO: { + name: "LinuxServer.io", + secretKinds: [[]], + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/linuxserver-io.svg", + category: ["releasesProvider"], + defaultUrl: "https://api.linuxserver.io", + }, + githubPackages: { + name: "Github Packages", + secretKinds: [[], ["personalAccessToken"]], + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/github.svg", + category: ["releasesProvider"], + defaultUrl: "https://api.github.com", + }, + quay: { + name: "Quay", + secretKinds: [[], ["personalAccessToken"]], + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/quay.png", + category: ["releasesProvider"], + defaultUrl: "https://quay.io", + }, ntfy: { name: "ntfy", secretKinds: [["topic"], ["topic", "apiKey"]], diff --git a/packages/integrations/src/base/creator.ts b/packages/integrations/src/base/creator.ts index dda1b03a5..c0dc58462 100644 --- a/packages/integrations/src/base/creator.ts +++ b/packages/integrations/src/base/creator.ts @@ -14,11 +14,13 @@ import { QBitTorrentIntegration } from "../download-client/qbittorrent/qbittorre import { SabnzbdIntegration } from "../download-client/sabnzbd/sabnzbd-integration"; import { TransmissionIntegration } from "../download-client/transmission/transmission-integration"; import { EmbyIntegration } from "../emby/emby-integration"; +import { GithubPackagesIntegration } from "../github-packages/github-packages-integration"; import { GithubIntegration } from "../github/github-integration"; import { GitlabIntegration } from "../gitlab/gitlab-integration"; import { HomeAssistantIntegration } from "../homeassistant/homeassistant-integration"; import { JellyfinIntegration } from "../jellyfin/jellyfin-integration"; import { JellyseerrIntegration } from "../jellyseerr/jellyseerr-integration"; +import { LinuxServerIOIntegration } from "../linuxserverio/linuxserverio-integration"; import { LidarrIntegration } from "../media-organizer/lidarr/lidarr-integration"; import { RadarrIntegration } from "../media-organizer/radarr/radarr-integration"; import { ReadarrIntegration } from "../media-organizer/readarr/readarr-integration"; @@ -34,6 +36,7 @@ import { createPiHoleIntegrationAsync } from "../pi-hole/pi-hole-integration-fac import { PlexIntegration } from "../plex/plex-integration"; import { ProwlarrIntegration } from "../prowlarr/prowlarr-integration"; import { ProxmoxIntegration } from "../proxmox/proxmox-integration"; +import { QuayIntegration } from "../quay/quay-integration"; import { UnifiControllerIntegration } from "../unifi-controller/unifi-controller-integration"; import type { Integration, IntegrationInput } from "./integration"; @@ -104,6 +107,9 @@ export const integrationCreators = { gitlab: GitlabIntegration, npm: NPMIntegration, codeberg: CodebergIntegration, + linuxServerIO: LinuxServerIOIntegration, + githubPackages: GithubPackagesIntegration, + quay: QuayIntegration, ntfy: NTFYIntegration, mock: MockIntegration, } satisfies Record Promise]>; diff --git a/packages/integrations/src/codeberg/codeberg-integration.ts b/packages/integrations/src/codeberg/codeberg-integration.ts index 08adfabc7..2836917a0 100644 --- a/packages/integrations/src/codeberg/codeberg-integration.ts +++ b/packages/integrations/src/codeberg/codeberg-integration.ts @@ -20,7 +20,7 @@ const localLogger = logger.child({ module: "CodebergIntegration" }); export class CodebergIntegration extends Integration implements ReleasesProviderIntegration { private async withHeadersAsync(callback: (headers: RequestInit["headers"]) => Promise): Promise { - if (!this.hasSecretValue("personalAccessToken")) return await callback({}); + if (!this.hasSecretValue("personalAccessToken")) return await callback(undefined); return await callback({ Authorization: `token ${this.getSecretValue("personalAccessToken")}`, @@ -61,7 +61,7 @@ export class CodebergIntegration extends Integration implements ReleasesProvider const details = await this.getDetailsAsync(owner, name); const releasesResponse = await this.withHeadersAsync(async (headers) => { - return fetchWithTrustedCertificatesAsync( + return await fetchWithTrustedCertificatesAsync( this.url(`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/releases`), { headers }, ); diff --git a/packages/integrations/src/docker-hub/docker-hub-integration.ts b/packages/integrations/src/docker-hub/docker-hub-integration.ts index 2affdf89d..a961ddc38 100644 --- a/packages/integrations/src/docker-hub/docker-hub-integration.ts +++ b/packages/integrations/src/docker-hub/docker-hub-integration.ts @@ -30,7 +30,8 @@ export class DockerHubIntegration extends Integration implements ReleasesProvide } private async withHeadersAsync(callback: (headers: RequestInit["headers"]) => Promise): Promise { - if (!this.hasSecretValue("username") || !this.hasSecretValue("personalAccessToken")) return await callback({}); + if (!this.hasSecretValue("username") || !this.hasSecretValue("personalAccessToken")) + return await callback(undefined); const storedSession = await this.sessionStore.getAsync(); diff --git a/packages/integrations/src/github-packages/github-packages-integration.ts b/packages/integrations/src/github-packages/github-packages-integration.ts new file mode 100644 index 000000000..64ef59f85 --- /dev/null +++ b/packages/integrations/src/github-packages/github-packages-integration.ts @@ -0,0 +1,145 @@ +import { Octokit, RequestError } from "octokit"; + +import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server"; +import { logger } from "@homarr/log"; + +import type { IntegrationTestingInput } from "../base/integration"; +import { Integration } from "../base/integration"; +import { TestConnectionError } from "../base/test-connection/test-connection-error"; +import type { TestingResult } from "../base/test-connection/test-connection-service"; +import type { ReleasesProviderIntegration } from "../interfaces/releases-providers/releases-providers-integration"; +import { getLatestRelease } from "../interfaces/releases-providers/releases-providers-integration"; +import type { + DetailsProviderResponse, + ReleaseProviderResponse, + ReleasesRepository, + ReleasesResponse, +} from "../interfaces/releases-providers/releases-providers-types"; + +const localLogger = logger.child({ module: "GithubPackagesIntegration" }); + +export class GithubPackagesIntegration extends Integration implements ReleasesProviderIntegration { + private static readonly userAgent = "Homarr-Lab/Homarr:GithubPackagesIntegration"; + + protected async testingAsync(input: IntegrationTestingInput): Promise { + const headers: RequestInit["headers"] = { + "User-Agent": GithubPackagesIntegration.userAgent, + }; + + if (this.hasSecretValue("personalAccessToken")) + headers.Authorization = `Bearer ${this.getSecretValue("personalAccessToken")}`; + + const response = await input.fetchAsync(this.url("/octocat"), { + headers, + }); + + if (!response.ok) { + return TestConnectionError.StatusResult(response); + } + + return { + success: true, + }; + } + + public async getLatestMatchingReleaseAsync(repository: ReleasesRepository): Promise { + const [owner, name] = repository.identifier.split("/"); + if (!owner || !name) { + localLogger.warn( + `Invalid identifier format. Expected 'owner/name', for ${repository.identifier} with Github Packages integration`, + { + identifier: repository.identifier, + }, + ); + return { + id: repository.id, + error: { code: "invalidIdentifier" }, + }; + } + + const api = this.getApi(); + const details = await this.getDetailsAsync(api, owner, name); + + try { + const releasesResponse = await api.rest.packages.getAllPackageVersionsForPackageOwnedByUser({ + username: owner, + package_type: "container", + package_name: name, + per_page: 100, + }); + + const releasesProviderResponse = releasesResponse.data.reduce((acc, release) => { + if (!release.metadata?.container?.tags || !(release.metadata.container.tags.length > 0)) return acc; + + release.metadata.container.tags.forEach((tag) => { + acc.push({ + latestRelease: tag, + latestReleaseAt: new Date(release.updated_at), + releaseUrl: release.html_url, + releaseDescription: release.description ?? undefined, + }); + }); + return acc; + }, []); + + return getLatestRelease(releasesProviderResponse, repository, details); + } catch (error) { + const errorMessage = error instanceof RequestError ? error.message : String(error); + + localLogger.warn(`Failed to get releases for ${owner}\\${name} with Github Packages integration`, { + owner, + name, + error: errorMessage, + }); + + return { + id: repository.id, + error: { message: errorMessage }, + }; + } + } + + protected async getDetailsAsync( + api: Octokit, + owner: string, + name: string, + ): Promise { + try { + const response = await api.rest.packages.getPackageForUser({ + username: owner, + package_type: "container", + package_name: name, + }); + + return { + projectUrl: response.data.repository?.html_url ?? response.data.html_url, + projectDescription: response.data.repository?.description ?? undefined, + isFork: response.data.repository?.fork, + isArchived: response.data.repository?.archived, + createdAt: new Date(response.data.created_at), + starsCount: response.data.repository?.stargazers_count, + openIssues: response.data.repository?.open_issues_count, + forksCount: response.data.repository?.forks_count, + }; + } catch (error) { + localLogger.warn(`Failed to get details for ${owner}\\${name} with Github Packages integration`, { + owner, + name, + error: error instanceof RequestError ? error.message : String(error), + }); + return undefined; + } + } + + private getApi() { + return new Octokit({ + baseUrl: this.url("/").origin, + request: { + fetch: fetchWithTrustedCertificatesAsync, + }, + userAgent: GithubPackagesIntegration.userAgent, + throttle: { enabled: false }, // Disable throttling for this integration, Octokit will retry by default after a set time, thus delaying the repsonse to the user in case of errors. Errors will be shown to the user, no need to retry the request. + ...(this.hasSecretValue("personalAccessToken") ? { auth: this.getSecretValue("personalAccessToken") } : {}), + }); + } +} diff --git a/packages/integrations/src/github/github-integration.ts b/packages/integrations/src/github/github-integration.ts index 97689518e..b97157904 100644 --- a/packages/integrations/src/github/github-integration.ts +++ b/packages/integrations/src/github/github-integration.ts @@ -58,7 +58,6 @@ export class GithubIntegration extends Integration implements ReleasesProviderIn } const api = this.getApi(); - const details = await this.getDetailsAsync(api, owner, name); try { diff --git a/packages/integrations/src/linuxserverio/linuxserverio-integration.ts b/packages/integrations/src/linuxserverio/linuxserverio-integration.ts new file mode 100644 index 000000000..b3039881d --- /dev/null +++ b/packages/integrations/src/linuxserverio/linuxserverio-integration.ts @@ -0,0 +1,88 @@ +import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server"; +import { logger } from "@homarr/log"; + +import type { IntegrationTestingInput } from "../base/integration"; +import { Integration } from "../base/integration"; +import { TestConnectionError } from "../base/test-connection/test-connection-error"; +import type { TestingResult } from "../base/test-connection/test-connection-service"; +import type { ReleasesProviderIntegration } from "../interfaces/releases-providers/releases-providers-integration"; +import type { ReleasesRepository, ReleasesResponse } from "../interfaces/releases-providers/releases-providers-types"; +import { releasesResponseSchema } from "./linuxserverio-schemas"; + +const localLogger = logger.child({ module: "LinuxServerIOsIntegration" }); + +export class LinuxServerIOIntegration extends Integration implements ReleasesProviderIntegration { + protected async testingAsync(input: IntegrationTestingInput): Promise { + const response = await input.fetchAsync(this.url("/health")); + + if (!response.ok) { + return TestConnectionError.StatusResult(response); + } + + return { + success: true, + }; + } + + public async getLatestMatchingReleaseAsync(repository: ReleasesRepository): Promise { + const [owner, name] = repository.identifier.split("/"); + if (!owner || !name) { + localLogger.warn( + `Invalid identifier format. Expected 'owner/name', for ${repository.identifier} with LinuxServerIO integration`, + { + identifier: repository.identifier, + }, + ); + return { + id: repository.id, + error: { code: "invalidIdentifier" }, + }; + } + + const releasesResponse = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/images")); + + if (!releasesResponse.ok) { + return { + id: repository.id, + error: { message: releasesResponse.statusText }, + }; + } + + const releasesResponseJson: unknown = await releasesResponse.json(); + const { data, success, error } = releasesResponseSchema.safeParse(releasesResponseJson); + + if (!success) { + return { + id: repository.id, + error: { + message: error.message, + }, + }; + } else { + const release = data.data.repositories.linuxserver.find((repo) => repo.name === name); + if (!release) { + localLogger.warn(`Repository ${name} not found on provider, with LinuxServerIO integration`, { + owner, + name, + }); + + return { + id: repository.id, + error: { code: "noReleasesFound" }, + }; + } + + return { + id: repository.id, + latestRelease: release.version, + latestReleaseAt: release.version_timestamp, + releaseDescription: release.changelog?.shift()?.desc, + projectUrl: release.github_url, + projectDescription: release.description, + isArchived: release.deprecated, + createdAt: release.initial_date ? new Date(release.initial_date) : undefined, + starsCount: release.stars, + }; + } + } +} diff --git a/packages/integrations/src/linuxserverio/linuxserverio-schemas.ts b/packages/integrations/src/linuxserverio/linuxserverio-schemas.ts new file mode 100644 index 000000000..bf9842a50 --- /dev/null +++ b/packages/integrations/src/linuxserverio/linuxserverio-schemas.ts @@ -0,0 +1,31 @@ +import { z } from "zod"; + +export const releasesResponseSchema = z.object({ + data: z.object({ + repositories: z.object({ + linuxserver: z.array( + z.object({ + name: z.string(), + initial_date: z + .string() + .transform((value) => new Date(value)) + .optional(), + github_url: z.string(), + description: z.string(), + version: z.string(), + version_timestamp: z.string().transform((value) => new Date(value)), + stars: z.number(), + deprecated: z.boolean(), + changelog: z + .array( + z.object({ + date: z.string().transform((value) => new Date(value)), + desc: z.string(), + }), + ) + .optional(), + }), + ), + }), + }), +}); diff --git a/packages/integrations/src/quay/quay-integration.ts b/packages/integrations/src/quay/quay-integration.ts new file mode 100644 index 000000000..f84052315 --- /dev/null +++ b/packages/integrations/src/quay/quay-integration.ts @@ -0,0 +1,109 @@ +import type { RequestInit, Response } from "undici"; + +import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server"; +import { logger } from "@homarr/log"; + +import type { IntegrationTestingInput } from "../base/integration"; +import { Integration } from "../base/integration"; +import { TestConnectionError } from "../base/test-connection/test-connection-error"; +import type { TestingResult } from "../base/test-connection/test-connection-service"; +import type { ReleasesProviderIntegration } from "../interfaces/releases-providers/releases-providers-integration"; +import { getLatestRelease } from "../interfaces/releases-providers/releases-providers-integration"; +import type { + ReleaseProviderResponse, + ReleasesRepository, + ReleasesResponse, +} from "../interfaces/releases-providers/releases-providers-types"; +import { releasesResponseSchema } from "./quay-schemas"; + +const localLogger = logger.child({ module: "QuayIntegration" }); + +export class QuayIntegration extends Integration implements ReleasesProviderIntegration { + private async withHeadersAsync(callback: (headers: RequestInit["headers"]) => Promise): Promise { + if (!this.hasSecretValue("personalAccessToken")) return await callback(undefined); + + return await callback({ + Authorization: `token ${this.getSecretValue("personalAccessToken")}`, + }); + } + + protected async testingAsync(input: IntegrationTestingInput): Promise { + const response = await this.withHeadersAsync(async (headers) => { + return await input.fetchAsync(this.url("/api/v1/discovery"), { + headers, + }); + }); + + if (!response.ok) { + return TestConnectionError.StatusResult(response); + } + + return { + success: true, + }; + } + + public async getLatestMatchingReleaseAsync(repository: ReleasesRepository): Promise { + const [owner, name] = repository.identifier.split("/"); + if (!owner || !name) { + localLogger.warn( + `Invalid identifier format. Expected 'owner/name', for ${repository.identifier} with LinuxServerIO integration`, + { + identifier: repository.identifier, + }, + ); + return { + id: repository.id, + error: { code: "invalidIdentifier" }, + }; + } + + const releasesResponse = await this.withHeadersAsync(async (headers) => { + return await fetchWithTrustedCertificatesAsync( + this.url( + `/api/v1/repository/${encodeURIComponent(owner)}/${encodeURIComponent(name)}?includeTags=true&includeStats=true`, + ), + { + headers, + }, + ); + }); + + if (!releasesResponse.ok) { + return { + id: repository.id, + error: { message: releasesResponse.statusText }, + }; + } + + const releasesResponseJson: unknown = await releasesResponse.json(); + const { data, success, error } = releasesResponseSchema.safeParse(releasesResponseJson); + + if (!success) { + return { + id: repository.id, + error: { + message: error.message, + }, + }; + } else { + const details = { + projectDescription: data.description, + }; + + const releasesProviderResponse = Object.entries(data.tags).reduce((acc, [_, tag]) => { + if (!tag.name || !tag.last_modified) return acc; + + acc.push({ + latestRelease: tag.name, + latestReleaseAt: new Date(tag.last_modified), + releaseUrl: `https://quay.io/repository/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/tag/${encodeURIComponent(tag.name)}`, + }); + + return acc; + }, []); + + return getLatestRelease(releasesProviderResponse, repository, details); + } + } +} diff --git a/packages/integrations/src/quay/quay-schemas.ts b/packages/integrations/src/quay/quay-schemas.ts new file mode 100644 index 000000000..2de28c018 --- /dev/null +++ b/packages/integrations/src/quay/quay-schemas.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; + +export const releasesResponseSchema = z.object({ + description: z.string().optional(), + tags: z.record( + z.object({ + name: z.string(), + last_modified: z.string(), + }), + ), +}); diff --git a/packages/widgets/src/_inputs/widget-multiReleasesRepositories-input.tsx b/packages/widgets/src/_inputs/widget-multiReleasesRepositories-input.tsx index 268a75a4c..289936b6d 100644 --- a/packages/widgets/src/_inputs/widget-multiReleasesRepositories-input.tsx +++ b/packages/widgets/src/_inputs/widget-multiReleasesRepositories-input.tsx @@ -570,7 +570,7 @@ const ImportRepositorySelect = ({ )} - + {integration ? ( (({ innerProps, () => docker.data?.containers.reduce((acc, container) => { const [maybeSource, maybeIdentifierAndVersion] = container.image.split(/\/(.*)/); - const hasSource = maybeSource && maybeSource in sourceToProviderKind; + const hasSource = maybeSource && maybeSource in containerImageToProviderKind; const source = hasSource ? maybeSource : "docker.io"; - const identifierAndVersion = hasSource ? maybeIdentifierAndVersion : container.image; + const [identifier, version] = + hasSource && maybeIdentifierAndVersion ? maybeIdentifierAndVersion.split(":") : container.image.split(":"); - if (!identifierAndVersion) return acc; + if (!identifier) return acc; - const providerKey = sourceToProviderKind[source]; + const providerKind = containerImageToProviderKind[source] ?? "dockerHub"; const integrationId = Object.values(innerProps.integrations).find( - (integration) => integration.kind === providerKey, + (integration) => integration.kind === providerKind, )?.id; - const [identifier, version] = identifierAndVersion.split(":"); - - if (!identifier || !integrationId) return acc; - - if ( - acc.some( - (item) => - item.providerIntegrationId !== undefined && - innerProps.integrations[item.providerIntegrationId]?.kind === providerKey && - item.identifier === identifier, - ) - ) + if (acc.some((item) => item.providerIntegrationId === integrationId && item.identifier === identifier)) return acc; acc.push({ @@ -651,10 +641,7 @@ const RepositoryImportModal = createModal(({ innerProps, name: formatIdentifierName(identifier), versionFilter: version ? parseImageVersionToVersionFilter(version) : undefined, alreadyImported: innerProps.repositories.some( - (item) => - item.providerIntegrationId !== undefined && - innerProps.integrations[item.providerIntegrationId]?.kind === providerKey && - item.identifier === identifier, + (item) => item.providerIntegrationId === integrationId && item.identifier === identifier, ), }); return acc; @@ -811,9 +798,11 @@ const RepositoryImportModal = createModal(({ innerProps, size: "xl", }); -const sourceToProviderKind: Record = { +const containerImageToProviderKind: Record = { "ghcr.io": "github", "docker.io": "dockerHub", + "lscr.io": "linuxServerIO", + "quay.io": "quay", }; const parseImageVersionToVersionFilter = (imageVersion: string): ReleasesVersionFilter | undefined => {