fix: address widget review feedback
This commit is contained in:
1 parent
b1b17ad69f
commit
a38e731209
79 files changed
+2138
-244
No files matched your search
@@ -0,0 +1,76 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { createId } from "@homarr/common";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { integrations, integrationUserPermissions, users } from "@homarr/db/schema";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
|
||||
import { immichRouter } from "../../widgets/immich";
|
||||
|
||||
vi.hoisted(() => {
|
||||
process.env.SKIP_ENV_VALIDATION = "true";
|
||||
});
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
albumsHandler: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/request-handler/immich", () => ({
|
||||
immichAlbumRequestHandler: { handler: vi.fn() },
|
||||
immichStatsRequestHandler: { handler: vi.fn() },
|
||||
immichAlbumsRequestHandler: {
|
||||
handler: (_integration: unknown, input: unknown) => {
|
||||
mocks.albumsHandler(input);
|
||||
return { getDataAsync: async () => ({ data: [], timestamp: new Date() }) };
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const createSession = (userId: string): Session => ({
|
||||
user: { id: userId, permissions: [], colorScheme: "light" },
|
||||
expires: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const createCallerAsync = async () => {
|
||||
const db = createDb();
|
||||
const userId = createId();
|
||||
const integrationId = createId();
|
||||
await db.insert(users).values({ id: userId });
|
||||
await db.insert(integrations).values({
|
||||
id: integrationId,
|
||||
kind: "immich",
|
||||
name: "Immich",
|
||||
url: "https://immich.example.com",
|
||||
});
|
||||
await db.insert(integrationUserPermissions).values({ integrationId, userId, permission: "use" });
|
||||
|
||||
return {
|
||||
integrationId,
|
||||
caller: immichRouter.createCaller({ db: db as Database, deviceType: undefined, session: createSession(userId) }),
|
||||
};
|
||||
};
|
||||
|
||||
describe("immichRouter.getAlbums", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
test("passes a validated limit to the cached request handler", async () => {
|
||||
const { caller, integrationId } = await createCallerAsync();
|
||||
|
||||
await caller.getAlbums({ integrationId, limit: 50 });
|
||||
|
||||
expect(mocks.albumsHandler).toHaveBeenCalledWith({ limit: 50 });
|
||||
await expect(caller.getAlbums({ integrationId, limit: 0 })).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
await expect(caller.getAlbums({ integrationId, limit: 501 })).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
});
|
||||
|
||||
test("keeps the limit optional for unbounded callers", async () => {
|
||||
const { caller, integrationId } = await createCallerAsync();
|
||||
|
||||
await caller.getAlbums({ integrationId });
|
||||
|
||||
expect(mocks.albumsHandler).toHaveBeenCalledWith({ limit: undefined });
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import SuperJSON from "superjson";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { createId } from "@homarr/common";
|
||||
@@ -17,7 +17,18 @@ import { createDb } from "@homarr/db/test";
|
||||
import type { GroupPermissionKey } from "@homarr/definitions";
|
||||
|
||||
import type { WidgetComponentProps } from "../../../../../widgets/src/definition";
|
||||
import { canAccessAllFeedsAsync, restrictUrlsAsync } from "../../widgets/rssFeed";
|
||||
import { canAccessAllFeedsAsync, restrictUrlsAsync, rssFeedRouter } from "../../widgets/rssFeed";
|
||||
|
||||
const mocks = vi.hoisted(() => ({ logger: { warn: vi.fn() }, getFeed: vi.fn() }));
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/logs", () => ({ createLogger: () => mocks.logger }));
|
||||
vi.mock("@homarr/request-handler/rss-feeds", () => ({
|
||||
rssFeedsRequestHandler: {
|
||||
handler: (input: unknown) => ({ getDataAsync: async () => await mocks.getFeed(input) }),
|
||||
},
|
||||
}));
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
const createSession = (permissions: GroupPermissionKey[] = []): Session => ({
|
||||
user: {
|
||||
@@ -29,6 +40,32 @@ const createSession = (permissions: GroupPermissionKey[] = []): Session => ({
|
||||
});
|
||||
|
||||
describe("rssFeedRouter", () => {
|
||||
describe("getFeeds", () => {
|
||||
test("redacts feed secrets from logs and returns a generic error when every feed fails", async () => {
|
||||
const feedUrl = "https://user:password@example.com/feed?token=secret";
|
||||
mocks.getFeed.mockRejectedValue(new Error(`Failed to fetch ${feedUrl}`));
|
||||
const caller = rssFeedRouter.createCaller({
|
||||
db: createDb(),
|
||||
deviceType: undefined,
|
||||
session: createSession(["board-create"]),
|
||||
});
|
||||
|
||||
await expect(caller.getFeeds({ urls: [feedUrl], maximumAmountPosts: 5 })).rejects.toMatchObject({
|
||||
code: "BAD_GATEWAY",
|
||||
message: "All RSS feed requests failed",
|
||||
});
|
||||
expect(mocks.logger.warn).toHaveBeenCalledWith("RSS feed fetch failed", {
|
||||
feedIndex: 0,
|
||||
origin: "https://example.com",
|
||||
errorType: "Error",
|
||||
});
|
||||
const logged = JSON.stringify(mocks.logger.warn.mock.calls);
|
||||
expect(logged).not.toContain("password");
|
||||
expect(logged).not.toContain("token");
|
||||
expect(logged).not.toContain("secret");
|
||||
});
|
||||
});
|
||||
|
||||
describe("canAccessAllFeedsAsync", () => {
|
||||
test("should return false for unauthenticated users", async () => {
|
||||
// Arrange
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { stringify } from "superjson";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { 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, items, users } from "@homarr/db/schema";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
|
||||
import { timetableRouter } from "../../widgets/timetable";
|
||||
|
||||
const mocks = vi.hoisted(() => ({ getTimetable: vi.fn(), searchStations: vi.fn() }));
|
||||
const mocks = vi.hoisted(() => ({ getTimetable: vi.fn(), searchStations: vi.fn(), lookup: vi.fn() }));
|
||||
|
||||
vi.mock("node:dns/promises", () => ({ default: { lookup: mocks.lookup }, lookup: mocks.lookup }));
|
||||
|
||||
vi.mock("@homarr/request-handler/timetable", () => ({
|
||||
timetableGetTimetableRequestHandler: {
|
||||
@@ -30,6 +33,11 @@ vi.mock("@homarr/request-handler/timetable", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.lookup.mockResolvedValue([{ address: "8.8.8.8", family: 4 }]);
|
||||
});
|
||||
|
||||
const createSession = (userId: string): Session => ({
|
||||
user: { id: userId, permissions: [], colorScheme: "light" },
|
||||
expires: new Date().toISOString(),
|
||||
@@ -66,6 +74,14 @@ describe("timetableRouter source authorization", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("reports invalid timetable URLs as bad requests", async () => {
|
||||
const caller = createCaller(createDb(), null);
|
||||
|
||||
await expect(
|
||||
caller.searchStations({ baseUrl: "https://search.ch?redirect=/admin", query: "Bern" }),
|
||||
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
});
|
||||
|
||||
it("loads a persisted private endpoint only through a viewable timetable item", async () => {
|
||||
const db = createDb();
|
||||
const baseUrl = "http://timetable.internal:8080";
|
||||
@@ -89,14 +105,161 @@ describe("timetableRouter source authorization", () => {
|
||||
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
});
|
||||
|
||||
it("allows board modifiers to search an unsaved self-hosted endpoint", async () => {
|
||||
it("allows board modifiers to search an unsaved public endpoint", async () => {
|
||||
const db = createDb();
|
||||
const baseUrl = "http://timetable.internal:8080";
|
||||
const baseUrl = "https://timetable.example.com";
|
||||
const { boardId, ownerId } = await createTimetableItemAsync(db, { baseUrl: "https://search.ch", isPublic: false });
|
||||
const caller = createCaller(db, createSession(ownerId));
|
||||
|
||||
await caller.searchStations({ baseUrl, boardId, query: "Bern" });
|
||||
expect(mocks.searchStations).toHaveBeenLastCalledWith(expect.objectContaining({ baseUrl }));
|
||||
expect(mocks.lookup).toHaveBeenCalledWith("timetable.example.com", { all: true, verbatim: true });
|
||||
expect(mocks.searchStations).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ baseUrl, pinnedAddresses: [{ address: "8.8.8.8", family: 4 }] }),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"http://127.0.0.1:8080",
|
||||
"http://timetable.internal:8080",
|
||||
"http://[::1]:8080",
|
||||
"http://[::ffff:127.0.0.1]:8080",
|
||||
"http://[fd00::1]:8080",
|
||||
"http://[64:ff9b::7f00:1]:8080",
|
||||
"http://[2002:7f00:1::]:8080",
|
||||
])("blocks an unsaved private endpoint %s even for board modifiers", async (baseUrl) => {
|
||||
const db = createDb();
|
||||
const { boardId, ownerId } = await createTimetableItemAsync(db, {
|
||||
baseUrl: "https://search.ch",
|
||||
isPublic: false,
|
||||
});
|
||||
const caller = createCaller(db, createSession(ownerId));
|
||||
|
||||
await expect(caller.searchStations({ baseUrl, boardId, query: "Bern" })).rejects.toMatchObject({
|
||||
code: "BAD_REQUEST",
|
||||
});
|
||||
expect(mocks.searchStations).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks an RFC 2765 IPv4-translated loopback address", async () => {
|
||||
const db = createDb();
|
||||
const { boardId, ownerId } = await createTimetableItemAsync(db, {
|
||||
baseUrl: "https://search.ch",
|
||||
isPublic: false,
|
||||
});
|
||||
const caller = createCaller(db, createSession(ownerId));
|
||||
|
||||
await expect(
|
||||
caller.searchStations({ baseUrl: "http://[::ffff:0:7f00:1]:8080", boardId, query: "Bern" }),
|
||||
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
expect(mocks.searchStations).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks an unsaved public hostname that resolves to a private address", async () => {
|
||||
mocks.lookup.mockResolvedValueOnce([{ address: "10.0.0.8", family: 4 }]);
|
||||
const db = createDb();
|
||||
const { boardId, ownerId } = await createTimetableItemAsync(db, {
|
||||
baseUrl: "https://search.ch",
|
||||
isPublic: false,
|
||||
});
|
||||
const caller = createCaller(db, createSession(ownerId));
|
||||
|
||||
await expect(
|
||||
caller.searchStations({ baseUrl: "https://timetable.example.com", boardId, query: "Bern" }),
|
||||
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
expect(mocks.searchStations).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows the exact saved private endpoint when item and board IDs match", async () => {
|
||||
const db = createDb();
|
||||
const baseUrl = "http://timetable.internal:8080";
|
||||
const { boardId, itemId, ownerId } = await createTimetableItemAsync(db, { baseUrl, isPublic: false });
|
||||
|
||||
await createCaller(db, createSession(ownerId)).searchStations({ baseUrl, boardId, itemId, query: "Bern" });
|
||||
|
||||
expect(mocks.lookup).not.toHaveBeenCalled();
|
||||
expect(mocks.searchStations).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ baseUrl, pinnedAddresses: undefined }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects an item ID that does not belong to the requested board", async () => {
|
||||
const db = createDb();
|
||||
const first = await createTimetableItemAsync(db, { baseUrl: "http://timetable.internal:8080", isPublic: false });
|
||||
const second = await createTimetableItemAsync(db, { baseUrl: "https://search.ch", isPublic: false });
|
||||
|
||||
await expect(
|
||||
createCaller(db, createSession(second.ownerId)).searchStations({
|
||||
baseUrl: "http://timetable.internal:8080",
|
||||
boardId: second.boardId,
|
||||
itemId: first.itemId,
|
||||
query: "Bern",
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
expect(mocks.searchStations).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks a changed private endpoint for an existing item", async () => {
|
||||
const db = createDb();
|
||||
const { boardId, itemId, ownerId } = await createTimetableItemAsync(db, {
|
||||
baseUrl: "http://timetable.internal:8080",
|
||||
isPublic: false,
|
||||
});
|
||||
|
||||
await expect(
|
||||
createCaller(db, createSession(ownerId)).searchStations({
|
||||
baseUrl: "http://127.0.0.1:8080",
|
||||
boardId,
|
||||
itemId,
|
||||
query: "Bern",
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
expect(mocks.searchStations).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows a changed public endpoint for an existing item and pins its address", async () => {
|
||||
const db = createDb();
|
||||
const { boardId, itemId, ownerId } = await createTimetableItemAsync(db, {
|
||||
baseUrl: "http://timetable.internal:8080",
|
||||
isPublic: false,
|
||||
});
|
||||
const baseUrl = "https://timetable.example.com";
|
||||
|
||||
await createCaller(db, createSession(ownerId)).searchStations({ baseUrl, boardId, itemId, query: "Bern" });
|
||||
|
||||
expect(mocks.searchStations).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ baseUrl, pinnedAddresses: [{ address: "8.8.8.8", family: 4 }] }),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports malformed saved widget options as server data corruption", async () => {
|
||||
const db = createDb();
|
||||
const { itemId } = await createTimetableItemAsync(db, { baseUrl: "https://search.ch" });
|
||||
await db.update(items).set({ options: "{" }).where(eq(items.id, itemId));
|
||||
|
||||
await expect(
|
||||
createCaller(db, null).getTimetable({
|
||||
baseUrl: "https://search.ch",
|
||||
itemId,
|
||||
stationId: "station",
|
||||
limit: 10,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "INTERNAL_SERVER_ERROR", message: "Timetable widget configuration is invalid" });
|
||||
});
|
||||
|
||||
it("reports an invalid saved timetable URL as server data corruption", async () => {
|
||||
const db = createDb();
|
||||
const { itemId } = await createTimetableItemAsync(db, {
|
||||
baseUrl: "https://search.ch?redirect=/admin",
|
||||
});
|
||||
|
||||
await expect(
|
||||
createCaller(db, null).getTimetable({
|
||||
baseUrl: "https://search.ch",
|
||||
itemId,
|
||||
stationId: "station",
|
||||
limit: 10,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "INTERNAL_SERVER_ERROR", message: "Timetable widget configuration is invalid" });
|
||||
});
|
||||
|
||||
it("does not treat public board view access as configuration access", async () => {
|
||||
|
||||
@@ -37,6 +37,7 @@ const queryFirewall = <
|
||||
summary: createFallback(),
|
||||
error: error instanceof Error ? error.message : "Integration request failed",
|
||||
}),
|
||||
throwOnAllFailures: true,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -34,9 +34,16 @@ export const immichRouter = createTRPCRouter({
|
||||
return data.data;
|
||||
}),
|
||||
|
||||
getAlbums: publicProcedure.concat(createImmichIntegrationMiddleware("query")).query(async ({ ctx }) => {
|
||||
const innerHandler = immichAlbumsRequestHandler.handler(ctx.integration, {});
|
||||
const data = await innerHandler.getDataAsync();
|
||||
return data.data;
|
||||
}),
|
||||
getAlbums: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
limit: z.number().int().min(1).max(500).optional(),
|
||||
}),
|
||||
)
|
||||
.concat(createImmichIntegrationMiddleware("query"))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const innerHandler = immichAlbumsRequestHandler.handler(ctx.integration, { limit: input.limit });
|
||||
const data = await innerHandler.getDataAsync();
|
||||
return data.data;
|
||||
}),
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import SuperJSON from "superjson";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
@@ -14,6 +15,14 @@ import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
const logger = createLogger({ module: "rssFeed" });
|
||||
|
||||
const safeFeedLogContext = (url: string, feedIndex: number) => {
|
||||
try {
|
||||
return { feedIndex, origin: new URL(url).origin };
|
||||
} catch {
|
||||
return { feedIndex };
|
||||
}
|
||||
};
|
||||
|
||||
const feedsInput = z.object({
|
||||
urls: z.array(z.string()).max(100),
|
||||
maximumAmountPosts: z.number(),
|
||||
@@ -38,14 +47,24 @@ export const rssFeedRouter = createTRPCRouter({
|
||||
);
|
||||
|
||||
const failures = settled.filter((result) => result.status === "rejected");
|
||||
settled.forEach((result, index) => {
|
||||
if (result.status === "rejected") {
|
||||
logger.warn("RSS feed fetch failed", {
|
||||
...safeFeedLogContext(urls[index] ?? "", index),
|
||||
errorType: result.reason instanceof Error ? result.reason.name : typeof result.reason,
|
||||
});
|
||||
}
|
||||
});
|
||||
if (settled.length > 0 && failures.length === settled.length) {
|
||||
throw failures[0]?.reason;
|
||||
throw new TRPCError({
|
||||
code: "BAD_GATEWAY",
|
||||
message: "All RSS feed requests failed",
|
||||
});
|
||||
}
|
||||
|
||||
return settled
|
||||
.flatMap((result, index) => {
|
||||
.flatMap((result) => {
|
||||
if (result.status === "fulfilled") return result.value.data.entries;
|
||||
logger.warn("RSS feed fetch failed", { url: urls[index], error: result.reason });
|
||||
return [];
|
||||
})
|
||||
.toSorted((entryA, entryB) => {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
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";
|
||||
@@ -6,6 +8,7 @@ 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 { TimetableResolvedAddress } from "@homarr/request-handler/timetable";
|
||||
import {
|
||||
timetableGetTimetableRequestHandler,
|
||||
timetableSearchStationsRequestHandler,
|
||||
@@ -21,28 +24,184 @@ 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.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;
|
||||
};
|
||||
|
||||
const getSavedTimetableBaseUrl = (serializedOptions: string) => {
|
||||
const options = parseSavedTimetableOptions(serializedOptions);
|
||||
return normalizeSavedTimetableBaseUrl(options.baseUrl ?? DEFAULT_TIMETABLE_BASE_URL);
|
||||
};
|
||||
|
||||
interface ResolvedTimetableSource {
|
||||
baseUrl: string;
|
||||
pinnedAddresses?: TimetableResolvedAddress[];
|
||||
}
|
||||
|
||||
const resolveTimetableBaseUrlAsync = async (
|
||||
ctx: { db: Database; session: Session | null },
|
||||
input: z.infer<typeof timetableSourceSchema>,
|
||||
allowBoardConfiguration = false,
|
||||
) => {
|
||||
const requestedBaseUrl = normalizeTimetableBaseUrl(input.baseUrl);
|
||||
): Promise<ResolvedTimetableSource> => {
|
||||
const requestedBaseUrl = normalizeBaseUrlOrThrowBadRequest(input.baseUrl);
|
||||
if (allowBoardConfiguration) {
|
||||
if (input.boardId) {
|
||||
let savedOptions: string | undefined;
|
||||
if (input.itemId) {
|
||||
const item = await ctx.db.query.items.findFirst({
|
||||
where: and(eq(items.id, input.itemId), eq(items.kind, "timetable"), eq(items.boardId, input.boardId)),
|
||||
});
|
||||
if (!item) throw new TRPCError({ code: "NOT_FOUND", message: "Timetable widget not found" });
|
||||
savedOptions = item.options;
|
||||
}
|
||||
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify");
|
||||
return requestedBaseUrl;
|
||||
const savedBaseUrl = savedOptions === undefined ? undefined : getSavedTimetableBaseUrl(savedOptions);
|
||||
if (requestedBaseUrl === savedBaseUrl || requestedBaseUrl === DEFAULT_TIMETABLE_BASE_URL) {
|
||||
return { baseUrl: requestedBaseUrl };
|
||||
}
|
||||
|
||||
const pinnedAddresses = await resolvePublicTimetableAddressesAsync(requestedBaseUrl);
|
||||
return { baseUrl: requestedBaseUrl, pinnedAddresses };
|
||||
}
|
||||
if (requestedBaseUrl !== DEFAULT_TIMETABLE_BASE_URL) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "Board modify access is required for this endpoint" });
|
||||
}
|
||||
return DEFAULT_TIMETABLE_BASE_URL;
|
||||
return { baseUrl: DEFAULT_TIMETABLE_BASE_URL };
|
||||
}
|
||||
if (!input.itemId) {
|
||||
if (requestedBaseUrl !== DEFAULT_TIMETABLE_BASE_URL) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "A saved timetable widget is required for this endpoint" });
|
||||
}
|
||||
return DEFAULT_TIMETABLE_BASE_URL;
|
||||
return { baseUrl: DEFAULT_TIMETABLE_BASE_URL };
|
||||
}
|
||||
|
||||
const item = await ctx.db.query.items.findFirst({
|
||||
@@ -51,14 +210,11 @@ const resolveTimetableBaseUrlAsync = async (
|
||||
if (!item) throw new TRPCError({ code: "NOT_FOUND", message: "Timetable widget not found" });
|
||||
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, item.boardId), "view");
|
||||
const options = parse<Record<string, unknown>>(item.options);
|
||||
const savedBaseUrl = normalizeTimetableBaseUrl(
|
||||
typeof options.baseUrl === "string" ? options.baseUrl : DEFAULT_TIMETABLE_BASE_URL,
|
||||
);
|
||||
const savedBaseUrl = getSavedTimetableBaseUrl(item.options);
|
||||
if (requestedBaseUrl !== savedBaseUrl) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "Timetable URL does not match the saved widget" });
|
||||
}
|
||||
return savedBaseUrl;
|
||||
return { baseUrl: savedBaseUrl };
|
||||
};
|
||||
|
||||
export const timetableRouter = createTRPCRouter({
|
||||
@@ -70,7 +226,7 @@ export const timetableRouter = createTRPCRouter({
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const baseUrl = await resolveTimetableBaseUrlAsync(ctx, input);
|
||||
const { baseUrl } = await resolveTimetableBaseUrlAsync(ctx, input);
|
||||
const { itemId: _itemId, boardId: _boardId, ...handlerInput } = input;
|
||||
const { data } = await timetableGetTimetableRequestHandler.handler({ ...handlerInput, baseUrl }).getDataAsync();
|
||||
return data;
|
||||
@@ -82,9 +238,11 @@ export const timetableRouter = createTRPCRouter({
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const baseUrl = await resolveTimetableBaseUrlAsync(ctx, input, true);
|
||||
const { baseUrl, pinnedAddresses } = await resolveTimetableBaseUrlAsync(ctx, input, true);
|
||||
const { itemId: _itemId, boardId: _boardId, ...handlerInput } = input;
|
||||
const { data } = await timetableSearchStationsRequestHandler.handler({ ...handlerInput, baseUrl }).getDataAsync();
|
||||
const { data } = await timetableSearchStationsRequestHandler
|
||||
.handler({ ...handlerInput, baseUrl, pinnedAddresses })
|
||||
.getDataAsync();
|
||||
return data;
|
||||
}),
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||
|
||||
@@ -50,7 +52,11 @@ export async function settleIntegrationQueries<TIntegration extends IntegrationL
|
||||
errors.length > 0 &&
|
||||
(results.length === 0 || (options?.throwOnAllFailures === true && errors.length === integrations.length))
|
||||
) {
|
||||
throw errors[0];
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "All integration queries failed",
|
||||
cause: errors[0],
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { settleIntegrationQueries } from "../settle-integrations";
|
||||
|
||||
@@ -19,6 +19,8 @@ const integrations = [
|
||||
{ id: "second", name: "Second", kind: "jellyfin" },
|
||||
];
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe("settleIntegrationQueries", () => {
|
||||
test("returns an empty result when no integrations are configured", async () => {
|
||||
await expect(settleIntegrationQueries([], vi.fn())).resolves.toEqual([]);
|
||||
@@ -35,9 +37,14 @@ describe("settleIntegrationQueries", () => {
|
||||
});
|
||||
|
||||
test("rejects when every integration fails", async () => {
|
||||
const error = new Error("Integration unavailable");
|
||||
const error = new Error("Integration unavailable with token=secret");
|
||||
|
||||
await expect(settleIntegrationQueries(integrations, () => Promise.reject(error))).rejects.toBe(error);
|
||||
await expect(settleIntegrationQueries(integrations, () => Promise.reject(error))).rejects.toMatchObject({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "All integration queries failed",
|
||||
cause: error,
|
||||
});
|
||||
expect(mocks.logger.warn).toHaveBeenCalledTimes(integrations.length);
|
||||
});
|
||||
|
||||
test("uses configured fallbacks when every integration fails", async () => {
|
||||
@@ -62,6 +69,10 @@ describe("settleIntegrationQueries", () => {
|
||||
options,
|
||||
),
|
||||
).resolves.toEqual(["First", "Second"]);
|
||||
await expect(settleIntegrationQueries(integrations, () => Promise.reject(error), options)).rejects.toBe(error);
|
||||
await expect(settleIntegrationQueries(integrations, () => Promise.reject(error), options)).rejects.toMatchObject({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "All integration queries failed",
|
||||
cause: error,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user