mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
Add guest upload tokens and share-session aware permissions
This commit is contained in:
@@ -4,7 +4,10 @@ import { auth } from '@/lib/auth';
|
||||
import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
|
||||
import { DeleteObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { rateLimit } from '@/lib/rate-limit';
|
||||
import { validateShareLinkAccess } from '@/lib/share-links';
|
||||
import { getShareSessionFromRequest } from '@/lib/share-session';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
import { getGuestIdentityFromRequest } from '@/lib/guest-identity';
|
||||
|
||||
type RouteParams = { params: Promise<{ commentId: string }> };
|
||||
|
||||
@@ -125,10 +128,8 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||
|
||||
const session = await auth();
|
||||
const { commentId } = await params;
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return apiErrors.unauthorized();
|
||||
}
|
||||
const body = await request.json();
|
||||
const { content, isResolved, tagId, annotationData } = body;
|
||||
|
||||
const comment = await db.comment.findUnique({
|
||||
where: { id: commentId },
|
||||
@@ -138,9 +139,7 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||
video: {
|
||||
include: {
|
||||
project: {
|
||||
include: {
|
||||
members: { where: { userId: session.user.id } },
|
||||
},
|
||||
include: { members: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -154,18 +153,25 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||
}
|
||||
|
||||
const project = comment.version.video.project;
|
||||
const isOwner = project.ownerId === session.user.id;
|
||||
const isAuthor = comment.authorId === session.user.id;
|
||||
const isMember = project.members.length > 0;
|
||||
const userId = session?.user?.id ?? null;
|
||||
const isOwner = userId === project.ownerId;
|
||||
const isAuthor = !!userId && comment.authorId === userId;
|
||||
const isMember = !!userId && project.members.some((member) => member.userId === userId);
|
||||
const guestIdentityId = !userId ? getGuestIdentityFromRequest(request) : null;
|
||||
const isGuestAuthor = !userId
|
||||
&& !comment.authorId
|
||||
&& !!comment.guestIdentityId
|
||||
&& guestIdentityId === comment.guestIdentityId;
|
||||
const canEditOwnContent = isAuthor || isGuestAuthor;
|
||||
|
||||
// Check workspace membership for resolve permissions
|
||||
let isWorkspaceMember = false;
|
||||
if (!isOwner && !isMember && session.user.id) {
|
||||
if (!isOwner && !isMember && userId) {
|
||||
const wsMember = await db.workspaceMember.findUnique({
|
||||
where: {
|
||||
workspaceId_userId: {
|
||||
workspaceId: project.workspaceId,
|
||||
userId: session.user.id,
|
||||
userId,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -173,19 +179,33 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||
where: { id: project.workspaceId },
|
||||
select: { ownerId: true },
|
||||
});
|
||||
isWorkspaceMember = !!wsMember || wsOwner?.ownerId === session.user.id;
|
||||
isWorkspaceMember = !!wsMember || wsOwner?.ownerId === userId;
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { content, isResolved, tagId, annotationData } = body;
|
||||
if (!userId && !isGuestAuthor) {
|
||||
const shareSession = getShareSessionFromRequest(request, comment.version.video.id);
|
||||
const shareAccess = shareSession
|
||||
? await validateShareLinkAccess({
|
||||
token: shareSession.token,
|
||||
projectId: project.id,
|
||||
videoId: comment.version.video.id,
|
||||
requiredPermission: 'COMMENT',
|
||||
passwordVerified: shareSession.passwordVerified,
|
||||
})
|
||||
: { hasAccess: false, canComment: false, canDownload: false, allowGuests: false, requiresPassword: false };
|
||||
const hasGuestAccess = project.visibility === 'PUBLIC' || (shareAccess.canComment && shareAccess.allowGuests);
|
||||
if (!hasGuestAccess) {
|
||||
return apiErrors.forbidden('Access denied');
|
||||
}
|
||||
}
|
||||
|
||||
// Only author can edit content or tag
|
||||
if ((content !== undefined || tagId !== undefined || annotationData !== undefined) && !isAuthor) {
|
||||
if ((content !== undefined || tagId !== undefined || annotationData !== undefined) && !canEditOwnContent) {
|
||||
return apiErrors.forbidden('Only the author can edit comment content');
|
||||
}
|
||||
|
||||
// Owner, author, members, or workspace members can resolve/unresolve
|
||||
if (isResolved !== undefined && !isOwner && !isAuthor && !isMember && !isWorkspaceMember) {
|
||||
if (isResolved !== undefined && !isOwner && !canEditOwnContent && !isMember && !isWorkspaceMember) {
|
||||
return apiErrors.forbidden('Access denied');
|
||||
}
|
||||
|
||||
@@ -213,7 +233,26 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||
},
|
||||
});
|
||||
|
||||
const response = successResponse(updatedComment);
|
||||
const { guestIdentityId: _updatedGuestIdentityId, ...updatedCommentData } = updatedComment;
|
||||
const response = successResponse({
|
||||
...updatedCommentData,
|
||||
canEdit: canEditOwnContent,
|
||||
canDelete: canEditOwnContent || isOwner,
|
||||
replies: updatedComment.replies.map((reply) => {
|
||||
const canEditReply = !!userId
|
||||
? reply.authorId === userId
|
||||
: !!guestIdentityId
|
||||
&& !reply.authorId
|
||||
&& !!reply.guestIdentityId
|
||||
&& reply.guestIdentityId === guestIdentityId;
|
||||
const { guestIdentityId: _replyGuestIdentityId, ...replyData } = reply;
|
||||
return {
|
||||
...replyData,
|
||||
canEdit: canEditReply,
|
||||
canDelete: canEditReply || isOwner,
|
||||
};
|
||||
}),
|
||||
});
|
||||
return withCacheControl(response, 'private, no-store');
|
||||
} catch (error) {
|
||||
console.error('Error updating comment:', error);
|
||||
@@ -230,13 +269,18 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
const session = await auth();
|
||||
const { commentId } = await params;
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return apiErrors.unauthorized();
|
||||
}
|
||||
|
||||
const comment = await db.comment.findUnique({
|
||||
where: { id: commentId },
|
||||
include: {
|
||||
version: {
|
||||
include: {
|
||||
video: {
|
||||
include: {
|
||||
project: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
replies: { select: { voiceUrl: true, imageUrl: true } },
|
||||
},
|
||||
});
|
||||
@@ -245,9 +289,37 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
return apiErrors.notFound('Comment');
|
||||
}
|
||||
|
||||
const isAuthor = comment.authorId === session.user.id;
|
||||
const userId = session?.user?.id ?? null;
|
||||
const isAuthor = !!userId && comment.authorId === userId;
|
||||
|
||||
if (!isAuthor) {
|
||||
let canDeleteOwnComment = isAuthor;
|
||||
if (!userId) {
|
||||
const guestIdentityId = getGuestIdentityFromRequest(request);
|
||||
const isGuestAuthor = !comment.authorId
|
||||
&& !!comment.guestIdentityId
|
||||
&& guestIdentityId === comment.guestIdentityId;
|
||||
|
||||
if (isGuestAuthor) {
|
||||
const project = comment.version.video.project;
|
||||
const shareSession = getShareSessionFromRequest(request, comment.version.video.id);
|
||||
const shareAccess = shareSession
|
||||
? await validateShareLinkAccess({
|
||||
token: shareSession.token,
|
||||
projectId: project.id,
|
||||
videoId: comment.version.video.id,
|
||||
requiredPermission: 'COMMENT',
|
||||
passwordVerified: shareSession.passwordVerified,
|
||||
})
|
||||
: { hasAccess: false, canComment: false, canDownload: false, allowGuests: false, requiresPassword: false };
|
||||
const hasGuestAccess = project.visibility === 'PUBLIC' || (shareAccess.canComment && shareAccess.allowGuests);
|
||||
if (!hasGuestAccess) {
|
||||
return apiErrors.forbidden('Access denied');
|
||||
}
|
||||
canDeleteOwnComment = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!canDeleteOwnComment) {
|
||||
return apiErrors.forbidden('You can only delete your own comments');
|
||||
}
|
||||
|
||||
|
||||
@@ -2,19 +2,12 @@ import { NextRequest } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { rateLimit } from '@/lib/rate-limit';
|
||||
import { validateShareLinkAccess } from '@/lib/share-links';
|
||||
import { getShareSessionFromRequest } from '@/lib/share-session';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
|
||||
type RouteParams = { params: Promise<{ projectId: string }> };
|
||||
|
||||
// Default tags to create for new projects
|
||||
const DEFAULT_TAGS = [
|
||||
{ name: 'Feedback', color: '#3B82F6', position: 0 },
|
||||
{ name: 'Technical', color: '#EF4444', position: 1 },
|
||||
{ name: 'Creative', color: '#8B5CF6', position: 2 },
|
||||
{ name: 'Approved', color: '#22C55E', position: 3 },
|
||||
{ name: 'Urgent', color: '#F59E0B', position: 4 },
|
||||
];
|
||||
|
||||
// Helper to check project access
|
||||
async function checkProjectAccess(projectId: string, userId: string) {
|
||||
const project = await db.project.findUnique({
|
||||
@@ -51,36 +44,56 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
const session = await auth();
|
||||
const { projectId } = await params;
|
||||
const videoId = request.nextUrl.searchParams.get('videoId');
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return apiErrors.unauthorized();
|
||||
const project = await db.project.findUnique({
|
||||
where: { id: projectId },
|
||||
select: { id: true, visibility: true },
|
||||
});
|
||||
if (!project) return apiErrors.notFound('Project');
|
||||
|
||||
if (session?.user?.id) {
|
||||
const { project: accessibleProject } = await checkProjectAccess(projectId, session.user.id);
|
||||
if (!accessibleProject) {
|
||||
return apiErrors.notFound('Project');
|
||||
}
|
||||
} else {
|
||||
let hasGuestAccess = project.visibility === 'PUBLIC';
|
||||
if (!hasGuestAccess && videoId) {
|
||||
const video = await db.video.findFirst({
|
||||
where: { id: videoId, projectId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (video) {
|
||||
const shareSession = getShareSessionFromRequest(request, video.id);
|
||||
const shareAccess = shareSession
|
||||
? await validateShareLinkAccess({
|
||||
token: shareSession.token,
|
||||
projectId,
|
||||
videoId: video.id,
|
||||
requiredPermission: 'COMMENT',
|
||||
passwordVerified: shareSession.passwordVerified,
|
||||
})
|
||||
: { hasAccess: false, canComment: false, canDownload: false, allowGuests: false, requiresPassword: false };
|
||||
hasGuestAccess = shareAccess.canComment && shareAccess.allowGuests;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasGuestAccess) {
|
||||
return apiErrors.forbidden('Access denied');
|
||||
}
|
||||
}
|
||||
|
||||
const { project } = await checkProjectAccess(projectId, session.user.id);
|
||||
if (!project) {
|
||||
return apiErrors.notFound('Project');
|
||||
}
|
||||
|
||||
let tags = await db.commentTag.findMany({
|
||||
const tags = await db.commentTag.findMany({
|
||||
where: { projectId },
|
||||
orderBy: { position: 'asc' },
|
||||
});
|
||||
|
||||
// Auto-create default tags if none exist (idempotent with skipDuplicates
|
||||
// to handle race conditions from concurrent requests)
|
||||
if (tags.length === 0) {
|
||||
await db.commentTag.createMany({
|
||||
data: DEFAULT_TAGS.map((tag) => ({ ...tag, projectId })),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
tags = await db.commentTag.findMany({
|
||||
where: { projectId },
|
||||
orderBy: { position: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
const response = successResponse(tags);
|
||||
return withCacheControl(response, 'private, max-age=120, stale-while-revalidate=300');
|
||||
const cacheControl = session?.user?.id
|
||||
? 'private, max-age=120, stale-while-revalidate=300'
|
||||
: 'private, no-cache';
|
||||
return withCacheControl(response, cacheControl);
|
||||
} catch (error) {
|
||||
console.error('Error fetching tags:', error);
|
||||
return apiErrors.internalError('Failed to fetch tags');
|
||||
|
||||
@@ -106,6 +106,8 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
isAuthenticated: !!session?.user?.id,
|
||||
currentUserId: session?.user?.id || null,
|
||||
currentUserName: session?.user?.name || null,
|
||||
canDownload: access.hasAccess,
|
||||
canManageTags: access.canEdit,
|
||||
});
|
||||
|
||||
return withCacheControl(response, 'private, no-cache');
|
||||
|
||||
@@ -30,8 +30,27 @@ async function requireShareManagementAccess(projectId: string, videoId: string,
|
||||
return { error: null, video };
|
||||
}
|
||||
|
||||
function resolveShareBaseUrl(request: NextRequest): string {
|
||||
const configuredBaseUrl = process.env.NEXTAUTH_URL ?? process.env.NEXT_PUBLIC_APP_URL;
|
||||
const normalizedConfiguredBaseUrl = configuredBaseUrl?.trim();
|
||||
|
||||
if (normalizedConfiguredBaseUrl) {
|
||||
const withProtocol = /^https?:\/\//i.test(normalizedConfiguredBaseUrl)
|
||||
? normalizedConfiguredBaseUrl
|
||||
: `https://${normalizedConfiguredBaseUrl}`;
|
||||
|
||||
try {
|
||||
return new URL(withProtocol).origin;
|
||||
} catch {
|
||||
// Fallback to request origin when env configuration is invalid.
|
||||
}
|
||||
}
|
||||
|
||||
return request.nextUrl.origin;
|
||||
}
|
||||
|
||||
function buildWatchUrl(request: NextRequest, videoId: string, token: string): string {
|
||||
const url = new URL(`/watch/${videoId}`, request.nextUrl.origin);
|
||||
const url = new URL(`/watch/${videoId}`, resolveShareBaseUrl(request));
|
||||
url.searchParams.set('shareToken', token);
|
||||
return url.toString();
|
||||
}
|
||||
@@ -44,6 +63,7 @@ function serializeShareLink(
|
||||
token: string;
|
||||
permission: string;
|
||||
allowGuests: boolean;
|
||||
allowDownloads: boolean;
|
||||
expiresAt: Date | null;
|
||||
createdAt: Date;
|
||||
passwordHash: string | null;
|
||||
@@ -59,6 +79,7 @@ function serializeShareLink(
|
||||
token: link.token,
|
||||
permission: link.permission,
|
||||
allowGuests: link.allowGuests,
|
||||
allowDownloads: link.allowDownloads,
|
||||
expiresAt: link.expiresAt,
|
||||
createdAt: link.createdAt,
|
||||
hasPassword: !!link.passwordHash,
|
||||
@@ -91,6 +112,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
token: true,
|
||||
permission: true,
|
||||
allowGuests: true,
|
||||
allowDownloads: true,
|
||||
expiresAt: true,
|
||||
createdAt: true,
|
||||
passwordHash: true,
|
||||
@@ -123,6 +145,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const allowGuests = typeof body?.allowGuests === 'boolean' ? body.allowGuests : true;
|
||||
const allowDownloads = typeof body?.allowDownloads === 'boolean' ? body.allowDownloads : false;
|
||||
const password = typeof body?.password === 'string' ? body.password.trim() : '';
|
||||
if (password.length > MAX_SHARE_PASSWORD_LENGTH) {
|
||||
return apiErrors.badRequest(`Password must be ${MAX_SHARE_PASSWORD_LENGTH} characters or fewer`);
|
||||
@@ -135,6 +158,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
token: string;
|
||||
permission: string;
|
||||
allowGuests: boolean;
|
||||
allowDownloads: boolean;
|
||||
expiresAt: Date | null;
|
||||
createdAt: Date;
|
||||
passwordHash: string | null;
|
||||
@@ -158,6 +182,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
data: {
|
||||
token,
|
||||
allowGuests,
|
||||
allowDownloads,
|
||||
passwordHash,
|
||||
expiresAt: null,
|
||||
},
|
||||
@@ -166,6 +191,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
token: true,
|
||||
permission: true,
|
||||
allowGuests: true,
|
||||
allowDownloads: true,
|
||||
expiresAt: true,
|
||||
createdAt: true,
|
||||
passwordHash: true,
|
||||
@@ -180,6 +206,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
videoId,
|
||||
permission: 'COMMENT',
|
||||
allowGuests,
|
||||
allowDownloads,
|
||||
passwordHash,
|
||||
},
|
||||
select: {
|
||||
@@ -187,6 +214,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
token: true,
|
||||
permission: true,
|
||||
allowGuests: true,
|
||||
allowDownloads: true,
|
||||
expiresAt: true,
|
||||
createdAt: true,
|
||||
passwordHash: true,
|
||||
@@ -232,6 +260,7 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const allowGuests = typeof body?.allowGuests === 'boolean' ? body.allowGuests : undefined;
|
||||
const allowDownloads = typeof body?.allowDownloads === 'boolean' ? body.allowDownloads : undefined;
|
||||
const rawPassword = typeof body?.password === 'string' ? body.password : undefined;
|
||||
const clearPassword = body?.clearPassword === true;
|
||||
if (rawPassword !== undefined && rawPassword.length > MAX_SHARE_PASSWORD_LENGTH) {
|
||||
@@ -266,6 +295,7 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
...(allowGuests !== undefined ? { allowGuests } : {}),
|
||||
...(allowDownloads !== undefined ? { allowDownloads } : {}),
|
||||
...(passwordHashUpdate !== undefined ? { passwordHash: passwordHashUpdate } : {}),
|
||||
...(shouldRotateToken ? { token: randomBytes(24).toString('base64url') } : {}),
|
||||
},
|
||||
@@ -274,6 +304,7 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||
token: true,
|
||||
permission: true,
|
||||
allowGuests: true,
|
||||
allowDownloads: true,
|
||||
expiresAt: true,
|
||||
createdAt: true,
|
||||
passwordHash: true,
|
||||
|
||||
+26
-13
@@ -4,6 +4,7 @@ import { auth } from '@/lib/auth';
|
||||
import { ProjectVisibility } from '@prisma/client';
|
||||
import { rateLimit } from '@/lib/rate-limit';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
import { DEFAULT_COMMENT_TAGS } from '@/lib/comment-tags';
|
||||
|
||||
// GET /api/projects - List all projects for the authenticated user
|
||||
export async function GET(request: NextRequest) {
|
||||
@@ -137,19 +138,31 @@ export async function POST(request: NextRequest) {
|
||||
return apiErrors.forbidden('Only workspace owners and admins can create projects');
|
||||
}
|
||||
|
||||
const project = await db.project.create({
|
||||
data: {
|
||||
name: name.trim(),
|
||||
description: description?.trim() || null,
|
||||
slug,
|
||||
visibility: visibility || ProjectVisibility.PRIVATE,
|
||||
ownerId: session.user.id,
|
||||
workspaceId,
|
||||
},
|
||||
include: {
|
||||
owner: { select: { id: true, name: true, image: true } },
|
||||
_count: { select: { videos: true, members: true } },
|
||||
},
|
||||
const project = await db.$transaction(async (tx) => {
|
||||
const createdProject = await tx.project.create({
|
||||
data: {
|
||||
name: name.trim(),
|
||||
description: description?.trim() || null,
|
||||
slug,
|
||||
visibility: visibility || ProjectVisibility.PRIVATE,
|
||||
ownerId: session.user.id,
|
||||
workspaceId,
|
||||
},
|
||||
include: {
|
||||
owner: { select: { id: true, name: true, image: true } },
|
||||
_count: { select: { videos: true, members: true } },
|
||||
},
|
||||
});
|
||||
|
||||
await tx.commentTag.createMany({
|
||||
data: DEFAULT_COMMENT_TAGS.map((tag) => ({
|
||||
...tag,
|
||||
projectId: createdProject.id,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
|
||||
return createdProject;
|
||||
});
|
||||
|
||||
const response = successResponse(project, 201);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
|
||||
import { GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { DeleteObjectCommand, GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { apiErrors } from '@/lib/api-response';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
// Only allow UUID filenames with safe extensions
|
||||
const SAFE_FILENAME = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z0-9]+$/i;
|
||||
@@ -15,6 +16,7 @@ const CONTENT_TYPE_MAP: Record<string, string> = {
|
||||
ogg: 'audio/ogg',
|
||||
wav: 'audio/wav',
|
||||
};
|
||||
const UNATTACHED_UPLOAD_TTL_MS = 15 * 60 * 1000;
|
||||
|
||||
function getContentType(filename: string): string {
|
||||
const ext = filename.split('.').pop()?.toLowerCase() || '';
|
||||
@@ -34,6 +36,7 @@ export async function GET(
|
||||
}
|
||||
|
||||
const key = `voice/${filename}`;
|
||||
const mediaUrl = `/api/upload/audio/${filename}`;
|
||||
|
||||
// Get file metadata to determine content type
|
||||
const headResponse = await r2Client.send(
|
||||
@@ -43,6 +46,23 @@ export async function GET(
|
||||
})
|
||||
);
|
||||
|
||||
const lastModified = headResponse.LastModified;
|
||||
if (lastModified && Date.now() - lastModified.getTime() > UNATTACHED_UPLOAD_TTL_MS) {
|
||||
const referenced = await db.comment.findFirst({
|
||||
where: { voiceUrl: mediaUrl },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!referenced) {
|
||||
await r2Client.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: R2_BUCKET_NAME,
|
||||
Key: key,
|
||||
})
|
||||
).catch(() => undefined);
|
||||
return apiErrors.notFound('File');
|
||||
}
|
||||
}
|
||||
|
||||
// Use the stored content-type or infer from filename extension
|
||||
const contentType = headResponse.ContentType || getContentType(filename);
|
||||
|
||||
@@ -78,7 +98,7 @@ export async function GET(
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Cache-Control': 'public, max-age=31536000, immutable',
|
||||
'Cache-Control': 'private, no-store',
|
||||
'Accept-Ranges': 'bytes',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
import { auth } from '@/lib/auth';
|
||||
import { NextRequest } from 'next/server';
|
||||
import { auth, checkProjectAccess } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
|
||||
import { PutObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { rateLimit } from '@/lib/rate-limit';
|
||||
import { validateShareLinkAccess } from '@/lib/share-links';
|
||||
import { getShareSessionFromRequest } from '@/lib/share-session';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
import {
|
||||
deriveGuestUploadContext,
|
||||
enforceGuestUploadQuota,
|
||||
verifyGuestUploadToken,
|
||||
} from '@/lib/guest-upload-token';
|
||||
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
const ALLOWED_TYPES = ['audio/webm', 'audio/ogg', 'audio/mp4', 'audio/mpeg', 'audio/wav'];
|
||||
|
||||
export async function POST(request: Request) {
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Check Content-Length header BEFORE loading the file
|
||||
const contentLength = request.headers.get('content-length');
|
||||
@@ -23,18 +32,69 @@ export async function POST(request: Request) {
|
||||
const limited = await rateLimit(request, 'voice-upload');
|
||||
if (limited) return limited;
|
||||
|
||||
// Require authentication
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return apiErrors.unauthorized();
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('audio') as File | null;
|
||||
const videoId = formData.get('videoId');
|
||||
const uploadToken = formData.get('uploadToken');
|
||||
|
||||
if (!file) {
|
||||
return apiErrors.badRequest('No audio file provided');
|
||||
}
|
||||
if (typeof videoId !== 'string' || !videoId.trim()) {
|
||||
return apiErrors.badRequest('videoId is required');
|
||||
}
|
||||
|
||||
const safeVideoId = videoId.trim();
|
||||
const video = await db.video.findUnique({
|
||||
where: { id: safeVideoId },
|
||||
include: { project: true },
|
||||
});
|
||||
if (!video) {
|
||||
return apiErrors.notFound('Video');
|
||||
}
|
||||
|
||||
const access = await checkProjectAccess(video.project, session?.user?.id);
|
||||
const shareSession = getShareSessionFromRequest(request, safeVideoId);
|
||||
const shareAccess = shareSession
|
||||
? await validateShareLinkAccess({
|
||||
token: shareSession.token,
|
||||
projectId: video.projectId,
|
||||
videoId: safeVideoId,
|
||||
requiredPermission: 'COMMENT',
|
||||
passwordVerified: shareSession.passwordVerified,
|
||||
})
|
||||
: { hasAccess: false, canComment: false, canDownload: false, allowGuests: false, requiresPassword: false };
|
||||
const canCommentWithMembership = !!session?.user?.id && access.hasAccess;
|
||||
const canCommentWithShareLink = shareAccess.canComment && (session?.user?.id ? true : shareAccess.allowGuests);
|
||||
if (!canCommentWithMembership && !canCommentWithShareLink) {
|
||||
return apiErrors.forbidden('Access denied');
|
||||
}
|
||||
|
||||
if (!session?.user?.id) {
|
||||
if (typeof uploadToken !== 'string' || !uploadToken.trim()) {
|
||||
return apiErrors.badRequest('uploadToken is required for guest uploads');
|
||||
}
|
||||
|
||||
const expectedContext = deriveGuestUploadContext(request, shareSession?.token ?? null);
|
||||
if (!expectedContext) {
|
||||
return apiErrors.forbidden('Missing trusted client IP header');
|
||||
}
|
||||
|
||||
const isValidUploadToken = verifyGuestUploadToken(uploadToken.trim(), {
|
||||
projectId: video.projectId,
|
||||
videoId: safeVideoId,
|
||||
intent: 'audio',
|
||||
context: expectedContext,
|
||||
});
|
||||
if (!isValidUploadToken) {
|
||||
return apiErrors.forbidden('Invalid upload token');
|
||||
}
|
||||
|
||||
const quotaError = await enforceGuestUploadQuota(request, safeVideoId, 'audio', shareSession?.token ?? null);
|
||||
if (quotaError) return quotaError;
|
||||
}
|
||||
|
||||
// Double-check file size (defense in depth - Content-Length can be spoofed)
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
|
||||
import { GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { DeleteObjectCommand, GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { apiErrors } from '@/lib/api-response';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
// Only allow UUID filenames with safe extensions
|
||||
const SAFE_FILENAME = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z0-9]+$/i;
|
||||
@@ -14,6 +15,7 @@ const CONTENT_TYPE_MAP: Record<string, string> = {
|
||||
gif: 'image/gif',
|
||||
svg: 'image/svg+xml',
|
||||
};
|
||||
const UNATTACHED_UPLOAD_TTL_MS = 15 * 60 * 1000;
|
||||
|
||||
function getContentType(filename: string): string {
|
||||
const ext = filename.split('.').pop()?.toLowerCase() || '';
|
||||
@@ -33,6 +35,7 @@ export async function GET(
|
||||
}
|
||||
|
||||
const key = `images/${filename}`;
|
||||
const mediaUrl = `/api/upload/image/${filename}`;
|
||||
|
||||
// Get file metadata to determine content type
|
||||
const headResponse = await r2Client.send(
|
||||
@@ -42,6 +45,23 @@ export async function GET(
|
||||
})
|
||||
);
|
||||
|
||||
const lastModified = headResponse.LastModified;
|
||||
if (lastModified && Date.now() - lastModified.getTime() > UNATTACHED_UPLOAD_TTL_MS) {
|
||||
const referenced = await db.comment.findFirst({
|
||||
where: { imageUrl: mediaUrl },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!referenced) {
|
||||
await r2Client.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: R2_BUCKET_NAME,
|
||||
Key: key,
|
||||
})
|
||||
).catch(() => undefined);
|
||||
return apiErrors.notFound('File');
|
||||
}
|
||||
}
|
||||
|
||||
const contentType = headResponse.ContentType || getContentType(filename);
|
||||
|
||||
const objectResponse = await r2Client.send(
|
||||
@@ -72,7 +92,7 @@ export async function GET(
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Cache-Control': 'public, max-age=31536000, immutable',
|
||||
'Cache-Control': 'private, no-store',
|
||||
'Accept-Ranges': 'bytes',
|
||||
},
|
||||
});
|
||||
@@ -85,4 +105,3 @@ export async function GET(
|
||||
return apiErrors.internalError('Failed to retrieve image');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import { auth } from '@/lib/auth';
|
||||
import { NextRequest } from 'next/server';
|
||||
import { auth, checkProjectAccess } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
|
||||
import { PutObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { rateLimit } from '@/lib/rate-limit';
|
||||
import { validateShareLinkAccess } from '@/lib/share-links';
|
||||
import { getShareSessionFromRequest } from '@/lib/share-session';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
import {
|
||||
deriveGuestUploadContext,
|
||||
enforceGuestUploadQuota,
|
||||
verifyGuestUploadToken,
|
||||
} from '@/lib/guest-upload-token';
|
||||
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
const ALLOWED_TYPES = [
|
||||
@@ -11,10 +20,9 @@ const ALLOWED_TYPES = [
|
||||
'image/png',
|
||||
'image/webp',
|
||||
'image/gif',
|
||||
'image/svg+xml'
|
||||
];
|
||||
|
||||
export async function POST(request: Request) {
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Check Content-Length header BEFORE loading the file
|
||||
const contentLength = request.headers.get('content-length');
|
||||
@@ -29,18 +37,69 @@ export async function POST(request: Request) {
|
||||
const limited = await rateLimit(request, 'image-upload');
|
||||
if (limited) return limited;
|
||||
|
||||
// Require authentication
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return apiErrors.unauthorized();
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('image') as File | null;
|
||||
const videoId = formData.get('videoId');
|
||||
const uploadToken = formData.get('uploadToken');
|
||||
|
||||
if (!file) {
|
||||
return apiErrors.badRequest('No image file provided');
|
||||
}
|
||||
if (typeof videoId !== 'string' || !videoId.trim()) {
|
||||
return apiErrors.badRequest('videoId is required');
|
||||
}
|
||||
|
||||
const safeVideoId = videoId.trim();
|
||||
const video = await db.video.findUnique({
|
||||
where: { id: safeVideoId },
|
||||
include: { project: true },
|
||||
});
|
||||
if (!video) {
|
||||
return apiErrors.notFound('Video');
|
||||
}
|
||||
|
||||
const access = await checkProjectAccess(video.project, session?.user?.id);
|
||||
const shareSession = getShareSessionFromRequest(request, safeVideoId);
|
||||
const shareAccess = shareSession
|
||||
? await validateShareLinkAccess({
|
||||
token: shareSession.token,
|
||||
projectId: video.projectId,
|
||||
videoId: safeVideoId,
|
||||
requiredPermission: 'COMMENT',
|
||||
passwordVerified: shareSession.passwordVerified,
|
||||
})
|
||||
: { hasAccess: false, canComment: false, canDownload: false, allowGuests: false, requiresPassword: false };
|
||||
const canCommentWithMembership = !!session?.user?.id && access.hasAccess;
|
||||
const canCommentWithShareLink = shareAccess.canComment && (session?.user?.id ? true : shareAccess.allowGuests);
|
||||
if (!canCommentWithMembership && !canCommentWithShareLink) {
|
||||
return apiErrors.forbidden('Access denied');
|
||||
}
|
||||
|
||||
if (!session?.user?.id) {
|
||||
if (typeof uploadToken !== 'string' || !uploadToken.trim()) {
|
||||
return apiErrors.badRequest('uploadToken is required for guest uploads');
|
||||
}
|
||||
|
||||
const expectedContext = deriveGuestUploadContext(request, shareSession?.token ?? null);
|
||||
if (!expectedContext) {
|
||||
return apiErrors.forbidden('Missing trusted client IP header');
|
||||
}
|
||||
|
||||
const isValidUploadToken = verifyGuestUploadToken(uploadToken.trim(), {
|
||||
projectId: video.projectId,
|
||||
videoId: safeVideoId,
|
||||
intent: 'image',
|
||||
context: expectedContext,
|
||||
});
|
||||
if (!isValidUploadToken) {
|
||||
return apiErrors.forbidden('Invalid upload token');
|
||||
}
|
||||
|
||||
const quotaError = await enforceGuestUploadQuota(request, safeVideoId, 'image', shareSession?.token ?? null);
|
||||
if (quotaError) return quotaError;
|
||||
}
|
||||
|
||||
// Double-check file size (defense in depth - Content-Length can be spoofed)
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
@@ -54,7 +113,7 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
// Generate unique filename
|
||||
const ext = contentType.split('/')[1] === 'svg+xml' ? 'svg' : contentType.split('/')[1] || 'jpeg';
|
||||
const ext = contentType.split('/')[1] || 'jpeg';
|
||||
const filename = `${randomUUID()}.${ext}`;
|
||||
const key = `images/${filename}`;
|
||||
|
||||
|
||||
@@ -6,10 +6,35 @@ import { notifyProjectOwner } from '@/lib/notifications';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
import { validateShareLinkAccess } from '@/lib/share-links';
|
||||
import { getShareSessionFromRequest } from '@/lib/share-session';
|
||||
import { HeadObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
|
||||
import { ensureGuestIdentityFromRequest, getGuestIdentityFromRequest, setGuestIdentityCookie } from '@/lib/guest-identity';
|
||||
|
||||
type RouteParams = { params: Promise<{ versionId: string }> };
|
||||
const SAFE_IMAGE_PATH = /^\/api\/upload\/image\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z0-9]+$/i;
|
||||
const SAFE_AUDIO_PATH = /^\/api\/upload\/audio\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z0-9]+$/i;
|
||||
const UNATTACHED_UPLOAD_TTL_MS = 15 * 60 * 1000;
|
||||
|
||||
async function isFreshAttachment(url: string, kind: 'audio' | 'image'): Promise<boolean> {
|
||||
const prefix = kind === 'audio' ? '/api/upload/audio/' : '/api/upload/image/';
|
||||
if (!url.startsWith(prefix)) return false;
|
||||
|
||||
const filename = url.slice(prefix.length);
|
||||
const key = kind === 'audio' ? `voice/${filename}` : `images/${filename}`;
|
||||
|
||||
try {
|
||||
const head = await r2Client.send(
|
||||
new HeadObjectCommand({
|
||||
Bucket: R2_BUCKET_NAME,
|
||||
Key: key,
|
||||
})
|
||||
);
|
||||
if (!head.LastModified) return false;
|
||||
return Date.now() - head.LastModified.getTime() <= UNATTACHED_UPLOAD_TTL_MS;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/versions/[versionId]/comments
|
||||
export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
@@ -200,7 +225,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
requiredPermission: 'COMMENT',
|
||||
passwordVerified: shareSession.passwordVerified,
|
||||
})
|
||||
: { hasAccess: false, canComment: false, allowGuests: false, requiresPassword: false };
|
||||
: { hasAccess: false, canComment: false, canDownload: false, allowGuests: false, requiresPassword: false };
|
||||
|
||||
// Check if user can comment
|
||||
const canComment = isOwner || isMember || isPublic || isWorkspaceMember || shareAccess.canComment;
|
||||
@@ -247,10 +272,18 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
if (voiceUrl && !SAFE_AUDIO_PATH.test(voiceUrl)) {
|
||||
return apiErrors.badRequest('Voice URL must reference an uploaded audio file');
|
||||
}
|
||||
if (voiceUrl && !(await isFreshAttachment(voiceUrl, 'audio'))) {
|
||||
return apiErrors.badRequest('Voice upload expired. Please upload again.');
|
||||
}
|
||||
|
||||
if (imageUrl && !SAFE_IMAGE_PATH.test(imageUrl)) {
|
||||
return apiErrors.badRequest('Image URL must reference an uploaded image file');
|
||||
}
|
||||
if (imageUrl && !(await isFreshAttachment(imageUrl, 'image'))) {
|
||||
return apiErrors.badRequest('Image upload expired. Please upload again.');
|
||||
}
|
||||
|
||||
const guestIdentity = isGuest ? ensureGuestIdentityFromRequest(request) : null;
|
||||
|
||||
const comment = await db.comment.create({
|
||||
data: {
|
||||
@@ -265,6 +298,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
authorId: session?.user?.id || null,
|
||||
guestName: isGuest ? guestName : null,
|
||||
guestEmail: isGuest ? guestEmail : null,
|
||||
guestIdentityId: isGuest ? guestIdentity?.identityId ?? null : null,
|
||||
tagId: tagId || null,
|
||||
versionId,
|
||||
},
|
||||
@@ -319,7 +353,25 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
}
|
||||
}
|
||||
|
||||
const response = successResponse(comment, 201);
|
||||
const viewerUserId = session?.user?.id ?? null;
|
||||
const viewerGuestIdentityId = viewerUserId
|
||||
? null
|
||||
: guestIdentity?.identityId ?? getGuestIdentityFromRequest(request);
|
||||
const canEditComment = viewerUserId
|
||||
? comment.authorId === viewerUserId
|
||||
: !!viewerGuestIdentityId
|
||||
&& !!comment.guestIdentityId
|
||||
&& comment.guestIdentityId === viewerGuestIdentityId;
|
||||
const { guestIdentityId: _guestIdentityId, ...commentData } = comment;
|
||||
|
||||
const response = successResponse({
|
||||
...commentData,
|
||||
canEdit: canEditComment,
|
||||
canDelete: canEditComment || viewerUserId === project.ownerId,
|
||||
}, 201);
|
||||
if (isGuest && guestIdentity?.shouldSetCookie) {
|
||||
setGuestIdentityCookie(response, guestIdentity.identityId);
|
||||
}
|
||||
return withCacheControl(response, 'private, no-store');
|
||||
} catch (error) {
|
||||
console.error('Error creating comment:', error);
|
||||
|
||||
@@ -2,6 +2,9 @@ import { db } from '@/lib/db';
|
||||
import { auth, checkProjectAccess } from '@/lib/auth';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
import { rateLimit } from '@/lib/rate-limit';
|
||||
import { validateShareLinkAccess } from '@/lib/share-links';
|
||||
import { getShareSessionFromRequest } from '@/lib/share-session';
|
||||
import { NextRequest } from 'next/server';
|
||||
import { DownloadEgressSource } from '@prisma/client';
|
||||
|
||||
type RouteParams = { params: Promise<{ versionId: string }> };
|
||||
@@ -319,7 +322,7 @@ function parseEstimatedBytes(contentLengthHeader: string | null): bigint {
|
||||
}
|
||||
|
||||
// GET /api/versions/[versionId]/download
|
||||
export async function GET(request: Request, { params }: RouteParams) {
|
||||
export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const isPrepareOnly = searchParams.get('prepare') === '1';
|
||||
@@ -362,7 +365,18 @@ export async function GET(request: Request, { params }: RouteParams) {
|
||||
}
|
||||
|
||||
const access = await checkProjectAccess(version.video.project, session?.user?.id);
|
||||
if (!access.hasAccess) {
|
||||
const shareSession = getShareSessionFromRequest(request, version.video.id);
|
||||
const shareAccess = shareSession
|
||||
? await validateShareLinkAccess({
|
||||
token: shareSession.token,
|
||||
projectId: version.video.projectId,
|
||||
videoId: version.video.id,
|
||||
requiredPermission: 'VIEW',
|
||||
passwordVerified: shareSession.passwordVerified,
|
||||
})
|
||||
: { hasAccess: false, canComment: false, canDownload: false, allowGuests: false, requiresPassword: false };
|
||||
const canDownloadViaShareLink = shareAccess.hasAccess && shareAccess.canDownload;
|
||||
if (!access.hasAccess && !canDownloadViaShareLink) {
|
||||
return apiErrors.forbidden('Access denied');
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response
|
||||
import { rateLimit } from '@/lib/rate-limit';
|
||||
import { validateShareLinkAccess } from '@/lib/share-links';
|
||||
import { getShareSessionFromRequest } from '@/lib/share-session';
|
||||
import { getGuestIdentityFromRequest } from '@/lib/guest-identity';
|
||||
|
||||
type RouteParams = { params: Promise<{ videoId: string }> };
|
||||
|
||||
@@ -48,6 +49,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
annotationData: true,
|
||||
parentId: true,
|
||||
authorId: true,
|
||||
guestIdentityId: true,
|
||||
tagId: true,
|
||||
versionId: true,
|
||||
guestName: true,
|
||||
@@ -70,6 +72,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
annotationData: true,
|
||||
parentId: true,
|
||||
authorId: true,
|
||||
guestIdentityId: true,
|
||||
tagId: true,
|
||||
versionId: true,
|
||||
guestName: true,
|
||||
@@ -109,7 +112,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
requiredPermission: 'VIEW',
|
||||
passwordVerified: shareSession.passwordVerified,
|
||||
})
|
||||
: { hasAccess: false, canComment: false, allowGuests: false, requiresPassword: false };
|
||||
: { hasAccess: false, canComment: false, canDownload: false, allowGuests: false, requiresPassword: false };
|
||||
|
||||
if (!access.hasAccess && !shareAccess.hasAccess) {
|
||||
return apiErrors.forbidden('Access denied');
|
||||
@@ -117,10 +120,65 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
|
||||
// Include auth context so the client knows if the viewer is a guest
|
||||
const { project, ...videoData } = video;
|
||||
const viewerUserId = session?.user?.id ?? null;
|
||||
const viewerGuestIdentityId = viewerUserId ? null : getGuestIdentityFromRequest(request);
|
||||
const isProjectOwner = viewerUserId === project.ownerId;
|
||||
|
||||
const versions = videoData.versions.map((version) => {
|
||||
if (!('comments' in version)) {
|
||||
return version;
|
||||
}
|
||||
|
||||
return {
|
||||
...version,
|
||||
comments: version.comments.map((comment) => {
|
||||
const canEditComment = viewerUserId
|
||||
? comment.authorId === viewerUserId
|
||||
: !!viewerGuestIdentityId
|
||||
&& !!comment.guestIdentityId
|
||||
&& comment.guestIdentityId === viewerGuestIdentityId;
|
||||
const canDeleteComment = canEditComment || isProjectOwner;
|
||||
const {
|
||||
authorId: _commentAuthorId,
|
||||
guestIdentityId: _commentGuestIdentityId,
|
||||
replies,
|
||||
...commentData
|
||||
} = comment;
|
||||
|
||||
return {
|
||||
...commentData,
|
||||
canEdit: canEditComment,
|
||||
canDelete: canDeleteComment,
|
||||
replies: replies.map((reply) => {
|
||||
const canEditReply = viewerUserId
|
||||
? reply.authorId === viewerUserId
|
||||
: !!viewerGuestIdentityId
|
||||
&& !!reply.guestIdentityId
|
||||
&& reply.guestIdentityId === viewerGuestIdentityId;
|
||||
const canDeleteReply = canEditReply || isProjectOwner;
|
||||
const {
|
||||
authorId: _replyAuthorId,
|
||||
guestIdentityId: _replyGuestIdentityId,
|
||||
...replyData
|
||||
} = reply;
|
||||
return {
|
||||
...replyData,
|
||||
canEdit: canEditReply,
|
||||
canDelete: canDeleteReply,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const canCommentWithMembership = access.hasAccess;
|
||||
const canCommentWithShareLink = shareAccess.canComment && (session?.user?.id ? true : shareAccess.allowGuests);
|
||||
const canDownloadWithMembership = access.hasAccess;
|
||||
const canDownloadWithShareLink = shareAccess.hasAccess && shareAccess.canDownload;
|
||||
const response = successResponse({
|
||||
...videoData,
|
||||
versions,
|
||||
projectId: video.projectId,
|
||||
project: {
|
||||
name: project.name,
|
||||
@@ -131,6 +189,8 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
currentUserId: session?.user?.id || null,
|
||||
currentUserName: session?.user?.name || null,
|
||||
canComment: canCommentWithMembership || canCommentWithShareLink,
|
||||
canDownload: canDownloadWithMembership || canDownloadWithShareLink,
|
||||
canManageTags: access.canEdit,
|
||||
});
|
||||
|
||||
return withCacheControl(response, 'private, no-cache');
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { auth, checkProjectAccess } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
import { rateLimit } from '@/lib/rate-limit';
|
||||
import { validateShareLinkAccess } from '@/lib/share-links';
|
||||
import { getShareSessionFromRequest } from '@/lib/share-session';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
import {
|
||||
createGuestUploadToken,
|
||||
deriveGuestUploadContext,
|
||||
guestUploadTokenTtlSeconds,
|
||||
type GuestUploadIntent,
|
||||
} from '@/lib/guest-upload-token';
|
||||
|
||||
type RouteParams = { params: Promise<{ videoId: string }> };
|
||||
|
||||
function validateSameOriginRequest(request: NextRequest): Response | null {
|
||||
const origin = request.headers.get('origin');
|
||||
if (!origin) {
|
||||
return apiErrors.forbidden('Missing Origin header');
|
||||
}
|
||||
|
||||
if (origin !== request.nextUrl.origin) {
|
||||
return apiErrors.forbidden('Cross-origin requests are not allowed');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
const originError = validateSameOriginRequest(request);
|
||||
if (originError) return originError;
|
||||
|
||||
const limited = await rateLimit(request, 'guest-upload-token', {
|
||||
windowMs: 60 * 1000,
|
||||
maxRequests: 20,
|
||||
});
|
||||
if (limited) return limited;
|
||||
|
||||
const session = await auth();
|
||||
if (session?.user?.id) {
|
||||
return apiErrors.badRequest('Upload token is only required for guest uploads');
|
||||
}
|
||||
|
||||
const { videoId } = await params;
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const intent = body?.intent;
|
||||
if (intent !== 'audio' && intent !== 'image') {
|
||||
return apiErrors.badRequest('intent must be "audio" or "image"');
|
||||
}
|
||||
|
||||
const video = await db.video.findUnique({
|
||||
where: { id: videoId },
|
||||
include: { project: true },
|
||||
});
|
||||
if (!video) {
|
||||
return apiErrors.notFound('Video');
|
||||
}
|
||||
|
||||
const access = await checkProjectAccess(video.project, session?.user?.id);
|
||||
const shareSession = getShareSessionFromRequest(request, video.id);
|
||||
const shareAccess = shareSession
|
||||
? await validateShareLinkAccess({
|
||||
token: shareSession.token,
|
||||
projectId: video.projectId,
|
||||
videoId: video.id,
|
||||
requiredPermission: 'COMMENT',
|
||||
passwordVerified: shareSession.passwordVerified,
|
||||
})
|
||||
: { hasAccess: false, canComment: false, canDownload: false, allowGuests: false, requiresPassword: false };
|
||||
|
||||
const canCommentWithMembership = !!session?.user?.id && access.hasAccess;
|
||||
const canCommentWithShareLink = shareAccess.canComment && shareAccess.allowGuests;
|
||||
if (!canCommentWithMembership && !canCommentWithShareLink) {
|
||||
return apiErrors.forbidden('Access denied');
|
||||
}
|
||||
|
||||
const context = deriveGuestUploadContext(request, shareSession?.token ?? null);
|
||||
if (!context) {
|
||||
return apiErrors.forbidden('Missing trusted client IP header');
|
||||
}
|
||||
|
||||
const token = createGuestUploadToken({
|
||||
projectId: video.projectId,
|
||||
videoId: video.id,
|
||||
intent: intent as GuestUploadIntent,
|
||||
context,
|
||||
});
|
||||
|
||||
const response = successResponse({
|
||||
token,
|
||||
intent,
|
||||
expiresInSeconds: guestUploadTokenTtlSeconds,
|
||||
});
|
||||
return withCacheControl(response, 'private, no-store');
|
||||
} catch (error) {
|
||||
console.error('Error issuing guest upload token:', error);
|
||||
return apiErrors.internalError('Failed to issue upload token');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user