feat: add crud for integrations (#11)
* wip: add crud for services and integrations * feat: remove services * feat: move integration definitions to homarr/definitions, add temporary test connection solution without actual request * feat: add integration count badge * feat: add translation for integrations * feat: add notifications and translate them * feat: add notice to integration forms about test connection * chore: fix ci check issues * feat: add confirm modals for integration deletion and secret card cancellation, change ordering for list page, add name property to integrations * refactor: move revalidate path action * chore: fix ci check issues * chore: install missing dependencies * chore: fix ci check issues * chore: address pull request feedback
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
import { integrationRouter } from "./router/integration";
|
||||
import { userRouter } from "./router/user";
|
||||
import { createTRPCRouter } from "./trpc";
|
||||
|
||||
export const appRouter = createTRPCRouter({
|
||||
user: userRouter,
|
||||
integration: integrationRouter,
|
||||
});
|
||||
|
||||
// export type definition of API
|
||||
|
||||
243
packages/api/src/router/integration.ts
Normal file
243
packages/api/src/router/integration.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import crypto from "crypto";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { and, createId, eq } from "@homarr/db";
|
||||
import { integrations, integrationSecrets } from "@homarr/db/schema/sqlite";
|
||||
import type { IntegrationSecretKind } from "@homarr/definitions";
|
||||
import {
|
||||
getSecretKinds,
|
||||
integrationKinds,
|
||||
integrationSecretKindObject,
|
||||
} from "@homarr/definitions";
|
||||
import { validation } from "@homarr/validation";
|
||||
|
||||
import { createTRPCRouter, publicProcedure } from "../trpc";
|
||||
|
||||
export const integrationRouter = createTRPCRouter({
|
||||
all: publicProcedure.query(async ({ ctx }) => {
|
||||
const integrations = await ctx.db.query.integrations.findMany();
|
||||
return integrations
|
||||
.map((integration) => ({
|
||||
id: integration.id,
|
||||
name: integration.name,
|
||||
kind: integration.kind,
|
||||
url: integration.url,
|
||||
}))
|
||||
.sort(
|
||||
(integrationA, integrationB) =>
|
||||
integrationKinds.indexOf(integrationA.kind) -
|
||||
integrationKinds.indexOf(integrationB.kind),
|
||||
);
|
||||
}),
|
||||
byId: publicProcedure
|
||||
.input(validation.integration.byId)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const integration = await ctx.db.query.integrations.findFirst({
|
||||
where: eq(integrations.id, input.id),
|
||||
with: {
|
||||
secrets: {
|
||||
columns: {
|
||||
kind: true,
|
||||
value: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!integration) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Integration not found",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: integration.id,
|
||||
name: integration.name,
|
||||
kind: integration.kind,
|
||||
url: integration.url,
|
||||
secrets: integration.secrets.map((secret) => ({
|
||||
kind: secret.kind,
|
||||
// Only return the value if the secret is public, so for example the username
|
||||
value: integrationSecretKindObject[secret.kind].isPublic
|
||||
? decryptSecret(secret.value)
|
||||
: null,
|
||||
updatedAt: secret.updatedAt,
|
||||
})),
|
||||
};
|
||||
}),
|
||||
create: publicProcedure
|
||||
.input(validation.integration.create)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const integrationId = createId();
|
||||
await ctx.db.insert(integrations).values({
|
||||
id: integrationId,
|
||||
name: input.name,
|
||||
url: input.url,
|
||||
kind: input.kind,
|
||||
});
|
||||
|
||||
for (const secret of input.secrets) {
|
||||
await ctx.db.insert(integrationSecrets).values({
|
||||
kind: secret.kind,
|
||||
value: encryptSecret(secret.value),
|
||||
updatedAt: new Date(),
|
||||
integrationId,
|
||||
});
|
||||
}
|
||||
}),
|
||||
update: publicProcedure
|
||||
.input(validation.integration.update)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const integration = await ctx.db.query.integrations.findFirst({
|
||||
where: eq(integrations.id, input.id),
|
||||
with: {
|
||||
secrets: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!integration) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Integration not found",
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.db
|
||||
.update(integrations)
|
||||
.set({
|
||||
name: input.name,
|
||||
url: input.url,
|
||||
})
|
||||
.where(eq(integrations.id, input.id));
|
||||
|
||||
const decryptedSecrets = integration.secrets.map((secret) => ({
|
||||
...secret,
|
||||
value: decryptSecret(secret.value),
|
||||
}));
|
||||
|
||||
const changedSecrets = input.secrets.filter(
|
||||
(secret): secret is { kind: IntegrationSecretKind; value: string } =>
|
||||
secret.value !== null && // only update secrets that have a value
|
||||
!decryptedSecrets.find(
|
||||
(dSecret) =>
|
||||
dSecret.kind === secret.kind && dSecret.value === secret.value,
|
||||
),
|
||||
);
|
||||
|
||||
if (changedSecrets.length > 0) {
|
||||
for (const changedSecret of changedSecrets) {
|
||||
await ctx.db
|
||||
.update(integrationSecrets)
|
||||
.set({
|
||||
value: encryptSecret(changedSecret.value),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(integrationSecrets.integrationId, input.id),
|
||||
eq(integrationSecrets.kind, changedSecret.kind),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}),
|
||||
delete: publicProcedure
|
||||
.input(validation.integration.delete)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const integration = await ctx.db.query.integrations.findFirst({
|
||||
where: eq(integrations.id, input.id),
|
||||
});
|
||||
|
||||
if (!integration) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Integration not found",
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.db.delete(integrations).where(eq(integrations.id, input.id));
|
||||
}),
|
||||
testConnection: publicProcedure
|
||||
.input(validation.integration.testConnection)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const secretKinds = getSecretKinds(input.kind);
|
||||
const secrets = input.secrets.filter(
|
||||
(secret): secret is { kind: IntegrationSecretKind; value: string } =>
|
||||
!!secret.value,
|
||||
);
|
||||
const everyInputSecretDefined = secretKinds.every((secretKind) =>
|
||||
secrets.some((secret) => secret.kind === secretKind),
|
||||
);
|
||||
if (!everyInputSecretDefined && input.id === null) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "SECRETS_NOT_DEFINED",
|
||||
});
|
||||
}
|
||||
|
||||
if (!everyInputSecretDefined && input.id !== null) {
|
||||
const integration = await ctx.db.query.integrations.findFirst({
|
||||
where: eq(integrations.id, input.id),
|
||||
with: {
|
||||
secrets: true,
|
||||
},
|
||||
});
|
||||
if (!integration) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "SECRETS_NOT_DEFINED",
|
||||
});
|
||||
}
|
||||
const decryptedSecrets = integration.secrets.map((secret) => ({
|
||||
...secret,
|
||||
value: decryptSecret(secret.value),
|
||||
}));
|
||||
|
||||
// Add secrets that are not defined in the input from the database
|
||||
for (const dbSecret of decryptedSecrets) {
|
||||
if (!secrets.find((secret) => secret.kind === dbSecret.kind)) {
|
||||
secrets.push({
|
||||
kind: dbSecret.kind,
|
||||
value: dbSecret.value,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: actually test the connection
|
||||
// Probably by calling a function on the integration class
|
||||
// getIntegration(input.kind).testConnection(secrets)
|
||||
// getIntegration(kind: IntegrationKind): Integration
|
||||
// interface Integration {
|
||||
// testConnection(): Promise<void>;
|
||||
// }
|
||||
}),
|
||||
});
|
||||
|
||||
const algorithm = "aes-256-cbc"; //Using AES encryption
|
||||
const key = Buffer.from(
|
||||
"1d71cceced68159ba59a277d056a66173613052cbeeccbfbd15ab1c909455a4d",
|
||||
"hex",
|
||||
); // TODO: generate with const data = crypto.randomBytes(32).toString('hex')
|
||||
|
||||
//Encrypting text
|
||||
function encryptSecret(text: string): `${string}.${string}` {
|
||||
const iv = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv(algorithm, Buffer.from(key), iv);
|
||||
let encrypted = cipher.update(text);
|
||||
encrypted = Buffer.concat([encrypted, cipher.final()]);
|
||||
return `${encrypted.toString("hex")}.${iv.toString("hex")}`;
|
||||
}
|
||||
|
||||
// Decrypting text
|
||||
function decryptSecret(value: `${string}.${string}`) {
|
||||
const [data, dataIv] = value.split(".") as [string, string];
|
||||
const iv = Buffer.from(dataIv, "hex");
|
||||
const encryptedText = Buffer.from(data, "hex");
|
||||
const decipher = crypto.createDecipheriv(algorithm, Buffer.from(key), iv);
|
||||
let decrypted = decipher.update(encryptedText);
|
||||
decrypted = Buffer.concat([decrypted, decipher.final()]);
|
||||
return decrypted.toString();
|
||||
}
|
||||
@@ -9,8 +9,8 @@
|
||||
import { initTRPC, TRPCError } from "@trpc/server";
|
||||
import superjson from "superjson";
|
||||
|
||||
import { auth } from "@homarr/auth";
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { auth } from "@homarr/auth";
|
||||
import { db } from "@homarr/db";
|
||||
import { ZodError } from "@homarr/validation";
|
||||
|
||||
@@ -49,11 +49,11 @@ const createInnerTRPCContext = (opts: CreateContextOptions) => {
|
||||
* @link https://trpc.io/docs/context
|
||||
*/
|
||||
export const createTRPCContext = async (opts: {
|
||||
req?: Request;
|
||||
headers?: Headers;
|
||||
auth: Session | null;
|
||||
}) => {
|
||||
const session = opts.auth ?? (await auth());
|
||||
const source = opts.req?.headers.get("x-trpc-source") ?? "unknown";
|
||||
const source = opts.headers?.get("x-trpc-source") ?? "unknown";
|
||||
|
||||
console.log(">>> tRPC Request from", source, "by", session?.user);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user