Merge remote-tracking branch 'origin/release/v2' into feat/pr-6691-hardening

# Conflicts:
#	apps/nextjs/src/app/[locale]/layout.tsx
#	packages/api/src/router/widgets/anchor-notes.ts
#	packages/api/src/router/widgets/archive-team-warrior.ts
#	packages/api/src/router/widgets/audio-stats.ts
#	packages/api/src/router/widgets/bazarr.ts
#	packages/api/src/router/widgets/coolify.ts
#	packages/api/src/router/widgets/firewall.ts
#	packages/api/src/router/widgets/immich.ts
#	packages/api/src/router/widgets/paperless-ngx.ts
#	packages/api/src/router/widgets/patchmon.ts
#	packages/api/src/router/widgets/speedtest-tracker.ts
#	packages/api/src/router/widgets/tracearr.ts
#	packages/api/src/router/widgets/traefik.ts
#	packages/api/src/router/widgets/umami.ts
#	packages/api/src/router/widgets/uptime-kuma.ts
#	packages/api/src/router/widgets/vpn.ts
#	packages/api/src/router/widgets/wud.ts
#	packages/definitions/src/widget-integration-map.ts
#	packages/widgets/src/anchor-note/index.ts
#	packages/widgets/src/archive-team-warrior/index.ts
#	packages/widgets/src/audio-stats/index.ts
#	packages/widgets/src/bazarr/index.ts
#	packages/widgets/src/coolify/index.ts
#	packages/widgets/src/firewall/index.ts
#	packages/widgets/src/immich/album-carousel/index.ts
#	packages/widgets/src/immich/server-stats/index.ts
#	packages/widgets/src/paperless-ngx/index.ts
#	packages/widgets/src/patchmon/index.ts
#	packages/widgets/src/speedtest-tracker/index.ts
#	packages/widgets/src/system-disks/index.ts
#	packages/widgets/src/system-resources/index.ts
#	packages/widgets/src/tracearr/index.ts
#	packages/widgets/src/traefik/index.ts
#	packages/widgets/src/umami/index.ts
#	packages/widgets/src/uptime-kuma/index.ts
#	packages/widgets/src/vpn/index.ts
#	packages/widgets/src/wud/index.ts
This commit is contained in:
ajnart committed 2026-08-26 15:12:49 +02:00
commit ac7e3f2ef4
201 files changed
+13795 -981

No files matched your search

+3 -3
View File
@@ -495,7 +495,7 @@ export const assistantRouter = createTRPCRouter({
models: [
{
id: "homarr-assistant",
name: "Homarr Assistant (Demo)",
name: "Assistant (Demo)",
description: null,
contextLength: null,
promptPrice: null,
@@ -507,7 +507,7 @@ export const assistantRouter = createTRPCRouter({
};
}
if (!configuration?.enabled || !configuration.modelId) {
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Homarr Assistant is not configured." });
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Assistant is not configured." });
}
const discoveredModels = await getAssistantModelsAsync(configuration).catch(() => []);
const resolvedDefaultId = resolveAssistantModelId(discoveredModels, configuration.modelId) ?? configuration.modelId;
@@ -708,7 +708,7 @@ export const assistantRouter = createTRPCRouter({
getConfigurationAsync(ctx.db),
]);
if (!configuration?.enabled || !configuration.modelId) {
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Homarr Assistant is not configured." });
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Assistant is not configured." });
}
const selectedModel = await getSelectedModelDetailsAsync(configuration, input.modelId).catch(() => null);
if (input.modelId !== configuration.modelId && !selectedModel) {
+30 -3
View File
@@ -85,7 +85,9 @@ interface BoardItemPlacementRectangle {
const boardItemPlacementTails = new Map<string, Promise<void>>();
const maxManageOverviewPreviewRows = 12;
const manageOverviewInputSchema = z.object({ fullPreview: z.boolean().default(false) }).optional();
const manageOverviewInputSchema = z
.object({ fullPreview: z.boolean().default(false), userId: z.string().optional() })
.optional();
const serializeBoardItemPlacementAsync = async <T>(boardId: string, operation: () => Promise<T>) => {
const previous = boardItemPlacementTails.get(boardId) ?? Promise.resolve();
@@ -207,6 +209,7 @@ const searchAccessibleBoardsAsync = async (
group: {
with: {
boardPermissions: {},
permissions: {},
},
},
},
@@ -363,9 +366,14 @@ export const boardRouter = createTRPCRouter({
}));
}),
getManageOverview: publicProcedure.input(manageOverviewInputSchema).query(async ({ ctx, input }) => {
const userId = ctx.session?.user.id;
const sessionUserId = ctx.session?.user.id;
const userId = input?.userId ?? sessionUserId;
if (userId !== sessionUserId && !ctx.session?.user.permissions.includes("admin")) {
throw new TRPCError({ code: "FORBIDDEN", message: "You are not allowed to view boards for this user" });
}
const { boardIds, currentUser, groupMemberships } = await getBoardAccessContextAsync(ctx.db, userId);
const groupPermissionWhere = getBoardGroupPermissionWhere(groupMemberships);
const canViewAllBoards = getCanViewAllBoards(groupMemberships);
const dbBoards = await ctx.db.query.boards.findMany({
columns: {
@@ -415,7 +423,7 @@ export const boardRouter = createTRPCRouter({
orderBy: (section, { asc }) => [asc(section.xOffset), asc(section.id)],
},
},
where: getAccessibleBoardsWhere(ctx.session?.user.permissions.includes("board-view-all"), userId, boardIds),
where: getAccessibleBoardsWhere(canViewAllBoards, userId, boardIds),
});
const previewLayoutIds = dbBoards.flatMap((board) => board.layouts.map((layout) => layout.id));
@@ -2615,6 +2623,7 @@ const getBoardAccessContextAsync = async (db: Database, userId: string | undefin
group: {
with: {
boardPermissions: {},
permissions: {},
},
},
},
@@ -2638,6 +2647,24 @@ const getBoardAccessContextAsync = async (db: Database, userId: string | undefin
return { boardIds, currentUser, groupMemberships };
};
const getCanViewAllBoards = (
groupMemberships: Awaited<ReturnType<typeof getBoardAccessContextAsync>>["groupMemberships"],
) => {
const permissions = new Set(
groupMemberships.flatMap((membership) => membership.group.permissions.map(({ permission }) => permission)),
);
return getPermissionsWithChildren([...permissions]).includes("board-view-all");
};
export const getAccessibleBoardIdsForUserAsync = async (db: Database, userId: string) => {
const { boardIds, groupMemberships } = await getBoardAccessContextAsync(db, userId);
const accessibleBoards = await db.query.boards.findMany({
columns: { id: true },
where: getAccessibleBoardsWhere(getCanViewAllBoards(groupMemberships), userId, boardIds),
});
return new Set(accessibleBoards.map(({ id }) => id));
};
const getBoardGroupPermissionWhere = (
groupMemberships: Awaited<ReturnType<typeof getBoardAccessContextAsync>>["groupMemberships"],
) =>
+40 -3
View File
@@ -10,7 +10,12 @@ import {
} from "@homarr/db/queries";
import { boards, serverSettings } from "@homarr/db/schema";
import type { ServerSettings } from "@homarr/server-settings";
import { defaultServerSettingsKeys } from "@homarr/server-settings";
import {
authBrandingSchema,
brandingServerSettingsSchema,
defaultServerSettingsKeys,
parseBrandingSettings,
} from "@homarr/server-settings";
import { createTRPCRouter, permissionRequiredProcedure, publicProcedure } from "../trpc";
@@ -22,7 +27,14 @@ const boardServerSettingsSchema = z.object({
}) satisfies z.ZodType<ServerSettings["board"]>;
const boardServerSettingsUpdateSchema = boardServerSettingsSchema.partial();
const brandingServerSettingsUpdateSchema = brandingServerSettingsSchema.partial().extend({
authBranding: authBrandingSchema.partial().optional(),
});
const legacyAuthBrandingUpdateSchema = z.object({
showCustomAppNameOnLogin: z.boolean().optional(),
showCustomLogoOnLogin: z.boolean().optional(),
showCustomGreetingOnLogin: z.boolean().optional(),
});
export const serverSettingsRouter = createTRPCRouter({
getCulture: publicProcedure.query(async ({ ctx }) => {
return await getServerSettingByKeyAsync(ctx.db, "culture");
@@ -30,6 +42,14 @@ export const serverSettingsRouter = createTRPCRouter({
getAll: permissionRequiredProcedure.requiresPermission("admin").query(async ({ ctx }) => {
return await getServerSettingsAsync(ctx.db);
}),
getBranding: publicProcedure
.meta({
mcp: { enabled: true, description: "Returns the public instance branding configuration." },
})
.query(async ({ ctx }) => {
const branding = await getServerSettingByKeyAsync(ctx.db, "branding");
return parseBrandingSettings(branding);
}),
getBoardSettings: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
@@ -100,10 +120,27 @@ export const serverSettingsRouter = createTRPCRouter({
}),
)
.mutation(async ({ ctx, input }) => {
if (input.settingsKey === "branding") {
const current = await getServerSettingByKeyAsync(ctx.db, "branding");
const parsedInput = brandingServerSettingsUpdateSchema.parse(input.value);
const legacyInput = legacyAuthBrandingUpdateSchema.parse(input.value);
const authBranding = { ...current.authBranding };
authBranding.showAppName = legacyInput.showCustomAppNameOnLogin ?? authBranding.showAppName;
authBranding.showLogo = legacyInput.showCustomLogoOnLogin ?? authBranding.showLogo;
authBranding.showGreeting = legacyInput.showCustomGreetingOnLogin ?? authBranding.showGreeting;
Object.assign(authBranding, parsedInput.authBranding);
const value = brandingServerSettingsSchema.parse({
...parseBrandingSettings(current),
...parsedInput,
authBranding,
});
await updateServerSettingByKeyAsync(ctx.db, "branding", value);
return;
}
const current = await getServerSettingByKeyAsync(ctx.db, input.settingsKey);
await updateServerSettingByKeyAsync(ctx.db, input.settingsKey, {
...current,
...input.value,
} as ServerSettings[keyof ServerSettings]);
} as ServerSettings[typeof input.settingsKey]);
}),
});
@@ -79,7 +79,7 @@ describe("saveSettings", () => {
});
const dbSettings = await db.select().from(serverSettings);
expect(dbSettings).toStrictEqual([
expect(dbSettings.filter(({ settingKey }) => settingKey === "analytics")).toStrictEqual([
{
settingKey: "analytics",
value: stringify({
@@ -124,7 +124,7 @@ describe("board settings API", () => {
forceDisableStatus: true,
});
const dbSettings = await db.select().from(serverSettings);
expect(dbSettings).toStrictEqual([
expect(dbSettings.filter(({ settingKey }) => settingKey === "board")).toStrictEqual([
{
settingKey: "board",
value: stringify(result),
@@ -155,7 +155,7 @@ describe("board settings API", () => {
enableStatusByDefault: false,
});
const dbSettings = await db.select().from(serverSettings);
expect(dbSettings).toStrictEqual([
expect(dbSettings.filter(({ settingKey }) => settingKey === "board")).toStrictEqual([
{
settingKey: "board",
value: stringify(result),
+68 -1
View File
@@ -15,8 +15,11 @@ import { selectUserSchema } from "@homarr/db/validationSchemas";
import type { SupportedAuthProvider } from "@homarr/definitions";
import { credentialsAdminGroup, supportedAuthProviders } from "@homarr/definitions";
import { byIdSchema } from "@homarr/validation/common";
import type { userBaseCreateSchema } from "@homarr/validation/user";
import type { HeaderPreferences, userBaseCreateSchema } from "@homarr/validation/user";
import {
getHeaderItems,
headerPreferencesMutationSchema,
parseHeaderPreferences,
userChangeColorSchemeSchema,
userChangeHomeBoardsSchema,
userChangePasswordApiSchema,
@@ -30,6 +33,7 @@ import {
userPingIconsEnabledSchema,
userRegistrationApiSchema,
} from "@homarr/validation/user";
import { serializeHeaderPreferences } from "@homarr/validation/header-preferences";
import { convertIntersectionToZodObject } from "../schema-merger";
import {
@@ -40,6 +44,7 @@ import {
protectedProcedure,
publicProcedure,
} from "../trpc";
import { getAccessibleBoardIdsForUserAsync } from "./board";
import { throwIfActionForbiddenAsync } from "./board/board-access";
import { throwIfCredentialsDisabled } from "./invite/checks";
import { changeSearchPreferencesAsync, changeSearchPreferencesInputSchema } from "./user/change-search-preferences";
@@ -368,6 +373,7 @@ export const userRouter = createTRPCRouter({
firstDayOfWeek: true,
pingIconsEnabled: true,
enableRightClickOnWidgets: true,
headerPreferences: true,
defaultSearchEngineId: true,
openSearchInNewTab: true,
ddgBangs: true,
@@ -405,6 +411,7 @@ export const userRouter = createTRPCRouter({
firstDayOfWeek: true,
pingIconsEnabled: true,
enableRightClickOnWidgets: true,
headerPreferences: true,
defaultSearchEngineId: true,
openSearchInNewTab: true,
ddgBangs: true,
@@ -719,6 +726,63 @@ export const userRouter = createTRPCRouter({
})
.where(eq(users.id, input.id));
}),
changeHeaderPreferences: protectedProcedure
.meta({
openapi: {
method: "PATCH",
path: "/api/users/header-preferences",
tags: ["users"],
protect: true,
},
mcp: {
enabled: true,
description:
"Update the header layout preferences of a user. REQUIRED: id (user ID), headerPreferences (zones for left, center and right, visible flag, searchDisplay and logoDisplay). Admins can change any user; other users can only change their own. Board shortcut items must reference boards the target user can view",
},
})
.input(z.object({ id: z.string(), headerPreferences: headerPreferencesMutationSchema }))
.output(z.void())
.mutation(async ({ input, ctx }) => {
if (!ctx.session.user.permissions.includes("admin") && ctx.session.user.id !== input.id) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
const targetUser = await ctx.db.query.users.findFirst({
columns: { headerPreferences: true },
where: eq(users.id, input.id),
});
if (!targetUser) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
const submittedBoardIds = getBoardShortcutIds(input.headerPreferences);
if (submittedBoardIds.length > 0) {
const existingBoardIds = new Set(getBoardShortcutIds(parseHeaderPreferences(targetUser.headerPreferences)));
const accessibleBoardIds = await getAccessibleBoardIdsForUserAsync(ctx.db, input.id);
const hasUnavailableShortcut = submittedBoardIds.some(
(boardId) => !accessibleBoardIds.has(boardId) && !existingBoardIds.has(boardId),
);
if (hasUnavailableShortcut) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "One or more board shortcuts are unavailable to this user",
});
}
}
await ctx.db
.update(users)
.set({
headerPreferences: serializeHeaderPreferences(input.headerPreferences),
})
.where(eq(users.id, input.id));
}),
changeDdgBangs: protectedProcedure
.input(convertIntersectionToZodObject(userDdgBangsSchema.and(byIdSchema)))
.output(z.void())
@@ -829,6 +893,9 @@ export const userRouter = createTRPCRouter({
}),
});
const getBoardShortcutIds = (preferences: HeaderPreferences) =>
getHeaderItems(preferences.zones).flatMap((item) => (item.type === "board" ? [item.boardId] : []));
const createUserAsync = async (db: Database, input: Omit<z.infer<typeof userBaseCreateSchema>, "groupIds">) => {
const hashedPassword = await hashPasswordAsync(input.password);
@@ -3,6 +3,7 @@ import { z } from "zod/v4";
import { ResponseError } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { mockWidgetData } 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";
@@ -16,6 +17,12 @@ const noteIdInput = z.object({
const logger = createLogger({ module: "anchorNotesRouter" });
const assertMockNoteExists = (noteId: string) => {
if (noteId === mockWidgetData.anchorNote.id) return;
throw new TRPCError({ code: "NOT_FOUND", message: "Note not found" });
};
const isJsonDeltaString = (value: string) => {
try {
const parsed: unknown = JSON.parse(value);
@@ -44,7 +51,19 @@ export const anchorNotesRouter = createTRPCRouter({
.concat(createOneWidgetIntegrationMiddleware("query", "anchorNote"))
.input(anchorNotesListInputSchema)
.query(async ({ ctx, input }) => {
const handler = anchorNotesListRequestHandler.handler(ctx.integration, { ...input, limit: input.limit ?? 50 });
if (ctx.integration.kind === "mock") {
const search = input.search?.toLowerCase();
const notes = mockWidgetData.anchorNotes.filter((note) => !search || note.title.toLowerCase().includes(search));
return notes.slice(0, input.limit ?? 50);
}
const handler = anchorNotesListRequestHandler.handler(
{ ...ctx.integration, kind: "anchor" },
{
...input,
limit: input.limit ?? 50,
},
);
const { data } = await handler.getDataAsync();
@@ -54,7 +73,15 @@ export const anchorNotesRouter = createTRPCRouter({
.concat(createOneWidgetIntegrationMiddleware("query", "anchorNote"))
.input(noteIdInput)
.query(async ({ ctx, input }) => {
const handler = anchorNoteRequestHandler.handler(ctx.integration, { noteId: input.noteId });
if (ctx.integration.kind === "mock") {
assertMockNoteExists(input.noteId);
return mockWidgetData.anchorNote;
}
const handler = anchorNoteRequestHandler.handler(
{ ...ctx.integration, kind: "anchor" },
{ noteId: input.noteId },
);
const { data } = await handler.getDataAsync();
@@ -64,11 +91,21 @@ export const anchorNotesRouter = createTRPCRouter({
.concat(createOneWidgetIntegrationMiddleware("interact", "anchorNote"))
.input(anchorNoteUpdateInputSchema)
.mutation(async ({ ctx, input }) => {
const integrationInstance = await createIntegrationAsync(ctx.integration);
const normalizedContent = normalizeAnchorContent(input.content);
const isContentNormalized =
input.content !== undefined && normalizedContent !== undefined && input.content !== normalizedContent;
if (ctx.integration.kind === "mock") {
assertMockNoteExists(input.noteId);
return {
...mockWidgetData.anchorNote,
...(input.title !== undefined ? { title: input.title } : {}),
...(normalizedContent !== undefined ? { content: normalizedContent } : {}),
};
}
const integrationInstance = await createIntegrationAsync({ ...ctx.integration, kind: "anchor" });
try {
const updatedNote = await integrationInstance.updateNoteAsync({
noteId: input.noteId,
@@ -1,4 +1,5 @@
import { archiveTeamWarriorRequestHandler } from "@homarr/request-handler/archive-team-warrior";
import { mockWidgetData } from "@homarr/integrations";
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
@@ -7,7 +8,11 @@ export const archiveTeamWarriorRouter = createTRPCRouter({
getStatus: publicProcedure
.concat(createOneWidgetIntegrationMiddleware("query", "archiveTeamWarrior"))
.query(async ({ ctx }) => {
const handler = archiveTeamWarriorRequestHandler.handler(ctx.integration, {});
if (ctx.integration.kind === "mock") {
return { status: mockWidgetData.archiveTeamWarrior, updatedAt: new Date(mockWidgetData.timestamp) };
}
const handler = archiveTeamWarriorRequestHandler.handler({ ...ctx.integration, kind: "archiveTeamWarrior" }, {});
const { data, timestamp } = await handler.getDataAsync();
return {
@@ -1,5 +1,6 @@
import { audiobookshelfRequestHandler } from "@homarr/request-handler/audiobookshelf";
import { navidromeRequestHandler } from "@homarr/request-handler/navidrome";
import { mockWidgetData } from "@homarr/integrations";
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
@@ -9,6 +10,7 @@ export const audioStatsRouter = createTRPCRouter({
.concat(createOneWidgetIntegrationMiddleware("query", "audioStats"))
.query(async ({ ctx }) => {
const { kind } = ctx.integration;
if (kind === "mock") return { kind: "navidrome" as const, data: mockWidgetData.audioStats };
const fetchByKind = {
navidrome: async () => {
+4 -1
View File
@@ -1,4 +1,5 @@
import { bazarrBadgesRequestHandler } from "@homarr/request-handler/bazarr";
import { mockWidgetData } from "@homarr/integrations";
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
@@ -14,7 +15,9 @@ export const bazarrRouter = createTRPCRouter({
})
.concat(createOneWidgetIntegrationMiddleware("query", "bazarr"))
.query(async ({ ctx }) => {
const innerHandler = bazarrBadgesRequestHandler.handler(ctx.integration, {});
if (ctx.integration.kind === "mock") return mockWidgetData.bazarr;
const innerHandler = bazarrBadgesRequestHandler.handler({ ...ctx.integration, kind: "bazarr" }, {});
const data = await innerHandler.getDataAsync();
return data.data;
}),
+12 -1
View File
@@ -1,4 +1,5 @@
import type { CoolifyInstanceInfo } from "@homarr/integrations/types";
import { mockWidgetData } from "@homarr/integrations";
import { coolifyRequestHandler } from "@homarr/request-handler/coolify";
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
@@ -21,7 +22,17 @@ export const coolifyRouter = createTRPCRouter({
return await settleIntegrationQueries(
ctx.integrations,
async (integration): Promise<CoolifyInstanceResult> => {
const innerHandler = coolifyRequestHandler.handler(integration, {});
if (integration.kind === "mock") {
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
instanceInfo: mockWidgetData.coolify,
updatedAt: new Date(mockWidgetData.timestamp),
};
}
const innerHandler = coolifyRequestHandler.handler({ ...integration, kind: "coolify" }, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return {
+37 -8
View File
@@ -1,3 +1,4 @@
import { mockWidgetData } from "@homarr/integrations";
import {
firewallCpuRequestHandler,
firewallInterfacesRequestHandler,
@@ -24,19 +25,31 @@ interface FirewallQueryResult<TData> {
integration: {
id: string;
name: string;
kind: FirewallIntegration["kind"];
kind: FirewallIntegration["kind"] | "mock";
updatedAt: Date;
};
summary: TData;
error: string | undefined;
}
const queryFirewall = <TData>(handler: FirewallHandler<TData>, createFallback: () => TData) =>
const queryFirewall = <TData>(handler: FirewallHandler<TData>, createFallback: () => TData, getMockData: () => TData) =>
publicProcedure.concat(firewallMiddleware).query(async ({ ctx }) =>
settleIntegrationQueries(
ctx.integrations,
async (integration): Promise<FirewallQueryResult<TData>> => {
const { data, timestamp } = await handler.handler(integration, {}).getDataAsync();
if (integration.kind === "mock") {
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt: new Date(mockWidgetData.timestamp),
},
summary: getMockData(),
error: undefined,
};
}
const { data, timestamp } = await handler.handler({ ...integration, kind: "opnsense" }, {}).getDataAsync();
return {
integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: timestamp },
summary: data,
@@ -44,7 +57,7 @@ const queryFirewall = <TData>(handler: FirewallHandler<TData>, createFallback: (
};
},
{
fallback: (integration, error) => ({
fallback: (integration, error): FirewallQueryResult<TData> => ({
integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: new Date(0) },
summary: createFallback(),
error: toPublicIntegrationError(error),
@@ -55,8 +68,24 @@ const queryFirewall = <TData>(handler: FirewallHandler<TData>, createFallback: (
);
export const firewallRouter = createTRPCRouter({
getFirewallCpuStatus: queryFirewall(firewallCpuRequestHandler, () => ({ total: 0 })),
getFirewallInterfacesStatus: queryFirewall(firewallInterfacesRequestHandler, () => []),
getFirewallVersionStatus: queryFirewall(firewallVersionRequestHandler, () => ({ version: "Unknown" })),
getFirewallMemoryStatus: queryFirewall(firewallMemoryRequestHandler, () => ({ used: 0, total: 0, percent: 0 })),
getFirewallCpuStatus: queryFirewall(
firewallCpuRequestHandler,
() => ({ total: 0 }),
() => mockWidgetData.firewallCpu,
),
getFirewallInterfacesStatus: queryFirewall(
firewallInterfacesRequestHandler,
() => [],
() => mockWidgetData.firewallInterfaces,
),
getFirewallVersionStatus: queryFirewall(
firewallVersionRequestHandler,
() => ({ version: "Unknown" }),
() => mockWidgetData.firewallVersion,
),
getFirewallMemoryStatus: queryFirewall(
firewallMemoryRequestHandler,
() => ({ used: 0, total: 0, percent: 0 }),
() => mockWidgetData.firewallMemory,
),
});
+17 -3
View File
@@ -1,5 +1,6 @@
import { z } from "zod/v4";
import { mockWidgetData } from "@homarr/integrations";
import {
immichAlbumRequestHandler,
immichAlbumsRequestHandler,
@@ -13,7 +14,8 @@ export const immichRouter = createTRPCRouter({
getServerStats: publicProcedure
.concat(createOneWidgetIntegrationMiddleware("query", "immich-serverStats"))
.query(async ({ ctx }) => {
const innerHandler = immichStatsRequestHandler.handler(ctx.integration, {});
if (ctx.integration.kind === "mock") return mockWidgetData.immichStats;
const innerHandler = immichStatsRequestHandler.handler({ ...ctx.integration, kind: "immich" }, {});
const data = await innerHandler.getDataAsync();
return data.data;
}),
@@ -26,7 +28,13 @@ export const immichRouter = createTRPCRouter({
}),
)
.query(async ({ ctx, input }) => {
const innerHandler = immichAlbumRequestHandler.handler(ctx.integration, { albumId: input.albumId });
if (ctx.integration.kind === "mock") return mockWidgetData.immichAlbum;
const innerHandler = immichAlbumRequestHandler.handler(
{ ...ctx.integration, kind: "immich" },
{
albumId: input.albumId,
},
);
const data = await innerHandler.getDataAsync();
return data.data;
}),
@@ -39,7 +47,13 @@ export const immichRouter = createTRPCRouter({
)
.concat(createOneWidgetIntegrationMiddleware("query", "immich-albumCarousel"))
.query(async ({ ctx, input }) => {
const innerHandler = immichAlbumsRequestHandler.handler(ctx.integration, { limit: input.limit });
if (ctx.integration.kind === "mock") return mockWidgetData.immichAlbums.slice(0, input.limit);
const innerHandler = immichAlbumsRequestHandler.handler(
{ ...ctx.integration, kind: "immich" },
{
limit: input.limit,
},
);
const data = await innerHandler.getDataAsync();
return data.data;
}),
@@ -1,4 +1,5 @@
import { paperlessNgxStatsRequestHandler } from "@homarr/request-handler/paperless-ngx";
import { mockWidgetData } from "@homarr/integrations";
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
@@ -7,7 +8,8 @@ export const paperlessNgxRouter = createTRPCRouter({
getStats: publicProcedure
.concat(createOneWidgetIntegrationMiddleware("query", "paperlessNgx"))
.query(async ({ ctx }) => {
const innerHandler = paperlessNgxStatsRequestHandler.handler(ctx.integration, {});
if (ctx.integration.kind === "mock") return mockWidgetData.paperlessNgx;
const innerHandler = paperlessNgxStatsRequestHandler.handler({ ...ctx.integration, kind: "paperlessNgx" }, {});
const data = await innerHandler.getDataAsync();
return data.data;
}),
+3 -1
View File
@@ -1,4 +1,5 @@
import { patchmonStatsRequestHandler } from "@homarr/request-handler/patchmon";
import { mockWidgetData } from "@homarr/integrations";
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, protectedProcedure } from "../../trpc";
@@ -14,7 +15,8 @@ export const patchmonRouter = createTRPCRouter({
})
.concat(createOneWidgetIntegrationMiddleware("query", "patchmon"))
.query(async ({ ctx }) => {
const innerHandler = patchmonStatsRequestHandler.handler(ctx.integration, {});
if (ctx.integration.kind === "mock") return mockWidgetData.patchmon;
const innerHandler = patchmonStatsRequestHandler.handler({ ...ctx.integration, kind: "patchmon" }, {});
const { data } = await innerHandler.getDataAsync();
return data;
}),
@@ -1,4 +1,5 @@
import { speedtestTrackerRequestHandler } from "@homarr/request-handler/speedtest-tracker";
import { mockWidgetData } from "@homarr/integrations";
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
@@ -11,7 +12,17 @@ export const speedtestTrackerRouter = createTRPCRouter({
return await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const innerHandler = speedtestTrackerRequestHandler.handler(integration, {});
if (integration.kind === "mock") {
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
dashboard: mockWidgetData.speedtestTracker,
updatedAt: new Date(mockWidgetData.timestamp),
error: undefined,
};
}
const innerHandler = speedtestTrackerRequestHandler.handler({ ...integration, kind: "speedtestTracker" }, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return {
+14 -3
View File
@@ -1,4 +1,5 @@
import { tracearrRequestHandler } from "@homarr/request-handler/tracearr";
import { mockWidgetData } from "@homarr/integrations";
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
@@ -11,16 +12,26 @@ export const tracearrRouter = createTRPCRouter({
return await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const innerHandler = tracearrRequestHandler.handler(integration, {});
if (integration.kind === "mock") {
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
dashboard: mockWidgetData.tracearr,
updatedAt: new Date(mockWidgetData.timestamp),
error: undefined,
};
}
const innerHandler = tracearrRequestHandler.handler({ ...integration, kind: "tracearr" }, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
dashboard: data,
dashboard: data as typeof data | null,
updatedAt: timestamp,
error: undefined,
error: undefined as string | undefined,
};
},
{
+12 -1
View File
@@ -1,4 +1,5 @@
import { traefikRequestHandler } from "@homarr/request-handler/traefik";
import { mockWidgetData } from "@homarr/integrations";
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
@@ -18,7 +19,17 @@ export const traefikRouter = createTRPCRouter({
return await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const innerHandler = traefikRequestHandler.handler(integration, {});
if (integration.kind === "mock") {
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
dashboard: mockWidgetData.traefik,
updatedAt: new Date(mockWidgetData.timestamp),
error: undefined,
};
}
const innerHandler = traefikRequestHandler.handler({ ...integration, kind: "traefik" }, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return {
+63 -25
View File
@@ -2,6 +2,7 @@ import { z } from "zod/v4";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import { mockWidgetData } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import {
umamiActiveVisitorsRequestHandler,
@@ -23,8 +24,9 @@ const logger = createLogger({ module: "umami-router" });
export const umamiRouter = createTRPCRouter({
getWebsites: publicProcedure.concat(createOneWidgetIntegrationMiddleware("query", "umami")).query(async ({ ctx }) => {
if (ctx.integration.kind === "mock") return mockWidgetData.umamiWebsites;
try {
const instance = await createIntegrationAsync(ctx.integration);
const instance = await createIntegrationAsync({ ...ctx.integration, kind: "umami" });
return await instance.getWebsitesAsync();
} catch (error) {
logger.warn(new Error("Failed to load websites", { cause: error }));
@@ -43,11 +45,23 @@ export const umamiRouter = createTRPCRouter({
.concat(createManyWidgetIntegrationMiddleware("query", "umami"))
.query(async ({ ctx, input }) => {
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
const innerHandler = umamiRequestHandler.handler(integration, {
websiteId: input.websiteId,
timeFrame: input.timeFrame,
eventName: input.eventName,
});
if (integration.kind === "mock") {
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
visitorStats: { ...mockWidgetData.umamiVisitorStats, timeFrame: input.timeFrame },
updatedAt: new Date(mockWidgetData.timestamp),
};
}
const innerHandler = umamiRequestHandler.handler(
{ ...integration, kind: "umami" },
{
websiteId: input.websiteId,
timeFrame: input.timeFrame,
eventName: input.eventName,
},
);
const { data, timestamp } = await innerHandler.getDataAsync();
return {
@@ -64,8 +78,14 @@ export const umamiRouter = createTRPCRouter({
.input(z.object({ websiteId: z.string() }))
.concat(createOneWidgetIntegrationMiddleware("query", "umami"))
.query(async ({ ctx, input }) => {
if (ctx.integration.kind === "mock") return mockWidgetData.umamiEventNames;
try {
const innerHandler = umamiEventNamesRequestHandler.handler(ctx.integration, { websiteId: input.websiteId });
const innerHandler = umamiEventNamesRequestHandler.handler(
{ ...ctx.integration, kind: "umami" },
{
websiteId: input.websiteId,
},
);
const { data } = await innerHandler.getDataAsync();
return data;
} catch (error) {
@@ -86,12 +106,16 @@ export const umamiRouter = createTRPCRouter({
)
.concat(createOneWidgetIntegrationMiddleware("query", "umami"))
.query(async ({ ctx, input }) => {
if (ctx.integration.kind === "mock") return mockWidgetData.umamiTopPages.slice(0, input.limit);
try {
const innerHandler = umamiTopPagesRequestHandler.handler(ctx.integration, {
websiteId: input.websiteId,
timeFrame: input.timeFrame,
limit: input.limit,
});
const innerHandler = umamiTopPagesRequestHandler.handler(
{ ...ctx.integration, kind: "umami" },
{
websiteId: input.websiteId,
timeFrame: input.timeFrame,
limit: input.limit,
},
);
const { data } = await innerHandler.getDataAsync();
return data;
} catch (error) {
@@ -116,12 +140,16 @@ export const umamiRouter = createTRPCRouter({
)
.concat(createOneWidgetIntegrationMiddleware("query", "umami"))
.query(async ({ ctx, input }) => {
if (ctx.integration.kind === "mock") return mockWidgetData.umamiTopReferrers.slice(0, input.limit);
try {
const innerHandler = umamiTopReferrersRequestHandler.handler(ctx.integration, {
websiteId: input.websiteId,
timeFrame: input.timeFrame,
limit: input.limit,
});
const innerHandler = umamiTopReferrersRequestHandler.handler(
{ ...ctx.integration, kind: "umami" },
{
websiteId: input.websiteId,
timeFrame: input.timeFrame,
limit: input.limit,
},
);
const { data } = await innerHandler.getDataAsync();
return data;
} catch (error) {
@@ -146,13 +174,19 @@ export const umamiRouter = createTRPCRouter({
)
.concat(createOneWidgetIntegrationMiddleware("query", "umami"))
.query(async ({ ctx, input }) => {
if (ctx.integration.kind === "mock") {
return mockWidgetData.umamiEventSeries.filter((series) => input.eventNames.includes(series.eventName));
}
try {
const sortedNames = [...input.eventNames].toSorted();
const innerHandler = umamiMultiEventRequestHandler.handler(ctx.integration, {
websiteId: input.websiteId,
timeFrame: input.timeFrame,
eventNames: sortedNames,
});
const innerHandler = umamiMultiEventRequestHandler.handler(
{ ...ctx.integration, kind: "umami" },
{
websiteId: input.websiteId,
timeFrame: input.timeFrame,
eventNames: sortedNames,
},
);
const { data } = await innerHandler.getDataAsync();
return data;
} catch (error) {
@@ -171,10 +205,14 @@ export const umamiRouter = createTRPCRouter({
.input(z.object({ websiteId: z.string() }))
.concat(createOneWidgetIntegrationMiddleware("query", "umami"))
.query(async ({ ctx, input }) => {
if (ctx.integration.kind === "mock") return 7;
try {
const innerHandler = umamiActiveVisitorsRequestHandler.handler(ctx.integration, {
websiteId: input.websiteId,
});
const innerHandler = umamiActiveVisitorsRequestHandler.handler(
{ ...ctx.integration, kind: "umami" },
{
websiteId: input.websiteId,
},
);
const { data } = await innerHandler.getDataAsync();
return data;
} catch (error) {
+12 -1
View File
@@ -1,4 +1,5 @@
import { uptimeKumaRequestHandler } from "@homarr/request-handler/uptime-kuma";
import { mockWidgetData } from "@homarr/integrations";
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
@@ -11,7 +12,17 @@ export const uptimeKumaRouter = createTRPCRouter({
return await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const innerHandler = uptimeKumaRequestHandler.handler(integration, {});
if (integration.kind === "mock") {
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
dashboard: mockWidgetData.uptimeKuma,
updatedAt: new Date(mockWidgetData.timestamp),
error: undefined,
};
}
const innerHandler = uptimeKumaRequestHandler.handler({ ...integration, kind: "uptimeKuma" }, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return {
+26 -3
View File
@@ -1,17 +1,40 @@
import type { IntegrationKind } from "@homarr/definitions";
import { mockWidgetData } from "@homarr/integrations";
import type { VpnSummary } from "@homarr/integrations/types";
import { vpnSummaryHandler } from "@homarr/request-handler/vpn";
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
interface VpnResult {
integration: { id: string; name: string; kind: IntegrationKind; updatedAt: Date };
summary: VpnSummary | null;
error?: string;
}
export const vpnRouter = createTRPCRouter({
getSummaries: publicProcedure
.unstable_concat(createManyWidgetIntegrationMiddleware("query", "vpn"))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const { data, timestamp } = await vpnSummaryHandler.handler(integration, {}).getDataAsync();
async (integration): Promise<VpnResult> => {
if (integration.kind === "mock") {
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt: new Date(mockWidgetData.timestamp),
},
summary: mockWidgetData.vpn,
error: undefined,
};
}
const { data, timestamp } = await vpnSummaryHandler
.handler({ ...integration, kind: "gluetun" }, {})
.getDataAsync();
return {
integration: {
id: integration.id,
@@ -24,7 +47,7 @@ export const vpnRouter = createTRPCRouter({
};
},
{
fallback: (integration, error) => ({
fallback: (integration, error): VpnResult => ({
integration: {
id: integration.id,
name: integration.name,
+6 -2
View File
@@ -1,4 +1,5 @@
import { wudStatsRequestHandler } from "@homarr/request-handler/wud";
import { mockWidgetData } from "@homarr/integrations";
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
@@ -9,12 +10,15 @@ export const wudRouter = createTRPCRouter({
mcp: {
enabled: true,
description:
"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.",
"Returns monitored-container counts and available updates from a What's Up Docker integration, or mock statistics from a mock integration. REQUIRED: integrationId from integration_all. The caller needs query permission for that integration.",
},
})
.concat(createOneWidgetIntegrationMiddleware("query", "wud"))
.query(async ({ ctx }) => {
const handler = wudStatsRequestHandler.handler(ctx.integration, {});
if (ctx.integration.kind === "mock") {
return { stats: mockWidgetData.wud, updatedAt: new Date(mockWidgetData.timestamp) };
}
const handler = wudStatsRequestHandler.handler({ ...ctx.integration, kind: "wud" }, {});
const { data, timestamp } = await handler.getDataAsync();
return {
+1
View File
@@ -65,6 +65,7 @@ const MCP_TOOL_ALLOWLIST = {
"mediaServer_getCurrentStreams",
"patchmon_getStats",
"serverSettings_getBoardSettings",
"serverSettings_getBranding",
"smartHome_entityDetails",
"smartHome_entityState",
"widgetSecrets_getConfiguredKinds",
+1
View File
@@ -13,6 +13,7 @@ export const getRscUserSettingsAsync = cache(async (userId: string) =>
firstDayOfWeek: true,
pingIconsEnabled: true,
enableRightClickOnWidgets: true,
headerPreferences: true,
defaultSearchEngineId: true,
openSearchInNewTab: true,
ddgBangs: true,