From 644b239ab3dcfa6f56c5f80d13ef57d3ebf2f05f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20=C4=B0pek?= Date: Tue, 24 Feb 2026 18:07:04 +0300 Subject: [PATCH] feat: add pagination and strict query validation for workspace/project listings --- .../workspaces/[workspaceId]/page.tsx | 112 ++++++++++++------ app/api/projects/[projectId]/route.ts | 18 ++- app/api/projects/route.ts | 23 +++- .../workspaces/[workspaceId]/members/route.ts | 56 +++++++-- app/api/workspaces/[workspaceId]/route.ts | 20 ++++ app/api/workspaces/route.ts | 72 ++++++++--- 6 files changed, 236 insertions(+), 65 deletions(-) diff --git a/app/(dashboard)/workspaces/[workspaceId]/page.tsx b/app/(dashboard)/workspaces/[workspaceId]/page.tsx index d089cff..be26597 100644 --- a/app/(dashboard)/workspaces/[workspaceId]/page.tsx +++ b/app/(dashboard)/workspaces/[workspaceId]/page.tsx @@ -44,16 +44,27 @@ function formatRelativeTime(date: Date): string { interface WorkspacePageProps { params: Promise<{ workspaceId: string }>; + searchParams: Promise<{ page?: string }>; } -export default async function WorkspacePage({ params }: WorkspacePageProps) { +export default async function WorkspacePage({ params, searchParams }: WorkspacePageProps) { const session = await auth(); const { workspaceId } = await params; + const resolvedSearchParams = await searchParams; + const MAX_PAGE = 1000; if (!session?.user?.id) { redirect('/login'); } + const pageParam = resolvedSearchParams?.page; + const parsedPage = pageParam ? Number(pageParam) : 1; + const page = Number.isSafeInteger(parsedPage) && parsedPage > 0 && parsedPage <= MAX_PAGE + ? parsedPage + : 1; + const pageSize = 20; + const skip = (page - 1) * pageSize; + const workspace = await db.workspace.findUnique({ where: { id: workspaceId }, include: { @@ -64,6 +75,8 @@ export default async function WorkspacePage({ params }: WorkspacePageProps) { }, projects: { orderBy: { updatedAt: 'desc' }, + skip, + take: pageSize, include: { _count: { select: { videos: true, members: true } }, }, @@ -85,6 +98,8 @@ export default async function WorkspacePage({ params }: WorkspacePageProps) { redirect('/workspaces'); } + const totalPages = Math.ceil(workspace._count.projects / pageSize); + return (
{/* Back & Header */} @@ -143,42 +158,65 @@ export default async function WorkspacePage({ params }: WorkspacePageProps) { {/* Projects Grid */} {workspace.projects.length > 0 ? ( -
- {workspace.projects.map((project: (typeof workspace.projects)[number]) => ( - - - -
- - - {project.name} - - - - {project.visibility.toLowerCase()} - -
- - {project.description || 'No description'} - -
- -
- - - {formatRelativeTime(project.updatedAt)} - - - - {project._count.members + 1} - - {project._count.videos} videos -
-
-
- - ))} -
+ <> +
+ {workspace.projects.map((project: (typeof workspace.projects)[number]) => ( + + + +
+ + + {project.name} + + + + {project.visibility.toLowerCase()} + +
+ + {project.description || 'No description'} + +
+ +
+ + + {formatRelativeTime(project.updatedAt)} + + + + {project._count.members + 1} + + {project._count.videos} videos +
+
+
+ + ))} +
+ {totalPages > 1 && ( +
+ + + Page {page} of {totalPages} + + +
+ )} + ) : ( diff --git a/app/api/projects/[projectId]/route.ts b/app/api/projects/[projectId]/route.ts index b083140..8c61d64 100644 --- a/app/api/projects/[projectId]/route.ts +++ b/app/api/projects/[projectId]/route.ts @@ -12,11 +12,25 @@ export async function GET(request: NextRequest, { params }: RouteParams) { try { const session = await auth(); const { projectId } = await params; + const MAX_LIMIT = 100; + const MAX_OFFSET = 10000; // Parse pagination params const searchParams = request.nextUrl.searchParams; - const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 100); - const offset = Math.max(0, parseInt(searchParams.get('offset') || '0')); + const limitParam = searchParams.get('limit'); + const offsetParam = searchParams.get('offset'); + + const limitRaw = limitParam === null ? 20 : Number(limitParam); + if (!Number.isSafeInteger(limitRaw) || limitRaw < 1 || limitRaw > MAX_LIMIT) { + return apiErrors.badRequest('Invalid limit. Must be a positive integer between 1 and 100.'); + } + + const offset = offsetParam === null ? 0 : Number(offsetParam); + if (!Number.isSafeInteger(offset) || offset < 0 || offset > MAX_OFFSET) { + return apiErrors.badRequest('Invalid offset. Must be a non-negative integer up to 10000.'); + } + + const limit = limitRaw; const project = await db.project.findUnique({ where: { id: projectId }, diff --git a/app/api/projects/route.ts b/app/api/projects/route.ts index dd3375d..1ebc8e2 100644 --- a/app/api/projects/route.ts +++ b/app/api/projects/route.ts @@ -10,16 +10,35 @@ import { DEFAULT_COMMENT_TAGS } from '@/lib/comment-tags'; export async function GET(request: NextRequest) { try { const session = await auth(); + const MAX_LIMIT = 100; + const MAX_PAGE = 1000; + const MAX_OFFSET = 10000; if (!session?.user?.id) { return apiErrors.unauthorized(); } const { searchParams } = new URL(request.url); - const page = parseInt(searchParams.get('page') || '1'); - const limit = parseInt(searchParams.get('limit') || '10'); + const pageParam = searchParams.get('page'); + const limitParam = searchParams.get('limit'); const workspaceId = searchParams.get('workspaceId'); + + const pageRaw = pageParam === null ? 1 : Number(pageParam); + if (!Number.isSafeInteger(pageRaw) || pageRaw < 1 || pageRaw > MAX_PAGE) { + return apiErrors.badRequest('Invalid page. Must be a positive integer.'); + } + + const limitRaw = limitParam === null ? 10 : Number(limitParam); + if (!Number.isSafeInteger(limitRaw) || limitRaw < 1 || limitRaw > MAX_LIMIT) { + return apiErrors.badRequest('Invalid limit. Must be a positive integer between 1 and 100.'); + } + + const page = pageRaw; + const limit = limitRaw; const skip = (page - 1) * limit; + if (!Number.isSafeInteger(skip) || skip > MAX_OFFSET) { + return apiErrors.badRequest('Invalid page range. Offset must be 10000 or less.'); + } // Build base filter: user is owner OR a member const baseFilter: Record = { diff --git a/app/api/workspaces/[workspaceId]/members/route.ts b/app/api/workspaces/[workspaceId]/members/route.ts index cf49b54..ca77b63 100644 --- a/app/api/workspaces/[workspaceId]/members/route.ts +++ b/app/api/workspaces/[workspaceId]/members/route.ts @@ -12,11 +12,35 @@ export async function GET(request: NextRequest, { params }: RouteParams) { try { const session = await auth(); const { workspaceId } = await params; + const MAX_LIMIT = 100; + const MAX_PAGE = 1000; + const MAX_OFFSET = 10000; if (!session?.user?.id) { return apiErrors.unauthorized(); } + const searchParams = request.nextUrl.searchParams; + const pageParam = searchParams.get('page'); + const limitParam = searchParams.get('limit'); + + const pageRaw = pageParam === null ? 1 : Number(pageParam); + if (!Number.isSafeInteger(pageRaw) || pageRaw < 1 || pageRaw > MAX_PAGE) { + return apiErrors.badRequest('Invalid page. Must be a positive integer.'); + } + + const limitRaw = limitParam === null ? 20 : Number(limitParam); + if (!Number.isSafeInteger(limitRaw) || limitRaw < 1 || limitRaw > MAX_LIMIT) { + return apiErrors.badRequest('Invalid limit. Must be a positive integer between 1 and 100.'); + } + + const page = pageRaw; + const limit = limitRaw; + const skip = (page - 1) * limit; + if (!Number.isSafeInteger(skip) || skip > MAX_OFFSET) { + return apiErrors.badRequest('Invalid page range. Offset must be 10000 or less.'); + } + const workspace = await db.workspace.findUnique({ where: { id: workspaceId }, include: { @@ -35,13 +59,20 @@ export async function GET(request: NextRequest, { params }: RouteParams) { return apiErrors.forbidden('Access denied'); } - const members = await db.workspaceMember.findMany({ - where: { workspaceId }, - include: { - user: { select: { id: true, name: true, email: true, image: true } }, - }, - orderBy: { createdAt: 'asc' }, - }); + const [members, total] = await Promise.all([ + db.workspaceMember.findMany({ + where: { workspaceId }, + include: { + user: { select: { id: true, name: true, email: true, image: true } }, + }, + orderBy: { createdAt: 'asc' }, + skip, + take: limit, + }), + db.workspaceMember.count({ + where: { workspaceId }, + }), + ]); // Include the owner as well const owner = await db.user.findUnique({ @@ -49,7 +80,16 @@ export async function GET(request: NextRequest, { params }: RouteParams) { select: { id: true, name: true, email: true, image: true }, }); - const response = successResponse({ members, owner }); + const response = successResponse( + { members, owner }, + 200, + { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + } + ); return withCacheControl(response, 'private, max-age=60, stale-while-revalidate=120'); } catch (error) { console.error('Error fetching workspace members:', error); diff --git a/app/api/workspaces/[workspaceId]/route.ts b/app/api/workspaces/[workspaceId]/route.ts index 6ab178a..5a12de5 100644 --- a/app/api/workspaces/[workspaceId]/route.ts +++ b/app/api/workspaces/[workspaceId]/route.ts @@ -35,11 +35,29 @@ export async function GET(request: NextRequest, { params }: RouteParams) { try { const session = await auth(); const { workspaceId } = await params; + const MAX_LIMIT = 100; + const MAX_OFFSET = 10000; if (!session?.user?.id) { return apiErrors.unauthorized(); } + const searchParams = request.nextUrl.searchParams; + const limitParam = searchParams.get('limit'); + const offsetParam = searchParams.get('offset'); + + const limitRaw = limitParam === null ? 20 : Number(limitParam); + if (!Number.isSafeInteger(limitRaw) || limitRaw < 1 || limitRaw > MAX_LIMIT) { + return apiErrors.badRequest('Invalid limit. Must be a positive integer between 1 and 100.'); + } + + const offset = offsetParam === null ? 0 : Number(offsetParam); + if (!Number.isSafeInteger(offset) || offset < 0 || offset > MAX_OFFSET) { + return apiErrors.badRequest('Invalid offset. Must be a non-negative integer up to 10000.'); + } + + const limit = limitRaw; + const workspace = await db.workspace.findUnique({ where: { id: workspaceId }, include: { @@ -51,6 +69,8 @@ export async function GET(request: NextRequest, { params }: RouteParams) { }, projects: { orderBy: { updatedAt: 'desc' }, + skip: offset, + take: limit, include: { _count: { select: { videos: true, members: true } }, }, diff --git a/app/api/workspaces/route.ts b/app/api/workspaces/route.ts index 2216236..d5f77e7 100644 --- a/app/api/workspaces/route.ts +++ b/app/api/workspaces/route.ts @@ -5,30 +5,70 @@ import { rateLimit } from '@/lib/rate-limit'; import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; // GET /api/workspaces - List all workspaces for the authenticated user -export async function GET() { +export async function GET(request: NextRequest) { try { const session = await auth(); + const MAX_LIMIT = 100; + const MAX_PAGE = 1000; + const MAX_OFFSET = 10000; if (!session?.user?.id) { return apiErrors.unauthorized(); } - // Get workspaces where user is owner OR a member - const workspaces = await db.workspace.findMany({ - where: { - OR: [ - { ownerId: session.user.id }, - { members: { some: { userId: session.user.id } } }, - ], - }, - include: { - owner: { select: { id: true, name: true, image: true } }, - _count: { select: { projects: true, members: true } }, - }, - orderBy: { updatedAt: 'desc' }, - }); + const searchParams = request.nextUrl.searchParams; + const pageParam = searchParams.get('page'); + const limitParam = searchParams.get('limit'); - const response = successResponse({ workspaces }); + const pageRaw = pageParam === null ? 1 : Number(pageParam); + if (!Number.isSafeInteger(pageRaw) || pageRaw < 1 || pageRaw > MAX_PAGE) { + return apiErrors.badRequest('Invalid page. Must be a positive integer.'); + } + + const limitRaw = limitParam === null ? 20 : Number(limitParam); + if (!Number.isSafeInteger(limitRaw) || limitRaw < 1 || limitRaw > MAX_LIMIT) { + return apiErrors.badRequest('Invalid limit. Must be a positive integer between 1 and 100.'); + } + + const page = pageRaw; + const limit = limitRaw; + const skip = (page - 1) * limit; + if (!Number.isSafeInteger(skip) || skip > MAX_OFFSET) { + return apiErrors.badRequest('Invalid page range. Offset must be 10000 or less.'); + } + + const where = { + OR: [ + { ownerId: session.user.id }, + { members: { some: { userId: session.user.id } } }, + ], + }; + + // Get workspaces where user is owner OR a member + const [workspaces, total] = await Promise.all([ + db.workspace.findMany({ + where, + include: { + owner: { select: { id: true, name: true, image: true } }, + _count: { select: { projects: true, members: true } }, + }, + orderBy: { updatedAt: 'desc' }, + skip, + take: limit, + }), + db.workspace.count({ where }), + ]); + + const response = successResponse( + { workspaces }, + 200, + { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + } + ); return withCacheControl(response, 'private, max-age=60, stale-while-revalidate=120'); } catch (error) { console.error('Error fetching workspaces:', error);