refactor: streamline widget and integration architecture
This commit is contained in:
1 parent
ec295fe26e
commit
425cb653a1
243 files changed
+5722
-2255
No files matched your search
+49
-36
@@ -2,6 +2,9 @@ export { createTRPCContext } from "./trpc";
|
||||
export { extractMcpToolsFromProcedures } from "./mcp-tools";
|
||||
export type { McpTool } from "./mcp-tools";
|
||||
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||
|
||||
import { createTRPCRouter } from "./trpc";
|
||||
import { appRouter as appRouterForApps } from "./router/app";
|
||||
import { apiKeysRouter } from "./router/apiKeys";
|
||||
@@ -12,41 +15,51 @@ import { iconsRouter } from "./router/icons";
|
||||
import { infoRouter } from "./router/info";
|
||||
import { integrationRouter } from "./router/integration/integration-router";
|
||||
import { inviteRouter } from "./router/invite";
|
||||
import { airQualityRouter } from "./router/widgets/air-quality";
|
||||
import { bazarrRouter } from "./router/widgets/bazarr";
|
||||
import { serverSettingsRouter } from "./router/serverSettings";
|
||||
import { beszelRouter } from "./router/widgets/beszel";
|
||||
import { calendarRouter } from "./router/widgets/calendar";
|
||||
import { dnsHoleRouter } from "./router/widgets/dns-hole";
|
||||
import { downloadsRouter } from "./router/widgets/downloads";
|
||||
import { healthMonitoringRouter } from "./router/widgets/health-monitoring";
|
||||
import { mediaRequestsRouter } from "./router/widgets/media-requests";
|
||||
import { mediaServerRouter } from "./router/widgets/media-server";
|
||||
import { patchmonRouter } from "./router/widgets/patchmon";
|
||||
import { smartHomeRouter } from "./router/widgets/smart-home";
|
||||
import { widgetSecretsRouter } from "./router/widgets/widget-secrets";
|
||||
import { loadMcpWidgetRoutersAsync } from "./router/widgets/registry";
|
||||
|
||||
export const mcpRouter = createTRPCRouter({
|
||||
app: appRouterForApps,
|
||||
apiKeys: apiKeysRouter,
|
||||
board: boardRouter,
|
||||
customWidget: customWidgetRouter,
|
||||
docker: dockerRouter,
|
||||
icon: iconsRouter,
|
||||
info: infoRouter,
|
||||
integration: integrationRouter,
|
||||
invite: inviteRouter,
|
||||
airQuality: airQualityRouter,
|
||||
bazarr: bazarrRouter,
|
||||
serverSettings: serverSettingsRouter,
|
||||
beszel: beszelRouter,
|
||||
calendar: calendarRouter,
|
||||
dnsHole: dnsHoleRouter,
|
||||
downloads: downloadsRouter,
|
||||
healthMonitoring: healthMonitoringRouter,
|
||||
mediaRequests: mediaRequestsRouter,
|
||||
mediaServer: mediaServerRouter,
|
||||
patchmon: patchmonRouter,
|
||||
smartHome: smartHomeRouter,
|
||||
widgetSecrets: widgetSecretsRouter,
|
||||
});
|
||||
const logger = createLogger({ module: "mcpRouter" });
|
||||
|
||||
const createMcpRouterAsync = async () => {
|
||||
const startedAt = Date.now();
|
||||
const widgetRouters = await loadMcpWidgetRoutersAsync();
|
||||
|
||||
const router = createTRPCRouter({
|
||||
app: appRouterForApps,
|
||||
apiKeys: apiKeysRouter,
|
||||
board: boardRouter,
|
||||
customWidget: customWidgetRouter,
|
||||
docker: dockerRouter,
|
||||
icon: iconsRouter,
|
||||
info: infoRouter,
|
||||
integration: integrationRouter,
|
||||
invite: inviteRouter,
|
||||
serverSettings: serverSettingsRouter,
|
||||
...widgetRouters,
|
||||
});
|
||||
|
||||
logger.info("Materialized MCP router", {
|
||||
durationMs: Date.now() - startedAt,
|
||||
widgetRouters: Object.keys(widgetRouters),
|
||||
});
|
||||
return router;
|
||||
};
|
||||
|
||||
export type McpRouter = Awaited<ReturnType<typeof createMcpRouterAsync>>;
|
||||
|
||||
let mcpRouterPromise: Promise<McpRouter> | null = null;
|
||||
|
||||
export async function getMcpRouterAsync(): Promise<McpRouter> {
|
||||
if (mcpRouterPromise) return await mcpRouterPromise;
|
||||
|
||||
const loadPromise = createMcpRouterAsync();
|
||||
mcpRouterPromise = loadPromise;
|
||||
|
||||
try {
|
||||
return await loadPromise;
|
||||
} catch (error) {
|
||||
if (mcpRouterPromise === loadPromise) mcpRouterPromise = null;
|
||||
logger.error(new ErrorWithMetadata("Failed to materialize MCP router", {}, { cause: error }));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,10 @@ import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { env } from "@homarr/docker/env";
|
||||
|
||||
import { isDemoMode, publicProcedure } from "../trpc";
|
||||
import { isDemoMode, middlewareProcedure } from "../trpc";
|
||||
|
||||
export const dockerMiddleware = () => {
|
||||
return publicProcedure.use(async ({ next }) => {
|
||||
return middlewareProcedure.use(async ({ next }) => {
|
||||
if (env.ENABLE_DOCKER || isDemoMode) {
|
||||
return await next();
|
||||
}
|
||||
|
||||
@@ -9,28 +9,74 @@ import type { AtLeastOneOf } from "@homarr/common/types";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { and, eq, inArray } from "@homarr/db";
|
||||
import { integrations } from "@homarr/db/schema";
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
import type { IntegrationKind, WidgetIntegrationKind, WidgetKindWithIntegration } from "@homarr/definitions";
|
||||
import { getWidgetFeatureIntegrationKinds } from "@homarr/definitions";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
|
||||
import { publicProcedure } from "../trpc";
|
||||
import { middlewareProcedure } from "../trpc";
|
||||
|
||||
export type IntegrationAction = "query" | "interact";
|
||||
|
||||
/**
|
||||
* Creates a middleware that provides the integration in the context that is of the specified kinds
|
||||
* @param action query for showing data or interact for mutating data
|
||||
* @param kinds kinds of integrations that are supported
|
||||
* @returns middleware that can be used with trpc
|
||||
* @example publicProcedure.concat(createOneIntegrationMiddleware("query", "piHole", "homeAssistant")).query(...)
|
||||
* @throws TRPCError NOT_FOUND if the integration was not found
|
||||
* @throws TRPCError FORBIDDEN if the user does not have permission to perform the specified action on the specified integration
|
||||
*/
|
||||
export const createOneIntegrationMiddleware = <TKind extends IntegrationKind>(
|
||||
action: IntegrationAction,
|
||||
...kinds: AtLeastOneOf<TKind> // Ensure at least one kind is provided
|
||||
const logger = createLogger({ module: "integrationMiddleware" });
|
||||
|
||||
const getIntegrationKindForLog = (integration: object) => {
|
||||
if ("kind" in integration && typeof integration.kind === "string") return integration.kind;
|
||||
return "unknown";
|
||||
};
|
||||
|
||||
interface IntegrationAccessMetadata {
|
||||
action: IntegrationAction;
|
||||
cardinality: "one" | "many";
|
||||
userId: string | undefined;
|
||||
requestedIntegrationIds: readonly string[];
|
||||
allowedIntegrationKinds: readonly IntegrationKind[];
|
||||
}
|
||||
|
||||
interface WidgetIntegrationAvailability {
|
||||
widgetKinds: readonly [WidgetKindWithIntegration, ...WidgetKindWithIntegration[]];
|
||||
}
|
||||
|
||||
const getCompatibleIntegrationKindsOrThrow = <TKind extends IntegrationKind>(
|
||||
kinds: readonly TKind[],
|
||||
widgetAvailability?: WidgetIntegrationAvailability,
|
||||
) => {
|
||||
return publicProcedure.input(z.object({ integrationId: z.string() })).use(async ({ input, ctx, next }) => {
|
||||
if (!widgetAvailability) return kinds;
|
||||
|
||||
const widgetIntegrationKinds = new Set(
|
||||
widgetAvailability.widgetKinds.flatMap((widgetKind) => getWidgetFeatureIntegrationKinds(widgetKind)),
|
||||
);
|
||||
const compatibleKinds = kinds.filter((kind) => widgetIntegrationKinds.has(kind));
|
||||
if (compatibleKinds.length >= 1) return compatibleKinds;
|
||||
|
||||
logger.warn("No compatible integration kinds for widget route", {
|
||||
widgetKinds: widgetAvailability.widgetKinds,
|
||||
configuredIntegrationKinds: kinds,
|
||||
reason: "no-compatible-widget-integration-kinds",
|
||||
});
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "No integration kind is compatible with the consuming widget",
|
||||
});
|
||||
};
|
||||
|
||||
const createOneIntegrationMiddlewareForKinds = <TKind extends IntegrationKind>(
|
||||
action: IntegrationAction,
|
||||
kinds: readonly TKind[],
|
||||
widgetAvailability?: WidgetIntegrationAvailability,
|
||||
) => {
|
||||
return middlewareProcedure.input(z.object({ integrationId: z.string() })).use(async ({ input, ctx, next }) => {
|
||||
const compatibleKinds = getCompatibleIntegrationKindsOrThrow(kinds, widgetAvailability);
|
||||
const accessMetadata: IntegrationAccessMetadata = {
|
||||
action,
|
||||
cardinality: "one",
|
||||
userId: ctx.session?.user.id,
|
||||
requestedIntegrationIds: [input.integrationId],
|
||||
allowedIntegrationKinds: compatibleKinds,
|
||||
};
|
||||
logger.debug("Resolving integration access", accessMetadata);
|
||||
|
||||
const integration = await ctx.db.query.integrations.findFirst({
|
||||
where: and(eq(integrations.id, input.integrationId), inArray(integrations.kind, kinds)),
|
||||
where: and(eq(integrations.id, input.integrationId), inArray(integrations.kind, compatibleKinds)),
|
||||
with: {
|
||||
app: true,
|
||||
secrets: true,
|
||||
@@ -45,13 +91,18 @@ export const createOneIntegrationMiddleware = <TKind extends IntegrationKind>(
|
||||
});
|
||||
|
||||
if (!integration) {
|
||||
logger.warn("Integration access target was not found", accessMetadata);
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: `Integration with id ${input.integrationId} not found or not of kinds ${kinds.join(",")}`,
|
||||
message: `Integration with id ${input.integrationId} not found or not of kinds ${compatibleKinds.join(",")}`,
|
||||
});
|
||||
}
|
||||
|
||||
await throwIfActionIsNotAllowedAsync(action, ctx.db, [integration], ctx.session);
|
||||
logger.debug("Integration access granted", {
|
||||
...accessMetadata,
|
||||
resolvedIntegrationKinds: [integration.kind],
|
||||
});
|
||||
|
||||
const {
|
||||
secrets,
|
||||
@@ -78,6 +129,101 @@ export const createOneIntegrationMiddleware = <TKind extends IntegrationKind>(
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a middleware that provides the integration in the context that is of the specified kinds
|
||||
* @param action query for showing data or interact for mutating data
|
||||
* @param kinds kinds of integrations that are supported
|
||||
* @returns middleware that can be used with trpc
|
||||
* @example publicProcedure.concat(createOneIntegrationMiddleware("query", "piHole", "homeAssistant")).query(...)
|
||||
* @throws TRPCError NOT_FOUND if the integration was not found
|
||||
* @throws TRPCError FORBIDDEN if the user does not have permission to perform the specified action on the specified integration
|
||||
*/
|
||||
export const createOneIntegrationMiddleware = <TKind extends IntegrationKind>(
|
||||
action: IntegrationAction,
|
||||
...kinds: AtLeastOneOf<TKind> // Ensure at least one kind is provided
|
||||
) => {
|
||||
return createOneIntegrationMiddlewareForKinds(action, kinds);
|
||||
};
|
||||
|
||||
const createManyIntegrationMiddlewareForKinds = <TKind extends IntegrationKind>(
|
||||
action: IntegrationAction,
|
||||
kinds: readonly TKind[],
|
||||
widgetAvailability?: WidgetIntegrationAvailability,
|
||||
) => {
|
||||
return middlewareProcedure
|
||||
.input(z.object({ integrationIds: z.array(z.string()) }))
|
||||
.use(async ({ ctx, input, next }) => {
|
||||
const compatibleKinds = getCompatibleIntegrationKindsOrThrow(kinds, widgetAvailability);
|
||||
const accessMetadata: IntegrationAccessMetadata = {
|
||||
action,
|
||||
cardinality: "many",
|
||||
userId: ctx.session?.user.id,
|
||||
requestedIntegrationIds: input.integrationIds,
|
||||
allowedIntegrationKinds: compatibleKinds,
|
||||
};
|
||||
logger.debug("Resolving integration access", accessMetadata);
|
||||
|
||||
const findIntegrations = async () => {
|
||||
if (input.integrationIds.length === 0) return [];
|
||||
|
||||
return await ctx.db.query.integrations.findMany({
|
||||
where: and(inArray(integrations.id, input.integrationIds), inArray(integrations.kind, compatibleKinds)),
|
||||
with: {
|
||||
app: true,
|
||||
secrets: true,
|
||||
items: {
|
||||
with: {
|
||||
item: true,
|
||||
},
|
||||
},
|
||||
userPermissions: true,
|
||||
groupPermissions: true,
|
||||
},
|
||||
});
|
||||
};
|
||||
const dbIntegrations = await findIntegrations();
|
||||
|
||||
const offset = input.integrationIds.length - dbIntegrations.length;
|
||||
if (offset !== 0) {
|
||||
logger.warn("Integration access targets were not found", {
|
||||
...accessMetadata,
|
||||
missingIntegrationCount: offset,
|
||||
resolvedIntegrationIds: dbIntegrations.map(({ id }) => id),
|
||||
resolvedIntegrationKinds: dbIntegrations.map(({ kind }) => kind),
|
||||
});
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: `${offset} of the specified integrations not found or not of kinds ${compatibleKinds.join(",")}: ([${input.integrationIds.join(",")}] compared to [${dbIntegrations.map(({ id, kind }) => `${kind}:${id}`).join(",")}])`,
|
||||
});
|
||||
}
|
||||
|
||||
if (dbIntegrations.length >= 1) {
|
||||
await throwIfActionIsNotAllowedAsync(action, ctx.db, dbIntegrations, ctx.session);
|
||||
}
|
||||
|
||||
logger.debug("Integration access granted", {
|
||||
...accessMetadata,
|
||||
resolvedIntegrationKinds: dbIntegrations.map(({ kind }) => kind),
|
||||
});
|
||||
|
||||
return next({
|
||||
ctx: {
|
||||
integrations: dbIntegrations.map(
|
||||
({ secrets, kind, items: _ignore1, groupPermissions: _ignore2, userPermissions: _ignore3, ...rest }) => ({
|
||||
...rest,
|
||||
externalUrl: rest.app?.href ?? null,
|
||||
kind: kind as TKind,
|
||||
decryptedSecrets: secrets.map((secret) => ({
|
||||
...secret,
|
||||
value: decryptSecret(secret.value),
|
||||
})),
|
||||
}),
|
||||
),
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a middleware that provides the integrations in the context that are of the specified kinds and have the specified item
|
||||
* It also ensures that the user has permission to perform the specified action on the integrations
|
||||
@@ -92,55 +238,115 @@ export const createManyIntegrationMiddleware = <TKind extends IntegrationKind>(
|
||||
action: IntegrationAction,
|
||||
...kinds: AtLeastOneOf<TKind> // Ensure at least one kind is provided
|
||||
) => {
|
||||
return publicProcedure.input(z.object({ integrationIds: z.array(z.string()) })).use(async ({ ctx, input, next }) => {
|
||||
const dbIntegrations =
|
||||
input.integrationIds.length >= 1
|
||||
? await ctx.db.query.integrations.findMany({
|
||||
where: and(inArray(integrations.id, input.integrationIds), inArray(integrations.kind, kinds)),
|
||||
with: {
|
||||
app: true,
|
||||
secrets: true,
|
||||
items: {
|
||||
with: {
|
||||
item: true,
|
||||
},
|
||||
},
|
||||
userPermissions: true,
|
||||
groupPermissions: true,
|
||||
},
|
||||
})
|
||||
: [];
|
||||
|
||||
const offset = input.integrationIds.length - dbIntegrations.length;
|
||||
if (offset !== 0) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: `${offset} of the specified integrations not found or not of kinds ${kinds.join(",")}: ([${input.integrationIds.join(",")}] compared to [${dbIntegrations.map(({ id, kind }) => `${kind}:${id}`).join(",")}])`,
|
||||
});
|
||||
}
|
||||
|
||||
if (dbIntegrations.length >= 1) {
|
||||
await throwIfActionIsNotAllowedAsync(action, ctx.db, dbIntegrations, ctx.session);
|
||||
}
|
||||
|
||||
return next({
|
||||
ctx: {
|
||||
integrations: dbIntegrations.map(
|
||||
({ secrets, kind, items: _ignore1, groupPermissions: _ignore2, userPermissions: _ignore3, ...rest }) => ({
|
||||
...rest,
|
||||
externalUrl: rest.app?.href ?? null,
|
||||
kind: kind as TKind,
|
||||
decryptedSecrets: secrets.map((secret) => ({
|
||||
...secret,
|
||||
value: decryptSecret(secret.value),
|
||||
})),
|
||||
}),
|
||||
),
|
||||
},
|
||||
});
|
||||
});
|
||||
return createManyIntegrationMiddlewareForKinds(action, kinds);
|
||||
};
|
||||
|
||||
/**
|
||||
* Widget procedures should use these adapters so their server-side permission
|
||||
* guards stay aligned with the feature catalog automatically.
|
||||
*/
|
||||
export function createOneWidgetIntegrationMiddleware<TWidgetKind extends WidgetKindWithIntegration>(
|
||||
action: IntegrationAction,
|
||||
widgetKind: TWidgetKind,
|
||||
): ReturnType<typeof createOneIntegrationMiddlewareForKinds<WidgetIntegrationKind<TWidgetKind>>>;
|
||||
export function createOneWidgetIntegrationMiddleware<
|
||||
TWidgetKind extends WidgetKindWithIntegration,
|
||||
TIntegrationKind extends WidgetIntegrationKind<TWidgetKind>,
|
||||
>(
|
||||
action: IntegrationAction,
|
||||
widgetKind: TWidgetKind,
|
||||
integrationKinds: readonly [TIntegrationKind, ...TIntegrationKind[]],
|
||||
): ReturnType<typeof createOneIntegrationMiddlewareForKinds<TIntegrationKind>>;
|
||||
export function createOneWidgetIntegrationMiddleware(
|
||||
action: IntegrationAction,
|
||||
widgetKind: WidgetKindWithIntegration,
|
||||
integrationKinds?: readonly IntegrationKind[],
|
||||
) {
|
||||
const configuredIntegrationKinds = integrationKinds ?? getWidgetFeatureIntegrationKinds(widgetKind);
|
||||
return createOneIntegrationMiddlewareForKinds(action, configuredIntegrationKinds, { widgetKinds: [widgetKind] });
|
||||
}
|
||||
|
||||
export function createOneSharedWidgetIntegrationMiddleware<
|
||||
TContractWidgetKind extends WidgetKindWithIntegration,
|
||||
const TAdditionalWidgetKinds extends readonly [WidgetKindWithIntegration, ...WidgetKindWithIntegration[]],
|
||||
>(
|
||||
action: IntegrationAction,
|
||||
contractWidgetKind: TContractWidgetKind,
|
||||
additionalWidgetKinds: TAdditionalWidgetKinds,
|
||||
): ReturnType<typeof createOneIntegrationMiddlewareForKinds<WidgetIntegrationKind<TContractWidgetKind>>>;
|
||||
export function createOneSharedWidgetIntegrationMiddleware<
|
||||
TContractWidgetKind extends WidgetKindWithIntegration,
|
||||
const TAdditionalWidgetKinds extends readonly [WidgetKindWithIntegration, ...WidgetKindWithIntegration[]],
|
||||
TIntegrationKind extends WidgetIntegrationKind<TContractWidgetKind>,
|
||||
>(
|
||||
action: IntegrationAction,
|
||||
contractWidgetKind: TContractWidgetKind,
|
||||
additionalWidgetKinds: TAdditionalWidgetKinds,
|
||||
integrationKinds: readonly [TIntegrationKind, ...TIntegrationKind[]],
|
||||
): ReturnType<typeof createOneIntegrationMiddlewareForKinds<TIntegrationKind>>;
|
||||
export function createOneSharedWidgetIntegrationMiddleware(
|
||||
action: IntegrationAction,
|
||||
contractWidgetKind: WidgetKindWithIntegration,
|
||||
additionalWidgetKinds: readonly [WidgetKindWithIntegration, ...WidgetKindWithIntegration[]],
|
||||
integrationKinds?: readonly IntegrationKind[],
|
||||
) {
|
||||
const configuredIntegrationKinds = integrationKinds ?? getWidgetFeatureIntegrationKinds(contractWidgetKind);
|
||||
return createOneIntegrationMiddlewareForKinds(action, configuredIntegrationKinds, {
|
||||
widgetKinds: [contractWidgetKind, ...additionalWidgetKinds],
|
||||
});
|
||||
}
|
||||
|
||||
export function createManyWidgetIntegrationMiddleware<TWidgetKind extends WidgetKindWithIntegration>(
|
||||
action: IntegrationAction,
|
||||
widgetKind: TWidgetKind,
|
||||
): ReturnType<typeof createManyIntegrationMiddlewareForKinds<WidgetIntegrationKind<TWidgetKind>>>;
|
||||
export function createManyWidgetIntegrationMiddleware<
|
||||
TWidgetKind extends WidgetKindWithIntegration,
|
||||
TIntegrationKind extends WidgetIntegrationKind<TWidgetKind>,
|
||||
>(
|
||||
action: IntegrationAction,
|
||||
widgetKind: TWidgetKind,
|
||||
integrationKinds: readonly [TIntegrationKind, ...TIntegrationKind[]],
|
||||
): ReturnType<typeof createManyIntegrationMiddlewareForKinds<TIntegrationKind>>;
|
||||
export function createManyWidgetIntegrationMiddleware(
|
||||
action: IntegrationAction,
|
||||
widgetKind: WidgetKindWithIntegration,
|
||||
integrationKinds?: readonly IntegrationKind[],
|
||||
) {
|
||||
const configuredIntegrationKinds = integrationKinds ?? getWidgetFeatureIntegrationKinds(widgetKind);
|
||||
return createManyIntegrationMiddlewareForKinds(action, configuredIntegrationKinds, { widgetKinds: [widgetKind] });
|
||||
}
|
||||
|
||||
export function createManySharedWidgetIntegrationMiddleware<
|
||||
TContractWidgetKind extends WidgetKindWithIntegration,
|
||||
const TAdditionalWidgetKinds extends readonly [WidgetKindWithIntegration, ...WidgetKindWithIntegration[]],
|
||||
>(
|
||||
action: IntegrationAction,
|
||||
contractWidgetKind: TContractWidgetKind,
|
||||
additionalWidgetKinds: TAdditionalWidgetKinds,
|
||||
): ReturnType<typeof createManyIntegrationMiddlewareForKinds<WidgetIntegrationKind<TContractWidgetKind>>>;
|
||||
export function createManySharedWidgetIntegrationMiddleware<
|
||||
TContractWidgetKind extends WidgetKindWithIntegration,
|
||||
const TAdditionalWidgetKinds extends readonly [WidgetKindWithIntegration, ...WidgetKindWithIntegration[]],
|
||||
TIntegrationKind extends WidgetIntegrationKind<TContractWidgetKind>,
|
||||
>(
|
||||
action: IntegrationAction,
|
||||
contractWidgetKind: TContractWidgetKind,
|
||||
additionalWidgetKinds: TAdditionalWidgetKinds,
|
||||
integrationKinds: readonly [TIntegrationKind, ...TIntegrationKind[]],
|
||||
): ReturnType<typeof createManyIntegrationMiddlewareForKinds<TIntegrationKind>>;
|
||||
export function createManySharedWidgetIntegrationMiddleware(
|
||||
action: IntegrationAction,
|
||||
contractWidgetKind: WidgetKindWithIntegration,
|
||||
additionalWidgetKinds: readonly [WidgetKindWithIntegration, ...WidgetKindWithIntegration[]],
|
||||
integrationKinds?: readonly IntegrationKind[],
|
||||
) {
|
||||
const configuredIntegrationKinds = integrationKinds ?? getWidgetFeatureIntegrationKinds(contractWidgetKind);
|
||||
return createManyIntegrationMiddlewareForKinds(action, configuredIntegrationKinds, {
|
||||
widgetKinds: [contractWidgetKind, ...additionalWidgetKinds],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws a TRPCError FORBIDDEN if the user does not have permission to perform the specified action on at least one of the specified integrations
|
||||
* @param action action to perform
|
||||
@@ -152,25 +358,39 @@ export const createManyIntegrationMiddleware = <TKind extends IntegrationKind>(
|
||||
const throwIfActionIsNotAllowedAsync = async (
|
||||
action: IntegrationAction,
|
||||
db: Database,
|
||||
integrations: Parameters<typeof hasQueryAccessToIntegrationsAsync>[1],
|
||||
integrationRecords: Parameters<typeof hasQueryAccessToIntegrationsAsync>[1],
|
||||
session: Session | null,
|
||||
) => {
|
||||
if (action === "interact") {
|
||||
const haveAllInteractAccess = integrations
|
||||
const haveAllInteractAccess = integrationRecords
|
||||
.map((integration) => constructIntegrationPermissions(integration, session))
|
||||
.every(({ hasInteractAccess }) => hasInteractAccess);
|
||||
if (haveAllInteractAccess) return;
|
||||
|
||||
logger.warn("Integration access denied", {
|
||||
action,
|
||||
userId: session?.user.id,
|
||||
integrationIds: integrationRecords.map(({ id }) => id),
|
||||
integrationKinds: integrationRecords.map(getIntegrationKindForLog),
|
||||
});
|
||||
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "User does not have permission to interact with at least one of the specified integrations",
|
||||
});
|
||||
}
|
||||
|
||||
const hasQueryAccess = await hasQueryAccessToIntegrationsAsync(db, integrations, session);
|
||||
const hasQueryAccess = await hasQueryAccessToIntegrationsAsync(db, integrationRecords, session);
|
||||
|
||||
if (hasQueryAccess) return;
|
||||
|
||||
logger.warn("Integration access denied", {
|
||||
action,
|
||||
userId: session?.user.id,
|
||||
integrationIds: integrationRecords.map(({ id }) => id),
|
||||
integrationKinds: integrationRecords.map(getIntegrationKindForLog),
|
||||
});
|
||||
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "User does not have permission to query at least one of the specified integration",
|
||||
|
||||
@@ -5,10 +5,10 @@ import { and, eq } from "@homarr/db";
|
||||
import { items } from "@homarr/db/schema";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
|
||||
import { publicProcedure } from "../trpc";
|
||||
import { middlewareProcedure } from "../trpc";
|
||||
|
||||
export const createOneItemMiddleware = (kind: WidgetKind) => {
|
||||
return publicProcedure.input(z.object({ itemId: z.string() })).use(async ({ input, ctx, next }) => {
|
||||
return middlewareProcedure.input(z.object({ itemId: z.string() })).use(async ({ input, ctx, next }) => {
|
||||
const item = await ctx.db.query.items.findFirst({
|
||||
where: and(eq(items.id, input.itemId), eq(items.kind, kind)),
|
||||
});
|
||||
|
||||
@@ -2,10 +2,10 @@ import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { env } from "@homarr/docker/env";
|
||||
|
||||
import { publicProcedure } from "../trpc";
|
||||
import { middlewareProcedure } from "../trpc";
|
||||
|
||||
export const kubernetesMiddleware = () => {
|
||||
return publicProcedure.use(async ({ next }) => {
|
||||
return middlewareProcedure.use(async ({ next }) => {
|
||||
if (env.ENABLE_KUBERNETES) {
|
||||
return await next();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { QueryKey } from "@tanstack/react-query";
|
||||
|
||||
import { generatedWidgetOperationPolicies } from "@homarr/definitions";
|
||||
|
||||
// RSC-prefetched data must survive the hydration pass without an immediate
|
||||
// duplicate fetch. Widget-specific intervals still control live refreshes.
|
||||
export const queryCacheDefaultStaleTimeMs = 30_000;
|
||||
@@ -11,8 +13,15 @@ const widgetDataQueryPaths = new Set(["app.byId", "app.byIds", "docker.getContai
|
||||
// Beszel system stats are large time-series payloads and were deliberately kept out of
|
||||
// the persisted cache in #6289; live stats come from an SSE subscription instead.
|
||||
const excludedWidgetPaths = new Set(["widget.app.ping", "widget.beszel.getSystemStats"]);
|
||||
const generatedWidgetPersistencePolicies = new Map(
|
||||
generatedWidgetOperationPolicies
|
||||
.filter((policy) => policy.kind === "query")
|
||||
.map((policy) => [policy.path.join("."), policy.persist] as const),
|
||||
);
|
||||
|
||||
export const isWidgetDataTrpcPath = (path: string) => {
|
||||
const generatedPolicy = generatedWidgetPersistencePolicies.get(path);
|
||||
if (generatedPolicy !== undefined) return generatedPolicy;
|
||||
if (path.startsWith("widget.")) return !excludedWidgetPaths.has(path);
|
||||
return widgetDataQueryPaths.has(path);
|
||||
};
|
||||
|
||||
@@ -25,7 +25,9 @@ import {
|
||||
assistantProviderPresets,
|
||||
assistantProviderRequiresApiKey,
|
||||
getIconUrl,
|
||||
integrationKinds,
|
||||
resolveAssistantModelId,
|
||||
widgetKinds,
|
||||
} from "@homarr/definitions";
|
||||
|
||||
import type { createTRPCContext } from "../trpc";
|
||||
@@ -336,6 +338,7 @@ export const getAssistantContextEntitiesAsync = async (ctx: AssistantContext): P
|
||||
boardRouter.createCaller(ctx).getAllBoards(),
|
||||
ctx.db.query.integrations.findMany({
|
||||
columns: { id: true, name: true, kind: true },
|
||||
where: inArray(integrations.kind, integrationKinds),
|
||||
with: {
|
||||
userPermissions: {
|
||||
where: eq(integrationUserPermissions.userId, ctx.session.user.id),
|
||||
@@ -364,9 +367,12 @@ export const getAssistantContextEntitiesAsync = async (ctx: AssistantContext): P
|
||||
? []
|
||||
: await ctx.db.query.items.findMany({
|
||||
columns: { id: true, boardId: true, kind: true },
|
||||
where: inArray(
|
||||
items.boardId,
|
||||
availableBoards.map((board) => board.id),
|
||||
where: and(
|
||||
inArray(
|
||||
items.boardId,
|
||||
availableBoards.map((board) => board.id),
|
||||
),
|
||||
inArray(items.kind, widgetKinds),
|
||||
),
|
||||
orderBy: asc(items.kind),
|
||||
limit: 500,
|
||||
|
||||
+175
-122
@@ -19,9 +19,7 @@ import {
|
||||
groupMembers,
|
||||
groupPermissions,
|
||||
groups,
|
||||
integrationGroupPermissions,
|
||||
integrationItems,
|
||||
integrationUserPermissions,
|
||||
integrations,
|
||||
apps,
|
||||
itemLayouts,
|
||||
@@ -70,12 +68,11 @@ import { sectionSchema, sharedItemSchema } from "@homarr/validation/shared";
|
||||
|
||||
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../trpc";
|
||||
import { throwIfActionForbiddenAsync } from "./board/board-access";
|
||||
import { throwIfActionForbiddenAsync as throwIfIntegrationActionForbiddenAsync } from "./integration/integration-access";
|
||||
import { throwIfIntegrationActionsForbiddenAsync } from "./integration/integration-access";
|
||||
import {
|
||||
throwIfCustomWidgetBoardDuplicationForbidden,
|
||||
throwIfCustomWidgetPlacementChangeForbidden,
|
||||
} from "./board/custom-widget-placement-access";
|
||||
import { validateTimetableOptionsChangeAsync } from "./widgets/timetable";
|
||||
|
||||
interface BoardItemPlacementRectangle {
|
||||
xOffset: number;
|
||||
@@ -123,6 +120,81 @@ const findFirstAvailableBoardItemPosition = (
|
||||
return null;
|
||||
};
|
||||
|
||||
interface WidgetPlacementRecord {
|
||||
id: string;
|
||||
kind: WidgetKind;
|
||||
}
|
||||
|
||||
interface WidgetConfigurationRecord extends WidgetPlacementRecord {
|
||||
integrationIds: readonly string[];
|
||||
}
|
||||
|
||||
interface IntegrationKindRecord {
|
||||
id: string;
|
||||
kind: IntegrationKind;
|
||||
}
|
||||
|
||||
const haveSameIntegrationIds = (left: readonly string[], right: readonly string[]) =>
|
||||
left.length === right.length && left.every((integrationId) => right.includes(integrationId));
|
||||
|
||||
const validateWidgetConfigurationsAsync = async (
|
||||
ctx: Parameters<typeof throwIfIntegrationActionsForbiddenAsync>[0],
|
||||
submittedItems: readonly WidgetConfigurationRecord[],
|
||||
storedItems: readonly WidgetConfigurationRecord[],
|
||||
) => {
|
||||
const storedItemsById = new Map(storedItems.map((item) => [item.id, item]));
|
||||
const changedItems = submittedItems.filter((item) => {
|
||||
const storedItem = storedItemsById.get(item.id);
|
||||
if (!storedItem || storedItem.kind !== item.kind) return true;
|
||||
return !haveSameIntegrationIds(item.integrationIds, storedItem.integrationIds);
|
||||
});
|
||||
if (changedItems.length === 0) return;
|
||||
|
||||
const selectedIntegrationIds = [...new Set(changedItems.flatMap(({ integrationIds }) => integrationIds))];
|
||||
let integrationRecords: IntegrationKindRecord[] = [];
|
||||
if (selectedIntegrationIds.length > 0) {
|
||||
integrationRecords = await ctx.db.query.integrations.findMany({
|
||||
columns: { id: true, kind: true },
|
||||
where: inArray(integrations.id, selectedIntegrationIds),
|
||||
});
|
||||
}
|
||||
const integrationRecordsById = new Map(integrationRecords.map((integration) => [integration.id, integration]));
|
||||
const invalidIntegrationIds = selectedIntegrationIds.filter(
|
||||
(integrationId) => !integrationRecordsById.has(integrationId),
|
||||
);
|
||||
if (invalidIntegrationIds.length > 0) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Invalid integration IDs: ${invalidIntegrationIds.join(", ")}`,
|
||||
});
|
||||
}
|
||||
|
||||
const addedOrReconfiguredIntegrationIds = [
|
||||
...new Set(
|
||||
changedItems.flatMap((item) => {
|
||||
const storedItem = storedItemsById.get(item.id);
|
||||
if (!storedItem || storedItem.kind !== item.kind) return item.integrationIds;
|
||||
return item.integrationIds.filter((integrationId) => !storedItem.integrationIds.includes(integrationId));
|
||||
}),
|
||||
),
|
||||
];
|
||||
await throwIfIntegrationActionsForbiddenAsync(ctx, addedOrReconfiguredIntegrationIds, "use");
|
||||
|
||||
for (const item of changedItems) {
|
||||
const selectedIntegrationKinds = item.integrationIds.flatMap((integrationId) => {
|
||||
const integration = integrationRecordsById.get(integrationId);
|
||||
return integration ? [integration.kind] : [];
|
||||
});
|
||||
const integrationIssue = getWidgetIntegrationIssue(item.kind, selectedIntegrationKinds);
|
||||
if (!integrationIssue) continue;
|
||||
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: getWidgetIntegrationIssueMessage(item.kind, integrationIssue),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const boardRouter = createTRPCRouter({
|
||||
exists: permissionRequiredProcedure
|
||||
.requiresPermission("board-create")
|
||||
@@ -633,7 +705,7 @@ export const boardRouter = createTRPCRouter({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Duplicate an existing board into a new board. Requires board-create permission and view permission on the source board. REQUIRED: id (source board ID), name (unique name for the new board). Returns { boardId }",
|
||||
"Duplicate an existing board into a new board. Requires board-create permission, view permission on the source board, and use permission for every linked integration. Disabled or invalid widget configurations must be removed before duplication. REQUIRED: id (source board ID), name (unique name for the new board). Returns { boardId }",
|
||||
},
|
||||
})
|
||||
.input(boardDuplicateSchema)
|
||||
@@ -670,6 +742,15 @@ export const boardRouter = createTRPCRouter({
|
||||
|
||||
const { sections: boardSections, items: boardItems, layouts: boardLayouts, ...boardProps } = board;
|
||||
throwIfCustomWidgetBoardDuplicationForbidden(ctx.session.user.permissions.includes("admin"), boardItems);
|
||||
await validateWidgetConfigurationsAsync(
|
||||
ctx,
|
||||
boardItems.map((item) => ({
|
||||
id: item.id,
|
||||
kind: item.kind,
|
||||
integrationIds: item.integrations.map(({ integrationId }) => integrationId),
|
||||
})),
|
||||
[],
|
||||
);
|
||||
|
||||
const newBoardId = createId();
|
||||
|
||||
@@ -750,17 +831,19 @@ export const boardRouter = createTRPCRouter({
|
||||
|
||||
const sectionLayoutsToInsert: InferInsertModel<typeof sectionLayouts>[] = boardSections
|
||||
.flatMap((section) =>
|
||||
section.layouts.map((layoutSection): InferInsertModel<typeof sectionLayouts> => ({
|
||||
...layoutSection,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
layoutId: layoutsMap.get(layoutSection.layoutId)!,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
sectionId: sectionMap.get(layoutSection.sectionId)!,
|
||||
parentSectionId: layoutSection.parentSectionId
|
||||
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
sectionMap.get(layoutSection.parentSectionId)!
|
||||
: layoutSection.parentSectionId,
|
||||
})),
|
||||
section.layouts.map(
|
||||
(layoutSection): InferInsertModel<typeof sectionLayouts> => ({
|
||||
...layoutSection,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
layoutId: layoutsMap.get(layoutSection.layoutId)!,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
sectionId: sectionMap.get(layoutSection.sectionId)!,
|
||||
parentSectionId: layoutSection.parentSectionId
|
||||
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
sectionMap.get(layoutSection.parentSectionId)!
|
||||
: layoutSection.parentSectionId,
|
||||
}),
|
||||
),
|
||||
)
|
||||
.concat(
|
||||
(generatedMobilePositions?.sectionLayouts ?? []).map((layoutSection) => ({
|
||||
@@ -777,11 +860,13 @@ export const boardRouter = createTRPCRouter({
|
||||
);
|
||||
const sectionCollapseStatesToInsert: InferInsertModel<typeof sectionCollapseStates>[] = boardSections.flatMap(
|
||||
(section) =>
|
||||
section.collapseStates.map((collapseState): InferInsertModel<typeof sectionCollapseStates> => ({
|
||||
...collapseState,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
sectionId: sectionMap.get(collapseState.sectionId)!,
|
||||
})),
|
||||
section.collapseStates.map(
|
||||
(collapseState): InferInsertModel<typeof sectionCollapseStates> => ({
|
||||
...collapseState,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
sectionId: sectionMap.get(collapseState.sectionId)!,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const itemMap = new Map<string, string>(boardItems.map((item) => [item.id, createId()]));
|
||||
@@ -796,15 +881,17 @@ export const boardRouter = createTRPCRouter({
|
||||
|
||||
const itemLayoutsToInsert: InferInsertModel<typeof itemLayouts>[] = boardItems
|
||||
.flatMap((item) =>
|
||||
item.layouts.map((layoutSection): InferInsertModel<typeof itemLayouts> => ({
|
||||
...layoutSection,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
sectionId: sectionMap.get(layoutSection.sectionId)!,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
itemId: itemMap.get(layoutSection.itemId)!,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
layoutId: layoutsMap.get(layoutSection.layoutId)!,
|
||||
})),
|
||||
item.layouts.map(
|
||||
(layoutSection): InferInsertModel<typeof itemLayouts> => ({
|
||||
...layoutSection,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
sectionId: sectionMap.get(layoutSection.sectionId)!,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
itemId: itemMap.get(layoutSection.itemId)!,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
layoutId: layoutsMap.get(layoutSection.layoutId)!,
|
||||
}),
|
||||
),
|
||||
)
|
||||
.concat(
|
||||
(generatedMobilePositions?.itemSectionLayouts ?? []).map((layoutSection) => ({
|
||||
@@ -818,34 +905,12 @@ export const boardRouter = createTRPCRouter({
|
||||
})),
|
||||
);
|
||||
|
||||
// Creates a list with all integration ids the user has access to
|
||||
const hasAccessForAll = ctx.session.user.permissions.includes("integration-use-all");
|
||||
const integrationIdsWithAccess = hasAccessForAll
|
||||
? []
|
||||
: await ctx.db
|
||||
.selectDistinct({
|
||||
id: integrationGroupPermissions.integrationId,
|
||||
})
|
||||
.from(integrationGroupPermissions)
|
||||
.leftJoin(groupMembers, eq(integrationGroupPermissions.groupId, groupMembers.groupId))
|
||||
.where(eq(groupMembers.userId, ctx.session.user.id))
|
||||
.union(
|
||||
ctx.db
|
||||
.selectDistinct({ id: integrationUserPermissions.integrationId })
|
||||
.from(integrationUserPermissions)
|
||||
.where(eq(integrationUserPermissions.userId, ctx.session.user.id)),
|
||||
)
|
||||
.then((result) => result.map((row) => row.id));
|
||||
|
||||
const itemIntegrationsToInsert = boardItems.flatMap((item) =>
|
||||
item.integrations
|
||||
// Restrict integrations to only those the user has access to
|
||||
.filter(({ integrationId }) => integrationIdsWithAccess.includes(integrationId) || hasAccessForAll)
|
||||
.map((integration) => ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
itemId: itemMap.get(item.id)!,
|
||||
integrationId: integration.integrationId,
|
||||
})),
|
||||
item.integrations.map((integration) => ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
itemId: itemMap.get(item.id)!,
|
||||
integrationId: integration.integrationId,
|
||||
})),
|
||||
);
|
||||
|
||||
await handleTransactionsAsync(ctx.db, {
|
||||
@@ -1488,6 +1553,7 @@ export const boardRouter = createTRPCRouter({
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "modify");
|
||||
|
||||
const dbBoard = await getFullBoardWithWhereAsync(ctx.db, eq(boards.id, input.id), ctx.session.user.id);
|
||||
await validateWidgetConfigurationsAsync(ctx, input.items, dbBoard.items);
|
||||
throwIfCustomWidgetPlacementChangeForbidden({
|
||||
isAdmin: ctx.session.user.permissions.includes("admin"),
|
||||
submittedItems: input.items,
|
||||
@@ -1497,6 +1563,7 @@ export const boardRouter = createTRPCRouter({
|
||||
for (const item of input.items) {
|
||||
if (item.kind !== "timetable") continue;
|
||||
const previousItem = dbBoard.items.find((dbItem) => dbItem.id === item.id);
|
||||
const { validateTimetableOptionsChangeAsync } = await import("./timetable-options-validation");
|
||||
await validateTimetableOptionsChangeAsync(
|
||||
item.options,
|
||||
previousItem?.kind === "timetable" ? previousItem.options : undefined,
|
||||
@@ -1524,15 +1591,17 @@ export const boardRouter = createTRPCRouter({
|
||||
const sectionLayoutsToInsert = addedSections
|
||||
.filter((section) => section.kind === "container")
|
||||
.flatMap((section) =>
|
||||
section.layouts.map((sectionLayout): InferInsertModel<typeof schema.sectionLayouts> => ({
|
||||
layoutId: sectionLayout.layoutId,
|
||||
sectionId: section.id,
|
||||
parentSectionId: sectionLayout.parentSectionId,
|
||||
height: sectionLayout.height,
|
||||
width: sectionLayout.width,
|
||||
xOffset: sectionLayout.xOffset,
|
||||
yOffset: sectionLayout.yOffset,
|
||||
})),
|
||||
section.layouts.map(
|
||||
(sectionLayout): InferInsertModel<typeof schema.sectionLayouts> => ({
|
||||
layoutId: sectionLayout.layoutId,
|
||||
sectionId: section.id,
|
||||
parentSectionId: sectionLayout.parentSectionId,
|
||||
height: sectionLayout.height,
|
||||
width: sectionLayout.width,
|
||||
xOffset: sectionLayout.xOffset,
|
||||
yOffset: sectionLayout.yOffset,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
if (sectionLayoutsToInsert.length > 0) {
|
||||
@@ -1554,15 +1623,17 @@ export const boardRouter = createTRPCRouter({
|
||||
);
|
||||
await transaction.insert(schema.itemLayouts).values(
|
||||
addedItems.flatMap((item) =>
|
||||
item.layouts.map((layoutSection): InferInsertModel<typeof schema.itemLayouts> => ({
|
||||
layoutId: layoutSection.layoutId,
|
||||
sectionId: layoutSection.sectionId,
|
||||
itemId: item.id,
|
||||
height: layoutSection.height,
|
||||
width: layoutSection.width,
|
||||
xOffset: layoutSection.xOffset,
|
||||
yOffset: layoutSection.yOffset,
|
||||
})),
|
||||
item.layouts.map(
|
||||
(layoutSection): InferInsertModel<typeof schema.itemLayouts> => ({
|
||||
layoutId: layoutSection.layoutId,
|
||||
sectionId: layoutSection.sectionId,
|
||||
itemId: item.id,
|
||||
height: layoutSection.height,
|
||||
width: layoutSection.width,
|
||||
xOffset: layoutSection.xOffset,
|
||||
yOffset: layoutSection.yOffset,
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1720,15 +1791,17 @@ export const boardRouter = createTRPCRouter({
|
||||
const sectionLayoutsToInsert = addedSections
|
||||
.filter((section) => section.kind === "container")
|
||||
.flatMap((section) =>
|
||||
section.layouts.map((sectionLayout): InferInsertModel<typeof sectionLayouts> => ({
|
||||
layoutId: sectionLayout.layoutId,
|
||||
sectionId: section.id,
|
||||
parentSectionId: sectionLayout.parentSectionId,
|
||||
height: sectionLayout.height,
|
||||
width: sectionLayout.width,
|
||||
xOffset: sectionLayout.xOffset,
|
||||
yOffset: sectionLayout.yOffset,
|
||||
})),
|
||||
section.layouts.map(
|
||||
(sectionLayout): InferInsertModel<typeof sectionLayouts> => ({
|
||||
layoutId: sectionLayout.layoutId,
|
||||
sectionId: section.id,
|
||||
parentSectionId: sectionLayout.parentSectionId,
|
||||
height: sectionLayout.height,
|
||||
width: sectionLayout.width,
|
||||
xOffset: sectionLayout.xOffset,
|
||||
yOffset: sectionLayout.yOffset,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
if (sectionLayoutsToInsert.length > 0) {
|
||||
@@ -1755,15 +1828,17 @@ export const boardRouter = createTRPCRouter({
|
||||
.insert(itemLayouts)
|
||||
.values(
|
||||
addedItems.flatMap((item) =>
|
||||
item.layouts.map((layoutSection): InferInsertModel<typeof itemLayouts> => ({
|
||||
layoutId: layoutSection.layoutId,
|
||||
sectionId: layoutSection.sectionId,
|
||||
itemId: item.id,
|
||||
height: layoutSection.height,
|
||||
width: layoutSection.width,
|
||||
xOffset: layoutSection.xOffset,
|
||||
yOffset: layoutSection.yOffset,
|
||||
})),
|
||||
item.layouts.map(
|
||||
(layoutSection): InferInsertModel<typeof itemLayouts> => ({
|
||||
layoutId: layoutSection.layoutId,
|
||||
sectionId: layoutSection.sectionId,
|
||||
itemId: item.id,
|
||||
height: layoutSection.height,
|
||||
width: layoutSection.width,
|
||||
xOffset: layoutSection.xOffset,
|
||||
yOffset: layoutSection.yOffset,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
.run();
|
||||
@@ -2066,6 +2141,12 @@ export const boardRouter = createTRPCRouter({
|
||||
.output(z.object({ itemId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify");
|
||||
|
||||
await validateWidgetConfigurationsAsync(
|
||||
ctx,
|
||||
[{ id: "", kind: input.kind, integrationIds: input.integrationIds }],
|
||||
[],
|
||||
);
|
||||
throwIfCustomWidgetPlacementChangeForbidden({
|
||||
isAdmin: ctx.session.user.permissions.includes("admin"),
|
||||
submittedItems: [{ id: "", kind: input.kind, options: input.options }],
|
||||
@@ -2073,38 +2154,10 @@ export const boardRouter = createTRPCRouter({
|
||||
});
|
||||
|
||||
if (input.kind === "timetable") {
|
||||
const { validateTimetableOptionsChangeAsync } = await import("./timetable-options-validation");
|
||||
await validateTimetableOptionsChangeAsync(input.options);
|
||||
}
|
||||
|
||||
let selectedIntegrationKinds: IntegrationKind[] = [];
|
||||
if (input.integrationIds.length > 0) {
|
||||
const existing = await ctx.db.query.integrations.findMany({
|
||||
columns: { id: true, kind: true },
|
||||
where: inArray(integrations.id, input.integrationIds),
|
||||
});
|
||||
const validIds = new Set(existing.map((row) => row.id));
|
||||
const invalid = input.integrationIds.filter((id) => !validIds.has(id));
|
||||
if (invalid.length > 0) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: `Invalid integration IDs: ${invalid.join(", ")}` });
|
||||
}
|
||||
|
||||
selectedIntegrationKinds = existing.map((integration) => integration.kind);
|
||||
|
||||
await Promise.all(
|
||||
input.integrationIds.map((integrationId) =>
|
||||
throwIfIntegrationActionForbiddenAsync(ctx, eq(integrations.id, integrationId), "use"),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const integrationIssue = getWidgetIntegrationIssue(input.kind, selectedIntegrationKinds);
|
||||
if (integrationIssue) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: getWidgetIntegrationIssueMessage(input.kind, integrationIssue),
|
||||
});
|
||||
}
|
||||
|
||||
return await serializeBoardItemPlacementAsync(input.boardId, async () => {
|
||||
const board = await ctx.db.query.boards.findFirst({
|
||||
where: eq(boards.id, input.boardId),
|
||||
|
||||
@@ -19,12 +19,12 @@ import {
|
||||
import { getAppManagementAccess, getIntegrationManagementAccess } from "@homarr/definitions";
|
||||
import { createTRPCRouter, publicProcedure } from "../trpc";
|
||||
|
||||
const getFullAccessIntegrationIdsAsync = async (db: Database, userId: string) => {
|
||||
const groupsOfCurrentUser = await db.query.groupMembers.findMany({
|
||||
const getFullAccessIntegrationIdsAsync = async (database: Database, userId: string) => {
|
||||
const groupsOfCurrentUser = await database.query.groupMembers.findMany({
|
||||
where: eq(groupMembers.userId, userId),
|
||||
});
|
||||
|
||||
const accessibleIntegrations = await db.query.integrations.findMany({
|
||||
const accessibleIntegrations = await database.query.integrations.findMany({
|
||||
columns: {
|
||||
id: true,
|
||||
},
|
||||
|
||||
@@ -4,9 +4,26 @@ import type { Session } from "@homarr/auth";
|
||||
import { constructIntegrationPermissions } from "@homarr/auth/shared";
|
||||
import type { Database, SQL } from "@homarr/db";
|
||||
import { eq, inArray } from "@homarr/db";
|
||||
import { groupMembers, integrationGroupPermissions, integrationUserPermissions } from "@homarr/db/schema";
|
||||
import { groupMembers, integrationGroupPermissions, integrations, integrationUserPermissions } from "@homarr/db/schema";
|
||||
import type { IntegrationPermission } from "@homarr/definitions";
|
||||
|
||||
interface IntegrationPermissionRecord {
|
||||
userPermissions: { permission: IntegrationPermission }[];
|
||||
groupPermissions: { permission: IntegrationPermission }[];
|
||||
}
|
||||
|
||||
const hasIntegrationPermission = (
|
||||
integration: IntegrationPermissionRecord,
|
||||
session: Session | null,
|
||||
permission: IntegrationPermission,
|
||||
) => {
|
||||
const { hasUseAccess, hasInteractAccess, hasFullAccess } = constructIntegrationPermissions(integration, session);
|
||||
if (hasFullAccess) return true;
|
||||
if (permission === "interact") return hasInteractAccess;
|
||||
if (permission === "use") return hasUseAccess;
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Throws NOT_FOUND if user is not allowed to perform action on integration
|
||||
* @param ctx trpc router context
|
||||
@@ -44,23 +61,51 @@ export const throwIfActionForbiddenAsync = async (
|
||||
notAllowed();
|
||||
}
|
||||
|
||||
const { hasUseAccess, hasInteractAccess, hasFullAccess } = constructIntegrationPermissions(integration, session);
|
||||
|
||||
if (hasFullAccess) {
|
||||
return; // As full access is required and user has full access, allow
|
||||
}
|
||||
|
||||
if (["interact", "use"].includes(permission) && hasInteractAccess) {
|
||||
return; // As interact access is required and user has interact access, allow
|
||||
}
|
||||
|
||||
if (permission === "use" && hasUseAccess) {
|
||||
return; // As use access is required and user has use access, allow
|
||||
}
|
||||
if (hasIntegrationPermission(integration, session, permission)) return;
|
||||
|
||||
notAllowed();
|
||||
};
|
||||
|
||||
/**
|
||||
* Batched variant for feature configuration flows. It applies the same direct
|
||||
* user, group, and global permission rules without issuing one query per ID.
|
||||
*/
|
||||
export const throwIfIntegrationActionsForbiddenAsync = async (
|
||||
ctx: { db: Database; session: Session | null },
|
||||
integrationIds: readonly string[],
|
||||
permission: IntegrationPermission,
|
||||
) => {
|
||||
const uniqueIntegrationIds = [...new Set(integrationIds)];
|
||||
if (uniqueIntegrationIds.length === 0) return;
|
||||
|
||||
const { db, session } = ctx;
|
||||
const groupsOfCurrentUser = await db.query.groupMembers.findMany({
|
||||
where: eq(groupMembers.userId, session?.user.id ?? ""),
|
||||
});
|
||||
const integrationRecords = await db.query.integrations.findMany({
|
||||
where: inArray(integrations.id, uniqueIntegrationIds),
|
||||
columns: { id: true },
|
||||
with: {
|
||||
userPermissions: {
|
||||
where: eq(integrationUserPermissions.userId, session?.user.id ?? ""),
|
||||
},
|
||||
groupPermissions: {
|
||||
where: inArray(
|
||||
integrationGroupPermissions.groupId,
|
||||
groupsOfCurrentUser.map(({ groupId }) => groupId).concat(""),
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (
|
||||
integrationRecords.length !== uniqueIntegrationIds.length ||
|
||||
!integrationRecords.every((integration) => hasIntegrationPermission(integration, session, permission))
|
||||
) {
|
||||
notAllowed();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* This method returns NOT_FOUND to prevent snooping on board existence
|
||||
* A function is used to use the method without return statement
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
integrationKinds,
|
||||
integrationSecretKindObject,
|
||||
} from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import { createIntegrationAsync } from "@homarr/integrations/factory";
|
||||
import { invalidateIntegrationCacheAsync } from "@homarr/redis";
|
||||
import { byIdSchema } from "@homarr/validation/common";
|
||||
import {
|
||||
@@ -156,9 +156,21 @@ export const integrationRouter = createTRPCRouter({
|
||||
})
|
||||
.input(byIdSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
|
||||
const integrationWhere = eq(integrations.id, input.id);
|
||||
|
||||
await throwIfActionForbiddenAsync(ctx, integrationWhere, "full").catch((error) => {
|
||||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||||
logger.warn("Integration lookup refused", {
|
||||
integrationId: input.id,
|
||||
errorCode: error.code,
|
||||
reason: "integration-not-found-or-forbidden",
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
|
||||
const integration = await ctx.db.query.integrations.findFirst({
|
||||
where: eq(integrations.id, input.id),
|
||||
where: integrationWhere,
|
||||
with: {
|
||||
secrets: {
|
||||
columns: {
|
||||
@@ -179,6 +191,11 @@ export const integrationRouter = createTRPCRouter({
|
||||
});
|
||||
|
||||
if (!integration) {
|
||||
logger.warn("Integration lookup refused", {
|
||||
integrationId: input.id,
|
||||
errorCode: "NOT_FOUND",
|
||||
reason: "integration-not-found",
|
||||
});
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Integration not found",
|
||||
|
||||
@@ -4,7 +4,7 @@ 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 { createIntegrationAsync } from "@homarr/integrations/factory";
|
||||
|
||||
const logger = createLogger({ module: "integrationTestConnection" });
|
||||
|
||||
|
||||
@@ -218,6 +218,7 @@ export const onboardRouter = createTRPCRouter({
|
||||
const discoveredApps: DiscoveredApp[] = [];
|
||||
|
||||
for (const service of discovery.services) {
|
||||
const widgetKind = service.widgetKind;
|
||||
if (service.integrationKind) {
|
||||
discoveredIntegrations.push({
|
||||
sourceId: service.sourceId,
|
||||
@@ -231,7 +232,7 @@ export const onboardRouter = createTRPCRouter({
|
||||
group: service.group,
|
||||
boardName: service.boardName,
|
||||
host: service.host,
|
||||
widgetKind: service.widgetKind,
|
||||
widgetKind,
|
||||
description: service.description,
|
||||
pingUrl: service.pingUrl,
|
||||
});
|
||||
@@ -249,7 +250,7 @@ export const onboardRouter = createTRPCRouter({
|
||||
description: service.description,
|
||||
pingUrl: service.pingUrl,
|
||||
host: service.host,
|
||||
widgetKind: service.widgetKind,
|
||||
widgetKind,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import * as homarrDefinitions from "@homarr/definitions";
|
||||
import * as homarrIntegrations from "@homarr/integrations";
|
||||
import type * as homarrIntegrations from "@homarr/integrations";
|
||||
import * as homarrIntegrationFactory from "@homarr/integrations/factory";
|
||||
|
||||
import { testConnectionAsync } from "../../integration/integration-test-connection";
|
||||
|
||||
@@ -18,7 +19,7 @@ vi.mock("@homarr/common/server", async (importActual) => {
|
||||
describe("testConnectionAsync should run test connection of integration", () => {
|
||||
test("with input of only form secrets matching api key kind it should use form apiKey", async () => {
|
||||
// Arrange
|
||||
const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
|
||||
const factorySpy = vi.spyOn(homarrIntegrationFactory, "createIntegrationAsync");
|
||||
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
|
||||
factorySpy.mockReturnValue(
|
||||
Promise.resolve({
|
||||
@@ -61,7 +62,7 @@ describe("testConnectionAsync should run test connection of integration", () =>
|
||||
|
||||
test("with input of only null form secrets and the required db secrets matching api key kind it should use db apiKey", async () => {
|
||||
// Arrange
|
||||
const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
|
||||
const factorySpy = vi.spyOn(homarrIntegrationFactory, "createIntegrationAsync");
|
||||
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
|
||||
factorySpy.mockReturnValue(
|
||||
Promise.resolve({
|
||||
@@ -111,7 +112,7 @@ describe("testConnectionAsync should run test connection of integration", () =>
|
||||
|
||||
test("with input of form and db secrets matching api key kind it should use form apiKey", async () => {
|
||||
// Arrange
|
||||
const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
|
||||
const factorySpy = vi.spyOn(homarrIntegrationFactory, "createIntegrationAsync");
|
||||
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
|
||||
factorySpy.mockReturnValue(
|
||||
Promise.resolve({
|
||||
@@ -161,7 +162,7 @@ describe("testConnectionAsync should run test connection of integration", () =>
|
||||
|
||||
test("with input of form apiKey and db secrets for username and password it should use form apiKey when both is allowed", async () => {
|
||||
// Arrange
|
||||
const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
|
||||
const factorySpy = vi.spyOn(homarrIntegrationFactory, "createIntegrationAsync");
|
||||
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
|
||||
factorySpy.mockReturnValue(
|
||||
Promise.resolve({
|
||||
@@ -215,7 +216,7 @@ describe("testConnectionAsync should run test connection of integration", () =>
|
||||
|
||||
test("with input of null form apiKey and db secrets for username and password it should use db username and password when both is allowed", async () => {
|
||||
// Arrange
|
||||
const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
|
||||
const factorySpy = vi.spyOn(homarrIntegrationFactory, "createIntegrationAsync");
|
||||
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
|
||||
factorySpy.mockReturnValue(
|
||||
Promise.resolve({
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { lookup } from "node:dns/promises";
|
||||
import { BlockList, isIP } from "node:net";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { parse } from "superjson";
|
||||
import z from "zod";
|
||||
|
||||
import type { TimetableResolvedAddress } from "@homarr/request-handler/timetable";
|
||||
import { DEFAULT_TIMETABLE_BASE_URL, normalizeTimetableBaseUrl } from "@homarr/request-handler/timetable-url";
|
||||
|
||||
const timetableOptionsSchema = z.object({ baseUrl: z.string().optional() }).passthrough();
|
||||
|
||||
const blockedTimetableIpv4Addresses = new BlockList();
|
||||
const blockedTimetableIpv6Addresses = new BlockList();
|
||||
const blockedTimetableSubnets = [
|
||||
["0.0.0.0", 8, "ipv4"],
|
||||
["10.0.0.0", 8, "ipv4"],
|
||||
["100.64.0.0", 10, "ipv4"],
|
||||
["127.0.0.0", 8, "ipv4"],
|
||||
["169.254.0.0", 16, "ipv4"],
|
||||
["172.16.0.0", 12, "ipv4"],
|
||||
["192.0.0.0", 24, "ipv4"],
|
||||
["192.0.2.0", 24, "ipv4"],
|
||||
["192.88.99.0", 24, "ipv4"],
|
||||
["192.168.0.0", 16, "ipv4"],
|
||||
["198.18.0.0", 15, "ipv4"],
|
||||
["198.51.100.0", 24, "ipv4"],
|
||||
["203.0.113.0", 24, "ipv4"],
|
||||
["224.0.0.0", 4, "ipv4"],
|
||||
["240.0.0.0", 4, "ipv4"],
|
||||
["::", 96, "ipv6"],
|
||||
["::ffff:0:0", 96, "ipv6"],
|
||||
["::ffff:0:0:0", 96, "ipv6"],
|
||||
["64:ff9b::", 96, "ipv6"],
|
||||
["64:ff9b:1::", 48, "ipv6"],
|
||||
["100::", 64, "ipv6"],
|
||||
["2001::", 32, "ipv6"],
|
||||
["2001:2::", 48, "ipv6"],
|
||||
["2001:10::", 28, "ipv6"],
|
||||
["2001:db8::", 32, "ipv6"],
|
||||
["2002::", 16, "ipv6"],
|
||||
["fc00::", 7, "ipv6"],
|
||||
["fe80::", 10, "ipv6"],
|
||||
["fec0::", 10, "ipv6"],
|
||||
["ff00::", 8, "ipv6"],
|
||||
] as const;
|
||||
|
||||
for (const [network, prefix, family] of blockedTimetableSubnets) {
|
||||
const blockList = family === "ipv4" ? blockedTimetableIpv4Addresses : blockedTimetableIpv6Addresses;
|
||||
blockList.addSubnet(network, prefix, family);
|
||||
}
|
||||
|
||||
export const normalizeTimetableBaseUrlOrThrowBadRequest = (baseUrl: string) => {
|
||||
try {
|
||||
return normalizeTimetableBaseUrl(baseUrl);
|
||||
} catch (cause) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: cause instanceof Error ? cause.message : "Invalid timetable base URL",
|
||||
cause,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const throwInvalidSavedTimetableConfiguration = (cause: unknown): never => {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Timetable widget configuration is invalid",
|
||||
cause,
|
||||
});
|
||||
};
|
||||
|
||||
export const parseSavedTimetableOptions = (serializedOptions: string) => {
|
||||
try {
|
||||
return timetableOptionsSchema.parse(parse<unknown>(serializedOptions));
|
||||
} catch (cause) {
|
||||
return throwInvalidSavedTimetableConfiguration(cause);
|
||||
}
|
||||
};
|
||||
|
||||
export const normalizeSavedTimetableBaseUrl = (baseUrl: string) => {
|
||||
try {
|
||||
return normalizeTimetableBaseUrl(baseUrl);
|
||||
} catch (cause) {
|
||||
return throwInvalidSavedTimetableConfiguration(cause);
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeHostname = (hostname: string) => {
|
||||
const withoutBrackets = hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
|
||||
return withoutBrackets.toLowerCase().replace(/\.$/, "");
|
||||
};
|
||||
|
||||
const isBlockedTimetableAddress = (address: string, family: number) => {
|
||||
return family === 6
|
||||
? blockedTimetableIpv6Addresses.check(address, "ipv6")
|
||||
: blockedTimetableIpv4Addresses.check(address, "ipv4");
|
||||
};
|
||||
|
||||
export const resolvePublicTimetableAddressesAsync = async (baseUrl: string): Promise<TimetableResolvedAddress[]> => {
|
||||
const hostname = normalizeHostname(new URL(baseUrl).hostname);
|
||||
const hostnameFamily = isIP(hostname);
|
||||
const isInternalHostname =
|
||||
hostnameFamily === 0 &&
|
||||
(!hostname.includes(".") ||
|
||||
hostname === "localhost" ||
|
||||
hostname.endsWith(".localhost") ||
|
||||
hostname.endsWith(".local") ||
|
||||
hostname.endsWith(".internal") ||
|
||||
hostname.endsWith(".lan") ||
|
||||
hostname.endsWith(".home.arpa"));
|
||||
|
||||
if (isInternalHostname) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Timetable base URL must use a public host" });
|
||||
}
|
||||
|
||||
let addresses: TimetableResolvedAddress[];
|
||||
try {
|
||||
const resolvedAddresses =
|
||||
hostnameFamily === 0
|
||||
? await lookup(hostname, { all: true, verbatim: true })
|
||||
: [{ address: hostname, family: hostnameFamily }];
|
||||
addresses = resolvedAddresses.filter(
|
||||
(entry): entry is TimetableResolvedAddress => entry.family === 4 || entry.family === 6,
|
||||
);
|
||||
} catch (cause) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Timetable base URL host could not be resolved",
|
||||
cause,
|
||||
});
|
||||
}
|
||||
|
||||
if (addresses.length === 0 || addresses.some(({ address, family }) => isBlockedTimetableAddress(address, family))) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Timetable base URL must not target a private or internal address",
|
||||
});
|
||||
}
|
||||
|
||||
return addresses.toSorted((left, right) => left.family - right.family || left.address.localeCompare(right.address));
|
||||
};
|
||||
|
||||
const normalizeTimetableOptionsBaseUrlOrThrowBadRequest = (options: Record<string, unknown>) => {
|
||||
const parsedOptions = timetableOptionsSchema.safeParse(options);
|
||||
if (!parsedOptions.success) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid timetable widget options" });
|
||||
}
|
||||
return normalizeTimetableBaseUrlOrThrowBadRequest(parsedOptions.data.baseUrl ?? DEFAULT_TIMETABLE_BASE_URL);
|
||||
};
|
||||
|
||||
export const validateTimetableOptionsChangeAsync = async (
|
||||
nextOptions: Record<string, unknown>,
|
||||
previousOptions?: Record<string, unknown>,
|
||||
) => {
|
||||
const nextBaseUrl = normalizeTimetableOptionsBaseUrlOrThrowBadRequest(nextOptions);
|
||||
if (nextBaseUrl === DEFAULT_TIMETABLE_BASE_URL) return;
|
||||
|
||||
let previousBaseUrl: string | undefined;
|
||||
if (previousOptions) {
|
||||
try {
|
||||
previousBaseUrl = normalizeTimetableOptionsBaseUrlOrThrowBadRequest(previousOptions);
|
||||
} catch {
|
||||
// Invalid legacy options must not prevent replacing them with a valid configuration.
|
||||
}
|
||||
}
|
||||
if (nextBaseUrl === previousBaseUrl) return;
|
||||
|
||||
await resolvePublicTimetableAddressesAsync(nextBaseUrl);
|
||||
};
|
||||
@@ -3,10 +3,11 @@ import { z } from "zod/v4";
|
||||
|
||||
import { ResponseError } from "@homarr/common/server";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { anchorNotesListInputSchema, anchorNoteUpdateInputSchema, createIntegrationAsync } from "@homarr/integrations";
|
||||
import { anchorNotesListInputSchema, anchorNoteUpdateInputSchema } from "@homarr/integrations/anchor";
|
||||
import { createIntegrationAsync } from "@homarr/integrations/factory";
|
||||
import { anchorNoteRequestHandler, anchorNotesListRequestHandler } from "@homarr/request-handler/anchor-notes";
|
||||
|
||||
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
|
||||
|
||||
const noteIdInput = z.object({
|
||||
@@ -40,7 +41,7 @@ const normalizeAnchorContent = (content: string | undefined) => {
|
||||
|
||||
export const anchorNotesRouter = createTRPCRouter({
|
||||
listNotes: publicProcedure
|
||||
.concat(createOneIntegrationMiddleware("query", "anchor"))
|
||||
.concat(createOneWidgetIntegrationMiddleware("query", "anchorNote"))
|
||||
.input(anchorNotesListInputSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const handler = anchorNotesListRequestHandler.handler(ctx.integration, { ...input, limit: input.limit ?? 50 });
|
||||
@@ -50,7 +51,7 @@ export const anchorNotesRouter = createTRPCRouter({
|
||||
return data;
|
||||
}),
|
||||
getNote: publicProcedure
|
||||
.concat(createOneIntegrationMiddleware("query", "anchor"))
|
||||
.concat(createOneWidgetIntegrationMiddleware("query", "anchorNote"))
|
||||
.input(noteIdInput)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const handler = anchorNoteRequestHandler.handler(ctx.integration, { noteId: input.noteId });
|
||||
@@ -60,7 +61,7 @@ export const anchorNotesRouter = createTRPCRouter({
|
||||
return data;
|
||||
}),
|
||||
updateNote: protectedProcedure
|
||||
.concat(createOneIntegrationMiddleware("interact", "anchor"))
|
||||
.concat(createOneWidgetIntegrationMiddleware("interact", "anchorNote"))
|
||||
.input(anchorNoteUpdateInputSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const integrationInstance = await createIntegrationAsync(ctx.integration);
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { archiveTeamWarriorRequestHandler } from "@homarr/request-handler/archive-team-warrior";
|
||||
|
||||
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
export const archiveTeamWarriorRouter = createTRPCRouter({
|
||||
getStatus: publicProcedure
|
||||
.concat(createOneIntegrationMiddleware("query", "archiveTeamWarrior"))
|
||||
.concat(createOneWidgetIntegrationMiddleware("query", "archiveTeamWarrior"))
|
||||
.query(async ({ ctx }) => {
|
||||
const handler = archiveTeamWarriorRequestHandler.handler(ctx.integration, {});
|
||||
const { data, timestamp } = await handler.getDataAsync();
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { audiobookshelfRequestHandler } from "@homarr/request-handler/audiobookshelf";
|
||||
import { navidromeRequestHandler } from "@homarr/request-handler/navidrome";
|
||||
|
||||
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
export const audioStatsRouter = createTRPCRouter({
|
||||
getStats: publicProcedure
|
||||
.concat(createOneIntegrationMiddleware("query", "navidrome", "audiobookshelf"))
|
||||
.concat(createOneWidgetIntegrationMiddleware("query", "audioStats"))
|
||||
.query(async ({ ctx }) => {
|
||||
const { kind } = ctx.integration;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { bazarrBadgesRequestHandler } from "@homarr/request-handler/bazarr";
|
||||
|
||||
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
export const bazarrRouter = createTRPCRouter({
|
||||
@@ -12,7 +12,7 @@ export const bazarrRouter = createTRPCRouter({
|
||||
"Get missing subtitle counts, provider issues, and health warnings for a Bazarr integration. REQUIRED: integrationId from integration_all",
|
||||
},
|
||||
})
|
||||
.concat(createOneIntegrationMiddleware("query", "bazarr"))
|
||||
.concat(createOneWidgetIntegrationMiddleware("query", "bazarr"))
|
||||
.query(async ({ ctx }) => {
|
||||
const innerHandler = bazarrBadgesRequestHandler.handler(ctx.integration, {});
|
||||
const data = await innerHandler.getDataAsync();
|
||||
|
||||
@@ -2,7 +2,7 @@ import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import { createIntegrationAsync } from "@homarr/integrations/factory";
|
||||
import type { LiveStatsEvent } from "@homarr/integrations/types";
|
||||
import {
|
||||
beszelAlertsRequestHandler,
|
||||
@@ -11,7 +11,10 @@ import {
|
||||
} from "@homarr/request-handler/beszel";
|
||||
|
||||
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import {
|
||||
createManySharedWidgetIntegrationMiddleware,
|
||||
createManyWidgetIntegrationMiddleware,
|
||||
} from "../../middlewares/integration";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
import { BoundedAsyncQueue } from "./bounded-async-queue";
|
||||
|
||||
@@ -27,7 +30,12 @@ export const beszelRouter = createTRPCRouter({
|
||||
"Get all Beszel-monitored systems with CPU, memory, disk, GPU, network, temperature, and status. REQUIRED: integrationIds (array of Beszel integration IDs from integration_all)",
|
||||
},
|
||||
})
|
||||
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
|
||||
.concat(
|
||||
createManySharedWidgetIntegrationMiddleware("query", "beszelSystemGrid", [
|
||||
"beszelSystemTable",
|
||||
"beszelSystemStats",
|
||||
]),
|
||||
)
|
||||
.query(async ({ ctx }) => {
|
||||
const integrationIds = ctx.integrations.map((i) => i.id);
|
||||
logger.debug("getSystems called", { userId: ctx.session?.user?.id, integrationIds });
|
||||
@@ -71,7 +79,7 @@ export const beszelRouter = createTRPCRouter({
|
||||
"Get Beszel alerts and optional alert history for all monitored systems. REQUIRED: integrationIds (array of Beszel integration IDs from integration_all). OPTIONAL: includeHistory (default true), maxHistoryItems (default 10)",
|
||||
},
|
||||
})
|
||||
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
|
||||
.concat(createManyWidgetIntegrationMiddleware("query", "beszelAlerts"))
|
||||
.input(
|
||||
z.object({
|
||||
includeHistory: z.boolean().default(true),
|
||||
@@ -145,7 +153,12 @@ export const beszelRouter = createTRPCRouter({
|
||||
"Get historical Beszel system metrics (CPU, memory, disk, network, temperature) and optional Docker container stats. REQUIRED: integrationIds (pass the single integrationId from the beszel_getSystems entry containing the target system — only the first ID is used), systemId (from beszel_getSystems). OPTIONAL: timePeriod (1m/1h/12h/24h/1w/30d, default 1h), includeDocker (default true)",
|
||||
},
|
||||
})
|
||||
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
|
||||
.concat(
|
||||
createManySharedWidgetIntegrationMiddleware("query", "beszelSystemStats", [
|
||||
"beszelSystemTable",
|
||||
"beszelSystemGrid",
|
||||
]),
|
||||
)
|
||||
.input(
|
||||
z.object({
|
||||
systemId: z.string(),
|
||||
@@ -196,7 +209,12 @@ export const beszelRouter = createTRPCRouter({
|
||||
}),
|
||||
|
||||
subscribeSystemStats: publicProcedure
|
||||
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
|
||||
.concat(
|
||||
createManySharedWidgetIntegrationMiddleware("query", "beszelSystemStats", [
|
||||
"beszelSystemTable",
|
||||
"beszelSystemGrid",
|
||||
]),
|
||||
)
|
||||
.input(
|
||||
z.object({
|
||||
systemId: z.string(),
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import { radarrReleaseTypes } from "@homarr/integrations/types";
|
||||
import { calendarMonthRequestHandler } from "@homarr/request-handler/calendar";
|
||||
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
@@ -25,7 +24,7 @@ export const calendarRouter = createTRPCRouter({
|
||||
showUnmonitored: z.boolean(),
|
||||
}),
|
||||
)
|
||||
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("calendar")))
|
||||
.concat(createManyWidgetIntegrationMiddleware("query", "calendar"))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return await settleIntegrationQueries(
|
||||
ctx.integrations,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { CoolifyInstanceInfo } from "@homarr/integrations/types";
|
||||
import { coolifyRequestHandler } from "@homarr/request-handler/coolify";
|
||||
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
@@ -16,7 +16,7 @@ interface CoolifyInstanceResult {
|
||||
|
||||
export const coolifyRouter = createTRPCRouter({
|
||||
getInstancesInfo: publicProcedure
|
||||
.concat(createManyIntegrationMiddleware("query", "coolify"))
|
||||
.concat(createManyWidgetIntegrationMiddleware("query", "coolify"))
|
||||
.query(async ({ ctx }) => {
|
||||
return await settleIntegrationQueries(
|
||||
ctx.integrations,
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import { createIntegrationAsync } from "@homarr/integrations/factory";
|
||||
import type { DnsHoleSummary } from "@homarr/integrations/types";
|
||||
import { dnsHoleRequestHandler } from "@homarr/request-handler/dns-hole";
|
||||
|
||||
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import {
|
||||
createManySharedWidgetIntegrationMiddleware,
|
||||
createOneWidgetIntegrationMiddleware,
|
||||
} from "../../middlewares/integration";
|
||||
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
|
||||
|
||||
@@ -32,7 +34,7 @@ export const dnsHoleRouter = createTRPCRouter({
|
||||
"Get DNS blocking statistics from Pi-hole/AdGuard (queries, blocked, percentage). REQUIRED: integrationIds (array of Pi-hole/AdGuard integration IDs from integration_all)",
|
||||
},
|
||||
})
|
||||
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("dnsHole")))
|
||||
.concat(createManySharedWidgetIntegrationMiddleware("query", "dnsHoleSummary", ["dnsHoleControls"]))
|
||||
.query(async ({ ctx }) => {
|
||||
return await settleIntegrationQueries<(typeof ctx.integrations)[number], DnsHoleSummaryResult>(
|
||||
ctx.integrations,
|
||||
@@ -66,7 +68,7 @@ export const dnsHoleRouter = createTRPCRouter({
|
||||
"Enable DNS blocking on Pi-hole/AdGuard. REQUIRED: integrationId (single Pi-hole/AdGuard integration ID from integration_all)",
|
||||
},
|
||||
})
|
||||
.concat(createOneIntegrationMiddleware("interact", ...getIntegrationKindsByCategory("dnsHole")))
|
||||
.concat(createOneWidgetIntegrationMiddleware("interact", "dnsHoleControls"))
|
||||
.mutation(async ({ ctx: { integration } }) => {
|
||||
const client = await createIntegrationAsync(integration);
|
||||
await client.enableAsync();
|
||||
@@ -85,7 +87,7 @@ export const dnsHoleRouter = createTRPCRouter({
|
||||
duration: z.number().optional(),
|
||||
}),
|
||||
)
|
||||
.concat(createOneIntegrationMiddleware("interact", ...getIntegrationKindsByCategory("dnsHole")))
|
||||
.concat(createOneWidgetIntegrationMiddleware("interact", "dnsHoleControls"))
|
||||
.mutation(async ({ ctx: { integration }, input }) => {
|
||||
const client = await createIntegrationAsync(integration);
|
||||
await client.disableAsync(input.duration);
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import type { DownloadClientJobsAndStatus } from "@homarr/integrations";
|
||||
import { createIntegrationAsync, downloadClientItemSchema } from "@homarr/integrations";
|
||||
import { downloadClientItemSchema } from "@homarr/integrations/downloads";
|
||||
import { createIntegrationAsync } from "@homarr/integrations/factory";
|
||||
import { downloadClientRequestHandler } from "@homarr/request-handler/downloads";
|
||||
|
||||
import type { IntegrationAction } from "../../middlewares/integration";
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
|
||||
|
||||
const createDownloadClientIntegrationMiddleware = (action: IntegrationAction) =>
|
||||
createManyIntegrationMiddleware(action, ...getIntegrationKindsByCategory("downloadClient"));
|
||||
|
||||
interface DownloadClientQueryResultBase {
|
||||
integrationId: string;
|
||||
integrationName: string;
|
||||
@@ -51,7 +47,7 @@ export const downloadsRouter = createTRPCRouter({
|
||||
"Get active download jobs and queue status from connected download clients (qBittorrent, SABnzbd, Transmission, Deluge, NZBGet). REQUIRED: integrationIds (array of download client integration IDs from integration_all). OPTIONAL: limitPerIntegration (number, default 50)",
|
||||
},
|
||||
})
|
||||
.concat(createDownloadClientIntegrationMiddleware("query"))
|
||||
.concat(createManyWidgetIntegrationMiddleware("query", "downloads"))
|
||||
.input(z.object({ limitPerIntegration: z.number().default(50) }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return await settleIntegrationQueries<(typeof ctx.integrations)[number], DownloadClientQueryResult>(
|
||||
@@ -86,7 +82,7 @@ export const downloadsRouter = createTRPCRouter({
|
||||
"Pause all download queues across connected download clients. REQUIRED: integrationIds (array of download client integration IDs from integration_all)",
|
||||
},
|
||||
})
|
||||
.concat(createDownloadClientIntegrationMiddleware("interact"))
|
||||
.concat(createManyWidgetIntegrationMiddleware("interact", "downloads"))
|
||||
.mutation(async ({ ctx }) => {
|
||||
await Promise.all(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
@@ -97,7 +93,7 @@ export const downloadsRouter = createTRPCRouter({
|
||||
downloadClientRequestHandler.invalidateCache();
|
||||
}),
|
||||
pauseItem: protectedProcedure
|
||||
.concat(createDownloadClientIntegrationMiddleware("interact"))
|
||||
.concat(createManyWidgetIntegrationMiddleware("interact", "downloads"))
|
||||
.input(z.object({ item: downloadClientItemSchema }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await Promise.all(
|
||||
@@ -116,7 +112,7 @@ export const downloadsRouter = createTRPCRouter({
|
||||
"Resume all download queues across connected download clients. REQUIRED: integrationIds (array of download client integration IDs from integration_all)",
|
||||
},
|
||||
})
|
||||
.concat(createDownloadClientIntegrationMiddleware("interact"))
|
||||
.concat(createManyWidgetIntegrationMiddleware("interact", "downloads"))
|
||||
.mutation(async ({ ctx }) => {
|
||||
await Promise.all(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
@@ -127,7 +123,7 @@ export const downloadsRouter = createTRPCRouter({
|
||||
downloadClientRequestHandler.invalidateCache();
|
||||
}),
|
||||
resumeItem: protectedProcedure
|
||||
.concat(createDownloadClientIntegrationMiddleware("interact"))
|
||||
.concat(createManyWidgetIntegrationMiddleware("interact", "downloads"))
|
||||
.input(z.object({ item: downloadClientItemSchema }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await Promise.all(
|
||||
@@ -139,7 +135,7 @@ export const downloadsRouter = createTRPCRouter({
|
||||
downloadClientRequestHandler.invalidateCache();
|
||||
}),
|
||||
deleteItem: protectedProcedure
|
||||
.concat(createDownloadClientIntegrationMiddleware("interact"))
|
||||
.concat(createManyWidgetIntegrationMiddleware("interact", "downloads"))
|
||||
.input(z.object({ item: downloadClientItemSchema, fromDisk: z.boolean() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await Promise.all(
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import {
|
||||
firewallCpuRequestHandler,
|
||||
firewallInterfacesRequestHandler,
|
||||
@@ -6,11 +5,11 @@ import {
|
||||
firewallVersionRequestHandler,
|
||||
} from "@homarr/request-handler/firewall";
|
||||
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
const firewallMiddleware = createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall"));
|
||||
const firewallMiddleware = createManyWidgetIntegrationMiddleware("query", "firewall");
|
||||
|
||||
const queryFirewall = <
|
||||
THandler extends {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// This file is generated by pnpm widgets:sync. Do not edit it directly.
|
||||
|
||||
import type { AnyTRPCRouter } from "@trpc/server";
|
||||
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
|
||||
const registerGeneratedWidgetRouter = <TRouter extends AnyTRPCRouter>(
|
||||
widgetKinds: readonly [WidgetKind, ...WidgetKind[]],
|
||||
loadRouter: () => Promise<TRouter>,
|
||||
mcp: false | true | string,
|
||||
) => ({ widgetKinds, loadRouter, mcp });
|
||||
|
||||
export const generatedWidgetRouterRegistry = {
|
||||
downloads: registerGeneratedWidgetRouter(
|
||||
["downloads"],
|
||||
() => import("./downloads").then((module) => module.downloadsRouter),
|
||||
true,
|
||||
),
|
||||
weather: registerGeneratedWidgetRouter(
|
||||
["clock", "weather"],
|
||||
() => import("./weather").then((module) => module.weatherRouter),
|
||||
false,
|
||||
),
|
||||
};
|
||||
@@ -1,12 +1,17 @@
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import { createIntegrationAsync } from "@homarr/integrations/factory";
|
||||
import type { SystemHealthMonitoring } from "@homarr/integrations";
|
||||
import { SynologyIntegration } from "@homarr/integrations";
|
||||
import { clusterInfoRequestHandler, systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring";
|
||||
|
||||
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import {
|
||||
createManySharedWidgetIntegrationMiddleware,
|
||||
createOneSharedWidgetIntegrationMiddleware,
|
||||
createOneWidgetIntegrationMiddleware,
|
||||
} from "../../middlewares/integration";
|
||||
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
// This endpoint uses the generic system-info handler, so it intentionally excludes
|
||||
// health-monitoring integrations with specialized data models such as Proxmox and Coolify.
|
||||
const healthMonitoringIntegrationKinds = [
|
||||
"openmediavault",
|
||||
"dashDot",
|
||||
@@ -34,7 +39,14 @@ export const healthMonitoringRouter = createTRPCRouter({
|
||||
"Get system health status (CPU, memory, disk, network) from NAS/server monitoring integrations. REQUIRED: integrationIds (array of TrueNAS/Synology/Unraid/Glances/OpenMediaVault/DashDot integration IDs from integration_all)",
|
||||
},
|
||||
})
|
||||
.concat(createManyIntegrationMiddleware("query", ...healthMonitoringIntegrationKinds))
|
||||
.concat(
|
||||
createManySharedWidgetIntegrationMiddleware(
|
||||
"query",
|
||||
"healthMonitoring",
|
||||
["systemResources", "systemDisks"],
|
||||
healthMonitoringIntegrationKinds,
|
||||
),
|
||||
)
|
||||
.query(async ({ ctx }) => {
|
||||
return await settleIntegrationQueries<(typeof ctx.integrations)[number], SystemHealthQueryResult>(
|
||||
ctx.integrations,
|
||||
@@ -66,13 +78,9 @@ export const healthMonitoringRouter = createTRPCRouter({
|
||||
"List storage volumes from a Synology DiskStation integration for widget configuration. REQUIRED: integrationId from integration_all (Synology integration only)",
|
||||
},
|
||||
})
|
||||
.concat(createOneIntegrationMiddleware("query", "synology"))
|
||||
.concat(createOneSharedWidgetIntegrationMiddleware("query", "healthMonitoring", ["systemDisks"], ["synology"]))
|
||||
.query(async ({ ctx }) => {
|
||||
const integrationInstance = await createIntegrationAsync(ctx.integration);
|
||||
if (!(integrationInstance instanceof SynologyIntegration)) {
|
||||
throw new Error("Expected Synology integration");
|
||||
}
|
||||
|
||||
return await integrationInstance.listStorageVolumesAsync();
|
||||
}),
|
||||
getClusterHealthStatus: publicProcedure
|
||||
@@ -83,7 +91,7 @@ export const healthMonitoringRouter = createTRPCRouter({
|
||||
"Get Proxmox cluster health status including nodes, VMs, and resource usage. REQUIRED: integrationId (single Proxmox integration ID from integration_all)",
|
||||
},
|
||||
})
|
||||
.concat(createOneIntegrationMiddleware("query", "proxmox", "mock"))
|
||||
.concat(createOneWidgetIntegrationMiddleware("query", "healthMonitoring", ["proxmox", "mock"]))
|
||||
.query(async ({ ctx }) => {
|
||||
const innerHandler = clusterInfoRequestHandler.handler(ctx.integration, {});
|
||||
const { data } = await innerHandler.getDataAsync();
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import {
|
||||
immichAlbumRequestHandler,
|
||||
immichAlbumsRequestHandler,
|
||||
immichStatsRequestHandler,
|
||||
} from "@homarr/request-handler/immich";
|
||||
|
||||
import type { IntegrationAction } from "../../middlewares/integration";
|
||||
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import {
|
||||
createOneSharedWidgetIntegrationMiddleware,
|
||||
createOneWidgetIntegrationMiddleware,
|
||||
} from "../../middlewares/integration";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
const createImmichIntegrationMiddleware = (action: IntegrationAction) =>
|
||||
createOneIntegrationMiddleware(action, ...getIntegrationKindsByCategory("photoService"));
|
||||
|
||||
export const immichRouter = createTRPCRouter({
|
||||
getServerStats: publicProcedure.concat(createImmichIntegrationMiddleware("query")).query(async ({ ctx }) => {
|
||||
const innerHandler = immichStatsRequestHandler.handler(ctx.integration, {});
|
||||
const data = await innerHandler.getDataAsync();
|
||||
return data.data;
|
||||
}),
|
||||
getServerStats: publicProcedure
|
||||
.concat(createOneWidgetIntegrationMiddleware("query", "immich-serverStats"))
|
||||
.query(async ({ ctx }) => {
|
||||
const innerHandler = immichStatsRequestHandler.handler(ctx.integration, {});
|
||||
const data = await innerHandler.getDataAsync();
|
||||
return data.data;
|
||||
}),
|
||||
|
||||
getAlbum: publicProcedure
|
||||
.concat(createImmichIntegrationMiddleware("query"))
|
||||
.concat(createOneWidgetIntegrationMiddleware("query", "immich-albumCarousel"))
|
||||
.input(
|
||||
z.object({
|
||||
albumId: z.string().optional(),
|
||||
@@ -40,7 +40,7 @@ export const immichRouter = createTRPCRouter({
|
||||
limit: z.number().int().min(1).max(500).optional(),
|
||||
}),
|
||||
)
|
||||
.concat(createImmichIntegrationMiddleware("query"))
|
||||
.concat(createOneSharedWidgetIntegrationMiddleware("query", "immich-albumCarousel", ["immich-serverStats"]))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const innerHandler = immichAlbumsRequestHandler.handler(ctx.integration, { limit: input.limit });
|
||||
const data = await innerHandler.getDataAsync();
|
||||
|
||||
@@ -1,49 +1,30 @@
|
||||
import type { AnyTRPCRouter } from "@trpc/server";
|
||||
import { lazy } from "@trpc/server";
|
||||
|
||||
import { createTRPCRouter } from "../../trpc";
|
||||
import { widgetRouterRegistry } from "./registry";
|
||||
import type { WidgetRouterName } from "./registry";
|
||||
|
||||
const createLazyWidgetRouter = <TRouter extends AnyTRPCRouter>(loadRouter: () => Promise<TRouter>) => lazy(loadRouter);
|
||||
|
||||
const lazyWidgetRouters = Object.fromEntries(
|
||||
Object.entries(widgetRouterRegistry).map(([name, registration]) => [
|
||||
name,
|
||||
createLazyWidgetRouter(registration.loadRouter as () => Promise<AnyTRPCRouter>),
|
||||
]),
|
||||
) as unknown as {
|
||||
[TName in WidgetRouterName]: ReturnType<
|
||||
typeof createLazyWidgetRouter<
|
||||
(typeof widgetRouterRegistry)[TName] extends { loadRouter: () => Promise<infer TRouter> }
|
||||
? TRouter extends AnyTRPCRouter
|
||||
? TRouter
|
||||
: never
|
||||
: never
|
||||
>
|
||||
>;
|
||||
};
|
||||
|
||||
export const widgetRouter = createTRPCRouter({
|
||||
archiveTeamWarrior: lazy(() => import("./archive-team-warrior").then((mod) => mod.archiveTeamWarriorRouter)),
|
||||
beszel: lazy(() => import("./beszel").then((mod) => mod.beszelRouter)),
|
||||
anchorNotes: lazy(() => import("./anchor-notes").then((mod) => mod.anchorNotesRouter)),
|
||||
coolify: lazy(() => import("./coolify").then((mod) => mod.coolifyRouter)),
|
||||
immich: lazy(() => import("./immich").then((mod) => mod.immichRouter)),
|
||||
paperlessNgx: lazy(() => import("./paperless-ngx").then((mod) => mod.paperlessNgxRouter)),
|
||||
patchmon: lazy(() => import("./patchmon").then((mod) => mod.patchmonRouter)),
|
||||
bazarr: lazy(() => import("./bazarr").then((mod) => mod.bazarrRouter)),
|
||||
notebook: lazy(() => import("./notebook").then((mod) => mod.notebookRouter)),
|
||||
weather: lazy(() => import("./weather").then((mod) => mod.weatherRouter)),
|
||||
airQuality: lazy(() => import("./air-quality").then((mod) => mod.airQualityRouter)),
|
||||
app: lazy(() => import("./app").then((mod) => mod.appRouter)),
|
||||
dnsHole: lazy(() => import("./dns-hole").then((mod) => mod.dnsHoleRouter)),
|
||||
smartHome: lazy(() => import("./smart-home").then((mod) => mod.smartHomeRouter)),
|
||||
stockPrice: lazy(() => import("./stocks").then((mod) => mod.stockPriceRouter)),
|
||||
mediaServer: lazy(() => import("./media-server").then((mod) => mod.mediaServerRouter)),
|
||||
mediaRelease: lazy(() => import("./media-release").then((mod) => mod.mediaReleaseRouter)),
|
||||
calendar: lazy(() => import("./calendar").then((mod) => mod.calendarRouter)),
|
||||
downloads: lazy(() => import("./downloads").then((mod) => mod.downloadsRouter)),
|
||||
mediaRequests: lazy(() => import("./media-requests").then((mod) => mod.mediaRequestsRouter)),
|
||||
mediaOrganizer: lazy(() => import("./media-organizer").then((mod) => mod.mediaOrganizerRouter)),
|
||||
rssFeed: lazy(() => import("./rssFeed").then((mod) => mod.rssFeedRouter)),
|
||||
indexerManager: lazy(() => import("./indexer-manager").then((mod) => mod.indexerManagerRouter)),
|
||||
healthMonitoring: lazy(() => import("./health-monitoring").then((mod) => mod.healthMonitoringRouter)),
|
||||
mediaTranscoding: lazy(() => import("./media-transcoding").then((mod) => mod.mediaTranscodingRouter)),
|
||||
minecraft: lazy(() => import("./minecraft").then((mod) => mod.minecraftRouter)),
|
||||
...lazyWidgetRouters,
|
||||
options: lazy(() => import("./options").then((mod) => mod.optionsRouter)),
|
||||
releases: lazy(() => import("./releases").then((mod) => mod.releasesRouter)),
|
||||
networkController: lazy(() => import("./network-controller").then((mod) => mod.networkControllerRouter)),
|
||||
firewall: lazy(() => import("./firewall").then((mod) => mod.firewallRouter)),
|
||||
notifications: lazy(() => import("./notifications").then((mod) => mod.notificationsRouter)),
|
||||
timetable: lazy(() => import("./timetable").then((mod) => mod.timetableRouter)),
|
||||
tracearr: lazy(() => import("./tracearr").then((mod) => mod.tracearrRouter)),
|
||||
speedtestTracker: lazy(() => import("./speedtest-tracker").then((mod) => mod.speedtestTrackerRouter)),
|
||||
uptimeKuma: lazy(() => import("./uptime-kuma").then((mod) => mod.uptimeKumaRouter)),
|
||||
audioStats: lazy(() => import("./audio-stats").then((mod) => mod.audioStatsRouter)),
|
||||
umami: lazy(() => import("./umami").then((mod) => mod.umamiRouter)),
|
||||
vpn: lazy(() => import("./vpn").then((mod) => mod.vpnRouter)),
|
||||
ups: lazy(() => import("./ups").then((mod) => mod.upsRouter)),
|
||||
traefik: lazy(() => import("./traefik").then((mod) => mod.traefikRouter)),
|
||||
customApi: lazy(() => import("./custom-api").then((mod) => mod.customApiRouter)),
|
||||
secrets: lazy(() => import("./widget-secrets").then((mod) => mod.widgetSecretsRouter)),
|
||||
wud: lazy(() => import("./wud").then((mod) => mod.wudRouter)),
|
||||
});
|
||||
@@ -1,20 +1,15 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import { createIntegrationAsync } from "@homarr/integrations/factory";
|
||||
import { indexerManagerRequestHandler } from "@homarr/request-handler/indexer-manager";
|
||||
|
||||
import type { IntegrationAction } from "../../middlewares/integration";
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
|
||||
|
||||
const createIndexerManagerIntegrationMiddleware = (action: IntegrationAction) =>
|
||||
createManyIntegrationMiddleware(action, ...getIntegrationKindsByCategory("indexerManager"));
|
||||
|
||||
export const indexerManagerRouter = createTRPCRouter({
|
||||
getIndexersStatus: publicProcedure
|
||||
.concat(createIndexerManagerIntegrationMiddleware("query"))
|
||||
.concat(createManyWidgetIntegrationMiddleware("query", "indexerManager"))
|
||||
.query(async ({ ctx }) => {
|
||||
return await settleIntegrationQueries(
|
||||
ctx.integrations,
|
||||
@@ -41,7 +36,7 @@ export const indexerManagerRouter = createTRPCRouter({
|
||||
);
|
||||
}),
|
||||
testAllIndexers: protectedProcedure
|
||||
.concat(createIndexerManagerIntegrationMiddleware("interact"))
|
||||
.concat(createManyWidgetIntegrationMiddleware("interact", "indexerManager"))
|
||||
.mutation(async ({ ctx }) => {
|
||||
await Promise.all(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import { mediaOrganizerRequestHandler } from "@homarr/request-handler/media-organizer";
|
||||
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
@@ -16,7 +15,7 @@ export const mediaOrganizerRouter = createTRPCRouter({
|
||||
"Get missing and queued movies/episodes from Radarr and Sonarr. Requires query (use) access to each integration. REQUIRED: integrationIds (array of Radarr/Sonarr integration IDs from integration_all). OPTIONAL: pageSize (1-50, default 10)",
|
||||
},
|
||||
})
|
||||
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaOrganizer")))
|
||||
.concat(createManyWidgetIntegrationMiddleware("query", "mediaMissing"))
|
||||
.input(z.object({ pageSize: z.number().min(1).max(50).default(10) }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return await settleIntegrationQueries(
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import { mediaReleaseRequestHandler } from "@homarr/request-handler/media-release";
|
||||
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
import { getMediaReleaseResponse } from "./media-release-result";
|
||||
|
||||
export const mediaReleaseRouter = createTRPCRouter({
|
||||
getMediaReleases: publicProcedure
|
||||
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRelease")))
|
||||
.concat(createManyWidgetIntegrationMiddleware("query", "mediaReleases"))
|
||||
.query(async ({ ctx }) => {
|
||||
return await getMediaReleaseResponse(ctx.integrations, async (integration) => {
|
||||
const innerHandler = mediaReleaseRequestHandler.handler(integration, {});
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import { createIntegrationAsync } from "@homarr/integrations/factory";
|
||||
import type { MediaRequest, MediaRequestStats } from "@homarr/integrations/types";
|
||||
import { mediaRequestStatusConfiguration } from "@homarr/integrations/types";
|
||||
import {
|
||||
@@ -11,7 +10,10 @@ import {
|
||||
} from "@homarr/request-handler/media-request-list";
|
||||
import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats";
|
||||
|
||||
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import {
|
||||
createManyWidgetIntegrationMiddleware,
|
||||
createOneWidgetIntegrationMiddleware,
|
||||
} from "../../middlewares/integration";
|
||||
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
|
||||
|
||||
@@ -30,7 +32,7 @@ export const mediaRequestsRouter = createTRPCRouter({
|
||||
"Get latest media requests from Overseerr/Jellyseerr with their status (pending, approved, declined, failed, completed). REQUIRED: integrationIds (array of Overseerr/Jellyseerr integration IDs from integration_all). OPTIONAL: statuses (array of statuses to include, must be non-empty) and recentDays (number 0-365; 0 disables the time filter).",
|
||||
},
|
||||
})
|
||||
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")))
|
||||
.concat(createManyWidgetIntegrationMiddleware("query", "mediaRequests-requestList"))
|
||||
.input(mediaRequestListInputSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const results: IntegrationQueryResult<MediaRequest[]>[] = await settleIntegrationQueries(
|
||||
@@ -85,7 +87,7 @@ export const mediaRequestsRouter = createTRPCRouter({
|
||||
"Get media request statistics including total counts and top requesters. REQUIRED: integrationIds (array of Overseerr/Jellyseerr integration IDs from integration_all)",
|
||||
},
|
||||
})
|
||||
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")))
|
||||
.concat(createManyWidgetIntegrationMiddleware("query", "mediaRequests-requestStats"))
|
||||
.query(async ({ ctx }) => {
|
||||
const results: IntegrationQueryResult<{
|
||||
stats: MediaRequestStats["stats"] | null;
|
||||
@@ -134,7 +136,7 @@ export const mediaRequestsRouter = createTRPCRouter({
|
||||
"Approve or decline a pending media request. REQUIRED: integrationId (single Overseerr/Jellyseerr integration ID), requestId (number from getLatestRequests), answer ('approve' or 'decline')",
|
||||
},
|
||||
})
|
||||
.concat(createOneIntegrationMiddleware("interact", ...getIntegrationKindsByCategory("mediaRequest")))
|
||||
.concat(createOneWidgetIntegrationMiddleware("interact", "mediaRequests-requestList"))
|
||||
.input(
|
||||
z.object({
|
||||
requestId: z.number(),
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import { mediaServerRequestHandler } from "@homarr/request-handler/media-server";
|
||||
|
||||
import type { IntegrationAction } from "../../middlewares/integration";
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createManySharedWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
const createMediaServerIntegrationMiddleware = (action: IntegrationAction) =>
|
||||
createManyIntegrationMiddleware(action, ...getIntegrationKindsByCategory("mediaService"));
|
||||
|
||||
export const mediaServerRouter = createTRPCRouter({
|
||||
getCurrentStreams: publicProcedure
|
||||
.meta({
|
||||
@@ -20,7 +15,7 @@ export const mediaServerRouter = createTRPCRouter({
|
||||
"Get currently active streams from Plex/Jellyfin/Emby/Navidrome media servers. REQUIRED: integrationIds (array of media server integration IDs from integration_all), showOnlyPlaying (boolean — true to filter to actively playing streams only)",
|
||||
},
|
||||
})
|
||||
.concat(createMediaServerIntegrationMiddleware("query"))
|
||||
.concat(createManySharedWidgetIntegrationMiddleware("query", "mediaServer", ["audioStats"]))
|
||||
.input(z.object({ showOnlyPlaying: z.boolean() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return await settleIntegrationQueries(
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import { mediaTranscodingRequestHandler } from "@homarr/request-handler/media-transcoding";
|
||||
import { paginatedSchema } from "@homarr/validation/common";
|
||||
|
||||
import type { IntegrationAction } from "../../middlewares/integration";
|
||||
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
const createIndexerManagerIntegrationMiddleware = (action: IntegrationAction) =>
|
||||
createOneIntegrationMiddleware(action, ...getIntegrationKindsByCategory("mediaTranscoding"));
|
||||
|
||||
export const mediaTranscodingRouter = createTRPCRouter({
|
||||
getDataAsync: publicProcedure
|
||||
.concat(createIndexerManagerIntegrationMiddleware("query"))
|
||||
.concat(createOneWidgetIntegrationMiddleware("query", "mediaTranscoding"))
|
||||
.input(paginatedSchema.pick({ page: true, pageSize: true }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const innerHandler = mediaTranscodingRequestHandler.handler(ctx.integration, {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import type { NetworkControllerSummary } from "@homarr/integrations/types";
|
||||
import { networkControllerRequestHandler } from "@homarr/request-handler/network-controller";
|
||||
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createManySharedWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
@@ -22,7 +21,9 @@ interface NetworkControllerQueryResult {
|
||||
|
||||
export const networkControllerRouter = createTRPCRouter({
|
||||
summary: publicProcedure
|
||||
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("networkController")))
|
||||
.concat(
|
||||
createManySharedWidgetIntegrationMiddleware("query", "networkControllerSummary", ["networkControllerStatus"]),
|
||||
)
|
||||
.query(async ({ ctx }) => {
|
||||
return await settleIntegrationQueries<(typeof ctx.integrations)[number], NetworkControllerQueryResult>(
|
||||
ctx.integrations,
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import { notificationsRequestHandler } from "@homarr/request-handler/notifications";
|
||||
|
||||
import type { IntegrationAction } from "../../middlewares/integration";
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
const createNotificationsIntegrationMiddleware = (action: IntegrationAction) =>
|
||||
createManyIntegrationMiddleware(action, ...getIntegrationKindsByCategory("notifications"));
|
||||
|
||||
export const notificationsRouter = createTRPCRouter({
|
||||
getNotifications: publicProcedure
|
||||
.unstable_concat(createNotificationsIntegrationMiddleware("query"))
|
||||
.unstable_concat(createManyWidgetIntegrationMiddleware("query", "notifications"))
|
||||
.query(async ({ ctx }) => {
|
||||
return await settleIntegrationQueries(
|
||||
ctx.integrations,
|
||||
|
||||
@@ -9,7 +9,6 @@ import { boards, items } from "@homarr/db/schema";
|
||||
import type { WidgetOptionsSettings } from "../../../../widgets/src";
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
|
||||
import { throwIfActionForbiddenAsync } from "../board/board-access";
|
||||
import { validateTimetableOptionsChangeAsync } from "./timetable";
|
||||
import { throwIfCustomWidgetPlacementChangeForbidden } from "../board/custom-widget-placement-access";
|
||||
|
||||
export const optionsRouter = createTRPCRouter({
|
||||
@@ -50,6 +49,7 @@ export const optionsRouter = createTRPCRouter({
|
||||
const previousOptions = SuperJSON.parse<Record<string, unknown>>(item.options);
|
||||
const updatedOptions = { ...previousOptions, ...input.newOptions };
|
||||
if (item.kind === "timetable") {
|
||||
const { validateTimetableOptionsChangeAsync } = await import("../timetable-options-validation");
|
||||
await validateTimetableOptionsChangeAsync(updatedOptions, previousOptions);
|
||||
}
|
||||
throwIfCustomWidgetPlacementChangeForbidden({
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { paperlessNgxStatsRequestHandler } from "@homarr/request-handler/paperless-ngx";
|
||||
|
||||
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
export const paperlessNgxRouter = createTRPCRouter({
|
||||
getStats: publicProcedure.concat(createOneIntegrationMiddleware("query", "paperlessNgx")).query(async ({ ctx }) => {
|
||||
const innerHandler = paperlessNgxStatsRequestHandler.handler(ctx.integration, {});
|
||||
const data = await innerHandler.getDataAsync();
|
||||
return data.data;
|
||||
}),
|
||||
getStats: publicProcedure
|
||||
.concat(createOneWidgetIntegrationMiddleware("query", "paperlessNgx"))
|
||||
.query(async ({ ctx }) => {
|
||||
const innerHandler = paperlessNgxStatsRequestHandler.handler(ctx.integration, {});
|
||||
const data = await innerHandler.getDataAsync();
|
||||
return data.data;
|
||||
}),
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { patchmonStatsRequestHandler } from "@homarr/request-handler/patchmon";
|
||||
|
||||
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, protectedProcedure } from "../../trpc";
|
||||
|
||||
export const patchmonRouter = createTRPCRouter({
|
||||
@@ -12,7 +12,7 @@ export const patchmonRouter = createTRPCRouter({
|
||||
"Get PatchMon host patch statistics including total hosts, hosts needing updates, security update counts, up-to-date hosts, outdated packages, repositories, and OS distribution. REQUIRED: integrationId (single PatchMon integration ID from integration_all). Requires authenticated session (API key or browser login) and integration query access.",
|
||||
},
|
||||
})
|
||||
.concat(createOneIntegrationMiddleware("query", "patchmon"))
|
||||
.concat(createOneWidgetIntegrationMiddleware("query", "patchmon"))
|
||||
.query(async ({ ctx }) => {
|
||||
const innerHandler = patchmonStatsRequestHandler.handler(ctx.integration, {});
|
||||
const { data } = await innerHandler.getDataAsync();
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import type { AnyTRPCRouter } from "@trpc/server";
|
||||
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
|
||||
import { generatedWidgetRouterRegistry } from "./generated-registry";
|
||||
|
||||
const {
|
||||
weather: generatedWeatherRouter,
|
||||
downloads: generatedDownloadsRouter,
|
||||
...additionalGeneratedWidgetRouters
|
||||
} = generatedWidgetRouterRegistry;
|
||||
|
||||
const logger = createLogger({ module: "widgetRouterRegistry" });
|
||||
|
||||
type McpRouterNamespace = false | true | string;
|
||||
|
||||
type WidgetRouterRegistration<TRouter extends AnyTRPCRouter, TMcpNamespace extends McpRouterNamespace> = {
|
||||
widgetKinds: readonly [WidgetKind, ...WidgetKind[]];
|
||||
loadRouter: () => Promise<TRouter>;
|
||||
mcp: TMcpNamespace;
|
||||
};
|
||||
|
||||
const registerWidgetRouter = <TRouter extends AnyTRPCRouter, const TMcpNamespace extends McpRouterNamespace = false>(
|
||||
widgetKinds: readonly [WidgetKind, ...WidgetKind[]],
|
||||
loadRouter: () => Promise<TRouter>,
|
||||
options?: { mcp?: TMcpNamespace },
|
||||
): WidgetRouterRegistration<TRouter, TMcpNamespace> => ({
|
||||
widgetKinds,
|
||||
loadRouter,
|
||||
mcp: (options?.mcp ?? false) as TMcpNamespace,
|
||||
});
|
||||
|
||||
/**
|
||||
* Shared namespace-to-feature contract for widget tRPC consumers.
|
||||
*
|
||||
* Keep import paths explicit so Next.js can split every implementation while
|
||||
* consumers share one widget-to-router association.
|
||||
*/
|
||||
export const widgetRouterRegistry = {
|
||||
archiveTeamWarrior: registerWidgetRouter(["archiveTeamWarrior"], () =>
|
||||
import("./archive-team-warrior").then((module) => module.archiveTeamWarriorRouter),
|
||||
),
|
||||
beszel: registerWidgetRouter(
|
||||
["beszelSystemTable", "beszelSystemGrid", "beszelAlerts", "beszelSystemStats"],
|
||||
() => import("./beszel").then((module) => module.beszelRouter),
|
||||
{ mcp: true },
|
||||
),
|
||||
anchorNotes: registerWidgetRouter(["anchorNote"], () =>
|
||||
import("./anchor-notes").then((module) => module.anchorNotesRouter),
|
||||
),
|
||||
coolify: registerWidgetRouter(["coolify"], () => import("./coolify").then((module) => module.coolifyRouter)),
|
||||
immich: registerWidgetRouter(["immich-serverStats", "immich-albumCarousel"], () =>
|
||||
import("./immich").then((module) => module.immichRouter),
|
||||
),
|
||||
paperlessNgx: registerWidgetRouter(["paperlessNgx"], () =>
|
||||
import("./paperless-ngx").then((module) => module.paperlessNgxRouter),
|
||||
),
|
||||
patchmon: registerWidgetRouter(["patchmon"], () => import("./patchmon").then((module) => module.patchmonRouter), {
|
||||
mcp: true,
|
||||
}),
|
||||
bazarr: registerWidgetRouter(["bazarr"], () => import("./bazarr").then((module) => module.bazarrRouter), {
|
||||
mcp: true,
|
||||
}),
|
||||
notebook: registerWidgetRouter(["notebook"], () => import("./notebook").then((module) => module.notebookRouter)),
|
||||
weather: generatedWeatherRouter,
|
||||
airQuality: registerWidgetRouter(
|
||||
["airQuality"],
|
||||
() => import("./air-quality").then((module) => module.airQualityRouter),
|
||||
{ mcp: true },
|
||||
),
|
||||
app: registerWidgetRouter(["app"], () => import("./app").then((module) => module.appRouter)),
|
||||
dnsHole: registerWidgetRouter(
|
||||
["dnsHoleSummary", "dnsHoleControls"],
|
||||
() => import("./dns-hole").then((module) => module.dnsHoleRouter),
|
||||
{ mcp: true },
|
||||
),
|
||||
smartHome: registerWidgetRouter(
|
||||
["smartHome-entityState", "smartHome-executeAutomation"],
|
||||
() => import("./smart-home").then((module) => module.smartHomeRouter),
|
||||
{ mcp: true },
|
||||
),
|
||||
stockPrice: registerWidgetRouter(["stockPrice"], () => import("./stocks").then((module) => module.stockPriceRouter)),
|
||||
mediaServer: registerWidgetRouter(
|
||||
["mediaServer", "audioStats"],
|
||||
() => import("./media-server").then((module) => module.mediaServerRouter),
|
||||
{ mcp: true },
|
||||
),
|
||||
mediaRelease: registerWidgetRouter(["mediaReleases"], () =>
|
||||
import("./media-release").then((module) => module.mediaReleaseRouter),
|
||||
),
|
||||
calendar: registerWidgetRouter(["calendar"], () => import("./calendar").then((module) => module.calendarRouter), {
|
||||
mcp: true,
|
||||
}),
|
||||
downloads: generatedDownloadsRouter,
|
||||
mediaRequests: registerWidgetRouter(
|
||||
["mediaRequests-requestList", "mediaRequests-requestStats"],
|
||||
() => import("./media-requests").then((module) => module.mediaRequestsRouter),
|
||||
{ mcp: true },
|
||||
),
|
||||
mediaOrganizer: registerWidgetRouter(["mediaMissing"], () =>
|
||||
import("./media-organizer").then((module) => module.mediaOrganizerRouter),
|
||||
),
|
||||
rssFeed: registerWidgetRouter(["rssFeed"], () => import("./rssFeed").then((module) => module.rssFeedRouter)),
|
||||
indexerManager: registerWidgetRouter(["indexerManager"], () =>
|
||||
import("./indexer-manager").then((module) => module.indexerManagerRouter),
|
||||
),
|
||||
healthMonitoring: registerWidgetRouter(
|
||||
["healthMonitoring", "systemResources", "systemDisks"],
|
||||
() => import("./health-monitoring").then((module) => module.healthMonitoringRouter),
|
||||
{ mcp: true },
|
||||
),
|
||||
mediaTranscoding: registerWidgetRouter(["mediaTranscoding"], () =>
|
||||
import("./media-transcoding").then((module) => module.mediaTranscodingRouter),
|
||||
),
|
||||
minecraft: registerWidgetRouter(["minecraftServerStatus"], () =>
|
||||
import("./minecraft").then((module) => module.minecraftRouter),
|
||||
),
|
||||
releases: registerWidgetRouter(["releases"], () => import("./releases").then((module) => module.releasesRouter)),
|
||||
networkController: registerWidgetRouter(["networkControllerSummary", "networkControllerStatus"], () =>
|
||||
import("./network-controller").then((module) => module.networkControllerRouter),
|
||||
),
|
||||
firewall: registerWidgetRouter(["firewall"], () => import("./firewall").then((module) => module.firewallRouter)),
|
||||
notifications: registerWidgetRouter(["notifications"], () =>
|
||||
import("./notifications").then((module) => module.notificationsRouter),
|
||||
),
|
||||
timetable: registerWidgetRouter(["timetable"], () => import("./timetable").then((module) => module.timetableRouter)),
|
||||
tracearr: registerWidgetRouter(["tracearr"], () => import("./tracearr").then((module) => module.tracearrRouter)),
|
||||
speedtestTracker: registerWidgetRouter(["speedtestTracker"], () =>
|
||||
import("./speedtest-tracker").then((module) => module.speedtestTrackerRouter),
|
||||
),
|
||||
uptimeKuma: registerWidgetRouter(["uptimeKuma"], () =>
|
||||
import("./uptime-kuma").then((module) => module.uptimeKumaRouter),
|
||||
),
|
||||
audioStats: registerWidgetRouter(["audioStats"], () =>
|
||||
import("./audio-stats").then((module) => module.audioStatsRouter),
|
||||
),
|
||||
umami: registerWidgetRouter(["umami"], () => import("./umami").then((module) => module.umamiRouter)),
|
||||
vpn: registerWidgetRouter(["vpn"], () => import("./vpn").then((module) => module.vpnRouter)),
|
||||
ups: registerWidgetRouter(["ups"], () => import("./ups").then((module) => module.upsRouter)),
|
||||
traefik: registerWidgetRouter(["traefik"], () => import("./traefik").then((module) => module.traefikRouter)),
|
||||
customApi: registerWidgetRouter(["customApi"], () => import("./custom-api").then((module) => module.customApiRouter)),
|
||||
secrets: registerWidgetRouter(
|
||||
["releases"],
|
||||
() => import("./widget-secrets").then((module) => module.widgetSecretsRouter),
|
||||
{ mcp: "widgetSecrets" },
|
||||
),
|
||||
wud: registerWidgetRouter(["wud"], () => import("./wud").then((module) => module.wudRouter)),
|
||||
...additionalGeneratedWidgetRouters,
|
||||
};
|
||||
|
||||
export type WidgetRouterName = keyof typeof widgetRouterRegistry;
|
||||
type LoadedWidgetRouterRecord = {
|
||||
[TName in WidgetRouterName]: Awaited<ReturnType<(typeof widgetRouterRegistry)[TName]["loadRouter"]>>;
|
||||
};
|
||||
|
||||
interface McpWidgetRouterSelection {
|
||||
name: WidgetRouterName;
|
||||
namespace: string;
|
||||
}
|
||||
|
||||
export const mcpWidgetRouterSelections: readonly McpWidgetRouterSelection[] = Object.entries(
|
||||
widgetRouterRegistry,
|
||||
).flatMap(([name, registration]) => {
|
||||
if (!registration.mcp) return [];
|
||||
|
||||
let namespace = name;
|
||||
if (typeof registration.mcp === "string") namespace = registration.mcp;
|
||||
return [{ name: name as WidgetRouterName, namespace }];
|
||||
});
|
||||
|
||||
export const mcpWidgetRouterNames: readonly WidgetRouterName[] = mcpWidgetRouterSelections.map(
|
||||
(selection) => selection.name,
|
||||
);
|
||||
|
||||
export async function loadWidgetRoutersAsync<const TNames extends readonly WidgetRouterName[]>(
|
||||
names: TNames,
|
||||
consumer: string,
|
||||
): Promise<Pick<LoadedWidgetRouterRecord, TNames[number]>> {
|
||||
const loadedRouters: Partial<Record<WidgetRouterName, AnyTRPCRouter>> = {};
|
||||
|
||||
await Promise.all(
|
||||
names.map(async (name) => {
|
||||
const registration = widgetRouterRegistry[name];
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
loadedRouters[name] = await registration.loadRouter();
|
||||
logger.debug("Loaded widget router", {
|
||||
consumer,
|
||||
routerName: name,
|
||||
widgetKinds: registration.widgetKinds,
|
||||
durationMs: Date.now() - startedAt,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
new ErrorWithMetadata(
|
||||
"Failed to load widget router",
|
||||
{
|
||||
consumer,
|
||||
routerName: name,
|
||||
widgetKinds: registration.widgetKinds,
|
||||
durationMs: Date.now() - startedAt,
|
||||
},
|
||||
{ cause: error },
|
||||
),
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return loadedRouters as Pick<LoadedWidgetRouterRecord, TNames[number]>;
|
||||
}
|
||||
|
||||
export async function loadMcpWidgetRoutersAsync(): Promise<Record<string, AnyTRPCRouter>> {
|
||||
const loadedRouters = await loadWidgetRoutersAsync(mcpWidgetRouterNames, "mcp");
|
||||
const mcpRouters: Record<string, AnyTRPCRouter> = {};
|
||||
|
||||
for (const selection of mcpWidgetRouterSelections) {
|
||||
mcpRouters[selection.namespace] = loadedRouters[selection.name];
|
||||
}
|
||||
|
||||
return mcpRouters;
|
||||
}
|
||||
@@ -1,20 +1,15 @@
|
||||
import { z } from "zod/v4";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import { createIntegrationAsync } from "@homarr/integrations/factory";
|
||||
import {
|
||||
smartHomeEntityStateRequestHandler,
|
||||
toSafeEntityDetails,
|
||||
} from "@homarr/request-handler/smart-home-entity-state";
|
||||
|
||||
import type { IntegrationAction } from "../../middlewares/integration";
|
||||
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
|
||||
|
||||
const createSmartHomeIntegrationMiddleware = (action: IntegrationAction) =>
|
||||
createOneIntegrationMiddleware(action, ...getIntegrationKindsByCategory("smartHomeServer"));
|
||||
|
||||
export const smartHomeRouter = createTRPCRouter({
|
||||
entityState: publicProcedure
|
||||
.meta({
|
||||
@@ -25,7 +20,7 @@ export const smartHomeRouter = createTRPCRouter({
|
||||
},
|
||||
})
|
||||
.input(z.object({ entityId: z.string() }))
|
||||
.concat(createSmartHomeIntegrationMiddleware("query"))
|
||||
.concat(createOneWidgetIntegrationMiddleware("query", "smartHome-entityState"))
|
||||
.query(async ({ ctx: { integration }, input }) => {
|
||||
const innerHandler = smartHomeEntityStateRequestHandler.handler(integration, { entityId: input.entityId });
|
||||
const { data } = await innerHandler.getDataAsync();
|
||||
@@ -40,7 +35,7 @@ export const smartHomeRouter = createTRPCRouter({
|
||||
},
|
||||
})
|
||||
.input(z.object({ entityId: z.string() }))
|
||||
.concat(createSmartHomeIntegrationMiddleware("query"))
|
||||
.concat(createOneWidgetIntegrationMiddleware("query", "smartHome-entityState"))
|
||||
.query(async ({ ctx: { integration }, input }) => {
|
||||
const innerHandler = smartHomeEntityStateRequestHandler.handler(integration, { entityId: input.entityId });
|
||||
const { data } = await innerHandler.getDataAsync();
|
||||
@@ -54,7 +49,7 @@ export const smartHomeRouter = createTRPCRouter({
|
||||
"Toggle a Home Assistant entity (turn on/off a light, switch, etc.). REQUIRED: integrationId (Home Assistant integration ID from integration_all), entityId (e.g. 'light.living_room')",
|
||||
},
|
||||
})
|
||||
.concat(createSmartHomeIntegrationMiddleware("interact"))
|
||||
.concat(createOneWidgetIntegrationMiddleware("interact", "smartHome-entityState"))
|
||||
.input(z.object({ entityId: z.string() }))
|
||||
.mutation(async ({ ctx: { integration }, input }) => {
|
||||
const client = await createIntegrationAsync(integration);
|
||||
@@ -72,7 +67,7 @@ export const smartHomeRouter = createTRPCRouter({
|
||||
"Trigger a Home Assistant automation by its ID. REQUIRED: integrationId (Home Assistant integration ID from integration_all), automationId (the automation entity ID)",
|
||||
},
|
||||
})
|
||||
.concat(createSmartHomeIntegrationMiddleware("interact"))
|
||||
.concat(createOneWidgetIntegrationMiddleware("interact", "smartHome-executeAutomation"))
|
||||
.input(z.object({ automationId: z.string() }))
|
||||
.mutation(async ({ ctx: { integration }, input }) => {
|
||||
const client = await createIntegrationAsync(integration);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { speedtestTrackerRequestHandler } from "@homarr/request-handler/speedtest-tracker";
|
||||
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
export const speedtestTrackerRouter = createTRPCRouter({
|
||||
getDashboard: publicProcedure
|
||||
.concat(createManyIntegrationMiddleware("query", "speedtestTracker"))
|
||||
.concat(createManyWidgetIntegrationMiddleware("query", "speedtestTracker"))
|
||||
.query(async ({ ctx }) => {
|
||||
return await settleIntegrationQueries(
|
||||
ctx.integrations,
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import { lookup } from "node:dns/promises";
|
||||
import { BlockList, isIP } from "node:net";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { parse } from "superjson";
|
||||
import z from "zod";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
@@ -13,10 +10,16 @@ import {
|
||||
timetableGetTimetableRequestHandler,
|
||||
timetableSearchStationsRequestHandler,
|
||||
} from "@homarr/request-handler/timetable";
|
||||
import { DEFAULT_TIMETABLE_BASE_URL, normalizeTimetableBaseUrl } from "@homarr/request-handler/timetable-url";
|
||||
import { DEFAULT_TIMETABLE_BASE_URL } from "@homarr/request-handler/timetable-url";
|
||||
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
import { throwIfActionForbiddenAsync } from "../board/board-access";
|
||||
import {
|
||||
normalizeSavedTimetableBaseUrl,
|
||||
normalizeTimetableBaseUrlOrThrowBadRequest,
|
||||
parseSavedTimetableOptions,
|
||||
resolvePublicTimetableAddressesAsync,
|
||||
} from "../timetable-options-validation";
|
||||
|
||||
const baseUrlSchema = z.string().url().max(2_048);
|
||||
const timetableSourceSchema = z.object({
|
||||
@@ -24,167 +27,6 @@ const timetableSourceSchema = z.object({
|
||||
itemId: z.string().max(100).optional(),
|
||||
boardId: z.string().max(100).optional(),
|
||||
});
|
||||
const timetableOptionsSchema = z.object({ baseUrl: z.string().optional() }).passthrough();
|
||||
|
||||
const blockedTimetableIpv4Addresses = new BlockList();
|
||||
const blockedTimetableIpv6Addresses = new BlockList();
|
||||
const blockedTimetableSubnets = [
|
||||
["0.0.0.0", 8, "ipv4"],
|
||||
["10.0.0.0", 8, "ipv4"],
|
||||
["100.64.0.0", 10, "ipv4"],
|
||||
["127.0.0.0", 8, "ipv4"],
|
||||
["169.254.0.0", 16, "ipv4"],
|
||||
["172.16.0.0", 12, "ipv4"],
|
||||
["192.0.0.0", 24, "ipv4"],
|
||||
["192.0.2.0", 24, "ipv4"],
|
||||
["192.88.99.0", 24, "ipv4"],
|
||||
["192.168.0.0", 16, "ipv4"],
|
||||
["198.18.0.0", 15, "ipv4"],
|
||||
["198.51.100.0", 24, "ipv4"],
|
||||
["203.0.113.0", 24, "ipv4"],
|
||||
["224.0.0.0", 4, "ipv4"],
|
||||
["240.0.0.0", 4, "ipv4"],
|
||||
["::", 96, "ipv6"],
|
||||
["::ffff:0:0", 96, "ipv6"],
|
||||
["::ffff:0:0:0", 96, "ipv6"],
|
||||
["64:ff9b::", 96, "ipv6"],
|
||||
["64:ff9b:1::", 48, "ipv6"],
|
||||
["100::", 64, "ipv6"],
|
||||
["2001::", 32, "ipv6"],
|
||||
["2001:2::", 48, "ipv6"],
|
||||
["2001:10::", 28, "ipv6"],
|
||||
["2001:db8::", 32, "ipv6"],
|
||||
["2002::", 16, "ipv6"],
|
||||
["fc00::", 7, "ipv6"],
|
||||
["fe80::", 10, "ipv6"],
|
||||
["fec0::", 10, "ipv6"],
|
||||
["ff00::", 8, "ipv6"],
|
||||
] as const;
|
||||
|
||||
for (const [network, prefix, family] of blockedTimetableSubnets) {
|
||||
const blockList = family === "ipv4" ? blockedTimetableIpv4Addresses : blockedTimetableIpv6Addresses;
|
||||
blockList.addSubnet(network, prefix, family);
|
||||
}
|
||||
|
||||
const normalizeBaseUrlOrThrowBadRequest = (baseUrl: string) => {
|
||||
try {
|
||||
return normalizeTimetableBaseUrl(baseUrl);
|
||||
} catch (cause) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: cause instanceof Error ? cause.message : "Invalid timetable base URL",
|
||||
cause,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const throwInvalidSavedTimetableConfiguration = (cause: unknown): never => {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Timetable widget configuration is invalid",
|
||||
cause,
|
||||
});
|
||||
};
|
||||
|
||||
const parseSavedTimetableOptions = (serializedOptions: string) => {
|
||||
try {
|
||||
return timetableOptionsSchema.parse(parse<unknown>(serializedOptions));
|
||||
} catch (cause) {
|
||||
return throwInvalidSavedTimetableConfiguration(cause);
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeSavedTimetableBaseUrl = (baseUrl: string) => {
|
||||
try {
|
||||
return normalizeTimetableBaseUrl(baseUrl);
|
||||
} catch (cause) {
|
||||
return throwInvalidSavedTimetableConfiguration(cause);
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeHostname = (hostname: string) => {
|
||||
const withoutBrackets = hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
|
||||
return withoutBrackets.toLowerCase().replace(/\.$/, "");
|
||||
};
|
||||
|
||||
const isBlockedTimetableAddress = (address: string, family: number) => {
|
||||
return family === 6
|
||||
? blockedTimetableIpv6Addresses.check(address, "ipv6")
|
||||
: blockedTimetableIpv4Addresses.check(address, "ipv4");
|
||||
};
|
||||
|
||||
const resolvePublicTimetableAddressesAsync = async (baseUrl: string): Promise<TimetableResolvedAddress[]> => {
|
||||
const hostname = normalizeHostname(new URL(baseUrl).hostname);
|
||||
const hostnameFamily = isIP(hostname);
|
||||
const isInternalHostname =
|
||||
hostnameFamily === 0 &&
|
||||
(!hostname.includes(".") ||
|
||||
hostname === "localhost" ||
|
||||
hostname.endsWith(".localhost") ||
|
||||
hostname.endsWith(".local") ||
|
||||
hostname.endsWith(".internal") ||
|
||||
hostname.endsWith(".lan") ||
|
||||
hostname.endsWith(".home.arpa"));
|
||||
|
||||
if (isInternalHostname) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Timetable base URL must use a public host" });
|
||||
}
|
||||
|
||||
let addresses: TimetableResolvedAddress[];
|
||||
try {
|
||||
const resolvedAddresses =
|
||||
hostnameFamily === 0
|
||||
? await lookup(hostname, { all: true, verbatim: true })
|
||||
: [{ address: hostname, family: hostnameFamily }];
|
||||
addresses = resolvedAddresses.filter(
|
||||
(entry): entry is TimetableResolvedAddress => entry.family === 4 || entry.family === 6,
|
||||
);
|
||||
} catch (cause) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Timetable base URL host could not be resolved",
|
||||
cause,
|
||||
});
|
||||
}
|
||||
|
||||
if (addresses.length === 0 || addresses.some(({ address, family }) => isBlockedTimetableAddress(address, family))) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Timetable base URL must not target a private or internal address",
|
||||
});
|
||||
}
|
||||
|
||||
return addresses.toSorted((left, right) => left.family - right.family || left.address.localeCompare(right.address));
|
||||
};
|
||||
|
||||
const normalizeTimetableOptionsBaseUrlOrThrowBadRequest = (options: Record<string, unknown>) => {
|
||||
const parsedOptions = timetableOptionsSchema.safeParse(options);
|
||||
if (!parsedOptions.success) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid timetable widget options" });
|
||||
}
|
||||
return normalizeBaseUrlOrThrowBadRequest(parsedOptions.data.baseUrl ?? DEFAULT_TIMETABLE_BASE_URL);
|
||||
};
|
||||
|
||||
export const validateTimetableOptionsChangeAsync = async (
|
||||
nextOptions: Record<string, unknown>,
|
||||
previousOptions?: Record<string, unknown>,
|
||||
) => {
|
||||
const nextBaseUrl = normalizeTimetableOptionsBaseUrlOrThrowBadRequest(nextOptions);
|
||||
if (nextBaseUrl === DEFAULT_TIMETABLE_BASE_URL) return;
|
||||
|
||||
let previousBaseUrl: string | undefined;
|
||||
if (previousOptions) {
|
||||
try {
|
||||
previousBaseUrl = normalizeTimetableOptionsBaseUrlOrThrowBadRequest(previousOptions);
|
||||
} catch {
|
||||
// Invalid legacy options must not prevent replacing them with a valid configuration.
|
||||
}
|
||||
}
|
||||
if (nextBaseUrl === previousBaseUrl) return;
|
||||
|
||||
await resolvePublicTimetableAddressesAsync(nextBaseUrl);
|
||||
};
|
||||
|
||||
const getSavedTimetableBaseUrl = (serializedOptions: string) => {
|
||||
const options = parseSavedTimetableOptions(serializedOptions);
|
||||
return normalizeSavedTimetableBaseUrl(options.baseUrl ?? DEFAULT_TIMETABLE_BASE_URL);
|
||||
@@ -200,7 +42,7 @@ const resolveTimetableBaseUrlAsync = async (
|
||||
input: z.infer<typeof timetableSourceSchema>,
|
||||
allowBoardConfiguration = false,
|
||||
): Promise<ResolvedTimetableSource> => {
|
||||
const requestedBaseUrl = normalizeBaseUrlOrThrowBadRequest(input.baseUrl);
|
||||
const requestedBaseUrl = normalizeTimetableBaseUrlOrThrowBadRequest(input.baseUrl);
|
||||
if (allowBoardConfiguration) {
|
||||
if (input.boardId) {
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify");
|
||||
|
||||
@@ -1,37 +1,39 @@
|
||||
import { tracearrRequestHandler } from "@homarr/request-handler/tracearr";
|
||||
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
export const tracearrRouter = createTRPCRouter({
|
||||
getDashboard: publicProcedure.concat(createManyIntegrationMiddleware("query", "tracearr")).query(async ({ ctx }) => {
|
||||
return await settleIntegrationQueries(
|
||||
ctx.integrations,
|
||||
async (integration) => {
|
||||
const innerHandler = tracearrRequestHandler.handler(integration, {});
|
||||
const { data, timestamp } = await innerHandler.getDataAsync();
|
||||
getDashboard: publicProcedure
|
||||
.concat(createManyWidgetIntegrationMiddleware("query", "tracearr"))
|
||||
.query(async ({ ctx }) => {
|
||||
return await settleIntegrationQueries(
|
||||
ctx.integrations,
|
||||
async (integration) => {
|
||||
const innerHandler = tracearrRequestHandler.handler(integration, {});
|
||||
const { data, timestamp } = await innerHandler.getDataAsync();
|
||||
|
||||
return {
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
integrationUrl: integration.url,
|
||||
dashboard: data as typeof data | null,
|
||||
updatedAt: timestamp,
|
||||
error: undefined as string | undefined,
|
||||
};
|
||||
},
|
||||
{
|
||||
fallback: (integration, error) => ({
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
integrationUrl: integration.url,
|
||||
dashboard: null,
|
||||
updatedAt: new Date(0),
|
||||
error: toPublicIntegrationError(error),
|
||||
}),
|
||||
throwOnAllFailures: true,
|
||||
},
|
||||
);
|
||||
}),
|
||||
return {
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
integrationUrl: integration.url,
|
||||
dashboard: data as typeof data | null,
|
||||
updatedAt: timestamp,
|
||||
error: undefined as string | undefined,
|
||||
};
|
||||
},
|
||||
{
|
||||
fallback: (integration, error) => ({
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
integrationUrl: integration.url,
|
||||
dashboard: null,
|
||||
updatedAt: new Date(0),
|
||||
error: toPublicIntegrationError(error),
|
||||
}),
|
||||
throwOnAllFailures: true,
|
||||
},
|
||||
);
|
||||
}),
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { traefikRequestHandler } from "@homarr/request-handler/traefik";
|
||||
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
@@ -13,7 +13,7 @@ export const traefikRouter = createTRPCRouter({
|
||||
"Retrieve Traefik dashboard data, including routers, services, middlewares, and entry points, for the given integration IDs.",
|
||||
},
|
||||
})
|
||||
.concat(createManyIntegrationMiddleware("query", "traefik"))
|
||||
.concat(createManyWidgetIntegrationMiddleware("query", "traefik"))
|
||||
.query(async ({ ctx }) => {
|
||||
return await settleIntegrationQueries(
|
||||
ctx.integrations,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { z } from "zod/v4";
|
||||
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import { createIntegrationAsync } from "@homarr/integrations/factory";
|
||||
import {
|
||||
umamiActiveVisitorsRequestHandler,
|
||||
umamiEventNamesRequestHandler,
|
||||
@@ -12,14 +12,17 @@ import {
|
||||
umamiTopReferrersRequestHandler,
|
||||
} from "@homarr/request-handler/umami";
|
||||
|
||||
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import {
|
||||
createManyWidgetIntegrationMiddleware,
|
||||
createOneWidgetIntegrationMiddleware,
|
||||
} from "../../middlewares/integration";
|
||||
import { settleIntegrationQueries } from "../../settle-integrations";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
const logger = createLogger({ module: "umami-router" });
|
||||
|
||||
export const umamiRouter = createTRPCRouter({
|
||||
getWebsites: publicProcedure.concat(createOneIntegrationMiddleware("query", "umami")).query(async ({ ctx }) => {
|
||||
getWebsites: publicProcedure.concat(createOneWidgetIntegrationMiddleware("query", "umami")).query(async ({ ctx }) => {
|
||||
try {
|
||||
const instance = await createIntegrationAsync(ctx.integration);
|
||||
return await instance.getWebsitesAsync();
|
||||
@@ -37,7 +40,7 @@ export const umamiRouter = createTRPCRouter({
|
||||
eventName: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.concat(createManyIntegrationMiddleware("query", "umami"))
|
||||
.concat(createManyWidgetIntegrationMiddleware("query", "umami"))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
|
||||
const innerHandler = umamiRequestHandler.handler(integration, {
|
||||
@@ -59,7 +62,7 @@ export const umamiRouter = createTRPCRouter({
|
||||
|
||||
getEventNames: publicProcedure
|
||||
.input(z.object({ websiteId: z.string() }))
|
||||
.concat(createOneIntegrationMiddleware("query", "umami"))
|
||||
.concat(createOneWidgetIntegrationMiddleware("query", "umami"))
|
||||
.query(async ({ ctx, input }) => {
|
||||
try {
|
||||
const innerHandler = umamiEventNamesRequestHandler.handler(ctx.integration, { websiteId: input.websiteId });
|
||||
@@ -81,7 +84,7 @@ export const umamiRouter = createTRPCRouter({
|
||||
limit: z.number().int().min(1).max(500),
|
||||
}),
|
||||
)
|
||||
.concat(createOneIntegrationMiddleware("query", "umami"))
|
||||
.concat(createOneWidgetIntegrationMiddleware("query", "umami"))
|
||||
.query(async ({ ctx, input }) => {
|
||||
try {
|
||||
const innerHandler = umamiTopPagesRequestHandler.handler(ctx.integration, {
|
||||
@@ -111,7 +114,7 @@ export const umamiRouter = createTRPCRouter({
|
||||
limit: z.number().int().min(1).max(500),
|
||||
}),
|
||||
)
|
||||
.concat(createOneIntegrationMiddleware("query", "umami"))
|
||||
.concat(createOneWidgetIntegrationMiddleware("query", "umami"))
|
||||
.query(async ({ ctx, input }) => {
|
||||
try {
|
||||
const innerHandler = umamiTopReferrersRequestHandler.handler(ctx.integration, {
|
||||
@@ -141,7 +144,7 @@ export const umamiRouter = createTRPCRouter({
|
||||
eventNames: z.array(z.string()),
|
||||
}),
|
||||
)
|
||||
.concat(createOneIntegrationMiddleware("query", "umami"))
|
||||
.concat(createOneWidgetIntegrationMiddleware("query", "umami"))
|
||||
.query(async ({ ctx, input }) => {
|
||||
try {
|
||||
const sortedNames = [...input.eventNames].toSorted();
|
||||
@@ -166,7 +169,7 @@ export const umamiRouter = createTRPCRouter({
|
||||
|
||||
getActiveVisitors: publicProcedure
|
||||
.input(z.object({ websiteId: z.string() }))
|
||||
.concat(createOneIntegrationMiddleware("query", "umami"))
|
||||
.concat(createOneWidgetIntegrationMiddleware("query", "umami"))
|
||||
.query(async ({ ctx, input }) => {
|
||||
try {
|
||||
const innerHandler = umamiActiveVisitorsRequestHandler.handler(ctx.integration, {
|
||||
|
||||
@@ -1,40 +1,37 @@
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import { upsSummariesRequestHandler } from "@homarr/request-handler/ups";
|
||||
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
export const upsRouter = createTRPCRouter({
|
||||
getSummaries: publicProcedure
|
||||
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("ups")))
|
||||
.query(async ({ ctx }) => {
|
||||
return await settleIntegrationQueries(
|
||||
ctx.integrations,
|
||||
async (integration) => {
|
||||
const innerHandler = upsSummariesRequestHandler.handler(integration, {});
|
||||
const { data, timestamp } = await innerHandler.getDataAsync();
|
||||
getSummaries: publicProcedure.concat(createManyWidgetIntegrationMiddleware("query", "ups")).query(async ({ ctx }) => {
|
||||
return await settleIntegrationQueries(
|
||||
ctx.integrations,
|
||||
async (integration) => {
|
||||
const innerHandler = upsSummariesRequestHandler.handler(integration, {});
|
||||
const { data, timestamp } = await innerHandler.getDataAsync();
|
||||
|
||||
return {
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
integrationUrl: integration.url,
|
||||
summaries: data,
|
||||
updatedAt: timestamp,
|
||||
error: undefined as string | undefined,
|
||||
};
|
||||
},
|
||||
{
|
||||
fallback: (integration, error) => ({
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
integrationUrl: integration.url,
|
||||
summaries: [],
|
||||
updatedAt: new Date(0),
|
||||
error: toPublicIntegrationError(error),
|
||||
}),
|
||||
throwOnAllFailures: true,
|
||||
},
|
||||
);
|
||||
}),
|
||||
return {
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
integrationUrl: integration.url,
|
||||
summaries: data,
|
||||
updatedAt: timestamp,
|
||||
error: undefined as string | undefined,
|
||||
};
|
||||
},
|
||||
{
|
||||
fallback: (integration, error) => ({
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
integrationUrl: integration.url,
|
||||
summaries: [],
|
||||
updatedAt: new Date(0),
|
||||
error: toPublicIntegrationError(error),
|
||||
}),
|
||||
throwOnAllFailures: true,
|
||||
},
|
||||
);
|
||||
}),
|
||||
});
|
||||
@@ -1,12 +1,12 @@
|
||||
import { uptimeKumaRequestHandler } from "@homarr/request-handler/uptime-kuma";
|
||||
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
export const uptimeKumaRouter = createTRPCRouter({
|
||||
getDashboard: publicProcedure
|
||||
.concat(createManyIntegrationMiddleware("query", "uptimeKuma"))
|
||||
.concat(createManyWidgetIntegrationMiddleware("query", "uptimeKuma"))
|
||||
.query(async ({ ctx }) => {
|
||||
return await settleIntegrationQueries(
|
||||
ctx.integrations,
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import { vpnSummaryHandler } from "@homarr/request-handler/vpn";
|
||||
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
export const vpnRouter = createTRPCRouter({
|
||||
getSummaries: publicProcedure
|
||||
.unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("vpn")))
|
||||
.unstable_concat(createManyWidgetIntegrationMiddleware("query", "vpn"))
|
||||
.query(async ({ ctx }) => {
|
||||
return await settleIntegrationQueries(
|
||||
ctx.integrations,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { wudStatsRequestHandler } from "@homarr/request-handler/wud";
|
||||
|
||||
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
export const wudRouter = createTRPCRouter({
|
||||
@@ -12,7 +12,7 @@ export const wudRouter = createTRPCRouter({
|
||||
"Returns monitored-container counts and available updates from a What's Up Docker integration. REQUIRED: integrationId from integration_all. The caller needs query permission for that integration.",
|
||||
},
|
||||
})
|
||||
.concat(createOneIntegrationMiddleware("query", "wud"))
|
||||
.concat(createOneWidgetIntegrationMiddleware("query", "wud"))
|
||||
.query(async ({ ctx }) => {
|
||||
const handler = wudStatsRequestHandler.handler(ctx.integration, {});
|
||||
const { data, timestamp } = await handler.getDataAsync();
|
||||
|
||||
@@ -2,11 +2,13 @@ import type { TRPCError } from "@trpc/server";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { mcpRouter } from "../mcp";
|
||||
import { getMcpRouterAsync } from "../mcp";
|
||||
import { extractMcpToolsFromProcedures } from "../mcp-tools";
|
||||
|
||||
vi.mock("@homarr/auth", () => ({}));
|
||||
|
||||
const mcpRouter = await getMcpRouterAsync();
|
||||
|
||||
const MCP_TOOL_ALLOWLIST = {
|
||||
query: [
|
||||
"airQuality_atLocation",
|
||||
|
||||
@@ -16,6 +16,7 @@ import { extractBaseUrlFromHeaders, FlattenError } from "@homarr/common";
|
||||
import { userAgent } from "@homarr/common/server";
|
||||
import type { DeviceType } from "@homarr/common/server";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||
import { db } from "@homarr/db";
|
||||
import type { GroupPermissionKey, OnboardingStep } from "@homarr/definitions";
|
||||
|
||||
@@ -105,6 +106,13 @@ export const createCallerFactory = t.createCallerFactory;
|
||||
*/
|
||||
export const createTRPCRouter = t.router;
|
||||
|
||||
/**
|
||||
* Unadorned procedure builder for middleware fragments that are concatenated
|
||||
* onto a public, protected, or internal procedure. Global middleware belongs
|
||||
* on the outer procedure so observability and policy checks run exactly once.
|
||||
*/
|
||||
export const middlewareProcedure = t.procedure;
|
||||
|
||||
export const isDemoMode = env.DEMO_MODE;
|
||||
|
||||
// The public "real demo" (demo + mock integrations) stays read-only, while preview
|
||||
@@ -121,7 +129,47 @@ const enforceDemoModeReadOnly = t.middleware(({ ctx, next, type }) => {
|
||||
return next({ ctx });
|
||||
});
|
||||
|
||||
const baseProcedure = isDemoReadOnly ? t.procedure.use(enforceDemoModeReadOnly) : t.procedure;
|
||||
const observeProcedure = t.middleware(async ({ ctx, next, path, type }) => {
|
||||
const startedAt = Date.now();
|
||||
const metadata = {
|
||||
path,
|
||||
type,
|
||||
userId: ctx.session?.user.id,
|
||||
};
|
||||
|
||||
logger.debug("tRPC procedure started", metadata);
|
||||
|
||||
const result = await next();
|
||||
const durationMs = Date.now() - startedAt;
|
||||
|
||||
if (result.ok) {
|
||||
logger.debug("tRPC procedure completed", { ...metadata, durationMs });
|
||||
return result;
|
||||
}
|
||||
|
||||
const safeError = new Error(result.error.message);
|
||||
safeError.name = result.error.name;
|
||||
safeError.stack = result.error.stack;
|
||||
|
||||
logger.warn(
|
||||
new ErrorWithMetadata(
|
||||
"tRPC procedure failed",
|
||||
{
|
||||
...metadata,
|
||||
durationMs,
|
||||
errorCode: result.error.code,
|
||||
causeType: result.error.cause instanceof Error ? result.error.cause.name : undefined,
|
||||
},
|
||||
{ cause: safeError },
|
||||
),
|
||||
);
|
||||
return result;
|
||||
});
|
||||
|
||||
let baseProcedure = t.procedure.use(observeProcedure);
|
||||
if (isDemoReadOnly) {
|
||||
baseProcedure = baseProcedure.use(enforceDemoModeReadOnly);
|
||||
}
|
||||
|
||||
/**
|
||||
* Public (unauthed) procedure
|
||||
@@ -145,7 +193,7 @@ const enforceOnboardingAccess = t.middleware(async ({ ctx, next }) => {
|
||||
|
||||
export const onboardingClaimedProcedure = baseProcedure.use(enforceOnboardingAccess);
|
||||
|
||||
export const internalProcedure = t.procedure;
|
||||
export const internalProcedure = t.procedure.use(observeProcedure);
|
||||
|
||||
const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
|
||||
if (!ctx.session?.user) {
|
||||
|
||||
Reference in new issue
Block a user