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
@@ -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;
|
||||
}),
|
||||
});
|
||||
Reference in new issue
Block a user