feat(access-control): implement project access control for audio and image uploads

This commit is contained in:
Yusuf İpek
2026-04-10 21:27:52 +03:00
parent c92181b90f
commit 3d0e430230
3 changed files with 126 additions and 2 deletions
+64 -1
View File
@@ -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 { apiErrors } from '@/lib/api-response';
import { proxyR2MediaObject } from '@/lib/r2-media-proxy'; import { proxyR2MediaObject } from '@/lib/r2-media-proxy';
import { logError } from '@/lib/logger'; import { logError } from '@/lib/logger';
@@ -20,7 +25,7 @@ function getContentType(filename: string): string {
} }
export async function GET( export async function GET(
request: Request, request: NextRequest,
{ params }: { params: Promise<{ filename: string }> } { params }: { params: Promise<{ filename: string }> }
) { ) {
try { try {
@@ -31,12 +36,70 @@ export async function GET(
return apiErrors.badRequest('Invalid filename'); 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}`; const key = `voice/${filename}`;
return proxyR2MediaObject({ return proxyR2MediaObject({
request, request,
key, key,
fallbackContentType: getContentType(filename), fallbackContentType: getContentType(filename),
cacheControl: 'private, no-store', cacheControl: 'private, no-store',
extraHeaders: {
'X-Content-Type-Options': 'nosniff',
'Content-Security-Policy': "default-src 'none'; sandbox",
},
internalErrorMessage: 'Failed to retrieve audio', internalErrorMessage: 'Failed to retrieve audio',
}); });
} catch (error: unknown) { } catch (error: unknown) {
+60 -1
View File
@@ -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 { apiErrors } from '@/lib/api-response';
import { proxyR2MediaObject } from '@/lib/r2-media-proxy'; import { proxyR2MediaObject } from '@/lib/r2-media-proxy';
import { logError } from '@/lib/logger'; import { logError } from '@/lib/logger';
@@ -18,7 +23,7 @@ function getContentType(filename: string): string {
} }
export async function GET( export async function GET(
request: Request, request: NextRequest,
{ params }: { params: Promise<{ filename: string }> } { params }: { params: Promise<{ filename: string }> }
) { ) {
try { try {
@@ -29,6 +34,60 @@ export async function GET(
return apiErrors.badRequest('Invalid filename'); 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}`; const key = `images/${filename}`;
return proxyR2MediaObject({ return proxyR2MediaObject({
request, request,
+2
View File
@@ -466,6 +466,8 @@ model Comment {
@@index([guestIdentityId]) @@index([guestIdentityId])
@@index([timestamp]) @@index([timestamp])
@@index([tagId]) @@index([tagId])
@@unique([imageUrl])
@@unique([voiceUrl])
@@index([versionId, isResolved, timestamp]) @@index([versionId, isResolved, timestamp])
@@index([versionId, parentId, createdAt]) @@index([versionId, parentId, createdAt])
@@map("comments") @@map("comments")