mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
feat(access-control): implement project access control for audio and image uploads
This commit is contained in:
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user