feat(certificates): handle self signed certificates (#1951)
* wip: add page and loading of certificates in folder * wip: add certificate addition and removal * feat: add removal ui for certificates * feat: migrate integrations to fetch or agent with trusted certificates * fix: lock file issues * fix: typecheck issue * fix: inconsistent package versions * chore: address pull request feedback * fix: add missing navigation item and restrict access to page * chore: address pull request feedback * fix: inconsistent undici dependency version * fix: inconsistent undici dependency version
This commit is contained in:
@@ -28,6 +28,10 @@ DB_URL='FULL_PATH_TO_YOUR_SQLITE_DB_FILE'
|
||||
# DB_PASSWORD='password'
|
||||
# DB_NAME='name-of-database'
|
||||
|
||||
# The below path can be used to store trusted certificates during development, it is not required and can be left empty.
|
||||
# If it is used, please use the full path to the directory where the certificates are stored.
|
||||
# LOCAL_CERTIFICATE_PATH='FULL_PATH_TO_CERTIFICATES'
|
||||
|
||||
TURBO_TELEMETRY_DISABLED=1
|
||||
|
||||
# Configure logging to use winston logger
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -64,4 +64,4 @@ packages/cli/cli.cjs
|
||||
e2e/shared/tmp
|
||||
|
||||
#personal backgrounds
|
||||
apps/nextjs/public/images/background.png
|
||||
apps/nextjs/public/images/background.png
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"@homarr/analytics": "workspace:^0.1.0",
|
||||
"@homarr/api": "workspace:^0.1.0",
|
||||
"@homarr/auth": "workspace:^0.1.0",
|
||||
"@homarr/certificates": "workspace:^0.1.0",
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/cron-job-status": "workspace:^0.1.0",
|
||||
"@homarr/db": "workspace:^0.1.0",
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
IconBrandDiscord,
|
||||
IconBrandDocker,
|
||||
IconBrandGithub,
|
||||
IconCertificate,
|
||||
IconGitFork,
|
||||
IconHome,
|
||||
IconInfoSmall,
|
||||
@@ -119,6 +120,12 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
href: "/manage/tools/logs",
|
||||
hidden: !session?.user.permissions.includes("other-view-logs"),
|
||||
},
|
||||
{
|
||||
label: t("items.tools.items.certificates"),
|
||||
icon: IconCertificate,
|
||||
href: "/manage/tools/certificates",
|
||||
hidden: !session?.user.permissions.includes("admin"),
|
||||
},
|
||||
{
|
||||
label: t("items.tools.items.tasks"),
|
||||
icon: IconReport,
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import { Button, FileInput, Group, Stack } from "@mantine/core";
|
||||
import { IconCertificate } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { createModal, useModalAction } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { superRefineCertificateFile, z } from "@homarr/validation";
|
||||
|
||||
export const AddCertificateButton = () => {
|
||||
const { openModal } = useModalAction(AddCertificateModal);
|
||||
const t = useI18n();
|
||||
|
||||
const handleClick = () => {
|
||||
openModal({});
|
||||
};
|
||||
|
||||
return <Button onClick={handleClick}>{t("certificate.action.create.label")}</Button>;
|
||||
};
|
||||
|
||||
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 (
|
||||
<form
|
||||
onSubmit={form.onSubmit(async (values) => {
|
||||
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"),
|
||||
});
|
||||
},
|
||||
});
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<FileInput leftSection={<IconCertificate size={16} />} {...form.getInputProps("file")} />
|
||||
<Group justify="end">
|
||||
<Button onClick={actions.closeModal} variant="subtle" color="gray">
|
||||
{t("common.action.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" loading={form.submitting}>
|
||||
{t("common.action.add")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
}).withOptions({
|
||||
defaultTitle(t) {
|
||||
return t("certificate.action.create.label");
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
"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 RemoveCertificateProps {
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
export const RemoveCertificate = ({ fileName }: RemoveCertificateProps) => {
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const { mutateAsync } = clientApi.certificates.removeCertificate.useMutation();
|
||||
const t = useI18n();
|
||||
|
||||
const handleClick = () => {
|
||||
openConfirmModal({
|
||||
title: t("certificate.action.remove.label"),
|
||||
children: t("certificate.action.remove.confirm"),
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
async onConfirm() {
|
||||
await mutateAsync(
|
||||
{ fileName },
|
||||
{
|
||||
async onSuccess() {
|
||||
showSuccessNotification({
|
||||
title: t("certificate.action.remove.notification.success.title"),
|
||||
message: t("certificate.action.remove.notification.success.message"),
|
||||
});
|
||||
await revalidatePathActionAsync("/manage/tools/certificates");
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: t("certificate.action.remove.notification.error.title"),
|
||||
message: t("certificate.action.remove.notification.error.message"),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<ActionIcon onClick={handleClick} color="red" variant="subtle">
|
||||
<IconTrash color="red" size={16} />
|
||||
</ActionIcon>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
import { X509Certificate } from "node:crypto";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Card, Group, SimpleGrid, Stack, Text, Title } from "@mantine/core";
|
||||
import { IconCertificate, IconCertificateOff } from "@tabler/icons-react";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { loadCustomRootCertificatesAsync } from "@homarr/certificates/server";
|
||||
import { getMantineColor } from "@homarr/common";
|
||||
import type { SupportedLanguage } from "@homarr/translation";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { NoResults } from "~/components/no-results";
|
||||
import { AddCertificateButton } from "./_components/add-certificate";
|
||||
import { RemoveCertificate } from "./_components/remove-certificate";
|
||||
|
||||
interface CertificatesPageProps {
|
||||
params: Promise<{
|
||||
locale: SupportedLanguage;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function CertificatesPage({ params }: CertificatesPageProps) {
|
||||
const session = await auth();
|
||||
if (!session?.user.permissions.includes("admin")) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { locale } = await params;
|
||||
const t = await getI18n();
|
||||
const certificates = await loadCustomRootCertificatesAsync();
|
||||
const x509Certificates = certificates
|
||||
.map((cert) => ({
|
||||
...cert,
|
||||
x509: new X509Certificate(cert.content),
|
||||
}))
|
||||
.sort((certA, certB) => certA.x509.validToDate.getTime() - certB.x509.validToDate.getTime());
|
||||
|
||||
return (
|
||||
<>
|
||||
<DynamicBreadcrumb />
|
||||
|
||||
<Stack>
|
||||
<Group justify="space-between">
|
||||
<Stack gap={4}>
|
||||
<Title>{t("certificate.page.list.title")}</Title>
|
||||
<Text>{t("certificate.page.list.description")}</Text>
|
||||
</Stack>
|
||||
|
||||
<AddCertificateButton />
|
||||
</Group>
|
||||
|
||||
{x509Certificates.length === 0 && (
|
||||
<NoResults icon={IconCertificateOff} title={t("certificate.page.list.noResults.title")} />
|
||||
)}
|
||||
|
||||
<SimpleGrid cols={{ sm: 1, lg: 2, xl: 3 }} spacing="lg">
|
||||
{x509Certificates.map((cert) => (
|
||||
<Card key={cert.x509.fingerprint} withBorder>
|
||||
<Group wrap="nowrap">
|
||||
<IconCertificate color={getMantineColor(iconColor(cert.x509.validToDate), 6)} size={32} stroke={1.5} />
|
||||
<Stack flex={1} gap="xs">
|
||||
<Group justify="space-between">
|
||||
<Text fw={500}>{cert.x509.subject}</Text>
|
||||
<Text c="gray.6" ta="end" size="sm">
|
||||
{cert.fileName}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="gray.6" title={cert.x509.validToDate.toISOString()}>
|
||||
{t("certificate.page.list.expires", {
|
||||
when: new Intl.RelativeTimeFormat(locale).format(
|
||||
dayjs(cert.x509.validToDate).diff(dayjs(), "days"),
|
||||
"days",
|
||||
),
|
||||
})}
|
||||
</Text>
|
||||
<RemoveCertificate fileName={cert.fileName} />
|
||||
</Group>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const iconColor = (validTo: Date) => {
|
||||
const daysUntilInvalid = dayjs(validTo).diff(new Date(), "days");
|
||||
if (daysUntilInvalid < 1) return "red";
|
||||
if (daysUntilInvalid < 7) return "orange";
|
||||
if (daysUntilInvalid < 30) return "yellow";
|
||||
return "green";
|
||||
};
|
||||
@@ -1,35 +1,6 @@
|
||||
import type { Dispatcher } from "undici";
|
||||
import { Agent, setGlobalDispatcher } from "undici";
|
||||
import { setGlobalDispatcher } from "undici";
|
||||
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
export class LoggingAgent extends Agent {
|
||||
constructor(...props: ConstructorParameters<typeof Agent>) {
|
||||
super(...props);
|
||||
}
|
||||
|
||||
dispatch(options: Dispatcher.DispatchOptions, handler: Dispatcher.DispatchHandler): boolean {
|
||||
const url = new URL(`${options.origin as string}${options.path}`);
|
||||
|
||||
// The below code should prevent sensitive data from being logged as
|
||||
// some integrations use query parameters for auth
|
||||
url.searchParams.forEach((value, key) => {
|
||||
if (value === "") return; // Skip empty values
|
||||
if (/^-?\d{1,12}$/.test(value)) return; // Skip small numbers
|
||||
if (value === "true" || value === "false") return; // Skip boolean values
|
||||
if (/^[a-zA-Z]{1,12}$/.test(value)) return; // Skip short strings
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return; // Skip dates
|
||||
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value)) return; // Skip date times
|
||||
|
||||
url.searchParams.set(key, "REDACTED");
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`Dispatching request ${url.toString().replaceAll("=&", "&")} (${Object.keys(options.headers ?? {}).length} headers)`,
|
||||
);
|
||||
return super.dispatch(options, handler);
|
||||
}
|
||||
}
|
||||
import { LoggingAgent } from "@homarr/common/server";
|
||||
|
||||
const agent = new LoggingAgent();
|
||||
setGlobalDispatcher(agent);
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"prettier": "@homarr/prettier-config",
|
||||
"dependencies": {
|
||||
"@homarr/auth": "workspace:^0.1.0",
|
||||
"@homarr/certificates": "workspace:^0.1.0",
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/cron-job-runner": "workspace:^0.1.0",
|
||||
"@homarr/cron-job-status": "workspace:^0.1.0",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { apiKeysRouter } from "./router/apiKeys";
|
||||
import { appRouter as innerAppRouter } from "./router/app";
|
||||
import { boardRouter } from "./router/board";
|
||||
import { certificateRouter } from "./router/certificates/certificate-router";
|
||||
import { cronJobsRouter } from "./router/cron-jobs";
|
||||
import { dockerRouter } from "./router/docker/docker-router";
|
||||
import { groupRouter } from "./router/group";
|
||||
@@ -41,6 +42,7 @@ export const appRouter = createTRPCRouter({
|
||||
apiKeys: apiKeysRouter,
|
||||
media: mediaRouter,
|
||||
updateChecker: updateCheckerRouter,
|
||||
certificates: certificateRouter,
|
||||
});
|
||||
|
||||
// export type definition of API
|
||||
|
||||
27
packages/api/src/router/certificates/certificate-router.ts
Normal file
27
packages/api/src/router/certificates/certificate-router.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { z } from "zod";
|
||||
import { zfd } from "zod-form-data";
|
||||
|
||||
import { addCustomRootCertificateAsync, removeCustomRootCertificateAsync } from "@homarr/certificates/server";
|
||||
import { superRefineCertificateFile, validation } from "@homarr/validation";
|
||||
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from "../../trpc";
|
||||
|
||||
export const certificateRouter = createTRPCRouter({
|
||||
addCertificate: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(
|
||||
zfd.formData({
|
||||
file: zfd.file().superRefine(superRefineCertificateFile),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
const content = await input.file.text();
|
||||
await addCustomRootCertificateAsync(input.file.name, content);
|
||||
}),
|
||||
removeCertificate: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(z.object({ fileName: validation.certificates.validFileNameSchema }))
|
||||
.mutation(async ({ input }) => {
|
||||
await removeCustomRootCertificateAsync(input.fileName);
|
||||
}),
|
||||
});
|
||||
9
packages/certificates/eslint.config.js
Normal file
9
packages/certificates/eslint.config.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import baseConfig from "@homarr/eslint-config/base";
|
||||
|
||||
/** @type {import('typescript-eslint').Config} */
|
||||
export default [
|
||||
{
|
||||
ignores: [],
|
||||
},
|
||||
...baseConfig,
|
||||
];
|
||||
35
packages/certificates/package.json
Normal file
35
packages/certificates/package.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@homarr/certificates",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./server": "./src/server.ts"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"*": [
|
||||
"src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||
"lint": "eslint",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"prettier": "@homarr/prettier-config",
|
||||
"dependencies": {
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"undici": "7.2.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.18.0",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
87
packages/certificates/src/server.ts
Normal file
87
packages/certificates/src/server.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import fsSync from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import { Agent } from "node:https";
|
||||
import path from "node:path";
|
||||
import { rootCertificates } from "node:tls";
|
||||
import axios from "axios";
|
||||
import { fetch } from "undici";
|
||||
|
||||
import { LoggingAgent } from "@homarr/common/server";
|
||||
|
||||
const getCertificateFolder = () => {
|
||||
return process.env.NODE_ENV === "production"
|
||||
? path.join("/appdata", "trusted-certificates")
|
||||
: process.env.LOCAL_CERTIFICATE_PATH;
|
||||
};
|
||||
|
||||
export const loadCustomRootCertificatesAsync = async () => {
|
||||
const folder = getCertificateFolder();
|
||||
|
||||
if (!folder) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!fsSync.existsSync(folder)) {
|
||||
await fs.mkdir(folder, { recursive: true });
|
||||
}
|
||||
|
||||
const dirContent = await fs.readdir(folder);
|
||||
return await Promise.all(
|
||||
dirContent
|
||||
.filter((file) => file.endsWith(".crt"))
|
||||
.map(async (file) => ({
|
||||
content: await fs.readFile(path.join(folder, file), "utf8"),
|
||||
fileName: file,
|
||||
})),
|
||||
);
|
||||
};
|
||||
|
||||
export const removeCustomRootCertificateAsync = async (fileName: string) => {
|
||||
const folder = getCertificateFolder();
|
||||
if (!folder) {
|
||||
return;
|
||||
}
|
||||
|
||||
await fs.rm(path.join(folder, fileName));
|
||||
};
|
||||
|
||||
export const addCustomRootCertificateAsync = async (fileName: string, content: string) => {
|
||||
const folder = getCertificateFolder();
|
||||
if (!folder) {
|
||||
throw new Error(
|
||||
"When you want to use custom certificates locally you need to set LOCAL_CERTIFICATE_PATH to an absolute path",
|
||||
);
|
||||
}
|
||||
|
||||
if (fileName.includes("/")) {
|
||||
throw new Error("Invalid file name");
|
||||
}
|
||||
|
||||
await fs.writeFile(path.join(folder, fileName), content);
|
||||
};
|
||||
|
||||
export const createCertificateAgentAsync = async () => {
|
||||
const customCertificates = await loadCustomRootCertificatesAsync();
|
||||
return new LoggingAgent({
|
||||
connect: {
|
||||
ca: rootCertificates.concat(customCertificates.map((cert) => cert.content)),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const createAxiosCertificateInstanceAsync = async () => {
|
||||
const customCertificates = await loadCustomRootCertificatesAsync();
|
||||
return axios.create({
|
||||
httpsAgent: new Agent({
|
||||
ca: rootCertificates.concat(customCertificates.map((cert) => cert.content)),
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
export const fetchWithTrustedCertificatesAsync: typeof fetch = async (url, options) => {
|
||||
const agent = await createCertificateAgentAsync();
|
||||
return fetch(url, {
|
||||
...options,
|
||||
dispatcher: agent,
|
||||
});
|
||||
};
|
||||
8
packages/certificates/tsconfig.json
Normal file
8
packages/certificates/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@homarr/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": ["*.ts", "src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -32,6 +32,7 @@
|
||||
"next": "15.1.4",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"undici": "7.2.3",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
32
packages/common/src/fetch-agent.ts
Normal file
32
packages/common/src/fetch-agent.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { Dispatcher } from "undici";
|
||||
import { Agent } from "undici";
|
||||
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
export class LoggingAgent extends Agent {
|
||||
constructor(...props: ConstructorParameters<typeof Agent>) {
|
||||
super(...props);
|
||||
}
|
||||
|
||||
dispatch(options: Dispatcher.DispatchOptions, handler: Dispatcher.DispatchHandler): boolean {
|
||||
const url = new URL(`${options.origin as string}${options.path}`);
|
||||
|
||||
// The below code should prevent sensitive data from being logged as
|
||||
// some integrations use query parameters for auth
|
||||
url.searchParams.forEach((value, key) => {
|
||||
if (value === "") return; // Skip empty values
|
||||
if (/^-?\d{1,12}$/.test(value)) return; // Skip small numbers
|
||||
if (value === "true" || value === "false") return; // Skip boolean values
|
||||
if (/^[a-zA-Z]{1,12}$/.test(value)) return; // Skip short strings
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return; // Skip dates
|
||||
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value)) return; // Skip date times
|
||||
|
||||
url.searchParams.set(key, "REDACTED");
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`Dispatching request ${url.toString().replaceAll("=&", "&")} (${Object.keys(options.headers ?? {}).length} headers)`,
|
||||
);
|
||||
return super.dispatch(options, handler);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./security";
|
||||
export * from "./encryption";
|
||||
export * from "./user-agent";
|
||||
export * from "./fetch-agent";
|
||||
|
||||
@@ -3,7 +3,7 @@ import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
import { LoggingAgent } from "~/undici-log-agent-override";
|
||||
import { LoggingAgent } from "../fetch-agent";
|
||||
|
||||
vi.mock("undici", () => {
|
||||
return {
|
||||
@@ -27,6 +27,7 @@
|
||||
"@ctrl/deluge": "^7.1.0",
|
||||
"@ctrl/qbittorrent": "^9.2.0",
|
||||
"@ctrl/transmission": "^7.2.0",
|
||||
"@homarr/certificates": "workspace:^0.1.0",
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/db": "workspace:^0.1.0",
|
||||
"@homarr/definitions": "workspace:^0.1.0",
|
||||
@@ -35,6 +36,7 @@
|
||||
"@homarr/translation": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@jellyfin/sdk": "^0.11.0",
|
||||
"undici": "7.2.3",
|
||||
"xml2js": "^0.6.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
|
||||
|
||||
import { Integration } from "../base/integration";
|
||||
import { IntegrationTestConnectionError } from "../base/test-connection-error";
|
||||
import type { DnsHoleSummaryIntegration } from "../interfaces/dns-hole-summary/dns-hole-summary-integration";
|
||||
@@ -6,7 +8,7 @@ import { filteringStatusSchema, statsResponseSchema, statusResponseSchema } from
|
||||
|
||||
export class AdGuardHomeIntegration extends Integration implements DnsHoleSummaryIntegration {
|
||||
public async getSummaryAsync(): Promise<DnsHoleSummary> {
|
||||
const statsResponse = await fetch(this.url("/control/stats"), {
|
||||
const statsResponse = await fetchWithTrustedCertificatesAsync(this.url("/control/stats"), {
|
||||
headers: {
|
||||
Authorization: `Basic ${this.getAuthorizationHeaderValue()}`,
|
||||
},
|
||||
@@ -18,7 +20,7 @@ export class AdGuardHomeIntegration extends Integration implements DnsHoleSummar
|
||||
);
|
||||
}
|
||||
|
||||
const statusResponse = await fetch(this.url("/control/status"), {
|
||||
const statusResponse = await fetchWithTrustedCertificatesAsync(this.url("/control/status"), {
|
||||
headers: {
|
||||
Authorization: `Basic ${this.getAuthorizationHeaderValue()}`,
|
||||
},
|
||||
@@ -30,7 +32,7 @@ export class AdGuardHomeIntegration extends Integration implements DnsHoleSummar
|
||||
);
|
||||
}
|
||||
|
||||
const filteringStatusResponse = await fetch(this.url("/control/filtering/status"), {
|
||||
const filteringStatusResponse = await fetchWithTrustedCertificatesAsync(this.url("/control/filtering/status"), {
|
||||
headers: {
|
||||
Authorization: `Basic ${this.getAuthorizationHeaderValue()}`,
|
||||
},
|
||||
@@ -86,7 +88,7 @@ export class AdGuardHomeIntegration extends Integration implements DnsHoleSummar
|
||||
public async testConnectionAsync(): Promise<void> {
|
||||
await super.handleTestConnectionResponseAsync({
|
||||
queryFunctionAsync: async () => {
|
||||
return await fetch(this.url("/control/status"), {
|
||||
return await fetchWithTrustedCertificatesAsync(this.url("/control/status"), {
|
||||
headers: {
|
||||
Authorization: `Basic ${this.getAuthorizationHeaderValue()}`,
|
||||
},
|
||||
@@ -94,7 +96,7 @@ export class AdGuardHomeIntegration extends Integration implements DnsHoleSummar
|
||||
},
|
||||
handleResponseAsync: async (response) => {
|
||||
try {
|
||||
const result = (await response.json()) as unknown;
|
||||
const result = await response.json();
|
||||
if (typeof result === "object" && result !== null) return;
|
||||
} catch {
|
||||
throw new IntegrationTestConnectionError("invalidJson");
|
||||
@@ -106,7 +108,7 @@ export class AdGuardHomeIntegration extends Integration implements DnsHoleSummar
|
||||
}
|
||||
|
||||
public async enableAsync(): Promise<void> {
|
||||
const response = await fetch(this.url("/control/protection"), {
|
||||
const response = await fetchWithTrustedCertificatesAsync(this.url("/control/protection"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -124,7 +126,7 @@ export class AdGuardHomeIntegration extends Integration implements DnsHoleSummar
|
||||
}
|
||||
|
||||
public async disableAsync(duration = 0): Promise<void> {
|
||||
const response = await fetch(this.url("/control/protection"), {
|
||||
const response = await fetchWithTrustedCertificatesAsync(this.url("/control/protection"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { Response } from "undici";
|
||||
|
||||
import { extractErrorMessage, removeTrailingSlash } from "@homarr/common";
|
||||
import type { IntegrationSecretKind } from "@homarr/definitions";
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
@@ -4,6 +4,7 @@ import "@homarr/redis";
|
||||
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
|
||||
import { z } from "@homarr/validation";
|
||||
|
||||
import { createChannelEventHistory } from "../../../redis/src/lib/channel";
|
||||
@@ -12,7 +13,7 @@ import type { HealthMonitoring } from "../types";
|
||||
|
||||
export class DashDotIntegration extends Integration {
|
||||
public async testConnectionAsync(): Promise<void> {
|
||||
const response = await fetch(this.url("/info"));
|
||||
const response = await fetchWithTrustedCertificatesAsync(this.url("/info"));
|
||||
await response.json();
|
||||
}
|
||||
|
||||
@@ -52,7 +53,7 @@ export class DashDotIntegration extends Integration {
|
||||
}
|
||||
|
||||
private async getInfoAsync() {
|
||||
const infoResponse = await fetch(this.url("/info"));
|
||||
const infoResponse = await fetchWithTrustedCertificatesAsync(this.url("/info"));
|
||||
const serverInfo = await internalServerInfoApi.parseAsync(await infoResponse.json());
|
||||
return {
|
||||
maxAvailableMemoryBytes: serverInfo.ram.size,
|
||||
@@ -66,7 +67,7 @@ export class DashDotIntegration extends Integration {
|
||||
|
||||
private async getCurrentCpuLoadAsync() {
|
||||
const channel = this.getChannel();
|
||||
const cpu = await fetch(this.url("/load/cpu"));
|
||||
const cpu = await fetchWithTrustedCertificatesAsync(this.url("/load/cpu"));
|
||||
const data = await cpuLoadPerCoreApiList.parseAsync(await cpu.json());
|
||||
await channel.pushAsync(data);
|
||||
return {
|
||||
@@ -88,12 +89,12 @@ export class DashDotIntegration extends Integration {
|
||||
}
|
||||
|
||||
private async getCurrentStorageLoadAsync() {
|
||||
const storageLoad = await fetch(this.url("/load/storage"));
|
||||
const storageLoad = await fetchWithTrustedCertificatesAsync(this.url("/load/storage"));
|
||||
return (await storageLoad.json()) as number[];
|
||||
}
|
||||
|
||||
private async getCurrentMemoryLoadAsync() {
|
||||
const memoryLoad = await fetch(this.url("/load/ram"));
|
||||
const memoryLoad = await fetchWithTrustedCertificatesAsync(this.url("/load/ram"));
|
||||
const data = await memoryLoadApi.parseAsync(await memoryLoad.json());
|
||||
return {
|
||||
loadInBytes: data.load,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Deluge } from "@ctrl/deluge";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { createCertificateAgentAsync } from "@homarr/certificates/server";
|
||||
|
||||
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";
|
||||
@@ -8,13 +10,13 @@ import type { DownloadClientStatus } from "../../interfaces/downloads/download-c
|
||||
|
||||
export class DelugeIntegration extends DownloadClientIntegration {
|
||||
public async testConnectionAsync(): Promise<void> {
|
||||
const client = this.getClient();
|
||||
const client = await this.getClientAsync();
|
||||
await client.login();
|
||||
}
|
||||
|
||||
public async getClientJobsAndStatusAsync(): Promise<DownloadClientJobsAndStatus> {
|
||||
const type = "torrent";
|
||||
const client = this.getClient();
|
||||
const client = await this.getClientAsync();
|
||||
const {
|
||||
stats: { download_rate, upload_rate },
|
||||
torrents: rawTorrents,
|
||||
@@ -57,7 +59,7 @@ export class DelugeIntegration extends DownloadClientIntegration {
|
||||
}
|
||||
|
||||
public async pauseQueueAsync() {
|
||||
const client = this.getClient();
|
||||
const client = await this.getClientAsync();
|
||||
const store = (await client.listTorrents()).result.torrents;
|
||||
await Promise.all(
|
||||
Object.entries(store).map(async ([id]) => {
|
||||
@@ -67,11 +69,12 @@ export class DelugeIntegration extends DownloadClientIntegration {
|
||||
}
|
||||
|
||||
public async pauseItemAsync({ id }: DownloadClientItem): Promise<void> {
|
||||
await this.getClient().pauseTorrent(id);
|
||||
const client = await this.getClientAsync();
|
||||
await client.pauseTorrent(id);
|
||||
}
|
||||
|
||||
public async resumeQueueAsync() {
|
||||
const client = this.getClient();
|
||||
const client = await this.getClientAsync();
|
||||
const store = (await client.listTorrents()).result.torrents;
|
||||
await Promise.all(
|
||||
Object.entries(store).map(async ([id]) => {
|
||||
@@ -81,17 +84,20 @@ export class DelugeIntegration extends DownloadClientIntegration {
|
||||
}
|
||||
|
||||
public async resumeItemAsync({ id }: DownloadClientItem): Promise<void> {
|
||||
await this.getClient().resumeTorrent(id);
|
||||
const client = await this.getClientAsync();
|
||||
await client.resumeTorrent(id);
|
||||
}
|
||||
|
||||
public async deleteItemAsync({ id }: DownloadClientItem, fromDisk: boolean): Promise<void> {
|
||||
await this.getClient().removeTorrent(id, fromDisk);
|
||||
const client = await this.getClientAsync();
|
||||
await client.removeTorrent(id, fromDisk);
|
||||
}
|
||||
|
||||
private getClient() {
|
||||
private async getClientAsync() {
|
||||
return new Deluge({
|
||||
baseUrl: this.url("/").toString(),
|
||||
password: this.getSecretValue("password"),
|
||||
dispatcher: await createCertificateAgentAsync(),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
|
||||
|
||||
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";
|
||||
@@ -96,7 +98,7 @@ export class NzbGetIntegration extends DownloadClientIntegration {
|
||||
const password = this.getSecretValue("password");
|
||||
const url = this.url(`/${username}:${password}/jsonrpc`);
|
||||
const body = JSON.stringify({ method, params });
|
||||
return await fetch(url, { method: "POST", body })
|
||||
return await fetchWithTrustedCertificatesAsync(url, { method: "POST", body })
|
||||
.then(async (response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(response.statusText);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { QBittorrent } from "@ctrl/qbittorrent";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { createCertificateAgentAsync } from "@homarr/certificates/server";
|
||||
|
||||
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";
|
||||
@@ -8,13 +10,13 @@ import type { DownloadClientStatus } from "../../interfaces/downloads/download-c
|
||||
|
||||
export class QBitTorrentIntegration extends DownloadClientIntegration {
|
||||
public async testConnectionAsync(): Promise<void> {
|
||||
const client = this.getClient();
|
||||
const client = await this.getClientAsync();
|
||||
await client.login();
|
||||
}
|
||||
|
||||
public async getClientJobsAndStatusAsync(): Promise<DownloadClientJobsAndStatus> {
|
||||
const type = "torrent";
|
||||
const client = this.getClient();
|
||||
const client = await this.getClientAsync();
|
||||
const torrents = await client.listTorrents();
|
||||
const rates = torrents.reduce(
|
||||
({ down, up }, { dlspeed, upspeed }) => ({ down: down + dlspeed, up: up + upspeed }),
|
||||
@@ -50,30 +52,36 @@ export class QBitTorrentIntegration extends DownloadClientIntegration {
|
||||
}
|
||||
|
||||
public async pauseQueueAsync() {
|
||||
await this.getClient().pauseTorrent("all");
|
||||
const client = await this.getClientAsync();
|
||||
await client.pauseTorrent("all");
|
||||
}
|
||||
|
||||
public async pauseItemAsync({ id }: DownloadClientItem): Promise<void> {
|
||||
await this.getClient().pauseTorrent(id);
|
||||
const client = await this.getClientAsync();
|
||||
await client.pauseTorrent(id);
|
||||
}
|
||||
|
||||
public async resumeQueueAsync() {
|
||||
await this.getClient().resumeTorrent("all");
|
||||
const client = await this.getClientAsync();
|
||||
await client.resumeTorrent("all");
|
||||
}
|
||||
|
||||
public async resumeItemAsync({ id }: DownloadClientItem): Promise<void> {
|
||||
await this.getClient().resumeTorrent(id);
|
||||
const client = await this.getClientAsync();
|
||||
await client.resumeTorrent(id);
|
||||
}
|
||||
|
||||
public async deleteItemAsync({ id }: DownloadClientItem, fromDisk: boolean): Promise<void> {
|
||||
await this.getClient().removeTorrent(id, fromDisk);
|
||||
const client = await this.getClientAsync();
|
||||
await client.removeTorrent(id, fromDisk);
|
||||
}
|
||||
|
||||
private getClient() {
|
||||
private async getClientAsync() {
|
||||
return new QBittorrent({
|
||||
baseUrl: this.url("/").toString(),
|
||||
username: this.getSecretValue("username"),
|
||||
password: this.getSecretValue("password"),
|
||||
dispatcher: await createCertificateAgentAsync(),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import dayjs from "dayjs";
|
||||
import duration from "dayjs/plugin/duration";
|
||||
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
|
||||
|
||||
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";
|
||||
@@ -106,12 +108,12 @@ export class SabnzbdIntegration extends DownloadClientIntegration {
|
||||
apikey: this.getSecretValue("apiKey"),
|
||||
});
|
||||
|
||||
return await fetch(url)
|
||||
return await fetchWithTrustedCertificatesAsync(url)
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(response.statusText);
|
||||
}
|
||||
return response.json() as Promise<unknown>;
|
||||
return response.json();
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error instanceof Error) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Transmission } from "@ctrl/transmission";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { createCertificateAgentAsync } from "@homarr/certificates/server";
|
||||
|
||||
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";
|
||||
@@ -8,12 +10,13 @@ import type { DownloadClientStatus } from "../../interfaces/downloads/download-c
|
||||
|
||||
export class TransmissionIntegration extends DownloadClientIntegration {
|
||||
public async testConnectionAsync(): Promise<void> {
|
||||
await this.getClient().getSession();
|
||||
const client = await this.getClientAsync();
|
||||
await client.getSession();
|
||||
}
|
||||
|
||||
public async getClientJobsAndStatusAsync(): Promise<DownloadClientJobsAndStatus> {
|
||||
const type = "torrent";
|
||||
const client = this.getClient();
|
||||
const client = await this.getClientAsync();
|
||||
const { torrents } = (await client.listTorrents()).arguments;
|
||||
const rates = torrents.reduce(
|
||||
({ down, up }, { rateDownload, rateUpload }) => ({ down: down + rateDownload, up: up + rateUpload }),
|
||||
@@ -47,34 +50,38 @@ export class TransmissionIntegration extends DownloadClientIntegration {
|
||||
}
|
||||
|
||||
public async pauseQueueAsync() {
|
||||
const client = this.getClient();
|
||||
const client = await this.getClientAsync();
|
||||
const ids = (await client.listTorrents()).arguments.torrents.map(({ hashString }) => hashString);
|
||||
await this.getClient().pauseTorrent(ids);
|
||||
await client.pauseTorrent(ids);
|
||||
}
|
||||
|
||||
public async pauseItemAsync({ id }: DownloadClientItem): Promise<void> {
|
||||
await this.getClient().pauseTorrent(id);
|
||||
const client = await this.getClientAsync();
|
||||
await client.pauseTorrent(id);
|
||||
}
|
||||
|
||||
public async resumeQueueAsync() {
|
||||
const client = this.getClient();
|
||||
const client = await this.getClientAsync();
|
||||
const ids = (await client.listTorrents()).arguments.torrents.map(({ hashString }) => hashString);
|
||||
await this.getClient().resumeTorrent(ids);
|
||||
await client.resumeTorrent(ids);
|
||||
}
|
||||
|
||||
public async resumeItemAsync({ id }: DownloadClientItem): Promise<void> {
|
||||
await this.getClient().resumeTorrent(id);
|
||||
const client = await this.getClientAsync();
|
||||
await client.resumeTorrent(id);
|
||||
}
|
||||
|
||||
public async deleteItemAsync({ id }: DownloadClientItem, fromDisk: boolean): Promise<void> {
|
||||
await this.getClient().removeTorrent(id, fromDisk);
|
||||
const client = await this.getClientAsync();
|
||||
await client.removeTorrent(id, fromDisk);
|
||||
}
|
||||
|
||||
private getClient() {
|
||||
private async getClientAsync() {
|
||||
return new Transmission({
|
||||
baseUrl: this.url("/").toString(),
|
||||
username: this.getSecretValue("username"),
|
||||
password: this.getSecretValue("password"),
|
||||
dispatcher: await createCertificateAgentAsync(),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
import { Integration } from "../base/integration";
|
||||
@@ -7,7 +8,7 @@ export class HomeAssistantIntegration extends Integration {
|
||||
public async getEntityStateAsync(entityId: string) {
|
||||
try {
|
||||
const response = await this.getAsync(`/api/states/${entityId}`);
|
||||
const body = (await response.json()) as unknown;
|
||||
const body = await response.json();
|
||||
if (!response.ok) {
|
||||
logger.warn(`Response did not indicate success`);
|
||||
return {
|
||||
@@ -71,7 +72,7 @@ export class HomeAssistantIntegration extends Integration {
|
||||
* @returns the response from the API
|
||||
*/
|
||||
private async getAsync(path: `/api/${string}`) {
|
||||
return await fetch(this.url(path), {
|
||||
return await fetchWithTrustedCertificatesAsync(this.url(path), {
|
||||
headers: this.getAuthHeaders(),
|
||||
});
|
||||
}
|
||||
@@ -84,7 +85,7 @@ export class HomeAssistantIntegration extends Integration {
|
||||
* @returns the response from the API
|
||||
*/
|
||||
private async postAsync(path: `/api/${string}`, body: Record<string, string>) {
|
||||
return await fetch(this.url(path), {
|
||||
return await fetchWithTrustedCertificatesAsync(this.url(path), {
|
||||
headers: this.getAuthHeaders(),
|
||||
body: JSON.stringify(body),
|
||||
method: "POST",
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Jellyfin } from "@jellyfin/sdk";
|
||||
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
||||
import { getSystemApi } from "@jellyfin/sdk/lib/utils/api/system-api";
|
||||
|
||||
import { createAxiosCertificateInstanceAsync } from "@homarr/certificates/server";
|
||||
|
||||
import { Integration } from "../base/integration";
|
||||
import type { StreamSession } from "../interfaces/media-server/session";
|
||||
|
||||
@@ -65,12 +67,13 @@ export class JellyfinIntegration extends Integration {
|
||||
* @returns An instance of Api that has been authenticated
|
||||
*/
|
||||
private async getApiAsync() {
|
||||
const httpsAgent = await createAxiosCertificateInstanceAsync();
|
||||
if (this.hasSecretValue("apiKey")) {
|
||||
const apiKey = this.getSecretValue("apiKey");
|
||||
return this.jellyfin.createApi(this.url("/").toString(), apiKey);
|
||||
return this.jellyfin.createApi(this.url("/").toString(), apiKey, httpsAgent);
|
||||
}
|
||||
|
||||
const apiClient = this.jellyfin.createApi(this.url("/").toString());
|
||||
const apiClient = this.jellyfin.createApi(this.url("/").toString(), undefined, httpsAgent);
|
||||
// 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
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
|
||||
import { logger } from "@homarr/log";
|
||||
import { z } from "@homarr/validation";
|
||||
|
||||
@@ -8,7 +9,7 @@ export class LidarrIntegration extends MediaOrganizerIntegration {
|
||||
public async testConnectionAsync(): Promise<void> {
|
||||
await super.handleTestConnectionResponseAsync({
|
||||
queryFunctionAsync: async () => {
|
||||
return await fetch(this.url("/api"), {
|
||||
return await fetchWithTrustedCertificatesAsync(this.url("/api"), {
|
||||
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
|
||||
});
|
||||
},
|
||||
@@ -28,7 +29,7 @@ export class LidarrIntegration extends MediaOrganizerIntegration {
|
||||
unmonitored: includeUnmonitored,
|
||||
});
|
||||
|
||||
const response = await fetch(url, {
|
||||
const response = await fetchWithTrustedCertificatesAsync(url, {
|
||||
headers: {
|
||||
"X-Api-Key": super.getSecretValue("apiKey"),
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
|
||||
import type { AtLeastOneOf } from "@homarr/common/types";
|
||||
import { logger } from "@homarr/log";
|
||||
import { z } from "@homarr/validation";
|
||||
@@ -20,7 +21,7 @@ export class RadarrIntegration extends MediaOrganizerIntegration {
|
||||
unmonitored: includeUnmonitored,
|
||||
});
|
||||
|
||||
const response = await fetch(url, {
|
||||
const response = await fetchWithTrustedCertificatesAsync(url, {
|
||||
headers: {
|
||||
"X-Api-Key": super.getSecretValue("apiKey"),
|
||||
},
|
||||
@@ -94,7 +95,7 @@ export class RadarrIntegration extends MediaOrganizerIntegration {
|
||||
public async testConnectionAsync(): Promise<void> {
|
||||
await super.handleTestConnectionResponseAsync({
|
||||
queryFunctionAsync: async () => {
|
||||
return await fetch(this.url("/api"), {
|
||||
return await fetchWithTrustedCertificatesAsync(this.url("/api"), {
|
||||
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
|
||||
import { logger } from "@homarr/log";
|
||||
import { z } from "@homarr/validation";
|
||||
|
||||
@@ -8,7 +9,7 @@ export class ReadarrIntegration extends MediaOrganizerIntegration {
|
||||
public async testConnectionAsync(): Promise<void> {
|
||||
await super.handleTestConnectionResponseAsync({
|
||||
queryFunctionAsync: async () => {
|
||||
return await fetch(this.url("/api"), {
|
||||
return await fetchWithTrustedCertificatesAsync(this.url("/api"), {
|
||||
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
|
||||
});
|
||||
},
|
||||
@@ -34,7 +35,7 @@ export class ReadarrIntegration extends MediaOrganizerIntegration {
|
||||
includeAuthor,
|
||||
});
|
||||
|
||||
const response = await fetch(url, {
|
||||
const response = await fetchWithTrustedCertificatesAsync(url, {
|
||||
headers: {
|
||||
"X-Api-Key": super.getSecretValue("apiKey"),
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
|
||||
import { logger } from "@homarr/log";
|
||||
import { z } from "@homarr/validation";
|
||||
|
||||
@@ -21,7 +22,7 @@ export class SonarrIntegration extends MediaOrganizerIntegration {
|
||||
includeEpisodeImages: true,
|
||||
});
|
||||
|
||||
const response = await fetch(url, {
|
||||
const response = await fetchWithTrustedCertificatesAsync(url, {
|
||||
headers: {
|
||||
"X-Api-Key": super.getSecretValue("apiKey"),
|
||||
},
|
||||
@@ -93,7 +94,7 @@ export class SonarrIntegration extends MediaOrganizerIntegration {
|
||||
public async testConnectionAsync(): Promise<void> {
|
||||
await super.handleTestConnectionResponseAsync({
|
||||
queryFunctionAsync: async () => {
|
||||
return await fetch(this.url("/api"), {
|
||||
return await fetchWithTrustedCertificatesAsync(this.url("/api"), {
|
||||
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
|
||||
import { z } from "@homarr/validation";
|
||||
|
||||
import { Integration } from "../base/integration";
|
||||
@@ -9,7 +10,7 @@ import { getNodesResponseSchema, getStatisticsSchema, getStatusTableSchema } fro
|
||||
export class TdarrIntegration extends Integration {
|
||||
public async testConnectionAsync(): Promise<void> {
|
||||
const url = this.url("/api/v2/status");
|
||||
const response = await fetch(url);
|
||||
const response = await fetchWithTrustedCertificatesAsync(url);
|
||||
if (response.status !== 200) {
|
||||
throw new Error(`Unexpected status code: ${response.status}`);
|
||||
}
|
||||
@@ -19,7 +20,7 @@ export class TdarrIntegration extends Integration {
|
||||
|
||||
public async getStatisticsAsync(): Promise<TdarrStatistics> {
|
||||
const url = this.url("/api/v2/cruddb");
|
||||
const response = await fetch(url, {
|
||||
const response = await fetchWithTrustedCertificatesAsync(url, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
@@ -61,7 +62,7 @@ export class TdarrIntegration extends Integration {
|
||||
|
||||
public async getWorkersAsync(): Promise<TdarrWorker[]> {
|
||||
const url = this.url("/api/v2/get-nodes");
|
||||
const response = await fetch(url, {
|
||||
const response = await fetchWithTrustedCertificatesAsync(url, {
|
||||
method: "GET",
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
@@ -101,7 +102,7 @@ export class TdarrIntegration extends Integration {
|
||||
|
||||
private async getTranscodingQueueAsync(firstItemIndex: number, pageSize: number) {
|
||||
const url = this.url("/api/v2/client/status-tables");
|
||||
const response = await fetch(url, {
|
||||
const response = await fetchWithTrustedCertificatesAsync(url, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
@@ -136,7 +137,7 @@ export class TdarrIntegration extends Integration {
|
||||
|
||||
private async getHealthCheckDataAsync(firstItemIndex: number, pageSize: number, totalQueueCount: number) {
|
||||
const url = this.url("/api/v2/client/status-tables");
|
||||
const response = await fetch(url, {
|
||||
const response = await fetchWithTrustedCertificatesAsync(url, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import type { Response } from "undici";
|
||||
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
|
||||
|
||||
import { Integration } from "../base/integration";
|
||||
import { IntegrationTestConnectionError } from "../base/test-connection-error";
|
||||
import type { HealthMonitoring } from "../types";
|
||||
@@ -105,7 +109,7 @@ export class OpenMediaVaultIntegration extends Integration {
|
||||
if (!response.ok) {
|
||||
throw new IntegrationTestConnectionError("invalidCredentials");
|
||||
}
|
||||
const result = (await response.json()) as unknown;
|
||||
const result = await response.json();
|
||||
if (typeof result !== "object" || result === null || !("response" in result)) {
|
||||
throw new IntegrationTestConnectionError("invalidJson");
|
||||
}
|
||||
@@ -117,7 +121,7 @@ export class OpenMediaVaultIntegration extends Integration {
|
||||
params: Record<string, unknown>,
|
||||
headers: Record<string, string> = {},
|
||||
): Promise<Response> {
|
||||
return await fetch(this.url("/rpc.php"), {
|
||||
return await fetchWithTrustedCertificatesAsync(this.url("/rpc.php"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
|
||||
import { logger } from "@homarr/log";
|
||||
import { z } from "@homarr/validation";
|
||||
|
||||
@@ -20,7 +21,7 @@ interface OverseerrSearchResult {
|
||||
*/
|
||||
export class OverseerrIntegration extends Integration implements ISearchableIntegration<OverseerrSearchResult> {
|
||||
public async searchAsync(query: string) {
|
||||
const response = await fetch(this.url("/api/v1/search", { query }), {
|
||||
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/search", { query }), {
|
||||
headers: {
|
||||
"X-Api-Key": this.getSecretValue("apiKey"),
|
||||
},
|
||||
@@ -44,7 +45,7 @@ export class OverseerrIntegration extends Integration implements ISearchableInte
|
||||
|
||||
public async getSeriesInformationAsync(mediaType: "movie" | "tv", id: number) {
|
||||
const url = mediaType === "tv" ? this.url(`/api/v1/tv/${id}`) : this.url(`/api/v1/movie/${id}`);
|
||||
const response = await fetch(url, {
|
||||
const response = await fetchWithTrustedCertificatesAsync(url, {
|
||||
headers: {
|
||||
"X-Api-Key": this.getSecretValue("apiKey"),
|
||||
},
|
||||
@@ -60,7 +61,7 @@ export class OverseerrIntegration extends Integration implements ISearchableInte
|
||||
*/
|
||||
public async requestMediaAsync(mediaType: "movie" | "tv", id: number, seasons?: number[]): Promise<void> {
|
||||
const url = this.url("/api/v1/request");
|
||||
const response = await fetch(url, {
|
||||
const response = await fetchWithTrustedCertificatesAsync(url, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
mediaType,
|
||||
@@ -80,13 +81,12 @@ export class OverseerrIntegration extends Integration implements ISearchableInte
|
||||
}
|
||||
|
||||
public async testConnectionAsync(): Promise<void> {
|
||||
const response = await fetch(this.url("/api/v1/auth/me"), {
|
||||
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/auth/me"), {
|
||||
headers: {
|
||||
"X-Api-Key": this.getSecretValue("apiKey"),
|
||||
},
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const json: object = await response.json();
|
||||
const json = (await response.json()) as object;
|
||||
if (Object.keys(json).includes("id")) {
|
||||
return;
|
||||
}
|
||||
@@ -96,14 +96,17 @@ export class OverseerrIntegration extends Integration implements ISearchableInte
|
||||
|
||||
public async getRequestsAsync(): Promise<MediaRequest[]> {
|
||||
//Ensure to get all pending request first
|
||||
const pendingRequests = await fetch(this.url("/api/v1/request", { take: -1, filter: "pending" }), {
|
||||
headers: {
|
||||
"X-Api-Key": this.getSecretValue("apiKey"),
|
||||
const pendingRequests = await fetchWithTrustedCertificatesAsync(
|
||||
this.url("/api/v1/request", { take: -1, filter: "pending" }),
|
||||
{
|
||||
headers: {
|
||||
"X-Api-Key": this.getSecretValue("apiKey"),
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
|
||||
//Change 20 to integration setting (set to -1 for all)
|
||||
const allRequests = await fetch(this.url("/api/v1/request", { take: 20 }), {
|
||||
const allRequests = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/request", { take: 20 }), {
|
||||
headers: {
|
||||
"X-Api-Key": this.getSecretValue("apiKey"),
|
||||
},
|
||||
@@ -151,7 +154,7 @@ export class OverseerrIntegration extends Integration implements ISearchableInte
|
||||
}
|
||||
|
||||
public async getStatsAsync(): Promise<RequestStats> {
|
||||
const response = await fetch(this.url("/api/v1/request/count"), {
|
||||
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/request/count"), {
|
||||
headers: {
|
||||
"X-Api-Key": this.getSecretValue("apiKey"),
|
||||
},
|
||||
@@ -160,7 +163,7 @@ export class OverseerrIntegration extends Integration implements ISearchableInte
|
||||
}
|
||||
|
||||
public async getUsersAsync(): Promise<RequestUser[]> {
|
||||
const response = await fetch(this.url("/api/v1/user", { take: -1 }), {
|
||||
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/user", { take: -1 }), {
|
||||
headers: {
|
||||
"X-Api-Key": this.getSecretValue("apiKey"),
|
||||
},
|
||||
@@ -177,7 +180,7 @@ export class OverseerrIntegration extends Integration implements ISearchableInte
|
||||
|
||||
public async approveRequestAsync(requestId: number): Promise<void> {
|
||||
logger.info(`Approving media request id='${requestId}' integration='${this.integration.name}'`);
|
||||
await fetch(this.url(`/api/v1/request/${requestId}/approve`), {
|
||||
await fetchWithTrustedCertificatesAsync(this.url(`/api/v1/request/${requestId}/approve`), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-Api-Key": this.getSecretValue("apiKey"),
|
||||
@@ -195,7 +198,7 @@ export class OverseerrIntegration extends Integration implements ISearchableInte
|
||||
|
||||
public async declineRequestAsync(requestId: number): Promise<void> {
|
||||
logger.info(`Declining media request id='${requestId}' integration='${this.integration.name}'`);
|
||||
await fetch(this.url(`/api/v1/request/${requestId}/decline`), {
|
||||
await fetchWithTrustedCertificatesAsync(this.url(`/api/v1/request/${requestId}/decline`), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-Api-Key": this.getSecretValue("apiKey"),
|
||||
@@ -212,7 +215,7 @@ export class OverseerrIntegration extends Integration implements ISearchableInte
|
||||
}
|
||||
|
||||
private async getItemInformationAsync(id: number, type: MediaRequest["type"]): Promise<MediaInformation> {
|
||||
const response = await fetch(this.url(`/api/v1/${type}/${id}`), {
|
||||
const response = await fetchWithTrustedCertificatesAsync(this.url(`/api/v1/${type}/${id}`), {
|
||||
headers: {
|
||||
"X-Api-Key": this.getSecretValue("apiKey"),
|
||||
},
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
|
||||
|
||||
import { Integration } from "../base/integration";
|
||||
import { IntegrationTestConnectionError } from "../base/test-connection-error";
|
||||
import type { DnsHoleSummaryIntegration } from "../interfaces/dns-hole-summary/dns-hole-summary-integration";
|
||||
@@ -7,7 +9,7 @@ import { summaryResponseSchema } from "./pi-hole-types";
|
||||
export class PiHoleIntegration extends Integration implements DnsHoleSummaryIntegration {
|
||||
public async getSummaryAsync(): Promise<DnsHoleSummary> {
|
||||
const apiKey = super.getSecretValue("apiKey");
|
||||
const response = await fetch(this.url("/admin/api.php?summaryRaw", { auth: 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}`,
|
||||
@@ -36,11 +38,11 @@ export class PiHoleIntegration extends Integration implements DnsHoleSummaryInte
|
||||
|
||||
await super.handleTestConnectionResponseAsync({
|
||||
queryFunctionAsync: async () => {
|
||||
return await fetch(this.url("/admin/api.php?status", { auth: apiKey }));
|
||||
return await fetchWithTrustedCertificatesAsync(this.url("/admin/api.php?status", { auth: apiKey }));
|
||||
},
|
||||
handleResponseAsync: async (response) => {
|
||||
try {
|
||||
const result = (await response.json()) as unknown;
|
||||
const result = await response.json();
|
||||
if (typeof result === "object" && result !== null && "status" in result) return;
|
||||
} catch {
|
||||
throw new IntegrationTestConnectionError("invalidJson");
|
||||
@@ -53,7 +55,7 @@ export class PiHoleIntegration extends Integration implements DnsHoleSummaryInte
|
||||
|
||||
public async enableAsync(): Promise<void> {
|
||||
const apiKey = super.getSecretValue("apiKey");
|
||||
const response = await fetch(this.url("/admin/api.php?enable", { auth: apiKey }));
|
||||
const response = await fetchWithTrustedCertificatesAsync(this.url("/admin/api.php?enable", { auth: apiKey }));
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to enable PiHole for ${this.integration.name} (${this.integration.id}): ${response.statusText}`,
|
||||
@@ -64,7 +66,7 @@ export class PiHoleIntegration extends Integration implements DnsHoleSummaryInte
|
||||
public async disableAsync(duration?: number): Promise<void> {
|
||||
const apiKey = super.getSecretValue("apiKey");
|
||||
const url = this.url(`/admin/api.php?disable${duration ? `=${duration}` : ""}`, { auth: apiKey });
|
||||
const response = await fetch(url);
|
||||
const response = await fetchWithTrustedCertificatesAsync(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to disable PiHole for ${this.integration.name} (${this.integration.id}): ${response.statusText}`,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { parseStringPromise } from "xml2js";
|
||||
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
import { Integration } from "../base/integration";
|
||||
@@ -11,7 +12,7 @@ export class PlexIntegration extends Integration {
|
||||
public async getCurrentSessionsAsync(): Promise<StreamSession[]> {
|
||||
const token = super.getSecretValue("apiKey");
|
||||
|
||||
const response = await fetch(this.url("/status/sessions"), {
|
||||
const response = await fetchWithTrustedCertificatesAsync(this.url("/status/sessions"), {
|
||||
headers: {
|
||||
"X-Plex-Token": token,
|
||||
},
|
||||
@@ -66,7 +67,7 @@ export class PlexIntegration extends Integration {
|
||||
|
||||
await super.handleTestConnectionResponseAsync({
|
||||
queryFunctionAsync: async () => {
|
||||
return await fetch(this.url("/"), {
|
||||
return await fetchWithTrustedCertificatesAsync(this.url("/"), {
|
||||
headers: {
|
||||
"X-Plex-Token": token,
|
||||
},
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
|
||||
|
||||
import { Integration } from "../base/integration";
|
||||
import { IntegrationTestConnectionError } from "../base/test-connection-error";
|
||||
import type { Indexer } from "../interfaces/indexer-manager/indexer";
|
||||
@@ -7,7 +9,7 @@ export class ProwlarrIntegration extends Integration {
|
||||
public async getIndexersAsync(): Promise<Indexer[]> {
|
||||
const apiKey = super.getSecretValue("apiKey");
|
||||
|
||||
const indexerResponse = await fetch(this.url("/api/v1/indexer"), {
|
||||
const indexerResponse = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/indexer"), {
|
||||
headers: {
|
||||
"X-Api-Key": apiKey,
|
||||
},
|
||||
@@ -18,7 +20,7 @@ export class ProwlarrIntegration extends Integration {
|
||||
);
|
||||
}
|
||||
|
||||
const statusResponse = await fetch(this.url("/api/v1/indexerstatus"), {
|
||||
const statusResponse = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/indexerstatus"), {
|
||||
headers: {
|
||||
"X-Api-Key": apiKey,
|
||||
},
|
||||
@@ -60,7 +62,7 @@ export class ProwlarrIntegration extends Integration {
|
||||
|
||||
public async testAllAsync(): Promise<void> {
|
||||
const apiKey = super.getSecretValue("apiKey");
|
||||
const response = await fetch(this.url("/api/v1/indexer/testall"), {
|
||||
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/indexer/testall"), {
|
||||
headers: {
|
||||
"X-Api-Key": apiKey,
|
||||
},
|
||||
@@ -78,7 +80,7 @@ export class ProwlarrIntegration extends Integration {
|
||||
|
||||
await super.handleTestConnectionResponseAsync({
|
||||
queryFunctionAsync: async () => {
|
||||
return await fetch(this.url("/api"), {
|
||||
return await fetchWithTrustedCertificatesAsync(this.url("/api"), {
|
||||
headers: {
|
||||
"X-Api-Key": apiKey,
|
||||
},
|
||||
@@ -86,7 +88,7 @@ export class ProwlarrIntegration extends Integration {
|
||||
},
|
||||
handleResponseAsync: async (response) => {
|
||||
try {
|
||||
const result = (await response.json()) as unknown;
|
||||
const result = await response.json();
|
||||
if (typeof result === "object" && result !== null) return;
|
||||
} catch {
|
||||
throw new IntegrationTestConnectionError("invalidJson");
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Response } from "undici";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { IntegrationTestConnectionError } from "../src";
|
||||
|
||||
@@ -897,6 +897,7 @@
|
||||
"passwordRequirements": "Password does not meet the requirements",
|
||||
"boardAlreadyExists": "A board with this name already exists",
|
||||
"invalidFileType": "Invalid file type, expected {expected}",
|
||||
"invalidFileName": "Invalid file name",
|
||||
"fileTooLarge": "File is too large, maximum size is {maxSize}",
|
||||
"invalidConfiguration": "Invalid configuration",
|
||||
"groupNameTaken": "Group name already taken"
|
||||
@@ -2101,6 +2102,7 @@
|
||||
"docker": "Docker",
|
||||
"logs": "Logs",
|
||||
"api": "API",
|
||||
"certificates": "Certificates",
|
||||
"tasks": "Tasks"
|
||||
}
|
||||
},
|
||||
@@ -2706,6 +2708,9 @@
|
||||
},
|
||||
"logs": {
|
||||
"label": "Logs"
|
||||
},
|
||||
"certificates": {
|
||||
"label": "Certificates"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
@@ -3101,5 +3106,46 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"certificate": {
|
||||
"page": {
|
||||
"list": {
|
||||
"title": "Trusted certificates",
|
||||
"description": "Used by Homarr to request data from integrations.",
|
||||
"noResults": {
|
||||
"title": "There are no certificates yet"
|
||||
},
|
||||
"expires": "Expires {when}"
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
"create": {
|
||||
"label": "Add certificate",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "Certificate added",
|
||||
"message": "The certificate was added successfully"
|
||||
},
|
||||
"error": {
|
||||
"title": "Failed to add certificate",
|
||||
"message": "The certificate could not be added"
|
||||
}
|
||||
}
|
||||
},
|
||||
"remove": {
|
||||
"label": "Remove certificate",
|
||||
"confirm": "Are you sure you want to remove the certificate?",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "Certificate removed",
|
||||
"message": "The certificate was removed successfully"
|
||||
},
|
||||
"error": {
|
||||
"title": "Certificate not removed",
|
||||
"message": "The certificate could not be removed"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
52
packages/validation/src/certificates.ts
Normal file
52
packages/validation/src/certificates.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { createCustomErrorParams } from "./form/i18n";
|
||||
|
||||
const validFileNameSchema = z.string().regex(/^[\w\-. ]+$/);
|
||||
|
||||
export const superRefineCertificateFile = (value: File | null, context: z.RefinementCtx) => {
|
||||
if (!value) {
|
||||
return context.addIssue({
|
||||
code: "invalid_type",
|
||||
expected: "object",
|
||||
received: "null",
|
||||
});
|
||||
}
|
||||
|
||||
const result = validFileNameSchema.safeParse(value.name);
|
||||
if (!result.success) {
|
||||
return context.addIssue({
|
||||
code: "custom",
|
||||
params: createCustomErrorParams({
|
||||
key: "invalidFileName",
|
||||
params: {},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
if (value.type !== "application/x-x509-ca-cert" && value.type !== "application/pkix-cert") {
|
||||
return context.addIssue({
|
||||
code: "custom",
|
||||
params: createCustomErrorParams({
|
||||
key: "invalidFileType",
|
||||
params: { expected: ".crt" },
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
if (value.size > 1024 * 1024) {
|
||||
return context.addIssue({
|
||||
code: "custom",
|
||||
params: createCustomErrorParams({
|
||||
key: "fileTooLarge",
|
||||
params: { maxSize: "1 MB" },
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const certificateSchemas = {
|
||||
validFileNameSchema,
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import { appSchemas } from "./app";
|
||||
import { boardSchemas } from "./board";
|
||||
import { certificateSchemas } from "./certificates";
|
||||
import { commonSchemas } from "./common";
|
||||
import { groupSchemas } from "./group";
|
||||
import { iconsSchemas } from "./icons";
|
||||
@@ -24,6 +25,7 @@ export const validation = {
|
||||
media: mediaSchemas,
|
||||
settings: settingsSchemas,
|
||||
common: commonSchemas,
|
||||
certificates: certificateSchemas,
|
||||
};
|
||||
|
||||
export {
|
||||
@@ -33,6 +35,7 @@ export {
|
||||
type BoardItemAdvancedOptions,
|
||||
type BoardItemIntegration,
|
||||
} from "./shared";
|
||||
export { superRefineCertificateFile } from "./certificates";
|
||||
export { passwordRequirements } from "./user";
|
||||
export { supportedMediaUploadFormats } from "./media";
|
||||
export { zodEnumFromArray, zodUnionFromArray } from "./enums";
|
||||
|
||||
40
pnpm-lock.yaml
generated
40
pnpm-lock.yaml
generated
@@ -88,6 +88,9 @@ importers:
|
||||
'@homarr/auth':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../../packages/auth
|
||||
'@homarr/certificates':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../../packages/certificates
|
||||
'@homarr/common':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../../packages/common
|
||||
@@ -491,6 +494,9 @@ importers:
|
||||
'@homarr/auth':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../auth
|
||||
'@homarr/certificates':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../certificates
|
||||
'@homarr/common':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../common
|
||||
@@ -665,6 +671,31 @@ importers:
|
||||
specifier: ^5.7.3
|
||||
version: 5.7.3
|
||||
|
||||
packages/certificates:
|
||||
dependencies:
|
||||
'@homarr/common':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../common
|
||||
undici:
|
||||
specifier: 7.2.3
|
||||
version: 7.2.3
|
||||
devDependencies:
|
||||
'@homarr/eslint-config':
|
||||
specifier: workspace:^0.2.0
|
||||
version: link:../../tooling/eslint
|
||||
'@homarr/prettier-config':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../../tooling/prettier
|
||||
'@homarr/tsconfig':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../../tooling/typescript
|
||||
eslint:
|
||||
specifier: ^9.18.0
|
||||
version: 9.18.0
|
||||
typescript:
|
||||
specifier: ^5.7.3
|
||||
version: 5.7.3
|
||||
|
||||
packages/cli:
|
||||
dependencies:
|
||||
'@drizzle-team/brocli':
|
||||
@@ -716,6 +747,9 @@ importers:
|
||||
react-dom:
|
||||
specifier: 19.0.0
|
||||
version: 19.0.0(react@19.0.0)
|
||||
undici:
|
||||
specifier: 7.2.3
|
||||
version: 7.2.3
|
||||
zod:
|
||||
specifier: ^3.24.1
|
||||
version: 3.24.1
|
||||
@@ -1052,6 +1086,9 @@ importers:
|
||||
'@ctrl/transmission':
|
||||
specifier: ^7.2.0
|
||||
version: 7.2.0
|
||||
'@homarr/certificates':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../certificates
|
||||
'@homarr/common':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../common
|
||||
@@ -1076,6 +1113,9 @@ importers:
|
||||
'@jellyfin/sdk':
|
||||
specifier: ^0.11.0
|
||||
version: 0.11.0(axios@1.7.7)
|
||||
undici:
|
||||
specifier: 7.2.3
|
||||
version: 7.2.3
|
||||
xml2js:
|
||||
specifier: ^0.6.2
|
||||
version: 0.6.2
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Create sub directories in volume
|
||||
mkdir -p /appdata/db
|
||||
mkdir -p /appdata/redis
|
||||
mkdir -p /appdata/trusted-certificates
|
||||
|
||||
# Run migrations
|
||||
if [ $DB_MIGRATIONS_DISABLED = "true" ]; then
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"DOCKER_PORTS",
|
||||
"NODE_ENV",
|
||||
"PORT",
|
||||
"LOCAL_CERTIFICATE_PATH",
|
||||
"SECRET_ENCRYPTION_KEY",
|
||||
"SKIP_ENV_VALIDATION"
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user