Add guest upload tokens and share-session aware permissions

This commit is contained in:
Yusuf İpek
2026-02-23 18:17:22 +03:00
parent 9058317247
commit fe7235052e
21 changed files with 1117 additions and 150 deletions
+96 -24
View File
@@ -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');
}
+44 -31
View File
@@ -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
View File
@@ -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);
+22 -2
View File
@@ -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',
},
});
+66 -6
View File
@@ -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) {
+22 -3
View File
@@ -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');
}
}
+67 -8
View File
@@ -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}`;
+54 -2
View File
@@ -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);
+16 -2
View File
@@ -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');
}
+61 -1
View File
@@ -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');
}
}