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:
Meier Lukas
2025-01-17 00:08:40 +01:00
committed by GitHub
parent b10b2013af
commit 8c36c3e36b
47 changed files with 737 additions and 122 deletions

View File

@@ -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",

View File

@@ -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

View 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);
}),
});

View File

@@ -0,0 +1,9 @@
import baseConfig from "@homarr/eslint-config/base";
/** @type {import('typescript-eslint').Config} */
export default [
{
ignores: [],
},
...baseConfig,
];

View 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"
}
}

View 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,
});
};

View File

@@ -0,0 +1,8 @@
{
"extends": "@homarr/tsconfig/base.json",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["*.ts", "src"],
"exclude": ["node_modules"]
}

View File

@@ -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": {

View 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);
}
}

View File

@@ -1,3 +1,4 @@
export * from "./security";
export * from "./encryption";
export * from "./user-agent";
export * from "./fetch-agent";

View File

@@ -0,0 +1,99 @@
import type { Dispatcher } from "undici";
import { describe, expect, test, vi } from "vitest";
import { logger } from "@homarr/log";
import { LoggingAgent } from "../fetch-agent";
vi.mock("undici", () => {
return {
Agent: class Agent {
dispatch(_options: Dispatcher.DispatchOptions, _handler: Dispatcher.DispatchHandler): boolean {
return true;
}
},
setGlobalDispatcher: () => undefined,
};
});
const REDACTED = "REDACTED";
describe("LoggingAgent should log all requests", () => {
test("should log all requests", () => {
// Arrange
const infoLogSpy = vi.spyOn(logger, "info");
const agent = new LoggingAgent();
// Act
agent.dispatch({ origin: "https://homarr.dev", path: "/", method: "GET" }, {});
// Assert
expect(infoLogSpy).toHaveBeenCalledWith("Dispatching request https://homarr.dev/ (0 headers)");
});
test("should show amount of headers", () => {
// Arrange
const infoLogSpy = vi.spyOn(logger, "info");
const agent = new LoggingAgent();
// Act
agent.dispatch(
{
origin: "https://homarr.dev",
path: "/",
method: "GET",
headers: {
"Content-Type": "text/html",
"User-Agent": "Mozilla/5.0",
},
},
{},
);
// Assert
expect(infoLogSpy).toHaveBeenCalledWith(expect.stringContaining("(2 headers)"));
});
test.each([
["/?hex=a3815e8ada2ef9a31", `/?hex=${REDACTED}`],
["/?uuid=f7c3f65e-c511-4f90-ba9a-3fd31418bd49", `/?uuid=${REDACTED}`],
["/?password=complexPassword123", `/?password=${REDACTED}`],
[
// JWT for John Doe
"/?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c",
`/?jwt=${REDACTED}`,
],
["/?one=a1&two=b2&three=c3", `/?one=${REDACTED}&two=${REDACTED}&three=${REDACTED}`],
["/?numberWith13Chars=1234567890123", `/?numberWith13Chars=${REDACTED}`],
[`/?stringWith13Chars=${"a".repeat(13)}`, `/?stringWith13Chars=${REDACTED}`],
])("should redact sensitive data in url https://homarr.dev%s", (path, expected) => {
// Arrange
const infoLogSpy = vi.spyOn(logger, "info");
const agent = new LoggingAgent();
// Act
agent.dispatch({ origin: "https://homarr.dev", path, method: "GET" }, {});
// Assert
expect(infoLogSpy).toHaveBeenCalledWith(expect.stringContaining(` https://homarr.dev${expected} `));
});
test.each([
["empty", "/?empty"],
["numbers with max 12 chars", "/?number=123456789012"],
["true", "/?true=true"],
["false", "/?false=false"],
["strings with max 12 chars", `/?short=${"a".repeat(12)}`],
["dates", "/?date=2022-01-01"],
["date times", "/?datetime=2022-01-01T00:00:00.000Z"],
])("should not redact values that are %s", (_reason, path) => {
// Arrange
const infoLogSpy = vi.spyOn(logger, "info");
const agent = new LoggingAgent();
// Act
agent.dispatch({ origin: "https://homarr.dev", path, method: "GET" }, {});
// Assert
expect(infoLogSpy).toHaveBeenCalledWith(expect.stringContaining(` https://homarr.dev${path} `));
});
});

View File

@@ -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": {

View File

@@ -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",

View File

@@ -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";

View File

@@ -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,

View File

@@ -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(),
});
}

View File

@@ -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);

View File

@@ -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(),
});
}

View File

@@ -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) {

View File

@@ -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(),
});
}

View File

@@ -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",

View File

@@ -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

View File

@@ -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"),
},

View File

@@ -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") },
});
},

View File

@@ -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"),
},

View File

@@ -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") },
});
},

View File

@@ -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({

View File

@@ -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",

View File

@@ -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"),
},

View File

@@ -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}`,

View File

@@ -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,
},

View File

@@ -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");

View File

@@ -1,3 +1,4 @@
import { Response } from "undici";
import { describe, expect, test } from "vitest";
import { IntegrationTestConnectionError } from "../src";

View File

@@ -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"
}
}
}
}
}
}

View 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,
};

View File

@@ -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";