feat: add pi hole summary integration (#521)

* feat: add pi hole summary integration

* feat: add pi hole summary widget

* fix: type issues with integrations and integrationIds

* feat: add middleware for integrations and improve cache redis channel

* feat: add error boundary for widgets

* fix: broken lock file

* fix: format format issues

* fix: typecheck issue

* fix: deepsource issues

* fix: widget sandbox without error boundary

* chore: address pull request feedback

* chore: remove todo comment and created issue

* fix: format issues

* fix: deepsource issue
This commit is contained in:
Meier Lukas
2024-05-26 17:13:34 +02:00
committed by GitHub
parent 96c71aed6e
commit d57b771a17
45 changed files with 902 additions and 124 deletions

View File

@@ -0,0 +1,76 @@
import { TRPCError } from "@trpc/server";
import { and, eq, inArray } from "@homarr/db";
import { integrations } from "@homarr/db/schema/sqlite";
import type { IntegrationKind } from "@homarr/definitions";
import { z } from "@homarr/validation";
import { decryptSecret } from "../router/integration";
import { publicProcedure } from "../trpc";
export const createOneIntegrationMiddleware = <TKind extends IntegrationKind>(...kinds: TKind[]) => {
return publicProcedure.input(z.object({ integrationId: z.string() })).use(async ({ input, ctx, next }) => {
const integration = await ctx.db.query.integrations.findFirst({
where: and(eq(integrations.id, input.integrationId), inArray(integrations.kind, kinds)),
with: {
secrets: true,
},
});
if (!integration) {
throw new TRPCError({
code: "NOT_FOUND",
message: `Integration with id ${input.integrationId} not found or not of kinds ${kinds.join(",")}`,
});
}
const { secrets, kind, ...rest } = integration;
return next({
ctx: {
integration: {
...rest,
kind: kind as TKind,
decryptedSecrets: secrets.map((secret) => ({
...secret,
value: decryptSecret(secret.value),
})),
},
},
});
});
};
export const createManyIntegrationMiddleware = <TKind extends IntegrationKind>(...kinds: TKind[]) => {
return publicProcedure
.input(z.object({ integrationIds: z.array(z.string()).min(1) }))
.use(async ({ ctx, input, next }) => {
const dbIntegrations = await ctx.db.query.integrations.findMany({
where: and(inArray(integrations.id, input.integrationIds), inArray(integrations.kind, kinds)),
with: {
secrets: true,
},
});
const offset = input.integrationIds.length - dbIntegrations.length;
if (offset !== 0) {
throw new TRPCError({
code: "NOT_FOUND",
message: `${offset} of the specified integrations not found or not of kinds ${kinds.join(",")}`,
});
}
return next({
ctx: {
integrations: dbIntegrations.map(({ secrets, kind, ...rest }) => ({
...rest,
kind: kind as TKind,
decryptedSecrets: secrets.map((secret) => ({
...secret,
value: decryptSecret(secret.value),
})),
})),
},
});
});
};

View File

@@ -236,15 +236,15 @@ export const boardRouter = createTRPCRouter({
);
}
const inputIntegrationRelations = inputItems.flatMap(({ integrations, id: itemId }) =>
integrations.map((integration) => ({
integrationId: integration.id,
const inputIntegrationRelations = inputItems.flatMap(({ integrationIds, id: itemId }) =>
integrationIds.map((integrationId) => ({
integrationId,
itemId,
})),
);
const dbIntegrationRelations = dbItems.flatMap(({ integrations, id: itemId }) =>
integrations.map((integration) => ({
integrationId: integration.id,
const dbIntegrationRelations = dbItems.flatMap(({ integrationIds, id: itemId }) =>
integrationIds.map((integrationId) => ({
integrationId,
itemId,
})),
);
@@ -277,6 +277,7 @@ export const boardRouter = createTRPCRouter({
xOffset: item.xOffset,
yOffset: item.yOffset,
options: superjson.stringify(item.options),
advancedOptions: superjson.stringify(item.advancedOptions),
sectionId: item.sectionId,
})
.where(eq(items.id, item.id));
@@ -514,9 +515,9 @@ const getFullBoardWithWhereAsync = async (db: Database, where: SQL<unknown>, use
sections: sections.map((section) =>
parseSection({
...section,
items: section.items.map((item) => ({
items: section.items.map(({ integrations: itemIntegrations, ...item }) => ({
...item,
integrations: item.integrations.map((item) => item.integration),
integrationIds: itemIntegrations.map((item) => item.integration.id),
advancedOptions: superjson.parse<BoardItemAdvancedOptions>(item.advancedOptions),
options: superjson.parse<Record<string, unknown>>(item.options),
})),

View File

@@ -210,7 +210,6 @@ export const integrationRouter = createTRPCRouter({
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);
@@ -219,8 +218,7 @@ export function encryptSecret(text: string): `${string}.${string}` {
return `${encrypted.toString("hex")}.${initializationVector.toString("hex")}`;
}
// Decrypting text
function decryptSecret(value: `${string}.${string}`) {
export 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");

View File

@@ -659,7 +659,7 @@ describe("saveBoard should save full board", () => {
id: createId(),
kind: "clock",
options: { is24HourFormat: true },
integrations: [],
integrationIds: [],
height: 1,
width: 1,
xOffset: 0,
@@ -720,7 +720,7 @@ describe("saveBoard should save full board", () => {
id: itemId,
kind: "clock",
options: { is24HourFormat: true },
integrations: [anotherIntegration],
integrationIds: [anotherIntegration.id],
height: 1,
width: 1,
xOffset: 0,
@@ -834,7 +834,7 @@ describe("saveBoard should save full board", () => {
id: newItemId,
kind: "clock",
options: { is24HourFormat: true },
integrations: [],
integrationIds: [],
height: 1,
width: 1,
xOffset: 3,
@@ -903,7 +903,7 @@ describe("saveBoard should save full board", () => {
id: itemId,
kind: "clock",
options: { is24HourFormat: true },
integrations: [integration],
integrationIds: [integration.id],
height: 1,
width: 1,
xOffset: 0,
@@ -1017,7 +1017,7 @@ describe("saveBoard should save full board", () => {
id: itemId,
kind: "clock",
options: { is24HourFormat: false },
integrations: [],
integrationIds: [],
height: 3,
width: 2,
xOffset: 7,
@@ -1245,10 +1245,9 @@ const expectInputToBeFullBoardWithName = (
if (firstItem.kind === "clock") {
expect(firstItem.options.is24HourFormat).toBe(true);
}
expect(firstItem.integrations.length).toBe(1);
const firstIntegration = expectToBeDefined(firstItem.integrations[0]);
expect(firstIntegration.id).toBe(props.integrationId);
expect(firstIntegration.kind).toBe("adGuardHome");
expect(firstItem.integrationIds.length).toBe(1);
const firstIntegration = expectToBeDefined(firstItem.integrationIds[0]);
expect(firstIntegration).toBe(props.integrationId);
};
const createFullBoardAsync = async (db: Database, name: string) => {

View File

@@ -0,0 +1,32 @@
import { TRPCError } from "@trpc/server";
import { PiHoleIntegration } from "@homarr/integrations";
import type { DnsHoleSummary } from "@homarr/integrations/types";
import { logger } from "@homarr/log";
import { createCacheChannel } from "@homarr/redis";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const dnsHoleRouter = createTRPCRouter({
summary: publicProcedure.unstable_concat(createOneIntegrationMiddleware("piHole")).query(async ({ ctx }) => {
const cache = createCacheChannel<DnsHoleSummary>(`dns-hole-summary:${ctx.integration.id}`);
const { data } = await cache.consumeAsync(async () => {
const client = new PiHoleIntegration(ctx.integration);
return await client.getSummaryAsync().catch((err) => {
logger.error("dns-hole router - ", err);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Failed to fetch DNS Hole summary for ${ctx.integration.name} (${ctx.integration.id})`,
});
});
});
return {
...data,
integrationId: ctx.integration.id,
};
}),
});

View File

@@ -1,8 +1,10 @@
import { createTRPCRouter } from "../../trpc";
import { dnsHoleRouter } from "./dns-hole";
import { notebookRouter } from "./notebook";
import { weatherRouter } from "./weather";
export const widgetRouter = createTRPCRouter({
notebook: notebookRouter,
weather: weatherRouter,
dnsHole: dnsHoleRouter,
});