From 062a0aec837d17feabf0819412da1946d3d2df79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20=C4=B0pek?= Date: Sun, 8 Feb 2026 14:17:12 +0300 Subject: [PATCH] refactor(auth): Centralize project access checks - Move `checkProjectAccess` function to `lib/auth.ts` - Consolidate project access logic into a single utility - Simplify access checks in video API routes - Remove redundant project member inclusions from queries --- .../[projectId]/videos/[videoId]/route.ts | 14 ++--- app/api/watch/[videoId]/route.ts | 37 ++----------- lib/auth.ts | 53 +++++++++++++++++++ 3 files changed, 61 insertions(+), 43 deletions(-) diff --git a/app/api/projects/[projectId]/videos/[videoId]/route.ts b/app/api/projects/[projectId]/videos/[videoId]/route.ts index 8df61ba..ab525b7 100644 --- a/app/api/projects/[projectId]/videos/[videoId]/route.ts +++ b/app/api/projects/[projectId]/videos/[videoId]/route.ts @@ -1,7 +1,7 @@ import { NextRequest } from 'next/server'; import { revalidatePath } from 'next/cache'; import { db } from '@/lib/db'; -import { auth } from '@/lib/auth'; +import { auth, checkProjectAccess } from '@/lib/auth'; import { ProjectMemberRole, WorkspaceMemberRole } from '@prisma/client'; import { rateLimit } from '@/lib/rate-limit'; import { cleanupVideoVoiceFiles } from '@/lib/r2-cleanup'; @@ -18,9 +18,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) { const video = await db.video.findFirst({ where: { id: videoId, projectId }, include: { - project: { - include: { members: { where: { userId: session?.user?.id || '' } } }, - }, + project: true, versions: { orderBy: { versionNumber: 'desc' }, include: { @@ -49,12 +47,10 @@ export async function GET(request: NextRequest, { params }: RouteParams) { return apiErrors.notFound('Video'); } - // Check access - const isOwner = session?.user?.id === video.project.ownerId; - const isMember = video.project.members.length > 0; - const isPublic = video.project.visibility === 'PUBLIC'; + // Check access including workspace membership + const access = await checkProjectAccess(video.project, session?.user?.id); - if (!isOwner && !isMember && !isPublic) { + if (!access.hasAccess) { return apiErrors.forbidden('Access denied'); } diff --git a/app/api/watch/[videoId]/route.ts b/app/api/watch/[videoId]/route.ts index a5f173c..b6f24c3 100644 --- a/app/api/watch/[videoId]/route.ts +++ b/app/api/watch/[videoId]/route.ts @@ -1,37 +1,10 @@ import { NextRequest } from 'next/server'; import { db } from '@/lib/db'; -import { auth } from '@/lib/auth'; +import { auth, checkProjectAccess } from '@/lib/auth'; import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; type RouteParams = { params: Promise<{ videoId: string }> }; -// Helper to check project access including workspace membership -async function checkProjectAccess(project: { ownerId: string; workspaceId: string; visibility: string; members: { userId: string }[] }, userId: string | undefined) { - const isOwner = userId === project.ownerId; - const isMember = project.members.some(m => m.userId === userId); - const isPublic = project.visibility === 'PUBLIC'; - - // Check workspace membership for access - let isWorkspaceMember = false; - if (!isOwner && !isMember && !isPublic && userId) { - const wsMember = await db.workspaceMember.findUnique({ - where: { - workspaceId_userId: { - workspaceId: project.workspaceId, - userId: userId, - }, - }, - }); - const wsOwner = await db.workspace.findUnique({ - where: { id: project.workspaceId }, - select: { ownerId: true }, - }); - isWorkspaceMember = !!wsMember || wsOwner?.ownerId === userId; - } - - return { isOwner, isMember, isPublic, isWorkspaceMember, hasAccess: isOwner || isMember || isPublic || isWorkspaceMember }; -} - // GET /api/watch/[videoId] - Public watch endpoint (no projectId needed) export async function GET(request: NextRequest, { params }: RouteParams) { try { @@ -41,11 +14,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) { const video = await db.video.findUnique({ where: { id: videoId }, include: { - project: { - include: { - members: { where: { userId: session?.user?.id || '' } }, - }, - }, + project: true, versions: { orderBy: { versionNumber: 'desc' }, include: { @@ -92,7 +61,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) { visibility: project.visibility, }, isAuthenticated: !!session?.user?.id, - canComment: access.isOwner || access.isMember || access.isPublic || access.isWorkspaceMember, + canComment: access.hasAccess, }); return withCacheControl(response, 'private, no-cache'); diff --git a/lib/auth.ts b/lib/auth.ts index 62cd51f..dad77bf 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -2,6 +2,7 @@ import NextAuth from 'next-auth'; import Credentials from 'next-auth/providers/credentials'; import bcrypt from 'bcryptjs'; import { db } from '@/lib/db'; +import { ProjectMemberRole, WorkspaceMemberRole } from '@prisma/client'; // Dummy hash for timing-safe comparison when user doesn't exist // This prevents user enumeration via timing attacks @@ -72,3 +73,55 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ }, }, }); + +// Helper to check project access including workspace membership +export async function checkProjectAccess( + project: { id: string; ownerId: string; workspaceId: string; visibility: string }, + userId: string | undefined +) { + const isOwner = userId === project.ownerId; + const isPublic = project.visibility === 'PUBLIC'; + + // Get project membership + const projectMember = userId + ? await db.projectMember.findUnique({ + where: { projectId_userId: { projectId: project.id, userId } }, + }) + : null; + const isProjectMember = !!projectMember; + const isProjectAdmin = projectMember?.role === ProjectMemberRole.ADMIN; + + // Check workspace membership + let workspaceRole: string | null = null; + if (!isOwner && !isProjectMember && userId) { + const wsMember = await db.workspaceMember.findUnique({ + where: { workspaceId_userId: { workspaceId: project.workspaceId, userId } }, + }); + const wsOwner = await db.workspace.findUnique({ + where: { id: project.workspaceId }, + select: { ownerId: true }, + }); + if (wsOwner?.ownerId === userId) { + workspaceRole = 'OWNER'; + } else if (wsMember) { + workspaceRole = wsMember.role; + } + } + const isWorkspaceMember = !!workspaceRole; + const isWorkspaceAdmin = workspaceRole === WorkspaceMemberRole.ADMIN || workspaceRole === 'OWNER'; + + const hasAccess = isOwner || isProjectMember || isPublic || isWorkspaceMember; + const canEdit = isOwner || isProjectAdmin || isWorkspaceAdmin; + const canDelete = isOwner || workspaceRole === 'OWNER'; + + return { + isOwner, + isProjectMember, + isProjectAdmin, + isWorkspaceMember, + isWorkspaceAdmin, + hasAccess, + canEdit, + canDelete, + }; +}