diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/_components/secrets/integration-secret-icons.ts b/apps/nextjs/src/app/[locale]/manage/integrations/_components/secrets/integration-secret-icons.ts index 1c15710a5..e489963e7 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/_components/secrets/integration-secret-icons.ts +++ b/apps/nextjs/src/app/[locale]/manage/integrations/_components/secrets/integration-secret-icons.ts @@ -1,4 +1,12 @@ -import { IconGrid3x3, IconKey, IconMessage, IconPassword, IconServer, IconUser } from "@tabler/icons-react"; +import { + IconGrid3x3, + IconKey, + IconMessage, + IconPassword, + IconPasswordUser, + IconServer, + IconUser, +} from "@tabler/icons-react"; import type { IntegrationSecretKind } from "@homarr/definitions"; import type { TablerIcon } from "@homarr/ui"; @@ -9,5 +17,6 @@ export const integrationSecretIcons = { password: IconPassword, realm: IconServer, tokenId: IconGrid3x3, + personalAccessToken: IconPasswordUser, topic: IconMessage, } satisfies Record; diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx index dcef7488d..06c66619a 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx @@ -21,7 +21,13 @@ import { z } from "zod"; import { clientApi } from "@homarr/api/client"; import { revalidatePathActionAsync } from "@homarr/common/client"; import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions"; -import { getAllSecretKindOptions, getIconUrl, getIntegrationName, integrationDefs } from "@homarr/definitions"; +import { + getAllSecretKindOptions, + getIconUrl, + getIntegrationDefaultUrl, + getIntegrationName, + integrationDefs, +} from "@homarr/definitions"; import type { UseFormReturnType } from "@homarr/form"; import { useZodForm } from "@homarr/form"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; @@ -54,7 +60,7 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) => const form = useZodForm(formSchema, { initialValues: { name: searchParams.name ?? getIntegrationName(searchParams.kind), - url: searchParams.url ?? "", + url: searchParams.url ?? getIntegrationDefaultUrl(searchParams.kind) ?? "", secrets: secretKinds[0].map((kind) => ({ kind, value: "", diff --git a/packages/api/src/router/integration/integration-router.ts b/packages/api/src/router/integration/integration-router.ts index 5c898fbef..cc0ee8c99 100644 --- a/packages/api/src/router/integration/integration-router.ts +++ b/packages/api/src/router/integration/integration-router.ts @@ -19,6 +19,7 @@ import { getIconUrl, getIntegrationKindsByCategory, getPermissionsWithParents, + integrationCategories, integrationDefs, integrationKinds, integrationSecretKindObject, @@ -129,6 +130,57 @@ export const integrationRouter = createTRPCRouter({ integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind), ); }), + allOfGivenCategory: publicProcedure + .input( + z.object({ + category: z.enum(integrationCategories), + }), + ) + .query(async ({ ctx, input }) => { + const groupsOfCurrentUser = await ctx.db.query.groupMembers.findMany({ + where: eq(groupMembers.userId, ctx.session?.user.id ?? ""), + }); + + const intergrationKinds = getIntegrationKindsByCategory(input.category); + + const integrationsFromDb = await ctx.db.query.integrations.findMany({ + with: { + userPermissions: { + where: eq(integrationUserPermissions.userId, ctx.session?.user.id ?? ""), + }, + groupPermissions: { + where: inArray( + integrationGroupPermissions.groupId, + groupsOfCurrentUser.map((group) => group.groupId), + ), + }, + }, + where: inArray(integrations.kind, intergrationKinds), + }); + return integrationsFromDb + .map((integration) => { + const permissions = integration.userPermissions + .map(({ permission }) => permission) + .concat(integration.groupPermissions.map(({ permission }) => permission)); + + return { + id: integration.id, + name: integration.name, + kind: integration.kind, + url: integration.url, + permissions: { + hasUseAccess: + permissions.includes("use") || permissions.includes("interact") || permissions.includes("full"), + hasInteractAccess: permissions.includes("interact") || permissions.includes("full"), + hasFullAccess: permissions.includes("full"), + }, + }; + }) + .sort( + (integrationA, integrationB) => + integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind), + ); + }), search: protectedProcedure .input(z.object({ query: z.string(), limit: z.number().min(1).max(100).default(10) })) .query(async ({ ctx, input }) => { diff --git a/packages/api/src/router/widgets/releases.ts b/packages/api/src/router/widgets/releases.ts index 5d90911bb..6402586c6 100644 --- a/packages/api/src/router/widgets/releases.ts +++ b/packages/api/src/router/widgets/releases.ts @@ -1,8 +1,10 @@ import { escapeForRegEx } from "@tiptap/react"; import { z } from "zod"; +import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { releasesRequestHandler } from "@homarr/request-handler/releases"; +import { createOneIntegrationMiddleware } from "../../middlewares/integration"; import { createTRPCRouter, publicProcedure } from "../../trpc"; const formatVersionFilterRegex = (versionFilter: z.infer | undefined) => { @@ -23,31 +25,31 @@ const releaseVersionFilterSchema = z.object({ export const releasesRouter = createTRPCRouter({ getLatest: publicProcedure + .concat(createOneIntegrationMiddleware("query", ...getIntegrationKindsByCategory("releasesProvider"))) .input( z.object({ repositories: z.array( z.object({ - providerKey: z.string(), + id: z.string(), identifier: z.string(), versionFilter: releaseVersionFilterSchema.optional(), }), ), }), ) - .query(async ({ input }) => { - const result = await Promise.all( + .query(async ({ ctx, input }) => { + return await Promise.all( input.repositories.map(async (repository) => { - const innerHandler = releasesRequestHandler.handler({ - providerKey: repository.providerKey, + const innerHandler = releasesRequestHandler.handler(ctx.integration, { + id: repository.id, identifier: repository.identifier, versionRegex: formatVersionFilterRegex(repository.versionFilter), }); + return await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false, }); }), ); - - return result; }), }); diff --git a/packages/auth/permissions/integration-provider.tsx b/packages/auth/permissions/integration-provider.tsx index f1f8eef9a..f63fd3c3d 100644 --- a/packages/auth/permissions/integration-provider.tsx +++ b/packages/auth/permissions/integration-provider.tsx @@ -3,12 +3,14 @@ import type { PropsWithChildren } from "react"; import { createContext, useContext } from "react"; +import type { IntegrationKind } from "@homarr/definitions"; + interface IntegrationContextProps { integrations: { id: string; name: string; url: string; - kind: string; + kind: IntegrationKind; permissions: { hasFullAccess: boolean; hasInteractAccess: boolean; diff --git a/packages/common/package.json b/packages/common/package.json index 0183b2f0a..648e75db9 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -29,6 +29,7 @@ "dependencies": { "@homarr/env": "workspace:^0.1.0", "@homarr/log": "workspace:^0.1.0", + "@paralleldrive/cuid2": "^2.2.2", "dayjs": "^1.11.13", "next": "15.3.5", "react": "19.1.0", diff --git a/packages/common/src/id.ts b/packages/common/src/id.ts new file mode 100644 index 000000000..72b8e27b0 --- /dev/null +++ b/packages/common/src/id.ts @@ -0,0 +1 @@ +export { createId } from "@paralleldrive/cuid2"; diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index d8910a866..7ffe71664 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -5,6 +5,7 @@ export * from "./array"; export * from "./date"; export * from "./stopwatch"; export * from "./hooks"; +export * from "./id"; export * from "./url"; export * from "./number"; export * from "./error"; diff --git a/packages/db/migrations/custom/0000_release_widget_provider_to_options.ts b/packages/db/migrations/custom/0000_release_widget_provider_to_options.ts new file mode 100644 index 000000000..951831482 --- /dev/null +++ b/packages/db/migrations/custom/0000_release_widget_provider_to_options.ts @@ -0,0 +1,72 @@ +import SuperJSON from "superjson"; + +import { createId } from "@homarr/common"; +import type { IntegrationKind } from "@homarr/definitions"; +import { getIntegrationKindsByCategory } from "@homarr/definitions"; + +import { eq } from "../.."; +import type { Database } from "../.."; +import { items } from "../../schema"; + +export async function migrateReleaseWidgetProviderToOptionsAsync(db: Database) { + const existingItems = await db.query.items.findMany({ + where: (items, { eq }) => eq(items.kind, "releases"), + }); + + const integrationKinds = getIntegrationKindsByCategory("releasesProvider"); + const providerIntegrations = await db.query.integrations.findMany({ + where: (integrations, { inArray }) => inArray(integrations.kind, integrationKinds), + columns: { + id: true, + kind: true, + }, + }); + + const providerIntegrationMap = new Map( + providerIntegrations.map((integration) => [integration.kind, integration.id]), + ); + + const updates: { + id: string; + options: object; + }[] = []; + for (const item of existingItems) { + const options = SuperJSON.parse(item.options); + if (!("repositories" in options)) continue; + if (!Array.isArray(options.repositories)) continue; + if (options.repositories.length === 0) continue; + if (!options.repositories.some((repository) => "providerKey" in repository)) continue; + + const updatedRepositories = options.repositories.map( + ({ providerKey, ...otherFields }: { providerKey: string; [key: string]: unknown }) => { + // Ensure providerKey is camelCase + const provider = providerKey.charAt(0).toLowerCase() + providerKey.slice(1); + + return { + id: createId(), + providerIntegrationId: providerIntegrationMap.get(provider as IntegrationKind) ?? null, + ...otherFields, + }; + }, + ); + + updates.push({ + id: item.id, + options: { + ...options, + repositories: updatedRepositories, + }, + }); + } + + for (const update of updates) { + await db + .update(items) + .set({ + options: SuperJSON.stringify(update.options), + }) + .where(eq(items.id, update.id)); + } + + console.log(`Migrated release widget providers to integrations count="${updates.length}"`); +} diff --git a/packages/db/migrations/custom/index.ts b/packages/db/migrations/custom/index.ts new file mode 100644 index 000000000..e60a52c5e --- /dev/null +++ b/packages/db/migrations/custom/index.ts @@ -0,0 +1,6 @@ +import type { Database } from "../.."; +import { migrateReleaseWidgetProviderToOptionsAsync } from "./0000_release_widget_provider_to_options"; + +export const applyCustomMigrationsAsync = async (db: Database) => { + await migrateReleaseWidgetProviderToOptionsAsync(db); +}; diff --git a/packages/db/migrations/custom/run-custom.ts b/packages/db/migrations/custom/run-custom.ts new file mode 100644 index 000000000..659a9d6c3 --- /dev/null +++ b/packages/db/migrations/custom/run-custom.ts @@ -0,0 +1,12 @@ +import { applyCustomMigrationsAsync } from "."; +import { database } from "../../driver"; + +applyCustomMigrationsAsync(database) + .then(() => { + console.log("Custom migrations applied successfully"); + process.exit(0); + }) + .catch((err) => { + console.log("Failed to apply custom migrations\n\t", err); + process.exit(1); + }); diff --git a/packages/db/migrations/mysql/migrate.ts b/packages/db/migrations/mysql/migrate.ts index 4b2616c49..afca2d7d4 100644 --- a/packages/db/migrations/mysql/migrate.ts +++ b/packages/db/migrations/mysql/migrate.ts @@ -5,6 +5,7 @@ import mysql from "mysql2"; import type { Database } from "../.."; import { env } from "../../env"; import * as mysqlSchema from "../../schema/mysql"; +import { applyCustomMigrationsAsync } from "../custom"; import { seedDataAsync } from "../seed"; const migrationsFolder = process.argv[2] ?? "."; @@ -30,6 +31,7 @@ const migrateAsync = async () => { await migrate(db, { migrationsFolder }); await seedDataAsync(db as unknown as Database); + await applyCustomMigrationsAsync(db as unknown as Database); }; migrateAsync() diff --git a/packages/db/migrations/seed.ts b/packages/db/migrations/seed.ts index cb4fac094..f9c425ed0 100644 --- a/packages/db/migrations/seed.ts +++ b/packages/db/migrations/seed.ts @@ -1,5 +1,11 @@ import { objectKeys } from "@homarr/common"; -import { createDocumentationLink, everyoneGroup } from "@homarr/definitions"; +import { + createDocumentationLink, + everyoneGroup, + getIntegrationDefaultUrl, + getIntegrationName, + integrationKinds, +} from "@homarr/definitions"; import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings"; import type { Database } from ".."; @@ -9,7 +15,8 @@ import { insertServerSettingByKeyAsync, updateServerSettingByKeyAsync, } from "../queries/server-setting"; -import { onboarding, searchEngines } from "../schema"; +import { integrations, onboarding, searchEngines } from "../schema"; +import type { Integration } from "../schema"; import { groups } from "../schema/mysql"; export const seedDataAsync = async (db: Database) => { @@ -17,6 +24,7 @@ export const seedDataAsync = async (db: Database) => { await seedOnboardingAsync(db); await seedServerSettingsAsync(db); await seedDefaultSearchEnginesAsync(db); + await seedDefaultIntegrationsAsync(db); }; const seedEveryoneGroupAsync = async (db: Database) => { @@ -131,3 +139,53 @@ const seedServerSettingsAsync = async (db: Database) => { console.log(`Updated serverSetting through seed key=${settingsKey}`); } }; + +const seedDefaultIntegrationsAsync = async (db: Database) => { + const defaultIntegrations = integrationKinds.reduce((acc, kind) => { + const name = getIntegrationName(kind); + const defaultUrl = getIntegrationDefaultUrl(kind); + + if (defaultUrl !== undefined) { + acc.push({ + id: "new", + name: `${name} Default`, + url: defaultUrl, + kind, + }); + } + + return acc; + }, []); + + if (defaultIntegrations.length === 0) { + console.warn("No default integrations found to seed"); + return; + } + + let createdCount = 0; + await Promise.all( + defaultIntegrations.map(async (integration) => { + const existingKind = await db.$count(integrations, eq(integrations.kind, integration.kind)); + + if (existingKind > 0) { + console.log(`Skipping seeding of default ${integration.kind} integration as one already exists`); + return; + } + + const newIntegration = { + ...integration, + id: createId(), + }; + + await db.insert(integrations).values(newIntegration); + createdCount++; + }), + ); + + if (createdCount === 0) { + console.log("No default integrations were created as they already exist"); + return; + } + + console.log(`Created ${createdCount} default integrations through seeding process`); +}; diff --git a/packages/db/migrations/sqlite/migrate.ts b/packages/db/migrations/sqlite/migrate.ts index e075a8d14..90d745746 100644 --- a/packages/db/migrations/sqlite/migrate.ts +++ b/packages/db/migrations/sqlite/migrate.ts @@ -4,6 +4,7 @@ import { migrate } from "drizzle-orm/better-sqlite3/migrator"; import { env } from "../../env"; import * as sqliteSchema from "../../schema/sqlite"; +import { applyCustomMigrationsAsync } from "../custom"; import { seedDataAsync } from "../seed"; const migrationsFolder = process.argv[2] ?? "."; @@ -16,6 +17,7 @@ const migrateAsync = async () => { migrate(db, { migrationsFolder }); await seedDataAsync(db); + await applyCustomMigrationsAsync(db); }; migrateAsync() diff --git a/packages/db/package.json b/packages/db/package.json index d8e11ace0..b521f861f 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -23,12 +23,13 @@ "clean": "rm -rf .turbo node_modules", "format": "prettier --check . --ignore-path ../../.gitignore", "lint": "eslint", + "migration:custom": "pnpm with-env tsx ./migrations/custom/run-custom.ts", "migration:mysql:drop": "pnpm with-env drizzle-kit drop --config ./configs/mysql.config.ts", "migration:mysql:generate": "pnpm with-env drizzle-kit generate --config ./configs/mysql.config.ts", - "migration:mysql:run": "pnpm with-env drizzle-kit migrate --config ./configs/mysql.config.ts && pnpm run seed", + "migration:mysql:run": "pnpm with-env drizzle-kit migrate --config ./configs/mysql.config.ts && pnpm run seed && pnpm run migration:custom", "migration:sqlite:drop": "pnpm with-env drizzle-kit drop --config ./configs/sqlite.config.ts", "migration:sqlite:generate": "pnpm with-env drizzle-kit generate --config ./configs/sqlite.config.ts", - "migration:sqlite:run": "pnpm with-env drizzle-kit migrate --config ./configs/sqlite.config.ts && pnpm run seed", + "migration:sqlite:run": "pnpm with-env drizzle-kit migrate --config ./configs/sqlite.config.ts && pnpm run seed && pnpm run migration:custom", "push:mysql": "pnpm with-env drizzle-kit push --config ./configs/mysql.config.ts", "push:sqlite": "pnpm with-env drizzle-kit push --config ./configs/sqlite.config.ts", "seed": "pnpm with-env tsx ./migrations/run-seed.ts", @@ -52,7 +53,8 @@ "drizzle-kit": "^0.31.4", "drizzle-orm": "^0.44.2", "drizzle-zod": "^0.7.1", - "mysql2": "3.14.2" + "mysql2": "3.14.2", + "superjson": "2.2.2" }, "devDependencies": { "@homarr/eslint-config": "workspace:^0.2.0", diff --git a/packages/definitions/src/integration.ts b/packages/definitions/src/integration.ts index ca3fdc3da..1506182f1 100644 --- a/packages/definitions/src/integration.ts +++ b/packages/definitions/src/integration.ts @@ -7,6 +7,7 @@ export const integrationSecretKindObject = { password: { isPublic: false }, tokenId: { isPublic: true }, realm: { isPublic: true }, + personalAccessToken: { isPublic: false }, topic: { isPublic: true }, } satisfies Record; @@ -17,6 +18,7 @@ interface integrationDefinition { iconUrl: string; secretKinds: AtLeastOneOf; // at least one secret kind set is required category: AtLeastOneOf; + defaultUrl?: string; // optional default URL for the integration } export const integrationDefs = { @@ -170,6 +172,41 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/unifi.png", category: ["networkController"], }, + github: { + name: "Github", + secretKinds: [[], ["personalAccessToken"]], + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/github.svg", + category: ["releasesProvider"], + defaultUrl: "https://api.github.com", + }, + dockerHub: { + name: "Docker Hub", + secretKinds: [[], ["username", "personalAccessToken"]], + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/docker.svg", + category: ["releasesProvider"], + defaultUrl: "https://hub.docker.com", + }, + gitlab: { + name: "Gitlab", + secretKinds: [[], ["personalAccessToken"]], + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/gitlab.svg", + category: ["releasesProvider"], + defaultUrl: "https://gitlab.com", + }, + npm: { + name: "NPM", + secretKinds: [[]], + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/npm.svg", + category: ["releasesProvider"], + defaultUrl: "https://registry.npmjs.org", + }, + codeberg: { + name: "Codeberg", + secretKinds: [[], ["personalAccessToken"]], + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/codeberg.svg", + category: ["releasesProvider"], + defaultUrl: "https://codeberg.org", + }, ntfy: { name: "ntfy", secretKinds: [["topic"], ["topic", "apiKey"]], @@ -209,6 +246,11 @@ export const getDefaultSecretKinds = (integration: IntegrationKind): Integration export const getAllSecretKindOptions = (integration: IntegrationKind): AtLeastOneOf => integrationDefs[integration].secretKinds; +export const getIntegrationDefaultUrl = (integration: IntegrationKind) => { + const definition = integrationDefs[integration]; + return "defaultUrl" in definition ? definition.defaultUrl : undefined; +}; + /** * Get all integration kinds that share a category, typed only by the kinds belonging to the category * @param category Category to filter by, belonging to IntegrationCategory @@ -234,20 +276,25 @@ export type IntegrationKindByCategory = { export type IntegrationSecretKind = keyof typeof integrationSecretKindObject; export type IntegrationKind = keyof typeof integrationDefs; -export type IntegrationCategory = - | "dnsHole" - | "mediaService" - | "calendar" - | "mediaSearch" - | "mediaRequest" - | "downloadClient" - | "usenet" - | "torrent" - | "miscellaneous" - | "smartHomeServer" - | "indexerManager" - | "healthMonitoring" - | "search" - | "mediaTranscoding" - | "networkController" - | "notifications"; + +export const integrationCategories = [ + "dnsHole", + "mediaService", + "calendar", + "mediaSearch", + "mediaRequest", + "downloadClient", + "usenet", + "torrent", + "miscellaneous", + "smartHomeServer", + "indexerManager", + "healthMonitoring", + "search", + "mediaTranscoding", + "networkController", + "releasesProvider", + "notifications", +] as const; + +export type IntegrationCategory = (typeof integrationCategories)[number]; diff --git a/packages/integrations/package.json b/packages/integrations/package.json index c44105ef2..e00f211d9 100644 --- a/packages/integrations/package.json +++ b/packages/integrations/package.json @@ -28,6 +28,7 @@ "@ctrl/deluge": "^7.1.0", "@ctrl/qbittorrent": "^9.6.0", "@ctrl/transmission": "^7.2.0", + "@gitbeaker/rest": "^42.5.0", "@homarr/certificates": "workspace:^0.1.0", "@homarr/common": "workspace:^0.1.0", "@homarr/db": "workspace:^0.1.0", @@ -40,6 +41,7 @@ "@jellyfin/sdk": "^0.11.0", "maria2": "^0.4.1", "node-ical": "^0.20.1", + "octokit": "^5.0.3", "proxmox-api": "1.1.1", "tsdav": "^2.1.5", "undici": "7.11.0", diff --git a/packages/integrations/src/base/creator.ts b/packages/integrations/src/base/creator.ts index b1f7cff56..dda1b03a5 100644 --- a/packages/integrations/src/base/creator.ts +++ b/packages/integrations/src/base/creator.ts @@ -4,7 +4,9 @@ import type { Integration as DbIntegration } from "@homarr/db/schema"; import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions"; import { AdGuardHomeIntegration } from "../adguard-home/adguard-home-integration"; +import { CodebergIntegration } from "../codeberg/codeberg-integration"; import { DashDotIntegration } from "../dashdot/dashdot-integration"; +import { DockerHubIntegration } from "../docker-hub/docker-hub-integration"; import { Aria2Integration } from "../download-client/aria2/aria2-integration"; import { DelugeIntegration } from "../download-client/deluge/deluge-integration"; import { NzbGetIntegration } from "../download-client/nzbget/nzbget-integration"; @@ -12,6 +14,8 @@ 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 { 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"; @@ -22,6 +26,7 @@ import { SonarrIntegration } from "../media-organizer/sonarr/sonarr-integration" import { TdarrIntegration } from "../media-transcoding/tdarr-integration"; import { MockIntegration } from "../mock/mock-integration"; import { NextcloudIntegration } from "../nextcloud/nextcloud.integration"; +import { NPMIntegration } from "../npm/npm-integration"; import { NTFYIntegration } from "../ntfy/ntfy-integration"; import { OpenMediaVaultIntegration } from "../openmediavault/openmediavault-integration"; import { OverseerrIntegration } from "../overseerr/overseerr-integration"; @@ -94,6 +99,11 @@ export const integrationCreators = { emby: EmbyIntegration, nextcloud: NextcloudIntegration, unifiController: UnifiControllerIntegration, + github: GithubIntegration, + dockerHub: DockerHubIntegration, + gitlab: GitlabIntegration, + npm: NPMIntegration, + codeberg: CodebergIntegration, 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 new file mode 100644 index 000000000..08adfabc7 --- /dev/null +++ b/packages/integrations/src/codeberg/codeberg-integration.ts @@ -0,0 +1,143 @@ +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 { + DetailsProviderResponse, + ReleasesRepository, + ReleasesResponse, +} from "../interfaces/releases-providers/releases-providers-types"; +import { detailsResponseSchema, releasesResponseSchema } from "./codeberg-schemas"; + +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({}); + + 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("/version"), { + 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 Codeberg integration`, + { + identifier: repository.identifier, + }, + ); + return { + id: repository.id, + error: { code: "invalidIdentifier" }, + }; + } + + const details = await this.getDetailsAsync(owner, name); + + const releasesResponse = await this.withHeadersAsync(async (headers) => { + return fetchWithTrustedCertificatesAsync( + this.url(`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/releases`), + { 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: releasesResponseJson ? JSON.stringify(releasesResponseJson, null, 2) : error.message, + }, + }; + } else { + const formattedReleases = data.map((tag) => ({ + latestRelease: tag.tag_name, + latestReleaseAt: tag.published_at, + releaseUrl: tag.url, + releaseDescription: tag.body, + isPreRelease: tag.prerelease, + })); + return getLatestRelease(formattedReleases, repository, details); + } + } + + protected async getDetailsAsync(owner: string, name: string): Promise { + const response = await this.withHeadersAsync(async (headers) => { + return await fetchWithTrustedCertificatesAsync( + this.url(`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`), + { + headers, + }, + ); + }); + + if (!response.ok) { + localLogger.warn(`Failed to get details response for ${owner}/${name} with Codeberg integration`, { + owner, + name, + error: response.statusText, + }); + + return undefined; + } + + const responseJson = await response.json(); + const { data, success, error } = detailsResponseSchema.safeParse(responseJson); + + if (!success) { + localLogger.warn(`Failed to parse details response for ${owner}/${name} with Codeberg integration`, { + owner, + name, + error, + }); + + return undefined; + } + + return { + projectUrl: data.html_url, + projectDescription: data.description, + isFork: data.fork, + isArchived: data.archived, + createdAt: data.created_at, + starsCount: data.stars_count, + openIssues: data.open_issues_count, + forksCount: data.forks_count, + }; + } +} diff --git a/packages/integrations/src/codeberg/codeberg-schemas.ts b/packages/integrations/src/codeberg/codeberg-schemas.ts new file mode 100644 index 000000000..1a6dafb39 --- /dev/null +++ b/packages/integrations/src/codeberg/codeberg-schemas.ts @@ -0,0 +1,22 @@ +import { z } from "zod"; + +export const releasesResponseSchema = z.array( + z.object({ + tag_name: z.string(), + published_at: z.string().transform((value) => new Date(value)), + url: z.string(), + body: z.string(), + prerelease: z.boolean(), + }), +); + +export const detailsResponseSchema = z.object({ + html_url: z.string(), + description: z.string(), + fork: z.boolean(), + archived: z.boolean(), + created_at: z.string().transform((value) => new Date(value)), + stars_count: z.number(), + open_issues_count: z.number(), + forks_count: z.number(), +}); diff --git a/packages/integrations/src/docker-hub/docker-hub-integration.ts b/packages/integrations/src/docker-hub/docker-hub-integration.ts new file mode 100644 index 000000000..2affdf89d --- /dev/null +++ b/packages/integrations/src/docker-hub/docker-hub-integration.ts @@ -0,0 +1,190 @@ +import type { fetch, RequestInit, Response } from "undici"; + +import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server"; +import { ResponseError } from "@homarr/common/server"; +import { logger } from "@homarr/log"; + +import type { IntegrationInput, IntegrationTestingInput } from "../base/integration"; +import { Integration } from "../base/integration"; +import type { SessionStore } from "../base/session-store"; +import { createSessionStore } from "../base/session-store"; +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, + ReleasesRepository, + ReleasesResponse, +} from "../interfaces/releases-providers/releases-providers-types"; +import { accessTokenResponseSchema, detailsResponseSchema, releasesResponseSchema } from "./docker-hub-schemas"; + +const localLogger = logger.child({ module: "DockerHubIntegration" }); + +export class DockerHubIntegration extends Integration implements ReleasesProviderIntegration { + private readonly sessionStore: SessionStore; + + constructor(integration: IntegrationInput) { + super(integration); + this.sessionStore = createSessionStore(integration); + } + + private async withHeadersAsync(callback: (headers: RequestInit["headers"]) => Promise): Promise { + if (!this.hasSecretValue("username") || !this.hasSecretValue("personalAccessToken")) return await callback({}); + + const storedSession = await this.sessionStore.getAsync(); + + if (storedSession) { + localLogger.debug("Using stored session for request", { integrationId: this.integration.id }); + const response = await callback({ + Authorization: `Bearer ${storedSession}`, + }); + if (response.status !== 401) { + return response; + } + + localLogger.debug("Session expired, getting new session", { integrationId: this.integration.id }); + } + + const accessToken = await this.getSessionAsync(); + await this.sessionStore.setAsync(accessToken); + return await callback({ + Authorization: `Bearer ${accessToken}`, + }); + } + + protected async testingAsync(input: IntegrationTestingInput): Promise { + const hasAuth = this.hasSecretValue("username") && this.hasSecretValue("personalAccessToken"); + + if (hasAuth) { + localLogger.debug("Testing DockerHub connection with authentication", { integrationId: this.integration.id }); + await this.getSessionAsync(input.fetchAsync); + } else { + localLogger.debug("Testing DockerHub connection without authentication", { integrationId: this.integration.id }); + const response = await input.fetchAsync(this.url("/v2/repositories/library")); + if (!response.ok) { + return TestConnectionError.StatusResult(response); + } + } + + return { + success: true, + }; + } + + public async getLatestMatchingReleaseAsync(repository: ReleasesRepository): Promise { + const relativeUrl = this.getRelativeUrl(repository.identifier); + if (relativeUrl === "/") { + localLogger.warn( + `Invalid identifier format. Expected 'owner/name' or 'name', for ${repository.identifier} on DockerHub`, + { + identifier: repository.identifier, + }, + ); + return { + id: repository.id, + error: { code: "invalidIdentifier" }, + }; + } + + const details = await this.getDetailsAsync(relativeUrl); + + const releasesResponse = await this.withHeadersAsync(async (headers) => { + return await fetchWithTrustedCertificatesAsync(this.url(`${relativeUrl}/tags?page_size=100`), { + headers, + }); + }); + + if (!releasesResponse.ok) { + return { + id: repository.id, + error: { message: releasesResponse.statusText }, + }; + } + + const releasesResponseJson: unknown = await releasesResponse.json(); + const releasesResult = releasesResponseSchema.safeParse(releasesResponseJson); + + if (!releasesResult.success) { + return { + id: repository.id, + error: { + message: releasesResponseJson ? JSON.stringify(releasesResponseJson, null, 2) : releasesResult.error.message, + }, + }; + } else { + return getLatestRelease(releasesResult.data.results, repository, details); + } + } + + private async getDetailsAsync(relativeUrl: `/${string}`): Promise { + const response = await this.withHeadersAsync(async (headers) => { + return await fetchWithTrustedCertificatesAsync(this.url(`${relativeUrl}/`), { + headers, + }); + }); + + if (!response.ok) { + localLogger.warn(`Failed to get details response for ${relativeUrl} with DockerHub integration`, { + relativeUrl, + error: response.statusText, + }); + + return undefined; + } + + const responseJson = await response.json(); + const { data, success, error } = detailsResponseSchema.safeParse(responseJson); + + if (!success) { + localLogger.warn(`Failed to parse details response for ${relativeUrl} with DockerHub integration`, { + relativeUrl, + error, + }); + + return undefined; + } + + return { + projectUrl: `https://hub.docker.com/r/${data.namespace === "library" ? "_" : data.namespace}/${data.name}`, + projectDescription: data.description, + createdAt: data.date_registered, + starsCount: data.star_count, + }; + } + + private getRelativeUrl(identifier: string): `/${string}` { + if (identifier.indexOf("/") > 0) { + const [owner, name] = identifier.split("/"); + if (!owner || !name) { + return "/"; + } + return `/v2/namespaces/${encodeURIComponent(owner)}/repositories/${encodeURIComponent(name)}`; + } else { + return `/v2/repositories/library/${encodeURIComponent(identifier)}`; + } + } + + private async getSessionAsync(fetchAsync: typeof fetch = fetchWithTrustedCertificatesAsync): Promise { + const response = await fetchAsync(this.url("/v2/auth/token"), { + method: "POST", + body: JSON.stringify({ + identifier: this.getSecretValue("username"), + secret: this.getSecretValue("personalAccessToken"), + }), + }); + + if (!response.ok) throw new ResponseError(response); + + const data = await response.json(); + const result = await accessTokenResponseSchema.parseAsync(data); + + if (!result.access_token) { + throw new ResponseError({ status: 401, url: response.url }); + } + + localLogger.info("Received session successfully", { integrationId: this.integration.id }); + + return result.access_token; + } +} diff --git a/packages/integrations/src/docker-hub/docker-hub-schemas.ts b/packages/integrations/src/docker-hub/docker-hub-schemas.ts new file mode 100644 index 000000000..02b184383 --- /dev/null +++ b/packages/integrations/src/docker-hub/docker-hub-schemas.ts @@ -0,0 +1,22 @@ +import { z } from "zod"; + +export const accessTokenResponseSchema = z.object({ + access_token: z.string(), +}); + +export const releasesResponseSchema = z.object({ + results: z.array( + z.object({ name: z.string(), last_updated: z.string().transform((value) => new Date(value)) }).transform((tag) => ({ + latestRelease: tag.name, + latestReleaseAt: tag.last_updated, + })), + ), +}); + +export const detailsResponseSchema = z.object({ + name: z.string(), + namespace: z.string(), + description: z.string(), + star_count: z.number(), + date_registered: z.string().transform((value) => new Date(value)), +}); diff --git a/packages/integrations/src/github/github-integration.ts b/packages/integrations/src/github/github-integration.ts new file mode 100644 index 000000000..97689518e --- /dev/null +++ b/packages/integrations/src/github/github-integration.ts @@ -0,0 +1,152 @@ +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: "GithubIntegration" }); + +export class GithubIntegration extends Integration implements ReleasesProviderIntegration { + private static readonly userAgent = "Homarr-Lab/Homarr:GithubIntegration"; + + protected async testingAsync(input: IntegrationTestingInput): Promise { + const headers: RequestInit["headers"] = { + "User-Agent": GithubIntegration.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 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.repos.listReleases({ + owner, + repo: name, + }); + + if (releasesResponse.data.length === 0) { + localLogger.warn(`No releases found, for ${repository.identifier} with Github integration`, { + identifier: repository.identifier, + }); + return { + id: repository.id, + error: { code: "noReleasesFound" }, + }; + } + + const releasesProviderResponse = releasesResponse.data.reduce((acc, release) => { + if (!release.published_at) return acc; + + acc.push({ + latestRelease: release.tag_name, + latestReleaseAt: new Date(release.published_at), + releaseUrl: release.html_url, + releaseDescription: release.body ?? undefined, + isPreRelease: release.prerelease, + }); + 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 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.repos.get({ + owner, + repo: name, + }); + + return { + projectUrl: response.data.html_url, + projectDescription: response.data.description ?? undefined, + isFork: response.data.fork, + isArchived: response.data.archived, + createdAt: new Date(response.data.created_at), + starsCount: response.data.stargazers_count, + openIssues: response.data.open_issues_count, + forksCount: response.data.forks_count, + }; + } catch (error) { + localLogger.warn(`Failed to get details for ${owner}\\${name} with Github 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: GithubIntegration.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/gitlab/gitlab-integration.ts b/packages/integrations/src/gitlab/gitlab-integration.ts new file mode 100644 index 000000000..1e42eea48 --- /dev/null +++ b/packages/integrations/src/gitlab/gitlab-integration.ts @@ -0,0 +1,159 @@ +import type { Gitlab as CoreGitlab } from "@gitbeaker/core"; +import { createRequesterFn, defaultOptionsHandler } from "@gitbeaker/requester-utils"; +import type { FormattedResponse, RequestOptions, ResourceOptions } from "@gitbeaker/requester-utils"; +import { Gitlab } from "@gitbeaker/rest"; + +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: "GitlabIntegration" }); + +export class GitlabIntegration extends Integration implements ReleasesProviderIntegration { + protected async testingAsync(input: IntegrationTestingInput): Promise { + const response = await input.fetchAsync(this.url("/api/v4/projects"), { + headers: { + ...(this.hasSecretValue("personalAccessToken") + ? { Authorization: `Bearer ${this.getSecretValue("personalAccessToken")}` } + : {}), + }, + }); + + if (!response.ok) { + return TestConnectionError.StatusResult(response); + } + + return { + success: true, + }; + } + + public async getLatestMatchingReleaseAsync(repository: ReleasesRepository): Promise { + const api = this.getApi(); + + const details = await this.getDetailsAsync(api, repository.identifier); + + try { + const releasesResponse = await api.ProjectReleases.all(repository.identifier, { + perPage: 100, + }); + + if (releasesResponse instanceof Error) { + localLogger.warn(`Failed to get releases for ${repository.identifier} with Gitlab integration`, { + identifier: repository.identifier, + error: releasesResponse.message, + }); + return { + id: repository.id, + error: { code: "noReleasesFound" }, + }; + } + + const releasesProviderResponse = releasesResponse.reduce((acc, release) => { + if (!release.released_at) return acc; + + const releaseDate = new Date(release.released_at); + + acc.push({ + latestRelease: release.name ?? release.tag_name, + latestReleaseAt: releaseDate, + releaseUrl: release._links.self, + releaseDescription: release.description ?? undefined, + isPreRelease: releaseDate > new Date(), // For upcoming releases the `released_at` will be set to the future (https://docs.gitlab.com/api/releases/#upcoming-releases). Gitbreaker doesn't currently support the `upcoming_release` field (https://github.com/jdalrymple/gitbeaker/issues/3730) + }); + return acc; + }, []); + + return getLatestRelease(releasesProviderResponse, repository, details); + } catch (error) { + localLogger.warn(`Failed to get releases for ${repository.identifier} with Gitlab integration`, { + identifier: repository.identifier, + error: error instanceof Error ? error.message : String(error), + }); + + return { + id: repository.id, + error: { code: "noReleasesFound" }, + }; + } + } + + protected async getDetailsAsync(api: CoreGitlab, identifier: string): Promise { + try { + const response = await api.Projects.show(identifier); + + if (response instanceof Error) { + localLogger.warn(`Failed to get details for ${identifier} with Gitlab integration`, { + identifier, + error: response.message, + }); + + return undefined; + } + + if (!response.web_url) { + localLogger.warn(`No web URL found for ${identifier} with Gitlab integration`, { + identifier, + }); + return undefined; + } + + return { + projectUrl: response.web_url, + projectDescription: response.description, + isFork: response.forked_from_project !== null, + isArchived: response.archived, + createdAt: new Date(response.created_at), + starsCount: response.star_count, + openIssues: response.open_issues_count, + forksCount: response.forks_count, + }; + } catch (error) { + localLogger.warn(`Failed to get details for ${identifier} with Gitlab integration`, { + identifier, + error: error instanceof Error ? error.message : String(error), + }); + + return undefined; + } + } + + private getApi() { + return new Gitlab({ + host: this.url("/").origin, + requesterFn: createRequesterFn( + async (serviceOptions: ResourceOptions, _: RequestOptions) => await defaultOptionsHandler(serviceOptions), + async (endpoint: string, options?: Record): Promise => { + if (options === undefined) { + throw new Error("Gitlab library is not configured correctly. Options must be provided."); + } + + const response = await fetchWithTrustedCertificatesAsync( + `${options.prefixUrl as string}${endpoint}`, + options, + ); + const headers = Object.fromEntries(response.headers.entries()); + + return { + status: response.status, + headers, + body: await response.json(), + } as FormattedResponse; + }, + ), + ...(this.hasSecretValue("personalAccessToken") ? { token: this.getSecretValue("personalAccessToken") } : {}), + }); + } +} diff --git a/packages/integrations/src/index.ts b/packages/integrations/src/index.ts index b697ce852..d11232186 100644 --- a/packages/integrations/src/index.ts +++ b/packages/integrations/src/index.ts @@ -27,6 +27,7 @@ export type { IntegrationInput } from "./base/integration"; export type { DownloadClientJobsAndStatus } from "./interfaces/downloads/download-client-data"; export type { ExtendedDownloadClientItem } from "./interfaces/downloads/download-client-items"; export type { ExtendedClientStatus } from "./interfaces/downloads/download-client-status"; + export type { SystemHealthMonitoring } from "./interfaces/health-monitoring/health-monitoring-types"; export { MediaRequestStatus } from "./interfaces/media-requests/media-request-types"; export type { MediaRequestList, MediaRequestStats } from "./interfaces/media-requests/media-request-types"; @@ -37,6 +38,7 @@ export type { TdarrStatistics, TdarrWorker, } from "./interfaces/media-transcoding/media-transcoding-types"; +export type { ReleasesResponse } from "./interfaces/releases-providers/releases-providers-types"; export type { Notification } from "./interfaces/notifications/notification-types"; // Schemas diff --git a/packages/integrations/src/interfaces/releases-providers/releases-providers-integration.ts b/packages/integrations/src/interfaces/releases-providers/releases-providers-integration.ts new file mode 100644 index 000000000..442c607f1 --- /dev/null +++ b/packages/integrations/src/interfaces/releases-providers/releases-providers-integration.ts @@ -0,0 +1,47 @@ +import type { + DetailsProviderResponse, + ReleaseProviderResponse, + ReleasesRepository, + ReleasesResponse, +} from "./releases-providers-types"; + +export const getLatestRelease = ( + releases: ReleaseProviderResponse[], + repository: ReleasesRepository, + details?: DetailsProviderResponse, +): ReleasesResponse => { + const validReleases = releases.filter((result) => { + if (result.latestRelease) { + return repository.versionRegex ? new RegExp(repository.versionRegex).test(result.latestRelease) : true; + } + + return true; + }); + + const latest = + validReleases.length === 0 + ? ({ + id: repository.id, + error: { code: "noMatchingVersion" }, + } as ReleasesResponse) + : validReleases.reduce( + (latest, result) => { + return { + ...details, + ...(result.latestReleaseAt > latest.latestReleaseAt ? result : latest), + id: repository.id, + }; + }, + { + id: "", + latestRelease: "", + latestReleaseAt: new Date(0), + }, + ); + + return latest; +}; + +export interface ReleasesProviderIntegration { + getLatestMatchingReleaseAsync(repository: ReleasesRepository): Promise; +} diff --git a/packages/integrations/src/interfaces/releases-providers/releases-providers-types.ts b/packages/integrations/src/interfaces/releases-providers/releases-providers-types.ts new file mode 100644 index 000000000..335c64dcb --- /dev/null +++ b/packages/integrations/src/interfaces/releases-providers/releases-providers-types.ts @@ -0,0 +1,53 @@ +import type { TranslationObject } from "@homarr/translation"; + +export interface DetailsProviderResponse { + projectUrl?: string; + projectDescription?: string; + isFork?: boolean; + isArchived?: boolean; + createdAt?: Date; + starsCount?: number; + openIssues?: number; + forksCount?: number; +} + +export interface ReleaseProviderResponse { + latestRelease: string; + latestReleaseAt: Date; + releaseUrl?: string; + releaseDescription?: string; + isPreRelease?: boolean; +} + +export interface ReleasesRepository { + id: string; + identifier: string; + versionRegex?: string; +} + +type ReleasesErrorKeys = keyof TranslationObject["widget"]["releases"]["error"]["messages"]; + +export interface ReleasesResponse { + id: string; + latestRelease?: string; + latestReleaseAt?: Date; + releaseUrl?: string; + releaseDescription?: string; + isPreRelease?: boolean; + projectUrl?: string; + projectDescription?: string; + isFork?: boolean; + isArchived?: boolean; + createdAt?: Date; + starsCount?: number; + openIssues?: number; + forksCount?: number; + + error?: + | { + code: ReleasesErrorKeys; + } + | { + message: string; + }; +} diff --git a/packages/integrations/src/npm/npm-integration.ts b/packages/integrations/src/npm/npm-integration.ts new file mode 100644 index 000000000..f82e0ba22 --- /dev/null +++ b/packages/integrations/src/npm/npm-integration.ts @@ -0,0 +1,56 @@ +import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server"; + +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 { ReleasesRepository, ReleasesResponse } from "../interfaces/releases-providers/releases-providers-types"; +import { releasesResponseSchema } from "./npm-schemas"; + +export class NPMIntegration extends Integration implements ReleasesProviderIntegration { + protected async testingAsync(input: IntegrationTestingInput): Promise { + const response = await input.fetchAsync(this.url("/")); + + if (!response.ok) { + return TestConnectionError.StatusResult(response); + } + + return { + success: true, + }; + } + + public async getLatestMatchingReleaseAsync(repository: ReleasesRepository): Promise { + const releasesResponse = await fetchWithTrustedCertificatesAsync( + this.url(`/${encodeURIComponent(repository.identifier)}`), + ); + + 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: releasesResponseJson ? JSON.stringify(releasesResponseJson, null, 2) : error.message, + }, + }; + } else { + const formattedReleases = data.time.map((tag) => ({ + ...tag, + releaseUrl: `https://www.npmjs.com/package/${encodeURIComponent(data.name)}/v/${encodeURIComponent(tag.latestRelease)}`, + releaseDescription: data.versions[tag.latestRelease]?.description ?? "", + })); + return getLatestRelease(formattedReleases, repository); + } + } +} diff --git a/packages/integrations/src/npm/npm-schemas.ts b/packages/integrations/src/npm/npm-schemas.ts new file mode 100644 index 000000000..290cea8ed --- /dev/null +++ b/packages/integrations/src/npm/npm-schemas.ts @@ -0,0 +1,12 @@ +import { z } from "zod"; + +export const releasesResponseSchema = z.object({ + time: z.record(z.string().transform((value) => new Date(value))).transform((version) => + Object.entries(version).map(([key, value]) => ({ + latestRelease: key, + latestReleaseAt: value, + })), + ), + versions: z.record(z.object({ description: z.string() })), + name: z.string(), +}); diff --git a/packages/integrations/src/openmediavault/openmediavault-integration.ts b/packages/integrations/src/openmediavault/openmediavault-integration.ts index 3e2fdd69b..c3b1c6ada 100644 --- a/packages/integrations/src/openmediavault/openmediavault-integration.ts +++ b/packages/integrations/src/openmediavault/openmediavault-integration.ts @@ -155,7 +155,7 @@ export class OpenMediaVaultIntegration extends Integration implements ISystemHea return response; } - localLogger.info("Session expired, getting new session", { integrationId: this.integration.id }); + localLogger.debug("Session expired, getting new session", { integrationId: this.integration.id }); } const session = await this.getSessionAsync(); diff --git a/packages/integrations/src/pi-hole/v6/pi-hole-integration-v6.ts b/packages/integrations/src/pi-hole/v6/pi-hole-integration-v6.ts index 8c863dc0e..5e38648f1 100644 --- a/packages/integrations/src/pi-hole/v6/pi-hole-integration-v6.ts +++ b/packages/integrations/src/pi-hole/v6/pi-hole-integration-v6.ts @@ -128,7 +128,7 @@ export class PiHoleIntegrationV6 extends Integration implements DnsHoleSummaryIn return response; } - localLogger.info("Session expired, getting new session", { integrationId: this.integration.id }); + localLogger.debug("Session expired, getting new session", { integrationId: this.integration.id }); } const sessionId = await this.getSessionAsync(); diff --git a/packages/request-handler/src/releases-providers.ts b/packages/request-handler/src/releases-providers.ts deleted file mode 100644 index bd413ec07..000000000 --- a/packages/request-handler/src/releases-providers.ts +++ /dev/null @@ -1,304 +0,0 @@ -import { z } from "zod"; - -export interface ReleasesProvider { - getDetailsUrl: (identifier: string) => string | undefined; - parseDetailsResponse: (response: unknown) => z.SafeParseReturnType | undefined; - getReleasesUrl: (identifier: string) => string; - parseReleasesResponse: (response: unknown) => z.SafeParseReturnType; -} - -interface ProvidersProps { - [key: string]: ReleasesProvider; - DockerHub: ReleasesProvider; - Github: ReleasesProvider; - Gitlab: ReleasesProvider; - Npm: ReleasesProvider; - Codeberg: ReleasesProvider; -} - -export const Providers: ProvidersProps = { - DockerHub: { - getDetailsUrl(identifier) { - if (identifier.indexOf("/") > 0) { - const [owner, name] = identifier.split("/"); - if (!owner || !name) { - return ""; - } - return `https://hub.docker.com/v2/namespaces/${encodeURIComponent(owner)}/repositories/${encodeURIComponent(name)}`; - } else { - return `https://hub.docker.com/v2/repositories/library/${encodeURIComponent(identifier)}`; - } - }, - parseDetailsResponse(response) { - return z - .object({ - name: z.string(), - namespace: z.string(), - description: z.string(), - star_count: z.number(), - date_registered: z.string().transform((value) => new Date(value)), - }) - .transform((resp) => ({ - projectUrl: `https://hub.docker.com/r/${resp.namespace === "library" ? "_" : resp.namespace}/${resp.name}`, - projectDescription: resp.description, - createdAt: resp.date_registered, - starsCount: resp.star_count, - })) - .safeParse(response); - }, - getReleasesUrl(identifier) { - return `${this.getDetailsUrl(identifier)}/tags?page_size=200`; - }, - parseReleasesResponse(response) { - return z - .object({ - results: z.array( - z - .object({ name: z.string(), last_updated: z.string().transform((value) => new Date(value)) }) - .transform((tag) => ({ - identifier: "", - latestRelease: tag.name, - latestReleaseAt: tag.last_updated, - })), - ), - }) - .transform((resp) => { - return resp.results; - }) - .safeParse(response); - }, - }, - Github: { - getDetailsUrl(identifier) { - const [owner, name] = identifier.split("/"); - if (!owner || !name) { - return ""; - } - return `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`; - }, - parseDetailsResponse(response) { - return z - .object({ - html_url: z.string(), - description: z.string().nullable(), - fork: z.boolean(), - archived: z.boolean(), - created_at: z.string().transform((value) => new Date(value)), - stargazers_count: z.number(), - open_issues_count: z.number(), - forks_count: z.number(), - }) - .transform((resp) => ({ - projectUrl: resp.html_url, - projectDescription: resp.description ?? undefined, - isFork: resp.fork, - isArchived: resp.archived, - createdAt: resp.created_at, - starsCount: resp.stargazers_count, - openIssues: resp.open_issues_count, - forksCount: resp.forks_count, - })) - .safeParse(response); - }, - getReleasesUrl(identifier) { - return `${this.getDetailsUrl(identifier)}/releases`; - }, - parseReleasesResponse(response) { - return z - .array( - z - .object({ - tag_name: z.string(), - published_at: z.string().transform((value) => new Date(value)), - html_url: z.string(), - body: z.string().nullable(), - prerelease: z.boolean(), - }) - .transform((tag) => ({ - identifier: "", - latestRelease: tag.tag_name, - latestReleaseAt: tag.published_at, - releaseUrl: tag.html_url, - releaseDescription: tag.body ?? undefined, - isPreRelease: tag.prerelease, - })), - ) - .safeParse(response); - }, - }, - Gitlab: { - getDetailsUrl(identifier) { - return `https://gitlab.com/api/v4/projects/${encodeURIComponent(identifier)}`; - }, - parseDetailsResponse(response) { - return z - .object({ - web_url: z.string(), - description: z.string(), - forked_from_project: z.object({ id: z.number() }).optional(), - archived: z.boolean().optional(), - created_at: z.string().transform((value) => new Date(value)), - star_count: z.number(), - open_issues_count: z.number().optional(), - forks_count: z.number(), - }) - .transform((resp) => ({ - projectUrl: resp.web_url, - projectDescription: resp.description, - isFork: resp.forked_from_project !== undefined, - isArchived: resp.archived, - createdAt: resp.created_at, - starsCount: resp.star_count, - openIssues: resp.open_issues_count, - forksCount: resp.forks_count, - })) - .safeParse(response); - }, - getReleasesUrl(identifier) { - return `${this.getDetailsUrl(identifier)}/releases`; - }, - parseReleasesResponse(response) { - return z - .array( - z - .object({ - name: z.string(), - released_at: z.string().transform((value) => new Date(value)), - description: z.string(), - _links: z.object({ self: z.string() }), - upcoming_release: z.boolean(), - }) - .transform((tag) => ({ - identifier: "", - latestRelease: tag.name, - latestReleaseAt: tag.released_at, - releaseUrl: tag._links.self, - releaseDescription: tag.description, - isPreRelease: tag.upcoming_release, - })), - ) - .safeParse(response); - }, - }, - Npm: { - getDetailsUrl(_) { - return undefined; - }, - parseDetailsResponse(_) { - return undefined; - }, - getReleasesUrl(identifier) { - return `https://registry.npmjs.org/${encodeURIComponent(identifier)}`; - }, - parseReleasesResponse(response) { - return z - .object({ - time: z.record(z.string().transform((value) => new Date(value))).transform((version) => - Object.entries(version).map(([key, value]) => ({ - identifier: "", - latestRelease: key, - latestReleaseAt: value, - })), - ), - versions: z.record(z.object({ description: z.string() })), - name: z.string(), - }) - .transform((resp) => { - return resp.time.map((release) => ({ - ...release, - releaseUrl: `https://www.npmjs.com/package/${resp.name}/v/${release.latestRelease}`, - releaseDescription: resp.versions[release.latestRelease]?.description ?? "", - })); - }) - .safeParse(response); - }, - }, - Codeberg: { - getDetailsUrl(identifier) { - const [owner, name] = identifier.split("/"); - if (!owner || !name) { - return ""; - } - return `https://codeberg.org/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`; - }, - parseDetailsResponse(response) { - return z - .object({ - html_url: z.string(), - description: z.string(), - fork: z.boolean(), - archived: z.boolean(), - created_at: z.string().transform((value) => new Date(value)), - stars_count: z.number(), - open_issues_count: z.number(), - forks_count: z.number(), - }) - .transform((resp) => ({ - projectUrl: resp.html_url, - projectDescription: resp.description, - isFork: resp.fork, - isArchived: resp.archived, - createdAt: resp.created_at, - starsCount: resp.stars_count, - openIssues: resp.open_issues_count, - forksCount: resp.forks_count, - })) - .safeParse(response); - }, - getReleasesUrl(identifier) { - return `${this.getDetailsUrl(identifier)}/releases`; - }, - parseReleasesResponse(response) { - return z - .array( - z - .object({ - tag_name: z.string(), - published_at: z.string().transform((value) => new Date(value)), - url: z.string(), - body: z.string(), - prerelease: z.boolean(), - }) - .transform((tag) => ({ - latestRelease: tag.tag_name, - latestReleaseAt: tag.published_at, - releaseUrl: tag.url, - releaseDescription: tag.body, - isPreRelease: tag.prerelease, - })), - ) - .safeParse(response); - }, - }, -}; - -const _detailsSchema = z - .object({ - projectUrl: z.string().optional(), - projectDescription: z.string().optional(), - isFork: z.boolean().optional(), - isArchived: z.boolean().optional(), - createdAt: z.date().optional(), - starsCount: z.number().optional(), - openIssues: z.number().optional(), - forksCount: z.number().optional(), - }) - .optional(); - -const _releasesSchema = z.object({ - latestRelease: z.string(), - latestReleaseAt: z.date(), - releaseUrl: z.string().optional(), - releaseDescription: z.string().optional(), - isPreRelease: z.boolean().optional(), - error: z - .object({ - code: z.string().optional(), - message: z.string().optional(), - }) - .optional(), -}); - -export type DetailsResponse = z.infer; - -export type ReleasesResponse = z.infer; diff --git a/packages/request-handler/src/releases.ts b/packages/request-handler/src/releases.ts index 9c5ec8b3f..8eeee1b0e 100644 --- a/packages/request-handler/src/releases.ts +++ b/packages/request-handler/src/releases.ts @@ -1,122 +1,37 @@ import dayjs from "dayjs"; -import { z } from "zod"; -import { fetchWithTimeout } from "@homarr/common"; -import { logger } from "@homarr/log"; +import type { IntegrationKindByCategory } from "@homarr/definitions"; +import { getIconUrl } from "@homarr/definitions"; +import { createIntegrationAsync } from "@homarr/integrations"; +import type { ReleasesResponse } from "@homarr/integrations"; -import { createCachedWidgetRequestHandler } from "./lib/cached-widget-request-handler"; -import { Providers } from "./releases-providers"; -import type { DetailsResponse } from "./releases-providers"; +import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler"; -const errorSchema = z.object({ - code: z.string().optional(), - message: z.string().optional(), -}); +export const releasesRequestHandler = createCachedIntegrationRequestHandler< + ReleasesResponse, + IntegrationKindByCategory<"releasesProvider">, + { + id: string; + identifier: string; + versionRegex?: string; + } +>({ + async requestAsync(integration, input) { + const integrationInstance = await createIntegrationAsync(integration); + const response = await integrationInstance.getLatestMatchingReleaseAsync({ + id: input.id, + identifier: input.identifier, + versionRegex: input.versionRegex, + }); -type ReleasesError = z.infer; - -const _reponseSchema = z.object({ - identifier: z.string(), - providerKey: z.string(), - latestRelease: z.string().optional(), - latestReleaseAt: z.date().optional(), - releaseUrl: z.string().optional(), - releaseDescription: z.string().optional(), - isPreRelease: z.boolean().optional(), - projectUrl: z.string().optional(), - projectDescription: z.string().optional(), - isFork: z.boolean().optional(), - isArchived: z.boolean().optional(), - createdAt: z.date().optional(), - starsCount: z.number().optional(), - openIssues: z.number().optional(), - forksCount: z.number().optional(), - error: errorSchema.optional(), -}); - -const formatErrorRelease = (identifier: string, providerKey: string, error: ReleasesError) => ({ - identifier, - providerKey, - latestRelease: undefined, - latestReleaseAt: undefined, - releaseUrl: undefined, - releaseDescription: undefined, - isPreRelease: undefined, - projectUrl: undefined, - projectDescription: undefined, - isFork: undefined, - isArchived: undefined, - createdAt: undefined, - starsCount: undefined, - openIssues: undefined, - forksCount: undefined, - error, -}); - -export const releasesRequestHandler = createCachedWidgetRequestHandler({ - queryKey: "releasesApiResult", - widgetKind: "releases", - async requestAsync(input: { providerKey: string; identifier: string; versionRegex: string | undefined }) { - const provider = Providers[input.providerKey]; - - if (!provider) return undefined; - - let detailsResult: DetailsResponse; - const detailsUrl = provider.getDetailsUrl(input.identifier); - if (detailsUrl !== undefined) { - const detailsResponse = await fetchWithTimeout(detailsUrl); - const parsedDetails = provider.parseDetailsResponse(await detailsResponse.json()); - - if (parsedDetails?.success) { - detailsResult = parsedDetails.data; - } else { - detailsResult = undefined; - logger.warn(`Failed to parse details response for ${input.identifier} on ${input.providerKey}`, { - provider: input.providerKey, - identifier: input.identifier, - detailsUrl, - error: parsedDetails?.error, - }); - } - } - - const releasesResponse = await fetchWithTimeout(provider.getReleasesUrl(input.identifier)); - const releasesResponseJson: unknown = await releasesResponse.json(); - const releasesResult = provider.parseReleasesResponse(releasesResponseJson); - - if (!releasesResult.success) { - return formatErrorRelease(input.identifier, input.providerKey, { - message: releasesResponseJson ? JSON.stringify(releasesResponseJson, null, 2) : releasesResult.error.message, - }); - } else { - const releases = releasesResult.data.filter((result) => - input.versionRegex && result.latestRelease ? new RegExp(input.versionRegex).test(result.latestRelease) : true, - ); - - const latest = - releases.length === 0 - ? formatErrorRelease(input.identifier, input.providerKey, { code: "noMatchingVersion" }) - : releases.reduce( - (latest, result) => { - return { - ...detailsResult, - ...(result.latestReleaseAt > latest.latestReleaseAt ? result : latest), - identifier: input.identifier, - providerKey: input.providerKey, - }; - }, - { - identifier: "", - providerKey: "", - latestRelease: "", - latestReleaseAt: new Date(0), - }, - ); - - return latest; - } + return { + ...response, + integration: { + name: integration.name, + iconUrl: getIconUrl(integration.kind), + }, + }; }, cacheDuration: dayjs.duration(5, "minutes"), + queryKey: "repositoriesReleases", }); - -export type ReleaseResponse = z.infer; diff --git a/packages/translation/src/lang/ca.json b/packages/translation/src/lang/ca.json index 8859f9791..590a4ca37 100644 --- a/packages/translation/src/lang/ca.json +++ b/packages/translation/src/lang/ca.json @@ -2304,7 +2304,7 @@ "created": "", "error": { "label": "", - "options": { + "messages": { "noMatchingVersion": "" } } diff --git a/packages/translation/src/lang/cn.json b/packages/translation/src/lang/cn.json index fba83610d..c9b2dee9e 100644 --- a/packages/translation/src/lang/cn.json +++ b/packages/translation/src/lang/cn.json @@ -2304,7 +2304,7 @@ "created": "已创建", "error": { "label": "错误", - "options": { + "messages": { "noMatchingVersion": "没有找到匹配的版本" } } diff --git a/packages/translation/src/lang/cs.json b/packages/translation/src/lang/cs.json index 27d81e344..dc2939107 100644 --- a/packages/translation/src/lang/cs.json +++ b/packages/translation/src/lang/cs.json @@ -2304,7 +2304,7 @@ "created": "", "error": { "label": "", - "options": { + "messages": { "noMatchingVersion": "" } } diff --git a/packages/translation/src/lang/da.json b/packages/translation/src/lang/da.json index 44da3d02e..23b95e21a 100644 --- a/packages/translation/src/lang/da.json +++ b/packages/translation/src/lang/da.json @@ -2304,7 +2304,7 @@ "created": "Oprettet", "error": { "label": "Fejl", - "options": { + "messages": { "noMatchingVersion": "Ingen matchende version fundet" } } diff --git a/packages/translation/src/lang/de-CH.json b/packages/translation/src/lang/de-CH.json index 8d26bc5e4..933c2e1aa 100644 --- a/packages/translation/src/lang/de-CH.json +++ b/packages/translation/src/lang/de-CH.json @@ -2304,7 +2304,7 @@ "created": "", "error": { "label": "", - "options": { + "messages": { "noMatchingVersion": "" } } diff --git a/packages/translation/src/lang/de.json b/packages/translation/src/lang/de.json index 6b87f575b..4e7cf40c8 100644 --- a/packages/translation/src/lang/de.json +++ b/packages/translation/src/lang/de.json @@ -2304,7 +2304,7 @@ "created": "Erstellt", "error": { "label": "Fehler", - "options": { + "messages": { "noMatchingVersion": "Keine passende Version gefunden" } } diff --git a/packages/translation/src/lang/el.json b/packages/translation/src/lang/el.json index 73df8637e..abc9e8d62 100644 --- a/packages/translation/src/lang/el.json +++ b/packages/translation/src/lang/el.json @@ -2304,7 +2304,7 @@ "created": "", "error": { "label": "", - "options": { + "messages": { "noMatchingVersion": "" } } diff --git a/packages/translation/src/lang/en-gb.json b/packages/translation/src/lang/en-gb.json index 8dd2bdb1f..f39897280 100644 --- a/packages/translation/src/lang/en-gb.json +++ b/packages/translation/src/lang/en-gb.json @@ -2304,7 +2304,7 @@ "created": "", "error": { "label": "", - "options": { + "messages": { "noMatchingVersion": "" } } diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index ede271c0b..2ccbd3795 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -937,6 +937,10 @@ "label": "Realm", "newLabel": "New realm" }, + "personalAccessToken": { + "label": "Personal Access Token", + "newLabel": "New Personal Access Token" + }, "topic": { "label": "Topic", "newLabel": "New topic" @@ -2288,7 +2292,11 @@ "example": { "label": "Example" }, - "invalid": "Invalid repository definition, please check the values" + "invalid": "Invalid repository definition, please check the values", + "noProvider": { + "label": "No Provider", + "tooltip": "The provider could not be parsed, please manually set it after importing the images" + } } }, "not-found": "Not Found", @@ -2304,8 +2312,12 @@ "created": "Created", "error": { "label": "Error", - "options": { - "noMatchingVersion": "No matching version found" + "messages": { + "invalidIdentifier": "Invalid identifier", + "noMatchingVersion": "No matching version found", + "noReleasesFound": "No releases found", + "noProviderSeleceted": "No provider selected", + "noProviderResponse": "No response from provider" } } }, diff --git a/packages/translation/src/lang/es.json b/packages/translation/src/lang/es.json index 9efb40993..0934733dc 100644 --- a/packages/translation/src/lang/es.json +++ b/packages/translation/src/lang/es.json @@ -2304,7 +2304,7 @@ "created": "", "error": { "label": "", - "options": { + "messages": { "noMatchingVersion": "" } } diff --git a/packages/translation/src/lang/et.json b/packages/translation/src/lang/et.json index e3120b72c..ca82f8596 100644 --- a/packages/translation/src/lang/et.json +++ b/packages/translation/src/lang/et.json @@ -2304,7 +2304,7 @@ "created": "", "error": { "label": "", - "options": { + "messages": { "noMatchingVersion": "" } } diff --git a/packages/translation/src/lang/fr.json b/packages/translation/src/lang/fr.json index 5005dc489..4bde78947 100644 --- a/packages/translation/src/lang/fr.json +++ b/packages/translation/src/lang/fr.json @@ -2304,7 +2304,7 @@ "created": "", "error": { "label": "", - "options": { + "messages": { "noMatchingVersion": "" } } diff --git a/packages/translation/src/lang/he.json b/packages/translation/src/lang/he.json index 074c14dd3..4514d6164 100644 --- a/packages/translation/src/lang/he.json +++ b/packages/translation/src/lang/he.json @@ -2304,7 +2304,7 @@ "created": "נוצר", "error": { "label": "שגיאה", - "options": { + "messages": { "noMatchingVersion": "לא נמצאה גרסה תואמת" } } diff --git a/packages/translation/src/lang/hr.json b/packages/translation/src/lang/hr.json index 5acd3fb5f..6172f296a 100644 --- a/packages/translation/src/lang/hr.json +++ b/packages/translation/src/lang/hr.json @@ -2304,7 +2304,7 @@ "created": "", "error": { "label": "", - "options": { + "messages": { "noMatchingVersion": "" } } diff --git a/packages/translation/src/lang/hu.json b/packages/translation/src/lang/hu.json index 8983425dd..e820ee6ae 100644 --- a/packages/translation/src/lang/hu.json +++ b/packages/translation/src/lang/hu.json @@ -2304,7 +2304,7 @@ "created": "", "error": { "label": "", - "options": { + "messages": { "noMatchingVersion": "" } } diff --git a/packages/translation/src/lang/it.json b/packages/translation/src/lang/it.json index 39588ebd6..23cb31c45 100644 --- a/packages/translation/src/lang/it.json +++ b/packages/translation/src/lang/it.json @@ -2304,7 +2304,7 @@ "created": "", "error": { "label": "", - "options": { + "messages": { "noMatchingVersion": "" } } diff --git a/packages/translation/src/lang/ja.json b/packages/translation/src/lang/ja.json index 800a71677..3ef694ac7 100644 --- a/packages/translation/src/lang/ja.json +++ b/packages/translation/src/lang/ja.json @@ -2304,7 +2304,7 @@ "created": "作成日", "error": { "label": "エラー", - "options": { + "messages": { "noMatchingVersion": "一致するバージョンが見つかりません" } } diff --git a/packages/translation/src/lang/ko.json b/packages/translation/src/lang/ko.json index c39775515..d93079dfc 100644 --- a/packages/translation/src/lang/ko.json +++ b/packages/translation/src/lang/ko.json @@ -2304,7 +2304,7 @@ "created": "", "error": { "label": "", - "options": { + "messages": { "noMatchingVersion": "" } } diff --git a/packages/translation/src/lang/lt.json b/packages/translation/src/lang/lt.json index 898dd4490..67cacf4c4 100644 --- a/packages/translation/src/lang/lt.json +++ b/packages/translation/src/lang/lt.json @@ -2304,7 +2304,7 @@ "created": "", "error": { "label": "", - "options": { + "messages": { "noMatchingVersion": "" } } diff --git a/packages/translation/src/lang/lv.json b/packages/translation/src/lang/lv.json index edfcd72eb..feeda3196 100644 --- a/packages/translation/src/lang/lv.json +++ b/packages/translation/src/lang/lv.json @@ -2304,7 +2304,7 @@ "created": "", "error": { "label": "", - "options": { + "messages": { "noMatchingVersion": "" } } diff --git a/packages/translation/src/lang/nl.json b/packages/translation/src/lang/nl.json index c5d9a954f..d7b5e1e15 100644 --- a/packages/translation/src/lang/nl.json +++ b/packages/translation/src/lang/nl.json @@ -2304,7 +2304,7 @@ "created": "", "error": { "label": "", - "options": { + "messages": { "noMatchingVersion": "" } } diff --git a/packages/translation/src/lang/no.json b/packages/translation/src/lang/no.json index bf32cc2a2..e344e5a92 100644 --- a/packages/translation/src/lang/no.json +++ b/packages/translation/src/lang/no.json @@ -2304,7 +2304,7 @@ "created": "", "error": { "label": "", - "options": { + "messages": { "noMatchingVersion": "" } } diff --git a/packages/translation/src/lang/pl.json b/packages/translation/src/lang/pl.json index 3968b1f5f..987cd806e 100644 --- a/packages/translation/src/lang/pl.json +++ b/packages/translation/src/lang/pl.json @@ -2304,7 +2304,7 @@ "created": "", "error": { "label": "", - "options": { + "messages": { "noMatchingVersion": "" } } diff --git a/packages/translation/src/lang/pt.json b/packages/translation/src/lang/pt.json index 25b5c20b3..82b30510c 100644 --- a/packages/translation/src/lang/pt.json +++ b/packages/translation/src/lang/pt.json @@ -2304,7 +2304,7 @@ "created": "", "error": { "label": "", - "options": { + "messages": { "noMatchingVersion": "" } } diff --git a/packages/translation/src/lang/ro.json b/packages/translation/src/lang/ro.json index 6e3ace5c1..27f411a07 100644 --- a/packages/translation/src/lang/ro.json +++ b/packages/translation/src/lang/ro.json @@ -2304,7 +2304,7 @@ "created": "", "error": { "label": "", - "options": { + "messages": { "noMatchingVersion": "" } } diff --git a/packages/translation/src/lang/ru.json b/packages/translation/src/lang/ru.json index 2040158f8..cb19f3d04 100644 --- a/packages/translation/src/lang/ru.json +++ b/packages/translation/src/lang/ru.json @@ -2304,7 +2304,7 @@ "created": "", "error": { "label": "", - "options": { + "messages": { "noMatchingVersion": "" } } diff --git a/packages/translation/src/lang/sk.json b/packages/translation/src/lang/sk.json index 3cdefd2ae..911140710 100644 --- a/packages/translation/src/lang/sk.json +++ b/packages/translation/src/lang/sk.json @@ -2304,7 +2304,7 @@ "created": "Vytvorené", "error": { "label": "Chyba", - "options": { + "messages": { "noMatchingVersion": "Nenašla sa žiadna zodpovedajúca verzia" } } diff --git a/packages/translation/src/lang/sl.json b/packages/translation/src/lang/sl.json index 59b761240..49bd772ea 100644 --- a/packages/translation/src/lang/sl.json +++ b/packages/translation/src/lang/sl.json @@ -2304,7 +2304,7 @@ "created": "", "error": { "label": "", - "options": { + "messages": { "noMatchingVersion": "" } } diff --git a/packages/translation/src/lang/sv.json b/packages/translation/src/lang/sv.json index 9d9cc6b4f..5f9015a96 100644 --- a/packages/translation/src/lang/sv.json +++ b/packages/translation/src/lang/sv.json @@ -2304,7 +2304,7 @@ "created": "", "error": { "label": "", - "options": { + "messages": { "noMatchingVersion": "" } } diff --git a/packages/translation/src/lang/tr.json b/packages/translation/src/lang/tr.json index 5dc91cde8..831bbb1c9 100644 --- a/packages/translation/src/lang/tr.json +++ b/packages/translation/src/lang/tr.json @@ -2304,7 +2304,7 @@ "created": "Oluşturuldu", "error": { "label": "Hata", - "options": { + "messages": { "noMatchingVersion": "Eşleşen sürüm bulunamadı" } } diff --git a/packages/translation/src/lang/uk.json b/packages/translation/src/lang/uk.json index 89985c9a0..c55529986 100644 --- a/packages/translation/src/lang/uk.json +++ b/packages/translation/src/lang/uk.json @@ -2304,7 +2304,7 @@ "created": "", "error": { "label": "", - "options": { + "messages": { "noMatchingVersion": "" } } diff --git a/packages/translation/src/lang/vi.json b/packages/translation/src/lang/vi.json index 96fcbcf37..6b1c11997 100644 --- a/packages/translation/src/lang/vi.json +++ b/packages/translation/src/lang/vi.json @@ -2304,7 +2304,7 @@ "created": "", "error": { "label": "", - "options": { + "messages": { "noMatchingVersion": "" } } diff --git a/packages/translation/src/lang/zh.json b/packages/translation/src/lang/zh.json index 0cd558303..8a22a4a2a 100644 --- a/packages/translation/src/lang/zh.json +++ b/packages/translation/src/lang/zh.json @@ -2304,7 +2304,7 @@ "created": "已創建", "error": { "label": "錯誤", - "options": { + "messages": { "noMatchingVersion": "找不到匹配的版本" } } diff --git a/packages/widgets/src/_inputs/widget-multiReleasesRepositories-input.tsx b/packages/widgets/src/_inputs/widget-multiReleasesRepositories-input.tsx index 22c9ad253..5d31f0bb3 100644 --- a/packages/widgets/src/_inputs/widget-multiReleasesRepositories-input.tsx +++ b/packages/widgets/src/_inputs/widget-multiReleasesRepositories-input.tsx @@ -23,6 +23,7 @@ import type { CheckboxProps } from "@mantine/core"; import type { FormErrors } from "@mantine/form"; import { useDebouncedValue } from "@mantine/hooks"; import { + IconAlertTriangleFilled, IconBrandDocker, IconEdit, IconPlus, @@ -35,13 +36,17 @@ import { escapeForRegEx } from "@tiptap/react"; import { clientApi } from "@homarr/api/client"; import { useSession } from "@homarr/auth/client"; +import { createId } from "@homarr/common"; +import { getIconUrl } from "@homarr/definitions"; +import type { IntegrationKind } from "@homarr/definitions"; import { findBestIconMatch, IconPicker } from "@homarr/forms-collection"; import { createModal, useModalAction } from "@homarr/modals"; import { useScopedI18n } from "@homarr/translation/client"; import { MaskedImage } from "@homarr/ui"; -import { isProviderKey, Providers } from "../releases/releases-providers"; import type { ReleasesRepository, ReleasesVersionFilter } from "../releases/releases-repository"; +import { WidgetIntegrationSelect } from "../widget-integration-select"; +import type { IntegrationSelectOption } from "../widget-integration-select"; import type { CommonWidgetInputProps } from "./common"; import { useWidgetInputTranslation } from "./common"; import { useFormContext } from "./form"; @@ -51,6 +56,10 @@ interface FormValidation { errors: FormErrors; } +interface Integration extends IntegrationSelectOption { + iconUrl: string; +} + export const WidgetMultiReleasesRepositoriesInput = ({ property, kind, @@ -68,9 +77,34 @@ export const WidgetMultiReleasesRepositoriesInput = ({ const { data: session } = useSession(); const isAdmin = session?.user.permissions.includes("admin") ?? false; + const integrationsApi = clientApi.integration.allOfGivenCategory.useQuery( + { + category: "releasesProvider", + }, + { + refetchOnMount: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }, + ); + const integrations = useMemo( + () => + integrationsApi.data?.reduce>((acc, integration) => { + acc[integration.id] = { + id: integration.id, + name: integration.name, + url: integration.url, + kind: integration.kind, + iconUrl: getIconUrl(integration.kind), + }; + return acc; + }, {}) ?? {}, + [integrationsApi], + ); + const onRepositorySave = useCallback( (repository: ReleasesRepository, index: number): FormValidation => { - form.setFieldValue(`options.${property}.${index}.providerKey`, repository.providerKey); + form.setFieldValue(`options.${property}.${index}.providerIntegrationId`, repository.providerIntegrationId); form.setFieldValue(`options.${property}.${index}.identifier`, repository.identifier); form.setFieldValue(`options.${property}.${index}.name`, repository.name); form.setFieldValue(`options.${property}.${index}.versionFilter`, repository.versionFilter); @@ -94,7 +128,7 @@ export const WidgetMultiReleasesRepositoriesInput = ({ const addNewRepository = () => { const repository: ReleasesRepository = { - providerKey: "DockerHub", + id: createId(), identifier: "", }; @@ -117,6 +151,7 @@ export const WidgetMultiReleasesRepositoriesInput = ({ onRepositorySave: (saved) => onRepositorySave(saved, index), onRepositoryCancel: () => onRepositoryRemove(index), versionFilterPrecisionOptions, + integrations, }); }; @@ -147,6 +182,7 @@ export const WidgetMultiReleasesRepositoriesInput = ({ onClick={() => openImportModal({ repositories, + integrations, versionFilterPrecisionOptions, onConfirm: (selectedRepositories) => { if (!selectedRepositories.length) return; @@ -173,11 +209,14 @@ export const WidgetMultiReleasesRepositoriesInput = ({ {repositories.map((repository, index) => { + const integration = repository.providerIntegrationId + ? integrations[repository.providerIntegrationId] + : undefined; return ( - + - {Providers[repository.providerKey].name} + {integration?.name ?? ""} @@ -202,6 +241,7 @@ export const WidgetMultiReleasesRepositoriesInput = ({ repository, onRepositorySave: (saved) => onRepositorySave(saved, index), versionFilterPrecisionOptions, + integrations, }) } variant="light" @@ -253,6 +293,7 @@ interface RepositoryEditProps { onRepositorySave: (repository: ReleasesRepository) => FormValidation; onRepositoryCancel?: () => void; versionFilterPrecisionOptions: string[]; + integrations: Record; } const RepositoryEditModal = createModal(({ innerProps, actions }) => { @@ -260,6 +301,10 @@ const RepositoryEditModal = createModal(({ innerProps, acti const [loading, setLoading] = useState(false); const [tempRepository, setTempRepository] = useState(() => ({ ...innerProps.repository })); const [formErrors, setFormErrors] = useState({}); + const integrationSelectOptions: IntegrationSelectOption[] = useMemo( + () => Object.values(innerProps.integrations), + [innerProps.integrations], + ); // Allows user to not select an icon by removing the url from the input, // will only try and get an icon if the name or identifier changes @@ -313,23 +358,20 @@ const RepositoryEditModal = createModal(({ innerProps, acti return ( - -