feat(integration): add github app authentication (#3968)
This commit is contained in:
@@ -1,9 +1,11 @@
|
|||||||
import {
|
import {
|
||||||
|
IconCode,
|
||||||
IconGrid3x3,
|
IconGrid3x3,
|
||||||
IconKey,
|
IconKey,
|
||||||
IconMessage,
|
IconMessage,
|
||||||
IconPassword,
|
IconPassword,
|
||||||
IconPasswordUser,
|
IconPasswordUser,
|
||||||
|
IconPlug,
|
||||||
IconServer,
|
IconServer,
|
||||||
IconUser,
|
IconUser,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
@@ -21,4 +23,7 @@ export const integrationSecretIcons = {
|
|||||||
topic: IconMessage,
|
topic: IconMessage,
|
||||||
opnsenseApiKey: IconKey,
|
opnsenseApiKey: IconKey,
|
||||||
opnsenseApiSecret: IconPassword,
|
opnsenseApiSecret: IconPassword,
|
||||||
|
githubAppId: IconCode,
|
||||||
|
githubInstallationId: IconPlug,
|
||||||
|
privateKey: IconKey,
|
||||||
} satisfies Record<IntegrationSecretKind, TablerIcon>;
|
} satisfies Record<IntegrationSecretKind, TablerIcon>;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { ChangeEventHandler, FocusEventHandler } from "react";
|
import type { ChangeEventHandler, FocusEventHandler } from "react";
|
||||||
import { PasswordInput, TextInput } from "@mantine/core";
|
import { PasswordInput, Textarea, TextInput } from "@mantine/core";
|
||||||
|
|
||||||
import { integrationSecretKindObject } from "@homarr/definitions";
|
import { integrationSecretKindObject } from "@homarr/definitions";
|
||||||
import type { IntegrationSecretKind } from "@homarr/definitions";
|
import type { IntegrationSecretKind } from "@homarr/definitions";
|
||||||
@@ -14,9 +14,9 @@ interface IntegrationSecretInputProps {
|
|||||||
label?: string;
|
label?: string;
|
||||||
kind: IntegrationSecretKind;
|
kind: IntegrationSecretKind;
|
||||||
value?: string;
|
value?: string;
|
||||||
onChange: ChangeEventHandler<HTMLInputElement>;
|
onChange: ChangeEventHandler<HTMLInputElement | HTMLTextAreaElement>;
|
||||||
onFocus?: FocusEventHandler<HTMLInputElement>;
|
onFocus?: FocusEventHandler<HTMLInputElement | HTMLTextAreaElement>;
|
||||||
onBlur?: FocusEventHandler<HTMLInputElement>;
|
onBlur?: FocusEventHandler<HTMLInputElement | HTMLTextAreaElement>;
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,6 +31,19 @@ export const IntegrationSecretInput = (props: IntegrationSecretInputProps) => {
|
|||||||
const PublicSecretInput = ({ kind, ...props }: IntegrationSecretInputProps) => {
|
const PublicSecretInput = ({ kind, ...props }: IntegrationSecretInputProps) => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const Icon = integrationSecretIcons[kind];
|
const Icon = integrationSecretIcons[kind];
|
||||||
|
const { multiline } = integrationSecretKindObject[kind];
|
||||||
|
if (multiline) {
|
||||||
|
return (
|
||||||
|
<Textarea
|
||||||
|
{...props}
|
||||||
|
label={props.label ?? t(`integration.secrets.kind.${kind}.label`)}
|
||||||
|
w="100%"
|
||||||
|
leftSection={<Icon size={20} stroke={1.5} />}
|
||||||
|
autosize
|
||||||
|
minRows={2}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TextInput
|
<TextInput
|
||||||
@@ -45,6 +58,21 @@ const PublicSecretInput = ({ kind, ...props }: IntegrationSecretInputProps) => {
|
|||||||
const PrivateSecretInput = ({ kind, ...props }: IntegrationSecretInputProps) => {
|
const PrivateSecretInput = ({ kind, ...props }: IntegrationSecretInputProps) => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const Icon = integrationSecretIcons[kind];
|
const Icon = integrationSecretIcons[kind];
|
||||||
|
const { multiline } = integrationSecretKindObject[kind];
|
||||||
|
|
||||||
|
if (multiline) {
|
||||||
|
return (
|
||||||
|
<Textarea
|
||||||
|
{...props}
|
||||||
|
label={props.label ?? t(`integration.secrets.kind.${kind}.label`)}
|
||||||
|
description={t("integration.secrets.secureNotice")}
|
||||||
|
w="100%"
|
||||||
|
leftSection={<Icon size={20} stroke={1.5} />}
|
||||||
|
autosize
|
||||||
|
minRows={2}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
|
|||||||
@@ -25,16 +25,6 @@ export const testConnectionAsync = async (
|
|||||||
integrationUrl: integration.url,
|
integrationUrl: integration.url,
|
||||||
});
|
});
|
||||||
|
|
||||||
const formSecrets = integration.secrets
|
|
||||||
.filter((secret) => secret.value !== null)
|
|
||||||
.map((secret) => ({
|
|
||||||
...secret,
|
|
||||||
// We ensured above that the value is not null
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
||||||
value: secret.value!,
|
|
||||||
source: "form" as const,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const decryptedDbSecrets = dbSecrets
|
const decryptedDbSecrets = dbSecrets
|
||||||
.map((secret) => {
|
.map((secret) => {
|
||||||
try {
|
try {
|
||||||
@@ -55,6 +45,15 @@ export const testConnectionAsync = async (
|
|||||||
})
|
})
|
||||||
.filter((secret) => secret !== null);
|
.filter((secret) => secret !== null);
|
||||||
|
|
||||||
|
const formSecrets = integration.secrets
|
||||||
|
.map((secret) => ({
|
||||||
|
...secret,
|
||||||
|
// If the value is not defined in the form (because we only changed other values) we use the existing value from the db if it exists
|
||||||
|
value: secret.value ?? decryptedDbSecrets.find((dbSecret) => dbSecret.kind === secret.kind)?.value ?? null,
|
||||||
|
source: "form" as const,
|
||||||
|
}))
|
||||||
|
.filter((secret): secret is SourcedIntegrationSecret<"form"> => secret.value !== null);
|
||||||
|
|
||||||
const sourcedSecrets = [...formSecrets, ...decryptedDbSecrets];
|
const sourcedSecrets = [...formSecrets, ...decryptedDbSecrets];
|
||||||
const secretKinds = getSecretKindOption(integration.kind, sourcedSecrets);
|
const secretKinds = getSecretKindOption(integration.kind, sourcedSecrets);
|
||||||
|
|
||||||
@@ -89,10 +88,10 @@ export const testConnectionAsync = async (
|
|||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface SourcedIntegrationSecret {
|
interface SourcedIntegrationSecret<TSource extends string = "db" | "form"> {
|
||||||
kind: IntegrationSecretKind;
|
kind: IntegrationSecretKind;
|
||||||
value: string;
|
value: string;
|
||||||
source: "db" | "form";
|
source: TSource;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getSecretKindOption = (kind: IntegrationKind, sourcedSecrets: SourcedIntegrationSecret[]) => {
|
const getSecretKindOption = (kind: IntegrationKind, sourcedSecrets: SourcedIntegrationSecret[]) => {
|
||||||
@@ -111,7 +110,9 @@ const getSecretKindOption = (kind: IntegrationKind, sourcedSecrets: SourcedInteg
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onlyFormSecretsKindOptions = matchingSecretKindOptions.filter((secretKinds) =>
|
const onlyFormSecretsKindOptions = matchingSecretKindOptions.filter((secretKinds) =>
|
||||||
sourcedSecrets.filter((secret) => secretKinds.includes(secret.kind)).every((secret) => secret.source === "form"),
|
secretKinds.every((secretKind) =>
|
||||||
|
sourcedSecrets.find((secret) => secret.kind === secretKind && secret.source === "form"),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (onlyFormSecretsKindOptions.length >= 1) {
|
if (onlyFormSecretsKindOptions.length >= 1) {
|
||||||
|
|||||||
@@ -265,4 +265,77 @@ describe("testConnectionAsync should run test connection of integration", () =>
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("with input of existing github app", async () => {
|
||||||
|
// Arrange
|
||||||
|
const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
|
||||||
|
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
|
||||||
|
factorySpy.mockReturnValue(
|
||||||
|
Promise.resolve({
|
||||||
|
testConnectionAsync: async () => await Promise.resolve({ success: true }),
|
||||||
|
} as homarrIntegrations.PiHoleIntegrationV6),
|
||||||
|
);
|
||||||
|
optionsSpy.mockReturnValue([[], ["githubAppId", "githubInstallationId", "privateKey"]]);
|
||||||
|
|
||||||
|
const integration = {
|
||||||
|
id: "new",
|
||||||
|
name: "GitHub",
|
||||||
|
url: "https://api.github.com",
|
||||||
|
kind: "github" as const,
|
||||||
|
secrets: [
|
||||||
|
{
|
||||||
|
kind: "githubAppId" as const,
|
||||||
|
value: "345",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "githubInstallationId" as const,
|
||||||
|
value: "456",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "privateKey" as const,
|
||||||
|
value: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const dbSecrets = [
|
||||||
|
{
|
||||||
|
kind: "githubAppId" as const,
|
||||||
|
value: "123.encrypted" as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "githubInstallationId" as const,
|
||||||
|
value: "234.encrypted" as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "privateKey" as const,
|
||||||
|
value: "privateKey.encrypted" as const,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await testConnectionAsync(integration, dbSecrets);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(factorySpy).toHaveBeenCalledWith({
|
||||||
|
id: "new",
|
||||||
|
name: "GitHub",
|
||||||
|
url: "https://api.github.com",
|
||||||
|
kind: "github" as const,
|
||||||
|
decryptedSecrets: [
|
||||||
|
expect.objectContaining({
|
||||||
|
kind: "githubAppId",
|
||||||
|
value: "345",
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
kind: "githubInstallationId",
|
||||||
|
value: "456",
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
kind: "privateKey",
|
||||||
|
value: "privateKey",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
"dayjs": "^1.11.18",
|
"dayjs": "^1.11.18",
|
||||||
"dns-caching": "^0.2.5",
|
"dns-caching": "^0.2.5",
|
||||||
"next": "15.5.2",
|
"next": "15.5.2",
|
||||||
|
"octokit": "^5.0.3",
|
||||||
"react": "19.1.1",
|
"react": "19.1.1",
|
||||||
"react-dom": "19.1.1",
|
"react-dom": "19.1.1",
|
||||||
"undici": "7.15.0",
|
"undici": "7.15.0",
|
||||||
|
|||||||
@@ -3,3 +3,4 @@ export * from "./fetch-http-error-handler";
|
|||||||
export * from "./ofetch-http-error-handler";
|
export * from "./ofetch-http-error-handler";
|
||||||
export * from "./axios-http-error-handler";
|
export * from "./axios-http-error-handler";
|
||||||
export * from "./tsdav-http-error-handler";
|
export * from "./tsdav-http-error-handler";
|
||||||
|
export * from "./octokit-http-error-handler";
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { RequestError as OctokitRequestError } from "octokit";
|
||||||
|
|
||||||
|
import type { AnyRequestError } from "../request-error";
|
||||||
|
import { ResponseError } from "../response-error";
|
||||||
|
import { HttpErrorHandler } from "./http-error-handler";
|
||||||
|
|
||||||
|
export class OctokitHttpErrorHandler extends HttpErrorHandler {
|
||||||
|
/**
|
||||||
|
* I wasn't able to get a request error triggered. Therefore we ignore them for now
|
||||||
|
* and just forward them as unknown errors
|
||||||
|
*/
|
||||||
|
handleRequestError(_: unknown): AnyRequestError | undefined {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleResponseError(error: unknown): ResponseError | undefined {
|
||||||
|
if (!(error instanceof OctokitRequestError)) return undefined;
|
||||||
|
|
||||||
|
return new ResponseError({
|
||||||
|
status: error.status,
|
||||||
|
url: error.response?.url,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,16 +4,19 @@ import type { AtLeastOneOf } from "@homarr/common/types";
|
|||||||
import { createDocumentationLink } from "./docs";
|
import { createDocumentationLink } from "./docs";
|
||||||
|
|
||||||
export const integrationSecretKindObject = {
|
export const integrationSecretKindObject = {
|
||||||
apiKey: { isPublic: false },
|
apiKey: { isPublic: false, multiline: false },
|
||||||
username: { isPublic: true },
|
username: { isPublic: true, multiline: false },
|
||||||
password: { isPublic: false },
|
password: { isPublic: false, multiline: false },
|
||||||
tokenId: { isPublic: true },
|
tokenId: { isPublic: true, multiline: false },
|
||||||
realm: { isPublic: true },
|
realm: { isPublic: true, multiline: false },
|
||||||
personalAccessToken: { isPublic: false },
|
personalAccessToken: { isPublic: false, multiline: false },
|
||||||
topic: { isPublic: true },
|
topic: { isPublic: true, multiline: false },
|
||||||
opnsenseApiKey: { isPublic: false },
|
opnsenseApiKey: { isPublic: false, multiline: false },
|
||||||
opnsenseApiSecret: { isPublic: false },
|
opnsenseApiSecret: { isPublic: false, multiline: false },
|
||||||
} satisfies Record<string, { isPublic: boolean }>;
|
privateKey: { isPublic: false, multiline: true },
|
||||||
|
githubAppId: { isPublic: true, multiline: false },
|
||||||
|
githubInstallationId: { isPublic: true, multiline: false },
|
||||||
|
} satisfies Record<string, { isPublic: boolean; multiline: boolean }>;
|
||||||
|
|
||||||
export const integrationSecretKinds = objectKeys(integrationSecretKindObject);
|
export const integrationSecretKinds = objectKeys(integrationSecretKindObject);
|
||||||
|
|
||||||
@@ -211,7 +214,7 @@ export const integrationDefs = {
|
|||||||
},
|
},
|
||||||
github: {
|
github: {
|
||||||
name: "Github",
|
name: "Github",
|
||||||
secretKinds: [[], ["personalAccessToken"]],
|
secretKinds: [[], ["personalAccessToken"], ["githubAppId", "githubInstallationId", "privateKey"]],
|
||||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/github.svg",
|
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/github.svg",
|
||||||
category: ["releasesProvider"],
|
category: ["releasesProvider"],
|
||||||
defaultUrl: "https://api.github.com",
|
defaultUrl: "https://api.github.com",
|
||||||
@@ -259,7 +262,7 @@ export const integrationDefs = {
|
|||||||
},
|
},
|
||||||
gitHubContainerRegistry: {
|
gitHubContainerRegistry: {
|
||||||
name: "GitHub Container Registry",
|
name: "GitHub Container Registry",
|
||||||
secretKinds: [[], ["personalAccessToken"]],
|
secretKinds: [[], ["personalAccessToken"], ["githubAppId", "githubInstallationId", "privateKey"]],
|
||||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/github.svg",
|
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/github.svg",
|
||||||
category: ["releasesProvider"],
|
category: ["releasesProvider"],
|
||||||
defaultUrl: "https://api.github.com",
|
defaultUrl: "https://api.github.com",
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
"@homarr/translation": "workspace:^0.1.0",
|
"@homarr/translation": "workspace:^0.1.0",
|
||||||
"@homarr/validation": "workspace:^0.1.0",
|
"@homarr/validation": "workspace:^0.1.0",
|
||||||
"@jellyfin/sdk": "^0.11.0",
|
"@jellyfin/sdk": "^0.11.0",
|
||||||
|
"@octokit/auth-app": "^8.1.0",
|
||||||
"maria2": "^0.4.1",
|
"maria2": "^0.4.1",
|
||||||
"node-ical": "^0.20.1",
|
"node-ical": "^0.20.1",
|
||||||
"octokit": "^5.0.3",
|
"octokit": "^5.0.3",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
AxiosHttpErrorHandler,
|
AxiosHttpErrorHandler,
|
||||||
FetchHttpErrorHandler,
|
FetchHttpErrorHandler,
|
||||||
|
OctokitHttpErrorHandler,
|
||||||
OFetchHttpErrorHandler,
|
OFetchHttpErrorHandler,
|
||||||
TsdavHttpErrorHandler,
|
TsdavHttpErrorHandler,
|
||||||
} from "@homarr/common/server";
|
} from "@homarr/common/server";
|
||||||
@@ -11,3 +12,4 @@ export const integrationFetchHttpErrorHandler = new IntegrationHttpErrorHandler(
|
|||||||
export const integrationOFetchHttpErrorHandler = new IntegrationHttpErrorHandler(new OFetchHttpErrorHandler());
|
export const integrationOFetchHttpErrorHandler = new IntegrationHttpErrorHandler(new OFetchHttpErrorHandler());
|
||||||
export const integrationAxiosHttpErrorHandler = new IntegrationHttpErrorHandler(new AxiosHttpErrorHandler());
|
export const integrationAxiosHttpErrorHandler = new IntegrationHttpErrorHandler(new AxiosHttpErrorHandler());
|
||||||
export const integrationTsdavHttpErrorHandler = new IntegrationHttpErrorHandler(new TsdavHttpErrorHandler());
|
export const integrationTsdavHttpErrorHandler = new IntegrationHttpErrorHandler(new TsdavHttpErrorHandler());
|
||||||
|
export const integrationOctokitHttpErrorHandler = new IntegrationHttpErrorHandler(new OctokitHttpErrorHandler());
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
|
import { createAppAuth } from "@octokit/auth-app";
|
||||||
import { Octokit, RequestError } from "octokit";
|
import { Octokit, RequestError } from "octokit";
|
||||||
|
import type { fetch } from "undici";
|
||||||
|
|
||||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
|
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
|
||||||
import { logger } from "@homarr/log";
|
import { logger } from "@homarr/log";
|
||||||
|
|
||||||
|
import { HandleIntegrationErrors } from "../base/errors/decorator";
|
||||||
|
import { integrationOctokitHttpErrorHandler } from "../base/errors/http";
|
||||||
import type { IntegrationTestingInput } from "../base/integration";
|
import type { IntegrationTestingInput } from "../base/integration";
|
||||||
import { Integration } 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 { TestingResult } from "../base/test-connection/test-connection-service";
|
||||||
import type { ReleasesProviderIntegration } from "../interfaces/releases-providers/releases-providers-integration";
|
import type { ReleasesProviderIntegration } from "../interfaces/releases-providers/releases-providers-integration";
|
||||||
import { getLatestRelease } from "../interfaces/releases-providers/releases-providers-integration";
|
import { getLatestRelease } from "../interfaces/releases-providers/releases-providers-integration";
|
||||||
@@ -18,23 +21,21 @@ import type {
|
|||||||
|
|
||||||
const localLogger = logger.child({ module: "GitHubContainerRegistryIntegration" });
|
const localLogger = logger.child({ module: "GitHubContainerRegistryIntegration" });
|
||||||
|
|
||||||
|
@HandleIntegrationErrors([integrationOctokitHttpErrorHandler])
|
||||||
export class GitHubContainerRegistryIntegration extends Integration implements ReleasesProviderIntegration {
|
export class GitHubContainerRegistryIntegration extends Integration implements ReleasesProviderIntegration {
|
||||||
private static readonly userAgent = "Homarr-Lab/Homarr:GitHubContainerRegistryIntegration";
|
private static readonly userAgent = "Homarr-Lab/Homarr:GitHubContainerRegistryIntegration";
|
||||||
|
|
||||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||||
const headers: RequestInit["headers"] = {
|
const api = this.getApi(input.fetchAsync);
|
||||||
"User-Agent": GitHubContainerRegistryIntegration.userAgent,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (this.hasSecretValue("personalAccessToken"))
|
if (this.hasSecretValue("personalAccessToken")) {
|
||||||
headers.Authorization = `Bearer ${this.getSecretValue("personalAccessToken")}`;
|
await api.rest.users.getAuthenticated();
|
||||||
|
} else if (this.hasSecretValue("githubAppId")) {
|
||||||
const response = await input.fetchAsync(this.url("/octocat"), {
|
await api.rest.apps.getInstallation({
|
||||||
headers,
|
installation_id: Number(this.getSecretValue("githubInstallationId")),
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
if (!response.ok) {
|
await api.request("GET /octocat");
|
||||||
return TestConnectionError.StatusResult(response);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -131,15 +132,38 @@ export class GitHubContainerRegistryIntegration extends Integration implements R
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private getApi() {
|
private getAuthProperties(): Pick<OctokitOptions, "auth" | "authStrategy"> {
|
||||||
|
if (this.hasSecretValue("personalAccessToken"))
|
||||||
|
return {
|
||||||
|
auth: this.getSecretValue("personalAccessToken"),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.hasSecretValue("githubAppId"))
|
||||||
|
return {
|
||||||
|
authStrategy: createAppAuth,
|
||||||
|
auth: {
|
||||||
|
appId: this.getSecretValue("githubAppId"),
|
||||||
|
installationId: this.getSecretValue("githubInstallationId"),
|
||||||
|
privateKey: this.getSecretValue("privateKey"),
|
||||||
|
} satisfies Parameters<typeof createAppAuth>[0],
|
||||||
|
};
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
private getApi(customFetch?: typeof fetch) {
|
||||||
return new Octokit({
|
return new Octokit({
|
||||||
baseUrl: this.url("/").origin,
|
baseUrl: this.url("/").origin,
|
||||||
request: {
|
request: {
|
||||||
fetch: fetchWithTrustedCertificatesAsync,
|
fetch: customFetch ?? fetchWithTrustedCertificatesAsync,
|
||||||
},
|
},
|
||||||
userAgent: GitHubContainerRegistryIntegration.userAgent,
|
userAgent: GitHubContainerRegistryIntegration.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.
|
// Disable throttling for this integration, Octokit will retry by default after a set time,
|
||||||
...(this.hasSecretValue("personalAccessToken") ? { auth: this.getSecretValue("personalAccessToken") } : {}),
|
// thus delaying the repsonse to the user in case of errors. Errors will be shown to the user, no need to retry the request.
|
||||||
|
throttle: { enabled: false },
|
||||||
|
...this.getAuthProperties(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type OctokitOptions = Exclude<ConstructorParameters<typeof Octokit>[0], undefined>;
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { Octokit, RequestError } from "octokit";
|
import { createAppAuth } from "@octokit/auth-app";
|
||||||
|
import { Octokit, RequestError as OctokitRequestError } from "octokit";
|
||||||
|
import type { fetch } from "undici";
|
||||||
|
|
||||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
|
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
|
||||||
import { logger } from "@homarr/log";
|
import { logger } from "@homarr/log";
|
||||||
|
|
||||||
|
import { HandleIntegrationErrors } from "../base/errors/decorator";
|
||||||
|
import { integrationOctokitHttpErrorHandler } from "../base/errors/http";
|
||||||
import type { IntegrationTestingInput } from "../base/integration";
|
import type { IntegrationTestingInput } from "../base/integration";
|
||||||
import { Integration } 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 { TestingResult } from "../base/test-connection/test-connection-service";
|
||||||
import type { ReleasesProviderIntegration } from "../interfaces/releases-providers/releases-providers-integration";
|
import type { ReleasesProviderIntegration } from "../interfaces/releases-providers/releases-providers-integration";
|
||||||
import { getLatestRelease } from "../interfaces/releases-providers/releases-providers-integration";
|
import { getLatestRelease } from "../interfaces/releases-providers/releases-providers-integration";
|
||||||
@@ -18,23 +21,21 @@ import type {
|
|||||||
|
|
||||||
const localLogger = logger.child({ module: "GithubIntegration" });
|
const localLogger = logger.child({ module: "GithubIntegration" });
|
||||||
|
|
||||||
|
@HandleIntegrationErrors([integrationOctokitHttpErrorHandler])
|
||||||
export class GithubIntegration extends Integration implements ReleasesProviderIntegration {
|
export class GithubIntegration extends Integration implements ReleasesProviderIntegration {
|
||||||
private static readonly userAgent = "Homarr-Lab/Homarr:GithubIntegration";
|
private static readonly userAgent = "Homarr-Lab/Homarr:GithubIntegration";
|
||||||
|
|
||||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||||
const headers: RequestInit["headers"] = {
|
const api = this.getApi(input.fetchAsync);
|
||||||
"User-Agent": GithubIntegration.userAgent,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (this.hasSecretValue("personalAccessToken"))
|
if (this.hasSecretValue("personalAccessToken")) {
|
||||||
headers.Authorization = `Bearer ${this.getSecretValue("personalAccessToken")}`;
|
await api.rest.users.getAuthenticated();
|
||||||
|
} else if (this.hasSecretValue("githubAppId")) {
|
||||||
const response = await input.fetchAsync(this.url("/octocat"), {
|
await api.rest.apps.getInstallation({
|
||||||
headers,
|
installation_id: Number(this.getSecretValue("githubInstallationId")),
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
if (!response.ok) {
|
await api.request("GET /octocat");
|
||||||
return TestConnectionError.StatusResult(response);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -91,7 +92,7 @@ export class GithubIntegration extends Integration implements ReleasesProviderIn
|
|||||||
|
|
||||||
return getLatestRelease(releasesProviderResponse, repository, details);
|
return getLatestRelease(releasesProviderResponse, repository, details);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof RequestError ? error.message : String(error);
|
const errorMessage = error instanceof OctokitRequestError ? error.message : String(error);
|
||||||
|
|
||||||
localLogger.warn(`Failed to get releases for ${owner}\\${name} with Github integration`, {
|
localLogger.warn(`Failed to get releases for ${owner}\\${name} with Github integration`, {
|
||||||
owner,
|
owner,
|
||||||
@@ -131,21 +132,44 @@ export class GithubIntegration extends Integration implements ReleasesProviderIn
|
|||||||
localLogger.warn(`Failed to get details for ${owner}\\${name} with Github integration`, {
|
localLogger.warn(`Failed to get details for ${owner}\\${name} with Github integration`, {
|
||||||
owner,
|
owner,
|
||||||
name,
|
name,
|
||||||
error: error instanceof RequestError ? error.message : String(error),
|
error: error instanceof OctokitRequestError ? error.message : String(error),
|
||||||
});
|
});
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private getApi() {
|
private getAuthProperties(): Pick<OctokitOptions, "auth" | "authStrategy"> {
|
||||||
|
if (this.hasSecretValue("personalAccessToken"))
|
||||||
|
return {
|
||||||
|
auth: this.getSecretValue("personalAccessToken"),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.hasSecretValue("githubAppId"))
|
||||||
|
return {
|
||||||
|
authStrategy: createAppAuth,
|
||||||
|
auth: {
|
||||||
|
appId: this.getSecretValue("githubAppId"),
|
||||||
|
installationId: this.getSecretValue("githubInstallationId"),
|
||||||
|
privateKey: this.getSecretValue("privateKey"),
|
||||||
|
} satisfies Parameters<typeof createAppAuth>[0],
|
||||||
|
};
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
private getApi(customFetch?: typeof fetch) {
|
||||||
return new Octokit({
|
return new Octokit({
|
||||||
baseUrl: this.url("/").origin,
|
baseUrl: this.url("/").origin,
|
||||||
request: {
|
request: {
|
||||||
fetch: fetchWithTrustedCertificatesAsync,
|
fetch: customFetch ?? fetchWithTrustedCertificatesAsync,
|
||||||
},
|
},
|
||||||
userAgent: GithubIntegration.userAgent,
|
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.
|
// Disable throttling for this integration, Octokit will retry by default after a set time,
|
||||||
...(this.hasSecretValue("personalAccessToken") ? { auth: this.getSecretValue("personalAccessToken") } : {}),
|
// thus delaying the repsonse to the user in case of errors. Errors will be shown to the user, no need to retry the request.
|
||||||
|
throttle: { enabled: false },
|
||||||
|
...this.getAuthProperties(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type OctokitOptions = Exclude<ConstructorParameters<typeof Octokit>[0], undefined>;
|
||||||
|
|||||||
@@ -952,6 +952,18 @@
|
|||||||
"opnsenseApiSecret": {
|
"opnsenseApiSecret": {
|
||||||
"label": "API Key (Secret)",
|
"label": "API Key (Secret)",
|
||||||
"newLabel": "New API Key (Secret)"
|
"newLabel": "New API Key (Secret)"
|
||||||
|
},
|
||||||
|
"githubAppId": {
|
||||||
|
"label": "App Id",
|
||||||
|
"newLabel": "New App Id"
|
||||||
|
},
|
||||||
|
"githubInstallationId": {
|
||||||
|
"label": "Installation Id",
|
||||||
|
"newLabel": "New Installation Id"
|
||||||
|
},
|
||||||
|
"privateKey": {
|
||||||
|
"label": "Private key",
|
||||||
|
"newLabel": "New private key"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
28
pnpm-lock.yaml
generated
28
pnpm-lock.yaml
generated
@@ -870,6 +870,9 @@ importers:
|
|||||||
next:
|
next:
|
||||||
specifier: 15.5.2
|
specifier: 15.5.2
|
||||||
version: 15.5.2(@babel/core@7.28.3)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(sass@1.92.1)
|
version: 15.5.2(@babel/core@7.28.3)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(sass@1.92.1)
|
||||||
|
octokit:
|
||||||
|
specifier: ^5.0.3
|
||||||
|
version: 5.0.3
|
||||||
react:
|
react:
|
||||||
specifier: 19.1.1
|
specifier: 19.1.1
|
||||||
version: 19.1.1
|
version: 19.1.1
|
||||||
@@ -1462,6 +1465,9 @@ importers:
|
|||||||
'@jellyfin/sdk':
|
'@jellyfin/sdk':
|
||||||
specifier: ^0.11.0
|
specifier: ^0.11.0
|
||||||
version: 0.11.0(axios@1.11.0)
|
version: 0.11.0(axios@1.11.0)
|
||||||
|
'@octokit/auth-app':
|
||||||
|
specifier: ^8.1.0
|
||||||
|
version: 8.1.0
|
||||||
maria2:
|
maria2:
|
||||||
specifier: ^0.4.1
|
specifier: ^0.4.1
|
||||||
version: 0.4.1
|
version: 0.4.1
|
||||||
@@ -3471,12 +3477,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
|
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
|
|
||||||
'@octokit/app@16.0.1':
|
'@octokit/app@16.1.0':
|
||||||
resolution: {integrity: sha512-kgTeTsWmpUX+s3Fs4EK4w1K+jWCDB6ClxLSWUWTyhlw7+L3jHtuXDR4QtABu2GsmCMdk67xRhruiXotS3ay3Yw==}
|
resolution: {integrity: sha512-OdKHnm0CYLk8Setr47CATT4YnRTvWkpTYvE+B/l2B0mjszlfOIit3wqPHVslD2jfc1bD4UbO7Mzh6gjCuMZKsA==}
|
||||||
engines: {node: '>= 20'}
|
engines: {node: '>= 20'}
|
||||||
|
|
||||||
'@octokit/auth-app@8.0.1':
|
'@octokit/auth-app@8.1.0':
|
||||||
resolution: {integrity: sha512-P2J5pB3pjiGwtJX4WqJVYCtNkcZ+j5T2Wm14aJAEIC3WJOrv12jvBley3G1U/XI8q9o1A7QMG54LiFED2BiFlg==}
|
resolution: {integrity: sha512-6bWhyvLXqCSfHiqlwzn9pScLZ+Qnvh/681GR/UEEPCMIVwfpRDBw0cCzy3/t2Dq8B7W2X/8pBgmw6MOiyE0DXQ==}
|
||||||
engines: {node: '>= 20'}
|
engines: {node: '>= 20'}
|
||||||
|
|
||||||
'@octokit/auth-oauth-app@9.0.1':
|
'@octokit/auth-oauth-app@9.0.1':
|
||||||
@@ -11828,9 +11834,9 @@ snapshots:
|
|||||||
'@nodelib/fs.scandir': 2.1.5
|
'@nodelib/fs.scandir': 2.1.5
|
||||||
fastq: 1.17.1
|
fastq: 1.17.1
|
||||||
|
|
||||||
'@octokit/app@16.0.1':
|
'@octokit/app@16.1.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@octokit/auth-app': 8.0.1
|
'@octokit/auth-app': 8.1.0
|
||||||
'@octokit/auth-unauthenticated': 7.0.1
|
'@octokit/auth-unauthenticated': 7.0.1
|
||||||
'@octokit/core': 7.0.2
|
'@octokit/core': 7.0.2
|
||||||
'@octokit/oauth-app': 8.0.1
|
'@octokit/oauth-app': 8.0.1
|
||||||
@@ -11838,7 +11844,7 @@ snapshots:
|
|||||||
'@octokit/types': 14.1.0
|
'@octokit/types': 14.1.0
|
||||||
'@octokit/webhooks': 14.0.0
|
'@octokit/webhooks': 14.0.0
|
||||||
|
|
||||||
'@octokit/auth-app@8.0.1':
|
'@octokit/auth-app@8.1.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@octokit/auth-oauth-app': 9.0.1
|
'@octokit/auth-oauth-app': 9.0.1
|
||||||
'@octokit/auth-oauth-user': 6.0.0
|
'@octokit/auth-oauth-user': 6.0.0
|
||||||
@@ -11885,7 +11891,7 @@ snapshots:
|
|||||||
'@octokit/graphql': 9.0.1
|
'@octokit/graphql': 9.0.1
|
||||||
'@octokit/request': 10.0.2
|
'@octokit/request': 10.0.2
|
||||||
'@octokit/request-error': 7.0.0
|
'@octokit/request-error': 7.0.0
|
||||||
'@octokit/types': 14.0.0
|
'@octokit/types': 14.1.0
|
||||||
before-after-hook: 4.0.0
|
before-after-hook: 4.0.0
|
||||||
universal-user-agent: 7.0.2
|
universal-user-agent: 7.0.2
|
||||||
|
|
||||||
@@ -11944,13 +11950,13 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@octokit/core': 7.0.2
|
'@octokit/core': 7.0.2
|
||||||
'@octokit/request-error': 7.0.0
|
'@octokit/request-error': 7.0.0
|
||||||
'@octokit/types': 14.0.0
|
'@octokit/types': 14.1.0
|
||||||
bottleneck: 2.19.5
|
bottleneck: 2.19.5
|
||||||
|
|
||||||
'@octokit/plugin-throttling@11.0.1(@octokit/core@7.0.2)':
|
'@octokit/plugin-throttling@11.0.1(@octokit/core@7.0.2)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@octokit/core': 7.0.2
|
'@octokit/core': 7.0.2
|
||||||
'@octokit/types': 14.0.0
|
'@octokit/types': 14.1.0
|
||||||
bottleneck: 2.19.5
|
bottleneck: 2.19.5
|
||||||
|
|
||||||
'@octokit/request-error@7.0.0':
|
'@octokit/request-error@7.0.0':
|
||||||
@@ -17406,7 +17412,7 @@ snapshots:
|
|||||||
|
|
||||||
octokit@5.0.3:
|
octokit@5.0.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@octokit/app': 16.0.1
|
'@octokit/app': 16.1.0
|
||||||
'@octokit/core': 7.0.2
|
'@octokit/core': 7.0.2
|
||||||
'@octokit/oauth-app': 8.0.1
|
'@octokit/oauth-app': 8.0.1
|
||||||
'@octokit/plugin-paginate-graphql': 6.0.0(@octokit/core@7.0.2)
|
'@octokit/plugin-paginate-graphql': 6.0.0(@octokit/core@7.0.2)
|
||||||
|
|||||||
Reference in New Issue
Block a user