Skip to content
23 changes: 14 additions & 9 deletions app/app/api/discovery/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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) => {
Expand All @@ -450,7 +455,7 @@ export async function GET(request: NextRequest) {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
totalPages,
hasMore: page * limit < total,
},
ranking: {
Expand All @@ -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);
}
}
17 changes: 10 additions & 7 deletions app/app/api/leaderboard/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
12 changes: 10 additions & 2 deletions app/app/api/notifications/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import { parsePaginationParam } from "@/lib/validation";
import {
getPaginatedNotifications,
markAllAsRead,
Expand All @@ -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,
Expand Down
12 changes: 10 additions & 2 deletions app/app/api/posts/[id]/comments/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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([
Expand Down
17 changes: 10 additions & 7 deletions app/app/api/posts/[id]/entries/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 },
Expand Down
12 changes: 10 additions & 2 deletions app/app/api/posts/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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"] };
Expand Down
12 changes: 10 additions & 2 deletions app/app/api/users/[id]/followers/route.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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
Expand Down
12 changes: 10 additions & 2 deletions app/app/api/users/[id]/following/route.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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
Expand Down
15 changes: 10 additions & 5 deletions app/app/api/wallet/transactions/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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([
Expand Down
49 changes: 49 additions & 0 deletions app/lib/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
15 changes: 15 additions & 0 deletions app/tests/api/discovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,22 @@
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);

Check failure on line 249 in app/tests/api/discovery.test.ts

View workflow job for this annotation

GitHub Actions / test

tests/api/discovery.test.ts > Discovery API > handles invalid pagination parameters gracefully

AssertionError: expected 500 to be 200 // Object.is equality - Expected + Received - 200 + 500 ❯ tests/api/discovery.test.ts:249:20
expect(data.success).toBe(true);
expect(data.data.pagination.page).toBe(1);
expect(data.data.pagination.limit).toBe(10);
});

});
Loading
Loading