diff --git a/app/api/upload/audio/[filename]/route.ts b/app/api/upload/audio/[filename]/route.ts index 54edca7..065771c 100644 --- a/app/api/upload/audio/[filename]/route.ts +++ b/app/api/upload/audio/[filename]/route.ts @@ -1,3 +1,8 @@ +import { NextRequest } from 'next/server'; +import { auth, checkProjectAccess } from '@/lib/auth'; +import { db } from '@/lib/db'; +import { validateShareLinkAccess } from '@/lib/share-links'; +import { getShareSessionFromRequest } from '@/lib/share-session'; import { apiErrors } from '@/lib/api-response'; import { proxyR2MediaObject } from '@/lib/r2-media-proxy'; import { logError } from '@/lib/logger'; @@ -20,7 +25,7 @@ function getContentType(filename: string): string { } export async function GET( - request: Request, + request: NextRequest, { params }: { params: Promise<{ filename: string }> } ) { try { @@ -31,12 +36,70 @@ export async function GET( return apiErrors.badRequest('Invalid filename'); } + // Parallelize the DB lookup and session check to narrow the timing delta + // between "asset not found" and "asset found, access denied" responses. + const voiceUrl = `/api/upload/audio/${filename}`; + const [comment, session] = await Promise.all([ + db.comment.findFirst({ + where: { voiceUrl }, + select: { + version: { + select: { + video: { + select: { + id: true, + projectId: true, + project: { + select: { + id: true, + ownerId: true, + workspaceId: true, + visibility: true, + }, + }, + }, + }, + }, + }, + }, + }), + auth(), + ]); + + if (!comment) { + return apiErrors.forbidden('Access denied'); + } + + const { video } = comment.version; + const access = await checkProjectAccess(video.project, session?.user?.id); + + if (!access.hasAccess) { + const shareSession = getShareSessionFromRequest(request, video.id); + const shareAccess = shareSession + ? await validateShareLinkAccess({ + token: shareSession.token, + projectId: video.projectId, + videoId: video.id, + requiredPermission: 'VIEW', + passwordVerified: shareSession.passwordVerified, + }) + : null; + + if (!shareAccess?.hasAccess) { + return apiErrors.forbidden('Access denied'); + } + } + const key = `voice/${filename}`; return proxyR2MediaObject({ request, key, fallbackContentType: getContentType(filename), cacheControl: 'private, no-store', + extraHeaders: { + 'X-Content-Type-Options': 'nosniff', + 'Content-Security-Policy': "default-src 'none'; sandbox", + }, internalErrorMessage: 'Failed to retrieve audio', }); } catch (error: unknown) { diff --git a/app/api/upload/image/[filename]/route.ts b/app/api/upload/image/[filename]/route.ts index 7b41bbd..272bfcf 100644 --- a/app/api/upload/image/[filename]/route.ts +++ b/app/api/upload/image/[filename]/route.ts @@ -1,3 +1,8 @@ +import { NextRequest } from 'next/server'; +import { auth, checkProjectAccess } from '@/lib/auth'; +import { db } from '@/lib/db'; +import { validateShareLinkAccess } from '@/lib/share-links'; +import { getShareSessionFromRequest } from '@/lib/share-session'; import { apiErrors } from '@/lib/api-response'; import { proxyR2MediaObject } from '@/lib/r2-media-proxy'; import { logError } from '@/lib/logger'; @@ -18,7 +23,7 @@ function getContentType(filename: string): string { } export async function GET( - request: Request, + request: NextRequest, { params }: { params: Promise<{ filename: string }> } ) { try { @@ -29,6 +34,60 @@ export async function GET( return apiErrors.badRequest('Invalid filename'); } + // Parallelize the DB lookup and session check to narrow the timing delta + // between "asset not found" and "asset found, access denied" responses. + const imageUrl = `/api/upload/image/${filename}`; + const [comment, session] = await Promise.all([ + db.comment.findFirst({ + where: { imageUrl }, + select: { + version: { + select: { + video: { + select: { + id: true, + projectId: true, + project: { + select: { + id: true, + ownerId: true, + workspaceId: true, + visibility: true, + }, + }, + }, + }, + }, + }, + }, + }), + auth(), + ]); + + if (!comment) { + return apiErrors.forbidden('Access denied'); + } + + const { video } = comment.version; + const access = await checkProjectAccess(video.project, session?.user?.id); + + if (!access.hasAccess) { + const shareSession = getShareSessionFromRequest(request, video.id); + const shareAccess = shareSession + ? await validateShareLinkAccess({ + token: shareSession.token, + projectId: video.projectId, + videoId: video.id, + requiredPermission: 'VIEW', + passwordVerified: shareSession.passwordVerified, + }) + : null; + + if (!shareAccess?.hasAccess) { + return apiErrors.forbidden('Access denied'); + } + } + const key = `images/${filename}`; return proxyR2MediaObject({ request, diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8008d32..0d57b4e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -466,6 +466,8 @@ model Comment { @@index([guestIdentityId]) @@index([timestamp]) @@index([tagId]) + @@unique([imageUrl]) + @@unique([voiceUrl]) @@index([versionId, isResolved, timestamp]) @@index([versionId, parentId, createdAt]) @@map("comments")