diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index d68beb25d..50274be50 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -31,6 +31,7 @@ body: label: Version description: What version of Homarr are you running? options: + - 1.19.1 - 1.19.0 - 1.18.0 - 1.17.0 diff --git a/.github/workflows/automatic-approval.yml b/.github/workflows/automatic-approval.yml index 20a8ffc76..5893a5d7d 100644 --- a/.github/workflows/automatic-approval.yml +++ b/.github/workflows/automatic-approval.yml @@ -6,7 +6,7 @@ on: jobs: approve-automatic-prs: runs-on: ubuntu-latest - if: github.actor_id == 158783068 || github.actor_id == 190541745 # Id of renovate bot and crowdin bot see https://api.github.com/users/homarr-renovate%5Bbot%5D and https://api.github.com/users/homarr-crowdin%5Bbot%5D + if: github.actor_id == 158783068 || github.actor_id == 190541745 || github.actor_id == 210161987 # Id of renovate bot and crowdin bot see https://api.github.com/users/homarr-renovate%5Bbot%5D and https://api.github.com/users/homarr-crowdin%5Bbot%5D and https://api.github.com/users/homarr-update-contributors%5Bbot%5D steps: - name: Checkout code uses: actions/checkout@v4 diff --git a/.github/workflows/update-contributors.yml b/.github/workflows/update-contributors.yml new file mode 100644 index 000000000..adfd68ad5 --- /dev/null +++ b/.github/workflows/update-contributors.yml @@ -0,0 +1,70 @@ +name: Update Contributors + +on: + schedule: + - cron: "0 12 * * FRI" # At 12:00 on Friday. + workflow_dispatch: + +env: + GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" + CROWDIN_TOKEN: "${{ secrets.CROWDIN_UPDATE_CONTRIBUTORS_TOKEN }}" + +permissions: + contents: write + +jobs: + update-contributors: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [22] + steps: + - name: Obtain token + id: obtainToken + uses: tibdex/github-app-token@v2 + with: + private_key: ${{ secrets.HOMARR_UPDATE_CONTRIBUTORS_PRIVATE_KEY }} + app_id: ${{ vars.HOMARR_UPDATE_CONTRIBUTORS_APP_ID }} + + - name: Checkout repository + uses: actions/checkout@v4 + env: + GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }} + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + + - name: Run update script + run: node ./scripts/update-contributors.mjs + + - name: Commit changes + env: + GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }} + run: | + git config --global user.email "210161987+homarr-update-contributors[bot]@users.noreply.github.com" + git config --global user.name "Homarr Update Contributors" + git add . + git commit -m "chore: update contributors" + + - name: Create Pull Request + id: create-pull-request + uses: peter-evans/create-pull-request@v7 + with: + token: ${{ steps.obtainToken.outputs.token }} + branch: update-contributors + base: dev + title: "chore: update contributors" + delete-branch: true + body: | + This PR updates the contributors list in the static-data directory. + + - name: Install GitHub CLI + run: sudo apt-get install -y gh + + - name: Enable auto-merge + env: + GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }} + run: | + gh pr merge ${{steps.create-pull-request.outputs.pull-request-number}} --auto --squash diff --git a/.nvmrc b/.nvmrc index b8ffd7075..8320a6d29 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -22.15.0 +22.15.1 diff --git a/Dockerfile b/Dockerfile index 592a6d5cf..8c1f3c784 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:22.15.0-alpine AS base +FROM node:22.15.1-alpine AS base FROM base AS builder RUN apk add --no-cache libc6-compat diff --git a/apps/nextjs/package.json b/apps/nextjs/package.json index dfab908f2..7eaea6e65 100644 --- a/apps/nextjs/package.json +++ b/apps/nextjs/package.json @@ -48,17 +48,17 @@ "@homarr/ui": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0", "@homarr/widgets": "workspace:^0.1.0", - "@mantine/colors-generator": "^8.0.0", - "@mantine/core": "^8.0.0", - "@mantine/dropzone": "^8.0.0", - "@mantine/hooks": "^8.0.0", - "@mantine/modals": "^8.0.0", - "@mantine/tiptap": "^8.0.0", + "@mantine/colors-generator": "^8.0.1", + "@mantine/core": "^8.0.1", + "@mantine/dropzone": "^8.0.1", + "@mantine/hooks": "^8.0.1", + "@mantine/modals": "^8.0.1", + "@mantine/tiptap": "^8.0.1", "@million/lint": "1.0.14", "@tabler/icons-react": "^3.31.0", - "@tanstack/react-query": "^5.75.7", - "@tanstack/react-query-devtools": "^5.75.7", - "@tanstack/react-query-next-experimental": "^5.75.7", + "@tanstack/react-query": "^5.76.1", + "@tanstack/react-query-devtools": "^5.76.1", + "@tanstack/react-query-next-experimental": "^5.76.1", "@trpc/client": "^11.1.2", "@trpc/next": "^11.1.2", "@trpc/react-query": "^11.1.2", @@ -81,7 +81,7 @@ "react-dom": "19.1.0", "react-error-boundary": "^6.0.0", "react-simple-code-editor": "^0.14.1", - "sass": "^1.87.0", + "sass": "^1.89.0", "superjson": "2.2.2", "swagger-ui-react": "^5.21.0", "use-deep-compare-effect": "^1.8.1", @@ -92,10 +92,10 @@ "@homarr/prettier-config": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0", "@types/chroma-js": "3.1.1", - "@types/node": "^22.15.17", + "@types/node": "^22.15.18", "@types/prismjs": "^1.26.5", - "@types/react": "19.1.3", - "@types/react-dom": "19.1.3", + "@types/react": "19.1.4", + "@types/react-dom": "19.1.5", "@types/swagger-ui-react": "^5.18.0", "concurrently": "^9.1.2", "eslint": "^9.26.0", 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/src/components/layout/logo/logo.tsx b/apps/nextjs/src/components/layout/logo/logo.tsx index beb3f493c..36eaba57b 100644 --- a/apps/nextjs/src/components/layout/logo/logo.tsx +++ b/apps/nextjs/src/components/layout/logo/logo.tsx @@ -11,11 +11,11 @@ interface LogoProps { export const Logo = ({ size = 60, shouldUseNextImage = false, src, alt }: LogoProps) => shouldUseNextImage ? ( - {alt} + {alt} ) : ( // we only want to use next/image for logos that we are sure will be preloaded and are allowed // eslint-disable-next-line @next/next/no-img-element - {alt} + {alt} ); const logoWithTitleSizes = { 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/package.json b/apps/tasks/package.json index 0dc370fc0..52344d92e 100644 --- a/apps/tasks/package.json +++ b/apps/tasks/package.json @@ -38,13 +38,13 @@ "dayjs": "^1.11.13", "dotenv": "^16.5.0", "superjson": "2.2.2", - "undici": "7.8.0" + "undici": "7.9.0" }, "devDependencies": { "@homarr/eslint-config": "workspace:^0.2.0", "@homarr/prettier-config": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0", - "@types/node": "^22.15.17", + "@types/node": "^22.15.18", "dotenv-cli": "^8.0.0", "eslint": "^9.26.0", "prettier": "^3.5.3", 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 ee6b0d648..7a759f1d2 100644 --- a/package.json +++ b/package.json @@ -47,15 +47,15 @@ "jsdom": "^26.1.0", "prettier": "^3.5.3", "semantic-release": "^24.2.3", - "testcontainers": "^10.25.0", + "testcontainers": "^10.26.0", "turbo": "^2.5.3", "typescript": "^5.8.3", "vite-tsconfig-paths": "^5.1.4", "vitest": "^3.1.3" }, - "packageManager": "pnpm@10.10.0", + "packageManager": "pnpm@10.11.0", "engines": { - "node": ">=22.15.0" + "node": ">=22.15.1" }, "pnpm": { "onlyBuiltDependencies": [ @@ -70,13 +70,16 @@ "tree-sitter-json" ], "overrides": { - "proxmox-api>undici": "7.8.0" + "proxmox-api>undici": "7.9.0" }, "allowUnusedPatches": true, "ignoredBuiltDependencies": [ "@scarf/scarf", "core-js-pure", "protobufjs" - ] + ], + "patchedDependencies": { + "@types/node-unifi": "patches/@types__node-unifi.patch" + } } } diff --git a/packages/api/package.json b/packages/api/package.json index 58b30b589..ba65933d2 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -41,7 +41,7 @@ "@homarr/server-settings": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0", "@kubernetes/client-node": "^1.2.0", - "@tanstack/react-query": "^5.75.7", + "@tanstack/react-query": "^5.76.1", "@trpc/client": "^11.1.2", "@trpc/react-query": "^11.1.2", "@trpc/server": "^11.1.2", 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/auth/env.ts b/packages/auth/env.ts index e58aa9673..40878312f 100644 --- a/packages/auth/env.ts +++ b/packages/auth/env.ts @@ -40,6 +40,7 @@ export const env = createEnv({ AUTH_OIDC_GROUPS_ATTRIBUTE: z.string().default("groups"), // Is used in the signIn event to assign the correct groups, key is from object of decoded id_token AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE: z.string().optional(), AUTH_OIDC_FORCE_USERINFO: createBooleanSchema(false), + AUTH_OIDC_ENABLE_DANGEROUS_ACCOUNT_LINKING: createBooleanSchema(false), } : {}), ...(authProviders.includes("ldap") diff --git a/packages/auth/package.json b/packages/auth/package.json index 59ec3ecc6..b1a970695 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -23,8 +23,8 @@ }, "prettier": "@homarr/prettier-config", "dependencies": { - "@auth/core": "^0.39.0", - "@auth/drizzle-adapter": "^1.9.0", + "@auth/core": "^0.39.1", + "@auth/drizzle-adapter": "^1.9.1", "@homarr/certificates": "workspace:^0.1.0", "@homarr/common": "workspace:^0.1.0", "@homarr/db": "workspace:^0.1.0", @@ -32,11 +32,11 @@ "@homarr/env": "workspace:^0.1.0", "@homarr/log": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0", - "bcrypt": "^5.1.1", + "bcrypt": "^6.0.0", "cookies": "^0.9.1", "ldapts": "8.0.0", "next": "15.3.2", - "next-auth": "5.0.0-beta.27", + "next-auth": "5.0.0-beta.28", "react": "19.1.0", "react-dom": "19.1.0", "zod": "^3.24.4" diff --git a/packages/auth/providers/oidc/oidc-provider.ts b/packages/auth/providers/oidc/oidc-provider.ts index bd72f9359..72fe191e8 100644 --- a/packages/auth/providers/oidc/oidc-provider.ts +++ b/packages/auth/providers/oidc/oidc-provider.ts @@ -15,6 +15,7 @@ export const OidcProvider = (headers: ReadonlyHeaders | null): OIDCConfig { 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 6148d9c56..c5fe11b44 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -33,8 +33,9 @@ "next": "15.3.2", "react": "19.1.0", "react-dom": "19.1.0", - "undici": "7.8.0", - "zod": "^3.24.4" + "undici": "7.9.0", + "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/package.json b/packages/db/package.json index 89628e825..7a4c6afb8 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -38,15 +38,15 @@ }, "prettier": "@homarr/prettier-config", "dependencies": { - "@auth/core": "^0.39.0", + "@auth/core": "^0.39.1", "@homarr/common": "workspace:^0.1.0", "@homarr/definitions": "workspace:^0.1.0", "@homarr/env": "workspace:^0.1.0", "@homarr/log": "workspace:^0.1.0", "@homarr/server-settings": "workspace:^0.1.0", - "@mantine/core": "^8.0.0", + "@mantine/core": "^8.0.1", "@paralleldrive/cuid2": "^2.2.2", - "@testcontainers/mysql": "^10.25.0", + "@testcontainers/mysql": "^10.26.0", "better-sqlite3": "^11.10.0", "dotenv": "^16.5.0", "drizzle-kit": "^0.31.1", 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/definitions/package.json b/packages/definitions/package.json index bd85d07a7..ef85127ac 100644 --- a/packages/definitions/package.json +++ b/packages/definitions/package.json @@ -23,13 +23,16 @@ }, "prettier": "@homarr/prettier-config", "dependencies": { - "@homarr/common": "workspace:^0.1.0" + "@homarr/common": "workspace:^0.1.0", + "fast-xml-parser": "^5.2.3", + "zod": "^3.24.4" }, "devDependencies": { "@homarr/eslint-config": "workspace:^0.2.0", "@homarr/prettier-config": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0", "eslint": "^9.26.0", + "tsx": "4.19.4", "typescript": "^5.8.3" } } diff --git a/packages/definitions/src/docs/codegen.ts b/packages/definitions/src/docs/codegen.ts index 9f35a3af3..e8087efb8 100644 --- a/packages/definitions/src/docs/codegen.ts +++ b/packages/definitions/src/docs/codegen.ts @@ -1,6 +1,6 @@ -import fs from "fs/promises"; -import path, { dirname } from "path"; -import { fileURLToPath } from "url"; +import fs from "node:fs/promises"; +import path, { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; import { XMLParser } from "fast-xml-parser"; import { z } from "zod"; diff --git a/packages/definitions/src/docs/homarr-docs-sitemap.ts b/packages/definitions/src/docs/homarr-docs-sitemap.ts index 67cc3c132..fdf1afea3 100644 --- a/packages/definitions/src/docs/homarr-docs-sitemap.ts +++ b/packages/definitions/src/docs/homarr-docs-sitemap.ts @@ -174,6 +174,7 @@ export type HomarrDocumentationPath = | "/docs/getting-started/installation/easy-panel" | "/docs/getting-started/installation/helm" | "/docs/getting-started/installation/home-assistant" + | "/docs/getting-started/installation/pika-pods" | "/docs/getting-started/installation/portainer" | "/docs/getting-started/installation/proxmox" | "/docs/getting-started/installation/qnap" diff --git a/packages/form/package.json b/packages/form/package.json index 63ed6f031..f8b3f15ec 100644 --- a/packages/form/package.json +++ b/packages/form/package.json @@ -26,7 +26,7 @@ "@homarr/common": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0", - "@mantine/form": "^8.0.0", + "@mantine/form": "^8.0.1", "zod": "^3.24.4" }, "devDependencies": { diff --git a/packages/forms-collection/package.json b/packages/forms-collection/package.json index 6df505144..351fcebba 100644 --- a/packages/forms-collection/package.json +++ b/packages/forms-collection/package.json @@ -29,7 +29,7 @@ "@homarr/notifications": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0", - "@mantine/core": "^8.0.0", + "@mantine/core": "^8.0.1", "react": "19.1.0", "zod": "^3.24.4" }, diff --git a/packages/forms-collection/src/index.tsx b/packages/forms-collection/src/index.tsx index 273e5547d..218655cb5 100644 --- a/packages/forms-collection/src/index.tsx +++ b/packages/forms-collection/src/index.tsx @@ -2,5 +2,6 @@ export * from "./new-app/_app-new-form"; export * from "./new-app/_form"; export * from "./icon-picker/icon-picker"; +export * from "./new-app/icon-matcher"; export * from "./upload-media/upload-media"; diff --git a/packages/integrations/package.json b/packages/integrations/package.json index f226e87f8..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,16 +33,16 @@ "@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.8.0", + "undici": "7.9.0", "xml2js": "^0.6.2", "zod": "^3.24.4" }, 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/package.json b/packages/modals-collection/package.json index 2d3418e30..098217764 100644 --- a/packages/modals-collection/package.json +++ b/packages/modals-collection/package.json @@ -33,7 +33,7 @@ "@homarr/translation": "workspace:^0.1.0", "@homarr/ui": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0", - "@mantine/core": "^8.0.0", + "@mantine/core": "^8.0.1", "@tabler/icons-react": "^3.31.0", "dayjs": "^1.11.13", "next": "15.3.2", 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/modals/package.json b/packages/modals/package.json index db6c023f6..381ae1a72 100644 --- a/packages/modals/package.json +++ b/packages/modals/package.json @@ -24,8 +24,8 @@ "dependencies": { "@homarr/translation": "workspace:^0.1.0", "@homarr/ui": "workspace:^0.1.0", - "@mantine/core": "^8.0.0", - "@mantine/hooks": "^8.0.0", + "@mantine/core": "^8.0.1", + "@mantine/hooks": "^8.0.1", "react": "19.1.0" }, "devDependencies": { diff --git a/packages/notifications/package.json b/packages/notifications/package.json index 977edd2a1..cc218806e 100644 --- a/packages/notifications/package.json +++ b/packages/notifications/package.json @@ -24,7 +24,7 @@ "prettier": "@homarr/prettier-config", "dependencies": { "@homarr/ui": "workspace:^0.1.0", - "@mantine/notifications": "^8.0.0", + "@mantine/notifications": "^8.0.1", "@tabler/icons-react": "^3.31.0" }, "devDependencies": { diff --git a/packages/old-import/package.json b/packages/old-import/package.json index 04d87b073..fe137aff1 100644 --- a/packages/old-import/package.json +++ b/packages/old-import/package.json @@ -37,8 +37,8 @@ "@homarr/translation": "workspace:^0.1.0", "@homarr/ui": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0", - "@mantine/core": "^8.0.0", - "@mantine/hooks": "^8.0.0", + "@mantine/core": "^8.0.1", + "@mantine/hooks": "^8.0.1", "adm-zip": "0.5.16", "next": "15.3.2", "react": "19.1.0", diff --git a/packages/request-handler/package.json b/packages/request-handler/package.json index a159e1178..ec6474d2e 100644 --- a/packages/request-handler/package.json +++ b/packages/request-handler/package.json @@ -22,7 +22,7 @@ }, "prettier": "@homarr/prettier-config", "dependencies": { - "@extractus/feed-extractor": "7.1.5", + "@extractus/feed-extractor": "7.1.6", "@homarr/common": "workspace:^0.1.0", "@homarr/db": "workspace:^0.1.0", "@homarr/definitions": "workspace:^0.1.0", diff --git a/packages/settings/package.json b/packages/settings/package.json index ccfbda94c..aec3cf33f 100644 --- a/packages/settings/package.json +++ b/packages/settings/package.json @@ -26,7 +26,7 @@ "@homarr/api": "workspace:^0.1.0", "@homarr/db": "workspace:^0.1.0", "@homarr/server-settings": "workspace:^0.1.0", - "@mantine/dates": "^8.0.0", + "@mantine/dates": "^8.0.1", "next": "15.3.2", "react": "19.1.0", "react-dom": "19.1.0" diff --git a/packages/spotlight/package.json b/packages/spotlight/package.json index e780ced05..93e5801d1 100644 --- a/packages/spotlight/package.json +++ b/packages/spotlight/package.json @@ -33,9 +33,9 @@ "@homarr/settings": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0", "@homarr/ui": "workspace:^0.1.0", - "@mantine/core": "^8.0.0", - "@mantine/hooks": "^8.0.0", - "@mantine/spotlight": "^8.0.0", + "@mantine/core": "^8.0.1", + "@mantine/hooks": "^8.0.1", + "@mantine/spotlight": "^8.0.1", "@tabler/icons-react": "^3.31.0", "jotai": "^2.12.4", "next": "15.3.2", diff --git a/packages/translation/src/lang/ca.json b/packages/translation/src/lang/ca.json index 156413786..5d6d0268d 100644 --- a/packages/translation/src/lang/ca.json +++ b/packages/translation/src/lang/ca.json @@ -2072,6 +2072,10 @@ "showDetails": { "label": "" }, + "topReleases": { + "label": "", + "description": "" + }, "repositories": { "label": "", "addRRepository": { @@ -2084,6 +2088,9 @@ "label": "", "placeholder": "" }, + "name": { + "label": "" + }, "versionFilter": { "label": "", "prefix": { @@ -2130,7 +2137,13 @@ "openProjectPage": "", "openReleasePage": "", "releaseDescription": "", - "created": "" + "created": "", + "error": { + "label": "", + "options": { + "noMatchingVersion": "" + } + } }, "networkControllerSummary": { "option": {}, diff --git a/packages/translation/src/lang/cn.json b/packages/translation/src/lang/cn.json index 227fb722a..79b810abf 100644 --- a/packages/translation/src/lang/cn.json +++ b/packages/translation/src/lang/cn.json @@ -2072,6 +2072,10 @@ "showDetails": { "label": "" }, + "topReleases": { + "label": "", + "description": "" + }, "repositories": { "label": "", "addRRepository": { @@ -2084,6 +2088,9 @@ "label": "", "placeholder": "" }, + "name": { + "label": "" + }, "versionFilter": { "label": "", "prefix": { @@ -2130,7 +2137,13 @@ "openProjectPage": "", "openReleasePage": "", "releaseDescription": "", - "created": "" + "created": "", + "error": { + "label": "", + "options": { + "noMatchingVersion": "" + } + } }, "networkControllerSummary": { "option": {}, diff --git a/packages/translation/src/lang/cs.json b/packages/translation/src/lang/cs.json index 2201dbe4f..1a8df3707 100644 --- a/packages/translation/src/lang/cs.json +++ b/packages/translation/src/lang/cs.json @@ -2072,6 +2072,10 @@ "showDetails": { "label": "" }, + "topReleases": { + "label": "", + "description": "" + }, "repositories": { "label": "", "addRRepository": { @@ -2084,6 +2088,9 @@ "label": "", "placeholder": "" }, + "name": { + "label": "" + }, "versionFilter": { "label": "", "prefix": { @@ -2130,7 +2137,13 @@ "openProjectPage": "", "openReleasePage": "", "releaseDescription": "", - "created": "" + "created": "", + "error": { + "label": "", + "options": { + "noMatchingVersion": "" + } + } }, "networkControllerSummary": { "option": {}, diff --git a/packages/translation/src/lang/da.json b/packages/translation/src/lang/da.json index ed750b39b..ed4043a88 100644 --- a/packages/translation/src/lang/da.json +++ b/packages/translation/src/lang/da.json @@ -2072,6 +2072,10 @@ "showDetails": { "label": "" }, + "topReleases": { + "label": "", + "description": "" + }, "repositories": { "label": "", "addRRepository": { @@ -2084,6 +2088,9 @@ "label": "", "placeholder": "" }, + "name": { + "label": "" + }, "versionFilter": { "label": "", "prefix": { @@ -2130,7 +2137,13 @@ "openProjectPage": "", "openReleasePage": "", "releaseDescription": "", - "created": "" + "created": "", + "error": { + "label": "", + "options": { + "noMatchingVersion": "" + } + } }, "networkControllerSummary": { "option": {}, diff --git a/packages/translation/src/lang/de-CH.json b/packages/translation/src/lang/de-CH.json index 3c8922019..286800648 100644 --- a/packages/translation/src/lang/de-CH.json +++ b/packages/translation/src/lang/de-CH.json @@ -2072,6 +2072,10 @@ "showDetails": { "label": "" }, + "topReleases": { + "label": "", + "description": "" + }, "repositories": { "label": "", "addRRepository": { @@ -2084,6 +2088,9 @@ "label": "", "placeholder": "" }, + "name": { + "label": "" + }, "versionFilter": { "label": "", "prefix": { @@ -2130,7 +2137,13 @@ "openProjectPage": "", "openReleasePage": "", "releaseDescription": "", - "created": "" + "created": "", + "error": { + "label": "", + "options": { + "noMatchingVersion": "" + } + } }, "networkControllerSummary": { "option": {}, diff --git a/packages/translation/src/lang/de.json b/packages/translation/src/lang/de.json index 3a4c2b320..b4b966872 100644 --- a/packages/translation/src/lang/de.json +++ b/packages/translation/src/lang/de.json @@ -2072,6 +2072,10 @@ "showDetails": { "label": "" }, + "topReleases": { + "label": "", + "description": "" + }, "repositories": { "label": "", "addRRepository": { @@ -2084,6 +2088,9 @@ "label": "", "placeholder": "" }, + "name": { + "label": "" + }, "versionFilter": { "label": "", "prefix": { @@ -2130,7 +2137,13 @@ "openProjectPage": "", "openReleasePage": "", "releaseDescription": "", - "created": "" + "created": "", + "error": { + "label": "", + "options": { + "noMatchingVersion": "" + } + } }, "networkControllerSummary": { "option": {}, diff --git a/packages/translation/src/lang/el.json b/packages/translation/src/lang/el.json index 8f400ba6e..612c71836 100644 --- a/packages/translation/src/lang/el.json +++ b/packages/translation/src/lang/el.json @@ -2072,6 +2072,10 @@ "showDetails": { "label": "" }, + "topReleases": { + "label": "", + "description": "" + }, "repositories": { "label": "", "addRRepository": { @@ -2084,6 +2088,9 @@ "label": "", "placeholder": "" }, + "name": { + "label": "" + }, "versionFilter": { "label": "", "prefix": { @@ -2130,7 +2137,13 @@ "openProjectPage": "", "openReleasePage": "", "releaseDescription": "", - "created": "" + "created": "", + "error": { + "label": "", + "options": { + "noMatchingVersion": "" + } + } }, "networkControllerSummary": { "option": {}, diff --git a/packages/translation/src/lang/en-gb.json b/packages/translation/src/lang/en-gb.json index 8e758a660..c14f4d750 100644 --- a/packages/translation/src/lang/en-gb.json +++ b/packages/translation/src/lang/en-gb.json @@ -2072,6 +2072,10 @@ "showDetails": { "label": "" }, + "topReleases": { + "label": "", + "description": "" + }, "repositories": { "label": "", "addRRepository": { @@ -2084,6 +2088,9 @@ "label": "", "placeholder": "" }, + "name": { + "label": "" + }, "versionFilter": { "label": "", "prefix": { @@ -2130,7 +2137,13 @@ "openProjectPage": "", "openReleasePage": "", "releaseDescription": "", - "created": "" + "created": "", + "error": { + "label": "", + "options": { + "noMatchingVersion": "" + } + } }, "networkControllerSummary": { "option": {}, diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 08f197944..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", @@ -2072,6 +2199,10 @@ "showDetails": { "label": "Show Details" }, + "topReleases": { + "label": "Top Releases", + "description": "The max number of latest releases to show. Zero means no limit." + }, "repositories": { "label": "Repositories", "addRRepository": { @@ -2084,6 +2215,9 @@ "label": "Identifier", "placeholder": "Name or Owner/Name" }, + "name": { + "label": "Name" + }, "versionFilter": { "label": "Version Filter", "prefix": { @@ -3489,7 +3623,10 @@ "label": "Logs" }, "certificates": { - "label": "Certificates" + "label": "Certificates", + "hostnames": { + "label": "Hostnames" + } } }, "settings": { @@ -3887,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", @@ -3898,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": { @@ -3928,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/packages/translation/src/lang/es.json b/packages/translation/src/lang/es.json index c3c8f0b52..2adaec83c 100644 --- a/packages/translation/src/lang/es.json +++ b/packages/translation/src/lang/es.json @@ -2072,6 +2072,10 @@ "showDetails": { "label": "" }, + "topReleases": { + "label": "", + "description": "" + }, "repositories": { "label": "", "addRRepository": { @@ -2084,6 +2088,9 @@ "label": "", "placeholder": "" }, + "name": { + "label": "" + }, "versionFilter": { "label": "", "prefix": { @@ -2130,7 +2137,13 @@ "openProjectPage": "", "openReleasePage": "", "releaseDescription": "", - "created": "" + "created": "", + "error": { + "label": "", + "options": { + "noMatchingVersion": "" + } + } }, "networkControllerSummary": { "option": {}, diff --git a/packages/translation/src/lang/et.json b/packages/translation/src/lang/et.json index 91ec10167..5c1c02160 100644 --- a/packages/translation/src/lang/et.json +++ b/packages/translation/src/lang/et.json @@ -2072,6 +2072,10 @@ "showDetails": { "label": "" }, + "topReleases": { + "label": "", + "description": "" + }, "repositories": { "label": "", "addRRepository": { @@ -2084,6 +2088,9 @@ "label": "", "placeholder": "" }, + "name": { + "label": "" + }, "versionFilter": { "label": "", "prefix": { @@ -2130,7 +2137,13 @@ "openProjectPage": "", "openReleasePage": "", "releaseDescription": "", - "created": "" + "created": "", + "error": { + "label": "", + "options": { + "noMatchingVersion": "" + } + } }, "networkControllerSummary": { "option": {}, diff --git a/packages/translation/src/lang/fr.json b/packages/translation/src/lang/fr.json index 930f1325e..ea933509a 100644 --- a/packages/translation/src/lang/fr.json +++ b/packages/translation/src/lang/fr.json @@ -2072,6 +2072,10 @@ "showDetails": { "label": "" }, + "topReleases": { + "label": "", + "description": "" + }, "repositories": { "label": "", "addRRepository": { @@ -2084,6 +2088,9 @@ "label": "", "placeholder": "" }, + "name": { + "label": "" + }, "versionFilter": { "label": "", "prefix": { @@ -2130,7 +2137,13 @@ "openProjectPage": "", "openReleasePage": "", "releaseDescription": "", - "created": "" + "created": "", + "error": { + "label": "", + "options": { + "noMatchingVersion": "" + } + } }, "networkControllerSummary": { "option": {}, diff --git a/packages/translation/src/lang/he.json b/packages/translation/src/lang/he.json index e3ef91a1d..3f66cc144 100644 --- a/packages/translation/src/lang/he.json +++ b/packages/translation/src/lang/he.json @@ -1093,7 +1093,7 @@ "label": "אינטגרציות" }, "title": { - "label": "" + "label": "כותרת" }, "customCssClasses": { "label": "מחלקות עיצוב מותאמות אישית" @@ -1771,8 +1771,8 @@ "description": "הצג את הזרמים הנוכחיים בשרתי המדיה שלך", "option": { "showOnlyPlaying": { - "label": "", - "description": "" + "label": "הצג רק את ההפעלה הנוכחית", + "description": "השבתת אפשרות זו לא תעבוד עבור פלקס" } }, "items": { @@ -1950,7 +1950,7 @@ "approved": "אושר", "declined": "נדחה", "failed": "נכשל", - "completed": "" + "completed": "הושלם" }, "toBeDetermined": "ייקבע בהמשך" }, @@ -2054,83 +2054,96 @@ } }, "releases": { - "name": "", - "description": "", + "name": "מהדורות", + "description": "מציג רשימה של הגרסה הנוכחית של המאגרים הנתונים עם הקוד הרגולרי של הגרסה הנתון.", "option": { "newReleaseWithin": { - "label": "", + "label": "מהדורה חדשה בתוך", "description": "" }, "staleReleaseWithin": { - "label": "", + "label": "מהדורה חדשה בתוך", "description": "" }, "showOnlyHighlighted": { + "label": "הצג רק את המודגשים", + "description": "הצג רק מהדורות חדשות או ישנות. בהתאם לאמור לעיל." + }, + "showDetails": { + "label": "הצג פרטים" + }, + "topReleases": { "label": "", "description": "" }, - "showDetails": { - "label": "" - }, "repositories": { - "label": "", + "label": "מאגרים", "addRRepository": { - "label": "" + "label": "הוסף מאגר" }, "provider": { - "label": "" + "label": "ספק" }, "identifier": { - "label": "", - "placeholder": "" + "label": "מזהה", + "placeholder": "שם או בעלים/שם" + }, + "name": { + "label": "" }, "versionFilter": { - "label": "", + "label": "מסנן גירסאות", "prefix": { - "label": "" + "label": "קידומת" }, "precision": { - "label": "", + "label": "דיוק", "options": { - "none": "" + "none": "ללא" } }, "suffix": { - "label": "" + "label": "סיומת" }, "regex": { - "label": "" + "label": "ביטויים רגילים" } }, "edit": { - "label": "" + "label": "עריכה" }, "editForm": { - "title": "", + "title": "ערוך מאגר", "cancel": { - "label": "" + "label": "בטל" }, "confirm": { - "label": "" + "label": "אשר" } }, "example": { - "label": "" + "label": "דוגמא" }, - "invalid": "" + "invalid": "הגדרת מאגר לא חוקית, אנא בדוק את הערכים" } }, - "not-found": "", - "pre-release": "", - "archived": "", - "forked": "", - "starsCount": "", - "forksCount": "", - "issuesCount": "", - "openProjectPage": "", - "openReleasePage": "", - "releaseDescription": "", - "created": "" + "not-found": "לא נמצא", + "pre-release": "קדם-הפצה", + "archived": "בארכיון", + "forked": "מפוצל", + "starsCount": "כוכבים", + "forksCount": "פיצולים", + "issuesCount": "תקלות פתוחות", + "openProjectPage": "פתח את דף הפרויקט", + "openReleasePage": "פתח את דף הגרסאות", + "releaseDescription": "תיאור הגרסה", + "created": "נוצר", + "error": { + "label": "", + "options": { + "noMatchingVersion": "" + } + } }, "networkControllerSummary": { "option": {}, @@ -3889,8 +3902,8 @@ "title": "אין עדיין תעודות אבטחה" }, "invalid": { - "title": "", - "description": "" + "title": "אישור לא חוקי", + "description": "נכשל בהוספת תעודת אבטחה" }, "expires": "פג ב- {when}" } diff --git a/packages/translation/src/lang/hr.json b/packages/translation/src/lang/hr.json index f0b7eba92..cfc3de796 100644 --- a/packages/translation/src/lang/hr.json +++ b/packages/translation/src/lang/hr.json @@ -2072,6 +2072,10 @@ "showDetails": { "label": "" }, + "topReleases": { + "label": "", + "description": "" + }, "repositories": { "label": "", "addRRepository": { @@ -2084,6 +2088,9 @@ "label": "", "placeholder": "" }, + "name": { + "label": "" + }, "versionFilter": { "label": "", "prefix": { @@ -2130,7 +2137,13 @@ "openProjectPage": "", "openReleasePage": "", "releaseDescription": "", - "created": "" + "created": "", + "error": { + "label": "", + "options": { + "noMatchingVersion": "" + } + } }, "networkControllerSummary": { "option": {}, diff --git a/packages/translation/src/lang/hu.json b/packages/translation/src/lang/hu.json index 54131ba6e..08711bfef 100644 --- a/packages/translation/src/lang/hu.json +++ b/packages/translation/src/lang/hu.json @@ -2072,6 +2072,10 @@ "showDetails": { "label": "" }, + "topReleases": { + "label": "", + "description": "" + }, "repositories": { "label": "", "addRRepository": { @@ -2084,6 +2088,9 @@ "label": "", "placeholder": "" }, + "name": { + "label": "" + }, "versionFilter": { "label": "", "prefix": { @@ -2130,7 +2137,13 @@ "openProjectPage": "", "openReleasePage": "", "releaseDescription": "", - "created": "" + "created": "", + "error": { + "label": "", + "options": { + "noMatchingVersion": "" + } + } }, "networkControllerSummary": { "option": {}, diff --git a/packages/translation/src/lang/it.json b/packages/translation/src/lang/it.json index f73900801..9f806dbae 100644 --- a/packages/translation/src/lang/it.json +++ b/packages/translation/src/lang/it.json @@ -2072,6 +2072,10 @@ "showDetails": { "label": "" }, + "topReleases": { + "label": "", + "description": "" + }, "repositories": { "label": "", "addRRepository": { @@ -2084,6 +2088,9 @@ "label": "", "placeholder": "" }, + "name": { + "label": "" + }, "versionFilter": { "label": "", "prefix": { @@ -2130,7 +2137,13 @@ "openProjectPage": "", "openReleasePage": "", "releaseDescription": "", - "created": "" + "created": "", + "error": { + "label": "", + "options": { + "noMatchingVersion": "" + } + } }, "networkControllerSummary": { "option": {}, diff --git a/packages/translation/src/lang/ja.json b/packages/translation/src/lang/ja.json index aaff155f1..69fdfba25 100644 --- a/packages/translation/src/lang/ja.json +++ b/packages/translation/src/lang/ja.json @@ -2072,6 +2072,10 @@ "showDetails": { "label": "" }, + "topReleases": { + "label": "", + "description": "" + }, "repositories": { "label": "", "addRRepository": { @@ -2084,6 +2088,9 @@ "label": "", "placeholder": "" }, + "name": { + "label": "" + }, "versionFilter": { "label": "", "prefix": { @@ -2130,7 +2137,13 @@ "openProjectPage": "", "openReleasePage": "", "releaseDescription": "", - "created": "" + "created": "", + "error": { + "label": "", + "options": { + "noMatchingVersion": "" + } + } }, "networkControllerSummary": { "option": {}, diff --git a/packages/translation/src/lang/ko.json b/packages/translation/src/lang/ko.json index 9a5158984..934afca77 100644 --- a/packages/translation/src/lang/ko.json +++ b/packages/translation/src/lang/ko.json @@ -2072,6 +2072,10 @@ "showDetails": { "label": "" }, + "topReleases": { + "label": "", + "description": "" + }, "repositories": { "label": "", "addRRepository": { @@ -2084,6 +2088,9 @@ "label": "", "placeholder": "" }, + "name": { + "label": "" + }, "versionFilter": { "label": "", "prefix": { @@ -2130,7 +2137,13 @@ "openProjectPage": "", "openReleasePage": "", "releaseDescription": "", - "created": "" + "created": "", + "error": { + "label": "", + "options": { + "noMatchingVersion": "" + } + } }, "networkControllerSummary": { "option": {}, diff --git a/packages/translation/src/lang/lt.json b/packages/translation/src/lang/lt.json index cf5ba5ff3..34de96955 100644 --- a/packages/translation/src/lang/lt.json +++ b/packages/translation/src/lang/lt.json @@ -2072,6 +2072,10 @@ "showDetails": { "label": "" }, + "topReleases": { + "label": "", + "description": "" + }, "repositories": { "label": "", "addRRepository": { @@ -2084,6 +2088,9 @@ "label": "", "placeholder": "" }, + "name": { + "label": "" + }, "versionFilter": { "label": "", "prefix": { @@ -2130,7 +2137,13 @@ "openProjectPage": "", "openReleasePage": "", "releaseDescription": "", - "created": "" + "created": "", + "error": { + "label": "", + "options": { + "noMatchingVersion": "" + } + } }, "networkControllerSummary": { "option": {}, diff --git a/packages/translation/src/lang/lv.json b/packages/translation/src/lang/lv.json index 4a15babdc..ea94cfcd8 100644 --- a/packages/translation/src/lang/lv.json +++ b/packages/translation/src/lang/lv.json @@ -2072,6 +2072,10 @@ "showDetails": { "label": "" }, + "topReleases": { + "label": "", + "description": "" + }, "repositories": { "label": "", "addRRepository": { @@ -2084,6 +2088,9 @@ "label": "", "placeholder": "" }, + "name": { + "label": "" + }, "versionFilter": { "label": "", "prefix": { @@ -2130,7 +2137,13 @@ "openProjectPage": "", "openReleasePage": "", "releaseDescription": "", - "created": "" + "created": "", + "error": { + "label": "", + "options": { + "noMatchingVersion": "" + } + } }, "networkControllerSummary": { "option": {}, diff --git a/packages/translation/src/lang/nl.json b/packages/translation/src/lang/nl.json index 3c4fa3edd..8846a4414 100644 --- a/packages/translation/src/lang/nl.json +++ b/packages/translation/src/lang/nl.json @@ -2072,6 +2072,10 @@ "showDetails": { "label": "" }, + "topReleases": { + "label": "", + "description": "" + }, "repositories": { "label": "", "addRRepository": { @@ -2084,6 +2088,9 @@ "label": "", "placeholder": "" }, + "name": { + "label": "" + }, "versionFilter": { "label": "", "prefix": { @@ -2130,7 +2137,13 @@ "openProjectPage": "", "openReleasePage": "", "releaseDescription": "", - "created": "" + "created": "", + "error": { + "label": "", + "options": { + "noMatchingVersion": "" + } + } }, "networkControllerSummary": { "option": {}, diff --git a/packages/translation/src/lang/no.json b/packages/translation/src/lang/no.json index 303c9ddd1..60ff8e0e6 100644 --- a/packages/translation/src/lang/no.json +++ b/packages/translation/src/lang/no.json @@ -2072,6 +2072,10 @@ "showDetails": { "label": "" }, + "topReleases": { + "label": "", + "description": "" + }, "repositories": { "label": "", "addRRepository": { @@ -2084,6 +2088,9 @@ "label": "", "placeholder": "" }, + "name": { + "label": "" + }, "versionFilter": { "label": "", "prefix": { @@ -2130,7 +2137,13 @@ "openProjectPage": "", "openReleasePage": "", "releaseDescription": "", - "created": "" + "created": "", + "error": { + "label": "", + "options": { + "noMatchingVersion": "" + } + } }, "networkControllerSummary": { "option": {}, diff --git a/packages/translation/src/lang/pl.json b/packages/translation/src/lang/pl.json index 8c9c742ec..dfb00ae09 100644 --- a/packages/translation/src/lang/pl.json +++ b/packages/translation/src/lang/pl.json @@ -2072,6 +2072,10 @@ "showDetails": { "label": "" }, + "topReleases": { + "label": "", + "description": "" + }, "repositories": { "label": "", "addRRepository": { @@ -2084,6 +2088,9 @@ "label": "", "placeholder": "" }, + "name": { + "label": "" + }, "versionFilter": { "label": "", "prefix": { @@ -2130,7 +2137,13 @@ "openProjectPage": "", "openReleasePage": "", "releaseDescription": "", - "created": "" + "created": "", + "error": { + "label": "", + "options": { + "noMatchingVersion": "" + } + } }, "networkControllerSummary": { "option": {}, diff --git a/packages/translation/src/lang/pt.json b/packages/translation/src/lang/pt.json index 942ebba58..ca5533d69 100644 --- a/packages/translation/src/lang/pt.json +++ b/packages/translation/src/lang/pt.json @@ -2072,6 +2072,10 @@ "showDetails": { "label": "" }, + "topReleases": { + "label": "", + "description": "" + }, "repositories": { "label": "", "addRRepository": { @@ -2084,6 +2088,9 @@ "label": "", "placeholder": "" }, + "name": { + "label": "" + }, "versionFilter": { "label": "", "prefix": { @@ -2130,7 +2137,13 @@ "openProjectPage": "", "openReleasePage": "", "releaseDescription": "", - "created": "" + "created": "", + "error": { + "label": "", + "options": { + "noMatchingVersion": "" + } + } }, "networkControllerSummary": { "option": {}, diff --git a/packages/translation/src/lang/ro.json b/packages/translation/src/lang/ro.json index a740be322..307362251 100644 --- a/packages/translation/src/lang/ro.json +++ b/packages/translation/src/lang/ro.json @@ -2072,6 +2072,10 @@ "showDetails": { "label": "" }, + "topReleases": { + "label": "", + "description": "" + }, "repositories": { "label": "", "addRRepository": { @@ -2084,6 +2088,9 @@ "label": "", "placeholder": "" }, + "name": { + "label": "" + }, "versionFilter": { "label": "", "prefix": { @@ -2130,7 +2137,13 @@ "openProjectPage": "", "openReleasePage": "", "releaseDescription": "", - "created": "" + "created": "", + "error": { + "label": "", + "options": { + "noMatchingVersion": "" + } + } }, "networkControllerSummary": { "option": {}, diff --git a/packages/translation/src/lang/ru.json b/packages/translation/src/lang/ru.json index 23200adca..1419eddce 100644 --- a/packages/translation/src/lang/ru.json +++ b/packages/translation/src/lang/ru.json @@ -2072,6 +2072,10 @@ "showDetails": { "label": "" }, + "topReleases": { + "label": "", + "description": "" + }, "repositories": { "label": "", "addRRepository": { @@ -2084,6 +2088,9 @@ "label": "", "placeholder": "" }, + "name": { + "label": "" + }, "versionFilter": { "label": "", "prefix": { @@ -2130,7 +2137,13 @@ "openProjectPage": "", "openReleasePage": "", "releaseDescription": "", - "created": "" + "created": "", + "error": { + "label": "", + "options": { + "noMatchingVersion": "" + } + } }, "networkControllerSummary": { "option": {}, diff --git a/packages/translation/src/lang/sk.json b/packages/translation/src/lang/sk.json index b5b516216..53d2f2cbf 100644 --- a/packages/translation/src/lang/sk.json +++ b/packages/translation/src/lang/sk.json @@ -2072,6 +2072,10 @@ "showDetails": { "label": "" }, + "topReleases": { + "label": "", + "description": "" + }, "repositories": { "label": "", "addRRepository": { @@ -2084,6 +2088,9 @@ "label": "", "placeholder": "" }, + "name": { + "label": "" + }, "versionFilter": { "label": "", "prefix": { @@ -2130,7 +2137,13 @@ "openProjectPage": "", "openReleasePage": "", "releaseDescription": "", - "created": "" + "created": "", + "error": { + "label": "", + "options": { + "noMatchingVersion": "" + } + } }, "networkControllerSummary": { "option": {}, diff --git a/packages/translation/src/lang/sl.json b/packages/translation/src/lang/sl.json index bde793786..c4b6d31b9 100644 --- a/packages/translation/src/lang/sl.json +++ b/packages/translation/src/lang/sl.json @@ -2072,6 +2072,10 @@ "showDetails": { "label": "" }, + "topReleases": { + "label": "", + "description": "" + }, "repositories": { "label": "", "addRRepository": { @@ -2084,6 +2088,9 @@ "label": "", "placeholder": "" }, + "name": { + "label": "" + }, "versionFilter": { "label": "", "prefix": { @@ -2130,7 +2137,13 @@ "openProjectPage": "", "openReleasePage": "", "releaseDescription": "", - "created": "" + "created": "", + "error": { + "label": "", + "options": { + "noMatchingVersion": "" + } + } }, "networkControllerSummary": { "option": {}, diff --git a/packages/translation/src/lang/sv.json b/packages/translation/src/lang/sv.json index 0d7342420..aaf4b5497 100644 --- a/packages/translation/src/lang/sv.json +++ b/packages/translation/src/lang/sv.json @@ -2072,6 +2072,10 @@ "showDetails": { "label": "" }, + "topReleases": { + "label": "", + "description": "" + }, "repositories": { "label": "", "addRRepository": { @@ -2084,6 +2088,9 @@ "label": "", "placeholder": "" }, + "name": { + "label": "" + }, "versionFilter": { "label": "", "prefix": { @@ -2130,7 +2137,13 @@ "openProjectPage": "", "openReleasePage": "", "releaseDescription": "", - "created": "" + "created": "", + "error": { + "label": "", + "options": { + "noMatchingVersion": "" + } + } }, "networkControllerSummary": { "option": {}, diff --git a/packages/translation/src/lang/tr.json b/packages/translation/src/lang/tr.json index 962152627..a4772e931 100644 --- a/packages/translation/src/lang/tr.json +++ b/packages/translation/src/lang/tr.json @@ -2072,6 +2072,10 @@ "showDetails": { "label": "Ayrıntıları Göster" }, + "topReleases": { + "label": "En İyi Sürümler", + "description": "Gösterilecek en son sürümlerin maksimum sayısı. Sıfır, sınır olmadığı anlamına gelir." + }, "repositories": { "label": "Depolar", "addRRepository": { @@ -2084,6 +2088,9 @@ "label": "Tanımlayıcı", "placeholder": "Ad veya Sahip/Ad" }, + "name": { + "label": "İsim" + }, "versionFilter": { "label": "Sürüm Filtresi", "prefix": { @@ -2130,7 +2137,13 @@ "openProjectPage": "Proje Sayfasını Aç", "openReleasePage": "Sürüm Sayfasını Aç", "releaseDescription": "Sürüm Açıklaması", - "created": "Oluşturuldu" + "created": "Oluşturuldu", + "error": { + "label": "Hata", + "options": { + "noMatchingVersion": "Eşleşen sürüm bulunamadı" + } + } }, "networkControllerSummary": { "option": {}, diff --git a/packages/translation/src/lang/uk.json b/packages/translation/src/lang/uk.json index 1e904af56..2215c75de 100644 --- a/packages/translation/src/lang/uk.json +++ b/packages/translation/src/lang/uk.json @@ -2072,6 +2072,10 @@ "showDetails": { "label": "" }, + "topReleases": { + "label": "", + "description": "" + }, "repositories": { "label": "", "addRRepository": { @@ -2084,6 +2088,9 @@ "label": "", "placeholder": "" }, + "name": { + "label": "" + }, "versionFilter": { "label": "", "prefix": { @@ -2130,7 +2137,13 @@ "openProjectPage": "", "openReleasePage": "", "releaseDescription": "", - "created": "" + "created": "", + "error": { + "label": "", + "options": { + "noMatchingVersion": "" + } + } }, "networkControllerSummary": { "option": {}, diff --git a/packages/translation/src/lang/vi.json b/packages/translation/src/lang/vi.json index b349e18da..f4583545b 100644 --- a/packages/translation/src/lang/vi.json +++ b/packages/translation/src/lang/vi.json @@ -2072,6 +2072,10 @@ "showDetails": { "label": "" }, + "topReleases": { + "label": "", + "description": "" + }, "repositories": { "label": "", "addRRepository": { @@ -2084,6 +2088,9 @@ "label": "", "placeholder": "" }, + "name": { + "label": "" + }, "versionFilter": { "label": "", "prefix": { @@ -2130,7 +2137,13 @@ "openProjectPage": "", "openReleasePage": "", "releaseDescription": "", - "created": "" + "created": "", + "error": { + "label": "", + "options": { + "noMatchingVersion": "" + } + } }, "networkControllerSummary": { "option": {}, diff --git a/packages/translation/src/lang/zh.json b/packages/translation/src/lang/zh.json index 94e10751c..b9f6006ae 100644 --- a/packages/translation/src/lang/zh.json +++ b/packages/translation/src/lang/zh.json @@ -2072,6 +2072,10 @@ "showDetails": { "label": "顯示詳情" }, + "topReleases": { + "label": "", + "description": "" + }, "repositories": { "label": "儲存庫", "addRRepository": { @@ -2084,6 +2088,9 @@ "label": "識別碼", "placeholder": "名稱或擁有者/名稱" }, + "name": { + "label": "" + }, "versionFilter": { "label": "版本篩選", "prefix": { @@ -2130,7 +2137,13 @@ "openProjectPage": "開啟專案頁面", "openReleasePage": "開啟發布葉面", "releaseDescription": "版本描述", - "created": "已創建" + "created": "已創建", + "error": { + "label": "錯誤", + "options": { + "noMatchingVersion": "找不到匹配的版本" + } + } }, "networkControllerSummary": { "option": {}, diff --git a/packages/ui/package.json b/packages/ui/package.json index 4754050c3..4ea2b5385 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -29,9 +29,9 @@ "@homarr/log": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0", - "@mantine/core": "^8.0.0", - "@mantine/dates": "^8.0.0", - "@mantine/hooks": "^8.0.0", + "@mantine/core": "^8.0.1", + "@mantine/dates": "^8.0.1", + "@mantine/hooks": "^8.0.1", "@tabler/icons-react": "^3.31.0", "mantine-react-table": "2.0.0-beta.9", "next": "15.3.2", diff --git a/packages/widgets/package.json b/packages/widgets/package.json index cd74af873..f34bc0bb8 100644 --- a/packages/widgets/package.json +++ b/packages/widgets/package.json @@ -47,9 +47,9 @@ "@homarr/translation": "workspace:^0.1.0", "@homarr/ui": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0", - "@mantine/charts": "^8.0.0", - "@mantine/core": "^8.0.0", - "@mantine/hooks": "^8.0.0", + "@mantine/charts": "^8.0.1", + "@mantine/core": "^8.0.1", + "@mantine/hooks": "^8.0.1", "@tabler/icons-react": "^3.31.0", "@tiptap/extension-color": "2.12.0", "@tiptap/extension-highlight": "2.12.0", diff --git a/packages/widgets/src/_inputs/widget-multiReleasesRepositories-input.tsx b/packages/widgets/src/_inputs/widget-multiReleasesRepositories-input.tsx index 466001e0c..24313c67e 100644 --- a/packages/widgets/src/_inputs/widget-multiReleasesRepositories-input.tsx +++ b/packages/widgets/src/_inputs/widget-multiReleasesRepositories-input.tsx @@ -1,12 +1,14 @@ "use client"; -import React, { useCallback, useMemo, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; import { ActionIcon, Button, Divider, Fieldset, Group, Select, Stack, Text, TextInput } from "@mantine/core"; import type { FormErrors } from "@mantine/form"; +import { useDebouncedValue } from "@mantine/hooks"; import { IconEdit, IconTrash, IconTriangleFilled } from "@tabler/icons-react"; import { escapeForRegEx } from "@tiptap/react"; -import { IconPicker } from "@homarr/forms-collection"; +import { clientApi } from "@homarr/api/client"; +import { findBestIconMatch, IconPicker } from "@homarr/forms-collection"; import { createModal, useModalAction } from "@homarr/modals"; import { useScopedI18n } from "@homarr/translation/client"; import { MaskedOrNormalImage } from "@homarr/ui"; @@ -40,6 +42,7 @@ export const WidgetMultiReleasesRepositoriesInput = ({ (repository: ReleasesRepository, index: number): FormValidation => { form.setFieldValue(`options.${property}.${index}.providerKey`, repository.providerKey); form.setFieldValue(`options.${property}.${index}.identifier`, repository.identifier); + form.setFieldValue(`options.${property}.${index}.name`, repository.name); form.setFieldValue(`options.${property}.${index}.versionFilter`, repository.versionFilter); form.setFieldValue(`options.${property}.${index}.iconUrl`, repository.iconUrl); @@ -82,11 +85,12 @@ export const WidgetMultiReleasesRepositoriesInput = ({ fieldPath: `options.${property}.${index}`, repository: item, onRepositorySave: (saved) => onRepositorySave(saved, index), + onRepositoryCancel: () => onRepositoryRemove(index), versionFilterPrecisionOptions, }); }; - const onReleaseRemove = (index: number) => { + const onRepositoryRemove = (index: number) => { form.setValues((previous) => { const previousValues = previous.options?.[property] as ReleasesRepository[]; return { @@ -98,6 +102,7 @@ export const WidgetMultiReleasesRepositoriesInput = ({ }; }); }; + return (
@@ -123,11 +128,8 @@ export const WidgetMultiReleasesRepositoriesInput = ({ - {repository.identifier} - - - - {formatVersionFilterRegex(repository.versionFilter) ?? ""} + {/* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing */} + {repository.name || repository.identifier} @@ -147,7 +149,7 @@ export const WidgetMultiReleasesRepositoriesInput = ({ {tRepository("edit.label")} - onReleaseRemove(index)}> + onRepositoryRemove(index)}> @@ -178,10 +180,16 @@ const formatVersionFilterRegex = (versionFilter: ReleasesVersionFilter | undefin return `^${escapedPrefix}${precision}${escapedSuffix}$`; }; +const formatIdentifierName = (identifier: string) => { + const unformattedName = identifier.split("/").pop(); + return unformattedName?.replace(/[-_]/g, " ").replace(/(?:^\w|[A-Z]|\b\w)/g, (char) => char.toUpperCase()) ?? ""; +}; + interface ReleaseEditProps { fieldPath: string; repository: ReleasesRepository; onRepositorySave: (repository: ReleasesRepository) => FormValidation; + onRepositoryCancel?: () => void; versionFilterPrecisionOptions: string[]; } @@ -191,6 +199,13 @@ const ReleaseEditModal = createModal(({ innerProps, actions }) const [tempRepository, setTempRepository] = useState(() => ({ ...innerProps.repository })); const [formErrors, setFormErrors] = useState({}); + // Allows user to not select an icon by removing the url from the input, + // will only try and get an icon if the name or identifier changes + const [autoSetIcon, setAutoSetIcon] = useState(false); + + // Debounce the name value with 200ms delay + const [debouncedName] = useDebouncedValue(tempRepository.name, 800); + const handleConfirm = useCallback(() => { setLoading(true); @@ -203,13 +218,40 @@ const ReleaseEditModal = createModal(({ innerProps, actions }) setLoading(false); }, [innerProps, tempRepository, actions]); + const handleCancel = useCallback(() => { + if (innerProps.onRepositoryCancel) { + innerProps.onRepositoryCancel(); + } + + actions.closeModal(); + }, [innerProps, actions]); + const handleChange = useCallback((changedValue: Partial) => { setTempRepository((prev) => ({ ...prev, ...changedValue })); }, []); + // Auto-select icon based on identifier formatted name with debounced search + const { data: iconsData } = clientApi.icon.findIcons.useQuery( + { + searchText: debouncedName, + }, + { + enabled: autoSetIcon && (debouncedName?.length ?? 0) > 3, + }, + ); + + useEffect(() => { + if (autoSetIcon && debouncedName && !tempRepository.iconUrl && iconsData?.icons) { + const bestMatch = findBestIconMatch(debouncedName, iconsData.icons); + if (bestMatch) { + handleChange({ iconUrl: bestMatch }); + } + } + }, [debouncedName, iconsData, tempRepository, handleChange, autoSetIcon]); + return ( - +