diff --git a/apps/web/__tests__/unit/pagination-params.test.ts b/apps/web/__tests__/unit/pagination-params.test.ts new file mode 100644 index 00000000000..06a2d7b7a65 --- /dev/null +++ b/apps/web/__tests__/unit/pagination-params.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { parseLimitParam, parsePageParam } from "../../lib/pagination"; + +describe("pagination params validation (#3567)", () => { + describe("parsePageParam", () => { + it("falls back to default page on missing, non-positive, or fractional input", () => { + expect(parsePageParam(undefined)).toBe(1); + expect(parsePageParam("")).toBe(1); + expect(parsePageParam("0")).toBe(1); + expect(parsePageParam("-1")).toBe(1); + expect(parsePageParam("-10")).toBe(1); + expect(parsePageParam("1.5")).toBe(1); + expect(parsePageParam("abc")).toBe(1); + expect(parsePageParam(NaN as unknown as string)).toBe(1); + }); + + it("parses valid positive integer pages", () => { + expect(parsePageParam("1")).toBe(1); + expect(parsePageParam("5")).toBe(5); + expect(parsePageParam(["3", "4"])).toBe(3); + }); + + it("supports custom default page", () => { + expect(parsePageParam("-5", 2)).toBe(2); + }); + }); + + describe("parseLimitParam", () => { + it("falls back to default limit on missing, non-positive, or fractional input", () => { + expect(parseLimitParam(undefined, 15)).toBe(15); + expect(parseLimitParam("", 15)).toBe(15); + expect(parseLimitParam("0", 15)).toBe(15); + expect(parseLimitParam("-5", 15)).toBe(15); + expect(parseLimitParam("2.5", 15)).toBe(15); + expect(parseLimitParam("foo", 15)).toBe(15); + }); + + it("parses valid positive integer limits", () => { + expect(parseLimitParam("10", 15)).toBe(10); + expect(parseLimitParam("25", 15)).toBe(25); + expect(parseLimitParam(["20", "30"], 15)).toBe(20); + }); + + it("caps oversized limits to maxLimit", () => { + expect(parseLimitParam("500", 15, 100)).toBe(100); + expect(parseLimitParam("100", 15, 100)).toBe(100); + }); + }); +}); diff --git a/apps/web/app/(org)/dashboard/caps/page.tsx b/apps/web/app/(org)/dashboard/caps/page.tsx index 51696e1eb10..67951bad6ce 100644 --- a/apps/web/app/(org)/dashboard/caps/page.tsx +++ b/apps/web/app/(org)/dashboard/caps/page.tsx @@ -22,6 +22,7 @@ import { and, count, desc, eq, inArray, isNull, sql } from "drizzle-orm"; import { type Array, Effect } from "effect"; import type { Metadata } from "next"; import { redirect } from "next/navigation"; +import { parseLimitParam, parsePageParam } from "@/lib/pagination"; import { seedThumbnailUrls } from "@/lib/seeded-thumbnails"; import { runPromise } from "@/lib/server"; import { Caps } from "./Caps"; @@ -133,8 +134,8 @@ export default async function CapsPage(props: PageProps<"/dashboard/caps">) { redirect("/login"); } - const page = Number(searchParams.page) || 1; - const limit = Number(searchParams.limit) || 15; + const page = parsePageParam(searchParams.page, 1); + const limit = parseLimitParam(searchParams.limit, 15); const userId = user.id; const offset = (page - 1) * limit; diff --git a/apps/web/app/(org)/dashboard/spaces/[spaceId]/page.tsx b/apps/web/app/(org)/dashboard/spaces/[spaceId]/page.tsx index df563360460..64cf5af2707 100644 --- a/apps/web/app/(org)/dashboard/spaces/[spaceId]/page.tsx +++ b/apps/web/app/(org)/dashboard/spaces/[spaceId]/page.tsx @@ -32,6 +32,7 @@ import { and, count, desc, eq, inArray, isNull, or, sql } from "drizzle-orm"; import { Effect } from "effect"; import type { Metadata } from "next"; import { notFound } from "next/navigation"; +import { parseLimitParam, parsePageParam } from "@/lib/pagination"; import { seedThumbnailUrls } from "@/lib/seeded-thumbnails"; import { runPromise } from "@/lib/server"; import { SharedCaps } from "./SharedCaps"; @@ -288,8 +289,8 @@ export default async function SharedCapsPage(props: { }) { const searchParams = await props.searchParams; const params = await props.params; - const page = Number(searchParams.page) || 1; - const limit = Number(searchParams.limit) || 15; + const page = parsePageParam(searchParams.page, 1); + const limit = parseLimitParam(searchParams.limit, 15); const user = await getCurrentUser(); if (!user) notFound(); diff --git a/apps/web/lib/pagination.ts b/apps/web/lib/pagination.ts new file mode 100644 index 00000000000..1a15eabb730 --- /dev/null +++ b/apps/web/lib/pagination.ts @@ -0,0 +1,24 @@ +export function parsePageParam( + page: string | string[] | undefined, + defaultValue = 1, +): number { + const value = Array.isArray(page) ? page[0] : page; + const parsed = Number(value); + + if (!Number.isInteger(parsed) || parsed < 1) return defaultValue; + + return parsed; +} + +export function parseLimitParam( + limit: string | string[] | undefined, + defaultValue = 15, + maxLimit = 100, +): number { + const value = Array.isArray(limit) ? limit[0] : limit; + const parsed = Number(value); + + if (!Number.isInteger(parsed) || parsed < 1) return defaultValue; + + return Math.min(parsed, maxLimit); +} diff --git a/apps/web/lib/public-collections-policy.ts b/apps/web/lib/public-collections-policy.ts index 5d42e5f049f..fc6c1030094 100644 --- a/apps/web/lib/public-collections-policy.ts +++ b/apps/web/lib/public-collections-policy.ts @@ -1,4 +1,5 @@ import { isEmailAllowedByRestriction } from "@cap/utils"; +import { parsePageParam } from "./pagination"; export const PUBLIC_COLLECTION_PAGE_SIZE = 15; @@ -19,12 +20,7 @@ export type PublicCollectionAccess = export function parsePublicCollectionPage( page: string | string[] | undefined, ): number { - const value = Array.isArray(page) ? page[0] : page; - const parsed = Number(value); - - if (!Number.isInteger(parsed) || parsed < 1) return 1; - - return parsed; + return parsePageParam(page, 1); } export function getPublicCollectionHref(id: string, page: number): string {