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:
76
packages/api/src/middlewares/integration.ts
Normal file
76
packages/api/src/middlewares/integration.ts
Normal 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),
|
||||
})),
|
||||
})),
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -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),
|
||||
})),
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
32
packages/api/src/router/widgets/dns-hole.ts
Normal file
32
packages/api/src/router/widgets/dns-hole.ts
Normal 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,
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user