feat: modernize widget experience

This commit is contained in:
Thomas Camlong authored and ajnart committed 2026-08-13 19:54:48 +02:00
1 parent 6331c88911
commit 1fa345edd1
165 files changed
+3698 -1649

No files matched your search

@@ -0,0 +1,45 @@
import { TRPCError } from "@trpc/server";
import { parse } from "superjson";
import { z } from "zod/v4";
import type { Session } from "@homarr/auth";
import type { Database } from "@homarr/db";
import { and, eq } from "@homarr/db";
import { boards, items } from "@homarr/db/schema";
import type { BoardPermission } from "@homarr/definitions";
import { throwIfActionForbiddenAsync } from "../board/board-access";
export const customApiItemInputSchema = z.object({
boardId: z.string().min(1).max(100),
itemId: z.string().min(1).max(100),
definitionId: z.string().min(1).max(100),
});
const customApiOptionsSchema = z.object({ definitionId: z.string() }).passthrough();
export async function assertCustomApiItemBindingAsync(
ctx: { db: Database; session: Session | null },
input: z.infer<typeof customApiItemInputSchema>,
permission?: BoardPermission,
) {
const item = await ctx.db.query.items.findFirst({
columns: { options: true },
where: and(eq(items.id, input.itemId), eq(items.boardId, input.boardId), eq(items.kind, "customApi")),
});
if (!item) throwCustomApiWidgetNotFound();
if (permission) await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), permission);
try {
const options = customApiOptionsSchema.parse(parse<unknown>(item.options));
if (options.definitionId !== input.definitionId) throwCustomApiWidgetNotFound();
} catch (error) {
if (error instanceof TRPCError) throw error;
throwCustomApiWidgetNotFound();
}
}
function throwCustomApiWidgetNotFound(): never {
throw new TRPCError({ code: "NOT_FOUND", message: "Custom API widget not found" });
}
+3 -4
View File
@@ -37,11 +37,10 @@ export const locationRouter = createTRPCRouter({
.input(locationSearchCityInput)
.output(locationSearchCityOutput)
.query(async ({ input }) => {
const url = new URL("https://geocoding-api.open-meteo.com/v1/search");
url.searchParams.set("name", input.query);
const res = await withTimeoutAsync(async (signal) => {
return await fetchWithTrustedCertificatesAsync(
`https://geocoding-api.open-meteo.com/v1/search?name=${input.query}`,
{ signal },
);
return await fetchWithTrustedCertificatesAsync(url.toString(), { signal });
});
return (await res.json()) as z.infer<typeof locationSearchCityOutput>;
}),
@@ -0,0 +1,376 @@
// @vitest-environment node
import { stringify } from "superjson";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import type { Database } from "@homarr/db";
import { eq } from "@homarr/db";
import { boards, boardUserPermissions, customWidgetDefinitions, items, users } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import type { GroupPermissionKey } from "@homarr/definitions";
import { customWidgetRouter } from "../custom-widget/custom-widget-router";
import { customApiRouter } from "../widgets/custom-api";
const createSession = (userId: string, permissions: GroupPermissionKey[] = []): Session => ({
user: { id: userId, permissions, colorScheme: "light" },
expires: new Date().toISOString(),
});
const createCustomWidgetCaller = (db: Database, session: Session | null) =>
customWidgetRouter.createCaller({ db, deviceType: undefined, session });
const createCustomApiCaller = (db: Database, session: Session | null) =>
customApiRouter.createCaller({ db, deviceType: undefined, session });
async function createFixtureAsync(db: Database) {
const ownerId = createId();
const viewerId = createId();
const modifierId = createId();
const unrelatedId = createId();
const adminId = createId();
const boardId = createId();
const itemId = createId();
const definitionId = createId();
await db.insert(users).values(
[ownerId, viewerId, modifierId, unrelatedId, adminId].map((id) => ({
id,
name: id,
})),
);
await db.insert(boards).values({ id: boardId, name: `board-${boardId}`, creatorId: ownerId, isPublic: false });
await db.insert(boardUserPermissions).values([
{ boardId, userId: viewerId, permission: "view" },
{ boardId, userId: modifierId, permission: "modify" },
]);
await db.insert(customWidgetDefinitions).values({
id: definitionId,
name: "Restart service",
description: "Restarts a service",
url: "https://api.example.com/restart",
method: "POST",
displayType: "actionButton",
displayConfig: stringify({
type: "actionButton",
buttonLabel: "Restart",
confirmText: "Continue?",
}),
creatorId: adminId,
});
await db.insert(items).values({
id: itemId,
boardId,
kind: "customApi",
options: stringify({ definitionId, refreshInterval: 30 }),
});
return { ownerId, viewerId, modifierId, unrelatedId, adminId, boardId, itemId, definitionId };
}
beforeEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
afterEach(() => {
vi.unstubAllGlobals();
vi.useRealTimers();
});
describe("custom widget definition authorization", () => {
it("restricts the full definition list to administrators", async () => {
const db = createDb();
const fixture = await createFixtureAsync(db);
await expect(createCustomWidgetCaller(db, null).all()).rejects.toMatchObject({ code: "UNAUTHORIZED" });
for (const userId of [fixture.ownerId, fixture.viewerId, fixture.modifierId, fixture.unrelatedId]) {
await expect(createCustomWidgetCaller(db, createSession(userId)).all()).rejects.toMatchObject({
code: "FORBIDDEN",
});
}
await expect(createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"])).all()).resolves.toEqual([
expect.objectContaining({ id: fixture.definitionId, url: "https://api.example.com/restart" }),
]);
});
it("returns only safe definition summaries to board modifiers", async () => {
const db = createDb();
const fixture = await createFixtureAsync(db);
const result = await createCustomWidgetCaller(db, createSession(fixture.modifierId)).available({
boardId: fixture.boardId,
});
expect(result).toEqual([
{
id: fixture.definitionId,
name: "Restart service",
description: "Restarts a service",
iconUrl: null,
displayType: "actionButton",
enabled: true,
},
]);
expect(JSON.stringify(result)).not.toContain("api.example.com");
for (const userId of [fixture.viewerId, fixture.unrelatedId]) {
await expect(
createCustomWidgetCaller(db, createSession(userId)).available({ boardId: fixture.boardId }),
).rejects.toMatchObject({ code: "NOT_FOUND" });
}
});
});
describe("custom widget action authorization", () => {
it("requires an administrator even for users who can view or modify the board", async () => {
const db = createDb();
const fixture = await createFixtureAsync(db);
const input = {
boardId: fixture.boardId,
itemId: fixture.itemId,
definitionId: fixture.definitionId,
};
await expect(createCustomWidgetCaller(db, null).execute(input)).rejects.toMatchObject({ code: "UNAUTHORIZED" });
for (const userId of [fixture.ownerId, fixture.viewerId, fixture.modifierId, fixture.unrelatedId]) {
await expect(createCustomWidgetCaller(db, createSession(userId)).execute(input)).rejects.toMatchObject({
code: "FORBIDDEN",
});
}
});
it("executes for an administrator only when board, item, and definition are bound", async () => {
const db = createDb();
const fixture = await createFixtureAsync(db);
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
vi.stubGlobal("fetch", fetchMock);
const caller = createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"]));
await expect(
caller.execute({
boardId: fixture.boardId,
itemId: fixture.itemId,
definitionId: fixture.definitionId,
}),
).resolves.toEqual({ success: true, responseInfo: { status: 204 } });
expect(fetchMock).toHaveBeenCalledWith(
"https://api.example.com/restart",
expect.objectContaining({ method: "POST", redirect: "error", signal: expect.any(AbortSignal) }),
);
});
it.each(["board", "item", "definition"] as const)("rejects a mismatched %s binding", async (mismatch) => {
const db = createDb();
const fixture = await createFixtureAsync(db);
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const input = {
boardId: mismatch === "board" ? createId() : fixture.boardId,
itemId: mismatch === "item" ? createId() : fixture.itemId,
definitionId: mismatch === "definition" ? createId() : fixture.definitionId,
};
await expect(
createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"])).execute(input),
).rejects.toMatchObject({ code: "NOT_FOUND" });
expect(fetchMock).not.toHaveBeenCalled();
});
it("rejects disabled definitions before sending a request", async () => {
const db = createDb();
const fixture = await createFixtureAsync(db);
await db
.update(customWidgetDefinitions)
.set({ enabled: false })
.where(eq(customWidgetDefinitions.id, fixture.definitionId));
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
await expect(
createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"])).execute({
boardId: fixture.boardId,
itemId: fixture.itemId,
definitionId: fixture.definitionId,
}),
).rejects.toMatchObject({ code: "FORBIDDEN" });
expect(fetchMock).not.toHaveBeenCalled();
});
});
describe("custom API outbound request hardening", () => {
it("rejects credential-bearing action URLs without exposing the credential", async () => {
const db = createDb();
const fixture = await createFixtureAsync(db);
await db
.update(customWidgetDefinitions)
.set({ url: "https://operator:super-secret@private.example.com/restart" })
.where(eq(customWidgetDefinitions.id, fixture.definitionId));
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const result = await createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"])).execute({
boardId: fixture.boardId,
itemId: fixture.itemId,
definitionId: fixture.definitionId,
});
expect(result).toEqual({ success: false, errorCode: "REQUEST_FAILED", responseInfo: null });
expect(JSON.stringify(result)).not.toMatch(/super-secret|private\.example\.com/);
expect(fetchMock).not.toHaveBeenCalled();
});
it("rejects redirects and redacts fetch errors", async () => {
const db = createDb();
const fixture = await createFixtureAsync(db);
const fetchMock = vi
.fn()
.mockRejectedValue(
new TypeError("Redirect to http://token:secret@internal.example.local/admin?apiKey=top-secret was rejected"),
);
vi.stubGlobal("fetch", fetchMock);
const result = await createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"])).execute({
boardId: fixture.boardId,
itemId: fixture.itemId,
definitionId: fixture.definitionId,
});
expect(fetchMock).toHaveBeenCalledWith(
"https://api.example.com/restart",
expect.objectContaining({ redirect: "error" }),
);
expect(result).toEqual({ success: false, errorCode: "REQUEST_FAILED", responseInfo: null });
expect(JSON.stringify(result)).not.toMatch(/top-secret|internal\.example\.local|apiKey/);
});
it("aborts timed-out action requests and returns a stable failure", async () => {
vi.useFakeTimers();
const db = createDb();
const fixture = await createFixtureAsync(db);
const started = Promise.withResolvers<void>();
const fetchMock = vi.fn((_url: string, init: RequestInit) => {
started.resolve();
return new Promise<Response>((_resolve, reject) => {
init.signal?.addEventListener("abort", () => {
reject(new Error("Timed out calling http://internal.example.local?secret=timeout-secret"));
});
});
});
vi.stubGlobal("fetch", fetchMock);
const request = createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"])).execute({
boardId: fixture.boardId,
itemId: fixture.itemId,
definitionId: fixture.definitionId,
});
await started.promise;
await vi.advanceTimersByTimeAsync(10_000);
const result = await request;
expect(result).toEqual({ success: false, errorCode: "REQUEST_FAILED", responseInfo: null });
expect(JSON.stringify(result)).not.toMatch(/timeout-secret|internal\.example\.local/);
});
it("bounds action responses before reporting success", async () => {
const db = createDb();
const fixture = await createFixtureAsync(db);
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("x".repeat(2 * 1024 * 1024 + 1))));
await expect(
createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"])).execute({
boardId: fixture.boardId,
itemId: fixture.itemId,
definitionId: fixture.definitionId,
}),
).resolves.toEqual({ success: false, errorCode: "REQUEST_FAILED", responseInfo: null });
});
it("binds data requests to a viewable item and never exposes backend error text", async () => {
const db = createDb();
const fixture = await createFixtureAsync(db);
await db
.update(customWidgetDefinitions)
.set({
displayType: "singleValue",
method: "GET",
displayConfig: stringify({ type: "singleValue", jsonPath: "$.value" }),
})
.where(eq(customWidgetDefinitions.id, fixture.definitionId));
vi.stubGlobal(
"fetch",
vi.fn().mockRejectedValue(new Error("Backend http://private.example.local/path?token=super-secret\nstack trace")),
);
const input = {
boardId: fixture.boardId,
itemId: fixture.itemId,
definitionId: fixture.definitionId,
};
const error = await createCustomApiCaller(db, createSession(fixture.viewerId))
.getData(input)
.catch((caught: unknown) => caught);
expect(error).toMatchObject({ code: "INTERNAL_SERVER_ERROR", message: "Custom API request failed" });
expect(JSON.stringify(error)).not.toMatch(/super-secret|private\.example\.local|stack trace/);
await expect(createCustomApiCaller(db, createSession(fixture.unrelatedId)).getData(input)).rejects.toMatchObject({
code: "NOT_FOUND",
});
await expect(
createCustomApiCaller(db, createSession(fixture.viewerId)).getData({ ...input, definitionId: createId() }),
).rejects.toMatchObject({ code: "NOT_FOUND" });
});
it("bounds and disposes non-success data responses", async () => {
const db = createDb();
const fixture = await createFixtureAsync(db);
await db
.update(customWidgetDefinitions)
.set({
displayType: "singleValue",
method: "GET",
displayConfig: stringify({ type: "singleValue", jsonPath: "$.value" }),
})
.where(eq(customWidgetDefinitions.id, fixture.definitionId));
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("x".repeat(2 * 1024 * 1024 + 1), { status: 502 })));
const error = await createCustomApiCaller(db, createSession(fixture.viewerId))
.getData({
boardId: fixture.boardId,
itemId: fixture.itemId,
definitionId: fixture.definitionId,
})
.catch((caught: unknown) => caught);
expect(error).toMatchObject({ code: "INTERNAL_SERVER_ERROR", message: "Custom API request failed" });
expect(JSON.stringify(error)).not.toContain("x".repeat(100));
});
it("redacts unsafe preview failures and rejects redirects", async () => {
const db = createDb();
const fixture = await createFixtureAsync(db);
const fetchMock = vi.fn().mockRejectedValue(new Error("redirected to http://private.local?secret=value"));
vi.stubGlobal("fetch", fetchMock);
const caller = createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"]));
const result = await caller.preview({
url: "https://api.example.com/data",
method: "GET",
authType: "none",
displayType: "singleValue",
displayConfig: { type: "singleValue", jsonPath: "$.value", label: "Value", unit: "" },
secrets: [],
});
expect(fetchMock).toHaveBeenCalledWith(
"https://api.example.com/data",
expect.objectContaining({ redirect: "error" }),
);
expect(result).toEqual({
success: false,
error: "Custom API preview failed",
responseInfo: null,
rawResponse: null,
});
expect(JSON.stringify(result)).not.toMatch(/private\.local|secret=value/);
});
});
@@ -0,0 +1,36 @@
// @vitest-environment node
import { beforeEach, describe, expect, test, vi } from "vitest";
import { createDb } from "@homarr/db/test";
import { locationRouter } from "../location";
const mocks = vi.hoisted(() => ({ fetch: vi.fn() }));
vi.mock("@homarr/core/infrastructure/http", () => ({
fetchWithTrustedCertificatesAsync: mocks.fetch,
}));
vi.mock("@homarr/core/infrastructure/http/timeout", () => ({
withTimeoutAsync: async (callback: (signal: AbortSignal) => Promise<Response>) =>
await callback(new AbortController().signal),
}));
beforeEach(() => {
vi.clearAllMocks();
mocks.fetch.mockResolvedValue(new Response(JSON.stringify({ results: [] })));
});
describe("locationRouter", () => {
test("encodes the complete search text as one name parameter", async () => {
const caller = locationRouter.createCaller({ db: createDb(), deviceType: undefined, session: null });
await caller.searchCity({ query: "Paris&language=xx&token=secret" });
const requestedUrl = new URL(mocks.fetch.mock.calls[0]?.[0] as string);
expect(requestedUrl.searchParams.get("name")).toBe("Paris&language=xx&token=secret");
expect(requestedUrl.searchParams.has("language")).toBe(false);
expect(requestedUrl.searchParams.has("token")).toBe(false);
});
});
@@ -12,8 +12,14 @@ import type { MediaRequest, MediaRequestStats } from "@homarr/integrations/types
import type * as MediaRequestListHandlerModule from "@homarr/request-handler/media-request-list";
import { calendarRouter } from "../../widgets/calendar";
import { beszelRouter } from "../../widgets/beszel";
import { firewallRouter } from "../../widgets/firewall";
import { indexerManagerRouter } from "../../widgets/indexer-manager";
import { mediaOrganizerRouter } from "../../widgets/media-organizer";
import { mediaRequestsRouter } from "../../widgets/media-requests";
import { mediaServerRouter } from "../../widgets/media-server";
import { notificationsRouter } from "../../widgets/notifications";
import { vpnRouter } from "../../widgets/vpn";
vi.hoisted(() => {
process.env.SKIP_ENV_VALIDATION = "true";
@@ -21,7 +27,11 @@ vi.hoisted(() => {
const createHandler = <T>(integration: { name: string }, data: T) => ({
getDataAsync: async () => {
if (integration.name === "Offline") throw new Error("offline https://internal.example?token=secret");
if (integration.name === "Offline") {
throw new Error(
"GET http://admin:password@internal.example/private/path?token=secret returned\nprivate response body",
);
}
return { data, timestamp: new Date() };
},
});
@@ -34,6 +44,40 @@ vi.mock("@homarr/request-handler/media-server", () => ({
mediaServerRequestHandler: { handler: (integration: { name: string }) => createHandler(integration, []) },
}));
vi.mock("@homarr/request-handler/media-organizer", () => ({
mediaOrganizerRequestHandler: {
handler: (integration: { name: string }) =>
createHandler(integration, { missing: [], missingCount: 0, queued: [], queuedCount: 0 }),
},
}));
vi.mock("@homarr/request-handler/beszel", () => ({
beszelSystemsRequestHandler: { handler: (integration: { name: string }) => createHandler(integration, []) },
}));
vi.mock("@homarr/request-handler/firewall", () => ({
firewallCpuRequestHandler: { handler: (integration: { name: string }) => createHandler(integration, { total: 1 }) },
firewallInterfacesRequestHandler: { handler: (integration: { name: string }) => createHandler(integration, []) },
firewallMemoryRequestHandler: {
handler: (integration: { name: string }) => createHandler(integration, { used: 1, total: 2, percent: 50 }),
},
firewallVersionRequestHandler: {
handler: (integration: { name: string }) => createHandler(integration, { version: "1.0" }),
},
}));
vi.mock("@homarr/request-handler/indexer-manager", () => ({
indexerManagerRequestHandler: { handler: (integration: { name: string }) => createHandler(integration, []) },
}));
vi.mock("@homarr/request-handler/notifications", () => ({
notificationsRequestHandler: { handler: (integration: { name: string }) => createHandler(integration, []) },
}));
vi.mock("@homarr/request-handler/vpn", () => ({
vpnSummaryHandler: { handler: (integration: { name: string }) => createHandler(integration, { connected: true }) },
}));
vi.mock("@homarr/request-handler/media-request-list", async (importOriginal) => {
const actual = await importOriginal<typeof MediaRequestListHandlerModule>();
return {
@@ -121,7 +165,7 @@ describe("partial integration failures", () => {
expect.arrayContaining([
expect.objectContaining({
integration: expect.objectContaining({ id: calendar.offlineId }),
error: "Integration request failed",
error: "INTEGRATION_REQUEST_FAILED",
}),
]),
);
@@ -136,7 +180,7 @@ describe("partial integration failures", () => {
expect.objectContaining({
integrationId: mediaServer.offlineId,
sessions: [],
error: "Integration request failed",
error: "INTEGRATION_REQUEST_FAILED",
}),
]),
);
@@ -165,13 +209,13 @@ describe("partial integration failures", () => {
}),
]);
expect(latest.failedIntegrations).toEqual([
expect.objectContaining({ integrationId: mediaRequests.offlineId, error: "Integration request failed" }),
expect.objectContaining({ integrationId: mediaRequests.offlineId, error: "INTEGRATION_REQUEST_FAILED" }),
]);
expect(JSON.stringify(latest)).not.toContain("secret");
const stats = await caller.getStats({ integrationIds: mediaRequests.integrationIds });
expect(stats.failedIntegrations).toEqual([
expect.objectContaining({ integrationId: mediaRequests.offlineId, error: "Integration request failed" }),
expect.objectContaining({ integrationId: mediaRequests.offlineId, error: "INTEGRATION_REQUEST_FAILED" }),
]);
expect(JSON.stringify(stats)).not.toContain("secret");
expect(stats.stats).toHaveLength(1);
@@ -183,4 +227,58 @@ describe("partial integration failures", () => {
}),
]);
});
test("aggregate widget payloads never expose sensitive partial-failure details", async () => {
const cases = [
{
kind: "sonarr" as const,
run: async (setup: Awaited<ReturnType<typeof setupAsync>>) =>
await mediaOrganizerRouter
.createCaller({ db: setup.db, deviceType: undefined, session: setup.session })
.getData({ integrationIds: setup.integrationIds, pageSize: 10 }),
},
{
kind: "beszel" as const,
run: async (setup: Awaited<ReturnType<typeof setupAsync>>) =>
await beszelRouter
.createCaller({ db: setup.db, deviceType: undefined, session: setup.session })
.getSystems({ integrationIds: setup.integrationIds }),
},
{
kind: "opnsense" as const,
run: async (setup: Awaited<ReturnType<typeof setupAsync>>) =>
await firewallRouter
.createCaller({ db: setup.db, deviceType: undefined, session: setup.session })
.getFirewallCpuStatus({ integrationIds: setup.integrationIds }),
},
{
kind: "prowlarr" as const,
run: async (setup: Awaited<ReturnType<typeof setupAsync>>) =>
await indexerManagerRouter
.createCaller({ db: setup.db, deviceType: undefined, session: setup.session })
.getIndexersStatus({ integrationIds: setup.integrationIds }),
},
{
kind: "ntfy" as const,
run: async (setup: Awaited<ReturnType<typeof setupAsync>>) =>
await notificationsRouter
.createCaller({ db: setup.db, deviceType: undefined, session: setup.session })
.getNotifications({ integrationIds: setup.integrationIds }),
},
{
kind: "gluetun" as const,
run: async (setup: Awaited<ReturnType<typeof setupAsync>>) =>
await vpnRouter
.createCaller({ db: setup.db, deviceType: undefined, session: setup.session })
.getSummaries({ integrationIds: setup.integrationIds }),
},
];
for (const testCase of cases) {
const setup = await setupAsync(testCase.kind);
const payload = JSON.stringify(await testCase.run(setup));
expect(payload).toContain("INTEGRATION_REQUEST_FAILED");
expect(payload).not.toMatch(/internal\.example|token=secret|\/private\/|response body|password/i);
}
});
});
@@ -1,3 +1,5 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import { readBoundedJsonResponseAsync, validateCustomApiUrl } from "../custom-api-security";
+10 -15
View File
@@ -10,7 +10,7 @@ import {
beszelSystemsRequestHandler,
} from "@homarr/request-handler/beszel";
import { settleIntegrationQueries } from "../../settle-integrations";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
import { BoundedAsyncQueue } from "./bounded-async-queue";
@@ -18,8 +18,6 @@ import { BoundedAsyncQueue } from "./bounded-async-queue";
const logger = createLogger({ module: "beszelRouter" });
const MAX_PENDING_LIVE_EVENTS = 4;
const errorMessage = (error: unknown) => (error instanceof Error ? error.message : String(error));
export const beszelRouter = createTRPCRouter({
getSystems: publicProcedure
.meta({
@@ -41,7 +39,6 @@ export const beszelRouter = createTRPCRouter({
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
systems: data,
updatedAt: timestamp,
};
@@ -50,10 +47,9 @@ export const beszelRouter = createTRPCRouter({
fallback: (integration, error) => ({
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
systems: [],
updatedAt: new Date(0),
error: errorMessage(error),
error: toPublicIntegrationError(error),
}),
throwOnAllFailures: true,
},
@@ -127,7 +123,7 @@ export const beszelRouter = createTRPCRouter({
history: [],
systemNameMap: {},
updatedAt: new Date(0),
error: errorMessage(error),
error: toPublicIntegrationError(error),
}),
throwOnAllFailures: true,
},
@@ -194,7 +190,7 @@ export const beszelRouter = createTRPCRouter({
systemStats: [],
containerStats: [],
updatedAt: new Date(0),
error: errorMessage(error),
error: toPublicIntegrationError(error),
};
}
}),
@@ -271,15 +267,14 @@ export const beszelRouter = createTRPCRouter({
integrationId: integration.id,
systemId: input.systemId,
emittedEventCount,
error: errorMessage(error),
error,
});
queue.fail(
error instanceof TRPCError
? error
: new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: error instanceof Error ? error.message : String(error),
}),
new TRPCError({
code: "BAD_GATEWAY",
message: "Live integration request failed",
cause: error,
}),
);
}
})();
+2 -2
View File
@@ -5,7 +5,7 @@ import { radarrReleaseTypes } from "@homarr/integrations/types";
import { calendarMonthRequestHandler } from "@homarr/request-handler/calendar";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const calendarRouter = createTRPCRouter({
@@ -52,7 +52,7 @@ export const calendarRouter = createTRPCRouter({
name: integration.name,
kind: integration.kind,
},
error: "Integration request failed",
error: PUBLIC_INTEGRATION_ERROR,
}),
throwOnAllFailures: true,
},
@@ -1,6 +1,7 @@
import { readBoundedJsonResponseAsync as readBoundedJsonResponse } from "@homarr/common/server";
export const MAX_CUSTOM_API_RESPONSE_BYTES = 2 * 1024 * 1024;
export const CUSTOM_API_FETCH_TIMEOUT_MS = 10_000;
export function validateCustomApiUrl(urlString: string): URL {
if (urlString.length > 2_048) throw new Error("Custom API URL is too long");
@@ -19,3 +20,48 @@ export async function readBoundedJsonResponseAsync(
): Promise<unknown> {
return await readBoundedJsonResponse(response, maxBytes, "Custom API response");
}
export async function consumeBoundedResponseAsync(
response: Response,
maxBytes = MAX_CUSTOM_API_RESPONSE_BYTES,
): Promise<void> {
const declaredLength = Number(response.headers.get("content-length"));
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
await response.body?.cancel();
throw new Error(`Custom API response exceeds ${maxBytes} bytes`);
}
if (!response.body) return;
const reader = response.body.getReader();
let byteLength = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) return;
byteLength += value?.byteLength ?? 0;
if (byteLength > maxBytes) {
await reader.cancel();
throw new Error(`Custom API response exceeds ${maxBytes} bytes`);
}
}
}
export async function withCustomApiResponseAsync<TResult>(
url: URL,
init: Omit<RequestInit, "redirect" | "signal">,
consumeResponseAsync: (response: Response) => Promise<TResult>,
timeoutMs = CUSTOM_API_FETCH_TIMEOUT_MS,
): Promise<TResult> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url.toString(), {
...init,
redirect: "error",
signal: controller.signal,
});
return await consumeResponseAsync(response);
} finally {
clearTimeout(timeout);
}
}
+2 -2
View File
@@ -7,7 +7,7 @@ import {
} from "@homarr/request-handler/firewall";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const firewallMiddleware = createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall"));
@@ -35,7 +35,7 @@ const queryFirewall = <
fallback: (integration, error) => ({
integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: new Date(0) },
summary: createFallback(),
error: error instanceof Error ? error.message : "Integration request failed",
error: toPublicIntegrationError(error),
}),
throwOnAllFailures: true,
},
@@ -6,7 +6,7 @@ import { indexerManagerRequestHandler } from "@homarr/request-handler/indexer-ma
import type { IntegrationAction } from "../../middlewares/integration";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
const createIndexerManagerIntegrationMiddleware = (action: IntegrationAction) =>
@@ -34,7 +34,7 @@ export const indexerManagerRouter = createTRPCRouter({
integrationId: integration.id,
integrationName: integration.name,
indexers: [],
error: error instanceof Error ? error.message : String(error),
error: toPublicIntegrationError(error),
}),
throwOnAllFailures: true,
},
@@ -4,7 +4,7 @@ import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { mediaOrganizerRequestHandler } from "@homarr/request-handler/media-organizer";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const mediaOrganizerRouter = createTRPCRouter({
@@ -39,7 +39,7 @@ export const mediaOrganizerRouter = createTRPCRouter({
missingCount: 0,
queued: [],
queuedCount: 0,
error: error instanceof Error ? error.message : String(error),
error: toPublicIntegrationError(error),
}),
throwOnAllFailures: true,
},
@@ -12,7 +12,7 @@ import {
import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats";
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
interface IntegrationQueryResult<TData> {
@@ -47,7 +47,7 @@ export const mediaRequestsRouter = createTRPCRouter({
fallback: (integration) => ({
integration: { id: integration.id, name: integration.name, kind: integration.kind },
data: [],
error: "Integration request failed",
error: PUBLIC_INTEGRATION_ERROR,
}),
throwOnAllFailures: true,
},
@@ -104,7 +104,7 @@ export const mediaRequestsRouter = createTRPCRouter({
fallback: (integration) => ({
integration: { id: integration.id, name: integration.name, kind: integration.kind },
data: { stats: null, users: [] },
error: "Integration request failed",
error: PUBLIC_INTEGRATION_ERROR,
}),
throwOnAllFailures: true,
},
@@ -5,7 +5,7 @@ import { mediaServerRequestHandler } from "@homarr/request-handler/media-server"
import type { IntegrationAction } from "../../middlewares/integration";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const createMediaServerIntegrationMiddleware = (action: IntegrationAction) =>
@@ -43,7 +43,7 @@ export const mediaServerRouter = createTRPCRouter({
integrationName: integration.name,
integrationKind: integration.kind,
sessions: [],
error: "Integration request failed",
error: PUBLIC_INTEGRATION_ERROR,
}),
throwOnAllFailures: true,
},
@@ -3,7 +3,7 @@ import { notificationsRequestHandler } from "@homarr/request-handler/notificatio
import type { IntegrationAction } from "../../middlewares/integration";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const createNotificationsIntegrationMiddleware = (action: IntegrationAction) =>
@@ -39,7 +39,7 @@ export const notificationsRouter = createTRPCRouter({
updatedAt: new Date(0),
},
data: [],
error: error instanceof Error ? error.message : String(error),
error: toPublicIntegrationError(error),
}),
throwOnAllFailures: true,
},
+22 -2
View File
@@ -13,6 +13,7 @@ import { createTRPCRouter, publicProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "../board/board-access";
const logger = createLogger({ module: "releasesRouter" });
const PUBLIC_RELEASE_ERROR_MESSAGE: string = "Release provider request failed";
const formatVersionFilterRegex = (versionFilter: z.infer<typeof releaseVersionFilterSchema> | undefined) => {
if (!versionFilter) return undefined;
@@ -100,19 +101,38 @@ export const releasesRouter = createTRPCRouter({
})
.getDataAsync();
const data = response.data.success
? response.data
: {
success: false as const,
error: { code: response.data.error.code, message: PUBLIC_RELEASE_ERROR_MESSAGE },
};
if (!response.data.success) {
logger.warn("Release provider request failed", {
repositoryId,
provider: repository.provider,
error: response.data.error,
});
}
return {
id: repositoryId,
provider: repository.provider,
timestamp: response.timestamp,
...response.data,
...data,
};
} catch (error) {
logger.warn("Release provider request failed", {
repositoryId,
provider: repository.provider,
error,
});
return {
id: repositoryId,
provider: repository.provider,
timestamp: new Date(),
success: false as const,
error: { code: "unexpected" as const, message: String(error) },
error: { code: "unexpected" as const, message: PUBLIC_RELEASE_ERROR_MESSAGE },
};
}
}),
+2 -2
View File
@@ -2,7 +2,7 @@ import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { vpnSummaryHandler } from "@homarr/request-handler/vpn";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const vpnRouter = createTRPCRouter({
@@ -33,7 +33,7 @@ export const vpnRouter = createTRPCRouter({
updatedAt: new Date(0),
},
summary: null,
error: error instanceof Error ? error.message : String(error),
error: toPublicIntegrationError(error),
}),
throwOnAllFailures: true,
},
+4
View File
@@ -5,6 +5,10 @@ import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
const logger = createLogger({ module: "settleIntegrations" });
export const PUBLIC_INTEGRATION_ERROR: string = "INTEGRATION_REQUEST_FAILED";
export const toPublicIntegrationError = (_error: unknown): string => PUBLIC_INTEGRATION_ERROR;
interface IntegrationLike {
id: string;
name: string;
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { settleIntegrationQueries } from "../settle-integrations";
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries, toPublicIntegrationError } from "../settle-integrations";
const mocks = vi.hoisted(() => ({
logger: { warn: vi.fn() },
@@ -22,6 +22,17 @@ const integrations = [
beforeEach(() => vi.clearAllMocks());
describe("settleIntegrationQueries", () => {
test("maps sensitive integration failures to a stable public code", () => {
const error = new Error(
"GET http://admin:password@nas.internal.local/private/path?token=secret returned\nprivate response body",
);
const publicError = toPublicIntegrationError(error);
expect(publicError).toBe(PUBLIC_INTEGRATION_ERROR);
expect(publicError).not.toMatch(/admin|password|nas\.internal|private|token|secret|response body/i);
});
test("returns an empty result when no integrations are configured", async () => {
await expect(settleIntegrationQueries([], vi.fn())).resolves.toEqual([]);
});