mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
feat: add pagination and strict query validation for workspace/project listings
This commit is contained in:
@@ -44,16 +44,27 @@ function formatRelativeTime(date: Date): string {
|
|||||||
|
|
||||||
interface WorkspacePageProps {
|
interface WorkspacePageProps {
|
||||||
params: Promise<{ workspaceId: string }>;
|
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 session = await auth();
|
||||||
const { workspaceId } = await params;
|
const { workspaceId } = await params;
|
||||||
|
const resolvedSearchParams = await searchParams;
|
||||||
|
const MAX_PAGE = 1000;
|
||||||
|
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
redirect('/login');
|
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({
|
const workspace = await db.workspace.findUnique({
|
||||||
where: { id: workspaceId },
|
where: { id: workspaceId },
|
||||||
include: {
|
include: {
|
||||||
@@ -64,6 +75,8 @@ export default async function WorkspacePage({ params }: WorkspacePageProps) {
|
|||||||
},
|
},
|
||||||
projects: {
|
projects: {
|
||||||
orderBy: { updatedAt: 'desc' },
|
orderBy: { updatedAt: 'desc' },
|
||||||
|
skip,
|
||||||
|
take: pageSize,
|
||||||
include: {
|
include: {
|
||||||
_count: { select: { videos: true, members: true } },
|
_count: { select: { videos: true, members: true } },
|
||||||
},
|
},
|
||||||
@@ -85,6 +98,8 @@ export default async function WorkspacePage({ params }: WorkspacePageProps) {
|
|||||||
redirect('/workspaces');
|
redirect('/workspaces');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(workspace._count.projects / pageSize);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-6 lg:px-8 py-8 w-full">
|
<div className="px-6 lg:px-8 py-8 w-full">
|
||||||
{/* Back & Header */}
|
{/* Back & Header */}
|
||||||
@@ -143,42 +158,65 @@ export default async function WorkspacePage({ params }: WorkspacePageProps) {
|
|||||||
|
|
||||||
{/* Projects Grid */}
|
{/* Projects Grid */}
|
||||||
{workspace.projects.length > 0 ? (
|
{workspace.projects.length > 0 ? (
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<>
|
||||||
{workspace.projects.map((project: (typeof workspace.projects)[number]) => (
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
<Link key={project.id} href={`/projects/${project.id}`}>
|
{workspace.projects.map((project: (typeof workspace.projects)[number]) => (
|
||||||
<Card className="h-full transition-colors hover:bg-accent/50 cursor-pointer">
|
<Link key={project.id} href={`/projects/${project.id}`}>
|
||||||
<CardHeader>
|
<Card className="h-full transition-colors hover:bg-accent/50 cursor-pointer">
|
||||||
<div className="flex items-start justify-between">
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<div className="flex items-start justify-between">
|
||||||
<FolderOpen className="h-5 w-5 text-primary" />
|
<CardTitle className="flex items-center gap-2">
|
||||||
{project.name}
|
<FolderOpen className="h-5 w-5 text-primary" />
|
||||||
</CardTitle>
|
{project.name}
|
||||||
<Badge variant="outline" className="flex items-center gap-1">
|
</CardTitle>
|
||||||
<VisibilityIcon visibility={project.visibility} />
|
<Badge variant="outline" className="flex items-center gap-1">
|
||||||
{project.visibility.toLowerCase()}
|
<VisibilityIcon visibility={project.visibility} />
|
||||||
</Badge>
|
{project.visibility.toLowerCase()}
|
||||||
</div>
|
</Badge>
|
||||||
<CardDescription className="line-clamp-2">
|
</div>
|
||||||
{project.description || 'No description'}
|
<CardDescription className="line-clamp-2">
|
||||||
</CardDescription>
|
{project.description || 'No description'}
|
||||||
</CardHeader>
|
</CardDescription>
|
||||||
<CardContent>
|
</CardHeader>
|
||||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
<CardContent>
|
||||||
<span className="flex items-center gap-1">
|
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||||
<Clock className="h-3.5 w-3.5" />
|
<span className="flex items-center gap-1">
|
||||||
{formatRelativeTime(project.updatedAt)}
|
<Clock className="h-3.5 w-3.5" />
|
||||||
</span>
|
{formatRelativeTime(project.updatedAt)}
|
||||||
<span className="flex items-center gap-1">
|
</span>
|
||||||
<Users className="h-3.5 w-3.5" />
|
<span className="flex items-center gap-1">
|
||||||
{project._count.members + 1}
|
<Users className="h-3.5 w-3.5" />
|
||||||
</span>
|
{project._count.members + 1}
|
||||||
<span>{project._count.videos} videos</span>
|
</span>
|
||||||
</div>
|
<span>{project._count.videos} videos</span>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</CardContent>
|
||||||
</Link>
|
</Card>
|
||||||
))}
|
</Link>
|
||||||
</div>
|
))}
|
||||||
|
</div>
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="mt-8 flex items-center justify-end space-x-2">
|
||||||
|
<Button variant="outline" size="sm" disabled={page <= 1} asChild={page > 1}>
|
||||||
|
{page > 1 ? (
|
||||||
|
<Link href={`/workspaces/${workspaceId}?page=${page - 1}`}>Previous</Link>
|
||||||
|
) : (
|
||||||
|
'Previous'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
Page {page} of {totalPages}
|
||||||
|
</span>
|
||||||
|
<Button variant="outline" size="sm" disabled={page >= totalPages} asChild={page < totalPages}>
|
||||||
|
{page < totalPages ? (
|
||||||
|
<Link href={`/workspaces/${workspaceId}?page=${page + 1}`}>Next</Link>
|
||||||
|
) : (
|
||||||
|
'Next'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Card className="border-dashed">
|
<Card className="border-dashed">
|
||||||
<CardContent className="flex flex-col items-center justify-center py-16">
|
<CardContent className="flex flex-col items-center justify-center py-16">
|
||||||
|
|||||||
@@ -12,11 +12,25 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
|||||||
try {
|
try {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const { projectId } = await params;
|
const { projectId } = await params;
|
||||||
|
const MAX_LIMIT = 100;
|
||||||
|
const MAX_OFFSET = 10000;
|
||||||
|
|
||||||
// Parse pagination params
|
// Parse pagination params
|
||||||
const searchParams = request.nextUrl.searchParams;
|
const searchParams = request.nextUrl.searchParams;
|
||||||
const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 100);
|
const limitParam = searchParams.get('limit');
|
||||||
const offset = Math.max(0, parseInt(searchParams.get('offset') || '0'));
|
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({
|
const project = await db.project.findUnique({
|
||||||
where: { id: projectId },
|
where: { id: projectId },
|
||||||
|
|||||||
@@ -10,16 +10,35 @@ import { DEFAULT_COMMENT_TAGS } from '@/lib/comment-tags';
|
|||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
const MAX_LIMIT = 100;
|
||||||
|
const MAX_PAGE = 1000;
|
||||||
|
const MAX_OFFSET = 10000;
|
||||||
|
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return apiErrors.unauthorized();
|
return apiErrors.unauthorized();
|
||||||
}
|
}
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const page = parseInt(searchParams.get('page') || '1');
|
const pageParam = searchParams.get('page');
|
||||||
const limit = parseInt(searchParams.get('limit') || '10');
|
const limitParam = searchParams.get('limit');
|
||||||
const workspaceId = searchParams.get('workspaceId');
|
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;
|
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
|
// Build base filter: user is owner OR a member
|
||||||
const baseFilter: Record<string, unknown> = {
|
const baseFilter: Record<string, unknown> = {
|
||||||
|
|||||||
@@ -12,11 +12,35 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
|||||||
try {
|
try {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const { workspaceId } = await params;
|
const { workspaceId } = await params;
|
||||||
|
const MAX_LIMIT = 100;
|
||||||
|
const MAX_PAGE = 1000;
|
||||||
|
const MAX_OFFSET = 10000;
|
||||||
|
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return apiErrors.unauthorized();
|
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({
|
const workspace = await db.workspace.findUnique({
|
||||||
where: { id: workspaceId },
|
where: { id: workspaceId },
|
||||||
include: {
|
include: {
|
||||||
@@ -35,13 +59,20 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
|||||||
return apiErrors.forbidden('Access denied');
|
return apiErrors.forbidden('Access denied');
|
||||||
}
|
}
|
||||||
|
|
||||||
const members = await db.workspaceMember.findMany({
|
const [members, total] = await Promise.all([
|
||||||
where: { workspaceId },
|
db.workspaceMember.findMany({
|
||||||
include: {
|
where: { workspaceId },
|
||||||
user: { select: { id: true, name: true, email: true, image: true } },
|
include: {
|
||||||
},
|
user: { select: { id: true, name: true, email: true, image: true } },
|
||||||
orderBy: { createdAt: 'asc' },
|
},
|
||||||
});
|
orderBy: { createdAt: 'asc' },
|
||||||
|
skip,
|
||||||
|
take: limit,
|
||||||
|
}),
|
||||||
|
db.workspaceMember.count({
|
||||||
|
where: { workspaceId },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
// Include the owner as well
|
// Include the owner as well
|
||||||
const owner = await db.user.findUnique({
|
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 },
|
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');
|
return withCacheControl(response, 'private, max-age=60, stale-while-revalidate=120');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching workspace members:', error);
|
console.error('Error fetching workspace members:', error);
|
||||||
|
|||||||
@@ -35,11 +35,29 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
|||||||
try {
|
try {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const { workspaceId } = await params;
|
const { workspaceId } = await params;
|
||||||
|
const MAX_LIMIT = 100;
|
||||||
|
const MAX_OFFSET = 10000;
|
||||||
|
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return apiErrors.unauthorized();
|
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({
|
const workspace = await db.workspace.findUnique({
|
||||||
where: { id: workspaceId },
|
where: { id: workspaceId },
|
||||||
include: {
|
include: {
|
||||||
@@ -51,6 +69,8 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
|||||||
},
|
},
|
||||||
projects: {
|
projects: {
|
||||||
orderBy: { updatedAt: 'desc' },
|
orderBy: { updatedAt: 'desc' },
|
||||||
|
skip: offset,
|
||||||
|
take: limit,
|
||||||
include: {
|
include: {
|
||||||
_count: { select: { videos: true, members: true } },
|
_count: { select: { videos: true, members: true } },
|
||||||
},
|
},
|
||||||
|
|||||||
+56
-16
@@ -5,30 +5,70 @@ import { rateLimit } from '@/lib/rate-limit';
|
|||||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||||
|
|
||||||
// GET /api/workspaces - List all workspaces for the authenticated user
|
// GET /api/workspaces - List all workspaces for the authenticated user
|
||||||
export async function GET() {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
const MAX_LIMIT = 100;
|
||||||
|
const MAX_PAGE = 1000;
|
||||||
|
const MAX_OFFSET = 10000;
|
||||||
|
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return apiErrors.unauthorized();
|
return apiErrors.unauthorized();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get workspaces where user is owner OR a member
|
const searchParams = request.nextUrl.searchParams;
|
||||||
const workspaces = await db.workspace.findMany({
|
const pageParam = searchParams.get('page');
|
||||||
where: {
|
const limitParam = searchParams.get('limit');
|
||||||
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 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');
|
return withCacheControl(response, 'private, max-age=60, stale-while-revalidate=120');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching workspaces:', error);
|
console.error('Error fetching workspaces:', error);
|
||||||
|
|||||||
Reference in New Issue
Block a user