Merge branch 'dev' into add-more-openapi
This commit is contained in:
323 files changed
+4555
-2926
No files matched your search
@@ -22,8 +22,8 @@
|
||||
},
|
||||
"prettier": "@homarr/prettier-config",
|
||||
"dependencies": {
|
||||
"@homarr/core": "workspace:^0.1.0",
|
||||
"@homarr/db": "workspace:^0.1.0",
|
||||
"@homarr/log": "workspace:^0.1.0",
|
||||
"@homarr/server-settings": "workspace:^0.1.0",
|
||||
"@umami/node": "^0.4.0",
|
||||
"superjson": "2.2.6"
|
||||
@@ -32,7 +32,7 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint": "^9.39.2",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,17 @@
|
||||
import type { UmamiEventData } from "@umami/node";
|
||||
import { Umami } from "@umami/node";
|
||||
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { count, db } from "@homarr/db";
|
||||
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||
import { integrations, items, users } from "@homarr/db/schema";
|
||||
import { logger } from "@homarr/log";
|
||||
import type { defaultServerSettings } from "@homarr/server-settings";
|
||||
|
||||
import { Stopwatch } from "../../common/src";
|
||||
import { UMAMI_HOST_URL, UMAMI_WEBSITE_ID } from "./constants";
|
||||
|
||||
const logger = createLogger({ module: "analytics" });
|
||||
|
||||
export const sendServerAnalyticsAsync = async () => {
|
||||
const stopWatch = new Stopwatch();
|
||||
const analyticsSettings = await getServerSettingByKeyAsync(db, "analytics");
|
||||
|
||||
@@ -22,7 +22,6 @@
|
||||
"prettier": "@homarr/prettier-config",
|
||||
"dependencies": {
|
||||
"@homarr/auth": "workspace:^0.1.0",
|
||||
"@homarr/certificates": "workspace:^0.1.0",
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/core": "workspace:^0.1.0",
|
||||
"@homarr/cron-job-api": "workspace:^0.1.0",
|
||||
@@ -33,7 +32,6 @@
|
||||
"@homarr/docker": "workspace:^0.1.0",
|
||||
"@homarr/icons": "workspace:^0.1.0",
|
||||
"@homarr/integrations": "workspace:^0.1.0",
|
||||
"@homarr/log": "workspace:^",
|
||||
"@homarr/old-import": "workspace:^0.1.0",
|
||||
"@homarr/old-schema": "workspace:^0.1.0",
|
||||
"@homarr/ping": "workspace:^0.1.0",
|
||||
@@ -44,14 +42,14 @@
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@kubernetes/client-node": "^1.4.0",
|
||||
"@tanstack/react-query": "^5.90.12",
|
||||
"@trpc/client": "^11.7.2",
|
||||
"@trpc/react-query": "^11.7.2",
|
||||
"@trpc/server": "^11.7.2",
|
||||
"@trpc/tanstack-react-query": "^11.7.2",
|
||||
"@trpc/client": "^11.8.0",
|
||||
"@trpc/react-query": "^11.8.0",
|
||||
"@trpc/server": "^11.8.0",
|
||||
"@trpc/tanstack-react-query": "^11.8.0",
|
||||
"lodash.clonedeep": "^4.5.0",
|
||||
"next": "16.0.7",
|
||||
"react": "19.2.1",
|
||||
"react-dom": "19.2.1",
|
||||
"next": "16.0.10",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"superjson": "2.2.6",
|
||||
"trpc-to-openapi": "^3.1.0",
|
||||
"zod": "^4.1.13"
|
||||
@@ -60,7 +58,7 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint": "^9.39.2",
|
||||
"prettier": "^3.7.4",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
|
||||
@@ -3,14 +3,19 @@ import { TRPCError } from "@trpc/server";
|
||||
import { zfd } from "zod-form-data";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { addCustomRootCertificateAsync, removeCustomRootCertificateAsync } from "@homarr/certificates/server";
|
||||
import {
|
||||
addCustomRootCertificateAsync,
|
||||
removeCustomRootCertificateAsync,
|
||||
} from "@homarr/core/infrastructure/certificates";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { and, eq } from "@homarr/db";
|
||||
import { trustedCertificateHostnames } from "@homarr/db/schema";
|
||||
import { logger } from "@homarr/log";
|
||||
import { certificateValidFileNameSchema, checkCertificateFile } from "@homarr/validation/certificates";
|
||||
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from "../../trpc";
|
||||
|
||||
const logger = createLogger({ module: "certificateRouter" });
|
||||
|
||||
export const certificateRouter = createTRPCRouter({
|
||||
addCertificate: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { observable } from "@trpc/server/observable";
|
||||
import z from "zod/v4";
|
||||
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { cronExpressionSchema, jobGroupKeys, jobNameSchema } from "@homarr/cron-job-api";
|
||||
import { cronJobApi } from "@homarr/cron-job-api/client";
|
||||
import type { TaskStatus } from "@homarr/cron-job-status";
|
||||
import { createCronJobStatusChannel } from "@homarr/cron-job-status";
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from "../trpc";
|
||||
|
||||
const logger = createLogger({ module: "cronJobsRouter" });
|
||||
|
||||
export const cronJobsRouter = createTRPCRouter({
|
||||
triggerJob: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
|
||||
@@ -3,6 +3,7 @@ import { z } from "zod/v4";
|
||||
|
||||
import { createId, objectEntries } from "@homarr/common";
|
||||
import { decryptSecret, encryptSecret } from "@homarr/common/server";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { and, asc, eq, handleTransactionsAsync, inArray, like, or } from "@homarr/db";
|
||||
import {
|
||||
@@ -26,7 +27,6 @@ import {
|
||||
integrationSecretKindObject,
|
||||
} from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import { logger } from "@homarr/log";
|
||||
import { byIdSchema } from "@homarr/validation/common";
|
||||
import {
|
||||
integrationCreateSchema,
|
||||
@@ -40,6 +40,8 @@ import { throwIfActionForbiddenAsync } from "./integration-access";
|
||||
import { MissingSecretError, testConnectionAsync } from "./integration-test-connection";
|
||||
import { mapTestConnectionError } from "./map-test-connection-error";
|
||||
|
||||
const logger = createLogger({ module: "integrationRouter" });
|
||||
|
||||
export const integrationRouter = createTRPCRouter({
|
||||
all: publicProcedure.query(async ({ ctx }) => {
|
||||
const groupsOfCurrentUser = await ctx.db.query.groupMembers.findMany({
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { decryptSecret } from "@homarr/common/server";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||
import type { Integration } from "@homarr/db/schema";
|
||||
import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions";
|
||||
import { getAllSecretKindOptions } from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
const logger = createLogger({ module: "integrationTestConnection" });
|
||||
|
||||
type FormIntegration = Omit<Integration, "appId"> & {
|
||||
secrets: {
|
||||
@@ -35,8 +38,13 @@ export const testConnectionAsync = async (
|
||||
};
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
new Error(
|
||||
`Failed to decrypt secret from database integration="${integration.name}" secretKind="${secret.kind}"`,
|
||||
new ErrorWithMetadata(
|
||||
"Failed to decrypt secret from database",
|
||||
{
|
||||
integrationName: integration.name,
|
||||
integrationKind: integration.kind,
|
||||
secretKind: secret.kind,
|
||||
},
|
||||
{ cause: error },
|
||||
),
|
||||
);
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { V1NodeList, VersionInfo } from "@kubernetes/client-node";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import type { ClusterResourceCount, KubernetesCluster } from "@homarr/definitions";
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
import { kubernetesMiddleware } from "../../../middlewares/kubernetes";
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from "../../../trpc";
|
||||
@@ -129,7 +128,6 @@ export const clusterRouter = createTRPCRouter({
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error("Unable to retrieve cluster", error);
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "An error occurred while fetching Kubernetes cluster",
|
||||
@@ -165,7 +163,6 @@ export const clusterRouter = createTRPCRouter({
|
||||
{ label: "volumes", count: volumes.items.length },
|
||||
];
|
||||
} catch (error) {
|
||||
logger.error("Unable to retrieve cluster resource counts", error);
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "An error occurred while fetching Kubernetes resources count",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import type { KubernetesBaseResource } from "@homarr/definitions";
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
import { kubernetesMiddleware } from "../../../middlewares/kubernetes";
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from "../../../trpc";
|
||||
@@ -25,7 +24,6 @@ export const configMapsRouter = createTRPCRouter({
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Unable to retrieve configMaps", error);
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "An error occurred while fetching Kubernetes ConfigMaps",
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { V1HTTPIngressPath, V1Ingress, V1IngressRule } from "@kubernetes/cl
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import type { KubernetesIngress, KubernetesIngressPath, KubernetesIngressRuleAndPath } from "@homarr/definitions";
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
import { kubernetesMiddleware } from "../../../middlewares/kubernetes";
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from "../../../trpc";
|
||||
@@ -43,7 +42,6 @@ export const ingressesRouter = createTRPCRouter({
|
||||
|
||||
return ingresses.items.map(mapIngress);
|
||||
} catch (error) {
|
||||
logger.error("Unable to retrieve ingresses", error);
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "An error occurred while fetching Kubernetes ingresses",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import type { KubernetesNamespace, KubernetesNamespaceState } from "@homarr/definitions";
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
import { kubernetesMiddleware } from "../../../middlewares/kubernetes";
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from "../../../trpc";
|
||||
@@ -25,7 +24,6 @@ export const namespacesRouter = createTRPCRouter({
|
||||
} satisfies KubernetesNamespace;
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Unable to retrieve namespaces", error);
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "An error occurred while fetching Kubernetes namespaces",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import type { KubernetesNode, KubernetesNodeState } from "@homarr/definitions";
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
import { kubernetesMiddleware } from "../../../middlewares/kubernetes";
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from "../../../trpc";
|
||||
@@ -57,7 +56,6 @@ export const nodesRouter = createTRPCRouter({
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Unable to retrieve nodes", error);
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "An error occurred while fetching Kubernetes nodes",
|
||||
|
||||
@@ -2,13 +2,15 @@ import type { KubeConfig, V1OwnerReference } from "@kubernetes/client-node";
|
||||
import { AppsV1Api } from "@kubernetes/client-node";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import type { KubernetesPod } from "@homarr/definitions";
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
import { kubernetesMiddleware } from "../../../middlewares/kubernetes";
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from "../../../trpc";
|
||||
import { KubernetesClient } from "../kubernetes-client";
|
||||
|
||||
const logger = createLogger({ module: "podsRouter" });
|
||||
|
||||
export const podsRouter = createTRPCRouter({
|
||||
getPods: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
@@ -55,7 +57,6 @@ export const podsRouter = createTRPCRouter({
|
||||
|
||||
return pods;
|
||||
} catch (error) {
|
||||
logger.error("Unable to retrieve pods", error);
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "An error occurred while fetching Kubernetes pods",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import type { KubernetesSecret } from "@homarr/definitions";
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
import { kubernetesMiddleware } from "../../../middlewares/kubernetes";
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from "../../../trpc";
|
||||
@@ -25,7 +24,6 @@ export const secretsRouter = createTRPCRouter({
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Unable to retrieve secrets", error);
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "An error occurred while fetching Kubernetes secrets",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import type { KubernetesService } from "@homarr/definitions";
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
import { kubernetesMiddleware } from "../../../middlewares/kubernetes";
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from "../../../trpc";
|
||||
@@ -29,7 +28,6 @@ export const servicesRouter = createTRPCRouter({
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Unable to retrieve services", error);
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "An error occurred while fetching Kubernetes services",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import type { KubernetesVolume } from "@homarr/definitions";
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
import { kubernetesMiddleware } from "../../../middlewares/kubernetes";
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from "../../../trpc";
|
||||
@@ -31,7 +30,6 @@ export const volumesRouter = createTRPCRouter({
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Unable to retrieve volumes", error);
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "An error occurred while fetching Kubernetes Volumes",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { fetchWithTimeout } from "@homarr/common";
|
||||
import { fetchWithTimeoutAsync } from "@homarr/core/infrastructure/http/timeout";
|
||||
|
||||
import { createTRPCRouter, publicProcedure } from "../trpc";
|
||||
|
||||
@@ -36,7 +36,7 @@ export const locationRouter = createTRPCRouter({
|
||||
.input(locationSearchCityInput)
|
||||
.output(locationSearchCityOutput)
|
||||
.query(async ({ input }) => {
|
||||
const res = await fetchWithTimeout(`https://geocoding-api.open-meteo.com/v1/search?name=${input.query}`);
|
||||
const res = await fetchWithTimeoutAsync(`https://geocoding-api.open-meteo.com/v1/search?name=${input.query}`);
|
||||
return (await res.json()) as z.infer<typeof locationSearchCityOutput>;
|
||||
}),
|
||||
});
|
||||
@@ -1,14 +1,16 @@
|
||||
import { observable } from "@trpc/server/observable";
|
||||
import z from "zod/v4";
|
||||
|
||||
import { logger } from "@homarr/log";
|
||||
import { logLevels } from "@homarr/log/constants";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { logLevels } from "@homarr/core/infrastructure/logs/constants";
|
||||
import type { LoggerMessage } from "@homarr/redis";
|
||||
import { loggingChannel } from "@homarr/redis";
|
||||
import { zodEnumFromArray } from "@homarr/validation/enums";
|
||||
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from "../trpc";
|
||||
|
||||
const logger = createLogger({ module: "logRouter" });
|
||||
|
||||
export const logRouter = createTRPCRouter({
|
||||
subscribe: permissionRequiredProcedure
|
||||
.requiresPermission("other-view-logs")
|
||||
|
||||
@@ -2,11 +2,11 @@ import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createId } from "@homarr/common";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { asc, eq, like } from "@homarr/db";
|
||||
import { getServerSettingByKeyAsync, updateServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||
import { searchEngines, users } from "@homarr/db/schema";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import { logger } from "@homarr/log";
|
||||
import { byIdSchema, paginatedSchema, searchSchema } from "@homarr/validation/common";
|
||||
import { searchEngineEditSchema, searchEngineManageSchema } from "@homarr/validation/search-engine";
|
||||
import { mediaRequestOptionsSchema, mediaRequestRequestSchema } from "@homarr/validation/widgets/media-request";
|
||||
@@ -14,6 +14,8 @@ import { mediaRequestOptionsSchema, mediaRequestRequestSchema } from "@homarr/va
|
||||
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc";
|
||||
|
||||
const logger = createLogger({ module: "searchEngineRouter" });
|
||||
|
||||
export const searchEngineRouter = createTRPCRouter({
|
||||
getPaginated: protectedProcedure.input(paginatedSchema).query(async ({ input, ctx }) => {
|
||||
const whereQuery = input.search ? like(searchEngines.name, `%${input.search.trim()}%`) : undefined;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { logger } from "@homarr/log";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { updateCheckerRequestHandler } from "@homarr/request-handler/update-checker";
|
||||
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from "../trpc";
|
||||
|
||||
const logger = createLogger({ module: "updateCheckerRouter" });
|
||||
|
||||
export const updateCheckerRouter = createTRPCRouter({
|
||||
getAvailableUpdates: permissionRequiredProcedure.requiresPermission("admin").query(async () => {
|
||||
try {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { z } from "zod/v4";
|
||||
|
||||
import { createSaltAsync, hashPasswordAsync } from "@homarr/auth";
|
||||
import { createId } from "@homarr/common";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { and, eq, like } from "@homarr/db";
|
||||
import { getMaxGroupPositionAsync } from "@homarr/db/queries";
|
||||
@@ -10,7 +11,6 @@ import { boards, groupMembers, groupPermissions, groups, invites, users } from "
|
||||
import { selectUserSchema } from "@homarr/db/validationSchemas";
|
||||
import { credentialsAdminGroup } from "@homarr/definitions";
|
||||
import type { SupportedAuthProvider } from "@homarr/definitions";
|
||||
import { logger } from "@homarr/log";
|
||||
import { byIdSchema } from "@homarr/validation/common";
|
||||
import type { userBaseCreateSchema } from "@homarr/validation/user";
|
||||
import {
|
||||
@@ -39,6 +39,8 @@ import { throwIfCredentialsDisabled } from "./invite/checks";
|
||||
import { nextOnboardingStepAsync } from "./onboard/onboard-queries";
|
||||
import { changeSearchPreferencesAsync, changeSearchPreferencesInputSchema } from "./user/change-search-preferences";
|
||||
|
||||
const logger = createLogger({ module: "userRouter" });
|
||||
|
||||
export const userRouter = createTRPCRouter({
|
||||
initUser: onboardingProcedure
|
||||
.requiresStep("user")
|
||||
@@ -364,9 +366,11 @@ export const userRouter = createTRPCRouter({
|
||||
// Admins can change the password of other users without providing the previous password
|
||||
const isPreviousPasswordRequired = ctx.session.user.id === input.userId;
|
||||
|
||||
logger.info(
|
||||
`User ${user.id} is changing password for user ${input.userId}, previous password is required: ${isPreviousPasswordRequired}`,
|
||||
);
|
||||
logger.info("Changing user password", {
|
||||
actorId: ctx.session.user.id,
|
||||
targetUserId: input.userId,
|
||||
previousPasswordRequired: isPreviousPasswordRequired,
|
||||
});
|
||||
|
||||
if (isPreviousPasswordRequired) {
|
||||
const previousPasswordHash = await hashPasswordAsync(input.previousPassword, dbUser.salt ?? "");
|
||||
|
||||
@@ -4,7 +4,6 @@ import { observable } from "@trpc/server/observable";
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import type { Indexer } from "@homarr/integrations/types";
|
||||
import { logger } from "@homarr/log";
|
||||
import { indexerManagerRequestHandler } from "@homarr/request-handler/indexer-manager";
|
||||
|
||||
import type { IntegrationAction } from "../../middlewares/integration";
|
||||
@@ -61,10 +60,10 @@ export const indexerManagerRouter = createTRPCRouter({
|
||||
ctx.integrations.map(async (integration) => {
|
||||
const client = await createIntegrationAsync(integration);
|
||||
await client.testAllAsync().catch((err) => {
|
||||
logger.error("indexer-manager router - ", err);
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: `Failed to test all indexers for ${integration.name} (${integration.id})`,
|
||||
cause: err,
|
||||
});
|
||||
});
|
||||
}),
|
||||
|
||||
@@ -14,12 +14,14 @@ import { ZodError } from "zod/v4";
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { FlattenError } from "@homarr/common";
|
||||
import { userAgent } from "@homarr/common/server";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { db } from "@homarr/db";
|
||||
import type { GroupPermissionKey, OnboardingStep } from "@homarr/definitions";
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
import { getOnboardingOrFallbackAsync } from "./router/onboard/onboard-queries";
|
||||
|
||||
const logger = createLogger({ module: "trpc" });
|
||||
|
||||
/**
|
||||
* 1. CONTEXT
|
||||
*
|
||||
@@ -36,7 +38,7 @@ export const createTRPCContext = (opts: { headers: Headers; session: Session | n
|
||||
const session = opts.session;
|
||||
const source = opts.headers.get("x-trpc-source") ?? "unknown";
|
||||
|
||||
logger.info(`tRPC request from ${source} by user '${session?.user.name} (${session?.user.id})'`, session?.user);
|
||||
logger.info("Received tRPC request", { source, userId: session?.user.id, userName: session?.user.name });
|
||||
|
||||
return {
|
||||
session,
|
||||
|
||||
@@ -3,9 +3,9 @@ import { cookies } from "next/headers";
|
||||
import NextAuth from "next-auth";
|
||||
import Credentials from "next-auth/providers/credentials";
|
||||
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { db } from "@homarr/db";
|
||||
import type { SupportedAuthProvider } from "@homarr/definitions";
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
import { createAdapter } from "./adapter";
|
||||
import { createSessionCallback } from "./callbacks";
|
||||
@@ -18,6 +18,8 @@ import { OidcProvider } from "./providers/oidc/oidc-provider";
|
||||
import { createRedirectUri } from "./redirect";
|
||||
import { expireDateAfter, generateSessionToken, sessionTokenCookieName } from "./session";
|
||||
|
||||
const logger = createLogger({ module: "authConfiguration" });
|
||||
|
||||
// See why it's unknown in the [...nextauth]/route.ts file
|
||||
export const createConfiguration = (
|
||||
provider: SupportedAuthProvider | "unknown",
|
||||
|
||||
+36
-22
@@ -2,15 +2,17 @@ import { cookies } from "next/headers";
|
||||
import dayjs from "dayjs";
|
||||
import type { NextAuthConfig } from "next-auth";
|
||||
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { and, eq, inArray } from "@homarr/db";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { groupMembers, groups, users } from "@homarr/db/schema";
|
||||
import { colorSchemeCookieKey, everyoneGroup } from "@homarr/definitions";
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
import { env } from "./env";
|
||||
import { extractProfileName } from "./providers/oidc/oidc-provider";
|
||||
|
||||
const logger = createLogger({ module: "authEvents" });
|
||||
|
||||
export const createSignInEventHandler = (db: Database): Exclude<NextAuthConfig["events"], undefined>["signIn"] => {
|
||||
return async ({ user, profile }) => {
|
||||
logger.debug(`SignIn EventHandler for user: ${JSON.stringify(user)} . profile: ${JSON.stringify(profile)}`);
|
||||
@@ -43,9 +45,11 @@ export const createSignInEventHandler = (db: Database): Exclude<NextAuthConfig["
|
||||
|
||||
if (dbUser.name !== user.name) {
|
||||
await db.update(users).set({ name: user.name }).where(eq(users.id, user.id));
|
||||
logger.info(
|
||||
`Username for user of credentials provider has changed. user=${user.id} old=${dbUser.name} new=${user.name}`,
|
||||
);
|
||||
logger.info("Username for user of credentials provider has changed.", {
|
||||
userId: user.id,
|
||||
oldName: dbUser.name,
|
||||
newName: user.name,
|
||||
});
|
||||
}
|
||||
|
||||
if (profile) {
|
||||
@@ -56,9 +60,11 @@ export const createSignInEventHandler = (db: Database): Exclude<NextAuthConfig["
|
||||
|
||||
if (dbUser.name !== profileUsername) {
|
||||
await db.update(users).set({ name: profileUsername }).where(eq(users.id, user.id));
|
||||
logger.info(
|
||||
`Username for user of oidc provider has changed. user=${user.id} old='${dbUser.name}' new='${profileUsername}'`,
|
||||
);
|
||||
logger.info("Username for user of oidc provider has changed.", {
|
||||
userId: user.id,
|
||||
oldName: dbUser.name,
|
||||
newName: profileUsername,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -67,11 +73,13 @@ export const createSignInEventHandler = (db: Database): Exclude<NextAuthConfig["
|
||||
!dbUser.image?.startsWith("data:")
|
||||
) {
|
||||
await db.update(users).set({ image: profile.picture }).where(eq(users.id, user.id));
|
||||
logger.info(`Profile picture for user of oidc provider has changed. user=${user.id}'`);
|
||||
logger.info("Profile picture for user of oidc provider has changed.", {
|
||||
userId: user.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`User '${dbUser.name}' logged in at ${dayjs().format()}`);
|
||||
logger.info("User logged in", { userId: user.id, userName: dbUser.name, timestamp: dayjs().format() });
|
||||
|
||||
// We use a cookie as localStorage is not shared with server (otherwise flickering would occur)
|
||||
(await cookies()).set(colorSchemeCookieKey, dbUser.colorScheme, {
|
||||
@@ -96,7 +104,7 @@ const addUserToEveryoneGroupIfNotMemberAsync = async (db: Database, userId: stri
|
||||
userId,
|
||||
groupId: dbEveryoneGroup.id,
|
||||
});
|
||||
logger.info(`Added user to everyone group. user=${userId}`);
|
||||
logger.info("Added user to everyone group.", { userId });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -118,9 +126,10 @@ const synchronizeGroupsWithExternalForUserAsync = async (db: Database, userId: s
|
||||
);
|
||||
|
||||
if (missingExternalGroupsForUser.length > 0) {
|
||||
logger.debug(
|
||||
`Homarr does not have the user in certain groups. user=${userId} count=${missingExternalGroupsForUser.length}`,
|
||||
);
|
||||
logger.debug("Homarr does not have the user in certain groups.", {
|
||||
user: userId,
|
||||
count: missingExternalGroupsForUser.length,
|
||||
});
|
||||
|
||||
const groupIds = await db.query.groups.findMany({
|
||||
columns: {
|
||||
@@ -129,7 +138,10 @@ const synchronizeGroupsWithExternalForUserAsync = async (db: Database, userId: s
|
||||
where: inArray(groups.name, missingExternalGroupsForUser),
|
||||
});
|
||||
|
||||
logger.debug(`Homarr has found groups in the database user is not in. user=${userId} count=${groupIds.length}`);
|
||||
logger.debug("Homarr has found groups in the database user is not in.", {
|
||||
user: userId,
|
||||
count: groupIds.length,
|
||||
});
|
||||
|
||||
if (groupIds.length > 0) {
|
||||
await db.insert(groupMembers).values(
|
||||
@@ -139,9 +151,9 @@ const synchronizeGroupsWithExternalForUserAsync = async (db: Database, userId: s
|
||||
})),
|
||||
);
|
||||
|
||||
logger.info(`Added user to groups successfully. user=${userId} count=${groupIds.length}`);
|
||||
logger.info("Added user to groups successfully.", { user: userId, count: groupIds.length });
|
||||
} else {
|
||||
logger.debug(`User is already in all groups of Homarr. user=${userId}`);
|
||||
logger.debug("User is already in all groups of Homarr.", { user: userId });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,9 +166,10 @@ const synchronizeGroupsWithExternalForUserAsync = async (db: Database, userId: s
|
||||
);
|
||||
|
||||
if (groupsUserIsNoLongerMemberOfExternally.length > 0) {
|
||||
logger.debug(
|
||||
`Homarr has the user in certain groups that LDAP does not have. user=${userId} count=${groupsUserIsNoLongerMemberOfExternally.length}`,
|
||||
);
|
||||
logger.debug("Homarr has the user in certain groups that LDAP does not have.", {
|
||||
user: userId,
|
||||
count: groupsUserIsNoLongerMemberOfExternally.length,
|
||||
});
|
||||
|
||||
await db.delete(groupMembers).where(
|
||||
and(
|
||||
@@ -168,8 +181,9 @@ const synchronizeGroupsWithExternalForUserAsync = async (db: Database, userId: s
|
||||
),
|
||||
);
|
||||
|
||||
logger.info(
|
||||
`Removed user from groups successfully. user=${userId} count=${groupsUserIsNoLongerMemberOfExternally.length}`,
|
||||
);
|
||||
logger.info("Removed user from groups successfully.", {
|
||||
user: userId,
|
||||
count: groupsUserIsNoLongerMemberOfExternally.length,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -25,20 +25,18 @@
|
||||
"dependencies": {
|
||||
"@auth/core": "^0.41.1",
|
||||
"@auth/drizzle-adapter": "^1.11.1",
|
||||
"@homarr/certificates": "workspace:^0.1.0",
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/core": "workspace:^0.1.0",
|
||||
"@homarr/db": "workspace:^0.1.0",
|
||||
"@homarr/definitions": "workspace:^0.1.0",
|
||||
"@homarr/log": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"cookies": "^0.9.1",
|
||||
"ldapts": "8.0.12",
|
||||
"next": "16.0.7",
|
||||
"ldapts": "8.0.23",
|
||||
"next": "16.0.10",
|
||||
"next-auth": "5.0.0-beta.30",
|
||||
"react": "19.2.1",
|
||||
"react-dom": "19.2.1",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"zod": "^4.1.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -47,7 +45,7 @@
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/bcrypt": "6.0.0",
|
||||
"@types/cookies": "0.9.2",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint": "^9.39.2",
|
||||
"prettier": "^3.7.4",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import bcrypt from "bcrypt";
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { and, eq } from "@homarr/db";
|
||||
import { users } from "@homarr/db/schema";
|
||||
import { logger } from "@homarr/log";
|
||||
import type { userSignInSchema } from "@homarr/validation/user";
|
||||
|
||||
const logger = createLogger({ module: "basicAuthorization" });
|
||||
|
||||
export const authorizeWithBasicCredentialsAsync = async (
|
||||
db: Database,
|
||||
credentials: z.infer<typeof userSignInSchema>,
|
||||
@@ -16,19 +18,19 @@ export const authorizeWithBasicCredentialsAsync = async (
|
||||
});
|
||||
|
||||
if (!user?.password) {
|
||||
logger.info(`user ${credentials.name} was not found`);
|
||||
logger.info("User not found", { userName: credentials.name });
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.info(`user ${user.name} is trying to log in. checking password...`);
|
||||
logger.info("User is trying to log in. Checking password...", { userName: user.name });
|
||||
const isValidPassword = await bcrypt.compare(credentials.password, user.password);
|
||||
|
||||
if (!isValidPassword) {
|
||||
logger.warn(`password for user ${user.name} was incorrect`);
|
||||
logger.warn("Password for user was incorrect", { userName: user.name });
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.info(`user ${user.name} successfully authorized`);
|
||||
logger.info("User successfully authorized", { userName: user.name });
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
import { CredentialsSignin } from "@auth/core/errors";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createId, extractErrorMessage } from "@homarr/common";
|
||||
import { createId } from "@homarr/common";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import type { Database, InferInsertModel } from "@homarr/db";
|
||||
import { and, eq } from "@homarr/db";
|
||||
import { users } from "@homarr/db/schema";
|
||||
import { logger } from "@homarr/log";
|
||||
import type { ldapSignInSchema } from "@homarr/validation/user";
|
||||
|
||||
import { env } from "../../../env";
|
||||
import { LdapClient } from "../ldap-client";
|
||||
|
||||
const logger = createLogger({ module: "ldapAuthorization" });
|
||||
|
||||
export const authorizeWithLdapCredentialsAsync = async (
|
||||
db: Database,
|
||||
credentials: z.infer<typeof ldapSignInSchema>,
|
||||
) => {
|
||||
logger.info(`user ${credentials.name} is trying to log in using LDAP. Connecting to LDAP server...`);
|
||||
logger.info("User is trying to log in using LDAP. Connecting to LDAP server...", { userName: credentials.name });
|
||||
const client = new LdapClient();
|
||||
await client
|
||||
.bindAsync({
|
||||
@@ -23,8 +25,7 @@ export const authorizeWithLdapCredentialsAsync = async (
|
||||
password: env.AUTH_LDAP_BIND_PASSWORD,
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error(`Failed to connect to LDAP server ${extractErrorMessage(error)}`);
|
||||
throw new CredentialsSignin();
|
||||
throw new CredentialsSignin("Failed to connect to LDAP server", { cause: error });
|
||||
});
|
||||
|
||||
logger.info("Connected to LDAP server. Searching for user...");
|
||||
@@ -48,21 +49,21 @@ export const authorizeWithLdapCredentialsAsync = async (
|
||||
});
|
||||
|
||||
if (!ldapUser) {
|
||||
logger.warn(`User ${credentials.name} not found in LDAP`);
|
||||
throw new CredentialsSignin();
|
||||
throw new CredentialsSignin(`User not found in LDAP username="${credentials.name}"`);
|
||||
}
|
||||
|
||||
// Validate email
|
||||
const mailResult = await z.string().email().safeParseAsync(ldapUser[env.AUTH_LDAP_USER_MAIL_ATTRIBUTE]);
|
||||
|
||||
if (!mailResult.success) {
|
||||
logger.error(
|
||||
`User ${credentials.name} found but with invalid or non-existing Email. Not Supported: "${ldapUser[env.AUTH_LDAP_USER_MAIL_ATTRIBUTE]}"`,
|
||||
);
|
||||
throw new CredentialsSignin();
|
||||
logger.error("User found in LDAP but with invalid or non-existing Email", {
|
||||
userName: credentials.name,
|
||||
emailValue: ldapUser[env.AUTH_LDAP_USER_MAIL_ATTRIBUTE],
|
||||
});
|
||||
throw new CredentialsSignin("User found in LDAP but with invalid or non-existing Email");
|
||||
}
|
||||
|
||||
logger.info(`User ${credentials.name} found in LDAP. Logging in...`);
|
||||
logger.info("User found in LDAP. Logging in...", { userName: credentials.name });
|
||||
|
||||
// Bind with user credentials to check if the password is correct
|
||||
const userClient = new LdapClient();
|
||||
@@ -72,12 +73,12 @@ export const authorizeWithLdapCredentialsAsync = async (
|
||||
password: credentials.password,
|
||||
})
|
||||
.catch(() => {
|
||||
logger.warn(`Wrong credentials for user ${credentials.name}`);
|
||||
logger.warn("Wrong credentials for user", { userName: credentials.name });
|
||||
throw new CredentialsSignin();
|
||||
});
|
||||
await userClient.disconnectAsync();
|
||||
|
||||
logger.info(`User ${credentials.name} logged in successfully, retrieving user groups...`);
|
||||
logger.info("User credentials are correct. Retrieving user groups...", { userName: credentials.name });
|
||||
|
||||
const userGroups = await client
|
||||
.searchAsync({
|
||||
@@ -93,7 +94,7 @@ export const authorizeWithLdapCredentialsAsync = async (
|
||||
})
|
||||
.then((entries) => entries.map((entry) => entry.cn).filter((group): group is string => group !== undefined));
|
||||
|
||||
logger.info(`Found ${userGroups.length} groups for user ${credentials.name}.`);
|
||||
logger.info("User groups retrieved", { userName: credentials.name, groups: userGroups.length });
|
||||
|
||||
await client.disconnectAsync();
|
||||
|
||||
@@ -111,7 +112,7 @@ export const authorizeWithLdapCredentialsAsync = async (
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
logger.info(`User ${credentials.name} not found in the database. Creating...`);
|
||||
logger.info("User not found in the database. Creating...", { userName: credentials.name });
|
||||
|
||||
const insertUser = {
|
||||
id: createId(),
|
||||
@@ -126,7 +127,7 @@ export const authorizeWithLdapCredentialsAsync = async (
|
||||
|
||||
user = insertUser;
|
||||
|
||||
logger.info(`User ${credentials.name} created successfully.`);
|
||||
logger.info("User created successfully", { userName: credentials.name });
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { OIDCConfig } from "@auth/core/providers";
|
||||
import type { Profile } from "@auth/core/types";
|
||||
import { customFetch } from "next-auth";
|
||||
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
|
||||
import { env } from "../../env";
|
||||
import { createRedirectUri } from "../../redirect";
|
||||
|
||||
@@ -25,14 +25,14 @@
|
||||
"prettier": "@homarr/prettier-config",
|
||||
"dependencies": {
|
||||
"@homarr/api": "workspace:^0.1.0",
|
||||
"react": "19.2.1",
|
||||
"react-dom": "19.2.1"
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.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.39.1",
|
||||
"eslint": "^9.39.2",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import baseConfig from "@homarr/eslint-config/base";
|
||||
|
||||
/** @type {import('typescript-eslint').Config} */
|
||||
export default [
|
||||
{
|
||||
ignores: [],
|
||||
},
|
||||
...baseConfig,
|
||||
];
|
||||
@@ -1,36 +0,0 @@
|
||||
{
|
||||
"name": "@homarr/certificates",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"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",
|
||||
"@homarr/db": "workspace:^0.1.0",
|
||||
"undici": "7.16.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.39.1",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
import { X509Certificate } from "node:crypto";
|
||||
import fsSync from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import type { AgentOptions } from "node:https";
|
||||
import { Agent as HttpsAgent } from "node:https";
|
||||
import path from "node:path";
|
||||
import { checkServerIdentity, rootCertificates } from "node:tls";
|
||||
import axios from "axios";
|
||||
import type { RequestInfo, RequestInit, Response } from "undici";
|
||||
import { fetch } from "undici";
|
||||
|
||||
import { env } from "@homarr/common/env";
|
||||
import { LoggingAgent } from "@homarr/common/server";
|
||||
import type { InferSelectModel } from "@homarr/db";
|
||||
import { db } from "@homarr/db";
|
||||
import type { trustedCertificateHostnames } from "@homarr/db/schema";
|
||||
|
||||
const getCertificateFolder = () => {
|
||||
if (env.NODE_ENV !== "production") return process.env.LOCAL_CERTIFICATE_PATH;
|
||||
return process.env.LOCAL_CERTIFICATE_PATH ?? path.join("/appdata", "trusted-certificates");
|
||||
};
|
||||
|
||||
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") || file.endsWith(".pem"))
|
||||
.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 null;
|
||||
}
|
||||
|
||||
const existingFiles = await fs.readdir(folder, { withFileTypes: true });
|
||||
if (!existingFiles.some((file) => file.isFile() && file.name === fileName)) {
|
||||
throw new Error(`File ${fileName} does not exist`);
|
||||
}
|
||||
|
||||
const fullPath = path.join(folder, fileName);
|
||||
const content = await fs.readFile(fullPath, "utf8");
|
||||
|
||||
await fs.rm(fullPath);
|
||||
try {
|
||||
return new X509Certificate(content);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const addCustomRootCertificateAsync = async (fileName: string, content: string) => {
|
||||
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 getTrustedCertificateHostnamesAsync = async () => {
|
||||
return await db.query.trustedCertificateHostnames.findMany();
|
||||
};
|
||||
|
||||
export const getAllTrustedCertificatesAsync = async () => {
|
||||
const customCertificates = await loadCustomRootCertificatesAsync();
|
||||
return rootCertificates.concat(customCertificates.map((cert) => cert.content));
|
||||
};
|
||||
|
||||
export const createCustomCheckServerIdentity = (
|
||||
trustedHostnames: InferSelectModel<typeof trustedCertificateHostnames>[],
|
||||
): typeof checkServerIdentity => {
|
||||
return (hostname, peerCertificate) => {
|
||||
const matchingTrustedHostnames = trustedHostnames.filter(
|
||||
(cert) => cert.thumbprint === peerCertificate.fingerprint256,
|
||||
);
|
||||
|
||||
// We trust the certificate if we have a matching hostname
|
||||
if (matchingTrustedHostnames.some((cert) => cert.hostname === hostname)) return undefined;
|
||||
|
||||
return checkServerIdentity(hostname, peerCertificate);
|
||||
};
|
||||
};
|
||||
|
||||
export const createCertificateAgentAsync = async (override?: {
|
||||
ca: string | string[];
|
||||
checkServerIdentity: typeof checkServerIdentity;
|
||||
}) => {
|
||||
return new LoggingAgent({
|
||||
connect: override ?? {
|
||||
ca: await getAllTrustedCertificatesAsync(),
|
||||
checkServerIdentity: createCustomCheckServerIdentity(await getTrustedCertificateHostnamesAsync()),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const createHttpsAgentAsync = async (override?: Pick<AgentOptions, "ca" | "checkServerIdentity">) => {
|
||||
return new HttpsAgent(
|
||||
override ?? {
|
||||
ca: await getAllTrustedCertificatesAsync(),
|
||||
checkServerIdentity: createCustomCheckServerIdentity(await getTrustedCertificateHostnamesAsync()),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const createAxiosCertificateInstanceAsync = async (
|
||||
override?: Pick<AgentOptions, "ca" | "checkServerIdentity">,
|
||||
) => {
|
||||
return axios.create({
|
||||
httpsAgent: await createHttpsAgentAsync(override),
|
||||
});
|
||||
};
|
||||
|
||||
export const fetchWithTrustedCertificatesAsync = async (url: RequestInfo, options?: RequestInit): Promise<Response> => {
|
||||
const agent = await createCertificateAgentAsync(undefined);
|
||||
return fetch(url, {
|
||||
...options,
|
||||
dispatcher: agent,
|
||||
});
|
||||
};
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"extends": "@homarr/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": ["*.ts", "src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -35,7 +35,7 @@
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"esbuild": "^0.27.1",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint": "^9.39.2",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -24,12 +24,10 @@ export const env = createEnv({
|
||||
message: `SECRET_ENCRYPTION_KEY must only contain hex characters${errorSuffix}`,
|
||||
}),
|
||||
NO_EXTERNAL_CONNECTION: createBooleanSchema(false),
|
||||
ENABLE_DNS_CACHING: createBooleanSchema(false),
|
||||
},
|
||||
runtimeEnv: {
|
||||
SECRET_ENCRYPTION_KEY: process.env.SECRET_ENCRYPTION_KEY,
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
NO_EXTERNAL_CONNECTION: process.env.NO_EXTERNAL_CONNECTION,
|
||||
ENABLE_DNS_CACHING: process.env.ENABLE_DNS_CACHING,
|
||||
},
|
||||
});
|
||||
@@ -8,7 +8,6 @@
|
||||
".": "./index.ts",
|
||||
"./types": "./src/types.ts",
|
||||
"./server": "./src/server.ts",
|
||||
"./init-dns": "./src/dns.ts",
|
||||
"./client": "./src/client.ts",
|
||||
"./env": "./env.ts"
|
||||
},
|
||||
@@ -28,14 +27,12 @@
|
||||
"prettier": "@homarr/prettier-config",
|
||||
"dependencies": {
|
||||
"@homarr/core": "workspace:^0.1.0",
|
||||
"@homarr/log": "workspace:^0.1.0",
|
||||
"@paralleldrive/cuid2": "^3.1.0",
|
||||
"dayjs": "^1.11.19",
|
||||
"dns-caching": "^0.2.7",
|
||||
"next": "16.0.7",
|
||||
"next": "16.0.10",
|
||||
"octokit": "^5.0.5",
|
||||
"react": "19.2.1",
|
||||
"react-dom": "19.2.1",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"undici": "7.16.0",
|
||||
"zod": "^4.1.13",
|
||||
"zod-validation-error": "^5.0.0"
|
||||
@@ -44,7 +41,7 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint": "^9.39.2",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import { AxiosError } from "axios";
|
||||
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
import type { AnyRequestError } from "../request-error";
|
||||
import { RequestError } from "../request-error";
|
||||
import { ResponseError } from "../response-error";
|
||||
@@ -9,11 +7,15 @@ import { matchErrorCode } from "./fetch-http-error-handler";
|
||||
import { HttpErrorHandler } from "./http-error-handler";
|
||||
|
||||
export class AxiosHttpErrorHandler extends HttpErrorHandler {
|
||||
constructor() {
|
||||
super("axios");
|
||||
}
|
||||
|
||||
handleRequestError(error: unknown): AnyRequestError | undefined {
|
||||
if (!(error instanceof AxiosError)) return undefined;
|
||||
if (error.code === undefined) return undefined;
|
||||
|
||||
logger.debug("Received Axios request error", {
|
||||
this.logRequestError({
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
});
|
||||
@@ -28,8 +30,7 @@ export class AxiosHttpErrorHandler extends HttpErrorHandler {
|
||||
handleResponseError(error: unknown): ResponseError | undefined {
|
||||
if (!(error instanceof AxiosError)) return undefined;
|
||||
if (error.response === undefined) return undefined;
|
||||
|
||||
logger.debug("Received Axios response error", {
|
||||
this.logResponseError({
|
||||
status: error.response.status,
|
||||
url: error.response.config.url,
|
||||
message: error.message,
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
import { objectEntries } from "../../../object";
|
||||
import type { Modify } from "../../../types";
|
||||
import type { AnyRequestError, AnyRequestErrorInput, RequestErrorCode, RequestErrorReason } from "../request-error";
|
||||
@@ -9,13 +7,13 @@ import { HttpErrorHandler } from "./http-error-handler";
|
||||
|
||||
export class FetchHttpErrorHandler extends HttpErrorHandler {
|
||||
constructor(private type = "undici") {
|
||||
super();
|
||||
super(type);
|
||||
}
|
||||
|
||||
handleRequestError(error: unknown): AnyRequestError | undefined {
|
||||
if (!isTypeErrorWithCode(error)) return undefined;
|
||||
|
||||
logger.debug(`Received ${this.type} request error`, {
|
||||
this.logRequestError({
|
||||
code: error.cause.code,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,24 @@
|
||||
import type { ILogger } from "@homarr/core/infrastructure/logs";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
|
||||
import type { AnyRequestError } from "../request-error";
|
||||
import type { ResponseError } from "../response-error";
|
||||
|
||||
export abstract class HttpErrorHandler {
|
||||
protected logger: ILogger;
|
||||
|
||||
constructor(type: string) {
|
||||
this.logger = createLogger({ module: "httpErrorHandler", type });
|
||||
}
|
||||
|
||||
protected logRequestError<T extends { code: string }>(metadata: T) {
|
||||
this.logger.debug("Received request error", metadata);
|
||||
}
|
||||
|
||||
protected logResponseError<T extends { status: number; url: string | undefined }>(metadata: T) {
|
||||
this.logger.debug("Received response error", metadata);
|
||||
}
|
||||
|
||||
abstract handleRequestError(error: unknown): AnyRequestError | undefined;
|
||||
abstract handleResponseError(error: unknown): ResponseError | undefined;
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import { FetchError } from "node-fetch";
|
||||
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
import { RequestError } from "../request-error";
|
||||
import type { AnyRequestError } from "../request-error";
|
||||
import type { ResponseError } from "../response-error";
|
||||
@@ -15,14 +13,14 @@ import { HttpErrorHandler } from "./http-error-handler";
|
||||
*/
|
||||
export class NodeFetchHttpErrorHandler extends HttpErrorHandler {
|
||||
constructor(private type = "node-fetch") {
|
||||
super();
|
||||
super(type);
|
||||
}
|
||||
|
||||
handleRequestError(error: unknown): AnyRequestError | undefined {
|
||||
if (!(error instanceof FetchError)) return undefined;
|
||||
if (error.code === undefined) return undefined;
|
||||
|
||||
logger.debug(`Received ${this.type} request error`, {
|
||||
this.logRequestError({
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
});
|
||||
|
||||
@@ -5,6 +5,10 @@ import { ResponseError } from "../response-error";
|
||||
import { HttpErrorHandler } from "./http-error-handler";
|
||||
|
||||
export class OctokitHttpErrorHandler extends HttpErrorHandler {
|
||||
constructor() {
|
||||
super("octokit");
|
||||
}
|
||||
|
||||
/**
|
||||
* I wasn't able to get a request error triggered. Therefore we ignore them for now
|
||||
* and just forward them as unknown errors
|
||||
@@ -16,6 +20,11 @@ export class OctokitHttpErrorHandler extends HttpErrorHandler {
|
||||
handleResponseError(error: unknown): ResponseError | undefined {
|
||||
if (!(error instanceof OctokitRequestError)) return undefined;
|
||||
|
||||
this.logResponseError({
|
||||
status: error.status,
|
||||
url: error.response?.url,
|
||||
});
|
||||
|
||||
return new ResponseError({
|
||||
status: error.status,
|
||||
url: error.response?.url,
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { FetchError } from "ofetch";
|
||||
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
import type { AnyRequestError } from "../request-error";
|
||||
import { ResponseError } from "../response-error";
|
||||
import { FetchHttpErrorHandler } from "./fetch-http-error-handler";
|
||||
@@ -14,6 +12,10 @@ import { HttpErrorHandler } from "./http-error-handler";
|
||||
* It is for example used within the ctrl packages like qbittorrent, deluge, transmission, etc.
|
||||
*/
|
||||
export class OFetchHttpErrorHandler extends HttpErrorHandler {
|
||||
constructor() {
|
||||
super("ofetch");
|
||||
}
|
||||
|
||||
handleRequestError(error: unknown): AnyRequestError | undefined {
|
||||
if (!(error instanceof FetchError)) return undefined;
|
||||
if (!(error.cause instanceof TypeError)) return undefined;
|
||||
@@ -28,7 +30,7 @@ export class OFetchHttpErrorHandler extends HttpErrorHandler {
|
||||
if (!(error instanceof FetchError)) return undefined;
|
||||
if (error.response === undefined) return undefined;
|
||||
|
||||
logger.debug("Received ofetch response error", {
|
||||
this.logResponseError({
|
||||
status: error.response.status,
|
||||
url: error.response.url,
|
||||
});
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
import type { AnyRequestError } from "../request-error";
|
||||
import { ResponseError } from "../response-error";
|
||||
import { HttpErrorHandler } from "./http-error-handler";
|
||||
import { NodeFetchHttpErrorHandler } from "./node-fetch-http-error-handler";
|
||||
|
||||
export class TsdavHttpErrorHandler extends HttpErrorHandler {
|
||||
constructor() {
|
||||
super("tsdav");
|
||||
}
|
||||
|
||||
handleRequestError(error: unknown): AnyRequestError | undefined {
|
||||
return new NodeFetchHttpErrorHandler("tsdav").handleRequestError(error);
|
||||
}
|
||||
@@ -16,8 +18,9 @@ export class TsdavHttpErrorHandler extends HttpErrorHandler {
|
||||
// https://github.com/natelindev/tsdav/blob/bf33f04b1884694d685ee6f2b43fe9354b12d167/src/account.ts#L86
|
||||
if (error.message !== "Invalid credentials") return undefined;
|
||||
|
||||
logger.debug("Received tsdav response error", {
|
||||
this.logResponseError({
|
||||
status: 401,
|
||||
url: undefined,
|
||||
});
|
||||
|
||||
return new ResponseError({ status: 401, url: "?" });
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
import { ParseError } from "../parse-error";
|
||||
import { ParseErrorHandler } from "./parse-error-handler";
|
||||
|
||||
export class JsonParseErrorHandler extends ParseErrorHandler {
|
||||
constructor() {
|
||||
super("json");
|
||||
}
|
||||
|
||||
handleParseError(error: unknown): ParseError | undefined {
|
||||
if (!(error instanceof SyntaxError)) return undefined;
|
||||
|
||||
logger.debug("Received JSON parse error", {
|
||||
this.logParseError({
|
||||
message: error.message,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
import type { ILogger } from "@homarr/core/infrastructure/logs";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
|
||||
import type { ParseError } from "../parse-error";
|
||||
|
||||
export abstract class ParseErrorHandler {
|
||||
protected logger: ILogger;
|
||||
constructor(type: string) {
|
||||
this.logger = createLogger({ module: "parseErrorHandler", type });
|
||||
}
|
||||
|
||||
protected logParseError(metadata?: Record<string, unknown>) {
|
||||
this.logger.debug("Received parse error", metadata);
|
||||
}
|
||||
|
||||
abstract handleParseError(error: unknown): ParseError | undefined;
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { ZodError } from "zod/v4";
|
||||
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
import { ParseError } from "../parse-error";
|
||||
import { ParseErrorHandler } from "./parse-error-handler";
|
||||
|
||||
export class ZodParseErrorHandler extends ParseErrorHandler {
|
||||
constructor() {
|
||||
super("zod");
|
||||
}
|
||||
|
||||
handleParseError(error: unknown): ParseError | undefined {
|
||||
if (!(error instanceof ZodError)) return undefined;
|
||||
|
||||
@@ -17,7 +19,7 @@ export class ZodParseErrorHandler extends ParseErrorHandler {
|
||||
prefix: null,
|
||||
}).toString();
|
||||
|
||||
logger.debug("Received Zod parse error");
|
||||
this.logParseError();
|
||||
|
||||
return new ParseError(message, { cause: error });
|
||||
}
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import type { Dispatcher } from "undici";
|
||||
import { Agent } from "undici";
|
||||
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
// The below import statement initializes dns-caching
|
||||
import "./dns";
|
||||
|
||||
export class LoggingAgent extends Agent {
|
||||
constructor(...props: ConstructorParameters<typeof Agent>) {
|
||||
super(...props);
|
||||
}
|
||||
|
||||
dispatch(options: Dispatcher.DispatchOptions, handler: Dispatcher.DispatchHandler): boolean {
|
||||
const path = options.path
|
||||
.split("/")
|
||||
.map((segment) => (segment.length >= 32 && !segment.startsWith("?") ? "REDACTED" : segment))
|
||||
.join("/");
|
||||
const url = new URL(`${options.origin as string}${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.debug(
|
||||
`Dispatching request ${url.toString().replaceAll("=&", "&")} (${Object.keys(options.headers ?? {}).length} headers)`,
|
||||
);
|
||||
return super.dispatch(options, handler);
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
/**
|
||||
* Same as fetch, but with a timeout of 10 seconds.
|
||||
* https://stackoverflow.com/questions/46946380/fetch-api-request-timeout
|
||||
* @param param0 fetch arguments
|
||||
* @returns fetch response
|
||||
*/
|
||||
export const fetchWithTimeout = (...[url, requestInit]: Parameters<typeof fetch>) => {
|
||||
const controller = new AbortController();
|
||||
|
||||
// 10 seconds timeout:
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000);
|
||||
|
||||
return fetch(url, { signal: controller.signal, ...requestInit }).finally(() => {
|
||||
clearTimeout(timeoutId);
|
||||
});
|
||||
};
|
||||
@@ -9,7 +9,6 @@ export * from "./id";
|
||||
export * from "./url";
|
||||
export * from "./number";
|
||||
export * from "./error";
|
||||
export * from "./fetch-with-timeout";
|
||||
export * from "./theme";
|
||||
export * from "./function";
|
||||
export * from "./id";
|
||||
@@ -1,5 +1,4 @@
|
||||
export * from "./security";
|
||||
export * from "./encryption";
|
||||
export * from "./user-agent";
|
||||
export * from "./fetch-agent";
|
||||
export * from "./errors";
|
||||
@@ -7,7 +7,20 @@
|
||||
"exports": {
|
||||
"./infrastructure/redis": "./src/infrastructure/redis/client.ts",
|
||||
"./infrastructure/env": "./src/infrastructure/env/index.ts",
|
||||
".": "./src/index.ts"
|
||||
"./infrastructure/logs": "./src/infrastructure/logs/index.ts",
|
||||
"./infrastructure/logs/constants": "./src/infrastructure/logs/constants.ts",
|
||||
"./infrastructure/logs/env": "./src/infrastructure/logs/env.ts",
|
||||
"./infrastructure/logs/error": "./src/infrastructure/logs/error.ts",
|
||||
"./infrastructure/db": "./src/infrastructure/db/index.ts",
|
||||
"./infrastructure/db/env": "./src/infrastructure/db/env.ts",
|
||||
"./infrastructure/db/constants": "./src/infrastructure/db/constants.ts",
|
||||
"./infrastructure/certificates": "./src/infrastructure/certificates/index.ts",
|
||||
"./infrastructure/certificates/hostnames/db/sqlite": "./src/infrastructure/certificates/hostnames/db/sqlite.ts",
|
||||
"./infrastructure/certificates/hostnames/db/mysql": "./src/infrastructure/certificates/hostnames/db/mysql.ts",
|
||||
"./infrastructure/certificates/hostnames/db/postgresql": "./src/infrastructure/certificates/hostnames/db/postgresql.ts",
|
||||
"./infrastructure/dns/init": "./src/infrastructure/dns/init.ts",
|
||||
"./infrastructure/http": "./src/infrastructure/http/index.ts",
|
||||
"./infrastructure/http/timeout": "./src/infrastructure/http/timeout.ts"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
@@ -25,14 +38,23 @@
|
||||
"prettier": "@homarr/prettier-config",
|
||||
"dependencies": {
|
||||
"@t3-oss/env-nextjs": "^0.13.8",
|
||||
"better-sqlite3": "^12.5.0",
|
||||
"dns-caching": "^0.2.9",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"ioredis": "5.8.2",
|
||||
"mysql2": "3.15.3",
|
||||
"pg": "^8.16.3",
|
||||
"superjson": "2.2.6",
|
||||
"winston": "3.19.0",
|
||||
"zod": "^4.1.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.39.1",
|
||||
"@types/better-sqlite3": "7.6.13",
|
||||
"@types/pg": "^8.16.0",
|
||||
"eslint": "^9.39.2",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { X509Certificate } from "node:crypto";
|
||||
import fsSync from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { rootCertificates } from "node:tls";
|
||||
|
||||
const getCertificateFolder = () => {
|
||||
if (process.env.NODE_ENV !== "production") return process.env.LOCAL_CERTIFICATE_PATH;
|
||||
return process.env.LOCAL_CERTIFICATE_PATH ?? path.join("/appdata", "trusted-certificates");
|
||||
};
|
||||
|
||||
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") || file.endsWith(".pem"))
|
||||
.map(async (file) => ({
|
||||
content: await fs.readFile(path.join(folder, file), "utf8"),
|
||||
fileName: file,
|
||||
})),
|
||||
);
|
||||
};
|
||||
|
||||
export const getAllTrustedCertificatesAsync = async () => {
|
||||
const customCertificates = await loadCustomRootCertificatesAsync();
|
||||
return rootCertificates.concat(customCertificates.map((cert) => cert.content));
|
||||
};
|
||||
|
||||
export const removeCustomRootCertificateAsync = async (fileName: string) => {
|
||||
const folder = getCertificateFolder();
|
||||
if (!folder) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const existingFiles = await fs.readdir(folder, { withFileTypes: true });
|
||||
if (!existingFiles.some((file) => file.isFile() && file.name === fileName)) {
|
||||
throw new Error(`File ${fileName} does not exist`);
|
||||
}
|
||||
|
||||
const fullPath = path.join(folder, fileName);
|
||||
const content = await fs.readFile(fullPath, "utf8");
|
||||
|
||||
await fs.rm(fullPath);
|
||||
try {
|
||||
return new X509Certificate(content);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const addCustomRootCertificateAsync = async (fileName: string, content: string) => {
|
||||
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);
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import { mysqlTable, primaryKey, text, varchar } from "drizzle-orm/mysql-core";
|
||||
|
||||
export const trustedCertificateHostnames = mysqlTable(
|
||||
"trusted_certificate_hostname",
|
||||
{
|
||||
hostname: varchar({ length: 256 }).notNull(),
|
||||
thumbprint: varchar({ length: 128 }).notNull(),
|
||||
certificate: text().notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
compoundKey: primaryKey({
|
||||
columns: [table.hostname, table.thumbprint],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,15 @@
|
||||
import { pgTable, primaryKey, text, varchar } from "drizzle-orm/pg-core";
|
||||
|
||||
export const trustedCertificateHostnames = pgTable(
|
||||
"trusted_certificate_hostname",
|
||||
{
|
||||
hostname: varchar({ length: 256 }).notNull(),
|
||||
thumbprint: varchar({ length: 128 }).notNull(),
|
||||
certificate: text().notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
compoundKey: primaryKey({
|
||||
columns: [table.hostname, table.thumbprint],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createSchema } from "../../../db";
|
||||
import * as mysql from "./mysql";
|
||||
import * as postgresql from "./postgresql";
|
||||
import * as sqlite from "./sqlite";
|
||||
|
||||
export const schema = createSchema({
|
||||
"better-sqlite3": () => sqlite,
|
||||
mysql2: () => mysql,
|
||||
"node-postgres": () => postgresql,
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
|
||||
export const trustedCertificateHostnames = sqliteTable(
|
||||
"trusted_certificate_hostname",
|
||||
{
|
||||
hostname: text().notNull(),
|
||||
thumbprint: text().notNull(),
|
||||
certificate: text().notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
compoundKey: primaryKey({
|
||||
columns: [table.hostname, table.thumbprint],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { InferSelectModel } from "drizzle-orm";
|
||||
|
||||
import { createDb } from "../../db";
|
||||
import { schema } from "./db/schema";
|
||||
|
||||
const db = createDb(schema);
|
||||
|
||||
export const getTrustedCertificateHostnamesAsync = async () => {
|
||||
return await db.query.trustedCertificateHostnames.findMany();
|
||||
};
|
||||
|
||||
export type TrustedCertificateHostname = InferSelectModel<typeof schema.trustedCertificateHostnames>;
|
||||
@@ -0,0 +1,7 @@
|
||||
export { getTrustedCertificateHostnamesAsync } from "./hostnames";
|
||||
export {
|
||||
addCustomRootCertificateAsync,
|
||||
removeCustomRootCertificateAsync,
|
||||
getAllTrustedCertificatesAsync,
|
||||
loadCustomRootCertificatesAsync,
|
||||
} from "./files";
|
||||
@@ -0,0 +1,3 @@
|
||||
import type { Casing } from "drizzle-orm";
|
||||
|
||||
export const DB_CASING: Casing = "snake_case";
|
||||
@@ -0,0 +1,27 @@
|
||||
import { DB_CASING } from "../constants";
|
||||
import { createDbMapping } from "../mapping";
|
||||
import { createMysqlDb } from "./mysql";
|
||||
import { createPostgresDb } from "./postgresql";
|
||||
import type { SharedDrizzleConfig } from "./shared";
|
||||
import { WinstonDrizzleLogger } from "./shared";
|
||||
import { createSqliteDb } from "./sqlite";
|
||||
|
||||
export type Database<TSchema extends Record<string, unknown>> = ReturnType<typeof createSqliteDb<TSchema>>;
|
||||
|
||||
export const createSharedConfig = <TSchema extends Record<string, unknown>>(
|
||||
schema: TSchema,
|
||||
): SharedDrizzleConfig<TSchema> => ({
|
||||
logger: new WinstonDrizzleLogger(),
|
||||
casing: DB_CASING,
|
||||
schema,
|
||||
});
|
||||
|
||||
export const createDb = <TSchema extends Record<string, unknown>>(schema: TSchema) => {
|
||||
const config = createSharedConfig(schema);
|
||||
|
||||
return createDbMapping({
|
||||
mysql2: () => createMysqlDb(config),
|
||||
"node-postgres": () => createPostgresDb(config),
|
||||
"better-sqlite3": () => createSqliteDb(config),
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import { drizzle } from "drizzle-orm/mysql2";
|
||||
import mysql from "mysql2";
|
||||
import type { PoolOptions } from "mysql2";
|
||||
|
||||
import { dbEnv } from "../env";
|
||||
import type { SharedDrizzleConfig } from "./shared";
|
||||
|
||||
export const createMysqlDb = <TSchema extends Record<string, unknown>>(config: SharedDrizzleConfig<TSchema>) => {
|
||||
const connection = createMysqlDbConnection();
|
||||
return drizzle<TSchema>(connection, {
|
||||
...config,
|
||||
mode: "default",
|
||||
});
|
||||
};
|
||||
|
||||
const createMysqlDbConnection = () => {
|
||||
const defaultOptions = {
|
||||
maxIdle: 0,
|
||||
idleTimeout: 60000,
|
||||
enableKeepAlive: true,
|
||||
} satisfies Partial<PoolOptions>;
|
||||
|
||||
if (!dbEnv.HOST) {
|
||||
return mysql.createPool({ ...defaultOptions, uri: dbEnv.URL });
|
||||
}
|
||||
|
||||
return mysql.createPool({
|
||||
...defaultOptions,
|
||||
port: dbEnv.PORT,
|
||||
user: dbEnv.USER,
|
||||
password: dbEnv.PASSWORD,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import { drizzle as drizzlePostgres } from "drizzle-orm/node-postgres";
|
||||
import type { PoolOptions as PostgresPoolOptions } from "pg";
|
||||
import { Pool as PostgresPool } from "pg";
|
||||
|
||||
import { dbEnv } from "../env";
|
||||
import type { SharedDrizzleConfig } from "./shared";
|
||||
|
||||
export const createPostgresDb = <TSchema extends Record<string, unknown>>(config: SharedDrizzleConfig<TSchema>) => {
|
||||
const connection = createPostgresDbConnection();
|
||||
return drizzlePostgres({
|
||||
...config,
|
||||
client: connection,
|
||||
});
|
||||
};
|
||||
|
||||
const createPostgresDbConnection = () => {
|
||||
const defaultOptions = {
|
||||
max: 0,
|
||||
idleTimeoutMillis: 60000,
|
||||
allowExitOnIdle: false,
|
||||
} satisfies Partial<PostgresPoolOptions>;
|
||||
|
||||
if (!dbEnv.HOST) {
|
||||
return new PostgresPool({
|
||||
...defaultOptions,
|
||||
connectionString: dbEnv.URL,
|
||||
});
|
||||
}
|
||||
|
||||
return new PostgresPool({
|
||||
...defaultOptions,
|
||||
host: dbEnv.HOST,
|
||||
port: dbEnv.PORT,
|
||||
database: dbEnv.NAME,
|
||||
user: dbEnv.USER,
|
||||
password: dbEnv.PASSWORD,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { DrizzleConfig, Logger } from "drizzle-orm";
|
||||
|
||||
import { createLogger } from "../../logs";
|
||||
|
||||
export type SharedDrizzleConfig<TSchema extends Record<string, unknown>> = Required<
|
||||
Pick<DrizzleConfig<TSchema>, "logger" | "casing" | "schema">
|
||||
>;
|
||||
|
||||
const logger = createLogger({ module: "db" });
|
||||
|
||||
export class WinstonDrizzleLogger implements Logger {
|
||||
logQuery(query: string, _: unknown[]): void {
|
||||
logger.debug("Executed SQL query", { query });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import Database from "better-sqlite3";
|
||||
import { drizzle as drizzleSqlite } from "drizzle-orm/better-sqlite3";
|
||||
|
||||
import { dbEnv } from "../env";
|
||||
import type { SharedDrizzleConfig } from "./shared";
|
||||
|
||||
export const createSqliteDb = <TSchema extends Record<string, unknown>>(config: SharedDrizzleConfig<TSchema>) => {
|
||||
const connection = new Database(dbEnv.URL);
|
||||
return drizzleSqlite<TSchema>(connection, config);
|
||||
};
|
||||
@@ -1,7 +1,6 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { env as commonEnv } from "@homarr/common/env";
|
||||
import { createEnv } from "@homarr/core/infrastructure/env";
|
||||
import { createEnv, runtimeEnvWithPrefix } from "@homarr/core/infrastructure/env";
|
||||
|
||||
const drivers = {
|
||||
betterSqlite3: "better-sqlite3",
|
||||
@@ -15,40 +14,40 @@ const onlyAllowUrl = isDriver(drivers.betterSqlite3);
|
||||
const urlRequired = onlyAllowUrl || !isUsingDbHost;
|
||||
const hostRequired = isUsingDbHost && !onlyAllowUrl;
|
||||
|
||||
export const env = createEnv({
|
||||
export const dbEnv = createEnv({
|
||||
/**
|
||||
* Specify your server-side environment variables schema here. This way you can ensure the app isn't
|
||||
* built with invalid env vars.
|
||||
*/
|
||||
server: {
|
||||
DB_DRIVER: z
|
||||
DRIVER: z
|
||||
.union([z.literal(drivers.betterSqlite3), z.literal(drivers.mysql2), z.literal(drivers.nodePostgres)], {
|
||||
message: `Invalid database driver, supported are ${Object.keys(drivers).join(", ")}`,
|
||||
})
|
||||
.default(drivers.betterSqlite3),
|
||||
...(urlRequired
|
||||
? {
|
||||
DB_URL:
|
||||
URL:
|
||||
// Fallback to the default sqlite file path in production
|
||||
commonEnv.NODE_ENV === "production" && isDriver("better-sqlite3")
|
||||
process.env.NODE_ENV === "production" && isDriver("better-sqlite3")
|
||||
? z.string().default("/appdata/db/db.sqlite")
|
||||
: z.string().nonempty(),
|
||||
}
|
||||
: {}),
|
||||
...(hostRequired
|
||||
? {
|
||||
DB_HOST: z.string(),
|
||||
DB_PORT: z
|
||||
HOST: z.string(),
|
||||
PORT: z
|
||||
.string()
|
||||
.regex(/\d+/)
|
||||
.transform(Number)
|
||||
.refine((number) => number >= 1)
|
||||
.default(isDriver(drivers.mysql2) ? 3306 : 5432),
|
||||
DB_USER: z.string(),
|
||||
DB_PASSWORD: z.string(),
|
||||
DB_NAME: z.string(),
|
||||
USER: z.string(),
|
||||
PASSWORD: z.string(),
|
||||
NAME: z.string(),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
experimental__runtimeEnv: process.env,
|
||||
runtimeEnv: runtimeEnvWithPrefix("DB_"),
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createDbMapping } from "./mapping";
|
||||
|
||||
export { createDb } from "./drivers";
|
||||
export const createSchema = createDbMapping;
|
||||
|
||||
export { createMysqlDb } from "./drivers/mysql";
|
||||
export { createSqliteDb } from "./drivers/sqlite";
|
||||
export { createPostgresDb } from "./drivers/postgresql";
|
||||
export { createSharedConfig as createSharedDbConfig } from "./drivers";
|
||||
@@ -0,0 +1,9 @@
|
||||
import { dbEnv } from "./env";
|
||||
|
||||
type DbMappingInput = Record<typeof dbEnv.DRIVER, () => unknown>;
|
||||
|
||||
export const createDbMapping = <TInput extends DbMappingInput>(input: TInput) => {
|
||||
// The DRIVER can be undefined when validation of env vars is skipped
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
return input[dbEnv.DRIVER ?? "better-sqlite3"]() as ReturnType<TInput["better-sqlite3"]>;
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import { createBooleanSchema, createEnv } from "../env";
|
||||
|
||||
export const dnsEnv = createEnv({
|
||||
server: {
|
||||
ENABLE_DNS_CACHING: createBooleanSchema(false),
|
||||
},
|
||||
experimental__runtimeEnv: process.env,
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
import { DnsCacheManager } from "dns-caching";
|
||||
|
||||
import { logger } from "@homarr/log";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
|
||||
import { env } from "../env";
|
||||
import { dnsEnv } from "./env";
|
||||
|
||||
// Add global type augmentation for homarr
|
||||
declare global {
|
||||
@@ -12,6 +12,8 @@ declare global {
|
||||
};
|
||||
}
|
||||
|
||||
const logger = createLogger({ module: "dns" });
|
||||
|
||||
// Initialize global.homarr if not present
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
global.homarr ??= {};
|
||||
@@ -21,6 +23,6 @@ global.homarr.dnsCacheManager ??= new DnsCacheManager({
|
||||
logger,
|
||||
});
|
||||
|
||||
if (env.ENABLE_DNS_CACHING) {
|
||||
if (dnsEnv.ENABLE_DNS_CACHING) {
|
||||
global.homarr.dnsCacheManager.initialize();
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { Dispatcher } from "undici";
|
||||
import { Agent } from "undici";
|
||||
|
||||
import type { ILogger } from "@homarr/core/infrastructure/logs";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
|
||||
// The below import statement initializes dns-caching
|
||||
import "@homarr/core/infrastructure/dns/init";
|
||||
|
||||
interface HttpAgentOptions extends Agent.Options {
|
||||
logger?: ILogger;
|
||||
}
|
||||
|
||||
export class UndiciHttpAgent extends Agent {
|
||||
private logger: ILogger;
|
||||
|
||||
constructor(props?: HttpAgentOptions) {
|
||||
super(props);
|
||||
|
||||
this.logger = props?.logger ?? createLogger({ module: "httpAgent" });
|
||||
}
|
||||
|
||||
dispatch(options: Dispatcher.DispatchOptions, handler: Dispatcher.DispatchHandler): boolean {
|
||||
this.logRequestDispatch(options);
|
||||
return super.dispatch(options, handler);
|
||||
}
|
||||
|
||||
private logRequestDispatch(options: Dispatcher.DispatchOptions) {
|
||||
const path = this.redactPathParams(options.path);
|
||||
let url = new URL(`${options.origin as string}${path}`);
|
||||
url = this.redactSearchParams(url);
|
||||
|
||||
this.logger.debug(
|
||||
`Dispatching request ${url.toString().replaceAll("=&", "&")} (${Object.keys(options.headers ?? {}).length} headers)`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Redact path parameters that are longer than 32 characters
|
||||
* This is to prevent sensitive data from being logged
|
||||
* @param path path of the request
|
||||
* @returns redacted path
|
||||
*/
|
||||
private redactPathParams(path: string): string {
|
||||
return path
|
||||
.split("/")
|
||||
.map((segment) => (segment.length >= 32 && !segment.startsWith("?") ? "REDACTED" : segment))
|
||||
.join("/");
|
||||
}
|
||||
|
||||
/**
|
||||
* Redact sensitive search parameters from the URL.
|
||||
* It allows certain patterns to remain unredacted.
|
||||
* Like small numbers, booleans, short strings, dates, and date-times.
|
||||
* Some integrations use query parameters for auth.
|
||||
* @param url URL object of the request
|
||||
* @returns redacted URL object
|
||||
*/
|
||||
private redactSearchParams(url: URL): URL {
|
||||
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");
|
||||
});
|
||||
return url;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export { UndiciHttpAgent } from "./http-agent";
|
||||
export {
|
||||
createAxiosCertificateInstanceAsync,
|
||||
createCertificateAgentAsync,
|
||||
createCustomCheckServerIdentity,
|
||||
createHttpsAgentAsync,
|
||||
fetchWithTrustedCertificatesAsync,
|
||||
} from "./request";
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { AgentOptions } from "node:https";
|
||||
import { Agent as HttpsAgent } from "node:https";
|
||||
import { checkServerIdentity } from "node:tls";
|
||||
import axios from "axios";
|
||||
import type { RequestInfo, RequestInit, Response } from "undici";
|
||||
import { fetch } from "undici";
|
||||
|
||||
import {
|
||||
getAllTrustedCertificatesAsync,
|
||||
getTrustedCertificateHostnamesAsync,
|
||||
} from "@homarr/core/infrastructure/certificates";
|
||||
import { UndiciHttpAgent } from "@homarr/core/infrastructure/http";
|
||||
|
||||
import type { TrustedCertificateHostname } from "../certificates/hostnames";
|
||||
import { withTimeoutAsync } from "./timeout";
|
||||
|
||||
export const createCustomCheckServerIdentity = (
|
||||
trustedHostnames: TrustedCertificateHostname[],
|
||||
): typeof checkServerIdentity => {
|
||||
return (hostname, peerCertificate) => {
|
||||
const matchingTrustedHostnames = trustedHostnames.filter(
|
||||
(cert) => cert.thumbprint === peerCertificate.fingerprint256,
|
||||
);
|
||||
|
||||
// We trust the certificate if we have a matching hostname
|
||||
if (matchingTrustedHostnames.some((cert) => cert.hostname === hostname)) return undefined;
|
||||
|
||||
return checkServerIdentity(hostname, peerCertificate);
|
||||
};
|
||||
};
|
||||
|
||||
export const createCertificateAgentAsync = async (override?: {
|
||||
ca: string | string[];
|
||||
checkServerIdentity: typeof checkServerIdentity;
|
||||
}) => {
|
||||
return new UndiciHttpAgent({
|
||||
connect: override ?? {
|
||||
ca: await getAllTrustedCertificatesAsync(),
|
||||
checkServerIdentity: createCustomCheckServerIdentity(await getTrustedCertificateHostnamesAsync()),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const createHttpsAgentAsync = async (override?: Pick<AgentOptions, "ca" | "checkServerIdentity">) => {
|
||||
return new HttpsAgent(
|
||||
override ?? {
|
||||
ca: await getAllTrustedCertificatesAsync(),
|
||||
checkServerIdentity: createCustomCheckServerIdentity(await getTrustedCertificateHostnamesAsync()),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const createAxiosCertificateInstanceAsync = async (
|
||||
override?: Pick<AgentOptions, "ca" | "checkServerIdentity">,
|
||||
) => {
|
||||
return axios.create({
|
||||
httpsAgent: await createHttpsAgentAsync(override),
|
||||
});
|
||||
};
|
||||
|
||||
export const fetchWithTrustedCertificatesAsync = async (
|
||||
url: RequestInfo,
|
||||
options?: RequestInit & { timeout?: number },
|
||||
): Promise<Response> => {
|
||||
const agent = await createCertificateAgentAsync(undefined);
|
||||
if (options?.timeout) {
|
||||
return await withTimeoutAsync(
|
||||
async (signal) =>
|
||||
fetch(url, {
|
||||
...options,
|
||||
signal,
|
||||
dispatcher: agent,
|
||||
}),
|
||||
options.timeout,
|
||||
);
|
||||
}
|
||||
|
||||
return fetch(url, {
|
||||
...options,
|
||||
dispatcher: agent,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Response as UndiciResponse } from "undici";
|
||||
|
||||
// https://stackoverflow.com/questions/46946380/fetch-api-request-timeout
|
||||
export const withTimeoutAsync = async <TResponse extends Response | UndiciResponse>(
|
||||
callback: (signal: AbortSignal) => Promise<TResponse>,
|
||||
timeout = 10000,
|
||||
) => {
|
||||
const controller = new AbortController();
|
||||
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||
|
||||
return await callback(controller.signal).finally(() => {
|
||||
clearTimeout(timeoutId);
|
||||
});
|
||||
};
|
||||
|
||||
export const fetchWithTimeoutAsync = async (...[url, requestInit]: Parameters<typeof fetch>) => {
|
||||
return await withTimeoutAsync((signal) => fetch(url, { ...requestInit, signal }));
|
||||
};
|
||||
File renamed without changes.
@@ -0,0 +1,11 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createEnv, runtimeEnvWithPrefix } from "../env";
|
||||
import { logLevels } from "./constants";
|
||||
|
||||
export const logsEnv = createEnv({
|
||||
server: {
|
||||
LEVEL: z.enum(logLevels).default("info"),
|
||||
},
|
||||
runtimeEnv: runtimeEnvWithPrefix("LOG_"),
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
export class ErrorWithMetadata extends Error {
|
||||
public metadata: Record<string, unknown>;
|
||||
|
||||
constructor(message: string, metadata: Record<string, unknown> = {}, options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
this.name = "Error";
|
||||
this.metadata = metadata;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
import { logsEnv } from "../env";
|
||||
import { formatMetadata } from "./metadata";
|
||||
|
||||
const ERROR_OBJECT_PRUNE_DEPTH = logsEnv.LEVEL === "debug" ? 10 : 3;
|
||||
const ERROR_STACK_LINE_LIMIT = logsEnv.LEVEL === "debug" ? undefined : 5;
|
||||
const ERROR_CAUSE_DEPTH = logsEnv.LEVEL === "debug" ? 10 : 5;
|
||||
|
||||
/**
|
||||
* Formats the cause of an error in the format
|
||||
* @example caused by Error: {message}
|
||||
@@ -10,7 +15,7 @@ import { formatMetadata } from "./metadata";
|
||||
*/
|
||||
export const formatErrorCause = (cause: unknown, iteration = 0): string => {
|
||||
// Prevent infinite recursion
|
||||
if (iteration > 5) {
|
||||
if (iteration > ERROR_CAUSE_DEPTH) {
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -22,8 +27,12 @@ export const formatErrorCause = (cause: unknown, iteration = 0): string => {
|
||||
return `\ncaused by ${formatErrorTitle(cause)}\n${formatErrorStack(cause.stack)}${formatErrorCause(cause.cause, iteration + 1)}`;
|
||||
}
|
||||
|
||||
if (cause instanceof Object) {
|
||||
return `\ncaused by ${JSON.stringify(cause)}`;
|
||||
if (typeof cause === "object" && cause !== null) {
|
||||
if ("cause" in cause) {
|
||||
const { cause: innerCause, ...rest } = cause;
|
||||
return `\ncaused by ${JSON.stringify(prune(rest, ERROR_OBJECT_PRUNE_DEPTH))}${formatErrorCause(innerCause, iteration + 1)}`;
|
||||
}
|
||||
return `\ncaused by ${JSON.stringify(prune(cause, ERROR_OBJECT_PRUNE_DEPTH))}`;
|
||||
}
|
||||
|
||||
return `\ncaused by ${cause as string}`;
|
||||
@@ -50,5 +59,28 @@ export const formatErrorTitle = (error: Error) => {
|
||||
* @param stack stack trace
|
||||
* @returns formatted stack trace
|
||||
*/
|
||||
export const formatErrorStack = (stack: string | undefined) => (stack ? removeFirstLine(stack) : "");
|
||||
const removeFirstLine = (stack: string) => stack.split("\n").slice(1).join("\n");
|
||||
export const formatErrorStack = (stack: string | undefined) =>
|
||||
stack
|
||||
?.split("\n")
|
||||
.slice(1, ERROR_STACK_LINE_LIMIT ? ERROR_STACK_LINE_LIMIT + 1 : undefined)
|
||||
.join("\n") ?? "";
|
||||
|
||||
/**
|
||||
* Removes nested properties from an object beyond a certain depth
|
||||
*/
|
||||
const prune = (value: unknown, depth: number): unknown => {
|
||||
if (typeof value !== "object" || value === null) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
if (depth === 0) return [];
|
||||
return value.map((item) => prune(item, depth - 1));
|
||||
}
|
||||
|
||||
if (depth === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return Object.fromEntries(Object.entries(value).map(([key, val]) => [key, prune(val, depth - 1)]));
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import { format } from "winston";
|
||||
|
||||
import { formatErrorCause, formatErrorStack } from "./error";
|
||||
import { formatMetadata } from "./metadata";
|
||||
|
||||
export const logFormat = format.combine(
|
||||
format.colorize(),
|
||||
format.timestamp(),
|
||||
format.errors({ stack: true, cause: true }),
|
||||
format.printf(({ level, message, timestamp, cause, stack, ...metadata }) => {
|
||||
const firstLine = `${timestamp as string} ${level}: ${message as string} ${formatMetadata(metadata)}`;
|
||||
|
||||
if (!cause && !stack) {
|
||||
return firstLine;
|
||||
}
|
||||
|
||||
const formatedStack = formatErrorStack(stack as string | undefined);
|
||||
|
||||
if (!cause) {
|
||||
return `${firstLine}\n${formatedStack}`;
|
||||
}
|
||||
|
||||
return `${firstLine}\n${formatedStack}${formatErrorCause(cause)}`;
|
||||
}),
|
||||
);
|
||||
+6
-2
@@ -1,7 +1,11 @@
|
||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||
|
||||
export const formatMetadata = (metadata: Record<string, unknown> | Error, ignoreKeys?: string[]) => {
|
||||
const filteredMetadata = Object.keys(metadata)
|
||||
const metadataObject = metadata instanceof ErrorWithMetadata ? metadata.metadata : metadata;
|
||||
|
||||
const filteredMetadata = Object.keys(metadataObject)
|
||||
.filter((key) => !ignoreKeys?.includes(key))
|
||||
.map((key) => ({ key, value: metadata[key as keyof typeof metadata] }))
|
||||
.map((key) => ({ key, value: metadataObject[key as keyof typeof metadataObject] }))
|
||||
.filter(({ value }) => typeof value !== "object" && typeof value !== "function");
|
||||
|
||||
return filteredMetadata.map(({ key, value }) => `${key}="${value as string}"`).join(" ");
|
||||
@@ -0,0 +1,26 @@
|
||||
import winston from "winston";
|
||||
|
||||
import { logsEnv } from "./env";
|
||||
import { logFormat } from "./format";
|
||||
import { logTransports } from "./transports";
|
||||
|
||||
const logger = winston.createLogger({
|
||||
format: logFormat,
|
||||
transports: logTransports,
|
||||
level: logsEnv.LEVEL,
|
||||
});
|
||||
|
||||
interface DefaultMetadata {
|
||||
module: string;
|
||||
}
|
||||
|
||||
export const createLogger = (metadata: DefaultMetadata & Record<string, unknown>) => logger.child(metadata);
|
||||
|
||||
type LogMethod = ((message: string, metadata?: Record<string, unknown>) => void) | ((error: unknown) => void);
|
||||
|
||||
export interface ILogger {
|
||||
debug: LogMethod;
|
||||
info: LogMethod;
|
||||
warn: LogMethod;
|
||||
error: LogMethod;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { transports } from "winston";
|
||||
import type { transport } from "winston";
|
||||
|
||||
import { RedisTransport } from "./redis-transport";
|
||||
|
||||
const getTransports = () => {
|
||||
const defaultTransports: transport[] = [new transports.Console()];
|
||||
|
||||
// Only add the Redis transport if we are not in CI
|
||||
if (!(Boolean(process.env.CI) || Boolean(process.env.DISABLE_REDIS_LOGS))) {
|
||||
return defaultTransports.concat(
|
||||
new RedisTransport({
|
||||
level: "debug",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return defaultTransports;
|
||||
};
|
||||
|
||||
export const logTransports = getTransports();
|
||||
+4
-3
@@ -1,8 +1,8 @@
|
||||
import superjson from "superjson";
|
||||
import Transport from "winston-transport";
|
||||
|
||||
import type { RedisClient } from "@homarr/core/infrastructure/redis";
|
||||
import { createRedisClient } from "@homarr/core/infrastructure/redis";
|
||||
import type { RedisClient } from "../../redis/client";
|
||||
import { createRedisClient } from "../../redis/client";
|
||||
|
||||
const messageSymbol = Symbol.for("message");
|
||||
const levelSymbol = Symbol.for("level");
|
||||
@@ -13,6 +13,7 @@ const levelSymbol = Symbol.for("level");
|
||||
//
|
||||
export class RedisTransport extends Transport {
|
||||
private redis: RedisClient | null = null;
|
||||
public static readonly publishChannel = "pubSub:logging";
|
||||
|
||||
/**
|
||||
* Log the info to the Redis channel
|
||||
@@ -27,7 +28,7 @@ export class RedisTransport extends Transport {
|
||||
|
||||
this.redis
|
||||
.publish(
|
||||
"pubSub:logging",
|
||||
RedisTransport.publishChannel,
|
||||
superjson.stringify({
|
||||
message: info[messageSymbol],
|
||||
level: info[levelSymbol],
|
||||
+18
-15
@@ -1,9 +1,9 @@
|
||||
import type { Dispatcher } from "undici";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { logger } from "@homarr/log";
|
||||
import { UndiciHttpAgent } from "@homarr/core/infrastructure/http";
|
||||
|
||||
import { LoggingAgent } from "../fetch-agent";
|
||||
import { TestLogger } from "../logs";
|
||||
|
||||
vi.mock("undici", () => {
|
||||
return {
|
||||
@@ -18,23 +18,26 @@ vi.mock("undici", () => {
|
||||
|
||||
const REDACTED = "REDACTED";
|
||||
|
||||
describe("LoggingAgent should log all requests", () => {
|
||||
describe("UndiciHttpAgent should log all requests", () => {
|
||||
test("should log all requests", () => {
|
||||
// Arrange
|
||||
const infoLogSpy = vi.spyOn(logger, "debug");
|
||||
const agent = new LoggingAgent();
|
||||
const logger = new TestLogger();
|
||||
const agent = new UndiciHttpAgent({ logger });
|
||||
|
||||
// Act
|
||||
agent.dispatch({ origin: "https://homarr.dev", path: "/", method: "GET" }, {});
|
||||
|
||||
// Assert
|
||||
expect(infoLogSpy).toHaveBeenCalledWith("Dispatching request https://homarr.dev/ (0 headers)");
|
||||
expect(logger.messages).toContainEqual({
|
||||
level: "debug",
|
||||
message: "Dispatching request https://homarr.dev/ (0 headers)",
|
||||
});
|
||||
});
|
||||
|
||||
test("should show amount of headers", () => {
|
||||
// Arrange
|
||||
const infoLogSpy = vi.spyOn(logger, "debug");
|
||||
const agent = new LoggingAgent();
|
||||
const logger = new TestLogger();
|
||||
const agent = new UndiciHttpAgent({ logger });
|
||||
|
||||
// Act
|
||||
agent.dispatch(
|
||||
@@ -51,7 +54,7 @@ describe("LoggingAgent should log all requests", () => {
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(infoLogSpy).toHaveBeenCalledWith(expect.stringContaining("(2 headers)"));
|
||||
expect(logger.messages.at(-1)?.message).toContain("(2 headers)");
|
||||
});
|
||||
|
||||
test.each([
|
||||
@@ -69,14 +72,14 @@ describe("LoggingAgent should log all requests", () => {
|
||||
[`/${"a".repeat(32)}/?param=123`, `/${REDACTED}/?param=123`],
|
||||
])("should redact sensitive data in url https://homarr.dev%s", (path, expected) => {
|
||||
// Arrange
|
||||
const infoLogSpy = vi.spyOn(logger, "debug");
|
||||
const agent = new LoggingAgent();
|
||||
const logger = new TestLogger();
|
||||
const agent = new UndiciHttpAgent({ logger });
|
||||
|
||||
// Act
|
||||
agent.dispatch({ origin: "https://homarr.dev", path, method: "GET" }, {});
|
||||
|
||||
// Assert
|
||||
expect(infoLogSpy).toHaveBeenCalledWith(expect.stringContaining(` https://homarr.dev${expected} `));
|
||||
expect(logger.messages.at(-1)?.message).toContain(` https://homarr.dev${expected} `);
|
||||
});
|
||||
test.each([
|
||||
["empty", "/?empty"],
|
||||
@@ -88,13 +91,13 @@ describe("LoggingAgent should log all requests", () => {
|
||||
["date times", "/?datetime=2022-01-01T00:00:00.000Z"],
|
||||
])("should not redact values that are %s", (_reason, path) => {
|
||||
// Arrange
|
||||
const infoLogSpy = vi.spyOn(logger, "debug");
|
||||
const agent = new LoggingAgent();
|
||||
const logger = new TestLogger();
|
||||
const agent = new UndiciHttpAgent({ logger });
|
||||
|
||||
// Act
|
||||
agent.dispatch({ origin: "https://homarr.dev", path, method: "GET" }, {});
|
||||
|
||||
// Assert
|
||||
expect(infoLogSpy).toHaveBeenCalledWith(expect.stringContaining(` https://homarr.dev${path} `));
|
||||
expect(logger.messages.at(-1)?.message).toContain(` https://homarr.dev${path} `);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { ILogger } from "@homarr/core/infrastructure/logs";
|
||||
import type { LogLevel } from "@homarr/core/infrastructure/logs/constants";
|
||||
|
||||
interface LogMessage {
|
||||
level: LogLevel;
|
||||
message: string;
|
||||
meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface LogError {
|
||||
level: LogLevel;
|
||||
error: unknown;
|
||||
}
|
||||
|
||||
type LogEntry = LogMessage | LogError;
|
||||
|
||||
export class TestLogger implements ILogger {
|
||||
public entries: LogEntry[] = [];
|
||||
public get messages(): LogMessage[] {
|
||||
return this.entries.filter((entry) => "message" in entry);
|
||||
}
|
||||
public get errors(): LogError[] {
|
||||
return this.entries.filter((entry) => "error" in entry);
|
||||
}
|
||||
|
||||
private log(level: LogLevel, param1: unknown, param2?: Record<string, unknown>): void {
|
||||
if (typeof param1 === "string") {
|
||||
this.entries.push({ level, message: param1, meta: param2 });
|
||||
} else {
|
||||
this.entries.push({ level, error: param1 });
|
||||
}
|
||||
}
|
||||
|
||||
debug(param1: unknown, param2?: Record<string, unknown>): void {
|
||||
this.log("debug", param1, param2);
|
||||
}
|
||||
|
||||
info(param1: unknown, param2?: Record<string, unknown>): void {
|
||||
this.log("info", param1, param2);
|
||||
}
|
||||
|
||||
warn(param1: unknown, param2?: Record<string, unknown>): void {
|
||||
this.log("warn", param1, param2);
|
||||
}
|
||||
|
||||
error(param1: unknown, param2?: Record<string, unknown>): void {
|
||||
this.log("error", param1, param2);
|
||||
}
|
||||
}
|
||||
@@ -28,13 +28,12 @@
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/core": "workspace:^0.1.0",
|
||||
"@homarr/cron-jobs": "workspace:^0.1.0",
|
||||
"@homarr/log": "workspace:^0.1.0",
|
||||
"@tanstack/react-query": "^5.90.12",
|
||||
"@trpc/client": "^11.7.2",
|
||||
"@trpc/server": "^11.7.2",
|
||||
"@trpc/tanstack-react-query": "^11.7.2",
|
||||
"@trpc/client": "^11.8.0",
|
||||
"@trpc/server": "^11.8.0",
|
||||
"@trpc/tanstack-react-query": "^11.8.0",
|
||||
"node-cron": "^4.2.1",
|
||||
"react": "19.2.1",
|
||||
"react": "19.2.3",
|
||||
"zod": "^4.1.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -43,7 +42,7 @@
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/react": "19.2.7",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint": "^9.39.2",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint": "^9.39.2",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@
|
||||
"prettier": "@homarr/prettier-config",
|
||||
"dependencies": {
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/core": "workspace:^",
|
||||
"@homarr/db": "workspace:^0.1.0",
|
||||
"node-cron": "^4.2.1"
|
||||
},
|
||||
@@ -33,7 +34,7 @@
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint": "^9.39.2",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { AxiosError } from "axios";
|
||||
import { createTask, validate } from "node-cron";
|
||||
|
||||
import { Stopwatch } from "@homarr/common";
|
||||
import type { MaybePromise } from "@homarr/common/types";
|
||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||
import { db } from "@homarr/db";
|
||||
|
||||
import type { Logger } from "./logger";
|
||||
@@ -33,33 +33,39 @@ const createCallback = <TAllowedNames extends string, TName extends TAllowedName
|
||||
return (callback: () => MaybePromise<void>) => {
|
||||
const catchingCallbackAsync = async () => {
|
||||
try {
|
||||
creatorOptions.logger.logDebug(`The callback of '${name}' cron job started`);
|
||||
creatorOptions.logger.logDebug("The callback of cron job started", {
|
||||
name,
|
||||
});
|
||||
const stopwatch = new Stopwatch();
|
||||
await creatorOptions.beforeCallback?.(name);
|
||||
const beforeCallbackTook = stopwatch.getElapsedInHumanWords();
|
||||
await callback();
|
||||
const callbackTook = stopwatch.getElapsedInHumanWords();
|
||||
creatorOptions.logger.logDebug(
|
||||
`The callback of '${name}' cron job succeeded (before callback took ${beforeCallbackTook}, callback took ${callbackTook})`,
|
||||
);
|
||||
creatorOptions.logger.logDebug("The callback of cron job succeeded", {
|
||||
name,
|
||||
beforeCallbackTook,
|
||||
callbackTook,
|
||||
});
|
||||
|
||||
const durationInMillis = stopwatch.getElapsedInMilliseconds();
|
||||
if (durationInMillis > expectedMaximumDurationInMillis) {
|
||||
creatorOptions.logger.logWarning(
|
||||
`The callback of '${name}' succeeded but took ${(durationInMillis - expectedMaximumDurationInMillis).toFixed(2)}ms longer than expected (${expectedMaximumDurationInMillis}ms). This may indicate that your network performance, host performance or something else is too slow. If this happens too often, it should be looked into.`,
|
||||
);
|
||||
creatorOptions.logger.logWarning("The callback of cron job took longer than expected", {
|
||||
name,
|
||||
durationInMillis,
|
||||
expectedMaximumDurationInMillis,
|
||||
});
|
||||
}
|
||||
await creatorOptions.onCallbackSuccess?.(name);
|
||||
} catch (error) {
|
||||
// Log AxiosError in a less detailed way to prevent very long output
|
||||
if (error instanceof AxiosError) {
|
||||
creatorOptions.logger.logError(
|
||||
`Failed to run job '${name}': [AxiosError] ${error.message} ${error.response?.status} ${error.response?.config.url}\n${error.stack}`,
|
||||
);
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
|
||||
creatorOptions.logger.logError(`Failed to run job '${name}': ${error}`);
|
||||
}
|
||||
creatorOptions.logger.logError(
|
||||
new ErrorWithMetadata(
|
||||
"The callback of cron job failed",
|
||||
{
|
||||
name,
|
||||
},
|
||||
{ cause: error },
|
||||
),
|
||||
);
|
||||
await creatorOptions.onCallbackError?.(name, error);
|
||||
}
|
||||
};
|
||||
@@ -80,21 +86,28 @@ const createCallback = <TAllowedNames extends string, TName extends TAllowedName
|
||||
timezone: creatorOptions.timezone,
|
||||
},
|
||||
);
|
||||
creatorOptions.logger.logDebug(
|
||||
`The cron job '${name}' was created with expression ${defaultCronExpression} in timezone ${creatorOptions.timezone} and runOnStart ${options.runOnStart}`,
|
||||
);
|
||||
creatorOptions.logger.logDebug("The scheduled task for cron job was created", {
|
||||
name,
|
||||
cronExpression: defaultCronExpression,
|
||||
timezone: creatorOptions.timezone,
|
||||
runOnStart: options.runOnStart,
|
||||
});
|
||||
|
||||
return scheduledTask;
|
||||
},
|
||||
async onStartAsync() {
|
||||
if (options.beforeStart) {
|
||||
creatorOptions.logger.logDebug(`Running beforeStart for job: ${name}`);
|
||||
creatorOptions.logger.logDebug("Running beforeStart for job", {
|
||||
name,
|
||||
});
|
||||
await options.beforeStart();
|
||||
}
|
||||
|
||||
if (!options.runOnStart) return;
|
||||
|
||||
creatorOptions.logger.logDebug(`The cron job '${name}' is running because runOnStart is set to true`);
|
||||
creatorOptions.logger.logDebug("The cron job is configured to run on start, executing callback", {
|
||||
name,
|
||||
});
|
||||
await catchingCallbackAsync();
|
||||
},
|
||||
async executeAsync() {
|
||||
@@ -117,11 +130,17 @@ export const createCronJobCreator = <TAllowedNames extends string = string>(
|
||||
defaultCronExpression: TExpression,
|
||||
options: CreateCronJobOptions = { runOnStart: false },
|
||||
) => {
|
||||
creatorOptions.logger.logDebug(`Validating cron expression '${defaultCronExpression}' for job: ${name}`);
|
||||
creatorOptions.logger.logDebug("Validating cron expression for cron job", {
|
||||
name,
|
||||
cronExpression: defaultCronExpression,
|
||||
});
|
||||
if (!validate(defaultCronExpression)) {
|
||||
throw new Error(`Invalid cron expression '${defaultCronExpression}' for job '${name}'`);
|
||||
}
|
||||
creatorOptions.logger.logDebug(`Cron job expression '${defaultCronExpression}' for job ${name} is valid`);
|
||||
creatorOptions.logger.logDebug("Cron job expression for cron job is valid", {
|
||||
name,
|
||||
cronExpression: defaultCronExpression,
|
||||
});
|
||||
|
||||
const returnValue = {
|
||||
withCallback: createCallback<TAllowedNames, TName>(name, defaultCronExpression, options, creatorOptions),
|
||||
|
||||
@@ -19,11 +19,15 @@ export const createJobGroupCreator = <TAllowedNames extends string = string>(
|
||||
options: CreateCronJobGroupCreatorOptions,
|
||||
) => {
|
||||
return <TJobs extends Jobs<TAllowedNames>>(jobs: TJobs) => {
|
||||
options.logger.logDebug(`Creating job group with ${Object.keys(jobs).length} jobs.`);
|
||||
options.logger.logDebug("Creating job group.", {
|
||||
jobCount: Object.keys(jobs).length,
|
||||
});
|
||||
for (const [key, job] of objectEntries(jobs)) {
|
||||
if (typeof key !== "string" || typeof job.name !== "string") continue;
|
||||
|
||||
options.logger.logDebug(`Added job ${job.name} to the job registry.`);
|
||||
options.logger.logDebug("Registering job in the job registry.", {
|
||||
name: job.name,
|
||||
});
|
||||
jobRegistry.set(key, {
|
||||
...job,
|
||||
name: job.name,
|
||||
@@ -54,7 +58,9 @@ export const createJobGroupCreator = <TAllowedNames extends string = string>(
|
||||
if (!job) return;
|
||||
if (!tasks.has(job.name)) return;
|
||||
|
||||
options.logger.logInfo(`Starting schedule cron job ${job.name}.`);
|
||||
options.logger.logInfo("Starting schedule of cron job.", {
|
||||
name: job.name,
|
||||
});
|
||||
await job.onStartAsync();
|
||||
await tasks.get(name as string)?.start();
|
||||
},
|
||||
@@ -64,7 +70,9 @@ export const createJobGroupCreator = <TAllowedNames extends string = string>(
|
||||
continue;
|
||||
}
|
||||
|
||||
options.logger.logInfo(`Starting schedule of cron job ${job.name}.`);
|
||||
options.logger.logInfo("Starting schedule of cron job.", {
|
||||
name: job.name,
|
||||
});
|
||||
await job.onStartAsync();
|
||||
await tasks.get(job.name)?.start();
|
||||
}
|
||||
@@ -76,19 +84,25 @@ export const createJobGroupCreator = <TAllowedNames extends string = string>(
|
||||
throw new Error(`The job "${job.name}" can not be executed manually.`);
|
||||
}
|
||||
|
||||
options.logger.logInfo(`Running schedule cron job ${job.name} manually.`);
|
||||
options.logger.logInfo("Running schedule cron job manually.", {
|
||||
name: job.name,
|
||||
});
|
||||
await tasks.get(name as string)?.execute();
|
||||
},
|
||||
stopAsync: async (name: keyof TJobs) => {
|
||||
const job = jobRegistry.get(name as string);
|
||||
if (!job) return;
|
||||
|
||||
options.logger.logInfo(`Stopping schedule cron job ${job.name}.`);
|
||||
options.logger.logInfo("Stopping schedule of cron job.", {
|
||||
name: job.name,
|
||||
});
|
||||
await tasks.get(name as string)?.stop();
|
||||
},
|
||||
stopAllAsync: async () => {
|
||||
for (const job of jobRegistry.values()) {
|
||||
options.logger.logInfo(`Stopping schedule cron job ${job.name}.`);
|
||||
options.logger.logInfo("Stopping schedule of cron job.", {
|
||||
name: job.name,
|
||||
});
|
||||
await tasks.get(job.name)?.stop();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import type { CreateCronJobCreatorOptions } from "./creator";
|
||||
import { createCronJobCreator } from "./creator";
|
||||
import { createJobGroupCreator } from "./group";
|
||||
import { ConsoleLogger } from "./logger";
|
||||
|
||||
export const createCronJobFunctions = <TAllowedNames extends string>(
|
||||
options: CreateCronJobCreatorOptions<TAllowedNames> = { logger: new ConsoleLogger() },
|
||||
options: CreateCronJobCreatorOptions<TAllowedNames>,
|
||||
) => {
|
||||
return {
|
||||
createCronJob: createCronJobCreator<TAllowedNames>(options),
|
||||
|
||||
@@ -1,24 +1,7 @@
|
||||
export interface Logger {
|
||||
logDebug(message: string): void;
|
||||
logInfo(message: string): void;
|
||||
logDebug(message: string, metadata?: Record<string, unknown>): void;
|
||||
logInfo(message: string, metadata?: Record<string, unknown>): void;
|
||||
logError(message: string, metadata?: Record<string, unknown>): void;
|
||||
logError(error: unknown): void;
|
||||
logWarning(message: string): void;
|
||||
}
|
||||
|
||||
export class ConsoleLogger implements Logger {
|
||||
public logDebug(message: string) {
|
||||
console.log(message);
|
||||
}
|
||||
|
||||
public logInfo(message: string) {
|
||||
console.log(message);
|
||||
}
|
||||
|
||||
public logError(error: unknown) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
public logWarning(message: string) {
|
||||
console.warn(message);
|
||||
}
|
||||
logWarning(message: string, metadata?: Record<string, unknown>): void;
|
||||
}
|
||||
@@ -25,13 +25,13 @@
|
||||
"@homarr/analytics": "workspace:^0.1.0",
|
||||
"@homarr/auth": "workspace:^0.1.0",
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/core": "workspace:^0.1.0",
|
||||
"@homarr/cron-job-status": "workspace:^0.1.0",
|
||||
"@homarr/cron-jobs-core": "workspace:^0.1.0",
|
||||
"@homarr/db": "workspace:^0.1.0",
|
||||
"@homarr/definitions": "workspace:^0.1.0",
|
||||
"@homarr/icons": "workspace:^0.1.0",
|
||||
"@homarr/integrations": "workspace:^0.1.0",
|
||||
"@homarr/log": "workspace:^0.1.0",
|
||||
"@homarr/ping": "workspace:^0.1.0",
|
||||
"@homarr/redis": "workspace:^0.1.0",
|
||||
"@homarr/request-handler": "workspace:^0.1.0",
|
||||
@@ -44,7 +44,7 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint": "^9.39.2",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,17 @@
|
||||
import SuperJSON from "superjson";
|
||||
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||
import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions";
|
||||
import { db, eq } from "@homarr/db";
|
||||
import { items } from "@homarr/db/schema";
|
||||
import { logger } from "@homarr/log";
|
||||
import { dockerContainersRequestHandler } from "@homarr/request-handler/docker";
|
||||
|
||||
import type { WidgetComponentProps } from "../../../widgets";
|
||||
import { createCronJob } from "../lib";
|
||||
|
||||
const logger = createLogger({ module: "dockerJobs" });
|
||||
|
||||
export const dockerContainersJob = createCronJob("dockerContainers", EVERY_MINUTE).withCallback(async () => {
|
||||
const dockerItems = await db.query.items.findMany({
|
||||
where: eq(items.kind, "dockerContainers"),
|
||||
@@ -21,7 +24,7 @@ export const dockerContainersJob = createCronJob("dockerContainers", EVERY_MINUT
|
||||
const innerHandler = dockerContainersRequestHandler.handler(options);
|
||||
await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: true });
|
||||
} catch (error) {
|
||||
logger.error("Failed to update Docker container status", { item, error });
|
||||
logger.error(new ErrorWithMetadata("Failed to update Docker container status", { item }, { cause: error }));
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { createId, splitToNChunks, Stopwatch } from "@homarr/common";
|
||||
import { env } from "@homarr/common/env";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { EVERY_WEEK } from "@homarr/cron-jobs-core/expressions";
|
||||
import type { InferInsertModel } from "@homarr/db";
|
||||
import { db, handleTransactionsAsync, inArray, sql } from "@homarr/db";
|
||||
import { iconRepositories, icons } from "@homarr/db/schema";
|
||||
import { fetchIconsAsync } from "@homarr/icons";
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
import { createCronJob } from "../lib";
|
||||
|
||||
const logger = createLogger({ module: "iconsUpdaterJobs" });
|
||||
|
||||
export const iconsUpdaterJob = createCronJob("iconsUpdater", EVERY_WEEK, {
|
||||
runOnStart: true,
|
||||
expectedMaximumDurationInMillis: 10 * 1000,
|
||||
@@ -21,9 +23,11 @@ export const iconsUpdaterJob = createCronJob("iconsUpdater", EVERY_WEEK, {
|
||||
const countIcons = repositoryIconGroups
|
||||
.map((group) => group.icons.length)
|
||||
.reduce((partialSum, arrayLength) => partialSum + arrayLength, 0);
|
||||
logger.info(
|
||||
`Successfully fetched ${countIcons} icons from ${repositoryIconGroups.length} repositories within ${stopWatch.getElapsedInHumanWords()}`,
|
||||
);
|
||||
logger.info("Fetched icons from repositories", {
|
||||
repositoryCount: repositoryIconGroups.length,
|
||||
iconCount: countIcons,
|
||||
duration: stopWatch.getElapsedInHumanWords(),
|
||||
});
|
||||
|
||||
const databaseIconRepositories = await db.query.iconRepositories.findMany({
|
||||
with: {
|
||||
@@ -162,5 +166,9 @@ export const iconsUpdaterJob = createCronJob("iconsUpdater", EVERY_WEEK, {
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`Updated database within ${stopWatch.getElapsedInHumanWords()} (-${countDeleted}, +${countInserted})`);
|
||||
logger.info("Updated icons in database", {
|
||||
duration: stopWatch.getElapsedInHumanWords(),
|
||||
added: countInserted,
|
||||
deleted: countDeleted,
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,15 @@
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||
import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions";
|
||||
import { db } from "@homarr/db";
|
||||
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||
import { logger } from "@homarr/log";
|
||||
import { sendPingRequestAsync } from "@homarr/ping";
|
||||
import { pingChannel, pingUrlChannel } from "@homarr/redis";
|
||||
|
||||
import { createCronJob } from "../lib";
|
||||
|
||||
const logger = createLogger({ module: "pingJobs" });
|
||||
|
||||
const resetPreviousUrlsAsync = async () => {
|
||||
await pingUrlChannel.clearAsync();
|
||||
logger.info("Cleared previous ping urls");
|
||||
@@ -31,9 +34,9 @@ const pingAsync = async (url: string) => {
|
||||
const pingResult = await sendPingRequestAsync(url);
|
||||
|
||||
if ("statusCode" in pingResult) {
|
||||
logger.debug(`executed ping for url ${url} with status code ${pingResult.statusCode}`);
|
||||
logger.debug("Executed ping successfully", { url, statusCode: pingResult.statusCode });
|
||||
} else {
|
||||
logger.error(`Executing ping for url ${url} failed with error: ${pingResult.error}`);
|
||||
logger.error(new ErrorWithMetadata("Executing ping failed", { url }, { cause: pingResult.error }));
|
||||
}
|
||||
|
||||
await pingChannel.publishAsync({
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import SuperJSON from "superjson";
|
||||
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||
import { EVERY_10_MINUTES } from "@homarr/cron-jobs-core/expressions";
|
||||
import { db, eq } from "@homarr/db";
|
||||
import { items } from "@homarr/db/schema";
|
||||
import { logger } from "@homarr/log";
|
||||
// This import is done that way to avoid circular dependencies.
|
||||
import { rssFeedsRequestHandler } from "@homarr/request-handler/rss-feeds";
|
||||
|
||||
import type { WidgetComponentProps } from "../../../widgets";
|
||||
import { createCronJob } from "../lib";
|
||||
|
||||
const logger = createLogger({ module: "rssFeedsJobs" });
|
||||
|
||||
export const rssFeedsJob = createCronJob("rssFeeds", EVERY_10_MINUTES).withCallback(async () => {
|
||||
const rssItems = await db.query.items.findMany({
|
||||
where: eq(items.kind, "rssFeed"),
|
||||
@@ -29,7 +32,7 @@ export const rssFeedsJob = createCronJob("rssFeeds", EVERY_10_MINUTES).withCallb
|
||||
forceUpdate: true,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Failed to update RSS feed", { url, error });
|
||||
logger.error(new ErrorWithMetadata("Failed to update RSS feed", { url }, { cause: error }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import SuperJSON from "superjson";
|
||||
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||
import { EVERY_10_MINUTES } from "@homarr/cron-jobs-core/expressions";
|
||||
import { db, eq } from "@homarr/db";
|
||||
import { items } from "@homarr/db/schema";
|
||||
import { logger } from "@homarr/log";
|
||||
import { weatherRequestHandler } from "@homarr/request-handler/weather";
|
||||
|
||||
import type { WidgetComponentProps } from "../../../widgets";
|
||||
import { createCronJob } from "../lib";
|
||||
|
||||
const logger = createLogger({ module: "weatherJobs" });
|
||||
|
||||
export const weatherJob = createCronJob("weather", EVERY_10_MINUTES).withCallback(async () => {
|
||||
const weatherItems = await db.query.items.findMany({
|
||||
where: eq(items.kind, "weather"),
|
||||
@@ -27,7 +30,7 @@ export const weatherJob = createCronJob("weather", EVERY_10_MINUTES).withCallbac
|
||||
});
|
||||
await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: true });
|
||||
} catch (error) {
|
||||
logger.error("Failed to update weather", { id: item.id, error });
|
||||
logger.error(new ErrorWithMetadata("Failed to update weather", { id: item.id }, { cause: error }));
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,24 +1,29 @@
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { beforeCallbackAsync, onCallbackErrorAsync, onCallbackSuccessAsync } from "@homarr/cron-job-status/publisher";
|
||||
import { createCronJobFunctions } from "@homarr/cron-jobs-core";
|
||||
import type { Logger } from "@homarr/cron-jobs-core/logger";
|
||||
import { logger } from "@homarr/log";
|
||||
import type { TranslationObject } from "@homarr/translation";
|
||||
|
||||
const logger = createLogger({ module: "cronJobs" });
|
||||
|
||||
class WinstonCronJobLogger implements Logger {
|
||||
logDebug(message: string) {
|
||||
logger.debug(message);
|
||||
logDebug(message: string, metadata?: Record<string, unknown>): void {
|
||||
logger.debug(message, metadata);
|
||||
}
|
||||
|
||||
logInfo(message: string) {
|
||||
logger.info(message);
|
||||
logInfo(message: string, metadata?: Record<string, unknown>): void {
|
||||
logger.info(message, metadata);
|
||||
}
|
||||
|
||||
logError(error: unknown) {
|
||||
logger.error(error);
|
||||
logError(message: string, metadata?: Record<string, unknown>): void;
|
||||
logError(error: unknown): void;
|
||||
logError(messageOrError: unknown, metadata?: Record<string, unknown>): void {
|
||||
if (typeof messageOrError === "string") {
|
||||
logger.error(messageOrError, metadata);
|
||||
return;
|
||||
}
|
||||
logger.error(messageOrError);
|
||||
}
|
||||
|
||||
logWarning(message: string) {
|
||||
logger.warn(message);
|
||||
logWarning(message: string, metadata?: Record<string, unknown>): void {
|
||||
logger.warn(message, metadata);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { InferInsertModel } from "drizzle-orm";
|
||||
|
||||
import { objectEntries } from "@homarr/common";
|
||||
import { dbEnv } from "@homarr/core/infrastructure/db/env";
|
||||
|
||||
import type { HomarrDatabase, HomarrDatabaseMysql, HomarrDatabasePostgresql } from "./driver";
|
||||
import { env } from "./env";
|
||||
import * as schema from "./schema";
|
||||
|
||||
type TableKey = {
|
||||
@@ -11,11 +11,11 @@ type TableKey = {
|
||||
}[keyof typeof schema];
|
||||
|
||||
export function isMysql(): boolean {
|
||||
return env.DB_DRIVER === "mysql2";
|
||||
return dbEnv.DRIVER === "mysql2";
|
||||
}
|
||||
|
||||
export function isPostgresql(): boolean {
|
||||
return env.DB_DRIVER === "node-postgres";
|
||||
return dbEnv.DRIVER === "node-postgres";
|
||||
}
|
||||
|
||||
export const createDbInsertCollectionForTransaction = <TTableKey extends TableKey>(
|
||||
@@ -66,7 +66,7 @@ export const createDbInsertCollectionWithoutTransaction = <TTableKey extends Tab
|
||||
return {
|
||||
...collection,
|
||||
insertAllAsync: async (db: HomarrDatabase) => {
|
||||
switch (env.DB_DRIVER) {
|
||||
switch (dbEnv.DRIVER) {
|
||||
case "mysql2":
|
||||
case "node-postgres":
|
||||
// For mysql2 and node-postgres, we can use the async insertAllAsync method
|
||||
|
||||
Loaded 100 of 323 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user