diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/_components/test-connection/integration-test-connection-error.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/_components/test-connection/integration-test-connection-error.tsx new file mode 100644 index 000000000..cdaa70d93 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/integrations/_components/test-connection/integration-test-connection-error.tsx @@ -0,0 +1,96 @@ +import { useMemo } from "react"; +import { Accordion, Anchor, Card, Stack, Text } from "@mantine/core"; +import { IconSubtask } from "@tabler/icons-react"; + +import { getMantineColor } from "@homarr/common"; +import { useI18n } from "@homarr/translation/client"; + +import { CertificateErrorDetails } from "./test-connection-certificate"; +import type { AnyMappedTestConnectionError, MappedError } from "./types"; + +interface IntegrationTestConnectionErrorProps { + error: AnyMappedTestConnectionError; + url: string; +} + +export const IntegrationTestConnectionError = ({ error, url }: IntegrationTestConnectionErrorProps) => { + const t = useI18n(); + const causeArray = useMemo(() => toCauseArray(error.cause), [error.cause]); + + return ( + + + + + {t(`integration.testConnection.error.${error.type}.title`)} + + + {error.type !== "request" && error.type !== "certificate" && error.type !== "statusCode" ? ( + {t(`integration.testConnection.error.${error.type}.description`)} + ) : null} + + {error.type === "request" ? ( + + {t( + `integration.testConnection.error.request.description.${error.data.type}.${error.data.reason}` as never, + )} + + ) : null} + + {error.type === "statusCode" ? ( + error.data.reason === "other" ? ( + + {t.rich("integration.testConnection.error.statusCode.otherDescription", { + statusCode: error.data.statusCode.toString(), + url: () => {error.data.url}, + })} + + ) : ( + + {t.rich("integration.testConnection.error.statusCode.description", { + reason: t(`integration.testConnection.error.statusCode.reason.${error.data.reason}`), + statusCode: error.data.statusCode.toString(), + url: () => {error.data.url}, + })} + + ) + ) : null} + + {error.type === "certificate" ? : null} + + + {error.cause ? ( + + + }> + {t("integration.testConnection.error.common.cause.title")} + + +
+                  {error.name}: {error.message}
+                  {"\n"}
+                  {causeArray
+                    .map(
+                      (cause) =>
+                        `caused by ${cause.name}${cause.message ? `: ${cause.message}` : ""} ${cause.metadata.map((item) => `${item.key}=${item.value}`).join(" ")}`,
+                    )
+                    .join("\n")}
+                
+
+
+
+ ) : null} +
+
+ ); +}; + +const toCauseArray = (cause: MappedError | undefined) => { + const causeArray: MappedError[] = []; + let currentCause: MappedError | undefined = cause; + while (currentCause) { + causeArray.push(currentCause); + currentCause = currentCause.cause; + } + return causeArray.map(({ cause: _innerCause, ...cause }) => cause); +}; diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/_components/test-connection/test-connection-certificate.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/_components/test-connection/test-connection-certificate.tsx new file mode 100644 index 000000000..a84302b55 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/integrations/_components/test-connection/test-connection-certificate.tsx @@ -0,0 +1,327 @@ +import { useState } from "react"; +import { ActionIcon, Alert, Anchor, Button, Card, CopyButton, Group, SimpleGrid, Stack, Text } from "@mantine/core"; +import { IconAlertTriangle, IconCheck, IconCopy, IconExclamationCircle, IconRepeat } from "@tabler/icons-react"; + +import { clientApi } from "@homarr/api/client"; +import { useSession } from "@homarr/auth/client"; +import { getMantineColor } from "@homarr/common"; +import { createId } from "@homarr/db/client"; +import { createDocumentationLink } from "@homarr/definitions"; +import { createModal, useConfirmModal, useModalAction } from "@homarr/modals"; +import { AddCertificateModal } from "@homarr/modals-collection"; +import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; +import { useCurrentLocale, useI18n, useScopedI18n } from "@homarr/translation/client"; + +import type { MappedCertificate, MappedTestConnectionCertificateError } from "./types"; + +interface CertificateErrorDetailsProps { + error: MappedTestConnectionCertificateError; + url: string; +} + +export const CertificateErrorDetails = ({ error, url }: CertificateErrorDetailsProps) => { + const tError = useScopedI18n("integration.testConnection.error"); + const { data: session } = useSession(); + const isAdmin = session?.user.permissions.includes("admin") ?? false; + const [showRetryButton, setShowRetryButton] = useState(false); + + const { openModal: openUploadModal } = useModalAction(AddCertificateModal); + const { openConfirmModal } = useConfirmModal(); + const { mutateAsync: trustHostnameAsync } = clientApi.certificates.trustHostnameMismatch.useMutation(); + const { mutateAsync: addCertificateAsync } = clientApi.certificates.addCertificate.useMutation(); + + const handleTrustHostname = () => { + const { hostname } = new URL(url); + openConfirmModal({ + title: tError("certificate.hostnameMismatch.confirm.title"), + children: tError("certificate.hostnameMismatch.confirm.message"), + // eslint-disable-next-line no-restricted-syntax + async onConfirm() { + await trustHostnameAsync( + { + hostname, + certificate: error.data.certificate.pem, + }, + { + onSuccess() { + showSuccessNotification({ + title: tError("certificate.hostnameMismatch.notification.success.title"), + message: tError("certificate.hostnameMismatch.notification.success.message"), + }); + setShowRetryButton(true); + }, + onError() { + showErrorNotification({ + title: tError("certificate.hostnameMismatch.notification.error.title"), + message: tError("certificate.hostnameMismatch.notification.error.message"), + }); + }, + }, + ); + }, + }); + }; + + const handleTrustSelfSigned = () => { + const { hostname } = new URL(url); + openConfirmModal({ + title: tError("certificate.selfSigned.confirm.title"), + children: tError("certificate.selfSigned.confirm.message"), + // eslint-disable-next-line no-restricted-syntax + async onConfirm() { + const formData = new FormData(); + formData.append( + "file", + new File([error.data.certificate.pem], `${hostname}-${createId()}.crt`, { + type: "application/x-x509-ca-cert", + }), + ); + await addCertificateAsync(formData, { + onSuccess() { + showSuccessNotification({ + title: tError("certificate.selfSigned.notification.success.title"), + message: tError("certificate.selfSigned.notification.success.message"), + }); + setShowRetryButton(true); + }, + onError() { + showErrorNotification({ + title: tError("certificate.selfSigned.notification.error.title"), + message: tError("certificate.selfSigned.notification.error.message"), + }); + }, + }); + }, + }); + }; + + const description = {tError(`certificate.description.${error.data.reason}`)}; + + if (!isAdmin) { + return ( + <> + {description} + + + ); + } + + return ( + <> + {description} + + + + {error.data.reason === "hostnameMismatch" && } + + {!error.data.certificate.isSelfSigned && error.data.reason === "untrusted" && } + + {showRetryButton && ( + + )} + + {(error.data.reason === "untrusted" && error.data.certificate.isSelfSigned) || + error.data.reason === "hostnameMismatch" ? ( + + ) : null} + {error.data.reason === "untrusted" && !error.data.certificate.isSelfSigned ? ( + + ) : null} + + ); +}; + +const NotEnoughPermissionsAlert = () => { + const t = useI18n(); + return ( + } + title={t("integration.testConnection.error.certificate.alert.permission.title")} + color="yellow" + > + {t("integration.testConnection.error.certificate.alert.permission.message")} + + ); +}; + +const HostnameMismatchAlert = () => { + const t = useI18n(); + return ( + } + title={t("integration.testConnection.error.certificate.alert.hostnameMismatch.title")} + color="yellow" + > + {t("integration.testConnection.error.certificate.alert.hostnameMismatch.message")} + + ); +}; + +const CertificateExtractAlert = () => { + const t = useI18n(); + return ( + } + title={t("integration.testConnection.error.certificate.alert.extract.title")} + color="red" + > + {t.rich("integration.testConnection.error.certificate.alert.extract.message", { + docsLink: () => ( + + {t("common.here")} + + ), + })} + + ); +}; + +interface CertificateDetailsProps { + certificate: MappedCertificate; +} + +export const CertificateDetailsCard = ({ certificate }: CertificateDetailsProps) => { + const { openModal } = useModalAction(PemContentModal); + const locale = useCurrentLocale(); + const tDetails = useScopedI18n("integration.testConnection.error.certificate.details"); + const tCertificateField = useScopedI18n("certificate.field"); + + return ( + + {tDetails("title")} + + + {tDetails("description")} + + openModal({ content: certificate.pem })} + > + {tDetails("content.action")} + + + + + + + {tCertificateField("subject.label")} + + {certificate.subject} + + + + {tCertificateField("issuer.label")} + + {certificate.issuer} + + + + {tCertificateField("validFrom.label")} + + + {new Intl.DateTimeFormat(locale, { + dateStyle: "full", + timeStyle: "long", + }).format(certificate.validFrom)} + + + + + {tCertificateField("validTo.label")} + + + {new Intl.DateTimeFormat(locale, { + dateStyle: "full", + timeStyle: "long", + }).format(certificate.validTo)} + + + + + {tCertificateField("serialNumber.label")} + + {certificate.serialNumber} + + + + + + + {tCertificateField("fingerprint.label")} + + {certificate.fingerprint} + + + + ); +}; + +const PemContentModal = createModal<{ content: string }>(({ actions, innerProps }) => { + const t = useI18n(); + + return ( + + +
+          {innerProps.content}
+        
+ + {({ copy, copied }) => ( + + {copied ? ( + + ) : ( + + )} + + )} + +
+ + +
+ ); +}).withOptions({ + defaultTitle(t) { + return t("integration.testConnection.error.certificate.details.content.title"); + }, + size: "lg", +}); diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/_components/test-connection/types.ts b/apps/nextjs/src/app/[locale]/manage/integrations/_components/test-connection/types.ts new file mode 100644 index 000000000..7ca7f281c --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/integrations/_components/test-connection/types.ts @@ -0,0 +1,6 @@ +import type { RouterOutputs } from "@homarr/api"; + +export type AnyMappedTestConnectionError = Exclude["error"]; +export type MappedTestConnectionCertificateError = Extract; +export type MappedCertificate = MappedTestConnectionCertificateError["data"]["certificate"]; +export type MappedError = Exclude; diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/edit/[id]/_integration-edit-form.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/edit/[id]/_integration-edit-form.tsx index 06fb662ec..7427c7633 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/edit/[id]/_integration-edit-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/edit/[id]/_integration-edit-form.tsx @@ -1,5 +1,6 @@ "use client"; +import { useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { Button, Fieldset, Group, Stack, TextInput } from "@mantine/core"; @@ -10,7 +11,6 @@ import { clientApi } from "@homarr/api/client"; import { revalidatePathActionAsync } from "@homarr/common/client"; import { getAllSecretKindOptions, getDefaultSecretKinds } from "@homarr/definitions"; import { useZodForm } from "@homarr/form"; -import { convertIntegrationTestConnectionError } from "@homarr/integrations/client"; import { useConfirmModal } from "@homarr/modals"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { useI18n } from "@homarr/translation/client"; @@ -18,6 +18,8 @@ import { integrationUpdateSchema } from "@homarr/validation/integration"; import { SecretCard } from "../../_components/secrets/integration-secret-card"; import { IntegrationSecretInput } from "../../_components/secrets/integration-secret-inputs"; +import { IntegrationTestConnectionError } from "../../_components/test-connection/integration-test-connection-error"; +import type { AnyMappedTestConnectionError } from "../../_components/test-connection/types"; interface EditIntegrationForm { integration: RouterOutputs["integration"]["byId"]; @@ -43,6 +45,7 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => { }, }); const { mutateAsync, isPending } = clientApi.integration.update.useMutation(); + const [error, setError] = useState(null); const secretsMap = new Map(integration.secrets.map((secret) => [secret.kind, secret])); @@ -57,26 +60,24 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => { })), }, { - onSuccess: () => { + onSuccess: (data) => { + // We do it this way as we are unable to send a typesafe error through onError + if (data?.error) { + setError(data.error); + showErrorNotification({ + title: t("integration.page.edit.notification.error.title"), + message: t("integration.page.edit.notification.error.message"), + }); + return; + } + showSuccessNotification({ title: t("integration.page.edit.notification.success.title"), message: t("integration.page.edit.notification.success.message"), }); void revalidatePathActionAsync("/manage/integrations").then(() => router.push("/manage/integrations")); }, - onError: (error) => { - const testConnectionError = convertIntegrationTestConnectionError(error.data?.error); - - if (testConnectionError) { - showErrorNotification({ - title: t(`integration.testConnection.notification.${testConnectionError.key}.title`), - message: - testConnectionError.message ?? - t(`integration.testConnection.notification.${testConnectionError.key}.message`), - }); - return; - } - + onError: () => { showErrorNotification({ title: t("integration.page.edit.notification.error.title"), message: t("integration.page.edit.notification.error.message"), @@ -128,6 +129,8 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => { + {error !== null && } + ; }; - -const AddCertificateModal = createModal(({ actions }) => { - const t = useI18n(); - const form = useZodForm( - z.object({ - file: z.instanceof(File).nullable().superRefine(superRefineCertificateFile), - }), - { - initialValues: { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - file: null!, - }, - }, - ); - const { mutateAsync } = clientApi.certificates.addCertificate.useMutation(); - - return ( -
{ - const formData = new FormData(); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - formData.set("file", values.file!); - await mutateAsync(formData, { - async onSuccess() { - showSuccessNotification({ - title: t("certificate.action.create.notification.success.title"), - message: t("certificate.action.create.notification.success.message"), - }); - await revalidatePathActionAsync("/manage/tools/certificates"); - actions.closeModal(); - }, - onError() { - showErrorNotification({ - title: t("certificate.action.create.notification.error.title"), - message: t("certificate.action.create.notification.error.message"), - }); - }, - }); - })} - > - - } {...form.getInputProps("file")} /> - - - - - -
- ); -}).withOptions({ - defaultTitle(t) { - return t("certificate.action.create.label"); - }, -}); diff --git a/apps/nextjs/src/app/[locale]/manage/tools/certificates/hostnames/_components/remove-hostname.tsx b/apps/nextjs/src/app/[locale]/manage/tools/certificates/hostnames/_components/remove-hostname.tsx new file mode 100644 index 000000000..d0004c849 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/certificates/hostnames/_components/remove-hostname.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { ActionIcon } from "@mantine/core"; +import { IconTrash } from "@tabler/icons-react"; + +import { clientApi } from "@homarr/api/client"; +import { revalidatePathActionAsync } from "@homarr/common/client"; +import { useConfirmModal } from "@homarr/modals"; +import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; +import { useI18n } from "@homarr/translation/client"; + +interface RemoveHostnameActionIconProps { + hostname: string; + thumbprint: string; +} + +export const RemoveHostnameActionIcon = (input: RemoveHostnameActionIconProps) => { + const { mutateAsync } = clientApi.certificates.removeTrustedHostname.useMutation(); + const { openConfirmModal } = useConfirmModal(); + const t = useI18n(); + + const handleRemove = () => { + openConfirmModal({ + title: t("certificate.action.removeHostname.label"), + children: t("certificate.action.removeHostname.confirm"), + // eslint-disable-next-line no-restricted-syntax + async onConfirm() { + await mutateAsync(input, { + async onSuccess() { + await revalidatePathActionAsync("/manage/tools/certificates/hostnames"); + showSuccessNotification({ + title: t("certificate.action.removeHostname.notification.success.title"), + message: t("certificate.action.removeHostname.notification.success.message"), + }); + }, + onError() { + showErrorNotification({ + title: t("certificate.action.removeHostname.notification.error.title"), + message: t("certificate.action.removeHostname.notification.error.message"), + }); + }, + }); + }, + }); + }; + + return ( + + + + ); +}; diff --git a/apps/nextjs/src/app/[locale]/manage/tools/certificates/hostnames/page.tsx b/apps/nextjs/src/app/[locale]/manage/tools/certificates/hostnames/page.tsx new file mode 100644 index 000000000..5482b27a0 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/certificates/hostnames/page.tsx @@ -0,0 +1,99 @@ +import { X509Certificate } from "node:crypto"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { + Button, + Group, + Stack, + Table, + TableTbody, + TableTd, + TableTh, + TableThead, + TableTr, + Text, + Title, +} from "@mantine/core"; +import { IconCertificateOff } from "@tabler/icons-react"; + +import { auth } from "@homarr/auth/next"; +import { getTrustedCertificateHostnamesAsync } from "@homarr/certificates/server"; +import { getI18n } from "@homarr/translation/server"; + +import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; +import { NoResults } from "~/components/no-results"; +import { RemoveHostnameActionIcon } from "./_components/remove-hostname"; + +export default async function TrustedHostnamesPage() { + const session = await auth(); + if (!session?.user.permissions.includes("admin")) { + notFound(); + } + + const t = await getI18n(); + + const trustedHostnames = await getTrustedCertificateHostnamesAsync().then((hostnames) => { + return hostnames.map((hostname) => { + let subject: string | null; + try { + subject = new X509Certificate(hostname.certificate).subject; + } catch { + subject = null; + } + return { + ...hostname, + subject, + }; + }); + }); + + return ( + <> + + + + + + {t("certificate.page.hostnames.title")} + {t("certificate.page.hostnames.description")} + + + + + + {trustedHostnames.length === 0 && ( + + )} + + {trustedHostnames.length >= 1 && ( + + + + {t("certificate.field.hostname.label")} + {t("certificate.field.subject.label")} + {t("certificate.field.fingerprint.label")} + + + + + {trustedHostnames.map(({ hostname, subject, thumbprint }) => ( + + {hostname} + {subject} + {thumbprint} + + + + + + + ))} + +
+ )} +
+ + ); +} diff --git a/apps/nextjs/src/app/[locale]/manage/tools/certificates/page.tsx b/apps/nextjs/src/app/[locale]/manage/tools/certificates/page.tsx index cac4915d5..97aca1c59 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/certificates/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/certificates/page.tsx @@ -1,6 +1,7 @@ import { X509Certificate } from "node:crypto"; +import Link from "next/link"; import { notFound } from "next/navigation"; -import { Card, Group, SimpleGrid, Stack, Text, Title } from "@mantine/core"; +import { Button, Card, Group, SimpleGrid, Stack, Text, Title } from "@mantine/core"; import { IconAlertTriangle, IconCertificate, IconCertificateOff } from "@tabler/icons-react"; import dayjs from "dayjs"; @@ -64,7 +65,12 @@ export default async function CertificatesPage({ params }: CertificatesPageProps {t("certificate.page.list.description")} - + + + +
{x509Certificates.length === 0 && ( diff --git a/apps/nextjs/tsconfig.json b/apps/nextjs/tsconfig.json index 32fb2d1c8..916d4280c 100644 --- a/apps/nextjs/tsconfig.json +++ b/apps/nextjs/tsconfig.json @@ -3,7 +3,8 @@ "compilerOptions": { "baseUrl": ".", "paths": { - "~/*": ["./src/*"] + "~/*": ["./src/*"], + "@homarr/node-unifi": ["../../node_modules/@types/node-unifi"] }, "plugins": [ { diff --git a/apps/tasks/tsconfig.json b/apps/tasks/tsconfig.json index 632d413db..51e14eb17 100644 --- a/apps/tasks/tsconfig.json +++ b/apps/tasks/tsconfig.json @@ -3,7 +3,8 @@ "compilerOptions": { "baseUrl": ".", "paths": { - "~/*": ["src/*"] + "~/*": ["src/*"], + "@homarr/node-unifi": ["../../node_modules/@types/node-unifi"] }, "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" }, diff --git a/apps/websocket/tsconfig.json b/apps/websocket/tsconfig.json index 632d413db..51e14eb17 100644 --- a/apps/websocket/tsconfig.json +++ b/apps/websocket/tsconfig.json @@ -3,7 +3,8 @@ "compilerOptions": { "baseUrl": ".", "paths": { - "~/*": ["src/*"] + "~/*": ["src/*"], + "@homarr/node-unifi": ["../../node_modules/@types/node-unifi"] }, "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" }, diff --git a/package.json b/package.json index fd7ce8375..7a759f1d2 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,9 @@ "@scarf/scarf", "core-js-pure", "protobufjs" - ] + ], + "patchedDependencies": { + "@types/node-unifi": "patches/@types__node-unifi.patch" + } } } diff --git a/packages/api/src/router/certificates/certificate-router.ts b/packages/api/src/router/certificates/certificate-router.ts index 941f5f13a..f69076f35 100644 --- a/packages/api/src/router/certificates/certificate-router.ts +++ b/packages/api/src/router/certificates/certificate-router.ts @@ -4,6 +4,9 @@ import { z } from "zod"; import { zfd } from "zod-form-data"; import { addCustomRootCertificateAsync, removeCustomRootCertificateAsync } from "@homarr/certificates/server"; +import { and, eq } from "@homarr/db"; +import { trustedCertificateHostnames } from "@homarr/db/schema"; +import { logger } from "@homarr/log"; import { certificateValidFileNameSchema, superRefineCertificateFile } from "@homarr/validation/certificates"; import { createTRPCRouter, permissionRequiredProcedure } from "../../trpc"; @@ -20,8 +23,13 @@ export const certificateRouter = createTRPCRouter({ const content = await input.file.text(); // Validate the certificate + let x509Certificate: X509Certificate; try { - new X509Certificate(content); + x509Certificate = new X509Certificate(content); + logger.info("Adding trusted certificate", { + subject: x509Certificate.subject, + issuer: x509Certificate.issuer, + }); } catch { throw new TRPCError({ code: "BAD_REQUEST", @@ -30,11 +38,89 @@ export const certificateRouter = createTRPCRouter({ } await addCustomRootCertificateAsync(input.file.name, content); + + logger.info("Added trusted certificate", { + subject: x509Certificate.subject, + issuer: x509Certificate.issuer, + }); + }), + trustHostnameMismatch: permissionRequiredProcedure + .requiresPermission("admin") + .input(z.object({ hostname: z.string(), certificate: z.string() })) + .mutation(async ({ ctx, input }) => { + // Validate the certificate + let x509Certificate: X509Certificate; + try { + x509Certificate = new X509Certificate(input.certificate); + logger.info("Adding trusted hostname", { + subject: x509Certificate.subject, + issuer: x509Certificate.issuer, + thumbprint: x509Certificate.fingerprint256, + hostname: input.hostname, + }); + } catch { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Invalid certificate", + }); + } + + await ctx.db.insert(trustedCertificateHostnames).values({ + hostname: input.hostname, + thumbprint: x509Certificate.fingerprint256, + certificate: input.certificate, + }); + + logger.info("Added trusted hostname", { + subject: x509Certificate.subject, + issuer: x509Certificate.issuer, + thumbprint: x509Certificate.fingerprint256, + hostname: input.hostname, + }); + }), + removeTrustedHostname: permissionRequiredProcedure + .requiresPermission("admin") + .input(z.object({ hostname: z.string(), thumbprint: z.string() })) + .mutation(async ({ ctx, input }) => { + logger.info("Removing trusted hostname", { + hostname: input.hostname, + thumbprint: input.thumbprint, + }); + const dbResult = await ctx.db + .delete(trustedCertificateHostnames) + .where( + and( + eq(trustedCertificateHostnames.hostname, input.hostname), + eq(trustedCertificateHostnames.thumbprint, input.thumbprint), + ), + ); + + logger.info("Removed trusted hostname", { + hostname: input.hostname, + thumbprint: input.thumbprint, + count: dbResult.changes, + }); }), removeCertificate: permissionRequiredProcedure .requiresPermission("admin") .input(z.object({ fileName: certificateValidFileNameSchema })) - .mutation(async ({ input }) => { - await removeCustomRootCertificateAsync(input.fileName); + .mutation(async ({ input, ctx }) => { + logger.info("Removing trusted certificate", { + fileName: input.fileName, + }); + + const certificate = await removeCustomRootCertificateAsync(input.fileName); + if (!certificate) return; + + // Delete all trusted hostnames for this certificate + await ctx.db + .delete(trustedCertificateHostnames) + .where(eq(trustedCertificateHostnames.thumbprint, certificate.fingerprint256)); + + logger.info("Removed trusted certificate", { + fileName: input.fileName, + subject: certificate.subject, + issuer: certificate.issuer, + }); }), }); diff --git a/packages/api/src/router/integration/integration-router.ts b/packages/api/src/router/integration/integration-router.ts index 02a50707e..5c898fbef 100644 --- a/packages/api/src/router/integration/integration-router.ts +++ b/packages/api/src/router/integration/integration-router.ts @@ -24,6 +24,7 @@ import { integrationSecretKindObject, } from "@homarr/definitions"; import { createIntegrationAsync } from "@homarr/integrations"; +import { logger } from "@homarr/log"; import { byIdSchema } from "@homarr/validation/common"; import { integrationCreateSchema, @@ -34,7 +35,8 @@ import { import { createOneIntegrationMiddleware } from "../../middlewares/integration"; import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc"; import { throwIfActionForbiddenAsync } from "./integration-access"; -import { testConnectionAsync } from "./integration-test-connection"; +import { MissingSecretError, testConnectionAsync } from "./integration-test-connection"; +import { mapTestConnectionError } from "./map-test-connection-error"; export const integrationRouter = createTRPCRouter({ all: publicProcedure.query(async ({ ctx }) => { @@ -185,14 +187,34 @@ export const integrationRouter = createTRPCRouter({ .requiresPermission("integration-create") .input(integrationCreateSchema) .mutation(async ({ ctx, input }) => { - await testConnectionAsync({ + logger.info("Creating integration", { + name: input.name, + kind: input.kind, + url: input.url, + }); + + const result = await testConnectionAsync({ id: "new", name: input.name, url: input.url, kind: input.kind, secrets: input.secrets, + }).catch((error) => { + if (!(error instanceof MissingSecretError)) throw error; + + throw new TRPCError({ + code: "BAD_REQUEST", + message: error.message, + }); }); + if (!result.success) { + logger.error(result.error); + return { + error: mapTestConnectionError(result.error), + }; + } + const integrationId = createId(); await ctx.db.insert(integrations).values({ id: integrationId, @@ -211,6 +233,13 @@ export const integrationRouter = createTRPCRouter({ ); } + logger.info("Created integration", { + id: integrationId, + name: input.name, + kind: input.kind, + url: input.url, + }); + if ( input.attemptSearchEngineCreation && integrationDefs[input.kind].category.flatMap((category) => category).includes("search") @@ -229,6 +258,10 @@ export const integrationRouter = createTRPCRouter({ update: protectedProcedure.input(integrationUpdateSchema).mutation(async ({ ctx, input }) => { await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full"); + logger.info("Updating integration", { + id: input.id, + }); + const integration = await ctx.db.query.integrations.findFirst({ where: eq(integrations.id, input.id), with: { @@ -243,7 +276,7 @@ export const integrationRouter = createTRPCRouter({ }); } - await testConnectionAsync( + const testResult = await testConnectionAsync( { id: input.id, name: input.name, @@ -252,7 +285,21 @@ export const integrationRouter = createTRPCRouter({ secrets: input.secrets, }, integration.secrets, - ); + ).catch((error) => { + if (!(error instanceof MissingSecretError)) throw error; + + throw new TRPCError({ + code: "BAD_REQUEST", + message: error.message, + }); + }); + + if (!testResult.success) { + logger.error(testResult.error); + return { + error: mapTestConnectionError(testResult.error), + }; + } await ctx.db .update(integrations) @@ -286,6 +333,13 @@ export const integrationRouter = createTRPCRouter({ } } } + + logger.info("Updated integration", { + id: input.id, + name: input.name, + kind: integration.kind, + url: input.url, + }); }), delete: protectedProcedure.input(byIdSchema).mutation(async ({ ctx, input }) => { await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full"); diff --git a/packages/api/src/router/integration/integration-test-connection.ts b/packages/api/src/router/integration/integration-test-connection.ts index 5888b1ddc..b48cb9386 100644 --- a/packages/api/src/router/integration/integration-test-connection.ts +++ b/packages/api/src/router/integration/integration-test-connection.ts @@ -2,7 +2,7 @@ import { decryptSecret } from "@homarr/common/server"; import type { Integration } from "@homarr/db/schema"; import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions"; import { getAllSecretKindOptions } from "@homarr/definitions"; -import { createIntegrationAsync, IntegrationTestConnectionError } from "@homarr/integrations"; +import { createIntegrationAsync } from "@homarr/integrations"; import { logger } from "@homarr/log"; type FormIntegration = Integration & { @@ -19,6 +19,12 @@ export const testConnectionAsync = async ( value: `${string}.${string}`; }[] = [], ) => { + logger.info("Testing connection", { + integrationName: integration.name, + integrationKind: integration.kind, + integrationUrl: integration.url, + }); + const formSecrets = integration.secrets .filter((secret) => secret.value !== null) .map((secret) => ({ @@ -72,7 +78,15 @@ export const testConnectionAsync = async ( decryptedSecrets, }); - await integrationInstance.testConnectionAsync(); + const result = await integrationInstance.testConnectionAsync(); + if (result.success) { + logger.info("Tested connection successfully", { + integrationName: integration.name, + integrationKind: integration.kind, + integrationUrl: integration.url, + }); + } + return result; }; interface SourcedIntegrationSecret { @@ -87,7 +101,7 @@ const getSecretKindOption = (kind: IntegrationKind, sourcedSecrets: SourcedInteg ); if (matchingSecretKindOptions.length === 0) { - throw new IntegrationTestConnectionError("secretNotDefined"); + throw new MissingSecretError(); } if (matchingSecretKindOptions.length === 1) { @@ -122,3 +136,9 @@ const getSecretKindOption = (kind: IntegrationKind, sourcedSecrets: SourcedInteg // eslint-disable-next-line @typescript-eslint/no-non-null-assertion return matchingSecretKindOptions[0]!; }; + +export class MissingSecretError extends Error { + constructor() { + super("No secret defined for this integration"); + } +} diff --git a/packages/api/src/router/integration/map-test-connection-error.ts b/packages/api/src/router/integration/map-test-connection-error.ts new file mode 100644 index 000000000..66620235a --- /dev/null +++ b/packages/api/src/router/integration/map-test-connection-error.ts @@ -0,0 +1,141 @@ +import type { X509Certificate } from "node:crypto"; + +import type { RequestErrorCode } from "@homarr/common/server"; +import type { + AnyTestConnectionError, + TestConnectionErrorDataOfType, + TestConnectionErrorType, +} from "@homarr/integrations/test-connection"; + +export interface MappedError { + name: string; + message: string; + metadata: { key: string; value: string | number | boolean }[]; + cause?: MappedError; +} + +const ignoredErrorProperties = ["name", "message", "cause", "stack"]; +const mapError = (error: Error): MappedError => { + const metadata = Object.entries(error) + .filter(([key]) => !ignoredErrorProperties.includes(key)) + .map(([key, value]) => { + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { + return { key, value }; + } + return null; + }) + .filter((value) => value !== null); + return { + name: error.name, + message: error.message, + metadata, + cause: error.cause && error.cause instanceof Error ? mapError(error.cause) : undefined, + }; +}; + +export interface MappedCertificate { + isSelfSigned: boolean; + issuer: string; + subject: string; + serialNumber: string; + validFrom: Date; + validTo: Date; + fingerprint: string; + pem: string; +} + +const mapCertificate = (certificate: X509Certificate, code: RequestErrorCode): MappedCertificate => ({ + isSelfSigned: certificate.ca || code === "DEPTH_ZERO_SELF_SIGNED_CERT", + issuer: certificate.issuer, + subject: certificate.subject, + serialNumber: certificate.serialNumber, + validFrom: certificate.validFromDate, + validTo: certificate.validToDate, + fingerprint: certificate.fingerprint256, + pem: certificate.toString(), +}); + +type MappedData = TType extends "unknown" | "parse" + ? undefined + : TType extends "certificate" + ? { + type: TestConnectionErrorDataOfType["requestError"]["type"]; + reason: TestConnectionErrorDataOfType["requestError"]["reason"]; + certificate: MappedCertificate; + } + : TType extends "request" + ? { + type: TestConnectionErrorDataOfType["requestError"]["type"]; + reason: TestConnectionErrorDataOfType["requestError"]["reason"]; + } + : TType extends "authorization" + ? { + statusCode: TestConnectionErrorDataOfType["statusCode"]; + reason: TestConnectionErrorDataOfType["reason"]; + } + : TType extends "statusCode" + ? { + statusCode: TestConnectionErrorDataOfType["statusCode"]; + reason: TestConnectionErrorDataOfType["reason"]; + url: TestConnectionErrorDataOfType["url"]; + } + : never; + +type AnyMappedData = { + [TType in TestConnectionErrorType]: MappedData; +}[TestConnectionErrorType]; + +const mapData = (error: AnyTestConnectionError): AnyMappedData => { + if (error.type === "unknown") return undefined; + if (error.type === "parse") return undefined; + if (error.type === "certificate") { + return { + type: error.data.requestError.type, + reason: error.data.requestError.reason, + certificate: mapCertificate(error.data.certificate, error.data.requestError.code), + }; + } + if (error.type === "request") { + return { + type: error.data.requestError.type, + reason: error.data.requestError.reason, + }; + } + if (error.type === "authorization") { + return { + statusCode: error.data.statusCode, + reason: error.data.reason, + }; + } + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (error.type === "statusCode") { + return { + statusCode: error.data.statusCode, + reason: error.data.reason, + url: error.data.url, + }; + } + + throw new Error(`Unsupported error type: ${(error as AnyTestConnectionError).type}`); +}; + +interface MappedTestConnectionError { + type: TType; + name: string; + message: string; + data: MappedData; + cause?: MappedError; +} +export type AnyMappedTestConnectionError = { + [TType in TestConnectionErrorType]: MappedTestConnectionError; +}[TestConnectionErrorType]; + +export const mapTestConnectionError = (error: AnyTestConnectionError) => { + return { + type: error.type, + name: error.name, + message: error.message, + data: mapData(error), + cause: error.cause ? mapError(error.cause) : undefined, + } as AnyMappedTestConnectionError; +}; diff --git a/packages/api/src/router/test/integration/integration-router.spec.ts b/packages/api/src/router/test/integration/integration-router.spec.ts index e786eb535..582752a6b 100644 --- a/packages/api/src/router/test/integration/integration-router.spec.ts +++ b/packages/api/src/router/test/integration/integration-router.spec.ts @@ -25,7 +25,7 @@ const defaultSessionWithPermissions = (permissions: GroupPermissionKey[] = []) = // Mock the auth module to return an empty session vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session })); vi.mock("../../integration/integration-test-connection", () => ({ - testConnectionAsync: async () => await Promise.resolve(undefined), + testConnectionAsync: async () => await Promise.resolve({ success: true }), })); describe("all should return all integrations", () => { diff --git a/packages/api/src/router/test/integration/integration-test-connection.spec.ts b/packages/api/src/router/test/integration/integration-test-connection.spec.ts index fad9e961e..bcd279b9a 100644 --- a/packages/api/src/router/test/integration/integration-test-connection.spec.ts +++ b/packages/api/src/router/test/integration/integration-test-connection.spec.ts @@ -22,7 +22,7 @@ describe("testConnectionAsync should run test connection of integration", () => const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions"); factorySpy.mockReturnValue( Promise.resolve({ - testConnectionAsync: async () => await Promise.resolve(), + testConnectionAsync: async () => await Promise.resolve({ success: true }), } as homarrIntegrations.PiHoleIntegrationV6), ); optionsSpy.mockReturnValue([["apiKey"]]); @@ -64,7 +64,7 @@ describe("testConnectionAsync should run test connection of integration", () => const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions"); factorySpy.mockReturnValue( Promise.resolve({ - testConnectionAsync: async () => await Promise.resolve(), + testConnectionAsync: async () => await Promise.resolve({ success: true }), } as homarrIntegrations.PiHoleIntegrationV6), ); optionsSpy.mockReturnValue([["apiKey"]]); @@ -113,7 +113,7 @@ describe("testConnectionAsync should run test connection of integration", () => const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions"); factorySpy.mockReturnValue( Promise.resolve({ - testConnectionAsync: async () => await Promise.resolve(), + testConnectionAsync: async () => await Promise.resolve({ success: true }), } as homarrIntegrations.PiHoleIntegrationV6), ); optionsSpy.mockReturnValue([["apiKey"]]); @@ -162,7 +162,7 @@ describe("testConnectionAsync should run test connection of integration", () => const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions"); factorySpy.mockReturnValue( Promise.resolve({ - testConnectionAsync: async () => await Promise.resolve(), + testConnectionAsync: async () => await Promise.resolve({ success: true }), } as homarrIntegrations.PiHoleIntegrationV6), ); optionsSpy.mockReturnValue([["username", "password"], ["apiKey"]]); @@ -215,7 +215,7 @@ describe("testConnectionAsync should run test connection of integration", () => const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions"); factorySpy.mockReturnValue( Promise.resolve({ - testConnectionAsync: async () => await Promise.resolve(), + testConnectionAsync: async () => await Promise.resolve({ success: true }), } as homarrIntegrations.PiHoleIntegrationV6), ); optionsSpy.mockReturnValue([["username", "password"], ["apiKey"]]); diff --git a/packages/certificates/package.json b/packages/certificates/package.json index fc6a27b37..9bc86d64c 100644 --- a/packages/certificates/package.json +++ b/packages/certificates/package.json @@ -23,6 +23,7 @@ "prettier": "@homarr/prettier-config", "dependencies": { "@homarr/common": "workspace:^0.1.0", + "@homarr/db": "workspace:^0.1.0", "undici": "7.9.0" }, "devDependencies": { diff --git a/packages/certificates/src/server.ts b/packages/certificates/src/server.ts index cd8ea9551..73f0f9205 100644 --- a/packages/certificates/src/server.ts +++ b/packages/certificates/src/server.ts @@ -1,13 +1,18 @@ +import { X509Certificate } from "node:crypto"; import fsSync from "node:fs"; import fs from "node:fs/promises"; +import type { AgentOptions } from "node:https"; import { Agent as HttpsAgent } from "node:https"; import path from "node:path"; -import { rootCertificates } from "node:tls"; +import { checkServerIdentity, rootCertificates } from "node:tls"; import axios from "axios"; import { fetch } from "undici"; import { env } from "@homarr/common/env"; import { LoggingAgent } from "@homarr/common/server"; +import type { InferSelectModel } from "@homarr/db"; +import { db } from "@homarr/db"; +import type { trustedCertificateHostnames } from "@homarr/db/schema"; const getCertificateFolder = () => { return env.NODE_ENV === "production" @@ -40,10 +45,23 @@ export const loadCustomRootCertificatesAsync = async () => { export const removeCustomRootCertificateAsync = async (fileName: string) => { const folder = getCertificateFolder(); if (!folder) { - return; + return null; } - await fs.rm(path.join(folder, fileName)); + const existingFiles = await fs.readdir(folder, { withFileTypes: true }); + if (!existingFiles.some((file) => file.isFile() && file.name === fileName)) { + throw new Error(`File ${fileName} does not exist`); + } + + const fullPath = path.join(folder, fileName); + const content = await fs.readFile(fullPath, "utf8"); + + await fs.rm(fullPath); + try { + return new X509Certificate(content); + } catch { + return null; + } }; export const addCustomRootCertificateAsync = async (fileName: string, content: string) => { @@ -61,25 +79,56 @@ export const addCustomRootCertificateAsync = async (fileName: string, content: s await fs.writeFile(path.join(folder, fileName), content); }; -export const createCertificateAgentAsync = async () => { +export const getTrustedCertificateHostnamesAsync = async () => { + return await db.query.trustedCertificateHostnames.findMany(); +}; + +export const getAllTrustedCertificatesAsync = async () => { const customCertificates = await loadCustomRootCertificatesAsync(); + return rootCertificates.concat(customCertificates.map((cert) => cert.content)); +}; + +export const createCustomCheckServerIdentity = ( + trustedHostnames: InferSelectModel[], +): typeof checkServerIdentity => { + return (hostname, peerCertificate) => { + const matchingTrustedHostnames = trustedHostnames.filter( + (cert) => cert.thumbprint === peerCertificate.fingerprint256, + ); + + // We trust the certificate if we have a matching hostname + if (matchingTrustedHostnames.some((cert) => cert.hostname === hostname)) return undefined; + + return checkServerIdentity(hostname, peerCertificate); + }; +}; + +export const createCertificateAgentAsync = async (override?: { + ca: string | string[]; + checkServerIdentity: typeof checkServerIdentity; +}) => { return new LoggingAgent({ - connect: { - ca: rootCertificates.concat(customCertificates.map((cert) => cert.content)), + connect: override ?? { + ca: await getAllTrustedCertificatesAsync(), + checkServerIdentity: createCustomCheckServerIdentity(await getTrustedCertificateHostnamesAsync()), }, }); }; -export const createHttpsAgentAsync = async () => { - const customCertificates = await loadCustomRootCertificatesAsync(); - return new HttpsAgent({ - ca: rootCertificates.concat(customCertificates.map((cert) => cert.content)), - }); +export const createHttpsAgentAsync = async (override?: Pick) => { + return new HttpsAgent( + override ?? { + ca: await getAllTrustedCertificatesAsync(), + checkServerIdentity: createCustomCheckServerIdentity(await getTrustedCertificateHostnamesAsync()), + }, + ); }; -export const createAxiosCertificateInstanceAsync = async () => { +export const createAxiosCertificateInstanceAsync = async ( + override?: Pick, +) => { return axios.create({ - httpsAgent: await createHttpsAgentAsync(), + httpsAgent: await createHttpsAgentAsync(override), }); }; diff --git a/packages/common/package.json b/packages/common/package.json index fab22fd96..c5fe11b44 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -34,7 +34,8 @@ "react": "19.1.0", "react-dom": "19.1.0", "undici": "7.9.0", - "zod": "^3.24.4" + "zod": "^3.24.4", + "zod-validation-error": "^3.4.0" }, "devDependencies": { "@homarr/eslint-config": "workspace:^0.2.0", diff --git a/packages/common/src/errors/http/handlers/axios-http-error-handler.ts b/packages/common/src/errors/http/handlers/axios-http-error-handler.ts new file mode 100644 index 000000000..471be713f --- /dev/null +++ b/packages/common/src/errors/http/handlers/axios-http-error-handler.ts @@ -0,0 +1,43 @@ +import { AxiosError } from "axios"; + +import { logger } from "@homarr/log"; + +import type { AnyRequestError } from "../request-error"; +import { RequestError } from "../request-error"; +import { ResponseError } from "../response-error"; +import { matchErrorCode } from "./fetch-http-error-handler"; +import { HttpErrorHandler } from "./http-error-handler"; + +export class AxiosHttpErrorHandler extends HttpErrorHandler { + handleRequestError(error: unknown): AnyRequestError | undefined { + if (!(error instanceof AxiosError)) return undefined; + if (error.code === undefined) return undefined; + + logger.debug("Received Axios request error", { + code: error.code, + message: error.message, + }); + + const requestErrorInput = matchErrorCode(error.code); + if (!requestErrorInput) return undefined; + + return new RequestError(requestErrorInput, { + cause: error, + }); + } + handleResponseError(error: unknown): ResponseError | undefined { + if (!(error instanceof AxiosError)) return undefined; + if (error.response === undefined) return undefined; + + logger.debug("Received Axios response error", { + status: error.response.status, + url: error.response.config.url, + message: error.message, + }); + + return new ResponseError({ + status: error.response.status, + url: error.response.config.url ?? "?", + }); + } +} diff --git a/packages/common/src/errors/http/handlers/fetch-http-error-handler.ts b/packages/common/src/errors/http/handlers/fetch-http-error-handler.ts new file mode 100644 index 000000000..db5d7b9fb --- /dev/null +++ b/packages/common/src/errors/http/handlers/fetch-http-error-handler.ts @@ -0,0 +1,68 @@ +import { logger } from "@homarr/log"; + +import { objectEntries } from "../../../object"; +import type { Modify } from "../../../types"; +import type { AnyRequestError, AnyRequestErrorInput, RequestErrorCode, RequestErrorReason } from "../request-error"; +import { RequestError, requestErrorMap } from "../request-error"; +import type { ResponseError } from "../response-error"; +import { HttpErrorHandler } from "./http-error-handler"; + +export class FetchHttpErrorHandler extends HttpErrorHandler { + constructor(private type = "undici") { + super(); + } + + handleRequestError(error: unknown): AnyRequestError | undefined { + if (!isTypeErrorWithCode(error)) return undefined; + + logger.debug(`Received ${this.type} request error`, { + code: error.cause.code, + }); + + const result = matchErrorCode(error.cause.code); + if (!result) return undefined; + + return new RequestError(result, { cause: error }); + } + + /** + * Response errors do not exist for fetch as it does not throw errors for non successful responses. + */ + handleResponseError(_: unknown): ResponseError | undefined { + return undefined; + } +} + +type TypeErrorWithCode = Modify< + TypeError, + { + cause: Error & { code: string }; + } +>; + +const isTypeErrorWithCode = (error: unknown): error is TypeErrorWithCode => { + return ( + error instanceof TypeError && + error.cause instanceof Error && + "code" in error.cause && + typeof error.cause.code === "string" + ); +}; + +export const matchErrorCode = (code: string): AnyRequestErrorInput | undefined => { + for (const [key, value] of objectEntries(requestErrorMap)) { + const entries = Object.entries(value) as [string, string | string[]][]; + const found = entries.find(([_, entryCode]) => + typeof entryCode === "string" ? entryCode === code : entryCode.includes(code), + ); + if (!found) continue; + + return { + type: key, + reason: found[0] as RequestErrorReason, + code: code as RequestErrorCode, + }; + } + + return undefined; +}; diff --git a/packages/common/src/errors/http/handlers/http-error-handler.ts b/packages/common/src/errors/http/handlers/http-error-handler.ts new file mode 100644 index 000000000..4195e0dd5 --- /dev/null +++ b/packages/common/src/errors/http/handlers/http-error-handler.ts @@ -0,0 +1,7 @@ +import type { AnyRequestError } from "../request-error"; +import type { ResponseError } from "../response-error"; + +export abstract class HttpErrorHandler { + abstract handleRequestError(error: unknown): AnyRequestError | undefined; + abstract handleResponseError(error: unknown): ResponseError | undefined; +} diff --git a/packages/common/src/errors/http/handlers/index.ts b/packages/common/src/errors/http/handlers/index.ts new file mode 100644 index 000000000..0288a6b6c --- /dev/null +++ b/packages/common/src/errors/http/handlers/index.ts @@ -0,0 +1,5 @@ +export * from "./http-error-handler"; +export * from "./fetch-http-error-handler"; +export * from "./ofetch-http-error-handler"; +export * from "./axios-http-error-handler"; +export * from "./tsdav-http-error-handler"; diff --git a/packages/common/src/errors/http/handlers/ofetch-http-error-handler.ts b/packages/common/src/errors/http/handlers/ofetch-http-error-handler.ts new file mode 100644 index 000000000..22d79ad2c --- /dev/null +++ b/packages/common/src/errors/http/handlers/ofetch-http-error-handler.ts @@ -0,0 +1,38 @@ +import { FetchError } from "ofetch"; + +import { logger } from "@homarr/log"; + +import type { AnyRequestError } from "../request-error"; +import { ResponseError } from "../response-error"; +import { FetchHttpErrorHandler } from "./fetch-http-error-handler"; +import { HttpErrorHandler } from "./http-error-handler"; + +/** + * Ofetch is a wrapper around the native fetch API + * which will always throw the FetchError (also for non successful responses). + * + * It is for example used within the ctrl packages like qbittorrent, deluge, transmission, etc. + */ +export class OFetchHttpErrorHandler extends HttpErrorHandler { + handleRequestError(error: unknown): AnyRequestError | undefined { + if (!(error instanceof FetchError)) return undefined; + if (!(error.cause instanceof TypeError)) return undefined; + + const result = new FetchHttpErrorHandler("ofetch").handleRequestError(error.cause); + if (!result) return undefined; + + return result; + } + + handleResponseError(error: unknown): ResponseError | undefined { + if (!(error instanceof FetchError)) return undefined; + if (error.response === undefined) return undefined; + + logger.debug("Received ofetch response error", { + status: error.response.status, + url: error.response.url, + }); + + return new ResponseError(error.response); + } +} diff --git a/packages/common/src/errors/http/handlers/tsdav-http-error-handler.ts b/packages/common/src/errors/http/handlers/tsdav-http-error-handler.ts new file mode 100644 index 000000000..9f9c363c5 --- /dev/null +++ b/packages/common/src/errors/http/handlers/tsdav-http-error-handler.ts @@ -0,0 +1,25 @@ +import { logger } from "@homarr/log"; + +import type { AnyRequestError } from "../request-error"; +import { ResponseError } from "../response-error"; +import { FetchHttpErrorHandler } from "./fetch-http-error-handler"; +import { HttpErrorHandler } from "./http-error-handler"; + +export class TsdavHttpErrorHandler extends HttpErrorHandler { + handleRequestError(error: unknown): AnyRequestError | undefined { + return new FetchHttpErrorHandler("tsdav").handleRequestError(error); + } + + handleResponseError(error: unknown): ResponseError | undefined { + if (!(error instanceof Error)) return undefined; + // Tsdav sadly does not throw a custom error and rather just uses "Error" + // https://github.com/natelindev/tsdav/blob/bf33f04b1884694d685ee6f2b43fe9354b12d167/src/account.ts#L86 + if (error.message !== "Invalid credentials") return undefined; + + logger.debug("Received Tsdav response error", { + status: 401, + }); + + return new ResponseError({ status: 401, url: "?" }); + } +} diff --git a/packages/common/src/errors/http/index.ts b/packages/common/src/errors/http/index.ts new file mode 100644 index 000000000..368905d06 --- /dev/null +++ b/packages/common/src/errors/http/index.ts @@ -0,0 +1,3 @@ +export * from "./handlers"; +export * from "./request-error"; +export * from "./response-error"; diff --git a/packages/common/src/errors/http/request-error.ts b/packages/common/src/errors/http/request-error.ts new file mode 100644 index 000000000..d294706fc --- /dev/null +++ b/packages/common/src/errors/http/request-error.ts @@ -0,0 +1,73 @@ +export type AnyRequestError = { + [key in keyof RequestErrorMap]: RequestError; +}[keyof RequestErrorMap]; + +export type AnyRequestErrorInput = { + [key in RequestErrorType]: RequestErrorInput; +}[RequestErrorType]; + +export interface RequestErrorInput { + type: TType; + reason: RequestErrorReason; + code: RequestErrorCode; +} + +export class RequestError extends Error { + public readonly type: TType; + public readonly reason: RequestErrorReason; + public readonly code: RequestErrorCode; + + constructor(input: AnyRequestErrorInput, options: { cause?: Error }) { + super("Request failed", options); + this.name = RequestError.name; + + this.type = input.type as TType; + this.reason = input.reason as RequestErrorReason; + this.code = input.code; + } + + get cause(): Error | undefined { + return super.cause as Error | undefined; + } +} + +export const requestErrorMap = { + certificate: { + expired: ["CERT_HAS_EXPIRED"], + hostnameMismatch: ["ERR_TLS_CERT_ALTNAME_INVALID", "CERT_COMMON_NAME_INVALID"], + notYetValid: ["CERT_NOT_YET_VALID"], + untrusted: ["DEPTH_ZERO_SELF_SIGNED_CERT", "UNABLE_TO_VERIFY_LEAF_SIGNATURE", "UNABLE_TO_GET_ISSUER_CERT_LOCALLY"], + }, + connection: { + hostUnreachable: "EHOSTUNREACH", + networkUnreachable: "ENETUNREACH", + refused: "ECONNREFUSED", + reset: "ECONNRESET", + }, + dns: { + notFound: "ENOTFOUND", + timeout: "EAI_AGAIN", + noAnswer: "ENODATA", + }, + timeout: { + aborted: "ECONNABORTED", + timeout: "ETIMEDOUT", + }, +} as const satisfies Record>; + +type RequestErrorMap = typeof requestErrorMap; + +export type RequestErrorType = keyof RequestErrorMap; + +export type RequestErrorReason = keyof RequestErrorMap[TType]; +export type AnyRequestErrorReason = { + [key in keyof RequestErrorMap]: RequestErrorReason; +}[keyof RequestErrorMap]; + +type ExtractInnerValues = { + [K in keyof T]: T[K][keyof T[K]]; +}[keyof T]; + +type FlattenStringOrStringArray = T extends (infer U)[] ? U : T; + +export type RequestErrorCode = FlattenStringOrStringArray>; diff --git a/packages/common/src/errors/http/response-error.ts b/packages/common/src/errors/http/response-error.ts new file mode 100644 index 000000000..a0b381c37 --- /dev/null +++ b/packages/common/src/errors/http/response-error.ts @@ -0,0 +1,12 @@ +export class ResponseError extends Error { + public readonly statusCode: number; + public readonly url?: string; + + constructor(response: { status: number; url?: string }, options?: ErrorOptions) { + super("Response did not indicate success", options); + this.name = ResponseError.name; + + this.statusCode = response.status; + this.url = response.url; + } +} diff --git a/packages/common/src/errors/index.ts b/packages/common/src/errors/index.ts new file mode 100644 index 000000000..e69b29a61 --- /dev/null +++ b/packages/common/src/errors/index.ts @@ -0,0 +1,2 @@ +export * from "./parse"; +export * from "./http"; diff --git a/packages/common/src/errors/parse/handlers/index.ts b/packages/common/src/errors/parse/handlers/index.ts new file mode 100644 index 000000000..9d71e5f02 --- /dev/null +++ b/packages/common/src/errors/parse/handlers/index.ts @@ -0,0 +1,3 @@ +export * from "./parse-error-handler"; +export * from "./zod-parse-error-handler"; +export * from "./json-parse-error-handler"; diff --git a/packages/common/src/errors/parse/handlers/json-parse-error-handler.ts b/packages/common/src/errors/parse/handlers/json-parse-error-handler.ts new file mode 100644 index 000000000..b0b5581a8 --- /dev/null +++ b/packages/common/src/errors/parse/handlers/json-parse-error-handler.ts @@ -0,0 +1,16 @@ +import { logger } from "@homarr/log"; + +import { ParseError } from "../parse-error"; +import { ParseErrorHandler } from "./parse-error-handler"; + +export class JsonParseErrorHandler extends ParseErrorHandler { + handleParseError(error: unknown): ParseError | undefined { + if (!(error instanceof SyntaxError)) return undefined; + + logger.debug("Received JSON parse error", { + message: error.message, + }); + + return new ParseError("Failed to parse json", { cause: error }); + } +} diff --git a/packages/common/src/errors/parse/handlers/parse-error-handler.ts b/packages/common/src/errors/parse/handlers/parse-error-handler.ts new file mode 100644 index 000000000..7b19b579d --- /dev/null +++ b/packages/common/src/errors/parse/handlers/parse-error-handler.ts @@ -0,0 +1,5 @@ +import type { ParseError } from "../parse-error"; + +export abstract class ParseErrorHandler { + abstract handleParseError(error: unknown): ParseError | undefined; +} diff --git a/packages/common/src/errors/parse/handlers/zod-parse-error-handler.ts b/packages/common/src/errors/parse/handlers/zod-parse-error-handler.ts new file mode 100644 index 000000000..c695f1809 --- /dev/null +++ b/packages/common/src/errors/parse/handlers/zod-parse-error-handler.ts @@ -0,0 +1,24 @@ +import { ZodError } from "zod"; +import { fromError } from "zod-validation-error"; + +import { logger } from "@homarr/log"; + +import { ParseError } from "../parse-error"; +import { ParseErrorHandler } from "./parse-error-handler"; + +export class ZodParseErrorHandler extends ParseErrorHandler { + handleParseError(error: unknown): ParseError | undefined { + if (!(error instanceof ZodError)) return undefined; + + // TODO: migrate to zod v4 prettfyError once it's released + // https://v4.zod.dev/v4#error-pretty-printing + const message = fromError(error, { + issueSeparator: "\n", + prefix: null, + }).toString(); + + logger.debug("Received Zod parse error"); + + return new ParseError(message, { cause: error }); + } +} diff --git a/packages/common/src/errors/parse/index.ts b/packages/common/src/errors/parse/index.ts new file mode 100644 index 000000000..be31350c7 --- /dev/null +++ b/packages/common/src/errors/parse/index.ts @@ -0,0 +1,2 @@ +export * from "./handlers"; +export * from "./parse-error"; diff --git a/packages/common/src/errors/parse/parse-error.ts b/packages/common/src/errors/parse/parse-error.ts new file mode 100644 index 000000000..218d559a5 --- /dev/null +++ b/packages/common/src/errors/parse/parse-error.ts @@ -0,0 +1,10 @@ +export class ParseError extends Error { + constructor(message: string, options?: { cause: Error }) { + super(`Failed to parse data:\n${message}`, options); + this.name = ParseError.name; + } + + get cause(): Error { + return super.cause as Error; + } +} diff --git a/packages/common/src/function.ts b/packages/common/src/function.ts new file mode 100644 index 000000000..55a5a9fde --- /dev/null +++ b/packages/common/src/function.ts @@ -0,0 +1,3 @@ +export const isFunction = (value: unknown): value is (...args: unknown[]) => unknown => { + return typeof value === "function"; +}; diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index fc69d4120..d8910a866 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -10,3 +10,4 @@ export * from "./number"; export * from "./error"; export * from "./fetch-with-timeout"; export * from "./theme"; +export * from "./function"; diff --git a/packages/common/src/server.ts b/packages/common/src/server.ts index 8203e2757..5d0a7de4f 100644 --- a/packages/common/src/server.ts +++ b/packages/common/src/server.ts @@ -2,3 +2,4 @@ export * from "./security"; export * from "./encryption"; export * from "./user-agent"; export * from "./fetch-agent"; +export * from "./errors"; diff --git a/packages/db/migrations/mysql/0032_add_trusted_certificate_hostnames.sql b/packages/db/migrations/mysql/0032_add_trusted_certificate_hostnames.sql new file mode 100644 index 000000000..b909fbfff --- /dev/null +++ b/packages/db/migrations/mysql/0032_add_trusted_certificate_hostnames.sql @@ -0,0 +1,6 @@ +CREATE TABLE `trusted_certificate_hostname` ( + `hostname` varchar(256) NOT NULL, + `thumbprint` varchar(128) NOT NULL, + `certificate` text NOT NULL, + CONSTRAINT `trusted_certificate_hostname_hostname_thumbprint_pk` PRIMARY KEY(`hostname`,`thumbprint`) +); diff --git a/packages/db/migrations/mysql/meta/0032_snapshot.json b/packages/db/migrations/mysql/meta/0032_snapshot.json new file mode 100644 index 000000000..c780e6814 --- /dev/null +++ b/packages/db/migrations/mysql/meta/0032_snapshot.json @@ -0,0 +1,2056 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "e1dd9109-989b-4538-9306-45c725672ff3", + "prevId": "177bc66f-d1ec-404c-9ce8-d23e3538e5fd", + "tables": { + "account": { + "name": "account", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "userId_idx": { + "name": "userId_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_provider_provider_account_id_pk": { + "name": "account_provider_provider_account_id_pk", + "columns": ["provider", "provider_account_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "apiKey": { + "name": "apiKey", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "salt": { + "name": "salt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "apiKey_user_id_user_id_fk": { + "name": "apiKey_user_id_user_id_fk", + "tableFrom": "apiKey", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "apiKey_id": { + "name": "apiKey_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "app": { + "name": "app", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "href": { + "name": "href", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ping_url": { + "name": "ping_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "app_id": { + "name": "app_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "boardGroupPermission": { + "name": "boardGroupPermission", + "columns": { + "board_id": { + "name": "board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "boardGroupPermission_board_id_board_id_fk": { + "name": "boardGroupPermission_board_id_board_id_fk", + "tableFrom": "boardGroupPermission", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "boardGroupPermission_group_id_group_id_fk": { + "name": "boardGroupPermission_group_id_group_id_fk", + "tableFrom": "boardGroupPermission", + "tableTo": "group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "boardGroupPermission_board_id_group_id_permission_pk": { + "name": "boardGroupPermission_board_id_group_id_permission_pk", + "columns": ["board_id", "group_id", "permission"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "boardUserPermission": { + "name": "boardUserPermission", + "columns": { + "board_id": { + "name": "board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "boardUserPermission_board_id_board_id_fk": { + "name": "boardUserPermission_board_id_board_id_fk", + "tableFrom": "boardUserPermission", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "boardUserPermission_user_id_user_id_fk": { + "name": "boardUserPermission_user_id_user_id_fk", + "tableFrom": "boardUserPermission", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "boardUserPermission_board_id_user_id_permission_pk": { + "name": "boardUserPermission_board_id_user_id_permission_pk", + "columns": ["board_id", "user_id", "permission"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "board": { + "name": "board", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "creator_id": { + "name": "creator_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "page_title": { + "name": "page_title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "meta_title": { + "name": "meta_title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "logo_image_url": { + "name": "logo_image_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "favicon_image_url": { + "name": "favicon_image_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "background_image_url": { + "name": "background_image_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "background_image_attachment": { + "name": "background_image_attachment", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('fixed')" + }, + "background_image_repeat": { + "name": "background_image_repeat", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('no-repeat')" + }, + "background_image_size": { + "name": "background_image_size", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('cover')" + }, + "primary_color": { + "name": "primary_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('#fa5252')" + }, + "secondary_color": { + "name": "secondary_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('#fd7e14')" + }, + "opacity": { + "name": "opacity", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 100 + }, + "custom_css": { + "name": "custom_css", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon_color": { + "name": "icon_color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "item_radius": { + "name": "item_radius", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('lg')" + }, + "disable_status": { + "name": "disable_status", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "board_creator_id_user_id_fk": { + "name": "board_creator_id_user_id_fk", + "tableFrom": "board", + "tableTo": "user", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "board_id": { + "name": "board_id", + "columns": ["id"] + } + }, + "uniqueConstraints": { + "board_name_unique": { + "name": "board_name_unique", + "columns": ["name"] + } + }, + "checkConstraint": {} + }, + "groupMember": { + "name": "groupMember", + "columns": { + "group_id": { + "name": "group_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "groupMember_group_id_group_id_fk": { + "name": "groupMember_group_id_group_id_fk", + "tableFrom": "groupMember", + "tableTo": "group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "groupMember_user_id_user_id_fk": { + "name": "groupMember_user_id_user_id_fk", + "tableFrom": "groupMember", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "groupMember_group_id_user_id_pk": { + "name": "groupMember_group_id_user_id_pk", + "columns": ["group_id", "user_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "groupPermission": { + "name": "groupPermission", + "columns": { + "group_id": { + "name": "group_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "groupPermission_group_id_group_id_fk": { + "name": "groupPermission_group_id_group_id_fk", + "tableFrom": "groupPermission", + "tableTo": "group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "group": { + "name": "group", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "home_board_id": { + "name": "home_board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mobile_home_board_id": { + "name": "mobile_home_board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "group_owner_id_user_id_fk": { + "name": "group_owner_id_user_id_fk", + "tableFrom": "group", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "group_home_board_id_board_id_fk": { + "name": "group_home_board_id_board_id_fk", + "tableFrom": "group", + "tableTo": "board", + "columnsFrom": ["home_board_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "group_mobile_home_board_id_board_id_fk": { + "name": "group_mobile_home_board_id_board_id_fk", + "tableFrom": "group", + "tableTo": "board", + "columnsFrom": ["mobile_home_board_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "group_id": { + "name": "group_id", + "columns": ["id"] + } + }, + "uniqueConstraints": { + "group_name_unique": { + "name": "group_name_unique", + "columns": ["name"] + } + }, + "checkConstraint": {} + }, + "iconRepository": { + "name": "iconRepository", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "varchar(150)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "iconRepository_id": { + "name": "iconRepository_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "icon": { + "name": "icon", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(250)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "icon_repository_id": { + "name": "icon_repository_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "icon_icon_repository_id_iconRepository_id_fk": { + "name": "icon_icon_repository_id_iconRepository_id_fk", + "tableFrom": "icon", + "tableTo": "iconRepository", + "columnsFrom": ["icon_repository_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "icon_id": { + "name": "icon_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "integrationGroupPermissions": { + "name": "integrationGroupPermissions", + "columns": { + "integration_id": { + "name": "integration_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "integrationGroupPermissions_integration_id_integration_id_fk": { + "name": "integrationGroupPermissions_integration_id_integration_id_fk", + "tableFrom": "integrationGroupPermissions", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integrationGroupPermissions_group_id_group_id_fk": { + "name": "integrationGroupPermissions_group_id_group_id_fk", + "tableFrom": "integrationGroupPermissions", + "tableTo": "group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integration_group_permission__pk": { + "name": "integration_group_permission__pk", + "columns": ["integration_id", "group_id", "permission"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "integration_item": { + "name": "integration_item", + "columns": { + "item_id": { + "name": "item_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "integration_id": { + "name": "integration_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "integration_item_item_id_item_id_fk": { + "name": "integration_item_item_id_item_id_fk", + "tableFrom": "integration_item", + "tableTo": "item", + "columnsFrom": ["item_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integration_item_integration_id_integration_id_fk": { + "name": "integration_item_integration_id_integration_id_fk", + "tableFrom": "integration_item", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integration_item_item_id_integration_id_pk": { + "name": "integration_item_item_id_integration_id_pk", + "columns": ["item_id", "integration_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "integrationSecret": { + "name": "integrationSecret", + "columns": { + "kind": { + "name": "kind", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "integration_id": { + "name": "integration_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "integration_secret__kind_idx": { + "name": "integration_secret__kind_idx", + "columns": ["kind"], + "isUnique": false + }, + "integration_secret__updated_at_idx": { + "name": "integration_secret__updated_at_idx", + "columns": ["updated_at"], + "isUnique": false + } + }, + "foreignKeys": { + "integrationSecret_integration_id_integration_id_fk": { + "name": "integrationSecret_integration_id_integration_id_fk", + "tableFrom": "integrationSecret", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integrationSecret_integration_id_kind_pk": { + "name": "integrationSecret_integration_id_kind_pk", + "columns": ["integration_id", "kind"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "integrationUserPermission": { + "name": "integrationUserPermission", + "columns": { + "integration_id": { + "name": "integration_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "integrationUserPermission_integration_id_integration_id_fk": { + "name": "integrationUserPermission_integration_id_integration_id_fk", + "tableFrom": "integrationUserPermission", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integrationUserPermission_user_id_user_id_fk": { + "name": "integrationUserPermission_user_id_user_id_fk", + "tableFrom": "integrationUserPermission", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integrationUserPermission_integration_id_user_id_permission_pk": { + "name": "integrationUserPermission_integration_id_user_id_permission_pk", + "columns": ["integration_id", "user_id", "permission"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "integration": { + "name": "integration", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "integration__kind_idx": { + "name": "integration__kind_idx", + "columns": ["kind"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "integration_id": { + "name": "integration_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "invite": { + "name": "invite", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiration_date": { + "name": "expiration_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "creator_id": { + "name": "creator_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "invite_creator_id_user_id_fk": { + "name": "invite_creator_id_user_id_fk", + "tableFrom": "invite", + "tableTo": "user", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "invite_id": { + "name": "invite_id", + "columns": ["id"] + } + }, + "uniqueConstraints": { + "invite_token_unique": { + "name": "invite_token_unique", + "columns": ["token"] + } + }, + "checkConstraint": {} + }, + "item_layout": { + "name": "item_layout", + "columns": { + "item_id": { + "name": "item_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_id": { + "name": "section_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "layout_id": { + "name": "layout_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "x_offset": { + "name": "x_offset", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "y_offset": { + "name": "y_offset", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "width": { + "name": "width", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "item_layout_item_id_item_id_fk": { + "name": "item_layout_item_id_item_id_fk", + "tableFrom": "item_layout", + "tableTo": "item", + "columnsFrom": ["item_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "item_layout_section_id_section_id_fk": { + "name": "item_layout_section_id_section_id_fk", + "tableFrom": "item_layout", + "tableTo": "section", + "columnsFrom": ["section_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "item_layout_layout_id_layout_id_fk": { + "name": "item_layout_layout_id_layout_id_fk", + "tableFrom": "item_layout", + "tableTo": "layout", + "columnsFrom": ["layout_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "item_layout_item_id_section_id_layout_id_pk": { + "name": "item_layout_item_id_section_id_layout_id_pk", + "columns": ["item_id", "section_id", "layout_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "item": { + "name": "item", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "board_id": { + "name": "board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "options": { + "name": "options", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('{\"json\": {}}')" + }, + "advanced_options": { + "name": "advanced_options", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('{\"json\": {}}')" + } + }, + "indexes": {}, + "foreignKeys": { + "item_board_id_board_id_fk": { + "name": "item_board_id_board_id_fk", + "tableFrom": "item", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "item_id": { + "name": "item_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "layout": { + "name": "layout", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "board_id": { + "name": "board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "column_count": { + "name": "column_count", + "type": "tinyint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "breakpoint": { + "name": "breakpoint", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "layout_board_id_board_id_fk": { + "name": "layout_board_id_board_id_fk", + "tableFrom": "layout", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "layout_id": { + "name": "layout_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "media": { + "name": "media", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "BLOB", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "creator_id": { + "name": "creator_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "media_creator_id_user_id_fk": { + "name": "media_creator_id_user_id_fk", + "tableFrom": "media", + "tableTo": "user", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "media_id": { + "name": "media_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "onboarding": { + "name": "onboarding", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "step": { + "name": "step", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "previous_step": { + "name": "previous_step", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "onboarding_id": { + "name": "onboarding_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "search_engine": { + "name": "search_engine", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "short": { + "name": "short", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url_template": { + "name": "url_template", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'generic'" + }, + "integration_id": { + "name": "integration_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "search_engine_integration_id_integration_id_fk": { + "name": "search_engine_integration_id_integration_id_fk", + "tableFrom": "search_engine", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "search_engine_id": { + "name": "search_engine_id", + "columns": ["id"] + } + }, + "uniqueConstraints": { + "search_engine_short_unique": { + "name": "search_engine_short_unique", + "columns": ["short"] + } + }, + "checkConstraint": {} + }, + "section_collapse_state": { + "name": "section_collapse_state", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_id": { + "name": "section_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collapsed": { + "name": "collapsed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "section_collapse_state_user_id_user_id_fk": { + "name": "section_collapse_state_user_id_user_id_fk", + "tableFrom": "section_collapse_state", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "section_collapse_state_section_id_section_id_fk": { + "name": "section_collapse_state_section_id_section_id_fk", + "tableFrom": "section_collapse_state", + "tableTo": "section", + "columnsFrom": ["section_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "section_collapse_state_user_id_section_id_pk": { + "name": "section_collapse_state_user_id_section_id_pk", + "columns": ["user_id", "section_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "section_layout": { + "name": "section_layout", + "columns": { + "section_id": { + "name": "section_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "layout_id": { + "name": "layout_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_section_id": { + "name": "parent_section_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "x_offset": { + "name": "x_offset", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "y_offset": { + "name": "y_offset", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "width": { + "name": "width", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "section_layout_section_id_section_id_fk": { + "name": "section_layout_section_id_section_id_fk", + "tableFrom": "section_layout", + "tableTo": "section", + "columnsFrom": ["section_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "section_layout_layout_id_layout_id_fk": { + "name": "section_layout_layout_id_layout_id_fk", + "tableFrom": "section_layout", + "tableTo": "layout", + "columnsFrom": ["layout_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "section_layout_parent_section_id_section_id_fk": { + "name": "section_layout_parent_section_id_section_id_fk", + "tableFrom": "section_layout", + "tableTo": "section", + "columnsFrom": ["parent_section_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "section_layout_section_id_layout_id_pk": { + "name": "section_layout_section_id_layout_id_pk", + "columns": ["section_id", "layout_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "section": { + "name": "section", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "board_id": { + "name": "board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "x_offset": { + "name": "x_offset", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "y_offset": { + "name": "y_offset", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "options": { + "name": "options", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "('{\"json\": {}}')" + } + }, + "indexes": {}, + "foreignKeys": { + "section_board_id_board_id_fk": { + "name": "section_board_id_board_id_fk", + "tableFrom": "section", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "section_id": { + "name": "section_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "serverSetting": { + "name": "serverSetting", + "columns": { + "setting_key": { + "name": "setting_key", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('{\"json\": {}}')" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "serverSetting_setting_key": { + "name": "serverSetting_setting_key", + "columns": ["setting_key"] + } + }, + "uniqueConstraints": { + "serverSetting_settingKey_unique": { + "name": "serverSetting_settingKey_unique", + "columns": ["setting_key"] + } + }, + "checkConstraint": {} + }, + "session": { + "name": "session", + "columns": { + "session_token": { + "name": "session_token", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_id_idx": { + "name": "user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "session_session_token": { + "name": "session_session_token", + "columns": ["session_token"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "trusted_certificate_hostname": { + "name": "trusted_certificate_hostname", + "columns": { + "hostname": { + "name": "hostname", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "thumbprint": { + "name": "thumbprint", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "certificate": { + "name": "certificate", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "trusted_certificate_hostname_hostname_thumbprint_pk": { + "name": "trusted_certificate_hostname_hostname_thumbprint_pk", + "columns": ["hostname", "thumbprint"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "salt": { + "name": "salt", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'credentials'" + }, + "home_board_id": { + "name": "home_board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mobile_home_board_id": { + "name": "mobile_home_board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_search_engine_id": { + "name": "default_search_engine_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "open_search_in_new_tab": { + "name": "open_search_in_new_tab", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "color_scheme": { + "name": "color_scheme", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'dark'" + }, + "first_day_of_week": { + "name": "first_day_of_week", + "type": "tinyint", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "ping_icons_enabled": { + "name": "ping_icons_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_home_board_id_board_id_fk": { + "name": "user_home_board_id_board_id_fk", + "tableFrom": "user", + "tableTo": "board", + "columnsFrom": ["home_board_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "user_mobile_home_board_id_board_id_fk": { + "name": "user_mobile_home_board_id_board_id_fk", + "tableFrom": "user", + "tableTo": "board", + "columnsFrom": ["mobile_home_board_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "user_default_search_engine_id_search_engine_id_fk": { + "name": "user_default_search_engine_id_search_engine_id_fk", + "tableFrom": "user", + "tableTo": "search_engine", + "columnsFrom": ["default_search_engine_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_id": { + "name": "user_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "verificationToken": { + "name": "verificationToken", + "columns": { + "identifier": { + "name": "identifier", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verificationToken_identifier_token_pk": { + "name": "verificationToken_identifier_token_pk", + "columns": ["identifier", "token"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} diff --git a/packages/db/migrations/mysql/meta/_journal.json b/packages/db/migrations/mysql/meta/_journal.json index 63ef0879d..f37a92d73 100644 --- a/packages/db/migrations/mysql/meta/_journal.json +++ b/packages/db/migrations/mysql/meta/_journal.json @@ -225,6 +225,13 @@ "when": 1740784837957, "tag": "0031_add_dynamic_section_options", "breakpoints": true + }, + { + "idx": 32, + "version": "5", + "when": 1746821770071, + "tag": "0032_add_trusted_certificate_hostnames", + "breakpoints": true } ] } diff --git a/packages/db/migrations/sqlite/0032_add_trusted_certificate_hostnames.sql b/packages/db/migrations/sqlite/0032_add_trusted_certificate_hostnames.sql new file mode 100644 index 000000000..fd87de4c0 --- /dev/null +++ b/packages/db/migrations/sqlite/0032_add_trusted_certificate_hostnames.sql @@ -0,0 +1,6 @@ +CREATE TABLE `trusted_certificate_hostname` ( + `hostname` text NOT NULL, + `thumbprint` text NOT NULL, + `certificate` text NOT NULL, + PRIMARY KEY(`hostname`, `thumbprint`) +); diff --git a/packages/db/migrations/sqlite/meta/0032_snapshot.json b/packages/db/migrations/sqlite/meta/0032_snapshot.json new file mode 100644 index 000000000..7e77c1d6b --- /dev/null +++ b/packages/db/migrations/sqlite/meta/0032_snapshot.json @@ -0,0 +1,1976 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "cd54fd6e-3f1e-42a2-8145-e9c40b75fb47", + "prevId": "c83137fb-068e-49cb-b955-bde696efcc6c", + "tables": { + "account": { + "name": "account", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "userId_idx": { + "name": "userId_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_provider_provider_account_id_pk": { + "columns": ["provider", "provider_account_id"], + "name": "account_provider_provider_account_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "apiKey": { + "name": "apiKey", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "salt": { + "name": "salt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "apiKey_user_id_user_id_fk": { + "name": "apiKey_user_id_user_id_fk", + "tableFrom": "apiKey", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "app": { + "name": "app", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "href": { + "name": "href", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ping_url": { + "name": "ping_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "boardGroupPermission": { + "name": "boardGroupPermission", + "columns": { + "board_id": { + "name": "board_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "boardGroupPermission_board_id_board_id_fk": { + "name": "boardGroupPermission_board_id_board_id_fk", + "tableFrom": "boardGroupPermission", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "boardGroupPermission_group_id_group_id_fk": { + "name": "boardGroupPermission_group_id_group_id_fk", + "tableFrom": "boardGroupPermission", + "tableTo": "group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "boardGroupPermission_board_id_group_id_permission_pk": { + "columns": ["board_id", "group_id", "permission"], + "name": "boardGroupPermission_board_id_group_id_permission_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "boardUserPermission": { + "name": "boardUserPermission", + "columns": { + "board_id": { + "name": "board_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "boardUserPermission_board_id_board_id_fk": { + "name": "boardUserPermission_board_id_board_id_fk", + "tableFrom": "boardUserPermission", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "boardUserPermission_user_id_user_id_fk": { + "name": "boardUserPermission_user_id_user_id_fk", + "tableFrom": "boardUserPermission", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "boardUserPermission_board_id_user_id_permission_pk": { + "columns": ["board_id", "user_id", "permission"], + "name": "boardUserPermission_board_id_user_id_permission_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "board": { + "name": "board", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_public": { + "name": "is_public", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "page_title": { + "name": "page_title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "meta_title": { + "name": "meta_title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "logo_image_url": { + "name": "logo_image_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "favicon_image_url": { + "name": "favicon_image_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "background_image_url": { + "name": "background_image_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "background_image_attachment": { + "name": "background_image_attachment", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'fixed'" + }, + "background_image_repeat": { + "name": "background_image_repeat", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'no-repeat'" + }, + "background_image_size": { + "name": "background_image_size", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'cover'" + }, + "primary_color": { + "name": "primary_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'#fa5252'" + }, + "secondary_color": { + "name": "secondary_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'#fd7e14'" + }, + "opacity": { + "name": "opacity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 100 + }, + "custom_css": { + "name": "custom_css", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon_color": { + "name": "icon_color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "item_radius": { + "name": "item_radius", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'lg'" + }, + "disable_status": { + "name": "disable_status", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "board_name_unique": { + "name": "board_name_unique", + "columns": ["name"], + "isUnique": true + } + }, + "foreignKeys": { + "board_creator_id_user_id_fk": { + "name": "board_creator_id_user_id_fk", + "tableFrom": "board", + "tableTo": "user", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "groupMember": { + "name": "groupMember", + "columns": { + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "groupMember_group_id_group_id_fk": { + "name": "groupMember_group_id_group_id_fk", + "tableFrom": "groupMember", + "tableTo": "group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "groupMember_user_id_user_id_fk": { + "name": "groupMember_user_id_user_id_fk", + "tableFrom": "groupMember", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "groupMember_group_id_user_id_pk": { + "columns": ["group_id", "user_id"], + "name": "groupMember_group_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "groupPermission": { + "name": "groupPermission", + "columns": { + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "groupPermission_group_id_group_id_fk": { + "name": "groupPermission_group_id_group_id_fk", + "tableFrom": "groupPermission", + "tableTo": "group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "group": { + "name": "group", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "home_board_id": { + "name": "home_board_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mobile_home_board_id": { + "name": "mobile_home_board_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "group_name_unique": { + "name": "group_name_unique", + "columns": ["name"], + "isUnique": true + } + }, + "foreignKeys": { + "group_owner_id_user_id_fk": { + "name": "group_owner_id_user_id_fk", + "tableFrom": "group", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "group_home_board_id_board_id_fk": { + "name": "group_home_board_id_board_id_fk", + "tableFrom": "group", + "tableTo": "board", + "columnsFrom": ["home_board_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "group_mobile_home_board_id_board_id_fk": { + "name": "group_mobile_home_board_id_board_id_fk", + "tableFrom": "group", + "tableTo": "board", + "columnsFrom": ["mobile_home_board_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "iconRepository": { + "name": "iconRepository", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "icon": { + "name": "icon", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "icon_repository_id": { + "name": "icon_repository_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "icon_icon_repository_id_iconRepository_id_fk": { + "name": "icon_icon_repository_id_iconRepository_id_fk", + "tableFrom": "icon", + "tableTo": "iconRepository", + "columnsFrom": ["icon_repository_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "integrationGroupPermissions": { + "name": "integrationGroupPermissions", + "columns": { + "integration_id": { + "name": "integration_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "integrationGroupPermissions_integration_id_integration_id_fk": { + "name": "integrationGroupPermissions_integration_id_integration_id_fk", + "tableFrom": "integrationGroupPermissions", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integrationGroupPermissions_group_id_group_id_fk": { + "name": "integrationGroupPermissions_group_id_group_id_fk", + "tableFrom": "integrationGroupPermissions", + "tableTo": "group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integrationGroupPermissions_integration_id_group_id_permission_pk": { + "columns": ["integration_id", "group_id", "permission"], + "name": "integrationGroupPermissions_integration_id_group_id_permission_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "integration_item": { + "name": "integration_item", + "columns": { + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "integration_id": { + "name": "integration_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "integration_item_item_id_item_id_fk": { + "name": "integration_item_item_id_item_id_fk", + "tableFrom": "integration_item", + "tableTo": "item", + "columnsFrom": ["item_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integration_item_integration_id_integration_id_fk": { + "name": "integration_item_integration_id_integration_id_fk", + "tableFrom": "integration_item", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integration_item_item_id_integration_id_pk": { + "columns": ["item_id", "integration_id"], + "name": "integration_item_item_id_integration_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "integrationSecret": { + "name": "integrationSecret", + "columns": { + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "integration_id": { + "name": "integration_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "integration_secret__kind_idx": { + "name": "integration_secret__kind_idx", + "columns": ["kind"], + "isUnique": false + }, + "integration_secret__updated_at_idx": { + "name": "integration_secret__updated_at_idx", + "columns": ["updated_at"], + "isUnique": false + } + }, + "foreignKeys": { + "integrationSecret_integration_id_integration_id_fk": { + "name": "integrationSecret_integration_id_integration_id_fk", + "tableFrom": "integrationSecret", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integrationSecret_integration_id_kind_pk": { + "columns": ["integration_id", "kind"], + "name": "integrationSecret_integration_id_kind_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "integrationUserPermission": { + "name": "integrationUserPermission", + "columns": { + "integration_id": { + "name": "integration_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "integrationUserPermission_integration_id_integration_id_fk": { + "name": "integrationUserPermission_integration_id_integration_id_fk", + "tableFrom": "integrationUserPermission", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integrationUserPermission_user_id_user_id_fk": { + "name": "integrationUserPermission_user_id_user_id_fk", + "tableFrom": "integrationUserPermission", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integrationUserPermission_integration_id_user_id_permission_pk": { + "columns": ["integration_id", "user_id", "permission"], + "name": "integrationUserPermission_integration_id_user_id_permission_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "integration": { + "name": "integration", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "integration__kind_idx": { + "name": "integration__kind_idx", + "columns": ["kind"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invite": { + "name": "invite", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiration_date": { + "name": "expiration_date", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "invite_token_unique": { + "name": "invite_token_unique", + "columns": ["token"], + "isUnique": true + } + }, + "foreignKeys": { + "invite_creator_id_user_id_fk": { + "name": "invite_creator_id_user_id_fk", + "tableFrom": "invite", + "tableTo": "user", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "item_layout": { + "name": "item_layout", + "columns": { + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_id": { + "name": "section_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "layout_id": { + "name": "layout_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "x_offset": { + "name": "x_offset", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "y_offset": { + "name": "y_offset", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "item_layout_item_id_item_id_fk": { + "name": "item_layout_item_id_item_id_fk", + "tableFrom": "item_layout", + "tableTo": "item", + "columnsFrom": ["item_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "item_layout_section_id_section_id_fk": { + "name": "item_layout_section_id_section_id_fk", + "tableFrom": "item_layout", + "tableTo": "section", + "columnsFrom": ["section_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "item_layout_layout_id_layout_id_fk": { + "name": "item_layout_layout_id_layout_id_fk", + "tableFrom": "item_layout", + "tableTo": "layout", + "columnsFrom": ["layout_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "item_layout_item_id_section_id_layout_id_pk": { + "columns": ["item_id", "section_id", "layout_id"], + "name": "item_layout_item_id_section_id_layout_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "item": { + "name": "item", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "board_id": { + "name": "board_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "options": { + "name": "options", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{\"json\": {}}'" + }, + "advanced_options": { + "name": "advanced_options", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{\"json\": {}}'" + } + }, + "indexes": {}, + "foreignKeys": { + "item_board_id_board_id_fk": { + "name": "item_board_id_board_id_fk", + "tableFrom": "item", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "layout": { + "name": "layout", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "board_id": { + "name": "board_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "column_count": { + "name": "column_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "breakpoint": { + "name": "breakpoint", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "layout_board_id_board_id_fk": { + "name": "layout_board_id_board_id_fk", + "tableFrom": "layout", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "media": { + "name": "media", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "blob", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "media_creator_id_user_id_fk": { + "name": "media_creator_id_user_id_fk", + "tableFrom": "media", + "tableTo": "user", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "onboarding": { + "name": "onboarding", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "step": { + "name": "step", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "previous_step": { + "name": "previous_step", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "search_engine": { + "name": "search_engine", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "short": { + "name": "short", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url_template": { + "name": "url_template", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'generic'" + }, + "integration_id": { + "name": "integration_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "search_engine_short_unique": { + "name": "search_engine_short_unique", + "columns": ["short"], + "isUnique": true + } + }, + "foreignKeys": { + "search_engine_integration_id_integration_id_fk": { + "name": "search_engine_integration_id_integration_id_fk", + "tableFrom": "search_engine", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "section_collapse_state": { + "name": "section_collapse_state", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_id": { + "name": "section_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collapsed": { + "name": "collapsed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "section_collapse_state_user_id_user_id_fk": { + "name": "section_collapse_state_user_id_user_id_fk", + "tableFrom": "section_collapse_state", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "section_collapse_state_section_id_section_id_fk": { + "name": "section_collapse_state_section_id_section_id_fk", + "tableFrom": "section_collapse_state", + "tableTo": "section", + "columnsFrom": ["section_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "section_collapse_state_user_id_section_id_pk": { + "columns": ["user_id", "section_id"], + "name": "section_collapse_state_user_id_section_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "section_layout": { + "name": "section_layout", + "columns": { + "section_id": { + "name": "section_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "layout_id": { + "name": "layout_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_section_id": { + "name": "parent_section_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "x_offset": { + "name": "x_offset", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "y_offset": { + "name": "y_offset", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "section_layout_section_id_section_id_fk": { + "name": "section_layout_section_id_section_id_fk", + "tableFrom": "section_layout", + "tableTo": "section", + "columnsFrom": ["section_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "section_layout_layout_id_layout_id_fk": { + "name": "section_layout_layout_id_layout_id_fk", + "tableFrom": "section_layout", + "tableTo": "layout", + "columnsFrom": ["layout_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "section_layout_parent_section_id_section_id_fk": { + "name": "section_layout_parent_section_id_section_id_fk", + "tableFrom": "section_layout", + "tableTo": "section", + "columnsFrom": ["parent_section_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "section_layout_section_id_layout_id_pk": { + "columns": ["section_id", "layout_id"], + "name": "section_layout_section_id_layout_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "section": { + "name": "section", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "board_id": { + "name": "board_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "x_offset": { + "name": "x_offset", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "y_offset": { + "name": "y_offset", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "options": { + "name": "options", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'{\"json\": {}}'" + } + }, + "indexes": {}, + "foreignKeys": { + "section_board_id_board_id_fk": { + "name": "section_board_id_board_id_fk", + "tableFrom": "section", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "serverSetting": { + "name": "serverSetting", + "columns": { + "setting_key": { + "name": "setting_key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{\"json\": {}}'" + } + }, + "indexes": { + "serverSetting_settingKey_unique": { + "name": "serverSetting_settingKey_unique", + "columns": ["setting_key"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_id_idx": { + "name": "user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "trusted_certificate_hostname": { + "name": "trusted_certificate_hostname", + "columns": { + "hostname": { + "name": "hostname", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "thumbprint": { + "name": "thumbprint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "certificate": { + "name": "certificate", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "trusted_certificate_hostname_hostname_thumbprint_pk": { + "columns": ["hostname", "thumbprint"], + "name": "trusted_certificate_hostname_hostname_thumbprint_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "salt": { + "name": "salt", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'credentials'" + }, + "home_board_id": { + "name": "home_board_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mobile_home_board_id": { + "name": "mobile_home_board_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_search_engine_id": { + "name": "default_search_engine_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "open_search_in_new_tab": { + "name": "open_search_in_new_tab", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "color_scheme": { + "name": "color_scheme", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'dark'" + }, + "first_day_of_week": { + "name": "first_day_of_week", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "ping_icons_enabled": { + "name": "ping_icons_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_home_board_id_board_id_fk": { + "name": "user_home_board_id_board_id_fk", + "tableFrom": "user", + "tableTo": "board", + "columnsFrom": ["home_board_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "user_mobile_home_board_id_board_id_fk": { + "name": "user_mobile_home_board_id_board_id_fk", + "tableFrom": "user", + "tableTo": "board", + "columnsFrom": ["mobile_home_board_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "user_default_search_engine_id_search_engine_id_fk": { + "name": "user_default_search_engine_id_search_engine_id_fk", + "tableFrom": "user", + "tableTo": "search_engine", + "columnsFrom": ["default_search_engine_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verificationToken": { + "name": "verificationToken", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verificationToken_identifier_token_pk": { + "columns": ["identifier", "token"], + "name": "verificationToken_identifier_token_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/packages/db/migrations/sqlite/meta/_journal.json b/packages/db/migrations/sqlite/meta/_journal.json index 96d7b2bac..0245f5643 100644 --- a/packages/db/migrations/sqlite/meta/_journal.json +++ b/packages/db/migrations/sqlite/meta/_journal.json @@ -225,6 +225,13 @@ "when": 1740784849045, "tag": "0031_add_dynamic_section_options", "breakpoints": true + }, + { + "idx": 32, + "version": "6", + "when": 1746821779051, + "tag": "0032_add_trusted_certificate_hostnames", + "breakpoints": true } ] } diff --git a/packages/db/schema/index.ts b/packages/db/schema/index.ts index f9bcd4e53..fb733d1aa 100644 --- a/packages/db/schema/index.ts +++ b/packages/db/schema/index.ts @@ -39,6 +39,7 @@ export const { layouts, itemLayouts, sectionLayouts, + trustedCertificateHostnames, } = schema; export type User = InferSelectModel; diff --git a/packages/db/schema/mysql.ts b/packages/db/schema/mysql.ts index 3da9ef1b6..55a53a9b5 100644 --- a/packages/db/schema/mysql.ts +++ b/packages/db/schema/mysql.ts @@ -494,6 +494,20 @@ export const onboarding = mysqlTable("onboarding", { previousStep: varchar({ length: 64 }).$type(), }); +export const trustedCertificateHostnames = mysqlTable( + "trusted_certificate_hostname", + { + hostname: varchar({ length: 256 }).notNull(), + thumbprint: varchar({ length: 128 }).notNull(), + certificate: text().notNull(), + }, + (table) => ({ + compoundKey: primaryKey({ + columns: [table.hostname, table.thumbprint], + }), + }), +); + export const accountRelations = relations(accounts, ({ one }) => ({ user: one(users, { fields: [accounts.userId], diff --git a/packages/db/schema/sqlite.ts b/packages/db/schema/sqlite.ts index e1b4ad124..53c06f793 100644 --- a/packages/db/schema/sqlite.ts +++ b/packages/db/schema/sqlite.ts @@ -479,6 +479,20 @@ export const onboarding = sqliteTable("onboarding", { previousStep: text().$type(), }); +export const trustedCertificateHostnames = sqliteTable( + "trusted_certificate_hostname", + { + hostname: text().notNull(), + thumbprint: text().notNull(), + certificate: text().notNull(), + }, + (table) => ({ + compoundKey: primaryKey({ + columns: [table.hostname, table.thumbprint], + }), + }), +); + export const accountRelations = relations(accounts, ({ one }) => ({ user: one(users, { fields: [accounts.userId], diff --git a/packages/integrations/package.json b/packages/integrations/package.json index 916f40e3b..475edfb4e 100644 --- a/packages/integrations/package.json +++ b/packages/integrations/package.json @@ -6,6 +6,7 @@ "type": "module", "exports": { ".": "./index.ts", + "./test-connection": "./src/base/test-connection/index.ts", "./client": "./src/client.ts", "./types": "./src/types.ts" }, @@ -32,13 +33,13 @@ "@homarr/db": "workspace:^0.1.0", "@homarr/definitions": "workspace:^0.1.0", "@homarr/log": "workspace:^0.1.0", + "@homarr/node-unifi": "^2.6.0", "@homarr/redis": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0", "@jellyfin/sdk": "^0.11.0", "maria2": "^0.4.0", "node-ical": "^0.20.1", - "node-unifi": "^2.5.1", "proxmox-api": "1.1.1", "tsdav": "^2.1.4", "undici": "7.9.0", diff --git a/packages/integrations/src/adguard-home/adguard-home-integration.ts b/packages/integrations/src/adguard-home/adguard-home-integration.ts index 2ba8880ef..227ffdbdb 100644 --- a/packages/integrations/src/adguard-home/adguard-home-integration.ts +++ b/packages/integrations/src/adguard-home/adguard-home-integration.ts @@ -1,7 +1,10 @@ import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server"; +import { ParseError } from "@homarr/common/server"; +import type { IntegrationTestingInput } from "../base/integration"; import { Integration } from "../base/integration"; -import { IntegrationTestConnectionError } from "../base/test-connection-error"; +import { TestConnectionError } from "../base/test-connection/test-connection-error"; +import type { TestingResult } from "../base/test-connection/test-connection-service"; import type { DnsHoleSummaryIntegration } from "../interfaces/dns-hole-summary/dns-hole-summary-integration"; import type { DnsHoleSummary } from "../interfaces/dns-hole-summary/dns-hole-summary-types"; import { filteringStatusSchema, statsResponseSchema, statusResponseSchema } from "./adguard-home-types"; @@ -85,26 +88,19 @@ export class AdGuardHomeIntegration extends Integration implements DnsHoleSummar }; } - public async testConnectionAsync(): Promise { - await super.handleTestConnectionResponseAsync({ - queryFunctionAsync: async () => { - return await fetchWithTrustedCertificatesAsync(this.url("/control/status"), { - headers: { - Authorization: `Basic ${this.getAuthorizationHeaderValue()}`, - }, - }); - }, - handleResponseAsync: async (response) => { - try { - const result = await response.json(); - if (typeof result === "object" && result !== null) return; - } catch { - throw new IntegrationTestConnectionError("invalidJson"); - } - - throw new IntegrationTestConnectionError("invalidCredentials"); + protected async testingAsync(input: IntegrationTestingInput): Promise { + const response = await input.fetchAsync(this.url("/control/status"), { + headers: { + Authorization: `Basic ${this.getAuthorizationHeaderValue()}`, }, }); + + if (!response.ok) return TestConnectionError.StatusResult(response); + + const result = await response.json(); + if (typeof result === "object" && result !== null) return { success: true }; + + return TestConnectionError.ParseResult(new ParseError("Expected object data")); } public async enableAsync(): Promise { diff --git a/packages/integrations/src/base/error.ts b/packages/integrations/src/base/error.ts deleted file mode 100644 index 46fe913cf..000000000 --- a/packages/integrations/src/base/error.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { Response as UndiciResponse } from "undici"; -import type { z } from "zod"; - -import type { IntegrationInput } from "./integration"; - -export class ParseError extends Error { - public readonly zodError: z.ZodError; - public readonly input: unknown; - - constructor(dataName: string, zodError: z.ZodError, input?: unknown) { - super(`Failed to parse ${dataName}`); - this.zodError = zodError; - this.input = input; - } -} - -export class ResponseError extends Error { - public readonly statusCode: number; - public readonly url: string; - public readonly content?: string; - - constructor(response: Response | UndiciResponse, content: unknown) { - super("Response failed"); - - this.statusCode = response.status; - this.url = response.url; - - try { - this.content = JSON.stringify(content); - } catch { - this.content = content as string; - } - } -} - -export class IntegrationResponseError extends ResponseError { - public readonly integration: Pick; - - constructor(integration: IntegrationInput, response: Response | UndiciResponse, content: unknown) { - super(response, content); - this.integration = { - id: integration.id, - name: integration.name, - url: integration.url, - }; - } -} diff --git a/packages/integrations/src/base/errors/decorator.ts b/packages/integrations/src/base/errors/decorator.ts new file mode 100644 index 000000000..f9f3a7663 --- /dev/null +++ b/packages/integrations/src/base/errors/decorator.ts @@ -0,0 +1,88 @@ +import { isFunction } from "@homarr/common"; +import { logger } from "@homarr/log"; + +import type { Integration } from "../integration"; +import type { IIntegrationErrorHandler } from "./handler"; +import { IntegrationError } from "./integration-error"; +import { IntegrationUnknownError } from "./integration-unknown-error"; + +const localLogger = logger.child({ + module: "HandleIntegrationErrors", +}); + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type, @typescript-eslint/no-explicit-any +type AbstractConstructor = abstract new (...args: any[]) => T; + +export const HandleIntegrationErrors = (errorHandlers: IIntegrationErrorHandler[]) => { + return >(IntegrationBaseClass: T): T => { + abstract class ErrorHandledIntegration extends IntegrationBaseClass { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + constructor(...args: any[]) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + super(...args); + const processedProperties = new Set(); + + let currentProto: unknown = Object.getPrototypeOf(this); + + while (currentProto && currentProto !== Object.prototype) { + for (const propertyKey of Object.getOwnPropertyNames(currentProto)) { + if (propertyKey === "constructor" || processedProperties.has(propertyKey)) continue; + + const descriptor = Object.getOwnPropertyDescriptor(currentProto, propertyKey); + + if (!descriptor) continue; + const original: unknown = descriptor.value; + if (!isFunction(original)) continue; + + processedProperties.add(propertyKey); + + const wrapped = (...methodArgs: unknown[]) => { + const handleError = (error: unknown) => { + if (error instanceof IntegrationError) { + throw error; + } + + for (const handler of errorHandlers) { + const handledError = handler.handleError(error, this.publicIntegration); + if (!handledError) continue; + + throw handledError; + } + + // If the error was handled and should be thrown again, throw it + localLogger.debug("Unhandled error in integration", { + error: error instanceof Error ? `${error.name}: ${error.message}` : undefined, + integrationName: this.publicIntegration.name, + }); + throw new IntegrationUnknownError(this.publicIntegration, { cause: error }); + }; + + try { + const result = original.apply(this, methodArgs); + + if (result instanceof Promise) { + return result.catch((error: unknown) => { + handleError(error); + }); + } + + return result; + } catch (error: unknown) { + handleError(error); + } + }; + + Object.defineProperty(this, propertyKey, { + ...descriptor, + value: wrapped, + }); + } + + currentProto = Object.getPrototypeOf(currentProto); + } + } + } + + return ErrorHandledIntegration; + }; +}; diff --git a/packages/integrations/src/base/errors/handler.ts b/packages/integrations/src/base/errors/handler.ts new file mode 100644 index 000000000..c81fa1337 --- /dev/null +++ b/packages/integrations/src/base/errors/handler.ts @@ -0,0 +1,5 @@ +import type { IntegrationError, IntegrationErrorData } from "./integration-error"; + +export interface IIntegrationErrorHandler { + handleError(error: unknown, integration: IntegrationErrorData): IntegrationError | undefined; +} diff --git a/packages/integrations/src/base/errors/http/index.ts b/packages/integrations/src/base/errors/http/index.ts new file mode 100644 index 000000000..dc90256db --- /dev/null +++ b/packages/integrations/src/base/errors/http/index.ts @@ -0,0 +1,13 @@ +import { + AxiosHttpErrorHandler, + FetchHttpErrorHandler, + OFetchHttpErrorHandler, + TsdavHttpErrorHandler, +} from "@homarr/common/server"; + +import { IntegrationHttpErrorHandler } from "./integration-http-error-handler"; + +export const integrationFetchHttpErrorHandler = new IntegrationHttpErrorHandler(new FetchHttpErrorHandler()); +export const integrationOFetchHttpErrorHandler = new IntegrationHttpErrorHandler(new OFetchHttpErrorHandler()); +export const integrationAxiosHttpErrorHandler = new IntegrationHttpErrorHandler(new AxiosHttpErrorHandler()); +export const integrationTsdavHttpErrorHandler = new IntegrationHttpErrorHandler(new TsdavHttpErrorHandler()); diff --git a/packages/integrations/src/base/errors/http/integration-http-error-handler.ts b/packages/integrations/src/base/errors/http/integration-http-error-handler.ts new file mode 100644 index 000000000..6eff210b7 --- /dev/null +++ b/packages/integrations/src/base/errors/http/integration-http-error-handler.ts @@ -0,0 +1,26 @@ +import { RequestError, ResponseError } from "@homarr/common/server"; +import type { HttpErrorHandler } from "@homarr/common/server"; + +import type { IIntegrationErrorHandler } from "../handler"; +import type { IntegrationError, IntegrationErrorData } from "../integration-error"; +import { IntegrationRequestError } from "./integration-request-error"; +import { IntegrationResponseError } from "./integration-response-error"; + +export class IntegrationHttpErrorHandler implements IIntegrationErrorHandler { + private readonly httpErrorHandler: HttpErrorHandler; + + constructor(httpErrorHandler: HttpErrorHandler) { + this.httpErrorHandler = httpErrorHandler; + } + + handleError(error: unknown, integration: IntegrationErrorData): IntegrationError | undefined { + if (error instanceof RequestError) return new IntegrationRequestError(integration, { cause: error }); + if (error instanceof ResponseError) return new IntegrationResponseError(integration, { cause: error }); + + const requestError = this.httpErrorHandler.handleRequestError(error); + if (requestError) return new IntegrationRequestError(integration, { cause: requestError }); + const responseError = this.httpErrorHandler.handleResponseError(error); + if (responseError) return new IntegrationResponseError(integration, { cause: responseError }); + return undefined; + } +} diff --git a/packages/integrations/src/base/errors/http/integration-request-error.ts b/packages/integrations/src/base/errors/http/integration-request-error.ts new file mode 100644 index 000000000..2f5c9e934 --- /dev/null +++ b/packages/integrations/src/base/errors/http/integration-request-error.ts @@ -0,0 +1,19 @@ +import type { AnyRequestError, RequestError, RequestErrorType } from "@homarr/common/server"; + +import type { IntegrationErrorData } from "../integration-error"; +import { IntegrationError } from "../integration-error"; + +export type IntegrationRequestErrorOfType = IntegrationRequestError & { + cause: RequestError; +}; + +export class IntegrationRequestError extends IntegrationError { + constructor(integration: IntegrationErrorData, { cause }: { cause: AnyRequestError }) { + super(integration, "Request to integration failed", { cause }); + this.name = IntegrationRequestError.name; + } + + get cause(): AnyRequestError { + return super.cause as AnyRequestError; + } +} diff --git a/packages/integrations/src/base/errors/http/integration-response-error.ts b/packages/integrations/src/base/errors/http/integration-response-error.ts new file mode 100644 index 000000000..ea41ed6db --- /dev/null +++ b/packages/integrations/src/base/errors/http/integration-response-error.ts @@ -0,0 +1,15 @@ +import type { ResponseError } from "@homarr/common/server"; + +import type { IntegrationErrorData } from "../integration-error"; +import { IntegrationError } from "../integration-error"; + +export class IntegrationResponseError extends IntegrationError { + constructor(integration: IntegrationErrorData, { cause }: { cause: ResponseError }) { + super(integration, "Response from integration did not indicate success", { cause }); + this.name = IntegrationResponseError.name; + } + + get cause(): ResponseError { + return super.cause as ResponseError; + } +} diff --git a/packages/integrations/src/base/errors/integration-error.ts b/packages/integrations/src/base/errors/integration-error.ts new file mode 100644 index 000000000..901f4a2af --- /dev/null +++ b/packages/integrations/src/base/errors/integration-error.ts @@ -0,0 +1,18 @@ +export interface IntegrationErrorData { + id: string; + name: string; + url: string; +} + +export abstract class IntegrationError extends Error { + public readonly integrationId: string; + public readonly integrationName: string; + public readonly integrationUrl: string; + + constructor(integration: IntegrationErrorData, message: string, { cause }: ErrorOptions) { + super(message, { cause }); + this.integrationId = integration.id; + this.integrationName = integration.name; + this.integrationUrl = integration.url; + } +} diff --git a/packages/integrations/src/base/errors/integration-unknown-error.ts b/packages/integrations/src/base/errors/integration-unknown-error.ts new file mode 100644 index 000000000..d111562ea --- /dev/null +++ b/packages/integrations/src/base/errors/integration-unknown-error.ts @@ -0,0 +1,8 @@ +import type { IntegrationErrorData } from "./integration-error"; +import { IntegrationError } from "./integration-error"; + +export class IntegrationUnknownError extends IntegrationError { + constructor(integration: IntegrationErrorData, { cause }: ErrorOptions) { + super(integration, "An unknown error occured while executing Integration method", { cause }); + } +} diff --git a/packages/integrations/src/base/errors/parse/index.ts b/packages/integrations/src/base/errors/parse/index.ts new file mode 100644 index 000000000..c4e522cec --- /dev/null +++ b/packages/integrations/src/base/errors/parse/index.ts @@ -0,0 +1,6 @@ +import { JsonParseErrorHandler, ZodParseErrorHandler } from "@homarr/common/server"; + +import { IntegrationParseErrorHandler } from "./integration-parse-error-handler"; + +export const integrationZodParseErrorHandler = new IntegrationParseErrorHandler(new ZodParseErrorHandler()); +export const integrationJsonParseErrorHandler = new IntegrationParseErrorHandler(new JsonParseErrorHandler()); diff --git a/packages/integrations/src/base/errors/parse/integration-parse-error-handler.ts b/packages/integrations/src/base/errors/parse/integration-parse-error-handler.ts new file mode 100644 index 000000000..7e17ba8a7 --- /dev/null +++ b/packages/integrations/src/base/errors/parse/integration-parse-error-handler.ts @@ -0,0 +1,22 @@ +import { ParseError } from "@homarr/common/server"; +import type { ParseErrorHandler } from "@homarr/common/server"; + +import type { IIntegrationErrorHandler } from "../handler"; +import type { IntegrationError, IntegrationErrorData } from "../integration-error"; +import { IntegrationParseError } from "./integration-parse-error"; + +export class IntegrationParseErrorHandler implements IIntegrationErrorHandler { + private readonly parseErrorHandler: ParseErrorHandler; + + constructor(parseErrorHandler: ParseErrorHandler) { + this.parseErrorHandler = parseErrorHandler; + } + + handleError(error: unknown, integration: IntegrationErrorData): IntegrationError | undefined { + if (error instanceof ParseError) return new IntegrationParseError(integration, { cause: error }); + const parseError = this.parseErrorHandler.handleParseError(error); + if (parseError) return new IntegrationParseError(integration, { cause: parseError }); + + return undefined; + } +} diff --git a/packages/integrations/src/base/errors/parse/integration-parse-error.ts b/packages/integrations/src/base/errors/parse/integration-parse-error.ts new file mode 100644 index 000000000..aa289ec5f --- /dev/null +++ b/packages/integrations/src/base/errors/parse/integration-parse-error.ts @@ -0,0 +1,15 @@ +import type { ParseError } from "@homarr/common/server"; + +import type { IntegrationErrorData } from "../integration-error"; +import { IntegrationError } from "../integration-error"; + +export class IntegrationParseError extends IntegrationError { + constructor(integration: IntegrationErrorData, { cause }: { cause: ParseError }) { + super(integration, "Failed to parse integration data", { cause }); + this.name = IntegrationParseError.name; + } + + get cause(): ParseError { + return super.cause as ParseError; + } +} diff --git a/packages/integrations/src/base/integration.ts b/packages/integrations/src/base/integration.ts index b3c1141e6..5496e3e38 100644 --- a/packages/integrations/src/base/integration.ts +++ b/packages/integrations/src/base/integration.ts @@ -1,18 +1,20 @@ -import type { Response } from "undici"; -import { z } from "zod"; +import type tls from "node:tls"; +import type { AxiosInstance } from "axios"; +import type { Dispatcher } from "undici"; +import { fetch as undiciFetch } from "undici"; -import { extractErrorMessage, removeTrailingSlash } from "@homarr/common"; +import { createAxiosCertificateInstanceAsync, createCertificateAgentAsync } from "@homarr/certificates/server"; +import { removeTrailingSlash } from "@homarr/common"; import type { IntegrationSecretKind } from "@homarr/definitions"; -import { logger } from "@homarr/log"; -import type { TranslationObject } from "@homarr/translation"; -import { IntegrationTestConnectionError } from "./test-connection-error"; +import { HandleIntegrationErrors } from "./errors/decorator"; +import { integrationFetchHttpErrorHandler } from "./errors/http"; +import { integrationJsonParseErrorHandler, integrationZodParseErrorHandler } from "./errors/parse"; +import { TestConnectionError } from "./test-connection/test-connection-error"; +import type { TestingResult } from "./test-connection/test-connection-service"; +import { TestConnectionService } from "./test-connection/test-connection-service"; import type { IntegrationSecret } from "./types"; -const causeSchema = z.object({ - code: z.string(), -}); - export interface IntegrationInput { id: string; name: string; @@ -20,9 +22,32 @@ export interface IntegrationInput { decryptedSecrets: IntegrationSecret[]; } +export interface IntegrationTestingInput { + fetchAsync: typeof undiciFetch; + dispatcher: Dispatcher; + axiosInstance: AxiosInstance; + options: { + ca: string[] | string; + checkServerIdentity: typeof tls.checkServerIdentity; + }; +} + +@HandleIntegrationErrors([ + integrationZodParseErrorHandler, + integrationJsonParseErrorHandler, + integrationFetchHttpErrorHandler, +]) export abstract class Integration { constructor(protected integration: IntegrationInput) {} + public get publicIntegration() { + return { + id: this.integration.id, + name: this.integration.name, + url: this.integration.url, + }; + } + protected getSecretValue(kind: IntegrationSecretKind) { const secret = this.integration.decryptedSecrets.find((secret) => secret.kind === kind); if (!secret) { @@ -48,89 +73,43 @@ export abstract class Integration { return url; } - /** - * Test the connection to the integration - * @throws {IntegrationTestConnectionError} if the connection fails - */ - public abstract testConnectionAsync(): Promise; + public async testConnectionAsync(): Promise { + try { + const url = new URL(this.integration.url); + return await new TestConnectionService(url).handleAsync(async ({ ca, checkServerIdentity }) => { + const fetchDispatcher = await createCertificateAgentAsync({ + ca, + checkServerIdentity, + }); - protected async handleTestConnectionResponseAsync({ - queryFunctionAsync, - handleResponseAsync, - }: { - queryFunctionAsync: () => Promise; - handleResponseAsync?: (response: Response) => Promise; - }): Promise { - const response = await queryFunctionAsync().catch((error) => { - if (error instanceof Error) { - const cause = causeSchema.safeParse(error.cause); - if (!cause.success) { - logger.error("Failed to test connection", error); - throw new IntegrationTestConnectionError("commonError", extractErrorMessage(error)); - } + const axiosInstance = await createAxiosCertificateInstanceAsync({ + ca, + checkServerIdentity, + }); - if (cause.data.code === "ENOTFOUND") { - logger.error("Failed to test connection: Domain not found"); - throw new IntegrationTestConnectionError("domainNotFound"); - } - - if (cause.data.code === "ECONNREFUSED") { - logger.error("Failed to test connection: Connection refused"); - throw new IntegrationTestConnectionError("connectionRefused"); - } - - if (cause.data.code === "ECONNABORTED") { - logger.error("Failed to test connection: Connection aborted"); - throw new IntegrationTestConnectionError("connectionAborted"); - } + const testingAsync: typeof this.testingAsync = this.testingAsync.bind(this); + return await testingAsync({ + dispatcher: fetchDispatcher, + fetchAsync: async (url, options) => await undiciFetch(url, { ...options, dispatcher: fetchDispatcher }), + axiosInstance, + options: { + ca, + checkServerIdentity, + }, + }); + }); + } catch (error) { + if (!(error instanceof TestConnectionError)) { + return TestConnectionError.UnknownResult(error); } - logger.error("Failed to test connection", error); - - throw new IntegrationTestConnectionError("commonError", extractErrorMessage(error)); - }); - - if (response.status >= 400) { - const body = await response.text(); - logger.error(`Failed to test connection with status code ${response.status}. Body: '${body}'`); - - throwErrorByStatusCode(response.status); + return error.toResult(); } - - await handleResponseAsync?.(response); } -} -export interface TestConnectionError { - key: Exclude; - message?: string; + /** + * Test the connection to the integration + * @returns {Promise} + */ + protected abstract testingAsync(input: IntegrationTestingInput): Promise; } -export type TestConnectionResult = - | { - success: false; - error: TestConnectionError; - } - | { - success: true; - }; - -export const throwErrorByStatusCode = (statusCode: number) => { - switch (statusCode) { - case 400: - throw new IntegrationTestConnectionError("badRequest"); - case 401: - throw new IntegrationTestConnectionError("unauthorized"); - case 403: - throw new IntegrationTestConnectionError("forbidden"); - case 404: - throw new IntegrationTestConnectionError("notFound"); - case 429: - throw new IntegrationTestConnectionError("tooManyRequests"); - case 500: - throw new IntegrationTestConnectionError("internalServerError"); - case 503: - throw new IntegrationTestConnectionError("serviceUnavailable"); - default: - throw new IntegrationTestConnectionError("commonError"); - } -}; diff --git a/packages/integrations/src/base/test-connection-error.ts b/packages/integrations/src/base/test-connection-error.ts deleted file mode 100644 index c0f7d97e0..000000000 --- a/packages/integrations/src/base/test-connection-error.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { z } from "zod"; - -import { FlattenError } from "@homarr/common"; - -import type { TestConnectionError } from "./integration"; - -export class IntegrationTestConnectionError extends FlattenError { - constructor( - public key: TestConnectionError["key"], - public detailMessage?: string, - ) { - super("Checking integration connection failed", { key, message: detailMessage }); - } -} - -const schema = z.object({ - key: z.custom((value) => z.string().parse(value)), - message: z.string().optional(), -}); -export const convertIntegrationTestConnectionError = (error: unknown) => { - const result = schema.safeParse(error); - if (!result.success) { - return; - } - - return result.data; -}; diff --git a/packages/integrations/src/base/test-connection/index.ts b/packages/integrations/src/base/test-connection/index.ts new file mode 100644 index 000000000..d37b123bb --- /dev/null +++ b/packages/integrations/src/base/test-connection/index.ts @@ -0,0 +1,6 @@ +export type { + TestConnectionError, + AnyTestConnectionError, + TestConnectionErrorDataOfType, + TestConnectionErrorType, +} from "./test-connection-error"; diff --git a/packages/integrations/src/base/test-connection/test-connection-error.ts b/packages/integrations/src/base/test-connection/test-connection-error.ts new file mode 100644 index 000000000..ffe5f240c --- /dev/null +++ b/packages/integrations/src/base/test-connection/test-connection-error.ts @@ -0,0 +1,176 @@ +import type { X509Certificate } from "node:crypto"; + +import type { AnyRequestError, ParseError, RequestError } from "@homarr/common/server"; + +import { IntegrationRequestError } from "../errors/http/integration-request-error"; +import { IntegrationResponseError } from "../errors/http/integration-response-error"; +import type { IntegrationError } from "../errors/integration-error"; +import { IntegrationUnknownError } from "../errors/integration-unknown-error"; +import { IntegrationParseError } from "../errors/parse/integration-parse-error"; + +export type TestConnectionErrorType = keyof TestConnectionErrorMap; +export type AnyTestConnectionError = { + [TType in TestConnectionErrorType]: TestConnectionError; +}[TestConnectionErrorType]; +export type TestConnectionErrorDataOfType = TestConnectionErrorMap[TType]; + +export class TestConnectionError extends Error { + public readonly type: TType; + public readonly data: TestConnectionErrorMap[TType]; + + private constructor(type: TType, data: TestConnectionErrorMap[TType], options?: { cause: Error }) { + super("Unable to connect to the integration", options); + this.name = TestConnectionError.name; + this.type = type; + this.data = data; + } + + get cause(): Error | undefined { + return super.cause as Error | undefined; + } + + public toResult() { + return { + success: false, + error: this, + } as const; + } + + private static Unknown(cause: unknown) { + return new TestConnectionError( + "unknown", + undefined, + cause instanceof Error + ? { + cause, + } + : undefined, + ); + } + + public static UnknownResult(cause: unknown) { + return this.Unknown(cause).toResult(); + } + + private static Certificate(requestError: RequestError<"certificate">, certificate: X509Certificate) { + return new TestConnectionError( + "certificate", + { + requestError, + certificate, + }, + { + cause: requestError, + }, + ); + } + + public static CertificateResult(requestError: RequestError<"certificate">, certificate: X509Certificate) { + return this.Certificate(requestError, certificate).toResult(); + } + + private static Authorization(statusCode: number) { + return new TestConnectionError("authorization", { + statusCode, + reason: statusCode === 403 ? "forbidden" : "unauthorized", + }); + } + + public static UnauthorizedResult(statusCode: number) { + return this.Authorization(statusCode).toResult(); + } + + private static Status(input: { status: number; url: string }) { + if (input.status === 401 || input.status === 403) return this.Authorization(input.status); + + // We don't want to leak the query parameters in the error message + const urlWithoutQuery = new URL(input.url); + urlWithoutQuery.search = ""; + + return new TestConnectionError("statusCode", { + statusCode: input.status, + reason: input.status in statusCodeMap ? statusCodeMap[input.status as keyof typeof statusCodeMap] : "other", + url: urlWithoutQuery.toString(), + }); + } + + public static StatusResult(input: { status: number; url: string }) { + return this.Status(input).toResult(); + } + + private static Request(requestError: Exclude>) { + return new TestConnectionError( + "request", + { requestError }, + { + cause: requestError, + }, + ); + } + + public static RequestResult(requestError: Exclude>) { + return this.Request(requestError).toResult(); + } + + private static Parse(cause: ParseError) { + return new TestConnectionError("parse", undefined, { cause }); + } + + public static ParseResult(cause: ParseError) { + return this.Parse(cause).toResult(); + } + + static FromIntegrationError(error: IntegrationError): AnyTestConnectionError { + if (error instanceof IntegrationUnknownError) { + return this.Unknown(error.cause); + } + if (error instanceof IntegrationRequestError) { + if (error.cause.type === "certificate") { + return this.Unknown(new Error("FromIntegrationError can not be used for certificate errors", { cause: error })); + } + + return this.Request(error.cause); + } + if (error instanceof IntegrationResponseError) { + return this.Status({ + status: error.cause.statusCode, + url: error.cause.url ?? "?", + }); + } + if (error instanceof IntegrationParseError) { + return this.Parse(error.cause); + } + + return this.Unknown(new Error("FromIntegrationError received unknown IntegrationError", { cause: error })); + } +} + +const statusCodeMap = { + 400: "badRequest", + 404: "notFound", + 429: "tooManyRequests", + 500: "internalServerError", + 503: "serviceUnavailable", + 504: "gatewayTimeout", +} as const; + +interface TestConnectionErrorMap { + unknown: undefined; + parse: undefined; + authorization: { + statusCode: number; + reason: "unauthorized" | "forbidden"; + }; + statusCode: { + statusCode: number; + reason: (typeof statusCodeMap)[keyof typeof statusCodeMap] | "other"; + url: string; + }; + certificate: { + requestError: RequestError<"certificate">; + certificate: X509Certificate; + }; + request: { + requestError: Exclude>; + }; +} diff --git a/packages/integrations/src/base/test-connection/test-connection-service.ts b/packages/integrations/src/base/test-connection/test-connection-service.ts new file mode 100644 index 000000000..16bf664b7 --- /dev/null +++ b/packages/integrations/src/base/test-connection/test-connection-service.ts @@ -0,0 +1,134 @@ +import type { X509Certificate } from "node:crypto"; +import tls from "node:tls"; + +import { + createCustomCheckServerIdentity, + getAllTrustedCertificatesAsync, + getTrustedCertificateHostnamesAsync, +} from "@homarr/certificates/server"; +import { getPortFromUrl } from "@homarr/common"; +import { logger } from "@homarr/log"; + +import type { IntegrationRequestErrorOfType } from "../errors/http/integration-request-error"; +import { IntegrationRequestError } from "../errors/http/integration-request-error"; +import { IntegrationError } from "../errors/integration-error"; +import type { AnyTestConnectionError } from "./test-connection-error"; +import { TestConnectionError } from "./test-connection-error"; + +const localLogger = logger.child({ + module: "TestConnectionService", +}); + +export type TestingResult = + | { + success: true; + } + | { + success: false; + error: AnyTestConnectionError; + }; +type AsyncTestingCallback = (input: { + ca: string[] | string; + checkServerIdentity: typeof tls.checkServerIdentity; +}) => Promise; + +export class TestConnectionService { + constructor(private url: URL) {} + + public async handleAsync(testingCallbackAsync: AsyncTestingCallback) { + localLogger.debug("Testing connection", { + url: this.url.toString(), + }); + + const testingResult = await testingCallbackAsync({ + ca: await getAllTrustedCertificatesAsync(), + checkServerIdentity: createCustomCheckServerIdentity(await getTrustedCertificateHostnamesAsync()), + }) + .then((result) => { + if (result.success) return result; + + const error = result.error; + if (error instanceof TestConnectionError) return error.toResult(); + + return TestConnectionError.UnknownResult(error); + }) + .catch((error: unknown) => { + if (!(error instanceof IntegrationError)) { + return TestConnectionError.UnknownResult(error); + } + + if (!(error instanceof IntegrationRequestError)) { + return TestConnectionError.FromIntegrationError(error).toResult(); + } + + if (error.cause.type !== "certificate") { + return TestConnectionError.FromIntegrationError(error).toResult(); + } + + return { + success: false, + error: error as IntegrationRequestErrorOfType<"certificate">, + } as const; + }); + + if (testingResult.success) { + localLogger.debug("Testing connection succeeded", { + url: this.url.toString(), + }); + + return testingResult; + } + + localLogger.debug("Testing connection failed", { + url: this.url.toString(), + error: `${testingResult.error.name}: ${testingResult.error.message}`, + }); + + if (!(testingResult.error instanceof IntegrationRequestError)) { + return testingResult.error.toResult(); + } + + const certificate = await this.fetchCertificateAsync(); + if (!certificate) { + return TestConnectionError.UnknownResult(new Error("Unable to fetch certificate")); + } + + return TestConnectionError.CertificateResult(testingResult.error.cause, certificate); + } + + private async fetchCertificateAsync(): Promise { + logger.debug("Fetching certificate", { + url: this.url.toString(), + }); + + const url = this.url; + const port = getPortFromUrl(url); + const socket = await new Promise((resolve, reject) => { + try { + const innerSocket = tls.connect( + { + host: url.hostname, + servername: url.hostname, + port, + rejectUnauthorized: false, + }, + () => { + resolve(innerSocket); + }, + ); + } catch (error) { + reject(new Error("Unable to fetch certificate", { cause: error })); + } + }); + + const x509 = socket.getPeerX509Certificate(); + socket.destroy(); + + localLogger.debug("Fetched certificate", { + url: this.url.toString(), + subject: x509?.subject, + issuer: x509?.issuer, + }); + return x509; + } +} diff --git a/packages/integrations/src/client.ts b/packages/integrations/src/client.ts deleted file mode 100644 index d62df98bb..000000000 --- a/packages/integrations/src/client.ts +++ /dev/null @@ -1 +0,0 @@ -export { convertIntegrationTestConnectionError } from "./base/test-connection-error"; diff --git a/packages/integrations/src/dashdot/dashdot-integration.ts b/packages/integrations/src/dashdot/dashdot-integration.ts index 7b0723b4d..c569fccef 100644 --- a/packages/integrations/src/dashdot/dashdot-integration.ts +++ b/packages/integrations/src/dashdot/dashdot-integration.ts @@ -8,13 +8,22 @@ import { z } from "zod"; import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server"; import { createChannelEventHistory } from "../../../redis/src/lib/channel"; +import type { IntegrationTestingInput } 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 { HealthMonitoring } from "../types"; export class DashDotIntegration extends Integration { - public async testConnectionAsync(): Promise { - const response = await fetchWithTrustedCertificatesAsync(this.url("/info")); + protected async testingAsync(input: IntegrationTestingInput): Promise { + const response = await input.fetchAsync(this.url("/info")); + if (!response.ok) return TestConnectionError.StatusResult(response); + await response.json(); + + return { + success: true, + }; } public async getSystemInfoAsync(): Promise { diff --git a/packages/integrations/src/download-client/aria2/aria2-integration.ts b/packages/integrations/src/download-client/aria2/aria2-integration.ts index b0da1b399..84458fda5 100644 --- a/packages/integrations/src/download-client/aria2/aria2-integration.ts +++ b/packages/integrations/src/download-client/aria2/aria2-integration.ts @@ -1,7 +1,11 @@ import path from "path"; +import type { fetch as undiciFetch } from "undici"; import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server"; +import { ResponseError } from "@homarr/common/server"; +import type { IntegrationTestingInput } from "../../base/integration"; +import type { TestingResult } from "../../base/test-connection/test-connection-service"; import type { DownloadClientJobsAndStatus } from "../../interfaces/downloads/download-client-data"; import { DownloadClientIntegration } from "../../interfaces/downloads/download-client-integration"; import type { DownloadClientItem } from "../../interfaces/downloads/download-client-items"; @@ -91,12 +95,15 @@ export class Aria2Integration extends DownloadClientIntegration { } } - public async testConnectionAsync(): Promise { - const client = this.getClient(); + protected async testingAsync(input: IntegrationTestingInput): Promise { + const client = this.getClient(input.fetchAsync); await client.getVersion(); + return { + success: true, + }; } - private getClient() { + private getClient(fetchAsync: typeof undiciFetch = fetchWithTrustedCertificatesAsync) { const url = this.url("/jsonrpc"); return new Proxy( @@ -114,21 +121,24 @@ export class Aria2Integration extends DownloadClientIntegration { method: `aria2.${method}`, params, }); - return await fetchWithTrustedCertificatesAsync(url, { method: "POST", body }) + + return await fetchAsync(url, { method: "POST", body }) .then(async (response) => { const responseBody = (await response.json()) as { result: ReturnType }; if (!response.ok) { - throw new Error(response.statusText); + throw new ResponseError(response); } return responseBody.result; }) .catch((error) => { if (error instanceof Error) { - throw new Error(error.message); - } else { - throw new Error("Error communicating with Aria2"); + throw error; } + + throw new Error("Error communicating with Aria2", { + cause: error, + }); }); }; }, diff --git a/packages/integrations/src/download-client/deluge/deluge-integration.ts b/packages/integrations/src/download-client/deluge/deluge-integration.ts index a0011e5bb..e5669446a 100644 --- a/packages/integrations/src/download-client/deluge/deluge-integration.ts +++ b/packages/integrations/src/download-client/deluge/deluge-integration.ts @@ -1,17 +1,32 @@ import { Deluge } from "@ctrl/deluge"; import dayjs from "dayjs"; +import type { Dispatcher } from "undici"; import { createCertificateAgentAsync } from "@homarr/certificates/server"; +import { HandleIntegrationErrors } from "../../base/errors/decorator"; +import { integrationOFetchHttpErrorHandler } from "../../base/errors/http"; +import type { IntegrationTestingInput } from "../../base/integration"; +import { TestConnectionError } from "../../base/test-connection/test-connection-error"; +import type { TestingResult } from "../../base/test-connection/test-connection-service"; import type { DownloadClientJobsAndStatus } from "../../interfaces/downloads/download-client-data"; import { DownloadClientIntegration } from "../../interfaces/downloads/download-client-integration"; import type { DownloadClientItem } from "../../interfaces/downloads/download-client-items"; import type { DownloadClientStatus } from "../../interfaces/downloads/download-client-status"; +@HandleIntegrationErrors([integrationOFetchHttpErrorHandler]) export class DelugeIntegration extends DownloadClientIntegration { - public async testConnectionAsync(): Promise { - const client = await this.getClientAsync(); - await client.login(); + protected async testingAsync(input: IntegrationTestingInput): Promise { + const client = await this.getClientAsync(input.dispatcher); + const isSuccess = await client.login(); + + if (!isSuccess) { + return TestConnectionError.UnauthorizedResult(401); + } + + return { + success: true, + }; } public async getClientJobsAndStatusAsync(): Promise { @@ -93,11 +108,11 @@ export class DelugeIntegration extends DownloadClientIntegration { await client.removeTorrent(id, fromDisk); } - private async getClientAsync() { + private async getClientAsync(dispatcher?: Dispatcher) { return new Deluge({ baseUrl: this.url("/").toString(), password: this.getSecretValue("password"), - dispatcher: await createCertificateAgentAsync(), + dispatcher: dispatcher ?? (await createCertificateAgentAsync()), }); } diff --git a/packages/integrations/src/download-client/nzbget/nzbget-integration.ts b/packages/integrations/src/download-client/nzbget/nzbget-integration.ts index 711f438d4..65905cdd8 100644 --- a/packages/integrations/src/download-client/nzbget/nzbget-integration.ts +++ b/packages/integrations/src/download-client/nzbget/nzbget-integration.ts @@ -1,7 +1,11 @@ import dayjs from "dayjs"; +import type { fetch as undiciFetch } from "undici"; import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server"; +import { ResponseError } from "@homarr/common/server"; +import type { IntegrationTestingInput } from "../../base/integration"; +import type { TestingResult } from "../../base/test-connection/test-connection-service"; import type { DownloadClientJobsAndStatus } from "../../interfaces/downloads/download-client-data"; import { DownloadClientIntegration } from "../../interfaces/downloads/download-client-integration"; import type { DownloadClientItem } from "../../interfaces/downloads/download-client-items"; @@ -9,8 +13,11 @@ import type { DownloadClientStatus } from "../../interfaces/downloads/download-c import type { NzbGetClient } from "./nzbget-types"; export class NzbGetIntegration extends DownloadClientIntegration { - public async testConnectionAsync(): Promise { - await this.nzbGetApiCallAsync("version"); + protected async testingAsync(input: IntegrationTestingInput): Promise { + await this.nzbGetApiCallWithCustomFetchAsync(input.fetchAsync, "version"); + return { + success: true, + }; } public async getClientJobsAndStatusAsync(): Promise { @@ -93,24 +100,34 @@ export class NzbGetIntegration extends DownloadClientIntegration { private async nzbGetApiCallAsync( method: CallType, ...params: Parameters + ): Promise> { + return await this.nzbGetApiCallWithCustomFetchAsync(fetchWithTrustedCertificatesAsync, method, ...params); + } + + private async nzbGetApiCallWithCustomFetchAsync( + fetchAsync: typeof undiciFetch, + method: CallType, + ...params: Parameters ): Promise> { const username = this.getSecretValue("username"); const password = this.getSecretValue("password"); const url = this.url(`/${encodeURIComponent(username)}:${encodeURIComponent(password)}/jsonrpc`); const body = JSON.stringify({ method, params }); - return await fetchWithTrustedCertificatesAsync(url, { method: "POST", body }) + return await fetchAsync(url, { method: "POST", body }) .then(async (response) => { if (!response.ok) { - throw new Error(response.statusText); + throw new ResponseError(response); } return ((await response.json()) as { result: ReturnType }).result; }) .catch((error) => { if (error instanceof Error) { - throw new Error(error.message); - } else { - throw new Error("Error communicating with NzbGet"); + throw error; } + + throw new Error("Error communicating with NzbGet", { + cause: error, + }); }); } diff --git a/packages/integrations/src/download-client/qbittorrent/qbittorrent-integration.ts b/packages/integrations/src/download-client/qbittorrent/qbittorrent-integration.ts index fb3234acb..2c649d256 100644 --- a/packages/integrations/src/download-client/qbittorrent/qbittorrent-integration.ts +++ b/packages/integrations/src/download-client/qbittorrent/qbittorrent-integration.ts @@ -1,17 +1,29 @@ import { QBittorrent } from "@ctrl/qbittorrent"; import dayjs from "dayjs"; +import type { Dispatcher } from "undici"; import { createCertificateAgentAsync } from "@homarr/certificates/server"; +import { HandleIntegrationErrors } from "../../base/errors/decorator"; +import { integrationOFetchHttpErrorHandler } from "../../base/errors/http"; +import type { IntegrationTestingInput } from "../../base/integration"; +import { TestConnectionError } from "../../base/test-connection/test-connection-error"; +import type { TestingResult } from "../../base/test-connection/test-connection-service"; import type { DownloadClientJobsAndStatus } from "../../interfaces/downloads/download-client-data"; import { DownloadClientIntegration } from "../../interfaces/downloads/download-client-integration"; import type { DownloadClientItem } from "../../interfaces/downloads/download-client-items"; import type { DownloadClientStatus } from "../../interfaces/downloads/download-client-status"; +@HandleIntegrationErrors([integrationOFetchHttpErrorHandler]) export class QBitTorrentIntegration extends DownloadClientIntegration { - public async testConnectionAsync(): Promise { - const client = await this.getClientAsync(); - await client.login(); + protected async testingAsync(input: IntegrationTestingInput): Promise { + const client = await this.getClientAsync(input.dispatcher); + const isSuccess = await client.login(); + if (!isSuccess) return TestConnectionError.UnauthorizedResult(401); + + return { + success: true, + }; } public async getClientJobsAndStatusAsync(): Promise { @@ -76,12 +88,12 @@ export class QBitTorrentIntegration extends DownloadClientIntegration { await client.removeTorrent(id, fromDisk); } - private async getClientAsync() { + private async getClientAsync(dispatcher?: Dispatcher) { return new QBittorrent({ baseUrl: this.url("/").toString(), username: this.getSecretValue("username"), password: this.getSecretValue("password"), - dispatcher: await createCertificateAgentAsync(), + dispatcher: dispatcher ?? (await createCertificateAgentAsync()), }); } diff --git a/packages/integrations/src/download-client/sabnzbd/sabnzbd-integration.ts b/packages/integrations/src/download-client/sabnzbd/sabnzbd-integration.ts index 19c84fce3..505e4cd51 100644 --- a/packages/integrations/src/download-client/sabnzbd/sabnzbd-integration.ts +++ b/packages/integrations/src/download-client/sabnzbd/sabnzbd-integration.ts @@ -1,8 +1,12 @@ import dayjs from "dayjs"; import duration from "dayjs/plugin/duration"; +import type { fetch as undiciFetch } from "undici"; import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server"; +import { ResponseError } from "@homarr/common/server"; +import type { IntegrationTestingInput } from "../../base/integration"; +import type { TestingResult } from "../../base/test-connection/test-connection-service"; import type { DownloadClientJobsAndStatus } from "../../interfaces/downloads/download-client-data"; import { DownloadClientIntegration } from "../../interfaces/downloads/download-client-integration"; import type { DownloadClientItem } from "../../interfaces/downloads/download-client-items"; @@ -12,9 +16,10 @@ import { historySchema, queueSchema } from "./sabnzbd-schema"; dayjs.extend(duration); export class SabnzbdIntegration extends DownloadClientIntegration { - public async testConnectionAsync(): Promise { + protected async testingAsync(input: IntegrationTestingInput): Promise { //This is the one call that uses the least amount of data while requiring the api key - await this.sabNzbApiCallAsync("translate", { value: "ping" }); + await this.sabNzbApiCallWithCustomFetchAsync(input.fetchAsync, "translate", { value: "ping" }); + return { success: true }; } public async getClientJobsAndStatusAsync(): Promise { @@ -101,6 +106,13 @@ export class SabnzbdIntegration extends DownloadClientIntegration { } private async sabNzbApiCallAsync(mode: string, searchParams?: Record): Promise { + return await this.sabNzbApiCallWithCustomFetchAsync(fetchWithTrustedCertificatesAsync, mode, searchParams); + } + private async sabNzbApiCallWithCustomFetchAsync( + fetchAsync: typeof undiciFetch, + mode: string, + searchParams?: Record, + ): Promise { const url = this.url("/api", { ...searchParams, output: "json", @@ -108,19 +120,21 @@ export class SabnzbdIntegration extends DownloadClientIntegration { apikey: this.getSecretValue("apiKey"), }); - return await fetchWithTrustedCertificatesAsync(url) + return await fetchAsync(url) .then((response) => { if (!response.ok) { - throw new Error(response.statusText); + throw new ResponseError(response); } return response.json(); }) .catch((error) => { if (error instanceof Error) { - throw new Error(error.message); - } else { - throw new Error("Error communicating with SABnzbd"); + throw error; } + + throw new Error("Error communicating with SABnzbd", { + cause: error, + }); }); } diff --git a/packages/integrations/src/download-client/transmission/transmission-integration.ts b/packages/integrations/src/download-client/transmission/transmission-integration.ts index 4750238ef..350ce0552 100644 --- a/packages/integrations/src/download-client/transmission/transmission-integration.ts +++ b/packages/integrations/src/download-client/transmission/transmission-integration.ts @@ -1,17 +1,26 @@ import { Transmission } from "@ctrl/transmission"; import dayjs from "dayjs"; +import type { Dispatcher } from "undici"; import { createCertificateAgentAsync } from "@homarr/certificates/server"; +import { HandleIntegrationErrors } from "../../base/errors/decorator"; +import { integrationOFetchHttpErrorHandler } from "../../base/errors/http"; +import type { IntegrationTestingInput } from "../../base/integration"; +import type { TestingResult } from "../../base/test-connection/test-connection-service"; import type { DownloadClientJobsAndStatus } from "../../interfaces/downloads/download-client-data"; import { DownloadClientIntegration } from "../../interfaces/downloads/download-client-integration"; import type { DownloadClientItem } from "../../interfaces/downloads/download-client-items"; import type { DownloadClientStatus } from "../../interfaces/downloads/download-client-status"; +@HandleIntegrationErrors([integrationOFetchHttpErrorHandler]) export class TransmissionIntegration extends DownloadClientIntegration { - public async testConnectionAsync(): Promise { - const client = await this.getClientAsync(); + protected async testingAsync(input: IntegrationTestingInput): Promise { + const client = await this.getClientAsync(input.dispatcher); await client.getSession(); + return { + success: true, + }; } public async getClientJobsAndStatusAsync(): Promise { @@ -76,12 +85,12 @@ export class TransmissionIntegration extends DownloadClientIntegration { await client.removeTorrent(id, fromDisk); } - private async getClientAsync() { + private async getClientAsync(dispatcher?: Dispatcher) { return new Transmission({ baseUrl: this.url("/").toString(), username: this.getSecretValue("username"), password: this.getSecretValue("password"), - dispatcher: await createCertificateAgentAsync(), + dispatcher: dispatcher ?? (await createCertificateAgentAsync()), }); } diff --git a/packages/integrations/src/emby/emby-integration.ts b/packages/integrations/src/emby/emby-integration.ts index 7c0db54e5..a5642db5f 100644 --- a/packages/integrations/src/emby/emby-integration.ts +++ b/packages/integrations/src/emby/emby-integration.ts @@ -3,7 +3,10 @@ import { z } from "zod"; import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server"; +import type { IntegrationTestingInput } 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 { CurrentSessionsInput, StreamSession } from "../interfaces/media-server/session"; import { convertJellyfinType } from "../jellyfin/jellyfin-integration"; @@ -32,19 +35,22 @@ export class EmbyIntegration extends Integration { private static readonly deviceId = "homarr-emby-integration"; private static readonly authorizationHeaderValue = `Emby Client="Dashboard", Device="Homarr", DeviceId="${EmbyIntegration.deviceId}", Version="0.0.1"`; - public async testConnectionAsync(): Promise { + protected async testingAsync(input: IntegrationTestingInput): Promise { const apiKey = super.getSecretValue("apiKey"); - - await super.handleTestConnectionResponseAsync({ - queryFunctionAsync: async () => { - return await fetchWithTrustedCertificatesAsync(super.url("/emby/System/Ping"), { - headers: { - [EmbyIntegration.apiKeyHeader]: apiKey, - Authorization: EmbyIntegration.authorizationHeaderValue, - }, - }); + const response = await input.fetchAsync(super.url("/emby/System/Ping"), { + headers: { + [EmbyIntegration.apiKeyHeader]: apiKey, + Authorization: EmbyIntegration.authorizationHeaderValue, }, }); + + if (!response.ok) { + return TestConnectionError.StatusResult(response); + } + + return { + success: true, + }; } public async getCurrentSessionsAsync(options: CurrentSessionsInput): Promise { diff --git a/packages/integrations/src/homeassistant/homeassistant-integration.ts b/packages/integrations/src/homeassistant/homeassistant-integration.ts index 2f280002b..5fe767d8a 100644 --- a/packages/integrations/src/homeassistant/homeassistant-integration.ts +++ b/packages/integrations/src/homeassistant/homeassistant-integration.ts @@ -1,7 +1,10 @@ import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server"; import { logger } from "@homarr/log"; +import type { IntegrationTestingInput } 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 { entityStateSchema } from "./homeassistant-types"; export class HomeAssistantIntegration extends Integration { @@ -57,12 +60,18 @@ export class HomeAssistantIntegration extends Integration { } } - public async testConnectionAsync(): Promise { - await super.handleTestConnectionResponseAsync({ - queryFunctionAsync: async () => { - return await this.getAsync("/api/config"); - }, + protected async testingAsync(input: IntegrationTestingInput): Promise { + const response = await input.fetchAsync(this.url("/api/config"), { + headers: this.getAuthHeaders(), }); + + if (!response.ok) { + return TestConnectionError.StatusResult(response); + } + + return { + success: true, + }; } /** diff --git a/packages/integrations/src/index.ts b/packages/integrations/src/index.ts index d0999bee1..80752e149 100644 --- a/packages/integrations/src/index.ts +++ b/packages/integrations/src/index.ts @@ -40,4 +40,3 @@ export { downloadClientItemSchema } from "./interfaces/downloads/download-client // Helpers export { createIntegrationAsync, createIntegrationAsyncFromSecrets } from "./base/creator"; -export { IntegrationTestConnectionError } from "./base/test-connection-error"; diff --git a/packages/integrations/src/jellyfin/jellyfin-integration.ts b/packages/integrations/src/jellyfin/jellyfin-integration.ts index 95962bdcf..35c18df35 100644 --- a/packages/integrations/src/jellyfin/jellyfin-integration.ts +++ b/packages/integrations/src/jellyfin/jellyfin-integration.ts @@ -2,12 +2,18 @@ import { Jellyfin } from "@jellyfin/sdk"; import { BaseItemKind } from "@jellyfin/sdk/lib/generated-client/models"; import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api"; import { getSystemApi } from "@jellyfin/sdk/lib/utils/api/system-api"; +import type { AxiosInstance } from "axios"; import { createAxiosCertificateInstanceAsync } from "@homarr/certificates/server"; +import { HandleIntegrationErrors } from "../base/errors/decorator"; +import { integrationAxiosHttpErrorHandler } from "../base/errors/http"; +import type { IntegrationTestingInput } from "../base/integration"; import { Integration } from "../base/integration"; +import type { TestingResult } from "../base/test-connection/test-connection-service"; import type { CurrentSessionsInput, StreamSession } from "../interfaces/media-server/session"; +@HandleIntegrationErrors([integrationAxiosHttpErrorHandler]) export class JellyfinIntegration extends Integration { private readonly jellyfin: Jellyfin = new Jellyfin({ clientInfo: { @@ -20,10 +26,11 @@ export class JellyfinIntegration extends Integration { }, }); - public async testConnectionAsync(): Promise { - const api = await this.getApiAsync(); + protected async testingAsync(input: IntegrationTestingInput): Promise { + const api = await this.getApiAsync(input.axiosInstance); const systemApi = getSystemApi(api); await systemApi.getPingSystem(); + return { success: true }; } public async getCurrentSessionsAsync(options: CurrentSessionsInput): Promise { @@ -31,10 +38,6 @@ export class JellyfinIntegration extends Integration { const sessionApi = getSessionApi(api); const sessions = await sessionApi.getSessions(); - if (sessions.status !== 200) { - throw new Error(`Jellyfin server ${this.url("/")} returned a non successful status code: ${sessions.status}`); - } - return sessions.data .filter((sessionInfo) => sessionInfo.UserId !== undefined) .filter((sessionInfo) => sessionInfo.DeviceId !== "homarr") @@ -71,14 +74,14 @@ export class JellyfinIntegration extends Integration { * with a username and password. * @returns An instance of Api that has been authenticated */ - private async getApiAsync() { - const httpsAgent = await createAxiosCertificateInstanceAsync(); + private async getApiAsync(fallbackInstance?: AxiosInstance) { + const axiosInstance = fallbackInstance ?? (await createAxiosCertificateInstanceAsync()); if (this.hasSecretValue("apiKey")) { const apiKey = this.getSecretValue("apiKey"); - return this.jellyfin.createApi(this.url("/").toString(), apiKey, httpsAgent); + return this.jellyfin.createApi(this.url("/").toString(), apiKey, axiosInstance); } - const apiClient = this.jellyfin.createApi(this.url("/").toString(), undefined, httpsAgent); + const apiClient = this.jellyfin.createApi(this.url("/").toString(), undefined, axiosInstance); // Authentication state is stored internally in the Api class, so now // requests that require authentication can be made normally. // see https://typescript-sdk.jellyfin.org/#usage diff --git a/packages/integrations/src/media-organizer/lidarr/lidarr-integration.ts b/packages/integrations/src/media-organizer/lidarr/lidarr-integration.ts index b515e7b2f..f4107c61d 100644 --- a/packages/integrations/src/media-organizer/lidarr/lidarr-integration.ts +++ b/packages/integrations/src/media-organizer/lidarr/lidarr-integration.ts @@ -3,18 +3,22 @@ import { z } from "zod"; import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server"; import { logger } from "@homarr/log"; +import type { IntegrationTestingInput } from "../../base/integration"; +import { TestConnectionError } from "../../base/test-connection/test-connection-error"; +import type { TestingResult } from "../../base/test-connection/test-connection-service"; import type { CalendarEvent } from "../../calendar-types"; import { MediaOrganizerIntegration } from "../media-organizer-integration"; export class LidarrIntegration extends MediaOrganizerIntegration { - public async testConnectionAsync(): Promise { - await super.handleTestConnectionResponseAsync({ - queryFunctionAsync: async () => { - return await fetchWithTrustedCertificatesAsync(this.url("/api"), { - headers: { "X-Api-Key": super.getSecretValue("apiKey") }, - }); - }, + protected async testingAsync(input: IntegrationTestingInput): Promise { + const response = await input.fetchAsync(this.url("/api"), { + headers: { "X-Api-Key": super.getSecretValue("apiKey") }, }); + + if (!response.ok) return TestConnectionError.StatusResult(response); + + await response.json(); + return { success: true }; } /** diff --git a/packages/integrations/src/media-organizer/radarr/radarr-integration.ts b/packages/integrations/src/media-organizer/radarr/radarr-integration.ts index 2e7008036..e4777f69a 100644 --- a/packages/integrations/src/media-organizer/radarr/radarr-integration.ts +++ b/packages/integrations/src/media-organizer/radarr/radarr-integration.ts @@ -4,6 +4,9 @@ import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server"; import type { AtLeastOneOf } from "@homarr/common/types"; import { logger } from "@homarr/log"; +import type { IntegrationTestingInput } from "../../base/integration"; +import { TestConnectionError } from "../../base/test-connection/test-connection-error"; +import type { TestingResult } from "../../base/test-connection/test-connection-service"; import type { CalendarEvent } from "../../calendar-types"; import { radarrReleaseTypes } from "../../calendar-types"; import { MediaOrganizerIntegration } from "../media-organizer-integration"; @@ -93,14 +96,15 @@ export class RadarrIntegration extends MediaOrganizerIntegration { return bestImage.remoteUrl; }; - public async testConnectionAsync(): Promise { - await super.handleTestConnectionResponseAsync({ - queryFunctionAsync: async () => { - return await fetchWithTrustedCertificatesAsync(this.url("/api"), { - headers: { "X-Api-Key": super.getSecretValue("apiKey") }, - }); - }, + protected async testingAsync(input: IntegrationTestingInput): Promise { + const response = await input.fetchAsync(this.url("/api"), { + headers: { "X-Api-Key": super.getSecretValue("apiKey") }, }); + + if (!response.ok) return TestConnectionError.StatusResult(response); + + await response.json(); + return { success: true }; } } diff --git a/packages/integrations/src/media-organizer/readarr/readarr-integration.ts b/packages/integrations/src/media-organizer/readarr/readarr-integration.ts index adbf159ca..507bde1ae 100644 --- a/packages/integrations/src/media-organizer/readarr/readarr-integration.ts +++ b/packages/integrations/src/media-organizer/readarr/readarr-integration.ts @@ -3,18 +3,22 @@ import { z } from "zod"; import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server"; import { logger } from "@homarr/log"; +import type { IntegrationTestingInput } from "../../base/integration"; +import { TestConnectionError } from "../../base/test-connection/test-connection-error"; +import type { TestingResult } from "../../base/test-connection/test-connection-service"; import type { CalendarEvent } from "../../calendar-types"; import { MediaOrganizerIntegration } from "../media-organizer-integration"; export class ReadarrIntegration extends MediaOrganizerIntegration { - public async testConnectionAsync(): Promise { - await super.handleTestConnectionResponseAsync({ - queryFunctionAsync: async () => { - return await fetchWithTrustedCertificatesAsync(this.url("/api"), { - headers: { "X-Api-Key": super.getSecretValue("apiKey") }, - }); - }, + protected async testingAsync(input: IntegrationTestingInput): Promise { + const response = await input.fetchAsync(this.url("/api"), { + headers: { "X-Api-Key": super.getSecretValue("apiKey") }, }); + + if (!response.ok) return TestConnectionError.StatusResult(response); + + await response.json(); + return { success: true }; } /** diff --git a/packages/integrations/src/media-organizer/sonarr/sonarr-integration.ts b/packages/integrations/src/media-organizer/sonarr/sonarr-integration.ts index c1d8847a1..0c0482130 100644 --- a/packages/integrations/src/media-organizer/sonarr/sonarr-integration.ts +++ b/packages/integrations/src/media-organizer/sonarr/sonarr-integration.ts @@ -3,6 +3,9 @@ import { z } from "zod"; import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server"; import { logger } from "@homarr/log"; +import type { IntegrationTestingInput } from "../../base/integration"; +import { TestConnectionError } from "../../base/test-connection/test-connection-error"; +import type { TestingResult } from "../../base/test-connection/test-connection-service"; import type { CalendarEvent } from "../../calendar-types"; import { MediaOrganizerIntegration } from "../media-organizer-integration"; @@ -92,14 +95,15 @@ export class SonarrIntegration extends MediaOrganizerIntegration { return bestImage.remoteUrl; }; - public async testConnectionAsync(): Promise { - await super.handleTestConnectionResponseAsync({ - queryFunctionAsync: async () => { - return await fetchWithTrustedCertificatesAsync(this.url("/api"), { - headers: { "X-Api-Key": super.getSecretValue("apiKey") }, - }); - }, + protected async testingAsync(input: IntegrationTestingInput): Promise { + const response = await input.fetchAsync(this.url("/api"), { + headers: { "X-Api-Key": super.getSecretValue("apiKey") }, }); + + if (!response.ok) return TestConnectionError.StatusResult(response); + + await response.json(); + return { success: true }; } } diff --git a/packages/integrations/src/media-transcoding/tdarr-integration.ts b/packages/integrations/src/media-transcoding/tdarr-integration.ts index 5e39d74c9..68e24336c 100644 --- a/packages/integrations/src/media-transcoding/tdarr-integration.ts +++ b/packages/integrations/src/media-transcoding/tdarr-integration.ts @@ -1,24 +1,28 @@ import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server"; +import type { IntegrationTestingInput } 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 { TdarrQueue } from "../interfaces/media-transcoding/queue"; import type { TdarrStatistics } from "../interfaces/media-transcoding/statistics"; import type { TdarrWorker } from "../interfaces/media-transcoding/workers"; import { getNodesResponseSchema, getStatisticsSchema, getStatusTableSchema } from "./tdarr-validation-schemas"; export class TdarrIntegration extends Integration { - public async testConnectionAsync(): Promise { - await super.handleTestConnectionResponseAsync({ - queryFunctionAsync: async () => { - return await fetchWithTrustedCertificatesAsync(this.url("/api/v2/is-server-alive"), { - method: "POST", - headers: { - accept: "application/json", - "X-Api-Key": super.hasSecretValue("apiKey") ? super.getSecretValue("apiKey") : "", - }, - }); + protected async testingAsync(input: IntegrationTestingInput): Promise { + const response = await input.fetchAsync(this.url("/api/v2/is-server-alive"), { + method: "POST", + headers: { + accept: "application/json", + "X-Api-Key": super.hasSecretValue("apiKey") ? super.getSecretValue("apiKey") : "", }, }); + + if (!response.ok) return TestConnectionError.StatusResult(response); + + await response.json(); + return { success: true }; } public async getStatisticsAsync(): Promise { diff --git a/packages/integrations/src/nextcloud/nextcloud.integration.ts b/packages/integrations/src/nextcloud/nextcloud.integration.ts index b9a9e7063..858f37848 100644 --- a/packages/integrations/src/nextcloud/nextcloud.integration.ts +++ b/packages/integrations/src/nextcloud/nextcloud.integration.ts @@ -3,21 +3,28 @@ import objectSupport from "dayjs/plugin/objectSupport"; import utc from "dayjs/plugin/utc"; import * as ical from "node-ical"; import { DAVClient } from "tsdav"; -import type { RequestInit as UndiciFetchRequestInit } from "undici"; +import type { Dispatcher, RequestInit as UndiciFetchRequestInit } from "undici"; import { createCertificateAgentAsync } from "@homarr/certificates/server"; import { logger } from "@homarr/log"; +import { HandleIntegrationErrors } from "../base/errors/decorator"; +import { integrationTsdavHttpErrorHandler } from "../base/errors/http"; +import type { IntegrationTestingInput } from "../base/integration"; import { Integration } from "../base/integration"; +import type { TestingResult } from "../base/test-connection/test-connection-service"; import type { CalendarEvent } from "../calendar-types"; dayjs.extend(utc); dayjs.extend(objectSupport); +@HandleIntegrationErrors([integrationTsdavHttpErrorHandler]) export class NextcloudIntegration extends Integration { - public async testConnectionAsync(): Promise { - const client = await this.createCalendarClientAsync(); + protected async testingAsync(input: IntegrationTestingInput): Promise { + const client = await this.createCalendarClientAsync(input.dispatcher); await client.login(); + + return { success: true }; } public async getCalendarEventsAsync(start: Date, end: Date): Promise { @@ -85,7 +92,7 @@ export class NextcloudIntegration extends Integration { }); } - private async createCalendarClientAsync() { + private async createCalendarClientAsync(dispatcher?: Dispatcher) { return new DAVClient({ serverUrl: this.integration.url, credentials: { @@ -96,7 +103,7 @@ export class NextcloudIntegration extends Integration { defaultAccountType: "caldav", fetchOptions: { // We can use the undici options as the global fetch is used instead of the polyfilled. - dispatcher: await createCertificateAgentAsync(), + dispatcher: dispatcher ?? (await createCertificateAgentAsync()), } satisfies UndiciFetchRequestInit as RequestInit, }); } diff --git a/packages/integrations/src/openmediavault/openmediavault-integration.ts b/packages/integrations/src/openmediavault/openmediavault-integration.ts index 09f718954..bfda3ec5a 100644 --- a/packages/integrations/src/openmediavault/openmediavault-integration.ts +++ b/packages/integrations/src/openmediavault/openmediavault-integration.ts @@ -1,14 +1,14 @@ -import type { Headers, HeadersInit, Response as UndiciResponse } from "undici"; +import type { Headers, HeadersInit, fetch as undiciFetch, Response as UndiciResponse } from "undici"; import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server"; +import { ResponseError } from "@homarr/common/server"; import { logger } from "@homarr/log"; -import { ResponseError } from "../base/error"; -import type { IntegrationInput } from "../base/integration"; +import type { IntegrationInput, IntegrationTestingInput } from "../base/integration"; import { Integration } from "../base/integration"; import type { SessionStore } from "../base/session-store"; import { createSessionStore } from "../base/session-store"; -import { IntegrationTestConnectionError } from "../base/test-connection-error"; +import type { TestingResult } from "../base/test-connection/test-connection-service"; import type { HealthMonitoring } from "../types"; import { cpuTempSchema, fileSystemSchema, smartSchema, systemInformationSchema } from "./openmediavault-types"; @@ -84,12 +84,9 @@ export class OpenMediaVaultIntegration extends Integration { }; } - public async testConnectionAsync(): Promise { - await this.getSessionAsync().catch((error) => { - if (error instanceof ResponseError) { - throw new IntegrationTestConnectionError("invalidCredentials"); - } - }); + protected async testingAsync(input: IntegrationTestingInput): Promise { + await this.getSessionAsync(input.fetchAsync); + return { success: true }; } private async makeAuthenticatedRpcCallAsync( @@ -111,13 +108,14 @@ export class OpenMediaVaultIntegration extends Integration { }); } - private async makeRpcCallAsync( + private async makeRpcCallWithCustomFetchAsync( serviceName: string, method: string, params: Record = {}, headers: HeadersInit = {}, + fetchAsync: typeof undiciFetch = fetchWithTrustedCertificatesAsync, ): Promise { - return await fetchWithTrustedCertificatesAsync(this.url("/rpc.php"), { + return await fetchAsync(this.url("/rpc.php"), { method: "POST", headers: { "Content-Type": "application/json", @@ -132,6 +130,15 @@ export class OpenMediaVaultIntegration extends Integration { }); } + private async makeRpcCallAsync( + serviceName: string, + method: string, + params: Record = {}, + headers: HeadersInit = {}, + ): Promise { + return await this.makeRpcCallWithCustomFetchAsync(serviceName, method, params, headers); + } + /** * Run the callback with the current session id * @param callback @@ -159,11 +166,21 @@ export class OpenMediaVaultIntegration extends Integration { * Get a session id from the openmediavault server * @returns The session details */ - private async getSessionAsync(): Promise { - const response = await this.makeRpcCallAsync("session", "login", { - username: this.getSecretValue("username"), - password: this.getSecretValue("password"), - }); + private async getSessionAsync(fetchAsync?: typeof undiciFetch): Promise { + const response = await this.makeRpcCallWithCustomFetchAsync( + "session", + "login", + { + username: this.getSecretValue("username"), + password: this.getSecretValue("password"), + }, + undefined, + fetchAsync, + ); + + if (!response.ok) { + throw new ResponseError(response); + } const data = (await response.json()) as { response?: { sessionid?: string } }; if (data.response?.sessionid) { @@ -176,10 +193,10 @@ export class OpenMediaVaultIntegration extends Integration { const loginToken = OpenMediaVaultIntegration.extractLoginTokenFromCookies(response.headers); if (!sessionId || !loginToken) { - throw new ResponseError( - response, - `${JSON.stringify(data)} - sessionId=${"*".repeat(sessionId?.length ?? 0)} loginToken=${"*".repeat(loginToken?.length ?? 0)}`, - ); + throw new ResponseError({ + status: 401, + url: response.url, + }); } return { diff --git a/packages/integrations/src/overseerr/overseerr-integration.ts b/packages/integrations/src/overseerr/overseerr-integration.ts index c3feddb6f..8296a7a09 100644 --- a/packages/integrations/src/overseerr/overseerr-integration.ts +++ b/packages/integrations/src/overseerr/overseerr-integration.ts @@ -3,8 +3,11 @@ import { z } from "zod"; import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server"; import { logger } from "@homarr/log"; +import type { IntegrationTestingInput } from "../base/integration"; import { Integration } from "../base/integration"; import type { ISearchableIntegration } from "../base/searchable-integration"; +import { TestConnectionError } from "../base/test-connection/test-connection-error"; +import type { TestingResult } from "../base/test-connection/test-connection-service"; import type { MediaRequest, RequestStats, RequestUser } from "../interfaces/media-requests/media-request"; import { MediaAvailability, MediaRequestStatus } from "../interfaces/media-requests/media-request"; @@ -81,18 +84,18 @@ export class OverseerrIntegration extends Integration implements ISearchableInte } } - public async testConnectionAsync(): Promise { - const response = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/auth/me"), { + protected async testingAsync(input: IntegrationTestingInput): Promise { + const response = await input.fetchAsync(this.url("/api/v1/auth/me"), { headers: { "X-Api-Key": this.getSecretValue("apiKey"), }, }); - const json = (await response.json()) as object; - if (Object.keys(json).includes("id")) { - return; - } - throw new Error(`Received response but unable to parse it: ${JSON.stringify(json)}`); + if (!response.ok) return TestConnectionError.StatusResult(response); + + const responseSchema = z.object({ id: z.number() }); + await responseSchema.parseAsync(await response.json()); + return { success: true }; } public async getRequestsAsync(): Promise { diff --git a/packages/integrations/src/pi-hole/pi-hole-integration-factory.ts b/packages/integrations/src/pi-hole/pi-hole-integration-factory.ts index 71e687927..0d542b540 100644 --- a/packages/integrations/src/pi-hole/pi-hole-integration-factory.ts +++ b/packages/integrations/src/pi-hole/pi-hole-integration-factory.ts @@ -6,18 +6,25 @@ import { PiHoleIntegrationV5 } from "./v5/pi-hole-integration-v5"; import { PiHoleIntegrationV6 } from "./v6/pi-hole-integration-v6"; export const createPiHoleIntegrationAsync = async (input: IntegrationInput) => { - const baseUrl = removeTrailingSlash(input.url); - const url = new URL(`${baseUrl}/api/info/version`); - const response = await fetchWithTrustedCertificatesAsync(url); + try { + const baseUrl = removeTrailingSlash(input.url); + const url = new URL(`${baseUrl}/api/info/version`); + const response = await fetchWithTrustedCertificatesAsync(url); - /** - * In pi-hole 5 the api was at /admin/api.php, in pi-hole 6 it was moved to /api - * For the /api/info/version endpoint, the response is 404 in pi-hole 5 - * and 401 in pi-hole 6 - */ - if (response.status === 404) { - return new PiHoleIntegrationV5(input); + /** + * In pi-hole 5 the api was at /admin/api.php, in pi-hole 6 it was moved to /api + * For the /api/info/version endpoint, the response is 404 in pi-hole 5 + * and 401 in pi-hole 6 + */ + if (response.status === 404) { + return new PiHoleIntegrationV5(input); + } + + return new PiHoleIntegrationV6(input); + } catch { + // We fall back to v6 if we can't reach the endpoint + // This is the case if the integration is not reachable + // the error will then be handled in the integration + return new PiHoleIntegrationV6(input); } - - return new PiHoleIntegrationV6(input); }; diff --git a/packages/integrations/src/pi-hole/v5/pi-hole-integration-v5.ts b/packages/integrations/src/pi-hole/v5/pi-hole-integration-v5.ts index 3f3e263d8..3ffc2e248 100644 --- a/packages/integrations/src/pi-hole/v5/pi-hole-integration-v5.ts +++ b/packages/integrations/src/pi-hole/v5/pi-hole-integration-v5.ts @@ -1,7 +1,10 @@ import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server"; +import { ResponseError } from "@homarr/common/server"; +import type { IntegrationTestingInput } from "../../base/integration"; import { Integration } from "../../base/integration"; -import { IntegrationTestConnectionError } from "../../base/test-connection-error"; +import { TestConnectionError } from "../../base/test-connection/test-connection-error"; +import type { TestingResult } from "../../base/test-connection/test-connection-service"; import type { DnsHoleSummaryIntegration } from "../../interfaces/dns-hole-summary/dns-hole-summary-integration"; import type { DnsHoleSummary } from "../../interfaces/dns-hole-summary/dns-hole-summary-types"; import { summaryResponseSchema } from "./pi-hole-schemas-v5"; @@ -11,46 +14,35 @@ export class PiHoleIntegrationV5 extends Integration implements DnsHoleSummaryIn const apiKey = super.getSecretValue("apiKey"); const response = await fetchWithTrustedCertificatesAsync(this.url("/admin/api.php?summaryRaw", { auth: apiKey })); if (!response.ok) { - throw new Error( - `Failed to fetch summary for ${this.integration.name} (${this.integration.id}): ${response.statusText}`, - ); + throw new ResponseError(response); } - const result = summaryResponseSchema.safeParse(await response.json()); - - if (!result.success) { - throw new Error( - `Failed to parse summary for ${this.integration.name} (${this.integration.id}), most likely your api key is wrong: ${result.error.message}`, - ); - } + const data = await summaryResponseSchema.parseAsync(await response.json()); return { - status: result.data.status, - adsBlockedToday: result.data.ads_blocked_today, - adsBlockedTodayPercentage: result.data.ads_percentage_today, - domainsBeingBlocked: result.data.domains_being_blocked, - dnsQueriesToday: result.data.dns_queries_today, + status: data.status, + adsBlockedToday: data.ads_blocked_today, + adsBlockedTodayPercentage: data.ads_percentage_today, + domainsBeingBlocked: data.domains_being_blocked, + dnsQueriesToday: data.dns_queries_today, }; } - public async testConnectionAsync(): Promise { + protected async testingAsync(input: IntegrationTestingInput): Promise { const apiKey = super.getSecretValue("apiKey"); - await super.handleTestConnectionResponseAsync({ - queryFunctionAsync: async () => { - return await fetchWithTrustedCertificatesAsync(this.url("/admin/api.php?status", { auth: apiKey })); - }, - handleResponseAsync: async (response) => { - try { - const result = await response.json(); - if (typeof result === "object" && result !== null && "status" in result) return; - } catch { - throw new IntegrationTestConnectionError("invalidJson"); - } + const response = await input.fetchAsync(this.url("/admin/api.php?status", { auth: apiKey })); - throw new IntegrationTestConnectionError("invalidCredentials"); - }, - }); + if (!response.ok) return TestConnectionError.StatusResult(response); + + const data = await response.json(); + + // Pi-hole v5 returned an empty array if the API key is wrong + if (typeof data !== "object" || Array.isArray(data)) { + return TestConnectionError.UnauthorizedResult(401); + } + + return { success: true }; } public async enableAsync(): Promise { diff --git a/packages/integrations/src/pi-hole/v6/pi-hole-integration-v6.ts b/packages/integrations/src/pi-hole/v6/pi-hole-integration-v6.ts index 8b3913ed0..6170bc2c1 100644 --- a/packages/integrations/src/pi-hole/v6/pi-hole-integration-v6.ts +++ b/packages/integrations/src/pi-hole/v6/pi-hole-integration-v6.ts @@ -1,16 +1,15 @@ -import type { Response as UndiciResponse } from "undici"; +import type { fetch as undiciFetch, Response as UndiciResponse } from "undici"; import type { z } from "zod"; import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server"; -import { extractErrorMessage } from "@homarr/common"; +import { ResponseError } from "@homarr/common/server"; import { logger } from "@homarr/log"; -import { IntegrationResponseError, ParseError, ResponseError } from "../../base/error"; -import type { IntegrationInput } from "../../base/integration"; +import type { IntegrationInput, IntegrationTestingInput } from "../../base/integration"; import { Integration } from "../../base/integration"; import type { SessionStore } from "../../base/session-store"; import { createSessionStore } from "../../base/session-store"; -import { IntegrationTestConnectionError } from "../../base/test-connection-error"; +import type { TestingResult } from "../../base/test-connection/test-connection-service"; import type { DnsHoleSummaryIntegration } from "../../interfaces/dns-hole-summary/dns-hole-summary-integration"; import type { DnsHoleSummary } from "../../types"; import { dnsBlockingGetSchema, sessionResponseSchema, statsSummaryGetSchema } from "./pi-hole-schemas-v6"; @@ -35,21 +34,17 @@ export class PiHoleIntegrationV6 extends Integration implements DnsHoleSummaryIn }); if (!response.ok) { - throw new IntegrationResponseError(this.integration, response, await response.json()); + throw new ResponseError(response); } - const result = dnsBlockingGetSchema.safeParse(await response.json()); + const result = await dnsBlockingGetSchema.parseAsync(await response.json()); - if (!result.success) { - throw new ParseError("DNS blocking status", result.error, await response.json()); - } - - return result.data; + return result; } private async getStatsSummaryAsync(): Promise> { const response = await this.withAuthAsync(async (sessionId) => { - return await fetchWithTrustedCertificatesAsync(this.url("/api/stats/summary"), { + return fetchWithTrustedCertificatesAsync(this.url("/api/stats/summary"), { headers: { sid: sessionId, }, @@ -57,17 +52,13 @@ export class PiHoleIntegrationV6 extends Integration implements DnsHoleSummaryIn }); if (!response.ok) { - throw new IntegrationResponseError(this.integration, response, await response.json()); + throw new ResponseError(response); } const data = await response.json(); - const result = statsSummaryGetSchema.safeParse(data); + const result = await statsSummaryGetSchema.parseAsync(data); - if (!result.success) { - throw new ParseError("stats summary", result.error, data); - } - - return result.data; + return result; } public async getSummaryAsync(): Promise { @@ -83,21 +74,10 @@ export class PiHoleIntegrationV6 extends Integration implements DnsHoleSummaryIn }; } - public async testConnectionAsync(): Promise { - try { - const sessionId = await this.getSessionAsync(); - await this.clearSessionAsync(sessionId); - } catch (error: unknown) { - if (error instanceof ParseError) { - throw new IntegrationTestConnectionError("invalidJson"); - } - - if (error instanceof ResponseError && error.statusCode === 401) { - throw new IntegrationTestConnectionError("invalidCredentials"); - } - - throw new IntegrationTestConnectionError("commonError", extractErrorMessage(error)); - } + protected async testingAsync({ fetchAsync }: IntegrationTestingInput): Promise { + const sessionId = await this.getSessionAsync(fetchAsync); + await this.clearSessionAsync(sessionId, fetchAsync); + return { success: true }; } public async enableAsync(): Promise { @@ -112,7 +92,7 @@ export class PiHoleIntegrationV6 extends Integration implements DnsHoleSummaryIn }); if (!response.ok) { - throw new IntegrationResponseError(this.integration, response, await response.json()); + throw new ResponseError(response); } } @@ -128,7 +108,7 @@ export class PiHoleIntegrationV6 extends Integration implements DnsHoleSummaryIn }); if (!response.ok) { - throw new IntegrationResponseError(this.integration, response, await response.json()); + throw new ResponseError(response); } } @@ -160,35 +140,39 @@ export class PiHoleIntegrationV6 extends Integration implements DnsHoleSummaryIn * Get a session id from the Pi-hole server * @returns The session id */ - private async getSessionAsync(): Promise { + private async getSessionAsync(fetchAsync: typeof undiciFetch = fetchWithTrustedCertificatesAsync): Promise { const apiKey = super.getSecretValue("apiKey"); - const response = await fetchWithTrustedCertificatesAsync(this.url("/api/auth"), { + const response = await fetchAsync(this.url("/api/auth"), { method: "POST", body: JSON.stringify({ password: apiKey }), headers: { "User-Agent": "Homarr", }, }); + + if (!response.ok) throw new ResponseError(response); + const data = await response.json(); - const result = sessionResponseSchema.safeParse(data); - if (!result.success) { - throw new ParseError("session response", result.error, data); - } - if (!result.data.session.sid) { - throw new IntegrationResponseError(this.integration, response, data); + const result = await sessionResponseSchema.parseAsync(data); + + if (!result.session.sid) { + throw new ResponseError({ status: 401, url: response.url }); } localLogger.info("Received session id successfully", { integrationId: this.integration.id }); - return result.data.session.sid; + return result.session.sid; } /** * Remove the session from the Pi-hole server * @param sessionId The session id to remove */ - private async clearSessionAsync(sessionId: string) { - const response = await fetchWithTrustedCertificatesAsync(this.url("/api/auth"), { + private async clearSessionAsync( + sessionId: string, + fetchAsync: typeof undiciFetch = fetchWithTrustedCertificatesAsync, + ) { + const response = await fetchAsync(this.url("/api/auth"), { method: "DELETE", headers: { sid: sessionId, diff --git a/packages/integrations/src/plex/plex-integration.ts b/packages/integrations/src/plex/plex-integration.ts index edca5779e..4cdb61984 100644 --- a/packages/integrations/src/plex/plex-integration.ts +++ b/packages/integrations/src/plex/plex-integration.ts @@ -1,10 +1,13 @@ import { parseStringPromise } from "xml2js"; import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server"; +import { ParseError } from "@homarr/common/server"; import { logger } from "@homarr/log"; +import type { IntegrationTestingInput } from "../base/integration"; import { Integration } from "../base/integration"; -import { IntegrationTestConnectionError } from "../base/test-connection-error"; +import { TestConnectionError } from "../base/test-connection/test-connection-error"; +import type { TestingResult } from "../base/test-connection/test-connection-service"; import type { CurrentSessionsInput, StreamSession } from "../interfaces/media-server/session"; import type { PlexResponse } from "./interface"; @@ -19,7 +22,7 @@ export class PlexIntegration extends Integration { }); const body = await response.text(); // convert xml response to objects, as there is no JSON api - const data = await PlexIntegration.parseXml(body); + const data = await PlexIntegration.parseXmlAsync(body); const mediaContainer = data.MediaContainer; const mediaElements = [mediaContainer.Video ?? [], mediaContainer.Track ?? []].flat(); @@ -62,31 +65,36 @@ export class PlexIntegration extends Integration { return medias; } - public async testConnectionAsync(): Promise { + protected async testingAsync(input: IntegrationTestingInput): Promise { const token = super.getSecretValue("apiKey"); - await super.handleTestConnectionResponseAsync({ - queryFunctionAsync: async () => { - return await fetchWithTrustedCertificatesAsync(this.url("/"), { - headers: { - "X-Plex-Token": token, - }, - }); - }, - handleResponseAsync: async (response) => { - try { - const result = await response.text(); - await PlexIntegration.parseXml(result); - return; - } catch { - throw new IntegrationTestConnectionError("invalidCredentials"); - } + const response = await input.fetchAsync(this.url("/"), { + headers: { + "X-Plex-Token": token, }, }); + + if (!response.ok) return TestConnectionError.StatusResult(response); + + const result = await response.text(); + + await PlexIntegration.parseXmlAsync(result); + return { success: true }; } - static parseXml(xml: string): Promise { - return parseStringPromise(xml) as Promise; + static async parseXmlAsync(xml: string): Promise { + try { + return (await parseStringPromise(xml)) as Promise; + } catch (error) { + throw new ParseError( + "Invalid xml format", + error instanceof Error + ? { + cause: error, + } + : undefined, + ); + } } static getCurrentlyPlayingType(type: string): NonNullable["type"] { diff --git a/packages/integrations/src/prowlarr/prowlarr-integration.ts b/packages/integrations/src/prowlarr/prowlarr-integration.ts index 7f11cd07a..992d13986 100644 --- a/packages/integrations/src/prowlarr/prowlarr-integration.ts +++ b/packages/integrations/src/prowlarr/prowlarr-integration.ts @@ -1,7 +1,11 @@ +import { z } from "zod"; + import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server"; +import type { IntegrationTestingInput } from "../base/integration"; import { Integration } from "../base/integration"; -import { IntegrationTestConnectionError } from "../base/test-connection-error"; +import { TestConnectionError } from "../base/test-connection/test-connection-error"; +import type { TestingResult } from "../base/test-connection/test-connection-service"; import type { Indexer } from "../interfaces/indexer-manager/indexer"; import { indexerResponseSchema, statusResponseSchema } from "./prowlarr-types"; @@ -75,27 +79,19 @@ export class ProwlarrIntegration extends Integration { } } - public async testConnectionAsync(): Promise { + protected async testingAsync(input: IntegrationTestingInput): Promise { const apiKey = super.getSecretValue("apiKey"); - - await super.handleTestConnectionResponseAsync({ - queryFunctionAsync: async () => { - return await fetchWithTrustedCertificatesAsync(this.url("/api"), { - headers: { - "X-Api-Key": apiKey, - }, - }); - }, - handleResponseAsync: async (response) => { - try { - const result = await response.json(); - if (typeof result === "object" && result !== null) return; - } catch { - throw new IntegrationTestConnectionError("invalidJson"); - } - - throw new IntegrationTestConnectionError("invalidCredentials"); + const response = await input.fetchAsync(this.url("/api"), { + headers: { + "X-Api-Key": apiKey, }, }); + + if (!response.ok) return TestConnectionError.StatusResult(response); + + const responseSchema = z.object({}); + + await responseSchema.parseAsync(await response.json()); + return { success: true }; } } diff --git a/packages/integrations/src/proxmox/proxmox-error-handler.ts b/packages/integrations/src/proxmox/proxmox-error-handler.ts new file mode 100644 index 000000000..541a20baf --- /dev/null +++ b/packages/integrations/src/proxmox/proxmox-error-handler.ts @@ -0,0 +1,30 @@ +import { ResponseError } from "@homarr/common/server"; + +import type { IIntegrationErrorHandler } from "../base/errors/handler"; +import { integrationFetchHttpErrorHandler } from "../base/errors/http"; +import { IntegrationResponseError } from "../base/errors/http/integration-response-error"; +import type { IntegrationError, IntegrationErrorData } from "../base/errors/integration-error"; + +export class ProxmoxApiErrorHandler implements IIntegrationErrorHandler { + handleError(error: unknown, integration: IntegrationErrorData): IntegrationError | undefined { + if (!(error instanceof Error)) return undefined; + if (error.cause && error.cause instanceof TypeError) { + return integrationFetchHttpErrorHandler.handleError(error.cause, integration); + } + + if (error.message.includes(" return Error 400 ")) + return new IntegrationResponseError(integration, { cause: new ResponseError({ status: 400 }, { cause: error }) }); + if (error.message.includes(" return Error 500 ")) + return new IntegrationResponseError(integration, { cause: new ResponseError({ status: 500 }, { cause: error }) }); + if (error.message.includes(" return Error 401 ")) + return new IntegrationResponseError(integration, { cause: new ResponseError({ status: 401 }, { cause: error }) }); + + const otherStatusCode = /connection failed with (\d{3})/.exec(error.message)?.at(1); + if (!otherStatusCode) return undefined; + + const statusCode = parseInt(otherStatusCode, 10); + return new IntegrationResponseError(integration, { + cause: new ResponseError({ status: statusCode }, { cause: error }), + }); + } +} diff --git a/packages/integrations/src/proxmox/proxmox-integration.ts b/packages/integrations/src/proxmox/proxmox-integration.ts index fceab7642..3f185ab20 100644 --- a/packages/integrations/src/proxmox/proxmox-integration.ts +++ b/packages/integrations/src/proxmox/proxmox-integration.ts @@ -2,11 +2,13 @@ import type { Proxmox } from "proxmox-api"; import proxmoxApi from "proxmox-api"; import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server"; -import { extractErrorMessage } from "@homarr/common"; import { logger } from "@homarr/log"; +import { HandleIntegrationErrors } from "../base/errors/decorator"; +import type { IntegrationTestingInput } from "../base/integration"; import { Integration } from "../base/integration"; -import { IntegrationTestConnectionError } from "../base/test-connection-error"; +import type { TestingResult } from "../base/test-connection/test-connection-service"; +import { ProxmoxApiErrorHandler } from "./proxmox-error-handler"; import type { ComputeResourceBase, LxcResource, @@ -16,12 +18,12 @@ import type { StorageResource, } from "./proxmox-types"; +@HandleIntegrationErrors([new ProxmoxApiErrorHandler()]) export class ProxmoxIntegration extends Integration { - public async testConnectionAsync(): Promise { - const proxmox = this.getPromoxApi(); - await proxmox.nodes.$get().catch((error) => { - throw new IntegrationTestConnectionError("internalServerError", extractErrorMessage(error)); - }); + protected async testingAsync(input: IntegrationTestingInput): Promise { + const proxmox = this.getPromoxApi(input.fetchAsync); + await proxmox.nodes.$get(); + return { success: true }; } public async getClusterInfoAsync() { @@ -41,12 +43,12 @@ export class ProxmoxIntegration extends Integration { }; } - private getPromoxApi() { + private getPromoxApi(fetchAsync = fetchWithTrustedCertificatesAsync) { return proxmoxApi({ host: this.url("/").host, tokenID: `${this.getSecretValue("username")}@${this.getSecretValue("realm")}!${this.getSecretValue("tokenId")}`, tokenSecret: this.getSecretValue("apiKey"), - fetch: fetchWithTrustedCertificatesAsync, + fetch: fetchAsync, }); } } diff --git a/packages/integrations/src/proxmox/test/proxmox-error-handler.spec.ts b/packages/integrations/src/proxmox/test/proxmox-error-handler.spec.ts new file mode 100644 index 000000000..e1196cdf1 --- /dev/null +++ b/packages/integrations/src/proxmox/test/proxmox-error-handler.spec.ts @@ -0,0 +1,89 @@ +import proxmoxApi from "proxmox-api"; +import type { fetch as undiciFetch } from "undici"; +import { Response } from "undici"; +import { describe, expect, test } from "vitest"; + +import { IntegrationRequestError } from "../../base/errors/http/integration-request-error"; +import { IntegrationResponseError } from "../../base/errors/http/integration-response-error"; +import { ProxmoxApiErrorHandler } from "../proxmox-error-handler"; + +describe("ProxmoxApiErrorHandler handleError should handle the provided error accordingly", () => { + test.each([400, 401, 500])("should handle %s error", async (statusCode) => { + // Arrange + // eslint-disable-next-line no-restricted-syntax + const mockedFetch: typeof undiciFetch = async () => { + return Promise.resolve(createFakeResponse(statusCode)); + }; + + // Act + const result = await runWithAsync(mockedFetch); + + // Assert + expect(result).toBeInstanceOf(IntegrationResponseError); + const error = result as unknown as IntegrationResponseError; + expect(error.cause.statusCode).toBe(statusCode); + }); + test("should handle other non successful status codes", async () => { + // Arrange + // eslint-disable-next-line no-restricted-syntax + const mockedFetch: typeof undiciFetch = async () => { + return Promise.resolve(createFakeResponse(404)); + }; + + // Act + const result = await runWithAsync(mockedFetch); + + // Assert + expect(result).toBeInstanceOf(IntegrationResponseError); + const error = result as unknown as IntegrationResponseError; + expect(error.cause.statusCode).toBe(404); + }); + test("should handle request error", async () => { + // Arrange + const mockedFetch: typeof undiciFetch = () => { + const errorWithCode = new Error("Inner error") as Error & { code: string }; + errorWithCode.code = "ECONNREFUSED"; + throw new TypeError("Outer error", { cause: errorWithCode }); + }; + + // Act + const result = await runWithAsync(mockedFetch); + + // Assert + // In the end in should have the structure IntegrationRequestError -> RequestError -> TypeError -> Error (with code) + expect(result).toBeInstanceOf(IntegrationRequestError); + const error = result as unknown as IntegrationRequestError; + expect(error.cause.cause).toBeInstanceOf(TypeError); + expect(error.cause.cause?.message).toBe("Outer error"); + expect(error.cause.cause?.cause).toBeInstanceOf(Error); + const cause = error.cause.cause?.cause as Error & { code: string }; + expect(cause.message).toBe("Inner error"); + expect(cause.code).toBe("ECONNREFUSED"); + }); +}); + +const createFakeResponse = (statusCode: number) => { + return new Response(JSON.stringify({ data: {} }), { + status: statusCode, + // It expects a content-type and valid json response + // https://github.com/UrielCh/proxmox-api/blob/master/api/src/ProxmoxEngine.ts#L258 + headers: { "content-type": "application/json;charset=UTF-8" }, + }); +}; + +const runWithAsync = async (mockedFetch: typeof undiciFetch) => { + const integration = { id: "test", name: "test", url: "http://proxmox.example.com" }; + const client = createProxmoxClient(mockedFetch); + const handler = new ProxmoxApiErrorHandler(); + + return await client.nodes.$get().catch((error) => handler.handleError(error, integration)); +}; + +const createProxmoxClient = (fetch: typeof undiciFetch) => { + return proxmoxApi({ + host: "proxmox.example.com", + tokenID: "username@realm!tokenId", + tokenSecret: crypto.randomUUID(), + fetch, + }); +}; diff --git a/packages/integrations/src/unifi-controller/unifi-controller-integration.ts b/packages/integrations/src/unifi-controller/unifi-controller-integration.ts index 1c4ed7959..f47e18ff7 100644 --- a/packages/integrations/src/unifi-controller/unifi-controller-integration.ts +++ b/packages/integrations/src/unifi-controller/unifi-controller-integration.ts @@ -1,13 +1,26 @@ -import type { SiteStats } from "node-unifi"; -import { Controller } from "node-unifi"; +import type tls from "node:tls"; +import axios from "axios"; +import { HttpCookieAgent, HttpsCookieAgent } from "http-cookie-agent/http"; +import { + createCustomCheckServerIdentity, + getAllTrustedCertificatesAsync, + getTrustedCertificateHostnamesAsync, +} from "@homarr/certificates/server"; import { getPortFromUrl } from "@homarr/common"; +import type { SiteStats } from "@homarr/node-unifi"; +import Unifi from "@homarr/node-unifi"; +import { HandleIntegrationErrors } from "../base/errors/decorator"; +import { integrationAxiosHttpErrorHandler } from "../base/errors/http"; +import type { IntegrationTestingInput } from "../base/integration"; import { Integration } from "../base/integration"; +import type { TestingResult } from "../base/test-connection/test-connection-service"; import type { NetworkControllerSummaryIntegration } from "../interfaces/network-controller-summary/network-controller-summary-integration"; import type { NetworkControllerSummary } from "../interfaces/network-controller-summary/network-controller-summary-types"; import type { HealthSubsystem } from "./unifi-controller-types"; +@HandleIntegrationErrors([integrationAxiosHttpErrorHandler]) export class UnifiControllerIntegration extends Integration implements NetworkControllerSummaryIntegration { public async getNetworkSummaryAsync(): Promise { const client = await this.createControllerClientAsync(); @@ -38,20 +51,38 @@ export class UnifiControllerIntegration extends Integration implements NetworkCo } satisfies NetworkControllerSummary; } - public async testConnectionAsync(): Promise { - const client = await this.createControllerClientAsync(); + protected async testingAsync({ options }: IntegrationTestingInput): Promise { + const client = await this.createControllerClientAsync(options); await client.getSitesStats(); + return { success: true }; } - private async createControllerClientAsync() { + private async createControllerClientAsync(options?: { + ca: string | string[]; + checkServerIdentity: typeof tls.checkServerIdentity; + }) { const url = new URL(this.integration.url); + const certificateOptions = options ?? { + ca: await getAllTrustedCertificatesAsync(), + checkServerIdentity: createCustomCheckServerIdentity(await getTrustedCertificateHostnamesAsync()), + }; - const client = new Controller({ + const client = new Unifi.Controller({ host: url.hostname, port: getPortFromUrl(url), - sslverify: false, // TODO: implement a "ignore certificate toggle", see https://github.com/homarr-labs/homarr/issues/2553 username: this.getSecretValue("username"), password: this.getSecretValue("password"), + createAxiosInstance({ cookies }) { + return axios.create({ + adapter: "http", + httpAgent: new HttpCookieAgent({ cookies }), + httpsAgent: new HttpsCookieAgent({ + cookies, + requestCert: true, + ...certificateOptions, + }), + }); + }, }); await client.login(this.getSecretValue("username"), this.getSecretValue("password"), null); diff --git a/packages/integrations/test/aria2.spec.ts b/packages/integrations/test/aria2.spec.ts index 1cde98723..5ea0d8ed4 100644 --- a/packages/integrations/test/aria2.spec.ts +++ b/packages/integrations/test/aria2.spec.ts @@ -1,9 +1,20 @@ import type { StartedTestContainer } from "testcontainers"; import { GenericContainer, getContainerRuntimeClient, ImageName, Wait } from "testcontainers"; -import { beforeAll, describe, expect, test } from "vitest"; +import { beforeAll, describe, expect, test, vi } from "vitest"; + +import { createDb } from "@homarr/db/test"; import { Aria2Integration } from "../src"; +vi.mock("@homarr/db", async (importActual) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + const actual = await importActual(); + return { + ...actual, + db: createDb(), + }; +}); + const API_KEY = "ARIA2_API_KEY"; const IMAGE_NAME = "hurlenko/aria2-ariang:latest"; @@ -19,10 +30,10 @@ describe("Aria2 integration", () => { const aria2Integration = createAria2Intergration(startedContainer, API_KEY); // Act - const actAsync = async () => await aria2Integration.testConnectionAsync(); + const result = await aria2Integration.testConnectionAsync(); // Assert - await expect(actAsync()).resolves.not.toThrow(); + expect(result.success).toBe(true); // Cleanup await startedContainer.stop(); diff --git a/packages/integrations/test/base.spec.ts b/packages/integrations/test/base.spec.ts index e58ea5208..0282319a8 100644 --- a/packages/integrations/test/base.spec.ts +++ b/packages/integrations/test/base.spec.ts @@ -1,250 +1,57 @@ -import { Response } from "undici"; -import { describe, expect, test } from "vitest"; +import { describe, expect, test, vi } from "vitest"; -import { IntegrationTestConnectionError } from "../src"; +import { ResponseError } from "@homarr/common/server"; +import { createDb } from "@homarr/db/test"; + +import type { IntegrationTestingInput } from "../src/base/integration"; import { Integration } from "../src/base/integration"; +import type { TestingResult } from "../src/base/test-connection/test-connection-service"; -type HandleResponseProps = Parameters[0]; - -class BaseIntegrationMock extends Integration { - public async fakeTestConnectionAsync(props: HandleResponseProps): Promise { - await super.handleTestConnectionResponseAsync(props); - } - - // eslint-disable-next-line @typescript-eslint/no-empty-function - public async testConnectionAsync(): Promise {} -} +vi.mock("@homarr/db", async (importActual) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + const actual = await importActual(); + return { + ...actual, + db: createDb(), + }; +}); describe("Base integration", () => { - describe("handleTestConnectionResponseAsync", () => { - test("With no cause error should throw IntegrationTestConnectionError with key commonError", async () => { - // Arrange - const integration = new BaseIntegrationMock({ id: "id", name: "name", url: "url", decryptedSecrets: [] }); + test("testConnectionAsync should handle errors", async () => { + const responseError = new ResponseError({ status: 500, url: "https://example.com" }); + const integration = new FakeIntegration(undefined, responseError); - const errorMessage = "The error message"; - const props: HandleResponseProps = { - async queryFunctionAsync() { - return await Promise.reject(new Error(errorMessage)); - }, - }; + const result = await integration.testConnectionAsync(); - // Act - const actPromise = integration.fakeTestConnectionAsync(props); - - // Assert - await expect(actPromise).rejects.toHaveProperty("key", "commonError"); - await expect(actPromise).rejects.toHaveProperty("detailMessage", errorMessage); - }); - - test("With cause ENOTFOUND should throw IntegrationTestConnectionError with key domainNotFound", async () => { - // Arrange - const integration = new BaseIntegrationMock({ id: "id", name: "name", url: "url", decryptedSecrets: [] }); - - const props: HandleResponseProps = { - async queryFunctionAsync() { - return await Promise.reject(new Error("Error", { cause: { code: "ENOTFOUND" } })); - }, - }; - - // Act - const actAsync = async () => await integration.fakeTestConnectionAsync(props); - - // Assert - await expect(actAsync()).rejects.toHaveProperty("key", "domainNotFound"); - }); - - test("With cause ENOTFOUND should throw IntegrationTestConnectionError with key connectionRefused", async () => { - // Arrange - const integration = new BaseIntegrationMock({ id: "id", name: "name", url: "url", decryptedSecrets: [] }); - - const props: HandleResponseProps = { - async queryFunctionAsync() { - return await Promise.reject(new Error("Error", { cause: { code: "ECONNREFUSED" } })); - }, - }; - - // Act - const actAsync = async () => await integration.fakeTestConnectionAsync(props); - - // Assert - await expect(actAsync()).rejects.toHaveProperty("key", "connectionRefused"); - }); - - test("With cause ENOTFOUND should throw IntegrationTestConnectionError with key connectionAborted", async () => { - // Arrange - const integration = new BaseIntegrationMock({ id: "id", name: "name", url: "url", decryptedSecrets: [] }); - - const props: HandleResponseProps = { - async queryFunctionAsync() { - return await Promise.reject(new Error("Error", { cause: { code: "ECONNABORTED" } })); - }, - }; - - // Act - const actAsync = async () => await integration.fakeTestConnectionAsync(props); - - // Assert - await expect(actAsync()).rejects.toHaveProperty("key", "connectionAborted"); - }); - - test("With not handled cause error should throw IntegrationTestConnectionError with key commonError", async () => { - // Arrange - const integration = new BaseIntegrationMock({ id: "id", name: "name", url: "url", decryptedSecrets: [] }); - - const errorMessage = "The error message"; - const props: HandleResponseProps = { - async queryFunctionAsync() { - return await Promise.reject(new Error(errorMessage)); - }, - }; - - // Act - const actPromise = integration.fakeTestConnectionAsync(props); - - // Assert - await expect(actPromise).rejects.toHaveProperty("key", "commonError"); - await expect(actPromise).rejects.toHaveProperty("detailMessage", errorMessage); - }); - - test("With response status code 400 should throw IntegrationTestConnectionError with key badRequest", async () => { - // Arrange - const integration = new BaseIntegrationMock({ id: "id", name: "name", url: "url", decryptedSecrets: [] }); - - const props: HandleResponseProps = { - async queryFunctionAsync() { - return await Promise.resolve(new Response(null, { status: 400 })); - }, - }; - - // Act - const actAsync = async () => await integration.fakeTestConnectionAsync(props); - - // Assert - await expect(actAsync()).rejects.toHaveProperty("key", "badRequest"); - }); - - test("With response status code 401 should throw IntegrationTestConnectionError with key unauthorized", async () => { - // Arrange - const integration = new BaseIntegrationMock({ id: "id", name: "name", url: "url", decryptedSecrets: [] }); - - const props: HandleResponseProps = { - async queryFunctionAsync() { - return await Promise.resolve(new Response(null, { status: 401 })); - }, - }; - - // Act - const actAsync = async () => await integration.fakeTestConnectionAsync(props); - - // Assert - await expect(actAsync()).rejects.toHaveProperty("key", "unauthorized"); - }); - - test("With response status code 403 should throw IntegrationTestConnectionError with key forbidden", async () => { - // Arrange - const integration = new BaseIntegrationMock({ id: "id", name: "name", url: "url", decryptedSecrets: [] }); - - const props: HandleResponseProps = { - async queryFunctionAsync() { - return await Promise.resolve(new Response(null, { status: 403 })); - }, - }; - - // Act - const actAsync = async () => await integration.fakeTestConnectionAsync(props); - - // Assert - await expect(actAsync()).rejects.toHaveProperty("key", "forbidden"); - }); - - test("With response status code 404 should throw IntegrationTestConnectionError with key notFound", async () => { - // Arrange - const integration = new BaseIntegrationMock({ id: "id", name: "name", url: "url", decryptedSecrets: [] }); - - const props: HandleResponseProps = { - async queryFunctionAsync() { - return await Promise.resolve(new Response(null, { status: 404 })); - }, - }; - - // Act - const actAsync = async () => await integration.fakeTestConnectionAsync(props); - - // Assert - await expect(actAsync()).rejects.toHaveProperty("key", "notFound"); - }); - - test("With response status code 500 should throw IntegrationTestConnectionError with key internalServerError", async () => { - // Arrange - const integration = new BaseIntegrationMock({ id: "id", name: "name", url: "url", decryptedSecrets: [] }); - - const props: HandleResponseProps = { - async queryFunctionAsync() { - return await Promise.resolve(new Response(null, { status: 500 })); - }, - }; - - // Act - const actAsync = async () => await integration.fakeTestConnectionAsync(props); - - // Assert - await expect(actAsync()).rejects.toHaveProperty("key", "internalServerError"); - }); - - test("With response status code 503 should throw IntegrationTestConnectionError with key serviceUnavailable", async () => { - // Arrange - const integration = new BaseIntegrationMock({ id: "id", name: "name", url: "url", decryptedSecrets: [] }); - - const props: HandleResponseProps = { - async queryFunctionAsync() { - return await Promise.resolve(new Response(null, { status: 503 })); - }, - }; - - // Act - const actAsync = async () => await integration.fakeTestConnectionAsync(props); - - // Assert - await expect(actAsync()).rejects.toHaveProperty("key", "serviceUnavailable"); - }); - - test("With response status code 418 (or any other unhandled code) should throw IntegrationTestConnectionError with key commonError", async () => { - // Arrange - const integration = new BaseIntegrationMock({ id: "id", name: "name", url: "url", decryptedSecrets: [] }); - - const props: HandleResponseProps = { - async queryFunctionAsync() { - return await Promise.resolve(new Response(null, { status: 418 })); - }, - }; - - // Act - const actAsync = async () => await integration.fakeTestConnectionAsync(props); - - // Assert - await expect(actAsync()).rejects.toHaveProperty("key", "commonError"); - }); - - test("Errors from handleResponseAsync should be thrown", async () => { - // Arrange - const integration = new BaseIntegrationMock({ id: "id", name: "name", url: "url", decryptedSecrets: [] }); - - const errorMessage = "The error message"; - const props: HandleResponseProps = { - async queryFunctionAsync() { - return await Promise.resolve(new Response(null, { status: 200 })); - }, - async handleResponseAsync() { - return await Promise.reject(new IntegrationTestConnectionError("commonError", errorMessage)); - }, - }; - - // Act - const actPromise = integration.fakeTestConnectionAsync(props); - - // Assert - await expect(actPromise).rejects.toHaveProperty("key", "commonError"); - await expect(actPromise).rejects.toHaveProperty("detailMessage", errorMessage); - }); + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.type === "statusCode").toBe(true); + if (result.error.type !== "statusCode") return; + expect(result.error.data.statusCode).toBe(500); + expect(result.error.data.url).toContain("https://example.com"); + expect(result.error.data.reason).toBe("internalServerError"); }); }); + +class FakeIntegration extends Integration { + constructor( + private testingResult?: TestingResult, + private error?: Error, + ) { + super({ + id: "test", + name: "Test", + url: "https://example.com", + decryptedSecrets: [], + }); + } + + // eslint-disable-next-line no-restricted-syntax + protected testingAsync(_: IntegrationTestingInput): Promise { + if (this.error) { + return Promise.reject(this.error); + } + + return Promise.resolve(this.testingResult ?? { success: true }); + } +} diff --git a/packages/integrations/test/home-assistant.spec.ts b/packages/integrations/test/home-assistant.spec.ts index d4f796b82..f6bebf413 100644 --- a/packages/integrations/test/home-assistant.spec.ts +++ b/packages/integrations/test/home-assistant.spec.ts @@ -1,9 +1,21 @@ import { join } from "path"; import type { StartedTestContainer } from "testcontainers"; import { GenericContainer, getContainerRuntimeClient, ImageName, Wait } from "testcontainers"; -import { beforeAll, describe, expect, test } from "vitest"; +import { beforeAll, describe, expect, test, vi } from "vitest"; -import { HomeAssistantIntegration, IntegrationTestConnectionError } from "../src"; +import { createDb } from "@homarr/db/test"; + +import { HomeAssistantIntegration } from "../src"; +import { TestConnectionError } from "../src/base/test-connection/test-connection-error"; + +vi.mock("@homarr/db", async (importActual) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + const actual = await importActual(); + return { + ...actual, + db: createDb(), + }; +}); const DEFAULT_API_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJkNjQwY2VjNDFjOGU0NGM5YmRlNWQ4ZmFjMjUzYWViZiIsImlhdCI6MTcxODQ3MTE1MSwiZXhwIjoyMDMzODMxMTUxfQ.uQCZ5FZTokipa6N27DtFhLHkwYEXU1LZr0fsVTryL2Q"; @@ -21,10 +33,10 @@ describe("Home Assistant integration", () => { const homeAssistantIntegration = createHomeAssistantIntegration(startedContainer); // Act - const actAsync = async () => await homeAssistantIntegration.testConnectionAsync(); + const result = await homeAssistantIntegration.testConnectionAsync(); // Assert - await expect(actAsync()).resolves.not.toThrow(); + expect(result.success).toBe(true); // Cleanup await startedContainer.stop(); @@ -35,10 +47,14 @@ describe("Home Assistant integration", () => { const homeAssistantIntegration = createHomeAssistantIntegration(startedContainer, "wrong-api-key"); // Act - const actAsync = async () => await homeAssistantIntegration.testConnectionAsync(); + const result = await homeAssistantIntegration.testConnectionAsync(); // Assert - await expect(actAsync()).rejects.toThrow(IntegrationTestConnectionError); + expect(result.success).toBe(false); + if (result.success) return; + + expect(result.error).toBeInstanceOf(TestConnectionError); + expect(result.error.type).toBe("authorization"); // Cleanup await startedContainer.stop(); diff --git a/packages/integrations/test/nzbget.spec.ts b/packages/integrations/test/nzbget.spec.ts index c30008a99..938ac927e 100644 --- a/packages/integrations/test/nzbget.spec.ts +++ b/packages/integrations/test/nzbget.spec.ts @@ -2,9 +2,21 @@ import { readFile } from "fs/promises"; import { join } from "path"; import type { StartedTestContainer } from "testcontainers"; import { GenericContainer, getContainerRuntimeClient, ImageName, Wait } from "testcontainers"; -import { beforeAll, describe, expect, test } from "vitest"; +import { beforeAll, describe, expect, test, vi } from "vitest"; + +import { createDb } from "@homarr/db/test"; import { NzbGetIntegration } from "../src"; +import { TestConnectionError } from "../src/base/test-connection/test-connection-error"; + +vi.mock("@homarr/db", async (importActual) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + const actual = await importActual(); + return { + ...actual, + db: createDb(), + }; +}); const username = "nzbget"; const password = "tegbzn6789"; @@ -22,10 +34,10 @@ describe("Nzbget integration", () => { const nzbGetIntegration = createNzbGetIntegration(startedContainer, username, password); // Act - const actAsync = async () => await nzbGetIntegration.testConnectionAsync(); + const result = await nzbGetIntegration.testConnectionAsync(); // Assert - await expect(actAsync()).resolves.not.toThrow(); + expect(result.success).toBe(true); // Cleanup await startedContainer.stop(); @@ -37,10 +49,14 @@ describe("Nzbget integration", () => { const nzbGetIntegration = createNzbGetIntegration(startedContainer, "wrong-user", "wrong-password"); // Act - const actAsync = async () => await nzbGetIntegration.testConnectionAsync(); + const result = await nzbGetIntegration.testConnectionAsync(); // Assert - await expect(actAsync()).rejects.toThrow(); + expect(result.success).toBe(false); + if (result.success) return; + + expect(result.error).toBeInstanceOf(TestConnectionError); + expect(result.error.type).toBe("authorization"); // Cleanup await startedContainer.stop(); diff --git a/packages/integrations/test/pi-hole.spec.ts b/packages/integrations/test/pi-hole.spec.ts index aaa25d03c..80712a370 100644 --- a/packages/integrations/test/pi-hole.spec.ts +++ b/packages/integrations/test/pi-hole.spec.ts @@ -2,8 +2,20 @@ import type { StartedTestContainer } from "testcontainers"; import { GenericContainer, Wait } from "testcontainers"; import { describe, expect, test, vi } from "vitest"; +import { createDb } from "@homarr/db/test"; + import { PiHoleIntegrationV5, PiHoleIntegrationV6 } from "../src"; import type { SessionStore } from "../src/base/session-store"; +import { TestConnectionError } from "../src/base/test-connection/test-connection-error"; + +vi.mock("@homarr/db", async (importActual) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + const actual = await importActual(); + return { + ...actual, + db: createDb(), + }; +}); const DEFAULT_PASSWORD = "12341234"; const DEFAULT_API_KEY = "3b1434980677dcf53fa8c4a611db3b1f0f88478790097515c0abb539102778b9"; // Some hash generated from password @@ -27,31 +39,34 @@ describe("Pi-hole v5 integration", () => { await piholeContainer.stop(); }, 20_000); // Timeout of 20 seconds - test("testConnectionAsync should not throw", async () => { + test("testConnectionAsync should be successful", async () => { // Arrange const piholeContainer = await createPiHoleV5Container(DEFAULT_PASSWORD).start(); const piHoleIntegration = createPiHoleIntegrationV5(piholeContainer, DEFAULT_API_KEY); // Act - const actAsync = async () => await piHoleIntegration.testConnectionAsync(); + const result = await piHoleIntegration.testConnectionAsync(); // Assert - await expect(actAsync()).resolves.not.toThrow(); + expect(result.success).toBe(true); // Cleanup await piholeContainer.stop(); }, 20_000); // Timeout of 20 seconds - test("testConnectionAsync should throw with wrong credentials", async () => { + test("testConnectionAsync should fail with unauthorized for wrong credentials", async () => { // Arrange const piholeContainer = await createPiHoleV5Container(DEFAULT_PASSWORD).start(); const piHoleIntegration = createPiHoleIntegrationV5(piholeContainer, "wrong-api-key"); // Act - const actAsync = async () => await piHoleIntegration.testConnectionAsync(); + const result = await piHoleIntegration.testConnectionAsync(); // Assert - await expect(actAsync()).rejects.toThrow(); + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error).toBeInstanceOf(TestConnectionError); + expect(result.error.type).toBe("authorization"); // Cleanup await piholeContainer.stop(); @@ -138,31 +153,34 @@ describe("Pi-hole v6 integration", () => { expect(status.timer).toBeGreaterThan(timer - 10); }, 20_000); // Timeout of 20 seconds - test("testConnectionAsync should not throw", async () => { + test("testConnectionAsync should be successful", async () => { // Arrange const piholeContainer = await createPiHoleV6Container(DEFAULT_PASSWORD).start(); const piHoleIntegration = createPiHoleIntegrationV6(piholeContainer, DEFAULT_PASSWORD); // Act - const actAsync = async () => await piHoleIntegration.testConnectionAsync(); + const result = await piHoleIntegration.testConnectionAsync(); // Assert - await expect(actAsync()).resolves.not.toThrow(); + expect(result.success).toBe(true); // Cleanup await piholeContainer.stop(); }, 20_000); // Timeout of 20 seconds - test("testConnectionAsync should throw with wrong credentials", async () => { + test("testConnectionAsync should fail with unauthorized for wrong credentials", async () => { // Arrange const piholeContainer = await createPiHoleV6Container(DEFAULT_PASSWORD).start(); const piHoleIntegration = createPiHoleIntegrationV6(piholeContainer, "wrong-api-key"); // Act - const actAsync = async () => await piHoleIntegration.testConnectionAsync(); + const result = await piHoleIntegration.testConnectionAsync(); // Assert - await expect(actAsync()).rejects.toThrow(); + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error).toBeInstanceOf(TestConnectionError); + expect(result.error.type).toBe("authorization"); // Cleanup await piholeContainer.stop(); diff --git a/packages/integrations/test/sabnzbd.spec.ts b/packages/integrations/test/sabnzbd.spec.ts index b153a2a86..cb2201120 100644 --- a/packages/integrations/test/sabnzbd.spec.ts +++ b/packages/integrations/test/sabnzbd.spec.ts @@ -1,11 +1,23 @@ import { join } from "path"; import { GenericContainer, getContainerRuntimeClient, ImageName, Wait } from "testcontainers"; import type { StartedTestContainer } from "testcontainers"; -import { beforeAll, describe, expect, test } from "vitest"; +import { beforeAll, describe, expect, test, vi } from "vitest"; + +import { createDb } from "@homarr/db/test"; import { SabnzbdIntegration } from "../src"; +import { TestConnectionError } from "../src/base/test-connection/test-connection-error"; import type { DownloadClientItem } from "../src/interfaces/downloads/download-client-items"; +vi.mock("@homarr/db", async (importActual) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + const actual = await importActual(); + return { + ...actual, + db: createDb(), + }; +}); + const DEFAULT_API_KEY = "8r45mfes43s3iw7x3oecto6dl9ilxnf9"; const IMAGE_NAME = "linuxserver/sabnzbd:latest"; @@ -21,10 +33,10 @@ describe("Sabnzbd integration", () => { const sabnzbdIntegration = createSabnzbdIntegration(startedContainer, DEFAULT_API_KEY); // Act - const actAsync = async () => await sabnzbdIntegration.testConnectionAsync(); + const result = await sabnzbdIntegration.testConnectionAsync(); // Assert - await expect(actAsync()).resolves.not.toThrow(); + expect(result.success).toBe(true); // Cleanup await startedContainer.stop(); @@ -36,10 +48,13 @@ describe("Sabnzbd integration", () => { const sabnzbdIntegration = createSabnzbdIntegration(startedContainer, "wrong-api-key"); // Act - const actAsync = async () => await sabnzbdIntegration.testConnectionAsync(); + const result = await sabnzbdIntegration.testConnectionAsync(); // Assert - await expect(actAsync()).rejects.toThrow(); + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error).toBeInstanceOf(TestConnectionError); + expect(result.error.type).toBe("authorization"); // Cleanup await startedContainer.stop(); diff --git a/packages/modals-collection/src/certificates/add-certificate-modal.tsx b/packages/modals-collection/src/certificates/add-certificate-modal.tsx new file mode 100644 index 000000000..9ec4e4533 --- /dev/null +++ b/packages/modals-collection/src/certificates/add-certificate-modal.tsx @@ -0,0 +1,73 @@ +import { Button, FileInput, Group, Stack } from "@mantine/core"; +import { IconCertificate } from "@tabler/icons-react"; +import { z } from "zod"; + +import { clientApi } from "@homarr/api/client"; +import type { MaybePromise } from "@homarr/common/types"; +import { useZodForm } from "@homarr/form"; +import { createModal } from "@homarr/modals"; +import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; +import { useI18n } from "@homarr/translation/client"; +import { superRefineCertificateFile } from "@homarr/validation/certificates"; + +interface InnerProps { + onSuccess?: () => MaybePromise; +} + +export const AddCertificateModal = createModal(({ actions, innerProps }) => { + const t = useI18n(); + const form = useZodForm( + z.object({ + file: z.instanceof(File).nullable().superRefine(superRefineCertificateFile), + }), + { + initialValues: { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + file: null!, + }, + }, + ); + const { mutateAsync } = clientApi.certificates.addCertificate.useMutation(); + + return ( +
{ + const formData = new FormData(); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + formData.set("file", values.file!); + await mutateAsync(formData, { + async onSuccess() { + showSuccessNotification({ + title: t("certificate.action.create.notification.success.title"), + message: t("certificate.action.create.notification.success.message"), + }); + await innerProps.onSuccess?.(); + actions.closeModal(); + }, + onError() { + showErrorNotification({ + title: t("certificate.action.create.notification.error.title"), + message: t("certificate.action.create.notification.error.message"), + }); + }, + }); + })} + > + + } {...form.getInputProps("file")} /> + + + + + +
+ ); +}).withOptions({ + defaultTitle(t) { + return t("certificate.action.create.label"); + }, +}); diff --git a/packages/modals-collection/src/certificates/index.ts b/packages/modals-collection/src/certificates/index.ts new file mode 100644 index 000000000..0bdf423ea --- /dev/null +++ b/packages/modals-collection/src/certificates/index.ts @@ -0,0 +1 @@ +export * from "./add-certificate-modal"; diff --git a/packages/modals-collection/src/index.ts b/packages/modals-collection/src/index.ts index 2e6f1cf22..02f9f3bee 100644 --- a/packages/modals-collection/src/index.ts +++ b/packages/modals-collection/src/index.ts @@ -4,3 +4,4 @@ export * from "./groups"; export * from "./search-engines"; export * from "./docker"; export * from "./apps"; +export * from "./certificates"; diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 80e5198b5..0902cc0f6 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -702,6 +702,132 @@ "create": "Test connection and create", "edit": "Test connection and save" }, + "error": { + "common": { + "cause": { + "title": "Cause with more details" + } + }, + "unknown": { + "title": "Unknown error", + "description": "An unknown error occurred, open the cause below to see more details" + }, + "parse": { + "title": "Parse error", + "description": "The response could not be parsed. Please verify that the URL is pointing to the base URL of the service." + }, + "authorization": { + "title": "Authorization error", + "description": "The request was not authorized. Please verify that the credentials are correct and you have them configured with enough permissions." + }, + "statusCode": { + "title": "Response error", + "description": "Received unexpected {statusCode} ({reason}) response from . Please verify that the URL is pointing to the base URL of the integration.", + "otherDescription": "Received unexpected {statusCode} response from . Please verify that the URL is pointing to the base URL of the integration.", + "reason": { + "badRequest": "Bad request", + "notFound": "Not found", + "tooManyRequests": "Too many requests", + "internalServerError": "Internal server error", + "serviceUnavailable": "Service unavailable", + "gatewayTimeout": "Gateway timeout" + } + }, + "certificate": { + "title": "Certificate error", + "description": { + "expired": "The certificate has expired.", + "notYetValid": "The certificate is not yet valid.", + "untrusted": "The certificate is not trusted.", + "hostnameMismatch": "The certificate hostname does not match the URL." + }, + "alert": { + "permission": { + "title": "Not enough permissions", + "message": "You are not allowed to trust or upload certificates. Please contact your administrator to upload the necessary root certificate." + }, + "hostnameMismatch": { + "title": "Hostname mismatch", + "message": "The hostname in the certificate does not match the hostname you are connecting to. This could indicate a security risk, but you can still choose to trust this certificate." + }, + "extract": { + "title": "CA certificate extraction failed", + "message": "Only self signed certificates without a chain can be fetched automatically. If you are using a self signed certificate, please make sure to upload the CA certificate manually. You can find instructions on how to do this ." + } + }, + "action": { + "retry": { + "label": "Retry creation" + }, + "trust": { + "label": "Trust certificate" + }, + "upload": { + "label": "Upload certificate" + } + }, + "hostnameMismatch": { + "confirm": { + "title": "Trust hostname mismatch", + "message": "Are you sure you want to trust the certificate with a hostname mismatch?" + }, + "notification": { + "success": { + "title": "Trusted certificate", + "message": "Added hostname to trusted certificate list" + }, + "error": { + "title": "Failed to trust certificate", + "message": "The certificate with a hostname mismatch could not be trusted" + } + } + }, + "selfSigned": { + "confirm": { + "title": "Trust self signed certificate", + "message": "Are you sure you want to trust this self signed certificate?" + }, + "notification": { + "success": { + "title": "Trusted certificate", + "message": "Added certificate to trusted certificate list" + }, + "error": { + "title": "Failed to trust certificate", + "message": "Failed to add certificate to trusted certificate list" + } + } + }, + "details": { + "title": "Details", + "description": "Review the certificate information before deciding to trust it.", + "content": { + "action": "Show content", + "title": "PEM Certificate" + } + } + }, + "request": { + "title": "Request error", + "description": { + "connection": { + "hostUnreachable": "The server could not be reached. This usually means the host is offline or unreachable from your network.", + "networkUnreachable": "The network is unreachable. Please check your internet connection or network configuration.", + "refused": "The server refused the connection. It may not be running or is rejecting requests on the specified port.", + "reset": "The connection was unexpectedly closed by the server. This can happen if the server is unstable or restarted." + }, + "dns": { + "notFound": "The server address could not be found. Please check the URL for typos or invalid domain names.", + "timeout": "DNS lookup timed out. This may be a temporary issue—please try again in a few moments.", + "noAnswer": "The DNS server didn't return a valid response. The domain may exist but has no valid records." + }, + "timeout": { + "aborted": "The request was aborted before it could complete. This might be due to a user action or system timeout.", + "timeout": "The request took too long to complete and was timed out. Check your network or try again later." + } + } + } + }, "alertNotice": "The Save button is enabled once a successful connection is established", "notification": { "success": { @@ -878,6 +1004,7 @@ "cancel": "Cancel", "delete": "Delete", "discard": "Discard", + "close": "Close", "confirm": "Confirm", "continue": "Continue", "previous": "Previous", @@ -3496,7 +3623,10 @@ "label": "Logs" }, "certificates": { - "label": "Certificates" + "label": "Certificates", + "hostnames": { + "label": "Hostnames" + } } }, "settings": { @@ -3894,6 +4024,29 @@ } }, "certificate": { + "field": { + "hostname": { + "label": "Hostname" + }, + "subject": { + "label": "Subject" + }, + "issuer": { + "label": "Issuer" + }, + "validFrom": { + "label": "Valid from" + }, + "validTo": { + "label": "Valid to" + }, + "serialNumber": { + "label": "Serial number" + }, + "fingerprint": { + "label": "Fingerprint" + } + }, "page": { "list": { "title": "Trusted certificates", @@ -3905,7 +4058,16 @@ "title": "Invalid certificate", "description": "Failed to parse certificate" }, - "expires": "Expires {when}" + "expires": "Expires {when}", + "toHostnames": "Trusted hostnames" + }, + "hostnames": { + "title": "Trusted certificate hostnames", + "description": "Some certificates do not allow the specific domain Homarr uses to request them, because of this all trusted hostnames with their certificate thumbprints are used to bypass these restrictions.", + "noResults": { + "title": "There are no hostnames yet" + }, + "toCertificates": "Certificates" } }, "action": { @@ -3935,6 +4097,20 @@ "message": "The certificate could not be removed" } } + }, + "removeHostname": { + "label": "Remove trusted hostname", + "confirm": "Are you sure you want to remove this trusted hostname? This can cause some integrations to stop working.", + "notification": { + "success": { + "title": "Hostname removed", + "message": "The hostname was removed successfully" + }, + "error": { + "title": "Hostname not removed", + "message": "The hostname could not be removed" + } + } } } } diff --git a/patches/@types__node-unifi.patch b/patches/@types__node-unifi.patch new file mode 100644 index 000000000..054999ec6 --- /dev/null +++ b/patches/@types__node-unifi.patch @@ -0,0 +1,36 @@ +diff --git a/index.d.ts b/index.d.ts +index 04d9340306707b7d9c028bc692a6884c28e5a975..7c15a97c398fc5c41528803822f4e7bd1a27b36e 100644 +--- a/index.d.ts ++++ b/index.d.ts +@@ -1,4 +1,6 @@ + import { EventEmitter2 as EventEmitter } from "eventemitter2"; ++import {CookieAgentOptions} from 'http-cookie-agent/http' ++import { AxiosInstance } from "axios"; + + declare namespace nodeUnifi { + interface SiteSysinfo { +@@ -176,6 +178,8 @@ declare namespace nodeUnifi { + } + } + ++type CreateAxiosInstance = (options: Required) => AxiosInstance; ++ + declare class Controller extends EventEmitter { + /** + * Initialize a new Controller instance +@@ -187,6 +191,7 @@ declare class Controller extends EventEmitter { + * @param {boolean} [options.sslverify] - Enable SSL verification + * @param {number} [options.timeout] - Request timeout in milliseconds + * @param {boolean} [options.rememberMe] - Remember login session ++ * @param {CreateAxiosInstance} [options.createAxiosInstance] - Custom Axios instance + */ + constructor(options?: { + host?: string; +@@ -198,6 +203,7 @@ declare class Controller extends EventEmitter { + sslverify?: boolean; + timeout?: number; + rememberMe?: boolean; ++ createAxiosInstance?: CreateAxiosInstance; + }); + + /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3bb7f3575..e505d064f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,11 @@ settings: overrides: proxmox-api>undici: 7.9.0 +patchedDependencies: + '@types/node-unifi': + hash: 5e6ae51e2a17a7f9729bfa30b0eb3d0842a5810ac6db47603ab4a6efa1ed84c5 + path: patches/@types__node-unifi.patch + importers: .: @@ -744,6 +749,9 @@ importers: '@homarr/common': specifier: workspace:^0.1.0 version: link:../common + '@homarr/db': + specifier: workspace:^0.1.0 + version: link:../db undici: specifier: 7.9.0 version: 7.9.0 @@ -827,6 +835,9 @@ importers: zod: specifier: ^3.24.4 version: 3.24.4 + zod-validation-error: + specifier: ^3.4.0 + version: 3.4.0(zod@3.24.4) devDependencies: '@homarr/eslint-config': specifier: workspace:^0.2.0 @@ -1298,6 +1309,9 @@ importers: '@homarr/log': specifier: workspace:^0.1.0 version: link:../log + '@homarr/node-unifi': + specifier: ^2.6.0 + version: 2.6.0(undici@7.9.0) '@homarr/redis': specifier: workspace:^0.1.0 version: link:../redis @@ -1309,16 +1323,13 @@ importers: version: link:../validation '@jellyfin/sdk': specifier: ^0.11.0 - version: 0.11.0(axios@1.8.4) + version: 0.11.0(axios@1.9.0) maria2: specifier: ^0.4.0 version: 0.4.0 node-ical: specifier: ^0.20.1 version: 0.20.1 - node-unifi: - specifier: ^2.5.1 - version: 2.5.1(deasync@0.1.30)(undici@7.9.0) proxmox-api: specifier: 1.1.1 version: 1.1.1 @@ -1346,7 +1357,7 @@ importers: version: link:../../tooling/typescript '@types/node-unifi': specifier: ^2.5.1 - version: 2.5.1 + version: 2.5.1(patch_hash=5e6ae51e2a17a7f9729bfa30b0eb3d0842a5810ac6db47603ab4a6efa1ed84c5) '@types/xml2js': specifier: ^0.4.14 version: 0.4.14 @@ -3219,6 +3230,10 @@ packages: '@homarr/gridstack@1.12.0': resolution: {integrity: sha512-5gg9HaSPETgDMmpcPWYKEiaQsDEkMPu3xgHKvpmTo9JKUSE8Q06fdrf+N2WN3A2wGhZz2+/jPoIEXAuoYfvn0w==} + '@homarr/node-unifi@2.6.0': + resolution: {integrity: sha512-Nsh23+aIMzWhHelM7XDGz5B+5ADprOWuPVUyCpEStOgnHQfdC1Wa6z66VkROBeV2ep4a7YgvQV166/OCzP1sgw==} + engines: {node: '>=22.0.0'} + '@hono/node-server@1.13.0': resolution: {integrity: sha512-kz323qIQkNQElEGroo/E9MKPDuIR5pkuk/XEWd50K+cSEKdmdiYx0PKWUdaNY2ecJYngtF+njDMsMKplL6zfEg==} engines: {node: '>=18.14.1'} @@ -5211,15 +5226,15 @@ packages: resolution: {integrity: sha512-Mr2ZakwQ7XUAjp7pAwQWRhhK8mQQ6JAaNWSjmjxil0R8BPioMtQsTLOolGYkji1rcL++3dCqZA3zWqpT+9Ew6g==} engines: {node: '>=4'} - axios@1.6.2: - resolution: {integrity: sha512-7i24Ri4pmDRfJTR7LDBhsOTtcm+9kjX5WiY1X3wIisx6G9So3pfMkEiU7emUBe46oceVImccTEM3k6C5dbVW8A==} - axios@1.7.7: resolution: {integrity: sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==} axios@1.8.4: resolution: {integrity: sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw==} + axios@1.9.0: + resolution: {integrity: sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==} + axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} @@ -5850,10 +5865,6 @@ packages: dayjs@1.11.13: resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==} - deasync@0.1.30: - resolution: {integrity: sha512-OaAjvEQuQ9tJsKG4oHO9nV1UHTwb2Qc2+fadB0VeVtD0Z9wiG1XPGLJ4W3aLhAoQSYTaLROFRbd5X20Dkzf7MQ==} - engines: {node: '>=0.11.0'} - debug@3.2.7: resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} peerDependencies: @@ -6992,16 +7003,13 @@ packages: html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} - http-cookie-agent@5.0.4: - resolution: {integrity: sha512-OtvikW69RvfyP6Lsequ0fN5R49S+8QcS9zwd58k6VSr6r57T8G29BkPdyrBcSwLq6ExLs9V+rBlfxu7gDstJag==} - engines: {node: '>=14.18.0 <15.0.0 || >=16.0.0'} + http-cookie-agent@6.0.8: + resolution: {integrity: sha512-qnYh3yLSr2jBsTYkw11elq+T361uKAJaZ2dR4cfYZChw1dt9uL5t3zSUwehoqqVb4oldk1BpkXKm2oat8zV+oA==} + engines: {node: '>=18.0.0'} peerDependencies: - deasync: ^0.1.26 - tough-cookie: ^4.0.0 - undici: ^5.11.0 + tough-cookie: ^4.0.0 || ^5.0.0 + undici: ^5.11.0 || ^6.0.0 peerDependenciesMeta: - deasync: - optional: true undici: optional: true @@ -8121,9 +8129,6 @@ packages: node-abort-controller@3.1.1: resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} - node-addon-api@1.7.2: - resolution: {integrity: sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==} - node-addon-api@3.2.1: resolution: {integrity: sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A==} @@ -8192,10 +8197,6 @@ packages: node-releases@2.0.19: resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==} - node-unifi@2.5.1: - resolution: {integrity: sha512-mYLJFNKhONaXIFU2PeQ+p1fjr6C3q/Na8XyhZXpGalOArCAJLzpAoWl1rg9ZbmuJiVqwprqCq3u9Srn23CcpuA==} - engines: {node: '>=14.18.0 <15.0.0 || >=16.0.0'} - normalize-package-data@6.0.2: resolution: {integrity: sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==} engines: {node: ^16.14.0 || >=18.0.0} @@ -8821,9 +8822,6 @@ packages: proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} - psl@1.15.0: - resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} - pump@3.0.2: resolution: {integrity: sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==} @@ -8831,9 +8829,6 @@ packages: resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} engines: {node: '>=6'} - punycode@1.4.1: - resolution: {integrity: sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==} - punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -9885,10 +9880,6 @@ packages: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'} - tough-cookie@4.1.4: - resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==} - engines: {node: '>=6'} - tough-cookie@5.0.0: resolution: {integrity: sha512-FRKsF7cz96xIIeMZ82ehjC3xW2E+O2+v11udrDYewUbszngYhsGa8z6YUMMzO9QJZzzyd0nGGXnML/TReX6W8Q==} engines: {node: '>=16'} @@ -10213,10 +10204,6 @@ packages: universal-user-agent@7.0.2: resolution: {integrity: sha512-0JCqzSKnStlRRQfCdowvqy3cy0Dvtlb8xecj/H8JFZuCze4rwjPZQOgvFvn0Ws/usCHQFGpyr+pB9adaGwXn4Q==} - universalify@0.2.0: - resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} - engines: {node: '>= 4.0.0'} - universalify@2.0.1: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} @@ -10275,10 +10262,6 @@ packages: url-toolkit@2.2.5: resolution: {integrity: sha512-mtN6xk+Nac+oyJ/PrI7tzfmomRVNFIWKUbG8jdYFt52hxbiReFAXIjYskvu64/dvuW71IcB7lV8l0HvZMac6Jg==} - url@0.11.4: - resolution: {integrity: sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg==} - engines: {node: '>= 0.4'} - use-callback-ref@1.3.3: resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} engines: {node: '>=10'} @@ -10728,6 +10711,12 @@ packages: peerDependencies: zod: ^3.24.1 + zod-validation-error@3.4.0: + resolution: {integrity: sha512-ZOPR9SVY6Pb2qqO5XHt+MkkTRxGXb4EVtnjc9JpXUOtUB1T9Ru7mZOT361AN3MsetVe7R0a1KZshJDZdgp9miQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.18.0 + zod@3.24.4: resolution: {integrity: sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==} @@ -11531,6 +11520,19 @@ snapshots: '@homarr/gridstack@1.12.0': {} + '@homarr/node-unifi@2.6.0(undici@7.9.0)': + dependencies: + axios: 1.9.0 + eventemitter2: 6.4.9 + http-cookie-agent: 6.0.8(tough-cookie@5.1.2)(undici@7.9.0) + tough-cookie: 5.1.2 + ws: 8.18.2 + transitivePeerDependencies: + - bufferutil + - debug + - undici + - utf-8-validate + '@hono/node-server@1.13.0(hono@4.6.1)': dependencies: hono: 4.6.1 @@ -11650,9 +11652,9 @@ snapshots: '@istanbuljs/schema@0.1.3': {} - '@jellyfin/sdk@0.11.0(axios@1.8.4)': + '@jellyfin/sdk@0.11.0(axios@1.9.0)': dependencies: - axios: 1.8.4 + axios: 1.9.0 '@jridgewell/gen-mapping@0.3.5': dependencies: @@ -13347,7 +13349,7 @@ snapshots: '@types/node': 22.15.18 form-data: 4.0.1 - '@types/node-unifi@2.5.1': + '@types/node-unifi@2.5.1(patch_hash=5e6ae51e2a17a7f9729bfa30b0eb3d0842a5810ac6db47603ab4a6efa1ed84c5)': dependencies: eventemitter2: 6.4.9 @@ -13999,14 +14001,6 @@ snapshots: axe-core@4.10.0: {} - axios@1.6.2: - dependencies: - follow-redirects: 1.15.9 - form-data: 4.0.1 - proxy-from-env: 1.1.0 - transitivePeerDependencies: - - debug - axios@1.7.7: dependencies: follow-redirects: 1.15.9 @@ -14023,6 +14017,14 @@ snapshots: transitivePeerDependencies: - debug + axios@1.9.0: + dependencies: + follow-redirects: 1.15.9 + form-data: 4.0.1 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + axobject-query@4.1.0: {} b4a@1.6.6: {} @@ -14683,12 +14685,6 @@ snapshots: dayjs@1.11.13: {} - deasync@0.1.30: - dependencies: - bindings: 1.5.0 - node-addon-api: 1.7.2 - optional: true - debug@3.2.7: dependencies: ms: 2.1.3 @@ -16084,12 +16080,11 @@ snapshots: html-url-attributes@3.0.1: {} - http-cookie-agent@5.0.4(deasync@0.1.30)(tough-cookie@4.1.4)(undici@7.9.0): + http-cookie-agent@6.0.8(tough-cookie@5.1.2)(undici@7.9.0): dependencies: agent-base: 7.1.3 - tough-cookie: 4.1.4 + tough-cookie: 5.1.2 optionalDependencies: - deasync: 0.1.30 undici: 7.9.0 http-errors@2.0.0: @@ -17341,9 +17336,6 @@ snapshots: node-abort-controller@3.1.1: {} - node-addon-api@1.7.2: - optional: true - node-addon-api@3.2.1: optional: true @@ -17414,21 +17406,6 @@ snapshots: node-releases@2.0.19: {} - node-unifi@2.5.1(deasync@0.1.30)(undici@7.9.0): - dependencies: - axios: 1.6.2 - eventemitter2: 6.4.9 - http-cookie-agent: 5.0.4(deasync@0.1.30)(tough-cookie@4.1.4)(undici@7.9.0) - tough-cookie: 4.1.4 - url: 0.11.4 - ws: 8.18.2 - transitivePeerDependencies: - - bufferutil - - deasync - - debug - - undici - - utf-8-validate - normalize-package-data@6.0.2: dependencies: hosted-git-info: 7.0.2 @@ -18077,10 +18054,6 @@ snapshots: proxy-from-env@1.1.0: {} - psl@1.15.0: - dependencies: - punycode: 2.3.1 - pump@3.0.2: dependencies: end-of-stream: 1.4.4 @@ -18088,8 +18061,6 @@ snapshots: punycode.js@2.3.1: {} - punycode@1.4.1: {} - punycode@2.3.1: {} pupa@2.1.1: @@ -19443,13 +19414,6 @@ snapshots: totalist@3.0.1: {} - tough-cookie@4.1.4: - dependencies: - psl: 1.15.0 - punycode: 2.3.1 - universalify: 0.2.0 - url-parse: 1.5.10 - tough-cookie@5.0.0: dependencies: tldts: 6.1.69 @@ -19794,8 +19758,6 @@ snapshots: universal-user-agent@7.0.2: {} - universalify@0.2.0: {} - universalify@2.0.1: {} unpipe@1.0.0: {} @@ -19866,11 +19828,6 @@ snapshots: url-toolkit@2.2.5: {} - url@0.11.4: - dependencies: - punycode: 1.4.1 - qs: 6.13.1 - use-callback-ref@1.3.3(@types/react@19.1.4)(react@19.1.0): dependencies: react: 19.1.0 @@ -20363,6 +20320,10 @@ snapshots: dependencies: zod: 3.24.4 + zod-validation-error@3.4.0(zod@3.24.4): + dependencies: + zod: 3.24.4 + zod@3.24.4: {} zwitch@2.0.4: {} diff --git a/tooling/typescript/base.json b/tooling/typescript/base.json index ae235642b..4ff98d150 100644 --- a/tooling/typescript/base.json +++ b/tooling/typescript/base.json @@ -18,8 +18,10 @@ "incremental": true, "noUncheckedIndexedAccess": true, "strictNullChecks": true, + "experimentalDecorators": true, "baseUrl": ".", "paths": { + "@homarr/node-unifi": ["${configDir}/../../node_modules/@types/node-unifi"], "*": ["node_modules/*"] } },