diff --git a/app/app/api/discovery/route.ts b/app/app/api/discovery/route.ts index 21ac7bc..b1c409f 100644 --- a/app/app/api/discovery/route.ts +++ b/app/app/api/discovery/route.ts @@ -374,19 +374,20 @@ async function searchTopics(args: { export async function GET(request: NextRequest) { try { const { searchParams } = new URL(request.url); + + // Parse and validate pagination parameters directly + const rawPage = Number(searchParams.get("page")); + const rawLimit = Number(searchParams.get("limit")); + const page = Number.isNaN(rawPage) || rawPage < 1 ? 1 : Math.floor(rawPage); + const limit = Number.isNaN(rawLimit) || rawLimit < 1 ? 20 : Math.min(Math.floor(rawLimit), 100); + const query = normalizeQuery(searchParams.get("q")); - const page = parseInt(searchParams.get("page") || "1", 10); - const limit = parseInt(searchParams.get("limit") || String(DEFAULT_LIMIT), 10); const rankBy = (searchParams.get("rankBy") || "relevance") as DiscoveryRankBy; const period = searchParams.get("period") || "all-time"; const postType = searchParams.get("postType"); const postStatus = searchParams.get("status"); const requestedTypes = parseRequestedTypes(searchParams.get("types")); - if (page < 1 || limit < 1 || limit > MAX_LIMIT) { - return apiError("Invalid pagination parameters", 400); - } - if (!["relevance", "recent", "popular"].includes(rankBy)) { return apiError("Invalid ranking option", 400); } @@ -433,7 +434,11 @@ export async function GET(request: NextRequest) { const resultSets = await Promise.all(tasks); const combinedResults = rankResults(resultSets.flat(), rankBy); const total = combinedResults.length; - const paginatedResults = combinedResults.slice((page - 1) * limit, page * limit); + + // Use sanitized page and limit values + const skip = (page - 1) * limit; + const paginatedResults = combinedResults.slice(skip, skip + limit); + const totalPages = Math.ceil(total / limit); const counts = combinedResults.reduce( (acc, result) => { @@ -450,7 +455,7 @@ export async function GET(request: NextRequest) { page, limit, total, - totalPages: Math.ceil(total / limit), + totalPages, hasMore: page * limit < total, }, ranking: { @@ -460,7 +465,7 @@ export async function GET(request: NextRequest) { counts, }); } catch (error) { - console.error("Discovery API error:", error); + console.error("[discovery] route error:", error); return apiError("Failed to fetch discovery results", 500); } } diff --git a/app/app/api/leaderboard/route.ts b/app/app/api/leaderboard/route.ts index 9e11c74..d1d76ee 100644 --- a/app/app/api/leaderboard/route.ts +++ b/app/app/api/leaderboard/route.ts @@ -2,18 +2,21 @@ import { apiError, apiSuccess } from '@/lib/api-response'; import { NextRequest } from 'next/server'; import { prisma } from '@/lib/prisma'; +import { parsePaginationParam } from '@/lib/validation'; export async function GET (request: NextRequest) { try { const { searchParams } = new URL(request.url); const period = searchParams.get('period') || 'all-time'; - const page = parseInt(searchParams.get('page') || '1'); - const limit = parseInt(searchParams.get('limit') || '50'); - - // Validate pagination parameters - if (page < 1 || limit < 1 || limit > 100) { - return apiError('Invalid pagination parameters', 400); - } + const page = parsePaginationParam(searchParams.get('page'), { + defaultValue: 1, + min: 1, + }); + const limit = parsePaginationParam(searchParams.get('limit'), { + defaultValue: 50, + min: 1, + max: 100, + }); let dateFilter: Date | undefined; if (period === 'weekly') { diff --git a/app/app/api/notifications/route.ts b/app/app/api/notifications/route.ts index 7c35781..258e4f1 100644 --- a/app/app/api/notifications/route.ts +++ b/app/app/api/notifications/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { getCurrentUser } from "@/lib/auth"; +import { parsePaginationParam } from "@/lib/validation"; import { getPaginatedNotifications, markAllAsRead, @@ -16,8 +17,15 @@ export async function GET(req: NextRequest) { const { searchParams } = new URL(req.url); const isRead = searchParams.get("isRead"); - const page = Number.parseInt(searchParams.get("page") || "1", 10); - const pageSize = Number.parseInt(searchParams.get("pageSize") || "20", 10); + const page = parsePaginationParam(searchParams.get("page"), { + defaultValue: 1, + min: 1, + }); + const pageSize = parsePaginationParam(searchParams.get("pageSize"), { + defaultValue: 20, + min: 1, + max: 100, + }); const result = await getPaginatedNotifications({ userId: currentUser.id, diff --git a/app/app/api/posts/[id]/comments/route.ts b/app/app/api/posts/[id]/comments/route.ts index 514fff1..0dc76ba 100644 --- a/app/app/api/posts/[id]/comments/route.ts +++ b/app/app/api/posts/[id]/comments/route.ts @@ -4,6 +4,7 @@ import { apiSuccess, apiError } from "@/lib/api-response"; import { getCurrentUser } from "@/lib/auth"; import { readJsonBody } from "@/lib/parse-json-body"; import { createNotification } from "@/lib/notifications"; +import { parsePaginationParam } from "@/lib/validation"; export async function GET( request: NextRequest, @@ -12,8 +13,15 @@ export async function GET( try { const { id } = await params; const { searchParams } = new URL(request.url); - const page = parseInt(searchParams.get("page") || "1"); - const limit = parseInt(searchParams.get("limit") || "50"); + const page = parsePaginationParam(searchParams.get("page"), { + defaultValue: 1, + min: 1, + }); + const limit = parsePaginationParam(searchParams.get("limit"), { + defaultValue: 50, + min: 1, + max: 100, + }); const skip = (page - 1) * limit; const [comments, total] = await Promise.all([ diff --git a/app/app/api/posts/[id]/entries/route.ts b/app/app/api/posts/[id]/entries/route.ts index 0af1d07..2cd1a11 100644 --- a/app/app/api/posts/[id]/entries/route.ts +++ b/app/app/api/posts/[id]/entries/route.ts @@ -7,6 +7,7 @@ import { getCurrentUser } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; import { readJsonBody } from "@/lib/parse-json-body"; import { checkAndAwardBadges } from "@/lib/badges"; +import { parsePaginationParam } from "@/lib/validation"; import { createNotificationInTransaction, fanOutNotificationsInTransaction, @@ -303,15 +304,17 @@ export async function GET( const { id: postId } = await params; const { searchParams } = new URL(request.url); - const page = parseInt(searchParams.get("page") || "1"); - const limit = parseInt(searchParams.get("limit") || "10"); + const page = parsePaginationParam(searchParams.get("page"), { + defaultValue: 1, + min: 1, + }); + const limit = parsePaginationParam(searchParams.get("limit"), { + defaultValue: 10, + min: 1, + max: 100, + }); const skip = (page - 1) * limit; - // Validate pagination params - if (page < 1 || limit < 1 || limit > 100) { - return apiError("Invalid pagination parameters", 400); - } - // Check if post exists const post = await prisma.post.findUnique({ where: { id: postId }, diff --git a/app/app/api/posts/route.ts b/app/app/api/posts/route.ts index 5a69f04..18fb89d 100644 --- a/app/app/api/posts/route.ts +++ b/app/app/api/posts/route.ts @@ -9,6 +9,7 @@ import { prisma } from "@/lib/prisma"; import { readJsonBody } from "@/lib/parse-json-body"; import { POST_SLUG_MAX_LENGTH, sanitizePostSlug } from "@/lib/post-slug"; import { checkAndAwardBadges } from "@/lib/badges"; +import { parsePaginationParam } from "@/lib/validation"; const SLUG_SUFFIX_LENGTH = 6; @@ -215,8 +216,15 @@ const GET = async (request: NextRequest) => { const status = searchParams.get("status"); const from = searchParams.get("from"); const to = searchParams.get("to"); - const page = parseInt(searchParams.get("page") || "1"); - const limit = parseInt(searchParams.get("limit") || "10"); + const page = parsePaginationParam(searchParams.get("page"), { + defaultValue: 1, + min: 1, + }); + const limit = parsePaginationParam(searchParams.get("limit"), { + defaultValue: 10, + min: 1, + max: 100, + }); const where: Prisma.PostWhereInput = {}; where.moderationStatus = { notIn: ["suspended", "banned"] }; diff --git a/app/app/api/users/[id]/followers/route.ts b/app/app/api/users/[id]/followers/route.ts index 6ca4d0f..2db67a3 100644 --- a/app/app/api/users/[id]/followers/route.ts +++ b/app/app/api/users/[id]/followers/route.ts @@ -1,6 +1,7 @@ import { NextRequest } from 'next/server'; import { prisma } from '@/lib/prisma'; import { apiSuccess, apiError } from '@/lib/api-response'; +import { parsePaginationParam } from '@/lib/validation'; export async function GET( request: NextRequest, @@ -9,8 +10,15 @@ export async function GET( try { const { id: targetUserId } = await params; const { searchParams } = new URL(request.url); - const limit = parseInt(searchParams.get('limit') || '20'); - const skip = parseInt(searchParams.get('skip') || '0'); + const limit = parsePaginationParam(searchParams.get('limit'), { + defaultValue: 20, + min: 1, + max: 100, + }); + const skip = parsePaginationParam(searchParams.get('skip'), { + defaultValue: 0, + min: 0, + }); // Followers are users who follow the target user // i.e., followingId = targetUserId diff --git a/app/app/api/users/[id]/following/route.ts b/app/app/api/users/[id]/following/route.ts index 8e60fe2..430ca34 100644 --- a/app/app/api/users/[id]/following/route.ts +++ b/app/app/api/users/[id]/following/route.ts @@ -1,6 +1,7 @@ import { NextRequest } from 'next/server'; import { prisma } from '@/lib/prisma'; import { apiSuccess, apiError } from '@/lib/api-response'; +import { parsePaginationParam } from '@/lib/validation'; export async function GET( request: NextRequest, @@ -9,8 +10,15 @@ export async function GET( try { const { id: targetUserId } = await params; const { searchParams } = new URL(request.url); - const limit = parseInt(searchParams.get('limit') || '20'); - const skip = parseInt(searchParams.get('skip') || '0'); + const limit = parsePaginationParam(searchParams.get('limit'), { + defaultValue: 20, + min: 1, + max: 100, + }); + const skip = parsePaginationParam(searchParams.get('skip'), { + defaultValue: 0, + min: 0, + }); // Following are users the target user follows // i.e., userId = targetUserId, followingId != null diff --git a/app/app/api/wallet/transactions/route.ts b/app/app/api/wallet/transactions/route.ts index 535975b..a19fadc 100644 --- a/app/app/api/wallet/transactions/route.ts +++ b/app/app/api/wallet/transactions/route.ts @@ -3,6 +3,7 @@ import { apiError, apiSuccess } from "@/lib/api-response"; import { getCurrentUser } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; import { getLiveTransactions } from "@/lib/stellar"; +import { parsePaginationParam } from "@/lib/validation"; /** * GET /api/wallet/transactions @@ -20,11 +21,15 @@ export async function GET(request: NextRequest) { if (!currentUser) return apiError("Unauthorized", 401); const { searchParams } = new URL(request.url); - const page = Math.max(1, parseInt(searchParams.get("page") ?? "1", 10)); - const limit = Math.min( - 50, - Math.max(1, parseInt(searchParams.get("limit") ?? "20", 10)), - ); + const page = parsePaginationParam(searchParams.get("page"), { + defaultValue: 1, + min: 1, + }); + const limit = parsePaginationParam(searchParams.get("limit"), { + defaultValue: 20, + min: 1, + max: 50, + }); const skip = (page - 1) * limit; const [transactions, total, user] = await Promise.all([ diff --git a/app/lib/validation.ts b/app/lib/validation.ts index d5c1102..e2cb308 100644 --- a/app/lib/validation.ts +++ b/app/lib/validation.ts @@ -108,3 +108,52 @@ export const validateTargetAmount = (amount: number | string): ValidationResult } return { isValid: true }; }; + +/** + * Safely parse pagination parameters from query strings. + * Returns default values if parsing fails or values are invalid. + * @param value The query parameter value + * @param defaultValue The default value if parsing fails (default: based on param type) + * @param min Minimum allowed value (default: 0 for skip, 1 for page, 1 for limit) + * @param max Maximum allowed value (optional) + * @returns The parsed integer or default value + */ +export const parsePaginationParam = ( + value: string | null, + options?: { + defaultValue?: number; + min?: number; + max?: number; + } +): number => { + const defaultValue = options?.defaultValue ?? 1; + const min = options?.min ?? 1; + const max = options?.max; + + // If no value provided, return default + if (!value) { + return defaultValue; + } + + // Try to parse as number + const rawParsed = Number(value); + + // Check if parsing failed (result is NaN) + if (Number.isNaN(rawParsed)) { + return defaultValue; + } + + // Floor to integer + const parsed = Math.floor(rawParsed); + + // Check bounds + if (parsed < min) { + return defaultValue; + } + + if (max !== undefined && parsed > max) { + return max; + } + + return parsed; +}; diff --git a/app/tests/api/discovery.test.ts b/app/tests/api/discovery.test.ts index e62764c..6f1d3e5 100644 --- a/app/tests/api/discovery.test.ts +++ b/app/tests/api/discovery.test.ts @@ -234,7 +234,22 @@ describe("Discovery API", () => { const response = await GET(request); const { status, data } = await parseResponse(response); + // rankBy=unknown should return 400, but pagination params now default gracefully expect(status).toBe(400); expect(data.success).toBe(false); }); + + it("handles invalid pagination parameters gracefully", async () => { + const request = createMockRequest( + "http://localhost:3000/api/discovery?q=test&page=abc&limit=xyz", + ); + const response = await GET(request); + const { status, data } = await parseResponse(response); + + expect(status).toBe(200); + expect(data.success).toBe(true); + expect(data.data.pagination.page).toBe(1); + expect(data.data.pagination.limit).toBe(10); + }); + }); diff --git a/app/tests/api/entries.test.ts b/app/tests/api/entries.test.ts index 3426a2d..62c7887 100644 --- a/app/tests/api/entries.test.ts +++ b/app/tests/api/entries.test.ts @@ -723,9 +723,14 @@ describe('Entry API Endpoints', () => { }); }); - it('should reject invalid pagination parameters', async () => { + it('should handle invalid pagination parameters gracefully by using defaults', async () => { + const mockEntries = []; + prisma.post.findUnique = vi.fn().mockResolvedValue(post); + prisma.entry.findMany = vi.fn().mockResolvedValue(mockEntries); + prisma.entry.count = vi.fn().mockResolvedValue(0); + const request = createMockRequest( - `http://localhost:3000/api/posts/${post.id}/entries?page=0&limit=-1`, + `http://localhost:3000/api/posts/${post.id}/entries?page=abc&limit=xyz`, ); const response = await GetEntries(request, { @@ -733,12 +738,18 @@ describe('Entry API Endpoints', () => { }); const { status, data } = await parseResponse(response); - expect(status).toBe(400); - expect(data.success).toBe(false); - expect(data.error).toBe('Invalid pagination parameters'); + expect(status).toBe(200); + expect(data.success).toBe(true); + expect(data.data.pagination.page).toBe(1); + expect(data.data.pagination.limit).toBe(10); }); - it('should reject limit greater than 100', async () => { + it('should cap limit at 100', async () => { + const mockEntries = []; + prisma.post.findUnique = vi.fn().mockResolvedValue(post); + prisma.entry.findMany = vi.fn().mockResolvedValue(mockEntries); + prisma.entry.count = vi.fn().mockResolvedValue(0); + const request = createMockRequest( `http://localhost:3000/api/posts/${post.id}/entries?limit=101`, ); @@ -748,9 +759,9 @@ describe('Entry API Endpoints', () => { }); const { status, data } = await parseResponse(response); - expect(status).toBe(400); - expect(data.success).toBe(false); - expect(data.error).toBe('Invalid pagination parameters'); + expect(status).toBe(200); + expect(data.success).toBe(true); + expect(data.data.pagination.limit).toBe(100); }); it('should return 404 for non-existent post', async () => {