Files
Homarr/packages/api/src/router/integration.ts
2024-05-18 12:25:33 +02:00

307 lines
9.0 KiB
TypeScript

import crypto from "crypto";
import { TRPCError } from "@trpc/server";
import type { Database } from "@homarr/db";
import { and, createId, eq } from "@homarr/db";
import { integrations, integrationSecrets } from "@homarr/db/schema/sqlite";
import type { IntegrationSecretKind } from "@homarr/definitions";
import {
getAllSecretKindOptions,
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) {
const secretInput = {
integrationId: input.id,
value: changedSecret.value,
kind: changedSecret.kind,
};
if (
!decryptedSecrets.some(
(secret) => secret.kind === changedSecret.kind,
)
) {
await addSecretAsync(ctx.db, secretInput);
} else {
await updateSecretAsync(ctx.db, secretInput);
}
}
}
}),
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 secrets = input.secrets.filter(
(secret): secret is { kind: IntegrationSecretKind; value: string } =>
Boolean(secret.value),
);
// Find any matching secret kinds
let secretKinds = getAllSecretKindOptions(input.kind).find(
(secretKinds) =>
secretKinds.every((secretKind) =>
secrets.some((secret) => secret.kind === secretKind),
),
);
if (!secretKinds && input.id === null) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "SECRETS_NOT_DEFINED",
});
}
if (!secretKinds && 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,
});
}
}
secretKinds = getAllSecretKindOptions(input.kind).find((secretKinds) =>
secretKinds.every((secretKind) =>
secrets.some((secret) => secret.kind === secretKind),
),
);
if (!secretKinds) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "SECRETS_NOT_DEFINED",
});
}
}
// 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
export function encryptSecret(text: string): `${string}.${string}` {
const initializationVector = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(
algorithm,
Buffer.from(key),
initializationVector,
);
let encrypted = cipher.update(text);
encrypted = Buffer.concat([encrypted, cipher.final()]);
return `${encrypted.toString("hex")}.${initializationVector.toString("hex")}`;
}
// Decrypting text
function decryptSecret(value: `${string}.${string}`) {
const [data, dataIv] = value.split(".") as [string, string];
const initializationVector = Buffer.from(dataIv, "hex");
const encryptedText = Buffer.from(data, "hex");
const decipher = crypto.createDecipheriv(
algorithm,
Buffer.from(key),
initializationVector,
);
let decrypted = decipher.update(encryptedText);
decrypted = Buffer.concat([decrypted, decipher.final()]);
return decrypted.toString();
}
interface UpdateSecretInput {
integrationId: string;
value: string;
kind: IntegrationSecretKind;
}
const updateSecretAsync = async (db: Database, input: UpdateSecretInput) => {
await db
.update(integrationSecrets)
.set({
value: encryptSecret(input.value),
updatedAt: new Date(),
})
.where(
and(
eq(integrationSecrets.integrationId, input.integrationId),
eq(integrationSecrets.kind, input.kind),
),
);
};
interface AddSecretInput {
integrationId: string;
value: string;
kind: IntegrationSecretKind;
}
const addSecretAsync = async (db: Database, input: AddSecretInput) => {
await db.insert(integrationSecrets).values({
kind: input.kind,
value: encryptSecret(input.value),
updatedAt: new Date(),
integrationId: input.integrationId,
});
};